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.
fithia2/scripts/ensemble_overlay_backtest.py

161 lines
6.0 KiB
Python

#!/usr/bin/env python3
"""Research-only dynamic ensemble overlay from strategy equity curves.
This does not route orders through the main backtester. It combines already
realized daily return series from multiple strategy runs using lagged rolling
Sharpe weights.
"""
from __future__ import annotations
import argparse
import json
from dataclasses import dataclass
from pathlib import Path
import numpy as np
import pandas as pd
TRADING_DAYS_PER_YEAR = 252.0
@dataclass(frozen=True)
class CurveInput:
label: str
path: Path
def _parse_curve_arg(value: str) -> CurveInput:
if "=" not in value:
raise argparse.ArgumentTypeError("curve must be LABEL=/abs/path/to/daily_equity_curve.parquet")
label, raw_path = value.split("=", 1)
label = label.strip()
path = Path(raw_path.strip())
if not label:
raise argparse.ArgumentTypeError("curve label must be non-empty")
if not path.exists():
raise argparse.ArgumentTypeError(f"curve file not found: {path}")
return CurveInput(label=label, path=path)
def _load_curve(curve: CurveInput) -> pd.DataFrame:
df = pd.read_parquet(curve.path)
if "date" not in df.columns or "equity" not in df.columns:
raise ValueError(f"{curve.path} must contain date/equity columns")
out = df[["date", "equity"]].copy()
out["date"] = pd.to_datetime(out["date"])
out = out.sort_values("date")
out[curve.label] = out["equity"].pct_change().fillna(0.0)
return out[["date", curve.label]]
def _rolling_sharpe(series: pd.Series, window: int) -> pd.Series:
mean = series.rolling(window, min_periods=window).mean()
std = series.rolling(window, min_periods=window).std(ddof=0)
sharpe = mean / std.replace(0.0, np.nan)
return sharpe * np.sqrt(TRADING_DAYS_PER_YEAR)
def build_ensemble_returns(
daily_returns: pd.DataFrame,
window: int = 63,
fallback: str = "equal",
) -> tuple[pd.DataFrame, pd.DataFrame]:
strategy_cols = [c for c in daily_returns.columns if c != "date"]
if len(strategy_cols) < 2:
raise ValueError("need at least two strategy return series")
weights = pd.DataFrame({"date": daily_returns["date"]})
rolling = {}
for col in strategy_cols:
rolling[col] = _rolling_sharpe(daily_returns[col], window).shift(1)
sharpes = pd.DataFrame({"date": daily_returns["date"], **rolling})
positive = sharpes[strategy_cols].clip(lower=0.0)
positive_sum = positive.sum(axis=1)
if fallback == "equal":
fallback_weights = pd.DataFrame(
np.full((len(daily_returns), len(strategy_cols)), 1.0 / len(strategy_cols)),
columns=strategy_cols,
)
else:
raise ValueError(f"unsupported fallback: {fallback}")
normalized = positive.div(positive_sum.replace(0.0, np.nan), axis=0)
resolved = normalized.where(positive_sum.gt(0), fallback_weights)
weights = pd.concat([weights, resolved], axis=1)
ensemble = daily_returns.copy()
ensemble["ensemble_return"] = 0.0
for col in strategy_cols:
ensemble["ensemble_return"] += ensemble[col] * weights[col]
return ensemble, weights
def summarize_ensemble(ensemble_returns: pd.DataFrame, initial_equity: float) -> dict:
out = ensemble_returns[["date", "ensemble_return"]].copy()
out["equity"] = initial_equity * (1.0 + out["ensemble_return"]).cumprod()
out["peak"] = out["equity"].cummax()
out["drawdown_pct"] = (out["equity"] / out["peak"] - 1.0) * 100.0
total_return_pct = (out["equity"].iloc[-1] / out["equity"].iloc[0] - 1.0) * 100.0
years = max(len(out) / TRADING_DAYS_PER_YEAR, 1.0 / TRADING_DAYS_PER_YEAR)
annualized_return_pct = ((out["equity"].iloc[-1] / out["equity"].iloc[0]) ** (1.0 / years) - 1.0) * 100.0
daily_mean = out["ensemble_return"].mean()
daily_std = out["ensemble_return"].std(ddof=0)
sharpe = float(daily_mean / daily_std * np.sqrt(TRADING_DAYS_PER_YEAR)) if daily_std > 0 else 0.0
out["year"] = out["date"].dt.year
yearly_returns = {}
for year, year_df in out.groupby("year"):
yearly_returns[str(year)] = (
(1.0 + year_df["ensemble_return"]).prod() - 1.0
) * 100.0
return {
"total_return_pct": total_return_pct,
"annualized_return_pct": annualized_return_pct,
"max_drawdown_pct": float(-out["drawdown_pct"].min()),
"sharpe_ratio": sharpe,
"days": int(len(out)),
"yearly_returns_pct": yearly_returns,
}
def main() -> int:
parser = argparse.ArgumentParser(description="Research-only rolling-Sharpe ensemble overlay")
parser.add_argument("--curve", action="append", type=_parse_curve_arg, required=True, help="LABEL=/abs/path/to/daily_equity_curve.parquet")
parser.add_argument("--window", type=int, default=63, help="Rolling Sharpe lookback in trading days")
parser.add_argument("--initial-equity", type=float, default=100000.0)
parser.add_argument("--fallback", choices=["equal"], default="equal")
parser.add_argument("--output-dir", required=True, help="Directory to write ensemble artifacts")
args = parser.parse_args()
curves = args.curve
merged = None
for curve in curves:
loaded = _load_curve(curve)
merged = loaded if merged is None else merged.merge(loaded, on="date", how="inner")
if merged is None or merged.empty:
raise SystemExit("no overlapping dates across curves")
ensemble, weights = build_ensemble_returns(merged, window=args.window, fallback=args.fallback)
summary = summarize_ensemble(ensemble, args.initial_equity)
out_dir = Path(args.output_dir)
out_dir.mkdir(parents=True, exist_ok=True)
ensemble_out = ensemble[["date", "ensemble_return"]].copy()
ensemble_out["equity"] = args.initial_equity * (1.0 + ensemble_out["ensemble_return"]).cumprod()
ensemble_out.to_parquet(out_dir / "daily_equity_curve.parquet", index=False)
weights.to_parquet(out_dir / "weights.parquet", index=False)
(out_dir / "summary.json").write_text(json.dumps(summary, indent=2))
print(json.dumps(summary, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())