"""Opening Range Breakout (ORB) simulation engine.

Strategy: At 09:35 ET, identify the first 5-min candle direction.
For bullish candles (long-only V1), place a stop-buy order at the candle's high.
If filled before timeout (10:15 ET), manage position with ATR-based stops.
Exit at 15:55 ET or on stop/trailing stop.

Pure functions — no API calls, no disk I/O.
run_orb_simulation() takes pre-loaded data and enrichment, returns DayResult list.
Compatible with the existing metrics pipeline (compute_metrics, format_summary, etc.).
"""
from __future__ import annotations

import datetime as dt
from collections import deque
from dataclasses import dataclass, field
from typing import Callable
from zoneinfo import ZoneInfo

from libs.intraday.domain import DayResult, IntradayTrade, ORBStrategyParams
from libs.intraday.features import compute_rvol_approx
from libs.intraday.simulator import (
    _apply_slippage_entry,
    _apply_slippage_exit,
    _market_open_ts,
    _parse_ts,
    filter_market_hours,
)


_ET = ZoneInfo("America/New_York")
_PREMARKET_OPEN = dt.time(4, 0)
_MARKET_OPEN = dt.time(9, 30)
_MARKET_CLOSE = dt.time(16, 0)
_MIN_BARS = 5  # minimum market-hours bars required

# Doji threshold: if |close - open| / open < this, classify as doji
_DOJI_THRESHOLD = 0.001


def _compute_running_vwap(bars: list[dict], up_to_ts: dt.datetime) -> float | None:
    """Compute running VWAP from market open up to (and including) the given timestamp.

    Uses typical price = (high + low + close) / 3 for each bar.
    Returns None if no bars with volume are found.
    """
    cum_pv = 0.0
    cum_vol = 0.0
    for b in bars:
        ts = _parse_ts(b["timestamp"])
        if ts > up_to_ts:
            break
        vol = float(b.get("volume", 0) or 0)
        if vol <= 0:
            continue
        typical = (float(b["high"]) + float(b["low"]) + float(b["close"])) / 3.0
        cum_pv += typical * vol
        cum_vol += vol
    if cum_vol <= 0:
        return None
    return cum_pv / cum_vol


def _linear_scaler(
    value: float | None,
    low: float | None,
    high: float | None,
    floor: float = 1.0,
    *,
    invert: bool = False,
) -> float:
    """Linear interpolation scaler (same logic as simulator._linear_scaler)."""
    if value is None or low is None or high is None or high <= low:
        return 1.0
    floor = max(0.0, min(1.0, floor))
    if invert:
        if value <= low:
            return floor
        if value >= high:
            return 1.0
        frac = (value - low) / (high - low)
        return floor + frac * (1.0 - floor)
    if value <= low:
        return 1.0
    if value >= high:
        return floor
    frac = (value - low) / (high - low)
    return 1.0 - frac * (1.0 - floor)


def _orb_vix_size_scaler(vix_value: float | None, params: ORBStrategyParams) -> float:
    """Position-size scaler based on VIX level for ORB strategy."""
    return _linear_scaler(
        vix_value,
        params.vix_size_scale_low,
        params.vix_size_scale_high,
        params.vix_size_scale_min,
    )


def _orb_entropy_size_scaler(entropy_20d: float | None, params: ORBStrategyParams) -> float:
    """Per-candidate size scaler based on entropy_20d (from momentum strategy).
    Higher entropy → lower size. Disabled when entropy_size_scale_low is None."""
    return _linear_scaler(
        entropy_20d,
        getattr(params, "entropy_size_scale_low", None),
        getattr(params, "entropy_size_scale_high", None),
        getattr(params, "entropy_size_scale_min", 0.6),
    )


@dataclass
class ORBSimulationState:
    """Rolling ORB simulation state for chunked backtests."""

    equity: float
    ticker_last_traded: dict[str, str] = field(default_factory=dict)
    settled_cash: float | None = None
    pending_settlements: list[tuple[str, float]] = field(default_factory=list)
    recent_daily_pnl: list[float] = field(default_factory=list)
    """Recent daily PnL history for cross-chunk rolling loss filter continuity."""


# ── Bar Aggregation ───────────────────────────────────────────────────────


def _aggregate_bars(bars: list[dict], group_size: int) -> list[dict]:
    """Aggregate consecutive bars into larger intervals (e.g. 6 × 5-min → 30-min).

    Each output bar has: timestamp (from LAST bar in group — when the candle completes),
    OHLCV aggregated. Incomplete trailing groups are still emitted.
    """
    if group_size <= 1:
        return bars
    result: list[dict] = []
    for i in range(0, len(bars), group_size):
        group = bars[i : i + group_size]
        result.append({
            "timestamp": group[-1]["timestamp"],  # end of bar: when trader sees completed candle
            "open": group[0]["open"],
            "high": max(b["high"] for b in group),
            "low": min(b["low"] for b in group),
            "close": group[-1]["close"],
            "volume": sum(b.get("volume", 0) for b in group),
        })
    return result


# ── ORB Candle Classification ──────────────────────────────────────────────


def classify_orb_candle(orb_bar: dict) -> str:
    """Classify the ORB candle as 'bullish', 'bearish', or 'doji'.

    Args:
        orb_bar: The first 5-min bar dict with 'open' and 'close' keys.

    Returns:
        'bullish' if close > open (by more than doji threshold),
        'bearish' if close < open (by more than doji threshold),
        'doji' if close ≈ open.
    """
    o = orb_bar.get("open", 0)
    c = orb_bar.get("close", 0)
    if o <= 0:
        return "doji"
    diff_pct = (c - o) / o
    if diff_pct > _DOJI_THRESHOLD:
        return "bullish"
    if diff_pct < -_DOJI_THRESHOLD:
        return "bearish"
    return "doji"


# ── Composite Ranking ──────────────────────────────────────────────────────


def _normalize_scores(values: list[float]) -> list[float]:
    """Min-max normalize a list to [0, 1]. Returns zeros if all values equal."""
    if not values:
        return []
    mn, mx = min(values), max(values)
    if mx <= mn:
        return [0.5] * len(values)
    return [(v - mn) / (mx - mn) for v in values]


def _compute_composite_score(
    rvol: float,
    gap_pct: float,
    first_bar_dollar_vol: float,
    params: ORBStrategyParams,
    rvol_list: list[float],
    gap_list: list[float],
    dolvol_list: list[float],
    idx: int,
) -> float:
    """Compute normalized composite ranking score for a single candidate.

    Uses pre-normalized lists (same index) to ensure cross-candidate normalization.
    """
    # Clamp gap to positive (only care about gap-up for long-only)
    norm_rvol = rvol_list[idx]
    norm_gap = gap_list[idx]
    norm_dolvol = dolvol_list[idx]
    return (
        norm_rvol * params.weight_rvol
        + norm_gap * params.weight_gap
        + norm_dolvol * params.weight_dollar_vol
    )


def _effective_orb_engine_family(params: ORBStrategyParams) -> str:
    family = getattr(params, "engine_family", "quality_breakout") or "quality_breakout"
    if family not in {
        "classic_breakout",
        "quality_breakout",
        "compression_breakout",
        "gainers_leader",
        "leader_followthrough",
        "stocks_in_play_dual_regime",
        "orb_pullback_v1",
        "vwap_reclaim_v1",
        "hypergap_failure_v1",
    }:
        return "quality_breakout"
    return family


def _compute_premarket_dollar_vol(all_bars: list[dict], date_str: str) -> float:
    """Premarket dollar volume proxy from 04:00-09:30 ET bars on the trade date."""
    total = 0.0
    for bar in all_bars:
        ts = _parse_ts(bar["timestamp"]).astimezone(_ET)
        if ts.date().isoformat() != date_str:
            continue
        if not (_PREMARKET_OPEN <= ts.time() < _MARKET_OPEN):
            continue
        price = bar.get("close") or bar.get("open") or 0.0
        volume = bar.get("volume", 0) or 0.0
        if price > 0 and volume > 0:
            total += price * volume
    return total


# ── ORB Candidate Selection ────────────────────────────────────────────────


def compute_orb_candidates(
    bars_by_ticker: dict[str, list[dict]],
    date_str: str,
    params: ORBStrategyParams,
    enrichment: dict[str, dict[str, dict]],
    blacklisted_tickers: set[str] | None = None,
    spy_bars: list[dict] | None = None,
    ticker_sectors: dict[str, str] | None = None,
    _stats_out: dict | None = None,
    overlay_tickers: set[str] | None = None,
) -> list[dict]:
    """Identify and rank ORB candidates for a given trading day.

    Pipeline per ticker:
    1. Get market-hours bars, require >= _MIN_BARS
    2. Extract ORB candle (first bar = 9:30–9:35 ET bar)
    3. Filter by direction: bullish only (long-only V1)
    4. Apply quality filters from enrichment: price, ATR, dollar_vol
    5. Compute approximate RVOL; filter by min_rvol
    6. Compute gap% from prev_close
    7. Rank by composite score: RVOL × w + gap × w + dollar_vol × w
    8. Return top max_candidates

    Args:
        bars_by_ticker: {ticker: [bar_dict, ...]} for today.
        date_str: Today's date as 'YYYY-MM-DD'.
        params: ORB strategy parameters.
        enrichment: {ticker: {date: features}} from enrich_daily_bars().
        blacklisted_tickers: Tickers in cooldown period.
        spy_bars: SPY intraday bars for market regime filter.

    Returns:
        List of candidate dicts, sorted by composite score descending, capped at max_candidates.
        Each dict: {ticker, orb_bar, direction, rvol, gap_pct, atr, first_bar_dollar_vol, score, mkt_bars}
    """
    market_open = _market_open_ts(date_str)
    engine_family = _effective_orb_engine_family(params)

    raw_candidates: list[dict] = []

    # Filter stats — populated only when no candidates found (for diagnostics)
    _f_no_bars = _f_late = _f_price = _f_dir = _f_atr = _f_dolvol = _f_rvol = _f_gap = 0

    for ticker, all_bars in bars_by_ticker.items():
        if blacklisted_tickers and ticker in blacklisted_tickers:
            continue

        mkt_bars = filter_market_hours(all_bars)
        if len(mkt_bars) < _MIN_BARS:
            _f_no_bars += 1
            continue

        # Verify first bar is near market open (allow data irregularities up to 10 min)
        first_bar_ts = _parse_ts(mkt_bars[0]["timestamp"])
        _late_diff = abs((first_bar_ts - market_open).total_seconds() / 60)
        if _late_diff > 10:
            _f_late += 1
            continue

        # Build ORB candle: aggregate first N 5-min bars per orb_minutes setting.
        # e.g. orb_minutes=10 → merge bars 0 and 1 into a single 10-min ORB candle.
        n_orb_bars = max(1, params.orb_minutes // 5)
        if len(mkt_bars) < n_orb_bars + 1:
            _f_no_bars += 1
            continue  # not enough bars to have both ORB window and at least one trading bar
        orb_bars_raw = mkt_bars[:n_orb_bars]
        if n_orb_bars == 1:
            orb_bar = orb_bars_raw[0]
        else:
            orb_bar = {
                "timestamp": orb_bars_raw[-1]["timestamp"],  # end of ORB window
                "open": orb_bars_raw[0]["open"],
                "high": max(b["high"] for b in orb_bars_raw),
                "low": min(b["low"] for b in orb_bars_raw),
                "close": orb_bars_raw[-1]["close"],
                "volume": sum(b.get("volume", 0) or 0 for b in orb_bars_raw),
            }
        orb_vwap = None
        orb_vwap_num = 0.0
        orb_vwap_den = 0.0
        for bar in orb_bars_raw:
            bar_vwap = bar.get("vwap")
            bar_volume = float(bar.get("volume", 0) or 0)
            if bar_vwap is None or bar_volume <= 0:
                continue
            orb_vwap_num += float(bar_vwap) * bar_volume
            orb_vwap_den += bar_volume
        if orb_vwap_den > 0:
            orb_vwap = orb_vwap_num / orb_vwap_den

        # Price filter (use ORB candle open as current price)
        open_price = orb_bar.get("open", 0)
        if open_price < params.min_price:
            _f_price += 1
            continue

        # Direction filter (uses aggregated ORB candle open/close)
        direction = classify_orb_candle(orb_bar)
        followthrough_engine = engine_family in {
            "gainers_leader",
            "leader_followthrough",
            "stocks_in_play_dual_regime",
            "orb_pullback_v1",
            "hypergap_failure_v1",
        }
        allow_doji_breakout = (
            followthrough_engine
            and bool(getattr(params, "allow_doji_breakout", False))
        )
        allow_red_to_green_breakout = (
            followthrough_engine
            and bool(getattr(params, "allow_red_to_green_breakout", False))
        )
        if params.entry_direction == "long_only":
            if direction == "bearish" and not allow_red_to_green_breakout:
                _f_dir += 1
                continue
            if direction == "bearish" and allow_red_to_green_breakout:
                direction = "bullish"
            if direction == "doji" and not allow_doji_breakout:
                _f_dir += 1
                continue
            if direction == "doji" and allow_doji_breakout:
                direction = "bullish"
        elif params.entry_direction == "short_only":
            # Gap failure: only trade bearish ORB candles (gap held, then sold off in first bar)
            if direction in ("bullish", "doji"):
                _f_dir += 1
                continue
        elif direction == "doji":
            _f_dir += 1
            continue

        # Enrichment features (all computed from PRIOR bars → no lookahead)
        ticker_enrich = enrichment.get(ticker, {}).get(date_str, {})
        atr = ticker_enrich.get("atr_14")
        avg_dollar_vol = ticker_enrich.get("avg_dollar_vol_30d")
        avg_daily_vol = ticker_enrich.get("avg_daily_vol_14d")
        prev_close = ticker_enrich.get("prev_close")
        premarket_dollar_vol = _compute_premarket_dollar_vol(all_bars, date_str)

        # ATR filter
        if atr is None or atr < params.min_atr_14:
            _f_atr += 1
            continue
        if prev_close and prev_close > 0:
            atr_ratio = atr / prev_close
            if params.min_atr_pct is not None and atr_ratio < params.min_atr_pct:
                _f_atr += 1
                continue
            if params.max_atr_pct is not None and atr_ratio > params.max_atr_pct:
                _f_atr += 1
                continue

        # Dollar volume filter
        if avg_dollar_vol is None or avg_dollar_vol < params.min_avg_dollar_volume:
            _f_dolvol += 1
            continue

        # Leader/liquid overlay tickers bypass gapper-specific filters (rvol, gap, premarket).
        # They have their own quality gates applied upstream in the overlay function.
        is_overlay = overlay_tickers is not None and ticker in overlay_tickers

        orb_vol = orb_bar.get("volume", 0) or 0
        rvol = compute_rvol_approx(orb_vol, avg_daily_vol) if avg_daily_vol else None

        if not is_overlay and params.min_rvol is not None and (rvol is None or rvol < params.min_rvol):
            _f_rvol += 1
            continue

        # Gap %
        gap_pct = 0.0
        if prev_close and prev_close > 0:
            gap_pct = (open_price - prev_close) / prev_close
        abs_gap_pct = abs(gap_pct)

        used_small_gap_attention_override = False
        if not is_overlay:
            if params.min_abs_gap_pct is not None and abs_gap_pct < params.min_abs_gap_pct:
                small_gap_attention_override = (
                    followthrough_engine
                    and getattr(params, "small_gap_attention_override_premarket_dollar_vol", None) is not None
                    and premarket_dollar_vol
                    >= float(getattr(params, "small_gap_attention_override_premarket_dollar_vol"))
                )
                if small_gap_attention_override:
                    small_gap_rvol_min = getattr(params, "small_gap_attention_override_rvol", None)
                    if small_gap_rvol_min is not None and (rvol is None or rvol < float(small_gap_rvol_min)):
                        _f_gap += 1
                        continue
                    used_small_gap_attention_override = True
                else:
                    _f_gap += 1
                    continue

            # Optional max gap filter. Useful for classical ORB continuation, but typically
            # disabled in gainers/leader style engines that explicitly seek outsized movers.
            if params.max_gap_pct is not None and gap_pct > params.max_gap_pct:
                _f_gap += 1
                continue

            if (
                params.min_premarket_dollar_vol is not None
                and premarket_dollar_vol < params.min_premarket_dollar_vol
            ):
                _f_dolvol += 1
                continue

        # First-bar dollar volume (ORB window total)
        first_bar_dollar_vol = orb_vol * open_price

        # ORB candle directional conviction: how decisively did the candle move?
        # For longs: (close - open) / range; for shorts: (open - close) / range.
        # Range clamped to avoid division by zero on flat candles.
        orb_range = orb_bar["high"] - orb_bar["low"]
        orb_close = orb_bar["close"]
        orb_open_price = orb_bar["open"]
        if orb_range > 0:
            if direction == "bullish":
                body_ratio = max((orb_close - orb_open_price) / orb_range, 0.0)
            else:
                body_ratio = max((orb_open_price - orb_close) / orb_range, 0.0)
            close_location = (orb_close - orb_bar["low"]) / orb_range
        else:
            body_ratio = 0.0
            close_location = 0.5

        # ORB range quality filter: skip if range is too narrow or too wide relative to ATR
        if atr > 0 and orb_range > 0:
            orb_range_atr_ratio = orb_range / atr
            if params.orb_range_atr_min is not None and orb_range_atr_ratio < params.orb_range_atr_min:
                _f_dir += 1
                continue
            if params.orb_range_atr_max is not None and orb_range_atr_ratio > params.orb_range_atr_max:
                _f_dir += 1
                continue

        if engine_family != "classic_breakout" and body_ratio < getattr(params, "min_body_ratio", 0.0):
            _f_dir += 1
            continue
        if engine_family == "leader_followthrough" and close_location < getattr(params, "min_close_location", 0.0):
            _f_dir += 1
            continue

        # 5-day prior momentum in the direction of the breakout.
        # For longs: positive ret_5d = stock already trending up (momentum alignment).
        # For shorts: negative ret_5d = stock already trending down.
        ret_5d = ticker_enrich.get("ret_5d")
        if ret_5d is not None:
            momentum = ret_5d if direction == "bullish" else -ret_5d
        else:
            momentum = 0.0

        entropy_20d = ticker_enrich.get("entropy_20d")
        obv_slope_20 = ticker_enrich.get("obv_slope_20")
        obv_slope_5 = ticker_enrich.get("obv_slope_5")
        atr_ratio_10_60 = ticker_enrich.get("atr_ratio_10_60")
        range_compression_10_60 = ticker_enrich.get("range_compression_10_60")
        gap_zscore_20d = ticker_enrich.get("gap_zscore_20d")
        event_flag = bool(ticker_enrich.get("event_flag"))
        raw_event_types = ticker_enrich.get("event_types") or []
        event_types = [str(v) for v in raw_event_types if str(v)]
        event_score = float(ticker_enrich.get("event_score") or 0.0)
        attention_wiki_spike_10d = ticker_enrich.get("attention_wiki_spike_10d")
        attention_wiki_zscore_20d = ticker_enrich.get("attention_wiki_zscore_20d")
        attention_article_count_3d = int(ticker_enrich.get("attention_article_count_3d") or 0)
        attention_us_article_count_3d = int(ticker_enrich.get("attention_us_article_count_3d") or 0)
        attention_resolver_confidence = float(ticker_enrich.get("attention_resolver_confidence") or 0.0)

        allowed_event_types = {str(v).lower() for v in getattr(params, "allowed_event_types", []) if str(v)}
        if allowed_event_types and event_flag:
            if not any(str(event_type).lower() in allowed_event_types for event_type in event_types):
                event_flag = False
                event_score = 0.0
                event_types = []

        if engine_family == "compression_breakout":
            min_entropy = getattr(params, "min_entropy", None)
            max_entropy = getattr(params, "max_entropy", None)
            compression_ratio_max = getattr(params, "compression_ratio_max", None)
            if min_entropy is not None and (entropy_20d is None or entropy_20d < min_entropy):
                _f_rvol += 1
                continue
            if max_entropy is not None and (entropy_20d is None or entropy_20d > max_entropy):
                _f_rvol += 1
                continue
            if compression_ratio_max is not None and (
                range_compression_10_60 is None or range_compression_10_60 > compression_ratio_max
            ):
                _f_rvol += 1
                continue

        if engine_family == "gainers_leader":
            max_gzs = getattr(params, "max_gap_zscore_20d", None)
            if max_gzs is not None and (gap_zscore_20d is None or gap_zscore_20d > max_gzs):
                _f_rvol += 1
                continue
            min_obs = getattr(params, "min_obv_slope_20d", None)
            if min_obs is not None and (obv_slope_20 is None or obv_slope_20 < min_obs):
                _f_rvol += 1
                continue

        if engine_family == "stocks_in_play_dual_regime":
            if getattr(params, "require_event_flag", False) and not event_flag:
                _f_gap += 1
                continue
            if (
                getattr(params, "attention_min_wiki_spike_10d", None) is not None
                and (
                    attention_wiki_spike_10d is None
                    or attention_wiki_spike_10d < float(getattr(params, "attention_min_wiki_spike_10d"))
                )
            ):
                _f_gap += 1
                continue
            if (
                getattr(params, "attention_min_wiki_zscore_20d", None) is not None
                and (
                    attention_wiki_zscore_20d is None
                    or attention_wiki_zscore_20d < float(getattr(params, "attention_min_wiki_zscore_20d"))
                )
            ):
                _f_gap += 1
                continue
            if (
                getattr(params, "attention_min_article_count_3d", None) is not None
                and attention_article_count_3d < int(getattr(params, "attention_min_article_count_3d"))
            ):
                _f_gap += 1
                continue
            if (
                getattr(params, "attention_min_us_article_count_3d", None) is not None
                and attention_us_article_count_3d < int(getattr(params, "attention_min_us_article_count_3d"))
            ):
                _f_gap += 1
                continue
            if (
                getattr(params, "attention_min_resolver_confidence", None) is not None
                and attention_resolver_confidence < float(getattr(params, "attention_min_resolver_confidence"))
            ):
                _f_gap += 1
                continue
            if direction == "bullish":
                if close_location < getattr(params, "min_close_location", 0.0):
                    _f_dir += 1
                    continue
                if (
                    getattr(params, "require_vwap_confirmation", False)
                    and orb_vwap is not None
                    and orb_close < orb_vwap
                ):
                    _f_dir += 1
                    continue
            elif direction == "bearish":
                if not getattr(params, "allow_failed_orb_short", False):
                    _f_dir += 1
                    continue
                if gap_pct <= 0:
                    _f_gap += 1
                    continue
                if close_location > getattr(params, "max_close_location_short", 1.0):
                    _f_dir += 1
                    continue
                if (
                    getattr(params, "require_vwap_confirmation", False)
                    and orb_vwap is not None
                    and orb_close > orb_vwap
                ):
                    _f_dir += 1
                    continue
            else:
                _f_dir += 1
                continue

        raw_candidates.append({
            "ticker": ticker,
            "sector": (ticker_sectors or {}).get(ticker, "UNKNOWN"),
            "orb_bar": orb_bar,
            "direction": direction,
            "rvol": rvol,
            "gap_pct": gap_pct,
            "abs_gap_pct": abs_gap_pct,
            "atr": atr,
            "first_bar_dollar_vol": first_bar_dollar_vol,
            "premarket_dollar_vol": premarket_dollar_vol,
            "body_ratio": body_ratio,
            "close_location": close_location,
            "momentum": momentum,
            "entropy_20d": entropy_20d or 0.0,
            "obv_slope_20": obv_slope_20 if obv_slope_20 is not None else 0.0,
            "obv_slope_5": obv_slope_5 if obv_slope_5 is not None else 0.0,
            "atr_ratio_10_60": atr_ratio_10_60 or 0.0,
            "range_compression_10_60": range_compression_10_60,
            "gap_zscore_20d": gap_zscore_20d or 0.0,
            "event_flag": event_flag,
            "event_types": event_types,
            "event_score": event_score,
            "attention_wiki_spike_10d": attention_wiki_spike_10d or 0.0,
            "attention_article_count_3d": attention_article_count_3d,
            "attention_us_article_count_3d": attention_us_article_count_3d,
            "attention_resolver_confidence": attention_resolver_confidence,
            "orb_vwap": orb_vwap,
            "used_small_gap_attention_override": used_small_gap_attention_override,
            "orb_return": ((orb_close - orb_open_price) / orb_open_price) if orb_open_price > 0 else 0.0,
            "mkt_bars": mkt_bars,
        })

    _filter_stats = {
        "gap": _f_gap, "rvol": _f_rvol, "atr": _f_atr, "dolvol": _f_dolvol,
        "dir": _f_dir, "no_bars": _f_no_bars, "late": _f_late, "price": _f_price,
    }
    if _stats_out is not None:
        _stats_out.update(_filter_stats)

    if not raw_candidates:
        total = len(bars_by_ticker)
        import sys
        print(
            f"  [{date_str}] 0 ORB candidates from {total} tickers — "
            f"bearish/doji:{_f_dir} atr:{_f_atr} dolvol:{_f_dolvol} "
            f"rvol:{_f_rvol} gap>{params.max_gap_pct and f'{params.max_gap_pct*100:.0f}%' or '?'}:{_f_gap} "
            f"bars:{_f_no_bars} late:{_f_late} price:{_f_price}",
            file=sys.stderr,
        )
        return []

    if engine_family == "stocks_in_play_dual_regime":
        sector_returns: dict[tuple[str, str], list[float]] = {}
        for cand in raw_candidates:
            key = (str(cand.get("sector") or "UNKNOWN"), str(cand["direction"]))
            sector_returns.setdefault(key, []).append(float(cand.get("orb_return") or 0.0))
        filtered_candidates: list[dict] = []
        for cand in raw_candidates:
            key = (str(cand.get("sector") or "UNKNOWN"), str(cand["direction"]))
            sector_avg = (
                sum(sector_returns.get(key, [0.0])) / len(sector_returns.get(key, [0.0]))
                if sector_returns.get(key)
                else 0.0
            )
            sector_relative_strength = float(cand.get("orb_return") or 0.0) - sector_avg
            cand["sector_relative_strength"] = sector_relative_strength
            if (
                cand["direction"] == "bullish"
                and getattr(params, "min_sector_relative_strength", None) is not None
                and sector_relative_strength < float(getattr(params, "min_sector_relative_strength"))
            ):
                continue
            filtered_candidates.append(cand)
        raw_candidates = filtered_candidates

    if not raw_candidates:
        return []

    # Normalize and score
    rvol_vals = [c["rvol"] for c in raw_candidates]
    if engine_family in {"gainers_leader", "leader_followthrough", "hypergap_failure_v1"}:
        gap_vals = [c["abs_gap_pct"] for c in raw_candidates]
    else:
        gap_vals = [max(c["gap_pct"], 0.0) for c in raw_candidates]  # clip negative gaps
    dolvol_vals = [c["first_bar_dollar_vol"] for c in raw_candidates]
    premarket_dolvol_vals = [c["premarket_dollar_vol"] for c in raw_candidates]
    body_vals = [c["body_ratio"] for c in raw_candidates]
    close_location_vals = [c["close_location"] for c in raw_candidates]
    momentum_vals = [max(c["momentum"], 0.0) for c in raw_candidates]  # only reward aligned momentum
    event_vals = [c["event_score"] for c in raw_candidates]
    attention_wiki_vals = [c["attention_wiki_spike_10d"] for c in raw_candidates]
    attention_news_vals = [
        max(c["attention_article_count_3d"], c["attention_us_article_count_3d"])
        for c in raw_candidates
    ]
    entropy_vals = [c["entropy_20d"] for c in raw_candidates]
    obv_slope_vals = [c["obv_slope_20"] for c in raw_candidates]
    obv_slope5_vals = [c["obv_slope_5"] for c in raw_candidates]
    atr_ratio_vals = [c["atr_ratio_10_60"] for c in raw_candidates]
    gap_zscore_vals = [c["gap_zscore_20d"] for c in raw_candidates]
    structure_vals = [
        c["close_location"] if c["direction"] == "bullish" else 1.0 - c["close_location"]
        for c in raw_candidates
    ]

    norm_rvol = _normalize_scores(rvol_vals)
    norm_gap = _normalize_scores(gap_vals)
    norm_dolvol = _normalize_scores(dolvol_vals)
    norm_premarket_dolvol = _normalize_scores(premarket_dolvol_vals)
    norm_body = _normalize_scores(body_vals)
    norm_close_location = _normalize_scores(close_location_vals)
    norm_structure = _normalize_scores(structure_vals)
    norm_momentum = _normalize_scores(momentum_vals)
    norm_event = _normalize_scores(event_vals)
    norm_attention_wiki = _normalize_scores(attention_wiki_vals)
    norm_attention_news = _normalize_scores(attention_news_vals)
    norm_entropy = _normalize_scores(entropy_vals)
    norm_obv_slope = _normalize_scores(obv_slope_vals)
    norm_obv_slope5 = _normalize_scores(obv_slope5_vals)
    norm_atr_ratio = _normalize_scores(atr_ratio_vals)
    norm_gap_zscore = _normalize_scores(gap_zscore_vals)

    for i, cand in enumerate(raw_candidates):
        score = (
            norm_rvol[i] * params.weight_rvol
            + norm_gap[i] * params.weight_gap
            + norm_dolvol[i] * params.weight_dollar_vol
            + norm_premarket_dolvol[i] * params.weight_premarket_dollar_vol
        )
        if engine_family != "classic_breakout":
            score += norm_body[i] * params.weight_body_ratio
            score += norm_momentum[i] * params.weight_momentum
        if engine_family in {
            "gainers_leader", "leader_followthrough", "stocks_in_play_dual_regime",
            "hypergap_failure_v1",
        }:
            score += norm_structure[i] * params.weight_close_location
            score += norm_gap_zscore[i] * params.weight_gap_zscore
        if engine_family in {"stocks_in_play_dual_regime", "gainers_leader"}:
            score += norm_event[i] * params.weight_event_catalyst
        if engine_family == "stocks_in_play_dual_regime":
            score += norm_attention_wiki[i] * params.weight_attention_wiki
            score += norm_attention_news[i] * params.weight_attention_news
        if engine_family in {
            "compression_breakout", "gainers_leader", "leader_followthrough",
            "stocks_in_play_dual_regime", "hypergap_failure_v1",
        }:
            score += norm_entropy[i] * params.weight_entropy
            score += norm_obv_slope[i] * params.weight_obv_slope
            score += norm_obv_slope5[i] * params.weight_obv_slope_5
            score += norm_atr_ratio[i] * params.weight_atr_ratio
        if engine_family == "compression_breakout":
            # gap_zscore only added here for compression_breakout;
            # gainers_leader/leader_followthrough already add it above
            score += norm_gap_zscore[i] * params.weight_gap_zscore
        cand["score"] = score

    # Sort by score descending, take top N
    raw_candidates.sort(key=lambda c: c["score"], reverse=True)
    max_per_sector = getattr(params, "max_candidates_per_sector", None)
    max_small_gap_attention = getattr(params, "max_small_gap_attention_candidates", None)
    if (
        (max_per_sector is not None and max_per_sector > 0)
        or (max_small_gap_attention is not None and max_small_gap_attention >= 0)
    ):
        selected: list[dict] = []
        sector_counts: dict[str, int] = {}
        small_gap_attention_count = 0
        for cand in raw_candidates:
            sector = str(cand.get("sector") or "UNKNOWN")
            if max_per_sector is not None and max_per_sector > 0:
                if sector_counts.get(sector, 0) >= max_per_sector:
                    continue
            if (
                max_small_gap_attention is not None
                and max_small_gap_attention >= 0
                and cand.get("used_small_gap_attention_override")
            ):
                if small_gap_attention_count >= max_small_gap_attention:
                    continue
            selected.append(cand)
            if max_per_sector is not None and max_per_sector > 0:
                sector_counts[sector] = sector_counts.get(sector, 0) + 1
            if cand.get("used_small_gap_attention_override"):
                small_gap_attention_count += 1
            if len(selected) >= params.max_candidates:
                break
        return selected
    return raw_candidates[: params.max_candidates]


# ── Breakout Detection (for chronological ordering) ──────────────────────


def _find_breakout_time(
    mkt_bars: list[dict],
    orb_bar: dict,
    direction: str,
    params: ORBStrategyParams,
    date_str: str,
) -> dt.datetime | None:
    """Find the breakout time for a candidate without running the full simulation.

    Returns the timestamp of the bar where breakout occurs, or None if no breakout
    before timeout. Used to sort candidates chronologically before allocating capital.
    """
    group_size = max(1, params.sim_bar_minutes // 5)
    if group_size > 1:
        orb_ts_raw = _parse_ts(orb_bar["timestamp"])
        post_bars = [b for b in mkt_bars if _parse_ts(b["timestamp"]) > orb_ts_raw]
        post_bars = _aggregate_bars(post_bars, group_size)
    else:
        orb_ts_raw = _parse_ts(orb_bar["timestamp"])
        post_bars = [b for b in mkt_bars if _parse_ts(b["timestamp"]) > orb_ts_raw]

    market_open = _market_open_ts(date_str)
    timeout_ts = market_open + dt.timedelta(minutes=params.order_timeout_minutes)

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

    for b in post_bars:
        ts = _parse_ts(b["timestamp"])
        if ts > timeout_ts:
            return None
        if direction == "long" and b["high"] >= breakout_level:
            return ts
        if direction == "short" and b["low"] <= breakout_level:
            return ts
    return None


def _find_momentum_confirm_time(
    mkt_bars: list[dict],
    orb_bar: dict,
    params: ORBStrategyParams,
) -> tuple[dt.datetime, float] | None:
    """Find the momentum confirmation entry time for a candidate (hybrid dual-trigger).

    Confirmation logic:
    - Evaluate the first two post-ORB bars (09:40 and 09:45 close for 5-min bars).
    - Morning gain: (close_0945 - open) / open must be in [momo_min_morning_gain_pct, momo_max_morning_gain_pct].
    - Confirmation return: (close_0945 - close_0940) / close_0940 >= momo_min_confirmation_return_pct.
    - Window: confirmation bar must end within momo_confirm_window_minutes after ORB end (09:35).

    Returns (entry_timestamp, entry_price) or None if conditions not met.
    Only active when params.dual_trigger_enabled is True.
    """
    if not getattr(params, "dual_trigger_enabled", False):
        return None
    if not mkt_bars:
        return None
    open_price = mkt_bars[0]["open"]
    if not open_price:
        return None
    orb_ts_raw = _parse_ts(orb_bar["timestamp"])
    window_end = orb_ts_raw + dt.timedelta(minutes=params.momo_confirm_window_minutes)
    post_bars = [b for b in mkt_bars if _parse_ts(b["timestamp"]) > orb_ts_raw]
    if len(post_bars) < 2:
        return None
    bar_0940 = post_bars[0]
    bar_0945 = post_bars[1]
    confirm_ts = _parse_ts(bar_0945["timestamp"])
    if confirm_ts > window_end:
        return None
    close_0940 = bar_0940.get("close") or 0.0
    close_0945 = bar_0945.get("close") or 0.0
    if close_0940 <= 0 or close_0945 <= 0:
        return None
    morning_gain = (close_0945 - open_price) / open_price
    if morning_gain < params.momo_min_morning_gain_pct:
        return None
    if morning_gain > params.momo_max_morning_gain_pct:
        return None
    confirm_return = (close_0945 - close_0940) / close_0940
    if confirm_return < params.momo_min_confirmation_return_pct:
        return None
    return (confirm_ts, float(close_0945))


# ── Single Trade Simulation ────────────────────────────────────────────────


def simulate_orb_trade(
    mkt_bars: list[dict],
    orb_bar: dict,
    direction: str,
    atr: float,
    rvol: float,
    gap_pct: float,
    params: ORBStrategyParams,
    equity: float,
    date_str: str,
    ticker: str,
    available_cash: float | None = None,
    sizing_capital: float | None = None,
    score_rank_pct: float = 0.0,
    prev_close: float | None = None,
    entry_after_ts: dt.datetime | None = None,
    spy_bars: list[dict] | None = None,
    is_soft_day: bool = False,
    trigger_type: str = "orb",
    forced_entry_price: float | None = None,
    forced_entry_bar: dict | None = None,
) -> IntradayTrade | None:
    """Simulate a single ORB trade with ATR-based stops.

    Entry:
    - breakout_level = orb_bar["high"] (long) or orb_bar["low"] (short)
    - Iterate bars after ORB bar until breakout or timeout
    - Fill at max(breakout_level, bar.open) — conservative: if bar gaps above breakout,
      pay open price (worse than breakout_level)
    - If no fill by order_timeout_minutes: return None
    - trigger_type="momentum_confirm": skip breakout loop; enter at forced_entry_price/bar

    Position sizing (risk-based):
    - risk_dollars = equity × risk_per_trade_pct
    - stop_distance = atr × atr_stop_multiplier
    - shares = risk_dollars / stop_distance
    - cap: shares × entry_price ≤ equity × max_position_pct

    Stop management (bar iteration after entry):
    - initial_stop = entry_raw - stop_distance (long)
    - At +1R (breakeven_at_r): move stop to entry_raw
    - At +2R (trailing_at_r): activate trailing stop using last 3 bar lows
    - Trailing: current_stop = max(current_stop, max of last 3 bar lows)

    If sim_bar_minutes > 5, post-ORB bars are aggregated (e.g. 30-min) before iteration.

    Returns:
        IntradayTrade or None if no breakout fill before timeout.
    """
    if atr <= 0:
        return None

    effective_atr_stop_mult = (
        params.atr_stop_multiplier_weak
        if (is_soft_day and params.atr_stop_multiplier_weak is not None)
        else params.atr_stop_multiplier
    )
    effective_breakeven_at_r = (
        params.breakeven_at_r_weak
        if (is_soft_day and params.breakeven_at_r_weak is not None)
        else params.breakeven_at_r
    )
    stop_distance = atr * effective_atr_stop_mult
    if stop_distance <= 0:
        return None

    # Aggregate bars if sim_bar_minutes > 5 (e.g. 30-min bars).
    # The ORB bar (first 5-min bar) is always kept as-is; only post-ORB bars
    # are aggregated. This keeps the ORB classification on the original 5-min
    # candle while using larger bars for breakout detection and stop management.
    group_size = max(1, params.sim_bar_minutes // 5)
    raw_post_bars: list[dict] = []  # original 5-min post-ORB bars (for entry fill price)
    if group_size > 1:
        orb_ts_raw = _parse_ts(orb_bar["timestamp"])
        pre_bars = [b for b in mkt_bars if _parse_ts(b["timestamp"]) <= orb_ts_raw]
        post_bars = [b for b in mkt_bars if _parse_ts(b["timestamp"]) > orb_ts_raw]
        raw_post_bars = list(post_bars)  # save before aggregation
        post_bars = _aggregate_bars(post_bars, group_size)
        mkt_bars = pre_bars + post_bars

    market_open = _market_open_ts(date_str)
    orb_ts = _parse_ts(orb_bar["timestamp"])
    timeout_ts = market_open + dt.timedelta(minutes=params.order_timeout_minutes)

    # Exit time: market close - exit_minutes_before_close
    market_close = market_open.replace(hour=16, minute=0)
    exit_target = market_close - dt.timedelta(minutes=params.exit_minutes_before_close)

    slippage = params.slippage_bps

    # For long: breakout above ORB high; for short: below ORB low
    if direction == "long":
        breakout_level = orb_bar["high"]
    else:
        breakout_level = orb_bar["low"]

    # --- Phase 1: Wait for breakout (or use forced momentum-confirm entry) ---
    entry_bar: dict | None = None
    entry_price_raw = 0.0
    _sim_engine_family = _effective_orb_engine_family(params)

    if trigger_type == "momentum_confirm" and forced_entry_price is not None and forced_entry_bar is not None:
        # Momentum-confirmation path: entry price and bar pre-determined by the caller.
        # Skip the breakout loop entirely. Stop/trail logic is unchanged.
        entry_price_raw = forced_entry_price
        entry_bar = forced_entry_bar
    elif _sim_engine_family == "vwap_reclaim_v1":
        # VWAP Reclaim path: skip ORB breakout, scan for first bar closing above session VWAP
        # in the late-morning window [window_start_min, window_end_min] from market open.
        _vr_start = market_open + dt.timedelta(minutes=params.vwap_reclaim_window_start_min)
        _vr_end = market_open + dt.timedelta(minutes=params.vwap_reclaim_window_end_min)

        # Optional: require prior dip below VWAP (true reclaim, not a drift-above entry)
        if params.vwap_reclaim_require_prior_dip:
            _had_dip = False
            for b in mkt_bars:
                ts = _parse_ts(b["timestamp"])
                if ts >= _vr_start:
                    break
                _vwap_pre = _compute_running_vwap(mkt_bars, ts)
                if _vwap_pre is None:
                    continue
                if direction == "long" and b["close"] < _vwap_pre:
                    _had_dip = True
                    break
                elif direction == "short" and b["close"] > _vwap_pre:
                    _had_dip = True
                    break
            if not _had_dip:
                return None  # no prior dip — not a true VWAP reclaim setup

        for b in mkt_bars:
            ts = _parse_ts(b["timestamp"])
            if ts < _vr_start:
                continue
            if ts >= _vr_end or ts >= exit_target:
                break
            _vwap = _compute_running_vwap(mkt_bars, ts)
            if _vwap is None:
                continue
            _clearance = params.vwap_reclaim_min_clearance_pct
            if direction == "long" and b["close"] > _vwap * (1 + _clearance):
                entry_price_raw = b["close"]
                entry_bar = b
                break
            elif direction == "short" and b["close"] < _vwap * (1 - _clearance):
                entry_price_raw = b["close"]
                entry_bar = b
                break
    else:
        for b in mkt_bars:
            ts = _parse_ts(b["timestamp"])
            if ts <= orb_ts:
                continue  # skip ORB bar and anything before it

            # Re-entry mode: skip bars before the previous exit
            if entry_after_ts is not None and ts <= entry_after_ts:
                continue

            # Check timeout (disabled for re-entries — they happen later in the day)
            if entry_after_ts is None and ts > timeout_ts:
                return None  # no fill before timeout

            # Check breakout
            # entry_on_bar_close: require bar CLOSE above/below level (filters wick-only touches)
            use_bar_close_entry = params.entry_on_bar_close
            if direction == "long":
                bar_triggered = (
                    b["close"] >= breakout_level if use_bar_close_entry
                    else b["high"] >= breakout_level
                )
            else:
                bar_triggered = (
                    b["close"] <= breakout_level if use_bar_close_entry
                    else b["low"] <= breakout_level
                )

            if bar_triggered and direction == "long":
                if group_size > 1:
                    # Signal is only known at the END of the aggregated bar.
                    # Fill at the first 5-min bar's open after the signal bar ends —
                    # the aggregated bar's open (pre-signal) is unavailable to the trader.
                    agg_ts = _parse_ts(b["timestamp"])
                    fill_raw = next(
                        (r for r in raw_post_bars if _parse_ts(r["timestamp"]) > agg_ts), None
                    )
                    if fill_raw is None:
                        return None  # near close — no next bar available to fill
                    entry_price_raw = max(breakout_level, fill_raw["open"])
                    entry_bar = fill_raw  # entry_ts and entry_time use the fill bar
                elif use_bar_close_entry:
                    # Enter at bar close — trader waits for bar to complete
                    entry_price_raw = b["close"]
                    entry_bar = b
                else:
                    entry_price_raw = max(breakout_level, b["open"])
                    entry_bar = b
                break
            elif bar_triggered and direction == "short":
                if group_size > 1:
                    agg_ts = _parse_ts(b["timestamp"])
                    fill_raw = next(
                        (r for r in raw_post_bars if _parse_ts(r["timestamp"]) > agg_ts), None
                    )
                    if fill_raw is None:
                        return None
                    entry_price_raw = min(breakout_level, fill_raw["open"])
                    entry_bar = fill_raw
                elif use_bar_close_entry:
                    entry_price_raw = b["close"]
                    entry_bar = b
                else:
                    entry_price_raw = min(breakout_level, b["open"])
                    entry_bar = b
                break

    if entry_bar is None:
        return None  # no breakout fill

    # --- Pullback continuation entry ---
    # Instead of entering on the breakout, wait for a pullback and continuation.
    # 1. Record the breakout, then look for a bar that retraces from the post-breakout peak
    # 2. After the pullback, look for continuation (new bar making progress)
    # 3. Enter at the continuation bar close with stop at pullback extreme
    if params.pullback_entry:
        initial_breakout_bar = entry_bar
        initial_breakout_ts = _parse_ts(initial_breakout_bar["timestamp"])

        # Reset entry — we'll find a better one after pullback
        entry_bar = None
        entry_price_raw = 0.0

        post_breakout_peak = breakout_level
        pullback_extreme = breakout_level  # lowest point during pullback (long)
        pullback_found = False
        bars_after_breakout = 0

        # orb_pullback_v1: volume tracking for contraction check
        _impulse_vols: list[float] = []
        _pullback_vols: list[float] = []

        # orb_pullback_v1: impulse window cutoff — peak must form by X min from open
        _impulse_window_cutoff: dt.datetime | None = None
        if params.pullback_impulse_window_end_min is not None:
            _tdate = initial_breakout_ts.astimezone(_ET).date()
            _impulse_window_cutoff = dt.datetime(
                _tdate.year, _tdate.month, _tdate.day, 9, 30, tzinfo=_ET
            ) + dt.timedelta(minutes=params.pullback_impulse_window_end_min)

        for b in mkt_bars:
            ts = _parse_ts(b["timestamp"])
            if ts <= initial_breakout_ts:
                continue
            if ts >= exit_target:
                break  # too late in the day

            bars_after_breakout += 1
            if bars_after_breakout > params.pullback_max_bars:
                break

            if direction == "long":
                post_breakout_peak = max(post_breakout_peak, b["high"])
                move_from_breakout = post_breakout_peak - breakout_level

                if not pullback_found:
                    _impulse_vols.append(float(b.get("volume", 0) or 0))

                    # Impulse window expired — abort if peak not yet confirmed
                    if _impulse_window_cutoff is not None and ts > _impulse_window_cutoff:
                        break

                    # Look for pullback: price retraces from peak
                    if move_from_breakout > 0:
                        # Minimum impulse size gate
                        if (
                            params.pullback_impulse_min_move_atr is not None
                            and move_from_breakout < params.pullback_impulse_min_move_atr * atr
                        ):
                            continue

                        retracement = (post_breakout_peak - b["low"]) / move_from_breakout
                        depth_ok = retracement >= params.pullback_min_retracement_pct
                        if depth_ok and params.pullback_depth_max_pct is not None:
                            depth_ok = retracement <= params.pullback_depth_max_pct
                        if depth_ok:
                            pullback_found = True
                            pullback_extreme = b["low"]
                    continue

                # Pullback found — track the low and look for continuation
                _pullback_vols.append(float(b.get("volume", 0) or 0))
                pullback_extreme = min(pullback_extreme, b["low"])

                # VWAP floor: abort if pullback breaches VWAP too deeply
                if params.pullback_vwap_floor:
                    _running_vwap = _compute_running_vwap(mkt_bars, ts)
                    if _running_vwap is not None:
                        if b["low"] < _running_vwap * (1 - params.pullback_vwap_floor_tolerance_pct):
                            break

                # Continuation: bar closes green and above pullback extreme
                if b["close"] > b["open"] and b["close"] > pullback_extreme:
                    # Volume contraction gate
                    if params.pullback_volume_contraction_ratio is not None:
                        if _impulse_vols and _pullback_vols:
                            avg_imp = sum(_impulse_vols) / len(_impulse_vols)
                            avg_pb = sum(_pullback_vols) / len(_pullback_vols)
                            if avg_pb >= avg_imp * params.pullback_volume_contraction_ratio:
                                break  # no volume contraction — skip setup

                    # Reclaim rel-vol confirmation
                    if params.pullback_reclaim_confirm_rel_vol is not None:
                        _post_orb_avg = (
                            sum(_impulse_vols + _pullback_vols) / len(_impulse_vols + _pullback_vols)
                            if (_impulse_vols or _pullback_vols)
                            else 0.0
                        )
                        _bar_vol = float(b.get("volume", 0) or 0)
                        _reclaim_rvol = _bar_vol / _post_orb_avg if _post_orb_avg > 0 else 0.0
                        if _reclaim_rvol < params.pullback_reclaim_confirm_rel_vol:
                            break

                    entry_price_raw = b["close"]
                    entry_bar = b
                    # Stop assignment: legacy pullback_stop_at_low
                    if params.pullback_stop_at_low and pullback_extreme < entry_price_raw:
                        stop_distance = entry_price_raw - pullback_extreme
                    # Override: vwap_lower stop mode
                    if params.pullback_stop_mode == "vwap_lower":
                        _sv = _compute_running_vwap(mkt_bars, ts)
                        if _sv is not None and _sv < entry_price_raw:
                            _computed_sd = entry_price_raw - _sv * (1 - params.pullback_stop_vwap_buffer_pct)
                            if _computed_sd > 0:
                                stop_distance = _computed_sd
                    elif params.pullback_stop_mode == "pullback_low" and pullback_extreme < entry_price_raw:
                        stop_distance = entry_price_raw - pullback_extreme
                    break

            else:  # short
                post_breakout_peak = min(post_breakout_peak, b["low"])  # trough
                move_from_breakout = breakout_level - post_breakout_peak

                if not pullback_found:
                    _impulse_vols.append(float(b.get("volume", 0) or 0))

                    if _impulse_window_cutoff is not None and ts > _impulse_window_cutoff:
                        break

                    if move_from_breakout > 0:
                        if (
                            params.pullback_impulse_min_move_atr is not None
                            and move_from_breakout < params.pullback_impulse_min_move_atr * atr
                        ):
                            continue

                        retracement = (b["high"] - post_breakout_peak) / move_from_breakout
                        depth_ok = retracement >= params.pullback_min_retracement_pct
                        if depth_ok and params.pullback_depth_max_pct is not None:
                            depth_ok = retracement <= params.pullback_depth_max_pct
                        if depth_ok:
                            pullback_found = True
                            pullback_extreme = b["high"]
                    continue

                _pullback_vols.append(float(b.get("volume", 0) or 0))
                pullback_extreme = max(pullback_extreme, b["high"])

                if params.pullback_vwap_floor:
                    _running_vwap = _compute_running_vwap(mkt_bars, ts)
                    if _running_vwap is not None:
                        if b["high"] > _running_vwap * (1 + params.pullback_vwap_floor_tolerance_pct):
                            break

                if b["close"] < b["open"] and b["close"] < pullback_extreme:
                    if params.pullback_volume_contraction_ratio is not None:
                        if _impulse_vols and _pullback_vols:
                            avg_imp = sum(_impulse_vols) / len(_impulse_vols)
                            avg_pb = sum(_pullback_vols) / len(_pullback_vols)
                            if avg_pb >= avg_imp * params.pullback_volume_contraction_ratio:
                                break

                    entry_price_raw = b["close"]
                    entry_bar = b
                    if params.pullback_stop_at_low and pullback_extreme > entry_price_raw:
                        stop_distance = pullback_extreme - entry_price_raw
                    if params.pullback_stop_mode == "vwap_lower":
                        _sv = _compute_running_vwap(mkt_bars, ts)
                        if _sv is not None and _sv > entry_price_raw:
                            _computed_sd = _sv * (1 + params.pullback_stop_vwap_buffer_pct) - entry_price_raw
                            if _computed_sd > 0:
                                stop_distance = _computed_sd
                    elif params.pullback_stop_mode == "pullback_low" and pullback_extreme > entry_price_raw:
                        stop_distance = pullback_extreme - entry_price_raw
                    break

        if entry_bar is None:
            return None  # no pullback-continuation pattern found

    # --- VWAP-based stop override for vwap_reclaim_v1 ---
    # Tighter structural stop: distance from entry to VWAP floor instead of ATR multiple.
    # More shares per unit risk on high-gap stocks where VWAP is naturally a support floor.
    if _sim_engine_family == "vwap_reclaim_v1" and params.vwap_reclaim_stop_mode == "vwap":
        _entry_ts = _parse_ts(entry_bar["timestamp"])
        _sv = _compute_running_vwap(mkt_bars, _entry_ts)
        if _sv is not None and direction == "long" and _sv < entry_price_raw:
            _buf = params.vwap_reclaim_stop_vwap_buffer_pct
            _vwap_sd = entry_price_raw - _sv * (1 - _buf)
            if _vwap_sd > 0:
                stop_distance = _vwap_sd
        elif _sv is not None and direction == "short" and _sv > entry_price_raw:
            _buf = params.vwap_reclaim_stop_vwap_buffer_pct
            _vwap_sd = _sv * (1 + _buf) - entry_price_raw
            if _vwap_sd > 0:
                stop_distance = _vwap_sd

    # --- Breakout volume confirmation ---
    # Reject breakouts on thin volume (low conviction, likely to fail).
    # Skip for momentum_confirm trigger — volume confirmation is already embedded in
    # the morning_gain + confirmation_return gates of _find_momentum_confirm_time.
    if trigger_type == "orb" and params.min_breakout_rel_vol is not None:
        entry_vol = entry_bar.get("volume", 0) or 0
        # Average volume of all post-ORB bars (excluding ORB bar itself)
        post_orb_vols = [
            b.get("volume", 0) or 0
            for b in mkt_bars
            if _parse_ts(b["timestamp"]) > orb_ts
        ]
        avg_bar_vol = sum(post_orb_vols) / len(post_orb_vols) if post_orb_vols else 0
        if avg_bar_vol > 0 and entry_vol < avg_bar_vol * params.min_breakout_rel_vol:
            return None  # breakout bar volume too low

    # --- Position sizing (must happen before stop check so shares are known) ---
    initial_stop = (
        entry_price_raw - stop_distance if direction == "long"
        else entry_price_raw + stop_distance
    )

    # --- Confirmation bar requirement (lookahead-free) ---
    # After breakout, wait one bar. If confirmation bar closes in the right direction,
    # enter at the confirmation bar's close (the price available AFTER seeing confirmation).
    # This avoids retroactive cancellation bias — unconfirmed trades simply don't enter.
    if params.require_confirmation_bar:
        confirm_bar = None
        for b in mkt_bars:
            ts = _parse_ts(b["timestamp"])
            if ts <= _parse_ts(entry_bar["timestamp"]):
                continue
            confirm_bar = b
            break
        if confirm_bar is None:
            return None  # no bar after entry (near close)
        if direction == "long" and confirm_bar["close"] < entry_price_raw:
            return None  # confirmation failed — don't enter
        elif direction == "short" and confirm_bar["close"] > entry_price_raw:
            return None  # confirmation failed — don't enter
        # Confirmation passed — shift entry to confirmation bar's close
        # (the price available to the trader AFTER observing the confirmation)
        entry_price_raw = confirm_bar["close"]
        entry_bar = confirm_bar
        entry_ts = _parse_ts(confirm_bar["timestamp"])
        # Recalculate stop with new entry price
        initial_stop = (
            entry_price_raw - stop_distance if direction == "long"
            else entry_price_raw + stop_distance
        )

    # Use sizing_capital for position sizing (simple/compound mode).
    # sizing_capital = initial_capital when compound_returns=False, else current equity.
    cap = sizing_capital if sizing_capital is not None else equity
    # Score-based position sizing: top-ranked candidates get larger positions
    if params.score_sizing_multiplier is not None and params.score_sizing_multiplier > 1.0:
        # score_rank_pct: 1.0 = top rank, 0.0 = bottom rank
        sizing_mult = 1.0 + score_rank_pct * (params.score_sizing_multiplier - 1.0)
    else:
        sizing_mult = 1.0
    risk_dollars = cap * params.risk_per_trade_pct * sizing_mult
    # When fixed dollar stop is set, use it as the per-share risk for sizing
    effective_stop_for_sizing = params.fixed_loss_dollars if params.fixed_loss_dollars is not None else stop_distance
    shares_from_risk = risk_dollars / effective_stop_for_sizing
    max_shares_by_capital = (cap * params.max_position_pct) / entry_price_raw

    # GFV / cash account constraint: cannot deploy more than available settled cash.
    # Unsettled proceeds can buy but not same-day sell; since ORB always exits same day,
    # only settled cash is usable for new positions.
    if available_cash is not None:
        if available_cash <= 0:
            return None
        max_shares_by_cash = available_cash / entry_price_raw
        max_shares_by_capital = min(max_shares_by_capital, max_shares_by_cash)

    shares = int(min(shares_from_risk, max_shares_by_capital))  # whole shares only
    if shares <= 0:
        return None

    entry_price_filled = (
        _apply_slippage_entry(entry_price_raw, slippage) if direction == "long"
        else _apply_slippage_exit(entry_price_raw, slippage)
    )
    entry_ts = _parse_ts(entry_bar["timestamp"])

    # Same-bar stop: breakout AND stop both triggered within the same bar.
    # Only apply for 5-min bars (group_size == 1). For 30-min (or larger) bars,
    # we skip same-bar stop detection — the user only checks every N minutes,
    # so the stop is evaluated at the NEXT bar's open, not within the entry bar.
    # Also skip when entry_on_bar_close — trader enters at bar close, not exposed to intra-bar action.
    if group_size == 1 and not params.entry_on_bar_close and direction == "long" and entry_bar["low"] <= initial_stop:
        exit_price_raw = initial_stop
        exit_price = _apply_slippage_exit(exit_price_raw, slippage)
        pnl_pct = (exit_price - entry_price_filled) / entry_price_filled
        pnl = pnl_pct * (shares * entry_price_filled)
        slippage_cost = (
            abs(entry_price_filled - entry_price_raw) * shares
            + abs(exit_price - exit_price_raw) * shares
        )
        return IntradayTrade(
            date=date_str,
            ticker=ticker,
            entry_price=round(entry_price_filled, 4),
            exit_price=round(exit_price, 4),
            entry_time=entry_bar["timestamp"],
            exit_time=entry_bar["timestamp"],
            shares=round(shares, 4),
            pnl=round(pnl, 4),
            pnl_pct=round(pnl_pct, 6),
            exit_reason="stop_loss",
            morning_gain_pct=round(gap_pct, 6),
            slippage_cost=round(slippage_cost, 4),
            orb_direction=direction,
            rvol=round(rvol, 3),
            atr_at_entry=round(atr, 4),
            r_multiple_at_exit=-1.0,
            stop_level_at_exit="initial",
            trigger_type=trigger_type,
        )
    if group_size == 1 and not params.entry_on_bar_close and direction == "short" and entry_bar["high"] >= initial_stop:
        exit_price_raw = initial_stop
        exit_price = _apply_slippage_entry(exit_price_raw, slippage)
        pnl_pct = (entry_price_filled - exit_price) / entry_price_filled
        pnl = pnl_pct * (shares * entry_price_filled)
        slippage_cost = (
            abs(entry_price_filled - entry_price_raw) * shares
            + abs(exit_price - exit_price_raw) * shares
        )
        return IntradayTrade(
            date=date_str,
            ticker=ticker,
            entry_price=round(entry_price_filled, 4),
            exit_price=round(exit_price, 4),
            entry_time=entry_bar["timestamp"],
            exit_time=entry_bar["timestamp"],
            shares=round(shares, 4),
            pnl=round(pnl, 4),
            pnl_pct=round(pnl_pct, 6),
            exit_reason="stop_loss",
            morning_gain_pct=round(gap_pct, 6),
            slippage_cost=round(slippage_cost, 4),
            orb_direction=direction,
            rvol=round(rvol, 3),
            atr_at_entry=round(atr, 4),
            r_multiple_at_exit=-1.0,
            stop_level_at_exit="initial",
            trigger_type=trigger_type,
        )

    # --- Phase 2: Manage position ---
    current_stop = initial_stop
    trailing_active = False

    # Fixed dollar exit price levels — per share (e.g. +$2/share profit, -$1/share stop)
    fixed_pt_price: float | None = None
    fixed_sl_price: float | None = None
    if direction == "long":
        if params.fixed_profit_dollars is not None:
            fixed_pt_price = entry_price_raw + params.fixed_profit_dollars
        if params.fixed_loss_dollars is not None:
            fixed_sl_price = entry_price_raw - params.fixed_loss_dollars
    else:
        if params.fixed_profit_dollars is not None:
            fixed_pt_price = entry_price_raw - params.fixed_profit_dollars
        if params.fixed_loss_dollars is not None:
            fixed_sl_price = entry_price_raw + params.fixed_loss_dollars
    swing_low_window: deque[float] = deque(maxlen=3)
    peak_price = entry_price_raw  # tracks running high (long) or low (short) for ATR trailing

    exit_price_raw = entry_price_raw
    exit_time_str = entry_bar["timestamp"]
    exit_reason = "close"
    final_r = 0.0
    stop_level = "initial"  # 'initial' | 'breakeven' | 'trailing' — for diagnostics

    use_atr_trail = params.trailing_stop_atr_multiplier > 0

    # VWAP exit setup
    use_vwap_exit = params.vwap_exit_mode in ("exit", "floor")
    vwap_exit_buffer = atr * params.vwap_exit_buffer_atr

    # Max hold time exit
    max_hold_exit_ts: dt.datetime | None = None
    if params.max_hold_minutes is not None:
        max_hold_exit_ts = entry_ts + dt.timedelta(minutes=params.max_hold_minutes)

    # Gap fill: track previous close for emergency exit
    use_gap_fill_exit = params.exit_on_gap_fill and prev_close is not None and prev_close > 0

    # Track peak R for VWAP activation threshold
    peak_r = 0.0

    # Time-decay trailing: precompute decay schedule
    use_time_decay = (
        params.time_decay_start_minutes is not None
        and use_atr_trail
    )
    if use_time_decay:
        decay_start_ts = market_open + dt.timedelta(minutes=params.time_decay_start_minutes)
        decay_end_ts = exit_target  # decay completes at exit time
        decay_span = (decay_end_ts - decay_start_ts).total_seconds()
    else:
        decay_start_ts = decay_end_ts = None
        decay_span = 0.0

    # SPY intraday guard: precompute SPY open price for intraday comparison
    spy_guard_active = (
        params.spy_intraday_guard_pct is not None
        and spy_bars is not None
        and len(spy_bars) > 0
    )
    spy_open = 0.0
    if spy_guard_active:
        spy_open = spy_bars[0].get("open", 0.0) if spy_bars else 0.0

    # Partial exit state (Option B: blended single trade result)
    original_shares = shares
    remaining_shares = shares
    partial_exited = False
    partial_pnl = 0.0
    partial_exit_r_val: float | None = None
    partial_exit_slippage = 0.0

    # Pyramid state: track add-on legs separately for PnL
    pyramid_count = 0
    pyramid_legs: list[tuple[int, float, float]] = []  # (shares, entry_filled, entry_raw)
    pyramid_total_shares = 0

    for b in mkt_bars:
        ts = _parse_ts(b["timestamp"])
        if ts <= entry_ts:
            continue

        bar_open = b["open"]
        bar_high = b["high"]
        bar_low = b["low"]
        bar_close = b["close"]

        if direction == "long":
            # ── Step 0: Fixed dollar exits (override ATR when set) ──
            if fixed_sl_price is not None and bar_low <= fixed_sl_price:
                exit_price_raw = bar_open if bar_open <= fixed_sl_price else fixed_sl_price
                exit_time_str = b["timestamp"]
                exit_reason = "stop_loss"
                final_r = (exit_price_raw - entry_price_raw) / stop_distance
                break
            if fixed_pt_price is not None and bar_high >= fixed_pt_price:
                exit_price_raw = fixed_pt_price
                exit_time_str = b["timestamp"]
                exit_reason = "profit_target"
                final_r = (exit_price_raw - entry_price_raw) / stop_distance
                break

            # ── Step 1: Stop check FIRST (broker stop order model) ──
            # Check against PREVIOUS bar's stop level. If bar_low touched
            # the stop at any point, the broker fills the stop order.
            if fixed_sl_price is None and bar_low <= current_stop:
                # Gap-through: bar opened below stop → fill at bar_open (worse)
                # Normal: price crossed stop during bar → fill at stop level
                exit_price_raw = bar_open if bar_open <= current_stop else current_stop
                exit_time_str = b["timestamp"]
                exit_reason = "trailing_stop" if trailing_active else "stop_loss"
                final_r = (exit_price_raw - entry_price_raw) / stop_distance
                break

            # ── Step 1b: Gap fill protection ──
            if use_gap_fill_exit and bar_close < prev_close:
                exit_price_raw = bar_close
                exit_time_str = b["timestamp"]
                exit_reason = "gap_fill"
                final_r = (exit_price_raw - entry_price_raw) / stop_distance
                break

            # ── Step 1c: Max hold time exit ──
            if max_hold_exit_ts is not None and ts >= max_hold_exit_ts:
                exit_price_raw = bar_close
                exit_time_str = b["timestamp"]
                exit_reason = "max_hold"
                final_r = (exit_price_raw - entry_price_raw) / stop_distance
                break

            # ── Step 2: Update peak using actual bar high ──
            peak_price = max(peak_price, bar_high)

            # ── Step 3: R-multiple from close (trader sees close to decide adjustments) ──
            current_r = (bar_close - entry_price_raw) / stop_distance
            peak_r = max(peak_r, current_r)

            # ── Step 3b: VWAP exit check ──
            if use_vwap_exit and peak_r >= params.vwap_exit_after_r:
                running_vwap = _compute_running_vwap(mkt_bars, ts)
                if running_vwap is not None:
                    vwap_level = running_vwap - vwap_exit_buffer
                    if params.vwap_exit_mode == "exit" and bar_close < vwap_level:
                        exit_price_raw = bar_close
                        exit_time_str = b["timestamp"]
                        exit_reason = "vwap_exit"
                        final_r = current_r
                        break
                    elif params.vwap_exit_mode == "floor" and trailing_active:
                        # VWAP as trailing stop floor
                        if vwap_level > current_stop:
                            current_stop = vwap_level

            # ── Step 3c: Profit target exit ──
            if params.profit_target_r is not None and current_r >= params.profit_target_r:
                exit_price_raw = bar_close
                exit_time_str = b["timestamp"]
                exit_reason = "profit_target"
                final_r = current_r
                break

            # Partial exit: lock in profits at configured R-multiple
            if (
                params.partial_exit_at_r is not None
                and not partial_exited
                and current_r >= params.partial_exit_at_r
            ):
                p_shares = int(original_shares * params.partial_exit_pct)
                if p_shares > 0 and p_shares < remaining_shares:
                    p_exit_raw = bar_close
                    p_exit = _apply_slippage_exit(p_exit_raw, slippage)
                    partial_pnl = (p_exit - entry_price_filled) * p_shares
                    partial_exit_slippage = abs(p_exit - p_exit_raw) * p_shares
                    remaining_shares -= p_shares
                    partial_exited = True
                    partial_exit_r_val = current_r
                    # Protect remainder: move stop to breakeven if not already
                    if current_stop < entry_price_raw:
                        current_stop = entry_price_raw
                        stop_level = "breakeven"

            # Pyramiding: add to winning position at configured R-multiple
            if (
                params.pyramid_at_r is not None
                and pyramid_count < params.pyramid_max_adds
                and current_r >= params.pyramid_at_r * (1 + pyramid_count)
            ):
                add_shares = int(original_shares * params.pyramid_add_pct)
                if add_shares > 0:
                    p_entry_raw = bar_close
                    p_entry_filled = _apply_slippage_entry(p_entry_raw, slippage)
                    pyramid_legs.append((add_shares, p_entry_filled, p_entry_raw))
                    pyramid_total_shares += add_shares
                    pyramid_count += 1

            # Move stop to breakeven at configured R-multiple
            if current_r >= effective_breakeven_at_r and current_stop < entry_price_raw:
                current_stop = entry_price_raw
                stop_level = "breakeven"

            # Activate trailing stop at configured R-multiple
            if current_r >= params.trailing_at_r:
                trailing_active = True
                stop_level = "trailing"

            # ── Step 4: Update trailing stop for NEXT bar ──
            if trailing_active:
                if use_atr_trail:
                    # Two-stage trailing: wider trail initially, tightens at a higher R
                    atr_mult = params.trailing_stop_atr_multiplier
                    # Gap-adaptive trailing: override base multiplier based on gap size
                    if params.gap_trail_wide_threshold is not None:
                        if abs(gap_pct) > params.gap_trail_wide_threshold:
                            atr_mult = params.gap_trail_wide_atr_multiplier
                        elif params.gap_trail_tight_atr_multiplier is not None:
                            atr_mult = params.gap_trail_tight_atr_multiplier
                    if (
                        params.trailing_tighten_at_r is not None
                        and current_r >= params.trailing_tighten_at_r
                        and params.trailing_stop_atr_multiplier_tight > 0
                    ):
                        atr_mult = params.trailing_stop_atr_multiplier_tight
                    # Time-decay: linearly shrink trail width toward close
                    if use_time_decay and ts >= decay_start_ts and decay_span > 0:
                        elapsed = min((ts - decay_start_ts).total_seconds(), decay_span)
                        decay_pct = elapsed / decay_span  # 0 → 1
                        atr_mult *= 1.0 - decay_pct * (1.0 - params.time_decay_factor)
                    # SPY intraday guard: tighten trail when SPY drops from open
                    if spy_guard_active and spy_bars:
                        spy_bar = next(
                            (sb for sb in spy_bars if sb.get("timestamp") == b.get("timestamp")),
                            None,
                        )
                        if spy_bar is not None and spy_open > 0:
                            spy_change = (spy_bar["close"] - spy_open) / spy_open
                            if spy_change < params.spy_intraday_guard_pct:
                                atr_mult *= params.spy_intraday_guard_tighten
                    candidate_stop = peak_price - atr * atr_mult
                else:
                    swing_low_window.append(bar_close)
                    candidate_stop = max(swing_low_window)
                if candidate_stop > current_stop:
                    current_stop = candidate_stop

        else:  # short
            # ── Step 0: Fixed dollar exits (short) ──
            if fixed_sl_price is not None and bar_high >= fixed_sl_price:
                exit_price_raw = bar_open if bar_open >= fixed_sl_price else fixed_sl_price
                exit_time_str = b["timestamp"]
                exit_reason = "stop_loss"
                final_r = (entry_price_raw - exit_price_raw) / stop_distance
                break
            if fixed_pt_price is not None and bar_low <= fixed_pt_price:
                exit_price_raw = fixed_pt_price
                exit_time_str = b["timestamp"]
                exit_reason = "profit_target"
                final_r = (entry_price_raw - exit_price_raw) / stop_distance
                break

            # ── Step 1: Stop check FIRST ──
            if fixed_sl_price is None and bar_high >= current_stop:
                exit_price_raw = bar_open if bar_open >= current_stop else current_stop
                exit_time_str = b["timestamp"]
                exit_reason = "trailing_stop" if trailing_active else "stop_loss"
                final_r = (entry_price_raw - exit_price_raw) / stop_distance
                break

            # ── Step 1b: Gap fill protection (short: price rises above prev_close) ──
            if use_gap_fill_exit and bar_close > prev_close:
                exit_price_raw = bar_close
                exit_time_str = b["timestamp"]
                exit_reason = "gap_fill"
                final_r = (entry_price_raw - exit_price_raw) / stop_distance
                break

            # ── Step 1c: Max hold time exit ──
            if max_hold_exit_ts is not None and ts >= max_hold_exit_ts:
                exit_price_raw = bar_close
                exit_time_str = b["timestamp"]
                exit_reason = "max_hold"
                final_r = (entry_price_raw - exit_price_raw) / stop_distance
                break

            # ── Step 2: Update trough using actual bar low ──
            peak_price = min(peak_price, bar_low)

            # ── Step 3: R-multiple from close ──
            current_r = (entry_price_raw - bar_close) / stop_distance
            peak_r = max(peak_r, current_r)

            # ── Step 3b: VWAP exit check (short) ──
            if use_vwap_exit and peak_r >= params.vwap_exit_after_r:
                running_vwap = _compute_running_vwap(mkt_bars, ts)
                if running_vwap is not None:
                    vwap_level = running_vwap + vwap_exit_buffer
                    if params.vwap_exit_mode == "exit" and bar_close > vwap_level:
                        exit_price_raw = bar_close
                        exit_time_str = b["timestamp"]
                        exit_reason = "vwap_exit"
                        final_r = current_r
                        break
                    elif params.vwap_exit_mode == "floor" and trailing_active:
                        if vwap_level < current_stop:
                            current_stop = vwap_level

            # ── Step 3c: Profit target exit (short) ──
            if params.profit_target_r is not None and current_r >= params.profit_target_r:
                exit_price_raw = bar_close
                exit_time_str = b["timestamp"]
                exit_reason = "profit_target"
                final_r = current_r
                break

            # Partial exit (short)
            if (
                params.partial_exit_at_r is not None
                and not partial_exited
                and current_r >= params.partial_exit_at_r
            ):
                p_shares = int(original_shares * params.partial_exit_pct)
                if p_shares > 0 and p_shares < remaining_shares:
                    p_exit_raw = bar_close
                    p_exit = _apply_slippage_entry(p_exit_raw, slippage)
                    partial_pnl = (entry_price_filled - p_exit) * p_shares
                    partial_exit_slippage = abs(p_exit - p_exit_raw) * p_shares
                    remaining_shares -= p_shares
                    partial_exited = True
                    partial_exit_r_val = current_r
                    if current_stop > entry_price_raw:
                        current_stop = entry_price_raw
                        stop_level = "breakeven"

            # Pyramiding (short): add to winning position
            if (
                params.pyramid_at_r is not None
                and pyramid_count < params.pyramid_max_adds
                and current_r >= params.pyramid_at_r * (1 + pyramid_count)
            ):
                add_shares = int(original_shares * params.pyramid_add_pct)
                if add_shares > 0:
                    p_entry_raw = bar_close
                    p_entry_filled = _apply_slippage_exit(p_entry_raw, slippage)
                    pyramid_legs.append((add_shares, p_entry_filled, p_entry_raw))
                    pyramid_total_shares += add_shares
                    pyramid_count += 1

            if current_r >= effective_breakeven_at_r and current_stop > entry_price_raw:
                current_stop = entry_price_raw
                stop_level = "breakeven"

            if current_r >= params.trailing_at_r:
                trailing_active = True
                stop_level = "trailing"

            # ── Step 4: Update trailing stop for NEXT bar ──
            if trailing_active:
                if use_atr_trail:
                    atr_mult = params.trailing_stop_atr_multiplier
                    # Gap-adaptive trailing: override base multiplier based on gap size
                    if params.gap_trail_wide_threshold is not None:
                        if abs(gap_pct) > params.gap_trail_wide_threshold:
                            atr_mult = params.gap_trail_wide_atr_multiplier
                        elif params.gap_trail_tight_atr_multiplier is not None:
                            atr_mult = params.gap_trail_tight_atr_multiplier
                    if (
                        params.trailing_tighten_at_r is not None
                        and current_r >= params.trailing_tighten_at_r
                        and params.trailing_stop_atr_multiplier_tight > 0
                    ):
                        atr_mult = params.trailing_stop_atr_multiplier_tight
                    # Time-decay: linearly shrink trail width toward close
                    if use_time_decay and ts >= decay_start_ts and decay_span > 0:
                        elapsed = min((ts - decay_start_ts).total_seconds(), decay_span)
                        decay_pct = elapsed / decay_span
                        atr_mult *= 1.0 - decay_pct * (1.0 - params.time_decay_factor)
                    # SPY intraday guard (short): tighten trail when SPY rallies from open
                    if spy_guard_active and spy_bars:
                        spy_bar = next(
                            (sb for sb in spy_bars if sb.get("timestamp") == b.get("timestamp")),
                            None,
                        )
                        if spy_bar is not None and spy_open > 0:
                            spy_change = (spy_bar["close"] - spy_open) / spy_open
                            if spy_change > abs(params.spy_intraday_guard_pct):
                                atr_mult *= params.spy_intraday_guard_tighten
                    candidate_stop = peak_price + atr * atr_mult
                else:
                    swing_low_window.append(bar_close)
                    candidate_stop = min(swing_low_window)
                if candidate_stop < current_stop:
                    current_stop = candidate_stop

        # Time exit
        if ts >= exit_target:
            exit_price_raw = b["close"]
            exit_time_str = b["timestamp"]
            exit_reason = "close"
            if direction == "long":
                final_r = (exit_price_raw - entry_price_raw) / stop_distance
            else:
                final_r = (entry_price_raw - exit_price_raw) / stop_distance
            break

        # Running exit (last bar before exit time)
        exit_price_raw = b["close"]
        exit_time_str = b["timestamp"]
        if direction == "long":
            final_r = (exit_price_raw - entry_price_raw) / stop_distance
        else:
            final_r = (entry_price_raw - exit_price_raw) / stop_distance

    # Apply slippage to exit (on remaining original shares + pyramid shares)
    exit_price = (
        _apply_slippage_exit(exit_price_raw, slippage) if direction == "long"
        else _apply_slippage_entry(exit_price_raw, slippage)
    )

    if direction == "long":
        remainder_pnl_pct = (exit_price - entry_price_filled) / entry_price_filled
    else:
        remainder_pnl_pct = (entry_price_filled - exit_price) / entry_price_filled

    # Blended PnL: partial exit + final exit on remaining original shares
    remainder_pnl = remainder_pnl_pct * (remaining_shares * entry_price_filled)
    pnl = remainder_pnl + partial_pnl

    # Pyramid PnL: add-on legs exit at the same price as the main position
    pyr_pnl = 0.0
    pyr_entry_slippage = 0.0
    if pyramid_legs:
        for p_shares, p_entry_filled, p_entry_raw in pyramid_legs:
            if direction == "long":
                pyr_pnl += (exit_price - p_entry_filled) * p_shares
            else:
                pyr_pnl += (p_entry_filled - exit_price) * p_shares
            pyr_entry_slippage += abs(p_entry_filled - p_entry_raw) * p_shares
        pnl += pyr_pnl

    # pnl_pct as return on total deployed capital (original + pyramid)
    total_deployed_cost = original_shares * entry_price_filled + sum(
        s * e for s, e, _ in pyramid_legs
    )
    pnl_pct = pnl / total_deployed_cost if total_deployed_cost > 0 else 0.0

    entry_slippage = abs(entry_price_filled - entry_price_raw) * original_shares
    exit_slippage = abs(exit_price - exit_price_raw) * (remaining_shares + pyramid_total_shares)
    slippage_cost = entry_slippage + exit_slippage + partial_exit_slippage + pyr_entry_slippage

    return IntradayTrade(
        date=date_str,
        ticker=ticker,
        entry_price=round(entry_price_filled, 4),
        exit_price=round(exit_price, 4),
        entry_time=entry_bar["timestamp"],
        exit_time=exit_time_str,
        shares=round(original_shares, 4),
        pnl=round(pnl, 4),
        pnl_pct=round(pnl_pct, 6),
        exit_reason=exit_reason,
        morning_gain_pct=round(gap_pct, 6),  # reuse field for gap%
        slippage_cost=round(slippage_cost, 4),
        orb_direction=direction,
        rvol=round(rvol, 3),
        atr_at_entry=round(atr, 4),
        r_multiple_at_exit=round(final_r, 3),
        stop_level_at_exit=stop_level,
        partial_exit_r=round(partial_exit_r_val, 3) if partial_exit_r_val is not None else None,
        pyramid_adds=pyramid_count,
        pyramid_pnl=round(pyr_pnl, 4),
        total_capital_deployed=round(total_deployed_cost, 4),
        trigger_type=trigger_type,
    )


# ── Day Simulation ─────────────────────────────────────────────────────────


def simulate_orb_day(
    bars_by_ticker: dict[str, list[dict]],
    date_str: str,
    params: ORBStrategyParams,
    enrichment: dict[str, dict[str, dict]],
    equity: float,
    blacklisted_tickers: set[str] | None = None,
    spy_bars: list[dict] | None = None,
    ticker_sectors: dict[str, str] | None = None,
    available_cash: float | None = None,
    sizing_capital: float | None = None,
    vix_value: float | None = None,
    overlay_tickers: set[str] | None = None,
) -> DayResult:
    """Simulate one full trading day using the ORB strategy.

    1. VIX regime check — skip high-VIX days (max_vix)
    2. SPY regime check (daily gap from enrichment) — skip bad market days
    3. compute_orb_candidates — filter and rank candidates
    4. If fewer than min_candidates_to_trade → skip day
    5. For each candidate: simulate_orb_trade (with VIX size scaling)
    6. Apply daily loss limit and max-stops kill switch

    Args:
        bars_by_ticker: {ticker: [bars]} for this day.
        date_str: Trading date 'YYYY-MM-DD'.
        params: ORB strategy parameters.
        enrichment: Pre-computed features from enrich_daily_bars().
        equity: Current portfolio equity (for risk-based sizing).
        blacklisted_tickers: Tickers in cooldown.
        spy_bars: SPY bars (unused — regime check now uses enrichment).
        vix_value: Previous close VIX for this trading day (None if unavailable).

    Returns:
        DayResult compatible with compute_metrics().
    """
    result = DayResult(date=date_str)

    # VIX regime check: skip the entire day if VIX is too high.
    # vix_value is the prior close VIX (lookahead-free).
    if params.max_vix is not None and vix_value is not None:
        if vix_value > params.max_vix:
            result.skip_reason = "vix_gate"
            return result  # skip high-VIX days

    # VIX position size scaler (applied to sizing_capital later)
    vix_scaler = _orb_vix_size_scaler(vix_value, params)

    # Market regime check: index ETF daily gap (lookahead-free via enrichment)
    regime_scaler = 1.0
    if params.market_regime_spy_threshold is not None or params.regime_size_scale_low is not None:
        regime_ticker = getattr(params, "market_regime_ticker", None) or "SPY"
        regime_enrich = 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
            # Hard skip floor (V20 param or V19 legacy threshold)
            if params.regime_skip_below is not None and regime_gap < params.regime_skip_below:
                result.skip_reason = "market_regime"
                return result
            if (params.regime_size_scale_low is None
                    and params.market_regime_spy_threshold is not None
                    and regime_gap < params.market_regime_spy_threshold):
                result.skip_reason = "market_regime"
                return result
            # Soft scaler (V20 path)
            if params.regime_size_scale_low is not None and params.regime_size_scale_high is not None:
                regime_scaler = _linear_scaler(
                    regime_gap,
                    params.regime_size_scale_low,
                    params.regime_size_scale_high,
                    params.regime_size_scale_min,
                    invert=True,
                )

    # Candidate breadth filter
    breadth_scaler = 1.0
    min_breadth = getattr(params, "min_candidate_breadth", None)
    if min_breadth is not None or params.breadth_size_scale_low is not None:
        pos_gap_count = 0
        total_with_data = 0
        for ticker in bars_by_ticker:
            t_enrich = 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
            if params.breadth_skip_below is not None and breadth_ratio < params.breadth_skip_below:
                result.skip_reason = "breadth"
                return result
            if (params.breadth_size_scale_low is None
                    and min_breadth is not None
                    and breadth_ratio < min_breadth):
                result.skip_reason = "breadth"
                return result
            if params.breadth_size_scale_low is not None and params.breadth_size_scale_high is not None:
                breadth_scaler = _linear_scaler(
                    breadth_ratio,
                    params.breadth_size_scale_low,
                    params.breadth_size_scale_high,
                    params.breadth_size_scale_min,
                    invert=True,
                )

    combined_scaler = regime_scaler * breadth_scaler
    is_soft_day = combined_scaler < params.soft_day_scaler_threshold
    result.regime_scaler = regime_scaler
    result.breadth_scaler = breadth_scaler
    result.is_soft_day = is_soft_day

    _cand_stats: dict = {}
    candidates = compute_orb_candidates(
        bars_by_ticker,
        date_str,
        params,
        enrichment,
        blacklisted_tickers=blacklisted_tickers,
        spy_bars=None,  # handled above via enrichment
        ticker_sectors=ticker_sectors,
        _stats_out=_cand_stats,
        overlay_tickers=overlay_tickers,
    )
    result.candidates_found = len(candidates)
    result.candidate_filter_stats = _cand_stats if _cand_stats else None

    if len(candidates) < params.min_candidates_to_trade:
        result.skip_reason = "no_candidates" if len(candidates) == 0 else "below_min_candidates"
        return result

    # ── Pass 1: Find entry times for all candidates ──
    # Determines chronological order BEFORE allocating capital, so earlier entries
    # get capital first regardless of composite score ranking.
    # Each item: (entry_ts, cand, direction_str, trigger_type, forced_entry_price | None)
    timed_candidates: list[tuple[dt.datetime, dict, str, str, float | None]] = []
    for cand in candidates:
        direction_str = (
            "long" if cand["direction"] == "bullish"
            else "short" if cand["direction"] == "bearish"
            else cand["direction"]
        )
        breakout_ts = _find_breakout_time(
            cand["mkt_bars"], cand["orb_bar"], direction_str, params, date_str
        )
        momo_result = (
            _find_momentum_confirm_time(cand["mkt_bars"], cand["orb_bar"], params)
            if getattr(params, "dual_trigger_enabled", False) else None
        )
        if breakout_ts is not None and (momo_result is None or breakout_ts <= momo_result[0]):
            timed_candidates.append((breakout_ts, cand, direction_str, "orb", None))
        elif momo_result is not None:
            timed_candidates.append((momo_result[0], cand, direction_str, "momentum_confirm", momo_result[1]))

    # Sort by entry time ascending (earliest fills first)
    timed_candidates.sort(key=lambda x: x[0])

    # ── Pass 2: Simulate in chronological order with capital constraints ──
    sizing_cap = sizing_capital if sizing_capital is not None else equity
    # Apply VIX + regime/breadth scalers to sizing capital
    combined_size_mult = vix_scaler * combined_scaler
    adjusted_sizing = sizing_cap * combined_size_mult if combined_size_mult < 1.0 else sizing_cap
    daily_loss_limit = sizing_cap * params.daily_max_loss_pct

    remaining_cash = available_cash  # None → no constraint (settlement_days=0)
    result.available_cash_start = available_cash if available_cash is not None else equity
    skipped_cash = 0
    entries_at_ts: dict[str, int] = {}  # timestamp_str → entries taken at that bar
    total_deployed = 0.0  # cumulative deployed capital for deployment cap
    max_deploy = (
        sizing_cap * params.max_total_deployment_pct
        if params.max_total_deployment_pct is not None
        else None
    )

    for idx, (entry_ts_pass2, cand, direction_str, trigger_type_pass2, forced_price_pass2) in enumerate(timed_candidates):
        # Kill switch: only count losses from trades that have ALREADY EXITED
        # before this entry time (exit-time-aware accounting).
        realized_loss = sum(
            abs(t.pnl)
            for t in result.trades
            if t.pnl < 0 and _parse_ts(t.exit_time) <= entry_ts_pass2
        )
        realized_stops = sum(
            1
            for t in result.trades
            if t.exit_reason == "stop_loss"
            and t.r_multiple_at_exit is not None
            and t.r_multiple_at_exit <= -0.8
            and _parse_ts(t.exit_time) <= entry_ts_pass2
        )
        if realized_loss >= daily_loss_limit:
            break
        if realized_stops >= params.max_stops_per_day:
            break

        # Simultaneous-entry cap: limit correlated risk when all candidates enter
        # on the same bar. Top-ranked candidates are taken first because timed_candidates
        # is sorted by entry_ts (ties preserve ranking order).
        if params.max_simultaneous_entries is not None:
            ts_key = entry_ts_pass2.isoformat()
            if entries_at_ts.get(ts_key, 0) >= params.max_simultaneous_entries:
                continue

        # Cash exhaustion
        if remaining_cash is not None and remaining_cash <= 0:
            skipped_cash += len(timed_candidates) - idx
            break

        # Portfolio deployment cap
        if max_deploy is not None and total_deployed >= max_deploy:
            continue

        # Score rank percentage: 1.0 = top ranked, 0.0 = bottom ranked
        n_cands = len(candidates)
        cand_rank = next(
            (i for i, c in enumerate(candidates) if c["ticker"] == cand["ticker"]),
            n_cands - 1,
        )
        score_rank_pct = 1.0 - (cand_rank / max(n_cands - 1, 1))

        # Soft-day selection gates
        if is_soft_day:
            if params.soft_day_max_trades is not None and len(result.trades) >= params.soft_day_max_trades:
                continue
            if params.soft_day_min_score_pct is not None and score_rank_pct < params.soft_day_min_score_pct:
                continue

        # Previous close + entropy for gap fill protection and per-candidate size scaling
        ticker_enrich = enrichment.get(cand["ticker"], {}).get(date_str, {})
        cand_prev_close = ticker_enrich.get("prev_close")

        # Per-candidate entropy size scaler (from momentum strategy)
        entropy_20d = ticker_enrich.get("entropy_20d")
        entropy_scaler = (
            _orb_entropy_size_scaler(entropy_20d, params)
            if getattr(params, "entropy_size_scale_low", None) is not None else 1.0
        )
        base_sizing = adjusted_sizing if combined_size_mult < 1.0 else sizing_capital
        cand_sizing = (base_sizing * entropy_scaler) if entropy_scaler < 1.0 else base_sizing

        # Forced entry bar for momentum_confirm trigger (09:45 bar)
        forced_entry_bar_pass2: dict | None = None
        if trigger_type_pass2 == "momentum_confirm" and forced_price_pass2 is not None:
            orb_ts_raw = _parse_ts(cand["orb_bar"]["timestamp"])
            post_bars = [b for b in cand["mkt_bars"] if _parse_ts(b["timestamp"]) > orb_ts_raw]
            forced_entry_bar_pass2 = post_bars[1] if len(post_bars) >= 2 else None

        trade = simulate_orb_trade(
            mkt_bars=cand["mkt_bars"],
            orb_bar=cand["orb_bar"],
            direction=direction_str,
            atr=cand["atr"],
            rvol=cand["rvol"],
            gap_pct=cand["gap_pct"],
            params=params,
            equity=equity,
            date_str=date_str,
            ticker=cand["ticker"],
            available_cash=remaining_cash,
            sizing_capital=cand_sizing,
            score_rank_pct=score_rank_pct,
            prev_close=cand_prev_close,
            spy_bars=spy_bars,
            is_soft_day=is_soft_day,
            trigger_type=trigger_type_pass2,
            forced_entry_price=forced_price_pass2,
            forced_entry_bar=forced_entry_bar_pass2,
        )

        if trade is None:
            continue

        result.trades.append(trade)
        result.daily_pnl += trade.pnl

        # Track simultaneous entries count
        ts_key = entry_ts_pass2.isoformat()
        entries_at_ts[ts_key] = entries_at_ts.get(ts_key, 0) + 1

        # Track total deployed capital (original + pyramid)
        trade_deployed = trade.total_capital_deployed or (trade.shares * trade.entry_price)
        total_deployed += trade_deployed

        # Deduct deployed capital from remaining settled cash
        if remaining_cash is not None:
            remaining_cash -= trade_deployed

    # ── Pass 3 (optional): Re-entry after stop-out ──
    if params.reentry_after_stop:
        stopped_trades = [
            t for t in result.trades
            if t.exit_reason == "stop_loss" and not t.is_reentry
        ]
        for stopped_trade in stopped_trades:
            # Check re-entry count for this ticker
            reentries_done = sum(
                1 for t in result.trades if t.ticker == stopped_trade.ticker and t.is_reentry
            )
            if reentries_done >= params.reentry_max_per_ticker:
                continue

            # Deployment cap check
            if max_deploy is not None and total_deployed >= max_deploy:
                break

            # Find original candidate data
            cand_match = next(
                ((c, d) for _, c, d, *_ in timed_candidates if c["ticker"] == stopped_trade.ticker),
                None,
            )
            if cand_match is None:
                continue
            cand, direction_str = cand_match

            # Score rank for re-entry (same as original)
            n_cands = len(candidates)
            cand_rank = next(
                (i for i, c in enumerate(candidates) if c["ticker"] == cand["ticker"]),
                n_cands - 1,
            )
            score_rank_pct = 1.0 - (cand_rank / max(n_cands - 1, 1))

            ticker_enrich = enrichment.get(cand["ticker"], {}).get(date_str, {})
            cand_prev_close = ticker_enrich.get("prev_close")

            exit_ts = _parse_ts(stopped_trade.exit_time)
            reentry_trade = simulate_orb_trade(
                mkt_bars=cand["mkt_bars"],
                orb_bar=cand["orb_bar"],
                direction=direction_str,
                atr=cand["atr"],
                rvol=cand["rvol"],
                gap_pct=cand["gap_pct"],
                params=params,
                equity=equity,
                date_str=date_str,
                ticker=cand["ticker"],
                available_cash=remaining_cash,
                sizing_capital=adjusted_sizing if combined_size_mult < 1.0 else sizing_capital,
                score_rank_pct=score_rank_pct,
                prev_close=cand_prev_close,
                entry_after_ts=exit_ts,
                spy_bars=spy_bars,
                is_soft_day=is_soft_day,
            )

            if reentry_trade is not None:
                reentry_trade.is_reentry = True
                result.trades.append(reentry_trade)
                result.daily_pnl += reentry_trade.pnl
                re_deployed = reentry_trade.total_capital_deployed or (
                    reentry_trade.shares * reentry_trade.entry_price
                )
                total_deployed += re_deployed
                if remaining_cash is not None:
                    remaining_cash -= re_deployed

    result.skipped_insufficient_cash = skipped_cash
    result.capital_deployed = sum(
        t.total_capital_deployed or (t.shares * t.entry_price) for t in result.trades
    )
    # Note: daily_return_pct is set by run_orb_simulation (portfolio-level: PnL/equity).
    # Default 0.0 is correct for no-trade days.

    return result


# ── Full Backtest Simulation ───────────────────────────────────────────────


def run_orb_simulation_with_state(
    all_intraday: dict[str, dict[str, list[dict]]],
    trading_days: list[str],
    params: ORBStrategyParams,
    enrichment: dict[str, dict[str, dict]],
    ticker_sectors: dict[str, str] | None = None,
    state: ORBSimulationState | None = None,
    progress_callback: Callable[[int, int], None] | None = None,
    vix_by_day: dict[str, float] | None = None,
    overlay_tickers_per_day: dict[str, set[str]] | None = None,
) -> tuple[list[DayResult], ORBSimulationState]:
    """Run the full ORB backtest simulation across all trading days.

    Key differences from run_simulation() (momentum):
    - Uses compounding equity (position sizing depends on current equity)
    - ATR-based stop loss (dynamic, not fixed %)
    - Breakout entry (conditional, can miss)
    - RVOL + gap composite ranking

    Pure computation — no API calls, no disk I/O.
    Safe to call repeatedly with different params for sweep mode.

    Args:
        all_intraday: {date: {ticker: [bars]}} — pre-loaded intraday data.
        trading_days: Ordered list of dates to simulate.
        params: ORB strategy parameters.
        enrichment: {ticker: {date: features}} from enrich_daily_bars().

    Returns:
        (day_results, next_state) where next_state can be fed into the next chunk.
    """
    results: list[DayResult] = []
    equity = state.equity if state is not None else params.initial_capital

    # Ticker cooldown tracker
    ticker_last_traded: dict[str, dt.date] = (
        {ticker: dt.date.fromisoformat(last_date) for ticker, last_date in state.ticker_last_traded.items()}
        if state is not None else {}
    )

    # GFV / settlement tracking (only active when settlement_days > 0)
    # settled_cash: funds available for new day-trade positions (GFV-safe)
    # pending_settlements: (settlement_date_str, amount) — proceeds awaiting settlement
    settlement_enabled = params.settlement_days > 0
    settled_cash = (
        state.settled_cash
        if state is not None and state.settled_cash is not None
        else params.initial_capital
    )
    pending_settlements: list[tuple[str, float]] = (
        list(state.pending_settlements) if state is not None else []
    )

    # Rolling PnL window: persists cross-chunk daily PnL history for rolling loss filter.
    # Trimmed to rolling_loss_days length so memory stays bounded.
    rolling_pnl_window: list[float] = list(state.recent_daily_pnl) if state is not None else []

    # Drawdown governor: track peak equity to detect drawdowns
    peak_equity = equity

    # Streak sizing: track recent trade outcomes for streak-based sizing
    streak_outcomes: list[bool] = []  # True = win, False = loss (from recent trades)

    total_days = len(trading_days)
    for day_idx, date_str in enumerate(trading_days):
        if progress_callback:
            progress_callback(day_idx + 1, total_days)
        bars_by_ticker = all_intraday.get(date_str)
        if not bars_by_ticker:
            # No intraday data for this day — still record it (0% return, no trades)
            results.append(DayResult(date=date_str))
            rolling_pnl_window.append(0.0)
            continue

        # Step 1: Move proceeds that have reached their settlement date into settled_cash
        if settlement_enabled:
            still_pending = []
            for settle_date, amount in pending_settlements:
                if settle_date <= date_str:
                    settled_cash += amount
                else:
                    still_pending.append((settle_date, amount))
            pending_settlements = still_pending

        # Build blacklist from cooldown
        blacklisted: set[str] = set()
        if params.ticker_cooldown_days > 0:
            current_date = dt.date.fromisoformat(date_str)
            for ticker, last_dt in ticker_last_traded.items():
                if (current_date - last_dt).days <= params.ticker_cooldown_days:
                    blacklisted.add(ticker)

        # Extract SPY bars for regime filter
        spy_bars = (
            bars_by_ticker.get("SPY")
            if params.market_regime_spy_threshold is not None
            else None
        )

        # Rolling strategy loss filter: pause trading after sustained self-drawdown.
        # Uses rolling_pnl_window which persists across chunk boundaries (unlike results[]).
        if (
            params.rolling_loss_days is not None
            and params.rolling_loss_threshold is not None
            and len(rolling_pnl_window) >= params.rolling_loss_days
        ):
            n_roll = params.rolling_loss_days
            rolling_pnl = sum(rolling_pnl_window[-n_roll:])
            if params.daily_budget_reset or not params.compound_returns:
                sizing_capital_for_check = params.initial_capital
            else:
                sizing_capital_for_check = equity
            if sizing_capital_for_check > 0:
                rolling_return = rolling_pnl / sizing_capital_for_check
                if rolling_return < params.rolling_loss_threshold:
                    results.append(DayResult(date=date_str, skip_reason="rolling_loss"))
                    rolling_pnl_window.append(0.0)
                    if settlement_enabled:
                        still_pending = []
                        for settle_date, amount in pending_settlements:
                            if settle_date <= date_str:
                                settled_cash += amount
                            else:
                                still_pending.append((settle_date, amount))
                        pending_settlements = still_pending
                    continue

        # Multi-day SPY trend filter: skip if SPY is in a sustained downtrend
        # Uses enrichment[spy_ticker][date]["prev_close"] for N-day cumulative return.
        # enrichment[D]["prev_close"] = close of trading day before D.
        # N-day return = (close_yesterday - close_N_days_ago) / close_N_days_ago
        #   = (enrich[today]["prev_close"] - enrich[trading_days[day_idx-N+1]]["prev_close"])
        #     / enrich[trading_days[day_idx-N+1]]["prev_close"]
        if (
            params.market_regime_spy_trend_days is not None
            and params.market_regime_spy_trend_threshold is not None
            and day_idx >= params.market_regime_spy_trend_days
        ):
            spy_trend_ticker = getattr(params, "market_regime_ticker", None) or "SPY"
            spy_enrich = enrichment.get(spy_trend_ticker, {})
            close_yesterday = spy_enrich.get(date_str, {}).get("prev_close")
            n_days_back = params.market_regime_spy_trend_days
            look_back_date = trading_days[day_idx - n_days_back + 1]
            close_n_ago = spy_enrich.get(look_back_date, {}).get("prev_close")
            if close_yesterday and close_n_ago and close_n_ago > 0:
                spy_trend_return = (close_yesterday - close_n_ago) / close_n_ago
                if spy_trend_return < params.market_regime_spy_trend_threshold:
                    results.append(DayResult(date=date_str, skip_reason="spy_trend"))
                    rolling_pnl_window.append(0.0)
                    if settlement_enabled:
                        still_pending = []
                        for settle_date, amount in pending_settlements:
                            if settle_date <= date_str:
                                settled_cash += amount
                            else:
                                still_pending.append((settle_date, amount))
                        pending_settlements = still_pending
                    continue

        available_cash = settled_cash if settlement_enabled else None

        # Sizing mode resolution:
        # - daily_budget_reset: research mode, always initial_capital (ignores path)
        # - compound_returns: sizing_capital=None → simulate_orb_trade uses equity
        # - simple: fixed initial_capital
        if params.daily_budget_reset:
            sizing_capital = params.initial_capital
        elif params.compound_returns:
            sizing_capital = None
        else:
            sizing_capital = params.initial_capital

        # Drawdown governor: scale down sizing when equity drops below peak
        if params.drawdown_governor_threshold is not None and peak_equity > 0:
            dd_pct = (peak_equity - equity) / peak_equity  # 0.0 = at peak, 0.05 = 5% DD
            if dd_pct > params.drawdown_governor_threshold:
                # Linear ramp from 1.0 at threshold to min_scale at 2× threshold
                dd_range = params.drawdown_governor_threshold  # same width for the ramp
                dd_excess = dd_pct - params.drawdown_governor_threshold
                governor_scale = max(
                    params.drawdown_governor_min_scale,
                    1.0 - (1.0 - params.drawdown_governor_min_scale) * min(dd_excess / dd_range, 1.0),
                )
                if sizing_capital is not None:
                    sizing_capital = sizing_capital * governor_scale
                else:
                    # compound mode: scale equity for sizing
                    sizing_capital = equity * governor_scale

        # Streak sizing: apply win/loss streak multiplier after governor
        if (params.streak_sizing_win_bonus is not None or params.streak_sizing_loss_penalty is not None) and streak_outcomes:
            # Count consecutive wins or losses from the END of the list
            streak_len = 0
            is_winning = streak_outcomes[-1]
            for outcome in reversed(streak_outcomes):
                if outcome == is_winning:
                    streak_len += 1
                else:
                    break

            streak_mult = 1.0
            if is_winning and params.streak_sizing_win_bonus is not None:
                streak_mult = 1.0 + streak_len * params.streak_sizing_win_bonus
            elif not is_winning and params.streak_sizing_loss_penalty is not None:
                streak_mult = 1.0 - streak_len * params.streak_sizing_loss_penalty

            streak_mult = max(params.streak_sizing_min, min(params.streak_sizing_max, streak_mult))

            if sizing_capital is not None:
                sizing_capital = sizing_capital * streak_mult
            else:
                sizing_capital = equity * streak_mult

        # Rolling WR sizing: apply bonus/penalty based on recent win rate
        if params.rolling_wr_sizing_window is not None and len(streak_outcomes) >= params.rolling_wr_sizing_window:
            recent = streak_outcomes[-params.rolling_wr_sizing_window:]
            rolling_wr = sum(recent) / len(recent)

            wr_mult = 1.0
            if rolling_wr > params.rolling_wr_sizing_threshold:
                wr_mult = 1.0 + params.rolling_wr_sizing_bonus
            elif params.rolling_wr_sizing_penalty_threshold is not None and rolling_wr < params.rolling_wr_sizing_penalty_threshold:
                wr_mult = 1.0 - params.rolling_wr_sizing_penalty

            if wr_mult != 1.0:
                if sizing_capital is not None:
                    sizing_capital = sizing_capital * wr_mult
                else:
                    sizing_capital = equity * wr_mult

        # Single-trade loss cap: after all boosts, clamp sizing_capital so that
        # a single -1R trade cannot lose more than single_trade_loss_cap_pct × initial_capital.
        # Fixes streak_sizing_max amplifying losses beyond daily_max_loss_pct intent.
        if (
            params.single_trade_loss_cap_pct is not None
            and params.risk_per_trade_pct > 0
            and params.initial_capital > 0
        ):
            max_risk = params.single_trade_loss_cap_pct * params.initial_capital
            max_sizing = max_risk / params.risk_per_trade_pct
            if sizing_capital is not None:
                sizing_capital = min(sizing_capital, max_sizing)
            else:
                sizing_capital = min(equity, max_sizing)

        day_vix = vix_by_day.get(date_str) if vix_by_day else None

        day_result = simulate_orb_day(
            bars_by_ticker,
            date_str,
            params,
            enrichment,
            equity=equity,
            blacklisted_tickers=blacklisted if blacklisted else None,
            spy_bars=spy_bars,
            ticker_sectors=ticker_sectors,
            available_cash=available_cash,
            sizing_capital=sizing_capital,
            vix_value=day_vix,
            overlay_tickers=overlay_tickers_per_day.get(date_str) if overlay_tickers_per_day else None,
        )
        # Override daily_return_pct with portfolio-level return (PnL / equity at start of day).
        # simulate_orb_day uses deployed capital as denominator — that inflates returns.
        # Portfolio return properly reflects capital sitting idle on low-activity days.
        if equity > 0:
            day_result.daily_return_pct = day_result.daily_pnl / equity

        results.append(day_result)
        rolling_pnl_window.append(day_result.daily_pnl)

        # Update streak outcomes from today's trades
        for trade in day_result.trades:
            streak_outcomes.append(trade.pnl > 0)
        # Keep only last 20 outcomes to bound memory
        if len(streak_outcomes) > 20:
            streak_outcomes = streak_outcomes[-20:]

        # Update equity
        equity += day_result.daily_pnl
        equity = max(equity, 1.0)  # prevent zero/negative equity from crashing
        peak_equity = max(peak_equity, equity)

        # Step 2: After the day, deduct deployed capital and schedule proceeds for settlement
        if settlement_enabled:
            deployed = day_result.capital_deployed
            settled_cash -= deployed  # cash is now deployed (unsettled until proceeds settle)

            # Sale proceeds = cost basis + P&L; schedule settlement T+N trading days out
            proceeds = deployed + day_result.daily_pnl
            if proceeds > 0:
                settle_idx = day_idx + params.settlement_days
                if settle_idx < len(trading_days):
                    pending_settlements.append((trading_days[settle_idx], proceeds))
                else:
                    # Settlement date falls beyond simulation window; credit immediately
                    settled_cash += proceeds

        if params.ticker_cooldown_days > 0:
            current_date = dt.date.fromisoformat(date_str)
            for trade in day_result.trades:
                ticker_last_traded[trade.ticker] = current_date

    max_roll = params.rolling_loss_days or 0
    next_state = ORBSimulationState(
        equity=equity,
        ticker_last_traded={
            ticker: last_dt.isoformat() for ticker, last_dt in ticker_last_traded.items()
        },
        settled_cash=settled_cash if settlement_enabled else None,
        pending_settlements=list(pending_settlements),
        recent_daily_pnl=rolling_pnl_window[-max_roll:] if max_roll > 0 else [],
    )
    return results, next_state


def run_orb_simulation(
    all_intraday: dict[str, dict[str, list[dict]]],
    trading_days: list[str],
    params: ORBStrategyParams,
    enrichment: dict[str, dict[str, dict]],
    ticker_sectors: dict[str, str] | None = None,
    vix_by_day: dict[str, float] | None = None,
) -> list[DayResult]:
    """Run the full ORB backtest simulation across all trading days."""
    results, _ = run_orb_simulation_with_state(
        all_intraday,
        trading_days,
        params,
        enrichment,
        ticker_sectors=ticker_sectors,
        vix_by_day=vix_by_day,
    )
    return results
