"""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 bisect import bisect_right from collections import Counter, 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.dividend_calendar import load_pit_dividend_calendar from libs.backtest.earnings_calendar import ( OraclePointInTimeEarningsCalendar, load_pit_earnings_calendar, ) from libs.backtest.form4_calendar import load_pit_form4_calendar from libs.backtest.ownership_calendar import load_pit_ownership_calendar 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.proxies import peer_candidates_for_symbol from libs.backtest.metrics import build_metrics_bundle from libs.backtest.non_core_allocator import ( classify_non_core_overlap_class, classify_parking_class, compute_marginal_score, compute_native_rank_pct, compute_overlap_penalty, normalize_hold_days_est, normalize_liquidity_penalty, normalize_parking_proxy, ) from libs.backtest.selector import rank_candidates, select_candidates from libs.backtest.snapshots import resolve_snapshot_path 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 _DIVIDEND_CAPTURE_ENGINE_ID = "idle_dividend_capture" _DIVIDEND_CAPTURE_EVENT_TYPE = "dividend_capture" _FORM4_CAPTURE_ENGINE_ID = "idle_form4_capture" _FORM4_CAPTURE_EVENT_TYPE = "form4_capture" _OWNERSHIP_CAPTURE_ENGINE_ID = "idle_ownership_13d_capture" _OWNERSHIP_CAPTURE_EVENT_TYPE = "ownership_13d_capture" _RISK_OFF_ALPHA_ENGINE_ID = "idle_risk_off_alpha" _RISK_OFF_ALPHA_EVENT_TYPE = "risk_off_alpha" _OWNERSHIP_RUNTIME_HOUSEKEEPING_PHRASES = ( "continued to hold", "shareholding percentage", "shareholding percent", "no amendment to this item", "change in the number of outstanding", "number of outstanding shares", "resulted solely from", "solely as a result of", "solely due to", ) _OWNERSHIP_RUNTIME_STRUCTURAL_PHRASES = ( "exchange agreement", "in connection with the reorganization", ) 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() self._primary_strategy_engines = [ engine for engine in self._active_strategy_engines if not getattr(engine, "post_allocation_idle_only", False) ] self._post_allocation_idle_engines = [ engine for engine in self._active_strategy_engines if getattr(engine, "post_allocation_idle_only", False) ] self._strategy_engine_lookup = { engine.engine_id: self.config.resolve_strategy_engine(engine) for engine in self.config.strategy_engines } self._capital_bucket_specs: dict[str, float] = {} for engine in self._active_strategy_engines: allocation = getattr(engine, "capital_bucket_allocation_pct", None) if allocation is None: continue bucket_id = getattr(engine, "capital_bucket_id", None) or engine.engine_id self._capital_bucket_specs[bucket_id] = max( self._capital_bucket_specs.get(bucket_id, 0.0), float(allocation), ) from libs.backtest.attention import AttentionFilterService from libs.common.config import get_settings settings = get_settings() pit_calendar_path = config.earnings_calendar_pit_path or str(settings.earnings_calendar_pit_path) self._pit_earnings_calendar = load_pit_earnings_calendar(pit_calendar_path) dividend_calendar_path = self.config.dividend_capture.pit_calendar_path self._pit_dividend_calendar = ( load_pit_dividend_calendar(dividend_calendar_path) if dividend_calendar_path else None ) form4_events_path = self.config.form4_capture.pit_events_path self._pit_form4_calendar = ( load_pit_form4_calendar(form4_events_path) if form4_events_path else None ) ownership_events_path = self.config.ownership_capture.pit_events_path self._pit_ownership_calendar = ( load_pit_ownership_calendar(ownership_events_path) if ownership_events_path else None ) self._oracle_pit_earnings_calendar = OraclePointInTimeEarningsCalendar( settings.stock_oracle_url, timeout=float(settings.stock_oracle_timeout), ) self._attention_service = AttentionFilterService( oracle_url=settings.stock_oracle_url, scoring_model=config.signal.scoring_model, timeout=float(settings.stock_oracle_timeout), ) # 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 self._primary_candidate_slate_stats: dict[dt.date, dict[str, int]] = {} # 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 self._parking_gate_in_sgov: bool = False # state for hysteresis/recovery gates self._parking_sgov_value: float = 0.0 # parallel SGOV for proportional mode self._parking_entry_date: dt.date | None = None # when parking was bought self._parking_trade_counter: int = 0 self._parking_peak_price: float = 0.0 # highest close since parking entry self._parking_stopped_out: bool = False # waiting for recovery after stop self._parking_trend_sgov: bool = False # momentum negative, waiting for re-entry threshold self._parking_sgov_entry_value: float = 0.0 # original SGOV investment (before interest) self._parking_sgov_last_price: float = 0.0 self._parking_sgov_mark_date: dt.date | None = None self._parking_sold_today: bool = False # prevent same-day re-buy (day trading) self._parking_freed_for_cash_today: bool = False # avoid same-day re-parking after cash-use liquidation self._parking_target_cache_date: dt.date | None = None self._parking_target_cache_value: str | None = None self._parking_target_cache_valid: bool = False self._parking_committed_target: str | None = None self._parking_pending_target: str | None = None self._parking_pending_target_days: int = 0 # Overlay shock brake / dwell cap state self._parking_overlay_brake_cooldown: int = 0 # remaining cooldown days after brake self._parking_overlay_hold_days: int = 0 # consecutive days holding overlay symbol # Blend mode state (QQQM + TQQQ dual position) self._parking_blend_tqqq_shares: int = 0 self._parking_blend_tqqq_avg_price: float = 0.0 self._parking_blend_qqqm_shares: int = 0 self._parking_blend_qqqm_avg_price: float = 0.0 # 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._simulation_date_index: dict[dt.date, int] = {} self._next_trading_day: dict[dt.date, dt.date] = {} self._dividend_capture_trade_counter: int = 0 self._form4_capture_trade_counter: int = 0 self._ownership_capture_trade_counter: int = 0 self._lookback_entry_enabled: bool = config.execution.lookback_entry_enabled self._lookback_injected: bool = False self._non_core_allocator_shadow_rows: list[dict[str, Any]] = [] self._non_core_allocator_shadow_row_indices_by_date: dict[dt.date, list[int]] = defaultdict(list) def _get_known_upcoming_earnings_by_symbol( self, as_of_date: dt.date, allowed_reaction_dates: list[dt.date], calendar_mode: str = "future_row", symbols: list[str] | None = None, ) -> dict[str, dt.date]: if calendar_mode in {"pit_calendar", "pit_then_fallback"}: pit_matches: dict[str, dt.date] = {} if self._pit_earnings_calendar is not None: pit_matches = self._pit_earnings_calendar.get_known_upcoming_reaction_dates( as_of_date=as_of_date, allowed_reaction_dates=allowed_reaction_dates, symbols=symbols, ) if not pit_matches: pit_matches = self._oracle_pit_earnings_calendar.get_known_upcoming_reaction_dates( as_of_date=as_of_date, allowed_reaction_dates=allowed_reaction_dates, symbols=symbols, ) if calendar_mode == "pit_calendar" or pit_matches: return pit_matches upcoming_earnings_by_symbol: dict[str, dt.date] = {} symbol_filter = { str(symbol).strip().upper() for symbol in (symbols or []) if str(symbol).strip() } for future_date in allowed_reaction_dates: for row in self.store.get_candidates_for_reaction_date(future_date): symbol = str(row.get("symbol") or "").upper() if not symbol or symbol in upcoming_earnings_by_symbol: continue if symbol_filter and symbol not in symbol_filter: continue if str(row.get("event_type") or "") != "earnings_release": continue upcoming_earnings_by_symbol[symbol] = future_date return upcoming_earnings_by_symbol @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 _resolve_close_price(self, symbol: str, date: dt.date, fallback: float) -> float: """Best-effort close price: today's bar → latest prior bar → entry price.""" bar = self.store.get_bar(symbol, date) if bar and bar.get("close") is not None and float(bar["close"]) > 0: return float(bar["close"]) latest = self.store.get_latest_bar_on_or_before(symbol, date) if latest is not None: _, prev_bar = latest if prev_bar.get("close") is not None and float(prev_bar["close"]) > 0: return float(prev_bar["close"]) return fallback 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: close = self._resolve_close_price( pos.plan.candidate.symbol, date, 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 _get_candidate_capital_bucket_id(self, candidate: Candidate) -> str | None: return candidate.engine_capital_bucket_id def _get_candidate_capital_bucket_allocation_pct(self, candidate: Candidate) -> float | None: allocation = candidate.engine_capital_bucket_allocation_pct if allocation is None: return None return float(allocation) def _capital_bucket_notional(self, bucket_id: str, date: dt.date) -> float: notional = 0.0 for position in self._open_positions: pos_bucket = self._get_candidate_capital_bucket_id(position.plan.candidate) if pos_bucket != bucket_id: continue bar = self.store.get_bar(position.plan.candidate.symbol, date) price = ( float(bar["close"]) if bar and bar.get("close") is not None and float(bar["close"]) > 0 else position.entry_price ) notional += abs(price * position.shares_open) return notional def _capital_bucket_entry_cost(self, bucket_id: str) -> float: entry_cost = 0.0 for position in self._open_positions: pos_bucket = self._get_candidate_capital_bucket_id(position.plan.candidate) if pos_bucket != bucket_id: continue entry_cost += abs(position.entry_price * position.shares_open) return entry_cost def _capital_bucket_realized_pnl(self, bucket_id: str) -> float: realized = 0.0 for trade in self._closed_trades: candidate = self._candidate_map.get(trade.trade_id) if candidate is None: continue if self._get_candidate_capital_bucket_id(candidate) != bucket_id: continue realized += float(trade.net_pnl) return realized def _capital_bucket_equity(self, bucket_id: str, date: dt.date) -> float: allocation = self._capital_bucket_specs.get(bucket_id) if allocation is None: return 0.0 initial_bucket_equity = self.initial_equity * allocation market_value = self._capital_bucket_notional(bucket_id, date) entry_cost = self._capital_bucket_entry_cost(bucket_id) unrealized = market_value - entry_cost return max( 0.0, initial_bucket_equity + self._capital_bucket_realized_pnl(bucket_id) + unrealized, ) def _capital_bucket_cash_available(self, bucket_id: str, date: dt.date) -> float: market_value = self._capital_bucket_notional(bucket_id, date) return max(0.0, self._capital_bucket_equity(bucket_id, date) - market_value) def _active_capital_bucket_ids_for_candidates(self, candidates: list[Candidate]) -> set[str]: active_bucket_ids = { bucket_id for bucket_id in ( self._get_candidate_capital_bucket_id(candidate) for candidate in candidates ) if bucket_id } for position in self._open_positions: bucket_id = self._get_candidate_capital_bucket_id(position.plan.candidate) if bucket_id: active_bucket_ids.add(bucket_id) return active_bucket_ids def _adjust_portfolio_state_for_candidate( self, *, date: dt.date, candidate: Candidate, portfolio_state: DailyPortfolioState, active_bucket_ids: set[str], ) -> DailyPortfolioState: if not self._capital_bucket_specs or portfolio_state.cash_available <= 0: return portfolio_state configured_bucket_ids = set(self._capital_bucket_specs) if not configured_bucket_ids: return portfolio_state candidate_bucket = self._get_candidate_capital_bucket_id(candidate) relevant_bucket_ids = configured_bucket_ids & active_bucket_ids if candidate_bucket and candidate_bucket in configured_bucket_ids: relevant_bucket_ids.add(candidate_bucket) if not relevant_bucket_ids: return portfolio_state bucket_cash_available = { bucket_id: self._capital_bucket_cash_available(bucket_id, date) for bucket_id in relevant_bucket_ids } bucket_equity = { bucket_id: self._capital_bucket_equity(bucket_id, date) for bucket_id in relevant_bucket_ids } sizing_equity = _resolve_sizing_equity(portfolio_state) if candidate_bucket and candidate_bucket in relevant_bucket_ids: adjusted_cash = min( portfolio_state.cash_available, bucket_cash_available[candidate_bucket], ) adjusted_sizing_equity = bucket_equity[candidate_bucket] else: adjusted_cash = max( 0.0, portfolio_state.cash_available - sum(bucket_cash_available.values()), ) adjusted_sizing_equity = max( 0.0, sizing_equity - sum(bucket_equity.values()), ) if ( math.isclose(adjusted_cash, portfolio_state.cash_available, rel_tol=0.0, abs_tol=1e-9) and math.isclose( adjusted_sizing_equity, sizing_equity, rel_tol=0.0, abs_tol=1e-9, ) ): return portfolio_state return portfolio_state.model_copy( update={ "cash_available": adjusted_cash, "sizing_equity": adjusted_sizing_equity, } ) def _reset_parallel_sgov_state(self) -> None: self._parking_sgov_value = 0.0 self._parking_sgov_entry_value = 0.0 self._parking_sgov_last_price = 0.0 self._parking_sgov_mark_date = None def _mark_parallel_sgov_to_market( self, date: dt.date, macro: dict[str, Any] | None = None, ) -> float: if self._parking_sgov_value <= 1e-9: self._reset_parallel_sgov_state() return 0.0 macro_data = macro or self.store.get_macro_for_date(date) or {} close_raw = macro_data.get("sgov_close") close = float(close_raw) if close_raw is not None else 0.0 if close > 0: if self._parking_sgov_last_price > 0 and self._parking_sgov_mark_date != date: self._parking_sgov_value *= close / self._parking_sgov_last_price self._parking_sgov_last_price = close self._parking_sgov_mark_date = date return self._parking_sgov_value def _allocate_parallel_sgov( self, *, date: dt.date, amount: float, macro: dict[str, Any] | None = None, ) -> None: if amount <= 0: return self._mark_parallel_sgov_to_market(date, macro) self._parking_sgov_value += amount self._parking_sgov_entry_value += amount macro_data = macro or {} close_raw = macro_data.get("sgov_close") close = float(close_raw) if close_raw is not None else 0.0 if close > 0: self._parking_sgov_last_price = close self._parking_sgov_mark_date = date 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._simulation_date_index = { sim_date: idx for idx, sim_date in enumerate(self._simulation_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. last_date = all_dates[-1] if all_dates else dt.date.today() if self._open_positions: self._force_close_all(last_date, reason="end_of_backtest") # Liquidate remaining parking if self._parking_shares > 0 or self._parking_sgov_value > 0: self._liquidate_parking(last_date) 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, non_core_allocator_shadow_rows=self._non_core_allocator_shadow_rows, ) 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) self._parking_sold_today = False self._parking_freed_for_cash_today = False self._parking_target_cache_date = None self._parking_target_cache_value = None self._parking_target_cache_valid = False # Decrement cooldowns if self._cooldown_remaining > 0: self._cooldown_remaining -= 1 if self._kill_switch_cooldown_remaining > 0: self._kill_switch_cooldown_remaining -= 1 if self._parking_overlay_brake_cooldown > 0: self._parking_overlay_brake_cooldown -= 1 # --- CASH PARKING: check gate, sell if signal changed, hold if same --- # Check gate + trailing stop: sell parking if signal changed or stop hit if (self._parking_shares > 0 or self._parking_current_symbol == "tqqq_blend") and self.config.risk.cash_parking_enabled: sold = False # Trailing stop: track peak, exit if down X% from peak stop_pct = self.config.risk.cash_parking_stop_pct if stop_pct > 0 and self._parking_current_symbol not in ("sgov", "", "tqqq_blend"): macro_early = self.store.get_macro_for_date(date) or {} cur_price = macro_early.get(f"{self._parking_current_symbol}_close") if cur_price: self._parking_peak_price = max(self._parking_peak_price, cur_price) if self._parking_peak_price > 0 and cur_price < self._parking_peak_price * (1 - stop_pct): self._liquidate_parking(date) self._parking_stopped_out = True self._commit_parking_target("sgov") sold = True # Shock brake: fast exit from overlay symbol on vol acceleration / trend break overlay_sym = (self.config.risk.cash_parking_low_vol_overlay_symbol or "").lower() if not sold and overlay_sym and self.config.risk.cash_parking_overlay_shock_brake_enabled: is_overlay = ( self._parking_current_symbol == overlay_sym or self._parking_current_symbol == "tqqq_blend" ) if is_overlay: macro_early = self.store.get_macro_for_date(date) or {} if self._check_overlay_shock_brake(macro_early): self._liquidate_parking(date) self._parking_overlay_brake_cooldown = self.config.risk.cash_parking_overlay_shock_brake_cooldown_days self._parking_overlay_hold_days = 0 sold = True # Re-commit to base park symbol (QQQM) — NOT sgov base_sym = self.config.risk.cash_parking_symbol self._commit_parking_target(base_sym) # Dwell cap: max consecutive days holding overlay symbol max_hold = self.config.risk.cash_parking_overlay_max_hold_days if not sold and overlay_sym and max_hold > 0: is_overlay = ( self._parking_current_symbol == overlay_sym or self._parking_current_symbol == "tqqq_blend" ) if is_overlay and self._parking_overlay_hold_days >= max_hold: self._liquidate_parking(date) self._parking_overlay_hold_days = 0 sold = True base_sym = self.config.risk.cash_parking_symbol self._commit_parking_target(base_sym) # Dwell revalidation: periodically re-check overlay conditions reval = self.config.risk.cash_parking_overlay_revalidation_days if not sold and overlay_sym and reval > 0: is_overlay = ( self._parking_current_symbol == overlay_sym or self._parking_current_symbol == "tqqq_blend" ) if is_overlay and self._parking_overlay_hold_days > 0 and self._parking_overlay_hold_days % reval == 0: macro_reval = self.store.get_macro_for_date(date) or {} park_mode = self.config.risk.cash_parking_symbol if self._evaluate_low_vol_overlay_target(macro_reval, park_mode) is None: self._liquidate_parking(date) self._parking_overlay_hold_days = 0 sold = True self._commit_parking_target(park_mode) # Track overlay hold days if not sold and overlay_sym: is_overlay = ( self._parking_current_symbol == overlay_sym or self._parking_current_symbol == "tqqq_blend" ) if is_overlay: self._parking_overlay_hold_days += 1 # Gate signal check if not sold: target = self._evaluate_parking_target(date) if ( target == "sgov" and self.config.risk.cash_parking_gate_mode == "volatility" ): macro_early = self.store.get_macro_for_date(date) or {} relay_target = self._evaluate_defensive_relay_target( date, macro_early, self.config.risk.cash_parking_symbol, ) if relay_target is not None: target = relay_target if target and target != self._parking_current_symbol: self._liquidate_parking(date) # Check recovery after stop-out: re-enter when momentum confirms bounce if self._parking_stopped_out and self.config.risk.cash_parking_enabled: macro_rec = self.store.get_macro_for_date(date) or {} rec_days = self.config.risk.cash_parking_stop_recovery_days rec_pct = self.config.risk.cash_parking_stop_recovery_pct sym = self.config.risk.cash_parking_symbol if sym == "dynamic": sym = "qqq" mom = macro_rec.get(f"{sym}_mom_{rec_days}") if mom is not None and mom >= rec_pct: self._parking_stopped_out = False # recovery confirmed, allow re-entry # Increment days_held for all open positions for pos in self._open_positions: pos.days_held += 1 # --- DIVIDEND CAPTURE OPEN EXITS --- self._process_dividend_capture_open_exits(date) # --- OPENING EXITS (scheduled on prior close) --- if self._pending_open_exits.get(date): self._process_pending_open_exits(date) # --- RISK-OFF ALPHA OPEN EXITS --- self._process_risk_off_alpha_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: ks_bar, ks_date = bar, date if ks_bar is None: latest = self.store.get_latest_bar_on_or_before(pos.plan.candidate.symbol, date) if latest is not None: ks_date, ks_bar = latest trade = simulate_kill_switch_exit(pos, ks_bar, ks_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._get_parking_value(date) 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) # Lookback: on the first simulation day inject pre-start events still within mhd if self._lookback_entry_enabled and not self._lookback_injected: lookback = self._collect_lookback_candidates(date) if lookback: candidates = list(lookback) + list(candidates) self._lookback_injected = True 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, []) idle_delayed_candidates: list[Candidate] = [] if delayed: primary_delayed_candidates: list[Candidate] = [] for delayed_candidate in delayed: if self._is_post_allocation_idle_engine_id(delayed_candidate.engine_id): idle_delayed_candidates.append(delayed_candidate) else: primary_delayed_candidates.append(delayed_candidate) if primary_delayed_candidates: candidates = list(candidates) + primary_delayed_candidates 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) self._primary_candidate_slate_stats[date] = { "candidate_count": len(candidates), "unique_sector_count": len({candidate.sector for candidate in candidates}), } # --- ROTATION: proactively close stale positions if good candidates exist --- n_rotated = self._attempt_rotation_exits(date, candidates) if n_rotated > 0: portfolio_state = self._refresh_portfolio_state(date, drawdown_pct) portfolio_state = self._execute_candidate_entries( date=date, candidates=candidates, portfolio_state=portfolio_state, drawdown_pct=drawdown_pct, macro_data=macro_data, allow_same_day_cash_recycle=True, allow_parking_cash_release=True, ) self._schedule_add_on_candidates(date) self._schedule_delayed_entry_candidates(date) self._schedule_leader_follower_candidates(date) self._schedule_macro_short_candidates(date) self._schedule_macro_long_candidates(date) idle_candidates: list[Candidate] = [] shadow_form4_payloads: list[dict[str, Any]] = [] shadow_ownership_payloads: list[dict[str, Any]] = [] # --- IDLE-ONLY POST-ALLOCATION ENTRIES --- if not self._kill_switch_triggered and self._post_allocation_idle_engines: portfolio_state = self._refresh_portfolio_state(date, drawdown_pct) idle_candidates = self._select_post_allocation_idle_candidates_for_date(date) if idle_delayed_candidates: idle_candidates = list(idle_candidates) + self._tag_post_allocation_idle_candidates(idle_delayed_candidates) if idle_candidates: self._total_candidates_seen += len(idle_candidates) macro_data = self.store.get_macro_for_date(date) idle_candidates = self._apply_idle_alpha_meta_allocator( date=date, candidates=idle_candidates, portfolio_state=portfolio_state, ) idle_candidates = self._reorder_candidates_for_funding( idle_candidates, portfolio_state, macro_data, ) else: idle_candidates = [] live_non_core_allocator = self._non_core_allocator_v2_live_enabled() and not self._kill_switch_triggered shadow_non_core_allocator = self._non_core_allocator_v2_shadow_enabled() and not self._kill_switch_triggered if shadow_non_core_allocator: shadow_portfolio_state = self._refresh_portfolio_state(date, drawdown_pct) if self._form4_capture_enabled(): shadow_form4_payloads = self._select_form4_capture_candidates(date) if self._ownership_capture_enabled(): shadow_ownership_payloads = self._select_ownership_capture_candidates(date) idle_candidates = self._record_non_core_allocator_v2_shadow_day( date=date, portfolio_state=shadow_portfolio_state, idle_candidates=idle_candidates, form4_payloads=shadow_form4_payloads, ownership_payloads=shadow_ownership_payloads, ) if live_non_core_allocator: if self._dividend_capture_enabled(): self._enter_dividend_capture_positions(date) live_form4_payloads: list[dict[str, Any]] = [] live_ownership_payloads: list[dict[str, Any]] = [] if self._form4_capture_enabled(): live_form4_payloads = self._select_form4_capture_candidates(date) if self._ownership_capture_enabled(): live_ownership_payloads = self._select_ownership_capture_candidates(date) self._execute_non_core_allocator_v2_live_day( date=date, drawdown_pct=drawdown_pct, idle_candidates=idle_candidates, form4_payloads=live_form4_payloads, ownership_payloads=live_ownership_payloads, ) else: if idle_candidates: portfolio_state = self._refresh_portfolio_state(date, drawdown_pct) macro_data = self.store.get_macro_for_date(date) portfolio_state = self._execute_candidate_entries( date=date, candidates=idle_candidates, portfolio_state=portfolio_state, drawdown_pct=drawdown_pct, macro_data=macro_data, allow_same_day_cash_recycle=False, allow_parking_cash_release=True, ) # --- FORM 4 RESIDUAL-CASH SLEEVE --- if self._dividend_capture_enabled(): self._enter_dividend_capture_positions(date) if self._form4_capture_enabled(): self._enter_form4_capture_positions(date) # --- OWNERSHIP 13D/13G RESIDUAL-CASH SLEEVE --- if self._ownership_capture_enabled(): self._enter_ownership_capture_positions(date) if self._risk_off_alpha_enabled(): self._enter_risk_off_alpha_positions(date) self._finalize_non_core_allocator_v2_shadow_day(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" defensive_symbol = self._get_parking_defensive_symbol() # Resolve which symbol to park in gate_p = self.config.risk.cash_parking_gate_sma_period gate_mode = self.config.risk.cash_parking_gate_mode spy_c = macro_data_eod.get("spy_close") qqq_c = macro_data_eod.get("qqq_close") def _is_up(prefix: str) -> bool: """Evaluate trend gate for a given symbol prefix.""" c = macro_data_eod.get(f"{prefix}_close") if gate_mode == "dual_sma": gate_long = self.config.risk.cash_parking_gate_sma_long s_short = macro_data_eod.get(f"{prefix}_sma_{gate_p}") s_long = macro_data_eod.get(f"{prefix}_sma_{gate_long}") return bool(s_short and s_long and s_short > s_long) elif gate_mode == "drawdown": lb = self.config.risk.cash_parking_gate_drawdown_lookback dd_pct = self.config.risk.cash_parking_gate_drawdown_pct rh = macro_data_eod.get(f"{prefix}_high_{lb}") return bool(c and rh and rh > 0 and (rh - c) / rh < dd_pct) elif gate_mode == "momentum": mom_days = self.config.risk.cash_parking_gate_momentum_days mom = macro_data_eod.get(f"{prefix}_mom_{mom_days}") return bool(mom is not None and mom > 0) elif gate_mode == "pct_threshold": sma = macro_data_eod.get(f"{prefix}_sma_{gate_p}") thr = self.config.risk.cash_parking_gate_pct_threshold return bool(c and sma and sma > 0 and (c - sma) / sma >= thr) elif gate_mode == "combo": votes = 0 sma = macro_data_eod.get(f"{prefix}_sma_{gate_p}") if c and sma and c > sma: votes += 1 mom_days = self.config.risk.cash_parking_gate_momentum_days mom = macro_data_eod.get(f"{prefix}_mom_{mom_days}") if mom is not None and mom > 0: votes += 1 lb = self.config.risk.cash_parking_gate_drawdown_lookback dd_pct = self.config.risk.cash_parking_gate_drawdown_pct rh = macro_data_eod.get(f"{prefix}_high_{lb}") if c and rh and rh > 0 and (rh - c) / rh < dd_pct: votes += 1 return votes >= self.config.risk.cash_parking_gate_combo_require elif gate_mode == "hysteresis": # Asymmetric: enter QQQ at tight threshold, exit at wide threshold lb = self.config.risk.cash_parking_gate_drawdown_lookback rh = macro_data_eod.get(f"{prefix}_high_{lb}") if not (c and rh and rh > 0): return not self._parking_gate_in_sgov dd = (rh - c) / rh enter_pct = self.config.risk.cash_parking_gate_hyst_enter_pct exit_pct = self.config.risk.cash_parking_gate_hyst_exit_pct if self._parking_gate_in_sgov: # Currently in SGOV — need strong recovery to re-enter if dd < enter_pct: self._parking_gate_in_sgov = False return True return False else: # Currently in QQQ — need big drop to exit if dd >= exit_pct: self._parking_gate_in_sgov = True return False return True elif gate_mode == "slope": # SMA slope: is the SMA itself rising? sma_now = macro_data_eod.get(f"{prefix}_sma_{gate_p}") # Use momentum of SMA as proxy for slope # SMA rising = close N days ago had lower SMA # Approximate: compare current SMA to SMA from slope_period ago # We don't have lagged SMA, so use: SMA is rising if close > SMA and SMA > longer SMA sma_long = macro_data_eod.get(f"{prefix}_sma_{self.config.risk.cash_parking_gate_sma_long}") return bool(sma_now and sma_long and sma_now > sma_long) elif gate_mode == "breakout": # New N-day high → bullish lb = self.config.risk.cash_parking_gate_breakout_lookback rh = macro_data_eod.get(f"{prefix}_high_{lb}") if not (c and rh and rh > 0): return False # Close within 1% of N-day high = breakout return c >= rh * 0.99 elif gate_mode == "volatility": # Low vol = calm = park in QQQ vol_lb = self.config.risk.cash_parking_gate_vol_lookback vol = macro_data_eod.get(f"{prefix}_vol_{vol_lb}") threshold = self.config.risk.cash_parking_gate_vol_threshold if vol is None: return True # no data → assume OK return vol < threshold elif gate_mode == "recovery": # Like drawdown but with recovery confirmation to re-enter lb = self.config.risk.cash_parking_gate_drawdown_lookback dd_pct = self.config.risk.cash_parking_gate_drawdown_pct rh = macro_data_eod.get(f"{prefix}_high_{lb}") if not (c and rh and rh > 0): return not self._parking_gate_in_sgov dd = (rh - c) / rh if self._parking_gate_in_sgov: rec_days = self.config.risk.cash_parking_gate_recovery_days rec_pct = self.config.risk.cash_parking_gate_recovery_pct mom = macro_data_eod.get(f"{prefix}_mom_{rec_days}") if mom is not None and mom >= rec_pct and dd < dd_pct: self._parking_gate_in_sgov = False return True return False else: if dd >= dd_pct: self._parking_gate_in_sgov = True return False return True elif gate_mode == "vol_trend": # Vol + trend confirmation: exit only if vol high AND below SMA vol_lb = self.config.risk.cash_parking_gate_vol_lookback vol = macro_data_eod.get(f"{prefix}_vol_{vol_lb}") threshold = self.config.risk.cash_parking_gate_vol_threshold sma = macro_data_eod.get(f"{prefix}_sma_{gate_p}") if vol is None: return True # High vol but above SMA → temporary spike, stay in QQQ if vol >= threshold and c and sma and c < sma: return False # vol high + downtrend → SGOV if vol >= threshold: return True # vol high but uptrend → stay QQQ return True # vol low → QQQ elif gate_mode == "vol_dd": # Vol + drawdown safety net: vol OK AND drawdown OK → QQQ vol_lb = self.config.risk.cash_parking_gate_vol_lookback vol = macro_data_eod.get(f"{prefix}_vol_{vol_lb}") threshold = self.config.risk.cash_parking_gate_vol_threshold lb = self.config.risk.cash_parking_gate_drawdown_lookback dd_pct = self.config.risk.cash_parking_gate_drawdown_pct rh = macro_data_eod.get(f"{prefix}_high_{lb}") vol_ok = vol is None or vol < threshold dd_ok = True if c and rh and rh > 0: dd_ok = (rh - c) / rh < dd_pct return vol_ok and dd_ok elif gate_mode == "vol_regime": # 3-way: low vol → QQQ, mid vol → SPY, high vol → SGOV # Returns True/False for the asked prefix, but dynamic mode handles switching vol_lb = self.config.risk.cash_parking_gate_vol_lookback vol = macro_data_eod.get(f"{prefix}_vol_{vol_lb}") threshold = self.config.risk.cash_parking_gate_vol_threshold if vol is None: return True return vol < threshold else: # price_above (default) sma = macro_data_eod.get(f"{prefix}_sma_{gate_p}") return bool(c and sma and c > sma) defensive_up = _is_up(self._get_parking_signal_prefix(defensive_symbol)) qqq_up = _is_up("qqq") # Determine target parking symbol using centralized gate logic # This ensures trend_sgov state is checked consistently target_sym = self._evaluate_parking_target(date) if target_sym is None and gate_mode in ("vol_proportional", "vol_tqqq"): pass # handled separately below elif target_sym is None: target_sym = park_mode elif target_sym == "sgov" and gate_mode == "volatility": crisis_target = self._evaluate_crisis_relay_target(macro_data_eod) if crisis_target is not None: target_sym = crisis_target else: relay_target = self._evaluate_defensive_relay_target(date, macro_data_eod, park_mode) if relay_target is not None: target_sym = relay_target # Override for stopped out state if self._parking_stopped_out: target_sym = "sgov" # Dynamic mode uses _is_up() results (not _evaluate_parking_target) if park_mode == "dynamic" and not self._parking_stopped_out and not self._parking_trend_sgov: if defensive_up and qqq_up: target_sym = "qqq" elif defensive_up: target_sym = defensive_symbol elif qqq_up: target_sym = "qqq" else: target_sym = "sgov" # After freeing parking for event entries, keep residual cash idle until next session. # This avoids meaningless sell-at-open / buy-at-close churn in the parking sleeve. if self._parking_freed_for_cash_today: target_sym = None # Day trade prevention (margin accounts only — cash accounts exempt from PDT) elif self._parking_sold_today and self.config.risk.cash_parking_account_type == "margin": target_sym = None # If gate signal changed (e.g., QQQ→SGOV), liquidate existing parking if target_sym is not None and self._parking_shares > 0 and self._parking_current_symbol != target_sym: self._liquidate_parking(date) # Calculate investable from idle cash existing_parking_value = 0.0 if self._parking_shares > 0: p = macro_data_eod.get(f"{self._parking_current_symbol}_close", self._parking_avg_price) existing_parking_value = self._parking_shares * p existing_parking_value += self._mark_parallel_sgov_to_market(date, macro_data_eod) mv_for_parking = self._compute_positions_market_value(date) equity_est = self._cash + mv_for_parking + existing_parking_value reserve = equity_est * self.config.risk.cash_parking_reserve_pct investable = max(0.0, self._cash - reserve) if gate_mode == "vol_proportional" and investable > 0: # Sell existing parking first, then redistribute self._liquidate_parking(date) vol_lb = self.config.risk.cash_parking_gate_vol_lookback vol = macro_data_eod.get(f"qqq_vol_{vol_lb}") full_pct = self.config.risk.cash_parking_gate_vol_full_pct zero_pct = self.config.risk.cash_parking_gate_vol_zero_pct if vol is None or vol <= full_pct: qqq_frac = 1.0 elif vol >= zero_pct: qqq_frac = 0.0 else: qqq_frac = (zero_pct - vol) / (zero_pct - full_pct) # Recalculate investable after liquidation equity_est = self._cash + mv_for_parking reserve = equity_est * self.config.risk.cash_parking_reserve_pct investable = max(0.0, self._cash - reserve) qqq_amount = investable * qqq_frac sgov_amount = investable * (1.0 - qqq_frac) qqq_close = macro_data_eod.get("qqq_close") if qqq_close and qqq_close > 0 and qqq_amount >= qqq_close: shares = int(qqq_amount / qqq_close) self._parking_shares = shares self._parking_avg_price = qqq_close self._parking_current_symbol = "qqq" self._commit_parking_target("qqq") self._cash -= shares * qqq_close sgov_amount += qqq_amount - shares * qqq_close else: sgov_amount += qqq_amount if sgov_amount > 0: self._allocate_parallel_sgov(date=date, amount=sgov_amount, macro=macro_data_eod) self._cash -= sgov_amount elif gate_mode in ("science_blend", "relative_strength", "vt_blend", "vt_pair_blend") and investable > 0: # Continuous regime sizing: park some capital in risk asset, rest in SGOV. self._liquidate_parking(date) if gate_mode == "science_blend": target_sym, invested_fraction = self._evaluate_science_blend_plan( macro_data_eod, park_mode ) elif gate_mode == "vt_blend": target_sym, invested_fraction = self._evaluate_vt_blend_plan( macro_data_eod, park_mode ) elif gate_mode == "vt_pair_blend": target_sym, invested_fraction = self._evaluate_vt_pair_blend_plan( macro_data_eod, park_mode ) else: target_sym, invested_fraction = self._evaluate_relative_strength_plan( date, macro_data_eod, park_mode ) equity_est = self._cash + mv_for_parking reserve = equity_est * self.config.risk.cash_parking_reserve_pct investable = max(0.0, self._cash - reserve) risk_amount = investable * invested_fraction sgov_amount = investable - risk_amount if target_sym != "sgov": park_close = macro_data_eod.get(f"{target_sym}_close") if park_close and park_close > 0 and risk_amount >= park_close: shares = int(risk_amount / park_close) used_amount = shares * park_close if shares > 0: self._parking_shares = shares self._parking_avg_price = park_close self._parking_current_symbol = target_sym self._commit_parking_target(target_sym) self._cash -= used_amount sgov_amount += max(0.0, risk_amount - used_amount) else: sgov_amount += risk_amount else: sgov_amount += risk_amount else: sgov_amount = investable if sgov_amount > 0: self._allocate_parallel_sgov(date=date, amount=sgov_amount, macro=macro_data_eod) self._cash -= sgov_amount elif gate_mode == "vol_tqqq" and investable > 0: vol_lb = self.config.risk.cash_parking_gate_vol_lookback vol = macro_data_eod.get(f"qqq_vol_{vol_lb}") tqqq_pct = self.config.risk.cash_parking_gate_vol_tqqq_pct vol_thr = self.config.risk.cash_parking_gate_vol_threshold if vol is not None and vol < tqqq_pct: target_sym = "tqqq" elif vol is None or vol < vol_thr: target_sym = "qqq" else: target_sym = "sgov" # If symbol changed, already liquidated above if self._parking_shares > 0 and self._parking_current_symbol != target_sym: self._liquidate_parking(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 target_sym == "sgov": park_close = macro_data_eod.get("sgov_close") if park_close and park_close > 0 and investable >= park_close: new_shares = int(investable / park_close) if new_shares > 0: if self._parking_shares > 0 and self._parking_current_symbol == "sgov": total_cost = self._parking_shares * self._parking_avg_price + new_shares * park_close self._parking_shares += new_shares self._parking_avg_price = total_cost / self._parking_shares else: self._parking_shares = new_shares self._parking_avg_price = park_close self._parking_current_symbol = "sgov" self._commit_parking_target("sgov") self._cash -= new_shares * park_close else: park_close = macro_data_eod.get(f"{target_sym}_close") if park_close and park_close > 0 and investable >= park_close: new_shares = int(investable / park_close) allow_topup = True min_gain_pct = float(self.config.risk.cash_parking_topup_min_gain_pct) if ( self._parking_shares > 0 and self._parking_current_symbol == target_sym and min_gain_pct > -99 and self._parking_avg_price > 0 ): allow_topup = park_close >= self._parking_avg_price * (1.0 + min_gain_pct) if ( allow_topup and self._parking_shares > 0 and self._parking_current_symbol == target_sym and self._parking_entry_date is not None ): min_days_held = max(0, int(self.config.risk.cash_parking_topup_min_days_held or 0)) if min_days_held > 0 and (date - self._parking_entry_date).days < min_days_held: allow_topup = False if ( allow_topup and self._parking_shares > 0 and self._parking_current_symbol == target_sym and self._parking_peak_price > 0 and park_close < self._parking_peak_price ): topup_risk_score_max = float(self.config.risk.cash_parking_topup_risk_score_max or 0.0) if topup_risk_score_max > 0: risk_score = float(self._compute_parking_risk_score(macro_data_eod)) if risk_score > topup_risk_score_max: allow_topup = False if ( allow_topup and self._parking_shares > 0 and self._parking_current_symbol == target_sym and self._parking_peak_price > 0 ): max_peak_dd = float(self.config.risk.cash_parking_topup_max_peak_drawdown_pct) if max_peak_dd < 999 and park_close < self._parking_peak_price * (1.0 - max_peak_dd): allow_topup = False if self._parking_shares > 0 and self._parking_current_symbol == target_sym and allow_topup: old_cost = self._parking_shares * self._parking_avg_price self._parking_shares += new_shares self._parking_avg_price = ( old_cost + new_shares * park_close ) / self._parking_shares self._commit_parking_target(target_sym) elif new_shares > 0 and (self._parking_shares <= 0 or self._parking_current_symbol != target_sym): self._parking_shares = new_shares self._parking_avg_price = park_close self._parking_current_symbol = target_sym self._commit_parking_target(target_sym) if allow_topup: self._cash -= new_shares * park_close elif ( new_shares > 0 and self._parking_shares > 0 and self._parking_current_symbol == target_sym ): defensive_amount = new_shares * park_close self._allocate_parallel_sgov( date=date, amount=defensive_amount, macro=macro_data_eod, ) self._cash -= defensive_amount elif target_sym == "sgov": park_close = macro_data_eod.get("sgov_close") if park_close and park_close > 0 and investable >= park_close: new_shares = int(investable / park_close) if new_shares > 0: if self._parking_shares > 0 and self._parking_current_symbol == "sgov": # Add to existing SGOV position total_cost = self._parking_shares * self._parking_avg_price + new_shares * park_close self._parking_shares += new_shares self._parking_avg_price = total_cost / self._parking_shares else: self._parking_shares = new_shares self._parking_avg_price = park_close self._parking_current_symbol = "sgov" self._commit_parking_target("sgov") self._cash -= new_shares * park_close elif target_sym and investable > 0: # Overlay blend mode: QQQM + TQQQ dual position (continuous leverage) overlay_sym_cfg = (self.config.risk.cash_parking_low_vol_overlay_symbol or "").lower() _blend_handled = False if ( target_sym == overlay_sym_cfg and self.config.risk.cash_parking_overlay_blend_enabled and self._parking_current_symbol != "tqqq_blend" ): w_qqqm, w_tqqq = self._compute_overlay_blend_weights(macro_data_eod) tqqq_close = macro_data_eod.get("tqqq_close") qqqm_close = macro_data_eod.get("qqqm_close") if tqqq_close and tqqq_close > 0 and qqqm_close and qqqm_close > 0: tqqq_amount = investable * w_tqqq qqqm_amount = investable * w_qqqm tqqq_shares = int(tqqq_amount / tqqq_close) qqqm_shares = int(qqqm_amount / qqqm_close) total_cost = tqqq_shares * tqqq_close + qqqm_shares * qqqm_close if total_cost > 0: self._parking_blend_tqqq_shares = tqqq_shares self._parking_blend_tqqq_avg_price = tqqq_close self._parking_blend_qqqm_shares = qqqm_shares self._parking_blend_qqqm_avg_price = qqqm_close self._parking_shares = 0 self._parking_current_symbol = "tqqq_blend" self._commit_parking_target("tqqq_blend") self._cash -= total_cost self._parking_overlay_hold_days = 0 _blend_handled = True if not _blend_handled: bearish_sym = self.config.risk.cash_parking_bearish_symbol bearish_alloc_pct = min( 1.0, max(0.0, float(self.config.risk.cash_parking_bearish_alloc_pct or 0.0)), ) defensive_sym = self._get_parking_defensive_symbol() defensive_alloc_pct = min( 1.0, max(0.0, float(self.config.risk.cash_parking_defensive_alloc_pct or 0.0)), ) sgov_amount = 0.0 symbol_investable = investable if ( defensive_sym and target_sym == defensive_sym and defensive_alloc_pct < 0.999 ): symbol_investable = investable * defensive_alloc_pct sgov_amount = investable - symbol_investable elif ( bearish_sym and target_sym == bearish_sym and bearish_alloc_pct < 0.999 ): symbol_investable = investable * bearish_alloc_pct sgov_amount = investable - symbol_investable park_close = macro_data_eod.get(f"{target_sym}_close") if park_close and park_close > 0 and symbol_investable >= park_close: new_shares = int(symbol_investable / park_close) allow_topup = True min_gain_pct = float(self.config.risk.cash_parking_topup_min_gain_pct) if ( self._parking_shares > 0 and self._parking_current_symbol == target_sym and min_gain_pct > -99 and self._parking_avg_price > 0 ): allow_topup = park_close >= self._parking_avg_price * (1.0 + min_gain_pct) if ( allow_topup and self._parking_shares > 0 and self._parking_current_symbol == target_sym and self._parking_entry_date is not None ): min_days_held = max(0, int(self.config.risk.cash_parking_topup_min_days_held or 0)) if min_days_held > 0 and (date - self._parking_entry_date).days < min_days_held: allow_topup = False if ( allow_topup and self._parking_shares > 0 and self._parking_current_symbol == target_sym and self._parking_peak_price > 0 and park_close < self._parking_peak_price ): topup_risk_score_max = float(self.config.risk.cash_parking_topup_risk_score_max or 0.0) if topup_risk_score_max > 0: risk_score = float(self._compute_parking_risk_score(macro_data_eod)) if risk_score > topup_risk_score_max: allow_topup = False if ( allow_topup and self._parking_shares > 0 and self._parking_current_symbol == target_sym and self._parking_peak_price > 0 ): max_peak_dd = float(self.config.risk.cash_parking_topup_max_peak_drawdown_pct) if max_peak_dd < 999 and park_close < self._parking_peak_price * (1.0 - max_peak_dd): allow_topup = False if self._parking_shares > 0 and self._parking_current_symbol == target_sym and allow_topup: # Add to existing position old_cost = self._parking_shares * self._parking_avg_price self._parking_shares += new_shares self._parking_avg_price = (old_cost + new_shares * park_close) / self._parking_shares self._commit_parking_target(target_sym) elif new_shares > 0 and (self._parking_shares <= 0 or self._parking_current_symbol != target_sym): self._parking_shares = new_shares self._parking_avg_price = park_close self._parking_current_symbol = target_sym self._commit_parking_target(target_sym) if allow_topup: self._cash -= new_shares * park_close elif ( new_shares > 0 and self._parking_shares > 0 and self._parking_current_symbol == target_sym ): defensive_amount = new_shares * park_close self._allocate_parallel_sgov( date=date, amount=defensive_amount, macro=macro_data_eod, ) self._cash -= defensive_amount used_amount = new_shares * park_close if allow_topup else 0.0 if sgov_amount > 0: sgov_amount += max(0.0, symbol_investable - used_amount) elif sgov_amount > 0: sgov_amount += symbol_investable if sgov_amount > 0: self._allocate_parallel_sgov( date=date, amount=sgov_amount, macro=macro_data_eod, ) self._cash -= sgov_amount # Track parking entry date and peak price has_parking = ( self._parking_shares > 0 or self._parking_sgov_value > 0 or self._parking_current_symbol == "tqqq_blend" ) if has_parking and self._parking_entry_date is None: self._parking_entry_date = date self._parking_peak_price = self._parking_avg_price # --- 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_current_symbol == "tqqq_blend": parking_value = self._get_parking_value(date) 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 ) parking_value += self._mark_parallel_sgov_to_market(date, macro_for_eq) 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) ia_exposure_val = 0.0 for _pos in self._open_positions: if self._is_post_allocation_idle_engine_id(_pos.plan.engine_id): _close = self._resolve_close_price( _pos.plan.candidate.symbol, date, _pos.entry_price, ) ia_exposure_val += _close * _pos.shares_open 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, raw_cash=self._cash, parking_value=parking_value, idle_alpha_exposure=ia_exposure_val, primary_exposure=max(0.0, gross_exposure - ia_exposure_val), ) ) def _get_simulation_dates(self) -> list[dt.date]: """Return the full trading-day simulation range for the configured engines.""" requested_start = getattr(self.store, "_requested_start_date", None) requested_end = getattr(self.store, "_requested_end_date", None) if isinstance(requested_start, dt.date) and isinstance(requested_end, dt.date): from libs.backtest.calendar import get_trading_days if requested_start <= requested_end: return get_trading_days(requested_start, requested_end) 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 _collect_lookback_candidates(self, first_sim_date: dt.date) -> list[Candidate]: """Return candidates from before first_sim_date that are still within their holding window. Used on the first day of a bounded backtest so that events which fired before the requested start date — but whose max_holding_days has not yet expired — can still be entered at today's open price. """ from libs.backtest.calendar import get_trading_days # Compute the widest possible holding window across all engines / event profiles max_mhd = self.config.execution.max_holding_days if self.config.execution.dynamic_hold_enabled: max_mhd = max(max_mhd, self.config.execution.dynamic_hold_extend_to) for engine in self.config.get_strategy_engines(): if engine.max_holding_days is not None: engine_mhd = engine.max_holding_days if engine.dynamic_hold_extend_to_override is not None: engine_mhd = max(engine_mhd, engine.dynamic_hold_extend_to_override) max_mhd = max(max_mhd, engine_mhd) for profile in (self.config.event_type_profiles or {}).values(): if profile.max_holding_days_override is not None: max_mhd = max(max_mhd, profile.max_holding_days_override) # Collect raw rows for all execution dates before first_sim_date # Use a calendar-day buffer of 2x to cover weekends/holidays lookback_rows: list[dict[str, Any]] = [] for exec_date in sorted(self.store._candidates.keys()): if exec_date >= first_sim_date: break lookback_rows.extend(self.store._candidates[exec_date]) if not lookback_rows: return [] # Cache trading-day counts per execution_date for efficiency _elapsed_cache: dict[dt.date, int] = {} def _trading_days_elapsed(exec_date: dt.date) -> int: if exec_date not in _elapsed_cache: tdays = get_trading_days(exec_date, first_sim_date) # Inclusive on both ends; elapsed = days the position has been "in play" # (exec_date is day 0; first_sim_date adds another day beyond that) _elapsed_cache[exec_date] = max(0, len(tdays) - 1) return _elapsed_cache[exec_date] # Run through the same selection pipeline as normal candidates all_lookback: list[Candidate] = [] engine_list = list(self._primary_strategy_engines) if not engine_list: # No-engine (legacy single-engine) mode selected = select_candidates( lookback_rows, self.config.universe, self.config.signal, event_type_profiles=self.config.event_type_profiles or None, ) all_lookback.extend(selected) else: seen_event_ids: set[str] = set() for engine in engine_list: if not self._engine_allowed_for_date(engine, first_sim_date): continue if not self._engine_uses_snapshot_candidates(engine): continue effective_engine = self._effective_engine_for_date(engine, first_sim_date) selected = select_candidates( lookback_rows, self.config.universe, self.config.signal, event_type_profiles=self.config.event_type_profiles or None, strategy_engine=effective_engine, engine_lookup=self._strategy_engine_lookup, excluded_event_ids=seen_event_ids, ) all_lookback.extend(selected) seen_event_ids.update(cand.event_id for cand in selected) # Filter by elapsed holding days and annotate surviving candidates result: list[Candidate] = [] for candidate in all_lookback: elapsed = _trading_days_elapsed(candidate.execution_date) # Get the effective mhd for this specific candidate eff_exec = self._build_effective_execution_config(candidate) candidate_mhd = eff_exec.max_holding_days if eff_exec.dynamic_hold_enabled: candidate_mhd = max(candidate_mhd, eff_exec.dynamic_hold_extend_to) if elapsed >= candidate_mhd: continue # would have timed out by now candidate.features["is_lookback_entry"] = True candidate.features["lookback_days_elapsed"] = elapsed candidate.features["lookback_original_execution_date"] = ( candidate.execution_date.isoformat() ) result.append(candidate) logger.info( "lookback_entry_candidates", first_sim_date=str(first_sim_date), max_mhd=max_mhd, rows_scanned=len(lookback_rows), candidates_selected=len(result), ) return result def _select_candidates_for_date( self, date: dt.date, *, engines: list[Any] | None = None, include_scheduled_add_ons: bool = True, ) -> list[Candidate]: """Select daily candidates for single-engine or multi-engine mode.""" if engines is None and not self.config.get_strategy_engines(): raw_rows = 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, ) self._annotate_candidate_slate_features(selected) return selected engine_list = list(engines) if engines is not None else list(self._primary_strategy_engines) if not engine_list: return [] engine_queues: dict[str, list[Candidate]] = {} reserved_event_ids: set[str] = set() reserved_symbols: set[str] = set() for engine in engine_list: if not self._engine_allowed_for_date(engine, date): continue if not self._engine_uses_snapshot_candidates(engine): continue effective_engine = self._effective_engine_for_date(engine, date) prelimit = self.config.signal.max_candidates_per_day if self._engine_requires_attention(effective_engine): prelimit = max(prelimit * 5, prelimit) raw_rows = ( self.store.get_candidates_for_reaction_date(date) if effective_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=effective_engine, engine_lookup=self._strategy_engine_lookup, truncate_to=prelimit, excluded_event_ids=reserved_event_ids, excluded_symbols=reserved_symbols, ) selected = self._apply_attention_filters(selected, effective_engine) if selected: engine_queues[effective_engine.engine_id] = selected if effective_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 include_scheduled_add_ons else [] 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)) self._annotate_candidate_slate_features( [candidate for candidates in engine_queues.values() for candidate in 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.""" shadow_engines = [ engine for engine in self._shadow_strategy_engines if not getattr(engine, "post_allocation_idle_only", False) ] if not shadow_engines: return [] selected_shadow: list[Candidate] = [] reserved_event_ids: set[str] = set() reserved_symbols: set[str] = set() for engine in shadow_engines: if not self._engine_allowed_for_date(engine, date): continue if not self._engine_uses_snapshot_candidates(engine): continue effective_engine = self._effective_engine_for_date(engine, date) prelimit = self.config.signal.max_candidates_per_day if self._engine_requires_attention(effective_engine): prelimit = max(prelimit * 5, prelimit) raw_rows = ( self.store.get_candidates_for_reaction_date(date) if effective_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=effective_engine, engine_lookup=self._strategy_engine_lookup, truncate_to=prelimit, excluded_event_ids=reserved_event_ids, excluded_symbols=reserved_symbols, ) selected = self._apply_attention_filters(selected, effective_engine) if selected: selected_shadow.extend(selected) if effective_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) self._annotate_candidate_slate_features(selected_shadow) return selected_shadow def _select_post_allocation_idle_candidates_for_date(self, date: dt.date) -> list[Candidate]: """Select idle-alpha candidates only after primary engines have finished allocating capital.""" candidates = self._select_candidates_for_date( date, engines=self._post_allocation_idle_engines, include_scheduled_add_ons=False, ) return self._tag_post_allocation_idle_candidates(candidates) def _tag_post_allocation_idle_candidates(self, candidates: list[Candidate]) -> list[Candidate]: """Annotate candidates that belong to the idle-alpha sleeve for downstream UI/export.""" if not candidates: return candidates tagged: list[Candidate] = [] for candidate in candidates: features = dict(candidate.features) features["trade_sleeve"] = "idle_alpha" tagged.append(candidate.model_copy(update={"features": features})) return tagged def _is_post_allocation_idle_engine_id(self, engine_id: str) -> bool: engine = self._strategy_engine_lookup.get(engine_id) if engine is None: return False return bool(getattr(engine, "post_allocation_idle_only", False)) def _refresh_portfolio_state( self, date: dt.date, drawdown_pct: float, ) -> DailyPortfolioState: mv = self._compute_positions_market_value(date) self._equity = self._cash + mv + self._get_parking_value(date) unrealized = mv - sum(p.entry_price * p.shares_open for p in self._open_positions) return self._build_portfolio_state(date, drawdown_pct, unrealized) def _apply_idle_alpha_meta_allocator( self, *, date: dt.date, candidates: list[Candidate], portfolio_state: DailyPortfolioState, ) -> list[Candidate]: cfg = self.config.idle_alpha if not cfg.dynamic_allocator_enabled or not candidates: return candidates equity = max(float(portfolio_state.equity), 1.0) cash_ratio = max(float(portfolio_state.cash_available), 0.0) / equity cash_low = max(float(cfg.dynamic_allocator_cash_ratio_low), 0.0) cash_high = max(float(cfg.dynamic_allocator_cash_ratio_high), cash_low) low_scale = float(cfg.dynamic_allocator_cash_scale_low) high_scale = float(cfg.dynamic_allocator_cash_scale_high) if cash_high <= cash_low: cash_scale = high_scale if cash_ratio >= cash_high else low_scale elif cash_ratio <= cash_low: cash_scale = low_scale elif cash_ratio >= cash_high: cash_scale = high_scale else: progress = (cash_ratio - cash_low) / (cash_high - cash_low) cash_scale = low_scale + progress * (high_scale - low_scale) primary_stats = self._primary_candidate_slate_stats.get( date, {"candidate_count": 0, "unique_sector_count": 0}, ) crowded = False candidate_threshold = cfg.dynamic_allocator_crowded_primary_candidate_count if candidate_threshold is not None and primary_stats["candidate_count"] >= candidate_threshold: crowded = True sector_threshold = cfg.dynamic_allocator_crowded_primary_unique_sector_count if sector_threshold is not None and primary_stats["unique_sector_count"] >= sector_threshold: crowded = True scale = cash_scale if crowded: scale *= float(cfg.dynamic_allocator_crowded_scale) scale = max(float(cfg.dynamic_allocator_min_scale), min(float(cfg.dynamic_allocator_max_scale), scale)) if abs(scale - 1.0) < 1e-9: return candidates adjusted: list[Candidate] = [] for candidate in candidates: engine = self._strategy_engine_lookup.get(candidate.engine_id) if self._idle_alpha_candidate_in_reentry_cooldown( date=date, candidate=candidate, engine=engine, ): continue class_scale_multiplier = ( float(cfg.dynamic_allocator_synthetic_scale_multiplier) if bool(getattr(engine, "synthetic_only", False)) else float(cfg.dynamic_allocator_snapshot_scale_multiplier) ) candidate_scale = max( float(cfg.dynamic_allocator_min_scale), min(float(cfg.dynamic_allocator_max_scale), scale * class_scale_multiplier), ) updates: dict[str, Any] = { "engine_risk_budget_pct": candidate.engine_risk_budget_pct * candidate_scale, } if candidate.engine_per_trade_risk_pct is not None: updates["engine_per_trade_risk_pct"] = candidate.engine_per_trade_risk_pct * candidate_scale features = dict(candidate.features) features["idle_alpha_meta_scale"] = round(candidate_scale, 6) features["idle_alpha_meta_base_scale"] = round(scale, 6) features["idle_alpha_meta_class_scale_multiplier"] = round(class_scale_multiplier, 6) features["idle_alpha_meta_candidate_class"] = ( "synthetic" if bool(getattr(engine, "synthetic_only", False)) else "snapshot" ) features["idle_alpha_meta_cash_ratio"] = round(cash_ratio, 6) features["idle_alpha_meta_primary_candidate_count"] = primary_stats["candidate_count"] features["idle_alpha_meta_primary_unique_sector_count"] = primary_stats["unique_sector_count"] features["idle_alpha_meta_crowded"] = crowded updates["features"] = features adjusted.append(candidate.model_copy(update=updates)) return adjusted def _idle_alpha_candidate_in_reentry_cooldown( self, *, date: dt.date, candidate: Candidate, engine: StrategyEngineConfig | None, ) -> bool: cfg = self.config.idle_alpha cooldown_days = ( int(cfg.dynamic_allocator_synthetic_reentry_cooldown_days) if bool(getattr(engine, "synthetic_only", False)) else 0 ) if cooldown_days <= 0: return False current_idx = self._simulation_date_index.get(date) if current_idx is None: return False candidate_symbol = str(candidate.symbol).upper() for trade in reversed(self._closed_trades): if str(trade.symbol).upper() != candidate_symbol: continue prior_candidate = self._candidate_map.get(trade.trade_id) if prior_candidate is None: continue if prior_candidate.engine_id != candidate.engine_id: continue if prior_candidate.features.get("trade_sleeve") != "idle_alpha": continue exit_idx = self._simulation_date_index.get(trade.exit_date) if exit_idx is None: return False return (current_idx - exit_idx) <= cooldown_days return False def _non_core_allocator_v2_shadow_enabled(self) -> bool: cfg = self.config.non_core_allocator_v2 return ( bool(cfg.enabled) and str(cfg.mode).strip().lower() == "shadow" and str(cfg.scope).strip().lower() == "non_core" ) def _non_core_allocator_v2_live_enabled(self) -> bool: cfg = self.config.non_core_allocator_v2 return ( bool(cfg.enabled) and str(cfg.mode).strip().lower() == "live" and str(cfg.scope).strip().lower() == "non_core" ) def _non_core_allocator_v2_candidate_key(self, candidate: Candidate) -> str: return "|".join( [ str(candidate.engine_id or "").strip(), str(candidate.event_id or "").strip(), str(candidate.symbol or "").strip().upper(), ] ) def _non_core_allocator_v2_current_parking_symbol(self, date: dt.date) -> str: target = self._preview_effective_parking_target(date) if target: return str(target).strip().lower() if self._parking_current_symbol: return str(self._parking_current_symbol).strip().lower() if self._parking_committed_target: return str(self._parking_committed_target).strip().lower() symbol = str(self.config.risk.cash_parking_symbol or "").strip().lower() return symbol or "sgov" def _non_core_allocator_v2_idle_requested_cash_est( self, *, date: dt.date, candidate: Candidate, portfolio_state: DailyPortfolioState, active_bucket_ids: set[str], macro_data: dict[str, Any] | None, ) -> tuple[float, bool]: candidate_portfolio_state = self._adjust_portfolio_state_for_candidate( date=date, candidate=candidate, portfolio_state=portfolio_state, active_bucket_ids=active_bucket_ids, ) plan = build_planned_order( candidate=candidate, portfolio_state=candidate_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: return 0.0, False return max(float(plan.shares * candidate.entry_price_est), 0.0), True def _build_non_core_allocator_v2_day_context( self, *, date: dt.date, portfolio_state: DailyPortfolioState, idle_candidates: list[Candidate], form4_payloads: list[dict[str, Any]], ownership_payloads: list[dict[str, Any]], ) -> tuple[list[Candidate], list[dict[str, Any]], dict[str, Candidate], float]: cfg = self.config.non_core_allocator_v2 weights = cfg.weights parking_symbol = self._non_core_allocator_v2_current_parking_symbol(date) parking_class = classify_parking_class(parking_symbol) macro = self.store.get_macro_for_date(date) or {} parking_prefix = self._get_parking_signal_prefix(parking_symbol) parking_momentum_20 = float(macro.get(f"{parking_prefix}_mom_20") or 0.0) virtual_cash = max(0.0, float(portfolio_state.cash_available) + float(self._get_parking_value(date))) active_bucket_ids = self._active_capital_bucket_ids_for_candidates(idle_candidates) rows: list[dict[str, Any]] = [] annotated_idle_candidates: list[Candidate] = [] candidate_by_key: dict[str, Candidate] = {} current_index = 0 def _append_row( *, family: str, candidate: Candidate, rank_index: int, total_count: int, requested_cash_est: float, complete: bool, family_cap: int | None, ) -> Candidate: nonlocal current_index if family == "idle_alpha": hold_days_est = ( candidate.engine_max_holding_days or self._build_effective_execution_config(candidate).max_holding_days ) elif family == "form4": hold_days_est = int(self.config.form4_capture.hold_days) elif family == "ownership": hold_days_est = int(self.config.ownership_capture.hold_days) else: hold_days_est = int(self.config.risk_off_alpha.max_holding_days or 0) native_rank_pct = compute_native_rank_pct(rank_index, total_count) hold_norm = normalize_hold_days_est(hold_days_est) liquidity_penalty_norm = normalize_liquidity_penalty( requested_cash_est=requested_cash_est, avg_dollar_volume=candidate.avg_dollar_volume, ) overlap_class = classify_non_core_overlap_class( family, trade_symbol_mode=candidate.trade_symbol_mode, engine_id=candidate.engine_id, symbol=candidate.symbol, ) overlap_penalty_norm = compute_overlap_penalty(overlap_class, parking_class) parking_proxy_norm = normalize_parking_proxy( parking_symbol, parking_momentum_20=parking_momentum_20, hold_days_est=hold_days_est, sgov_annual_rate=self.config.risk.cash_parking_sgov_annual_rate, ) marginal_score = compute_marginal_score( native_rank_pct=native_rank_pct, hold_norm=hold_norm, liquidity_penalty_norm=liquidity_penalty_norm, overlap_penalty_norm=overlap_penalty_norm, parking_proxy_norm=parking_proxy_norm, native_strength=weights.native_strength, hold_penalty=weights.hold_penalty, liquidity_penalty=weights.liquidity_penalty, overlap_penalty=weights.overlap_penalty, parking_opportunity_penalty=weights.parking_opportunity_penalty, ) allocator_features = { "allocator_v2_family": family, "allocator_v2_native_rank_pct": round(native_rank_pct, 6), "allocator_v2_hold_days_est": int(hold_days_est), "allocator_v2_requested_cash_est": round(float(requested_cash_est), 6), "allocator_v2_avg_dollar_volume": round(float(candidate.avg_dollar_volume), 6), "allocator_v2_overlap_class": overlap_class, "allocator_v2_parking_symbol": parking_symbol, "allocator_v2_parking_class": parking_class, "allocator_v2_parking_proxy_norm": round(parking_proxy_norm, 6), "allocator_v2_hold_norm": round(hold_norm, 6), "allocator_v2_liquidity_penalty_norm": round(liquidity_penalty_norm, 6), "allocator_v2_overlap_penalty_norm": round(overlap_penalty_norm, 6), "allocator_v2_marginal_score": round(marginal_score, 6), "allocator_v2_complete": bool(complete), } features = dict(candidate.features) features.update(allocator_features) annotated_candidate = candidate.model_copy(update={"features": features}) candidate_key = self._non_core_allocator_v2_candidate_key(annotated_candidate) candidate_by_key[candidate_key] = annotated_candidate rows.append( { "date": date.isoformat(), "engine_id": annotated_candidate.engine_id, "event_id": annotated_candidate.event_id, "symbol": annotated_candidate.symbol, "source_symbol": annotated_candidate.source_symbol, "trade_sleeve": family, "allocator_v2_family": family, "allocator_v2_candidate_key": candidate_key, "allocator_v2_rank_index": int(rank_index), "allocator_v2_total_count": int(total_count), "allocator_v2_family_cap": family_cap, "allocator_v2_native_rank_pct": round(native_rank_pct, 6), "allocator_v2_hold_days_est": int(hold_days_est), "allocator_v2_requested_cash_est": round(float(requested_cash_est), 6), "allocator_v2_avg_dollar_volume": round(float(annotated_candidate.avg_dollar_volume), 6), "allocator_v2_overlap_class": overlap_class, "allocator_v2_parking_symbol": parking_symbol, "allocator_v2_parking_class": parking_class, "allocator_v2_parking_proxy_norm": round(parking_proxy_norm, 6), "allocator_v2_hold_norm": round(hold_norm, 6), "allocator_v2_liquidity_penalty_norm": round(liquidity_penalty_norm, 6), "allocator_v2_overlap_penalty_norm": round(overlap_penalty_norm, 6), "allocator_v2_marginal_score": round(marginal_score, 6), "allocator_v2_complete": bool(complete), "allocator_v2_virtual_cash_start": round(virtual_cash, 6), "allocator_v2_shadow_decision": "blocked_by_budget", "allocator_v2_shadow_selected": False, "allocator_v2_live_selected": False, "allocator_v2_live_vs_shadow_disagree": False, "_shadow_order_index": current_index, } ) current_index += 1 return annotated_candidate idle_total = len(idle_candidates) for rank_index, candidate in enumerate(idle_candidates): requested_cash_est, complete = self._non_core_allocator_v2_idle_requested_cash_est( date=date, candidate=candidate, portfolio_state=portfolio_state, active_bucket_ids=active_bucket_ids, macro_data=macro, ) annotated_idle_candidates.append( _append_row( family="idle_alpha", candidate=candidate, rank_index=rank_index, total_count=idle_total, requested_cash_est=requested_cash_est, complete=complete, family_cap=None, ) ) form4_positions = sum( 1 for position in self._open_positions if position.plan.engine_id == _FORM4_CAPTURE_ENGINE_ID ) form4_cap = max( 0, min( int(self.config.form4_capture.max_new_per_day), max(0, int(self.config.form4_capture.max_positions) - form4_positions), ), ) form4_divisor = max(1, min(len(form4_payloads), form4_cap if form4_cap > 0 else len(form4_payloads))) form4_budget_total = max(0.0, float(portfolio_state.equity) * float(self.config.form4_capture.reserve_pct)) form4_requested_cash_est = form4_budget_total / form4_divisor if form4_divisor > 0 else 0.0 for rank_index, payload in enumerate(form4_payloads): candidate = self._build_form4_capture_candidate( symbol=str(payload["symbol"]), date=date, filing_date=payload["filing_date"], avg_dollar_volume=float(payload["avg_dollar_volume"]), owner_count=int(payload["owner_count"]), transaction_count=int(payload["transaction_count"]), event_day_count=int(payload["event_day_count"]), total_value=float(payload["total_value"]), weighted_purchase_pct=float(payload["weighted_purchase_pct"]), max_lag_days=payload["max_lag_days"], min_lag_days=payload.get("min_lag_days"), has_officer_or_director=bool(payload["has_officer_or_director"]), ) _append_row( family="form4", candidate=candidate, rank_index=rank_index, total_count=len(form4_payloads), requested_cash_est=form4_requested_cash_est, complete=form4_requested_cash_est > 0, family_cap=form4_cap, ) ownership_positions = sum( 1 for position in self._open_positions if position.plan.engine_id == _OWNERSHIP_CAPTURE_ENGINE_ID ) ownership_cap = max( 0, min( int(self.config.ownership_capture.max_new_per_day), max(0, int(self.config.ownership_capture.max_positions) - ownership_positions), ), ) ownership_divisor = max( 1, min(len(ownership_payloads), ownership_cap if ownership_cap > 0 else len(ownership_payloads)), ) ownership_budget_total = 0.0 ownership_cfg = self.config.ownership_capture if float(ownership_cfg.reserve_pct) > 0: ownership_budget_total = float(portfolio_state.equity) * float(ownership_cfg.reserve_pct) extra_idle_deploy_pct = min(1.0, max(0.0, float(ownership_cfg.extra_idle_deploy_pct_above_reserve or 0.0))) if extra_idle_deploy_pct > 0 and float(portfolio_state.equity) > 0: virtual_cash_ratio = virtual_cash / float(portfolio_state.equity) if virtual_cash_ratio >= float(ownership_cfg.min_cash_ratio_for_overlay): ownership_budget_total += max(0.0, virtual_cash - ownership_budget_total) * extra_idle_deploy_pct elif float(portfolio_state.equity) > 0: virtual_cash_ratio = virtual_cash / float(portfolio_state.equity) if virtual_cash_ratio >= float(ownership_cfg.min_cash_ratio_for_overlay): ownership_budget_total = virtual_cash * min(1.0, max(0.0, float(ownership_cfg.max_idle_deploy_pct))) ownership_requested_cash_est = ownership_budget_total / ownership_divisor if ownership_divisor > 0 else 0.0 for rank_index, payload in enumerate(ownership_payloads): candidate = self._build_ownership_capture_candidate( symbol=str(payload["symbol"]), date=date, filing_date=payload["filing_date"], form_type=str(payload["form_type"]), percent_owned=float(payload["percent_owned"]), aggregate_shares=float(payload["aggregate_shares"]), activist_flag=bool(payload["activist_flag"]), is_amendment=bool(payload["is_amendment"]), prior_percent_owned=payload["prior_percent_owned"], percent_delta_points=payload["percent_delta_points"], prior_form_group=payload["prior_form_group"], is_initial_for_owner=bool(payload["is_initial_for_owner"]), is_13g_to_13d_transition=bool(payload["is_13g_to_13d_transition"]), avg_dollar_volume=float(payload["avg_dollar_volume"]), owner_name=payload.get("owner_name"), owner_key=payload.get("owner_key"), purpose_text=payload.get("purpose_text"), purpose_housekeeping_flag=bool(payload.get("purpose_housekeeping_flag")), ownership_strength_score=int(payload.get("ownership_strength_score") or 0), ) _append_row( family="ownership", candidate=candidate, rank_index=rank_index, total_count=len(ownership_payloads), requested_cash_est=ownership_requested_cash_est, complete=ownership_requested_cash_est > 0, family_cap=ownership_cap, ) signal_date = self._previous_simulation_date(date) if signal_date is not None: desired_symbol = self._select_risk_off_alpha_symbol(signal_date) existing_risk_off_positions = [ position for position in self._open_positions if position.plan.engine_id == _RISK_OFF_ALPHA_ENGINE_ID ] if desired_symbol and not existing_risk_off_positions: risk_off_budget_total = 0.0 risk_off_cfg = self.config.risk_off_alpha effective_reserve_pct = self._effective_risk_off_alpha_reserve_pct(signal_date) if effective_reserve_pct > 0: risk_off_budget_total = float(portfolio_state.equity) * effective_reserve_pct elif float(portfolio_state.equity) > 0: virtual_cash_ratio = virtual_cash / float(portfolio_state.equity) if virtual_cash_ratio >= float(risk_off_cfg.min_cash_ratio_for_overlay): risk_off_budget_total = virtual_cash * min(1.0, max(0.0, float(risk_off_cfg.max_idle_deploy_pct))) symbol = str(desired_symbol).upper() avg_dollar_volume = float(self.store.get_market_features(symbol, date).get("avg_dollar_volume_20d") or 0.0) signal_momentum = float( macro.get(f"{symbol.lower()}_mom_{max(1, int(risk_off_cfg.momentum_lookback_days or 20))}") or 0.0 ) candidate = self._build_risk_off_alpha_candidate( symbol=symbol, date=date, signal_date=signal_date, signal_momentum=signal_momentum, sgov_streak=self._risk_off_alpha_sgov_streak(signal_date), avg_dollar_volume=avg_dollar_volume, ) _append_row( family="risk_off_alpha", candidate=candidate, rank_index=0, total_count=1, requested_cash_est=risk_off_budget_total, complete=risk_off_budget_total > 0, family_cap=1, ) return annotated_idle_candidates, rows, candidate_by_key, virtual_cash def _select_non_core_allocator_v2_rows( self, *, rows: list[dict[str, Any]], virtual_cash: float, ) -> list[dict[str, Any]]: selected_counts: dict[str, int] = defaultdict(int) selected_any = False virtual_cash_remaining = float(virtual_cash) ordered_indices = sorted( range(len(rows)), key=lambda idx: ( -float(rows[idx]["allocator_v2_marginal_score"]), -float(rows[idx]["allocator_v2_native_rank_pct"]), int(rows[idx]["_shadow_order_index"]), ), ) for idx in ordered_indices: row = rows[idx] family = str(row["allocator_v2_family"]) family_cap = row["allocator_v2_family_cap"] requested_cash_est = float(row["allocator_v2_requested_cash_est"] or 0.0) if family_cap is not None and int(family_cap) >= 0 and selected_counts[family] >= int(family_cap): row["allocator_v2_shadow_decision"] = "blocked_by_sleeve_cap" continue if requested_cash_est <= 0 or virtual_cash_remaining + 1e-9 < requested_cash_est: row["allocator_v2_shadow_decision"] = ( "blocked_by_better_opportunity" if selected_any else "blocked_by_budget" ) continue row["allocator_v2_shadow_decision"] = "selected_shadow_v2" row["allocator_v2_shadow_selected"] = True virtual_cash_remaining = max(0.0, virtual_cash_remaining - requested_cash_est) selected_counts[family] += 1 selected_any = True return [rows[idx] for idx in ordered_indices] def _record_non_core_allocator_v2_shadow_day( self, *, date: dt.date, portfolio_state: DailyPortfolioState, idle_candidates: list[Candidate], form4_payloads: list[dict[str, Any]], ownership_payloads: list[dict[str, Any]], ) -> list[Candidate]: if not self._non_core_allocator_v2_shadow_enabled(): return idle_candidates annotated_idle_candidates, rows, _candidate_by_key, virtual_cash = self._build_non_core_allocator_v2_day_context( date=date, portfolio_state=portfolio_state, idle_candidates=idle_candidates, form4_payloads=form4_payloads, ownership_payloads=ownership_payloads, ) self._select_non_core_allocator_v2_rows(rows=rows, virtual_cash=virtual_cash) date_indices: list[int] = [] for row in rows: row.pop("_shadow_order_index", None) self._non_core_allocator_shadow_rows.append(row) date_indices.append(len(self._non_core_allocator_shadow_rows) - 1) self._non_core_allocator_shadow_row_indices_by_date[date] = date_indices return annotated_idle_candidates def _execute_fixed_budget_non_core_candidate( self, *, date: dt.date, candidate: Candidate, budget: float, allow_parking_cash_release: bool, max_parking_release: float | None = None, ) -> bool: budget = max(0.0, float(budget)) if budget <= 0: return False symbol = str(candidate.symbol).upper() open_symbols = {str(position.plan.candidate.symbol).upper() for position in self._open_positions} if symbol in open_symbols: return False entry_bar = self.store.get_bar(symbol, date) if entry_bar is None or entry_bar.get("open") is None: return False if self._cash + 1e-9 < budget and allow_parking_cash_release and self._get_parking_value(date) > 0: shortfall = budget - self._cash release_amount = shortfall if max_parking_release is not None: release_amount = min(release_amount, max(0.0, float(max_parking_release))) if release_amount > 0: self._liquidate_parking_for_cash(date, release_amount) effective_budget = min(budget, self._cash) if effective_budget <= 0: return False effective_exec = self._build_effective_execution_config(candidate) estimated_fill = float(entry_bar["open"]) * (1.0 + effective_exec.slippage_bps_base / 10_000.0) shares = int(effective_budget / estimated_fill) if estimated_fill > 0 else 0 if shares <= 0: return False event_date = candidate.event_date if candidate.event_date is not None else date plan = PlannedOrder( candidate=candidate, shares=shares, entry_price_limit=float(entry_bar["open"]), stop_price=0.01, target_price=float(entry_bar["open"]) * 100.0, risk_dollars=0.0, event_date=event_date, timing_class=str(candidate.timing_class or "unknown"), engine_id=str(candidate.engine_id or ""), entry_timing_policy="next_open", ) position = simulate_entry(plan, entry_bar, effective_exec) if position is None: return False trade_cost = position.entry_price * position.shares_open if trade_cost <= 0 or trade_cost > self._cash + 1e-9: return False self._cash -= trade_cost self._open_positions.append(position) return True def _execute_non_core_allocator_v2_live_day( self, *, date: dt.date, drawdown_pct: float, idle_candidates: list[Candidate], form4_payloads: list[dict[str, Any]], ownership_payloads: list[dict[str, Any]], ) -> None: if not self._non_core_allocator_v2_live_enabled(): return portfolio_state = self._refresh_portfolio_state(date, drawdown_pct) annotated_idle_candidates, rows, candidate_by_key, virtual_cash = self._build_non_core_allocator_v2_day_context( date=date, portfolio_state=portfolio_state, idle_candidates=idle_candidates, form4_payloads=form4_payloads, ownership_payloads=ownership_payloads, ) ordered_rows = self._select_non_core_allocator_v2_rows(rows=rows, virtual_cash=virtual_cash) idle_candidate_by_key = { self._non_core_allocator_v2_candidate_key(candidate): candidate for candidate in annotated_idle_candidates } for row in ordered_rows: if not bool(row.get("allocator_v2_shadow_selected")): continue candidate_key = str(row.get("allocator_v2_candidate_key") or "") family = str(row.get("allocator_v2_family") or "") requested_cash_est = float(row.get("allocator_v2_requested_cash_est") or 0.0) if family == "idle_alpha": candidate = idle_candidate_by_key.get(candidate_key) if candidate is None: continue current_state = self._refresh_portfolio_state(date, drawdown_pct) macro_data = self.store.get_macro_for_date(date) self._execute_candidate_entries( date=date, candidates=[candidate], portfolio_state=current_state, drawdown_pct=drawdown_pct, macro_data=macro_data, allow_same_day_cash_recycle=False, allow_parking_cash_release=True, ) continue candidate = candidate_by_key.get(candidate_key) if candidate is None: continue self._execute_fixed_budget_non_core_candidate( date=date, candidate=candidate, budget=requested_cash_est, allow_parking_cash_release=True, max_parking_release=(requested_cash_est * 0.05 if family == "form4" else None), ) def _finalize_non_core_allocator_v2_shadow_day(self, date: dt.date) -> None: if not self._non_core_allocator_v2_shadow_enabled(): return row_indices = self._non_core_allocator_shadow_row_indices_by_date.get(date, []) if not row_indices: return live_candidate_keys: set[str] = set() for position in self._open_positions: if position.entry_date != date: continue candidate = position.plan.candidate sleeve = str((candidate.features or {}).get("trade_sleeve") or "").strip().lower() if sleeve in {"idle_alpha", "form4", "ownership", "risk_off_alpha"}: live_candidate_keys.add(self._non_core_allocator_v2_candidate_key(candidate)) for trade in self._closed_trades: if trade.entry_date != date: continue candidate = self._candidate_map.get(trade.trade_id) if candidate is None: continue sleeve = str((candidate.features or {}).get("trade_sleeve") or "").strip().lower() if sleeve in {"idle_alpha", "form4", "ownership", "risk_off_alpha"}: live_candidate_keys.add(self._non_core_allocator_v2_candidate_key(candidate)) for idx in row_indices: row = self._non_core_allocator_shadow_rows[idx] live_selected = str(row.get("allocator_v2_candidate_key") or "") in live_candidate_keys row["allocator_v2_live_selected"] = live_selected row["allocator_v2_live_vs_shadow_disagree"] = bool(row.get("allocator_v2_shadow_selected")) != live_selected def _execute_candidate_entries( self, *, date: dt.date, candidates: list[Candidate], portfolio_state: DailyPortfolioState, drawdown_pct: float, macro_data: dict[str, Any] | None, allow_same_day_cash_recycle: bool, allow_parking_cash_release: bool, ) -> DailyPortfolioState: active_bucket_ids = self._active_capital_bucket_ids_for_candidates(candidates) for candidate in candidates: candidate_portfolio_state = self._adjust_portfolio_state_for_candidate( date=date, candidate=candidate, portfolio_state=portfolio_state, active_bucket_ids=active_bucket_ids, ) plan = build_planned_order( candidate=candidate, portfolio_state=candidate_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 ( allow_same_day_cash_recycle and plan.skip_reason == "insufficient_cash" and self._attempt_same_day_cash_recycle( date=date, candidate=candidate, portfolio_state=candidate_portfolio_state, ) ): portfolio_state = self._refresh_portfolio_state(date, drawdown_pct) candidate_portfolio_state = self._adjust_portfolio_state_for_candidate( date=date, candidate=candidate, portfolio_state=portfolio_state, active_bucket_ids=active_bucket_ids, ) plan = build_planned_order( candidate=candidate, portfolio_state=candidate_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], ) shortfall = self._estimate_cash_shortfall( candidate, candidate_portfolio_state, ) if ( allow_parking_cash_release and plan.skip_reason == "insufficient_cash" and self._liquidate_parking_for_cash(date, shortfall) ): portfolio_state = self._refresh_portfolio_state(date, drawdown_pct) candidate_portfolio_state = self._adjust_portfolio_state_for_candidate( date=date, candidate=candidate, portfolio_state=portfolio_state, active_bucket_ids=active_bucket_ids, ) plan = build_planned_order( candidate=candidate, portfolio_state=candidate_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 is_lookback = bool(candidate.features.get("is_lookback_entry", False)) bar = self.store.get_bar(candidate.symbol, date if is_lookback else candidate.execution_date) if not is_lookback: 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 None: self._release_add_on_reservation(candidate) continue pos.parent_position_id = candidate.parent_position_id pos.is_add_on = candidate.is_add_on if is_lookback: pos.days_held = int(candidate.features.get("lookback_days_elapsed", 0)) self._open_positions.append(pos) trade_cost = pos.entry_price * pos.shares_total if allow_parking_cash_release and self._cash < trade_cost and self._get_parking_value(date) > 0: shortfall = trade_cost - self._cash if self._liquidate_parking_for_cash(date, shortfall): portfolio_state = self._refresh_portfolio_state(date, drawdown_pct) self._cash -= trade_cost self._daily_new_risk_used += plan.risk_dollars self._engine_daily_new_risk_used[candidate.engine_id] += plan.risk_dollars portfolio_state = self._refresh_portfolio_state(date, drawdown_pct) return portfolio_state def _annotate_candidate_slate_features(self, candidates: list[Candidate]) -> None: """Attach same-day breadth/crowding metadata used by allocator scalers.""" if not candidates: return sector_counts = Counter(candidate.sector for candidate in candidates) engine_counts = Counter(candidate.engine_id for candidate in candidates) total_count = len(candidates) unique_sector_count = len(sector_counts) for candidate in candidates: candidate.features["daily_candidate_count_selected"] = total_count candidate.features["daily_unique_sector_count_selected"] = unique_sector_count candidate.features["daily_sector_candidate_count_selected"] = sector_counts.get(candidate.sector, 0) candidate.features["daily_engine_candidate_count_selected"] = engine_counts.get(candidate.engine_id, 0) 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: if getattr(engine, "volatility_crush_only", False) and not self._volatility_crush_condition_met(engine, date): return False 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 _previous_simulation_date(self, date: dt.date) -> dt.date | None: idx = self._simulation_date_index.get(date) if idx is None or idx <= 0: return None return self._simulation_dates[idx - 1] def _volatility_crush_state_for_date(self, date: dt.date) -> dict[str, float] | None: prev_date = self._previous_simulation_date(date) if prev_date is None: return None prev_macro = self.store.get_macro_for_date(prev_date) or {} curr_macro = self.store.get_macro_for_date(date) or {} prev_vix = prev_macro.get("VIXCLS") curr_vix = curr_macro.get("VIXCLS") prev_spy = prev_macro.get("spy_close") curr_spy = curr_macro.get("spy_close") if None in (prev_vix, curr_vix, prev_spy, curr_spy): return None prev_vix_f = float(prev_vix) curr_vix_f = float(curr_vix) prev_spy_f = float(prev_spy) curr_spy_f = float(curr_spy) if prev_vix_f <= 0 or prev_spy_f <= 0: return None return { "vix_drop_pct": (prev_vix_f - curr_vix_f) / prev_vix_f, "spy_return": (curr_spy_f / prev_spy_f) - 1.0, "prev_vix": prev_vix_f, "curr_vix": curr_vix_f, "prev_spy": prev_spy_f, "curr_spy": curr_spy_f, } def _effective_engine_for_date(self, engine: Any, date: dt.date) -> Any: has_crush_override = any( getattr(engine, field_name, None) is not None for field_name in ( "volatility_crush_score_threshold_override", "volatility_crush_per_trade_risk_pct_override", "volatility_crush_engine_risk_budget_pct_override", "volatility_crush_macro_vix_max_override", ) ) if not has_crush_override: return engine if getattr(engine, "volatility_crush_vix_drop_pct_min", None) is None: return engine crush_state = self._volatility_crush_state_for_date(date) if not self._volatility_crush_condition_met(engine, date, crush_state=crush_state): return engine updates: dict[str, Any] = {} if engine.volatility_crush_score_threshold_override is not None: updates["score_threshold_override"] = engine.volatility_crush_score_threshold_override if engine.volatility_crush_per_trade_risk_pct_override is not None: updates["per_trade_risk_pct_override"] = engine.volatility_crush_per_trade_risk_pct_override if engine.volatility_crush_engine_risk_budget_pct_override is not None: updates["engine_risk_budget_pct"] = engine.volatility_crush_engine_risk_budget_pct_override if engine.volatility_crush_macro_vix_max_override is not None: updates["macro_vix_max"] = engine.volatility_crush_macro_vix_max_override if not updates: return engine logger.debug( "volatility_crush_engine_override", date=date.isoformat(), engine_id=getattr(engine, "engine_id", "unknown"), vix_drop_pct=round(crush_state["vix_drop_pct"], 4), spy_return=round(crush_state["spy_return"], 4), updates=updates, ) return engine.model_copy(update=updates) def _volatility_crush_condition_met( self, engine: Any, date: dt.date, *, crush_state: dict[str, float] | None = None, ) -> bool: crush_min = getattr(engine, "volatility_crush_vix_drop_pct_min", None) spy_min = getattr(engine, "volatility_crush_spy_return_min", None) if crush_min is None: return False state = crush_state if crush_state is not None else self._volatility_crush_state_for_date(date) if state is None: return False if state["vix_drop_pct"] < float(crush_min): return False if spy_min is not None and state["spy_return"] < float(spy_min): return False return True 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 attention_symbol = candidate.source_symbol or candidate.symbol cache_key = (attention_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/{attention_symbol}", params={"event_date": event_date.isoformat()}, timeout=30, ) if response.status_code >= 400: logger.debug( "attention_fetch_failed", symbol=attention_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=attention_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 active_bucket_ids = self._active_capital_bucket_ids_for_candidates(candidates) ranked: list[tuple[float, float, int, Candidate]] = [] skipped: list[tuple[int, Candidate]] = [] for idx, candidate in enumerate(candidates): candidate_portfolio_state = self._adjust_portfolio_state_for_candidate( date=portfolio_state.date, candidate=candidate, portfolio_state=portfolio_state, active_bucket_ids=active_bucket_ids, ) plan = build_planned_order( candidate=candidate, portfolio_state=candidate_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 _dividend_capture_enabled(self) -> bool: cfg = self.config.dividend_capture return bool( cfg.enabled and cfg.reserve_pct > 0 and cfg.max_positions > 0 and self._pit_dividend_calendar is not None ) def _form4_capture_enabled(self) -> bool: cfg = self.config.form4_capture return bool( cfg.enabled and cfg.reserve_pct > 0 and cfg.max_positions > 0 and cfg.max_new_per_day > 0 and cfg.hold_days > 0 and self._pit_form4_calendar is not None ) def _ownership_capture_enabled(self) -> bool: cfg = self.config.ownership_capture return bool( cfg.enabled and cfg.max_positions > 0 and cfg.max_new_per_day > 0 and cfg.hold_days > 0 and (cfg.max_idle_deploy_pct > 0 or cfg.reserve_pct > 0) and self._pit_ownership_calendar is not None ) def _risk_off_alpha_enabled(self) -> bool: cfg = self.config.risk_off_alpha return bool( cfg.enabled and len(cfg.symbols) > 0 and (float(cfg.reserve_pct) > 0 or float(cfg.max_idle_deploy_pct) > 0) ) def _preview_effective_parking_target(self, date: dt.date) -> str | None: macro = self.store.get_macro_for_date(date) or {} if not macro: return None trend_state_before = self._parking_trend_sgov gate_state_before = self._parking_gate_in_sgov raw_target = self._compute_parking_target(date) self._parking_trend_sgov = trend_state_before self._parking_gate_in_sgov = gate_state_before target = raw_target if target == "sgov" and self.config.risk.cash_parking_gate_mode == "volatility": crisis_target = self._evaluate_crisis_relay_target(macro) if crisis_target is not None: target = crisis_target else: relay_target = self._evaluate_defensive_relay_target( date, macro, self.config.risk.cash_parking_symbol, ) if relay_target is not None: target = relay_target bearish_sym = self.config.risk.cash_parking_bearish_symbol if bearish_sym and target == "sgov": risk_score = self._compute_parking_risk_score(macro) if risk_score >= self.config.risk.cash_parking_bearish_threshold: target = bearish_sym return target def _risk_off_alpha_sgov_streak(self, signal_date: dt.date) -> int: streak = 0 cursor = signal_date while cursor is not None: if self._preview_effective_parking_target(cursor) != "sgov": break streak += 1 cursor = self._previous_simulation_date(cursor) return streak def _select_risk_off_alpha_symbol( self, signal_date: dt.date, *, current_symbol: str | None = None, ) -> str | None: if not self._risk_off_alpha_enabled(): return None if self._preview_effective_parking_target(signal_date) != "sgov": return None cfg = self.config.risk_off_alpha if self._risk_off_alpha_sgov_streak(signal_date) < int(cfg.min_consecutive_sgov_days): return None macro = self.store.get_macro_for_date(signal_date) or {} if not macro: return None if float(cfg.min_parking_risk_score or 0.0) > 0: risk_score = float(self._compute_parking_risk_score(macro)) if risk_score < float(cfg.min_parking_risk_score): return None lookback_days = max(1, int(cfg.momentum_lookback_days or 20)) min_mom = float(cfg.min_symbol_momentum or 0.0) candidates: list[tuple[str, float]] = [] for symbol in cfg.symbols: normalized = str(symbol).lower().strip() if not normalized: continue prefix = self._get_parking_signal_prefix(normalized) momentum = macro.get(f"{prefix}_mom_{lookback_days}") if momentum is None or float(momentum) < min_mom: continue candidates.append((normalized, float(momentum))) if not candidates: return None candidates.sort(key=lambda item: item[1], reverse=True) best_symbol, best_score = candidates[0] if current_symbol: current_normalized = str(current_symbol).lower() current_score = next((score for symbol, score in candidates if symbol == current_normalized), None) gap = float(cfg.rotation_momentum_gap or 0.0) if current_score is not None and best_symbol != current_normalized and best_score <= current_score + gap: return current_normalized return best_symbol def _effective_risk_off_alpha_reserve_pct(self, signal_date: dt.date) -> float: cfg = self.config.risk_off_alpha reserve_pct = float(cfg.reserve_pct or 0.0) if not bool(cfg.adaptive_reserve_enabled): return reserve_pct macro = self.store.get_macro_for_date(signal_date) or {} if not macro: return reserve_pct risk_score = float(self._compute_parking_risk_score(macro)) high_threshold = float(cfg.adaptive_reserve_score_high or 0.0) mid_threshold = float(cfg.adaptive_reserve_score_mid or 0.0) if high_threshold > 0 and risk_score >= high_threshold: return float(cfg.adaptive_reserve_pct_high or reserve_pct) if mid_threshold > 0 and risk_score >= mid_threshold: return float(cfg.adaptive_reserve_pct_mid or reserve_pct) return float(cfg.adaptive_reserve_pct_low or reserve_pct) def _build_risk_off_alpha_candidate( self, *, symbol: str, date: dt.date, signal_date: dt.date, signal_momentum: float, sgov_streak: int, avg_dollar_volume: float, ) -> Candidate: event_timestamp = dt.datetime.combine(signal_date, dt.time(0, 0), tzinfo=dt.timezone.utc) return Candidate( event_id=f"{_RISK_OFF_ALPHA_EVENT_TYPE}:{symbol}:{signal_date.isoformat()}", symbol=symbol.upper(), source_symbol=symbol.upper(), score=float(signal_momentum) * 1_000_000.0 + float(sgov_streak), sector="MACRO", event_type=_RISK_OFF_ALPHA_EVENT_TYPE, event_timestamp=event_timestamp, event_date=signal_date, filing_time_bucket="unknown", timing_class="unknown", reaction_date=date, execution_date=date, entry_price_est=float((self.store.get_bar(symbol.upper(), date) or {}).get("open") or 0.0), avg_dollar_volume=avg_dollar_volume, score_bucket="idle_risk_off_alpha", engine_id=_RISK_OFF_ALPHA_ENGINE_ID, entry_timing_policy="next_open", engine_max_holding_days=int(self.config.risk_off_alpha.max_holding_days or 0) or None, features={ "trade_sleeve": "risk_off_alpha", "risk_off_alpha_signal_date": signal_date.isoformat(), "risk_off_alpha_symbol": symbol.lower(), "risk_off_alpha_signal_momentum": signal_momentum, "risk_off_alpha_sgov_streak": sgov_streak, }, ) def _process_risk_off_alpha_open_exits(self, date: dt.date) -> None: if not self._open_positions: return signal_date = self._previous_simulation_date(date) if signal_date is None: return remaining_positions: list[OpenPosition] = [] max_hold_days = max(0, int(self.config.risk_off_alpha.max_holding_days or 0)) for position in self._open_positions: if position.plan.engine_id != _RISK_OFF_ALPHA_ENGINE_ID: remaining_positions.append(position) continue symbol = str(position.plan.candidate.symbol).upper() desired_symbol = self._select_risk_off_alpha_symbol(signal_date, current_symbol=symbol) exit_reason: str | None = None if desired_symbol is None: exit_reason = "RISK_OFF_ALPHA_OFF" elif str(desired_symbol).upper() != symbol: exit_reason = "RISK_OFF_ALPHA_ROTATE" elif max_hold_days > 0 and position.days_held >= max_hold_days: exit_reason = "RISK_OFF_ALPHA_MAX_HOLD" if exit_reason is None: remaining_positions.append(position) continue bar = self.store.get_bar(symbol, date) if bar is None or bar.get("open") 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=exit_reason, 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 self._open_positions = remaining_positions def _enter_risk_off_alpha_positions(self, date: dt.date) -> None: signal_date = self._previous_simulation_date(date) if signal_date is None: return desired_symbol = self._select_risk_off_alpha_symbol(signal_date) if desired_symbol is None: return existing_positions = [ position for position in self._open_positions if position.plan.engine_id == _RISK_OFF_ALPHA_ENGINE_ID ] if any(str(position.plan.candidate.symbol).upper() == str(desired_symbol).upper() for position in existing_positions): return if existing_positions: return cfg = self.config.risk_off_alpha market_value = self._compute_positions_market_value(date) equity_est = self._cash + market_value + self._get_parking_value(date) if equity_est <= 0: return effective_reserve_pct = self._effective_risk_off_alpha_reserve_pct(signal_date) if effective_reserve_pct > 0: reserve_budget = equity_est * effective_reserve_pct if reserve_budget <= 0: return if self._cash + 1e-9 < reserve_budget and self._get_parking_value(date) > 0: self._liquidate_parking_for_cash(date, reserve_budget - self._cash) total_budget = min(reserve_budget, self._cash) else: cash_ratio = self._cash / equity_est if cash_ratio < float(cfg.min_cash_ratio_for_overlay): return total_budget = self._cash * min(1.0, max(0.0, float(cfg.max_idle_deploy_pct))) if total_budget <= 0: return symbol = str(desired_symbol).upper() entry_bar = self.store.get_bar(symbol, date) if entry_bar is None or entry_bar.get("open") is None: return avg_dollar_volume = float(self.store.get_market_features(symbol, date).get("avg_dollar_volume_20d") or 0.0) signal_macro = self.store.get_macro_for_date(signal_date) or {} signal_momentum = float(signal_macro.get(f"{symbol.lower()}_mom_{max(1, int(cfg.momentum_lookback_days or 20))}") or 0.0) candidate = self._build_risk_off_alpha_candidate( symbol=symbol, date=date, signal_date=signal_date, signal_momentum=signal_momentum, sgov_streak=self._risk_off_alpha_sgov_streak(signal_date), avg_dollar_volume=avg_dollar_volume, ) candidate.features["risk_off_alpha_reserve_pct"] = effective_reserve_pct effective_exec = self._build_effective_execution_config(candidate) estimated_fill = float(entry_bar["open"]) * (1.0 + effective_exec.slippage_bps_base / 10_000.0) shares = int(total_budget / estimated_fill) if estimated_fill > 0 else 0 if shares <= 0: return plan = PlannedOrder( candidate=candidate, shares=shares, entry_price_limit=float(entry_bar["open"]), stop_price=0.01, target_price=float(entry_bar["open"]) * 100.0, risk_dollars=0.0, event_date=signal_date, timing_class="unknown", engine_id=_RISK_OFF_ALPHA_ENGINE_ID, entry_timing_policy="next_open", ) position = simulate_entry(plan, entry_bar, effective_exec) if position is None: return trade_cost = position.entry_price * position.shares_open if trade_cost <= 0 or trade_cost > self._cash + 1e-9: return self._cash -= trade_cost self._open_positions.append(position) def _process_dividend_capture_open_exits(self, date: dt.date) -> None: if not self._open_positions: return remaining_positions: list[OpenPosition] = [] for position in self._open_positions: if position.plan.engine_id != _DIVIDEND_CAPTURE_ENGINE_ID: remaining_positions.append(position) continue bar = self.store.get_bar(position.plan.candidate.symbol, date) if bar is None or bar.get("open") is None or float(bar.get("open") or 0.0) <= 0: 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="DIVIDEND_CAPTURE", 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 self._open_positions = remaining_positions def _select_dividend_capture_candidates( self, date: dt.date, next_date: dt.date, ) -> list[dict[str, Any]]: if not self._dividend_capture_enabled(): return [] cfg = self.config.dividend_capture known_entries = self._pit_dividend_calendar.get_known_upcoming_ex_dividends( as_of_date=date, allowed_ex_dates=[next_date], symbols=self.store._bars.keys(), ) if not known_entries: return [] open_symbols = { str(position.plan.candidate.symbol).upper() for position in self._open_positions } if self._parking_current_symbol: open_symbols.add(str(self._parking_current_symbol).upper()) candidates: list[dict[str, Any]] = [] for symbol, entry in known_entries.items(): normalized_symbol = str(symbol).upper() if normalized_symbol in open_symbols: continue entry_bar = self.store.get_bar(normalized_symbol, date) exit_bar = self.store.get_bar(normalized_symbol, next_date) if ( entry_bar is None or exit_bar is None or entry_bar.get("close") is None or exit_bar.get("open") is None ): continue entry_close = float(entry_bar.get("close") or 0.0) exit_open = float(exit_bar.get("open") or 0.0) if entry_close <= 0 or exit_open <= 0: continue dividend_yield_pct = float(entry.amount) / entry_close if dividend_yield_pct < float(cfg.min_dividend_yield_pct): continue max_yield_pct = cfg.max_dividend_yield_pct if max_yield_pct is not None and dividend_yield_pct > float(max_yield_pct): continue features = self.store.get_market_features(normalized_symbol, date) avg_dollar_volume = float(features.get("avg_dollar_volume_20d") or 0.0) if avg_dollar_volume < float(cfg.min_avg_dollar_volume): continue candidates.append( { "symbol": normalized_symbol, "entry_close": entry_close, "exit_open": exit_open, "avg_dollar_volume": avg_dollar_volume, "dividend_amount": float(entry.amount), "dividend_yield_pct": dividend_yield_pct, "entry": entry, } ) candidates.sort( key=lambda item: ( float(item["dividend_yield_pct"]), float(item["avg_dollar_volume"]), ), reverse=True, ) return candidates[: max(0, int(cfg.max_positions))] def _build_dividend_capture_candidate( self, *, symbol: str, date: dt.date, next_date: dt.date, entry_close: float, avg_dollar_volume: float, dividend_amount: float, dividend_yield_pct: float, ) -> Candidate: event_timestamp = dt.datetime.combine(next_date, dt.time(0, 0), tzinfo=dt.timezone.utc) return Candidate( event_id=f"{_DIVIDEND_CAPTURE_EVENT_TYPE}:{symbol}:{date.isoformat()}", symbol=symbol, source_symbol=symbol, score=float(dividend_yield_pct) * 100.0, sector="DIVIDEND", event_type=_DIVIDEND_CAPTURE_EVENT_TYPE, event_timestamp=event_timestamp, event_date=next_date, filing_time_bucket="post_market", timing_class="after_close", reaction_date=date, execution_date=date, entry_price_est=entry_close, avg_dollar_volume=avg_dollar_volume, score_bucket="idle_dividend", engine_id=_DIVIDEND_CAPTURE_ENGINE_ID, entry_timing_policy="reaction_close", engine_max_holding_days=3, features={ "dividend_amount": dividend_amount, "dividend_yield_pct": dividend_yield_pct, "ex_dividend_date": next_date.isoformat(), }, ) def _enter_dividend_capture_positions(self, date: dt.date) -> None: next_date = self._next_trading_day.get(date) if next_date is None: return selected = self._select_dividend_capture_candidates(date, next_date) if not selected: return cfg = self.config.dividend_capture market_value = self._compute_positions_market_value(date) equity_est = self._cash + market_value + self._get_parking_value(date) reserve_budget = equity_est * float(cfg.reserve_pct) if reserve_budget <= 0: return if self._cash + 1e-9 < reserve_budget and self._get_parking_value(date) > 0: self._liquidate_parking_for_cash(date, reserve_budget - self._cash) total_budget = min(reserve_budget, self._cash) if total_budget <= 0: return per_position_budget = total_budget / max(1, len(selected)) for payload in selected: candidate = self._build_dividend_capture_candidate( symbol=str(payload["symbol"]), date=date, next_date=next_date, entry_close=float(payload["entry_close"]), avg_dollar_volume=float(payload["avg_dollar_volume"]), dividend_amount=float(payload["dividend_amount"]), dividend_yield_pct=float(payload["dividend_yield_pct"]), ) effective_exec = self._build_effective_execution_config(candidate) estimated_fill = float(payload["entry_close"]) * (1.0 + effective_exec.slippage_bps_base / 10_000.0) shares = int(per_position_budget / estimated_fill) if estimated_fill > 0 else 0 if shares <= 0: continue plan = PlannedOrder( candidate=candidate, shares=shares, entry_price_limit=float(payload["entry_close"]), stop_price=max(0.01, float(payload["entry_close"]) * 0.5), target_price=float(payload["entry_close"]) * 2.0, risk_dollars=0.0, event_date=next_date, timing_class="after_close", engine_id=_DIVIDEND_CAPTURE_ENGINE_ID, entry_timing_policy="reaction_close", ) entry_bar = self.store.get_bar(candidate.symbol, date) position = simulate_entry(plan, entry_bar, effective_exec) if position is None: continue trade_cost = position.entry_price * position.shares_open if trade_cost <= 0 or trade_cost > self._cash + 1e-9: continue self._cash -= trade_cost self._open_positions.append(position) def _first_trading_day_after(self, target_date: dt.date) -> dt.date | None: idx = bisect_right(self._simulation_dates, target_date) if idx >= len(self._simulation_dates): return None return self._simulation_dates[idx] def _select_form4_capture_candidates(self, date: dt.date) -> list[dict[str, Any]]: if not self._form4_capture_enabled(): return [] date_index = self._simulation_date_index.get(date) if date_index is None or date_index <= 0: return [] prev_trading_date = self._simulation_dates[date_index - 1] start_filing_date = prev_trading_date end_filing_date = date - dt.timedelta(days=1) if end_filing_date < start_filing_date: return [] cfg = self.config.form4_capture filing_events = self._pit_form4_calendar.get_events_between( start_filing_date=start_filing_date, end_filing_date=end_filing_date, symbols=self.store._bars.keys(), ) if not filing_events: return [] open_symbols = { str(position.plan.candidate.symbol).upper() for position in self._open_positions } if self._parking_current_symbol: open_symbols.add(str(self._parking_current_symbol).upper()) rows: list[dict[str, Any]] = [] for event in filing_events: if self._first_trading_day_after(event.filing_date) != date: continue if event.owner_count < int(cfg.min_owner_count): continue if int(event.transaction_count) < int(cfg.min_transaction_count): continue if event.total_value < float(cfg.min_total_value): continue if event.weighted_purchase_pct < float(cfg.min_purchase_pct): continue if int(event.event_day_count) < int(cfg.min_event_day_count): continue if cfg.max_lag_days is not None and event.max_lag_days is not None and event.max_lag_days > int(cfg.max_lag_days): continue if cfg.max_min_lag_days is not None and event.min_lag_days is not None and event.min_lag_days > int(cfg.max_min_lag_days): continue if bool(cfg.require_officer_or_director) and not bool(event.has_officer_or_director): continue symbol = str(event.symbol).upper() if symbol in open_symbols: continue bar = self.store.get_bar(symbol, date) if bar is None or bar.get("open") is None: continue features = self.store.get_market_features(symbol, date) avg_dollar_volume = float(features.get("avg_dollar_volume_20d") or 0.0) rows.append( { "symbol": symbol, "filing_date": event.filing_date, "owner_count": int(event.owner_count), "transaction_count": int(event.transaction_count), "event_day_count": int(event.event_day_count), "total_value": float(event.total_value), "weighted_purchase_pct": float(event.weighted_purchase_pct), "max_lag_days": event.max_lag_days, "min_lag_days": event.min_lag_days, "has_officer_or_director": bool(event.has_officer_or_director), "avg_dollar_volume": avg_dollar_volume, } ) rows.sort( key=lambda item: ( -int(item["transaction_count"]), -int(item["owner_count"]), 999999 if item["min_lag_days"] is None else int(item["min_lag_days"]), -float(item["weighted_purchase_pct"]), -float(item["total_value"]), ), ) return rows def _build_form4_capture_candidate( self, *, symbol: str, date: dt.date, filing_date: dt.date, avg_dollar_volume: float, owner_count: int, transaction_count: int, event_day_count: int, total_value: float, weighted_purchase_pct: float, max_lag_days: int | None, min_lag_days: int | None, has_officer_or_director: bool, ) -> Candidate: event_timestamp = dt.datetime.combine(filing_date, dt.time(0, 0), tzinfo=dt.timezone.utc) return Candidate( event_id=f"{_FORM4_CAPTURE_EVENT_TYPE}:{symbol}:{filing_date.isoformat()}", symbol=symbol, source_symbol=symbol, score=float(owner_count) * 100_000_000.0 + float(total_value), sector="INSIDER", event_type=_FORM4_CAPTURE_EVENT_TYPE, event_timestamp=event_timestamp, event_date=filing_date, filing_time_bucket="unknown", timing_class="unknown", reaction_date=date, execution_date=date, entry_price_est=float((self.store.get_bar(symbol, date) or {}).get("open") or 0.0), avg_dollar_volume=avg_dollar_volume, score_bucket="idle_form4", engine_id=_FORM4_CAPTURE_ENGINE_ID, entry_timing_policy="next_open", engine_early_failure_close_below_entry_and_reaction_close=( not bool(self.config.form4_capture.disable_day1_early_failure) ), engine_early_failure_no_progress_days=self.config.form4_capture.no_progress_days_override, engine_early_failure_no_progress_r=self.config.form4_capture.no_progress_r_override, engine_early_failure_no_progress_fraction=self.config.form4_capture.no_progress_fraction_override, engine_max_holding_days=int(self.config.form4_capture.hold_days), features={ "trade_sleeve": "form4", "form4_filing_date": filing_date.isoformat(), "form4_owner_count": owner_count, "form4_transaction_count": transaction_count, "form4_event_day_count": event_day_count, "form4_total_value": total_value, "form4_weighted_purchase_pct": weighted_purchase_pct, "form4_max_lag_days": max_lag_days, "form4_min_lag_days": min_lag_days, "form4_has_officer_or_director": has_officer_or_director, }, ) def _enter_form4_capture_positions(self, date: dt.date) -> None: selected = self._select_form4_capture_candidates(date) if not selected: return cfg = self.config.form4_capture existing_form4_positions = sum( 1 for position in self._open_positions if position.plan.engine_id == _FORM4_CAPTURE_ENGINE_ID ) available_slots = max(0, int(cfg.max_positions) - existing_form4_positions) if available_slots <= 0: return selected = selected[: min(int(cfg.max_new_per_day), available_slots)] if not selected: return market_value = self._compute_positions_market_value(date) equity_est = self._cash + market_value + self._get_parking_value(date) reserve_budget = equity_est * float(cfg.reserve_pct) total_budget = min(reserve_budget, self._cash) if total_budget <= 0: return per_position_budget = total_budget / len(selected) for payload in selected: symbol = str(payload["symbol"]) candidate = self._build_form4_capture_candidate( symbol=symbol, date=date, filing_date=payload["filing_date"], avg_dollar_volume=float(payload["avg_dollar_volume"]), owner_count=int(payload["owner_count"]), transaction_count=int(payload["transaction_count"]), event_day_count=int(payload["event_day_count"]), total_value=float(payload["total_value"]), weighted_purchase_pct=float(payload["weighted_purchase_pct"]), max_lag_days=payload["max_lag_days"], min_lag_days=payload.get("min_lag_days"), has_officer_or_director=bool(payload["has_officer_or_director"]), ) entry_bar = self.store.get_bar(symbol, date) if entry_bar is None or entry_bar.get("open") is None: continue effective_exec = self._build_effective_execution_config(candidate) estimated_fill = float(entry_bar["open"]) * (1.0 + effective_exec.slippage_bps_base / 10_000.0) shares = int(per_position_budget / estimated_fill) if estimated_fill > 0 else 0 if shares <= 0: continue plan = PlannedOrder( candidate=candidate, shares=shares, entry_price_limit=float(entry_bar["open"]), stop_price=0.01, target_price=float(entry_bar["open"]) * 100.0, risk_dollars=0.0, event_date=payload["filing_date"], timing_class="unknown", engine_id=_FORM4_CAPTURE_ENGINE_ID, entry_timing_policy="next_open", ) position = simulate_entry(plan, entry_bar, effective_exec) if position is None: continue trade_cost = position.entry_price * position.shares_open if trade_cost <= 0 or trade_cost > self._cash + 1e-9: continue self._cash -= trade_cost self._open_positions.append(position) def _select_ownership_capture_candidates(self, date: dt.date) -> list[dict[str, Any]]: if not self._ownership_capture_enabled(): return [] date_index = self._simulation_date_index.get(date) if date_index is None or date_index <= 0: return [] prev_trading_date = self._simulation_dates[date_index - 1] start_filing_date = prev_trading_date end_filing_date = date - dt.timedelta(days=1) if end_filing_date < start_filing_date: return [] cfg = self.config.ownership_capture filing_events = self._pit_ownership_calendar.get_events_between( start_filing_date=start_filing_date, end_filing_date=end_filing_date, symbols=self.store._bars.keys(), ) if not filing_events: return [] allowed_form_groups = {str(group).strip().upper() for group in cfg.form_groups if str(group).strip()} open_symbols = { str(position.plan.candidate.symbol).upper() for position in self._open_positions } if self._parking_current_symbol: open_symbols.add(str(self._parking_current_symbol).upper()) best_by_symbol: dict[str, dict[str, Any]] = {} loss_cooldown_days = max(0, int(cfg.symbol_cooldown_days_after_loss or 0)) symbol_max_entries = ( int(cfg.symbol_max_entries_in_lookback) if cfg.symbol_max_entries_in_lookback is not None else 0 ) symbol_entry_lookback_days = max(1, int(cfg.symbol_entry_lookback_days or 365)) for event in filing_events: if self._first_trading_day_after(event.filing_date) != date: continue form_group = "13D" if "13D" in str(event.form_type).upper() else "13G" if allowed_form_groups and form_group not in allowed_form_groups: continue if bool(cfg.require_amendment) and not bool(event.is_amendment): continue if bool(cfg.require_initial) and not bool(event.is_initial_for_owner): continue if bool(cfg.require_activist) and not bool(event.activist_flag): continue if bool(cfg.require_13g_to_13d_transition) and not bool(event.is_13g_to_13d_transition): continue if float(event.percent_owned or 0.0) < float(cfg.min_percent_owned): continue if float(cfg.min_percent_delta_points) > 0 and float(event.percent_delta_points or 0.0) < float(cfg.min_percent_delta_points): continue purpose_text = str(event.purpose_text or "") runtime_housekeeping = False runtime_structural = False if purpose_text: lowered_purpose = purpose_text.lower() runtime_housekeeping = any( phrase in lowered_purpose for phrase in _OWNERSHIP_RUNTIME_HOUSEKEEPING_PHRASES ) runtime_structural = any( phrase in lowered_purpose for phrase in _OWNERSHIP_RUNTIME_STRUCTURAL_PHRASES ) if bool(cfg.exclude_housekeeping_purpose) and ( bool(event.purpose_housekeeping_flag) or runtime_housekeeping ): continue if bool(cfg.exclude_structural_exchange_purpose) and runtime_structural: continue if ( cfg.min_strength_score is not None and int(event.ownership_strength_score or 0) < int(cfg.min_strength_score) ): continue symbol = str(event.symbol).upper() if symbol in open_symbols: continue if loss_cooldown_days > 0: recent_loss_found = False for trade in self._closed_trades: candidate = self._candidate_map.get(trade.trade_id) if candidate is None or candidate.engine_id != _OWNERSHIP_CAPTURE_ENGINE_ID: continue if str(candidate.symbol).upper() != symbol: continue if float(trade.net_pnl) >= 0: continue if (date - trade.exit_date).days <= loss_cooldown_days: recent_loss_found = True break if recent_loss_found: continue if symbol_max_entries > 0: recent_entry_count = 0 for trade in self._closed_trades: candidate = self._candidate_map.get(trade.trade_id) if candidate is None or candidate.engine_id != _OWNERSHIP_CAPTURE_ENGINE_ID: continue if str(candidate.symbol).upper() != symbol: continue if (date - trade.entry_date).days > symbol_entry_lookback_days: continue recent_entry_count += 1 if recent_entry_count >= symbol_max_entries: break if recent_entry_count >= symbol_max_entries: continue bar = self.store.get_bar(symbol, date) if bar is None or bar.get("open") is None: continue features = self.store.get_market_features(symbol, date) avg_dollar_volume = float(features.get("avg_dollar_volume_20d") or 0.0) if avg_dollar_volume < float(cfg.min_avg_dollar_volume): continue sort_key = ( int(event.ownership_strength_score or 0), 1 if bool(event.activist_flag) else 0, float(event.percent_delta_points or 0.0), float(event.percent_owned or 0.0), ) row = { "symbol": symbol, "filing_date": event.filing_date, "form_type": str(event.form_type).upper(), "owner_name": event.owner_name, "owner_key": event.owner_key, "percent_owned": float(event.percent_owned or 0.0), "aggregate_shares": float(event.aggregate_shares or 0.0), "purpose_text": event.purpose_text, "purpose_housekeeping_flag": bool(event.purpose_housekeeping_flag or runtime_housekeeping), "ownership_structural_exchange_flag": bool(runtime_structural), "activist_flag": bool(event.activist_flag), "is_amendment": bool(event.is_amendment), "prior_percent_owned": ( float(event.prior_percent_owned) if event.prior_percent_owned is not None else None ), "percent_delta_points": ( float(event.percent_delta_points) if event.percent_delta_points is not None else None ), "prior_form_group": event.prior_form_group, "is_initial_for_owner": bool(event.is_initial_for_owner), "is_13g_to_13d_transition": bool(event.is_13g_to_13d_transition), "ownership_strength_score": int(event.ownership_strength_score or 0), "avg_dollar_volume": avg_dollar_volume, "_sort": sort_key, } existing = best_by_symbol.get(symbol) if existing is None or tuple(row["_sort"]) > tuple(existing["_sort"]): best_by_symbol[symbol] = row rows = list(best_by_symbol.values()) rows.sort(key=lambda item: item["_sort"], reverse=True) for row in rows: row.pop("_sort", None) return rows def _build_ownership_capture_candidate( self, *, symbol: str, date: dt.date, filing_date: dt.date, form_type: str, percent_owned: float, aggregate_shares: float, activist_flag: bool, is_amendment: bool, prior_percent_owned: float | None, percent_delta_points: float | None, prior_form_group: str | None, is_initial_for_owner: bool, is_13g_to_13d_transition: bool, avg_dollar_volume: float, owner_name: str | None, owner_key: str | None, purpose_text: str | None, purpose_housekeeping_flag: bool, ownership_strength_score: int, ) -> Candidate: event_timestamp = dt.datetime.combine(filing_date, dt.time(0, 0), tzinfo=dt.timezone.utc) score = ( (1_000_000_000.0 if activist_flag else 0.0) + float(percent_delta_points or 0.0) * 100_000_000.0 + float(percent_owned or 0.0) * 1_000_000.0 ) return Candidate( event_id=f"{_OWNERSHIP_CAPTURE_EVENT_TYPE}:{symbol}:{filing_date.isoformat()}:{form_type}", symbol=symbol, source_symbol=symbol, score=score, sector="OWNERSHIP", event_type=_OWNERSHIP_CAPTURE_EVENT_TYPE, event_timestamp=event_timestamp, event_date=filing_date, filing_time_bucket="unknown", timing_class="unknown", reaction_date=date, execution_date=date, entry_price_est=float((self.store.get_bar(symbol, date) or {}).get("open") or 0.0), avg_dollar_volume=avg_dollar_volume, score_bucket="idle_ownership", engine_id=_OWNERSHIP_CAPTURE_ENGINE_ID, entry_timing_policy="next_open", engine_early_failure_close_below_entry_and_reaction_close=( not bool(self.config.ownership_capture.disable_day1_early_failure) ), engine_early_failure_no_progress_days=self.config.ownership_capture.no_progress_days_override, engine_early_failure_no_progress_r=self.config.ownership_capture.no_progress_r_override, engine_early_failure_no_progress_fraction=self.config.ownership_capture.no_progress_fraction_override, engine_max_holding_days=int(self.config.ownership_capture.hold_days), features={ "trade_sleeve": "ownership", "ownership_filing_date": filing_date.isoformat(), "ownership_form_type": form_type, "ownership_percent_owned": percent_owned, "ownership_aggregate_shares": aggregate_shares, "ownership_purpose_text": purpose_text, "ownership_purpose_housekeeping_flag": purpose_housekeeping_flag, "ownership_activist_flag": activist_flag, "ownership_is_amendment": is_amendment, "ownership_prior_percent_owned": prior_percent_owned, "ownership_percent_delta_points": percent_delta_points, "ownership_prior_form_group": prior_form_group, "ownership_is_initial_for_owner": is_initial_for_owner, "ownership_is_13g_to_13d_transition": is_13g_to_13d_transition, "ownership_strength_score": ownership_strength_score, "ownership_owner_name": owner_name, "ownership_owner_key": owner_key, }, ) def _enter_ownership_capture_positions(self, date: dt.date) -> None: selected = self._select_ownership_capture_candidates(date) if not selected: return cfg = self.config.ownership_capture existing_positions = sum( 1 for position in self._open_positions if position.plan.engine_id == _OWNERSHIP_CAPTURE_ENGINE_ID ) available_slots = max(0, int(cfg.max_positions) - existing_positions) if available_slots <= 0: return selected = selected[: min(int(cfg.max_new_per_day), available_slots)] if not selected: return market_value = self._compute_positions_market_value(date) equity_est = self._cash + market_value + self._get_parking_value(date) if equity_est <= 0: return if float(cfg.reserve_pct) > 0: reserve_budget = equity_est * float(cfg.reserve_pct) if reserve_budget <= 0: return if self._cash + 1e-9 < reserve_budget and self._get_parking_value(date) > 0: self._liquidate_parking_for_cash(date, reserve_budget - self._cash) total_budget = min(reserve_budget, self._cash) extra_idle_deploy_pct = min( 1.0, max(0.0, float(cfg.extra_idle_deploy_pct_above_reserve or 0.0)), ) if extra_idle_deploy_pct > 0 and equity_est > 0: cash_ratio = self._cash / equity_est if cash_ratio >= float(cfg.min_cash_ratio_for_overlay): remaining_cash = max(0.0, self._cash - total_budget) total_budget += remaining_cash * extra_idle_deploy_pct else: cash_ratio = self._cash / equity_est if cash_ratio < float(cfg.min_cash_ratio_for_overlay): return total_budget = self._cash * min(1.0, max(0.0, float(cfg.max_idle_deploy_pct))) if total_budget <= 0: return selected_candidates: list[tuple[dict[str, Any], Candidate]] = [] for payload in selected: symbol = str(payload["symbol"]) candidate = self._build_ownership_capture_candidate( symbol=symbol, date=date, filing_date=payload["filing_date"], form_type=str(payload["form_type"]), percent_owned=float(payload["percent_owned"]), aggregate_shares=float(payload["aggregate_shares"]), activist_flag=bool(payload["activist_flag"]), is_amendment=bool(payload["is_amendment"]), prior_percent_owned=payload["prior_percent_owned"], percent_delta_points=payload["percent_delta_points"], prior_form_group=payload["prior_form_group"], is_initial_for_owner=bool(payload["is_initial_for_owner"]), is_13g_to_13d_transition=bool(payload["is_13g_to_13d_transition"]), avg_dollar_volume=float(payload["avg_dollar_volume"]), owner_name=payload.get("owner_name"), owner_key=payload.get("owner_key"), purpose_text=payload.get("purpose_text"), purpose_housekeeping_flag=bool(payload.get("purpose_housekeeping_flag")), ownership_strength_score=int(payload.get("ownership_strength_score") or 0), ) selected_candidates.append((payload, candidate)) if not selected_candidates: return per_position_budgets = [total_budget / len(selected_candidates)] * len(selected_candidates) for (payload, candidate), per_position_budget in zip(selected_candidates, per_position_budgets, strict=False): symbol = str(payload["symbol"]) entry_bar = self.store.get_bar(symbol, date) if entry_bar is None or entry_bar.get("open") is None: continue effective_exec = self._build_effective_execution_config(candidate) estimated_fill = float(entry_bar["open"]) * (1.0 + effective_exec.slippage_bps_base / 10_000.0) shares = int(per_position_budget / estimated_fill) if estimated_fill > 0 else 0 if shares <= 0: continue plan = PlannedOrder( candidate=candidate, shares=shares, entry_price_limit=float(entry_bar["open"]), stop_price=0.01, target_price=float(entry_bar["open"]) * 100.0, risk_dollars=0.0, event_date=payload["filing_date"], timing_class="unknown", engine_id=_OWNERSHIP_CAPTURE_ENGINE_ID, entry_timing_policy="next_open", ) position = simulate_entry(plan, entry_bar, effective_exec) if position is None: continue trade_cost = position.entry_price * position.shares_open if trade_cost <= 0 or trade_cost > self._cash + 1e-9: continue self._cash -= trade_cost self._open_positions.append(position) 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_early_failure_close_below_entry_and_reaction_close": ( engine.early_failure_close_below_entry_and_reaction_close_override ), "engine_early_failure_no_progress_days": ( engine.early_failure_no_progress_days_override ), "engine_early_failure_no_progress_r": ( engine.early_failure_no_progress_r_override ), "engine_early_failure_no_progress_fraction": ( engine.early_failure_no_progress_fraction_override ), "engine_next_open_gap_cap_pct": engine.next_open_gap_cap_pct, "engine_use_reaction_day_low_stop": False, "engine_veto_parse_confidence_min": ( engine.veto_parse_confidence_min_override ), "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 _schedule_leader_follower_candidates(self, date: dt.date) -> None: """Generate synthetic pre-earnings follower candidates from strong leader reactions. This is a calendar-proxy engine: it only uses the future follower event row to confirm that an earnings event exists within a short lookahead window. The synthetic candidate score and sizing inputs use only today's leader reaction and the follower's current market state. """ next_date = self._next_trading_day.get(date) if next_date is None: return follower_engines = [ e for e in self._active_strategy_engines if e.leader_follower_lookahead_days is not None and self._engine_allowed_for_date(e, date) ] if not follower_engines: return raw_rows = self.store.get_candidates_for_reaction_date(date) if not raw_rows: return try: current_idx = self._simulation_dates.index(date) next_idx = self._simulation_dates.index(next_date) except ValueError: return max_lookahead = max(int(e.leader_follower_lookahead_days or 0) for e in follower_engines) if max_lookahead <= 0: return open_symbols = {p.plan.candidate.symbol.upper() for p in self._open_positions} preexisting_symbols = { candidate.symbol.upper() for candidate in self._scheduled_delayed_entries.get(next_date, []) } pending_by_symbol: dict[str, Candidate] = {} future_dates = self._simulation_dates[next_idx + 1: next_idx + max_lookahead + 1] future_dates_by_mode: dict[tuple[str, tuple[str, ...]], dict[str, dt.date]] = {} for engine in follower_engines: prelimit = max(self.config.signal.max_candidates_per_day * 5, self.config.signal.max_candidates_per_day) leader_candidates = select_candidates( raw_rows, self.config.universe, self.config.signal, event_type_profiles=self.config.event_type_profiles or None, strategy_engine=engine, engine_lookup=self._strategy_engine_lookup, truncate_to=prelimit, ) leader_candidates = self._apply_attention_filters(leader_candidates, engine) if not leader_candidates: continue calendar_mode = str(getattr(engine, "leader_follower_calendar_mode", "future_row") or "future_row") follower_symbols = sorted( { follower_symbol for leader in leader_candidates for follower_symbol in self._leader_follower_peer_candidates( engine, str(leader.source_symbol or leader.symbol or "").upper(), str(leader.sector or "UNKNOWN"), ) } ) cache_key = (calendar_mode, tuple(follower_symbols)) upcoming_earnings_by_symbol = future_dates_by_mode.get(cache_key) if upcoming_earnings_by_symbol is None: upcoming_earnings_by_symbol = self._get_known_upcoming_earnings_by_symbol( date, future_dates, calendar_mode=calendar_mode, symbols=follower_symbols, ) future_dates_by_mode[cache_key] = upcoming_earnings_by_symbol if not upcoming_earnings_by_symbol: continue required_end_date = future_dates[-1] if future_dates else next_date self._ensure_leader_follower_market_data( list(upcoming_earnings_by_symbol.keys()), date, required_end_date=required_end_date, ) min_days_to_event = max(1, int(engine.leader_follower_min_days_to_event or 2)) hold_buffer_days = max(0, int(engine.leader_follower_hold_buffer_days)) for leader in leader_candidates: leader_symbol = str(leader.source_symbol or leader.symbol or "").upper() if not leader_symbol: continue leader_sector = str(leader.sector or "UNKNOWN") if leader_sector == "UNKNOWN": continue leader_reaction = float(leader.features.get("reaction_day_return") or 0.0) leader_close_location = float(leader.features.get("close_location") or 0.5) leader_volume_ratio = float( leader.features.get("volume_ratio") or leader.features.get("volume_ratio_20d") or 1.0 ) for follower_symbol in self._leader_follower_peer_candidates(engine, leader_symbol, leader_sector): if ( follower_symbol in open_symbols or follower_symbol in preexisting_symbols ): continue upcoming_reaction_date = upcoming_earnings_by_symbol.get(follower_symbol) if upcoming_reaction_date is None: continue try: upcoming_idx = self._simulation_dates.index(upcoming_reaction_date) except ValueError: continue trading_days_to_event = upcoming_idx - next_idx if trading_days_to_event < min_days_to_event: continue if trading_days_to_event > int(engine.leader_follower_lookahead_days or 0): continue follower_features = self.store.get_market_features(follower_symbol, date) if not follower_features: continue follower_reaction = follower_features.get("reaction_day_return") follower_gap = follower_features.get("gap_size") follower_close_location = follower_features.get("close_location") follower_volume_ratio = follower_features.get("volume_ratio_20d") follower_adv = follower_features.get("avg_dollar_volume_20d") follower_event_close = follower_features.get("event_close") follower_atr = follower_features.get("atr_14") if self._value_fails_bounds( float(follower_reaction) if follower_reaction is not None else None, engine.proxy_reaction_day_return_min, engine.proxy_reaction_day_return_max, ): continue if self._value_fails_bounds( float(follower_gap) if follower_gap is not None else None, engine.proxy_gap_size_min, engine.proxy_gap_size_max, ): continue if self._value_fails_bounds( float(follower_close_location) if follower_close_location is not None else None, engine.proxy_close_location_min, engine.proxy_close_location_max, ): continue if self._value_fails_bounds( float(follower_volume_ratio) if follower_volume_ratio is not None else None, engine.proxy_volume_ratio_min, engine.proxy_volume_ratio_max, ): continue if self._value_fails_bounds( float(follower_adv) if follower_adv is not None else None, engine.proxy_avg_dollar_volume_min, engine.proxy_avg_dollar_volume_max, ): continue close_value = float(follower_event_close or 0.0) if close_value <= 0: continue atr_value = float(follower_atr) if follower_atr is not None and float(follower_atr) > 0 else close_value * 0.02 adv_value = float(follower_adv) if follower_adv is not None and float(follower_adv) > 0 else 0.0 if adv_value <= 0: continue leader_quality = min(1.0, max(0.0, leader.score)) reaction_quality = min(1.0, max(0.0, leader_reaction) / max(abs(engine.reaction_day_return_min or 0.08), 0.08)) volume_quality = min(1.0, max(0.0, leader_volume_ratio) / max(engine.volume_ratio_min or 2.0, 1.0)) close_quality = min(1.0, max(0.0, leader_close_location)) follower_close_quality = min(1.0, max(0.0, float(follower_close_location or 0.0))) calm_reaction = 1.0 - min( 1.0, abs(float(follower_reaction or 0.0)) / max(abs(engine.proxy_reaction_day_return_max or 0.05), 0.05), ) calm_gap = 1.0 - min( 1.0, abs(float(follower_gap or 0.0)) / max(abs(engine.proxy_gap_size_max or 0.03), 0.03), ) timing_quality = 1.0 - min( 1.0, max(0, trading_days_to_event - min_days_to_event) / max(1.0, float((engine.leader_follower_lookahead_days or min_days_to_event) - min_days_to_event)), ) score = min( 0.99, 0.30 + 0.20 * leader_quality + 0.15 * reaction_quality + 0.10 * volume_quality + 0.08 * close_quality + 0.12 * calm_reaction + 0.05 * calm_gap + 0.08 * follower_close_quality + 0.10 * timing_quality, ) score_bucket = ( "high" if score >= 0.8 else "medium_high" if score >= 0.6 else "medium" ) max_holding_days = int(engine.max_holding_days or trading_days_to_event) max_holding_days = min(max_holding_days, max(1, trading_days_to_event - hold_buffer_days)) candidate = Candidate( event_id=f"synth_leader_follower_{leader_symbol.lower()}_{follower_symbol.lower()}_{date.isoformat()}", symbol=follower_symbol, source_symbol=leader_symbol, score=score, sector=leader_sector, event_type="leader_follower_preearnings", event_timestamp=dt.datetime.combine(date, dt.time(16, 0), tzinfo=dt.timezone.utc), event_date=date, filing_time_bucket="after_close", reaction_date=date, execution_date=next_date, entry_price_est=close_value, avg_dollar_volume=adv_value, atr_14=atr_value, score_bucket=score_bucket, engine_id=engine.engine_id, entry_timing_policy="next_open", trade_direction="long", engine_max_holding_days=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, engine_early_failure_close_below_entry_and_reaction_close=False, engine_early_failure_no_progress_days=engine.early_failure_no_progress_days_override, engine_early_failure_no_progress_r=engine.early_failure_no_progress_r_override, engine_early_failure_no_progress_fraction=engine.early_failure_no_progress_fraction_override, shadow_only=engine.shadow_only, features={ "leader_symbol": leader_symbol, "leader_event_id": leader.event_id, "leader_event_type": leader.event_type, "leader_score": leader.score, "leader_reaction_day_return": leader_reaction, "leader_close_location": leader_close_location, "leader_volume_ratio_20d": leader_volume_ratio, "follower_symbol": follower_symbol, "follower_reaction_day_return": follower_reaction, "follower_gap_size": follower_gap, "follower_close_location": follower_close_location, "follower_volume_ratio_20d": follower_volume_ratio, "leader_follower_upcoming_reaction_date": upcoming_reaction_date.isoformat(), "leader_follower_days_to_event": trading_days_to_event, }, ) existing = pending_by_symbol.get(follower_symbol) if existing is None or candidate.score > existing.score: pending_by_symbol[follower_symbol] = candidate if pending_by_symbol: scheduled = rank_candidates(list(pending_by_symbol.values())) self._scheduled_delayed_entries[next_date].extend(scheduled) def _leader_follower_peer_candidates( self, engine: StrategyEngineConfig, leader_symbol: str, leader_sector: str, ) -> list[str]: ordered = peer_candidates_for_symbol(leader_symbol, leader_sector) extra_by_leader = getattr(engine, "leader_follower_extra_peer_symbols_by_leader", None) or {} extra_by_sector = getattr(engine, "leader_follower_extra_peer_symbols_by_sector", None) or {} ordered.extend(extra_by_leader.get(leader_symbol, ())) ordered.extend(extra_by_sector.get(leader_sector, ())) allowed = { str(symbol).strip().upper() for symbol in (getattr(engine, "leader_follower_allowed_peer_symbols", None) or []) if str(symbol).strip() } seen: set[str] = set() result: list[str] = [] for symbol in ordered: candidate = str(symbol).strip().upper() if not candidate or candidate == leader_symbol or candidate in seen: continue if allowed and candidate not in allowed: continue seen.add(candidate) result.append(candidate) return result def _ensure_leader_follower_market_data( self, symbols: list[str], event_date: dt.date, *, required_end_date: dt.date | None = None, ) -> None: target_end_date = max(event_date, required_end_date or event_date) missing_symbols: list[str] = [] for symbol in symbols: normalized = str(symbol).strip().upper() if not normalized: continue latest_bar = self.store.get_latest_bar_on_or_before(normalized, event_date) latest_bar_date = latest_bar[0] if latest_bar is not None else None all_symbol_bars = self.store._bars.get(normalized) or {} max_available_date = max(all_symbol_bars.keys()) if all_symbol_bars else None if latest_bar_date is None or max_available_date is None or max_available_date < target_end_date: missing_symbols.append(normalized) if not missing_symbols: return import asyncio as _aio from libs.common.config import get_settings settings = get_settings() fetch_start = event_date - dt.timedelta(days=180) try: fetched_bars, _ = _aio.run( SnapshotStore._fetch_price_data( missing_symbols, (fetch_start, target_end_date), settings.stock_oracle_url, concurrency=8, ) ) except Exception as exc: logger.warning( "leader_follower_market_data_fetch_failed", symbol_count=len(missing_symbols), error=str(exc), ) return added_bars = 0 touched_symbols: set[str] = set() for symbol, date_bars in fetched_bars.items(): normalized = str(symbol).strip().upper() if not date_bars: continue existing = self.store._bars.setdefault(normalized, {}) for bar_date, bar in date_bars.items(): if bar_date > target_end_date or bar_date in existing: continue existing[bar_date] = bar added_bars += 1 if existing: touched_symbols.add(normalized) if not touched_symbols: return for symbol in touched_symbols: self.store._price_bar_cache.pop(symbol, None) stale_keys = [ key for key in self.store._market_feature_cache if key[0] in touched_symbols ] for key in stale_keys: self.store._market_feature_cache.pop(key, None) logger.info( "leader_follower_market_data_augmented", symbol_count=len(touched_symbols), added_bars=added_bars, fetch_start=fetch_start.isoformat(), event_date=event_date.isoformat(), target_end_date=target_end_date.isoformat(), ) def _schedule_macro_short_candidates(self, date: dt.date) -> None: """Generate synthetic SH (inverse ETF) candidates during deep bearish regimes. Uses composite risk score to identify stress periods. Only fires when an engine with macro_short_risk_threshold is configured. Treats SH like any other PEAD candidate — ATR-based stop, per-trade risk budget, trailing stop. """ next_date = self._next_trading_day.get(date) if next_date is None: return macro_engines = [ e for e in self._active_strategy_engines if e.macro_short_risk_threshold is not None ] if not macro_engines: return # Skip if SH position already open (no pyramiding) open_symbols = {p.plan.candidate.symbol for p in self._open_positions} if "SH" in open_symbols: return # Cooldown: don't re-enter SH within 5 calendar days of last SH exit (avoid churn) sh_exits = [t for t in self._closed_trades if t.symbol == "SH"] if sh_exits: last_sh_exit = max(t.exit_date for t in sh_exits) if (date - last_sh_exit).days < 5: return macro = self.store.get_macro_for_date(date) or {} if not macro: return # Only enter SH when SPY is in confirmed downtrend (below 20-day SMA). # This prevents entries during bear-market rallies and recovery phases # where the risk score lags but the market has already turned. spy_close = macro.get("spy_close") spy_sma = macro.get("spy_sma_20") if spy_close and spy_sma and float(spy_close) > float(spy_sma): return risk_score = self._compute_parking_risk_score(macro) for engine in macro_engines: if risk_score < engine.macro_short_risk_threshold: continue sh_close = macro.get("sh_close") if not sh_close or sh_close <= 0: continue # ATR estimate: annualized vol → daily vol → price-based ATR # sh_vol_20 is annualized realized vol; daily_vol = annualized / sqrt(252) sh_vol_ann = macro.get("sh_vol_20") if sh_vol_ann and sh_vol_ann > 0: atr_est = sh_close * (sh_vol_ann / (252 ** 0.5)) * 14 ** 0.5 # ~14-day ATR else: atr_est = sh_close * 0.02 # 2% fallback candidate = Candidate( event_id=f"synth_macro_sh_{date.isoformat()}", symbol="SH", score=min(1.0, risk_score / 100.0), sector="MACRO", event_type="macro_regime", event_timestamp=dt.datetime.combine(date, dt.time(16, 0), tzinfo=dt.timezone.utc), filing_time_bucket="after_close", reaction_date=date, execution_date=next_date, entry_price_est=float(sh_close), avg_dollar_volume=1e9, atr_14=float(atr_est), score_bucket="high", engine_id=engine.engine_id, entry_timing_policy="next_open", trade_direction="long", 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_stop_atr_multiplier=engine.stop_atr_multiplier_override, engine_trailing_warmup_days=engine.trailing_warmup_days_override, # Disable PEAD-style early-exit gates for macro trades: # The global config has early_failure_no_progress_days=1 (exit if no +0.15R by day 1) # which would kill every SH trade before the bear trend can materialize. engine_early_failure_no_progress_days=999, features={ "macro_risk_score": risk_score, "sh_entry_close": float(sh_close), }, ) self._scheduled_delayed_entries[next_date].append(candidate) @staticmethod def _value_fails_bounds( value: float | None, minimum: float | None, maximum: float | None, ) -> bool: if value is None: return minimum is not None or maximum is not None if minimum is not None and value < minimum: return True if maximum is not None and value > maximum: return True return False def _schedule_macro_long_candidates(self, date: dt.date) -> None: """Generate synthetic ETF long candidates when macro leadership/breadth expands.""" next_date = self._next_trading_day.get(date) if next_date is None: return macro_engines = [ e for e in self._active_strategy_engines if e.macro_long_symbol and self._engine_allowed_for_date(e, date) ] if not macro_engines: return open_symbols = {p.plan.candidate.symbol.upper() for p in self._open_positions} macro = self.store.get_macro_for_date(date) or {} macro_vix = macro.get("macro_vix") if macro_vix is None: macro_vix = macro.get("VIXCLS") selected_today = [ candidate for candidate in self._recent_scored_candidates.get(date, []) if not candidate.shadow_only ] event_breadth_count = len(selected_today) event_breadth_unique_sectors = len({candidate.sector for candidate in selected_today}) for engine in macro_engines: trigger_symbol = str(engine.macro_long_symbol or "").upper() if not trigger_symbol: continue if self._value_fails_bounds( float(macro_vix) if macro_vix is not None else None, engine.macro_vix_min, engine.macro_vix_max, ): continue market_features = self.store.get_market_features(trigger_symbol, date) if not market_features: continue reaction_return = market_features.get("reaction_day_return") volume_ratio = market_features.get("volume_ratio_20d") gap_size = market_features.get("gap_size") close_location = market_features.get("close_location") event_close = market_features.get("event_close") atr_14 = market_features.get("atr_14") avg_dollar_volume = market_features.get("avg_dollar_volume_20d") if self._value_fails_bounds( float(reaction_return) if reaction_return is not None else None, engine.macro_long_reaction_day_return_min, engine.macro_long_reaction_day_return_max, ): continue if self._value_fails_bounds( float(volume_ratio) if volume_ratio is not None else None, engine.macro_long_volume_ratio_min, engine.macro_long_volume_ratio_max, ): continue if self._value_fails_bounds( float(gap_size) if gap_size is not None else None, engine.macro_long_gap_size_min, engine.macro_long_gap_size_max, ): continue if self._value_fails_bounds( float(close_location) if close_location is not None else None, engine.macro_long_close_location_min, engine.macro_long_close_location_max, ): continue breadth_symbols = [ str(raw_symbol).upper() for raw_symbol in (engine.macro_long_breadth_symbols or []) if str(raw_symbol).strip() ] breadth_match_symbols: list[str] = [] breadth_feature_map: dict[str, dict[str, Any]] = {} breadth_count = event_breadth_count breadth_unique_sectors = event_breadth_unique_sectors leadership_vs_spy = None if breadth_symbols: deduped_breadth_symbols = list(dict.fromkeys(breadth_symbols)) for breadth_symbol in deduped_breadth_symbols: breadth_features = self.store.get_market_features(breadth_symbol, date) if not breadth_features: continue breadth_feature_map[breadth_symbol] = breadth_features if self._value_fails_bounds( float(breadth_features.get("reaction_day_return")) if breadth_features.get("reaction_day_return") is not None else None, engine.macro_long_breadth_reaction_day_return_min, engine.macro_long_breadth_reaction_day_return_max, ): continue if self._value_fails_bounds( float(breadth_features.get("volume_ratio_20d")) if breadth_features.get("volume_ratio_20d") is not None else None, engine.macro_long_breadth_volume_ratio_min, engine.macro_long_breadth_volume_ratio_max, ): continue if self._value_fails_bounds( float(breadth_features.get("gap_size")) if breadth_features.get("gap_size") is not None else None, engine.macro_long_breadth_gap_size_min, engine.macro_long_breadth_gap_size_max, ): continue if self._value_fails_bounds( float(breadth_features.get("close_location")) if breadth_features.get("close_location") is not None else None, engine.macro_long_breadth_close_location_min, engine.macro_long_breadth_close_location_max, ): continue breadth_match_symbols.append(breadth_symbol) breadth_count = len(breadth_match_symbols) breadth_unique_sectors = breadth_count if ( engine.macro_long_min_breadth_count is not None and breadth_count < engine.macro_long_min_breadth_count ): continue else: if ( engine.macro_long_min_daily_candidate_count is not None and breadth_count < engine.macro_long_min_daily_candidate_count ): continue if ( engine.macro_long_min_unique_sector_count is not None and breadth_unique_sectors < engine.macro_long_min_unique_sector_count ): continue if engine.macro_long_leadership_vs_spy_min is not None: spy_features = self.store.get_market_features("SPY", date) spy_reaction_return = spy_features.get("reaction_day_return") if reaction_return is None or spy_reaction_return is None: continue leadership_vs_spy = float(reaction_return) - float(spy_reaction_return) if leadership_vs_spy < engine.macro_long_leadership_vs_spy_min: continue trade_symbol = trigger_symbol trade_symbol_mode = str(engine.macro_long_trade_symbol_mode or "fixed").lower() if trade_symbol_mode == "leader" and breadth_match_symbols: def _leader_rank_key(symbol_name: str) -> tuple[float, float, float]: features = breadth_feature_map.get(symbol_name) or {} return ( float(features.get("reaction_day_return") or 0.0), float(features.get("close_location") or 0.0), float(features.get("volume_ratio_20d") or 0.0), ) trade_symbol = max(breadth_match_symbols, key=_leader_rank_key) market_features = breadth_feature_map.get(trade_symbol) or self.store.get_market_features(trade_symbol, date) reaction_return = market_features.get("reaction_day_return") volume_ratio = market_features.get("volume_ratio_20d") gap_size = market_features.get("gap_size") close_location = market_features.get("close_location") event_close = market_features.get("event_close") atr_14 = market_features.get("atr_14") avg_dollar_volume = market_features.get("avg_dollar_volume_20d") if trade_symbol in open_symbols: continue execution_bar = self.store.get_bar(trade_symbol, next_date) if execution_bar is None: continue reaction_quality = max(0.0, float(reaction_return or 0.0)) reaction_scale = abs(engine.macro_long_reaction_day_return_min or 0.015) or 0.015 reaction_quality = min(1.0, reaction_quality / reaction_scale) if engine.macro_long_volume_ratio_min: volume_quality = min(1.0, float(volume_ratio or 0.0) / engine.macro_long_volume_ratio_min) else: volume_quality = 0.5 if volume_ratio is None else min(1.0, float(volume_ratio) / 2.0) close_quality = 0.5 if close_location is None else max(0.0, min(1.0, float(close_location))) breadth_components: list[float] = [] if breadth_symbols: if engine.macro_long_min_breadth_count: breadth_components.append( min(1.0, breadth_count / float(engine.macro_long_min_breadth_count)) ) else: if engine.macro_long_min_daily_candidate_count: breadth_components.append( min(1.0, breadth_count / float(engine.macro_long_min_daily_candidate_count)) ) if engine.macro_long_min_unique_sector_count: breadth_components.append( min(1.0, breadth_unique_sectors / float(engine.macro_long_min_unique_sector_count)) ) breadth_quality = ( sum(breadth_components) / len(breadth_components) if breadth_components else 0.5 ) score = min( 0.99, 0.35 + 0.30 * reaction_quality + 0.15 * volume_quality + 0.10 * close_quality + 0.10 * breadth_quality, ) score_bucket = ( "high" if score >= 0.8 else "medium_high" if score >= 0.6 else "medium" ) close_value = float(event_close) if event_close is not None else float(execution_bar.get("close") or 0.0) if close_value <= 0: continue atr_value = float(atr_14) if atr_14 is not None and float(atr_14) > 0 else close_value * 0.02 adv_value = float(avg_dollar_volume) if avg_dollar_volume is not None and float(avg_dollar_volume) > 0 else 1e9 candidate = Candidate( event_id=f"synth_macro_long_{trade_symbol.lower()}_{date.isoformat()}", symbol=trade_symbol, source_symbol=trigger_symbol, score=score, sector="MACRO", event_type="macro_bullish_event", event_timestamp=dt.datetime.combine(date, dt.time(16, 0), tzinfo=dt.timezone.utc), event_date=date, filing_time_bucket="after_close", reaction_date=date, execution_date=next_date, entry_price_est=close_value, avg_dollar_volume=adv_value, atr_14=atr_value, score_bucket=score_bucket, engine_id=engine.engine_id, entry_timing_policy="next_open", trade_direction="long", 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, engine_early_failure_close_below_entry_and_reaction_close=False, engine_early_failure_no_progress_days=999, shadow_only=engine.shadow_only, features={ "macro_long_symbol": trigger_symbol, "macro_long_trade_symbol": trade_symbol, "macro_long_trade_symbol_mode": trade_symbol_mode, "macro_long_reaction_day_return": reaction_return, "macro_long_volume_ratio_20d": volume_ratio, "macro_long_gap_size": gap_size, "macro_long_close_location": close_location, "macro_long_breadth_symbols": breadth_match_symbols, "macro_long_breadth_count": breadth_count, "macro_long_leadership_vs_spy": leadership_vs_spy, "macro_long_daily_candidate_count": breadth_count, "macro_long_daily_unique_sector_count": breadth_unique_sectors, "macro_long_event_candidate_count": event_breadth_count, "macro_long_event_unique_sector_count": event_breadth_unique_sectors, "macro_long_vix": macro_vix, "macro_long_event_close": close_value, }, ) 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: close = self._resolve_close_price( pos.plan.candidate.symbol, date, pos.entry_price, ) is_short = pos.plan.candidate.trade_direction == "short" if is_short: total += (2.0 * pos.entry_price - close) * pos.shares_open else: total += close * 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 _get_parking_signal_prefix(self, symbol: str | None) -> str: """Map parking symbol to the macro prefix that has full signal coverage.""" normalized = (symbol or "").lower() # QQQM is a lower-fee parking vehicle, but the macro feature store only # computes the full signal stack (mom/vol/entropy/autocorr/...) for QQQ. # If we use `qqqm` as the signal prefix, stress exits can still fire from # generic QQQ features, but SGOV -> QQQM re-entry confirmation never # resolves because `qqqm_mom_*` etc. are missing. That leaves parking # effectively stuck in SGOV after the first risk-off transition. if normalized == "qqqm": return "qqq" if normalized in ( "spy", "spym", "qqq", "qual", "gld", "jepq", "bufb", "merix", "shy", "usfr", "bil", "vgsh", "iei", "ief", "tip", "dbc", "sh", "psq", ): return normalized return "qqq" def _get_parking_defensive_symbol(self) -> str: """Secondary defensive ETF used for multi-step parking ladders.""" symbol = (self.config.risk.cash_parking_defensive_symbol or "spy").lower() if symbol in ( "spy", "spym", "qual", "gld", "dbc", "jepq", "bufb", "merix", "shy", "usfr", "bil", "vgsh", "iei", "ief", "tip", "dbc", ): return symbol return "spy" def _get_parking_defensive_alt_symbol(self) -> str | None: symbol = (self.config.risk.cash_parking_defensive_alt_symbol or "").lower() if not symbol: return None if symbol in ( "spy", "spym", "qual", "gld", "dbc", "jepq", "bufb", "merix", "shy", "usfr", "bil", "vgsh", "iei", "ief", "tip", ): return symbol return None def _get_parking_defensive_prefix(self) -> str: defensive_symbol = self._get_parking_defensive_symbol() if defensive_symbol == "qual": return "qual" return self._get_parking_signal_prefix(defensive_symbol) def _get_parking_defensive_corr_key(self) -> str: return f"{self._get_parking_defensive_prefix()}_qqq_corr_20" def _get_parking_crisis_symbol(self) -> str | None: symbol = (self.config.risk.cash_parking_crisis_symbol or "").lower() if symbol in ("gld", "shy", "usfr", "bil", "vgsh", "iei", "ief", "tip", "dbc"): return symbol return None def _evaluate_crisis_relay_target(self, macro: dict) -> str | None: """Use a bond-like safe haven only during deep stress and only when it is already trending up.""" crisis_symbol = self._get_parking_crisis_symbol() if crisis_symbol is None: return None risk_score = float(self._compute_parking_risk_score(macro)) if risk_score < float(self.config.risk.cash_parking_crisis_threshold): return None prefix = self._get_parking_signal_prefix(crisis_symbol) mom_days = max(1, int(self.config.risk.cash_parking_crisis_momentum_days or 20)) momentum = macro.get(f"{prefix}_mom_{mom_days}") if momentum is None or momentum <= float(self.config.risk.cash_parking_crisis_momentum_min): return None vol_max = float(self.config.risk.cash_parking_crisis_vol_max or 0.0) if vol_max > 0: vol = macro.get(f"{prefix}_vol_20") if vol is None or vol > vol_max: return None return crisis_symbol def _evaluate_defensive_relay_target( self, date: dt.date, macro: dict, park_mode: str, ) -> str | None: """Use the defensive ETF instead of SGOV when primary risk-on gate is off but broad stress is still moderate.""" if not self.config.risk.cash_parking_defensive_relay_enabled: return None defensive_symbol = self._get_parking_defensive_symbol() signal_prefix = self._get_parking_signal_prefix(park_mode) period = self.config.risk.cash_parking_trend_sma_period risk_score = float(self._compute_parking_risk_score(macro)) relay_risk_cap = min( float(self.config.risk.cash_parking_composite_exit_score), float(self.config.risk.cash_parking_defensive_relay_risk_score_max), ) if risk_score >= relay_risk_cap: return None trigger_mode = self.config.risk.cash_parking_defensive_relay_trigger_mode turn_strength = self._compute_turn_of_month_strength(date) turn_min = float(self.config.risk.cash_parking_defensive_relay_turn_strength_min) turn_ok = turn_strength >= turn_min if turn_min > 0 else False recovery_days = max(1, int(self.config.risk.cash_parking_defensive_relay_recovery_momentum_days)) recovery_mom = macro.get(f"{signal_prefix}_mom_{recovery_days}") recovery_mom_min = float(self.config.risk.cash_parking_defensive_relay_recovery_momentum_min) dd_accel = macro.get(f"{signal_prefix}_drawdown_accel_5") dd_accel_max = float(self.config.risk.cash_parking_defensive_relay_drawdown_accel_max) recovery_ok = ( recovery_mom is not None and recovery_mom >= recovery_mom_min and (dd_accel is None or dd_accel <= dd_accel_max) ) if trigger_mode == "turn_of_month" and not turn_ok: return None if trigger_mode == "recovery" and not recovery_ok: return None if trigger_mode == "turn_or_recovery" and not (turn_ok or recovery_ok): return None def _candidate_ok(symbol: str) -> tuple[bool, float]: if symbol in ("", "sgov", park_mode): return False, float("-inf") prefix = self._get_parking_signal_prefix(symbol) vol_lb = self.config.risk.cash_parking_gate_vol_lookback ent_lb = self.config.risk.cash_parking_entropy_lookback defensive_vol_threshold = float( self.config.risk.cash_parking_defensive_vol_max or self.config.risk.cash_parking_gate_vol_spy_threshold or self.config.risk.cash_parking_gate_vol_threshold ) defensive_vol = macro.get(f"{prefix}_vol_{vol_lb}") if defensive_vol is not None and defensive_vol >= defensive_vol_threshold: return False, float("-inf") defensive_mom = macro.get(f"{prefix}_mom_{period}") if defensive_mom is not None and defensive_mom <= float(self.config.risk.cash_parking_defensive_momentum_min): return False, float("-inf") ent_thr = self.config.risk.cash_parking_entropy_threshold if ent_thr > 0: defensive_entropy = macro.get(f"{prefix}_entropy_{ent_lb}") if defensive_entropy is not None and defensive_entropy > ent_thr + 0.15: return False, float("-inf") defensive_downside = macro.get(f"{prefix}_downside_vol_20") if defensive_downside is not None and defensive_downside > 0.18: return False, float("-inf") defensive_ulcer = macro.get(f"{prefix}_ulcer_20") if defensive_ulcer is not None and defensive_ulcer > 0.06: return False, float("-inf") defensive_autocorr = macro.get(f"{prefix}_autocorr_20") if defensive_autocorr is not None and defensive_autocorr < -0.10: return False, float("-inf") return True, float(defensive_mom or 0.0) candidates: list[tuple[str, float]] = [] ok, score = _candidate_ok(defensive_symbol) if ok: candidates.append((defensive_symbol, score)) alt_symbol = self._get_parking_defensive_alt_symbol() if alt_symbol and alt_symbol != defensive_symbol: ok, score = _candidate_ok(alt_symbol) if ok: candidates.append((alt_symbol, score)) if not candidates: return None candidates.sort(key=lambda item: item[1], reverse=True) return candidates[0][0] def _check_overlay_shock_brake(self, macro: dict) -> bool: """Check QQQ/SMH acceleration signals for fast TQQQ exit. Returns True if any brake signal fires. Does NOT use TQQQ price. Three signals, any one triggers: 1. Vol acceleration: qqq_vol_5 / qqq_vol_20 > rv_ratio threshold 2. Trend break + sector: qqq_close < qqq_sma_10 AND smh_mom_5 < 0 3. Sharp 5-day drawdown: (qqq_high_5 - qqq_close) / qqq_high_5 > dd5_pct """ cfg = self.config.risk rv_ratio = cfg.cash_parking_overlay_shock_brake_rv_ratio dd5_pct = cfg.cash_parking_overlay_shock_brake_dd5_pct sma_cross = cfg.cash_parking_overlay_shock_brake_sma_cross # Signal 1: vol acceleration vol5 = macro.get("qqq_vol_5") vol20 = macro.get("qqq_vol_20") if vol5 is not None and vol20 is not None and vol20 > 0: if vol5 / vol20 > rv_ratio: return True # Signal 2: trend break + sector weakness if sma_cross: qqq_close = macro.get("qqq_close") qqq_sma10 = macro.get("qqq_sma_10") smh_mom5 = macro.get("smh_mom_5") if ( qqq_close is not None and qqq_sma10 is not None and smh_mom5 is not None and qqq_close < qqq_sma10 and smh_mom5 < 0 ): return True # Signal 3: sharp 5-day drawdown qqq_high5 = macro.get("qqq_high_5") qqq_close = macro.get("qqq_close") if ( qqq_high5 is not None and qqq_close is not None and qqq_high5 > 0 and (qqq_high5 - qqq_close) / qqq_high5 > dd5_pct ): return True # Signal 4: near-SMA buffer — exit when QQQ is within sma_buffer% ABOVE SMA10 (pre-emptive) sma_buffer = cfg.cash_parking_overlay_shock_brake_sma_buffer if sma_buffer > 0: qqq_sma10 = macro.get("qqq_sma_10") qqq_close2 = macro.get("qqq_close") if qqq_sma10 and qqq_close2 and qqq_sma10 > 0: sma_gap = (qqq_close2 - qqq_sma10) / qqq_sma10 if 0 < sma_gap < sma_buffer: return True return False def _compute_overlay_blend_weights(self, macro: dict) -> tuple[float, float]: """Compute QQQM and TQQQ weights for continuous leverage blend. Returns (w_qqqm, w_tqqq) where sum == 1.0. Effective leverage = 1*w_qqqm + 3*w_tqqq = 1 + 2*w_tqqq. Target leverage = clip(target_vol / rv20, 1.0, max_leverage). """ cfg = self.config.risk target_vol = cfg.cash_parking_overlay_blend_target_vol max_lev = cfg.cash_parking_overlay_blend_max_leverage rv20 = macro.get("qqq_vol_20") if not rv20 or rv20 <= 0: return (1.0, 0.0) target_L = max(1.0, min(target_vol / rv20, max_lev)) w_tqqq = (target_L - 1.0) / 2.0 w_tqqq = max(0.0, min(w_tqqq, 1.0)) return (1.0 - w_tqqq, w_tqqq) def _evaluate_low_vol_overlay_target(self, macro: dict, park_mode: str) -> str | None: """Upgrade a safe parking regime to a stricter overlay symbol. The base regime decision must already be safe enough for `park_mode`. This helper only allows the overlay when the market is materially calmer than the normal QQQ/QQQM parking threshold. """ # Shock brake cooldown: suppress overlay re-entry for N days after brake if self._parking_overlay_brake_cooldown > 0: return None overlay_symbol = self.config.risk.cash_parking_low_vol_overlay_symbol if not overlay_symbol or overlay_symbol == park_mode: return None vol_lb = self.config.risk.cash_parking_gate_vol_lookback overlay_vol_threshold = self.config.risk.cash_parking_low_vol_overlay_vol_threshold if overlay_vol_threshold > 0: vol = macro.get(f"qqq_vol_{vol_lb}") if vol is None or vol >= overlay_vol_threshold: return None temp_max = self.config.risk.cash_parking_low_vol_overlay_temperature_max if temp_max > 0: vol_short = macro.get("qqq_vol_15") vol_long = macro.get("qqq_vol_50") if ( vol_short is None or vol_long is None or vol_long <= 0 or (vol_short / vol_long) > temp_max ): return None prefix = self._get_parking_signal_prefix(park_mode) entropy_max = self.config.risk.cash_parking_low_vol_overlay_entropy_max if entropy_max > 0: ent_lb = self.config.risk.cash_parking_entropy_lookback entropy = macro.get(f"{prefix}_entropy_{ent_lb}") if entropy is None or entropy > entropy_max: return None hurst_min = self.config.risk.cash_parking_low_vol_overlay_hurst_min if hurst_min > 0: hurst = macro.get(f"{prefix}_hurst_60") if hurst is None or hurst < hurst_min: return None return overlay_symbol def _evaluate_science_regime_target(self, macro: dict, park_mode: str) -> str: """Multi-signal non-levered regime ladder: risk-on -> defensive ETF -> SGOV. This mode combines composite risk scoring with information-theory and econophysics-style blockers, then de-risks in stages instead of collapsing straight to cash. """ risk_score = self._compute_parking_risk_score(macro) enter_threshold = self.config.risk.cash_parking_composite_enter_score spy_threshold = self.config.risk.cash_parking_composite_spy_score exit_threshold = self.config.risk.cash_parking_composite_exit_score defensive_symbol = self._get_parking_defensive_symbol() defensive_prefix = self._get_parking_defensive_prefix() signal_prefix = self._get_parking_signal_prefix(park_mode) period = self.config.risk.cash_parking_trend_sma_period qqq_mom = macro.get(f"{signal_prefix}_mom_{period}") defensive_mom = macro.get(f"{defensive_prefix}_mom_{period}") reentry_pct = self.config.risk.cash_parking_trend_reentry_pct entropy_hot = False ent_thr = self.config.risk.cash_parking_entropy_threshold if ent_thr > 0: ent_lb = self.config.risk.cash_parking_entropy_lookback entropy = macro.get(f"{signal_prefix}_entropy_{ent_lb}") entropy_hot = entropy is not None and entropy > ent_thr temperature_hot = False temp_thr = self.config.risk.cash_parking_temperature_threshold if temp_thr > 0: vol_short = macro.get("qqq_vol_15") vol_long = macro.get("qqq_vol_50") if vol_short is not None and vol_long is not None and vol_long > 0: temperature_hot = (vol_short / vol_long) > temp_thr vix = macro.get("VIXCLS") vix_blocked = False vix_max = self.config.risk.cash_parking_vix_reentry_max if vix_max > 0: vix_blocked = vix is None or vix >= vix_max qqq_reentry_ok = qqq_mom is None or qqq_mom > reentry_pct qqq_risk_on_ok = qqq_mom is None or qqq_mom > 0 defensive_ok = defensive_mom is None or defensive_mom > 0 current_target = self._parking_current_symbol if current_target not in ("sgov", defensive_symbol, park_mode): current_target = "sgov" if self._parking_trend_sgov else park_mode hard_defensive = risk_score >= exit_threshold soft_defensive = ( risk_score >= spy_threshold or entropy_hot or temperature_hot or vix_blocked ) if current_target == "sgov" or self._parking_trend_sgov: if hard_defensive: self._parking_trend_sgov = True return "sgov" if risk_score <= enter_threshold and qqq_reentry_ok and not (entropy_hot or temperature_hot or vix_blocked): self._parking_trend_sgov = False return park_mode if risk_score <= spy_threshold and defensive_ok and not vix_blocked: self._parking_trend_sgov = False return defensive_symbol self._parking_trend_sgov = True return "sgov" if current_target == defensive_symbol: if hard_defensive or not defensive_ok: self._parking_trend_sgov = True return "sgov" if risk_score <= enter_threshold and qqq_reentry_ok and not (entropy_hot or temperature_hot or vix_blocked): return park_mode return defensive_symbol if hard_defensive: self._parking_trend_sgov = True return "sgov" if soft_defensive or not qqq_risk_on_ok: if defensive_ok and not vix_blocked: return defensive_symbol self._parking_trend_sgov = True return "sgov" return park_mode def _evaluate_science_blend_plan(self, macro: dict, park_mode: str) -> tuple[str, float]: """Continuous non-levered parking plan using composite and regime quality.""" def _clip(value: float, lo: float = 0.0, hi: float = 1.0) -> float: return max(lo, min(hi, value)) risk_score = float(self._compute_parking_risk_score(macro)) enter_threshold = float(self.config.risk.cash_parking_composite_enter_score) spy_threshold = float(self.config.risk.cash_parking_composite_spy_score) exit_threshold = float(self.config.risk.cash_parking_composite_exit_score) defensive_symbol = self._get_parking_defensive_symbol() signal_prefix = self._get_parking_signal_prefix(park_mode) defensive_prefix = self._get_parking_defensive_prefix() if exit_threshold <= spy_threshold: exit_threshold = spy_threshold + 10.0 if spy_threshold <= enter_threshold: spy_threshold = enter_threshold + 8.0 period = self.config.risk.cash_parking_trend_sma_period qqq_mom = macro.get(f"{signal_prefix}_mom_{period}") defensive_mom = macro.get(f"{defensive_prefix}_mom_{period}") vol_lb = self.config.risk.cash_parking_gate_vol_lookback vol_thr = self.config.risk.cash_parking_gate_vol_threshold or 0.24 vol = macro.get(f"qqq_vol_{vol_lb}") ent_lb = self.config.risk.cash_parking_entropy_lookback ent_thr = self.config.risk.cash_parking_entropy_threshold or 1.2 entropy = macro.get(f"{signal_prefix}_entropy_{ent_lb}") hurst = macro.get(f"{signal_prefix}_hurst_60") autocorr = macro.get(f"{signal_prefix}_autocorr_20") corr = macro.get(self._get_parking_defensive_corr_key()) efficiency = macro.get(f"{signal_prefix}_efficiency_20") downside_vol = macro.get(f"{signal_prefix}_downside_vol_20") ulcer = macro.get(f"{signal_prefix}_ulcer_20") current_dd = macro.get(f"{signal_prefix}_drawdown_20") dd_accel = macro.get(f"{signal_prefix}_drawdown_accel_5") vix = macro.get("VIXCLS") hy = macro.get("BAMLH0A0HYM2") temp = None vol_short = macro.get("qqq_vol_15") vol_long = macro.get("qqq_vol_50") if vol_short is not None and vol_long is not None and vol_long > 0: temp = vol_short / vol_long temp_thr = self.config.risk.cash_parking_temperature_threshold or 1.25 trend_quality = 0.5 if qqq_mom is None else _clip((qqq_mom + 0.04) / 0.12) vol_quality = 0.5 if vol is None else _clip((vol_thr + 0.04 - vol) / 0.12) predictability = 0.5 if entropy is None else _clip((ent_thr + 0.18 - entropy) / 0.45) hurst_quality = 0.5 if hurst is None else _clip((hurst - 0.45) / 0.15) autocorr_quality = 0.5 if autocorr is None else _clip((autocorr + 0.08) / 0.28) persistence = 0.5 * hurst_quality + 0.5 * autocorr_quality thermal_quality = 0.5 if temp is None else _clip((temp_thr + 0.10 - temp) / 0.35) structure_quality = 0.5 if corr is None else _clip((corr - 0.76) / 0.18) efficiency_quality = 0.5 if efficiency is None else _clip((efficiency - 0.22) / 0.45) downside_calm = 0.5 if downside_vol is None else _clip((0.18 - downside_vol) / 0.10) ulcer_calm = 0.5 if ulcer is None else _clip((0.06 - ulcer) / 0.05) drawdown_calm = 0.5 if current_dd is None else _clip((0.10 - current_dd) / 0.08) accel_calm = 0.5 if dd_accel is None else _clip((0.02 - dd_accel) / 0.06) if vix is not None and hy is not None: macro_calm = 0.5 * _clip((24.0 - vix) / 10.0) + 0.5 * _clip((5.5 - hy) / 2.0) elif vix is not None: macro_calm = _clip((24.0 - vix) / 10.0) elif hy is not None: macro_calm = _clip((5.5 - hy) / 2.0) else: macro_calm = 0.5 confidence = ( 0.18 * trend_quality + 0.10 * vol_quality + 0.11 * predictability + 0.09 * persistence + 0.08 * thermal_quality + 0.06 * structure_quality + 0.06 * macro_calm + 0.12 * efficiency_quality + 0.08 * downside_calm + 0.06 * ulcer_calm + 0.04 * drawdown_calm + 0.02 * accel_calm ) confidence = _clip(confidence) base_stress = _clip((risk_score - enter_threshold) / max(exit_threshold - enter_threshold, 1.0)) structural_stress = ( 0.16 * (1.0 - downside_calm) + 0.12 * (1.0 - ulcer_calm) + 0.10 * (1.0 - accel_calm) + 0.07 * (1.0 - drawdown_calm) ) stress = _clip(0.75 * base_stress + structural_stress) invested_fraction = _clip(0.08 + 0.92 * confidence * (1.0 - 0.82 * stress)) qqq_ok = qqq_mom is None or qqq_mom > 0 defensive_ok = defensive_mom is None or defensive_mom > -0.01 drawdown_break = ( current_dd is not None and dd_accel is not None and current_dd >= 0.07 and dd_accel > 0.015 ) if (risk_score >= exit_threshold or drawdown_break) and not defensive_ok: return "sgov", 0.0 if ( risk_score <= enter_threshold and confidence >= 0.60 and qqq_ok and downside_calm >= 0.40 and accel_calm >= 0.35 ): return park_mode, max(0.70, invested_fraction) if risk_score <= spy_threshold and confidence >= 0.42: if qqq_ok and confidence >= 0.54 and downside_calm >= 0.32: return park_mode, max(0.55, invested_fraction) return defensive_symbol, max(0.45, min(0.80, invested_fraction)) if defensive_ok and risk_score < exit_threshold: return defensive_symbol, max(0.25, min(0.65, invested_fraction)) return "sgov", 0.0 def _compute_turn_of_month_strength(self, date: dt.date) -> float: """Return a 0..1 strength score around the month turn.""" idx = self._simulation_date_index.get(date) if idx is None: return 0.0 strength = 0.0 lead_days = max(0, int(self.config.risk.cash_parking_turn_of_month_lead_days)) lag_days = max(0, int(self.config.risk.cash_parking_turn_of_month_lag_days)) days_from_start = 0 while ( lag_days > 0 and days_from_start < lag_days and idx - days_from_start - 1 >= 0 and self._simulation_dates[idx - days_from_start - 1].month == date.month ): days_from_start += 1 if lag_days > 0 and days_from_start < lag_days: strength = max(strength, 1.0 - (days_from_start / lag_days)) days_to_end = 0 while ( lead_days > 0 and days_to_end < lead_days and idx + days_to_end + 1 < len(self._simulation_dates) and self._simulation_dates[idx + days_to_end + 1].month == date.month ): days_to_end += 1 if lead_days > 0 and days_to_end < lead_days: strength = max(strength, 1.0 - (days_to_end / lead_days)) return strength def _evaluate_vt_blend_plan(self, macro: dict, park_mode: str) -> tuple[str, float]: """Aggressive QQQM/QQQ blend that trims exposure during left-tail stress.""" def _clip(value: float, lo: float = 0.0, hi: float = 1.0) -> float: return max(lo, min(hi, value)) vol_lb = self.config.risk.cash_parking_gate_vol_lookback ent_lb = self.config.risk.cash_parking_entropy_lookback vol_thr = self.config.risk.cash_parking_gate_vol_threshold or 0.24 temp_thr = self.config.risk.cash_parking_temperature_threshold or 1.3 ent_thr = self.config.risk.cash_parking_entropy_threshold or 1.4 vol = macro.get(f"qqq_vol_{vol_lb}") entropy = macro.get(f"qqq_entropy_{ent_lb}") downside_vol = macro.get("qqq_downside_vol_20") ulcer = macro.get("qqq_ulcer_20") efficiency = macro.get("qqq_efficiency_20") dd_accel = macro.get("qqq_drawdown_accel_5") autocorr = macro.get("qqq_autocorr_20") qqq_mom = macro.get(f"qqq_mom_{self.config.risk.cash_parking_trend_sma_period}") vol_short = macro.get("qqq_vol_15") vol_long = macro.get("qqq_vol_50") temp = None if vol_short is not None and vol_long is not None and vol_long > 0: temp = vol_short / vol_long penalty = 0.0 if vol is not None and vol > vol_thr: penalty += 0.48 * _clip((vol - vol_thr) / 0.08) if temp is not None and temp > temp_thr: penalty += 0.28 * _clip((temp - temp_thr) / 0.28) if entropy is not None and entropy > ent_thr: penalty += 0.18 * _clip((entropy - ent_thr) / 0.35) if downside_vol is not None and downside_vol > 0.20: penalty += 0.20 * _clip((downside_vol - 0.20) / 0.08) if ulcer is not None and ulcer > 0.05: penalty += 0.14 * _clip((ulcer - 0.05) / 0.04) if dd_accel is not None and dd_accel > 0.015: penalty += 0.16 * _clip((dd_accel - 0.015) / 0.04) if efficiency is not None and efficiency < 0.08: penalty += 0.12 * _clip((0.08 - efficiency) / 0.08) if autocorr is not None and autocorr < -0.08: penalty += 0.10 * _clip((-0.08 - autocorr) / 0.12) risk_score = float(self._compute_parking_risk_score(macro)) if risk_score >= 40: penalty += 0.10 if qqq_mom is not None and qqq_mom < -0.02: penalty += 0.12 invested_fraction = _clip(1.0 - penalty, 0.0, 1.0) if invested_fraction < 0.08: return "sgov", 0.0 return park_mode, invested_fraction def _evaluate_vt_pair_blend_plan(self, macro: dict, park_mode: str) -> tuple[str, float]: """Aggressive ladder: QQQM/QQQ -> defensive ETF -> SGOV as stress rises.""" target_sym, invested_fraction = self._evaluate_vt_blend_plan(macro, park_mode) if target_sym == "sgov": return "sgov", 0.0 defensive_symbol = self._get_parking_defensive_symbol() defensive_prefix = self._get_parking_defensive_prefix() vol_lb = self.config.risk.cash_parking_gate_vol_lookback ent_lb = self.config.risk.cash_parking_entropy_lookback vol_thr = self.config.risk.cash_parking_gate_vol_threshold or 0.24 temp_thr = self.config.risk.cash_parking_temperature_threshold or 1.3 ent_thr = self.config.risk.cash_parking_entropy_threshold or 1.4 vol = macro.get(f"qqq_vol_{vol_lb}") entropy = macro.get(f"qqq_entropy_{ent_lb}") downside_vol = macro.get("qqq_downside_vol_20") ulcer = macro.get("qqq_ulcer_20") dd_accel = macro.get("qqq_drawdown_accel_5") qqq_mom = macro.get(f"qqq_mom_{self.config.risk.cash_parking_trend_sma_period}") defensive_mom = macro.get(f"{defensive_prefix}_mom_{self.config.risk.cash_parking_trend_sma_period}") vol_short = macro.get("qqq_vol_15") vol_long = macro.get("qqq_vol_50") temp = None if vol_short is not None and vol_long is not None and vol_long > 0: temp = vol_short / vol_long stress = 0.0 if vol is not None and vol > vol_thr: stress += 0.40 * max(0.0, min(1.0, (vol - vol_thr) / 0.08)) if temp is not None and temp > temp_thr: stress += 0.22 * max(0.0, min(1.0, (temp - temp_thr) / 0.25)) if entropy is not None and entropy > ent_thr: stress += 0.14 * max(0.0, min(1.0, (entropy - ent_thr) / 0.35)) if downside_vol is not None and downside_vol > 0.20: stress += 0.18 * max(0.0, min(1.0, (downside_vol - 0.20) / 0.08)) if ulcer is not None and ulcer > 0.05: stress += 0.12 * max(0.0, min(1.0, (ulcer - 0.05) / 0.05)) if dd_accel is not None and dd_accel > 0.015: stress += 0.14 * max(0.0, min(1.0, (dd_accel - 0.015) / 0.04)) if stress >= 0.72: return "sgov", 0.0 if stress >= 0.38 and (defensive_mom is None or defensive_mom > -0.01): spy_fraction = max(0.72, min(1.0, 1.02 - 0.45 * (stress - 0.38))) return defensive_symbol, spy_fraction if qqq_mom is not None and qqq_mom < -0.03 and (defensive_mom is None or defensive_mom > -0.01): return defensive_symbol, max(0.78, invested_fraction) return target_sym, invested_fraction def _evaluate_relative_strength_plan( self, date: dt.date, macro: dict, park_mode: str, ) -> tuple[str, float]: """Safe base gate with QQQ-vs-SPY leadership and turn-of-month bridge.""" def _clip(value: float, lo: float = 0.0, hi: float = 1.0) -> float: return max(lo, min(hi, value)) risk_score = float(self._compute_parking_risk_score(macro)) enter_threshold = float(self.config.risk.cash_parking_composite_enter_score) spy_threshold = float(self.config.risk.cash_parking_composite_spy_score) exit_threshold = float(self.config.risk.cash_parking_composite_exit_score) defensive_symbol = self._get_parking_defensive_symbol() defensive_prefix = self._get_parking_defensive_prefix() if exit_threshold <= spy_threshold: exit_threshold = spy_threshold + 10.0 if spy_threshold <= enter_threshold: spy_threshold = enter_threshold + 8.0 fast_days = max(5, int(self.config.risk.cash_parking_rotation_fast_momentum_days)) slow_days = max(fast_days, int(self.config.risk.cash_parking_rotation_slow_momentum_days)) vol_lb = self.config.risk.cash_parking_gate_vol_lookback ent_lb = self.config.risk.cash_parking_entropy_lookback vol_thr = self.config.risk.cash_parking_gate_vol_threshold or 0.24 ent_thr = self.config.risk.cash_parking_entropy_threshold or 1.2 temp_thr = self.config.risk.cash_parking_temperature_threshold or 1.25 qqq_fast = macro.get(f"qqq_mom_{fast_days}") qqq_slow = macro.get(f"qqq_mom_{slow_days}") defensive_fast = macro.get(f"{defensive_prefix}_mom_{fast_days}") defensive_slow = macro.get(f"{defensive_prefix}_mom_{slow_days}") qqq_vol = macro.get(f"qqq_vol_{vol_lb}") qqq_entropy = macro.get(f"qqq_entropy_{ent_lb}") qqq_eff = macro.get("qqq_efficiency_20") defensive_eff = macro.get(f"{defensive_prefix}_efficiency_20") qqq_downside = macro.get("qqq_downside_vol_20") defensive_downside = macro.get(f"{defensive_prefix}_downside_vol_20") qqq_ulcer = macro.get("qqq_ulcer_20") defensive_ulcer = macro.get(f"{defensive_prefix}_ulcer_20") qqq_temp_short = macro.get("qqq_vol_15") qqq_temp_long = macro.get("qqq_vol_50") qqq_temp = None if qqq_temp_short is not None and qqq_temp_long is not None and qqq_temp_long > 0: qqq_temp = qqq_temp_short / qqq_temp_long vix = macro.get("VIXCLS") hy = macro.get("BAMLH0A0HYM2") if vix is not None and hy is not None: macro_calm = 0.5 * _clip((24.0 - vix) / 10.0) + 0.5 * _clip((5.5 - hy) / 2.0) elif vix is not None: macro_calm = _clip((24.0 - vix) / 10.0) elif hy is not None: macro_calm = _clip((5.5 - hy) / 2.0) else: macro_calm = 0.5 qqq_lead_threshold = self.config.risk.cash_parking_rotation_lead_threshold qqq_strong_threshold = self.config.risk.cash_parking_rotation_strong_threshold turn_strength = self._compute_turn_of_month_strength(date) turn_boost = self.config.risk.cash_parking_turn_of_month_boost base_stress = _clip((risk_score - enter_threshold) / max(exit_threshold - enter_threshold, 1.0)) vol_quality = 0.5 if qqq_vol is None else _clip((vol_thr + 0.03 - qqq_vol) / 0.10) entropy_quality = 0.5 if qqq_entropy is None else _clip((ent_thr + 0.12 - qqq_entropy) / 0.30) temp_quality = 0.5 if qqq_temp is None else _clip((temp_thr + 0.08 - qqq_temp) / 0.25) qqq_calm = ( (qqq_vol is None or qqq_vol <= vol_thr) and (qqq_entropy is None or qqq_entropy <= ent_thr) and (qqq_temp is None or qqq_temp <= temp_thr) and (qqq_downside is None or qqq_downside <= 0.18) ) defensive_ok = ( (defensive_slow is None or defensive_slow > -0.01) and (defensive_downside is None or defensive_downside <= 0.17) and (defensive_ulcer is None or defensive_ulcer <= 0.06) ) qqq_ok = (qqq_fast is None or qqq_fast > -0.005) and (qqq_slow is None or qqq_slow > -0.01) leadership_gap = ( 0.55 * ((qqq_fast or 0.0) - (defensive_fast or 0.0)) + 0.25 * ((qqq_slow or 0.0) - (defensive_slow or 0.0)) + 0.35 * ((qqq_eff or 0.25) - (defensive_eff or 0.25)) + 0.25 * ((defensive_downside or 0.15) - (qqq_downside or 0.15)) + turn_boost * turn_strength ) qqq_confidence = _clip( 0.28 * (0.5 if qqq_fast is None else _clip((qqq_fast + 0.015) / 0.05)) + 0.20 * (0.5 if qqq_slow is None else _clip((qqq_slow + 0.03) / 0.10)) + 0.16 * vol_quality + 0.14 * entropy_quality + 0.12 * temp_quality + 0.10 * macro_calm ) spy_confidence = _clip( 0.42 * (0.5 if defensive_slow is None else _clip((defensive_slow + 0.02) / 0.08)) + 0.20 * (0.5 if defensive_downside is None else _clip((0.17 - defensive_downside) / 0.08)) + 0.16 * (0.5 if defensive_ulcer is None else _clip((0.055 - defensive_ulcer) / 0.05)) + 0.22 * macro_calm ) if risk_score >= exit_threshold: return "sgov", 0.0 if qqq_calm and qqq_ok and leadership_gap >= qqq_strong_threshold: frac = _clip(0.74 + 0.14 * turn_strength + 0.10 * qqq_confidence - 0.12 * base_stress) return park_mode, frac if qqq_calm and qqq_ok and leadership_gap >= qqq_lead_threshold and risk_score <= spy_threshold + 4: frac = _clip(0.56 + 0.12 * turn_strength + 0.10 * qqq_confidence - 0.12 * base_stress) return park_mode, frac if ( turn_strength >= 0.70 and qqq_fast is not None and qqq_fast > -0.005 and qqq_vol is not None and qqq_vol <= vol_thr + 0.03 and (qqq_temp is None or qqq_temp <= temp_thr + 0.05) and risk_score <= spy_threshold + 2 ): frac = _clip(0.38 + 0.22 * turn_strength + 0.12 * qqq_confidence - 0.08 * base_stress) return park_mode, frac if defensive_ok and risk_score <= exit_threshold - 1: frac = _clip(0.40 + 0.18 * macro_calm + 0.10 * turn_strength + 0.10 * spy_confidence - 0.10 * base_stress) return defensive_symbol, frac if defensive_ok and risk_score < exit_threshold: frac = _clip(0.24 + 0.16 * macro_calm + 0.05 * turn_strength - 0.08 * base_stress) return defensive_symbol, frac return "sgov", 0.0 def _evaluate_parking_target(self, date: dt.date) -> str | None: if self._parking_target_cache_valid and self._parking_target_cache_date == date: return self._parking_target_cache_value trend_state_before = self._parking_trend_sgov gate_state_before = self._parking_gate_in_sgov raw_target = self._compute_parking_target(date) target = self._apply_parking_target_confirmation(raw_target) if target != raw_target: self._parking_trend_sgov = trend_state_before self._parking_gate_in_sgov = gate_state_before # Bearish override: upgrade SGOV → inverse ETF when deep stress confirmed. # Only activates when cash_parking_bearish_symbol is explicitly configured. bearish_sym = self.config.risk.cash_parking_bearish_symbol if bearish_sym and target == "sgov": macro = self.store.get_macro_for_date(date) or {} risk_score = self._compute_parking_risk_score(macro) if risk_score >= self.config.risk.cash_parking_bearish_threshold: target = bearish_sym self._parking_target_cache_date = date self._parking_target_cache_value = target self._parking_target_cache_valid = True return target def _parking_default_target(self) -> str: if self._parking_current_symbol: return self._parking_current_symbol if self._parking_committed_target: return self._parking_committed_target if self._parking_trend_sgov: return "sgov" park_mode = self.config.risk.cash_parking_symbol return "qqq" if park_mode == "dynamic" else park_mode def _commit_parking_target(self, target: str | None) -> None: self._parking_committed_target = target self._parking_pending_target = None self._parking_pending_target_days = 0 def _apply_parking_target_confirmation(self, raw_target: str | None) -> str | None: if raw_target is None: return None current_target = self._parking_default_target() if self._parking_committed_target is None: self._parking_committed_target = current_target if raw_target == current_target: self._parking_pending_target = None self._parking_pending_target_days = 0 return current_target if current_target == "sgov" and raw_target != "sgov": confirm_days = max(1, self.config.risk.cash_parking_entry_confirm_days) elif current_target != "sgov" and raw_target == "sgov": confirm_days = max(1, self.config.risk.cash_parking_exit_confirm_days) else: confirm_days = max( 1, self.config.risk.cash_parking_entry_confirm_days, self.config.risk.cash_parking_exit_confirm_days, ) if confirm_days <= 1: self._commit_parking_target(raw_target) return raw_target if self._parking_pending_target == raw_target: self._parking_pending_target_days += 1 else: self._parking_pending_target = raw_target self._parking_pending_target_days = 1 if self._parking_pending_target_days >= confirm_days: self._commit_parking_target(raw_target) return raw_target return current_target def _compute_parking_target(self, date: dt.date) -> str | None: """Evaluate gate and return target parking symbol for today.""" macro = self.store.get_macro_for_date(date) or {} if not macro: return None gate_mode = self.config.risk.cash_parking_gate_mode park_mode = self.config.risk.cash_parking_symbol defensive_symbol = self._get_parking_defensive_symbol() defensive_prefix = self._get_parking_defensive_prefix() signal_prefix = self._get_parking_signal_prefix(park_mode) if gate_mode == "regime_tiered": # VIX-driven 3-tier rotation: risk-on symbol → neutral symbol → SGOV vix = macro.get("VIXCLS") if vix is None: return park_mode # fallback to default when VIX unavailable low_thr = self.config.risk.cash_parking_regime_vix_low_threshold high_thr = self.config.risk.cash_parking_regime_vix_high_threshold hyst = self.config.risk.cash_parking_regime_hysteresis_buffer risk_on_sym = self.config.risk.cash_parking_regime_risk_on_symbol neutral_sym = self.config.risk.cash_parking_regime_neutral_symbol current = self._parking_current_symbol or neutral_sym # Hysteresis: require extra VIX movement to exit current tier if current == risk_on_sym: if vix > high_thr: return "sgov" elif vix > low_thr + hyst: return neutral_sym return risk_on_sym elif current == "sgov": if vix < low_thr: return risk_on_sym elif vix < high_thr - hyst: return neutral_sym return "sgov" else: # neutral if vix < low_thr: return risk_on_sym elif vix > high_thr: return "sgov" return neutral_sym if gate_mode in ("vol_proportional", "vol_tqqq"): # These modes handle symbol selection internally vol_lb = self.config.risk.cash_parking_gate_vol_lookback vol = macro.get(f"qqq_vol_{vol_lb}") if gate_mode == "vol_tqqq": tqqq_pct = self.config.risk.cash_parking_gate_vol_tqqq_pct vol_thr = self.config.risk.cash_parking_gate_vol_threshold if vol is not None and vol < tqqq_pct: return "tqqq" elif vol is None or vol < vol_thr: return "qqq" return "sgov" return None # proportional handles internally # Evaluate gate for volatility mode (most common) if gate_mode == "volatility": vol_lb = self.config.risk.cash_parking_gate_vol_lookback vol = macro.get(f"qqq_vol_{vol_lb}") threshold = self.config.risk.cash_parking_gate_vol_threshold if vol is not None and vol >= threshold: self._parking_gate_in_sgov = True return "sgov" require_trend = self.config.risk.cash_parking_require_trend def _should_exit_on_stress() -> bool: if require_trend: return True if not self._parking_trend_sgov: self._parking_trend_sgov = True return True return False # Entropy check: high entropy = chaotic market → SGOV ent_thr = self.config.risk.cash_parking_entropy_threshold if ent_thr > 0: ent_lb = self.config.risk.cash_parking_entropy_lookback entropy = macro.get(f"{signal_prefix}_entropy_{ent_lb}") if entropy is not None and entropy > ent_thr and _should_exit_on_stress(): self._parking_gate_in_sgov = True return "sgov" if self._parking_trend_sgov and not require_trend: # Re-enter when entropy drops (no momentum check) if entropy is not None and entropy <= ent_thr * 0.8: self._parking_trend_sgov = False else: return "sgov" # VRP check: VIX - realized_vol divergence = "quiet before the storm" vrp_thr = self.config.risk.cash_parking_vrp_threshold if vrp_thr > 0: vix_vrp = macro.get("VIXCLS") vol_vrp = macro.get(f"qqq_vol_{self.config.risk.cash_parking_gate_vol_lookback}") if vix_vrp is not None and vol_vrp is not None: vrp = vix_vrp - (vol_vrp * 100) if vrp > vrp_thr and _should_exit_on_stress(): self._parking_gate_in_sgov = True return "sgov" if self._parking_trend_sgov and not require_trend: if vrp <= vrp_thr * 0.6: self._parking_trend_sgov = False else: return "sgov" # Temperature check: vol acceleration (vol_15/vol_50 ratio) temp_thr = self.config.risk.cash_parking_temperature_threshold if temp_thr > 0: vol_short = macro.get("qqq_vol_15") vol_long = macro.get("qqq_vol_50") if vol_short is not None and vol_long is not None and vol_long > 0: temp = vol_short / vol_long if temp > temp_thr and _should_exit_on_stress(): self._parking_gate_in_sgov = True return "sgov" if self._parking_trend_sgov and not require_trend: if temp <= temp_thr * 0.7: self._parking_trend_sgov = False else: return "sgov" # Hurst check: H < threshold = mean-reverting/anti-persistent → SGOV hurst_thr = self.config.risk.cash_parking_hurst_threshold if hurst_thr > 0: hurst = macro.get(f"{signal_prefix}_hurst_60") if hurst is not None and hurst < hurst_thr and _should_exit_on_stress(): self._parking_gate_in_sgov = True return "sgov" if self._parking_trend_sgov and not require_trend: if hurst is not None and hurst >= hurst_thr + 0.05: self._parking_trend_sgov = False elif hurst is not None: return "sgov" # Efficiency check: noisy zig-zag moves are worse than smooth trend. eff_thr = self.config.risk.cash_parking_efficiency_threshold if eff_thr > 0: efficiency = macro.get(f"{signal_prefix}_efficiency_20") if efficiency is not None and efficiency < eff_thr and _should_exit_on_stress(): self._parking_gate_in_sgov = True return "sgov" if self._parking_trend_sgov and not require_trend: if efficiency is not None and efficiency >= eff_thr + 0.05: self._parking_trend_sgov = False elif efficiency is not None: return "sgov" # Downside semivolatility check: penalize left-tail turbulence only. downside_thr = self.config.risk.cash_parking_downside_vol_threshold if downside_thr > 0: downside_vol = macro.get(f"{signal_prefix}_downside_vol_20") if downside_vol is not None and downside_vol > downside_thr and _should_exit_on_stress(): self._parking_gate_in_sgov = True return "sgov" if self._parking_trend_sgov and not require_trend: if downside_vol is not None and downside_vol <= downside_thr * 0.82: self._parking_trend_sgov = False elif downside_vol is not None: return "sgov" # Ulcer check: stay out when recent drawdown pain is persistent. ulcer_thr = self.config.risk.cash_parking_ulcer_threshold if ulcer_thr > 0: ulcer = macro.get(f"{signal_prefix}_ulcer_20") if ulcer is not None and ulcer > ulcer_thr and _should_exit_on_stress(): self._parking_gate_in_sgov = True return "sgov" if self._parking_trend_sgov and not require_trend: if ulcer is not None and ulcer <= ulcer_thr * 0.75: self._parking_trend_sgov = False elif ulcer is not None: return "sgov" # Drawdown acceleration check: rising damage often arrives before vol fully expands. dd_accel_thr = self.config.risk.cash_parking_drawdown_accel_threshold if dd_accel_thr > 0: dd_accel = macro.get(f"{signal_prefix}_drawdown_accel_5") if dd_accel is not None and dd_accel > dd_accel_thr and _should_exit_on_stress(): self._parking_gate_in_sgov = True return "sgov" if self._parking_trend_sgov and not require_trend: if dd_accel is not None and dd_accel <= dd_accel_thr * 0.5: self._parking_trend_sgov = False elif dd_accel is not None: return "sgov" # Kurtosis check: fat tails → SGOV kurt_thr = self.config.risk.cash_parking_kurtosis_threshold if kurt_thr > 0: kurt = macro.get(f"{signal_prefix}_kurtosis_20") if kurt is not None and kurt > kurt_thr and _should_exit_on_stress(): self._parking_gate_in_sgov = True return "sgov" if self._parking_trend_sgov and not require_trend: if kurt is not None and kurt <= kurt_thr * 0.6: self._parking_trend_sgov = False elif kurt is not None: return "sgov" # Autocorrelation check: negative = reversal regime → SGOV ac_thr = self.config.risk.cash_parking_autocorr_threshold if ac_thr > -99: ac = macro.get(f"{signal_prefix}_autocorr_20") if ac is not None and ac < ac_thr and _should_exit_on_stress(): self._parking_gate_in_sgov = True return "sgov" if self._parking_trend_sgov and not require_trend: if ac is not None and ac >= ac_thr + 0.1: self._parking_trend_sgov = False elif ac is not None: return "sgov" # SPY-QQQ correlation check: decorrelation = regime shift → SGOV corr_thr = self.config.risk.cash_parking_corr_threshold if corr_thr > 0: corr = macro.get(self._get_parking_defensive_corr_key()) if corr is not None and corr < corr_thr and _should_exit_on_stress(): self._parking_gate_in_sgov = True return "sgov" if self._parking_trend_sgov and not require_trend: if corr is not None and corr >= corr_thr + 0.05: self._parking_trend_sgov = False elif corr is not None: return "sgov" # Additional trend check: QQQ must confirm uptrend to stay invested if require_trend: trend_mode = self.config.risk.cash_parking_trend_mode period = self.config.risk.cash_parking_trend_sma_period reentry_pct = self.config.risk.cash_parking_trend_reentry_pct if trend_mode == "momentum": mom = macro.get(f"{signal_prefix}_mom_{period}") if self._parking_trend_sgov: # In SGOV: need strong recovery + VIX calm to re-enter mom_ok = mom is not None and mom > reentry_pct vix_max = self.config.risk.cash_parking_vix_reentry_max vix_ok = True if vix_max > 0: vix = macro.get("VIXCLS") vix_ok = vix is not None and vix < vix_max if mom_ok and vix_ok: self._parking_trend_sgov = False return park_mode # recovery confirmed return "sgov" # still waiting else: # In QQQ: exit if momentum turns negative if mom is not None and mom <= 0: self._parking_trend_sgov = True return "sgov" # Also exit if entropy is too high (chaotic market) ent_thr = self.config.risk.cash_parking_entropy_threshold if ent_thr > 0: ent_lb = self.config.risk.cash_parking_entropy_lookback entropy = macro.get(f"{signal_prefix}_entropy_{ent_lb}") if entropy is not None and entropy > ent_thr: self._parking_trend_sgov = True return "sgov" else: # sma c = macro.get(f"{signal_prefix}_close") sma = macro.get(f"{signal_prefix}_sma_{period}") if c and sma and c < sma: return "sgov" if self._parking_gate_in_sgov: vol_mult = float(self.config.risk.cash_parking_stress_reentry_vol_mult or 1.0) temp_mult = float(self.config.risk.cash_parking_stress_reentry_temperature_mult or 1.0) ent_mult = float(self.config.risk.cash_parking_stress_reentry_entropy_mult or 1.0) ac_buffer = float(self.config.risk.cash_parking_stress_reentry_autocorr_buffer or 0.0) if vol is not None and vol_mult < 1.0 and vol >= threshold * vol_mult: return "sgov" if ent_thr > 0: entropy = macro.get(f"{signal_prefix}_entropy_{self.config.risk.cash_parking_entropy_lookback}") if entropy is not None and ent_mult < 1.0 and entropy > ent_thr * ent_mult: return "sgov" if temp_thr > 0: vol_short = macro.get("qqq_vol_15") vol_long = macro.get("qqq_vol_50") if vol_short is not None and vol_long is not None and vol_long > 0: temp = vol_short / vol_long if temp_mult < 1.0 and temp > temp_thr * temp_mult: return "sgov" if ac_thr > -99 and ac_buffer > 0: ac = macro.get(f"{signal_prefix}_autocorr_20") if ac is None or ac < ac_thr + ac_buffer: return "sgov" if require_trend and self.config.risk.cash_parking_trend_mode == "momentum": mom = macro.get(f"{signal_prefix}_mom_{self.config.risk.cash_parking_trend_sma_period}") if mom is None or mom <= reentry_pct: return "sgov" self._parking_gate_in_sgov = False overlay_target = self._evaluate_low_vol_overlay_target(macro, park_mode) if overlay_target is not None: return overlay_target return park_mode # qqq or spy # Composite gate: multi-signal risk score if gate_mode == "guarded_regime": vol_lb = self.config.risk.cash_parking_gate_vol_lookback vol = macro.get(f"qqq_vol_{vol_lb}") if vol is None: return "sgov" qqq_mom_days = self.config.risk.cash_parking_trend_sma_period qqq_mom = macro.get(f"qqq_mom_{qqq_mom_days}") defensive_mom = macro.get(f"{defensive_prefix}_mom_{qqq_mom_days}") tqqq_threshold = self.config.risk.cash_parking_gate_vol_tqqq_pct qqq_threshold = self.config.risk.cash_parking_gate_vol_threshold spy_threshold = self.config.risk.cash_parking_gate_vol_spy_threshold tqqq_mom_min = self.config.risk.cash_parking_trend_reentry_pct entropy_hot = False ent_thr = self.config.risk.cash_parking_entropy_threshold if ent_thr > 0: ent_lb = self.config.risk.cash_parking_entropy_lookback entropy = macro.get(f"qqq_entropy_{ent_lb}") entropy_hot = entropy is not None and entropy > ent_thr temperature_hot = False temp_thr = self.config.risk.cash_parking_temperature_threshold if temp_thr > 0: vol_short = macro.get("qqq_vol_15") vol_long = macro.get("qqq_vol_50") if vol_short is not None and vol_long is not None and vol_long > 0: temperature_hot = (vol_short / vol_long) > temp_thr vix_blocked = False vix_max = self.config.risk.cash_parking_vix_reentry_max if vix_max > 0: vix = macro.get("VIXCLS") vix_blocked = vix is None or vix >= vix_max if entropy_hot or temperature_hot or vix_blocked: if vol < spy_threshold and defensive_mom is not None and defensive_mom > 0: return defensive_symbol return "sgov" if ( vol < tqqq_threshold and qqq_mom is not None and qqq_mom > tqqq_mom_min ): return "tqqq" if vol < qqq_threshold and qqq_mom is not None and qqq_mom > 0: return park_mode if vol < spy_threshold and defensive_mom is not None and defensive_mom > 0: return defensive_symbol return "sgov" if gate_mode == "science_regime": return self._evaluate_science_regime_target(macro, park_mode) if gate_mode == "science_blend": target_sym, invested_fraction = self._evaluate_science_blend_plan(macro, park_mode) if invested_fraction <= 0.05: return "sgov" return target_sym if gate_mode == "vt_blend": target_sym, invested_fraction = self._evaluate_vt_blend_plan(macro, park_mode) if invested_fraction <= 0.05: return "sgov" return target_sym if gate_mode == "vt_pair_blend": target_sym, invested_fraction = self._evaluate_vt_pair_blend_plan(macro, park_mode) if invested_fraction <= 0.05: return "sgov" return target_sym if gate_mode == "relative_strength": target_sym, invested_fraction = self._evaluate_relative_strength_plan(date, macro, park_mode) if invested_fraction <= 0.05: return "sgov" return target_sym if gate_mode == "composite": risk_score = self._compute_parking_risk_score(macro) exit_threshold = self.config.risk.cash_parking_composite_exit_score enter_threshold = self.config.risk.cash_parking_composite_enter_score if self._parking_trend_sgov: # In SGOV: need low risk AND momentum recovery to re-enter risk_ok = risk_score <= enter_threshold if enter_threshold > 0 else True mom_ok = True if self.config.risk.cash_parking_require_trend and self.config.risk.cash_parking_trend_mode == "momentum": period = self.config.risk.cash_parking_trend_sma_period reentry_pct = self.config.risk.cash_parking_trend_reentry_pct mom = macro.get(f"{signal_prefix}_mom_{period}") mom_ok = mom is not None and mom > reentry_pct if risk_ok and mom_ok: self._parking_trend_sgov = False return park_mode return "sgov" else: # In QQQ: exit if risk too high OR momentum negative if risk_score >= exit_threshold: self._parking_trend_sgov = True return "sgov" # Also check momentum for low-vol declines if self.config.risk.cash_parking_require_trend and self.config.risk.cash_parking_trend_mode == "momentum": period = self.config.risk.cash_parking_trend_sma_period mom = macro.get(f"{signal_prefix}_mom_{period}") if mom is not None and mom <= 0: self._parking_trend_sgov = True return "sgov" overlay_target = self._evaluate_low_vol_overlay_target(macro, park_mode) if overlay_target is not None: return overlay_target return park_mode # For other gate modes, use simple SMA check gate_p = self.config.risk.cash_parking_gate_sma_period if park_mode in ("spy", "qqq", "spym", "qual") and self.config.risk.cash_parking_trend_gate: prefix = park_mode c = macro.get(f"{prefix}_close") sma = macro.get(f"{prefix}_sma_{gate_p}") if c and sma and c < sma: return "sgov" return park_mode def _compute_parking_risk_score(self, macro: dict) -> int: """Compute composite risk score from multiple independent signals. Returns 0 (safest) to 100 (most dangerous). Signals: VIX level, VIX velocity, HY credit spread, realized vol, momentum. """ score = 0 # 1. VIX level (forward-looking implied vol — fastest fear signal) vix = macro.get("VIXCLS") if vix is not None: if vix > 30: score += 45 elif vix > 25: score += 35 elif vix > 20: score += 15 elif vix > 17: score += 5 # 2. VIX velocity (rapid rise = panic incoming) vix_chg_5 = macro.get("vix_change_5d") if vix_chg_5 is not None: if vix_chg_5 > 8: score += 25 elif vix_chg_5 > 5: score += 15 elif vix_chg_5 > 3: score += 8 # 3. HY credit spread (independent bond market stress) hy = macro.get("BAMLH0A0HYM2") if hy is not None: if hy > 6.0: score += 30 elif hy > 5.0: score += 20 elif hy > 4.0: score += 8 # 4. QQQ realized vol vol = macro.get("qqq_vol_20") if vol is not None: if vol > 0.30: score += 20 elif vol > 0.24: score += 10 elif vol > 0.20: score += 3 # 5. QQQ momentum (trend direction) mom = macro.get("qqq_mom_20") if mom is not None: if mom < -0.05: score += 15 elif mom < -0.02: score += 10 elif mom < 0: score += 5 # 6. Defensive ETF + QQQ dual weakness (broad market confirmation) defensive_mom = macro.get(f"{self._get_parking_defensive_prefix()}_mom_20") if defensive_mom is not None and mom is not None: if defensive_mom < 0 and mom < 0: score += 8 # 7. VRP — Volatility Risk Premium (Carr & Wu, 2009) if vix is not None and vol is not None: vrp = vix - (vol * 100) if vrp > 12: score += 20 elif vrp > 8: score += 10 elif vrp > 5: score += 5 # 8. Market Temperature — vol acceleration (vol_15 / vol_50) vol_short = macro.get("qqq_vol_15") vol_long = macro.get("qqq_vol_50") if vol_short is not None and vol_long is not None and vol_long > 0: temp = vol_short / vol_long if temp > 1.5: score += 25 elif temp > 1.3: score += 15 elif temp > 1.1: score += 5 # 9. Hurst exponent — fractal dimension (Mandelbrot) hurst = macro.get("qqq_hurst_60") if hurst is not None: if hurst < 0.40: score += 15 elif hurst < 0.45: score += 8 # 10. Rolling kurtosis — fat tail detection (Taleb) kurt = macro.get("qqq_kurtosis_20") if kurt is not None: if kurt > 4.0: score += 15 elif kurt > 3.0: score += 8 # 11. Return autocorrelation — momentum quality (Lo, 2004) autocorr = macro.get("qqq_autocorr_20") if autocorr is not None: if autocorr < -0.2: score += 12 elif autocorr < -0.1: score += 6 # 12. Defensive-QQQ decorrelation — regime shift corr = macro.get(self._get_parking_defensive_corr_key()) if corr is not None: if corr < 0.75: score += 15 elif corr < 0.80: score += 8 return min(score, 100) def _get_parking_value(self, date: dt.date) -> float: """Current market value of all parked positions.""" val = 0.0 if self._parking_current_symbol == "tqqq_blend": macro = self.store.get_macro_for_date(date) or {} if self._parking_blend_tqqq_shares > 0: tqqq_p = macro.get("tqqq_close", self._parking_blend_tqqq_avg_price) val += self._parking_blend_tqqq_shares * tqqq_p if self._parking_blend_qqqm_shares > 0: qqqm_p = macro.get("qqqm_close", self._parking_blend_qqqm_avg_price) val += self._parking_blend_qqqm_shares * qqqm_p elif self._parking_shares > 0: macro = self.store.get_macro_for_date(date) or {} p = macro.get(f"{self._parking_current_symbol}_close", self._parking_avg_price) val += self._parking_shares * p val += self._mark_parallel_sgov_to_market(date) return val def _build_portfolio_state( self, date: dt.date, drawdown_pct: float, unrealized: float, ) -> DailyPortfolioState: gross_exposure, net_exposure = self._compute_portfolio_exposure(date) # Idle capital decomposition: separate IA-sleeve vs primary notional ia_exposure = 0.0 for pos in self._open_positions: if self._is_post_allocation_idle_engine_id(pos.plan.engine_id): close = self._resolve_close_price( pos.plan.candidate.symbol, date, pos.entry_price, ) ia_exposure += close * pos.shares_open parking_val = self._get_parking_value(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, raw_cash=self._cash, parking_value=parking_val, idle_alpha_exposure=ia_exposure, primary_exposure=max(0.0, gross_exposure - ia_exposure), ) def _liquidate_parking(self, date: dt.date) -> bool: """Sell parking position to free cash for event entries. Returns True if cash was freed.""" freed = False macro = self.store.get_macro_for_date(date) or {} self._mark_parallel_sgov_to_market(date, macro) # Blend mode: liquidate both TQQQ and QQQM legs if self._parking_current_symbol == "tqqq_blend": for sym, shares, avg_price in [ ("tqqq", self._parking_blend_tqqq_shares, self._parking_blend_tqqq_avg_price), ("qqqm", self._parking_blend_qqqm_shares, self._parking_blend_qqqm_avg_price), ]: if shares > 0: close_price = macro.get(f"{sym}_close") if close_price and close_price > 0: exit_price = close_price proceeds = shares * close_price else: exit_price = avg_price proceeds = shares * avg_price parking_pnl = proceeds - (shares * avg_price) self._cash += proceeds self._realized_pnl += parking_pnl self._record_parking_trade(date, sym, shares, avg_price, exit_price) self._parking_blend_tqqq_shares = 0 self._parking_blend_tqqq_avg_price = 0.0 self._parking_blend_qqqm_shares = 0 self._parking_blend_qqqm_avg_price = 0.0 self._parking_current_symbol = "" self._parking_sold_today = True self._parking_overlay_hold_days = 0 freed = True elif self._parking_shares > 0: entry_price_for_record = self._parking_avg_price park_close = macro.get(f"{self._parking_current_symbol}_close") if park_close and park_close > 0: exit_price = park_close proceeds = self._parking_shares * park_close parking_pnl = proceeds - (self._parking_shares * self._parking_avg_price) self._cash += proceeds self._realized_pnl += parking_pnl else: exit_price = self._parking_avg_price self._cash += self._parking_shares * self._parking_avg_price # Record parking trade self._record_parking_trade( date, self._parking_current_symbol, self._parking_shares, entry_price_for_record, exit_price, ) sold_sym = self._parking_current_symbol self._parking_shares = 0 self._parking_avg_price = 0.0 self._parking_current_symbol = "" self._parking_overlay_hold_days = 0 # Mark sold to prevent same-day re-buy (day trade rule) if sold_sym: self._parking_sold_today = True freed = True if self._parking_sgov_value > 0: self._realized_pnl += self._parking_sgov_value - self._parking_sgov_entry_value self._cash += self._parking_sgov_value self._reset_parallel_sgov_state() freed = True if self._parking_shares <= 0 and self._parking_sgov_value <= 0 and self._parking_current_symbol != "tqqq_blend": self._parking_entry_date = None self._parking_peak_price = 0.0 return freed def _liquidate_parking_for_cash(self, date: dt.date, required_cash: float) -> bool: """Free only the cash shortfall from parking instead of liquidating the whole sleeve.""" remaining_needed = max(0.0, required_cash) if remaining_needed <= 0: return False freed = False if self._parking_sgov_value > 0 and remaining_needed > 0: self._mark_parallel_sgov_to_market(date) starting_value = self._parking_sgov_value released = min(starting_value, remaining_needed) basis_released = ( self._parking_sgov_entry_value * (released / starting_value) if starting_value > 0 else 0.0 ) self._parking_sgov_value -= released self._parking_sgov_entry_value = max( 0.0, self._parking_sgov_entry_value - basis_released, ) self._cash += released self._realized_pnl += released - basis_released remaining_needed -= released freed = released > 0 if self._parking_sgov_value < 1e-9: self._reset_parallel_sgov_state() # Blend mode: liquidate entire blend position if needed if self._parking_current_symbol == "tqqq_blend" and remaining_needed > 0: blend_freed = self._liquidate_parking(date) if blend_freed: self._parking_freed_for_cash_today = True freed = True return freed if self._parking_shares <= 0 or remaining_needed <= 0: if self._parking_shares <= 0 and self._parking_sgov_value <= 0: self._parking_entry_date = None self._parking_peak_price = 0.0 if freed: self._parking_freed_for_cash_today = True return freed macro = self.store.get_macro_for_date(date) or {} symbol = self._parking_current_symbol park_close = macro.get(f"{symbol}_close") if not park_close or park_close <= 0: park_close = self._parking_avg_price if park_close <= 0: return freed shares_to_sell = min( self._parking_shares, max(1, math.ceil(remaining_needed / park_close)), ) proceeds = shares_to_sell * park_close parking_pnl = proceeds - (shares_to_sell * self._parking_avg_price) self._cash += proceeds self._realized_pnl += parking_pnl self._record_parking_trade( date, symbol, shares_to_sell, self._parking_avg_price, park_close, ) self._parking_shares -= shares_to_sell if self._parking_shares <= 0: self._parking_shares = 0 self._parking_avg_price = 0.0 self._parking_current_symbol = "" if self._parking_sgov_value <= 0: self._parking_entry_date = None self._parking_peak_price = 0.0 self._parking_sold_today = True self._parking_freed_for_cash_today = True return True def _record_parking_trade( self, exit_date: dt.date, symbol: str, shares: int, entry_price: float, exit_price: float, ) -> None: """Record a parking round-trip as a FilledTrade.""" if shares <= 0 or not symbol: return entry_date = self._parking_entry_date or exit_date entry_px = entry_price exit_px = exit_price shares_count = shares gross_pnl = (exit_px - entry_px) * shares_count net_pnl = gross_pnl pnl_pct = (exit_px - entry_px) / entry_px if entry_px > 0 else 0 self._parking_trade_counter += 1 trade = FilledTrade( trade_id=f"park-{self._parking_trade_counter}", position_id=f"park-{self._parking_trade_counter}", event_id="cash_parking", symbol=symbol.upper(), source_symbol=None, event_type="cash_parking", score=0.0, engine_id="cash_parking", trade_symbol_mode="event", entry_date=entry_date, exit_date=exit_date, entry_price=round(entry_px, 2), exit_price=round(exit_px, 2), exit_reason=ExitReason.PARKING, shares=shares_count, commission=0.0, slippage_bps=0.0, gross_pnl=round(gross_pnl, 2), net_pnl=round(net_pnl, 2), pnl_pct=round(pnl_pct, 4), r_multiple=0.0, holding_days=(exit_date - entry_date).days, ) self._closed_trades.append(trade) 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).""" exit_reason = ( ExitReason.END_OF_BACKTEST if reason == "end_of_backtest" else ExitReason.KILL_SWITCH ) for pos in list(self._open_positions): exit_date = date bar = self.store.get_bar(pos.plan.candidate.symbol, exit_date) if bar is None: latest_bar = self.store.get_latest_bar_on_or_before( pos.plan.candidate.symbol, exit_date, ) if latest_bar is not None: exit_date, bar = latest_bar trade = simulate_kill_switch_exit( pos, bar, exit_date, self.config.execution, exit_reason=exit_reason, ) 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_request_id = config.requested_snapshot_id or config.dataset_snapshot_id snapshot_dir = resolve_snapshot_path( snapshot_request_id, snapshot_dir=snapshot_dir_override, ) if snapshot_dir is None: raise FileNotFoundError( f"Snapshot directory not found for requested snapshot '{snapshot_request_id}' " f"(canonical '{config.canonical_snapshot_id or config.dataset_snapshot_id}')" ) scoring_fn = _resolve_scoring_fn(config) 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 _resolve_scoring_fn(config: BacktestConfig) -> Any | None: scoring_model = config.signal.scoring_model if scoring_model == "pead": from functools import partial from libs.backtest.scoring import compute_pead_score return partial( compute_pead_score, reaction_threshold=config.signal.pead_reaction_threshold, volume_threshold=config.signal.pead_volume_threshold, ) if scoring_model.startswith("return_max_long_"): from libs.backtest import scoring as scoring_mod suffix = scoring_model.removeprefix("return_max_long_") fn_name = "compute_return_max_long_score" if suffix == "v1" else f"compute_return_max_long_score_{suffix}" return getattr(scoring_mod, fn_name) if scoring_model.startswith("return_max_short_"): from libs.backtest import scoring as scoring_mod suffix = scoring_model.removeprefix("return_max_short_") return getattr(scoring_mod, f"compute_return_max_short_score_{suffix}") if scoring_model.startswith("return_max_longshort_"): from libs.backtest import scoring as scoring_mod suffix = scoring_model.removeprefix("return_max_longshort_") return getattr(scoring_mod, f"compute_return_max_longshort_{suffix}") if scoring_model == "oversold_bounce": from libs.backtest.scoring import compute_oversold_bounce_score return compute_oversold_bounce_score if scoring_model == "patient_drift": from libs.backtest.scoring import compute_patient_drift_score return compute_patient_drift_score if scoring_model == "microstructure": from libs.backtest.scoring import compute_microstructure_score return compute_microstructure_score return None 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: from libs.common.config import get_settings s = get_settings() snapshot_request_id = config.requested_snapshot_id or config.dataset_snapshot_id snapshot_dir = resolve_snapshot_path( snapshot_request_id, snapshot_dir=snapshot_dir_override, ) if snapshot_dir is None: raise FileNotFoundError( f"Snapshot directory not found for requested snapshot '{snapshot_request_id}' " f"(canonical '{config.canonical_snapshot_id or config.dataset_snapshot_id}')" ) return SnapshotStore.load_merged( snapshot_dir=snapshot_dir, split_names=["train", "valid", "test"], oracle_url=s.stock_oracle_url, db_dsn=s.postgres_dsn, scoring_fn=_resolve_scoring_fn(config), ) def _last_market_closed_date() -> dt.date: from libs.common.time_utils import is_trading_day, to_eastern, utc_now now_et = to_eastern(utc_now()) if not is_trading_day(now_et.date()) or now_et.hour >= 16: return now_et.date() return now_et.date() - dt.timedelta(days=1) def _compute_max_effective_mhd(config: BacktestConfig) -> int: """Return the widest max_holding_days value across all engines and event profiles.""" mhd = config.execution.max_holding_days if config.execution.dynamic_hold_enabled: mhd = max(mhd, config.execution.dynamic_hold_extend_to) for engine in config.get_strategy_engines(): if engine.max_holding_days is not None: engine_mhd = engine.max_holding_days if engine.dynamic_hold_extend_to_override is not None: engine_mhd = max(engine_mhd, engine.dynamic_hold_extend_to_override) mhd = max(mhd, engine_mhd) for profile in (config.event_type_profiles or {}).values(): if profile.max_holding_days_override is not None: mhd = max(mhd, profile.max_holding_days_override) return mhd def _extend_store_to_requested_window( *, store: SnapshotStore, config: BacktestConfig, start_date: dt.date, end_date: dt.date, snapshot_dir_override: str | None = None, ) -> SnapshotStore: """Extend macro/bars to requested window so parking-only runs honor the full range.""" import asyncio as _aio import pickle from libs.common.config import get_settings if start_date > end_date: return store setattr(store, "_requested_start_date", start_date) setattr(store, "_requested_end_date", end_date) settings = get_settings() snapshot_request_id = config.requested_snapshot_id or config.dataset_snapshot_id snapshot_path = resolve_snapshot_path( snapshot_request_id, snapshot_dir=snapshot_dir_override, ) extend_end = min(end_date, _last_market_closed_date()) def _merge_macro_dict(extra_macro: dict[dt.date, dict[str, Any]]) -> int: added = 0 for macro_date, values in extra_macro.items(): existing = store._macro.get(macro_date) if existing is None: store._macro[macro_date] = dict(values) added += 1 else: existing.update(values) return added # Backfill/extend macro window. Parking-only runs depend on macro dates # to create the full trading-day calendar even when there are no events. macro_cache_file = ( snapshot_path / f"macro_window_{start_date.isoformat()}_{extend_end.isoformat()}.pkl" if snapshot_path is not None else None ) macro_loaded_from_cache = False if macro_cache_file and macro_cache_file.exists(): try: cached_macro = pickle.loads(macro_cache_file.read_bytes()) if isinstance(cached_macro, dict): added = _merge_macro_dict(cached_macro) logger.info( "backtest_macro_window_loaded_from_cache", file=str(macro_cache_file), added_days=added, ) macro_loaded_from_cache = True except Exception as exc: logger.warning( "backtest_macro_window_cache_failed", file=str(macro_cache_file), error=str(exc), ) if not macro_loaded_from_cache: existing_macro_dates = sorted(store._macro.keys()) missing_ranges: list[tuple[dt.date, dt.date]] = [] if not existing_macro_dates: missing_ranges.append((start_date, extend_end)) else: min_macro = existing_macro_dates[0] max_macro = existing_macro_dates[-1] if start_date < min_macro: missing_ranges.append((start_date, min_macro - dt.timedelta(days=1))) if max_macro < extend_end: missing_ranges.append((max_macro + dt.timedelta(days=1), extend_end)) cached_macro_payload: dict[dt.date, dict[str, Any]] = {} total_added = 0 for range_start, range_end in missing_ranges: if range_start > range_end: continue try: fred_macro = _aio.run( SnapshotStore._fetch_macro((range_start, range_end), settings.postgres_dsn) ) except Exception as exc: logger.warning( "backtest_macro_fetch_failed", range_start=range_start.isoformat(), range_end=range_end.isoformat(), error=str(exc), ) fred_macro = {} try: price_macro = _aio.run( SnapshotStore._fetch_spy_macro((range_start, range_end), settings.stock_oracle_url) ) except Exception as exc: logger.warning( "backtest_spy_macro_fetch_failed", range_start=range_start.isoformat(), range_end=range_end.isoformat(), error=str(exc), ) price_macro = {} merged_segment: dict[dt.date, dict[str, Any]] = {} for macro_date, values in fred_macro.items(): merged_segment.setdefault(macro_date, {}).update(values) for macro_date, values in price_macro.items(): merged_segment.setdefault(macro_date, {}).update(values) total_added += _merge_macro_dict(merged_segment) for macro_date, values in merged_segment.items(): cached_macro_payload.setdefault(macro_date, {}).update(values) if total_added: logger.info( "backtest_macro_window_extended", added_days=total_added, start=min(store._macro).isoformat() if store._macro else None, end=max(store._macro).isoformat() if store._macro else None, ) if macro_cache_file and cached_macro_payload: try: macro_cache_file.parent.mkdir(parents=True, exist_ok=True) macro_cache_file.write_bytes( pickle.dumps(cached_macro_payload, protocol=pickle.HIGHEST_PROTOCOL) ) logger.info( "backtest_macro_window_cached", file=str(macro_cache_file), ) except Exception as exc: logger.warning( "backtest_macro_window_cache_write_failed", file=str(macro_cache_file), error=str(exc), ) # Extend individual stock bars only forward. Needed so open positions and # event entries can still be valued when end_date exceeds snapshot coverage. if store._bars: cache_file = ( snapshot_path / f"bars_extended_{extend_end.isoformat()}.pkl" if snapshot_path is not None else None ) cached = False if cache_file and cache_file.exists(): try: cached_bars = pickle.loads(cache_file.read_bytes()) added = 0 for sym, date_bars in cached_bars.items(): existing = store._bars.setdefault(sym, {}) for bar_date, bar in date_bars.items(): if bar_date not in existing: existing[bar_date] = bar added += 1 if added: logger.info( "backtest_bars_loaded_from_cache", file=str(cache_file), added_bars=added, ) cached = True except Exception: cached = False if not cached: symbols_to_extend: list[tuple[str, dt.date]] = [] for sym, sym_bars in store._bars.items(): if not sym_bars: continue max_bar_date = max(sym_bars.keys()) if max_bar_date < extend_end: symbols_to_extend.append((sym, max_bar_date)) if symbols_to_extend: fetch_start = min(max_bar_date for _, max_bar_date in symbols_to_extend) total = len(symbols_to_extend) logger.info( "backtest_bars_extending", symbols=total, fetch_range=f"{fetch_start}→{extend_end}", ) batch_size = 50 all_new_bars: dict[str, dict[dt.date, dict[str, Any]]] = {} for batch_idx in range(0, total, batch_size): batch = symbols_to_extend[batch_idx:batch_idx + batch_size] batch_num = batch_idx // batch_size + 1 total_batches = (total + batch_size - 1) // batch_size logger.info( "backtest_bars_batch", batch=f"{batch_num}/{total_batches}", symbols=len(batch), ) try: result = _aio.run( SnapshotStore._fetch_price_data( [sym for sym, _ in batch], (fetch_start, extend_end), settings.stock_oracle_url, concurrency=8, ) ) except Exception as exc: logger.warning( "backtest_bars_batch_failed", batch=batch_num, error=str(exc), ) continue for sym, new_bars in result[0].items(): all_new_bars.setdefault(sym, {}).update(new_bars) added_count = 0 new_bars_only: dict[str, dict[dt.date, dict[str, Any]]] = {} for sym, date_bars in all_new_bars.items(): existing = store._bars.setdefault(sym, {}) existing_max = max(existing.keys()) if existing else None for bar_date, bar in date_bars.items(): if existing_max is None or bar_date > existing_max: existing[bar_date] = bar new_bars_only.setdefault(sym, {})[bar_date] = bar added_count += 1 if added_count: logger.info( "backtest_bars_extended", symbols=len(symbols_to_extend), added_bars=added_count, ) if cache_file and new_bars_only: try: cache_file.parent.mkdir(parents=True, exist_ok=True) cache_file.write_bytes( pickle.dumps(new_bars_only, protocol=pickle.HIGHEST_PROTOCOL) ) logger.info("backtest_bars_cached", file=str(cache_file)) except Exception as exc: logger.warning("backtest_bars_cache_failed", error=str(exc)) return store 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, start_date: dt.date | None = None, end_date: dt.date | 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) if start_date is not None or end_date is not None: 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.") start_d = start_date or all_dates[0] end_d = end_date or all_dates[-1] merged_store = merged_store.slice_by_date_range(start_d, end_d) 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, start_date: dt.date | None = None, end_date: dt.date | None = None, ) -> RobustnessMatrixSummary: """Run rolling horizon robustness validation over multiple start dates.""" merged_store = _build_merged_snapshot_store(manifest, config, snapshot_dir_override) if start_date is not None or end_date is not None: 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.") start_d = start_date or all_dates[0] end_d = end_date or all_dates[-1] merged_store = merged_store.slice_by_date_range(start_d, end_d) 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/all). 'all' merges all splits for full-period backtest.") 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)") parser.add_argument("--start", default=None, help="Start date filter YYYY-MM-DD (inclusive)") parser.add_argument("--end", default=None, help="End date filter YYYY-MM-DD (inclusive)") parser.add_argument("--parking", default=None, help="Cash parking preset (e.g. qqqm_low_dd)") parser.add_argument("--idle-alpha", default=None, help="Idle alpha sleeve preset (e.g. micro_event_alpha)") parser.add_argument("--idle-alpha-dedup", default=None, choices=["skip", "rename"], help="IA dedup mode: 'skip' (default) skips conflicting IA engines; 'rename' adds __ia_sleeve suffix and injects anyway") parser.add_argument("--dividend-sleeve", default=None, help="Dividend capture sleeve preset name") parser.add_argument("--form4-sleeve", default=None, help="Form 4 capture sleeve preset (e.g. reserve_form4_cluster)") parser.add_argument("--ownership-sleeve", default=None, help="Ownership 13D/13G sleeve preset (e.g. ownership_13d_raise_reserve_plus_strict)") parser.add_argument("--risk-off-sleeve", default=None, help="Risk-off alpha sleeve preset (e.g. risk_off_alpha_gld_crisis60)") parser.add_argument("--non-core-allocator-v2", action="store_true", help="Enable non-core allocator v2") parser.add_argument("--non-core-allocator-v2-mode", choices=["shadow", "live"], default=None, help="Non-core allocator v2 mode") args = parser.parse_args() manifest = load_manifest(args.manifest) config = resolve_config(manifest, config_root=args.config_root, snapshot_id_override=args.snapshot_id) # Auto-refresh snapshot if stale (covers CLI and web subprocess paths). # Uses _last_market_closed_date() as the reference so the snapshot updates # once market closes today (same criteria as price bar extension). import asyncio as _asyncio from apps.paper_trader.backtest_sim import _refresh_snapshot, _snapshot_needs_refresh _snap_id = config.canonical_snapshot_id or config.dataset_snapshot_id if _snapshot_needs_refresh(_snap_id, _last_market_closed_date()): _asyncio.run(_refresh_snapshot(_snap_id, universe_profile=None)) if args.mode: config.risk.backtest_mode = args.mode if args.parking: config.risk.cash_parking_preset = args.parking config.risk.apply_parking_preset() if args.idle_alpha: if args.idle_alpha_dedup: config.idle_alpha_dedup_mode = args.idle_alpha_dedup config.idle_alpha_sleeve_preset = args.idle_alpha config.apply_idle_alpha_sleeve_preset() if args.dividend_sleeve: config.dividend_capture_sleeve_preset = args.dividend_sleeve config.apply_dividend_capture_sleeve_preset() if args.form4_sleeve: config.form4_capture_sleeve_preset = args.form4_sleeve config.apply_form4_capture_sleeve_preset() if args.ownership_sleeve: config.ownership_capture_sleeve_preset = args.ownership_sleeve config.apply_ownership_capture_sleeve_preset() if args.risk_off_sleeve: config.risk_off_alpha_sleeve_preset = args.risk_off_sleeve config.apply_risk_off_alpha_sleeve_preset() if args.non_core_allocator_v2 or args.non_core_allocator_v2_mode: config.non_core_allocator_v2.enabled = True if args.non_core_allocator_v2_mode: config.non_core_allocator_v2.mode = args.non_core_allocator_v2_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, start_date=dt.date.fromisoformat(args.start) if args.start else None, end_date=dt.date.fromisoformat(args.end) if args.end else None, ) 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, start_date=dt.date.fromisoformat(args.start) if args.start else None, end_date=dt.date.fromisoformat(args.end) if args.end else None, ) else: if args.split == "all": store = _build_merged_snapshot_store(manifest, config, snapshot_dir_override=args.snapshot_dir) else: store = _build_store(manifest, config, args.split, snapshot_dir_override=args.snapshot_dir) if args.start or args.end: all_store_dates = store.all_trading_days(include_reaction_dates=True) if not all_store_dates: raise RuntimeError("No trading days found in snapshot store.") start_d = dt.date.fromisoformat(args.start) if args.start else all_store_dates[0] end_d = dt.date.fromisoformat(args.end) if args.end else all_store_dates[-1] if config.execution.lookback_entry_enabled and args.start: # Extend slice start backward so pre-start events remain in the store # for _collect_lookback_candidates; simulation dates are still gated by # _requested_start_date set below, so those rows stay dormant otherwise. max_mhd = _compute_max_effective_mhd(config) lookback_start = start_d - dt.timedelta(days=max_mhd * 2) store = store.slice_by_date_range(lookback_start, end_d) else: store = store.slice_by_date_range(start_d, end_d) store = _extend_store_to_requested_window( store=store, config=config, start_date=start_d, end_date=end_d, 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())})") # Print sleeve decomposition if available sleeve_path_str = result.artifact_paths.get("sleeve_decomposition") sleeve_path = Path(sleeve_path_str) if sleeve_path_str else None if sleeve_path and sleeve_path.exists(): import json as _json sd = _json.loads(sleeve_path.read_text()) core_pct = sd.get("core", {}).get("contribution_pct", 0) ia_pct = sd.get("idle_alpha", {}).get("contribution_pct", 0) park_pct = sd.get("parking", {}).get("contribution_pct", 0) amp = sd.get("composite_amplification") idle = sd.get("avg_idle_fraction_pct") amp_str = f" amp: {amp:.2f}x" if amp is not None else "" idle_str = f" idle: {idle:.1f}%" if idle is not None else "" print( f"Sleeve: Core {core_pct:.1f}% | IA {ia_pct:.1f}% | Parking {park_pct:.1f}%" f"{amp_str}{idle_str}" ) if __name__ == "__main__": main()