"""Run the four production cadence/re-entry arms on one offline snapshot. The command executes the complete backtest once weekly and once daily. Each backtest contains two otherwise identical live-policy portfolio arms: immediate post-stop re-entry and the production gate-reset rule. It writes both full reports plus one compact four-arm comparison report. """ from __future__ import annotations import argparse import asyncio import json import os import sys from datetime import datetime from pathlib import Path from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine ROOT = Path(__file__).resolve().parents[1] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) def _parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "snapshot", help="SQLite snapshot created by scripts/create_backtest_snapshot.py.", ) parser.add_argument( "--out-dir", default="reports", help="Directory for the weekly, daily, and comparison JSON reports.", ) parser.add_argument( "--prefix", default=None, help="Output prefix. Defaults to backtest-cadence-.", ) parser.add_argument( "--workers", type=int, default=None, help="Override worker count; on a powerful offline PC use CPU count minus one.", ) parser.add_argument( "--allow-spawn", action="store_true", help="Enable multiprocessing spawn for the offline Windows run.", ) parser.add_argument("--quiet", action="store_true", help="Hide ticker progress.") return parser.parse_args() def _sqlite_url(path: Path) -> str: return f"sqlite+aiosqlite:///{path.resolve().as_posix()}" def _write_json(path: Path, payload: dict) -> None: path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") def _comparison_arms(report: dict) -> list[dict]: comparison = report.get("production_cadence_comparison") or {} arms = list(comparison.get("arms") or []) if len(arms) != 2: cadence = (report.get("params") or {}).get("entry_cadence", "unknown") raise RuntimeError( f"Expected two live comparison arms for {cadence}; found {len(arms)}" ) return arms def _print_arm(row: dict) -> None: print( f" {row['arm']}: Sharpe {row.get('sharpe')}, " f"CAGR {row.get('cagr_pct')}%, DD {row.get('max_drawdown_pct')}%, " f"trades {row.get('trades')}, post-stop re-entries " f"{row.get('post_stop_reentries', 0)}" ) async def _main() -> None: args = _parse_args() snapshot = Path(args.snapshot) if not snapshot.exists(): raise SystemExit(f"Snapshot not found: {snapshot}") os.environ["BACKTEST_SNAPSHOT_OFFLINE"] = "1" if args.allow_spawn: os.environ["BACKTEST_ALLOW_SPAWN"] = "1" from app.config import settings from app.services.backtest_service import run_backtest if args.workers is not None: settings.backtest_workers = args.workers out_dir = Path(args.out_dir) out_dir.mkdir(parents=True, exist_ok=True) prefix = args.prefix or f"backtest-cadence-{datetime.now():%Y%m%d-%H%M%S}" engine = create_async_engine(_sqlite_url(snapshot), pool_pre_ping=True) Session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) reports: dict[str, dict] = {} try: async with Session() as db: for cadence in ("weekly", "daily"): last_progress: tuple[int, int] | None = None def progress(done: int, total: int, symbol: str) -> None: nonlocal last_progress if args.quiet or last_progress == (done, total): return last_progress = (done, total) label = f" {symbol}" if symbol else "" print( f"{cadence} progress: {done}/{total}{label}", end="\r", ) reports[cadence] = await run_backtest( db, progress_cb=progress, target_model="production_gtl", cadence=cadence, ) if not args.quiet: print("") _write_json(out_dir / f"{prefix}-{cadence}.json", reports[cadence]) finally: await engine.dispose() arms = [ *_comparison_arms(reports["weekly"]), *_comparison_arms(reports["daily"]), ] expected = { "prod_live_setup_weekly", "prod_live_setup_daily", "gate_reset_weekly", "gate_reset_daily", } if {row.get("arm") for row in arms} != expected: raise RuntimeError("The generated cadence report does not contain all four arms") arm_order = { "prod_live_setup_weekly": 0, "prod_live_setup_daily": 1, "gate_reset_weekly": 2, "gate_reset_daily": 3, } arms.sort(key=lambda row: arm_order[str(row["arm"])]) comparison = { "generated_at": datetime.now().astimezone().isoformat(), "snapshot": str(snapshot.resolve()), "target_model": "production_gtl", "arms": arms, "full_reports": { cadence: str((out_dir / f"{prefix}-{cadence}.json").resolve()) for cadence in ("weekly", "daily") }, "note": ( "All four arms use the same snapshot, activation settings, target model, " "live Admin exit policy, fees, sizing, and portfolio constraints. Within " "each cadence pair, only the post-stop gate-reset rule differs." ), } comparison_path = out_dir / f"{prefix}-comparison.json" _write_json(comparison_path, comparison) print(f"Comparison written: {comparison_path}") for row in arms: _print_arm(row) if __name__ == "__main__": asyncio.run(_main())