"""BacktestRunner: main simulation class and CLI entry point.""" from __future__ import annotations import argparse import datetime as dt import subprocess import sys 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, 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 select_candidates from libs.backtest.snapshot_store import SnapshotStore 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, ) -> None: self.manifest = manifest self.config = config self.store = store self.initial_equity = initial_equity # 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 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) all_dates = self.store.all_execution_dates() if not all_dates: logger.warning("backtest_no_dates", run_id=run_id) 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 ) # 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, ) 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 # Decrement cooldown if self._cooldown_remaining > 0: self._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) --- 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) trade = simulate_exit(pos, bar, self.config.execution, date) if trade is not None: newly_closed.append(trade) else: still_open.append(pos) # Process closed trades for trade in newly_closed: self._closed_trades.append(trade) 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 --- unrealized = self._compute_unrealized_pnl(date) self._equity = self._cash + unrealized 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) self._kill_switch_triggered = True # --- ENTRIES (only if kill switch not triggered) --- if not self._kill_switch_triggered: raw_rows = self.store.get_candidates_for_date(date) self._total_candidates_seen += len(raw_rows) portfolio_state = self._build_portfolio_state(date, drawdown_pct, unrealized) candidates = select_candidates( raw_rows, self.config.universe, self.config.signal ) for candidate in candidates: plan = build_planned_order( candidate=candidate, portfolio_state=portfolio_state, open_positions=self._open_positions, config=self.config, cooldown_remaining=self._cooldown_remaining, ) 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, date) pos = simulate_entry(plan, bar, self.config.execution) 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 # Update portfolio state for next candidate in same day portfolio_state = self._build_portfolio_state( date, drawdown_pct, self._compute_unrealized_pnl(date) ) # --- Record daily equity curve snapshot --- unrealized_final = self._compute_unrealized_pnl(date) self._equity = self._cash + unrealized_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 ) self._equity_curve.append( DailyPortfolioState( date=date, equity=self._equity, cash_available=self._cash, gross_exposure=sum( p.entry_price * p.shares_open for p in self._open_positions ), net_exposure=sum( p.entry_price * p.shares_open for p in self._open_positions ), 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 _compute_unrealized_pnl(self, date: dt.date) -> float: total = 0.0 for pos in self._open_positions: bar = self.store.get_bar(pos.plan.candidate.symbol, date) if bar and bar.get("close"): total += (float(bar["close"]) - pos.entry_price) * pos.shares_open return total def _build_portfolio_state( self, date: dt.date, drawdown_pct: float, unrealized: float, ) -> DailyPortfolioState: return DailyPortfolioState( date=date, equity=self._equity, cash_available=self._cash, gross_exposure=sum( p.entry_price * p.shares_open for p in self._open_positions ), net_exposure=sum( p.entry_price * p.shares_open for p in self._open_positions ), 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._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 return SnapshotStore.load( snapshot_dir=snapshot_dir, split_name=split_name, oracle_url=s.stock_oracle_url, db_dsn=s.postgres_dsn, ) 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("--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") args = parser.parse_args() manifest = load_manifest(args.manifest) config = resolve_config(manifest, config_root=args.config_root, snapshot_id_override=args.snapshot_id) store = _build_store(manifest, config, args.split) runner = BacktestRunner( manifest=manifest, config=config, store=store, initial_equity=args.initial_equity, ) 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}%") if __name__ == "__main__": main()