"""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
import math
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)

# 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 _linear_range_scaler(
    value: float | None,
    low: float | None,
    high: float | None,
    low_scale: float,
    high_scale: float,
) -> float:
    """Linear interpolation between arbitrary low/high scaler bounds."""
    if value is None or low is None or high is None or high <= low:
        return 1.0
    low_scale = max(0.0, low_scale)
    high_scale = max(0.0, high_scale)
    if value <= low:
        return low_scale
    if value >= high:
        return high_scale
    frac = (value - low) / (high - low)
    return low_scale + frac * (high_scale - low_scale)


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),
    )


def _orb_sparse_day_scaler(selected_count: int, params: ORBStrategyParams) -> float:
    threshold = getattr(params, "full_size_positions_threshold", None)
    if threshold is None or threshold <= 0:
        return 1.0
    floor = max(0.0, min(1.0, getattr(params, "sparse_day_size_floor", 1.0)))
    if selected_count >= threshold:
        return 1.0
    ratio = selected_count / threshold
    return max(floor, min(1.0, ratio))


def _first_regular_bar(
    all_bars: list[dict],
    market_open: dt.datetime,
) -> dict | None:
    """Return the first regular-session bar when it is plausibly aligned to the open."""
    mkt_bars = filter_market_hours(all_bars)
    if not mkt_bars:
        return None
    first_bar = mkt_bars[0]
    first_bar_ts = _parse_ts(first_bar["timestamp"])
    if abs((first_bar_ts - market_open).total_seconds() / 60) > 10:
        return None
    return first_bar


def _first_regular_open(all_bars: list[dict], date_str: str) -> float | None:
    first_bar = _first_regular_bar(all_bars, _market_open_ts(date_str))
    if not first_bar:
        return None
    open_price = first_bar.get("open")
    if open_price is None:
        return None
    try:
        open_float = float(open_price)
    except (TypeError, ValueError):
        return None
    return open_float if open_float > 0 else None


def _regime_today_open(
    enrich: dict,
    all_bars: list[dict] | None,
    date_str: str,
) -> float | None:
    """Use live intraday open for synthetic same-day daily rows.

    Same-day daily bars can lag the intraday/today feed. The backtester creates a
    prev-close placeholder daily row so prior-day enrichment exists; using that
    placeholder open in breadth/regime would turn real gap-ups into zero-gaps.
    """
    if enrich.get("synthetic_today_daily") and all_bars:
        intraday_open = _first_regular_open(all_bars, date_str)
        if intraday_open is not None:
            return intraday_open
    today_open = enrich.get("today_open")
    if today_open is None:
        return None
    try:
        today_open_float = float(today_open)
    except (TypeError, ValueError):
        return None
    return today_open_float if today_open_float > 0 else None


def _bar_close_location(bar: dict | None) -> float | None:
    """Bar close location within range, normalized to [0, 1]."""
    if not bar:
        return None
    high = float(bar.get("high", 0) or 0)
    low = float(bar.get("low", 0) or 0)
    close = float(bar.get("close", 0) or 0)
    if high <= low:
        return None
    return (close - low) / (high - low)


def _bar_return_pct(bar: dict | None) -> float | None:
    """Bar return from open to close."""
    if not bar:
        return None
    open_ = float(bar.get("open", 0) or 0)
    close = float(bar.get("close", 0) or 0)
    if open_ <= 0:
        return None
    return (close - open_) / open_


def _bar_dollar_volume(bar: dict | None) -> float | None:
    """Approximate dollar volume for one intraday bar."""
    if not bar:
        return None
    try:
        volume = float(bar.get("volume", 0) or 0)
        close = float(bar.get("close", 0) or bar.get("open", 0) or 0)
    except (TypeError, ValueError):
        return None
    if volume <= 0 or close <= 0:
        return None
    return volume * close


def _last_regular_bar(all_bars: list[dict]) -> dict | None:
    mkt_bars = filter_market_hours(all_bars)
    if not mkt_bars:
        return None
    return mkt_bars[-1]


def _idle_entry_bar(
    all_bars: list[dict],
    date_str: str,
    params: ORBStrategyParams,
) -> dict | None:
    mkt_bars = filter_market_hours(all_bars)
    if not mkt_bars:
        return None
    timing = str(getattr(params, "orb_idle_sleeve_entry_timing", "close") or "close").lower()
    if timing in {"minutes_after_open", "after_open", "intraday"}:
        minutes = max(
            0,
            int(getattr(params, "orb_idle_sleeve_entry_minutes_after_open", 60) or 0),
        )
        target = _market_open_ts(date_str) + dt.timedelta(minutes=minutes)
        for bar in mkt_bars:
            if _parse_ts(bar["timestamp"]) >= target:
                return bar
        return None
    return mkt_bars[-1]


def _idle_sleeve_same_day_exit(params: ORBStrategyParams) -> bool:
    return str(
        getattr(params, "orb_idle_sleeve_exit_timing", "next_open") or "next_open"
    ).lower() in {"same_day_close", "day_close", "close"}


def _idle_session_stats(
    all_bars: list[dict],
    date_str: str,
    params: ORBStrategyParams,
) -> dict[str, float | dict | str] | None:
    """Regular-session stats known at the configured idle-sleeve decision point."""
    mkt_bars = filter_market_hours(all_bars)
    if not mkt_bars:
        return None
    entry_bar = _idle_entry_bar(all_bars, date_str, params)
    if entry_bar is None:
        return None
    entry_ts = _parse_ts(entry_bar["timestamp"])
    mkt_bars = [bar for bar in mkt_bars if _parse_ts(bar["timestamp"]) <= entry_ts]
    if not mkt_bars:
        return None
    first_bar = _first_regular_bar(all_bars, _market_open_ts(date_str))
    last_bar = mkt_bars[-1]
    if first_bar is None:
        first_bar = mkt_bars[0]
    open_price = float(first_bar.get("open", 0) or 0)
    close_price = float(last_bar.get("close", 0) or 0)
    if open_price <= 0 or close_price <= 0:
        return None
    high = max(float(b.get("high", 0) or 0) for b in mkt_bars)
    low = min(float(b.get("low", 0) or 0) for b in mkt_bars)
    dollar_vol = 0.0
    for bar in mkt_bars:
        vol = float(bar.get("volume", 0) or 0)
        close = float(bar.get("close", 0) or bar.get("open", 0) or 0)
        if vol > 0 and close > 0:
            dollar_vol += vol * close
    close_location = (close_price - low) / (high - low) if high > low else 0.5
    return {
        "first_bar": first_bar,
        "last_bar": last_bar,
        "open": open_price,
        "close": close_price,
        "return_pct": (close_price - open_price) / open_price,
        "close_location": close_location,
        "dollar_vol": dollar_vol,
        "timestamp": str(last_bar.get("timestamp") or ""),
    }


def _idle_sleeve_support_tickers(params: ORBStrategyParams) -> set[str]:
    tickers: set[str] = set()
    for ticker in getattr(params, "orb_idle_sleeve_parking_symbols", []) or []:
        if ticker:
            tickers.add(str(ticker).upper())
    for attr in (
        "orb_idle_sleeve_parking_base_symbol",
        "orb_idle_sleeve_parking_overlay_symbol",
        "orb_idle_sleeve_parking_defensive_symbol",
    ):
        ticker = getattr(params, attr, None)
        if ticker:
            tickers.add(str(ticker).upper())
    for ticker in getattr(params, "orb_idle_sleeve_risk_off_symbols", []) or []:
        if ticker:
            tickers.add(str(ticker).upper())
    for ticker in getattr(params, "orb_idle_sleeve_sector_rotation_symbols", []) or []:
        if ticker:
            tickers.add(str(ticker).upper())
    market_ticker = getattr(params, "orb_idle_sleeve_market_ticker", None)
    if market_ticker:
        tickers.add(str(market_ticker).upper())
    return tickers


def _idle_pick_best_symbol(
    symbols: list[str],
    bars_by_ticker: dict[str, list[dict]],
    date_str: str,
    params: ORBStrategyParams,
    *,
    min_return_pct: float | None = None,
    min_close_location: float | None = None,
) -> tuple[str, dict[str, float | dict | str]] | None:
    ranked: list[tuple[float, str, dict[str, float | dict | str]]] = []
    for raw_symbol in symbols:
        symbol = str(raw_symbol or "").upper()
        stats = _idle_session_stats(bars_by_ticker.get(symbol, []), date_str, params)
        if not stats:
            continue
        day_ret = float(stats["return_pct"])
        if min_return_pct is not None and day_ret < float(min_return_pct):
            continue
        close_location = float(stats["close_location"])
        if min_close_location is not None and close_location < float(min_close_location):
            continue
        score = day_ret + 0.10 * close_location
        ranked.append((score, symbol, stats))
    if not ranked:
        return None
    _score, symbol, stats = max(ranked, key=lambda item: item[0])
    return symbol, stats


def _idle_stats_for_symbol(
    symbol: str | None,
    bars_by_ticker: dict[str, list[dict]],
    date_str: str,
    params: ORBStrategyParams,
) -> tuple[str, dict[str, float | dict | str]] | None:
    ticker = str(symbol or "").upper()
    if not ticker:
        return None
    stats = _idle_session_stats(bars_by_ticker.get(ticker, []), date_str, params)
    if not stats:
        return None
    return ticker, stats


def _idle_pick_defensive_symbol(
    bars_by_ticker: dict[str, list[dict]],
    date_str: str,
    params: ORBStrategyParams,
) -> tuple[str, dict[str, float | dict | str]] | None:
    preferred = getattr(params, "orb_idle_sleeve_parking_defensive_symbol", "SGOV")
    symbols: list[str] = []
    if preferred:
        symbols.append(str(preferred))
    for fallback in ("SGOV",):
        if fallback not in {s.upper() for s in symbols}:
            symbols.append(fallback)
    for raw in getattr(params, "orb_idle_sleeve_risk_off_symbols", []) or []:
        if raw and str(raw).upper() not in {s.upper() for s in symbols}:
            symbols.append(str(raw))
    for symbol in symbols:
        pick = _idle_stats_for_symbol(symbol, bars_by_ticker, date_str, params)
        if pick is not None:
            return pick
    return None


def _idle_pick_pead_like_parking(
    bars_by_ticker: dict[str, list[dict]],
    date_str: str,
    params: ORBStrategyParams,
    *,
    market_stats: dict[str, float | dict | str] | None,
    market_day_return: float | None,
    risk_on_allowed: bool,
) -> tuple[str, dict[str, float | dict | str]] | None:
    if not risk_on_allowed or market_stats is None or market_day_return is None:
        return _idle_pick_defensive_symbol(bars_by_ticker, date_str, params)

    market_close_location = float(market_stats.get("close_location") or 0.0)
    overlay_symbol = getattr(params, "orb_idle_sleeve_parking_overlay_symbol", None)
    overlay_min_return = float(
        getattr(params, "orb_idle_sleeve_parking_overlay_min_market_return_pct", 0.005)
        or 0.0
    )
    overlay_min_close_location = float(
        getattr(params, "orb_idle_sleeve_parking_overlay_min_market_close_location", 0.70)
        or 0.0
    )
    if (
        overlay_symbol
        and market_day_return >= overlay_min_return
        and market_close_location >= overlay_min_close_location
    ):
        overlay = _idle_stats_for_symbol(overlay_symbol, bars_by_ticker, date_str, params)
        if overlay is not None:
            return overlay

    base = _idle_stats_for_symbol(
        getattr(params, "orb_idle_sleeve_parking_base_symbol", "QQQM"),
        bars_by_ticker,
        date_str,
        params,
    )
    if base is not None:
        return base

    best = _idle_pick_best_symbol(
        list(getattr(params, "orb_idle_sleeve_parking_symbols", []) or []),
        bars_by_ticker,
        date_str,
        params,
        min_return_pct=None,
    )
    if best is not None:
        return best

    if bool(getattr(params, "orb_idle_sleeve_force_defensive_fallback", False)):
        return _idle_pick_defensive_symbol(bars_by_ticker, date_str, params)
    return None


def _idle_stock_score(
    stats: dict[str, float | dict | str],
    info: dict,
    *,
    event_bonus: float = 0.0,
) -> float:
    day_ret = float(stats["return_pct"])
    close_location = float(stats["close_location"])
    dollar_vol = max(1.0, float(stats["dollar_vol"]))
    avg_dollar_vol = max(1.0, float(info.get("avg_dollar_vol_30d") or 0.0))
    return (
        day_ret * 100.0
        + close_location
        + 0.05 * math.log10(dollar_vol)
        + 0.03 * math.log10(avg_dollar_vol)
        + event_bonus
    )


def _idle_reclaim_stats(
    all_bars: list[dict],
    date_str: str,
    params: ORBStrategyParams,
) -> dict[str, float | dict[str, float | dict | str]] | None:
    session = _idle_session_stats(all_bars, date_str, params)
    if session is None:
        return None
    entry_ts = _parse_ts(str(session["timestamp"]))
    checkpoint_minutes = max(
        1,
        int(getattr(params, "orb_idle_sleeve_reclaim_checkpoint_minutes", 60) or 60),
    )
    checkpoint_ts = _market_open_ts(date_str) + dt.timedelta(minutes=checkpoint_minutes)
    if checkpoint_ts >= entry_ts:
        return None
    checkpoint_bar = None
    for bar in filter_market_hours(all_bars):
        bar_ts = _parse_ts(bar["timestamp"])
        if bar_ts > entry_ts:
            break
        if bar_ts >= checkpoint_ts:
            checkpoint_bar = bar
            break
    if checkpoint_bar is None:
        return None
    try:
        open_price = float(session["open"])
        entry_close = float(session["close"])
        checkpoint_close = float(checkpoint_bar.get("close", 0) or 0)
    except (TypeError, ValueError):
        return None
    if open_price <= 0 or checkpoint_close <= 0 or entry_close <= 0:
        return None
    return {
        "session": session,
        "early_return_pct": (checkpoint_close - open_price) / open_price,
        "late_return_pct": (entry_close - checkpoint_close) / checkpoint_close,
    }


def _idle_float(info: dict, key: str) -> float | None:
    value = info.get(key)
    if value is None:
        return None
    try:
        return float(value)
    except (TypeError, ValueError):
        return None


def _round_optional(value: object, ndigits: int) -> float | None:
    if value is None:
        return None
    try:
        return round(float(value), ndigits)
    except (TypeError, ValueError):
        return None


def _idle_bounds_ok(
    value: float | None,
    *,
    min_value: float | None,
    max_value: float | None,
) -> bool:
    if min_value is not None and (value is None or value < float(min_value)):
        return False
    if max_value is not None and (value is None or value > float(max_value)):
        return False
    return True


def _idle_feature_filters_ok(info: dict, params: ORBStrategyParams, prefix: str) -> bool:
    """Apply optional daily-feature bounds for ORB idle single-name sleeves."""
    gap_pct = _idle_float(info, "gap_pct")
    ret_5d = _idle_float(info, "ret_5d")
    return (
        _idle_bounds_ok(
            gap_pct,
            min_value=getattr(params, f"{prefix}_min_gap_pct", None),
            max_value=getattr(params, f"{prefix}_max_gap_pct", None),
        )
        and _idle_bounds_ok(
            ret_5d,
            min_value=getattr(params, f"{prefix}_min_ret_5d", None),
            max_value=getattr(params, f"{prefix}_max_ret_5d", None),
        )
    )


def _idle_event_age_ok(info: dict, params: ORBStrategyParams, key: str) -> bool:
    max_days = getattr(params, "orb_idle_sleeve_event_max_days_since", None)
    if max_days is None:
        return True
    days_since = _idle_float(info, key)
    return days_since is not None and days_since <= int(max_days)


def _build_idle_sleeve_orders(
    bars_by_ticker: dict[str, list[dict]],
    date_str: str,
    params: ORBStrategyParams,
    enrichment: dict[str, dict[str, dict]],
) -> list[_IdleSleeveOrder]:
    """Pick one close-entry candidate per idle sleeve.

    Sleeves intentionally reuse only data available by the configured fallback
    entry time: ETF parking, liquid close-strength alpha, late reclaim, Form 4,
    13D/G ownership, and risk-off GLD/SGOV-style defensive alpha.
    """
    raw_weights = getattr(params, "orb_idle_sleeve_weights", {}) or {}
    weights = {
        str(k): max(0.0, float(v or 0.0))
        for k, v in raw_weights.items()
    }
    max_positions = max(0, int(getattr(params, "orb_idle_sleeve_max_positions", 5) or 0))
    if max_positions <= 0:
        return []

    support_tickers = _idle_sleeve_support_tickers(params)
    orders: list[_IdleSleeveOrder] = []

    market_ticker = str(getattr(params, "orb_idle_sleeve_market_ticker", "QQQ") or "QQQ").upper()
    market_stats = _idle_session_stats(bars_by_ticker.get(market_ticker, []), date_str, params)
    market_day_return = (
        float(market_stats["return_pct"]) if market_stats is not None else None
    )
    market_close_location = (
        float(market_stats["close_location"]) if market_stats is not None else None
    )
    min_market_return = getattr(params, "orb_idle_sleeve_min_market_day_return_pct", None)
    min_market_close_location = getattr(params, "orb_idle_sleeve_min_market_close_location", None)
    risk_on_allowed = (
        min_market_return is None
        or (
            market_day_return is not None
            and market_day_return >= float(min_market_return)
        )
    ) and (
        min_market_close_location is None
        or (
            market_close_location is not None
            and market_close_location >= float(min_market_close_location)
        )
    )
    min_stock_market_return = getattr(
        params,
        "orb_idle_sleeve_min_market_day_return_for_stock_sleeves_pct",
        None,
    )
    stock_sleeves_allowed = (
        min_stock_market_return is None
        or (
            market_day_return is not None
            and market_day_return >= float(min_stock_market_return)
        )
    )

    if weights.get("parking", 0.0) > 0:
        parking_mode = str(getattr(params, "orb_idle_sleeve_parking_mode", "best") or "best").lower()
        if parking_mode == "pead_like":
            parking_pick = _idle_pick_pead_like_parking(
                bars_by_ticker,
                date_str,
                params,
                market_stats=market_stats,
                market_day_return=market_day_return,
                risk_on_allowed=risk_on_allowed,
            )
        elif risk_on_allowed:
            parking_pick = _idle_pick_best_symbol(
                list(getattr(params, "orb_idle_sleeve_parking_symbols", []) or []),
                bars_by_ticker,
                date_str,
                params,
                min_return_pct=min_market_return,
            )
        else:
            parking_pick = None
        if parking_pick is not None:
            symbol, stats = parking_pick
            orders.append(
                _IdleSleeveOrder(
                    ticker=symbol,
                    sleeve="parking",
                    weight=weights["parking"],
                    score=float(stats["return_pct"]),
                    entry_return_pct=float(stats["return_pct"]),
                    entry_close_location=float(stats["close_location"]),
                    market_return_pct=market_day_return,
                    market_close_location=market_close_location,
                )
            )

    if weights.get("sector_rotation", 0.0) > 0 and risk_on_allowed:
        rotation_pick = _idle_pick_best_symbol(
            list(getattr(params, "orb_idle_sleeve_sector_rotation_symbols", []) or []),
            bars_by_ticker,
            date_str,
            params,
            min_return_pct=getattr(
                params,
                "orb_idle_sleeve_sector_rotation_min_day_return_pct",
                0.0,
            ),
            min_close_location=getattr(
                params,
                "orb_idle_sleeve_sector_rotation_min_close_location",
                0.55,
            ),
        )
        if rotation_pick is not None:
            symbol, stats = rotation_pick
            selected_rotation_symbols = {order.ticker for order in orders}
            if symbol not in selected_rotation_symbols:
                orders.append(
                    _IdleSleeveOrder(
                        ticker=symbol,
                        sleeve="sector_rotation",
                        weight=weights["sector_rotation"],
                        score=float(stats["return_pct"]),
                        entry_return_pct=float(stats["return_pct"]),
                        entry_close_location=float(stats["close_location"]),
                        market_return_pct=market_day_return,
                        market_close_location=market_close_location,
                    )
                )

    stock_ranked: list[tuple[float, str]] = []
    reclaim_ranked: list[tuple[float, str]] = []
    stock_meta: dict[str, dict] = {}
    reclaim_meta: dict[str, dict] = {}
    form4_ranked: list[tuple[float, str]] = []
    ownership_ranked: list[tuple[float, str]] = []
    min_idle_ret = float(getattr(params, "orb_idle_sleeve_idle_min_day_return_pct", 0.01) or 0.0)
    min_idle_close_loc = float(getattr(params, "orb_idle_sleeve_idle_min_close_location", 0.60) or 0.0)
    min_idle_day_dv = float(getattr(params, "orb_idle_sleeve_idle_min_day_dollar_vol", 0.0) or 0.0)
    min_idle_avg_dv = float(getattr(params, "orb_idle_sleeve_idle_min_avg_dollar_vol", 0.0) or 0.0)
    min_reclaim_ret = float(getattr(params, "orb_idle_sleeve_reclaim_min_day_return_pct", 0.004) or 0.0)
    max_reclaim_early_ret = float(
        getattr(params, "orb_idle_sleeve_reclaim_max_early_return_pct", 0.004) or 0.0
    )
    min_reclaim_late_ret = float(
        getattr(params, "orb_idle_sleeve_reclaim_min_late_return_pct", 0.006) or 0.0
    )
    min_reclaim_close_loc = float(
        getattr(params, "orb_idle_sleeve_reclaim_min_close_location", 0.70) or 0.0
    )
    min_reclaim_day_dv = float(
        getattr(params, "orb_idle_sleeve_reclaim_min_day_dollar_vol", 0.0) or 0.0
    )
    min_reclaim_avg_dv = float(
        getattr(params, "orb_idle_sleeve_reclaim_min_avg_dollar_vol", 0.0) or 0.0
    )
    min_event_ret = float(getattr(params, "orb_idle_sleeve_event_min_day_return_pct", -0.005) or 0.0)
    max_idle_ret = getattr(params, "orb_idle_sleeve_idle_max_day_return_pct", None)
    min_idle_score = getattr(params, "orb_idle_sleeve_idle_min_score", None)
    min_reclaim_score = getattr(params, "orb_idle_sleeve_reclaim_min_score", None)
    min_event_close_loc = float(getattr(params, "orb_idle_sleeve_event_min_close_location", 0.50) or 0.0)
    min_event_day_dv = float(getattr(params, "orb_idle_sleeve_event_min_day_dollar_vol", 0.0) or 0.0)
    min_event_avg_dv = float(getattr(params, "orb_idle_sleeve_event_min_avg_dollar_vol", 0.0) or 0.0)

    if stock_sleeves_allowed:
        for ticker, ticker_bars in bars_by_ticker.items():
            symbol = str(ticker).upper()
            if symbol in support_tickers:
                continue
            stats = _idle_session_stats(ticker_bars, date_str, params)
            if not stats:
                continue
            close_price = float(stats["close"])
            if close_price < float(getattr(params, "min_price", 0.0) or 0.0):
                continue
            info = enrichment.get(symbol, {}).get(date_str, {})
            avg_dollar_vol = float(info.get("avg_dollar_vol_30d") or 0.0)
            day_ret = float(stats["return_pct"])
            close_location = float(stats["close_location"])
            day_dollar_vol = float(stats["dollar_vol"])
            if (
                day_ret >= min_idle_ret
                and (max_idle_ret is None or day_ret <= float(max_idle_ret))
                and close_location >= min_idle_close_loc
                and day_dollar_vol >= min_idle_day_dv
                and avg_dollar_vol >= min_idle_avg_dv
                and _idle_feature_filters_ok(info, params, "orb_idle_sleeve_idle")
            ):
                score = _idle_stock_score(stats, info)
                if min_idle_score is None or score >= float(min_idle_score):
                    stock_ranked.append((score, symbol))
                    stock_meta[symbol] = stats
            reclaim_stats = _idle_reclaim_stats(ticker_bars, date_str, params)
            if reclaim_stats is not None:
                early_ret = float(reclaim_stats["early_return_pct"])
                late_ret = float(reclaim_stats["late_return_pct"])
                if (
                    day_ret >= min_reclaim_ret
                    and early_ret <= max_reclaim_early_ret
                    and late_ret >= min_reclaim_late_ret
                    and close_location >= min_reclaim_close_loc
                    and day_dollar_vol >= min_reclaim_day_dv
                    and avg_dollar_vol >= min_reclaim_avg_dv
                    and _idle_feature_filters_ok(info, params, "orb_idle_sleeve_reclaim")
                ):
                    score = (
                        _idle_stock_score(stats, info)
                        + 120.0 * late_ret
                        - 30.0 * max(0.0, early_ret)
                    )
                    if min_reclaim_score is None or score >= float(min_reclaim_score):
                        reclaim_ranked.append((score, symbol))
                        reclaim_meta[symbol] = {**stats, **reclaim_stats}
            if (
                day_ret >= min_event_ret
                and close_location >= min_event_close_loc
                and day_dollar_vol >= min_event_day_dv
                and avg_dollar_vol >= min_event_avg_dv
                and _idle_feature_filters_ok(info, params, "orb_idle_sleeve_event")
            ):
                if bool(info.get("form4_flag")) and _idle_event_age_ok(info, params, "form4_days_since"):
                    bonus = (
                        0.25 * float(info.get("form4_owner_count") or 0.0)
                        + 0.35 * float(info.get("form4_c_suite_count") or 0.0)
                        + 0.05 * math.log10(max(1.0, float(info.get("form4_total_value") or 0.0)))
                    )
                    form4_ranked.append((_idle_stock_score(stats, info, event_bonus=bonus), symbol))
                if bool(info.get("ownership_13dg_flag")) and _idle_event_age_ok(
                    info, params, "ownership_13dg_days_since"
                ):
                    bonus = (
                        0.75 if bool(info.get("ownership_13dg_initial_flag")) else 0.0
                    ) + 0.10 * float(info.get("ownership_13dg_strength_score") or 0.0)
                    ownership_ranked.append((_idle_stock_score(stats, info, event_bonus=bonus), symbol))

    selected_symbols = {order.ticker for order in orders}

    if weights.get("close_reclaim", 0.0) > 0 and reclaim_ranked:
        for _score, symbol in sorted(reclaim_ranked, reverse=True):
            if symbol not in selected_symbols:
                orders.append(
                    _IdleSleeveOrder(
                        symbol,
                        "close_reclaim",
                        weights["close_reclaim"],
                        _score,
                        entry_return_pct=float(reclaim_meta.get(symbol, {}).get("return_pct") or 0.0),
                        entry_close_location=float(
                            reclaim_meta.get(symbol, {}).get("close_location") or 0.0
                        ),
                        reclaim_early_return_pct=float(
                            reclaim_meta.get(symbol, {}).get("early_return_pct") or 0.0
                        ),
                        reclaim_late_return_pct=float(
                            reclaim_meta.get(symbol, {}).get("late_return_pct") or 0.0
                        ),
                        market_return_pct=market_day_return,
                        market_close_location=market_close_location,
                    )
                )
                selected_symbols.add(symbol)
                break

    if weights.get("idle_alpha", 0.0) > 0 and stock_ranked:
        for _score, symbol in sorted(stock_ranked, reverse=True):
            if symbol not in selected_symbols:
                meta = stock_meta.get(symbol, {})
                orders.append(
                    _IdleSleeveOrder(
                        symbol,
                        "idle_alpha",
                        weights["idle_alpha"],
                        _score,
                        entry_return_pct=float(meta.get("return_pct") or 0.0),
                        entry_close_location=float(meta.get("close_location") or 0.0),
                        market_return_pct=market_day_return,
                        market_close_location=market_close_location,
                    )
                )
                selected_symbols.add(symbol)
                break

    if weights.get("form4", 0.0) > 0 and form4_ranked:
        for _score, symbol in sorted(form4_ranked, reverse=True):
            if symbol not in selected_symbols:
                orders.append(_IdleSleeveOrder(symbol, "form4", weights["form4"], _score))
                selected_symbols.add(symbol)
                break

    if weights.get("ownership", 0.0) > 0 and ownership_ranked:
        for _score, symbol in sorted(ownership_ranked, reverse=True):
            if symbol not in selected_symbols:
                orders.append(_IdleSleeveOrder(symbol, "ownership", weights["ownership"], _score))
                selected_symbols.add(symbol)
                break

    risk_off_weight = weights.get("risk_off_alpha", 0.0)
    risk_off_allowed = (
        risk_off_weight > 0
        and (
            not risk_on_allowed
            or not orders
            or (market_day_return is not None and market_day_return < 0.0)
        )
    )
    if risk_off_allowed:
        risk_off_pick = _idle_pick_best_symbol(
            list(getattr(params, "orb_idle_sleeve_risk_off_symbols", []) or []),
            bars_by_ticker,
            date_str,
            params,
            min_return_pct=None,
        )
        if risk_off_pick is not None:
            symbol, stats = risk_off_pick
            if symbol not in selected_symbols:
                orders.append(
                    _IdleSleeveOrder(
                        ticker=symbol,
                        sleeve="risk_off_alpha",
                        weight=risk_off_weight,
                        score=float(stats["return_pct"]),
                        entry_return_pct=float(stats["return_pct"]),
                        entry_close_location=float(stats["close_location"]),
                        market_return_pct=market_day_return,
                        market_close_location=market_close_location,
                    )
                )

    if not orders and bool(getattr(params, "orb_idle_sleeve_force_defensive_fallback", False)):
        defensive_pick = _idle_pick_defensive_symbol(bars_by_ticker, date_str, params)
        fallback_weight = max(weights.values()) if weights else 1.0
        if defensive_pick is not None and fallback_weight > 0:
            symbol, stats = defensive_pick
            orders.append(
                _IdleSleeveOrder(
                    ticker=symbol,
                    sleeve="parking_defensive_fallback",
                    weight=fallback_weight,
                    score=float(stats["return_pct"]),
                    entry_return_pct=float(stats["return_pct"]),
                    entry_close_location=float(stats["close_location"]),
                    market_return_pct=market_day_return,
                    market_close_location=market_close_location,
                )
            )

    return sorted(orders, key=lambda order: (order.weight, order.score), reverse=True)[:max_positions]


def _open_idle_sleeve_positions(
    bars_by_ticker: dict[str, list[dict]],
    date_str: str,
    params: ORBStrategyParams,
    enrichment: dict[str, dict[str, dict]],
    budget: float,
    next_date_str: str | None = None,
) -> list[dict]:
    if budget <= 0:
        return []
    orders = _build_idle_sleeve_orders(bars_by_ticker, date_str, params, enrichment)
    total_weight = sum(order.weight for order in orders if order.weight > 0)
    if total_weight <= 0:
        return []
    slippage = params.slippage_bps / 10_000.0
    positions: list[dict] = []
    used = 0.0
    support_tickers = _idle_sleeve_support_tickers(params)
    exit_timing = str(getattr(params, "orb_idle_sleeve_exit_timing", "next_open") or "next_open").lower()
    normalize_weights = bool(getattr(params, "orb_idle_sleeve_normalize_weights", True))
    max_positions = max(0, int(getattr(params, "orb_idle_sleeve_max_positions", 5) or 0))

    def add_position(order: _IdleSleeveOrder, sleeve_budget: float) -> float:
        stats = _idle_session_stats(bars_by_ticker.get(order.ticker, []), date_str, params)
        if not stats:
            return 0.0
        entry_raw = float(stats["close"])
        if entry_raw <= 0:
            return 0.0
        entry_price = _apply_slippage_entry(entry_raw, slippage)
        shares = math.floor(sleeve_budget / entry_price)
        if shares <= 0:
            return 0.0
        cost = shares * entry_price
        entry_slippage = abs(entry_price - entry_raw) * shares
        info = enrichment.get(order.ticker, {}).get(date_str, {})
        gap_pct = None
        try:
            prev_close = float(info.get("prev_close") or 0.0)
            today_open = float(info.get("today_open") or 0.0)
            if prev_close > 0 and today_open > 0:
                gap_pct = (today_open - prev_close) / prev_close
        except (TypeError, ValueError):
            gap_pct = None
        for existing in positions:
            if str(existing.get("ticker") or "").upper() != order.ticker:
                continue
            existing_shares = int(existing.get("shares") or 0)
            existing_cost = float(existing.get("cost") or 0.0)
            existing["shares"] = existing_shares + shares
            existing["cost"] = existing_cost + cost
            existing["entry_slippage"] = float(existing.get("entry_slippage") or 0.0) + entry_slippage
            return cost
        positions.append(
            {
                "entry_date": date_str,
                "ticker": order.ticker,
                "sleeve": order.sleeve,
                "entry_price": entry_price,
                "entry_price_raw": entry_raw,
                "entry_time": str(stats["timestamp"]),
                "shares": shares,
                "cost": cost,
                "entry_slippage": entry_slippage,
                "target_exit_date": next_date_str,
                "score": order.score,
                "gap_pct": gap_pct,
                "idle_entry_day_return_pct": order.entry_return_pct,
                "idle_entry_close_location": order.entry_close_location,
                "idle_reclaim_early_return_pct": order.reclaim_early_return_pct,
                "idle_reclaim_late_return_pct": order.reclaim_late_return_pct,
                "idle_market_day_return_pct": order.market_return_pct,
                "idle_market_close_location": order.market_close_location,
            }
        )
        return cost

    for order in orders:
        if (
            exit_timing not in {"next_close", "same_day_close", "day_close", "close"}
            and next_date_str
            and order.ticker not in support_tickers
        ):
            next_open = (
                enrichment.get(order.ticker, {}).get(next_date_str, {}).get("today_open")
            )
            try:
                next_open_ok = next_open is not None and float(next_open) > 0
            except (TypeError, ValueError):
                next_open_ok = False
            if not next_open_ok:
                continue
        if normalize_weights:
            raw_budget = budget * (order.weight / total_weight)
        else:
            raw_budget = budget * min(1.0, max(0.0, order.weight))
        remaining = max(0.0, budget - used)
        sleeve_budget = min(raw_budget, remaining)
        if sleeve_budget <= 0:
            break
        used += add_position(order, sleeve_budget)
    if bool(getattr(params, "orb_idle_sleeve_defensive_fill_unused_budget", False)):
        remaining = max(0.0, budget - used)
        if remaining > 0 and len(positions) < max_positions:
            defensive_pick = _idle_pick_defensive_symbol(bars_by_ticker, date_str, params)
            if defensive_pick is not None:
                symbol, stats = defensive_pick
                used += add_position(
                    _IdleSleeveOrder(
                        ticker=symbol,
                        sleeve="defensive_fill",
                        weight=1.0,
                        score=float(stats["return_pct"]),
                        entry_return_pct=float(stats["return_pct"]),
                        entry_close_location=float(stats["close_location"]),
                    ),
                    remaining,
                )
    return positions


def _idle_exit_bar(
    bars: list[dict],
    exit_timing: str,
) -> dict | None:
    mkt_bars = filter_market_hours(bars)
    if not mkt_bars:
        return None
    if str(exit_timing or "next_open").lower() in {
        "next_close",
        "same_day_close",
        "day_close",
        "close",
    }:
        return mkt_bars[-1]
    return mkt_bars[0]


def _idle_same_day_stop_fill(
    bars: list[dict],
    entry_time: str,
    entry_raw: float,
    stop_loss_pct: float | None,
) -> tuple[float, str] | None:
    if stop_loss_pct is None or stop_loss_pct >= 0 or entry_raw <= 0:
        return None
    try:
        entry_ts = _parse_ts(entry_time)
    except (TypeError, ValueError):
        return None
    stop_raw = entry_raw * (1.0 + float(stop_loss_pct))
    if stop_raw <= 0:
        return None
    for bar in filter_market_hours(bars):
        bar_ts = _parse_ts(bar["timestamp"])
        if bar_ts <= entry_ts:
            continue
        low = float(bar.get("low", 0.0) or 0.0)
        if low > 0 and low <= stop_raw:
            return stop_raw, str(bar.get("timestamp") or "")
    return None


def _close_idle_sleeve_positions(
    pending_positions: list[dict],
    bars_by_ticker: dict[str, list[dict]],
    date_str: str,
    params: ORBStrategyParams,
    enrichment: dict[str, dict[str, dict]],
) -> tuple[list[IntradayTrade], list[dict]]:
    if not pending_positions:
        return [], []
    slippage = params.slippage_bps / 10_000.0
    closed: list[IntradayTrade] = []
    still_open: list[dict] = []
    exit_timing = str(getattr(params, "orb_idle_sleeve_exit_timing", "next_open") or "next_open").lower()
    same_day_exit = _idle_sleeve_same_day_exit(params)
    if same_day_exit:
        exit_reason = "idle_sleeve_same_day_close"
    elif exit_timing == "next_close":
        exit_reason = "idle_sleeve_next_close"
    else:
        exit_reason = "idle_sleeve_next_open"
    for pos in pending_positions:
        pos_exit_reason = exit_reason
        target_exit_date = str(pos.get("target_exit_date") or "")
        if target_exit_date and date_str < target_exit_date:
            still_open.append(pos)
            continue
        ticker = str(pos.get("ticker") or "").upper()
        exit_bar = _idle_exit_bar(bars_by_ticker.get(ticker, []), exit_timing)
        exit_ts = ""
        if exit_bar is not None:
            exit_raw = float(exit_bar.get("close" if same_day_exit or exit_timing == "next_close" else "open", 0) or 0)
            exit_ts = str(exit_bar.get("timestamp") or "")
        elif not same_day_exit and exit_timing != "next_close":
            # Fallback to the daily open so overnight sleeves exit on the next
            # trading day even when the ticker was not in that day's intraday
            # fetch set. This avoids accidentally turning a next-open sleeve
            # into a multi-day hold because of candidate-map sparsity.
            exit_raw = float(
                enrichment.get(ticker, {}).get(date_str, {}).get("today_open") or 0.0
            )
            exit_ts = _market_open_ts(date_str).isoformat()
        else:
            still_open.append(pos)
            continue
        if exit_raw <= 0:
            if not same_day_exit and exit_timing != "next_close" and target_exit_date and date_str >= target_exit_date:
                exit_raw = float(pos.get("entry_price_raw") or pos.get("entry_price") or 0.0)
                exit_ts = _market_open_ts(date_str).isoformat()
                pos_exit_reason = "idle_sleeve_missing_next_open"
            else:
                still_open.append(pos)
                continue
        shares = int(pos.get("shares") or 0)
        entry_price = float(pos.get("entry_price") or 0.0)
        entry_raw = float(pos.get("entry_price_raw") or entry_price)
        if shares <= 0 or entry_price <= 0:
            continue
        if same_day_exit:
            stop_fill = _idle_same_day_stop_fill(
                bars_by_ticker.get(ticker, []),
                str(pos.get("entry_time") or ""),
                entry_raw,
                getattr(params, "orb_idle_sleeve_same_day_stop_loss_pct", None),
            )
            if stop_fill is not None:
                exit_raw, exit_ts = stop_fill
                pos_exit_reason = "idle_sleeve_same_day_stop_loss"
        exit_price = _apply_slippage_exit(exit_raw, slippage)
        pnl = (exit_price - entry_price) * shares
        exit_slippage = abs(exit_price - exit_raw) * shares
        entry_slippage = float(pos.get("entry_slippage") or 0.0)
        trade = IntradayTrade(
            date=date_str,
            ticker=ticker,
            entry_price=round(entry_price, 4),
            exit_price=round(exit_price, 4),
            entry_time=str(pos.get("entry_time") or ""),
            exit_time=exit_ts,
            shares=shares,
            pnl=round(pnl, 2),
            pnl_pct=round((exit_price - entry_price) / entry_price, 4),
            exit_reason=pos_exit_reason,
            slippage_cost=round(entry_slippage + exit_slippage, 2),
            trade_sleeve=f"orb_idle_{pos.get('sleeve') or 'unknown'}",
            trigger_type="orb_idle_sleeve",
            total_capital_deployed=round(float(pos.get("cost") or shares * entry_price), 2),
            candidate_score=round(float(pos.get("score") or 0.0), 4),
            orb_idle_sleeve_overnight=not same_day_exit,
            gap_pct=_round_optional(pos.get("gap_pct"), 6),
            idle_entry_day_return_pct=_round_optional(pos.get("idle_entry_day_return_pct"), 6),
            idle_entry_close_location=_round_optional(pos.get("idle_entry_close_location"), 6),
            idle_reclaim_early_return_pct=_round_optional(pos.get("idle_reclaim_early_return_pct"), 6),
            idle_reclaim_late_return_pct=_round_optional(pos.get("idle_reclaim_late_return_pct"), 6),
            idle_market_day_return_pct=_round_optional(pos.get("idle_market_day_return_pct"), 6),
            idle_market_close_location=_round_optional(pos.get("idle_market_close_location"), 6),
        )
        closed.append(trade)
    return closed, still_open


def _opening_breadth_stats(
    bars_by_ticker: dict[str, list[dict]],
    date_str: str,
    *,
    min_first_bar_dollar_vol: float | None,
    strong_close_location: float,
) -> dict[str, float | int | None]:
    """Point-in-time universe breadth from the first regular-session bar."""

    market_open = _market_open_ts(date_str)
    total = 0
    positive = 0
    strong_close_location_count = 0
    return_sum = 0.0
    for bars in bars_by_ticker.values():
        first_bar = _first_regular_bar(bars, market_open)
        first_bar_return = _bar_return_pct(first_bar)
        if first_bar_return is None:
            continue
        if min_first_bar_dollar_vol is not None:
            dollar_vol = _bar_dollar_volume(first_bar)
            if dollar_vol is None or dollar_vol < min_first_bar_dollar_vol:
                continue
        total += 1
        return_sum += first_bar_return
        if first_bar_return > 0:
            positive += 1
        close_location = _bar_close_location(first_bar)
        if (
            close_location is not None
            and close_location >= strong_close_location
        ):
            strong_close_location_count += 1

    if total <= 0:
        return {
            "total_count": 0,
            "positive_ratio": None,
            "avg_return_pct": None,
            "strong_close_location_ratio": None,
        }
    return {
        "total_count": total,
        "positive_ratio": positive / total,
        "avg_return_pct": return_sum / total,
        "strong_close_location_ratio": strong_close_location_count / total,
    }


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

    equity: float
    peak_equity: float | None = None
    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."""
    streak_outcomes: list[bool] = field(default_factory=list)
    """Recent trade outcomes for streak sizing continuity across chunks."""
    pending_idle_sleeve_positions: list[dict] = field(default_factory=list)
    """Open overnight idle-sleeve positions awaiting a future session exit."""


@dataclass
class _IdleSleeveOrder:
    ticker: str
    sleeve: str
    weight: float
    score: float
    entry_return_pct: float | None = None
    entry_close_location: float | None = None
    reclaim_early_return_pct: float | None = None
    reclaim_late_return_pct: float | None = None
    market_return_pct: float | None = None
    market_close_location: float | None = None


# ── 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 _rank_percentiles(values: list[float | None]) -> list[float]:
    """Return low-to-high percentile ranks in [0, 1] with average ranks for ties."""
    if not values:
        return []
    if len(values) == 1:
        return [1.0]

    clean_values = [
        float(v) if v is not None else 0.0
        for v in values
    ]
    sorted_pairs = sorted(enumerate(clean_values), key=lambda item: item[1])
    ranks = [0.0] * len(clean_values)
    i = 0
    while i < len(sorted_pairs):
        j = i + 1
        value = sorted_pairs[i][1]
        while j < len(sorted_pairs) and sorted_pairs[j][1] == value:
            j += 1
        avg_rank = (i + j - 1) / 2.0
        percentile = avg_rank / (len(sorted_pairs) - 1)
        for k in range(i, j):
            ranks[sorted_pairs[k][0]] = percentile
        i = j
    return ranks


def _rank_percentiles_by_key(
    values: list[float | None],
    keys: list[str],
    *,
    min_bucket_size: int,
    fallback: list[float] | None = None,
) -> list[float]:
    """Rank within each key bucket, falling back to global ranks for thin buckets."""
    if not values:
        return []
    ranks = list(fallback) if fallback is not None else _rank_percentiles(values)
    bucket_indices: dict[str, list[int]] = {}
    for i, key in enumerate(keys):
        bucket_indices.setdefault(key, []).append(i)

    min_size = max(2, int(min_bucket_size or 2))
    for indices in bucket_indices.values():
        if len(indices) < min_size:
            continue
        bucket_ranks = _rank_percentiles([values[i] for i in indices])
        for local_idx, rank in zip(indices, bucket_ranks, strict=False):
            ranks[local_idx] = rank
    return ranks


def _candidate_price_bucket(cand: dict) -> str:
    """Stable coarse price buckets for contextual volume ranks."""
    orb_bar = cand.get("orb_bar") or {}
    price = _float_or_none(orb_bar.get("open")) or 0.0
    if price < 30.0:
        return "p000_030"
    if price < 75.0:
        return "p030_075"
    if price < 150.0:
        return "p075_150"
    return "p150_plus"


def _timed_candidate_key(
    item: tuple[dt.datetime, dict, str, str, float | None],
) -> tuple[str, str, str]:
    entry_ts, cand, _direction, trigger_type, _forced_price = item
    return (str(cand.get("ticker") or ""), str(trigger_type), entry_ts.isoformat())


def _bar_dollar_volume(bar: dict | None) -> float:
    if not bar:
        return 0.0
    volume = float(bar.get("volume", 0.0) or 0.0)
    price = (
        _float_or_none(bar.get("close"))
        or _float_or_none(bar.get("open"))
        or 0.0
    )
    return max(0.0, volume * price)


def _entry_participation_features(
    mkt_bars: list[dict],
    orb_bar: dict,
    entry_ts: dt.datetime,
) -> dict[str, float]:
    """Scale-free participation features available at reclaim signal close."""
    entry_bar = next(
        (bar for bar in mkt_bars if _parse_ts(bar["timestamp"]) == entry_ts),
        None,
    )
    entry_bar_dollar_vol = _bar_dollar_volume(entry_bar)
    cumulative_dollar_vol = 0.0
    for bar in mkt_bars:
        ts = _parse_ts(bar["timestamp"])
        if ts > entry_ts:
            break
        cumulative_dollar_vol += _bar_dollar_volume(bar)

    entry_volume = float(entry_bar.get("volume", 0.0) or 0.0) if entry_bar else 0.0
    orb_ts = _parse_ts(orb_bar["timestamp"])
    prior_volumes = [
        float(bar.get("volume", 0.0) or 0.0)
        for bar in mkt_bars
        if orb_ts < _parse_ts(bar["timestamp"]) < entry_ts
    ]
    avg_prior_volume = (
        sum(prior_volumes) / len(prior_volumes)
        if prior_volumes
        else 0.0
    )
    entry_rel_volume = entry_volume / avg_prior_volume if avg_prior_volume > 0 else 0.0
    return {
        "entry_bar_dollar_vol": entry_bar_dollar_vol,
        "entry_cumulative_dollar_vol": max(0.0, cumulative_dollar_vol),
        "entry_rel_volume": max(0.0, entry_rel_volume),
    }


def _reclaim_entry_attention_trigger_allowed(
    params: ORBStrategyParams,
    trigger_type: str,
) -> bool:
    allowed = getattr(params, "reclaim_entry_attention_trigger_types", None)
    if allowed is None:
        return trigger_type in {"vwap_reclaim", "soft_day_vwap_reclaim"}
    allowed_set = {str(value) for value in allowed}
    return trigger_type in allowed_set


def _compute_reclaim_entry_attention_by_key(
    timed_candidates: list[tuple[dt.datetime, dict, str, str, float | None]],
    params: ORBStrategyParams,
) -> dict[tuple[str, str, str], dict[str, float]]:
    if not bool(getattr(params, "reclaim_entry_attention_enabled", False)):
        return {}
    if not timed_candidates:
        return {}

    keys: list[tuple[str, str, str]] = []
    features: list[dict[str, float]] = []
    for item in timed_candidates:
        entry_ts, cand, _direction, _trigger_type, _forced_price = item
        keys.append(_timed_candidate_key(item))
        features.append(
            _entry_participation_features(
                cand.get("mkt_bars") or [],
                cand.get("orb_bar") or {},
                entry_ts,
            )
        )

    entry_bar_dollar_ranks = _rank_percentiles(
        [row["entry_bar_dollar_vol"] for row in features]
    )
    cumulative_dollar_ranks = _rank_percentiles(
        [row["entry_cumulative_dollar_vol"] for row in features]
    )
    entry_rel_volume_ranks = _rank_percentiles(
        [row["entry_rel_volume"] for row in features]
    )
    w_bar = max(
        0.0,
        float(
            getattr(
                params,
                "reclaim_entry_attention_weight_entry_bar_dollar_vol",
                0.45,
            )
            or 0.0
        ),
    )
    w_cum = max(
        0.0,
        float(
            getattr(
                params,
                "reclaim_entry_attention_weight_cumulative_dollar_vol",
                0.35,
            )
            or 0.0
        ),
    )
    w_rel = max(
        0.0,
        float(
            getattr(
                params,
                "reclaim_entry_attention_weight_entry_rel_vol",
                0.20,
            )
            or 0.0
        ),
    )
    weight_sum = w_bar + w_cum + w_rel
    if weight_sum <= 0.0:
        w_bar, w_cum, w_rel = 0.45, 0.35, 0.20
        weight_sum = 1.0

    out: dict[tuple[str, str, str], dict[str, float]] = {}
    for i, key in enumerate(keys):
        rank = (
            entry_bar_dollar_ranks[i] * w_bar
            + cumulative_dollar_ranks[i] * w_cum
            + entry_rel_volume_ranks[i] * w_rel
        ) / weight_sum
        out[key] = {
            "reclaim_entry_attention_rank_pct": rank,
            "entry_bar_dollar_vol_rank_pct": entry_bar_dollar_ranks[i],
            "entry_cumulative_dollar_vol_rank_pct": cumulative_dollar_ranks[i],
            "entry_rel_volume_rank_pct": entry_rel_volume_ranks[i],
            "entry_rel_volume": features[i]["entry_rel_volume"],
        }
    return out


def _apply_orb_basket_quality_floor(
    candidates: list[dict],
    params: ORBStrategyParams,
) -> list[dict]:
    """Prune weak residual ORB names whose score trails the day leader too far."""
    floor = getattr(params, "basket_quality_relative_floor", None)
    if floor is None or not candidates:
        return candidates

    floor = float(floor)
    if floor <= 0:
        return candidates

    best_score = float(candidates[0].get("score") or 0.0)
    if best_score <= 0:
        return candidates

    keep_at_least = max(0, int(getattr(params, "basket_quality_min_count", 0) or 0))
    cutoff = best_score * floor
    kept = [cand for cand in candidates if float(cand.get("score") or 0.0) >= cutoff]
    if len(kept) < keep_at_least:
        return candidates[:keep_at_least]
    return kept


def _is_red_to_green_reserved_candidate(cand: dict, params: ORBStrategyParams) -> bool:
    """Return True when a candidate qualifies for the red-to-green reserved sleeve."""
    if cand.get("direction") != "bullish":
        return False

    gap_pct = float(cand.get("gap_pct") or 0.0)
    if gap_pct >= 0.0:
        return False

    min_abs_gap = getattr(params, "red_to_green_min_abs_gap_pct", None)
    if min_abs_gap is not None and abs(gap_pct) < float(min_abs_gap):
        return False

    min_body = getattr(params, "red_to_green_min_body_ratio", None)
    if min_body is not None and float(cand.get("body_ratio") or 0.0) < float(min_body):
        return False

    min_close = getattr(params, "red_to_green_min_close_location", None)
    if min_close is not None and float(cand.get("close_location") or 0.0) < float(min_close):
        return False

    min_orb_return = getattr(params, "red_to_green_min_orb_return", None)
    if min_orb_return is not None and float(cand.get("orb_return") or 0.0) < float(min_orb_return):
        return False

    min_rvol = getattr(params, "red_to_green_min_rvol", None)
    if min_rvol is not None and float(cand.get("rvol") or 0.0) < float(min_rvol):
        return False

    return True


def _is_candidate_seed_overlay_reserved_candidate(cand: dict) -> bool:
    """Return True when a candidate came from an explicit seed overlay."""

    return bool(cand.get("candidate_seed_overlay"))


def _is_broad_gapup_continuation_candidate(cand: dict) -> bool:
    """Return True when a candidate came from the high-gap continuation sleeve."""

    return bool(cand.get("broad_gapup_continuation"))


def _select_orb_candidates_with_overlays(
    raw_candidates: list[dict],
    params: ORBStrategyParams,
) -> list[dict]:
    """Apply final basket caps plus optional reserved candidate sleeves."""
    max_candidates = max(0, int(getattr(params, "max_candidates", 0) or 0))
    if max_candidates <= 0:
        return []

    max_per_sector = getattr(params, "max_candidates_per_sector", None)
    max_small_gap_attention = getattr(params, "max_small_gap_attention_candidates", None)
    max_broad_gapup = getattr(params, "broad_gapup_continuation_max_candidates", None)
    broad_reserved_slots = (
        max(0, int(max_broad_gapup))
        if max_broad_gapup is not None and max_broad_gapup >= 0
        else 0
    )
    reserved_slots = max(0, int(getattr(params, "red_to_green_reserved_slots", 0) or 0))
    seed_overlay_reserved_slots = max(
        0, int(getattr(params, "candidate_seed_overlay_reserved_slots", 0) or 0)
    )
    has_caps = (
        (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)
        or (max_broad_gapup is not None and max_broad_gapup >= 0)
    )
    if reserved_slots <= 0 and seed_overlay_reserved_slots <= 0 and not has_caps:
        return raw_candidates[:max_candidates]

    selected: list[dict] = []
    selected_tickers: set[str] = set()
    sector_counts: dict[str, int] = {}
    small_gap_attention_count = 0
    broad_gapup_count = 0

    def try_add(
        cand: dict,
        *,
        red_to_green_reserved: bool,
        seed_overlay_reserved: bool,
        allow_extra_broad: bool = False,
    ) -> bool:
        nonlocal small_gap_attention_count, broad_gapup_count

        is_broad_candidate = _is_broad_gapup_continuation_candidate(cand)
        candidate_limit = max_candidates
        if allow_extra_broad and is_broad_candidate:
            candidate_limit = max_candidates + broad_reserved_slots
        if len(selected) >= candidate_limit:
            return False
        ticker = str(cand.get("ticker") or "")
        if ticker in selected_tickers:
            return False

        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:
                return False

        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:
                return False

        if (
            max_broad_gapup is not None
            and max_broad_gapup >= 0
            and is_broad_candidate
        ):
            if broad_gapup_count >= int(max_broad_gapup):
                return False

        cand["red_to_green_reserved"] = bool(red_to_green_reserved)
        cand["candidate_seed_overlay_reserved"] = bool(seed_overlay_reserved)
        selected.append(cand)
        selected_tickers.add(ticker)
        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 is_broad_candidate:
            broad_gapup_count += 1
        return True

    if reserved_slots > 0:
        reserved_count = 0
        for cand in raw_candidates:
            if not _is_red_to_green_reserved_candidate(cand, params):
                continue
            if try_add(
                cand,
                red_to_green_reserved=True,
                seed_overlay_reserved=False,
            ):
                reserved_count += 1
            if reserved_count >= reserved_slots:
                break

    if seed_overlay_reserved_slots > 0:
        seed_overlay_reserved_count = 0
        for cand in raw_candidates:
            if not _is_candidate_seed_overlay_reserved_candidate(cand):
                continue
            if try_add(
                cand,
                red_to_green_reserved=False,
                seed_overlay_reserved=True,
            ):
                seed_overlay_reserved_count += 1
            if seed_overlay_reserved_count >= seed_overlay_reserved_slots:
                break

    for cand in raw_candidates:
        if broad_reserved_slots > 0 and _is_broad_gapup_continuation_candidate(cand):
            continue
        if (
            try_add(
                cand,
                red_to_green_reserved=False,
                seed_overlay_reserved=False,
            )
            and len(selected) >= max_candidates
        ):
            break

    if broad_reserved_slots > 0:
        for cand in raw_candidates:
            if not _is_broad_gapup_continuation_candidate(cand):
                continue
            try_add(
                cand,
                red_to_green_reserved=False,
                seed_overlay_reserved=False,
                allow_extra_broad=True,
            )
            if broad_gapup_count >= broad_reserved_slots:
                break

    selected.sort(
        key=lambda c: (
            _is_broad_gapup_continuation_candidate(c),
            -float(c.get("score") or 0.0),
        )
    )
    return selected


def _orb_sector_confirmation_enabled(params: ORBStrategyParams) -> bool:
    return (
        bool(getattr(params, "sector_confirmation_enabled", False))
        or float(getattr(params, "sector_confirmation_score_weight", 0.0) or 0.0) != 0.0
        or float(getattr(params, "sector_confirmation_size_scale", 1.0) or 1.0) != 1.0
        or getattr(params, "sector_confirmation_liquid_min_premarket_dollar_vol", None)
        is not None
        or getattr(params, "sector_confirmation_liquid_size_scale", None) is not None
        or getattr(params, "sector_confirmation_illiquid_size_scale", None) is not None
        or float(getattr(params, "sector_confirmation_unconfirmed_size_scale", 1.0) or 1.0) != 1.0
    )


def _signed_orb_return_for_direction(cand: dict) -> float:
    ret = float(cand.get("orb_return") or 0.0)
    return ret if cand.get("direction") == "bullish" else -ret


def _apply_orb_sector_confirmation(
    raw_candidates: list[dict],
    params: ORBStrategyParams,
) -> None:
    """Annotate ORB candidates with same-sector opening confirmation.

    This is intentionally cross-sectional and lookahead-free: it only uses the
    first ORB candle features of candidates that already passed the day's
    candidate filters.
    """
    for cand in raw_candidates:
        cand["sector_confirmation_active"] = False
        cand["sector_confirmation_member_count"] = 0
        cand["sector_confirmation_avg_orb_return"] = 0.0
        cand["sector_confirmation_total_first_bar_dollar_vol"] = 0.0
        cand["sector_confirmation_score"] = 0.0

    if not raw_candidates or not _orb_sector_confirmation_enabled(params):
        return

    groups: dict[tuple[str, str], list[dict]] = {}
    for cand in raw_candidates:
        sector = str(cand.get("sector") or "UNKNOWN").strip()
        if not sector or sector.upper() == "UNKNOWN":
            continue
        if _signed_orb_return_for_direction(cand) <= 0:
            continue
        key = (sector, str(cand.get("direction") or ""))
        groups.setdefault(key, []).append(cand)

    min_members = max(1, int(getattr(params, "sector_confirmation_min_members", 2) or 2))
    min_avg_return = getattr(params, "sector_confirmation_min_avg_orb_return_pct", None)
    min_total_dollar_vol = getattr(
        params,
        "sector_confirmation_min_total_first_bar_dollar_vol",
        None,
    )

    for members in groups.values():
        member_count = len(members)
        avg_return = (
            sum(_signed_orb_return_for_direction(cand) for cand in members) / member_count
            if member_count
            else 0.0
        )
        total_dollar_vol = sum(
            float(cand.get("first_bar_dollar_vol") or 0.0) for cand in members
        )
        active = member_count >= min_members
        if min_avg_return is not None and avg_return < float(min_avg_return):
            active = False
        if min_total_dollar_vol is not None and total_dollar_vol < float(min_total_dollar_vol):
            active = False

        raw_score = member_count * max(avg_return, 0.0) if active else 0.0
        for cand in members:
            cand["sector_confirmation_active"] = active
            cand["sector_confirmation_member_count"] = member_count
            cand["sector_confirmation_avg_orb_return"] = avg_return
            cand["sector_confirmation_total_first_bar_dollar_vol"] = total_dollar_vol
            cand["sector_confirmation_score"] = raw_score


def _orb_sector_confirmation_size_scale(
    params: ORBStrategyParams,
    cand: dict,
) -> float:
    if not _orb_sector_confirmation_enabled(params):
        return 1.0
    if cand.get("sector_confirmation_active"):
        raw_scale = getattr(params, "sector_confirmation_size_scale", 1.0)
        min_liquid_pm = getattr(
            params,
            "sector_confirmation_liquid_min_premarket_dollar_vol",
            None,
        )
        if min_liquid_pm is not None:
            premarket_dollar_vol = float(cand.get("premarket_dollar_vol") or 0.0)
            if premarket_dollar_vol >= float(min_liquid_pm):
                raw_scale = getattr(
                    params,
                    "sector_confirmation_liquid_size_scale",
                    raw_scale,
                )
            else:
                raw_scale = getattr(
                    params,
                    "sector_confirmation_illiquid_size_scale",
                    raw_scale,
                )
    else:
        raw_scale = getattr(params, "sector_confirmation_unconfirmed_size_scale", 1.0)
    return max(0.0, min(2.0, float(1.0 if raw_scale is None else raw_scale)))


def _orb_soft_day_sector_confirmation_override_allows(
    params: ORBStrategyParams,
    soft_day_reason: str | None,
    cand: dict,
    *,
    score_rank_pct: float,
    trigger_type: str,
) -> bool:
    if not getattr(params, "soft_day_sector_confirmation_override_enabled", False):
        return False
    if trigger_type == "soft_day_vwap_reclaim":
        return False
    allowed_triggers = getattr(
        params,
        "soft_day_sector_confirmation_override_allowed_trigger_types",
        None,
    )
    if allowed_triggers is not None and trigger_type not in set(allowed_triggers):
        return False
    if not cand.get("sector_confirmation_active"):
        return False

    allowed_parts = getattr(
        params,
        "soft_day_sector_confirmation_override_allowed_reason_parts",
        None,
    )
    if allowed_parts is not None:
        reason_parts = set(str(soft_day_reason or "").split("+"))
        if not any(str(part) in reason_parts for part in allowed_parts):
            return False

    min_score = getattr(
        params,
        "soft_day_sector_confirmation_override_min_score_pct",
        None,
    )
    if min_score is not None and score_rank_pct < float(min_score):
        return False

    min_pm_dollar_vol = getattr(
        params,
        "soft_day_sector_confirmation_override_min_premarket_dollar_vol",
        None,
    )
    if min_pm_dollar_vol is not None:
        pm_dollar_vol = cand.get("premarket_dollar_vol")
        if pm_dollar_vol is None or float(pm_dollar_vol) < float(min_pm_dollar_vol):
            return False

    return True


def _orb_soft_day_sector_confirmation_override_base_sizing(
    params: ORBStrategyParams,
    *,
    active: bool,
    sizing_cap: float,
    adjusted_sizing: float,
) -> tuple[float, float | None]:
    """Apply the optional soft-day sector override floor before risk overlays."""
    if not active:
        return adjusted_sizing, None
    raw_floor = getattr(
        params,
        "soft_day_sector_confirmation_override_min_day_size_scale",
        None,
    )
    if raw_floor is None:
        return adjusted_sizing, None
    floor = max(0.0, min(1.0, float(raw_floor)))
    return max(adjusted_sizing, sizing_cap * floor), floor


def _market_return_at_entry_open(
    market_bars: list[dict] | None,
    entry_ts: dt.datetime,
    date_str: str,
) -> float | None:
    """Market return available at a candidate entry time without same-bar close lookahead."""
    if not market_bars:
        return None
    market_open = _market_open_ts(date_str)
    first_bar = _first_regular_bar(market_bars, market_open)
    if not first_bar:
        return None
    session_open = float(first_bar.get("open") or 0.0)
    if session_open <= 0:
        return None

    latest_bar: dict | None = None
    latest_ts: dt.datetime | None = None
    for bar in market_bars:
        ts = _parse_ts(bar["timestamp"])
        if ts < market_open or ts > entry_ts:
            continue
        if latest_ts is None or ts > latest_ts:
            latest_bar = bar
            latest_ts = ts
    if latest_bar is None or latest_ts is None:
        return None

    # If the candidate enters during this same bar, only the market bar open is
    # observable. Earlier bars can use close because they have completed.
    price = (
        float(latest_bar.get("open") or 0.0)
        if latest_ts == entry_ts
        else float(latest_bar.get("close") or 0.0)
    )
    if price <= 0:
        return None
    return (price - session_open) / session_open


def _entry_market_guard_scale(
    params: ORBStrategyParams,
    market_bars: list[dict] | None,
    entry_ts: dt.datetime,
    date_str: str,
    is_soft_day: bool,
) -> tuple[bool, float | None, float]:
    if not bool(getattr(params, "entry_market_guard_enabled", False)):
        return False, None, 1.0
    if is_soft_day and not bool(getattr(params, "entry_market_guard_apply_to_soft_day", True)):
        return False, None, 1.0
    threshold = getattr(params, "entry_market_guard_min_return_pct", None)
    if threshold is None:
        return False, None, 1.0
    market_return = _market_return_at_entry_open(market_bars, entry_ts, date_str)
    if market_return is None or market_return >= float(threshold):
        return False, market_return, 1.0
    raw_scale = float(getattr(params, "entry_market_guard_size_scale", 1.0) or 0.0)
    return True, market_return, max(0.0, min(1.0, raw_scale))


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 _orb_broad_gapup_continuation_allows(
    params: ORBStrategyParams,
    *,
    direction: str,
    gap_pct: float,
    rvol: float | None,
    avg_dollar_vol: float | None,
    premarket_dollar_vol: float,
    first_bar_dollar_vol: float,
    body_ratio: float,
    close_location: float,
    ret_5d: float | None,
    gap_zscore_20d: float | None,
) -> bool:
    """Gate the high-gap continuation sleeve without relaxing base ORB filters."""

    if not bool(getattr(params, "broad_gapup_continuation_enabled", False)):
        return False
    if direction != "bullish" or gap_pct <= 0:
        return False

    min_gap = getattr(params, "broad_gapup_continuation_min_gap_pct", None)
    if min_gap is not None and gap_pct < float(min_gap):
        return False

    max_gap = getattr(params, "broad_gapup_continuation_max_gap_pct", None)
    if max_gap is not None and gap_pct > float(max_gap):
        return False

    min_rvol = getattr(params, "broad_gapup_continuation_min_rvol", None)
    if min_rvol is not None and (rvol is None or float(rvol) < float(min_rvol)):
        return False

    min_avg_dollar_vol = getattr(
        params,
        "broad_gapup_continuation_min_avg_dollar_vol",
        None,
    )
    if (
        min_avg_dollar_vol is not None
        and (
            avg_dollar_vol is None
            or float(avg_dollar_vol) < float(min_avg_dollar_vol)
        )
    ):
        return False

    min_pm_dollar_vol = getattr(
        params,
        "broad_gapup_continuation_min_premarket_dollar_vol",
        None,
    )
    if min_pm_dollar_vol is not None and premarket_dollar_vol < float(min_pm_dollar_vol):
        return False

    min_first_bar_dollar_vol = getattr(
        params,
        "broad_gapup_continuation_min_first_bar_dollar_vol",
        None,
    )
    if (
        min_first_bar_dollar_vol is not None
        and first_bar_dollar_vol < float(min_first_bar_dollar_vol)
    ):
        return False

    min_body = getattr(params, "broad_gapup_continuation_min_body_ratio", None)
    if min_body is not None and body_ratio < float(min_body):
        return False

    min_close = getattr(params, "broad_gapup_continuation_min_close_location", None)
    if min_close is not None and close_location < float(min_close):
        return False

    min_ret5 = getattr(params, "broad_gapup_continuation_min_ret_5d", None)
    if min_ret5 is not None and (ret_5d is None or float(ret_5d) < float(min_ret5)):
        return False

    max_ret5 = getattr(params, "broad_gapup_continuation_max_ret_5d", None)
    if max_ret5 is not None and (ret_5d is None or float(ret_5d) > float(max_ret5)):
        return False

    max_gap_zscore = getattr(
        params,
        "broad_gapup_continuation_max_gap_zscore_20d",
        None,
    )
    if (
        max_gap_zscore is not None
        and (
            gap_zscore_20d is None
            or float(gap_zscore_20d) > float(max_gap_zscore)
        )
    ):
        return False

    return True


def _orb_market_thrust_liquid_continuation_allows(
    params: ORBStrategyParams,
    *,
    direction: str,
    gap_pct: float,
    rvol: float | None,
    avg_dollar_vol: float | None,
    first_bar_dollar_vol: float,
    first_bar_return_pct: float,
    body_ratio: float,
    close_location: float,
    ret_5d: float | None,
) -> bool:
    """Gate liquid normal-gap leaders for strong index-thrust days."""

    if not bool(getattr(params, "market_thrust_liquid_continuation_enabled", False)):
        return False
    if direction != "bullish" or gap_pct <= 0:
        return False

    min_gap = getattr(params, "market_thrust_liquid_continuation_min_gap_pct", None)
    if min_gap is not None and gap_pct < float(min_gap):
        return False

    max_gap = getattr(params, "market_thrust_liquid_continuation_max_gap_pct", None)
    if max_gap is not None and gap_pct > float(max_gap):
        return False

    min_rvol = getattr(params, "market_thrust_liquid_continuation_min_rvol", None)
    if min_rvol is not None and (rvol is None or float(rvol) < float(min_rvol)):
        return False

    min_first_return = getattr(
        params,
        "market_thrust_liquid_continuation_min_first_bar_return_pct",
        None,
    )
    if min_first_return is not None and first_bar_return_pct < float(min_first_return):
        return False

    min_first_bar_dollar_vol = getattr(
        params,
        "market_thrust_liquid_continuation_min_first_bar_dollar_vol",
        None,
    )
    if (
        min_first_bar_dollar_vol is not None
        and first_bar_dollar_vol < float(min_first_bar_dollar_vol)
    ):
        return False

    min_avg_dollar_vol = getattr(
        params,
        "market_thrust_liquid_continuation_min_avg_dollar_vol",
        None,
    )
    if (
        min_avg_dollar_vol is not None
        and (
            avg_dollar_vol is None
            or float(avg_dollar_vol) < float(min_avg_dollar_vol)
        )
    ):
        return False

    min_body = getattr(params, "market_thrust_liquid_continuation_min_body_ratio", None)
    if min_body is not None and body_ratio < float(min_body):
        return False

    min_close = getattr(params, "market_thrust_liquid_continuation_min_close_location", None)
    if min_close is not None and close_location < float(min_close):
        return False

    min_ret5 = getattr(params, "market_thrust_liquid_continuation_min_ret_5d", None)
    if min_ret5 is not None and (ret_5d is None or float(ret_5d) < float(min_ret5)):
        return False

    max_ret5 = getattr(params, "market_thrust_liquid_continuation_max_ret_5d", None)
    if max_ret5 is not None and (ret_5d is None or float(ret_5d) > float(max_ret5)):
        return False

    return True


def _orb_market_thrust_liquid_rank_key(cand: dict, rank_mode: str | None) -> tuple[float, ...]:
    mode = str(rank_mode or "").strip().lower()
    if mode in {"opening_impulse", "first_bar_return", "orb_return"}:
        return (
            float(cand.get("orb_return") or 0.0),
            float(cand.get("first_bar_dollar_vol") or 0.0),
            float(cand.get("score") or 0.0),
        )
    return (float(cand.get("score") or 0.0),)


def _orb_market_thrust_opening_impulse_reclaim_allows(
    params: ORBStrategyParams,
    *,
    direction: str,
    gap_pct: float,
    avg_dollar_vol: float | None,
    first_bar_dollar_vol: float,
    first_bar_return_pct: float,
    body_ratio: float,
    close_location: float,
    ret_5d: float | None,
) -> bool:
    """Gate small/negative-gap opening impulse candidates on index-thrust days."""

    if not bool(getattr(params, "market_thrust_opening_impulse_reclaim_enabled", False)):
        return False
    if direction != "bullish":
        return False

    min_gap = getattr(params, "market_thrust_opening_impulse_reclaim_min_gap_pct", None)
    if min_gap is not None and gap_pct < float(min_gap):
        return False

    max_gap = getattr(params, "market_thrust_opening_impulse_reclaim_max_gap_pct", None)
    if max_gap is not None and gap_pct > float(max_gap):
        return False

    min_first_return = getattr(
        params,
        "market_thrust_opening_impulse_reclaim_min_first_bar_return_pct",
        None,
    )
    if min_first_return is not None and first_bar_return_pct < float(min_first_return):
        return False

    min_first_bar_dollar_vol = getattr(
        params,
        "market_thrust_opening_impulse_reclaim_min_first_bar_dollar_vol",
        None,
    )
    if (
        min_first_bar_dollar_vol is not None
        and first_bar_dollar_vol < float(min_first_bar_dollar_vol)
    ):
        return False

    min_avg_dollar_vol = getattr(
        params,
        "market_thrust_opening_impulse_reclaim_min_avg_dollar_vol",
        None,
    )
    if (
        min_avg_dollar_vol is not None
        and (
            avg_dollar_vol is None
            or float(avg_dollar_vol) < float(min_avg_dollar_vol)
        )
    ):
        return False

    min_body = getattr(params, "market_thrust_opening_impulse_reclaim_min_body_ratio", None)
    if min_body is not None and body_ratio < float(min_body):
        return False

    min_close = getattr(params, "market_thrust_opening_impulse_reclaim_min_close_location", None)
    if min_close is not None and close_location < float(min_close):
        return False

    min_ret5 = getattr(params, "market_thrust_opening_impulse_reclaim_min_ret_5d", None)
    if min_ret5 is not None and (ret_5d is None or float(ret_5d) < float(min_ret5)):
        return False

    max_ret5 = getattr(params, "market_thrust_opening_impulse_reclaim_max_ret_5d", None)
    if max_ret5 is not None and (ret_5d is None or float(ret_5d) > float(max_ret5)):
        return False

    return True


def _intraday_continuation_reclaim_signal(
    mkt_bars: list[dict],
    params: ORBStrategyParams,
) -> dict | None:
    """Return a lookahead-safe continuation signal using only early session bars."""

    if not bool(getattr(params, "intraday_continuation_reclaim_enabled", False)):
        return None
    if not mkt_bars:
        return None

    signal_minutes = max(
        5,
        int(getattr(params, "intraday_continuation_reclaim_signal_minutes", 30) or 30),
    )
    signal_bars = max(1, signal_minutes // 5)
    if len(mkt_bars) <= signal_bars:
        return None

    window = mkt_bars[:signal_bars]
    entry_bar = mkt_bars[signal_bars]
    open_price = float(window[0].get("open", 0.0) or 0.0)
    close = float(window[-1].get("close", 0.0) or 0.0)
    if open_price <= 0 or close <= 0:
        return None

    signal_return = (close - open_price) / open_price
    window_high = max(float(bar.get("high", 0.0) or 0.0) for bar in window)
    window_low = min(float(bar.get("low", 0.0) or 0.0) for bar in window)
    signal_close_location = (
        (close - window_low) / (window_high - window_low)
        if window_high > window_low
        else 0.5
    )
    signal_dollar_vol = sum(
        float(bar.get("volume", 0.0) or 0.0)
        * float(bar.get("close", bar.get("open", 0.0)) or 0.0)
        for bar in window
    )

    min_signal_return = getattr(
        params,
        "intraday_continuation_reclaim_min_signal_return_pct",
        None,
    )
    if min_signal_return is not None and signal_return < float(min_signal_return):
        return None

    min_signal_dollar_vol = getattr(
        params,
        "intraday_continuation_reclaim_min_signal_dollar_vol",
        None,
    )
    if (
        min_signal_dollar_vol is not None
        and signal_dollar_vol < float(min_signal_dollar_vol)
    ):
        return None

    min_signal_close_location = getattr(
        params,
        "intraday_continuation_reclaim_min_signal_close_location",
        None,
    )
    if (
        min_signal_close_location is not None
        and signal_close_location < float(min_signal_close_location)
    ):
        return None

    signal_ts = _parse_ts(window[-1]["timestamp"])
    signal_vwap = _compute_running_vwap(mkt_bars, signal_ts)
    if (
        bool(getattr(params, "intraday_continuation_reclaim_require_signal_above_vwap", True))
        and signal_vwap is not None
        and close <= signal_vwap
    ):
        return None

    entry_price = float(entry_bar.get("open", 0.0) or 0.0)
    if entry_price <= 0:
        return None

    return {
        "signal_return": signal_return,
        "signal_dollar_vol": signal_dollar_vol,
        "signal_close_location": signal_close_location,
        "signal_ts": signal_ts,
        "entry_ts": _parse_ts(entry_bar["timestamp"]),
        "entry_price": entry_price,
    }


def _orb_intraday_continuation_reclaim_allows(
    params: ORBStrategyParams,
    *,
    direction: str,
    signal: dict | None,
    gap_pct: float,
    atr_pct: float | None,
    avg_dollar_vol: float | None,
    first_bar_dollar_vol: float,
    first_bar_return_pct: float,
    ret_5d: float | None,
    gap_zscore_20d: float | None,
) -> bool:
    """Gate late-morning intraday top-gainer continuation candidates."""

    if signal is None:
        return False
    if direction != "bullish":
        return False

    min_gap = getattr(params, "intraday_continuation_reclaim_min_gap_pct", None)
    if min_gap is not None and gap_pct < float(min_gap):
        return False

    max_gap = getattr(params, "intraday_continuation_reclaim_max_gap_pct", None)
    if max_gap is not None and gap_pct > float(max_gap):
        return False

    min_atr_pct = getattr(params, "intraday_continuation_reclaim_min_atr_pct", None)
    if min_atr_pct is not None and (
        atr_pct is None or float(atr_pct) < float(min_atr_pct)
    ):
        return False

    max_atr_pct = getattr(params, "intraday_continuation_reclaim_max_atr_pct", None)
    if max_atr_pct is not None and (
        atr_pct is None or float(atr_pct) > float(max_atr_pct)
    ):
        return False

    min_avg_dollar_vol = getattr(
        params,
        "intraday_continuation_reclaim_min_avg_dollar_vol",
        None,
    )
    if (
        min_avg_dollar_vol is not None
        and (
            avg_dollar_vol is None
            or float(avg_dollar_vol) < float(min_avg_dollar_vol)
        )
    ):
        return False

    min_first_return = getattr(
        params,
        "intraday_continuation_reclaim_min_first_bar_return_pct",
        None,
    )
    if min_first_return is not None and first_bar_return_pct < float(min_first_return):
        return False

    min_first_dollar_vol = getattr(
        params,
        "intraday_continuation_reclaim_min_first_bar_dollar_vol",
        None,
    )
    if (
        min_first_dollar_vol is not None
        and first_bar_dollar_vol < float(min_first_dollar_vol)
    ):
        return False

    min_ret5 = getattr(params, "intraday_continuation_reclaim_min_ret_5d", None)
    if min_ret5 is not None and (ret_5d is None or float(ret_5d) < float(min_ret5)):
        return False

    max_ret5 = getattr(params, "intraday_continuation_reclaim_max_ret_5d", None)
    if max_ret5 is not None and (ret_5d is None or float(ret_5d) > float(max_ret5)):
        return False

    max_gap_zscore = getattr(
        params,
        "intraday_continuation_reclaim_max_gap_zscore_20d",
        None,
    )
    if (
        max_gap_zscore is not None
        and (
            gap_zscore_20d is None
            or float(gap_zscore_20d) > float(max_gap_zscore)
        )
    ):
        return False

    return True


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,
    _suppress_empty_log: bool = False,
    overlay_tickers: set[str] | None = None,
    iex_live_mode: bool = False,
) -> list[dict]:
    """Identify and rank ORB candidates for a given trading day.

    Pipeline per ticker:
    1. Get market-hours bars, require >= n_orb_bars + 1
    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
    _f_crowded_gap = 0
    _f_countertrend_gap = 0
    _f_distressed_reclaim = 0
    _f_hot_reclaim = 0
    _f_weak_downside_reclaim = 0
    _f_quiet_downside_reclaim = 0
    _f_stale_obv_reversal = 0
    _f_stalled_gap_up = 0
    _f_liquid_stalled_gap_up = 0
    _f_thin_gap_up_loss_cap = 0
    _f_moderate_downside_loss_cap = 0
    _f_gap_up_fill_exit = 0
    _f_intraday_continuation_reclaim = 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)

        # 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

        # 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
        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

        intraday_continuation_signal = _intraday_continuation_reclaim_signal(
            mkt_bars,
            params,
        )

        # 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",
            "vwap_reclaim_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))
        )
        allow_intraday_continuation_direction = intraday_continuation_signal is not None
        direction_allowed_by_intraday_continuation = False
        if params.entry_direction == "long_only":
            if (
                direction == "bearish"
                and not allow_red_to_green_breakout
                and not allow_intraday_continuation_direction
            ):
                _f_dir += 1
                continue
            if direction == "bearish" and (
                allow_red_to_green_breakout or allow_intraday_continuation_direction
            ):
                direction = "bullish"
                direction_allowed_by_intraday_continuation = bool(
                    allow_intraday_continuation_direction
                    and not allow_red_to_green_breakout
                )
            if (
                direction == "doji"
                and not allow_doji_breakout
                and not allow_intraday_continuation_direction
            ):
                _f_dir += 1
                continue
            if direction == "doji" and (
                allow_doji_breakout or allow_intraday_continuation_direction
            ):
                direction = "bullish"
                direction_allowed_by_intraday_continuation = bool(
                    allow_intraday_continuation_direction
                    and not allow_doji_breakout
                )
        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)

        # IEX live mode: the Oracle /alpaca/intraday/today endpoint uses the IEX
        # feed, which captures only ~2–5% of actual US equity volume (IEX is a
        # single exchange vs. the full SIP tape).  This creates a fundamental
        # mismatch with two filters that are calibrated on SIP historical data:
        #
        #   1. min_premarket_dollar_vol — Pre-market IEX bars are extremely sparse;
        #      many liquid gappers show $0 IEX pre-market vol even when true SIP
        #      pre-market vol exceeds $2.5M+.  A fixed scaling factor (e.g. ×25)
        #      is unworkable because IEX market share varies by ticker/time and
        #      pre-market IEX bars are often entirely absent.
        #
        #   2. min_rvol — RVOL is computed as (IEX first-bar volume) /
        #      (avg_daily_vol_14d from SIP).  Since the numerator is IEX (~3% of
        #      SIP) and the denominator is full SIP volume, the computed RVOL is
        #      roughly 30–50× lower than the true RVOL.  A stock with genuine
        #      RVOL of 5× would register as ~0.15× here, failing min_rvol=3.0.
        #
        # The live engine already enforces liquidity through SIP-sourced fields
        # from enrichment (min_avg_dollar_volume from 30-day historical daily bars,
        # min_price, ATR guards) and from snapshot data (min_abs_gap_pct).  Those
        # filters are unaffected by the IEX vs. SIP mismatch.  We therefore bypass
        # the two IEX-incompatible volume filters in live mode at the filter site,
        # while preserving the raw IEX value for diagnostics/ranking.

        # ATR filter
        base_atr_pct_filter_failed = False
        atr_ratio = None
        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:
                base_atr_pct_filter_failed = True
            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
        raw_rvol = compute_rvol_approx(orb_vol, avg_daily_vol) if avg_daily_vol else None
        rvol = raw_rvol

        # 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)
        raw_first_bar_dollar_vol = orb_vol * open_price
        first_bar_dollar_vol = raw_first_bar_dollar_vol
        raw_premarket_dollar_vol = premarket_dollar_vol
        if iex_live_mode:
            iex_multiplier = max(
                1.0,
                float(getattr(params, "iex_live_intraday_volume_multiplier", 1.0) or 1.0),
            )
            if iex_multiplier > 1.0:
                first_bar_dollar_vol = raw_first_bar_dollar_vol * iex_multiplier
                premarket_dollar_vol = raw_premarket_dollar_vol * iex_multiplier
                if raw_rvol is not None:
                    rvol = raw_rvol * iex_multiplier

        # ORB candle directional conviction: computed before max_gap_pct so a
        # bounded high-gap sleeve can inspect structure without relaxing the
        # normal ORB gap cap globally.
        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

        ret_5d = ticker_enrich.get("ret_5d")
        gap_zscore_20d = ticker_enrich.get("gap_zscore_20d")
        broad_gapup_continuation_active = False
        first_bar_return_pct = (
            (orb_close - orb_open_price) / orb_open_price
            if orb_open_price > 0 else 0.0
        )
        market_thrust_liquid_continuation_active = (
            _orb_market_thrust_liquid_continuation_allows(
                params,
                direction=direction,
                gap_pct=gap_pct,
                rvol=rvol,
                avg_dollar_vol=avg_dollar_vol,
                first_bar_dollar_vol=first_bar_dollar_vol,
                first_bar_return_pct=first_bar_return_pct,
                body_ratio=body_ratio,
                close_location=close_location,
                ret_5d=_float_or_none(ret_5d),
            )
        )
        market_thrust_opening_impulse_reclaim_active = (
            _orb_market_thrust_opening_impulse_reclaim_allows(
                params,
                direction=direction,
                gap_pct=gap_pct,
                avg_dollar_vol=avg_dollar_vol,
                first_bar_dollar_vol=first_bar_dollar_vol,
                first_bar_return_pct=first_bar_return_pct,
                body_ratio=body_ratio,
                close_location=close_location,
                ret_5d=_float_or_none(ret_5d),
            )
        )
        intraday_continuation_reclaim_active = (
            _orb_intraday_continuation_reclaim_allows(
                params,
                direction=direction,
                signal=intraday_continuation_signal,
                gap_pct=gap_pct,
                atr_pct=_float_or_none(atr_ratio),
                avg_dollar_vol=avg_dollar_vol,
                first_bar_dollar_vol=first_bar_dollar_vol,
                first_bar_return_pct=first_bar_return_pct,
                ret_5d=_float_or_none(ret_5d),
                gap_zscore_20d=_float_or_none(gap_zscore_20d),
            )
        )
        if base_atr_pct_filter_failed and not intraday_continuation_reclaim_active:
            _f_atr += 1
            continue
        if direction_allowed_by_intraday_continuation and not intraday_continuation_reclaim_active:
            _f_dir += 1
            continue

        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
                and not market_thrust_liquid_continuation_active
                and not market_thrust_opening_impulse_reclaim_active
                and not intraday_continuation_reclaim_active
            ):
                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:
                broad_gapup_continuation_active = _orb_broad_gapup_continuation_allows(
                    params,
                    direction=direction,
                    gap_pct=gap_pct,
                    rvol=rvol,
                    avg_dollar_vol=avg_dollar_vol,
                    premarket_dollar_vol=premarket_dollar_vol,
                    first_bar_dollar_vol=first_bar_dollar_vol,
                    body_ratio=body_ratio,
                    close_location=close_location,
                    ret_5d=_float_or_none(ret_5d),
                    gap_zscore_20d=_float_or_none(gap_zscore_20d),
                )
                if (
                    not broad_gapup_continuation_active
                    and not market_thrust_opening_impulse_reclaim_active
                    and not intraday_continuation_reclaim_active
                ):
                    _f_gap += 1
                    continue

            if (
                not iex_live_mode  # see iex_live_mode block above: IEX RVOL ≈ 3% of SIP RVOL
                and params.min_rvol is not None
                and not broad_gapup_continuation_active
                and not market_thrust_liquid_continuation_active
                and not market_thrust_opening_impulse_reclaim_active
                and not intraday_continuation_reclaim_active
                and (rvol is None or rvol < params.min_rvol)
            ):
                _f_rvol += 1
                continue

            if (
                params.min_premarket_dollar_vol is not None
                and not iex_live_mode  # see iex_live_mode block above: IEX pre-market volume is sparse
                and not broad_gapup_continuation_active
                and not market_thrust_liquid_continuation_active
                and not market_thrust_opening_impulse_reclaim_active
                and not intraday_continuation_reclaim_active
                and premarket_dollar_vol < params.min_premarket_dollar_vol
            ):
                _f_dolvol += 1
                continue

        # 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.
        if ret_5d is not None:
            momentum = ret_5d if direction == "bullish" else -ret_5d
        else:
            momentum = 0.0
        if intraday_continuation_reclaim_active and intraday_continuation_signal:
            momentum = max(
                momentum,
                float(intraday_continuation_signal.get("signal_return") or 0.0),
            )

        obv_slope_20 = ticker_enrich.get("obv_slope_20")
        obv_slope_5 = ticker_enrich.get("obv_slope_5")
        crowded_gap_requires_confirmation = False
        crowded_gap_size_scale = 1.0
        countertrend_gap_requires_confirmation = False
        countertrend_gap_size_scale = 1.0
        distressed_reclaim_requires_confirmation = False
        distressed_reclaim_size_scale = 1.0
        distressed_reclaim_skip_trade = False
        distressed_reclaim_reserve_cash = False
        distressed_reclaim_reserve_full_cash = False
        distressed_reclaim_triggered = False
        distressed_reclaim_loss_cap_pct = None
        hot_reclaim_requires_confirmation = False
        hot_reclaim_size_scale = 1.0
        hot_reclaim_skip_trade = False
        hot_reclaim_reserve_cash = False
        hot_reclaim_reserve_full_cash = False
        hot_reclaim_loss_cap_pct = None
        weak_downside_reclaim_requires_confirmation = False
        weak_downside_reclaim_size_scale = 1.0
        weak_downside_reclaim_skip_trade = False
        weak_downside_reclaim_reserve_cash = False
        weak_downside_reclaim_reserve_full_cash = False
        weak_downside_reclaim_loss_cap_pct = None
        quiet_downside_reclaim_requires_confirmation = False
        quiet_downside_reclaim_size_scale = 1.0
        quiet_downside_reclaim_skip_trade = False
        quiet_downside_reclaim_reserve_cash = False
        quiet_downside_reclaim_reserve_full_cash = False
        quiet_downside_reclaim_loss_cap_pct = None
        stale_obv_reversal_requires_confirmation = False
        stale_obv_reversal_size_scale = 1.0
        stale_obv_reversal_skip_trade = False
        stale_obv_reversal_reserve_cash = False
        stale_obv_reversal_reserve_full_cash = False
        stale_obv_reversal_loss_cap_pct = None
        stalled_gap_up_requires_confirmation = False
        stalled_gap_up_size_scale = 1.0
        stalled_gap_up_skip_trade = False
        stalled_gap_up_reserve_cash = False
        stalled_gap_up_reserve_full_cash = False
        stalled_gap_up_loss_cap_pct = None
        liquid_stalled_gap_up_requires_confirmation = False
        liquid_stalled_gap_up_size_scale = 1.0
        liquid_stalled_gap_up_skip_trade = False
        liquid_stalled_gap_up_reserve_cash = False
        liquid_stalled_gap_up_reserve_full_cash = False
        liquid_stalled_gap_up_loss_cap_pct = None
        thin_gap_up_loss_cap_active = False
        thin_gap_up_loss_cap_pct = None
        moderate_downside_loss_cap_active = False
        moderate_downside_loss_cap_pct = None
        gap_up_fill_exit_active = False
        crowded_gap_min_gap = getattr(params, "crowded_gap_reject_min_gap_pct", None)
        crowded_gap_min_ret5 = getattr(params, "crowded_gap_reject_min_ret_5d", None)
        crowded_gap_min_body = getattr(params, "crowded_gap_reject_min_body_ratio", None)
        crowded_gap_min_close = getattr(params, "crowded_gap_reject_min_close_location", None)
        crowded_gap_max_premarket = getattr(
            params, "crowded_gap_max_premarket_dollar_vol", None
        )
        if (
            direction == "bullish"
            and crowded_gap_min_gap is not None
            and crowded_gap_min_ret5 is not None
            and crowded_gap_min_body is not None
            and crowded_gap_min_close is not None
            and ret_5d is not None
            and gap_pct >= crowded_gap_min_gap
            and ret_5d >= crowded_gap_min_ret5
            and body_ratio >= crowded_gap_min_body
            and close_location >= crowded_gap_min_close
            and (
                crowded_gap_max_premarket is None
                or premarket_dollar_vol <= float(crowded_gap_max_premarket)
            )
        ):
            _f_crowded_gap += 1
            crowded_gap_action = str(getattr(params, "crowded_gap_action", "reject") or "reject")
            if crowded_gap_action in ("confirm", "confirm_scale"):
                crowded_gap_requires_confirmation = True
            if crowded_gap_action in ("scale", "confirm_scale"):
                crowded_gap_size_scale = max(
                    0.0,
                    min(1.0, float(getattr(params, "crowded_gap_size_scale", 1.0) or 1.0)),
                )
            if crowded_gap_action not in ("confirm", "scale", "confirm_scale"):
                continue

        countertrend_gap_min_gap = getattr(params, "countertrend_gap_min_gap_pct", None)
        countertrend_gap_max_ret5 = getattr(params, "countertrend_gap_max_ret_5d", None)
        countertrend_gap_min_body = getattr(params, "countertrend_gap_min_body_ratio", None)
        countertrend_gap_min_close = getattr(params, "countertrend_gap_min_close_location", None)
        if (
            direction == "bullish"
            and countertrend_gap_min_gap is not None
            and countertrend_gap_max_ret5 is not None
            and countertrend_gap_min_body is not None
            and countertrend_gap_min_close is not None
            and ret_5d is not None
            and gap_pct >= countertrend_gap_min_gap
            and ret_5d <= countertrend_gap_max_ret5
            and body_ratio >= countertrend_gap_min_body
            and close_location >= countertrend_gap_min_close
        ):
            _f_countertrend_gap += 1
            countertrend_gap_action = str(
                getattr(params, "countertrend_gap_action", "reject") or "reject"
            )
            if countertrend_gap_action in ("confirm", "confirm_scale"):
                countertrend_gap_requires_confirmation = True
            if countertrend_gap_action in ("scale", "confirm_scale"):
                countertrend_gap_size_scale = max(
                    0.0,
                    min(1.0, float(getattr(params, "countertrend_gap_size_scale", 1.0) or 1.0)),
                )
            if countertrend_gap_action not in ("confirm", "scale", "confirm_scale"):
                continue

        distressed_min_abs_gap = getattr(params, "distressed_reclaim_min_abs_gap_pct", None)
        distressed_max_ret5 = getattr(params, "distressed_reclaim_max_ret_5d", None)
        distressed_min_premarket = getattr(
            params, "distressed_reclaim_min_premarket_dollar_vol", None
        )
        distressed_max_close = getattr(params, "distressed_reclaim_max_close_location", None)
        distressed_max_obv20 = getattr(params, "distressed_reclaim_max_obv_slope_20d", None)
        distressed_max_obv5 = getattr(params, "distressed_reclaim_max_obv_slope_5d", None)
        if (
            direction == "bullish"
            and distressed_min_abs_gap is not None
            and distressed_max_ret5 is not None
            and distressed_min_premarket is not None
            and distressed_max_close is not None
            and ret_5d is not None
            and (
                distressed_max_obv20 is None
                or (obv_slope_20 is not None and obv_slope_20 <= float(distressed_max_obv20))
            )
            and (
                distressed_max_obv5 is None
                or (obv_slope_5 is not None and obv_slope_5 <= float(distressed_max_obv5))
            )
            and gap_pct <= -float(distressed_min_abs_gap)
            and ret_5d <= float(distressed_max_ret5)
            and premarket_dollar_vol >= float(distressed_min_premarket)
            and close_location <= float(distressed_max_close)
        ):
            _f_distressed_reclaim += 1
            distressed_reclaim_triggered = True
            distressed_action = str(
                getattr(params, "distressed_reclaim_action", "reject") or "reject"
            )
            if distressed_action in ("confirm", "confirm_scale", "confirm_scale_reserve"):
                distressed_reclaim_requires_confirmation = True
            if distressed_action in ("scale", "confirm_scale", "scale_reserve", "confirm_scale_reserve"):
                distressed_reclaim_size_scale = max(
                    0.0,
                    min(1.0, float(getattr(params, "distressed_reclaim_size_scale", 1.0) or 1.0)),
                )
            if distressed_action in (
                "skip",
                "no_backfill",
                "skip_trade",
                "skip_reserve",
                "reserve_skip",
            ):
                distressed_reclaim_skip_trade = True
            if distressed_action in ("skip_reserve", "reserve_skip"):
                distressed_reclaim_reserve_cash = True
            if distressed_action in ("scale_reserve", "confirm_scale_reserve"):
                distressed_reclaim_reserve_full_cash = True
            if getattr(params, "distressed_reclaim_loss_cap_pct", None) is not None:
                distressed_reclaim_loss_cap_pct = max(
                    0.0,
                    float(getattr(params, "distressed_reclaim_loss_cap_pct") or 0.0),
                )
            if distressed_action not in (
                "confirm",
                "scale",
                "confirm_scale",
                "scale_reserve",
                "confirm_scale_reserve",
                "loss_cap",
                "skip",
                "no_backfill",
                "skip_trade",
                "skip_reserve",
                "reserve_skip",
            ):
                continue

        weak_downside_min_abs_gap = getattr(
            params, "weak_downside_reclaim_min_abs_gap_pct", None
        )
        weak_downside_min_ret5 = getattr(params, "weak_downside_reclaim_min_ret_5d", None)
        weak_downside_max_ret5 = getattr(params, "weak_downside_reclaim_max_ret_5d", None)
        weak_downside_max_premarket = getattr(
            params, "weak_downside_reclaim_max_premarket_dollar_vol", None
        )
        weak_downside_max_body = getattr(
            params, "weak_downside_reclaim_max_body_ratio", None
        )
        weak_downside_max_close = getattr(
            params, "weak_downside_reclaim_max_close_location", None
        )
        weak_downside_max_obv5 = getattr(
            params, "weak_downside_reclaim_max_obv_slope_5d", None
        )
        if (
            direction == "bullish"
            and weak_downside_min_abs_gap is not None
            and weak_downside_max_close is not None
            and ret_5d is not None
            and gap_pct <= -float(weak_downside_min_abs_gap)
            and (
                weak_downside_min_ret5 is None
                or ret_5d >= float(weak_downside_min_ret5)
            )
            and (
                weak_downside_max_ret5 is None
                or ret_5d <= float(weak_downside_max_ret5)
            )
            and (
                weak_downside_max_premarket is None
                or premarket_dollar_vol <= float(weak_downside_max_premarket)
            )
            and (
                weak_downside_max_body is None
                or body_ratio <= float(weak_downside_max_body)
            )
            and close_location <= float(weak_downside_max_close)
            and (
                weak_downside_max_obv5 is None
                or (obv_slope_5 is not None and obv_slope_5 <= float(weak_downside_max_obv5))
            )
        ):
            _f_weak_downside_reclaim += 1
            weak_downside_action = str(
                getattr(params, "weak_downside_reclaim_action", "reject") or "reject"
            )
            if weak_downside_action in ("confirm", "confirm_scale", "confirm_scale_reserve"):
                weak_downside_reclaim_requires_confirmation = True
            if weak_downside_action in (
                "scale",
                "confirm_scale",
                "scale_reserve",
                "confirm_scale_reserve",
            ):
                weak_downside_reclaim_size_scale = max(
                    0.0,
                    min(
                        1.0,
                        float(
                            getattr(params, "weak_downside_reclaim_size_scale", 1.0)
                            or 1.0
                        ),
                    ),
                )
            if weak_downside_action in (
                "skip",
                "no_backfill",
                "skip_trade",
                "skip_reserve",
                "reserve_skip",
            ):
                weak_downside_reclaim_skip_trade = True
            if weak_downside_action in ("skip_reserve", "reserve_skip"):
                weak_downside_reclaim_reserve_cash = True
            if weak_downside_action in ("scale_reserve", "confirm_scale_reserve"):
                weak_downside_reclaim_reserve_full_cash = True
            if getattr(params, "weak_downside_reclaim_loss_cap_pct", None) is not None:
                weak_downside_reclaim_loss_cap_pct = max(
                    0.0,
                    float(getattr(params, "weak_downside_reclaim_loss_cap_pct") or 0.0),
                )
            if weak_downside_action not in (
                "confirm",
                "scale",
                "confirm_scale",
                "scale_reserve",
                "confirm_scale_reserve",
                "loss_cap",
                "skip",
                "no_backfill",
                "skip_trade",
                "skip_reserve",
                "reserve_skip",
            ):
                continue

        quiet_downside_min_abs_gap = getattr(
            params, "quiet_downside_reclaim_min_abs_gap_pct", None
        )
        quiet_downside_min_ret5 = getattr(params, "quiet_downside_reclaim_min_ret_5d", None)
        quiet_downside_max_ret5 = getattr(params, "quiet_downside_reclaim_max_ret_5d", None)
        quiet_downside_max_premarket = getattr(
            params, "quiet_downside_reclaim_max_premarket_dollar_vol", None
        )
        quiet_downside_max_body = getattr(
            params, "quiet_downside_reclaim_max_body_ratio", None
        )
        quiet_downside_max_close = getattr(
            params, "quiet_downside_reclaim_max_close_location", None
        )
        quiet_downside_max_obv5 = getattr(
            params, "quiet_downside_reclaim_max_obv_slope_5d", None
        )
        if (
            direction == "bullish"
            and quiet_downside_min_abs_gap is not None
            and quiet_downside_max_close is not None
            and ret_5d is not None
            and gap_pct <= -float(quiet_downside_min_abs_gap)
            and (
                quiet_downside_min_ret5 is None
                or ret_5d >= float(quiet_downside_min_ret5)
            )
            and (
                quiet_downside_max_ret5 is None
                or ret_5d <= float(quiet_downside_max_ret5)
            )
            and (
                quiet_downside_max_premarket is None
                or premarket_dollar_vol <= float(quiet_downside_max_premarket)
            )
            and (
                quiet_downside_max_body is None
                or body_ratio <= float(quiet_downside_max_body)
            )
            and close_location <= float(quiet_downside_max_close)
            and (
                quiet_downside_max_obv5 is None
                or (obv_slope_5 is not None and obv_slope_5 <= float(quiet_downside_max_obv5))
            )
        ):
            _f_quiet_downside_reclaim += 1
            quiet_downside_action = str(
                getattr(params, "quiet_downside_reclaim_action", "reject") or "reject"
            )
            if quiet_downside_action in ("confirm", "confirm_scale", "confirm_scale_reserve"):
                quiet_downside_reclaim_requires_confirmation = True
            if quiet_downside_action in (
                "scale",
                "confirm_scale",
                "scale_reserve",
                "confirm_scale_reserve",
            ):
                quiet_downside_reclaim_size_scale = max(
                    0.0,
                    min(
                        1.0,
                        float(
                            getattr(params, "quiet_downside_reclaim_size_scale", 1.0)
                            or 1.0
                        ),
                    ),
                )
            if quiet_downside_action in (
                "skip",
                "no_backfill",
                "skip_trade",
                "skip_reserve",
                "reserve_skip",
            ):
                quiet_downside_reclaim_skip_trade = True
            if quiet_downside_action in ("skip_reserve", "reserve_skip"):
                quiet_downside_reclaim_reserve_cash = True
            if quiet_downside_action in ("scale_reserve", "confirm_scale_reserve"):
                quiet_downside_reclaim_reserve_full_cash = True
            if getattr(params, "quiet_downside_reclaim_loss_cap_pct", None) is not None:
                quiet_downside_reclaim_loss_cap_pct = max(
                    0.0,
                    float(getattr(params, "quiet_downside_reclaim_loss_cap_pct") or 0.0),
                )
            if quiet_downside_action not in (
                "confirm",
                "scale",
                "confirm_scale",
                "scale_reserve",
                "confirm_scale_reserve",
                "loss_cap",
                "skip",
                "no_backfill",
                "skip_trade",
                "skip_reserve",
                "reserve_skip",
            ):
                continue

        stale_obv_max_obv20 = getattr(
            params, "stale_obv_reversal_max_obv_slope_20d", None
        )
        stale_obv_min_ret5 = getattr(params, "stale_obv_reversal_min_ret_5d", None)
        stale_obv_max_ret5 = getattr(params, "stale_obv_reversal_max_ret_5d", None)
        stale_obv_min_gap = getattr(params, "stale_obv_reversal_min_gap_pct", None)
        stale_obv_max_rvol = getattr(params, "stale_obv_reversal_max_rvol", None)
        stale_obv_max_body = getattr(params, "stale_obv_reversal_max_body_ratio", None)
        stale_obv_max_close = getattr(
            params, "stale_obv_reversal_max_close_location", None
        )
        stale_obv_max_orb_return = getattr(
            params, "stale_obv_reversal_max_orb_return", None
        )
        if (
            direction == "bullish"
            and stale_obv_max_obv20 is not None
            and obv_slope_20 is not None
            and ret_5d is not None
            and obv_slope_20 <= float(stale_obv_max_obv20)
            and (
                stale_obv_min_ret5 is None
                or ret_5d >= float(stale_obv_min_ret5)
            )
            and (
                stale_obv_max_ret5 is None
                or ret_5d <= float(stale_obv_max_ret5)
            )
            and (
                stale_obv_min_gap is None
                or (gap_pct is not None and gap_pct >= float(stale_obv_min_gap))
            )
            and (
                stale_obv_max_rvol is None
                or (rvol is not None and rvol <= float(stale_obv_max_rvol))
            )
            and (
                stale_obv_max_body is None
                or (body_ratio is not None and body_ratio <= float(stale_obv_max_body))
            )
            and (
                stale_obv_max_close is None
                or close_location <= float(stale_obv_max_close)
            )
            and (
                stale_obv_max_orb_return is None
                or (
                    first_bar_return_pct is not None
                    and first_bar_return_pct <= float(stale_obv_max_orb_return)
                )
            )
        ):
            _f_stale_obv_reversal += 1
            stale_obv_action = str(
                getattr(params, "stale_obv_reversal_action", "reject") or "reject"
            )
            if stale_obv_action in ("confirm", "confirm_scale", "confirm_scale_reserve"):
                stale_obv_reversal_requires_confirmation = True
            if stale_obv_action in (
                "scale",
                "confirm_scale",
                "scale_reserve",
                "confirm_scale_reserve",
            ):
                stale_obv_reversal_size_scale = max(
                    0.0,
                    min(
                        1.0,
                        float(
                            getattr(params, "stale_obv_reversal_size_scale", 1.0)
                            or 1.0
                        ),
                    ),
                )
            if stale_obv_action in (
                "skip",
                "no_backfill",
                "skip_trade",
                "skip_reserve",
                "reserve_skip",
            ):
                stale_obv_reversal_skip_trade = True
            if stale_obv_action in ("skip_reserve", "reserve_skip"):
                stale_obv_reversal_reserve_cash = True
            if stale_obv_action in ("scale_reserve", "confirm_scale_reserve"):
                stale_obv_reversal_reserve_full_cash = True
            if getattr(params, "stale_obv_reversal_loss_cap_pct", None) is not None:
                stale_obv_reversal_loss_cap_pct = max(
                    0.0,
                    float(getattr(params, "stale_obv_reversal_loss_cap_pct") or 0.0),
                )
            if stale_obv_action not in (
                "confirm",
                "scale",
                "confirm_scale",
                "scale_reserve",
                "confirm_scale_reserve",
                "loss_cap",
                "skip",
                "no_backfill",
                "skip_trade",
                "skip_reserve",
                "reserve_skip",
            ):
                continue

        hot_min_abs_gap = getattr(params, "hot_reclaim_min_abs_gap_pct", None)
        hot_min_ret5 = getattr(params, "hot_reclaim_min_ret_5d", None)
        hot_max_premarket = getattr(params, "hot_reclaim_max_premarket_dollar_vol", None)
        hot_min_body = getattr(params, "hot_reclaim_min_body_ratio", None)
        hot_min_close = getattr(params, "hot_reclaim_min_close_location", None)
        if (
            direction == "bullish"
            and hot_min_abs_gap is not None
            and hot_min_ret5 is not None
            and hot_min_body is not None
            and hot_min_close is not None
            and ret_5d is not None
            and gap_pct <= -float(hot_min_abs_gap)
            and ret_5d >= float(hot_min_ret5)
            and body_ratio >= float(hot_min_body)
            and close_location >= float(hot_min_close)
            and (
                hot_max_premarket is None
                or premarket_dollar_vol <= float(hot_max_premarket)
            )
        ):
            _f_hot_reclaim += 1
            hot_action = str(getattr(params, "hot_reclaim_action", "reject") or "reject")
            if hot_action in ("confirm", "confirm_scale", "confirm_scale_reserve"):
                hot_reclaim_requires_confirmation = True
            if hot_action in ("scale", "confirm_scale", "scale_reserve", "confirm_scale_reserve"):
                hot_reclaim_size_scale = max(
                    0.0,
                    min(1.0, float(getattr(params, "hot_reclaim_size_scale", 1.0) or 1.0)),
                )
            if hot_action in (
                "skip",
                "no_backfill",
                "skip_trade",
                "skip_reserve",
                "reserve_skip",
            ):
                hot_reclaim_skip_trade = True
            if hot_action in ("skip_reserve", "reserve_skip"):
                hot_reclaim_reserve_cash = True
            if hot_action in ("scale_reserve", "confirm_scale_reserve"):
                hot_reclaim_reserve_full_cash = True
            if getattr(params, "hot_reclaim_loss_cap_pct", None) is not None:
                hot_reclaim_loss_cap_pct = max(
                    0.0,
                    float(getattr(params, "hot_reclaim_loss_cap_pct") or 0.0),
                )
            if hot_action not in (
                "confirm",
                "scale",
                "confirm_scale",
                "scale_reserve",
                "confirm_scale_reserve",
                "loss_cap",
                "skip",
                "no_backfill",
                "skip_trade",
                "skip_reserve",
                "reserve_skip",
            ):
                continue

        stalled_min_gap = getattr(params, "stalled_gap_up_min_gap_pct", None)
        stalled_min_ret5 = getattr(params, "stalled_gap_up_min_ret_5d", None)
        stalled_max_ret5 = getattr(params, "stalled_gap_up_max_ret_5d", None)
        stalled_max_premarket = getattr(
            params, "stalled_gap_up_max_premarket_dollar_vol", None
        )
        stalled_max_close = getattr(params, "stalled_gap_up_max_close_location", None)
        stalled_max_obv5 = getattr(params, "stalled_gap_up_max_obv_slope_5d", None)
        if (
            direction == "bullish"
            and stalled_min_gap is not None
            and stalled_max_close is not None
            and ret_5d is not None
            and gap_pct >= float(stalled_min_gap)
            and (
                stalled_min_ret5 is None
                or ret_5d >= float(stalled_min_ret5)
            )
            and (
                stalled_max_ret5 is None
                or ret_5d <= float(stalled_max_ret5)
            )
            and close_location <= float(stalled_max_close)
            and (
                stalled_max_premarket is None
                or premarket_dollar_vol <= float(stalled_max_premarket)
            )
            and (
                stalled_max_obv5 is None
                or (obv_slope_5 is not None and obv_slope_5 <= float(stalled_max_obv5))
            )
        ):
            _f_stalled_gap_up += 1
            stalled_action = str(
                getattr(params, "stalled_gap_up_action", "reject") or "reject"
            )
            if stalled_action in ("confirm", "confirm_scale", "confirm_scale_reserve"):
                stalled_gap_up_requires_confirmation = True
            if stalled_action in ("scale", "confirm_scale", "scale_reserve", "confirm_scale_reserve"):
                stalled_gap_up_size_scale = max(
                    0.0,
                    min(1.0, float(getattr(params, "stalled_gap_up_size_scale", 1.0) or 1.0)),
                )
            if stalled_action in (
                "skip",
                "no_backfill",
                "skip_trade",
                "skip_reserve",
                "reserve_skip",
            ):
                stalled_gap_up_skip_trade = True
            if stalled_action in ("skip_reserve", "reserve_skip"):
                stalled_gap_up_reserve_cash = True
            if stalled_action in ("scale_reserve", "confirm_scale_reserve"):
                stalled_gap_up_reserve_full_cash = True
            if getattr(params, "stalled_gap_up_loss_cap_pct", None) is not None:
                stalled_gap_up_loss_cap_pct = max(
                    0.0,
                    float(getattr(params, "stalled_gap_up_loss_cap_pct") or 0.0),
                )
            if stalled_action not in (
                "confirm",
                "scale",
                "confirm_scale",
                "scale_reserve",
                "confirm_scale_reserve",
                "loss_cap",
                "skip",
                "no_backfill",
                "skip_trade",
                "skip_reserve",
                "reserve_skip",
            ):
                continue

        liquid_stalled_min_gap = getattr(
            params, "liquid_stalled_gap_up_min_gap_pct", None
        )
        liquid_stalled_min_ret5 = getattr(
            params, "liquid_stalled_gap_up_min_ret_5d", None
        )
        liquid_stalled_max_ret5 = getattr(
            params, "liquid_stalled_gap_up_max_ret_5d", None
        )
        liquid_stalled_min_premarket = getattr(
            params, "liquid_stalled_gap_up_min_premarket_dollar_vol", None
        )
        liquid_stalled_max_premarket = getattr(
            params, "liquid_stalled_gap_up_max_premarket_dollar_vol", None
        )
        liquid_stalled_max_body = getattr(
            params, "liquid_stalled_gap_up_max_body_ratio", None
        )
        liquid_stalled_max_close = getattr(
            params, "liquid_stalled_gap_up_max_close_location", None
        )
        liquid_stalled_max_obv5 = getattr(
            params, "liquid_stalled_gap_up_max_obv_slope_5d", None
        )
        if (
            direction == "bullish"
            and liquid_stalled_min_gap is not None
            and liquid_stalled_max_close is not None
            and ret_5d is not None
            and gap_pct >= float(liquid_stalled_min_gap)
            and (
                liquid_stalled_min_ret5 is None
                or ret_5d >= float(liquid_stalled_min_ret5)
            )
            and (
                liquid_stalled_max_ret5 is None
                or ret_5d <= float(liquid_stalled_max_ret5)
            )
            and (
                liquid_stalled_min_premarket is None
                or premarket_dollar_vol >= float(liquid_stalled_min_premarket)
            )
            and (
                liquid_stalled_max_premarket is None
                or premarket_dollar_vol <= float(liquid_stalled_max_premarket)
            )
            and (
                liquid_stalled_max_body is None
                or body_ratio <= float(liquid_stalled_max_body)
            )
            and close_location <= float(liquid_stalled_max_close)
            and (
                liquid_stalled_max_obv5 is None
                or (
                    obv_slope_5 is not None
                    and obv_slope_5 <= float(liquid_stalled_max_obv5)
                )
            )
        ):
            _f_liquid_stalled_gap_up += 1
            liquid_stalled_action = str(
                getattr(params, "liquid_stalled_gap_up_action", "reject") or "reject"
            )
            if liquid_stalled_action in (
                "confirm",
                "confirm_scale",
                "confirm_scale_reserve",
            ):
                liquid_stalled_gap_up_requires_confirmation = True
            if liquid_stalled_action in (
                "scale",
                "confirm_scale",
                "scale_reserve",
                "confirm_scale_reserve",
            ):
                liquid_stalled_gap_up_size_scale = max(
                    0.0,
                    min(
                        1.0,
                        float(
                            getattr(params, "liquid_stalled_gap_up_size_scale", 1.0)
                            or 1.0
                        ),
                    ),
                )
            if liquid_stalled_action in (
                "skip",
                "no_backfill",
                "skip_trade",
                "skip_reserve",
                "reserve_skip",
            ):
                liquid_stalled_gap_up_skip_trade = True
            if liquid_stalled_action in ("skip_reserve", "reserve_skip"):
                liquid_stalled_gap_up_reserve_cash = True
            if liquid_stalled_action in ("scale_reserve", "confirm_scale_reserve"):
                liquid_stalled_gap_up_reserve_full_cash = True
            if getattr(params, "liquid_stalled_gap_up_loss_cap_pct", None) is not None:
                liquid_stalled_gap_up_loss_cap_pct = max(
                    0.0,
                    float(getattr(params, "liquid_stalled_gap_up_loss_cap_pct") or 0.0),
                )
            if liquid_stalled_action not in (
                "confirm",
                "scale",
                "confirm_scale",
                "scale_reserve",
                "confirm_scale_reserve",
                "loss_cap",
                "skip",
                "no_backfill",
                "skip_trade",
                "skip_reserve",
                "reserve_skip",
            ):
                continue

        thin_gap_up_loss_cap_min_gap = getattr(
            params, "thin_gap_up_loss_cap_min_gap_pct", None
        )
        thin_gap_up_loss_cap_max_premarket = getattr(
            params, "thin_gap_up_loss_cap_max_premarket_dollar_vol", None
        )
        thin_gap_up_loss_cap_min_body = getattr(
            params, "thin_gap_up_loss_cap_min_body_ratio", None
        )
        thin_gap_up_loss_cap_min_close = getattr(
            params, "thin_gap_up_loss_cap_min_close_location", None
        )
        thin_gap_up_loss_cap_min_ret5 = getattr(
            params, "thin_gap_up_loss_cap_min_ret_5d", None
        )
        thin_gap_up_loss_cap_max_ret5 = getattr(
            params, "thin_gap_up_loss_cap_max_ret_5d", None
        )
        if (
            direction == "bullish"
            and thin_gap_up_loss_cap_min_gap is not None
            and thin_gap_up_loss_cap_max_premarket is not None
            and thin_gap_up_loss_cap_min_body is not None
            and thin_gap_up_loss_cap_min_close is not None
            and getattr(params, "thin_gap_up_loss_cap_pct", None) is not None
            and ret_5d is not None
            and gap_pct >= float(thin_gap_up_loss_cap_min_gap)
            and (
                thin_gap_up_loss_cap_min_ret5 is None
                or ret_5d >= float(thin_gap_up_loss_cap_min_ret5)
            )
            and (
                thin_gap_up_loss_cap_max_ret5 is None
                or ret_5d <= float(thin_gap_up_loss_cap_max_ret5)
            )
            and premarket_dollar_vol <= float(thin_gap_up_loss_cap_max_premarket)
            and body_ratio >= float(thin_gap_up_loss_cap_min_body)
            and close_location >= float(thin_gap_up_loss_cap_min_close)
        ):
            _f_thin_gap_up_loss_cap += 1
            thin_gap_up_loss_cap_active = True
            thin_gap_up_loss_cap_pct = max(
                0.0,
                float(getattr(params, "thin_gap_up_loss_cap_pct") or 0.0),
            )

        moderate_downside_loss_cap_min_abs_gap = getattr(
            params, "moderate_downside_loss_cap_min_abs_gap_pct", None
        )
        moderate_downside_loss_cap_max_abs_gap = getattr(
            params, "moderate_downside_loss_cap_max_abs_gap_pct", None
        )
        moderate_downside_loss_cap_min_ret5 = getattr(
            params, "moderate_downside_loss_cap_min_ret_5d", None
        )
        moderate_downside_loss_cap_max_ret5 = getattr(
            params, "moderate_downside_loss_cap_max_ret_5d", None
        )
        moderate_downside_loss_cap_max_premarket = getattr(
            params, "moderate_downside_loss_cap_max_premarket_dollar_vol", None
        )
        moderate_downside_loss_cap_max_body = getattr(
            params, "moderate_downside_loss_cap_max_body_ratio", None
        )
        moderate_downside_loss_cap_max_close = getattr(
            params, "moderate_downside_loss_cap_max_close_location", None
        )
        if (
            direction == "bullish"
            and moderate_downside_loss_cap_min_abs_gap is not None
            and moderate_downside_loss_cap_max_abs_gap is not None
            and moderate_downside_loss_cap_max_body is not None
            and moderate_downside_loss_cap_max_close is not None
            and getattr(params, "moderate_downside_loss_cap_pct", None) is not None
            and ret_5d is not None
            and gap_pct <= -float(moderate_downside_loss_cap_min_abs_gap)
            and gap_pct >= -float(moderate_downside_loss_cap_max_abs_gap)
            and (
                moderate_downside_loss_cap_min_ret5 is None
                or ret_5d >= float(moderate_downside_loss_cap_min_ret5)
            )
            and (
                moderate_downside_loss_cap_max_ret5 is None
                or ret_5d <= float(moderate_downside_loss_cap_max_ret5)
            )
            and (
                moderate_downside_loss_cap_max_premarket is None
                or premarket_dollar_vol <= float(moderate_downside_loss_cap_max_premarket)
            )
            and body_ratio <= float(moderate_downside_loss_cap_max_body)
            and close_location <= float(moderate_downside_loss_cap_max_close)
        ):
            _f_moderate_downside_loss_cap += 1
            moderate_downside_loss_cap_active = True
            moderate_downside_loss_cap_pct = max(
                0.0,
                float(getattr(params, "moderate_downside_loss_cap_pct") or 0.0),
            )

        gap_up_fill_exit_min_gap = getattr(params, "gap_up_fill_exit_min_gap_pct", None)
        if (
            direction == "bullish"
            and gap_up_fill_exit_min_gap is not None
            and gap_pct >= float(gap_up_fill_exit_min_gap)
            and (
                getattr(params, "gap_up_fill_exit_max_gap_pct", None) is None
                or gap_pct <= float(getattr(params, "gap_up_fill_exit_max_gap_pct"))
            )
            and (
                getattr(params, "gap_up_fill_exit_min_ret_5d", None) is None
                or (ret_5d is not None and ret_5d >= float(getattr(params, "gap_up_fill_exit_min_ret_5d")))
            )
            and (
                getattr(params, "gap_up_fill_exit_max_ret_5d", None) is None
                or (ret_5d is not None and ret_5d <= float(getattr(params, "gap_up_fill_exit_max_ret_5d")))
            )
            and (
                getattr(params, "gap_up_fill_exit_max_premarket_dollar_vol", None) is None
                or premarket_dollar_vol <= float(getattr(params, "gap_up_fill_exit_max_premarket_dollar_vol"))
            )
            and (
                getattr(params, "gap_up_fill_exit_min_body_ratio", None) is None
                or body_ratio >= float(getattr(params, "gap_up_fill_exit_min_body_ratio"))
            )
            and (
                getattr(params, "gap_up_fill_exit_max_body_ratio", None) is None
                or body_ratio <= float(getattr(params, "gap_up_fill_exit_max_body_ratio"))
            )
            and (
                getattr(params, "gap_up_fill_exit_max_close_location", None) is None
                or close_location <= float(getattr(params, "gap_up_fill_exit_max_close_location"))
            )
        ):
            _f_gap_up_fill_exit += 1
            gap_up_fill_exit_active = True

        entropy_20d = ticker_enrich.get("entropy_20d")
        atr_ratio_10_60 = ticker_enrich.get("atr_ratio_10_60")
        range_compression_10_60 = ticker_enrich.get("range_compression_10_60")
        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)
        ownership_lookback_days = int(getattr(params, "ownership_13dg_lookback_days", 0) or 0)
        ownership_days_since = _float_or_none(ticker_enrich.get("ownership_13dg_days_since"))
        ownership_within_lookback = (
            ownership_lookback_days > 0
            and ownership_days_since is not None
            and ownership_days_since <= ownership_lookback_days
        )
        ownership_13dg_flag = bool(ticker_enrich.get("ownership_13dg_flag")) and ownership_within_lookback
        ownership_13dg_initial_flag = (
            bool(ticker_enrich.get("ownership_13dg_initial_flag"))
            and ownership_within_lookback
        )
        ownership_13dg_score = (
            float(ticker_enrich.get("ownership_13dg_score") or 1.0)
            if ownership_13dg_flag
            else 0.0
        )
        ownership_13dg_initial_score = 1.0 if ownership_13dg_initial_flag else 0.0
        ownership_13dg_strength_score = (
            _float_or_none(ticker_enrich.get("ownership_13dg_strength_score"))
            if ownership_13dg_flag
            else None
        )
        form4_lookback_days = int(getattr(params, "form4_lookback_days", 0) or 0)
        form4_days_since = _float_or_none(ticker_enrich.get("form4_days_since"))
        form4_within_lookback = (
            form4_lookback_days > 0
            and form4_days_since is not None
            and form4_days_since <= form4_lookback_days
        )
        form4_flag = bool(ticker_enrich.get("form4_flag")) and form4_within_lookback
        form4_total_value = (
            _float_or_none(ticker_enrich.get("form4_total_value")) if form4_flag else None
        )
        form4_owner_count = (
            int(ticker_enrich.get("form4_owner_count") or 0) if form4_flag else 0
        )
        form4_c_suite_count = (
            int(ticker_enrich.get("form4_c_suite_count") or 0) if form4_flag else 0
        )
        form4_role_weight_score = (
            _float_or_none(ticker_enrich.get("form4_role_weight_score")) if form4_flag else None
        )

        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 in {"gainers_leader", "orb_pullback_v1", "vwap_reclaim_v1"}:
            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
            conditional_gzs = getattr(params, "conditional_gap_zscore_reject_above", None)
            conditional_ret5 = getattr(params, "conditional_gap_zscore_reject_ret_5d_below", None)
            if (
                conditional_gzs is not None
                and conditional_ret5 is not None
                and gap_zscore_20d is not None
                and ret_5d is not None
                and gap_zscore_20d > conditional_gzs
                and ret_5d < conditional_ret5
            ):
                _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": raw_rvol,
            "filter_rvol": rvol,
            "gap_pct": gap_pct,
            "abs_gap_pct": abs_gap_pct,
            "atr": atr,
            "first_bar_dollar_vol": raw_first_bar_dollar_vol,
            "filter_first_bar_dollar_vol": first_bar_dollar_vol,
            "premarket_dollar_vol": raw_premarket_dollar_vol,
            "filter_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,
            "ownership_13dg_flag": ownership_13dg_flag,
            "ownership_13dg_initial_flag": ownership_13dg_initial_flag,
            "ownership_13dg_score": ownership_13dg_score,
            "ownership_13dg_initial_score": ownership_13dg_initial_score,
            "ownership_13dg_days_since": int(ownership_days_since) if ownership_days_since is not None else None,
            "ownership_13dg_strength_score": ownership_13dg_strength_score,
            "form4_flag": form4_flag,
            "form4_days_since": int(form4_days_since) if form4_days_since is not None else None,
            "form4_total_value": form4_total_value,
            "form4_owner_count": form4_owner_count,
            "form4_c_suite_count": form4_c_suite_count,
            "form4_role_weight_score": form4_role_weight_score,
            "orb_vwap": orb_vwap,
            "used_small_gap_attention_override": used_small_gap_attention_override,
            "crowded_gap_requires_confirmation": crowded_gap_requires_confirmation,
            "crowded_gap_size_scale": crowded_gap_size_scale,
            "countertrend_gap_requires_confirmation": countertrend_gap_requires_confirmation,
            "countertrend_gap_size_scale": countertrend_gap_size_scale,
            "distressed_reclaim_requires_confirmation": distressed_reclaim_requires_confirmation,
            "distressed_reclaim_size_scale": distressed_reclaim_size_scale,
            "distressed_reclaim_skip_trade": distressed_reclaim_skip_trade,
            "distressed_reclaim_reserve_cash": distressed_reclaim_reserve_cash,
            "distressed_reclaim_reserve_full_cash": distressed_reclaim_reserve_full_cash,
            "distressed_reclaim_triggered": distressed_reclaim_triggered,
            "distressed_reclaim_loss_cap_pct": distressed_reclaim_loss_cap_pct,
            "hot_reclaim_requires_confirmation": hot_reclaim_requires_confirmation,
            "hot_reclaim_size_scale": hot_reclaim_size_scale,
            "hot_reclaim_skip_trade": hot_reclaim_skip_trade,
            "hot_reclaim_reserve_cash": hot_reclaim_reserve_cash,
            "hot_reclaim_reserve_full_cash": hot_reclaim_reserve_full_cash,
            "hot_reclaim_loss_cap_pct": hot_reclaim_loss_cap_pct,
            "weak_downside_reclaim_requires_confirmation": weak_downside_reclaim_requires_confirmation,
            "weak_downside_reclaim_size_scale": weak_downside_reclaim_size_scale,
            "weak_downside_reclaim_skip_trade": weak_downside_reclaim_skip_trade,
            "weak_downside_reclaim_reserve_cash": weak_downside_reclaim_reserve_cash,
            "weak_downside_reclaim_reserve_full_cash": weak_downside_reclaim_reserve_full_cash,
            "weak_downside_reclaim_loss_cap_pct": weak_downside_reclaim_loss_cap_pct,
            "quiet_downside_reclaim_requires_confirmation": (
                quiet_downside_reclaim_requires_confirmation
            ),
            "quiet_downside_reclaim_size_scale": quiet_downside_reclaim_size_scale,
            "quiet_downside_reclaim_skip_trade": quiet_downside_reclaim_skip_trade,
            "quiet_downside_reclaim_reserve_cash": quiet_downside_reclaim_reserve_cash,
            "quiet_downside_reclaim_reserve_full_cash": (
                quiet_downside_reclaim_reserve_full_cash
            ),
            "quiet_downside_reclaim_loss_cap_pct": quiet_downside_reclaim_loss_cap_pct,
            "stale_obv_reversal_requires_confirmation": (
                stale_obv_reversal_requires_confirmation
            ),
            "stale_obv_reversal_size_scale": stale_obv_reversal_size_scale,
            "stale_obv_reversal_skip_trade": stale_obv_reversal_skip_trade,
            "stale_obv_reversal_reserve_cash": stale_obv_reversal_reserve_cash,
            "stale_obv_reversal_reserve_full_cash": (
                stale_obv_reversal_reserve_full_cash
            ),
            "stale_obv_reversal_loss_cap_pct": stale_obv_reversal_loss_cap_pct,
            "stalled_gap_up_requires_confirmation": stalled_gap_up_requires_confirmation,
            "stalled_gap_up_size_scale": stalled_gap_up_size_scale,
            "stalled_gap_up_skip_trade": stalled_gap_up_skip_trade,
            "stalled_gap_up_reserve_cash": stalled_gap_up_reserve_cash,
            "stalled_gap_up_reserve_full_cash": stalled_gap_up_reserve_full_cash,
            "stalled_gap_up_loss_cap_pct": stalled_gap_up_loss_cap_pct,
            "liquid_stalled_gap_up_requires_confirmation": (
                liquid_stalled_gap_up_requires_confirmation
            ),
            "liquid_stalled_gap_up_size_scale": liquid_stalled_gap_up_size_scale,
            "liquid_stalled_gap_up_skip_trade": liquid_stalled_gap_up_skip_trade,
            "liquid_stalled_gap_up_reserve_cash": liquid_stalled_gap_up_reserve_cash,
            "liquid_stalled_gap_up_reserve_full_cash": (
                liquid_stalled_gap_up_reserve_full_cash
            ),
            "liquid_stalled_gap_up_loss_cap_pct": liquid_stalled_gap_up_loss_cap_pct,
            "thin_gap_up_loss_cap_active": thin_gap_up_loss_cap_active,
            "thin_gap_up_loss_cap_pct": thin_gap_up_loss_cap_pct,
            "moderate_downside_loss_cap_active": moderate_downside_loss_cap_active,
            "moderate_downside_loss_cap_pct": moderate_downside_loss_cap_pct,
            "gap_up_fill_exit_active": gap_up_fill_exit_active,
            "candidate_seed_overlay": is_overlay,
            "broad_gapup_continuation": broad_gapup_continuation_active,
            "broad_gapup_continuation_size_scale": (
                max(
                    0.0,
                    min(
                        1.0,
                        float(
                            getattr(
                                params,
                                "broad_gapup_continuation_size_scale",
                                1.0,
                            )
                            or 0.0
                        ),
                    ),
                )
                if broad_gapup_continuation_active
                else 1.0
            ),
            "market_thrust_liquid_continuation": (
                market_thrust_liquid_continuation_active
            ),
            "market_thrust_liquid_continuation_size_scale": (
                max(
                    0.0,
                    min(
                        1.0,
                        float(
                            getattr(
                                params,
                                "market_thrust_liquid_continuation_size_scale",
                                1.0,
                            )
                            or 0.0
                        ),
                    ),
                )
                if market_thrust_liquid_continuation_active
                else 1.0
            ),
            "market_thrust_opening_impulse_reclaim": (
                market_thrust_opening_impulse_reclaim_active
            ),
            "market_thrust_opening_impulse_reclaim_size_scale": (
                max(
                    0.0,
                    min(
                        1.0,
                        float(
                            getattr(
                                params,
                                "market_thrust_opening_impulse_reclaim_size_scale",
                                1.0,
                            )
                            or 0.0
                        ),
                    ),
                )
                if market_thrust_opening_impulse_reclaim_active
                else 1.0
            ),
            "intraday_continuation_reclaim": intraday_continuation_reclaim_active,
            "intraday_continuation_reclaim_size_scale": (
                max(
                    0.0,
                    min(
                        1.0,
                        float(
                            getattr(
                                params,
                                "intraday_continuation_reclaim_size_scale",
                                1.0,
                            )
                            or 0.0
                        ),
                    ),
                )
                if intraday_continuation_reclaim_active
                else 1.0
            ),
            "intraday_continuation_signal_return": (
                intraday_continuation_signal.get("signal_return")
                if intraday_continuation_signal
                else None
            ),
            "intraday_continuation_signal_dollar_vol": (
                intraday_continuation_signal.get("signal_dollar_vol")
                if intraday_continuation_signal
                else None
            ),
            "intraday_continuation_signal_close_location": (
                intraday_continuation_signal.get("signal_close_location")
                if intraday_continuation_signal
                else None
            ),
            "intraday_continuation_entry_ts": (
                intraday_continuation_signal.get("entry_ts")
                if intraday_continuation_signal
                else None
            ),
            "intraday_continuation_entry_price": (
                intraday_continuation_signal.get("entry_price")
                if intraday_continuation_signal
                else None
            ),
            "orb_return": first_bar_return_pct,
            "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,
        "crowded_gap": _f_crowded_gap,
        "countertrend_gap": _f_countertrend_gap,
        "distressed_reclaim": _f_distressed_reclaim,
        "hot_reclaim": _f_hot_reclaim,
        "weak_downside_reclaim": _f_weak_downside_reclaim,
        "quiet_downside_reclaim": _f_quiet_downside_reclaim,
        "stale_obv_reversal": _f_stale_obv_reversal,
        "stalled_gap_up": _f_stalled_gap_up,
        "liquid_stalled_gap_up": _f_liquid_stalled_gap_up,
        "thin_gap_up_loss_cap": _f_thin_gap_up_loss_cap,
        "moderate_downside_loss_cap": _f_moderate_downside_loss_cap,
        "gap_up_fill_exit": _f_gap_up_fill_exit,
        "broad_gapup_continuation": sum(
            1 for cand in raw_candidates if cand.get("broad_gapup_continuation")
        ),
        "market_thrust_liquid_continuation": sum(
            1 for cand in raw_candidates
            if cand.get("market_thrust_liquid_continuation")
        ),
        "market_thrust_opening_impulse_reclaim": sum(
            1 for cand in raw_candidates
            if cand.get("market_thrust_opening_impulse_reclaim")
        ),
        "intraday_continuation_reclaim": sum(
            1 for cand in raw_candidates
            if cand.get("intraday_continuation_reclaim")
        ),
    }
    if _stats_out is not None:
        _stats_out.update(_filter_stats)

    if not raw_candidates:
        total = len(bars_by_ticker)
        if not _suppress_empty_log:
            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 []

    rvol_values = [_float_or_none(c.get("rvol")) or 0.0 for c in raw_candidates]
    opening_dollar_values = [
        _float_or_none(c.get("first_bar_dollar_vol")) or 0.0
        for c in raw_candidates
    ]
    premarket_dollar_values = [
        _float_or_none(c.get("premarket_dollar_vol")) or 0.0
        for c in raw_candidates
    ]
    rvol_rank_pct = _rank_percentiles(rvol_values)
    opening_dollar_rank_pct = _rank_percentiles(opening_dollar_values)
    premarket_dollar_rank_pct = _rank_percentiles(premarket_dollar_values)

    context_min_bucket_size = int(
        getattr(params, "volume_attention_context_min_bucket_size", 3) or 3
    )
    sector_keys = [str(c.get("sector") or "UNKNOWN") for c in raw_candidates]
    price_keys = [_candidate_price_bucket(c) for c in raw_candidates]
    sector_rvol_rank_pct = _rank_percentiles_by_key(
        rvol_values,
        sector_keys,
        min_bucket_size=context_min_bucket_size,
        fallback=rvol_rank_pct,
    )
    sector_opening_dollar_rank_pct = _rank_percentiles_by_key(
        opening_dollar_values,
        sector_keys,
        min_bucket_size=context_min_bucket_size,
        fallback=opening_dollar_rank_pct,
    )
    sector_premarket_dollar_rank_pct = _rank_percentiles_by_key(
        premarket_dollar_values,
        sector_keys,
        min_bucket_size=context_min_bucket_size,
        fallback=premarket_dollar_rank_pct,
    )
    price_rvol_rank_pct = _rank_percentiles_by_key(
        rvol_values,
        price_keys,
        min_bucket_size=context_min_bucket_size,
        fallback=rvol_rank_pct,
    )
    price_opening_dollar_rank_pct = _rank_percentiles_by_key(
        opening_dollar_values,
        price_keys,
        min_bucket_size=context_min_bucket_size,
        fallback=opening_dollar_rank_pct,
    )
    price_premarket_dollar_rank_pct = _rank_percentiles_by_key(
        premarket_dollar_values,
        price_keys,
        min_bucket_size=context_min_bucket_size,
        fallback=premarket_dollar_rank_pct,
    )
    attn_w_rvol = max(
        0.0,
        float(getattr(params, "volume_attention_rank_weight_rvol", 0.45) or 0.0),
    )
    attn_w_open = max(
        0.0,
        float(getattr(params, "volume_attention_rank_weight_opening_dollar_vol", 0.45) or 0.0),
    )
    attn_w_pm = max(
        0.0,
        float(getattr(params, "volume_attention_rank_weight_premarket_dollar_vol", 0.10) or 0.0),
    )
    attn_weight_sum = attn_w_rvol + attn_w_open + attn_w_pm
    if attn_weight_sum <= 0:
        attn_w_rvol, attn_w_open, attn_w_pm = 0.45, 0.45, 0.10
        attn_weight_sum = 1.0
    context_w_global = max(
        0.0,
        float(getattr(params, "volume_attention_rank_weight_global_context", 1.0) or 0.0),
    )
    context_w_sector = max(
        0.0,
        float(getattr(params, "volume_attention_rank_weight_sector_context", 0.0) or 0.0),
    )
    context_w_price = max(
        0.0,
        float(getattr(params, "volume_attention_rank_weight_price_context", 0.0) or 0.0),
    )
    context_weight_sum = context_w_global + context_w_sector + context_w_price
    if context_weight_sum <= 0:
        context_w_global = 1.0
        context_weight_sum = 1.0

    for i, cand in enumerate(raw_candidates):
        cand["rvol_rank_pct"] = rvol_rank_pct[i]
        cand["opening_dollar_vol_rank_pct"] = opening_dollar_rank_pct[i]
        cand["premarket_dollar_vol_rank_pct"] = premarket_dollar_rank_pct[i]
        global_attention_rank = (
            rvol_rank_pct[i] * attn_w_rvol
            + opening_dollar_rank_pct[i] * attn_w_open
            + premarket_dollar_rank_pct[i] * attn_w_pm
        ) / attn_weight_sum
        sector_attention_rank = (
            sector_rvol_rank_pct[i] * attn_w_rvol
            + sector_opening_dollar_rank_pct[i] * attn_w_open
            + sector_premarket_dollar_rank_pct[i] * attn_w_pm
        ) / attn_weight_sum
        price_attention_rank = (
            price_rvol_rank_pct[i] * attn_w_rvol
            + price_opening_dollar_rank_pct[i] * attn_w_open
            + price_premarket_dollar_rank_pct[i] * attn_w_pm
        ) / attn_weight_sum
        cand["volume_attention_global_rank_pct"] = global_attention_rank
        cand["volume_attention_sector_rank_pct"] = sector_attention_rank
        cand["volume_attention_price_rank_pct"] = price_attention_rank
        cand["volume_attention_rank_pct"] = (
            global_attention_rank * context_w_global
            + sector_attention_rank * context_w_sector
            + price_attention_rank * context_w_price
        ) / context_weight_sum

    min_volume_attention_rank = getattr(params, "min_volume_attention_rank_pct", None)
    if min_volume_attention_rank is not None:
        min_volume_attention_rank_f = float(min_volume_attention_rank)
        raw_candidates = [
            cand for cand in raw_candidates
            if float(cand.get("volume_attention_rank_pct") or 0.0)
            >= min_volume_attention_rank_f
        ]

    if not raw_candidates:
        return []

    _apply_orb_sector_confirmation(raw_candidates, params)

    # Normalize and score
    rvol_vals = [c["rvol"] for c in raw_candidates]
    if engine_family in {"gainers_leader", "leader_followthrough", "vwap_reclaim_v1", "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]
    red_to_green_gap_vals = [max(-c["gap_pct"], 0.0) 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
    ]
    ownership_vals = [c["ownership_13dg_score"] for c in raw_candidates]
    ownership_initial_vals = [c["ownership_13dg_initial_score"] 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]
    sector_confirmation_vals = [
        c["sector_confirmation_score"] for c in raw_candidates
    ]
    volume_attention_rank_vals = [
        c["volume_attention_rank_pct"] 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_red_to_green_gap = _normalize_scores(red_to_green_gap_vals)
    norm_attention_wiki = _normalize_scores(attention_wiki_vals)
    norm_attention_news = _normalize_scores(attention_news_vals)
    norm_ownership = _normalize_scores(ownership_vals)
    norm_ownership_initial = _normalize_scores(ownership_initial_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)
    norm_sector_confirmation = _normalize_scores(sector_confirmation_vals)
    norm_volume_attention_rank = _normalize_scores(volume_attention_rank_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
            + norm_volume_attention_rank[i] * params.weight_volume_attention_rank
        )
        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",
            "orb_pullback_v1",
            "vwap_reclaim_v1", "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", "orb_pullback_v1"}:
            score += norm_event[i] * params.weight_event_catalyst
        if engine_family in {
            "gainers_leader",
            "leader_followthrough",
            "orb_pullback_v1",
            "vwap_reclaim_v1",
        }:
            score += norm_red_to_green_gap[i] * params.weight_red_to_green_gap
        if engine_family in {
            "stocks_in_play_dual_regime",
            "gainers_leader",
            "leader_followthrough",
            "orb_pullback_v1",
            "vwap_reclaim_v1",
        }:
            score += norm_attention_wiki[i] * params.weight_attention_wiki
            score += norm_attention_news[i] * params.weight_attention_news
            score += norm_ownership[i] * params.weight_ownership_13dg
            score += norm_ownership_initial[i] * params.weight_ownership_initial_13dg
        if engine_family in {
            "compression_breakout", "gainers_leader", "leader_followthrough",
            "orb_pullback_v1",
            "stocks_in_play_dual_regime", "vwap_reclaim_v1", "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
        score += (
            norm_sector_confirmation[i]
            * float(getattr(params, "sector_confirmation_score_weight", 0.0) or 0.0)
        )
        cand["score"] = score

    # Sort by score descending, take top N
    raw_candidates.sort(key=lambda c: c["score"], reverse=True)
    selected = _select_orb_candidates_with_overlays(raw_candidates, params)
    return _apply_orb_basket_quality_floor(selected, params)


# ── 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))


def _find_vwap_reclaim_time(
    mkt_bars: list[dict],
    orb_bar: dict,
    direction: str,
    params: ORBStrategyParams,
    date_str: str,
) -> tuple[dt.datetime, float] | None:
    """Find the first valid VWAP-reclaim entry for no-fill fallback paths."""
    if not mkt_bars:
        return None
    if direction not in {"long", "short"}:
        return None

    market_open = _market_open_ts(date_str)
    orb_ts = _parse_ts(orb_bar["timestamp"])
    window_start = market_open + dt.timedelta(minutes=params.vwap_reclaim_window_start_min)
    window_end = market_open + dt.timedelta(minutes=params.vwap_reclaim_window_end_min)
    post_orb_vols_so_far: list[float] = []

    if params.vwap_reclaim_require_prior_dip:
        had_dip = False
        for b in mkt_bars:
            ts = _parse_ts(b["timestamp"])
            if ts >= window_start:
                break
            if ts <= orb_ts:
                continue
            running_vwap = _compute_running_vwap(mkt_bars, ts)
            if running_vwap is None:
                continue
            if direction == "long" and b["close"] < running_vwap:
                had_dip = True
                break
            if direction == "short" and b["close"] > running_vwap:
                had_dip = True
                break
        if not had_dip:
            return None

    for b in mkt_bars:
        ts = _parse_ts(b["timestamp"])
        if ts <= orb_ts:
            continue
        bar_vol = float(b.get("volume", 0) or 0)
        if ts < window_start:
            post_orb_vols_so_far.append(bar_vol)
            continue
        if ts >= window_end:
            break

        running_vwap = _compute_running_vwap(mkt_bars, ts)
        if running_vwap is None:
            post_orb_vols_so_far.append(bar_vol)
            continue

        clearance = params.vwap_reclaim_min_clearance_pct
        if direction == "long":
            reclaim_ok = b["close"] > running_vwap * (1 + clearance)
            if reclaim_ok and params.vwap_reclaim_require_orb_open_retake:
                reclaim_ok = b["close"] >= orb_bar["open"]
        else:
            reclaim_ok = b["close"] < running_vwap * (1 - clearance)
            if reclaim_ok and params.vwap_reclaim_require_orb_open_retake:
                reclaim_ok = b["close"] <= orb_bar["open"]

        if reclaim_ok:
            vol_gate = params.vwap_reclaim_confirm_rel_vol
            if vol_gate is not None:
                avg_post_orb_vol = (
                    sum(post_orb_vols_so_far) / len(post_orb_vols_so_far)
                    if post_orb_vols_so_far
                    else 0.0
                )
                reclaim_rvol = bar_vol / avg_post_orb_vol if avg_post_orb_vol > 0 else 0.0
                if reclaim_rvol < vol_gate:
                    post_orb_vols_so_far.append(bar_vol)
                    continue
            return (ts, float(b["close"]))

        post_orb_vols_so_far.append(bar_vol)

    return None


def _find_late_breakout_time(
    mkt_bars: list[dict],
    orb_bar: dict,
    direction: str,
    params: ORBStrategyParams,
    date_str: str,
) -> tuple[dt.datetime, float] | None:
    """Find a late, close-confirmed ORB breakout after the primary timeout."""
    if not mkt_bars:
        return None
    if direction not in {"long", "short"}:
        return None

    market_open = _market_open_ts(date_str)
    orb_ts = _parse_ts(orb_bar["timestamp"])
    window_start = market_open + dt.timedelta(
        minutes=max(0, int(getattr(params, "late_breakout_window_start_min", 30) or 30))
    )
    window_end = market_open + dt.timedelta(
        minutes=max(0, int(getattr(params, "late_breakout_window_end_min", 150) or 150))
    )
    if window_end <= window_start:
        return None

    breakout_level = float(orb_bar["high"] if direction == "long" else orb_bar["low"])
    clearance = max(0.0, float(getattr(params, "late_breakout_min_clearance_pct", 0.0) or 0.0))
    rel_vol_gate = getattr(params, "late_breakout_confirm_rel_vol", None)
    require_vwap = bool(getattr(params, "late_breakout_require_vwap_confirmation", True))
    post_orb_vols_so_far: list[float] = []

    for b in mkt_bars:
        ts = _parse_ts(b["timestamp"])
        if ts <= orb_ts:
            continue
        bar_vol = float(b.get("volume", 0) or 0)
        if ts < window_start:
            post_orb_vols_so_far.append(bar_vol)
            continue
        if ts >= window_end:
            break

        close = float(b.get("close", 0.0) or 0.0)
        if close <= 0:
            post_orb_vols_so_far.append(bar_vol)
            continue

        if direction == "long":
            breakout_ok = close >= breakout_level * (1.0 + clearance)
        else:
            breakout_ok = close <= breakout_level * (1.0 - clearance)
        if not breakout_ok:
            post_orb_vols_so_far.append(bar_vol)
            continue

        if require_vwap:
            running_vwap = _compute_running_vwap(mkt_bars, ts)
            if running_vwap is None:
                post_orb_vols_so_far.append(bar_vol)
                continue
            if direction == "long" and close <= running_vwap:
                post_orb_vols_so_far.append(bar_vol)
                continue
            if direction == "short" and close >= running_vwap:
                post_orb_vols_so_far.append(bar_vol)
                continue

        if rel_vol_gate is not None:
            avg_post_orb_vol = (
                sum(post_orb_vols_so_far) / len(post_orb_vols_so_far)
                if post_orb_vols_so_far
                else 0.0
            )
            late_rel_vol = bar_vol / avg_post_orb_vol if avg_post_orb_vol > 0 else 0.0
            if late_rel_vol < float(rel_vol_gate):
                post_orb_vols_so_far.append(bar_vol)
                continue

        return (ts, close)

    return None


def _find_opening_followthrough_time(
    mkt_bars: list[dict],
    orb_bar: dict,
    direction: str,
    params: ORBStrategyParams,
) -> tuple[dt.datetime, float] | None:
    """Confirm the first post-ORB bar, then enter at the next bar open."""
    if not mkt_bars or direction not in {"long", "short"}:
        return None

    orb_ts = _parse_ts(orb_bar["timestamp"])
    post_bars = [bar for bar in mkt_bars if _parse_ts(bar["timestamp"]) > orb_ts]
    if len(post_bars) < 2:
        return None

    signal_bar = post_bars[0]
    entry_bar = post_bars[1]
    signal_return = _bar_return_pct(signal_bar)
    if signal_return is None:
        return None
    close_location = _bar_close_location(signal_bar)
    if close_location is None:
        return None

    min_return = getattr(
        params,
        "market_thrust_liquid_continuation_followthrough_min_return_pct",
        None,
    )
    if min_return is not None:
        if direction == "long" and signal_return < float(min_return):
            return None
        if direction == "short" and signal_return > -float(min_return):
            return None

    min_close = getattr(
        params,
        "market_thrust_liquid_continuation_followthrough_min_close_location",
        None,
    )
    if min_close is not None:
        if direction == "long" and close_location < float(min_close):
            return None
        if direction == "short" and (1.0 - close_location) < float(min_close):
            return None

    if getattr(
        params,
        "market_thrust_liquid_continuation_followthrough_require_orb_breakout",
        True,
    ):
        signal_close = float(signal_bar.get("close", 0.0) or 0.0)
        if direction == "long" and signal_close < float(orb_bar["high"]):
            return None
        if direction == "short" and signal_close > float(orb_bar["low"]):
            return None

    entry_price = float(entry_bar.get("open", 0.0) or 0.0)
    if entry_price <= 0:
        return None
    return (_parse_ts(entry_bar["timestamp"]), entry_price)


# ── 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,
    reject_reason_out: dict[str, str] | None = None,
    pyramid_allowed: bool = True,
    candidate_score: float | 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
    - trigger_type="broad_gapup_continuation": use the normal ORB breakout
      path, but tag sizing/diagnostics as the high-gap continuation sleeve.

    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.
    """
    def _none(reason: str) -> None:
        if reject_reason_out is not None:
            reject_reason_out["reason"] = reason
        return None

    if atr <= 0:
        return _none("atr_nonpositive")

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

    # 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 alternate-trigger entry) ---
    entry_bar: dict | None = None
    entry_price_raw = 0.0
    _sim_engine_family = _effective_orb_engine_family(params)

    if (
        trigger_type in {
            "momentum_confirm",
            "vwap_reclaim",
            "soft_day_vwap_reclaim",
            "late_breakout",
            "broad_gapup_continuation",
            "market_thrust_opening_burst",
            "market_thrust_opening_followthrough",
            "market_thrust_opening_impulse_reclaim",
            "intraday_continuation_reclaim",
        }
        and forced_entry_price is not None
        and forced_entry_bar is not None
    ):
        # Alternate-trigger 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" or trigger_type in {"vwap_reclaim", "soft_day_vwap_reclaim"}:
        # 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)
        _post_orb_vols_so_far: list[float] = []

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

        for b in mkt_bars:
            ts = _parse_ts(b["timestamp"])
            if ts <= orb_ts:
                continue
            if ts < _vr_start:
                _post_orb_vols_so_far.append(float(b.get("volume", 0) or 0))
                continue
            if ts >= _vr_end or ts >= exit_target:
                break
            _vwap = _compute_running_vwap(mkt_bars, ts)
            if _vwap is None:
                _post_orb_vols_so_far.append(float(b.get("volume", 0) or 0))
                continue
            _clearance = params.vwap_reclaim_min_clearance_pct
            if direction == "long" and b["close"] > _vwap * (1 + _clearance):
                if params.vwap_reclaim_require_orb_open_retake and b["close"] < orb_bar["open"]:
                    _post_orb_vols_so_far.append(float(b.get("volume", 0) or 0))
                    continue
                _vol_gate = params.vwap_reclaim_confirm_rel_vol
                if _vol_gate is not None:
                    _avg_post_orb_vol = (
                        sum(_post_orb_vols_so_far) / len(_post_orb_vols_so_far)
                        if _post_orb_vols_so_far
                        else 0.0
                    )
                    _bar_vol = float(b.get("volume", 0) or 0)
                    _reclaim_rvol = _bar_vol / _avg_post_orb_vol if _avg_post_orb_vol > 0 else 0.0
                    if _reclaim_rvol < _vol_gate:
                        _post_orb_vols_so_far.append(_bar_vol)
                        continue
                entry_price_raw = b["close"]
                entry_bar = b
                break
            elif direction == "short" and b["close"] < _vwap * (1 - _clearance):
                if params.vwap_reclaim_require_orb_open_retake and b["close"] > orb_bar["open"]:
                    _post_orb_vols_so_far.append(float(b.get("volume", 0) or 0))
                    continue
                _vol_gate = params.vwap_reclaim_confirm_rel_vol
                if _vol_gate is not None:
                    _avg_post_orb_vol = (
                        sum(_post_orb_vols_so_far) / len(_post_orb_vols_so_far)
                        if _post_orb_vols_so_far
                        else 0.0
                    )
                    _bar_vol = float(b.get("volume", 0) or 0)
                    _reclaim_rvol = _bar_vol / _avg_post_orb_vol if _avg_post_orb_vol > 0 else 0.0
                    if _reclaim_rvol < _vol_gate:
                        _post_orb_vols_so_far.append(_bar_vol)
                        continue
                entry_price_raw = b["close"]
                entry_bar = b
                break
            _post_orb_vols_so_far.append(float(b.get("volume", 0) or 0))
    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("breakout_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("aggregate_signal_no_next_fill_bar")
                    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("aggregate_signal_no_next_fill_bar")
                    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_entry_bar")

    # --- 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 shows real progress out of the pullback.
                if b["close"] > b["open"] and b["close"] > pullback_extreme:
                    if params.pullback_require_breakout_retake:
                        _reclaim_clearance = max(
                            float(getattr(params, "pullback_breakout_retake_clearance_pct", 0.0) or 0.0),
                            0.0,
                        )
                        if b["close"] < breakout_level * (1 + _reclaim_clearance):
                            continue

                    # 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:
                            continue

                    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_require_breakout_retake:
                        _reclaim_clearance = max(
                            float(getattr(params, "pullback_breakout_retake_clearance_pct", 0.0) or 0.0),
                            0.0,
                        )
                        if b["close"] > breakout_level * (1 - _reclaim_clearance):
                            continue

                    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

                    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:
                            continue

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

    # --- 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" or trigger_type in {"vwap_reclaim", "soft_day_vwap_reclaim"})
        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 in {"orb", "broad_gapup_continuation"}
        and forced_entry_bar is None
        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_rel_vol")

    # --- 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("confirmation_no_next_bar")
        if direction == "long" and confirm_bar["close"] < entry_price_raw:
            return _none("confirmation_failed_long")
        elif direction == "short" and confirm_bar["close"] > entry_price_raw:
            return _none("confirmation_failed_short")
        # 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
        )

    fixed_loss_per_share = params.fixed_loss_dollars
    if params.fixed_loss_pct is not None:
        fixed_loss_pct = max(0.0, float(params.fixed_loss_pct))
        fixed_loss_per_share = entry_price_raw * fixed_loss_pct if fixed_loss_pct > 0 else None
    if fixed_loss_per_share is not None and fixed_loss_per_share > 0:
        if direction == "long":
            initial_stop = max(initial_stop, entry_price_raw - fixed_loss_per_share)
        else:
            initial_stop = min(initial_stop, entry_price_raw + fixed_loss_per_share)
    fixed_loss_preserve_trailing = bool(getattr(params, "fixed_loss_preserve_trailing", False))

    enters_after_bar_close = trigger_type in {
        "momentum_confirm",
        "vwap_reclaim",
        "soft_day_vwap_reclaim",
        "late_breakout",
    }
    same_bar_stop_allowed = (
        group_size == 1
        and not params.entry_on_bar_close
        and not enters_after_bar_close
    )
    same_bar_stop_confirmation_enabled = bool(
        getattr(params, "same_bar_stop_confirmation_enabled", False)
    )
    same_bar_stop_confirmation_allowed_triggers = getattr(
        params,
        "same_bar_stop_confirmation_allowed_trigger_types",
        None,
    )
    if (
        same_bar_stop_confirmation_enabled
        and same_bar_stop_confirmation_allowed_triggers
    ):
        same_bar_stop_confirmation_enabled = (
            trigger_type in set(same_bar_stop_confirmation_allowed_triggers)
        )
    same_bar_stop_confirmation_active = (
        same_bar_stop_confirmation_enabled
        and same_bar_stop_allowed
        and (
            (direction == "long" and entry_bar["low"] <= initial_stop)
            or (direction == "short" and entry_bar["high"] >= initial_stop)
        )
    )
    if same_bar_stop_confirmation_active:
        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("same_bar_stop_confirmation_no_next_bar")
        if direction == "long" and confirm_bar["close"] < entry_price_raw:
            return _none("same_bar_stop_confirmation_failed_long")
        if direction == "short" and confirm_bar["close"] > entry_price_raw:
            return _none("same_bar_stop_confirmation_failed_short")
        entry_price_raw = confirm_bar["close"]
        entry_bar = confirm_bar
        initial_stop = (
            entry_price_raw - stop_distance if direction == "long"
            else entry_price_raw + stop_distance
        )
        fixed_loss_per_share = params.fixed_loss_dollars
        if params.fixed_loss_pct is not None:
            fixed_loss_pct = max(0.0, float(params.fixed_loss_pct))
            fixed_loss_per_share = (
                entry_price_raw * fixed_loss_pct if fixed_loss_pct > 0 else None
            )
        if fixed_loss_per_share is not None and fixed_loss_per_share > 0:
            if direction == "long":
                initial_stop = max(initial_stop, entry_price_raw - fixed_loss_per_share)
            else:
                initial_stop = min(initial_stop, entry_price_raw + fixed_loss_per_share)
        same_bar_stop_allowed = False

    # 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.
    # score_rank_pct: 1.0 = top rank, 0.0 = bottom rank.
    sizing_mult = 1.0
    if params.score_sizing_floor is not None:
        floor = max(0.0, min(1.0, float(params.score_sizing_floor)))
        sizing_mult *= floor + score_rank_pct * (1.0 - floor)
    if params.score_sizing_multiplier is not None and params.score_sizing_multiplier > 1.0:
        sizing_mult *= 1.0 + score_rank_pct * (params.score_sizing_multiplier - 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 = fixed_loss_per_share if fixed_loss_per_share 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("cash_unavailable")
        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("zero_shares")

    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 same_bar_stop_allowed 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,
            total_capital_deployed=round(shares * entry_price_filled, 4),
        )
    if same_bar_stop_allowed 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,
            total_capital_deployed=round(shares * entry_price_filled, 4),
        )

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

    # Fixed 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 fixed_loss_per_share is not None:
            fixed_sl_price = entry_price_raw - fixed_loss_per_share
    else:
        if params.fixed_profit_dollars is not None:
            fixed_pt_price = entry_price_raw - params.fixed_profit_dollars
        if fixed_loss_per_share is not None:
            fixed_sl_price = entry_price_raw + fixed_loss_per_share
    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)

    # Conditional time stop: unlike max_hold, only exits stalled trades that
    # have not reached enough favorable excursion.
    time_stop_exit_ts: dt.datetime | None = None
    if params.time_stop_minutes is not None:
        time_stop_exit_ts = entry_ts + dt.timedelta(minutes=params.time_stop_minutes)

    early_failure_exit_ts: dt.datetime | None = None
    early_failure_trigger_types = getattr(
        params,
        "early_failure_exit_trigger_types",
        None,
    )
    early_failure_enabled = params.early_failure_exit_minutes is not None
    if early_failure_enabled and early_failure_trigger_types is not None:
        early_failure_enabled = trigger_type in {str(t) for t in early_failure_trigger_types}
    if early_failure_enabled:
        early_failure_exit_ts = entry_ts + dt.timedelta(
            minutes=max(0, int(params.early_failure_exit_minutes or 0))
        )

    def _early_failure_support_level(ts: dt.datetime) -> float | None:
        level_mode = str(
            getattr(params, "early_failure_exit_level", "breakout") or "breakout"
        ).lower()
        if level_mode == "entry":
            return entry_price_raw
        if level_mode == "vwap":
            return _compute_running_vwap(mkt_bars, ts)
        return breakout_level

    # 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

    conviction_runner_trail_active = False
    if (
        getattr(params, "conviction_runner_trail_tighten_at_r", None) is not None
        or getattr(params, "conviction_runner_trail_gap_atr_multiplier", None) is not None
    ):
        conviction_runner_trail_active = True
        allowed_runner_triggers = getattr(
            params,
            "conviction_runner_trail_allowed_trigger_types",
            None,
        )
        if allowed_runner_triggers is not None:
            conviction_runner_trail_active = trigger_type in {
                str(t) for t in allowed_runner_triggers
            }
        min_runner_gap = getattr(params, "conviction_runner_trail_min_abs_gap_pct", None)
        if (
            conviction_runner_trail_active
            and min_runner_gap is not None
            and abs(float(gap_pct)) < float(min_runner_gap)
        ):
            conviction_runner_trail_active = False
        min_runner_score = getattr(
            params,
            "conviction_runner_trail_min_candidate_score",
            None,
        )
        if conviction_runner_trail_active and min_runner_score is not None:
            if candidate_score is None or float(candidate_score) < float(min_runner_score):
                conviction_runner_trail_active = False
        min_runner_rank = getattr(
            params,
            "conviction_runner_trail_min_score_rank_pct",
            None,
        )
        if (
            conviction_runner_trail_active
            and min_runner_rank is not None
            and score_rank_pct < float(min_runner_rank)
        ):
            conviction_runner_trail_active = False

    effective_trailing_tighten_at_r = params.trailing_tighten_at_r
    effective_gap_trail_wide_threshold = params.gap_trail_wide_threshold
    effective_gap_trail_wide_atr_multiplier = params.gap_trail_wide_atr_multiplier
    if conviction_runner_trail_active:
        runner_tighten_at_r = getattr(
            params,
            "conviction_runner_trail_tighten_at_r",
            None,
        )
        if runner_tighten_at_r is not None:
            effective_trailing_tighten_at_r = float(runner_tighten_at_r)
        runner_gap_atr = getattr(
            params,
            "conviction_runner_trail_gap_atr_multiplier",
            None,
        )
        if runner_gap_atr is not None:
            effective_gap_trail_wide_atr_multiplier = float(runner_gap_atr)
            min_runner_gap = getattr(
                params,
                "conviction_runner_trail_min_abs_gap_pct",
                None,
            )
            if effective_gap_trail_wide_threshold is None and min_runner_gap is not None:
                effective_gap_trail_wide_threshold = float(min_runner_gap)

    # 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
    pyramid_allowed_triggers = (
        set(params.pyramid_allowed_trigger_types)
        if params.pyramid_allowed_trigger_types is not None
        else None
    )
    pyramid_setup_allowed = params.pyramid_at_r is not None and pyramid_allowed
    if pyramid_setup_allowed and pyramid_allowed_triggers is not None:
        pyramid_setup_allowed = trigger_type in pyramid_allowed_triggers
    if (
        pyramid_setup_allowed
        and params.pyramid_min_score_rank_pct is not None
        and score_rank_pct < params.pyramid_min_score_rank_pct
    ):
        pyramid_setup_allowed = False
    if (
        pyramid_setup_allowed
        and params.pyramid_min_rvol is not None
        and rvol < params.pyramid_min_rvol
    ):
        pyramid_setup_allowed = False
    if (
        pyramid_setup_allowed
        and params.pyramid_max_rvol is not None
        and rvol > params.pyramid_max_rvol
    ):
        pyramid_setup_allowed = False

    def _cap_pyramid_add_shares(desired_shares: int, add_entry_price: float) -> int:
        if desired_shares <= 0:
            return 0
        if available_cash is None:
            return desired_shares
        deployed_so_far = original_shares * entry_price_filled + sum(
            s * e for s, e, _ in pyramid_legs
        )
        cash_left = available_cash - deployed_so_far
        if cash_left <= 0 or add_entry_price <= 0:
            return 0
        return min(desired_shares, int(cash_left / add_entry_price))

    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 exits can either replace or coexist with trailing. ──
            if (
                fixed_sl_price is not None
                and not fixed_loss_preserve_trailing
                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 or fixed_loss_preserve_trailing) 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 3a: Early structural failure exit ──
            if early_failure_exit_ts is not None and ts <= early_failure_exit_ts:
                max_peak_r = getattr(params, "early_failure_exit_max_peak_r", None)
                if max_peak_r is None or peak_r <= float(max_peak_r):
                    support_level = _early_failure_support_level(ts)
                    if support_level is not None and support_level > 0:
                        buffer = max(
                            0.0,
                            float(
                                getattr(
                                    params,
                                    "early_failure_exit_buffer_pct",
                                    0.0,
                                )
                                or 0.0
                            ),
                        )
                        if bar_close < support_level * (1.0 - buffer):
                            exit_price_raw = bar_close
                            exit_time_str = b["timestamp"]
                            exit_reason = "early_failure"
                            final_r = current_r
                            break

            # ── Step 3b: Conditional time stop ──
            if (
                time_stop_exit_ts is not None
                and ts >= time_stop_exit_ts
                and current_r <= params.time_stop_exit_below_r
                and (
                    params.time_stop_max_peak_r is None
                    or peak_r <= params.time_stop_max_peak_r
                )
            ):
                exit_price_raw = bar_close
                exit_time_str = b["timestamp"]
                exit_reason = "time_stop"
                final_r = current_r
                break

            # ── Step 3c: 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 3d: 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 (
                pyramid_setup_allowed
                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)
                    add_shares = _cap_pyramid_add_shares(add_shares, p_entry_filled)
                if add_shares > 0:
                    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 effective_gap_trail_wide_threshold is not None:
                        if abs(gap_pct) > effective_gap_trail_wide_threshold:
                            atr_mult = effective_gap_trail_wide_atr_multiplier
                        elif params.gap_trail_tight_atr_multiplier is not None:
                            atr_mult = params.gap_trail_tight_atr_multiplier
                    if (
                        effective_trailing_tighten_at_r is not None
                        and current_r >= effective_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 exits can either replace or coexist with trailing. ──
            if (
                fixed_sl_price is not None
                and not fixed_loss_preserve_trailing
                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 or fixed_loss_preserve_trailing) 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)

            if early_failure_exit_ts is not None and ts <= early_failure_exit_ts:
                max_peak_r = getattr(params, "early_failure_exit_max_peak_r", None)
                if max_peak_r is None or peak_r <= float(max_peak_r):
                    support_level = _early_failure_support_level(ts)
                    if support_level is not None and support_level > 0:
                        buffer = max(
                            0.0,
                            float(
                                getattr(
                                    params,
                                    "early_failure_exit_buffer_pct",
                                    0.0,
                                )
                                or 0.0
                            ),
                        )
                        if bar_close > support_level * (1.0 + buffer):
                            exit_price_raw = bar_close
                            exit_time_str = b["timestamp"]
                            exit_reason = "early_failure"
                            final_r = current_r
                            break

            if (
                time_stop_exit_ts is not None
                and ts >= time_stop_exit_ts
                and current_r <= params.time_stop_exit_below_r
                and (
                    params.time_stop_max_peak_r is None
                    or peak_r <= params.time_stop_max_peak_r
                )
            ):
                exit_price_raw = bar_close
                exit_time_str = b["timestamp"]
                exit_reason = "time_stop"
                final_r = current_r
                break

            # ── 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 (
                pyramid_setup_allowed
                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)
                    add_shares = _cap_pyramid_add_shares(add_shares, p_entry_filled)
                if add_shares > 0:
                    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 effective_gap_trail_wide_threshold is not None:
                        if abs(gap_pct) > effective_gap_trail_wide_threshold:
                            atr_mult = effective_gap_trail_wide_atr_multiplier
                        elif params.gap_trail_tight_atr_multiplier is not None:
                            atr_mult = params.gap_trail_tight_atr_multiplier
                    if (
                        effective_trailing_tighten_at_r is not None
                        and current_r >= effective_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,
    )


def _optional_float(value: object, ndigits: int | None = None) -> float | None:
    if value is None:
        return None
    try:
        result = float(value)
    except (TypeError, ValueError):
        return None
    return round(result, ndigits) if ndigits is not None else result


def _attach_orb_candidate_diagnostics(
    trade: IntradayTrade,
    cand: dict,
    ticker_enrich: dict,
    score_rank_pct: float,
) -> None:
    """Copy candidate-selection features onto filled trades for post-run analysis."""

    trade.gap_pct = _optional_float(cand.get("gap_pct"), 6)
    trade.entry_dollar_volume = _optional_float(cand.get("first_bar_dollar_vol"), 2)
    trade.avg_dollar_vol_30d = _optional_float(ticker_enrich.get("avg_dollar_vol_30d"), 2)
    trade.entropy_20d = _optional_float(cand.get("entropy_20d"), 4)
    trade.ret_5d = _optional_float(ticker_enrich.get("ret_5d"), 6)
    trade.event_score = _optional_float(cand.get("event_score"), 4)
    trade.ownership_13dg_flag = bool(cand.get("ownership_13dg_flag"))
    trade.ownership_13dg_initial_flag = bool(cand.get("ownership_13dg_initial_flag"))
    trade.ownership_13dg_days_since = (
        int(cand.get("ownership_13dg_days_since"))
        if cand.get("ownership_13dg_days_since") is not None
        else None
    )
    trade.ownership_13dg_strength_score = _optional_float(
        cand.get("ownership_13dg_strength_score"),
        4,
    )
    trade.form4_flag = bool(cand.get("form4_flag"))
    trade.form4_days_since = (
        int(cand.get("form4_days_since"))
        if cand.get("form4_days_since") is not None
        else None
    )
    trade.form4_total_value = _optional_float(cand.get("form4_total_value"), 2)
    trade.form4_owner_count = (
        int(cand.get("form4_owner_count"))
        if cand.get("form4_owner_count") is not None
        else None
    )
    trade.form4_c_suite_count = (
        int(cand.get("form4_c_suite_count"))
        if cand.get("form4_c_suite_count") is not None
        else None
    )

    trade.candidate_score = _optional_float(cand.get("score"), 6)
    trade.score_rank_pct = _optional_float(score_rank_pct, 6)
    trade.premarket_dollar_vol = _optional_float(cand.get("premarket_dollar_vol"), 2)
    trade.first_bar_dollar_vol = _optional_float(cand.get("first_bar_dollar_vol"), 2)
    trade.volume_attention_rank_pct = _optional_float(
        cand.get("volume_attention_rank_pct"),
        6,
    )
    trade.volume_attention_global_rank_pct = _optional_float(
        cand.get("volume_attention_global_rank_pct"),
        6,
    )
    trade.volume_attention_sector_rank_pct = _optional_float(
        cand.get("volume_attention_sector_rank_pct"),
        6,
    )
    trade.volume_attention_price_rank_pct = _optional_float(
        cand.get("volume_attention_price_rank_pct"),
        6,
    )
    trade.opening_dollar_vol_rank_pct = _optional_float(
        cand.get("opening_dollar_vol_rank_pct"),
        6,
    )
    trade.rvol_rank_pct = _optional_float(cand.get("rvol_rank_pct"), 6)
    trade.premarket_dollar_vol_rank_pct = _optional_float(
        cand.get("premarket_dollar_vol_rank_pct"),
        6,
    )
    trade.body_ratio = _optional_float(cand.get("body_ratio"), 6)
    trade.close_location = _optional_float(cand.get("close_location"), 6)
    trade.gap_zscore_20d = _optional_float(cand.get("gap_zscore_20d"), 6)
    trade.obv_slope_20 = _optional_float(cand.get("obv_slope_20"), 6)
    trade.obv_slope_5 = _optional_float(cand.get("obv_slope_5"), 6)
    trade.orb_return = _optional_float(cand.get("orb_return"), 6)
    trade.crowded_gap_requires_confirmation = bool(
        cand.get("crowded_gap_requires_confirmation")
    )
    trade.crowded_gap_size_scale = _optional_float(cand.get("crowded_gap_size_scale"), 4)
    trade.countertrend_gap_requires_confirmation = bool(
        cand.get("countertrend_gap_requires_confirmation")
    )
    trade.countertrend_gap_size_scale = _optional_float(
        cand.get("countertrend_gap_size_scale"), 4
    )
    trade.distressed_reclaim_requires_confirmation = bool(
        cand.get("distressed_reclaim_requires_confirmation")
    )
    trade.distressed_reclaim_size_scale = _optional_float(
        cand.get("distressed_reclaim_size_scale"), 4
    )
    trade.hot_reclaim_requires_confirmation = bool(
        cand.get("hot_reclaim_requires_confirmation")
    )
    trade.hot_reclaim_size_scale = _optional_float(cand.get("hot_reclaim_size_scale"), 4)
    trade.weak_downside_reclaim_requires_confirmation = bool(
        cand.get("weak_downside_reclaim_requires_confirmation")
    )
    trade.weak_downside_reclaim_size_scale = _optional_float(
        cand.get("weak_downside_reclaim_size_scale"), 4
    )
    trade.quiet_downside_reclaim_requires_confirmation = bool(
        cand.get("quiet_downside_reclaim_requires_confirmation")
    )
    trade.quiet_downside_reclaim_size_scale = _optional_float(
        cand.get("quiet_downside_reclaim_size_scale"), 4
    )
    trade.stale_obv_reversal_requires_confirmation = bool(
        cand.get("stale_obv_reversal_requires_confirmation")
    )
    trade.stale_obv_reversal_size_scale = _optional_float(
        cand.get("stale_obv_reversal_size_scale"), 4
    )
    trade.thin_gap_up_loss_cap_active = bool(cand.get("thin_gap_up_loss_cap_active"))
    trade.moderate_downside_loss_cap_active = bool(
        cand.get("moderate_downside_loss_cap_active")
    )
    trade.stalled_gap_up_requires_confirmation = bool(
        cand.get("stalled_gap_up_requires_confirmation")
    )
    trade.stalled_gap_up_size_scale = _optional_float(
        cand.get("stalled_gap_up_size_scale"), 4
    )
    trade.liquid_stalled_gap_up_requires_confirmation = bool(
        cand.get("liquid_stalled_gap_up_requires_confirmation")
    )
    trade.liquid_stalled_gap_up_size_scale = _optional_float(
        cand.get("liquid_stalled_gap_up_size_scale"), 4
    )
    trade.sector_confirmation_active = bool(cand.get("sector_confirmation_active"))
    trade.sector_confirmation_member_count = (
        int(cand.get("sector_confirmation_member_count") or 0) or None
    )
    trade.sector_confirmation_avg_orb_return = _optional_float(
        cand.get("sector_confirmation_avg_orb_return"), 6
    )
    trade.sector_confirmation_total_first_bar_dollar_vol = _optional_float(
        cand.get("sector_confirmation_total_first_bar_dollar_vol"), 2
    )
    trade.sector_confirmation_score = _optional_float(
        cand.get("sector_confirmation_score"), 6
    )
    trade.red_to_green_reserved = bool(cand.get("red_to_green_reserved"))
    trade.candidate_seed_overlay = bool(cand.get("candidate_seed_overlay"))
    trade.candidate_seed_overlay_reserved = bool(
        cand.get("candidate_seed_overlay_reserved")
    )
    trade.gap_up_fill_exit_active = bool(cand.get("gap_up_fill_exit_active"))


def _float_or_none(value: object) -> float | None:
    if value is None:
        return None
    try:
        return float(value)
    except (TypeError, ValueError):
        return None


def _orb_gap_exhaustion_pressure_size_scale(
    params: ORBStrategyParams,
    cand: dict,
    ticker_enrich: dict,
    direction_str: str,
) -> float:
    """Scale stretched positive gaps whose ORB candle does not close strongly."""

    min_gap_zscore = getattr(params, "gap_exhaustion_pressure_min_gap_zscore", None)
    max_close_location = getattr(
        params,
        "gap_exhaustion_pressure_max_close_location",
        None,
    )
    if direction_str != "long" or min_gap_zscore is None or max_close_location is None:
        return 1.0

    gap_pct = _float_or_none(cand.get("gap_pct"))
    min_gap_pct = getattr(params, "gap_exhaustion_pressure_min_gap_pct", None)
    if min_gap_pct is not None and (gap_pct is None or gap_pct < float(min_gap_pct)):
        return 1.0

    gap_zscore = _float_or_none(
        cand.get("gap_zscore_20d")
        if cand.get("gap_zscore_20d") is not None
        else ticker_enrich.get("gap_zscore_20d")
    )
    close_location = _float_or_none(cand.get("close_location"))
    ret_5d = _float_or_none(
        cand.get("ret_5d")
        if cand.get("ret_5d") is not None
        else ticker_enrich.get("ret_5d")
    )
    min_ret_5d = getattr(params, "gap_exhaustion_pressure_min_ret_5d", None)
    if (
        gap_zscore is None
        or close_location is None
        or gap_zscore < float(min_gap_zscore)
        or close_location > float(max_close_location)
        or (
            min_ret_5d is not None
            and (ret_5d is None or ret_5d < float(min_ret_5d))
        )
    ):
        return 1.0

    raw_scale = getattr(params, "gap_exhaustion_pressure_size_scale", 1.0)
    return max(
        0.0,
        min(
            1.0,
            float(1.0 if raw_scale is None else raw_scale),
        ),
    )


def _orb_stale_obv_rvol_pressure_size_scale(
    params: ORBStrategyParams,
    cand: dict,
    ticker_enrich: dict,
    score_rank_pct: float,
) -> float:
    """Scale low-RVOL breakouts when the prior OBV trend is stale."""

    max_rvol = getattr(params, "stale_obv_rvol_pressure_max_rvol", None)
    max_obv20 = getattr(params, "stale_obv_rvol_pressure_max_obv_slope_20d", None)
    if max_rvol is None or max_obv20 is None:
        return 1.0

    rvol = _float_or_none(cand.get("rvol"))
    obv20 = _float_or_none(
        cand.get("obv_slope_20")
        if cand.get("obv_slope_20") is not None
        else ticker_enrich.get("obv_slope_20")
    )
    ret_5d = _float_or_none(
        cand.get("ret_5d")
        if cand.get("ret_5d") is not None
        else ticker_enrich.get("ret_5d")
    )
    max_ret_5d = getattr(params, "stale_obv_rvol_pressure_max_ret_5d", None)
    max_rank = getattr(params, "stale_obv_rvol_pressure_max_score_rank_pct", None)
    if (
        rvol is None
        or obv20 is None
        or rvol > float(max_rvol)
        or obv20 > float(max_obv20)
        or (
            max_ret_5d is not None
            and (ret_5d is None or ret_5d > float(max_ret_5d))
        )
        or (max_rank is not None and score_rank_pct > float(max_rank))
    ):
        return 1.0

    raw_scale = getattr(params, "stale_obv_rvol_pressure_size_scale", 1.0)
    return max(
        0.0,
        min(
            1.0,
            float(1.0 if raw_scale is None else raw_scale),
        ),
    )


def _orb_mid_attention_exhaustion_size_scale(
    params: ORBStrategyParams,
    cand: dict,
    *,
    trigger_type: str,
    direction_str: str,
) -> tuple[bool, float]:
    """Scale mid-liquidity names with elevated but non-extreme opening RVOL."""

    raw_scale = getattr(params, "mid_attention_exhaustion_size_scale", 1.0)
    if raw_scale is None or float(raw_scale) >= 1.0:
        return False, 1.0
    if direction_str != "long":
        return False, 1.0

    allowed_triggers = getattr(
        params,
        "mid_attention_exhaustion_allowed_trigger_types",
        None,
    )
    if allowed_triggers is not None and trigger_type not in {str(t) for t in allowed_triggers}:
        return False, 1.0

    min_rvol = getattr(params, "mid_attention_exhaustion_min_rvol", None)
    max_rvol = getattr(params, "mid_attention_exhaustion_max_rvol", None)
    min_pm = getattr(
        params,
        "mid_attention_exhaustion_min_premarket_dollar_vol",
        None,
    )
    max_pm = getattr(
        params,
        "mid_attention_exhaustion_max_premarket_dollar_vol",
        None,
    )
    if min_rvol is None or max_rvol is None or min_pm is None or max_pm is None:
        return False, 1.0

    rvol = _float_or_none(cand.get("rvol"))
    pm_dollar_vol = _float_or_none(cand.get("premarket_dollar_vol"))
    active = (
        rvol is not None
        and pm_dollar_vol is not None
        and float(min_rvol) <= rvol <= float(max_rvol)
        and float(min_pm) <= pm_dollar_vol <= float(max_pm)
    )
    if not active:
        return False, 1.0

    return True, max(0.0, min(1.0, float(raw_scale)))


def _orb_mid_liquidity_fragility_size_scale(
    params: ORBStrategyParams,
    cand: dict,
    ticker_enrich: dict,
    *,
    trigger_type: str,
    direction_str: str,
) -> tuple[bool, float]:
    """Scale fragile mid-liquidity setups without removing the trade slot."""

    raw_scale = getattr(params, "mid_liquidity_fragility_size_scale", 1.0)
    if raw_scale is None or float(raw_scale) >= 1.0:
        return False, 1.0
    if direction_str != "long":
        return False, 1.0

    allowed_triggers = getattr(
        params,
        "mid_liquidity_fragility_allowed_trigger_types",
        None,
    )
    if allowed_triggers is not None and trigger_type not in {str(t) for t in allowed_triggers}:
        return False, 1.0

    pm_dollar_vol = _float_or_none(cand.get("premarket_dollar_vol"))
    rvol = _float_or_none(cand.get("rvol"))
    body_ratio = _float_or_none(cand.get("body_ratio"))
    ret_5d = _float_or_none(
        cand.get("ret_5d")
        if cand.get("ret_5d") is not None
        else ticker_enrich.get("ret_5d")
    )
    if pm_dollar_vol is None:
        return False, 1.0

    thin_min_pm = getattr(
        params,
        "mid_liquidity_fragility_thin_min_premarket_dollar_vol",
        None,
    )
    thin_max_pm = getattr(
        params,
        "mid_liquidity_fragility_thin_max_premarket_dollar_vol",
        None,
    )
    thin_active = False
    if thin_min_pm is not None and thin_max_pm is not None:
        thin_profile = float(thin_min_pm) <= pm_dollar_vol <= float(thin_max_pm)
        if thin_profile:
            thin_clauses: list[bool] = []
            thin_max_rvol = getattr(
                params,
                "mid_liquidity_fragility_thin_max_rvol",
                None,
            )
            if thin_max_rvol is not None:
                thin_clauses.append(rvol is not None and rvol <= float(thin_max_rvol))

            thin_min_ret = getattr(
                params,
                "mid_liquidity_fragility_thin_min_ret_5d",
                None,
            )
            thin_max_ret = getattr(
                params,
                "mid_liquidity_fragility_thin_max_ret_5d",
                None,
            )
            if thin_min_ret is not None and thin_max_ret is not None:
                thin_clauses.append(
                    ret_5d is not None
                    and float(thin_min_ret) <= ret_5d <= float(thin_max_ret)
                )

            thin_min_body = getattr(
                params,
                "mid_liquidity_fragility_thin_min_body_ratio",
                None,
            )
            if thin_min_body is not None:
                thin_clauses.append(
                    body_ratio is not None and body_ratio >= float(thin_min_body)
                )

            thin_active = any(thin_clauses)

    mid_min_pm = getattr(
        params,
        "mid_liquidity_fragility_mid_min_premarket_dollar_vol",
        None,
    )
    mid_max_pm = getattr(
        params,
        "mid_liquidity_fragility_mid_max_premarket_dollar_vol",
        None,
    )
    mid_min_body = getattr(
        params,
        "mid_liquidity_fragility_mid_min_body_ratio",
        None,
    )
    mid_max_body = getattr(
        params,
        "mid_liquidity_fragility_mid_max_body_ratio",
        None,
    )
    mid_active = (
        mid_min_pm is not None
        and mid_max_pm is not None
        and mid_min_body is not None
        and mid_max_body is not None
        and body_ratio is not None
        and float(mid_min_pm) <= pm_dollar_vol <= float(mid_max_pm)
        and float(mid_min_body) <= body_ratio <= float(mid_max_body)
    )

    if not (thin_active or mid_active):
        return False, 1.0

    return True, max(0.0, min(1.0, float(raw_scale)))


def _orb_orphan_thin_attention_size_scale(
    params: ORBStrategyParams,
    cand: dict,
    *,
    trigger_type: str,
    direction_str: str,
) -> tuple[bool, float]:
    """Scale thin ORB candidates whose attention is not sector-confirmed."""

    raw_scale = getattr(params, "orphan_thin_attention_size_scale", 1.0)
    max_pm = getattr(
        params,
        "orphan_thin_attention_max_premarket_dollar_vol",
        None,
    )
    if raw_scale is None or float(raw_scale) >= 1.0 or max_pm is None:
        return False, 1.0
    if direction_str != "long":
        return False, 1.0

    allowed_triggers = getattr(
        params,
        "orphan_thin_attention_allowed_trigger_types",
        None,
    )
    if allowed_triggers is not None and trigger_type not in {str(t) for t in allowed_triggers}:
        return False, 1.0

    if bool(cand.get("sector_confirmation_active")):
        return False, 1.0

    pm_dollar_vol = _float_or_none(cand.get("premarket_dollar_vol"))
    if pm_dollar_vol is None or pm_dollar_vol > float(max_pm):
        return False, 1.0

    return True, max(0.0, min(1.0, float(raw_scale)))


def _orb_gap_up_fill_trap_size_scale(
    params: ORBStrategyParams,
    cand: dict,
    *,
    trigger_type: str,
    direction_str: str,
) -> tuple[bool, float]:
    """Scale positive-gap candidates where gap-fill exit is not enough."""

    raw_scale = getattr(params, "gap_up_fill_trap_size_scale", 1.0)
    max_orb_return = getattr(params, "gap_up_fill_trap_max_orb_return", None)
    if raw_scale is None or float(raw_scale) >= 1.0 or max_orb_return is None:
        return False, 1.0
    if direction_str != "long":
        return False, 1.0

    allowed_triggers = getattr(
        params,
        "gap_up_fill_trap_allowed_trigger_types",
        None,
    )
    if allowed_triggers is not None and trigger_type not in {str(t) for t in allowed_triggers}:
        return False, 1.0

    if not bool(cand.get("gap_up_fill_exit_active")):
        return False, 1.0

    orb_return = _float_or_none(cand.get("orb_return"))
    if orb_return is None or orb_return > float(max_orb_return):
        return False, 1.0

    return True, max(0.0, min(1.0, float(raw_scale)))


def _orb_low_candidate_quality_size_scale(
    params: ORBStrategyParams,
    cand: dict,
    *,
    trigger_type: str,
    direction_str: str,
) -> tuple[bool, float]:
    """Scale entries whose composite cross-sectional score is too weak."""

    raw_scale = getattr(params, "low_candidate_quality_size_scale", 1.0)
    max_score = getattr(params, "low_candidate_quality_max_score", None)
    if raw_scale is None or float(raw_scale) >= 1.0 or max_score is None:
        return False, 1.0
    if direction_str != "long":
        return False, 1.0

    allowed_triggers = getattr(
        params,
        "low_candidate_quality_allowed_trigger_types",
        None,
    )
    if allowed_triggers is not None and trigger_type not in {str(t) for t in allowed_triggers}:
        return False, 1.0

    score = _float_or_none(cand.get("score"))
    if score is None or score > float(max_score):
        return False, 1.0

    return True, max(0.0, min(1.0, float(raw_scale)))


def _orb_unboosted_primary_fragility_size_scale(
    params: ORBStrategyParams,
    cand: dict,
    ticker_enrich: dict,
    *,
    trigger_type: str,
    direction_str: str,
    high_conviction_size_scales: tuple[float, ...],
) -> float:
    """Scale fragile primary ORB setups only when no high-conviction booster fired."""

    raw_scale = getattr(params, "unboosted_primary_fragility_size_scale", 1.0)
    if raw_scale is None or float(raw_scale) >= 1.0:
        return 1.0
    if direction_str != "long":
        return 1.0
    if any(scale > 1.0 for scale in high_conviction_size_scales):
        return 1.0

    allowed_triggers = getattr(
        params,
        "unboosted_primary_fragility_allowed_trigger_types",
        None,
    )
    if allowed_triggers is not None and trigger_type not in {str(t) for t in allowed_triggers}:
        return 1.0

    gap_pct = _float_or_none(cand.get("gap_pct"))
    pm_dollar_vol = _float_or_none(cand.get("premarket_dollar_vol"))
    close_location = _float_or_none(cand.get("close_location"))
    rvol = _float_or_none(cand.get("rvol"))
    ret_5d = _float_or_none(
        cand.get("ret_5d")
        if cand.get("ret_5d") is not None
        else ticker_enrich.get("ret_5d")
    )

    crowded_active = False
    crowded_min_gap = getattr(
        params,
        "unboosted_primary_fragility_crowded_min_gap_pct",
        None,
    )
    crowded_min_pm = getattr(
        params,
        "unboosted_primary_fragility_crowded_min_premarket_dollar_vol",
        None,
    )
    crowded_max_close = getattr(
        params,
        "unboosted_primary_fragility_crowded_max_close_location",
        None,
    )
    if (
        crowded_min_gap is not None
        and crowded_min_pm is not None
        and crowded_max_close is not None
    ):
        crowded_active = (
            gap_pct is not None
            and gap_pct >= float(crowded_min_gap)
            and pm_dollar_vol is not None
            and pm_dollar_vol >= float(crowded_min_pm)
            and close_location is not None
            and close_location <= float(crowded_max_close)
        )

    weak_attention_active = False
    weak_max_rvol = getattr(
        params,
        "unboosted_primary_fragility_weak_max_rvol",
        None,
    )
    weak_max_ret = getattr(
        params,
        "unboosted_primary_fragility_weak_max_ret_5d",
        None,
    )
    if weak_max_rvol is not None and weak_max_ret is not None:
        weak_attention_active = (
            rvol is not None
            and rvol <= float(weak_max_rvol)
            and ret_5d is not None
            and ret_5d <= float(weak_max_ret)
        )

    if not (crowded_active or weak_attention_active):
        return 1.0

    return max(0.0, min(1.0, float(raw_scale)))


def _orb_unsupported_attention_size_scale(
    params: ORBStrategyParams,
    cand: dict,
    *,
    trigger_type: str,
    direction_str: str,
    high_conviction_size_scales: tuple[float, ...],
) -> float:
    """Scale attention-heavy setups that lack sector/leader confirmation."""

    raw_scale = getattr(params, "unsupported_attention_size_scale", 1.0)
    if raw_scale is None or float(raw_scale) >= 1.0:
        return 1.0
    if direction_str != "long":
        return 1.0

    allowed_triggers = getattr(
        params,
        "unsupported_attention_allowed_trigger_types",
        None,
    )
    if allowed_triggers is not None and trigger_type not in {str(t) for t in allowed_triggers}:
        return 1.0

    if (
        getattr(params, "unsupported_attention_ignore_high_conviction", True)
        and any(scale > 1.0 for scale in high_conviction_size_scales)
    ):
        return 1.0

    score = _float_or_none(cand.get("score"))
    pm_dollar_vol = _float_or_none(cand.get("premarket_dollar_vol"))
    gap_pct = _float_or_none(cand.get("gap_pct"))
    rvol = _float_or_none(cand.get("rvol"))
    sector_confirmed = bool(cand.get("sector_confirmation_active"))

    liquid_active = False
    liquid_min_pm = getattr(
        params,
        "unsupported_attention_liquid_min_premarket_dollar_vol",
        None,
    )
    liquid_max_score = getattr(
        params,
        "unsupported_attention_liquid_max_candidate_score",
        None,
    )
    if liquid_min_pm is not None and liquid_max_score is not None:
        liquid_active = (
            pm_dollar_vol is not None
            and pm_dollar_vol >= float(liquid_min_pm)
            and score is not None
            and score <= float(liquid_max_score)
            and (
                not getattr(
                    params,
                    "unsupported_attention_liquid_require_no_sector_confirmation",
                    True,
                )
                or not sector_confirmed
            )
        )

    thin_active = False
    thin_max_pm = getattr(
        params,
        "unsupported_attention_thin_max_premarket_dollar_vol",
        None,
    )
    thin_min_gap = getattr(params, "unsupported_attention_thin_min_gap_pct", None)
    thin_min_rvol = getattr(params, "unsupported_attention_thin_min_rvol", None)
    if thin_max_pm is not None and thin_min_gap is not None and thin_min_rvol is not None:
        first_bar_dollar_vol = _float_or_none(cand.get("first_bar_dollar_vol"))
        thin_max_first_bar = getattr(
            params,
            "unsupported_attention_thin_max_first_bar_dollar_vol",
            None,
        )
        thin_active = (
            pm_dollar_vol is not None
            and pm_dollar_vol <= float(thin_max_pm)
            and gap_pct is not None
            and gap_pct >= float(thin_min_gap)
            and rvol is not None
            and rvol >= float(thin_min_rvol)
            and (
                thin_max_first_bar is None
                or (
                    first_bar_dollar_vol is not None
                    and first_bar_dollar_vol <= float(thin_max_first_bar)
                )
            )
        )
        thin_max_rvol = getattr(params, "unsupported_attention_thin_max_rvol", None)
        if thin_active and thin_max_rvol is not None:
            thin_active = rvol <= float(thin_max_rvol)

    if not (liquid_active or thin_active):
        return 1.0

    return max(0.0, min(1.0, float(raw_scale)))


def _orb_positive_gap_rebound_failure_size_scale(
    params: ORBStrategyParams,
    cand: dict,
    ticker_enrich: dict,
    *,
    trigger_type: str,
    direction_str: str,
) -> float:
    """Scale failed positive-gap rebounds after a sharp prior selloff."""

    raw_scale = getattr(params, "positive_gap_rebound_failure_size_scale", 1.0)
    if raw_scale is None or float(raw_scale) >= 1.0:
        return 1.0
    if direction_str != "long":
        return 1.0

    allowed_triggers = getattr(
        params,
        "positive_gap_rebound_failure_allowed_trigger_types",
        None,
    )
    if allowed_triggers is not None and trigger_type not in {str(t) for t in allowed_triggers}:
        return 1.0

    min_gap = getattr(params, "positive_gap_rebound_failure_min_gap_pct", None)
    max_ret_5d = getattr(params, "positive_gap_rebound_failure_max_ret_5d", None)
    max_orb_return = getattr(params, "positive_gap_rebound_failure_max_orb_return", None)
    min_attention = getattr(
        params,
        "positive_gap_rebound_failure_min_volume_attention_rank_pct",
        None,
    )
    if (
        min_gap is None
        or max_ret_5d is None
        or max_orb_return is None
        or min_attention is None
    ):
        return 1.0

    gap_pct = _float_or_none(cand.get("gap_pct"))
    ret_5d = _float_or_none(
        cand.get("ret_5d")
        if cand.get("ret_5d") is not None
        else ticker_enrich.get("ret_5d")
    )
    orb_return = _float_or_none(cand.get("orb_return"))
    attention_rank = _float_or_none(cand.get("volume_attention_rank_pct"))
    if (
        gap_pct is None
        or ret_5d is None
        or orb_return is None
        or attention_rank is None
    ):
        return 1.0

    active = (
        gap_pct >= float(min_gap)
        and ret_5d <= float(max_ret_5d)
        and orb_return <= float(max_orb_return)
        and attention_rank >= float(min_attention)
    )
    if not active:
        return 1.0

    return max(0.0, min(1.0, float(raw_scale)))


def _orb_red_to_green_acceleration_size_scale(
    params: ORBStrategyParams,
    cand: dict,
    ticker_enrich: dict,
    score_rank_pct: float,
    direction_str: str,
    risk_size_scales: tuple[float, ...],
) -> float:
    """Boost only clean, strong downside-gap reclaim breakouts."""

    raw_scale = getattr(params, "red_to_green_acceleration_size_scale", 1.0)
    if raw_scale is None or float(raw_scale) <= 1.0:
        return 1.0
    if direction_str != "long":
        return 1.0

    if getattr(params, "red_to_green_acceleration_ignore_scaled_risk_overlays", True):
        if any(scale < 1.0 for scale in risk_size_scales):
            return 1.0

    min_abs_gap = getattr(params, "red_to_green_acceleration_min_abs_gap_pct", None)
    if min_abs_gap is not None:
        gap_pct = cand.get("gap_pct")
        if gap_pct is None or float(gap_pct) > -float(min_abs_gap):
            return 1.0

    min_orb_return = getattr(params, "red_to_green_acceleration_min_orb_return", None)
    if min_orb_return is not None:
        orb_return = cand.get("orb_return")
        if orb_return is None or float(orb_return) < float(min_orb_return):
            return 1.0

    min_score_rank = getattr(
        params, "red_to_green_acceleration_min_score_rank_pct", None
    )
    if min_score_rank is not None and score_rank_pct < float(min_score_rank):
        return 1.0

    min_candidate_score = getattr(
        params, "red_to_green_acceleration_min_candidate_score", None
    )
    if min_candidate_score is not None:
        if float(cand.get("score") or 0.0) < float(min_candidate_score):
            return 1.0

    min_pm_dollar_vol = getattr(
        params, "red_to_green_acceleration_min_premarket_dollar_vol", None
    )
    if min_pm_dollar_vol is not None:
        pm_dollar_vol = cand.get("premarket_dollar_vol")
        if pm_dollar_vol is None or float(pm_dollar_vol) < float(min_pm_dollar_vol):
            return 1.0

    return max(1.0, min(3.0, float(raw_scale)))


def _orb_liquid_leader_conviction_size_scale(
    params: ORBStrategyParams,
    cand: dict,
    score_rank_pct: float,
    trigger_type: str,
    direction_str: str,
    risk_size_scales: tuple[float, ...],
) -> float:
    """Boost only liquid, top-ranked leaders that were not defensively scaled."""

    raw_scale = getattr(params, "liquid_leader_conviction_size_scale", 1.0)
    if raw_scale is None or float(raw_scale) <= 1.0:
        return 1.0
    if direction_str != "long":
        return 1.0

    allowed_triggers = getattr(
        params,
        "liquid_leader_conviction_allowed_trigger_types",
        None,
    )
    if allowed_triggers is not None and trigger_type not in set(allowed_triggers):
        return 1.0

    if (
        trigger_type != "orb"
        and bool(
            getattr(
                params,
                "liquid_leader_conviction_secondary_requires_sector_confirmation",
                False,
            )
        )
        and not bool(cand.get("sector_confirmation_active"))
    ):
        return 1.0

    secondary_min_body = getattr(
        params,
        "liquid_leader_conviction_secondary_min_body_ratio",
        None,
    )
    if trigger_type != "orb" and secondary_min_body is not None:
        body_ratio = cand.get("body_ratio")
        if body_ratio is None or float(body_ratio) < float(secondary_min_body):
            return 1.0

    if getattr(params, "liquid_leader_conviction_ignore_scaled_risk_overlays", True):
        if any(scale < 1.0 for scale in risk_size_scales):
            return 1.0

    min_candidate_score = getattr(
        params,
        "liquid_leader_conviction_min_candidate_score",
        None,
    )
    if min_candidate_score is not None:
        if float(cand.get("score") or 0.0) < float(min_candidate_score):
            return 1.0

    min_score_rank = getattr(
        params,
        "liquid_leader_conviction_min_score_rank_pct",
        None,
    )
    if min_score_rank is not None and score_rank_pct < float(min_score_rank):
        return 1.0

    min_pm_dollar_vol = getattr(
        params,
        "liquid_leader_conviction_min_premarket_dollar_vol",
        None,
    )
    if min_pm_dollar_vol is not None:
        pm_dollar_vol = cand.get("premarket_dollar_vol")
        if pm_dollar_vol is None or float(pm_dollar_vol) < float(min_pm_dollar_vol):
            return 1.0

    min_rvol = getattr(params, "liquid_leader_conviction_min_rvol", None)
    if min_rvol is not None:
        rvol = cand.get("rvol")
        if rvol is None or float(rvol) < float(min_rvol):
            return 1.0

    min_abs_gap = getattr(params, "liquid_leader_conviction_min_abs_gap_pct", None)
    if min_abs_gap is not None:
        gap_pct = cand.get("gap_pct")
        if gap_pct is None or abs(float(gap_pct)) < float(min_abs_gap):
            return 1.0

    return max(1.0, min(3.0, float(raw_scale)))


def _orb_ownership_initial_size_scale(
    params: ORBStrategyParams,
    cand: dict,
    score_rank_pct: float,
    trigger_type: str,
    direction_str: str,
    risk_size_scales: tuple[float, ...],
) -> float:
    """Boost only PIT-safe initial-owner 13D/13G names that are already clean ORB setups."""

    raw_scale = getattr(params, "ownership_initial_size_scale", 1.0)
    if raw_scale is None or float(raw_scale) <= 1.0:
        return 1.0
    if direction_str != "long":
        return 1.0
    if not bool(cand.get("ownership_13dg_initial_flag")):
        return 1.0

    allowed_triggers = getattr(params, "ownership_initial_allowed_trigger_types", None)
    if allowed_triggers is not None and trigger_type not in {str(t) for t in allowed_triggers}:
        return 1.0

    min_score_rank = getattr(params, "ownership_initial_min_score_rank_pct", None)
    if min_score_rank is not None and score_rank_pct < float(min_score_rank):
        return 1.0

    if getattr(params, "ownership_initial_ignore_scaled_risk_overlays", True):
        if any(scale < 1.0 for scale in risk_size_scales):
            return 1.0

    return max(1.0, min(2.0, float(raw_scale)))


def _orb_form4_size_scale(
    params: ORBStrategyParams,
    cand: dict,
    trigger_type: str,
    direction_str: str,
    risk_size_scales: tuple[float, ...],
) -> float:
    """Boost clean ORB setups with recent PIT-safe Form 4 insider-buy support."""

    raw_scale = getattr(params, "form4_size_scale", 1.0)
    if raw_scale is None or float(raw_scale) <= 1.0:
        return 1.0
    if direction_str != "long":
        return 1.0
    if not bool(cand.get("form4_flag")):
        return 1.0

    allowed_triggers = getattr(params, "form4_allowed_trigger_types", None)
    if allowed_triggers is not None and trigger_type not in {str(t) for t in allowed_triggers}:
        return 1.0

    if getattr(params, "form4_ignore_scaled_risk_overlays", True):
        if any(scale < 1.0 for scale in risk_size_scales):
            return 1.0

    min_total = getattr(params, "form4_min_total_value", None)
    if min_total is not None:
        total_value = cand.get("form4_total_value")
        if total_value is None or float(total_value) < float(min_total):
            return 1.0

    min_owner_count = getattr(params, "form4_min_owner_count", None)
    min_c_suite_count = getattr(params, "form4_min_c_suite_count", None)
    owner_count = int(cand.get("form4_owner_count") or 0)
    c_suite_count = int(cand.get("form4_c_suite_count") or 0)

    if bool(getattr(params, "form4_require_cluster_or_csuite", False)):
        cluster_ok = (
            min_owner_count is not None and owner_count >= int(min_owner_count)
        )
        csuite_ok = (
            min_c_suite_count is not None and c_suite_count >= int(min_c_suite_count)
        )
        if not (cluster_ok or csuite_ok):
            return 1.0
    else:
        if min_owner_count is not None and owner_count < int(min_owner_count):
            return 1.0
        if min_c_suite_count is not None and c_suite_count < int(min_c_suite_count):
            return 1.0

    return max(1.0, min(2.0, float(raw_scale)))


def _orb_opening_burst_liquid_size_scale(
    params: ORBStrategyParams,
    cand: dict,
    *,
    date_str: str,
    entry_ts: dt.datetime,
    trigger_type: str,
    score_rank_pct: float,
    direction_str: str,
    risk_size_scales: tuple[float, ...],
) -> float:
    """Boost liquid leaders only when the actual entry happens in the opening burst."""

    raw_scale = getattr(params, "opening_burst_liquid_size_scale", 1.0)
    if raw_scale is None or float(raw_scale) <= 1.0:
        return 1.0
    if direction_str != "long":
        return 1.0

    allowed_triggers = getattr(params, "opening_burst_liquid_allowed_trigger_types", None)
    if allowed_triggers is not None and trigger_type not in set(allowed_triggers):
        return 1.0

    max_minutes = getattr(
        params,
        "opening_burst_liquid_max_entry_minutes_after_open",
        None,
    )
    if max_minutes is None:
        return 1.0
    market_open = _market_open_ts(date_str)
    entry_minutes = (entry_ts.astimezone(_ET) - market_open).total_seconds() / 60.0
    if entry_minutes < 0 or entry_minutes > float(max_minutes):
        return 1.0

    min_pm_dollar_vol = getattr(
        params,
        "opening_burst_liquid_min_premarket_dollar_vol",
        None,
    )
    if min_pm_dollar_vol is not None:
        pm_dollar_vol = cand.get("premarket_dollar_vol")
        if pm_dollar_vol is None or float(pm_dollar_vol) < float(min_pm_dollar_vol):
            return 1.0

    min_gap_pct = getattr(params, "opening_burst_liquid_min_gap_pct", None)
    if min_gap_pct is not None:
        gap_pct = cand.get("gap_pct")
        if gap_pct is None or float(gap_pct) < float(min_gap_pct):
            return 1.0

    min_score_rank = getattr(
        params,
        "opening_burst_liquid_min_score_rank_pct",
        None,
    )
    if min_score_rank is not None and score_rank_pct < float(min_score_rank):
        return 1.0

    min_candidate_score = getattr(
        params,
        "opening_burst_liquid_min_candidate_score",
        None,
    )
    if min_candidate_score is not None:
        if float(cand.get("score") or 0.0) < float(min_candidate_score):
            return 1.0

    if getattr(params, "opening_burst_liquid_ignore_scaled_risk_overlays", True):
        if any(scale < 1.0 for scale in risk_size_scales):
            return 1.0

    return max(1.0, min(3.0, float(raw_scale)))


def _orb_soft_day_setup_profile_allows(
    params: ORBStrategyParams,
    soft_day_reason: str | None,
    cand: dict,
    ticker_enrich: dict,
) -> bool:
    """Named soft-day setup filters for former no-trade days."""
    profile = str(getattr(params, "soft_day_setup_profile", "none") or "none")
    if profile in {"", "none"}:
        return True
    if profile != "skip_day_reclaim_v1":
        return True

    reason = soft_day_reason or ""
    if reason == "market_regime":
        return False

    score = _float_or_none(cand.get("score")) or 0.0
    ret_5d = _float_or_none(cand.get("ret_5d", ticker_enrich.get("ret_5d")))
    rvol = _float_or_none(cand.get("rvol"))
    gap_pct = _float_or_none(cand.get("gap_pct"))
    premarket_dollar_vol = _float_or_none(cand.get("premarket_dollar_vol")) or 0.0
    body_ratio = _float_or_none(cand.get("body_ratio")) or 0.0
    close_location = _float_or_none(cand.get("close_location")) or 0.0

    if ret_5d is None or rvol is None or gap_pct is None:
        return False

    # Preserve the original high-conviction fallback path, but not for
    # market-regime-only days where the wide probe was net-negative.
    if score >= 1.0015 and ret_5d >= 0.03 and rvol <= 30.0:
        return True

    if reason == "breadth":
        if (
            gap_pct < 0.0
            and premarket_dollar_vol >= 20_000_000
            and rvol <= 30.0
            and (
                ret_5d >= 0.15
                or (0.03 <= ret_5d <= 0.08 and close_location <= 0.20)
            )
        ):
            return True
        if (
            gap_pct > 0.0
            and premarket_dollar_vol >= 5_000_000
            and rvol <= 30.0
            and ret_5d >= 0.03
            and body_ratio >= 0.70
            and close_location >= 0.90
        ):
            return True
        return False

    if reason == "market_regime+breadth":
        if (
            gap_pct > 0.0
            and 20_000_000 <= premarket_dollar_vol <= 1_200_000_000
            and rvol <= 30.0
            and ret_5d >= 0.20
            and body_ratio >= 0.60
            and close_location >= 0.65
        ):
            return True
        if (
            -0.10 <= gap_pct < -0.02
            and premarket_dollar_vol >= 10_000_000
            and rvol <= 30.0
            and 0.05 <= ret_5d <= 0.60
            and close_location <= 0.10
        ):
            return True
        return False

    return False


def _soft_day_reason_parts(soft_day_reason: str | None) -> set[str]:
    return {
        part.strip()
        for part in str(soft_day_reason or "").split("+")
        if part.strip()
    }


def _orb_soft_day_vwap_reason_allowed(
    params: ORBStrategyParams,
    soft_day_reason: str | None,
) -> bool:
    """Return whether the auxiliary VWAP sleeve is allowed for this soft-day reason."""

    raw_allowed = getattr(params, "soft_day_vwap_reclaim_allowed_reason_parts", None)
    if not raw_allowed:
        return True
    allowed = {str(part).strip() for part in raw_allowed if str(part).strip()}
    if not allowed:
        return True
    return bool(_soft_day_reason_parts(soft_day_reason) & allowed)


def _orb_soft_day_vwap_min_score_pct(
    params: ORBStrategyParams,
    soft_day_reason: str | None,
) -> float | None:
    """Reason-specific score floor for the soft-day VWAP auxiliary sleeve."""

    parts = _soft_day_reason_parts(soft_day_reason)
    override = None
    if "hard_breadth" in parts:
        override = getattr(
            params,
            "soft_day_vwap_reclaim_min_score_pct_hard_breadth",
            None,
        )
    elif "market_regime" in parts and "breadth" in parts:
        override = getattr(params, "soft_day_vwap_reclaim_min_score_pct_joint", None)
    elif "market_regime" in parts:
        override = getattr(
            params,
            "soft_day_vwap_reclaim_min_score_pct_market_regime",
            None,
        )
    elif "breadth" in parts:
        override = getattr(params, "soft_day_vwap_reclaim_min_score_pct_breadth", None)

    if override is not None:
        return float(override)
    base = getattr(params, "soft_day_vwap_reclaim_min_score_pct", None)
    return None if base is None else float(base)


def _orb_soft_day_vwap_reason_param(
    params: ORBStrategyParams,
    soft_day_reason: str | None,
    base_name: str,
) -> float | None:
    """Return a reason-specific soft-day VWAP gate value when configured."""

    parts = _soft_day_reason_parts(soft_day_reason)
    suffix = None
    if "hard_breadth" in parts:
        suffix = "hard_breadth"
    elif "market_regime" in parts and "breadth" in parts:
        suffix = "joint"
    elif "market_regime" in parts:
        suffix = "market_regime"
    elif "breadth" in parts:
        suffix = "breadth"

    if suffix:
        override = getattr(params, f"{base_name}_{suffix}", None)
        if override is not None:
            return float(override)

    base = getattr(params, base_name, None)
    return None if base is None else float(base)


def _orb_soft_day_vwap_size_scale(
    params: ORBStrategyParams,
    soft_day_reason: str | None,
) -> float:
    """Reason-specific size scale for the soft-day VWAP auxiliary sleeve."""

    parts = _soft_day_reason_parts(soft_day_reason)
    override = None
    if "hard_breadth" in parts:
        override = getattr(
            params,
            "soft_day_vwap_reclaim_size_scale_hard_breadth",
            None,
        )
    elif "market_regime" in parts and "breadth" in parts:
        override = getattr(params, "soft_day_vwap_reclaim_size_scale_joint", None)
    elif "market_regime" in parts:
        override = getattr(
            params,
            "soft_day_vwap_reclaim_size_scale_market_regime",
            None,
        )
    elif "breadth" in parts:
        override = getattr(params, "soft_day_vwap_reclaim_size_scale_breadth", None)

    raw = (
        getattr(params, "soft_day_vwap_reclaim_size_scale", 0.0)
        if override is None
        else override
    )
    return min(1.0, max(0.0, float(raw or 0.0)))


# ── 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
    regime_gap_pct = None
    soft_day_reason_parts: list[str] = []
    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_today_open(
            regime_enrich,
            bars_by_ticker.get(regime_ticker),
            date_str,
        )
        if regime_prev_close and regime_today_open and regime_prev_close > 0:
            regime_gap = (regime_today_open - regime_prev_close) / regime_prev_close
            regime_gap_pct = regime_gap
            # 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):
                if getattr(params, "soft_day_fallback_on_regime_skip", False):
                    regime_scaler = max(
                        0.0,
                        float(getattr(params, "soft_day_regime_skip_size_scale", 1.0) or 0.0),
                    )
                    soft_day_reason_parts.append("market_regime")
                else:
                    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
    breadth_ratio = None
    breadth_positive_count = None
    breadth_total_count = None
    hard_breadth_fallback_active = False
    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 = _regime_today_open(t_enrich, bars_by_ticker.get(ticker), date_str)
            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
            breadth_positive_count = pos_gap_count
            breadth_total_count = total_with_data
            if params.breadth_skip_below is not None and breadth_ratio < params.breadth_skip_below:
                hard_fallback_min = getattr(
                    params, "hard_breadth_soft_fallback_min_breadth", None
                )
                if (
                    getattr(params, "hard_breadth_soft_fallback_enabled", False)
                    and (
                        hard_fallback_min is None
                        or breadth_ratio >= float(hard_fallback_min)
                    )
                ):
                    breadth_scaler = max(
                        0.0,
                        float(
                            getattr(
                                params,
                                "hard_breadth_soft_fallback_size_scale",
                                0.03,
                            )
                            or 0.0
                        ),
                    )
                    soft_day_reason_parts.append("hard_breadth")
                    hard_breadth_fallback_active = True
                else:
                    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
                    and not hard_breadth_fallback_active):
                if getattr(params, "soft_day_fallback_on_breadth_skip", False):
                    breadth_scaler = max(
                        0.0,
                        float(getattr(params, "soft_day_breadth_skip_size_scale", 1.0) or 0.0),
                    )
                    soft_day_reason_parts.append("breadth")
                else:
                    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,
                )

    market_orb_quality_scaler = 1.0
    market_orb_quality_close_location = None
    market_orb_quality_return_pct = None
    market_orb_quality_secondary_close_location = None
    market_orb_quality_secondary_return_pct = None
    market_thrust_breadth_override_active = False
    market_thrust_index_breadth_override_active = False
    market_thrust_opening_breadth_override_active = False
    market_thrust_opening_breadth_positive_ratio = None
    market_thrust_opening_breadth_avg_return_pct = None
    market_thrust_opening_breadth_strong_close_location_ratio = None
    market_thrust_opening_breadth_total_count = None
    market_orb_quality_divergence_active = False
    market_orb_quality_divergence_max_trades_active = False
    market_orb_quality_primary_weak_secondary_strong_active = False
    market_orb_quality_primary_weak_secondary_strong_max_trades_active = False
    market_orb_quality_primary_lag_secondary_lead_active = False
    market_orb_quality_primary_lag_secondary_lead_max_trades_active = False
    market_orb_quality_joint_weak_active = False
    market_orb_quality_joint_weak_max_trades_active = False
    market_orb_quality_joint_panic_active = False
    market_orb_quality_joint_panic_max_trades_active = False
    market_orb_quality_divergence_max_trades = None
    market_orb_quality_joint_weak_condition_met = False
    market_orb_quality_joint_weak_require_confirmation = False
    market_orb_quality_joint_panic_condition_met = False
    market_orb_quality_joint_panic_require_confirmation = False
    quality_ticker = (
        getattr(params, "market_orb_quality_ticker", None)
        or getattr(params, "market_regime_ticker", None)
        or "SPY"
    )
    secondary_quality_ticker = getattr(params, "market_orb_quality_secondary_ticker", None)
    use_market_orb_quality = any(
        getattr(params, key, None) is not None
        for key in (
            "market_orb_quality_size_scale_low",
            "market_orb_quality_size_scale_high",
            "market_orb_quality_primary_strong_above",
            "market_orb_quality_secondary_weak_above",
            "market_orb_quality_secondary_weak_below",
            "market_orb_quality_divergence_scale",
            "market_orb_quality_primary_weak_below",
            "market_orb_quality_primary_weak_above",
            "market_orb_quality_secondary_strong_above",
            "market_orb_quality_primary_weak_secondary_strong_scale",
            "market_orb_quality_primary_lag_above",
            "market_orb_quality_primary_lag_below",
            "market_orb_quality_secondary_lead_above",
            "market_orb_quality_secondary_lead_below",
            "market_orb_quality_primary_lag_secondary_lead_scale",
            "market_orb_quality_joint_weak_primary_below",
            "market_orb_quality_joint_weak_primary_above",
            "market_orb_quality_joint_weak_secondary_below",
            "market_orb_quality_joint_weak_secondary_above",
            "market_orb_quality_joint_weak_scale",
            "market_orb_quality_joint_panic_primary_below",
            "market_orb_quality_joint_panic_secondary_below",
            "market_orb_quality_joint_panic_scale",
            "market_thrust_breadth_override_min_primary_close_location",
            "market_thrust_breadth_override_min_secondary_close_location",
            "market_thrust_breadth_override_min_primary_return_pct",
            "market_thrust_breadth_override_min_secondary_return_pct",
        )
    ) or bool(getattr(params, "market_thrust_breadth_override_enabled", False)) or bool(
        getattr(params, "market_thrust_opening_breadth_override_enabled", False)
    )
    if use_market_orb_quality:
        quality_bar = _first_regular_bar(
            bars_by_ticker.get(quality_ticker, []),
            _market_open_ts(date_str),
        )
        market_orb_quality_close_location = _bar_close_location(quality_bar)
        market_orb_quality_return_pct = _bar_return_pct(quality_bar)
        if (
            getattr(params, "market_orb_quality_size_scale_low", None) is not None
            and getattr(params, "market_orb_quality_size_scale_high", None) is not None
        ):
            market_orb_quality_scaler = _linear_range_scaler(
                market_orb_quality_close_location,
                getattr(params, "market_orb_quality_size_scale_low", None),
                getattr(params, "market_orb_quality_size_scale_high", None),
                getattr(params, "market_orb_quality_size_scale_min", 1.0),
                getattr(params, "market_orb_quality_size_scale_max", 1.0),
            )
        if secondary_quality_ticker:
            secondary_bar = _first_regular_bar(
                bars_by_ticker.get(secondary_quality_ticker, []),
                _market_open_ts(date_str),
            )
            market_orb_quality_secondary_close_location = _bar_close_location(secondary_bar)
            market_orb_quality_secondary_return_pct = _bar_return_pct(secondary_bar)
            primary_strong_above = getattr(params, "market_orb_quality_primary_strong_above", None)
            secondary_weak_below = getattr(params, "market_orb_quality_secondary_weak_below", None)
            secondary_weak_above = getattr(params, "market_orb_quality_secondary_weak_above", None)
            divergence_scale = getattr(params, "market_orb_quality_divergence_scale", None)
            primary_weak_below = getattr(params, "market_orb_quality_primary_weak_below", None)
            primary_weak_above = getattr(params, "market_orb_quality_primary_weak_above", None)
            secondary_strong_above = getattr(params, "market_orb_quality_secondary_strong_above", None)
            primary_weak_secondary_strong_scale = getattr(
                params, "market_orb_quality_primary_weak_secondary_strong_scale", None
            )
            primary_lag_above = getattr(params, "market_orb_quality_primary_lag_above", None)
            primary_lag_below = getattr(params, "market_orb_quality_primary_lag_below", None)
            secondary_lead_above = getattr(params, "market_orb_quality_secondary_lead_above", None)
            secondary_lead_below = getattr(params, "market_orb_quality_secondary_lead_below", None)
            primary_lag_secondary_lead_scale = getattr(
                params, "market_orb_quality_primary_lag_secondary_lead_scale", None
            )
            joint_weak_primary_below = getattr(
                params, "market_orb_quality_joint_weak_primary_below", None
            )
            joint_weak_primary_above = getattr(
                params, "market_orb_quality_joint_weak_primary_above", None
            )
            joint_weak_secondary_below = getattr(
                params, "market_orb_quality_joint_weak_secondary_below", None
            )
            joint_weak_secondary_above = getattr(
                params, "market_orb_quality_joint_weak_secondary_above", None
            )
            joint_panic_primary_below = getattr(
                params, "market_orb_quality_joint_panic_primary_below", None
            )
            joint_panic_secondary_below = getattr(
                params, "market_orb_quality_joint_panic_secondary_below", None
            )
            divergence_condition_met = (
                primary_strong_above is not None
                and secondary_weak_below is not None
                and market_orb_quality_close_location is not None
                and market_orb_quality_secondary_close_location is not None
                and market_orb_quality_close_location >= primary_strong_above
                and market_orb_quality_secondary_close_location <= secondary_weak_below
                and (
                    secondary_weak_above is None
                    or market_orb_quality_secondary_close_location >= secondary_weak_above
                )
            )
            if (
                divergence_scale is not None
                and divergence_condition_met
            ):
                market_orb_quality_scaler *= max(0.0, divergence_scale)
                market_orb_quality_divergence_active = True
            divergence_max_trades = getattr(
                params, "market_orb_quality_divergence_max_trades", None
            )
            if (
                divergence_max_trades is not None
                and divergence_condition_met
            ):
                market_orb_quality_divergence_max_trades = max(
                    0, int(divergence_max_trades)
                )
                market_orb_quality_divergence_max_trades_active = True
            primary_weak_secondary_strong_condition_met = (
                primary_weak_below is not None
                and secondary_strong_above is not None
                and market_orb_quality_close_location is not None
                and market_orb_quality_secondary_close_location is not None
                and market_orb_quality_close_location <= primary_weak_below
                and (
                    primary_weak_above is None
                    or market_orb_quality_close_location >= primary_weak_above
                )
                and market_orb_quality_secondary_close_location >= secondary_strong_above
            )
            if (
                primary_weak_secondary_strong_scale is not None
                and primary_weak_secondary_strong_condition_met
            ):
                market_orb_quality_scaler *= max(0.0, primary_weak_secondary_strong_scale)
                market_orb_quality_primary_weak_secondary_strong_active = True
            primary_weak_secondary_strong_max_trades = getattr(
                params, "market_orb_quality_primary_weak_secondary_strong_max_trades", None
            )
            if (
                primary_weak_secondary_strong_max_trades is not None
                and primary_weak_secondary_strong_condition_met
            ):
                primary_weak_secondary_strong_max_trades = max(
                    0, int(primary_weak_secondary_strong_max_trades)
                )
                if market_orb_quality_divergence_max_trades is None:
                    market_orb_quality_divergence_max_trades = primary_weak_secondary_strong_max_trades
                else:
                    market_orb_quality_divergence_max_trades = min(
                        market_orb_quality_divergence_max_trades,
                        primary_weak_secondary_strong_max_trades,
                    )
                market_orb_quality_primary_weak_secondary_strong_active = True
                market_orb_quality_primary_weak_secondary_strong_max_trades_active = True
            primary_lag_secondary_lead_condition_met = (
                primary_lag_below is not None
                and secondary_lead_above is not None
                and market_orb_quality_close_location is not None
                and market_orb_quality_secondary_close_location is not None
                and market_orb_quality_close_location <= primary_lag_below
                and (
                    primary_lag_above is None
                    or market_orb_quality_close_location >= primary_lag_above
                )
                and market_orb_quality_secondary_close_location >= secondary_lead_above
                and (
                    secondary_lead_below is None
                    or market_orb_quality_secondary_close_location <= secondary_lead_below
                )
            )
            if (
                primary_lag_secondary_lead_scale is not None
                and primary_lag_secondary_lead_condition_met
            ):
                market_orb_quality_scaler *= max(0.0, primary_lag_secondary_lead_scale)
                market_orb_quality_primary_lag_secondary_lead_active = True
            primary_lag_secondary_lead_max_trades = getattr(
                params, "market_orb_quality_primary_lag_secondary_lead_max_trades", None
            )
            if (
                primary_lag_secondary_lead_max_trades is not None
                and primary_lag_secondary_lead_condition_met
            ):
                primary_lag_secondary_lead_max_trades = max(
                    0, int(primary_lag_secondary_lead_max_trades)
                )
                if market_orb_quality_divergence_max_trades is None:
                    market_orb_quality_divergence_max_trades = primary_lag_secondary_lead_max_trades
                else:
                    market_orb_quality_divergence_max_trades = min(
                        market_orb_quality_divergence_max_trades,
                        primary_lag_secondary_lead_max_trades,
                    )
                market_orb_quality_primary_lag_secondary_lead_active = True
                market_orb_quality_primary_lag_secondary_lead_max_trades_active = True
            market_orb_quality_joint_weak_condition_met = (
                joint_weak_primary_below is not None
                and joint_weak_secondary_below is not None
                and market_orb_quality_close_location is not None
                and market_orb_quality_secondary_close_location is not None
                and market_orb_quality_close_location <= joint_weak_primary_below
                and (
                    joint_weak_primary_above is None
                    or market_orb_quality_close_location >= joint_weak_primary_above
                )
                and market_orb_quality_secondary_close_location <= joint_weak_secondary_below
                and (
                    joint_weak_secondary_above is None
                    or market_orb_quality_secondary_close_location >= joint_weak_secondary_above
                )
            )
            if market_orb_quality_joint_weak_condition_met:
                joint_weak_scale = getattr(params, "market_orb_quality_joint_weak_scale", None)
                if joint_weak_scale is not None:
                    market_orb_quality_scaler *= max(0.0, joint_weak_scale)
                    market_orb_quality_joint_weak_active = True
                joint_weak_max_trades = getattr(
                    params, "market_orb_quality_joint_weak_max_trades", None
                )
                if joint_weak_max_trades is not None:
                    joint_weak_max_trades = max(0, int(joint_weak_max_trades))
                    if market_orb_quality_divergence_max_trades is None:
                        market_orb_quality_divergence_max_trades = joint_weak_max_trades
                    else:
                        market_orb_quality_divergence_max_trades = min(
                            market_orb_quality_divergence_max_trades,
                            joint_weak_max_trades,
                        )
                    market_orb_quality_joint_weak_active = True
                    market_orb_quality_joint_weak_max_trades_active = True
                if getattr(params, "market_orb_quality_joint_weak_require_confirmation", False):
                    market_orb_quality_joint_weak_require_confirmation = True
                    market_orb_quality_joint_weak_active = True
            market_orb_quality_joint_panic_condition_met = (
                joint_panic_primary_below is not None
                and joint_panic_secondary_below is not None
                and market_orb_quality_close_location is not None
                and market_orb_quality_secondary_close_location is not None
                and market_orb_quality_close_location <= joint_panic_primary_below
                and market_orb_quality_secondary_close_location <= joint_panic_secondary_below
            )
            if market_orb_quality_joint_panic_condition_met:
                joint_panic_scale = getattr(params, "market_orb_quality_joint_panic_scale", None)
                if joint_panic_scale is not None:
                    market_orb_quality_scaler *= max(0.0, joint_panic_scale)
                    market_orb_quality_joint_panic_active = True
                joint_panic_max_trades = getattr(
                    params, "market_orb_quality_joint_panic_max_trades", None
                )
                if joint_panic_max_trades is not None:
                    joint_panic_max_trades = max(0, int(joint_panic_max_trades))
                    if market_orb_quality_divergence_max_trades is None:
                        market_orb_quality_divergence_max_trades = joint_panic_max_trades
                    else:
                        market_orb_quality_divergence_max_trades = min(
                            market_orb_quality_divergence_max_trades,
                            joint_panic_max_trades,
                        )
                    market_orb_quality_joint_panic_active = True
                    market_orb_quality_joint_panic_max_trades_active = True
                if getattr(params, "market_orb_quality_joint_panic_require_confirmation", False):
                    market_orb_quality_joint_panic_require_confirmation = True
                    market_orb_quality_joint_panic_active = True

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

    if getattr(params, "market_thrust_opening_breadth_override_enabled", False):
        opening_breadth_stats = _opening_breadth_stats(
            bars_by_ticker,
            date_str,
            min_first_bar_dollar_vol=getattr(
                params,
                "market_thrust_opening_breadth_override_min_first_bar_dollar_vol",
                None,
            ),
            strong_close_location=float(
                getattr(
                    params,
                    "market_thrust_opening_breadth_override_strong_close_location",
                    0.65,
                )
                or 0.65
            ),
        )
        market_thrust_opening_breadth_total_count = int(
            opening_breadth_stats.get("total_count") or 0
        )
        market_thrust_opening_breadth_positive_ratio = _float_or_none(
            opening_breadth_stats.get("positive_ratio")
        )
        market_thrust_opening_breadth_avg_return_pct = _float_or_none(
            opening_breadth_stats.get("avg_return_pct")
        )
        market_thrust_opening_breadth_strong_close_location_ratio = _float_or_none(
            opening_breadth_stats.get("strong_close_location_ratio")
        )

    market_thrust_breadth_override_candidate = (
        bool(getattr(params, "market_thrust_breadth_override_enabled", False))
        and "breadth" in soft_day_reason_parts
        and "hard_breadth" not in soft_day_reason_parts
        and not (
            market_orb_quality_divergence_active
            or market_orb_quality_primary_weak_secondary_strong_active
            or market_orb_quality_primary_lag_secondary_lead_active
            or market_orb_quality_joint_weak_active
            or market_orb_quality_joint_panic_active
        )
        and _meets_min(
            market_orb_quality_close_location,
            getattr(params, "market_thrust_breadth_override_min_primary_close_location", None),
        )
        and _meets_min(
            market_orb_quality_secondary_close_location,
            getattr(params, "market_thrust_breadth_override_min_secondary_close_location", None),
        )
        and _meets_min(
            market_orb_quality_return_pct,
            getattr(params, "market_thrust_breadth_override_min_primary_return_pct", None),
        )
        and _meets_min(
            market_orb_quality_secondary_return_pct,
            getattr(params, "market_thrust_breadth_override_min_secondary_return_pct", None),
        )
        and _meets_min(
            regime_gap_pct,
            getattr(params, "market_thrust_breadth_override_min_regime_gap_pct", None),
        )
        and _meets_min(
            breadth_ratio,
            getattr(params, "market_thrust_breadth_override_min_breadth_ratio", None),
        )
    )
    opening_breadth_reason_allowed = bool(soft_day_reason_parts) and (
        (
            "market_regime" in soft_day_reason_parts
            and bool(
                getattr(
                    params,
                    "market_thrust_opening_breadth_override_allow_regime_soft_day",
                    True,
                )
            )
        )
        or (
            "breadth" in soft_day_reason_parts
            and bool(
                getattr(
                    params,
                    "market_thrust_opening_breadth_override_allow_breadth_soft_day",
                    True,
                )
            )
        )
        or (
            "hard_breadth" in soft_day_reason_parts
            and bool(
                getattr(
                    params,
                    "market_thrust_opening_breadth_override_allow_hard_breadth",
                    False,
                )
            )
        )
    )
    market_thrust_opening_breadth_override_candidate = (
        bool(getattr(params, "market_thrust_opening_breadth_override_enabled", False))
        and opening_breadth_reason_allowed
        and market_thrust_opening_breadth_total_count is not None
        and market_thrust_opening_breadth_total_count
        >= int(
            getattr(
                params,
                "market_thrust_opening_breadth_override_min_total_count",
                100,
            )
            or 0
        )
        and _meets_min(
            market_thrust_opening_breadth_positive_ratio,
            getattr(
                params,
                "market_thrust_opening_breadth_override_min_positive_ratio",
                None,
            ),
        )
        and _meets_min(
            market_thrust_opening_breadth_avg_return_pct,
            getattr(
                params,
                "market_thrust_opening_breadth_override_min_avg_return_pct",
                None,
            ),
        )
        and _meets_min(
            market_thrust_opening_breadth_strong_close_location_ratio,
            getattr(
                params,
                "market_thrust_opening_breadth_override_min_strong_close_location_ratio",
                None,
            ),
        )
    )
    if market_thrust_breadth_override_candidate:
        breadth_scaler = max(
            breadth_scaler,
            max(
                0.0,
                float(
                    getattr(
                        params,
                        "market_thrust_breadth_override_size_scale_floor",
                        1.0,
                    )
                    or 0.0
                ),
            ),
        )
        market_thrust_index_breadth_override_active = True
        market_thrust_breadth_override_active = True
        if (
            bool(getattr(params, "market_thrust_breadth_override_clear_soft_day", False))
            and breadth_scaler >= params.soft_day_scaler_threshold
        ):
            soft_day_reason_parts = [
                reason for reason in soft_day_reason_parts if reason != "breadth"
            ]
    if market_thrust_opening_breadth_override_candidate:
        regime_scaler = max(
            regime_scaler,
            max(
                0.0,
                float(
                    getattr(
                        params,
                        "market_thrust_opening_breadth_override_regime_size_scale_floor",
                        1.0,
                    )
                    or 0.0
                ),
            ),
        )
        breadth_scaler = max(
            breadth_scaler,
            max(
                0.0,
                float(
                    getattr(
                        params,
                        "market_thrust_opening_breadth_override_breadth_size_scale_floor",
                        1.0,
                    )
                    or 0.0
                ),
            ),
        )
        market_thrust_breadth_override_active = True
        market_thrust_opening_breadth_override_active = True
        if getattr(
            params,
            "market_thrust_opening_breadth_override_clear_soft_day",
            False,
        ):
            soft_day_reason_parts = [
                reason
                for reason in soft_day_reason_parts
                if not (
                    (
                        reason == "market_regime"
                        and getattr(
                            params,
                            "market_thrust_opening_breadth_override_allow_regime_soft_day",
                            True,
                        )
                    )
                    or (
                        reason == "breadth"
                        and getattr(
                            params,
                            "market_thrust_opening_breadth_override_allow_breadth_soft_day",
                            True,
                        )
                    )
                    or (
                        reason == "hard_breadth"
                        and getattr(
                            params,
                            "market_thrust_opening_breadth_override_allow_hard_breadth",
                            False,
                        )
                    )
                )
            ]

    combined_scaler = regime_scaler * breadth_scaler
    if (
        soft_day_reason_parts
        and params.soft_day_combined_size_scale_floor is not None
    ):
        combined_scaler = max(
            combined_scaler,
            max(0.0, float(params.soft_day_combined_size_scale_floor)),
        )
    is_soft_day = bool(soft_day_reason_parts) or combined_scaler < params.soft_day_scaler_threshold
    result.regime_scaler = regime_scaler
    result.regime_gap_pct = regime_gap_pct
    result.breadth_scaler = breadth_scaler
    result.breadth_ratio = breadth_ratio
    result.breadth_positive_count = breadth_positive_count
    result.breadth_total_count = breadth_total_count
    result.sparse_day_scaler = 1.0
    result.market_orb_quality_scaler = market_orb_quality_scaler
    result.market_orb_quality_close_location = market_orb_quality_close_location
    result.market_orb_quality_return_pct = market_orb_quality_return_pct
    result.market_orb_quality_secondary_close_location = market_orb_quality_secondary_close_location
    result.market_orb_quality_secondary_return_pct = market_orb_quality_secondary_return_pct
    result.market_thrust_breadth_override_active = market_thrust_breadth_override_active
    result.market_thrust_opening_breadth_override_active = (
        market_thrust_opening_breadth_override_active
    )
    result.market_thrust_opening_breadth_positive_ratio = (
        market_thrust_opening_breadth_positive_ratio
    )
    result.market_thrust_opening_breadth_avg_return_pct = (
        market_thrust_opening_breadth_avg_return_pct
    )
    result.market_thrust_opening_breadth_strong_close_location_ratio = (
        market_thrust_opening_breadth_strong_close_location_ratio
    )
    result.market_thrust_opening_breadth_total_count = (
        market_thrust_opening_breadth_total_count
    )
    result.market_orb_quality_divergence_active = market_orb_quality_divergence_active
    result.market_orb_quality_divergence_max_trades_active = (
        market_orb_quality_divergence_max_trades_active
    )
    result.market_orb_quality_primary_weak_secondary_strong_active = (
        market_orb_quality_primary_weak_secondary_strong_active
    )
    result.market_orb_quality_primary_weak_secondary_strong_max_trades_active = (
        market_orb_quality_primary_weak_secondary_strong_max_trades_active
    )
    result.market_orb_quality_primary_lag_secondary_lead_active = (
        market_orb_quality_primary_lag_secondary_lead_active
    )
    result.market_orb_quality_primary_lag_secondary_lead_max_trades_active = (
        market_orb_quality_primary_lag_secondary_lead_max_trades_active
    )
    result.market_orb_quality_joint_weak_active = market_orb_quality_joint_weak_active
    result.market_orb_quality_joint_weak_max_trades_active = (
        market_orb_quality_joint_weak_max_trades_active
    )
    result.market_orb_quality_joint_panic_active = market_orb_quality_joint_panic_active
    result.market_orb_quality_joint_panic_max_trades_active = (
        market_orb_quality_joint_panic_max_trades_active
    )
    result.is_soft_day = is_soft_day
    result.soft_day_reason = "+".join(soft_day_reason_parts) if soft_day_reason_parts else None

    trade_params = params
    conditional_confirmation_active = False
    conditional_confirmation_below = getattr(
        params, "conditional_confirmation_below_return_pct", None
    )
    if conditional_confirmation_below is not None and not params.require_confirmation_bar:
        confirmation_ticker = (
            getattr(params, "conditional_confirmation_ticker", None)
            or getattr(params, "market_regime_ticker", None)
            or getattr(params, "market_orb_quality_ticker", None)
            or "QQQ"
        )
        confirmation_bar = _first_regular_bar(
            bars_by_ticker.get(confirmation_ticker, []),
            _market_open_ts(date_str),
        )
        confirmation_return = _bar_return_pct(confirmation_bar)
        if (
            confirmation_return is not None
            and confirmation_return <= conditional_confirmation_below
        ):
            trade_params = params.model_copy(update={"require_confirmation_bar": True})
            conditional_confirmation_active = True
    if (
        market_orb_quality_joint_weak_condition_met
        and market_orb_quality_joint_weak_require_confirmation
        and not trade_params.require_confirmation_bar
    ):
        trade_params = trade_params.model_copy(update={"require_confirmation_bar": True})
        conditional_confirmation_active = True
    if (
        market_orb_quality_joint_panic_condition_met
        and market_orb_quality_joint_panic_require_confirmation
        and not trade_params.require_confirmation_bar
    ):
        trade_params = trade_params.model_copy(update={"require_confirmation_bar": True})
        conditional_confirmation_active = True
    result.conditional_confirmation_active = conditional_confirmation_active

    broad_gapup_fallback_mode = bool(
        getattr(params, "broad_gapup_continuation_enabled", False)
    )
    market_thrust_liquid_mode = bool(
        getattr(params, "market_thrust_liquid_continuation_enabled", False)
    )
    market_thrust_opening_impulse_mode = bool(
        getattr(params, "market_thrust_opening_impulse_reclaim_enabled", False)
    )
    intraday_continuation_reclaim_mode = bool(
        getattr(params, "intraday_continuation_reclaim_enabled", False)
    )
    base_candidate_updates: dict[str, object] = {}
    if broad_gapup_fallback_mode:
        base_candidate_updates["broad_gapup_continuation_enabled"] = False
    if market_thrust_liquid_mode:
        base_candidate_updates["market_thrust_liquid_continuation_enabled"] = False
    if market_thrust_opening_impulse_mode:
        base_candidate_updates["market_thrust_opening_impulse_reclaim_enabled"] = False
    if intraday_continuation_reclaim_mode:
        base_candidate_updates["intraday_continuation_reclaim_enabled"] = False
    candidate_params = (
        params.model_copy(update=base_candidate_updates)
        if base_candidate_updates
        else params
    )

    _cand_stats: dict = {}
    candidates = compute_orb_candidates(
        bars_by_ticker,
        date_str,
        candidate_params,
        enrichment,
        blacklisted_tickers=blacklisted_tickers,
        spy_bars=None,  # handled above via enrichment
        ticker_sectors=ticker_sectors,
        _stats_out=_cand_stats,
        _suppress_empty_log=(
            broad_gapup_fallback_mode
            or intraday_continuation_reclaim_mode
        ),
        overlay_tickers=overlay_tickers,
    )
    broad_gapup_candidates: list[dict] = []
    if broad_gapup_fallback_mode:
        broad_scan_params = params.model_copy(
            update={
                "max_candidates": max(
                    int(getattr(params, "max_candidates", 0) or 0),
                    len(bars_by_ticker),
                ),
                "max_candidates_per_sector": None,
                "market_thrust_liquid_continuation_enabled": False,
                "market_thrust_opening_impulse_reclaim_enabled": False,
                "intraday_continuation_reclaim_enabled": False,
            }
        )
        broad_gapup_all_candidates = compute_orb_candidates(
            bars_by_ticker,
            date_str,
            broad_scan_params,
            enrichment,
            blacklisted_tickers=blacklisted_tickers,
            spy_bars=None,
            ticker_sectors=ticker_sectors,
            overlay_tickers=overlay_tickers,
        )
        broad_gapup_candidates = [
            cand for cand in broad_gapup_all_candidates
            if cand.get("broad_gapup_continuation")
        ]
    market_thrust_liquid_candidates: list[dict] = []
    opening_breadth_only_thrust = (
        market_thrust_opening_breadth_override_active
        and not market_thrust_index_breadth_override_active
    )
    liquid_allowed_on_opening_breadth = bool(
        getattr(
            params,
            "market_thrust_opening_breadth_override_activate_liquid_continuation",
            True,
        )
    )
    liquid_requires_market_thrust = bool(
        getattr(params, "market_thrust_liquid_continuation_require_market_thrust", True)
    )
    market_thrust_liquid_scan_active = (
        market_thrust_liquid_mode
        and (market_thrust_breadth_override_active or not liquid_requires_market_thrust)
        and (
            not opening_breadth_only_thrust
            or liquid_allowed_on_opening_breadth
        )
    )
    if market_thrust_liquid_scan_active:
        no_thrust_liquid_repair_scan = (
            not market_thrust_breadth_override_active
            and not liquid_requires_market_thrust
        )
        liquid_scan_updates = {
            "max_candidates": max(
                int(getattr(params, "max_candidates", 0) or 0),
                len(bars_by_ticker),
            ),
            "max_candidates_per_sector": None,
            "broad_gapup_continuation_enabled": False,
            "market_thrust_opening_impulse_reclaim_enabled": False,
            "intraday_continuation_reclaim_enabled": False,
        }
        if no_thrust_liquid_repair_scan:
            no_thrust_override_map = {
                "market_thrust_liquid_continuation_min_gap_pct": (
                    "market_thrust_liquid_continuation_no_thrust_min_gap_pct"
                ),
                "market_thrust_liquid_continuation_max_gap_pct": (
                    "market_thrust_liquid_continuation_no_thrust_max_gap_pct"
                ),
                "market_thrust_liquid_continuation_min_first_bar_return_pct": (
                    "market_thrust_liquid_continuation_no_thrust_min_first_bar_return_pct"
                ),
                "market_thrust_liquid_continuation_min_first_bar_dollar_vol": (
                    "market_thrust_liquid_continuation_no_thrust_min_first_bar_dollar_vol"
                ),
                "market_thrust_liquid_continuation_min_avg_dollar_vol": (
                    "market_thrust_liquid_continuation_no_thrust_min_avg_dollar_vol"
                ),
            }
            for base_field, override_field in no_thrust_override_map.items():
                override_value = getattr(params, override_field, None)
                if override_value is not None:
                    liquid_scan_updates[base_field] = override_value
        market_thrust_scan_params = params.model_copy(
            update=liquid_scan_updates
        )
        market_thrust_all_candidates = compute_orb_candidates(
            bars_by_ticker,
            date_str,
            market_thrust_scan_params,
            enrichment,
            blacklisted_tickers=blacklisted_tickers,
            spy_bars=None,
            ticker_sectors=ticker_sectors,
            overlay_tickers=overlay_tickers,
        )
        used_tickers = {
            cand["ticker"] for cand in candidates + broad_gapup_candidates
        }
        market_thrust_liquid_candidates = [
            cand for cand in market_thrust_all_candidates
            if cand.get("market_thrust_liquid_continuation")
            and cand["ticker"] not in used_tickers
        ]
        rank_mode = None
        priority_min_first_return = None
        if no_thrust_liquid_repair_scan:
            rank_mode = getattr(
                params,
                "market_thrust_liquid_continuation_no_thrust_rank_mode",
                None,
            )
            priority_min_first_return = getattr(
                params,
                (
                    "market_thrust_liquid_continuation_no_thrust_"
                    "priority_min_first_bar_return_pct"
                ),
                None,
            )
        if priority_min_first_return is not None:
            priority_floor = float(priority_min_first_return)
            market_thrust_liquid_candidates.sort(
                key=lambda cand: (
                    float(cand.get("orb_return") or 0.0) >= priority_floor,
                    float(cand.get("score") or 0.0),
                ),
                reverse=True,
            )
        elif rank_mode:
            market_thrust_liquid_candidates.sort(
                key=lambda cand: _orb_market_thrust_liquid_rank_key(cand, rank_mode),
                reverse=True,
            )
        max_market_thrust_candidates = getattr(
            params,
            "market_thrust_liquid_continuation_max_candidates",
            None,
        )
        if no_thrust_liquid_repair_scan:
            no_thrust_max_market_thrust_candidates = getattr(
                params,
                "market_thrust_liquid_continuation_no_thrust_max_candidates",
                None,
            )
            if no_thrust_max_market_thrust_candidates is not None:
                max_market_thrust_candidates = no_thrust_max_market_thrust_candidates
        if max_market_thrust_candidates is not None:
            market_thrust_liquid_candidates = market_thrust_liquid_candidates[
                : max(0, int(max_market_thrust_candidates))
            ]
    market_thrust_opening_impulse_candidates: list[dict] = []
    impulse_requires_market_thrust = bool(
        getattr(
            params,
            "market_thrust_opening_impulse_reclaim_require_market_thrust",
            True,
        )
    )
    market_thrust_impulse_scan_active = (
        market_thrust_opening_impulse_mode
        and (market_thrust_breadth_override_active or not impulse_requires_market_thrust)
    )
    if market_thrust_impulse_scan_active:
        no_thrust_impulse_repair_scan = (
            not market_thrust_breadth_override_active
            and not impulse_requires_market_thrust
        )
        impulse_scan_updates = {
            "max_candidates": max(
                int(getattr(params, "max_candidates", 0) or 0),
                len(bars_by_ticker),
            ),
            "max_candidates_per_sector": None,
            "broad_gapup_continuation_enabled": False,
            "market_thrust_liquid_continuation_enabled": False,
            "intraday_continuation_reclaim_enabled": False,
        }
        if no_thrust_impulse_repair_scan:
            no_thrust_impulse_override_map = {
                "market_thrust_opening_impulse_reclaim_min_gap_pct": (
                    "market_thrust_opening_impulse_reclaim_no_thrust_min_gap_pct"
                ),
                "market_thrust_opening_impulse_reclaim_max_gap_pct": (
                    "market_thrust_opening_impulse_reclaim_no_thrust_max_gap_pct"
                ),
                "market_thrust_opening_impulse_reclaim_min_first_bar_return_pct": (
                    "market_thrust_opening_impulse_reclaim_no_thrust_min_first_bar_return_pct"
                ),
                "market_thrust_opening_impulse_reclaim_min_first_bar_dollar_vol": (
                    "market_thrust_opening_impulse_reclaim_no_thrust_min_first_bar_dollar_vol"
                ),
                "market_thrust_opening_impulse_reclaim_min_avg_dollar_vol": (
                    "market_thrust_opening_impulse_reclaim_no_thrust_min_avg_dollar_vol"
                ),
                "market_thrust_opening_impulse_reclaim_min_body_ratio": (
                    "market_thrust_opening_impulse_reclaim_no_thrust_min_body_ratio"
                ),
                "market_thrust_opening_impulse_reclaim_min_close_location": (
                    "market_thrust_opening_impulse_reclaim_no_thrust_min_close_location"
                ),
                "market_thrust_opening_impulse_reclaim_min_ret_5d": (
                    "market_thrust_opening_impulse_reclaim_no_thrust_min_ret_5d"
                ),
                "market_thrust_opening_impulse_reclaim_max_ret_5d": (
                    "market_thrust_opening_impulse_reclaim_no_thrust_max_ret_5d"
                ),
            }
            for base_field, override_field in no_thrust_impulse_override_map.items():
                override_value = getattr(params, override_field, None)
                if override_value is not None:
                    impulse_scan_updates[base_field] = override_value
        market_thrust_impulse_scan_params = params.model_copy(
            update=impulse_scan_updates
        )
        market_thrust_impulse_all_candidates = compute_orb_candidates(
            bars_by_ticker,
            date_str,
            market_thrust_impulse_scan_params,
            enrichment,
            blacklisted_tickers=blacklisted_tickers,
            spy_bars=None,
            ticker_sectors=ticker_sectors,
            overlay_tickers=overlay_tickers,
        )
        used_tickers = {
            cand["ticker"]
            for cand in (
                candidates
                + broad_gapup_candidates
                + market_thrust_liquid_candidates
            )
        }
        market_thrust_opening_impulse_candidates = [
            cand for cand in market_thrust_impulse_all_candidates
            if cand.get("market_thrust_opening_impulse_reclaim")
            and cand["ticker"] not in used_tickers
        ]
        max_market_thrust_impulse_candidates = getattr(
            params,
            "market_thrust_opening_impulse_reclaim_max_candidates",
            None,
        )
        if no_thrust_impulse_repair_scan:
            no_thrust_max_impulse_candidates = getattr(
                params,
                "market_thrust_opening_impulse_reclaim_no_thrust_max_candidates",
                None,
            )
            if no_thrust_max_impulse_candidates is not None:
                max_market_thrust_impulse_candidates = no_thrust_max_impulse_candidates
        if max_market_thrust_impulse_candidates is not None:
            market_thrust_opening_impulse_candidates = (
                market_thrust_opening_impulse_candidates[
                    : max(0, int(max_market_thrust_impulse_candidates))
                ]
            )
    intraday_continuation_candidates: list[dict] = []
    intraday_cluster_count = 0
    intraday_cluster_avg_signal_return = 0.0
    intraday_cluster_total_signal_dollar_vol = 0.0
    if intraday_continuation_reclaim_mode:
        intraday_scan_params = params.model_copy(
            update={
                "max_candidates": max(
                    int(getattr(params, "max_candidates", 0) or 0),
                    len(bars_by_ticker),
                ),
                "max_candidates_per_sector": None,
                "broad_gapup_continuation_enabled": False,
                "market_thrust_liquid_continuation_enabled": False,
                "market_thrust_opening_impulse_reclaim_enabled": False,
            }
        )
        intraday_all_candidates = compute_orb_candidates(
            bars_by_ticker,
            date_str,
            intraday_scan_params,
            enrichment,
            blacklisted_tickers=blacklisted_tickers,
            spy_bars=None,
            ticker_sectors=ticker_sectors,
            overlay_tickers=overlay_tickers,
            _suppress_empty_log=True,
        )
        used_tickers = {
            cand["ticker"]
            for cand in (
                candidates
                + broad_gapup_candidates
                + market_thrust_liquid_candidates
                + market_thrust_opening_impulse_candidates
            )
        }
        intraday_continuation_candidates = [
            cand for cand in intraday_all_candidates
            if cand.get("intraday_continuation_reclaim")
            and cand["ticker"] not in used_tickers
        ]
        intraday_continuation_candidates.sort(
            key=lambda cand: (
                float(cand.get("intraday_continuation_signal_return") or 0.0),
                float(cand.get("intraday_continuation_signal_dollar_vol") or 0.0),
            ),
            reverse=True,
        )
        intraday_cluster_count = len(intraday_continuation_candidates)
        intraday_cluster_avg_signal_return = (
            sum(
                float(cand.get("intraday_continuation_signal_return") or 0.0)
                for cand in intraday_continuation_candidates
            )
            / intraday_cluster_count
            if intraday_cluster_count > 0
            else 0.0
        )
        intraday_cluster_total_signal_dollar_vol = sum(
            float(cand.get("intraday_continuation_signal_dollar_vol") or 0.0)
            for cand in intraday_continuation_candidates
        )
        min_intraday_cluster_count = getattr(
            params,
            "intraday_continuation_reclaim_min_cluster_count",
            None,
        )
        min_intraday_cluster_avg_signal_return = getattr(
            params,
            "intraday_continuation_reclaim_min_cluster_avg_signal_return_pct",
            None,
        )
        min_intraday_cluster_total_signal_dollar_vol = getattr(
            params,
            "intraday_continuation_reclaim_min_cluster_total_signal_dollar_vol",
            None,
        )
        if (
            (
                min_intraday_cluster_count is not None
                and intraday_cluster_count < max(0, int(min_intraday_cluster_count))
            )
            or (
                min_intraday_cluster_avg_signal_return is not None
                and intraday_cluster_avg_signal_return
                < float(min_intraday_cluster_avg_signal_return)
            )
            or (
                min_intraday_cluster_total_signal_dollar_vol is not None
                and intraday_cluster_total_signal_dollar_vol
                < float(min_intraday_cluster_total_signal_dollar_vol)
            )
        ):
            intraday_continuation_candidates = []
        max_intraday_candidates = getattr(
            params,
            "intraday_continuation_reclaim_max_candidates",
            None,
        )
        if max_intraday_candidates is not None:
            intraday_continuation_candidates = intraday_continuation_candidates[
                : max(0, int(max_intraday_candidates))
            ]
    result.candidates_found = (
        len(candidates)
        + len(broad_gapup_candidates)
        + len(market_thrust_liquid_candidates)
        + len(market_thrust_opening_impulse_candidates)
        + len(intraday_continuation_candidates)
    )
    result.candidate_filter_stats = _cand_stats if _cand_stats else None

    if (
        len(candidates) < params.min_candidates_to_trade
        and not broad_gapup_candidates
        and not market_thrust_liquid_candidates
        and not market_thrust_opening_impulse_candidates
        and not intraday_continuation_candidates
    ):
        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)
    primary_timed_candidates: list[tuple[dt.datetime, dict, str, str, float | None]] = []
    nofill_vwap_candidates: list[tuple[dt.datetime, dict, str, str, float | None]] = []
    late_breakout_candidates: list[tuple[dt.datetime, dict, str, str, float | None]] = []
    soft_day_vwap_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]):
            trigger_type = (
                "broad_gapup_continuation"
                if cand.get("broad_gapup_continuation")
                else "orb"
            )
            primary_timed_candidates.append((breakout_ts, cand, direction_str, trigger_type, None))
        elif momo_result is not None:
            primary_timed_candidates.append((momo_result[0], cand, direction_str, "momentum_confirm", momo_result[1]))
        elif getattr(params, "nofill_vwap_reclaim_enabled", False):
            vwap_result = _find_vwap_reclaim_time(
                cand["mkt_bars"], cand["orb_bar"], direction_str, params, date_str
            )
            if vwap_result is not None:
                nofill_vwap_candidates.append(
                    (vwap_result[0], cand, direction_str, "vwap_reclaim", vwap_result[1])
                )
        if breakout_ts is None and getattr(params, "late_breakout_enabled", False):
            late_breakout_result = _find_late_breakout_time(
                cand["mkt_bars"], cand["orb_bar"], direction_str, params, date_str
            )
            if late_breakout_result is not None:
                late_breakout_candidates.append(
                    (
                        late_breakout_result[0],
                        cand,
                        direction_str,
                        "late_breakout",
                        late_breakout_result[1],
                    )
                )
        if (
            is_soft_day
            and getattr(params, "soft_day_vwap_reclaim_enabled", False)
            and _orb_soft_day_vwap_reason_allowed(params, result.soft_day_reason)
        ):
            soft_vwap_result = _find_vwap_reclaim_time(
                cand["mkt_bars"], cand["orb_bar"], direction_str, params, date_str
            )
            if soft_vwap_result is not None:
                soft_day_vwap_candidates.append(
                    (
                        soft_vwap_result[0],
                        cand,
                        direction_str,
                        "soft_day_vwap_reclaim",
                        soft_vwap_result[1],
                    )
                )

    broad_gapup_entry_mode = str(
        getattr(params, "broad_gapup_continuation_entry_mode", "breakout")
        or "breakout"
    ).lower()
    for cand in broad_gapup_candidates:
        direction_str = (
            "long" if cand["direction"] == "bullish"
            else "short" if cand["direction"] == "bearish"
            else cand["direction"]
        )
        if broad_gapup_entry_mode == "opening_burst":
            orb_ts = _parse_ts(cand["orb_bar"]["timestamp"])
            entry_bar = next(
                (bar for bar in cand["mkt_bars"] if _parse_ts(bar["timestamp"]) > orb_ts),
                None,
            )
            if entry_bar is not None:
                primary_timed_candidates.append(
                    (
                        _parse_ts(entry_bar["timestamp"]),
                        cand,
                        direction_str,
                        "broad_gapup_continuation",
                        float(entry_bar["open"]),
                    )
                )
            continue
        if broad_gapup_entry_mode == "late_breakout":
            late_breakout_result = _find_late_breakout_time(
                cand["mkt_bars"], cand["orb_bar"], direction_str, params, date_str
            )
            if late_breakout_result is not None:
                primary_timed_candidates.append(
                    (
                        late_breakout_result[0],
                        cand,
                        direction_str,
                        "broad_gapup_continuation",
                        late_breakout_result[1],
                    )
                )
            continue
        if broad_gapup_entry_mode == "vwap_reclaim":
            vwap_result = _find_vwap_reclaim_time(
                cand["mkt_bars"], cand["orb_bar"], direction_str, params, date_str
            )
            if vwap_result is not None:
                primary_timed_candidates.append(
                    (
                        vwap_result[0],
                        cand,
                        direction_str,
                        "broad_gapup_continuation",
                        vwap_result[1],
                    )
                )
            continue
        breakout_ts = _find_breakout_time(
            cand["mkt_bars"], cand["orb_bar"], direction_str, params, date_str
        )
        if breakout_ts is not None:
            primary_timed_candidates.append(
                (breakout_ts, cand, direction_str, "broad_gapup_continuation", None)
            )

    market_thrust_impulse_entry_mode = str(
        getattr(
            params,
            "market_thrust_opening_impulse_reclaim_entry_mode",
            "vwap_reclaim",
        )
        or "vwap_reclaim"
    ).lower()
    for cand in market_thrust_opening_impulse_candidates:
        direction_str = (
            "long" if cand["direction"] == "bullish"
            else "short" if cand["direction"] == "bearish"
            else cand["direction"]
        )
        if market_thrust_impulse_entry_mode == "opening_burst":
            orb_ts = _parse_ts(cand["orb_bar"]["timestamp"])
            entry_bar = next(
                (bar for bar in cand["mkt_bars"] if _parse_ts(bar["timestamp"]) > orb_ts),
                None,
            )
            if entry_bar is not None:
                primary_timed_candidates.append(
                    (
                        _parse_ts(entry_bar["timestamp"]),
                        cand,
                        direction_str,
                        "market_thrust_opening_impulse_reclaim",
                        float(entry_bar["open"]),
                    )
                )
            continue
        if market_thrust_impulse_entry_mode == "late_breakout":
            late_breakout_result = _find_late_breakout_time(
                cand["mkt_bars"], cand["orb_bar"], direction_str, params, date_str
            )
            if late_breakout_result is not None:
                primary_timed_candidates.append(
                    (
                        late_breakout_result[0],
                        cand,
                        direction_str,
                        "market_thrust_opening_impulse_reclaim",
                        late_breakout_result[1],
                    )
                )
            continue
        vwap_result = _find_vwap_reclaim_time(
            cand["mkt_bars"], cand["orb_bar"], direction_str, params, date_str
        )
        if vwap_result is not None:
            primary_timed_candidates.append(
                (
                    vwap_result[0],
                    cand,
                    direction_str,
                    "market_thrust_opening_impulse_reclaim",
                    vwap_result[1],
                )
            )

    market_thrust_liquid_entry_mode = str(
        getattr(params, "market_thrust_liquid_continuation_entry_mode", "breakout")
        or "breakout"
    ).lower()
    for cand in market_thrust_liquid_candidates:
        direction_str = (
            "long" if cand["direction"] == "bullish"
            else "short" if cand["direction"] == "bearish"
            else cand["direction"]
        )
        if market_thrust_liquid_entry_mode == "opening_burst":
            orb_ts = _parse_ts(cand["orb_bar"]["timestamp"])
            entry_bar = next(
                (bar for bar in cand["mkt_bars"] if _parse_ts(bar["timestamp"]) > orb_ts),
                None,
            )
            if entry_bar is not None:
                primary_timed_candidates.append(
                    (
                        _parse_ts(entry_bar["timestamp"]),
                        cand,
                        direction_str,
                        "market_thrust_opening_burst",
                        float(entry_bar["open"]),
                    )
                )
            continue
        if market_thrust_liquid_entry_mode in {
            "opening_followthrough",
            "followthrough_burst",
        }:
            followthrough_result = _find_opening_followthrough_time(
                cand["mkt_bars"],
                cand["orb_bar"],
                direction_str,
                params,
            )
            if followthrough_result is not None:
                primary_timed_candidates.append(
                    (
                        followthrough_result[0],
                        cand,
                        direction_str,
                        "market_thrust_opening_followthrough",
                        followthrough_result[1],
                    )
                )
            continue
        breakout_ts = _find_breakout_time(
            cand["mkt_bars"], cand["orb_bar"], direction_str, params, date_str
        )
        if breakout_ts is not None:
            primary_timed_candidates.append(
                (
                    breakout_ts,
                    cand,
                    direction_str,
                    "market_thrust_liquid_continuation",
                    None,
                )
            )

    for cand in intraday_continuation_candidates:
        direction_str = (
            "long" if cand["direction"] == "bullish"
            else "short" if cand["direction"] == "bearish"
            else cand["direction"]
        )
        entry_ts = cand.get("intraday_continuation_entry_ts")
        entry_price = cand.get("intraday_continuation_entry_price")
        if isinstance(entry_ts, dt.datetime) and entry_price is not None:
            primary_timed_candidates.append(
                (
                    entry_ts,
                    cand,
                    direction_str,
                    "intraday_continuation_reclaim",
                    float(entry_price),
                )
            )

    if (
        getattr(params, "broad_gapup_continuation_only_when_no_primary_entries", False)
        and primary_timed_candidates
    ):
        non_broad_primary_timed_candidates = [
            item for item in primary_timed_candidates
            if item[3] != "broad_gapup_continuation"
        ]
        if non_broad_primary_timed_candidates:
            primary_timed_candidates = non_broad_primary_timed_candidates

    market_thrust_liquid_trigger_types = {
        "market_thrust_liquid_continuation",
        "market_thrust_opening_burst",
        "market_thrust_opening_followthrough",
    }
    no_thrust_liquid_priority_min_first_return = (
        getattr(
            params,
            (
                "market_thrust_liquid_continuation_no_thrust_"
                "priority_min_first_bar_return_pct"
            ),
            None,
        )
        if not market_thrust_breadth_override_active
        and not liquid_requires_market_thrust
        else None
    )
    market_thrust_impulse_trigger_types = {
        "market_thrust_opening_impulse_reclaim",
    }
    if (
        getattr(
            params,
            "market_thrust_liquid_continuation_only_when_no_primary_entries",
            False,
        )
        and not market_thrust_breadth_override_active
        and primary_timed_candidates
    ):
        non_liquid_primary_timed_candidates = [
            item
            for item in primary_timed_candidates
            if item[3] not in market_thrust_liquid_trigger_types
        ]
        if non_liquid_primary_timed_candidates:
            primary_timed_candidates = non_liquid_primary_timed_candidates
    if (
        getattr(
            params,
            "market_thrust_opening_impulse_reclaim_only_when_no_primary_entries",
            False,
        )
        and not market_thrust_breadth_override_active
        and primary_timed_candidates
    ):
        repair_trigger_types = (
            market_thrust_liquid_trigger_types | market_thrust_impulse_trigger_types
        )
        non_repair_primary_timed_candidates = [
            item
            for item in primary_timed_candidates
            if item[3] not in repair_trigger_types
        ]
        if non_repair_primary_timed_candidates:
            primary_timed_candidates = non_repair_primary_timed_candidates

    timed_candidates = list(primary_timed_candidates)
    if nofill_vwap_candidates:
        allow_nofill_vwap = (
            not getattr(params, "nofill_vwap_reclaim_only_when_no_primary_entries", True)
            or len(primary_timed_candidates) == 0
        )
        if allow_nofill_vwap:
            timed_candidates.extend(nofill_vwap_candidates)
    if late_breakout_candidates:
        allow_late_breakout = (
            not getattr(params, "late_breakout_only_when_no_primary_entries", True)
            or len(primary_timed_candidates) == 0
        )
        if allow_late_breakout:
            timed_candidates.extend(late_breakout_candidates)
    if soft_day_vwap_candidates:
        allow_soft_day_vwap = (
            not getattr(
                params,
                "soft_day_vwap_reclaim_only_when_no_primary_entries",
                False,
            )
            or len(primary_timed_candidates) == 0
        )
        if allow_soft_day_vwap:
            timed_candidates.extend(soft_day_vwap_candidates)

    reclaim_entry_attention_by_key = _compute_reclaim_entry_attention_by_key(
        timed_candidates,
        params,
    )

    result.entry_diagnostics = {
        "primary_timed_candidates": len(primary_timed_candidates),
        "nofill_vwap_candidates": len(nofill_vwap_candidates),
        "late_breakout_candidates": len(late_breakout_candidates),
        "soft_day_vwap_candidates": len(soft_day_vwap_candidates),
        "broad_gapup_continuation_candidates": len(
            [
                item for item in primary_timed_candidates
                if item[3] == "broad_gapup_continuation"
            ]
        ),
        "market_thrust_liquid_continuation_candidates": len(
            [
                item for item in primary_timed_candidates
                if item[3] == "market_thrust_liquid_continuation"
            ]
        ),
        "market_thrust_opening_burst_candidates": len(
            [
                item for item in primary_timed_candidates
                if item[3] == "market_thrust_opening_burst"
            ]
        ),
        "market_thrust_opening_followthrough_candidates": len(
            [
                item for item in primary_timed_candidates
                if item[3] == "market_thrust_opening_followthrough"
            ]
        ),
        "market_thrust_opening_impulse_reclaim_candidates": len(
            [
                item for item in primary_timed_candidates
                if item[3] == "market_thrust_opening_impulse_reclaim"
            ]
        ),
        "intraday_continuation_reclaim_candidates": len(
            [
                item for item in primary_timed_candidates
                if item[3] == "intraday_continuation_reclaim"
            ]
        ),
        "intraday_continuation_reclaim_cluster_count": intraday_cluster_count,
        "intraday_continuation_reclaim_cluster_avg_signal_return": (
            intraday_cluster_avg_signal_return
        ),
        "intraday_continuation_reclaim_cluster_total_signal_dollar_vol": (
            intraday_cluster_total_signal_dollar_vol
        ),
        "timed_candidates": len(timed_candidates),
    }

    # Sort by entry time ascending (earliest fills first). On defensive
    # soft-day sleeves, optionally allocate the single probe slot to the best
    # ranked setup before considering trigger time. When the auxiliary VWAP
    # sleeve is configured as a no-primary-trade fallback, primary entries must
    # get first right of refusal before VWAP probes are considered.
    soft_day_vwap_after_non_soft_path = (
        is_soft_day
        and (
            getattr(params, "soft_day_vwap_reclaim_only_when_no_primary_trades", False)
            or getattr(
                params,
                "soft_day_vwap_reclaim_only_when_no_existing_trades",
                False,
            )
        )
    )
    auxiliary_trigger_types = {
        "vwap_reclaim",
        "soft_day_vwap_reclaim",
        "late_breakout",
        "intraday_continuation_reclaim",
    }
    def _timed_rank_score(
        item: tuple[dt.datetime, dict, str, str, float | None],
    ) -> float:
        cand = item[1]
        if (
            item[3] in market_thrust_liquid_trigger_types
            and no_thrust_liquid_priority_min_first_return is not None
        ):
            priority_floor = float(no_thrust_liquid_priority_min_first_return)
            priority_bonus = (
                10_000.0
                if float(cand.get("orb_return") or 0.0) >= priority_floor
                else 0.0
            )
            return priority_bonus + float(cand.get("score") or 0.0)
        if item[3] == "intraday_continuation_reclaim":
            return float(cand.get("intraday_continuation_signal_return") or 0.0)
        return float(cand.get("score") or 0.0)

    late_breakout_after_primary_path = bool(
        getattr(params, "late_breakout_only_when_no_primary_trades", False)
        or getattr(params, "late_breakout_only_when_no_existing_trades", False)
    )
    entry_tie_break_rank_by_score = bool(
        getattr(params, "entry_tie_break_rank_by_score", False)
    )
    if is_soft_day and getattr(params, "soft_day_rank_before_time", False):
        timed_candidates.sort(
            key=lambda x: (
                (
                    1
                    if soft_day_vwap_after_non_soft_path
                    and x[3] == "soft_day_vwap_reclaim"
                    else 0
                ),
                (
                    1
                    if late_breakout_after_primary_path
                    and x[3] == "late_breakout"
                    else 0
                ),
                -_timed_rank_score(x),
                x[0],
            )
        )
    else:
        timed_candidates.sort(
            key=lambda x: (
                (
                    1
                    if soft_day_vwap_after_non_soft_path
                    and x[3] == "soft_day_vwap_reclaim"
                    else 0
                ),
                (
                    1
                    if late_breakout_after_primary_path
                    and x[3] == "late_breakout"
                    else 0
                ),
                x[0],
                -_timed_rank_score(x) if entry_tie_break_rank_by_score else 0.0,
            )
        )

    # ── Pass 2: Simulate in chronological order with capital constraints ──
    sizing_cap = sizing_capital if sizing_capital is not None else equity
    sparse_day_scaler = _orb_sparse_day_scaler(len(timed_candidates), params)
    result.sparse_day_scaler = sparse_day_scaler
    # Apply VIX + regime/breadth + market-ORB-quality scalers to sizing capital.
    # Market ORB quality may legitimately scale above 1.0 on especially strong open regimes.
    combined_size_mult = vix_scaler * combined_scaler * sparse_day_scaler * market_orb_quality_scaler
    adjusted_sizing = sizing_cap * combined_size_mult
    # Preserve legacy ORB semantics: day-level kill switch is anchored to the
    # unscaled research budget, even when intra-day sizing is softened by
    # regime/breadth/meta scalers. This avoids changing historical strategy
    # behavior when new size scalers are introduced.
    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
    nofill_vwap_trades = 0
    late_breakout_trades = 0
    soft_day_vwap_trades = 0
    broad_gapup_continuation_trades = 0
    market_thrust_liquid_continuation_trades = 0
    market_thrust_opening_burst_trades = 0
    market_thrust_opening_followthrough_trades = 0
    market_thrust_opening_impulse_reclaim_trades = 0
    intraday_continuation_reclaim_trades = 0
    soft_day_sector_confirmation_override_trades = 0
    traded_tickers: set[str] = set()
    entry_reject_stats: dict[str, int] = {}
    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
    )
    entry_market_guard_ticker = (
        getattr(params, "entry_market_guard_ticker", None)
        or getattr(params, "market_regime_ticker", None)
        or "QQQ"
    )
    entry_market_guard_bars = (
        bars_by_ticker.get(str(entry_market_guard_ticker))
        if bool(getattr(params, "entry_market_guard_enabled", False))
        else None
    )
    for idx, (entry_ts_pass2, cand, direction_str, trigger_type_pass2, forced_price_pass2) in enumerate(timed_candidates):
        def _reject(reason: str) -> None:
            entry_reject_stats[reason] = entry_reject_stats.get(reason, 0) + 1

        # 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

        if cand["ticker"] in traded_tickers:
            _reject("duplicate_ticker")
            continue

        if (
            market_orb_quality_divergence_max_trades is not None
            and len(result.trades) >= market_orb_quality_divergence_max_trades
        ):
            break

        if params.max_trades_per_day is not None and len(result.trades) >= max(
            0, int(params.max_trades_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:
                _reject("max_simultaneous_entries")
                continue

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

        # Portfolio deployment cap
        if max_deploy is not None and total_deployed >= max_deploy:
            _reject("max_deployment")
            continue

        # Score rank percentage: 1.0 = top ranked, 0.0 = bottom ranked
        rank_candidates = (
            broad_gapup_candidates
            if trigger_type_pass2 == "broad_gapup_continuation"
            and broad_gapup_candidates
            else market_thrust_liquid_candidates
            if trigger_type_pass2 in {
                "market_thrust_liquid_continuation",
                "market_thrust_opening_burst",
                "market_thrust_opening_followthrough",
            }
            and market_thrust_liquid_candidates
            else market_thrust_opening_impulse_candidates
            if trigger_type_pass2 == "market_thrust_opening_impulse_reclaim"
            and market_thrust_opening_impulse_candidates
            else intraday_continuation_candidates
            if trigger_type_pass2 == "intraday_continuation_reclaim"
            and intraday_continuation_candidates
            else candidates
        )
        n_cands = len(rank_candidates)
        cand_rank = next(
            (i for i, c in enumerate(rank_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, {})
        timed_key = (str(cand.get("ticker") or ""), str(trigger_type_pass2), entry_ts_pass2.isoformat())
        reclaim_entry_attention_diag = reclaim_entry_attention_by_key.get(timed_key, {})

        if trigger_type_pass2 == "vwap_reclaim":
            if (
                params.nofill_vwap_reclaim_max_trades is not None
                and nofill_vwap_trades >= params.nofill_vwap_reclaim_max_trades
            ):
                _reject("nofill_vwap_max_trades")
                continue
            if (
                params.nofill_vwap_reclaim_min_score_pct is not None
                and score_rank_pct < params.nofill_vwap_reclaim_min_score_pct
            ):
                _reject("nofill_vwap_score")
                continue
        elif trigger_type_pass2 == "late_breakout":
            if (
                getattr(params, "late_breakout_only_when_no_existing_trades", False)
                and result.trades
            ):
                _reject("late_breakout_existing_trade")
                continue
            if (
                getattr(params, "late_breakout_only_when_no_primary_trades", False)
                and any(t.trigger_type not in auxiliary_trigger_types for t in result.trades)
            ):
                _reject("late_breakout_primary_trade_exists")
                continue
            if (
                params.late_breakout_max_trades is not None
                and late_breakout_trades >= params.late_breakout_max_trades
            ):
                _reject("late_breakout_max_trades")
                continue
            if (
                params.late_breakout_min_score_pct is not None
                and score_rank_pct < params.late_breakout_min_score_pct
            ):
                _reject("late_breakout_score")
                continue
        elif trigger_type_pass2 == "soft_day_vwap_reclaim":
            if not _orb_soft_day_vwap_reason_allowed(params, result.soft_day_reason):
                _reject("soft_day_vwap_reason")
                continue
            if (
                getattr(
                    params,
                    "soft_day_vwap_reclaim_only_when_no_existing_trades",
                    False,
                )
                and result.trades
            ):
                _reject("soft_day_vwap_existing_trade")
                continue
            if (
                getattr(params, "soft_day_vwap_reclaim_only_when_no_primary_trades", False)
                and any(
                    t.trigger_type not in auxiliary_trigger_types
                    for t in result.trades
                )
            ):
                _reject("soft_day_vwap_primary_trade_exists")
                continue
            if (
                params.soft_day_vwap_reclaim_max_trades is not None
                and soft_day_vwap_trades >= params.soft_day_vwap_reclaim_max_trades
            ):
                _reject("soft_day_vwap_max_trades")
                continue
            soft_day_vwap_min_score = _orb_soft_day_vwap_min_score_pct(
                params,
                result.soft_day_reason,
            )
            if (
                soft_day_vwap_min_score is not None
                and score_rank_pct < soft_day_vwap_min_score
            ):
                _reject("soft_day_vwap_score")
                continue
            min_pm_dolvol = _orb_soft_day_vwap_reason_param(
                params,
                result.soft_day_reason,
                "soft_day_vwap_reclaim_min_premarket_dollar_vol",
            )
            if (
                min_pm_dolvol is not None
                and float(cand.get("premarket_dollar_vol") or 0.0) < min_pm_dolvol
            ):
                _reject("soft_day_vwap_premarket_dolvol")
                continue
            min_aux_ret5 = _orb_soft_day_vwap_reason_param(
                params,
                result.soft_day_reason,
                "soft_day_vwap_reclaim_min_ret_5d",
            )
            if min_aux_ret5 is not None:
                aux_ret5 = cand.get("ret_5d", ticker_enrich.get("ret_5d"))
                if aux_ret5 is None or float(aux_ret5) < min_aux_ret5:
                    _reject("soft_day_vwap_ret5")
                    continue
            gap_down_max_ret5 = _orb_soft_day_vwap_reason_param(
                params,
                result.soft_day_reason,
                "soft_day_vwap_reclaim_gap_down_max_ret_5d",
            )
            if gap_down_max_ret5 is not None and float(cand.get("gap_pct") or 0.0) < 0.0:
                aux_ret5 = cand.get("ret_5d", ticker_enrich.get("ret_5d"))
                if aux_ret5 is None or float(aux_ret5) > gap_down_max_ret5:
                    _reject("soft_day_vwap_gap_down_ret5")
                    continue
            weak_participation_max_rvol_rank = getattr(
                params,
                "soft_day_vwap_reclaim_weak_participation_max_rvol_rank_pct",
                None,
            )
            weak_participation_max_body = getattr(
                params,
                "soft_day_vwap_reclaim_weak_participation_max_body_ratio",
                None,
            )
            if (
                weak_participation_max_rvol_rank is not None
                and weak_participation_max_body is not None
            ):
                aux_rvol_rank = cand.get("rvol_rank_pct")
                aux_body = cand.get("body_ratio")
                if (
                    aux_rvol_rank is not None
                    and aux_body is not None
                    and float(aux_rvol_rank) <= float(weak_participation_max_rvol_rank)
                    and float(aux_body) <= float(weak_participation_max_body)
                ):
                    _reject("soft_day_vwap_weak_participation")
                    continue
            min_aux_rvol = _orb_soft_day_vwap_reason_param(
                params,
                result.soft_day_reason,
                "soft_day_vwap_reclaim_min_rvol",
            )
            if min_aux_rvol is not None:
                aux_rvol = cand.get("rvol")
                if aux_rvol is None or float(aux_rvol) < min_aux_rvol:
                    _reject("soft_day_vwap_min_rvol")
                    continue
            max_aux_rvol = _orb_soft_day_vwap_reason_param(
                params,
                result.soft_day_reason,
                "soft_day_vwap_reclaim_max_rvol",
            )
            if max_aux_rvol is not None:
                aux_rvol = cand.get("rvol")
                if aux_rvol is None or float(aux_rvol) > max_aux_rvol:
                    _reject("soft_day_vwap_rvol")
                    continue
            min_aux_body = _orb_soft_day_vwap_reason_param(
                params,
                result.soft_day_reason,
                "soft_day_vwap_reclaim_min_body_ratio",
            )
            if min_aux_body is not None and float(cand.get("body_ratio") or 0.0) < min_aux_body:
                _reject("soft_day_vwap_body")
                continue
            min_aux_close_loc = _orb_soft_day_vwap_reason_param(
                params,
                result.soft_day_reason,
                "soft_day_vwap_reclaim_min_close_location",
            )
            if (
                min_aux_close_loc is not None
                and float(cand.get("close_location") or 0.0) < min_aux_close_loc
            ):
                _reject("soft_day_vwap_close_location")
                continue
        elif trigger_type_pass2 == "broad_gapup_continuation":
            if (
                getattr(params, "broad_gapup_continuation_only_when_no_primary_trades", False)
                and any(
                    t.trigger_type not in auxiliary_trigger_types
                    and t.trigger_type != "broad_gapup_continuation"
                    for t in result.trades
                )
            ):
                _reject("broad_gapup_primary_trade_exists")
                continue
            max_broad_trades = getattr(
                params,
                "broad_gapup_continuation_max_trades",
                None,
            )
            if (
                max_broad_trades is not None
                and broad_gapup_continuation_trades >= max(0, int(max_broad_trades))
            ):
                _reject("broad_gapup_max_trades")
                continue
            broad_min_score_pct = getattr(
                params,
                "broad_gapup_continuation_min_score_pct",
                None,
            )
            if broad_min_score_pct is not None and score_rank_pct < float(broad_min_score_pct):
                _reject("broad_gapup_score")
                continue
        elif trigger_type_pass2 in {
            "market_thrust_liquid_continuation",
            "market_thrust_opening_burst",
            "market_thrust_opening_followthrough",
        }:
            if (
                getattr(
                    params,
                    "market_thrust_liquid_continuation_only_when_no_primary_trades",
                    False,
                )
                and not market_thrust_breadth_override_active
                and any(
                    t.trigger_type not in auxiliary_trigger_types
                    and t.trigger_type not in market_thrust_liquid_trigger_types
                    for t in result.trades
                )
            ):
                _reject("market_thrust_liquid_primary_trade_exists")
                continue
            max_market_thrust_trades = getattr(
                params,
                "market_thrust_liquid_continuation_max_trades",
                None,
            )
            if not market_thrust_breadth_override_active:
                no_thrust_max_market_thrust_trades = getattr(
                    params,
                    "market_thrust_liquid_continuation_no_thrust_max_trades",
                    None,
                )
                if no_thrust_max_market_thrust_trades is not None:
                    max_market_thrust_trades = no_thrust_max_market_thrust_trades
            if (
                max_market_thrust_trades is not None
                and (
                    market_thrust_liquid_continuation_trades
                    + market_thrust_opening_burst_trades
                    + market_thrust_opening_followthrough_trades
                )
                >= max(0, int(max_market_thrust_trades))
            ):
                _reject("market_thrust_liquid_max_trades")
                continue
            market_thrust_min_score_pct = getattr(
                params,
                "market_thrust_liquid_continuation_min_score_pct",
                None,
            )
            if (
                market_thrust_min_score_pct is not None
                and score_rank_pct < float(market_thrust_min_score_pct)
            ):
                _reject("market_thrust_liquid_score")
                continue
        elif trigger_type_pass2 == "market_thrust_opening_impulse_reclaim":
            if (
                getattr(
                    params,
                    "market_thrust_opening_impulse_reclaim_only_when_no_primary_trades",
                    False,
                )
                and not market_thrust_breadth_override_active
                and any(
                    t.trigger_type not in auxiliary_trigger_types
                    and t.trigger_type not in market_thrust_liquid_trigger_types
                    and t.trigger_type not in market_thrust_impulse_trigger_types
                    for t in result.trades
                )
            ):
                _reject("market_thrust_impulse_primary_trade_exists")
                continue
            max_market_thrust_impulse_trades = getattr(
                params,
                "market_thrust_opening_impulse_reclaim_max_trades",
                None,
            )
            if not market_thrust_breadth_override_active:
                no_thrust_max_impulse_trades = getattr(
                    params,
                    "market_thrust_opening_impulse_reclaim_no_thrust_max_trades",
                    None,
                )
                if no_thrust_max_impulse_trades is not None:
                    max_market_thrust_impulse_trades = no_thrust_max_impulse_trades
            if (
                max_market_thrust_impulse_trades is not None
                and market_thrust_opening_impulse_reclaim_trades
                >= max(0, int(max_market_thrust_impulse_trades))
            ):
                _reject("market_thrust_impulse_max_trades")
                continue
            market_thrust_impulse_min_score_pct = getattr(
                params,
                "market_thrust_opening_impulse_reclaim_min_score_pct",
                None,
            )
            if (
                market_thrust_impulse_min_score_pct is not None
                and score_rank_pct < float(market_thrust_impulse_min_score_pct)
            ):
                _reject("market_thrust_impulse_score")
                continue
        elif trigger_type_pass2 == "intraday_continuation_reclaim":
            if (
                getattr(
                    params,
                    "intraday_continuation_reclaim_only_when_no_primary_trades",
                    True,
                )
                and any(
                    t.trigger_type not in auxiliary_trigger_types
                    and t.trigger_type != "intraday_continuation_reclaim"
                    for t in result.trades
                )
            ):
                _reject("intraday_continuation_primary_trade_exists")
                continue
            max_intraday_trades = getattr(
                params,
                "intraday_continuation_reclaim_max_trades",
                None,
            )
            if (
                max_intraday_trades is not None
                and intraday_continuation_reclaim_trades >= max(0, int(max_intraday_trades))
            ):
                _reject("intraday_continuation_max_trades")
                continue
            intraday_min_score_pct = getattr(
                params,
                "intraday_continuation_reclaim_min_score_pct",
                None,
            )
            if (
                intraday_min_score_pct is not None
                and score_rank_pct < float(intraday_min_score_pct)
            ):
                _reject("intraday_continuation_score")
                continue

        if (
            reclaim_entry_attention_by_key
            and _reclaim_entry_attention_trigger_allowed(params, trigger_type_pass2)
        ):
            min_entry_rel = getattr(
                params,
                "reclaim_entry_attention_min_entry_rel_volume",
                None,
            )
            entry_rel = reclaim_entry_attention_diag.get("entry_rel_volume")
            if (
                min_entry_rel is not None
                and (entry_rel is None or entry_rel < float(min_entry_rel))
            ):
                _reject("reclaim_entry_attention_relvol")
                continue
            min_entry_rank = getattr(
                params,
                "reclaim_entry_attention_min_rank_pct",
                None,
            )
            entry_rank = reclaim_entry_attention_diag.get(
                "reclaim_entry_attention_rank_pct"
            )
            if (
                min_entry_rank is not None
                and (entry_rank is None or entry_rank < float(min_entry_rank))
            ):
                _reject("reclaim_entry_attention_rank")
                continue

        soft_day_sector_confirmation_override_active = False
        if is_soft_day and trigger_type_pass2 != "soft_day_vwap_reclaim":
            soft_day_sector_confirmation_override_active = (
                _orb_soft_day_sector_confirmation_override_allows(
                    params,
                    result.soft_day_reason,
                    cand,
                    score_rank_pct=score_rank_pct,
                    trigger_type=trigger_type_pass2,
                )
            )
            max_sector_override = getattr(
                params,
                "soft_day_sector_confirmation_override_max_trades",
                1,
            )
            if (
                soft_day_sector_confirmation_override_active
                and max_sector_override is not None
                and soft_day_sector_confirmation_override_trades
                >= max(0, int(max_sector_override))
            ):
                soft_day_sector_confirmation_override_active = False

        # Soft-day selection gates
        if is_soft_day:
            if (
                trigger_type_pass2 not in {
                    "broad_gapup_continuation",
                    "market_thrust_opening_burst",
                    "market_thrust_opening_followthrough",
                    "market_thrust_opening_impulse_reclaim",
                    "intraday_continuation_reclaim",
                }
                and params.soft_day_max_trades is not None
                and len(result.trades) >= params.soft_day_max_trades
            ):
                _reject("soft_day_max_trades")
                continue
            if (
                trigger_type_pass2 != "soft_day_vwap_reclaim"
                and trigger_type_pass2 != "broad_gapup_continuation"
                and trigger_type_pass2 != "market_thrust_liquid_continuation"
                and trigger_type_pass2 != "market_thrust_opening_burst"
                and trigger_type_pass2 != "market_thrust_opening_followthrough"
                and trigger_type_pass2 != "market_thrust_opening_impulse_reclaim"
                and trigger_type_pass2 != "intraday_continuation_reclaim"
                and not soft_day_sector_confirmation_override_active
            ):
                if params.soft_day_min_score_pct is not None and score_rank_pct < params.soft_day_min_score_pct:
                    _reject("soft_day_score")
                    continue
                if (
                    params.soft_day_min_candidate_score is not None
                    and float(cand.get("score") or 0.0) < params.soft_day_min_candidate_score
                ):
                    _reject("soft_day_candidate_score")
                    continue
                if params.soft_day_min_ret_5d is not None:
                    ret_5d = cand.get("ret_5d", ticker_enrich.get("ret_5d"))
                    if ret_5d is None or float(ret_5d) < params.soft_day_min_ret_5d:
                        _reject("soft_day_ret5")
                        continue
                if params.soft_day_max_rvol is not None:
                    cand_rvol = cand.get("rvol")
                    if cand_rvol is None or float(cand_rvol) > params.soft_day_max_rvol:
                        _reject("soft_day_rvol")
                        continue
                if not _orb_soft_day_setup_profile_allows(
                    params,
                    result.soft_day_reason,
                    cand,
                    ticker_enrich,
                ):
                    _reject("soft_day_profile")
                    continue

        entry_market_guard_active, entry_market_guard_return, entry_market_guard_size_scale = (
            _entry_market_guard_scale(
                params,
                entry_market_guard_bars,
                entry_ts_pass2,
                date_str,
                is_soft_day,
            )
        )
        if (
            entry_market_guard_active
            and bool(getattr(params, "entry_market_guard_skip_trade", False))
        ):
            _reject("entry_market_guard")
            continue

        # Previous close + entropy for gap fill protection and per-candidate size scaling
        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
        )
        crowded_gap_size_scale = float(cand.get("crowded_gap_size_scale") or 1.0)
        countertrend_gap_size_scale = float(
            cand.get("countertrend_gap_size_scale") or 1.0
        )
        distressed_reclaim_size_scale = float(
            cand.get("distressed_reclaim_size_scale") or 1.0
        )
        hot_reclaim_size_scale = float(cand.get("hot_reclaim_size_scale") or 1.0)
        weak_downside_reclaim_size_scale = float(
            cand.get("weak_downside_reclaim_size_scale") or 1.0
        )
        quiet_downside_reclaim_size_scale = float(
            cand.get("quiet_downside_reclaim_size_scale") or 1.0
        )
        stale_obv_reversal_size_scale = float(
            cand.get("stale_obv_reversal_size_scale") or 1.0
        )
        stalled_gap_up_size_scale = float(cand.get("stalled_gap_up_size_scale") or 1.0)
        liquid_stalled_gap_up_size_scale = float(
            cand.get("liquid_stalled_gap_up_size_scale") or 1.0
        )
        ranked_downside_gap_size_scale = 1.0
        ranked_downside_gap_reserve_full_cash = False
        ranked_downside_gap_loss_cap_pct = None
        ranked_downside_gap_min_abs_gap = getattr(
            params, "ranked_downside_gap_min_abs_gap_pct", None
        )
        ranked_downside_gap_action = str(
            getattr(params, "ranked_downside_gap_action", "none") or "none"
        )
        if (
            direction_str == "long"
            and ranked_downside_gap_min_abs_gap is not None
            and cand.get("gap_pct") is not None
            and float(cand.get("gap_pct") or 0.0) <= -float(ranked_downside_gap_min_abs_gap)
            and (
                getattr(params, "ranked_downside_gap_max_premarket_dollar_vol", None) is None
                or float(cand.get("premarket_dollar_vol") or 0.0)
                <= float(getattr(params, "ranked_downside_gap_max_premarket_dollar_vol"))
            )
            and (
                getattr(params, "ranked_downside_gap_max_score_rank_pct", None) is None
                or score_rank_pct <= float(getattr(params, "ranked_downside_gap_max_score_rank_pct"))
            )
            and (
                getattr(params, "ranked_downside_gap_min_market_secondary_close_location", None) is None
                or (
                    market_orb_quality_secondary_close_location is not None
                    and market_orb_quality_secondary_close_location
                    >= float(getattr(params, "ranked_downside_gap_min_market_secondary_close_location"))
                )
            )
        ):
            if ranked_downside_gap_action in ("scale", "scale_reserve"):
                ranked_downside_gap_size_scale = max(
                    0.0,
                    min(
                        1.0,
                        float(getattr(params, "ranked_downside_gap_size_scale", 1.0) or 1.0),
                    ),
                )
            if ranked_downside_gap_action == "scale_reserve":
                ranked_downside_gap_reserve_full_cash = True
            if ranked_downside_gap_action == "loss_cap":
                if getattr(params, "ranked_downside_gap_loss_cap_pct", None) is not None:
                    ranked_downside_gap_loss_cap_pct = max(
                        0.0,
                        float(getattr(params, "ranked_downside_gap_loss_cap_pct") or 0.0),
                    )
        isolated_downside_loss_cap_active = False
        isolated_downside_loss_cap_pct = None
        isolated_downside_size_scale = 1.0
        isolated_downside_min_abs_gap = getattr(
            params,
            "isolated_downside_loss_cap_min_abs_gap_pct",
            None,
        )
        if (
            direction_str == "long"
            and isolated_downside_min_abs_gap is not None
            and cand.get("gap_pct") is not None
            and float(cand.get("gap_pct") or 0.0) <= -float(isolated_downside_min_abs_gap)
            and not bool(cand.get("sector_confirmation_active"))
        ):
            allowed_triggers = getattr(
                params,
                "isolated_downside_loss_cap_allowed_trigger_types",
                None,
            )
            ret_5d_for_isolated = ticker_enrich.get("ret_5d")
            premarket_for_isolated = float(cand.get("premarket_dollar_vol") or 0.0)
            isolated_downside_ok = (
                (not allowed_triggers or trigger_type_pass2 in set(allowed_triggers))
                and (
                    getattr(params, "isolated_downside_loss_cap_max_ret_5d", None)
                    is None
                    or (
                        ret_5d_for_isolated is not None
                        and float(ret_5d_for_isolated)
                        <= float(getattr(params, "isolated_downside_loss_cap_max_ret_5d"))
                    )
                )
                and (
                    getattr(
                        params,
                        "isolated_downside_loss_cap_min_premarket_dollar_vol",
                        None,
                    )
                    is None
                    or premarket_for_isolated
                    >= float(
                        getattr(
                            params,
                            "isolated_downside_loss_cap_min_premarket_dollar_vol",
                        )
                    )
                )
                and (
                    getattr(
                        params,
                        "isolated_downside_loss_cap_max_premarket_dollar_vol",
                        None,
                    )
                    is None
                    or premarket_for_isolated
                    <= float(
                        getattr(
                            params,
                            "isolated_downside_loss_cap_max_premarket_dollar_vol",
                        )
                    )
                )
                and (
                    getattr(params, "isolated_downside_loss_cap_max_body_ratio", None)
                    is None
                    or float(cand.get("body_ratio") or 0.0)
                    <= float(getattr(params, "isolated_downside_loss_cap_max_body_ratio"))
                )
                and (
                    getattr(params, "isolated_downside_loss_cap_min_body_ratio", None)
                    is None
                    or float(cand.get("body_ratio") or 0.0)
                    >= float(getattr(params, "isolated_downside_loss_cap_min_body_ratio"))
                )
                and (
                    getattr(params, "isolated_downside_loss_cap_max_close_location", None)
                    is None
                    or float(cand.get("close_location") or 0.0)
                    <= float(
                        getattr(
                            params,
                            "isolated_downside_loss_cap_max_close_location",
                        )
                    )
                )
                and (
                    getattr(params, "isolated_downside_loss_cap_min_close_location", None)
                    is None
                    or float(cand.get("close_location") or 0.0)
                    >= float(
                        getattr(
                            params,
                            "isolated_downside_loss_cap_min_close_location",
                        )
                    )
                )
                and (
                    getattr(params, "isolated_downside_loss_cap_min_orb_return", None)
                    is None
                    or float(cand.get("orb_return") or 0.0)
                    >= float(getattr(params, "isolated_downside_loss_cap_min_orb_return"))
                )
                and (
                    getattr(params, "isolated_downside_loss_cap_max_score_rank_pct", None)
                    is None
                    or score_rank_pct
                    <= float(
                        getattr(
                            params,
                            "isolated_downside_loss_cap_max_score_rank_pct",
                        )
                    )
                )
            )
            if isolated_downside_ok:
                isolated_downside_loss_cap_active = True
                raw_isolated_scale = getattr(
                    params,
                    "isolated_downside_size_scale",
                    1.0,
                )
                isolated_downside_size_scale = max(
                    0.0,
                    min(1.0, float(raw_isolated_scale if raw_isolated_scale is not None else 1.0)),
                )
                isolated_downside_raw_loss_cap = getattr(
                    params,
                    "isolated_downside_loss_cap_pct",
                    None,
                )
                if isolated_downside_raw_loss_cap is not None:
                    isolated_downside_loss_cap_pct = max(
                        0.0,
                        float(isolated_downside_raw_loss_cap),
                    )
        isolated_downside_pressure_active = False
        isolated_downside_pressure_size_scale = 1.0
        isolated_pressure_min_abs_gap = getattr(
            params,
            "isolated_downside_pressure_min_abs_gap_pct",
            None,
        )
        if (
            direction_str == "long"
            and isolated_pressure_min_abs_gap is not None
            and cand.get("gap_pct") is not None
            and float(cand.get("gap_pct") or 0.0) <= -float(isolated_pressure_min_abs_gap)
            and not bool(cand.get("sector_confirmation_active"))
        ):
            isolated_pressure_allowed_triggers = getattr(
                params,
                "isolated_downside_pressure_allowed_trigger_types",
                None,
            )
            isolated_pressure_ret_5d = ticker_enrich.get("ret_5d")
            isolated_pressure_premarket = float(cand.get("premarket_dollar_vol") or 0.0)
            isolated_pressure_ok = (
                (
                    not isolated_pressure_allowed_triggers
                    or trigger_type_pass2 in set(isolated_pressure_allowed_triggers)
                )
                and (
                    getattr(params, "isolated_downside_pressure_max_ret_5d", None)
                    is None
                    or (
                        isolated_pressure_ret_5d is not None
                        and float(isolated_pressure_ret_5d)
                        <= float(getattr(params, "isolated_downside_pressure_max_ret_5d"))
                    )
                )
                and (
                    getattr(
                        params,
                        "isolated_downside_pressure_min_premarket_dollar_vol",
                        None,
                    )
                    is None
                    or isolated_pressure_premarket
                    >= float(
                        getattr(
                            params,
                            "isolated_downside_pressure_min_premarket_dollar_vol",
                        )
                    )
                )
                and (
                    getattr(params, "isolated_downside_pressure_max_body_ratio", None)
                    is None
                    or float(cand.get("body_ratio") or 0.0)
                    <= float(getattr(params, "isolated_downside_pressure_max_body_ratio"))
                )
                and (
                    getattr(
                        params,
                        "isolated_downside_pressure_max_close_location",
                        None,
                    )
                    is None
                    or float(cand.get("close_location") or 0.0)
                    <= float(
                        getattr(
                            params,
                            "isolated_downside_pressure_max_close_location",
                        )
                    )
                )
            )
            if isolated_pressure_ok:
                isolated_downside_pressure_active = True
                raw_isolated_pressure_scale = getattr(
                    params,
                    "isolated_downside_pressure_size_scale",
                    1.0,
                )
                isolated_downside_pressure_size_scale = max(
                    0.0,
                    min(
                        1.0,
                        float(
                            1.0
                            if raw_isolated_pressure_scale is None
                            else raw_isolated_pressure_scale
                        ),
                    ),
                )
        rank_rvol_pressure_size_scale = 1.0
        overextended_downside_reclaim_active = False
        overextended_downside_reclaim_size_scale = 1.0
        overextended_min_abs_gap = getattr(
            params,
            "overextended_downside_reclaim_min_abs_gap_pct",
            None,
        )
        overextended_min_ret = getattr(
            params,
            "overextended_downside_reclaim_min_ret_5d",
            None,
        )
        if (
            direction_str == "long"
            and overextended_min_abs_gap is not None
            and overextended_min_ret is not None
            and cand.get("gap_pct") is not None
            and float(cand.get("gap_pct") or 0.0) <= -float(overextended_min_abs_gap)
        ):
            overextended_allowed_triggers = getattr(
                params,
                "overextended_downside_reclaim_allowed_trigger_types",
                None,
            )
            overextended_ret_5d = ticker_enrich.get("ret_5d")
            overextended_premarket = float(cand.get("premarket_dollar_vol") or 0.0)
            overextended_ok = (
                (
                    not overextended_allowed_triggers
                    or trigger_type_pass2 in {str(t) for t in overextended_allowed_triggers}
                )
                and (
                    overextended_ret_5d is not None
                    and float(overextended_ret_5d) >= float(overextended_min_ret)
                )
                and (
                    getattr(
                        params,
                        "overextended_downside_reclaim_min_premarket_dollar_vol",
                        None,
                    )
                    is None
                    or overextended_premarket
                    >= float(
                        getattr(
                            params,
                            "overextended_downside_reclaim_min_premarket_dollar_vol",
                        )
                    )
                )
            )
            if overextended_ok:
                overextended_downside_reclaim_active = True
                raw_overextended_scale = getattr(
                    params,
                    "overextended_downside_reclaim_size_scale",
                    1.0,
                )
                overextended_downside_reclaim_size_scale = max(
                    0.0,
                    min(
                        1.0,
                        float(
                            1.0
                            if raw_overextended_scale is None
                            else raw_overextended_scale
                        ),
                    ),
                )
        (
            mid_attention_exhaustion_active,
            mid_attention_exhaustion_size_scale,
        ) = _orb_mid_attention_exhaustion_size_scale(
            params,
            cand,
            trigger_type=trigger_type_pass2,
            direction_str=direction_str,
        )
        (
            mid_liquidity_fragility_active,
            mid_liquidity_fragility_size_scale,
        ) = _orb_mid_liquidity_fragility_size_scale(
            params,
            cand,
            ticker_enrich,
            trigger_type=trigger_type_pass2,
            direction_str=direction_str,
        )
        (
            orphan_thin_attention_active,
            orphan_thin_attention_size_scale,
        ) = _orb_orphan_thin_attention_size_scale(
            params,
            cand,
            trigger_type=trigger_type_pass2,
            direction_str=direction_str,
        )
        (
            gap_up_fill_trap_active,
            gap_up_fill_trap_size_scale,
        ) = _orb_gap_up_fill_trap_size_scale(
            params,
            cand,
            trigger_type=trigger_type_pass2,
            direction_str=direction_str,
        )
        (
            low_candidate_quality_active,
            low_candidate_quality_size_scale,
        ) = _orb_low_candidate_quality_size_scale(
            params,
            cand,
            trigger_type=trigger_type_pass2,
            direction_str=direction_str,
        )
        rank_rvol_pressure_min_rvol = getattr(
            params, "rank_rvol_pressure_min_rvol", None
        )
        rank_rvol_pressure_max_rank = getattr(
            params, "rank_rvol_pressure_max_score_rank_pct", None
        )
        if (
            rank_rvol_pressure_min_rvol is not None
            and rank_rvol_pressure_max_rank is not None
            and float(cand.get("rvol") or 0.0) >= float(rank_rvol_pressure_min_rvol)
            and score_rank_pct <= float(rank_rvol_pressure_max_rank)
        ):
            rank_rvol_ret_5d = ticker_enrich.get("ret_5d")
            rank_rvol_max_ret_5d = getattr(
                params, "rank_rvol_pressure_max_ret_5d", None
            )
            if (
                rank_rvol_max_ret_5d is None
                or (
                    rank_rvol_ret_5d is not None
                    and float(rank_rvol_ret_5d) <= float(rank_rvol_max_ret_5d)
                )
            ):
                raw_rank_rvol_scale = getattr(
                    params,
                    "rank_rvol_pressure_size_scale",
                    1.0,
                )
                rank_rvol_pressure_size_scale = max(
                    0.0,
                    min(
                        1.0,
                        float(
                            1.0
                            if raw_rank_rvol_scale is None
                            else raw_rank_rvol_scale
                        ),
                    ),
                )
        gap_exhaustion_pressure_size_scale = (
            _orb_gap_exhaustion_pressure_size_scale(
                params,
                cand,
                ticker_enrich,
                direction_str,
            )
        )
        stale_obv_rvol_pressure_size_scale = (
            _orb_stale_obv_rvol_pressure_size_scale(
                params,
                cand,
                ticker_enrich,
                score_rank_pct,
            )
        )
        positive_gap_rebound_failure_size_scale = (
            _orb_positive_gap_rebound_failure_size_scale(
                params,
                cand,
                ticker_enrich,
                trigger_type=trigger_type_pass2,
                direction_str=direction_str,
            )
        )
        sector_confirmation_size_scale = _orb_sector_confirmation_size_scale(
            params,
            cand,
        )
        soft_day_sector_confirmation_override_size_scale = 1.0
        if soft_day_sector_confirmation_override_active:
            raw_soft_sector_scale = getattr(
                params,
                "soft_day_sector_confirmation_override_size_scale",
                1.0,
            )
            soft_day_sector_confirmation_override_size_scale = max(
                0.0,
                min(2.0, float(1.0 if raw_soft_sector_scale is None else raw_soft_sector_scale)),
            )
        defensive_risk_size_scales = (
            crowded_gap_size_scale,
            countertrend_gap_size_scale,
            distressed_reclaim_size_scale,
            hot_reclaim_size_scale,
            weak_downside_reclaim_size_scale,
            quiet_downside_reclaim_size_scale,
            stalled_gap_up_size_scale,
            liquid_stalled_gap_up_size_scale,
            stale_obv_reversal_size_scale,
            ranked_downside_gap_size_scale,
            isolated_downside_size_scale,
            isolated_downside_pressure_size_scale,
            overextended_downside_reclaim_size_scale,
            mid_attention_exhaustion_size_scale,
            mid_liquidity_fragility_size_scale,
            orphan_thin_attention_size_scale,
            gap_up_fill_trap_size_scale,
            low_candidate_quality_size_scale,
            rank_rvol_pressure_size_scale,
            gap_exhaustion_pressure_size_scale,
            stale_obv_rvol_pressure_size_scale,
            positive_gap_rebound_failure_size_scale,
            min(sector_confirmation_size_scale, 1.0),
        )
        red_to_green_acceleration_size_scale = (
            _orb_red_to_green_acceleration_size_scale(
                params,
                cand,
                ticker_enrich,
                score_rank_pct,
                direction_str,
                defensive_risk_size_scales,
            )
        )
        liquid_leader_conviction_size_scale = (
            _orb_liquid_leader_conviction_size_scale(
                params,
                cand,
                score_rank_pct,
                trigger_type_pass2,
                direction_str,
                defensive_risk_size_scales,
            )
        )
        opening_burst_liquid_size_scale = _orb_opening_burst_liquid_size_scale(
            params,
            cand,
            date_str=date_str,
            entry_ts=entry_ts_pass2,
            trigger_type=trigger_type_pass2,
            score_rank_pct=score_rank_pct,
            direction_str=direction_str,
            risk_size_scales=defensive_risk_size_scales,
        )
        ownership_initial_size_scale = _orb_ownership_initial_size_scale(
            params,
            cand,
            score_rank_pct,
            trigger_type_pass2,
            direction_str,
            defensive_risk_size_scales,
        )
        form4_size_scale = _orb_form4_size_scale(
            params,
            cand,
            trigger_type_pass2,
            direction_str,
            defensive_risk_size_scales,
        )
        high_conviction_size_scales = (
            red_to_green_acceleration_size_scale,
            liquid_leader_conviction_size_scale,
            opening_burst_liquid_size_scale,
            ownership_initial_size_scale,
            form4_size_scale,
        )
        unsupported_attention_size_scale = _orb_unsupported_attention_size_scale(
            params,
            cand,
            trigger_type=trigger_type_pass2,
            direction_str=direction_str,
            high_conviction_size_scales=high_conviction_size_scales,
        )
        unboosted_primary_fragility_size_scale = (
            _orb_unboosted_primary_fragility_size_scale(
                params,
                cand,
                ticker_enrich,
                trigger_type=trigger_type_pass2,
                direction_str=direction_str,
                high_conviction_size_scales=high_conviction_size_scales,
            )
        )
        base_sizing, soft_day_sector_confirmation_override_min_day_size_scale = (
            _orb_soft_day_sector_confirmation_override_base_sizing(
                params,
                active=soft_day_sector_confirmation_override_active,
                sizing_cap=sizing_cap,
                adjusted_sizing=adjusted_sizing,
            )
        )
        cand_sizing_before_distressed = (
            base_sizing
            * min(entropy_scaler, 1.0)
            * min(crowded_gap_size_scale, 1.0)
            * min(countertrend_gap_size_scale, 1.0)
        )
        cand_sizing_before_hot = (
            cand_sizing_before_distressed
            * min(distressed_reclaim_size_scale, 1.0)
        )
        cand_sizing_before_weak_downside = cand_sizing_before_hot * min(
            hot_reclaim_size_scale, 1.0
        )
        cand_sizing_before_stalled = cand_sizing_before_weak_downside * min(
            weak_downside_reclaim_size_scale, 1.0
        )
        cand_sizing_before_quiet_downside = cand_sizing_before_stalled * min(
            quiet_downside_reclaim_size_scale, 1.0
        )
        cand_sizing_before_liquid_stalled = cand_sizing_before_quiet_downside * min(
            stalled_gap_up_size_scale, 1.0
        )
        cand_sizing_before_stale_obv = cand_sizing_before_liquid_stalled * min(
            liquid_stalled_gap_up_size_scale, 1.0
        )
        cand_sizing_before_ranked_downside = cand_sizing_before_stale_obv * min(
            stale_obv_reversal_size_scale, 1.0
        )
        cand_sizing_before_rank_rvol_pressure = cand_sizing_before_ranked_downside * min(
            ranked_downside_gap_size_scale, 1.0
        )
        cand_sizing = cand_sizing_before_rank_rvol_pressure * min(
            rank_rvol_pressure_size_scale, 1.0
        )
        cand_sizing *= min(isolated_downside_size_scale, 1.0)
        cand_sizing *= min(isolated_downside_pressure_size_scale, 1.0)
        cand_sizing *= min(overextended_downside_reclaim_size_scale, 1.0)
        cand_sizing *= min(mid_attention_exhaustion_size_scale, 1.0)
        cand_sizing *= min(mid_liquidity_fragility_size_scale, 1.0)
        cand_sizing_before_orphan_thin_attention = cand_sizing
        cand_sizing *= min(orphan_thin_attention_size_scale, 1.0)
        cand_sizing_before_gap_up_fill_trap = cand_sizing
        cand_sizing *= min(gap_up_fill_trap_size_scale, 1.0)
        cand_sizing_before_low_candidate_quality = cand_sizing
        cand_sizing *= min(low_candidate_quality_size_scale, 1.0)
        cand_sizing *= min(gap_exhaustion_pressure_size_scale, 1.0)
        cand_sizing_before_stale_obv_rvol_pressure = cand_sizing
        cand_sizing *= min(stale_obv_rvol_pressure_size_scale, 1.0)
        cand_sizing_before_positive_gap_rebound_failure = cand_sizing
        cand_sizing *= min(positive_gap_rebound_failure_size_scale, 1.0)
        cand_sizing *= red_to_green_acceleration_size_scale
        cand_sizing *= liquid_leader_conviction_size_scale
        cand_sizing *= opening_burst_liquid_size_scale
        cand_sizing *= ownership_initial_size_scale
        cand_sizing *= form4_size_scale
        cand_sizing_before_unsupported_attention = cand_sizing
        cand_sizing *= unsupported_attention_size_scale
        cand_sizing *= unboosted_primary_fragility_size_scale
        cand_sizing *= sector_confirmation_size_scale
        cand_sizing *= soft_day_sector_confirmation_override_size_scale
        cand_sizing *= entry_market_guard_size_scale
        if trigger_type_pass2 == "vwap_reclaim":
            cand_sizing *= min(
                1.0,
                max(0.0, float(params.nofill_vwap_reclaim_size_scale or 0.0)),
            )
        elif trigger_type_pass2 == "late_breakout":
            cand_sizing *= min(
                1.0,
                max(0.0, float(params.late_breakout_size_scale or 0.0)),
            )
            late_floor_pct = getattr(params, "late_breakout_sizing_floor_pct", None)
            if late_floor_pct is not None:
                cand_sizing = max(
                    cand_sizing,
                    sizing_cap * max(0.0, float(late_floor_pct)),
                )
        elif trigger_type_pass2 == "soft_day_vwap_reclaim":
            cand_sizing *= _orb_soft_day_vwap_size_scale(
                params,
                result.soft_day_reason,
            )
        elif trigger_type_pass2 == "broad_gapup_continuation":
            cand_sizing *= min(
                1.0,
                max(
                    0.0,
                    float(
                        getattr(
                            params,
                            "broad_gapup_continuation_size_scale",
                            1.0,
                        )
                        or 0.0
                    ),
                ),
            )
        elif trigger_type_pass2 in {
            "market_thrust_liquid_continuation",
            "market_thrust_opening_burst",
            "market_thrust_opening_followthrough",
        }:
            liquid_size_scale = getattr(
                params,
                "market_thrust_liquid_continuation_size_scale",
                1.0,
            )
            if not market_thrust_breadth_override_active:
                no_thrust_liquid_size_scale = getattr(
                    params,
                    "market_thrust_liquid_continuation_no_thrust_size_scale",
                    None,
                )
                if no_thrust_liquid_size_scale is not None:
                    liquid_size_scale = no_thrust_liquid_size_scale
            cand_sizing *= min(1.0, max(0.0, float(liquid_size_scale or 0.0)))
        elif trigger_type_pass2 == "market_thrust_opening_impulse_reclaim":
            impulse_size_scale = getattr(
                params,
                "market_thrust_opening_impulse_reclaim_size_scale",
                1.0,
            )
            if not market_thrust_breadth_override_active:
                no_thrust_impulse_size_scale = getattr(
                    params,
                    "market_thrust_opening_impulse_reclaim_no_thrust_size_scale",
                    None,
                )
                if no_thrust_impulse_size_scale is not None:
                    impulse_size_scale = no_thrust_impulse_size_scale
            cand_sizing *= min(
                1.0,
                max(
                    0.0,
                    float(impulse_size_scale or 0.0),
                ),
            )
        elif trigger_type_pass2 == "intraday_continuation_reclaim":
            cand_sizing *= min(
                1.0,
                max(
                    0.0,
                    float(
                        getattr(
                            params,
                            "intraday_continuation_reclaim_size_scale",
                            1.0,
                        )
                        or 0.0
                    ),
                ),
            )
        late_trade_size_scale = 1.0
        if (
            params.late_trade_size_scale_after_n is not None
            and len(result.trades) >= max(0, int(params.late_trade_size_scale_after_n))
        ):
            late_trade_size_scale = max(
                0.0,
                min(1.0, float(params.late_trade_size_scale)),
            )
            cand_sizing *= late_trade_size_scale

        if cand.get("distressed_reclaim_skip_trade"):
            if cand.get("distressed_reclaim_reserve_cash"):
                reserved = cand_sizing_before_distressed * params.max_position_pct
                if max_deploy is not None:
                    reserved = min(reserved, max(0.0, max_deploy - total_deployed))
                if remaining_cash is not None:
                    reserved = min(reserved, max(0.0, remaining_cash))
                    remaining_cash -= reserved
                total_deployed += reserved
                if params.max_simultaneous_entries is not None and reserved > 0:
                    ts_key = entry_ts_pass2.isoformat()
                    entries_at_ts[ts_key] = entries_at_ts.get(ts_key, 0) + 1
            continue
        if cand.get("hot_reclaim_skip_trade"):
            if cand.get("hot_reclaim_reserve_cash"):
                reserved = cand_sizing_before_hot * params.max_position_pct
                if max_deploy is not None:
                    reserved = min(reserved, max(0.0, max_deploy - total_deployed))
                if remaining_cash is not None:
                    reserved = min(reserved, max(0.0, remaining_cash))
                    remaining_cash -= reserved
                total_deployed += reserved
                if params.max_simultaneous_entries is not None and reserved > 0:
                    ts_key = entry_ts_pass2.isoformat()
                    entries_at_ts[ts_key] = entries_at_ts.get(ts_key, 0) + 1
            continue
        if cand.get("weak_downside_reclaim_skip_trade"):
            if cand.get("weak_downside_reclaim_reserve_cash"):
                reserved = cand_sizing_before_weak_downside * params.max_position_pct
                if max_deploy is not None:
                    reserved = min(reserved, max(0.0, max_deploy - total_deployed))
                if remaining_cash is not None:
                    reserved = min(reserved, max(0.0, remaining_cash))
                    remaining_cash -= reserved
                total_deployed += reserved
                if params.max_simultaneous_entries is not None and reserved > 0:
                    ts_key = entry_ts_pass2.isoformat()
                    entries_at_ts[ts_key] = entries_at_ts.get(ts_key, 0) + 1
            continue
        if cand.get("quiet_downside_reclaim_skip_trade"):
            if cand.get("quiet_downside_reclaim_reserve_cash"):
                reserved = cand_sizing_before_stalled * params.max_position_pct
                if max_deploy is not None:
                    reserved = min(reserved, max(0.0, max_deploy - total_deployed))
                if remaining_cash is not None:
                    reserved = min(reserved, max(0.0, remaining_cash))
                    remaining_cash -= reserved
                total_deployed += reserved
                if params.max_simultaneous_entries is not None and reserved > 0:
                    ts_key = entry_ts_pass2.isoformat()
                    entries_at_ts[ts_key] = entries_at_ts.get(ts_key, 0) + 1
            continue
        if cand.get("stalled_gap_up_skip_trade"):
            if cand.get("stalled_gap_up_reserve_cash"):
                reserved = cand_sizing_before_quiet_downside * params.max_position_pct
                if max_deploy is not None:
                    reserved = min(reserved, max(0.0, max_deploy - total_deployed))
                if remaining_cash is not None:
                    reserved = min(reserved, max(0.0, remaining_cash))
                    remaining_cash -= reserved
                total_deployed += reserved
                if params.max_simultaneous_entries is not None and reserved > 0:
                    ts_key = entry_ts_pass2.isoformat()
                    entries_at_ts[ts_key] = entries_at_ts.get(ts_key, 0) + 1
            continue
        if cand.get("liquid_stalled_gap_up_skip_trade"):
            if cand.get("liquid_stalled_gap_up_reserve_cash"):
                reserved = cand_sizing_before_liquid_stalled * params.max_position_pct
                if max_deploy is not None:
                    reserved = min(reserved, max(0.0, max_deploy - total_deployed))
                if remaining_cash is not None:
                    reserved = min(reserved, max(0.0, remaining_cash))
                    remaining_cash -= reserved
                total_deployed += reserved
                if params.max_simultaneous_entries is not None and reserved > 0:
                    ts_key = entry_ts_pass2.isoformat()
                    entries_at_ts[ts_key] = entries_at_ts.get(ts_key, 0) + 1
            continue
        if cand.get("stale_obv_reversal_skip_trade"):
            if cand.get("stale_obv_reversal_reserve_cash"):
                reserved = cand_sizing_before_stale_obv * params.max_position_pct
                if max_deploy is not None:
                    reserved = min(reserved, max(0.0, max_deploy - total_deployed))
                if remaining_cash is not None:
                    reserved = min(reserved, max(0.0, remaining_cash))
                    remaining_cash -= reserved
                total_deployed += reserved
                if params.max_simultaneous_entries is not None and reserved > 0:
                    ts_key = entry_ts_pass2.isoformat()
                    entries_at_ts[ts_key] = entries_at_ts.get(ts_key, 0) + 1
            continue
        if gap_up_fill_trap_active and gap_up_fill_trap_size_scale <= 0.0:
            if bool(getattr(params, "gap_up_fill_trap_reserve_full_cash", False)):
                reserved = cand_sizing_before_gap_up_fill_trap * params.max_position_pct
                if max_deploy is not None:
                    reserved = min(reserved, max(0.0, max_deploy - total_deployed))
                if remaining_cash is not None:
                    reserved = min(reserved, max(0.0, remaining_cash))
                    remaining_cash -= reserved
                total_deployed += reserved
                if params.max_simultaneous_entries is not None and reserved > 0:
                    ts_key = entry_ts_pass2.isoformat()
                    entries_at_ts[ts_key] = entries_at_ts.get(ts_key, 0) + 1
            continue
        if low_candidate_quality_active and low_candidate_quality_size_scale <= 0.0:
            if bool(getattr(params, "low_candidate_quality_reserve_full_cash", False)):
                reserved = cand_sizing_before_low_candidate_quality * params.max_position_pct
                if max_deploy is not None:
                    reserved = min(reserved, max(0.0, max_deploy - total_deployed))
                if remaining_cash is not None:
                    reserved = min(reserved, max(0.0, remaining_cash))
                    remaining_cash -= reserved
                total_deployed += reserved
                if params.max_simultaneous_entries is not None and reserved > 0:
                    ts_key = entry_ts_pass2.isoformat()
                    entries_at_ts[ts_key] = entries_at_ts.get(ts_key, 0) + 1
            continue
        if positive_gap_rebound_failure_size_scale <= 0.0:
            if bool(
                getattr(
                    params,
                    "positive_gap_rebound_failure_reserve_full_cash",
                    False,
                )
            ):
                reserved = (
                    cand_sizing_before_positive_gap_rebound_failure
                    * params.max_position_pct
                )
                if max_deploy is not None:
                    reserved = min(reserved, max(0.0, max_deploy - total_deployed))
                if remaining_cash is not None:
                    reserved = min(reserved, max(0.0, remaining_cash))
                    remaining_cash -= reserved
                total_deployed += reserved
                if params.max_simultaneous_entries is not None and reserved > 0:
                    ts_key = entry_ts_pass2.isoformat()
                    entries_at_ts[ts_key] = entries_at_ts.get(ts_key, 0) + 1
            continue

        # Forced entry bar for alternate triggers.
        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
        elif (
            trigger_type_pass2 in {
                "vwap_reclaim",
                "soft_day_vwap_reclaim",
                "late_breakout",
                "broad_gapup_continuation",
                "market_thrust_opening_burst",
                "market_thrust_opening_followthrough",
                "market_thrust_opening_impulse_reclaim",
                "intraday_continuation_reclaim",
            }
            and forced_price_pass2 is not None
        ):
            forced_entry_bar_pass2 = next(
                (
                    b for b in cand["mkt_bars"]
                    if _parse_ts(b["timestamp"]) == entry_ts_pass2
                ),
                None,
            )

        candidate_trade_params = trade_params
        if (
            cand.get("crowded_gap_requires_confirmation")
            and not candidate_trade_params.require_confirmation_bar
        ):
            candidate_trade_params = candidate_trade_params.model_copy(
                update={"require_confirmation_bar": True}
            )
        if (
            cand.get("countertrend_gap_requires_confirmation")
            and not candidate_trade_params.require_confirmation_bar
        ):
            candidate_trade_params = candidate_trade_params.model_copy(
                update={"require_confirmation_bar": True}
            )
        if (
            cand.get("distressed_reclaim_requires_confirmation")
            and not candidate_trade_params.require_confirmation_bar
        ):
            candidate_trade_params = candidate_trade_params.model_copy(
                update={"require_confirmation_bar": True}
            )
        if (
            cand.get("hot_reclaim_requires_confirmation")
            and not candidate_trade_params.require_confirmation_bar
        ):
            candidate_trade_params = candidate_trade_params.model_copy(
                update={"require_confirmation_bar": True}
            )
        if (
            cand.get("weak_downside_reclaim_requires_confirmation")
            and not candidate_trade_params.require_confirmation_bar
        ):
            candidate_trade_params = candidate_trade_params.model_copy(
                update={"require_confirmation_bar": True}
            )
        if (
            cand.get("quiet_downside_reclaim_requires_confirmation")
            and not candidate_trade_params.require_confirmation_bar
        ):
            candidate_trade_params = candidate_trade_params.model_copy(
                update={"require_confirmation_bar": True}
            )
        if (
            cand.get("stale_obv_reversal_requires_confirmation")
            and not candidate_trade_params.require_confirmation_bar
        ):
            candidate_trade_params = candidate_trade_params.model_copy(
                update={"require_confirmation_bar": True}
            )
        if (
            cand.get("stalled_gap_up_requires_confirmation")
            and not candidate_trade_params.require_confirmation_bar
        ):
            candidate_trade_params = candidate_trade_params.model_copy(
                update={"require_confirmation_bar": True}
            )
        if (
            cand.get("liquid_stalled_gap_up_requires_confirmation")
            and not candidate_trade_params.require_confirmation_bar
        ):
            candidate_trade_params = candidate_trade_params.model_copy(
                update={"require_confirmation_bar": True}
            )
        if cand.get("gap_up_fill_exit_active") and not candidate_trade_params.exit_on_gap_fill:
            candidate_trade_params = candidate_trade_params.model_copy(
                update={"exit_on_gap_fill": True}
            )
        soft_day_sector_confirmation_override_loss_cap_pct = None
        if soft_day_sector_confirmation_override_active:
            raw_soft_sector_loss_cap = getattr(
                params,
                "soft_day_sector_confirmation_override_loss_cap_pct",
                None,
            )
            if raw_soft_sector_loss_cap is not None:
                soft_day_sector_confirmation_override_loss_cap_pct = max(
                    0.0,
                    float(raw_soft_sector_loss_cap),
                )
        loss_cap_values = [
            float(value)
            for value in (
                cand.get("distressed_reclaim_loss_cap_pct"),
                cand.get("hot_reclaim_loss_cap_pct"),
                cand.get("weak_downside_reclaim_loss_cap_pct"),
                cand.get("quiet_downside_reclaim_loss_cap_pct"),
                cand.get("stale_obv_reversal_loss_cap_pct"),
                cand.get("stalled_gap_up_loss_cap_pct"),
                cand.get("liquid_stalled_gap_up_loss_cap_pct"),
                cand.get("thin_gap_up_loss_cap_pct"),
                cand.get("moderate_downside_loss_cap_pct"),
                ranked_downside_gap_loss_cap_pct,
                isolated_downside_loss_cap_pct,
                soft_day_sector_confirmation_override_loss_cap_pct,
                (
                    getattr(params, "broad_gapup_continuation_fixed_loss_pct", None)
                    if trigger_type_pass2 == "broad_gapup_continuation"
                    else None
                ),
                (
                    getattr(
                        params,
                        "market_thrust_liquid_continuation_fixed_loss_pct",
                        None,
                    )
                    if trigger_type_pass2 in {
                        "market_thrust_liquid_continuation",
                        "market_thrust_opening_burst",
                        "market_thrust_opening_followthrough",
                    }
                    else None
                ),
                (
                    getattr(
                        params,
                        "market_thrust_opening_impulse_reclaim_fixed_loss_pct",
                        None,
                    )
                    if trigger_type_pass2 == "market_thrust_opening_impulse_reclaim"
                    else None
                ),
                (
                    getattr(
                        params,
                        "intraday_continuation_reclaim_fixed_loss_pct",
                        None,
                    )
                    if trigger_type_pass2 == "intraday_continuation_reclaim"
                    else None
                ),
            )
            if value is not None
        ]
        if loss_cap_values:
            candidate_trade_params = candidate_trade_params.model_copy(
                update={"fixed_loss_pct": min(loss_cap_values)}
            )

        simulate_reject: dict[str, str] = {}
        pyramid_allowed_for_candidate = not (
            bool(getattr(params, "pyramid_require_sector_confirmation", False))
            and not bool(cand.get("sector_confirmation_active"))
        )
        available_cash_for_trade = remaining_cash

        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=candidate_trade_params,
            equity=equity,
            date_str=date_str,
            ticker=cand["ticker"],
            available_cash=available_cash_for_trade,
            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,
            reject_reason_out=simulate_reject,
            pyramid_allowed=pyramid_allowed_for_candidate,
            candidate_score=_float_or_none(cand.get("score")),
        )

        if trade is None:
            _reject("simulate_trade_none")
            reason = simulate_reject.get("reason")
            if reason:
                _reject(f"simulate_trade_none:{reason}")
            continue

        trade_deployed = trade.total_capital_deployed or (trade.shares * trade.entry_price)

        _attach_orb_candidate_diagnostics(trade, cand, ticker_enrich, score_rank_pct)
        trade.reclaim_entry_attention_rank_pct = _optional_float(
            reclaim_entry_attention_diag.get("reclaim_entry_attention_rank_pct"),
            6,
        )
        trade.entry_bar_dollar_vol_rank_pct = _optional_float(
            reclaim_entry_attention_diag.get("entry_bar_dollar_vol_rank_pct"),
            6,
        )
        trade.entry_cumulative_dollar_vol_rank_pct = _optional_float(
            reclaim_entry_attention_diag.get("entry_cumulative_dollar_vol_rank_pct"),
            6,
        )
        trade.entry_rel_volume_rank_pct = _optional_float(
            reclaim_entry_attention_diag.get("entry_rel_volume_rank_pct"),
            6,
        )
        trade.entry_rel_volume = _optional_float(
            reclaim_entry_attention_diag.get("entry_rel_volume"),
            6,
        )
        trade.late_trade_size_scale = (
            round(late_trade_size_scale, 4) if late_trade_size_scale < 1.0 else None
        )
        trade.rank_rvol_pressure_size_scale = (
            round(rank_rvol_pressure_size_scale, 4)
            if rank_rvol_pressure_size_scale < 1.0
            else None
        )
        trade.gap_exhaustion_pressure_size_scale = (
            round(gap_exhaustion_pressure_size_scale, 4)
            if gap_exhaustion_pressure_size_scale < 1.0
            else None
        )
        trade.stale_obv_rvol_pressure_size_scale = (
            round(stale_obv_rvol_pressure_size_scale, 4)
            if stale_obv_rvol_pressure_size_scale < 1.0
            else None
        )
        trade.red_to_green_acceleration_size_scale = (
            round(red_to_green_acceleration_size_scale, 4)
            if red_to_green_acceleration_size_scale > 1.0
            else None
        )
        trade.liquid_leader_conviction_size_scale = (
            round(liquid_leader_conviction_size_scale, 4)
            if liquid_leader_conviction_size_scale > 1.0
            else None
        )
        trade.opening_burst_liquid_size_scale = (
            round(opening_burst_liquid_size_scale, 4)
            if opening_burst_liquid_size_scale > 1.0
            else None
        )
        trade.ownership_initial_size_scale = (
            round(ownership_initial_size_scale, 4)
            if ownership_initial_size_scale > 1.0
            else None
        )
        trade.form4_size_scale = (
            round(form4_size_scale, 4)
            if form4_size_scale > 1.0
            else None
        )
        trade.unboosted_primary_fragility_size_scale = (
            round(unboosted_primary_fragility_size_scale, 4)
            if unboosted_primary_fragility_size_scale < 1.0
            else None
        )
        trade.unsupported_attention_size_scale = (
            round(unsupported_attention_size_scale, 4)
            if unsupported_attention_size_scale < 1.0
            else None
        )
        trade.positive_gap_rebound_failure_size_scale = (
            round(positive_gap_rebound_failure_size_scale, 4)
            if positive_gap_rebound_failure_size_scale < 1.0
            else None
        )
        trade.isolated_downside_loss_cap_active = isolated_downside_loss_cap_active
        trade.isolated_downside_loss_cap_pct = (
            round(isolated_downside_loss_cap_pct, 4)
            if isolated_downside_loss_cap_pct is not None
            else None
        )
        trade.isolated_downside_size_scale = (
            round(isolated_downside_size_scale, 4)
            if isolated_downside_size_scale < 1.0
            else None
        )
        trade.isolated_downside_pressure_active = isolated_downside_pressure_active
        trade.isolated_downside_pressure_size_scale = (
            round(isolated_downside_pressure_size_scale, 4)
            if isolated_downside_pressure_size_scale < 1.0
            else None
        )
        trade.overextended_downside_reclaim_active = overextended_downside_reclaim_active
        trade.overextended_downside_reclaim_size_scale = (
            round(overextended_downside_reclaim_size_scale, 4)
            if overextended_downside_reclaim_size_scale < 1.0
            else None
        )
        trade.mid_attention_exhaustion_active = mid_attention_exhaustion_active
        trade.mid_attention_exhaustion_size_scale = (
            round(mid_attention_exhaustion_size_scale, 4)
            if mid_attention_exhaustion_size_scale < 1.0
            else None
        )
        trade.mid_liquidity_fragility_active = mid_liquidity_fragility_active
        trade.mid_liquidity_fragility_size_scale = (
            round(mid_liquidity_fragility_size_scale, 4)
            if mid_liquidity_fragility_size_scale < 1.0
            else None
        )
        trade.orphan_thin_attention_active = orphan_thin_attention_active
        trade.orphan_thin_attention_size_scale = (
            round(orphan_thin_attention_size_scale, 4)
            if orphan_thin_attention_size_scale < 1.0
            else None
        )
        trade.gap_up_fill_trap_active = gap_up_fill_trap_active
        trade.gap_up_fill_trap_size_scale = (
            round(gap_up_fill_trap_size_scale, 4)
            if gap_up_fill_trap_size_scale < 1.0
            else None
        )
        trade.low_candidate_quality_active = low_candidate_quality_active
        trade.low_candidate_quality_size_scale = (
            round(low_candidate_quality_size_scale, 4)
            if low_candidate_quality_size_scale < 1.0
            else None
        )
        trade.broad_gapup_continuation = bool(cand.get("broad_gapup_continuation"))
        trade.broad_gapup_continuation_size_scale = (
            round(float(cand.get("broad_gapup_continuation_size_scale") or 1.0), 4)
            if cand.get("broad_gapup_continuation")
            else None
        )
        trade.intraday_continuation_reclaim = bool(
            cand.get("intraday_continuation_reclaim")
        )
        trade.intraday_continuation_reclaim_size_scale = (
            round(
                float(cand.get("intraday_continuation_reclaim_size_scale") or 1.0),
                4,
            )
            if cand.get("intraday_continuation_reclaim")
            else None
        )
        trade.market_thrust_liquid_continuation = bool(
            cand.get("market_thrust_liquid_continuation")
        )
        trade.market_thrust_liquid_continuation_size_scale = (
            round(
                float(cand.get("market_thrust_liquid_continuation_size_scale") or 1.0),
                4,
            )
            if cand.get("market_thrust_liquid_continuation")
            else None
        )
        trade.market_thrust_opening_burst = (
            trigger_type_pass2 == "market_thrust_opening_burst"
        )
        trade.market_thrust_opening_followthrough = (
            trigger_type_pass2 == "market_thrust_opening_followthrough"
        )
        trade.sector_confirmation_size_scale = (
            round(sector_confirmation_size_scale, 4)
            if sector_confirmation_size_scale != 1.0
            else None
        )
        trade.soft_day_sector_confirmation_override_size_scale = (
            round(soft_day_sector_confirmation_override_size_scale, 4)
            if soft_day_sector_confirmation_override_active
            else None
        )
        trade.soft_day_sector_confirmation_override_min_day_size_scale = (
            round(soft_day_sector_confirmation_override_min_day_size_scale, 4)
            if soft_day_sector_confirmation_override_min_day_size_scale is not None
            else None
        )
        trade.entry_market_guard_active = entry_market_guard_active
        trade.entry_market_guard_return_pct = _optional_float(
            entry_market_guard_return,
            6,
        )
        trade.entry_market_guard_size_scale = (
            round(entry_market_guard_size_scale, 4)
            if entry_market_guard_size_scale != 1.0
            else None
        )
        trade.soft_day_trade = is_soft_day
        trade.soft_day_reason = result.soft_day_reason
        result.trades.append(trade)
        if trigger_type_pass2 == "vwap_reclaim":
            nofill_vwap_trades += 1
        elif trigger_type_pass2 == "late_breakout":
            late_breakout_trades += 1
        elif trigger_type_pass2 == "soft_day_vwap_reclaim":
            soft_day_vwap_trades += 1
        elif trigger_type_pass2 == "broad_gapup_continuation":
            broad_gapup_continuation_trades += 1
        elif trigger_type_pass2 == "market_thrust_liquid_continuation":
            market_thrust_liquid_continuation_trades += 1
        elif trigger_type_pass2 == "market_thrust_opening_burst":
            market_thrust_opening_burst_trades += 1
        elif trigger_type_pass2 == "market_thrust_opening_followthrough":
            market_thrust_opening_followthrough_trades += 1
        elif trigger_type_pass2 == "market_thrust_opening_impulse_reclaim":
            market_thrust_opening_impulse_reclaim_trades += 1
        elif trigger_type_pass2 == "intraday_continuation_reclaim":
            intraday_continuation_reclaim_trades += 1
        if soft_day_sector_confirmation_override_active:
            soft_day_sector_confirmation_override_trades += 1
        traded_tickers.add(cand["ticker"])
        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)
        total_deployed += trade_deployed

        # Deduct deployed capital from remaining settled cash
        if remaining_cash is not None:
            remaining_cash -= trade_deployed
            if cand.get("distressed_reclaim_reserve_full_cash"):
                full_slot_cash = cand_sizing_before_distressed * params.max_position_pct
                extra_reserved = max(0.0, full_slot_cash - trade_deployed)
                if max_deploy is not None:
                    extra_reserved = min(extra_reserved, max(0.0, max_deploy - total_deployed))
                extra_reserved = min(extra_reserved, max(0.0, remaining_cash))
                remaining_cash -= extra_reserved
                total_deployed += extra_reserved
            if cand.get("hot_reclaim_reserve_full_cash"):
                full_slot_cash = cand_sizing_before_hot * params.max_position_pct
                extra_reserved = max(0.0, full_slot_cash - trade_deployed)
                if max_deploy is not None:
                    extra_reserved = min(extra_reserved, max(0.0, max_deploy - total_deployed))
                extra_reserved = min(extra_reserved, max(0.0, remaining_cash))
                remaining_cash -= extra_reserved
                total_deployed += extra_reserved
            if cand.get("weak_downside_reclaim_reserve_full_cash"):
                full_slot_cash = cand_sizing_before_weak_downside * params.max_position_pct
                extra_reserved = max(0.0, full_slot_cash - trade_deployed)
                if max_deploy is not None:
                    extra_reserved = min(extra_reserved, max(0.0, max_deploy - total_deployed))
                extra_reserved = min(extra_reserved, max(0.0, remaining_cash))
                remaining_cash -= extra_reserved
                total_deployed += extra_reserved
            if cand.get("quiet_downside_reclaim_reserve_full_cash"):
                full_slot_cash = cand_sizing_before_stalled * params.max_position_pct
                extra_reserved = max(0.0, full_slot_cash - trade_deployed)
                if max_deploy is not None:
                    extra_reserved = min(extra_reserved, max(0.0, max_deploy - total_deployed))
                extra_reserved = min(extra_reserved, max(0.0, remaining_cash))
                remaining_cash -= extra_reserved
                total_deployed += extra_reserved
            if cand.get("stalled_gap_up_reserve_full_cash"):
                full_slot_cash = cand_sizing_before_quiet_downside * params.max_position_pct
                extra_reserved = max(0.0, full_slot_cash - trade_deployed)
                if max_deploy is not None:
                    extra_reserved = min(extra_reserved, max(0.0, max_deploy - total_deployed))
                extra_reserved = min(extra_reserved, max(0.0, remaining_cash))
                remaining_cash -= extra_reserved
                total_deployed += extra_reserved
            if cand.get("liquid_stalled_gap_up_reserve_full_cash"):
                full_slot_cash = cand_sizing_before_liquid_stalled * params.max_position_pct
                extra_reserved = max(0.0, full_slot_cash - trade_deployed)
                if max_deploy is not None:
                    extra_reserved = min(extra_reserved, max(0.0, max_deploy - total_deployed))
                extra_reserved = min(extra_reserved, max(0.0, remaining_cash))
                remaining_cash -= extra_reserved
                total_deployed += extra_reserved
            if ranked_downside_gap_reserve_full_cash:
                full_slot_cash = cand_sizing_before_ranked_downside * params.max_position_pct
                extra_reserved = max(0.0, full_slot_cash - trade_deployed)
                if max_deploy is not None:
                    extra_reserved = min(extra_reserved, max(0.0, max_deploy - total_deployed))
                extra_reserved = min(extra_reserved, max(0.0, remaining_cash))
                remaining_cash -= extra_reserved
                total_deployed += extra_reserved
            if (
                stale_obv_rvol_pressure_size_scale < 1.0
                and bool(getattr(params, "stale_obv_rvol_pressure_reserve_full_cash", False))
            ):
                full_slot_cash = (
                    cand_sizing_before_stale_obv_rvol_pressure * params.max_position_pct
                )
                extra_reserved = max(0.0, full_slot_cash - trade_deployed)
                if max_deploy is not None:
                    extra_reserved = min(extra_reserved, max(0.0, max_deploy - total_deployed))
                extra_reserved = min(extra_reserved, max(0.0, remaining_cash))
                remaining_cash -= extra_reserved
                total_deployed += extra_reserved
            if (
                positive_gap_rebound_failure_size_scale < 1.0
                and bool(
                    getattr(
                        params,
                        "positive_gap_rebound_failure_reserve_full_cash",
                        False,
                    )
                )
            ):
                full_slot_cash = (
                    cand_sizing_before_positive_gap_rebound_failure
                    * params.max_position_pct
                )
                extra_reserved = max(0.0, full_slot_cash - trade_deployed)
                if max_deploy is not None:
                    extra_reserved = min(
                        extra_reserved,
                        max(0.0, max_deploy - total_deployed),
                    )
                extra_reserved = min(extra_reserved, max(0.0, remaining_cash))
                remaining_cash -= extra_reserved
                total_deployed += extra_reserved
            if (
                orphan_thin_attention_size_scale < 1.0
                and bool(getattr(params, "orphan_thin_attention_reserve_full_cash", False))
            ):
                full_slot_cash = (
                    cand_sizing_before_orphan_thin_attention * params.max_position_pct
                )
                extra_reserved = max(0.0, full_slot_cash - trade_deployed)
                if max_deploy is not None:
                    extra_reserved = min(extra_reserved, max(0.0, max_deploy - total_deployed))
                extra_reserved = min(extra_reserved, max(0.0, remaining_cash))
                remaining_cash -= extra_reserved
                total_deployed += extra_reserved
            if (
                gap_up_fill_trap_size_scale < 1.0
                and bool(getattr(params, "gap_up_fill_trap_reserve_full_cash", False))
            ):
                full_slot_cash = (
                    cand_sizing_before_gap_up_fill_trap * params.max_position_pct
                )
                extra_reserved = max(0.0, full_slot_cash - trade_deployed)
                if max_deploy is not None:
                    extra_reserved = min(extra_reserved, max(0.0, max_deploy - total_deployed))
                extra_reserved = min(extra_reserved, max(0.0, remaining_cash))
                remaining_cash -= extra_reserved
                total_deployed += extra_reserved
            if (
                low_candidate_quality_size_scale < 1.0
                and bool(getattr(params, "low_candidate_quality_reserve_full_cash", False))
            ):
                full_slot_cash = (
                    cand_sizing_before_low_candidate_quality * params.max_position_pct
                )
                extra_reserved = max(0.0, full_slot_cash - trade_deployed)
                if max_deploy is not None:
                    extra_reserved = min(extra_reserved, max(0.0, max_deploy - total_deployed))
                extra_reserved = min(extra_reserved, max(0.0, remaining_cash))
                remaining_cash -= extra_reserved
                total_deployed += extra_reserved
            if (
                unsupported_attention_size_scale < 1.0
                and bool(getattr(params, "unsupported_attention_reserve_full_cash", False))
            ):
                full_slot_cash = (
                    cand_sizing_before_unsupported_attention * params.max_position_pct
                )
                extra_reserved = max(0.0, full_slot_cash - trade_deployed)
                if max_deploy is not None:
                    extra_reserved = min(extra_reserved, max(0.0, max_deploy - total_deployed))
                extra_reserved = min(extra_reserved, max(0.0, remaining_cash))
                remaining_cash -= extra_reserved
                total_deployed += extra_reserved

    if result.entry_diagnostics is None:
        result.entry_diagnostics = {}
    result.entry_diagnostics.update(
        {
            "primary_trades": len(
                [
                    t for t in result.trades
                    if t.trigger_type not in {"vwap_reclaim", "soft_day_vwap_reclaim", "late_breakout"}
                ]
            ),
            "nofill_vwap_trades": nofill_vwap_trades,
            "late_breakout_trades": late_breakout_trades,
            "soft_day_vwap_trades": soft_day_vwap_trades,
            "broad_gapup_continuation_trades": broad_gapup_continuation_trades,
            "market_thrust_liquid_continuation_trades": (
                market_thrust_liquid_continuation_trades
            ),
            "market_thrust_opening_burst_trades": market_thrust_opening_burst_trades,
            "market_thrust_opening_followthrough_trades": (
                market_thrust_opening_followthrough_trades
            ),
            "market_thrust_opening_impulse_reclaim_trades": (
                market_thrust_opening_impulse_reclaim_trades
            ),
            "intraday_continuation_reclaim_trades": (
                intraday_continuation_reclaim_trades
            ),
            "soft_day_sector_confirmation_override_trades": (
                soft_day_sector_confirmation_override_trades
            ),
            "entry_reject_stats": entry_reject_stats,
        }
    )

    # ── 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
            if (
                cand.get("distressed_reclaim_skip_trade")
                or cand.get("hot_reclaim_skip_trade")
                or cand.get("weak_downside_reclaim_skip_trade")
                or cand.get("quiet_downside_reclaim_skip_trade")
                or cand.get("stale_obv_reversal_skip_trade")
                or cand.get("stalled_gap_up_skip_trade")
                or cand.get("liquid_stalled_gap_up_skip_trade")
            ):
                continue

            # 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)
            candidate_trade_params = trade_params
            if (
                cand.get("crowded_gap_requires_confirmation")
                and not candidate_trade_params.require_confirmation_bar
            ):
                candidate_trade_params = candidate_trade_params.model_copy(
                    update={"require_confirmation_bar": True}
                )
            if (
                cand.get("countertrend_gap_requires_confirmation")
                and not candidate_trade_params.require_confirmation_bar
            ):
                candidate_trade_params = candidate_trade_params.model_copy(
                    update={"require_confirmation_bar": True}
                )
            if (
                cand.get("distressed_reclaim_requires_confirmation")
                and not candidate_trade_params.require_confirmation_bar
            ):
                candidate_trade_params = candidate_trade_params.model_copy(
                    update={"require_confirmation_bar": True}
                )
            if (
                cand.get("hot_reclaim_requires_confirmation")
                and not candidate_trade_params.require_confirmation_bar
            ):
                candidate_trade_params = candidate_trade_params.model_copy(
                    update={"require_confirmation_bar": True}
                )
            if (
                cand.get("weak_downside_reclaim_requires_confirmation")
                and not candidate_trade_params.require_confirmation_bar
            ):
                candidate_trade_params = candidate_trade_params.model_copy(
                    update={"require_confirmation_bar": True}
                )
            if (
                cand.get("quiet_downside_reclaim_requires_confirmation")
                and not candidate_trade_params.require_confirmation_bar
            ):
                candidate_trade_params = candidate_trade_params.model_copy(
                    update={"require_confirmation_bar": True}
                )
            if (
                cand.get("stale_obv_reversal_requires_confirmation")
                and not candidate_trade_params.require_confirmation_bar
            ):
                candidate_trade_params = candidate_trade_params.model_copy(
                    update={"require_confirmation_bar": True}
                )
            if (
                cand.get("stalled_gap_up_requires_confirmation")
                and not candidate_trade_params.require_confirmation_bar
            ):
                candidate_trade_params = candidate_trade_params.model_copy(
                    update={"require_confirmation_bar": True}
                )
            if (
                cand.get("liquid_stalled_gap_up_requires_confirmation")
                and not candidate_trade_params.require_confirmation_bar
            ):
                candidate_trade_params = candidate_trade_params.model_copy(
                    update={"require_confirmation_bar": True}
                )
            if cand.get("gap_up_fill_exit_active") and not candidate_trade_params.exit_on_gap_fill:
                candidate_trade_params = candidate_trade_params.model_copy(
                    update={"exit_on_gap_fill": True}
                )
            loss_cap_values = [
                float(value)
                for value in (
                    cand.get("distressed_reclaim_loss_cap_pct"),
                    cand.get("hot_reclaim_loss_cap_pct"),
                    cand.get("weak_downside_reclaim_loss_cap_pct"),
                    cand.get("quiet_downside_reclaim_loss_cap_pct"),
                    cand.get("stale_obv_reversal_loss_cap_pct"),
                    cand.get("stalled_gap_up_loss_cap_pct"),
                    cand.get("liquid_stalled_gap_up_loss_cap_pct"),
                    cand.get("thin_gap_up_loss_cap_pct"),
                    cand.get("moderate_downside_loss_cap_pct"),
                )
                if value is not None
            ]
            if loss_cap_values:
                candidate_trade_params = candidate_trade_params.model_copy(
                    update={"fixed_loss_pct": min(loss_cap_values)}
                )
            crowded_gap_size_scale = float(cand.get("crowded_gap_size_scale") or 1.0)
            countertrend_gap_size_scale = float(
                cand.get("countertrend_gap_size_scale") or 1.0
            )
            distressed_reclaim_size_scale = float(
                cand.get("distressed_reclaim_size_scale") or 1.0
            )
            hot_reclaim_size_scale = float(cand.get("hot_reclaim_size_scale") or 1.0)
            weak_downside_reclaim_size_scale = float(
                cand.get("weak_downside_reclaim_size_scale") or 1.0
            )
            quiet_downside_reclaim_size_scale = float(
                cand.get("quiet_downside_reclaim_size_scale") or 1.0
            )
            stale_obv_reversal_size_scale = float(
                cand.get("stale_obv_reversal_size_scale") or 1.0
            )
            stalled_gap_up_size_scale = float(cand.get("stalled_gap_up_size_scale") or 1.0)
            liquid_stalled_gap_up_size_scale = float(
                cand.get("liquid_stalled_gap_up_size_scale") or 1.0
            )
            rank_rvol_pressure_size_scale = 1.0
            rank_rvol_pressure_min_rvol = getattr(
                params, "rank_rvol_pressure_min_rvol", None
            )
            rank_rvol_pressure_max_rank = getattr(
                params, "rank_rvol_pressure_max_score_rank_pct", None
            )
            if (
                rank_rvol_pressure_min_rvol is not None
                and rank_rvol_pressure_max_rank is not None
                and float(cand.get("rvol") or 0.0) >= float(rank_rvol_pressure_min_rvol)
                and score_rank_pct <= float(rank_rvol_pressure_max_rank)
            ):
                rank_rvol_ret_5d = ticker_enrich.get("ret_5d")
                rank_rvol_max_ret_5d = getattr(
                    params, "rank_rvol_pressure_max_ret_5d", None
                )
                if (
                    rank_rvol_max_ret_5d is None
                    or (
                        rank_rvol_ret_5d is not None
                        and float(rank_rvol_ret_5d) <= float(rank_rvol_max_ret_5d)
                    )
                ):
                    raw_rank_rvol_scale = getattr(
                        params,
                        "rank_rvol_pressure_size_scale",
                        1.0,
                    )
                    rank_rvol_pressure_size_scale = max(
                        0.0,
                        min(
                            1.0,
                            float(
                                1.0
                                if raw_rank_rvol_scale is None
                                else raw_rank_rvol_scale
                            ),
                        ),
                    )
            gap_exhaustion_pressure_size_scale = (
                _orb_gap_exhaustion_pressure_size_scale(
                    params,
                    cand,
                    ticker_enrich,
                    direction_str,
                )
            )
            stale_obv_rvol_pressure_size_scale = (
                _orb_stale_obv_rvol_pressure_size_scale(
                    params,
                    cand,
                    ticker_enrich,
                    score_rank_pct,
                )
            )
            positive_gap_rebound_failure_size_scale = (
                _orb_positive_gap_rebound_failure_size_scale(
                    params,
                    cand,
                    ticker_enrich,
                    trigger_type="reentry",
                    direction_str=direction_str,
                )
            )
            (
                mid_attention_exhaustion_active,
                mid_attention_exhaustion_size_scale,
            ) = _orb_mid_attention_exhaustion_size_scale(
                params,
                cand,
                trigger_type="reentry",
                direction_str=direction_str,
            )
            (
                mid_liquidity_fragility_active,
                mid_liquidity_fragility_size_scale,
            ) = _orb_mid_liquidity_fragility_size_scale(
                params,
                cand,
                ticker_enrich,
                trigger_type="reentry",
                direction_str=direction_str,
            )
            (
                orphan_thin_attention_active,
                orphan_thin_attention_size_scale,
            ) = _orb_orphan_thin_attention_size_scale(
                params,
                cand,
                trigger_type="reentry",
                direction_str=direction_str,
            )
            (
                gap_up_fill_trap_active,
                gap_up_fill_trap_size_scale,
            ) = _orb_gap_up_fill_trap_size_scale(
                params,
                cand,
                trigger_type="reentry",
                direction_str=direction_str,
            )
            (
                low_candidate_quality_active,
                low_candidate_quality_size_scale,
            ) = _orb_low_candidate_quality_size_scale(
                params,
                cand,
                trigger_type="reentry",
                direction_str=direction_str,
            )
            sector_confirmation_size_scale = _orb_sector_confirmation_size_scale(
                params,
                cand,
            )
            defensive_risk_size_scales = (
                crowded_gap_size_scale,
                countertrend_gap_size_scale,
                distressed_reclaim_size_scale,
                hot_reclaim_size_scale,
                weak_downside_reclaim_size_scale,
                quiet_downside_reclaim_size_scale,
                stalled_gap_up_size_scale,
                liquid_stalled_gap_up_size_scale,
                stale_obv_reversal_size_scale,
                rank_rvol_pressure_size_scale,
                gap_exhaustion_pressure_size_scale,
                stale_obv_rvol_pressure_size_scale,
                positive_gap_rebound_failure_size_scale,
                mid_attention_exhaustion_size_scale,
                mid_liquidity_fragility_size_scale,
                orphan_thin_attention_size_scale,
                gap_up_fill_trap_size_scale,
                low_candidate_quality_size_scale,
                min(sector_confirmation_size_scale, 1.0),
            )
            red_to_green_acceleration_size_scale = (
                _orb_red_to_green_acceleration_size_scale(
                    params,
                    cand,
                    ticker_enrich,
                    score_rank_pct,
                    direction_str,
                    defensive_risk_size_scales,
                )
            )
            liquid_leader_conviction_size_scale = (
                _orb_liquid_leader_conviction_size_scale(
                    params,
                    cand,
                    score_rank_pct,
                    "reentry",
                    direction_str,
                    defensive_risk_size_scales,
                )
            )
            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=candidate_trade_params,
                equity=equity,
                date_str=date_str,
                ticker=cand["ticker"],
                available_cash=remaining_cash,
                sizing_capital=(
                    adjusted_sizing
                    * min(crowded_gap_size_scale, 1.0)
                    * min(countertrend_gap_size_scale, 1.0)
                    * min(distressed_reclaim_size_scale, 1.0)
                    * min(hot_reclaim_size_scale, 1.0)
                    * min(weak_downside_reclaim_size_scale, 1.0)
                    * min(quiet_downside_reclaim_size_scale, 1.0)
                    * min(stale_obv_reversal_size_scale, 1.0)
                    * min(stalled_gap_up_size_scale, 1.0)
                    * min(liquid_stalled_gap_up_size_scale, 1.0)
                    * min(rank_rvol_pressure_size_scale, 1.0)
                    * min(gap_exhaustion_pressure_size_scale, 1.0)
                    * min(stale_obv_rvol_pressure_size_scale, 1.0)
                    * min(positive_gap_rebound_failure_size_scale, 1.0)
                    * min(mid_attention_exhaustion_size_scale, 1.0)
                    * min(mid_liquidity_fragility_size_scale, 1.0)
                    * min(orphan_thin_attention_size_scale, 1.0)
                    * min(gap_up_fill_trap_size_scale, 1.0)
                    * min(low_candidate_quality_size_scale, 1.0)
                    * red_to_green_acceleration_size_scale
                    * liquid_leader_conviction_size_scale
                    * sector_confirmation_size_scale
                ),
                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,
                pyramid_allowed=not (
                    bool(getattr(params, "pyramid_require_sector_confirmation", False))
                    and not bool(cand.get("sector_confirmation_active"))
                ),
                candidate_score=_float_or_none(cand.get("score")),
            )

            if reentry_trade is not None:
                reentry_trade.is_reentry = True
                _attach_orb_candidate_diagnostics(
                    reentry_trade, cand, ticker_enrich, score_rank_pct
                )
                reentry_trade.rank_rvol_pressure_size_scale = (
                    round(rank_rvol_pressure_size_scale, 4)
                    if rank_rvol_pressure_size_scale < 1.0
                    else None
                )
                reentry_trade.gap_exhaustion_pressure_size_scale = (
                    round(gap_exhaustion_pressure_size_scale, 4)
                    if gap_exhaustion_pressure_size_scale < 1.0
                    else None
                )
                reentry_trade.stale_obv_rvol_pressure_size_scale = (
                    round(stale_obv_rvol_pressure_size_scale, 4)
                    if stale_obv_rvol_pressure_size_scale < 1.0
                    else None
                )
                reentry_trade.positive_gap_rebound_failure_size_scale = (
                    round(positive_gap_rebound_failure_size_scale, 4)
                    if positive_gap_rebound_failure_size_scale < 1.0
                    else None
                )
                reentry_trade.mid_attention_exhaustion_active = (
                    mid_attention_exhaustion_active
                )
                reentry_trade.mid_attention_exhaustion_size_scale = (
                    round(mid_attention_exhaustion_size_scale, 4)
                    if mid_attention_exhaustion_size_scale < 1.0
                    else None
                )
                reentry_trade.mid_liquidity_fragility_active = (
                    mid_liquidity_fragility_active
                )
                reentry_trade.mid_liquidity_fragility_size_scale = (
                    round(mid_liquidity_fragility_size_scale, 4)
                    if mid_liquidity_fragility_size_scale < 1.0
                    else None
                )
                reentry_trade.orphan_thin_attention_active = (
                    orphan_thin_attention_active
                )
                reentry_trade.orphan_thin_attention_size_scale = (
                    round(orphan_thin_attention_size_scale, 4)
                    if orphan_thin_attention_size_scale < 1.0
                    else None
                )
                reentry_trade.gap_up_fill_trap_active = gap_up_fill_trap_active
                reentry_trade.gap_up_fill_trap_size_scale = (
                    round(gap_up_fill_trap_size_scale, 4)
                    if gap_up_fill_trap_size_scale < 1.0
                    else None
                )
                reentry_trade.low_candidate_quality_active = (
                    low_candidate_quality_active
                )
                reentry_trade.low_candidate_quality_size_scale = (
                    round(low_candidate_quality_size_scale, 4)
                    if low_candidate_quality_size_scale < 1.0
                    else None
                )
                reentry_trade.red_to_green_acceleration_size_scale = (
                    round(red_to_green_acceleration_size_scale, 4)
                    if red_to_green_acceleration_size_scale > 1.0
                    else None
                )
                reentry_trade.liquid_leader_conviction_size_scale = (
                    round(liquid_leader_conviction_size_scale, 4)
                    if liquid_leader_conviction_size_scale > 1.0
                    else None
                )
                reentry_trade.sector_confirmation_size_scale = (
                    round(sector_confirmation_size_scale, 4)
                    if sector_confirmation_size_scale != 1.0
                    else None
                )
                reentry_trade.soft_day_trade = is_soft_day
                reentry_trade.soft_day_reason = result.soft_day_reason
                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,
    next_trading_day_after_window: 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).
    # daily_budget_reset is a research mode with a fresh fixed buying-power
    # budget each day, so it must not inherit accumulated settled cash.
    # settled_cash: funds available for new day-trade positions (GFV-safe)
    # pending_settlements: (settlement_date_str, amount) — proceeds awaiting settlement
    daily_cash_reset = bool(params.daily_budget_reset)
    settlement_enabled = params.settlement_days > 0 and not daily_cash_reset
    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 []
    )
    pending_idle_sleeve_positions: list[dict] = (
        list(state.pending_idle_sleeve_positions) 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 = state.peak_equity if state is not None and state.peak_equity is not None else equity

    # Streak sizing: track recent trade outcomes for streak-based sizing
    streak_outcomes: list[bool] = list(state.streak_outcomes) if state is not None else []

    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

        idle_exit_trades: list[IntradayTrade] = []
        idle_exit_pnl = 0.0
        if getattr(params, "orb_idle_sleeve_enabled", False) and pending_idle_sleeve_positions:
            idle_exit_trades, pending_idle_sleeve_positions = _close_idle_sleeve_positions(
                pending_idle_sleeve_positions,
                bars_by_ticker,
                date_str,
                params,
                enrichment,
            )
            if idle_exit_trades:
                idle_exit_pnl = sum(trade.pnl for trade in idle_exit_trades)
                equity += idle_exit_pnl
                equity = max(equity, 1.0)
                peak_equity = max(peak_equity, equity)
                if settlement_enabled:
                    for trade in idle_exit_trades:
                        proceeds = (trade.total_capital_deployed or 0.0) + trade.pnl
                        if proceeds <= 0:
                            continue
                        settle_idx = day_idx + params.settlement_days
                        if settle_idx < len(trading_days):
                            pending_settlements.append((trading_days[settle_idx], proceeds))
                        else:
                            settled_cash += proceeds

        # 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:
                    skip_result = DayResult(
                        date=date_str,
                        trades=list(idle_exit_trades),
                        daily_pnl=idle_exit_pnl,
                        skip_reason="rolling_loss",
                    )
                    return_base = (
                        params.initial_capital
                        if (params.daily_budget_reset or not params.compound_returns)
                        else equity
                    )
                    if return_base > 0:
                        skip_result.daily_return_pct = skip_result.daily_pnl / return_base
                    results.append(skip_result)
                    rolling_pnl_window.append(skip_result.daily_pnl)
                    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:
                    skip_result = DayResult(
                        date=date_str,
                        trades=list(idle_exit_trades),
                        daily_pnl=idle_exit_pnl,
                        skip_reason="spy_trend",
                    )
                    return_base = (
                        params.initial_capital
                        if (params.daily_budget_reset or not params.compound_returns)
                        else equity
                    )
                    if return_base > 0:
                        skip_result.daily_return_pct = skip_result.daily_pnl / return_base
                    results.append(skip_result)
                    rolling_pnl_window.append(skip_result.daily_pnl)
                    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 = (
            params.initial_capital
            if daily_cash_reset
            else (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 exceed the configured cap. Historical
        # strategies use initial_capital; compound-specific forks can opt into
        # current equity so the cap grows with the account.
        if (
            params.single_trade_loss_cap_pct is not None
            and params.risk_per_trade_pct > 0
            and params.initial_capital > 0
        ):
            loss_cap_basis = str(
                getattr(params, "single_trade_loss_cap_basis", "initial") or "initial"
            ).lower()
            cap_base = equity if loss_cap_basis == "equity" else params.initial_capital
            max_risk = params.single_trade_loss_cap_pct * cap_base
            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,
        )
        orb_trade_count = len(day_result.trades)
        if idle_exit_trades:
            day_result.trades = list(idle_exit_trades) + day_result.trades
            day_result.daily_pnl += idle_exit_pnl
        # Override daily_return_pct with portfolio-level return.
        # In daily-reset/simple modes, the research budget is fixed, so the
        # return denominator is initial_capital rather than accumulated equity.
        # This keeps Sharpe/loss stats aligned with non-compounded sizing.
        return_base = (
            params.initial_capital
            if (params.daily_budget_reset or not params.compound_returns)
            else equity
        )
        if return_base > 0:
            day_result.daily_return_pct = day_result.daily_pnl / return_base

        idle_same_day_exit = _idle_sleeve_same_day_exit(params)
        next_idle_exit_date = (
            trading_days[day_idx + 1]
            if day_idx + 1 < total_days
            else next_trading_day_after_window
        )
        if (
            getattr(params, "orb_idle_sleeve_enabled", False)
            and orb_trade_count == 0
            and (idle_same_day_exit or next_idle_exit_date is not None)
        ):
            idle_budget_base = (
                params.initial_capital
                if (params.daily_budget_reset or not params.compound_returns)
                else equity
            )
            idle_budget = max(
                0.0,
                idle_budget_base
                * max(0.0, float(getattr(params, "orb_idle_sleeve_total_budget_pct", 0.0) or 0.0)),
            )
            if settlement_enabled:
                idle_budget = min(idle_budget, max(0.0, settled_cash))
            opened_idle_positions = _open_idle_sleeve_positions(
                bars_by_ticker,
                date_str,
                params,
                enrichment,
                idle_budget,
                date_str if idle_same_day_exit else next_idle_exit_date,
            )
            if opened_idle_positions:
                opened_idle_capital = sum(float(pos.get("cost") or 0.0) for pos in opened_idle_positions)
                if day_result.entry_diagnostics is None:
                    day_result.entry_diagnostics = {}
                day_result.entry_diagnostics.update(
                    {
                        "orb_idle_sleeve_positions_opened": len(opened_idle_positions),
                        "orb_idle_sleeve_capital_opened": round(opened_idle_capital, 2),
                        "orb_idle_sleeve_symbols_opened": [
                            str(pos.get("ticker") or "") for pos in opened_idle_positions
                        ],
                        "orb_idle_sleeve_labels_opened": [
                            str(pos.get("sleeve") or "") for pos in opened_idle_positions
                        ],
                    }
                )
                if idle_same_day_exit:
                    same_day_idle_trades, _still_open = _close_idle_sleeve_positions(
                        opened_idle_positions,
                        bars_by_ticker,
                        date_str,
                        params,
                        enrichment,
                    )
                    if same_day_idle_trades:
                        same_day_idle_pnl = sum(trade.pnl for trade in same_day_idle_trades)
                        day_result.trades.extend(same_day_idle_trades)
                        day_result.daily_pnl += same_day_idle_pnl
                        day_result.capital_deployed += sum(
                            float(trade.total_capital_deployed or 0.0)
                            for trade in same_day_idle_trades
                        )
                        if return_base > 0:
                            day_result.daily_return_pct = day_result.daily_pnl / return_base
                else:
                    pending_idle_sleeve_positions.extend(opened_idle_positions)
                    if settlement_enabled:
                        settled_cash -= opened_idle_capital

        results.append(day_result)
        rolling_pnl_for_window = day_result.daily_pnl
        market_veto_penalty_pct = getattr(
            params, "market_orb_quality_veto_rolling_loss_pct", None
        )
        market_quality_vetoed_day = (
            market_veto_penalty_pct is not None
            and len(day_result.trades) == 0
            and day_result.market_orb_quality_scaler is not None
            and day_result.market_orb_quality_scaler <= 0
            and (
                day_result.market_orb_quality_divergence_active
                or day_result.market_orb_quality_primary_weak_secondary_strong_active
                or day_result.market_orb_quality_primary_lag_secondary_lead_active
                or day_result.market_orb_quality_joint_weak_active
                or day_result.market_orb_quality_joint_panic_active
            )
        )
        if market_quality_vetoed_day:
            rolling_pnl_for_window = float(market_veto_penalty_pct) * params.initial_capital
            day_result.rolling_loss_synthetic_pnl = rolling_pnl_for_window
        rolling_pnl_window.append(rolling_pnl_for_window)

        # Update streak outcomes from today's trades. Defensive soft-day fallback
        # sleeves can opt out so they do not contaminate the main allocator state.
        exclude_soft_day_from_streak = (
            day_result.is_soft_day
            and bool(getattr(params, "soft_day_exclude_from_streak", False))
        )
        day_result.soft_day_excluded_from_streak = bool(
            exclude_soft_day_from_streak and day_result.trades
        )
        if not exclude_soft_day_from_streak:
            for trade in day_result.trades:
                if (
                    getattr(trade, "orb_idle_sleeve_overnight", False)
                    or getattr(trade, "trigger_type", None) == "orb_idle_sleeve"
                ):
                    continue
                streak_outcomes.append(trade.pnl > 0)
        if (
            getattr(params, "distressed_reclaim_streak_loss_on_trigger", False)
            and (day_result.candidate_filter_stats or {}).get("distressed_reclaim", 0) > 0
        ):
            streak_outcomes.append(False)
        # 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 - idle_exit_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
        exclude_soft_day_from_settlement = (
            day_result.is_soft_day
            and bool(getattr(params, "soft_day_exclude_from_settlement", False))
        )
        if settlement_enabled and not exclude_soft_day_from_settlement:
            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 - idle_exit_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:
                if (
                    getattr(trade, "orb_idle_sleeve_overnight", False)
                    or getattr(trade, "trigger_type", None) == "orb_idle_sleeve"
                ):
                    continue
                ticker_last_traded[trade.ticker] = current_date

    max_roll = params.rolling_loss_days or 0
    next_state = ORBSimulationState(
        equity=equity,
        peak_equity=peak_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 [],
        streak_outcomes=streak_outcomes[-20:],
        pending_idle_sleeve_positions=list(pending_idle_sleeve_positions),
    )
    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,
    overlay_tickers_per_day: dict[str, set[str]] | 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,
        overlay_tickers_per_day=overlay_tickers_per_day,
    )
    return results
