diff --git a/CHANGELOG.md b/CHANGELOG.md index 38a193e..e643e0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,19 @@ # Changelog Notable changes to this project. +## [2.24.0] - 2026-08-03 +- add exact `roundScoreConfigs` identities, scoring windows, and payout settings + to `list_rounds` for Classic, Signals, and Crypto +- stop querying deprecated GraphQL round multiplier fields; keep the six + established Corr/MMC return keys as exact-name compatibility projections + until their scheduled removal in numerapi 3.0.0 +- document migration from legacy round multiplier roles and isolate the + deprecated `round_model_performances_v2` behavior +- expose the tournament-aware `stake_get` API consistently for Classic, + Signals, and Crypto +- allow `download_dataset` to use pandas Parquet filters and download only the + matching portion of a dataset + ## [2.23.3] - 2026-06-30 - fix `models_of_account` referencing incorrect type `Str!` instead of `String!` diff --git a/README.md b/README.md index b8b2c66..75069b3 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,12 @@ and `NUMERAI_SECRET_KEY`). napi = numerapi.NumerAPI(verbosity="info") # download current dataset => also check `https://numer.ai/data` napi.download_dataset("v4/train.parquet", "train.parquet") + # use pandas filter syntax to download only selected parquet rows + napi.download_dataset( + "v4/train.parquet", + "train_eras_1_and_2.parquet", + filters=[("era", "in", ["0001", "0002"])], + ) # get current leaderboard leaderboard = napi.get_leaderboard() # check if a new round has started diff --git a/docs/index.rst b/docs/index.rst index acd8cba..f4ad7ff 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -7,6 +7,7 @@ Contents :maxdepth: 2 changelog + round-score-configs license Indices and tables diff --git a/docs/round-score-configs.md b/docs/round-score-configs.md new file mode 100644 index 0000000..99db25d --- /dev/null +++ b/docs/round-score-configs.md @@ -0,0 +1,92 @@ +# Round score and payout configuration + +Starting in numerapi 2.24.0, `NumerAPI.list_rounds()`, +`SignalsAPI.list_rounds()`, and `CryptoAPI.list_rounds()` return the public +`roundScoreConfigs` list. Each item is an exact score definition and per-round +snapshot from the Tournament API. New code should select entries by `name`, +`version`, or `scoreConfigId`; it should not infer score identity from a legacy +payout role. + +Each item includes: + +- identity: `id`, `scoreConfigId`, `name`, `version`, and `displayName`; +- applicability: `roundNumberStart`, `roundNumberEnd`, `universe`, + and `isCanonScore`; +- scoring: `totalScoreDays`, `returnsLagDays`, `dataDelayDays`, + `scoringStart`, and `scoringEnd`; +- payout settings: `isPayout`, `minMultiplier`, `maxMultiplier`, + `defaultMultiplier`, `clipThreshold`, `stakeThreshold`, and `payoutFactor`. + +`scoringStart` and `scoringEnd` are returned as `datetime.datetime` objects, +consistent with other date fields in numerapi. GraphQL float and integer fields +retain their normal Python JSON types. + +## Migrating from legacy multiplier keys + +Before 2.24.0, `list_rounds()` requested server compatibility fields. For a +Signals round, a response could look like this even though the payout scores +were Alpha and MPC: + +```python +{ + "defaultCorrMultiplier": 0.3, + "defaultMmcMultiplier": 0.8, +} +``` + +In 2.24.0 the exact identities are available without knowing score names in +advance: + +```python +{ + "roundScoreConfigs": [ + { + "scoreConfigId": "...", + "name": "alpha", + "version": "2", + "displayName": "alpha", + "isPayout": True, + "defaultMultiplier": 0.3, + # Other identity, scoring, timing, and payout fields omitted. + }, + { + "scoreConfigId": "...", + "name": "meta_portfolio_contribution", + "version": "2", + "displayName": "mpc", + "isPayout": True, + "defaultMultiplier": 0.8, + }, + ], + "defaultCorrMultiplier": None, + "defaultMmcMultiplier": None, +} +``` + +The six established Corr/MMC keys (`min`, `max`, and `default` for each) stay +in the returned round dictionary throughout numerapi 2.x. They are now +identity-safe projections: Corr keys select only a payout config whose `name` +is exactly `correlation`, MMC keys select only a payout config whose `name` is +exactly `meta_model_contribution`, and the keys are `None` when there is no +exact match. Alpha and FNC are never projected as Corr; MPC is never projected +as MMC. If multiple exact payout configs exist, the projection uses the config +with the newest `roundNumberStart`, then compares the numeric `version` values +as integers and uses `id` for a numeric-version tie. If multiple configs at the +newest start contain a non-numeric future version, the compatibility keys are +`None` rather than guessing an order. The complete list remains available +unchanged in either case. + +These six compatibility keys are scheduled for removal in numerapi 3.0.0. +`list_rounds()` never exposed the three legacy TC multiplier fields, so this +migration does not introduce them. Code should migrate now by filtering +`roundScoreConfigs`, normally starting with `isPayout`. + +## Deprecated performance endpoint + +`round_model_performances_v2()` remains an isolated deprecated compatibility +method. Its `corrMultiplier` and `mmcMultiplier` fields come from the deprecated +`v2RoundModelPerformances` GraphQL endpoint and must not be used to infer score +identity. Use `submission_scores()` for identity-preserving score results and +join them to `list_rounds()` by round when payout configuration is needed. +Neither performance method nor `list_rounds()` has a dedicated CLI command, so +there is no CLI return shape to migrate. diff --git a/numerapi/base_api.py b/numerapi/base_api.py index 963bd29..8d332c4 100644 --- a/numerapi/base_api.py +++ b/numerapi/base_api.py @@ -1,12 +1,14 @@ -"""Parts of the API that is shared between Signals and Classic""" +"""API functionality shared by Classic, Signals, and Crypto.""" import datetime +import decimal import logging import os import warnings from io import BytesIO from typing import Dict, List, Tuple, Union +import fsspec import pandas as pd import pytz import requests @@ -176,7 +178,11 @@ def list_datasets(self, round_num: int | None = None) -> List[str]: return self.raw_query(query, args)["data"]["listDatasets"] def download_dataset( - self, filename: str, dest_path: str | None = None, round_num: int | None = None + self, + filename: str, + dest_path: str | None = None, + round_num: int | None = None, + filters: List[Tuple] | List[List[Tuple]] | None = None, ) -> str: """Download specified file for the given round. @@ -186,14 +192,26 @@ def download_dataset( stored, defaults to the same name as the source file round_num (int, optional): tournament round you are interested in. defaults to the current round + filters (list, optional): pandas ``read_parquet`` filters. When + provided, only matching Parquet data is read from the remote + dataset and written to ``dest_path``. See + :func:`pandas.read_parquet` for the supported filter syntax. Returns: str: path of the downloaded file Example: >>> filenames = NumerAPI().list_datasets() - >>> NumerAPI().download_dataset(filenames[0]}") + >>> NumerAPI().download_dataset(filenames[0]) + >>> NumerAPI().download_dataset( + ... "v4/train.parquet", + ... "train_eras_1_and_2.parquet", + ... filters=[("era", "in", ["0001", "0002"])], + ... ) """ + if filters is not None and not filename.lower().endswith(".parquet"): + raise ValueError("filters are only supported for Parquet datasets") + if dest_path is None: dest_path = filename @@ -221,7 +239,21 @@ def download_dataset( } dataset_url = self.raw_query(query, args)["data"]["dataset"] - utils.download_file(dataset_url, dest_path, self.show_progress_bars) + if filters is None: + utils.download_file( + dataset_url, dest_path, self.show_progress_bars + ) + else: + temp_path = dest_path + ".temp" + try: + with fsspec.open(dataset_url, "rb") as dataset_file: + dataset = pd.read_parquet(dataset_file, filters=filters) + dataset.to_parquet(temp_path) + os.replace(temp_path, dest_path) + except Exception: + if os.path.exists(temp_path): + os.remove(temp_path) + raise return dest_path def set_global_data_dir(self, directory: str): @@ -349,6 +381,85 @@ 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 stake_get(self, username: str) -> decimal.Decimal | None: + """Get the current stake for a model in this API's tournament. + + Args: + username (str): model name + + Returns: + decimal.Decimal or None: current stake, including projected NMR + earnings from open rounds, or None if the model has no stake + + Example: + >>> SignalsAPI().stake_get("uuazed") + Decimal('14.63') + """ + query = """ + query($model_name: String! + $tournament: Int) { + v3UserProfile(model_name: $model_name + tournament: $tournament) { + stakeValue + } + } + """ + arguments = {"model_name": username, "tournament": self.tournament_id} + data = self.raw_query(query, arguments)["data"]["v3UserProfile"] + return utils.parse_float_string(data["stakeValue"]) + def get_models(self, tournament: int | None = None) -> Dict: """Get mapping of account model names to model ids for convenience @@ -637,7 +748,16 @@ def list_rounds( limit (int, optional): maximum number of rounds to return Returns: - list of dicts: round entries matching the provided filters + list of dicts: round entries matching the provided filters. Each + entry includes ``roundScoreConfigs``, whose items retain the exact + score identity and per-round payout settings returned by the API. + + The legacy ``minCorrMultiplier`` through + ``defaultMmcMultiplier`` keys remain until numerapi 3.0.0. They are + compatibility projections of payout configs whose names are + exactly ``correlation`` or ``meta_model_contribution``; they are + ``None`` when no such payout config exists. Use + ``roundScoreConfigs`` for all new integrations. """ query = """ query($tournament: Int @@ -663,13 +783,30 @@ def list_rounds( resolvedStaking payoutFactor stakeThreshold - minCorrMultiplier - maxCorrMultiplier - defaultCorrMultiplier - minMmcMultiplier - maxMmcMultiplier - defaultMmcMultiplier dataDatestamp + roundScoreConfigs { + id + scoreConfigId + roundNumberStart + roundNumberEnd + name + version + displayName + totalScoreDays + returnsLagDays + dataDelayDays + universe + isCanonScore + isPayout + scoringStart + scoringEnd + minMultiplier + maxMultiplier + defaultMultiplier + clipThreshold + stakeThreshold + payoutFactor + } } } """ @@ -691,8 +828,64 @@ def list_rounds( ]: utils.replace(round_info, field, utils.parse_datetime_string) utils.replace(round_info, "payoutFactor", utils.parse_float_string) + for config in round_info["roundScoreConfigs"]: + utils.replace( + config, "scoringStart", utils.parse_datetime_string + ) + utils.replace( + config, "scoringEnd", utils.parse_datetime_string + ) + self._add_legacy_round_multipliers(round_info) return rounds + @staticmethod + def _add_legacy_round_multipliers(round_info: dict) -> None: + """Add deprecated, identity-safe round multiplier projections.""" + legacy_scores = { + "Corr": "correlation", + "Mmc": "meta_model_contribution", + } + multiplier_fields = { + "min": "minMultiplier", + "max": "maxMultiplier", + "default": "defaultMultiplier", + } + + for legacy_name, score_name in legacy_scores.items(): + matches = [ + config + for config in round_info["roundScoreConfigs"] + if config["isPayout"] and config["name"] == score_name + ] + config = Api._select_legacy_round_config(matches) + for prefix, config_field in multiplier_fields.items(): + field = f"{prefix}{legacy_name}Multiplier" + round_info[field] = ( + None if config is None else config[config_field] + ) + + @staticmethod + def _select_legacy_round_config(configs: List[Dict]) -> Dict | None: + """Select the latest config, failing closed on ambiguous versions.""" + if not configs: + return None + + latest_start = max(item["roundNumberStart"] for item in configs) + candidates = [ + item for item in configs if item["roundNumberStart"] == latest_start + ] + if len(candidates) == 1: + return candidates[0] + + try: + return max( + candidates, + key=lambda item: (int(item["version"]), item["id"]), + ) + except (TypeError, ValueError): + # A future non-numeric version contract cannot be ordered safely. + return None + def set_bio(self, model_id: str, bio: str) -> bool: """Set bio field for a model id. diff --git a/numerapi/numerapi.py b/numerapi/numerapi.py index 8c96c22..45bd5d7 100644 --- a/numerapi/numerapi.py +++ b/numerapi/numerapi.py @@ -247,74 +247,6 @@ def stake_set(self, nmr, model_id: str) -> Dict: self.logger.info("Stake already at desired value. Nothing to do.") return None - def stake_get(self, modelname: str) -> float: - """Get your current stake amount. - - Args: - modelname (str) - - Returns: - float: current stake (including projected NMR earnings from open - rounds) - - Example: - >>> api = NumerAPI() - >>> api.stake_get("uuazed") - 1.1 - """ - query = """ - query($modelname: String!) { - v3UserProfile(modelName: $modelname) { - stakeValue - } - } - """ - arguments = {'modelname': modelname} - 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..81f6a11 100644 --- a/numerapi/signalsapi.py +++ b/numerapi/signalsapi.py @@ -2,7 +2,6 @@ from typing import List, Dict, Tuple, Union import os -import decimal from io import BytesIO import requests @@ -151,49 +150,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. @@ -283,19 +239,3 @@ def ticker_universe(self) -> List[str]: """ path = self.download_dataset("signals/v1.0/live.parquet") return pd.read_parquet(path).numerai_ticker.tolist() - - def stake_get(self, username) -> decimal.Decimal: - """get current stake for a given users - - Args: - username (str) - - Returns: - decimal.Decimal: current stake - - Example: - >>> SignalsAPI().stake_get("uuazed") - Decimal('14.63') - """ - data = self.public_user_profile(username) - return data['totalStake'] diff --git a/requirements.txt b/requirements.txt index cc2afad..156d614 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,4 +3,5 @@ python-dateutil pytz tqdm>=4.29.1 click>=7.0 +fsspec[http] pandas>=1.1.0 diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..04ab7be --- /dev/null +++ b/ruff.toml @@ -0,0 +1,4 @@ +[lint] +# Keep the repository's historical lint baseline stable while the CI action +# follows unpinned Ruff releases. +select = ["E4", "E7", "E9", "F"] diff --git a/setup.py b/setup.py index f5fe41b..1d93053 100644 --- a/setup.py +++ b/setup.py @@ -5,8 +5,7 @@ def load(path): return open(path, "r").read() -numerapi_version = "2.23.3" - +numerapi_version = "2.24.0" classifiers = [ "Development Status :: 5 - Production/Stable", @@ -41,6 +40,7 @@ def load(path): "python-dateutil", "tqdm>=4.29.1", "click>=7.0", + "fsspec[http]", "pandas>=1.1.0", ], entry_points={"console_scripts": ["numerapi = numerapi.cli:cli"]}, diff --git a/tests/test_base_api.py b/tests/test_base_api.py index 84aa996..7e832f1 100644 --- a/tests/test_base_api.py +++ b/tests/test_base_api.py @@ -2,9 +2,12 @@ import decimal import json import os +from unittest.mock import MagicMock + import pytest import responses +import numerapi from numerapi import base_api @@ -14,12 +17,107 @@ def api_fixture(): return api +def _round_score_config( + name, + *, + config_id=None, + display_name=None, + version="1", + is_payout=True, + multiplier=0.5, + round_number_start=100, +): + config_id = config_id or f"{name}-{version}" + return { + "id": f"round-{config_id}", + "scoreConfigId": config_id, + "roundNumberStart": round_number_start, + "roundNumberEnd": None, + "name": name, + "version": version, + "displayName": display_name or name, + "totalScoreDays": 20, + "returnsLagDays": 2, + "dataDelayDays": 2, + "universe": "test-universe", + "isCanonScore": False, + "isPayout": is_payout, + "scoringStart": "2026-03-29", + "scoringEnd": "2026-04-18", + "minMultiplier": multiplier, + "maxMultiplier": multiplier, + "defaultMultiplier": multiplier, + "clipThreshold": 0.05, + "stakeThreshold": 100.0, + "payoutFactor": 1.0, + } + + def test_NumerAPI(): # invalid log level should raise with pytest.raises(AttributeError): base_api.Api(verbosity="FOO") +@responses.activate +def test_download_dataset_uses_regular_download_without_filters( + api, tmp_path, monkeypatch +): + dataset_url = "https://example.com/train.parquet" + responses.add( + responses.POST, + base_api.API_TOURNAMENT_URL, + json={"data": {"dataset": dataset_url}}, + ) + download_file = MagicMock() + monkeypatch.setattr(base_api.utils, "download_file", download_file) + dest_path = str(tmp_path / "train.parquet") + + result = api.download_dataset("v4/train.parquet", dest_path) + + assert result == dest_path + download_file.assert_called_once_with(dataset_url, dest_path, True) + + +@responses.activate +def test_download_dataset_reads_only_filtered_parquet_data( + api, tmp_path, monkeypatch +): + dataset_url = "https://example.com/train.parquet" + responses.add( + responses.POST, + base_api.API_TOURNAMENT_URL, + json={"data": {"dataset": dataset_url}}, + ) + filters = [("era", "in", ["0001", "0002"])] + remote_file = object() + remote_context = MagicMock() + remote_context.__enter__.return_value = remote_file + fsspec_open = MagicMock(return_value=remote_context) + monkeypatch.setattr(base_api.fsspec, "open", fsspec_open) + filtered_dataset = MagicMock() + read_parquet = MagicMock(return_value=filtered_dataset) + monkeypatch.setattr(base_api.pd, "read_parquet", read_parquet) + replace = MagicMock() + monkeypatch.setattr(base_api.os, "replace", replace) + dest_path = str(tmp_path / "train_filtered.parquet") + + result = api.download_dataset( + "v4/train.parquet", dest_path, filters=filters + ) + + assert result == dest_path + fsspec_open.assert_called_once_with(dataset_url, "rb") + read_parquet.assert_called_once_with(remote_file, filters=filters) + filtered_dataset.to_parquet.assert_called_once_with(dest_path + ".temp") + replace.assert_called_once_with(dest_path + ".temp", dest_path) + + +def test_download_dataset_rejects_filters_for_non_parquet(api): + with pytest.raises(ValueError, match="only supported for Parquet"): + api.download_dataset("v4/features.json", filters=[("era", "=", "0001")]) + + def test__login(api): # passing only one of public_id and secret_key is not enough api._login(public_id="foo", secret_key=None) @@ -37,6 +135,41 @@ def test__login(api): assert api.token == ("id", "key") +@pytest.mark.parametrize( + ("api_class", "tournament"), + [ + (numerapi.NumerAPI, 8), + (numerapi.SignalsAPI, 11), + (numerapi.CryptoAPI, 12), + ], +) +@pytest.mark.parametrize( + ("stake_value", "expected"), + [("14.63", decimal.Decimal("14.63")), (None, None)], +) +@responses.activate +def test_stake_get_is_shared_across_tournaments( + api_class, tournament, stake_value, expected +): + api = api_class() + assert api_class.stake_get is base_api.Api.stake_get + responses.add( + responses.POST, + base_api.API_TOURNAMENT_URL, + json={"data": {"v3UserProfile": {"stakeValue": stake_value}}}, + ) + + assert api.stake_get("uuazed") == expected + + request_body = json.loads(responses.calls[0].request.body) + assert "stakeValue" in request_body["query"] + assert "totalStake" not in request_body["query"] + assert request_body["variables"] == { + "model_name": "uuazed", + "tournament": tournament, + } + + @responses.activate def test_raw_query(api): query = "query {latestNmrPrice {priceUsd}}" @@ -180,7 +313,21 @@ def test_submission_scores(api): @responses.activate def test_list_rounds(api): - api.tournament_id = 11 + api.tournament_id = 8 + configs = [ + _round_score_config( + "correlation", + config_id="classic-corr", + display_name="v2_corr20", + multiplier=0.75, + ), + _round_score_config( + "meta_model_contribution", + config_id="classic-mmc", + display_name="mmc", + multiplier=2.25, + ), + ] data = { "data": { "rounds": [ @@ -198,13 +345,8 @@ def test_list_rounds(api): "resolvedStaking": False, "payoutFactor": "0.8", "stakeThreshold": 0.1, - "minCorrMultiplier": 0.0, - "maxCorrMultiplier": 1.0, - "defaultCorrMultiplier": 0.5, - "minMmcMultiplier": 0.0, - "maxMmcMultiplier": 1.0, - "defaultMmcMultiplier": 0.5, "dataDatestamp": 20260320, + "roundScoreConfigs": configs, } ] } @@ -220,13 +362,204 @@ def test_list_rounds(api): assert isinstance(res[0]["scoreTime"], datetime.datetime) assert isinstance(res[0]["resolveTime"], datetime.datetime) assert isinstance(res[0]["payoutFactor"], decimal.Decimal) + assert isinstance( + res[0]["roundScoreConfigs"][0]["scoringStart"], datetime.datetime + ) + assert isinstance( + res[0]["roundScoreConfigs"][0]["scoringEnd"], datetime.datetime + ) + assert res[0]["roundScoreConfigs"][0]["scoreConfigId"] == "classic-corr" + assert res[0]["defaultCorrMultiplier"] == 0.75 + assert res[0]["defaultMmcMultiplier"] == 2.25 request_body = json.loads(responses.calls[0].request.body) - assert request_body["variables"]["tournament"] == 11 + assert request_body["variables"]["tournament"] == 8 assert request_body["variables"]["number"] == 123 assert request_body["variables"]["target"] == "main" assert request_body["variables"]["status"] == "OPEN" assert request_body["variables"]["limit"] == 5 + requested_fields = { + "id", + "scoreConfigId", + "roundNumberStart", + "roundNumberEnd", + "name", + "version", + "displayName", + "totalScoreDays", + "returnsLagDays", + "dataDelayDays", + "universe", + "isCanonScore", + "isPayout", + "scoringStart", + "scoringEnd", + "minMultiplier", + "maxMultiplier", + "defaultMultiplier", + "clipThreshold", + "stakeThreshold", + "payoutFactor", + } + assert "roundScoreConfigs" in request_body["query"] + assert all(field in request_body["query"] for field in requested_fields) + assert "minCorrMultiplier" not in request_body["query"] + assert "minMmcMultiplier" not in request_body["query"] + + +@pytest.mark.parametrize( + ("api_class", "tournament", "score_names", "legacy_multipliers"), + [ + ( + numerapi.NumerAPI, + 8, + ["correlation", "meta_model_contribution"], + (0.5, 0.5), + ), + ( + numerapi.SignalsAPI, + 11, + [ + "alpha", + "v4_feature_neutral_correlation", + "meta_portfolio_contribution", + ], + (None, None), + ), + ( + numerapi.CryptoAPI, + 12, + ["correlation", "meta_model_contribution"], + (0.5, 0.5), + ), + ], +) +@responses.activate +def test_list_rounds_preserves_tournament_score_identities( + api_class, tournament, score_names, legacy_multipliers +): + api = api_class() + configs = [_round_score_config(name) for name in score_names] + responses.add( + responses.POST, + base_api.API_TOURNAMENT_URL, + json={"data": {"rounds": [{"roundScoreConfigs": configs}]}}, + ) + + result = api.list_rounds() + + returned_round = result[0] + assert [ + config["name"] for config in returned_round["roundScoreConfigs"] + ] == score_names + assert returned_round["defaultCorrMultiplier"] == legacy_multipliers[0] + assert returned_round["defaultMmcMultiplier"] == legacy_multipliers[1] + request_body = json.loads(responses.calls[0].request.body) + assert request_body["variables"]["tournament"] == tournament + + +@responses.activate +def test_list_rounds_keeps_coexisting_and_unfamiliar_score_configs(api): + configs = [ + _round_score_config("alpha", multiplier=0.3), + _round_score_config( + "correlation", + config_id="corr-old", + version="1", + multiplier=0.2, + round_number_start=100, + ), + _round_score_config( + "correlation", + config_id="corr-new", + version="2", + multiplier=0.4, + round_number_start=200, + ), + _round_score_config("meta_portfolio_contribution", multiplier=0.8), + _round_score_config("meta_model_contribution", multiplier=0.6), + _round_score_config("unfamiliar_score", multiplier=0.9), + ] + responses.add( + responses.POST, + base_api.API_TOURNAMENT_URL, + json={"data": {"rounds": [{"roundScoreConfigs": configs}]}}, + ) + + returned_round = api.list_rounds()[0] + + assert [ + config["scoreConfigId"] + for config in returned_round["roundScoreConfigs"] + ] == [config["scoreConfigId"] for config in configs] + assert returned_round["defaultCorrMultiplier"] == 0.4 + assert returned_round["defaultMmcMultiplier"] == 0.6 + + +@responses.activate +def test_list_rounds_orders_numeric_score_versions_numerically(api): + configs = [ + _round_score_config( + "correlation", + config_id="corr-9", + version="9", + multiplier=0.9, + round_number_start=200, + ), + _round_score_config( + "correlation", + config_id="corr-10", + version="10", + multiplier=1.0, + round_number_start=200, + ), + ] + responses.add( + responses.POST, + base_api.API_TOURNAMENT_URL, + json={"data": {"rounds": [{"roundScoreConfigs": configs}]}}, + ) + + returned_round = api.list_rounds()[0] + + assert [ + returned_round["minCorrMultiplier"], + returned_round["maxCorrMultiplier"], + returned_round["defaultCorrMultiplier"], + ] == [1.0, 1.0, 1.0] + + +@responses.activate +def test_list_rounds_fails_closed_for_ambiguous_non_numeric_versions(api): + configs = [ + _round_score_config( + "correlation", + config_id="corr-10", + version="10", + multiplier=1.0, + round_number_start=200, + ), + _round_score_config( + "correlation", + config_id="corr-next", + version="next", + multiplier=1.1, + round_number_start=200, + ), + ] + responses.add( + responses.POST, + base_api.API_TOURNAMENT_URL, + json={"data": {"rounds": [{"roundScoreConfigs": configs}]}}, + ) + + returned_round = api.list_rounds()[0] + + assert [ + returned_round["minCorrMultiplier"], + returned_round["maxCorrMultiplier"], + returned_round["defaultCorrMultiplier"], + ] == [None, None, None] @responses.activate 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')