From 0c925ed55d34dc3e09c737c29764dc0fbe5f21bf Mon Sep 17 00:00:00 2001 From: Josh Terrill Date: Sat, 6 Dec 2025 23:28:42 -0800 Subject: [PATCH 1/3] added --tournament flag to cli, added timezone-aware dates to base api --- CHANGELOG.md | 1 + README.md | 7 +- numerapi/base_api.py | 4 +- numerapi/cli.py | 171 +++++++++++++++++++++++++++++------------ tests/conftest.py | 12 +++ tests/test_cli.py | 33 ++++++-- tests/test_numerapi.py | 2 +- 7 files changed, 170 insertions(+), 60 deletions(-) create mode 100644 tests/conftest.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 2622f8e..54ccbc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ Notable changes to this project. ## [dev] +- cli: allow selecting tournaments (Signals/Crypto) via `--tournament` - more type hints ## [2.20.8] - 2025-09-11 diff --git a/README.md b/README.md index 3d19231..e50191c 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,10 @@ To get started with the cli interface, let's take a look at the help page: user Get all information about you!... version Installed numerapi version. +All CLI commands accept a `--tournament` option. It defaults to `8` +(the classic tournament), but you can point the CLI at Signals (`11`) or +Crypto (`12`) on a per-command basis, e.g. `numerapi list-datasets --tournament 11`. + Each command has it's own help page, for example: @@ -137,7 +141,8 @@ Each command has it's own help page, for example: Upload predictions from file. Options: - --tournament INTEGER The ID of the tournament, defaults to 1 + --tournament INTEGER Tournament to target (8 classic, 11 signals, 12 + crypto) [default: 8] --model_id TEXT An account model UUID (required for accounts with multiple models diff --git a/numerapi/base_api.py b/numerapi/base_api.py index f04ce74..440b2fa 100644 --- a/numerapi/base_api.py +++ b/numerapi/base_api.py @@ -1137,7 +1137,7 @@ def check_round_open(self) -> bool: return False open_time = utils.parse_datetime_string(raw['openTime']) deadline = utils.parse_datetime_string(raw["closeStakingTime"]) - now = datetime.datetime.utcnow().replace(tzinfo=pytz.utc) + now = datetime.datetime.now(tz=pytz.utc) is_open = open_time < now < deadline return is_open @@ -1174,7 +1174,7 @@ def check_new_round(self, hours: int = 12) -> bool: if raw is None: return False open_time = utils.parse_datetime_string(raw['openTime']) - now = datetime.datetime.utcnow().replace(tzinfo=pytz.utc) + now = datetime.datetime.now(tz=pytz.utc) is_new_round = open_time > now - datetime.timedelta(hours=hours) return is_new_round diff --git a/numerapi/cli.py b/numerapi/cli.py index c583158..4279361 100644 --- a/numerapi/cli.py +++ b/numerapi/cli.py @@ -8,7 +8,43 @@ import numerapi -napi = numerapi.NumerAPI() +DEFAULT_TOURNAMENT = 8 + + +def _get_api(tournament: int): + """ + Return the correct API implementation for a tournament. + + Classic (and any tournament other than Signals/Crypto) uses NumerAPI, + Signals (11) uses SignalsAPI, and Crypto (12) uses CryptoAPI. + """ + if tournament == 11: + return numerapi.SignalsAPI() + if tournament == 12: + return numerapi.CryptoAPI() + api = numerapi.NumerAPI() + api.tournament_id = tournament + return api + + +def _require_method(api, method_name: str, command_name: str): + """Ensure the requested command is supported for the selected tournament.""" + if not hasattr(api, method_name): + raise click.ClickException( + f"The '{command_name}' command is not available for tournament " + f"{api.tournament_id}.") + return getattr(api, method_name) + + +def tournament_option(func): + """Reusable Click option for selecting a tournament.""" + return click.option( + '--tournament', + type=int, + default=DEFAULT_TOURNAMENT, + show_default=True, + help="Tournament to target (8 classic, 11 signals, 12 crypto).", + )(func) class CommonJSONEncoder(json.JSONEncoder): @@ -38,44 +74,51 @@ def cli(): @cli.command() -@click.option('--round_num', - help='round you are interested in.defaults to the current round') -def list_datasets(round_num): +@click.option( + '--round_num', type=int, + help='round you are interested in. defaults to the current round') +@tournament_option +def list_datasets(round_num, tournament): """List of available data files""" - click.echo(prettify(napi.list_datasets(round_num=round_num))) + api = _get_api(tournament) + click.echo(prettify(api.list_datasets(round_num=round_num))) @cli.command() @click.option( - '--round_num', - help='round you are interested in.defaults to the current round') + '--round_num', type=int, + help='round you are interested in. defaults to the current round') @click.option( - '--filename', help='file to be downloaded') + '--filename', default="numerai_live_data.parquet", show_default=True, + help='file to be downloaded') @click.option( '--dest_path', - help='complate destination path, defaults to the name of the source file') + help='complete destination path, defaults to the name of the source file') +@tournament_option def download_dataset(round_num, filename="numerai_live_data.parquet", - dest_path=None): + dest_path=None, tournament=DEFAULT_TOURNAMENT): """Download specified file for the given round""" click.echo("WARNING to download the old data use `download-dataset-old`") - click.echo(napi.download_dataset( + api = _get_api(tournament) + click.echo(api.download_dataset( round_num=round_num, filename=filename, dest_path=dest_path)) @cli.command() -@click.option('--tournament', default=8, - help='The ID of the tournament, defaults to 8') -def competitions(tournament=8): +@tournament_option +def competitions(tournament=DEFAULT_TOURNAMENT): """Retrieves information about all competitions""" - click.echo(prettify(napi.get_competitions(tournament=tournament))) + api = _get_api(tournament) + method = _require_method(api, 'get_competitions', 'competitions') + click.echo(prettify(method(tournament=tournament))) @cli.command() -@click.option('--tournament', default=8, - help='The ID of the tournament, defaults to 8') -def current_round(tournament=8): +@tournament_option +def current_round(tournament=DEFAULT_TOURNAMENT): """Get number of the current active round.""" - click.echo(napi.get_current_round(tournament=tournament)) + api = _get_api(tournament) + click.echo(api.get_current_round(tournament=tournament)) @cli.command() @@ -83,94 +126,116 @@ def current_round(tournament=8): help='Number of items to return, defaults to 20') @click.option('--offset', default=0, help='Number of items to skip, defaults to 0') -def leaderboard(limit=20, offset=0): +@tournament_option +def leaderboard(limit=20, offset=0, tournament=DEFAULT_TOURNAMENT): """Get the leaderboard.""" - click.echo(prettify(napi.get_leaderboard(limit=limit, offset=offset))) + api = _get_api(tournament) + method = _require_method(api, 'get_leaderboard', 'leaderboard') + click.echo(prettify(method(limit=limit, offset=offset))) @cli.command() -@click.option('--tournament', type=int, default=None, - help='filter by ID of the tournament, defaults to None') @click.option('--round_num', type=int, default=None, help='filter by round number, defaults to None') @click.option( '--model_id', type=str, default=None, help="An account model UUID (required for accounts with multiple models") +@tournament_option def submission_filenames(round_num, tournament, model_id): """Get filenames of your submissions""" + api = _get_api(tournament) + method = _require_method( + api, 'get_submission_filenames', 'submission-filenames') click.echo(prettify( - napi.get_submission_filenames(tournament, round_num, model_id))) + method(tournament=tournament, round_num=round_num, model_id=model_id))) @cli.command() @click.option('--hours', default=12, help='timeframe to consider, defaults to 12') -def check_new_round(hours=12): +@tournament_option +def check_new_round(hours=12, tournament=DEFAULT_TOURNAMENT): """Check if a new round has started within the last `hours`.""" - click.echo(int(napi.check_new_round(hours=hours))) + api = _get_api(tournament) + click.echo(int(api.check_new_round(hours=hours))) @cli.command() -def account(): +@tournament_option +def account(tournament=DEFAULT_TOURNAMENT): """Get all information about your account!""" - click.echo(prettify(napi.get_account())) + api = _get_api(tournament) + click.echo(prettify(api.get_account())) @cli.command() -@click.option('--tournament', default=8, - help='The ID of the tournament, defaults to 8') -def models(tournament): +@tournament_option +def models(tournament=DEFAULT_TOURNAMENT): """Get map of account models!""" - click.echo(prettify(napi.get_models(tournament))) + api = _get_api(tournament) + click.echo(prettify(api.get_models(tournament))) @cli.command() @click.argument("username") -def profile(username): +@tournament_option +def profile(username, tournament=DEFAULT_TOURNAMENT): """Fetch the public profile of a user.""" - click.echo(prettify(napi.public_user_profile(username))) + api = _get_api(tournament) + method = _require_method(api, 'public_user_profile', 'profile') + click.echo(prettify(method(username))) @cli.command() @click.argument("username") -def daily_model_performances(username): +@tournament_option +def daily_model_performances(username, tournament=DEFAULT_TOURNAMENT): """Fetch daily performance of a model.""" - click.echo(prettify(napi.daily_model_performances(username))) + api = _get_api(tournament) + method = _require_method( + api, 'daily_model_performances', 'daily-model-performances') + click.echo(prettify(method(username))) @cli.command() -def transactions(): +@tournament_option +def transactions(tournament=DEFAULT_TOURNAMENT): """List all your deposits and withdrawals.""" - click.echo(prettify(napi.wallet_transactions())) + api = _get_api(tournament) + click.echo(prettify(api.wallet_transactions())) @cli.command() -@click.option('--tournament', default=8, - help='The ID of the tournament, defaults to 8') @click.option( '--model_id', type=str, default=None, help="An account model UUID (required for accounts with multiple models") @click.argument('path', type=click.Path(exists=True)) -def submit(path, tournament, model_id): +@tournament_option +def submit(path, model_id, tournament=DEFAULT_TOURNAMENT): """Upload predictions from file.""" - click.echo(napi.upload_predictions( - path, tournament, model_id)) + api = _get_api(tournament) + click.echo(api.upload_predictions(path, model_id=model_id)) @cli.command() @click.argument("username") -def stake_get(username): +@tournament_option +def stake_get(username, tournament=DEFAULT_TOURNAMENT): """Get stake value of a user.""" - click.echo(napi.stake_get(username)) + api = _get_api(tournament) + method = _require_method(api, 'stake_get', 'stake-get') + click.echo(method(username)) @cli.command() @click.option( '--model_id', type=str, default=None, help="An account model UUID (required for accounts with multiple models") -def stake_drain(model_id): +@tournament_option +def stake_drain(model_id, tournament=DEFAULT_TOURNAMENT): """Completely remove your stake.""" - click.echo(napi.stake_drain(model_id)) + api = _get_api(tournament) + click.echo(api.stake_drain(model_id)) @cli.command() @@ -178,9 +243,11 @@ def stake_drain(model_id): @click.option( '--model_id', type=str, default=None, help="An account model UUID (required for accounts with multiple models") -def stake_decrease(nmr, model_id): +@tournament_option +def stake_decrease(nmr, model_id, tournament=DEFAULT_TOURNAMENT): """Decrease your stake by `value` NMR.""" - click.echo(napi.stake_decrease(nmr, model_id)) + api = _get_api(tournament) + click.echo(api.stake_decrease(nmr, model_id)) @cli.command() @@ -188,9 +255,11 @@ def stake_decrease(nmr, model_id): @click.option( '--model_id', type=str, default=None, help="An account model UUID (required for accounts with multiple models") -def stake_increase(nmr, model_id): +@tournament_option +def stake_increase(nmr, model_id, tournament=DEFAULT_TOURNAMENT): """Increase your stake by `value` NMR.""" - click.echo(napi.stake_increase(nmr, model_id)) + api = _get_api(tournament) + click.echo(api.stake_increase(nmr, model_id)) @cli.command() diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..134febc --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,12 @@ +"""Ensure tests import the local numerapi package.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +root_str = str(ROOT) +if root_str not in sys.path: + sys.path.insert(0, root_str) + diff --git a/tests/test_cli.py b/tests/test_cli.py index ac0450e..e51ff7e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -16,10 +16,23 @@ def login(): del os.environ["NUMERAI_SECRET_KEY"] +@patch('numerapi.NumerAPI.list_datasets') +def test_list_datasets_default(mocked): + result = CliRunner().invoke(cli.list_datasets) + assert result.exit_code == 0 + mocked.assert_called_once_with(round_num=None) + + +@patch('numerapi.SignalsAPI.list_datasets') +def test_list_datasets_signals(mocked): + result = CliRunner().invoke(cli.list_datasets, ['--tournament', '11']) + assert result.exit_code == 0 + mocked.assert_called_once_with(round_num=None) + + @patch('numerapi.NumerAPI.download_dataset') def test_download_dataset(mocked): result = CliRunner().invoke(cli.download_dataset) - # just testing if calling works fine assert result.exit_code == 0 @@ -32,21 +45,28 @@ def test_leaderboard(mocked): @patch('numerapi.NumerAPI.get_competitions') def test_competitions(mocked): - result = CliRunner().invoke(cli.competitions, ['--tournament', 1]) + result = CliRunner().invoke(cli.competitions, ['--tournament', '1']) # just testing if calling works fine assert result.exit_code == 0 +def test_competitions_not_supported_for_signals(): + result = CliRunner().invoke(cli.competitions, ['--tournament', '11']) + assert result.exit_code != 0 + assert "not available" in result.output + + @patch('numerapi.NumerAPI.get_current_round') def test_current_round(mocked): - result = CliRunner().invoke(cli.current_round, ['--tournament', 1]) + result = CliRunner().invoke(cli.current_round, ['--tournament', '1']) # just testing if calling works fine assert result.exit_code == 0 @patch('numerapi.NumerAPI.get_submission_filenames') def test_submission_filenames(mocked): - result = CliRunner().invoke(cli.submission_filenames, ['--tournament', 1]) + result = CliRunner().invoke( + cli.submission_filenames, ['--tournament', '1']) # just testing if calling works fine assert result.exit_code == 0 @@ -86,8 +106,11 @@ def test_submit(mocked, login, tmpdir): result = CliRunner().invoke( cli.submit, [str(path), '--model_id', '31a42870-38b6-4435-ad49-18b987ff4148']) - # just testing if calling works fine assert result.exit_code == 0 + mocked.assert_called_once() + args, kwargs = mocked.call_args + assert args[0] == str(path) + assert kwargs['model_id'] == '31a42870-38b6-4435-ad49-18b987ff4148' def test_version(): diff --git a/tests/test_numerapi.py b/tests/test_numerapi.py index 2974035..0b6a80d 100644 --- a/tests/test_numerapi.py +++ b/tests/test_numerapi.py @@ -87,7 +87,7 @@ def test_upload_predictions_df(api): @responses.activate def test_check_new_round(api): - open_time = datetime.datetime.utcnow().replace(tzinfo=pytz.utc) + open_time = datetime.datetime.now(tz=pytz.utc) data = {"data": {"rounds": [{"openTime": open_time.isoformat()}]}} responses.add(responses.POST, base_api.API_TOURNAMENT_URL, json=data) From d7e50b0c982cc281b09167e962ec32c145120045 Mon Sep 17 00:00:00 2001 From: Josh Terrill Date: Sat, 6 Dec 2025 23:34:50 -0800 Subject: [PATCH 2/3] added mmcMultiplier and roundPayoutFactor --- numerapi/base_api.py | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/numerapi/base_api.py b/numerapi/base_api.py index 440b2fa..1585b8f 100644 --- a/numerapi/base_api.py +++ b/numerapi/base_api.py @@ -828,7 +828,9 @@ def round_model_performances_v2(self, model_id: str): * atRisk (`float`) * corrMultiplier (`float` or None) + * mmcMultiplier (`float` or None) * tcMultiplier (`float` or None) + * roundPayoutFactor (`float` or None) * roundNumber (`int`) * roundOpenTime (`datetime`) * roundResolveTime (`datetime`) @@ -851,19 +853,21 @@ def round_model_performances_v2(self, model_id: str): tournament: $tournament) { atRisk corrMultiplier + mmcMultiplier tcMultiplier - roundNumber, - roundOpenTime, - roundResolveTime, - roundResolved, - roundTarget, + roundPayoutFactor + roundNumber + roundOpenTime + roundResolveTime + roundResolved + roundTarget submissionScores { - date, - day, - displayName, - payoutPending, - payoutSettled, - percentile, + date + day + displayName + payoutPending + payoutSettled + percentile value } } From 388c33e4a75050f85bad9d176d92708a25f9d8de Mon Sep 17 00:00:00 2001 From: Josh Terrill Date: Sat, 6 Dec 2025 23:37:34 -0800 Subject: [PATCH 3/3] updated changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 54ccbc3..6245d61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ Notable changes to this project. ## [dev] - cli: allow selecting tournaments (Signals/Crypto) via `--tournament` +- added `mmcMultiplier` and `roundPayoutFactor` to `round_model_performances_v2` - more type hints ## [2.20.8] - 2025-09-11