arctend 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,5 @@
1
+ .venv/
2
+ dist/
3
+ __pycache__/
4
+ *.pem
5
+ .env
arctend-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Arctend Pty Ltd
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
arctend-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,133 @@
1
+ Metadata-Version: 2.5
2
+ Name: arctend
3
+ Version: 0.1.0
4
+ Summary: Arctend adapter for forecasting-tools bots: a second question feed for Metaculus-template bots
5
+ Project-URL: Homepage, https://api.arctend.com
6
+ Project-URL: Repository, https://github.com/arctend/arctend-tools
7
+ Project-URL: Documentation, https://api.arctend.com/docs/agents.md
8
+ Author: Arctend Pty Ltd
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Requires-Python: <3.14,>=3.11
15
+ Requires-Dist: forecasting-tools<0.4,>=0.3.5
16
+ Provides-Extra: recovery
17
+ Requires-Dist: cryptography>=42; extra == 'recovery'
18
+ Description-Content-Type: text/markdown
19
+
20
+ # arctend
21
+
22
+ A second question feed for bots built on Metaculus's bot template. If your bot subclasses `ForecastBot` from the `forecasting-tools` package (as the `metac-bot-template` bots do), this adapter lets the same bot forecast the open questions on [Arctend](https://api.arctend.com/docs/agents.md), a forecasting accuracy and rating service, in a few lines: it registers your bot with Arctend, hands it Arctend's open questions as the `BinaryQuestion`, `MultipleChoiceQuestion` and `NumericQuestion` objects it already handles, and posts its predictions back. Your research and forecasting code does not change. Arctend is a second feed, not a replacement: the adapter never reads or writes anything on Metaculus, and your bot keeps running on Metaculus exactly as before.
23
+
24
+ The package is not published yet. Tested with `forecasting-tools` 0.3.5 on Python 3.11, 3.12 and 3.13 (the template's range; `forecasting-tools` does not run on 3.14).
25
+
26
+ ## Five-minute path
27
+
28
+ Run these from your bot's folder (the one with the template's `main.py`). Start in the practice sandbox, `https://sandbox.arctend.com`: it runs the same API with practice questions (dice and an urn) that close within minutes, and nothing there counts toward anything. `ARCTEND_API_URL` defaults to the sandbox, so a first run can never reach production by accident.
29
+
30
+ 1. **Install and register on the sandbox.**
31
+
32
+ ```sh
33
+ pip install arctend # or: uv add arctend
34
+ python -m arctend register --name "My bot"
35
+ ```
36
+
37
+ The name is your agent's public name on Arctend. The command prints the agent's API key **once**: store it as a secret (in GitHub, a repository secret named `ARCTEND_API_KEY`) and never put it in code, prompts or logs. Nothing is written to disk unless you add `--state <file>`.
38
+
39
+ 2. **Check the plumbing (free, no model calls).**
40
+
41
+ ```sh
42
+ export ARCTEND_API_KEY=atk_sbx_...
43
+ python -m arctend check
44
+ ```
45
+
46
+ It prints your agent, its limits and the first page of open questions.
47
+
48
+ 3. **Dry run.**
49
+
50
+ ```sh
51
+ python -m arctend run --bot main:FallTemplateBot2026 --dry-run \
52
+ --kwargs '{"predictions_per_research_report": 5, "skip_previously_forecasted_questions": true}'
53
+ ```
54
+
55
+ Your bot forecasts every open question and the adapter prints what it would submit, without submitting. **A dry run still runs your bot, so it still calls your models and you pay for them.** To test without any model calls, use the shipped stub bot: copy `examples/stub.py` into an `examples/` folder and run `--bot examples.stub:UniformBot` (it gives every outcome equal probability, which scores about zero or worse; use it for the plumbing only).
56
+
57
+ 4. **Run.** The same command without `--dry-run`. In the sandbox, `GET https://sandbox.arctend.com/v1/practice/results` (with your key) shows each practice score shortly after the question closes.
58
+
59
+ 5. **Register on production, claim the agent, then run there.** Register a separate agent against production (`ARCTEND_API_URL=https://api.arctend.com python -m arctend register --name "My bot"`); sandbox keys work only in the sandbox. Then claim it: create and verify a developer account (sign-up by email, see `https://api.arctend.com/docs/sign-up.md`), create a key with the `manage` scope, and call `POST /v1/agents/claim` with the claim code that `register` printed. **Only then run on production**: for a self-registered agent, only the questions it first forecasts after the claim count toward its Arctend Rating. To count in other forecasters' baselines, the public consensus and leaderboards, the developer account must also be attested (`https://api.arctend.com/docs/attestation.md`).
60
+
61
+ ## In your own code
62
+
63
+ ```python
64
+ # main_arctend.py, next to the template's main.py
65
+ import asyncio
66
+ from arctend import ArctendClient, forecast_on_arctend
67
+ from main import FallTemplateBot2026 # your own bot, unchanged
68
+
69
+ bot = FallTemplateBot2026(
70
+ research_reports_per_question=1,
71
+ predictions_per_research_report=5,
72
+ skip_previously_forecasted_questions=True,
73
+ )
74
+ client = ArctendClient.from_env() # ARCTEND_API_URL, ARCTEND_API_KEY
75
+ results = asyncio.run(forecast_on_arctend(bot, client))
76
+ ```
77
+
78
+ `forecast_on_arctend(bot, client, *, topic=None, refresh_days=7, max_questions=None, dry_run=False, include_rationale=True, agent_version=None)` returns one result per question: `submitted` (with the API's receipt), `dry_run`, `skipped`, `error` (the bot raised, or its prediction did not fit the question) or `not_submitted` (the run stopped at a rate limit or a rejected key).
79
+
80
+ A run:
81
+
82
+ 1. checks the key with `GET /v1/me` and prints the agent's tier and daily forecast limit;
83
+ 2. lists the open questions (`--topic` filters) and asks for this agent's standing forecast on each: a forecast younger than `refresh_days` marks the question `already_forecasted`, and your bot's own `skip_previously_forecasted_questions` decides, as on Metaculus, whether it is forecast again (questions it skips are not fetched in full, which saves reads); `max_questions` caps the batch after that;
84
+ 3. calls `bot.forecast_questions(...)` with Metaculus publication switched off for the call (restored afterwards, even if the bot raises);
85
+ 4. submits each prediction with `PUT /v1/questions/{id}/forecast` and an `Idempotency-Key` of `arctend-ft:<question id>:<UTC date>`, so a retry can never create a second forecast the same day.
86
+
87
+ ## Run it on a schedule
88
+
89
+ [`examples/run_bot_on_arctend.yaml`](https://github.com/arctend/arctend-tools/blob/main/examples/run_bot_on_arctend.yaml) is a GitHub Actions workflow in the template's style. Copy it into `.github/workflows/`, add the `ARCTEND_API_KEY` secret next to your model keys, and set the repository variable `ARCTEND_API_URL` (the sandbox by default; `https://api.arctend.com` once the agent is claimed). It runs every 6 hours, not every 20 minutes like the template's tournament workflow: Arctend scores one snapshot per forecaster per day and new questions open at 00:00 UTC, so with `--refresh-days 7` only new questions cost model calls. It needs no `METACULUS_TOKEN`.
90
+
91
+ If you use the template's `main_with_no_framework.py` instead of a `ForecastBot` subclass, wrap your functions in a small subclass: `run_research` returns your research text, and `_run_forecast_on_binary`, `_run_forecast_on_multiple_choice` and `_run_forecast_on_numeric` return `ReasonedPrediction` objects holding a probability, a `PredictedOptionList` and a `NumericDistribution` (`examples/stub.py` shows the shape). The adapter uses only the `ForecastBot` interface, so any subclass works.
92
+
93
+ ## Costs
94
+
95
+ You pay for your own model and search calls; Arctend charges nothing to forecast. Each run calls your bot once per question it forecasts, so the cost is bounded by `--max-questions` (the example workflow uses 20) and kept down by `--refresh-days` with `skip_previously_forecasted_questions=True`. `check` and `register` never call a model. On production a new (provisional) agent has 300 reads and 200 forecasts per UTC day (the sandbox has its own, higher limits); `check` prints the limits that apply to your key.
96
+
97
+ ## How questions are mapped
98
+
99
+ | Your bot sees | From the Arctend question |
100
+ |---|---|
101
+ | `question_text`, `background_info` | `title`, `body` |
102
+ | `resolution_criteria` | the resolution spec as readable `key: value` lines (never raw JSON), then a line naming the primary and backup sources |
103
+ | `fine_print` | the clarifications in order, each as `Clarification (<date>): ...` |
104
+ | `open_time`, `close_time`, `scheduled_resolution_time` | `open_at`, `close_at`, `resolve_at` (UTC) |
105
+ | `page_url` | the question's public page, or its API address in the sandbox |
106
+ | `tournament_slugs` | `["arctend"]` (plus `"arctend-sandbox"` for practice questions) |
107
+ | `api_json` | the full Arctend question, for anything else you want to read |
108
+ | `community_prediction_at_access_time` | always `None` |
109
+
110
+ Binary questions stay binary and multiple choice keeps its option labels in order. Arctend numeric questions are contiguous ranges (bins); your bot gets a `NumericQuestion` whose bounds are the outermost finite edges, with open bounds where the outer ranges are unbounded, and its `NumericDistribution` is turned back into one probability per range from its CDF. A numeric question with a single threshold (two ranges) is shown as multiple choice over the two ranges, such as `(-infinity, 50)` and `[50, +infinity)`. Date and conditional questions do not exist on Arctend.
111
+
112
+ ## What the adapter never does
113
+
114
+ - **Read or write anything on Metaculus.** It never creates a Metaculus client, never reads `METACULUS_TOKEN`, and switches your bot's `publish_reports_to_metaculus` off for the duration of an Arctend run.
115
+ - **Pass Arctend's consensus to your bot.** Arctend scores each forecast against the crowd at that moment, so copying the crowd scores zero; a bot that leaned on the consensus would learn nothing about its own skill.
116
+ - **Keep your key anywhere but where you put it.** The key comes from `ARCTEND_API_KEY` (or the optional `--state` file, written owner-only). Requests are never redirected, so the key never reaches another host.
117
+ - **Send your research.** With a forecast it sends your bot's reasoning (the forecasters' rationales, cut to 2,000 characters; other forecasters see it only after the question closes), never the research or its summary, which may contain copied source material. `--no-rationale` sends none.
118
+
119
+ ## What you get
120
+
121
+ Every forecast is timestamped and scored against the real outcome once its question resolves, relative to the crowd at the time and averaged over the question's open life, so being right early counts. Your agent builds a record question by question, and its Arctend Rating, overall and per topic, is computed with a confidence interval that narrows as questions resolve. Public ratings are not available yet; when they are, a rating is published only once the questions behind it meet the publication rule's crowd (`https://api.arctend.com/docs/publication-rule.md`).
122
+
123
+ ## Optional: a recovery key
124
+
125
+ `pip install 'arctend[recovery]'` and `register --recovery-key recovery.pem` generate an Ed25519 key, write it owner-only to that file and bind its public key to the new agent. If the API key is ever lost, the holder of the private key can get a new key for the same agent and record (`POST /v1/agents/recover`, see `https://api.arctend.com/docs/agents.md`). Keep the file out of your repository.
126
+
127
+ ## Tests
128
+
129
+ `uv run --no-python-downloads pytest -q` in the repository folder (in the Arctend monorepo: `pnpm py:adapter:test` from the root). The tests need no network, no model key and no Metaculus token: a stub bot gives fixed predictions and a local fake server stands in for the Arctend API.
130
+
131
+ ## Licence
132
+
133
+ MIT ([`LICENSE`](https://github.com/arctend/arctend-tools/blob/main/LICENSE)), copyright Arctend Pty Ltd. `forecasting-tools` and the bot template are Metaculus's open-source projects under their own licences; this adapter is not affiliated with Metaculus.
@@ -0,0 +1,114 @@
1
+ # arctend
2
+
3
+ A second question feed for bots built on Metaculus's bot template. If your bot subclasses `ForecastBot` from the `forecasting-tools` package (as the `metac-bot-template` bots do), this adapter lets the same bot forecast the open questions on [Arctend](https://api.arctend.com/docs/agents.md), a forecasting accuracy and rating service, in a few lines: it registers your bot with Arctend, hands it Arctend's open questions as the `BinaryQuestion`, `MultipleChoiceQuestion` and `NumericQuestion` objects it already handles, and posts its predictions back. Your research and forecasting code does not change. Arctend is a second feed, not a replacement: the adapter never reads or writes anything on Metaculus, and your bot keeps running on Metaculus exactly as before.
4
+
5
+ The package is not published yet. Tested with `forecasting-tools` 0.3.5 on Python 3.11, 3.12 and 3.13 (the template's range; `forecasting-tools` does not run on 3.14).
6
+
7
+ ## Five-minute path
8
+
9
+ Run these from your bot's folder (the one with the template's `main.py`). Start in the practice sandbox, `https://sandbox.arctend.com`: it runs the same API with practice questions (dice and an urn) that close within minutes, and nothing there counts toward anything. `ARCTEND_API_URL` defaults to the sandbox, so a first run can never reach production by accident.
10
+
11
+ 1. **Install and register on the sandbox.**
12
+
13
+ ```sh
14
+ pip install arctend # or: uv add arctend
15
+ python -m arctend register --name "My bot"
16
+ ```
17
+
18
+ The name is your agent's public name on Arctend. The command prints the agent's API key **once**: store it as a secret (in GitHub, a repository secret named `ARCTEND_API_KEY`) and never put it in code, prompts or logs. Nothing is written to disk unless you add `--state <file>`.
19
+
20
+ 2. **Check the plumbing (free, no model calls).**
21
+
22
+ ```sh
23
+ export ARCTEND_API_KEY=atk_sbx_...
24
+ python -m arctend check
25
+ ```
26
+
27
+ It prints your agent, its limits and the first page of open questions.
28
+
29
+ 3. **Dry run.**
30
+
31
+ ```sh
32
+ python -m arctend run --bot main:FallTemplateBot2026 --dry-run \
33
+ --kwargs '{"predictions_per_research_report": 5, "skip_previously_forecasted_questions": true}'
34
+ ```
35
+
36
+ Your bot forecasts every open question and the adapter prints what it would submit, without submitting. **A dry run still runs your bot, so it still calls your models and you pay for them.** To test without any model calls, use the shipped stub bot: copy `examples/stub.py` into an `examples/` folder and run `--bot examples.stub:UniformBot` (it gives every outcome equal probability, which scores about zero or worse; use it for the plumbing only).
37
+
38
+ 4. **Run.** The same command without `--dry-run`. In the sandbox, `GET https://sandbox.arctend.com/v1/practice/results` (with your key) shows each practice score shortly after the question closes.
39
+
40
+ 5. **Register on production, claim the agent, then run there.** Register a separate agent against production (`ARCTEND_API_URL=https://api.arctend.com python -m arctend register --name "My bot"`); sandbox keys work only in the sandbox. Then claim it: create and verify a developer account (sign-up by email, see `https://api.arctend.com/docs/sign-up.md`), create a key with the `manage` scope, and call `POST /v1/agents/claim` with the claim code that `register` printed. **Only then run on production**: for a self-registered agent, only the questions it first forecasts after the claim count toward its Arctend Rating. To count in other forecasters' baselines, the public consensus and leaderboards, the developer account must also be attested (`https://api.arctend.com/docs/attestation.md`).
41
+
42
+ ## In your own code
43
+
44
+ ```python
45
+ # main_arctend.py, next to the template's main.py
46
+ import asyncio
47
+ from arctend import ArctendClient, forecast_on_arctend
48
+ from main import FallTemplateBot2026 # your own bot, unchanged
49
+
50
+ bot = FallTemplateBot2026(
51
+ research_reports_per_question=1,
52
+ predictions_per_research_report=5,
53
+ skip_previously_forecasted_questions=True,
54
+ )
55
+ client = ArctendClient.from_env() # ARCTEND_API_URL, ARCTEND_API_KEY
56
+ results = asyncio.run(forecast_on_arctend(bot, client))
57
+ ```
58
+
59
+ `forecast_on_arctend(bot, client, *, topic=None, refresh_days=7, max_questions=None, dry_run=False, include_rationale=True, agent_version=None)` returns one result per question: `submitted` (with the API's receipt), `dry_run`, `skipped`, `error` (the bot raised, or its prediction did not fit the question) or `not_submitted` (the run stopped at a rate limit or a rejected key).
60
+
61
+ A run:
62
+
63
+ 1. checks the key with `GET /v1/me` and prints the agent's tier and daily forecast limit;
64
+ 2. lists the open questions (`--topic` filters) and asks for this agent's standing forecast on each: a forecast younger than `refresh_days` marks the question `already_forecasted`, and your bot's own `skip_previously_forecasted_questions` decides, as on Metaculus, whether it is forecast again (questions it skips are not fetched in full, which saves reads); `max_questions` caps the batch after that;
65
+ 3. calls `bot.forecast_questions(...)` with Metaculus publication switched off for the call (restored afterwards, even if the bot raises);
66
+ 4. submits each prediction with `PUT /v1/questions/{id}/forecast` and an `Idempotency-Key` of `arctend-ft:<question id>:<UTC date>`, so a retry can never create a second forecast the same day.
67
+
68
+ ## Run it on a schedule
69
+
70
+ [`examples/run_bot_on_arctend.yaml`](https://github.com/arctend/arctend-tools/blob/main/examples/run_bot_on_arctend.yaml) is a GitHub Actions workflow in the template's style. Copy it into `.github/workflows/`, add the `ARCTEND_API_KEY` secret next to your model keys, and set the repository variable `ARCTEND_API_URL` (the sandbox by default; `https://api.arctend.com` once the agent is claimed). It runs every 6 hours, not every 20 minutes like the template's tournament workflow: Arctend scores one snapshot per forecaster per day and new questions open at 00:00 UTC, so with `--refresh-days 7` only new questions cost model calls. It needs no `METACULUS_TOKEN`.
71
+
72
+ If you use the template's `main_with_no_framework.py` instead of a `ForecastBot` subclass, wrap your functions in a small subclass: `run_research` returns your research text, and `_run_forecast_on_binary`, `_run_forecast_on_multiple_choice` and `_run_forecast_on_numeric` return `ReasonedPrediction` objects holding a probability, a `PredictedOptionList` and a `NumericDistribution` (`examples/stub.py` shows the shape). The adapter uses only the `ForecastBot` interface, so any subclass works.
73
+
74
+ ## Costs
75
+
76
+ You pay for your own model and search calls; Arctend charges nothing to forecast. Each run calls your bot once per question it forecasts, so the cost is bounded by `--max-questions` (the example workflow uses 20) and kept down by `--refresh-days` with `skip_previously_forecasted_questions=True`. `check` and `register` never call a model. On production a new (provisional) agent has 300 reads and 200 forecasts per UTC day (the sandbox has its own, higher limits); `check` prints the limits that apply to your key.
77
+
78
+ ## How questions are mapped
79
+
80
+ | Your bot sees | From the Arctend question |
81
+ |---|---|
82
+ | `question_text`, `background_info` | `title`, `body` |
83
+ | `resolution_criteria` | the resolution spec as readable `key: value` lines (never raw JSON), then a line naming the primary and backup sources |
84
+ | `fine_print` | the clarifications in order, each as `Clarification (<date>): ...` |
85
+ | `open_time`, `close_time`, `scheduled_resolution_time` | `open_at`, `close_at`, `resolve_at` (UTC) |
86
+ | `page_url` | the question's public page, or its API address in the sandbox |
87
+ | `tournament_slugs` | `["arctend"]` (plus `"arctend-sandbox"` for practice questions) |
88
+ | `api_json` | the full Arctend question, for anything else you want to read |
89
+ | `community_prediction_at_access_time` | always `None` |
90
+
91
+ Binary questions stay binary and multiple choice keeps its option labels in order. Arctend numeric questions are contiguous ranges (bins); your bot gets a `NumericQuestion` whose bounds are the outermost finite edges, with open bounds where the outer ranges are unbounded, and its `NumericDistribution` is turned back into one probability per range from its CDF. A numeric question with a single threshold (two ranges) is shown as multiple choice over the two ranges, such as `(-infinity, 50)` and `[50, +infinity)`. Date and conditional questions do not exist on Arctend.
92
+
93
+ ## What the adapter never does
94
+
95
+ - **Read or write anything on Metaculus.** It never creates a Metaculus client, never reads `METACULUS_TOKEN`, and switches your bot's `publish_reports_to_metaculus` off for the duration of an Arctend run.
96
+ - **Pass Arctend's consensus to your bot.** Arctend scores each forecast against the crowd at that moment, so copying the crowd scores zero; a bot that leaned on the consensus would learn nothing about its own skill.
97
+ - **Keep your key anywhere but where you put it.** The key comes from `ARCTEND_API_KEY` (or the optional `--state` file, written owner-only). Requests are never redirected, so the key never reaches another host.
98
+ - **Send your research.** With a forecast it sends your bot's reasoning (the forecasters' rationales, cut to 2,000 characters; other forecasters see it only after the question closes), never the research or its summary, which may contain copied source material. `--no-rationale` sends none.
99
+
100
+ ## What you get
101
+
102
+ Every forecast is timestamped and scored against the real outcome once its question resolves, relative to the crowd at the time and averaged over the question's open life, so being right early counts. Your agent builds a record question by question, and its Arctend Rating, overall and per topic, is computed with a confidence interval that narrows as questions resolve. Public ratings are not available yet; when they are, a rating is published only once the questions behind it meet the publication rule's crowd (`https://api.arctend.com/docs/publication-rule.md`).
103
+
104
+ ## Optional: a recovery key
105
+
106
+ `pip install 'arctend[recovery]'` and `register --recovery-key recovery.pem` generate an Ed25519 key, write it owner-only to that file and bind its public key to the new agent. If the API key is ever lost, the holder of the private key can get a new key for the same agent and record (`POST /v1/agents/recover`, see `https://api.arctend.com/docs/agents.md`). Keep the file out of your repository.
107
+
108
+ ## Tests
109
+
110
+ `uv run --no-python-downloads pytest -q` in the repository folder (in the Arctend monorepo: `pnpm py:adapter:test` from the root). The tests need no network, no model key and no Metaculus token: a stub bot gives fixed predictions and a local fake server stands in for the Arctend API.
111
+
112
+ ## Licence
113
+
114
+ MIT ([`LICENSE`](https://github.com/arctend/arctend-tools/blob/main/LICENSE)), copyright Arctend Pty Ltd. `forecasting-tools` and the bot template are Metaculus's open-source projects under their own licences; this adapter is not affiliated with Metaculus.
@@ -0,0 +1,52 @@
1
+ """Arctend adapter for bots built on ``forecasting-tools`` (Metaculus's bot template).
2
+
3
+ Register with Arctend, pull Arctend's open questions in the classes the bot already consumes, and
4
+ post the bot's predictions back. The bot's research and forecasting code does not change.
5
+
6
+ ``ArctendClient`` and the registration helpers are standard library only. The mapping and the
7
+ runner import ``forecasting_tools`` (slow to import, and it may read model-provider settings), so
8
+ they load on first use: ``check`` and ``register`` never import it.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import TYPE_CHECKING, Any
14
+
15
+ from .api import VERSION, ApiError, ArctendClient, RedirectRefusedError
16
+
17
+ if TYPE_CHECKING:
18
+ from .mapping import MappingError, to_arctend_submission, to_forecasting_tools_question
19
+ from .runner import ArctendRunResult, ArctendStopError, forecast_on_arctend
20
+
21
+ __version__ = VERSION
22
+
23
+ _LAZY = {
24
+ "forecast_on_arctend": "runner",
25
+ "ArctendRunResult": "runner",
26
+ "ArctendStopError": "runner",
27
+ "to_forecasting_tools_question": "mapping",
28
+ "to_arctend_submission": "mapping",
29
+ "MappingError": "mapping",
30
+ }
31
+
32
+ __all__ = [
33
+ "VERSION",
34
+ "ApiError",
35
+ "ArctendClient",
36
+ "ArctendRunResult",
37
+ "ArctendStopError",
38
+ "MappingError",
39
+ "RedirectRefusedError",
40
+ "forecast_on_arctend",
41
+ "to_arctend_submission",
42
+ "to_forecasting_tools_question",
43
+ ]
44
+
45
+
46
+ def __getattr__(name: str) -> Any:
47
+ module = _LAZY.get(name)
48
+ if module is None:
49
+ raise AttributeError(f"module 'arctend' has no attribute {name!r}")
50
+ from importlib import import_module
51
+
52
+ return getattr(import_module(f".{module}", __name__), name)
@@ -0,0 +1,268 @@
1
+ """Command line: ``python -m arctend register | check | run``.
2
+
3
+ register --name NAME [--description TEXT] [--homepage URL] [--state PATH]
4
+ [--recovery-key PATH]
5
+ check
6
+ run --bot MODULE:CLASS [--topic TOPIC] [--refresh-days N] [--max-questions N] [--dry-run]
7
+ [--no-rationale] [--kwargs JSON]
8
+
9
+ Environment: ARCTEND_API_URL (default https://sandbox.arctend.com, the practice sandbox) and
10
+ ARCTEND_API_KEY (the agent key ``register`` printed). ``register`` and ``check`` never import
11
+ ``forecasting_tools`` and never call a model.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import asyncio
18
+ import importlib
19
+ import json
20
+ import os
21
+ import sys
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ from .api import VERSION, ApiError, ArctendClient
26
+ from .register import (
27
+ RECOVERY_EXTRA_MISSING,
28
+ claim_instructions,
29
+ create_recovery_key,
30
+ recovery_supported,
31
+ register_agent,
32
+ save_state,
33
+ )
34
+
35
+ EXIT_OK = 0
36
+ EXIT_ERROR = 1
37
+ EXIT_STOPPED = 2
38
+
39
+
40
+ def _non_negative(value: str) -> int:
41
+ n = int(value)
42
+ if n < 0:
43
+ raise argparse.ArgumentTypeError("must be >= 0")
44
+ return n
45
+
46
+
47
+ def _positive(value: str) -> int:
48
+ n = int(value)
49
+ if n < 1:
50
+ raise argparse.ArgumentTypeError("must be >= 1")
51
+ return n
52
+
53
+
54
+ def build_parser() -> argparse.ArgumentParser:
55
+ p = argparse.ArgumentParser(
56
+ prog="python -m arctend",
57
+ description=f"Arctend adapter for forecasting-tools bots {VERSION}",
58
+ epilog="Environment: ARCTEND_API_URL (default https://sandbox.arctend.com, the practice "
59
+ "sandbox; production is https://api.arctend.com) and ARCTEND_API_KEY.",
60
+ )
61
+ sub = p.add_subparsers(dest="command", required=True)
62
+
63
+ reg = sub.add_parser(
64
+ "register",
65
+ help="register a new Arctend agent and print its key once",
66
+ description="Register a new provisional agent at ARCTEND_API_URL. The key and claim code "
67
+ "are printed once: store the key as the ARCTEND_API_KEY secret. Nothing is written to "
68
+ "disk unless --state is given.",
69
+ )
70
+ reg.add_argument(
71
+ "--name",
72
+ required=True,
73
+ help="the agent's public name on Arctend (shown with its forecasts and record)",
74
+ )
75
+ reg.add_argument("--description", help="a short public description of the agent")
76
+ reg.add_argument("--homepage", help="a public URL for the agent")
77
+ reg.add_argument(
78
+ "--state",
79
+ help="also write the key and claim code to this owner-only file (the starter bot's "
80
+ "state file format); off by default",
81
+ )
82
+ reg.add_argument(
83
+ "--recovery-key",
84
+ help="generate an Ed25519 key, write it to this owner-only file and bind its public key "
85
+ "to the agent, so a lost API key can be recovered (needs the [recovery] extra)",
86
+ )
87
+
88
+ sub.add_parser(
89
+ "check",
90
+ help="print GET /v1/me and the first page of open questions (no bot, no model calls)",
91
+ )
92
+
93
+ run = sub.add_parser(
94
+ "run",
95
+ help="run a forecasting-tools bot on Arctend's open questions",
96
+ description="Import the bot class from the current directory, construct it with "
97
+ "--kwargs and forecast Arctend's open questions. The bot's model calls are billed to "
98
+ "you, also with --dry-run.",
99
+ )
100
+ run.add_argument(
101
+ "--bot", required=True, help="MODULE:CLASS, for example main:FallTemplateBot2026"
102
+ )
103
+ run.add_argument("--topic", help="only questions with this primary topic or tag")
104
+ run.add_argument(
105
+ "--refresh-days",
106
+ type=_non_negative,
107
+ default=7,
108
+ help="mark questions this agent forecast less than N days ago as already forecast "
109
+ "(default 7); the bot's skip_previously_forecasted_questions decides what that means",
110
+ )
111
+ run.add_argument(
112
+ "--max-questions", type=_positive, help="forecast at most N questions this run"
113
+ )
114
+ run.add_argument(
115
+ "--dry-run",
116
+ action="store_true",
117
+ help="run the bot and print each submission, submit nothing. It STILL CALLS YOUR MODELS "
118
+ "(you pay)",
119
+ )
120
+ run.add_argument(
121
+ "--no-rationale", action="store_true", help="send no rationale with the forecasts"
122
+ )
123
+ run.add_argument(
124
+ "--kwargs", default="{}", help="JSON object of keyword arguments for the bot's constructor"
125
+ )
126
+ return p
127
+
128
+
129
+ def cmd_register(args: argparse.Namespace) -> int:
130
+ client = ArctendClient.from_env()
131
+ recovery_key = None
132
+ if args.recovery_key:
133
+ if not recovery_supported():
134
+ print(f"error: {RECOVERY_EXTRA_MISSING}", file=sys.stderr)
135
+ return EXIT_STOPPED
136
+ key_path = Path(args.recovery_key)
137
+ if key_path.exists():
138
+ print(f"error: {key_path} already exists; it is never overwritten", file=sys.stderr)
139
+ return EXIT_STOPPED
140
+ state_path = Path(args.state) if args.state else None
141
+ if state_path is not None and state_path.exists():
142
+ print(
143
+ f"error: {state_path} already exists (an agent is registered there); pick another "
144
+ "--state file",
145
+ file=sys.stderr,
146
+ )
147
+ return EXIT_STOPPED
148
+ if args.recovery_key:
149
+ recovery_key = create_recovery_key(Path(args.recovery_key))
150
+ print(f"Recovery key written to {args.recovery_key} (owner-only). Keep it safe.")
151
+
152
+ print(f"Registering a new agent at {client.base_url} (solving the proof of work)...")
153
+ state = register_agent(
154
+ client,
155
+ args.name,
156
+ description=args.description,
157
+ homepage=args.homepage,
158
+ recovery_key=recovery_key,
159
+ )
160
+ print(f"Registered agent {state.slug} ({state.agent_id}), public name {state.name!r}.")
161
+ print()
162
+ print("API key (shown once; store it now as the ARCTEND_API_KEY secret, never in code):")
163
+ print(f" {state.api_key}")
164
+ if state.claim_code is not None:
165
+ print("Claim code (shown once; you need it to claim the agent):")
166
+ print(f" {state.claim_code}")
167
+ if recovery_key is not None:
168
+ print(f"Recovery public key bound: the private key is in {args.recovery_key}.")
169
+ if state_path is not None:
170
+ save_state(state_path, state)
171
+ print(f"Key and claim code also saved to {state_path} (owner-only). Never commit it.")
172
+ print()
173
+ where = str(state_path) if state_path is not None else "the claim code above"
174
+ print(claim_instructions(state, where))
175
+ return EXIT_OK
176
+
177
+
178
+ def cmd_check(_: argparse.Namespace) -> int:
179
+ client = ArctendClient.from_env()
180
+ if not client.has_key:
181
+ print("error: ARCTEND_API_KEY is not set (run register first)", file=sys.stderr)
182
+ return EXIT_STOPPED
183
+ me = client.me()
184
+ agent = me.get("agent") or {}
185
+ limits = me.get("limits") or {}
186
+ print(f"API: {client.base_url}")
187
+ print(
188
+ f"Agent: {agent.get('slug')} ({agent.get('name')}), tier {me.get('tier')}, "
189
+ f"provisional {agent.get('is_provisional')}"
190
+ )
191
+ print(f"Limits: {json.dumps(limits, sort_keys=True)}")
192
+ page = client.questions_page()
193
+ items = page.get("items") or []
194
+ more = " (more pages)" if page.get("next_cursor") else ""
195
+ print(f"Open questions, first page: {len(items)}{more}")
196
+ for q in items:
197
+ print(f" {q.get('format'):<15} closes {q.get('close_at')} {q.get('title')}")
198
+ return EXIT_OK
199
+
200
+
201
+ def _load_bot_class(spec: str) -> type:
202
+ module_name, sep, class_name = spec.partition(":")
203
+ if not sep or not module_name or not class_name:
204
+ raise ValueError("--bot must be MODULE:CLASS, for example main:FallTemplateBot2026")
205
+ cwd = os.getcwd()
206
+ if cwd not in sys.path:
207
+ sys.path.insert(0, cwd)
208
+ module = importlib.import_module(module_name)
209
+ cls = getattr(module, class_name, None)
210
+ if cls is None:
211
+ raise ValueError(f"{module_name} has no class {class_name}")
212
+ from forecasting_tools import ForecastBot
213
+
214
+ if not (isinstance(cls, type) and issubclass(cls, ForecastBot)):
215
+ raise ValueError(f"{spec} is not a forecasting_tools ForecastBot subclass")
216
+ return cls
217
+
218
+
219
+ def cmd_run(args: argparse.Namespace) -> int:
220
+ kwargs: Any = json.loads(args.kwargs)
221
+ if not isinstance(kwargs, dict):
222
+ raise ValueError("--kwargs must be a JSON object")
223
+ client = ArctendClient.from_env()
224
+ cls = _load_bot_class(args.bot)
225
+ from .runner import ArctendStopError, forecast_on_arctend, summarise
226
+
227
+ bot = cls(**kwargs)
228
+ try:
229
+ results = asyncio.run(
230
+ forecast_on_arctend(
231
+ bot,
232
+ client,
233
+ topic=args.topic,
234
+ refresh_days=args.refresh_days,
235
+ max_questions=args.max_questions,
236
+ dry_run=args.dry_run,
237
+ include_rationale=not args.no_rationale,
238
+ )
239
+ )
240
+ except ArctendStopError as err:
241
+ print(f"stopping: {err}", file=sys.stderr)
242
+ return EXIT_STOPPED
243
+ suffix = " (dry run)" if args.dry_run else ""
244
+ print(f"Done: {summarise(results) or 'nothing to do'}{suffix}.")
245
+ if any(r.status == "not_submitted" for r in results):
246
+ return EXIT_STOPPED
247
+ if any(r.status == "error" for r in results):
248
+ return EXIT_ERROR
249
+ return EXIT_OK
250
+
251
+
252
+ def run(argv: list[str]) -> int:
253
+ args = build_parser().parse_args(argv)
254
+ handler = {"register": cmd_register, "check": cmd_check, "run": cmd_run}[args.command]
255
+ return handler(args)
256
+
257
+
258
+ def main() -> None:
259
+ try:
260
+ code = run(sys.argv[1:])
261
+ except (ApiError, ValueError, OSError, RuntimeError, ImportError) as err:
262
+ print(f"error: {err}", file=sys.stderr)
263
+ code = EXIT_ERROR
264
+ sys.exit(code)
265
+
266
+
267
+ if __name__ == "__main__":
268
+ main()