Bit-Trading-Company commited on
Commit
46f1a78
·
verified ·
1 Parent(s): 6dbc1b3

Backtest Lab v1.0.0

Browse files
.env.example ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ # Backtest Lab — required Space secrets (names only; never commit values)
2
+
3
+ # Write token for the bit-signal-store dataset repo. Set as a Space secret,
4
+ # server-side only. Used by store.py's CommitScheduler for coverage write-back.
5
+ HF_WRITE_TOKEN=
6
+
7
+ # Optional. Only needed to enable the Tiingo equity provider fallback.
8
+ # The provider chain works without it (yfinance -> Stooq).
9
+ TIINGO_KEY=
.gitattributes CHANGED
@@ -33,3 +33,12 @@ 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
+ assets/fonts/MacMinecraft.ttf filter=lfs diff=lfs merge=lfs -text
37
+ assets/fonts/StyreneA-Light.otf filter=lfs diff=lfs merge=lfs -text
38
+ assets/fonts/StyreneA-LightItalic.otf filter=lfs diff=lfs merge=lfs -text
39
+ assets/fonts/StyreneA-Medium.otf filter=lfs diff=lfs merge=lfs -text
40
+ assets/fonts/StyreneA-MediumItalic.otf filter=lfs diff=lfs merge=lfs -text
41
+ assets/fonts/StyreneA-Regular.otf filter=lfs diff=lfs merge=lfs -text
42
+ assets/fonts/StyreneA-RegularItalic.otf filter=lfs diff=lfs merge=lfs -text
43
+ assets/fonts/StyreneA-Thin.otf filter=lfs diff=lfs merge=lfs -text
44
+ assets/fonts/StyreneA-ThinItalic.otf filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ .env
4
+ .env.local
5
+ key.txt
6
+ .cache/
7
+ .hf_scheduler/
8
+ .pytest_cache/
DESIGN_NOTES.md ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Design reference — Backtest Lab
2
+
3
+ Extracted from `Bit Trading Portal Dashboard/Backtest Lab.dc.html` plus the Bit
4
+ design system (`_ds/bit-design-system-b707c3d1…`). This file is the contract the
5
+ Gradio UI is built against; `assets/tokens/*.css` and `assets/fonts/*` are
6
+ vendored verbatim from that design system.
7
+
8
+ ## Design system
9
+
10
+ Dark-first, terminal-flavoured. Tokens only — components never use raw colors.
11
+
12
+ | Role | Token | Value |
13
+ |---|---|---|
14
+ | Canvas | `--bg-canvas` | `#161512` (stone-950) |
15
+ | Panel | `--bg-panel` | `#1d1c18` (stone-900) |
16
+ | Raised | `--bg-raised` | `#24221d` (stone-850) |
17
+ | Border subtle / default | `--border-subtle` / `--border-default` | `#2c2a24` / `#3d3a32` |
18
+ | Text primary / secondary / tertiary | | `#f7f4ec` / `#b6b09a` / `#6f6a56` |
19
+ | Primary accent | `--accent-amber` / `-strong` | `#af9209` / `#cfab0a` |
20
+ | Secondary accent | `--accent-moss` / `-strong` | `#68781e` / `#7d901f` |
21
+ | Up / down | `--fin-up` / `--fin-down` | `oklch(66% .22 149)` / `oklch(62% .26 24)` |
22
+
23
+ Colorblind mode (`[data-colorblind=true]`) swaps up/down to blue/orange, and
24
+ direction is **also** marked with ▲/▼ glyphs — never color alone.
25
+
26
+ Fonts: headings `Styrene A` (400), body system sans (300), `JetBrains Mono` for
27
+ numbers, `Mac Minecraft` for the 8–10px pixel labels. Sizes are small:
28
+ `--text-2xs:8px … --text-base:12px … --text-4xl:62px`.
29
+
30
+ Radii are near-square (`--radius-sm:4px`, `--radius-md:8px`); panels are a 1px
31
+ border, no drop shadows (`--shadow-panel: 0 0 0 1px var(--border-default)`).
32
+
33
+ Section headings are ALL-CAPS Styrene with `--tracking-wide`; micro-labels are
34
+ uppercase Mac Minecraft with `--tracking-wider`.
35
+
36
+ ## Layout
37
+
38
+ Three zones; left `286px`, right `306px` (collapses to `46px`), center fluid.
39
+
40
+ - **Top bar** — BIT mark, `BIT / Backtest Lab`, context chip
41
+ (`BTC-USD · 1H · 2021-08-15 → 2026-08-15 · WALK-FORWARD`), run-status chip,
42
+ elapsed (`~4S`), amber **▶ Run backtest**, Display menu (Dark mode /
43
+ Colorblind-safe prices, footnote `UP/DOWN ALSO MARKED ▲ ▼`), `Portal ↗`.
44
+ - **Left — Strategy Builder** (`CFG #0142`), five numbered collapsible sections:
45
+ 1. Strategy — preset select + params
46
+ 2. Universe & Data — ticker chips (`+ ticker`), Asset class segmented
47
+ (Crypto/Equities/Both), Timeframe chips, Date range (`1Y 3Y 5Y Max`),
48
+ Regime filter (`Bull only / Bear only / Chop only`, `NEW` badge)
49
+ 3. Costs & Execution — Commission/side `0.10%`, Slippage `5 BPS`,
50
+ slippage model (`Fixed bps / Volume-scaled / Spread-based`),
51
+ fill (`Next bar open / Same bar close`), funding.
52
+ Warning: **"Costs on. Turning these off is how strategies lie to you."**
53
+ 4. Sizing & Risk — sizing (`Fixed % / Kelly fraction / Vol-target 15% ann.`),
54
+ leverage `1X…3X`, Max position `35%`, Max concurrent `2`
55
+ 5. Validation — `TRAIN 12MO · TEST 3MO · ROLL 3MO`, ■ TRAIN / ■ TEST bands,
56
+ OOS holdout `LAST 6MO`
57
+ Footer: `Save config`, `Share link`.
58
+ - **Center — Results Canvas**, tabs: `Overview · Trades · Comparison · Robustness · Report`
59
+ - **Right — Run Manager** — `RUNNING · {progress}`, Run history (`n/6 SELECTED`,
60
+ star = favourite, Sharpe color-coded), Trending public configs, `Clone config`,
61
+ `Metrics glossary`.
62
+ - **Footer** — always visible:
63
+ "Simulated results with modeled costs. Backtests are hypotheses, not promises.
64
+ Past performance does not predict future results. Not financial advice."
65
+ Right: `BITTRADING SDK 0.9.3`.
66
+
67
+ ## States
68
+
69
+ - **Empty**: "No run loaded" / "Configure a strategy on the left, or start from a
70
+ worked example and edit it." + `Load example: Sentiment-Gated Momentum · BTC 1h`
71
+ + shortcut hints `⌘↵ RUN`, `⌘S SAVE CONFIG`, `⌘K COMMANDS`.
72
+ - **Loading**: staged labels — `Fetching data`, `Simulating N trades`,
73
+ `Walk-forward window k/n`, `Computing robustness`.
74
+
75
+ ## Tab contents
76
+
77
+ **Overview** — stat band (each stat shows `IS x · OOS y`); `EQUITY CURVE`
78
+ (`STRATEGY VS BUY & HOLD`, log-scale toggle, drawdown shading, hatched `HOLDOUT`
79
+ band, `REGIME` strip); `UNDERWATER · MAX −23.6%`; `ROLLING 90D SHARPE · MEDIAN 1.24`;
80
+ price+trades chart (`▲ ENTRY · ▼ EXIT · HOLLOW = SHORT · HOVER A FLAG FOR THE TRADE CARD`);
81
+ `NET P&L DISTRIBUTION`, `HOLDING PERIOD (HOURS)`, `MAE / MFE SCATTER`;
82
+ `COSTS PAID TOTAL: $1,842` with the note that the costed number is the real one.
83
+
84
+ **Trades** — `312 TOTAL · SHOWING 14`, `Export CSV →`, sortable columns:
85
+ id, entry/exit time, side, entry/exit px, size, gross, costs, net, R, duration,
86
+ MAE, trigger.
87
+
88
+ **Comparison** — `Time-scale matrix` (`CELL = OOS SHARPE · CLICK TO LOAD RUN`,
89
+ scale −0.5 → 2.0), `n/6 SELECTED`, small-multiples equity grid, overlaid
90
+ cumulative return (`SHARED SCALE · OOS PERIOD SHADED`), metrics table
91
+ (rows: Total return, CAGR, OOS Sharpe, Max drawdown, Win rate, Trades; best
92
+ value amber+bold), `RETURN CORRELATION · ARE THESE THE SAME BET?`,
93
+ `RETURN ACROSS REGIMES · BULL / BEAR / CHOP`.
94
+
95
+ **Robustness** — `Overfit verdict` with grade + checklist; `PARAMETER SENSITIVITY ·
96
+ FAST MA × SLOW MA · OOS SHARPE` heatmap with `□ CHOSEN` marker;
97
+ `MONTE CARLO · 1,000 TRADE RESHUFFLES` cone with `P5 / P50 / P95` and
98
+ `P(RUIN > 30% DD)`; `WALK-FORWARD WINDOWS · OOS RETURN` bars with
99
+ `CONSISTENCY 6/8 POSITIVE`; `SLIPPAGE STRESS · SHARPE VS BPS`.
100
+
101
+ **Report** — prose summary, `EQUITY CURVE · STRATEGY VS BUY & HOLD`,
102
+ `CONFIG SNAPSHOT`, verdict line, actions: `Export PDF`, `Publish to leaderboard`,
103
+ `Publish to graveyard`, `Copy share link`, `Export trades CSV`, `Open in Colab`.
104
+
105
+ ## Presets (design list)
106
+
107
+ `SMA Crossover`, `RSI Mean Reversion`, `Bollinger Breakout`, `MACD Momentum`,
108
+ `Sentiment-Gated Momentum`, `Chronos Forecast Follower`, `Pairs Trading`,
109
+ `Buy & Hold (benchmark)`, `Custom (code)`.
110
+
111
+ `Custom (code)` is shown **disabled** — see DECISIONS.md (D-004). Executing
112
+ user-supplied strategy code is forbidden by the build spec, so the control is
113
+ present but inert with an explanation rather than silently removed.
114
+
115
+ ## Glossary copy (verbatim, used in the Run Manager tray)
116
+
117
+ - **SHARPE** — Annualized mean excess return divided by return volatility. Above 1 is good; above 3 usually means a bug.
118
+ - **SORTINO** — Sharpe with only downside deviation in the denominator.
119
+ - **MAX DRAWDOWN** — Worst peak-to-trough decline of the equity curve.
120
+ - **PROFIT FACTOR** — Gross profit over gross loss. Below 1.2 rarely survives real costs.
121
+ - **R-MULTIPLE** — Trade P&L expressed in units of initial risk.
122
+ - **MAE / MFE** — Worst and best unrealized excursion while the trade was open.
123
+ - **WALK-FORWARD** — Train on a rolling window, test on the next unseen window, repeat.
124
+ - **OOS** — Out of sample: data the parameters never saw during fitting.
README.md CHANGED
@@ -1,13 +1,177 @@
1
  ---
2
  title: Bit Backtest Lab
3
- emoji: 😻
4
  colorFrom: yellow
5
- colorTo: yellow
6
  sdk: gradio
7
- sdk_version: 6.24.0
8
- python_version: '3.13'
9
  app_file: app.py
10
- pinned: false
 
 
 
 
 
 
 
 
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: Bit Backtest Lab
3
+ emoji: 📉
4
  colorFrom: yellow
5
+ colorTo: gray
6
  sdk: gradio
7
+ sdk_version: 5.49.1
8
+ python_version: "3.11"
9
  app_file: app.py
10
+ pinned: true
11
+ license: apache-2.0
12
+ hf_oauth: true
13
+ hf_oauth_scopes:
14
+ - inference-api
15
+ short_description: Backtest forecasting models against honest costs
16
+ tags:
17
+ - finance
18
+ - backtesting
19
+ - time-series
20
+ - forecasting
21
  ---
22
 
23
+ # Backtest Lab
24
+
25
+ **The Bit Trading Company** · backtest and compare forecasting models and
26
+ rule-based trading strategies against a shared, precomputed signal store.
27
+
28
+ - **App**: https://huggingface.co/spaces/Bit-Trading-Company/bit-backtest-lab
29
+ - **Signal store**: https://huggingface.co/datasets/The-Bit-Trading-Company/bit-signal-store
30
+
31
+ ## What it is
32
+
33
+ Most backtesting tools make it easy to produce a beautiful, false result. This
34
+ one is built around the handful of things that actually decide whether a
35
+ backtest means anything: when you are allowed to trade, what it costs, and
36
+ whether you ever tested on data you had not already fitted.
37
+
38
+ The store holds **raw model outputs and prices only** — never trade decisions.
39
+ Trading rules, costs and sizing are applied live, per request, so many
40
+ strategies can be compared over the same forecasts without re-running inference.
41
+
42
+ ## Architecture
43
+
44
+ ```
45
+ ┌──────────────────────────────────────────────┐
46
+ │ bit-backtest-lab (Gradio Space) │
47
+ │ │
48
+ │ Strategy Builder ─┐ │
49
+ │ ├─► engine.py (vectorbt) │
50
+ │ Results Canvas ◄──┘ next-bar-open fills │
51
+ │ costs, stops, sizing │
52
+ │ walk-forward + holdout│
53
+ │ │ │
54
+ │ │ read-only ┌─────────────┐ │
55
+ │ └───────────────────►│ LRU cache │ │
56
+ │ └──────┬──────┘ │
57
+ │ "Extend coverage" │ │
58
+ │ └─► @spaces.GPU (user's quota) │ │
59
+ │ └─► CommitScheduler ──┐ │ │
60
+ └──────────────────────────────┼──────┼────────┘
61
+ ▼ │
62
+ ┌──────────────────────────────────────┐
63
+ │ bit-signal-store (Dataset) │
64
+ │ manifest.json coverage map │
65
+ │ signals/ raw q10/q50/q90 │
66
+ │ prices/ OHLCV cache + source │
67
+ │ comparisons/ precomputed tables │
68
+ │ runs/ saved run summaries │
69
+ └──────────────────────────────────────┘
70
+
71
+ │ batch refresh only
72
+ ┌────────────┴─────────────────────────┐
73
+ │ ccxt (Binance→Coinbase) │
74
+ │ yfinance → Stooq → Tiingo │
75
+ └──────────────────────────────────────┘
76
+ ```
77
+
78
+ ## How results are computed
79
+
80
+ **Fills.** Every fill executes at the **next bar's open**. A decision made at
81
+ bar `t` is shifted forward one bar by the engine and filled at `open[t+1]`.
82
+ There is no configuration that enters on the signal bar; `run_backtest` applies
83
+ the shift itself and a strategy cannot bypass it.
84
+
85
+ **No lookahead.** Strategies are checked by *perturbation*, not by convention:
86
+ the tail of the price series is scaled up and down, the indicator is re-run, and
87
+ every output before the perturbation point must be bit-identical. A strategy
88
+ that reads bar `t+1` — or normalises by a full-sample statistic, or uses a
89
+ centred rolling window — changes its earlier outputs and is caught.
90
+
91
+ **Costs.** On by default: commission per side plus a slippage model
92
+ (fixed bps or volume-scaled). Slippage is embedded in the fill price, so it is
93
+ reconstructed from the unslipped reference price and booked onto the trade row.
94
+ `gross − costs = net` holds exactly on every trade. Turning costs off is
95
+ possible and is labelled, loudly, as not real.
96
+
97
+ **Validation.** Simple split, walk-forward (configurable train/test/roll), and a
98
+ locked last-N-months holdout. Train and test never share a bar. The holdout is
99
+ excluded from `selectable_index()`, which is the only index any
100
+ parameter-selection path is given — it cannot be fitted on by accident. When a
101
+ configuration produces no out-of-sample period, the app says so instead of
102
+ printing `0.00`.
103
+
104
+ **Metrics.** Total return, CAGR, Sharpe, Sortino, max drawdown, win rate,
105
+ profit factor, exposure and trade count, each computed in-sample,
106
+ out-of-sample, and on the holdout. Every displayed number comes from
107
+ `metrics.py` operating on the equity curve or the trade list.
108
+
109
+ ## Honest limitations
110
+
111
+ - **Coverage is sparse.** The v1 seed covers 6 assets across 3 timeframes with
112
+ Chronos-Bolt small and base. Anything else needs extending.
113
+ - **Binance is geo-blocked from the seeding machine**, so crypto prices came
114
+ from Coinbase. Prices will not tick-match another venue.
115
+ - **Equity intraday history is provider-capped** — roughly 730 days of hourly
116
+ and 60 days of 15-minute bars. This is recorded in the manifest as a coverage
117
+ boundary, not hidden and not reported as an error.
118
+ - **Stooq began serving an HTML block page** instead of CSV during the build, so
119
+ the equity fallback chain effectively runs on yfinance alone right now.
120
+ - **Sentiment is a stub.** `Sentiment-Gated Momentum` runs against a labelled,
121
+ deterministic price-derived proxy, *not* news sentiment. It sits behind a
122
+ `SentimentSource` interface for a real feed later.
123
+ - **Pairs Trading and Custom (code)** appear in the design's preset list but are
124
+ not runnable. Custom-code strategies are disabled deliberately: this Space
125
+ never executes user-supplied code.
126
+ - **A backtest is a hypothesis.** Survivorship, regime change, liquidity and
127
+ your own future behaviour are not modelled.
128
+
129
+ ## Extending coverage
130
+
131
+ Anonymous visitors get full read and backtest access. Extending coverage
132
+ requires signing in with Hugging Face, because inference runs inside a
133
+ `@spaces.GPU` function on **your** ZeroGPU quota:
134
+
135
+ 1. Pick a model, asset, timeframe and range in the **Coverage** tab.
136
+ 2. **Estimate** shows the step count; the request is deduplicated against
137
+ `manifest.json`, so an already-covered range recomputes nothing.
138
+ 3. Per-request caps apply (2 years daily / 6 months hourly / 2 months 15m).
139
+ 4. On success the new slice, the manifest, and the regenerated comparison
140
+ tables are written back in a single atomic commit, attributed to you.
141
+
142
+ **Add model** takes an adapter family plus a Hub model id, resolves and pins the
143
+ revision, and runs a 100-step smoke test on your quota before the model appears
144
+ in the coverage map. Only allow-listed adapter families (`chronos`, `timesfm`)
145
+ can be constructed, and model ids are validated before they reach the Hub.
146
+
147
+ If your quota is exhausted the app says so and offers a duplicate-this-Space
148
+ link — the store is public, so a duplicate reads the same data.
149
+
150
+ ## Running locally
151
+
152
+ ```bash
153
+ uv venv --python 3.11 && uv pip install -r requirements.txt
154
+ python app.py
155
+ ```
156
+
157
+ Seeding your own store:
158
+
159
+ ```bash
160
+ python scripts/seed_store.py --plan v1 --dry-run
161
+ python scripts/seed_store.py --plan smoke --push
162
+ ```
163
+
164
+ Tests:
165
+
166
+ ```bash
167
+ bash tests/run_all.sh
168
+ ```
169
+
170
+ `HF_WRITE_TOKEN` is required only for write-back; reading and backtesting work
171
+ without any token. See `.env.example`.
172
+
173
+ ## Disclaimer
174
+
175
+ Backtested results are hypothetical, derived from historical data, and are not
176
+ indicative of future results. Nothing here is investment advice. The Bit Trading
177
+ Company is not a licensed investment adviser.
app.py ADDED
@@ -0,0 +1,871 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Bit Trading Company — Backtest Lab.
2
+
3
+ Gradio Blocks implementation of the Backtest Lab design: Strategy Builder on
4
+ the left, tabbed Results Canvas in the centre, Run Manager on the right, with
5
+ the disclaimer pinned to the footer.
6
+
7
+ The app reads exclusively from the cached signal store. The only path that can
8
+ reach an external provider is the batch refresh in `scripts/seed_store.py`, and
9
+ the only path that runs inference is the ZeroGPU extension flow in
10
+ `src/extension.py`, which spends the signed-in user's own quota.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ import os
17
+ from dataclasses import asdict
18
+
19
+ import gradio as gr
20
+ import pandas as pd
21
+
22
+ from src import charts, comparisons, config, runtime, strategies
23
+ from src.runtime import RunError, RunRecord, RunRequest
24
+ from src.ui import theme
25
+
26
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(message)s")
27
+ log = logging.getLogger("bit.app")
28
+
29
+ MAX_COMPARE = 6
30
+ GLOSSARY = [
31
+ ("SHARPE", "Annualized mean excess return divided by return volatility. "
32
+ "Above 1 is good; above 3 usually means a bug."),
33
+ ("SORTINO", "Sharpe with only downside deviation in the denominator."),
34
+ ("MAX DRAWDOWN", "Worst peak-to-trough decline of the equity curve."),
35
+ ("PROFIT FACTOR", "Gross profit over gross loss. Below 1.2 rarely survives real costs."),
36
+ ("R-MULTIPLE", "Trade P&L expressed in units of initial risk."),
37
+ ("MAE / MFE", "Worst and best unrealized excursion while the trade was open."),
38
+ ("WALK-FORWARD", "Train on a rolling window, test on the next unseen window, repeat."),
39
+ ("OOS", "Out of sample: data the parameters never saw during fitting."),
40
+ ]
41
+
42
+
43
+ # --------------------------------------------------------------------------
44
+ # Formatting helpers
45
+ # --------------------------------------------------------------------------
46
+
47
+
48
+ def pct(v, digits=1, signed=True) -> str:
49
+ if v is None or pd.isna(v):
50
+ return "—"
51
+ return f"{v * 100:+.{digits}f}%" if signed else f"{v * 100:.{digits}f}%"
52
+
53
+
54
+ def num(v, digits=2) -> str:
55
+ if v is None or pd.isna(v):
56
+ return "—"
57
+ return f"{v:.{digits}f}"
58
+
59
+
60
+ def money(v) -> str:
61
+ if v is None or pd.isna(v):
62
+ return "—"
63
+ return f"${v:,.0f}"
64
+
65
+
66
+ def _cls(v) -> str:
67
+ if v is None or pd.isna(v) or v == 0:
68
+ return ""
69
+ return "bit-up" if v > 0 else "bit-down"
70
+
71
+
72
+ def _arrow(v) -> str:
73
+ if v is None or pd.isna(v) or v == 0:
74
+ return ""
75
+ return " ▲" if v > 0 else " ▼"
76
+
77
+
78
+ def seg(metrics, fmt, *args, **kwargs) -> str:
79
+ """Format a segment metric, or an em dash when that segment has no bars.
80
+
81
+ A segment with no data must never render as 0.00 -- "the out-of-sample
82
+ Sharpe is zero" and "there is no out-of-sample period" are different claims,
83
+ and only one of them is true here.
84
+ """
85
+ if metrics is None or metrics.bars == 0:
86
+ return "—"
87
+ return fmt(*args, **kwargs)
88
+
89
+
90
+ def stat_band_html(rec: RunRecord | None) -> str:
91
+ """The stat band. Every stat carries its IS and OOS split, per the design."""
92
+ if rec is None:
93
+ return ""
94
+ r = rec.result
95
+ a, i, o = r.metrics_all, r.metrics_is, r.metrics_oos
96
+
97
+ def isoos(fmt, ikey, okey, *fargs):
98
+ iv = seg(i, fmt, getattr(i, ikey), *fargs)
99
+ ov = seg(o, fmt, getattr(o, okey), *fargs)
100
+ return f"IS {iv} · OOS {ov}"
101
+ bench_gap = a.total_return - (
102
+ float(r.benchmark_equity.iloc[-1] / r.benchmark_equity.iloc[0] - 1.0)
103
+ if len(r.benchmark_equity) else 0.0
104
+ )
105
+
106
+ cells = [
107
+ ("Total return", f"{pct(a.total_return)}{_arrow(a.total_return)}", _cls(a.total_return),
108
+ isoos(pct, "total_return", "total_return"),
109
+ "Cumulative return of the strategy equity curve, costs included."),
110
+ ("CAGR", pct(a.cagr), _cls(a.cagr), isoos(pct, "cagr", "cagr"),
111
+ "Compound annual growth rate implied by the equity curve."),
112
+ ("Sharpe", num(a.sharpe), _cls(a.sharpe),
113
+ isoos(num, "sharpe", "sharpe"), GLOSSARY[0][1]),
114
+ ("Sortino", num(a.sortino), _cls(a.sortino),
115
+ isoos(num, "sortino", "sortino"), GLOSSARY[1][1]),
116
+ ("Max drawdown", pct(a.max_drawdown), "bit-down",
117
+ isoos(pct, "max_drawdown", "max_drawdown"), GLOSSARY[2][1]),
118
+ ("Win rate", pct(a.win_rate, 0, signed=False), "",
119
+ f"IS {seg(i, pct, i.win_rate, 0, False)} · OOS {seg(o, pct, o.win_rate, 0, False)}",
120
+ "Share of closed trades with positive net P&L."),
121
+ ("Profit factor", num(a.profit_factor), _cls(a.profit_factor - 1.0),
122
+ isoos(num, "profit_factor", "profit_factor"), GLOSSARY[3][1]),
123
+ ("Trades", f"{a.trade_count}", "",
124
+ f"IS {seg(i, str, i.trade_count)} · OOS {seg(o, str, o.trade_count)}",
125
+ "Closed round-trip trades in the period."),
126
+ ("Exposure", pct(a.exposure, 0, signed=False), "",
127
+ f"IS {seg(i, pct, i.exposure, 0, False)} · OOS {seg(o, pct, o.exposure, 0, False)}",
128
+ "Fraction of bars holding a position."),
129
+ ("vs buy & hold", f"{pct(bench_gap)}", _cls(bench_gap),
130
+ f"costs paid {money(r.costs_paid)}",
131
+ "Strategy return minus buy-and-hold return over the same window."),
132
+ ]
133
+ html = ['<div class="bit-statband">']
134
+ for label, value, cls, sub, tip in cells:
135
+ html.append(
136
+ f'<div class="bit-stat" title="{tip}">'
137
+ f'<div class="bit-stat-label">{label}</div>'
138
+ f'<div class="bit-stat-value {cls}">{value}</div>'
139
+ f'<div class="bit-stat-sub">{sub}</div></div>'
140
+ )
141
+ html.append("</div>")
142
+ for note in getattr(r.plan, "notes", []):
143
+ html.append(f'<div class="bit-note bit-note-danger">{note}</div>')
144
+ if rec.result.metrics_holdout is not None:
145
+ h = rec.result.metrics_holdout
146
+ html.append(
147
+ f'<div class="bit-note">LOCKED HOLDOUT · return {pct(h.total_return)} · '
148
+ f'Sharpe {num(h.sharpe)} · {h.bars} bars never used for any parameter choice.</div>'
149
+ )
150
+ return "".join(html)
151
+
152
+
153
+ def context_chip(req: RunRequest) -> str:
154
+ mode = {"walk_forward": "WALK-FORWARD", "holdout": "HOLDOUT",
155
+ "split": "SPLIT", "none": "NO SPLIT"}.get(req.validation_mode, "")
156
+ try:
157
+ s, e = runtime.window_for(req.asset, req.timeframe, req.date_range)
158
+ span = f"{s.date()} → {e.date()}"
159
+ except Exception:
160
+ span = req.date_range
161
+ return (f'<span class="bit-chip">{req.asset} · {req.timeframe.upper()} · '
162
+ f'{span} · {mode}</span>')
163
+
164
+
165
+ def top_bar_html(status_text="NO RUN LOADED", status_cls="bit-chip", chip="") -> str:
166
+ return f"""
167
+ <div class="bit-topbar">
168
+ <span class="bit-mark"></span>
169
+ <span class="bit-h1">BIT</span>
170
+ <span style="color:var(--text-tertiary)">/</span>
171
+ <span class="bit-h1">Backtest Lab</span>
172
+ {chip}
173
+ <span style="flex:1"></span>
174
+ <span class="{status_cls}">{status_text}</span>
175
+ <a class="bit-chip" href="https://huggingface.co/datasets/{config.STORE_REPO}"
176
+ target="_blank" rel="noopener">SIGNAL STORE ↗</a>
177
+ </div>"""
178
+
179
+
180
+ FOOTER_HTML = f"""
181
+ <div class="bit-footer">
182
+ <span>{config.DISCLAIMER}</span>
183
+ <span style="white-space:nowrap">BITTRADING BACKTEST LAB v1.0.0</span>
184
+ </div>"""
185
+
186
+
187
+ EMPTY_HTML = """
188
+ <div class="bit-empty">
189
+ <div class="bit-h2">No run loaded</div>
190
+ <div style="color:var(--text-secondary);max-width:46ch">
191
+ Configure a strategy on the left, or start from a worked example and edit it.
192
+ </div>
193
+ <div style="display:flex;gap:8px;margin-top:8px">
194
+ <span class="bit-kbd">⌘↵ RUN</span>
195
+ <span class="bit-kbd">⌘S SAVE CONFIG</span>
196
+ <span class="bit-kbd">COSTS DEFAULT ON</span>
197
+ </div>
198
+ </div>"""
199
+
200
+
201
+ def glossary_html() -> str:
202
+ rows = "".join(
203
+ f'<div style="margin-bottom:8px"><div class="bit-micro">{t}</div>'
204
+ f'<div style="font-size:11px;color:var(--text-secondary)">{d}</div></div>'
205
+ for t, d in GLOSSARY
206
+ )
207
+ return f'<div class="bit-panel">{rows}</div>'
208
+
209
+
210
+ def trades_frame(rec: RunRecord | None) -> pd.DataFrame:
211
+ cols = ["#", "Entry", "Exit", "Side", "Entry px", "Exit px", "Size",
212
+ "Gross", "Costs", "Net", "R", "Bars", "MAE", "Segment", "Trigger"]
213
+ if rec is None or rec.result.trades.empty:
214
+ return pd.DataFrame(columns=cols)
215
+ t = rec.result.trades
216
+ return pd.DataFrame({
217
+ "#": t["id"],
218
+ "Entry": t["entry_ts"].dt.strftime("%Y-%m-%d %H:%M"),
219
+ "Exit": t["exit_ts"].dt.strftime("%Y-%m-%d %H:%M"),
220
+ "Side": t["side"].str.upper(),
221
+ "Entry px": t["entry_px"].round(2),
222
+ "Exit px": t["exit_px"].round(2),
223
+ "Size": t["size"].round(4),
224
+ "Gross": t["gross_pnl"].round(2),
225
+ "Costs": t["costs"].round(2),
226
+ "Net": t["net_pnl"].round(2),
227
+ "R": t["r_multiple"].round(2),
228
+ "Bars": t["duration_bars"],
229
+ "MAE": (t["mae"] * 100).round(1),
230
+ "Segment": t["segment"],
231
+ "Trigger": t["trigger"],
232
+ })
233
+
234
+
235
+ def report_markdown(rec: RunRecord | None) -> str:
236
+ if rec is None:
237
+ return "_Run a backtest to generate the report._"
238
+ r, req = rec.result, rec.request
239
+ a, o, h = r.metrics_all, r.metrics_oos, r.metrics_holdout
240
+ bench = float(r.benchmark_equity.iloc[-1] / r.benchmark_equity.iloc[0] - 1.0) \
241
+ if len(r.benchmark_equity) else 0.0
242
+ ratio = (o.sharpe / r.metrics_is.sharpe) if r.metrics_is.sharpe else float("nan")
243
+ grade, checks = runtime.overfit_verdict(rec)
244
+
245
+ lines = [
246
+ f"### {rec.label}",
247
+ f"`RUN {rec.run_id} · {rec.created_at} · "
248
+ f"{req.validation_mode.upper()} · COSTS {'ON' if req.costs_on else 'OFF'}`",
249
+ "",
250
+ f"Over {a.bars} bars and {a.trade_count} trades the strategy returns "
251
+ f"**{pct(a.total_return)}** (CAGR {pct(a.cagr)}, Sharpe {num(a.sharpe)}) "
252
+ f"against **{pct(bench)}** for buy and hold, with a maximum drawdown of "
253
+ f"{pct(a.max_drawdown)}. Modelled costs of {money(r.costs_paid)} are already "
254
+ f"deducted — the costed number is the real one.",
255
+ "",
256
+ (f"Out-of-sample Sharpe is {num(o.sharpe)}, which is {num(ratio)} of the "
257
+ f"in-sample figure." if o.bars else
258
+ "**No out-of-sample period was produced for this configuration**, so every "
259
+ "number above is in-sample. Widen the date range or shorten the training "
260
+ "window before reading anything into it.")
261
+ + (f" On the locked holdout — {h.bars} bars that no parameter choice ever "
262
+ f"touched — it returns {pct(h.total_return)} at Sharpe {num(h.sharpe)}."
263
+ if h else ""),
264
+ "",
265
+ f"**Verdict: {grade}**",
266
+ "",
267
+ ]
268
+ lines += [f"- {mark} {text}" for mark, text in checks]
269
+ lines += ["", "#### Config snapshot", "```json",
270
+ _pretty_config(req), "```"]
271
+ return "\n".join(lines)
272
+
273
+
274
+ def _pretty_config(req: RunRequest) -> str:
275
+ import json
276
+ return json.dumps(asdict(req), indent=2, sort_keys=True)
277
+
278
+
279
+ # --------------------------------------------------------------------------
280
+ # Handlers
281
+ # --------------------------------------------------------------------------
282
+
283
+
284
+ def collect_request(strategy, asset, timeframe, date_range, model_slug,
285
+ p1, p2, p3, costs_on, commission_bps, slippage_bps,
286
+ slippage_model, sizing_mode, size_pct, leverage,
287
+ sl_pct, tp_pct, trail_pct,
288
+ validation_mode, train_m, test_m, roll_m, holdout_m) -> RunRequest:
289
+ preset = strategies.PRESETS.get(strategy)
290
+ params = {}
291
+ if preset:
292
+ for (key, _label, _d, _lo, _hi), value in zip(preset.params, (p1, p2, p3)):
293
+ if value is not None:
294
+ params[key] = value
295
+ return RunRequest(
296
+ strategy=strategy, asset=asset, timeframe=timeframe, date_range=date_range,
297
+ model_slug=model_slug or "", params=params,
298
+ costs_on=bool(costs_on), commission_bps=float(commission_bps),
299
+ slippage_bps=float(slippage_bps),
300
+ slippage_model="volume_scaled" if slippage_model == "Volume-scaled" else "fixed",
301
+ sizing_mode={"Fixed %": "fixed_pct", "Vol-target 15% ann.": "vol_target"}.get(
302
+ sizing_mode, "fixed_pct"),
303
+ size_pct=float(size_pct), leverage=float(leverage),
304
+ sl_pct=(float(sl_pct) / 100.0 if sl_pct else None),
305
+ tp_pct=(float(tp_pct) / 100.0 if tp_pct else None),
306
+ trail_pct=(float(trail_pct) / 100.0 if trail_pct else None),
307
+ validation_mode={"Walk-forward": "walk_forward", "Simple split": "split",
308
+ "Holdout only": "holdout", "None": "none"}.get(
309
+ validation_mode, "walk_forward"),
310
+ train_months=int(train_m), test_months=int(test_m), roll_months=int(roll_m),
311
+ holdout_months=int(holdout_m),
312
+ )
313
+
314
+
315
+ def build_app() -> gr.Blocks:
316
+ css = theme.full_css()
317
+
318
+ with gr.Blocks(theme=theme.bit_theme(), css=css, title="Bit · Backtest Lab",
319
+ analytics_enabled=False, fill_height=True) as demo:
320
+
321
+ history = gr.State([]) # list[RunRecord]
322
+ selected = gr.State([]) # run_ids chosen for comparison
323
+ current = gr.State(None) # RunRecord
324
+
325
+ top_bar = gr.HTML(top_bar_html())
326
+
327
+ with gr.Row(equal_height=False):
328
+ # ============================ LEFT ============================
329
+ with gr.Column(scale=2, min_width=280):
330
+ gr.HTML('<div class="bit-h2" style="padding:12px 4px 4px">Strategy Builder</div>')
331
+
332
+ with gr.Accordion("1 · STRATEGY", open=True, elem_classes="bit-accordion"):
333
+ strategy = gr.Dropdown(
334
+ choices=[p.name for p in strategies.PRESETS.values()],
335
+ value="SMA Crossover", label="Preset", interactive=True,
336
+ )
337
+ preset_note = gr.HTML("")
338
+ p1 = gr.Number(label="Fast MA", value=20, precision=4)
339
+ p2 = gr.Number(label="Slow MA", value=50, precision=4)
340
+ p3 = gr.Number(label="—", value=None, visible=False, precision=4)
341
+ model_slug = gr.Dropdown(
342
+ choices=runtime.available_models(), value=None,
343
+ label="Forecast model", visible=False, interactive=True,
344
+ )
345
+
346
+ with gr.Accordion("2 · UNIVERSE & DATA", open=True, elem_classes="bit-accordion"):
347
+ asset = gr.Dropdown(choices=runtime.available_assets(),
348
+ value="BTC-USD", label="Asset")
349
+ timeframe = gr.Radio(choices=list(config.TIMEFRAMES),
350
+ value="1d", label="Timeframe")
351
+ date_range = gr.Radio(choices=["1Y", "3Y", "5Y", "Max"],
352
+ value="3Y", label="Date range")
353
+ coverage_note = gr.HTML("")
354
+
355
+ with gr.Accordion("3 · COSTS & EXECUTION", open=False, elem_classes="bit-accordion"):
356
+ costs_on = gr.Checkbox(value=True, label="Costs on")
357
+ gr.HTML('<div class="bit-note">Costs on. Turning these off is how '
358
+ 'strategies lie to you.</div>')
359
+ commission_bps = gr.Number(value=10.0, label="Commission bps / side")
360
+ slippage_bps = gr.Number(value=5.0, label="Slippage bps")
361
+ slippage_model = gr.Radio(choices=["Fixed bps", "Volume-scaled"],
362
+ value="Fixed bps", label="Slippage model")
363
+ gr.Radio(choices=["Next bar open"], value="Next bar open",
364
+ label="Fill", interactive=False,
365
+ info="Next-bar-open execution is enforced by the engine.")
366
+
367
+ with gr.Accordion("4 · SIZING & RISK", open=False, elem_classes="bit-accordion"):
368
+ sizing_mode = gr.Radio(choices=["Fixed %", "Vol-target 15% ann."],
369
+ value="Fixed %", label="Sizing")
370
+ size_pct = gr.Slider(0.05, 1.0, value=1.0, step=0.05,
371
+ label="Position size (fraction of equity)")
372
+ leverage = gr.Slider(1.0, 3.0, value=1.0, step=0.5, label="Leverage")
373
+ sl_pct = gr.Number(value=None, label="Stop loss %")
374
+ tp_pct = gr.Number(value=None, label="Take profit %")
375
+ trail_pct = gr.Number(value=None, label="Trailing stop %")
376
+
377
+ with gr.Accordion("5 · VALIDATION", open=False, elem_classes="bit-accordion"):
378
+ validation_mode = gr.Radio(
379
+ choices=["Walk-forward", "Simple split", "Holdout only", "None"],
380
+ value="Walk-forward", label="Mode")
381
+ train_m = gr.Number(value=12, label="Train months", precision=0)
382
+ test_m = gr.Number(value=3, label="Test months", precision=0)
383
+ roll_m = gr.Number(value=3, label="Roll months", precision=0)
384
+ holdout_m = gr.Number(value=6, label="OOS holdout months", precision=0)
385
+
386
+ run_btn = gr.Button("▶ Run backtest", variant="primary",
387
+ elem_classes="bit-run-btn")
388
+ with gr.Row():
389
+ example_btn = gr.Button("Load example", size="sm",
390
+ elem_classes="bit-ghost-btn")
391
+ share_btn = gr.Button("Share link", size="sm",
392
+ elem_classes="bit-ghost-btn")
393
+ share_out = gr.Textbox(label="Share token", visible=False,
394
+ show_copy_button=True, lines=2)
395
+
396
+ # =========================== CENTER ===========================
397
+ with gr.Column(scale=7, min_width=520):
398
+ run_status = gr.HTML("")
399
+ stat_band = gr.HTML("")
400
+ empty_state = gr.HTML(EMPTY_HTML)
401
+
402
+ with gr.Tabs():
403
+ with gr.Tab("Overview"):
404
+ equity_plot = gr.Plot(label=None)
405
+ with gr.Row():
406
+ log_scale = gr.Checkbox(value=False, label="Log scale")
407
+ cvd = gr.Checkbox(value=False, label="Colorblind-safe prices")
408
+ regime_plot = gr.Plot(label=None)
409
+ with gr.Row():
410
+ underwater_plot = gr.Plot(label=None)
411
+ rolling_plot = gr.Plot(label=None)
412
+ price_plot = gr.Plot(label=None)
413
+ with gr.Row():
414
+ pnl_plot = gr.Plot(label=None)
415
+ hold_plot = gr.Plot(label=None)
416
+ mae_plot = gr.Plot(label=None)
417
+ costs_note = gr.HTML("")
418
+
419
+ with gr.Tab("Trades"):
420
+ trades_head = gr.HTML("")
421
+ trades_table = gr.Dataframe(
422
+ value=pd.DataFrame(), interactive=False, wrap=False,
423
+ elem_classes="bit-table", max_height=520,
424
+ )
425
+ export_btn = gr.Button("Export CSV →", size="sm",
426
+ elem_classes="bit-ghost-btn")
427
+ export_file = gr.File(label="trades.csv", visible=False)
428
+
429
+ with gr.Tab("Comparison"):
430
+ gr.HTML('<div class="bit-micro">CELL = OOS SHARPE · '
431
+ 'PRECOMPUTED FROM THE SIGNAL STORE</div>')
432
+ heatmap_plot = gr.Plot(label=None)
433
+ gr.HTML('<div class="bit-micro">SELECTED RUNS · '
434
+ f'MAX {MAX_COMPARE}</div>')
435
+ compare_picker = gr.CheckboxGroup(choices=[], value=[],
436
+ label="Runs to compare")
437
+ overlay_plot = gr.Plot(label=None)
438
+ small_mult_plot = gr.Plot(label=None)
439
+ with gr.Row():
440
+ corr_plot = gr.Plot(label=None)
441
+ regime_bars_plot = gr.Plot(label=None)
442
+ metrics_table = gr.Dataframe(value=pd.DataFrame(),
443
+ interactive=False,
444
+ elem_classes="bit-table")
445
+
446
+ with gr.Tab("Robustness"):
447
+ verdict_html = gr.HTML("")
448
+ with gr.Row():
449
+ wf_plot = gr.Plot(label=None)
450
+ mc_plot = gr.Plot(label=None)
451
+ with gr.Row():
452
+ sens_plot = gr.Plot(label=None)
453
+ slip_plot = gr.Plot(label=None)
454
+ robust_btn = gr.Button(
455
+ "Run sensitivity + slippage stress (slower)",
456
+ size="sm", elem_classes="bit-ghost-btn")
457
+
458
+ with gr.Tab("Report"):
459
+ report_md = gr.Markdown("_Run a backtest to generate the report._")
460
+ report_equity = gr.Plot(label=None)
461
+ with gr.Row():
462
+ save_run_btn = gr.Button("Save run summary", size="sm",
463
+ elem_classes="bit-ghost-btn")
464
+ copy_cfg_btn = gr.Button("Copy share link", size="sm",
465
+ elem_classes="bit-ghost-btn")
466
+ save_note = gr.HTML("")
467
+
468
+ with gr.Tab("Coverage"):
469
+ gr.HTML('<div class="bit-micro">SIGNAL STORE COVERAGE MAP · '
470
+ 'MODEL × ASSET × TIMEFRAME</div>')
471
+ coverage_table = gr.Dataframe(
472
+ value=runtime.coverage_frame(), interactive=False,
473
+ elem_classes="bit-table", max_height=420)
474
+ gr.HTML('<div class="bit-micro" style="margin-top:8px">'
475
+ 'EXTEND COVERAGE</div>')
476
+ extend_panel = gr.HTML("")
477
+ with gr.Row():
478
+ ext_model = gr.Dropdown(choices=list(config.SEED_MODELS),
479
+ label="Model", scale=2)
480
+ ext_asset = gr.Dropdown(choices=list(config.ASSETS),
481
+ label="Asset", scale=2)
482
+ ext_tf = gr.Dropdown(choices=list(config.TIMEFRAMES),
483
+ value="1d", label="Timeframe", scale=1)
484
+ with gr.Row():
485
+ ext_start = gr.Textbox(label="Start (YYYY-MM-DD)", scale=2)
486
+ ext_end = gr.Textbox(label="End (YYYY-MM-DD)", scale=2)
487
+ with gr.Row():
488
+ estimate_btn = gr.Button("Estimate", size="sm",
489
+ elem_classes="bit-ghost-btn")
490
+ extend_btn = gr.Button("Extend coverage", size="sm",
491
+ elem_classes="bit-run-btn")
492
+ extend_out = gr.HTML("")
493
+
494
+ gr.HTML('<div class="bit-micro" style="margin-top:16px">'
495
+ 'ADD MODEL</div>')
496
+ with gr.Row():
497
+ add_family = gr.Dropdown(
498
+ choices=list(config.ALLOWED_ADAPTER_FAMILIES),
499
+ value="chronos", label="Adapter family", scale=1)
500
+ add_model_id = gr.Textbox(label="HF model id (owner/name)",
501
+ scale=2)
502
+ add_btn = gr.Button("Smoke test & add", size="sm",
503
+ elem_classes="bit-ghost-btn", scale=1)
504
+ add_out = gr.HTML("")
505
+
506
+ # ============================ RIGHT ===========================
507
+ with gr.Column(scale=2, min_width=250):
508
+ gr.HTML('<div class="bit-h2" style="padding:12px 4px 4px">Run Manager</div>')
509
+ login_slot = gr.HTML("")
510
+ try:
511
+ gr.LoginButton(value="Sign in with Hugging Face", size="sm")
512
+ except Exception:
513
+ login_slot.value = (
514
+ '<div class="bit-note">Sign-in appears when the Space runs '
515
+ 'on Hugging Face with OAuth enabled.</div>')
516
+ history_html = gr.HTML(
517
+ '<div class="bit-micro">NO RUNS YET IN THIS SESSION</div>')
518
+ with gr.Accordion("METRICS GLOSSARY", open=False,
519
+ elem_classes="bit-accordion"):
520
+ gr.HTML(glossary_html())
521
+ with gr.Accordion("HOW RESULTS ARE COMPUTED", open=False,
522
+ elem_classes="bit-accordion"):
523
+ gr.Markdown(
524
+ "- Fills execute at the **next bar's open**. A decision at bar "
525
+ "`t` can never trade at bar `t`.\n"
526
+ "- Strategies are checked for lookahead by perturbing future "
527
+ "prices and asserting past outputs do not move.\n"
528
+ "- Costs are **on by default**: commission per side plus a "
529
+ "slippage model, both booked onto every trade row.\n"
530
+ "- The locked holdout is excluded from every parameter-selection "
531
+ "path, not merely reported separately.\n"
532
+ "- The store holds raw model outputs only. Trading rules are "
533
+ "applied live, per run."
534
+ )
535
+
536
+ gr.HTML(FOOTER_HTML)
537
+
538
+ # ------------------------------------------------------------------
539
+ # Wiring
540
+ # ------------------------------------------------------------------
541
+
542
+ builder_inputs = [strategy, asset, timeframe, date_range, model_slug,
543
+ p1, p2, p3, costs_on, commission_bps, slippage_bps,
544
+ slippage_model, sizing_mode, size_pct, leverage,
545
+ sl_pct, tp_pct, trail_pct,
546
+ validation_mode, train_m, test_m, roll_m, holdout_m]
547
+
548
+ overview_outputs = [equity_plot, regime_plot, underwater_plot, rolling_plot,
549
+ price_plot, pnl_plot, hold_plot, mae_plot, costs_note]
550
+
551
+ def on_strategy_change(name):
552
+ preset = strategies.PRESETS.get(name)
553
+ if preset is None:
554
+ return (gr.update(), gr.update(), gr.update(), gr.update(), "")
555
+ if not preset.available:
556
+ note = (f'<div class="bit-note bit-note-danger">{name} is unavailable. '
557
+ f'{preset.unavailable_reason}</div>')
558
+ else:
559
+ note = ""
560
+ ups = []
561
+ for i in range(3):
562
+ if i < len(preset.params):
563
+ key, label, default, lo, hi = preset.params[i]
564
+ ups.append(gr.update(label=label, value=default, visible=True))
565
+ else:
566
+ ups.append(gr.update(visible=False, value=None))
567
+ model_up = gr.update(visible=preset.needs_signals,
568
+ choices=runtime.available_models(),
569
+ value=(runtime.available_models() or [None])[0]
570
+ if preset.needs_signals else None)
571
+ return (*ups, model_up, note)
572
+
573
+ strategy.change(on_strategy_change, [strategy], [p1, p2, p3, model_slug, preset_note])
574
+
575
+ def on_universe_change(a, tf):
576
+ cov = runtime.price_coverage_for(a, tf)
577
+ models = runtime.available_models(a, tf)
578
+ if cov is None:
579
+ html = ('<div class="bit-note bit-note-danger">No cached price coverage '
580
+ f'for {a} {tf}. Pick another pair or extend coverage.</div>')
581
+ else:
582
+ html = (f'<div class="bit-micro">CACHED {cov[0]} → {cov[1]}'
583
+ + (f' · MODELS: {", ".join(models)}' if models else
584
+ ' · NO MODEL SIGNALS') + '</div>')
585
+ return html, gr.update(choices=models,
586
+ value=(models[0] if models else None))
587
+
588
+ asset.change(on_universe_change, [asset, timeframe], [coverage_note, model_slug])
589
+ timeframe.change(on_universe_change, [asset, timeframe], [coverage_note, model_slug])
590
+
591
+ def do_run(hist, *vals, progress=gr.Progress()):
592
+ progress(0.05, desc="Reading cached slices")
593
+ req = collect_request(*vals)
594
+ try:
595
+ progress(0.35, desc="Simulating trades")
596
+ rec = runtime.execute(req)
597
+ except (RunError, ValueError) as e:
598
+ err = f'<div class="bit-note bit-note-danger">{e}</div>'
599
+ return (hist, None, top_bar_html("RUN FAILED", "bit-chip bit-chip-warn"),
600
+ err, "", gr.update(visible=True), *(gr.update(),) * 9,
601
+ "", pd.DataFrame(), "", gr.update(choices=[], value=[]),
602
+ "_Run failed._", None)
603
+
604
+ progress(0.75, desc="Building charts")
605
+ hist = ([rec] + list(hist))[:40]
606
+ figs = build_overview(rec, log_scale=False, cvd=False)
607
+ progress(0.95, desc="Computing robustness")
608
+
609
+ choices = [f"{r.run_id} · {r.label}" for r in hist]
610
+ return (
611
+ hist, rec,
612
+ top_bar_html(f"RUN {rec.run_id} COMPLETE · {rec.elapsed_s:.1f}S",
613
+ "bit-chip bit-chip-ok", context_chip(req)),
614
+ "", stat_band_html(rec), gr.update(visible=False),
615
+ *figs,
616
+ trades_head_html(rec), trades_frame(rec),
617
+ history_list_html(hist),
618
+ gr.update(choices=choices, value=choices[:1]),
619
+ report_markdown(rec),
620
+ charts.equity_curve(rec.result.equity, rec.result.benchmark_equity,
621
+ plan=rec.result.plan),
622
+ )
623
+
624
+ run_outputs = [history, current, top_bar, run_status, stat_band, empty_state,
625
+ *overview_outputs, trades_head, trades_table, history_html,
626
+ compare_picker, report_md, report_equity]
627
+
628
+ run_btn.click(do_run, [history, *builder_inputs], run_outputs)
629
+
630
+ def load_example():
631
+ """The design's worked example: Sentiment-Gated Momentum on BTC 1h."""
632
+ return ("Sentiment-Gated Momentum", "BTC-USD", "1h", "1Y",
633
+ gr.update(value=20, label="Fast MA", visible=True),
634
+ gr.update(value=50, label="Slow MA", visible=True),
635
+ gr.update(value=0.40, label="Sentiment gate", visible=True),
636
+ True, "Walk-forward")
637
+
638
+ example_btn.click(
639
+ load_example, None,
640
+ [strategy, asset, timeframe, date_range, p1, p2, p3, costs_on, validation_mode],
641
+ ).then(do_run, [history, *builder_inputs], run_outputs)
642
+
643
+ def replot(rec, log_s, colorblind):
644
+ if rec is None:
645
+ return (gr.update(),) * 9
646
+ return build_overview(rec, log_scale=log_s, cvd=colorblind)
647
+
648
+ log_scale.change(replot, [current, log_scale, cvd], overview_outputs)
649
+ cvd.change(replot, [current, log_scale, cvd], overview_outputs)
650
+
651
+ def do_share(*vals):
652
+ req = collect_request(*vals)
653
+ return gr.update(value=req.encode(), visible=True)
654
+
655
+ share_btn.click(do_share, builder_inputs, [share_out])
656
+ copy_cfg_btn.click(lambda r: gr.update(value=r.request.encode(), visible=True)
657
+ if r else gr.update(), [current], [share_out])
658
+
659
+ def do_export(rec):
660
+ if rec is None or rec.result.trades.empty:
661
+ return gr.update(visible=False)
662
+ path = f"/tmp/trades_{rec.run_id}.csv"
663
+ rec.result.trades.to_csv(path, index=False)
664
+ return gr.update(value=path, visible=True)
665
+
666
+ export_btn.click(do_export, [current], [export_file])
667
+
668
+ def do_save(rec):
669
+ if rec is None:
670
+ return '<div class="bit-note">Nothing to save yet.</div>'
671
+ try:
672
+ rid = runtime.save_run_summary(rec)
673
+ return (f'<div class="bit-note">Run <b>{rid}</b> staged for the store. '
674
+ f'It is committed with the next batch.</div>')
675
+ except Exception as e:
676
+ return f'<div class="bit-note bit-note-danger">Could not save: {e}</div>'
677
+
678
+ save_run_btn.click(do_save, [current], [save_note])
679
+
680
+ def do_compare(hist, picks):
681
+ hist = list(hist or [])
682
+ by_id = {f"{r.run_id} · {r.label}": r for r in hist}
683
+ chosen = [by_id[p] for p in (picks or [])[:MAX_COMPARE] if p in by_id]
684
+ if not chosen:
685
+ empty = charts.empty_figure("select runs to compare")
686
+ return empty, empty, empty, empty, pd.DataFrame()
687
+
688
+ curves = {r.label[:28]: r.result.equity for r in chosen}
689
+ rets = {r.label[:28]: r.result.equity.pct_change().dropna() for r in chosen}
690
+ regimes = [runtime.regime_breakdown(r) for r in chosen]
691
+ regime_df = regimes[0] if regimes else pd.DataFrame()
692
+ for extra in regimes[1:]:
693
+ if not extra.empty and not regime_df.empty:
694
+ regime_df = regime_df.merge(extra, on="regime", how="outer")
695
+
696
+ rows = []
697
+ for label, key in (("Total return", "total_return"), ("CAGR", "cagr"),
698
+ ("OOS Sharpe", None), ("Max drawdown", "max_drawdown"),
699
+ ("Win rate", "win_rate"), ("Trades", "trade_count")):
700
+ row = {"Metric": label}
701
+ for r in chosen:
702
+ if key is None:
703
+ row[r.label[:22]] = num(r.result.metrics_oos.sharpe)
704
+ elif key == "trade_count":
705
+ row[r.label[:22]] = r.result.metrics_all.trade_count
706
+ elif key == "win_rate":
707
+ row[r.label[:22]] = pct(r.result.metrics_all.win_rate, 0, False)
708
+ else:
709
+ row[r.label[:22]] = pct(getattr(r.result.metrics_all, key))
710
+ rows.append(row)
711
+
712
+ return (charts.overlaid_returns(curves),
713
+ charts.small_multiples(curves),
714
+ charts.correlation_matrix(rets),
715
+ charts.regime_bars(regime_df),
716
+ pd.DataFrame(rows))
717
+
718
+ compare_picker.change(do_compare, [history, compare_picker],
719
+ [overlay_plot, small_mult_plot, corr_plot,
720
+ regime_bars_plot, metrics_table])
721
+
722
+ def do_robustness(rec):
723
+ if rec is None:
724
+ e = charts.empty_figure("run a backtest first")
725
+ return "", e, e, e, e
726
+ grade, checks = runtime.overfit_verdict(rec)
727
+ items = "".join(
728
+ f'<div style="font-size:11px;color:var(--text-secondary)">{m} {t}</div>'
729
+ for m, t in checks)
730
+ html = (f'<div class="bit-panel"><div class="bit-h2">Overfit verdict: '
731
+ f'<span style="color:var(--accent-amber-strong)">{grade}</span></div>'
732
+ f'{items}</div>')
733
+ wf = charts.walk_forward_bars(rec.result.windows)
734
+ mc = charts.monte_carlo_cone(charts.monte_carlo_paths(rec.result.trades))
735
+ e = charts.empty_figure("press the button below to run the sweep")
736
+ return html, wf, mc, e, e
737
+
738
+ current.change(do_robustness, [current],
739
+ [verdict_html, wf_plot, mc_plot, sens_plot, slip_plot])
740
+
741
+ def do_sweep(rec, progress=gr.Progress()):
742
+ if rec is None:
743
+ e = charts.empty_figure("run a backtest first")
744
+ return e, e
745
+ req = rec.request
746
+ preset = strategies.PRESETS.get(req.strategy)
747
+ keys = [p[0] for p in (preset.params if preset else [])][:2]
748
+ if len(keys) < 2:
749
+ sens = charts.empty_figure("this preset has fewer than two parameters")
750
+ else:
751
+ progress(0.1, desc="Parameter sweep")
752
+ base_x = req.params.get(keys[0], 20)
753
+ base_y = req.params.get(keys[1], 50)
754
+ xs = sorted({max(2, int(base_x * m)) for m in (0.5, 0.75, 1.0, 1.5, 2.0)})
755
+ ys = sorted({max(3, int(base_y * m)) for m in (0.5, 0.75, 1.0, 1.5, 2.0)})
756
+ grid = runtime.parameter_sweep(req, keys[0], xs, keys[1], ys)
757
+ sens = charts.parameter_sensitivity(grid, x=keys[0], y=keys[1])
758
+ progress(0.7, desc="Slippage stress")
759
+ slip = charts.slippage_stress(runtime.slippage_stress(req))
760
+ return sens, slip
761
+
762
+ robust_btn.click(do_sweep, [current], [sens_plot, slip_plot])
763
+
764
+ # ---- Coverage / extension (Phase 4) ----
765
+ from src import extension
766
+
767
+ estimate_btn.click(extension.estimate_ui, [ext_model, ext_asset, ext_tf,
768
+ ext_start, ext_end], [extend_out])
769
+ extend_btn.click(extension.extend_ui,
770
+ [ext_model, ext_asset, ext_tf, ext_start, ext_end],
771
+ [extend_out, coverage_table])
772
+ add_btn.click(extension.add_model_ui, [add_family, add_model_id],
773
+ [add_out, coverage_table])
774
+
775
+ # ---- Load: heatmap, coverage, share-link restore ----
776
+ def on_load(request: gr.Request):
777
+ store = runtime.get_store()
778
+ heat = comparisons.load_table(store, comparisons.HEATMAP)
779
+ fig = charts.strategy_timeframe_heatmap(heat)
780
+ cov_html = ""
781
+ restored = [gr.update()] * 4
782
+ token = None
783
+ try:
784
+ token = dict(request.query_params).get("cfg") if request else None
785
+ except Exception:
786
+ token = None
787
+ if token:
788
+ try:
789
+ req = RunRequest.decode(token)
790
+ restored = [gr.update(value=req.strategy), gr.update(value=req.asset),
791
+ gr.update(value=req.timeframe),
792
+ gr.update(value=req.date_range)]
793
+ cov_html = '<div class="bit-note">Config restored from share link.</div>'
794
+ except Exception as e:
795
+ cov_html = (f'<div class="bit-note bit-note-danger">'
796
+ f'Share link rejected: {e}</div>')
797
+ return (fig, runtime.coverage_frame(), extension.status_html(),
798
+ cov_html, *restored)
799
+
800
+ demo.load(on_load, None,
801
+ [heatmap_plot, coverage_table, extend_panel, run_status,
802
+ strategy, asset, timeframe, date_range])
803
+
804
+ demo.load(lambda: on_universe_change("BTC-USD", "1d"), None,
805
+ [coverage_note, model_slug])
806
+
807
+ return demo
808
+
809
+
810
+ def trades_head_html(rec: RunRecord | None) -> str:
811
+ if rec is None or rec.result.trades.empty:
812
+ return '<div class="bit-micro">NO TRADES</div>'
813
+ n = len(rec.result.trades)
814
+ costs = rec.result.costs_paid
815
+ return (f'<div class="bit-micro">{n} TOTAL · COSTS PAID {money(costs)} · '
816
+ f'FILLS AT NEXT BAR OPEN</div>')
817
+
818
+
819
+ def history_list_html(hist) -> str:
820
+ if not hist:
821
+ return '<div class="bit-micro">NO RUNS YET IN THIS SESSION</div>'
822
+ rows = []
823
+ for r in hist[:12]:
824
+ s = r.sharpe
825
+ color = ("var(--accent-moss-strong)" if s >= 1
826
+ else "var(--fin-down)" if s < 0 else "var(--text-secondary)")
827
+ rows.append(
828
+ f'<div style="border:1px solid var(--border-subtle);padding:6px;margin-bottom:4px">'
829
+ f'<div style="font-size:11px;color:var(--text-primary)">{r.label[:34]}</div>'
830
+ f'<div class="bit-micro">{r.meta}</div>'
831
+ f'<div style="font-family:var(--font-mono);font-size:12px;color:{color}">'
832
+ f'SHARPE {s:.2f}</div></div>'
833
+ )
834
+ return "".join(rows)
835
+
836
+
837
+ def build_overview(rec: RunRecord, *, log_scale: bool, cvd: bool):
838
+ r = rec.result
839
+ bpy = config.bars_per_year(rec.request.asset, rec.request.timeframe)
840
+ window = {"1d": 90, "1h": 24 * 30, "15m": 4 * 24 * 14}.get(rec.request.timeframe, 90)
841
+
842
+ costs_html = (
843
+ f'<div class="bit-note">COSTS PAID TOTAL: {money(r.costs_paid)}. '
844
+ f'The costed number is the real one.</div>'
845
+ if rec.request.costs_on else
846
+ '<div class="bit-note bit-note-danger">COSTS ARE OFF. '
847
+ 'These numbers are not achievable.</div>'
848
+ )
849
+ return (
850
+ charts.equity_curve(r.equity, r.benchmark_equity, plan=r.plan,
851
+ log_scale=log_scale, cvd=cvd),
852
+ charts.regime_strip(r.prices),
853
+ charts.underwater_chart(r.equity),
854
+ charts.rolling_sharpe_chart(r.equity, window, bpy),
855
+ charts.price_with_trades(r.prices, r.trades, cvd=cvd),
856
+ charts.pnl_histogram(r.trades, cvd=cvd),
857
+ charts.holding_period_histogram(r.trades),
858
+ charts.mae_mfe_scatter(r.trades, cvd=cvd),
859
+ costs_html,
860
+ )
861
+
862
+
863
+ demo = build_app()
864
+
865
+ if __name__ == "__main__":
866
+ gr.set_static_paths(paths=theme.static_paths())
867
+ demo.queue(max_size=32).launch(
868
+ server_name="0.0.0.0",
869
+ server_port=int(os.environ.get("PORT", 7860)),
870
+ show_api=False,
871
+ )
assets/bit-trading-mark.svg ADDED
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
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
assets/fonts/StyreneA-LightItalic.otf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a0fc7dca57045d06858f42a6789882435fb698e4bd1ff1c8de654a0462b82246
3
+ size 143400
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
assets/fonts/StyreneA-MediumItalic.otf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b7411520eaa9f48bddfe873362a0f6b71c8898b14ef16298b0057efc4a8c0c91
3
+ size 146888
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
assets/fonts/StyreneA-RegularItalic.otf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:dade88ecafdaec57180d47b9652873c2d0f5712846c4fe3ae080c81d87c645b4
3
+ size 142532
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
assets/fonts/StyreneA-ThinItalic.otf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:ede6f8ad1611f064dd34ba35591801c725592b44c4f6b5566df17e2fe06cb2e9
3
+ size 141916
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);}
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
+
assets/tokens/fonts.css ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap');
2
+ @font-face{font-family:'Styrene A';src:url('../assets/fonts/StyreneA-Thin.otf') format('opentype');font-weight:100;font-style:normal;font-display:swap}
3
+ @font-face{font-family:'Styrene A';src:url('../assets/fonts/StyreneA-ThinItalic.otf') format('opentype');font-weight:100;font-style:italic;font-display:swap}
4
+ @font-face{font-family:'Styrene A';src:url('../assets/fonts/StyreneA-Light.otf') format('opentype');font-weight:300;font-style:normal;font-display:swap}
5
+ @font-face{font-family:'Styrene A';src:url('../assets/fonts/StyreneA-LightItalic.otf') format('opentype');font-weight:300;font-style:italic;font-display:swap}
6
+ @font-face{font-family:'Styrene A';src:url('../assets/fonts/StyreneA-Regular.otf') format('opentype');font-weight:400;font-style:normal;font-display:swap}
7
+ @font-face{font-family:'Styrene A';src:url('../assets/fonts/StyreneA-RegularItalic.otf') format('opentype');font-weight:400;font-style:italic;font-display:swap}
8
+ @font-face{font-family:'Styrene A';src:url('../assets/fonts/StyreneA-Medium.otf') format('opentype');font-weight:500;font-style:normal;font-display:swap}
9
+ @font-face{font-family:'Styrene A';src:url('../assets/fonts/StyreneA-MediumItalic.otf') format('opentype');font-weight:500;font-style:italic;font-display:swap}
10
+ @font-face{font-family:'Mac Minecraft';src:url('../assets/fonts/MacMinecraft.ttf') format('truetype');font-weight:400;font-style:normal;font-display:swap}
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
+ }
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
+ }
conftest.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ import sys, pathlib
2
+ sys.path.insert(0, str(pathlib.Path(__file__).parent))
pytest.ini ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ [pytest]
2
+ testpaths = tests
3
+ markers =
4
+ slow: tests that load a real model or hit the network
5
+ filterwarnings =
6
+ ignore::DeprecationWarning
requirements.txt ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Backtest Lab — pinned. Every version here is the one the test suite passes on.
2
+
3
+ # UI
4
+ gradio[oauth]==5.49.1
5
+ plotly==6.9.0
6
+
7
+ # Engine
8
+ vectorbt==1.1.0
9
+ numpy==2.4.6
10
+ pandas==2.3.3
11
+ numba==0.67.0
12
+ scipy==1.17.1
13
+ scikit-learn==1.9.0
14
+
15
+ # Store
16
+ huggingface_hub==1.27.0
17
+ pyarrow==25.0.1
18
+
19
+ # Price providers
20
+ ccxt==4.5.73
21
+ yfinance==1.6.0
22
+ requests==2.34.2
23
+
24
+ # Forecast adapters. Imported lazily inside ForecastAdapter.load(), so app boot
25
+ # never pays for them -- this keeps cold start fast on cpu-basic while still
26
+ # letting the ZeroGPU extension path work when GPU hardware is attached.
27
+ torch==2.13.0
28
+ chronos-forecasting==2.3.1
29
+
30
+ # ZeroGPU runtime. Absent locally and on cpu-basic; extension.py degrades to a
31
+ # "coming soon" state when it cannot be imported.
32
+ spaces==0.44.1
scripts/seed_store.py ADDED
@@ -0,0 +1,318 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ """Seed the signal store: refresh prices, run inference, build comparison tables.
3
+
4
+ Runnable locally, in Colab, or from the Space's ZeroGPU function. Progress is
5
+ checkpointed after every batch, so an interrupted run resumes where it stopped
6
+ rather than paying for the same inference twice.
7
+
8
+ python scripts/seed_store.py --plan v1 --dry-run
9
+ python scripts/seed_store.py --plan v1 --prices-only
10
+ python scripts/seed_store.py --plan v1 --push
11
+
12
+ Nothing is recomputed that the manifest already covers.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import json
19
+ import logging
20
+ import os
21
+ import sys
22
+ import time
23
+ from dataclasses import dataclass, asdict, field
24
+ from pathlib import Path
25
+
26
+ import numpy as np
27
+ import pandas as pd
28
+
29
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
30
+
31
+ from src import comparisons, config # noqa: E402
32
+ from src.adapters import build_windows, get_adapter # noqa: E402
33
+ from src.data import refresh # noqa: E402
34
+ from src.store import SignalStore # noqa: E402
35
+
36
+ log = logging.getLogger("seed")
37
+
38
+
39
+ # --------------------------------------------------------------------------
40
+ # Seed plans
41
+ # --------------------------------------------------------------------------
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class SeedTarget:
46
+ model_slug: str
47
+ asset: str
48
+ timeframe: str
49
+ years: float
50
+ # Placeholder targets are written with inference_version PLACEHOLDER and
51
+ # are replaced the moment a real run covers the same slice.
52
+ placeholder: bool = False
53
+
54
+ @property
55
+ def key(self) -> str:
56
+ return f"{self.model_slug}|{self.asset}|{self.timeframe}"
57
+
58
+
59
+ CRYPTO = ["BTC-USD", "ETH-USD", "SOL-USD"]
60
+ EQUITIES = ["SPY", "QQQ", "NVDA"]
61
+
62
+
63
+ def plan_v1() -> list[SeedTarget]:
64
+ """The v1 seed: crypto-first, honest about provider depth limits.
65
+
66
+ Daily coverage is real for every asset on both Chronos models. Hourly is
67
+ real for crypto on the small model. 15-minute coverage is placeholder-only
68
+ in v1 -- the inference cost is large and the provider depth for equities is
69
+ 60 days, so it is labelled rather than faked as real.
70
+ """
71
+ targets: list[SeedTarget] = []
72
+
73
+ for asset in CRYPTO + EQUITIES:
74
+ targets.append(SeedTarget("chronos-bolt-small", asset, "1d", 3.0))
75
+ for asset in CRYPTO:
76
+ targets.append(SeedTarget("chronos-bolt-base", asset, "1d", 3.0))
77
+
78
+ # Hourly: crypto has full depth from the exchange; equities are capped at
79
+ # the provider's ~730 days, recorded as a boundary in the manifest.
80
+ for asset in CRYPTO:
81
+ targets.append(SeedTarget("chronos-bolt-small", asset, "1h", 1.0))
82
+ for asset in EQUITIES:
83
+ targets.append(SeedTarget("chronos-bolt-small", asset, "1h", 1.5))
84
+
85
+ # 15-minute crypto. Batched Chronos-Bolt inference turned out to cost about
86
+ # a millisecond per step on this hardware, so these are real rather than
87
+ # placeholder -- the v1 seed ships with no synthetic slices at all.
88
+ for asset in CRYPTO:
89
+ targets.append(SeedTarget("chronos-bolt-small", asset, "15m", 0.25))
90
+ return targets
91
+
92
+
93
+ def plan_smoke() -> list[SeedTarget]:
94
+ """One model, one asset, six months -- proves the pipeline end to end."""
95
+ return [SeedTarget("chronos-bolt-small", "BTC-USD", "1d", 0.5)]
96
+
97
+
98
+ PLANS = {"v1": plan_v1, "smoke": plan_smoke}
99
+
100
+
101
+ # --------------------------------------------------------------------------
102
+ # Checkpointing
103
+ # --------------------------------------------------------------------------
104
+
105
+
106
+ @dataclass
107
+ class Checkpoint:
108
+ path: Path
109
+ done: dict[str, str] = field(default_factory=dict) # key -> last ts written
110
+ failed: dict[str, str] = field(default_factory=dict)
111
+
112
+ @classmethod
113
+ def load(cls, path: str | os.PathLike) -> "Checkpoint":
114
+ p = Path(path)
115
+ if p.exists():
116
+ try:
117
+ raw = json.loads(p.read_text())
118
+ return cls(path=p, done=raw.get("done", {}), failed=raw.get("failed", {}))
119
+ except json.JSONDecodeError:
120
+ log.warning("checkpoint at %s was corrupt; starting fresh", p)
121
+ return cls(path=p)
122
+
123
+ def save(self) -> None:
124
+ self.path.parent.mkdir(parents=True, exist_ok=True)
125
+ self.path.write_text(json.dumps(
126
+ {"done": self.done, "failed": self.failed,
127
+ "updated_at": pd.Timestamp.now(tz="UTC").isoformat()}, indent=2))
128
+
129
+ def mark(self, key: str, last_ts) -> None:
130
+ self.done[key] = str(last_ts)
131
+ self.failed.pop(key, None)
132
+ self.save()
133
+
134
+ def mark_failed(self, key: str, reason: str) -> None:
135
+ self.failed[key] = reason
136
+ self.save()
137
+
138
+ def last_ts(self, key: str) -> pd.Timestamp | None:
139
+ v = self.done.get(key)
140
+ return pd.Timestamp(v) if v else None
141
+
142
+
143
+ # --------------------------------------------------------------------------
144
+ # Steps
145
+ # --------------------------------------------------------------------------
146
+
147
+
148
+ def refresh_prices(store: SignalStore, targets: list[SeedTarget]) -> list[str]:
149
+ """Fetch only the price ranges the store is missing."""
150
+ notes = []
151
+ wanted: dict[tuple[str, str], float] = {}
152
+ for t in targets:
153
+ k = (t.asset, t.timeframe)
154
+ wanted[k] = max(wanted.get(k, 0.0), t.years)
155
+
156
+ end = pd.Timestamp.now(tz="UTC").floor("h")
157
+ for (asset, tf), years in sorted(wanted.items()):
158
+ start = end - pd.Timedelta(days=int(365 * years) + 30)
159
+ rep = refresh(store, asset, tf, start, end)
160
+ log.info("%s", rep.summary())
161
+ notes.append(rep.summary())
162
+ for n in rep.boundary_notes:
163
+ log.info(" boundary: %s", n)
164
+ notes.append(f" boundary: {n}")
165
+ return notes
166
+
167
+
168
+ def seed_target(store: SignalStore, target: SeedTarget, ckpt: Checkpoint,
169
+ *, batch_size: int = 256, device: str | None = None,
170
+ force_placeholder: bool = False) -> str:
171
+ """Run inference for one (model, asset, timeframe) and write the slice."""
172
+ spec = config.SEED_MODELS.get(target.model_slug)
173
+ if spec is None:
174
+ return f"SKIP {target.key}: unknown model"
175
+
176
+ prices = store.get_prices(target.asset, target.timeframe)
177
+ if prices.empty:
178
+ return f"SKIP {target.key}: no price coverage"
179
+
180
+ end = prices.index[-1]
181
+ start = end - pd.Timedelta(days=int(365 * target.years))
182
+ prices = prices[prices.index >= start]
183
+ close = prices["close"]
184
+
185
+ use_placeholder = target.placeholder or force_placeholder
186
+ family = "placeholder" if use_placeholder else spec.family
187
+ ctx_len = min(spec.context_len, max(64, len(close) // 3))
188
+
189
+ adapter = get_adapter(family, spec.model_id, context_len=ctx_len, device=device)
190
+ adapter.load()
191
+ revision = adapter.resolved_revision
192
+ version = adapter.inference_version()
193
+
194
+ stamps, windows = build_windows(close, ctx_len)
195
+ if len(stamps) == 0:
196
+ return f"SKIP {target.key}: only {len(close)} bars, need > {ctx_len}"
197
+
198
+ # Idempotency: never recompute what the manifest already covers. The range
199
+ # to check is the one the windows actually produce -- signals start a full
200
+ # context window after the first price bar, so checking the price range
201
+ # would always report the leading context as an uncovered gap.
202
+ missing = store.missing_ranges(target.model_slug, revision, target.asset,
203
+ target.timeframe, stamps[0], stamps[-1])
204
+ if not missing:
205
+ return f"SKIP {target.key}: already covered by the manifest"
206
+
207
+ resume_from = ckpt.last_ts(target.key)
208
+ if resume_from is not None:
209
+ keep = stamps > resume_from
210
+ stamps, windows = stamps[keep], windows[keep]
211
+ if len(stamps) == 0:
212
+ return f"SKIP {target.key}: checkpoint says complete"
213
+
214
+ t0 = time.perf_counter()
215
+ written = 0
216
+ for i in range(0, len(stamps), batch_size):
217
+ bs, bw = stamps[i:i + batch_size], windows[i:i + batch_size]
218
+ forecast = adapter.predict(bw)
219
+ frame = forecast.as_frame(bs, version)
220
+ store.write_signals(
221
+ target.model_slug, spec.model_id, revision, target.asset,
222
+ target.timeframe, frame,
223
+ inference_version=version, contributed_by="seed",
224
+ )
225
+ written += len(frame)
226
+ ckpt.mark(target.key, bs[-1])
227
+ log.info(" %s %d/%d", target.key, min(i + batch_size, len(stamps)), len(stamps))
228
+
229
+ dt = time.perf_counter() - t0
230
+ tag = " [PLACEHOLDER]" if use_placeholder else ""
231
+ return (f"OK {target.key}: {written} steps in {dt:.1f}s "
232
+ f"({dt / max(written, 1) * 1000:.0f} ms/step){tag}")
233
+
234
+
235
+ # --------------------------------------------------------------------------
236
+ # Main
237
+ # --------------------------------------------------------------------------
238
+
239
+
240
+ def main(argv=None) -> int:
241
+ ap = argparse.ArgumentParser(description="Seed the bit signal store")
242
+ ap.add_argument("--plan", default="v1", choices=sorted(PLANS))
243
+ ap.add_argument("--store-root", default=".cache/store")
244
+ ap.add_argument("--checkpoint", default=".cache/seed_checkpoint.json")
245
+ ap.add_argument("--repo", default=config.STORE_REPO)
246
+ ap.add_argument("--batch-size", type=int, default=256)
247
+ ap.add_argument("--device", default=None)
248
+ ap.add_argument("--prices-only", action="store_true")
249
+ ap.add_argument("--skip-prices", action="store_true")
250
+ ap.add_argument("--placeholder-only", action="store_true",
251
+ help="write labelled synthetic signals instead of running models")
252
+ ap.add_argument("--no-comparisons", action="store_true")
253
+ ap.add_argument("--push", action="store_true", help="commit to the Hub when done")
254
+ ap.add_argument("--offline", action="store_true")
255
+ ap.add_argument("--dry-run", action="store_true")
256
+ ap.add_argument("--only", default=None, help="substring filter on target keys")
257
+ args = ap.parse_args(argv)
258
+
259
+ logging.basicConfig(level=logging.INFO, format="%(message)s")
260
+
261
+ targets = PLANS[args.plan]()
262
+ if args.only:
263
+ targets = [t for t in targets if args.only in t.key]
264
+
265
+ if args.dry_run:
266
+ print(f"plan={args.plan} targets={len(targets)}")
267
+ for t in targets:
268
+ print(f" {t.key:48s} years={t.years:<5g} placeholder={t.placeholder}")
269
+ return 0
270
+
271
+ store = SignalStore(repo_id=None if args.offline else args.repo,
272
+ local_root=args.store_root, offline=args.offline)
273
+ ckpt = Checkpoint.load(args.checkpoint)
274
+ results: list[str] = []
275
+
276
+ if not args.skip_prices:
277
+ log.info("== refreshing prices ==")
278
+ results.extend(refresh_prices(store, targets))
279
+
280
+ if not args.prices_only:
281
+ log.info("== running inference ==")
282
+ for t in targets:
283
+ try:
284
+ msg = seed_target(store, t, ckpt, batch_size=args.batch_size,
285
+ device=args.device,
286
+ force_placeholder=args.placeholder_only)
287
+ except Exception as e:
288
+ log.exception("target %s failed", t.key)
289
+ ckpt.mark_failed(t.key, f"{type(e).__name__}: {e}")
290
+ msg = f"FAIL {t.key}: {type(e).__name__}: {e}"
291
+ log.info("%s", msg)
292
+ results.append(msg)
293
+
294
+ if not args.no_comparisons:
295
+ log.info("== regenerating comparison tables ==")
296
+ report = comparisons.regenerate(store)
297
+ results.append(
298
+ f"comparisons: perf={len(report.model_performance)} "
299
+ f"calib={len(report.calibration)} dir={len(report.directional)} "
300
+ f"heatmap={len(report.heatmap)}"
301
+ )
302
+ log.info("%s", results[-1])
303
+
304
+ if args.push and not args.offline:
305
+ log.info("== pushing to %s ==", args.repo)
306
+ oid = store.flush(f"Seed store ({args.plan})")
307
+ log.info("commit: %s", oid)
308
+ results.append(f"pushed commit {oid}")
309
+
310
+ print("\n=== SEED SUMMARY ===")
311
+ for r in results:
312
+ print(r)
313
+ failures = [r for r in results if r.startswith("FAIL")]
314
+ return 1 if failures else 0
315
+
316
+
317
+ if __name__ == "__main__":
318
+ raise SystemExit(main())
src/__init__.py ADDED
File without changes
src/adapters.py ADDED
@@ -0,0 +1,367 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Forecast model adapters.
2
+
3
+ Adding a model means giving a model id and naming an adapter *family*. The
4
+ family list is fixed in `config.ALLOWED_ADAPTER_FAMILIES`; nothing here ever
5
+ imports, downloads or executes code chosen by a user. A user-supplied model id
6
+ is loaded only through an already-vetted family's loader, and `trust_remote_code`
7
+ is never enabled.
8
+
9
+ Every adapter reports an `inference_version` that pins the adapter's own logic
10
+ alongside the model revision, so a stored slice can always be traced back to
11
+ exactly what produced it.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import hashlib
17
+ import logging
18
+ import os
19
+ from abc import ABC, abstractmethod
20
+ from dataclasses import dataclass
21
+
22
+ import numpy as np
23
+ import pandas as pd
24
+
25
+ from . import config
26
+
27
+ log = logging.getLogger("bit.adapters")
28
+
29
+ DEFAULT_QUANTILES = (0.1, 0.5, 0.9)
30
+
31
+
32
+ class AdapterError(RuntimeError):
33
+ pass
34
+
35
+
36
+ class ModelNotAllowed(AdapterError):
37
+ """The requested adapter family is not on the allow-list."""
38
+
39
+
40
+ @dataclass
41
+ class Forecast:
42
+ """One horizon-1 forecast per input window."""
43
+
44
+ q10: np.ndarray
45
+ q50: np.ndarray
46
+ q90: np.ndarray
47
+ context_len: int
48
+
49
+ def __post_init__(self):
50
+ if not (len(self.q10) == len(self.q50) == len(self.q90)):
51
+ raise AdapterError("quantile arrays have mismatched lengths")
52
+
53
+ def as_frame(self, ts: pd.DatetimeIndex, inference_version: str) -> pd.DataFrame:
54
+ if len(ts) != len(self.q50):
55
+ raise AdapterError(
56
+ f"timestamp count {len(ts)} != forecast count {len(self.q50)}"
57
+ )
58
+ # Quantiles must not cross; sorting is the honest repair for tiny
59
+ # numerical inversions and makes the store's validator pass.
60
+ stacked = np.sort(np.vstack([self.q10, self.q50, self.q90]), axis=0)
61
+ return pd.DataFrame({
62
+ "ts": ts,
63
+ "q10": stacked[0], "q50": stacked[1], "q90": stacked[2],
64
+ "context_len": self.context_len,
65
+ "inference_version": inference_version,
66
+ })
67
+
68
+
69
+ class ForecastAdapter(ABC):
70
+ """Uniform interface over quantile forecasters."""
71
+
72
+ family: str = "base"
73
+ adapter_version: str = "1"
74
+
75
+ def __init__(self, model_id: str, revision: str | None = None,
76
+ context_len: int = 512, device: str | None = None):
77
+ self.model_id = model_id
78
+ self.revision = revision
79
+ self.context_len = context_len
80
+ self.device = device or _default_device()
81
+ self._model = None
82
+ self._resolved_revision: str | None = None
83
+
84
+ # -- interface --------------------------------------------------------
85
+
86
+ @abstractmethod
87
+ def load(self, model_id: str | None = None, revision: str | None = None) -> "ForecastAdapter":
88
+ """Materialise the model. Idempotent."""
89
+
90
+ @abstractmethod
91
+ def predict(self, context_windows: np.ndarray) -> Forecast:
92
+ """`context_windows` is (n_windows, context_len). Returns horizon-1 quantiles."""
93
+
94
+ def inference_version(self) -> str:
95
+ """Identity of everything that determines the output values."""
96
+ rev = self._resolved_revision or self.revision or "unpinned"
97
+ payload = f"{self.family}|{self.adapter_version}|{self.model_id}|{rev}|{self.context_len}"
98
+ digest = hashlib.sha256(payload.encode()).hexdigest()[:12]
99
+ return f"{config.INFERENCE_VERSION}+{self.family}.{digest}"
100
+
101
+ @property
102
+ def resolved_revision(self) -> str:
103
+ return self._resolved_revision or self.revision or "unpinned"
104
+
105
+ # -- shared helpers ---------------------------------------------------
106
+
107
+ def resolve_revision(self) -> str:
108
+ """Pin the model to an immutable commit sha before any inference runs."""
109
+ if self._resolved_revision:
110
+ return self._resolved_revision
111
+ try:
112
+ from huggingface_hub import HfApi
113
+
114
+ info = HfApi().model_info(self.model_id, revision=self.revision)
115
+ self._resolved_revision = info.sha
116
+ except Exception as e:
117
+ log.warning("could not resolve revision for %s: %s", self.model_id, e)
118
+ self._resolved_revision = self.revision or "unpinned"
119
+ return self._resolved_revision
120
+
121
+
122
+ def _default_device() -> str:
123
+ try:
124
+ import torch
125
+
126
+ if torch.cuda.is_available():
127
+ return "cuda"
128
+ if getattr(torch.backends, "mps", None) and torch.backends.mps.is_available():
129
+ return "mps"
130
+ except Exception:
131
+ pass
132
+ return "cpu"
133
+
134
+
135
+ # --------------------------------------------------------------------------
136
+ # Chronos / Chronos-Bolt
137
+ # --------------------------------------------------------------------------
138
+
139
+
140
+ class ChronosAdapter(ForecastAdapter):
141
+ """amazon/chronos-* and amazon/chronos-bolt-* quantile forecasters."""
142
+
143
+ family = "chronos"
144
+ adapter_version = "1"
145
+
146
+ def load(self, model_id: str | None = None, revision: str | None = None):
147
+ if model_id:
148
+ self.model_id = model_id
149
+ if revision:
150
+ self.revision = revision
151
+ if self._model is not None:
152
+ return self
153
+
154
+ self.resolve_revision()
155
+ try:
156
+ from chronos import BaseChronosPipeline
157
+ except ImportError as e:
158
+ raise AdapterError(
159
+ "chronos-forecasting is not installed; add it to requirements.txt"
160
+ ) from e
161
+
162
+ import torch
163
+
164
+ dtype = torch.float32 if self.device in ("cpu", "mps") else torch.bfloat16
165
+ self._model = BaseChronosPipeline.from_pretrained(
166
+ self.model_id,
167
+ revision=self._resolved_revision if self._resolved_revision != "unpinned" else None,
168
+ device_map=self.device,
169
+ torch_dtype=dtype,
170
+ )
171
+ return self
172
+
173
+ def predict(self, context_windows: np.ndarray) -> Forecast:
174
+ if self._model is None:
175
+ self.load()
176
+ import torch
177
+
178
+ ctx = np.asarray(context_windows, dtype="float32")
179
+ if ctx.ndim == 1:
180
+ ctx = ctx[None, :]
181
+ tensors = [torch.tensor(row) for row in ctx]
182
+
183
+ q_levels = list(DEFAULT_QUANTILES)
184
+ with torch.inference_mode():
185
+ quantiles, _mean = self._model.predict_quantiles(
186
+ tensors, prediction_length=1, quantile_levels=q_levels,
187
+ )
188
+ # (n_series, prediction_length, n_quantiles) -> horizon 1
189
+ arr = quantiles.float().cpu().numpy()[:, 0, :]
190
+ return Forecast(q10=arr[:, 0], q50=arr[:, 1], q90=arr[:, 2],
191
+ context_len=ctx.shape[1])
192
+
193
+
194
+ # --------------------------------------------------------------------------
195
+ # TimesFM
196
+ # --------------------------------------------------------------------------
197
+
198
+
199
+ class TimesFMAdapter(ForecastAdapter):
200
+ """google/timesfm-* forecasters.
201
+
202
+ TimesFM returns a fixed quantile grid; the 0.1/0.5/0.9 columns are selected
203
+ from it rather than re-derived, so the stored numbers are the model's own.
204
+ """
205
+
206
+ family = "timesfm"
207
+ adapter_version = "1"
208
+ _QUANTILE_GRID = (0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9)
209
+
210
+ def load(self, model_id: str | None = None, revision: str | None = None):
211
+ if model_id:
212
+ self.model_id = model_id
213
+ if revision:
214
+ self.revision = revision
215
+ if self._model is not None:
216
+ return self
217
+
218
+ self.resolve_revision()
219
+ try:
220
+ import timesfm
221
+ except ImportError as e:
222
+ raise AdapterError(
223
+ "timesfm is not installed; add it to requirements.txt to enable "
224
+ "this adapter family"
225
+ ) from e
226
+
227
+ backend = {"cuda": "gpu", "mps": "cpu"}.get(self.device, "cpu")
228
+ self._model = timesfm.TimesFm(
229
+ hparams=timesfm.TimesFmHparams(
230
+ backend=backend, per_core_batch_size=32,
231
+ context_len=self.context_len, horizon_len=1,
232
+ ),
233
+ checkpoint=timesfm.TimesFmCheckpoint(huggingface_repo_id=self.model_id),
234
+ )
235
+ return self
236
+
237
+ def predict(self, context_windows: np.ndarray) -> Forecast:
238
+ if self._model is None:
239
+ self.load()
240
+
241
+ ctx = np.asarray(context_windows, dtype="float32")
242
+ if ctx.ndim == 1:
243
+ ctx = ctx[None, :]
244
+
245
+ _point, quantile_out = self._model.forecast(
246
+ [row for row in ctx], freq=[0] * len(ctx)
247
+ )
248
+ arr = np.asarray(quantile_out)[:, 0, :] # horizon 1
249
+ grid = list(self._QUANTILE_GRID)
250
+ # Column 0 of TimesFM's output is the mean; quantiles follow.
251
+ offset = arr.shape[1] - len(grid)
252
+ i10, i50, i90 = (grid.index(q) + offset for q in DEFAULT_QUANTILES)
253
+ return Forecast(q10=arr[:, i10], q50=arr[:, i50], q90=arr[:, i90],
254
+ context_len=ctx.shape[1])
255
+
256
+
257
+ # --------------------------------------------------------------------------
258
+ # Placeholder (no GPU / no model available)
259
+ # --------------------------------------------------------------------------
260
+
261
+
262
+ class PlaceholderAdapter(ForecastAdapter):
263
+ """Structurally identical synthetic output, permanently labelled.
264
+
265
+ Exists so the UI has complete shape before every cell has real coverage.
266
+ Its `inference_version` is the literal string `PLACEHOLDER`, which the store
267
+ and the UI both key off to mark the data as not-real. It is deterministic:
268
+ the same window always yields the same numbers.
269
+ """
270
+
271
+ family = "placeholder"
272
+ adapter_version = "1"
273
+
274
+ def load(self, model_id: str | None = None, revision: str | None = None):
275
+ if model_id:
276
+ self.model_id = model_id
277
+ self._resolved_revision = "PLACEHOLDER"
278
+ return self
279
+
280
+ def inference_version(self) -> str:
281
+ return config.PLACEHOLDER_VERSION
282
+
283
+ def predict(self, context_windows: np.ndarray) -> Forecast:
284
+ ctx = np.asarray(context_windows, dtype="float64")
285
+ if ctx.ndim == 1:
286
+ ctx = ctx[None, :]
287
+
288
+ last = ctx[:, -1]
289
+ # Deterministic pseudo-drift seeded by the window itself, plus a band
290
+ # scaled to that window's realised volatility.
291
+ seedvals = np.abs(np.sum(ctx[:, -8:], axis=1) * 1e6).astype("int64")
292
+ drift = np.array([
293
+ (np.random.default_rng(int(s) % (2**32)).normal(0.0, 1.0)) for s in seedvals
294
+ ])
295
+ vol = np.std(np.diff(ctx, axis=1), axis=1) / np.maximum(np.abs(last), 1e-9)
296
+ vol = np.clip(vol, 1e-4, 0.2)
297
+
298
+ q50 = last * (1.0 + 0.25 * drift * vol)
299
+ band = last * vol * 1.28
300
+ return Forecast(q10=q50 - band, q50=q50, q90=q50 + band,
301
+ context_len=ctx.shape[1])
302
+
303
+
304
+ # --------------------------------------------------------------------------
305
+ # Factory
306
+ # --------------------------------------------------------------------------
307
+
308
+ _FAMILIES: dict[str, type[ForecastAdapter]] = {
309
+ "chronos": ChronosAdapter,
310
+ "timesfm": TimesFMAdapter,
311
+ "placeholder": PlaceholderAdapter,
312
+ }
313
+
314
+
315
+ def get_adapter(family: str, model_id: str, revision: str | None = None,
316
+ context_len: int = 512, device: str | None = None) -> ForecastAdapter:
317
+ """Build an adapter. Only allow-listed families are constructible."""
318
+ fam = (family or "").strip().lower()
319
+ if fam == "placeholder":
320
+ return PlaceholderAdapter(model_id, revision, context_len, device)
321
+ if fam not in config.ALLOWED_ADAPTER_FAMILIES:
322
+ raise ModelNotAllowed(
323
+ f"adapter family {family!r} is not allowed; pick one of "
324
+ f"{list(config.ALLOWED_ADAPTER_FAMILIES)}"
325
+ )
326
+ return _FAMILIES[fam](model_id, revision, context_len, device)
327
+
328
+
329
+ def validate_model_id(model_id: str) -> str:
330
+ """Reject anything that is not a plain `owner/name` Hub id."""
331
+ mid = (model_id or "").strip()
332
+ if not mid or mid.count("/") != 1:
333
+ raise AdapterError(f"{model_id!r} is not a valid Hub model id (owner/name)")
334
+ owner, name = mid.split("/")
335
+ ok = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.")
336
+ if not owner or not name or set(mid) - ok - {"/"}:
337
+ raise AdapterError(f"{model_id!r} contains characters that are not allowed")
338
+ if ".." in mid:
339
+ raise AdapterError("model id may not contain '..'")
340
+ return mid
341
+
342
+
343
+ # --------------------------------------------------------------------------
344
+ # Rolling-window construction (shared by the seed script and the GPU path)
345
+ # --------------------------------------------------------------------------
346
+
347
+
348
+ def build_windows(series: pd.Series, context_len: int, stride: int = 1):
349
+ """Yield (timestamp, trailing window) pairs.
350
+
351
+ The window for timestamp `t` ends at `t` inclusive, so the forecast stored
352
+ at `t` used only data available at `t`. The engine then shifts it before any
353
+ trade can act on it.
354
+ """
355
+ values = series.to_numpy(dtype="float64")
356
+ index = series.index
357
+ n = len(values)
358
+ if n <= context_len:
359
+ return [], np.empty((0, context_len))
360
+
361
+ stamps, rows = [], []
362
+ for i in range(context_len, n, stride):
363
+ stamps.append(index[i])
364
+ rows.append(values[i - context_len + 1: i + 1])
365
+ if not rows:
366
+ return [], np.empty((0, context_len))
367
+ return pd.DatetimeIndex(stamps), np.vstack(rows)
src/charts.py ADDED
@@ -0,0 +1,615 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Plotly figures in the Bit design language.
2
+
3
+ Colors come from `PALETTE`, which mirrors the design system tokens. Plotly
4
+ cannot read CSS variables, so the token values are resolved here once; if the
5
+ design system changes, this table is the single place to update.
6
+
7
+ Every figure returns a `go.Figure` with the app's dark canvas, square corners,
8
+ tight type, and direction encoded by shape as well as color (the design marks
9
+ up/down with ▲/▼ so colorblind users are not reading hue alone).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import numpy as np
15
+ import pandas as pd
16
+ import plotly.graph_objects as go
17
+
18
+ from .metrics import drawdown_series, rolling_sharpe
19
+
20
+ # --------------------------------------------------------------------------
21
+ # Palette (mirrors _ds/tokens/colors.css)
22
+ # --------------------------------------------------------------------------
23
+
24
+ PALETTE = {
25
+ "canvas": "#161512",
26
+ "panel": "#1d1c18",
27
+ "raised": "#24221d",
28
+ "sunken": "#000000",
29
+ "border_subtle": "#2c2a24",
30
+ "border": "#3d3a32",
31
+ "border_strong": "#6f6a56",
32
+ "text": "#f7f4ec",
33
+ "text_secondary": "#b6b09a",
34
+ "text_tertiary": "#6f6a56",
35
+ "amber": "#af9209",
36
+ "amber_strong": "#cfab0a",
37
+ "amber_dim": "#7d6a09",
38
+ "moss": "#68781e",
39
+ "moss_strong": "#7d901f",
40
+ "moss_dim": "#4d5817",
41
+ "up": "#19b35a",
42
+ "up_strong": "#22c765",
43
+ "down": "#e0483c",
44
+ "down_strong": "#ee6152",
45
+ "up_cvd": "#3f7fd0",
46
+ "down_cvd": "#c07a2a",
47
+ "mute_red": "#8a5a54",
48
+ "mute_teal": "#54898a",
49
+ "mute_blue": "#59656e",
50
+ "mute_indigo": "#5c5c8a",
51
+ "mute_violet": "#75588a",
52
+ "mute_green": "#6e8a54",
53
+ "mute_yellow": "#8a8154",
54
+ }
55
+
56
+ SERIES_COLORS = [
57
+ PALETTE["amber_strong"], PALETTE["mute_teal"], PALETTE["mute_violet"],
58
+ PALETTE["moss_strong"], PALETTE["mute_indigo"], PALETTE["mute_red"],
59
+ ]
60
+
61
+ FONT = "ui-monospace, 'JetBrains Mono', SFMono-Regular, Menlo, monospace"
62
+ HEAD_FONT = "'Styrene A', -apple-system, system-ui, sans-serif"
63
+
64
+
65
+ def up_color(cvd: bool = False) -> str:
66
+ return PALETTE["up_cvd"] if cvd else PALETTE["up"]
67
+
68
+
69
+ def down_color(cvd: bool = False) -> str:
70
+ return PALETTE["down_cvd"] if cvd else PALETTE["down"]
71
+
72
+
73
+ def _base_layout(fig: go.Figure, height: int = 320, *, showlegend: bool = False,
74
+ margin: tuple[int, int, int, int] = (8, 8, 8, 8)) -> go.Figure:
75
+ l, r, t, b = margin
76
+ fig.update_layout(
77
+ template="plotly_dark",
78
+ paper_bgcolor=PALETTE["panel"],
79
+ plot_bgcolor=PALETTE["panel"],
80
+ font=dict(family=FONT, size=10, color=PALETTE["text_secondary"]),
81
+ height=height,
82
+ margin=dict(l=l, r=r, t=t, b=b),
83
+ showlegend=showlegend,
84
+ legend=dict(bgcolor="rgba(0,0,0,0)", borderwidth=0,
85
+ font=dict(size=9, color=PALETTE["text_secondary"]),
86
+ orientation="h", yanchor="bottom", y=1.0, x=0),
87
+ hoverlabel=dict(bgcolor=PALETTE["raised"], bordercolor=PALETTE["border"],
88
+ font=dict(family=FONT, size=10, color=PALETTE["text"])),
89
+ xaxis=dict(gridcolor=PALETTE["border_subtle"], zerolinecolor=PALETTE["border"],
90
+ linecolor=PALETTE["border"], tickfont=dict(size=9)),
91
+ yaxis=dict(gridcolor=PALETTE["border_subtle"], zerolinecolor=PALETTE["border"],
92
+ linecolor=PALETTE["border"], tickfont=dict(size=9)),
93
+ dragmode="pan",
94
+ )
95
+ return fig
96
+
97
+
98
+ def empty_figure(message: str = "No data", height: int = 320) -> go.Figure:
99
+ fig = go.Figure()
100
+ fig.add_annotation(text=message.upper(), showarrow=False,
101
+ font=dict(family=FONT, size=11, color=PALETTE["text_tertiary"]),
102
+ x=0.5, y=0.5, xref="paper", yref="paper")
103
+ fig.update_xaxes(visible=False)
104
+ fig.update_yaxes(visible=False)
105
+ return _base_layout(fig, height)
106
+
107
+
108
+ # --------------------------------------------------------------------------
109
+ # Equity curve
110
+ # --------------------------------------------------------------------------
111
+
112
+
113
+ def equity_curve(
114
+ equity: pd.Series,
115
+ benchmark: pd.Series | None = None,
116
+ *,
117
+ plan=None,
118
+ log_scale: bool = False,
119
+ height: int = 340,
120
+ drawdown_shading: bool = True,
121
+ cvd: bool = False,
122
+ ) -> go.Figure:
123
+ """Strategy vs buy & hold, with drawdown shading and validation bands."""
124
+ if equity is None or equity.empty:
125
+ return empty_figure("no equity curve", height)
126
+
127
+ fig = go.Figure()
128
+ base = float(equity.iloc[0])
129
+ pct = (equity / base - 1.0) * 100.0
130
+
131
+ if drawdown_shading:
132
+ dd = drawdown_series(equity)
133
+ # Shade the stretches spent more than 5% below the running peak.
134
+ in_dd = dd < -0.05
135
+ for lo, hi in _true_runs(in_dd):
136
+ fig.add_vrect(x0=equity.index[lo], x1=equity.index[hi],
137
+ fillcolor=PALETTE["down"], opacity=0.10,
138
+ line_width=0, layer="below")
139
+
140
+ if plan is not None:
141
+ for w in getattr(plan, "windows", []):
142
+ fig.add_vrect(x0=w.test_start, x1=w.test_end,
143
+ fillcolor=PALETTE["moss"], opacity=0.07,
144
+ line_width=0, layer="below")
145
+ hs = getattr(plan, "holdout_start", None)
146
+ if hs is not None:
147
+ fig.add_vrect(
148
+ x0=hs, x1=equity.index[-1], fillcolor=PALETTE["amber"], opacity=0.10,
149
+ line_width=1, line_color=PALETTE["amber_dim"], layer="below",
150
+ annotation_text="HOLDOUT", annotation_position="top left",
151
+ annotation_font=dict(family=FONT, size=9, color=PALETTE["amber_strong"]),
152
+ )
153
+
154
+ if benchmark is not None and not benchmark.empty:
155
+ bpct = (benchmark / float(benchmark.iloc[0]) - 1.0) * 100.0
156
+ fig.add_trace(go.Scatter(
157
+ x=bpct.index, y=bpct.to_numpy(), name="BUY & HOLD",
158
+ line=dict(color=PALETTE["text_tertiary"], width=1.2, dash="dash"),
159
+ hovertemplate="buy & hold %{y:.1f}%<extra></extra>",
160
+ ))
161
+
162
+ fig.add_trace(go.Scatter(
163
+ x=pct.index, y=pct.to_numpy(), name="STRATEGY",
164
+ line=dict(color=PALETTE["amber_strong"], width=1.8),
165
+ hovertemplate="strategy %{y:.1f}%<extra></extra>",
166
+ ))
167
+
168
+ fig.update_yaxes(ticksuffix="%", title=None)
169
+ if log_scale:
170
+ # Log scale needs a positive series, so plot the equity multiple.
171
+ fig.data = ()
172
+ mult = equity / base
173
+ if benchmark is not None and not benchmark.empty:
174
+ fig.add_trace(go.Scatter(
175
+ x=benchmark.index, y=(benchmark / float(benchmark.iloc[0])).to_numpy(),
176
+ name="BUY & HOLD",
177
+ line=dict(color=PALETTE["text_tertiary"], width=1.2, dash="dash")))
178
+ fig.add_trace(go.Scatter(x=mult.index, y=mult.to_numpy(), name="STRATEGY",
179
+ line=dict(color=PALETTE["amber_strong"], width=1.8)))
180
+ fig.update_yaxes(type="log", ticksuffix="x")
181
+
182
+ return _base_layout(fig, height, showlegend=True, margin=(8, 8, 24, 8))
183
+
184
+
185
+ def _true_runs(mask: pd.Series) -> list[tuple[int, int]]:
186
+ """Contiguous [start, end] index positions where `mask` is True."""
187
+ arr = mask.to_numpy()
188
+ runs, start = [], None
189
+ for i, v in enumerate(arr):
190
+ if v and start is None:
191
+ start = i
192
+ elif not v and start is not None:
193
+ runs.append((start, i - 1))
194
+ start = None
195
+ if start is not None:
196
+ runs.append((start, len(arr) - 1))
197
+ return runs
198
+
199
+
200
+ # --------------------------------------------------------------------------
201
+ # Underwater / rolling Sharpe
202
+ # --------------------------------------------------------------------------
203
+
204
+
205
+ def underwater_chart(equity: pd.Series, height: int = 150) -> go.Figure:
206
+ if equity is None or equity.empty:
207
+ return empty_figure("no drawdown data", height)
208
+ dd = drawdown_series(equity) * 100.0
209
+ fig = go.Figure(go.Scatter(
210
+ x=dd.index, y=dd.to_numpy(), fill="tozeroy", mode="lines",
211
+ line=dict(color=PALETTE["down"], width=1.0),
212
+ fillcolor="rgba(224,72,60,0.35)",
213
+ hovertemplate="%{y:.1f}%<extra></extra>",
214
+ ))
215
+ fig.update_yaxes(ticksuffix="%")
216
+ return _base_layout(fig, height)
217
+
218
+
219
+ def rolling_sharpe_chart(equity: pd.Series, window: int, bars_per_year: float,
220
+ height: int = 150) -> go.Figure:
221
+ if equity is None or equity.empty:
222
+ return empty_figure("no rolling sharpe", height)
223
+ rs = rolling_sharpe(equity, window, bars_per_year)
224
+ if rs.empty:
225
+ return empty_figure(f"needs > {window} bars", height)
226
+ fig = go.Figure(go.Scatter(
227
+ x=rs.index, y=rs.to_numpy(), mode="lines",
228
+ line=dict(color=PALETTE["mute_teal"], width=1.2),
229
+ hovertemplate="sharpe %{y:.2f}<extra></extra>",
230
+ ))
231
+ fig.add_hline(y=0, line=dict(color=PALETTE["border"], width=1))
232
+ fig.add_hline(y=1, line=dict(color=PALETTE["moss_dim"], width=1, dash="dot"))
233
+ return _base_layout(fig, height)
234
+
235
+
236
+ # --------------------------------------------------------------------------
237
+ # Price + trade flags
238
+ # --------------------------------------------------------------------------
239
+
240
+
241
+ def price_with_trades(
242
+ prices: pd.DataFrame, trades: pd.DataFrame, *, height: int = 420,
243
+ max_bars: int = 600, cvd: bool = False,
244
+ ) -> go.Figure:
245
+ """Candlesticks with volume, entry/exit flags and win/loss connectors."""
246
+ if prices is None or prices.empty:
247
+ return empty_figure("no price data", height)
248
+
249
+ px = prices.tail(max_bars)
250
+ up, down = up_color(cvd), down_color(cvd)
251
+
252
+ fig = go.Figure()
253
+ fig.add_trace(go.Candlestick(
254
+ x=px.index, open=px["open"], high=px["high"], low=px["low"], close=px["close"],
255
+ increasing=dict(line=dict(color=up, width=1), fillcolor=up),
256
+ decreasing=dict(line=dict(color=down, width=1), fillcolor=down),
257
+ name="price", yaxis="y", showlegend=False,
258
+ ))
259
+
260
+ if "volume" in px.columns:
261
+ vmax = float(px["volume"].max()) or 1.0
262
+ pmin = float(px["low"].min())
263
+ prange = float(px["high"].max()) - pmin or 1.0
264
+ scaled = pmin + (px["volume"] / vmax) * prange * 0.16
265
+ fig.add_trace(go.Bar(
266
+ x=px.index, y=scaled - pmin, base=pmin, marker_color=PALETTE["border"],
267
+ opacity=0.5, name="volume", showlegend=False, hoverinfo="skip",
268
+ ))
269
+
270
+ if trades is not None and not trades.empty:
271
+ window = trades[(trades["entry_ts"] >= px.index[0])
272
+ & (trades["entry_ts"] <= px.index[-1])]
273
+ for t in window.itertuples():
274
+ won = t.net_pnl > 0
275
+ color = up if won else down
276
+ hollow = str(t.side).lower() == "short"
277
+ fig.add_trace(go.Scatter(
278
+ x=[t.entry_ts, t.exit_ts], y=[t.entry_px, t.exit_px],
279
+ mode="lines", line=dict(color=color, width=1, dash="dot"),
280
+ showlegend=False, hoverinfo="skip",
281
+ ))
282
+ card = (
283
+ f"#{t.id} {str(t.side).upper()}<br>"
284
+ f"net {t.net_pnl:+,.2f} ({t.r_multiple:+.2f}R)<br>"
285
+ f"costs {t.costs:,.2f}<br>"
286
+ f"MAE {t.mae:.2%} · MFE {t.mfe:.2%}<br>"
287
+ f"<i>{t.trigger}</i>"
288
+ )
289
+ fig.add_trace(go.Scatter(
290
+ x=[t.entry_ts], y=[t.entry_px], mode="markers", showlegend=False,
291
+ marker=dict(symbol="triangle-up", size=10, color="rgba(0,0,0,0)" if hollow else color,
292
+ line=dict(color=color, width=1.5)),
293
+ hovertemplate=card + "<extra></extra>",
294
+ ))
295
+ fig.add_trace(go.Scatter(
296
+ x=[t.exit_ts], y=[t.exit_px], mode="markers", showlegend=False,
297
+ marker=dict(symbol="triangle-down", size=10, color="rgba(0,0,0,0)" if hollow else color,
298
+ line=dict(color=color, width=1.5)),
299
+ hovertemplate=card + "<extra></extra>",
300
+ ))
301
+
302
+ fig.update_layout(xaxis_rangeslider_visible=False, barmode="overlay")
303
+ return _base_layout(fig, height)
304
+
305
+
306
+ # --------------------------------------------------------------------------
307
+ # Distributions
308
+ # --------------------------------------------------------------------------
309
+
310
+
311
+ def pnl_histogram(trades: pd.DataFrame, height: int = 200, cvd: bool = False) -> go.Figure:
312
+ if trades is None or trades.empty:
313
+ return empty_figure("no trades", height)
314
+ net = trades["net_pnl"].astype(float)
315
+ colors = [up_color(cvd) if v > 0 else down_color(cvd) for v in net]
316
+ fig = go.Figure(go.Histogram(
317
+ x=net, nbinsx=min(40, max(8, len(net) // 3)),
318
+ marker=dict(color=PALETTE["mute_blue"], line=dict(color=PALETTE["border"], width=1)),
319
+ hovertemplate="%{y} trades in %{x}<extra></extra>",
320
+ ))
321
+ fig.add_vline(x=0, line=dict(color=PALETTE["text_tertiary"], width=1))
322
+ return _base_layout(fig, height)
323
+
324
+
325
+ def holding_period_histogram(trades: pd.DataFrame, height: int = 200) -> go.Figure:
326
+ if trades is None or trades.empty or "duration_bars" in trades.columns is None:
327
+ return empty_figure("no trades", height)
328
+ dur = trades["duration_bars"].dropna().astype(float)
329
+ if dur.empty:
330
+ return empty_figure("no durations", height)
331
+ fig = go.Figure(go.Histogram(
332
+ x=dur, nbinsx=min(30, max(6, len(dur) // 3)),
333
+ marker=dict(color=PALETTE["mute_indigo"], line=dict(color=PALETTE["border"], width=1)),
334
+ hovertemplate="%{y} trades held %{x} bars<extra></extra>",
335
+ ))
336
+ return _base_layout(fig, height)
337
+
338
+
339
+ def mae_mfe_scatter(trades: pd.DataFrame, height: int = 200, cvd: bool = False) -> go.Figure:
340
+ if trades is None or trades.empty:
341
+ return empty_figure("no trades", height)
342
+ t = trades.dropna(subset=["mae", "mfe"])
343
+ if t.empty:
344
+ return empty_figure("no excursion data", height)
345
+ won = t["net_pnl"] > 0
346
+ fig = go.Figure()
347
+ for label, mask, color, sym in (
348
+ ("wins", won, up_color(cvd), "triangle-up"),
349
+ ("losses", ~won, down_color(cvd), "triangle-down"),
350
+ ):
351
+ sub = t[mask]
352
+ if sub.empty:
353
+ continue
354
+ fig.add_trace(go.Scatter(
355
+ x=(sub["mae"] * 100).to_numpy(), y=(sub["mfe"] * 100).to_numpy(),
356
+ mode="markers", name=label.upper(),
357
+ marker=dict(color=color, size=6, symbol=sym, opacity=0.75),
358
+ customdata=sub[["id", "net_pnl"]].to_numpy(),
359
+ hovertemplate="#%{customdata[0]} net %{customdata[1]:+,.0f}<br>"
360
+ "MAE %{x:.1f}% · MFE %{y:.1f}%<extra></extra>",
361
+ ))
362
+ fig.update_xaxes(title=dict(text="MAE %", font=dict(size=9)), ticksuffix="%")
363
+ fig.update_yaxes(title=dict(text="MFE %", font=dict(size=9)), ticksuffix="%")
364
+ return _base_layout(fig, height, showlegend=True, margin=(8, 8, 22, 28))
365
+
366
+
367
+ # --------------------------------------------------------------------------
368
+ # Comparison
369
+ # --------------------------------------------------------------------------
370
+
371
+
372
+ def strategy_timeframe_heatmap(df: pd.DataFrame, *, height: int = 320,
373
+ value_col: str = "oos_sharpe") -> go.Figure:
374
+ """Strategy x timeframe OOS-Sharpe matrix. Scale fixed at -0.5 -> 2.0."""
375
+ if df is None or df.empty:
376
+ return empty_figure("no comparison coverage", height)
377
+ pivot = df.pivot_table(index="strategy", columns="timeframe",
378
+ values=value_col, aggfunc="mean")
379
+ order = [tf for tf in ("15m", "1h", "1d") if tf in pivot.columns]
380
+ pivot = pivot.reindex(columns=order or list(pivot.columns))
381
+
382
+ fig = go.Figure(go.Heatmap(
383
+ z=pivot.to_numpy(), x=list(pivot.columns), y=list(pivot.index),
384
+ zmin=-0.5, zmax=2.0,
385
+ colorscale=[[0.0, PALETTE["down"]], [0.2, PALETTE["panel"]],
386
+ [0.5, PALETTE["moss_dim"]], [1.0, PALETTE["amber_strong"]]],
387
+ hovertemplate="%{y} · %{x}<br>OOS Sharpe %{z:.2f}<extra></extra>",
388
+ colorbar=dict(thickness=8, len=0.8, tickfont=dict(size=9),
389
+ outlinewidth=0, title=dict(text="SHARPE", font=dict(size=9))),
390
+ xgap=2, ygap=2,
391
+ ))
392
+ return _base_layout(fig, height, margin=(8, 8, 8, 8))
393
+
394
+
395
+ def overlaid_returns(curves: dict[str, pd.Series], *, height: int = 300,
396
+ oos_start=None) -> go.Figure:
397
+ """Cumulative return of several runs on one shared scale."""
398
+ if not curves:
399
+ return empty_figure("select runs to compare", height)
400
+ fig = go.Figure()
401
+ for i, (name, eq) in enumerate(curves.items()):
402
+ if eq is None or eq.empty:
403
+ continue
404
+ pct = (eq / float(eq.iloc[0]) - 1.0) * 100.0
405
+ fig.add_trace(go.Scatter(
406
+ x=pct.index, y=pct.to_numpy(), name=name[:34],
407
+ line=dict(color=SERIES_COLORS[i % len(SERIES_COLORS)], width=1.4),
408
+ hovertemplate=f"{name}: %{{y:.1f}}%<extra></extra>",
409
+ ))
410
+ if oos_start is not None:
411
+ fig.add_vrect(x0=oos_start, x1=max(s.index[-1] for s in curves.values() if len(s)),
412
+ fillcolor=PALETTE["moss"], opacity=0.07, line_width=0, layer="below")
413
+ fig.update_yaxes(ticksuffix="%")
414
+ return _base_layout(fig, height, showlegend=True, margin=(8, 8, 26, 8))
415
+
416
+
417
+ def small_multiples(curves: dict[str, pd.Series], *, height: int = 260) -> go.Figure:
418
+ """Grid of equity curves, one per selected run, on a shared y-scale."""
419
+ from plotly.subplots import make_subplots
420
+
421
+ if not curves:
422
+ return empty_figure("select runs to compare", height)
423
+ n = len(curves)
424
+ cols = min(3, n)
425
+ rows = (n + cols - 1) // cols
426
+ fig = make_subplots(rows=rows, cols=cols, subplot_titles=[k[:26] for k in curves],
427
+ vertical_spacing=0.18, horizontal_spacing=0.06)
428
+ for i, (name, eq) in enumerate(curves.items()):
429
+ r, c = divmod(i, cols)
430
+ pct = (eq / float(eq.iloc[0]) - 1.0) * 100.0 if len(eq) else eq
431
+ fig.add_trace(go.Scatter(
432
+ x=pct.index, y=pct.to_numpy(), showlegend=False,
433
+ line=dict(color=SERIES_COLORS[i % len(SERIES_COLORS)], width=1.2),
434
+ ), row=r + 1, col=c + 1)
435
+ fig.update_annotations(font=dict(family=FONT, size=9, color=PALETTE["text_secondary"]))
436
+ return _base_layout(fig, max(height, 130 * rows), margin=(8, 8, 22, 8))
437
+
438
+
439
+ def correlation_matrix(returns: dict[str, pd.Series], height: int = 280) -> go.Figure:
440
+ """Return correlation between selected runs -- 'are these the same bet?'"""
441
+ if len(returns) < 2:
442
+ return empty_figure("select at least two runs", height)
443
+ df = pd.DataFrame({k: v for k, v in returns.items()}).dropna()
444
+ if df.empty or df.shape[1] < 2:
445
+ return empty_figure("no overlapping period", height)
446
+ corr = df.corr()
447
+ fig = go.Figure(go.Heatmap(
448
+ z=corr.to_numpy(), x=[c[:18] for c in corr.columns], y=[c[:18] for c in corr.index],
449
+ zmin=-1, zmax=1,
450
+ colorscale=[[0.0, PALETTE["mute_blue"]], [0.5, PALETTE["panel"]],
451
+ [1.0, PALETTE["amber_strong"]]],
452
+ hovertemplate="%{y} vs %{x}<br>r = %{z:.2f}<extra></extra>",
453
+ colorbar=dict(thickness=8, len=0.8, tickfont=dict(size=9), outlinewidth=0),
454
+ xgap=2, ygap=2,
455
+ ))
456
+ return _base_layout(fig, height)
457
+
458
+
459
+ def regime_bars(by_regime: pd.DataFrame, height: int = 260, cvd: bool = False) -> go.Figure:
460
+ """Return grouped by market regime (bull / bear / chop)."""
461
+ if by_regime is None or by_regime.empty:
462
+ return empty_figure("no regime breakdown", height)
463
+ fig = go.Figure()
464
+ for i, col in enumerate([c for c in by_regime.columns if c != "regime"]):
465
+ fig.add_trace(go.Bar(
466
+ x=by_regime["regime"], y=by_regime[col] * 100.0, name=col[:24],
467
+ marker_color=SERIES_COLORS[i % len(SERIES_COLORS)],
468
+ hovertemplate="%{x}: %{y:.1f}%<extra></extra>",
469
+ ))
470
+ fig.add_hline(y=0, line=dict(color=PALETTE["border"], width=1))
471
+ fig.update_yaxes(ticksuffix="%")
472
+ return _base_layout(fig, height, showlegend=True, margin=(8, 8, 26, 8))
473
+
474
+
475
+ # --------------------------------------------------------------------------
476
+ # Robustness
477
+ # --------------------------------------------------------------------------
478
+
479
+
480
+ def parameter_sensitivity(grid: pd.DataFrame, *, x: str, y: str, z: str = "oos_sharpe",
481
+ chosen: tuple | None = None, height: int = 300) -> go.Figure:
482
+ """Heatmap of OOS Sharpe across a two-parameter sweep."""
483
+ if grid is None or grid.empty:
484
+ return empty_figure("run a sweep to see sensitivity", height)
485
+ pivot = grid.pivot_table(index=y, columns=x, values=z, aggfunc="mean")
486
+ fig = go.Figure(go.Heatmap(
487
+ z=pivot.to_numpy(), x=list(pivot.columns), y=list(pivot.index),
488
+ colorscale=[[0.0, PALETTE["down"]], [0.35, PALETTE["panel"]],
489
+ [0.7, PALETTE["moss_dim"]], [1.0, PALETTE["amber_strong"]]],
490
+ hovertemplate=f"{x} %{{x}} · {y} %{{y}}<br>Sharpe %{{z:.2f}}<extra></extra>",
491
+ colorbar=dict(thickness=8, len=0.8, tickfont=dict(size=9), outlinewidth=0),
492
+ xgap=1, ygap=1,
493
+ ))
494
+ if chosen is not None:
495
+ fig.add_shape(type="rect",
496
+ x0=chosen[0] - 0.5, x1=chosen[0] + 0.5,
497
+ y0=chosen[1] - 0.5, y1=chosen[1] + 0.5,
498
+ line=dict(color=PALETTE["text"], width=2))
499
+ fig.update_xaxes(title=dict(text=x.upper(), font=dict(size=9)))
500
+ fig.update_yaxes(title=dict(text=y.upper(), font=dict(size=9)))
501
+ return _base_layout(fig, height, margin=(8, 8, 8, 28))
502
+
503
+
504
+ def monte_carlo_cone(paths: np.ndarray, index=None, *, height: int = 300) -> go.Figure:
505
+ """P5 / P50 / P95 cone over reshuffled trade sequences."""
506
+ if paths is None or len(paths) == 0:
507
+ return empty_figure("needs trades to reshuffle", height)
508
+ p5 = np.percentile(paths, 5, axis=0) * 100.0
509
+ p50 = np.percentile(paths, 50, axis=0) * 100.0
510
+ p95 = np.percentile(paths, 95, axis=0) * 100.0
511
+ x = list(index) if index is not None else list(range(len(p50)))
512
+
513
+ fig = go.Figure()
514
+ fig.add_trace(go.Scatter(x=x + x[::-1], y=list(p95) + list(p5)[::-1],
515
+ fill="toself", fillcolor="rgba(175,146,9,0.14)",
516
+ line=dict(width=0), hoverinfo="skip", showlegend=False))
517
+ fig.add_trace(go.Scatter(x=x, y=p50, line=dict(color=PALETTE["amber_strong"], width=1.6),
518
+ name="P50", hovertemplate="P50 %{y:.1f}%<extra></extra>"))
519
+ fig.add_hline(y=0, line=dict(color=PALETTE["border"], width=1))
520
+ fig.update_yaxes(ticksuffix="%")
521
+ return _base_layout(fig, height)
522
+
523
+
524
+ def walk_forward_bars(windows, height: int = 240, cvd: bool = False) -> go.Figure:
525
+ """Per-window OOS return -- the consistency check."""
526
+ if not windows:
527
+ return empty_figure("no walk-forward windows", height)
528
+ labels = [f"W{w.window.idx + 1}" for w in windows]
529
+ vals = [w.metrics.total_return * 100.0 for w in windows]
530
+ colors = [up_color(cvd) if v > 0 else down_color(cvd) for v in vals]
531
+ fig = go.Figure(go.Bar(
532
+ x=labels, y=vals, marker_color=colors,
533
+ hovertemplate="%{x}: %{y:+.1f}% OOS<extra></extra>",
534
+ ))
535
+ fig.add_hline(y=0, line=dict(color=PALETTE["border"], width=1))
536
+ fig.update_yaxes(ticksuffix="%")
537
+ return _base_layout(fig, height)
538
+
539
+
540
+ def slippage_stress(points: list[tuple[float, float]], height: int = 240) -> go.Figure:
541
+ """Sharpe as modelled slippage rises -- where does the edge die?"""
542
+ if not points:
543
+ return empty_figure("no stress run", height)
544
+ xs = [p[0] for p in points]
545
+ ys = [p[1] for p in points]
546
+ fig = go.Figure(go.Scatter(
547
+ x=xs, y=ys, mode="lines+markers",
548
+ line=dict(color=PALETTE["amber_strong"], width=1.6),
549
+ marker=dict(size=7, color=PALETTE["amber_strong"]),
550
+ hovertemplate="%{x} bps → Sharpe %{y:.2f}<extra></extra>",
551
+ ))
552
+ fig.add_hline(y=0, line=dict(color=PALETTE["down"], width=1, dash="dot"))
553
+ fig.update_xaxes(title=dict(text="SLIPPAGE (BPS)", font=dict(size=9)))
554
+ return _base_layout(fig, height, margin=(8, 8, 8, 28))
555
+
556
+
557
+ def regime_strip(prices: pd.DataFrame, height: int = 42) -> go.Figure:
558
+ """Thin bull/bear/chop band shown under the equity curve."""
559
+ if prices is None or prices.empty:
560
+ return empty_figure("", height)
561
+ reg = classify_regime(prices)
562
+ color_of = {"bull": PALETTE["moss_strong"], "bear": PALETTE["down"],
563
+ "chop": PALETTE["mute_yellow"]}
564
+ fig = go.Figure()
565
+ for lo, hi, label in _segments(reg):
566
+ fig.add_vrect(x0=reg.index[lo], x1=reg.index[hi],
567
+ fillcolor=color_of.get(label, PALETTE["border"]),
568
+ opacity=0.8, line_width=0)
569
+ fig.update_xaxes(visible=False)
570
+ fig.update_yaxes(visible=False, range=[0, 1])
571
+ fig.update_layout(margin=dict(l=0, r=0, t=0, b=0))
572
+ return _base_layout(fig, height, margin=(0, 0, 0, 0))
573
+
574
+
575
+ def classify_regime(prices: pd.DataFrame, window: int = 60) -> pd.Series:
576
+ """Bull / bear / chop from trailing trend and volatility. Causal."""
577
+ close = prices["close"]
578
+ trend = close.pct_change(window)
579
+ vol = close.pct_change().rolling(window).std()
580
+ med_vol = vol.rolling(window * 3, min_periods=window).median()
581
+ out = pd.Series("chop", index=close.index, dtype="object")
582
+ out[(trend > 0.05) & (vol <= med_vol * 1.5)] = "bull"
583
+ out[trend < -0.05] = "bear"
584
+ return out.fillna("chop")
585
+
586
+
587
+ def _segments(series: pd.Series) -> list[tuple[int, int, str]]:
588
+ vals = series.to_numpy()
589
+ out, start = [], 0
590
+ for i in range(1, len(vals)):
591
+ if vals[i] != vals[start]:
592
+ out.append((start, i - 1, vals[start]))
593
+ start = i
594
+ if len(vals):
595
+ out.append((start, len(vals) - 1, vals[start]))
596
+ return out
597
+
598
+
599
+ def monte_carlo_paths(trades: pd.DataFrame, n_paths: int = 1000, seed: int = 0) -> np.ndarray:
600
+ """Reshuffle the realised trade sequence `n_paths` times.
601
+
602
+ Seeded, so the cone the UI shows is reproducible run to run.
603
+ """
604
+ if trades is None or trades.empty:
605
+ return np.empty((0, 0))
606
+ rets = (trades["net_pnl"] / trades["entry_px"].abs().clip(lower=1e-9)
607
+ / trades["size"].abs().clip(lower=1e-9)).to_numpy()
608
+ rets = rets[np.isfinite(rets)]
609
+ if len(rets) == 0:
610
+ return np.empty((0, 0))
611
+ rng = np.random.default_rng(seed)
612
+ out = np.empty((n_paths, len(rets)))
613
+ for i in range(n_paths):
614
+ out[i] = np.cumprod(1.0 + rng.permutation(rets)) - 1.0
615
+ return out
src/comparisons.py ADDED
@@ -0,0 +1,324 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Derived comparison tables written into the store's `comparisons/` folder.
2
+
3
+ These are precomputed so the UI can render the Comparison tab instantly instead
4
+ of running inference or a sweep of backtests per page load. They are
5
+ regenerated after any coverage extension, so they never describe a stale store.
6
+
7
+ Four artefacts:
8
+
9
+ * `model_performance.parquet` — per (model, asset, timeframe): OOS Sharpe,
10
+ return and drawdown under the *default* Forecast Follower rule.
11
+ * `calibration.parquet` — empirical coverage of the q10-q90 band, plus pinball
12
+ losses. Answers "when this model says it is 80% sure, is it?".
13
+ * `directional_accuracy.parquet` — the model against three naive baselines.
14
+ * `strategy_timeframe_heatmap.parquet` — the strategy x timeframe OOS-Sharpe
15
+ matrix behind the Comparison tab's time-scale grid.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import logging
21
+ from dataclasses import dataclass
22
+
23
+ import numpy as np
24
+ import pandas as pd
25
+
26
+ from . import config, strategies
27
+ from .engine import BacktestConfig, Costs, Validation, run_backtest
28
+ from .metrics import (
29
+ calibration_coverage,
30
+ calibration_error,
31
+ directional_accuracy,
32
+ pinball_loss,
33
+ )
34
+ from .store import SignalStore
35
+
36
+ log = logging.getLogger("bit.comparisons")
37
+
38
+ MODEL_PERF = "comparisons/model_performance.parquet"
39
+ CALIBRATION = "comparisons/calibration.parquet"
40
+ DIRECTIONAL = "comparisons/directional_accuracy.parquet"
41
+ HEATMAP = "comparisons/strategy_timeframe_heatmap.parquet"
42
+ INDEX_JSON = "comparisons/index.json"
43
+
44
+ # Strategies shown in the time-scale matrix.
45
+ HEATMAP_STRATEGIES = (
46
+ "Buy & Hold (benchmark)",
47
+ "SMA Crossover",
48
+ "RSI Mean Reversion",
49
+ "Bollinger Breakout",
50
+ "MACD Momentum",
51
+ "Sentiment-Gated Momentum",
52
+ "Chronos Forecast Follower",
53
+ )
54
+
55
+
56
+ def _default_config(asset: str, timeframe: str, strategy: str) -> BacktestConfig:
57
+ """The single canonical config every comparison number is computed under.
58
+
59
+ Costs on, walk-forward validation, six-month locked holdout. Changing this
60
+ changes every published comparison, so it lives in one place.
61
+ """
62
+ return BacktestConfig(
63
+ asset=asset, timeframe=timeframe, strategy=strategy,
64
+ params=strategies.defaults_for(strategy),
65
+ costs=Costs(enabled=True),
66
+ validation=Validation(mode="walk_forward", train_months=12,
67
+ test_months=3, roll_months=3, holdout_months=6),
68
+ )
69
+
70
+
71
+ @dataclass
72
+ class ComparisonReport:
73
+ model_performance: pd.DataFrame
74
+ calibration: pd.DataFrame
75
+ directional: pd.DataFrame
76
+ heatmap: pd.DataFrame
77
+
78
+ def is_empty(self) -> bool:
79
+ return all(df.empty for df in
80
+ (self.model_performance, self.calibration, self.directional, self.heatmap))
81
+
82
+
83
+ # --------------------------------------------------------------------------
84
+ # Calibration & directional accuracy
85
+ # --------------------------------------------------------------------------
86
+
87
+
88
+ def calibration_row(prices: pd.DataFrame, signals: pd.DataFrame,
89
+ model_slug: str, asset: str, timeframe: str) -> dict | None:
90
+ """Compare each forecast against the outcome it was predicting.
91
+
92
+ The forecast stored at `t` is a one-step-ahead prediction, so it is scored
93
+ against the close at `t+1`, never against the close at `t`.
94
+ """
95
+ if signals.empty or prices.empty:
96
+ return None
97
+ close = prices["close"]
98
+ aligned = signals.reindex(close.index).dropna(subset=["q50"])
99
+ if aligned.empty:
100
+ return None
101
+
102
+ actual_next = close.shift(-1).reindex(aligned.index)
103
+ valid = actual_next.notna()
104
+ if valid.sum() < 20:
105
+ return None
106
+
107
+ actual_next = actual_next[valid]
108
+ q10 = aligned.loc[valid.index[valid], "q10"]
109
+ q50 = aligned.loc[valid.index[valid], "q50"]
110
+ q90 = aligned.loc[valid.index[valid], "q90"]
111
+
112
+ coverage = calibration_coverage(actual_next, q10, q90)
113
+ return {
114
+ "model_slug": model_slug, "asset": asset, "timeframe": timeframe,
115
+ "n": int(valid.sum()),
116
+ "coverage_q10_q90": coverage,
117
+ "nominal_coverage": 0.80,
118
+ "calibration_error": calibration_error(coverage, 0.80),
119
+ "pinball_q10": pinball_loss(actual_next, q10, 0.10),
120
+ "pinball_q50": pinball_loss(actual_next, q50, 0.50),
121
+ "pinball_q90": pinball_loss(actual_next, q90, 0.90),
122
+ "is_placeholder": bool(
123
+ (aligned["inference_version"] == config.PLACEHOLDER_VERSION).any()
124
+ if "inference_version" in aligned.columns else False
125
+ ),
126
+ }
127
+
128
+
129
+ def directional_row(prices: pd.DataFrame, signals: pd.DataFrame,
130
+ model_slug: str, asset: str, timeframe: str,
131
+ seed: int = 0) -> dict | None:
132
+ """Model directional hit-rate against three naive baselines.
133
+
134
+ Baselines: coin-flip (seeded, so the table is reproducible), momentum
135
+ (continue the last move), and yesterday's-move repeated. All are computed
136
+ causally from data available at the decision bar.
137
+ """
138
+ if signals.empty or prices.empty:
139
+ return None
140
+ close = prices["close"]
141
+ aligned = signals.reindex(close.index).dropna(subset=["q50"])
142
+ if aligned.empty:
143
+ return None
144
+
145
+ ref = close.reindex(aligned.index)
146
+ actual_next = close.shift(-1).reindex(aligned.index)
147
+ mask = actual_next.notna() & ref.notna()
148
+ if mask.sum() < 20:
149
+ return None
150
+
151
+ ref, actual_next = ref[mask], actual_next[mask]
152
+ q50 = aligned.loc[mask.index[mask], "q50"]
153
+
154
+ prev_move = close.diff().reindex(ref.index).fillna(0.0)
155
+ rng = np.random.default_rng(seed)
156
+ coin = pd.Series(rng.choice([-1.0, 1.0], size=len(ref)), index=ref.index)
157
+
158
+ return {
159
+ "model_slug": model_slug, "asset": asset, "timeframe": timeframe,
160
+ "n": int(mask.sum()),
161
+ "model_accuracy": directional_accuracy(actual_next, q50, ref),
162
+ "baseline_random": directional_accuracy(actual_next, ref + coin, ref),
163
+ "baseline_momentum": directional_accuracy(actual_next, ref + prev_move, ref),
164
+ "baseline_yesterday_move": directional_accuracy(
165
+ actual_next, ref + prev_move.shift(1).fillna(0.0), ref
166
+ ),
167
+ "is_placeholder": bool(
168
+ (aligned["inference_version"] == config.PLACEHOLDER_VERSION).any()
169
+ if "inference_version" in aligned.columns else False
170
+ ),
171
+ }
172
+
173
+
174
+ # --------------------------------------------------------------------------
175
+ # Backtest-derived tables
176
+ # --------------------------------------------------------------------------
177
+
178
+
179
+ def _run(strategy: str, prices: pd.DataFrame, signals: pd.DataFrame | None,
180
+ asset: str, timeframe: str):
181
+ cfg = _default_config(asset, timeframe, strategy)
182
+ out = strategies.build(strategy, prices, cfg.params, signals)
183
+ return run_backtest(prices, out, cfg,
184
+ bars_per_year=config.bars_per_year(asset, timeframe))
185
+
186
+
187
+ def model_performance_row(prices, signals, model_slug, asset, timeframe) -> dict | None:
188
+ """OOS performance of the default Forecast Follower rule over this model."""
189
+ if signals is None or signals.empty:
190
+ return None
191
+ try:
192
+ res = _run("Chronos Forecast Follower", prices, signals, asset, timeframe)
193
+ except Exception as e:
194
+ log.warning("forecast-follower run failed for %s/%s/%s: %s",
195
+ model_slug, asset, timeframe, e)
196
+ return None
197
+
198
+ m = res.metrics_oos
199
+ return {
200
+ "model_slug": model_slug, "asset": asset, "timeframe": timeframe,
201
+ "strategy": "Chronos Forecast Follower",
202
+ "oos_sharpe": m.sharpe, "oos_return": m.total_return,
203
+ "oos_max_drawdown": m.max_drawdown, "oos_sortino": m.sortino,
204
+ "trades": m.trade_count, "win_rate": m.win_rate,
205
+ "costs_paid": res.costs_paid,
206
+ "holdout_sharpe": res.metrics_holdout.sharpe if res.metrics_holdout else float("nan"),
207
+ "is_sharpe": res.metrics_is.sharpe,
208
+ "oos_is_ratio": (m.sharpe / res.metrics_is.sharpe)
209
+ if res.metrics_is.sharpe not in (0.0, None) else float("nan"),
210
+ "is_placeholder": bool(
211
+ (signals["inference_version"] == config.PLACEHOLDER_VERSION).any()
212
+ if "inference_version" in signals.columns else False
213
+ ),
214
+ }
215
+
216
+
217
+ def heatmap_rows(store: SignalStore, asset: str, model_slug: str | None = None) -> list[dict]:
218
+ """OOS Sharpe for every (strategy, timeframe) pair that has price coverage."""
219
+ rows = []
220
+ for tf in config.TIMEFRAMES:
221
+ prices = store.get_prices(asset, tf)
222
+ if len(prices) < 120:
223
+ continue
224
+ signals = (store.get_signals(model_slug, asset, tf)
225
+ if model_slug else pd.DataFrame())
226
+ for strategy in HEATMAP_STRATEGIES:
227
+ preset = strategies.PRESETS.get(strategy)
228
+ if preset is None or not preset.available:
229
+ continue
230
+ if preset.needs_signals and (signals is None or signals.empty):
231
+ rows.append({"asset": asset, "strategy": strategy, "timeframe": tf,
232
+ "oos_sharpe": float("nan"), "trades": 0,
233
+ "status": "no signal coverage"})
234
+ continue
235
+ try:
236
+ res = _run(strategy, prices, signals, asset, tf)
237
+ rows.append({
238
+ "asset": asset, "strategy": strategy, "timeframe": tf,
239
+ "oos_sharpe": res.metrics_oos.sharpe,
240
+ "oos_return": res.metrics_oos.total_return,
241
+ "trades": res.metrics_oos.trade_count,
242
+ "status": "ok",
243
+ })
244
+ except Exception as e:
245
+ log.warning("heatmap cell failed %s/%s/%s: %s", asset, strategy, tf, e)
246
+ rows.append({"asset": asset, "strategy": strategy, "timeframe": tf,
247
+ "oos_sharpe": float("nan"), "trades": 0,
248
+ "status": f"error: {type(e).__name__}"})
249
+ return rows
250
+
251
+
252
+ # --------------------------------------------------------------------------
253
+ # Regeneration
254
+ # --------------------------------------------------------------------------
255
+
256
+
257
+ def regenerate(store: SignalStore, *, assets: list[str] | None = None,
258
+ write: bool = True) -> ComparisonReport:
259
+ """Rebuild every comparison table from what the store currently holds."""
260
+ manifest = store.load_manifest()
261
+ assets = assets or sorted({e.asset for e in manifest.signals.values()}
262
+ | {p.asset for p in manifest.prices.values()})
263
+
264
+ perf, calib, direc, heat = [], [], [], []
265
+
266
+ for entry in manifest.signals.values():
267
+ if assets and entry.asset not in assets:
268
+ continue
269
+ prices = store.get_prices(entry.asset, entry.timeframe)
270
+ signals = store.get_signals(entry.model_slug, entry.asset, entry.timeframe)
271
+ if prices.empty or signals.empty:
272
+ continue
273
+
274
+ r = model_performance_row(prices, signals, entry.model_slug,
275
+ entry.asset, entry.timeframe)
276
+ if r:
277
+ perf.append(r)
278
+ r = calibration_row(prices, signals, entry.model_slug, entry.asset, entry.timeframe)
279
+ if r:
280
+ calib.append(r)
281
+ r = directional_row(prices, signals, entry.model_slug, entry.asset, entry.timeframe)
282
+ if r:
283
+ direc.append(r)
284
+
285
+ for asset in assets:
286
+ best_model = None
287
+ candidates = manifest.find_signals(asset=asset)
288
+ if candidates:
289
+ best_model = candidates[0].model_slug
290
+ heat.extend(heatmap_rows(store, asset, best_model))
291
+
292
+ report = ComparisonReport(
293
+ model_performance=pd.DataFrame(perf),
294
+ calibration=pd.DataFrame(calib),
295
+ directional=pd.DataFrame(direc),
296
+ heatmap=pd.DataFrame(heat),
297
+ )
298
+
299
+ if write:
300
+ if not report.model_performance.empty:
301
+ store.write_table(MODEL_PERF, report.model_performance)
302
+ if not report.calibration.empty:
303
+ store.write_table(CALIBRATION, report.calibration)
304
+ if not report.directional.empty:
305
+ store.write_table(DIRECTIONAL, report.directional)
306
+ if not report.heatmap.empty:
307
+ store.write_table(HEATMAP, report.heatmap)
308
+ store.write_json(INDEX_JSON, {
309
+ "generated_at": pd.Timestamp.now(tz="UTC").isoformat(),
310
+ "assets": list(assets),
311
+ "tables": {
312
+ "model_performance": {"path": MODEL_PERF, "rows": len(report.model_performance)},
313
+ "calibration": {"path": CALIBRATION, "rows": len(report.calibration)},
314
+ "directional_accuracy": {"path": DIRECTIONAL, "rows": len(report.directional)},
315
+ "strategy_timeframe_heatmap": {"path": HEATMAP, "rows": len(report.heatmap)},
316
+ },
317
+ "default_rule": "Chronos Forecast Follower, costs on, walk-forward 12/3/3, 6mo holdout",
318
+ })
319
+ return report
320
+
321
+
322
+ def load_table(store: SignalStore, path: str) -> pd.DataFrame:
323
+ df = store.read_parquet(path)
324
+ return df if df is not None else pd.DataFrame()
src/config.py ADDED
@@ -0,0 +1,207 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Central configuration for the Backtest Lab.
2
+
3
+ Everything that a maintainer might want to tune -- repo ids, provider chains,
4
+ asset universe, rate limits, guardrails -- lives here as data, not code.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from dataclasses import dataclass, field
11
+
12
+ # --------------------------------------------------------------------------
13
+ # Repos
14
+ # --------------------------------------------------------------------------
15
+
16
+ ORG = "The-Bit-Trading-Company"
17
+
18
+ # The shared signal store lives under the org (the company-branded data asset).
19
+ STORE_REPO = os.environ.get("BIT_STORE_REPO", f"{ORG}/bit-signal-store")
20
+ STORE_REPO_TYPE = "dataset"
21
+
22
+ # The Space itself. Gradio Spaces under an org require a paid Team/Enterprise
23
+ # plan, so the app is hosted under the owner's PRO personal namespace.
24
+ # See DECISIONS.md (D-001).
25
+ SPACE_REPO = os.environ.get("BIT_SPACE_REPO", "Bit-Trading-Company/bit-backtest-lab")
26
+
27
+ MANIFEST_PATH = "manifest.json"
28
+ MANIFEST_SCHEMA_VERSION = 1
29
+
30
+ # Bumped whenever a change to inference or storage semantics invalidates
31
+ # previously-written signal slices.
32
+ INFERENCE_VERSION = "1.0.0"
33
+ PLACEHOLDER_VERSION = "PLACEHOLDER"
34
+
35
+ # --------------------------------------------------------------------------
36
+ # Assets & timeframes
37
+ # --------------------------------------------------------------------------
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class Asset:
42
+ """One tradable symbol, with the per-provider symbol spellings it needs."""
43
+
44
+ slug: str # canonical id used in store paths, e.g. "BTC-USD"
45
+ display: str
46
+ kind: str # "crypto" | "equity"
47
+ ccxt_symbol: str | None = None
48
+ yahoo_symbol: str | None = None
49
+ stooq_symbol: str | None = None
50
+ tiingo_symbol: str | None = None
51
+
52
+
53
+ ASSETS: dict[str, Asset] = {
54
+ a.slug: a
55
+ for a in [
56
+ Asset("BTC-USD", "Bitcoin", "crypto", ccxt_symbol="BTC/USDT", yahoo_symbol="BTC-USD"),
57
+ Asset("ETH-USD", "Ethereum", "crypto", ccxt_symbol="ETH/USDT", yahoo_symbol="ETH-USD"),
58
+ Asset("SOL-USD", "Solana", "crypto", ccxt_symbol="SOL/USDT", yahoo_symbol="SOL-USD"),
59
+ Asset("SPY", "S&P 500 ETF", "equity", yahoo_symbol="SPY",
60
+ stooq_symbol="spy.us", tiingo_symbol="SPY"),
61
+ Asset("QQQ", "Nasdaq 100 ETF", "equity", yahoo_symbol="QQQ",
62
+ stooq_symbol="qqq.us", tiingo_symbol="QQQ"),
63
+ Asset("NVDA", "NVIDIA", "equity", yahoo_symbol="NVDA",
64
+ stooq_symbol="nvda.us", tiingo_symbol="NVDA"),
65
+ ]
66
+ }
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class Timeframe:
71
+ slug: str
72
+ pandas_freq: str
73
+ minutes: int
74
+ bars_per_year: float
75
+ ccxt_tf: str | None = None
76
+ yahoo_interval: str | None = None
77
+ # Provider-imposed history depth, in days. None = no practical limit.
78
+ # These are honest coverage boundaries, not errors (see data.py).
79
+ yahoo_max_days: int | None = None
80
+
81
+
82
+ TIMEFRAMES: dict[str, Timeframe] = {
83
+ t.slug: t
84
+ for t in [
85
+ Timeframe("1d", "D", 1440, 365.0, ccxt_tf="1d", yahoo_interval="1d"),
86
+ Timeframe("1h", "h", 60, 365.0 * 24, ccxt_tf="1h", yahoo_interval="1h",
87
+ yahoo_max_days=730),
88
+ Timeframe("15m", "15min", 15, 365.0 * 24 * 4, ccxt_tf="15m",
89
+ yahoo_interval="15m", yahoo_max_days=60),
90
+ ]
91
+ }
92
+
93
+ # Equities only trade during market hours, so a calendar year holds far fewer
94
+ # bars than the wall-clock math above. Annualisation uses these instead.
95
+ EQUITY_BARS_PER_YEAR = {"1d": 252.0, "1h": 252.0 * 6.5, "15m": 252.0 * 26.0}
96
+
97
+
98
+ def bars_per_year(asset_slug: str, tf_slug: str) -> float:
99
+ """Annualisation factor for Sharpe/CAGR, respecting market calendars."""
100
+ asset = ASSETS.get(asset_slug)
101
+ if asset is not None and asset.kind == "equity":
102
+ return EQUITY_BARS_PER_YEAR[tf_slug]
103
+ return TIMEFRAMES[tf_slug].bars_per_year
104
+
105
+
106
+ # --------------------------------------------------------------------------
107
+ # Provider chain (config, not code -- data.py walks these in order)
108
+ # --------------------------------------------------------------------------
109
+
110
+
111
+ @dataclass(frozen=True)
112
+ class ProviderSpec:
113
+ name: str
114
+ kinds: tuple[str, ...]
115
+ # Minimum seconds between calls, and backoff schedule on failure.
116
+ min_interval_s: float = 0.25
117
+ max_retries: int = 4
118
+ backoff_base_s: float = 1.5
119
+ requires_env: str | None = None
120
+
121
+
122
+ PROVIDER_CHAIN: tuple[ProviderSpec, ...] = (
123
+ ProviderSpec("binance", ("crypto",), min_interval_s=0.10),
124
+ ProviderSpec("coinbase", ("crypto",), min_interval_s=0.35),
125
+ ProviderSpec("yfinance", ("equity",), min_interval_s=1.20),
126
+ ProviderSpec("stooq", ("equity",), min_interval_s=1.00),
127
+ ProviderSpec("tiingo", ("equity",), min_interval_s=0.60, requires_env="TIINGO_KEY"),
128
+ )
129
+
130
+
131
+ def providers_for(kind: str) -> list[ProviderSpec]:
132
+ """Ordered, currently-usable providers for an asset kind."""
133
+ out = []
134
+ for p in PROVIDER_CHAIN:
135
+ if kind not in p.kinds:
136
+ continue
137
+ if p.requires_env and not os.environ.get(p.requires_env):
138
+ continue
139
+ out.append(p)
140
+ return out
141
+
142
+
143
+ # --------------------------------------------------------------------------
144
+ # Models
145
+ # --------------------------------------------------------------------------
146
+
147
+
148
+ @dataclass(frozen=True)
149
+ class ModelSpec:
150
+ slug: str # store path segment
151
+ model_id: str # HF model id
152
+ family: str # adapter family
153
+ display: str
154
+ context_len: int = 512
155
+ quantile_levels: tuple[float, ...] = (0.1, 0.5, 0.9)
156
+
157
+
158
+ SEED_MODELS: dict[str, ModelSpec] = {
159
+ m.slug: m
160
+ for m in [
161
+ ModelSpec("chronos-bolt-small", "amazon/chronos-bolt-small", "chronos",
162
+ "Chronos-Bolt Small", context_len=512),
163
+ ModelSpec("chronos-bolt-base", "amazon/chronos-bolt-base", "chronos",
164
+ "Chronos-Bolt Base", context_len=512),
165
+ ModelSpec("timesfm-2-500m", "google/timesfm-2.0-500m-pytorch", "timesfm",
166
+ "TimesFM 2.0 500M", context_len=512),
167
+ ]
168
+ }
169
+
170
+ # Adapter families a user may pick from in the "Add model" flow. Restricting to
171
+ # a fixed set is what keeps arbitrary model code from ever being executed.
172
+ ALLOWED_ADAPTER_FAMILIES = ("chronos", "timesfm")
173
+
174
+
175
+ # --------------------------------------------------------------------------
176
+ # Guardrails for user-funded coverage extension
177
+ # --------------------------------------------------------------------------
178
+
179
+
180
+ @dataclass(frozen=True)
181
+ class ExtensionCaps:
182
+ max_days: dict[str, int] = field(
183
+ default_factory=lambda: {"1d": 730, "1h": 183, "15m": 62}
184
+ )
185
+ max_steps_per_run: int = 4000
186
+ smoke_test_steps: int = 100
187
+
188
+
189
+ CAPS = ExtensionCaps()
190
+
191
+ # --------------------------------------------------------------------------
192
+ # Backtest defaults
193
+ # --------------------------------------------------------------------------
194
+
195
+ DEFAULT_INIT_CASH = 10_000.0
196
+ DEFAULT_COMMISSION_BPS = 10.0 # per side
197
+ DEFAULT_SLIPPAGE_BPS = 5.0
198
+ DEFAULT_HOLDOUT_MONTHS = 6
199
+
200
+ # In-process LRU sizing for parquet slices (Phase 3 perf target: <2s runs).
201
+ PARQUET_CACHE_SIZE = 64
202
+
203
+ DISCLAIMER = (
204
+ "Backtested results are hypothetical, derived from historical data, and are "
205
+ "not indicative of future results. Nothing here is investment advice. "
206
+ "The Bit Trading Company is not a licensed investment adviser."
207
+ )
src/data.py ADDED
@@ -0,0 +1,458 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """OHLCV acquisition: provider chain, rate limiting, validation, refresh.
2
+
3
+ Two hard rules enforced here:
4
+
5
+ 1. **Only the batch refresh path touches an external provider.** User-facing
6
+ requests read exclusively from the cached store. `allow_network()` gates
7
+ every outbound call and is off unless a refresh explicitly opens it.
8
+ 2. **Never silently return partial data.** Every fetch reports what it actually
9
+ got versus what was asked for; short coverage becomes an honest boundary in
10
+ the manifest, never a silent truncation and never an error.
11
+
12
+ Providers are declared in `config.PROVIDER_CHAIN`; adding one means adding a
13
+ spec plus a fetch function to `_FETCHERS`, not restructuring the chain.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import contextlib
19
+ import io
20
+ import logging
21
+ import os
22
+ import random
23
+ import threading
24
+ import time
25
+ from dataclasses import dataclass, field
26
+
27
+ import pandas as pd
28
+
29
+ from . import config
30
+ from .config import Asset, ProviderSpec
31
+ from .store import PRICE_COLUMNS, SignalStore, _iso, _utc, validate_price_frame
32
+
33
+ log = logging.getLogger("bit.data")
34
+
35
+ # --------------------------------------------------------------------------
36
+ # Network gate
37
+ # --------------------------------------------------------------------------
38
+
39
+ _network = threading.local()
40
+
41
+
42
+ def network_allowed() -> bool:
43
+ return getattr(_network, "allowed", False)
44
+
45
+
46
+ @contextlib.contextmanager
47
+ def allow_network():
48
+ """Open the gate for a batch refresh. Scoped to the calling thread."""
49
+ prev = getattr(_network, "allowed", False)
50
+ _network.allowed = True
51
+ try:
52
+ yield
53
+ finally:
54
+ _network.allowed = prev
55
+
56
+
57
+ class ProviderError(RuntimeError):
58
+ """A provider failed in a way that should advance the chain."""
59
+
60
+
61
+ class NetworkNotAllowed(RuntimeError):
62
+ """A user-facing code path tried to reach an external provider."""
63
+
64
+
65
+ # --------------------------------------------------------------------------
66
+ # Rate limiting + backoff
67
+ # --------------------------------------------------------------------------
68
+
69
+
70
+ class RateLimiter:
71
+ """Per-provider minimum spacing between outbound calls, process-wide."""
72
+
73
+ _locks: dict[str, threading.Lock] = {}
74
+ _last: dict[str, float] = {}
75
+ _guard = threading.Lock()
76
+
77
+ @classmethod
78
+ def wait(cls, name: str, min_interval_s: float) -> None:
79
+ with cls._guard:
80
+ lock = cls._locks.setdefault(name, threading.Lock())
81
+ with lock:
82
+ last = cls._last.get(name, 0.0)
83
+ delta = time.monotonic() - last
84
+ if delta < min_interval_s:
85
+ time.sleep(min_interval_s - delta)
86
+ cls._last[name] = time.monotonic()
87
+
88
+
89
+ def with_backoff(spec: ProviderSpec, fn, *args, **kwargs):
90
+ """Run `fn` under the provider's rate limit with exponential backoff."""
91
+ last_err: Exception | None = None
92
+ for attempt in range(spec.max_retries):
93
+ RateLimiter.wait(spec.name, spec.min_interval_s)
94
+ try:
95
+ return fn(*args, **kwargs)
96
+ except Exception as e: # provider libs raise a wide variety
97
+ last_err = e
98
+ if attempt == spec.max_retries - 1:
99
+ break
100
+ sleep_s = (spec.backoff_base_s ** attempt) + random.uniform(0, 0.4)
101
+ log.warning(
102
+ "provider %s attempt %d/%d failed (%s); backing off %.1fs",
103
+ spec.name, attempt + 1, spec.max_retries, type(e).__name__, sleep_s,
104
+ )
105
+ time.sleep(sleep_s)
106
+ raise ProviderError(f"{spec.name} exhausted retries: {last_err}") from last_err
107
+
108
+
109
+ # --------------------------------------------------------------------------
110
+ # Fetch result
111
+ # --------------------------------------------------------------------------
112
+
113
+
114
+ @dataclass
115
+ class FetchResult:
116
+ frame: pd.DataFrame
117
+ source: str
118
+ requested_start: pd.Timestamp
119
+ requested_end: pd.Timestamp
120
+ # True when the provider's own history depth, not our request, set the floor.
121
+ truncated_by_provider: bool = False
122
+ provider_max_days: int | None = None
123
+ notes: list[str] = field(default_factory=list)
124
+
125
+ @property
126
+ def rows(self) -> int:
127
+ return len(self.frame)
128
+
129
+ @property
130
+ def actual_start(self) -> pd.Timestamp | None:
131
+ return None if self.frame.empty else _utc(self.frame["ts"].iloc[0])
132
+
133
+ @property
134
+ def actual_end(self) -> pd.Timestamp | None:
135
+ return None if self.frame.empty else _utc(self.frame["ts"].iloc[-1])
136
+
137
+
138
+ def _frame(rows: list[dict], source: str) -> pd.DataFrame:
139
+ df = pd.DataFrame(rows, columns=["ts", "open", "high", "low", "close", "volume"])
140
+ df["source"] = source
141
+ if not df.empty:
142
+ df["ts"] = df["ts"].map(_utc)
143
+ df = df.drop_duplicates(subset="ts", keep="last").sort_values("ts")
144
+ return df.loc[:, PRICE_COLUMNS].reset_index(drop=True)
145
+
146
+
147
+ # --------------------------------------------------------------------------
148
+ # Providers
149
+ # --------------------------------------------------------------------------
150
+
151
+
152
+ def _ccxt_exchange(name: str):
153
+ import ccxt
154
+
155
+ klass = getattr(ccxt, name)
156
+ ex = klass({"enableRateLimit": True, "timeout": 20000})
157
+ return ex
158
+
159
+
160
+ def _fetch_ccxt(exchange_name: str, spec: ProviderSpec, asset: Asset,
161
+ tf: str, start, end) -> pd.DataFrame:
162
+ if not asset.ccxt_symbol:
163
+ raise ProviderError(f"{asset.slug} has no ccxt symbol")
164
+ ex = _ccxt_exchange(exchange_name)
165
+ ccxt_tf = config.TIMEFRAMES[tf].ccxt_tf
166
+ step_ms = config.TIMEFRAMES[tf].minutes * 60_000
167
+ since = int(_utc(start).timestamp() * 1000)
168
+ end_ms = int(_utc(end).timestamp() * 1000)
169
+ rows: list[dict] = []
170
+ guard = 0
171
+
172
+ while since < end_ms and guard < 4000:
173
+ guard += 1
174
+ batch = with_backoff(
175
+ spec, ex.fetch_ohlcv, asset.ccxt_symbol, ccxt_tf, since, 1000
176
+ )
177
+ if not batch:
178
+ break
179
+ for ts, o, h, l, c, v in batch:
180
+ if ts > end_ms:
181
+ break
182
+ rows.append({"ts": pd.Timestamp(ts, unit="ms", tz="UTC"),
183
+ "open": o, "high": h, "low": l, "close": c, "volume": v})
184
+ last = batch[-1][0]
185
+ if last <= since:
186
+ break
187
+ since = last + step_ms
188
+
189
+ with contextlib.suppress(Exception):
190
+ ex.close()
191
+ return _frame(rows, exchange_name)
192
+
193
+
194
+ def _fetch_binance(spec, asset, tf, start, end):
195
+ return _fetch_ccxt("binance", spec, asset, tf, start, end)
196
+
197
+
198
+ def _fetch_coinbase(spec, asset, tf, start, end):
199
+ return _fetch_ccxt("coinbase", spec, asset, tf, start, end)
200
+
201
+
202
+ def _fetch_yfinance(spec: ProviderSpec, asset: Asset, tf: str, start, end) -> pd.DataFrame:
203
+ if not asset.yahoo_symbol:
204
+ raise ProviderError(f"{asset.slug} has no Yahoo symbol")
205
+ import yfinance as yf
206
+
207
+ interval = config.TIMEFRAMES[tf].yahoo_interval
208
+ max_days = config.TIMEFRAMES[tf].yahoo_max_days
209
+ s, e = _utc(start), _utc(end)
210
+ if max_days is not None:
211
+ floor = pd.Timestamp.now(tz="UTC") - pd.Timedelta(days=max_days - 1)
212
+ s = max(s, floor) # honest boundary; see refresh() notes
213
+
214
+ def _call():
215
+ return yf.download(
216
+ asset.yahoo_symbol, start=s.date(), end=(e + pd.Timedelta(days=1)).date(),
217
+ interval=interval, auto_adjust=False, progress=False, threads=False,
218
+ )
219
+
220
+ raw = with_backoff(spec, _call)
221
+ if raw is None or raw.empty:
222
+ raise ProviderError("yfinance returned no rows")
223
+ if isinstance(raw.columns, pd.MultiIndex):
224
+ raw.columns = raw.columns.get_level_values(0)
225
+ # reset_index first: the timestamp arrives as the index ("Date"/"Datetime")
226
+ # and only becomes a column here, so lowercasing must happen afterwards.
227
+ raw = raw.reset_index()
228
+ raw.columns = [str(c).lower() for c in raw.columns]
229
+ tcol = next((c for c in ("datetime", "date", "index") if c in raw.columns), None)
230
+ if tcol is None:
231
+ raise ProviderError(f"yfinance frame has no timestamp column: {list(raw.columns)}")
232
+ rows = [
233
+ {"ts": r[tcol], "open": r["open"], "high": r["high"],
234
+ "low": r["low"], "close": r["close"], "volume": r.get("volume", 0.0)}
235
+ for _, r in raw.iterrows()
236
+ ]
237
+ return _frame(rows, "yfinance")
238
+
239
+
240
+ def _fetch_stooq(spec: ProviderSpec, asset: Asset, tf: str, start, end) -> pd.DataFrame:
241
+ """Stooq CSV endpoint -- no key, daily only."""
242
+ if tf != "1d":
243
+ raise ProviderError("stooq serves daily bars only")
244
+ if not asset.stooq_symbol:
245
+ raise ProviderError(f"{asset.slug} has no Stooq symbol")
246
+ import requests
247
+
248
+ url = f"https://stooq.com/q/d/l/?s={asset.stooq_symbol}&i=d"
249
+
250
+ def _call():
251
+ r = requests.get(url, timeout=20)
252
+ r.raise_for_status()
253
+ if "Date" not in r.text[:64]:
254
+ raise ProviderError("stooq returned no CSV header (rate limited?)")
255
+ return r.text
256
+
257
+ text = with_backoff(spec, _call)
258
+ raw = pd.read_csv(io.StringIO(text))
259
+ raw.columns = [c.lower() for c in raw.columns]
260
+ raw = raw.dropna(subset=["open", "high", "low", "close"])
261
+ s, e = _utc(start), _utc(end)
262
+ rows = []
263
+ for _, r in raw.iterrows():
264
+ ts = _utc(r["date"])
265
+ if ts < s or ts > e:
266
+ continue
267
+ rows.append({"ts": ts, "open": r["open"], "high": r["high"],
268
+ "low": r["low"], "close": r["close"], "volume": r.get("volume", 0.0)})
269
+ return _frame(rows, "stooq")
270
+
271
+
272
+ def _fetch_tiingo(spec: ProviderSpec, asset: Asset, tf: str, start, end) -> pd.DataFrame:
273
+ if tf != "1d":
274
+ raise ProviderError("tiingo adapter covers daily bars only")
275
+ key = os.environ.get("TIINGO_KEY")
276
+ if not key:
277
+ raise ProviderError("TIINGO_KEY not set")
278
+ import requests
279
+
280
+ sym = asset.tiingo_symbol or asset.slug
281
+ url = f"https://api.tiingo.com/tiingo/daily/{sym}/prices"
282
+ params = {"startDate": _utc(start).date().isoformat(),
283
+ "endDate": _utc(end).date().isoformat(), "token": key}
284
+
285
+ def _call():
286
+ r = requests.get(url, params=params, timeout=20)
287
+ r.raise_for_status()
288
+ return r.json()
289
+
290
+ payload = with_backoff(spec, _call)
291
+ rows = [
292
+ {"ts": _utc(d["date"]), "open": d["open"], "high": d["high"],
293
+ "low": d["low"], "close": d["close"], "volume": d.get("volume", 0.0)}
294
+ for d in payload
295
+ ]
296
+ return _frame(rows, "tiingo")
297
+
298
+
299
+ _FETCHERS = {
300
+ "binance": _fetch_binance,
301
+ "coinbase": _fetch_coinbase,
302
+ "yfinance": _fetch_yfinance,
303
+ "stooq": _fetch_stooq,
304
+ "tiingo": _fetch_tiingo,
305
+ }
306
+
307
+
308
+ # --------------------------------------------------------------------------
309
+ # Chain walk
310
+ # --------------------------------------------------------------------------
311
+
312
+
313
+ def fetch_ohlcv(asset_slug: str, timeframe: str, start, end) -> FetchResult:
314
+ """Walk the provider chain for `asset_slug` until one returns rows."""
315
+ if not network_allowed():
316
+ raise NetworkNotAllowed(
317
+ "external providers are reachable only from the batch refresh path; "
318
+ "user-facing requests must read from the cached store"
319
+ )
320
+ asset = config.ASSETS.get(asset_slug)
321
+ if asset is None:
322
+ raise ProviderError(f"unknown asset {asset_slug!r}")
323
+ if timeframe not in config.TIMEFRAMES:
324
+ raise ProviderError(f"unknown timeframe {timeframe!r}")
325
+
326
+ s, e = _utc(start), _utc(end)
327
+ chain = config.providers_for(asset.kind)
328
+ if not chain:
329
+ raise ProviderError(f"no usable provider for {asset.kind}")
330
+
331
+ notes: list[str] = []
332
+ for spec in chain:
333
+ fetcher = _FETCHERS.get(spec.name)
334
+ if fetcher is None:
335
+ continue
336
+ try:
337
+ frame = fetcher(spec, asset, timeframe, s, e)
338
+ except Exception as exc:
339
+ notes.append(f"{spec.name}: {type(exc).__name__}: {exc}")
340
+ log.warning("provider %s failed for %s %s: %s", spec.name, asset_slug, timeframe, exc)
341
+ continue
342
+ if frame.empty:
343
+ notes.append(f"{spec.name}: returned 0 rows")
344
+ continue
345
+
346
+ max_days = config.TIMEFRAMES[timeframe].yahoo_max_days if spec.name == "yfinance" else None
347
+ actual_start = _utc(frame["ts"].iloc[0])
348
+ truncated = max_days is not None and actual_start > s + pd.Timedelta(days=1)
349
+ if truncated:
350
+ notes.append(
351
+ f"{spec.name} serves at most ~{max_days}d of {timeframe} bars; "
352
+ f"coverage starts {_iso(actual_start)}"
353
+ )
354
+ return FetchResult(
355
+ frame=frame, source=spec.name, requested_start=s, requested_end=e,
356
+ truncated_by_provider=truncated, provider_max_days=max_days, notes=notes,
357
+ )
358
+
359
+ raise ProviderError(
360
+ f"all providers failed for {asset_slug} {timeframe} "
361
+ f"[{_iso(s)} .. {_iso(e)}]: " + " | ".join(notes)
362
+ )
363
+
364
+
365
+ # --------------------------------------------------------------------------
366
+ # Refresh
367
+ # --------------------------------------------------------------------------
368
+
369
+
370
+ @dataclass
371
+ class RefreshReport:
372
+ asset: str
373
+ timeframe: str
374
+ fetched_ranges: list[tuple[str, str]] = field(default_factory=list)
375
+ rows_added: int = 0
376
+ sources: list[str] = field(default_factory=list)
377
+ skipped_cached: bool = False
378
+ gaps: int = 0
379
+ boundary_notes: list[str] = field(default_factory=list)
380
+ errors: list[str] = field(default_factory=list)
381
+
382
+ @property
383
+ def ok(self) -> bool:
384
+ return not self.errors
385
+
386
+ def summary(self) -> str:
387
+ if self.skipped_cached:
388
+ return f"{self.asset} {self.timeframe}: already cached, nothing fetched"
389
+ if self.errors:
390
+ return f"{self.asset} {self.timeframe}: FAILED -- {'; '.join(self.errors)}"
391
+ return (
392
+ f"{self.asset} {self.timeframe}: +{self.rows_added} rows "
393
+ f"from {','.join(self.sources) or 'n/a'} ({self.gaps} gaps)"
394
+ )
395
+
396
+
397
+ def missing_price_ranges(
398
+ store: SignalStore, asset: str, timeframe: str, start, end
399
+ ) -> list[tuple[pd.Timestamp, pd.Timestamp]]:
400
+ """Sub-ranges of [start, end] absent from the price cache."""
401
+ s, e = _utc(start), _utc(end)
402
+ if s > e:
403
+ return []
404
+ cov = store.load_manifest().prices.get(f"{asset}|{timeframe}")
405
+ if cov is None or cov.rows == 0:
406
+ return [(s, e)]
407
+ cs, ce = _utc(cov.start_ts), _utc(cov.end_ts)
408
+ step = pd.Timedelta(minutes=config.TIMEFRAMES[timeframe].minutes)
409
+ out = []
410
+ if s < cs:
411
+ out.append((s, min(e, cs - step)))
412
+ if e > ce:
413
+ out.append((max(s, ce + step), e))
414
+ return [(a, b) for a, b in out if a <= b]
415
+
416
+
417
+ def refresh(
418
+ store: SignalStore, asset: str, timeframe: str, start, end, *, strict: bool = False
419
+ ) -> RefreshReport:
420
+ """Fetch only what the cache is missing, validate it, and write it.
421
+
422
+ Never fetches a range the manifest already covers.
423
+ """
424
+ rep = RefreshReport(asset=asset, timeframe=timeframe)
425
+ try:
426
+ gaps = missing_price_ranges(store, asset, timeframe, start, end)
427
+ except Exception as e:
428
+ rep.errors.append(f"coverage lookup failed: {e}")
429
+ return rep
430
+
431
+ if not gaps:
432
+ rep.skipped_cached = True
433
+ return rep
434
+
435
+ with allow_network():
436
+ for gs, ge in gaps:
437
+ try:
438
+ res = fetch_ohlcv(asset, timeframe, gs, ge)
439
+ except Exception as e:
440
+ rep.errors.append(f"[{_iso(gs)}..{_iso(ge)}] {e}")
441
+ continue
442
+
443
+ _, report = validate_price_frame(res.frame, timeframe, strict=strict)
444
+ if report.problems:
445
+ # Partial or dirty data is surfaced, never silently accepted.
446
+ rep.errors.append(
447
+ f"[{_iso(gs)}..{_iso(ge)}] validation: {'; '.join(report.problems)}"
448
+ )
449
+ continue
450
+
451
+ cov = store.write_prices(asset, timeframe, res.frame, strict=strict)
452
+ rep.fetched_ranges.append((_iso(gs), _iso(ge)))
453
+ rep.rows_added += res.rows
454
+ rep.gaps = max(rep.gaps, cov.gaps)
455
+ if res.source not in rep.sources:
456
+ rep.sources.append(res.source)
457
+ rep.boundary_notes.extend(res.notes)
458
+ return rep
src/engine.py ADDED
@@ -0,0 +1,707 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Backtest engine.
2
+
3
+ Two properties are enforced structurally rather than by convention:
4
+
5
+ **Fills happen at the next bar's open.** A strategy's decision at bar `t` is
6
+ shifted forward one bar by `_shift_decisions`, which `run_backtest` always
7
+ applies and a strategy cannot bypass, and the fill price handed to vectorbt is
8
+ `open`. There is no code path that enters on the signal bar.
9
+
10
+ **Strategies cannot see the future.** `assert_causal` perturbs the tail of a
11
+ price series, re-runs the indicator, and requires that every output before the
12
+ perturbation is bit-identical. A strategy that reads bar `t+1` changes its
13
+ earlier outputs when the future changes, and is caught. This is a property
14
+ test on the function, not a promise in a docstring.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import hashlib
20
+ import json
21
+ from dataclasses import dataclass, field, asdict, replace
22
+
23
+ import numpy as np
24
+ import pandas as pd
25
+ import vectorbt as vbt
26
+
27
+ from . import config
28
+ from .metrics import Metrics, compute_metrics
29
+
30
+ # --------------------------------------------------------------------------
31
+ # Errors
32
+ # --------------------------------------------------------------------------
33
+
34
+
35
+ class EngineError(RuntimeError):
36
+ pass
37
+
38
+
39
+ class LookaheadError(AssertionError):
40
+ """A strategy's output at bar t depended on data after bar t."""
41
+
42
+
43
+ # --------------------------------------------------------------------------
44
+ # Configuration
45
+ # --------------------------------------------------------------------------
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class Costs:
50
+ """Trading frictions. On by default -- turning them off is how a backtest lies."""
51
+
52
+ enabled: bool = True
53
+ commission_bps: float = config.DEFAULT_COMMISSION_BPS # per side
54
+ slippage_bps: float = config.DEFAULT_SLIPPAGE_BPS
55
+ slippage_model: str = "fixed" # "fixed" | "volume_scaled"
56
+ volume_scale_k: float = 0.5
57
+
58
+ @property
59
+ def commission_rate(self) -> float:
60
+ return (self.commission_bps / 10_000.0) if self.enabled else 0.0
61
+
62
+ @property
63
+ def slippage_rate(self) -> float:
64
+ return (self.slippage_bps / 10_000.0) if self.enabled else 0.0
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class Sizing:
69
+ mode: str = "fixed_pct" # "fixed_pct" | "vol_target" | "fixed_units"
70
+ pct: float = 1.0 # fraction of equity when fixed_pct
71
+ units: float = 1.0 # units when fixed_units
72
+ vol_target_ann: float = 0.15
73
+ vol_lookback: int = 20
74
+ leverage: float = 1.0
75
+ max_position: float = 1.0 # cap as fraction of equity
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class Stops:
80
+ sl_pct: float | None = None
81
+ tp_pct: float | None = None
82
+ trail_pct: float | None = None
83
+
84
+ @property
85
+ def any_set(self) -> bool:
86
+ return any(v is not None for v in (self.sl_pct, self.tp_pct, self.trail_pct))
87
+
88
+
89
+ @dataclass(frozen=True)
90
+ class Validation:
91
+ """How the period is carved into in-sample and out-of-sample."""
92
+
93
+ mode: str = "holdout" # "none" | "split" | "walk_forward" | "holdout"
94
+ split_frac: float = 0.7
95
+ train_months: int = 12
96
+ test_months: int = 3
97
+ roll_months: int = 3
98
+ holdout_months: int = config.DEFAULT_HOLDOUT_MONTHS
99
+
100
+
101
+ @dataclass(frozen=True)
102
+ class BacktestConfig:
103
+ asset: str = "BTC-USD"
104
+ timeframe: str = "1d"
105
+ strategy: str = "Buy & Hold"
106
+ params: dict = field(default_factory=dict)
107
+ costs: Costs = field(default_factory=Costs)
108
+ sizing: Sizing = field(default_factory=Sizing)
109
+ stops: Stops = field(default_factory=Stops)
110
+ validation: Validation = field(default_factory=Validation)
111
+ init_cash: float = config.DEFAULT_INIT_CASH
112
+ fill: str = "next_open" # the only supported value; see module docstring
113
+ direction: str = "longonly" # "longonly" | "both"
114
+ # Used for R-multiple when no stop is configured. Documented, not hidden.
115
+ risk_per_trade_pct: float = 0.02
116
+
117
+ def fingerprint(self) -> str:
118
+ """Stable hash of everything that can change results -- determinism key."""
119
+ payload = json.dumps(asdict(self), sort_keys=True, default=str)
120
+ return hashlib.sha256(payload.encode()).hexdigest()[:16]
121
+
122
+
123
+ # --------------------------------------------------------------------------
124
+ # Strategy output
125
+ # --------------------------------------------------------------------------
126
+
127
+
128
+ @dataclass
129
+ class StrategyOutput:
130
+ """Decisions aligned to bar *close*. The engine shifts them, not you."""
131
+
132
+ entries: pd.Series
133
+ exits: pd.Series
134
+ triggers: pd.Series | None = None
135
+ short_entries: pd.Series | None = None
136
+ short_exits: pd.Series | None = None
137
+ shifted: bool = False
138
+
139
+ def validate(self, index: pd.Index) -> None:
140
+ for name in ("entries", "exits", "short_entries", "short_exits"):
141
+ s = getattr(self, name)
142
+ if s is None:
143
+ continue
144
+ if not s.index.equals(index):
145
+ raise EngineError(f"{name} index does not match the price index")
146
+
147
+
148
+ def _shift_decisions(out: StrategyOutput) -> StrategyOutput:
149
+ """Move every decision one bar forward, so it can only act at the next open."""
150
+ if out.shifted:
151
+ raise EngineError("decisions were already shifted; shift exactly once")
152
+
153
+ def sh(s):
154
+ if s is None:
155
+ return None
156
+ # Cast before filling: filling an object-dtype series with False and
157
+ # then downcasting is deprecated in pandas and would change behaviour.
158
+ return s.astype("boolean").shift(1).fillna(False).astype(bool)
159
+
160
+ def sh_obj(s):
161
+ if s is None:
162
+ return None
163
+ return s.shift(1)
164
+
165
+ return StrategyOutput(
166
+ entries=sh(out.entries),
167
+ exits=sh(out.exits),
168
+ triggers=sh_obj(out.triggers),
169
+ short_entries=sh(out.short_entries),
170
+ short_exits=sh(out.short_exits),
171
+ shifted=True,
172
+ )
173
+
174
+
175
+ # --------------------------------------------------------------------------
176
+ # Structural causality check
177
+ # --------------------------------------------------------------------------
178
+
179
+
180
+ def assert_causal(
181
+ indicator_fn,
182
+ prices: pd.DataFrame,
183
+ *,
184
+ probe_fracs: tuple[float, ...] = (0.4, 0.55, 0.7, 0.85),
185
+ bumps: tuple[float, ...] = (1.35, 0.65),
186
+ ) -> None:
187
+ """Fail if `indicator_fn`'s output before bar k depends on data after bar k.
188
+
189
+ `indicator_fn(prices) -> StrategyOutput | Series | DataFrame`. For each
190
+ probe point the tail of the price frame is scaled, and every output value
191
+ strictly before the probe must be unchanged.
192
+
193
+ The tail is scaled both up and down. One direction alone is not enough: a
194
+ boolean comparison that is already `True` can survive an upward bump
195
+ unchanged, so a peeking strategy would slip through. Perturbing both ways
196
+ forces any future-dependent comparison to flip in at least one of them.
197
+ """
198
+ baseline = _as_frame(indicator_fn(prices))
199
+ n = len(prices)
200
+
201
+ for frac in probe_fracs:
202
+ k = int(n * frac)
203
+ if k < 2 or k >= n:
204
+ continue
205
+ for bump in bumps:
206
+ perturbed = prices.copy()
207
+ tail = perturbed.index[k:]
208
+ for col in ("open", "high", "low", "close"):
209
+ if col in perturbed.columns:
210
+ perturbed.loc[tail, col] = perturbed.loc[tail, col] * bump
211
+ if "volume" in perturbed.columns:
212
+ perturbed.loc[tail, "volume"] = perturbed.loc[tail, "volume"] * bump
213
+
214
+ probed = _as_frame(indicator_fn(perturbed))
215
+ a, b = baseline.iloc[:k], probed.iloc[:k]
216
+ if not _frames_equal(a, b):
217
+ where = _first_difference(a, b)
218
+ raise LookaheadError(
219
+ f"output changed before the perturbation point (bar {k}, "
220
+ f"{prices.index[k]}, tail scaled by {bump}): first divergence "
221
+ f"at {where}. The strategy is using information from after "
222
+ f"the bar it acts on."
223
+ )
224
+
225
+
226
+ def _as_frame(obj) -> pd.DataFrame:
227
+ if isinstance(obj, StrategyOutput):
228
+ cols = {"entries": obj.entries, "exits": obj.exits}
229
+ if obj.short_entries is not None:
230
+ cols["short_entries"] = obj.short_entries
231
+ if obj.short_exits is not None:
232
+ cols["short_exits"] = obj.short_exits
233
+ return pd.DataFrame(cols)
234
+ if isinstance(obj, pd.Series):
235
+ return obj.to_frame("value")
236
+ if isinstance(obj, pd.DataFrame):
237
+ return obj
238
+ raise EngineError(f"cannot interpret indicator output of type {type(obj)}")
239
+
240
+
241
+ def _frames_equal(a: pd.DataFrame, b: pd.DataFrame) -> bool:
242
+ if list(a.columns) != list(b.columns) or len(a) != len(b):
243
+ return False
244
+ for c in a.columns:
245
+ x, y = a[c], b[c]
246
+ if x.dtype == bool or y.dtype == bool:
247
+ xa = x.astype('boolean').fillna(False).astype(bool).to_numpy()
248
+ ya = y.astype('boolean').fillna(False).astype(bool).to_numpy()
249
+ if not (xa == ya).all():
250
+ return False
251
+ elif np.issubdtype(x.dtype, np.number):
252
+ if not np.allclose(x.fillna(0).to_numpy(), y.fillna(0).to_numpy(),
253
+ rtol=1e-12, atol=1e-12):
254
+ return False
255
+ else:
256
+ if not (x.fillna("").astype(str).to_numpy()
257
+ == y.fillna("").astype(str).to_numpy()).all():
258
+ return False
259
+ return True
260
+
261
+
262
+ def _first_difference(a: pd.DataFrame, b: pd.DataFrame) -> str:
263
+ for c in a.columns:
264
+ x = a[c].fillna(0) if np.issubdtype(a[c].dtype, np.number) else a[c].fillna("")
265
+ y = b[c].fillna(0) if np.issubdtype(b[c].dtype, np.number) else b[c].fillna("")
266
+ diff = np.where(x.to_numpy() != y.to_numpy())[0]
267
+ if len(diff):
268
+ return f"column {c!r}, bar {int(diff[0])} ({a.index[int(diff[0])]})"
269
+ return "unknown"
270
+
271
+
272
+ # --------------------------------------------------------------------------
273
+ # Validation plans
274
+ # --------------------------------------------------------------------------
275
+
276
+
277
+ @dataclass(frozen=True)
278
+ class Window:
279
+ idx: int
280
+ train_start: pd.Timestamp
281
+ train_end: pd.Timestamp
282
+ test_start: pd.Timestamp
283
+ test_end: pd.Timestamp
284
+
285
+ def validate(self) -> None:
286
+ if self.train_start > self.train_end:
287
+ raise EngineError(f"window {self.idx}: inverted train range")
288
+ if self.test_start > self.test_end:
289
+ raise EngineError(f"window {self.idx}: inverted test range")
290
+ if self.test_start <= self.train_end:
291
+ raise EngineError(
292
+ f"window {self.idx}: test starts {self.test_start} which is not "
293
+ f"after train end {self.train_end} -- train/test would overlap"
294
+ )
295
+
296
+
297
+ @dataclass
298
+ class ValidationPlan:
299
+ mode: str
300
+ index: pd.DatetimeIndex
301
+ holdout_start: pd.Timestamp | None = None
302
+ is_end: pd.Timestamp | None = None
303
+ windows: list[Window] = field(default_factory=list)
304
+ # Honest notes about what this plan could not do, surfaced in the UI.
305
+ notes: list[str] = field(default_factory=list)
306
+
307
+ @property
308
+ def holdout_mask(self) -> pd.Series:
309
+ if self.holdout_start is None:
310
+ return pd.Series(False, index=self.index)
311
+ return pd.Series(self.index >= self.holdout_start, index=self.index)
312
+
313
+ def selectable_index(self) -> pd.DatetimeIndex:
314
+ """Bars any parameter-selection path is permitted to touch.
315
+
316
+ The holdout is excluded here and nowhere else, so a caller cannot fit
317
+ on it by accident.
318
+ """
319
+ if self.holdout_start is None:
320
+ return self.index
321
+ return self.index[self.index < self.holdout_start]
322
+
323
+ def segment_of(self, ts: pd.Timestamp) -> str:
324
+ if self.holdout_start is not None and ts >= self.holdout_start:
325
+ return "holdout"
326
+ if self.is_end is not None:
327
+ return "IS" if ts <= self.is_end else "OOS"
328
+ for w in self.windows:
329
+ if w.test_start <= ts <= w.test_end:
330
+ return "OOS"
331
+ return "IS"
332
+
333
+
334
+ def build_validation_plan(index: pd.DatetimeIndex, v: Validation) -> ValidationPlan:
335
+ if len(index) == 0:
336
+ return ValidationPlan(mode=v.mode, index=index)
337
+
338
+ start, end = index[0], index[-1]
339
+
340
+ # A locked holdout applies to every mode except "none". Walk-forward in
341
+ # particular must not roll its windows into reserved data -- the design
342
+ # shows rolling windows and an "OOS holdout LAST 6MO" side by side.
343
+ holdout_start = None
344
+ if v.mode != "none" and v.holdout_months > 0:
345
+ holdout_start = end - pd.DateOffset(months=v.holdout_months)
346
+ if holdout_start <= start:
347
+ holdout_start = None
348
+
349
+ plan = ValidationPlan(mode=v.mode, index=index, holdout_start=holdout_start)
350
+
351
+ if v.mode == "none":
352
+ plan.is_end = end
353
+ return plan
354
+
355
+ if v.mode == "split":
356
+ cut = int(len(index) * v.split_frac)
357
+ cut = min(max(cut, 1), len(index) - 1)
358
+ plan.is_end = index[cut - 1]
359
+ return plan
360
+
361
+ if v.mode == "holdout":
362
+ plan.is_end = (holdout_start - pd.Timedelta(seconds=1)) if holdout_start else end
363
+ return plan
364
+
365
+ if v.mode == "walk_forward":
366
+ usable = plan.selectable_index() if holdout_start is not None else index
367
+ if len(usable) == 0:
368
+ return plan
369
+ w_start = usable[0]
370
+ i = 0
371
+ last = usable[-1]
372
+ while True:
373
+ train_start = w_start
374
+ train_end = train_start + pd.DateOffset(months=v.train_months)
375
+ test_start = train_end
376
+ test_end = test_start + pd.DateOffset(months=v.test_months)
377
+ if test_start >= last:
378
+ break
379
+ # train_end is exclusive of the test side: nudge back one instant so
380
+ # the two never share a boundary bar.
381
+ win = Window(
382
+ idx=i,
383
+ train_start=train_start,
384
+ train_end=train_end - pd.Timedelta(seconds=1),
385
+ test_start=test_start,
386
+ test_end=min(test_end - pd.Timedelta(seconds=1), last),
387
+ )
388
+ win.validate()
389
+ plan.windows.append(win)
390
+ i += 1
391
+ w_start = w_start + pd.DateOffset(months=v.roll_months)
392
+ if i > 200:
393
+ break
394
+ if not plan.windows:
395
+ span_days = (usable[-1] - usable[0]).days
396
+ plan.notes.append(
397
+ f"Walk-forward produced no out-of-sample windows: the selectable "
398
+ f"period is {span_days} days, which is shorter than one "
399
+ f"{v.train_months}-month train plus {v.test_months}-month test "
400
+ f"window. There is no OOS result to report — widen the date range "
401
+ f"or shorten the training window."
402
+ )
403
+ return plan
404
+
405
+ raise EngineError(f"unknown validation mode {v.mode!r}")
406
+
407
+
408
+ # --------------------------------------------------------------------------
409
+ # Results
410
+ # --------------------------------------------------------------------------
411
+
412
+
413
+ @dataclass
414
+ class WindowResult:
415
+ window: Window
416
+ metrics: Metrics
417
+ equity: pd.Series
418
+
419
+
420
+ @dataclass
421
+ class BacktestResult:
422
+ config: BacktestConfig
423
+ equity: pd.Series
424
+ benchmark_equity: pd.Series
425
+ position: pd.Series
426
+ trades: pd.DataFrame
427
+ metrics_all: Metrics
428
+ metrics_is: Metrics
429
+ metrics_oos: Metrics
430
+ metrics_holdout: Metrics | None
431
+ plan: ValidationPlan
432
+ windows: list[WindowResult] = field(default_factory=list)
433
+ prices: pd.DataFrame | None = None
434
+ fingerprint: str = ""
435
+ elapsed_s: float = 0.0
436
+
437
+ @property
438
+ def costs_paid(self) -> float:
439
+ return float(self.trades["costs"].sum()) if len(self.trades) else 0.0
440
+
441
+ def summary(self) -> dict:
442
+ return {
443
+ "fingerprint": self.fingerprint,
444
+ "trades": int(len(self.trades)),
445
+ "total_return": self.metrics_all.total_return,
446
+ "oos_sharpe": self.metrics_oos.sharpe,
447
+ "max_drawdown": self.metrics_all.max_drawdown,
448
+ "costs_paid": self.costs_paid,
449
+ }
450
+
451
+
452
+ # --------------------------------------------------------------------------
453
+ # Sizing / slippage helpers
454
+ # --------------------------------------------------------------------------
455
+
456
+
457
+ def _size_args(prices: pd.DataFrame, sizing: Sizing) -> tuple:
458
+ """Return (size, size_type) for vectorbt."""
459
+ if sizing.mode == "fixed_units":
460
+ return sizing.units, "amount"
461
+
462
+ if sizing.mode == "vol_target":
463
+ rets = prices["close"].pct_change()
464
+ realised = rets.rolling(sizing.vol_lookback).std()
465
+ bpy = config.bars_per_year("BTC-USD", "1d") # scale set by caller's tf
466
+ ann = realised * np.sqrt(bpy)
467
+ target = (sizing.vol_target_ann / ann.replace(0.0, np.nan))
468
+ target = target.clip(upper=sizing.max_position * sizing.leverage)
469
+ target = target.fillna(sizing.pct).clip(lower=0.0)
470
+ return target.to_numpy(), "percent"
471
+
472
+ frac = min(sizing.pct * sizing.leverage, sizing.max_position * sizing.leverage)
473
+ return float(frac), "percent"
474
+
475
+
476
+ def _slippage_arg(prices: pd.DataFrame, costs: Costs):
477
+ base = costs.slippage_rate
478
+ if base == 0.0:
479
+ return 0.0
480
+ if costs.slippage_model != "volume_scaled":
481
+ return base
482
+ vol = prices["volume"].astype("float64")
483
+ med = float(vol.replace(0.0, np.nan).median())
484
+ if not np.isfinite(med) or med <= 0:
485
+ return base
486
+ # Thin bars cost more to trade than typical ones.
487
+ scale = 1.0 + costs.volume_scale_k * ((med / vol.replace(0.0, np.nan)) - 1.0)
488
+ return (base * scale.clip(lower=0.5, upper=5.0).fillna(1.0)).to_numpy()
489
+
490
+
491
+ # --------------------------------------------------------------------------
492
+ # Trade list construction
493
+ # --------------------------------------------------------------------------
494
+
495
+
496
+ def _build_trades(
497
+ pf, prices: pd.DataFrame, triggers: pd.Series | None,
498
+ cfg: BacktestConfig, plan: ValidationPlan,
499
+ ) -> pd.DataFrame:
500
+ """Turn vectorbt's records into the trade list the UI and metrics use."""
501
+ cols = ["id", "entry_ts", "exit_ts", "side", "entry_px", "exit_px", "size",
502
+ "gross_pnl", "costs", "net_pnl", "r_multiple", "mae", "mfe",
503
+ "duration_bars", "trigger", "segment", "status"]
504
+ rec = pf.trades.records_readable
505
+ if rec is None or len(rec) == 0:
506
+ return pd.DataFrame(columns=cols)
507
+
508
+ idx = prices.index
509
+ pos_of = {ts: i for i, ts in enumerate(idx)}
510
+ slip = cfg.costs.slippage_rate
511
+ rows = []
512
+
513
+ # to_dict("records") preserves the spaced column names; itertuples would
514
+ # rename "Entry Timestamp" to a positional field.
515
+ for n, d in enumerate(rec.to_dict("records")):
516
+ entry_ts = pd.Timestamp(d["Entry Timestamp"])
517
+ exit_ts = pd.Timestamp(d["Exit Timestamp"])
518
+ size = float(d["Size"])
519
+ entry_px = float(d["Avg Entry Price"])
520
+ exit_px = float(d["Avg Exit Price"])
521
+ fees = float(d["Entry Fees"]) + float(d["Exit Fees"])
522
+ net = float(d["PnL"])
523
+ side = str(d["Direction"]).lower()
524
+ status = str(d.get("Status", "Closed"))
525
+
526
+ # Slippage is embedded in the fill price rather than booked as a fee, so
527
+ # it is reconstructed from the unslipped reference price. Slippage
528
+ # always works against the trade: a buy fills high, a sell fills low.
529
+ if slip:
530
+ if side == "long":
531
+ raw_entry, raw_exit = entry_px / (1 + slip), exit_px / (1 - slip)
532
+ else:
533
+ raw_entry, raw_exit = entry_px / (1 - slip), exit_px / (1 + slip)
534
+ slip_cost = slip * size * (raw_entry + raw_exit)
535
+ else:
536
+ slip_cost = 0.0
537
+ costs_total = fees + slip_cost
538
+ gross = net + costs_total
539
+
540
+ i0, i1 = pos_of.get(entry_ts), pos_of.get(exit_ts)
541
+ mae = mfe = float("nan")
542
+ if i0 is not None and i1 is not None and i1 >= i0:
543
+ window = prices.iloc[i0:i1 + 1]
544
+ lo, hi = float(window["low"].min()), float(window["high"].max())
545
+ if side == "long":
546
+ mae = lo / entry_px - 1.0
547
+ mfe = hi / entry_px - 1.0
548
+ else:
549
+ mae = 1.0 - hi / entry_px
550
+ mfe = 1.0 - lo / entry_px
551
+
552
+ if cfg.stops.sl_pct:
553
+ risk = cfg.stops.sl_pct * entry_px * size
554
+ else:
555
+ risk = cfg.risk_per_trade_pct * entry_px * size
556
+ r_mult = (net / risk) if risk else float("nan")
557
+
558
+ trig = ""
559
+ if triggers is not None and entry_ts in triggers.index:
560
+ v = triggers.loc[entry_ts]
561
+ trig = "" if (v is None or (isinstance(v, float) and np.isnan(v))) else str(v)
562
+
563
+ rows.append({
564
+ "id": n + 1,
565
+ "entry_ts": entry_ts, "exit_ts": exit_ts, "side": side,
566
+ "entry_px": entry_px, "exit_px": exit_px, "size": size,
567
+ "gross_pnl": gross, "costs": costs_total, "net_pnl": net,
568
+ "r_multiple": r_mult, "mae": mae, "mfe": mfe,
569
+ "duration_bars": (i1 - i0) if (i0 is not None and i1 is not None) else np.nan,
570
+ "trigger": trig,
571
+ "segment": plan.segment_of(entry_ts),
572
+ "status": status,
573
+ })
574
+
575
+ return pd.DataFrame(rows, columns=cols)
576
+
577
+
578
+ # --------------------------------------------------------------------------
579
+ # The run
580
+ # --------------------------------------------------------------------------
581
+
582
+
583
+ def run_backtest(
584
+ prices: pd.DataFrame,
585
+ output: StrategyOutput,
586
+ cfg: BacktestConfig,
587
+ *,
588
+ bars_per_year: float | None = None,
589
+ ) -> BacktestResult:
590
+ """Simulate `output` on `prices` under `cfg`.
591
+
592
+ `output` must carry decisions aligned to bar close; the shift to next-bar
593
+ execution is applied here and only here.
594
+ """
595
+ import time
596
+
597
+ t0 = time.perf_counter()
598
+
599
+ if prices.empty:
600
+ raise EngineError("no price data for the requested range")
601
+ for c in ("open", "high", "low", "close"):
602
+ if c not in prices.columns:
603
+ raise EngineError(f"price frame missing {c!r}")
604
+ if cfg.fill != "next_open":
605
+ raise EngineError(
606
+ f"fill={cfg.fill!r} is not supported; next-bar-open execution is "
607
+ "non-negotiable in this engine"
608
+ )
609
+
610
+ prices = prices.sort_index()
611
+ output.validate(prices.index)
612
+ shifted = _shift_decisions(output)
613
+ if not shifted.shifted:
614
+ raise EngineError("internal: decisions were not shifted")
615
+
616
+ bpy = bars_per_year if bars_per_year is not None else config.bars_per_year(
617
+ cfg.asset, cfg.timeframe
618
+ )
619
+ plan = build_validation_plan(prices.index, cfg.validation)
620
+
621
+ size, size_type = _size_args(prices, cfg.sizing)
622
+ slippage = _slippage_arg(prices, cfg.costs)
623
+
624
+ kwargs = dict(
625
+ close=prices["close"],
626
+ entries=shifted.entries,
627
+ exits=shifted.exits,
628
+ price=prices["open"], # next-bar-open execution
629
+ open=prices["open"], high=prices["high"], low=prices["low"],
630
+ fees=cfg.costs.commission_rate,
631
+ slippage=slippage,
632
+ init_cash=cfg.init_cash,
633
+ size=size, size_type=size_type,
634
+ freq=pd.infer_freq(prices.index) or f"{config.TIMEFRAMES[cfg.timeframe].minutes}min",
635
+ direction=cfg.direction,
636
+ seed=0, # determinism
637
+ )
638
+ if shifted.short_entries is not None:
639
+ kwargs["short_entries"] = shifted.short_entries
640
+ kwargs["short_exits"] = shifted.short_exits
641
+ kwargs["direction"] = "both"
642
+ if cfg.stops.sl_pct is not None:
643
+ kwargs["sl_stop"] = cfg.stops.sl_pct
644
+ if cfg.stops.trail_pct is not None:
645
+ kwargs["sl_stop"] = cfg.stops.trail_pct
646
+ kwargs["sl_trail"] = True
647
+ if cfg.stops.tp_pct is not None:
648
+ kwargs["tp_stop"] = cfg.stops.tp_pct
649
+
650
+ pf = vbt.Portfolio.from_signals(**kwargs)
651
+
652
+ equity = pf.value()
653
+ try:
654
+ position = pf.asset_flow().cumsum()
655
+ except Exception:
656
+ position = pd.Series(0.0, index=prices.index)
657
+
658
+ trades = _build_trades(pf, prices, shifted.triggers, cfg, plan)
659
+
660
+ # Benchmark: buy and hold the same asset, same costs-free basis, so the
661
+ # comparison isolates the strategy rather than the fee schedule.
662
+ bench = prices["close"] / prices["close"].iloc[0] * cfg.init_cash
663
+
664
+ def slice_metrics(mask: pd.Series, seg: str) -> Metrics:
665
+ eq = equity[mask]
666
+ if len(eq) < 2:
667
+ return Metrics(segment=seg)
668
+ tr = trades[trades["segment"] == seg] if len(trades) else trades
669
+ return compute_metrics(eq, tr, bpy, segment=seg,
670
+ position=position[mask] if position is not None else None)
671
+
672
+ holdout_mask = plan.holdout_mask
673
+ in_holdout = holdout_mask.to_numpy()
674
+ if plan.is_end is not None:
675
+ is_mask = pd.Series((prices.index <= plan.is_end) & ~in_holdout, index=prices.index)
676
+ oos_mask = pd.Series((prices.index > plan.is_end) & ~in_holdout, index=prices.index)
677
+ else:
678
+ seg = pd.Series([plan.segment_of(t) for t in prices.index], index=prices.index)
679
+ is_mask = (seg == "IS") & ~in_holdout
680
+ oos_mask = (seg == "OOS") & ~in_holdout
681
+
682
+ metrics_all = compute_metrics(equity, trades, bpy, segment="all", position=position)
683
+ metrics_is = slice_metrics(is_mask, "IS")
684
+ metrics_oos = slice_metrics(oos_mask, "OOS")
685
+ metrics_holdout = slice_metrics(holdout_mask, "holdout") if in_holdout.any() else None
686
+
687
+ windows: list[WindowResult] = []
688
+ for w in plan.windows:
689
+ m = (prices.index >= w.test_start) & (prices.index <= w.test_end)
690
+ eq = equity[m]
691
+ if len(eq) < 2:
692
+ continue
693
+ tr = trades[(trades["entry_ts"] >= w.test_start)
694
+ & (trades["entry_ts"] <= w.test_end)] if len(trades) else trades
695
+ windows.append(WindowResult(
696
+ window=w,
697
+ metrics=compute_metrics(eq, tr, bpy, segment=f"W{w.idx + 1}"),
698
+ equity=eq,
699
+ ))
700
+
701
+ return BacktestResult(
702
+ config=cfg, equity=equity, benchmark_equity=bench, position=position,
703
+ trades=trades, metrics_all=metrics_all, metrics_is=metrics_is,
704
+ metrics_oos=metrics_oos, metrics_holdout=metrics_holdout, plan=plan,
705
+ windows=windows, prices=prices, fingerprint=cfg.fingerprint(),
706
+ elapsed_s=time.perf_counter() - t0,
707
+ )
src/extension.py ADDED
@@ -0,0 +1,385 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """User-funded coverage extension and add-model, on ZeroGPU.
2
+
3
+ Inference runs inside a `@spaces.GPU` function so it draws on the *signed-in
4
+ user's* ZeroGPU quota, not the Space owner's. Anonymous visitors keep full read
5
+ and backtest access; only extension is gated.
6
+
7
+ Safety properties enforced here:
8
+
9
+ * **Dedup** — a range the manifest already covers is never recomputed.
10
+ * **Caps** — per-request range limits keep one user from monopolising the queue.
11
+ * **Single writer** — a process-wide lock serialises commits, so concurrent
12
+ extensions cannot interleave and corrupt the manifest.
13
+ * **Allow-list** — only vetted adapter families load, and model ids are
14
+ validated before they reach the Hub.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ import os
21
+ import threading
22
+ from dataclasses import dataclass
23
+
24
+ import gradio as gr
25
+ import pandas as pd
26
+
27
+ from . import comparisons, config, runtime
28
+ from .adapters import AdapterError, ModelNotAllowed, build_windows, get_adapter, validate_model_id
29
+ from .store import _utc
30
+
31
+ log = logging.getLogger("bit.extension")
32
+
33
+ # One writer at a time. Commits to the store are serialised process-wide so a
34
+ # second extension cannot land between another's data write and its manifest
35
+ # update.
36
+ _WRITE_LOCK = threading.Lock()
37
+
38
+ try:
39
+ import spaces # provided by the ZeroGPU runtime
40
+ HAS_SPACES = True
41
+ except Exception: # running locally or on CPU-only hardware
42
+ spaces = None
43
+ HAS_SPACES = False
44
+
45
+
46
+ def _gpu(duration=120):
47
+ """Apply @spaces.GPU when the runtime offers it, otherwise run on CPU."""
48
+ def deco(fn):
49
+ if HAS_SPACES:
50
+ return spaces.GPU(duration=duration)(fn)
51
+ return fn
52
+ return deco
53
+
54
+
55
+ class ExtensionError(RuntimeError):
56
+ pass
57
+
58
+
59
+ class QuotaExhausted(ExtensionError):
60
+ pass
61
+
62
+
63
+ # --------------------------------------------------------------------------
64
+ # Estimation & guardrails
65
+ # --------------------------------------------------------------------------
66
+
67
+
68
+ @dataclass
69
+ class Estimate:
70
+ model_slug: str
71
+ asset: str
72
+ timeframe: str
73
+ start: pd.Timestamp
74
+ end: pd.Timestamp
75
+ steps: int
76
+ already_covered: bool
77
+ capped: bool
78
+ cap_days: int
79
+ note: str = ""
80
+
81
+
82
+ def estimate(model_slug: str, asset: str, timeframe: str, start, end) -> Estimate:
83
+ if model_slug not in config.SEED_MODELS:
84
+ raise ExtensionError(f"unknown model {model_slug!r}")
85
+ if asset not in config.ASSETS:
86
+ raise ExtensionError(f"unknown asset {asset!r}")
87
+ if timeframe not in config.TIMEFRAMES:
88
+ raise ExtensionError(f"unknown timeframe {timeframe!r}")
89
+
90
+ try:
91
+ s, e = _utc(start), _utc(end)
92
+ except Exception as exc:
93
+ raise ExtensionError(f"could not parse the date range: {exc}") from exc
94
+ if s >= e:
95
+ raise ExtensionError("start must be before end")
96
+
97
+ cap_days = config.CAPS.max_days.get(timeframe, 365)
98
+ capped = (e - s).days > cap_days
99
+ if capped:
100
+ s = e - pd.Timedelta(days=cap_days)
101
+
102
+ store = runtime.get_store()
103
+ prices = store.get_prices(asset, timeframe, s, e)
104
+ if prices.empty:
105
+ raise ExtensionError(
106
+ f"No cached prices for {asset} {timeframe} in that range. "
107
+ "Price coverage has to exist before signals can be generated."
108
+ )
109
+
110
+ spec = config.SEED_MODELS[model_slug]
111
+ ctx = min(spec.context_len, max(64, len(prices) // 3))
112
+ steps = max(0, len(prices) - ctx)
113
+ if steps > config.CAPS.max_steps_per_run:
114
+ steps = config.CAPS.max_steps_per_run
115
+
116
+ # Dedup must compare against the range this request would actually
117
+ # *produce*, not the range the user typed. A forecast needs a full trailing
118
+ # context window, so the first producible timestamp sits `ctx` bars after
119
+ # the start of the price slice. Comparing the typed range instead would
120
+ # report an already-covered slice as uncovered and pay for it twice.
121
+ stamps, _ = build_windows(prices["close"], ctx)
122
+ if len(stamps) == 0:
123
+ produced_start, produced_end = s, e
124
+ steps = 0
125
+ else:
126
+ produced_start = _utc(stamps[0])
127
+ produced_end = _utc(stamps[min(steps, len(stamps)) - 1]) if steps else produced_start
128
+
129
+ # Revision is resolved lazily to avoid a Hub round-trip on every keystroke,
130
+ # so this matches any revision of the model.
131
+ covered = any(
132
+ ent.model_slug == model_slug and ent.asset == asset
133
+ and ent.timeframe == timeframe
134
+ and _utc(ent.start_ts) <= produced_start and _utc(ent.end_ts) >= produced_end
135
+ for ent in store.load_manifest().signals.values()
136
+ )
137
+
138
+ note = ""
139
+ if capped:
140
+ note = (f"Range trimmed to the {cap_days}-day cap for {timeframe} bars.")
141
+ return Estimate(model_slug, asset, timeframe, s, e, steps, covered,
142
+ capped, cap_days, note)
143
+
144
+
145
+ # --------------------------------------------------------------------------
146
+ # GPU inference
147
+ # --------------------------------------------------------------------------
148
+
149
+
150
+ @_gpu(duration=180)
151
+ def run_inference(model_id: str, family: str, values: list, ctx_len: int) -> dict:
152
+ """Inference on the caller's ZeroGPU allocation.
153
+
154
+ Kept deliberately small and picklable: it takes plain values and returns
155
+ plain lists, so nothing in the store or the app leaks into the GPU worker.
156
+ """
157
+ import numpy as np
158
+
159
+ adapter = get_adapter(family, model_id, context_len=ctx_len)
160
+ adapter.load()
161
+ windows = np.asarray(values, dtype="float32")
162
+ forecast = adapter.predict(windows)
163
+ return {
164
+ "q10": forecast.q10.tolist(),
165
+ "q50": forecast.q50.tolist(),
166
+ "q90": forecast.q90.tolist(),
167
+ "context_len": int(forecast.context_len),
168
+ "revision": adapter.resolved_revision,
169
+ "inference_version": adapter.inference_version(),
170
+ }
171
+
172
+
173
+ def _is_quota_error(exc: Exception) -> bool:
174
+ text = f"{type(exc).__name__} {exc}".lower()
175
+ return any(k in text for k in ("quota", "gpu task aborted", "exceeded",
176
+ "no gpu available", "zerogpu"))
177
+
178
+
179
+ QUOTA_FALLBACK = (
180
+ '<div class="bit-note bit-note-danger">'
181
+ "Your ZeroGPU quota is exhausted, so this extension could not run. "
182
+ "The quota refills over time. If you need to run a large batch now, "
183
+ f'<a href="https://huggingface.co/spaces/{config.SPACE_REPO}?duplicate=true" '
184
+ 'target="_blank" rel="noopener">duplicate this Space</a> and run it on your '
185
+ "own hardware — the signal store is public, so a duplicate reads the same data."
186
+ "</div>"
187
+ )
188
+
189
+
190
+ # --------------------------------------------------------------------------
191
+ # The extend flow
192
+ # --------------------------------------------------------------------------
193
+
194
+
195
+ def extend_coverage(model_slug: str, asset: str, timeframe: str, start, end,
196
+ username: str = "anonymous", progress=None) -> str:
197
+ """Dedup, estimate, run inference, commit, regenerate comparisons."""
198
+ est = estimate(model_slug, asset, timeframe, start, end)
199
+ if est.already_covered:
200
+ return ('<div class="bit-note">That range is already covered — nothing was '
201
+ "recomputed. Coverage is deduplicated against the manifest.</div>")
202
+ if est.steps <= 0:
203
+ return ('<div class="bit-note bit-note-danger">Not enough cached price '
204
+ "history in that range to build a single context window.</div>")
205
+
206
+ spec = config.SEED_MODELS[model_slug]
207
+ store = runtime.get_store()
208
+ prices = store.get_prices(asset, timeframe, est.start, est.end)
209
+ close = prices["close"]
210
+ ctx = min(spec.context_len, max(64, len(prices) // 3))
211
+
212
+ if progress:
213
+ progress(0.15, desc=f"Preparing {est.steps} context windows")
214
+ stamps, windows = build_windows(close, ctx)
215
+ if len(stamps) == 0:
216
+ return ('<div class="bit-note bit-note-danger">Not enough bars for a '
217
+ "context window.</div>")
218
+ stamps, windows = stamps[:est.steps], windows[:est.steps]
219
+
220
+ if progress:
221
+ progress(0.35, desc=f"Running {spec.display} on your GPU quota")
222
+ try:
223
+ out = run_inference(spec.model_id, spec.family, windows.tolist(), ctx)
224
+ except Exception as exc:
225
+ if _is_quota_error(exc):
226
+ log.warning("ZeroGPU quota exhausted for %s: %s", username, exc)
227
+ return QUOTA_FALLBACK
228
+ log.exception("extension inference failed")
229
+ return (f'<div class="bit-note bit-note-danger">Inference failed: '
230
+ f"{type(exc).__name__}: {exc}</div>")
231
+
232
+ frame = pd.DataFrame({
233
+ "ts": stamps, "q10": out["q10"], "q50": out["q50"], "q90": out["q90"],
234
+ "context_len": out["context_len"], "inference_version": out["inference_version"],
235
+ })
236
+
237
+ if progress:
238
+ progress(0.75, desc="Committing to the signal store")
239
+ with _WRITE_LOCK:
240
+ entry = store.write_signals(
241
+ model_slug, spec.model_id, out["revision"], asset, timeframe, frame,
242
+ inference_version=out["inference_version"],
243
+ contributed_by=username,
244
+ )
245
+ if progress:
246
+ progress(0.9, desc="Regenerating comparison tables")
247
+ try:
248
+ comparisons.regenerate(store, assets=[asset])
249
+ except Exception:
250
+ log.exception("comparison regeneration failed (coverage still written)")
251
+ oid = store.flush(f"Extend {model_slug}/{asset}/{timeframe} by @{username}")
252
+
253
+ runtime.cache_clear()
254
+ return (f'<div class="bit-note">Coverage extended by <b>@{username}</b> — '
255
+ f"{len(frame):,} new steps for {model_slug} on {asset} {timeframe} "
256
+ f"({entry.start_ts[:10]} → {entry.end_ts[:10]})."
257
+ + (f" Commit <code>{str(oid)[:8]}</code>." if oid else "")
258
+ + "</div>")
259
+
260
+
261
+ def add_model(family: str, model_id: str, username: str = "anonymous") -> str:
262
+ """Smoke-test a user-supplied model, then register it if it passes."""
263
+ try:
264
+ model_id = validate_model_id(model_id)
265
+ except AdapterError as e:
266
+ return f'<div class="bit-note bit-note-danger">{e}</div>'
267
+ if family not in config.ALLOWED_ADAPTER_FAMILIES:
268
+ return (f'<div class="bit-note bit-note-danger">Adapter family {family!r} '
269
+ "is not on the allow-list.</div>")
270
+
271
+ store = runtime.get_store()
272
+ asset, timeframe = "BTC-USD", "1d"
273
+ prices = store.get_prices(asset, timeframe)
274
+ if prices.empty:
275
+ return ('<div class="bit-note bit-note-danger">No cached prices to smoke '
276
+ "test against.</div>")
277
+
278
+ n = config.CAPS.smoke_test_steps
279
+ close = prices["close"]
280
+ ctx = min(512, max(64, len(close) // 3))
281
+ stamps, windows = build_windows(close, ctx)
282
+ if len(stamps) < n:
283
+ return ('<div class="bit-note bit-note-danger">Not enough history for a '
284
+ f"{n}-step smoke test.</div>")
285
+ stamps, windows = stamps[-n:], windows[-n:]
286
+
287
+ try:
288
+ out = run_inference(model_id, family, windows.tolist(), ctx)
289
+ except ModelNotAllowed as e:
290
+ return f'<div class="bit-note bit-note-danger">{e}</div>'
291
+ except Exception as exc:
292
+ if _is_quota_error(exc):
293
+ return QUOTA_FALLBACK
294
+ return (f'<div class="bit-note bit-note-danger">Smoke test failed: '
295
+ f"{type(exc).__name__}: {exc}</div>")
296
+
297
+ slug = model_id.split("/")[-1].lower()
298
+ frame = pd.DataFrame({
299
+ "ts": stamps, "q10": out["q10"], "q50": out["q50"], "q90": out["q90"],
300
+ "context_len": out["context_len"], "inference_version": out["inference_version"],
301
+ })
302
+ with _WRITE_LOCK:
303
+ store.write_signals(slug, model_id, out["revision"], asset, timeframe, frame,
304
+ inference_version=out["inference_version"],
305
+ contributed_by=username)
306
+ store.flush(f"Add model {model_id} (smoke test) by @{username}")
307
+ runtime.cache_clear()
308
+
309
+ return (f'<div class="bit-note">Smoke test passed: <b>{model_id}</b> '
310
+ f"(revision <code>{str(out['revision'])[:8]}</code>) produced {n} "
311
+ f"schema-valid steps and now appears in the coverage map as "
312
+ f"<code>{slug}</code>.</div>")
313
+
314
+
315
+ # --------------------------------------------------------------------------
316
+ # Gradio bindings
317
+ # --------------------------------------------------------------------------
318
+
319
+
320
+ def status_html() -> str:
321
+ if HAS_SPACES:
322
+ return ('<div class="bit-micro">ZEROGPU AVAILABLE · EXTENSION RUNS ON '
323
+ "YOUR OWN QUOTA WHEN SIGNED IN</div>")
324
+ return ('<div class="bit-note">This Space is running on CPU, so coverage '
325
+ "extension is disabled. Reading and backtesting the existing store "
326
+ "works normally.</div>")
327
+
328
+
329
+ def _username(profile) -> str | None:
330
+ if profile is None:
331
+ return None
332
+ return getattr(profile, "username", None) or getattr(profile, "name", None)
333
+
334
+
335
+ def estimate_ui(model_slug, asset, timeframe, start, end):
336
+ if not (model_slug and asset and timeframe and start and end):
337
+ return '<div class="bit-micro">PICK A MODEL, ASSET, TIMEFRAME AND RANGE</div>'
338
+ try:
339
+ est = estimate(model_slug, asset, timeframe, start, end)
340
+ except ExtensionError as e:
341
+ return f'<div class="bit-note bit-note-danger">{e}</div>'
342
+ if est.already_covered:
343
+ return ('<div class="bit-note">Already covered — running this would '
344
+ "recompute nothing. Pick a wider range.</div>")
345
+ return (f'<div class="bit-note">About <b>{est.steps:,}</b> inference steps for '
346
+ f"{est.start.date()} → {est.end.date()}. "
347
+ + (est.note + " " if est.note else "")
348
+ + "This runs on your ZeroGPU quota once you sign in.</div>")
349
+
350
+
351
+ def extend_ui(model_slug, asset, timeframe, start, end,
352
+ profile: gr.OAuthProfile | None = None, progress=None):
353
+ user = _username(profile)
354
+ if user is None:
355
+ return ('<div class="bit-note bit-note-danger">Sign in with Hugging Face to '
356
+ "extend coverage. Reading and backtesting stay open to everyone; "
357
+ "extension spends your own GPU quota, so it needs an account.</div>",
358
+ runtime.coverage_frame())
359
+ if not HAS_SPACES:
360
+ return (status_html(), runtime.coverage_frame())
361
+ try:
362
+ html = extend_coverage(model_slug, asset, timeframe, start, end,
363
+ username=user, progress=progress)
364
+ except ExtensionError as e:
365
+ html = f'<div class="bit-note bit-note-danger">{e}</div>'
366
+ except Exception as e:
367
+ log.exception("extend failed")
368
+ html = f'<div class="bit-note bit-note-danger">{type(e).__name__}: {e}</div>'
369
+ return html, runtime.coverage_frame()
370
+
371
+
372
+ def add_model_ui(family, model_id, profile: gr.OAuthProfile | None = None):
373
+ user = _username(profile)
374
+ if user is None:
375
+ return ('<div class="bit-note bit-note-danger">Sign in with Hugging Face to '
376
+ "add a model — the smoke test runs on your GPU quota.</div>",
377
+ runtime.coverage_frame())
378
+ if not HAS_SPACES:
379
+ return (status_html(), runtime.coverage_frame())
380
+ try:
381
+ html = add_model(family, model_id, username=user)
382
+ except Exception as e:
383
+ log.exception("add model failed")
384
+ html = f'<div class="bit-note bit-note-danger">{type(e).__name__}: {e}</div>'
385
+ return html, runtime.coverage_frame()
src/metrics.py ADDED
@@ -0,0 +1,263 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Performance metrics, computed from the equity curve and trade list.
2
+
3
+ Every number the UI shows comes from this module, so each one is traceable to
4
+ an equity curve or a trade row rather than to a library's internal accounting.
5
+ Metrics are pure functions of their inputs -- no globals, no randomness.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ from dataclasses import dataclass, asdict, field
12
+
13
+ import numpy as np
14
+ import pandas as pd
15
+
16
+ TRADING_DAYS = 252.0
17
+
18
+
19
+ @dataclass
20
+ class Metrics:
21
+ """One performance summary. `segment` names the slice it describes."""
22
+
23
+ segment: str = "all"
24
+ start_ts: str | None = None
25
+ end_ts: str | None = None
26
+ bars: int = 0
27
+
28
+ total_return: float = 0.0
29
+ cagr: float = 0.0
30
+ sharpe: float = 0.0
31
+ sortino: float = 0.0
32
+ max_drawdown: float = 0.0
33
+ volatility: float = 0.0
34
+
35
+ win_rate: float = 0.0
36
+ profit_factor: float = 0.0
37
+ exposure: float = 0.0
38
+ trade_count: int = 0
39
+
40
+ avg_win: float = 0.0
41
+ avg_loss: float = 0.0
42
+ avg_r: float = 0.0
43
+ best_trade: float = 0.0
44
+ worst_trade: float = 0.0
45
+ costs_paid: float = 0.0
46
+ gross_pnl: float = 0.0
47
+ net_pnl: float = 0.0
48
+
49
+ def to_dict(self) -> dict:
50
+ return asdict(self)
51
+
52
+
53
+ def _clean_returns(equity: pd.Series) -> pd.Series:
54
+ r = equity.astype("float64").pct_change()
55
+ return r.replace([np.inf, -np.inf], np.nan).dropna()
56
+
57
+
58
+ def total_return(equity: pd.Series) -> float:
59
+ if len(equity) < 2 or equity.iloc[0] == 0:
60
+ return 0.0
61
+ return float(equity.iloc[-1] / equity.iloc[0] - 1.0)
62
+
63
+
64
+ def cagr(equity: pd.Series, bars_per_year: float) -> float:
65
+ if len(equity) < 2 or equity.iloc[0] <= 0 or equity.iloc[-1] <= 0:
66
+ return 0.0
67
+ years = (len(equity) - 1) / bars_per_year
68
+ if years <= 0:
69
+ return 0.0
70
+ return float((equity.iloc[-1] / equity.iloc[0]) ** (1.0 / years) - 1.0)
71
+
72
+
73
+ def sharpe(equity: pd.Series, bars_per_year: float, rf: float = 0.0) -> float:
74
+ r = _clean_returns(equity)
75
+ if len(r) < 2:
76
+ return 0.0
77
+ excess = r - (rf / bars_per_year)
78
+ sd = float(excess.std(ddof=1))
79
+ if sd == 0 or not math.isfinite(sd):
80
+ return 0.0
81
+ return float(excess.mean() / sd * math.sqrt(bars_per_year))
82
+
83
+
84
+ def sortino(equity: pd.Series, bars_per_year: float, rf: float = 0.0) -> float:
85
+ r = _clean_returns(equity)
86
+ if len(r) < 2:
87
+ return 0.0
88
+ excess = r - (rf / bars_per_year)
89
+ downside = excess[excess < 0]
90
+ if len(downside) == 0:
91
+ return 0.0
92
+ dd = float(np.sqrt((downside ** 2).mean()))
93
+ if dd == 0 or not math.isfinite(dd):
94
+ return 0.0
95
+ return float(excess.mean() / dd * math.sqrt(bars_per_year))
96
+
97
+
98
+ def volatility(equity: pd.Series, bars_per_year: float) -> float:
99
+ r = _clean_returns(equity)
100
+ if len(r) < 2:
101
+ return 0.0
102
+ return float(r.std(ddof=1) * math.sqrt(bars_per_year))
103
+
104
+
105
+ def drawdown_series(equity: pd.Series) -> pd.Series:
106
+ if equity.empty:
107
+ return equity
108
+ peak = equity.cummax()
109
+ return equity / peak - 1.0
110
+
111
+
112
+ def max_drawdown(equity: pd.Series) -> float:
113
+ if len(equity) < 2:
114
+ return 0.0
115
+ dd = drawdown_series(equity)
116
+ return float(dd.min()) if len(dd) else 0.0
117
+
118
+
119
+ def rolling_sharpe(equity: pd.Series, window: int, bars_per_year: float) -> pd.Series:
120
+ r = _clean_returns(equity)
121
+ if len(r) < window:
122
+ return pd.Series(dtype="float64", index=pd.DatetimeIndex([], tz="UTC"))
123
+ mean = r.rolling(window).mean()
124
+ sd = r.rolling(window).std(ddof=1)
125
+ out = (mean / sd.replace(0.0, np.nan)) * math.sqrt(bars_per_year)
126
+ return out.dropna()
127
+
128
+
129
+ def underwater(equity: pd.Series) -> pd.Series:
130
+ return drawdown_series(equity)
131
+
132
+
133
+ def exposure(position: pd.Series) -> float:
134
+ """Fraction of bars holding a non-zero position."""
135
+ if position is None or len(position) == 0:
136
+ return 0.0
137
+ return float((position.abs() > 1e-12).mean())
138
+
139
+
140
+ # --------------------------------------------------------------------------
141
+ # Trade-derived statistics
142
+ # --------------------------------------------------------------------------
143
+
144
+
145
+ def trade_stats(trades: pd.DataFrame) -> dict:
146
+ """Win rate, profit factor and friends from the trade list."""
147
+ empty = {
148
+ "trade_count": 0, "win_rate": 0.0, "profit_factor": 0.0,
149
+ "avg_win": 0.0, "avg_loss": 0.0, "avg_r": 0.0,
150
+ "best_trade": 0.0, "worst_trade": 0.0,
151
+ "costs_paid": 0.0, "gross_pnl": 0.0, "net_pnl": 0.0,
152
+ }
153
+ if trades is None or trades.empty:
154
+ return empty
155
+
156
+ net = trades["net_pnl"].astype("float64")
157
+ wins = net[net > 0]
158
+ losses = net[net < 0]
159
+ gross_profit = float(wins.sum())
160
+ gross_loss = float(-losses.sum())
161
+
162
+ if gross_loss > 0:
163
+ pf = gross_profit / gross_loss
164
+ elif gross_profit > 0:
165
+ pf = float("inf")
166
+ else:
167
+ pf = 0.0
168
+
169
+ r_vals = trades["r_multiple"].replace([np.inf, -np.inf], np.nan).dropna() \
170
+ if "r_multiple" in trades.columns else pd.Series(dtype="float64")
171
+
172
+ return {
173
+ "trade_count": int(len(trades)),
174
+ "win_rate": float(len(wins) / len(net)) if len(net) else 0.0,
175
+ "profit_factor": float(pf),
176
+ "avg_win": float(wins.mean()) if len(wins) else 0.0,
177
+ "avg_loss": float(losses.mean()) if len(losses) else 0.0,
178
+ "avg_r": float(r_vals.mean()) if len(r_vals) else 0.0,
179
+ "best_trade": float(net.max()),
180
+ "worst_trade": float(net.min()),
181
+ "costs_paid": float(trades["costs"].sum()) if "costs" in trades.columns else 0.0,
182
+ "gross_pnl": float(trades["gross_pnl"].sum()) if "gross_pnl" in trades.columns else 0.0,
183
+ "net_pnl": float(net.sum()),
184
+ }
185
+
186
+
187
+ def compute_metrics(
188
+ equity: pd.Series,
189
+ trades: pd.DataFrame | None,
190
+ bars_per_year: float,
191
+ *,
192
+ segment: str = "all",
193
+ position: pd.Series | None = None,
194
+ ) -> Metrics:
195
+ """Assemble the full metric set for one equity slice."""
196
+ equity = equity.dropna()
197
+ m = Metrics(
198
+ segment=segment,
199
+ start_ts=str(equity.index[0]) if len(equity) else None,
200
+ end_ts=str(equity.index[-1]) if len(equity) else None,
201
+ bars=int(len(equity)),
202
+ total_return=total_return(equity),
203
+ cagr=cagr(equity, bars_per_year),
204
+ sharpe=sharpe(equity, bars_per_year),
205
+ sortino=sortino(equity, bars_per_year),
206
+ max_drawdown=max_drawdown(equity),
207
+ volatility=volatility(equity, bars_per_year),
208
+ exposure=exposure(position) if position is not None else 0.0,
209
+ )
210
+ for k, v in trade_stats(trades).items():
211
+ setattr(m, k, v)
212
+ return m
213
+
214
+
215
+ # --------------------------------------------------------------------------
216
+ # Forecast quality (used by comparisons/ in Phase 2)
217
+ # --------------------------------------------------------------------------
218
+
219
+
220
+ def calibration_coverage(
221
+ actual: pd.Series, lower: pd.Series, upper: pd.Series
222
+ ) -> float:
223
+ """Empirical coverage: share of actuals inside [lower, upper].
224
+
225
+ A well-calibrated q10-q90 band should cover ~0.80 of outcomes.
226
+ """
227
+ df = pd.concat([actual, lower, upper], axis=1).dropna()
228
+ if df.empty:
229
+ return float("nan")
230
+ a, lo, hi = df.iloc[:, 0], df.iloc[:, 1], df.iloc[:, 2]
231
+ return float(((a >= lo) & (a <= hi)).mean())
232
+
233
+
234
+ def calibration_error(coverage: float, nominal: float = 0.80) -> float:
235
+ """Signed miss against the nominal band width. 0.0 is perfect."""
236
+ if not math.isfinite(coverage):
237
+ return float("nan")
238
+ return float(coverage - nominal)
239
+
240
+
241
+ def directional_accuracy(actual_next: pd.Series, predicted_next: pd.Series,
242
+ reference: pd.Series) -> float:
243
+ """Share of bars where the predicted direction matched the realised one."""
244
+ df = pd.concat([actual_next, predicted_next, reference], axis=1).dropna()
245
+ if df.empty:
246
+ return float("nan")
247
+ a, p, ref = df.iloc[:, 0], df.iloc[:, 1], df.iloc[:, 2]
248
+ actual_dir = np.sign(a - ref)
249
+ pred_dir = np.sign(p - ref)
250
+ mask = actual_dir != 0
251
+ if not mask.any():
252
+ return float("nan")
253
+ return float((actual_dir[mask] == pred_dir[mask]).mean())
254
+
255
+
256
+ def pinball_loss(actual: pd.Series, pred: pd.Series, q: float) -> float:
257
+ """Quantile (pinball) loss -- lower is better."""
258
+ df = pd.concat([actual, pred], axis=1).dropna()
259
+ if df.empty:
260
+ return float("nan")
261
+ a, p = df.iloc[:, 0], df.iloc[:, 1]
262
+ diff = a - p
263
+ return float(np.maximum(q * diff, (q - 1) * diff).mean())
src/runtime.py ADDED
@@ -0,0 +1,480 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """App runtime: cached store access, run execution, run history, share links.
2
+
3
+ Hub I/O is cached in-process behind an LRU so a repeated backtest never
4
+ re-downloads a parquet slice. The cache key includes the store's manifest
5
+ `updated_at`, so a coverage extension invalidates exactly the slices that
6
+ changed instead of serving stale data forever.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import base64
12
+ import json
13
+ import logging
14
+ import os
15
+ import threading
16
+ import time
17
+ import uuid
18
+ from dataclasses import dataclass, field, asdict
19
+ from datetime import datetime, timezone
20
+
21
+ import pandas as pd
22
+
23
+ from . import comparisons, config, strategies
24
+ from .engine import (
25
+ BacktestConfig,
26
+ Costs,
27
+ Sizing,
28
+ Stops,
29
+ Validation,
30
+ BacktestResult,
31
+ run_backtest,
32
+ )
33
+ from .store import SignalStore
34
+
35
+ log = logging.getLogger("bit.runtime")
36
+
37
+ _store: SignalStore | None = None
38
+ _store_lock = threading.Lock()
39
+
40
+
41
+ def get_store() -> SignalStore:
42
+ """Process-wide store handle. Read-only for anonymous user traffic."""
43
+ global _store
44
+ with _store_lock:
45
+ if _store is None:
46
+ token = os.environ.get("HF_WRITE_TOKEN") or os.environ.get("HF_TOKEN")
47
+ _store = SignalStore(
48
+ repo_id=config.STORE_REPO,
49
+ local_root=os.environ.get("BIT_STORE_CACHE", ".cache/store"),
50
+ token=token,
51
+ )
52
+ return _store
53
+
54
+
55
+ # --------------------------------------------------------------------------
56
+ # Cached slice access
57
+ # --------------------------------------------------------------------------
58
+
59
+ _slice_cache: dict[tuple, pd.DataFrame] = {}
60
+ _cache_order: list[tuple] = []
61
+ _cache_lock = threading.Lock()
62
+
63
+
64
+ def _cache_get(key):
65
+ with _cache_lock:
66
+ hit = _slice_cache.get(key)
67
+ if hit is not None:
68
+ _cache_order.remove(key)
69
+ _cache_order.append(key)
70
+ return hit
71
+
72
+
73
+ def _cache_put(key, value):
74
+ with _cache_lock:
75
+ _slice_cache[key] = value
76
+ _cache_order.append(key)
77
+ while len(_cache_order) > config.PARQUET_CACHE_SIZE:
78
+ old = _cache_order.pop(0)
79
+ _slice_cache.pop(old, None)
80
+
81
+
82
+ def cache_clear() -> None:
83
+ with _cache_lock:
84
+ _slice_cache.clear()
85
+ _cache_order.clear()
86
+
87
+
88
+ def _manifest_stamp() -> str:
89
+ try:
90
+ return get_store().load_manifest().updated_at
91
+ except Exception:
92
+ return "unknown"
93
+
94
+
95
+ def load_prices(asset: str, timeframe: str, start=None, end=None) -> pd.DataFrame:
96
+ key = ("px", asset, timeframe, str(start), str(end), _manifest_stamp())
97
+ hit = _cache_get(key)
98
+ if hit is not None:
99
+ return hit
100
+ df = get_store().get_prices(asset, timeframe, start, end)
101
+ _cache_put(key, df)
102
+ return df
103
+
104
+
105
+ def load_signals(model_slug: str, asset: str, timeframe: str,
106
+ start=None, end=None) -> pd.DataFrame:
107
+ if not model_slug:
108
+ return pd.DataFrame()
109
+ key = ("sig", model_slug, asset, timeframe, str(start), str(end), _manifest_stamp())
110
+ hit = _cache_get(key)
111
+ if hit is not None:
112
+ return hit
113
+ df = get_store().get_signals(model_slug, asset, timeframe, start, end)
114
+ _cache_put(key, df)
115
+ return df
116
+
117
+
118
+ # --------------------------------------------------------------------------
119
+ # Coverage map
120
+ # --------------------------------------------------------------------------
121
+
122
+
123
+ @dataclass
124
+ class CoverageCell:
125
+ model_slug: str
126
+ asset: str
127
+ timeframe: str
128
+ start: str
129
+ end: str
130
+ rows: int
131
+ is_placeholder: bool
132
+ contributed_by: str
133
+
134
+
135
+ def coverage_map() -> list[CoverageCell]:
136
+ m = get_store().load_manifest()
137
+ return [
138
+ CoverageCell(
139
+ model_slug=e.model_slug, asset=e.asset, timeframe=e.timeframe,
140
+ start=e.start_ts[:10], end=e.end_ts[:10], rows=e.rows,
141
+ is_placeholder=e.is_placeholder, contributed_by=e.contributed_by,
142
+ )
143
+ for e in sorted(m.signals.values(),
144
+ key=lambda x: (x.model_slug, x.asset, x.timeframe))
145
+ ]
146
+
147
+
148
+ def coverage_frame() -> pd.DataFrame:
149
+ cells = coverage_map()
150
+ if not cells:
151
+ return pd.DataFrame(columns=["Model", "Asset", "TF", "Coverage", "Rows",
152
+ "Source", "Real?"])
153
+ return pd.DataFrame([{
154
+ "Model": c.model_slug, "Asset": c.asset, "TF": c.timeframe,
155
+ "Coverage": f"{c.start} → {c.end}", "Rows": f"{c.rows:,}",
156
+ "Source": c.contributed_by,
157
+ "Real?": "PLACEHOLDER" if c.is_placeholder else "real",
158
+ } for c in cells])
159
+
160
+
161
+ def available_models(asset: str | None = None, timeframe: str | None = None) -> list[str]:
162
+ m = get_store().load_manifest()
163
+ return sorted({e.model_slug for e in m.find_signals(asset=asset, timeframe=timeframe)})
164
+
165
+
166
+ def available_assets() -> list[str]:
167
+ m = get_store().load_manifest()
168
+ found = sorted({p.asset for p in m.prices.values()})
169
+ return found or list(config.ASSETS)
170
+
171
+
172
+ def price_coverage_for(asset: str, timeframe: str) -> tuple[str, str] | None:
173
+ cov = get_store().load_manifest().prices.get(f"{asset}|{timeframe}")
174
+ return (cov.start_ts[:10], cov.end_ts[:10]) if cov else None
175
+
176
+
177
+ # --------------------------------------------------------------------------
178
+ # Run configuration
179
+ # --------------------------------------------------------------------------
180
+
181
+ RANGE_YEARS = {"1Y": 1.0, "3Y": 3.0, "5Y": 5.0, "Max": 99.0}
182
+
183
+
184
+ @dataclass
185
+ class RunRequest:
186
+ """Everything the Strategy Builder collects, in one serialisable object."""
187
+
188
+ strategy: str = "SMA Crossover"
189
+ asset: str = "BTC-USD"
190
+ timeframe: str = "1d"
191
+ date_range: str = "3Y"
192
+ model_slug: str = ""
193
+ params: dict = field(default_factory=dict)
194
+
195
+ costs_on: bool = True
196
+ commission_bps: float = config.DEFAULT_COMMISSION_BPS
197
+ slippage_bps: float = config.DEFAULT_SLIPPAGE_BPS
198
+ slippage_model: str = "fixed"
199
+
200
+ sizing_mode: str = "fixed_pct"
201
+ size_pct: float = 1.0
202
+ leverage: float = 1.0
203
+ max_position: float = 1.0
204
+
205
+ sl_pct: float | None = None
206
+ tp_pct: float | None = None
207
+ trail_pct: float | None = None
208
+
209
+ validation_mode: str = "walk_forward"
210
+ train_months: int = 12
211
+ test_months: int = 3
212
+ roll_months: int = 3
213
+ holdout_months: int = 6
214
+
215
+ def to_config(self) -> BacktestConfig:
216
+ return BacktestConfig(
217
+ asset=self.asset, timeframe=self.timeframe, strategy=self.strategy,
218
+ params=dict(self.params),
219
+ costs=Costs(enabled=self.costs_on, commission_bps=self.commission_bps,
220
+ slippage_bps=self.slippage_bps, slippage_model=self.slippage_model),
221
+ sizing=Sizing(mode=self.sizing_mode, pct=self.size_pct,
222
+ leverage=self.leverage, max_position=self.max_position),
223
+ stops=Stops(sl_pct=self.sl_pct, tp_pct=self.tp_pct, trail_pct=self.trail_pct),
224
+ validation=Validation(mode=self.validation_mode, train_months=self.train_months,
225
+ test_months=self.test_months, roll_months=self.roll_months,
226
+ holdout_months=self.holdout_months),
227
+ )
228
+
229
+ # -- share links ------------------------------------------------------
230
+
231
+ def encode(self) -> str:
232
+ raw = json.dumps(asdict(self), sort_keys=True, separators=(",", ":"))
233
+ return base64.urlsafe_b64encode(raw.encode()).decode().rstrip("=")
234
+
235
+ @classmethod
236
+ def decode(cls, token: str) -> "RunRequest":
237
+ pad = "=" * (-len(token) % 4)
238
+ raw = base64.urlsafe_b64decode(token + pad).decode()
239
+ data = json.loads(raw)
240
+ known = {k: v for k, v in data.items() if k in cls.__dataclass_fields__}
241
+ req = cls(**known)
242
+ req.validate()
243
+ return req
244
+
245
+ def validate(self) -> None:
246
+ """Reject anything a share link could smuggle in."""
247
+ if self.strategy not in strategies.PRESETS:
248
+ raise ValueError(f"unknown strategy {self.strategy!r}")
249
+ if self.asset not in config.ASSETS:
250
+ raise ValueError(f"unknown asset {self.asset!r}")
251
+ if self.timeframe not in config.TIMEFRAMES:
252
+ raise ValueError(f"unknown timeframe {self.timeframe!r}")
253
+ if self.validation_mode not in ("none", "split", "walk_forward", "holdout"):
254
+ raise ValueError(f"unknown validation mode {self.validation_mode!r}")
255
+ if self.slippage_model not in ("fixed", "volume_scaled"):
256
+ raise ValueError(f"unknown slippage model {self.slippage_model!r}")
257
+ if self.sizing_mode not in ("fixed_pct", "vol_target", "fixed_units"):
258
+ raise ValueError(f"unknown sizing mode {self.sizing_mode!r}")
259
+ if not isinstance(self.params, dict):
260
+ raise ValueError("params must be an object")
261
+ for k in self.params:
262
+ if not isinstance(k, str) or not k.replace("_", "").isalnum():
263
+ raise ValueError(f"bad parameter name {k!r}")
264
+
265
+
266
+ # --------------------------------------------------------------------------
267
+ # Execution
268
+ # --------------------------------------------------------------------------
269
+
270
+
271
+ @dataclass
272
+ class RunRecord:
273
+ run_id: str
274
+ label: str
275
+ request: RunRequest
276
+ result: BacktestResult
277
+ created_at: str
278
+ elapsed_s: float
279
+
280
+ @property
281
+ def sharpe(self) -> float:
282
+ return self.result.metrics_oos.sharpe or self.result.metrics_all.sharpe
283
+
284
+ @property
285
+ def meta(self) -> str:
286
+ r = self.request
287
+ mode = {"walk_forward": "WF", "holdout": "HOLDOUT",
288
+ "split": "SPLIT", "none": "—"}.get(r.validation_mode, r.validation_mode)
289
+ return f"{r.timeframe} · {r.date_range} · {mode}"
290
+
291
+
292
+ class RunError(RuntimeError):
293
+ pass
294
+
295
+
296
+ def window_for(asset: str, timeframe: str, date_range: str):
297
+ cov = price_coverage_for(asset, timeframe)
298
+ if cov is None:
299
+ raise RunError(
300
+ f"No cached price coverage for {asset} {timeframe}. "
301
+ "Pick another pair, or extend coverage."
302
+ )
303
+ start_cov, end_cov = pd.Timestamp(cov[0], tz="UTC"), pd.Timestamp(cov[1], tz="UTC")
304
+ years = RANGE_YEARS.get(date_range, 3.0)
305
+ start = max(start_cov, end_cov - pd.Timedelta(days=int(365 * years)))
306
+ return start, end_cov
307
+
308
+
309
+ def execute(req: RunRequest) -> RunRecord:
310
+ """Run one backtest against cached data only. Never touches a provider."""
311
+ t0 = time.perf_counter()
312
+ req.validate()
313
+
314
+ preset = strategies.PRESETS[req.strategy]
315
+ if not preset.available:
316
+ raise RunError(f"{req.strategy}: {preset.unavailable_reason}")
317
+
318
+ start, end = window_for(req.asset, req.timeframe, req.date_range)
319
+ prices = load_prices(req.asset, req.timeframe, start, end)
320
+ if prices.empty or len(prices) < 60:
321
+ raise RunError(
322
+ f"Only {len(prices)} cached bars for {req.asset} {req.timeframe} — "
323
+ "not enough to backtest. Try a longer range or another timeframe."
324
+ )
325
+
326
+ signals = pd.DataFrame()
327
+ if preset.needs_signals:
328
+ model = req.model_slug or (available_models(req.asset, req.timeframe) or [""])[0]
329
+ if not model:
330
+ raise RunError(
331
+ f"{req.strategy} needs stored model signals, and none are cached "
332
+ f"for {req.asset} {req.timeframe}. Use Extend coverage to add them."
333
+ )
334
+ signals = load_signals(model, req.asset, req.timeframe, start, end)
335
+ if signals.empty:
336
+ raise RunError(f"No signal coverage for {model} on {req.asset} {req.timeframe}.")
337
+
338
+ params = {**strategies.defaults_for(req.strategy), **(req.params or {})}
339
+ out = strategies.build(req.strategy, prices, params, signals)
340
+ cfg = req.to_config()
341
+ result = run_backtest(prices, out, cfg,
342
+ bars_per_year=config.bars_per_year(req.asset, req.timeframe))
343
+
344
+ return RunRecord(
345
+ run_id=uuid.uuid4().hex[:8],
346
+ label=f"{req.strategy} · {req.asset} {req.timeframe}",
347
+ request=req, result=result,
348
+ created_at=datetime.now(timezone.utc).isoformat(timespec="seconds"),
349
+ elapsed_s=time.perf_counter() - t0,
350
+ )
351
+
352
+
353
+ # --------------------------------------------------------------------------
354
+ # Robustness helpers
355
+ # --------------------------------------------------------------------------
356
+
357
+
358
+ def parameter_sweep(req: RunRequest, x_key: str, x_vals: list, y_key: str,
359
+ y_vals: list) -> pd.DataFrame:
360
+ """Two-parameter OOS-Sharpe sweep for the sensitivity heatmap.
361
+
362
+ Runs against the selection window only; the holdout is never touched,
363
+ because `build_validation_plan` excludes it from every window it emits.
364
+ """
365
+ rows = []
366
+ for xv in x_vals:
367
+ for yv in y_vals:
368
+ trial = RunRequest(**{**asdict(req), "params": {**req.params, x_key: xv, y_key: yv}})
369
+ try:
370
+ rec = execute(trial)
371
+ rows.append({x_key: xv, y_key: yv,
372
+ "oos_sharpe": rec.result.metrics_oos.sharpe})
373
+ except Exception:
374
+ rows.append({x_key: xv, y_key: yv, "oos_sharpe": float("nan")})
375
+ return pd.DataFrame(rows)
376
+
377
+
378
+ def slippage_stress(req: RunRequest, bps_points=(0, 5, 10, 20)) -> list[tuple[float, float]]:
379
+ out = []
380
+ for bps in bps_points:
381
+ trial = RunRequest(**{**asdict(req), "costs_on": True, "slippage_bps": float(bps)})
382
+ try:
383
+ rec = execute(trial)
384
+ out.append((float(bps), rec.result.metrics_oos.sharpe))
385
+ except Exception:
386
+ out.append((float(bps), float("nan")))
387
+ return out
388
+
389
+
390
+ def regime_breakdown(rec: RunRecord) -> pd.DataFrame:
391
+ """Strategy return within each market regime."""
392
+ from .charts import classify_regime
393
+
394
+ res = rec.result
395
+ if res.prices is None or res.prices.empty:
396
+ return pd.DataFrame()
397
+ reg = classify_regime(res.prices)
398
+ eq = res.equity.reindex(reg.index).ffill()
399
+ rets = eq.pct_change().fillna(0.0)
400
+ rows = []
401
+ for name in ("bull", "bear", "chop"):
402
+ mask = reg == name
403
+ if not mask.any():
404
+ continue
405
+ rows.append({"regime": name.upper(),
406
+ rec.label[:24]: float((1 + rets[mask]).prod() - 1.0)})
407
+ return pd.DataFrame(rows)
408
+
409
+
410
+ def overfit_verdict(rec: RunRecord) -> tuple[str, list[tuple[str, str]]]:
411
+ """A blunt grade plus the checks behind it."""
412
+ res = rec.result
413
+ checks: list[tuple[str, str]] = []
414
+ score = 0
415
+
416
+ is_s, oos_s = res.metrics_is.sharpe, res.metrics_oos.sharpe
417
+ if res.metrics_oos.bars == 0:
418
+ checks.append(("✗", "no out-of-sample period was produced — this result is "
419
+ "entirely in-sample and cannot be trusted"))
420
+ else:
421
+ ratio = (oos_s / is_s) if is_s else float("nan")
422
+ if pd.notna(ratio) and ratio >= 0.5:
423
+ checks.append(("✓", f"OOS Sharpe holds at {ratio:.2f} of in-sample"))
424
+ score += 1
425
+ else:
426
+ checks.append(("✗", f"OOS Sharpe collapses to {ratio:.2f} of in-sample"))
427
+
428
+ n = res.metrics_all.trade_count
429
+ if n >= 30:
430
+ checks.append(("✓", f"{n} trades is enough to mean something"))
431
+ score += 1
432
+ else:
433
+ checks.append(("✗", f"only {n} trades — the result is mostly noise"))
434
+
435
+ if res.windows:
436
+ pos = sum(1 for w in res.windows if w.metrics.total_return > 0)
437
+ if pos >= len(res.windows) * 0.6:
438
+ checks.append(("✓", f"{pos}/{len(res.windows)} walk-forward windows positive"))
439
+ score += 1
440
+ else:
441
+ checks.append(("✗", f"only {pos}/{len(res.windows)} windows positive"))
442
+ else:
443
+ checks.append(("·", "no walk-forward windows in this configuration"))
444
+
445
+ if res.costs_paid > 0:
446
+ checks.append(("✓", f"costs modelled: ${res.costs_paid:,.0f} paid"))
447
+ score += 1
448
+ else:
449
+ checks.append(("✗", "costs are off — this number is not real"))
450
+
451
+ if res.metrics_holdout is not None:
452
+ hs = res.metrics_holdout.sharpe
453
+ if hs > 0:
454
+ checks.append(("✓", f"locked holdout Sharpe {hs:.2f}"))
455
+ score += 1
456
+ else:
457
+ checks.append(("✗", f"locked holdout Sharpe {hs:.2f} — it fails on unseen data"))
458
+ else:
459
+ checks.append(("·", "no locked holdout reserved"))
460
+
461
+ grade = ["FAILS", "FRAGILE", "FRAGILE", "PLAUSIBLE", "PLAUSIBLE", "SOLID"][min(score, 5)]
462
+ return grade, checks
463
+
464
+
465
+ def save_run_summary(rec: RunRecord) -> str:
466
+ """Write a shareable run summary into the store's runs/ folder."""
467
+ payload = {
468
+ "run_id": rec.run_id, "label": rec.label, "created_at": rec.created_at,
469
+ "config": asdict(rec.request), "share_token": rec.request.encode(),
470
+ "summary": rec.result.summary(),
471
+ "metrics": {
472
+ "all": rec.result.metrics_all.to_dict(),
473
+ "is": rec.result.metrics_is.to_dict(),
474
+ "oos": rec.result.metrics_oos.to_dict(),
475
+ "holdout": rec.result.metrics_holdout.to_dict()
476
+ if rec.result.metrics_holdout else None,
477
+ },
478
+ }
479
+ get_store().write_json(f"runs/{rec.run_id}.json", payload)
480
+ return rec.run_id
src/store.py ADDED
@@ -0,0 +1,760 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Signal store: manifest, coverage queries, and append-only Hub writes.
2
+
3
+ The store holds *raw model outputs and prices only* -- never trade decisions.
4
+ Trading rules, costs and sizing are applied live by engine.py per request.
5
+
6
+ Layout inside the dataset repo:
7
+
8
+ manifest.json
9
+ signals/{model_slug}/{asset}/{timeframe}/{year}.parquet
10
+ prices/{asset}/{timeframe}/{year}.parquet
11
+ comparisons/*.parquet | *.json
12
+ runs/{run_id}.json
13
+
14
+ Writes are staged into a local mirror directory and pushed as a *single*
15
+ atomic commit per flush, with the manifest as the last operation in the
16
+ commit. A CommitScheduler can be attached to batch flushes in the running
17
+ Space (see `attach_scheduler`).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import os
24
+ import threading
25
+ from dataclasses import dataclass, field, asdict
26
+ from datetime import datetime, timezone
27
+ from pathlib import Path
28
+ from typing import Iterable, Literal
29
+
30
+ import pandas as pd
31
+
32
+ from . import config
33
+
34
+ # --------------------------------------------------------------------------
35
+ # Errors
36
+ # --------------------------------------------------------------------------
37
+
38
+
39
+ class StoreError(RuntimeError):
40
+ """Raised when the store is asked to do something inconsistent."""
41
+
42
+
43
+ class SchemaError(StoreError):
44
+ """Raised when a manifest or parquet slice fails schema validation."""
45
+
46
+
47
+ # --------------------------------------------------------------------------
48
+ # Schema
49
+ # --------------------------------------------------------------------------
50
+
51
+ SIGNAL_COLUMNS_QUANTILE = ["ts", "q10", "q50", "q90", "context_len", "inference_version"]
52
+ SIGNAL_COLUMNS_CLASSIFIER = ["ts", "pred", "confidence", "context_len", "inference_version"]
53
+ PRICE_COLUMNS = ["ts", "open", "high", "low", "close", "volume", "source"]
54
+
55
+ SignalKind = Literal["quantile", "classifier"]
56
+
57
+
58
+ def _utc(ts) -> pd.Timestamp:
59
+ """Coerce anything timestamp-ish to a UTC-aware pandas Timestamp."""
60
+ t = pd.Timestamp(ts)
61
+ return t.tz_localize("UTC") if t.tzinfo is None else t.tz_convert("UTC")
62
+
63
+
64
+ def _iso(ts) -> str:
65
+ return _utc(ts).isoformat().replace("+00:00", "Z")
66
+
67
+
68
+ def _now_iso() -> str:
69
+ return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
70
+
71
+
72
+ def signal_key(model_slug: str, model_revision: str, asset: str, timeframe: str) -> str:
73
+ """Canonical manifest key. Revision is part of the identity on purpose:
74
+ a different model revision is a different signal series."""
75
+ return f"{model_slug}@{model_revision}|{asset}|{timeframe}"
76
+
77
+
78
+ def price_key(asset: str, timeframe: str) -> str:
79
+ return f"{asset}|{timeframe}"
80
+
81
+
82
+ @dataclass
83
+ class CoverageEntry:
84
+ """One (model, revision, asset, timeframe) coverage record."""
85
+
86
+ model_slug: str
87
+ model_id: str
88
+ model_revision: str
89
+ asset: str
90
+ timeframe: str
91
+ start_ts: str
92
+ end_ts: str
93
+ rows: int
94
+ inference_version: str
95
+ last_updated: str
96
+ contributed_by: str
97
+ signal_kind: SignalKind = "quantile"
98
+
99
+ @property
100
+ def key(self) -> str:
101
+ return signal_key(self.model_slug, self.model_revision, self.asset, self.timeframe)
102
+
103
+ @property
104
+ def is_placeholder(self) -> bool:
105
+ return self.inference_version == config.PLACEHOLDER_VERSION
106
+
107
+ def validate(self) -> None:
108
+ for f in ("model_slug", "model_id", "model_revision", "asset",
109
+ "timeframe", "inference_version", "contributed_by"):
110
+ if not getattr(self, f):
111
+ raise SchemaError(f"CoverageEntry.{f} must be non-empty")
112
+ if self.timeframe not in config.TIMEFRAMES:
113
+ raise SchemaError(f"unknown timeframe {self.timeframe!r}")
114
+ if self.rows < 0:
115
+ raise SchemaError("CoverageEntry.rows must be >= 0")
116
+ if _utc(self.start_ts) > _utc(self.end_ts):
117
+ raise SchemaError(f"start_ts after end_ts for {self.key}")
118
+ if self.signal_kind not in ("quantile", "classifier"):
119
+ raise SchemaError(f"bad signal_kind {self.signal_kind!r}")
120
+
121
+ @classmethod
122
+ def from_dict(cls, d: dict) -> "CoverageEntry":
123
+ known = {k: v for k, v in d.items() if k in cls.__dataclass_fields__}
124
+ return cls(**known)
125
+
126
+
127
+ @dataclass
128
+ class PriceCoverage:
129
+ """Cached OHLCV coverage for one (asset, timeframe).
130
+
131
+ `provider_max_days` records an honest provider depth boundary (e.g. Yahoo
132
+ only serves ~730d of 1h bars). It is a fact about coverage, not an error.
133
+ """
134
+
135
+ asset: str
136
+ timeframe: str
137
+ start_ts: str
138
+ end_ts: str
139
+ rows: int
140
+ sources: list[str]
141
+ last_updated: str
142
+ provider_max_days: int | None = None
143
+ gaps: int = 0
144
+
145
+ @property
146
+ def key(self) -> str:
147
+ return price_key(self.asset, self.timeframe)
148
+
149
+ def validate(self) -> None:
150
+ if self.timeframe not in config.TIMEFRAMES:
151
+ raise SchemaError(f"unknown timeframe {self.timeframe!r}")
152
+ if self.rows < 0:
153
+ raise SchemaError("PriceCoverage.rows must be >= 0")
154
+ if _utc(self.start_ts) > _utc(self.end_ts):
155
+ raise SchemaError(f"start_ts after end_ts for {self.key}")
156
+
157
+ @classmethod
158
+ def from_dict(cls, d: dict) -> "PriceCoverage":
159
+ known = {k: v for k, v in d.items() if k in cls.__dataclass_fields__}
160
+ return cls(**known)
161
+
162
+
163
+ @dataclass
164
+ class Manifest:
165
+ schema_version: int = config.MANIFEST_SCHEMA_VERSION
166
+ updated_at: str = field(default_factory=_now_iso)
167
+ signals: dict[str, CoverageEntry] = field(default_factory=dict)
168
+ prices: dict[str, PriceCoverage] = field(default_factory=dict)
169
+
170
+ # -- serialisation ----------------------------------------------------
171
+
172
+ def to_dict(self) -> dict:
173
+ return {
174
+ "schema_version": self.schema_version,
175
+ "updated_at": self.updated_at,
176
+ "signals": {k: asdict(v) for k, v in sorted(self.signals.items())},
177
+ "prices": {k: asdict(v) for k, v in sorted(self.prices.items())},
178
+ }
179
+
180
+ @classmethod
181
+ def from_dict(cls, d: dict) -> "Manifest":
182
+ if not isinstance(d, dict):
183
+ raise SchemaError("manifest must be a JSON object")
184
+ ver = d.get("schema_version")
185
+ if ver is None:
186
+ raise SchemaError("manifest missing schema_version")
187
+ if int(ver) > config.MANIFEST_SCHEMA_VERSION:
188
+ raise SchemaError(
189
+ f"manifest schema_version {ver} is newer than this app supports "
190
+ f"({config.MANIFEST_SCHEMA_VERSION}); upgrade the Space"
191
+ )
192
+ m = cls(
193
+ schema_version=int(ver),
194
+ updated_at=d.get("updated_at") or _now_iso(),
195
+ signals={k: CoverageEntry.from_dict(v) for k, v in (d.get("signals") or {}).items()},
196
+ prices={k: PriceCoverage.from_dict(v) for k, v in (d.get("prices") or {}).items()},
197
+ )
198
+ m.validate()
199
+ return m
200
+
201
+ def to_json(self) -> str:
202
+ return json.dumps(self.to_dict(), indent=2, sort_keys=False) + "\n"
203
+
204
+ @classmethod
205
+ def from_json(cls, text: str) -> "Manifest":
206
+ try:
207
+ return cls.from_dict(json.loads(text))
208
+ except json.JSONDecodeError as e:
209
+ raise SchemaError(f"manifest is not valid JSON: {e}") from e
210
+
211
+ def validate(self) -> None:
212
+ for k, e in self.signals.items():
213
+ e.validate()
214
+ if e.key != k:
215
+ raise SchemaError(f"manifest signal key {k!r} != entry key {e.key!r}")
216
+ for k, p in self.prices.items():
217
+ p.validate()
218
+ if p.key != k:
219
+ raise SchemaError(f"manifest price key {k!r} != entry key {p.key!r}")
220
+
221
+ # -- queries ----------------------------------------------------------
222
+
223
+ def get_signal(self, model_slug, model_revision, asset, timeframe) -> CoverageEntry | None:
224
+ return self.signals.get(signal_key(model_slug, model_revision, asset, timeframe))
225
+
226
+ def find_signals(self, model_slug=None, asset=None, timeframe=None) -> list[CoverageEntry]:
227
+ out = []
228
+ for e in self.signals.values():
229
+ if model_slug and e.model_slug != model_slug:
230
+ continue
231
+ if asset and e.asset != asset:
232
+ continue
233
+ if timeframe and e.timeframe != timeframe:
234
+ continue
235
+ out.append(e)
236
+ return sorted(out, key=lambda e: (e.model_slug, e.asset, e.timeframe))
237
+
238
+ def upsert_signal(self, entry: CoverageEntry) -> None:
239
+ """Merge a new slice into coverage, widening the range and summing rows.
240
+
241
+ Coverage is a *union*; re-writing an overlapping slice must not double
242
+ count, so callers pass the post-merge row count via `rows`.
243
+ """
244
+ entry.validate()
245
+ prev = self.signals.get(entry.key)
246
+ if prev is not None:
247
+ entry.start_ts = _iso(min(_utc(prev.start_ts), _utc(entry.start_ts)))
248
+ entry.end_ts = _iso(max(_utc(prev.end_ts), _utc(entry.end_ts)))
249
+ self.signals[entry.key] = entry
250
+ self.updated_at = _now_iso()
251
+
252
+ def upsert_price(self, cov: PriceCoverage) -> None:
253
+ cov.validate()
254
+ prev = self.prices.get(cov.key)
255
+ if prev is not None:
256
+ cov.start_ts = _iso(min(_utc(prev.start_ts), _utc(cov.start_ts)))
257
+ cov.end_ts = _iso(max(_utc(prev.end_ts), _utc(cov.end_ts)))
258
+ cov.sources = sorted(set(prev.sources) | set(cov.sources))
259
+ self.prices[cov.key] = cov
260
+ self.updated_at = _now_iso()
261
+
262
+
263
+ def empty_manifest() -> Manifest:
264
+ return Manifest()
265
+
266
+
267
+ # --------------------------------------------------------------------------
268
+ # Frame validation
269
+ # --------------------------------------------------------------------------
270
+
271
+
272
+ def validate_signal_frame(df: pd.DataFrame, kind: SignalKind = "quantile") -> pd.DataFrame:
273
+ """Check and normalise a signal frame. Returns a sorted, UTC-indexed copy."""
274
+ cols = SIGNAL_COLUMNS_QUANTILE if kind == "quantile" else SIGNAL_COLUMNS_CLASSIFIER
275
+ missing = [c for c in cols if c not in df.columns]
276
+ if missing:
277
+ raise SchemaError(f"signal frame missing columns: {missing}")
278
+ out = df.loc[:, cols].copy()
279
+ out["ts"] = out["ts"].map(_utc)
280
+ if out["ts"].duplicated().any():
281
+ dupes = out.loc[out["ts"].duplicated(), "ts"].head(3).tolist()
282
+ raise SchemaError(f"signal frame has duplicate timestamps, e.g. {dupes}")
283
+ if kind == "quantile":
284
+ for c in ("q10", "q50", "q90"):
285
+ if out[c].isna().any():
286
+ raise SchemaError(f"signal frame column {c} contains NaN")
287
+ # Quantiles must be monotone; a crossed quantile means a broken adapter.
288
+ bad = (out["q10"] > out["q50"]) | (out["q50"] > out["q90"])
289
+ if bad.any():
290
+ raise SchemaError(
291
+ f"{int(bad.sum())} rows have crossed quantiles (q10>q50 or q50>q90)"
292
+ )
293
+ return out.sort_values("ts").reset_index(drop=True)
294
+
295
+
296
+ @dataclass
297
+ class PriceValidation:
298
+ """Outcome of validating an OHLCV frame -- the gap report lives here."""
299
+
300
+ rows: int
301
+ gaps: int = 0
302
+ gap_ranges: list[tuple[str, str]] = field(default_factory=list)
303
+ problems: list[str] = field(default_factory=list)
304
+
305
+ @property
306
+ def ok(self) -> bool:
307
+ return not self.problems
308
+
309
+
310
+ def validate_price_frame(
311
+ df: pd.DataFrame, timeframe: str, *, strict: bool = True
312
+ ) -> tuple[pd.DataFrame, PriceValidation]:
313
+ """Validate an OHLCV frame and report gaps.
314
+
315
+ Rejects: missing columns, duplicate timestamps, negative/zero prices,
316
+ non-finite values, and OHLC bars that are internally inconsistent
317
+ (high < low, or a high below open/close).
318
+ """
319
+ missing = [c for c in PRICE_COLUMNS if c not in df.columns]
320
+ if missing:
321
+ raise SchemaError(f"price frame missing columns: {missing}")
322
+
323
+ out = df.loc[:, PRICE_COLUMNS].copy()
324
+ out["ts"] = out["ts"].map(_utc)
325
+ out = out.sort_values("ts").reset_index(drop=True)
326
+ report = PriceValidation(rows=len(out))
327
+
328
+ if out["ts"].duplicated().any():
329
+ dupes = out.loc[out["ts"].duplicated(), "ts"].head(3).tolist()
330
+ report.problems.append(f"duplicate timestamps: {dupes}")
331
+
332
+ ohlc = ["open", "high", "low", "close"]
333
+ for c in ohlc:
334
+ vals = pd.to_numeric(out[c], errors="coerce")
335
+ if vals.isna().any():
336
+ report.problems.append(f"{c} has non-numeric or NaN values")
337
+ if (vals <= 0).any():
338
+ n = int((vals <= 0).sum())
339
+ report.problems.append(f"{c} has {n} non-positive values")
340
+ out[c] = vals
341
+
342
+ vol = pd.to_numeric(out["volume"], errors="coerce")
343
+ if (vol < 0).any():
344
+ report.problems.append(f"volume has {int((vol < 0).sum())} negative values")
345
+ out["volume"] = vol
346
+
347
+ inconsistent = (
348
+ (out["high"] < out["low"])
349
+ | (out["high"] < out["open"])
350
+ | (out["high"] < out["close"])
351
+ | (out["low"] > out["open"])
352
+ | (out["low"] > out["close"])
353
+ )
354
+ if inconsistent.any():
355
+ report.problems.append(f"{int(inconsistent.sum())} bars have inconsistent OHLC")
356
+
357
+ # Gap report: how many expected bars are absent from the series.
358
+ if len(out) > 2:
359
+ step = pd.Timedelta(minutes=config.TIMEFRAMES[timeframe].minutes)
360
+ deltas = out["ts"].diff().dropna()
361
+ gap_mask = deltas > step * 1.5
362
+ report.gaps = int(gap_mask.sum())
363
+ idx = list(deltas.index[gap_mask])[:20]
364
+ report.gap_ranges = [
365
+ (_iso(out["ts"].iloc[i - 1]), _iso(out["ts"].iloc[i])) for i in idx
366
+ ]
367
+
368
+ if strict and report.problems:
369
+ raise SchemaError("price validation failed: " + "; ".join(report.problems))
370
+ return out, report
371
+
372
+
373
+ # --------------------------------------------------------------------------
374
+ # The store
375
+ # --------------------------------------------------------------------------
376
+
377
+
378
+ def _years(start, end) -> list[int]:
379
+ return list(range(_utc(start).year, _utc(end).year + 1))
380
+
381
+
382
+ class SignalStore:
383
+ """Read/write access to the signal store.
384
+
385
+ `local_root` is a working mirror of the repo. Reads fall back to the Hub
386
+ when a file is absent locally; writes stage into the mirror and are pushed
387
+ by `flush()` as one atomic commit.
388
+
389
+ Set `offline=True` (or leave `repo_id=None`) for a purely local store --
390
+ used by tests and by the seed script before it has a token.
391
+ """
392
+
393
+ def __init__(
394
+ self,
395
+ repo_id: str | None = config.STORE_REPO,
396
+ local_root: str | os.PathLike | None = None,
397
+ token: str | None = None,
398
+ offline: bool = False,
399
+ revision: str = "main",
400
+ ) -> None:
401
+ self.repo_id = repo_id
402
+ self.revision = revision
403
+ self.token = token or os.environ.get("HF_WRITE_TOKEN") or os.environ.get("HF_TOKEN")
404
+ self.offline = offline or repo_id is None
405
+ self.local_root = Path(local_root or Path(os.environ.get("BIT_STORE_CACHE", ".cache/store")))
406
+ self.local_root.mkdir(parents=True, exist_ok=True)
407
+ self._manifest: Manifest | None = None
408
+ self._pending: set[str] = set() # repo-relative paths staged for commit
409
+ self._lock = threading.RLock()
410
+ self._scheduler = None
411
+
412
+ # -- paths ------------------------------------------------------------
413
+
414
+ @staticmethod
415
+ def signal_path(model_slug: str, asset: str, timeframe: str, year: int) -> str:
416
+ return f"signals/{model_slug}/{asset}/{timeframe}/{year}.parquet"
417
+
418
+ @staticmethod
419
+ def price_path(asset: str, timeframe: str, year: int) -> str:
420
+ return f"prices/{asset}/{timeframe}/{year}.parquet"
421
+
422
+ @staticmethod
423
+ def comparison_path(name: str) -> str:
424
+ return f"comparisons/{name}"
425
+
426
+ def _local(self, repo_path: str) -> Path:
427
+ return self.local_root / repo_path
428
+
429
+ # -- low-level file access -------------------------------------------
430
+
431
+ def _fetch(self, repo_path: str) -> Path | None:
432
+ """Return a local path for `repo_path`, pulling from the Hub if needed."""
433
+ local = self._local(repo_path)
434
+ if local.exists():
435
+ return local
436
+ if self.offline:
437
+ return None
438
+ try:
439
+ from huggingface_hub import hf_hub_download
440
+
441
+ got = hf_hub_download(
442
+ repo_id=self.repo_id,
443
+ repo_type=config.STORE_REPO_TYPE,
444
+ filename=repo_path,
445
+ revision=self.revision,
446
+ token=self.token,
447
+ )
448
+ return Path(got)
449
+ except Exception:
450
+ # Absent from the Hub is a normal "no coverage" answer, not a fault.
451
+ return None
452
+
453
+ def read_parquet(self, repo_path: str) -> pd.DataFrame | None:
454
+ p = self._fetch(repo_path)
455
+ if p is None:
456
+ return None
457
+ return pd.read_parquet(p)
458
+
459
+ def _stage(self, repo_path: str, write) -> None:
460
+ """Write a file into the local mirror and mark it for the next commit."""
461
+ local = self._local(repo_path)
462
+ local.parent.mkdir(parents=True, exist_ok=True)
463
+ write(local)
464
+ with self._lock:
465
+ self._pending.add(repo_path)
466
+
467
+ # -- manifest ---------------------------------------------------------
468
+
469
+ def load_manifest(self, force: bool = False) -> Manifest:
470
+ with self._lock:
471
+ if self._manifest is not None and not force:
472
+ return self._manifest
473
+ local = self._local(config.MANIFEST_PATH)
474
+ text = None
475
+ if local.exists():
476
+ text = local.read_text()
477
+ elif not self.offline:
478
+ p = self._fetch(config.MANIFEST_PATH)
479
+ if p is not None:
480
+ text = Path(p).read_text()
481
+ self._manifest = Manifest.from_json(text) if text else empty_manifest()
482
+ return self._manifest
483
+
484
+ def save_manifest(self, manifest: Manifest | None = None) -> Manifest:
485
+ with self._lock:
486
+ m = manifest or self.load_manifest()
487
+ m.validate()
488
+ m.updated_at = _now_iso()
489
+ self._manifest = m
490
+ self._stage(config.MANIFEST_PATH, lambda p: p.write_text(m.to_json()))
491
+ return m
492
+
493
+ # -- coverage queries -------------------------------------------------
494
+
495
+ def has_coverage(
496
+ self, model_slug: str, model_revision: str, asset: str, timeframe: str,
497
+ start=None, end=None, *, allow_placeholder: bool = True,
498
+ ) -> bool:
499
+ e = self.load_manifest().get_signal(model_slug, model_revision, asset, timeframe)
500
+ if e is None or e.rows == 0:
501
+ return False
502
+ if not allow_placeholder and e.is_placeholder:
503
+ return False
504
+ if start is not None and _utc(start) < _utc(e.start_ts):
505
+ return False
506
+ if end is not None and _utc(end) > _utc(e.end_ts):
507
+ return False
508
+ return True
509
+
510
+ def missing_ranges(
511
+ self, model_slug: str, model_revision: str, asset: str, timeframe: str, start, end
512
+ ) -> list[tuple[pd.Timestamp, pd.Timestamp]]:
513
+ """Sub-ranges of [start, end] not yet covered. Drives extension dedup:
514
+ an already-covered request returns [] and must never be recomputed."""
515
+ s, e = _utc(start), _utc(end)
516
+ if s > e:
517
+ return []
518
+ entry = self.load_manifest().get_signal(model_slug, model_revision, asset, timeframe)
519
+ if entry is None or entry.rows == 0:
520
+ return [(s, e)]
521
+ cs, ce = _utc(entry.start_ts), _utc(entry.end_ts)
522
+ out = []
523
+ if s < cs:
524
+ out.append((s, min(e, cs - pd.Timedelta(seconds=1))))
525
+ if e > ce:
526
+ out.append((max(s, ce + pd.Timedelta(seconds=1)), e))
527
+ return [(a, b) for a, b in out if a <= b]
528
+
529
+ # -- reads ------------------------------------------------------------
530
+
531
+ def _read_years(self, path_fn, years: Iterable[int]) -> pd.DataFrame | None:
532
+ frames = []
533
+ for y in years:
534
+ df = self.read_parquet(path_fn(y))
535
+ if df is not None and len(df):
536
+ frames.append(df)
537
+ if not frames:
538
+ return None
539
+ out = pd.concat(frames, ignore_index=True)
540
+ out["ts"] = out["ts"].map(_utc)
541
+ return out.sort_values("ts").reset_index(drop=True)
542
+
543
+ def get_signals(
544
+ self, model_slug: str, asset: str, timeframe: str, start=None, end=None
545
+ ) -> pd.DataFrame:
546
+ """Signal slice, ts-indexed. Empty frame when there is no coverage."""
547
+ m = self.load_manifest()
548
+ entries = m.find_signals(model_slug=model_slug, asset=asset, timeframe=timeframe)
549
+ if start is None or end is None:
550
+ if not entries:
551
+ return pd.DataFrame(columns=SIGNAL_COLUMNS_QUANTILE).set_index(
552
+ pd.DatetimeIndex([], tz="UTC", name="ts")
553
+ )
554
+ start = start or min(_utc(e.start_ts) for e in entries)
555
+ end = end or max(_utc(e.end_ts) for e in entries)
556
+ s, e = _utc(start), _utc(end)
557
+ df = self._read_years(
558
+ lambda y: self.signal_path(model_slug, asset, timeframe, y), _years(s, e)
559
+ )
560
+ if df is None:
561
+ return pd.DataFrame(columns=SIGNAL_COLUMNS_QUANTILE).set_index(
562
+ pd.DatetimeIndex([], tz="UTC", name="ts")
563
+ )
564
+ df = df[(df["ts"] >= s) & (df["ts"] <= e)]
565
+ return df.set_index("ts").sort_index()
566
+
567
+ def get_prices(self, asset: str, timeframe: str, start=None, end=None) -> pd.DataFrame:
568
+ """OHLCV slice, ts-indexed. Empty frame when there is no coverage."""
569
+ m = self.load_manifest()
570
+ cov = m.prices.get(price_key(asset, timeframe))
571
+ if start is None:
572
+ start = cov.start_ts if cov else "1970-01-01"
573
+ if end is None:
574
+ end = cov.end_ts if cov else _now_iso()
575
+ s, e = _utc(start), _utc(end)
576
+ df = self._read_years(lambda y: self.price_path(asset, timeframe, y), _years(s, e))
577
+ if df is None:
578
+ return pd.DataFrame(columns=PRICE_COLUMNS).set_index(
579
+ pd.DatetimeIndex([], tz="UTC", name="ts")
580
+ )
581
+ df = df[(df["ts"] >= s) & (df["ts"] <= e)]
582
+ return df.set_index("ts").sort_index()
583
+
584
+ # -- writes -----------------------------------------------------------
585
+
586
+ def _merge_year(self, repo_path: str, new: pd.DataFrame) -> pd.DataFrame:
587
+ """Append-only merge: existing rows win on a ts collision."""
588
+ existing = self.read_parquet(repo_path)
589
+ if existing is None or not len(existing):
590
+ return new.sort_values("ts").reset_index(drop=True)
591
+ existing = existing.copy()
592
+ existing["ts"] = existing["ts"].map(_utc)
593
+ merged = pd.concat([existing, new], ignore_index=True)
594
+ merged = merged.drop_duplicates(subset="ts", keep="first")
595
+ return merged.sort_values("ts").reset_index(drop=True)
596
+
597
+ def write_signals(
598
+ self,
599
+ model_slug: str,
600
+ model_id: str,
601
+ model_revision: str,
602
+ asset: str,
603
+ timeframe: str,
604
+ df: pd.DataFrame,
605
+ *,
606
+ inference_version: str = config.INFERENCE_VERSION,
607
+ contributed_by: str = "seed",
608
+ signal_kind: SignalKind = "quantile",
609
+ ) -> CoverageEntry:
610
+ """Stage a signal slice and update the manifest. Idempotent per ts."""
611
+ frame = validate_signal_frame(df, kind=signal_kind)
612
+ if frame.empty:
613
+ raise StoreError("refusing to write an empty signal frame")
614
+
615
+ total_rows = 0
616
+ for year, part in frame.groupby(frame["ts"].dt.year):
617
+ path = self.signal_path(model_slug, asset, timeframe, int(year))
618
+ merged = self._merge_year(path, part)
619
+ self._stage(path, lambda p, m=merged: m.to_parquet(p, index=False))
620
+ total_rows += len(merged)
621
+
622
+ # Row count reflects the union across every year touched, so an
623
+ # overlapping re-write does not inflate the manifest.
624
+ m = self.load_manifest()
625
+ prev = m.get_signal(model_slug, model_revision, asset, timeframe)
626
+ untouched = 0
627
+ if prev is not None:
628
+ touched_years = set(frame["ts"].dt.year.unique())
629
+ for y in _years(prev.start_ts, prev.end_ts):
630
+ if y not in touched_years:
631
+ old = self.read_parquet(self.signal_path(model_slug, asset, timeframe, y))
632
+ untouched += 0 if old is None else len(old)
633
+
634
+ entry = CoverageEntry(
635
+ model_slug=model_slug,
636
+ model_id=model_id,
637
+ model_revision=model_revision,
638
+ asset=asset,
639
+ timeframe=timeframe,
640
+ start_ts=_iso(frame["ts"].iloc[0]),
641
+ end_ts=_iso(frame["ts"].iloc[-1]),
642
+ rows=total_rows + untouched,
643
+ inference_version=inference_version,
644
+ last_updated=_now_iso(),
645
+ contributed_by=contributed_by,
646
+ signal_kind=signal_kind,
647
+ )
648
+ m.upsert_signal(entry)
649
+ self.save_manifest(m)
650
+ return entry
651
+
652
+ def write_prices(
653
+ self, asset: str, timeframe: str, df: pd.DataFrame, *, strict: bool = True
654
+ ) -> PriceCoverage:
655
+ """Stage an OHLCV slice and update price coverage."""
656
+ frame, report = validate_price_frame(df, timeframe, strict=strict)
657
+ if frame.empty:
658
+ raise StoreError("refusing to write an empty price frame")
659
+
660
+ total_rows = 0
661
+ for year, part in frame.groupby(frame["ts"].dt.year):
662
+ path = self.price_path(asset, timeframe, int(year))
663
+ merged = self._merge_year(path, part)
664
+ self._stage(path, lambda p, m=merged: m.to_parquet(p, index=False))
665
+ total_rows += len(merged)
666
+
667
+ m = self.load_manifest()
668
+ prev = m.prices.get(price_key(asset, timeframe))
669
+ untouched = 0
670
+ if prev is not None:
671
+ touched_years = set(frame["ts"].dt.year.unique())
672
+ for y in _years(prev.start_ts, prev.end_ts):
673
+ if y not in touched_years:
674
+ old = self.read_parquet(self.price_path(asset, timeframe, y))
675
+ untouched += 0 if old is None else len(old)
676
+
677
+ cov = PriceCoverage(
678
+ asset=asset,
679
+ timeframe=timeframe,
680
+ start_ts=_iso(frame["ts"].iloc[0]),
681
+ end_ts=_iso(frame["ts"].iloc[-1]),
682
+ rows=total_rows + untouched,
683
+ sources=sorted(set(frame["source"].dropna().astype(str))),
684
+ last_updated=_now_iso(),
685
+ provider_max_days=config.TIMEFRAMES[timeframe].yahoo_max_days,
686
+ gaps=report.gaps,
687
+ )
688
+ m.upsert_price(cov)
689
+ self.save_manifest(m)
690
+ return cov
691
+
692
+ def write_json(self, repo_path: str, payload: dict) -> None:
693
+ self._stage(repo_path, lambda p: p.write_text(json.dumps(payload, indent=2) + "\n"))
694
+
695
+ def write_table(self, repo_path: str, df: pd.DataFrame) -> None:
696
+ self._stage(repo_path, lambda p: df.to_parquet(p, index=False))
697
+
698
+ # -- commit -----------------------------------------------------------
699
+
700
+ @property
701
+ def pending(self) -> list[str]:
702
+ with self._lock:
703
+ return sorted(self._pending)
704
+
705
+ def flush(self, message: str = "Update signal store") -> str | None:
706
+ """Push every staged file as ONE atomic commit, manifest operation last.
707
+
708
+ A single commit is strictly stronger than writing the manifest after
709
+ the data files: readers never observe a manifest that references a
710
+ parquet slice that is not yet present.
711
+ """
712
+ with self._lock:
713
+ paths = sorted(self._pending)
714
+ if not paths:
715
+ return None
716
+ if self.offline:
717
+ self._pending.clear()
718
+ return None
719
+
720
+ from huggingface_hub import CommitOperationAdd, HfApi
721
+
722
+ # Manifest last so it is the final operation in the commit.
723
+ ordered = [p for p in paths if p != config.MANIFEST_PATH]
724
+ if config.MANIFEST_PATH in paths:
725
+ ordered.append(config.MANIFEST_PATH)
726
+
727
+ ops = [
728
+ CommitOperationAdd(path_in_repo=p, path_or_fileobj=str(self._local(p)))
729
+ for p in ordered
730
+ ]
731
+ api = HfApi(token=self.token)
732
+ info = api.create_commit(
733
+ repo_id=self.repo_id,
734
+ repo_type=config.STORE_REPO_TYPE,
735
+ revision=self.revision,
736
+ operations=ops,
737
+ commit_message=message,
738
+ )
739
+ self._pending.clear()
740
+ return getattr(info, "oid", None) or str(info)
741
+
742
+ def attach_scheduler(self, every_minutes: float = 5.0):
743
+ """Batch commits in the background while the Space runs.
744
+
745
+ The scheduler watches the same local mirror `flush()` stages into, so
746
+ the two paths never disagree about what is on disk.
747
+ """
748
+ if self.offline or self._scheduler is not None:
749
+ return self._scheduler
750
+ from huggingface_hub import CommitScheduler
751
+
752
+ self._scheduler = CommitScheduler(
753
+ repo_id=self.repo_id,
754
+ repo_type=config.STORE_REPO_TYPE,
755
+ folder_path=str(self.local_root),
756
+ every=every_minutes,
757
+ token=self.token,
758
+ squash_history=False,
759
+ )
760
+ return self._scheduler
src/strategies.py ADDED
@@ -0,0 +1,348 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Strategy presets.
2
+
3
+ Every strategy is a pure function of price history (and, optionally, stored
4
+ model signals) that returns decisions aligned to **bar close**. None of them
5
+ shift their own output -- `engine.run_backtest` does that, exactly once, so
6
+ next-bar-open execution cannot be bypassed by a strategy.
7
+
8
+ Every indicator here is causal: it uses `rolling`/`ewm` over past bars only.
9
+ `tests/test_engine.py` proves this by perturbation rather than trusting it.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass
15
+ from typing import Callable, Protocol
16
+
17
+ import numpy as np
18
+ import pandas as pd
19
+
20
+ from .engine import StrategyOutput
21
+
22
+ # --------------------------------------------------------------------------
23
+ # Sentiment interface (stubbed for v1, real source lands later)
24
+ # --------------------------------------------------------------------------
25
+
26
+
27
+ class SentimentSource(Protocol):
28
+ """Anything that can score sentiment per bar, causally."""
29
+
30
+ def score(self, index: pd.DatetimeIndex, asset: str) -> pd.Series:
31
+ """Value in [-1, 1] per bar, using only information available at that bar."""
32
+ ...
33
+
34
+
35
+ class NeutralSentiment:
36
+ """Default source: no opinion. Keeps the gate open so the momentum leg
37
+ behaves as plain momentum until a real feed is wired in."""
38
+
39
+ name = "neutral-stub"
40
+ is_stub = True
41
+
42
+ def score(self, index: pd.DatetimeIndex, asset: str) -> pd.Series:
43
+ return pd.Series(1.0, index=index, dtype="float64")
44
+
45
+
46
+ class PriceProxySentiment:
47
+ """Deterministic stand-in derived from realised momentum.
48
+
49
+ Clearly labelled as a proxy -- it is *not* news sentiment. It exists so the
50
+ Sentiment-Gated preset is demonstrable end to end before the real feed
51
+ exists, and it is causal by construction.
52
+ """
53
+
54
+ name = "price-proxy-stub"
55
+ is_stub = True
56
+
57
+ def __init__(self, lookback: int = 24):
58
+ self.lookback = lookback
59
+
60
+ def score(self, index: pd.DatetimeIndex, asset: str) -> pd.Series:
61
+ return pd.Series(np.nan, index=index, dtype="float64")
62
+
63
+ def score_from_prices(self, prices: pd.DataFrame) -> pd.Series:
64
+ ret = prices["close"].pct_change(self.lookback)
65
+ scaled = np.tanh(ret / (ret.rolling(self.lookback * 4).std().replace(0, np.nan) + 1e-12))
66
+ return scaled.fillna(0.0).clip(-1.0, 1.0)
67
+
68
+
69
+ # --------------------------------------------------------------------------
70
+ # Indicator helpers (all causal)
71
+ # --------------------------------------------------------------------------
72
+
73
+
74
+ def sma(s: pd.Series, n: int) -> pd.Series:
75
+ return s.rolling(int(n), min_periods=int(n)).mean()
76
+
77
+
78
+ def ema(s: pd.Series, n: int) -> pd.Series:
79
+ return s.ewm(span=int(n), adjust=False, min_periods=int(n)).mean()
80
+
81
+
82
+ def rsi(s: pd.Series, n: int = 14) -> pd.Series:
83
+ delta = s.diff()
84
+ gain = delta.clip(lower=0.0)
85
+ loss = -delta.clip(upper=0.0)
86
+ avg_gain = gain.ewm(alpha=1 / int(n), adjust=False, min_periods=int(n)).mean()
87
+ avg_loss = loss.ewm(alpha=1 / int(n), adjust=False, min_periods=int(n)).mean()
88
+ rs = avg_gain / avg_loss.replace(0.0, np.nan)
89
+ return (100.0 - 100.0 / (1.0 + rs)).fillna(50.0)
90
+
91
+
92
+ def bollinger(s: pd.Series, n: int = 20, k: float = 2.0):
93
+ mid = s.rolling(int(n), min_periods=int(n)).mean()
94
+ sd = s.rolling(int(n), min_periods=int(n)).std(ddof=0)
95
+ return mid - k * sd, mid, mid + k * sd
96
+
97
+
98
+ def macd(s: pd.Series, fast: int = 12, slow: int = 26, signal: int = 9):
99
+ line = ema(s, fast) - ema(s, slow)
100
+ sig = line.ewm(span=int(signal), adjust=False, min_periods=int(signal)).mean()
101
+ return line, sig, line - sig
102
+
103
+
104
+ def _cross_up(a: pd.Series, b: pd.Series) -> pd.Series:
105
+ return ((a > b) & (a.shift(1) <= b.shift(1))).astype("boolean").fillna(False).astype(bool)
106
+
107
+
108
+ def _cross_down(a: pd.Series, b: pd.Series) -> pd.Series:
109
+ return ((a < b) & (a.shift(1) >= b.shift(1))).astype("boolean").fillna(False).astype(bool)
110
+
111
+
112
+ def _triggers(index, entries, exits, entry_text: str, exit_text: str) -> pd.Series:
113
+ t = pd.Series("", index=index, dtype="object")
114
+ t[entries] = entry_text
115
+ t[exits] = exit_text
116
+ return t
117
+
118
+
119
+ # --------------------------------------------------------------------------
120
+ # Presets
121
+ # --------------------------------------------------------------------------
122
+
123
+
124
+ def buy_and_hold(prices: pd.DataFrame, params: dict | None = None,
125
+ signals: pd.DataFrame | None = None) -> StrategyOutput:
126
+ """Enter on the first bar, never exit. The benchmark every claim is measured against."""
127
+ idx = prices.index
128
+ entries = pd.Series(False, index=idx)
129
+ exits = pd.Series(False, index=idx)
130
+ if len(idx):
131
+ entries.iloc[0] = True
132
+ return StrategyOutput(entries=entries, exits=exits,
133
+ triggers=_triggers(idx, entries, exits, "buy and hold entry", ""))
134
+
135
+
136
+ def sma_crossover(prices: pd.DataFrame, params: dict | None = None,
137
+ signals: pd.DataFrame | None = None) -> StrategyOutput:
138
+ p = params or {}
139
+ fast_n, slow_n = int(p.get("fast_ma", 20)), int(p.get("slow_ma", 50))
140
+ close = prices["close"]
141
+ fast, slow = sma(close, fast_n), sma(close, slow_n)
142
+ entries = _cross_up(fast, slow)
143
+ exits = _cross_down(fast, slow)
144
+ return StrategyOutput(
145
+ entries=entries, exits=exits,
146
+ triggers=_triggers(prices.index, entries, exits,
147
+ f"SMA{fast_n} crossed above SMA{slow_n}",
148
+ f"SMA{fast_n} crossed below SMA{slow_n}"),
149
+ )
150
+
151
+
152
+ def rsi_mean_reversion(prices: pd.DataFrame, params: dict | None = None,
153
+ signals: pd.DataFrame | None = None) -> StrategyOutput:
154
+ p = params or {}
155
+ n = int(p.get("rsi_period", 14))
156
+ lo, hi = float(p.get("oversold", 30)), float(p.get("overbought", 70))
157
+ r = rsi(prices["close"], n)
158
+ entries = ((r < lo) & (r.shift(1) >= lo)).astype("boolean").fillna(False).astype(bool)
159
+ exits = ((r > hi) & (r.shift(1) <= hi)).astype("boolean").fillna(False).astype(bool)
160
+ return StrategyOutput(
161
+ entries=entries, exits=exits,
162
+ triggers=_triggers(prices.index, entries, exits,
163
+ f"RSI({n}) fell below {lo:g}", f"RSI({n}) rose above {hi:g}"),
164
+ )
165
+
166
+
167
+ def bollinger_breakout(prices: pd.DataFrame, params: dict | None = None,
168
+ signals: pd.DataFrame | None = None) -> StrategyOutput:
169
+ p = params or {}
170
+ n, k = int(p.get("bb_period", 20)), float(p.get("bb_std", 2.0))
171
+ close = prices["close"]
172
+ lower, mid, upper = bollinger(close, n, k)
173
+ entries = ((close > upper) & (close.shift(1) <= upper.shift(1))).astype("boolean").fillna(False).astype(bool)
174
+ exits = ((close < mid) & (close.shift(1) >= mid.shift(1))).astype("boolean").fillna(False).astype(bool)
175
+ return StrategyOutput(
176
+ entries=entries, exits=exits,
177
+ triggers=_triggers(prices.index, entries, exits,
178
+ f"close broke above the {n}/{k:g}σ upper band",
179
+ "close fell back through the band midline"),
180
+ )
181
+
182
+
183
+ def macd_momentum(prices: pd.DataFrame, params: dict | None = None,
184
+ signals: pd.DataFrame | None = None) -> StrategyOutput:
185
+ p = params or {}
186
+ f, s, g = int(p.get("macd_fast", 12)), int(p.get("macd_slow", 26)), int(p.get("macd_signal", 9))
187
+ line, sig, _ = macd(prices["close"], f, s, g)
188
+ entries = _cross_up(line, sig)
189
+ exits = _cross_down(line, sig)
190
+ return StrategyOutput(
191
+ entries=entries, exits=exits,
192
+ triggers=_triggers(prices.index, entries, exits,
193
+ f"MACD({f},{s}) crossed above its {g}-period signal",
194
+ f"MACD({f},{s}) crossed below its {g}-period signal"),
195
+ )
196
+
197
+
198
+ def forecast_follower(prices: pd.DataFrame, params: dict | None = None,
199
+ signals: pd.DataFrame | None = None) -> StrategyOutput:
200
+ """Rule over stored quantiles: go long when the median forecast implies
201
+ enough upside; optionally exit when price breaches the q10 floor.
202
+
203
+ The stored forecast at bar `t` was produced from data up to `t`, and the
204
+ engine shifts it before acting, so the earliest possible fill is `t+1`'s open.
205
+ """
206
+ p = params or {}
207
+ threshold = float(p.get("threshold", 0.005))
208
+ use_q10_stop = bool(p.get("use_q10_stop", True))
209
+ exit_threshold = float(p.get("exit_threshold", 0.0))
210
+
211
+ idx = prices.index
212
+ close = prices["close"]
213
+ if signals is None or signals.empty or "q50" not in signals.columns:
214
+ false = pd.Series(False, index=idx)
215
+ return StrategyOutput(entries=false, exits=false.copy(),
216
+ triggers=pd.Series("", index=idx, dtype="object"))
217
+
218
+ q50 = signals["q50"].reindex(idx).ffill()
219
+ q10 = signals["q10"].reindex(idx).ffill() if "q10" in signals.columns else None
220
+
221
+ edge = (q50 / close) - 1.0
222
+ entries = ((edge > threshold) & (edge.shift(1) <= threshold)).astype("boolean").fillna(False).astype(bool)
223
+ exits = ((edge < exit_threshold) & (edge.shift(1) >= exit_threshold)).astype("boolean").fillna(False).astype(bool)
224
+ if use_q10_stop and q10 is not None:
225
+ breach = (close < q10).astype("boolean").fillna(False).astype(bool)
226
+ prev_breach = breach.astype("boolean").shift(1).fillna(False).astype(bool)
227
+ exits = (exits | (breach & ~prev_breach)).astype("boolean").fillna(False).astype(bool)
228
+
229
+ trig = pd.Series("", index=idx, dtype="object")
230
+ trig[entries] = f"forecast median implied >{threshold:.2%} upside"
231
+ trig[exits] = "forecast edge closed or price breached the q10 floor"
232
+ return StrategyOutput(entries=entries, exits=exits, triggers=trig)
233
+
234
+
235
+ def sentiment_gated_momentum(prices: pd.DataFrame, params: dict | None = None,
236
+ signals: pd.DataFrame | None = None,
237
+ sentiment: SentimentSource | None = None) -> StrategyOutput:
238
+ """Momentum that only fires while the sentiment gate is open.
239
+
240
+ The sentiment input sits behind `SentimentSource`. Until a real feed is
241
+ wired in, the default is a labelled stub -- see `NeutralSentiment`.
242
+ """
243
+ p = params or {}
244
+ fast_n, slow_n = int(p.get("fast_ma", 20)), int(p.get("slow_ma", 50))
245
+ gate = float(p.get("sentiment_gate", 0.40))
246
+ trail = p.get("trail_pct")
247
+
248
+ close = prices["close"]
249
+ fast, slow = sma(close, fast_n), sma(close, slow_n)
250
+
251
+ src = sentiment or PriceProxySentiment()
252
+ if hasattr(src, "score_from_prices"):
253
+ score = src.score_from_prices(prices)
254
+ else:
255
+ score = src.score(prices.index, "")
256
+ score = score.reindex(prices.index).fillna(0.0)
257
+
258
+ gate_open = score >= gate
259
+ entries = (_cross_up(fast, slow) & gate_open).astype("boolean").fillna(False).astype(bool)
260
+ was_open = gate_open.astype("boolean").shift(1).fillna(False).astype(bool)
261
+ exits = (_cross_down(fast, slow) | (~gate_open & was_open)) \
262
+ .astype("boolean").fillna(False).astype(bool)
263
+
264
+ trig = pd.Series("", index=prices.index, dtype="object")
265
+ trig[entries] = f"MA cross up with sentiment ≥ {gate:.2f}"
266
+ trig[exits] = "MA cross down or sentiment gate closed"
267
+ if trail:
268
+ trig[entries] = trig[entries] + f" (trailing stop {float(trail):.1%})"
269
+ return StrategyOutput(entries=entries, exits=exits, triggers=trig)
270
+
271
+
272
+ # --------------------------------------------------------------------------
273
+ # Registry
274
+ # --------------------------------------------------------------------------
275
+
276
+
277
+ @dataclass(frozen=True)
278
+ class Preset:
279
+ name: str
280
+ fn: Callable
281
+ needs_signals: bool = False
282
+ available: bool = True
283
+ unavailable_reason: str = ""
284
+ params: tuple[tuple[str, str, float, float, float], ...] = ()
285
+ # (key, label, default, min, max)
286
+
287
+
288
+ PRESETS: dict[str, Preset] = {
289
+ p.name: p
290
+ for p in [
291
+ Preset("Buy & Hold (benchmark)", buy_and_hold),
292
+ Preset("SMA Crossover", sma_crossover, params=(
293
+ ("fast_ma", "Fast MA", 20, 2, 200),
294
+ ("slow_ma", "Slow MA", 50, 3, 400),
295
+ )),
296
+ Preset("RSI Mean Reversion", rsi_mean_reversion, params=(
297
+ ("rsi_period", "RSI period", 14, 2, 100),
298
+ ("oversold", "Oversold", 30, 1, 49),
299
+ ("overbought", "Overbought", 70, 51, 99),
300
+ )),
301
+ Preset("Bollinger Breakout", bollinger_breakout, params=(
302
+ ("bb_period", "Period", 20, 5, 200),
303
+ ("bb_std", "Std devs", 2.0, 0.5, 5.0),
304
+ )),
305
+ Preset("MACD Momentum", macd_momentum, params=(
306
+ ("macd_fast", "Fast EMA", 12, 2, 100),
307
+ ("macd_slow", "Slow EMA", 26, 3, 200),
308
+ ("macd_signal", "Signal", 9, 2, 50),
309
+ )),
310
+ Preset("Chronos Forecast Follower", forecast_follower, needs_signals=True, params=(
311
+ ("threshold", "Entry edge", 0.005, 0.0, 0.2),
312
+ ("exit_threshold", "Exit edge", 0.0, -0.1, 0.1),
313
+ )),
314
+ Preset("Sentiment-Gated Momentum", sentiment_gated_momentum, params=(
315
+ ("fast_ma", "Fast MA", 20, 2, 200),
316
+ ("slow_ma", "Slow MA", 50, 3, 400),
317
+ ("sentiment_gate", "Sentiment gate", 0.40, -1.0, 1.0),
318
+ )),
319
+ # Present in the design; not runnable in v1.
320
+ Preset("Pairs Trading", buy_and_hold, available=False,
321
+ unavailable_reason="Needs a second leg; single-asset runs only in v1."),
322
+ Preset("Custom (code)", buy_and_hold, available=False,
323
+ unavailable_reason="Running user-supplied strategy code is disabled by "
324
+ "design — this Space never executes untrusted code."),
325
+ ]
326
+ }
327
+
328
+ PRESET_NAMES = list(PRESETS)
329
+
330
+
331
+ def build(name: str, prices: pd.DataFrame, params: dict | None = None,
332
+ signals: pd.DataFrame | None = None) -> StrategyOutput:
333
+ """Run a preset by name. Unknown or unavailable presets raise."""
334
+ preset = PRESETS.get(name)
335
+ if preset is None:
336
+ raise KeyError(f"unknown strategy preset {name!r}")
337
+ if not preset.available:
338
+ raise ValueError(f"{name} is not available: {preset.unavailable_reason}")
339
+ if preset.needs_signals and (signals is None or signals.empty):
340
+ raise ValueError(f"{name} needs stored model signals for this asset and timeframe")
341
+ return preset.fn(prices, params or {}, signals)
342
+
343
+
344
+ def defaults_for(name: str) -> dict:
345
+ preset = PRESETS.get(name)
346
+ if preset is None:
347
+ return {}
348
+ return {k: d for k, _, d, _, _ in preset.params}
src/ui/__init__.py ADDED
File without changes
src/ui/theme.py ADDED
@@ -0,0 +1,282 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Gradio theme and CSS built from the Bit design system tokens.
2
+
3
+ `assets/tokens/*.css` is vendored verbatim from the design system and imported
4
+ here rather than retyped, so token values cannot drift. This module adds only
5
+ the app-shell layout the design specifies (three zones, panels, chips, stat
6
+ band) on top of those tokens.
7
+
8
+ Fonts are served as static files. If they fail to load the page degrades to the
9
+ system sans / mono stack rather than breaking.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from pathlib import Path
15
+
16
+ import gradio as gr
17
+
18
+ ASSETS = Path(__file__).resolve().parent.parent.parent / "assets"
19
+ FONT_DIR = ASSETS / "fonts"
20
+
21
+
22
+ def _font_face_css() -> str:
23
+ """@font-face rules pointing at Gradio's static file route."""
24
+ faces = [
25
+ ("Styrene A", "StyreneA-Light.otf", 300, "normal"),
26
+ ("Styrene A", "StyreneA-Regular.otf", 400, "normal"),
27
+ ("Styrene A", "StyreneA-Medium.otf", 500, "normal"),
28
+ ("Mac Minecraft", "MacMinecraft.ttf", 400, "normal"),
29
+ ]
30
+ out = []
31
+ for family, filename, weight, style in faces:
32
+ path = FONT_DIR / filename
33
+ if not path.exists():
34
+ continue
35
+ fmt = "opentype" if filename.endswith(".otf") else "truetype"
36
+ out.append(
37
+ f"@font-face{{font-family:'{family}';"
38
+ f"src:url('/gradio_api/file={path}') format('{fmt}');"
39
+ f"font-weight:{weight};font-style:{style};font-display:swap}}"
40
+ )
41
+ return "\n".join(out)
42
+
43
+
44
+ def _tokens_css() -> str:
45
+ """Inline the vendored token files so there is one source of truth."""
46
+ parts = []
47
+ for name in ("colors.css", "typography.css", "spacing.css"):
48
+ p = ASSETS / "tokens" / name
49
+ if p.exists():
50
+ parts.append(p.read_text())
51
+ return "\n".join(parts)
52
+
53
+
54
+ SHELL_CSS = """
55
+ /* ---------- app shell ---------- */
56
+ .gradio-container{
57
+ max-width:100% !important; padding:0 !important;
58
+ background:var(--bg-canvas) !important;
59
+ font-family:var(--font-body); font-weight:var(--weight-body);
60
+ color:var(--text-primary);
61
+ }
62
+ .gradio-container *{ border-radius:var(--radius-sm) !important; }
63
+ footer{ display:none !important; }
64
+
65
+ /* ---------- typography ---------- */
66
+ .bit-h1,.bit-h2,.bit-h3{
67
+ font-family:var(--font-heading); font-weight:var(--weight-heading);
68
+ letter-spacing:var(--tracking-wide); text-transform:uppercase;
69
+ color:var(--text-primary); margin:0;
70
+ }
71
+ .bit-h1{ font-size:var(--text-lg); }
72
+ .bit-h2{ font-size:var(--text-md); }
73
+ .bit-h3{ font-size:var(--text-base); }
74
+ .bit-micro{
75
+ font-family:var(--font-tiny); font-size:var(--text-xs);
76
+ letter-spacing:var(--tracking-wider); text-transform:uppercase;
77
+ color:var(--text-tertiary);
78
+ }
79
+ .bit-mono{ font-family:var(--font-mono); font-size:var(--text-base); }
80
+
81
+ /* ---------- panels ---------- */
82
+ .bit-panel{
83
+ background:var(--bg-panel); border:var(--border-width) solid var(--border-default);
84
+ padding:var(--space-4); margin-bottom:var(--space-3);
85
+ }
86
+ .bit-panel-head{
87
+ display:flex; align-items:center; gap:var(--space-3);
88
+ border-bottom:var(--border-width) solid var(--border-subtle);
89
+ padding-bottom:var(--space-2); margin-bottom:var(--space-3);
90
+ }
91
+
92
+ /* ---------- top bar ---------- */
93
+ .bit-topbar{
94
+ display:flex; align-items:center; gap:var(--space-4);
95
+ background:var(--bg-panel); border-bottom:var(--border-width) solid var(--border-default);
96
+ padding:var(--space-3) var(--space-4); position:sticky; top:0; z-index:var(--z-header);
97
+ }
98
+ .bit-mark{ width:22px; height:22px; background:var(--accent-amber); display:inline-block; }
99
+ .bit-chip{
100
+ display:inline-flex; align-items:center; gap:6px;
101
+ font-family:var(--font-mono); font-size:var(--text-sm);
102
+ border:var(--border-width) solid var(--border-default);
103
+ padding:3px var(--space-2); color:var(--text-secondary);
104
+ }
105
+ .bit-chip-ok{ color:var(--accent-moss-strong); border-color:var(--accent-moss-dim); }
106
+ .bit-chip-run{ color:var(--accent-amber-strong); border-color:var(--accent-amber-dim); }
107
+ .bit-chip-warn{ color:var(--fin-down); border-color:var(--fin-down); }
108
+
109
+ /* ---------- stat band ---------- */
110
+ .bit-statband{ display:flex; flex-wrap:wrap; border:var(--border-width) solid var(--border-default); }
111
+ .bit-stat{
112
+ flex:1 1 150px; padding:var(--space-3) var(--space-4);
113
+ border-right:var(--border-width) solid var(--border-subtle);
114
+ }
115
+ .bit-stat:last-child{ border-right:none; }
116
+ .bit-stat-label{
117
+ font-family:var(--font-tiny); font-size:var(--text-xs);
118
+ letter-spacing:var(--tracking-wider); text-transform:uppercase; color:var(--text-tertiary);
119
+ }
120
+ .bit-stat-value{
121
+ font-family:var(--font-mono); font-size:var(--text-xl);
122
+ line-height:var(--leading-tight); color:var(--text-primary); margin:2px 0;
123
+ }
124
+ .bit-stat-sub{ font-family:var(--font-mono); font-size:var(--text-xs); color:var(--text-tertiary); }
125
+ .bit-up{ color:var(--fin-up-strong); } .bit-down{ color:var(--fin-down-strong); }
126
+
127
+ /* ---------- warnings / notes ---------- */
128
+ .bit-note{
129
+ font-family:var(--font-mono); font-size:var(--text-sm);
130
+ border-left:var(--border-width-strong) solid var(--accent-amber);
131
+ background:var(--bg-raised); padding:var(--space-2) var(--space-3);
132
+ color:var(--text-secondary);
133
+ }
134
+ .bit-note-danger{ border-left-color:var(--fin-down); }
135
+
136
+ /* ---------- footer disclaimer (always visible) ---------- */
137
+ .bit-footer{
138
+ position:sticky; bottom:0; z-index:var(--z-header);
139
+ display:flex; justify-content:space-between; gap:var(--space-4);
140
+ background:var(--bg-panel); border-top:var(--border-width) solid var(--border-default);
141
+ padding:var(--space-2) var(--space-4);
142
+ font-family:var(--font-mono); font-size:var(--text-sm); color:var(--text-tertiary);
143
+ }
144
+
145
+ /* ---------- gradio overrides ---------- */
146
+ .gradio-container .tabs > .tab-nav{
147
+ border-bottom:var(--border-width) solid var(--border-default) !important;
148
+ background:transparent !important; gap:0 !important;
149
+ }
150
+ .gradio-container .tabs > .tab-nav > button{
151
+ font-family:var(--font-heading) !important; text-transform:uppercase;
152
+ letter-spacing:var(--tracking-wide); font-size:var(--text-base) !important;
153
+ color:var(--text-tertiary) !important; background:transparent !important;
154
+ border:none !important; border-bottom:2px solid transparent !important;
155
+ padding:var(--space-2) var(--space-4) !important;
156
+ }
157
+ .gradio-container .tabs > .tab-nav > button.selected{
158
+ color:var(--text-primary) !important;
159
+ border-bottom-color:var(--accent-amber) !important;
160
+ }
161
+ .gradio-container .form, .gradio-container .block{
162
+ background:transparent !important; border:none !important;
163
+ }
164
+ /* Field captions only -- scoped so it never swallows radio/checkbox option text. */
165
+ .block > label > span, .block > .form > label > span{
166
+ font-family:var(--font-tiny) !important; font-size:var(--text-xs) !important;
167
+ letter-spacing:var(--tracking-wider) !important; text-transform:uppercase;
168
+ color:var(--text-tertiary) !important;
169
+ }
170
+ input, select, textarea{
171
+ font-family:var(--font-mono) !important; font-size:var(--text-base) !important;
172
+ background:var(--bg-sunken) !important; color:var(--text-primary) !important;
173
+ border:var(--border-width) solid var(--border-default) !important;
174
+ }
175
+
176
+ /* Radio / checkbox options read as segmented chips, per the design. */
177
+ .gradio-container fieldset label,
178
+ .gradio-container .wrap label:has(input[type="radio"]),
179
+ .gradio-container .wrap label:has(input[type="checkbox"]){
180
+ background:transparent !important;
181
+ border:var(--border-width) solid var(--border-default) !important;
182
+ color:var(--text-secondary) !important;
183
+ padding:4px 10px !important; margin:2px !important;
184
+ }
185
+ .gradio-container fieldset label span,
186
+ .gradio-container .wrap label:has(input[type="radio"]) span,
187
+ .gradio-container .wrap label:has(input[type="checkbox"]) span{
188
+ font-family:var(--font-mono) !important; font-size:var(--text-base) !important;
189
+ letter-spacing:var(--tracking-normal) !important; text-transform:none !important;
190
+ color:var(--text-secondary) !important; opacity:1 !important;
191
+ }
192
+ .gradio-container fieldset label.selected,
193
+ .gradio-container fieldset label:has(input:checked),
194
+ .gradio-container .wrap label:has(input[type="radio"]:checked){
195
+ background:var(--accent-amber) !important;
196
+ border-color:var(--accent-amber) !important;
197
+ }
198
+ .gradio-container fieldset label.selected span,
199
+ .gradio-container fieldset label:has(input:checked) span,
200
+ .gradio-container .wrap label:has(input[type="radio"]:checked) span{
201
+ color:var(--stone-950) !important; font-weight:500 !important;
202
+ }
203
+ .gradio-container input[type="radio"], .gradio-container input[type="checkbox"]{
204
+ accent-color:var(--accent-amber);
205
+ }
206
+ .bit-run-btn{
207
+ background:var(--accent-amber) !important; color:var(--stone-950) !important;
208
+ font-family:var(--font-heading) !important; text-transform:uppercase;
209
+ letter-spacing:var(--tracking-wide); border:none !important; font-weight:500 !important;
210
+ }
211
+ .bit-run-btn:hover{ background:var(--accent-amber-strong) !important; }
212
+ .bit-ghost-btn{
213
+ background:transparent !important; color:var(--text-secondary) !important;
214
+ border:var(--border-width) solid var(--border-default) !important;
215
+ font-family:var(--font-mono) !important; font-size:var(--text-sm) !important;
216
+ }
217
+ .bit-accordion{ border:var(--border-width) solid var(--border-subtle) !important; }
218
+
219
+ /* ---------- tables ---------- */
220
+ .bit-table table{ font-family:var(--font-mono) !important; font-size:var(--text-sm) !important; }
221
+ .bit-table thead th{
222
+ font-family:var(--font-tiny) !important; font-size:var(--text-xs) !important;
223
+ letter-spacing:var(--tracking-wider); text-transform:uppercase;
224
+ color:var(--text-tertiary) !important; background:var(--bg-raised) !important;
225
+ }
226
+
227
+ /* ---------- empty state ---------- */
228
+ .bit-empty{
229
+ display:flex; flex-direction:column; align-items:center; justify-content:center;
230
+ gap:var(--space-3); padding:var(--space-9) var(--space-4); text-align:center;
231
+ border:var(--border-width) dashed var(--border-default); background:var(--bg-panel);
232
+ }
233
+ .bit-kbd{
234
+ font-family:var(--font-mono); font-size:var(--text-xs); color:var(--text-tertiary);
235
+ border:var(--border-width) solid var(--border-default); padding:2px 6px;
236
+ }
237
+ @media (max-width: 900px){
238
+ .bit-statband{ flex-direction:column; }
239
+ .bit-stat{ border-right:none; border-bottom:var(--border-width) solid var(--border-subtle); }
240
+ }
241
+ """
242
+
243
+
244
+ def full_css() -> str:
245
+ return "\n".join([_font_face_css(), _tokens_css(), SHELL_CSS])
246
+
247
+
248
+ def bit_theme() -> gr.Theme:
249
+ """Gradio theme aligned to the token palette.
250
+
251
+ The CSS above does the detailed work; this makes Gradio's own generated
252
+ chrome start from the right colors instead of fighting it everywhere.
253
+ """
254
+ return gr.themes.Base(
255
+ primary_hue=gr.themes.colors.yellow,
256
+ secondary_hue=gr.themes.colors.lime,
257
+ neutral_hue=gr.themes.colors.stone,
258
+ font=[gr.themes.GoogleFont("Inter"), "system-ui", "sans-serif"],
259
+ font_mono=[gr.themes.GoogleFont("JetBrains Mono"), "ui-monospace", "monospace"],
260
+ ).set(
261
+ body_background_fill="#161512",
262
+ body_text_color="#f7f4ec",
263
+ background_fill_primary="#1d1c18",
264
+ background_fill_secondary="#24221d",
265
+ block_background_fill="#1d1c18",
266
+ block_border_color="#3d3a32",
267
+ block_label_text_color="#6f6a56",
268
+ border_color_primary="#3d3a32",
269
+ button_primary_background_fill="#af9209",
270
+ button_primary_text_color="#161512",
271
+ button_secondary_background_fill="transparent",
272
+ button_secondary_text_color="#b6b09a",
273
+ input_background_fill="#000000",
274
+ block_radius="4px",
275
+ button_large_radius="4px",
276
+ button_small_radius="4px",
277
+ )
278
+
279
+
280
+ def static_paths() -> list[str]:
281
+ """Directories Gradio may serve (fonts, mark)."""
282
+ return [str(ASSETS)]
tests/__init__.py ADDED
File without changes
tests/run_all.sh ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Full test suite. Exits non-zero if anything fails.
3
+ #
4
+ # bash tests/run_all.sh # everything except the real-model test
5
+ # bash tests/run_all.sh --slow # including the 100-step Chronos smoke test
6
+ set -euo pipefail
7
+ cd "$(dirname "$0")/.."
8
+
9
+ PY="${PYTHON:-python}"
10
+ [ -x "../.venv/bin/python" ] && PY="../.venv/bin/python"
11
+
12
+ MARK=(-m "not slow")
13
+ if [ "${1:-}" = "--slow" ]; then MARK=(); fi
14
+
15
+ echo "== Backtest Lab test suite =="
16
+ # The ${arr[@]+"${arr[@]}"} form keeps an empty array safe under `set -u`
17
+ # on the bash 3.2 that ships with macOS.
18
+ "$PY" -m pytest tests/ -q ${MARK[@]+"${MARK[@]}"} -W "error::FutureWarning"
19
+
20
+ echo
21
+ echo "== Known-answer tests (Phase 1 gate) =="
22
+ "$PY" -m pytest tests/test_engine.py -q -k "ka1 or ka2 or ka3 or ka4 or ka5 or ka6"
23
+
24
+ echo
25
+ echo "All green."
tests/test_adapters.py ADDED
@@ -0,0 +1,345 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Phase 2 acceptance: adapter contract, checkpoint resume, calibration maths.
2
+
3
+ The real-model smoke test is marked `slow` and skipped unless `chronos` is
4
+ importable, so the default suite stays fast and offline.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ import numpy as np
14
+ import pandas as pd
15
+ import pytest
16
+
17
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
18
+
19
+ from src import config
20
+ from src.adapters import (
21
+ AdapterError,
22
+ Forecast,
23
+ ModelNotAllowed,
24
+ PlaceholderAdapter,
25
+ build_windows,
26
+ get_adapter,
27
+ validate_model_id,
28
+ )
29
+ from src.metrics import calibration_coverage, calibration_error, directional_accuracy
30
+ from src.store import SignalStore, validate_signal_frame
31
+ from scripts.seed_store import Checkpoint, SeedTarget, plan_v1, seed_target
32
+
33
+
34
+ def series(n=600, seed=0, start="2023-01-01"):
35
+ rng = np.random.default_rng(seed)
36
+ return pd.Series(
37
+ 100 * np.exp(np.cumsum(rng.normal(0.0005, 0.02, n))),
38
+ index=pd.date_range(start, periods=n, freq="D", tz="UTC"),
39
+ )
40
+
41
+
42
+ def price_frame(n=600, seed=0):
43
+ close = series(n, seed)
44
+ open_ = close.shift(1).fillna(close.iloc[0] * 0.999)
45
+ return pd.DataFrame({
46
+ "open": open_, "high": pd.concat([open_, close], axis=1).max(axis=1) * 1.004,
47
+ "low": pd.concat([open_, close], axis=1).min(axis=1) * 0.996,
48
+ "close": close, "volume": 1000.0, "source": "synthetic",
49
+ })
50
+
51
+
52
+ # --------------------------------------------------------------------------
53
+ # Allow-list: no arbitrary code execution
54
+ # --------------------------------------------------------------------------
55
+
56
+
57
+ def test_unknown_adapter_family_is_refused():
58
+ with pytest.raises(ModelNotAllowed, match="not allowed"):
59
+ get_adapter("evil-custom", "someone/backdoor")
60
+
61
+
62
+ def test_allowed_families_are_exactly_the_configured_set():
63
+ assert set(config.ALLOWED_ADAPTER_FAMILIES) == {"chronos", "timesfm"}
64
+
65
+
66
+ @pytest.mark.parametrize("bad", [
67
+ "", "no-slash", "a/b/c", "../../etc/passwd", "owner/../name",
68
+ "owner/name;rm -rf /", "owner/na me", "owner/$(whoami)",
69
+ ])
70
+ def test_malformed_model_ids_are_rejected(bad):
71
+ with pytest.raises(AdapterError):
72
+ validate_model_id(bad)
73
+
74
+
75
+ @pytest.mark.parametrize("good", [
76
+ "amazon/chronos-bolt-small", "google/timesfm-2.0-500m-pytorch", "org/model_v1.2",
77
+ ])
78
+ def test_well_formed_model_ids_pass(good):
79
+ assert validate_model_id(good) == good
80
+
81
+
82
+ # --------------------------------------------------------------------------
83
+ # Windowing is causal
84
+ # --------------------------------------------------------------------------
85
+
86
+
87
+ def test_window_ends_at_its_own_timestamp():
88
+ s = series(n=100)
89
+ stamps, wins = build_windows(s, context_len=30)
90
+ assert wins.shape == (len(stamps), 30)
91
+ # The window stored at `t` must end with the value observed at `t`.
92
+ for i, ts in enumerate(list(stamps)[:5]):
93
+ assert wins[i, -1] == pytest.approx(float(s.loc[ts]))
94
+
95
+
96
+ def test_windows_never_include_future_values():
97
+ s = series(n=200)
98
+ stamps, wins = build_windows(s, context_len=50)
99
+ pos = {ts: i for i, ts in enumerate(s.index)}
100
+ for i, ts in enumerate(list(stamps)[:10]):
101
+ expected = s.to_numpy()[pos[ts] - 49: pos[ts] + 1]
102
+ assert np.allclose(wins[i], expected)
103
+
104
+
105
+ def test_too_short_series_yields_no_windows():
106
+ stamps, wins = build_windows(series(n=20), context_len=50)
107
+ assert len(stamps) == 0 and wins.shape[0] == 0
108
+
109
+
110
+ # --------------------------------------------------------------------------
111
+ # Placeholder adapter
112
+ # --------------------------------------------------------------------------
113
+
114
+
115
+ def test_placeholder_is_labelled_and_deterministic():
116
+ a = PlaceholderAdapter("synthetic/placeholder").load()
117
+ assert a.inference_version() == config.PLACEHOLDER_VERSION
118
+
119
+ _, wins = build_windows(series(n=300), 100)
120
+ first, second = a.predict(wins[:20]), a.predict(wins[:20])
121
+ assert np.allclose(first.q50, second.q50)
122
+ assert np.allclose(first.q10, second.q10)
123
+
124
+
125
+ def test_placeholder_output_is_schema_valid():
126
+ a = PlaceholderAdapter("synthetic/placeholder").load()
127
+ stamps, wins = build_windows(series(n=300), 100)
128
+ df = a.predict(wins[:50]).as_frame(stamps[:50], a.inference_version())
129
+ out = validate_signal_frame(df)
130
+ assert len(out) == 50
131
+ assert (out["inference_version"] == config.PLACEHOLDER_VERSION).all()
132
+ assert ((out["q10"] <= out["q50"]) & (out["q50"] <= out["q90"])).all()
133
+
134
+
135
+ def test_forecast_frame_sorts_crossed_quantiles():
136
+ f = Forecast(q10=np.array([5.0]), q50=np.array([1.0]), q90=np.array([3.0]),
137
+ context_len=10)
138
+ df = f.as_frame(pd.DatetimeIndex(["2024-01-01"], tz="UTC"), "v1")
139
+ assert df["q10"].iloc[0] <= df["q50"].iloc[0] <= df["q90"].iloc[0]
140
+
141
+
142
+ def test_forecast_rejects_mismatched_timestamp_count():
143
+ f = Forecast(q10=np.zeros(3), q50=np.zeros(3), q90=np.zeros(3), context_len=10)
144
+ with pytest.raises(AdapterError, match="!="):
145
+ f.as_frame(pd.DatetimeIndex(["2024-01-01"], tz="UTC"), "v1")
146
+
147
+
148
+ def test_inference_version_pins_model_and_revision():
149
+ a = get_adapter("chronos", "amazon/chronos-bolt-small", revision="abc123")
150
+ a._resolved_revision = "abc123"
151
+ b = get_adapter("chronos", "amazon/chronos-bolt-small", revision="def456")
152
+ b._resolved_revision = "def456"
153
+ assert a.inference_version() != b.inference_version()
154
+ assert a.inference_version() == a.inference_version()
155
+
156
+
157
+ # --------------------------------------------------------------------------
158
+ # Checkpoint resume
159
+ # --------------------------------------------------------------------------
160
+
161
+
162
+ def test_checkpoint_round_trips(tmp_path):
163
+ p = tmp_path / "ckpt.json"
164
+ c = Checkpoint.load(p)
165
+ c.mark("m|BTC-USD|1d", pd.Timestamp("2024-06-01", tz="UTC"))
166
+ again = Checkpoint.load(p)
167
+ assert again.last_ts("m|BTC-USD|1d") == pd.Timestamp("2024-06-01", tz="UTC")
168
+
169
+
170
+ def test_corrupt_checkpoint_starts_fresh_instead_of_crashing(tmp_path):
171
+ p = tmp_path / "ckpt.json"
172
+ p.write_text("{not json")
173
+ assert Checkpoint.load(p).done == {}
174
+
175
+
176
+ def test_checkpoint_records_failures_separately(tmp_path):
177
+ c = Checkpoint.load(tmp_path / "c.json")
178
+ c.mark_failed("m|ETH-USD|1h", "boom")
179
+ assert Checkpoint.load(tmp_path / "c.json").failed["m|ETH-USD|1h"] == "boom"
180
+
181
+
182
+ def test_seed_resumes_from_checkpoint_and_skips_finished_work(tmp_path, monkeypatch):
183
+ """An interrupted seed must not redo inference it already paid for."""
184
+ store = SignalStore(repo_id=None, local_root=tmp_path / "store", offline=True)
185
+ px = price_frame(n=400)
186
+ store.write_prices("BTC-USD", "1d", px.reset_index(names="ts"))
187
+
188
+ monkeypatch.setitem(config.SEED_MODELS, "test-model", config.ModelSpec(
189
+ slug="test-model", model_id="test/model", family="placeholder",
190
+ display="Test", context_len=100,
191
+ ))
192
+ target = SeedTarget("test-model", "BTC-USD", "1d", 3.0, placeholder=True)
193
+ ckpt = Checkpoint.load(tmp_path / "ckpt.json")
194
+
195
+ msg = seed_target(store, target, ckpt, batch_size=64, force_placeholder=True)
196
+ assert msg.startswith("OK")
197
+ first_rows = len(store.get_signals("test-model", "BTC-USD", "1d"))
198
+ assert first_rows > 0
199
+ assert ckpt.last_ts(target.key) is not None
200
+
201
+ # Second call: the checkpoint says complete, so nothing more is computed.
202
+ msg2 = seed_target(store, target, ckpt, batch_size=64, force_placeholder=True)
203
+ assert "SKIP" in msg2
204
+ assert len(store.get_signals("test-model", "BTC-USD", "1d")) == first_rows
205
+
206
+
207
+ def test_seed_skips_targets_already_in_the_manifest(tmp_path, monkeypatch):
208
+ store = SignalStore(repo_id=None, local_root=tmp_path / "store", offline=True)
209
+ store.write_prices("BTC-USD", "1d", price_frame(n=400).reset_index(names="ts"))
210
+ monkeypatch.setitem(config.SEED_MODELS, "test-model", config.ModelSpec(
211
+ slug="test-model", model_id="test/model", family="placeholder",
212
+ display="Test", context_len=100,
213
+ ))
214
+ target = SeedTarget("test-model", "BTC-USD", "1d", 3.0, placeholder=True)
215
+
216
+ seed_target(store, target, Checkpoint.load(tmp_path / "a.json"),
217
+ batch_size=64, force_placeholder=True)
218
+ # Fresh checkpoint, but the manifest already covers the range.
219
+ msg = seed_target(store, target, Checkpoint.load(tmp_path / "b.json"),
220
+ batch_size=64, force_placeholder=True)
221
+ assert "already covered" in msg
222
+
223
+
224
+ def test_seed_skips_when_there_is_no_price_coverage(tmp_path, monkeypatch):
225
+ store = SignalStore(repo_id=None, local_root=tmp_path / "store", offline=True)
226
+ monkeypatch.setitem(config.SEED_MODELS, "test-model", config.ModelSpec(
227
+ slug="test-model", model_id="test/model", family="placeholder",
228
+ display="Test", context_len=100,
229
+ ))
230
+ msg = seed_target(store, SeedTarget("test-model", "ETH-USD", "1d", 1.0),
231
+ Checkpoint.load(tmp_path / "c.json"), force_placeholder=True)
232
+ assert "no price coverage" in msg
233
+
234
+
235
+ def test_v1_plan_covers_the_specified_universe():
236
+ targets = plan_v1()
237
+ assets = {t.asset for t in targets}
238
+ assert {"BTC-USD", "ETH-USD", "SOL-USD"} <= assets
239
+ assert {"SPY", "QQQ", "NVDA"} <= assets
240
+ assert {t.timeframe for t in targets} == {"1d", "1h", "15m"}
241
+ # The v1 seed is entirely real: batched inference was cheap enough that no
242
+ # slice needs a synthetic placeholder.
243
+ assert not any(t.placeholder for t in targets)
244
+ assert len({t.model_slug for t in targets}) >= 2
245
+
246
+
247
+ # --------------------------------------------------------------------------
248
+ # Calibration maths against a synthetic series of KNOWN coverage
249
+ # --------------------------------------------------------------------------
250
+
251
+
252
+ def test_calibration_recovers_a_known_80_percent_coverage():
253
+ """Construct a series where exactly 80% of actuals sit inside the band."""
254
+ n = 1000
255
+ idx = pd.DatetimeIndex(pd.date_range("2024-01-01", periods=n, freq="D", tz="UTC"))
256
+ lower = pd.Series(90.0, index=idx)
257
+ upper = pd.Series(110.0, index=idx)
258
+ actual = pd.Series(100.0, index=idx) # inside
259
+ actual.iloc[:200] = 500.0 # 20% outside, by construction
260
+
261
+ cov = calibration_coverage(actual, lower, upper)
262
+ assert cov == pytest.approx(0.80, abs=1e-12)
263
+ assert calibration_error(cov, 0.80) == pytest.approx(0.0, abs=1e-12)
264
+
265
+
266
+ @pytest.mark.parametrize("frac", [0.0, 0.25, 0.5, 0.9, 1.0])
267
+ def test_calibration_recovers_any_known_coverage(frac):
268
+ n = 200
269
+ idx = pd.date_range("2024-01-01", periods=n, freq="D", tz="UTC")
270
+ lower, upper = pd.Series(0.0, index=idx), pd.Series(1.0, index=idx)
271
+ actual = pd.Series(5.0, index=idx)
272
+ inside = int(round(n * frac))
273
+ actual.iloc[:inside] = 0.5
274
+ assert calibration_coverage(actual, lower, upper) == pytest.approx(frac, abs=1e-12)
275
+
276
+
277
+ def test_overconfident_band_reads_as_undercoverage():
278
+ n = 500
279
+ idx = pd.date_range("2024-01-01", periods=n, freq="D", tz="UTC")
280
+ rng = np.random.default_rng(3)
281
+ actual = pd.Series(rng.normal(0, 1, n), index=idx)
282
+ # A band far narrower than the true spread must score well under 0.80.
283
+ lower, upper = pd.Series(-0.05, index=idx), pd.Series(0.05, index=idx)
284
+ cov = calibration_coverage(actual, lower, upper)
285
+ assert cov < 0.20
286
+ assert calibration_error(cov, 0.80) < -0.5
287
+
288
+
289
+ def test_calibration_of_a_correctly_specified_normal_band():
290
+ """A true 10th/90th percentile band on normal data covers ~80%."""
291
+ n = 20_000
292
+ idx = pd.date_range("2000-01-01", periods=n, freq="D", tz="UTC")
293
+ rng = np.random.default_rng(11)
294
+ actual = pd.Series(rng.normal(0, 1, n), index=idx)
295
+ lower = pd.Series(-1.2815515655446004, index=idx)
296
+ upper = pd.Series(1.2815515655446004, index=idx)
297
+ assert calibration_coverage(actual, lower, upper) == pytest.approx(0.80, abs=0.02)
298
+
299
+
300
+ def test_directional_accuracy_is_perfect_for_a_perfect_forecast():
301
+ idx = pd.date_range("2024-01-01", periods=100, freq="D", tz="UTC")
302
+ ref = pd.Series(np.linspace(100, 200, 100), index=idx)
303
+ actual_next = ref.shift(-1).ffill()
304
+ assert directional_accuracy(actual_next, actual_next, ref) == pytest.approx(1.0)
305
+
306
+
307
+ def test_directional_accuracy_is_zero_for_a_perfectly_wrong_forecast():
308
+ idx = pd.date_range("2024-01-01", periods=100, freq="D", tz="UTC")
309
+ ref = pd.Series(np.linspace(100, 200, 100), index=idx)
310
+ actual_next = ref.shift(-1).ffill()
311
+ inverted = ref - (actual_next - ref)
312
+ assert directional_accuracy(actual_next, inverted, ref) == pytest.approx(0.0)
313
+
314
+
315
+ # --------------------------------------------------------------------------
316
+ # Real-model smoke test
317
+ # --------------------------------------------------------------------------
318
+
319
+
320
+ def _chronos_available() -> bool:
321
+ try:
322
+ import chronos # noqa: F401
323
+ return True
324
+ except Exception:
325
+ return False
326
+
327
+
328
+ @pytest.mark.slow
329
+ @pytest.mark.skipif(not _chronos_available(), reason="chronos-forecasting not installed")
330
+ def test_chronos_100_step_run_is_schema_valid():
331
+ """Phase 2 acceptance: 100 steps on the real model, output schema-valid."""
332
+ a = get_adapter("chronos", "amazon/chronos-bolt-small", context_len=256).load()
333
+ assert a.resolved_revision not in ("", "unpinned")
334
+
335
+ stamps, wins = build_windows(series(n=600), 256)
336
+ stamps, wins = stamps[:100], wins[:100]
337
+ df = a.predict(wins).as_frame(stamps, a.inference_version())
338
+
339
+ out = validate_signal_frame(df)
340
+ assert len(out) == 100
341
+ assert ((out["q10"] <= out["q50"]) & (out["q50"] <= out["q90"])).all()
342
+ assert (out["context_len"] == 256).all()
343
+ assert out["inference_version"].nunique() == 1
344
+ assert out["inference_version"].iloc[0] != config.PLACEHOLDER_VERSION
345
+ assert np.isfinite(out[["q10", "q50", "q90"]].to_numpy()).all()
tests/test_data.py ADDED
@@ -0,0 +1,270 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Phase 0 acceptance: provider chain, network gate, incremental refresh.
2
+
3
+ No test here touches the network; provider fetchers are substituted.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import time
9
+
10
+ import pandas as pd
11
+ import pytest
12
+
13
+ from src import config, data
14
+ from src.data import (
15
+ NetworkNotAllowed,
16
+ ProviderError,
17
+ RateLimiter,
18
+ allow_network,
19
+ fetch_ohlcv,
20
+ missing_price_ranges,
21
+ refresh,
22
+ )
23
+ from src.store import SignalStore
24
+
25
+
26
+ @pytest.fixture
27
+ def store(tmp_path) -> SignalStore:
28
+ return SignalStore(repo_id=None, local_root=tmp_path / "store", offline=True)
29
+
30
+
31
+ def ohlcv(n=30, start="2024-01-01", freq="D", source="fake", base=100.0) -> pd.DataFrame:
32
+ ts = pd.date_range(start, periods=n, freq=freq, tz="UTC")
33
+ close = pd.Series([base + i for i in range(n)], dtype="float64")
34
+ return pd.DataFrame({
35
+ "ts": ts, "open": close * 0.99, "high": close * 1.02,
36
+ "low": close * 0.98, "close": close, "volume": 1000.0, "source": source,
37
+ })
38
+
39
+
40
+ @pytest.fixture
41
+ def fake_chain(monkeypatch):
42
+ """Replace every provider fetcher with a recorder we control."""
43
+ calls: list[str] = []
44
+
45
+ def make(name, *, rows=30, fail=False, start="2024-01-01"):
46
+ def _f(spec, asset, tf, s, e):
47
+ calls.append(name)
48
+ if fail:
49
+ raise ProviderError(f"{name} is down")
50
+ return ohlcv(n=rows, start=start, source=name)
51
+ return _f
52
+
53
+ monkeypatch.setattr(data, "_FETCHERS", {}, raising=False)
54
+ return calls, make
55
+
56
+
57
+ # --------------------------------------------------------------------------
58
+ # Network gate — user-facing paths must never reach a provider
59
+ # --------------------------------------------------------------------------
60
+
61
+
62
+ def test_fetch_is_blocked_outside_the_refresh_path():
63
+ with pytest.raises(NetworkNotAllowed, match="cached store"):
64
+ fetch_ohlcv("BTC-USD", "1d", "2024-01-01", "2024-02-01")
65
+
66
+
67
+ def test_gate_closes_again_after_the_context_exits():
68
+ assert not data.network_allowed()
69
+ with allow_network():
70
+ assert data.network_allowed()
71
+ assert not data.network_allowed()
72
+
73
+
74
+ def test_gate_is_thread_local():
75
+ import threading
76
+
77
+ seen = {}
78
+
79
+ def worker():
80
+ seen["other"] = data.network_allowed()
81
+
82
+ with allow_network():
83
+ t = threading.Thread(target=worker)
84
+ t.start()
85
+ t.join()
86
+ assert seen["other"] is False
87
+
88
+
89
+ # --------------------------------------------------------------------------
90
+ # Chain walk / fallback
91
+ # --------------------------------------------------------------------------
92
+
93
+
94
+ def test_primary_provider_wins(fake_chain, monkeypatch):
95
+ calls, make = fake_chain
96
+ monkeypatch.setitem(data._FETCHERS, "binance", make("binance"))
97
+ monkeypatch.setitem(data._FETCHERS, "coinbase", make("coinbase"))
98
+ with allow_network():
99
+ res = fetch_ohlcv("BTC-USD", "1d", "2024-01-01", "2024-02-01")
100
+ assert res.source == "binance"
101
+ assert calls == ["binance"]
102
+
103
+
104
+ def test_falls_back_when_primary_fails(fake_chain, monkeypatch):
105
+ calls, make = fake_chain
106
+ monkeypatch.setitem(data._FETCHERS, "binance", make("binance", fail=True))
107
+ monkeypatch.setitem(data._FETCHERS, "coinbase", make("coinbase"))
108
+ with allow_network():
109
+ res = fetch_ohlcv("BTC-USD", "1d", "2024-01-01", "2024-02-01")
110
+ assert res.source == "coinbase"
111
+ assert calls == ["binance", "coinbase"]
112
+
113
+
114
+ def test_exhausted_chain_raises_with_every_reason(fake_chain, monkeypatch):
115
+ _, make = fake_chain
116
+ monkeypatch.setitem(data._FETCHERS, "binance", make("binance", fail=True))
117
+ monkeypatch.setitem(data._FETCHERS, "coinbase", make("coinbase", fail=True))
118
+ with allow_network():
119
+ with pytest.raises(ProviderError) as exc:
120
+ fetch_ohlcv("BTC-USD", "1d", "2024-01-01", "2024-02-01")
121
+ assert "binance is down" in str(exc.value)
122
+ assert "coinbase is down" in str(exc.value)
123
+
124
+
125
+ def test_equity_and_crypto_use_different_chains():
126
+ crypto = [p.name for p in config.providers_for("crypto")]
127
+ equity = [p.name for p in config.providers_for("equity")]
128
+ assert crypto[:2] == ["binance", "coinbase"]
129
+ assert equity[:2] == ["yfinance", "stooq"]
130
+ assert not set(crypto) & set(equity)
131
+
132
+
133
+ def test_tiingo_only_joins_the_chain_when_its_key_is_present(monkeypatch):
134
+ monkeypatch.delenv("TIINGO_KEY", raising=False)
135
+ assert "tiingo" not in [p.name for p in config.providers_for("equity")]
136
+ monkeypatch.setenv("TIINGO_KEY", "x")
137
+ assert "tiingo" in [p.name for p in config.providers_for("equity")]
138
+
139
+
140
+ def test_unknown_asset_is_rejected():
141
+ with allow_network():
142
+ with pytest.raises(ProviderError, match="unknown asset"):
143
+ fetch_ohlcv("DOGE-USD", "1d", "2024-01-01", "2024-02-01")
144
+
145
+
146
+ # --------------------------------------------------------------------------
147
+ # Rate limiting + backoff
148
+ # --------------------------------------------------------------------------
149
+
150
+
151
+ def test_rate_limiter_spaces_calls():
152
+ name = "unit-test-limiter"
153
+ RateLimiter.wait(name, 0.0)
154
+ t0 = time.monotonic()
155
+ RateLimiter.wait(name, 0.15)
156
+ assert time.monotonic() - t0 >= 0.14
157
+
158
+
159
+ def test_backoff_retries_then_succeeds(monkeypatch):
160
+ monkeypatch.setattr(time, "sleep", lambda s: None)
161
+ spec = config.ProviderSpec("retry-test", ("crypto",), min_interval_s=0.0,
162
+ max_retries=4, backoff_base_s=1.0)
163
+ attempts = {"n": 0}
164
+
165
+ def flaky():
166
+ attempts["n"] += 1
167
+ if attempts["n"] < 3:
168
+ raise RuntimeError("transient")
169
+ return "ok"
170
+
171
+ assert data.with_backoff(spec, flaky) == "ok"
172
+ assert attempts["n"] == 3
173
+
174
+
175
+ def test_backoff_gives_up_and_reports(monkeypatch):
176
+ monkeypatch.setattr(time, "sleep", lambda s: None)
177
+ spec = config.ProviderSpec("giveup-test", ("crypto",), min_interval_s=0.0,
178
+ max_retries=3, backoff_base_s=1.0)
179
+
180
+ def always_fails():
181
+ raise RuntimeError("nope")
182
+
183
+ with pytest.raises(ProviderError, match="exhausted retries"):
184
+ data.with_backoff(spec, always_fails)
185
+
186
+
187
+ # --------------------------------------------------------------------------
188
+ # Incremental refresh — only fetch what is missing
189
+ # --------------------------------------------------------------------------
190
+
191
+
192
+ def test_missing_ranges_on_empty_cache(store):
193
+ gaps = missing_price_ranges(store, "BTC-USD", "1d", "2024-01-01", "2024-03-01")
194
+ assert len(gaps) == 1
195
+
196
+
197
+ def test_missing_ranges_fully_cached(store):
198
+ store.write_prices("BTC-USD", "1d", ohlcv(n=60, start="2024-01-01"))
199
+ assert missing_price_ranges(store, "BTC-USD", "1d",
200
+ "2024-01-10", "2024-02-10") == []
201
+
202
+
203
+ def test_missing_ranges_finds_both_edges(store):
204
+ store.write_prices("BTC-USD", "1d", ohlcv(n=30, start="2024-02-01"))
205
+ gaps = missing_price_ranges(store, "BTC-USD", "1d", "2024-01-01", "2024-04-01")
206
+ assert len(gaps) == 2
207
+ assert gaps[0][0] < pd.Timestamp("2024-02-01", tz="UTC")
208
+ assert gaps[1][1] > pd.Timestamp("2024-03-01", tz="UTC")
209
+
210
+
211
+ def test_refresh_skips_a_fully_cached_range(store, fake_chain, monkeypatch):
212
+ calls, make = fake_chain
213
+ monkeypatch.setitem(data._FETCHERS, "binance", make("binance", rows=60))
214
+ store.write_prices("BTC-USD", "1d", ohlcv(n=60, start="2024-01-01"))
215
+
216
+ rep = refresh(store, "BTC-USD", "1d", "2024-01-10", "2024-02-10")
217
+ assert rep.skipped_cached and rep.rows_added == 0
218
+ assert calls == [] # no provider was contacted
219
+
220
+
221
+ def test_refresh_fetches_and_writes(store, fake_chain, monkeypatch):
222
+ calls, make = fake_chain
223
+ monkeypatch.setitem(data._FETCHERS, "binance", make("binance", rows=30))
224
+ rep = refresh(store, "BTC-USD", "1d", "2024-01-01", "2024-01-30")
225
+ assert rep.ok and rep.rows_added == 30
226
+ assert rep.sources == ["binance"]
227
+ assert len(store.get_prices("BTC-USD", "1d")) == 30
228
+
229
+
230
+ def test_refresh_reports_failure_rather_than_returning_partial_data(
231
+ store, fake_chain, monkeypatch
232
+ ):
233
+ _, make = fake_chain
234
+ monkeypatch.setitem(data._FETCHERS, "binance", make("binance", fail=True))
235
+ monkeypatch.setitem(data._FETCHERS, "coinbase", make("coinbase", fail=True))
236
+ rep = refresh(store, "BTC-USD", "1d", "2024-01-01", "2024-01-30")
237
+ assert not rep.ok
238
+ assert rep.rows_added == 0
239
+ assert "FAILED" in rep.summary()
240
+
241
+
242
+ def test_refresh_surfaces_dirty_data_instead_of_writing_it(
243
+ store, fake_chain, monkeypatch
244
+ ):
245
+ def bad(spec, asset, tf, s, e):
246
+ df = ohlcv(n=30, source="binance")
247
+ df.loc[5, "close"] = -1.0
248
+ return df
249
+
250
+ monkeypatch.setitem(data._FETCHERS, "binance", bad)
251
+ monkeypatch.setitem(data._FETCHERS, "coinbase", bad)
252
+ rep = refresh(store, "BTC-USD", "1d", "2024-01-01", "2024-01-30")
253
+ assert not rep.ok
254
+ assert any("non-positive" in e for e in rep.errors)
255
+ assert store.get_prices("BTC-USD", "1d").empty
256
+
257
+
258
+ def test_refresh_records_source_per_row(store, fake_chain, monkeypatch):
259
+ _, make = fake_chain
260
+ monkeypatch.setitem(data._FETCHERS, "binance", make("binance", rows=20))
261
+ refresh(store, "BTC-USD", "1d", "2024-01-01", "2024-01-20")
262
+ got = store.get_prices("BTC-USD", "1d")
263
+ assert set(got["source"].unique()) == {"binance"}
264
+
265
+
266
+ def test_provider_depth_limit_is_recorded_as_a_boundary_not_an_error(store):
267
+ """Yahoo serves ~730d of 1h bars; that is coverage truth, not a failure."""
268
+ store.write_prices("SPY", "1h", ohlcv(n=48, start="2025-01-01", freq="h"))
269
+ cov = store.load_manifest().prices["SPY|1h"]
270
+ assert cov.provider_max_days == config.TIMEFRAMES["1h"].yahoo_max_days == 730
tests/test_engine.py ADDED
@@ -0,0 +1,506 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Phase 1 acceptance: the six known-answer tests, plus engine invariants.
2
+
3
+ The numbered tests map one-to-one onto the build spec's list. They are the gate
4
+ for the whole engine; if any of them fails, nothing downstream is trustworthy.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import numpy as np
10
+ import pandas as pd
11
+ import pytest
12
+
13
+ from src import strategies
14
+ from src.engine import (
15
+ BacktestConfig,
16
+ Costs,
17
+ EngineError,
18
+ LookaheadError,
19
+ Sizing,
20
+ Stops,
21
+ StrategyOutput,
22
+ Validation,
23
+ assert_causal,
24
+ build_validation_plan,
25
+ run_backtest,
26
+ )
27
+
28
+ BARS_PER_YEAR = 365.0
29
+
30
+
31
+ # --------------------------------------------------------------------------
32
+ # Deterministic synthetic price series
33
+ # --------------------------------------------------------------------------
34
+
35
+
36
+ def prices(n=400, seed=7, start="2022-01-01", freq="D", trend=0.0004, vol=0.02):
37
+ rng = np.random.default_rng(seed)
38
+ steps = rng.normal(trend, vol, n)
39
+ close = 100.0 * np.exp(np.cumsum(steps))
40
+ idx = pd.date_range(start, periods=n, freq=freq, tz="UTC")
41
+ close = pd.Series(close, index=idx)
42
+ open_ = close.shift(1).fillna(close.iloc[0] * 0.999)
43
+ high = pd.concat([open_, close], axis=1).max(axis=1) * 1.004
44
+ low = pd.concat([open_, close], axis=1).min(axis=1) * 0.996
45
+ return pd.DataFrame({
46
+ "open": open_, "high": high, "low": low, "close": close,
47
+ "volume": pd.Series(rng.uniform(800, 1200, n), index=idx),
48
+ "source": "synthetic",
49
+ })
50
+
51
+
52
+ def linear_prices(n=100, start="2022-01-01"):
53
+ """Monotone series -- makes hand-computed expectations trivial."""
54
+ idx = pd.date_range(start, periods=n, freq="D", tz="UTC")
55
+ close = pd.Series(np.linspace(100.0, 200.0, n), index=idx)
56
+ open_ = close * 0.995
57
+ return pd.DataFrame({
58
+ "open": open_, "high": close * 1.01, "low": open_ * 0.99,
59
+ "close": close, "volume": 1000.0, "source": "synthetic",
60
+ })
61
+
62
+
63
+ def no_costs() -> Costs:
64
+ return Costs(enabled=False)
65
+
66
+
67
+ def cfg(**kw) -> BacktestConfig:
68
+ base = dict(
69
+ asset="BTC-USD", timeframe="1d", strategy="Buy & Hold (benchmark)",
70
+ costs=no_costs(), validation=Validation(mode="none"), init_cash=100_000.0,
71
+ )
72
+ base.update(kw)
73
+ return BacktestConfig(**base)
74
+
75
+
76
+ # ==========================================================================
77
+ # KNOWN-ANSWER TEST 1
78
+ # Buy & Hold with zero costs reproduces the asset's return over the period.
79
+ # ==========================================================================
80
+
81
+
82
+ @pytest.mark.parametrize("px", [linear_prices(), prices(n=300, seed=3)])
83
+ def test_ka1_buy_and_hold_zero_costs_matches_asset_return(px):
84
+ out = strategies.buy_and_hold(px)
85
+ res = run_backtest(px, out, cfg(), bars_per_year=BARS_PER_YEAR)
86
+
87
+ # Execution is next-bar-open, so the position is opened at the second bar's
88
+ # open. That fill price is the honest basis for "the asset's return".
89
+ expected = float(px["close"].iloc[-1] / px["open"].iloc[1] - 1.0)
90
+ assert res.metrics_all.total_return == pytest.approx(expected, abs=1e-9)
91
+ assert len(res.trades) == 1
92
+ assert res.trades["costs"].sum() == pytest.approx(0.0, abs=1e-12)
93
+
94
+
95
+ def test_ka1_entry_fill_is_the_second_bars_open_not_the_first():
96
+ px = linear_prices()
97
+ res = run_backtest(px, strategies.buy_and_hold(px), cfg(), bars_per_year=BARS_PER_YEAR)
98
+ entry_px = float(res.trades["entry_px"].iloc[0])
99
+ assert entry_px == pytest.approx(float(px["open"].iloc[1]), abs=1e-9)
100
+ assert entry_px != pytest.approx(float(px["open"].iloc[0]), abs=1e-9)
101
+ assert res.trades["entry_ts"].iloc[0] == px.index[1]
102
+
103
+
104
+ # ==========================================================================
105
+ # KNOWN-ANSWER TEST 2
106
+ # A deliberately lookahead-biased strategy is caught structurally.
107
+ # ==========================================================================
108
+
109
+
110
+ def peeking_strategy(px: pd.DataFrame) -> StrategyOutput:
111
+ """Cheats: decides at bar t using bar t+1's close."""
112
+ future = px["close"].shift(-1)
113
+ entries = (future > px["close"]).fillna(False)
114
+ exits = (future <= px["close"]).fillna(False)
115
+ return StrategyOutput(entries=entries, exits=exits)
116
+
117
+
118
+ def subtle_peeking_strategy(px: pd.DataFrame) -> StrategyOutput:
119
+ """Cheats less obviously: a centred rolling mean leaks the future."""
120
+ centred = px["close"].rolling(11, center=True, min_periods=1).mean()
121
+ entries = (px["close"] > centred).fillna(False)
122
+ exits = (px["close"] <= centred).fillna(False)
123
+ return StrategyOutput(entries=entries, exits=exits)
124
+
125
+
126
+ def test_ka2_obvious_lookahead_is_caught():
127
+ px = prices(n=300, seed=11)
128
+ with pytest.raises(LookaheadError, match="after the bar it acts on"):
129
+ assert_causal(peeking_strategy, px)
130
+
131
+
132
+ def test_ka2_subtle_lookahead_is_caught():
133
+ px = prices(n=300, seed=12)
134
+ with pytest.raises(LookaheadError):
135
+ assert_causal(subtle_peeking_strategy, px)
136
+
137
+
138
+ def test_ka2_global_normalisation_lookahead_is_caught():
139
+ """Scaling by the full-sample max leaks the future into every early bar."""
140
+ def global_scaled(px):
141
+ z = px["close"] / px["close"].max()
142
+ entries = (z > 0.8).fillna(False)
143
+ return StrategyOutput(entries=entries, exits=(~entries).fillna(False))
144
+
145
+ with pytest.raises(LookaheadError):
146
+ assert_causal(global_scaled, prices(n=300, seed=13))
147
+
148
+
149
+ @pytest.mark.parametrize("name", [
150
+ "Buy & Hold (benchmark)", "SMA Crossover", "RSI Mean Reversion",
151
+ "Bollinger Breakout", "MACD Momentum", "Sentiment-Gated Momentum",
152
+ ])
153
+ def test_ka2_every_shipped_preset_is_causal(name):
154
+ px = prices(n=400, seed=5)
155
+ assert_causal(lambda p: strategies.build(name, p, strategies.defaults_for(name)), px)
156
+
157
+
158
+ def test_ka2_forecast_follower_is_causal_against_stored_signals():
159
+ px = prices(n=300, seed=17)
160
+ sig = pd.DataFrame({
161
+ "q10": px["close"] * 0.97, "q50": px["close"] * 1.01, "q90": px["close"] * 1.05,
162
+ }, index=px.index)
163
+ # Signals are pinned, so only the price path is perturbed -- exactly the
164
+ # situation the store creates when a cached forecast slice is replayed.
165
+ assert_causal(lambda p: strategies.forecast_follower(p, {"threshold": 0.005}, sig), px)
166
+
167
+
168
+ def test_ka2_engine_refuses_same_bar_fills():
169
+ px = linear_prices()
170
+ with pytest.raises(EngineError, match="non-negotiable"):
171
+ run_backtest(px, strategies.buy_and_hold(px), cfg(fill="same_bar_close"),
172
+ bars_per_year=BARS_PER_YEAR)
173
+
174
+
175
+ def test_ka2_decisions_cannot_be_shifted_twice():
176
+ from src.engine import _shift_decisions
177
+
178
+ px = linear_prices()
179
+ once = _shift_decisions(strategies.buy_and_hold(px))
180
+ with pytest.raises(EngineError, match="already shifted"):
181
+ _shift_decisions(once)
182
+
183
+
184
+ # ==========================================================================
185
+ # KNOWN-ANSWER TEST 3
186
+ # Zero-cost vs costed runs differ by exactly the modeled costs on the trades.
187
+ # ==========================================================================
188
+
189
+
190
+ def alternating(px: pd.DataFrame, hold=7, gap=5) -> StrategyOutput:
191
+ """Deterministic in-and-out signal, independent of price."""
192
+ entries = pd.Series(False, index=px.index)
193
+ exits = pd.Series(False, index=px.index)
194
+ i = 10
195
+ while i + hold < len(px) - 2:
196
+ entries.iloc[i] = True
197
+ exits.iloc[i + hold] = True
198
+ i += hold + gap
199
+ return StrategyOutput(entries=entries, exits=exits)
200
+
201
+
202
+ def test_ka3_cost_difference_equals_sum_of_trade_costs():
203
+ px = prices(n=300, seed=21)
204
+ out = alternating(px)
205
+
206
+ # Fixed unit sizing keeps costs additive: with percent sizing, fees change
207
+ # position size and the difference compounds instead of summing.
208
+ sizing = Sizing(mode="fixed_units", units=1.0)
209
+ free = run_backtest(px, alternating(px),
210
+ cfg(costs=no_costs(), sizing=sizing), bars_per_year=BARS_PER_YEAR)
211
+ paid = run_backtest(px, out,
212
+ cfg(costs=Costs(enabled=True, commission_bps=10.0,
213
+ slippage_bps=5.0, slippage_model="fixed"),
214
+ sizing=sizing), bars_per_year=BARS_PER_YEAR)
215
+
216
+ assert len(free.trades) == len(paid.trades) > 3
217
+
218
+ pnl_gap = float(free.trades["net_pnl"].sum() - paid.trades["net_pnl"].sum())
219
+ modeled = float(paid.trades["costs"].sum())
220
+ assert pnl_gap == pytest.approx(modeled, rel=1e-9, abs=1e-6)
221
+
222
+
223
+ def test_ka3_gross_minus_costs_equals_net_on_every_trade():
224
+ px = prices(n=300, seed=22)
225
+ res = run_backtest(px, alternating(px),
226
+ cfg(costs=Costs(enabled=True, commission_bps=8.0, slippage_bps=4.0),
227
+ sizing=Sizing(mode="fixed_units", units=1.0)),
228
+ bars_per_year=BARS_PER_YEAR)
229
+ residual = res.trades["gross_pnl"] - res.trades["costs"] - res.trades["net_pnl"]
230
+ assert np.allclose(residual.to_numpy(), 0.0, atol=1e-9)
231
+
232
+
233
+ def test_ka3_costs_are_on_by_default():
234
+ c = Costs()
235
+ assert c.enabled
236
+ assert c.commission_rate > 0 and c.slippage_rate > 0
237
+ assert BacktestConfig().costs.enabled
238
+
239
+
240
+ def test_ka3_more_slippage_never_helps():
241
+ px = prices(n=300, seed=23)
242
+ sizing = Sizing(mode="fixed_units", units=1.0)
243
+ returns = []
244
+ for bps in (0.0, 5.0, 20.0):
245
+ r = run_backtest(px, alternating(px),
246
+ cfg(costs=Costs(enabled=True, commission_bps=0.0, slippage_bps=bps),
247
+ sizing=sizing), bars_per_year=BARS_PER_YEAR)
248
+ returns.append(float(r.trades["net_pnl"].sum()))
249
+ assert returns[0] > returns[1] > returns[2]
250
+
251
+
252
+ # ==========================================================================
253
+ # KNOWN-ANSWER TEST 4
254
+ # A strategy with no signals produces flat equity and zero trades.
255
+ # ==========================================================================
256
+
257
+
258
+ def test_ka4_no_signals_gives_flat_equity_and_no_trades():
259
+ px = prices(n=200, seed=31)
260
+ silent = StrategyOutput(
261
+ entries=pd.Series(False, index=px.index),
262
+ exits=pd.Series(False, index=px.index),
263
+ )
264
+ res = run_backtest(px, silent, cfg(), bars_per_year=BARS_PER_YEAR)
265
+
266
+ assert len(res.trades) == 0
267
+ assert res.metrics_all.trade_count == 0
268
+ assert res.equity.nunique() == 1
269
+ assert float(res.equity.iloc[-1]) == pytest.approx(100_000.0, abs=1e-9)
270
+ assert res.metrics_all.total_return == pytest.approx(0.0, abs=1e-12)
271
+ assert res.metrics_all.max_drawdown == pytest.approx(0.0, abs=1e-12)
272
+ assert res.metrics_all.sharpe == 0.0
273
+ assert res.metrics_all.win_rate == 0.0
274
+ assert res.metrics_all.profit_factor == 0.0
275
+ assert res.costs_paid == 0.0
276
+
277
+
278
+ def test_ka4_flat_run_with_costs_still_pays_nothing():
279
+ px = prices(n=200, seed=32)
280
+ silent = StrategyOutput(entries=pd.Series(False, index=px.index),
281
+ exits=pd.Series(False, index=px.index))
282
+ res = run_backtest(px, silent, cfg(costs=Costs(enabled=True)), bars_per_year=BARS_PER_YEAR)
283
+ assert res.costs_paid == 0.0
284
+ assert float(res.equity.iloc[-1]) == pytest.approx(100_000.0, abs=1e-9)
285
+
286
+
287
+ # ==========================================================================
288
+ # KNOWN-ANSWER TEST 5
289
+ # Walk-forward boundaries never overlap; the holdout is never selectable.
290
+ # ==========================================================================
291
+
292
+
293
+ def test_ka5_walk_forward_windows_never_overlap_train_and_test():
294
+ idx = pd.date_range("2021-01-01", "2026-01-01", freq="D", tz="UTC")
295
+ plan = build_validation_plan(idx, Validation(
296
+ mode="walk_forward", train_months=12, test_months=3, roll_months=3,
297
+ ))
298
+ assert len(plan.windows) >= 4
299
+ for w in plan.windows:
300
+ assert w.train_start < w.train_end
301
+ assert w.test_start > w.train_end, f"window {w.idx} overlaps"
302
+ assert w.test_start <= w.test_end
303
+
304
+
305
+ def test_ka5_consecutive_test_windows_do_not_overlap_each_other():
306
+ idx = pd.date_range("2021-01-01", "2026-01-01", freq="D", tz="UTC")
307
+ plan = build_validation_plan(idx, Validation(
308
+ mode="walk_forward", train_months=12, test_months=3, roll_months=3,
309
+ ))
310
+ for a, b in zip(plan.windows, plan.windows[1:]):
311
+ assert b.test_start > a.test_end
312
+
313
+
314
+ def test_ka5_holdout_is_excluded_from_the_parameter_selection_index():
315
+ idx = pd.date_range("2021-01-01", "2026-01-01", freq="D", tz="UTC")
316
+ plan = build_validation_plan(idx, Validation(mode="holdout", holdout_months=6))
317
+ assert plan.holdout_start is not None
318
+
319
+ selectable = plan.selectable_index()
320
+ holdout = idx[idx >= plan.holdout_start]
321
+
322
+ assert len(holdout) > 0
323
+ assert len(set(selectable) & set(holdout)) == 0
324
+ assert selectable.max() < holdout.min()
325
+
326
+
327
+ def test_ka5_walk_forward_windows_never_reach_into_the_holdout():
328
+ idx = pd.date_range("2021-01-01", "2026-01-01", freq="D", tz="UTC")
329
+ v = Validation(mode="walk_forward", train_months=12, test_months=3,
330
+ roll_months=3, holdout_months=6)
331
+ plan = build_validation_plan(idx, v)
332
+ assert plan.holdout_start is not None
333
+ assert len(plan.windows) > 0
334
+ for w in plan.windows:
335
+ assert w.test_end < plan.holdout_start
336
+ assert w.train_end < plan.holdout_start
337
+
338
+
339
+ def test_ka5_holdout_bars_are_labelled_holdout_not_oos():
340
+ px = prices(n=900, seed=41, start="2022-01-01")
341
+ res = run_backtest(px, strategies.buy_and_hold(px),
342
+ cfg(validation=Validation(mode="holdout", holdout_months=6)),
343
+ bars_per_year=BARS_PER_YEAR)
344
+ assert res.metrics_holdout is not None
345
+ assert res.metrics_holdout.bars > 0
346
+ assert res.plan.segment_of(px.index[-1]) == "holdout"
347
+ assert res.plan.segment_of(px.index[0]) == "IS"
348
+
349
+
350
+ def test_ka5_split_mode_partitions_every_bar_exactly_once():
351
+ """With no holdout reserved, IS and OOS must tile the whole period."""
352
+ px = prices(n=400, seed=42)
353
+ res = run_backtest(px, strategies.buy_and_hold(px),
354
+ cfg(validation=Validation(mode="split", split_frac=0.7,
355
+ holdout_months=0)),
356
+ bars_per_year=BARS_PER_YEAR)
357
+ assert res.metrics_holdout is None
358
+ assert res.metrics_is.bars + res.metrics_oos.bars == len(px)
359
+ assert res.metrics_is.bars == pytest.approx(280, abs=1)
360
+
361
+
362
+ def test_ka5_holdout_is_carved_out_of_split_mode_too():
363
+ """A reserved holdout is honoured regardless of how the rest is divided."""
364
+ px = prices(n=900, seed=43, start="2022-01-01")
365
+ res = run_backtest(px, strategies.buy_and_hold(px),
366
+ cfg(validation=Validation(mode="split", split_frac=0.7,
367
+ holdout_months=6)),
368
+ bars_per_year=BARS_PER_YEAR)
369
+ assert res.metrics_holdout is not None and res.metrics_holdout.bars > 0
370
+ total = res.metrics_is.bars + res.metrics_oos.bars + res.metrics_holdout.bars
371
+ assert total == len(px)
372
+
373
+
374
+ # ==========================================================================
375
+ # KNOWN-ANSWER TEST 6
376
+ # Same config + same data => bit-identical results.
377
+ # ==========================================================================
378
+
379
+
380
+ def _signature(res) -> tuple:
381
+ return (
382
+ tuple(np.round(res.equity.to_numpy(), 12)),
383
+ tuple(np.round(res.trades["net_pnl"].to_numpy(), 12)),
384
+ tuple(np.round(res.trades["costs"].to_numpy(), 12)),
385
+ res.metrics_all.total_return,
386
+ res.metrics_all.sharpe,
387
+ res.metrics_oos.sharpe,
388
+ )
389
+
390
+
391
+ def test_ka6_repeated_runs_are_bit_identical():
392
+ px = prices(n=400, seed=51)
393
+ c = cfg(strategy="SMA Crossover", costs=Costs(enabled=True),
394
+ validation=Validation(mode="walk_forward"))
395
+ params = {"fast_ma": 20, "slow_ma": 50}
396
+
397
+ runs = [
398
+ run_backtest(px, strategies.build("SMA Crossover", px, params), c,
399
+ bars_per_year=BARS_PER_YEAR)
400
+ for _ in range(3)
401
+ ]
402
+ assert _signature(runs[0]) == _signature(runs[1]) == _signature(runs[2])
403
+
404
+
405
+ def test_ka6_fingerprint_is_stable_and_config_sensitive():
406
+ a = cfg(strategy="SMA Crossover", params={"fast_ma": 20})
407
+ b = cfg(strategy="SMA Crossover", params={"fast_ma": 20})
408
+ c = cfg(strategy="SMA Crossover", params={"fast_ma": 21})
409
+ assert a.fingerprint() == b.fingerprint()
410
+ assert a.fingerprint() != c.fingerprint()
411
+
412
+
413
+ def test_ka6_cost_change_changes_the_fingerprint():
414
+ a = cfg(costs=Costs(enabled=True, commission_bps=10.0))
415
+ b = cfg(costs=Costs(enabled=True, commission_bps=11.0))
416
+ assert a.fingerprint() != b.fingerprint()
417
+
418
+
419
+ # ==========================================================================
420
+ # Engine invariants beyond the six
421
+ # ==========================================================================
422
+
423
+
424
+ def test_trade_list_carries_every_documented_column():
425
+ px = prices(n=300, seed=61)
426
+ res = run_backtest(px, alternating(px), cfg(costs=Costs(enabled=True)),
427
+ bars_per_year=BARS_PER_YEAR)
428
+ for col in ("id", "entry_ts", "exit_ts", "side", "entry_px", "exit_px", "size",
429
+ "gross_pnl", "costs", "net_pnl", "r_multiple", "mae", "mfe",
430
+ "duration_bars", "trigger", "segment"):
431
+ assert col in res.trades.columns
432
+
433
+
434
+ def test_mae_is_never_positive_and_mfe_never_negative_for_longs():
435
+ px = prices(n=300, seed=62)
436
+ res = run_backtest(px, alternating(px), cfg(), bars_per_year=BARS_PER_YEAR)
437
+ longs = res.trades[res.trades["side"] == "long"]
438
+ assert (longs["mae"] <= 1e-12).all()
439
+ assert (longs["mfe"] >= -1e-12).all()
440
+
441
+
442
+ def test_trigger_reason_is_populated_from_the_strategy():
443
+ px = prices(n=400, seed=63)
444
+ out = strategies.build("SMA Crossover", px, {"fast_ma": 10, "slow_ma": 30})
445
+ res = run_backtest(px, out, cfg(strategy="SMA Crossover"), bars_per_year=BARS_PER_YEAR)
446
+ assert len(res.trades) > 0
447
+ assert (res.trades["trigger"].str.len() > 0).all()
448
+ assert "crossed above" in res.trades["trigger"].iloc[0]
449
+
450
+
451
+ def test_index_mismatch_is_rejected():
452
+ px = prices(n=100, seed=64)
453
+ bad = StrategyOutput(
454
+ entries=pd.Series(False, index=px.index[:50]),
455
+ exits=pd.Series(False, index=px.index[:50]),
456
+ )
457
+ with pytest.raises(EngineError, match="index does not match"):
458
+ run_backtest(px, bad, cfg(), bars_per_year=BARS_PER_YEAR)
459
+
460
+
461
+ def test_empty_prices_are_rejected():
462
+ empty = pd.DataFrame(columns=["open", "high", "low", "close", "volume"])
463
+ out = StrategyOutput(entries=pd.Series(dtype=bool), exits=pd.Series(dtype=bool))
464
+ with pytest.raises(EngineError, match="no price data"):
465
+ run_backtest(empty, out, cfg(), bars_per_year=BARS_PER_YEAR)
466
+
467
+
468
+ def test_stops_are_applied_when_configured():
469
+ px = prices(n=400, seed=65, trend=-0.002, vol=0.03)
470
+ with_stop = run_backtest(px, alternating(px),
471
+ cfg(stops=Stops(sl_pct=0.02)), bars_per_year=BARS_PER_YEAR)
472
+ without = run_backtest(px, alternating(px), cfg(), bars_per_year=BARS_PER_YEAR)
473
+ assert with_stop.trades["net_pnl"].min() > without.trades["net_pnl"].min()
474
+
475
+
476
+ def test_r_multiple_uses_the_configured_stop_distance():
477
+ px = prices(n=300, seed=66)
478
+ res = run_backtest(px, alternating(px), cfg(stops=Stops(sl_pct=0.05)),
479
+ bars_per_year=BARS_PER_YEAR)
480
+ t = res.trades.iloc[0]
481
+ expected = t["net_pnl"] / (0.05 * t["entry_px"] * t["size"])
482
+ assert t["r_multiple"] == pytest.approx(expected, rel=1e-9)
483
+
484
+
485
+ def test_unavailable_presets_refuse_to_run():
486
+ px = prices(n=100, seed=67)
487
+ with pytest.raises(ValueError, match="never executes untrusted code"):
488
+ strategies.build("Custom (code)", px)
489
+ with pytest.raises(ValueError, match="second leg"):
490
+ strategies.build("Pairs Trading", px)
491
+
492
+
493
+ def test_forecast_follower_requires_signals():
494
+ px = prices(n=100, seed=68)
495
+ with pytest.raises(ValueError, match="needs stored model signals"):
496
+ strategies.build("Chronos Forecast Follower", px, {}, signals=None)
497
+
498
+
499
+ def test_default_run_completes_well_under_two_seconds():
500
+ """Perf budget: a 3-year daily run must leave room for chart building."""
501
+ px = prices(n=365 * 3, seed=69)
502
+ out = strategies.build("SMA Crossover", px, {"fast_ma": 20, "slow_ma": 50})
503
+ res = run_backtest(px, out, cfg(strategy="SMA Crossover", costs=Costs(enabled=True),
504
+ validation=Validation(mode="walk_forward")),
505
+ bars_per_year=BARS_PER_YEAR)
506
+ assert res.elapsed_s < 2.0, f"engine took {res.elapsed_s:.2f}s"
tests/test_extension.py ADDED
@@ -0,0 +1,307 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Phase 4 acceptance: extend flow, dedup, quota fallback, manifest atomicity.
2
+
3
+ Auth and GPU inference are both mocked, so these run offline and deterministically.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import sys
9
+ import threading
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+
13
+ import numpy as np
14
+ import pandas as pd
15
+ import pytest
16
+
17
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
18
+
19
+ from src import config, extension, runtime
20
+ from src.extension import ExtensionError, add_model, estimate, extend_coverage
21
+ from src.store import SignalStore
22
+
23
+
24
+ @dataclass
25
+ class FakeProfile:
26
+ username: str
27
+
28
+
29
+ def ohlcv(n=900, start="2023-01-01", freq="D"):
30
+ rng = np.random.default_rng(4)
31
+ close = pd.Series(100 * np.exp(np.cumsum(rng.normal(0.0004, 0.02, n))))
32
+ ts = pd.date_range(start, periods=n, freq=freq, tz="UTC")
33
+ return pd.DataFrame({
34
+ "ts": ts, "open": close * 0.999, "high": close * 1.02,
35
+ "low": close * 0.98, "close": close, "volume": 1000.0, "source": "test",
36
+ })
37
+
38
+
39
+ @pytest.fixture
40
+ def wired(tmp_path, monkeypatch):
41
+ """A fresh offline store wired into runtime, with GPU inference mocked."""
42
+ store = SignalStore(repo_id=None, local_root=tmp_path / "store", offline=True)
43
+ store.write_prices("BTC-USD", "1d", ohlcv())
44
+ monkeypatch.setattr(runtime, "_store", store, raising=False)
45
+ monkeypatch.setattr(runtime, "get_store", lambda: store)
46
+ monkeypatch.setattr(extension, "HAS_SPACES", True)
47
+ runtime.cache_clear()
48
+
49
+ calls = {"n": 0}
50
+
51
+ def fake_inference(model_id, family, values, ctx_len):
52
+ calls["n"] += 1
53
+ arr = np.asarray(values, dtype="float64")
54
+ last = arr[:, -1]
55
+ return {
56
+ "q10": (last * 0.97).tolist(), "q50": last.tolist(),
57
+ "q90": (last * 1.03).tolist(), "context_len": int(ctx_len),
58
+ "revision": "deadbeef", "inference_version": "1.0.0+test.deadbeef",
59
+ }
60
+
61
+ monkeypatch.setattr(extension, "run_inference", fake_inference)
62
+ return store, calls
63
+
64
+
65
+ # --------------------------------------------------------------------------
66
+ # Auth gating
67
+ # --------------------------------------------------------------------------
68
+
69
+
70
+ def test_anonymous_users_cannot_extend(wired):
71
+ html, _ = extension.extend_ui("chronos-bolt-small", "BTC-USD", "1d",
72
+ "2024-01-01", "2024-06-01", profile=None)
73
+ assert "Sign in with Hugging Face" in html
74
+ assert "spends your own GPU quota" in html
75
+
76
+
77
+ def test_anonymous_users_cannot_add_models(wired):
78
+ html, _ = extension.add_model_ui("chronos", "amazon/chronos-bolt-small", profile=None)
79
+ assert "Sign in" in html
80
+
81
+
82
+ def test_signed_in_user_can_extend(wired):
83
+ store, calls = wired
84
+ html, cov = extension.extend_ui("chronos-bolt-small", "BTC-USD", "1d",
85
+ "2024-01-01", "2024-06-01",
86
+ profile=FakeProfile("alice"))
87
+ assert "Coverage extended by <b>@alice</b>" in html
88
+ assert calls["n"] == 1
89
+ assert not cov.empty
90
+
91
+
92
+ def test_contribution_is_attributed_to_the_user(wired):
93
+ store, _ = wired
94
+ extend_coverage("chronos-bolt-small", "BTC-USD", "1d",
95
+ "2024-01-01", "2024-06-01", username="bob")
96
+ entries = store.load_manifest().find_signals(model_slug="chronos-bolt-small")
97
+ assert entries and entries[0].contributed_by == "bob"
98
+
99
+
100
+ # --------------------------------------------------------------------------
101
+ # Dedup — never recompute covered ranges
102
+ # --------------------------------------------------------------------------
103
+
104
+
105
+ def test_second_identical_request_recomputes_nothing(wired):
106
+ store, calls = wired
107
+ first = extend_coverage("chronos-bolt-small", "BTC-USD", "1d",
108
+ "2024-01-01", "2024-06-01", username="alice")
109
+ assert "Coverage extended" in first
110
+ assert calls["n"] == 1
111
+
112
+ second = extend_coverage("chronos-bolt-small", "BTC-USD", "1d",
113
+ "2024-01-01", "2024-06-01", username="alice")
114
+ assert "already covered" in second
115
+ assert calls["n"] == 1, "inference ran again for an already-covered range"
116
+
117
+
118
+ def test_estimate_reports_already_covered(wired):
119
+ extend_coverage("chronos-bolt-small", "BTC-USD", "1d",
120
+ "2024-01-01", "2024-06-01", username="alice")
121
+ est = estimate("chronos-bolt-small", "BTC-USD", "1d", "2024-02-01", "2024-05-01")
122
+ assert est.already_covered
123
+
124
+
125
+ def test_estimate_counts_steps_for_an_uncovered_range(wired):
126
+ est = estimate("chronos-bolt-small", "BTC-USD", "1d", "2024-01-01", "2024-06-01")
127
+ assert est.steps > 0 and not est.already_covered
128
+
129
+
130
+ # --------------------------------------------------------------------------
131
+ # Guardrails
132
+ # --------------------------------------------------------------------------
133
+
134
+
135
+ def test_range_is_capped_per_timeframe(wired):
136
+ est = estimate("chronos-bolt-small", "BTC-USD", "1d", "2015-01-01", "2025-01-01")
137
+ assert est.capped
138
+ assert (est.end - est.start).days <= config.CAPS.max_days["1d"]
139
+ assert "cap" in est.note
140
+
141
+
142
+ def test_steps_never_exceed_the_per_run_ceiling(wired):
143
+ est = estimate("chronos-bolt-small", "BTC-USD", "1d", "2023-01-01", "2025-01-01")
144
+ assert est.steps <= config.CAPS.max_steps_per_run
145
+
146
+
147
+ @pytest.mark.parametrize("bad", [
148
+ ("nope-model", "BTC-USD", "1d"),
149
+ ("chronos-bolt-small", "DOGE-USD", "1d"),
150
+ ("chronos-bolt-small", "BTC-USD", "3y"),
151
+ ])
152
+ def test_unknown_selections_are_rejected(wired, bad):
153
+ with pytest.raises(ExtensionError):
154
+ estimate(bad[0], bad[1], bad[2], "2024-01-01", "2024-06-01")
155
+
156
+
157
+ def test_inverted_range_is_rejected(wired):
158
+ with pytest.raises(ExtensionError, match="start must be before end"):
159
+ estimate("chronos-bolt-small", "BTC-USD", "1d", "2024-06-01", "2024-01-01")
160
+
161
+
162
+ def test_extension_requires_existing_price_coverage(wired):
163
+ with pytest.raises(ExtensionError, match="No cached prices"):
164
+ estimate("chronos-bolt-small", "ETH-USD", "1d", "2024-01-01", "2024-06-01")
165
+
166
+
167
+ def test_add_model_rejects_hostile_ids(wired):
168
+ for bad in ("../../etc/passwd", "no-slash", "owner/name;rm -rf /"):
169
+ html = add_model("chronos", bad, username="alice")
170
+ assert "not a valid Hub model id" in html or "not allowed" in html
171
+
172
+
173
+ def test_add_model_rejects_families_off_the_allow_list(wired):
174
+ html = add_model("evil", "someone/backdoor", username="alice")
175
+ assert "not on the allow-list" in html
176
+
177
+
178
+ def test_add_model_smoke_test_registers_on_success(wired):
179
+ store, calls = wired
180
+ html = add_model("chronos", "amazon/chronos-bolt-small", username="carol")
181
+ assert "Smoke test passed" in html
182
+ assert calls["n"] == 1
183
+ slugs = {e.model_slug for e in store.load_manifest().signals.values()}
184
+ assert "chronos-bolt-small" in slugs
185
+
186
+
187
+ # --------------------------------------------------------------------------
188
+ # Quota exhaustion
189
+ # --------------------------------------------------------------------------
190
+
191
+
192
+ def test_quota_exhaustion_renders_the_duplicate_space_fallback(wired, monkeypatch):
193
+ def boom(*a, **k):
194
+ raise RuntimeError("ZeroGPU quota exceeded for this account")
195
+
196
+ monkeypatch.setattr(extension, "run_inference", boom)
197
+ html = extend_coverage("chronos-bolt-small", "BTC-USD", "1d",
198
+ "2024-01-01", "2024-06-01", username="alice")
199
+ assert "quota is exhausted" in html
200
+ assert "duplicate=true" in html
201
+
202
+
203
+ @pytest.mark.parametrize("message", [
204
+ "GPU task aborted", "ZeroGPU quota exceeded", "No GPU available right now",
205
+ ])
206
+ def test_quota_errors_are_recognised(message):
207
+ assert extension._is_quota_error(RuntimeError(message))
208
+
209
+
210
+ def test_ordinary_errors_are_not_mistaken_for_quota_errors(wired, monkeypatch):
211
+ monkeypatch.setattr(extension, "run_inference",
212
+ lambda *a, **k: (_ for _ in ()).throw(ValueError("bad tensor shape")))
213
+ html = extend_coverage("chronos-bolt-small", "BTC-USD", "1d",
214
+ "2024-01-01", "2024-06-01", username="alice")
215
+ assert "quota" not in html.lower()
216
+ assert "bad tensor shape" in html
217
+
218
+
219
+ # --------------------------------------------------------------------------
220
+ # Manifest atomicity under concurrent writers
221
+ # --------------------------------------------------------------------------
222
+
223
+
224
+ def test_parallel_extensions_do_not_corrupt_the_manifest(wired):
225
+ """Simulated parallel writers must leave a valid, complete manifest."""
226
+ store, calls = wired
227
+ store.write_prices("ETH-USD", "1d", ohlcv())
228
+ store.write_prices("SOL-USD", "1d", ohlcv())
229
+
230
+ targets = [("BTC-USD", "alice"), ("ETH-USD", "bob"), ("SOL-USD", "carol")]
231
+ errors: list[Exception] = []
232
+ barrier = threading.Barrier(len(targets))
233
+
234
+ def worker(asset, user):
235
+ try:
236
+ barrier.wait(timeout=10) # maximise overlap
237
+ extend_coverage("chronos-bolt-small", asset, "1d",
238
+ "2024-01-01", "2024-06-01", username=user)
239
+ except Exception as e:
240
+ errors.append(e)
241
+
242
+ threads = [threading.Thread(target=worker, args=t) for t in targets]
243
+ for t in threads:
244
+ t.start()
245
+ for t in threads:
246
+ t.join(timeout=60)
247
+
248
+ assert not errors, f"concurrent extensions raised: {errors}"
249
+
250
+ # Manifest must still validate and hold every contribution.
251
+ m = store.load_manifest(force=True)
252
+ m.validate()
253
+ assets = {e.asset for e in m.signals.values()}
254
+ assert assets == {"BTC-USD", "ETH-USD", "SOL-USD"}
255
+ contributors = {e.contributed_by for e in m.signals.values()}
256
+ assert contributors == {"alice", "bob", "carol"}
257
+
258
+ # And every referenced slice must actually exist and be readable.
259
+ for e in m.signals.values():
260
+ df = store.get_signals(e.model_slug, e.asset, e.timeframe)
261
+ assert not df.empty, f"manifest references an empty slice for {e.key}"
262
+
263
+
264
+ def test_repeated_parallel_requests_for_the_same_slice_run_inference_once(wired):
265
+ store, calls = wired
266
+ barrier = threading.Barrier(4)
267
+
268
+ def worker():
269
+ barrier.wait(timeout=10)
270
+ extend_coverage("chronos-bolt-small", "BTC-USD", "1d",
271
+ "2024-01-01", "2024-06-01", username="alice")
272
+
273
+ threads = [threading.Thread(target=worker) for _ in range(4)]
274
+ for t in threads:
275
+ t.start()
276
+ for t in threads:
277
+ t.join(timeout=60)
278
+
279
+ m = store.load_manifest(force=True)
280
+ m.validate()
281
+ entries = [e for e in m.signals.values() if e.asset == "BTC-USD"]
282
+ assert len(entries) == 1, "duplicate manifest entries for the same slice"
283
+
284
+
285
+ def test_write_lock_serialises_commits():
286
+ assert isinstance(extension._WRITE_LOCK, type(threading.Lock()))
287
+
288
+
289
+ # --------------------------------------------------------------------------
290
+ # CPU-only degradation
291
+ # --------------------------------------------------------------------------
292
+
293
+
294
+ def test_cpu_only_space_disables_extension_with_an_explanation(wired, monkeypatch):
295
+ monkeypatch.setattr(extension, "HAS_SPACES", False)
296
+ html, _ = extension.extend_ui("chronos-bolt-small", "BTC-USD", "1d",
297
+ "2024-01-01", "2024-06-01",
298
+ profile=FakeProfile("alice"))
299
+ assert "running on CPU" in html
300
+ assert "works normally" in html
301
+
302
+
303
+ def test_status_html_states_which_mode_the_space_is_in(monkeypatch):
304
+ monkeypatch.setattr(extension, "HAS_SPACES", True)
305
+ assert "ZEROGPU AVAILABLE" in extension.status_html()
306
+ monkeypatch.setattr(extension, "HAS_SPACES", False)
307
+ assert "CPU" in extension.status_html()
tests/test_store.py ADDED
@@ -0,0 +1,361 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Phase 0 acceptance: manifest round-trip, idempotent writes, price validation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ import pandas as pd
8
+ import pytest
9
+
10
+ from src import config
11
+ from src.store import (
12
+ CoverageEntry,
13
+ Manifest,
14
+ PriceCoverage,
15
+ SchemaError,
16
+ SignalStore,
17
+ empty_manifest,
18
+ signal_key,
19
+ validate_price_frame,
20
+ validate_signal_frame,
21
+ )
22
+
23
+
24
+ # --------------------------------------------------------------------------
25
+ # Fixtures
26
+ # --------------------------------------------------------------------------
27
+
28
+
29
+ @pytest.fixture
30
+ def store(tmp_path) -> SignalStore:
31
+ return SignalStore(repo_id=None, local_root=tmp_path / "store", offline=True)
32
+
33
+
34
+ def make_signals(n=30, start="2024-01-01", freq="D", base=100.0) -> pd.DataFrame:
35
+ ts = pd.date_range(start, periods=n, freq=freq, tz="UTC")
36
+ q50 = pd.Series([base + i for i in range(n)], dtype="float64")
37
+ return pd.DataFrame({
38
+ "ts": ts,
39
+ "q10": q50 * 0.97,
40
+ "q50": q50,
41
+ "q90": q50 * 1.03,
42
+ "context_len": 512,
43
+ "inference_version": config.INFERENCE_VERSION,
44
+ })
45
+
46
+
47
+ def make_prices(n=30, start="2024-01-01", freq="D", base=100.0) -> pd.DataFrame:
48
+ ts = pd.date_range(start, periods=n, freq=freq, tz="UTC")
49
+ close = pd.Series([base + i for i in range(n)], dtype="float64")
50
+ return pd.DataFrame({
51
+ "ts": ts,
52
+ "open": close * 0.99,
53
+ "high": close * 1.02,
54
+ "low": close * 0.98,
55
+ "close": close,
56
+ "volume": 1000.0,
57
+ "source": "test",
58
+ })
59
+
60
+
61
+ # --------------------------------------------------------------------------
62
+ # Manifest round-trip
63
+ # --------------------------------------------------------------------------
64
+
65
+
66
+ def test_empty_manifest_round_trips():
67
+ m = empty_manifest()
68
+ again = Manifest.from_json(m.to_json())
69
+ assert again.schema_version == config.MANIFEST_SCHEMA_VERSION
70
+ assert again.signals == {} and again.prices == {}
71
+
72
+
73
+ def test_manifest_round_trip_preserves_entries():
74
+ m = empty_manifest()
75
+ m.upsert_signal(CoverageEntry(
76
+ model_slug="chronos-bolt-small", model_id="amazon/chronos-bolt-small",
77
+ model_revision="abc123", asset="BTC-USD", timeframe="1d",
78
+ start_ts="2022-01-01T00:00:00Z", end_ts="2024-12-31T00:00:00Z",
79
+ rows=1096, inference_version="1.0.0", last_updated="2026-08-15T00:00:00Z",
80
+ contributed_by="seed",
81
+ ))
82
+ m.upsert_price(PriceCoverage(
83
+ asset="BTC-USD", timeframe="1d", start_ts="2022-01-01T00:00:00Z",
84
+ end_ts="2024-12-31T00:00:00Z", rows=1096, sources=["binance"],
85
+ last_updated="2026-08-15T00:00:00Z",
86
+ ))
87
+
88
+ again = Manifest.from_json(m.to_json())
89
+ assert again.to_dict()["signals"] == m.to_dict()["signals"]
90
+ assert again.to_dict()["prices"] == m.to_dict()["prices"]
91
+
92
+ e = again.get_signal("chronos-bolt-small", "abc123", "BTC-USD", "1d")
93
+ assert e is not None and e.rows == 1096
94
+ assert e.key == signal_key("chronos-bolt-small", "abc123", "BTC-USD", "1d")
95
+
96
+
97
+ def test_manifest_rejects_missing_schema_version():
98
+ with pytest.raises(SchemaError, match="schema_version"):
99
+ Manifest.from_json(json.dumps({"signals": {}, "prices": {}}))
100
+
101
+
102
+ def test_manifest_rejects_future_schema_version():
103
+ with pytest.raises(SchemaError, match="newer than this app"):
104
+ Manifest.from_json(json.dumps({"schema_version": 999}))
105
+
106
+
107
+ def test_manifest_rejects_malformed_json():
108
+ with pytest.raises(SchemaError, match="not valid JSON"):
109
+ Manifest.from_json("{not json")
110
+
111
+
112
+ def test_manifest_rejects_inverted_range():
113
+ m = empty_manifest()
114
+ bad = CoverageEntry(
115
+ model_slug="m", model_id="o/m", model_revision="r", asset="BTC-USD",
116
+ timeframe="1d", start_ts="2024-12-31T00:00:00Z", end_ts="2022-01-01T00:00:00Z",
117
+ rows=1, inference_version="1.0.0", last_updated="x", contributed_by="seed",
118
+ )
119
+ with pytest.raises(SchemaError, match="start_ts after end_ts"):
120
+ m.upsert_signal(bad)
121
+
122
+
123
+ def test_revision_is_part_of_identity():
124
+ """Two revisions of the same model are distinct coverage, never merged."""
125
+ m = empty_manifest()
126
+ for rev in ("rev-a", "rev-b"):
127
+ m.upsert_signal(CoverageEntry(
128
+ model_slug="chronos", model_id="amazon/chronos", model_revision=rev,
129
+ asset="BTC-USD", timeframe="1d", start_ts="2024-01-01T00:00:00Z",
130
+ end_ts="2024-02-01T00:00:00Z", rows=32, inference_version="1.0.0",
131
+ last_updated="x", contributed_by="seed",
132
+ ))
133
+ assert len(m.signals) == 2
134
+
135
+
136
+ # --------------------------------------------------------------------------
137
+ # Store persistence + idempotency
138
+ # --------------------------------------------------------------------------
139
+
140
+
141
+ def test_store_manifest_persists_to_disk(store, tmp_path):
142
+ store.write_signals("m1", "org/m1", "rev1", "BTC-USD", "1d", make_signals())
143
+ path = tmp_path / "store" / config.MANIFEST_PATH
144
+ assert path.exists()
145
+ reloaded = SignalStore(repo_id=None, local_root=tmp_path / "store", offline=True)
146
+ assert reloaded.load_manifest().get_signal("m1", "rev1", "BTC-USD", "1d").rows == 30
147
+
148
+
149
+ def test_signal_write_is_idempotent(store):
150
+ df = make_signals(n=30)
151
+ first = store.write_signals("m1", "org/m1", "rev1", "BTC-USD", "1d", df)
152
+ second = store.write_signals("m1", "org/m1", "rev1", "BTC-USD", "1d", df)
153
+
154
+ assert first.rows == second.rows == 30
155
+ got = store.get_signals("m1", "BTC-USD", "1d")
156
+ assert len(got) == 30
157
+ assert not got.index.duplicated().any()
158
+
159
+
160
+ def test_rewriting_a_slice_does_not_change_stored_values(store):
161
+ """Append-only: an existing ts keeps its original value."""
162
+ store.write_signals("m1", "org/m1", "rev1", "BTC-USD", "1d", make_signals(base=100.0))
163
+ before = store.get_signals("m1", "BTC-USD", "1d")["q50"].tolist()
164
+
165
+ conflicting = make_signals(base=999.0)
166
+ store.write_signals("m1", "org/m1", "rev1", "BTC-USD", "1d", conflicting)
167
+ after = store.get_signals("m1", "BTC-USD", "1d")["q50"].tolist()
168
+
169
+ assert before == after
170
+
171
+
172
+ def test_extending_coverage_widens_range_and_adds_rows(store):
173
+ store.write_signals("m1", "org/m1", "rev1", "BTC-USD", "1d",
174
+ make_signals(n=30, start="2024-01-01"))
175
+ entry = store.write_signals("m1", "org/m1", "rev1", "BTC-USD", "1d",
176
+ make_signals(n=30, start="2024-02-01"))
177
+ assert entry.rows == 60
178
+ assert entry.start_ts.startswith("2024-01-01")
179
+ assert entry.end_ts.startswith("2024-03-01")
180
+ assert len(store.get_signals("m1", "BTC-USD", "1d")) == 60
181
+
182
+
183
+ def test_write_spanning_year_boundary_splits_files(store, tmp_path):
184
+ store.write_signals("m1", "org/m1", "rev1", "BTC-USD", "1d",
185
+ make_signals(n=60, start="2023-12-10"))
186
+ root = tmp_path / "store" / "signals" / "m1" / "BTC-USD" / "1d"
187
+ assert (root / "2023.parquet").exists()
188
+ assert (root / "2024.parquet").exists()
189
+ assert len(store.get_signals("m1", "BTC-USD", "1d")) == 60
190
+
191
+
192
+ def test_coverage_across_years_counts_untouched_years(store):
193
+ store.write_signals("m1", "org/m1", "rev1", "BTC-USD", "1d",
194
+ make_signals(n=20, start="2023-01-01"))
195
+ entry = store.write_signals("m1", "org/m1", "rev1", "BTC-USD", "1d",
196
+ make_signals(n=20, start="2024-06-01"))
197
+ assert entry.rows == 40
198
+
199
+
200
+ def test_has_coverage_and_missing_ranges(store):
201
+ assert not store.has_coverage("m1", "rev1", "BTC-USD", "1d")
202
+ store.write_signals("m1", "org/m1", "rev1", "BTC-USD", "1d",
203
+ make_signals(n=30, start="2024-01-01"))
204
+
205
+ assert store.has_coverage("m1", "rev1", "BTC-USD", "1d",
206
+ "2024-01-05", "2024-01-20")
207
+ assert not store.has_coverage("m1", "rev1", "BTC-USD", "1d",
208
+ "2023-01-01", "2024-01-20")
209
+
210
+ # Fully covered -> nothing to recompute. This is the extension dedup gate.
211
+ assert store.missing_ranges("m1", "rev1", "BTC-USD", "1d",
212
+ "2024-01-05", "2024-01-20") == []
213
+ gaps = store.missing_ranges("m1", "rev1", "BTC-USD", "1d",
214
+ "2023-06-01", "2024-06-01")
215
+ assert len(gaps) == 2
216
+
217
+
218
+ def test_placeholder_coverage_can_be_excluded(store):
219
+ store.write_signals("m1", "org/m1", "rev1", "BTC-USD", "1d", make_signals(),
220
+ inference_version=config.PLACEHOLDER_VERSION)
221
+ assert store.has_coverage("m1", "rev1", "BTC-USD", "1d")
222
+ assert not store.has_coverage("m1", "rev1", "BTC-USD", "1d",
223
+ allow_placeholder=False)
224
+
225
+
226
+ def test_get_signals_respects_window(store):
227
+ store.write_signals("m1", "org/m1", "rev1", "BTC-USD", "1d",
228
+ make_signals(n=30, start="2024-01-01"))
229
+ got = store.get_signals("m1", "BTC-USD", "1d", "2024-01-10", "2024-01-14")
230
+ assert len(got) == 5
231
+ assert str(got.index[0].date()) == "2024-01-10"
232
+
233
+
234
+ def test_missing_coverage_returns_empty_frame_not_error(store):
235
+ got = store.get_signals("nope", "BTC-USD", "1d", "2024-01-01", "2024-02-01")
236
+ assert got.empty
237
+
238
+
239
+ def test_pending_files_are_tracked_for_commit(store):
240
+ store.write_signals("m1", "org/m1", "rev1", "BTC-USD", "1d", make_signals())
241
+ pending = store.pending
242
+ assert config.MANIFEST_PATH in pending
243
+ assert any(p.startswith("signals/m1/BTC-USD/1d/") for p in pending)
244
+
245
+
246
+ # --------------------------------------------------------------------------
247
+ # Signal frame validation
248
+ # --------------------------------------------------------------------------
249
+
250
+
251
+ def test_signal_validation_rejects_crossed_quantiles():
252
+ df = make_signals(n=10)
253
+ df.loc[3, "q10"] = df.loc[3, "q90"] + 5 # q10 > q50 > q90
254
+ with pytest.raises(SchemaError, match="crossed quantiles"):
255
+ validate_signal_frame(df)
256
+
257
+
258
+ def test_signal_validation_rejects_duplicate_timestamps():
259
+ df = make_signals(n=10)
260
+ df.loc[5, "ts"] = df.loc[4, "ts"]
261
+ with pytest.raises(SchemaError, match="duplicate timestamps"):
262
+ validate_signal_frame(df)
263
+
264
+
265
+ def test_signal_validation_rejects_missing_columns():
266
+ df = make_signals(n=10).drop(columns=["q90"])
267
+ with pytest.raises(SchemaError, match="missing columns"):
268
+ validate_signal_frame(df)
269
+
270
+
271
+ def test_signal_validation_normalises_naive_timestamps_to_utc():
272
+ df = make_signals(n=5)
273
+ df["ts"] = df["ts"].dt.tz_localize(None)
274
+ out = validate_signal_frame(df)
275
+ assert str(out["ts"].dt.tz) == "UTC"
276
+
277
+
278
+ # --------------------------------------------------------------------------
279
+ # Price validation — injected bad rows must be caught
280
+ # --------------------------------------------------------------------------
281
+
282
+
283
+ def test_price_validation_accepts_clean_frame():
284
+ out, report = validate_price_frame(make_prices(), "1d")
285
+ assert report.ok and report.gaps == 0 and len(out) == 30
286
+
287
+
288
+ def test_price_validation_catches_negative_price():
289
+ df = make_prices()
290
+ df.loc[7, "close"] = -50.0
291
+ with pytest.raises(SchemaError, match="non-positive"):
292
+ validate_price_frame(df, "1d")
293
+
294
+
295
+ def test_price_validation_catches_zero_price():
296
+ df = make_prices()
297
+ df.loc[2, "open"] = 0.0
298
+ with pytest.raises(SchemaError, match="non-positive"):
299
+ validate_price_frame(df, "1d")
300
+
301
+
302
+ def test_price_validation_catches_duplicate_timestamps():
303
+ df = make_prices()
304
+ df.loc[9, "ts"] = df.loc[8, "ts"]
305
+ with pytest.raises(SchemaError, match="duplicate timestamps"):
306
+ validate_price_frame(df, "1d")
307
+
308
+
309
+ def test_price_validation_catches_inconsistent_ohlc():
310
+ df = make_prices()
311
+ df.loc[4, "high"] = df.loc[4, "low"] - 1.0
312
+ with pytest.raises(SchemaError, match="inconsistent OHLC"):
313
+ validate_price_frame(df, "1d")
314
+
315
+
316
+ def test_price_validation_catches_negative_volume():
317
+ df = make_prices()
318
+ df.loc[11, "volume"] = -1.0
319
+ with pytest.raises(SchemaError, match="negative"):
320
+ validate_price_frame(df, "1d")
321
+
322
+
323
+ def test_price_validation_catches_nan_price():
324
+ df = make_prices()
325
+ df.loc[6, "close"] = float("nan")
326
+ with pytest.raises(SchemaError, match="NaN"):
327
+ validate_price_frame(df, "1d")
328
+
329
+
330
+ def test_price_validation_reports_gaps_without_failing():
331
+ """A gap is a fact about coverage, not a validation failure."""
332
+ df = make_prices(n=30).drop(index=[10, 11, 12]).reset_index(drop=True)
333
+ out, report = validate_price_frame(df, "1d")
334
+ assert report.ok
335
+ assert report.gaps == 1
336
+ assert len(report.gap_ranges) == 1
337
+ assert len(out) == 27
338
+
339
+
340
+ def test_price_validation_non_strict_collects_problems(make_bad=None):
341
+ df = make_prices()
342
+ df.loc[7, "close"] = -50.0
343
+ out, report = validate_price_frame(df, "1d", strict=False)
344
+ assert not report.ok
345
+ assert any("non-positive" in p for p in report.problems)
346
+
347
+
348
+ def test_price_write_and_read_round_trip(store):
349
+ cov = store.write_prices("BTC-USD", "1d", make_prices(n=40))
350
+ assert cov.rows == 40 and cov.sources == ["test"]
351
+ got = store.get_prices("BTC-USD", "1d")
352
+ assert len(got) == 40
353
+ assert list(got.columns) == ["open", "high", "low", "close", "volume", "source"]
354
+
355
+
356
+ def test_price_write_is_idempotent(store):
357
+ df = make_prices(n=40)
358
+ store.write_prices("BTC-USD", "1d", df)
359
+ cov = store.write_prices("BTC-USD", "1d", df)
360
+ assert cov.rows == 40
361
+ assert len(store.get_prices("BTC-USD", "1d")) == 40
tests/test_ui.py ADDED
@@ -0,0 +1,298 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Phase 3 acceptance: the UI renders, and every displayed number is traceable.
2
+
3
+ These run against the real cached store when it is present, and skip cleanly
4
+ when it is not (a fresh clone with no `.cache/store` yet).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ import pandas as pd
14
+ import plotly.graph_objects as go
15
+ import pytest
16
+
17
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
18
+
19
+ import app as bitapp
20
+ from src import charts, comparisons, config, runtime, strategies
21
+ from src.runtime import RunRequest
22
+
23
+
24
+ def _store_ready() -> bool:
25
+ try:
26
+ m = runtime.get_store().load_manifest()
27
+ return len(m.prices) > 0
28
+ except Exception:
29
+ return False
30
+
31
+
32
+ pytestmark = pytest.mark.skipif(not _store_ready(),
33
+ reason="no cached signal store available")
34
+
35
+
36
+ @pytest.fixture(scope="module")
37
+ def rec():
38
+ return runtime.execute(RunRequest(
39
+ strategy="SMA Crossover", asset="BTC-USD", timeframe="1d", date_range="3Y",
40
+ ))
41
+
42
+
43
+ @pytest.fixture(scope="module")
44
+ def three_runs():
45
+ reqs = [
46
+ RunRequest(strategy="SMA Crossover", asset="BTC-USD", timeframe="1d", date_range="3Y"),
47
+ RunRequest(strategy="RSI Mean Reversion", asset="ETH-USD", timeframe="1d", date_range="3Y"),
48
+ RunRequest(strategy="Buy & Hold (benchmark)", asset="SPY", timeframe="1d", date_range="3Y"),
49
+ ]
50
+ return [runtime.execute(r) for r in reqs]
51
+
52
+
53
+ # --------------------------------------------------------------------------
54
+ # App construction
55
+ # --------------------------------------------------------------------------
56
+
57
+
58
+ def test_app_object_exists():
59
+ assert bitapp.demo is not None
60
+
61
+
62
+ def test_theme_css_carries_the_design_tokens():
63
+ from src.ui import theme
64
+
65
+ css = theme.full_css()
66
+ for token in ("--bg-canvas", "--accent-amber", "--fin-up", "--font-styrene"):
67
+ assert token in css, f"{token} missing from theme CSS"
68
+ assert "#161512" in css # stone-950 canvas
69
+ assert "#af9209" in css # accent amber
70
+
71
+
72
+ def test_disclaimer_is_present_in_the_footer():
73
+ assert "not indicative of future results" in bitapp.FOOTER_HTML
74
+ assert "Not financial advice" in bitapp.FOOTER_HTML or \
75
+ "not a licensed investment adviser" in bitapp.FOOTER_HTML
76
+
77
+
78
+ def test_empty_state_offers_the_worked_example():
79
+ assert "No run loaded" in bitapp.EMPTY_HTML
80
+ assert "worked example" in bitapp.EMPTY_HTML
81
+
82
+
83
+ def test_glossary_covers_every_design_term():
84
+ terms = {t for t, _ in bitapp.GLOSSARY}
85
+ assert {"SHARPE", "SORTINO", "MAX DRAWDOWN", "PROFIT FACTOR",
86
+ "R-MULTIPLE", "MAE / MFE", "WALK-FORWARD", "OOS"} <= terms
87
+
88
+
89
+ # --------------------------------------------------------------------------
90
+ # The stat band must match engine output exactly
91
+ # --------------------------------------------------------------------------
92
+
93
+
94
+ def test_stat_band_values_match_engine_metrics(rec):
95
+ html = bitapp.stat_band_html(rec)
96
+ m = rec.result.metrics_all
97
+
98
+ assert bitapp.pct(m.total_return) in html
99
+ assert bitapp.pct(m.cagr) in html
100
+ assert bitapp.num(m.sharpe) in html
101
+ assert bitapp.num(m.sortino) in html
102
+ assert bitapp.pct(m.max_drawdown) in html
103
+ assert bitapp.num(m.profit_factor) in html
104
+ assert f">{m.trade_count}<" in html
105
+
106
+
107
+ def test_stat_band_shows_is_and_oos_for_every_stat(rec):
108
+ html = bitapp.stat_band_html(rec)
109
+ assert html.count("IS ") >= 9
110
+ assert html.count("· OOS") >= 9
111
+ assert bitapp.num(rec.result.metrics_oos.sharpe) in html
112
+ assert bitapp.num(rec.result.metrics_is.sharpe) in html
113
+
114
+
115
+ def test_stat_band_reports_costs_actually_paid(rec):
116
+ html = bitapp.stat_band_html(rec)
117
+ assert bitapp.money(rec.result.costs_paid) in html
118
+ assert rec.result.costs_paid > 0, "costs default to ON, so this must be positive"
119
+
120
+
121
+ def test_empty_segment_renders_an_em_dash_not_a_zero():
122
+ """A segment with no bars must not read as 0.00."""
123
+ short = runtime.execute(RunRequest(
124
+ strategy="SMA Crossover", asset="BTC-USD", timeframe="1h", date_range="1Y"))
125
+ if short.result.metrics_oos.bars:
126
+ pytest.skip("this range did produce OOS windows")
127
+ html = bitapp.stat_band_html(short)
128
+ assert "· OOS —" in html
129
+ assert "no out-of-sample windows" in html
130
+
131
+
132
+ def test_trade_table_rows_match_the_engine_trade_list(rec):
133
+ df = bitapp.trades_frame(rec)
134
+ assert len(df) == len(rec.result.trades)
135
+ if len(df):
136
+ assert df["Net"].iloc[0] == pytest.approx(
137
+ round(float(rec.result.trades["net_pnl"].iloc[0]), 2))
138
+ assert set(df["Segment"]) <= {"IS", "OOS", "holdout"}
139
+ assert (df["Costs"] >= 0).all()
140
+
141
+
142
+ def test_report_quotes_the_same_numbers_as_the_stat_band(rec):
143
+ md = bitapp.report_markdown(rec)
144
+ assert bitapp.pct(rec.result.metrics_all.total_return) in md
145
+ assert bitapp.money(rec.result.costs_paid) in md
146
+ assert rec.run_id in md
147
+
148
+
149
+ def test_costs_off_is_called_out_as_not_real():
150
+ off = runtime.execute(RunRequest(
151
+ strategy="SMA Crossover", asset="BTC-USD", timeframe="1d",
152
+ date_range="3Y", costs_on=False))
153
+ _, _, _, _, _, _, _, _, note = bitapp.build_overview(off, log_scale=False, cvd=False)
154
+ assert "COSTS ARE OFF" in note
155
+ assert off.result.costs_paid == 0.0
156
+
157
+
158
+ # --------------------------------------------------------------------------
159
+ # Charts
160
+ # --------------------------------------------------------------------------
161
+
162
+
163
+ def test_overview_builds_every_figure(rec):
164
+ figs = bitapp.build_overview(rec, log_scale=False, cvd=False)
165
+ assert len(figs) == 9
166
+ for f in figs[:8]:
167
+ assert isinstance(f, go.Figure)
168
+ assert isinstance(figs[8], str)
169
+
170
+
171
+ def test_log_scale_and_colorblind_variants_render(rec):
172
+ for log_s in (False, True):
173
+ for cb in (False, True):
174
+ figs = bitapp.build_overview(rec, log_scale=log_s, cvd=cb)
175
+ assert isinstance(figs[0], go.Figure)
176
+
177
+
178
+ def test_equity_chart_marks_the_holdout_band(rec):
179
+ fig = charts.equity_curve(rec.result.equity, rec.result.benchmark_equity,
180
+ plan=rec.result.plan)
181
+ if rec.result.plan.holdout_start is not None:
182
+ texts = [str(a.text) for a in fig.layout.annotations]
183
+ assert any("HOLDOUT" in t for t in texts)
184
+
185
+
186
+ def test_charts_survive_empty_inputs():
187
+ empty = pd.Series(dtype="float64")
188
+ assert isinstance(charts.equity_curve(empty), go.Figure)
189
+ assert isinstance(charts.underwater_chart(empty), go.Figure)
190
+ assert isinstance(charts.pnl_histogram(pd.DataFrame()), go.Figure)
191
+ assert isinstance(charts.mae_mfe_scatter(pd.DataFrame()), go.Figure)
192
+ assert isinstance(charts.walk_forward_bars([]), go.Figure)
193
+ assert isinstance(charts.monte_carlo_cone(None), go.Figure)
194
+
195
+
196
+ def test_colorblind_palette_differs_from_default():
197
+ assert charts.up_color(cvd=True) != charts.up_color(cvd=False)
198
+ assert charts.down_color(cvd=True) != charts.down_color(cvd=False)
199
+
200
+
201
+ # --------------------------------------------------------------------------
202
+ # Comparison tab with three runs
203
+ # --------------------------------------------------------------------------
204
+
205
+
206
+ def test_comparison_renders_three_runs(three_runs):
207
+ curves = {r.label[:28]: r.result.equity for r in three_runs}
208
+ rets = {r.label[:28]: r.result.equity.pct_change().dropna() for r in three_runs}
209
+
210
+ assert len(curves) == 3
211
+ assert isinstance(charts.overlaid_returns(curves), go.Figure)
212
+ assert isinstance(charts.small_multiples(curves), go.Figure)
213
+
214
+ corr = charts.correlation_matrix(rets)
215
+ assert isinstance(corr, go.Figure)
216
+ assert len(corr.data[0].z) == 3
217
+
218
+
219
+ def test_comparison_respects_the_six_run_cap():
220
+ assert bitapp.MAX_COMPARE == 6
221
+
222
+
223
+ def test_precomputed_heatmap_loads_from_the_store():
224
+ heat = comparisons.load_table(runtime.get_store(), comparisons.HEATMAP)
225
+ if heat.empty:
226
+ pytest.skip("comparison tables not generated yet")
227
+ assert {"asset", "strategy", "timeframe", "oos_sharpe"} <= set(heat.columns)
228
+ assert isinstance(charts.strategy_timeframe_heatmap(heat), go.Figure)
229
+
230
+
231
+ def test_regime_breakdown_covers_the_named_regimes(rec):
232
+ df = runtime.regime_breakdown(rec)
233
+ if df.empty:
234
+ pytest.skip("no regime variation in this window")
235
+ assert set(df["regime"]) <= {"BULL", "BEAR", "CHOP"}
236
+
237
+
238
+ # --------------------------------------------------------------------------
239
+ # Coverage map & share links
240
+ # --------------------------------------------------------------------------
241
+
242
+
243
+ def test_coverage_map_renders_from_the_live_manifest():
244
+ df = runtime.coverage_frame()
245
+ assert not df.empty
246
+ assert {"Model", "Asset", "TF", "Coverage", "Rows", "Real?"} <= set(df.columns)
247
+
248
+
249
+ def test_placeholder_slices_are_labelled_in_the_coverage_map():
250
+ df = runtime.coverage_frame()
251
+ assert set(df["Real?"]) <= {"real", "PLACEHOLDER"}
252
+
253
+
254
+ def test_share_link_round_trips():
255
+ req = RunRequest(strategy="RSI Mean Reversion", asset="ETH-USD",
256
+ timeframe="1h", date_range="1Y", params={"rsi_period": 21})
257
+ again = RunRequest.decode(req.encode())
258
+ assert again.strategy == req.strategy
259
+ assert again.asset == req.asset
260
+ assert again.params["rsi_period"] == 21
261
+
262
+
263
+ @pytest.mark.parametrize("payload", [
264
+ '{"strategy":"__import__(\'os\').system","asset":"BTC-USD"}',
265
+ '{"strategy":"SMA Crossover","asset":"../../etc/passwd"}',
266
+ '{"strategy":"SMA Crossover","asset":"BTC-USD","timeframe":"99y"}',
267
+ '{"strategy":"SMA Crossover","asset":"BTC-USD","validation_mode":"eval"}',
268
+ ])
269
+ def test_hostile_share_links_are_rejected(payload):
270
+ import base64
271
+
272
+ token = base64.urlsafe_b64encode(payload.encode()).decode().rstrip("=")
273
+ with pytest.raises(ValueError):
274
+ RunRequest.decode(token)
275
+
276
+
277
+ def test_unavailable_presets_are_listed_but_refuse_to_run():
278
+ assert "Custom (code)" in strategies.PRESETS
279
+ assert not strategies.PRESETS["Custom (code)"].available
280
+ with pytest.raises(runtime.RunError, match="never executes untrusted code"):
281
+ runtime.execute(RunRequest(strategy="Custom (code)", asset="BTC-USD"))
282
+
283
+
284
+ # --------------------------------------------------------------------------
285
+ # Performance budget
286
+ # --------------------------------------------------------------------------
287
+
288
+
289
+ def test_default_three_year_daily_run_is_under_two_seconds():
290
+ runtime.cache_clear()
291
+ req = RunRequest(strategy="SMA Crossover", asset="BTC-USD",
292
+ timeframe="1d", date_range="3Y")
293
+ runtime.execute(req) # warm the slice cache
294
+ import time
295
+
296
+ t0 = time.perf_counter()
297
+ runtime.execute(req)
298
+ assert time.perf_counter() - t0 < 2.0