Bit-Trading-Company commited on
Commit
8028640
·
verified ·
1 Parent(s): d5e2a4c

CI deploy local

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +5 -0
  2. .gitignore +3 -0
  3. README.md +162 -8
  4. app.py +555 -0
  5. pytest.ini +8 -0
  6. requirements.txt +42 -0
  7. scripts/bootstrap_registry.py +175 -0
  8. scripts/daily_job.py +97 -0
  9. scripts/seed_trackrecord.py +190 -0
  10. src/__init__.py +0 -0
  11. src/adapters/__init__.py +117 -0
  12. src/adapters/base.py +409 -0
  13. src/adapters/baseline.py +214 -0
  14. src/adapters/chronos.py +183 -0
  15. src/adapters/kronos.py +296 -0
  16. src/adapters/timesfm.py +198 -0
  17. src/config.py +196 -0
  18. src/prices.py +171 -0
  19. src/runtime.py +308 -0
  20. src/store.py +400 -0
  21. src/trackrecord.py +241 -0
  22. src/ui/__init__.py +16 -0
  23. src/ui/chart.py +300 -0
  24. src/ui/chrome.py +158 -0
  25. src/ui/shell.py +1056 -0
  26. src/view.py +396 -0
  27. tests/__init__.py +0 -0
  28. tests/conftest.py +34 -0
  29. tests/fixture.py +33 -0
  30. tests/test_acceptance.py +426 -0
  31. tests/test_app.py +168 -0
  32. tests/test_kronos_vendor.py +88 -0
  33. tests/test_runtime.py +167 -0
  34. tests/test_ui.py +360 -0
  35. vendor/bit-ui/.gitignore +7 -0
  36. vendor/bit-ui/README.md +85 -0
  37. vendor/bit-ui/bit_ui/__init__.py +41 -0
  38. vendor/bit-ui/bit_ui/assets/fonts/MacMinecraft.ttf +3 -0
  39. vendor/bit-ui/bit_ui/assets/fonts/StyreneA-Light.otf +3 -0
  40. vendor/bit-ui/bit_ui/assets/fonts/StyreneA-Medium.otf +3 -0
  41. vendor/bit-ui/bit_ui/assets/fonts/StyreneA-Regular.otf +3 -0
  42. vendor/bit-ui/bit_ui/assets/fonts/StyreneA-Thin.otf +3 -0
  43. vendor/bit-ui/bit_ui/assets/tokens/base.css +33 -0
  44. vendor/bit-ui/bit_ui/assets/tokens/colors.css +125 -0
  45. vendor/bit-ui/bit_ui/assets/tokens/spacing.css +29 -0
  46. vendor/bit-ui/bit_ui/assets/tokens/typography.css +38 -0
  47. vendor/bit-ui/bit_ui/bridge.py +329 -0
  48. vendor/bit-ui/bit_ui/dialogs.py +311 -0
  49. vendor/bit-ui/bit_ui/icons.py +65 -0
  50. vendor/bit-ui/bit_ui/markup.py +32 -0
.gitattributes CHANGED
@@ -33,3 +33,8 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ vendor/bit-ui/bit_ui/assets/fonts/MacMinecraft.ttf filter=lfs diff=lfs merge=lfs -text
37
+ vendor/bit-ui/bit_ui/assets/fonts/StyreneA-Light.otf filter=lfs diff=lfs merge=lfs -text
38
+ vendor/bit-ui/bit_ui/assets/fonts/StyreneA-Medium.otf filter=lfs diff=lfs merge=lfs -text
39
+ vendor/bit-ui/bit_ui/assets/fonts/StyreneA-Regular.otf filter=lfs diff=lfs merge=lfs -text
40
+ vendor/bit-ui/bit_ui/assets/fonts/StyreneA-Thin.otf filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ .cache/
2
+ __pycache__/
3
+ .pytest_cache/
README.md CHANGED
@@ -1,13 +1,167 @@
1
  ---
2
- title: Bit Forecast Arena
3
- emoji: 🌖
4
- colorFrom: indigo
5
- colorTo: green
6
  sdk: gradio
7
- sdk_version: 6.25.0
8
- python_version: '3.13'
9
  app_file: app.py
10
- pinned: false
 
 
 
 
 
 
 
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Forecast Arena
3
+ emoji: 📈
4
+ colorFrom: yellow
5
+ colorTo: gray
6
  sdk: gradio
7
+ sdk_version: "5.49.1"
 
8
  app_file: app.py
9
+ pinned: true
10
+ license: apache-2.0
11
+ hf_oauth: true
12
+ short_description: Every forecast archived, every model graded
13
+ tags:
14
+ - time-series
15
+ - forecasting
16
+ - finance
17
+ - calibration
18
+ - benchmark
19
  ---
20
 
21
+ # Forecast Arena
22
+
23
+ Several time-series foundation models forecast the same markets. Every forecast
24
+ is archived **frozen at issue**, and every model accumulates a public
25
+ calibration record that nobody — including us — can quietly edit afterwards.
26
+
27
+ Built by [The Bit Trading Company](https://huggingface.co/Bit-Trading-Company).
28
+ The archive lives in
29
+ [`bit-signal-store`](https://huggingface.co/datasets/The-Bit-Trading-Company/bit-signal-store).
30
+
31
+ > **Nothing here is financial advice.** These are model outputs on historical
32
+ > price series, not predictions, recommendations or signals. Forecasting
33
+ > markets is close to the hardest thing you can ask a time-series model to do,
34
+ > and the calibration grades on this page exist to show you how often these
35
+ > models are wrong.
36
+
37
+ ## What the Arena actually measures
38
+
39
+ Not accuracy. **Calibration** — whether a model's uncertainty is honest.
40
+
41
+ A model that says "80% chance the price lands in this band" should be right
42
+ about 80% of the time. If it is right 58% of the time, its bands are too narrow
43
+ and its confidence is a lie, however good its median looks. That is the number
44
+ this page leads with, because it is the one that tells you whether you can use
45
+ the forecast for anything.
46
+
47
+ | Grade | Gap from nominal 80% coverage |
48
+ |---|---|
49
+ | A | within 5 points |
50
+ | B | within 10 points |
51
+ | C | within 20 points |
52
+ | D | worse |
53
+ | – | fewer than 20 resolved forecasts — no grade shown |
54
+
55
+ Refusing to grade below 20 resolved forecasts is deliberate. With a handful of
56
+ observations a coverage estimate is mostly noise, and a confident "A" computed
57
+ from four forecasts would be the most misleading thing here.
58
+
59
+ ## Method
60
+
61
+ **Frozen at issue.** A forecast is written to the archive at the moment it is
62
+ made, with its seed, its sampling settings, the model revision that produced it
63
+ and the length of the context it saw. Those rows are never rewritten.
64
+ Resolution appends outcomes to a *separate* tree. A track record whose history
65
+ can be edited after the fact is not evidence of anything.
66
+
67
+ **No lookahead, structurally.** Contexts are sliced at the issue timestamp and
68
+ every adapter re-validates that slice on every call. A context bar dated after
69
+ the issue moment raises an error rather than producing a flattering number.
70
+ This is not a convention we ask callers to honour — there is no code path that
71
+ skips it, including in the backfill.
72
+
73
+ **Seeded and reproducible.** The same context, horizon, sample count and seed
74
+ produce bit-identical output. Models that expose no seed argument (Kronos
75
+ samples through a global RNG) are wrapped so their sampling is seeded anyway.
76
+
77
+ **Quantiles are the models' own numbers.** The stored levels — 10/20/50/80/90 —
78
+ were chosen so that every family emits them natively rather than by
79
+ interpolation. Path-sampling models get empirical quantiles from their sampled
80
+ paths; direct-quantile models return theirs unchanged.
81
+
82
+ **Backfill is labelled.** A backfilled entry is a forecast made *as of* a past
83
+ timestamp, using only data available then. It is real evidence, but it is not
84
+ the same as a forecast issued before the outcome existed, so those entries carry
85
+ a `BACKFILL` badge everywhere they appear and a `backfilled` column in the data.
86
+
87
+ **Baselines are first-class.** A random walk and a drift model sit in the
88
+ standings next to the foundation models. A model that cannot out-forecast
89
+ "tomorrow looks like today" is not worth the GPU time, and a leaderboard that
90
+ omits that comparison is flattering rather than useful.
91
+
92
+ ## Models and attribution
93
+
94
+ | Family | Models | Licence | Emits |
95
+ |---|---|---|---|
96
+ | **Kronos** | `NeoQuasar/Kronos-{mini,small,base}` | MIT | sampled OHLCV paths |
97
+ | **Chronos-Bolt** | `amazon/chronos-bolt-{tiny,mini,small,base}` | Apache-2.0 | quantile line |
98
+ | **TimesFM** | `google/timesfm-2.5-200m-pytorch` | Apache-2.0 | quantile line |
99
+ | **Baselines** | random walk, drift, block bootstrap | this repo | reference |
100
+
101
+ **Kronos** — [Kronos: A Foundation Model for the Language of Financial
102
+ Markets](https://arxiv.org/abs/2508.02739), Shi et al.
103
+ [GitHub](https://github.com/shiyu-coder/Kronos). Kronos publishes weights but no
104
+ inference package, so its source is vendored at a pinned commit under
105
+ `vendor/kronos` with one documented local change; see the PROVENANCE there.
106
+ It is the only family here native to candlesticks, which is why it is the one
107
+ that can draw sampled OHLCV paths.
108
+
109
+ **Chronos** — [Chronos: Learning the Language of Time
110
+ Series](https://arxiv.org/abs/2403.07815), Ansari et al., Amazon.
111
+ [GitHub](https://github.com/amazon-science/chronos-forecasting).
112
+
113
+ **TimesFM** — [A decoder-only foundation model for time-series
114
+ forecasting](https://arxiv.org/abs/2310.10688), Das et al., Google Research,
115
+ ICML 2024. [GitHub](https://github.com/google-research/timesfm). Not an
116
+ officially supported Google product.
117
+
118
+ None of these models were trained by us, fine-tuned by us, or selected because
119
+ they perform well here. They are run zero-shot, through their own official
120
+ inference paths, at pinned revisions.
121
+
122
+ ## Limitations
123
+
124
+ Read these before drawing any conclusion from the standings.
125
+
126
+ - **Zero-shot, out of domain.** Chronos and TimesFM are general time-series
127
+ models; markets are not what they were built for. Kronos was pre-trained on
128
+ candlesticks, which is an advantage on this task and worth remembering when
129
+ comparing across families.
130
+ - **Grades are per asset class, not per asset.** Crypto and equities are graded
131
+ separately, but BTC and ETH are not independent enough to justify separate
132
+ grades.
133
+ - **Coverage is estimated from correlated observations.** Forecast windows
134
+ overlap, so the effective sample size is smaller than the resolved count. Read
135
+ small differences in coverage as noise.
136
+ - **One sampling configuration.** Every model runs at its own defaults. A model
137
+ might be better calibrated at a different temperature; the Arena does not tune
138
+ per model, because tuning on the same data it is graded on would invalidate
139
+ the grade.
140
+ - **Horizons past 64 steps are rolled forward** for Chronos, on its own median.
141
+ Uncertainty stops compounding properly there, which flatters its bands.
142
+ - **Prices come from public providers** (ccxt for crypto, yfinance with a Stooq
143
+ fallback for equities) and carry whatever errors those carry.
144
+ - **The archive starts small.** Backfill gives models a history without waiting
145
+ months for one, but a model enrolled today has a thinner record than the
146
+ count alone suggests.
147
+
148
+ ## Running it
149
+
150
+ Anonymous visitors get CPU-tier models with a per-session cap. Signing in with
151
+ Hugging Face unlocks GPU-tier models on your own quota, backfill contributions,
152
+ and enrolling a model.
153
+
154
+ **Enrolling a model** takes a Hub model id and an adapter family. Only families
155
+ with a vetted loader are accepted, `trust_remote_code` is never set, and no
156
+ user-supplied code is ever imported or executed. A model is pinned to an
157
+ immutable revision and must pass a smoke test before it enters the standings.
158
+
159
+ ## Development
160
+
161
+ ```bash
162
+ cd space
163
+ python -m pytest tests/ -q -m "not slow" # what CI runs; offline, no credentials
164
+ python app.py # http://127.0.0.1:7860
165
+ ```
166
+
167
+ The suite runs offline with no credentials. Keep it that way.
app.py ADDED
@@ -0,0 +1,555 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Forecast Arena — a Hugging Face Space by The Bit Trading Company.
2
+
3
+ Many time-series foundation models forecast the same markets. Every forecast is
4
+ archived frozen at issue, and every model accumulates a public calibration
5
+ record that nobody can quietly edit afterwards.
6
+
7
+ Architecture, in one paragraph: the visible layer is the design's own markup,
8
+ rendered by `src/ui/shell.py` from a plain state dict. Gradio owns the transport
9
+ and nothing that is seen — one `gr.HTML` sink, a hidden textbox the click bridge
10
+ writes into, and a hidden button it clicks. Every interaction is
11
+ `state -> apply_action -> state -> render`, which keeps the whole UI a pure
12
+ function of state and makes it testable without a browser.
13
+
14
+ Model loading is lazy and cached in `src/runtime.py`; the store is read once at
15
+ boot and refreshed only when a forecast is archived. There is no Hub call in the
16
+ render path.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import logging
22
+ import os
23
+
24
+ import gradio as gr
25
+ import pandas as pd
26
+
27
+ from src import config, runtime, view
28
+ from src.adapters import ALLOWED_ADAPTER_FAMILIES
29
+ from src.store import ArenaStore, now_utc
30
+ from src.ui import chart, chrome, shell # noqa: F401 - sets the vendor path
31
+
32
+ from bit_ui import bridge
33
+ from bit_ui import nav as bit_nav
34
+ from bit_ui import theme
35
+
36
+ logging.basicConfig(level=logging.INFO,
37
+ format="%(asctime)s %(levelname)-7s %(name)s: %(message)s")
38
+ log = logging.getLogger("arena.app")
39
+
40
+ ON_SPACE = bool(os.environ.get("SPACE_ID"))
41
+
42
+ STORE_URL = f"https://huggingface.co/datasets/{config.STORE_REPO}"
43
+ SPACE_URL = f"https://huggingface.co/spaces/{config.SPACE_REPO}"
44
+ ORG_URL = "https://huggingface.co/Bit-Trading-Company"
45
+
46
+ FOOTER_RESOURCES = (
47
+ ("Signal store", STORE_URL),
48
+ ("Backtest Lab", "https://huggingface.co/spaces/Bit-Trading-Company/bit-backtest-lab"),
49
+ ("Finance Atlas", "https://huggingface.co/spaces/Bit-Trading-Company/bit-finance-atlas"),
50
+ ("Hugging Face org", ORG_URL),
51
+ )
52
+
53
+ # Adapter families offered in the enrollment form, labelled by what they emit.
54
+ FAMILY_LABELS = {
55
+ "kronos": "Kronos · OHLCV paths",
56
+ "chronos": "Chronos · quantile line",
57
+ "timesfm": "TimesFM · quantile line",
58
+ "baseline": "Baseline · reference",
59
+ }
60
+
61
+
62
+ # --------------------------------------------------------------------------
63
+ # Boot
64
+ # --------------------------------------------------------------------------
65
+
66
+ STORE = ArenaStore(
67
+ local_root=os.environ.get("ARENA_LOCAL_STORE") or None,
68
+ offline=bool(os.environ.get("ARENA_OFFLINE")),
69
+ )
70
+
71
+ NAV = bit_nav.load(local_path=os.environ.get("ARENA_LOCAL_NAV") or None)
72
+
73
+
74
+ def _boot() -> dict:
75
+ """Read everything the UI needs once, at start-up.
76
+
77
+ A failure here must not stop the Space from booting: an empty registry
78
+ renders the designed empty states, which is a far better outcome than a
79
+ Space that will not start because the dataset was briefly unreachable.
80
+ """
81
+ try:
82
+ registry = STORE.get_registry()
83
+ except Exception as e: # pragma: no cover
84
+ log.warning("registry unavailable: %s", e)
85
+ registry = {"models": {}}
86
+ try:
87
+ standings = STORE.get_standings()
88
+ except Exception as e: # pragma: no cover
89
+ log.warning("standings unavailable: %s", e)
90
+ standings = pd.DataFrame()
91
+ try:
92
+ trackrecord = STORE.all_trackrecords()
93
+ except Exception as e: # pragma: no cover
94
+ log.warning("track record unavailable: %s", e)
95
+ trackrecord = pd.DataFrame()
96
+ return {"registry": registry, "standings": standings,
97
+ "trackrecord": trackrecord,
98
+ "summary": view.Summary(standings, trackrecord)}
99
+
100
+
101
+ BOOT = _boot()
102
+
103
+
104
+ def default_state() -> dict:
105
+ state = view.default_state()
106
+ entries = BOOT["registry"].get("models", {})
107
+ # Open on a CPU-tier foundation model: an anonymous visitor's first click
108
+ # must work without a sign-in, and landing on a baseline would show the
109
+ # reference rather than the thing people came to see.
110
+ ranked = sorted(
111
+ entries,
112
+ key=lambda s: (
113
+ entries[s].get("capabilities", {}).get("hardware") != "cpu",
114
+ entries[s].get("family") == "baseline",
115
+ s,
116
+ ))
117
+ state["selected"] = ranked[:1]
118
+ return state
119
+
120
+
121
+ # --------------------------------------------------------------------------
122
+ # Data assembly
123
+ # --------------------------------------------------------------------------
124
+
125
+
126
+ def model_rows(registry: dict) -> list[dict]:
127
+ """The picker's cards, in a stable order."""
128
+ out = []
129
+ for slug, entry in sorted(registry.get("models", {}).items()):
130
+ caps = entry.get("capabilities", {})
131
+ stand = BOOT["summary"].standing(slug, "BTC-USD")
132
+ grade = str(stand.get("grade", "-")) if stand else "-"
133
+ resolved = stand.get("resolved_count") if stand else None
134
+ out.append({
135
+ "slug": slug,
136
+ "name": entry.get("display", slug),
137
+ "family": entry.get("family", ""),
138
+ "size": (entry.get("components", {}).get("params")
139
+ or _size_hint(entry) or ""),
140
+ "caps": caps,
141
+ "color": "var(--accent-amber-strong)",
142
+ "grade": grade,
143
+ "grade_color": view.grade_color(grade),
144
+ "resolved_label": (f"{view.count(resolved)} RESOLVED"
145
+ if resolved else "UNRESOLVED"),
146
+ })
147
+ return out
148
+
149
+
150
+ def _size_hint(entry: dict) -> str:
151
+ """Parameter count, if the adapter family recorded one."""
152
+ from src.adapters.chronos import CHRONOS_MODELS
153
+ from src.adapters.kronos import KRONOS_MODELS
154
+ from src.adapters.timesfm import TIMESFM_MODELS
155
+
156
+ for table in (KRONOS_MODELS, CHRONOS_MODELS, TIMESFM_MODELS):
157
+ spec = table.get(entry.get("model_id", ""))
158
+ if spec and spec.get("params"):
159
+ return spec["params"]
160
+ return ""
161
+
162
+
163
+ def build_data(state: dict) -> dict:
164
+ """Everything the renderer needs. Pure with respect to the UI."""
165
+ registry = BOOT["registry"]
166
+ summary = BOOT["summary"]
167
+ models = model_rows(registry)
168
+ by_slug = {m["slug"]: m for m in models}
169
+
170
+ runs = [r for r in state.get("_runs", []) if r is not None]
171
+
172
+ panels = {}
173
+ for slug in state["selected"]:
174
+ panels[slug] = summary.panel(slug, state["asset"], state["tf"],
175
+ state["horizon"])
176
+
177
+ history = state.get("_history")
178
+ drawing = {}
179
+ if runs and history is not None and len(history):
180
+ drawing = chart.build(history, runs, state["asset"], state["horizon"],
181
+ show_ghosts=state.get("ghosts", True))
182
+
183
+ vol = view.volatility(runs[0] if runs else None,
184
+ history if history is not None else pd.DataFrame(),
185
+ state["tf"])
186
+
187
+ # Anonymous visitors get CPU-tier models only; a GPU-tier pick is blocked
188
+ # with the designed banner rather than failing after a long wait.
189
+ picked_gpu = any(by_slug.get(s, {}).get("caps", {}).get("hardware") == "gpu"
190
+ for s in state["selected"])
191
+ quota_blocked = picked_gpu and not state.get("signed_in")
192
+
193
+ archived, resolved = _totals()
194
+
195
+ return {
196
+ "models": models,
197
+ "models_by_slug": by_slug,
198
+ "panels": panels,
199
+ "runs": runs,
200
+ "chart": drawing,
201
+ "volatility": vol,
202
+ "stats": view.headline_stats(registry, BOOT["standings"],
203
+ archived, resolved),
204
+ "standings_rows": view.standings_rows(BOOT["standings"], registry,
205
+ state["cls"], state["selected"]),
206
+ "coverage": _coverage(state, summary),
207
+ "families": tuple(FAMILY_LABELS[f] for f in ALLOWED_ADAPTER_FAMILIES
208
+ if f in FAMILY_LABELS),
209
+ "nav": NAV,
210
+ "archived_total": archived,
211
+ "resolved_total": resolved,
212
+ "resolver_last_ran": _resolver_last_ran(),
213
+ "runtime_hint": _runtime_hint(state, by_slug, quota_blocked),
214
+ "quota_blocked": quota_blocked,
215
+ "quota_msg": "GPU-tier model — sign in to use your own quota",
216
+ "login_url": SPACE_URL,
217
+ "duplicate_url": f"{SPACE_URL}?duplicate=true",
218
+ "store_url": STORE_URL,
219
+ "standings_url": f"{STORE_URL}/blob/main/{config.STANDINGS_PATH}",
220
+ "methodology_url": f"{SPACE_URL}/blob/main/README.md",
221
+ "footer_resources": FOOTER_RESOURCES,
222
+ "status_line": _status_line(),
223
+ "error": state.get("error"),
224
+ }
225
+
226
+
227
+ def _totals() -> tuple[int | None, int | None]:
228
+ tr = BOOT["trackrecord"]
229
+ resolved = int(tr["forecast_id"].nunique()) if len(tr) else 0
230
+ stand = BOOT["standings"]
231
+ archived = (int(stand["forecast_count"].sum()) if len(stand)
232
+ and "forecast_count" in stand else resolved)
233
+ return archived, resolved
234
+
235
+
236
+ def _resolver_last_ran() -> str:
237
+ tr = BOOT["trackrecord"]
238
+ if not len(tr) or "resolved_ts" not in tr:
239
+ return "NEVER"
240
+ try:
241
+ return pd.to_datetime(tr["resolved_ts"], utc=True).max() \
242
+ .strftime("%Y-%m-%d %H:%M UTC")
243
+ except Exception: # pragma: no cover
244
+ return "—"
245
+
246
+
247
+ def _status_line() -> str:
248
+ tier = "ZEROGPU" if os.environ.get("SPACES_ZERO_GPU") else "CPU BASIC"
249
+ return f"SYSTEM STATUS: OPERATIONAL · HARDWARE {tier} · STORE {config.STORE_REPO}"
250
+
251
+
252
+ def _runtime_hint(state: dict, by_slug: dict, blocked: bool) -> str:
253
+ if blocked:
254
+ return "BLOCKED"
255
+ total, unknown = 0.0, False
256
+ for slug in state["selected"]:
257
+ entry = BOOT["registry"].get("models", {}).get(slug, {})
258
+ measured = (entry.get("latency") or {}).get("warm_s")
259
+ if measured is None:
260
+ unknown = True
261
+ else:
262
+ total += float(measured)
263
+ if unknown or not state["selected"]:
264
+ return "TIMING UNKNOWN"
265
+ return f"~{max(1, round(total))}S"
266
+
267
+
268
+ def _coverage(state: dict, summary: view.Summary) -> dict:
269
+ """How much archived history exists for the current selection."""
270
+ slug = state["selected"][0] if state["selected"] else None
271
+ if not slug:
272
+ return {"scope": "", "pct_label": view.DASH, "pct_width": "0%", "rows": []}
273
+ rows = summary.scoped(slug, state["asset"], state["tf"])
274
+ name = BOOT["registry"].get("models", {}).get(slug, {}).get("display", slug)
275
+ resolved = int(rows["forecast_id"].nunique()) if len(rows) else 0
276
+ target = 296 # the archive depth a full backfill would produce
277
+ share = min(1.0, resolved / target) if target else 0.0
278
+ oldest = (pd.to_datetime(rows["issued_ts"], utc=True).min().strftime("%Y-%m-%d")
279
+ if len(rows) else view.DASH)
280
+ return {
281
+ "scope": f"Archived coverage · {name} · {state['asset']} {state['tf']}",
282
+ "pct_label": view.pct(share),
283
+ "pct_width": f"{share * 100:.0f}%",
284
+ "rows": [
285
+ ("RESOLVED WINDOWS", f"{resolved} OF {target}"),
286
+ ("OLDEST ENTRY", oldest),
287
+ ("BACKFILLED", view.pct(float(rows["backfilled"].mean())
288
+ if len(rows) else None)),
289
+ ],
290
+ }
291
+
292
+
293
+ # --------------------------------------------------------------------------
294
+ # Actions
295
+ # --------------------------------------------------------------------------
296
+
297
+
298
+ def apply_action(state: dict, raw: str) -> dict:
299
+ """Fold one action into the state. Every value is validated."""
300
+ parsed = chrome.parse_action(raw)
301
+ if not parsed:
302
+ return state
303
+ key, value = parsed.key, parsed.value
304
+ # `parse_action` strips the nonce; it is recovered here because the
305
+ # duplicate-delivery guard below needs it. Two event paths mean one click
306
+ # can arrive twice, and "run" is expensive enough that acting on both
307
+ # would cost a second forecast.
308
+ nonce = raw.split(bridge.NONCE_SEP, 1)[1] if bridge.NONCE_SEP in raw else ""
309
+
310
+ # Two delivery paths mean one click can arrive twice. Folding a duplicate
311
+ # to a no-op keeps a double-delivered "run" from costing two forecasts.
312
+ if nonce and nonce == state.get("nonce"):
313
+ return state
314
+ state = dict(state)
315
+ state["nonce"] = nonce
316
+ state["error"] = None
317
+
318
+ if key == "mode" and value in ("playground", "matchup"):
319
+ state["mode"] = value
320
+ if value == "playground" and len(state["selected"]) > 1:
321
+ state["selected"] = state["selected"][:1]
322
+ state["_runs"] = []
323
+
324
+ elif key == "pick":
325
+ state = _pick(state, value)
326
+
327
+ elif key == "asset" and value in config.ASSETS:
328
+ state["asset"], state["_runs"] = value, []
329
+
330
+ elif key == "tf" and value in config.TIMEFRAMES:
331
+ state["tf"] = value
332
+ state["horizon"] = config.DEFAULT_HORIZON.get(value, 24)
333
+ state["_runs"] = []
334
+
335
+ elif key == "hz":
336
+ try:
337
+ h = int(value)
338
+ except (TypeError, ValueError):
339
+ return state
340
+ if 1 <= h <= config.MAX_HORIZON.get(state["tf"], 96):
341
+ state["horizon"], state["_runs"] = h, []
342
+
343
+ elif key == "gear":
344
+ state["gear"] = not state.get("gear")
345
+
346
+ elif key == "ghosts":
347
+ state["ghosts"] = not state.get("ghosts", True)
348
+
349
+ elif key == "cls" and value in ("All", "Crypto", "Equities"):
350
+ state["cls"] = value
351
+
352
+ elif key == "stand" and value in BOOT["registry"].get("models", {}):
353
+ state["mode"], state["selected"], state["_runs"] = "playground", [value], []
354
+
355
+ elif key == "hfid":
356
+ state["hfid"] = str(value)[:120]
357
+
358
+ elif key == "family":
359
+ for slug, label in FAMILY_LABELS.items():
360
+ if value in (slug, label):
361
+ state["family"] = label
362
+ break
363
+
364
+ elif key == "enroll":
365
+ state = _enroll(state) if value == "run" else state
366
+
367
+ elif key == "run":
368
+ state = _run(state)
369
+
370
+ elif key == "backfill":
371
+ state["backfill_requested"] = True
372
+
373
+ elif key == "dismiss":
374
+ state["error"] = None
375
+
376
+ return state
377
+
378
+
379
+ def _pick(state: dict, slug: str) -> dict:
380
+ if slug not in BOOT["registry"].get("models", {}):
381
+ return state
382
+ if state["mode"] == "playground":
383
+ state["selected"] = [slug]
384
+ else:
385
+ current = list(state["selected"])
386
+ if slug in current:
387
+ if len(current) > 1:
388
+ current.remove(slug)
389
+ elif len(current) < view.MAX_MATCHUP:
390
+ current.append(slug)
391
+ state["selected"] = current
392
+ state["_runs"] = []
393
+ return state
394
+
395
+
396
+ def _run(state: dict) -> dict:
397
+ """Issue and archive a forecast for every selected model."""
398
+ if not state["selected"]:
399
+ return state
400
+
401
+ if (not state.get("signed_in")
402
+ and state.get("session_forecasts", 0) >= config.ANON_SESSION_CAP):
403
+ state["error"] = {
404
+ "kind": "quota",
405
+ "title": "Session limit reached",
406
+ "message": (f"Anonymous sessions are capped at "
407
+ f"{config.ANON_SESSION_CAP} forecasts so the shared CPU "
408
+ f"stays responsive. Sign in with Hugging Face to keep going "
409
+ f"on your own quota."),
410
+ "login_url": SPACE_URL,
411
+ }
412
+ return state
413
+
414
+ runs, history = [], None
415
+ for slug in state["selected"]:
416
+ try:
417
+ run = runtime.run_forecast(
418
+ STORE, slug, state["asset"], state["tf"],
419
+ horizon=state["horizon"], n_samples=state["n_samples"],
420
+ seed=state["seed"], registry=BOOT["registry"])
421
+ except runtime.ForecastUnavailable as e:
422
+ state["error"] = {
423
+ "kind": e.kind,
424
+ "title": ("No price history" if e.kind == "no_data"
425
+ else "Model could not be loaded"),
426
+ "message": str(e),
427
+ "duplicate_url": f"{SPACE_URL}?duplicate=true",
428
+ }
429
+ continue
430
+ except Exception as e: # pragma: no cover
431
+ log.exception("forecast failed for %s", slug)
432
+ state["error"] = {"kind": "load_failure",
433
+ "title": "Forecast failed", "message": str(e)[:300],
434
+ "duplicate_url": f"{SPACE_URL}?duplicate=true"}
435
+ continue
436
+ runs.append(run)
437
+ history = run.context
438
+
439
+ if runs:
440
+ state["_runs"] = runs
441
+ state["_history"] = history
442
+ state["session_forecasts"] = state.get("session_forecasts", 0) + len(runs)
443
+ state["log"] = _log_entries(runs, state) + list(state.get("log", []))
444
+ _flush_archive()
445
+ return state
446
+
447
+
448
+ def _log_entries(runs, state) -> list[dict]:
449
+ name = (", ".join(r.model_slug for r in runs) if len(runs) > 1
450
+ else runs[0].model_slug)
451
+ return [{
452
+ "id": runs[0].forecast_id or "",
453
+ "time": now_utc().strftime("%H:%M"),
454
+ "name": f"{name} · {state['asset']}",
455
+ "meta": (f"{state['tf'].upper()} · H+{state['horizon']} · "
456
+ f"SEED {state['seed']}"),
457
+ }]
458
+
459
+
460
+ def _flush_archive() -> None:
461
+ """Push staged archive rows. A push failure must not lose the forecast."""
462
+ try:
463
+ pushed = STORE.flush("arena: archive forecasts")
464
+ if pushed:
465
+ log.info("archived %d file(s) to the store", pushed)
466
+ except Exception as e: # pragma: no cover
467
+ # The rows are already in the local mirror; the next flush retries.
468
+ log.warning("could not push the archive: %s", e)
469
+
470
+
471
+ def _enroll(state: dict) -> dict:
472
+ family = next((slug for slug, label in FAMILY_LABELS.items()
473
+ if label == state.get("family")), state.get("family", ""))
474
+ outcome = runtime.enroll(STORE, family, state.get("hfid", ""),
475
+ enrolled_by=state.get("user") or "anonymous",
476
+ registry=BOOT["registry"])
477
+ state["enroll_ok"] = bool(outcome.ok)
478
+ state["enroll_note"] = outcome.message
479
+ if outcome.ok and not outcome.already:
480
+ _flush_archive()
481
+ return state
482
+
483
+
484
+ # --------------------------------------------------------------------------
485
+ # Render
486
+ # --------------------------------------------------------------------------
487
+
488
+
489
+ def render(state: dict) -> str:
490
+ return shell.page(state, build_data(state))
491
+
492
+
493
+ def on_action(raw: str, state: dict):
494
+ state = apply_action(state or default_state(), raw or "")
495
+ return render(state), state
496
+
497
+
498
+ # --------------------------------------------------------------------------
499
+ # App
500
+ # --------------------------------------------------------------------------
501
+
502
+
503
+ def build_app() -> gr.Blocks:
504
+ with gr.Blocks(
505
+ title="Forecast Arena — The Bit Trading Company",
506
+ theme=theme.bit_theme(),
507
+ # chrome.full_css(), not theme.full_css(): the latter is bit-ui's
508
+ # shared CSS only and silently drops the Arena's own zone layout,
509
+ # card and chart rules.
510
+ css=chrome.full_css(),
511
+ analytics_enabled=False,
512
+ fill_width=True,
513
+ ) as demo:
514
+ state = gr.State(default_state())
515
+ view_html = gr.HTML(render(default_state()), elem_id="bit-view")
516
+
517
+ # Both must be rendered: `visible=False` removes an element from the
518
+ # DOM entirely and the delegated listener would write into nothing.
519
+ # They are hidden in CSS instead.
520
+ action = gr.Textbox("", elem_id=bridge.ACTION_ELEMENT_ID, label="",
521
+ show_label=False, container=False, interactive=True)
522
+ trigger = gr.Button("", elem_id=bridge.TRIGGER_ELEMENT_ID)
523
+
524
+ action.change(on_action, [action, state], [view_html, state],
525
+ show_progress="hidden")
526
+ # `api_name` keeps this reachable from `gradio_client`, which is how CI
527
+ # smoke-tests the deployed Space: a green build only proves the image
528
+ # started, not that an action still renders a page.
529
+ trigger.click(on_action, [action, state], [view_html, state],
530
+ show_progress="hidden", api_name="action")
531
+
532
+ # Installed via its own `load` with no outputs: Gradio treats a `js=`
533
+ # return value as the output values, so sharing a load that has outputs
534
+ # would wipe them. Spaces also drops `gr.Blocks(head=...)`.
535
+ demo.load(fn=None, inputs=None, outputs=None, js=bridge.BRIDGE_LOAD_JS)
536
+
537
+ return demo
538
+
539
+
540
+ demo = build_app()
541
+
542
+ if __name__ == "__main__":
543
+ demo.launch(
544
+ server_name="0.0.0.0" if ON_SPACE else "127.0.0.1",
545
+ server_port=int(os.environ.get("PORT", 7860)),
546
+ show_api=False,
547
+ allowed_paths=theme.static_paths(),
548
+ # Gradio 5 defaults to SSR. The page then renders server-side and only
549
+ # becomes interactive once the client hydrates -- and when hydration
550
+ # does not complete, every handler stays unbound: the markup is all
551
+ # there, the buttons look right, and nothing does anything, with no
552
+ # console error. This app renders its own markup and needs only the
553
+ # event wiring, so SSR buys nothing and costs that failure mode.
554
+ ssr_mode=False,
555
+ )
pytest.ini ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ [pytest]
2
+ testpaths = tests
3
+ pythonpath = . ..
4
+ addopts = -q --tb=short
5
+ markers =
6
+ slow: needs model weights or network; excluded from the CI gate
7
+ filterwarnings =
8
+ ignore::DeprecationWarning
requirements.txt ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Forecast Arena -- pinned to the exact versions the suite passes on, resolved
2
+ # against Python 3.10, which is what the Space image provides.
3
+ #
4
+ # Two rules govern this file:
5
+ #
6
+ # * ZeroGPU accepts only torch 2.11.0 / 2.10.0 / 2.9.1 / 2.8.0. A version
7
+ # outside that list is a CONFIG_ERROR at build time, not a warning.
8
+ # * `spaces` is injected by the ZeroGPU builder itself. Pinning it here
9
+ # conflicts with the builder's own copy, so it is deliberately absent.
10
+ #
11
+ # Everything under "Forecast adapters" is imported lazily inside
12
+ # `ForecastAdapter.load()`, so booting the app never pays for it. That is what
13
+ # keeps the cold start inside budget while still letting a GPU path work when
14
+ # hardware is attached.
15
+
16
+ # UI
17
+ gradio[oauth]==5.49.1
18
+ plotly==6.9.0
19
+
20
+ # Store and data
21
+ huggingface_hub==1.27.0
22
+ pandas==2.3.3
23
+ pyarrow==25.0.1
24
+ numpy==2.2.6
25
+
26
+ # Price providers -- batch refresh only, never in the request path.
27
+ ccxt==4.5.64
28
+ yfinance==1.6.0
29
+ requests==2.34.2
30
+
31
+ # Forecast adapters.
32
+ torch==2.11.0
33
+ chronos-forecasting==2.3.1
34
+ # TimesFM 2.5. The model card still says "pip install coming soon" and points
35
+ # at a git clone; that is stale -- 2.0.2 exports TimesFM_2p5_200M_torch and
36
+ # installs with only numpy/safetensors/huggingface_hub/torch.
37
+ timesfm==2.0.2
38
+ # Kronos ships no inference package at all (the `kronos` name on PyPI is an
39
+ # unrelated Django library). Its source is vendored under vendor/kronos; these
40
+ # are what that source imports.
41
+ einops==0.8.1
42
+ safetensors==0.6.2
scripts/bootstrap_registry.py ADDED
@@ -0,0 +1,175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Enroll the v1 model line-up and write `arena/registry.json`.
2
+
3
+ Every model goes through the same `runtime.enroll` path a user would: the
4
+ revision is pinned to an immutable sha, a smoke test runs, and the result is
5
+ recorded. Nothing is registered that has not actually produced a forecast.
6
+
7
+ Latency is measured here too, and a CPU-tier model that misses its budget is
8
+ demoted to GPU tier in the registry rather than left to time out in front of
9
+ someone. The measurement is only meaningful on the hardware it was taken on,
10
+ so the machine is recorded alongside the number -- run this on the Space to
11
+ get numbers that describe the Space.
12
+
13
+ HF_TOKEN=$(cat ../../key.txt) python scripts/bootstrap_registry.py
14
+ python scripts/bootstrap_registry.py --dry-run # no writes
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import json
21
+ import logging
22
+ import os
23
+ import platform
24
+ import sys
25
+ import time
26
+ from pathlib import Path
27
+
28
+ import numpy as np
29
+ import pandas as pd
30
+
31
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
32
+
33
+ from src import config, runtime # noqa: E402
34
+ from src.adapters import get_adapter # noqa: E402
35
+ from src.store import ArenaStore, now_utc # noqa: E402
36
+
37
+ logging.basicConfig(level=logging.INFO, format="%(levelname)-7s %(message)s")
38
+ log = logging.getLogger("bootstrap")
39
+
40
+ # The v1 line-up. Order is the order they appear in the UI.
41
+ SEED_MODELS = [
42
+ ("baseline", "baseline/random-walk"),
43
+ ("baseline", "baseline/drift"),
44
+ ("baseline", "baseline/bootstrap"),
45
+ ("chronos", "amazon/chronos-bolt-tiny"),
46
+ ("chronos", "amazon/chronos-bolt-mini"),
47
+ ("chronos", "amazon/chronos-bolt-small"),
48
+ ("chronos", "amazon/chronos-bolt-base"),
49
+ ("timesfm", "google/timesfm-2.5-200m-pytorch"),
50
+ ("kronos", "NeoQuasar/Kronos-mini"),
51
+ ("kronos", "NeoQuasar/Kronos-small"),
52
+ ("kronos", "NeoQuasar/Kronos-base"),
53
+ ]
54
+
55
+ # The forecast the budget is defined against: a default 1h forecast.
56
+ BUDGET_HORIZON = 24
57
+ BUDGET_SAMPLES = config.DEFAULT_N_SAMPLES
58
+
59
+
60
+ def _synthetic(n=config.DEFAULT_CONTEXT_BARS):
61
+ rng = np.random.default_rng(7)
62
+ close = 50000.0 * np.exp(np.cumsum(rng.normal(0, 0.01, n)))
63
+ openp = np.concatenate([[50000.0], close[:-1]])
64
+ return pd.DataFrame({
65
+ "ts": pd.date_range("2025-01-01", periods=n, freq="1h", tz="UTC"),
66
+ "open": openp,
67
+ "high": np.maximum(openp, close) * 1.002,
68
+ "low": np.minimum(openp, close) * 0.998,
69
+ "close": close,
70
+ "volume": np.abs(rng.normal(1000, 200, n)) + 1.0,
71
+ })
72
+
73
+
74
+ def measure(family: str, model_id: str, context) -> dict:
75
+ """Cold and warm latency for a default forecast."""
76
+ adapter = get_adapter(family, model_id)
77
+ # Measure on the context the app actually sends, not on the model's own
78
+ # ceiling: the cost is roughly linear in context length, so measuring at
79
+ # `max_context` would report a number no user ever waits for.
80
+ context = context.iloc[-min(len(context), config.DEFAULT_CONTEXT_BARS):]
81
+
82
+ t0 = time.time()
83
+ adapter.load()
84
+ load_s = time.time() - t0
85
+
86
+ t0 = time.time()
87
+ adapter.predict(context, horizon=BUDGET_HORIZON,
88
+ n_samples=BUDGET_SAMPLES, seed=0)
89
+ cold_s = time.time() - t0
90
+
91
+ warm = []
92
+ for _ in range(3):
93
+ t0 = time.time()
94
+ adapter.predict(context, horizon=BUDGET_HORIZON,
95
+ n_samples=BUDGET_SAMPLES, seed=0)
96
+ warm.append(time.time() - t0)
97
+ warm_s = float(np.median(warm))
98
+
99
+ declared = adapter.capabilities().hardware
100
+ # "Cold" for a user is load plus first forecast: the weights are not in
101
+ # memory when they arrive.
102
+ total_cold = load_s + cold_s
103
+ misses = declared == "cpu" and (
104
+ total_cold > config.CPU_BUDGET_COLD_S or warm_s > config.CPU_BUDGET_WARM_S)
105
+
106
+ return {
107
+ "declared_hardware": declared,
108
+ "hardware": "gpu" if misses else declared,
109
+ "demoted": bool(misses),
110
+ "load_s": round(load_s, 3),
111
+ "cold_s": round(total_cold, 3),
112
+ "warm_s": round(warm_s, 3),
113
+ "horizon": BUDGET_HORIZON,
114
+ "n_samples": BUDGET_SAMPLES,
115
+ "machine": f"{platform.system()}-{platform.machine()}-py{platform.python_version()}",
116
+ "on_space": bool(os.environ.get("SPACE_ID")),
117
+ "measured_ts": now_utc().isoformat(),
118
+ }
119
+
120
+
121
+ def main() -> int:
122
+ ap = argparse.ArgumentParser()
123
+ ap.add_argument("--dry-run", action="store_true",
124
+ help="measure and print, write nothing")
125
+ ap.add_argument("--skip-measure", action="store_true",
126
+ help="enroll without a latency run (declared tiers stand)")
127
+ ap.add_argument("--local-root", default=None)
128
+ args = ap.parse_args()
129
+
130
+ offline = args.dry_run or not os.environ.get("HF_TOKEN")
131
+ store = ArenaStore(local_root=args.local_root, offline=offline)
132
+ registry = store.get_registry()
133
+ context = _synthetic()
134
+
135
+ for family, model_id in SEED_MODELS:
136
+ log.info("enrolling %s (%s)", model_id, family)
137
+ outcome = runtime.enroll(store, family, model_id,
138
+ enrolled_by="bit-trading-company",
139
+ registry=registry)
140
+ if not outcome.ok:
141
+ log.error(" refused: %s", outcome.message)
142
+ continue
143
+ if outcome.already:
144
+ log.info(" already enrolled at this revision")
145
+
146
+ entry = registry["models"][outcome.model_slug]
147
+ if not args.skip_measure:
148
+ try:
149
+ latency = measure(family, model_id, context)
150
+ entry["latency"] = latency
151
+ entry["capabilities"]["hardware"] = latency["hardware"]
152
+ flag = " DEMOTED to gpu" if latency["demoted"] else ""
153
+ log.info(" cold=%.1fs warm=%.2fs tier=%s%s",
154
+ latency["cold_s"], latency["warm_s"],
155
+ latency["hardware"], flag)
156
+ except Exception as e:
157
+ log.error(" latency run failed: %s", e)
158
+ entry["latency"] = {"error": str(e)[:200]}
159
+
160
+ registry["updated_ts"] = now_utc().isoformat()
161
+ registry["version"] = 1
162
+
163
+ if args.dry_run:
164
+ print(json.dumps(registry, indent=2)[:4000])
165
+ log.info("dry run: nothing written")
166
+ return 0
167
+
168
+ store.put_registry(registry)
169
+ pushed = store.flush("arena: bootstrap model registry")
170
+ log.info("registry written (%d file(s) pushed)", pushed)
171
+ return 0
172
+
173
+
174
+ if __name__ == "__main__":
175
+ raise SystemExit(main())
scripts/daily_job.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The scheduled job: refresh prices, resolve what has matured, rebuild standings.
2
+
3
+ Run daily. Each step is idempotent, so a re-run after a failure is safe and a
4
+ missed day is caught up by the next run rather than lost.
5
+
6
+ Deliberately *not* part of the code deploy. The store holds forecasts that cost
7
+ real compute -- some of them contributed by signed-in users on their own quota
8
+ -- and a stale checkout overwriting that would be unrecoverable. This job only
9
+ appends.
10
+
11
+ HF_TOKEN=... python scripts/daily_job.py
12
+ python scripts/daily_job.py --skip-prices # resolve only
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import logging
19
+ import os
20
+ import sys
21
+ from pathlib import Path
22
+
23
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
24
+
25
+ from src import config, prices as price_feed, trackrecord # noqa: E402
26
+ from src.store import ArenaStore, now_utc # noqa: E402
27
+
28
+ logging.basicConfig(level=logging.INFO,
29
+ format="%(asctime)s %(levelname)-7s %(message)s")
30
+ log = logging.getLogger("daily")
31
+
32
+
33
+ def main() -> int:
34
+ ap = argparse.ArgumentParser()
35
+ ap.add_argument("--skip-prices", action="store_true")
36
+ ap.add_argument("--local-root", default=None)
37
+ ap.add_argument("--dry-run", action="store_true")
38
+ args = ap.parse_args()
39
+
40
+ if not os.environ.get("HF_TOKEN") and not args.dry_run:
41
+ log.error("HF_TOKEN is not set")
42
+ return 2
43
+
44
+ store = ArenaStore(local_root=args.local_root, offline=args.dry_run)
45
+ registry = store.get_registry()
46
+ models = sorted(registry.get("models", {}))
47
+ if not models:
48
+ log.warning("registry is empty; nothing to resolve")
49
+ return 0
50
+
51
+ # -- 1. prices ---------------------------------------------------------
52
+ if not args.skip_prices:
53
+ for asset in config.SEED_ASSETS:
54
+ for tf in config.SEED_TIMEFRAMES:
55
+ try:
56
+ added = price_feed.refresh(store, asset, tf)
57
+ if added:
58
+ log.info("prices %s %s: +%d bars", asset, tf, added)
59
+ except Exception as e:
60
+ # A provider outage must not stop the resolver from
61
+ # scoring everything that already has prices.
62
+ log.warning("price refresh failed for %s %s: %s", asset, tf, e)
63
+ if not args.dry_run:
64
+ store.flush("arena: refresh prices")
65
+
66
+ # -- 2. resolve --------------------------------------------------------
67
+ resolved_total = 0
68
+ for model_slug in models:
69
+ for asset in config.SEED_ASSETS:
70
+ for tf in config.SEED_TIMEFRAMES:
71
+ try:
72
+ rows = trackrecord.resolve(store, model_slug, asset, tf)
73
+ except Exception as e:
74
+ log.warning("resolve failed for %s %s %s: %s",
75
+ model_slug, asset, tf, e)
76
+ continue
77
+ if len(rows):
78
+ resolved_total += len(rows)
79
+ log.info("resolved %d rows for %s %s %s",
80
+ len(rows), model_slug, asset, tf)
81
+ log.info("resolved %d rows in total", resolved_total)
82
+
83
+ # -- 3. standings ------------------------------------------------------
84
+ standings = trackrecord.regenerate_standings(store, now=now_utc())
85
+ log.info("standings: %d rows", len(standings))
86
+
87
+ if args.dry_run:
88
+ log.info("dry run: nothing pushed")
89
+ return 0
90
+
91
+ pushed = store.flush("arena: daily resolve and standings")
92
+ log.info("pushed %d file(s)", pushed)
93
+ return 0
94
+
95
+
96
+ if __name__ == "__main__":
97
+ raise SystemExit(main())
scripts/seed_trackrecord.py ADDED
@@ -0,0 +1,190 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Backfill the track record by forecasting through recent history, honestly.
2
+
3
+ The point of a backfill is to give a new model a track record without waiting
4
+ months for one. The danger is that a backfill is trivially easy to fake: run
5
+ the model over history it has already seen, or let it peek at the bar it is
6
+ predicting, and it will look extraordinary.
7
+
8
+ So this script forecasts *as of* each historical timestamp. The context is
9
+ sliced at that timestamp by `runtime.load_context`, and the adapter re-checks
10
+ the slice against `issued_ts` on every call -- a context bar dated after the
11
+ issue moment raises rather than producing a flattering number. Nothing here
12
+ opts out of that check; there is no fast path that skips it.
13
+
14
+ Every row written carries `backfilled: true`, and the UI badges those entries
15
+ wherever they appear. A backfilled record is evidence of a kind, but it is not
16
+ the same evidence as a forecast made before the outcome existed, and the
17
+ difference is on the page rather than in a footnote.
18
+
19
+ HF_TOKEN=$(cat ../../key.txt) python scripts/seed_trackrecord.py
20
+ python scripts/seed_trackrecord.py --dry-run --models chronos-bolt-tiny
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import argparse
26
+ import json
27
+ import logging
28
+ import os
29
+ import sys
30
+ from pathlib import Path
31
+
32
+ import pandas as pd
33
+
34
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
35
+
36
+ from src import config, runtime, trackrecord # noqa: E402
37
+ from src.store import ArenaStore, now_utc # noqa: E402
38
+
39
+ logging.basicConfig(level=logging.INFO, format="%(levelname)-7s %(message)s")
40
+ log = logging.getLogger("backfill")
41
+
42
+ # How many forecasts to seed per (model, asset, timeframe).
43
+ TARGET_PER_SERIES = 60
44
+
45
+ # Space the issue points out so the windows do not all overlap: consecutive
46
+ # forecasts sharing 95% of their context would give 60 nearly identical
47
+ # observations and a coverage figure with far less evidence behind it than the
48
+ # count suggests.
49
+ STRIDE = {"1h": 12, "1d": 3}
50
+
51
+
52
+ def checkpoint_path(root: Path) -> Path:
53
+ return root / "backfill_checkpoint.json"
54
+
55
+
56
+ def load_checkpoint(root: Path) -> dict:
57
+ p = checkpoint_path(root)
58
+ if p.exists():
59
+ try:
60
+ return json.loads(p.read_text())
61
+ except Exception:
62
+ log.warning("unreadable checkpoint; starting fresh")
63
+ return {"done": {}}
64
+
65
+
66
+ def save_checkpoint(root: Path, state: dict) -> None:
67
+ checkpoint_path(root).parent.mkdir(parents=True, exist_ok=True)
68
+ checkpoint_path(root).write_text(json.dumps(state, indent=2, sort_keys=True))
69
+
70
+
71
+ def series_key(model: str, asset: str, tf: str) -> str:
72
+ return f"{model}|{asset}|{tf}"
73
+
74
+
75
+ def backfill_series(store: ArenaStore, model_slug: str, asset: str, tf: str,
76
+ registry: dict, target: int, horizon: int,
77
+ seed: int, dry_run: bool) -> int:
78
+ """Issue `target` as-of forecasts, oldest first. Returns rows archived."""
79
+ prices = store.get_prices(asset, tf)
80
+ if not len(prices):
81
+ log.warning(" no prices for %s %s", asset, tf)
82
+ return 0
83
+
84
+ ts = pd.to_datetime(prices["ts"], utc=True).reset_index(drop=True)
85
+ stride = STRIDE.get(tf, 6)
86
+
87
+ # Only issue points whose horizon has already elapsed -- an unresolved
88
+ # backfill row would sit in the archive forever without ever scoring.
89
+ latest_resolvable = len(ts) - horizon - 1
90
+ earliest = config.DEFAULT_CONTEXT_BARS + 1
91
+ if latest_resolvable <= earliest:
92
+ log.warning(" not enough history for %s %s (%d bars)", asset, tf, len(ts))
93
+ return 0
94
+
95
+ points = list(range(latest_resolvable, earliest, -stride))[:target]
96
+ points.reverse()
97
+
98
+ written = 0
99
+ for i, idx in enumerate(points):
100
+ as_of = ts.iloc[idx]
101
+ try:
102
+ run = runtime.run_forecast(
103
+ store, model_slug, asset, tf, horizon=horizon,
104
+ n_samples=config.DEFAULT_N_SAMPLES, seed=seed, as_of=as_of,
105
+ registry=registry, archive=not dry_run, backfilled=True)
106
+ except runtime.ForecastUnavailable as e:
107
+ log.warning(" %s: %s", as_of.date(), e)
108
+ continue
109
+ written += run.archived_rows
110
+ if (i + 1) % 10 == 0:
111
+ log.info(" %d/%d issued (%s)", i + 1, len(points), as_of.date())
112
+ return written
113
+
114
+
115
+ def main() -> int:
116
+ ap = argparse.ArgumentParser()
117
+ ap.add_argument("--dry-run", action="store_true")
118
+ ap.add_argument("--models", nargs="*", default=None,
119
+ help="model slugs; default is every CPU-tier enrolled model")
120
+ ap.add_argument("--assets", nargs="*", default=list(config.SEED_ASSETS))
121
+ ap.add_argument("--timeframes", nargs="*", default=list(config.SEED_TIMEFRAMES))
122
+ ap.add_argument("--target", type=int, default=TARGET_PER_SERIES)
123
+ ap.add_argument("--seed", type=int, default=42)
124
+ ap.add_argument("--include-gpu", action="store_true",
125
+ help="also seed GPU-tier models (slow on CPU)")
126
+ ap.add_argument("--local-root", default=None)
127
+ args = ap.parse_args()
128
+
129
+ store = ArenaStore(local_root=args.local_root,
130
+ offline=not os.environ.get("HF_TOKEN"))
131
+ registry = store.get_registry()
132
+ models = registry.get("models", {})
133
+ if not models:
134
+ log.error("registry is empty; run scripts/bootstrap_registry.py first")
135
+ return 1
136
+
137
+ if args.models:
138
+ chosen = [m for m in args.models if m in models]
139
+ missing = set(args.models) - set(chosen)
140
+ if missing:
141
+ log.error("not enrolled: %s", ", ".join(sorted(missing)))
142
+ return 1
143
+ else:
144
+ chosen = [slug for slug, entry in sorted(models.items())
145
+ if args.include_gpu
146
+ or entry.get("capabilities", {}).get("hardware") == "cpu"]
147
+
148
+ root = Path(store.local_root)
149
+ checkpoint = load_checkpoint(root)
150
+ total = 0
151
+
152
+ for model_slug in chosen:
153
+ log.info("model %s", model_slug)
154
+ for asset in args.assets:
155
+ for tf in args.timeframes:
156
+ key = series_key(model_slug, asset, tf)
157
+ if checkpoint["done"].get(key):
158
+ log.info(" %s %s already done, skipping", asset, tf)
159
+ continue
160
+ horizon = config.DEFAULT_HORIZON.get(tf, 24)
161
+ log.info(" %s %s (h=%d, target=%d)", asset, tf, horizon, args.target)
162
+ written = backfill_series(store, model_slug, asset, tf, registry,
163
+ args.target, horizon, args.seed,
164
+ args.dry_run)
165
+ total += written
166
+ if not args.dry_run:
167
+ checkpoint["done"][key] = {
168
+ "rows": written, "ts": now_utc().isoformat()}
169
+ save_checkpoint(root, checkpoint)
170
+ # Commit per series rather than at the end: a run that dies
171
+ # halfway must not throw away hours of forecasting.
172
+ store.flush(f"arena: backfill {model_slug} {asset} {tf}")
173
+
174
+ log.info("archived %d forecast rows", total)
175
+
176
+ if not args.dry_run:
177
+ log.info("resolving")
178
+ for model_slug in chosen:
179
+ for asset in args.assets:
180
+ for tf in args.timeframes:
181
+ trackrecord.resolve(store, model_slug, asset, tf)
182
+ trackrecord.regenerate_standings(store)
183
+ store.flush("arena: resolve backfill and rebuild standings")
184
+ log.info("standings rebuilt")
185
+
186
+ return 0
187
+
188
+
189
+ if __name__ == "__main__":
190
+ raise SystemExit(main())
src/__init__.py ADDED
File without changes
src/adapters/__init__.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The adapter allow-list and the factory that reaches it.
2
+
3
+ Enrolling a model means naming an adapter *family* and a Hub model id. The
4
+ family list is fixed in this file. Nothing here ever imports, downloads or
5
+ executes code chosen by a user: a user-supplied id is loaded only through an
6
+ already-vetted family's loader, and `trust_remote_code` is never set anywhere
7
+ in this package.
8
+
9
+ That is the whole security model for the "enroll a model" flow, and it is why
10
+ an unsupported family is a clear rejection rather than a best-effort attempt.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+
17
+ from .base import (HARDWARE_CPU, HARDWARE_GPU, OHLCV_COLUMNS,
18
+ OUTPUT_OHLCV_PATHS, OUTPUT_QUANTILE_LINE, AdapterError,
19
+ Capabilities, ContextError, ForecastAdapter,
20
+ ForecastResult, LookaheadError, ModelNotAllowed,
21
+ check_context, default_device, seed_everything)
22
+ from .baseline import BASELINE_MODELS, BaselineAdapter
23
+ from .chronos import CHRONOS_MODELS, ChronosAdapter
24
+ from .kronos import KRONOS_MODELS, KronosAdapter
25
+ from .timesfm import TIMESFM_MODELS, TimesFMAdapter
26
+
27
+ __all__ = [
28
+ "AdapterError", "Capabilities", "ContextError", "ForecastAdapter",
29
+ "ForecastResult", "LookaheadError", "ModelNotAllowed", "check_context",
30
+ "default_device", "seed_everything", "get_adapter", "validate_model_id",
31
+ "ALLOWED_ADAPTER_FAMILIES", "KNOWN_MODELS", "family_for",
32
+ "OHLCV_COLUMNS", "OUTPUT_OHLCV_PATHS", "OUTPUT_QUANTILE_LINE",
33
+ "HARDWARE_CPU", "HARDWARE_GPU",
34
+ ]
35
+
36
+ # The allow-list. Adding an entry here is the deliberate act of accepting a new
37
+ # code path; there is no dynamic discovery.
38
+ ADAPTERS: dict[str, type[ForecastAdapter]] = {
39
+ "kronos": KronosAdapter,
40
+ "chronos": ChronosAdapter,
41
+ "timesfm": TimesFMAdapter,
42
+ "baseline": BaselineAdapter,
43
+ }
44
+
45
+ ALLOWED_ADAPTER_FAMILIES = tuple(ADAPTERS)
46
+
47
+ # Every model this app ships knowing about, and the family that loads it. A
48
+ # user may enroll an id that is not on this list, but only under a family that
49
+ # is -- see `runtime.enroll`.
50
+ KNOWN_MODELS: dict[str, str] = {
51
+ **{mid: "kronos" for mid in KRONOS_MODELS},
52
+ **{mid: "chronos" for mid in CHRONOS_MODELS},
53
+ **{mid: "timesfm" for mid in TIMESFM_MODELS},
54
+ **{mid: "baseline" for mid in BASELINE_MODELS},
55
+ }
56
+
57
+ # `namespace/name`, the Hub's own shape. Anything else is not a model id and is
58
+ # rejected before it reaches a loader.
59
+ _MODEL_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,95}/[A-Za-z0-9][A-Za-z0-9._-]{0,95}$")
60
+
61
+
62
+ def validate_model_id(model_id: str) -> str:
63
+ """Accept a Hub model id, or raise.
64
+
65
+ Model ids arrive from the enrollment form and are user input. Validating
66
+ the shape here keeps path traversal and injection out of every downstream
67
+ consumer -- the store writes files under a slug derived from this.
68
+ """
69
+ if not isinstance(model_id, str):
70
+ raise AdapterError("model id must be a string")
71
+ model_id = model_id.strip()
72
+ if not _MODEL_ID.match(model_id):
73
+ raise AdapterError(
74
+ f"{model_id!r} is not a valid Hugging Face model id "
75
+ f"(expected 'namespace/name')"
76
+ )
77
+ if ".." in model_id:
78
+ raise AdapterError("model id may not contain '..'")
79
+ return model_id
80
+
81
+
82
+ def family_for(model_id: str) -> str | None:
83
+ """The family that ships support for this id, if any."""
84
+ return KNOWN_MODELS.get(model_id)
85
+
86
+
87
+ def get_adapter(family: str, model_id: str, revision: str | None = None,
88
+ **kwargs) -> ForecastAdapter:
89
+ """Build an adapter, or refuse.
90
+
91
+ Refusing is the important half: an unknown family is `ModelNotAllowed`
92
+ with the supported list in the message, never a silent fallback to a
93
+ generic loader.
94
+ """
95
+ fam = (family or "").strip().lower()
96
+ if fam not in ADAPTERS:
97
+ raise ModelNotAllowed(
98
+ f"adapter family {family!r} is not supported. "
99
+ f"Supported families: {', '.join(ALLOWED_ADAPTER_FAMILIES)}"
100
+ )
101
+ return ADAPTERS[fam](validate_model_id(model_id), revision=revision, **kwargs)
102
+
103
+
104
+ def model_slug(model_id: str) -> str:
105
+ """Store path segment for a model id.
106
+
107
+ `amazon/chronos-bolt-small` -> `chronos-bolt-small`. The namespace is
108
+ dropped because it is redundant with the registry entry, and the result is
109
+ checked against the same character class the id was, so it can never climb
110
+ out of the store's directory tree.
111
+ """
112
+ validate_model_id(model_id)
113
+ slug = model_id.split("/", 1)[1].lower()
114
+ slug = re.sub(r"[^a-z0-9._-]", "-", slug)
115
+ if not slug or slug in (".", ".."):
116
+ raise AdapterError(f"cannot derive a slug from {model_id!r}")
117
+ return slug
src/adapters/base.py ADDED
@@ -0,0 +1,409 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The adapter interface every forecasting model is reached through.
2
+
3
+ Three ideas carry most of the weight here.
4
+
5
+ **Capabilities are declared, not inferred.** An adapter says what kind of
6
+ output it produces, what hardware it needs, and how much context it can take.
7
+ The UI renders strictly from that declaration, so there is no place outside
8
+ this package where a model is special-cased by name. Adding a family is a new
9
+ module plus a registry entry, never an `if model == ...` in a renderer.
10
+
11
+ **Seeding is part of the interface.** `predict` takes a seed and is required to
12
+ be bit-reproducible under it. Some upstream models expose no seed argument at
13
+ all; those are wrapped so that their sampling is seeded anyway. A forecast that
14
+ cannot be reproduced cannot be audited, and an archive of unauditable forecasts
15
+ is not a track record.
16
+
17
+ **Contexts are validated, not trusted.** `check_context` runs on every call. It
18
+ is the structural guarantee behind the no-lookahead rule: a context bar dated
19
+ after the issue timestamp raises rather than quietly producing a forecast that
20
+ would look brilliant.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import hashlib
26
+ import logging
27
+ from abc import ABC, abstractmethod
28
+ from dataclasses import dataclass, field
29
+
30
+ import numpy as np
31
+ import pandas as pd
32
+
33
+ from .. import config
34
+
35
+ log = logging.getLogger("arena.adapters")
36
+
37
+ OHLCV_COLUMNS = ("open", "high", "low", "close", "volume")
38
+
39
+ # Output kinds. `ohlcv_paths` models emit whole sampled candlestick paths and
40
+ # can drive the ghost-path and dispersion views; `quantile_line` models emit a
41
+ # band on the close and nothing else.
42
+ OUTPUT_QUANTILE_LINE = "quantile_line"
43
+ OUTPUT_OHLCV_PATHS = "ohlcv_paths"
44
+ OUTPUT_KINDS = (OUTPUT_QUANTILE_LINE, OUTPUT_OHLCV_PATHS)
45
+
46
+ HARDWARE_CPU = "cpu"
47
+ HARDWARE_GPU = "gpu"
48
+ HARDWARE_TIERS = (HARDWARE_CPU, HARDWARE_GPU)
49
+
50
+
51
+ class AdapterError(RuntimeError):
52
+ """Anything an adapter refuses to do."""
53
+
54
+
55
+ class ModelNotAllowed(AdapterError):
56
+ """The requested adapter family is not on the allow-list."""
57
+
58
+
59
+ class LookaheadError(AdapterError):
60
+ """A context window reached past the moment the forecast is issued."""
61
+
62
+
63
+ class ContextError(AdapterError):
64
+ """The context window is unusable -- gaps, NaNs, or too short."""
65
+
66
+
67
+ # --------------------------------------------------------------------------
68
+ # Capabilities
69
+ # --------------------------------------------------------------------------
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class Capabilities:
74
+ """What a model can do, as the model itself declares it.
75
+
76
+ `asset_generality` is a plain-language claim about training scope, not a
77
+ quality score: "financial" means the model was pre-trained on market data,
78
+ "general" means arbitrary time series. It drives one honest caveat in the
79
+ UI and nothing else.
80
+ """
81
+
82
+ output: str
83
+ hardware: str
84
+ max_context: int
85
+ asset_generality: str = "general"
86
+ seedable_natively: bool = True
87
+
88
+ def __post_init__(self):
89
+ if self.output not in OUTPUT_KINDS:
90
+ raise AdapterError(f"unknown output kind {self.output!r}")
91
+ if self.hardware not in HARDWARE_TIERS:
92
+ raise AdapterError(f"unknown hardware tier {self.hardware!r}")
93
+ if self.max_context < 1:
94
+ raise AdapterError("max_context must be positive")
95
+
96
+ @property
97
+ def emits_paths(self) -> bool:
98
+ return self.output == OUTPUT_OHLCV_PATHS
99
+
100
+ def as_dict(self) -> dict:
101
+ return {
102
+ "output": self.output,
103
+ "hardware": self.hardware,
104
+ "max_context": self.max_context,
105
+ "asset_generality": self.asset_generality,
106
+ "seedable_natively": self.seedable_natively,
107
+ }
108
+
109
+
110
+ # --------------------------------------------------------------------------
111
+ # Result
112
+ # --------------------------------------------------------------------------
113
+
114
+
115
+ @dataclass
116
+ class ForecastResult:
117
+ """One multi-step forecast.
118
+
119
+ `quantiles` is (horizon, n_levels) over the *close* price, always present.
120
+ `paths` is (n_samples, horizon, 5) of OHLCV and is present only for
121
+ `ohlcv_paths` models -- the UI checks the capability, never this field, so
122
+ that a model which merely happens to return paths cannot change how it is
123
+ rendered.
124
+ """
125
+
126
+ quantiles: np.ndarray
127
+ levels: tuple[float, ...]
128
+ horizon: int
129
+ context_len: int
130
+ inference_version: str
131
+ seed: int
132
+ n_samples: int
133
+ paths: np.ndarray | None = None
134
+
135
+ def __post_init__(self):
136
+ self.quantiles = np.asarray(self.quantiles, dtype="float64")
137
+ if self.quantiles.shape != (self.horizon, len(self.levels)):
138
+ raise AdapterError(
139
+ f"quantiles shape {self.quantiles.shape} != "
140
+ f"{(self.horizon, len(self.levels))}"
141
+ )
142
+ if not np.all(np.isfinite(self.quantiles)):
143
+ raise AdapterError("forecast contains non-finite values")
144
+ # Quantiles must not cross. Sorting along the level axis is the honest
145
+ # repair for the tiny numerical inversions that sampling produces, and
146
+ # it is what makes the coverage arithmetic downstream well-defined.
147
+ self.quantiles = np.sort(self.quantiles, axis=1)
148
+ if self.paths is not None:
149
+ self.paths = np.asarray(self.paths, dtype="float64")
150
+ if self.paths.ndim != 3 or self.paths.shape[1] != self.horizon:
151
+ raise AdapterError(f"paths shape {self.paths.shape} is not (n, {self.horizon}, k)")
152
+
153
+ def level_index(self, level: float) -> int:
154
+ for i, lv in enumerate(self.levels):
155
+ if abs(lv - level) < 1e-9:
156
+ return i
157
+ raise AdapterError(f"level {level} not in {self.levels}")
158
+
159
+ def band(self, low: float = 0.1, high: float = 0.9):
160
+ return (self.quantiles[:, self.level_index(low)],
161
+ self.quantiles[:, self.level_index(high)])
162
+
163
+ def median(self) -> np.ndarray:
164
+ return self.quantiles[:, self.level_index(0.5)]
165
+
166
+ def dispersion(self) -> np.ndarray:
167
+ """Per-step spread, as a fraction of the median.
168
+
169
+ Sampled-path standard deviation where paths exist, band half-width
170
+ otherwise. Both are a width; they are not the same statistic, and the
171
+ UI labels which one it is showing.
172
+ """
173
+ med = np.abs(self.median()) + 1e-12
174
+ if self.paths is not None:
175
+ close = self.paths[:, :, OHLCV_COLUMNS.index("close")]
176
+ return close.std(axis=0) / med
177
+ lo, hi = self.band()
178
+ return (hi - lo) / 2.0 / med
179
+
180
+
181
+ # --------------------------------------------------------------------------
182
+ # Context validation
183
+ # --------------------------------------------------------------------------
184
+
185
+
186
+ def check_context(context: pd.DataFrame, issued_ts: pd.Timestamp | None = None,
187
+ min_len: int = 32) -> pd.DataFrame:
188
+ """Validate a context window, or raise.
189
+
190
+ This is the structural half of the no-lookahead guarantee. It is not a
191
+ convention that callers are asked to honour: every adapter runs it on every
192
+ call, so a forecast issued from data it should not have seen fails loudly
193
+ at the point of use.
194
+ """
195
+ if not isinstance(context, pd.DataFrame):
196
+ raise ContextError("context must be a DataFrame")
197
+
198
+ missing = [c for c in OHLCV_COLUMNS if c not in context.columns]
199
+ if missing:
200
+ raise ContextError(f"context is missing columns {missing}")
201
+
202
+ if len(context) < min_len:
203
+ raise ContextError(f"context has {len(context)} bars, need at least {min_len}")
204
+
205
+ if "ts" not in context.columns:
206
+ raise ContextError("context must carry a 'ts' column")
207
+
208
+ ts = pd.to_datetime(context["ts"], utc=True)
209
+ if ts.isna().any():
210
+ raise ContextError("context has unparseable timestamps")
211
+ if not ts.is_monotonic_increasing:
212
+ raise ContextError("context timestamps are not sorted ascending")
213
+ if ts.duplicated().any():
214
+ raise ContextError("context has duplicate timestamps")
215
+
216
+ prices = context[list(OHLCV_COLUMNS)]
217
+ if not np.isfinite(prices.to_numpy(dtype="float64")).all():
218
+ raise ContextError("context contains NaN or infinite values")
219
+ if (context[["open", "high", "low", "close"]].to_numpy(dtype="float64") <= 0).any():
220
+ raise ContextError("context contains non-positive prices")
221
+
222
+ # A hole in the cache means the model sees two bars as adjacent when they
223
+ # are weeks apart, and produces a confident wrong answer from it.
224
+ #
225
+ # But "irregular spacing" is not the same as "missing data". An hourly
226
+ # equity series closes for ~17 hours every night and ~65 hours every
227
+ # weekend; those gaps are the market, not the cache. Judging them against
228
+ # the modal bar rejected every SPY and NVDA hourly context outright.
229
+ #
230
+ # So the threshold adapts to the series' own gap distribution: a session
231
+ # boundary recurs and therefore sits inside the 99th percentile, while a
232
+ # genuinely absent period stands outside it.
233
+ if len(ts) > 20:
234
+ deltas = ts.diff().dropna()
235
+ modal = deltas.mode()
236
+ if len(modal):
237
+ step = modal.iloc[0]
238
+ p99 = deltas.quantile(0.99)
239
+
240
+ # Any *single* gap far beyond the series' own worst regular one is
241
+ # a hole, however few there are. This is the check that survives a
242
+ # series where absence has become the pattern -- an adaptive
243
+ # threshold alone would quietly normalise that.
244
+ hard = max(step * 24, p99 * 5)
245
+ worst = deltas.max()
246
+ if worst > hard:
247
+ raise ContextError(
248
+ f"context has a {worst} gap, far beyond its own session "
249
+ f"pattern; the cache is incomplete for this window"
250
+ )
251
+
252
+ # And an accumulation of smaller anomalies is a hole too.
253
+ ceiling = max(step * 4, p99 * 1.5)
254
+ bad = deltas[deltas > ceiling]
255
+ if len(bad) > max(2, len(ts) // 50):
256
+ raise ContextError(
257
+ f"context has {len(bad)} gaps beyond its own session pattern "
258
+ f"(> {ceiling}); the cache is incomplete for this window"
259
+ )
260
+
261
+ if issued_ts is not None:
262
+ issued = pd.Timestamp(issued_ts)
263
+ if issued.tzinfo is None:
264
+ issued = issued.tz_localize("UTC")
265
+ last = ts.iloc[-1]
266
+ if last > issued:
267
+ raise LookaheadError(
268
+ f"context ends at {last.isoformat()}, after issued_ts "
269
+ f"{issued.isoformat()}: a forecast may only see data at or "
270
+ f"before the moment it is issued"
271
+ )
272
+
273
+ return context
274
+
275
+
276
+ # --------------------------------------------------------------------------
277
+ # Determinism
278
+ # --------------------------------------------------------------------------
279
+
280
+
281
+ def seed_everything(seed: int) -> None:
282
+ """Pin every RNG an adapter might reach for.
283
+
284
+ Seeding numpy alone is not enough: the sampling models draw through torch,
285
+ and Kronos in particular calls `torch.multinomial` with no seed argument of
286
+ its own. This is what makes "same inputs and seed produce bit-identical
287
+ output" true rather than aspirational.
288
+ """
289
+ seed = int(seed) % (2 ** 31 - 1)
290
+ np.random.seed(seed)
291
+ try:
292
+ import random
293
+
294
+ random.seed(seed)
295
+ except Exception: # pragma: no cover
296
+ pass
297
+ try:
298
+ import torch
299
+
300
+ torch.manual_seed(seed)
301
+ if torch.cuda.is_available():
302
+ torch.cuda.manual_seed_all(seed)
303
+ except ImportError:
304
+ pass
305
+
306
+
307
+ # --------------------------------------------------------------------------
308
+ # The interface
309
+ # --------------------------------------------------------------------------
310
+
311
+
312
+ class ForecastAdapter(ABC):
313
+ """Uniform interface over multi-step probabilistic forecasters."""
314
+
315
+ family: str = "base"
316
+ # Bumped when this adapter's own logic changes the numbers it produces.
317
+ adapter_version: str = "1"
318
+
319
+ def __init__(self, model_id: str, revision: str | None = None,
320
+ device: str | None = None):
321
+ self.model_id = model_id
322
+ self.revision = revision
323
+ self.device = device or default_device()
324
+ self._model = None
325
+ self._resolved_revision: str | None = None
326
+
327
+ # -- interface --------------------------------------------------------
328
+
329
+ @abstractmethod
330
+ def load(self, model_id: str | None = None, revision: str | None = None) -> "ForecastAdapter":
331
+ """Materialise the model. Idempotent."""
332
+
333
+ @abstractmethod
334
+ def predict(self, context_ohlcv: pd.DataFrame, horizon: int,
335
+ n_samples: int = config.DEFAULT_N_SAMPLES, seed: int = 0,
336
+ issued_ts: pd.Timestamp | None = None) -> ForecastResult:
337
+ """Forecast `horizon` bars ahead from `context_ohlcv`.
338
+
339
+ Must be bit-reproducible in `seed`.
340
+ """
341
+
342
+ @abstractmethod
343
+ def capabilities(self) -> Capabilities:
344
+ """What this model can do. The UI renders from this and nothing else."""
345
+
346
+ # -- shared -----------------------------------------------------------
347
+
348
+ def component_versions(self) -> dict[str, str]:
349
+ """Every external thing whose version changes the output.
350
+
351
+ Overridden by families that load a companion repo (Kronos ships its
352
+ tokenizer separately) or depend on an inference package whose version
353
+ moves the numbers.
354
+ """
355
+ return {"model": f"{self.model_id}@{self.resolved_revision}"}
356
+
357
+ def inference_version(self) -> str:
358
+ """Identity of everything that determines the output values.
359
+
360
+ A short hash rather than the full component list, because it is written
361
+ onto every archived row; the components themselves are recorded once in
362
+ the registry so the hash can always be expanded.
363
+ """
364
+ parts = [config.INFERENCE_VERSION, self.family, self.adapter_version]
365
+ parts += [f"{k}={v}" for k, v in sorted(self.component_versions().items())]
366
+ digest = hashlib.sha256("|".join(parts).encode()).hexdigest()[:12]
367
+ return f"{config.INFERENCE_VERSION}+{self.family}.{digest}"
368
+
369
+ @property
370
+ def resolved_revision(self) -> str:
371
+ return self._resolved_revision or self.revision or "unpinned"
372
+
373
+ def resolve_revision(self) -> str:
374
+ """Pin the model to an immutable commit sha before any inference runs.
375
+
376
+ A floating `main` would mean two forecasts a week apart are not
377
+ comparable and neither is reproducible, which defeats the archive.
378
+ """
379
+ if self._resolved_revision:
380
+ return self._resolved_revision
381
+ from huggingface_hub import HfApi
382
+
383
+ info = HfApi().model_info(self.model_id, revision=self.revision)
384
+ self._resolved_revision = info.sha
385
+ return self._resolved_revision
386
+
387
+ # -- helpers for subclasses -------------------------------------------
388
+
389
+ def _trim(self, context: pd.DataFrame) -> pd.DataFrame:
390
+ """Cut a context down to what the model can actually attend over."""
391
+ cap = self.capabilities().max_context
392
+ return context.iloc[-cap:] if len(context) > cap else context
393
+
394
+ @staticmethod
395
+ def _quantiles_from_paths(paths_close: np.ndarray,
396
+ levels=config.QUANTILE_LEVELS) -> np.ndarray:
397
+ """(n_samples, horizon) -> (horizon, n_levels)."""
398
+ return np.quantile(paths_close, list(levels), axis=0).T
399
+
400
+
401
+ def default_device() -> str:
402
+ try:
403
+ import torch
404
+
405
+ if torch.cuda.is_available():
406
+ return "cuda"
407
+ except ImportError:
408
+ pass
409
+ return "cpu"
src/adapters/baseline.py ADDED
@@ -0,0 +1,214 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Naive baselines, deliberately first-class citizens of the Arena.
2
+
3
+ A foundation model that cannot out-forecast "tomorrow looks like today" is not
4
+ worth the GPU time, and a leaderboard that quietly omits that comparison is
5
+ flattering rather than useful. So the baselines are enrolled, archived and
6
+ graded on exactly the same footing as everything else, and they appear on the
7
+ standings table next to the models they embarrass.
8
+
9
+ They are also the only family that runs with no weights, no network and no
10
+ torch, which makes them the fixture the test suite reasons about: capability
11
+ gating, determinism and coverage arithmetic are all checked here first, where
12
+ the expected answer can be worked out by hand.
13
+
14
+ `random-walk` is the reference. Its band comes from the empirical distribution
15
+ of historical log returns, scaled by sqrt(h) -- which is the correct widening
16
+ for a driftless random walk and is why a well-calibrated foundation model
17
+ should be able to beat it on error but will struggle to beat it on coverage.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import numpy as np
23
+ import pandas as pd
24
+
25
+ from .. import config
26
+ from .base import (OHLCV_COLUMNS, OUTPUT_OHLCV_PATHS, OUTPUT_QUANTILE_LINE,
27
+ AdapterError, Capabilities, ForecastAdapter,
28
+ ForecastResult, check_context)
29
+
30
+ # `kind` -> how the median path is built, and what the adapter emits.
31
+ BASELINE_MODELS = {
32
+ "baseline/random-walk": {
33
+ "kind": "naive",
34
+ "output": OUTPUT_QUANTILE_LINE,
35
+ "display": "Random walk",
36
+ },
37
+ "baseline/drift": {
38
+ "kind": "drift",
39
+ "output": OUTPUT_QUANTILE_LINE,
40
+ "display": "Drift",
41
+ },
42
+ # A bootstrap over historical candles, so there is at least one path-
43
+ # emitting reference to hold Kronos's ghost paths against.
44
+ "baseline/bootstrap": {
45
+ "kind": "bootstrap",
46
+ "output": OUTPUT_OHLCV_PATHS,
47
+ "display": "Block bootstrap",
48
+ },
49
+ }
50
+
51
+ # How much history the return distribution is estimated from.
52
+ LOOKBACK = 256
53
+
54
+
55
+ class BaselineAdapter(ForecastAdapter):
56
+ """Closed-form and bootstrap baselines. No weights, no network."""
57
+
58
+ family = "baseline"
59
+ adapter_version = "1"
60
+
61
+ def __init__(self, model_id: str, revision: str | None = None,
62
+ device: str | None = None, hardware: str | None = None):
63
+ super().__init__(model_id, revision=revision, device=device)
64
+ spec = BASELINE_MODELS.get(model_id)
65
+ if spec is None:
66
+ raise AdapterError(
67
+ f"unknown baseline {model_id!r}; known: {sorted(BASELINE_MODELS)}")
68
+ self.kind = spec["kind"]
69
+ self._output = spec["output"]
70
+
71
+ def capabilities(self) -> Capabilities:
72
+ return Capabilities(
73
+ output=self._output,
74
+ hardware="cpu",
75
+ max_context=LOOKBACK,
76
+ asset_generality="general",
77
+ seedable_natively=True,
78
+ )
79
+
80
+ def component_versions(self) -> dict[str, str]:
81
+ # No revision to pin: the model *is* this file.
82
+ return {"model": self.model_id, "kind": self.kind, "lookback": str(LOOKBACK)}
83
+
84
+ def resolve_revision(self) -> str:
85
+ self._resolved_revision = "builtin"
86
+ return self._resolved_revision
87
+
88
+ def load(self, model_id: str | None = None, revision: str | None = None):
89
+ if model_id and model_id != self.model_id:
90
+ spec = BASELINE_MODELS.get(model_id)
91
+ if spec is None:
92
+ raise AdapterError(f"unknown baseline {model_id!r}")
93
+ self.model_id = model_id
94
+ self.kind = spec["kind"]
95
+ self._output = spec["output"]
96
+ self.resolve_revision()
97
+ self._model = self.kind
98
+ return self
99
+
100
+ def predict(self, context_ohlcv: pd.DataFrame, horizon: int,
101
+ n_samples: int = config.DEFAULT_N_SAMPLES, seed: int = 0,
102
+ issued_ts: pd.Timestamp | None = None) -> ForecastResult:
103
+ check_context(context_ohlcv, issued_ts=issued_ts)
104
+ if horizon < 1:
105
+ raise AdapterError("horizon must be at least 1")
106
+ if self._model is None:
107
+ self.load()
108
+
109
+ ctx = self._trim(context_ohlcv)
110
+ close = ctx["close"].to_numpy(dtype="float64")
111
+ last = float(close[-1])
112
+
113
+ log_returns = np.diff(np.log(close))
114
+ if len(log_returns) < 8:
115
+ raise AdapterError("need at least 9 bars to estimate a return distribution")
116
+
117
+ steps = np.arange(1, horizon + 1, dtype="float64")
118
+
119
+ # A local RNG, not the global one: a baseline must not be able to
120
+ # perturb the sampling of whatever model runs after it in a matchup.
121
+ rng = np.random.default_rng(int(seed))
122
+
123
+ if self.kind == "bootstrap":
124
+ paths = self._bootstrap_paths(ctx, horizon, n_samples, rng)
125
+ close_paths = paths[:, :, OHLCV_COLUMNS.index("close")]
126
+ quantiles = self._quantiles_from_paths(close_paths)
127
+ return ForecastResult(
128
+ quantiles=quantiles, levels=config.QUANTILE_LEVELS,
129
+ horizon=horizon, context_len=len(ctx),
130
+ inference_version=self.inference_version(),
131
+ seed=int(seed), n_samples=int(n_samples), paths=paths,
132
+ )
133
+
134
+ # Analytic band. The median is flat for `naive` and extends the mean
135
+ # historical return for `drift`; the width is the empirical return
136
+ # quantile scaled by sqrt(h), which is the random walk's own spread.
137
+ mu = float(log_returns.mean()) if self.kind == "drift" else 0.0
138
+ sigma = float(log_returns.std(ddof=1))
139
+
140
+ cols = []
141
+ for level in config.QUANTILE_LEVELS:
142
+ # Normal quantile via the error function's inverse, so scipy is not
143
+ # a dependency for the one family that has no dependencies.
144
+ z = _norm_ppf(level)
145
+ cols.append(last * np.exp(mu * steps + z * sigma * np.sqrt(steps)))
146
+ quantiles = np.stack(cols, axis=1)
147
+
148
+ return ForecastResult(
149
+ quantiles=quantiles, levels=config.QUANTILE_LEVELS,
150
+ horizon=horizon, context_len=len(ctx),
151
+ inference_version=self.inference_version(),
152
+ seed=int(seed), n_samples=int(n_samples), paths=None,
153
+ )
154
+
155
+ def _bootstrap_paths(self, ctx: pd.DataFrame, horizon: int,
156
+ n_samples: int, rng) -> np.ndarray:
157
+ """Resample historical candles in blocks, chained off the last close.
158
+
159
+ Blocks rather than single bars, because independent draws destroy the
160
+ volatility clustering that makes a market path look like a market path.
161
+ """
162
+ frame = ctx[list(OHLCV_COLUMNS)].to_numpy(dtype="float64")
163
+ close = frame[:, OHLCV_COLUMNS.index("close")]
164
+ log_returns = np.diff(np.log(close))
165
+ # Per-bar shape: how open/high/low/volume sat relative to that bar's
166
+ # close. Resampling the shape keeps the sampled candles plausible.
167
+ ratios = frame[1:, :4] / close[1:, None]
168
+ volumes = frame[1:, OHLCV_COLUMNS.index("volume")]
169
+
170
+ block = max(1, min(8, len(log_returns) // 4))
171
+ n = len(log_returns)
172
+ paths = np.empty((n_samples, horizon, len(OHLCV_COLUMNS)), dtype="float64")
173
+
174
+ for s in range(n_samples):
175
+ picked_r, picked_i = [], []
176
+ while len(picked_r) < horizon:
177
+ start = int(rng.integers(0, max(1, n - block)))
178
+ for j in range(start, min(start + block, n)):
179
+ picked_r.append(log_returns[j])
180
+ picked_i.append(j)
181
+ picked_r = np.asarray(picked_r[:horizon])
182
+ picked_i = np.asarray(picked_i[:horizon])
183
+
184
+ closes = float(close[-1]) * np.exp(np.cumsum(picked_r))
185
+ shape = ratios[picked_i]
186
+ paths[s, :, 0] = closes * shape[:, 0] # open
187
+ paths[s, :, 1] = closes * shape[:, 1] # high
188
+ paths[s, :, 2] = closes * shape[:, 2] # low
189
+ paths[s, :, 3] = closes # close
190
+ paths[s, :, 4] = volumes[picked_i]
191
+
192
+ # Keep the sampled candle self-consistent, same rule as Kronos.
193
+ body_hi = np.maximum(paths[:, :, 0], paths[:, :, 3])
194
+ body_lo = np.minimum(paths[:, :, 0], paths[:, :, 3])
195
+ paths[:, :, 1] = np.maximum(paths[:, :, 1], body_hi)
196
+ paths[:, :, 2] = np.minimum(paths[:, :, 2], body_lo)
197
+ return paths
198
+
199
+
200
+ def _norm_ppf(p: float) -> float:
201
+ """Inverse standard normal CDF, via the inverse error function."""
202
+ from math import erf, sqrt
203
+
204
+ if not 0.0 < p < 1.0:
205
+ raise AdapterError(f"quantile level {p} out of range")
206
+ # Bisection: exact enough (1e-12) and keeps this file dependency-free.
207
+ lo, hi = -12.0, 12.0
208
+ for _ in range(200):
209
+ mid = (lo + hi) / 2.0
210
+ if 0.5 * (1.0 + erf(mid / sqrt(2.0))) < p:
211
+ lo = mid
212
+ else:
213
+ hi = mid
214
+ return (lo + hi) / 2.0
src/adapters/chronos.py ADDED
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Chronos-Bolt -- direct multi-step quantile forecasting over the close.
2
+
3
+ Bolt is the fast half of Amazon's Chronos family: an encoder-decoder that emits
4
+ quantiles for the whole horizon in a single forward pass, with no sampling
5
+ loop. That makes it the cheapest family here by a wide margin and the natural
6
+ CPU tier, and it is why all four sizes are enrolled -- comparing them isolates
7
+ model capacity from every other variable in the Arena.
8
+
9
+ It is a `quantile_line` model. It sees a univariate close series, not candles,
10
+ and it emits a band and nothing else. The UI must not draw ghost paths for it,
11
+ and the capability declaration is what stops that happening.
12
+
13
+ A note on determinism: Bolt's forward pass is deterministic, so the seed does
14
+ not change its output. The seed is still recorded on every archived row, both
15
+ because the interface promises it and because the reproducibility test must be
16
+ able to run identically across every family.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import logging
22
+
23
+ import numpy as np
24
+ import pandas as pd
25
+
26
+ from .. import config
27
+ from .base import (OUTPUT_QUANTILE_LINE, AdapterError, Capabilities,
28
+ ForecastAdapter, ForecastResult, check_context,
29
+ seed_everything)
30
+
31
+ log = logging.getLogger("arena.adapters.chronos")
32
+
33
+ # Context length is the training context from the Chronos-Bolt model card.
34
+ # Bolt was trained to emit 64 steps directly; longer horizons are produced by
35
+ # rolling the model forward, which `predict` handles explicitly.
36
+ CHRONOS_MODELS = {
37
+ "amazon/chronos-bolt-tiny": {"max_context": 2048, "params": "9M", "hardware": "cpu"},
38
+ "amazon/chronos-bolt-mini": {"max_context": 2048, "params": "21M", "hardware": "cpu"},
39
+ "amazon/chronos-bolt-small": {"max_context": 2048, "params": "48M", "hardware": "cpu"},
40
+ "amazon/chronos-bolt-base": {"max_context": 2048, "params": "205M", "hardware": "cpu"},
41
+ }
42
+
43
+ # The horizon Bolt emits in one pass. Beyond it the forecast is rolled forward
44
+ # on its own median, which is a real approximation and is labelled as one.
45
+ NATIVE_HORIZON = 64
46
+
47
+
48
+ class ChronosAdapter(ForecastAdapter):
49
+ """`amazon/chronos-bolt-*` direct quantile forecasters."""
50
+
51
+ family = "chronos"
52
+ adapter_version = "1"
53
+
54
+ def __init__(self, model_id: str, revision: str | None = None,
55
+ device: str | None = None, hardware: str | None = None):
56
+ super().__init__(model_id, revision=revision, device=device)
57
+ spec = CHRONOS_MODELS.get(model_id, {})
58
+ self._max_context = int(spec.get("max_context", 2048))
59
+ self._hardware = hardware or spec.get("hardware", "cpu")
60
+
61
+ def capabilities(self) -> Capabilities:
62
+ return Capabilities(
63
+ output=OUTPUT_QUANTILE_LINE,
64
+ hardware=self._hardware,
65
+ max_context=self._max_context,
66
+ asset_generality="general",
67
+ seedable_natively=True,
68
+ )
69
+
70
+ def component_versions(self) -> dict[str, str]:
71
+ versions = {"model": f"{self.model_id}@{self.resolved_revision}"}
72
+ for pkg in ("chronos", "torch"):
73
+ try:
74
+ mod = __import__(pkg)
75
+ versions[pkg] = getattr(mod, "__version__", "unknown")
76
+ except ImportError:
77
+ pass
78
+ return versions
79
+
80
+ def load(self, model_id: str | None = None, revision: str | None = None):
81
+ if model_id and model_id != self.model_id:
82
+ self.model_id = model_id
83
+ spec = CHRONOS_MODELS.get(model_id, {})
84
+ self._max_context = int(spec.get("max_context", self._max_context))
85
+ self._hardware = spec.get("hardware", self._hardware)
86
+ self._model = None
87
+ if revision:
88
+ self.revision = revision
89
+ if self._model is not None:
90
+ return self
91
+
92
+ try:
93
+ from chronos import BaseChronosPipeline
94
+ except ImportError as e: # pragma: no cover
95
+ raise AdapterError(
96
+ "chronos-forecasting is not installed; add it to requirements.txt"
97
+ ) from e
98
+
99
+ import torch
100
+
101
+ self.resolve_revision()
102
+ self._model = BaseChronosPipeline.from_pretrained(
103
+ self.model_id,
104
+ revision=self._resolved_revision,
105
+ device_map=self.device,
106
+ # float32 on CPU: bfloat16 on CPU is emulated and slower, and the
107
+ # Arena's whole premise is that archived numbers are reproducible,
108
+ # which mixed precision makes harder to guarantee across hardware.
109
+ torch_dtype=torch.float32 if self.device == "cpu" else torch.bfloat16,
110
+ )
111
+ return self
112
+
113
+ def predict(self, context_ohlcv: pd.DataFrame, horizon: int,
114
+ n_samples: int = config.DEFAULT_N_SAMPLES, seed: int = 0,
115
+ issued_ts: pd.Timestamp | None = None) -> ForecastResult:
116
+ check_context(context_ohlcv, issued_ts=issued_ts)
117
+ if horizon < 1:
118
+ raise AdapterError("horizon must be at least 1")
119
+
120
+ if self._model is None:
121
+ self.load()
122
+
123
+ import torch
124
+
125
+ ctx = self._trim(context_ohlcv)
126
+ closes = ctx["close"].to_numpy(dtype="float32")
127
+
128
+ seed_everything(seed)
129
+ levels = list(config.QUANTILE_LEVELS)
130
+
131
+ chunks: list[np.ndarray] = []
132
+ series = closes.copy()
133
+ remaining = horizon
134
+
135
+ while remaining > 0:
136
+ step = min(remaining, NATIVE_HORIZON)
137
+ with torch.inference_mode():
138
+ # chronos-forecasting 2.x takes the series positionally as
139
+ # `inputs`; the 1.x `context=` keyword no longer exists.
140
+ q, _mean = self._model.predict_quantiles(
141
+ [torch.tensor(series)],
142
+ prediction_length=step,
143
+ quantile_levels=levels,
144
+ )
145
+ # (1, step, n_levels) -> (step, n_levels)
146
+ block = _to_array(q)[0][:step]
147
+ chunks.append(block)
148
+ remaining -= step
149
+ if remaining > 0:
150
+ # Roll forward on the median. This is the honest weak point of
151
+ # any horizon past 64: uncertainty stops compounding properly
152
+ # because the continuation is a point estimate.
153
+ median = block[:, levels.index(0.5)]
154
+ series = np.concatenate([series, median.astype("float32")])
155
+ if len(series) > self._max_context:
156
+ series = series[-self._max_context:]
157
+
158
+ quantiles = np.concatenate(chunks, axis=0)[:horizon]
159
+
160
+ return ForecastResult(
161
+ quantiles=quantiles,
162
+ levels=config.QUANTILE_LEVELS,
163
+ horizon=horizon,
164
+ context_len=len(ctx),
165
+ inference_version=self.inference_version(),
166
+ seed=int(seed),
167
+ n_samples=int(n_samples),
168
+ paths=None,
169
+ )
170
+
171
+
172
+ def _to_array(quantiles) -> np.ndarray:
173
+ """Normalise what the pipeline returns into (batch, steps, levels).
174
+
175
+ Bolt returns one stacked tensor. Keeping the unwrapping in one place means
176
+ a future Chronos variant that returns a list of per-item tensors is a
177
+ change here rather than in `predict`.
178
+ """
179
+ if isinstance(quantiles, (list, tuple)):
180
+ return np.stack([np.asarray(q.cpu() if hasattr(q, "cpu") else q)
181
+ for q in quantiles], axis=0)
182
+ arr = quantiles.cpu().numpy() if hasattr(quantiles, "cpu") else np.asarray(quantiles)
183
+ return arr
src/adapters/kronos.py ADDED
@@ -0,0 +1,296 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Kronos -- a foundation model trained on candlesticks rather than on numbers.
2
+
3
+ Kronos is the only family here that is native to OHLCV: it tokenises whole
4
+ candles and generates whole candles, so it is the family that can emit sampled
5
+ price *paths* rather than just a band on the close. That is what drives the
6
+ ghost-path view and the sampled-path dispersion widget.
7
+
8
+ Two facts about it shape this adapter.
9
+
10
+ **It has no inference package.** Weights ship on the Hub, the code ships only
11
+ on GitHub, and the `kronos` name on PyPI is an unrelated Django library. The
12
+ source is vendored under `vendor/kronos` at a pinned commit; see the
13
+ PROVENANCE.md there.
14
+
15
+ **It has no seed argument.** Sampling runs through `torch.multinomial` against
16
+ the global RNG. Determinism is therefore something this adapter imposes, by
17
+ seeding immediately before inference, rather than something Kronos offers.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import logging
23
+ import sys
24
+ from pathlib import Path
25
+
26
+ import numpy as np
27
+ import pandas as pd
28
+
29
+ from .. import config
30
+ from .base import (OHLCV_COLUMNS, OUTPUT_OHLCV_PATHS, AdapterError,
31
+ Capabilities, ForecastAdapter, ForecastResult,
32
+ check_context, seed_everything)
33
+
34
+ log = logging.getLogger("arena.adapters.kronos")
35
+
36
+ _VENDOR = Path(__file__).resolve().parents[2] / "vendor"
37
+ if str(_VENDOR) not in sys.path:
38
+ sys.path.insert(0, str(_VENDOR))
39
+
40
+ # Each Kronos checkpoint is trained against one tokenizer and is meaningless
41
+ # with any other, so the pairing is fixed here rather than left to the caller.
42
+ # Context lengths are the model card's, not guesses.
43
+ KRONOS_MODELS = {
44
+ "NeoQuasar/Kronos-mini": {
45
+ "tokenizer": "NeoQuasar/Kronos-Tokenizer-2k",
46
+ "max_context": 2048,
47
+ "params": "4.1M",
48
+ # Measured 6.7-7.1s warm at h=24, n=32 on an M-series laptop. That is
49
+ # inside the 8s budget here but cpu-basic is slower, so the tier is
50
+ # confirmed by `scripts/benchmark.py` running on the Space itself and
51
+ # demoted in the registry if it misses.
52
+ "hardware": "cpu",
53
+ },
54
+ "NeoQuasar/Kronos-small": {
55
+ "tokenizer": "NeoQuasar/Kronos-Tokenizer-base",
56
+ "max_context": 512,
57
+ "params": "24.7M",
58
+ # Measured 23.5-24.3s warm at h=24, n=32 on an M-series laptop, which
59
+ # is faster than cpu-basic. Nowhere near the 8s warm budget.
60
+ "hardware": "gpu",
61
+ },
62
+ "NeoQuasar/Kronos-base": {
63
+ "tokenizer": "NeoQuasar/Kronos-Tokenizer-base",
64
+ "max_context": 512,
65
+ "params": "102.3M",
66
+ # 102M params generating autoregressively is not a cpu-basic workload.
67
+ "hardware": "gpu",
68
+ },
69
+ }
70
+
71
+ # Sampling defaults. These are the model card's own recommended values; they
72
+ # are recorded in `component_versions` because changing them changes every
73
+ # number the model produces.
74
+ DEFAULT_T = 1.0
75
+ DEFAULT_TOP_P = 0.9
76
+ DEFAULT_TOP_K = 0
77
+ CLIP = 5
78
+
79
+
80
+ class KronosAdapter(ForecastAdapter):
81
+ """`NeoQuasar/Kronos-*` candlestick generators."""
82
+
83
+ family = "kronos"
84
+ adapter_version = "1"
85
+
86
+ def __init__(self, model_id: str, revision: str | None = None,
87
+ device: str | None = None, tokenizer_id: str | None = None,
88
+ tokenizer_revision: str | None = None,
89
+ hardware: str | None = None):
90
+ super().__init__(model_id, revision=revision, device=device)
91
+ spec = KRONOS_MODELS.get(model_id, {})
92
+ self.tokenizer_id = tokenizer_id or spec.get("tokenizer")
93
+ self.tokenizer_revision = tokenizer_revision
94
+ self._max_context = int(spec.get("max_context", 512))
95
+ # The declared default, which the registry may override downward after
96
+ # a measured latency run. Declaring a tier a model cannot hold is how
97
+ # a user ends up watching a spinner for ninety seconds.
98
+ self._hardware = hardware or spec.get("hardware", "gpu")
99
+ self._tokenizer = None
100
+ self._resolved_tokenizer_revision: str | None = None
101
+
102
+ # -- capabilities -----------------------------------------------------
103
+
104
+ def capabilities(self) -> Capabilities:
105
+ return Capabilities(
106
+ output=OUTPUT_OHLCV_PATHS,
107
+ # Kronos generates autoregressively -- one forward pass per
108
+ # forecast step. The tier here is the declared default; a measured
109
+ # miss against the CPU budget demotes it in the registry.
110
+ hardware=self._hardware,
111
+ max_context=self._max_context,
112
+ asset_generality="financial",
113
+ seedable_natively=False,
114
+ )
115
+
116
+ def component_versions(self) -> dict[str, str]:
117
+ versions = {
118
+ "model": f"{self.model_id}@{self.resolved_revision}",
119
+ "tokenizer": f"{self.tokenizer_id}@{self._resolved_tokenizer_revision or 'unpinned'}",
120
+ "sampling": f"T={DEFAULT_T},top_p={DEFAULT_TOP_P},top_k={DEFAULT_TOP_K},clip={CLIP}",
121
+ "vendor": "kronos@67b630e6",
122
+ }
123
+ try:
124
+ import torch
125
+
126
+ versions["torch"] = torch.__version__
127
+ except ImportError:
128
+ pass
129
+ return versions
130
+
131
+ # -- load -------------------------------------------------------------
132
+
133
+ def load(self, model_id: str | None = None, revision: str | None = None):
134
+ if model_id and model_id != self.model_id:
135
+ self.model_id = model_id
136
+ spec = KRONOS_MODELS.get(model_id, {})
137
+ self.tokenizer_id = spec.get("tokenizer", self.tokenizer_id)
138
+ self._max_context = int(spec.get("max_context", self._max_context))
139
+ self._hardware = spec.get("hardware", self._hardware)
140
+ self._model = None
141
+ if revision:
142
+ self.revision = revision
143
+ if self._model is not None:
144
+ return self
145
+
146
+ if not self.tokenizer_id:
147
+ raise AdapterError(
148
+ f"no tokenizer known for {self.model_id}; Kronos checkpoints are "
149
+ f"only valid with the tokenizer they were trained against"
150
+ )
151
+
152
+ try:
153
+ from kronos import Kronos, KronosTokenizer
154
+ except ImportError as e: # pragma: no cover
155
+ raise AdapterError(
156
+ "the vendored Kronos source is not importable; check vendor/kronos"
157
+ ) from e
158
+
159
+ self.resolve_revision()
160
+ self._resolved_tokenizer_revision = _resolve(self.tokenizer_id,
161
+ self.tokenizer_revision)
162
+
163
+ self._tokenizer = KronosTokenizer.from_pretrained(
164
+ self.tokenizer_id, revision=self._resolved_tokenizer_revision)
165
+ self._model = Kronos.from_pretrained(
166
+ self.model_id, revision=self._resolved_revision)
167
+
168
+ self._tokenizer = self._tokenizer.to(self.device).eval()
169
+ self._model = self._model.to(self.device).eval()
170
+ return self
171
+
172
+ # -- predict ----------------------------------------------------------
173
+
174
+ def predict(self, context_ohlcv: pd.DataFrame, horizon: int,
175
+ n_samples: int = config.DEFAULT_N_SAMPLES, seed: int = 0,
176
+ issued_ts: pd.Timestamp | None = None) -> ForecastResult:
177
+ check_context(context_ohlcv, issued_ts=issued_ts)
178
+ if horizon < 1:
179
+ raise AdapterError("horizon must be at least 1")
180
+ if n_samples < 1:
181
+ raise AdapterError("n_samples must be at least 1")
182
+
183
+ if self._model is None:
184
+ self.load()
185
+
186
+ import torch
187
+
188
+ from kronos.kronos import auto_regressive_inference, calc_time_stamps
189
+
190
+ ctx = self._trim(context_ohlcv).reset_index(drop=True)
191
+ ts = pd.to_datetime(ctx["ts"], utc=True)
192
+
193
+ # Kronos consumes `amount` (quote volume) alongside OHLCV. Where the
194
+ # cache has no amount column, the upstream predictor's own fallback is
195
+ # volume times the mean price, and that is reproduced here so the
196
+ # inputs match what `KronosPredictor` would have built.
197
+ frame = ctx[list(OHLCV_COLUMNS)].astype("float64").copy()
198
+ if "amount" in ctx.columns:
199
+ frame["amount"] = ctx["amount"].astype("float64")
200
+ else:
201
+ frame["amount"] = frame["volume"] * frame[["open", "high", "low", "close"]].mean(axis=1)
202
+
203
+ x = frame.to_numpy(dtype="float32")
204
+ future_ts = _future_timestamps(ts, horizon)
205
+
206
+ # Normalisation, replicated from `KronosPredictor.predict`. It is
207
+ # replicated rather than called because the upstream method averages
208
+ # the sampled paths away before returning, and the paths are the point.
209
+ # `test_kronos_vendor.py` asserts this reproduces the upstream result.
210
+ x_mean = x.mean(axis=0)
211
+ x_std = x.std(axis=0)
212
+ x_norm = (x - x_mean) / (x_std + 1e-5)
213
+ x_norm = np.clip(x_norm, -CLIP, CLIP)
214
+
215
+ x_stamp = calc_time_stamps(ts.reset_index(drop=True)).to_numpy(dtype="float32")
216
+ y_stamp = calc_time_stamps(pd.Series(future_ts)).to_numpy(dtype="float32")
217
+
218
+ x_t = torch.from_numpy(x_norm[np.newaxis, :].astype("float32")).to(self.device)
219
+ xs_t = torch.from_numpy(x_stamp[np.newaxis, :]).to(self.device)
220
+ ys_t = torch.from_numpy(y_stamp[np.newaxis, :]).to(self.device)
221
+
222
+ # The seed goes in here and nowhere else: Kronos draws through the
223
+ # global torch RNG, so this call is what makes the result reproducible.
224
+ seed_everything(seed)
225
+
226
+ with torch.inference_mode():
227
+ raw = auto_regressive_inference(
228
+ self._tokenizer, self._model, x_t, xs_t, ys_t,
229
+ max_context=self._max_context, pred_len=horizon, clip=CLIP,
230
+ T=DEFAULT_T, top_k=DEFAULT_TOP_K, top_p=DEFAULT_TOP_P,
231
+ sample_count=int(n_samples), verbose=False,
232
+ return_paths=True,
233
+ )
234
+
235
+ # (batch=1, n_samples, seq, features) -> (n_samples, horizon, features)
236
+ paths = np.asarray(raw)[0][:, -horizon:, :]
237
+ paths = paths * (x_std + 1e-5) + x_mean
238
+
239
+ # A sampled candle can come back internally inconsistent -- a high
240
+ # below the close, say -- because each field is decoded from its own
241
+ # token. Repairing it is more honest than rendering an impossible
242
+ # candle, and it only ever widens the bar to contain what it must.
243
+ paths = _repair_candles(paths)
244
+
245
+ close = paths[:, :, OHLCV_COLUMNS.index("close")]
246
+ quantiles = self._quantiles_from_paths(close)
247
+
248
+ return ForecastResult(
249
+ quantiles=quantiles,
250
+ levels=config.QUANTILE_LEVELS,
251
+ horizon=horizon,
252
+ context_len=len(ctx),
253
+ inference_version=self.inference_version(),
254
+ seed=int(seed),
255
+ n_samples=int(n_samples),
256
+ paths=paths[:, :, :len(OHLCV_COLUMNS)],
257
+ )
258
+
259
+
260
+ # --------------------------------------------------------------------------
261
+ # Helpers
262
+ # --------------------------------------------------------------------------
263
+
264
+
265
+ def _resolve(repo_id: str, revision: str | None) -> str:
266
+ from huggingface_hub import HfApi
267
+
268
+ return HfApi().model_info(repo_id, revision=revision).sha
269
+
270
+
271
+ def _future_timestamps(ts: pd.Series, horizon: int) -> pd.DatetimeIndex:
272
+ """Continue the context's own cadence forward.
273
+
274
+ Kronos conditions on calendar features, so the future stamps have to be
275
+ plausible rather than arbitrary. The modal spacing of the context is used
276
+ so that this works for both 1h and 1d without being told which it is.
277
+ """
278
+ if len(ts) < 2:
279
+ raise AdapterError("cannot infer cadence from fewer than two bars")
280
+ deltas = ts.diff().dropna()
281
+ step = deltas.mode().iloc[0] if len(deltas.mode()) else deltas.median()
282
+ last = ts.iloc[-1]
283
+ return pd.DatetimeIndex([last + step * (i + 1) for i in range(horizon)])
284
+
285
+
286
+ def _repair_candles(paths: np.ndarray) -> np.ndarray:
287
+ """Force high >= max(o,c) and low <= min(o,c) on every sampled candle."""
288
+ o, h, l, c = (OHLCV_COLUMNS.index(k) for k in ("open", "high", "low", "close"))
289
+ body_hi = np.maximum(paths[:, :, o], paths[:, :, c])
290
+ body_lo = np.minimum(paths[:, :, o], paths[:, :, c])
291
+ paths[:, :, h] = np.maximum(paths[:, :, h], body_hi)
292
+ paths[:, :, l] = np.minimum(paths[:, :, l], body_lo)
293
+ # Volume is a count; a negative one is a decode artefact, not information.
294
+ v = OHLCV_COLUMNS.index("volume")
295
+ paths[:, :, v] = np.maximum(paths[:, :, v], 0.0)
296
+ return paths
src/adapters/timesfm.py ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """TimesFM 2.5 -- Google's decoder-only time-series foundation model.
2
+
3
+ A `quantile_line` family, like Chronos: it forecasts a univariate series and
4
+ returns a quantile grid, with no notion of a candle and no sampled paths.
5
+
6
+ Two things are worth knowing before changing this file.
7
+
8
+ **The model card is out of date about installation.** It says a PyPI release is
9
+ "coming soon" and directs you to clone the GitHub repo. That is stale --
10
+ `timesfm==2.0.2` on PyPI does export `TimesFM_2p5_200M_torch`, and it installs
11
+ with only numpy, safetensors, huggingface_hub and torch. Installing from a git
12
+ URL instead would have put an unpinnable dependency in the Space's build.
13
+
14
+ **It must be compiled before it can forecast, and compilation fixes the
15
+ horizon.** `compile()` builds a cached decode function up to `max_horizon`, so
16
+ compiling per request would dominate the latency. This adapter compiles once,
17
+ at the largest horizon the Arena offers, and slices shorter forecasts out of
18
+ that -- which is why `_ensure_compiled` exists rather than a line in `load`.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import logging
24
+
25
+ import numpy as np
26
+ import pandas as pd
27
+
28
+ from .. import config
29
+ from .base import (OUTPUT_QUANTILE_LINE, AdapterError, Capabilities,
30
+ ForecastAdapter, ForecastResult, check_context,
31
+ seed_everything)
32
+
33
+ log = logging.getLogger("arena.adapters.timesfm")
34
+
35
+ TIMESFM_MODELS = {
36
+ "google/timesfm-2.5-200m-pytorch": {
37
+ "max_context": 2048, # the checkpoint supports 16384; see below
38
+ "params": "200M",
39
+ "hardware": "cpu",
40
+ },
41
+ }
42
+
43
+ # TimesFM 2.5's own ceiling is 16384, but `max_context` is what the compiled
44
+ # decode function pads *every* input to, so an oversized value is paid on every
45
+ # request whether the context is that long or not. 2048 covers the Arena's
46
+ # longest offered context with room to spare.
47
+ COMPILE_MAX_HORIZON = 256
48
+
49
+ # The quantile grid TimesFM returns: column 0 is the mean, then 0.1 .. 0.9.
50
+ QUANTILE_GRID = (0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9)
51
+
52
+
53
+ class TimesFMAdapter(ForecastAdapter):
54
+ """`google/timesfm-2.5-*` quantile forecasters."""
55
+
56
+ family = "timesfm"
57
+ adapter_version = "1"
58
+
59
+ def __init__(self, model_id: str, revision: str | None = None,
60
+ device: str | None = None, hardware: str | None = None):
61
+ super().__init__(model_id, revision=revision, device=device)
62
+ spec = TIMESFM_MODELS.get(model_id, {})
63
+ self._max_context = int(spec.get("max_context", 2048))
64
+ self._hardware = hardware or spec.get("hardware", "cpu")
65
+ self._compiled_horizon = 0
66
+
67
+ def capabilities(self) -> Capabilities:
68
+ return Capabilities(
69
+ output=OUTPUT_QUANTILE_LINE,
70
+ hardware=self._hardware,
71
+ max_context=self._max_context,
72
+ asset_generality="general",
73
+ seedable_natively=True,
74
+ )
75
+
76
+ def component_versions(self) -> dict[str, str]:
77
+ versions = {
78
+ "model": f"{self.model_id}@{self.resolved_revision}",
79
+ "forecast_config": (
80
+ f"max_context={self._max_context},max_horizon={COMPILE_MAX_HORIZON},"
81
+ f"normalize_inputs=True,continuous_quantile_head=True,"
82
+ f"fix_quantile_crossing=True"
83
+ ),
84
+ }
85
+ for pkg in ("timesfm", "torch"):
86
+ try:
87
+ mod = __import__(pkg)
88
+ versions[pkg] = getattr(mod, "__version__", "unknown")
89
+ except ImportError:
90
+ pass
91
+ return versions
92
+
93
+ def load(self, model_id: str | None = None, revision: str | None = None):
94
+ if model_id and model_id != self.model_id:
95
+ self.model_id = model_id
96
+ spec = TIMESFM_MODELS.get(model_id, {})
97
+ self._max_context = int(spec.get("max_context", self._max_context))
98
+ self._hardware = spec.get("hardware", self._hardware)
99
+ self._model = None
100
+ self._compiled_horizon = 0
101
+ if revision:
102
+ self.revision = revision
103
+ if self._model is not None:
104
+ return self
105
+
106
+ try:
107
+ import timesfm
108
+ except ImportError as e: # pragma: no cover
109
+ raise AdapterError(
110
+ "timesfm is not installed; add it to requirements.txt"
111
+ ) from e
112
+
113
+ if not hasattr(timesfm, "TimesFM_2p5_200M_torch"): # pragma: no cover
114
+ raise AdapterError(
115
+ "the installed timesfm does not expose the 2.5 API; pin >=2.0.2"
116
+ )
117
+
118
+ self.resolve_revision()
119
+ self._model = timesfm.TimesFM_2p5_200M_torch.from_pretrained(
120
+ self.model_id,
121
+ revision=self._resolved_revision,
122
+ # torch.compile spends tens of seconds warming up and buys little
123
+ # on a CPU box with batch size 1. It is the difference between
124
+ # clearing the cold-start budget and blowing straight through it.
125
+ torch_compile=(self.device == "cuda"),
126
+ )
127
+ return self
128
+
129
+ def _ensure_compiled(self) -> None:
130
+ if self._compiled_horizon >= COMPILE_MAX_HORIZON:
131
+ return
132
+ import timesfm
133
+
134
+ self._model.compile(timesfm.ForecastConfig(
135
+ max_context=self._max_context,
136
+ max_horizon=COMPILE_MAX_HORIZON,
137
+ normalize_inputs=True,
138
+ use_continuous_quantile_head=True,
139
+ force_flip_invariance=True,
140
+ infer_is_positive=True,
141
+ # Without this, sampled quantiles can cross at long horizons and
142
+ # the band arithmetic downstream stops meaning anything.
143
+ fix_quantile_crossing=True,
144
+ ))
145
+ self._compiled_horizon = COMPILE_MAX_HORIZON
146
+
147
+ def predict(self, context_ohlcv: pd.DataFrame, horizon: int,
148
+ n_samples: int = config.DEFAULT_N_SAMPLES, seed: int = 0,
149
+ issued_ts: pd.Timestamp | None = None) -> ForecastResult:
150
+ check_context(context_ohlcv, issued_ts=issued_ts)
151
+ if horizon < 1:
152
+ raise AdapterError("horizon must be at least 1")
153
+ if horizon > COMPILE_MAX_HORIZON:
154
+ raise AdapterError(
155
+ f"horizon {horizon} exceeds the compiled maximum {COMPILE_MAX_HORIZON}"
156
+ )
157
+
158
+ if self._model is None:
159
+ self.load()
160
+ self._ensure_compiled()
161
+
162
+ ctx = self._trim(context_ohlcv)
163
+ closes = ctx["close"].to_numpy(dtype="float32")
164
+
165
+ seed_everything(seed)
166
+ _point, quantile_out = self._model.forecast(
167
+ horizon=horizon, inputs=[closes])
168
+
169
+ # (1, horizon, 10) -> (horizon, 10); drop the mean in column 0.
170
+ grid = np.asarray(quantile_out)[0][:horizon, 1:]
171
+ if grid.shape[1] != len(QUANTILE_GRID): # pragma: no cover
172
+ raise AdapterError(
173
+ f"expected {len(QUANTILE_GRID)} quantile columns, got {grid.shape[1]}"
174
+ )
175
+
176
+ # Select the Arena's levels out of the model's own grid. Every level
177
+ # the Arena stores is on this grid by construction -- see the comment
178
+ # on config.QUANTILE_LEVELS -- so nothing is interpolated.
179
+ cols = []
180
+ for level in config.QUANTILE_LEVELS:
181
+ matches = [i for i, g in enumerate(QUANTILE_GRID) if abs(g - level) < 1e-9]
182
+ if not matches:
183
+ raise AdapterError(
184
+ f"level {level} is not on TimesFM's native grid {QUANTILE_GRID}"
185
+ )
186
+ cols.append(matches[0])
187
+ quantiles = grid[:, cols]
188
+
189
+ return ForecastResult(
190
+ quantiles=quantiles,
191
+ levels=config.QUANTILE_LEVELS,
192
+ horizon=horizon,
193
+ context_len=len(ctx),
194
+ inference_version=self.inference_version(),
195
+ seed=int(seed),
196
+ n_samples=int(n_samples),
197
+ paths=None,
198
+ )
src/config.py ADDED
@@ -0,0 +1,196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Configuration for the Forecast Arena.
2
+
3
+ Everything that names an external resource -- a repo, a model, an asset -- is
4
+ declared here, so that changing where the Arena reads or writes is one edit in
5
+ one file rather than a search across the app.
6
+
7
+ The store is the *organisation's* dataset, not the user account's. That is
8
+ where the validated price cache and the signal conventions already live, and
9
+ it is what `bit-backtest-lab` reads and writes. See docs/DECISIONS.md.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ from dataclasses import dataclass, field
16
+
17
+ # --------------------------------------------------------------------------
18
+ # Repos
19
+ # --------------------------------------------------------------------------
20
+
21
+ ORG = "The-Bit-Trading-Company"
22
+ USER = "Bit-Trading-Company"
23
+
24
+ # The signal store. `BIT_STORE_REPO` overrides it for tests and local runs.
25
+ STORE_REPO = os.environ.get("BIT_STORE_REPO", f"{ORG}/bit-signal-store")
26
+ STORE_REPO_TYPE = "dataset"
27
+
28
+ SPACE_REPO = f"{USER}/bit-forecast-arena"
29
+
30
+ # Bumped when a change to *this* app alters forecast values. It travels into
31
+ # every archived row through `inference_version()`, so a stored forecast can
32
+ # always be traced to the code that produced it.
33
+ INFERENCE_VERSION = "arena-1"
34
+
35
+
36
+ # --------------------------------------------------------------------------
37
+ # Store layout
38
+ # --------------------------------------------------------------------------
39
+
40
+ # Everything the Arena writes lives under `arena/`.
41
+ #
42
+ # The build spec named `signals/{model_slug}/...` for the forecast archive, but
43
+ # that tree is already occupied. `bit-backtest-lab` writes per-bar, horizon-1
44
+ # signals there -- columns `ts,q10,q50,q90,context_len,inference_version` -- and
45
+ # four of its model slugs (`chronos-bolt-{tiny,mini,small,base}`) are slugs the
46
+ # Arena also uses. Writing frozen multi-step forecasts to the same paths would
47
+ # have merged two incompatible schemas into one file and destroyed signals that
48
+ # cost real GPU time to produce.
49
+ #
50
+ # So the archive is namespaced instead. Same layout, same conventions, one
51
+ # level down. See docs/DECISIONS.md.
52
+ def signals_path(model_slug: str, asset: str, timeframe: str, year: int) -> str:
53
+ return f"arena/forecasts/{model_slug}/{asset}/{timeframe}/{year}.parquet"
54
+
55
+
56
+ def trackrecord_path(model_slug: str, asset: str, timeframe: str) -> str:
57
+ return f"arena/trackrecord/{model_slug}/{asset}/{timeframe}.parquet"
58
+
59
+
60
+ def prices_path(asset: str, timeframe: str, year: int) -> str:
61
+ return f"prices/{asset}/{timeframe}/{year}.parquet"
62
+
63
+
64
+ STANDINGS_PATH = "arena/standings.parquet"
65
+ REGISTRY_PATH = "arena/registry.json"
66
+
67
+
68
+ # --------------------------------------------------------------------------
69
+ # Assets and timeframes
70
+ # --------------------------------------------------------------------------
71
+
72
+ @dataclass(frozen=True)
73
+ class Asset:
74
+ slug: str # store path segment, matches the existing price cache
75
+ display: str
76
+ asset_class: str # "crypto" | "equity"
77
+
78
+
79
+ ASSETS: dict[str, Asset] = {
80
+ a.slug: a
81
+ for a in [
82
+ Asset("BTC-USD", "BTC/USD", "crypto"),
83
+ Asset("ETH-USD", "ETH/USD", "crypto"),
84
+ Asset("SOL-USD", "SOL/USD", "crypto"),
85
+ Asset("SPY", "SPY", "equity"),
86
+ Asset("NVDA", "NVDA", "equity"),
87
+ Asset("QQQ", "QQQ", "equity"),
88
+ ]
89
+ }
90
+
91
+ # The seed/backfill scope named in the build spec.
92
+ SEED_ASSETS = ("BTC-USD", "ETH-USD", "SOL-USD", "SPY", "NVDA")
93
+
94
+
95
+ @dataclass(frozen=True)
96
+ class Timeframe:
97
+ slug: str
98
+ display: str
99
+ minutes: int
100
+
101
+
102
+ TIMEFRAMES: dict[str, Timeframe] = {
103
+ t.slug: t
104
+ for t in [
105
+ Timeframe("1h", "1 hour", 60),
106
+ Timeframe("1d", "1 day", 1440),
107
+ ]
108
+ }
109
+
110
+ SEED_TIMEFRAMES = ("1h", "1d")
111
+
112
+ # Default forecast horizons offered in the UI, per timeframe.
113
+ DEFAULT_HORIZON = {"1h": 24, "1d": 30}
114
+ MAX_HORIZON = {"1h": 168, "1d": 90}
115
+
116
+
117
+ # --------------------------------------------------------------------------
118
+ # Sampling and latency
119
+ # --------------------------------------------------------------------------
120
+
121
+ # Quantile levels stored for every forecast.
122
+ #
123
+ # These are chosen so that *every* family emits them natively. TimesFM returns
124
+ # a fixed 0.1-step grid, so 0.25/0.75 would have had to be interpolated for it
125
+ # while Chronos and Kronos produced theirs directly -- three models' bands
126
+ # would then not have been the same kind of number. Snapping to 0.2/0.8 keeps
127
+ # every stored quantile the model's own output.
128
+ #
129
+ # The pair either side of the median gives a second nominal band (60%) to
130
+ # check coverage against, which is what stops a model tuning itself to look
131
+ # calibrated at exactly one width.
132
+ QUANTILE_LEVELS = (0.1, 0.2, 0.5, 0.8, 0.9)
133
+
134
+ # How many bars of history a forecast actually conditions on.
135
+ #
136
+ # This is capped *below* several models' `max_context` on purpose. Kronos-mini
137
+ # will attend over 2048 bars and TimesFM over far more, but the cost is roughly
138
+ # linear in context and the accuracy gain past a few hundred bars is small: at
139
+ # 2048 bars Kronos-mini took 36.8s for a 24-step forecast, and at 512 it takes
140
+ # a fraction of that for a materially similar answer. The design says "Fetching
141
+ # 512 candles", and 512 is what the app fetches.
142
+ #
143
+ # A model whose `max_context` is *smaller* than this still gets trimmed to its
144
+ # own limit by `ForecastAdapter._trim`.
145
+ DEFAULT_CONTEXT_BARS = 512
146
+
147
+ DEFAULT_N_SAMPLES = 32
148
+ MAX_N_SAMPLES = 128
149
+
150
+ # How many sampled paths are ever sent to the browser as "ghosts". Sending all
151
+ # of them is a payload problem, not an information gain.
152
+ GHOST_PATHS = 12
153
+
154
+ # CPU-tier budget on `cpu-basic`, in seconds. A model that misses it is demoted
155
+ # to GPU tier in the registry rather than left to time out in front of a user.
156
+ CPU_BUDGET_COLD_S = 20.0
157
+ CPU_BUDGET_WARM_S = 8.0
158
+
159
+ # Per-session forecast cap for anonymous visitors.
160
+ ANON_SESSION_CAP = 12
161
+
162
+
163
+ # --------------------------------------------------------------------------
164
+ # Calibration grading
165
+ # --------------------------------------------------------------------------
166
+
167
+ # A grade is assigned on the absolute gap between empirical coverage of the
168
+ # 80% band (q10..q90) and its nominal 0.80. The thresholds are deliberately
169
+ # wide: with ~60 resolved forecasts the standard error on a coverage estimate
170
+ # is around 5 points, so anything tighter would be grading noise.
171
+ #
172
+ # `MIN_RESOLVED_FOR_GRADE` is the point below which no grade is shown at all --
173
+ # the UI renders the count instead. Refusing to grade is more honest than
174
+ # grading 3 observations.
175
+ # (low level, high level, nominal coverage) for each band the resolver scores.
176
+ BANDS = ((0.1, 0.9, 0.80), (0.2, 0.8, 0.60))
177
+ NOMINAL_COVERAGE = 0.80 # the band the headline grade is computed on
178
+ MIN_RESOLVED_FOR_GRADE = 20
179
+
180
+ GRADE_THRESHOLDS = (
181
+ ("A", 0.05), # within 5 points of nominal
182
+ ("B", 0.10),
183
+ ("C", 0.20),
184
+ ("D", 1.00), # anything worse
185
+ )
186
+
187
+
188
+ @dataclass(frozen=True)
189
+ class Caps:
190
+ """Guardrails on user-funded work."""
191
+
192
+ smoke_test_steps: int = 100
193
+ max_enrollments_per_session: int = 3
194
+
195
+
196
+ CAPS = Caps()
src/prices.py ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Price refresh. Batch path only -- never called while rendering a page.
2
+
3
+ The Arena reads prices from the shared store and writes new bars back into the
4
+ same tree the rest of the estate uses, so a refresh here also benefits the
5
+ Backtest Lab. Provider order is crypto via ccxt, equities via yfinance falling
6
+ back to Stooq, which is the convention the store was built on.
7
+
8
+ Two properties matter more than throughput:
9
+
10
+ **Only closed bars are stored.** A bar for the period currently in progress
11
+ will change before it closes, and a forecast issued against it would be
12
+ conditioning on a number that later moves -- a subtle lookahead that no
13
+ timestamp check would catch.
14
+
15
+ **A partial refresh is still a valid store.** Bars are appended per year file
16
+ and deduplicated on timestamp, so a provider dying halfway leaves a shorter
17
+ history rather than a corrupt one.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import logging
23
+
24
+ import pandas as pd
25
+
26
+ from . import config
27
+ from .store import ArenaStore, now_utc
28
+
29
+ log = logging.getLogger("arena.prices")
30
+
31
+ PRICE_COLUMNS = ["ts", "open", "high", "low", "close", "volume"]
32
+
33
+ # ccxt symbols for the crypto assets, and Stooq/yfinance symbols for equities.
34
+ CCXT_SYMBOLS = {"BTC-USD": "BTC/USDT", "ETH-USD": "ETH/USDT", "SOL-USD": "SOL/USDT"}
35
+ CCXT_TIMEFRAMES = {"1h": "1h", "1d": "1d"}
36
+ STOOQ_SYMBOLS = {"SPY": "spy.us", "NVDA": "nvda.us", "QQQ": "qqq.us"}
37
+
38
+
39
+ class ProviderError(RuntimeError):
40
+ pass
41
+
42
+
43
+ def refresh(store: ArenaStore, asset: str, timeframe: str,
44
+ lookback_days: int = 30) -> int:
45
+ """Fetch recent bars and merge them into the store. Returns bars added."""
46
+ spec = config.ASSETS.get(asset)
47
+ if spec is None:
48
+ raise ProviderError(f"unknown asset {asset!r}")
49
+
50
+ existing = store.get_prices(asset, timeframe)
51
+ since = None
52
+ if len(existing):
53
+ since = pd.to_datetime(existing["ts"], utc=True).max()
54
+
55
+ if spec.asset_class == "crypto":
56
+ fresh = _fetch_ccxt(asset, timeframe, since, lookback_days)
57
+ else:
58
+ try:
59
+ fresh = _fetch_yfinance(asset, timeframe, since, lookback_days)
60
+ except Exception as e:
61
+ log.info("yfinance failed for %s (%s); falling back to Stooq", asset, e)
62
+ fresh = _fetch_stooq(asset, timeframe)
63
+
64
+ if fresh is None or not len(fresh):
65
+ return 0
66
+
67
+ fresh = _drop_open_bar(fresh, timeframe)
68
+ if since is not None:
69
+ fresh = fresh[pd.to_datetime(fresh["ts"], utc=True) > since]
70
+ if not len(fresh):
71
+ return 0
72
+
73
+ added = 0
74
+ combined = pd.concat([existing, fresh], ignore_index=True) if len(existing) else fresh
75
+ combined["ts"] = pd.to_datetime(combined["ts"], utc=True)
76
+ combined = (combined.drop_duplicates(subset=["ts"], keep="first")
77
+ .sort_values("ts").reset_index(drop=True))
78
+
79
+ for year, chunk in combined.groupby(combined["ts"].dt.year):
80
+ path = config.prices_path(asset, timeframe, int(year))
81
+ before = store.read_parquet(path)
82
+ if before is not None and len(before) == len(chunk):
83
+ continue
84
+ store.write_parquet(path, chunk[PRICE_COLUMNS])
85
+ added += len(chunk) - (len(before) if before is not None else 0)
86
+ return max(0, added)
87
+
88
+
89
+ def _drop_open_bar(frame: pd.DataFrame, timeframe: str) -> pd.DataFrame:
90
+ """Discard the bar for the period still in progress.
91
+
92
+ An in-progress bar's close is not its close. Storing it would let a
93
+ forecast condition on a value that changes afterwards.
94
+ """
95
+ if not len(frame):
96
+ return frame
97
+ minutes = config.TIMEFRAMES[timeframe].minutes
98
+ cutoff = now_utc().floor(f"{minutes}min")
99
+ ts = pd.to_datetime(frame["ts"], utc=True)
100
+ return frame[ts < cutoff].reset_index(drop=True)
101
+
102
+
103
+ def _fetch_ccxt(asset: str, timeframe: str, since, lookback_days: int) -> pd.DataFrame:
104
+ import ccxt
105
+
106
+ symbol = CCXT_SYMBOLS.get(asset)
107
+ if not symbol:
108
+ raise ProviderError(f"no ccxt symbol for {asset}")
109
+ tf = CCXT_TIMEFRAMES.get(timeframe)
110
+ if not tf:
111
+ raise ProviderError(f"ccxt cannot serve {timeframe}")
112
+
113
+ exchange = ccxt.binance({"enableRateLimit": True})
114
+ start = since if since is not None else now_utc() - pd.Timedelta(days=lookback_days)
115
+ ms = int(pd.Timestamp(start).timestamp() * 1000)
116
+
117
+ rows = []
118
+ for _ in range(20): # bounded: 20 pages of 1000 bars is plenty
119
+ batch = exchange.fetch_ohlcv(symbol, tf, since=ms, limit=1000)
120
+ if not batch:
121
+ break
122
+ rows.extend(batch)
123
+ ms = batch[-1][0] + 1
124
+ if len(batch) < 1000:
125
+ break
126
+
127
+ if not rows:
128
+ return pd.DataFrame(columns=PRICE_COLUMNS)
129
+ frame = pd.DataFrame(rows, columns=["ts", "open", "high", "low", "close", "volume"])
130
+ frame["ts"] = pd.to_datetime(frame["ts"], unit="ms", utc=True)
131
+ return frame[PRICE_COLUMNS]
132
+
133
+
134
+ def _fetch_yfinance(asset: str, timeframe: str, since, lookback_days: int) -> pd.DataFrame:
135
+ import yfinance as yf
136
+
137
+ interval = {"1h": "1h", "1d": "1d"}[timeframe]
138
+ period = f"{max(lookback_days, 7)}d" if timeframe == "1h" else "2y"
139
+ raw = yf.Ticker(asset).history(period=period, interval=interval, auto_adjust=False)
140
+ if raw is None or not len(raw):
141
+ raise ProviderError("yfinance returned no rows")
142
+
143
+ frame = raw.reset_index()
144
+ stamp = next((c for c in ("Datetime", "Date", "index") if c in frame.columns), None)
145
+ if stamp is None:
146
+ raise ProviderError(f"no timestamp column in {list(frame.columns)}")
147
+ frame = frame.rename(columns={stamp: "ts", "Open": "open", "High": "high",
148
+ "Low": "low", "Close": "close", "Volume": "volume"})
149
+ frame["ts"] = pd.to_datetime(frame["ts"], utc=True)
150
+ return frame[PRICE_COLUMNS]
151
+
152
+
153
+ def _fetch_stooq(asset: str, timeframe: str) -> pd.DataFrame:
154
+ import io
155
+
156
+ import requests
157
+
158
+ if timeframe != "1d":
159
+ raise ProviderError("Stooq serves daily bars only")
160
+ symbol = STOOQ_SYMBOLS.get(asset)
161
+ if not symbol:
162
+ raise ProviderError(f"no Stooq symbol for {asset}")
163
+
164
+ response = requests.get(f"https://stooq.com/q/d/l/?s={symbol}&i=d", timeout=30)
165
+ response.raise_for_status()
166
+ frame = pd.read_csv(io.StringIO(response.text))
167
+ frame = frame.rename(columns={c: c.lower() for c in frame.columns})
168
+ if "date" not in frame.columns:
169
+ raise ProviderError("Stooq returned no date column")
170
+ frame["ts"] = pd.to_datetime(frame["date"], utc=True)
171
+ return frame[PRICE_COLUMNS]
src/runtime.py ADDED
@@ -0,0 +1,308 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Running a forecast, and enrolling a model.
2
+
3
+ This is the layer the UI calls. It owns three things the UI must not:
4
+
5
+ **Adapter caching.** Loading weights costs seconds; doing it per request would
6
+ put every model outside its latency budget. Adapters are cached per
7
+ (family, model id, revision) and reused.
8
+
9
+ **The issue moment.** `issued_ts` is the timestamp of the last bar in the
10
+ validated price cache, never the wall clock. That is what makes a live forecast
11
+ and a backfilled one structurally the same operation -- the backfill just
12
+ supplies an earlier cut. There is no code path where a forecast can see a bar
13
+ it should not have.
14
+
15
+ **Enrollment.** Adding a model is: validate the id, check the family is on the
16
+ allow-list, pin the revision to an immutable sha, run a smoke test, write the
17
+ registry entry. No user-supplied code is ever imported or executed, and an
18
+ unsupported family is rejected with a message that says so.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import logging
24
+ import time
25
+ from dataclasses import dataclass, field
26
+
27
+ import pandas as pd
28
+
29
+ from . import config, trackrecord
30
+ from .adapters import (ALLOWED_ADAPTER_FAMILIES, AdapterError, ForecastAdapter,
31
+ ForecastResult, ModelNotAllowed, family_for,
32
+ get_adapter, validate_model_id)
33
+ from .adapters import model_slug as slug_for
34
+ from .store import ArenaStore, now_utc
35
+
36
+ log = logging.getLogger("arena.runtime")
37
+
38
+
39
+ class ForecastUnavailable(RuntimeError):
40
+ """A forecast could not be produced. Carries a UI-renderable reason."""
41
+
42
+ def __init__(self, message: str, kind: str = "load_failure"):
43
+ super().__init__(message)
44
+ self.kind = kind
45
+
46
+
47
+ # --------------------------------------------------------------------------
48
+ # Adapter cache
49
+ # --------------------------------------------------------------------------
50
+
51
+ _ADAPTERS: dict[tuple, ForecastAdapter] = {}
52
+
53
+
54
+ def adapter_for(family: str, model_id: str, revision: str | None = None,
55
+ **kwargs) -> ForecastAdapter:
56
+ """A loaded adapter, cached. Raises `ForecastUnavailable` on load failure."""
57
+ key = (family, model_id, revision or "pinned", tuple(sorted(kwargs.items())))
58
+ cached = _ADAPTERS.get(key)
59
+ if cached is not None:
60
+ return cached
61
+ try:
62
+ adapter = get_adapter(family, model_id, revision=revision, **kwargs)
63
+ adapter.load()
64
+ except ModelNotAllowed:
65
+ raise
66
+ except Exception as e:
67
+ # Weights that will not download or will not fit are the single most
68
+ # likely runtime failure, and the UI has a designed state for it.
69
+ raise ForecastUnavailable(
70
+ f"{model_id} could not be loaded: {e}", kind="load_failure") from e
71
+ _ADAPTERS[key] = adapter
72
+ return adapter
73
+
74
+
75
+ def clear_adapter_cache() -> None:
76
+ _ADAPTERS.clear()
77
+
78
+
79
+ # --------------------------------------------------------------------------
80
+ # Forecasting
81
+ # --------------------------------------------------------------------------
82
+
83
+
84
+ @dataclass
85
+ class ForecastRun:
86
+ """Everything the UI needs about one issued forecast."""
87
+
88
+ model_slug: str
89
+ model_id: str
90
+ family: str
91
+ asset: str
92
+ timeframe: str
93
+ horizon: int
94
+ issued_ts: pd.Timestamp
95
+ target_ts: pd.DatetimeIndex
96
+ result: ForecastResult
97
+ context: pd.DataFrame
98
+ forecast_id: str = ""
99
+ archived_rows: int = 0
100
+ elapsed_s: float = 0.0
101
+ capabilities: dict = field(default_factory=dict)
102
+
103
+
104
+ def future_timestamps(context: pd.DataFrame, horizon: int) -> pd.DatetimeIndex:
105
+ """Continue the context's own cadence forward by `horizon` bars."""
106
+ ts = pd.to_datetime(context["ts"], utc=True)
107
+ deltas = ts.diff().dropna()
108
+ if not len(deltas):
109
+ raise AdapterError("cannot infer cadence from a single bar")
110
+ modal = deltas.mode()
111
+ step = modal.iloc[0] if len(modal) else deltas.median()
112
+ last = ts.iloc[-1]
113
+ return pd.DatetimeIndex([last + step * (i + 1) for i in range(horizon)])
114
+
115
+
116
+ def load_context(store: ArenaStore, asset: str, timeframe: str,
117
+ as_of=None, context_len: int = 512) -> pd.DataFrame:
118
+ """The most recent validated bars at or before `as_of`.
119
+
120
+ Slicing on `as_of` here rather than in the caller is what makes the
121
+ no-lookahead guarantee structural: every context the app ever builds goes
122
+ through this function, and it cannot return a bar past the cut.
123
+ """
124
+ prices = store.get_prices(asset, timeframe)
125
+ if not len(prices):
126
+ raise ForecastUnavailable(
127
+ f"no price history cached for {asset} {timeframe}", kind="no_data")
128
+ if as_of is not None:
129
+ cut = pd.Timestamp(as_of)
130
+ cut = cut.tz_localize("UTC") if cut.tzinfo is None else cut.tz_convert("UTC")
131
+ prices = prices[prices["ts"] <= cut]
132
+ if len(prices) < 64:
133
+ raise ForecastUnavailable(
134
+ f"only {len(prices)} bars available for {asset} {timeframe}",
135
+ kind="no_data")
136
+ return prices.iloc[-context_len:].reset_index(drop=True)
137
+
138
+
139
+ def run_forecast(store: ArenaStore, model_slug: str, asset: str, timeframe: str,
140
+ horizon: int | None = None,
141
+ n_samples: int = config.DEFAULT_N_SAMPLES,
142
+ seed: int = 0, as_of=None, registry: dict | None = None,
143
+ archive: bool = True, backfilled: bool = False) -> ForecastRun:
144
+ """Issue one forecast, archive it, and return everything needed to draw it."""
145
+ registry = registry if registry is not None else store.get_registry()
146
+ entry = registry.get("models", {}).get(model_slug)
147
+ if entry is None:
148
+ raise ForecastUnavailable(
149
+ f"{model_slug} is not enrolled in the Arena", kind="not_enrolled")
150
+
151
+ horizon = int(horizon or config.DEFAULT_HORIZON.get(timeframe, 24))
152
+ max_h = config.MAX_HORIZON.get(timeframe, 168)
153
+ if not 1 <= horizon <= max_h:
154
+ raise ForecastUnavailable(
155
+ f"horizon must be between 1 and {max_h} for {timeframe}",
156
+ kind="bad_request")
157
+
158
+ adapter = adapter_for(entry["family"], entry["model_id"],
159
+ revision=entry.get("revision"))
160
+ caps = adapter.capabilities()
161
+
162
+ # The smaller of what the model can take and what the Arena spends. See
163
+ # config.DEFAULT_CONTEXT_BARS for why the ceiling is not the model's own.
164
+ context = load_context(store, asset, timeframe, as_of=as_of,
165
+ context_len=min(caps.max_context,
166
+ config.DEFAULT_CONTEXT_BARS))
167
+ issued_ts = pd.to_datetime(context["ts"], utc=True).iloc[-1]
168
+ targets = future_timestamps(context, horizon)
169
+
170
+ started = time.time()
171
+ result = adapter.predict(context, horizon=horizon, n_samples=n_samples,
172
+ seed=int(seed), issued_ts=issued_ts)
173
+ elapsed = time.time() - started
174
+
175
+ run = ForecastRun(
176
+ model_slug=model_slug, model_id=entry["model_id"], family=entry["family"],
177
+ asset=asset, timeframe=timeframe, horizon=horizon,
178
+ issued_ts=issued_ts, target_ts=targets, result=result, context=context,
179
+ elapsed_s=elapsed, capabilities=caps.as_dict(),
180
+ )
181
+
182
+ if archive:
183
+ fid, written = trackrecord.archive(
184
+ store, result, model_slug, asset, timeframe, issued_ts, targets,
185
+ backfilled=backfilled)
186
+ run.forecast_id, run.archived_rows = fid, written
187
+
188
+ return run
189
+
190
+
191
+ # --------------------------------------------------------------------------
192
+ # Enrollment
193
+ # --------------------------------------------------------------------------
194
+
195
+
196
+ @dataclass
197
+ class Enrollment:
198
+ ok: bool
199
+ model_slug: str = ""
200
+ message: str = ""
201
+ entry: dict | None = None
202
+ already: bool = False
203
+
204
+
205
+ def enroll(store: ArenaStore, family: str, model_id: str,
206
+ enrolled_by: str = "anonymous", registry: dict | None = None,
207
+ smoke_steps: int = config.CAPS.smoke_test_steps) -> Enrollment:
208
+ """Validate, pin, smoke-test and register a model.
209
+
210
+ Enrolling an (id, revision) that is already registered is a no-op rather
211
+ than an error: the flow is idempotent so that a double-submitted form or a
212
+ retried job cannot fork the registry.
213
+ """
214
+ try:
215
+ model_id = validate_model_id(model_id)
216
+ except AdapterError as e:
217
+ return Enrollment(ok=False, message=str(e))
218
+
219
+ fam = (family or "").strip().lower()
220
+ if fam not in ALLOWED_ADAPTER_FAMILIES:
221
+ return Enrollment(ok=False, message=(
222
+ f"'{family}' is not a supported adapter family. The Arena runs "
223
+ f"models only through vetted loaders, so a model outside "
224
+ f"{', '.join(ALLOWED_ADAPTER_FAMILIES)} cannot be enrolled."))
225
+
226
+ known = family_for(model_id)
227
+ if known is not None and known != fam:
228
+ return Enrollment(ok=False, message=(
229
+ f"{model_id} is a {known} model; enrolling it as {fam} would load "
230
+ f"it with the wrong loader."))
231
+
232
+ registry = registry if registry is not None else store.get_registry()
233
+ slug = slug_for(model_id)
234
+
235
+ try:
236
+ adapter = get_adapter(fam, model_id)
237
+ revision = adapter.resolve_revision()
238
+ except ModelNotAllowed as e:
239
+ return Enrollment(ok=False, message=str(e))
240
+ except Exception as e:
241
+ return Enrollment(ok=False, message=(
242
+ f"could not reach {model_id} on the Hub: {e}"))
243
+
244
+ existing = registry.get("models", {}).get(slug)
245
+ if existing and existing.get("revision") == revision:
246
+ return Enrollment(ok=True, model_slug=slug, already=True,
247
+ entry=existing,
248
+ message=f"{model_id} is already enrolled at this revision.")
249
+
250
+ try:
251
+ smoke = _smoke_test(adapter, steps=smoke_steps)
252
+ except Exception as e:
253
+ return Enrollment(ok=False, message=f"{model_id} failed its smoke test: {e}")
254
+
255
+ caps = adapter.capabilities()
256
+ entry = {
257
+ "model_slug": slug,
258
+ "model_id": model_id,
259
+ "family": fam,
260
+ "revision": revision,
261
+ "display": model_id.split("/", 1)[1],
262
+ "capabilities": caps.as_dict(),
263
+ "components": adapter.component_versions(),
264
+ "inference_version": adapter.inference_version(),
265
+ "enrolled_by": str(enrolled_by or "anonymous")[:64],
266
+ "enrolled_ts": now_utc().isoformat(),
267
+ "smoke_test": smoke,
268
+ }
269
+ registry.setdefault("models", {})[slug] = entry
270
+ store.put_registry(registry)
271
+ return Enrollment(ok=True, model_slug=slug, entry=entry,
272
+ message=f"{model_id} enrolled as '{slug}'.")
273
+
274
+
275
+ def _smoke_test(adapter: ForecastAdapter, steps: int) -> dict:
276
+ """Forecast a synthetic series and check the output is usable.
277
+
278
+ Synthetic rather than real prices so enrollment works before the cache has
279
+ any coverage for a new asset, and so the test is identical every time.
280
+ """
281
+ import numpy as np
282
+
283
+ n = max(128, steps)
284
+ rng = np.random.default_rng(0)
285
+ close = 100.0 * np.exp(np.cumsum(rng.normal(0, 0.01, n)))
286
+ frame = pd.DataFrame({
287
+ "ts": pd.date_range("2025-01-01", periods=n, freq="1h", tz="UTC"),
288
+ "open": close, "high": close * 1.001, "low": close * 0.999,
289
+ "close": close, "volume": 1000.0,
290
+ })
291
+
292
+ horizon = min(16, max(1, steps // 8))
293
+ started = time.time()
294
+ result = adapter.predict(frame, horizon=horizon, n_samples=8, seed=0)
295
+ elapsed = time.time() - started
296
+
297
+ lo, hi = result.band()
298
+ if not (lo <= hi).all():
299
+ raise AdapterError("smoke test produced a crossed band")
300
+
301
+ return {
302
+ "ok": True,
303
+ "steps": int(n),
304
+ "horizon": int(horizon),
305
+ "elapsed_s": round(elapsed, 3),
306
+ "emits_paths": bool(result.paths is not None),
307
+ "ran_ts": now_utc().isoformat(),
308
+ }
src/store.py ADDED
@@ -0,0 +1,400 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Read and write access to the signal store.
2
+
3
+ The store is a Hugging Face dataset repo laid out as parquet under stable
4
+ paths. `local_root` is a working mirror: reads fall back to the Hub when a file
5
+ is absent locally, writes stage into the mirror, and `flush()` pushes them as
6
+ one commit.
7
+
8
+ The rule that shapes this whole module is **frozen at issue**. An archived
9
+ forecast row is written once and never rewritten. Resolution appends to a
10
+ different tree; it does not revise the archive. So `append_forecast` is
11
+ first-write-wins on its dedup key rather than last-write-wins, which is the
12
+ opposite of what a cache would do and is the entire point: a track record whose
13
+ history can be edited after the fact is not evidence of anything.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import hashlib
19
+ import json
20
+ import logging
21
+ import os
22
+ import threading
23
+ from pathlib import Path
24
+
25
+ import numpy as np
26
+ import pandas as pd
27
+
28
+ from . import config
29
+
30
+ log = logging.getLogger("arena.store")
31
+
32
+
33
+ class StoreError(RuntimeError):
34
+ pass
35
+
36
+
37
+ # --------------------------------------------------------------------------
38
+ # Schemas
39
+ # --------------------------------------------------------------------------
40
+
41
+ # One row per (forecast, step). Flat rather than array-valued because every
42
+ # consumer -- the resolver, the standings, the chart -- wants steps as rows.
43
+ FORECAST_COLUMNS = [
44
+ "forecast_id", "model_slug", "asset", "timeframe",
45
+ "issued_ts", "target_ts", "step", "horizon_bars",
46
+ "seed", "n_samples", "context_len", "inference_version",
47
+ "dispersion", "path_ref", "backfilled",
48
+ ] + [f"q{int(round(q * 100)):02d}" for q in config.QUANTILE_LEVELS]
49
+
50
+ QUANTILE_COLUMNS = [f"q{int(round(q * 100)):02d}" for q in config.QUANTILE_LEVELS]
51
+
52
+ # Resolved outcomes. Append-only, never revised.
53
+ TRACKRECORD_COLUMNS = [
54
+ "forecast_id", "model_slug", "asset", "timeframe",
55
+ "issued_ts", "target_ts", "step", "horizon_bars",
56
+ "realized_close", "q10", "q50", "q90", "q20", "q80",
57
+ "inside_80", "inside_60",
58
+ "abs_error", "pct_error", "resolved_ts", "backfilled",
59
+ ]
60
+
61
+ STANDINGS_COLUMNS = [
62
+ "model_slug", "asset_class", "resolved_count", "forecast_count",
63
+ "coverage_80", "coverage_60", "median_abs_pct_error",
64
+ "calibration_gap", "grade", "updated_ts",
65
+ ]
66
+
67
+
68
+ def forecast_id(model_slug: str, asset: str, timeframe: str,
69
+ issued_ts, seed: int) -> str:
70
+ """The dedup key, as a short stable hash.
71
+
72
+ Deliberately *excludes* `inference_version`. Re-running the same request
73
+ after a model upgrade must not append a second row: the first answer is the
74
+ one that was issued, and the archive records what was issued. The version
75
+ that produced it is kept as a column so the row is still traceable.
76
+ """
77
+ payload = "|".join([
78
+ str(model_slug), str(asset), str(timeframe),
79
+ _iso(issued_ts), str(int(seed)),
80
+ ])
81
+ return hashlib.sha256(payload.encode()).hexdigest()[:16]
82
+
83
+
84
+ def _utc(ts) -> pd.Timestamp:
85
+ t = pd.Timestamp(ts)
86
+ return t.tz_localize("UTC") if t.tzinfo is None else t.tz_convert("UTC")
87
+
88
+
89
+ def _iso(ts) -> str:
90
+ return _utc(ts).isoformat()
91
+
92
+
93
+ def now_utc() -> pd.Timestamp:
94
+ """A tz-aware "now".
95
+
96
+ `pd.Timestamp.utcnow()` returns tz-aware on current pandas and tz-naive on
97
+ older ones, so localising it unconditionally raises. Every "now" in the app
98
+ comes through here so that difference is handled once.
99
+ """
100
+ return _utc(pd.Timestamp.utcnow())
101
+
102
+
103
+ # --------------------------------------------------------------------------
104
+ # Store
105
+ # --------------------------------------------------------------------------
106
+
107
+
108
+ class ArenaStore:
109
+ """Parquet access to the Arena's trees in the signal store.
110
+
111
+ Set `offline=True` (or leave `repo_id=None`) for a purely local store --
112
+ what the tests and the backfill script use before they have a token.
113
+ """
114
+
115
+ def __init__(self, repo_id: str | None = config.STORE_REPO,
116
+ local_root: str | os.PathLike | None = None,
117
+ token: str | None = None, offline: bool = False,
118
+ revision: str = "main") -> None:
119
+ self.repo_id = repo_id
120
+ self.revision = revision
121
+ self.token = token or os.environ.get("HF_WRITE_TOKEN") or os.environ.get("HF_TOKEN")
122
+ self.offline = offline or repo_id is None
123
+ self.local_root = Path(local_root
124
+ or os.environ.get("BIT_STORE_CACHE", ".cache/store"))
125
+ self.local_root.mkdir(parents=True, exist_ok=True)
126
+ self._pending: set[str] = set()
127
+ self._price_cache: dict[tuple[str, str], pd.DataFrame] = {}
128
+ self._lock = threading.RLock()
129
+
130
+ # -- low-level --------------------------------------------------------
131
+
132
+ def _local(self, repo_path: str) -> Path:
133
+ return self.local_root / repo_path
134
+
135
+ def _fetch(self, repo_path: str) -> Path | None:
136
+ local = self._local(repo_path)
137
+ if local.exists():
138
+ return local
139
+ if self.offline:
140
+ return None
141
+ try:
142
+ from huggingface_hub import hf_hub_download
143
+
144
+ return Path(hf_hub_download(
145
+ repo_id=self.repo_id, repo_type=config.STORE_REPO_TYPE,
146
+ filename=repo_path, revision=self.revision, token=self.token,
147
+ ))
148
+ except Exception:
149
+ # Absent from the Hub is a normal "no data" answer, not a fault.
150
+ return None
151
+
152
+ def read_parquet(self, repo_path: str) -> pd.DataFrame | None:
153
+ p = self._fetch(repo_path)
154
+ if p is None:
155
+ return None
156
+ try:
157
+ return pd.read_parquet(p)
158
+ except Exception as e: # pragma: no cover
159
+ log.warning("unreadable parquet %s: %s", repo_path, e)
160
+ return None
161
+
162
+ def read_json(self, repo_path: str) -> dict | None:
163
+ p = self._fetch(repo_path)
164
+ if p is None:
165
+ return None
166
+ try:
167
+ return json.loads(Path(p).read_text())
168
+ except Exception as e: # pragma: no cover
169
+ log.warning("unreadable json %s: %s", repo_path, e)
170
+ return None
171
+
172
+ def _stage(self, repo_path: str, write) -> None:
173
+ local = self._local(repo_path)
174
+ local.parent.mkdir(parents=True, exist_ok=True)
175
+ write(local)
176
+ with self._lock:
177
+ self._pending.add(repo_path)
178
+
179
+ def write_parquet(self, repo_path: str, df: pd.DataFrame) -> None:
180
+ self._stage(repo_path, lambda p: df.to_parquet(p, index=False))
181
+ if repo_path.startswith("prices/"):
182
+ self.invalidate_prices()
183
+
184
+ def write_json(self, repo_path: str, payload: dict) -> None:
185
+ self._stage(repo_path,
186
+ lambda p: Path(p).write_text(json.dumps(payload, indent=2, sort_keys=True)))
187
+
188
+ @property
189
+ def pending(self) -> list[str]:
190
+ with self._lock:
191
+ return sorted(self._pending)
192
+
193
+ def flush(self, message: str = "arena: update") -> int:
194
+ """Push everything staged as one commit. Returns the file count."""
195
+ with self._lock:
196
+ paths = sorted(self._pending)
197
+ if not paths or self.offline:
198
+ self._pending.clear()
199
+ return 0
200
+ from huggingface_hub import CommitOperationAdd, HfApi
201
+
202
+ ops = [CommitOperationAdd(path_in_repo=p, path_or_fileobj=str(self._local(p)))
203
+ for p in paths]
204
+ HfApi(token=self.token).create_commit(
205
+ repo_id=self.repo_id, repo_type=config.STORE_REPO_TYPE,
206
+ operations=ops, commit_message=message, revision=self.revision,
207
+ )
208
+ with self._lock:
209
+ self._pending.clear()
210
+ return len(ops)
211
+
212
+ # -- prices -----------------------------------------------------------
213
+
214
+ def get_prices(self, asset: str, timeframe: str,
215
+ start=None, end=None) -> pd.DataFrame:
216
+ """OHLCV slice with a `ts` column, sorted ascending.
217
+
218
+ Reads the existing price cache written by the shared pipeline; the
219
+ Arena never fetches a price in the request path.
220
+
221
+ The full series is memoised per (asset, timeframe). Without it the
222
+ backfill re-read and re-concatenated ~9,600 rows of parquet for every
223
+ one of its thousands of as-of forecasts, which dominated the run;
224
+ `invalidate_prices` drops the memo after a refresh writes new bars.
225
+ """
226
+ key = (asset, timeframe)
227
+ cached = self._price_cache.get(key)
228
+ if cached is not None:
229
+ return self._slice(cached, start, end)
230
+ full = self._read_prices(asset, timeframe)
231
+ self._price_cache[key] = full
232
+ return self._slice(full, start, end)
233
+
234
+ @staticmethod
235
+ def _slice(frame: pd.DataFrame, start, end) -> pd.DataFrame:
236
+ if start is None and end is None:
237
+ return frame
238
+ out = frame
239
+ if start is not None:
240
+ out = out[out["ts"] >= _utc(start)]
241
+ if end is not None:
242
+ out = out[out["ts"] <= _utc(end)]
243
+ return out.reset_index(drop=True)
244
+
245
+ def invalidate_prices(self, asset: str | None = None,
246
+ timeframe: str | None = None) -> None:
247
+ if asset is None:
248
+ self._price_cache.clear()
249
+ return
250
+ self._price_cache.pop((asset, timeframe), None)
251
+
252
+ def _read_prices(self, asset: str, timeframe: str) -> pd.DataFrame:
253
+ start = end = None
254
+ years = _years_between(start, end)
255
+ frames = []
256
+ for y in years:
257
+ df = self.read_parquet(config.prices_path(asset, timeframe, y))
258
+ if df is not None and len(df):
259
+ frames.append(df)
260
+ if not frames:
261
+ return pd.DataFrame(columns=["ts", "open", "high", "low", "close", "volume"])
262
+ out = pd.concat(frames, ignore_index=True)
263
+ out["ts"] = pd.to_datetime(out["ts"], utc=True)
264
+ return out.drop_duplicates(subset=["ts"]).sort_values("ts").reset_index(drop=True)
265
+
266
+ # -- forecasts (the archive) ------------------------------------------
267
+
268
+ def get_forecasts(self, model_slug: str, asset: str, timeframe: str,
269
+ years: list[int] | None = None) -> pd.DataFrame:
270
+ years = years or _years_between(None, None)
271
+ frames = []
272
+ for y in years:
273
+ df = self.read_parquet(config.signals_path(model_slug, asset, timeframe, y))
274
+ if df is not None and len(df):
275
+ frames.append(df)
276
+ if not frames:
277
+ return pd.DataFrame(columns=FORECAST_COLUMNS)
278
+ out = pd.concat(frames, ignore_index=True)
279
+ out["issued_ts"] = pd.to_datetime(out["issued_ts"], utc=True)
280
+ out["target_ts"] = pd.to_datetime(out["target_ts"], utc=True)
281
+ return out
282
+
283
+ def append_forecast(self, rows: pd.DataFrame) -> int:
284
+ """Archive one forecast. Returns rows actually written (0 if a dup).
285
+
286
+ First write wins. A forecast already present under this id is left
287
+ exactly as it was -- see the module docstring.
288
+ """
289
+ if rows is None or not len(rows):
290
+ return 0
291
+ missing = [c for c in FORECAST_COLUMNS if c not in rows.columns]
292
+ if missing:
293
+ raise StoreError(f"forecast rows missing columns {missing}")
294
+
295
+ fid = str(rows["forecast_id"].iloc[0])
296
+ if rows["forecast_id"].nunique() != 1:
297
+ raise StoreError("append_forecast takes one forecast at a time")
298
+
299
+ model_slug = str(rows["model_slug"].iloc[0])
300
+ asset = str(rows["asset"].iloc[0])
301
+ tf = str(rows["timeframe"].iloc[0])
302
+ year = int(_utc(rows["issued_ts"].iloc[0]).year)
303
+ path = config.signals_path(model_slug, asset, tf, year)
304
+
305
+ existing = self.read_parquet(path)
306
+ if existing is not None and len(existing):
307
+ if fid in set(existing["forecast_id"].astype(str)):
308
+ return 0
309
+ merged = pd.concat([existing, rows[FORECAST_COLUMNS]], ignore_index=True)
310
+ else:
311
+ merged = rows[FORECAST_COLUMNS].copy()
312
+
313
+ merged = merged.sort_values(["issued_ts", "step"]).reset_index(drop=True)
314
+ self.write_parquet(path, merged)
315
+ return len(rows)
316
+
317
+ # -- track record -----------------------------------------------------
318
+
319
+ def get_trackrecord(self, model_slug: str, asset: str,
320
+ timeframe: str) -> pd.DataFrame:
321
+ df = self.read_parquet(config.trackrecord_path(model_slug, asset, timeframe))
322
+ if df is None or not len(df):
323
+ return pd.DataFrame(columns=TRACKRECORD_COLUMNS)
324
+ df["issued_ts"] = pd.to_datetime(df["issued_ts"], utc=True)
325
+ df["target_ts"] = pd.to_datetime(df["target_ts"], utc=True)
326
+ return df
327
+
328
+ def append_trackrecord(self, model_slug: str, asset: str, timeframe: str,
329
+ rows: pd.DataFrame) -> int:
330
+ """Append resolved outcomes. Idempotent on (forecast_id, step)."""
331
+ if rows is None or not len(rows):
332
+ return 0
333
+ path = config.trackrecord_path(model_slug, asset, timeframe)
334
+ existing = self.get_trackrecord(model_slug, asset, timeframe)
335
+ if len(existing):
336
+ seen = set(zip(existing["forecast_id"].astype(str),
337
+ existing["step"].astype(int)))
338
+ mask = [(str(f), int(s)) not in seen
339
+ for f, s in zip(rows["forecast_id"], rows["step"])]
340
+ rows = rows[np.asarray(mask)]
341
+ if not len(rows):
342
+ return 0
343
+ merged = pd.concat([existing, rows[TRACKRECORD_COLUMNS]], ignore_index=True)
344
+ else:
345
+ merged = rows[TRACKRECORD_COLUMNS].copy()
346
+ merged = merged.sort_values(["issued_ts", "step"]).reset_index(drop=True)
347
+ self.write_parquet(path, merged)
348
+ return len(rows)
349
+
350
+ def all_trackrecords(self) -> pd.DataFrame:
351
+ """Every resolved row in the store, for the standings rebuild.
352
+
353
+ Walks the local mirror. The scheduled job syncs the tree first, so this
354
+ sees the whole store; in the Space it sees whatever has been fetched,
355
+ which is why standings are *read* there and never rebuilt.
356
+ """
357
+ root = self.local_root / "arena" / "trackrecord"
358
+ if not root.exists():
359
+ return pd.DataFrame(columns=TRACKRECORD_COLUMNS)
360
+ frames = []
361
+ for p in sorted(root.rglob("*.parquet")):
362
+ try:
363
+ frames.append(pd.read_parquet(p))
364
+ except Exception as e: # pragma: no cover
365
+ log.warning("skipping unreadable trackrecord %s: %s", p, e)
366
+ if not frames:
367
+ return pd.DataFrame(columns=TRACKRECORD_COLUMNS)
368
+ out = pd.concat(frames, ignore_index=True)
369
+ out["issued_ts"] = pd.to_datetime(out["issued_ts"], utc=True)
370
+ out["target_ts"] = pd.to_datetime(out["target_ts"], utc=True)
371
+ return out
372
+
373
+ # -- standings and registry -------------------------------------------
374
+
375
+ def get_standings(self) -> pd.DataFrame:
376
+ df = self.read_parquet(config.STANDINGS_PATH)
377
+ if df is None:
378
+ return pd.DataFrame(columns=STANDINGS_COLUMNS)
379
+ return df
380
+
381
+ def put_standings(self, df: pd.DataFrame) -> None:
382
+ self.write_parquet(config.STANDINGS_PATH, df[STANDINGS_COLUMNS])
383
+
384
+ def get_registry(self) -> dict:
385
+ reg = self.read_json(config.REGISTRY_PATH)
386
+ if not reg:
387
+ return {"version": 1, "models": {}}
388
+ reg.setdefault("models", {})
389
+ return reg
390
+
391
+ def put_registry(self, registry: dict) -> None:
392
+ self.write_json(config.REGISTRY_PATH, registry)
393
+
394
+
395
+ def _years_between(start, end) -> list[int]:
396
+ """Year partitions to look in. Defaults to a window around now."""
397
+ now = now_utc()
398
+ s = _utc(start).year if start is not None else now.year - 3
399
+ e = _utc(end).year if end is not None else now.year + 1
400
+ return list(range(int(s), int(e) + 1))
src/trackrecord.py ADDED
@@ -0,0 +1,241 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Archiving, resolution and standings.
2
+
3
+ The pipeline is three steps, and the boundary between them is the point.
4
+
5
+ 1. `archive` writes a forecast at the moment it is issued. Those rows are
6
+ final. Nothing downstream ever rewrites them.
7
+ 2. `resolve` looks for archived forecasts whose horizon has now elapsed,
8
+ compares them against realised prices, and *appends* the outcome to a
9
+ separate tree.
10
+ 3. `build_standings` derives the leaderboard from the resolved outcomes and
11
+ nothing else, so it is a pure function of the track record and can be
12
+ regenerated from scratch at any time.
13
+
14
+ Keeping resolution out of the archive is what makes the record evidence rather
15
+ than marketing. If a bad forecast could be quietly corrected once the outcome
16
+ was known, the calibration numbers would measure nothing at all.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import logging
22
+
23
+ import numpy as np
24
+ import pandas as pd
25
+
26
+ from . import config
27
+ from .adapters import ForecastResult
28
+ from .store import (FORECAST_COLUMNS, QUANTILE_COLUMNS, STANDINGS_COLUMNS,
29
+ TRACKRECORD_COLUMNS, ArenaStore, forecast_id, now_utc)
30
+
31
+ log = logging.getLogger("arena.trackrecord")
32
+
33
+
34
+ def _as_utc(ts) -> pd.Timestamp:
35
+ """Normalise a caller-supplied "now" to tz-aware UTC."""
36
+ if ts is None:
37
+ return now_utc()
38
+ t = pd.Timestamp(ts)
39
+ return t.tz_localize("UTC") if t.tzinfo is None else t.tz_convert("UTC")
40
+
41
+
42
+ # --------------------------------------------------------------------------
43
+ # Archive
44
+ # --------------------------------------------------------------------------
45
+
46
+
47
+ def forecast_rows(result: ForecastResult, model_slug: str, asset: str,
48
+ timeframe: str, issued_ts, target_ts,
49
+ backfilled: bool = False, path_ref: str | None = None) -> pd.DataFrame:
50
+ """Turn an adapter result into archive rows -- one per forecast step."""
51
+ if len(target_ts) != result.horizon:
52
+ raise ValueError(
53
+ f"{len(target_ts)} target timestamps for a horizon of {result.horizon}")
54
+
55
+ fid = forecast_id(model_slug, asset, timeframe, issued_ts, result.seed)
56
+ dispersion = result.dispersion()
57
+
58
+ data = {
59
+ "forecast_id": fid,
60
+ "model_slug": model_slug,
61
+ "asset": asset,
62
+ "timeframe": timeframe,
63
+ "issued_ts": pd.to_datetime([issued_ts] * result.horizon, utc=True),
64
+ "target_ts": pd.to_datetime(list(target_ts), utc=True),
65
+ "step": np.arange(1, result.horizon + 1, dtype="int32"),
66
+ "horizon_bars": np.int32(result.horizon),
67
+ "seed": np.int64(result.seed),
68
+ "n_samples": np.int32(result.n_samples),
69
+ "context_len": np.int32(result.context_len),
70
+ "inference_version": result.inference_version,
71
+ "dispersion": dispersion.astype("float64"),
72
+ # A reference, not the paths themselves: whole sampled paths are far
73
+ # too heavy to inline into every archive row.
74
+ "path_ref": path_ref or "",
75
+ "backfilled": bool(backfilled),
76
+ }
77
+ for i, level in enumerate(result.levels):
78
+ data[f"q{int(round(level * 100)):02d}"] = result.quantiles[:, i]
79
+
80
+ return pd.DataFrame(data)[FORECAST_COLUMNS]
81
+
82
+
83
+ def archive(store: ArenaStore, result: ForecastResult, model_slug: str,
84
+ asset: str, timeframe: str, issued_ts, target_ts,
85
+ backfilled: bool = False) -> tuple[str, int]:
86
+ """Archive a forecast. Returns (forecast_id, rows written; 0 if a duplicate)."""
87
+ rows = forecast_rows(result, model_slug, asset, timeframe,
88
+ issued_ts, target_ts, backfilled=backfilled)
89
+ written = store.append_forecast(rows)
90
+ return str(rows["forecast_id"].iloc[0]), written
91
+
92
+
93
+ # --------------------------------------------------------------------------
94
+ # Resolve
95
+ # --------------------------------------------------------------------------
96
+
97
+
98
+ def resolve(store: ArenaStore, model_slug: str, asset: str, timeframe: str,
99
+ now=None) -> pd.DataFrame:
100
+ """Resolve every archived forecast whose target bar has printed.
101
+
102
+ Idempotent: rows already in the track record are skipped, so a re-run adds
103
+ nothing. It never touches the archive.
104
+ """
105
+ now = _as_utc(now)
106
+
107
+ forecasts = store.get_forecasts(model_slug, asset, timeframe)
108
+ if not len(forecasts):
109
+ return pd.DataFrame(columns=TRACKRECORD_COLUMNS)
110
+
111
+ already = store.get_trackrecord(model_slug, asset, timeframe)
112
+ seen = set(zip(already["forecast_id"].astype(str), already["step"].astype(int))) \
113
+ if len(already) else set()
114
+
115
+ due = forecasts[forecasts["target_ts"] <= now]
116
+ if len(due):
117
+ mask = [(str(f), int(s)) not in seen
118
+ for f, s in zip(due["forecast_id"], due["step"])]
119
+ due = due[np.asarray(mask)]
120
+ if not len(due):
121
+ return pd.DataFrame(columns=TRACKRECORD_COLUMNS)
122
+
123
+ prices = store.get_prices(asset, timeframe,
124
+ start=due["target_ts"].min(),
125
+ end=due["target_ts"].max())
126
+ if not len(prices):
127
+ log.info("no prices to resolve %s/%s/%s against", model_slug, asset, timeframe)
128
+ return pd.DataFrame(columns=TRACKRECORD_COLUMNS)
129
+
130
+ realized = prices.set_index("ts")["close"]
131
+ joined = due.copy()
132
+ joined["realized_close"] = joined["target_ts"].map(realized)
133
+ # A target bar that has not printed -- a market holiday, a gap in the cache
134
+ # -- is left unresolved rather than filled with the nearest neighbour.
135
+ # Resolving against a price from the wrong bar would be a silent lie.
136
+ joined = joined[joined["realized_close"].notna()]
137
+ if not len(joined):
138
+ return pd.DataFrame(columns=TRACKRECORD_COLUMNS)
139
+
140
+ out = pd.DataFrame({
141
+ "forecast_id": joined["forecast_id"].astype(str),
142
+ "model_slug": model_slug,
143
+ "asset": asset,
144
+ "timeframe": timeframe,
145
+ "issued_ts": joined["issued_ts"],
146
+ "target_ts": joined["target_ts"],
147
+ "step": joined["step"].astype("int32"),
148
+ "horizon_bars": joined["horizon_bars"].astype("int32"),
149
+ "realized_close": joined["realized_close"].astype("float64"),
150
+ "q10": joined["q10"].astype("float64"),
151
+ "q20": joined["q20"].astype("float64"),
152
+ "q50": joined["q50"].astype("float64"),
153
+ "q80": joined["q80"].astype("float64"),
154
+ "q90": joined["q90"].astype("float64"),
155
+ "resolved_ts": now,
156
+ "backfilled": joined["backfilled"].astype(bool),
157
+ })
158
+ out["inside_80"] = ((out["realized_close"] >= out["q10"]) &
159
+ (out["realized_close"] <= out["q90"]))
160
+ out["inside_60"] = ((out["realized_close"] >= out["q20"]) &
161
+ (out["realized_close"] <= out["q80"]))
162
+ out["abs_error"] = (out["realized_close"] - out["q50"]).abs()
163
+ out["pct_error"] = out["abs_error"] / out["realized_close"].abs().replace(0, np.nan) * 100.0
164
+
165
+ out = out[TRACKRECORD_COLUMNS]
166
+ store.append_trackrecord(model_slug, asset, timeframe, out)
167
+ return out
168
+
169
+
170
+ # --------------------------------------------------------------------------
171
+ # Standings
172
+ # --------------------------------------------------------------------------
173
+
174
+
175
+ def grade_for(gap: float, resolved_count: int) -> str:
176
+ """Map a calibration gap to a letter, or to '-' when there is too little.
177
+
178
+ Refusing to grade below `MIN_RESOLVED_FOR_GRADE` is deliberate: with a
179
+ handful of observations the coverage estimate is mostly noise, and a
180
+ confident 'A' computed from four forecasts would be the single most
181
+ misleading thing on the page.
182
+ """
183
+ if resolved_count < config.MIN_RESOLVED_FOR_GRADE:
184
+ return "-"
185
+ if gap is None or not np.isfinite(gap):
186
+ return "-"
187
+ for letter, threshold in config.GRADE_THRESHOLDS:
188
+ if abs(gap) <= threshold:
189
+ return letter
190
+ return config.GRADE_THRESHOLDS[-1][0]
191
+
192
+
193
+ def build_standings(trackrecord: pd.DataFrame, now=None) -> pd.DataFrame:
194
+ """Derive the standings table. A pure function of the track record.
195
+
196
+ Grouped by (model, asset_class) rather than by asset: a model's calibration
197
+ on crypto and on equities are different claims, but BTC and ETH are not
198
+ independent enough for separate grades to mean much.
199
+ """
200
+ now = _as_utc(now)
201
+
202
+ if trackrecord is None or not len(trackrecord):
203
+ return pd.DataFrame(columns=STANDINGS_COLUMNS)
204
+
205
+ df = trackrecord.copy()
206
+ df["asset_class"] = df["asset"].map(
207
+ lambda a: config.ASSETS[a].asset_class if a in config.ASSETS else "other")
208
+
209
+ rows = []
210
+ for (model_slug, asset_class), group in df.groupby(["model_slug", "asset_class"],
211
+ sort=True):
212
+ resolved = len(group)
213
+ coverage_80 = float(group["inside_80"].mean())
214
+ coverage_60 = float(group["inside_60"].mean())
215
+ median_err = float(group["pct_error"].median())
216
+ gap = coverage_80 - config.NOMINAL_COVERAGE
217
+ rows.append({
218
+ "model_slug": model_slug,
219
+ "asset_class": asset_class,
220
+ "resolved_count": int(resolved),
221
+ "forecast_count": int(group["forecast_id"].nunique()),
222
+ "coverage_80": coverage_80,
223
+ "coverage_60": coverage_60,
224
+ "median_abs_pct_error": median_err,
225
+ "calibration_gap": float(gap),
226
+ "grade": grade_for(gap, resolved),
227
+ "updated_ts": now,
228
+ })
229
+
230
+ out = pd.DataFrame(rows, columns=STANDINGS_COLUMNS)
231
+ # Deterministic order, so that "same inputs produce the same standings" is
232
+ # true of the bytes and not merely of the contents.
233
+ return out.sort_values(["model_slug", "asset_class"]).reset_index(drop=True)
234
+
235
+
236
+ def regenerate_standings(store: ArenaStore, now=None) -> pd.DataFrame:
237
+ """Rebuild `arena/standings.parquet` from the whole track record."""
238
+ tr = store.all_trackrecords()
239
+ standings = build_standings(tr, now=now)
240
+ store.put_standings(standings)
241
+ return standings
src/ui/__init__.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The Arena's rendering layer.
2
+
3
+ Importing this package puts the vendored `bit-ui` on the path, so every module
4
+ under it can `from bit_ui import ...` without repeating the dance. bit-ui
5
+ travels as a git submodule and is uploaded to the Space as ordinary files, so
6
+ there is nothing to install at boot.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ _VENDOR = Path(__file__).resolve().parents[2] / "vendor" / "bit-ui"
15
+ if _VENDOR.exists() and str(_VENDOR) not in sys.path:
16
+ sys.path.insert(0, str(_VENDOR))
src/ui/chart.py ADDED
@@ -0,0 +1,300 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The forecast chart, as the design's own SVG.
2
+
3
+ Every number in here comes from the design file rather than from taste: the
4
+ 1200x352 viewBox, the 14/252 plot band, the volume strip that starts at y=266
5
+ and draws upward from 322, the 1.4px wicks. Reproducing them exactly is what
6
+ makes the port faithful; rounding them to something tidier is what makes a port
7
+ look approximately right in a way nobody can name.
8
+
9
+ The chart is rendered server-side as markup rather than drawn by a plotting
10
+ library, because the design specifies the shapes directly and a library would
11
+ fight it the whole way.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import numpy as np
17
+ import pandas as pd
18
+
19
+ from ..adapters import OHLCV_COLUMNS
20
+ from bit_ui.markup import DASH, a, e # noqa: F401
21
+
22
+ # --- the design's geometry, verbatim --------------------------------------
23
+ VIEW_W, VIEW_H = 1200, 352
24
+ PLOT_TOP, PLOT_BOTTOM = 14.0, 252.0
25
+ VOL_BASELINE = 266.0 # the rule under the price plot
26
+ VOL_FLOOR = 322.0 # volume bars grow upward from here
27
+ VOL_SCALE = 46.0
28
+ NOW_TOP, NOW_BOTTOM = 8.0, 330.0
29
+ GRID_LINES = 4 # produces 5 labels, hi..lo
30
+ HISTORY_BARS = 56 # how much history the design shows
31
+
32
+ # Series colours, in selection order. The design's own palette.
33
+ SERIES_COLORS = (
34
+ "var(--accent-amber-strong)",
35
+ "var(--mute-teal)",
36
+ "var(--mute-violet)",
37
+ )
38
+ # Matchup fan styles cycle solid / hatch / open, as the design does.
39
+ FAN_DASH = ("none", "6 4", "2 2 6 2")
40
+
41
+
42
+ def series_color(index: int) -> str:
43
+ return SERIES_COLORS[index % len(SERIES_COLORS)]
44
+
45
+
46
+ class Scale:
47
+ """Maps bar index and price onto the design's coordinate system."""
48
+
49
+ def __init__(self, n_history: int, horizon: int, lo: float, hi: float):
50
+ self.n_history = n_history
51
+ self.horizon = horizon
52
+ self.slots = max(1, n_history + horizon)
53
+ self.slot_w = VIEW_W / self.slots
54
+ self.bar_w = max(3.0, self.slot_w * 0.6)
55
+ pad = (hi - lo) * 0.06 or max(abs(hi), 1.0) * 0.01
56
+ self.lo, self.hi = lo - pad, hi + pad
57
+
58
+ def x(self, i: float) -> float:
59
+ return self.slot_w * (i + 0.5)
60
+
61
+ def y(self, v: float) -> float:
62
+ span = self.hi - self.lo
63
+ if span <= 0:
64
+ return PLOT_BOTTOM
65
+ return PLOT_BOTTOM - ((v - self.lo) / span) * (PLOT_BOTTOM - PLOT_TOP)
66
+
67
+
68
+ def _fmt_price(v: float, decimals: int) -> str:
69
+ return f"{v:,.{decimals}f}"
70
+
71
+
72
+ def decimals_for(asset: str) -> int:
73
+ """BTC prints whole dollars in the design; everything else two places."""
74
+ return 0 if asset.startswith("BTC") else 2
75
+
76
+
77
+ def build(history: pd.DataFrame, runs: list, asset: str, horizon: int,
78
+ show_ghosts: bool = True, ghost_limit: int = 7) -> dict:
79
+ """Render the chart. Returns the SVG plus the overlay values around it.
80
+
81
+ `runs` is a list of `ForecastRun`. Ghost paths are drawn only for runs
82
+ whose *declared capability* is `ohlcv_paths` -- never because a result
83
+ happens to carry a `paths` array. That is the capability gate, and it lives
84
+ here so no renderer downstream has to know a model's name.
85
+ """
86
+ hist = history.tail(HISTORY_BARS).reset_index(drop=True)
87
+ n = len(hist)
88
+ if n == 0:
89
+ return {"svg": "", "grid": [], "time_labels": [], "now_left": "0%",
90
+ "now_time": "", "forecast_width": "0"}
91
+
92
+ lo = float(hist["low"].min())
93
+ hi = float(hist["high"].max())
94
+ for run in runs:
95
+ q = run.result.quantiles
96
+ lo = min(lo, float(q.min()))
97
+ hi = max(hi, float(q.max()))
98
+ scale = Scale(n, horizon, lo, hi)
99
+
100
+ parts: list[str] = [
101
+ f'<svg viewBox="0 0 {VIEW_W} {VIEW_H}" preserveAspectRatio="none" '
102
+ f'role="img" aria-label="Price history and forecast for {a(asset)}">',
103
+ '<defs><pattern id="faHatch" width="7" height="7" '
104
+ 'patternUnits="userSpaceOnUse" patternTransform="rotate(45)">'
105
+ '<line x1="0" y1="0" x2="0" y2="7" stroke="var(--text-tertiary)" '
106
+ 'stroke-width="2.4" opacity="0.5"></line></pattern></defs>',
107
+ ]
108
+
109
+ # -- grid ------------------------------------------------------------
110
+ grid = []
111
+ dec = decimals_for(asset)
112
+ for i in range(GRID_LINES + 1):
113
+ v = scale.hi - (scale.hi - scale.lo) * (i / GRID_LINES)
114
+ y = scale.y(v)
115
+ parts.append(f'<line x1="0" y1="{y:.1f}" x2="{VIEW_W}" y2="{y:.1f}" '
116
+ f'stroke="var(--border-subtle)" stroke-width="1" opacity="0.55"></line>')
117
+ grid.append({"top": f"{y / VIEW_H * 100:.2f}%", "label": _fmt_price(v, dec)})
118
+
119
+ # -- the forecast region ---------------------------------------------
120
+ now_x = scale.x(n - 0.5)
121
+ parts.append(f'<rect x="{now_x:.1f}" y="8" width="{VIEW_W - now_x:.1f}" '
122
+ f'height="252" fill="var(--bg-canvas)" opacity="0.5"></rect>')
123
+
124
+ # -- ghost paths, behind the fans ------------------------------------
125
+ if show_ghosts:
126
+ for idx, run in enumerate(runs):
127
+ if run.capabilities.get("output") != "ohlcv_paths":
128
+ continue # the capability gate
129
+ paths = run.result.paths
130
+ if paths is None:
131
+ continue
132
+ color = series_color(idx)
133
+ parts.append(_ghost_cells(paths, scale, n, horizon, color, ghost_limit))
134
+
135
+ # -- fans -------------------------------------------------------------
136
+ for idx, run in enumerate(runs):
137
+ parts.append(_fan(run, scale, n, horizon, idx, len(runs)))
138
+
139
+ # -- candles ----------------------------------------------------------
140
+ parts.append(_candles(hist, scale))
141
+
142
+ # -- the now line and the volume strip --------------------------------
143
+ parts.append(f'<line x1="{now_x:.1f}" y1="{NOW_TOP}" x2="{now_x:.1f}" '
144
+ f'y2="{NOW_BOTTOM}" stroke="var(--accent-amber)" '
145
+ f'stroke-width="1.4" stroke-dasharray="5 4"></line>')
146
+ parts.append(f'<line x1="0" y1="{VOL_BASELINE}" x2="{VIEW_W}" y2="{VOL_BASELINE}" '
147
+ f'stroke="var(--border-subtle)" stroke-width="1"></line>')
148
+ parts.append(_volumes(hist, runs, scale, n, horizon))
149
+
150
+ parts.append("</svg>")
151
+
152
+ ts = pd.to_datetime(hist["ts"], utc=True)
153
+ return {
154
+ "svg": "".join(parts),
155
+ "grid": grid,
156
+ "time_labels": _time_labels(ts, scale, n, horizon),
157
+ "now_left": f"{now_x / VIEW_W * 100:.2f}%",
158
+ "now_time": f"· {ts.iloc[-1].strftime('%H:%M')} UTC",
159
+ "forecast_width": f"{VIEW_W - now_x:.1f}",
160
+ }
161
+
162
+
163
+ # --------------------------------------------------------------------------
164
+ # Pieces
165
+ # --------------------------------------------------------------------------
166
+
167
+
168
+ def _candles(hist: pd.DataFrame, scale: Scale) -> str:
169
+ out = []
170
+ bw = scale.bar_w
171
+ o = hist["open"].to_numpy(dtype="float64")
172
+ h = hist["high"].to_numpy(dtype="float64")
173
+ lw = hist["low"].to_numpy(dtype="float64")
174
+ c = hist["close"].to_numpy(dtype="float64")
175
+ for i in range(len(hist)):
176
+ up = c[i] >= o[i]
177
+ color = "var(--fin-up)" if up else "var(--fin-down)"
178
+ x = scale.x(i)
179
+ top, bot = scale.y(max(o[i], c[i])), scale.y(min(o[i], c[i]))
180
+ wick_top, wick_bot = scale.y(h[i]), scale.y(lw[i])
181
+ out.append(
182
+ f'<rect x="{x - 0.7:.1f}" y="{wick_top:.1f}" width="1.4" '
183
+ f'height="{max(1.0, wick_bot - wick_top):.1f}" fill="{color}"></rect>'
184
+ f'<rect x="{x - bw / 2:.1f}" y="{top:.1f}" width="{bw:.1f}" '
185
+ f'height="{max(1.4, bot - top):.1f}" fill="{color}"></rect>')
186
+ return "".join(out)
187
+
188
+
189
+ def _fan(run, scale: Scale, n: int, horizon: int, idx: int, total: int) -> str:
190
+ """The q10/q50/q90 envelope, starting from the last real close."""
191
+ q = run.result
192
+ lo_i, hi_i = q.level_index(0.1), q.level_index(0.9)
193
+ mid_i = q.level_index(0.5)
194
+ color = series_color(idx)
195
+
196
+ steps = min(horizon, q.horizon)
197
+ upper, lower, mid = [], [], []
198
+ for t in range(steps):
199
+ x = scale.x(n + t)
200
+ upper.append(f"{x:.1f} {scale.y(q.quantiles[t, hi_i]):.1f}")
201
+ lower.append(f"{x:.1f} {scale.y(q.quantiles[t, lo_i]):.1f}")
202
+ mid.append(f"{x:.1f} {scale.y(q.quantiles[t, mid_i]):.1f}")
203
+ if not mid:
204
+ return ""
205
+
206
+ last_close = float(run.context["close"].iloc[-1])
207
+ start = f"{scale.x(n - 1):.1f} {scale.y(last_close):.1f}"
208
+
209
+ matchup = total > 1
210
+ style = idx % 3 if matchup else 0
211
+ dash = FAN_DASH[style] if matchup else "none"
212
+ fill = "url(#faHatch)" if style == 1 and matchup else color
213
+ fill_op = "0.05" if style == 2 and matchup else ("0.9" if style == 1 and matchup
214
+ else ("0.14" if matchup else "0.18"))
215
+
216
+ band = "M" + start + " L" + " L".join(upper) + " L" + " L".join(reversed(lower)) + " Z"
217
+ return (
218
+ f'<path d="{band}" fill="{fill}" opacity="{fill_op}"></path>'
219
+ f'<path d="M{start} L{" L".join(upper)}" fill="none" stroke="{color}" '
220
+ f'stroke-width="1.2" stroke-dasharray="2 3" opacity="0.85"></path>'
221
+ f'<path d="M{start} L{" L".join(lower)}" fill="none" stroke="{color}" '
222
+ f'stroke-width="1.2" stroke-dasharray="2 3" opacity="0.85"></path>'
223
+ f'<path d="M{start} L{" L".join(mid)}" fill="none" stroke="{color}" '
224
+ f'stroke-width="2" stroke-dasharray="{dash}"></path>')
225
+
226
+
227
+ def _ghost_cells(paths: np.ndarray, scale: Scale, n: int, horizon: int,
228
+ color: str, limit: int) -> str:
229
+ """Sampled OHLCV paths, drawn as faint candle bodies.
230
+
231
+ Only `limit` of them are drawn. Sending every sampled path would multiply
232
+ the payload by the sample count for no readable gain -- past a handful the
233
+ ghosts stop being individually legible and become a smear.
234
+ """
235
+ o_i, c_i = OHLCV_COLUMNS.index("open"), OHLCV_COLUMNS.index("close")
236
+ out = []
237
+ bw = scale.bar_w
238
+ take = min(limit, paths.shape[0])
239
+ # Evenly spaced through the samples rather than the first N, so the ghosts
240
+ # represent the spread instead of whichever paths happened to be drawn first.
241
+ picks = np.linspace(0, paths.shape[0] - 1, take).round().astype(int)
242
+ steps = min(horizon, paths.shape[1])
243
+ for s in picks:
244
+ for t in range(steps):
245
+ o = float(paths[s, t, o_i])
246
+ c = float(paths[s, t, c_i])
247
+ top, bot = scale.y(max(o, c)), scale.y(min(o, c))
248
+ out.append(
249
+ f'<rect x="{scale.x(n + t) - bw * 0.35:.1f}" y="{top:.1f}" '
250
+ f'width="{bw * 0.7:.1f}" height="{max(1.2, bot - top):.1f}" '
251
+ f'fill="{color}" opacity="0.17"></rect>')
252
+ return "".join(out)
253
+
254
+
255
+ def _volumes(hist: pd.DataFrame, runs: list, scale: Scale, n: int,
256
+ horizon: int) -> str:
257
+ vol = hist["volume"].to_numpy(dtype="float64")
258
+ peak = float(vol.max()) or 1.0
259
+ o = hist["open"].to_numpy(dtype="float64")
260
+ c = hist["close"].to_numpy(dtype="float64")
261
+ bw = scale.bar_w
262
+ out = []
263
+ for i in range(n):
264
+ h = (vol[i] / peak) * VOL_SCALE
265
+ color = "var(--fin-up)" if c[i] >= o[i] else "var(--fin-down)"
266
+ out.append(f'<rect x="{scale.x(i) - bw / 2:.1f}" y="{VOL_FLOOR - h:.1f}" '
267
+ f'width="{bw:.1f}" height="{h:.1f}" fill="{color}" opacity="0.55"></rect>')
268
+
269
+ # Forecast-side volume, only from a model that actually forecasts volume.
270
+ for idx, run in enumerate(runs[:1]):
271
+ if run.capabilities.get("output") != "ohlcv_paths" or run.result.paths is None:
272
+ continue
273
+ v_i = OHLCV_COLUMNS.index("volume")
274
+ mean_vol = run.result.paths[:, :, v_i].mean(axis=0)
275
+ color = series_color(idx)
276
+ for t in range(min(horizon, len(mean_vol))):
277
+ h = (float(mean_vol[t]) / peak) * VOL_SCALE
278
+ h = max(0.0, min(h, VOL_SCALE * 1.5))
279
+ out.append(f'<rect x="{scale.x(n + t) - bw / 2:.1f}" '
280
+ f'y="{VOL_FLOOR - h:.1f}" width="{bw:.1f}" height="{h:.1f}" '
281
+ f'fill="{color}" opacity="0.22"></rect>')
282
+ return "".join(out)
283
+
284
+
285
+ def _time_labels(ts: pd.Series, scale: Scale, n: int, horizon: int) -> list[dict]:
286
+ if len(ts) < 2:
287
+ return []
288
+ step = ts.diff().dropna().mode()
289
+ step = step.iloc[0] if len(step) else pd.Timedelta("1h")
290
+ last = ts.iloc[-1]
291
+ out = []
292
+ for i in range(6):
293
+ idx = round((scale.slots - 1) * i / 5)
294
+ when = last + step * (idx - (n - 1))
295
+ x = scale.x(idx)
296
+ out.append({
297
+ "left": f"{min(97.0, max(3.0, x / VIEW_W * 100)):.2f}%",
298
+ "label": when.strftime("%d %H:%M"),
299
+ })
300
+ return out
src/ui/chrome.py ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The Arena's bridge vocabulary and its own CSS.
2
+
3
+ Everything shared with the other Spaces -- the sidebar, the command palette,
4
+ the dialogs, the design system's stylesheets, the click-bridge mechanism --
5
+ comes from `bit_ui`. This module is the thin Arena-specific layer: the actions
6
+ this Space accepts, and CSS for the parts of the design only this Space has.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from bit_ui import bridge, theme
12
+
13
+ # Actions this Space accepts, on top of the ones bit-ui's chrome emits. An
14
+ # action off this list is dropped by the bridge, so a hand-crafted click cannot
15
+ # reach a code path the UI does not offer.
16
+ ARENA_KEYS = frozenset({
17
+ "mode", # playground | matchup
18
+ "pick", # select/deselect a model card
19
+ "asset", # choose an asset
20
+ "tf", # choose a timeframe
21
+ "hz", # choose a horizon
22
+ "gear", # toggle the sampling popover
23
+ "ghosts", # toggle sampled-path rendering
24
+ "run", # issue the forecast
25
+ "cls", # standings asset-class filter
26
+ "stand", # a standings row: deep-link into playground
27
+ "hfid", # the enrollment id box (arrives live as hfid=<text>)
28
+ "family", # the enrollment adapter-family choice
29
+ "enroll", # run the smoke test and enroll
30
+ "backfill", # contribute a backfill run
31
+ "reopen", # re-open an archived forecast from the log
32
+ "dismiss", # dismiss an error banner
33
+ })
34
+
35
+ # `sidebar` is deliberately absent: collapsing is a client-side attribute flip
36
+ # in bit_ui.bridge, not a server action. Routing it through Gradio would put a
37
+ # full-page re-render behind a purely visual toggle.
38
+ ALLOWED_KEYS = (bridge.SHARED_KEYS - {"sidebar"}) | ARENA_KEYS
39
+
40
+ emit = bridge.make_emitter(ALLOWED_KEYS)
41
+
42
+
43
+ def parse_action(raw):
44
+ return bridge.parse_action(raw, ALLOWED_KEYS)
45
+
46
+
47
+ # --------------------------------------------------------------------------
48
+ # Arena-only CSS
49
+ # --------------------------------------------------------------------------
50
+ #
51
+ # Two kinds of rule live here.
52
+ #
53
+ # Layout: the design pins its zones in pixels (`railW:304px`) and switches at
54
+ # widths Gradio knows nothing about. Those are media queries rather than the
55
+ # design's inline JS-computed values, because the server cannot know the
56
+ # viewport and a round trip to find out would be absurd for a layout breakpoint.
57
+ #
58
+ # Repeated rows: the design's inline styles are the most faithful port for
59
+ # markup rendered once, but a model card carries ~700 bytes of inline style and
60
+ # there are a dozen of them, re-sent on every interaction. The classes below
61
+ # hold the design's exact values, just relocated.
62
+
63
+ ARENA_CSS = """
64
+ /* ---- zone layout ------------------------------------------------------ */
65
+ .fa-zones { display:flex; align-items:flex-start; gap:12px; flex-wrap:nowrap; }
66
+ .fa-main { flex:1 1 auto; min-width:0; display:flex; flex-direction:column; gap:12px; }
67
+ .fa-rail { flex:0 0 304px; width:304px; min-width:0;
68
+ display:flex; flex-direction:column; gap:12px; }
69
+ .fa-picker { display:grid; grid-template-columns:repeat(6,minmax(0,1fr)); gap:0; }
70
+ .fa-foot { display:grid; grid-template-columns:1.1fr 0.8fr 0.8fr 1.6fr; gap:0; }
71
+
72
+ @media (max-width:1400px) {
73
+ .fa-zones { flex-wrap:wrap; }
74
+ .fa-main { flex:1 1 100%; width:100%; }
75
+ .fa-rail { flex:1 1 100%; width:100%; }
76
+ }
77
+ @media (max-width:1180px) { .fa-picker { grid-template-columns:repeat(2,minmax(0,1fr)); } }
78
+ @media (max-width:900px) { .fa-foot { grid-template-columns:1fr; } }
79
+
80
+ /* ---- model card (repeated) -------------------------------------------- */
81
+ .fa-card { text-align:left; display:flex; flex-direction:column; gap:7px;
82
+ padding:10px 11px; border:0; border-right:1px solid var(--border-subtle);
83
+ border-top:3px solid transparent; cursor:pointer;
84
+ color:var(--text-primary); background:transparent;
85
+ transition:background 1s ease; }
86
+ .fa-card:hover { background:var(--bg-raised); }
87
+ .fa-card.on { background:var(--bg-raised); }
88
+ .fa-card-top { display:flex; align-items:center; gap:7px; }
89
+ .fa-card-name { font-family:var(--font-styrene); font-size:var(--text-md);
90
+ text-transform:uppercase; letter-spacing:var(--tracking-wide);
91
+ color:var(--text-secondary);
92
+ white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
93
+ .fa-card.on .fa-card-name { color:var(--text-primary); }
94
+ .fa-card-caps { display:flex; flex-wrap:wrap; gap:4px; }
95
+ .fa-card-cal { display:flex; align-items:center; gap:6px;
96
+ border-top:1px solid var(--border-subtle); padding-top:6px; }
97
+ .fa-chk { margin-left:auto; display:inline-flex; align-items:center;
98
+ justify-content:center; width:16px; height:16px;
99
+ border:1px solid var(--border-default); background:transparent;
100
+ color:var(--text-tertiary); font-family:var(--font-mono);
101
+ font-size:var(--text-2xs); line-height:1; }
102
+ .fa-tag { font-size:var(--text-2xs); padding:1px 5px; white-space:nowrap;
103
+ border:1px solid var(--border-default); background:transparent; }
104
+
105
+ /* ---- standings row (repeated) ----------------------------------------- */
106
+ .fa-stand { width:100%; text-align:left; display:flex; align-items:center;
107
+ gap:8px; padding:6px 10px; background:transparent; border:0;
108
+ border-bottom:1px solid var(--border-subtle);
109
+ border-left:2px solid transparent; cursor:pointer;
110
+ color:var(--text-primary); transition:background 1s ease; }
111
+ .fa-stand:hover { background:var(--bg-raised); }
112
+ .fa-stand.on { background:var(--bg-raised); }
113
+ .fa-stand-mid { flex:1 1 auto; min-width:0; display:flex;
114
+ flex-direction:column; gap:1px; }
115
+ .fa-stand-name { font-size:var(--text-xs); white-space:nowrap;
116
+ overflow:hidden; text-overflow:ellipsis; }
117
+
118
+ /* ---- track-record thumbnails (repeated) -------------------------------- */
119
+ .fa-trcell { flex:0 0 auto; width:112px; padding:8px 9px;
120
+ border-right:1px solid var(--border-subtle);
121
+ display:flex; flex-direction:column; gap:5px; }
122
+ .fa-trcell.miss { background:var(--bg-raised); }
123
+ .fa-trstrip { display:flex; gap:0; overflow-x:auto; }
124
+
125
+ /* ---- pill buttons (repeated) ------------------------------------------ */
126
+ .fa-pill { padding:3px 8px; background:transparent;
127
+ border:1px solid var(--border-default); color:var(--text-secondary);
128
+ cursor:pointer; font-family:var(--font-mono); font-size:var(--text-xs);
129
+ transition:background 1s ease,color 1s ease; }
130
+ .fa-pill.on { background:var(--bg-raised); border-color:var(--accent-amber);
131
+ color:var(--accent-amber-strong); }
132
+ .fa-seg { padding:3px 10px; background:transparent;
133
+ border:1px solid var(--border-default); border-left-width:0;
134
+ color:var(--text-secondary); cursor:pointer;
135
+ font-family:var(--font-mono); font-size:var(--text-xs);
136
+ transition:background 1s ease,color 1s ease; }
137
+ .fa-seg.first { border-left-width:1px; }
138
+ .fa-seg.on { background:var(--accent-amber); color:var(--stone-950); }
139
+
140
+ /* The chart is a fixed-ratio SVG; letting it size from content makes the
141
+ iframe ratchet, which is the failure `bit_ui.theme.find_forced_vh` exists
142
+ to catch. `width:100%;height:auto` on a viewBox does the right thing. */
143
+ .fa-chart svg { display:block; width:100%; height:auto; }
144
+ .fa-chart { position:relative; background:var(--bg-sunken);
145
+ display:flex; align-items:stretch; }
146
+ .fa-axis { flex:0 0 auto; width:66px; position:relative;
147
+ border-left:1px solid var(--border-subtle); }
148
+ """
149
+
150
+
151
+ def full_css(extra: str = "") -> str:
152
+ """bit-ui's shared CSS plus the Arena's own.
153
+
154
+ Calling `theme.full_css()` directly is the mistake to avoid: it returns the
155
+ shared chrome only and silently drops everything above, which reads as a
156
+ layout bug rather than a missing stylesheet.
157
+ """
158
+ return theme.full_css(ARENA_CSS + extra)
src/ui/shell.py ADDED
@@ -0,0 +1,1056 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The Arena's visible layer: the design's own markup, rendered from state.
2
+
3
+ Gradio is the backend here and nothing else -- one `gr.HTML` sink, a hidden
4
+ textbox the click bridge writes into, and a hidden button it clicks. Every
5
+ control on the page is the `<button>` the design specifies, carrying a
6
+ `data-bit` action, because the design is built from 13 buttons against 2 inputs
7
+ and Gradio renders something structurally different for the same concepts.
8
+
9
+ The one rule to keep when editing: **render from capabilities, never from a
10
+ model's name.** `caps["output"] == "ohlcv_paths"` is what decides whether the
11
+ ghost-path control exists. There is no branch anywhere below that mentions
12
+ Kronos, Chronos or TimesFM.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import pandas as pd
18
+ from bit_ui import icons, palette, sidebar
19
+ from bit_ui.markup import a, e
20
+
21
+ from .. import config, view
22
+ from ..view import DASH, count, grade_color, num, pct
23
+ from . import chart
24
+ from .chrome import emit
25
+
26
+ STYRENE = ("font-family:var(--font-styrene);text-transform:uppercase;"
27
+ "letter-spacing:var(--tracking-wide)")
28
+ PANEL = "border:1px solid var(--border-default);background:var(--bg-panel)"
29
+ HEAD = ("display:flex;align-items:center;gap:8px;padding:7px 10px;"
30
+ "border-bottom:1px solid var(--border-default)")
31
+
32
+
33
+ def bit(action: str, value: str = "") -> str:
34
+ """The full `data-bit` attribute for an action, escaped.
35
+
36
+ `emit()` returns the attribute *value*, not the attribute. Interpolating it
37
+ bare renders `<button run:go>`, which is not a data attribute at all: the
38
+ delegated listener never matches it and the control does nothing, with no
39
+ error anywhere. This wrapper is the only way actions are attached below.
40
+ """
41
+ return f'type="button" data-bit="{a(emit(action, value))}"'
42
+
43
+
44
+ def _panel_title(text: str, right: str = "") -> str:
45
+ return (f'<div style="{HEAD}">'
46
+ f'<span style="{STYRENE};font-size:var(--text-sm)">{e(text)}</span>'
47
+ f'{right}</div>')
48
+
49
+
50
+ # --------------------------------------------------------------------------
51
+ # Header
52
+ # --------------------------------------------------------------------------
53
+
54
+
55
+ def header(state: dict, data: dict) -> str:
56
+ archived = data.get("archived_total")
57
+ resolved = data.get("resolved_total")
58
+ tally = (f"ARCHIVE: {count(archived)} FORECASTS · {count(resolved)} RESOLVED"
59
+ if archived is not None else "ARCHIVE: BUILDING")
60
+
61
+ account = ("ACCOUNT · SIGNED IN" if state.get("signed_in")
62
+ else "Connect with Hugging Face")
63
+ dot = "var(--fin-up)" if state.get("signed_in") else "var(--mute-orange)"
64
+
65
+ return (
66
+ '<header style="position:sticky;top:0;z-index:20;background:var(--bg-panel);'
67
+ 'border-bottom:1px solid var(--border-default)">'
68
+ '<div style="display:flex;align-items:center;gap:10px;padding:8px 14px;'
69
+ 'min-height:48px;flex-wrap:wrap">'
70
+ '<div style="display:flex;align-items:center;gap:8px;flex:0 0 auto">'
71
+ '<span style="display:inline-block;width:6px;height:6px;'
72
+ 'background:var(--fin-up);animation:bitPulse 2s ease-in-out infinite"></span>'
73
+ f'<span style="{STYRENE};font-size:var(--text-sm);color:var(--text-secondary)">Arena</span>'
74
+ '</div>'
75
+ f'<a href="{a(data.get("store_url", "#"))}" target="_blank" rel="noopener" '
76
+ 'style="display:flex;align-items:center;gap:8px;padding:3px 8px;'
77
+ 'border:1px solid var(--accent-amber-dim);color:var(--accent-amber-strong);'
78
+ 'flex:0 0 auto;text-decoration:none">'
79
+ f'<span class="mono-data" style="font-size:var(--text-xs)">{e(tally)}</span></a>'
80
+ '<div style="flex:1 1 auto;min-width:180px;max-width:400px">'
81
+ f'{palette.search_bar(state.get("q", ""), emit, count_label="")}'
82
+ '</div>'
83
+ '<span style="display:inline-flex;align-items:center;gap:6px;padding:3px 8px;'
84
+ 'border:1px solid var(--border-default);margin-left:auto">'
85
+ f'<span style="display:inline-block;width:5px;height:5px;background:{dot}"></span>'
86
+ f'<span class="mono-data" style="font-size:var(--text-2xs);'
87
+ f'color:var(--text-secondary);white-space:nowrap">{e(account)}</span></span>'
88
+ '</div>'
89
+ '</header>'
90
+ )
91
+
92
+
93
+ # --------------------------------------------------------------------------
94
+ # Stats and title
95
+ # --------------------------------------------------------------------------
96
+
97
+
98
+ def title_row(state: dict, data: dict) -> str:
99
+ deep = ""
100
+ if state.get("deep_linked"):
101
+ deep = ('<span style="display:inline-flex;align-items:center;gap:7px;'
102
+ 'padding:4px 8px;border:1px solid var(--accent-amber-dim);'
103
+ 'background:var(--bg-raised)">'
104
+ '<span class="pixel-text" style="color:var(--accent-amber-strong)">→</span>'
105
+ '<span class="mono-data" style="font-size:var(--text-2xs);'
106
+ f'color:var(--text-secondary)">DEEP LINK ?model={e(state["selected"][0])}'
107
+ ' · PRESELECTED</span></span>')
108
+
109
+ ran = data.get("resolver_last_ran") or DASH
110
+ return (
111
+ '<div style="display:flex;align-items:flex-end;gap:12px;flex-wrap:wrap">'
112
+ '<div style="display:flex;flex-direction:column;gap:3px;min-width:0">'
113
+ f'<h1 style="{STYRENE};font-weight:500;font-size:var(--text-2xl);'
114
+ 'line-height:var(--leading-tight);margin:0">Forecast Arena</h1>'
115
+ '<span style="color:var(--text-secondary);font-size:var(--text-base)">'
116
+ 'Every model forecasts. Every forecast is remembered.</span></div>'
117
+ f'{deep}'
118
+ '<span style="display:inline-flex;align-items:center;gap:6px;padding:4px 8px;'
119
+ f'border:1px solid var(--border-default);background:var(--bg-panel);margin-left:auto">'
120
+ '<span style="display:inline-block;width:5px;height:5px;'
121
+ 'background:var(--accent-amber);animation:bitPulse 2s ease-in-out infinite"></span>'
122
+ '<span class="mono-data" style="font-size:var(--text-2xs);'
123
+ f'color:var(--text-secondary);white-space:nowrap">RESOLVER LAST RAN {e(ran)}</span>'
124
+ '</span></div>'
125
+ )
126
+
127
+
128
+ def stats_bar(stats: list[dict]) -> str:
129
+ cells = []
130
+ for s in stats:
131
+ cells.append(
132
+ f'<div style="padding:10px 12px;border-right:1px solid var(--border-subtle);'
133
+ f'border-left:2px solid {a(s["mark"])}">'
134
+ f'<div style="{STYRENE};font-weight:300;font-size:var(--text-2xs);'
135
+ f'letter-spacing:var(--tracking-wider);color:var(--text-tertiary);'
136
+ f'white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{e(s["label"])}</div>'
137
+ f'<div class="mono-data" style="font-size:var(--text-2xl);line-height:1.15;'
138
+ f'white-space:nowrap;color:{a(s["color"])}">{e(s["value"])}</div>'
139
+ f'<div style="font-size:var(--text-2xs);color:var(--text-tertiary);'
140
+ f'white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{e(s["note"])}</div>'
141
+ f'</div>')
142
+ return ('<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));'
143
+ f'{PANEL}">{"".join(cells)}</div>')
144
+
145
+
146
+ # --------------------------------------------------------------------------
147
+ # Model picker
148
+ # --------------------------------------------------------------------------
149
+
150
+
151
+ def models_section(state: dict, data: dict) -> str:
152
+ mode = state["mode"]
153
+ selected = state["selected"]
154
+ sel_note = (f"MATCHUP · {len(selected)} OF {view.MAX_MATCHUP} SELECTED"
155
+ if mode == "matchup" else "PLAYGROUND · SINGLE MODEL")
156
+
157
+ tabs = []
158
+ for i, (key, label) in enumerate((("playground", "Playground"),
159
+ ("matchup", "Matchup"))):
160
+ on = mode == key
161
+ tabs.append(
162
+ f'<button {bit("mode", key)} style="padding:4px 12px;'
163
+ f'background:{"var(--accent-amber)" if on else "transparent"};'
164
+ f'border:1px solid var(--border-default);border-left-width:{"1px" if i == 0 else "0"};'
165
+ f'color:{"var(--stone-950)" if on else "var(--text-secondary)"};{STYRENE};'
166
+ f'font-size:var(--text-xs);cursor:pointer;transition:background 1s ease">'
167
+ f'{e(label)}</button>')
168
+
169
+ cards = "".join(_model_card(m, m["slug"] in selected, data)
170
+ for m in data["models"])
171
+
172
+ add = (
173
+ f'<button {bit("enroll", "focus")} style="text-align:left;display:flex;'
174
+ 'flex-direction:column;gap:6px;justify-content:center;padding:10px 11px;'
175
+ 'background:transparent;border:0;border-left:1px dashed var(--border-default);'
176
+ 'cursor:pointer;color:var(--text-tertiary)">'
177
+ '<span class="mono-data" style="font-size:var(--text-lg)">+</span>'
178
+ f'<span style="{STYRENE};font-size:var(--text-sm)">Add a fine-tune</span>'
179
+ '<span style="font-size:var(--text-2xs);text-wrap:pretty">Any Hub model with a '
180
+ 'Kronos, Chronos or TimesFM adapter family</span></button>')
181
+
182
+ return (
183
+ f'<section style="{PANEL}">'
184
+ '<div style="display:flex;align-items:center;gap:10px;padding:8px 12px;'
185
+ 'border-bottom:1px solid var(--border-default);flex-wrap:wrap">'
186
+ f'<span style="{STYRENE};font-size:var(--text-md)">Models</span>'
187
+ f'<span class="mono-data" style="font-size:var(--text-2xs);'
188
+ f'color:var(--text-tertiary)">{e(sel_note)}</span>'
189
+ f'<div style="display:flex;margin-left:auto">{"".join(tabs)}</div></div>'
190
+ f'<div class="fa-picker">{cards}{add}</div></section>'
191
+ )
192
+
193
+
194
+ def _model_card(model: dict, on: bool, data: dict) -> str:
195
+ """One model card. Rendered from declared capabilities only."""
196
+ color = model["color"]
197
+ caps = model["caps"]
198
+
199
+ # The two capability chips the design specifies: what the model emits, and
200
+ # what hardware it costs. Both come from the adapter's declaration.
201
+ paths = caps.get("output") == "ohlcv_paths"
202
+ gpu = caps.get("hardware") == "gpu"
203
+ chips = [
204
+ ("OHLCV paths" if paths else "quantile line",
205
+ "var(--accent-amber-strong)" if paths else "var(--text-secondary)",
206
+ "var(--accent-amber-dim)" if paths else "var(--border-default)"),
207
+ ("your GPU quota" if gpu else "CPU · instant",
208
+ "var(--mute-orange)" if gpu else "var(--accent-moss-strong)",
209
+ "var(--mute-orange)" if gpu else "var(--accent-moss-dim)"),
210
+ ]
211
+ chip_html = "".join(
212
+ f'<span class="mono-data fa-tag" style="color:{a(fg)};border-color:{a(bd)}">'
213
+ f'{e(label)}</span>' for label, fg, bd in chips)
214
+
215
+ chk_style = (f'background:{a(color)};color:var(--stone-950);border-color:{a(color)}'
216
+ if on else "")
217
+ return (
218
+ f'<button {bit("pick", model["slug"])} class="fa-card{" on" if on else ""}" '
219
+ f'style="border-top-color:{a(color) if on else "transparent"}">'
220
+ '<div class="fa-card-top">'
221
+ f'<span class="mono-data fa-tag" style="color:{a(color) if on else "var(--text-tertiary)"};'
222
+ f'border-color:{a(color) if on else "var(--border-default)"}">{e(model["family"].upper())}</span>'
223
+ f'<span class="mono-data" style="font-size:var(--text-2xs);'
224
+ f'color:var(--text-tertiary)">{e(model["size"])}</span>'
225
+ f'<span class="fa-chk" style="{chk_style}">{"✓" if on else ""}</span></div>'
226
+ f'<div class="fa-card-name">{e(model["name"])}</div>'
227
+ f'<div class="fa-card-caps">{chip_html}</div>'
228
+ '<div class="fa-card-cal">'
229
+ '<span class="pixel-text" style="color:var(--text-tertiary)">CALIBRATION</span>'
230
+ f'<span class="mono-data" style="font-size:var(--text-md);'
231
+ f'color:{a(model["grade_color"])};margin-left:auto">{e(model["grade"])}</span>'
232
+ f'<span class="mono-data" style="font-size:var(--text-2xs);'
233
+ f'color:var(--text-tertiary)">{e(model["resolved_label"])}</span></div>'
234
+ '</button>')
235
+
236
+
237
+ # --------------------------------------------------------------------------
238
+ # Controls
239
+ # --------------------------------------------------------------------------
240
+
241
+
242
+ def controls(state: dict, data: dict) -> str:
243
+ assets = "".join(
244
+ f'<button {bit("asset", slug)} class="fa-pill'
245
+ f'{" on" if state["asset"] == slug else ""}">{e(spec.display)}</button>'
246
+ for slug, spec in config.ASSETS.items())
247
+
248
+ tfs = "".join(
249
+ f'<button {bit("tf", slug)} class="fa-seg{" first" if i == 0 else ""}'
250
+ f'{" on" if state["tf"] == slug else ""}">{e(slug)}</button>'
251
+ for i, slug in enumerate(config.TIMEFRAMES))
252
+
253
+ options = _horizon_options(state["tf"])
254
+ hzs = "".join(
255
+ f'<button {bit("hz", str(h))} class="fa-seg{" first" if i == 0 else ""}'
256
+ f'{" on" if int(state["horizon"]) == h else ""}">{h} steps</button>'
257
+ for i, h in enumerate(options))
258
+
259
+ hours = state["horizon"] * (1 if state["tf"] == "1h" else 24)
260
+ hz_note = f"= {hours}H AHEAD"
261
+
262
+ gear = _gear(state) if state.get("gear") else ""
263
+ quota = _quota_banner(data) if data.get("quota_blocked") else ""
264
+
265
+ run_label = ("▶ Forecast" if state["mode"] == "playground"
266
+ else f"▶ Forecast {len(state['selected'])} models")
267
+ runtime = data.get("runtime_hint", DASH)
268
+ blocked = bool(data.get("quota_blocked"))
269
+
270
+ return (
271
+ f'<section style="{PANEL};display:flex;align-items:center;gap:14px;'
272
+ 'padding:9px 12px;flex-wrap:wrap">'
273
+ '<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap">'
274
+ '<span class="pixel-text" style="color:var(--text-tertiary)">ASSET</span>'
275
+ f'{assets}</div>'
276
+ '<div style="width:1px;height:22px;background:var(--border-subtle)"></div>'
277
+ '<div style="display:flex;align-items:center;gap:6px">'
278
+ '<span class="pixel-text" style="color:var(--text-tertiary)">TIMEFRAME</span>'
279
+ f'<div style="display:flex">{tfs}</div></div>'
280
+ '<div style="width:1px;height:22px;background:var(--border-subtle)"></div>'
281
+ '<div style="display:flex;align-items:center;gap:6px">'
282
+ '<span class="pixel-text" style="color:var(--text-tertiary)">HORIZON</span>'
283
+ f'<div style="display:flex">{hzs}</div>'
284
+ f'<span class="mono-data" style="font-size:var(--text-2xs);'
285
+ f'color:var(--text-tertiary)">{e(hz_note)}</span></div>'
286
+ '<div style="position:relative;margin-left:auto;display:flex;'
287
+ 'align-items:center;gap:8px">'
288
+ f'<button {bit("gear", "toggle")} title="Sampling and seed" '
289
+ f'style="width:26px;height:26px;background:'
290
+ f'{"var(--bg-raised)" if state.get("gear") else "transparent"};'
291
+ f'border:1px solid {"var(--accent-amber)" if state.get("gear") else "var(--border-default)"};'
292
+ f'color:{"var(--accent-amber-strong)" if state.get("gear") else "var(--text-tertiary)"};'
293
+ 'cursor:pointer;display:flex;align-items:center;justify-content:center">'
294
+ f'{icons.icon("sliders", size="13px")}</button>'
295
+ f'{gear}{quota}'
296
+ f'<span class="mono-data" style="font-size:var(--text-2xs);'
297
+ f'color:var(--text-tertiary);padding:3px 7px;'
298
+ f'border:1px solid var(--border-subtle)">{e(runtime)}</span>'
299
+ f'<button {bit("run", "go")} '
300
+ f'style="padding:6px 14px;background:'
301
+ f'{"var(--bg-raised)" if blocked else "var(--accent-amber)"};'
302
+ f'border:1px solid {"var(--border-default)" if blocked else "var(--accent-amber)"};'
303
+ f'color:{"var(--text-disabled)" if blocked else "var(--stone-950)"};'
304
+ f'{STYRENE};font-size:var(--text-xs);'
305
+ f'cursor:{"not-allowed" if blocked else "pointer"}">{e(run_label)}</button>'
306
+ '</div></section>'
307
+ )
308
+
309
+
310
+ def _horizon_options(timeframe: str) -> list[int]:
311
+ """Horizon choices, capped at what the timeframe allows."""
312
+ ceiling = config.MAX_HORIZON.get(timeframe, 96)
313
+ return [h for h in (12, 24, 48, 96) if h <= ceiling] or [ceiling]
314
+
315
+
316
+ def _gear(state: dict) -> str:
317
+ rows = [
318
+ ("Sampled paths", str(state.get("n_samples", config.DEFAULT_N_SAMPLES))),
319
+ ("Quantiles", "/".join(f"{q:g}" for q in config.QUANTILE_LEVELS)),
320
+ ("Seed", str(state.get("seed", 42))),
321
+ ]
322
+ body = "".join(
323
+ '<div style="display:flex;align-items:center;gap:8px">'
324
+ f'<span style="flex:1 1 auto;{STYRENE};font-weight:300;'
325
+ f'font-size:var(--text-xs);color:var(--text-secondary)">{e(label)}</span>'
326
+ f'<span class="mono-data" style="font-size:var(--text-xs);'
327
+ f'color:var(--text-primary);border:1px solid var(--border-default);'
328
+ f'padding:2px 7px">{e(value)}</span></div>'
329
+ for label, value in rows)
330
+ return (
331
+ '<div style="position:absolute;top:32px;right:0;z-index:40;width:230px;'
332
+ 'background:var(--bg-raised);border:1px solid var(--border-strong);'
333
+ 'padding:10px 12px;display:flex;flex-direction:column;gap:9px">'
334
+ f'<span style="{STYRENE};font-weight:300;font-size:var(--text-xs);'
335
+ 'color:var(--text-tertiary)">Sampling</span>'
336
+ f'{body}'
337
+ '<span class="pixel-text" style="color:var(--text-tertiary)">'
338
+ 'SEED IS ARCHIVED WITH THE FORECAST</span></div>')
339
+
340
+
341
+ def _quota_banner(data: dict) -> str:
342
+ return (
343
+ '<span style="display:inline-flex;align-items:center;gap:7px;padding:4px 9px;'
344
+ 'border:1px solid var(--mute-orange);background:var(--bg-raised)">'
345
+ '<span class="pixel-text" style="color:var(--mute-orange)">⚠</span>'
346
+ f'<span style="font-size:var(--text-xs);color:var(--text-secondary)">'
347
+ f'{e(data.get("quota_msg", "GPU-tier model — sign in to use your quota"))}</span>'
348
+ f'<a href="{a(data.get("login_url", "#"))}" style="font-family:var(--font-mono);'
349
+ 'font-size:var(--text-2xs);white-space:nowrap">SIGN IN ↗</a></span>')
350
+
351
+
352
+ # --------------------------------------------------------------------------
353
+ # Chart panel
354
+ # --------------------------------------------------------------------------
355
+
356
+
357
+ def chart_section(state: dict, data: dict) -> str:
358
+ runs = data.get("runs", [])
359
+ mode = state["mode"]
360
+ primary = runs[0] if runs else None
361
+
362
+ title = "Matchup" if mode == "matchup" else "Forecast"
363
+ who = (f"{len(runs)} MODELS" if mode == "matchup"
364
+ else (data["models_by_slug"].get(state["selected"][0], {}).get("name", "")
365
+ .upper() if state["selected"] else ""))
366
+ meta = f'{state["asset"]} · {state["tf"]} · H+{state["horizon"]} · {who}'
367
+
368
+ # The capability gate, in one place. `ghost_capable` is true when the
369
+ # *selected* model declares OHLCV paths -- not when a result happens to
370
+ # carry them.
371
+ ghost_capable = bool(primary
372
+ and primary.capabilities.get("output") == "ohlcv_paths"
373
+ and mode == "playground")
374
+ ghost_locked = bool(primary
375
+ and primary.capabilities.get("output") != "ohlcv_paths"
376
+ and mode == "playground")
377
+
378
+ if ghost_capable:
379
+ on = state.get("ghosts", True)
380
+ toggle = (
381
+ f'<button {bit("ghosts", "toggle")} style="display:inline-flex;'
382
+ 'align-items:center;gap:6px;padding:3px 8px;background:'
383
+ f'{"var(--bg-raised)" if on else "transparent"};'
384
+ f'border:1px solid {"var(--accent-amber)" if on else "var(--border-default)"};'
385
+ f'color:{"var(--accent-amber-strong)" if on else "var(--text-secondary)"};'
386
+ 'cursor:pointer">'
387
+ f'<span class="mono-data" style="font-size:var(--text-2xs)">{"✓" if on else ""}</span>'
388
+ f'<span style="{STYRENE};font-size:var(--text-xs)">Sampled OHLCV paths</span>'
389
+ '</button>')
390
+ elif ghost_locked:
391
+ toggle = (
392
+ '<span style="display:inline-flex;align-items:center;gap:6px;padding:3px 8px;'
393
+ 'border:1px dashed var(--border-default);color:var(--text-disabled)">'
394
+ '<span class="mono-data" style="font-size:var(--text-2xs)">✕</span>'
395
+ f'<span style="{STYRENE};font-size:var(--text-xs)">'
396
+ 'Quantile line only — no OHLCV paths</span></span>')
397
+ else:
398
+ toggle = ""
399
+
400
+ frozen = (f'FROZEN {primary.issued_ts.strftime("%Y-%m-%d %H:%M")} UTC · '
401
+ f'SEED {primary.result.seed}' if primary else "NO FORECAST ISSUED YET")
402
+
403
+ drawing = data.get("chart") or {}
404
+ body = _chart_body(drawing) if drawing.get("svg") else _chart_empty(data)
405
+
406
+ return (
407
+ f'<section style="{PANEL}">'
408
+ '<div style="display:flex;align-items:center;gap:10px;padding:8px 12px;'
409
+ 'border-bottom:1px solid var(--border-default);flex-wrap:wrap">'
410
+ f'<span style="{STYRENE};font-size:var(--text-md)">{e(title)}</span>'
411
+ f'<span class="mono-data" style="font-size:var(--text-xs);'
412
+ 'color:var(--text-secondary);padding:2px 7px;'
413
+ f'border:1px solid var(--border-default)">{e(meta)}</span>'
414
+ '<div style="display:flex;align-items:center;gap:8px;margin-left:auto;'
415
+ f'flex-wrap:wrap">{toggle}'
416
+ f'<span class="mono-data" style="font-size:var(--text-2xs);'
417
+ f'color:var(--text-tertiary)">{e(frozen)}</span></div></div>'
418
+ f'{_legend(data)}{body}</section>'
419
+ )
420
+
421
+
422
+ def _legend(data: dict) -> str:
423
+ runs = data.get("runs", [])
424
+ if not runs:
425
+ return ""
426
+ items = []
427
+ for i, run in enumerate(runs):
428
+ color = chart.series_color(i)
429
+ panel = data["panels"].get(run.model_slug, {})
430
+ name = data["models_by_slug"].get(run.model_slug, {}).get("name", run.model_slug)
431
+ width = panel.get("width")
432
+ band = (f'10 / 50 / 90 · {pct(width, 1)} MEAN WIDTH' if width is not None
433
+ else "10 / 50 / 90")
434
+ grade = panel.get("grade", "-")
435
+ dash = chart.FAN_DASH[i % 3] if len(runs) > 1 else "none"
436
+ items.append(
437
+ '<div style="display:flex;align-items:center;gap:8px">'
438
+ '<svg width="34" height="12" viewBox="0 0 34 12" '
439
+ 'style="flex:0 0 auto;display:block">'
440
+ f'<rect x="0" y="2" width="34" height="8" fill="{a(color)}" opacity="0.16"></rect>'
441
+ f'<line x1="0" y1="6" x2="34" y2="6" stroke="{a(color)}" '
442
+ f'stroke-width="1.6" stroke-dasharray="{a(dash)}"></line></svg>'
443
+ f'<span style="{STYRENE};font-size:var(--text-sm)">{e(name)}</span>'
444
+ f'<span class="mono-data" style="font-size:var(--text-2xs);'
445
+ f'color:var(--text-tertiary)">{e(band)}</span>'
446
+ f'<span class="mono-data" style="font-size:var(--text-xs);'
447
+ f'color:{a(grade_color(grade))};border:1px solid {a(grade_color(grade))};'
448
+ f'padding:1px 5px">{e(grade)}</span>'
449
+ f'<span style="font-size:var(--text-2xs);'
450
+ f'color:{a(panel.get("verdict_color", "var(--text-tertiary)"))}">'
451
+ f'{e(panel.get("verdict_short", ""))}</span></div>')
452
+ return ('<div style="display:flex;align-items:center;gap:14px;padding:7px 12px;'
453
+ 'border-bottom:1px solid var(--border-subtle);background:var(--bg-raised);'
454
+ f'flex-wrap:wrap">{"".join(items)}</div>')
455
+
456
+
457
+ def _chart_body(drawing: dict) -> str:
458
+ axis = "".join(
459
+ f'<span class="mono-data" style="position:absolute;right:6px;top:{a(g["top"])};'
460
+ 'transform:translateY(-50%);font-size:var(--text-2xs);'
461
+ f'color:var(--text-tertiary);white-space:nowrap">{e(g["label"])}</span>'
462
+ for g in drawing.get("grid", []))
463
+ times = "".join(
464
+ f'<span class="mono-data" style="position:absolute;left:{a(t["left"])};'
465
+ 'transform:translateX(-50%);font-size:var(--text-2xs);'
466
+ f'color:var(--text-tertiary);white-space:nowrap">{e(t["label"])}</span>'
467
+ for t in drawing.get("time_labels", []))
468
+ left = a(drawing.get("now_left", "50%"))
469
+ return (
470
+ '<div class="fa-chart"><div style="position:relative;flex:1 1 auto;min-width:0">'
471
+ f'{drawing["svg"]}'
472
+ f'<span class="mono-data" style="position:absolute;left:{left};top:5px;'
473
+ 'transform:translateX(-100%);padding-right:6px;font-size:var(--text-2xs);'
474
+ f'color:var(--accent-amber-strong);white-space:nowrap">NOW '
475
+ f'{e(drawing.get("now_time", ""))}</span>'
476
+ f'<span class="mono-data" style="position:absolute;left:{left};top:5px;'
477
+ 'padding-left:6px;font-size:var(--text-2xs);color:var(--text-tertiary);'
478
+ 'white-space:nowrap">FORECAST →</span>'
479
+ '<span class="mono-data" style="position:absolute;left:4px;top:76%;'
480
+ 'font-size:var(--text-2xs);color:var(--text-tertiary)">VOLUME</span>'
481
+ '<div style="position:absolute;left:0;right:0;bottom:0;height:16px">'
482
+ f'{times}</div></div>'
483
+ f'<div class="fa-axis">{axis}</div></div>')
484
+
485
+
486
+ def _chart_empty(data: dict) -> str:
487
+ err = data.get("error")
488
+ if err:
489
+ return _error_state(err)
490
+ return (
491
+ '<div style="display:flex;flex-direction:column;align-items:center;gap:8px;'
492
+ 'padding:52px 16px;background:var(--bg-sunken)">'
493
+ '<span class="mono-data" style="font-size:var(--text-2xl);'
494
+ 'color:var(--text-disabled)">▱</span>'
495
+ f'<span style="{STYRENE};font-size:var(--text-md);color:var(--text-secondary)">'
496
+ 'No forecast issued</span>'
497
+ '<span style="font-size:var(--text-xs);color:var(--text-tertiary);'
498
+ 'text-align:center;max-width:340px;text-wrap:pretty">'
499
+ 'Pick a model and press Forecast. The result is frozen at issue and '
500
+ 'archived before it is drawn.</span></div>')
501
+
502
+
503
+ def _error_state(err: dict) -> str:
504
+ """The designed failure states: quota, load failure, rejected enrollment."""
505
+ kind = err.get("kind", "load_failure")
506
+ glyph = {"quota": "⚠", "load_failure": "✕", "enroll": "✕",
507
+ "no_data": "∅"}.get(kind, "✕")
508
+ color = {"quota": "var(--mute-orange)"}.get(kind, "var(--fin-down)")
509
+ extra = ""
510
+ if kind == "quota":
511
+ extra = (f'<a href="{a(err.get("login_url", "#"))}" '
512
+ 'style="font-family:var(--font-mono);font-size:var(--text-2xs)">'
513
+ 'SIGN IN ↗</a>')
514
+ elif kind == "load_failure":
515
+ # A model that will not load on this hardware is not always a bug in
516
+ # the Space: duplicating it gives the user a box they control.
517
+ extra = (f'<a href="{a(err.get("duplicate_url", "#"))}" target="_blank" '
518
+ 'rel="noopener" style="font-family:var(--font-mono);'
519
+ 'font-size:var(--text-2xs)">DUPLICATE THIS SPACE ↗</a>')
520
+ return (
521
+ '<div style="display:flex;flex-direction:column;align-items:center;gap:9px;'
522
+ 'padding:44px 16px;background:var(--bg-sunken)">'
523
+ f'<span class="mono-data" style="font-size:var(--text-2xl);color:{a(color)}">'
524
+ f'{e(glyph)}</span>'
525
+ f'<span style="{STYRENE};font-size:var(--text-md);color:var(--text-secondary)">'
526
+ f'{e(err.get("title", "Forecast failed"))}</span>'
527
+ '<span style="font-size:var(--text-xs);color:var(--text-tertiary);'
528
+ 'text-align:center;max-width:420px;text-wrap:pretty">'
529
+ f'{e(err.get("message", ""))}</span>'
530
+ f'<div style="display:flex;gap:10px;align-items:center">{extra}'
531
+ f'<button {bit("dismiss", "error")} style="padding:3px 9px;'
532
+ 'background:transparent;border:1px solid var(--border-default);'
533
+ 'color:var(--text-secondary);cursor:pointer;font-family:var(--font-mono);'
534
+ 'font-size:var(--text-2xs)">DISMISS</button></div></div>')
535
+
536
+
537
+ # --------------------------------------------------------------------------
538
+ # Track record
539
+ # --------------------------------------------------------------------------
540
+
541
+
542
+ def trackrecord_section(state: dict, data: dict) -> str:
543
+ mode = state["mode"]
544
+ slugs = state["selected"]
545
+ scope = (f'{state["asset"]} · {state["tf"]} · H+{state["horizon"]} · '
546
+ f'LAST {view.THUMBS} RESOLVED').upper()
547
+
548
+ if mode == "matchup" and len(slugs) > 1:
549
+ question = "Same stats, side by side — best in row highlighted."
550
+ body = _scoreboard(state, data)
551
+ else:
552
+ primary = slugs[0] if slugs else None
553
+ panel = data["panels"].get(primary, {})
554
+ name = data["models_by_slug"].get(primary, {}).get("name", primary or "")
555
+ question = f"How wrong has {name} been here recently?"
556
+ body = _empty_record() if panel.get("empty", True) else _playground_record(panel, data)
557
+
558
+ return (
559
+ f'<section style="{PANEL}">'
560
+ '<div style="display:flex;align-items:center;gap:10px;padding:8px 12px;'
561
+ 'border-bottom:1px solid var(--border-default);'
562
+ 'border-left:3px solid var(--accent-amber);flex-wrap:wrap">'
563
+ f'<span style="{STYRENE};font-size:var(--text-md)">Track record</span>'
564
+ f'<span style="color:var(--text-secondary);font-size:var(--text-xs)">'
565
+ f'{e(question)}</span>'
566
+ f'<span class="mono-data" style="font-size:var(--text-2xs);'
567
+ f'color:var(--text-tertiary);margin-left:auto">{e(scope)}</span></div>'
568
+ f'{body}</section>')
569
+
570
+
571
+ def _empty_record() -> str:
572
+ return (
573
+ '<div style="display:flex;flex-direction:column;align-items:center;gap:8px;'
574
+ 'padding:26px 16px">'
575
+ '<span class="mono-data" style="font-size:var(--text-2xl);'
576
+ 'color:var(--text-disabled)">∅</span>'
577
+ f'<span style="{STYRENE};font-size:var(--text-md);color:var(--text-secondary)">'
578
+ 'No history yet</span>'
579
+ '<span style="font-size:var(--text-xs);color:var(--text-tertiary);'
580
+ 'text-align:center;max-width:340px;text-wrap:pretty">Forecasts made here start '
581
+ 'the record. The first resolved forecast for this model, asset and timeframe '
582
+ 'appears in this panel.</span>'
583
+ f'<button {bit("run", "go")} style="margin-top:2px;padding:4px 9px;'
584
+ 'background:transparent;border:1px solid var(--border-default);'
585
+ 'color:var(--text-secondary);cursor:pointer;font-family:var(--font-mono);'
586
+ 'font-size:var(--text-2xs)">MAKE THE FIRST FORECAST →</button></div>')
587
+
588
+
589
+ def _playground_record(panel: dict, data: dict) -> str:
590
+ stats = [
591
+ ("Empirical 80% coverage", pct(panel.get("coverage")),
592
+ f'target 80% · {count(panel.get("resolved"))} resolved',
593
+ panel.get("verdict_color", "var(--text-primary)")),
594
+ ("Median abs error", pct(panel.get("median_err", 0) / 100
595
+ if panel.get("median_err") is not None else None, 2),
596
+ "at horizon close", "var(--text-primary)"),
597
+ ("Mean band width", pct(panel.get("width"), 1),
598
+ "10→90 span, % of price", "var(--text-primary)"),
599
+ ("Backfilled share", pct(panel.get("backfilled_share")),
600
+ "labelled in the record", "var(--mute-blue)"),
601
+ ]
602
+ stat_html = "".join(
603
+ '<div style="padding:9px 12px;border-right:1px solid var(--border-subtle)">'
604
+ f'<div style="{STYRENE};font-weight:300;font-size:var(--text-2xs);'
605
+ f'letter-spacing:var(--tracking-wider);color:var(--text-tertiary)">{e(label)}</div>'
606
+ f'<div class="mono-data" style="font-size:var(--text-xl);line-height:1.2;'
607
+ f'color:{a(color)}">{e(value)}</div>'
608
+ f'<div style="font-size:var(--text-2xs);color:var(--text-tertiary)">{e(note)}</div>'
609
+ '</div>'
610
+ for label, value, note, color in stats)
611
+
612
+ cells = "".join(_thumb(t, data) for t in panel.get("thumbs", []))
613
+ vcolor = panel.get("verdict_color", "var(--text-tertiary)")
614
+
615
+ return (
616
+ '<div><div style="display:grid;'
617
+ 'grid-template-columns:repeat(auto-fit,minmax(168px,1fr));'
618
+ f'border-bottom:1px solid var(--border-default)">{stat_html}</div>'
619
+ f'<div class="fa-trstrip">{cells}</div>'
620
+ '<div style="display:flex;align-items:center;gap:10px;padding:7px 12px;'
621
+ 'border-top:1px solid var(--border-subtle);background:var(--bg-raised);'
622
+ 'flex-wrap:wrap">'
623
+ f'<span class="mono-data" style="font-size:var(--text-xs);'
624
+ f'color:{a(vcolor)};border:1px solid {a(vcolor)};padding:2px 7px">'
625
+ f'{e(panel.get("verdict", ""))}</span>'
626
+ '<span style="font-size:var(--text-xs);color:var(--text-secondary);'
627
+ f'text-wrap:pretty">{e(_verdict_note(panel))}</span>'
628
+ f'<a href="{a(data.get("store_url", "#"))}" target="_blank" rel="noopener" '
629
+ 'style="margin-left:auto;font-family:var(--font-mono);font-size:var(--text-2xs);'
630
+ f'white-space:nowrap">ALL {e(count(panel.get("total")))} ARCHIVED ↗</a>'
631
+ '</div></div>')
632
+
633
+
634
+ def _verdict_note(panel: dict) -> str:
635
+ coverage = panel.get("coverage")
636
+ if coverage is None:
637
+ return ("Not enough resolved forecasts here to say anything about "
638
+ "calibration yet.")
639
+ gap = coverage - config.NOMINAL_COVERAGE
640
+ if abs(gap) <= 0.05:
641
+ return ("Bands hold up on this pair: the realized path stayed inside the "
642
+ "80% envelope about as often as it should.")
643
+ if gap < 0:
644
+ return ("Bands are narrower than the realized error justifies here. "
645
+ "Read the median, not the confidence.")
646
+ return ("Bands are wider than the realized error justifies here — the model "
647
+ "is hedging more than this pair has needed.")
648
+
649
+
650
+ def _thumb(t: dict, data: dict) -> str:
651
+ spark = t["spark"]
652
+ backfill = ('<span class="pixel-text" style="color:var(--mute-blue);'
653
+ 'margin-left:auto">BACKFILL</span>' if t["backfilled"] else "")
654
+ return (
655
+ f'<div class="fa-trcell{" miss" if t["miss"] else ""}">'
656
+ '<div style="display:flex;align-items:center;gap:5px">'
657
+ f'<span class="mono-data" style="font-size:var(--text-2xs);'
658
+ f'color:var(--text-tertiary)">{e(t["date"])}</span>'
659
+ f'<span class="mono-data" style="font-size:var(--text-xs);'
660
+ f'color:{a(t["hit_color"])};margin-left:auto">{e(t["hit"])}</span></div>'
661
+ '<svg width="94" height="38" viewBox="0 0 94 38" style="display:block">'
662
+ f'<path d="{a(spark["band"])}" fill="var(--accent-amber-strong)" opacity="0.2"></path>'
663
+ f'<path d="{a(spark["mid"])}" fill="none" stroke="var(--accent-amber-strong)" '
664
+ 'stroke-width="1.2" stroke-dasharray="3 2"></path>'
665
+ f'<path d="{a(spark["real"])}" fill="none" stroke="var(--text-primary)" '
666
+ 'stroke-width="1.4"></path></svg>'
667
+ '<div style="display:flex;align-items:center;gap:4px">'
668
+ f'<span class="mono-data" style="font-size:var(--text-2xs);'
669
+ f'color:var(--text-secondary)">{e(t["err"])}</span>{backfill}</div></div>')
670
+
671
+
672
+ def _scoreboard(state: dict, data: dict) -> str:
673
+ """The matchup table: same stats, aligned, best in row highlighted."""
674
+ slugs = [s for s in state["selected"] if s in data["panels"]]
675
+ if not slugs:
676
+ return _empty_record()
677
+ panels = [data["panels"][s] for s in slugs]
678
+ cols = f'1.15fr repeat({len(slugs)},minmax(0,1fr))'
679
+
680
+ heads = "".join(
681
+ '<div style="padding:7px 12px;border-right:1px solid var(--border-subtle);'
682
+ f'border-top:3px solid {a(chart.series_color(i))};display:flex;'
683
+ 'align-items:center;gap:7px">'
684
+ f'<span style="{STYRENE};font-size:var(--text-sm);white-space:nowrap;'
685
+ 'overflow:hidden;text-overflow:ellipsis">'
686
+ f'{e(data["models_by_slug"].get(s, {}).get("name", s))}</span>'
687
+ f'<span class="mono-data" style="font-size:var(--text-xs);'
688
+ f'color:{a(grade_color(p.get("grade", "-")))};'
689
+ f'border:1px solid {a(grade_color(p.get("grade", "-")))};'
690
+ f'padding:1px 5px;margin-left:auto">{e(p.get("grade", "-"))}</span></div>'
691
+ for i, (s, p) in enumerate(zip(slugs, panels)))
692
+
693
+ # (label, hint, value fn, "best" fn or None). Coverage is scored on
694
+ # distance from nominal, not on being highest -- a narrower band is not a
695
+ # better one, and the design says so on the page.
696
+ rows = [
697
+ ("80% band coverage", "TARGET 80%",
698
+ lambda p: pct(p.get("coverage")),
699
+ lambda p: (-abs(p["coverage"] - config.NOMINAL_COVERAGE)
700
+ if p.get("coverage") is not None else None)),
701
+ ("Median abs error", "LOWER BETTER",
702
+ lambda p: pct(p["median_err"] / 100 if p.get("median_err") is not None else None, 2),
703
+ lambda p: (-p["median_err"] if p.get("median_err") is not None else None)),
704
+ ("Mean band width", "CONTEXT ONLY",
705
+ lambda p: pct(p.get("width"), 1), None),
706
+ ("Resolved forecasts", "SAMPLE SIZE",
707
+ lambda p: count(p.get("resolved")),
708
+ lambda p: p.get("resolved")),
709
+ ("Calibration verdict", "FROM RESOLVED ONLY",
710
+ lambda p: p.get("verdict", DASH), None),
711
+ ]
712
+
713
+ body = []
714
+ for label, hint, value_of, best_of in rows:
715
+ best_index = None
716
+ if best_of is not None:
717
+ scored = [(i, best_of(p)) for i, p in enumerate(panels)]
718
+ scored = [(i, v) for i, v in scored if v is not None]
719
+ if scored:
720
+ best_index = max(scored, key=lambda kv: kv[1])[0]
721
+ cells = []
722
+ for i, p in enumerate(panels):
723
+ is_best = i == best_index
724
+ fg = ("var(--accent-moss-strong)" if is_best else "var(--text-primary)")
725
+ if label.startswith("Calibration"):
726
+ fg = p.get("verdict_color", "var(--text-primary)")
727
+ cells.append(
728
+ '<div style="padding:7px 12px;border-right:1px solid var(--border-subtle);'
729
+ f'background:{"var(--bg-raised)" if is_best else "transparent"};'
730
+ 'display:flex;align-items:center;gap:7px">'
731
+ f'<span class="mono-data" style="font-size:var(--text-md);'
732
+ f'color:{a(fg)}">{e(value_of(p))}</span>'
733
+ '<span class="mono-data" style="font-size:var(--text-2xs);'
734
+ f'color:var(--text-tertiary);margin-left:auto">{"BEST" if is_best else ""}</span>'
735
+ '</div>')
736
+ body.append(
737
+ f'<div style="display:grid;grid-template-columns:{cols};'
738
+ 'border-bottom:1px solid var(--border-subtle)">'
739
+ '<div style="padding:7px 12px;border-right:1px solid var(--border-subtle);'
740
+ 'display:flex;flex-direction:column;gap:1px">'
741
+ f'<span style="{STYRENE};font-weight:300;font-size:var(--text-xs);'
742
+ f'color:var(--text-secondary)">{e(label)}</span>'
743
+ f'<span class="pixel-text" style="color:var(--text-tertiary)">{e(hint)}</span>'
744
+ f'</div>{"".join(cells)}</div>')
745
+
746
+ return (
747
+ f'<div><div style="display:grid;grid-template-columns:{cols};'
748
+ 'border-bottom:1px solid var(--border-default);background:var(--bg-raised)">'
749
+ '<div style="padding:7px 12px;border-right:1px solid var(--border-subtle)">'
750
+ '<span class="pixel-text" style="color:var(--text-tertiary)">METRIC</span></div>'
751
+ f'{heads}</div>{"".join(body)}'
752
+ '<div style="display:flex;align-items:center;gap:10px;padding:7px 12px;'
753
+ 'background:var(--bg-raised);flex-wrap:wrap">'
754
+ '<span class="pixel-text" style="color:var(--accent-amber-strong)">i</span>'
755
+ '<span style="font-size:var(--text-xs);color:var(--text-secondary);'
756
+ 'text-wrap:pretty">Highlighted cell is best in row. Coverage closest to 80% '
757
+ 'wins — a narrower band is not a better one.</span></div></div>')
758
+
759
+
760
+ # --------------------------------------------------------------------------
761
+ # Rail
762
+ # --------------------------------------------------------------------------
763
+
764
+
765
+ def rail(state: dict, data: dict) -> str:
766
+ return ('<aside class="fa-rail">'
767
+ f'{_standings(state, data)}{_volatility(data)}{_log(state, data)}'
768
+ f'{_extend(state, data)}{_enroll(state, data)}</aside>')
769
+
770
+
771
+ def _standings(state: dict, data: dict) -> str:
772
+ rows = data.get("standings_rows", [])
773
+ enrolled = len(data.get("models", []))
774
+
775
+ tabs = "".join(
776
+ f'<button {bit("cls", c)} style="flex:1 1 auto;padding:4px 5px;'
777
+ f'background:{"var(--bg-raised)" if state["cls"] == c else "transparent"};'
778
+ 'border:0;border-right:1px solid var(--border-subtle);'
779
+ f'color:{"var(--accent-amber-strong)" if state["cls"] == c else "var(--text-tertiary)"};'
780
+ f'{STYRENE};font-size:var(--text-2xs);cursor:pointer">{e(c)}</button>'
781
+ for c in ("All", "Crypto", "Equities"))
782
+
783
+ if rows:
784
+ body = "".join(
785
+ f'<button {bit("stand", r["slug"])} class="fa-stand'
786
+ f'{" on" if r["selected"] else ""}" '
787
+ f'style="border-left-color:{a(chart.series_color(0)) if r["selected"] else "transparent"}">'
788
+ f'<span class="mono-data" style="font-size:var(--text-2xs);'
789
+ f'color:var(--text-tertiary);flex:0 0 auto">{e(r["rank"])}</span>'
790
+ '<span class="fa-stand-mid">'
791
+ f'<span class="mono-data fa-stand-name">{e(r["name"])}</span>'
792
+ f'<span class="pixel-text" style="color:var(--text-tertiary)">{e(r["meta"])}</span>'
793
+ '</span>'
794
+ f'<span class="mono-data" style="font-size:var(--text-md);'
795
+ f'color:{a(r["grade_color"])};flex:0 0 auto">{e(r["grade"])}</span></button>'
796
+ for r in rows)
797
+ else:
798
+ body = ('<div style="padding:18px 10px;text-align:center">'
799
+ '<span style="font-size:var(--text-xs);color:var(--text-tertiary);'
800
+ 'text-wrap:pretty">No forecasts have resolved yet. Standings appear '
801
+ 'once the resolver has outcomes to score.</span></div>')
802
+
803
+ right = ('<span class="mono-data" style="font-size:var(--text-2xs);'
804
+ f'color:var(--text-tertiary);margin-left:auto">{enrolled} ENROLLED</span>')
805
+
806
+ return (
807
+ f'<div style="{PANEL}">'
808
+ f'{_panel_title("Arena standings", right)}'
809
+ f'<div style="display:flex;border-bottom:1px solid var(--border-subtle)">{tabs}</div>'
810
+ f'{body}'
811
+ '<div style="padding:6px 10px;display:flex;align-items:center">'
812
+ '<span class="pixel-text" style="color:var(--text-tertiary)">'
813
+ 'GRADES FROM RESOLVED FORECASTS ONLY</span>'
814
+ f'<a href="{a(data.get("standings_url", "#"))}" target="_blank" rel="noopener" '
815
+ 'style="margin-left:auto;font-family:var(--font-mono);font-size:var(--text-2xs)">'
816
+ 'FULL TABLE ↗</a></div></div>')
817
+
818
+
819
+ def _volatility(data: dict) -> str:
820
+ vol = data.get("volatility") or {}
821
+ rows = vol.get("rows", [])
822
+ if not rows:
823
+ return ""
824
+ body = "".join(
825
+ '<div style="display:flex;flex-direction:column;gap:4px">'
826
+ '<div style="display:flex;align-items:center;gap:8px">'
827
+ f'<span style="{STYRENE};font-weight:300;font-size:var(--text-2xs);'
828
+ f'color:var(--text-secondary)">{e(r["label"])}</span>'
829
+ f'<span class="mono-data" style="font-size:var(--text-xs);'
830
+ f'color:{a(r["fg"])};margin-left:auto">{e(r["value"])}</span></div>'
831
+ '<div style="height:5px;background:var(--bg-sunken);'
832
+ 'border:1px solid var(--border-subtle);position:relative;overflow:hidden">'
833
+ f'<div style="position:absolute;left:0;top:0;bottom:0;width:{a(r["pct"])};'
834
+ f'background:{a(r["fg"])}"></div></div></div>'
835
+ for r in rows)
836
+ right = ('<span class="mono-data" style="font-size:var(--text-2xs);'
837
+ f'color:var(--text-tertiary);margin-left:auto">{e(vol.get("verdict", ""))}</span>')
838
+ return (
839
+ f'<div style="{PANEL}">{_panel_title("Volatility read", right)}'
840
+ '<div style="padding:10px;display:flex;flex-direction:column;gap:9px">'
841
+ f'{body}'
842
+ '<span style="font-size:var(--text-2xs);color:var(--text-tertiary);'
843
+ f'text-wrap:pretty">{e(vol.get("note", ""))}</span></div></div>')
844
+
845
+
846
+ def _log(state: dict, data: dict) -> str:
847
+ entries = state.get("log", [])[:view.LOG_LIMIT]
848
+ if entries:
849
+ body = "".join(
850
+ '<div style="display:flex;align-items:center;gap:8px;padding:6px 10px;'
851
+ 'border-bottom:1px solid var(--border-subtle)">'
852
+ f'<span class="mono-data" style="font-size:var(--text-2xs);'
853
+ f'color:var(--text-tertiary);flex:0 0 auto">{e(l["time"])}</span>'
854
+ '<span class="fa-stand-mid">'
855
+ f'<span class="mono-data fa-stand-name">{e(l["name"])}</span>'
856
+ f'<span class="pixel-text" style="color:var(--text-tertiary)">{e(l["meta"])}</span>'
857
+ '</span>'
858
+ f'<button {bit("reopen", l["id"])} style="background:transparent;border:0;'
859
+ 'color:var(--accent-amber-strong);cursor:pointer;font-family:var(--font-mono);'
860
+ 'font-size:var(--text-2xs);flex:0 0 auto">RE-OPEN</button></div>'
861
+ for l in entries)
862
+ else:
863
+ body = ('<div style="padding:14px 10px">'
864
+ '<span style="font-size:var(--text-xs);color:var(--text-tertiary)">'
865
+ 'Forecasts you issue this session appear here.</span></div>')
866
+ right = ('<span class="mono-data" style="font-size:var(--text-2xs);'
867
+ 'color:var(--text-tertiary);margin-left:auto">THIS SESSION</span>')
868
+ return (
869
+ f'<div style="{PANEL}">{_panel_title("Forecast log", right)}{body}'
870
+ '<div style="padding:6px 10px">'
871
+ '<span class="pixel-text" style="color:var(--text-tertiary)">'
872
+ 'ARCHIVED IMMUTABLY · SEED + CONFIG SAVED</span></div></div>')
873
+
874
+
875
+ def _extend(state: dict, data: dict) -> str:
876
+ cov = data.get("coverage") or {}
877
+ pct_label = cov.get("pct_label", DASH)
878
+ width = cov.get("pct_width", "0%")
879
+ rows = "".join(
880
+ '<div style="display:flex;align-items:center;gap:8px">'
881
+ f'<span class="pixel-text" style="color:var(--text-tertiary)">{e(label)}</span>'
882
+ f'<span class="mono-data" style="font-size:var(--text-2xs);'
883
+ f'color:var(--text-secondary);margin-left:auto">{e(value)}</span></div>'
884
+ for label, value in cov.get("rows", []))
885
+
886
+ label = ("Backfill queued" if state.get("backfill_requested")
887
+ else "Contribute a backfill run (uses your quota)")
888
+ return (
889
+ f'<div style="{PANEL}">{_panel_title("Extend the record")}'
890
+ '<div style="padding:10px;display:flex;flex-direction:column;gap:8px">'
891
+ '<div style="display:flex;align-items:center;gap:8px">'
892
+ f'<span style="font-size:var(--text-xs);color:var(--text-secondary)">'
893
+ f'{e(cov.get("scope", ""))}</span>'
894
+ f'<span class="mono-data" style="font-size:var(--text-md);'
895
+ f'color:var(--accent-amber-strong);margin-left:auto">{e(pct_label)}</span></div>'
896
+ '<div style="height:6px;background:var(--bg-sunken);'
897
+ 'border:1px solid var(--border-subtle);position:relative;overflow:hidden">'
898
+ f'<div style="position:absolute;left:0;top:0;bottom:0;width:{a(width)};'
899
+ 'background:var(--accent-amber)"></div></div>'
900
+ f'<div style="display:flex;flex-direction:column;gap:3px">{rows}</div>'
901
+ f'<button {bit("backfill", "run")} style="width:100%;padding:6px;'
902
+ 'background:transparent;border:1px solid var(--border-default);'
903
+ f'color:var(--text-secondary);cursor:pointer;{STYRENE};'
904
+ f'font-size:var(--text-xs)">{e(label)}</button>'
905
+ '<span class="pixel-text" style="color:var(--text-tertiary)">'
906
+ 'BACKFILLED ENTRIES ARE LABELLED IN THE RECORD</span></div></div>')
907
+
908
+
909
+ def _enroll(state: dict, data: dict) -> str:
910
+ note = state.get("enroll_note") or "A smoke test runs before a model is enrolled"
911
+ ok = state.get("enroll_ok")
912
+ color = ("var(--accent-moss-strong)" if ok else
913
+ "var(--fin-down)" if ok is False else "var(--text-tertiary)")
914
+
915
+ families = "".join(
916
+ f'<button {bit("family", f)} class="fa-pill'
917
+ f'{" on" if state.get("family") == f else ""}" '
918
+ f'style="font-size:var(--text-2xs)">{e(f)}</button>'
919
+ for f in data.get("families", ()))
920
+
921
+ return (
922
+ f'<div style="{PANEL}">{_panel_title("Enroll a model")}'
923
+ '<div style="padding:10px;display:flex;flex-direction:column;gap:8px">'
924
+ f'<input value="{a(state.get("hfid", ""))}" '
925
+ f'data-bit-input="hfid" placeholder="author/model-name" '
926
+ 'style="width:100%;padding:5px 7px;background:var(--bg-sunken);'
927
+ 'border:1px solid var(--border-default);outline:none;'
928
+ 'font-family:var(--font-mono);font-size:var(--text-xs);'
929
+ 'color:var(--text-primary)" />'
930
+ f'<div style="display:flex;gap:4px;flex-wrap:wrap">{families}</div>'
931
+ '<div style="display:flex;align-items:center;gap:7px">'
932
+ f'<button {bit("enroll", "run")} style="padding:5px 10px;'
933
+ 'background:transparent;border:1px solid var(--border-default);'
934
+ f'color:var(--text-secondary);cursor:pointer;{STYRENE};'
935
+ 'font-size:var(--text-xs)">Smoke-test</button></div>'
936
+ f'<span class="mono-data" style="font-size:var(--text-2xs);'
937
+ f'color:{a(color)};text-wrap:pretty">{e(note)}</span>'
938
+ '<span style="font-size:var(--text-2xs);color:var(--text-tertiary);'
939
+ 'text-wrap:pretty">Only models loadable through a vetted adapter family are '
940
+ 'accepted. No user-supplied code is ever executed.</span></div></div>')
941
+
942
+
943
+ # --------------------------------------------------------------------------
944
+ # Footer
945
+ # --------------------------------------------------------------------------
946
+
947
+
948
+ def methodology(data: dict) -> str:
949
+ return (
950
+ '<div style="display:flex;align-items:flex-start;gap:10px;padding:9px 12px;'
951
+ 'border:1px solid var(--border-subtle);background:var(--bg-panel)">'
952
+ '<span class="pixel-text" style="color:var(--accent-amber-strong);'
953
+ 'flex:0 0 auto;margin-top:2px">i</span>'
954
+ '<span style="font-size:var(--text-xs);color:var(--text-secondary);'
955
+ 'text-wrap:pretty">Forecasts are frozen at issue and never revised; quantiles '
956
+ 'from N sampled paths where applicable; seeds shown; backfilled entries '
957
+ 'labelled; grades computed from resolved forecasts only. Nothing here is '
958
+ 'financial advice.</span>'
959
+ f'<a href="{a(data.get("methodology_url", "#"))}" target="_blank" rel="noopener" '
960
+ 'style="margin-left:auto;font-family:var(--font-mono);font-size:var(--text-2xs);'
961
+ 'white-space:nowrap;flex:0 0 auto">METHODOLOGY ↗</a></div>')
962
+
963
+
964
+ def footer(data: dict) -> str:
965
+ nav = data.get("nav") or {}
966
+ products = [i.get("name", "") for i in nav.get("items", [])][:12]
967
+ prod_html = "".join(
968
+ f'<span style="color:var(--text-secondary);font-size:var(--text-xs)">{e(p)}</span>'
969
+ for p in products)
970
+ resources = "".join(
971
+ f'<a href="{a(url)}" target="_blank" rel="noopener" '
972
+ f'style="color:var(--text-secondary);font-size:var(--text-xs)">{e(label)}</a>'
973
+ for label, url in data.get("footer_resources", ()))
974
+
975
+ return (
976
+ f'<footer style="{PANEL}">'
977
+ '<div class="fa-foot">'
978
+ '<div style="padding:14px;border-right:1px solid var(--border-subtle)">'
979
+ f'<div style="{STYRENE};font-size:var(--text-2xs);'
980
+ 'letter-spacing:var(--tracking-wider);color:var(--text-tertiary);'
981
+ 'margin-bottom:8px">Products</div>'
982
+ '<div style="display:grid;grid-template-columns:1fr 1fr;gap:3px 12px">'
983
+ f'{prod_html}</div></div>'
984
+ '<div style="padding:14px;border-right:1px solid var(--border-subtle)">'
985
+ f'<div style="{STYRENE};font-size:var(--text-2xs);'
986
+ 'letter-spacing:var(--tracking-wider);color:var(--text-tertiary);'
987
+ 'margin-bottom:8px">Resources</div>'
988
+ '<div style="display:flex;flex-direction:column;gap:3px">'
989
+ f'{resources}</div></div>'
990
+ '<div style="padding:14px;border-right:1px solid var(--border-subtle)">'
991
+ f'<div style="{STYRENE};font-size:var(--text-2xs);'
992
+ 'letter-spacing:var(--tracking-wider);color:var(--text-tertiary);'
993
+ 'margin-bottom:8px">Method</div>'
994
+ '<div style="display:flex;flex-direction:column;gap:3px">'
995
+ '<span style="color:var(--text-secondary);font-size:var(--text-xs)">'
996
+ 'Frozen at issue</span>'
997
+ '<span style="color:var(--text-secondary);font-size:var(--text-xs)">'
998
+ 'Seeded and reproducible</span>'
999
+ '<span style="color:var(--text-secondary);font-size:var(--text-xs)">'
1000
+ 'Backfill labelled</span>'
1001
+ '<span style="color:var(--text-secondary);font-size:var(--text-xs)">'
1002
+ 'Graded on resolved only</span></div></div>'
1003
+ '<div style="padding:14px;background:var(--bg-raised)">'
1004
+ '<div style="display:flex;align-items:center;gap:6px;margin-bottom:8px">'
1005
+ '<span class="pixel-text" style="color:var(--accent-amber-strong)">⚠</span>'
1006
+ f'<span style="{STYRENE};font-size:var(--text-2xs);'
1007
+ 'letter-spacing:var(--tracking-wider);color:var(--accent-amber-strong)">'
1008
+ 'Disclaimer</span></div>'
1009
+ '<div class="styrene-text" style="font-size:var(--text-xs);'
1010
+ 'text-transform:uppercase;letter-spacing:var(--tracking-wide);'
1011
+ 'color:var(--text-secondary);line-height:var(--leading-normal);'
1012
+ 'text-wrap:pretty">The Bit Trading Company publishes research tools and '
1013
+ 'educational software. Nothing here is financial advice. Forecasts are model '
1014
+ 'output, not predictions. Past performance does not predict future results.'
1015
+ '</div></div></div>'
1016
+ '<div style="display:flex;align-items:center;gap:14px;padding:8px 14px;'
1017
+ 'border-top:1px solid var(--border-subtle);flex-wrap:wrap">'
1018
+ '<span style="display:inline-block;width:6px;height:6px;background:var(--fin-up);'
1019
+ 'animation:bitPulse 2s ease-in-out infinite"></span>'
1020
+ '<span class="mono-data" style="font-size:var(--text-2xs);'
1021
+ f'color:var(--text-tertiary)">{e(data.get("status_line", ""))}</span>'
1022
+ '<span class="mono-data" style="font-size:var(--text-2xs);'
1023
+ 'color:var(--text-tertiary);margin-left:auto">'
1024
+ '© 2026 THE BIT TRADING COMPANY</span></div></footer>')
1025
+
1026
+
1027
+ # --------------------------------------------------------------------------
1028
+ # The page
1029
+ # --------------------------------------------------------------------------
1030
+
1031
+
1032
+ def page(state: dict, data: dict) -> str:
1033
+ """The whole document body, from state. Pure -- no I/O anywhere below."""
1034
+ return (
1035
+ '<div class="bit-root" style="background:var(--bg-canvas);'
1036
+ 'color:var(--text-primary);display:flex;align-items:stretch;'
1037
+ 'font-family:var(--font-body);font-size:var(--text-base)">'
1038
+ f'{sidebar.render(data.get("nav") or {}, active="Forecast Arena", emit=emit)}'
1039
+ '<div style="flex:1 1 auto;min-width:0;display:flex;flex-direction:column">'
1040
+ f'{header(state, data)}'
1041
+ '<main style="flex:1 1 auto;min-width:0;padding:12px;display:flex;'
1042
+ 'flex-direction:column;gap:12px">'
1043
+ f'{title_row(state, data)}'
1044
+ f'{stats_bar(data.get("stats", []))}'
1045
+ f'{models_section(state, data)}'
1046
+ f'{controls(state, data)}'
1047
+ '<div class="fa-zones">'
1048
+ '<div class="fa-main">'
1049
+ f'{chart_section(state, data)}'
1050
+ f'{trackrecord_section(state, data)}'
1051
+ '</div>'
1052
+ f'{rail(state, data)}'
1053
+ '</div>'
1054
+ f'{methodology(data)}'
1055
+ f'{footer(data)}'
1056
+ '</main></div></div>')
src/view.py ADDED
@@ -0,0 +1,396 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """State, and the derivations the UI renders from.
2
+
3
+ The whole UI is a pure function of the dict `default_state()` returns. Actions
4
+ fold that dict forward; the renderer turns it into markup. Nothing in the view
5
+ layer reaches for a model, a store or a clock, which is what lets the entire
6
+ interface be tested without a browser or a network.
7
+
8
+ Numbers that do not exist render as an em dash, never as `0.00`. A model with
9
+ no resolved forecasts has no coverage -- saying `0%` would claim it was wrong
10
+ every time, which is the opposite of the truth.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import numpy as np
16
+ import pandas as pd
17
+
18
+ from . import config
19
+ from .store import now_utc
20
+ from .trackrecord import grade_for
21
+
22
+ DASH = "—"
23
+
24
+ MAX_MATCHUP = 3
25
+ LOG_LIMIT = 8
26
+ THUMBS = 10
27
+
28
+
29
+ def default_state() -> dict:
30
+ return {
31
+ "mode": "playground",
32
+ "selected": [], # filled at boot from the registry
33
+ "asset": "BTC-USD",
34
+ "tf": "1h",
35
+ "horizon": config.DEFAULT_HORIZON["1h"],
36
+ "ghosts": True,
37
+ "gear": False,
38
+ "cls": "All",
39
+ "seed": 42,
40
+ "n_samples": config.DEFAULT_N_SAMPLES,
41
+ "hfid": "",
42
+ "family": "kronos",
43
+ "enroll_note": "",
44
+ "enroll_ok": None,
45
+ "log": [],
46
+ "error": None,
47
+ "session_forecasts": 0,
48
+ "signed_in": False,
49
+ "user": "",
50
+ "backfill_requested": False,
51
+ "nonce": "",
52
+ }
53
+
54
+
55
+ # --------------------------------------------------------------------------
56
+ # Formatting
57
+ # --------------------------------------------------------------------------
58
+
59
+
60
+ def pct(value, digits: int = 0) -> str:
61
+ if value is None or (isinstance(value, float) and not np.isfinite(value)):
62
+ return DASH
63
+ return f"{value * 100:.{digits}f}%"
64
+
65
+
66
+ def num(value, digits: int = 2) -> str:
67
+ if value is None or (isinstance(value, float) and not np.isfinite(value)):
68
+ return DASH
69
+ return f"{value:,.{digits}f}"
70
+
71
+
72
+ def count(value) -> str:
73
+ if value is None:
74
+ return DASH
75
+ return f"{int(value):,}"
76
+
77
+
78
+ def grade_color(grade: str) -> str:
79
+ head = (grade or "")[:1]
80
+ if head == "A":
81
+ return "var(--accent-moss-strong)"
82
+ if head == "B":
83
+ return "var(--accent-amber-strong)"
84
+ if head in ("C", "D"):
85
+ return "var(--mute-orange)"
86
+ return "var(--text-tertiary)"
87
+
88
+
89
+ def verdict_for(coverage, resolved: int) -> tuple[str, str, str]:
90
+ """(chip, short, colour) describing a model's calibration in words.
91
+
92
+ The wording is deliberately unflattering where the data is unflattering: a
93
+ model whose bands are too narrow is called overconfident on the page, not
94
+ in a footnote.
95
+ """
96
+ if resolved < config.MIN_RESOLVED_FOR_GRADE or coverage is None:
97
+ return ("NOT YET GRADED", "too few resolved", "var(--text-tertiary)")
98
+ gap = coverage - config.NOMINAL_COVERAGE
99
+ if abs(gap) <= 0.05:
100
+ return ("WELL CALIBRATED", "bands match reality", "var(--accent-moss-strong)")
101
+ if gap < -0.15:
102
+ return ("OVERCONFIDENT · BANDS TOO NARROW",
103
+ f"runs {abs(gap) * 100:.0f}pts under", "var(--mute-orange)")
104
+ if gap < 0:
105
+ return ("SLIGHTLY OVERCONFIDENT",
106
+ f"runs {abs(gap) * 100:.0f}pts under", "var(--accent-amber-strong)")
107
+ if gap > 0.15:
108
+ return ("UNDERCONFIDENT · BANDS TOO WIDE",
109
+ f"runs {gap * 100:.0f}pts over", "var(--mute-orange)")
110
+ return ("SLIGHTLY UNDERCONFIDENT",
111
+ f"runs {gap * 100:.0f}pts over", "var(--accent-amber-strong)")
112
+
113
+
114
+ # --------------------------------------------------------------------------
115
+ # Summaries
116
+ # --------------------------------------------------------------------------
117
+
118
+
119
+ class Summary:
120
+ """Precomputed per-(model, asset, timeframe) track record.
121
+
122
+ Built once at boot from the standings table and the resolved rows, so the
123
+ Track Record panel is an in-memory lookup rather than a parquet read on
124
+ every interaction.
125
+ """
126
+
127
+ def __init__(self, standings: pd.DataFrame, trackrecord: pd.DataFrame):
128
+ self.standings = standings if standings is not None else pd.DataFrame()
129
+ self.trackrecord = trackrecord if trackrecord is not None else pd.DataFrame()
130
+
131
+ def class_of(self, asset: str) -> str:
132
+ spec = config.ASSETS.get(asset)
133
+ return spec.asset_class if spec else "other"
134
+
135
+ def standing(self, model_slug: str, asset: str) -> dict:
136
+ """The standings row for this model in this asset's class."""
137
+ if not len(self.standings):
138
+ return {}
139
+ rows = self.standings[
140
+ (self.standings["model_slug"] == model_slug)
141
+ & (self.standings["asset_class"] == self.class_of(asset))]
142
+ return rows.iloc[0].to_dict() if len(rows) else {}
143
+
144
+ def scoped(self, model_slug: str, asset: str, timeframe: str) -> pd.DataFrame:
145
+ if not len(self.trackrecord):
146
+ return pd.DataFrame()
147
+ tr = self.trackrecord
148
+ return tr[(tr["model_slug"] == model_slug) & (tr["asset"] == asset)
149
+ & (tr["timeframe"] == timeframe)]
150
+
151
+ def panel(self, model_slug: str, asset: str, timeframe: str,
152
+ horizon: int) -> dict:
153
+ """Everything the Track Record panel needs for one model."""
154
+ rows = self.scoped(model_slug, asset, timeframe)
155
+ stand = self.standing(model_slug, asset)
156
+ resolved = len(rows)
157
+
158
+ if resolved:
159
+ coverage = float(rows["inside_80"].mean())
160
+ median_err = float(rows["pct_error"].median())
161
+ width = float(((rows["q90"] - rows["q10"]).abs()
162
+ / rows["realized_close"].abs()).median())
163
+ backfilled = float(rows["backfilled"].mean())
164
+ else:
165
+ coverage = median_err = width = backfilled = None
166
+
167
+ chip, short, color = verdict_for(coverage, resolved)
168
+ grade = stand.get("grade") or grade_for(
169
+ (coverage - config.NOMINAL_COVERAGE) if coverage is not None else None,
170
+ resolved)
171
+
172
+ return {
173
+ "empty": resolved == 0,
174
+ "resolved": resolved,
175
+ "coverage": coverage,
176
+ "median_err": median_err,
177
+ "width": width,
178
+ "backfilled_share": backfilled,
179
+ "grade": grade,
180
+ "grade_color": grade_color(grade),
181
+ "verdict": chip,
182
+ "verdict_short": short,
183
+ "verdict_color": color,
184
+ "thumbs": self.thumbs(rows, horizon),
185
+ "total": int(stand.get("resolved_count") or resolved),
186
+ }
187
+
188
+ def thumbs(self, rows: pd.DataFrame, horizon: int) -> list[dict]:
189
+ """The last ~10 resolved forecasts, as sparkline thumbnails."""
190
+ if not len(rows):
191
+ return []
192
+ out = []
193
+ # One thumbnail per forecast, newest first.
194
+ for fid, group in sorted(rows.groupby("forecast_id"),
195
+ key=lambda kv: kv[1]["issued_ts"].max(),
196
+ reverse=True)[:THUMBS]:
197
+ group = group.sort_values("step")
198
+ hit = bool(group["inside_80"].mean() >= 0.5)
199
+ err = float(group["pct_error"].median())
200
+ out.append({
201
+ "id": str(fid),
202
+ "date": pd.Timestamp(group["issued_ts"].iloc[0]).strftime("%m-%d %H:%M"),
203
+ "hit": "✓" if hit else "✕",
204
+ "hit_color": "var(--fin-up)" if hit else "var(--fin-down)",
205
+ "miss": not hit,
206
+ "err": f"{err:.2f}% ERR",
207
+ "backfilled": bool(group["backfilled"].any()),
208
+ "spark": _spark(group),
209
+ })
210
+ return out
211
+
212
+
213
+ def _spark(group: pd.DataFrame) -> dict:
214
+ """Band, median and realised path as SVG paths in a 94x38 box."""
215
+ n = len(group)
216
+ if n < 2:
217
+ return {"band": "", "mid": "", "real": ""}
218
+ lo = group["q10"].to_numpy(dtype="float64")
219
+ hi = group["q90"].to_numpy(dtype="float64")
220
+ mid = group["q50"].to_numpy(dtype="float64")
221
+ real = group["realized_close"].to_numpy(dtype="float64")
222
+
223
+ floor = float(min(lo.min(), real.min()))
224
+ ceil = float(max(hi.max(), real.max()))
225
+ span = (ceil - floor) or 1.0
226
+
227
+ def xy(i, v):
228
+ return f"{6 + i * (82 / max(1, n - 1)):.1f} {35 - (v - floor) / span * 32:.1f}"
229
+
230
+ upper = [xy(i, hi[i]) for i in range(n)]
231
+ lower = [xy(i, lo[i]) for i in range(n)]
232
+ return {
233
+ "band": "M" + " L".join(upper) + " L" + " L".join(reversed(lower)) + " Z",
234
+ "mid": "M" + " L".join(xy(i, mid[i]) for i in range(n)),
235
+ "real": "M" + " L".join(xy(i, real[i]) for i in range(n)),
236
+ }
237
+
238
+
239
+ # --------------------------------------------------------------------------
240
+ # Volatility
241
+ # --------------------------------------------------------------------------
242
+
243
+
244
+ def volatility(run, history: pd.DataFrame, timeframe: str) -> dict:
245
+ """Forecast dispersion against trailing realised volatility.
246
+
247
+ For a path model the forecast figure is the standard deviation across
248
+ sampled paths; for a quantile-only model it is the band half-width. Those
249
+ are not the same statistic, and the label says which one is on screen
250
+ rather than quietly presenting them as comparable.
251
+ """
252
+ bars_per_day = 24 if timeframe == "1h" else 1
253
+ horizon_days = max(1.0, (run.horizon / bars_per_day)) if run else 1.0
254
+
255
+ # Before any forecast has been issued there is no history to read. An
256
+ # empty frame has no columns at all, so this guards on the column rather
257
+ # than on the length.
258
+ if history is None or "close" not in getattr(history, "columns", []):
259
+ return {"rows": [], "verdict": DASH, "note": "", "kind": ""}
260
+ close = history["close"].to_numpy(dtype="float64")
261
+ if len(close) < 10:
262
+ return {"rows": [], "verdict": DASH, "note": "", "kind": ""}
263
+
264
+ log_returns = np.diff(np.log(close))
265
+ # `a or b` on an array is a truthiness test, not a fallback. Slice widths
266
+ # are clamped explicitly instead.
267
+ recent_n = max(2, min(len(log_returns), int(bars_per_day)))
268
+ week_n = max(2, min(len(log_returns), int(bars_per_day * 7)))
269
+ realized_recent = float(np.std(log_returns[-recent_n:]))
270
+ realized_week = float(np.std(log_returns[-week_n:]))
271
+ scale = np.sqrt(bars_per_day)
272
+ realized_recent_d = realized_recent * scale
273
+ realized_week_d = realized_week * scale
274
+
275
+ if run is None:
276
+ forecast = None
277
+ kind = ""
278
+ else:
279
+ # Terminal dispersion, expressed per-day so it is comparable with the
280
+ # realised figures next to it.
281
+ forecast = float(run.result.dispersion()[-1]) / np.sqrt(horizon_days)
282
+ kind = ("SAMPLED-PATH DISPERSION"
283
+ if run.capabilities.get("output") == "ohlcv_paths"
284
+ else "BAND HALF-WIDTH")
285
+
286
+ peak = max(v for v in (forecast or 0, realized_recent_d, realized_week_d, 1e-9))
287
+ rows = [
288
+ {"label": f"Forecast-implied vol", "value": pct(forecast, 2) + " / 24H",
289
+ "pct": f"{min(100, (forecast or 0) / peak * 100):.0f}%",
290
+ "fg": "var(--accent-amber-strong)"},
291
+ {"label": "Realized vol · recent", "value": pct(realized_recent_d, 2) + " / 24H",
292
+ "pct": f"{min(100, realized_recent_d / peak * 100):.0f}%",
293
+ "fg": "var(--text-secondary)"},
294
+ {"label": "Realized vol · 7d avg", "value": pct(realized_week_d, 2) + " / 24H",
295
+ "pct": f"{min(100, realized_week_d / peak * 100):.0f}%",
296
+ "fg": "var(--text-tertiary)"},
297
+ ]
298
+
299
+ if forecast is None:
300
+ verdict, note = DASH, "Run a forecast to compare its dispersion with realised volatility."
301
+ else:
302
+ ratio = forecast / (realized_week_d or 1e-9)
303
+ if ratio > 1.15:
304
+ verdict = "FORECAST > REALIZED"
305
+ note = (f"{kind.capitalize()} is running {(ratio - 1) * 100:.0f}% above "
306
+ f"trailing realised vol — this model is pricing a wider range "
307
+ f"than the last week delivered.")
308
+ elif ratio < 0.85:
309
+ verdict = "FORECAST < REALIZED"
310
+ note = (f"{kind.capitalize()} is {(1 - ratio) * 100:.0f}% below trailing "
311
+ f"realised vol — the bands are tighter than recent moves justify.")
312
+ else:
313
+ verdict = "FORECAST ≈ REALIZED"
314
+ note = (f"{kind.capitalize()} is close to trailing realised vol.")
315
+
316
+ return {"rows": rows, "verdict": verdict, "note": note, "kind": kind}
317
+
318
+
319
+ # --------------------------------------------------------------------------
320
+ # Headline stats
321
+ # --------------------------------------------------------------------------
322
+
323
+
324
+ def headline_stats(registry: dict, standings: pd.DataFrame,
325
+ archived: int | None, resolved: int | None) -> list[dict]:
326
+ models = registry.get("models", {})
327
+ families = {m.get("family") for m in models.values() if m.get("family")}
328
+
329
+ best_label, best_note, best_color = DASH, "no graded models yet", "var(--text-primary)"
330
+ if standings is not None and len(standings):
331
+ graded = standings[standings["grade"] != "-"]
332
+ if len(graded):
333
+ best = graded.assign(_gap=graded["calibration_gap"].abs()) \
334
+ .sort_values("_gap").iloc[0]
335
+ best_label = str(best["grade"])
336
+ best_note = (f"{best['model_slug']} · "
337
+ f"{pct(float(best['coverage_80']))} coverage")
338
+ best_color = grade_color(best_label)
339
+
340
+ awaiting = None
341
+ if archived is not None and resolved is not None:
342
+ awaiting = max(0, archived - resolved)
343
+
344
+ return [
345
+ {"label": "Models enrolled", "value": count(len(models)),
346
+ "note": f"{len(families)} adapter families",
347
+ "mark": "var(--accent-amber)", "color": "var(--text-primary)"},
348
+ {"label": "Forecasts archived", "value": count(archived),
349
+ "note": "frozen at issue, never revised",
350
+ "mark": "var(--border-strong)", "color": "var(--text-primary)"},
351
+ {"label": "Resolved", "value": count(resolved),
352
+ "note": (f"{count(awaiting)} awaiting horizon" if awaiting is not None
353
+ else "awaiting the first resolver run"),
354
+ "mark": "var(--border-strong)", "color": "var(--text-primary)"},
355
+ {"label": "Best calibrated", "value": best_label, "note": best_note,
356
+ "mark": "var(--accent-moss)", "color": best_color},
357
+ ]
358
+
359
+
360
+ def standings_rows(standings: pd.DataFrame, registry: dict, cls: str,
361
+ selected: list[str]) -> list[dict]:
362
+ """The rail's leaderboard, filtered by asset class."""
363
+ if standings is None or not len(standings):
364
+ return []
365
+ df = standings
366
+ if cls and cls != "All":
367
+ wanted = {"Crypto": "crypto", "Equities": "equity"}.get(cls, cls.lower())
368
+ df = df[df["asset_class"] == wanted]
369
+ if not len(df):
370
+ return []
371
+
372
+ # Rank by calibration first, then by how much evidence there is. A model
373
+ # with a perfect gap over 20 forecasts should not outrank one with a nearly
374
+ # perfect gap over two thousand.
375
+ df = df.assign(_gap=df["calibration_gap"].abs(),
376
+ _graded=(df["grade"] != "-").astype(int))
377
+ df = df.sort_values(["_graded", "_gap", "resolved_count"],
378
+ ascending=[False, True, False])
379
+
380
+ models = registry.get("models", {})
381
+ out = []
382
+ for i, (_, row) in enumerate(df.iterrows()):
383
+ slug = str(row["model_slug"])
384
+ entry = models.get(slug, {})
385
+ out.append({
386
+ "rank": f"{i + 1:02d}",
387
+ "slug": slug,
388
+ "name": entry.get("display", slug),
389
+ "grade": str(row["grade"]),
390
+ "grade_color": grade_color(str(row["grade"])),
391
+ "meta": (f"{count(row['resolved_count'])} RESOLVED · "
392
+ f"{pct(float(row['coverage_80']))} COV · "
393
+ f"{str(row['asset_class']).upper()}"),
394
+ "selected": slug in selected,
395
+ })
396
+ return out
tests/__init__.py ADDED
File without changes
tests/conftest.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared fixtures. Everything here runs offline with no credentials."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+
11
+ ROOT = Path(__file__).resolve().parents[1]
12
+ if str(ROOT) not in sys.path:
13
+ sys.path.insert(0, str(ROOT))
14
+
15
+ # No test may reach the Hub. Pointing the store at a temp dir is not enough --
16
+ # a missing local file falls back to a download -- so `offline` is set on every
17
+ # store the suite builds, and this is the belt to that pair of braces.
18
+ os.environ.setdefault("HF_HUB_OFFLINE", "1")
19
+ os.environ.pop("HF_TOKEN", None)
20
+ os.environ.pop("HF_WRITE_TOKEN", None)
21
+
22
+
23
+ @pytest.fixture
24
+ def store(tmp_path):
25
+ from src.store import ArenaStore
26
+
27
+ return ArenaStore(repo_id=None, local_root=tmp_path / "store", offline=True)
28
+
29
+
30
+ @pytest.fixture
31
+ def context():
32
+ from tests.fixture import synth
33
+
34
+ return synth(400)
tests/fixture.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Synthetic market data with known statistics.
2
+
3
+ A geometric random walk is used rather than a recorded price series because
4
+ the coverage test needs a distribution whose true quantiles are known in
5
+ closed form. Real prices would make that test a measurement of the market
6
+ rather than of the arithmetic.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import numpy as np
12
+ import pandas as pd
13
+
14
+
15
+ def synth(n: int = 600, tf: str = "1h", seed: int = 7, s0: float = 50000.0,
16
+ sigma: float = 0.01, mu: float = 0.0) -> pd.DataFrame:
17
+ rng = np.random.default_rng(seed)
18
+ r = rng.normal(mu, sigma, n)
19
+ close = s0 * np.exp(np.cumsum(r))
20
+ openp = np.concatenate([[s0], close[:-1]])
21
+ hi = np.maximum(openp, close) * (1 + np.abs(rng.normal(0, sigma / 3, n)))
22
+ lo = np.minimum(openp, close) * (1 - np.abs(rng.normal(0, sigma / 3, n)))
23
+ vol = np.abs(rng.normal(1000, 200, n)) + 1.0
24
+ step = pd.Timedelta("1h") if tf == "1h" else pd.Timedelta("1d")
25
+ ts = pd.date_range("2025-01-01", periods=n, freq=step, tz="UTC")
26
+ return pd.DataFrame({"ts": ts, "open": openp, "high": hi, "low": lo,
27
+ "close": close, "volume": vol})
28
+
29
+
30
+ def future_ts(context: pd.DataFrame, horizon: int) -> pd.DatetimeIndex:
31
+ ts = pd.to_datetime(context["ts"], utc=True)
32
+ step = ts.diff().dropna().mode().iloc[0]
33
+ return pd.DatetimeIndex([ts.iloc[-1] + step * (i + 1) for i in range(horizon)])
tests/test_acceptance.py ADDED
@@ -0,0 +1,426 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The acceptance suite.
2
+
3
+ Each test here corresponds to a numbered promise the Arena makes about its own
4
+ data. They are the gate on deploying, so they are written to fail loudly rather
5
+ than to pass conveniently, and every one of them runs offline in under a
6
+ second.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import numpy as np
12
+ import pandas as pd
13
+ import pytest
14
+
15
+ from src import config, trackrecord as tr
16
+ from src.adapters import (ForecastResult, LookaheadError, ModelNotAllowed,
17
+ check_context, get_adapter)
18
+ from src.adapters.base import ContextError
19
+ from src.store import forecast_id
20
+ from tests.fixture import future_ts, synth
21
+
22
+ CPU_FAMILIES = ["baseline/random-walk", "baseline/drift", "baseline/bootstrap"]
23
+
24
+
25
+ # --------------------------------------------------------------------------
26
+ # 1. Determinism
27
+ # --------------------------------------------------------------------------
28
+
29
+
30
+ @pytest.mark.parametrize("model_id", CPU_FAMILIES)
31
+ def test_same_seed_is_bit_identical(model_id, context):
32
+ """Same (context, horizon, n_samples, seed) => bit-identical output."""
33
+ a = get_adapter("baseline", model_id).load()
34
+ kw = dict(horizon=12, n_samples=16, seed=1234)
35
+ first = a.predict(context, **kw)
36
+ second = a.predict(context, **kw)
37
+
38
+ assert np.array_equal(first.quantiles, second.quantiles)
39
+ if first.paths is not None:
40
+ assert np.array_equal(first.paths, second.paths)
41
+ assert first.inference_version == second.inference_version
42
+
43
+
44
+ @pytest.mark.parametrize("model_id", CPU_FAMILIES)
45
+ def test_different_seed_changes_sampled_output(model_id, context):
46
+ """A seed that does nothing is a seed that is not wired up."""
47
+ a = get_adapter("baseline", model_id).load()
48
+ kw = dict(horizon=12, n_samples=16)
49
+ first = a.predict(context, seed=1, **kw)
50
+ second = a.predict(context, seed=2, **kw)
51
+ if a.capabilities().emits_paths:
52
+ assert not np.array_equal(first.paths, second.paths)
53
+ else:
54
+ # The analytic baselines are closed-form: no sampling, so the seed
55
+ # legitimately does not move them. Asserting the opposite would be
56
+ # asserting a bug.
57
+ assert np.array_equal(first.quantiles, second.quantiles)
58
+
59
+
60
+ @pytest.mark.slow
61
+ @pytest.mark.parametrize("family,model_id", [
62
+ ("chronos", "amazon/chronos-bolt-tiny"),
63
+ ("timesfm", "google/timesfm-2.5-200m-pytorch"),
64
+ ("kronos", "NeoQuasar/Kronos-mini"),
65
+ ])
66
+ def test_determinism_across_all_three_families(family, model_id):
67
+ """The same promise, against real weights. Needs network; not in the gate."""
68
+ ctx = synth(400)
69
+ a = get_adapter(family, model_id).load()
70
+ kw = dict(horizon=8, n_samples=4, seed=99)
71
+ first = a.predict(ctx, **kw)
72
+ second = a.predict(ctx, **kw)
73
+ assert np.array_equal(first.quantiles, second.quantiles)
74
+ if first.paths is not None:
75
+ assert np.array_equal(first.paths, second.paths)
76
+
77
+
78
+ # --------------------------------------------------------------------------
79
+ # 2. No lookahead
80
+ # --------------------------------------------------------------------------
81
+
82
+
83
+ def test_context_after_issued_ts_raises(context):
84
+ """A context bar dated after the issue moment is refused, structurally."""
85
+ issued = pd.to_datetime(context["ts"], utc=True).iloc[-5]
86
+ with pytest.raises(LookaheadError):
87
+ check_context(context, issued_ts=issued)
88
+
89
+
90
+ def test_context_at_issued_ts_is_allowed(context):
91
+ """The boundary is inclusive: a bar *at* the issue moment is legitimate."""
92
+ issued = pd.to_datetime(context["ts"], utc=True).iloc[-1]
93
+ assert check_context(context, issued_ts=issued) is context
94
+
95
+
96
+ def test_adapters_enforce_lookahead_on_every_call(context):
97
+ a = get_adapter("baseline", "baseline/random-walk").load()
98
+ issued = pd.to_datetime(context["ts"], utc=True).iloc[-5]
99
+ with pytest.raises(LookaheadError):
100
+ a.predict(context, horizon=4, seed=0, issued_ts=issued)
101
+
102
+
103
+ def test_backfill_slicing_is_structurally_clean():
104
+ """As-of slicing on a fixture passes the same check the live path uses."""
105
+ bars = synth(300)
106
+ ts = pd.to_datetime(bars["ts"], utc=True)
107
+ for cut in (120, 200, 260):
108
+ issued = ts.iloc[cut]
109
+ as_of = bars.iloc[:cut + 1]
110
+ assert check_context(as_of, issued_ts=issued) is as_of
111
+
112
+
113
+ def test_context_rejects_nans_and_gaps(context):
114
+ bad = context.copy()
115
+ bad.loc[bad.index[10], "close"] = np.nan
116
+ with pytest.raises(ContextError):
117
+ check_context(bad)
118
+
119
+
120
+ # --------------------------------------------------------------------------
121
+ # 3. Frozen at issue
122
+ # --------------------------------------------------------------------------
123
+
124
+
125
+ def _issue(store, seed=7, horizon=6, backfilled=False, model="baseline/random-walk"):
126
+ ctx = synth(200)
127
+ a = get_adapter("baseline", model).load()
128
+ result = a.predict(ctx, horizon=horizon, n_samples=8, seed=seed)
129
+ issued = pd.to_datetime(ctx["ts"], utc=True).iloc[-1]
130
+ targets = future_ts(ctx, horizon)
131
+ fid, written = tr.archive(store, result, "random-walk", "BTC-USD", "1h",
132
+ issued, targets, backfilled=backfilled)
133
+ return fid, written, ctx, targets, issued
134
+
135
+
136
+ def test_resolution_leaves_archived_rows_byte_identical(store):
137
+ *_, issued = _issue(store, horizon=4)
138
+ path = config.signals_path("random-walk", "BTC-USD", "1h", issued.year)
139
+ # The archive lives in the local mirror; read the bytes, not the frame.
140
+ local = store.local_root / path
141
+ before = local.read_bytes()
142
+
143
+ _seed_prices(store, "BTC-USD", "1h")
144
+ resolved = tr.resolve(store, "random-walk", "BTC-USD", "1h",
145
+ now=issued + pd.Timedelta("400d"))
146
+
147
+ # Without this the assertion below would pass vacuously if the resolver
148
+ # silently did nothing at all.
149
+ assert len(resolved) == 4, "nothing was resolved; the test proves nothing"
150
+ assert local.read_bytes() == before, "resolution rewrote the archive"
151
+
152
+
153
+ def test_resolver_is_idempotent(store):
154
+ *_, issued = _issue(store, horizon=4)
155
+ _seed_prices(store, "BTC-USD", "1h")
156
+ later = issued + pd.Timedelta("400d")
157
+
158
+ first = tr.resolve(store, "random-walk", "BTC-USD", "1h", now=later)
159
+ second = tr.resolve(store, "random-walk", "BTC-USD", "1h", now=later)
160
+
161
+ assert len(first) > 0
162
+ assert len(second) == 0, "a second resolve appended rows again"
163
+ assert len(store.get_trackrecord("random-walk", "BTC-USD", "1h")) == len(first)
164
+
165
+
166
+ # --------------------------------------------------------------------------
167
+ # 4. Coverage arithmetic
168
+ # --------------------------------------------------------------------------
169
+
170
+
171
+ def test_empirical_coverage_matches_nominal_on_a_known_distribution():
172
+ """Draw from the band's own distribution; coverage must land on nominal.
173
+
174
+ The forecast is a standard normal band and the outcomes are standard
175
+ normal draws, so 80% of them belong inside q10..q90 by construction. If
176
+ this drifts, the inside-band arithmetic is wrong -- not the model.
177
+ """
178
+ rng = np.random.default_rng(0)
179
+ n = 20000
180
+ realized = rng.normal(0.0, 1.0, n)
181
+ q10, q90 = -1.2815515655446004, 1.2815515655446004 # exact N(0,1) deciles
182
+ q20, q80 = -0.8416212335729143, 0.8416212335729143
183
+
184
+ frame = pd.DataFrame({
185
+ "forecast_id": [f"f{i}" for i in range(n)],
186
+ "model_slug": "synthetic", "asset": "BTC-USD", "timeframe": "1h",
187
+ "issued_ts": pd.Timestamp("2026-01-01", tz="UTC"),
188
+ "target_ts": pd.Timestamp("2026-01-02", tz="UTC"),
189
+ "step": 1, "horizon_bars": 1,
190
+ "realized_close": realized,
191
+ "q10": q10, "q20": q20, "q50": 0.0, "q80": q80, "q90": q90,
192
+ "inside_80": (realized >= q10) & (realized <= q90),
193
+ "inside_60": (realized >= q20) & (realized <= q80),
194
+ "abs_error": np.abs(realized), "pct_error": np.abs(realized),
195
+ "resolved_ts": pd.Timestamp("2026-01-02", tz="UTC"), "backfilled": False,
196
+ })
197
+
198
+ standings = tr.build_standings(frame)
199
+ row = standings.iloc[0]
200
+ # 3 standard errors at n=20000 is about 0.008.
201
+ assert abs(row["coverage_80"] - 0.80) < 0.01
202
+ assert abs(row["coverage_60"] - 0.60) < 0.01
203
+ assert row["grade"] == "A"
204
+
205
+
206
+ @pytest.mark.parametrize("gap,expected", [
207
+ (0.00, "A"), (0.04, "A"), (-0.05, "A"),
208
+ (0.06, "B"), (-0.10, "B"),
209
+ (0.15, "C"), (-0.20, "C"),
210
+ (0.30, "D"), (-0.55, "D"),
211
+ ])
212
+ def test_grade_thresholds_map_correctly(gap, expected):
213
+ assert tr.grade_for(gap, resolved_count=100) == expected
214
+
215
+
216
+ def test_grade_withheld_below_the_minimum():
217
+ """Too little evidence produces no grade, not a flattering one."""
218
+ assert tr.grade_for(0.0, resolved_count=config.MIN_RESOLVED_FOR_GRADE - 1) == "-"
219
+ assert tr.grade_for(0.0, resolved_count=config.MIN_RESOLVED_FOR_GRADE) == "A"
220
+
221
+
222
+ # --------------------------------------------------------------------------
223
+ # 5. Dedup
224
+ # --------------------------------------------------------------------------
225
+
226
+
227
+ def test_identical_forecast_issued_twice_archives_once(store):
228
+ fid1, written1, *_ = _issue(store, seed=7)
229
+ fid2, written2, *_ = _issue(store, seed=7)
230
+
231
+ assert fid1 == fid2
232
+ assert written1 > 0
233
+ assert written2 == 0, "the duplicate was archived a second time"
234
+
235
+ rows = store.get_forecasts("random-walk", "BTC-USD", "1h")
236
+ assert rows["forecast_id"].nunique() == 1
237
+ assert len(rows) == written1
238
+
239
+
240
+ def test_a_different_seed_is_a_different_forecast(store):
241
+ fid1, *_ = _issue(store, seed=7)
242
+ fid2, written2, *_ = _issue(store, seed=8)
243
+ assert fid1 != fid2
244
+ assert written2 > 0
245
+
246
+
247
+ def test_forecast_id_excludes_inference_version():
248
+ """Re-issuing after a model upgrade must not fork the archive."""
249
+ a = forecast_id("m", "BTC-USD", "1h", "2026-01-01T00:00:00+00:00", 3)
250
+ b = forecast_id("m", "BTC-USD", "1h", pd.Timestamp("2026-01-01", tz="UTC"), 3)
251
+ assert a == b
252
+
253
+
254
+ # --------------------------------------------------------------------------
255
+ # 6. Capability gating
256
+ # --------------------------------------------------------------------------
257
+
258
+
259
+ def test_quantile_line_models_declare_no_paths(context):
260
+ a = get_adapter("baseline", "baseline/random-walk").load()
261
+ caps = a.capabilities()
262
+ assert caps.output == "quantile_line"
263
+ assert caps.emits_paths is False
264
+ result = a.predict(context, horizon=6, n_samples=8, seed=0)
265
+ assert result.paths is None
266
+
267
+
268
+ def test_path_models_declare_and_return_paths(context):
269
+ a = get_adapter("baseline", "baseline/bootstrap").load()
270
+ caps = a.capabilities()
271
+ assert caps.output == "ohlcv_paths"
272
+ assert caps.emits_paths is True
273
+ result = a.predict(context, horizon=6, n_samples=8, seed=0)
274
+ assert result.paths is not None
275
+ assert result.paths.shape[:2] == (8, 6)
276
+
277
+
278
+ def test_dispersion_is_defined_for_both_output_kinds(context):
279
+ for model_id in ("baseline/random-walk", "baseline/bootstrap"):
280
+ a = get_adapter("baseline", model_id).load()
281
+ result = a.predict(context, horizon=6, n_samples=8, seed=0)
282
+ d = result.dispersion()
283
+ assert d.shape == (6,)
284
+ assert np.all(np.isfinite(d)) and np.all(d >= 0)
285
+
286
+
287
+ def test_unknown_family_is_refused():
288
+ with pytest.raises(ModelNotAllowed):
289
+ get_adapter("definitely-not-a-family", "someone/somemodel")
290
+
291
+
292
+ # --------------------------------------------------------------------------
293
+ # 8. Standings are a pure function of the track record
294
+ # --------------------------------------------------------------------------
295
+
296
+
297
+ def test_standings_are_deterministic_in_their_input():
298
+ frame = _synthetic_trackrecord()
299
+ now = pd.Timestamp("2026-06-01", tz="UTC")
300
+ first = tr.build_standings(frame, now=now)
301
+ second = tr.build_standings(frame.sample(frac=1.0, random_state=3), now=now)
302
+ pd.testing.assert_frame_equal(first, second)
303
+
304
+
305
+ def test_standings_ignore_everything_but_the_track_record():
306
+ frame = _synthetic_trackrecord()
307
+ now = pd.Timestamp("2026-06-01", tz="UTC")
308
+ baseline = tr.build_standings(frame, now=now)
309
+ # Columns the standings must not read.
310
+ noisy = frame.copy()
311
+ noisy["backfilled"] = ~noisy["backfilled"]
312
+ noisy["resolved_ts"] = pd.Timestamp("2030-01-01", tz="UTC")
313
+ pd.testing.assert_frame_equal(tr.build_standings(noisy, now=now), baseline)
314
+
315
+
316
+ def test_empty_trackrecord_gives_empty_standings():
317
+ out = tr.build_standings(pd.DataFrame())
318
+ assert len(out) == 0
319
+ assert list(out.columns) == list(tr.STANDINGS_COLUMNS)
320
+
321
+
322
+ # --------------------------------------------------------------------------
323
+ # Helpers
324
+ # --------------------------------------------------------------------------
325
+
326
+
327
+ def _seed_prices(store, asset, timeframe):
328
+ """Put a price history into the store covering the fixture's future."""
329
+ bars = synth(600, tf=timeframe)
330
+ ts = pd.to_datetime(bars["ts"], utc=True)
331
+ step = ts.diff().dropna().mode().iloc[0]
332
+ forward = pd.DataFrame({
333
+ "ts": pd.DatetimeIndex([ts.iloc[-1] + step * (i + 1) for i in range(200)]),
334
+ "open": 50000.0, "high": 50100.0, "low": 49900.0,
335
+ "close": 50000.0, "volume": 1000.0,
336
+ })
337
+ full = pd.concat([bars, forward], ignore_index=True)
338
+ for year, chunk in full.groupby(full["ts"].dt.year):
339
+ store.write_parquet(config.prices_path(asset, timeframe, int(year)), chunk)
340
+
341
+
342
+ def _synthetic_trackrecord(n=200):
343
+ rng = np.random.default_rng(11)
344
+ realized = rng.normal(100.0, 5.0, n)
345
+ return pd.DataFrame({
346
+ "forecast_id": [f"f{i // 4}" for i in range(n)],
347
+ "model_slug": ["alpha" if i % 2 else "beta" for i in range(n)],
348
+ "asset": ["BTC-USD" if i % 3 else "SPY" for i in range(n)],
349
+ "timeframe": "1h",
350
+ "issued_ts": pd.Timestamp("2026-01-01", tz="UTC"),
351
+ "target_ts": pd.Timestamp("2026-01-02", tz="UTC"),
352
+ "step": [(i % 4) + 1 for i in range(n)],
353
+ "horizon_bars": 4,
354
+ "realized_close": realized,
355
+ "q10": 92.0, "q20": 95.0, "q50": 100.0, "q80": 105.0, "q90": 108.0,
356
+ "inside_80": (realized >= 92.0) & (realized <= 108.0),
357
+ "inside_60": (realized >= 95.0) & (realized <= 105.0),
358
+ "abs_error": np.abs(realized - 100.0),
359
+ "pct_error": np.abs(realized - 100.0) / realized * 100,
360
+ "resolved_ts": pd.Timestamp("2026-01-02", tz="UTC"),
361
+ "backfilled": [bool(i % 5 == 0) for i in range(n)],
362
+ })
363
+
364
+
365
+ # --------------------------------------------------------------------------
366
+ # Context validation must not reject a market's own trading calendar
367
+ # --------------------------------------------------------------------------
368
+
369
+
370
+ def _equity_hours(days: int = 90) -> pd.DataFrame:
371
+ """Hourly bars that trade 09:30-16:00 on weekdays and close otherwise.
372
+
373
+ Regression fixture. Judging gaps against the modal bar rejected every SPY
374
+ and NVDA hourly context outright -- 73 "gaps" in 512 bars that were simply
375
+ overnight and weekend closures. Nobody could forecast an equity intraday
376
+ series at all.
377
+ """
378
+ stamps = []
379
+ day = pd.Timestamp("2026-01-05", tz="UTC") # a Monday
380
+ for _ in range(days):
381
+ if day.weekday() < 5:
382
+ stamps += [day + pd.Timedelta(hours=h) for h in range(14, 21)]
383
+ day += pd.Timedelta(days=1)
384
+ ts = pd.DatetimeIndex(stamps)
385
+ rng = np.random.default_rng(5)
386
+ close = 600 * np.exp(np.cumsum(rng.normal(0, 0.004, len(ts))))
387
+ openp = np.concatenate([[600.0], close[:-1]])
388
+ return pd.DataFrame({
389
+ "ts": ts, "open": openp,
390
+ "high": np.maximum(openp, close) * 1.001,
391
+ "low": np.minimum(openp, close) * 0.999,
392
+ "close": close, "volume": 1000.0,
393
+ })
394
+
395
+
396
+ def test_equity_session_gaps_are_not_treated_as_missing_data():
397
+ bars = _equity_hours()
398
+ assert len(bars) > 400
399
+ assert check_context(bars) is bars
400
+
401
+
402
+ def test_a_real_hole_in_the_cache_is_still_caught():
403
+ """The session-aware threshold must not have disabled the check.
404
+
405
+ A missing month in an hourly series is ~720 hours against a worst regular
406
+ gap of ~65 (a weekend), so it is caught by the hard ceiling even though it
407
+ is a single gap.
408
+ """
409
+ bars = _equity_hours()
410
+ ts = pd.to_datetime(bars["ts"], utc=True)
411
+ start = ts.iloc[200]
412
+ holed = bars[(ts < start) | (ts > start + pd.Timedelta(days=30))]
413
+
414
+ with pytest.raises(ContextError, match="cache is incomplete"):
415
+ check_context(holed.reset_index(drop=True))
416
+
417
+
418
+ def test_a_crypto_series_still_rejects_a_modest_hole():
419
+ """24/7 data has no sessions, so its tolerance stays tight."""
420
+ bars = synth(400)
421
+ ts = pd.to_datetime(bars["ts"], utc=True)
422
+ start = ts.iloc[100]
423
+ holed = bars[(ts < start) | (ts > start + pd.Timedelta(hours=60))]
424
+
425
+ with pytest.raises(ContextError):
426
+ check_context(holed.reset_index(drop=True))
tests/test_app.py ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The app's state machine: actions fold correctly, and nothing else moves.
2
+
3
+ `apply_action` is the only way state changes, so these tests are the contract
4
+ for every control on the page. They run against the real module with the store
5
+ pointed at a temp directory, so no network and no credentials are involved.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import importlib
11
+ import os
12
+
13
+ import pytest
14
+
15
+
16
+ @pytest.fixture(scope="module")
17
+ def app(tmp_path_factory):
18
+ """The app module, booted against an empty local store."""
19
+ os.environ["ARENA_OFFLINE"] = "1"
20
+ os.environ["ARENA_LOCAL_STORE"] = str(tmp_path_factory.mktemp("store"))
21
+ os.environ["ARENA_LOCAL_NAV"] = ""
22
+ import app as app_module
23
+
24
+ app_module = importlib.reload(app_module)
25
+
26
+ # Enroll the dependency-free baselines so the selection tests exercise real
27
+ # registry entries rather than skipping. They need no weights or network.
28
+ from src import runtime as rt
29
+
30
+ for model_id in ("baseline/random-walk", "baseline/drift", "baseline/bootstrap"):
31
+ rt.enroll(app_module.STORE, "baseline", model_id, enrolled_by="tests",
32
+ registry=app_module.BOOT["registry"])
33
+ app_module.BOOT["registry"] = app_module.STORE.get_registry()
34
+ return app_module
35
+
36
+
37
+ def _state(app, **over):
38
+ state = app.default_state()
39
+ state.update(over)
40
+ return state
41
+
42
+
43
+ # --------------------------------------------------------------------------
44
+ # Action folding
45
+ # --------------------------------------------------------------------------
46
+
47
+
48
+ def test_an_unknown_action_changes_nothing(app):
49
+ before = _state(app)
50
+ after = app.apply_action(before, "not-an-action:whatever|n1")
51
+ assert after == before
52
+
53
+
54
+ def test_a_malformed_payload_changes_nothing(app):
55
+ before = _state(app)
56
+ for raw in ("", "no-colon", "|", None):
57
+ assert app.apply_action(before, raw) == before
58
+
59
+
60
+ def test_asset_and_timeframe_are_validated(app):
61
+ state = _state(app)
62
+ assert app.apply_action(state, "asset:BTC-USD|n1")["asset"] == "BTC-USD"
63
+ # Off the known list: ignored, not stored.
64
+ assert app.apply_action(state, "asset:DOGE-USD|n2")["asset"] == state["asset"]
65
+ assert app.apply_action(state, "tf:1d|n3")["tf"] == "1d"
66
+ assert app.apply_action(state, "tf:7y|n4")["tf"] == state["tf"]
67
+
68
+
69
+ def test_changing_the_timeframe_resets_the_horizon_to_its_default(app):
70
+ state = app.apply_action(_state(app), "hz:96|n1")
71
+ assert state["horizon"] == 96
72
+ state = app.apply_action(state, "tf:1d|n2")
73
+ assert state["horizon"] == app.config.DEFAULT_HORIZON["1d"]
74
+
75
+
76
+ def test_an_out_of_range_horizon_is_rejected(app):
77
+ state = _state(app, tf="1d")
78
+ assert app.apply_action(state, "hz:100000|n1")["horizon"] == state["horizon"]
79
+ assert app.apply_action(state, "hz:-4|n2")["horizon"] == state["horizon"]
80
+ assert app.apply_action(state, "hz:notanumber|n3")["horizon"] == state["horizon"]
81
+
82
+
83
+ def test_the_same_nonce_twice_folds_once(app):
84
+ """Two delivery paths mean one click can arrive twice."""
85
+ state = _state(app, gear=False)
86
+ once = app.apply_action(state, "gear:toggle|abc")
87
+ twice = app.apply_action(once, "gear:toggle|abc")
88
+ assert once["gear"] is True
89
+ assert twice["gear"] is True, "the duplicate delivery toggled it back"
90
+
91
+
92
+ def test_playground_holds_one_model_and_matchup_holds_up_to_three(app):
93
+ slugs = sorted(app.BOOT["registry"].get("models", {}))
94
+ if len(slugs) < 2:
95
+ pytest.skip("needs at least two enrolled models")
96
+
97
+ state = _state(app, mode="matchup", selected=[slugs[0]])
98
+ for slug in slugs[1:5]:
99
+ state = app.apply_action(state, f"pick:{slug}|n{slug}")
100
+ assert len(state["selected"]) <= app.view.MAX_MATCHUP
101
+
102
+ state = app.apply_action(state, "mode:playground|nz")
103
+ assert len(state["selected"]) == 1
104
+
105
+
106
+ def test_deselecting_the_last_model_is_refused(app):
107
+ slugs = sorted(app.BOOT["registry"].get("models", {}))
108
+ if not slugs:
109
+ pytest.skip("needs an enrolled model")
110
+ state = _state(app, mode="matchup", selected=[slugs[0]])
111
+ state = app.apply_action(state, f"pick:{slugs[0]}|n1")
112
+ assert state["selected"] == [slugs[0]], "left the UI with no model selected"
113
+
114
+
115
+ def test_picking_an_unenrolled_model_is_ignored(app):
116
+ state = _state(app)
117
+ after = app.apply_action(state, "pick:some-model-nobody-enrolled|n1")
118
+ assert after["selected"] == state["selected"]
119
+
120
+
121
+ def test_standings_class_filter_is_validated(app):
122
+ state = _state(app)
123
+ assert app.apply_action(state, "cls:Crypto|n1")["cls"] == "Crypto"
124
+ assert app.apply_action(state, "cls:Nonsense|n2")["cls"] == state["cls"]
125
+
126
+
127
+ def test_the_enrollment_box_is_length_capped(app):
128
+ state = app.apply_action(_state(app), "hfid:" + "x" * 5000 + "|n1")
129
+ assert len(state["hfid"]) <= 120
130
+
131
+
132
+ # --------------------------------------------------------------------------
133
+ # Rendering
134
+ # --------------------------------------------------------------------------
135
+
136
+
137
+ def test_the_page_renders_for_every_mode(app):
138
+ for mode in ("playground", "matchup"):
139
+ html = app.render(_state(app, mode=mode))
140
+ assert "Forecast Arena" in html
141
+ assert len(html) > 5000
142
+
143
+
144
+ def test_the_page_renders_with_no_models_enrolled(app, monkeypatch):
145
+ """An empty registry is the first-boot state, not an error."""
146
+ monkeypatch.setitem(app.BOOT, "registry", {"models": {}})
147
+ html = app.render(_state(app, selected=[]))
148
+ assert "Forecast Arena" in html
149
+
150
+
151
+ def test_the_disclaimer_is_always_on_the_page(app):
152
+ """A forecast must never be presented as advice, on any state of the page."""
153
+ html = app.render(_state(app)).lower()
154
+ assert "nothing here is financial advice" in html
155
+ # Twice: once beside the method note, once in the footer.
156
+ assert html.count("financial advice") >= 2
157
+
158
+
159
+ def test_the_methodology_promises_are_on_the_page(app):
160
+ html = app.render(_state(app))
161
+ for promise in ("frozen at issue", "backfilled entries", "resolved forecasts only"):
162
+ assert promise in html.lower()
163
+
164
+
165
+ def test_on_action_returns_html_and_state(app):
166
+ html, state = app.on_action("cls:Crypto|n1", app.default_state())
167
+ assert isinstance(html, str) and len(html) > 1000
168
+ assert state["cls"] == "Crypto"
tests/test_kronos_vendor.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The vendored Kronos source is what was reviewed, and behaves like upstream.
2
+
3
+ `vendor/kronos` is third-party source carrying two local patches. Two things
4
+ can go wrong with that arrangement and both are silent:
5
+
6
+ * someone re-vendors from a newer upstream and the patches quietly vanish, or
7
+ arrive alongside changes nobody read;
8
+ * the normalisation `adapters/kronos.py` replicates drifts away from the
9
+ normalisation `KronosPredictor.predict` actually applies, so archived Kronos
10
+ forecasts stop matching what the model would really have said.
11
+
12
+ The hash test catches the first. The equivalence test catches the second, by
13
+ running both paths and requiring the mean of the adapter's sampled paths to
14
+ equal upstream's averaged output for the same seed.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import hashlib
20
+ from pathlib import Path
21
+
22
+ import numpy as np
23
+ import pytest
24
+
25
+ VENDOR = Path(__file__).resolve().parents[1] / "vendor" / "kronos"
26
+
27
+ # Update these deliberately, and only after reading the diff.
28
+ REVIEWED = {
29
+ "__init__.py": "f8f856ca3fedadcaac97e196be23d1aeda1c3c9ffe8903d66d43ea3bcac6240c",
30
+ "module.py": "a07edbadc0e96804c8158c021bbc6063bb7cc43b34d7fc470d5c8ff2005a409f",
31
+ }
32
+
33
+
34
+ @pytest.mark.parametrize("name,expected", sorted(REVIEWED.items()))
35
+ def test_vendored_files_are_the_reviewed_content(name, expected):
36
+ digest = hashlib.sha256((VENDOR / name).read_bytes()).hexdigest()
37
+ assert digest == expected, (
38
+ f"vendor/kronos/{name} changed. Read the diff, then update REVIEWED."
39
+ )
40
+
41
+
42
+ def test_the_two_local_patches_are_present():
43
+ source = (VENDOR / "kronos.py").read_text()
44
+ assert "from .module import *" in source, "relative-import patch is missing"
45
+ assert "return_paths" in source, "path-returning patch is missing"
46
+ assert "from model.module import *" not in source
47
+
48
+
49
+ @pytest.mark.slow
50
+ def test_adapter_normalisation_matches_upstream_predictor():
51
+ """The adapter's replicated normalisation reproduces upstream's answer."""
52
+ import sys
53
+
54
+ import pandas as pd
55
+ import torch
56
+
57
+ sys.path.insert(0, str(VENDOR.parent))
58
+ from kronos import Kronos, KronosPredictor, KronosTokenizer
59
+
60
+ from src.adapters import get_adapter
61
+ from tests.fixture import synth
62
+
63
+ ctx = synth(300)
64
+ horizon, n_samples, seed = 6, 4, 4321
65
+
66
+ adapter = get_adapter("kronos", "NeoQuasar/Kronos-mini").load()
67
+ mine = adapter.predict(ctx, horizon=horizon, n_samples=n_samples, seed=seed)
68
+
69
+ predictor = KronosPredictor(adapter._model, adapter._tokenizer,
70
+ device=adapter.device, max_context=2048)
71
+ frame = ctx[["open", "high", "low", "close", "volume"]].copy()
72
+ frame["amount"] = frame["volume"] * frame[["open", "high", "low", "close"]].mean(axis=1)
73
+ ts = pd.to_datetime(ctx["ts"], utc=True)
74
+ step = ts.diff().dropna().mode().iloc[0]
75
+ future = pd.Series([ts.iloc[-1] + step * (i + 1) for i in range(horizon)])
76
+
77
+ torch.manual_seed(seed % (2 ** 31 - 1))
78
+ np.random.seed(seed % (2 ** 31 - 1))
79
+ theirs = predictor.predict(df=frame, x_timestamp=ts, y_timestamp=future,
80
+ pred_len=horizon, T=1.0, top_p=0.9, top_k=0,
81
+ sample_count=n_samples, verbose=False)
82
+
83
+ # Upstream returns the mean over samples; the adapter keeps them, so the
84
+ # comparison is against the mean of what it kept.
85
+ mine_mean_close = mine.paths[:, :, 3].mean(axis=0)
86
+ np.testing.assert_allclose(mine_mean_close,
87
+ theirs["close"].to_numpy(dtype="float64"),
88
+ rtol=1e-6, atol=1e-6)
tests/test_runtime.py ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Enrollment, the no-lookahead guarantee at the runtime layer, and tiering.
2
+
3
+ These cover the parts of the acceptance list that live between the adapters and
4
+ the UI: that enrollment is idempotent and refuses anything it cannot load
5
+ safely, that `load_context` cannot hand a model a bar from the future, and that
6
+ a CPU-tier model either meets its budget or is recorded as demoted.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import numpy as np
12
+ import pandas as pd
13
+ import pytest
14
+
15
+ from src import config, runtime
16
+ from src.adapters import LookaheadError
17
+ from tests.fixture import synth
18
+
19
+
20
+ @pytest.fixture
21
+ def seeded_store(store):
22
+ """A store with prices and one enrolled baseline."""
23
+ bars = synth(600)
24
+ for year, chunk in bars.groupby(bars["ts"].dt.year):
25
+ store.write_parquet(config.prices_path("BTC-USD", "1h", int(year)), chunk)
26
+ outcome = runtime.enroll(store, "baseline", "baseline/random-walk",
27
+ enrolled_by="tests")
28
+ assert outcome.ok, outcome.message
29
+ return store
30
+
31
+
32
+ # --------------------------------------------------------------------------
33
+ # 5. Enrollment is idempotent, and refuses what it cannot vouch for
34
+ # --------------------------------------------------------------------------
35
+
36
+
37
+ def test_enrolling_the_same_revision_twice_is_a_no_op(store):
38
+ first = runtime.enroll(store, "baseline", "baseline/drift", enrolled_by="a")
39
+ registry = store.get_registry()
40
+ second = runtime.enroll(store, "baseline", "baseline/drift", enrolled_by="b",
41
+ registry=registry)
42
+
43
+ assert first.ok and second.ok
44
+ assert second.already is True
45
+ assert len(registry["models"]) == 1
46
+ # The second attempt must not have rewritten who enrolled it.
47
+ assert registry["models"]["drift"]["enrolled_by"] == "a"
48
+
49
+
50
+ def test_unsupported_family_is_refused_with_a_clear_message(store):
51
+ outcome = runtime.enroll(store, "llama", "meta-llama/Llama-3-8B")
52
+ assert not outcome.ok
53
+ assert "not a supported adapter family" in outcome.message
54
+ assert "baseline" in outcome.message # names what *is* supported
55
+
56
+
57
+ def test_a_malformed_model_id_is_refused(store):
58
+ for bad in ("not-a-model-id", "../../etc/passwd", "a/b/c", ""):
59
+ outcome = runtime.enroll(store, "baseline", bad)
60
+ assert not outcome.ok, f"{bad!r} was accepted"
61
+
62
+
63
+ def test_enrolling_under_the_wrong_family_is_refused(store):
64
+ """A known Chronos model must not be loaded through the Kronos loader."""
65
+ outcome = runtime.enroll(store, "kronos", "amazon/chronos-bolt-tiny")
66
+ assert not outcome.ok
67
+ assert "chronos" in outcome.message
68
+
69
+
70
+ def test_enrollment_records_what_it_verified(store):
71
+ runtime.enroll(store, "baseline", "baseline/bootstrap", enrolled_by="tests")
72
+ entry = store.get_registry()["models"]["bootstrap"]
73
+
74
+ assert entry["revision"] # pinned
75
+ assert entry["smoke_test"]["ok"] is True # actually ran
76
+ assert entry["capabilities"]["output"] == "ohlcv_paths"
77
+ assert entry["enrolled_by"] == "tests"
78
+ assert entry["inference_version"]
79
+
80
+
81
+ # --------------------------------------------------------------------------
82
+ # 2. No lookahead, at the runtime layer
83
+ # --------------------------------------------------------------------------
84
+
85
+
86
+ def test_load_context_never_returns_a_bar_after_the_cut(seeded_store):
87
+ prices = seeded_store.get_prices("BTC-USD", "1h")
88
+ cut = pd.to_datetime(prices["ts"], utc=True).iloc[300]
89
+
90
+ context = runtime.load_context(seeded_store, "BTC-USD", "1h", as_of=cut)
91
+ assert pd.to_datetime(context["ts"], utc=True).max() <= cut
92
+
93
+
94
+ def test_an_as_of_forecast_is_issued_from_the_cut_not_the_clock(seeded_store):
95
+ prices = seeded_store.get_prices("BTC-USD", "1h")
96
+ cut = pd.to_datetime(prices["ts"], utc=True).iloc[400]
97
+
98
+ run = runtime.run_forecast(seeded_store, "random-walk", "BTC-USD", "1h",
99
+ horizon=6, seed=1, as_of=cut, archive=False)
100
+ assert run.issued_ts <= cut
101
+ assert run.target_ts[0] > cut
102
+
103
+
104
+ def test_a_context_reaching_past_the_issue_moment_raises(seeded_store):
105
+ """The adapter re-checks, so a caller cannot smuggle one past it."""
106
+ from src.adapters import get_adapter
107
+
108
+ context = runtime.load_context(seeded_store, "BTC-USD", "1h")
109
+ stale = pd.to_datetime(context["ts"], utc=True).iloc[-10]
110
+ adapter = get_adapter("baseline", "baseline/random-walk").load()
111
+
112
+ with pytest.raises(LookaheadError):
113
+ adapter.predict(context, horizon=4, seed=0, issued_ts=stale)
114
+
115
+
116
+ def test_forecast_targets_start_after_the_context_ends(seeded_store):
117
+ run = runtime.run_forecast(seeded_store, "random-walk", "BTC-USD", "1h",
118
+ horizon=8, seed=3, archive=False)
119
+ assert run.target_ts[0] > run.issued_ts
120
+ assert len(run.target_ts) == 8
121
+
122
+
123
+ # --------------------------------------------------------------------------
124
+ # 7. Tiering
125
+ # --------------------------------------------------------------------------
126
+
127
+
128
+ def test_a_cpu_tier_model_either_meets_the_budget_or_is_recorded_as_demoted(store):
129
+ """The rule the bootstrap script applies, checked directly.
130
+
131
+ The registry on the Hub is written by a measurement run; this asserts the
132
+ rule that run enforces, so a change to the thresholds cannot silently let a
133
+ model sit in the CPU tier while missing its budget.
134
+ """
135
+ from scripts.bootstrap_registry import measure
136
+
137
+ context = synth(config.DEFAULT_CONTEXT_BARS)
138
+ latency = measure("baseline", "baseline/random-walk", context)
139
+
140
+ assert latency["cold_s"] <= config.CPU_BUDGET_COLD_S
141
+ assert latency["warm_s"] <= config.CPU_BUDGET_WARM_S
142
+ assert latency["hardware"] == "cpu"
143
+ assert latency["demoted"] is False
144
+ # The measurement is meaningless without the machine it was taken on.
145
+ assert latency["machine"]
146
+
147
+
148
+ def test_the_demotion_rule_fires_when_a_budget_is_missed(monkeypatch):
149
+ """A CPU-declared model that is too slow must come back as gpu."""
150
+ from scripts import bootstrap_registry
151
+
152
+ context = synth(config.DEFAULT_CONTEXT_BARS)
153
+ monkeypatch.setattr(config, "CPU_BUDGET_WARM_S", 0.0)
154
+ latency = bootstrap_registry.measure("baseline", "baseline/drift", context)
155
+
156
+ assert latency["declared_hardware"] == "cpu"
157
+ assert latency["hardware"] == "gpu"
158
+ assert latency["demoted"] is True
159
+
160
+
161
+ # --------------------------------------------------------------------------
162
+ # Anonymous session cap
163
+ # --------------------------------------------------------------------------
164
+
165
+
166
+ def test_the_session_cap_is_a_real_number():
167
+ assert config.ANON_SESSION_CAP > 0
tests/test_ui.py ADDED
@@ -0,0 +1,360 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The rendering layer: the design ports faithfully, and stays capability-gated.
2
+
3
+ Two classes of test here.
4
+
5
+ *Fidelity*: every stylesheet the design system ships actually reaches the page,
6
+ and nothing forces height from `vh` (which ratchets the Spaces iframe). These
7
+ catch the failure where the app looks approximately right in a way that is hard
8
+ to name.
9
+
10
+ *Gating*: the UI decides what to draw from an adapter's declared capabilities
11
+ and never from a model's name. The fixture adapters below exist so that claim
12
+ can be tested without loading a single weight.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+
19
+ import numpy as np
20
+ import pandas as pd
21
+ import pytest
22
+
23
+ from src import config, view
24
+ from src.adapters.base import (OUTPUT_OHLCV_PATHS, OUTPUT_QUANTILE_LINE,
25
+ Capabilities, ForecastAdapter, ForecastResult)
26
+ from src.ui import chart, chrome, shell
27
+ from tests.fixture import future_ts, synth
28
+
29
+ from bit_ui import theme
30
+
31
+
32
+ # --------------------------------------------------------------------------
33
+ # Fixture adapters -- declared capabilities, no weights
34
+ # --------------------------------------------------------------------------
35
+
36
+
37
+ class _Fixture(ForecastAdapter):
38
+ """An adapter that declares whatever the test needs it to."""
39
+
40
+ family = "fixture"
41
+
42
+ def __init__(self, output=OUTPUT_QUANTILE_LINE, hardware="cpu"):
43
+ super().__init__("fixture/model", revision="test")
44
+ self._output, self._hardware = output, hardware
45
+
46
+ def capabilities(self):
47
+ return Capabilities(output=self._output, hardware=self._hardware,
48
+ max_context=256)
49
+
50
+ def load(self, model_id=None, revision=None):
51
+ self._model = True
52
+ self._resolved_revision = "test"
53
+ return self
54
+
55
+ def predict(self, context_ohlcv, horizon=6, n_samples=8, seed=0,
56
+ issued_ts=None):
57
+ last = float(context_ohlcv["close"].iloc[-1])
58
+ q = np.tile(np.array([0.97, 0.99, 1.0, 1.01, 1.03]) * last, (horizon, 1))
59
+ paths = None
60
+ if self._output == OUTPUT_OHLCV_PATHS:
61
+ paths = np.tile(last, (n_samples, horizon, 5)).astype("float64")
62
+ return ForecastResult(
63
+ quantiles=q, levels=config.QUANTILE_LEVELS, horizon=horizon,
64
+ context_len=len(context_ohlcv), inference_version="fixture",
65
+ seed=seed, n_samples=n_samples, paths=paths)
66
+
67
+
68
+ class _Run:
69
+ """The shape `shell` expects from `runtime.ForecastRun`."""
70
+
71
+ def __init__(self, adapter, context, horizon=6, slug="fixture"):
72
+ self.model_slug = slug
73
+ self.model_id = "fixture/model"
74
+ self.family = "fixture"
75
+ self.asset, self.timeframe, self.horizon = "BTC-USD", "1h", horizon
76
+ self.context = context
77
+ self.result = adapter.predict(context, horizon=horizon)
78
+ self.issued_ts = pd.to_datetime(context["ts"], utc=True).iloc[-1]
79
+ self.target_ts = future_ts(context, horizon)
80
+ self.capabilities = adapter.capabilities().as_dict()
81
+ self.forecast_id, self.archived_rows, self.elapsed_s = "fid", horizon, 0.1
82
+
83
+
84
+ def _data(runs, state, **over):
85
+ models = [{
86
+ "slug": r.model_slug, "name": r.model_slug, "family": "fixture",
87
+ "size": "1M", "caps": r.capabilities, "color": "var(--accent-amber-strong)",
88
+ "grade": "-", "grade_color": "var(--text-tertiary)",
89
+ "resolved_label": "UNRESOLVED",
90
+ } for r in runs] or [{
91
+ "slug": "fixture", "name": "fixture", "family": "fixture", "size": "1M",
92
+ "caps": {"output": OUTPUT_QUANTILE_LINE, "hardware": "cpu"},
93
+ "color": "var(--accent-amber-strong)", "grade": "-",
94
+ "grade_color": "var(--text-tertiary)", "resolved_label": "UNRESOLVED"}]
95
+ data = {
96
+ "models": models,
97
+ "models_by_slug": {m["slug"]: m for m in models},
98
+ "panels": {r.model_slug: {"empty": True} for r in runs},
99
+ "runs": runs,
100
+ "chart": (chart.build(runs[0].context, runs, "BTC-USD", runs[0].horizon)
101
+ if runs else {}),
102
+ "volatility": {"rows": [], "verdict": "", "note": ""},
103
+ "stats": [], "standings_rows": [], "families": ("kronos",),
104
+ "coverage": {"scope": "", "pct_label": "—", "pct_width": "0%", "rows": []},
105
+ "nav": {}, "archived_total": 0, "resolved_total": 0,
106
+ "resolver_last_ran": "NEVER", "runtime_hint": "~1S",
107
+ "quota_blocked": False, "footer_resources": (), "status_line": "",
108
+ "error": None,
109
+ }
110
+ data.update(over)
111
+ return data
112
+
113
+
114
+ @pytest.fixture
115
+ def context():
116
+ return synth(300)
117
+
118
+
119
+ def _state(**over):
120
+ state = view.default_state()
121
+ state["selected"] = ["fixture"]
122
+ state.update(over)
123
+ return state
124
+
125
+
126
+ # --------------------------------------------------------------------------
127
+ # 6. Capability gating (fixture adapter)
128
+ # --------------------------------------------------------------------------
129
+
130
+
131
+ def test_quantile_model_never_reaches_the_ghost_path_code(context):
132
+ adapter = _Fixture(output=OUTPUT_QUANTILE_LINE).load()
133
+ run = _Run(adapter, context)
134
+ html = shell.chart_section(_state(), _data([run], _state()))
135
+
136
+ assert "Quantile line only" in html
137
+ assert "Sampled OHLCV paths" not in html
138
+ # The ghost renderer's own opacity value must appear nowhere.
139
+ assert 'opacity="0.17"' not in html
140
+
141
+
142
+ def test_path_model_gets_the_ghost_control_and_the_ghosts(context):
143
+ adapter = _Fixture(output=OUTPUT_OHLCV_PATHS).load()
144
+ run = _Run(adapter, context)
145
+ html = shell.chart_section(_state(ghosts=True), _data([run], _state()))
146
+
147
+ assert "Sampled OHLCV paths" in html
148
+ assert "Quantile line only" not in html
149
+
150
+
151
+ def test_ghosts_are_suppressed_when_the_toggle_is_off(context):
152
+ adapter = _Fixture(output=OUTPUT_OHLCV_PATHS).load()
153
+ run = _Run(adapter, context)
154
+ drawing = chart.build(context, [run], "BTC-USD", run.horizon, show_ghosts=False)
155
+ assert 'opacity="0.17"' not in drawing["svg"]
156
+
157
+ drawing_on = chart.build(context, [run], "BTC-USD", run.horizon, show_ghosts=True)
158
+ assert 'opacity="0.17"' in drawing_on["svg"]
159
+
160
+
161
+ def test_chart_ignores_paths_when_the_capability_does_not_declare_them(context):
162
+ """A result carrying paths but declaring `quantile_line` draws no ghosts.
163
+
164
+ This is the gate that matters: rendering keys off the declaration, not off
165
+ whether the array happens to be populated.
166
+ """
167
+ adapter = _Fixture(output=OUTPUT_OHLCV_PATHS).load()
168
+ run = _Run(adapter, context)
169
+ assert run.result.paths is not None
170
+ run.capabilities = {"output": OUTPUT_QUANTILE_LINE, "hardware": "cpu"}
171
+
172
+ drawing = chart.build(context, [run], "BTC-USD", run.horizon, show_ghosts=True)
173
+ assert 'opacity="0.17"' not in drawing["svg"]
174
+
175
+
176
+ def test_hardware_capability_drives_the_cpu_gpu_chip(context):
177
+ for hardware, expected in (("cpu", "CPU · instant"), ("gpu", "your GPU quota")):
178
+ adapter = _Fixture(hardware=hardware).load()
179
+ run = _Run(adapter, context)
180
+ html = shell.models_section(_state(), _data([run], _state()))
181
+ assert expected in html
182
+
183
+
184
+ def test_no_model_family_is_named_in_the_rendering_layer():
185
+ """The UI must not special-case a model by name.
186
+
187
+ Capability flags are the whole interface between adapters and rendering;
188
+ a name check here is how that boundary rots.
189
+ """
190
+ import pathlib
191
+
192
+ for name in ("shell.py", "chart.py"):
193
+ source = (pathlib.Path(__file__).resolve().parents[1]
194
+ / "src" / "ui" / name).read_text().lower()
195
+ # Prose in docstrings is fine; a comparison against a model name is not.
196
+ for family in ("kronos", "chronos", "timesfm"):
197
+ assert f'== "{family}' not in source
198
+ assert f"'{family}'" not in source.replace("'kronos, chronos or timesfm'", "")
199
+
200
+
201
+ # --------------------------------------------------------------------------
202
+ # Design fidelity
203
+ # --------------------------------------------------------------------------
204
+
205
+
206
+ def test_every_design_system_stylesheet_reaches_the_page():
207
+ """Keyed on a rule only that file contains.
208
+
209
+ Vendoring the token files and forgetting `base.css` is the classic version
210
+ of this bug: the app inherits the framework's rounded corners and roomy
211
+ padding and looks approximately right in a way nobody can name.
212
+ """
213
+ css = chrome.full_css()
214
+ markers = {
215
+ "colors.css": "--bg-canvas",
216
+ "typography.css": "--text-2xs",
217
+ "spacing.css": "--tracking-wider",
218
+ "base.css": "user-select",
219
+ }
220
+ for filename, marker in markers.items():
221
+ assert marker in css, f"{filename} did not reach the page ({marker} missing)"
222
+
223
+
224
+ def test_arena_css_reaches_the_page_too():
225
+ """`theme.full_css()` alone would silently drop the Arena's own rules."""
226
+ css = chrome.full_css()
227
+ for marker in (".fa-rail", ".fa-picker", ".fa-card", ".fa-chart"):
228
+ assert marker in css
229
+
230
+
231
+ def test_nothing_forces_height_from_vh(context):
232
+ """`height:100vh` inside the Spaces iframe ratchets the page height."""
233
+ adapter = _Fixture(output=OUTPUT_OHLCV_PATHS).load()
234
+ run = _Run(adapter, context)
235
+ state = _state()
236
+ html = shell.page(state, _data([run], state))
237
+ offenders = theme.find_forced_vh(html)
238
+ assert not offenders, f"forced vh heights: {offenders}"
239
+
240
+
241
+ def test_chart_uses_the_designs_geometry(context):
242
+ adapter = _Fixture().load()
243
+ run = _Run(adapter, context)
244
+ svg = chart.build(context, [run], "BTC-USD", run.horizon)["svg"]
245
+ assert 'viewBox="0 0 1200 352"' in svg
246
+
247
+
248
+ def test_interpolated_values_are_escaped(context):
249
+ """Model ids and user text reach the markup; they must not become markup."""
250
+ state = _state(hfid='"><script>alert(1)</script>')
251
+ html = shell._enroll(state, _data([], state))
252
+ assert "<script>alert(1)" not in html
253
+ assert "&lt;script&gt;" in html or "&quot;&gt;" in html
254
+
255
+
256
+ # --------------------------------------------------------------------------
257
+ # 9. Error states
258
+ # --------------------------------------------------------------------------
259
+
260
+
261
+ @pytest.mark.parametrize("kind,needle", [
262
+ ("quota", "SIGN IN"),
263
+ ("load_failure", "DUPLICATE THIS SPACE"),
264
+ ("no_data", "∅"),
265
+ ])
266
+ def test_designed_error_states_render(kind, needle):
267
+ state = _state()
268
+ err = {"kind": kind, "title": "Something went wrong",
269
+ "message": "the detail", "login_url": "#", "duplicate_url": "#"}
270
+ html = shell.chart_section(state, _data([], state, error=err))
271
+ assert needle in html
272
+ assert "the detail" in html
273
+ assert "DISMISS" in html
274
+
275
+
276
+ def test_quota_banner_renders_when_blocked():
277
+ state = _state()
278
+ html = shell.controls(state, _data([], state, quota_blocked=True,
279
+ quota_msg="GPU-tier model — sign in",
280
+ runtime_hint="BLOCKED"))
281
+ assert "GPU-tier model" in html
282
+ assert "BLOCKED" in html
283
+
284
+
285
+ def test_rejected_enrollment_renders_its_message():
286
+ state = _state(enroll_ok=False,
287
+ enroll_note="'nope' is not a supported adapter family.")
288
+ html = shell._enroll(state, _data([], state))
289
+ assert "not a supported adapter family" in html
290
+ assert "var(--fin-down)" in html
291
+
292
+
293
+ def test_empty_track_record_renders_the_designed_state():
294
+ state = _state()
295
+ html = shell.trackrecord_section(state, _data([], state))
296
+ assert "No history yet" in html
297
+ assert "MAKE THE FIRST FORECAST" in html
298
+
299
+
300
+ # --------------------------------------------------------------------------
301
+ # Bridge
302
+ # --------------------------------------------------------------------------
303
+
304
+
305
+ def test_actions_off_the_allow_list_are_dropped():
306
+ assert chrome.parse_action("definitely-not-an-action:1|n") is None
307
+ assert chrome.parse_action("run:go|n1") is not None
308
+
309
+
310
+ def test_emit_refuses_an_action_it_does_not_know():
311
+ with pytest.raises(Exception):
312
+ chrome.emit("not-a-real-action", "x")
313
+
314
+
315
+ def test_sidebar_collapse_is_not_a_server_action():
316
+ """Routing it through Gradio would re-render the whole page to move a rail."""
317
+ assert "sidebar" not in chrome.ALLOWED_KEYS
318
+
319
+
320
+ # --------------------------------------------------------------------------
321
+ # The bridge actually reaches the markup
322
+ # --------------------------------------------------------------------------
323
+
324
+
325
+ def test_every_control_carries_a_real_data_bit_attribute(context):
326
+ """Regression: `emit()` returns the attribute *value*, not the attribute.
327
+
328
+ Interpolating it bare renders `<button run:go>` -- an unknown attribute,
329
+ no `data-bit`, so the delegated listener never matches and every control on
330
+ the page silently does nothing. Nothing else in the suite caught that,
331
+ because the markup was still well-formed and still contained the action
332
+ strings. This asserts the attribute, not the string.
333
+ """
334
+ adapter = _Fixture(output=OUTPUT_OHLCV_PATHS).load()
335
+ run = _Run(adapter, context)
336
+ state = _state()
337
+ html = shell.page(state, _data([run], state))
338
+
339
+ buttons = re.findall(r"<button\b[^>]*>", html)
340
+ assert len(buttons) > 12, f"only {len(buttons)} buttons rendered"
341
+
342
+ # The sidebar toggle is the one deliberate exception: collapsing is a
343
+ # client-side class flip (`data-bit-sidebar-toggle`), never a server
344
+ # action, because routing it through Gradio would re-render the whole page
345
+ # to move a rail 160px.
346
+ without = [b for b in buttons
347
+ if "data-bit=" not in b and "data-bit-sidebar-toggle" not in b]
348
+ assert not without, f"buttons with no data-bit: {without[:3]}"
349
+
350
+ # And the actions that reach it must be ones the bridge accepts.
351
+ for value in re.findall(r'data-bit="([^"]+)"', html):
352
+ assert chrome.parse_action(f"{value}|nonce") is not None, \
353
+ f"{value!r} would be dropped by the bridge"
354
+
355
+
356
+ def test_the_run_control_is_present_and_addressable(context):
357
+ """The single most important control on the page."""
358
+ state = _state()
359
+ html = shell.controls(state, _data([], state))
360
+ assert 'data-bit="run:go"' in html
vendor/bit-ui/.gitignore ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ .pytest_cache/
4
+ .DS_Store
5
+ key.txt
6
+ *.key
7
+ .env
vendor/bit-ui/README.md ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # bit-ui
2
+
3
+ Shared chrome for The Bit Trading Company's Hugging Face Spaces: the App
4
+ Sidebar, the design system's stylesheets and fonts, the Phosphor icon set, the
5
+ click bridge, the ⌘K command palette, and the coming-soon / contact dialogs.
6
+
7
+ Every BTC Space wears this, so a change here lands everywhere.
8
+
9
+ ## How a Space consumes it
10
+
11
+ As a **git submodule**, vendored under the Space's app directory:
12
+
13
+ ```bash
14
+ git submodule add https://github.com/Bit-Trading-Company/bit-ui space/vendor/bit-ui
15
+ ```
16
+
17
+ ```python
18
+ import sys
19
+ from pathlib import Path
20
+ sys.path.insert(0, str(Path(__file__).resolve().parent / "vendor" / "bit-ui"))
21
+
22
+ from bit_ui import bridge, dialogs, nav, palette, sidebar, theme
23
+ ```
24
+
25
+ The Space deploy uploads a folder, so the submodule's contents travel as
26
+ ordinary files — no pip install, no build step, and no requirement that this
27
+ repo be public.
28
+
29
+ CI needs `submodules: recursive` on checkout.
30
+
31
+ ## Two mechanisms, on purpose
32
+
33
+ | | Shared via | Changes reach a Space |
34
+ |---|---|---|
35
+ | **Code** (rendering, styles, bridge) | this repo, pinned per Space by the submodule commit | when that Space bumps and redeploys |
36
+ | **Config** (nav links, launch status) | `nav.json` in the `bit-config` dataset, read at boot | on the Space's next restart — no redeploy |
37
+
38
+ The split is the point. The thing that changes often — which modules are live,
39
+ where they point — is data, so promoting a module from *coming soon* to *live*
40
+ is one edit to one JSON file rather than nine pull requests. The thing that
41
+ changes rarely is code, and it is pinned so a bad change cannot break every
42
+ Space at once.
43
+
44
+ ## Modules
45
+
46
+ | Module | What |
47
+ |---|---|
48
+ | `nav.py` | the shared navigation document: load, validate, fall back |
49
+ | `sidebar.py` | the App Sidebar, collapsible, collapsed by default |
50
+ | `palette.py` | the ⌘K command palette and the live search bar |
51
+ | `dialogs.py` | coming-soon and contact modals |
52
+ | `theme.py` | design-system CSS, Gradio theme, the forced-`vh` checker |
53
+ | `bridge.py` | design `<button>`s → Python actions |
54
+ | `icons.py` | vendored Phosphor paths (MIT) |
55
+ | `markup.py` | `e()` / `a()` escaping |
56
+
57
+ ## Rules
58
+
59
+ 1. **Escape everything interpolated.** `nav.json` is data, and it reaches every
60
+ Space at once.
61
+ 2. **Never force height from `vh`.** `height:100vh` / `min-height:100vh` inside
62
+ the Spaces iframe ratchets the page height — measured at 3890px against
63
+ 2100px of real content. `max-height` is fine. `theme.find_forced_vh()` and
64
+ the test suite enforce this.
65
+ 3. **Never name a module after a stdlib module.** `html.py` at a package root
66
+ shadows the real `html` for the whole process; it broke pytest before it was
67
+ renamed to `markup.py`.
68
+ 4. **Don't ship placeholder details.** No `555` phone numbers, no invented
69
+ build-progress percentages. If it is not real, it does not render.
70
+ 5. **Key CSS off a class on the element, not `body`.** Gradio re-emits every
71
+ rule prefixed `.gradio-container … .contain`; a `body`-rooted selector
72
+ cannot be prefixed, so only the weak unprefixed copy survives and it loses
73
+ to the prefixed base rule.
74
+ 6. **Purely visual state stays client-side.** Every Gradio action re-renders
75
+ and re-sends the whole page. The sidebar collapse is a class flip in
76
+ `bridge.py`, with `<body data-sidebar>` as the source of truth because it
77
+ survives a re-render.
78
+
79
+ ## Tests
80
+
81
+ ```bash
82
+ python -m pytest tests/ -q
83
+ ```
84
+
85
+ Offline, no credentials. 40 tests.
vendor/bit-ui/bit_ui/__init__.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """bit-ui — shared chrome for The Bit Trading Company's Hugging Face Spaces.
2
+
3
+ Every BTC Space wears the same shell: the App Sidebar, the design system's
4
+ stylesheets and fonts, the Phosphor icon set, the click bridge that lets the
5
+ design's own `<button>`s reach Python, and the coming-soon / contact dialogs.
6
+ This package is that shell, in one place, so a change lands everywhere.
7
+
8
+ It is consumed as a **git submodule mounted at `space/src/bit_ui/`**. The Space
9
+ deploy uploads a folder, so the submodule's contents travel as ordinary files
10
+ — no pip install, no build step, and no requirement that this repo be public.
11
+
12
+ Two things are shared, by two different mechanisms, on purpose:
13
+
14
+ **Code** — this package. Pinned per Space by the submodule commit, so a bad
15
+ change here cannot break all nine Spaces at once. Bump deliberately.
16
+
17
+ **Config** — `nav.py` reads the navigation (which modules exist, which are
18
+ live, where they point) from a shared Hugging Face dataset at boot, with a
19
+ baked-in fallback. Flipping a module from "coming soon" to live updates every
20
+ Space on its next restart, with no redeploy and no submodule bump anywhere.
21
+
22
+ That split is the point: the thing that changes often (links, launch status) is
23
+ data, and the thing that changes rarely (how it renders) is code.
24
+
25
+ Usage:
26
+
27
+ from src.bit_ui import bridge, dialogs, icons, nav, sidebar, theme
28
+
29
+ navigation = nav.load()
30
+ html = sidebar.render(navigation, active="Finance Atlas", collapsed=True)
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ __version__ = "1.0.0"
36
+
37
+ from . import (bridge, dialogs, icons, markup, nav, palette, # noqa: F401
38
+ sidebar, theme)
39
+
40
+ __all__ = ["bridge", "dialogs", "icons", "markup", "nav", "palette",
41
+ "sidebar", "theme", "__version__"]
vendor/bit-ui/bit_ui/assets/fonts/MacMinecraft.ttf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:8a437149023107ac856b4d305ac6c9a6f810273895d23437156378ccf505aa81
3
+ size 252332
vendor/bit-ui/bit_ui/assets/fonts/StyreneA-Light.otf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:48979155896cea590bb6085850a98bf7ccb5b48e9761ad03b8e8f671e92c746c
3
+ size 134944
vendor/bit-ui/bit_ui/assets/fonts/StyreneA-Medium.otf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:012e4c8b383b2e9b6758524018a81352e3cd61e2967a4ed5b8b127890e46994c
3
+ size 141072
vendor/bit-ui/bit_ui/assets/fonts/StyreneA-Regular.otf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:0d282ef9078d7899784c452efef335121768aedc33283ae5b4b4c225e1a176e9
3
+ size 134580
vendor/bit-ui/bit_ui/assets/fonts/StyreneA-Thin.otf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:0a71505a658d6ddfb2327afc23e235d1d276ef15e30a857f1e642a5b42f4ce9f
3
+ size 133416
vendor/bit-ui/bit_ui/assets/tokens/base.css ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *{box-sizing:border-box;border-radius:0 !important;user-select:none}
2
+ input,textarea,[contenteditable],code,kbd,.mono-data,.bit-selectable{user-select:text}
3
+ @keyframes bitFadeIn{from{opacity:0}to{opacity:1}}
4
+ @keyframes bitPopIn{from{opacity:0;transform:scale(0.96)}to{opacity:1;transform:scale(1)}}
5
+ @keyframes bitPopUp{from{opacity:0;transform:translateY(8px) scale(0.9)}to{opacity:1;transform:translateY(0) scale(1)}}
6
+ html,body{margin:0;padding:0;background:var(--bg-canvas);color:var(--text-primary);font-family:var(--font-body);font-weight:var(--weight-body);font-size:var(--text-base);line-height:var(--leading-normal)}
7
+ h1,h2,h3,h4,h5,h6{font-family:var(--font-heading);font-weight:var(--weight-heading);margin:0;text-transform:uppercase;letter-spacing:var(--tracking-wide)}
8
+ a{color:var(--accent-amber);text-decoration:none;transition:color var(--duration) var(--ease)}
9
+ a:hover{color:var(--accent-amber-strong);text-decoration:underline}
10
+ .styrene-text{font-family:var(--font-styrene);font-weight:var(--weight-styrene-thin);text-transform:none;letter-spacing:normal}
11
+ .small-caps{font-variant:small-caps;text-transform:lowercase;letter-spacing:var(--tracking-wide)}
12
+ .pixel-text,.mono-tiny{font-family:var(--font-tiny);font-weight:var(--weight-tiny);font-size:8px !important;letter-spacing:0;text-transform:uppercase}
13
+ code,kbd,.mono-data{font-family:var(--font-mono);font-weight:var(--weight-mono);font-variant-numeric:tabular-nums;letter-spacing:0}
14
+ :focus-visible{outline:2px solid var(--focus-ring);outline-offset:2px}
15
+ .bit-btn-secondary:not(:disabled):hover,.bit-btn-ghost:not(:disabled):hover{background:var(--accent-amber);color:var(--stone-950);border-color:var(--accent-amber)}
16
+ .bit-btn-primary:not(:disabled):hover{background:var(--stone-950);color:var(--accent-amber);border-color:var(--accent-amber)}
17
+ .bit-btn-danger:not(:disabled):hover{background:var(--off-white);color:var(--mute-red);border-color:var(--mute-red)}
18
+ .bit-btn-buy:not(:disabled):hover{background:var(--fin-buy-strong);color:var(--stone-950);border-color:var(--fin-buy-strong)}
19
+ .bit-btn-sell:not(:disabled):hover{background:var(--fin-sell-strong);color:var(--stone-950);border-color:var(--fin-sell-strong)}
20
+ .bit-btn:not(:disabled):active{filter:brightness(0.9)}
21
+ .bit-iconbtn:hover{background:var(--bg-raised);border-color:var(--border-default)}
22
+ .bit-avatar{border-radius:50% !important}
23
+ .bit-pill{border-radius:999px !important}
24
+ .bit-rounded-sm{border-radius:var(--radius-sm) !important}
25
+ .bit-rounded{border-radius:var(--radius-md) !important}
26
+ .bit-glass{background:var(--glass-bg);backdrop-filter:blur(var(--glass-blur));-webkit-backdrop-filter:blur(var(--glass-blur));border:1px solid var(--glass-border)}
27
+ .bit-menu-item:hover{background:var(--bg-raised)}
28
+ .bit-vote:hover{color:var(--text-primary) !important}
29
+ ::selection{background:var(--accent-amber);color:var(--stone-950)}
30
+ *{scrollbar-color:var(--bg-canvas) var(--bg-canvas)}
31
+ ::-webkit-scrollbar{width:12px;height:12px}
32
+ ::-webkit-scrollbar-track{background:var(--bg-canvas);border-radius: 0;}
33
+ ::-webkit-scrollbar-thumb{background:var(--bg-canvas);border-radius: 0;border:2px solid var(--border-strong);}
vendor/bit-ui/bit_ui/assets/tokens/colors.css ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root{
2
+ --pure-black:#000000;
3
+ --pure-white:#ffffff;
4
+ --stone-950:#161512;
5
+ --stone-900:#1d1c18;
6
+ --stone-850:#24221d;
7
+ --stone-800:#2c2a24;
8
+ --stone-700:#3d3a32;
9
+ --stone-600:#54503f;
10
+ --stone-500:#6f6a56;
11
+ --stone-400:#918c76;
12
+ --stone-300:#b6b09a;
13
+ --stone-200:#d6d1bf;
14
+ --stone-100:#ece8dc;
15
+ --stone-50:#f5f3ea;
16
+ --off-white:#f7f4ec;
17
+
18
+ --accent-moss:#68781e;
19
+ --accent-moss-strong:#7d901f;
20
+ --accent-moss-dim:#4d5817;
21
+ --accent-amber:#af9209;
22
+ --accent-amber-strong:#cfab0a;
23
+ --accent-amber-dim:#7d6a09;
24
+
25
+ --mute-red:#8a5a54;
26
+ --mute-orange:#8a6f54;
27
+ --mute-yellow:#8a8154;
28
+ --mute-green:#6e8a54;
29
+ --mute-teal:#54898a;
30
+ --mute-blue:#59656e;
31
+ --mute-indigo:#5c5c8a;
32
+ --mute-violet:#75588a;
33
+
34
+ --fin-up:oklch(66% 0.22 149);
35
+ --fin-up-strong:oklch(72% 0.24 149);
36
+ --fin-down:oklch(62% 0.26 24);
37
+ --fin-down-strong:oklch(68% 0.27 24);
38
+ --fin-flat:var(--stone-400);
39
+ --fin-up-cvd:oklch(58% 0.13 240);
40
+ --fin-up-cvd-strong:oklch(64% 0.14 240);
41
+ --fin-down-cvd:oklch(58% 0.13 55);
42
+ --fin-down-cvd-strong:oklch(64% 0.14 55);
43
+ --fin-buy:var(--fin-up);
44
+ --fin-buy-strong:var(--fin-up-strong);
45
+ --fin-sell:var(--fin-down);
46
+ --fin-sell-strong:var(--fin-down-strong);
47
+
48
+ --focus-ring:var(--accent-amber);
49
+
50
+ --bg-canvas:var(--stone-950);
51
+ --bg-panel:var(--stone-900);
52
+ --bg-raised:var(--stone-850);
53
+ --bg-sunken:var(--pure-black);
54
+ --border-subtle:var(--stone-800);
55
+ --border-default:var(--stone-700);
56
+ --border-strong:var(--stone-500);
57
+ --text-primary:var(--off-white);
58
+ --text-secondary:var(--stone-300);
59
+ --text-tertiary:var(--stone-500);
60
+ --text-disabled:var(--stone-600);
61
+ --text-inverse:var(--stone-950);
62
+ --surface-accent-fg:var(--stone-950);
63
+
64
+ --glass-bg:rgba(29,28,24,0.72);
65
+ --glass-border:rgba(247,244,236,0.14);
66
+ --glass-blur:20px;
67
+ }
68
+ [data-theme="dark"]{
69
+ --bg-canvas:var(--stone-950);
70
+ --bg-panel:var(--stone-900);
71
+ --bg-raised:var(--stone-850);
72
+ --bg-sunken:var(--pure-black);
73
+ --border-subtle:var(--stone-800);
74
+ --border-default:var(--stone-700);
75
+ --border-strong:var(--stone-500);
76
+ --text-primary:var(--off-white);
77
+ --text-secondary:var(--stone-300);
78
+ --text-tertiary:var(--stone-500);
79
+ --text-disabled:var(--stone-600);
80
+ --text-inverse:var(--stone-950);
81
+ --surface-accent-fg:var(--stone-950);
82
+ }
83
+ [data-colorblind="true"]{
84
+ --fin-up:var(--fin-up-cvd);
85
+ --fin-up-strong:var(--fin-up-cvd-strong);
86
+ --fin-down:var(--fin-down-cvd);
87
+ --fin-down-strong:var(--fin-down-cvd-strong);
88
+ }
89
+ [data-theme="light"]{
90
+ --bg-canvas:var(--off-white);
91
+ --bg-panel:var(--stone-50);
92
+ --bg-raised:var(--pure-white);
93
+ --bg-sunken:var(--stone-100);
94
+ --border-subtle:var(--stone-200);
95
+ --border-default:var(--stone-300);
96
+ --border-strong:var(--stone-500);
97
+ --text-primary:var(--stone-950);
98
+ --text-secondary:var(--stone-700);
99
+ --text-tertiary:var(--stone-500);
100
+ --text-disabled:var(--stone-300);
101
+ --text-inverse:var(--off-white);
102
+ --surface-accent-fg:var(--stone-950);
103
+ --glass-bg:rgba(245,243,234,0.72);
104
+ --glass-border:rgba(22,21,18,0.1);
105
+ }
106
+ /* Custom themes: copy this block, rename the [data-theme] value, and override only
107
+ the tokens that should change. Every component reads these tokens, never raw colors,
108
+ so a new theme needs no component edits — set data-theme on any ancestor (SidebarNav's
109
+ theme selector does this at the app root) and everything downstream updates. */
110
+ [data-theme="custom"]{
111
+ --bg-canvas:var(--stone-950);
112
+ --bg-panel:var(--stone-900);
113
+ --bg-raised:var(--stone-850);
114
+ --bg-sunken:var(--pure-black);
115
+ --border-subtle:var(--stone-800);
116
+ --border-default:var(--stone-700);
117
+ --border-strong:var(--accent-amber-dim);
118
+ --text-primary:var(--off-white);
119
+ --text-secondary:var(--stone-300);
120
+ --text-tertiary:var(--stone-500);
121
+ --text-disabled:var(--stone-600);
122
+ --text-inverse:var(--stone-950);
123
+ --surface-accent-fg:var(--stone-950);
124
+ }
125
+
vendor/bit-ui/bit_ui/assets/tokens/spacing.css ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root{
2
+ --space-0:0px;
3
+ --space-1:4px;
4
+ --space-2:8px;
5
+ --space-3:12px;
6
+ --space-4:16px;
7
+ --space-5:24px;
8
+ --space-6:32px;
9
+ --space-7:48px;
10
+ --space-8:64px;
11
+ --space-9:96px;
12
+
13
+ --radius-0:0px;
14
+ --radius-sm:4px;
15
+ --radius-md:8px;
16
+ --radius-pill:999px;
17
+ --border-width:1px;
18
+ --border-width-strong:2px;
19
+
20
+ --ease:ease;/* @kind other */
21
+ --duration:1s;/* @kind other */
22
+
23
+ --shadow-none:none;
24
+ --shadow-panel:0 0 0 var(--border-width) var(--border-default);
25
+ --z-header:100;/* @kind other */
26
+ --z-menu:200;/* @kind other */
27
+ --z-modal:300;/* @kind other */
28
+ --z-toast:400;/* @kind other */
29
+ }
vendor/bit-ui/bit_ui/assets/tokens/typography.css ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root{
2
+ --font-styrene:'Styrene A',sans-serif;
3
+ --font-system:-apple-system,BlinkMacSystemFont,system-ui,sans-serif;
4
+ --font-mono:'JetBrains Mono',ui-monospace,'SFMono-Regular',Menlo,monospace;
5
+ --font-tiny:'Mac Minecraft',monospace;
6
+ --font-body:var(--font-system);
7
+ --font-heading:var(--font-styrene);
8
+ --weight-heading:400;
9
+ --weight-body:300;
10
+ --weight-styrene-thin:100;
11
+ --weight-styrene-light:300;
12
+ --weight-styrene-regular:400;
13
+ --weight-styrene-medium:500;
14
+ --weight-mono:400;
15
+ --weight-tiny:400;
16
+ --weight-strong:700;
17
+
18
+ --text-2xs:8px;
19
+ --text-xs:10px;
20
+ --text-sm:11px;
21
+ --text-base:12px;
22
+ --text-md:16px;
23
+ --text-lg:19px;
24
+ --text-xl:25px;
25
+ --text-2xl:33px;
26
+ --text-3xl:45px;
27
+ --text-4xl:62px;
28
+
29
+ --leading-tight:1.1;
30
+ --leading-snug:1.3;
31
+ --leading-normal:1.5;
32
+ --leading-relaxed:1.7;
33
+
34
+ --tracking-tight:-0.01em;
35
+ --tracking-normal:0;
36
+ --tracking-wide:0.04em;
37
+ --tracking-wider:0.12em;
38
+ }
vendor/bit-ui/bit_ui/bridge.py ADDED
@@ -0,0 +1,329 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Click bridge: the design's markup in, Python state out.
2
+
3
+ The designs are built from real `<button>` elements with exact inline styles.
4
+ Gradio renders a structurally different DOM for the same concepts — a
5
+ `gr.Radio` is a `<fieldset>` of `<label>`s wrapping hidden inputs — so
6
+ restyling Gradio's controls only ever approximates the design. Instead the
7
+ visible layer is the design's own markup, and this module is how a real
8
+ `<button>` reaches Python:
9
+
10
+ <button data-bit="sort:Likes">Likes</button>
11
+ |
12
+ | delegated listener (installed via demo.load(js=...))
13
+ v
14
+ hidden Textbox #bit-action <- value set via the native setter
15
+ |
16
+ v
17
+ hidden Button #bit-trigger clicked -> Python handler -> re-render
18
+
19
+ A nonce is appended to every action because Gradio's `.change()` only fires
20
+ when the value actually differs; clicking the same button twice must still
21
+ register.
22
+
23
+ ## Allow-lists are per Space
24
+
25
+ This module owns the mechanism; each Space owns its vocabulary. `SHARED_KEYS`
26
+ covers what bit-ui's own components emit (the sidebar toggle, the dialogs);
27
+ a Space unions its own keys on top and builds an emitter:
28
+
29
+ ALLOWED_KEYS = bridge.SHARED_KEYS | frozenset({"task", "sort", ...})
30
+ emit = bridge.make_emitter(ALLOWED_KEYS)
31
+
32
+ Nothing here evaluates anything. Actions are `key:value` pairs, the key must be
33
+ on the caller's allow-list, and the value is returned as an opaque string for
34
+ the caller to validate against its own domain.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ from dataclasses import dataclass
40
+
41
+ # What bit-ui's own components emit. A Space that renders the sidebar or the
42
+ # dialogs must allow these, or its own chrome will not respond.
43
+ SHARED_KEYS = frozenset({
44
+ "sidebar", # sidebar:toggle -- collapse / expand the rail
45
+ "soon", # soon:<module> to open, soon:close to dismiss
46
+ "contact", # contact:open | contact:close | contact:form | contact:send
47
+ "topic", # topic:<name> -- contact form topic chip
48
+ "notify", # notify:<module> -- "tell me when this launches"
49
+ "nemail", # the address typed into that dialog's field
50
+ "cname", "cemail", "cmsg", # contact form fields (arrive as key=value)
51
+ "palette", # palette:open | palette:close | palette:go=<target>
52
+ "pq", # palette query -- arrives live as pq=<text> while typing
53
+ "noop", # explicit no-op; also stops click-through on dialog panels
54
+ })
55
+
56
+ ACTION_ELEMENT_ID = "bit-action"
57
+ TRIGGER_ELEMENT_ID = "bit-trigger"
58
+
59
+ # Separates the action from its nonce. A pipe rather than a space because
60
+ # action values are human-readable names -- "Recently updated", "Data
61
+ # correction" -- which legitimately contain spaces. No value contains a pipe.
62
+ NONCE_SEP = "|"
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class Action:
67
+ key: str
68
+ value: str
69
+
70
+ @property
71
+ def is_noop(self) -> bool:
72
+ return self.key == "noop"
73
+
74
+
75
+ def emit(action: str, value: str = "") -> str:
76
+ """Build a `data-bit` value from either `"key:value"` or (key, value).
77
+
78
+ Accepts the combined form so bit-ui's components can emit `"soon:close"`
79
+ without knowing the host's emitter signature.
80
+ """
81
+ if ":" in action and not value:
82
+ key, _, value = action.partition(":")
83
+ else:
84
+ key = action
85
+ if NONCE_SEP in str(value):
86
+ raise ValueError(f"action values may not contain {NONCE_SEP!r}")
87
+ return f"{key}:{value}"
88
+
89
+
90
+ def make_emitter(allowed):
91
+ """An `emit` that refuses any key not on this Space's allow-list.
92
+
93
+ Failing at render time rather than silently at click time is the point: a
94
+ control the bridge would drop is a control that does nothing, and that is
95
+ much cheaper to find here.
96
+ """
97
+ allowed = frozenset(allowed)
98
+
99
+ def _emit(action: str, value: str = "") -> str:
100
+ key = action.partition(":")[0] if ":" in action and not value else action
101
+ if key not in allowed:
102
+ raise ValueError(
103
+ f"{key!r} is not an allowed UI action "
104
+ f"(add it to ALLOWED_KEYS)")
105
+ return emit(action, value)
106
+
107
+ return _emit
108
+
109
+
110
+ def parse_action(raw, allowed=None):
111
+ """Parse what the bridge wrote into the hidden textbox.
112
+
113
+ Returns None for anything unparseable or off the allow-list, so a
114
+ malformed or hand-crafted payload is ignored rather than raising.
115
+ """
116
+ if not raw or not isinstance(raw, str):
117
+ return None
118
+ payload = raw.split(NONCE_SEP, 1)[0]
119
+ if ":" not in payload:
120
+ return None
121
+ key, _, value = payload.partition(":")
122
+ key = key.strip()
123
+ if allowed is not None and key not in allowed:
124
+ return None
125
+ return Action(key=key, value=value.strip())
126
+
127
+
128
+ def parse_pair(value: str):
129
+ """Split a compound action value like `fast_ma=20` -> ("fast_ma", "20")."""
130
+ name, _, val = value.partition("=")
131
+ return name.strip(), val.strip()
132
+
133
+
134
+ # `demo.load(js=...)` takes a JS *function*. Spaces drops
135
+ # `gr.Blocks(head=...)`, so this is the path that actually works once deployed.
136
+ BRIDGE_LOAD_JS = """
137
+ () => {
138
+ if (window.__bitBridgeInstalled) return;
139
+ window.__bitBridgeInstalled = true;
140
+
141
+ function holder() {
142
+ var root = document.getElementById('__ELEM_ID__');
143
+ return root ? root.querySelector('textarea, input') : null;
144
+ }
145
+
146
+ function send(payload) {
147
+ var box = holder();
148
+ if (!box) return;
149
+ var nonce = Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
150
+
151
+ // Assigning .value does not reliably wake Svelte's binding, so the native
152
+ // setter is used and both events are dispatched...
153
+ var proto = box.tagName === 'TEXTAREA'
154
+ ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype;
155
+ var setter = Object.getOwnPropertyDescriptor(proto, 'value').set;
156
+ setter.call(box, payload + '|' + nonce);
157
+ box.dispatchEvent(new Event('input', { bubbles: true }));
158
+ box.dispatchEvent(new Event('change', { bubbles: true }));
159
+
160
+ // ...and a hidden Gradio Button is then clicked. A real click on a real
161
+ // Button is the one event path Gradio always honours, so the handler fires
162
+ // even if the textbox binding did not register.
163
+ var trigger = document.getElementById('__TRIGGER_ID__');
164
+ var btn = trigger ? (trigger.tagName === 'BUTTON'
165
+ ? trigger : trigger.querySelector('button')) : null;
166
+ if (btn) { setTimeout(function () { btn.click(); }, 0); }
167
+ }
168
+ window.__bitSend = send;
169
+
170
+ // The sidebar collapse is pure presentation, so it is handled entirely
171
+ // here: flip an attribute on <body> and stop. No server call, no re-render.
172
+ // Round-tripping it meant re-sending the whole page (1.16 MB on the Atlas,
173
+ // 95% of it the model table) to move a rail 160px -- 1.5-2.2s for something
174
+ // that should be instant.
175
+ //
176
+ // <body> and not a wrapper inside the app: everything inside the gr.HTML
177
+ // sink is replaced on the next re-render, which would reset the rail.
178
+ var SIDEBAR_KEY = 'bit-sidebar';
179
+ // The class goes on the rail itself. Gradio emits a scoped copy of every
180
+ // rule prefixed with `.gradio-container ... .contain`, and a selector rooted
181
+ // at `body` cannot be prefixed legally, so it silently loses the cascade to
182
+ // the prefixed base rule. <body> stays the source of truth because it
183
+ // survives a re-render; `applySidebar` mirrors it onto the rail.
184
+ function applySidebar() {
185
+ var expanded = document.body.getAttribute('data-sidebar') === 'expanded';
186
+ var rail = document.querySelector('.bit-sidebar');
187
+ if (rail) {
188
+ rail.classList.toggle('is-expanded', expanded);
189
+ }
190
+ var btn = document.querySelector('[data-bit-sidebar-toggle]');
191
+ if (btn) {
192
+ btn.setAttribute('aria-expanded', expanded ? 'true' : 'false');
193
+ btn.setAttribute('title', (expanded ? 'Collapse' : 'Expand') + ' sidebar');
194
+ }
195
+ }
196
+
197
+ function setSidebar(expanded) {
198
+ if (expanded) { document.body.setAttribute('data-sidebar', 'expanded'); }
199
+ else { document.body.removeAttribute('data-sidebar'); }
200
+ applySidebar();
201
+ try { localStorage.setItem(SIDEBAR_KEY, expanded ? 'expanded' : 'collapsed'); }
202
+ catch (err) {}
203
+ }
204
+ try {
205
+ if (localStorage.getItem(SIDEBAR_KEY) === 'expanded') setSidebar(true);
206
+ } catch (err) {}
207
+
208
+ document.addEventListener('click', function (e) {
209
+ var toggle = e.target && e.target.closest
210
+ ? e.target.closest('[data-bit-sidebar-toggle]') : null;
211
+ if (toggle) {
212
+ e.preventDefault();
213
+ e.stopPropagation();
214
+ setSidebar(document.body.getAttribute('data-sidebar') !== 'expanded');
215
+ return;
216
+ }
217
+
218
+ var el = e.target && e.target.closest ? e.target.closest('[data-bit]') : null;
219
+ if (!el) return;
220
+ var action = el.getAttribute('data-bit');
221
+ // `noop:` stops the closest() walk without acting -- it is what keeps a
222
+ // click inside a dialog panel from reaching the backdrop's close action.
223
+ if (!action || action.indexOf('noop:') === 0) return;
224
+ e.preventDefault();
225
+ e.stopPropagation();
226
+ send(action);
227
+ }, true);
228
+
229
+ // Text and number fields commit on blur / Enter, not per keystroke, so a
230
+ // re-render is not triggered on every character typed.
231
+ // NOTE the ':' separator. Fields used to send `key=value`, which has no
232
+ // colon, and `parse_action` splits on ':' -- so every keystroke was parsed
233
+ // as an unknown action and dropped. The search box silently did nothing.
234
+ function commit(el) {
235
+ if (!el || !el.hasAttribute || !el.hasAttribute('data-bit-input')) return;
236
+ send(el.getAttribute('data-bit-input') + ':' + el.value);
237
+ }
238
+ document.addEventListener('change', function (e) { commit(e.target); }, true);
239
+ document.addEventListener('keydown', function (e) {
240
+ if (e.key === 'Enter' && e.target && e.target.tagName !== 'TEXTAREA') {
241
+ commit(e.target);
242
+ }
243
+ }, true);
244
+
245
+ // Live fields (`data-bit-live`) send as you type, debounced. Search wants
246
+ // this: waiting for Enter to see whether anything matched is the difference
247
+ // between a search box and a form field. The debounce is what stops it from
248
+ // being one round trip per keystroke.
249
+ var liveTimer = null, liveLast = null;
250
+ document.addEventListener('input', function (e) {
251
+ var el = e.target;
252
+ if (!el || !el.hasAttribute || !el.hasAttribute('data-bit-live')) return;
253
+ var key = el.getAttribute('data-bit-live');
254
+ var val = el.value;
255
+ if (val === liveLast) return;
256
+ liveLast = val;
257
+ clearTimeout(liveTimer);
258
+ liveTimer = setTimeout(function () { send(key + ':' + val); }, 220);
259
+ }, true);
260
+
261
+ // Palette keyboard navigation. Arrow keys move the highlight and Enter
262
+ // activates it, entirely client-side -- a server round trip per arrow press
263
+ // would make the list feel broken.
264
+ function paletteRows() {
265
+ return Array.prototype.slice.call(
266
+ document.querySelectorAll('[data-bit-option]'));
267
+ }
268
+ function highlight(rows, next) {
269
+ rows.forEach(function (r, i) {
270
+ var on = i === next;
271
+ r.setAttribute('data-active', on ? '1' : '0');
272
+ if (on && r.scrollIntoView) r.scrollIntoView({ block: 'nearest' });
273
+ });
274
+ }
275
+ document.addEventListener('keydown', function (e) {
276
+ if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp' && e.key !== 'Enter') return;
277
+ var rows = paletteRows();
278
+ if (!rows.length) return;
279
+ var cur = rows.findIndex(function (r) {
280
+ return r.getAttribute('data-active') === '1'; });
281
+ if (e.key === 'Enter') {
282
+ if (cur < 0) return;
283
+ e.preventDefault();
284
+ e.stopPropagation();
285
+ rows[cur].click();
286
+ return;
287
+ }
288
+ e.preventDefault();
289
+ var next = e.key === 'ArrowDown'
290
+ ? (cur + 1) % rows.length
291
+ : (cur <= 0 ? rows.length - 1 : cur - 1);
292
+ highlight(rows, next);
293
+ }, true);
294
+
295
+ // Keyboard shortcuts declared in the markup as data-bit-key="<combo>".
296
+ // Cmd/Ctrl+K opens the command palette; Escape closes whatever is open.
297
+ document.addEventListener('keydown', function (e) {
298
+ var mod = e.metaKey || e.ctrlKey;
299
+ if (mod && (e.key === 'k' || e.key === 'K')) {
300
+ e.preventDefault();
301
+ send('palette:open');
302
+ return;
303
+ }
304
+ if (e.key === 'Escape') {
305
+ var el = document.querySelector('[data-bit-escape]');
306
+ if (el) { e.preventDefault(); send(el.getAttribute('data-bit-escape')); }
307
+ }
308
+ }, true);
309
+
310
+ // Focus whatever the freshly rendered page asks for -- the palette input
311
+ // after Cmd+K, without which the shortcut opens a box you must then click.
312
+ var focusPending = null;
313
+ function applyAutofocus() {
314
+ var el = document.querySelector('[data-bit-autofocus]');
315
+ if (!el || el === focusPending) return;
316
+ focusPending = el;
317
+ setTimeout(function () {
318
+ try { el.focus(); if (el.select) el.select(); } catch (err) {}
319
+ }, 30);
320
+ }
321
+ // A re-render replaces the rail and the toggle, so both are restored from
322
+ // the attribute that survived on <body>.
323
+ new MutationObserver(function () { applyAutofocus(); applySidebar(); })
324
+ .observe(document.body, { childList: true, subtree: true });
325
+ applyAutofocus();
326
+ applySidebar();
327
+ }
328
+ """.replace("__ELEM_ID__", ACTION_ELEMENT_ID).replace(
329
+ "__TRIGGER_ID__", TRIGGER_ELEMENT_ID)
vendor/bit-ui/bit_ui/dialogs.py ADDED
@@ -0,0 +1,311 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Coming-soon and contact dialogs.
2
+
3
+ Both follow the design's own markup. Two deliberate departures, both for the
4
+ same reason — a public page should not carry details that are not real:
5
+
6
+ **No placeholder contact details.** The design fills the contact card with
7
+ `atlas@bittrading.co` and `+1 617 555 0142`. The `555` prefix is reserved for
8
+ fiction, and a phone number that rings nowhere is worse than no phone number.
9
+ Channels come from `nav.json` instead, so real ones can be added without a
10
+ code change, and only what is configured renders.
11
+
12
+ **No invented progress bar.** The design shows BUILD PROGRESS at 64%, 72%, 58%.
13
+ Nobody is tracking those numbers. The bar renders only when `nav.json` gives a
14
+ real percentage for that module; otherwise the dialog says the module is in
15
+ build and leaves it there.
16
+
17
+ Both dialogs emit actions through the host Space's bridge. The host is
18
+ responsible for handling `contact:close`, `soon:close`, `contact:send` and
19
+ `notify:<module>` — see `ALLOWED_KEYS` in its bridge module.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from .markup import a, e
25
+ from .icons import icon
26
+
27
+ # The panel carries a `noop:` marker so a click inside it does not bubble up to
28
+ # the backdrop's close action and dismiss the dialog mid-typing. The bridge
29
+ # returns early on `noop:`, which stops `closest('[data-bit]')` at the panel.
30
+ PANEL_GUARD = 'data-bit="noop:"'
31
+
32
+
33
+ def _close_button(action: str, emit) -> str:
34
+ return (
35
+ f'<button type="button" data-bit="{a(emit(action))}" title="Close" '
36
+ f'aria-label="Close" class="bit-hover-strong" '
37
+ f'style="margin-left:auto;background:transparent;'
38
+ f'border:1px solid var(--border-default);color:var(--text-tertiary);'
39
+ f'width:22px;height:22px;display:flex;align-items:center;'
40
+ f'justify-content:center;cursor:pointer">{icon("close", "11px")}</button>'
41
+ )
42
+
43
+
44
+ def _backdrop(action: str, emit, z: int, body: str) -> str:
45
+ return (
46
+ f'<div data-bit="{a(emit(action))}" style="position:fixed;inset:0;'
47
+ f'z-index:{z};background:rgba(10,10,8,0.6);display:flex;'
48
+ f'align-items:center;justify-content:center;padding:24px;'
49
+ f'animation:bitFade 0.3s ease">{body}</div>'
50
+ )
51
+
52
+
53
+ # --------------------------------------------------------------------------
54
+ # Coming soon
55
+ # --------------------------------------------------------------------------
56
+
57
+
58
+ def coming_soon(item: dict, navigation: dict, emit, notice: str = "") -> str:
59
+ """The dialog shown for a module that is planned but not built."""
60
+ if not item:
61
+ return ""
62
+
63
+ name = item.get("name", "")
64
+ kind = item.get("kind") or "SPACE · IN BUILD"
65
+ progress = item.get("progress")
66
+
67
+ if progress is None:
68
+ # No real figure, so no bar. Said plainly rather than drawn vaguely.
69
+ progress_block = (
70
+ f'<div style="display:flex;align-items:center;gap:8px;padding:7px 9px;'
71
+ f'border:1px solid var(--border-subtle);background:var(--bg-raised)">'
72
+ f'<span class="pixel-text" style="color:var(--text-tertiary)">·</span>'
73
+ f'<span class="mono-data" style="font-size:var(--text-2xs);'
74
+ f'color:var(--text-tertiary)">NO PUBLISHED BUILD DATE</span></div>'
75
+ )
76
+ else:
77
+ progress_block = (
78
+ f'<div><div style="display:flex;justify-content:space-between;'
79
+ f'margin-bottom:5px">'
80
+ f'<span class="mono-data" style="font-size:var(--text-2xs);'
81
+ f'color:var(--text-tertiary)">BUILD PROGRESS</span>'
82
+ f'<span class="mono-data" style="font-size:var(--text-2xs);'
83
+ f'color:var(--accent-amber-strong)">{progress}%</span></div>'
84
+ f'<div style="height:6px;background:var(--bg-raised);'
85
+ f'border:1px solid var(--border-default);position:relative;'
86
+ f'overflow:hidden"><div style="position:absolute;left:0;top:0;'
87
+ f'bottom:0;width:{progress}%;background:var(--accent-amber)"></div>'
88
+ f'</div></div>'
89
+ )
90
+
91
+ live = [i for g in navigation.get("groups") or ()
92
+ for i in g.get("items") or ()
93
+ if i.get("status") == "live" and i.get("url")]
94
+ if live:
95
+ target = live[0]
96
+ live_row = (
97
+ f'<div style="display:flex;align-items:center;gap:10px;'
98
+ f'border-top:1px solid var(--border-subtle);padding-top:10px">'
99
+ f'<span class="pixel-text" style="color:var(--text-tertiary)">'
100
+ f'OR OPEN A TOOL THAT IS LIVE</span>'
101
+ f'<a href="{a(target["url"])}" target="_blank" rel="noopener noreferrer" '
102
+ f'class="bit-hover-connect" style="margin-left:auto;'
103
+ f'display:inline-flex;align-items:center;gap:6px;padding:4px 9px;'
104
+ f'border:1px solid var(--accent-amber-dim);'
105
+ f'color:var(--accent-amber-strong);text-decoration:none">'
106
+ f'<span style="font-family:var(--font-styrene);'
107
+ f'font-size:var(--text-xs);text-transform:uppercase;'
108
+ f'letter-spacing:var(--tracking-wide);white-space:nowrap;'
109
+ f'margin-top:2px">{e(target["name"])}</span>'
110
+ f'{icon("chevronRight", "11px")}</a></div>'
111
+ )
112
+ else:
113
+ live_row = ""
114
+
115
+ note = ""
116
+ if notice:
117
+ note = (f'<span class="mono-data" style="font-size:var(--text-2xs);'
118
+ f'color:var(--accent-moss-strong)">{e(notice)}</span>')
119
+
120
+ body = f"""
121
+ <div {PANEL_GUARD} role="dialog" aria-modal="true" aria-label="{a(name)} — coming soon"
122
+ style="width:min(460px,100%);background:var(--bg-panel);
123
+ border:1px solid var(--border-strong)">
124
+ <div style="display:flex;align-items:center;gap:8px;padding:8px 12px;
125
+ border-bottom:1px solid var(--border-default);background:var(--bg-raised)">
126
+ <span style="display:inline-block;width:6px;height:6px;
127
+ background:var(--accent-amber);animation:bitPulse 2s ease-in-out infinite"></span>
128
+ <span style="font-family:var(--font-styrene);font-size:var(--text-2xs);
129
+ text-transform:uppercase;letter-spacing:var(--tracking-wider);
130
+ color:var(--text-tertiary)">Coming soon</span>
131
+ {_close_button('soon:close', emit)}
132
+ </div>
133
+ <div style="padding:18px 16px;display:flex;flex-direction:column;gap:14px;
134
+ position:relative;overflow:hidden">
135
+ <div aria-hidden="true" style="position:absolute;left:0;right:0;top:0;
136
+ height:1px;background:var(--accent-amber);opacity:0.55"></div>
137
+ <div style="display:flex;align-items:center;gap:12px">
138
+ <span aria-hidden="true" style="flex:0 0 auto;width:40px;height:40px;
139
+ display:flex;align-items:center;justify-content:center;
140
+ border:1px solid var(--border-default);background:var(--bg-raised);
141
+ color:var(--text-tertiary)">{icon(item.get('icon') or 'cube', '20px')}</span>
142
+ <div style="min-width:0">
143
+ <div style="font-family:var(--font-styrene);font-size:var(--text-lg);
144
+ text-transform:uppercase;letter-spacing:var(--tracking-wide);
145
+ line-height:var(--leading-tight)">{e(name)}</div>
146
+ <div class="mono-data" style="font-size:var(--text-2xs);
147
+ color:var(--text-tertiary);margin-top:3px">{e(kind)}</div>
148
+ </div>
149
+ </div>
150
+ <div style="color:var(--text-secondary);text-wrap:pretty">This Space is
151
+ still in build. Finance Atlas and Backtest Lab are the tools open right
152
+ now — everything else unlocks as it clears validation.</div>
153
+ {progress_block}
154
+ <div style="display:flex;gap:8px;align-items:center">
155
+ <input data-bit-input="nemail" type="email" placeholder="you@firm.com"
156
+ aria-label="Email me when {a(name)} launches"
157
+ style="flex:1 1 auto;min-width:0;padding:6px 8px;background:var(--bg-sunken);
158
+ border:1px solid var(--border-default);outline:none;
159
+ font-family:var(--font-mono);font-size:var(--text-xs);
160
+ color:var(--text-primary)" />
161
+ <button type="button" data-bit="{a(emit('notify:' + name))}"
162
+ class="bit-hover-cta" style="flex:0 0 auto;padding:6px 11px;
163
+ background:var(--accent-amber);border:1px solid var(--accent-amber);
164
+ color:var(--stone-950);cursor:pointer;font-family:var(--font-styrene);
165
+ font-size:var(--text-xs);text-transform:uppercase;
166
+ letter-spacing:var(--tracking-wide)">Notify me</button>
167
+ </div>
168
+ {note}
169
+ {live_row}
170
+ </div>
171
+ </div>"""
172
+ return _backdrop("soon:close", emit, 80, body)
173
+
174
+
175
+ # --------------------------------------------------------------------------
176
+ # Contact
177
+ # --------------------------------------------------------------------------
178
+
179
+
180
+ def contact(navigation: dict, state: dict, emit) -> str:
181
+ """The contact dialog: real channels, plus a message form."""
182
+ config = navigation.get("contact") or {}
183
+ channels = config.get("channels") or []
184
+ topics = config.get("topics") or ["Data correction", "Press", "Other"]
185
+
186
+ rows = []
187
+ for index, channel in enumerate(channels):
188
+ href = channel.get("href") or ""
189
+ last = index == len(channels) - 1
190
+ border = "" if last else "border-bottom:1px solid var(--border-subtle);"
191
+ rows.append(
192
+ f'<a href="{a(href)}" target="_blank" rel="noopener noreferrer" '
193
+ f'class="bit-hover-row" style="display:flex;align-items:center;'
194
+ f'gap:10px;padding:10px 12px;{border}text-decoration:none">'
195
+ f'{icon(channel.get("icon") or "cube", "14px", "var(--accent-amber-strong)")}'
196
+ f'<span style="flex:1 1 auto;min-width:0;display:flex;'
197
+ f'flex-direction:column;gap:1px">'
198
+ f'<span style="font-family:var(--font-styrene);font-weight:300;'
199
+ f'font-size:var(--text-2xs);text-transform:uppercase;'
200
+ f'letter-spacing:var(--tracking-wider);color:var(--text-tertiary)">'
201
+ f'{e(channel.get("kind") or "")}</span>'
202
+ f'<span class="mono-data" style="font-size:var(--text-xs);'
203
+ f'color:var(--text-primary)">{e(channel.get("value") or "")}</span>'
204
+ f'</span>'
205
+ f'<span class="mono-data" style="font-size:var(--text-2xs);'
206
+ f'color:var(--text-tertiary)">{e(channel.get("note") or "")}</span></a>'
207
+ )
208
+
209
+ open_form = bool(state.get("contact_form_open"))
210
+ chosen = state.get("contact_topic") or topics[0]
211
+ topic_chips = "".join(
212
+ f'<button type="button" data-bit="{a(emit("topic:" + t))}" '
213
+ f'aria-pressed="{"true" if t == chosen else "false"}" '
214
+ f'style="padding:2px 7px;'
215
+ f'background:{"var(--accent-amber-dim)" if t == chosen else "transparent"};'
216
+ f'border:1px solid '
217
+ f'{"var(--accent-amber)" if t == chosen else "var(--border-default)"};'
218
+ f'color:{"var(--text-primary)" if t == chosen else "var(--text-secondary)"};'
219
+ f'cursor:pointer;font-family:var(--font-mono);font-size:var(--text-2xs)">'
220
+ f'{e(t)}</button>'
221
+ for t in topics
222
+ )
223
+
224
+ sent_note = state.get("contact_note") or "NO ATTACHMENTS · PLAIN TEXT ONLY"
225
+ sent_colour = ("var(--accent-moss-strong)" if state.get("contact_ok")
226
+ else "var(--text-tertiary)")
227
+
228
+ form = ""
229
+ if open_form:
230
+ form = f"""
231
+ <div style="padding:12px;display:flex;flex-direction:column;gap:10px">
232
+ <div style="display:flex;gap:8px;flex-wrap:wrap">
233
+ <div style="flex:1 1 140px;min-width:0">
234
+ {_field_label('Name')}
235
+ <input data-bit-input="cname" value="{a(state.get('contact_name') or '')}"
236
+ placeholder="Full name" style="{_INPUT}" />
237
+ </div>
238
+ <div style="flex:1 1 140px;min-width:0">
239
+ {_field_label('Email')}
240
+ <input data-bit-input="cemail" type="email"
241
+ value="{a(state.get('contact_email') or '')}"
242
+ placeholder="you@firm.com" style="{_INPUT}" />
243
+ </div>
244
+ </div>
245
+ <div>{_field_label('Topic')}
246
+ <div style="display:flex;flex-wrap:wrap;gap:4px">{topic_chips}</div>
247
+ </div>
248
+ <div>{_field_label('Message')}
249
+ <textarea data-bit-input="cmsg" rows="4"
250
+ placeholder="Model ID, page, or question — be specific."
251
+ style="{_INPUT};resize:vertical">{e(state.get('contact_message') or '')}</textarea>
252
+ </div>
253
+ <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
254
+ <button type="button" data-bit="{a(emit('contact:send'))}"
255
+ class="bit-hover-cta" style="padding:7px 12px;
256
+ background:var(--accent-amber);border:1px solid var(--accent-amber);
257
+ color:var(--stone-950);cursor:pointer;font-family:var(--font-styrene);
258
+ font-size:var(--text-sm);text-transform:uppercase;
259
+ letter-spacing:var(--tracking-wide)">Send</button>
260
+ <span class="mono-data" style="font-size:var(--text-2xs);
261
+ color:{sent_colour}">{e(sent_note)}</span>
262
+ </div>
263
+ <div class="styrene-text" style="font-size:var(--text-xs);
264
+ text-transform:uppercase;letter-spacing:var(--tracking-wide);
265
+ color:var(--text-tertiary);text-wrap:pretty">The Bit Trading Company
266
+ answers research and data questions. We do not give financial advice and
267
+ we cannot discuss individual positions.</div>
268
+ </div>"""
269
+
270
+ body = f"""
271
+ <div {PANEL_GUARD} role="dialog" aria-modal="true" aria-label="Contact us"
272
+ class="bit-scroll" style="width:min(460px,100%);max-height:90vh;
273
+ overflow-y:auto;background:var(--bg-panel);
274
+ border:1px solid var(--border-strong)">
275
+ <div style="display:flex;align-items:center;gap:8px;padding:9px 12px;
276
+ border-bottom:1px solid var(--border-default);background:var(--bg-raised)">
277
+ <span style="display:inline-block;width:5px;height:5px;
278
+ background:var(--accent-amber);animation:bitPulse 2s ease-in-out infinite"></span>
279
+ <span style="font-family:var(--font-styrene);font-size:var(--text-md);
280
+ text-transform:uppercase;letter-spacing:var(--tracking-wide)">Contact us</span>
281
+ {_close_button('contact:close', emit)}
282
+ </div>
283
+ <div style="border-bottom:1px solid var(--border-default)">{''.join(rows)}</div>
284
+ <button type="button" data-bit="{a(emit('contact:form'))}"
285
+ class="bit-hover-row" style="width:100%;display:flex;align-items:center;
286
+ gap:8px;padding:9px 12px;background:transparent;border:none;
287
+ border-bottom:1px solid var(--border-default);cursor:pointer;
288
+ color:var(--text-primary);text-align:left">
289
+ <span style="font-family:var(--font-styrene);font-size:var(--text-sm);
290
+ text-transform:uppercase;letter-spacing:var(--tracking-wide)">Send a message</span>
291
+ <span class="mono-data" style="font-size:var(--text-2xs);
292
+ color:var(--text-tertiary)">{'' if open_form else 'CORRECTIONS · VERIFICATION REQUESTS · PRESS'}</span>
293
+ <span class="mono-data" aria-hidden="true" style="margin-left:auto;
294
+ font-size:var(--text-2xs);color:var(--text-tertiary)">{'▴' if open_form else '▾'}</span>
295
+ </button>
296
+ {form}
297
+ </div>"""
298
+ return _backdrop("contact:close", emit, 90, body)
299
+
300
+
301
+ _INPUT = ("width:100%;padding:5px 7px;background:var(--bg-sunken);"
302
+ "border:1px solid var(--border-default);outline:none;"
303
+ "font-family:var(--font-mono);font-size:var(--text-xs);"
304
+ "color:var(--text-primary)")
305
+
306
+
307
+ def _field_label(text: str) -> str:
308
+ return (f'<div style="font-family:var(--font-styrene);font-weight:300;'
309
+ f'font-size:var(--text-2xs);text-transform:uppercase;'
310
+ f'letter-spacing:var(--tracking-wider);color:var(--text-tertiary);'
311
+ f'margin-bottom:3px">{e(text)}</div>')
vendor/bit-ui/bit_ui/icons.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Phosphor icon paths, vendored.
2
+
3
+ The design system pulls these from `unpkg.com/@phosphor-icons/web` at runtime.
4
+ Vendoring the two dozen the Atlas actually uses keeps the Space self-contained:
5
+ no third-party request on the critical path, and the icons still render if
6
+ unpkg is unreachable.
7
+
8
+ Phosphor Icons is MIT licensed (https://phosphoricons.com). These are the
9
+ "fill" weight, which is what the design system uses -- the flat, solid look is
10
+ part of the visual language and the outline weight reads as a different system.
11
+
12
+ Generated from phosphor-icons/core; the mapping from the design's icon names to
13
+ Phosphor's kebab-case names is the design system's own NAME_MAP.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from html import escape
19
+
20
+ # name -> inner SVG markup, viewBox 0 0 256 256
21
+ PATHS = {
22
+ 'search': '<path d="M168,112a56,56,0,1,1-56-56A56,56,0,0,1,168,112Zm61.66,117.66a8,8,0,0,1-11.32,0l-50.06-50.07a88,88,0,1,1,11.32-11.31l50.06,50.06A8,8,0,0,1,229.66,229.66ZM112,184a72,72,0,1,0-72-72A72.08,72.08,0,0,0,112,184Z"/>',
23
+ 'chevronRight': '<path d="M181.66,133.66l-80,80A8,8,0,0,1,88,208V48a8,8,0,0,1,13.66-5.66l80,80A8,8,0,0,1,181.66,133.66Z"/>',
24
+ 'filter': '<path d="M227.81,66.76l-.08.09L160,139.17v55.49A16,16,0,0,1,152.87,208l-32,21.34A16,16,0,0,1,96,216V139.17L28.27,66.85l-.08-.09A16,16,0,0,1,40,40H216a16,16,0,0,1,11.84,26.76Z"/>',
25
+ 'close': '<path d="M208,32H48A16,16,0,0,0,32,48V208a16,16,0,0,0,16,16H208a16,16,0,0,0,16-16V48A16,16,0,0,0,208,32ZM181.66,170.34a8,8,0,0,1-11.32,11.32L128,139.31,85.66,181.66a8,8,0,0,1-11.32-11.32L116.69,128,74.34,85.66A8,8,0,0,1,85.66,74.34L128,116.69l42.34-42.35a8,8,0,0,1,11.32,11.32L139.31,128Z"/>',
26
+ 'globe': '<path d="M128,24h0A104,104,0,1,0,232,128,104.12,104.12,0,0,0,128,24Zm78.36,64H170.71a135.28,135.28,0,0,0-22.3-45.6A88.29,88.29,0,0,1,206.37,88ZM216,128a87.61,87.61,0,0,1-3.33,24H174.16a157.44,157.44,0,0,0,0-48h38.51A87.61,87.61,0,0,1,216,128ZM128,43a115.27,115.27,0,0,1,26,45H102A115.11,115.11,0,0,1,128,43ZM102,168H154a115.11,115.11,0,0,1-26,45A115.27,115.27,0,0,1,102,168Zm-3.9-16a140.84,140.84,0,0,1,0-48h59.88a140.84,140.84,0,0,1,0,48Zm50.35,61.6a135.28,135.28,0,0,0,22.3-45.6h35.66A88.29,88.29,0,0,1,148.41,213.6Z"/>',
27
+ 'chart': '<path d="M216,40H40A16,16,0,0,0,24,56V200a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A16,16,0,0,0,216,40ZM200,176a8,8,0,0,1,0,16H56a8,8,0,0,1-8-8V72a8,8,0,0,1,16,0v62.92l34.88-29.07a8,8,0,0,1,9.56-.51l43,28.69,43.41-36.18a8,8,0,0,1,10.24,12.3l-48,40a8,8,0,0,1-9.56.51l-43-28.69L64,155.75V176Z"/>',
28
+ 'fileText': '<path d="M213.66,82.34l-56-56A8,8,0,0,0,152,24H56A16,16,0,0,0,40,40V216a16,16,0,0,0,16,16H200a16,16,0,0,0,16-16V88A8,8,0,0,0,213.66,82.34ZM160,176H96a8,8,0,0,1,0-16h64a8,8,0,0,1,0,16Zm0-32H96a8,8,0,0,1,0-16h64a8,8,0,0,1,0,16Zm-8-56V44l44,44Z"/>',
29
+ 'file': '<path d="M213.66,82.34l-56-56A8,8,0,0,0,152,24H56A16,16,0,0,0,40,40V216a16,16,0,0,0,16,16H200a16,16,0,0,0,16-16V88A8,8,0,0,0,213.66,82.34ZM152,88V44l44,44Z"/>',
30
+ 'trend': '<path d="M240,56v64a8,8,0,0,1-13.66,5.66L200,99.31l-58.34,58.35a8,8,0,0,1-11.32,0L96,123.31,29.66,189.66a8,8,0,0,1-11.32-11.32l72-72a8,8,0,0,1,11.32,0L136,140.69,188.69,88,162.34,61.66A8,8,0,0,1,168,48h64A8,8,0,0,1,240,56Z"/>',
31
+ 'sliders': '<path d="M32,80a8,8,0,0,1,8-8H77.17a28,28,0,0,1,53.66,0H216a8,8,0,0,1,0,16H130.83a28,28,0,0,1-53.66,0H40A8,8,0,0,1,32,80Zm184,88H194.83a28,28,0,0,0-53.66,0H40a8,8,0,0,0,0,16H141.17a28,28,0,0,0,53.66,0H216a8,8,0,0,0,0-16Z"/>',
32
+ 'star': '<path d="M234.29,114.85l-45,38.83L203,211.75a16.4,16.4,0,0,1-24.5,17.82L128,198.49,77.47,229.57A16.4,16.4,0,0,1,53,211.75l13.76-58.07-45-38.83A16.46,16.46,0,0,1,31.08,86l59-4.76,22.76-55.08a16.36,16.36,0,0,1,30.27,0l22.75,55.08,59,4.76a16.46,16.46,0,0,1,9.37,28.86Z"/>',
33
+ 'braces': '<path d="M216,40H40A16,16,0,0,0,24,56V200a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A16,16,0,0,0,216,40ZM88,155.84c.29,14.26.41,20.16,16,20.16a8,8,0,0,1,0,16c-31.27,0-31.72-22.43-32-35.84C71.71,141.9,71.59,136,56,136a8,8,0,0,1,0-16c15.59,0,15.71-5.9,16-20.16C72.28,86.43,72.73,64,104,64a8,8,0,0,1,0,16c-15.59,0-15.71,5.9-16,20.16-.17,8.31-.41,20.09-8,27.84C87.59,135.75,87.83,147.53,88,155.84ZM200,136c-15.59,0-15.71,5.9-16,20.16-.28,13.41-.73,35.84-32,35.84a8,8,0,0,1,0-16c15.59,0,15.71-5.9,16-20.16.17-8.31.41-20.09,8-27.84-7.6-7.75-7.84-19.53-8-27.84C167.71,85.9,167.59,80,152,80a8,8,0,0,1,0-16c31.27,0,31.72,22.43,32,35.84.29,14.26.41,20.16,16,20.16a8,8,0,0,1,0,16Z"/>',
34
+ 'flag': '<path d="M232,56V176a8,8,0,0,1-2.76,6c-15.28,13.23-29.89,18-43.82,18-18.91,0-36.57-8.74-53-16.85C105.87,170,82.79,158.61,56,179.77V224a8,8,0,0,1-16,0V56a8,8,0,0,1,2.77-6h0c36-31.18,68.31-15.21,96.79-1.12C167,62.46,190.79,74.2,218.76,50A8,8,0,0,1,232,56Z"/>',
35
+ 'grid': '<path d="M120,56v48a16,16,0,0,1-16,16H56a16,16,0,0,1-16-16V56A16,16,0,0,1,56,40h48A16,16,0,0,1,120,56Zm80-16H152a16,16,0,0,0-16,16v48a16,16,0,0,0,16,16h48a16,16,0,0,0,16-16V56A16,16,0,0,0,200,40Zm-96,96H56a16,16,0,0,0-16,16v48a16,16,0,0,0,16,16h48a16,16,0,0,0,16-16V152A16,16,0,0,0,104,136Zm96,0H152a16,16,0,0,0-16,16v48a16,16,0,0,0,16,16h48a16,16,0,0,0,16-16V152A16,16,0,0,0,200,136Z"/>',
36
+ 'cube': '<path d="M223.68,66.15,135.68,18a15.88,15.88,0,0,0-15.36,0l-88,48.17a16,16,0,0,0-8.32,14v95.64a16,16,0,0,0,8.32,14l88,48.17a15.88,15.88,0,0,0,15.36,0l88-48.17a16,16,0,0,0,8.32-14V80.18A16,16,0,0,0,223.68,66.15ZM128,120,47.65,76,128,32l80.35,44Zm8,99.64V133.83l80-43.78v85.76Z"/>',
37
+ 'shield': '<path d="M208,40H48A16,16,0,0,0,32,56v56c0,52.72,25.52,84.67,46.93,102.19,23.06,18.86,46,25.26,47,25.53a8,8,0,0,0,4.2,0c1-.27,23.91-6.67,47-25.53C198.48,196.67,224,164.72,224,112V56A16,16,0,0,0,208,40Zm-34.32,69.66-56,56a8,8,0,0,1-11.32,0l-24-24a8,8,0,0,1,11.32-11.32L112,148.69l50.34-50.35a8,8,0,0,1,11.32,11.32Z"/>',
38
+ 'list2': '<path d="M208,32H48A16,16,0,0,0,32,48V208a16,16,0,0,0,16,16H208a16,16,0,0,0,16-16V48A16,16,0,0,0,208,32ZM68,188a12,12,0,1,1,12-12A12,12,0,0,1,68,188Zm0-48a12,12,0,1,1,12-12A12,12,0,0,1,68,140Zm0-48A12,12,0,1,1,80,80,12,12,0,0,1,68,92Zm124,92H104a8,8,0,0,1,0-16h88a8,8,0,0,1,0,16Zm0-48H104a8,8,0,0,1,0-16h88a8,8,0,0,1,0,16Zm0-48H104a8,8,0,0,1,0-16h88a8,8,0,0,1,0,16Z"/>',
39
+ 'database': '<path d="M128,24C74.17,24,32,48.6,32,80v96c0,31.4,42.17,56,96,56s96-24.6,96-56V80C224,48.6,181.83,24,128,24Zm80,104c0,9.62-7.88,19.43-21.61,26.92C170.93,163.35,150.19,168,128,168s-42.93-4.65-58.39-13.08C55.88,147.43,48,137.62,48,128V111.36c17.06,15,46.23,24.64,80,24.64s62.94-9.68,80-24.64Zm-21.61,74.92C170.93,211.35,150.19,216,128,216s-42.93-4.65-58.39-13.08C55.88,195.43,48,185.62,48,176V159.36c17.06,15,46.23,24.64,80,24.64s62.94-9.68,80-24.64V176C208,185.62,200.12,195.43,186.39,202.92Z"/>',
40
+ 'shape': '<path d="M224,48V208a16,16,0,0,1-16,16H48a16,16,0,0,1-16-16V48A16,16,0,0,1,48,32H208A16,16,0,0,1,224,48Z"/>',
41
+ 'code': '<path d="M216,40H40A16,16,0,0,0,24,56V200a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A16,16,0,0,0,216,40ZM92.8,145.6a8,8,0,1,1-9.6,12.8l-32-24a8,8,0,0,1,0-12.8l32-24a8,8,0,0,1,9.6,12.8L69.33,128Zm58.89-71.4-32,112a8,8,0,1,1-15.38-4.4l32-112a8,8,0,0,1,15.38,4.4Zm53.11,60.2-32,24a8,8,0,0,1-9.6-12.8L186.67,128,163.2,110.4a8,8,0,1,1,9.6-12.8l32,24a8,8,0,0,1,0,12.8Z"/>',
42
+ 'bookmark': '<path d="M184,32H72A16,16,0,0,0,56,48V224a8,8,0,0,0,12.24,6.78L128,193.43l59.77,37.35A8,8,0,0,0,200,224V48A16,16,0,0,0,184,32Z"/>',
43
+ 'chat': '<path d="M232,128A104,104,0,0,1,79.12,219.82L45.07,231.17a16,16,0,0,1-20.24-20.24l11.35-34.05A104,104,0,1,1,232,128Z"/>',
44
+ 'question': '<path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,168a12,12,0,1,1,12-12A12,12,0,0,1,128,192Zm8-48.72V144a8,8,0,0,1-16,0v-8a8,8,0,0,1,8-8c13.23,0,24-9,24-20s-10.77-20-24-20-24,9-24,20v4a8,8,0,0,1-16,0v-4c0-19.85,17.94-36,40-36s40,16.15,40,36C168,125.38,154.24,139.93,136,143.28Z"/>',
45
+ 'paperPlane': '<path d="M231.4,44.34s0,.1,0,.15l-58.2,191.94a15.88,15.88,0,0,1-14,11.51q-.69.06-1.38.06a15.86,15.86,0,0,1-14.42-9.15L107,164.15a4,4,0,0,1,.77-4.58l57.92-57.92a8,8,0,0,0-11.31-11.31L96.43,148.26a4,4,0,0,1-4.58.77L17.08,112.64a16,16,0,0,1,2.49-29.8l191.94-58.2.15,0A16,16,0,0,1,231.4,44.34Z"/>',
46
+ 'headset': '<path d="M232,128v80a40,40,0,0,1-40,40H136a8,8,0,0,1,0-16h56a24,24,0,0,0,24-24H192a24,24,0,0,1-24-24V144a24,24,0,0,1,24-24h23.65A88,88,0,0,0,66,65.54,87.29,87.29,0,0,0,40.36,120H64a24,24,0,0,1,24,24v40a24,24,0,0,1-24,24H48a24,24,0,0,1-24-24V128A104.11,104.11,0,0,1,201.89,54.66,103.41,103.41,0,0,1,232,128Z"/>',
47
+ }
48
+
49
+
50
+ def icon(name: str, size: str = "14px", color: str = "currentColor",
51
+ extra_style: str = "") -> str:
52
+ """One inline SVG. An unknown name renders nothing rather than raising.
53
+
54
+ Icons are decorative here -- every control they sit inside carries its own
55
+ text or a title attribute -- so they are hidden from assistive technology.
56
+ """
57
+ body = PATHS.get(name)
58
+ if not body:
59
+ return ""
60
+ style = f"flex:0 0 auto;{extra_style}"
61
+ return (
62
+ f'<svg viewBox="0 0 256 256" width="{escape(size)}" height="{escape(size)}" '
63
+ f'fill="{escape(color)}" aria-hidden="true" focusable="false" '
64
+ f'style="{escape(style, quote=True)}">{body}</svg>'
65
+ )
vendor/bit-ui/bit_ui/markup.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Escaping helpers.
2
+
3
+ Named `markup` rather than `html` on purpose: a module called `html.py` at a
4
+ package root shadows Python's own stdlib `html` for the entire process the
5
+ moment that directory lands on `sys.path`. It broke pytest itself (pygments
6
+ imports `html`), and it would break just as silently inside a Space.
7
+
8
+ Small, but the single most important module here. Everything a Space renders
9
+ into its markup — model ids, licences, red-flag text, nav labels pulled from a
10
+ shared config file — is data from somewhere else, and it goes through one of
11
+ these two functions on the way in.
12
+
13
+ `e()` for element content, `a()` for attribute values. The difference is that
14
+ `a()` escapes quotes; forgetting it is how a value breaks out of an attribute
15
+ and becomes markup.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from html import escape
21
+
22
+ DASH = "—"
23
+
24
+
25
+ def e(value) -> str:
26
+ """Escape text for element content."""
27
+ return escape("" if value is None else str(value), quote=False)
28
+
29
+
30
+ def a(value) -> str:
31
+ """Escape text for an attribute value (quotes included)."""
32
+ return escape("" if value is None else str(value), quote=True)