You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

194 lines
6.7 KiB
Python

"""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,
}