"""Portfolio contribution analysis for multi-engine ORB strategies. Runs two strategy sleeves independently, then computes portfolio-level metrics that measure how well the new engine diversifies V23: - Daily PnL correlation - Trade overlap (same ticker+date) - Combined equity curve and drawdown - Worst-20% day relief (how much the new engine helps on V23's bad days) Usage: python -m apps.intraday_bt.portfolio_report \\ --base configs/intraday/strategies/orb_gainers_v23.yaml \\ --new configs/intraday/strategies/orb_pullback_v1.yaml \\ --days 600 """ from __future__ import annotations import argparse import asyncio import math import statistics from pathlib import Path from apps.intraday_bt.composite import _build_sleeve_config from apps.intraday_bt.run import load_config, run as run_sleeve # ── Stats helpers ───────────────────────────────────────────────────────── def _pearson_corr(xs: list[float], ys: list[float]) -> float: n = len(xs) if n < 2: return float("nan") mx, my = statistics.mean(xs), statistics.mean(ys) cov = sum((x - mx) * (y - my) for x, y in zip(xs, ys)) / (n - 1) sx = statistics.stdev(xs) sy = statistics.stdev(ys) if sx == 0 or sy == 0: return float("nan") return cov / (sx * sy) def _max_drawdown(equity_curve: list[float]) -> float: peak = equity_curve[0] max_dd = 0.0 for eq in equity_curve: if eq > peak: peak = eq dd = (eq - peak) / peak if dd < max_dd: max_dd = dd return max_dd def _build_equity_curve(dates: list[str], pnl_by_date: dict[str, float], initial: float) -> list[float]: curve = [initial] eq = initial for d in dates: eq += pnl_by_date.get(d, 0.0) curve.append(eq) return curve # ── Analysis ────────────────────────────────────────────────────────────── def analyze( base_day_results: list, new_day_results: list, base_name: str, new_name: str, base_capital: float, new_capital: float, ) -> None: # Build daily PnL dicts base_pnl: dict[str, float] = {} new_pnl: dict[str, float] = {} base_trades_by_date: dict[str, list] = {} new_trades_by_date: dict[str, list] = {} for dr in base_day_results: base_pnl[dr.date] = dr.daily_pnl base_trades_by_date[dr.date] = dr.trades or [] for dr in new_day_results: new_pnl[dr.date] = dr.daily_pnl new_trades_by_date[dr.date] = dr.trades or [] all_dates = sorted(set(base_pnl.keys()) | set(new_pnl.keys())) if not all_dates: print("No data to analyze.") return # Daily PnL vectors (only dates where BOTH sleeves traded or had any activity) common_dates = sorted(set(base_pnl.keys()) & set(new_pnl.keys())) # Correlation if common_dates: xs = [base_pnl[d] for d in common_dates] ys = [new_pnl[d] for d in common_dates] corr = _pearson_corr(xs, ys) else: corr = float("nan") # Trade overlap: same (ticker, date) pairs base_pairs: set[tuple[str, str]] = set() new_pairs: set[tuple[str, str]] = set() for date, trades in base_trades_by_date.items(): for t in trades: base_pairs.add((t.ticker, date)) for date, trades in new_trades_by_date.items(): for t in trades: new_pairs.add((t.ticker, date)) overlap_count = len(base_pairs & new_pairs) overlap_pct = overlap_count / min(len(base_pairs), len(new_pairs)) if min(len(base_pairs), len(new_pairs)) > 0 else 0.0 # Combined equity curve and DD total_capital = base_capital + new_capital combined_pnl_by_date = {d: base_pnl.get(d, 0.0) + new_pnl.get(d, 0.0) for d in all_dates} base_curve = _build_equity_curve(all_dates, base_pnl, base_capital) new_curve = _build_equity_curve(all_dates, new_pnl, new_capital) combined_curve = _build_equity_curve(all_dates, combined_pnl_by_date, total_capital) base_dd = _max_drawdown(base_curve) new_dd = _max_drawdown(new_curve) combined_dd = _max_drawdown(combined_curve) base_return = (base_curve[-1] - base_capital) / base_capital new_return = (new_curve[-1] - new_capital) / new_capital combined_return = (combined_curve[-1] - total_capital) / total_capital # Worst-20% day relief base_trading_dates = [d for d in all_dates if d in base_pnl] if base_trading_dates: sorted_by_pnl = sorted(base_trading_dates, key=lambda d: base_pnl[d]) n_worst = max(1, len(sorted_by_pnl) // 5) worst_dates = sorted_by_pnl[:n_worst] base_worst_sum = sum(base_pnl[d] for d in worst_dates) combined_worst_sum = sum(base_pnl.get(d, 0.0) + new_pnl.get(d, 0.0) for d in worst_dates) relief_pct = (combined_worst_sum - base_worst_sum) / base_capital if base_capital > 0 else 0.0 else: worst_dates = [] base_worst_sum = combined_worst_sum = relief_pct = 0.0 # Sharpe def _sharpe(curve: list[float], capital: float) -> float: rets = [(curve[i + 1] - curve[i]) / curve[i] for i in range(len(curve) - 1) if curve[i] > 0] if len(rets) < 2: return 0.0 return statistics.mean(rets) / statistics.stdev(rets) * math.sqrt(252) # Trade counts base_trade_count = sum(len(v) for v in base_trades_by_date.values()) new_trade_count = sum(len(v) for v in new_trades_by_date.values()) base_wr = ( sum(1 for v in base_trades_by_date.values() for t in v if t.pnl > 0) / base_trade_count if base_trade_count > 0 else 0.0 ) new_wr = ( sum(1 for v in new_trades_by_date.values() for t in v if t.pnl > 0) / new_trade_count if new_trade_count > 0 else 0.0 ) # Print report print("\n" + "=" * 65) print(" PORTFOLIO CONTRIBUTION REPORT") print("=" * 65) print(f" Period: {all_dates[0]} → {all_dates[-1]} ({len(all_dates)} calendar days)") print() print(f" {'Metric':<35} {'Base':>10} {'New':>10} {'Combined':>10}") print(f" {'-'*35} {'-'*10} {'-'*10} {'-'*10}") print(f" {'Capital':<35} {'${:,.0f}'.format(base_capital):>10} {'${:,.0f}'.format(new_capital):>10} {'${:,.0f}'.format(total_capital):>10}") print(f" {'Total return':<35} {base_return*100:>+9.2f}% {new_return*100:>+9.2f}% {combined_return*100:>+9.2f}%") print(f" {'Sharpe':<35} {_sharpe(base_curve, base_capital):>10.3f} {_sharpe(new_curve, new_capital):>10.3f} {_sharpe(combined_curve, total_capital):>10.3f}") print(f" {'Max DD':<35} {base_dd*100:>+9.2f}% {new_dd*100:>+9.2f}% {combined_dd*100:>+9.2f}%") print(f" {'Trades':<35} {base_trade_count:>10} {new_trade_count:>10} {base_trade_count+new_trade_count:>10}") print(f" {'Win rate':<35} {base_wr*100:>9.1f}% {new_wr*100:>9.1f}%") print() print(f" Daily PnL correlation (common dates: {len(common_dates)}): {corr:+.4f}") print(f" {' → Gate: corr ≤ 0.30':<45} {'PASS' if corr <= 0.30 else 'FAIL':>5}") print() print(f" Trade overlap: {overlap_count} shared (ticker, date) pairs") print(f" = {overlap_pct*100:.1f}% of min(|base|, |new|) trade set") print(f" {' → Gate: overlap ≤ 20%':<45} {'PASS' if overlap_pct <= 0.20 else 'FAIL':>5}") print() print(f" DD improvement (combined vs base): {(combined_dd - base_dd)*100:+.2f}pp") print(f" {' → Gate: DD improvement ≥ 5pp':<45} {'PASS' if combined_dd - base_dd <= -0.05 else 'FAIL':>5}") print() print(f" Worst-20% day relief ({len(worst_dates)} days):") print(f" Base worst sum: ${base_worst_sum:+.2f}") print(f" Combined worst sum: ${combined_worst_sum:+.2f}") print(f" Relief: {relief_pct*100:+.2f}% of base capital") print(f" {' → Gate: combined > base on worst days':<45} {'PASS' if combined_worst_sum > base_worst_sum else 'FAIL':>5}") print() print(" G1 standalone gates (new engine):") print(f" trades ≥ 50: {new_trade_count:4d} {'PASS' if new_trade_count >= 50 else 'FAIL'}") print(f" WR ≥ 50%: {new_wr*100:5.1f}% {'PASS' if new_wr >= 0.50 else 'FAIL'}") print(f" return ≥ 0%: {new_return*100:+5.2f}% {'PASS' if new_return >= 0 else 'FAIL'}") print(f" max_dd ≥ -20%: {new_dd*100:+5.2f}% {'PASS' if new_dd >= -0.20 else 'FAIL'}") print("=" * 65) # ── Entry point ─────────────────────────────────────────────────────────── async def _main(args: argparse.Namespace) -> None: days = args.days total_cap = args.total_capital base_weight = args.base_weight new_weight = 1.0 - base_weight base_config = load_config(args.base) new_config = load_config(args.new) base_capital = total_cap * base_weight new_capital = total_cap * new_weight print(f"\nRunning base sleeve: {Path(args.base).stem} (capital ${base_capital:,.0f})") base_sleeve = _build_sleeve_config(base_config, base_capital, days) base_day_results, base_metrics, _, _ = await run_sleeve(base_sleeve) print(f"\nRunning new sleeve: {Path(args.new).stem} (capital ${new_capital:,.0f})") new_sleeve = _build_sleeve_config(new_config, new_capital, days) new_day_results, new_metrics, _, _ = await run_sleeve(new_sleeve) analyze( base_day_results=base_day_results, new_day_results=new_day_results, base_name=Path(args.base).stem, new_name=Path(args.new).stem, base_capital=base_capital, new_capital=new_capital, ) def main() -> None: parser = argparse.ArgumentParser(description="Portfolio contribution report for multi-engine ORB") parser.add_argument("--base", required=True, help="Base strategy YAML (e.g. V23)") parser.add_argument("--new", required=True, help="New engine YAML (e.g. orb_pullback_v1)") parser.add_argument("--days", type=int, default=600) parser.add_argument("--total-capital", type=float, default=10_000.0, dest="total_capital") parser.add_argument("--base-weight", type=float, default=0.60, dest="base_weight", help="Fraction of capital allocated to base sleeve (default 0.60)") args = parser.parse_args() asyncio.run(_main(args)) if __name__ == "__main__": main()