"""Run the existing backtest service against a local SQLite snapshot. The runner is offline/read-only: it does not refresh benchmark prices and does not cache the report back to any database. It writes a local JSON report. """ from __future__ import annotations import argparse import asyncio import json import os import sys from datetime import date, datetime from pathlib import Path from typing import Any 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 _sqlite_url(path: Path) -> str: return f"sqlite+aiosqlite:///{path.resolve().as_posix()}" def _parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("snapshot", help="SQLite snapshot created by create_backtest_snapshot.py.") parser.add_argument( "--out", default=None, help="JSON report path. Defaults to reports/backtest-.json.", ) parser.add_argument( "--workers", type=int, default=None, help="Override backtest worker count. On an 8-thread machine, 6 is a good starting point.", ) parser.add_argument( "--allow-spawn", action="store_true", help="Allow spawn multiprocessing for offline CLI runs, useful on Windows.", ) parser.add_argument("--quiet", action="store_true", help="Hide progress output.") parser.add_argument( "--sr-variant", choices=( "production_control", "rr_aligned_control", "rewrite", "soft_zones", "confirmed_rounds", "gate_v2", "rewrite_legacy_primary", "soft_zones_legacy_primary", "confirmed_rounds_legacy_primary", "gate_v2_legacy_primary", "legacy_geometry_neutral", "legacy_pivots_only", "legacy_traffic_grid_only", "legacy_range_grid_touch", "legacy_range_grid_neutral", "production_range504", "rewrite_range504_legacy_primary", "rewrite_range504_structural_legacy_primary", "rewrite_range504_structural_primary2", "production_structural_overlay", "explicit_target_ladder", "gtl_tuning", ), default=None, help="Research-only S/R detector/gate arm.", ) parser.add_argument( "--gtl-config", default=None, help="Research-only GTL configuration as a JSON object (requires --sr-variant gtl_tuning).", ) parser.add_argument( "--entry-start", default=None, help="Include entries on/after YYYY-MM-DD.", ) parser.add_argument( "--entry-end", default=None, help="Include entries on/before YYYY-MM-DD.", ) parser.add_argument( "--sr-audit", action="store_true", help="Include candidate-level S/R audit rows for paired comparison.", ) parser.add_argument( "--holdout-split", default=None, help="Add a disjoint train/test portfolio report split at YYYY-MM-DD.", ) return parser.parse_args() def _default_output_path() -> Path: stamp = datetime.now().strftime("%Y%m%d-%H%M%S") return Path("reports") / f"backtest-{stamp}.json" def _pct(value: Any) -> str: return "-" if value is None else f"{float(value):+.1f}%" def _drawdown_pct(value: Any) -> str: return "-" if value is None else f"-{abs(float(value)):.1f}%" def _r(value: Any) -> str: return "-" if value is None else f"{float(value):+.2f}R" def _print_summary(report: dict) -> None: qualified = report.get("overall_qualified") or {} all_setups = report.get("overall_all") or {} time_exit = {row.get("hold_days"): row for row in report.get("time_exit_sweep") or []} hold_30 = time_exit.get(30) or {} policies = { row.get("policy"): row for row in ((report.get("portfolio_sim") or {}).get("policies") or []) } hold_policy = policies.get("hold") or {} print("") print("Backtest summary") print(f" candidates: {report.get('candidates')}") print(f" qualified: {report.get('qualified')}") print(f" all setups net avg R: {_r(all_setups.get('net_avg_r'))}") print(f" qualified net avg R: {_r(qualified.get('net_avg_r'))}") print(f" qualified total R: {_r(qualified.get('total_r'))}") print(f" 30d hold net avg R: {_r(hold_30.get('net_avg_r'))}") print(f" 30d hold total R: {_r(hold_30.get('total_r'))}") if hold_policy: print(f" hold CAGR: {_pct(hold_policy.get('cagr_pct'))}") print(f" hold max drawdown: {_drawdown_pct(hold_policy.get('max_drawdown_pct'))}") print(f" hold Sharpe: {hold_policy.get('sharpe')}") print(f" hold trades: {hold_policy.get('trades')}") variants = list((report.get("strategy_variants") or {}).get("variants") or []) ranked = sorted( (v for v in variants if v.get("sharpe") is not None), key=lambda v: v.get("sharpe"), reverse=True, ) if ranked: print(" top strategy variants by Sharpe:") for row in ranked[:5]: print( " " f"{row.get('variant')}: " f"Sharpe {row.get('sharpe')}, " f"CAGR {_pct(row.get('cagr_pct'))}, " f"DD {_drawdown_pct(row.get('max_drawdown_pct'))}, " f"trades {row.get('trades')}" ) exits = list((report.get("exit_policy_variants") or {}).get("variants") or []) ranked_exits = sorted( (v for v in exits if v.get("sharpe") is not None), key=lambda v: v.get("sharpe"), reverse=True, ) if ranked_exits: print(" exit policies for 80/20 entry by Sharpe:") for row in ranked_exits[:5]: print( " " f"{row.get('exit_policy')}: " f"Sharpe {row.get('sharpe')}, " f"CAGR {_pct(row.get('cagr_pct'))}, " f"DD {_drawdown_pct(row.get('max_drawdown_pct'))}, " f"trades {row.get('trades')}" ) monitor_rows = [ row for row in ((report.get("portfolio_monitor") or {}).get("runs") or []) if row.get("lookback") == "all" and ( row.get("is_production") or row.get("strategy") == "production_structural_overlay5_atr3" ) ] if monitor_rows: print(" live-path full-period comparison:") for row in monitor_rows: print( " " f"{row.get('strategy')}: " f"Sharpe {row.get('sharpe')}, " f"CAGR {_pct(row.get('cagr_pct'))}, " f"DD {_drawdown_pct(row.get('max_drawdown_pct'))}, " f"trades {row.get('trades')}" ) 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" if args.sr_variant: os.environ["BACKTEST_SR_VARIANT"] = args.sr_variant if args.gtl_config: if args.sr_variant != "gtl_tuning": raise SystemExit("--gtl-config requires --sr-variant gtl_tuning") os.environ["BACKTEST_GTL_CONFIG"] = args.gtl_config if args.entry_start: os.environ["BACKTEST_ENTRY_START"] = args.entry_start if args.entry_end: os.environ["BACKTEST_ENTRY_END"] = args.entry_end if args.sr_audit: os.environ["BACKTEST_SR_AUDIT"] = "1" if args.holdout_split: try: date.fromisoformat(args.holdout_split) except ValueError as exc: raise SystemExit("--holdout-split must use YYYY-MM-DD") from exc os.environ["BACKTEST_HOLDOUT_SPLIT"] = args.holdout_split from app.config import settings from app.services.backtest_service import run_backtest if args.workers is not None: settings.backtest_workers = args.workers output = Path(args.out) if args.out else _default_output_path() output.parent.mkdir(parents=True, exist_ok=True) engine = create_async_engine(_sqlite_url(snapshot), pool_pre_ping=True) Session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) last_progress: tuple[int, int] | None = None def progress(done: int, total: int, symbol: str) -> None: nonlocal last_progress if args.quiet: return marker = (done, total) if marker == last_progress: return last_progress = marker label = f" {symbol}" if symbol else "" print(f"progress: {done}/{total}{label}", end="\r") try: async with Session() as db: report = await run_backtest(db, progress_cb=progress) finally: await engine.dispose() if not args.quiet: print("") with output.open("w", encoding="utf-8") as fh: json.dump(report, fh, indent=2) fh.write("\n") print(f"Report written: {output}") _print_summary(report) if __name__ == "__main__": asyncio.run(_main())