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.

324 lines
12 KiB
Python

"""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()