"""PaperTradingEngine: daily processing loop for paper trading.

Strategy decisions (WHAT to buy/sell) are made locally using backtest logic.
Order execution (HOW to execute) is done via Alpaca Paper Trading API.
"""
from __future__ import annotations

import datetime as dt
import json
import math
import time
from dataclasses import dataclass, field
from typing import Any

from libs.backtest.allocator import build_planned_order
from libs.backtest.domain import (
    BacktestConfig,
    Candidate,
    DailyPortfolioState,
    ExecutionConfig,
    OpenPosition,
    PlannedOrder,
    PositionStatus,
)
from libs.backtest.execution import simulate_exit, simulate_scheduled_open_exit, update_trailing_stop
from libs.backtest.manifests import load_manifest, resolve_config
from libs.backtest.selector import select_candidates
from libs.common.logging import get_logger

from apps.paper_trader.alpaca_broker import AlpacaBroker, AccountInfo, Order, Position
from apps.paper_trader.event_detector import EventDetector
from apps.paper_trader.state import (
    DailySnapshotRow,
    SessionRow,
    StateManager,
    StrategyStateRow,
)

logger = get_logger(__name__)

# Kill-switch threshold (matches backtest)
_KILL_SWITCH_DRAWDOWN_PCT = 25.0


@dataclass
class ReconciliationReport:
    """Result of comparing Alpaca positions vs local strategy states."""
    orphaned_alpaca: list[str] = field(default_factory=list)  # on Alpaca but not local
    ghost_local: list[str] = field(default_factory=list)      # in local but not on Alpaca
    reconciled_exits: list[str] = field(default_factory=list)  # ghost positions auto-closed
    stale_orders_cancelled: list[str] = field(default_factory=list)

    @property
    def has_issues(self) -> bool:
        return bool(self.orphaned_alpaca or self.ghost_local)


def _is_reaction_close_entry(candidate_json: str) -> bool:
    """Return True if the position was entered at the reaction-day CLOSE (MOC order).

    Same-day events (timing_class == "same_day") enter via MOC; after-close events
    enter at the next open. This distinction matters for exit checking: MOC entries
    must not have their stop checked against the entry bar's intraday low/high,
    because the position did not exist during that intraday period.
    """
    try:
        import json
        cand = json.loads(candidate_json)
        return cand.get("timing_class") == "same_day"
    except Exception:
        return False


class PaperTradingEngine:
    """Daily processing loop. Mirrors BacktestRunner._simulate_day() for live use."""

    def __init__(
        self,
        session: SessionRow,
        broker: AlpacaBroker,
        state: StateManager,
        event_detector: EventDetector,
        snapshot_store: "SnapshotStore | None" = None,
    ) -> None:
        self._session = session
        self._broker = broker
        self._state = state
        self._detector = event_detector
        self._snapshot_store = snapshot_store

        manifest = load_manifest(session.config_path)
        self._config: BacktestConfig = resolve_config(manifest)

        # Apply parking preset override stored at session creation time
        if session.parking_preset:
            self._config.risk.cash_parking_preset = session.parking_preset
            self._config.risk.apply_parking_preset()
        if session.idle_alpha_preset:
            self._config.idle_alpha_sleeve_preset = session.idle_alpha_preset
            self._config.apply_idle_alpha_sleeve_preset()
        if session.form4_sleeve_preset:
            self._config.form4_capture_sleeve_preset = session.form4_sleeve_preset
            self._config.apply_form4_capture_sleeve_preset()
        if session.ownership_sleeve_preset:
            self._config.ownership_capture_sleeve_preset = session.ownership_sleeve_preset
            self._config.apply_ownership_capture_sleeve_preset()
        if getattr(session, "risk_off_alpha_sleeve_preset", None):
            self._config.risk_off_alpha_sleeve_preset = session.risk_off_alpha_sleeve_preset
            self._config.apply_risk_off_alpha_sleeve_preset()

        # Shared attention filtering service (matches BacktestRunner)
        from libs.backtest.attention import AttentionFilterService
        oracle_url = event_detector._oracle_url if hasattr(event_detector, '_oracle_url') else ""
        self._attention_service = AttentionFilterService(
            oracle_url=oracle_url,
            scoring_model=self._config.signal.scoring_model,
        )
        self._capital_bucket_specs: dict[str, float] = {}
        get_strategy_engines = getattr(self._config, "get_strategy_engines", None)
        strategy_engines = get_strategy_engines() if callable(get_strategy_engines) else []
        for engine in strategy_engines or []:
            bucket_id = getattr(engine, "capital_bucket_id", None)
            allocation = getattr(engine, "capital_bucket_allocation_pct", None)
            if not bucket_id or allocation is None or allocation <= 0:
                continue
            self._capital_bucket_specs[bucket_id] = max(
                self._capital_bucket_specs.get(bucket_id, 0.0),
                float(allocation),
            )
        # Lookback entry: fired once per daemon session on the first run_next_open call
        self._lookback_injected: bool = False
        # Overlay shock brake cooldown (in-memory, session-scoped)
        self._parking_brake_cooldown_remaining: int = 0
        self._parking_brake_skip_buy_today: bool = False  # skip same-day re-buy after brake fires

    def _get_candidate_capital_bucket_id(self, candidate: Candidate) -> str | None:
        return candidate.engine_capital_bucket_id

    def _get_strategy_state_capital_bucket_id(self, state: StrategyStateRow) -> str | None:
        try:
            payload = json.loads(state.candidate_json)
        except Exception:
            return None
        bucket_id = payload.get("engine_capital_bucket_id") or payload.get("capital_bucket_id")
        if not bucket_id:
            return None
        return str(bucket_id)

    def _active_capital_bucket_ids_for_candidates(
        self,
        candidates: list[Candidate],
        strategy_states: dict[str, StrategyStateRow],
    ) -> 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 state in strategy_states.values():
            bucket_id = self._get_strategy_state_capital_bucket_id(state)
            if bucket_id:
                active_bucket_ids.add(bucket_id)
        return active_bucket_ids

    def _capital_bucket_notional(
        self,
        bucket_id: str,
        alpaca_positions: list[Position],
        strategy_states: dict[str, StrategyStateRow],
    ) -> float:
        notional = 0.0
        for position in alpaca_positions:
            state = strategy_states.get(position.symbol)
            if state is None or self._get_strategy_state_capital_bucket_id(state) != bucket_id:
                continue
            notional += abs(float(position.market_value))
        return notional

    def _capital_bucket_entry_cost(
        self,
        bucket_id: str,
        alpaca_positions: list[Position],
        strategy_states: dict[str, StrategyStateRow],
    ) -> float:
        entry_cost = 0.0
        for position in alpaca_positions:
            state = strategy_states.get(position.symbol)
            if state is None or self._get_strategy_state_capital_bucket_id(state) != bucket_id:
                continue
            entry_cost += abs(float(position.avg_entry_price) * float(position.qty))
        return entry_cost

    def _capital_bucket_realized_pnl(self, session_id: str, bucket_id: str) -> float:
        realized = 0.0
        for trade in self._state.list_trades(session_id):
            if trade.get("capital_bucket_id") != bucket_id:
                continue
            realized += float(trade.get("net_pnl") or 0.0)
        return realized

    def _capital_bucket_equity(
        self,
        bucket_id: str,
        session_id: str,
        alpaca_positions: list[Position],
        strategy_states: dict[str, StrategyStateRow],
    ) -> float:
        allocation = self._capital_bucket_specs.get(bucket_id)
        if allocation is None:
            return 0.0
        initial_bucket_equity = self._session.initial_equity * allocation
        market_value = self._capital_bucket_notional(bucket_id, alpaca_positions, strategy_states)
        entry_cost = self._capital_bucket_entry_cost(bucket_id, alpaca_positions, strategy_states)
        unrealized = market_value - entry_cost
        return max(
            0.0,
            initial_bucket_equity
            + self._capital_bucket_realized_pnl(session_id, bucket_id)
            + unrealized,
        )

    def _capital_bucket_cash_available(
        self,
        bucket_id: str,
        session_id: str,
        alpaca_positions: list[Position],
        strategy_states: dict[str, StrategyStateRow],
    ) -> float:
        market_value = self._capital_bucket_notional(bucket_id, alpaca_positions, strategy_states)
        return max(
            0.0,
            self._capital_bucket_equity(bucket_id, session_id, alpaca_positions, strategy_states)
            - market_value,
        )

    def _adjust_portfolio_state_for_candidate(
        self,
        *,
        session_id: str,
        candidate: Candidate,
        portfolio_state: DailyPortfolioState,
        active_bucket_ids: set[str],
        alpaca_positions: list[Position],
        strategy_states: dict[str, StrategyStateRow],
    ) -> DailyPortfolioState:
        if not self._capital_bucket_specs or portfolio_state.cash_available <= 0:
            return portfolio_state

        configured_bucket_ids = set(self._capital_bucket_specs)
        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, session_id, alpaca_positions, strategy_states
            )
            for bucket_id in relevant_bucket_ids
        }
        bucket_equity = {
            bucket_id: self._capital_bucket_equity(
                bucket_id, session_id, alpaca_positions, strategy_states
            )
            for bucket_id in relevant_bucket_ids
        }
        sizing_equity = portfolio_state.sizing_equity or portfolio_state.equity
        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,
            }
        )

    # ------------------------------------------------------------------ #
    # Reconciliation & safety
    # ------------------------------------------------------------------ #

    def _cancel_stale_orders(self) -> list[str]:
        """Cancel all open orders. Daily system — any leftover is stale."""
        cancelled: list[str] = []
        try:
            open_orders = self._broker.list_orders("open")
            for order in open_orders:
                try:
                    self._broker.cancel_order(order.id)
                    cancelled.append(f"{order.symbol}:{order.id}")
                    logger.warning(
                        "paper_engine_stale_order_cancelled",
                        symbol=order.symbol, order_id=order.id,
                    )
                except Exception as exc:
                    logger.error(
                        "paper_engine_cancel_failed",
                        order_id=order.id, error=f"{type(exc).__name__}: {exc}",
                    )
        except Exception as exc:
            logger.error("paper_engine_list_orders_failed", error=f"{type(exc).__name__}: {exc}")
        return cancelled

    def _reconcile_positions(
        self,
        alpaca_positions: list[Position],
        strategy_states: dict[str, StrategyStateRow],
        today: dt.date,
    ) -> ReconciliationReport:
        """Compare Alpaca positions vs local state. Fix ghost positions."""
        session_id = self._session.session_id
        report = ReconciliationReport()

        alpaca_symbols = {p.symbol for p in alpaca_positions}
        local_symbols = set(strategy_states.keys())

        # Orphaned: on Alpaca but no local state (e.g. manual buy, or state save failed)
        for sym in sorted(alpaca_symbols - local_symbols):
            report.orphaned_alpaca.append(sym)
            logger.warning(
                "paper_engine_orphaned_position",
                symbol=sym,
                msg="Position on Alpaca but no local strategy state — skipping (manual intervention needed)",
            )

        # Ghost: local state but no Alpaca position (e.g. manually closed, or order never filled)
        for sym in sorted(local_symbols - alpaca_symbols):
            ss = strategy_states[sym]
            report.ghost_local.append(sym)
            logger.warning(
                "paper_engine_ghost_position",
                symbol=sym,
                entry_date=ss.entry_date,
                msg="Local state exists but no Alpaca position — auto-closing",
            )
            # Close local state and record as reconciled
            self._state.close_strategy_state(session_id, sym)
            self._state.record_trade(
                session_id=session_id,
                symbol=sym,
                engine_id=ss.engine_id,
                capital_bucket_id=self._get_strategy_state_capital_bucket_id(ss),
                entry_date=ss.entry_date,
                exit_date=today.isoformat(),
                entry_price=None,
                exit_price=0.0,
                exit_reason="RECONCILED",
                shares=0,
                net_pnl=0.0,
                r_multiple=0.0,
                holding_days=ss.days_held,
            )
            report.reconciled_exits.append(sym)

        if report.has_issues:
            logger.info(
                "paper_engine_reconciliation_summary",
                orphaned=len(report.orphaned_alpaca),
                ghost=len(report.ghost_local),
                reconciled=len(report.reconciled_exits),
            )
        return report

    def _verify_order_fill(self, order_id: str, symbol: str, timeout_sec: float = 15.0) -> Order | None:
        """Poll broker to verify order fill. Returns filled Order or None."""
        deadline = time.monotonic() + timeout_sec
        while time.monotonic() < deadline:
            try:
                order = self._broker.get_order(order_id)
                if order.status == "filled" and order.filled_qty > 0:
                    return order
                if order.status in ("canceled", "expired", "rejected", "cancelled"):
                    logger.warning(
                        "paper_engine_order_rejected",
                        symbol=symbol, order_id=order_id, status=order.status,
                    )
                    return None
            except Exception:
                pass
            time.sleep(0.5)
        # Timeout — market orders almost always fill instantly
        logger.warning(
            "paper_engine_order_fill_timeout",
            symbol=symbol, order_id=order_id, timeout_sec=timeout_sec,
        )
        return None

    def _check_kill_switch(self, drawdown_pct: float, session_st: Any) -> bool:
        """Activate kill switch if drawdown exceeds threshold. Returns True if triggered."""
        if drawdown_pct >= _KILL_SWITCH_DRAWDOWN_PCT and not session_st.kill_switch_triggered:
            session_st.kill_switch_triggered = True
            self._state.update_session_state(session_st)
            logger.critical(
                "paper_engine_kill_switch_triggered",
                drawdown_pct=round(drawdown_pct, 2),
                threshold=_KILL_SWITCH_DRAWDOWN_PCT,
            )
            return True
        return session_st.kill_switch_triggered

    # ------------------------------------------------------------------ #
    # Main entry point
    # ------------------------------------------------------------------ #

    async def run_daily(self, target_date: dt.date | None = None, force: bool = False) -> dict[str, Any]:
        """Process one trading day. Returns a summary dict for the CLI to display."""
        today = target_date or dt.date.today()
        session_id = self._session.session_id

        # 1. Idempotency: skip if already processed (unless forced)
        if not force and self._state.is_date_processed(session_id, today):
            logger.info("paper_engine_already_processed", date=today.isoformat())
            return {"date": today, "status": "already_processed"}

        # 2. Check trading day
        from libs.common.time_utils import is_trading_day
        if not is_trading_day(today):
            logger.info("paper_engine_non_trading_day", date=today.isoformat())
            return {"date": today, "status": "non_trading_day"}

        # 3. Fetch Alpaca state
        account = self._broker.get_account()
        alpaca_positions = self._broker.list_positions()
        held_symbols = [p.symbol for p in alpaca_positions]

        # 4. Load local strategy states
        strategy_states = {
            ss.symbol: ss
            for ss in self._state.get_open_strategy_states(session_id)
        }

        # 4a. Cancel stale open orders
        stale_cancelled = self._cancel_stale_orders()

        # 4b. Reconcile Alpaca vs local state
        recon = self._reconcile_positions(alpaca_positions, strategy_states, today)
        recon.stale_orders_cancelled = stale_cancelled
        # Remove ghost symbols so exit phase doesn't process them
        for sym in recon.ghost_local:
            strategy_states.pop(sym, None)

        # 4c. Kill switch check
        session_st = self._state.get_session_state(session_id)
        if session_st.kill_switch_triggered:
            logger.warning("paper_engine_kill_switch_active", date=today.isoformat())
            self._state.mark_date_processed(session_id, today)
            return {
                "date": today, "status": "kill_switch_active",
                "reconciliation": recon,
            }

        # 5. Fetch price bars for held positions (last 30 days)
        bars_by_symbol: dict[str, dict[dt.date, dict]] = {}
        if held_symbols:
            bar_start = today - dt.timedelta(days=30)
            bars_by_symbol = self._broker.get_bars_as_dict(held_symbols, bar_start, today)

        # ============================================================
        # EXIT PHASE
        # ============================================================
        exits: list[dict[str, Any]] = []
        session_st = self._state.get_session_state(session_id)
        net_pnl_today = 0.0

        for alpaca_pos in alpaca_positions:
            sym = alpaca_pos.symbol
            ss = strategy_states.get(sym)
            if ss is None:
                logger.debug("paper_engine_no_local_state", symbol=sym)
                continue

            ss.days_held += 1

            sym_bars = bars_by_symbol.get(sym, {})
            bar = sym_bars.get(today)
            if bar is None:
                logger.warning("paper_engine_no_bar", symbol=sym, date=today.isoformat())
                self._state.update_strategy_state(
                    session_id, sym, days_held=ss.days_held
                )
                continue

            # Convert to OpenPosition for backtest logic
            open_pos = self._to_open_position(alpaca_pos, ss)

            # Update trailing stop
            effective_exec = self._resolve_execution_config(ss)
            if effective_exec.trailing_model:
                update_trailing_stop(
                    open_pos,
                    bar,
                    trailing_model=effective_exec.trailing_model,
                    warmup_days=effective_exec.trailing_warmup_days,
                )
                ss.current_stop = open_pos.current_stop
                ss.peak_price = open_pos.peak_price

            # Check exit
            filled_trade = simulate_exit(open_pos, bar, effective_exec, today)
            if filled_trade is not None:
                try:
                    close_qty = None
                    if filled_trade.shares < alpaca_pos.qty:
                        close_qty = filled_trade.shares
                    self._broker.close_position(sym, qty=close_qty, fill_price=filled_trade.exit_price)
                    logger.info(
                        "paper_engine_exit",
                        symbol=sym,
                        reason=filled_trade.exit_reason.value,
                        pnl=filled_trade.net_pnl,
                    )
                except Exception as exc:
                    logger.error("paper_engine_close_failed", symbol=sym, error=str(exc))
                    continue

                self._state.close_strategy_state(session_id, sym)
                self._state.record_trade(
                    session_id=session_id,
                    symbol=sym,
                    engine_id=ss.engine_id,
                    capital_bucket_id=self._get_strategy_state_capital_bucket_id(ss),
                    entry_date=ss.entry_date,
                    exit_date=today.isoformat(),
                    entry_price=alpaca_pos.avg_entry_price,
                    exit_price=filled_trade.exit_price,
                    exit_reason=filled_trade.exit_reason.value,
                    shares=filled_trade.shares,
                    net_pnl=filled_trade.net_pnl,
                    r_multiple=filled_trade.r_multiple,
                    holding_days=ss.days_held,
                )
                net_pnl_today += filled_trade.net_pnl

                # Update consecutive losses / cooldown
                if filled_trade.net_pnl < 0:
                    session_st.consecutive_losses += 1
                    streak = self._config.risk.cooldown_after_loss_streak
                    if streak > 0 and session_st.consecutive_losses >= streak:
                        session_st.cooldown_remaining = self._config.risk.cooldown_days
                        session_st.consecutive_losses = 0
                else:
                    session_st.consecutive_losses = 0

                exits.append({
                    "symbol": sym,
                    "reason": filled_trade.exit_reason.value,
                    "pnl": filled_trade.net_pnl,
                    "r_multiple": filled_trade.r_multiple,
                    "shares": filled_trade.shares,
                    "exit_price": filled_trade.exit_price,
                })
            else:
                # No exit — persist updated trailing state
                self._state.update_strategy_state(
                    session_id,
                    sym,
                    days_held=ss.days_held,
                    current_stop=ss.current_stop,
                    peak_price=ss.peak_price,
                )

        # Decrement cooldown
        if session_st.cooldown_remaining > 0:
            session_st.cooldown_remaining -= 1

        # Reset daily risk usage
        session_st.daily_new_risk_used = 0.0

        # ============================================================
        # CASH PARKING: accrue interest, update peak, check gate (before entries)
        # ============================================================
        parking_sold_today = False
        if self._config.risk.cash_parking_enabled:
            parking_st = self._state.get_parking_state(session_id)
            # Reset sold_today flag from yesterday
            if parking_st and parking_st.get("sold_today", 0):
                self._state.update_parking_gate_state(session_id, sold_today=0)
            # Update peak price for trailing stop / top-up
            if parking_st and parking_st["symbol"] != "SGOV":
                sym = parking_st["symbol"]
                bars = self._broker.get_latest_bars([sym])
                if sym in bars:
                    cur_price = bars[sym].close
                    peak = parking_st.get("peak_price", 0) or parking_st["avg_price"]
                    if cur_price > peak:
                        self._state.update_parking_peak(session_id, cur_price)
            # Gate check and sell if signal changed
            parking_sold_today = self._parking_check_and_sell(session_id, today)

        # ============================================================
        # ENTRY PHASE
        # ============================================================
        entries: list[dict[str, Any]] = []
        rejected: list[dict[str, Any]] = []

        # Refresh account/positions after exits
        account = self._broker.get_account()
        alpaca_positions_after_exits = self._broker.list_positions()
        strategy_states_after_exits = {
            ss.symbol: ss
            for ss in self._state.get_open_strategy_states(session_id)
        }

        # Get event candidates for today — reaction_close convention only
        candidate_rows = await self._detector.get_candidates_for_date(
            today, self._config, convention="reaction_close"
        )

        # Filter already-processed events
        n_before = len(candidate_rows)
        candidate_rows = [
            r for r in candidate_rows
            if not self._state.has_processed_event(session_id, str(r.get("event_id", "")))
        ]
        if n_before != len(candidate_rows):
            logger.debug(
                "paper_engine_candidates_after_dedup",
                before=n_before,
                after=len(candidate_rows),
            )

        # Run selection pipeline for each enabled engine
        open_positions = self._to_open_positions(alpaca_positions_after_exits, strategy_states_after_exits)
        portfolio_state = self._build_portfolio_state(account, alpaca_positions_after_exits, today)

        # Fetch macro data
        macro_data = await self._fetch_macro(today)

        engine_daily_risk_used: dict[str, float] = {}
        engines = self._config.get_active_strategy_engines()
        logger.debug(
            "paper_engine_selection_input",
            date=today.isoformat(),
            candidate_rows=len(candidate_rows),
            engines=len(engines),
            symbols=[r.get("symbol") for r in candidate_rows],
        )
        if candidate_rows:
            sample = candidate_rows[0]
            logger.debug(
                "paper_engine_sample_row",
                symbol=sample.get("symbol"),
                event_type=sample.get("event_type"),
                event_direction=sample.get("event_direction"),
                filing_time_bucket=sample.get("filing_time_bucket"),
                entry_price_est=sample.get("entry_price_est"),
                avg_dollar_volume=sample.get("avg_dollar_volume"),
                avg_dollar_volume_20d=sample.get("avg_dollar_volume_20d"),
                event_close=sample.get("event_close"),
                close_location=sample.get("close_location"),
                gap_size=sample.get("gap_size"),
                reaction_day_return=sample.get("reaction_day_return"),
                market_cap_proxy=sample.get("market_cap_proxy"),
                execution_date=str(sample.get("execution_date")),
                event_timestamp=str(sample.get("event_timestamp")),
            )

        if engines:
            # Residual reserve: engines that set residual_reserve_selected=True
            # prevent later engines from picking the same event_id/symbol.
            # Matches BacktestRunner._select_candidates_for_date().
            reserved_event_ids: set[str] = {
                ss.event_id for ss in strategy_states_after_exits.values()
            }
            reserved_symbols: set[str] = {
                p.symbol for p in alpaca_positions_after_exits
                if p.symbol in strategy_states_after_exits
            }
            engine_batches: list[tuple[Any, list[Candidate]]] = []
            for engine_cfg in engines:
                prelimit = self._config.signal.max_candidates_per_day
                if self._attention_service.engine_requires_attention(engine_cfg):
                    prelimit = max(prelimit * 5, prelimit)
                engine_candidates = select_candidates(
                    raw_rows=candidate_rows,
                    universe_config=self._config.universe,
                    signal_config=self._config.signal,
                    event_type_profiles=self._config.event_type_profiles or {},
                    strategy_engine=engine_cfg,
                    truncate_to=prelimit,
                    excluded_event_ids=reserved_event_ids,
                    excluded_symbols=reserved_symbols,
                )
                # Attention filtering (matches BacktestRunner)
                engine_candidates = self._attention_service.apply_filters(
                    engine_candidates, engine_cfg, self._config.signal,
                )
                # Residual reserve for next engine
                if engine_cfg.residual_reserve_selected and engine_candidates:
                    reserved_event_ids.update(c.event_id for c in engine_candidates)
                    reserved_symbols.update(c.symbol.upper() for c in engine_candidates)
                engine_batches.append((engine_cfg, engine_candidates))

            active_bucket_ids = self._active_capital_bucket_ids_for_candidates(
                [
                    candidate
                    for _, batch_candidates in engine_batches
                    for candidate in batch_candidates
                ],
                strategy_states_after_exits,
            )
            for engine_cfg, engine_candidates in engine_batches:
                for candidate in engine_candidates:
                    engine_risk_used = engine_daily_risk_used.get(engine_cfg.engine_id, 0.0)
                    candidate_portfolio_state = self._adjust_portfolio_state_for_candidate(
                        session_id=session_id,
                        candidate=candidate,
                        portfolio_state=portfolio_state,
                        active_bucket_ids=active_bucket_ids,
                        alpaca_positions=alpaca_positions_after_exits,
                        strategy_states=strategy_states_after_exits,
                    )
                    plan = build_planned_order(
                        candidate=candidate,
                        portfolio_state=candidate_portfolio_state,
                        open_positions=open_positions,
                        config=self._config,
                        cooldown_remaining=session_st.cooldown_remaining,
                        macro_data=macro_data,
                        engine_daily_new_risk_used=engine_risk_used,
                    )
                    if plan.skip_reason == "insufficient_cash":
                        # Attempt to free parking cash before giving up
                        needed = plan.shares * float(candidate.entry_price_est) if plan.shares else float(
                            candidate.entry_price_est * 1
                        )
                        if self._parking_liquidate_for_event(session_id, today, needed):
                            account = self._broker.get_account()
                            _ap2 = self._broker.list_positions()
                            alpaca_positions_after_exits = _ap2
                            portfolio_state = self._build_portfolio_state(account, _ap2, today)
                            candidate_portfolio_state = self._adjust_portfolio_state_for_candidate(
                                session_id=session_id,
                                candidate=candidate,
                                portfolio_state=portfolio_state,
                                active_bucket_ids=active_bucket_ids,
                                alpaca_positions=_ap2,
                                strategy_states=strategy_states_after_exits,
                            )
                            plan = build_planned_order(
                                candidate=candidate,
                                portfolio_state=candidate_portfolio_state,
                                open_positions=open_positions,
                                config=self._config,
                                cooldown_remaining=session_st.cooldown_remaining,
                                macro_data=macro_data,
                                engine_daily_new_risk_used=engine_risk_used,
                            )

                    if plan.skip_reason:
                        self._state.record_processed_event(
                            session_id, candidate.event_id, today.isoformat(),
                            "rejected", skip_reason=plan.skip_reason,
                        )
                        rejected.append({
                            "symbol": candidate.symbol,
                            "event_type": candidate.event_type,
                            "score": candidate.score,
                            "reason": plan.skip_reason,
                        })
                        continue

                    # Submit market buy via Alpaca
                    try:
                        order = self._broker.submit_market_buy(candidate.symbol, plan.shares)
                        logger.info(
                            "paper_engine_buy_submitted",
                            symbol=candidate.symbol,
                            qty=plan.shares,
                            order_id=order.id,
                        )
                    except Exception as exc:
                        logger.error(
                            "paper_engine_buy_failed",
                            symbol=candidate.symbol,
                            error=str(exc),
                        )
                        self._state.record_processed_event(
                            session_id, candidate.event_id, today.isoformat(),
                            "rejected", skip_reason=f"order_failed:{exc}",
                        )
                        rejected.append({
                            "symbol": candidate.symbol,
                            "event_type": candidate.event_type,
                            "score": candidate.score,
                            "reason": f"order_failed:{exc}",
                        })
                        continue

                    # Verify fill
                    verified = self._verify_order_fill(order.id, candidate.symbol)
                    if verified is None:
                        self._state.record_processed_event(
                            session_id, candidate.event_id, today.isoformat(),
                            "rejected", skip_reason="order_not_filled",
                        )
                        rejected.append({
                            "symbol": candidate.symbol,
                            "event_type": candidate.event_type,
                            "score": candidate.score,
                            "reason": "order_not_filled",
                        })
                        continue
                    fill_price = verified.filled_avg_price or plan.entry_price_limit

                    # Save local strategy state
                    self._state.save_strategy_state(
                        session_id,
                        StrategyStateRow(
                            session_id=session_id,
                            symbol=candidate.symbol,
                            event_id=candidate.event_id,
                            engine_id=candidate.engine_id,
                            order_id=order.id,
                            entry_date=today.isoformat(),
                            stop_price=plan.stop_price,
                            target_price=plan.target_price,
                            current_stop=plan.stop_price,
                            peak_price=fill_price,
                            days_held=0,
                            trade_direction=candidate.trade_direction,
                            candidate_json=candidate.model_dump_json(),
                            plan_json=plan.model_dump_json(),
                            status="open",
                        ),
                    )
                    self._state.record_processed_event(
                        session_id, candidate.event_id, today.isoformat(), "entered",
                    )

                    trade_risk_state = candidate_portfolio_state.sizing_equity or candidate_portfolio_state.equity
                    trade_risk = trade_risk_state * (
                        candidate.engine_per_trade_risk_pct
                        or self._config.risk.per_trade_risk_pct
                    )
                    engine_daily_risk_used[engine_cfg.engine_id] = engine_risk_used + trade_risk
                    session_st.daily_new_risk_used += trade_risk

                    # Refresh portfolio state after each entry
                    alpaca_positions_after_exits = self._broker.list_positions()
                    strategy_states_after_exits = {
                        ss.symbol: ss
                        for ss in self._state.get_open_strategy_states(session_id)
                    }
                    open_positions = self._to_open_positions(
                        alpaca_positions_after_exits, strategy_states_after_exits
                    )
                    # Add the new virtual position to open_positions for gate checks
                    new_open = self._virtual_open_position(candidate, plan, today)
                    open_positions.append(new_open)
                    portfolio_state = DailyPortfolioState(
                        date=portfolio_state.date,
                        equity=portfolio_state.equity,
                        sizing_equity=portfolio_state.sizing_equity,
                        cash_available=max(
                            0.0,
                            portfolio_state.cash_available - plan.entry_price_limit * plan.shares,
                        ),
                        gross_exposure=portfolio_state.gross_exposure + plan.entry_price_limit * plan.shares,
                        net_exposure=portfolio_state.net_exposure + plan.entry_price_limit * plan.shares,
                        reserved_risk_budget=portfolio_state.reserved_risk_budget,
                        unrealized_pnl=portfolio_state.unrealized_pnl,
                        realized_pnl=portfolio_state.realized_pnl,
                        open_positions=[p.position_id for p in open_positions],
                        daily_new_risk_used=session_st.daily_new_risk_used,
                        peak_equity=portfolio_state.peak_equity,
                        current_drawdown_pct=portfolio_state.current_drawdown_pct,
                    )

                    entries.append({
                        "symbol": candidate.symbol,
                        "event_type": candidate.event_type,
                        "score": candidate.score,
                        "shares": plan.shares,
                        "entry_price": plan.entry_price_limit,
                        "stop": plan.stop_price,
                        "target": plan.target_price,
                        "order_id": order.id,
                    })

        else:
            # No engines defined — use flat candidate selection
            all_candidates = select_candidates(
                raw_rows=candidate_rows,
                universe_config=self._config.universe,
                signal_config=self._config.signal,
                event_type_profiles=self._config.event_type_profiles or {},
                excluded_event_ids={ss.event_id for ss in strategy_states_after_exits.values()},
                excluded_symbols={p.symbol for p in alpaca_positions_after_exits if p.symbol in strategy_states_after_exits},
            )
            active_bucket_ids = self._active_capital_bucket_ids_for_candidates(
                list(all_candidates),
                strategy_states_after_exits,
            )
            for candidate in all_candidates:
                candidate_portfolio_state = self._adjust_portfolio_state_for_candidate(
                    session_id=session_id,
                    candidate=candidate,
                    portfolio_state=portfolio_state,
                    active_bucket_ids=active_bucket_ids,
                    alpaca_positions=alpaca_positions_after_exits,
                    strategy_states=strategy_states_after_exits,
                )
                plan = build_planned_order(
                    candidate=candidate,
                    portfolio_state=candidate_portfolio_state,
                    open_positions=open_positions,
                    config=self._config,
                    cooldown_remaining=session_st.cooldown_remaining,
                    macro_data=macro_data,
                )
                if plan.skip_reason:
                    self._state.record_processed_event(
                        session_id, candidate.event_id, today.isoformat(),
                        "rejected", skip_reason=plan.skip_reason,
                    )
                    rejected.append({
                        "symbol": candidate.symbol,
                        "event_type": candidate.event_type,
                        "score": candidate.score,
                        "reason": plan.skip_reason,
                    })
                    continue

                try:
                    order = self._broker.submit_market_buy(candidate.symbol, plan.shares)
                except Exception as exc:
                    self._state.record_processed_event(
                        session_id, candidate.event_id, today.isoformat(),
                        "rejected", skip_reason=f"order_failed:{exc}",
                    )
                    rejected.append({
                        "symbol": candidate.symbol,
                        "event_type": candidate.event_type,
                        "score": candidate.score,
                        "reason": f"order_failed:{exc}",
                    })
                    continue

                verified = self._verify_order_fill(order.id, candidate.symbol)
                if verified is None:
                    self._state.record_processed_event(
                        session_id, candidate.event_id, today.isoformat(),
                        "rejected", skip_reason="order_not_filled",
                    )
                    rejected.append({
                        "symbol": candidate.symbol,
                        "event_type": candidate.event_type,
                        "score": candidate.score,
                        "reason": "order_not_filled",
                    })
                    continue
                fill_price = verified.filled_avg_price or plan.entry_price_limit

                self._state.save_strategy_state(
                    session_id,
                    StrategyStateRow(
                        session_id=session_id,
                        symbol=candidate.symbol,
                        event_id=candidate.event_id,
                        engine_id=candidate.engine_id,
                        order_id=order.id,
                        entry_date=today.isoformat(),
                        stop_price=plan.stop_price,
                        target_price=plan.target_price,
                        current_stop=plan.stop_price,
                        peak_price=fill_price,
                        days_held=0,
                        trade_direction=candidate.trade_direction,
                        candidate_json=candidate.model_dump_json(),
                        plan_json=plan.model_dump_json(),
                        status="open",
                    ),
                )
                self._state.record_processed_event(
                    session_id, candidate.event_id, today.isoformat(), "entered",
                )
                entries.append({
                    "symbol": candidate.symbol,
                    "event_type": candidate.event_type,
                    "score": candidate.score,
                    "shares": plan.shares,
                    "entry_price": plan.entry_price_limit,
                    "stop": plan.stop_price,
                    "target": plan.target_price,
                    "order_id": order.id,
                })

        # ============================================================
        # CASH PARKING: buy with idle cash (after all entries)
        # ============================================================
        if self._config.risk.cash_parking_enabled and not parking_sold_today:
            self._parking_buy(session_id, today)

        summary = self._finalize_day(today, session_st, exits, entries, rejected, len(candidate_rows))
        summary["reconciliation"] = recon
        self._state.mark_date_processed(session_id, today)
        return summary

    # ------------------------------------------------------------------ #
    # Cash Parking
    # ------------------------------------------------------------------ #

    def _parking_evaluate_gate(self, today: dt.date) -> str:
        """Evaluate parking gate and return target symbol or 'sgov'.

        Uses broker price data to compute all gate signals, matching
        backtester's _evaluate_parking_target() logic including temperature,
        entropy, VRP, Hurst, autocorrelation, and kurtosis.
        """
        import math

        risk = self._config.risk
        gate_mode = risk.cash_parking_gate_mode
        # Actual parking symbol (qqqm, qqq, spy, etc.)
        actual_sym = risk.cash_parking_symbol
        # Gate signal source (always qqq or spy for indicators)
        gate_sym = actual_sym if actual_sym in ("spy", "qqq") else "qqq"

        # Fetch historical bars for gate calculation
        lookback = max(risk.cash_parking_gate_vol_lookback + 5, 80)
        start = today - dt.timedelta(days=lookback * 2)
        bars_dict = self._broker.get_bars_as_dict([gate_sym.upper()], start, today)
        bars = bars_dict.get(gate_sym.upper(), {})
        if len(bars) < 20:
            return "sgov"  # insufficient data → safe default

        sorted_dates = sorted(bars.keys())
        closes = [bars[d]["close"] for d in sorted_dates]
        n = len(closes)

        # --- Compute indicators ---
        # Volatility (multiple lookbacks)
        def _compute_vol(lb: int) -> float | None:
            if n < lb + 1:
                return None
            log_rets = []
            for i in range(n - lb, n):
                if closes[i - 1] > 0:
                    log_rets.append(math.log(closes[i] / closes[i - 1]))
            if not log_rets:
                return None
            mean_r = sum(log_rets) / len(log_rets)
            var_r = sum((r - mean_r) ** 2 for r in log_rets) / len(log_rets)
            return math.sqrt(var_r * 252)

        vol = _compute_vol(risk.cash_parking_gate_vol_lookback)
        vol_15 = _compute_vol(15)
        vol_50 = _compute_vol(50)

        # Momentum
        def _compute_mom(period: int) -> float | None:
            if n <= period or closes[-1 - period] <= 0:
                return None
            return (closes[-1] - closes[-1 - period]) / closes[-1 - period]

        # Entropy
        def _compute_entropy(lb: int) -> float | None:
            if n < lb + 1:
                return None
            daily_rets = []
            for i in range(n - lb, n):
                if closes[i - 1] > 0:
                    daily_rets.append(closes[i] / closes[i - 1] - 1)
            if len(daily_rets) < lb - 1:
                return None
            n_pos = sum(1 for r in daily_rets if r > 0.001)
            n_neg = sum(1 for r in daily_rets if r < -0.001)
            n_flat = len(daily_rets) - n_pos - n_neg
            n_total = len(daily_rets)
            entropy = 0.0
            for count in (n_pos, n_neg, n_flat):
                if count > 0:
                    p = count / n_total
                    entropy -= p * math.log2(p)
            return entropy

        # Autocorrelation (lag-1)
        def _compute_autocorr(lb: int) -> float | None:
            if n < lb + 2:
                return None
            rets = []
            for i in range(n - lb - 1, n):
                if closes[i - 1] > 0:
                    rets.append(closes[i] / closes[i - 1] - 1)
            if len(rets) < lb:
                return None
            x, y = rets[:-1], rets[1:]
            n_ac = len(x)
            mx = sum(x) / n_ac
            my = sum(y) / n_ac
            cov = sum((x[k] - mx) * (y[k] - my) for k in range(n_ac)) / n_ac
            sx = (sum((x[k] - mx) ** 2 for k in range(n_ac)) / n_ac) ** 0.5
            sy = (sum((y[k] - my) ** 2 for k in range(n_ac)) / n_ac) ** 0.5
            if sx < 1e-12 or sy < 1e-12:
                return None
            return cov / (sx * sy)

        # --- Determine SGOV state from parking_state ---
        parking_st = self._state.get_parking_state(self._session.session_id)
        in_sgov = parking_st is not None and parking_st["symbol"] == "SGOV"

        # --- Gate evaluation (volatility mode) ---
        if gate_mode == "volatility":
            # Vol gate
            if vol is not None and vol >= risk.cash_parking_gate_vol_threshold:
                return "sgov"

            # Entropy check
            ent_thr = risk.cash_parking_entropy_threshold
            if ent_thr > 0:
                ent_lb = risk.cash_parking_entropy_lookback
                entropy = _compute_entropy(ent_lb)
                if entropy is not None and entropy > ent_thr and not in_sgov:
                    return "sgov"
                if in_sgov and not risk.cash_parking_require_trend:
                    if entropy is not None and entropy <= ent_thr * 0.8:
                        pass  # allow recovery
                    elif entropy is not None:
                        return "sgov"

            # VRP check
            vrp_thr = risk.cash_parking_vrp_threshold
            if vrp_thr > 0 and vol is not None:
                # Try to get VIX from broker or skip
                try:
                    vix_bars = self._broker.get_bars_as_dict(["VIXY"], today - dt.timedelta(days=5), today)
                    # Fallback: estimate VRP from vol ratio if VIX unavailable
                except Exception:
                    pass  # VRP check skipped in live (VIX not easily available)

            # Temperature check
            temp_thr = risk.cash_parking_temperature_threshold
            if temp_thr > 0 and vol_15 is not None and vol_50 is not None and vol_50 > 0:
                temp = vol_15 / vol_50
                if temp > temp_thr and not in_sgov:
                    return "sgov"
                if in_sgov and not risk.cash_parking_require_trend:
                    if temp <= temp_thr * 0.7:
                        pass  # allow recovery
                    else:
                        return "sgov"

            # Autocorrelation check
            ac_thr = risk.cash_parking_autocorr_threshold
            if ac_thr > -99:
                ac = _compute_autocorr(20)
                if ac is not None and ac < ac_thr and not in_sgov:
                    return "sgov"
                if in_sgov and not risk.cash_parking_require_trend:
                    if ac is not None and ac >= ac_thr + 0.1:
                        pass
                    elif ac is not None:
                        return "sgov"

            # Momentum trend check (asymmetric re-entry)
            if risk.cash_parking_require_trend and risk.cash_parking_trend_mode == "momentum":
                period = risk.cash_parking_trend_sma_period
                mom = _compute_mom(period)
                reentry_pct = risk.cash_parking_trend_reentry_pct

                if in_sgov:
                    mom_ok = mom is not None and mom > reentry_pct
                    if mom_ok:
                        pass  # allow recovery
                    else:
                        return "sgov"
                else:
                    if mom is not None and mom <= 0:
                        return "sgov"
                    # Entropy check within momentum (for vme presets)
                    if ent_thr > 0:
                        ent_lb = risk.cash_parking_entropy_lookback
                        entropy = _compute_entropy(ent_lb)
                        if entropy is not None and entropy > ent_thr:
                            return "sgov"

        return actual_sym

    def _parking_check_shock_brake(self, today: dt.date) -> bool:
        """Check QQQ/SMH acceleration signals for fast overlay exit.

        Returns True if any brake signal fires.
        """
        import math

        risk = self._config.risk
        rv_ratio = risk.cash_parking_overlay_shock_brake_rv_ratio
        dd5_pct = risk.cash_parking_overlay_shock_brake_dd5_pct
        sma_cross = risk.cash_parking_overlay_shock_brake_sma_cross

        lookback = 55  # enough for vol_20 + buffer
        start = today - dt.timedelta(days=lookback * 2)
        qqq_bars_dict = self._broker.get_bars_as_dict(["QQQ"], start, today)
        qqq_bars = qqq_bars_dict.get("QQQ", {})
        if len(qqq_bars) < 22:
            return False

        sorted_dates = sorted(qqq_bars.keys())
        qqq_closes = [qqq_bars[d]["close"] for d in sorted_dates]
        n = len(qqq_closes)

        def _vol(lb: int) -> float | None:
            if n < lb + 1:
                return None
            log_rets = []
            for i in range(n - lb, n):
                if qqq_closes[i - 1] > 0:
                    log_rets.append(math.log(qqq_closes[i] / qqq_closes[i - 1]))
            if not log_rets:
                return None
            mean_r = sum(log_rets) / len(log_rets)
            var_r = sum((r - mean_r) ** 2 for r in log_rets) / len(log_rets)
            return math.sqrt(var_r * 252)

        # Signal 1: vol acceleration
        vol5 = _vol(5)
        vol20 = _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 and n >= 11:
            qqq_close_now = qqq_closes[-1]
            qqq_sma10 = sum(qqq_closes[-10:]) / 10
            if qqq_close_now < qqq_sma10:
                # Check SMH mom_5
                smh_bars_dict = self._broker.get_bars_as_dict(["SMH"], start, today)
                smh_bars = smh_bars_dict.get("SMH", {})
                if len(smh_bars) >= 7:
                    smh_sorted = sorted(smh_bars.keys())
                    smh_closes = [smh_bars[d]["close"] for d in smh_sorted]
                    if len(smh_closes) >= 6 and smh_closes[-6] > 0:
                        smh_mom5 = (smh_closes[-1] - smh_closes[-6]) / smh_closes[-6]
                        if smh_mom5 < 0:
                            return True

        # Signal 3: sharp 5-day drawdown
        if n >= 6:
            qqq_high5 = max(qqq_closes[-5:])
            qqq_close_now = qqq_closes[-1]
            if qqq_high5 > 0 and (qqq_high5 - qqq_close_now) / qqq_high5 > dd5_pct:
                return True

        # Signal 4: near-SMA buffer — exit when QQQ within sma_buffer% ABOVE SMA10 (pre-emptive)
        # Requires vol5/vol20 ∈ (0.25, rv_ratio_upper) — "barely elevated" pre-crash signature.
        # Upper bound filters out high-vol days (regular gate handles those) and false alarms.
        sma_buffer = getattr(risk, "cash_parking_overlay_shock_brake_sma_buffer", 0.0)
        if sma_buffer > 0 and n >= 11:
            qqq_close_now = qqq_closes[-1]
            qqq_sma10_pt = sum(qqq_closes[-10:]) / 10
            vol5_pt = _vol(5)
            vol20_pt = _vol(20)
            if (
                qqq_sma10_pt > 0
                and vol5_pt is not None and vol20_pt is not None and vol20_pt > 0
            ):
                rv = vol5_pt / vol20_pt
                rv_upper = getattr(risk, "cash_parking_overlay_shock_brake_rv_ratio_upper", 0.0)
                vol_ok = rv > 0.25 and (rv_upper <= 0 or rv < rv_upper)
                if vol_ok:
                    sma_gap = (qqq_close_now - qqq_sma10_pt) / qqq_sma10_pt
                    if 0 < sma_gap < sma_buffer:
                        return True

        return False

    def _parking_check_and_sell(self, session_id: str, today: dt.date) -> bool:
        """Check gate signal, apply confirmation, trailing stop. Returns True if sold."""
        import math

        parking_st = self._state.get_parking_state(session_id)
        if parking_st is None:
            return False

        current_sym = parking_st["symbol"].lower()
        risk = self._config.risk
        target = self._parking_evaluate_gate(today)

        # --- Decrement overlay brake cooldown ---
        brake_cooldown = int(parking_st.get("overlay_brake_cooldown", 0))
        if brake_cooldown > 0:
            brake_cooldown -= 1
            self._state.update_parking_gate_state(session_id, overlay_brake_cooldown=brake_cooldown)

        # --- Shock brake check ---
        overlay_sym = (risk.cash_parking_low_vol_overlay_symbol or "").lower()
        if (
            overlay_sym
            and risk.cash_parking_overlay_shock_brake_enabled
            and current_sym == overlay_sym
        ):
            brake_fired = self._parking_check_shock_brake(today)
            if brake_fired:
                logger.info("parking_shock_brake", symbol=current_sym, date=str(today))
                # Execute sell immediately (bypass confirmation)
                sym = parking_st["symbol"]
                qty = parking_st["qty"]
                try:
                    if qty > 0:
                        self._broker.close_position(sym, qty=qty)
                        import time
                        time.sleep(1)
                    self._state.close_parking_state(session_id)
                    bars_now = self._broker.get_latest_bars([sym])
                    exit_price = bars_now[sym].close if sym in bars_now else parking_st["avg_price"]
                    self._state.record_trade(
                        session_id=session_id,
                        symbol=sym,
                        engine_id=None,
                        capital_bucket_id=None,
                        entry_date=parking_st["entry_date"],
                        exit_date=today.isoformat(),
                        entry_price=parking_st["avg_price"],
                        exit_price=exit_price,
                        exit_reason="PARKING_SHOCK_BRAKE",
                        shares=qty,
                        net_pnl=(exit_price - parking_st["avg_price"]) * qty,
                        r_multiple=0.0,
                        holding_days=(today - dt.date.fromisoformat(parking_st["entry_date"])).days,
                    )
                except Exception as e:
                    logger.warning("parking_shock_brake_sell_failed", error=str(e))
                # Set cooldown for next active parking state (will be created on re-buy)
                # We store cooldown on a session-level attribute for now
                self._parking_brake_cooldown_remaining = risk.cash_parking_overlay_shock_brake_cooldown_days
                self._parking_brake_skip_buy_today = True  # skip same-day re-buy
                return True

        # --- Dwell cap check ---
        overlay_hold_days = int(parking_st.get("overlay_hold_days", 0))
        if overlay_sym and current_sym == overlay_sym:
            overlay_hold_days += 1
            self._state.update_parking_gate_state(session_id, overlay_hold_days=overlay_hold_days)

            max_hold = risk.cash_parking_overlay_max_hold_days
            if max_hold > 0 and overlay_hold_days >= max_hold:
                logger.info("parking_dwell_cap", symbol=current_sym, hold_days=overlay_hold_days)
                sym = parking_st["symbol"]
                qty = parking_st["qty"]
                try:
                    if qty > 0:
                        self._broker.close_position(sym, qty=qty)
                        import time
                        time.sleep(1)
                    self._state.close_parking_state(session_id)
                    bars_now = self._broker.get_latest_bars([sym])
                    exit_price = bars_now[sym].close if sym in bars_now else parking_st["avg_price"]
                    self._state.record_trade(
                        session_id=session_id,
                        symbol=sym,
                        engine_id=None,
                        capital_bucket_id=None,
                        entry_date=parking_st["entry_date"],
                        exit_date=today.isoformat(),
                        entry_price=parking_st["avg_price"],
                        exit_price=exit_price,
                        exit_reason="PARKING_DWELL_CAP",
                        shares=qty,
                        net_pnl=(exit_price - parking_st["avg_price"]) * qty,
                        r_multiple=0.0,
                        holding_days=(today - dt.date.fromisoformat(parking_st["entry_date"])).days,
                    )
                except Exception as e:
                    logger.warning("parking_dwell_cap_sell_failed", error=str(e))
                return True

        # --- Trailing stop check ---
        stop_pct = risk.cash_parking_stop_pct
        if stop_pct > 0 and current_sym != "sgov":
            peak = parking_st.get("peak_price", 0) or parking_st["avg_price"]
            bars = self._broker.get_latest_bars([parking_st["symbol"]])
            if parking_st["symbol"] in bars:
                cur_price = bars[parking_st["symbol"]].close
                if peak > 0 and cur_price < peak * (1 - stop_pct):
                    logger.info("parking_trailing_stop", symbol=parking_st["symbol"],
                                peak=round(peak, 2), current=round(cur_price, 2))
                    target = "sgov"  # force exit

        # --- Target confirmation (2-day) to prevent whipsaw ---
        committed = parking_st.get("committed_target", current_sym)
        pending = parking_st.get("pending_target", "")
        pending_days = parking_st.get("pending_days", 0)

        if target == committed:
            # Signal agrees with committed → reset pending
            if pending:
                self._state.update_parking_gate_state(
                    session_id, pending_target="", pending_days=0)
            # No change needed
            if target == current_sym:
                return False
        else:
            # Signal disagrees with committed → accumulate pending
            if target == pending:
                pending_days += 1
            else:
                pending = target
                pending_days = 1
            self._state.update_parking_gate_state(
                session_id, pending_target=pending, pending_days=pending_days)

            if pending_days < 2:
                return False  # not confirmed yet, hold current
            # Confirmed after 2 days — commit and execute
            committed = target
            self._state.update_parking_gate_state(
                session_id, committed_target=committed, pending_target="", pending_days=0)
            if committed == current_sym:
                return False  # same symbol after confirmation

        # --- Execute sell ---
        sym = parking_st["symbol"]
        qty = parking_st["qty"]
        logger.info("parking_sell", symbol=sym, qty=qty, reason=f"gate→{target}")
        try:
            if qty > 0:
                self._broker.close_position(sym, qty=qty)
                time.sleep(1)
            self._state.close_parking_state(session_id)

            # Record trade
            bars = self._broker.get_latest_bars([sym])
            exit_price = bars[sym].close if sym in bars else parking_st["avg_price"]
            self._state.record_trade(
                session_id=session_id,
                symbol=sym,
                engine_id=None,
                capital_bucket_id=None,
                entry_date=parking_st["entry_date"],
                exit_date=today.isoformat(),
                entry_price=parking_st["avg_price"],
                exit_price=exit_price,
                exit_reason="PARKING",
                shares=qty,
                net_pnl=(exit_price - parking_st["avg_price"]) * qty,
                r_multiple=0.0,
                holding_days=(today - dt.date.fromisoformat(parking_st["entry_date"])).days,
            )
            # Mark sold today
            # Note: parking_state is now closed, so we track sold_today via instance var
        except Exception as e:
            logger.warning("parking_sell_failed", error=str(e))
        return True

    def _parking_liquidate_for_event(
        self, session_id: str, today: dt.date, needed: float
    ) -> bool:
        """Release parking cash to fund an event entry that has insufficient cash.

        Mirrors BacktestRunner._liquidate_parking_for_cash().
        Sells shares via broker; waits 1 s for fill.
        Returns True if any cash was freed.
        """
        parking_st = self._state.get_parking_state(session_id)
        if parking_st is None:
            return False

        sym = parking_st["symbol"]

        # Broker position (SGOV / QQQM / QQQ / SPY)
        qty = parking_st.get("qty", 0)
        if qty <= 0:
            return False
        bars = self._broker.get_latest_bars([sym])
        cur_price = bars[sym].close if sym in bars else float(parking_st.get("avg_price", 0))
        if cur_price <= 0:
            return False
        shares_to_sell = min(qty, max(1, math.ceil(needed / cur_price)))
        try:
            self._broker.close_position(sym, qty=shares_to_sell)
            time.sleep(1)
            new_qty = qty - shares_to_sell
            if new_qty <= 0:
                self._state.close_parking_state(session_id)
            else:
                avg = float(parking_st.get("avg_price", cur_price))
                self._state.save_parking_state(
                    session_id, sym,
                    parking_st.get("entry_date", today),
                    new_qty, avg, new_qty * avg,
                    peak_price=float(parking_st.get("peak_price", 0) or avg),
                    gate_in_sgov=int(parking_st.get("gate_in_sgov", 0)),
                    sgov_entry_value=0.0,
                )
            logger.info(
                "parking_partial_sell_for_event",
                symbol=sym, shares_sold=shares_to_sell, remaining_qty=max(0, new_qty),
            )
            return True
        except Exception as exc:
            logger.warning("parking_sell_for_event_failed", error=str(exc))
            return False

    def _parking_position_value(
        self, session_id: str, alpaca_positions: "list[Any]"
    ) -> "tuple[float, float]":
        """Return (parking_mv, parking_unrealized_pnl) for this session's parking.

        Uses the already-fetched alpaca_positions to avoid an extra API call.
        Each session tracks its own qty, so two sessions parking in the same
        symbol (e.g. QQQ) get correctly separated market values.
        """
        parking_st = self._state.get_parking_state(session_id)
        if parking_st is None:
            return 0.0, 0.0
        sym = parking_st["symbol"]
        # Broker position (SGOV / QQQ / SPY / TQQQ / QQQM …)
        qty = parking_st.get("qty", 0)
        avg = float(parking_st.get("avg_price", 0))
        if qty <= 0:
            return 0.0, 0.0
        cur_price = avg  # fallback to avg if not found in positions
        for p in alpaca_positions:
            if p.symbol == sym:
                cur_price = float(p.current_price)
                break
        return cur_price * qty, (cur_price - avg) * qty

    def _session_cash(self, session_id: str) -> tuple[float, float]:
        """Return (session_equity, session_cash) derived from local state.

        Avoids using the shared Alpaca account directly so multiple sessions
        on the same broker account each see their own isolated balance.
        Includes parking positions so available cash is correctly reduced.
        """
        from apps.paper_trader.mock_broker import MockBroker
        if isinstance(self._broker, MockBroker):
            acct = self._broker.get_account()
            return float(acct.equity), float(acct.cash)

        # Derive from session snapshots + open positions
        session_symbols = {
            ss.symbol for ss in self._state.get_open_strategy_states(session_id)
        }
        alpaca_positions = self._broker.list_positions()
        session_mv = sum(p.market_value for p in alpaca_positions if p.symbol in session_symbols)
        session_unreal = sum(p.unrealized_pl for p in alpaca_positions if p.symbol in session_symbols)

        # Include parking position so cash isn't over-stated
        parking_mv, _ = self._parking_position_value(session_id, alpaca_positions)
        session_mv += parking_mv

        snapshots = self._state.list_snapshots(session_id)
        if snapshots:
            session_equity = snapshots[-1]["equity"]
        else:
            total_realized = sum(
                t.get("net_pnl", 0.0) for t in self._state.list_trades(session_id)
            )
            session_equity = self._session.initial_equity + total_realized + session_unreal
        session_cash = max(0.0, session_equity - session_mv)
        return session_equity, session_cash

    def _parking_buy(self, session_id: str, today: dt.date) -> None:
        """Buy or top-up parking with idle cash after all entries are done."""
        risk = self._config.risk
        parking_st = self._state.get_parking_state(session_id)

        # --- Top-up existing parking ---
        if parking_st is not None:
            sym = parking_st["symbol"]
            # Check top-up conditions
            topup_dd = getattr(risk, "cash_parking_topup_max_peak_drawdown_pct", 0)
            if topup_dd > 0:
                peak = parking_st.get("peak_price", 0) or parking_st["avg_price"]
                bars = self._broker.get_latest_bars([sym])
                if sym in bars and peak > 0:
                    cur_price = bars[sym].close
                    dd_from_peak = (peak - cur_price) / peak
                    if dd_from_peak > topup_dd:
                        logger.info("parking_topup_blocked",
                                    symbol=sym, dd=f"{dd_from_peak:.2%}", threshold=f"{topup_dd:.2%}")
                        # Send blocked cash to SGOV instead
                        session_equity, session_cash = self._session_cash(session_id)
                        reserve = session_equity * risk.cash_parking_reserve_pct
                        investable = max(0.0, session_cash - reserve)
                        if investable > 100:
                            self._state.save_parking_state(
                                session_id, "SGOV", today, 1, investable, investable,
                                gate_in_sgov=0, sgov_entry_value=investable,
                            )
                        return
            # Allow top-up (no blocking condition met)
            return  # For now, no actual top-up execution (matches backtester behavior of holding)

        # --- New parking position ---
        # Skip same-day re-buy if brake fired today; next day gate re-evaluates fresh.
        if self._parking_brake_skip_buy_today:
            self._parking_brake_skip_buy_today = False
            return

        target = self._parking_evaluate_gate(today)

        # Overlay brake cooldown: suppress TQQQ overlay re-entry during cooldown
        overlay_sym_cfg = (risk.cash_parking_low_vol_overlay_symbol or "").lower()
        if (
            overlay_sym_cfg
            and target.lower() == overlay_sym_cfg
            and self._parking_brake_cooldown_remaining > 0
        ):
            self._parking_brake_cooldown_remaining -= 1
            # Fall back to base park symbol
            target = risk.cash_parking_symbol
            logger.info("parking_overlay_cooldown_active", remaining=self._parking_brake_cooldown_remaining)

        # SGOV/QQQ/SPY/QQQM/TQQQ: buy through broker
        sym = target.upper()
        session_equity, session_cash = self._session_cash(session_id)
        reserve = session_equity * risk.cash_parking_reserve_pct
        investable = max(0.0, session_cash - reserve)

        bars = self._broker.get_latest_bars([sym])
        if sym not in bars:
            return
        price = bars[sym].close
        if price <= 0 or investable < price:
            return

        qty = int(investable / price)
        if qty <= 0:
            return

        logger.info("parking_buy", symbol=sym, qty=qty, price=round(price, 2))
        try:
            order = self._broker.submit_market_buy(sym, qty)
            for _ in range(5):
                time.sleep(1)
                filled = self._broker.get_order(order.id)
                if filled and filled.filled_avg_price:
                    avg_price = filled.filled_avg_price
                    self._state.save_parking_state(
                        session_id, sym, today, qty, avg_price, avg_price * qty,
                        peak_price=avg_price, gate_in_sgov=1 if target == "sgov" else 0,
                        committed_target=target,
                    )
                    logger.info("parking_filled", symbol=sym, qty=qty, price=round(avg_price, 2))
                    return
            logger.warning("parking_buy_timeout", symbol=sym, order_id=order.id)
        except Exception as e:
            logger.warning("parking_buy_failed", error=str(e))

    # ------------------------------------------------------------------ #
    # Phased execution: reaction_close / next_open / monitor
    # ------------------------------------------------------------------ #

    async def run_reaction_close(
        self, target_date: dt.date | None = None, force: bool = False
    ) -> dict[str, Any]:
        """장 마감 직전 (~3:40 PM ET): same-day 이벤트 후보 → MOC 매수 주문.

        파이프라인 없이도 호출 가능. 당일 DB에 이미 적재된 이벤트를 사용.
        """
        today = target_date or dt.date.today()
        session_id = self._session.session_id
        phase = "reaction_close"

        if not force and self._state.is_phase_processed(session_id, today, phase):
            logger.info("paper_engine_already_processed", date=today.isoformat(), phase=phase)
            return {"date": today, "status": "already_processed", "phase": phase}

        from libs.common.time_utils import is_trading_day
        if not is_trading_day(today):
            return {"date": today, "status": "non_trading_day", "phase": phase}

        account = self._broker.get_account()
        alpaca_positions = self._broker.list_positions()
        session_st = self._state.get_session_state(session_id)
        strategy_states = {
            ss.symbol: ss
            for ss in self._state.get_open_strategy_states(session_id)
        }

        all_rows = await self._detector.get_candidates_for_date(
            today, self._config, convention="reaction_close"
        )
        macro_data = await self._fetch_macro(today)

        entries, rejected = await self._process_entries(
            today, all_rows, account, alpaca_positions, strategy_states,
            session_st, macro_data, self._broker.submit_moc_buy,
            entry_timing="reaction_close",
        )

        session_st.last_processed_date = today.isoformat()
        self._state.update_session_state(session_st)
        self._state.mark_phase_processed(session_id, today, phase)

        session_eq, session_ca = self._session_cash(session_id)
        logger.info(
            "paper_engine_reaction_close_done",
            date=today.isoformat(),
            candidates=len(all_rows),
            entries=len(entries),
            rejected=len(rejected),
        )
        return {
            "date": today,
            "status": "processed",
            "phase": phase,
            "entries": entries,
            "rejected": rejected,
            "candidates_detected": len(all_rows),
            "account": {"equity": session_eq, "cash": session_ca,
                        "market_value": account.long_market_value},
        }

    async def run_next_open(
        self, target_date: dt.date | None = None, force: bool = False
    ) -> dict[str, Any]:
        """장 시작 직후 (~9:30 AM ET): 전날 바로 exit 판단 + after-close 이벤트 → 시장가 매수.

        파이프라인이 전날 저녁 실행됐다고 가정.
        """
        today = target_date or dt.date.today()
        session_id = self._session.session_id
        phase = "next_open"

        if not force and self._state.is_phase_processed(session_id, today, phase):
            logger.info("paper_engine_already_processed", date=today.isoformat(), phase=phase)
            return {"date": today, "status": "already_processed", "phase": phase}

        from libs.common.time_utils import is_trading_day
        if not is_trading_day(today):
            return {"date": today, "status": "non_trading_day", "phase": phase}

        account = self._broker.get_account()
        alpaca_positions = self._broker.list_positions()
        session_st = self._state.get_session_state(session_id)
        strategy_states = {
            ss.symbol: ss
            for ss in self._state.get_open_strategy_states(session_id)
        }

        # Cancel stale orders + reconcile
        self._cancel_stale_orders()
        recon = self._reconcile_positions(alpaca_positions, strategy_states, today)
        for sym in recon.ghost_local:
            strategy_states.pop(sym, None)

        # Exit: 전날 (bar_date = today - 1) 종가 기준으로 exit 판단
        prev_date = today - dt.timedelta(days=1)
        exits = await self._process_exits(
            today, prev_date, alpaca_positions, strategy_states, session_st
        )

        # Refresh Alpaca state after exits
        account = self._broker.get_account()
        alpaca_positions = self._broker.list_positions()
        strategy_states = {
            ss.symbol: ss
            for ss in self._state.get_open_strategy_states(session_id)
        }

        # ============================================================
        # CASH PARKING: sell gate check first to free cash for entries
        # ============================================================
        parking_sold_today = False
        if self._config.risk.cash_parking_enabled:
            parking_sold_today = self._parking_check_and_sell(session_id, today)

        # Lookback entry: on first run_next_open, pick up events from previous days
        # that are still within their holding window (fires once per daemon session).
        lookback_entries: list[dict[str, Any]] = []
        lookback_rejected: list[dict[str, Any]] = []
        if self._config.execution.lookback_entry_enabled and not self._lookback_injected:
            self._lookback_injected = True
            if self._snapshot_store is not None:
                from libs.backtest.calendar import get_trading_days
                start_lb = self._lookback_start_date(today)
                lookback_rows = []
                for lb_date in get_trading_days(start_lb, today):
                    if lb_date >= today:
                        continue
                    for row in self._snapshot_store.get_candidates_for_date(lb_date):
                        row = dict(row)
                        row["is_lookback_entry"] = True
                        tdays = get_trading_days(lb_date, today)
                        row["lookback_days_elapsed"] = max(0, len(tdays) - 1)
                        lookback_rows.append(row)
            else:
                lookback_rows = await self._detector.get_candidates_for_lookback(
                    today, self._lookback_start_date(today), self._config
                )
            if lookback_rows:
                macro_data_lb = await self._fetch_macro(today)
                lookback_entries, lookback_rejected = await self._process_entries(
                    today, lookback_rows, account, alpaca_positions, strategy_states,
                    session_st, macro_data_lb, self._broker.submit_market_buy,
                    entry_timing="next_open",
                )
                # Refresh state after lookback entries
                alpaca_positions = self._broker.list_positions()
                strategy_states = {
                    ss.symbol: ss
                    for ss in self._state.get_open_strategy_states(session_id)
                }

        # Entry: all events with entry_date == today, across both conventions.
        # Mirrors BacktestRunner: next_open engines call get_candidates_for_date(date)
        # which returns ALL rows with execution_date==date regardless of entry_convention.
        #
        # Exclusion: same-day events with reaction_close convention are excluded here
        # because in the Parquet their execution_date is reaction_date+1 (next_open_after
        # _reaction_close entry), not reaction_date. Their next_open entry will appear
        # tomorrow with entry_convention='next_open_after_reaction_close'.
        # ENB (after-close with reaction_close convention, entry_date=reaction_date) is
        # correctly included because event_date != reaction_date (not same_day).
        if self._snapshot_store is not None:
            all_rows = self._snapshot_store.get_candidates_for_date(today)
        else:
            all_rows = await self._detector.get_candidates_for_date(
                today, self._config, convention=None
            )
        next_open_rows = [
            r for r in all_rows
            if not (self._is_same_day_event(r) and r.get("entry_convention") == "reaction_close")
        ]
        macro_data = await self._fetch_macro(today)

        entries, rejected = await self._process_entries(
            today, next_open_rows, account, alpaca_positions, strategy_states,
            session_st, macro_data, self._broker.submit_market_buy,
            entry_timing="next_open",
        )
        entries = lookback_entries + entries
        rejected = lookback_rejected + rejected

        # CASH PARKING: buy with remaining idle cash after entries
        if self._config.risk.cash_parking_enabled and not parking_sold_today:
            self._parking_buy(session_id, today)

        summary = self._finalize_day(today, session_st, exits, entries, rejected, len(next_open_rows))
        summary["phase"] = phase
        self._state.mark_phase_processed(session_id, today, phase)
        return summary

    async def run_monitor(self, interval_sec: int = 60) -> None:
        """장중 실시간 모니터링: stop/target 조건 충족 시 즉시 청산.

        Ctrl+C 로 종료. 별도 터미널에서 실행 권장.
        """
        import asyncio as _asyncio
        session_id = self._session.session_id
        logger.info("paper_engine_monitor_start", session=session_id, interval_sec=interval_sec)

        while True:
            try:
                alpaca_positions = self._broker.list_positions()
                strategy_states = {
                    ss.symbol: ss
                    for ss in self._state.get_open_strategy_states(session_id)
                }

                for pos in alpaca_positions:
                    ss = strategy_states.get(pos.symbol)
                    if ss is None:
                        continue

                    price = pos.current_price

                    # Trailing peak 업데이트
                    if price > ss.peak_price:
                        ss.peak_price = price
                        self._state.update_strategy_state(
                            session_id, pos.symbol, peak_price=price
                        )

                    # Stop 조건
                    if price < ss.current_stop:
                        logger.info(
                            "paper_engine_monitor_stop_hit",
                            symbol=pos.symbol, price=price, stop=ss.current_stop,
                        )
                        self._monitor_close(pos, ss, "STOP_INTRADAY", price)

                    # Target 조건
                    elif ss.target_price and price >= ss.target_price:
                        logger.info(
                            "paper_engine_monitor_target_hit",
                            symbol=pos.symbol, price=price, target=ss.target_price,
                        )
                        self._monitor_close(pos, ss, "TARGET_INTRADAY", price)

            except Exception as exc:
                logger.error("paper_engine_monitor_error", error=str(exc))

            await _asyncio.sleep(interval_sec)

    def _monitor_close(self, pos: Any, ss: Any, reason: str, price: float) -> None:
        """모니터링 루프에서 포지션 청산 처리."""
        session_id = self._session.session_id
        try:
            self._broker.close_position(pos.symbol, fill_price=price)
            self._state.close_strategy_state(session_id, pos.symbol)
            self._state.record_trade(
                session_id=session_id,
                symbol=pos.symbol,
                engine_id=ss.engine_id,
                capital_bucket_id=self._get_strategy_state_capital_bucket_id(ss),
                entry_date=ss.entry_date,
                exit_date=dt.date.today().isoformat(),
                entry_price=pos.avg_entry_price,
                exit_price=price,
                exit_reason=reason,
                shares=pos.qty,
                net_pnl=(price - pos.avg_entry_price) * pos.qty,
                r_multiple=0.0,
                holding_days=ss.days_held,
            )
        except Exception as exc:
            logger.error("paper_engine_monitor_close_failed", symbol=pos.symbol, error=str(exc))

    # ------------------------------------------------------------------ #
    # Shared helpers for phased execution
    # ------------------------------------------------------------------ #

    def _lookback_start_date(self, today: dt.date) -> dt.date:
        """Return the earliest date to search for lookback events (calendar-day buffer)."""
        from apps.backtester.run import _compute_max_effective_mhd
        max_mhd = _compute_max_effective_mhd(self._config)
        return today - dt.timedelta(days=max_mhd * 2)

    @staticmethod
    def _is_same_day_event(row: dict[str, Any]) -> bool:
        """event_date == reaction_date 이면 same-day (종가 진입) 이벤트."""
        def _pd(v: Any) -> dt.date | None:
            if isinstance(v, dt.datetime):
                return v.date()
            if isinstance(v, dt.date):
                return v
            if isinstance(v, str):
                try:
                    return dt.date.fromisoformat(v[:10])
                except ValueError:
                    return None
            return None
        ed = _pd(row.get("event_date"))
        rd = _pd(row.get("reaction_date"))
        return ed is not None and rd is not None and ed == rd

    async def _process_exits(
        self,
        today: dt.date,
        bar_date: dt.date,
        alpaca_positions: list[Any],
        strategy_states: dict[str, Any],
        session_st: Any,
    ) -> list[dict[str, Any]]:
        """보유 포지션에 대해 bar_date 기준 exit 로직 실행."""
        session_id = self._session.session_id
        exits: list[dict[str, Any]] = []
        held_symbols = [p.symbol for p in alpaca_positions]
        if not held_symbols:
            session_st.daily_new_risk_used = 0.0
            return exits

        bar_start = bar_date - dt.timedelta(days=30)
        # Fetch up to today so NO_PROGRESS / EARLY_FAILURE can execute at today's open.
        bars_by_symbol = self._broker.get_bars_as_dict(held_symbols, bar_start, today)

        for alpaca_pos in alpaca_positions:
            sym = alpaca_pos.symbol
            ss = strategy_states.get(sym)
            if ss is None:
                logger.debug("paper_engine_no_local_state", symbol=sym)
                continue

            ss.days_held += 1

            # Skip exit check for reaction_close (same-day) positions on their entry bar.
            # These positions were opened at the CLOSE of bar_date, so the intraday
            # bar data (low/high) precedes the actual entry and must not trigger stops.
            # next_open positions are NOT skipped — they entered at the OPEN so the
            # full day bar is valid for exit checking.
            if ss.entry_date == bar_date.isoformat() and _is_reaction_close_entry(ss.candidate_json):
                self._state.update_strategy_state(
                    session_id, sym, days_held=ss.days_held,
                    current_stop=ss.current_stop, peak_price=ss.peak_price,
                )
                continue

            sym_bars = bars_by_symbol.get(sym, {})
            available = [d for d in sym_bars if d <= bar_date]
            bar = sym_bars[max(available)] if available else None

            if bar is None:
                logger.warning("paper_engine_no_bar", symbol=sym, date=bar_date.isoformat())
                self._state.update_strategy_state(session_id, sym, days_held=ss.days_held)
                continue

            open_pos = self._to_open_position(alpaca_pos, ss)
            effective_exec = self._resolve_execution_config(ss)

            if effective_exec.trailing_model:
                update_trailing_stop(
                    open_pos, bar,
                    trailing_model=effective_exec.trailing_model,
                    warmup_days=effective_exec.trailing_warmup_days,
                )
                ss.current_stop = open_pos.current_stop
                ss.peak_price = open_pos.peak_price

            filled_trade = simulate_exit(open_pos, bar, effective_exec, bar_date)

            # NO_PROGRESS / EARLY_FAILURE: check yesterday's close, execute at today's open.
            # Mirrors BacktestRunner._evaluate_pending_open_exit + _process_pending_open_exits.
            if filled_trade is None:
                close_val = bar.get("close")
                if close_val is not None:
                    close_val = float(close_val)
                    np_days = effective_exec.early_failure_no_progress_days
                    np_r = effective_exec.early_failure_no_progress_r
                    scheduled_reason: str | None = None
                    # NO_PROGRESS: not enough progress by day N
                    if (
                        np_days is not None and np_r is not None
                        and ss.days_held == np_days
                        and open_pos.status.value != "partial"
                    ):
                        initial_r = abs(alpaca_pos.avg_entry_price - ss.current_stop)
                        progress_price = alpaca_pos.avg_entry_price + initial_r * np_r
                        if close_val < progress_price:
                            scheduled_reason = "NO_PROGRESS"
                    # EARLY_FAILURE: day-1 close below both entry and reaction close
                    if scheduled_reason is None and (
                        effective_exec.early_failure_close_below_entry_and_reaction_close
                        and ss.days_held == 1
                        and close_val < alpaca_pos.avg_entry_price
                    ):
                        reaction_close = float(open_pos.plan.candidate.features.get("event_close") or close_val)
                        if close_val < reaction_close:
                            scheduled_reason = "EARLY_FAILURE"
                    if scheduled_reason is not None:
                        today_bar = bars_by_symbol.get(sym, {}).get(today)
                        if today_bar is not None:
                            filled_trade = simulate_scheduled_open_exit(
                                position=open_pos,
                                bar=today_bar,
                                config=effective_exec,
                                current_date=today,
                                reason=scheduled_reason,
                                fraction=float(effective_exec.early_failure_no_progress_fraction or 1.0),
                            )

            if filled_trade is not None:
                is_partial = filled_trade.shares < alpaca_pos.qty
                try:
                    self._broker.close_position(sym, qty=filled_trade.shares if is_partial else None, fill_price=filled_trade.exit_price)
                    logger.info(
                        "paper_engine_exit",
                        symbol=sym, reason=filled_trade.exit_reason.value, pnl=filled_trade.net_pnl,
                        partial=is_partial,
                    )
                except Exception as exc:
                    logger.error("paper_engine_close_failed", symbol=sym, error=str(exc))
                    continue

                if is_partial:
                    # T1 partial exit: keep position tracked with breakeven stop
                    self._state.update_strategy_state(
                        session_id, sym,
                        days_held=ss.days_held,
                        current_stop=open_pos.current_stop,  # set to entry_price by simulate_exit
                        peak_price=ss.peak_price,
                        status="partial",
                    )
                else:
                    self._state.close_strategy_state(session_id, sym)
                self._state.record_trade(
                    session_id=session_id, symbol=sym,
                    engine_id=ss.engine_id,
                    capital_bucket_id=self._get_strategy_state_capital_bucket_id(ss),
                    entry_date=ss.entry_date, exit_date=today.isoformat(),
                    entry_price=alpaca_pos.avg_entry_price, exit_price=filled_trade.exit_price,
                    exit_reason=filled_trade.exit_reason.value, shares=filled_trade.shares,
                    net_pnl=filled_trade.net_pnl, r_multiple=filled_trade.r_multiple,
                    holding_days=ss.days_held,
                )
                if filled_trade.net_pnl < 0:
                    session_st.consecutive_losses += 1
                    streak = self._config.risk.cooldown_after_loss_streak
                    if streak > 0 and session_st.consecutive_losses >= streak:
                        session_st.cooldown_remaining = self._config.risk.cooldown_days
                        session_st.consecutive_losses = 0
                else:
                    session_st.consecutive_losses = 0
                exits.append({
                    "symbol": sym, "reason": filled_trade.exit_reason.value,
                    "pnl": filled_trade.net_pnl, "r_multiple": filled_trade.r_multiple,
                    "shares": filled_trade.shares, "exit_price": filled_trade.exit_price,
                })
            else:
                self._state.update_strategy_state(
                    session_id, sym, days_held=ss.days_held,
                    current_stop=ss.current_stop, peak_price=ss.peak_price,
                )

        if session_st.cooldown_remaining > 0:
            session_st.cooldown_remaining -= 1
        session_st.daily_new_risk_used = 0.0
        return exits

    async def _process_entries(
        self,
        today: dt.date,
        candidate_rows: list[dict[str, Any]],
        account: Any,
        alpaca_positions: list[Any],
        strategy_states: dict[str, Any],
        session_st: Any,
        macro_data: dict[str, Any],
        order_fn: Any,
        entry_timing: str | None = None,
    ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
        """후보군에 대해 진입 판단 + 주문 제출. order_fn = submit_market_buy | submit_moc_buy.

        entry_timing: 'reaction_close' or 'next_open'. When set, only engines with
        matching entry_timing_policy are used. Mirrors BacktestRunner's per-engine
        get_candidates_for_date vs get_candidates_for_reaction_date split.
        """
        session_id = self._session.session_id
        entries: list[dict[str, Any]] = []
        rejected: list[dict[str, Any]] = []

        # Kill switch gate
        if session_st.kill_switch_triggered:
            logger.warning("paper_engine_kill_switch_blocks_entries")
            return entries, rejected

        candidate_rows = [
            r for r in candidate_rows
            if not self._state.has_processed_event(session_id, str(r.get("event_id", "")))
        ]

        # Inject macro values from _fetch_macro() into candidate rows.
        # EventDetector (PostgreSQL) rows lack macro_vix/macro_hy_spread; the
        # Parquet snapshot pre-embeds them. Without this injection, any engine
        # with macro_vix_max set will reject all candidates (None fails the check).
        _macro_vix = macro_data.get("VIXCLS")
        _macro_hy = macro_data.get("BAMLH0A0HYM2")
        if _macro_vix is not None or _macro_hy is not None:
            for row in candidate_rows:
                if _macro_vix is not None and row.get("macro_vix") is None:
                    row["macro_vix"] = _macro_vix
                if _macro_hy is not None and row.get("macro_hy_spread") is None:
                    row["macro_hy_spread"] = _macro_hy

        open_positions = self._to_open_positions(alpaca_positions, strategy_states)
        portfolio_state = self._build_portfolio_state(account, alpaca_positions, today)
        engines = self._config.get_active_strategy_engines()
        engine_daily_risk_used: dict[str, float] = {}

        logger.debug(
            "paper_engine_selection_input",
            date=today.isoformat(),
            candidate_rows=len(candidate_rows),
            engines=len(engines),
            symbols=[r.get("symbol") for r in candidate_rows],
        )
        if candidate_rows:
            s = candidate_rows[0]
            logger.debug(
                "paper_engine_sample_row",
                symbol=s.get("symbol"), event_type=s.get("event_type"),
                event_direction=s.get("event_direction"), filing_time_bucket=s.get("filing_time_bucket"),
                entry_price_est=s.get("entry_price_est"), avg_dollar_volume=s.get("avg_dollar_volume"),
                avg_dollar_volume_20d=s.get("avg_dollar_volume_20d"), event_close=s.get("event_close"),
                close_location=s.get("close_location"), gap_size=s.get("gap_size"),
                reaction_day_return=s.get("reaction_day_return"), market_cap_proxy=s.get("market_cap_proxy"),
                execution_date=str(s.get("execution_date")), event_timestamp=str(s.get("event_timestamp")),
            )

        engine_list = engines if engines else [None]
        reserved_event_ids: set[str] = {ss.event_id for ss in strategy_states.values()}
        reserved_symbols: set[str] = {p.symbol for p in alpaca_positions if p.symbol in strategy_states}
        candidate_batches: list[tuple[Any | None, list[Candidate]]] = []
        for engine_cfg in engine_list:
            if engine_cfg is not None:
                # Skip engines that don't match the requested entry timing policy.
                # Mirrors BacktestRunner: reaction_close engines use get_candidates_for_reaction_date,
                # next_open engines use get_candidates_for_date.
                if entry_timing is not None and engine_cfg.entry_timing_policy != entry_timing:
                    continue
                prelimit = self._config.signal.max_candidates_per_day
                if self._attention_service.engine_requires_attention(engine_cfg):
                    prelimit = max(prelimit * 5, prelimit)
                engine_candidates = select_candidates(
                    raw_rows=candidate_rows,
                    universe_config=self._config.universe,
                    signal_config=self._config.signal,
                    event_type_profiles=self._config.event_type_profiles or {},
                    strategy_engine=engine_cfg,
                    truncate_to=prelimit,
                    excluded_event_ids=reserved_event_ids,
                    excluded_symbols=reserved_symbols,
                )
                # Attention filtering (matches BacktestRunner)
                engine_candidates = self._attention_service.apply_filters(
                    engine_candidates, engine_cfg, self._config.signal,
                )
                if engine_cfg.residual_reserve_selected and engine_candidates:
                    reserved_event_ids.update(c.event_id for c in engine_candidates)
                    reserved_symbols.update(c.symbol.upper() for c in engine_candidates)
            else:
                engine_candidates = select_candidates(
                    raw_rows=candidate_rows,
                    universe_config=self._config.universe,
                    signal_config=self._config.signal,
                    event_type_profiles=self._config.event_type_profiles or {},
                    excluded_event_ids=reserved_event_ids,
                    excluded_symbols=reserved_symbols,
                )
            candidate_batches.append((engine_cfg, engine_candidates))

        active_bucket_ids = self._active_capital_bucket_ids_for_candidates(
            [
                candidate
                for _, batch_candidates in candidate_batches
                for candidate in batch_candidates
            ],
            strategy_states,
        )
        for engine_cfg, engine_candidates in candidate_batches:
            for candidate in engine_candidates:
                engine_risk_used = (
                    engine_daily_risk_used.get(engine_cfg.engine_id, 0.0)
                    if engine_cfg is not None
                    else 0.0
                )
                candidate_portfolio_state = self._adjust_portfolio_state_for_candidate(
                    session_id=session_id,
                    candidate=candidate,
                    portfolio_state=portfolio_state,
                    active_bucket_ids=active_bucket_ids,
                    alpaca_positions=alpaca_positions,
                    strategy_states=strategy_states,
                )
                plan = build_planned_order(
                    candidate=candidate, portfolio_state=candidate_portfolio_state,
                    open_positions=open_positions, config=self._config,
                    cooldown_remaining=session_st.cooldown_remaining,
                    macro_data=macro_data,
                    engine_daily_new_risk_used=engine_risk_used if engine_cfg else 0.0,
                )
                if plan.skip_reason == "insufficient_cash":
                    # Attempt to free parking cash before giving up
                    needed = plan.shares * float(candidate.entry_price_est) if plan.shares else float(candidate.entry_price_est)
                    if self._parking_liquidate_for_event(session_id, today, needed):
                        account = self._broker.get_account()
                        _ap2 = self._broker.list_positions()
                        alpaca_positions = _ap2
                        portfolio_state = self._build_portfolio_state(account, _ap2, today)
                        candidate_portfolio_state = self._adjust_portfolio_state_for_candidate(
                            session_id=session_id,
                            candidate=candidate,
                            portfolio_state=portfolio_state,
                            active_bucket_ids=active_bucket_ids,
                            alpaca_positions=_ap2,
                            strategy_states=strategy_states,
                        )
                        plan = build_planned_order(
                            candidate=candidate, portfolio_state=candidate_portfolio_state,
                            open_positions=open_positions, config=self._config,
                            cooldown_remaining=session_st.cooldown_remaining,
                            macro_data=macro_data,
                            engine_daily_new_risk_used=engine_risk_used if engine_cfg else 0.0,
                        )

                if plan.skip_reason:
                    self._state.record_processed_event(
                        session_id, candidate.event_id, today.isoformat(),
                        "rejected", skip_reason=plan.skip_reason,
                    )
                    rejected.append({
                        "symbol": candidate.symbol, "event_type": candidate.event_type,
                        "score": candidate.score, "reason": plan.skip_reason,
                    })
                    continue
                # Gap cap check for next_open entries (matches BacktestRunner).
                # Skipped for lookback entries: multi-day price drift vs reaction-day
                # close is not comparable to an overnight gap.
                is_lookback = bool(candidate.features.get("is_lookback_entry", False))
                if not is_lookback:
                    from libs.backtest.execution import check_next_open_gap_cap
                    today_bar = self._broker.get_bar(candidate.symbol) if hasattr(self._broker, 'get_bar') else None
                    gap_reason = check_next_open_gap_cap(candidate, today_bar)
                    if gap_reason:
                        self._state.record_processed_event(
                            session_id, candidate.event_id, today.isoformat(),
                            "rejected", skip_reason=gap_reason,
                        )
                        rejected.append({
                            "symbol": candidate.symbol, "event_type": candidate.event_type,
                            "score": candidate.score, "reason": gap_reason,
                        })
                        continue
                try:
                    order = order_fn(candidate.symbol, plan.shares)
                    logger.info("paper_engine_buy_submitted", symbol=candidate.symbol, qty=plan.shares, order_id=order.id)
                except Exception as exc:
                    logger.error("paper_engine_buy_failed", symbol=candidate.symbol, error=str(exc))
                    self._state.record_processed_event(
                        session_id, candidate.event_id, today.isoformat(),
                        "rejected", skip_reason=f"order_failed:{exc}",
                    )
                    rejected.append({"symbol": candidate.symbol, "event_type": candidate.event_type, "score": candidate.score, "reason": f"order_failed:{exc}"})
                    continue

                # Verify fill (skip for MOC orders — they fill at close)
                is_moc = (order_fn != self._broker.submit_market_buy)
                if not is_moc:
                    verified = self._verify_order_fill(order.id, candidate.symbol)
                    if verified is None:
                        self._state.record_processed_event(
                            session_id, candidate.event_id, today.isoformat(),
                            "rejected", skip_reason="order_not_filled",
                        )
                        rejected.append({"symbol": candidate.symbol, "event_type": candidate.event_type, "score": candidate.score, "reason": "order_not_filled"})
                        continue
                    fill_price = verified.filled_avg_price or plan.entry_price_limit
                else:
                    fill_price = plan.entry_price_limit  # MOC: actual price unknown until close

                initial_days_held = int(candidate.features.get("lookback_days_elapsed", 0))
                self._state.save_strategy_state(
                    session_id,
                    StrategyStateRow(
                        session_id=session_id, symbol=candidate.symbol, event_id=candidate.event_id,
                        engine_id=candidate.engine_id, order_id=order.id, entry_date=today.isoformat(),
                        stop_price=plan.stop_price, target_price=plan.target_price,
                        current_stop=plan.stop_price, peak_price=fill_price,
                        days_held=initial_days_held, trade_direction=candidate.trade_direction,
                        candidate_json=candidate.model_dump_json(), plan_json=plan.model_dump_json(),
                        status="open",
                    ),
                )
                self._state.record_processed_event(
                    session_id, candidate.event_id, today.isoformat(), "entered",
                )
                trade_risk_state = candidate_portfolio_state.sizing_equity or candidate_portfolio_state.equity
                trade_risk = trade_risk_state * (
                    candidate.engine_per_trade_risk_pct or self._config.risk.per_trade_risk_pct
                )
                if engine_cfg:
                    engine_daily_risk_used[engine_cfg.engine_id] = engine_risk_used + trade_risk
                session_st.daily_new_risk_used += trade_risk

                alpaca_positions = self._broker.list_positions()
                strategy_states = {
                    ss.symbol: ss
                    for ss in self._state.get_open_strategy_states(session_id)
                }
                open_positions = self._to_open_positions(alpaca_positions, strategy_states)
                open_positions.append(self._virtual_open_position(candidate, plan, today))
                portfolio_state = DailyPortfolioState(
                    date=portfolio_state.date, equity=portfolio_state.equity,
                    sizing_equity=portfolio_state.sizing_equity,
                    cash_available=max(0.0, portfolio_state.cash_available - plan.entry_price_limit * plan.shares),
                    gross_exposure=portfolio_state.gross_exposure + plan.entry_price_limit * plan.shares,
                    net_exposure=portfolio_state.net_exposure + plan.entry_price_limit * plan.shares,
                    reserved_risk_budget=portfolio_state.reserved_risk_budget,
                    unrealized_pnl=portfolio_state.unrealized_pnl, realized_pnl=portfolio_state.realized_pnl,
                    open_positions=[p.position_id for p in open_positions],
                    daily_new_risk_used=session_st.daily_new_risk_used,
                    peak_equity=portfolio_state.peak_equity, current_drawdown_pct=portfolio_state.current_drawdown_pct,
                )
                entries.append({
                    "symbol": candidate.symbol, "event_type": candidate.event_type,
                    "score": candidate.score, "shares": plan.shares,
                    "entry_price": plan.entry_price_limit,
                    "stop": plan.stop_price, "target": plan.target_price, "order_id": order.id,
                })

        return entries, rejected

    def _finalize_day(
        self,
        today: dt.date,
        session_st: Any,
        exits: list[dict[str, Any]],
        entries: list[dict[str, Any]],
        rejected: list[dict[str, Any]],
        candidates_detected: int,
    ) -> dict[str, Any]:
        """일일 스냅샷 저장 + summary dict 반환."""
        session_id = self._session.session_id
        # 세션 소유 포지션만 집계 (Alpaca 전체 계좌가 아닌 세션 기준)
        alpaca_positions_final = self._broker.list_positions()
        session_symbols_final = {
            ss.symbol for ss in self._state.get_open_strategy_states(session_id)
        }
        session_positions_final = [p for p in alpaca_positions_final if p.symbol in session_symbols_final]
        session_market_value_final = sum(p.market_value for p in session_positions_final)
        session_unrealized_pl_final = sum(p.unrealized_pl for p in session_positions_final)

        # Include parking position in MV and unrealized P&L
        parking_mv_final, parking_unreal_final = self._parking_position_value(
            session_id, alpaca_positions_final
        )
        session_market_value_final += parking_mv_final
        session_unrealized_pl_final += parking_unreal_final

        # 세션 equity = initial_equity + 전체 실현 P&L + 현재 미실현 P&L (parking 포함)
        total_realized_pnl = sum(
            t.get("net_pnl", 0.0) for t in self._state.list_trades(session_id)
        )
        session_equity_final = self._session.initial_equity + total_realized_pnl + session_unrealized_pl_final
        session_cash_final = max(0.0, session_equity_final - session_market_value_final)

        prev_snapshots = self._state.list_snapshots(session_id)
        prev_equity = prev_snapshots[-1]["equity"] if prev_snapshots else self._session.initial_equity

        peak_equity = self._state.get_peak_equity(session_id, self._session.initial_equity)
        peak_equity = max(peak_equity, session_equity_final)
        total_pnl = session_equity_final - self._session.initial_equity
        drawdown_pct = (
            max(0.0, (peak_equity - session_equity_final) / peak_equity * 100)
            if peak_equity > 0 else 0.0
        )
        self._state.save_daily_snapshot(
            DailySnapshotRow(
                session_id=session_id, date=today.isoformat(), equity=session_equity_final,
                cash=session_cash_final, market_value=session_market_value_final,
                daily_pnl=session_equity_final - prev_equity, total_pnl=total_pnl,
                drawdown_pct=drawdown_pct, open_position_count=len(session_positions_final),
            )
        )
        # Kill switch check after drawdown computation
        self._check_kill_switch(drawdown_pct, session_st)

        session_st.last_processed_date = today.isoformat()
        self._state.update_session_state(session_st)
        logger.info(
            "paper_engine_day_done",
            date=today.isoformat(), exits=len(exits), entries=len(entries), rejected=len(rejected),
        )
        return {
            "date": today, "status": "processed",
            "exits": exits, "entries": entries, "rejected": rejected,
            "candidates_detected": candidates_detected,
            "account": {
                "equity": session_equity_final, "cash": session_cash_final,
                "market_value": session_market_value_final,
                "total_pnl": total_pnl, "drawdown_pct": drawdown_pct,
            },
        }

    # ------------------------------------------------------------------ #
    # Conversion helpers
    # ------------------------------------------------------------------ #

    def _to_open_position(
        self, alpaca_pos: Position, ss: StrategyStateRow
    ) -> OpenPosition:
        """Convert Alpaca position + local state to backtest OpenPosition."""
        candidate = Candidate.model_validate_json(ss.candidate_json)
        plan = PlannedOrder.model_validate_json(ss.plan_json)
        pos_status = (
            PositionStatus.PARTIALLY_EXITED if ss.status == "partial"
            else PositionStatus.ENTERED
        )
        return OpenPosition(
            position_id=ss.order_id or ss.symbol,
            plan=plan,
            entry_date=dt.date.fromisoformat(ss.entry_date),
            entry_price=alpaca_pos.avg_entry_price,
            entry_fill_slippage_bps=0.0,
            current_stop=ss.current_stop,
            target_price=ss.target_price,
            peak_price=ss.peak_price,
            shares_open=alpaca_pos.qty,
            shares_total=alpaca_pos.qty,
            days_held=ss.days_held,
            status=pos_status,
        )

    def _to_open_positions(
        self,
        alpaca_positions: list[Position],
        strategy_states: dict[str, StrategyStateRow],
    ) -> list[OpenPosition]:
        result: list[OpenPosition] = []
        for alpaca_pos in alpaca_positions:
            ss = strategy_states.get(alpaca_pos.symbol)
            if ss is None:
                continue
            result.append(self._to_open_position(alpaca_pos, ss))
        return result

    def _virtual_open_position(
        self, candidate: Candidate, plan: PlannedOrder, entry_date: dt.date
    ) -> OpenPosition:
        """Create a virtual OpenPosition for gate-checking after a new entry."""
        return OpenPosition(
            position_id=f"virtual_{candidate.symbol}",
            plan=plan,
            entry_date=entry_date,
            entry_price=plan.entry_price_limit,
            entry_fill_slippage_bps=0.0,
            current_stop=plan.stop_price,
            target_price=plan.target_price,
            peak_price=plan.entry_price_limit,
            shares_open=plan.shares,
            shares_total=plan.shares,
            days_held=0,
            status=PositionStatus.ENTERED,
        )

    def _build_portfolio_state(
        self,
        account: AccountInfo,
        alpaca_positions: list[Position],
        date: dt.date,
    ) -> DailyPortfolioState:
        """세션별 독립 equity/cash 기준으로 포트폴리오 상태 계산.

        Alpaca 계좌는 여러 세션이 공유하므로 account.equity/cash를 직접 쓰면
        안 됨. 대신 이 세션 고유의 equity(SQLite 스냅샷 기준)와 이 세션이
        보유한 포지션만 사용한다.
        """
        session_id = self._session.session_id

        # 이 세션 소유 포지션만 (SQLite strategy_states 기준)
        session_symbols = {
            ss.symbol for ss in self._state.get_open_strategy_states(session_id)
        }
        session_positions = [p for p in alpaca_positions if p.symbol in session_symbols]
        session_market_value = sum(p.market_value for p in session_positions)
        session_unrealized_pl = sum(p.unrealized_pl for p in session_positions)

        # Include parking position so available cash is not over-stated
        parking_mv, parking_unreal = self._parking_position_value(session_id, alpaca_positions)
        session_market_value += parking_mv
        session_unrealized_pl += parking_unreal

        # MockBroker (backtest): broker IS the session, use actual cash directly.
        # AlpacaBroker (live): multiple sessions may share account, derive from snapshot.
        from apps.paper_trader.mock_broker import MockBroker
        if isinstance(self._broker, MockBroker):
            session_cash = max(0.0, account.cash)
            session_equity = session_cash + session_market_value
        else:
            snapshots = self._state.list_snapshots(session_id)
            session_equity = (
                snapshots[-1]["equity"] if snapshots else self._session.initial_equity
            )
            session_cash = max(0.0, session_equity - session_market_value)

        peak_equity = self._state.get_peak_equity(session_id, self._session.initial_equity)
        peak_equity = max(peak_equity, session_equity)
        drawdown_pct = (
            max(0.0, (peak_equity - session_equity) / peak_equity * 100)
            if peak_equity > 0 else 0.0
        )
        session_st = self._state.get_session_state(session_id)
        return DailyPortfolioState(
            date=date,
            equity=session_equity,
            sizing_equity=session_equity,
            cash_available=session_cash,
            gross_exposure=session_market_value,
            net_exposure=session_market_value,
            reserved_risk_budget=0.0,
            unrealized_pnl=session_unrealized_pl,
            realized_pnl=0.0,
            open_positions=[p.symbol for p in session_positions],
            daily_new_risk_used=session_st.daily_new_risk_used,
            peak_equity=peak_equity,
            current_drawdown_pct=drawdown_pct,
        )

    def _resolve_execution_config(self, ss: StrategyStateRow) -> ExecutionConfig:
        """Get effective ExecutionConfig using shared function.

        Delegates to libs.backtest.execution.build_effective_execution_config()
        for consistency with BacktestRunner.
        """
        from libs.backtest.execution import build_effective_execution_config
        plan = PlannedOrder.model_validate_json(ss.plan_json)
        return build_effective_execution_config(plan.candidate, self._config)

    # ------------------------------------------------------------------ #
    # Macro data
    # ------------------------------------------------------------------ #

    async def _fetch_macro(self, date: dt.date) -> dict[str, Any]:
        """Fetch SPY/QQQ macro data for regime filtering."""
        try:
            sma_period = self._config.risk.macro_sma_period
            start = date - dt.timedelta(days=sma_period * 2 + 10)
            symbols = ["SPY", "QQQ"]

            # Fast path: use bars_cache from EventDetector (backtest mode)
            bars_cache = getattr(self._detector, "_bars_cache", None)
            if bars_cache is not None:
                macro: dict[str, Any] = {}
                for sym in symbols:
                    all_bars = bars_cache.get(sym, {})
                    closes = [
                        float(all_bars[d]["close"])
                        for d in sorted(all_bars.keys())
                        if start <= d <= date
                    ]
                    if closes:
                        key_prefix = sym.lower()
                        macro[f"{key_prefix}_close"] = closes[-1]
                        if len(closes) >= sma_period:
                            macro[f"{key_prefix}_sma_{sma_period}"] = sum(closes[-sma_period:]) / sma_period
                return macro

            from libs.oracle_client import OracleClient, PriceService

            async with OracleClient(base_url=self._detector._oracle_url) as client:
                svc = PriceService(client)

                async def _fetch_macro_sym(sym: str) -> tuple[str, list[Any]]:
                    try:
                        resp = await svc.get_daily_bars(sym, start=start.isoformat(), end=date.isoformat())
                        return sym, resp.bars
                    except Exception:
                        return sym, []

                results = await __import__("asyncio").gather(
                    *(_fetch_macro_sym(sym) for sym in symbols)
                )

            macro = {}
            for sym, bars in results:
                if not bars:
                    continue
                key_prefix = sym.lower()
                closes = [float(b.close) for b in bars]
                if closes:
                    macro[f"{key_prefix}_close"] = closes[-1]
                    if len(closes) >= sma_period:
                        macro[f"{key_prefix}_sma_{sma_period}"] = sum(closes[-sma_period:]) / sma_period

            # Fetch FRED macro data (VIX, HY spread) for regime sizing
            # Matches SnapshotStore._fetch_macro() which loads MacroObservation from DB
            try:
                from libs.oracle_client import FredService, OracleClient as _OC
                async with _OC(base_url=self._detector._oracle_url) as fred_client:
                    fred_svc = FredService(fred_client)
                    for series_id in ("VIXCLS", "BAMLH0A0HYM2"):
                        try:
                            resp = await fred_svc.get_observations(series_id, start=start.isoformat(), end=date.isoformat())
                            if resp.observations:
                                latest = [o for o in resp.observations if o.value is not None]
                                if latest:
                                    macro[series_id] = latest[-1].value
                        except Exception:
                            pass
            except Exception:
                pass

            return macro

        except Exception as exc:
            logger.warning("paper_engine_macro_fetch_failed", error=str(exc))
            return {}
