"""Multi-sleeve composite intraday backtester. Runs N independent strategy sleeves, each with proportional capital allocation, then combines results into a unified portfolio report. Directly ports the PEAD composed_gld multi-sleeve approach to intraday ORB strategies. Usage: python -m apps.intraday_bt.composite \\ --sleeve configs/intraday/strategies/orb_gainers_v16.yaml:0.60 \\ --sleeve configs/intraday/strategies/orb_compression.yaml:0.25 \\ --sleeve configs/intraday/strategies/orb_stocks_in_play_v17a.yaml:0.15 \\ --total-capital 10000 \\ --days 200 """ from __future__ import annotations import argparse import asyncio import json import math import statistics import uuid from collections import defaultdict from datetime import datetime from pathlib import Path from apps.intraday_bt.run import load_config, run as run_sleeve from libs.intraday.domain import BacktestParams, IntradayConfig, ORBStrategyParams # ── Merging ──────────────────────────────────────────────────────────────── def _merge_day_results( sleeve_results: list[tuple[list, str, float]], ) -> tuple[dict[str, float], dict[str, list], list[str]]: """Combine day-level PnL and trades from all sleeves. Returns: daily_pnl: {date: combined dollar PnL} daily_trades: {date: list of all sleeve trades} sorted dates """ combined_pnl: dict[str, float] = defaultdict(float) combined_trades: dict[str, list] = defaultdict(list) all_dates: set[str] = set() for day_results, _name, _capital in sleeve_results: for dr in day_results: all_dates.add(dr.date) combined_pnl[dr.date] += dr.daily_pnl combined_trades[dr.date].extend(dr.trades) return dict(combined_pnl), dict(combined_trades), sorted(all_dates) # ── Metrics ──────────────────────────────────────────────────────────────── def _compute_composite_metrics( daily_pnl: dict[str, float], daily_trades: dict[str, list], dates: list[str], total_capital: float, run_id: str = "", ) -> dict: """Compute portfolio-level metrics from merged sleeve results.""" if not dates: return {} # Equity curve (each sleeve already compounds internally) equity = total_capital equity_curve: list[float] = [equity] daily_returns: list[float] = [] for date in dates: pnl = daily_pnl.get(date, 0.0) daily_ret = pnl / equity if equity > 0 else 0.0 daily_returns.append(daily_ret) equity += pnl equity_curve.append(equity) final_equity = equity total_return = (final_equity - total_capital) / total_capital # Annualized return (252 trading days per year) n_days = len(dates) annualized = (1 + total_return) ** (252 / n_days) - 1 if n_days > 0 else 0.0 # Sharpe (annualized daily Sharpe) if len(daily_returns) > 1: mean_ret = statistics.mean(daily_returns) std_ret = statistics.stdev(daily_returns) sharpe = (mean_ret / std_ret * math.sqrt(252)) if std_ret > 0 else 0.0 else: sharpe = 0.0 # Sortino (downside deviation) downside = [r for r in daily_returns if r < 0] if downside: downside_std = math.sqrt(sum(r ** 2 for r in downside) / len(daily_returns)) mean_ret_s = statistics.mean(daily_returns) sortino = (mean_ret_s / downside_std * math.sqrt(252)) if downside_std > 0 else 0.0 else: sortino = float("inf") # Max drawdown peak = total_capital 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 # Calmar calmar = (annualized / abs(max_dd)) if max_dd != 0 else float("inf") # Trade stats (combined) all_trades = [t for date in dates for t in daily_trades.get(date, [])] wins = [t for t in all_trades if t.pnl > 0] losses = [t for t in all_trades if t.pnl <= 0] win_rate = len(wins) / len(all_trades) if all_trades else 0.0 gross_profit = sum(t.pnl for t in wins) gross_loss = abs(sum(t.pnl for t in losses)) profit_factor = gross_profit / gross_loss if gross_loss > 0 else float("inf") days_with_trades = sum(1 for date in dates if daily_trades.get(date)) return { "run_id": run_id or str(uuid.uuid4())[:8], "start_date": dates[0], "end_date": dates[-1], "trading_days": n_days, "days_with_trades": days_with_trades, "total_return_pct": total_return, "annualized_return_pct": annualized, "sharpe_ratio": sharpe, "sortino_ratio": sortino, "calmar_ratio": calmar, "max_drawdown_pct": max_dd, "total_trades": len(all_trades), "win_rate": win_rate, "profit_factor": profit_factor, "initial_capital": total_capital, "final_equity": final_equity, } # ── Sleeve runner ────────────────────────────────────────────────────────── def _build_sleeve_config( base_config: IntradayConfig, capital: float, days: int | None, ) -> IntradayConfig: """Clone config with adjusted initial_capital and optional lookback override.""" orb = base_config.orb_strategy if orb is None: raise ValueError("Composite only supports orb strategy_mode sleeves.") orb_dict = orb.model_dump() orb_dict["initial_capital"] = capital new_orb = ORBStrategyParams(**orb_dict) bt_dict = base_config.backtest.model_dump() if days is not None: bt_dict["lookback_trading_days"] = days return IntradayConfig( strategy_mode=base_config.strategy_mode, strategy=base_config.strategy, orb_strategy=new_orb, universe=base_config.universe, backtest=BacktestParams(**bt_dict), cache=base_config.cache, output=base_config.output, ) # ── Reporting ────────────────────────────────────────────────────────────── def _format_composite_summary( metrics: dict, sleeve_summaries: list[dict], total_capital: float, ) -> str: lines = [] lines.append("\n" + "=" * 60) lines.append(" COMPOSITE PORTFOLIO RESULTS") lines.append("=" * 60) lines.append(f" Period: {metrics['start_date']} → {metrics['end_date']}") lines.append(f" Capital: ${total_capital:,.0f}") lines.append(f" Final equity: ${metrics['final_equity']:,.2f}") lines.append("") lines.append(f" Total return: {metrics['total_return_pct']*100:+.2f}%") lines.append(f" Ann. return: {metrics['annualized_return_pct']*100:+.2f}%") lines.append(f" Sharpe: {metrics['sharpe_ratio']:.3f}") lines.append(f" Sortino: {metrics['sortino_ratio']:.3f}") lines.append(f" Max DD: {metrics['max_drawdown_pct']*100:.2f}%") lines.append(f" Calmar: {metrics['calmar_ratio']:.2f}") lines.append(f" Trades: {metrics['total_trades']}") lines.append(f" Win rate: {metrics['win_rate']*100:.1f}%") lines.append(f" Profit factor: {metrics['profit_factor']:.3f}") lines.append("") lines.append(" Sleeve breakdown:") for s in sleeve_summaries: lines.append( f" {s['name']:40s} {s['weight']:4.0%} | " f"{s['return_pct']*100:+6.2f}% | Sharpe {s['sharpe']:.2f} | " f"DD {s['max_dd']*100:.2f}%" ) lines.append("=" * 60) return "\n".join(lines) # ── Main ─────────────────────────────────────────────────────────────────── async def run_composite( sleeve_specs: list[tuple[str, float]], total_capital: float = 10_000.0, days: int | None = None, ) -> dict: """Run all sleeves sequentially and return composite metrics.""" # Normalize weights total_w = sum(w for _, w in sleeve_specs) sleeve_specs = [(p, w / total_w) for p, w in sleeve_specs] sleeve_results: list[tuple[list, str, float]] = [] sleeve_summaries: list[dict] = [] for config_path, weight in sleeve_specs: capital = total_capital * weight name = Path(config_path).stem print(f"\n{'='*60}") print(f" Sleeve: {name} ({weight:.0%} — ${capital:,.0f})") print("=" * 60) base_config = load_config(config_path) sleeve_config = _build_sleeve_config(base_config, capital, days) day_results, metrics, _, _ = await run_sleeve(sleeve_config) sleeve_results.append((day_results, name, capital)) sleeve_summaries.append({ "name": name, "weight": weight, "return_pct": metrics.total_return_pct or 0.0, "sharpe": metrics.sharpe_ratio or 0.0, "max_dd": metrics.max_drawdown_pct or 0.0, "trades": metrics.total_trades, }) print(f" → {name}: {(metrics.total_return_pct or 0)*100:+.2f}% | " f"Sharpe {metrics.sharpe_ratio:.2f} | DD {(metrics.max_drawdown_pct or 0)*100:.2f}%") # Merge and compute daily_pnl, daily_trades, dates = _merge_day_results(sleeve_results) run_id = str(uuid.uuid4())[:8] composite_metrics = _compute_composite_metrics( daily_pnl, daily_trades, dates, total_capital, run_id=run_id ) print(_format_composite_summary(composite_metrics, sleeve_summaries, total_capital)) # Save results out_dir = Path("runs/intraday_orb") out_dir.mkdir(parents=True, exist_ok=True) ts = datetime.now().strftime("%Y%m%d_%H%M%S") out_file = out_dir / f"composite_{ts}_{run_id}.json" out_file.write_text(json.dumps({ "run_id": run_id, "generated_at": datetime.now().isoformat(), "total_capital": total_capital, "sleeves": sleeve_summaries, "metrics": composite_metrics, }, indent=2, default=str)) print(f"\nResults saved to: {out_file}") return composite_metrics def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Composite multi-sleeve intraday backtester") parser.add_argument( "--sleeve", dest="sleeves", action="append", default=[], metavar="CONFIG:WEIGHT", help="Sleeve config path and weight, e.g. orb_v16.yaml:0.60 (can repeat)", ) parser.add_argument("--total-capital", type=float, default=10_000.0, dest="total_capital") parser.add_argument("--days", type=int, default=None) return parser.parse_args() async def main_async() -> None: args = parse_args() if not args.sleeves: print("Error: at least one --sleeve CONFIG:WEIGHT is required.") return sleeve_specs: list[tuple[str, float]] = [] for spec in args.sleeves: if ":" not in spec: print(f"Error: sleeve spec must be 'path:weight', got: {spec!r}") return path, weight_str = spec.rsplit(":", 1) sleeve_specs.append((path, float(weight_str))) await run_composite(sleeve_specs, total_capital=args.total_capital, days=args.days) def main() -> None: asyncio.run(main_async()) if __name__ == "__main__": main()