"""BacktestRunner: main simulation class and CLI entry point.""" from __future__ import annotations import argparse import datetime as dt import json import math import statistics import subprocess import sys from collections import defaultdict from pathlib import Path from typing import Any import requests from libs.backtest.allocator import ( _cap_shares_by_position_limits, _cap_shares_to_remaining_risk_budget, _remaining_risk_budget_dollars, _resolve_effective_per_trade_risk_pct, _resolve_stop_risk_config, _resolve_sizing_equity, build_planned_order, compute_shares, compute_stop_price, ) from libs.backtest.artifacts import create_run_directory, write_all_artifacts from libs.backtest.domain import ( BacktestConfig, Candidate, DailyPortfolioState, ExecutionConfig, ExitReason, ExperimentManifest, ExperimentResult, FilledTrade, MetricsBundle, OpenPosition, PlannedOrder, PositionStatus, RobustnessHorizonSummary, RobustnessMatrixSummary, SplitResult, WalkForwardAggregate, WalkForwardFoldResult, WalkForwardGapStats, WalkForwardSummary, ) from libs.backtest.execution import ( simulate_scheduled_open_exit, simulate_entry, simulate_exit, simulate_kill_switch_exit, simulate_recycle_close_exit, simulate_rotation_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_robustness_windows, generate_walk_forward_windows from libs.common.logging import get_logger from libs.common.time_utils import utc_now from libs.oracle_client.models import EventAttentionResponse 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() self._shadow_strategy_engines = self.config.get_shadow_strategy_engines() from libs.backtest.attention import AttentionFilterService from libs.common.config import get_settings settings = get_settings() self._attention_service = AttentionFilterService( oracle_url=settings.stock_oracle_url, scoring_model=config.signal.scoring_model, ) # Legacy attributes for backward compat with remaining inline methods self._attention_cache = self._attention_service._cache self._attention_base_url = self._attention_service._base_url or None self._attention_session = self._attention_service._session # 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 self._fixed_capital_sizing = config.risk.fixed_capital_sizing # Cash parking (idle cash → SPY/QQQ/SGOV) self._parking_shares: int = 0 self._parking_avg_price: float = 0.0 self._parking_current_symbol: str = "" # tracks which symbol is currently parked # 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) self._scheduled_add_ons: dict[dt.date, list[Candidate]] = defaultdict(list) self._scheduled_delayed_entries: dict[dt.date, list[Candidate]] = defaultdict(list) self._recent_scored_candidates: dict[dt.date, list[Candidate]] = {} self._pending_open_exits: dict[dt.date, list[dict[str, Any]]] = defaultdict(list) self._parent_add_on_counts: dict[str, int] = defaultdict(int) self._simulation_dates: list[dt.date] = [] self._next_trading_day: dict[dt.date, dt.date] = {} @property def _sizing_equity(self) -> float: """Equity used for position sizing. Returns initial_capital when fixed_capital_sizing is enabled.""" if self._fixed_capital_sizing: return self.initial_equity return self._equity 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 _compute_buying_power(self, equity: float, gross_exposure: float) -> float: multiplier = self.config.risk.buying_power_multiplier or 1.0 max_gross = max(0.0, equity * multiplier) return max(0.0, max_gross - gross_exposure) 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() self._simulation_dates = list(all_dates) self._next_trading_day = { all_dates[idx]: all_dates[idx + 1] for idx in range(len(all_dates) - 1) } # Record initial equity state (before any trades) if all_dates: self._equity_curve.append( DailyPortfolioState( date=all_dates[0], equity=self.initial_equity, sizing_equity=self.initial_equity, cash_available=self._compute_buying_power(self.initial_equity, 0.0), 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 ) metrics = metrics.model_copy(update=self._build_benchmark_and_contribution_metrics(metrics)) 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 # --- CASH PARKING: sell parked position to free cash for today's trades --- macro_data_early = self.store.get_macro_for_date(date) if self._parking_shares > 0 and macro_data_early: if self._parking_current_symbol == "sgov": # SGOV: accrue daily interest daily_rate = self.config.risk.cash_parking_sgov_annual_rate / 252 interest = self._parking_shares * self._parking_avg_price * daily_rate self._cash += self._parking_shares * self._parking_avg_price + interest self._realized_pnl += interest else: park_close = macro_data_early.get(f"{self._parking_current_symbol}_close") if park_close and park_close > 0: proceeds = self._parking_shares * park_close parking_pnl = proceeds - (self._parking_shares * self._parking_avg_price) self._cash += proceeds self._realized_pnl += parking_pnl self._parking_shares = 0 self._parking_avg_price = 0.0 self._parking_current_symbol = "" # Increment days_held for all open positions for pos in self._open_positions: pos.days_held += 1 # --- OPENING EXITS (scheduled on prior close) --- if self._pending_open_exits.get(date): self._process_pending_open_exits(date) # --- SEASONAL RESET: close stale/underwater positions before peak event season --- if ( self.config.risk.seasonal_reset_enabled and self._open_positions and date.month == self.config.risk.seasonal_reset_month and date.day >= self.config.risk.seasonal_reset_day ): to_close = [] for pos in self._open_positions: bar = self.store.get_bar(pos.plan.candidate.symbol, date) if bar is None or bar.get("close") is None: continue close_price = float(bar["close"]) # Only close underwater or stale positions (held > 10 days with low R) stop_dist = abs(pos.entry_price - pos.plan.stop_price) unrealized_r = (close_price - pos.entry_price) / stop_dist if stop_dist > 0 else 0.0 if unrealized_r < 0.3 and pos.days_held >= 5: to_close.append(pos) for pos in to_close: bar = self.store.get_bar(pos.plan.candidate.symbol, date) trade = simulate_rotation_exit(pos, bar, date, self._build_effective_execution_config(pos.plan.candidate)) if trade: 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 = [p for p in self._open_positions if p.position_id != pos.position_id] logger.info("seasonal_reset", date=str(date), symbol=pos.plan.candidate.symbol, unrealized_r=round(unrealized_r, 2)) # --- 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: pending_exit = self._evaluate_pending_open_exit(pos, bar, effective_exec, date) if pending_exit is not None: self._queue_pending_open_exit(date, pending_exit) 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) shadow_candidates = self._select_shadow_candidates_for_date(date) # Store scored candidates for delayed entry lookback recent_candidates = list(candidates) if shadow_candidates: recent_candidates.extend(shadow_candidates) if recent_candidates: self._recent_scored_candidates[date] = recent_candidates # Prune old entries (keep last 10 trading days) cutoff = max(0, len(self._simulation_dates) - 15) if cutoff > 0: idx = self._simulation_dates.index(date) if date in self._simulation_dates else -1 if idx >= 15: old_date = self._simulation_dates[idx - 15] self._recent_scored_candidates.pop(old_date, None) # Inject delayed entry candidates delayed = self._scheduled_delayed_entries.pop(date, []) if delayed: candidates = list(candidates) + delayed self._total_candidates_seen += len(candidates) macro_data = self.store.get_macro_for_date(date) candidates = self._reorder_candidates_for_funding(candidates, portfolio_state, macro_data) # --- ROTATION: proactively close stale positions if good candidates exist --- n_rotated = self._attempt_rotation_exits(date, candidates) if n_rotated > 0: mv = self._compute_positions_market_value(date) self._equity = self._cash + mv unrealized = mv - sum(p.entry_price * p.shares_open for p in self._open_positions) portfolio_state = self._build_portfolio_state(date, drawdown_pct, unrealized) 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: if plan.skip_reason == "insufficient_cash" and self._attempt_same_day_cash_recycle( date=date, candidate=candidate, portfolio_state=portfolio_state, ): 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) 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 self._release_add_on_reservation(candidate) logger.debug( "order_rejected", engine_id=candidate.engine_id, symbol=candidate.symbol, reason=plan.skip_reason, date=str(date), ) continue bar = self.store.get_bar(candidate.symbol, candidate.execution_date) gap_skip_reason = self._check_next_open_gap_cap(candidate, bar) if gap_skip_reason is not None: self._total_orders_rejected += 1 self._release_add_on_reservation(candidate) logger.debug( "order_rejected", engine_id=candidate.engine_id, symbol=candidate.symbol, reason=gap_skip_reason, date=str(date), ) continue pos = simulate_entry( plan, bar, self._build_effective_execution_config(candidate), ) if pos is not None: pos.parent_position_id = candidate.parent_position_id pos.is_add_on = candidate.is_add_on 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 ) else: self._release_add_on_reservation(candidate) self._schedule_add_on_candidates(date) self._schedule_delayed_entry_candidates(date) # --- CASH PARKING: invest idle cash --- if self.config.risk.cash_parking_enabled: macro_data_eod = self.store.get_macro_for_date(date) or {} park_mode = self.config.risk.cash_parking_symbol # "spy", "qqq", "dynamic", "sgov" # Resolve which symbol to park in spy_c = macro_data_eod.get("spy_close") spy_s = macro_data_eod.get("spy_sma_20") qqq_c = macro_data_eod.get("qqq_close") qqq_s = macro_data_eod.get("qqq_sma_20") spy_up = spy_c and spy_s and spy_c > spy_s qqq_up = qqq_c and qqq_s and qqq_c > qqq_s if park_mode == "dynamic": # 3-way dynamic: QQQ (aggressive) / SPY (defensive) / SGOV (safe) if spy_up and qqq_up: chosen_sym = "qqq" elif spy_up: chosen_sym = "spy" elif qqq_up: chosen_sym = "qqq" else: chosen_sym = "sgov" elif park_mode == "sgov": chosen_sym = "sgov" else: # Fixed symbol (spy/qqq) with gate → SGOV fallback chosen_sym = park_mode if self.config.risk.cash_parking_trend_gate: sym_up = spy_up if park_mode == "spy" else qqq_up if not sym_up: chosen_sym = "sgov" # gate failed → SGOV fallback mv_for_parking = self._compute_positions_market_value(date) equity_est = self._cash + mv_for_parking reserve = equity_est * self.config.risk.cash_parking_reserve_pct investable = max(0.0, self._cash - reserve) if chosen_sym == "sgov": if investable > 0: self._parking_shares = 1 self._parking_avg_price = investable self._parking_current_symbol = "sgov" self._cash -= investable else: park_close_eod = macro_data_eod.get(f"{chosen_sym}_close") if park_close_eod and park_close_eod > 0 and investable >= park_close_eod: shares = int(investable / park_close_eod) self._parking_shares = shares self._parking_avg_price = park_close_eod self._parking_current_symbol = chosen_sym self._cash -= shares * park_close_eod # --- Record daily equity curve snapshot --- market_value_final = self._compute_positions_market_value(date) macro_for_eq = self.store.get_macro_for_date(date) or {} if self._parking_shares > 0 and self._parking_current_symbol == "sgov": parking_value = self._parking_avg_price # SGOV: face value elif self._parking_shares > 0 and self._parking_current_symbol: park_price = macro_for_eq.get(f"{self._parking_current_symbol}_close", self._parking_avg_price) parking_value = self._parking_shares * park_price else: parking_value = 0.0 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 + parking_value 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, sizing_equity=self._sizing_equity, cash_available=self._compute_buying_power(self._equity, gross_exposure), 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]] = {} reserved_event_ids: set[str] = set() reserved_symbols: set[str] = set() for engine in self._active_strategy_engines: if not self._engine_allowed_for_date(engine, date): continue if not self._engine_uses_snapshot_candidates(engine): continue prelimit = self.config.signal.max_candidates_per_day if self._engine_requires_attention(engine): prelimit = max(prelimit * 5, prelimit) 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, truncate_to=prelimit, excluded_event_ids=reserved_event_ids, excluded_symbols=reserved_symbols, ) selected = self._apply_attention_filters(selected, engine) if selected: engine_queues[engine.engine_id] = selected if engine.residual_reserve_selected: reserved_event_ids.update(candidate.event_id for candidate in selected) reserved_symbols.update(candidate.symbol.upper() for candidate in selected) scheduled_add_ons = self._scheduled_add_ons.pop(date, []) if scheduled_add_ons: grouped_add_ons: dict[str, list[Candidate]] = defaultdict(list) for candidate in scheduled_add_ons: grouped_add_ons[candidate.engine_id].append(candidate) for engine_id, candidates in grouped_add_ons.items(): engine_queues.setdefault(engine_id, []) engine_queues[engine_id].extend(rank_candidates(candidates)) if self.config.strategy_engine_selection_mode == "global_score": merged = [] for candidates in engine_queues.values(): merged.extend(candidates) merged = rank_candidates(merged, self.config.signal.ranking_fields) return merged[: self.config.signal.max_candidates_per_day] if self.config.strategy_engine_selection_mode == "interleave_head_score": return self._interleave_engine_candidates_by_head_score(engine_queues) return self._interleave_engine_candidates(engine_queues) def _select_shadow_candidates_for_date(self, date: dt.date) -> list[Candidate]: """Select shadow candidates used only for synthetic lookback logic.""" if not self._shadow_strategy_engines: return [] selected_shadow: list[Candidate] = [] reserved_event_ids: set[str] = set() reserved_symbols: set[str] = set() for engine in self._shadow_strategy_engines: if not self._engine_allowed_for_date(engine, date): continue if not self._engine_uses_snapshot_candidates(engine): continue prelimit = self.config.signal.max_candidates_per_day if self._engine_requires_attention(engine): prelimit = max(prelimit * 5, prelimit) 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, truncate_to=prelimit, excluded_event_ids=reserved_event_ids, excluded_symbols=reserved_symbols, ) selected = self._apply_attention_filters(selected, engine) if selected: selected_shadow.extend(selected) if engine.residual_reserve_selected: reserved_event_ids.update(candidate.event_id for candidate in selected) reserved_symbols.update(candidate.symbol.upper() for candidate in selected) return selected_shadow def _engine_uses_snapshot_candidates(self, engine: Any) -> bool: if getattr(engine, "synthetic_only", False): return False # Backward compatibility: delayed add-ons were always intended to be # scheduled synthetic child lots, not direct snapshot candidates. if engine.engine_id == "delayed_add_on_long": return False return True def _engine_allowed_for_date(self, engine: Any, date: dt.date) -> bool: allowed_regimes = getattr(engine, "allowed_macro_regimes", None) if not allowed_regimes: return True if not self.config.risk.macro_regime_enabled: return True return self._macro_regime_state_for_date(date) in set(allowed_regimes) def _engine_requires_attention(self, engine: Any) -> bool: return self._attention_service.engine_requires_attention(engine) def _engine_requires_attention_data(self, engine: Any) -> bool: return self._attention_service.engine_requires_attention_data(engine) def _apply_attention_filters( self, candidates: list[Candidate], engine: Any, ) -> list[Candidate]: """Delegate to shared AttentionFilterService.""" return self._attention_service.apply_filters( candidates, engine, self.config.signal, ) def _get_event_attention(self, candidate: Candidate) -> EventAttentionResponse | None: event_date = candidate.event_date or candidate.reaction_date cache_key = (candidate.symbol, event_date) if cache_key in self._attention_cache: return self._attention_cache[cache_key] if not self._attention_base_url or self._attention_session is None: self._attention_cache[cache_key] = None return None try: response = self._attention_session.get( f"{self._attention_base_url}/api/v1/attention/event/{candidate.symbol}", params={"event_date": event_date.isoformat()}, timeout=30, ) if response.status_code >= 400: logger.debug( "attention_fetch_failed", symbol=candidate.symbol, event_date=event_date.isoformat(), status_code=response.status_code, ) self._attention_cache[cache_key] = None return None payload = EventAttentionResponse.model_validate(response.json()) except Exception as exc: logger.warning( "attention_fetch_error", symbol=candidate.symbol, event_date=event_date.isoformat(), error=str(exc), ) self._attention_cache[cache_key] = None return None self._attention_cache[cache_key] = payload return payload def _passes_attention_filters( self, engine: Any, attention: EventAttentionResponse, ) -> bool: if ( engine.attention_min_wiki_spike_10d is not None and ( attention.wiki.spike_10d is None or attention.wiki.spike_10d < engine.attention_min_wiki_spike_10d ) ): return False if ( engine.attention_min_wiki_zscore_20d is not None and ( attention.wiki.zscore_20d is None or attention.wiki.zscore_20d < engine.attention_min_wiki_zscore_20d ) ): return False if ( engine.attention_max_wiki_spike_10d is not None and ( attention.wiki.spike_10d is not None and attention.wiki.spike_10d > engine.attention_max_wiki_spike_10d ) ): return False if ( engine.attention_max_wiki_zscore_20d is not None and ( attention.wiki.zscore_20d is not None and attention.wiki.zscore_20d > engine.attention_max_wiki_zscore_20d ) ): return False if ( engine.attention_min_article_count_3d is not None and attention.news.article_count_3d < engine.attention_min_article_count_3d ): return False if ( engine.attention_min_us_article_count_3d is not None and attention.news.us_article_count_3d < engine.attention_min_us_article_count_3d ): return False if ( engine.attention_min_resolver_confidence is not None and attention.entity.resolver_confidence < engine.attention_min_resolver_confidence ): return False return True def _attach_attention_features( self, candidate: Candidate, attention: EventAttentionResponse, ) -> Candidate: features = dict(candidate.features) features.update( { "attention_wiki_spike_10d": attention.wiki.spike_10d, "attention_wiki_zscore_20d": attention.wiki.zscore_20d, "attention_article_count_3d": attention.news.article_count_3d, "attention_us_article_count_3d": attention.news.us_article_count_3d, "attention_gdelt_status": attention.news.gdelt_status, "attention_resolver_confidence": attention.entity.resolver_confidence, } ) return candidate.model_copy(update={"features": features}) def _maybe_rescore_with_attention(self, candidate: Candidate) -> Candidate: if self.config.signal.scoring_model not in {"return_max_long_v1", "return_max_long_v2", "return_max_long_v3", "return_max_long_v4", "return_max_long_v5", "return_max_long_v6", "return_max_long_v7", "return_max_long_v8", "return_max_long_v12", "return_max_long_v12r", "return_max_long_v12b", "return_max_long_v12o"}: return candidate from libs.backtest.scoring import ( compute_return_max_long_score, compute_return_max_long_score_v2, compute_return_max_long_score_v3, compute_return_max_long_score_v4, compute_return_max_long_score_v5, compute_return_max_long_score_v6, compute_return_max_long_score_v7, compute_return_max_long_score_v8, compute_return_max_long_score_v9, compute_return_max_long_score_v9g, compute_return_max_long_score_v10, compute_return_max_long_score_v11, compute_return_max_long_score_v11g, ) rescored_features = dict(candidate.features) rescored_features.update( { "event_type": candidate.event_type, "event_direction": rescored_features.get("event_direction"), } ) if self.config.signal.scoring_model == "return_max_long_v11": score = compute_return_max_long_score_v11(rescored_features) elif self.config.signal.scoring_model == "return_max_long_v11g": score = compute_return_max_long_score_v11g(rescored_features) elif self.config.signal.scoring_model == "return_max_long_v10": score = compute_return_max_long_score_v10(rescored_features) elif self.config.signal.scoring_model == "return_max_long_v9g": score = compute_return_max_long_score_v9g(rescored_features) elif self.config.signal.scoring_model == "return_max_long_v9": score = compute_return_max_long_score_v9(rescored_features) elif self.config.signal.scoring_model == "return_max_long_v8": score = compute_return_max_long_score_v8(rescored_features) elif self.config.signal.scoring_model == "return_max_long_v7": score = compute_return_max_long_score_v7(rescored_features) elif self.config.signal.scoring_model == "return_max_long_v6": score = compute_return_max_long_score_v6(rescored_features) elif self.config.signal.scoring_model == "return_max_long_v3": score = compute_return_max_long_score_v3(rescored_features) elif self.config.signal.scoring_model == "return_max_long_v5": score = compute_return_max_long_score_v5(rescored_features) elif self.config.signal.scoring_model == "return_max_long_v4": score = compute_return_max_long_score_v4(rescored_features) elif self.config.signal.scoring_model == "return_max_long_v2": score = compute_return_max_long_score_v2(rescored_features) elif self.config.signal.scoring_model == "return_max_long_v12": from libs.backtest.scoring import compute_return_max_long_score_v12 score = compute_return_max_long_score_v12(rescored_features) elif self.config.signal.scoring_model == "return_max_long_v12r": from libs.backtest.scoring import compute_return_max_long_score_v12r score = compute_return_max_long_score_v12r(rescored_features) elif self.config.signal.scoring_model == "return_max_long_v12b": from libs.backtest.scoring import compute_return_max_long_score_v12b score = compute_return_max_long_score_v12b(rescored_features) elif self.config.signal.scoring_model == "return_max_long_v12o": from libs.backtest.scoring import compute_return_max_long_score_v12o score = compute_return_max_long_score_v12o(rescored_features) else: score = compute_return_max_long_score(rescored_features) return candidate.model_copy(update={"score": score}) def _check_next_open_gap_cap( self, candidate: Candidate, bar: dict[str, Any] | None, ) -> str | None: if candidate.entry_timing_policy != "next_open": return None if candidate.engine_next_open_gap_cap_pct is None: return None if bar is None or bar.get("open") is None: return None reaction_close = candidate.features.get("event_close") if reaction_close is None: return None try: reaction_close = float(reaction_close) except (TypeError, ValueError): return None if reaction_close <= 0: return None gap = float(bar["open"]) / reaction_close - 1.0 if gap > candidate.engine_next_open_gap_cap_pct: return "next_open_gap_cap" return None 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 _interleave_engine_candidates_by_head_score( self, engine_queues: dict[str, list[Candidate]], ) -> list[Candidate]: """Round-robin by picking the strongest current head candidate each turn.""" if not engine_queues: return [] working = { engine_id: list(candidates) for engine_id, candidates in engine_queues.items() } engine_order = { engine.engine_id: index for index, engine in enumerate(self._active_strategy_engines) } ordered: list[Candidate] = [] while True: head_pool: list[tuple[float, int, Candidate]] = [] for engine in self._active_strategy_engines: queue = working.get(engine.engine_id, []) if not queue: continue candidate = queue[0] head_pool.append( ( candidate.score, -engine_order.get(engine.engine_id, 0), candidate, ) ) if not head_pool: break _, _, winner = max(head_pool, key=lambda item: (item[0], item[1])) ordered.append(working[winner.engine_id].pop(0)) return ordered[: self.config.signal.max_candidates_per_day] def _reorder_candidates_for_funding( self, candidates: list[Candidate], portfolio_state: DailyPortfolioState, macro_data: dict[str, Any] | None, ) -> list[Candidate]: mode = self.config.strategy_engine_selection_mode if mode not in { "interleave_cap_efficiency_soft", "interleave_cap_efficiency_strict", "interleave_cash_tiebreak", }: return candidates ranked: list[tuple[float, float, int, Candidate]] = [] skipped: list[tuple[int, Candidate]] = [] for idx, candidate in enumerate(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 or plan.shares <= 0: skipped.append((idx, candidate)) continue estimated_cash = max(float(plan.shares * candidate.entry_price_est), 1.0) if mode == "interleave_cash_tiebreak": score_band = math.floor(candidate.score / 0.02) efficiency = float(score_band) cash_rank = estimated_cash elif mode == "interleave_cap_efficiency_strict": efficiency = candidate.score / estimated_cash cash_rank = candidate.score else: efficiency = candidate.score / math.sqrt(estimated_cash) cash_rank = candidate.score ranked.append((efficiency, cash_rank, idx, candidate)) if mode == "interleave_cash_tiebreak": ranked.sort(key=lambda item: (-item[0], item[1], item[2])) else: ranked.sort(key=lambda item: (-item[0], -item[1], item[2])) ordered = [candidate for _, _, _, candidate in ranked] ordered.extend(candidate for _, candidate in sorted(skipped, key=lambda item: item[0])) return ordered def _build_effective_execution_config(self, candidate: Candidate) -> ExecutionConfig: """Resolve per-engine and per-event execution overrides. Delegates to shared function in libs.backtest.execution for consistency with PaperTradingEngine. """ from libs.backtest.execution import build_effective_execution_config return build_effective_execution_config(candidate, self.config) def _build_per_engine_metrics(self) -> dict[str, dict[str, Any]]: """Compute per-engine trade attribution from the main run's trades. Instead of re-running the full backtest N times (one per engine), group actual trades by engine_id and compute metrics for each group. """ # Group trades by engine_id engine_trades: dict[str, list[FilledTrade]] = defaultdict(list) engine_candidates: dict[str, dict[str, Candidate]] = defaultdict(dict) for trade in self._closed_trades: cand = self._candidate_map.get(trade.trade_id) eid = cand.engine_id if cand else "unknown" engine_trades[eid].append(trade) if cand: engine_candidates[eid][trade.trade_id] = cand summaries: dict[str, dict[str, Any]] = {} for engine in self.config.get_strategy_engines(): eid = engine.engine_id trades = engine_trades.get(eid, []) cand_map = engine_candidates.get(eid, {}) if trades: engine_metrics = build_metrics_bundle(trades, self._equity_curve, cand_map) metrics_dict = engine_metrics.model_dump(mode="json") else: metrics_dict = MetricsBundle().model_dump(mode="json") summaries[eid] = { "engine_id": eid, "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_r_override": engine.target_1_r_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, "trade_count": len(trades), "net_pnl": round(sum(t.net_pnl for t in trades), 4), "win_rate": ( round(sum(1 for t in trades if t.net_pnl > 0) / len(trades), 4) if trades else None ), "metrics": metrics_dict, } return summaries def _is_a_tier(self, candidate: Candidate) -> bool: threshold = self.config.signal.a_tier_score_threshold return threshold is not None and candidate.score >= threshold def _macro_regime_state_for_date(self, date: dt.date) -> str: macro_data = self.store.get_macro_for_date(date) if not self.config.risk.macro_regime_enabled: return "disabled" if self.config.risk.macro_regime_mode == "spy_qqq_scaler": spy_close = macro_data.get("spy_close") spy_sma = macro_data.get("spy_sma_20") qqq_close = macro_data.get("qqq_close") qqq_sma = macro_data.get("qqq_sma_20") if None in (spy_close, spy_sma, qqq_close, qqq_sma): return "unknown" spy_on = float(spy_close) >= float(spy_sma) qqq_on = float(qqq_close) >= float(qqq_sma) if spy_on and qqq_on: return "risk_on" if spy_on or qqq_on: return "neutral" return "risk_off" spy_close = macro_data.get("spy_close") spy_sma = macro_data.get("spy_sma_20") if spy_close is None or spy_sma is None: return "unknown" return "risk_off" if float(spy_close) < float(spy_sma) else "risk_on" def _queue_pending_open_exit(self, date: dt.date, payload: dict[str, Any]) -> None: next_date = self._next_trading_day.get(date) if next_date is None: return existing = self._pending_open_exits.get(next_date, []) if any( item.get("position_id") == payload.get("position_id") and item.get("reason") == payload.get("reason") for item in existing ): return self._pending_open_exits[next_date].append(payload) def _evaluate_pending_open_exit( self, position: OpenPosition, bar: dict[str, Any], execution_config: ExecutionConfig, date: dt.date, ) -> dict[str, Any] | None: if position.plan.candidate.trade_direction != "long": return None close_value = bar.get("close") if close_value is None: return None close_value = float(close_value) reaction_close = position.plan.candidate.features.get("event_close") if reaction_close is None: reaction_close = position.plan.candidate.entry_price_est try: reaction_close = float(reaction_close) except (TypeError, ValueError): reaction_close = position.plan.candidate.entry_price_est if ( execution_config.early_failure_close_below_entry_and_reaction_close and position.days_held == 1 and close_value < position.entry_price and close_value < reaction_close ): return { "position_id": position.position_id, "reason": "EARLY_FAILURE", "fraction": 1.0, } no_progress_days = execution_config.early_failure_no_progress_days no_progress_r = execution_config.early_failure_no_progress_r if ( no_progress_days is not None and no_progress_r is not None and position.days_held >= no_progress_days and position.days_held == no_progress_days and position.status != PositionStatus.PARTIALLY_EXITED ): initial_r = abs(position.entry_price - position.plan.stop_price) progress_price = position.entry_price + initial_r * no_progress_r if close_value < progress_price: return { "position_id": position.position_id, "reason": "NO_PROGRESS", "fraction": execution_config.early_failure_no_progress_fraction or 0.5, } trigger_r = execution_config.early_pop_giveback_trigger_r min_r = execution_config.early_pop_giveback_min_r from_peak_pct = execution_config.early_pop_giveback_from_peak_pct if ( trigger_r is not None and min_r is not None and from_peak_pct is not None and position.status != PositionStatus.PARTIALLY_EXITED ): event_direction = str(position.plan.candidate.features.get("event_direction", "")).lower() guidance_status = str(position.plan.candidate.features.get("guidance_status", "")).lower() is_unknown_inline = ( event_direction == "unknown" and guidance_status == "inline_or_maintained" ) days_min = execution_config.early_pop_giveback_days_min or 1 days_max = execution_config.early_pop_giveback_days_max or position.days_held if not is_unknown_inline and days_min <= position.days_held <= days_max: initial_r = abs(position.entry_price - position.plan.stop_price) if initial_r > 0: peak_progress = position.peak_price - position.entry_price close_progress = close_value - position.entry_price gave_back_r = close_progress < (initial_r * min_r) gave_back_pct = close_value < (position.peak_price * (1.0 - from_peak_pct)) if peak_progress >= (initial_r * trigger_r) and (gave_back_r or gave_back_pct): return { "position_id": position.position_id, "reason": "GIVEBACK", "fraction": execution_config.early_pop_giveback_fraction or 1.0, } return None def _process_pending_open_exits(self, date: dt.date) -> None: payloads = self._pending_open_exits.pop(date, []) if not payloads: return by_position_id = {payload["position_id"]: payload for payload in payloads} remaining_positions: list[OpenPosition] = [] for position in self._open_positions: payload = by_position_id.get(position.position_id) if payload is None: remaining_positions.append(position) continue bar = self.store.get_bar(position.plan.candidate.symbol, date) if bar is None: remaining_positions.append(position) continue trade = simulate_scheduled_open_exit( position=position, bar=bar, config=self._build_effective_execution_config(position.plan.candidate), current_date=date, reason=payload["reason"], fraction=float(payload.get("fraction", 1.0)), ) if trade is None: remaining_positions.append(position) continue self._closed_trades.append(trade) self._candidate_map[trade.trade_id] = position.plan.candidate self._realized_pnl += trade.net_pnl self._cash += trade.net_pnl + (trade.entry_price * trade.shares) 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 if position.shares_open > 0: remaining_positions.append(position) self._open_positions = remaining_positions def _schedule_add_on_candidates(self, date: dt.date) -> None: next_date = self._next_trading_day.get(date) if next_date is None: return add_on_engine = next( (engine for engine in self._active_strategy_engines if engine.engine_id == "delayed_add_on_long"), None, ) if add_on_engine is None: return if self._macro_regime_state_for_date(date) == "risk_off": return for position in self._open_positions: if position.is_add_on: continue current_add_on_count = self._parent_add_on_counts.get(position.position_id, 0) max_add_on_count = max(1, add_on_engine.add_on_max_count) if current_add_on_count >= max_add_on_count: continue if position.plan.candidate.trade_direction != "long": continue min_days_held = add_on_engine.add_on_min_parent_days_held or 1 max_days_held = add_on_engine.add_on_max_parent_days_held or 2 if position.days_held < min_days_held or position.days_held > max_days_held: continue if ( add_on_engine.add_on_schedule_days and position.days_held not in set(add_on_engine.add_on_schedule_days) ): continue if ( add_on_engine.add_on_parent_score_min is not None and position.plan.candidate.score < add_on_engine.add_on_parent_score_min ): continue if ( add_on_engine.add_on_parent_engine_ids and position.plan.candidate.engine_id not in add_on_engine.add_on_parent_engine_ids ): continue bar = self.store.get_bar(position.plan.candidate.symbol, date) if bar is None or bar.get("close") is None: continue close_value = float(bar["close"]) if close_value <= position.entry_price: continue reaction_close = position.plan.candidate.features.get("event_close") if reaction_close is None: reaction_close = position.plan.candidate.entry_price_est try: reaction_close = float(reaction_close) except (TypeError, ValueError): reaction_close = position.plan.candidate.entry_price_est if close_value <= reaction_close: continue high = bar.get("high") low = bar.get("low") if high is None or low is None or float(high) <= float(low): continue close_location = (close_value - float(low)) / (float(high) - float(low)) close_location_min = add_on_engine.add_on_close_location_min or 0.65 if close_location < close_location_min: continue initial_r = abs(position.entry_price - position.plan.stop_price) progress_levels = add_on_engine.add_on_progress_r_levels or [] if progress_levels: progress_r_min = progress_levels[min(current_add_on_count, len(progress_levels) - 1)] else: progress_r_min = add_on_engine.add_on_progress_r_min or 0.5 if close_value <= position.entry_price + (progress_r_min * initial_r): continue if add_on_engine.add_on_require_above_reaction_high: reaction_high = position.plan.candidate.features.get("reaction_day_high") try: if reaction_high is None or close_value <= float(reaction_high): continue except (TypeError, ValueError): continue size_fraction = add_on_engine.add_on_size_fraction or 0.5 forced_shares = max(1, int(position.shares_open * size_fraction)) candidate = position.plan.candidate.model_copy( update={ "engine_id": add_on_engine.engine_id, "entry_timing_policy": "next_open", "execution_date": next_date, "reaction_date": date, "entry_price_est": close_value, "engine_max_holding_days": add_on_engine.max_holding_days, "engine_risk_budget_pct": add_on_engine.engine_risk_budget_pct, "engine_target_atr_multiplier": add_on_engine.target_atr_multiplier_override, "engine_target_1_r": add_on_engine.target_1_r_override, "engine_target_1_fraction": add_on_engine.target_1_fraction_override, "engine_trailing_model": add_on_engine.trailing_model_override, "engine_trailing_warmup_days": add_on_engine.trailing_warmup_days_override, "engine_next_open_gap_cap_pct": add_on_engine.next_open_gap_cap_pct, "engine_add_on_max_count": add_on_engine.add_on_max_count, "engine_add_on_size_fraction": add_on_engine.add_on_size_fraction, "parent_position_id": position.position_id, "is_add_on": True, "forced_shares": forced_shares, "features": { **position.plan.candidate.features, "add_on_signal_date": date.isoformat(), "add_on_close_location": close_location, "add_on_progress_r_min": progress_r_min, "add_on_index": current_add_on_count + 1, }, } ) self._scheduled_add_ons[next_date].append(candidate) self._parent_add_on_counts[position.position_id] += 1 def _schedule_delayed_entry_candidates(self, date: dt.date) -> None: """Generate delayed-entry candidates from past events where drift is confirmed. For engines with delayed_entry_lookback_days set, look back N trading days to find scored candidates whose price has continued drifting upward. This captures the continuation phase of PEAD after initial momentum is confirmed. """ next_date = self._next_trading_day.get(date) if next_date is None: return delayed_engines = [ e for e in self._active_strategy_engines if e.delayed_entry_lookback_days is not None ] if not delayed_engines: return open_symbols = {p.plan.candidate.symbol for p in self._open_positions} for engine in delayed_engines: lookback = engine.delayed_entry_lookback_days source_engine_ids = set(engine.delayed_entry_source_engine_ids or []) min_drift = engine.delayed_entry_min_drift_pct or 0.0 cl_min = engine.delayed_entry_close_location_min or 0.50 # Find the date that was `lookback` trading days ago try: sim_idx = self._simulation_dates.index(date) except ValueError: continue if sim_idx < lookback: continue lookback_date = self._simulation_dates[sim_idx - lookback] past_candidates = self._recent_scored_candidates.get(lookback_date, []) for past_cand in past_candidates: if source_engine_ids and past_cand.engine_id not in source_engine_ids: continue if past_cand.symbol in open_symbols: continue if past_cand.trade_direction != "long": continue bar = self.store.get_bar(past_cand.symbol, date) if bar is None or bar.get("close") is None: continue close_value = float(bar["close"]) reaction_close = past_cand.entry_price_est if reaction_close <= 0: continue drift_pct = (close_value - reaction_close) / reaction_close if drift_pct < min_drift: continue high = bar.get("high") low = bar.get("low") if high is None or low is None or float(high) <= float(low): continue today_cl = (close_value - float(low)) / (float(high) - float(low)) if today_cl < cl_min: continue candidate = past_cand.model_copy( update={ "engine_id": engine.engine_id, "entry_timing_policy": "next_open", "execution_date": next_date, "reaction_date": date, "entry_price_est": close_value, "engine_max_holding_days": engine.max_holding_days, "engine_risk_budget_pct": engine.engine_risk_budget_pct, "engine_per_trade_risk_pct": engine.per_trade_risk_pct_override, "engine_target_1_r": engine.target_1_r_override, "engine_target_1_fraction": engine.target_1_fraction_override, "engine_trailing_model": engine.trailing_model_override, "engine_trailing_warmup_days": engine.trailing_warmup_days_override, "engine_stop_atr_multiplier": engine.stop_atr_multiplier_override, "engine_next_open_gap_cap_pct": engine.next_open_gap_cap_pct, "engine_use_reaction_day_low_stop": False, "shadow_only": engine.shadow_only, "is_add_on": False, "parent_position_id": None, "forced_shares": None, "features": { **past_cand.features, "delayed_entry_signal_date": date.isoformat(), "delayed_entry_drift_pct": round(drift_pct, 4), "delayed_entry_close_location": round(today_cl, 4), }, } ) self._scheduled_delayed_entries[next_date].append(candidate) def _release_add_on_reservation(self, candidate: Candidate) -> None: if not candidate.is_add_on or candidate.parent_position_id is None: return reserved = self._parent_add_on_counts.get(candidate.parent_position_id, 0) if reserved <= 0: return self._parent_add_on_counts[candidate.parent_position_id] = reserved - 1 def _build_benchmark_and_contribution_metrics(self, metrics: MetricsBundle) -> dict[str, Any]: updates: dict[str, Any] = {} qqq_return = self._compute_qqq_benchmark_return_pct() updates["qqq_benchmark_return_pct"] = qqq_return updates["excess_vs_qqq_pct"] = ( metrics.total_return_pct - qqq_return if metrics.total_return_pct is not None and qqq_return is not None else None ) short_net_pnl = sum( trade.net_pnl for trade in self._closed_trades if self._candidate_map.get(trade.trade_id) is not None and self._candidate_map[trade.trade_id].trade_direction == "short" ) long_net_pnl = sum( trade.net_pnl for trade in self._closed_trades if self._candidate_map.get(trade.trade_id) is not None and self._candidate_map[trade.trade_id].trade_direction == "long" ) total_net_pnl = long_net_pnl + short_net_pnl updates["long_net_pnl"] = round(long_net_pnl, 4) updates["short_net_pnl"] = round(short_net_pnl, 4) if total_net_pnl != 0: updates["long_pnl_contribution_pct"] = long_net_pnl / total_net_pnl * 100.0 updates["short_pnl_contribution_pct"] = short_net_pnl / total_net_pnl * 100.0 else: updates["long_pnl_contribution_pct"] = None updates["short_pnl_contribution_pct"] = None return updates def _compute_qqq_benchmark_return_pct(self) -> float | None: if not self._equity_curve: return None first = None last = None for state in self._equity_curve: qqq_close = self.store.get_macro_for_date(state.date).get("qqq_close") if qqq_close is None: continue if first is None: first = qqq_close last = qqq_close if first in (None, 0) or last is None: return None return (float(last) - float(first)) / float(first) * 100.0 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, sizing_equity=self._sizing_equity, cash_available=self._compute_buying_power(self._equity, gross_exposure), 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 _attempt_same_day_cash_recycle( self, date: dt.date, candidate: Candidate, portfolio_state: DailyPortfolioState, ) -> bool: engine = next( (item for item in self._active_strategy_engines if item.engine_id == candidate.engine_id), None, ) if engine is None or not engine.recycle_on_cash_block: return False if candidate.trade_direction != "long": return False if candidate.entry_timing_policy not in {"reaction_close", "next_open"}: return False shortfall = self._estimate_cash_shortfall(candidate, portfolio_state) if shortfall <= 0: return False victim = self._select_recycle_victim( date=date, candidate=candidate, shortfall=shortfall, engine=engine, ) if victim is None: return False return self._execute_same_day_recycle_exit(victim, date, candidate) def _estimate_cash_shortfall( self, candidate: Candidate, portfolio_state: DailyPortfolioState, ) -> float: trade_risk_pct = _resolve_effective_per_trade_risk_pct(candidate, self.config) stop_price = compute_stop_price(candidate, _resolve_stop_risk_config(candidate, self.config)) shares = compute_shares( _resolve_sizing_equity(portfolio_state), candidate.entry_price_est, stop_price, self.config.risk, risk_pct_override=trade_risk_pct, ) shares = _cap_shares_by_position_limits(shares, candidate, portfolio_state, self.config) if self.config.risk.allow_budget_downsizing: remaining_risk, _, _ = _remaining_risk_budget_dollars( candidate, portfolio_state, self.config, engine_daily_new_risk_used=self._engine_daily_new_risk_used[candidate.engine_id], ) shares = _cap_shares_to_remaining_risk_budget( shares, candidate.entry_price_est, stop_price, remaining_risk, ) required_notional = max(0.0, shares * float(candidate.entry_price_est)) return max(0.0, required_notional - portfolio_state.cash_available) def _select_recycle_victim( self, *, date: dt.date, candidate: Candidate, shortfall: float, engine: Any, ) -> OpenPosition | None: allow_any_engine = bool(getattr(engine, "recycle_allow_any_victim_engine", False)) allow_cross_timing = bool(getattr(engine, "recycle_allow_cross_timing", False)) allowed_victims = None if allow_any_engine else set( engine.recycle_allowed_victim_engine_ids or [candidate.engine_id] ) min_days = engine.recycle_min_days_held or 0 min_delta = engine.recycle_min_score_delta or 0.0 max_victim_fitness = getattr(engine, "recycle_max_victim_fitness", None) max_victim_unrealized_r = getattr(engine, "recycle_max_victim_unrealized_r", None) use_generic_victim_filter = ( allow_any_engine or allow_cross_timing or max_victim_fitness is not None or max_victim_unrealized_r is not None ) eligible: list[tuple[float, float, float, float, int, OpenPosition]] = [] for position in self._open_positions: victim_candidate = position.plan.candidate if victim_candidate.trade_direction != candidate.trade_direction: continue if not allow_cross_timing and victim_candidate.entry_timing_policy != candidate.entry_timing_policy: continue if allowed_victims is not None and victim_candidate.engine_id not in allowed_victims: continue if position.days_held < min_days: continue if candidate.score < (victim_candidate.score + min_delta): continue bar = self.store.get_bar(victim_candidate.symbol, date) if bar is None or bar.get("close") is None: continue close_value = float(bar["close"]) if close_value <= 0: continue if engine.recycle_positive_pnl_only and close_value < position.entry_price: continue effective_exec = self._build_effective_execution_config(victim_candidate) fitness = self._compute_hold_fitness(position, bar, effective_exec) stop_dist = abs(position.entry_price - position.plan.stop_price) unrealized_r = ( (close_value - position.entry_price) / stop_dist if stop_dist > 0 else 0.0 ) if max_victim_fitness is not None and fitness > max_victim_fitness: continue if max_victim_unrealized_r is not None and unrealized_r > max_victim_unrealized_r: continue proceeds = close_value * position.shares_open if proceeds < shortfall: continue if use_generic_victim_filter: eligible.append( (fitness, unrealized_r, victim_candidate.score, proceeds, -position.days_held, position) ) else: eligible.append( (victim_candidate.score, proceeds, -position.days_held, proceeds, -position.days_held, position) ) if not eligible: return None eligible.sort(key=lambda item: item[:-1]) return eligible[0][-1] def _execute_same_day_recycle_exit( self, position: OpenPosition, date: dt.date, candidate: Candidate, ) -> bool: bar = self.store.get_bar(position.plan.candidate.symbol, date) trade = simulate_recycle_close_exit( position=position, bar=bar, current_date=date, config=self._build_effective_execution_config(position.plan.candidate), ) if trade is None: return False self._closed_trades.append(trade) self._candidate_map[trade.trade_id] = position.plan.candidate self._realized_pnl += trade.net_pnl self._cash += trade.net_pnl + (trade.entry_price * trade.shares) self._open_positions = [ existing for existing in self._open_positions if existing.position_id != position.position_id ] logger.info( "same_day_recycle_exit", date=str(date), victim_symbol=position.plan.candidate.symbol, victim_engine=position.plan.candidate.engine_id, replacement_symbol=candidate.symbol, replacement_engine=candidate.engine_id, ) return True def _compute_hold_fitness( self, position: OpenPosition, bar: dict[str, Any], effective_exec: ExecutionConfig, ) -> float: """Multi-factor fitness score for a held position. Lower = weaker hold.""" close_price = float(bar.get("close", position.entry_price)) max_days = effective_exec.max_holding_days or 25 time_used = min(1.0, position.days_held / max_days) stop_dist = abs(position.entry_price - position.plan.stop_price) unrealized_r = (close_price - position.entry_price) / stop_dist if stop_dist > 0 else 0.0 target_r = effective_exec.target_1_r or effective_exec.a_tier_target_1_r or 2.0 progress = unrealized_r / target_r if target_r > 0 else 0.0 peak = position.peak_price if position.peak_price > 0 else position.entry_price peak_dd = (peak - close_price) / peak if peak > 0 else 0.0 return 0.4 * progress + 0.3 * (1.0 - time_used) + 0.3 * (1.0 - peak_dd) def _attempt_rotation_exits( self, date: dt.date, candidates: list[Candidate], ) -> int: """Proactively close stale positions when good opportunities exist today. Returns number of positions rotated out. """ # Check if any engine has rotation enabled rotation_engines = { e.engine_id: e for e in self._active_strategy_engines if e.rotation_enabled } if not rotation_engines: return 0 # Check if there's a credible opportunity today min_score = min(e.rotation_min_candidate_score for e in rotation_engines.values()) has_opportunity = any(c.score >= min_score for c in candidates) if not has_opportunity: return 0 rotated = 0 positions_to_remove: list[str] = [] for position in self._open_positions: engine_cfg = rotation_engines.get(position.plan.candidate.engine_id) if engine_cfg is None: continue if position.days_held < engine_cfg.rotation_min_days_held: continue if position.status != PositionStatus.ENTERED: continue bar = self.store.get_bar(position.plan.candidate.symbol, date) if bar is None or bar.get("close") is None: continue effective_exec = self._build_effective_execution_config(position.plan.candidate) fitness = self._compute_hold_fitness(position, bar, effective_exec) unrealized_r = ( (float(bar["close"]) - position.entry_price) / max(0.01, abs(position.entry_price - position.plan.stop_price)) ) if fitness >= engine_cfg.rotation_fitness_threshold: continue if ( engine_cfg.rotation_max_unrealized_r is not None and unrealized_r > engine_cfg.rotation_max_unrealized_r ): continue trade = simulate_rotation_exit( position=position, bar=bar, current_date=date, config=effective_exec, ) if trade is None: continue self._closed_trades.append(trade) self._candidate_map[trade.trade_id] = position.plan.candidate self._realized_pnl += trade.net_pnl self._cash += trade.net_pnl + (trade.entry_price * trade.shares) positions_to_remove.append(position.position_id) rotated += 1 logger.info( "rotation_exit", date=str(date), symbol=position.plan.candidate.symbol, fitness=round(fitness, 3), days_held=position.days_held, unrealized_r=round(unrealized_r, 2), ) if positions_to_remove: self._open_positions = [ p for p in self._open_positions if p.position_id not in positions_to_remove ] return rotated 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, ) elif config.signal.scoring_model == "return_max_long_v1": from libs.backtest.scoring import compute_return_max_long_score scoring_fn = compute_return_max_long_score elif config.signal.scoring_model == "return_max_long_v2": from libs.backtest.scoring import compute_return_max_long_score_v2 scoring_fn = compute_return_max_long_score_v2 elif config.signal.scoring_model == "return_max_long_v3": from libs.backtest.scoring import compute_return_max_long_score_v3 scoring_fn = compute_return_max_long_score_v3 elif config.signal.scoring_model == "return_max_long_v4": from libs.backtest.scoring import compute_return_max_long_score_v4 scoring_fn = compute_return_max_long_score_v4 elif config.signal.scoring_model == "return_max_long_v5": from libs.backtest.scoring import compute_return_max_long_score_v5 scoring_fn = compute_return_max_long_score_v5 elif config.signal.scoring_model == "return_max_long_v6": from libs.backtest.scoring import compute_return_max_long_score_v6 scoring_fn = compute_return_max_long_score_v6 elif config.signal.scoring_model == "return_max_long_v7": from libs.backtest.scoring import compute_return_max_long_score_v7 scoring_fn = compute_return_max_long_score_v7 elif config.signal.scoring_model == "return_max_long_v8": from libs.backtest.scoring import compute_return_max_long_score_v8 scoring_fn = compute_return_max_long_score_v8 elif config.signal.scoring_model == "return_max_long_v9": from libs.backtest.scoring import compute_return_max_long_score_v9 scoring_fn = compute_return_max_long_score_v9 elif config.signal.scoring_model == "return_max_long_v9g": from libs.backtest.scoring import compute_return_max_long_score_v9g scoring_fn = compute_return_max_long_score_v9g elif config.signal.scoring_model == "return_max_long_v10": from libs.backtest.scoring import compute_return_max_long_score_v10 scoring_fn = compute_return_max_long_score_v10 elif config.signal.scoring_model == "return_max_long_v11": from libs.backtest.scoring import compute_return_max_long_score_v11 scoring_fn = compute_return_max_long_score_v11 elif config.signal.scoring_model == "return_max_long_v11g": from libs.backtest.scoring import compute_return_max_long_score_v11g scoring_fn = compute_return_max_long_score_v11g elif config.signal.scoring_model == "return_max_long_v11_surprise": from libs.backtest.scoring import compute_return_max_long_score_v11_surprise scoring_fn = compute_return_max_long_score_v11_surprise elif config.signal.scoring_model == "return_max_long_v12": from libs.backtest.scoring import compute_return_max_long_score_v12 scoring_fn = compute_return_max_long_score_v12 elif config.signal.scoring_model == "return_max_long_v12r": from libs.backtest.scoring import compute_return_max_long_score_v12r scoring_fn = compute_return_max_long_score_v12r elif config.signal.scoring_model == "return_max_long_v12b": from libs.backtest.scoring import compute_return_max_long_score_v12b scoring_fn = compute_return_max_long_score_v12b elif config.signal.scoring_model == "return_max_long_v12o": from libs.backtest.scoring import compute_return_max_long_score_v12o scoring_fn = compute_return_max_long_score_v12o elif config.signal.scoring_model == "return_max_long_v13": from libs.backtest.scoring import compute_return_max_long_score_v13 scoring_fn = compute_return_max_long_score_v13 elif config.signal.scoring_model == "return_max_long_v13h": from libs.backtest.scoring import compute_return_max_long_score_v13h scoring_fn = compute_return_max_long_score_v13h elif config.signal.scoring_model == "return_max_long_v13e": from libs.backtest.scoring import compute_return_max_long_score_v13e scoring_fn = compute_return_max_long_score_v13e elif config.signal.scoring_model == "return_max_long_v13s": from libs.backtest.scoring import compute_return_max_long_score_v13s scoring_fn = compute_return_max_long_score_v13s elif config.signal.scoring_model == "return_max_long_v15": from libs.backtest.scoring import compute_return_max_long_score_v15 scoring_fn = compute_return_max_long_score_v15 elif config.signal.scoring_model == "return_max_long_v15b": from libs.backtest.scoring import compute_return_max_long_score_v15b scoring_fn = compute_return_max_long_score_v15b elif config.signal.scoring_model == "return_max_long_v15c": from libs.backtest.scoring import compute_return_max_long_score_v15c scoring_fn = compute_return_max_long_score_v15c elif config.signal.scoring_model == "return_max_long_v15d": from libs.backtest.scoring import compute_return_max_long_score_v15d scoring_fn = compute_return_max_long_score_v15d elif config.signal.scoring_model == "return_max_long_v15e": from libs.backtest.scoring import compute_return_max_long_score_v15e scoring_fn = compute_return_max_long_score_v15e elif config.signal.scoring_model == "return_max_long_v15f": from libs.backtest.scoring import compute_return_max_long_score_v15f scoring_fn = compute_return_max_long_score_v15f elif config.signal.scoring_model == "return_max_long_v14": from libs.backtest.scoring import compute_return_max_long_score_v14 scoring_fn = compute_return_max_long_score_v14 elif config.signal.scoring_model == "return_max_long_v14_ou": from libs.backtest.scoring import compute_return_max_long_score_v14_ou scoring_fn = compute_return_max_long_score_v14_ou elif config.signal.scoring_model == "return_max_long_v14_gp": from libs.backtest.scoring import compute_return_max_long_score_v14_gp scoring_fn = compute_return_max_long_score_v14_gp elif config.signal.scoring_model == "return_max_long_v14_mt": from libs.backtest.scoring import compute_return_max_long_score_v14_mt scoring_fn = compute_return_max_long_score_v14_mt elif config.signal.scoring_model == "return_max_long_v14e": from libs.backtest.scoring import compute_return_max_long_score_v14e scoring_fn = compute_return_max_long_score_v14e elif config.signal.scoring_model == "oversold_bounce": from libs.backtest.scoring import compute_oversold_bounce_score scoring_fn = compute_oversold_bounce_score elif config.signal.scoring_model == "patient_drift": from libs.backtest.scoring import compute_patient_drift_score scoring_fn = compute_patient_drift_score elif config.signal.scoring_model == "microstructure": from libs.backtest.scoring import compute_microstructure_score scoring_fn = compute_microstructure_score 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 _build_split_result_from_metrics(run_id: str, metrics: MetricsBundle) -> SplitResult: return SplitResult( run_id=run_id, trade_count=metrics.trade_count, profit_factor=metrics.profit_factor, total_return_pct=metrics.total_return_pct, win_rate=metrics.win_rate, max_drawdown_pct=metrics.max_drawdown_pct, sharpe_ratio=metrics.sharpe_ratio, monthly_win_rate=metrics.monthly_win_rate, equity_curve_r_squared=metrics.equity_curve_r_squared, avg_gross_exposure_pct=metrics.avg_gross_exposure_pct, avg_net_exposure_pct=metrics.avg_net_exposure_pct, days_in_market_pct=metrics.days_in_market_pct, ) def _effective_profit_factor(result: SplitResult) -> float | None: if result.profit_factor is not None: return result.profit_factor if result.trade_count > 0 and result.win_rate is not None and result.win_rate >= 0.999: return 3.0 return None def _build_walk_forward_aggregate(results: list[SplitResult]) -> WalkForwardAggregate: returns = [r.total_return_pct for r in results if r.total_return_pct is not None] profit_factors = [ pf for pf in (_effective_profit_factor(r) for r in results) if pf is not None ] drawdowns = [r.max_drawdown_pct for r in results if r.max_drawdown_pct is not None] positive_folds = [ r for r in results if r.total_return_pct is not None and r.total_return_pct > 0 ] trade_counts = [float(r.trade_count) for r in results] win_rates = [r.win_rate for r in results if r.win_rate is not None] return WalkForwardAggregate( mean_return_pct=round(statistics.mean(returns), 2) if returns else None, median_return_pct=round(statistics.median(returns), 2) if returns else None, worst_return_pct=round(min(returns), 2) if returns else None, positive_fold_rate_pct=round(len(positive_folds) / len(results) * 100.0, 1) if results else None, mean_profit_factor=round(statistics.mean(profit_factors), 2) if profit_factors else None, mean_max_drawdown_pct=round(statistics.mean(drawdowns), 2) if drawdowns else None, mean_trade_count=round(statistics.mean(trade_counts), 1) if trade_counts else None, mean_win_rate=round(statistics.mean(win_rates), 4) if win_rates else None, ) def _build_walk_forward_gap_stats( train_results: list[SplitResult], test_results: list[SplitResult], ) -> WalkForwardGapStats: gaps = [ (train.total_return_pct or 0.0) - (test.total_return_pct or 0.0) for train, test in zip(train_results, test_results, strict=False) if train.total_return_pct is not None and test.total_return_pct is not None ] test_returns = [r.total_return_pct for r in test_results if r.total_return_pct is not None] fold_return_cv = None if len(test_returns) >= 2: mean_ret = statistics.mean(test_returns) if abs(mean_ret) > 1e-9: fold_return_cv = round(statistics.stdev(test_returns) / abs(mean_ret), 3) return WalkForwardGapStats( mean_train_test_return_gap_pct=round(statistics.mean(gaps), 2) if gaps else None, worst_train_test_return_gap_pct=round(max(gaps), 2) if gaps else None, fold_return_cv=fold_return_cv, ) def _build_merged_snapshot_store( manifest: ExperimentManifest, config: BacktestConfig, snapshot_dir_override: str | None, ) -> SnapshotStore: stores: list[SnapshotStore] = [] for split in ["train", "valid", "test"]: try: stores.append(_build_store(manifest, config, split, snapshot_dir_override=snapshot_dir_override)) except FileNotFoundError: continue if not stores: raise FileNotFoundError("No snapshot splits found.") merged_candidates: dict[dt.date, dict[tuple[Any, ...], dict[str, Any]]] = defaultdict(dict) merged_bars: dict[str, dict[dt.date, dict[str, Any]]] = {} merged_macro: dict[dt.date, dict[str, Any]] = {} for store in stores: for exec_date in store.all_execution_dates(): for candidate in store.get_candidates_for_date(exec_date): dedupe_key = ( candidate.get("event_id"), candidate.get("symbol"), candidate.get("execution_date"), candidate.get("reaction_date"), ) merged_candidates[exec_date].setdefault(dedupe_key, candidate) for symbol, bars in store._bars.items(): merged_bars.setdefault(symbol, {}).update(bars) for macro_date, macro_values in store._macro.items(): merged_macro.setdefault(macro_date, {}).update(macro_values) return SnapshotStore( candidates_by_exec_date={ date: list(rows.values()) for date, rows in merged_candidates.items() }, bars_by_symbol_date=merged_bars, macro_by_date=merged_macro, ) 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, ) -> WalkForwardSummary: """Run rolling walk-forward validation with explicit train/test folds.""" step_days = step_days or test_days merged_store = _build_merged_snapshot_store(manifest, config, snapshot_dir_override) all_dates = merged_store.all_trading_days(include_reaction_dates=True) if not all_dates: raise RuntimeError("No trading days found in merged snapshot data for walk-forward run.") windows = generate_walk_forward_windows( all_dates, train_days=train_days, test_days=test_days, step_days=step_days, ) if not windows: raise RuntimeError( f"Not enough data for walk-forward windows (need {train_days + test_days} days, have {len(all_dates)})." ) wf_root = Path(output_root) / "walk_forward" wf_root.mkdir(parents=True, exist_ok=True) fold_results: list[WalkForwardFoldResult] = [] train_split_results: list[SplitResult] = [] test_split_results: list[SplitResult] = [] for window in windows: fold_dir = wf_root / f"fold_{window.window_index:02d}" train_store = merged_store.slice_by_date_range(window.train_start, window.train_end) test_store = merged_store.slice_by_date_range(window.test_start, window.test_end) train_runner = BacktestRunner( manifest=manifest, config=config, store=train_store, initial_equity=initial_equity, split_name=f"wf_train_{window.window_index:02d}", ) train_result = train_runner.run(output_root=fold_dir / "train") test_runner = BacktestRunner( manifest=manifest, config=config, store=test_store, initial_equity=initial_equity, split_name=f"wf_test_{window.window_index:02d}", ) test_result = test_runner.run(output_root=fold_dir / "test") train_metrics = _build_split_result_from_metrics(train_result.run_id, train_result.metrics) test_metrics = _build_split_result_from_metrics(test_result.run_id, test_result.metrics) train_split_results.append(train_metrics) test_split_results.append(test_metrics) fold_results.append( WalkForwardFoldResult( fold_index=window.window_index, train_start=window.train_start, train_end=window.train_end, test_start=window.test_start, test_end=window.test_end, train_run_id=train_result.run_id, test_run_id=test_result.run_id, train_metrics=train_metrics, test_metrics=test_metrics, ) ) print( f"Fold {window.window_index:02d}: " f"train {window.train_start}→{window.train_end} " f"| test {window.test_start}→{window.test_end} " f"| train_ret={train_result.metrics.total_return_pct or 0:.2f}% " f"| test_ret={test_result.metrics.total_return_pct or 0:.2f}% " f"| test_trades={test_result.metrics.trade_count}" ) summary = WalkForwardSummary( train_days=train_days, test_days=test_days, step_days=step_days, fold_count=len(fold_results), folds=fold_results, train_aggregate=_build_walk_forward_aggregate(train_split_results), test_aggregate=_build_walk_forward_aggregate(test_split_results), gap_stats=_build_walk_forward_gap_stats(train_split_results, test_split_results), ) summary_path = wf_root / "walk_forward_summary.json" summary_path.write_text(summary.model_dump_json(indent=2)) print(f"\n--- Walk-Forward Summary ({summary.fold_count} folds) ---") if summary.test_aggregate.mean_return_pct is not None: print(f"Test mean return: {summary.test_aggregate.mean_return_pct:.2f}%") if summary.test_aggregate.median_return_pct is not None: print(f"Test median return: {summary.test_aggregate.median_return_pct:.2f}%") if summary.test_aggregate.worst_return_pct is not None: print(f"Test worst return: {summary.test_aggregate.worst_return_pct:.2f}%") if summary.test_aggregate.positive_fold_rate_pct is not None: print(f"Test positive fold rate: {summary.test_aggregate.positive_fold_rate_pct:.1f}%") if summary.gap_stats.mean_train_test_return_gap_pct is not None: print(f"Mean train-test gap: {summary.gap_stats.mean_train_test_return_gap_pct:.2f}%") print(f"Summary written to: {summary_path}") return summary def run_robustness_matrix( manifest: ExperimentManifest, config: BacktestConfig, snapshot_dir_override: str | None, initial_equity: float, output_root: str, horizons_days: list[int], step_days: int = 21, ) -> RobustnessMatrixSummary: """Run rolling horizon robustness validation over multiple start dates.""" merged_store = _build_merged_snapshot_store(manifest, config, snapshot_dir_override) all_dates = merged_store.all_trading_days(include_reaction_dates=True) if not all_dates: raise RuntimeError("No trading days found in merged snapshot data for robustness matrix.") windows_by_horizon = generate_robustness_windows( all_dates, horizons_days=horizons_days, step_days=step_days, ) if not windows_by_horizon: raise RuntimeError( f"Not enough data for robustness windows (need {min(horizons_days)} days, have {len(all_dates)})." ) rm_root = Path(output_root) / "robustness_matrix" rm_root.mkdir(parents=True, exist_ok=True) horizon_summaries: list[RobustnessHorizonSummary] = [] overall_results: list[SplitResult] = [] for horizon_days in sorted(windows_by_horizon): window_results: list[SplitResult] = [] for window in windows_by_horizon[horizon_days]: window_store = merged_store.slice_by_date_range(window.start, window.end) runner = BacktestRunner( manifest=manifest, config=config, store=window_store, initial_equity=initial_equity, split_name=f"rm_{horizon_days}_{window.window_index:02d}", ) result = runner.run(output_root=None) split_result = _build_split_result_from_metrics(result.run_id, result.metrics) window_results.append(split_result) overall_results.append(split_result) aggregate = _build_walk_forward_aggregate(window_results) horizon_summaries.append( RobustnessHorizonSummary( horizon_days=horizon_days, window_count=len(window_results), mean_return_pct=aggregate.mean_return_pct, median_return_pct=aggregate.median_return_pct, worst_return_pct=aggregate.worst_return_pct, positive_window_rate_pct=aggregate.positive_fold_rate_pct, mean_max_drawdown_pct=aggregate.mean_max_drawdown_pct, ) ) overall_positive = [ result for result in overall_results if result.total_return_pct is not None and result.total_return_pct > 0 ] overall_returns = [result.total_return_pct for result in overall_results if result.total_return_pct is not None] summary = RobustnessMatrixSummary( horizons_days=sorted(windows_by_horizon), step_days=step_days, overall_window_count=len(overall_results), overall_positive_window_rate_pct=( round(len(overall_positive) / len(overall_results) * 100.0, 1) if overall_results else None ), overall_worst_return_pct=round(min(overall_returns), 2) if overall_returns else None, horizon_summaries=horizon_summaries, ) summary_path = rm_root / "robustness_matrix_summary.json" summary_path.write_text(summary.model_dump_json(indent=2)) print("\n--- Robustness Matrix Summary ---") print(f"Horizons: {', '.join(str(h) for h in summary.horizons_days)}") print(f"Window count: {summary.overall_window_count}") if summary.overall_positive_window_rate_pct is not None: print(f"Positive window rate: {summary.overall_positive_window_rate_pct:.1f}%") if summary.overall_worst_return_pct is not None: print(f"Worst window return: {summary.overall_worst_return_pct:.2f}%") print(f"Summary written to: {summary_path}") return summary 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("--wf-step-days", type=int, default=None, help="Walk-forward step size (default: wf-test-days)") parser.add_argument( "--robustness-matrix", action="store_true", help="Run rolling horizon robustness matrix instead of single backtest", ) parser.add_argument( "--rm-horizons", default="21,63,126,252,504", help="Comma-separated robustness horizons in trading days", ) parser.add_argument("--rm-step-days", type=int, default=21, help="Robustness matrix step size (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 and args.robustness_matrix: raise SystemExit("Use either --walk-forward or --robustness-matrix, not both.") if args.walk_forward: 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, step_days=args.wf_step_days, ) elif args.robustness_matrix: horizons = [int(part.strip()) for part in args.rm_horizons.split(",") if part.strip()] run_robustness_matrix( manifest=manifest, config=config, snapshot_dir_override=args.snapshot_dir, initial_equity=args.initial_equity, output_root=args.output_root, horizons_days=horizons, step_days=args.rm_step_days, ) 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()