From c1739ac32ef6c7f2039a03146321ade648e56590 Mon Sep 17 00:00:00 2001 From: "numerai-hosted-agent-bot[bot]" <247676148+numerai-hosted-agent-bot[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 11:37:34 -0700 Subject: [PATCH 1/9] Migrate round payout score configuration (#131) - migrate `list_rounds` from deprecated GraphQL round multiplier fields to the full public `roundScoreConfigs` surface for Classic, Signals, and Crypto - preserve the six established Corr/MMC return keys through numerapi 2.x as exact-name compatibility projections, without mapping Alpha/FNC to Corr or MPC to MMC - document old/new response shapes, the numerapi 2.24.0 migration, planned 3.0.0 removal, and the isolated deprecated `round_model_performances_v2` endpoint - keep Ruff CI stable across unpinned Ruff releases by recording the repository's historical lint selection --- .readthedocs.yaml | 4 +- CHANGELOG.md | 14 ++ docs/conf.py | 2 +- docs/index.rst | 1 + docs/requirements.txt | 7 +- docs/round-score-configs.md | 92 +++++++++++++ numerapi/base_api.py | 98 ++++++++++++-- numerapi/utils.py | 6 +- ruff.toml | 4 + setup.py | 2 +- tests/test_base_api.py | 253 ++++++++++++++++++++++++++++++++++-- 11 files changed, 460 insertions(+), 23 deletions(-) create mode 100644 docs/round-score-configs.md create mode 100644 ruff.toml diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 24f15fe..37b8d21 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -7,9 +7,9 @@ version: 2 # Set the version of Python and other tools you might need build: - os: ubuntu-22.04 + os: ubuntu-24.04 tools: - python: "3.10" + python: "3.12" jobs: pre_build: - sphinx-apidoc --separate --no-toc --force -o docs/api/ numerapi diff --git a/CHANGELOG.md b/CHANGELOG.md index a51a548..ea8fdd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,20 @@ # 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 + +## [2.23.3] - 2026-06-30 +- fix `models_of_account` referencing incorrect type `Str!` instead of `String!` + +## [2.23.2] - 2026-06-02 +- increase dataset download chunk size to 1 MB to improve download speeds ## [2.23.1] - 2026-04-23 - fix package version lookup diff --git a/docs/conf.py b/docs/conf.py index 435550f..cfdd147 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -42,7 +42,7 @@ "sphinx.ext.viewcode", "sphinx.ext.napoleon", 'sphinx.ext.doctest', - 'm2r' + 'sphinx_mdinclude', ] # Add any paths that contain templates here, relative to this directory. 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/requirements.txt b/docs/requirements.txt index 142a838..6d11b4a 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1 +1,6 @@ -m2r +sphinx-mdinclude +python-dateutil +tqdm +pandas +click +pytz 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 97071f9..d58d7b0 100644 --- a/numerapi/base_api.py +++ b/numerapi/base_api.py @@ -331,7 +331,7 @@ def models_of_account(self, account) -> Dict[str, str]: {'uuazed': '9b157d9b-ce61-4ab5-9413-413f13a0c0a6', ...} """ query = """ - query($username: Str! + query($username: String! $tournament: Int) { accountProfile(username: $username tournament: $tournament){ @@ -637,7 +637,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 +672,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 +717,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/utils.py b/numerapi/utils.py index 76985c0..b3534a4 100644 --- a/numerapi/utils.py +++ b/numerapi/utils.py @@ -14,6 +14,8 @@ logger = logging.getLogger(__name__) +DOWNLOAD_CHUNK_SIZE = 1024 * 1024 # 1 MiB + def load_secrets() -> tuple: """load secrets from environment variables or dotenv file""" @@ -96,9 +98,9 @@ def download_file(url: str, dest_path: str, show_progress_bars: bool = True): # Update progress bar to reflect how much of the file is already downloaded pbar.update(file_size) with open(temp_path, "ab") as dest_file: - for chunk in req.iter_content(1024): + for chunk in req.iter_content(DOWNLOAD_CHUNK_SIZE): dest_file.write(chunk) - pbar.update(1024) + pbar.update(len(chunk)) # move temp file to target destination os.replace(temp_path, dest_path) return dest_path 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 86ce77f..8ff0e8a 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ def load(path): return open(path, "r").read() -numerapi_version = "2.23.1" +numerapi_version = "2.24.0" classifiers = [ diff --git a/tests/test_base_api.py b/tests/test_base_api.py index 84aa996..9f30a58 100644 --- a/tests/test_base_api.py +++ b/tests/test_base_api.py @@ -5,6 +5,7 @@ import pytest import responses +import numerapi from numerapi import base_api @@ -14,6 +15,42 @@ 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): @@ -180,7 +217,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 +249,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 +266,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 From 0818423e36e9daa1f6e57e0cc08c592f699f383d Mon Sep 17 00:00:00 2001 From: Ling Li Date: Tue, 4 Aug 2026 20:40:34 +0200 Subject: [PATCH 2/9] Add public_user_profile to Crypto API (resolve model_id by name) (#128) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `v3UserProfile` GraphQL field accepts a `tournament` argument that disambiguates a model name within a tournament. The old Classic/Signals implementations ignored it — meaning a name shared across tournaments could resolve to the **wrong** model's id (verified: `quixotic15` returns a different id with vs. without `tournament=12`). This unifies all three tournaments into a single `base_api.Api.public_user_profile` that passes `tournament=self.tournament_id`: - ✅ `CryptoAPI` gains `public_user_profile` (tournament 12) — the missing piece - ✅ Signals / Classic now resolve names within their own tournament (11 / 8) - ✅ duplicated overrides in `numerapi.py` and `signalsapi.py` removed (DRY) Return shape is unchanged (`id`, `username`, `startDate`, `bio`, `nmrStaked`). --- numerapi/base_api.py | 52 ++++++++++++++++++++++++++++++++++++++++ numerapi/numerapi.py | 42 -------------------------------- numerapi/signalsapi.py | 43 --------------------------------- tests/test_cryptoapi.py | 41 +++++++++++++++++++++++++++++++ tests/test_signalsapi.py | 19 +++++++++++++++ 5 files changed, 112 insertions(+), 85 deletions(-) diff --git a/numerapi/base_api.py b/numerapi/base_api.py index d58d7b0..b579e00 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') From c546404b4b0bcf22c6b21f5a2777672f1c2f423b Mon Sep 17 00:00:00 2001 From: Ling Li Date: Tue, 4 Aug 2026 20:41:43 +0200 Subject: [PATCH 3/9] Fix SignalsAPI.stake_get (KeyError on removed totalStake field) (#129) Query the current stake field, `stakeValue` (type `Nmr`), directly within the signals tournament and parse it to `Decimal`, returning `None` for models with no stake (instead of raising): ```python query = ''' query($model_name: String!, $tournament: Int) { v3UserProfile(model_name: $model_name, tournament: $tournament) { stakeValue } } ''' ``` --- numerapi/signalsapi.py | 21 ++++++++++++++++----- setup.py | 2 +- tests/test_signalsapi.py | 22 ++++++++++++++++++++++ 3 files changed, 39 insertions(+), 6 deletions(-) diff --git a/numerapi/signalsapi.py b/numerapi/signalsapi.py index 8e1bf9f..b16d2bf 100644 --- a/numerapi/signalsapi.py +++ b/numerapi/signalsapi.py @@ -241,18 +241,29 @@ 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 + def stake_get(self, username) -> decimal.Decimal | None: + """get current stake for a given user Args: username (str) Returns: - decimal.Decimal: current stake + decimal.Decimal or None: current stake, or None if the model has + no stake Example: >>> SignalsAPI().stake_get("uuazed") Decimal('14.63') """ - data = self.public_user_profile(username) - return data['totalStake'] + 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']) diff --git a/setup.py b/setup.py index 8ff0e8a..ceda313 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ def load(path): return open(path, "r").read() - + numerapi_version = "2.24.0" diff --git a/tests/test_signalsapi.py b/tests/test_signalsapi.py index 9f58e4a..f767087 100644 --- a/tests/test_signalsapi.py +++ b/tests/test_signalsapi.py @@ -1,3 +1,4 @@ +import decimal from unittest.mock import patch import pytest @@ -32,6 +33,27 @@ def api_fixture(): return api +@patch("numerapi.signalsapi.SignalsAPI.raw_query") +def test_stake_get(mocked, api): + mocked.return_value = {"data": {"v3UserProfile": {"stakeValue": "14.63"}}} + + stake = api.stake_get("uuazed") + + assert stake == decimal.Decimal("14.63") + args, _ = mocked.call_args + # current stake lives in `stakeValue`; `totalStake` no longer exists + assert "stakeValue" in args[0] + assert "totalStake" not in args[0] + assert args[1]["tournament"] == api.tournament_id == 11 + + +@patch("numerapi.signalsapi.SignalsAPI.raw_query") +def test_stake_get_no_stake(mocked, api): + # a model with no stake returns null -> None, not a KeyError + mocked.return_value = {"data": {"v3UserProfile": {"stakeValue": None}}} + assert api.stake_get("uuazed") is None + + @pytest.mark.live_api def test_get_leaderboard(api): lb = api.get_leaderboard(1) From 8c85455fb8ce0db5b42c9e8362096f122d482024 Mon Sep 17 00:00:00 2001 From: Noah Harasz Date: Tue, 4 Aug 2026 12:30:46 -0700 Subject: [PATCH 4/9] refactor stake_get to support all tournaments --- CHANGELOG.md | 2 ++ numerapi/base_api.py | 30 +++++++++++++++++++++++++++++- numerapi/numerapi.py | 26 -------------------------- numerapi/signalsapi.py | 28 ---------------------------- tests/test_base_api.py | 35 +++++++++++++++++++++++++++++++++++ tests/test_signalsapi.py | 22 ---------------------- 6 files changed, 66 insertions(+), 77 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea8fdd0..84ff868 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ Notable changes to this project. 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 ## [2.23.3] - 2026-06-30 - fix `models_of_account` referencing incorrect type `Str!` instead of `String!` diff --git a/numerapi/base_api.py b/numerapi/base_api.py index b579e00..dca465f 100644 --- a/numerapi/base_api.py +++ b/numerapi/base_api.py @@ -1,6 +1,7 @@ -"""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 @@ -401,6 +402,33 @@ def public_user_profile(self, username: str) -> Dict: 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 diff --git a/numerapi/numerapi.py b/numerapi/numerapi.py index 0bae34b..45bd5d7 100644 --- a/numerapi/numerapi.py +++ b/numerapi/numerapi.py @@ -247,32 +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 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 b16d2bf..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 @@ -240,30 +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 | None: - """get current stake for a given user - - Args: - username (str) - - Returns: - decimal.Decimal or None: current stake, 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']) diff --git a/tests/test_base_api.py b/tests/test_base_api.py index 9f30a58..26e3302 100644 --- a/tests/test_base_api.py +++ b/tests/test_base_api.py @@ -74,6 +74,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}}" diff --git a/tests/test_signalsapi.py b/tests/test_signalsapi.py index f767087..9f58e4a 100644 --- a/tests/test_signalsapi.py +++ b/tests/test_signalsapi.py @@ -1,4 +1,3 @@ -import decimal from unittest.mock import patch import pytest @@ -33,27 +32,6 @@ def api_fixture(): return api -@patch("numerapi.signalsapi.SignalsAPI.raw_query") -def test_stake_get(mocked, api): - mocked.return_value = {"data": {"v3UserProfile": {"stakeValue": "14.63"}}} - - stake = api.stake_get("uuazed") - - assert stake == decimal.Decimal("14.63") - args, _ = mocked.call_args - # current stake lives in `stakeValue`; `totalStake` no longer exists - assert "stakeValue" in args[0] - assert "totalStake" not in args[0] - assert args[1]["tournament"] == api.tournament_id == 11 - - -@patch("numerapi.signalsapi.SignalsAPI.raw_query") -def test_stake_get_no_stake(mocked, api): - # a model with no stake returns null -> None, not a KeyError - mocked.return_value = {"data": {"v3UserProfile": {"stakeValue": None}}} - assert api.stake_get("uuazed") is None - - @pytest.mark.live_api def test_get_leaderboard(api): lb = api.get_leaderboard(1) From 18ad4e973670237d9d6018bb0c0462d178a621d8 Mon Sep 17 00:00:00 2001 From: "numerai-hosted-agent-bot[bot]" <247676148+numerai-hosted-agent-bot[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 21:26:09 +0000 Subject: [PATCH 5/9] T-588 publish NumerAPI 2.24.0.dev0 (#133) Co-authored-by: numerai-hosted-agent-bot[bot] <247676148+numerai-hosted-agent-bot[bot]@users.noreply.github.com> --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index ce5af75..2f04863 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ def load(path): return open(path, "r").read() -numerapi_version = "2.24.0" +numerapi_version = "2.24.0.dev0" classifiers = [ "Development Status :: 5 - Production/Stable", From a424b6826512b3fb73bb8ddf12bd9ec1c475e047 Mon Sep 17 00:00:00 2001 From: Noah Harasz Date: Wed, 5 Aug 2026 10:58:50 -0700 Subject: [PATCH 6/9] update to 2.24.0 --- setup.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 2f04863..bf3f11c 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,8 @@ def load(path): return open(path, "r").read() -numerapi_version = "2.24.0.dev0" + +numerapi_version = "2.24.0" classifiers = [ "Development Status :: 5 - Production/Stable", From 4f67fa035169fa9e19795bc8b1a6e7781e521205 Mon Sep 17 00:00:00 2001 From: Noah Harasz Date: Wed, 5 Aug 2026 11:46:59 -0700 Subject: [PATCH 7/9] add partial parquet dataset downloads --- CHANGELOG.md | 5 ++++ README.md | 6 +++++ numerapi/base_api.py | 37 ++++++++++++++++++++++--- requirements.txt | 1 + setup.py | 3 ++- tests/test_base_api.py | 61 ++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 109 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 84ff868..ec34a97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,11 @@ # Changelog Notable changes to this project. +## [2.24.0.dev1] - 2026-08-05 + +- allow `download_dataset` to use pandas Parquet filters and download only the + matching portion of a dataset + ## [2.24.0] - 2026-08-03 - add exact `roundScoreConfigs` identities, scoring windows, and payout settings to `list_rounds` for Classic, Signals, and Crypto 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/numerapi/base_api.py b/numerapi/base_api.py index dca465f..8d332c4 100644 --- a/numerapi/base_api.py +++ b/numerapi/base_api.py @@ -8,6 +8,7 @@ from io import BytesIO from typing import Dict, List, Tuple, Union +import fsspec import pandas as pd import pytz import requests @@ -177,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. @@ -187,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 @@ -222,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): 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/setup.py b/setup.py index bf3f11c..7f646ff 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ def load(path): return open(path, "r").read() -numerapi_version = "2.24.0" +numerapi_version = "2.24.0.dev1" classifiers = [ "Development Status :: 5 - Production/Stable", @@ -40,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 26e3302..7e832f1 100644 --- a/tests/test_base_api.py +++ b/tests/test_base_api.py @@ -2,6 +2,8 @@ import decimal import json import os +from unittest.mock import MagicMock + import pytest import responses @@ -57,6 +59,65 @@ def test_NumerAPI(): 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) From ba401c2a5895f61420df4d36fd68598ae6f281e2 Mon Sep 17 00:00:00 2001 From: Noah Harasz Date: Wed, 5 Aug 2026 12:47:06 -0700 Subject: [PATCH 8/9] fix changelog --- CHANGELOG.md | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec34a97..e643e0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,6 @@ # Changelog Notable changes to this project. -## [2.24.0.dev1] - 2026-08-05 - -- allow `download_dataset` to use pandas Parquet filters and download only the - matching portion of a dataset - ## [2.24.0] - 2026-08-03 - add exact `roundScoreConfigs` identities, scoring windows, and payout settings to `list_rounds` for Classic, Signals, and Crypto @@ -16,6 +11,8 @@ Notable changes to this project. 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!` From 78bf4f270a6bb4005d80c107bd3e0316a4b0bfc3 Mon Sep 17 00:00:00 2001 From: Noah Harasz Date: Wed, 5 Aug 2026 13:15:36 -0700 Subject: [PATCH 9/9] update version tag to main version --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 7f646ff..1d93053 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ def load(path): return open(path, "r").read() -numerapi_version = "2.24.0.dev1" +numerapi_version = "2.24.0" classifiers = [ "Development Status :: 5 - Production/Stable",