"""ORB Paper Trading Engine.

One engine instance is created per session per trading day.
The scheduler calls phase methods in order:
  1. run_pre_screen()    — 09:20 ET (daily bars + enrichment + quality filter)
  2. run_orb_detection() — 09:40 ET (intraday bars → candidates)
     └─ run_pre_screen 미실행 시 full fallback (daily bars도 자체 fetch)
  3. run_breakout_check() — every sim_bar_minutes from orb_end until order_timeout
  4. run_stop_check()   — every sim_bar_minutes from orb_end until 15:55 ET
  5. run_eod_exit()     — 15:55 ET
  6. run_post_close()   — 16:00 ET

Stop management logic mirrors orb_simulator.py:477-580 exactly.
"""
from __future__ import annotations

import copy
import datetime as dt
import logging
import time
import uuid
from pathlib import Path
from typing import Any
from zoneinfo import ZoneInfo

from apps.orb_trader.models import (
    ORBCandidateRow,
    ORBDailySnapshotRow,
    ORBPositionRow,
    ORBTradeRow,
)
from apps.orb_trader.screener import (
    bars_to_enrichment_format,
    intraday_bars_to_format,
    live_pre_screen,
    load_universe,
)
from apps.orb_trader.state import ORBStateManager
from libs.intraday.features import enrich_daily_bars
from libs.intraday.orb_simulator import (
    _aggregate_bars,
    _bar_close_location,
    _bar_return_pct,
    _first_regular_bar,
    _linear_range_scaler,
    _linear_scaler,
    _opening_breadth_stats,
    compute_orb_candidates,
)
from libs.intraday.simulator import _parse_ts, filter_market_hours
from libs.oracle_client.alpaca import get_snapshots

log = logging.getLogger(__name__)

# Structured logger — events flow to journal/events.db via libs.common.logging
# sink processor (configured by the daemon at startup). Used at trade/error
# sites so the Logs/Health UI gets ticker, fill_price, qty, etc. as fields,
# not embedded in a free-text message.
try:
    from libs.common.logging import get_logger as _get_struct_logger
    structured_log = _get_struct_logger("apps.orb_trader.engine")
except Exception:  # pragma: no cover — defensive; structlog should always import
    structured_log = None  # type: ignore[assignment]


def _emit(event: str, **fields: Any) -> None:
    """Emit a structured event if structlog is available; no-op otherwise."""
    if structured_log is None:
        return
    level = fields.pop("_level", "info")
    try:
        getattr(structured_log, level)(event, **fields)
    except Exception:
        pass

_ET = ZoneInfo("America/New_York")
_ACCOUNT_CIRCUIT_BREAKER_PCT = 25.0  # halt if equity drops >25% from peak


class ORBTradingEngine:
    """Intraday paper trading engine for the ORB strategy.

    Holds per-day state (enrichment, candidates, date_str) as instance variables.
    On server restart mid-day, state is reconstructed from the DB.
    """

    def __init__(
        self,
        session: Any,
        broker: Any,
        state: ORBStateManager,
        params: Any,
        log_callback: Any = None,
    ) -> None:
        self._session = session
        self._broker = broker
        self._state = state
        # Keep live execution overrides local to this engine instance. Strategy
        # filters must remain identical to the backtest config; otherwise a
        # session named "v49.86" can make different candidate decisions live.
        self._params = params.model_copy(deep=True) if hasattr(params, "model_copy") else copy.copy(params)
        self._log_callback = log_callback  # optional scheduler._log for UI visibility

        # Live execution details. These affect paper/live order handling, not
        # strategy candidate selection.
        self._params.settlement_days = 0  # paper trading; no real T+1 settlement
        self._params.slippage_bps = 0.0   # real fills, no simulated slippage

        # Per-day in-memory state (reset each day)
        self._date_str: str = ""
        self._enrichment: dict[str, dict[str, dict]] = {}
        self._daily_bars: dict[str, list[dict]] = {}
        self._candidates: list[dict] = []      # computed by run_orb_detection
        self._pending_cands: list[dict] = []   # not yet filled (for breakout checks)
        # Pre-screened tickers: None = pre_screen not yet run, [] = ran but nothing passed
        self._pre_screened_tickers: list[str] | None = None
        self._day_size_scale: float = 1.0
        self._soft_day_reason: str | None = None
        self._market_orb_quality_max_trades: int | None = None
        self._market_orb_quality_reason: str | None = None
        self._market_thrust_breadth_override_active: bool = False
        self._market_thrust_index_breadth_override_active: bool = False
        self._market_thrust_opening_breadth_override_active: bool = False

    def _get_equity(self) -> float:
        """Current equity = last snapshot equity, or initial if no snapshots."""
        eq = self._state.get_equity(self._session.session_id)
        return eq if eq is not None else self._session.initial_equity

    @staticmethod
    def _last_trading_day(ref: dt.date) -> dt.date:
        """Return the most recent weekday strictly before ref.

        Used for daily-bar end_date: today's bar is incomplete during market hours,
        and weekend dates cause Alpaca to return 502 Bad Gateway.
        """
        d = ref - dt.timedelta(days=1)
        while d.weekday() >= 5:  # 5=Sat, 6=Sun
            d -= dt.timedelta(days=1)
        return d

    def _log(self, msg: str) -> None:
        log.info("[ORB:%s] %s", self._session.session_name, msg)
        if self._log_callback is not None:
            try:
                self._log_callback(f"  [engine] {msg}")
            except Exception:
                pass

    def _market_context_tickers(self) -> list[str]:
        """Tickers needed for live regime/quality gates even if not trade candidates."""
        tickers: list[str] = []
        regime_ticker = getattr(self._params, "market_regime_ticker", None) or "QQQ"
        tickers.append(regime_ticker)
        for attr in (
            "market_regime_gap_ticker",
            "market_orb_quality_ticker",
            "market_orb_quality_secondary_ticker",
        ):
            ticker = getattr(self._params, attr, None)
            if ticker:
                tickers.append(str(ticker))
        return list(dict.fromkeys(t.upper() for t in tickers if t))

    def _with_market_context_tickers(self, tickers: list[str]) -> list[str]:
        """Fetch market context bars without expanding the tradable universe."""
        return list(dict.fromkeys([*self._market_context_tickers(), *tickers]))

    def _reset_day_context(self) -> None:
        self._day_size_scale = 1.0
        self._soft_day_reason = None
        self._market_orb_quality_max_trades = None
        self._market_orb_quality_reason = None
        self._market_thrust_breadth_override_active = False
        self._market_thrust_index_breadth_override_active = False
        self._market_thrust_opening_breadth_override_active = False

    @staticmethod
    def _copy_params_with_updates(params: Any, updates: dict[str, Any]) -> Any:
        """Return a params copy with updates for parity scans."""
        if not updates:
            return params
        if hasattr(params, "model_copy"):
            return params.model_copy(update=updates)
        copied = copy.copy(params)
        for key, value in updates.items():
            setattr(copied, key, value)
        return copied

    @staticmethod
    def _meets_min(value: float | None, threshold: Any) -> bool:
        return threshold is None or (value is not None and value >= float(threshold))

    def _apply_market_thrust_breadth_override(
        self,
        bars_by_ticker: dict[str, list[dict]],
        date_str: str,
        *,
        soft_day_reason_parts: list[str],
        regime_gap_pct: float | None,
        breadth_ratio: float | None,
        breadth_scaler: float,
    ) -> tuple[float, list[str]]:
        """Mirror the backtest gate that enables market-thrust auxiliary sleeves."""
        index_override_enabled = bool(
            getattr(self._params, "market_thrust_breadth_override_enabled", False)
        )
        opening_breadth_override_enabled = bool(
            getattr(self._params, "market_thrust_opening_breadth_override_enabled", False)
        )
        if not (index_override_enabled or opening_breadth_override_enabled):
            self._market_thrust_breadth_override_active = False
            self._market_thrust_index_breadth_override_active = False
            self._market_thrust_opening_breadth_override_active = False
            return breadth_scaler, soft_day_reason_parts

        market_open = dt.datetime.fromisoformat(f"{date_str}T09:30:00").replace(tzinfo=_ET)
        primary_ticker = (
            getattr(self._params, "market_orb_quality_ticker", None)
            or getattr(self._params, "market_regime_ticker", None)
            or "SPY"
        )
        secondary_ticker = getattr(self._params, "market_orb_quality_secondary_ticker", None)
        primary_bar = _first_regular_bar(bars_by_ticker.get(str(primary_ticker), []), market_open)
        secondary_bar = (
            _first_regular_bar(bars_by_ticker.get(str(secondary_ticker), []), market_open)
            if secondary_ticker
            else None
        )
        primary_close_loc = _bar_close_location(primary_bar)
        secondary_close_loc = _bar_close_location(secondary_bar)
        primary_ret = _bar_return_pct(primary_bar)
        secondary_ret = _bar_return_pct(secondary_bar)

        quality_blocks_thrust = self._market_quality_blocks_thrust(
            primary_close_loc,
            secondary_close_loc,
        )
        active = (
            index_override_enabled
            and "breadth" in soft_day_reason_parts
            and "hard_breadth" not in soft_day_reason_parts
            and not quality_blocks_thrust
            and self._meets_min(
                primary_close_loc,
                getattr(
                    self._params,
                    "market_thrust_breadth_override_min_primary_close_location",
                    None,
                ),
            )
            and self._meets_min(
                secondary_close_loc,
                getattr(
                    self._params,
                    "market_thrust_breadth_override_min_secondary_close_location",
                    None,
                ),
            )
            and self._meets_min(
                primary_ret,
                getattr(
                    self._params,
                    "market_thrust_breadth_override_min_primary_return_pct",
                    None,
                ),
            )
            and self._meets_min(
                secondary_ret,
                getattr(
                    self._params,
                    "market_thrust_breadth_override_min_secondary_return_pct",
                    None,
                ),
            )
            and self._meets_min(
                regime_gap_pct,
                getattr(self._params, "market_thrust_breadth_override_min_regime_gap_pct", None),
            )
            and self._meets_min(
                breadth_ratio,
                getattr(self._params, "market_thrust_breadth_override_min_breadth_ratio", None),
            )
        )
        opening_breadth_active = False
        if opening_breadth_override_enabled:
            opening_reason_allowed = bool(soft_day_reason_parts) and (
                (
                    "market_regime" in soft_day_reason_parts
                    and bool(
                        getattr(
                            self._params,
                            "market_thrust_opening_breadth_override_allow_regime_soft_day",
                            True,
                        )
                    )
                )
                or (
                    "breadth" in soft_day_reason_parts
                    and bool(
                        getattr(
                            self._params,
                            "market_thrust_opening_breadth_override_allow_breadth_soft_day",
                            True,
                        )
                    )
                )
                or (
                    "hard_breadth" in soft_day_reason_parts
                    and bool(
                        getattr(
                            self._params,
                            "market_thrust_opening_breadth_override_allow_hard_breadth",
                            False,
                        )
                    )
                )
            )
            opening_stats = _opening_breadth_stats(
                bars_by_ticker,
                date_str,
                min_first_bar_dollar_vol=getattr(
                    self._params,
                    "market_thrust_opening_breadth_override_min_first_bar_dollar_vol",
                    None,
                ),
                strong_close_location=float(
                    getattr(
                        self._params,
                        "market_thrust_opening_breadth_override_strong_close_location",
                        0.65,
                    )
                    or 0.65
                ),
            )
            opening_total_count = int(opening_stats.get("total_count") or 0)
            opening_positive_ratio = opening_stats.get("positive_ratio")
            opening_avg_return = opening_stats.get("avg_return_pct")
            opening_strong_ratio = opening_stats.get("strong_close_location_ratio")
            opening_breadth_active = (
                opening_reason_allowed
                and opening_total_count
                >= int(
                    getattr(
                        self._params,
                        "market_thrust_opening_breadth_override_min_total_count",
                        100,
                    )
                    or 0
                )
                and self._meets_min(
                    opening_positive_ratio,
                    getattr(
                        self._params,
                        "market_thrust_opening_breadth_override_min_positive_ratio",
                        None,
                    ),
                )
                and self._meets_min(
                    opening_avg_return,
                    getattr(
                        self._params,
                        "market_thrust_opening_breadth_override_min_avg_return_pct",
                        None,
                    ),
                )
                and self._meets_min(
                    opening_strong_ratio,
                    getattr(
                        self._params,
                        "market_thrust_opening_breadth_override_min_strong_close_location_ratio",
                        None,
                    ),
                )
            )
            self._log(
                "Opening-breadth thrust override: "
                f"active={opening_breadth_active}, count={opening_total_count}, "
                f"positive={opening_positive_ratio if opening_positive_ratio is not None else 'NA'}, "
                f"avg_ret={opening_avg_return if opening_avg_return is not None else 'NA'}, "
                f"strong_close={opening_strong_ratio if opening_strong_ratio is not None else 'NA'}"
            )

        self._market_thrust_index_breadth_override_active = active
        self._market_thrust_opening_breadth_override_active = opening_breadth_active
        self._market_thrust_breadth_override_active = active or opening_breadth_active
        if active:
            floor = max(
                0.0,
                float(
                    getattr(self._params, "market_thrust_breadth_override_size_scale_floor", 1.0)
                    or 0.0
                ),
            )
            breadth_scaler = max(breadth_scaler, floor)
            if (
                bool(getattr(self._params, "market_thrust_breadth_override_clear_soft_day", False))
                and breadth_scaler >= getattr(self._params, "soft_day_scaler_threshold", 1.0)
            ):
                soft_day_reason_parts = [
                    reason for reason in soft_day_reason_parts if reason != "breadth"
                ]
        if opening_breadth_active:
            regime_floor = max(
                0.0,
                float(
                    getattr(
                        self._params,
                        "market_thrust_opening_breadth_override_regime_size_scale_floor",
                        1.0,
                    )
                    or 0.0
                ),
            )
            breadth_floor = max(
                0.0,
                float(
                    getattr(
                        self._params,
                        "market_thrust_opening_breadth_override_breadth_size_scale_floor",
                        1.0,
                    )
                    or 0.0
                ),
            )
            # The caller has already applied regime_scaler, so live can only lift
            # breadth sizing here. Backtest parity for regime-floor lifting is
            # handled before combined sizing in the simulator.
            if regime_floor > 0:
                breadth_scaler = max(breadth_scaler, min(regime_floor, breadth_floor))
            else:
                breadth_scaler = max(breadth_scaler, breadth_floor)
            if getattr(self._params, "market_thrust_opening_breadth_override_clear_soft_day", False):
                soft_day_reason_parts = [
                    reason
                    for reason in soft_day_reason_parts
                    if not (
                        (
                            reason == "market_regime"
                            and getattr(
                                self._params,
                                "market_thrust_opening_breadth_override_allow_regime_soft_day",
                                True,
                            )
                        )
                        or (
                            reason == "breadth"
                            and getattr(
                                self._params,
                                "market_thrust_opening_breadth_override_allow_breadth_soft_day",
                                True,
                            )
                        )
                        or (
                            reason == "hard_breadth"
                            and getattr(
                                self._params,
                                "market_thrust_opening_breadth_override_allow_hard_breadth",
                                False,
                            )
                        )
                    )
                ]
        self._log(
            "Market thrust breadth override: "
            f"active={active}, {primary_ticker} close_loc={primary_close_loc if primary_close_loc is not None else 'NA'} "
            f"ret={primary_ret if primary_ret is not None else 'NA'}, "
            f"{secondary_ticker or '-'} close_loc={secondary_close_loc if secondary_close_loc is not None else 'NA'} "
            f"ret={secondary_ret if secondary_ret is not None else 'NA'}, "
            f"breadth={breadth_ratio if breadth_ratio is not None else 'NA'}, "
            f"regime_gap={regime_gap_pct if regime_gap_pct is not None else 'NA'}"
        )
        return breadth_scaler, soft_day_reason_parts

    def _market_quality_blocks_thrust(
        self,
        primary_close_loc: float | None,
        secondary_close_loc: float | None,
    ) -> bool:
        """Backtest market-thrust override is blocked by defensive quality states."""
        primary_strong_above = getattr(self._params, "market_orb_quality_primary_strong_above", None)
        secondary_weak_below = getattr(self._params, "market_orb_quality_secondary_weak_below", None)
        secondary_weak_above = getattr(self._params, "market_orb_quality_secondary_weak_above", None)
        divergence = (
            primary_strong_above is not None
            and secondary_weak_below is not None
            and primary_close_loc is not None
            and secondary_close_loc is not None
            and primary_close_loc >= primary_strong_above
            and secondary_close_loc <= secondary_weak_below
            and (secondary_weak_above is None or secondary_close_loc >= secondary_weak_above)
        )

        primary_weak_below = getattr(self._params, "market_orb_quality_primary_weak_below", None)
        primary_weak_above = getattr(self._params, "market_orb_quality_primary_weak_above", None)
        secondary_strong_above = getattr(self._params, "market_orb_quality_secondary_strong_above", None)
        primary_weak_secondary_strong = (
            primary_weak_below is not None
            and secondary_strong_above is not None
            and primary_close_loc is not None
            and secondary_close_loc is not None
            and primary_close_loc <= primary_weak_below
            and (primary_weak_above is None or primary_close_loc >= primary_weak_above)
            and secondary_close_loc >= secondary_strong_above
        )

        primary_lag_above = getattr(self._params, "market_orb_quality_primary_lag_above", None)
        primary_lag_below = getattr(self._params, "market_orb_quality_primary_lag_below", None)
        secondary_lead_above = getattr(self._params, "market_orb_quality_secondary_lead_above", None)
        secondary_lead_below = getattr(self._params, "market_orb_quality_secondary_lead_below", None)
        primary_lag_secondary_lead = (
            primary_lag_below is not None
            and secondary_lead_above is not None
            and primary_close_loc is not None
            and secondary_close_loc is not None
            and primary_close_loc <= primary_lag_below
            and (primary_lag_above is None or primary_close_loc >= primary_lag_above)
            and secondary_close_loc >= secondary_lead_above
            and (secondary_lead_below is None or secondary_close_loc <= secondary_lead_below)
        )

        joint_weak_primary_below = getattr(self._params, "market_orb_quality_joint_weak_primary_below", None)
        joint_weak_primary_above = getattr(self._params, "market_orb_quality_joint_weak_primary_above", None)
        joint_weak_secondary_below = getattr(self._params, "market_orb_quality_joint_weak_secondary_below", None)
        joint_weak_secondary_above = getattr(self._params, "market_orb_quality_joint_weak_secondary_above", None)
        joint_weak = (
            joint_weak_primary_below is not None
            and joint_weak_secondary_below is not None
            and primary_close_loc is not None
            and secondary_close_loc is not None
            and primary_close_loc <= joint_weak_primary_below
            and (joint_weak_primary_above is None or primary_close_loc >= joint_weak_primary_above)
            and secondary_close_loc <= joint_weak_secondary_below
            and (joint_weak_secondary_above is None or secondary_close_loc >= joint_weak_secondary_above)
        )

        joint_panic_primary_below = getattr(self._params, "market_orb_quality_joint_panic_primary_below", None)
        joint_panic_secondary_below = getattr(self._params, "market_orb_quality_joint_panic_secondary_below", None)
        joint_panic = (
            joint_panic_primary_below is not None
            and joint_panic_secondary_below is not None
            and primary_close_loc is not None
            and secondary_close_loc is not None
            and primary_close_loc <= joint_panic_primary_below
            and secondary_close_loc <= joint_panic_secondary_below
        )

        return bool(
            divergence
            or primary_weak_secondary_strong
            or primary_lag_secondary_lead
            or joint_weak
            or joint_panic
        )

    def _apply_market_orb_quality(
        self,
        bars_by_ticker: dict[str, list[dict]],
        date_str: str,
    ) -> None:
        """Mirror the backtest market-ORB-quality day scaler in live trading."""
        use_quality = any(
            getattr(self._params, attr, None) is not None
            for attr in (
                "market_orb_quality_size_scale_low",
                "market_orb_quality_size_scale_high",
                "market_orb_quality_primary_strong_above",
                "market_orb_quality_secondary_weak_above",
                "market_orb_quality_secondary_weak_below",
                "market_orb_quality_divergence_scale",
                "market_orb_quality_primary_weak_below",
                "market_orb_quality_primary_weak_above",
                "market_orb_quality_secondary_strong_above",
                "market_orb_quality_primary_weak_secondary_strong_scale",
                "market_orb_quality_primary_lag_above",
                "market_orb_quality_primary_lag_below",
                "market_orb_quality_secondary_lead_above",
                "market_orb_quality_secondary_lead_below",
                "market_orb_quality_primary_lag_secondary_lead_scale",
                "market_orb_quality_joint_weak_primary_below",
                "market_orb_quality_joint_weak_primary_above",
                "market_orb_quality_joint_weak_secondary_below",
                "market_orb_quality_joint_weak_secondary_above",
                "market_orb_quality_joint_weak_scale",
                "market_orb_quality_joint_panic_primary_below",
                "market_orb_quality_joint_panic_secondary_below",
                "market_orb_quality_joint_panic_scale",
            )
        )
        if not use_quality:
            return

        market_open = dt.datetime.fromisoformat(f"{date_str}T09:30:00").replace(tzinfo=_ET)
        primary_ticker = (
            getattr(self._params, "market_orb_quality_ticker", None)
            or getattr(self._params, "market_regime_ticker", None)
            or "SPY"
        )
        secondary_ticker = getattr(self._params, "market_orb_quality_secondary_ticker", None)
        primary_bar = _first_regular_bar(bars_by_ticker.get(str(primary_ticker), []), market_open)
        secondary_bar = (
            _first_regular_bar(bars_by_ticker.get(str(secondary_ticker), []), market_open)
            if secondary_ticker
            else None
        )
        primary_close_loc = _bar_close_location(primary_bar)
        secondary_close_loc = _bar_close_location(secondary_bar)
        primary_ret = _bar_return_pct(primary_bar)
        secondary_ret = _bar_return_pct(secondary_bar)

        scaler = 1.0
        reasons: list[str] = []
        max_trades: int | None = None

        low = getattr(self._params, "market_orb_quality_size_scale_low", None)
        high = getattr(self._params, "market_orb_quality_size_scale_high", None)
        if low is not None and high is not None:
            scaler *= _linear_range_scaler(
                primary_close_loc,
                low,
                high,
                getattr(self._params, "market_orb_quality_size_scale_min", 1.0),
                getattr(self._params, "market_orb_quality_size_scale_max", 1.0),
            )

        def _cap_trades(raw: Any) -> None:
            nonlocal max_trades
            if raw is None:
                return
            cap = max(0, int(raw))
            max_trades = cap if max_trades is None else min(max_trades, cap)

        primary_strong_above = getattr(self._params, "market_orb_quality_primary_strong_above", None)
        secondary_weak_above = getattr(self._params, "market_orb_quality_secondary_weak_above", None)
        secondary_weak_below = getattr(self._params, "market_orb_quality_secondary_weak_below", None)
        divergence = (
            primary_strong_above is not None
            and secondary_weak_above is not None
            and primary_close_loc is not None
            and secondary_close_loc is not None
            and primary_close_loc >= primary_strong_above
            and secondary_close_loc >= secondary_weak_above
            and (secondary_weak_below is None or secondary_close_loc <= secondary_weak_below)
        )
        if divergence:
            scale = getattr(self._params, "market_orb_quality_divergence_scale", None)
            if scale is not None:
                scaler *= max(0.0, float(scale))
            _cap_trades(getattr(self._params, "market_orb_quality_divergence_max_trades", None))
            reasons.append("divergence")

        primary_weak_below = getattr(self._params, "market_orb_quality_primary_weak_below", None)
        primary_weak_above = getattr(self._params, "market_orb_quality_primary_weak_above", None)
        secondary_strong_above = getattr(self._params, "market_orb_quality_secondary_strong_above", None)
        primary_weak_secondary_strong = (
            primary_weak_below is not None
            and secondary_strong_above is not None
            and primary_close_loc is not None
            and secondary_close_loc is not None
            and primary_close_loc <= primary_weak_below
            and (primary_weak_above is None or primary_close_loc >= primary_weak_above)
            and secondary_close_loc >= secondary_strong_above
        )
        if primary_weak_secondary_strong:
            scale = getattr(self._params, "market_orb_quality_primary_weak_secondary_strong_scale", None)
            if scale is not None:
                scaler *= max(0.0, float(scale))
            _cap_trades(getattr(self._params, "market_orb_quality_primary_weak_secondary_strong_max_trades", None))
            reasons.append("primary_weak_secondary_strong")

        primary_lag_above = getattr(self._params, "market_orb_quality_primary_lag_above", None)
        primary_lag_below = getattr(self._params, "market_orb_quality_primary_lag_below", None)
        secondary_lead_above = getattr(self._params, "market_orb_quality_secondary_lead_above", None)
        secondary_lead_below = getattr(self._params, "market_orb_quality_secondary_lead_below", None)
        primary_lag_secondary_lead = (
            primary_lag_below is not None
            and secondary_lead_above is not None
            and primary_close_loc is not None
            and secondary_close_loc is not None
            and primary_close_loc <= primary_lag_below
            and (primary_lag_above is None or primary_close_loc >= primary_lag_above)
            and secondary_close_loc >= secondary_lead_above
            and (secondary_lead_below is None or secondary_close_loc <= secondary_lead_below)
        )
        if primary_lag_secondary_lead:
            scale = getattr(self._params, "market_orb_quality_primary_lag_secondary_lead_scale", None)
            if scale is not None:
                scaler *= max(0.0, float(scale))
            _cap_trades(getattr(self._params, "market_orb_quality_primary_lag_secondary_lead_max_trades", None))
            reasons.append("primary_lag_secondary_lead")

        joint_weak_primary_below = getattr(self._params, "market_orb_quality_joint_weak_primary_below", None)
        joint_weak_primary_above = getattr(self._params, "market_orb_quality_joint_weak_primary_above", None)
        joint_weak_secondary_below = getattr(self._params, "market_orb_quality_joint_weak_secondary_below", None)
        joint_weak_secondary_above = getattr(self._params, "market_orb_quality_joint_weak_secondary_above", None)
        joint_weak = (
            joint_weak_primary_below is not None
            and joint_weak_secondary_below is not None
            and primary_close_loc is not None
            and secondary_close_loc is not None
            and primary_close_loc <= joint_weak_primary_below
            and (joint_weak_primary_above is None or primary_close_loc >= joint_weak_primary_above)
            and secondary_close_loc <= joint_weak_secondary_below
            and (joint_weak_secondary_above is None or secondary_close_loc >= joint_weak_secondary_above)
        )
        if joint_weak:
            scale = getattr(self._params, "market_orb_quality_joint_weak_scale", None)
            if scale is not None:
                scaler *= max(0.0, float(scale))
            _cap_trades(getattr(self._params, "market_orb_quality_joint_weak_max_trades", None))
            reasons.append("joint_weak")

        joint_panic_primary_below = getattr(self._params, "market_orb_quality_joint_panic_primary_below", None)
        joint_panic_secondary_below = getattr(self._params, "market_orb_quality_joint_panic_secondary_below", None)
        joint_panic = (
            joint_panic_primary_below is not None
            and joint_panic_secondary_below is not None
            and primary_close_loc is not None
            and secondary_close_loc is not None
            and primary_close_loc <= joint_panic_primary_below
            and secondary_close_loc <= joint_panic_secondary_below
        )
        if joint_panic:
            scale = getattr(self._params, "market_orb_quality_joint_panic_scale", None)
            if scale is not None:
                scaler *= max(0.0, float(scale))
            _cap_trades(getattr(self._params, "market_orb_quality_joint_panic_max_trades", None))
            reasons.append("joint_panic")

        self._day_size_scale *= max(0.0, scaler)
        self._market_orb_quality_max_trades = max_trades
        self._market_orb_quality_reason = "+".join(reasons) if reasons else None
        self._log(
            "Market ORB quality: "
            f"{primary_ticker} close_loc={primary_close_loc if primary_close_loc is not None else 'NA'} "
            f"ret={primary_ret if primary_ret is not None else 'NA'}, "
            f"{secondary_ticker or '-'} close_loc={secondary_close_loc if secondary_close_loc is not None else 'NA'} "
            f"ret={secondary_ret if secondary_ret is not None else 'NA'}, "
            f"scale={scaler:.3f}, max_trades={max_trades}, "
            f"reason={self._market_orb_quality_reason or '-'}"
        )

    # ── Phase 1: Pre-market Screening (daily bars + enrichment + quality filter) ─

    def run_pre_screen(self, date_str: str) -> dict[str, Any]:
        """Pre-market screening: fetch daily bars, compute enrichment, filter universe.

        Called at ~9:20 ET (before market open). Narrows the universe from ~971
        to ~250-350 tickers using quality filters (price, ATR, dollar volume).
        The expensive intraday bar fetch in run_orb_detection() then only
        fetches data for pre-screened tickers.

        If this method is never called (late start, failure), run_orb_detection()
        falls back to the full pipeline automatically.

        Returns summary dict.
        """
        self._date_str = date_str
        self._state.update_daily_state(
            self._session.session_id, date_str, phase="pre_screen"
        )

        # Cheap Oracle health probe — warns early so the operator can restart Oracle
        # before the bar-fetch loop (5 chunks × 15s timeout) burns 75s silently.
        try:
            import httpx as _httpx
            from libs.oracle_client.alpaca import _base_url as _oracle_base_url
            _oracle_health_url = _oracle_base_url() + "/api/v1/health"
            _r = _httpx.get(_oracle_health_url, timeout=3.0)
            _ok = _r.status_code < 400
        except Exception:
            _ok = False
        if not _ok:
            self._log(
                "CRITICAL: Oracle is unreachable — bar fetches will likely fail. "
                "Start Oracle before 09:20 ET on live trading days."
            )
            _emit(
                "orb_engine_oracle_unreachable",
                _level="error",
                session_id=self._session.session_id,
                session_name=self._session.session_name,
            )

        universe_source = getattr(self._params, "_universe_source", "midlarge")
        universe_symbols_file = getattr(self._params, "_universe_symbols_file", None)
        tickers = load_universe(universe_source, universe_symbols_file)

        # Always include the regime ticker so regime/breadth filters have data
        regime_ticker = getattr(self._params, "market_regime_ticker", None) or "QQQ"
        fetch_tickers = list(dict.fromkeys([regime_ticker] + tickers))  # deduplicate, regime first
        self._log(f"Pre-screen: {len(tickers)} tickers (+{regime_ticker}) — fetching daily bars")

        today = dt.date.fromisoformat(date_str)
        bars_end = self._last_trading_day(today)
        start = bars_end - dt.timedelta(days=65)

        raw_bars: dict[str, list] = {}
        chunk_size = 200
        for i in range(0, len(fetch_tickers), chunk_size):
            chunk = fetch_tickers[i : i + chunk_size]
            try:
                raw_bars.update(self._broker.get_bars(chunk, start, bars_end))
            except Exception as e:
                self._log(f"  WARNING: daily bars chunk {i//chunk_size+1} failed ({e}) — skipping")
                _emit(
                    "orb_engine_bars_chunk_failed",
                    _level="warning",
                    session_id=self._session.session_id,
                    chunk_kind="daily",
                    chunk_idx=i // chunk_size + 1,
                    error=str(e),
                )

        daily_bars_dict = bars_to_enrichment_format(raw_bars)

        # Add synthetic today row so enrich_daily_bars() produces entries for date_str
        for sym, bars in daily_bars_dict.items():
            if bars:
                last = bars[-1]
                if last["date"] < date_str:
                    daily_bars_dict[sym] = bars + [{
                        "date": date_str,
                        "open": last["close"], "high": last["close"],
                        "low": last["close"], "close": last["close"],
                        "volume": 0,
                    }]

        self._enrichment = enrich_daily_bars(daily_bars_dict, [date_str])
        self._daily_bars = daily_bars_dict

        qualified = live_pre_screen(self._enrichment, date_str, self._params)
        self._pre_screened_tickers = qualified

        daily_bars_count = len([s for s, b in raw_bars.items() if b])
        self._log(
            f"Pre-screen 완료: {daily_bars_count} daily bars → "
            f"{len(qualified)} qualified (전체 {len(tickers)}개 중)"
        )
        return {
            "universe_size": len(tickers),
            "daily_bars": daily_bars_count,
            "pre_screened": len(qualified),
        }

    # ── Phase 2: ORB Detection (intraday bars → candidates) ──────────────────

    def run_orb_detection(self, date_str: str) -> dict[str, Any]:
        """Fetch 5-min ORB bars for (pre-screened or full) universe, then compute candidates.

        Called once at 9:30 + orb_minutes (e.g., 9:40 for a 10-min ORB).
        If run_pre_screen() was called earlier, uses cached enrichment and
        pre-screened ticker list (skips daily bars fetch). Otherwise runs
        the full pipeline as a fallback.

        Returns summary dict.
        """
        self._date_str = date_str
        self._reset_day_context()
        self._state.update_daily_state(
            self._session.session_id, date_str, phase="orb_detection"
        )

        # Rolling loss filter: skip day if recent N-day equity return is below threshold
        roll_days = getattr(self._params, "rolling_loss_days", None)
        roll_thresh = getattr(self._params, "rolling_loss_threshold", None)
        if roll_days is not None and roll_thresh is not None:
            snapshots = self._state.list_snapshots(self._session.session_id)
            past = [s for s in snapshots if s["date"] < date_str]
            if len(past) >= roll_days:
                window = past[-roll_days:]
                rolling_pnl = sum(s["daily_pnl"] for s in window)
                sizing_base = self._session.initial_equity  # daily_budget_reset mode
                if sizing_base > 0 and rolling_pnl / sizing_base < roll_thresh:
                    self._log(
                        f"Rolling loss filter triggered ({rolling_pnl/sizing_base:.2%} "
                        f"< {roll_thresh:.2%}) — skipping today"
                    )
                    self._state.update_daily_state(
                        self._session.session_id, date_str, phase="done"
                    )
                    return {
                        "universe_size": 0,
                        "daily_bars": 0,
                        "intraday_bars": 0,
                        "orb_candidates": 0,
                        "long": 0,
                        "short": 0,
                        "skip_reason": "rolling_loss",
                    }

        # Account-level circuit breaker: halt if equity has dropped >25% from peak
        equity_now = self._get_equity()
        peak_eq = self._state.get_peak_equity(
            self._session.session_id, self._session.initial_equity
        )
        if peak_eq > 0:
            account_dd_pct = (peak_eq - equity_now) / peak_eq * 100
            if account_dd_pct >= _ACCOUNT_CIRCUIT_BREAKER_PCT:
                self._log(
                    f"CIRCUIT BREAKER: account drawdown {account_dd_pct:.1f}% "
                    f">= {_ACCOUNT_CIRCUIT_BREAKER_PCT}% — session halted"
                )
                _emit(
                    "orb_engine_circuit_breaker",
                    _level="error",
                    session_id=self._session.session_id,
                    drawdown_pct=round(float(account_dd_pct), 2),
                    threshold_pct=_ACCOUNT_CIRCUIT_BREAKER_PCT,
                    peak_equity=round(float(peak_eq), 2),
                    current_equity=round(float(equity_now), 2),
                )
                self._state.set_session_status(self._session.session_id, "paused")
                self._state.update_daily_state(
                    self._session.session_id, date_str, phase="done"
                )
                return {
                    "universe_size": 0, "daily_bars": 0, "intraday_bars": 0,
                    "orb_candidates": 0, "long": 0, "short": 0,
                    "skip_reason": "circuit_breaker",
                }

        # ── Determine intraday_tickers: use pre-screen cache or fetch daily bars ─
        if self._enrichment and self._pre_screened_tickers is not None:
            # Pre-screen already ran — skip daily bars fetch
            candidate_tickers = list(self._pre_screened_tickers)
            candidate_ticker_set = set(candidate_tickers)
            intraday_tickers = self._with_market_context_tickers(candidate_tickers)
            daily_bars_count = len([s for s, b in self._daily_bars.items() if b])
            self._log(
                f"Pre-screened universe 사용: {len(candidate_tickers)} tickers "
                f"(daily bars 캐시됨)"
            )
        else:
            # Fallback: full pipeline (pre_screen missed or failed)
            universe_source = getattr(self._params, "_universe_source", "midlarge")
            universe_symbols_file = getattr(self._params, "_universe_symbols_file", None)
            tickers = load_universe(universe_source, universe_symbols_file)
            regime_ticker = getattr(self._params, "market_regime_ticker", None) or "QQQ"
            fetch_tickers = list(dict.fromkeys([regime_ticker] + tickers))
            self._log(f"Universe: {len(tickers)} tickers (+{regime_ticker}) — fetching daily bars")

            today = dt.date.fromisoformat(date_str)
            bars_end = self._last_trading_day(today)
            start = bars_end - dt.timedelta(days=65)

            raw_bars: dict[str, list] = {}
            chunk_size = 200
            for i in range(0, len(fetch_tickers), chunk_size):
                chunk = fetch_tickers[i : i + chunk_size]
                try:
                    raw_bars.update(self._broker.get_bars(chunk, start, bars_end))
                except Exception as e:
                    self._log(f"  WARNING: daily bars chunk {i//chunk_size+1} failed ({e}) — skipping")

            daily_bars_dict = bars_to_enrichment_format(raw_bars)

            # Add synthetic today row (yesterday's close as placeholder) so
            # enrich_daily_bars() produces a keyed entry for date_str
            for sym, bars in daily_bars_dict.items():
                if bars:
                    last = bars[-1]
                    if last["date"] < date_str:
                        daily_bars_dict[sym] = bars + [{
                            "date": date_str,
                            "open": last["close"], "high": last["close"],
                            "low": last["close"], "close": last["close"],
                            "volume": 0,
                        }]

            self._enrichment = enrich_daily_bars(daily_bars_dict, [date_str])
            self._daily_bars = daily_bars_dict
            daily_bars_count = len([s for s, b in raw_bars.items() if b])
            candidate_tickers = list(tickers)
            candidate_ticker_set = set(candidate_tickers)
            intraday_tickers = self._with_market_context_tickers(candidate_tickers)

        context_added = len(intraday_tickers) - len(candidate_ticker_set)
        if context_added > 0:
            self._log(
                f"Market context bars 포함: {context_added} tickers "
                f"({', '.join(self._market_context_tickers())})"
            )

        # ── Fetch 5-min intraday bars for (pre-screened or full) universe ──────
        today = dt.date.fromisoformat(date_str)
        market_open = dt.datetime(today.year, today.month, today.day, 9, 30, tzinfo=_ET)
        orb_end = market_open + dt.timedelta(minutes=self._params.orb_minutes + 5)
        fetch_end = dt.datetime.now(_ET).replace(second=0, microsecond=0)
        if fetch_end < orb_end:
            fetch_end = orb_end

        intraday_raw: dict[str, list[dict]] = {}
        chunk_size = 100
        for i in range(0, len(intraday_tickers), chunk_size):
            chunk = intraday_tickers[i : i + chunk_size]
            try:
                chunk_bars = self._broker.get_intraday_bars(
                    chunk,
                    start=market_open,
                    end=fetch_end,
                    timeframe_minutes=5,
                )
                intraday_raw.update(chunk_bars)
            except Exception as e:
                self._log(f"  WARNING: intraday bars chunk {i//chunk_size+1} failed ({e}) — skipping")
                _emit(
                    "orb_engine_bars_chunk_failed",
                    _level="warning",
                    session_id=self._session.session_id,
                    chunk_kind="intraday",
                    chunk_idx=i // chunk_size + 1,
                    error=str(e),
                )

        bars_by_ticker = intraday_bars_to_format(intraday_raw)
        market_bars_by_ticker = {
            ticker: mkt_bars
            for ticker, bars in bars_by_ticker.items()
            if (mkt_bars := filter_market_hours(bars))
        }
        intraday_count = len(market_bars_by_ticker)
        self._log(
            f"Daily bars: {daily_bars_count} tickers | "
            f"Intraday bars: {intraday_count} tickers"
        )
        if daily_bars_count == 0:
            self._log("WARNING: no daily bars fetched — enrichment will be empty; check Oracle/Alpaca connection")
            _emit(
                "orb_engine_no_daily_bars",
                _level="error",
                session_id=self._session.session_id,
            )
        if intraday_count == 0:
            self._log("WARNING: no intraday bars fetched — zero candidates will be produced")
            _emit(
                "orb_engine_no_intraday_bars",
                _level="error",
                session_id=self._session.session_id,
            )

        # Patch today_open in enrichment with actual first-bar open from intraday data.
        # The pre_screen synthetic row uses prev_close as today_open (gap=0), which breaks
        # market_regime_spy_threshold and breadth filters. Overwrite with real opening price.
        for ticker, ticker_bars in market_bars_by_ticker.items():
            if not ticker_bars:
                continue
            first_bar = ticker_bars[0]
            real_open = first_bar.get("open")
            if real_open and ticker in self._enrichment:
                if date_str in self._enrichment[ticker]:
                    self._enrichment[ticker][date_str]["today_open"] = real_open
                else:
                    # Fallback: find the entry that was created for this date
                    for d in sorted(self._enrichment[ticker].keys(), reverse=True):
                        if d <= date_str:
                            # Create a date_str entry inheriting from latest
                            self._enrichment[ticker][date_str] = copy.copy(
                                self._enrichment[ticker][d]
                            )
                            self._enrichment[ticker][date_str]["today_open"] = real_open
                            break

        # Market regime / breadth checks mirror simulate_orb_day's soft-day fallback.
        regime_scaler = 1.0
        breadth_scaler = 1.0
        regime_gap_pct: float | None = None
        breadth_ratio: float | None = None
        soft_day_reason_parts: list[str] = []

        regime_thresh = getattr(self._params, "market_regime_spy_threshold", None)
        if regime_thresh is not None or getattr(self._params, "regime_size_scale_low", None) is not None:
            regime_ticker = getattr(self._params, "market_regime_ticker", None) or "QQQ"
            regime_enrich = self._enrichment.get(regime_ticker, {}).get(date_str, {})
            regime_prev_close = regime_enrich.get("prev_close")
            regime_today_open = regime_enrich.get("today_open")
            if regime_prev_close and regime_today_open and regime_prev_close > 0:
                regime_gap = (regime_today_open - regime_prev_close) / regime_prev_close
                regime_gap_pct = regime_gap
                regime_skip_below = getattr(self._params, "regime_skip_below", None)
                if regime_skip_below is not None and regime_gap < regime_skip_below:
                    self._log(
                        f"Regime filter: {regime_ticker} gap {regime_gap:.3%} "
                        f"< hard floor {regime_skip_below:.3%} — skipping today"
                    )
                    self._state.update_daily_state(
                        self._session.session_id, date_str, phase="done"
                    )
                    return {
                        "universe_size": intraday_count, "daily_bars": daily_bars_count,
                        "intraday_bars": intraday_count, "orb_candidates": 0,
                        "long": 0, "short": 0, "skip_reason": "market_regime",
                    }
                if (
                    getattr(self._params, "regime_size_scale_low", None) is None
                    and regime_thresh is not None
                    and regime_gap < regime_thresh
                ):
                    if getattr(self._params, "soft_day_fallback_on_regime_skip", False):
                        regime_scaler = max(
                            0.0,
                            float(
                                getattr(
                                    self._params,
                                    "soft_day_regime_skip_size_scale",
                                    1.0,
                                )
                                or 0.0
                            ),
                        )
                        soft_day_reason_parts.append("market_regime")
                        self._log(
                            f"Regime soft fallback: {regime_ticker} gap {regime_gap:.3%} "
                            f"< {regime_thresh:.3%}; size_scale={regime_scaler:.3f}"
                        )
                    else:
                        self._log(
                            f"Regime filter: {regime_ticker} gap {regime_gap:.3%} "
                            f"< {regime_thresh:.3%} — skipping today"
                        )
                        self._state.update_daily_state(
                            self._session.session_id, date_str, phase="done"
                        )
                        return {
                            "universe_size": intraday_count, "daily_bars": daily_bars_count,
                            "intraday_bars": intraday_count, "orb_candidates": 0,
                            "long": 0, "short": 0, "skip_reason": "market_regime",
                        }
                regime_low = getattr(self._params, "regime_size_scale_low", None)
                regime_high = getattr(self._params, "regime_size_scale_high", None)
                if regime_low is not None and regime_high is not None:
                    regime_scaler = _linear_scaler(
                        regime_gap,
                        regime_low,
                        regime_high,
                        getattr(self._params, "regime_size_scale_min", 0.0),
                        invert=True,
                    )

        # Breadth filter
        min_breadth = getattr(self._params, "min_candidate_breadth", None)
        if min_breadth is not None or getattr(self._params, "breadth_size_scale_low", None) is not None:
            pos_gap_count = 0
            total_with_data = 0
            for ticker in bars_by_ticker:
                t_enrich = self._enrichment.get(ticker, {}).get(date_str, {})
                prev_c = t_enrich.get("prev_close")
                today_o = t_enrich.get("today_open")
                if prev_c and today_o and prev_c > 0:
                    total_with_data += 1
                    if today_o > prev_c:
                        pos_gap_count += 1
            if total_with_data > 0:
                breadth_ratio = pos_gap_count / total_with_data
                hard_breadth_fallback_active = False
                breadth_skip_below = getattr(self._params, "breadth_skip_below", None)
                if breadth_skip_below is not None and breadth_ratio < breadth_skip_below:
                    fallback_min = getattr(
                        self._params,
                        "hard_breadth_soft_fallback_min_breadth",
                        None,
                    )
                    if (
                        getattr(self._params, "hard_breadth_soft_fallback_enabled", False)
                        and (fallback_min is None or breadth_ratio >= float(fallback_min))
                    ):
                        breadth_scaler = max(
                            0.0,
                            float(
                                getattr(
                                    self._params,
                                    "hard_breadth_soft_fallback_size_scale",
                                    0.03,
                                )
                                or 0.0
                            ),
                        )
                        soft_day_reason_parts.append("hard_breadth")
                        hard_breadth_fallback_active = True
                        self._log(
                            f"Hard breadth soft fallback: {breadth_ratio:.1%} "
                            f"< {breadth_skip_below:.1%}; size_scale={breadth_scaler:.3f}"
                        )
                    else:
                        self._log(
                            f"Breadth filter: {breadth_ratio:.1%} positive gaps "
                            f"< hard floor {breadth_skip_below:.1%} — skipping today"
                        )
                        self._state.update_daily_state(
                            self._session.session_id, date_str, phase="done"
                        )
                        return {
                            "universe_size": intraday_count, "daily_bars": daily_bars_count,
                            "intraday_bars": intraday_count, "orb_candidates": 0,
                            "long": 0, "short": 0, "skip_reason": "breadth",
                        }
                if (
                    getattr(self._params, "breadth_size_scale_low", None) is None
                    and min_breadth is not None
                    and breadth_ratio < min_breadth
                    and not hard_breadth_fallback_active
                ):
                    if getattr(self._params, "soft_day_fallback_on_breadth_skip", False):
                        breadth_scaler = max(
                            0.0,
                            float(
                                getattr(
                                    self._params,
                                    "soft_day_breadth_skip_size_scale",
                                    1.0,
                                )
                                or 0.0
                            ),
                        )
                        soft_day_reason_parts.append("breadth")
                        self._log(
                            f"Breadth soft fallback: {breadth_ratio:.1%} "
                            f"< {min_breadth:.1%}; size_scale={breadth_scaler:.3f}"
                        )
                    else:
                        self._log(
                            f"Breadth filter: {breadth_ratio:.1%} positive gaps "
                            f"< {min_breadth:.1%} — skipping today"
                        )
                        self._state.update_daily_state(
                            self._session.session_id, date_str, phase="done"
                        )
                        return {
                            "universe_size": intraday_count, "daily_bars": daily_bars_count,
                            "intraday_bars": intraday_count, "orb_candidates": 0,
                            "long": 0, "short": 0, "skip_reason": "breadth",
                        }
                breadth_low = getattr(self._params, "breadth_size_scale_low", None)
                breadth_high = getattr(self._params, "breadth_size_scale_high", None)
                if breadth_low is not None and breadth_high is not None:
                    breadth_scaler = _linear_scaler(
                        breadth_ratio,
                        breadth_low,
                        breadth_high,
                        getattr(self._params, "breadth_size_scale_min", 0.0),
                        invert=True,
                    )

        breadth_scaler, soft_day_reason_parts = self._apply_market_thrust_breadth_override(
            market_bars_by_ticker,
            date_str,
            soft_day_reason_parts=soft_day_reason_parts,
            regime_gap_pct=regime_gap_pct,
            breadth_ratio=breadth_ratio,
            breadth_scaler=breadth_scaler,
        )
        self._day_size_scale = max(0.0, regime_scaler * breadth_scaler)
        if (
            soft_day_reason_parts
            and getattr(self._params, "soft_day_combined_size_scale_floor", None) is not None
        ):
            self._day_size_scale = max(
                self._day_size_scale,
                max(
                    0.0,
                    float(getattr(self._params, "soft_day_combined_size_scale_floor")),
                ),
            )
        if soft_day_reason_parts:
            self._soft_day_reason = "+".join(soft_day_reason_parts)
            self._log(
                f"Soft-day active: reason={self._soft_day_reason}, "
                f"day_size_scale={self._day_size_scale:.3f}"
            )

        self._apply_market_orb_quality(market_bars_by_ticker, date_str)
        if self._day_size_scale <= 0:
            self._log("Day sizing disabled by market ORB quality — candidates may be recorded, but no entries will be placed")

        broad_gapup_fallback_mode = bool(
            getattr(self._params, "broad_gapup_continuation_enabled", False)
        )
        market_thrust_liquid_mode = bool(
            getattr(self._params, "market_thrust_liquid_continuation_enabled", False)
        )
        market_thrust_opening_impulse_mode = bool(
            getattr(self._params, "market_thrust_opening_impulse_reclaim_enabled", False)
        )
        base_candidate_updates: dict[str, Any] = {}
        if broad_gapup_fallback_mode:
            base_candidate_updates["broad_gapup_continuation_enabled"] = False
        if market_thrust_liquid_mode:
            base_candidate_updates["market_thrust_liquid_continuation_enabled"] = False
        if market_thrust_opening_impulse_mode:
            base_candidate_updates["market_thrust_opening_impulse_reclaim_enabled"] = False
        candidate_params = self._copy_params_with_updates(
            self._params,
            base_candidate_updates,
        )
        computed_candidates = compute_orb_candidates(
            bars_by_ticker=bars_by_ticker,
            date_str=date_str,
            params=candidate_params,
            enrichment=self._enrichment,
        )
        normal_candidates = [
            cand for cand in computed_candidates
            if cand["ticker"] in candidate_ticker_set
        ]
        broad_candidates: list[dict[str, Any]] = []
        allow_broad_candidates = (
            broad_gapup_fallback_mode
            and (
                normal_candidates == []
                or not getattr(
                    self._params,
                    "broad_gapup_continuation_only_when_no_primary_entries",
                    False,
                )
            )
        )
        if allow_broad_candidates:
            broad_scan_params = self._copy_params_with_updates(
                self._params,
                {
                    "max_candidates": max(
                        int(getattr(self._params, "max_candidates", 0) or 0),
                        len(candidate_ticker_set),
                    ),
                    "max_candidates_per_sector": None,
                    "market_thrust_liquid_continuation_enabled": False,
                    "market_thrust_opening_impulse_reclaim_enabled": False,
                },
            )
            broad_candidates = [
                cand for cand in compute_orb_candidates(
                    bars_by_ticker=bars_by_ticker,
                    date_str=date_str,
                    params=broad_scan_params,
                    enrichment=self._enrichment,
                )
                if cand["ticker"] in candidate_ticker_set
                and cand.get("broad_gapup_continuation")
            ]

        used_tickers = {cand["ticker"] for cand in normal_candidates + broad_candidates}
        market_thrust_liquid_candidates: list[dict[str, Any]] = []
        opening_breadth_only_thrust = (
            self._market_thrust_opening_breadth_override_active
            and not self._market_thrust_index_breadth_override_active
        )
        liquid_allowed_on_opening_breadth = bool(
            getattr(
                self._params,
                "market_thrust_opening_breadth_override_activate_liquid_continuation",
                True,
            )
        )
        market_thrust_liquid_scan_active = (
            market_thrust_liquid_mode
            and self._market_thrust_breadth_override_active
            and (
                not opening_breadth_only_thrust
                or liquid_allowed_on_opening_breadth
            )
        )
        if market_thrust_liquid_scan_active:
            market_thrust_scan_params = self._copy_params_with_updates(
                self._params,
                {
                    "max_candidates": max(
                        int(getattr(self._params, "max_candidates", 0) or 0),
                        len(candidate_ticker_set),
                    ),
                    "max_candidates_per_sector": None,
                    "broad_gapup_continuation_enabled": False,
                    "market_thrust_opening_impulse_reclaim_enabled": False,
                },
            )
            market_thrust_liquid_candidates = [
                cand for cand in compute_orb_candidates(
                    bars_by_ticker=bars_by_ticker,
                    date_str=date_str,
                    params=market_thrust_scan_params,
                    enrichment=self._enrichment,
                )
                if cand["ticker"] in candidate_ticker_set
                and cand.get("market_thrust_liquid_continuation")
                and cand["ticker"] not in used_tickers
            ]
            max_market_thrust_candidates = getattr(
                self._params,
                "market_thrust_liquid_continuation_max_candidates",
                None,
            )
            if max_market_thrust_candidates is not None:
                market_thrust_liquid_candidates = market_thrust_liquid_candidates[
                    : max(0, int(max_market_thrust_candidates))
                ]
            used_tickers.update(cand["ticker"] for cand in market_thrust_liquid_candidates)

        market_thrust_opening_impulse_candidates: list[dict[str, Any]] = []
        if market_thrust_opening_impulse_mode and self._market_thrust_breadth_override_active:
            market_thrust_impulse_params = self._copy_params_with_updates(
                self._params,
                {
                    "max_candidates": max(
                        int(getattr(self._params, "max_candidates", 0) or 0),
                        len(candidate_ticker_set),
                    ),
                    "max_candidates_per_sector": None,
                    "broad_gapup_continuation_enabled": False,
                    "market_thrust_liquid_continuation_enabled": False,
                },
            )
            market_thrust_opening_impulse_candidates = [
                cand for cand in compute_orb_candidates(
                    bars_by_ticker=bars_by_ticker,
                    date_str=date_str,
                    params=market_thrust_impulse_params,
                    enrichment=self._enrichment,
                )
                if cand["ticker"] in candidate_ticker_set
                and cand.get("market_thrust_opening_impulse_reclaim")
                and cand["ticker"] not in used_tickers
            ]
            max_market_thrust_impulse_candidates = getattr(
                self._params,
                "market_thrust_opening_impulse_reclaim_max_candidates",
                None,
            )
            if max_market_thrust_impulse_candidates is not None:
                market_thrust_opening_impulse_candidates = market_thrust_opening_impulse_candidates[
                    : max(0, int(max_market_thrust_impulse_candidates))
                ]

        self._candidates = (
            normal_candidates
            + broad_candidates
            + market_thrust_liquid_candidates
            + market_thrust_opening_impulse_candidates
        )

        # Save candidates to DB
        for cand in self._candidates:
            orb_bar = cand["orb_bar"]
            direction = cand["direction"]
            breakout_level = orb_bar["high"] if direction == "bullish" else orb_bar["low"]
            row = ORBCandidateRow(
                session_id=self._session.session_id,
                date=date_str,
                ticker=cand["ticker"],
                direction=direction,
                orb_high=orb_bar["high"],
                orb_low=orb_bar["low"],
                breakout_level=breakout_level,
                atr=cand["atr"],
                rvol=cand["rvol"],
                gap_pct=cand["gap_pct"],
                composite_score=cand["score"],
            )
            self._state.save_candidate(row)

        # Keep as pending (not yet filled)
        self._pending_cands = list(self._candidates)

        n_long = sum(1 for c in self._candidates if c["direction"] == "bullish")
        n_short = sum(1 for c in self._candidates if c["direction"] == "bearish")
        self._log(
            f"ORB candidates: {len(self._candidates)} "
            f"(long={n_long}, short={n_short})"
        )

        self._state.update_daily_state(
            self._session.session_id, date_str, phase="breakout"
        )

        return {
            "universe_size": len(intraday_tickers),
            "daily_bars": daily_bars_count,
            "intraday_bars": intraday_count,
            "orb_candidates": len(self._candidates),
            "long": n_long,
            "short": n_short,
        }

    # ── Phase 3: Breakout Check ───────────────────────────────────────────────

    def run_breakout_check(self, date_str: str) -> dict[str, Any]:
        """Check for breakouts and place orders for unfilled candidates.

        Called every sim_bar_minutes from orb_end until order_timeout elapses.
        """
        self._date_str = date_str

        # Reload state if engine was recreated (e.g., server restart)
        if not self._pending_cands and not self._candidates:
            self._pending_cands = self._rebuild_pending_candidates(date_str)

        if not self._pending_cands:
            return {"checked": 0, "filled": 0, "remaining": 0}

        daily_state = self._state.get_daily_state(
            self._session.session_id, date_str
        )
        if daily_state.kill_switch:
            self._log("Kill switch active — skipping breakout check")
            _emit(
                "orb_engine_kill_switch_active",
                _level="warning",
                session_id=self._session.session_id,
                phase="breakout",
            )
            return {"checked": 0, "filled": 0, "remaining": 0, "kill_switch": True}

        equity = self._get_equity()

        # Fetch real-time snapshots for pending candidates via Oracle API
        tickers = [c["ticker"] for c in self._pending_cands]
        snapshots = get_snapshots(tickers)

        filled_count = 0
        still_pending = []

        for cand in self._pending_cands:
            ticker = cand["ticker"]
            direction = cand["direction"]
            orb_bar = cand["orb_bar"]
            atr = cand["atr"]
            score = cand["score"]
            rvol = cand["rvol"]

            breakout_level = orb_bar["high"] if direction == "bullish" else orb_bar["low"]

            # Check if already traded today or at max simultaneous positions
            open_positions = self._state.get_open_positions(
                self._session.session_id, date_str
            )
            if any(p.ticker == ticker for p in open_positions):
                self._state.update_candidate_status(
                    self._session.session_id, date_str, ticker, "filled"
                )
                continue
            quality_max_trades = getattr(self, "_market_orb_quality_max_trades", None)
            if quality_max_trades is not None and len(open_positions) >= quality_max_trades:
                self._log(
                    f"  {ticker}: market ORB quality max_trades={quality_max_trades} "
                    f"reached ({self._market_orb_quality_reason or 'quality_gate'}) — cancelling"
                )
                self._state.update_candidate_status(
                    self._session.session_id, date_str, ticker, "cancelled"
                )
                continue
            max_sim = getattr(self._params, "max_simultaneous_entries", None)
            if max_sim is not None and len(open_positions) >= max_sim:
                still_pending.append(cand)
                continue

            # Check breakout using real-time snapshot price
            snap = snapshots.get(ticker)
            if snap is None or snap.price is None:
                still_pending.append(cand)
                continue

            current_price = snap.price
            broke_out = (
                (direction == "bullish" and current_price >= breakout_level)
                or (direction == "bearish" and current_price <= breakout_level)
            )
            if not broke_out:
                still_pending.append(cand)
                continue

            # Breakout detected — compute position size and place order
            stop_distance = atr * self._params.atr_stop_multiplier
            if stop_distance <= 0:
                still_pending.append(cand)
                continue

            day_size_scale = max(0.0, float(getattr(self, "_day_size_scale", 1.0) or 0.0))
            candidate_size_scale = self._candidate_size_scale(cand)
            sizing_capital = (
                self._compute_sizing_capital(equity)
                * day_size_scale
                * candidate_size_scale
            )
            if sizing_capital <= 0:
                self._log(
                    f"  {ticker}: day_size_scale={day_size_scale:.3f}, "
                    f"candidate_size_scale={candidate_size_scale:.3f}; sizing disabled"
                )
                self._state.update_candidate_status(
                    self._session.session_id, date_str, ticker, "cancelled"
                )
                continue
            risk_dollars = sizing_capital * self._params.risk_per_trade_pct
            shares_from_risk = risk_dollars / stop_distance

            entry_price_est = max(breakout_level, current_price)
            max_shares_by_capital = (sizing_capital * self._params.max_position_pct) / entry_price_est
            shares = int(min(shares_from_risk, max_shares_by_capital))
            if shares <= 0:
                self._log(f"  {ticker}: shares=0 after sizing — skipping")
                self._state.update_candidate_status(
                    self._session.session_id, date_str, ticker, "cancelled"
                )
                continue

            # Check buying power
            try:
                acct = self._broker.get_account()
                if acct.buying_power < shares * entry_price_est:
                    self._log(f"  {ticker}: insufficient buying power — skipping")
                    self._state.update_candidate_status(
                        self._session.session_id, date_str, ticker, "cancelled"
                    )
                    continue
            except Exception as e:
                self._log(f"  {ticker}: account check error: {e}")

            # Place order
            try:
                if direction == "bullish":
                    order = self._broker.submit_market_buy(ticker, shares)
                else:
                    order = self._broker.submit_market_sell(ticker, shares)
                self._log(
                    f"  {ticker}: {direction} breakout → {shares} shares "
                    f"(order {order.id})"
                )
                _emit(
                    "orb_engine_buy_submitted",
                    session_id=self._session.session_id,
                    ticker=ticker, direction=direction, qty=shares,
                    entry_price_est=round(entry_price_est, 4),
                    order_id=order.id,
                    category="order",
                )
            except Exception as e:
                self._log(f"  {ticker}: order failed: {e}")
                _emit(
                    "orb_engine_buy_rejected",
                    _level="error",
                    session_id=self._session.session_id,
                    ticker=ticker, direction=direction, qty=shares,
                    error=str(e),
                    category="order",
                )
                still_pending.append(cand)
                continue

            # Wait for fill (poll up to 30s)
            fill_price = entry_price_est
            order_rejected = False
            reject_status: str | None = None
            for _ in range(6):
                time.sleep(5)
                try:
                    filled_order = self._broker.get_order(order.id)
                    if filled_order.status == "filled" and filled_order.filled_avg_price:
                        fill_price = filled_order.filled_avg_price
                        break
                    if filled_order.status in ("cancelled", "rejected", "expired"):
                        self._log(f"  {ticker}: order {filled_order.status} — no position created")
                        order_rejected = True
                        reject_status = filled_order.status
                        break
                except Exception:
                    pass

            if order_rejected:
                _emit(
                    "orb_engine_buy_rejected",
                    _level="error",
                    session_id=self._session.session_id,
                    ticker=ticker, qty=shares,
                    order_id=order.id,
                    status=reject_status or "unknown",
                    category="order",
                )
                self._state.update_candidate_status(
                    self._session.session_id, date_str, ticker, "cancelled"
                )
                continue
            _emit(
                "orb_engine_buy_filled",
                session_id=self._session.session_id,
                ticker=ticker, direction=direction, qty=shares,
                fill_price=round(float(fill_price), 4),
                order_id=order.id,
                category="order",
            )

            # Record position
            initial_stop = (
                fill_price - stop_distance
                if direction == "bullish"
                else fill_price + stop_distance
            )
            pos = ORBPositionRow(
                session_id=self._session.session_id,
                date=date_str,
                ticker=ticker,
                direction="long" if direction == "bullish" else "short",
                entry_price=fill_price,
                entry_time=dt.datetime.now(_ET).isoformat(),
                shares=shares,
                orb_high=orb_bar["high"],
                orb_low=orb_bar["low"],
                atr_at_entry=atr,
                stop_distance=stop_distance,
                current_stop=initial_stop,
                peak_price=fill_price,
                rvol=rvol,
                composite_score=score,
                order_id=order.id,
            )
            self._state.save_position(pos)
            self._state.update_candidate_status(
                self._session.session_id, date_str, ticker, "filled"
            )
            filled_count += 1

            # Check kill switches
            daily_state = self._state.get_daily_state(
                self._session.session_id, date_str
            )
            if daily_state.kill_switch:
                self._log("Kill switch triggered — stopping breakout monitoring")
                _emit(
                    "orb_engine_kill_switch_triggered",
                    _level="error",
                    session_id=self._session.session_id,
                    phase="breakout",
                )
                break

        self._pending_cands = still_pending
        self._log(
            f"Breakout check: filled={filled_count}, remaining={len(still_pending)}"
        )

        return {
            "checked": len(tickers),
            "filled": filled_count,
            "remaining": len(still_pending),
        }

    # ── Phase 4: Stop Check (sim_bar_minutes checkpoints) ────────────────────

    def run_stop_check(self, date_str: str) -> dict[str, Any]:
        """Evaluate stops for all open positions using aggregated bars.

        Called every sim_bar_minutes from orb_end until 15:55 ET.
        Stop logic mirrors orb_simulator.py:477-580 exactly.
        """
        self._date_str = date_str

        positions = self._state.get_open_positions(self._session.session_id, date_str)
        if not positions:
            return {"positions_checked": 0, "stops_hit": 0}

        daily_state = self._state.get_daily_state(
            self._session.session_id, date_str
        )
        if daily_state.kill_switch:
            return {"positions_checked": 0, "stops_hit": 0, "kill_switch": True}

        today = dt.date.fromisoformat(date_str)
        market_open = dt.datetime(today.year, today.month, today.day, 9, 30, tzinfo=_ET)
        now_et = dt.datetime.now(_ET)

        tickers = [p.ticker for p in positions]
        bars_raw = self._broker.get_intraday_bars(
            tickers,
            start=market_open,
            end=now_et,
            timeframe_minutes=5,
        )


        group_size = self._params.sim_bar_minutes // 5

        stops_hit = 0
        equity = self._get_equity()

        for pos in positions:
            ticker = pos.ticker
            all_bars = bars_raw.get(ticker, [])
            mkt_bars = filter_market_hours(all_bars)

            if not mkt_bars:
                continue

            # Filter bars after entry time
            entry_ts = _parse_ts(pos.entry_time)
            post_entry = [b for b in mkt_bars if _parse_ts(b["timestamp"]) > entry_ts]

            if not post_entry:
                continue

            # Aggregate to sim_bar_minutes (e.g., 90-min)
            agg_bars = _aggregate_bars(post_entry, group_size)

            # Run stop management on each aggregated bar
            current_stop = pos.current_stop
            peak_price = pos.peak_price
            trailing_active = pos.trailing_active
            stop_distance = pos.stop_distance
            atr = pos.atr_at_entry
            exit_bar = None
            exit_reason = "close"

            use_atr_trail = self._params.trailing_stop_atr_multiplier > 0

            for bar in agg_bars:
                bar_high = bar["high"]
                bar_low = bar["low"]

                if pos.direction == "long":
                    peak_price = max(peak_price, bar_high)
                    current_r = (bar_high - pos.entry_price) / stop_distance if stop_distance > 0 else 0

                    if current_r >= self._params.breakeven_at_r and current_stop < pos.entry_price:
                        current_stop = pos.entry_price

                    if current_r >= self._params.trailing_at_r:
                        trailing_active = True

                    # Check stop hit BEFORE updating trailing
                    if bar_low <= current_stop:
                        exit_bar = bar
                        exit_reason = "trailing_stop" if trailing_active else "stop_loss"
                        break

                    # Update trailing AFTER stop check
                    if trailing_active:
                        if use_atr_trail:
                            tighten_r = getattr(self._params, "trailing_tighten_at_r", None)
                            tight_mult = getattr(self._params, "trailing_stop_atr_multiplier_tight", 0.0)
                            if (tighten_r is not None and current_r >= tighten_r and tight_mult > 0):
                                atr_mult = tight_mult
                            else:
                                atr_mult = self._params.trailing_stop_atr_multiplier
                            candidate = peak_price - atr * atr_mult
                        else:
                            candidate = max(bar_low, current_stop)
                        if candidate > current_stop:
                            current_stop = candidate

                else:  # short
                    peak_price = min(peak_price, bar_low)
                    current_r = (pos.entry_price - bar_low) / stop_distance if stop_distance > 0 else 0

                    if current_r >= self._params.breakeven_at_r and current_stop > pos.entry_price:
                        current_stop = pos.entry_price

                    if current_r >= self._params.trailing_at_r:
                        trailing_active = True

                    if bar_high >= current_stop:
                        exit_bar = bar
                        exit_reason = "trailing_stop" if trailing_active else "stop_loss"
                        break

                    if trailing_active:
                        if use_atr_trail:
                            tighten_r = getattr(self._params, "trailing_tighten_at_r", None)
                            tight_mult = getattr(self._params, "trailing_stop_atr_multiplier_tight", 0.0)
                            if (tighten_r is not None and current_r >= tighten_r and tight_mult > 0):
                                atr_mult = tight_mult
                            else:
                                atr_mult = self._params.trailing_stop_atr_multiplier
                            candidate = peak_price + atr * atr_mult
                        else:
                            candidate = min(bar_high, current_stop)
                        if candidate < current_stop:
                            current_stop = candidate

            # Update DB stop levels
            self._state.update_position_stop(
                self._session.session_id, date_str, ticker,
                current_stop, peak_price, trailing_active,
            )

            if exit_bar:
                # Close position — use qty so only this session's shares are closed
                # (other sessions may hold the same ticker in the same Alpaca account).
                exit_price = current_stop  # fallback if fill poll fails
                try:
                    close_order = self._broker.close_position(ticker, qty=int(pos.shares))
                    # Poll for actual broker fill price (captures gap-through losses)
                    for _ in range(4):
                        time.sleep(3)
                        try:
                            o = self._broker.get_order(close_order.id)
                            if o.filled_avg_price:
                                exit_price = o.filled_avg_price
                                break
                        except Exception:
                            pass
                    self._log(
                        f"  {ticker}: stop hit ({exit_reason}) @ {exit_price:.2f}"
                    )
                    _emit(
                        "orb_engine_close_filled",
                        session_id=self._session.session_id,
                        ticker=ticker, qty=int(pos.shares),
                        exit_price=round(float(exit_price), 4),
                        entry_price=round(float(pos.entry_price), 4),
                        reason="stop",
                        exit_subreason=exit_reason,
                        order_id=close_order.id,
                        category="order",
                    )
                except Exception as e:
                    self._log(f"  {ticker}: close error: {e}")
                    _emit(
                        "orb_engine_close_failed",
                        _level="error",
                        session_id=self._session.session_id,
                        ticker=ticker, qty=int(pos.shares),
                        reason="stop",
                        exit_subreason=exit_reason,
                        error=str(e),
                        category="order",
                    )

                self._record_trade(pos, exit_price, exit_bar["timestamp"], exit_reason, equity)
                stops_hit += 1

                # Update daily kill switches
                loss = (exit_price - pos.entry_price) * pos.shares
                if pos.direction == "short":
                    loss = (pos.entry_price - exit_price) * pos.shares
                if loss < 0:
                    new_cum_loss = daily_state.cumulative_loss + abs(loss)
                    new_stops = daily_state.stops_hit + 1
                    kill = (
                        new_cum_loss >= equity * self._params.daily_max_loss_pct
                        or new_stops >= self._params.max_stops_per_day
                    )
                    self._state.update_daily_state(
                        self._session.session_id, date_str,
                        cumulative_loss=new_cum_loss,
                        stops_hit=new_stops,
                        kill_switch=kill,
                    )
                    daily_state = self._state.get_daily_state(
                        self._session.session_id, date_str
                    )
                    if kill:
                        self._log("Kill switch triggered!")
                        _emit(
                            "orb_engine_kill_switch_triggered",
                            _level="error",
                            session_id=self._session.session_id,
                            phase="stop_check",
                            cumulative_loss=round(float(new_cum_loss), 2),
                            stops_hit=int(new_stops),
                        )
                        break

        self._log(f"Stop check: {len(positions)} positions, {stops_hit} stops hit")
        return {"positions_checked": len(positions), "stops_hit": stops_hit}

    # ── Phase 5: EOD Exit ─────────────────────────────────────────────────────

    def run_eod_exit(self, date_str: str) -> dict[str, Any]:
        """Close all remaining open positions at 15:55 ET."""
        self._date_str = date_str
        self._state.update_daily_state(
            self._session.session_id, date_str, phase="eod_exit"
        )

        # Cancel any unfilled breakout candidates — must run unconditionally so
        # stale pending records are cleaned up even when there are no open positions
        # (e.g., server restarted after ORB detection but before any breakout).
        for cand in self._pending_cands:
            self._state.update_candidate_status(
                self._session.session_id, date_str, cand["ticker"], "timeout"
            )
        self._pending_cands = []

        db_cands = self._state.list_candidates(self._session.session_id, date_str)
        for c in db_cands:
            if c["status"] == "pending":
                self._state.update_candidate_status(
                    self._session.session_id, date_str, c["ticker"], "timeout"
                )

        positions = self._state.get_open_positions(self._session.session_id, date_str)
        if not positions:
            self._log("EOD: no open positions")
            return {"closed": 0}

        equity = self._get_equity()
        closed = 0
        now_str = dt.datetime.now(_ET).isoformat()

        for pos in positions:
            try:
                # Use qty so only this session's shares are closed
                close_order = self._broker.close_position(pos.ticker, qty=int(pos.shares))
                exit_price = pos.entry_price
                for _ in range(4):
                    time.sleep(3)
                    try:
                        o = self._broker.get_order(close_order.id)
                        if o.filled_avg_price:
                            exit_price = o.filled_avg_price
                            break
                    except Exception:
                        pass

                self._record_trade(pos, exit_price, now_str, "close", equity)
                self._log(f"  EOD close: {pos.ticker} @ {exit_price:.2f}")
                _emit(
                    "orb_engine_close_filled",
                    session_id=self._session.session_id,
                    ticker=pos.ticker, qty=int(pos.shares),
                    exit_price=round(float(exit_price), 4),
                    entry_price=round(float(pos.entry_price), 4),
                    reason="eod",
                    order_id=close_order.id,
                    category="order",
                )
                closed += 1
            except Exception as e:
                self._log(f"  EOD close error {pos.ticker}: {e}")
                _emit(
                    "orb_engine_close_failed",
                    _level="error",
                    session_id=self._session.session_id,
                    ticker=pos.ticker, qty=int(pos.shares),
                    reason="eod",
                    error=str(e),
                    category="order",
                )
                # Mark as closed in DB anyway to prevent zombie positions
                self._state.close_position_record(
                    self._session.session_id, date_str, pos.ticker
                )

        return {"closed": closed}

    # ── Phase 6: Post-close ───────────────────────────────────────────────────

    def run_post_close(self, date_str: str) -> dict[str, Any]:
        """Record daily equity snapshot and finalize day."""
        self._date_str = date_str
        self._state.update_daily_state(
            self._session.session_id, date_str, phase="done"
        )

        trades_today = self._state.list_trades(self._session.session_id)
        today_trades = [t for t in trades_today if t["date"] == date_str]

        daily_pnl = sum(t["pnl"] for t in today_trades)
        stops_hit = sum(1 for t in today_trades if t["exit_reason"] in ("stop_loss", "trailing_stop"))

        prev_equity = self._get_equity()
        if prev_equity is None:
            prev_equity = self._session.initial_equity
        new_equity = max(prev_equity + daily_pnl, 0.01)

        # Drawdown
        peak_equity = self._state.get_peak_equity(
            self._session.session_id, self._session.initial_equity
        )
        drawdown_pct = ((new_equity - peak_equity) / peak_equity * 100) if peak_equity > 0 else 0.0

        snap = ORBDailySnapshotRow(
            session_id=self._session.session_id,
            date=date_str,
            equity=new_equity,
            daily_pnl=daily_pnl,
            total_pnl=new_equity - self._session.initial_equity,
            trades_taken=len(today_trades),
            stops_hit=stops_hit,
            drawdown_pct=drawdown_pct,
        )
        self._state.save_daily_snapshot(snap)

        self._log(
            f"Post-close: equity={new_equity:.2f}, pnl={daily_pnl:+.2f}, "
            f"trades={len(today_trades)}, stops={stops_hit}"
        )

        return {
            "equity": new_equity,
            "daily_pnl": daily_pnl,
            "trades": len(today_trades),
            "stops_hit": stops_hit,
            "drawdown_pct": drawdown_pct,
        }

    # ── Helpers ───────────────────────────────────────────────────────────────

    def _record_trade(
        self,
        pos: ORBPositionRow,
        exit_price: float,
        exit_time: str,
        exit_reason: str,
        equity: float,
    ) -> None:
        """Record a completed trade in the DB and close the position record."""
        if pos.direction == "long":
            pnl = (exit_price - pos.entry_price) * pos.shares
        else:
            pnl = (pos.entry_price - exit_price) * pos.shares

        r_multiple = (
            pnl / (pos.stop_distance * pos.shares)
            if pos.stop_distance > 0 and pos.shares > 0
            else 0.0
        )

        trade = ORBTradeRow(
            trade_id=str(uuid.uuid4())[:12],
            session_id=self._session.session_id,
            date=pos.date,
            ticker=pos.ticker,
            direction=pos.direction,
            entry_price=pos.entry_price,
            exit_price=exit_price,
            entry_time=pos.entry_time,
            exit_time=exit_time,
            shares=pos.shares,
            pnl=round(pnl, 4),
            r_multiple=round(r_multiple, 3),
            exit_reason=exit_reason,
            atr_at_entry=pos.atr_at_entry,
            rvol=pos.rvol,
            composite_score=pos.composite_score,
        )
        self._state.save_trade(trade)
        self._state.close_position_record(
            self._session.session_id, pos.date, pos.ticker
        )

    def _compute_sizing_capital(self, equity: float) -> float:
        """Replicate backtest sizing_capital formula: governor + streak multiplier.

        Mirrors libs/intraday/orb_simulator.py:2141-2187.
        V23 uses daily_budget_reset=True: base sizing = initial_equity (not equity).
        This matches the backtest 단리 mode where each day starts from $10k.
        """
        # daily_budget_reset: fixed daily budget matches V23 backtest 단리 mode
        daily_reset = getattr(self._params, "daily_budget_reset", False)
        sizing = self._session.initial_equity if daily_reset else equity

        # Drawdown governor: scale down when equity drops below peak
        gov_thresh = getattr(self._params, "drawdown_governor_threshold", None)
        gov_min = getattr(self._params, "drawdown_governor_min_scale", 0.30)
        if gov_thresh is not None:
            peak_equity = self._state.get_peak_equity(
                self._session.session_id, self._session.initial_equity
            )
            if peak_equity > 0:
                dd_pct = (peak_equity - equity) / peak_equity
                if dd_pct > gov_thresh:
                    dd_excess = dd_pct - gov_thresh
                    governor_scale = max(
                        gov_min,
                        1.0 - (1.0 - gov_min) * min(dd_excess / gov_thresh, 1.0),
                    )
                    sizing = sizing * governor_scale

        # Streak sizing: amplify after consecutive wins, reduce after consecutive losses.
        # list_trades returns DESC (newest first) — outcomes[0] = most recent trade.
        win_bonus = getattr(self._params, "streak_sizing_win_bonus", None)
        loss_penalty = getattr(self._params, "streak_sizing_loss_penalty", None)
        streak_max = getattr(self._params, "streak_sizing_max", 2.5)
        streak_min = getattr(self._params, "streak_sizing_min", 0.5)
        if win_bonus is not None or loss_penalty is not None:
            trades = self._state.list_trades(self._session.session_id)
            if trades:
                outcomes = [t["pnl"] > 0 for t in trades]  # newest first
                is_winning = outcomes[0]  # most recent outcome
                streak_len = 0
                for o in outcomes:  # count from newest
                    if o == is_winning:
                        streak_len += 1
                    else:
                        break
                streak_mult = 1.0
                if is_winning and win_bonus is not None:
                    streak_mult = 1.0 + streak_len * win_bonus
                elif not is_winning and loss_penalty is not None:
                    streak_mult = 1.0 - streak_len * loss_penalty
                streak_mult = max(streak_min, min(streak_max, streak_mult))
                sizing = sizing * streak_mult

        return sizing

    def _candidate_size_scale(self, cand: dict[str, Any]) -> float:
        """Per-candidate live sizing scale for sleeves that survive in memory/DB."""
        if not bool(getattr(self._params, "broad_gapup_continuation_enabled", False)):
            return 1.0

        is_broad_gapup = bool(cand.get("broad_gapup_continuation"))
        if not is_broad_gapup:
            max_gap = getattr(self._params, "max_gap_pct", None)
            gap_pct = cand.get("gap_pct")
            is_broad_gapup = (
                max_gap is not None
                and gap_pct is not None
                and float(gap_pct) > float(max_gap)
            )
        if not is_broad_gapup:
            return 1.0

        raw_scale = getattr(self._params, "broad_gapup_continuation_size_scale", 1.0)
        return max(0.0, min(1.0, float(raw_scale or 0.0)))

    def _rebuild_pending_candidates(self, date_str: str) -> list[dict]:
        """Reconstruct pending candidates from DB (after server restart)."""
        db_cands = self._state.list_candidates(self._session.session_id, date_str)
        open_positions = self._state.get_open_positions(
            self._session.session_id, date_str
        )
        filled_tickers = {p.ticker for p in open_positions}

        result = []
        for c in db_cands:
            if c["status"] != "pending":
                continue
            if c["ticker"] in filled_tickers:
                continue
            # Reconstruct minimal candidate dict for breakout check
            result.append({
                "ticker": c["ticker"],
                "direction": c["direction"],
                "orb_bar": {
                    "high": c["orb_high"],
                    "low": c["orb_low"],
                    "timestamp": "",
                    "open": 0, "close": 0, "volume": 0,
                },
                "atr": c["atr"],
                "rvol": c["rvol"],
                "gap_pct": c["gap_pct"],
                "score": c["composite_score"],
            })
        return result


# ── Engine factory ────────────────────────────────────────────────────────────

def make_orb_engine(
    session: Any,
    db_path: str | None = None,
    broker_override: Any = None,
    log_callback: Any = None,
) -> ORBTradingEngine:
    """Create an ORBTradingEngine for the given session.

    broker_override: pass a MockORBBroker (or any duck-typed broker) to avoid
    real Alpaca API calls during testing.
    """
    from apps.orb_trader.state import ORBStateManager
    from libs.intraday.domain import IntradayConfig
    from apps.intraday_bt.run import _load_config_yaml

    if broker_override is not None:
        broker = broker_override
    else:
        from apps.paper_trader.alpaca_broker import AlpacaBroker
        broker = AlpacaBroker.from_env()

    state = ORBStateManager(db_path)

    # Load strategy params from YAML config (resolves `extends` inheritance)
    raw = _load_config_yaml(Path(session.config_path)) or {}
    # Strip _meta and other non-model keys
    config_data = {k: v for k, v in raw.items() if not k.startswith("_")}
    config = IntradayConfig(**config_data)
    params = config.orb_strategy
    if params is None:
        from libs.intraday.domain import ORBStrategyParams
        params = ORBStrategyParams()

    # Store universe source on params for runtime use
    params._universe_source = config.universe.source
    params._universe_symbols_file = config.universe.symbols_file

    return ORBTradingEngine(
        session=session,
        broker=broker,
        state=state,
        params=params,
        log_callback=log_callback,
    )
