Spaces:
Running on Zero
Running on Zero
CI deploy local
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +5 -0
- .gitignore +3 -0
- README.md +162 -8
- app.py +555 -0
- pytest.ini +8 -0
- requirements.txt +42 -0
- scripts/bootstrap_registry.py +175 -0
- scripts/daily_job.py +97 -0
- scripts/seed_trackrecord.py +190 -0
- src/__init__.py +0 -0
- src/adapters/__init__.py +117 -0
- src/adapters/base.py +409 -0
- src/adapters/baseline.py +214 -0
- src/adapters/chronos.py +183 -0
- src/adapters/kronos.py +296 -0
- src/adapters/timesfm.py +198 -0
- src/config.py +196 -0
- src/prices.py +171 -0
- src/runtime.py +308 -0
- src/store.py +400 -0
- src/trackrecord.py +241 -0
- src/ui/__init__.py +16 -0
- src/ui/chart.py +300 -0
- src/ui/chrome.py +158 -0
- src/ui/shell.py +1056 -0
- src/view.py +396 -0
- tests/__init__.py +0 -0
- tests/conftest.py +34 -0
- tests/fixture.py +33 -0
- tests/test_acceptance.py +426 -0
- tests/test_app.py +168 -0
- tests/test_kronos_vendor.py +88 -0
- tests/test_runtime.py +167 -0
- tests/test_ui.py +360 -0
- vendor/bit-ui/.gitignore +7 -0
- vendor/bit-ui/README.md +85 -0
- vendor/bit-ui/bit_ui/__init__.py +41 -0
- vendor/bit-ui/bit_ui/assets/fonts/MacMinecraft.ttf +3 -0
- vendor/bit-ui/bit_ui/assets/fonts/StyreneA-Light.otf +3 -0
- vendor/bit-ui/bit_ui/assets/fonts/StyreneA-Medium.otf +3 -0
- vendor/bit-ui/bit_ui/assets/fonts/StyreneA-Regular.otf +3 -0
- vendor/bit-ui/bit_ui/assets/fonts/StyreneA-Thin.otf +3 -0
- vendor/bit-ui/bit_ui/assets/tokens/base.css +33 -0
- vendor/bit-ui/bit_ui/assets/tokens/colors.css +125 -0
- vendor/bit-ui/bit_ui/assets/tokens/spacing.css +29 -0
- vendor/bit-ui/bit_ui/assets/tokens/typography.css +38 -0
- vendor/bit-ui/bit_ui/bridge.py +329 -0
- vendor/bit-ui/bit_ui/dialogs.py +311 -0
- vendor/bit-ui/bit_ui/icons.py +65 -0
- 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:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version:
|
| 8 |
-
python_version: '3.13'
|
| 9 |
app_file: app.py
|
| 10 |
-
pinned:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 "<script>" in html or "">" 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)
|