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