diff --git a/numerapi/base_api.py b/numerapi/base_api.py index 97071f9..f2e77d7 100644 --- a/numerapi/base_api.py +++ b/numerapi/base_api.py @@ -349,6 +349,58 @@ def models_of_account(self, account) -> Dict[str, str]: for item in sorted(data, key=lambda x: x["displayName"]) } + def public_user_profile(self, username: str) -> Dict: + """Fetch the public profile of a user / model. + + The model is resolved within this API's tournament + (``self.tournament_id``), so the returned ``id`` is the model UUID + for *this* tournament. That id is what e.g. :meth:`submission_scores` + expects as ``model_id``. This works identically for Numerai Classic, + Signals and Crypto - the only difference is the tournament the + concrete API class is configured for. + + Args: + username (str): the model name (called "username" on the + Crypto / Signals leaderboards) + + Returns: + dict: user profile including the following fields: + + * username (`str`) + * startDate (`datetime`) + * id (`str`) - the model UUID, usable as `model_id` + * bio (`str`) + * nmrStaked (`decimal.Decimal`) + + Example: + >>> api = CryptoAPI() + >>> api.public_user_profile("quixotic15") + {'bio': None, + 'id': '0c58da1a-8df4-4e98-a99a-71a12fbbe36b', + 'startDate': datetime.datetime(2024, 11, 28, ...), + 'nmrStaked': None, + 'username': 'quixotic15'} + """ + query = """ + query($model_name: String! + $tournament: Int) { + v3UserProfile(model_name: $model_name + tournament: $tournament) { + id + startDate + username + bio + nmrStaked + } + } + """ + arguments = {"model_name": username, "tournament": self.tournament_id} + data = self.raw_query(query, arguments)["data"]["v3UserProfile"] + # convert strings to python objects + utils.replace(data, "startDate", utils.parse_datetime_string) + utils.replace(data, "nmrStaked", utils.parse_float_string) + return data + def get_models(self, tournament: int | None = None) -> Dict: """Get mapping of account model names to model ids for convenience diff --git a/numerapi/numerapi.py b/numerapi/numerapi.py index 8c96c22..0bae34b 100644 --- a/numerapi/numerapi.py +++ b/numerapi/numerapi.py @@ -273,48 +273,6 @@ def stake_get(self, modelname: str) -> float: data = self.raw_query(query, arguments)['data']['v3UserProfile'] return data['stakeValue'] - def public_user_profile(self, username: str) -> Dict: - """Fetch the public profile of a user. - - Args: - username (str) - - Returns: - dict: user profile including the following fields: - * username (`str`) - * startDate (`datetime`) - * id (`string`) - * bio (`str`) - * nmrStaked (`float`) - - Example: - >>> api = NumerAPI() - >>> api.public_user_profile("integration_test") - {'bio': 'The official example model. Submits example predictions.', - 'id': '59de8728-38e5-45bd-a3d5-9d4ad649dd3f', - 'startDate': datetime.datetime( - 2018, 6, 6, 17, 33, 21, tzinfo=tzutc()), - 'nmrStaked': '57.582371875005243780', - 'username': 'integration_test'} - - """ - query = """ - query($model_name: String!) { - v3UserProfile(model_name: $model_name) { - id - startDate - username - bio - nmrStaked - } - } - """ - arguments = {'model_name': username} - data = self.raw_query(query, arguments)['data']['v3UserProfile'] - # convert strings to python objects - utils.replace(data, "startDate", utils.parse_datetime_string) - return data - def daily_model_performances(self, username: str) -> List[Dict]: """Fetch daily performance of a user. diff --git a/numerapi/signalsapi.py b/numerapi/signalsapi.py index c698422..8e1bf9f 100644 --- a/numerapi/signalsapi.py +++ b/numerapi/signalsapi.py @@ -151,49 +151,6 @@ def upload_predictions(self, file_path: str = "predictions.csv", create = self.raw_query(create_query, arguments, authorization=True) return create['data']['createSignalsSubmission']['id'] - def public_user_profile(self, username: str) -> Dict: - """Fetch the public Numerai Signals profile of a user. - - Args: - username (str) - - Returns: - dict: user profile including the following fields: - - * username (`str`) - * startDate (`datetime`) - * id (`string`) - * bio (`str`) - * nmrStaked (`decimal.Decimal`) - - Example: - >>> api = SignalsAPI() - >>> api.public_user_profile("floury_kerril_moodle") - {'bio': None, - 'id': '635db2a4-bdc6-4e5d-b515-f5120392c8c9', - 'startDate': datetime.datetime(2019, 3, 26, 0, 43), - 'username': 'floury_kerril_moodle', - 'nmrStaked': Decimal('14.630994874320760131')} - - """ - query = """ - query($username: String!) { - v2SignalsProfile(modelName: $username) { - id - startDate - username - bio - nmrStaked - } - } - """ - arguments = {'username': username} - data = self.raw_query(query, arguments)['data']['v2SignalsProfile'] - # convert strings to python objects - utils.replace(data, "startDate", utils.parse_datetime_string) - utils.replace(data, "nmrStaked", utils.parse_float_string) - return data - def daily_model_performances(self, username: str) -> List[Dict]: """Fetch daily Numerai Signals performance of a model. diff --git a/tests/test_cryptoapi.py b/tests/test_cryptoapi.py index 2f48abd..9686b21 100644 --- a/tests/test_cryptoapi.py +++ b/tests/test_cryptoapi.py @@ -1,3 +1,4 @@ +import datetime import decimal from unittest.mock import patch @@ -41,3 +42,43 @@ def test_get_leaderboard(mocked, api): assert "cryptosignalsLeaderboard" in args[0] assert args[1] == {"limit": 1, "offset": 0} assert kwargs == {} + + +@patch("numerapi.cryptoapi.CryptoAPI.raw_query") +def test_public_user_profile(mocked, api): + mocked.return_value = { + "data": { + "v3UserProfile": { + "id": "08d44800-be35-41f5-9896-63a1be9c51ef", + "username": "crypto_user", + "startDate": "2024-11-28T13:09:20Z", + "bio": None, + "nmrStaked": "13.0", + } + } + } + + profile = api.public_user_profile("crypto_user") + + assert profile["id"] == "08d44800-be35-41f5-9896-63a1be9c51ef" + assert profile["username"] == "crypto_user" + # string fields are converted to python objects + assert isinstance(profile["startDate"], datetime.datetime) + assert profile["nmrStaked"] == decimal.Decimal("13.0") + mocked.assert_called_once() + args, kwargs = mocked.call_args + # crypto must be resolved by name *within its own tournament* (12), + # otherwise a model id from another tournament could be returned + assert "v3UserProfile" in args[0] + assert args[1]["tournament"] == api.tournament_id == 12 + + +@patch("numerapi.cryptoapi.CryptoAPI.raw_query") +def test_public_user_profile_to_model_id(mocked, api): + # the model id is what `submission_scores` needs + mocked.return_value = { + "data": {"v3UserProfile": { + "id": "the-model-id", "username": "crypto_user", + "startDate": None, "bio": None, "nmrStaked": None}}} + model_id = api.public_user_profile("crypto_user")["id"] + assert model_id == "the-model-id" diff --git a/tests/test_signalsapi.py b/tests/test_signalsapi.py index 0ccaf0f..9f58e4a 100644 --- a/tests/test_signalsapi.py +++ b/tests/test_signalsapi.py @@ -1,3 +1,5 @@ +from unittest.mock import patch + import pytest import responses @@ -7,6 +9,23 @@ from numerapi import base_api +@patch("numerapi.base_api.Api.raw_query") +def test_public_user_profile(mocked, api): + mocked.return_value = { + "data": {"v3UserProfile": { + "id": "49962e16-6bc9-4a78-a751-09c20c99bcb3", + "username": "floury_kerril_moodle", + "startDate": "2020-05-12T01:23:00Z", + "bio": None, "nmrStaked": None}}} + + profile = api.public_user_profile("floury_kerril_moodle") + + assert profile["id"] == "49962e16-6bc9-4a78-a751-09c20c99bcb3" + args, _ = mocked.call_args + # signals models must be resolved within the signals tournament (11) + assert args[1]["tournament"] == api.tournament_id == 11 + + @pytest.fixture(scope='function', name="api") def api_fixture(): api = numerapi.SignalsAPI(verbosity='DEBUG')