Spaces:
Running on Zero
Running on Zero
File size: 13,012 Bytes
46f1a78 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 | """Derived comparison tables written into the store's `comparisons/` folder.
These are precomputed so the UI can render the Comparison tab instantly instead
of running inference or a sweep of backtests per page load. They are
regenerated after any coverage extension, so they never describe a stale store.
Four artefacts:
* `model_performance.parquet` — per (model, asset, timeframe): OOS Sharpe,
return and drawdown under the *default* Forecast Follower rule.
* `calibration.parquet` — empirical coverage of the q10-q90 band, plus pinball
losses. Answers "when this model says it is 80% sure, is it?".
* `directional_accuracy.parquet` — the model against three naive baselines.
* `strategy_timeframe_heatmap.parquet` — the strategy x timeframe OOS-Sharpe
matrix behind the Comparison tab's time-scale grid.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
import numpy as np
import pandas as pd
from . import config, strategies
from .engine import BacktestConfig, Costs, Validation, run_backtest
from .metrics import (
calibration_coverage,
calibration_error,
directional_accuracy,
pinball_loss,
)
from .store import SignalStore
log = logging.getLogger("bit.comparisons")
MODEL_PERF = "comparisons/model_performance.parquet"
CALIBRATION = "comparisons/calibration.parquet"
DIRECTIONAL = "comparisons/directional_accuracy.parquet"
HEATMAP = "comparisons/strategy_timeframe_heatmap.parquet"
INDEX_JSON = "comparisons/index.json"
# Strategies shown in the time-scale matrix.
HEATMAP_STRATEGIES = (
"Buy & Hold (benchmark)",
"SMA Crossover",
"RSI Mean Reversion",
"Bollinger Breakout",
"MACD Momentum",
"Sentiment-Gated Momentum",
"Chronos Forecast Follower",
)
def _default_config(asset: str, timeframe: str, strategy: str) -> BacktestConfig:
"""The single canonical config every comparison number is computed under.
Costs on, walk-forward validation, six-month locked holdout. Changing this
changes every published comparison, so it lives in one place.
"""
return BacktestConfig(
asset=asset, timeframe=timeframe, strategy=strategy,
params=strategies.defaults_for(strategy),
costs=Costs(enabled=True),
validation=Validation(mode="walk_forward", train_months=12,
test_months=3, roll_months=3, holdout_months=6),
)
@dataclass
class ComparisonReport:
model_performance: pd.DataFrame
calibration: pd.DataFrame
directional: pd.DataFrame
heatmap: pd.DataFrame
def is_empty(self) -> bool:
return all(df.empty for df in
(self.model_performance, self.calibration, self.directional, self.heatmap))
# --------------------------------------------------------------------------
# Calibration & directional accuracy
# --------------------------------------------------------------------------
def calibration_row(prices: pd.DataFrame, signals: pd.DataFrame,
model_slug: str, asset: str, timeframe: str) -> dict | None:
"""Compare each forecast against the outcome it was predicting.
The forecast stored at `t` is a one-step-ahead prediction, so it is scored
against the close at `t+1`, never against the close at `t`.
"""
if signals.empty or prices.empty:
return None
close = prices["close"]
aligned = signals.reindex(close.index).dropna(subset=["q50"])
if aligned.empty:
return None
actual_next = close.shift(-1).reindex(aligned.index)
valid = actual_next.notna()
if valid.sum() < 20:
return None
actual_next = actual_next[valid]
q10 = aligned.loc[valid.index[valid], "q10"]
q50 = aligned.loc[valid.index[valid], "q50"]
q90 = aligned.loc[valid.index[valid], "q90"]
coverage = calibration_coverage(actual_next, q10, q90)
return {
"model_slug": model_slug, "asset": asset, "timeframe": timeframe,
"n": int(valid.sum()),
"coverage_q10_q90": coverage,
"nominal_coverage": 0.80,
"calibration_error": calibration_error(coverage, 0.80),
"pinball_q10": pinball_loss(actual_next, q10, 0.10),
"pinball_q50": pinball_loss(actual_next, q50, 0.50),
"pinball_q90": pinball_loss(actual_next, q90, 0.90),
"is_placeholder": bool(
(aligned["inference_version"] == config.PLACEHOLDER_VERSION).any()
if "inference_version" in aligned.columns else False
),
}
def directional_row(prices: pd.DataFrame, signals: pd.DataFrame,
model_slug: str, asset: str, timeframe: str,
seed: int = 0) -> dict | None:
"""Model directional hit-rate against three naive baselines.
Baselines: coin-flip (seeded, so the table is reproducible), momentum
(continue the last move), and yesterday's-move repeated. All are computed
causally from data available at the decision bar.
"""
if signals.empty or prices.empty:
return None
close = prices["close"]
aligned = signals.reindex(close.index).dropna(subset=["q50"])
if aligned.empty:
return None
ref = close.reindex(aligned.index)
actual_next = close.shift(-1).reindex(aligned.index)
mask = actual_next.notna() & ref.notna()
if mask.sum() < 20:
return None
ref, actual_next = ref[mask], actual_next[mask]
q50 = aligned.loc[mask.index[mask], "q50"]
prev_move = close.diff().reindex(ref.index).fillna(0.0)
rng = np.random.default_rng(seed)
coin = pd.Series(rng.choice([-1.0, 1.0], size=len(ref)), index=ref.index)
return {
"model_slug": model_slug, "asset": asset, "timeframe": timeframe,
"n": int(mask.sum()),
"model_accuracy": directional_accuracy(actual_next, q50, ref),
"baseline_random": directional_accuracy(actual_next, ref + coin, ref),
"baseline_momentum": directional_accuracy(actual_next, ref + prev_move, ref),
"baseline_yesterday_move": directional_accuracy(
actual_next, ref + prev_move.shift(1).fillna(0.0), ref
),
"is_placeholder": bool(
(aligned["inference_version"] == config.PLACEHOLDER_VERSION).any()
if "inference_version" in aligned.columns else False
),
}
# --------------------------------------------------------------------------
# Backtest-derived tables
# --------------------------------------------------------------------------
def _run(strategy: str, prices: pd.DataFrame, signals: pd.DataFrame | None,
asset: str, timeframe: str):
cfg = _default_config(asset, timeframe, strategy)
out = strategies.build(strategy, prices, cfg.params, signals)
return run_backtest(prices, out, cfg,
bars_per_year=config.bars_per_year(asset, timeframe))
def model_performance_row(prices, signals, model_slug, asset, timeframe) -> dict | None:
"""OOS performance of the default Forecast Follower rule over this model."""
if signals is None or signals.empty:
return None
try:
res = _run("Chronos Forecast Follower", prices, signals, asset, timeframe)
except Exception as e:
log.warning("forecast-follower run failed for %s/%s/%s: %s",
model_slug, asset, timeframe, e)
return None
m = res.metrics_oos
return {
"model_slug": model_slug, "asset": asset, "timeframe": timeframe,
"strategy": "Chronos Forecast Follower",
"oos_sharpe": m.sharpe, "oos_return": m.total_return,
"oos_max_drawdown": m.max_drawdown, "oos_sortino": m.sortino,
"trades": m.trade_count, "win_rate": m.win_rate,
"costs_paid": res.costs_paid,
"holdout_sharpe": res.metrics_holdout.sharpe if res.metrics_holdout else float("nan"),
"is_sharpe": res.metrics_is.sharpe,
"oos_is_ratio": (m.sharpe / res.metrics_is.sharpe)
if res.metrics_is.sharpe not in (0.0, None) else float("nan"),
"is_placeholder": bool(
(signals["inference_version"] == config.PLACEHOLDER_VERSION).any()
if "inference_version" in signals.columns else False
),
}
def heatmap_rows(store: SignalStore, asset: str, model_slug: str | None = None) -> list[dict]:
"""OOS Sharpe for every (strategy, timeframe) pair that has price coverage."""
rows = []
for tf in config.TIMEFRAMES:
prices = store.get_prices(asset, tf)
if len(prices) < 120:
continue
signals = (store.get_signals(model_slug, asset, tf)
if model_slug else pd.DataFrame())
for strategy in HEATMAP_STRATEGIES:
preset = strategies.PRESETS.get(strategy)
if preset is None or not preset.available:
continue
if preset.needs_signals and (signals is None or signals.empty):
rows.append({"asset": asset, "strategy": strategy, "timeframe": tf,
"oos_sharpe": float("nan"), "trades": 0,
"status": "no signal coverage"})
continue
try:
res = _run(strategy, prices, signals, asset, tf)
rows.append({
"asset": asset, "strategy": strategy, "timeframe": tf,
"oos_sharpe": res.metrics_oos.sharpe,
"oos_return": res.metrics_oos.total_return,
"trades": res.metrics_oos.trade_count,
"status": "ok",
})
except Exception as e:
log.warning("heatmap cell failed %s/%s/%s: %s", asset, strategy, tf, e)
rows.append({"asset": asset, "strategy": strategy, "timeframe": tf,
"oos_sharpe": float("nan"), "trades": 0,
"status": f"error: {type(e).__name__}"})
return rows
# --------------------------------------------------------------------------
# Regeneration
# --------------------------------------------------------------------------
def regenerate(store: SignalStore, *, assets: list[str] | None = None,
write: bool = True) -> ComparisonReport:
"""Rebuild every comparison table from what the store currently holds."""
manifest = store.load_manifest()
assets = assets or sorted({e.asset for e in manifest.signals.values()}
| {p.asset for p in manifest.prices.values()})
perf, calib, direc, heat = [], [], [], []
for entry in manifest.signals.values():
if assets and entry.asset not in assets:
continue
prices = store.get_prices(entry.asset, entry.timeframe)
signals = store.get_signals(entry.model_slug, entry.asset, entry.timeframe)
if prices.empty or signals.empty:
continue
r = model_performance_row(prices, signals, entry.model_slug,
entry.asset, entry.timeframe)
if r:
perf.append(r)
r = calibration_row(prices, signals, entry.model_slug, entry.asset, entry.timeframe)
if r:
calib.append(r)
r = directional_row(prices, signals, entry.model_slug, entry.asset, entry.timeframe)
if r:
direc.append(r)
for asset in assets:
best_model = None
candidates = manifest.find_signals(asset=asset)
if candidates:
best_model = candidates[0].model_slug
heat.extend(heatmap_rows(store, asset, best_model))
report = ComparisonReport(
model_performance=pd.DataFrame(perf),
calibration=pd.DataFrame(calib),
directional=pd.DataFrame(direc),
heatmap=pd.DataFrame(heat),
)
if write:
if not report.model_performance.empty:
store.write_table(MODEL_PERF, report.model_performance)
if not report.calibration.empty:
store.write_table(CALIBRATION, report.calibration)
if not report.directional.empty:
store.write_table(DIRECTIONAL, report.directional)
if not report.heatmap.empty:
store.write_table(HEATMAP, report.heatmap)
store.write_json(INDEX_JSON, {
"generated_at": pd.Timestamp.now(tz="UTC").isoformat(),
"assets": list(assets),
"tables": {
"model_performance": {"path": MODEL_PERF, "rows": len(report.model_performance)},
"calibration": {"path": CALIBRATION, "rows": len(report.calibration)},
"directional_accuracy": {"path": DIRECTIONAL, "rows": len(report.directional)},
"strategy_timeframe_heatmap": {"path": HEATMAP, "rows": len(report.heatmap)},
},
"default_rule": "Chronos Forecast Follower, costs on, walk-forward 12/3/3, 6mo holdout",
})
return report
def load_table(store: SignalStore, path: str) -> pd.DataFrame:
df = store.read_parquet(path)
return df if df is not None else pd.DataFrame()
|