#!/usr/bin/env python3 """ HV-RSI (SPY) CAR25 / safe-f evaluation via the CANONICAL edge-risknorm engine. Answers "is the SPY system worth developing?" with the risk-normalized metric the rest of the platform ranks on (RN-BANDY-001 / RN-BANDY-HLC-001): - safe_f : portfolio fraction (fully-invested) that maximizes CAR25 subject to a drawdown constraint - CAR25 : 25th-percentile annualized return at safe_f (the conservative, risk-normalized return — what paperfolio PF-003 / chatx CX-006 step 4 use) Two canonical estimators, both from edge-risknorm (no bespoke MC): 1. monkey.bandy_safe_f — PORTFOLIO-AWARE (batches of concurrent positions, per-position size = f / max_positions). Faithful to the 20-slot system. 2. trade.mc_trade — single-stream Bandy with triangular recency weighting. Cross-check; weights recent trades more. Run on three SPY trade streams (survivorship-bias-free IndexCon membership): IS 2005-2017 (output/SPY_SandP_500/oos_IS/trades.parquet) OOS 2018-2026 (output/SPY_SandP_500/oos_OOS/trades.parquet) Full 2005-2026 (output/SPY_SandP_500/trades.parquet) Trade returns are simple per-trade pnl_pct; the method is scale-invariant. CAR figures here are leverage/sizing-normalized — distinct from the raw fixed-10%-slot CAGR in results.md, and they EXCLUDE cash interest. """ import sys from pathlib import Path import numpy as np import polars as pl sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "shared")) from edgerisknorm.monkey import bandy_safe_f from edgerisknorm.trade import mc_trade OUT = Path(__file__).parent / "output" / "SPY_SandP_500" STREAMS = { "IS (2005-17)": OUT / "oos_IS" / "trades.parquet", "OOS (2018-26)": OUT / "oos_OOS" / "trades.parquet", "Full(2005-26)": OUT / "trades.parquet", } # years per window for trades/year annualization YEARS = {"IS (2005-17)": 13.0, "OOS (2018-26)": 8.2, "Full(2005-26)": 21.2} def main(): rows = [] for label, path in STREAMS.items(): if not path.exists(): print(f" MISSING: {path}") continue df = pl.read_parquet(path) r = df["pnl_pct"].to_numpy().astype(float) r = r[np.isfinite(r)] n = len(r) tpy = max(1.0, n / YEARS[label]) # 1. Canonical portfolio-aware Bandy (RN-BANDY-001, dd_pctile=5 v2 default) b = bandy_safe_f( r, n_trials=2000, trades_per_trial=500, trades_per_year=tpy, max_positions=20, dd_constraint=-0.20, dd_pctile=5, seed=42, ) # 2. Recency-weighted single-stream cross-check mc = mc_trade(r, hold_days=4) rows.append({ "window": label, "n_trades": n, "win_rate": round(float((r > 0).mean()), 4), "mean_ret": round(float(r.mean()), 4), "trades_per_yr": round(tpy, 1), # portfolio-aware Bandy "safe_f": round(b.safe_f, 3), "CAR25": round(b.car25, 4), "CAR50": round(b.car50, 4), "CAR75": round(b.car75, 4), "maxDD_p5": round(b.max_dd_at_constraint_pctile, 4), # recency-weighted cross-check "mc_safe_f": round(mc.safe_f, 3), "mc_CAR25": round(mc.car25, 4), "mc_dd95": round(mc.dd_95, 4), }) print(f"\n{'='*60}\n{label} — {n} trades, {tpy:.1f}/yr, WR {(r>0).mean()*100:.1f}%") print(f"{'='*60}") print(" Portfolio-aware Bandy (RN-BANDY-001, dd_pctile=5):") print(f" safe_f : {b.safe_f*100:6.1f}% (per-position {b.safe_f/20*100:.2f}%)") print(f" CAR25 : {b.car25*100:+6.2f}% <- risk-normalized return") print(f" CAR50 : {b.car50*100:+6.2f}%") print(f" CAR75 : {b.car75*100:+6.2f}%") print(f" maxDD@p5 : {b.max_dd_at_constraint_pctile*100:+6.2f}%") print(" Recency-weighted single-stream (mc_trade):") print(f" safe_f : {mc.safe_f*100:6.1f}%") print(f" CAR25 : {mc.car25*100:+6.2f}%") print(f" dd_95 : {mc.dd_95*100:+6.2f}%") summary = pl.DataFrame(rows) print(f"\n{'='*60}\nSUMMARY — SPY CAR25 / safe-f (canonical edge-risknorm)\n{'='*60}") with pl.Config(tbl_cols=-1, tbl_width_chars=220): print(summary) out = OUT / "car25_eval.parquet" summary.write_parquet(out) print(f"\nSaved -> {out}") if __name__ == "__main__": main()