"""Core intraday simulation engine.

DST-aware (uses zoneinfo America/New_York throughout).
Pure functions — no API calls, no disk I/O.
run_simulation() takes pre-loaded data and returns DayResult list,
making sweep mode trivial (call once per parameter combination).
"""
from __future__ import annotations

import datetime as dt
import math
from zoneinfo import ZoneInfo

from libs.intraday.domain import DayResult, IntradayTrade, StrategyParams


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

SECTOR_PROXY_BY_LABEL: dict[str, str] = {
    "Basic Materials": "XLB",
    "Communication Services": "XLC",
    "Consumer Cyclical": "XLY",
    "Consumer Defensive": "XLP",
    "Consumer Staples": "XLP",
    "Energy": "XLE",
    "Financial Services": "XLF",
    "Financial": "XLF",
    "Healthcare": "XLV",
    "Industrials": "XLI",
    "Real Estate": "XLRE",
    "Technology": "XLK",
    "Utilities": "XLU",
}
SECTOR_PROXY_TICKERS: tuple[str, ...] = tuple(sorted(set(SECTOR_PROXY_BY_LABEL.values())))


# ── Timestamp Parsing ──────────────────────────────────────────────────────


def _parse_ts(ts_str: str) -> dt.datetime:
    """Parse Alpaca ISO 8601 timestamp to timezone-aware ET datetime."""
    s = ts_str.replace("Z", "+00:00")
    return dt.datetime.fromisoformat(s).astimezone(_ET)


# ── Market Hours Filtering ─────────────────────────────────────────────────


def filter_market_hours(bars: list[dict]) -> list[dict]:
    """Return only bars that fall within regular trading hours (9:30-16:00 ET).

    Handles DST transitions correctly via zoneinfo.
    """
    result = []
    for b in bars:
        ts = _parse_ts(b["timestamp"])
        t = ts.time()
        if _MARKET_OPEN <= t < _MARKET_CLOSE:
            result.append(b)
    return result


def _bar_at_offset(
    bars: list[dict],
    market_open_ts: dt.datetime,
    offset_minutes: int,
    tolerance_minutes: int = 7,
) -> dict | None:
    """Find the bar closest to (market_open + offset_minutes).

    Returns None if no bar is within tolerance_minutes of the target.
    """
    target = market_open_ts + dt.timedelta(minutes=offset_minutes)
    best: dict | None = None
    best_diff = float("inf")
    for b in bars:
        ts = _parse_ts(b["timestamp"])
        diff = abs((ts - target).total_seconds())
        if diff < best_diff and diff <= tolerance_minutes * 60:
            best = b
            best_diff = diff
    return best


def _market_open_ts(date_str: str) -> dt.datetime:
    """Return 9:30 AM ET datetime for the given date string."""
    d = dt.date.fromisoformat(date_str)
    naive = dt.datetime.combine(d, _MARKET_OPEN)
    return naive.replace(tzinfo=_ET)


def _volume_up_to_bar(bars: list[dict], entry_ts: dt.datetime) -> float:
    """Sum volume of all bars up to and including entry_ts."""
    total = 0.0
    for b in bars:
        ts = _parse_ts(b["timestamp"])
        if ts <= entry_ts:
            total += b.get("volume", 0) or 0
    return total


def _dollar_volume_up_to_bar(bars: list[dict], entry_ts: dt.datetime) -> float:
    """Sum approximate dollar volume of all bars up to and including entry_ts."""
    total = 0.0
    for b in bars:
        ts = _parse_ts(b["timestamp"])
        if ts <= entry_ts:
            close = b.get("close") or 0.0
            volume = b.get("volume") or 0.0
            total += float(close) * float(volume)
    return total


def _linear_scaler(
    value: float | None,
    low: float | None,
    high: float | None,
    floor: float,
    *,
    invert: bool = False,
) -> float:
    """Piecewise-linear scaler bounded to [floor, 1.0].

    When invert=False, values <= low map to 1.0 and values >= high map to floor.
    When invert=True, values <= low map to floor and values >= high map to 1.0.
    """
    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 _vix_day_scaler(vix_value: float | None, strategy: StrategyParams) -> float:
    return _linear_scaler(
        vix_value,
        strategy.vix_size_scale_low,
        strategy.vix_size_scale_high,
        strategy.vix_size_scale_min,
    )


def _entropy_trade_scaler(entropy_20d: float | None, strategy: StrategyParams) -> float:
    return _linear_scaler(
        entropy_20d,
        strategy.entropy_size_scale_low,
        strategy.entropy_size_scale_high,
        strategy.entropy_size_scale_min,
    )


def _sparse_day_scaler(selected_count: int, strategy: StrategyParams) -> float:
    threshold = strategy.full_size_positions_threshold
    if threshold is None or threshold <= 0:
        return 1.0
    floor = max(0.0, min(1.0, strategy.sparse_day_size_floor))
    if selected_count >= threshold:
        return 1.0
    ratio = selected_count / threshold
    return max(floor, min(1.0, ratio))


def _intraday_regime_failed(
    strategy: StrategyParams,
    spy_bars: list[dict] | None,
    date_str: str,
) -> bool:
    if strategy.market_regime_spy_threshold is None or not spy_bars:
        return False
    spy_mkt = filter_market_hours(spy_bars)
    if len(spy_mkt) < 2:
        return False
    spy_open = spy_mkt[0]["open"]
    spy_entry_bar = _bar_at_offset(
        spy_mkt,
        _market_open_ts(date_str),
        strategy.entry_minutes_after_open,
    )
    if spy_open <= 0 or spy_entry_bar is None:
        return False
    spy_gain = (spy_entry_bar["close"] - spy_open) / spy_open
    return spy_gain < strategy.market_regime_spy_threshold


def _day_regime_scaler(
    strategy: StrategyParams,
    daily_features_by_ticker: dict[str, dict] | None,
) -> tuple[float, str | None]:
    if not any(
        value is not None and value != default
        for value, default in [
            (strategy.market_regime_gap_threshold, None),
            (strategy.regime_size_scale_low, None),
            (strategy.regime_size_scale_high, None),
            (strategy.regime_skip_below, None),
        ]
    ):
        return 1.0, None
    regime_ticker = strategy.market_regime_gap_ticker or "SPY"
    regime_features = (daily_features_by_ticker or {}).get(regime_ticker, {})
    prev_close = regime_features.get("prev_close")
    today_open = regime_features.get("today_open")
    if not prev_close or not today_open or prev_close <= 0:
        return 1.0, None
    regime_gap = (today_open - prev_close) / prev_close
    if strategy.regime_skip_below is not None and regime_gap < strategy.regime_skip_below:
        return 1.0, "market_regime"
    if (
        strategy.regime_size_scale_low is None
        and strategy.market_regime_gap_threshold is not None
        and regime_gap < strategy.market_regime_gap_threshold
    ):
        return 1.0, "market_regime"
    scaler = _linear_scaler(
        regime_gap,
        strategy.regime_size_scale_low,
        strategy.regime_size_scale_high,
        strategy.regime_size_scale_min,
        invert=True,
    )
    return scaler, None


def _day_breadth_scaler(
    strategy: StrategyParams,
    bars_by_ticker: dict[str, list[dict]],
    daily_features_by_ticker: dict[str, dict] | None,
) -> tuple[float, str | None]:
    if not any(
        value is not None and value != default
        for value, default in [
            (strategy.min_candidate_breadth, None),
            (strategy.breadth_size_scale_low, None),
            (strategy.breadth_size_scale_high, None),
            (strategy.breadth_skip_below, None),
        ]
    ):
        return 1.0, None
    pos_gap_count = 0
    total_with_data = 0
    for ticker in bars_by_ticker:
        ticker_day = (daily_features_by_ticker or {}).get(ticker, {})
        prev_close = ticker_day.get("prev_close")
        today_open = ticker_day.get("today_open")
        if prev_close and today_open and prev_close > 0:
            total_with_data += 1
            if today_open > prev_close:
                pos_gap_count += 1
    if total_with_data <= 0:
        return 1.0, None
    breadth_ratio = pos_gap_count / total_with_data
    if strategy.breadth_skip_below is not None and breadth_ratio < strategy.breadth_skip_below:
        return 1.0, "breadth"
    if (
        strategy.breadth_size_scale_low is None
        and strategy.min_candidate_breadth is not None
        and breadth_ratio < strategy.min_candidate_breadth
    ):
        return 1.0, "breadth"
    scaler = _linear_scaler(
        breadth_ratio,
        strategy.breadth_size_scale_low,
        strategy.breadth_size_scale_high,
        strategy.breadth_size_scale_min,
        invert=True,
    )
    return scaler, None


def _basket_sector_scaler(
    picks: list[tuple[str, str]],
    ticker_sectors: dict[str, str] | None,
    strategy: StrategyParams,
) -> float:
    if not picks or not ticker_sectors:
        return 1.0
    counts: dict[str, int] = {}
    known = 0
    for ticker, _sleeve in picks:
        sector = str(ticker_sectors.get(ticker) or "").strip()
        if not sector or sector.upper() == "UNKNOWN":
            continue
        counts[sector] = counts.get(sector, 0) + 1
        known += 1
    if known <= 1 or not counts:
        return 1.0
    concentration = max(counts.values()) / known
    return _linear_scaler(
        concentration,
        strategy.sector_concentration_scale_low,
        strategy.sector_concentration_scale_high,
        strategy.sector_concentration_scale_min,
    )


def _safe_value(value: float | None, *, default: float = 0.0) -> float:
    return default if value is None else float(value)


def _trade_trailing_stop_pct(info: dict, strategy: StrategyParams) -> float | None:
    """Return the per-trade trailing stop, tightening only overextended leaders."""
    trailing_stop_pct = strategy.trailing_stop_pct
    if (
        trailing_stop_pct is None
        or strategy.overextended_trailing_gain_pct is None
        or strategy.overextended_trailing_stop_pct is None
    ):
        return trailing_stop_pct
    gain_pct = info.get("gain_pct")
    if gain_pct is None or gain_pct < strategy.overextended_trailing_gain_pct:
        return trailing_stop_pct
    return strategy.overextended_trailing_stop_pct


def _trade_catastrophic_stop_price(info: dict, strategy: StrategyParams) -> float | None:
    """Return the initial catastrophic stop price for a trade, if any."""
    entry_price_raw = info.get("entry_price_raw")
    if entry_price_raw is None or entry_price_raw <= 0:
        return None
    if strategy.atr_stop_multiplier is not None:
        atr_14 = info.get("atr_14")
        if atr_14 is None or atr_14 <= 0:
            return None
        return max(0.0, float(entry_price_raw) - float(atr_14) * strategy.atr_stop_multiplier)
    if strategy.opening_range_stop_multiplier is not None:
        opening_range_width = info.get("opening_range_width")
        if opening_range_width is None or opening_range_width <= 0:
            return None
        return max(
            0.0,
            float(entry_price_raw) - float(opening_range_width) * strategy.opening_range_stop_multiplier,
        )
    if strategy.stop_loss_pct is not None:
        return max(0.0, float(entry_price_raw) * (1.0 + strategy.stop_loss_pct))
    return None


def _five_sleeve_specs(strategy: StrategyParams) -> list[dict[str, object]]:
    sleeves: list[dict[str, object]] = [
        {
            "label": "core",
            "weight": strategy.five_sleeve_core_weight,
            "key_fn": lambda item: (
                item[1]["gain_pct"],
                item[1].get("volume_ratio_14d", 0.0),
                item[1].get("entry_volume", 0.0),
            ),
            "component": lambda info: info["gain_pct"],
        },
        {
            "label": "gap",
            "weight": strategy.five_sleeve_gap_weight,
            "key_fn": lambda item: (
                _safe_value(item[1].get("gap_pct"), default=-999.0),
                item[1]["gain_pct"],
                item[1].get("volume_ratio_14d", 0.0),
            ),
            "component": lambda info: max(info.get("gap_pct") or 0.0, 0.0),
        },
        {
            "label": "volume",
            "weight": strategy.five_sleeve_volume_weight,
            "key_fn": lambda item: (
                item[1].get("volume_ratio_14d", 0.0),
                item[1].get("entry_volume", 0.0),
                item[1]["gain_pct"],
            ),
            "component": lambda info: info.get("volume_ratio_14d") or 0.0,
        },
        {
            "label": "entropy",
            "weight": strategy.five_sleeve_entropy_weight,
            "key_fn": lambda item: (
                -_safe_value(item[1].get("entropy_20d"), default=1.0),
                item[1]["gain_pct"],
                item[1].get("volume_ratio_14d", 0.0),
            ),
            "component": lambda info: (
                1.0 - info["entropy_20d"] if info.get("entropy_20d") is not None else 0.0
            ),
        },
        {
            "label": "trend",
            "weight": strategy.five_sleeve_trend_weight,
            "key_fn": lambda item: (
                _safe_value(item[1].get("ret_5d"), default=-999.0),
                item[1]["gain_pct"],
                item[1].get("volume_ratio_14d", 0.0),
            ),
            "component": lambda info: max(info.get("ret_5d") or 0.0, 0.0),
        },
    ]
    if strategy.use_event_sleeve and strategy.event_weight > 0:
        sleeves.append(
            {
                "label": "event",
                "weight": strategy.event_weight,
                "key_fn": lambda item: (
                    1 if item[1].get("is_event_candidate") else 0,
                    item[1].get("event_score", 0.0),
                    item[1].get("confirmation_return_pct", -999.0),
                    item[1].get("entry_dollar_volume", 0.0),
                    item[1].get("gain_pct", 0.0),
                ),
                "component": lambda info: (
                    (
                        min(max(info.get("event_score") or 0.0, 0.0), 3.0)
                        + max((info.get("confirmation_return_pct") or 0.0) * 20.0, 0.0)
                        + min(
                            max((info.get("entry_dollar_volume") or 0.0) / 250_000_000.0, 0.0),
                            4.0,
                        )
                        + max((info.get("gain_pct") or 0.0) * 10.0, 0.0)
                    )
                    if info.get("is_event_candidate")
                    else 0.0
                ),
            }
        )
    if strategy.use_slow_ignite_sleeve and strategy.slow_ignite_weight > 0:
        sleeves.append(
            {
                "label": "slow_ignite",
                "weight": strategy.slow_ignite_weight,
                "key_fn": lambda item: (
                    1 if item[1].get("is_slow_ignite") else 0,
                    item[1].get("confirmation_return_pct", -999.0),
                    item[1].get("volume_ratio_14d", 0.0),
                    _safe_value(item[1].get("ret_5d"), default=-999.0),
                    item[1].get("entry_dollar_volume", 0.0),
                ),
                "component": lambda info: (
                    (
                        max(info.get("confirmation_return_pct") or 0.0, 0.0) * 5.0
                        + max(min(info.get("volume_ratio_14d") or 0.0, 0.5), 0.0)
                        + max(min(info.get("ret_5d") or 0.0, 0.2), 0.0)
                    )
                    if info.get("is_slow_ignite")
                    else 0.0
                ),
            }
        )
    if strategy.use_liquid_largecap_sleeve and strategy.liquid_largecap_weight > 0:
        sleeves.append(
            {
                "label": "liquid_largecap",
                "weight": strategy.liquid_largecap_weight,
                "key_fn": lambda item: (
                    1 if item[1].get("is_liquid_largecap") else 0,
                    item[1].get("entry_dollar_volume", 0.0),
                    item[1].get("confirmation_return_pct", -999.0),
                    item[1].get("gain_pct", 0.0),
                    item[1].get("avg_dollar_vol_30d", 0.0),
                ),
                "component": lambda info: (
                    (
                        min(max((info.get("entry_dollar_volume") or 0.0) / 500_000_000.0, 0.0), 4.0)
                        + max((info.get("confirmation_return_pct") or 0.0) * 10.0, 0.0)
                        + max((info.get("gain_pct") or 0.0) * 10.0, 0.0)
                        + min(max((info.get("avg_dollar_vol_30d") or 0.0) / 1_000_000_000.0, 0.0), 3.0)
                    )
                    if info.get("is_liquid_largecap")
                    else 0.0
                ),
            }
        )
    if strategy.use_moderate_gap_liquid_sleeve and strategy.moderate_gap_liquid_weight > 0:
        sleeves.append(
            {
                "label": "moderate_gap_liquid",
                "weight": strategy.moderate_gap_liquid_weight,
                "key_fn": lambda item: (
                    1 if item[1].get("is_moderate_gap_liquid") else 0,
                    item[1].get("confirmation_return_pct", -999.0),
                    item[1].get("entry_dollar_volume", 0.0),
                    item[1].get("gain_pct", 0.0),
                    -_safe_value(item[1].get("entropy_20d"), default=1.0),
                    item[1].get("avg_dollar_vol_30d", 0.0),
                ),
                "component": lambda info: (
                    (
                        max((info.get("confirmation_return_pct") or 0.0) * 15.0, 0.0)
                        + max((info.get("gain_pct") or 0.0) * 8.0, 0.0)
                        + min(max((info.get("entry_dollar_volume") or 0.0) / 150_000_000.0, 0.0), 3.0)
                        + min(max((info.get("avg_dollar_vol_30d") or 0.0) / 750_000_000.0, 0.0), 2.5)
                        + max(1.0 - float(info.get("entropy_20d") or 1.0), 0.0)
                    )
                    if info.get("is_moderate_gap_liquid")
                    else 0.0
                ),
            }
        )
    if strategy.use_sector_thrust_sleeve and strategy.sector_thrust_weight > 0:
        sleeves.append(
            {
                "label": "sector_thrust",
                "weight": strategy.sector_thrust_weight,
                "key_fn": lambda item: (
                    1 if item[1].get("is_sector_thrust") else 0,
                    item[1].get("sector_thrust_score", 0.0),
                    item[1].get("sector_thrust_member_count", 0),
                    item[1].get("entry_dollar_volume", 0.0),
                    item[1].get("confirmation_return_pct", -999.0),
                    item[1].get("gain_pct", 0.0),
                ),
                "component": lambda info: (
                    float(info.get("sector_thrust_score") or 0.0)
                    if info.get("is_sector_thrust")
                    else 0.0
                ),
            }
        )
    if strategy.use_gap_reclaim_sleeve and strategy.gap_reclaim_weight > 0:
        sleeves.append(
            {
                "label": "gap_reclaim",
                "weight": strategy.gap_reclaim_weight,
                "key_fn": lambda item: (
                    1 if item[1].get("is_gap_reclaim") else 0,
                    item[1].get("confirmation_return_pct", -999.0),
                    item[1].get("recovery_from_opening_low_pct", 0.0),
                    item[1].get("entry_dollar_volume", 0.0),
                    item[1].get("gap_pct", 0.0),
                ),
                "component": lambda info: (
                    (
                        max((info.get("confirmation_return_pct") or 0.0) * 10.0, 0.0)
                        + max((info.get("recovery_from_opening_low_pct") or 0.0) * 20.0, 0.0)
                        + min(max((info.get("entry_dollar_volume") or 0.0) / 250_000_000.0, 0.0), 4.0)
                        + min(max((info.get("gap_pct") or 0.0) * 5.0, 0.0), 2.0)
                    )
                    if info.get("is_gap_reclaim")
                    else 0.0
                ),
            }
        )
    return sleeves


def _momentum_quality_score(info: dict, strategy: StrategyParams) -> float:
    if strategy.use_five_sleeves:
        score = 0.0
        for sleeve in _five_sleeve_specs(strategy):
            weight = float(sleeve["weight"])
            if weight <= 0:
                continue
            score += weight * float(sleeve["component"](info))
        return score
    return (
        max(float(info.get("gain_pct") or 0.0), 0.0) * 5.0
        + max(float(info.get("confirmation_return_pct") or 0.0), 0.0) * 10.0
        + min(max(float(info.get("volume_ratio_14d") or 0.0), 0.0), 0.5)
        + min(max(float(info.get("entry_dollar_volume") or 0.0) / 50_000_000.0, 0.0), 4.0)
        + max(float(info.get("ret_5d") or 0.0), 0.0)
        + max(1.0 - float(info.get("entropy_20d") or 1.0), 0.0)
    )


def _clip_unit_score(value: float | None, cap: float) -> float:
    if value is None or cap <= 0:
        return 0.0
    return min(max(float(value), 0.0), cap) / cap


def _clip_log_score(value: float | None, low: float, high: float) -> float:
    if value is None or value <= 0 or low <= 0 or high <= low:
        return 0.0
    scaled = (math.log10(float(value)) - math.log10(low)) / (
        math.log10(high) - math.log10(low)
    )
    return min(max(scaled, 0.0), 1.0)


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 _same_day_support_score(info: dict) -> float:
    """Blend liquidity and same-day attention into one support score.

    This is intentionally conservative: a thin single-name move only receives
    meaningful support when both prior liquidity and entry-time liquidity are
    decent, or when there is unusually strong same-day attention.
    """
    if bool(info.get("is_liquid_largecap")):
        return 1.0

    prior_liquidity = _clip_log_score(
        info.get("avg_dollar_vol_30d"),
        30_000_000.0,
        300_000_000.0,
    )
    entry_liquidity = _clip_log_score(
        info.get("entry_dollar_volume"),
        5_000_000.0,
        50_000_000.0,
    )
    liquidity_support = min(prior_liquidity, entry_liquidity)

    attention_support = max(
        _clip_unit_score(info.get("attention_wiki_spike_10d"), 5.0),
        _clip_unit_score(
            max(
                int(info.get("attention_article_count_3d") or 0),
                int(info.get("attention_us_article_count_3d") or 0),
            ),
            3.0,
        ),
    )
    catalyst_support = _clip_unit_score(info.get("event_score"), 1.25) * 0.25
    return max(liquidity_support, attention_support, catalyst_support)


def sector_proxy_ticker_for_sector(sector: str | None) -> str | None:
    if sector is None:
        return None
    normalized = str(sector).strip()
    if not normalized or normalized.upper() == "UNKNOWN":
        return None
    return SECTOR_PROXY_BY_LABEL.get(normalized)


def _liquid_cluster_overlay_enabled(strategy: StrategyParams) -> bool:
    return bool(strategy.use_liquid_cluster_engine or strategy.use_sector_etf_sleeve)


def _event_day_liquid_overlay_enabled(strategy: StrategyParams) -> bool:
    return bool(strategy.use_event_day_liquid_sleeve)


def _event_day_liquid_activation_stats(
    morning_gains: dict[str, dict],
    strategy: StrategyParams,
) -> dict[str, float | int | bool]:
    contributors: list[dict] = []
    min_event_score = getattr(strategy, "event_day_liquid_min_event_score", None)
    min_support_score = getattr(strategy, "event_day_liquid_min_event_support_score", None)
    allowed_event_types = {
        str(value).strip().lower()
        for value in getattr(strategy, "event_day_liquid_allowed_event_types", [])
        if str(value).strip()
    }

    for info in morning_gains.values():
        raw_event_flag = bool(info.get("raw_event_flag", info.get("event_flag")))
        if not raw_event_flag:
            continue
        event_score = float(info.get("raw_event_score", info.get("event_score")) or 0.0)
        if allowed_event_types:
            event_types = {
                str(value).strip().lower()
                for value in (info.get("event_types") or [])
                if str(value).strip()
            }
            if not event_types or not any(event_type in allowed_event_types for event_type in event_types):
                continue
        elif not bool(info.get("event_flag")):
            continue
        if min_event_score is not None and event_score < float(min_event_score):
            continue
        support_score = _same_day_support_score(info)
        if min_support_score is not None and support_score < float(min_support_score):
            continue
        contributors.append(
            {
                "event_score": event_score,
                "support_score": support_score,
                "entry_dollar_volume": float(info.get("entry_dollar_volume") or 0.0),
            }
        )

    if not contributors:
        return {
            "qualifies": False,
            "event_count": 0,
            "max_event_score": 0.0,
            "max_support_score": 0.0,
            "total_entry_dollar_volume": 0.0,
        }

    event_count = len(contributors)
    total_entry_dollar_volume = sum(item["entry_dollar_volume"] for item in contributors)
    qualifies = event_count >= max(1, int(strategy.event_day_liquid_min_event_names or 1))
    if (
        qualifies
        and strategy.event_day_liquid_min_total_event_entry_dollar_volume is not None
        and total_entry_dollar_volume < float(strategy.event_day_liquid_min_total_event_entry_dollar_volume)
    ):
        qualifies = False
    return {
        "qualifies": qualifies,
        "event_count": event_count,
        "max_event_score": max(item["event_score"] for item in contributors),
        "max_support_score": max(item["support_score"] for item in contributors),
        "total_entry_dollar_volume": total_entry_dollar_volume,
    }


def _event_day_liquid_pick_score(
    info: dict,
) -> tuple[float, float, float, float, float, float, float]:
    entropy_20d = info.get("entropy_20d")
    return (
        1.0 if info.get("is_moderate_gap_liquid") else 0.0,
        1.0 if info.get("is_liquid_largecap") else 0.0,
        _same_day_support_score(info),
        float(info.get("confirmation_return_pct") or 0.0),
        float(info.get("entry_dollar_volume") or 0.0),
        float(info.get("avg_dollar_vol_30d") or 0.0),
        -(float(entropy_20d) if entropy_20d is not None else 1.0),
    )


def _passes_liquid_cluster_own_gate(
    strategy: StrategyParams,
    *,
    gain_pct: float,
    confirmation_return_pct: float | None,
    entry_dollar_volume: float,
    avg_dollar_vol_30d: float | None,
    volume_ratio_14d: float | None,
    entropy_20d: float | None,
    is_moderate_gap_liquid: bool,
    is_liquid_largecap: bool,
) -> bool:
    if (
        strategy.liquid_cluster_require_special_liquidity_gate
        and not (is_moderate_gap_liquid or is_liquid_largecap)
    ):
        return False
    if (
        strategy.liquid_cluster_min_gain_pct is not None
        and gain_pct < strategy.liquid_cluster_min_gain_pct
    ):
        return False
    if (
        strategy.liquid_cluster_max_gain_pct is not None
        and gain_pct > strategy.liquid_cluster_max_gain_pct
    ):
        return False
    if (
        strategy.liquid_cluster_min_confirmation_return_pct is not None
        and (
            confirmation_return_pct is None
            or confirmation_return_pct < strategy.liquid_cluster_min_confirmation_return_pct
        )
    ):
        return False
    if (
        strategy.liquid_cluster_min_entry_dollar_volume is not None
        and entry_dollar_volume < strategy.liquid_cluster_min_entry_dollar_volume
    ):
        return False
    if (
        strategy.liquid_cluster_min_avg_dollar_vol_30d is not None
        and (
            avg_dollar_vol_30d is None
            or avg_dollar_vol_30d < strategy.liquid_cluster_min_avg_dollar_vol_30d
        )
    ):
        return False
    if (
        strategy.liquid_cluster_max_avg_dollar_vol_30d is not None
        and (
            avg_dollar_vol_30d is None
            or avg_dollar_vol_30d > strategy.liquid_cluster_max_avg_dollar_vol_30d
        )
    ):
        return False
    if (
        strategy.liquid_cluster_min_volume_ratio_14d is not None
        and (
            volume_ratio_14d is None
            or volume_ratio_14d < strategy.liquid_cluster_min_volume_ratio_14d
        )
    ):
        return False
    if (
        strategy.liquid_cluster_max_entropy_20d is not None
        and (
            entropy_20d is None
            or entropy_20d > strategy.liquid_cluster_max_entropy_20d
        )
    ):
        return False
    return True


def _annotate_sector_thrust_features(
    morning_gains: dict[str, dict],
    strategy: StrategyParams,
    ticker_sectors: dict[str, str] | None,
) -> dict[str, dict]:
    """Annotate PEAD-style sector breadth-confirmation features."""
    annotated = {ticker: dict(info) for ticker, info in morning_gains.items()}
    for info in annotated.values():
        info.setdefault("is_sector_thrust", False)
        info.setdefault("sector_thrust_member_count", 0)
        info.setdefault("sector_thrust_total_entry_dollar_volume", 0.0)
        info.setdefault("sector_thrust_avg_confirmation_return_pct", 0.0)
        info.setdefault("sector_thrust_score", 0.0)

    if not annotated or not strategy.use_sector_thrust_sleeve or not ticker_sectors:
        return annotated

    def _sector_for_ticker(ticker: str) -> str | None:
        sector = str(ticker_sectors.get(ticker) or "").strip()
        if not sector or sector.upper() == "UNKNOWN":
            return None
        return sector

    def _passes_own_gate(info: dict) -> bool:
        gain_pct = float(info.get("gain_pct") or 0.0)
        confirmation_return_pct = float(info.get("confirmation_return_pct") or 0.0)
        entry_dollar_volume = float(info.get("entry_dollar_volume") or 0.0)
        avg_dollar_vol_30d = float(info.get("avg_dollar_vol_30d") or 0.0)
        if (
            strategy.sector_thrust_min_gain_pct is not None
            and gain_pct < strategy.sector_thrust_min_gain_pct
        ):
            return False
        if (
            strategy.sector_thrust_min_confirmation_return_pct is not None
            and confirmation_return_pct < strategy.sector_thrust_min_confirmation_return_pct
        ):
            return False
        if (
            strategy.sector_thrust_min_entry_dollar_volume is not None
            and entry_dollar_volume < strategy.sector_thrust_min_entry_dollar_volume
        ):
            return False
        if (
            strategy.sector_thrust_min_avg_dollar_vol_30d is not None
            and avg_dollar_vol_30d < strategy.sector_thrust_min_avg_dollar_vol_30d
        ):
            return False
        return True

    sector_members: dict[str, list[tuple[str, dict]]] = {}
    for ticker, info in annotated.items():
        sector = _sector_for_ticker(ticker)
        if sector is None or not _passes_own_gate(info):
            continue
        sector_members.setdefault(sector, []).append((ticker, info))

    min_members = max(1, int(strategy.sector_thrust_min_members or 1))
    sector_stats: dict[str, dict[str, float | bool | set[str]]] = {}
    for sector, members in sector_members.items():
        member_count = len(members)
        avg_confirmation = sum(
            float(info.get("confirmation_return_pct") or 0.0)
            for _ticker, info in members
        ) / member_count
        total_entry_dollar_volume = sum(
            float(info.get("entry_dollar_volume") or 0.0)
            for _ticker, info in members
        )
        qualifies = member_count >= min_members
        if (
            strategy.sector_thrust_min_sector_avg_confirmation_return_pct is not None
            and avg_confirmation < strategy.sector_thrust_min_sector_avg_confirmation_return_pct
        ):
            qualifies = False
        if (
            strategy.sector_thrust_min_sector_total_entry_dollar_volume is not None
            and total_entry_dollar_volume < strategy.sector_thrust_min_sector_total_entry_dollar_volume
        ):
            qualifies = False
        sector_stats[sector] = {
            "member_count": float(member_count),
            "avg_confirmation_return_pct": avg_confirmation,
            "total_entry_dollar_volume": total_entry_dollar_volume,
            "qualifies": qualifies,
            "tickers": {ticker for ticker, _info in members},
        }

    for ticker, info in annotated.items():
        sector = _sector_for_ticker(ticker)
        if sector is None:
            continue
        stats = sector_stats.get(sector)
        if not stats:
            continue
        info["sector_thrust_member_count"] = int(stats["member_count"])
        info["sector_thrust_total_entry_dollar_volume"] = float(
            stats["total_entry_dollar_volume"]
        )
        info["sector_thrust_avg_confirmation_return_pct"] = float(
            stats["avg_confirmation_return_pct"]
        )
        if not bool(stats["qualifies"]) or ticker not in stats["tickers"]:
            continue
        count_score = _clip_unit_score(stats["member_count"], 5.0)
        avg_confirmation_score = _clip_unit_score(
            stats["avg_confirmation_return_pct"],
            0.015,
        )
        total_entry_dollar_volume_score = _clip_log_score(
            stats["total_entry_dollar_volume"],
            50_000_000.0,
            2_000_000_000.0,
        )
        own_confirmation_score = _clip_unit_score(
            info.get("confirmation_return_pct"),
            0.02,
        )
        own_entry_dollar_volume_score = _clip_log_score(
            info.get("entry_dollar_volume"),
            10_000_000.0,
            500_000_000.0,
        )
        info["is_sector_thrust"] = True
        info["sector_thrust_score"] = (
            0.30 * count_score
            + 0.20 * avg_confirmation_score
            + 0.20 * total_entry_dollar_volume_score
            + 0.20 * own_confirmation_score
            + 0.10 * own_entry_dollar_volume_score
        )
    return annotated


def _annotate_liquid_cluster_features(
    morning_gains: dict[str, dict],
    strategy: StrategyParams,
    ticker_sectors: dict[str, str] | None,
) -> tuple[dict[str, dict], dict[str, dict[str, object]]]:
    """Annotate separate post-allocation liquid-cluster overlay features."""
    annotated = {ticker: dict(info) for ticker, info in morning_gains.items()}
    for info in annotated.values():
        info.setdefault("is_liquid_cluster", False)
        info.setdefault("liquid_cluster_member_count", 0)
        info.setdefault("liquid_cluster_total_entry_dollar_volume", 0.0)
        info.setdefault("liquid_cluster_sector", None)
        info.setdefault("liquid_cluster_sector_score", 0.0)
        info.setdefault("liquid_cluster_score", 0.0)
        info.setdefault("sector_proxy_ticker", None)

    if not annotated or not ticker_sectors or not _liquid_cluster_overlay_enabled(strategy):
        return annotated, {}

    def _sector_for_ticker(ticker: str) -> str | None:
        sector = str(ticker_sectors.get(ticker) or "").strip()
        if not sector or sector.upper() == "UNKNOWN":
            return None
        return sector

    def _passes_own_gate(info: dict) -> bool:
        return _passes_liquid_cluster_own_gate(
            strategy,
            gain_pct=float(info.get("gain_pct") or 0.0),
            confirmation_return_pct=info.get("confirmation_return_pct"),
            entry_dollar_volume=float(info.get("entry_dollar_volume") or 0.0),
            avg_dollar_vol_30d=info.get("avg_dollar_vol_30d"),
            volume_ratio_14d=info.get("volume_ratio_14d"),
            entropy_20d=info.get("entropy_20d"),
            is_moderate_gap_liquid=bool(info.get("is_moderate_gap_liquid")),
            is_liquid_largecap=bool(info.get("is_liquid_largecap")),
        )

    sector_members: dict[str, list[tuple[str, dict]]] = {}
    for ticker, info in annotated.items():
        sector = _sector_for_ticker(ticker)
        if sector is None or not _passes_own_gate(info):
            continue
        sector_members.setdefault(sector, []).append((ticker, info))

    min_members = max(1, int(strategy.liquid_cluster_min_members or 1))
    sector_stats: dict[str, dict[str, object]] = {}
    for sector, members in sector_members.items():
        member_count = len(members)
        avg_confirmation = sum(
            float(info.get("confirmation_return_pct") or 0.0)
            for _ticker, info in members
        ) / member_count
        total_entry_dollar_volume = sum(
            float(info.get("entry_dollar_volume") or 0.0)
            for _ticker, info in members
        )
        qualifies = member_count >= min_members
        if (
            strategy.liquid_cluster_min_sector_avg_confirmation_return_pct is not None
            and avg_confirmation < strategy.liquid_cluster_min_sector_avg_confirmation_return_pct
        ):
            qualifies = False
        if (
            strategy.liquid_cluster_min_sector_total_entry_dollar_volume is not None
            and total_entry_dollar_volume < strategy.liquid_cluster_min_sector_total_entry_dollar_volume
        ):
            qualifies = False

        count_score = _clip_unit_score(member_count, 5.0)
        avg_confirmation_score = _clip_unit_score(avg_confirmation, 0.015)
        total_entry_dollar_volume_score = _clip_log_score(
            total_entry_dollar_volume,
            100_000_000.0,
            5_000_000_000.0,
        )
        sector_score = (
            0.35 * count_score
            + 0.30 * avg_confirmation_score
            + 0.35 * total_entry_dollar_volume_score
        )
        sector_stats[sector] = {
            "member_count": float(member_count),
            "avg_confirmation_return_pct": avg_confirmation,
            "total_entry_dollar_volume": total_entry_dollar_volume,
            "qualifies": qualifies,
            "sector_score": sector_score,
            "proxy_ticker": sector_proxy_ticker_for_sector(sector),
            "tickers": {ticker for ticker, _info in members},
        }

    for ticker, info in annotated.items():
        sector = _sector_for_ticker(ticker)
        if sector is None:
            continue
        stats = sector_stats.get(sector)
        if not stats:
            continue
        info["liquid_cluster_member_count"] = int(stats["member_count"])
        info["liquid_cluster_total_entry_dollar_volume"] = float(
            stats["total_entry_dollar_volume"]
        )
        info["liquid_cluster_sector"] = sector
        info["liquid_cluster_sector_score"] = float(stats["sector_score"])
        info["sector_proxy_ticker"] = stats["proxy_ticker"]
        if not bool(stats["qualifies"]) or ticker not in stats["tickers"]:
            continue

        own_confirmation_score = _clip_unit_score(
            info.get("confirmation_return_pct"),
            0.02,
        )
        own_entry_dollar_volume_score = _clip_log_score(
            info.get("entry_dollar_volume"),
            10_000_000.0,
            1_000_000_000.0,
        )
        own_avg_dollar_vol_score = _clip_log_score(
            info.get("avg_dollar_vol_30d"),
            100_000_000.0,
            10_000_000_000.0,
        )
        own_gain_score = _clip_unit_score(
            info.get("gain_pct"),
            0.05,
        )
        info["is_liquid_cluster"] = True
        info["liquid_cluster_score"] = (
            0.45 * float(stats["sector_score"])
            + 0.20 * own_confirmation_score
            + 0.15 * own_entry_dollar_volume_score
            + 0.15 * own_avg_dollar_vol_score
            + 0.05 * own_gain_score
        )
    return annotated, sector_stats


def _basket_quality_stats(
    picks: list[tuple[str, str]],
    morning_gains: dict[str, dict],
    strategy: StrategyParams,
) -> dict[str, float]:
    if not picks:
        return {
            "count": 0.0,
            "avg_quality": 0.0,
            "best_quality": 0.0,
            "event_count": 0.0,
            "strong_event_count": 0.0,
            "liquid_largecap_count": 0.0,
            "moderate_gap_liquid_count": 0.0,
            "max_gain_pct": 0.0,
            "avg_support": 0.0,
            "max_entropy_20d": 0.0,
            "max_confirmation_return_pct": 0.0,
        }
    qualities: list[float] = []
    support_scores: list[float] = []
    event_count = 0
    strong_event_count = 0
    liquid_largecap_count = 0
    moderate_gap_liquid_count = 0
    max_gain_pct = 0.0
    max_entropy_20d = 0.0
    max_confirmation_return_pct = 0.0
    for ticker, _sleeve in picks:
        info = morning_gains.get(ticker, {})
        qualities.append(_momentum_quality_score(info, strategy))
        support_score = _same_day_support_score(info)
        support_scores.append(support_score)
        if info.get("is_event_candidate"):
            event_count += 1
            min_event_support = strategy.tail_risk_day_event_exemption_min_support_score
            if min_event_support is None or support_score >= min_event_support:
                strong_event_count += 1
        if info.get("is_liquid_largecap"):
            liquid_largecap_count += 1
        if info.get("is_moderate_gap_liquid"):
            moderate_gap_liquid_count += 1
        max_gain_pct = max(max_gain_pct, float(info.get("gain_pct") or 0.0))
        max_entropy_20d = max(max_entropy_20d, float(info.get("entropy_20d") or 0.0))
        max_confirmation_return_pct = max(
            max_confirmation_return_pct,
            float(info.get("confirmation_return_pct") or 0.0),
        )
    return {
        "count": float(len(picks)),
        "avg_quality": sum(qualities) / len(qualities),
        "best_quality": max(qualities),
        "event_count": float(event_count),
        "strong_event_count": float(strong_event_count),
        "liquid_largecap_count": float(liquid_largecap_count),
        "moderate_gap_liquid_count": float(moderate_gap_liquid_count),
        "max_gain_pct": max_gain_pct,
        "avg_support": sum(support_scores) / len(support_scores),
        "max_entropy_20d": max_entropy_20d,
        "max_confirmation_return_pct": max_confirmation_return_pct,
    }


def _should_enable_soft_day_event_sleeve(
    picks: list[tuple[str, str]],
    morning_gains: dict[str, dict],
    strategy: StrategyParams,
    *,
    base_soft_day: bool,
) -> bool:
    if not (
        base_soft_day
        and strategy.use_event_sleeve
        and strategy.event_sleeve_soft_day_only
    ):
        return False
    stats = _basket_quality_stats(picks, morning_gains, strategy)
    if (
        strategy.event_sleeve_soft_day_max_trades is not None
        and stats["count"] > strategy.event_sleeve_soft_day_max_trades
    ):
        return False
    if (
        strategy.event_sleeve_soft_day_max_avg_quality is not None
        and stats["avg_quality"] > strategy.event_sleeve_soft_day_max_avg_quality
    ):
        return False
    if (
        strategy.event_sleeve_soft_day_require_no_existing_event
        and stats["event_count"] > 0
    ):
        return False
    return True


def _tail_risk_day_scaler(
    picks: list[tuple[str, str]],
    morning_gains: dict[str, dict],
    strategy: StrategyParams,
) -> float:
    if not picks or strategy.tail_risk_day_scale >= 1.0:
        return 1.0
    if not any(
        [
            strategy.tail_risk_day_max_trades is not None,
            strategy.tail_risk_day_min_max_gain_pct is not None,
            strategy.tail_risk_day_max_avg_quality is not None,
            strategy.tail_risk_day_max_support_score is not None,
            strategy.tail_risk_day_min_max_entropy_20d is not None,
            strategy.tail_risk_day_min_max_confirmation_return_pct is not None,
            strategy.tail_risk_day_require_no_event,
            strategy.tail_risk_day_exempt_largecap,
        ]
    ):
        return 1.0
    stats = _basket_quality_stats(picks, morning_gains, strategy)
    if (
        strategy.tail_risk_day_max_trades is not None
        and stats["count"] > strategy.tail_risk_day_max_trades
    ):
        return 1.0
    if (
        strategy.tail_risk_day_min_max_gain_pct is not None
        and stats["max_gain_pct"] < strategy.tail_risk_day_min_max_gain_pct
    ):
        return 1.0
    if (
        strategy.tail_risk_day_max_avg_quality is not None
        and stats["avg_quality"] > strategy.tail_risk_day_max_avg_quality
    ):
        return 1.0
    if (
        strategy.tail_risk_day_max_support_score is not None
        and stats["avg_support"] > strategy.tail_risk_day_max_support_score
    ):
        return 1.0
    if (
        strategy.tail_risk_day_min_max_entropy_20d is not None
        and stats["max_entropy_20d"] < strategy.tail_risk_day_min_max_entropy_20d
    ):
        return 1.0
    if (
        strategy.tail_risk_day_min_max_confirmation_return_pct is not None
        and stats["max_confirmation_return_pct"] < strategy.tail_risk_day_min_max_confirmation_return_pct
    ):
        return 1.0
    if strategy.tail_risk_day_require_no_event and stats["strong_event_count"] > 0:
        return 1.0
    if strategy.tail_risk_day_exempt_largecap and stats["liquid_largecap_count"] > 0:
        return 1.0
    return max(0.0, min(1.0, strategy.tail_risk_day_scale))


def _low_momentum_single_name_scaler(
    picks: list[tuple[str, str]],
    morning_gains: dict[str, dict],
    strategy: StrategyParams,
) -> float:
    scale = strategy.low_momentum_single_name_scale
    threshold = strategy.low_momentum_single_name_max_gain_pct
    if not picks or len(picks) != 1 or threshold is None or scale >= 1.0:
        return 1.0

    stats = _basket_quality_stats(picks, morning_gains, strategy)
    if stats["max_gain_pct"] > threshold:
        return 1.0
    if (
        strategy.low_momentum_single_name_require_no_event
        and stats["strong_event_count"] > 0
    ):
        return 1.0
    if (
        strategy.low_momentum_single_name_exempt_largecap
        and stats["liquid_largecap_count"] > 0
    ):
        return 1.0
    return max(0.0, min(1.0, scale))


def _soft_day_sparse_scaler(
    picks: list[tuple[str, str]],
    morning_gains: dict[str, dict],
    strategy: StrategyParams,
    *,
    is_soft_day: bool,
) -> float:
    scale = strategy.soft_day_sparse_scale
    if not is_soft_day or not picks or scale >= 1.0:
        return 1.0
    if not any(
        [
            strategy.soft_day_sparse_max_trades is not None,
            strategy.soft_day_sparse_require_no_event,
            strategy.soft_day_sparse_exempt_largecap,
            strategy.soft_day_sparse_exempt_moderate_gap_liquid,
        ]
    ):
        return 1.0

    stats = _basket_quality_stats(picks, morning_gains, strategy)
    if (
        strategy.soft_day_sparse_max_trades is not None
        and stats["count"] > strategy.soft_day_sparse_max_trades
    ):
        return 1.0
    if (
        strategy.soft_day_sparse_require_no_event
        and stats["strong_event_count"] > 0
    ):
        return 1.0
    if (
        strategy.soft_day_sparse_exempt_largecap
        and stats["liquid_largecap_count"] > 0
    ):
        return 1.0
    if (
        strategy.soft_day_sparse_exempt_moderate_gap_liquid
        and stats["moderate_gap_liquid_count"] > 0
    ):
        return 1.0
    return max(0.0, min(1.0, scale))


def _apply_basket_quality_floor(
    picks: list[tuple[str, str]],
    morning_gains: dict[str, dict],
    strategy: StrategyParams,
) -> list[tuple[str, str]]:
    floor = getattr(strategy, "basket_quality_relative_floor", None)
    if floor is None or floor <= 0 or not picks:
        return picks

    quality_by_ticker = {
        ticker: _momentum_quality_score(morning_gains[ticker], strategy)
        for ticker, _sleeve in picks
    }
    best_quality = max(quality_by_ticker.values(), default=0.0)
    if best_quality <= 0:
        return picks

    threshold = best_quality * float(floor)
    blend_only = bool(getattr(strategy, "basket_quality_prune_blend_only", False))
    prunable = [
        (ticker, sleeve)
        for ticker, sleeve in picks
        if not blend_only or sleeve == "blend"
    ]
    if not prunable:
        return picks

    protected = {
        ticker
        for ticker, sleeve in picks
        if blend_only and sleeve != "blend"
    }
    min_count = max(0, min(int(getattr(strategy, "basket_quality_min_count", 0) or 0), len(picks)))
    required_from_prunable = max(0, min_count - len(protected))
    forced = {
        ticker
        for ticker, _score in sorted(
            ((ticker, quality_by_ticker[ticker]) for ticker, _sleeve in prunable),
            key=lambda item: item[1],
            reverse=True,
        )[:required_from_prunable]
    }
    kept: list[tuple[str, str]] = []
    for ticker, sleeve in picks:
        if ticker in protected or ticker in forced or quality_by_ticker.get(ticker, 0.0) >= threshold:
            kept.append((ticker, sleeve))
    return kept


def _is_liquid_continuation_candidate(info: dict) -> bool:
    return bool(
        info.get("is_moderate_gap_liquid")
        or info.get("is_liquid_largecap")
        or info.get("is_sector_thrust")
    )


def _liquid_continuation_core_score(info: dict) -> float:
    low_entropy = max(1.0 - float(info.get("entropy_20d") or 1.0), 0.0)
    return (
        (3.0 if info.get("is_moderate_gap_liquid") else 0.0)
        + (2.5 if info.get("is_liquid_largecap") else 0.0)
        + (1.5 if info.get("is_sector_thrust") else 0.0)
        + 2.0 * _same_day_support_score(info)
        + max((info.get("confirmation_return_pct") or 0.0) * 25.0, 0.0)
        + max((info.get("gain_pct") or 0.0) * 8.0, 0.0)
        + min(max((info.get("entry_dollar_volume") or 0.0) / 150_000_000.0, 0.0), 4.0)
        + min(max((info.get("avg_dollar_vol_30d") or 0.0) / 1_000_000_000.0, 0.0), 4.0)
        + low_entropy
    )


def _select_liquid_continuation_core(
    eligible_gains: dict[str, dict],
    strategy: StrategyParams,
    *,
    can_pick_ticker,
    record_pick,
) -> list[tuple[str, str]]:
    items = list(eligible_gains.items())
    preferred = [item for item in items if _is_liquid_continuation_candidate(item[1])]
    if not preferred:
        return []

    ranked_preferred = sorted(
        preferred,
        key=lambda item: (
            _liquid_continuation_core_score(item[1]),
            _same_day_support_score(item[1]),
            item[1].get("confirmation_return_pct", -999.0),
            item[1].get("entry_dollar_volume", 0.0),
            item[1].get("gain_pct", 0.0),
            -_safe_value(item[1].get("entropy_20d"), default=1.0),
        ),
        reverse=True,
    )

    picks: list[tuple[str, str]] = []
    chosen: set[str] = set()
    for ticker, _info in ranked_preferred:
        if ticker in chosen:
            continue
        if not can_pick_ticker(ticker):
            continue
        picks.append((ticker, "liquid_continuation_core"))
        chosen.add(ticker)
        record_pick(ticker)
        if len(picks) >= strategy.top_n:
            return _apply_basket_quality_floor(picks, eligible_gains, strategy)
    return _apply_basket_quality_floor(picks, eligible_gains, strategy)


def _select_momentum_sleeves(
    morning_gains: dict[str, dict],
    strategy: StrategyParams,
    ticker_sectors: dict[str, str] | None = None,
) -> list[tuple[str, str]]:
    """Return ordered (ticker, sleeve) picks for the day."""
    if not morning_gains:
        return []
    morning_gains = _annotate_sector_thrust_features(
        morning_gains,
        strategy,
        ticker_sectors,
    )
    sector_cap = strategy.max_positions_per_sector if strategy.max_positions_per_sector and strategy.max_positions_per_sector > 0 else None
    sector_counts: dict[str, int] = {}

    def _sector_for_ticker(ticker: str) -> str | None:
        if not ticker_sectors:
            return None
        sector = str(ticker_sectors.get(ticker) or "").strip()
        if not sector or sector.upper() == "UNKNOWN":
            return None
        return sector

    def _can_pick_ticker(ticker: str) -> bool:
        if sector_cap is None:
            return True
        sector = _sector_for_ticker(ticker)
        if sector is None:
            return True
        return sector_counts.get(sector, 0) < sector_cap

    def _record_pick(ticker: str) -> None:
        if sector_cap is None:
            return
        sector = _sector_for_ticker(ticker)
        if sector is None:
            return
        sector_counts[sector] = sector_counts.get(sector, 0) + 1

    eligible_gains = {
        ticker: info
        for ticker, info in morning_gains.items()
        if not info.get("overlay_only_candidate")
    }
    if not eligible_gains:
        return []

    selection_mode = str(getattr(strategy, "momentum_selection_mode", "standard") or "standard").lower()
    if selection_mode == "liquid_continuation":
        return _select_liquid_continuation_core(
            eligible_gains,
            strategy,
            can_pick_ticker=_can_pick_ticker,
            record_pick=_record_pick,
        )

    if not strategy.use_five_sleeves:
        ranked = sorted(
            eligible_gains.keys(),
            key=lambda t: (
                eligible_gains[t]["gain_pct"],
                eligible_gains[t].get("entry_volume", 0.0),
            ),
            reverse=True,
        )
        picks: list[tuple[str, str]] = []
        for ticker in ranked:
            if not _can_pick_ticker(ticker):
                continue
            picks.append((ticker, "core"))
            _record_pick(ticker)
            if len(picks) >= strategy.top_n:
                break
        return _apply_basket_quality_floor(picks, eligible_gains, strategy)

    sleeves = _five_sleeve_specs(strategy)

    picks: list[tuple[str, str]] = []
    chosen: set[str] = set()
    items = list(eligible_gains.items())
    forced_sleeves = [
        sleeve
        for sleeve in sorted(sleeves, key=lambda sleeve: float(sleeve["weight"]), reverse=True)
        if float(sleeve["weight"]) > 0
    ][: max(0, min(strategy.five_sleeve_force_count, len(sleeves)))]
    for sleeve in forced_sleeves:
        key_fn = sleeve["key_fn"]
        ranked = sorted(items, key=key_fn, reverse=True)
        for ticker, _info in ranked:
            if ticker in chosen:
                continue
            if not _can_pick_ticker(ticker):
                continue
            picks.append((ticker, str(sleeve["label"])))
            chosen.add(ticker)
            _record_pick(ticker)
            break
        if len(picks) >= strategy.top_n:
            return _apply_basket_quality_floor(picks[: strategy.top_n], eligible_gains, strategy)

    fallback_slots = max(0, int(getattr(strategy, "fallback_liquid_largecap_slots", 0) or 0))
    fallback_trigger = max(0, int(getattr(strategy, "fallback_liquid_largecap_trigger_below", 0) or 0))
    if (
        fallback_slots > 0
        and len(picks) < strategy.top_n
        and len(picks) < fallback_trigger
    ):
        ranked_liquid = sorted(
            (
                item for item in items
                if item[1].get("is_liquid_largecap")
            ),
            key=lambda item: (
                item[1].get("entry_dollar_volume", 0.0),
                item[1].get("confirmation_return_pct", 0.0),
                item[1].get("gain_pct", 0.0),
                item[1].get("avg_dollar_vol_30d", 0.0),
            ),
            reverse=True,
        )
        added = 0
        for ticker, _info in ranked_liquid:
            if ticker in chosen:
                continue
            if not _can_pick_ticker(ticker):
                continue
            picks.append((ticker, "liquid_largecap_fallback"))
            chosen.add(ticker)
            _record_pick(ticker)
            added += 1
            if len(picks) >= strategy.top_n or added >= fallback_slots:
                break

    def blended_score(item: tuple[str, dict]) -> float:
        _ticker, info = item
        score = 0.0
        for sleeve in sleeves:
            weight = float(sleeve["weight"])
            if weight <= 0:
                continue
            score += weight * float(sleeve["component"](info))
        return score

    ranked_fill = sorted(items, key=blended_score, reverse=True)
    for ticker, _info in ranked_fill:
        if ticker in chosen:
            continue
        if not _can_pick_ticker(ticker):
            continue
        picks.append((ticker, "blend"))
        chosen.add(ticker)
        _record_pick(ticker)
        if len(picks) >= strategy.top_n:
            break
    return _apply_basket_quality_floor(picks, eligible_gains, strategy)


def _select_event_day_liquid_picks(
    morning_gains: dict[str, dict],
    existing_picks: list[tuple[str, str]],
    strategy: StrategyParams,
    *,
    is_soft_day: bool,
    activation_stats: dict[str, float | int | bool] | None = None,
) -> list[tuple[str, str]]:
    if not (
        strategy.use_event_day_liquid_sleeve
        and strategy.event_day_liquid_capital_fraction > 0
        and strategy.event_day_liquid_max_positions > 0
    ):
        return []
    if strategy.event_day_liquid_soft_day_only and not is_soft_day:
        return []

    activation = activation_stats or _event_day_liquid_activation_stats(morning_gains, strategy)
    if not bool(activation.get("qualifies")):
        return []

    chosen = {ticker for ticker, _sleeve in existing_picks}
    ranked = sorted(
        (
            (ticker, info)
            for ticker, info in morning_gains.items()
            if ticker not in chosen
        ),
        key=lambda item: _event_day_liquid_pick_score(item[1]),
        reverse=True,
    )
    picks: list[tuple[str, str]] = []
    for ticker, info in ranked:
        gain_pct = float(info.get("gain_pct") or 0.0)
        if (
            strategy.event_day_liquid_min_gain_pct is not None
            and gain_pct < strategy.event_day_liquid_min_gain_pct
        ):
            continue
        if (
            strategy.event_day_liquid_max_gain_pct is not None
            and gain_pct > strategy.event_day_liquid_max_gain_pct
        ):
            continue
        confirmation_return_pct = info.get("confirmation_return_pct")
        if (
            strategy.event_day_liquid_min_confirmation_return_pct is not None
            and (
                confirmation_return_pct is None
                or confirmation_return_pct < strategy.event_day_liquid_min_confirmation_return_pct
            )
        ):
            continue
        entry_dollar_volume = float(info.get("entry_dollar_volume") or 0.0)
        if (
            strategy.event_day_liquid_min_entry_dollar_volume is not None
            and entry_dollar_volume < strategy.event_day_liquid_min_entry_dollar_volume
        ):
            continue
        avg_dollar_vol_30d = info.get("avg_dollar_vol_30d")
        if (
            strategy.event_day_liquid_min_avg_dollar_vol_30d is not None
            and (
                avg_dollar_vol_30d is None
                or avg_dollar_vol_30d < strategy.event_day_liquid_min_avg_dollar_vol_30d
            )
        ):
            continue
        entropy_20d = info.get("entropy_20d")
        if (
            strategy.event_day_liquid_max_entropy_20d is not None
            and (
                entropy_20d is None
                or entropy_20d > strategy.event_day_liquid_max_entropy_20d
            )
        ):
            continue
        support_score = _same_day_support_score(info)
        if (
            strategy.event_day_liquid_min_support_score is not None
            and support_score < strategy.event_day_liquid_min_support_score
        ):
            continue
        picks.append((ticker, "event_day_liquid"))
        if len(picks) >= strategy.event_day_liquid_max_positions:
            break
    return picks


def _select_liquid_cluster_picks(
    morning_gains: dict[str, dict],
    base_picks: list[tuple[str, str]],
    strategy: StrategyParams,
) -> list[tuple[str, str]]:
    if not (
        strategy.use_liquid_cluster_engine
        and strategy.liquid_cluster_capital_fraction > 0
        and strategy.liquid_cluster_max_positions > 0
    ):
        return []

    chosen = {ticker for ticker, _sleeve in base_picks}
    max_per_sector = max(1, int(strategy.liquid_cluster_max_positions_per_sector or 1))
    sector_counts: dict[str, int] = {}
    picks: list[tuple[str, str]] = []
    ranked = sorted(
        (
            (ticker, info)
            for ticker, info in morning_gains.items()
            if info.get("is_liquid_cluster") and ticker not in chosen
        ),
        key=lambda item: (
            float(item[1].get("liquid_cluster_score") or 0.0),
            float(item[1].get("entry_dollar_volume") or 0.0),
            float(item[1].get("confirmation_return_pct") or -999.0),
            float(item[1].get("gain_pct") or 0.0),
        ),
        reverse=True,
    )
    for ticker, info in ranked:
        sector = str(info.get("liquid_cluster_sector") or "").strip()
        if sector and sector_counts.get(sector, 0) >= max_per_sector:
            continue
        picks.append((ticker, "liquid_cluster_engine"))
        chosen.add(ticker)
        if sector:
            sector_counts[sector] = sector_counts.get(sector, 0) + 1
        if len(picks) >= strategy.liquid_cluster_max_positions:
            break
    return picks


def _select_sector_etf_picks(
    morning_gains: dict[str, dict],
    cluster_stats: dict[str, dict[str, object]],
    base_picks: list[tuple[str, str]],
    cluster_picks: list[tuple[str, str]],
    sector_proxy_bars_by_ticker: dict[str, list[dict]] | None,
    strategy: StrategyParams,
) -> list[tuple[str, str, str]]:
    if not (
        strategy.use_sector_etf_sleeve
        and strategy.sector_etf_capital_fraction > 0
        and strategy.sector_etf_max_positions > 0
        and sector_proxy_bars_by_ticker
    ):
        return []

    base_sectors = {
        str(morning_gains.get(ticker, {}).get("liquid_cluster_sector") or "").strip()
        for ticker, _sleeve in cluster_picks
    }
    ranked: list[tuple[float, float, float, str, str]] = []
    for sector, stats in cluster_stats.items():
        if not bool(stats.get("qualifies")):
            continue
        if sector in base_sectors:
            continue
        proxy_ticker = stats.get("proxy_ticker")
        if not isinstance(proxy_ticker, str) or proxy_ticker not in sector_proxy_bars_by_ticker:
            continue
        sector_score = float(stats.get("sector_score") or 0.0)
        if (
            strategy.sector_etf_min_sector_score is not None
            and sector_score < strategy.sector_etf_min_sector_score
        ):
            continue
        ranked.append(
            (
                sector_score,
                float(stats.get("total_entry_dollar_volume") or 0.0),
                float(stats.get("avg_confirmation_return_pct") or 0.0),
                sector,
                proxy_ticker,
            )
        )

    ranked.sort(reverse=True)
    picks: list[tuple[str, str, str]] = []
    chosen_proxies = {ticker for ticker, _sleeve in base_picks}
    for _score, _total_dv, _avg_conf, sector, proxy_ticker in ranked:
        if proxy_ticker in chosen_proxies:
            continue
        picks.append((proxy_ticker, "sector_etf", sector))
        chosen_proxies.add(proxy_ticker)
        if len(picks) >= strategy.sector_etf_max_positions:
            break
    return picks


def _execution_info_from_bars(
    bars: list[dict],
    strategy: StrategyParams,
    date_str: str,
) -> dict | None:
    """Build execution context from raw intraday bars without candidate filters."""
    market_open = _market_open_ts(date_str)
    mkt_bars = filter_market_hours(bars)
    if len(mkt_bars) < _MIN_BARS:
        return None

    open_price = mkt_bars[0]["open"]
    if open_price <= 0:
        return None

    initial_entry_bar = _bar_at_offset(mkt_bars, market_open, strategy.entry_minutes_after_open)
    if initial_entry_bar is None:
        return None

    entry_bar = initial_entry_bar
    confirmation_return = None
    if strategy.confirmation_minutes_after_entry > 0:
        confirmation_bar = _bar_at_offset(
            mkt_bars,
            market_open,
            strategy.entry_minutes_after_open + strategy.confirmation_minutes_after_entry,
        )
        if confirmation_bar is None:
            return None
        confirmation_return = (
            confirmation_bar["close"] - initial_entry_bar["close"]
        ) / initial_entry_bar["close"]
        entry_bar = confirmation_bar

    entry_price_raw = entry_bar["close"]
    if entry_price_raw <= 0:
        return None

    entry_ts = _parse_ts(entry_bar["timestamp"])
    opening_range_bars = [bar for bar in mkt_bars if _parse_ts(bar["timestamp"]) <= entry_ts]
    if not opening_range_bars:
        return None
    opening_range_high = max(float(bar["high"]) for bar in opening_range_bars)
    opening_range_low = min(float(bar["low"]) for bar in opening_range_bars)
    opening_range_width = max(0.0, opening_range_high - opening_range_low)
    return {
        "gain_pct": (entry_price_raw - open_price) / open_price,
        "entry_price_raw": entry_price_raw,
        "entry_bar": entry_bar,
        "mkt_bars": mkt_bars,
        "entry_volume": _volume_up_to_bar(mkt_bars, entry_ts),
        "entry_dollar_volume": _dollar_volume_up_to_bar(mkt_bars, entry_ts),
        "opening_range_width": opening_range_width,
        "confirmation_return_pct": confirmation_return,
        "recovery_from_opening_low_pct": (
            (entry_price_raw - opening_range_low) / opening_range_low
            if opening_range_low > 0
            else None
        ),
        "gap_pct": None,
        "volume_ratio_14d": None,
        "ret_5d": None,
        "entropy_20d": None,
        "avg_dollar_vol_30d": None,
        "atr_14": None,
        "event_score": None,
        "is_liquid_largecap": False,
        "is_moderate_gap_liquid": False,
        "is_sector_thrust": False,
        "is_liquid_cluster": False,
    }


def _build_intraday_trade(
    ticker: str,
    info: dict,
    *,
    date_str: str,
    strategy: StrategyParams,
    trade_capital: float,
    sleeve: str,
) -> IntradayTrade:
    entry_price_raw = info["entry_price_raw"]
    entry_bar = info["entry_bar"]
    mkt_bars = info["mkt_bars"]

    exit_price, exit_time_str, exit_reason = simulate_trade(
        mkt_bars,
        entry_bar,
        entry_price_raw,
        strategy.exit_minutes_before_close,
        strategy.stop_loss_pct,
        _trade_trailing_stop_pct(info, strategy),
        _trade_catastrophic_stop_price(info, strategy),
        strategy.trailing_activation_gain_pct,
        strategy.slippage_bps,
        date_str,
    )

    entry_price_filled = _apply_slippage_entry(entry_price_raw, strategy.slippage_bps)
    shares = trade_capital / entry_price_filled if trade_capital > 0 else 0.0
    pnl_pct = (exit_price - entry_price_filled) / entry_price_filled if entry_price_filled > 0 else 0.0
    pnl = pnl_pct * trade_capital
    slippage_cost = (
        (entry_price_filled - entry_price_raw) +
        (entry_price_raw * strategy.slippage_bps / 10_000)
    ) * 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=exit_time_str,
        shares=round(shares, 4),
        pnl=round(pnl, 4),
        pnl_pct=round(pnl_pct, 6),
        exit_reason=exit_reason,
        morning_gain_pct=round(float(info.get("gain_pct") or 0.0), 6),
        slippage_cost=round(slippage_cost, 4),
        trade_sleeve=sleeve,
        gap_pct=_round_optional(info.get("gap_pct"), 6),
        confirmation_return_pct=_round_optional(info.get("confirmation_return_pct"), 6),
        entry_dollar_volume=_round_optional(info.get("entry_dollar_volume"), 2),
        avg_dollar_vol_30d=_round_optional(info.get("avg_dollar_vol_30d"), 2),
        entropy_20d=_round_optional(info.get("entropy_20d"), 6),
        ret_5d=_round_optional(info.get("ret_5d"), 6),
        event_score=_round_optional(info.get("event_score"), 4),
        support_score=round(_same_day_support_score(info), 6),
        is_liquid_largecap=bool(info.get("is_liquid_largecap")),
        is_moderate_gap_liquid=bool(info.get("is_moderate_gap_liquid")),
        is_sector_thrust=bool(info.get("is_sector_thrust")),
        sector_thrust_member_count=int(info.get("sector_thrust_member_count") or 0),
        sector_thrust_total_entry_dollar_volume=_round_optional(
            info.get("sector_thrust_total_entry_dollar_volume"),
            2,
        ),
        is_liquid_cluster=bool(info.get("is_liquid_cluster")),
        liquid_cluster_member_count=int(info.get("liquid_cluster_member_count") or 0),
        liquid_cluster_total_entry_dollar_volume=_round_optional(
            info.get("liquid_cluster_total_entry_dollar_volume"),
            2,
        ),
        liquid_cluster_sector=(
            str(info.get("liquid_cluster_sector"))
            if info.get("liquid_cluster_sector")
            else None
        ),
        liquid_cluster_sector_score=_round_optional(info.get("liquid_cluster_sector_score"), 6),
        sector_proxy_ticker=(
            str(info.get("sector_proxy_ticker"))
            if info.get("sector_proxy_ticker")
            else None
        ),
        total_capital_deployed=round(trade_capital, 4),
    )


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


def _apply_slippage_entry(price: float, slippage_bps: float) -> float:
    """Long entry fill: price × (1 + bps/10000)."""
    return price * (1.0 + slippage_bps / 10_000)


def _apply_slippage_exit(price: float, slippage_bps: float) -> float:
    """Long exit fill: price × (1 - bps/10000)."""
    return price * (1.0 - slippage_bps / 10_000)


def simulate_trade(
    bars: list[dict],
    entry_bar: dict,
    entry_price_raw: float,
    exit_offset_minutes: int,
    stop_loss_pct: float | None,
    trailing_stop_pct: float | None,
    catastrophic_stop_price_raw: float | None,
    trailing_activation_gain_pct: float | None,
    slippage_bps: float,
    date_str: str,
) -> tuple[float, str, str]:
    """Simulate a single intraday trade.

    Supports catastrophic/fixed stops plus optional delayed trailing stops.

    Returns:
        (exit_price_after_slippage, exit_time_str, exit_reason)
    """
    entry_price = _apply_slippage_entry(entry_price_raw, slippage_bps)
    entry_ts = _parse_ts(entry_bar["timestamp"])

    # Compute exit target time
    market_close = _market_open_ts(date_str).replace(hour=16, minute=0)
    exit_target = market_close - dt.timedelta(minutes=exit_offset_minutes)

    exit_price_raw = entry_price_raw
    exit_time_str = entry_bar["timestamp"]
    exit_reason = "close"

    # Trailing stop state
    peak_price = entry_price_raw

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

        # Update peak for trailing stop
        if b["high"] > peak_price:
            peak_price = b["high"]

        peak_gain_pct = (peak_price - entry_price_raw) / entry_price_raw if entry_price_raw > 0 else 0.0
        trailing_active = (
            trailing_stop_pct is not None
            and (
                trailing_activation_gain_pct is None
                or peak_gain_pct >= trailing_activation_gain_pct
            )
        )

        # Determine effective stop level
        if trailing_active:
            # Trailing: stop = peak × (1 + trailing_pct), trails upward
            stop_price = peak_price * (1.0 + trailing_stop_pct)  # trailing_pct is negative
            low_price = b["low"]
            if low_price <= stop_price:
                exit_price_raw = stop_price
                exit_time_str = b["timestamp"]
                exit_reason = "trailing_stop"
                break
        else:
            stop_price = catastrophic_stop_price_raw
            if stop_price is None and stop_loss_pct is not None:
                stop_price = entry_price_raw * (1.0 + stop_loss_pct)
            if stop_price is not None and b["low"] <= stop_price:
                exit_price_raw = stop_price
                exit_time_str = b["timestamp"]
                exit_reason = "stop_loss"
                break

        # Check scheduled exit time
        if ts >= exit_target:
            exit_price_raw = b["close"]
            exit_time_str = b["timestamp"]
            exit_reason = "close"
            break

        # Update running exit (last bar before exit time)
        exit_price_raw = b["close"]
        exit_time_str = b["timestamp"]

    exit_price = _apply_slippage_exit(exit_price_raw, slippage_bps)
    return exit_price, exit_time_str, exit_reason


# ── Morning Gain Computation ───────────────────────────────────────────────


def compute_morning_gains(
    bars_by_ticker: dict[str, list[dict]],
    strategy: StrategyParams,
    date_str: str,
    blacklisted_tickers: set[str] | None = None,
    spy_bars: list[dict] | None = None,
    daily_features_by_ticker: dict[str, dict] | None = None,
    vix_value: float | None = None,
) -> dict[str, dict]:
    """Compute each ticker's gain from open to entry time, applying all filters.

    Filters applied:
    - Minimum market-hours bars (_MIN_BARS)
    - min_morning_gain_pct: stock must be up enough to qualify
    - max_morning_gain_pct: cap extreme gap-ups that tend to mean-revert
    - min_entry_volume: require sufficient trading activity by entry time
    - blacklisted_tickers: tickers in cooldown period (recently traded)
    - market_regime_spy_threshold: skip if SPY is down too much

    Returns:
        {ticker: {gain_pct, entry_price_raw, entry_bar, mkt_bars, entry_volume}}
    """
    market_open = _market_open_ts(date_str)
    allowed_event_types = {
        str(value).strip().lower()
        for value in getattr(strategy, "candidate_allowed_event_types", [])
        if str(value).strip()
    }

    def _effective_event_state(daily_features: dict) -> tuple[bool, float]:
        raw_event_flag = bool(daily_features.get("event_flag"))
        raw_event_score = float(daily_features.get("event_score") or 0.0)
        if not raw_event_flag:
            return False, 0.0
        if allowed_event_types:
            raw_event_types = daily_features.get("event_types") or []
            event_types = {
                str(value).strip().lower()
                for value in raw_event_types
                if str(value).strip()
            }
            if not event_types or not any(event_type in allowed_event_types for event_type in event_types):
                return False, 0.0
        return True, raw_event_score

    # Market regime check: compute SPY's morning return
    if strategy.market_regime_spy_threshold is not None and spy_bars:
        spy_mkt = filter_market_hours(spy_bars)
        if len(spy_mkt) >= 2:
            spy_open = spy_mkt[0]["open"]
            spy_entry_bar = _bar_at_offset(spy_mkt, market_open, strategy.entry_minutes_after_open)
            if spy_open > 0 and spy_entry_bar is not None:
                spy_gain = (spy_entry_bar["close"] - spy_open) / spy_open
                if spy_gain < strategy.market_regime_spy_threshold:
                    return {}  # Skip this day entirely

    if strategy.max_vix is not None and vix_value is not None and vix_value > strategy.max_vix:
        return {}

    result = {}

    for ticker, all_bars in bars_by_ticker.items():
        # Skip blacklisted tickers (cooldown)
        if blacklisted_tickers and ticker in blacklisted_tickers:
            continue

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

        open_price = mkt_bars[0]["open"]
        if open_price <= 0:
            continue

        initial_entry_bar = _bar_at_offset(mkt_bars, market_open, strategy.entry_minutes_after_open)
        if initial_entry_bar is None:
            continue

        entry_bar = initial_entry_bar
        if strategy.confirmation_minutes_after_entry > 0:
            confirmation_bar = _bar_at_offset(
                mkt_bars,
                market_open,
                strategy.entry_minutes_after_open + strategy.confirmation_minutes_after_entry,
            )
            if confirmation_bar is None:
                continue
            confirmation_return = (
                confirmation_bar["close"] - initial_entry_bar["close"]
            ) / initial_entry_bar["close"]
            entry_bar = confirmation_bar
        else:
            confirmation_return = None

        entry_price_raw = entry_bar["close"]
        if entry_price_raw <= 0:
            continue

        gain_pct = (entry_price_raw - open_price) / open_price

        # volume filter: cumulative volume up to entry time
        entry_ts = _parse_ts(entry_bar["timestamp"])
        entry_vol = _volume_up_to_bar(mkt_bars, entry_ts)
        if strategy.min_entry_volume is not None and entry_vol < strategy.min_entry_volume:
            continue
        entry_dollar_vol = _dollar_volume_up_to_bar(mkt_bars, entry_ts)
        if (
            strategy.min_entry_dollar_volume is not None
            and entry_dollar_vol < strategy.min_entry_dollar_volume
        ):
            continue

        daily_features = (daily_features_by_ticker or {}).get(ticker, {})
        gap_pct = daily_features.get("gap_pct")
        gap_min_ok = (
            strategy.min_gap_pct is None
            or (gap_pct is not None and gap_pct >= strategy.min_gap_pct)
        )
        gap_max_ok = (
            strategy.max_gap_pct is None
            or (gap_pct is not None and gap_pct <= strategy.max_gap_pct)
        )

        volume_ratio_14d = None
        avg_daily_vol_14d = daily_features.get("avg_daily_vol_14d")
        if avg_daily_vol_14d and avg_daily_vol_14d > 0:
            volume_ratio_14d = entry_vol / avg_daily_vol_14d
        if (
            strategy.min_volume_ratio_14d is not None
            and (volume_ratio_14d is None or volume_ratio_14d < strategy.min_volume_ratio_14d)
        ):
            continue

        ret_5d = daily_features.get("ret_5d")
        if strategy.min_ret_5d is not None and (ret_5d is None or ret_5d < strategy.min_ret_5d):
            continue

        entropy_20d = daily_features.get("entropy_20d")
        avg_dollar_vol_30d = daily_features.get("avg_dollar_vol_30d")
        atr_14 = daily_features.get("atr_14")
        raw_event_flag = bool(daily_features.get("event_flag"))
        raw_event_score = float(daily_features.get("event_score") or 0.0)
        event_types = list(daily_features.get("event_types") or [])
        event_flag, event_score = _effective_event_state(daily_features)
        attention_wiki_spike_10d = float(daily_features.get("attention_wiki_spike_10d") or 0.0)
        attention_article_count_3d = int(daily_features.get("attention_article_count_3d") or 0)
        attention_us_article_count_3d = int(daily_features.get("attention_us_article_count_3d") or 0)
        attention_resolver_confidence = float(daily_features.get("attention_resolver_confidence") or 0.0)
        is_event_candidate = False
        if strategy.use_event_sleeve and event_flag:
            if strategy.event_min_score is None or event_score >= strategy.event_min_score:
                is_event_candidate = True
        if strategy.min_entropy_20d is not None and (entropy_20d is None or entropy_20d < strategy.min_entropy_20d):
            continue
        global_max_entropy_ok = True
        if strategy.max_entropy_20d is not None and (entropy_20d is None or entropy_20d > strategy.max_entropy_20d):
            global_max_entropy_ok = False

        opening_range_bars = [bar for bar in mkt_bars if _parse_ts(bar["timestamp"]) <= entry_ts]
        if not opening_range_bars:
            continue
        opening_range_high = max(float(bar["high"]) for bar in opening_range_bars)
        opening_range_low = min(float(bar["low"]) for bar in opening_range_bars)
        opening_range_width = max(0.0, opening_range_high - opening_range_low)
        recovery_from_opening_low_pct = (
            (entry_price_raw - opening_range_low) / opening_range_low
            if opening_range_low > 0
            else None
        )

        if strategy.atr_stop_multiplier is not None and (atr_14 is None or atr_14 <= 0):
            continue
        if strategy.opening_range_stop_multiplier is not None and opening_range_width <= 0:
            continue

        confirmation_ok = (
            strategy.min_confirmation_return_pct is None
            or confirmation_return is None
            or confirmation_return >= strategy.min_confirmation_return_pct
        )

        regular_ok = gap_min_ok and gap_max_ok and global_max_entropy_ok and confirmation_ok and gain_pct >= strategy.min_morning_gain_pct and (
            strategy.max_morning_gain_pct is None or gain_pct <= strategy.max_morning_gain_pct
        )
        slow_ignite_ok = False
        if (
            strategy.use_slow_ignite_sleeve
            and gap_min_ok
            and gap_max_ok
            and global_max_entropy_ok
            and confirmation_ok
            and gain_pct < strategy.min_morning_gain_pct
        ):
            if strategy.slow_ignite_min_gain_pct is not None and gain_pct < strategy.slow_ignite_min_gain_pct:
                pass
            elif strategy.slow_ignite_max_gain_pct is not None and gain_pct > strategy.slow_ignite_max_gain_pct:
                pass
            elif (
                strategy.slow_ignite_min_entry_dollar_volume is not None
                and entry_dollar_vol < strategy.slow_ignite_min_entry_dollar_volume
            ):
                pass
            elif (
                strategy.slow_ignite_min_volume_ratio_14d is not None
                and (volume_ratio_14d is None or volume_ratio_14d < strategy.slow_ignite_min_volume_ratio_14d)
            ):
                pass
            elif (
                strategy.slow_ignite_min_ret_5d is not None
                and (ret_5d is None or ret_5d < strategy.slow_ignite_min_ret_5d)
            ):
                pass
            elif (
                strategy.slow_ignite_max_entropy_20d is not None
                and (entropy_20d is None or entropy_20d > strategy.slow_ignite_max_entropy_20d)
            ):
                pass
            else:
                slow_ignite_ok = True

        liquid_largecap_ok = False
        liquid_largecap_enabled = (
            strategy.use_liquid_largecap_sleeve
            or (getattr(strategy, "fallback_liquid_largecap_slots", 0) or 0) > 0
            or _liquid_cluster_overlay_enabled(strategy)
            or _event_day_liquid_overlay_enabled(strategy)
        )
        if liquid_largecap_enabled and gap_min_ok and gap_max_ok and confirmation_ok:
            liquid_largecap_entropy_cap = strategy.liquid_largecap_max_entropy_20d
            if liquid_largecap_entropy_cap is None:
                liquid_largecap_entropy_cap = strategy.max_entropy_20d
            if (
                strategy.liquid_largecap_min_gain_pct is not None
                and gain_pct < strategy.liquid_largecap_min_gain_pct
            ):
                pass
            elif (
                strategy.liquid_largecap_max_gain_pct is not None
                and gain_pct > strategy.liquid_largecap_max_gain_pct
            ):
                pass
            elif (
                strategy.liquid_largecap_min_confirmation_return_pct is not None
                and confirmation_return < strategy.liquid_largecap_min_confirmation_return_pct
            ):
                pass
            elif (
                strategy.liquid_largecap_min_entry_dollar_volume is not None
                and entry_dollar_vol < strategy.liquid_largecap_min_entry_dollar_volume
            ):
                pass
            elif (
                strategy.liquid_largecap_min_avg_dollar_vol_30d is not None
                and (
                    avg_dollar_vol_30d is None
                    or avg_dollar_vol_30d < strategy.liquid_largecap_min_avg_dollar_vol_30d
                )
            ):
                pass
            elif (
                liquid_largecap_entropy_cap is not None
                and (entropy_20d is None or entropy_20d > liquid_largecap_entropy_cap)
            ):
                pass
            else:
                liquid_largecap_ok = True

        moderate_gap_liquid_ok = False
        moderate_gap_liquid_enabled = (
            strategy.use_moderate_gap_liquid_sleeve
            or _liquid_cluster_overlay_enabled(strategy)
            or _event_day_liquid_overlay_enabled(strategy)
        )
        if moderate_gap_liquid_enabled:
            moderate_entropy_cap = strategy.moderate_gap_liquid_max_entropy_20d
            if moderate_entropy_cap is None:
                moderate_entropy_cap = strategy.max_entropy_20d
            atr_pct = (
                float(atr_14) / float(open_price)
                if atr_14 is not None and open_price > 0
                else None
            )
            if strategy.moderate_gap_liquid_min_gap_pct is not None and (
                gap_pct is None or gap_pct < strategy.moderate_gap_liquid_min_gap_pct
            ):
                pass
            elif strategy.moderate_gap_liquid_max_gap_pct is not None and (
                gap_pct is None or gap_pct > strategy.moderate_gap_liquid_max_gap_pct
            ):
                pass
            elif (
                strategy.moderate_gap_liquid_min_gain_pct is not None
                and gain_pct < strategy.moderate_gap_liquid_min_gain_pct
            ):
                pass
            elif (
                strategy.moderate_gap_liquid_max_gain_pct is not None
                and gain_pct > strategy.moderate_gap_liquid_max_gain_pct
            ):
                pass
            elif (
                strategy.moderate_gap_liquid_min_confirmation_return_pct is not None
                and (
                    confirmation_return is None
                    or confirmation_return < strategy.moderate_gap_liquid_min_confirmation_return_pct
                )
            ):
                pass
            elif (
                strategy.moderate_gap_liquid_min_entry_dollar_volume is not None
                and entry_dollar_vol < strategy.moderate_gap_liquid_min_entry_dollar_volume
            ):
                pass
            elif (
                strategy.moderate_gap_liquid_min_avg_dollar_vol_30d is not None
                and (
                    avg_dollar_vol_30d is None
                    or avg_dollar_vol_30d < strategy.moderate_gap_liquid_min_avg_dollar_vol_30d
                )
            ):
                pass
            elif (
                strategy.moderate_gap_liquid_max_avg_dollar_vol_30d is not None
                and (
                    avg_dollar_vol_30d is None
                    or avg_dollar_vol_30d > strategy.moderate_gap_liquid_max_avg_dollar_vol_30d
                )
            ):
                pass
            elif (
                strategy.moderate_gap_liquid_min_volume_ratio_14d is not None
                and (
                    volume_ratio_14d is None
                    or volume_ratio_14d < strategy.moderate_gap_liquid_min_volume_ratio_14d
                )
            ):
                pass
            elif (
                strategy.moderate_gap_liquid_min_atr_pct is not None
                and (atr_pct is None or atr_pct < strategy.moderate_gap_liquid_min_atr_pct)
            ):
                pass
            elif (
                moderate_entropy_cap is not None
                and (entropy_20d is None or entropy_20d > moderate_entropy_cap)
            ):
                pass
            else:
                moderate_gap_liquid_ok = True

        liquid_cluster_candidate_ok = False
        if _liquid_cluster_overlay_enabled(strategy):
            liquid_cluster_candidate_ok = _passes_liquid_cluster_own_gate(
                strategy,
                gain_pct=gain_pct,
                confirmation_return_pct=confirmation_return,
                entry_dollar_volume=entry_dollar_vol,
                avg_dollar_vol_30d=avg_dollar_vol_30d,
                volume_ratio_14d=volume_ratio_14d,
                entropy_20d=entropy_20d,
                is_moderate_gap_liquid=moderate_gap_liquid_ok,
                is_liquid_largecap=liquid_largecap_ok,
            )

        gap_reclaim_ok = False
        if strategy.use_gap_reclaim_sleeve:
            if strategy.gap_reclaim_min_gap_pct is not None and (
                gap_pct is None or gap_pct < strategy.gap_reclaim_min_gap_pct
            ):
                pass
            elif strategy.gap_reclaim_min_gain_pct is not None and gain_pct < strategy.gap_reclaim_min_gain_pct:
                pass
            elif strategy.gap_reclaim_max_gain_pct is not None and gain_pct > strategy.gap_reclaim_max_gain_pct:
                pass
            elif (
                strategy.gap_reclaim_min_confirmation_return_pct is not None
                and (
                    confirmation_return is None
                    or confirmation_return < strategy.gap_reclaim_min_confirmation_return_pct
                )
            ):
                pass
            elif (
                strategy.gap_reclaim_min_entry_dollar_volume is not None
                and entry_dollar_vol < strategy.gap_reclaim_min_entry_dollar_volume
            ):
                pass
            elif (
                strategy.gap_reclaim_min_recovery_from_opening_low_pct is not None
                and (
                    recovery_from_opening_low_pct is None
                    or recovery_from_opening_low_pct < strategy.gap_reclaim_min_recovery_from_opening_low_pct
                )
            ):
                pass
            else:
                gap_reclaim_ok = True

        if (
            not regular_ok
            and not slow_ignite_ok
            and not liquid_largecap_ok
            and not moderate_gap_liquid_ok
            and not liquid_cluster_candidate_ok
            and not gap_reclaim_ok
        ):
            continue

        result[ticker] = {
            "gain_pct": gain_pct,
            "entry_price_raw": entry_price_raw,
            "entry_bar": entry_bar,
            "mkt_bars": mkt_bars,
            "entry_volume": entry_vol,
            "entry_dollar_volume": entry_dollar_vol,
            "gap_pct": gap_pct,
            "volume_ratio_14d": volume_ratio_14d,
            "ret_5d": ret_5d,
            "entropy_20d": entropy_20d,
            "avg_dollar_vol_30d": avg_dollar_vol_30d,
            "atr_14": atr_14,
            "raw_event_flag": raw_event_flag,
            "raw_event_score": raw_event_score,
            "event_types": event_types,
            "event_flag": event_flag,
            "event_score": event_score,
            "attention_wiki_spike_10d": attention_wiki_spike_10d,
            "attention_article_count_3d": attention_article_count_3d,
            "attention_us_article_count_3d": attention_us_article_count_3d,
            "attention_resolver_confidence": attention_resolver_confidence,
            "is_event_candidate": is_event_candidate,
            "opening_range_width": opening_range_width,
            "recovery_from_opening_low_pct": recovery_from_opening_low_pct,
            "confirmation_return_pct": confirmation_return,
            "is_slow_ignite": slow_ignite_ok,
            "is_liquid_largecap": liquid_largecap_ok,
            "is_moderate_gap_liquid": moderate_gap_liquid_ok,
            "is_gap_reclaim": gap_reclaim_ok,
            "overlay_only_candidate": bool(
                liquid_cluster_candidate_ok
                and not regular_ok
                and not slow_ignite_ok
                and not liquid_largecap_ok
                and not moderate_gap_liquid_ok
                and not gap_reclaim_ok
            ),
        }

    return result


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


def simulate_day(
    bars_by_ticker: dict[str, list[dict]],
    date_str: str,
    strategy: StrategyParams,
    blacklisted_tickers: set[str] | None = None,
    spy_bars: list[dict] | None = None,
    daily_features_by_ticker: dict[str, dict] | None = None,
    vix_value: float | None = None,
    current_equity: float | None = None,
    ticker_sectors: dict[str, str] | None = None,
    sector_proxy_bars_by_ticker: dict[str, list[dict]] | None = None,
) -> DayResult:
    """Simulate one full trading day.

    1. Apply all filters to find qualified morning gainers.
    2. Rank by gain, pick top N.
    3. Simulate each trade with stop-loss / trailing stop.
    4. Compute daily P&L.

    Args:
        current_equity: Current portfolio equity for compound position sizing.
                        When strategy.compound_returns=True and this is provided,
                        position sizes scale with current equity. Otherwise uses
                        strategy.initial_capital (simple/단리 mode).
    """
    result = DayResult(date=date_str)

    if strategy.max_vix is not None and vix_value is not None and vix_value > strategy.max_vix:
        result.skip_reason = "vix_gate"
        return result

    if _intraday_regime_failed(strategy, spy_bars, date_str):
        result.skip_reason = "market_regime"
        return result

    regime_scaler, regime_skip = _day_regime_scaler(strategy, daily_features_by_ticker)
    if regime_skip is not None:
        result.skip_reason = regime_skip
        return result
    breadth_scaler, breadth_skip = _day_breadth_scaler(
        strategy,
        bars_by_ticker,
        daily_features_by_ticker,
    )
    if breadth_skip is not None:
        result.skip_reason = breadth_skip
        return result
    result.regime_scaler = regime_scaler
    result.breadth_scaler = breadth_scaler

    morning_gains = compute_morning_gains(
        bars_by_ticker,
        strategy,
        date_str,
        blacklisted_tickers=blacklisted_tickers,
        spy_bars=None,
        daily_features_by_ticker=daily_features_by_ticker,
        vix_value=None,
    )
    morning_gains = _annotate_sector_thrust_features(
        morning_gains,
        strategy,
        ticker_sectors,
    )
    morning_gains, liquid_cluster_stats = _annotate_liquid_cluster_features(
        morning_gains,
        strategy,
        ticker_sectors,
    )
    result.candidates_found = len(morning_gains)

    if not morning_gains:
        result.skip_reason = "no_candidates"
        return result

    selection_strategy = strategy
    if strategy.use_event_sleeve and strategy.event_sleeve_soft_day_only:
        selection_strategy = strategy.model_copy(update={"use_event_sleeve": False, "event_weight": 0.0})

    top_tickers = _select_momentum_sleeves(
        morning_gains,
        selection_strategy,
        ticker_sectors=ticker_sectors,
    )
    if not top_tickers:
        result.skip_reason = "no_candidates"
        return result
    if len(top_tickers) < max(1, strategy.min_positions_to_trade):
        result.skip_reason = "below_min_candidates"
        return result

    sector_scaler = _basket_sector_scaler(top_tickers, ticker_sectors, strategy)
    result.sector_scaler = sector_scaler
    base_soft_day = (
        regime_scaler * breadth_scaler * sector_scaler
    ) < strategy.soft_day_scaler_threshold
    result.is_soft_day = base_soft_day
    if _should_enable_soft_day_event_sleeve(
        top_tickers,
        morning_gains,
        strategy,
        base_soft_day=base_soft_day,
    ):
        soft_day_tickers = _select_momentum_sleeves(
            morning_gains,
            strategy,
            ticker_sectors=ticker_sectors,
        )
        if soft_day_tickers:
            top_tickers = soft_day_tickers
            sector_scaler = _basket_sector_scaler(top_tickers, ticker_sectors, strategy)
            result.sector_scaler = sector_scaler
            result.is_soft_day = (
                regime_scaler * breadth_scaler * sector_scaler
            ) < strategy.soft_day_scaler_threshold
    if result.is_soft_day and strategy.soft_day_max_trades is not None and strategy.soft_day_max_trades > 0:
        top_tickers = top_tickers[: strategy.soft_day_max_trades]
        sector_scaler = _basket_sector_scaler(top_tickers, ticker_sectors, strategy)
        result.sector_scaler = sector_scaler
        result.is_soft_day = (regime_scaler * breadth_scaler * sector_scaler) < strategy.soft_day_scaler_threshold
    soft_day_sparse_scaler = _soft_day_sparse_scaler(
        top_tickers,
        morning_gains,
        strategy,
        is_soft_day=result.is_soft_day,
    )
    result.soft_day_sparse_scaler = soft_day_sparse_scaler
    tail_risk_scaler = min(
        _tail_risk_day_scaler(top_tickers, morning_gains, strategy),
        _low_momentum_single_name_scaler(top_tickers, morning_gains, strategy),
        soft_day_sparse_scaler,
    )
    result.tail_risk_scaler = tail_risk_scaler
    event_day_liquid_activation = _event_day_liquid_activation_stats(morning_gains, strategy)
    result.event_day_liquid_active = bool(event_day_liquid_activation.get("qualifies"))
    result.event_day_liquid_event_count = int(event_day_liquid_activation.get("event_count") or 0)
    result.event_day_liquid_total_event_entry_dollar_volume = round(
        float(event_day_liquid_activation.get("total_entry_dollar_volume") or 0.0),
        2,
    )
    event_day_liquid_picks = _select_event_day_liquid_picks(
        morning_gains,
        top_tickers,
        strategy,
        is_soft_day=result.is_soft_day,
        activation_stats=event_day_liquid_activation,
    )
    liquid_cluster_picks = _select_liquid_cluster_picks(
        morning_gains,
        top_tickers + event_day_liquid_picks,
        strategy,
    )
    sector_etf_picks = _select_sector_etf_picks(
        morning_gains,
        liquid_cluster_stats,
        top_tickers,
        liquid_cluster_picks,
        sector_proxy_bars_by_ticker,
        strategy,
    )

    if strategy.daily_budget_reset:
        # Research mode: every day resets to initial_capital (ignore prior-day PnL).
        sizing_capital = strategy.initial_capital
    elif strategy.compound_returns and current_equity is not None:
        sizing_capital = max(current_equity, 0.0)
    elif current_equity is not None:
        # Simple mode: fixed at initial_capital, but cannot exceed actual equity
        # (can't invest money you don't have after drawdowns).
        sizing_capital = min(strategy.initial_capital, max(current_equity, 0.0))
    else:
        sizing_capital = strategy.initial_capital
    capital_budget = (
        sizing_capital
        * _vix_day_scaler(vix_value, strategy)
        * regime_scaler
        * breadth_scaler
        * sector_scaler
        * tail_risk_scaler
        * _sparse_day_scaler(len(top_tickers), strategy)
    )
    event_day_liquid_fraction = (
        max(0.0, min(1.0, strategy.event_day_liquid_capital_fraction))
        if event_day_liquid_picks
        else 0.0
    )
    liquid_cluster_fraction = (
        max(0.0, min(1.0, strategy.liquid_cluster_capital_fraction))
        if liquid_cluster_picks
        else 0.0
    )
    sector_etf_fraction = (
        max(0.0, min(1.0, strategy.sector_etf_capital_fraction))
        if sector_etf_picks
        else 0.0
    )
    reserved_fraction = event_day_liquid_fraction + liquid_cluster_fraction + sector_etf_fraction
    if reserved_fraction > 1.0:
        event_day_liquid_fraction /= reserved_fraction
        liquid_cluster_fraction /= reserved_fraction
        sector_etf_fraction /= reserved_fraction
        base_fraction = 0.0
    else:
        base_fraction = 1.0 - reserved_fraction

    base_capital_budget = capital_budget * base_fraction
    event_day_liquid_capital_budget = capital_budget * event_day_liquid_fraction
    liquid_cluster_capital_budget = capital_budget * liquid_cluster_fraction
    sector_etf_capital_budget = capital_budget * sector_etf_fraction

    if top_tickers and base_capital_budget > 0:
        capital_per_trade = base_capital_budget / len(top_tickers)
        for ticker, sleeve in top_tickers:
            info = morning_gains[ticker]
            trade_capital = capital_per_trade * _entropy_trade_scaler(info.get("entropy_20d"), strategy)
            trade = _build_intraday_trade(
                ticker,
                info,
                date_str=date_str,
                strategy=strategy,
                trade_capital=trade_capital,
                sleeve=sleeve,
            )
            result.trades.append(trade)
            result.daily_pnl += trade.pnl

    if event_day_liquid_picks and event_day_liquid_capital_budget > 0:
        event_day_liquid_capital_per_trade = event_day_liquid_capital_budget / len(event_day_liquid_picks)
        for ticker, sleeve in event_day_liquid_picks:
            info = morning_gains[ticker]
            trade_capital = event_day_liquid_capital_per_trade * _entropy_trade_scaler(
                info.get("entropy_20d"),
                strategy,
            )
            trade = _build_intraday_trade(
                ticker,
                info,
                date_str=date_str,
                strategy=strategy,
                trade_capital=trade_capital,
                sleeve=sleeve,
            )
            result.trades.append(trade)
            result.daily_pnl += trade.pnl

    if liquid_cluster_picks and liquid_cluster_capital_budget > 0:
        cluster_capital_per_trade = liquid_cluster_capital_budget / len(liquid_cluster_picks)
        for ticker, sleeve in liquid_cluster_picks:
            info = morning_gains[ticker]
            trade_capital = cluster_capital_per_trade * _entropy_trade_scaler(info.get("entropy_20d"), strategy)
            trade = _build_intraday_trade(
                ticker,
                info,
                date_str=date_str,
                strategy=strategy,
                trade_capital=trade_capital,
                sleeve=sleeve,
            )
            result.trades.append(trade)
            result.daily_pnl += trade.pnl

    if sector_etf_picks and sector_etf_capital_budget > 0 and sector_proxy_bars_by_ticker:
        etf_capital_per_trade = sector_etf_capital_budget / len(sector_etf_picks)
        for proxy_ticker, sleeve, sector in sector_etf_picks:
            proxy_info = _execution_info_from_bars(
                sector_proxy_bars_by_ticker.get(proxy_ticker, []),
                strategy,
                date_str,
            )
            if not proxy_info:
                continue
            stats = liquid_cluster_stats.get(sector, {})
            proxy_info["liquid_cluster_sector"] = sector
            proxy_info["liquid_cluster_sector_score"] = float(stats.get("sector_score") or 0.0)
            proxy_info["liquid_cluster_member_count"] = int(stats.get("member_count") or 0)
            proxy_info["liquid_cluster_total_entry_dollar_volume"] = float(
                stats.get("total_entry_dollar_volume") or 0.0
            )
            proxy_info["sector_proxy_ticker"] = proxy_ticker
            trade = _build_intraday_trade(
                proxy_ticker,
                proxy_info,
                date_str=date_str,
                strategy=strategy,
                trade_capital=etf_capital_per_trade,
                sleeve=sleeve,
            )
            result.trades.append(trade)
            result.daily_pnl += trade.pnl

    if result.trades:
        total_deployed = sum((t.total_capital_deployed or (t.shares * t.entry_price)) for t in result.trades)
        result.capital_deployed = round(total_deployed, 4)
        # Daily return should reflect portfolio-level exposure, not just deployed capital.
        # This keeps sparse-day / VIX / entropy size scaling visible in Sharpe and loss metrics.
        if sizing_capital > 0:
            result.daily_return_pct = result.daily_pnl / sizing_capital

    return result


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


def run_simulation(
    all_intraday: dict[str, dict[str, list[dict]]],
    trading_days: list[str],
    strategy: StrategyParams,
    *,
    daily_enrichment: dict[str, dict[str, dict]] | None = None,
    vix_by_day: dict[str, float] | None = None,
    ticker_sectors: dict[str, str] | None = None,
    sector_proxy_intraday_by_day: dict[str, dict[str, list[dict]]] | None = None,
) -> list[DayResult]:
    """Run the full backtest simulation across all trading days.

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

    Implements:
    - Ticker cooldown (blackout period after trading a ticker)
    - Market regime filter via SPY bars
    - All strategy filters (max gain, min volume, trailing stop, etc.)

    Args:
        all_intraday: {date: {ticker: [bars]}} — pre-loaded intraday data.
        trading_days: Ordered list of dates to simulate.
        strategy: Strategy parameters.

    Returns:
        List of DayResult objects (one per trading day; days without intraday data get a 0% return result).
    """
    results: list[DayResult] = []

    # Ticker cooldown: map ticker -> last traded date
    ticker_last_traded: dict[str, dt.date] = {}

    # Compound return tracking: equity grows with each day's P&L
    equity = strategy.initial_capital
    rolling_pnl_window: list[float] = []

    for date_str in trading_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

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

        if (
            strategy.rolling_loss_days is not None
            and strategy.rolling_loss_threshold is not None
            and len(rolling_pnl_window) >= strategy.rolling_loss_days
        ):
            n_roll = strategy.rolling_loss_days
            rolling_pnl = sum(rolling_pnl_window[-n_roll:])
            if strategy.daily_budget_reset or not strategy.compound_returns:
                sizing_capital_for_check = strategy.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 < strategy.rolling_loss_threshold:
                    results.append(DayResult(date=date_str, skip_reason="rolling_loss"))
                    rolling_pnl_window.append(0.0)
                    continue

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

        day_features_by_ticker = (
            {
                ticker: daily_enrichment.get(ticker, {}).get(date_str, {})
                for ticker in bars_by_ticker.keys()
            }
            if daily_enrichment else None
        )
        if daily_enrichment and (
            strategy.market_regime_gap_threshold is not None
            or strategy.regime_size_scale_low is not None
            or strategy.regime_skip_below is not None
        ):
            regime_ticker = strategy.market_regime_gap_ticker or "SPY"
            day_features_by_ticker = day_features_by_ticker or {}
            day_features_by_ticker.setdefault(
                regime_ticker,
                daily_enrichment.get(regime_ticker, {}).get(date_str, {}),
            )

        day_result = simulate_day(
            bars_by_ticker,
            date_str,
            strategy,
            blacklisted_tickers=blacklisted if blacklisted else None,
            spy_bars=spy_bars,
            daily_features_by_ticker=day_features_by_ticker,
            vix_value=(vix_by_day or {}).get(date_str),
            current_equity=equity,
            ticker_sectors=ticker_sectors,
            sector_proxy_bars_by_ticker=(sector_proxy_intraday_by_day or {}).get(date_str),
        )
        results.append(day_result)
        equity += day_result.daily_pnl
        rolling_pnl_window.append(day_result.daily_pnl)

        # Update cooldown tracker
        if strategy.ticker_cooldown_days > 0:
            current_date = dt.date.fromisoformat(date_str)
            for trade in day_result.trades:
                ticker_last_traded[trade.ticker] = current_date

    return results
