Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
# Changelog
Notable changes to this project.

## [3.0.0] - Unreleased
- remove the six deprecated Corr/MMC multiplier projections from
`list_rounds`; use the identity-preserving `roundScoreConfigs` list instead

## [2.24.0] - 2026-08-03
- add exact `roundScoreConfigs` identities, scoring windows, and payout settings
to `list_rounds` for Classic, Signals, and Crypto
Expand Down
33 changes: 10 additions & 23 deletions docs/round-score-configs.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ Each item includes:
consistent with other date fields in numerapi. GraphQL float and integer fields
retain their normal Python JSON types.

## Migrating from legacy multiplier keys
## Legacy multiplier keys removed in 3.0.0

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
Expand All @@ -34,8 +34,8 @@ were Alpha and MPC:
}
```

In 2.24.0 the exact identities are available without knowing score names in
advance:
In 3.0.0, the exact identities are the only payout configuration returned by
`list_rounds()`:

```python
{
Expand All @@ -57,29 +57,16 @@ advance:
"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`.
The six Corr/MMC compatibility keys (`min`, `max`, and `default` for each) are
no longer added to the returned round dictionary. `list_rounds()` never
exposed the three legacy TC multiplier fields. Code should filter
`roundScoreConfigs`, normally starting with `isPayout`, and preserve each
configuration's `name`, `version`, and `scoreConfigId` rather than projecting
different scores into Corr or MMC roles.

## Deprecated performance endpoint

Expand Down
56 changes: 0 additions & 56 deletions numerapi/base_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -751,13 +751,6 @@ def list_rounds(
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
Expand Down Expand Up @@ -835,57 +828,8 @@ def list_rounds(
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.

Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ def load(path):
return open(path, "r").read()


numerapi_version = "2.24.0"
numerapi_version = "3.0.0"

classifiers = [
"Development Status :: 5 - Production/Stable",
Expand Down
97 changes: 18 additions & 79 deletions tests/test_base_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -369,8 +369,14 @@ def test_list_rounds(api):
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
assert not {
"minCorrMultiplier",
"maxCorrMultiplier",
"defaultCorrMultiplier",
"minMmcMultiplier",
"maxMmcMultiplier",
"defaultMmcMultiplier",
}.intersection(res[0])

request_body = json.loads(responses.calls[0].request.body)
assert request_body["variables"]["tournament"] == 8
Expand Down Expand Up @@ -403,18 +409,23 @@ def test_list_rounds(api):
}
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"]
assert not {
"minCorrMultiplier",
"maxCorrMultiplier",
"defaultCorrMultiplier",
"minMmcMultiplier",
"maxMmcMultiplier",
"defaultMmcMultiplier",
}.intersection(request_body["query"].split())


@pytest.mark.parametrize(
("api_class", "tournament", "score_names", "legacy_multipliers"),
("api_class", "tournament", "score_names"),
[
(
numerapi.NumerAPI,
8,
["correlation", "meta_model_contribution"],
(0.5, 0.5),
),
(
numerapi.SignalsAPI,
Expand All @@ -424,19 +435,17 @@ def test_list_rounds(api):
"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_class, tournament, score_names
):
api = api_class()
configs = [_round_score_config(name) for name in score_names]
Expand All @@ -452,8 +461,6 @@ def test_list_rounds_preserves_tournament_score_identities(
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

Expand Down Expand Up @@ -492,74 +499,6 @@ def test_list_rounds_keeps_coexisting_and_unfamiliar_score_configs(api):
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
Expand Down
Loading