"""BacktestRunner: main simulation class and CLI entry point.""" from __future__ import annotations import argparse import datetime as dt import json import subprocess import sys from collections import defaultdict from pathlib import Path from typing import Any from libs.backtest.allocator import build_planned_order from libs.backtest.artifacts import create_run_directory, write_all_artifacts from libs.backtest.domain import ( BacktestConfig, Candidate, DailyPortfolioState, ExecutionConfig, ExperimentManifest, ExperimentResult, FilledTrade, MetricsBundle, OpenPosition, PositionStatus, ) from libs.backtest.execution import ( simulate_entry, simulate_exit, simulate_kill_switch_exit, update_trailing_stop, ) from libs.backtest.manifests import generate_run_id, load_manifest, resolve_config from libs.backtest.metrics import build_metrics_bundle from libs.backtest.selector import rank_candidates, select_candidates from libs.backtest.snapshot_store import SnapshotStore from libs.backtest.splits import generate_walk_forward_windows from libs.common.logging import get_logger from libs.common.time_utils import utc_now logger = get_logger(__name__) _KILL_SWITCH_DRAWDOWN_PCT = 25.0 def _get_git_commit_hash() -> str: try: result = subprocess.run( ["git", "rev-parse", "--short", "HEAD"], capture_output=True, text=True, timeout=5, ) return result.stdout.strip() or "unknown" except Exception: return "unknown" class BacktestRunner: """Event-driven backtester simulation engine.""" def __init__( self, manifest: ExperimentManifest, config: BacktestConfig, store: SnapshotStore, initial_equity: float = 100_000.0, split_name: str | None = None, enable_engine_analysis: bool = True, ) -> None: self.split_name = split_name self.manifest = manifest self.config = config self.store = store self.initial_equity = initial_equity self.enable_engine_analysis = enable_engine_analysis self._active_strategy_engines = self.config.get_active_strategy_engines() # Simulation state self._equity = initial_equity self._cash = initial_equity self._open_positions: list[OpenPosition] = [] self._closed_trades: list[FilledTrade] = [] self._equity_curve: list[DailyPortfolioState] = [] self._candidate_map: dict[str, Candidate] = {} # trade_id → candidate # Stats self._total_candidates_seen = 0 self._total_orders_rejected = 0 self._peak_equity = initial_equity self._realized_pnl = 0.0 self._daily_new_risk_used = 0.0 self._consecutive_losses = 0 self._cooldown_remaining = 0 self._kill_switch_triggered = False self._kill_switch_cooldown_remaining = 0 self._engine_daily_new_risk_used: dict[str, float] = defaultdict(float) def _compute_portfolio_exposure(self, date: dt.date) -> tuple[float, float]: """Return (gross, net) exposure using current close notional when available.""" gross = 0.0 net = 0.0 for pos in self._open_positions: bar = self.store.get_bar(pos.plan.candidate.symbol, date) close = ( float(bar["close"]) if bar and bar.get("close") is not None and float(bar["close"]) > 0 else pos.entry_price ) notional = close * pos.shares_open gross += abs(notional) net += -notional if pos.plan.candidate.trade_direction == "short" else notional return gross, net def run(self, output_root: str | Path | None = None) -> ExperimentResult: """Execute the full simulation. Returns ExperimentResult.""" started_at = utc_now() run_id = generate_run_id(self.config) logger.info("backtest_start", run_id=run_id, strategy=self.config.strategy_name) exec_dates = self.store.all_execution_dates() if not exec_dates: logger.warning("backtest_no_dates", run_id=run_id) # Iterate ALL trading days (not just candidate days) so stop/target/time # exits are checked every day, not just on days with new candidates. all_dates = self._get_simulation_dates() # Record initial equity state (before any trades) if all_dates: self._equity_curve.append( DailyPortfolioState( date=all_dates[0], equity=self.initial_equity, cash_available=self.initial_equity, gross_exposure=0.0, net_exposure=0.0, reserved_risk_budget=0.0, unrealized_pnl=0.0, realized_pnl=0.0, open_positions=[], daily_new_risk_used=0.0, peak_equity=self.initial_equity, current_drawdown_pct=0.0, ) ) for date in all_dates: self._simulate_day(date) # Force-close any remaining open positions at end of backtest if self._open_positions: last_date = all_dates[-1] if all_dates else dt.date.today() self._force_close_all(last_date, reason="end_of_backtest") finished_at = utc_now() metrics = build_metrics_bundle( self._closed_trades, self._equity_curve, self._candidate_map ) per_engine_metrics = ( self._build_per_engine_metrics() if self.enable_engine_analysis and self.config.get_strategy_engines() else {} ) # Create run directory and write artifacts run_dir = None artifact_paths: dict[str, str] = {} if output_root is not None: run_dir = create_run_directory(output_root, run_id) git_hash = _get_git_commit_hash() artifact_paths = write_all_artifacts( run_dir=run_dir, run_id=run_id, manifest=self.manifest, config=self.config, metrics=metrics, trades=self._closed_trades, equity_curve=self._equity_curve, open_positions=self._open_positions, candidate_map=self._candidate_map, started_at=started_at, finished_at=finished_at, git_hash=git_hash, total_trading_days=len(self._equity_curve), total_candidates_seen=self._total_candidates_seen, total_orders_rejected=self._total_orders_rejected, split_name=self.split_name, per_engine_metrics=per_engine_metrics, ) logger.info( "backtest_complete", run_id=run_id, trades=len(self._closed_trades), days=len(self._equity_curve), ) return ExperimentResult( run_id=run_id, manifest=self.manifest, resolved_config=self.config, metrics=metrics, artifact_paths=artifact_paths, started_at=started_at, finished_at=finished_at, total_trading_days=len(self._equity_curve), total_candidates_seen=self._total_candidates_seen, total_orders_rejected=self._total_orders_rejected, ) def _simulate_day(self, date: dt.date) -> None: """Simulate a single trading day.""" # Reset daily risk tracker self._daily_new_risk_used = 0.0 self._engine_daily_new_risk_used = defaultdict(float) # Decrement cooldowns if self._cooldown_remaining > 0: self._cooldown_remaining -= 1 if self._kill_switch_cooldown_remaining > 0: self._kill_switch_cooldown_remaining -= 1 # Increment days_held for all open positions for pos in self._open_positions: pos.days_held += 1 # --- EXITS FIRST (using today's OHLCV) --- # Build position → candidate lookup for attribution mapping pos_to_candidate = {pos.position_id: pos.plan.candidate for pos in self._open_positions} newly_closed: list[FilledTrade] = [] still_open: list[OpenPosition] = [] for pos in self._open_positions: bar = self.store.get_bar(pos.plan.candidate.symbol, date) # Kill switch: force close if self._kill_switch_triggered: trade = simulate_kill_switch_exit(pos, bar, date, self.config.execution) newly_closed.append(trade) continue if bar is None: # Missing bar — hold position (do not impute zero) still_open.append(pos) continue # Update trailing stop if configured if self.config.execution.trailing_model: update_trailing_stop( pos, bar, self.config.execution.trailing_model, warmup_days=self.config.execution.trailing_warmup_days, ) effective_exec = self._build_effective_execution_config(pos.plan.candidate) prev_status = pos.status trade = simulate_exit(pos, bar, effective_exec, date) if trade is not None: newly_closed.append(trade) # Partial exit: status just changed from ENTERED to PARTIALLY_EXITED # Keep position open for remaining shares if prev_status == PositionStatus.ENTERED and pos.status == PositionStatus.PARTIALLY_EXITED: still_open.append(pos) else: still_open.append(pos) # Process closed trades for trade in newly_closed: self._closed_trades.append(trade) # Map trade to candidate for attribution cand = pos_to_candidate.get(trade.position_id) if cand: self._candidate_map[trade.trade_id] = cand self._realized_pnl += trade.net_pnl self._cash += trade.net_pnl + (trade.entry_price * trade.shares) # Track consecutive losses for cooldown if trade.net_pnl < 0: self._consecutive_losses += 1 else: self._consecutive_losses = 0 if ( self.config.risk.cooldown_after_loss_streak > 0 and self._consecutive_losses >= self.config.risk.cooldown_after_loss_streak ): self._cooldown_remaining = self.config.risk.cooldown_days self._consecutive_losses = 0 self._open_positions = still_open # --- Compute current equity for kill-switch check --- market_value = self._compute_positions_market_value(date) unrealized = market_value - sum( p.entry_price * p.shares_open for p in self._open_positions ) self._equity = self._cash + market_value self._peak_equity = max(self._peak_equity, self._equity) drawdown_pct = ( (self._peak_equity - self._equity) / self._peak_equity * 100.0 if self._peak_equity > 0 else 0.0 ) if drawdown_pct >= _KILL_SWITCH_DRAWDOWN_PCT and not self._kill_switch_triggered: logger.warning("kill_switch_triggered", date=str(date), drawdown_pct=drawdown_pct) if self.config.risk.kill_switch_log_only: logger.info("kill_switch_log_only_mode", date=str(date)) # Don't trigger — just observe else: self._kill_switch_triggered = True if self.config.risk.backtest_mode == "research": self._kill_switch_cooldown_remaining = self.config.risk.kill_switch_cooldown_days # Research mode: reset kill switch after cooldown expires # Reset peak_equity to current equity so drawdown restarts from 0 if ( self._kill_switch_triggered and self.config.risk.backtest_mode == "research" and self._kill_switch_cooldown_remaining <= 0 ): self._kill_switch_triggered = False self._peak_equity = self._equity drawdown_pct = 0.0 logger.info("kill_switch_reset", date=str(date)) # --- ENTRIES (only if kill switch not triggered) --- if not self._kill_switch_triggered: portfolio_state = self._build_portfolio_state(date, drawdown_pct, unrealized) candidates = self._select_candidates_for_date(date) self._total_candidates_seen += len(candidates) macro_data = self.store.get_macro_for_date(date) for candidate in candidates: plan = build_planned_order( candidate=candidate, portfolio_state=portfolio_state, open_positions=self._open_positions, config=self.config, execution_config=self._build_effective_execution_config(candidate), cooldown_remaining=self._cooldown_remaining, macro_data=macro_data, engine_daily_new_risk_used=self._engine_daily_new_risk_used[candidate.engine_id], ) if plan.skip_reason is not None: self._total_orders_rejected += 1 logger.debug( "order_rejected", symbol=candidate.symbol, reason=plan.skip_reason, date=str(date), ) continue bar = self.store.get_bar(candidate.symbol, candidate.execution_date) pos = simulate_entry( plan, bar, self._build_effective_execution_config(candidate), ) if pos is not None: self._open_positions.append(pos) self._cash -= pos.entry_price * pos.shares_total self._daily_new_risk_used += plan.risk_dollars self._engine_daily_new_risk_used[candidate.engine_id] += plan.risk_dollars # Update equity and portfolio state for next candidate mv = self._compute_positions_market_value(date) self._equity = self._cash + mv ur = mv - sum( p.entry_price * p.shares_open for p in self._open_positions ) portfolio_state = self._build_portfolio_state( date, drawdown_pct, ur ) # --- Record daily equity curve snapshot --- market_value_final = self._compute_positions_market_value(date) unrealized_final = market_value_final - sum( p.entry_price * p.shares_open for p in self._open_positions ) self._equity = self._cash + market_value_final self._peak_equity = max(self._peak_equity, self._equity) final_drawdown = ( (self._peak_equity - self._equity) / self._peak_equity * 100.0 if self._peak_equity > 0 else 0.0 ) gross_exposure, net_exposure = self._compute_portfolio_exposure(date) self._equity_curve.append( DailyPortfolioState( date=date, equity=self._equity, cash_available=self._cash, gross_exposure=gross_exposure, net_exposure=net_exposure, reserved_risk_budget=self._daily_new_risk_used, unrealized_pnl=unrealized_final, realized_pnl=self._realized_pnl, open_positions=[p.position_id for p in self._open_positions], daily_new_risk_used=self._daily_new_risk_used, peak_equity=self._peak_equity, current_drawdown_pct=final_drawdown, ) ) def _get_simulation_dates(self) -> list[dt.date]: """Return the full trading-day simulation range for the configured engines.""" if not self.config.get_strategy_engines(): return self.store.all_trading_days() include_reaction_dates = any( engine.entry_timing_policy == "reaction_close" for engine in self._active_strategy_engines ) return self.store.all_trading_days(include_reaction_dates=include_reaction_dates) def _select_candidates_for_date(self, date: dt.date) -> list[Candidate]: """Select daily candidates for single-engine or multi-engine mode.""" if not self.config.get_strategy_engines(): raw_rows = self.store.get_candidates_for_date(date) return select_candidates( raw_rows, self.config.universe, self.config.signal, event_type_profiles=self.config.event_type_profiles or None, ) if not self._active_strategy_engines: return [] engine_queues: dict[str, list[Candidate]] = {} for engine in self._active_strategy_engines: raw_rows = ( self.store.get_candidates_for_reaction_date(date) if engine.entry_timing_policy == "reaction_close" else self.store.get_candidates_for_date(date) ) selected = select_candidates( raw_rows, self.config.universe, self.config.signal, event_type_profiles=self.config.event_type_profiles or None, strategy_engine=engine, ) if selected: engine_queues[engine.engine_id] = selected if self.config.strategy_engine_selection_mode == "global_score": merged = [] for candidates in engine_queues.values(): merged.extend(candidates) merged = rank_candidates(merged) return merged[: self.config.signal.max_candidates_per_day] return self._interleave_engine_candidates(engine_queues) def _interleave_engine_candidates( self, engine_queues: dict[str, list[Candidate]], ) -> list[Candidate]: """Round-robin engine queues using manifest order.""" if not engine_queues: return [] working = { engine_id: list(candidates) for engine_id, candidates in engine_queues.items() } ordered: list[Candidate] = [] while True: advanced = False for engine in self._active_strategy_engines: queue = working.get(engine.engine_id, []) if not queue: continue ordered.append(queue.pop(0)) advanced = True if not advanced: break return ordered[: self.config.signal.max_candidates_per_day] def _build_effective_execution_config(self, candidate: Candidate) -> ExecutionConfig: """Resolve per-engine and per-event execution overrides.""" execution_updates: dict[str, Any] = {} max_holding_days = candidate.engine_max_holding_days if max_holding_days is None: evt_profile = self.config.get_event_profile(candidate.event_type) if evt_profile and evt_profile.max_holding_days_override is not None: max_holding_days = evt_profile.max_holding_days_override if max_holding_days is not None: execution_updates["max_holding_days"] = max_holding_days if candidate.engine_target_atr_multiplier is not None: execution_updates["target_atr_multiplier"] = candidate.engine_target_atr_multiplier if candidate.engine_target_1_fraction is not None: execution_updates["target_1_fraction"] = candidate.engine_target_1_fraction if candidate.engine_trailing_model is not None: execution_updates["trailing_model"] = candidate.engine_trailing_model if candidate.engine_trailing_warmup_days is not None: execution_updates["trailing_warmup_days"] = candidate.engine_trailing_warmup_days if not execution_updates: return self.config.execution return self.config.execution.model_copy(update=execution_updates) def _build_per_engine_metrics(self) -> dict[str, dict[str, Any]]: """Run each engine in isolation for standalone metrics and shadow summaries.""" summaries: dict[str, dict[str, Any]] = {} for engine in self.config.get_strategy_engines(): isolated_engine = engine.model_copy( update={ "shadow_only": False, # Shadow engines should paper-trade freely for diagnostics. "engine_risk_budget_pct": ( 1.0 if engine.shadow_only else engine.engine_risk_budget_pct ), } ) isolated_manifest = self.manifest.model_copy( update={"strategy_engines": [isolated_engine]} ) isolated_config = self.config.model_copy( update={ "strategy_name": f"{self.config.strategy_name}__{engine.engine_id}", "strategy_engines": [isolated_engine], } ) runner = BacktestRunner( manifest=isolated_manifest, config=isolated_config, store=self.store, initial_equity=self.initial_equity, split_name=self.split_name, enable_engine_analysis=False, ) result = runner.run(output_root=None) summaries[engine.engine_id] = { "engine_id": engine.engine_id, "shadow_only": engine.shadow_only, "event_types": list(engine.event_types), "timing_class": engine.timing_class, "direction": engine.direction, "entry_timing_policy": engine.entry_timing_policy, "max_holding_days": engine.max_holding_days, "engine_risk_budget_pct": engine.engine_risk_budget_pct, "target_atr_multiplier_override": engine.target_atr_multiplier_override, "target_1_fraction_override": engine.target_1_fraction_override, "trailing_model_override": engine.trailing_model_override, "trailing_warmup_days_override": engine.trailing_warmup_days_override, "total_candidates_seen": result.total_candidates_seen, "total_orders_rejected": result.total_orders_rejected, "net_pnl": round(sum(trade.net_pnl for trade in runner._closed_trades), 4), "metrics": result.metrics.model_dump(mode="json"), } return summaries def _compute_positions_market_value(self, date: dt.date) -> float: """Market value of all open positions using today's close. For long: market_value = close * shares. For short: market_value = (2 * entry - close) * shares. This reflects that a short position gains when price falls: the "value" of a short at entry is entry_price * shares, and PnL = (entry - close) * shares, so effective value = entry + PnL = (2*entry - close). Falls back to entry_price when bar is missing (assumes no change rather than treating the position as worthless). """ total = 0.0 for pos in self._open_positions: bar = self.store.get_bar(pos.plan.candidate.symbol, date) is_short = pos.plan.candidate.trade_direction == "short" if bar and bar.get("close"): close = float(bar["close"]) if is_short: total += (2.0 * pos.entry_price - close) * pos.shares_open else: total += close * pos.shares_open else: total += pos.entry_price * pos.shares_open return total def _compute_unrealized_pnl(self, date: dt.date) -> float: """Unrealized PnL = market_value − cost_basis.""" market_value = self._compute_positions_market_value(date) cost_basis = sum(p.entry_price * p.shares_open for p in self._open_positions) return market_value - cost_basis def _build_portfolio_state( self, date: dt.date, drawdown_pct: float, unrealized: float, ) -> DailyPortfolioState: gross_exposure, net_exposure = self._compute_portfolio_exposure(date) return DailyPortfolioState( date=date, equity=self._equity, cash_available=self._cash, gross_exposure=gross_exposure, net_exposure=net_exposure, reserved_risk_budget=self._daily_new_risk_used, unrealized_pnl=unrealized, realized_pnl=self._realized_pnl, open_positions=[p.position_id for p in self._open_positions], daily_new_risk_used=self._daily_new_risk_used, peak_equity=self._peak_equity, current_drawdown_pct=drawdown_pct, ) def _force_close_all(self, date: dt.date, reason: str = "force_close") -> None: """Close all open positions (end of backtest or kill switch).""" for pos in list(self._open_positions): bar = self.store.get_bar(pos.plan.candidate.symbol, date) trade = simulate_kill_switch_exit(pos, bar, date, self.config.execution) self._closed_trades.append(trade) self._candidate_map[trade.trade_id] = pos.plan.candidate self._realized_pnl += trade.net_pnl self._cash += trade.net_pnl + (trade.entry_price * trade.shares) self._open_positions = [] # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- def _build_store( manifest: ExperimentManifest, config: BacktestConfig, split_name: str, snapshot_dir_override: str | None = None, ) -> SnapshotStore: from libs.common.config import get_settings s = get_settings() snapshot_dir = Path(snapshot_dir_override or s.parquet_dir) / config.dataset_snapshot_id # Resolve scoring function from config scoring_fn = None if config.signal.scoring_model == "pead": from libs.backtest.scoring import compute_pead_score from functools import partial scoring_fn = partial( compute_pead_score, reaction_threshold=config.signal.pead_reaction_threshold, volume_threshold=config.signal.pead_volume_threshold, ) return SnapshotStore.load( snapshot_dir=snapshot_dir, split_name=split_name, oracle_url=s.stock_oracle_url, db_dsn=s.postgres_dsn, scoring_fn=scoring_fn, ) def run_walk_forward( manifest: ExperimentManifest, config: BacktestConfig, snapshot_dir_override: str | None, initial_equity: float, output_root: str, train_days: int = 252, test_days: int = 63, step_days: int | None = None, ) -> list[dict[str, Any]]: """Run walk-forward cross-validation over all splits. Loads all three splits (train/valid/test) into one SnapshotStore, generates walk-forward windows, and runs a separate backtest on each window's test period. Returns per-fold metrics. """ from libs.common.config import get_settings s = get_settings() snapshot_root = Path(snapshot_dir_override or s.parquet_dir) / config.dataset_snapshot_id # Merge all splits into a single SnapshotStore stores: list[SnapshotStore] = [] for split in ["train", "valid", "test"]: parquet_path = snapshot_root / f"{split}.parquet" if parquet_path.exists(): st = SnapshotStore.load( snapshot_dir=snapshot_root, split_name=split, oracle_url=s.stock_oracle_url, db_dsn=s.postgres_dsn, ) stores.append(st) if not stores: print("No splits found to load.") return [] # Merge candidates and bars from all stores merged_candidates: dict[dt.date, list[dict[str, Any]]] = {} merged_bars: dict[str, dict[dt.date, dict[str, Any]]] = {} merged_macro: dict[dt.date, dict[str, Any]] = {} for st in stores: for d in st.all_execution_dates(): merged_candidates.setdefault(d, []).extend(st.get_candidates_for_date(d)) merged_bars.update(st._bars) merged_macro.update(st._macro) merged_store = SnapshotStore( candidates_by_exec_date=merged_candidates, bars_by_symbol_date=merged_bars, macro_by_date=merged_macro, ) all_dates = merged_store.all_trading_days() if not all_dates: print("No trading days found in merged data.") return [] windows = generate_walk_forward_windows( all_dates, train_days=train_days, test_days=test_days, step_days=step_days, ) if not windows: print(f"Not enough data for walk-forward windows (need {train_days + test_days} days, have {len(all_dates)}).") # Fall back to a single window using all available data print("Running single-window backtest on all data instead.") windows_dates = [(all_dates[0], all_dates[-1])] else: windows_dates = [(w.test_start, w.test_end) for w in windows] fold_results: list[dict[str, Any]] = [] for fold_idx, (test_start, test_end) in enumerate(windows_dates): # Filter candidates to only those within the test window filtered_candidates: dict[dt.date, list[dict[str, Any]]] = {} for d, cands in merged_candidates.items(): if test_start <= d <= test_end: filtered_candidates[d] = cands fold_store = SnapshotStore( candidates_by_exec_date=filtered_candidates, bars_by_symbol_date=merged_bars, macro_by_date=merged_macro, ) runner = BacktestRunner( manifest=manifest, config=config, store=fold_store, initial_equity=initial_equity, ) result = runner.run(output_root=output_root) fold_info = { "fold": fold_idx, "test_start": str(test_start), "test_end": str(test_end), "trade_count": result.metrics.trade_count, "total_return_pct": result.metrics.total_return_pct, "win_rate": result.metrics.win_rate, "profit_factor": result.metrics.profit_factor, "max_drawdown_pct": result.metrics.max_drawdown_pct, "sharpe_ratio": result.metrics.sharpe_ratio, "run_id": result.run_id, } fold_results.append(fold_info) print( f"Fold {fold_idx}: {test_start}→{test_end} | " f"Trades={result.metrics.trade_count} " f"Return={result.metrics.total_return_pct or 0:.2f}% " f"WinRate={result.metrics.win_rate or 0:.1%}" ) # Aggregate summary total_trades = sum(f["trade_count"] or 0 for f in fold_results) returns = [f["total_return_pct"] for f in fold_results if f["total_return_pct"] is not None] win_rates = [f["win_rate"] for f in fold_results if f["win_rate"] is not None] print(f"\n--- Walk-Forward Summary ({len(fold_results)} folds) ---") print(f"Total trades: {total_trades}") if returns: import statistics print(f"Mean return: {statistics.mean(returns):.2f}%") if len(returns) > 1: print(f"StdDev return: {statistics.stdev(returns):.2f}%") if win_rates: import statistics print(f"Mean win rate: {statistics.mean(win_rates):.1%}") return fold_results def main() -> None: parser = argparse.ArgumentParser(description="ACE-F Backtester") parser.add_argument("--manifest", required=True, help="Path to experiment manifest JSON") parser.add_argument("--snapshot-id", help="Override dataset_snapshot_id") parser.add_argument("--snapshot-dir", help="Override snapshot root directory (default: data/parquet/)") parser.add_argument("--split", default="train", help="Split name (train/valid/test)") parser.add_argument("--output-root", default="./runs", help="Output root directory") parser.add_argument("--initial-equity", type=float, default=100_000.0) parser.add_argument("--config-root", default=".", help="Root dir for resolving config paths") parser.add_argument( "--walk-forward", action="store_true", help="Run walk-forward cross-validation instead of single backtest", ) parser.add_argument("--wf-train-days", type=int, default=252, help="Walk-forward train window (trading days)") parser.add_argument("--wf-test-days", type=int, default=63, help="Walk-forward test window (trading days)") parser.add_argument("--mode", choices=["research", "live"], default=None, help="Backtest mode: research (kill switch resets) or live (permanent)") args = parser.parse_args() manifest = load_manifest(args.manifest) config = resolve_config(manifest, config_root=args.config_root, snapshot_id_override=args.snapshot_id) if args.mode: config.risk.backtest_mode = args.mode if args.walk_forward: fold_results = run_walk_forward( manifest=manifest, config=config, snapshot_dir_override=args.snapshot_dir, initial_equity=args.initial_equity, output_root=args.output_root, train_days=args.wf_train_days, test_days=args.wf_test_days, ) # Write summary JSON summary_path = Path(args.output_root) / "walk_forward_summary.json" summary_path.parent.mkdir(parents=True, exist_ok=True) summary_path.write_text(json.dumps(fold_results, indent=2)) print(f"\nSummary written to: {summary_path}") else: store = _build_store(manifest, config, args.split, snapshot_dir_override=args.snapshot_dir) runner = BacktestRunner( manifest=manifest, config=config, store=store, initial_equity=args.initial_equity, split_name=args.split, ) result = runner.run(output_root=args.output_root) print(f"Run complete: {result.run_id}") print(f"Trades: {result.metrics.trade_count}") if result.metrics.total_return_pct is not None: print(f"Total return: {result.metrics.total_return_pct:.2f}%") # Print SQS score from libs.backtest.tracker import compute_sqs sqs_score, sqs_breakdown = compute_sqs(result.metrics) print(f"SQS: {sqs_score} ({', '.join(f'{k}={v}' for k, v in sqs_breakdown.items())})") if __name__ == "__main__": main()