Spaces:
Running on Zero
Running on Zero
Backtest Lab v1.0.0
Browse files- .env.example +9 -0
- .gitattributes +9 -0
- .gitignore +8 -0
- DESIGN_NOTES.md +124 -0
- README.md +170 -6
- app.py +871 -0
- assets/bit-trading-mark.svg +3 -0
- assets/fonts/MacMinecraft.ttf +3 -0
- assets/fonts/StyreneA-Light.otf +3 -0
- assets/fonts/StyreneA-LightItalic.otf +3 -0
- assets/fonts/StyreneA-Medium.otf +3 -0
- assets/fonts/StyreneA-MediumItalic.otf +3 -0
- assets/fonts/StyreneA-Regular.otf +3 -0
- assets/fonts/StyreneA-RegularItalic.otf +3 -0
- assets/fonts/StyreneA-Thin.otf +3 -0
- assets/fonts/StyreneA-ThinItalic.otf +3 -0
- assets/tokens/base.css +33 -0
- assets/tokens/colors.css +125 -0
- assets/tokens/fonts.css +10 -0
- assets/tokens/spacing.css +29 -0
- assets/tokens/typography.css +38 -0
- conftest.py +2 -0
- pytest.ini +6 -0
- requirements.txt +32 -0
- scripts/seed_store.py +318 -0
- src/__init__.py +0 -0
- src/adapters.py +367 -0
- src/charts.py +615 -0
- src/comparisons.py +324 -0
- src/config.py +207 -0
- src/data.py +458 -0
- src/engine.py +707 -0
- src/extension.py +385 -0
- src/metrics.py +263 -0
- src/runtime.py +480 -0
- src/store.py +760 -0
- src/strategies.py +348 -0
- src/ui/__init__.py +0 -0
- src/ui/theme.py +282 -0
- tests/__init__.py +0 -0
- tests/run_all.sh +25 -0
- tests/test_adapters.py +345 -0
- tests/test_data.py +270 -0
- tests/test_engine.py +506 -0
- tests/test_extension.py +307 -0
- tests/test_store.py +361 -0
- tests/test_ui.py +298 -0
.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:
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version:
|
| 8 |
-
python_version:
|
| 9 |
app_file: app.py
|
| 10 |
-
pinned:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|