"""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()
