"""Evaluate deterministic book overlays from daily equity curves.""" from __future__ import annotations import datetime as dt import math from pathlib import Path from typing import Any import pandas as pd def load_merged_store_from_snapshot_dir( snapshot_dir: str | Path, *, oracle_url: str, db_dsn: str, ): """Load and merge train/valid/test splits from an explicit snapshot directory.""" from libs.backtest.snapshot_store import SnapshotStore base = Path(snapshot_dir) stores = [] for split in ("train", "valid", "test"): if not (base / f"{split}.parquet").exists(): continue stores.append( SnapshotStore.load( snapshot_dir=base, split_name=split, oracle_url=oracle_url, db_dsn=db_dsn, ) ) if not stores: raise FileNotFoundError(f"No snapshot splits found under {base}") merged_candidates: dict[dt.date, dict[tuple[Any, ...], dict[str, Any]]] = {} merged_bars: dict[str, dict[dt.date, dict[str, Any]]] = {} merged_macro: dict[dt.date, dict[str, Any]] = {} for store in stores: for exec_date in store.all_execution_dates(): bucket = merged_candidates.setdefault(exec_date, {}) for candidate in store.get_candidates_for_date(exec_date): dedupe_key = ( candidate.get("event_id"), candidate.get("symbol"), candidate.get("execution_date"), candidate.get("reaction_date"), ) bucket.setdefault(dedupe_key, candidate) for symbol, bars in store._bars.items(): merged_bars.setdefault(symbol, {}).update(bars) for macro_date, macro_values in store._macro.items(): merged_macro.setdefault(macro_date, {}).update(macro_values) from libs.backtest.snapshot_store import SnapshotStore return SnapshotStore( candidates_by_exec_date={ date: list(rows.values()) for date, rows in merged_candidates.items() }, bars_by_symbol_date=merged_bars, macro_by_date=merged_macro, ) def load_equity_curve_csv(path: str | Path) -> pd.DataFrame: """Load a paper backtest equity CSV into a normalized daily returns frame.""" csv_path = Path(path) df = pd.read_csv(csv_path, parse_dates=["date"]) required = {"date", "equity"} missing = required.difference(df.columns) if missing: raise ValueError(f"Missing columns in {csv_path}: {sorted(missing)}") if df.empty: raise ValueError(f"Equity CSV has no rows: {csv_path}") df = df.sort_values("date").copy() df["date"] = pd.to_datetime(df["date"]).dt.date df["equity"] = df["equity"].astype(float) df["daily_return"] = df["equity"].pct_change().fillna(0.0) return df[["date", "equity", "daily_return"]] def validate_allocations( allocations: dict[str, dict[str, float]], labels: set[str], *, tolerance: float = 1e-6, ) -> None: """Ensure each regime allocation references known labels and sums to 1.""" if not allocations: raise ValueError("allocations must not be empty") if "unknown" not in allocations: raise ValueError("allocations must include an 'unknown' regime") for regime, weights in allocations.items(): unknown = set(weights).difference(labels) if unknown: raise ValueError( f"Allocation for regime '{regime}' references unknown labels: {sorted(unknown)}" ) total = sum(float(weight) for weight in weights.values()) if abs(total - 1.0) > tolerance: raise ValueError( f"Allocation for regime '{regime}' must sum to 1.0, got {total:.6f}" ) def build_overlay_curve( *, curves: dict[str, pd.DataFrame], allocations: dict[str, dict[str, float]], regimes_by_date: dict[dt.date, str], initial_equity: float = 10_000.0, ) -> pd.DataFrame: """Combine per-book daily returns into a single overlay equity curve.""" labels = set(curves) if not labels: raise ValueError("curves must not be empty") validate_allocations(allocations, labels) merged: pd.DataFrame | None = None for label, df in curves.items(): renamed = df.rename( columns={ "equity": f"equity_{label}", "daily_return": f"daily_return_{label}", } ) frame = renamed[["date", f"daily_return_{label}"]] merged = frame if merged is None else merged.merge(frame, on="date", how="inner") if merged is None or merged.empty: raise ValueError("No overlapping dates across curves") merged = merged.sort_values("date").copy() merged["regime"] = merged["date"].map(regimes_by_date).fillna("unknown") overlay_returns: list[float] = [] for row in merged.itertuples(index=False): weights = allocations.get(row.regime, allocations["unknown"]) ret = 0.0 for label in labels: ret += float(weights.get(label, 0.0)) * float(getattr(row, f"daily_return_{label}")) overlay_returns.append(ret) merged["overlay_return"] = overlay_returns equity = initial_equity overlay_equity: list[float] = [] for ret in overlay_returns: equity *= 1.0 + float(ret) overlay_equity.append(equity) merged["overlay_equity"] = overlay_equity return merged[["date", "regime", "overlay_return", "overlay_equity"]] def summarize_overlay_curve(curve: pd.DataFrame, *, initial_equity: float) -> dict[str, Any]: """Return total return, drawdown, Sharpe, and regime counts for an overlay.""" if curve.empty: raise ValueError("curve must not be empty") final_equity = float(curve["overlay_equity"].iloc[-1]) total_return_pct = (final_equity / float(initial_equity) - 1.0) * 100.0 peak = float(initial_equity) max_drawdown_pct = 0.0 for equity in curve["overlay_equity"]: peak = max(peak, float(equity)) drawdown_pct = (peak - float(equity)) / peak * 100.0 if peak > 0 else 0.0 max_drawdown_pct = max(max_drawdown_pct, drawdown_pct) rets = curve["overlay_return"].astype(float) if len(rets) >= 2 and float(rets.std()) > 0: sharpe = float(rets.mean() / rets.std() * math.sqrt(252.0)) else: sharpe = 0.0 regime_counts = { str(regime): int(count) for regime, count in curve["regime"].value_counts().sort_index().items() } return { "return_pct": total_return_pct, "max_dd_pct": max_drawdown_pct, "sharpe": sharpe, "final_equity": final_equity, "day_count": int(len(curve)), "regime_day_counts": regime_counts, }