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