from __future__ import annotations

import datetime as dt

import pytest

from libs.intraday.domain import ORBStrategyParams
from libs.intraday.orb_simulator import (
    ORBSimulationState,
    _build_idle_sleeve_orders,
    _entry_market_guard_scale,
    _market_return_at_entry_open,
    _orb_form4_size_scale,
    _orb_liquid_leader_conviction_size_scale,
    _orb_opening_burst_liquid_size_scale,
    _orb_ownership_initial_size_scale,
    _orb_sector_confirmation_size_scale,
    _orb_soft_day_sector_confirmation_override_allows,
    _orb_soft_day_sector_confirmation_override_base_sizing,
    _orb_soft_day_setup_profile_allows,
    _orb_soft_day_vwap_min_score_pct,
    _orb_soft_day_vwap_reason_allowed,
    _orb_soft_day_vwap_reason_param,
    _orb_soft_day_vwap_size_scale,
    _orb_unboosted_primary_fragility_size_scale,
    _orb_unsupported_attention_size_scale,
    compute_orb_candidates,
    run_orb_simulation,
    run_orb_simulation_with_state,
    simulate_orb_day,
    simulate_orb_trade,
)


def _enrichment_for(*tickers: str) -> dict[str, dict[str, dict]]:
    return {
        ticker: {
            "2026-01-05": {
                "atr_14": 1.0,
                "avg_dollar_vol_30d": 1_000_000_000.0,
                "avg_daily_vol_14d": 10_000.0,
                "prev_close": 99.0,
                "today_open": 100.0,
                "entropy_20d": 0.5,
                "atr_ratio_10_60": 0.8,
                "range_compression_10_60": 0.7,
                "gap_zscore_20d": 0.2,
            }
        }
        for ticker in tickers
    }


def _parse_test_ts(value: str) -> dt.datetime:
    return dt.datetime.fromisoformat(value)


def _idle_bars(open_price: float, close_price: float) -> list[dict]:
    return [
        {
            "timestamp": "2026-01-05T09:30:00-05:00",
            "open": open_price,
            "high": max(open_price, close_price),
            "low": min(open_price, close_price),
            "close": open_price,
            "volume": 100_000,
        },
        {
            "timestamp": "2026-01-05T15:55:00-05:00",
            "open": close_price,
            "high": close_price,
            "low": close_price,
            "close": close_price,
            "volume": 100_000,
        },
    ]


def _idle_reclaim_bars(open_price: float, checkpoint_close: float, close_price: float) -> list[dict]:
    return [
        {
            "timestamp": "2026-01-05T09:30:00-05:00",
            "open": open_price,
            "high": max(open_price, checkpoint_close),
            "low": min(open_price, checkpoint_close),
            "close": open_price,
            "volume": 100_000,
        },
        {
            "timestamp": "2026-01-05T10:30:00-05:00",
            "open": open_price,
            "high": max(open_price, checkpoint_close),
            "low": min(open_price, checkpoint_close),
            "close": checkpoint_close,
            "volume": 100_000,
        },
        {
            "timestamp": "2026-01-05T15:55:00-05:00",
            "open": checkpoint_close,
            "high": max(open_price, checkpoint_close, close_price),
            "low": min(open_price, checkpoint_close, close_price),
            "close": close_price,
            "volume": 100_000,
        },
    ]


def test_pead_like_idle_parking_uses_tqqq_on_strong_market_close() -> None:
    params = ORBStrategyParams(
        orb_idle_sleeve_enabled=True,
        orb_idle_sleeve_parking_mode="pead_like",
        orb_idle_sleeve_parking_symbols=["QQQM", "TQQQ"],
        orb_idle_sleeve_weights={"parking": 1.0},
        orb_idle_sleeve_market_ticker="QQQ",
        orb_idle_sleeve_min_market_day_return_pct=-0.005,
        orb_idle_sleeve_parking_overlay_min_market_return_pct=0.005,
        orb_idle_sleeve_parking_overlay_min_market_close_location=0.70,
    )
    orders = _build_idle_sleeve_orders(
        {
            "QQQ": _idle_bars(100.0, 101.0),
            "QQQM": _idle_bars(100.0, 100.4),
            "TQQQ": _idle_bars(100.0, 103.0),
        },
        "2026-01-05",
        params,
        {},
    )

    assert [(order.ticker, order.sleeve) for order in orders] == [("TQQQ", "parking")]


def test_pead_like_idle_parking_uses_qqqm_on_ordinary_risk_on_close() -> None:
    params = ORBStrategyParams(
        orb_idle_sleeve_enabled=True,
        orb_idle_sleeve_parking_mode="pead_like",
        orb_idle_sleeve_parking_symbols=["QQQM", "TQQQ"],
        orb_idle_sleeve_weights={"parking": 1.0},
        orb_idle_sleeve_market_ticker="QQQ",
        orb_idle_sleeve_min_market_day_return_pct=-0.005,
        orb_idle_sleeve_parking_overlay_min_market_return_pct=0.005,
        orb_idle_sleeve_parking_overlay_min_market_close_location=0.70,
    )
    orders = _build_idle_sleeve_orders(
        {
            "QQQ": _idle_bars(100.0, 100.1),
            "QQQM": _idle_bars(100.0, 100.2),
            "TQQQ": _idle_bars(100.0, 100.6),
        },
        "2026-01-05",
        params,
        {},
    )

    assert [(order.ticker, order.sleeve) for order in orders] == [("QQQM", "parking")]


def test_pead_like_idle_parking_falls_back_to_sgov_when_risk_on_disallowed() -> None:
    params = ORBStrategyParams(
        orb_idle_sleeve_enabled=True,
        orb_idle_sleeve_parking_mode="pead_like",
        orb_idle_sleeve_parking_symbols=["QQQM", "TQQQ"],
        orb_idle_sleeve_risk_off_symbols=["GLD", "SGOV"],
        orb_idle_sleeve_weights={"parking": 1.0},
        orb_idle_sleeve_market_ticker="QQQ",
        orb_idle_sleeve_min_market_day_return_pct=-0.005,
        orb_idle_sleeve_force_defensive_fallback=True,
    )
    orders = _build_idle_sleeve_orders(
        {
            "QQQ": _idle_bars(100.0, 99.0),
            "QQQM": _idle_bars(100.0, 99.5),
            "TQQQ": _idle_bars(100.0, 97.0),
            "GLD": _idle_bars(100.0, 101.0),
            "SGOV": _idle_bars(100.0, 100.01),
        },
        "2026-01-05",
        params,
        {},
    )

    assert [(order.ticker, order.sleeve) for order in orders] == [("SGOV", "parking")]


def test_idle_sleeve_can_build_pead_like_five_sleeve_book() -> None:
    params = ORBStrategyParams(
        orb_idle_sleeve_enabled=True,
        orb_idle_sleeve_parking_mode="pead_like",
        orb_idle_sleeve_parking_symbols=["QQQM", "TQQQ"],
        orb_idle_sleeve_risk_off_symbols=["GLD", "SGOV"],
        orb_idle_sleeve_weights={
            "parking": 0.35,
            "idle_alpha": 0.30,
            "form4": 0.15,
            "ownership": 0.10,
            "risk_off_alpha": 0.10,
        },
        orb_idle_sleeve_max_positions=5,
        orb_idle_sleeve_market_ticker="QQQ",
        orb_idle_sleeve_min_market_day_return_pct=-0.005,
        orb_idle_sleeve_min_market_day_return_for_stock_sleeves_pct=-0.005,
    )
    enrichment = _enrichment_for("ALPHA", "F4", "OWN")
    enrichment["F4"]["2026-01-05"].update(
        {
            "form4_flag": True,
            "form4_owner_count": 2,
            "form4_c_suite_count": 1,
            "form4_total_value": 1_000_000,
        }
    )
    enrichment["OWN"]["2026-01-05"].update(
        {
            "ownership_13dg_flag": True,
            "ownership_13dg_initial_flag": True,
            "ownership_13dg_strength_score": 3.0,
        }
    )

    orders = _build_idle_sleeve_orders(
        {
            "QQQ": _idle_bars(100.0, 99.8),
            "QQQM": _idle_bars(100.0, 100.1),
            "TQQQ": _idle_bars(100.0, 100.3),
            "GLD": _idle_bars(100.0, 100.4),
            "SGOV": _idle_bars(100.0, 100.01),
            "ALPHA": _idle_bars(100.0, 102.0),
            "F4": _idle_bars(100.0, 100.2),
            "OWN": _idle_bars(100.0, 100.3),
        },
        "2026-01-05",
        params,
        enrichment,
    )

    assert {(order.ticker, order.sleeve) for order in orders} == {
        ("QQQM", "parking"),
        ("ALPHA", "idle_alpha"),
        ("F4", "form4"),
        ("OWN", "ownership"),
        ("GLD", "risk_off_alpha"),
    }


def test_idle_sleeve_can_build_late_close_reclaim_book() -> None:
    params = ORBStrategyParams(
        orb_idle_sleeve_enabled=True,
        orb_idle_sleeve_weights={"close_reclaim": 1.0},
        orb_idle_sleeve_reclaim_checkpoint_minutes=60,
        orb_idle_sleeve_reclaim_min_day_return_pct=0.004,
        orb_idle_sleeve_reclaim_max_early_return_pct=0.004,
        orb_idle_sleeve_reclaim_min_late_return_pct=0.006,
        orb_idle_sleeve_reclaim_min_close_location=0.70,
        orb_idle_sleeve_reclaim_min_day_dollar_vol=20_000_000,
        orb_idle_sleeve_reclaim_min_avg_dollar_vol=80_000_000,
    )
    orders = _build_idle_sleeve_orders(
        {
            "RECL": _idle_reclaim_bars(100.0, 99.2, 102.0),
            "MORN": _idle_reclaim_bars(100.0, 102.0, 103.0),
        },
        "2026-01-05",
        params,
        _enrichment_for("RECL", "MORN"),
    )

    assert [(order.ticker, order.sleeve) for order in orders] == [("RECL", "close_reclaim")]


def test_idle_sleeve_can_build_sector_rotation_book() -> None:
    params = ORBStrategyParams(
        orb_idle_sleeve_enabled=True,
        orb_idle_sleeve_weights={"sector_rotation": 1.0},
        orb_idle_sleeve_sector_rotation_symbols=["XLK", "XLE"],
        orb_idle_sleeve_sector_rotation_min_day_return_pct=0.002,
        orb_idle_sleeve_sector_rotation_min_close_location=0.60,
        orb_idle_sleeve_min_market_day_return_pct=None,
    )
    orders = _build_idle_sleeve_orders(
        {
            "XLK": _idle_bars(100.0, 100.3),
            "XLE": _idle_bars(100.0, 101.0),
        },
        "2026-01-05",
        params,
        {},
    )

    assert [(order.ticker, order.sleeve) for order in orders] == [("XLE", "sector_rotation")]


def test_idle_sleeve_opens_on_streaming_chunk_boundary() -> None:
    params = ORBStrategyParams(
        min_candidates_to_trade=99,
        initial_capital=10_000.0,
        daily_budget_reset=True,
        compound_returns=False,
        orb_idle_sleeve_enabled=True,
        orb_idle_sleeve_parking_mode="pead_like",
        orb_idle_sleeve_parking_symbols=["QQQM"],
        orb_idle_sleeve_weights={"parking": 1.0},
        orb_idle_sleeve_market_ticker="QQQ",
        orb_idle_sleeve_min_market_day_return_pct=-0.005,
    )
    day1 = "2026-01-05"
    day2 = "2026-01-06"
    all_intraday = {
        day1: {
            "QQQ": _idle_bars(100.0, 100.2),
            "QQQM": _idle_bars(100.0, 100.5),
        },
        day2: {
            "QQQM": [
                {
                    "timestamp": f"{day2}T09:30:00-05:00",
                    "open": 101.0,
                    "high": 101.1,
                    "low": 100.9,
                    "close": 101.0,
                    "volume": 100_000,
                }
            ],
        },
    }

    chunk1, state = run_orb_simulation_with_state(
        {day1: all_intraday[day1]},
        [day1],
        params,
        {},
        next_trading_day_after_window=day2,
    )
    chunk2, _ = run_orb_simulation_with_state(
        {day2: all_intraday[day2]},
        [day2],
        params,
        {},
        state=state,
    )

    assert chunk1[0].entry_diagnostics["orb_idle_sleeve_symbols_opened"] == ["QQQM"]
    assert len(chunk2[0].trades) == 1
    assert chunk2[0].trades[0].ticker == "QQQM"
    assert chunk2[0].trades[0].exit_reason == "idle_sleeve_next_open"


def test_idle_sleeve_can_enter_after_open_and_exit_same_day_close() -> None:
    params = ORBStrategyParams(
        min_candidates_to_trade=99,
        initial_capital=10_000.0,
        daily_budget_reset=True,
        compound_returns=False,
        orb_idle_sleeve_enabled=True,
        orb_idle_sleeve_entry_timing="minutes_after_open",
        orb_idle_sleeve_entry_minutes_after_open=60,
        orb_idle_sleeve_exit_timing="same_day_close",
        orb_idle_sleeve_parking_mode="pead_like",
        orb_idle_sleeve_parking_symbols=["QQQM"],
        orb_idle_sleeve_weights={"parking": 1.0},
        orb_idle_sleeve_market_ticker="QQQ",
        orb_idle_sleeve_min_market_day_return_pct=-0.005,
    )
    day = "2026-01-05"
    bars = [
        {
            "timestamp": f"{day}T09:30:00-05:00",
            "open": 100.0,
            "high": 100.2,
            "low": 99.9,
            "close": 100.1,
            "volume": 100_000,
        },
        {
            "timestamp": f"{day}T10:30:00-05:00",
            "open": 100.1,
            "high": 100.6,
            "low": 100.0,
            "close": 100.5,
            "volume": 100_000,
        },
        {
            "timestamp": f"{day}T15:55:00-05:00",
            "open": 100.5,
            "high": 101.1,
            "low": 100.4,
            "close": 101.0,
            "volume": 100_000,
        },
    ]

    results = run_orb_simulation(
        {day: {"QQQ": bars, "QQQM": bars}},
        [day],
        params,
        {},
    )

    assert len(results[0].trades) == 1
    trade = results[0].trades[0]
    assert trade.ticker == "QQQM"
    assert trade.entry_time == f"{day}T10:30:00-05:00"
    assert trade.exit_time == f"{day}T15:55:00-05:00"
    assert trade.exit_reason == "idle_sleeve_same_day_close"
    assert trade.orb_idle_sleeve_overnight is False


def test_idle_sleeve_same_day_stop_loss_exits_before_close() -> None:
    params = ORBStrategyParams(
        min_candidates_to_trade=99,
        initial_capital=10_000.0,
        daily_budget_reset=True,
        compound_returns=False,
        orb_idle_sleeve_enabled=True,
        orb_idle_sleeve_entry_timing="minutes_after_open",
        orb_idle_sleeve_entry_minutes_after_open=60,
        orb_idle_sleeve_exit_timing="same_day_close",
        orb_idle_sleeve_same_day_stop_loss_pct=-0.03,
        orb_idle_sleeve_parking_mode="pead_like",
        orb_idle_sleeve_parking_symbols=["QQQM"],
        orb_idle_sleeve_weights={"parking": 1.0},
        orb_idle_sleeve_market_ticker="QQQ",
        orb_idle_sleeve_min_market_day_return_pct=-0.005,
    )
    day = "2026-01-05"
    bars = [
        {
            "timestamp": f"{day}T09:30:00-05:00",
            "open": 100.0,
            "high": 100.2,
            "low": 99.8,
            "close": 100.0,
            "volume": 100_000,
        },
        {
            "timestamp": f"{day}T10:30:00-05:00",
            "open": 100.0,
            "high": 102.2,
            "low": 99.9,
            "close": 102.0,
            "volume": 100_000,
        },
        {
            "timestamp": f"{day}T11:00:00-05:00",
            "open": 102.0,
            "high": 102.1,
            "low": 98.5,
            "close": 99.0,
            "volume": 100_000,
        },
        {
            "timestamp": f"{day}T15:55:00-05:00",
            "open": 99.0,
            "high": 105.0,
            "low": 98.9,
            "close": 105.0,
            "volume": 100_000,
        },
    ]

    results = run_orb_simulation(
        {day: {"QQQ": bars, "QQQM": bars}},
        [day],
        params,
        {},
    )

    trade = results[0].trades[0]
    assert trade.exit_reason == "idle_sleeve_same_day_stop_loss"
    assert trade.exit_time == f"{day}T11:00:00-05:00"
    assert trade.exit_price == 98.94
    assert trade.orb_idle_sleeve_overnight is False


def test_soft_day_setup_profile_rejects_market_regime_only_fallback() -> None:
    params = ORBStrategyParams(soft_day_setup_profile="skip_day_reclaim_v1")
    cand = {
        "score": 1.05,
        "rvol": 10.0,
        "gap_pct": 0.03,
        "premarket_dollar_vol": 100_000_000,
        "body_ratio": 0.8,
        "close_location": 0.9,
    }
    enrich = {"ret_5d": 0.3}

    assert not _orb_soft_day_setup_profile_allows(
        params, "market_regime", cand, enrich
    )


def test_soft_day_setup_profile_allows_breadth_gap_down_momentum() -> None:
    params = ORBStrategyParams(soft_day_setup_profile="skip_day_reclaim_v1")
    cand = {
        "score": 0.70,
        "rvol": 18.0,
        "gap_pct": -0.04,
        "premarket_dollar_vol": 50_000_000,
        "body_ratio": 0.5,
        "close_location": 0.65,
    }
    enrich = {"ret_5d": 0.20}

    assert _orb_soft_day_setup_profile_allows(params, "breadth", cand, enrich)


def test_soft_day_vwap_reason_overrides_default_score_and_size() -> None:
    params = ORBStrategyParams(
        soft_day_vwap_reclaim_min_score_pct=0.85,
        soft_day_vwap_reclaim_min_score_pct_market_regime=0.70,
        soft_day_vwap_reclaim_min_score_pct_breadth=0.80,
        soft_day_vwap_reclaim_min_score_pct_joint=0.90,
        soft_day_vwap_reclaim_size_scale=0.03,
        soft_day_vwap_reclaim_size_scale_market_regime=0.05,
        soft_day_vwap_reclaim_size_scale_joint=0.02,
    )

    assert _orb_soft_day_vwap_min_score_pct(params, "market_regime") == 0.70
    assert _orb_soft_day_vwap_min_score_pct(params, "breadth") == 0.80
    assert _orb_soft_day_vwap_min_score_pct(params, "market_regime+breadth") == 0.90
    assert _orb_soft_day_vwap_min_score_pct(params, None) == 0.85
    assert _orb_soft_day_vwap_size_scale(params, "market_regime") == 0.05
    assert _orb_soft_day_vwap_size_scale(params, "market_regime+breadth") == 0.02
    assert _orb_soft_day_vwap_size_scale(params, "breadth") == 0.03


def test_soft_day_vwap_reason_allowlist_limits_auxiliary_sleeve() -> None:
    params = ORBStrategyParams(
        soft_day_vwap_reclaim_allowed_reason_parts=["hard_breadth"]
    )

    assert _orb_soft_day_vwap_reason_allowed(params, "market_regime+hard_breadth")
    assert not _orb_soft_day_vwap_reason_allowed(params, "market_regime+breadth")
    assert not _orb_soft_day_vwap_reason_allowed(params, "breadth")


def test_soft_day_vwap_reason_quality_overrides_default_gates() -> None:
    params = ORBStrategyParams(
        soft_day_vwap_reclaim_min_premarket_dollar_vol=20_000_000,
        soft_day_vwap_reclaim_min_premarket_dollar_vol_hard_breadth=5_000_000,
        soft_day_vwap_reclaim_min_body_ratio=0.15,
        soft_day_vwap_reclaim_min_body_ratio_hard_breadth=0.0,
        soft_day_vwap_reclaim_min_close_location=0.65,
        soft_day_vwap_reclaim_min_close_location_hard_breadth=0.32,
        soft_day_vwap_reclaim_min_rvol=4.0,
        soft_day_vwap_reclaim_min_rvol_joint=5.0,
        soft_day_vwap_reclaim_max_rvol=12.0,
        soft_day_vwap_reclaim_max_rvol_breadth=35.0,
    )

    assert (
        _orb_soft_day_vwap_reason_param(
            params,
            "market_regime+hard_breadth",
            "soft_day_vwap_reclaim_min_premarket_dollar_vol",
        )
        == 5_000_000
    )
    assert (
        _orb_soft_day_vwap_reason_param(
            params,
            "market_regime",
            "soft_day_vwap_reclaim_min_premarket_dollar_vol",
        )
        == 20_000_000
    )
    assert (
        _orb_soft_day_vwap_reason_param(
            params,
            "market_regime+hard_breadth",
            "soft_day_vwap_reclaim_min_body_ratio",
        )
        == 0.0
    )
    assert (
        _orb_soft_day_vwap_reason_param(
            params,
            "market_regime+hard_breadth",
            "soft_day_vwap_reclaim_min_close_location",
        )
        == 0.32
    )
    assert (
        _orb_soft_day_vwap_reason_param(
            params,
            "breadth",
            "soft_day_vwap_reclaim_min_rvol",
        )
        == 4.0
    )
    assert (
        _orb_soft_day_vwap_reason_param(
            params,
            "market_regime+breadth",
            "soft_day_vwap_reclaim_min_rvol",
        )
        == 5.0
    )
    assert (
        _orb_soft_day_vwap_reason_param(
            params,
            "breadth",
            "soft_day_vwap_reclaim_max_rvol",
        )
        == 35.0
    )


def test_broad_gapup_continuation_admits_bounded_high_gap_candidate() -> None:
    bars = {
        "HIGH": [
            {"timestamp": "2026-01-05T08:00:00-05:00", "open": 100.0, "high": 100.2, "low": 99.8, "close": 100.0, "volume": 300_000},
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.9, "volume": 10_000},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 100.9, "high": 101.5, "low": 100.7, "close": 101.3, "volume": 5_000},
        ],
    }
    enrichment = _enrichment_for("HIGH")
    enrichment["HIGH"]["2026-01-05"]["prev_close"] = 90.0
    enrichment["HIGH"]["2026-01-05"]["ret_5d"] = 0.08
    enrichment["HIGH"]["2026-01-05"]["avg_daily_vol_14d"] = 1_000_000.0

    base_params = ORBStrategyParams(
        engine_family="gainers_leader",
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=1000.0,
        min_abs_gap_pct=0.02,
        max_gap_pct=0.04,
        max_candidates=10,
    )
    assert compute_orb_candidates(bars, "2026-01-05", base_params, enrichment) == []

    params = base_params.model_copy(
        update={
            "broad_gapup_continuation_enabled": True,
            "broad_gapup_continuation_min_gap_pct": 0.04,
            "broad_gapup_continuation_max_gap_pct": 0.20,
            "broad_gapup_continuation_min_rvol": None,
            "broad_gapup_continuation_min_premarket_dollar_vol": 10_000_000,
            "broad_gapup_continuation_min_first_bar_dollar_vol": 500_000,
            "broad_gapup_continuation_min_body_ratio": 0.50,
            "broad_gapup_continuation_min_close_location": 0.80,
        }
    )

    candidates = compute_orb_candidates(bars, "2026-01-05", params, enrichment)

    assert len(candidates) == 1
    assert candidates[0]["ticker"] == "HIGH"
    assert candidates[0]["broad_gapup_continuation"] is True
    assert candidates[0]["rvol"] < base_params.min_rvol

    liquid_only_params = params.model_copy(
        update={"broad_gapup_continuation_min_avg_dollar_vol": 2_000_000_000}
    )
    assert compute_orb_candidates(bars, "2026-01-05", liquid_only_params, enrichment) == []


def test_broad_gapup_continuation_trades_with_dedicated_size_scale() -> None:
    bars = {
        "HIGH": [
            {"timestamp": "2026-01-05T08:00:00-05:00", "open": 100.0, "high": 100.2, "low": 99.8, "close": 100.0, "volume": 300_000},
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.9, "volume": 10_000},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.0, "high": 102.0, "low": 100.8, "close": 101.8, "volume": 5_000},
            {"timestamp": "2026-01-05T15:55:00-05:00", "open": 101.8, "high": 102.0, "low": 101.5, "close": 101.9, "volume": 5_000},
        ],
    }
    enrichment = _enrichment_for("HIGH")
    enrichment["HIGH"]["2026-01-05"]["prev_close"] = 90.0
    enrichment["HIGH"]["2026-01-05"]["ret_5d"] = 0.08
    params = ORBStrategyParams(
        engine_family="gainers_leader",
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        min_abs_gap_pct=0.02,
        max_gap_pct=0.04,
        max_candidates=10,
        min_candidates_to_trade=1,
        atr_stop_multiplier=10.0,
        trailing_at_r=99.0,
        breakeven_at_r=99.0,
        risk_per_trade_pct=0.01,
        max_position_pct=1.0,
        slippage_bps=0.0,
        broad_gapup_continuation_enabled=True,
        broad_gapup_continuation_min_gap_pct=0.04,
        broad_gapup_continuation_max_gap_pct=0.20,
        broad_gapup_continuation_min_rvol=1.0,
        broad_gapup_continuation_min_premarket_dollar_vol=10_000_000,
        broad_gapup_continuation_min_first_bar_dollar_vol=500_000,
        broad_gapup_continuation_min_body_ratio=0.50,
        broad_gapup_continuation_min_close_location=0.80,
        broad_gapup_continuation_size_scale=0.25,
        broad_gapup_continuation_max_trades=1,
    )

    result = simulate_orb_day(bars, "2026-01-05", params, enrichment, equity=10_000.0)

    assert len(result.trades) == 1
    trade = result.trades[0]
    assert trade.trigger_type == "broad_gapup_continuation"
    assert trade.broad_gapup_continuation is True
    assert trade.broad_gapup_continuation_size_scale == 0.25
    assert trade.shares == 2


def test_broad_gapup_continuation_opening_burst_enters_next_bar_open() -> None:
    bars = {
        "HIGH": [
            {"timestamp": "2026-01-05T08:00:00-05:00", "open": 100.0, "high": 100.2, "low": 99.8, "close": 100.0, "volume": 300_000},
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.9, "volume": 10_000},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 100.7, "high": 100.9, "low": 100.2, "close": 100.8, "volume": 5_000},
            {"timestamp": "2026-01-05T15:55:00-05:00", "open": 100.8, "high": 101.4, "low": 100.7, "close": 101.2, "volume": 5_000},
        ],
    }
    enrichment = _enrichment_for("HIGH")
    enrichment["HIGH"]["2026-01-05"]["prev_close"] = 90.0
    enrichment["HIGH"]["2026-01-05"]["ret_5d"] = 0.08
    params = ORBStrategyParams(
        engine_family="gainers_leader",
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        min_abs_gap_pct=0.02,
        max_gap_pct=0.04,
        max_candidates=10,
        min_candidates_to_trade=1,
        atr_stop_multiplier=10.0,
        trailing_at_r=99.0,
        breakeven_at_r=99.0,
        risk_per_trade_pct=0.01,
        max_position_pct=1.0,
        slippage_bps=0.0,
        broad_gapup_continuation_enabled=True,
        broad_gapup_continuation_entry_mode="opening_burst",
        broad_gapup_continuation_min_gap_pct=0.04,
        broad_gapup_continuation_max_gap_pct=0.20,
        broad_gapup_continuation_min_rvol=1.0,
        broad_gapup_continuation_min_premarket_dollar_vol=10_000_000,
        broad_gapup_continuation_min_first_bar_dollar_vol=500_000,
        broad_gapup_continuation_min_body_ratio=0.50,
        broad_gapup_continuation_min_close_location=0.80,
        broad_gapup_continuation_size_scale=0.25,
        broad_gapup_continuation_max_trades=1,
    )

    result = simulate_orb_day(bars, "2026-01-05", params, enrichment, equity=10_000.0)

    assert len(result.trades) == 1
    assert result.trades[0].trigger_type == "broad_gapup_continuation"
    assert result.trades[0].entry_price == 100.7


def test_entry_tie_break_rank_by_score_prioritizes_auxiliary_leader_on_same_bar() -> None:
    bars = {
        "BASE": [
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.8, "volume": 10_000},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.0, "high": 102.0, "low": 100.8, "close": 101.8, "volume": 10_000},
            {"timestamp": "2026-01-05T15:55:00-05:00", "open": 101.8, "high": 102.0, "low": 101.5, "close": 101.9, "volume": 10_000},
        ],
        "HIGH": [
            {"timestamp": "2026-01-05T08:00:00-05:00", "open": 100.0, "high": 100.2, "low": 99.8, "close": 100.0, "volume": 300_000},
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.9, "volume": 100_000},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.0, "high": 102.0, "low": 100.8, "close": 101.8, "volume": 50_000},
            {"timestamp": "2026-01-05T15:55:00-05:00", "open": 101.8, "high": 102.0, "low": 101.5, "close": 101.9, "volume": 50_000},
        ],
    }
    enrichment = _enrichment_for("BASE", "HIGH")
    enrichment["BASE"]["2026-01-05"]["prev_close"] = 98.0
    enrichment["BASE"]["2026-01-05"]["ret_5d"] = 0.03
    enrichment["HIGH"]["2026-01-05"]["prev_close"] = 90.0
    enrichment["HIGH"]["2026-01-05"]["ret_5d"] = 0.08
    params = ORBStrategyParams(
        engine_family="gainers_leader",
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        min_abs_gap_pct=0.02,
        max_gap_pct=0.04,
        max_candidates=10,
        min_candidates_to_trade=1,
        atr_stop_multiplier=1.0,
        trailing_at_r=99.0,
        breakeven_at_r=99.0,
        risk_per_trade_pct=1.0,
        max_position_pct=1.0,
        max_trades_per_day=2,
        slippage_bps=0.0,
        broad_gapup_continuation_enabled=True,
        broad_gapup_continuation_min_gap_pct=0.04,
        broad_gapup_continuation_max_gap_pct=0.20,
        broad_gapup_continuation_min_rvol=1.0,
        broad_gapup_continuation_min_premarket_dollar_vol=10_000_000,
        broad_gapup_continuation_min_first_bar_dollar_vol=500_000,
        broad_gapup_continuation_min_body_ratio=0.50,
        broad_gapup_continuation_min_close_location=0.80,
        broad_gapup_continuation_size_scale=1.0,
        broad_gapup_continuation_max_trades=1,
    )

    legacy = simulate_orb_day(
        bars,
        "2026-01-05",
        params,
        enrichment,
        equity=10_000.0,
        available_cash=10_000.0,
    )
    ranked = simulate_orb_day(
        bars,
        "2026-01-05",
        params.model_copy(update={"entry_tie_break_rank_by_score": True}),
        enrichment,
        equity=10_000.0,
        available_cash=10_000.0,
    )

    assert legacy.trades[0].ticker == "BASE"
    assert ranked.trades[0].ticker == "HIGH"
    assert ranked.trades[0].trigger_type == "broad_gapup_continuation"


def test_broad_gapup_continuation_bypasses_generic_soft_day_profile() -> None:
    bars = {
        "HIGH": [
            {"timestamp": "2026-01-05T08:00:00-05:00", "open": 100.0, "high": 100.2, "low": 99.8, "close": 100.0, "volume": 300_000},
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.9, "volume": 10_000},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.0, "high": 102.0, "low": 100.8, "close": 101.8, "volume": 5_000},
            {"timestamp": "2026-01-05T15:55:00-05:00", "open": 101.8, "high": 102.0, "low": 101.5, "close": 101.9, "volume": 5_000},
        ],
    }
    enrichment = _enrichment_for("HIGH", "QQQ")
    enrichment["HIGH"]["2026-01-05"]["prev_close"] = 90.0
    enrichment["HIGH"]["2026-01-05"]["ret_5d"] = 0.08
    enrichment["QQQ"]["2026-01-05"]["prev_close"] = 100.0
    enrichment["QQQ"]["2026-01-05"]["today_open"] = 100.0
    params = ORBStrategyParams(
        engine_family="gainers_leader",
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        min_abs_gap_pct=0.02,
        max_gap_pct=0.04,
        max_candidates=10,
        min_candidates_to_trade=1,
        atr_stop_multiplier=10.0,
        trailing_at_r=99.0,
        breakeven_at_r=99.0,
        risk_per_trade_pct=0.01,
        max_position_pct=1.0,
        slippage_bps=0.0,
        market_regime_spy_threshold=0.01,
        market_regime_ticker="QQQ",
        soft_day_fallback_on_regime_skip=True,
        soft_day_regime_skip_size_scale=0.1,
        soft_day_combined_size_scale_floor=1.0,
        soft_day_setup_profile="skip_day_reclaim_v1",
        soft_day_min_ret_5d=0.50,
        broad_gapup_continuation_enabled=True,
        broad_gapup_continuation_min_gap_pct=0.04,
        broad_gapup_continuation_max_gap_pct=0.20,
        broad_gapup_continuation_min_rvol=1.0,
        broad_gapup_continuation_min_premarket_dollar_vol=10_000_000,
        broad_gapup_continuation_min_first_bar_dollar_vol=500_000,
        broad_gapup_continuation_min_body_ratio=0.50,
        broad_gapup_continuation_min_close_location=0.80,
        broad_gapup_continuation_size_scale=0.25,
    )

    result = simulate_orb_day(bars, "2026-01-05", params, enrichment, equity=10_000.0)

    assert result.is_soft_day is True
    assert result.soft_day_reason == "market_regime"
    assert len(result.trades) == 1
    assert result.trades[0].trigger_type == "broad_gapup_continuation"


def test_broad_gapup_continuation_scan_is_not_blocked_by_primary_sector_cap() -> None:
    bars = {
        "BASE": [
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 100.8, "low": 99.8, "close": 100.7, "volume": 20_000},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 100.8, "high": 101.2, "low": 100.7, "close": 101.1, "volume": 10_000},
            {"timestamp": "2026-01-05T15:55:00-05:00", "open": 101.1, "high": 101.3, "low": 100.9, "close": 101.2, "volume": 10_000},
        ],
        "HIGH": [
            {"timestamp": "2026-01-05T08:00:00-05:00", "open": 100.0, "high": 100.2, "low": 99.8, "close": 100.0, "volume": 300_000},
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.9, "volume": 10_000},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.0, "high": 102.0, "low": 100.8, "close": 101.8, "volume": 5_000},
            {"timestamp": "2026-01-05T15:55:00-05:00", "open": 101.8, "high": 102.0, "low": 101.5, "close": 101.9, "volume": 5_000},
        ],
    }
    enrichment = _enrichment_for("BASE", "HIGH")
    enrichment["BASE"]["2026-01-05"]["prev_close"] = 98.0
    enrichment["BASE"]["2026-01-05"]["ret_5d"] = 0.03
    enrichment["HIGH"]["2026-01-05"]["prev_close"] = 90.0
    enrichment["HIGH"]["2026-01-05"]["ret_5d"] = 0.08
    params = ORBStrategyParams(
        engine_family="gainers_leader",
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        min_abs_gap_pct=0.02,
        max_gap_pct=0.04,
        max_candidates=1,
        max_candidates_per_sector=1,
        min_candidates_to_trade=1,
        atr_stop_multiplier=10.0,
        trailing_at_r=99.0,
        breakeven_at_r=99.0,
        risk_per_trade_pct=0.01,
        max_position_pct=1.0,
        slippage_bps=0.0,
        broad_gapup_continuation_enabled=True,
        broad_gapup_continuation_min_gap_pct=0.04,
        broad_gapup_continuation_max_gap_pct=0.20,
        broad_gapup_continuation_min_rvol=1.0,
        broad_gapup_continuation_min_premarket_dollar_vol=10_000_000,
        broad_gapup_continuation_min_first_bar_dollar_vol=500_000,
        broad_gapup_continuation_min_body_ratio=0.50,
        broad_gapup_continuation_min_close_location=0.80,
        broad_gapup_continuation_size_scale=0.25,
        broad_gapup_continuation_max_candidates=1,
        broad_gapup_continuation_max_trades=1,
    )

    result = simulate_orb_day(
        bars,
        "2026-01-05",
        params,
        enrichment,
        equity=10_000.0,
        ticker_sectors={"BASE": "TECH", "HIGH": "TECH"},
    )

    assert "broad_gapup_continuation" in {trade.trigger_type for trade in result.trades}


def test_intraday_continuation_can_use_dedicated_low_atr_pct_gate() -> None:
    bars = {
        "LIQ": [
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 100.2, "low": 99.8, "close": 100.1, "volume": 10_000},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 100.1, "high": 100.5, "low": 100.0, "close": 100.4, "volume": 10_000},
            {"timestamp": "2026-01-05T09:40:00-05:00", "open": 100.4, "high": 100.9, "low": 100.3, "close": 100.8, "volume": 10_000},
            {"timestamp": "2026-01-05T09:45:00-05:00", "open": 100.8, "high": 101.2, "low": 100.7, "close": 101.1, "volume": 10_000},
            {"timestamp": "2026-01-05T09:50:00-05:00", "open": 101.1, "high": 101.6, "low": 101.0, "close": 101.5, "volume": 10_000},
            {"timestamp": "2026-01-05T09:55:00-05:00", "open": 101.5, "high": 102.1, "low": 101.4, "close": 102.0, "volume": 10_000},
            {"timestamp": "2026-01-05T10:00:00-05:00", "open": 102.1, "high": 103.0, "low": 102.0, "close": 102.8, "volume": 10_000},
            {"timestamp": "2026-01-05T15:55:00-05:00", "open": 102.8, "high": 103.2, "low": 102.6, "close": 103.0, "volume": 10_000},
        ],
    }
    enrichment = _enrichment_for("LIQ")
    enrichment["LIQ"]["2026-01-05"]["atr_14"] = 2.0
    enrichment["LIQ"]["2026-01-05"]["prev_close"] = 99.0

    base_params = ORBStrategyParams(
        engine_family="gainers_leader",
        min_price=10.0,
        min_avg_dollar_volume=100_000_000.0,
        min_atr_14=0.1,
        min_atr_pct=0.04,
        min_rvol=1000.0,
        min_abs_gap_pct=0.02,
        max_gap_pct=0.04,
        max_candidates=10,
    )
    assert compute_orb_candidates(bars, "2026-01-05", base_params, enrichment) == []

    params = base_params.model_copy(
        update={
            "intraday_continuation_reclaim_enabled": True,
            "intraday_continuation_reclaim_signal_minutes": 30,
            "intraday_continuation_reclaim_min_signal_return_pct": 0.015,
            "intraday_continuation_reclaim_min_signal_dollar_vol": 1_000_000,
            "intraday_continuation_reclaim_min_signal_close_location": 0.80,
            "intraday_continuation_reclaim_require_signal_above_vwap": False,
            "intraday_continuation_reclaim_min_gap_pct": -0.02,
            "intraday_continuation_reclaim_max_gap_pct": 0.02,
            "intraday_continuation_reclaim_min_avg_dollar_vol": 100_000_000,
            "intraday_continuation_reclaim_min_atr_pct": 0.01,
            "intraday_continuation_reclaim_max_atr_pct": 0.03,
        }
    )

    candidates = compute_orb_candidates(bars, "2026-01-05", params, enrichment)

    assert len(candidates) == 1
    assert candidates[0]["ticker"] == "LIQ"
    assert candidates[0]["intraday_continuation_reclaim"] is True
    assert candidates[0]["intraday_continuation_entry_price"] == 102.1

    too_low_volatility_ceiling = params.model_copy(
        update={"intraday_continuation_reclaim_max_atr_pct": 0.015}
    )
    assert compute_orb_candidates(
        bars,
        "2026-01-05",
        too_low_volatility_ceiling,
        enrichment,
    ) == []


def test_market_thrust_liquid_opening_burst_enters_without_breakout() -> None:
    bars = {
        "BURST": [
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.8, "close": 100.9, "volume": 10_000},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.2, "high": 101.2, "low": 100.8, "close": 101.0, "volume": 5_000},
            {"timestamp": "2026-01-05T15:55:00-05:00", "open": 101.0, "high": 102.2, "low": 100.9, "close": 102.0, "volume": 5_000},
        ],
    }
    enrichment = _enrichment_for("BURST")
    enrichment["BURST"]["2026-01-05"]["prev_close"] = 98.0
    enrichment["BURST"]["2026-01-05"]["ret_5d"] = 0.03
    params = ORBStrategyParams(
        engine_family="gainers_leader",
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=1000.0,
        min_abs_gap_pct=0.01,
        max_gap_pct=0.04,
        max_candidates=10,
        min_candidates_to_trade=1,
        min_candidate_breadth=1.1,
        soft_day_fallback_on_breadth_skip=True,
        soft_day_breadth_skip_size_scale=0.1,
        soft_day_combined_size_scale_floor=1.0,
        atr_stop_multiplier=10.0,
        trailing_at_r=99.0,
        breakeven_at_r=99.0,
        risk_per_trade_pct=0.01,
        max_position_pct=1.0,
        slippage_bps=0.0,
        market_thrust_breadth_override_enabled=True,
        market_thrust_liquid_continuation_enabled=True,
        market_thrust_liquid_continuation_entry_mode="opening_burst",
        market_thrust_liquid_continuation_min_gap_pct=0.02,
        market_thrust_liquid_continuation_max_gap_pct=0.04,
        market_thrust_liquid_continuation_min_first_bar_return_pct=0.007,
        market_thrust_liquid_continuation_min_first_bar_dollar_vol=100_000,
        market_thrust_liquid_continuation_min_avg_dollar_vol=100_000,
        market_thrust_liquid_continuation_min_body_ratio=0.5,
        market_thrust_liquid_continuation_min_close_location=0.8,
        market_thrust_liquid_continuation_max_candidates=1,
        market_thrust_liquid_continuation_max_trades=1,
        market_thrust_liquid_continuation_size_scale=0.2,
    )

    result = simulate_orb_day(bars, "2026-01-05", params, enrichment, equity=10_000.0)

    assert result.entry_diagnostics["market_thrust_opening_burst_candidates"] == 1
    assert len(result.trades) == 1
    trade = result.trades[0]
    assert trade.trigger_type == "market_thrust_opening_burst"
    assert trade.entry_time == "2026-01-05T09:35:00-05:00"
    assert trade.entry_price == 101.2
    assert trade.market_thrust_opening_burst is True


def test_iex_live_volume_multiplier_rescales_market_thrust_first_bar_gate() -> None:
    bars = {
        "IEX": [
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.8, "close": 100.9, "volume": 500},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.2, "high": 101.5, "low": 100.8, "close": 101.3, "volume": 500},
            {"timestamp": "2026-01-05T15:55:00-05:00", "open": 101.3, "high": 102.0, "low": 101.0, "close": 101.8, "volume": 500},
        ],
    }
    enrichment = _enrichment_for("IEX")
    enrichment["IEX"]["2026-01-05"]["prev_close"] = 98.0
    enrichment["IEX"]["2026-01-05"]["avg_dollar_vol_30d"] = 300_000_000.0

    params = ORBStrategyParams(
        engine_family="gainers_leader",
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=None,
        min_abs_gap_pct=0.01,
        max_gap_pct=0.04,
        max_candidates=10,
        market_thrust_liquid_continuation_enabled=True,
        market_thrust_liquid_continuation_min_gap_pct=0.02,
        market_thrust_liquid_continuation_max_gap_pct=0.04,
        market_thrust_liquid_continuation_min_first_bar_return_pct=0.007,
        market_thrust_liquid_continuation_min_first_bar_dollar_vol=1_000_000,
        market_thrust_liquid_continuation_min_avg_dollar_vol=100_000_000,
        market_thrust_liquid_continuation_min_body_ratio=0.5,
        market_thrust_liquid_continuation_min_close_location=0.8,
    )

    unscaled = compute_orb_candidates(
        bars,
        "2026-01-05",
        params,
        enrichment,
        iex_live_mode=True,
    )
    scaled = compute_orb_candidates(
        bars,
        "2026-01-05",
        params.model_copy(update={"iex_live_intraday_volume_multiplier": 30.0}),
        enrichment,
        iex_live_mode=True,
    )

    assert len(unscaled) == 1
    assert unscaled[0]["market_thrust_liquid_continuation"] is False
    assert len(scaled) == 1
    assert scaled[0]["market_thrust_liquid_continuation"] is True
    assert scaled[0]["first_bar_dollar_vol"] == 50_000.0
    assert scaled[0]["filter_first_bar_dollar_vol"] == 1_500_000.0


def test_no_thrust_liquid_repair_can_rank_by_opening_impulse() -> None:
    bars = {
        "SCORE": [
            {"timestamp": "2026-01-05T08:00:00-05:00", "open": 100.0, "high": 100.0, "low": 100.0, "close": 100.0, "volume": 1_000_000},
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.2, "low": 99.9, "close": 101.0, "volume": 20_000},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.1, "high": 101.5, "low": 100.8, "close": 101.2, "volume": 10_000},
            {"timestamp": "2026-01-05T15:55:00-05:00", "open": 101.2, "high": 101.4, "low": 101.0, "close": 101.3, "volume": 10_000},
        ],
        "IMPULSE": [
            {"timestamp": "2026-01-05T08:00:00-05:00", "open": 100.0, "high": 100.0, "low": 100.0, "close": 100.0, "volume": 10_000},
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 103.8, "low": 99.9, "close": 103.5, "volume": 2_000},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 103.6, "high": 104.2, "low": 103.3, "close": 104.0, "volume": 2_000},
            {"timestamp": "2026-01-05T15:55:00-05:00", "open": 104.0, "high": 104.5, "low": 103.8, "close": 104.4, "volume": 2_000},
        ],
    }
    enrichment = _enrichment_for("SCORE", "IMPULSE")
    for ticker in enrichment:
        enrichment[ticker]["2026-01-05"]["prev_close"] = 99.0
        enrichment[ticker]["2026-01-05"]["ret_5d"] = 0.02

    params = ORBStrategyParams(
        engine_family="gainers_leader",
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=1000.0,
        min_abs_gap_pct=0.02,
        max_gap_pct=0.04,
        max_candidates=10,
        min_candidates_to_trade=1,
        min_candidate_breadth=0.0,
        weight_rvol=0.0,
        weight_gap=0.0,
        weight_dollar_vol=0.0,
        weight_premarket_dollar_vol=1.0,
        weight_momentum=0.0,
        weight_obv_slope=0.0,
        weight_event_catalyst=0.0,
        weight_close_location=0.0,
        atr_stop_multiplier=10.0,
        trailing_at_r=99.0,
        breakeven_at_r=99.0,
        risk_per_trade_pct=0.01,
        max_position_pct=1.0,
        slippage_bps=0.0,
        market_thrust_liquid_continuation_enabled=True,
        market_thrust_liquid_continuation_require_market_thrust=False,
        market_thrust_liquid_continuation_entry_mode="opening_burst",
        market_thrust_liquid_continuation_no_thrust_min_gap_pct=0.0,
        market_thrust_liquid_continuation_no_thrust_max_gap_pct=0.04,
        market_thrust_liquid_continuation_no_thrust_min_first_bar_return_pct=0.005,
        market_thrust_liquid_continuation_no_thrust_min_first_bar_dollar_vol=100_000,
        market_thrust_liquid_continuation_no_thrust_min_avg_dollar_vol=100_000,
        market_thrust_liquid_continuation_no_thrust_max_candidates=1,
        market_thrust_liquid_continuation_no_thrust_max_trades=1,
        market_thrust_liquid_continuation_no_thrust_rank_mode="opening_impulse",
        market_thrust_liquid_continuation_no_thrust_size_scale=0.2,
    )

    result = simulate_orb_day(bars, "2026-01-05", params, enrichment, equity=10_000.0)

    assert result.entry_diagnostics["market_thrust_opening_burst_candidates"] == 1
    assert len(result.trades) == 1
    assert result.trades[0].ticker == "IMPULSE"
    assert result.trades[0].trigger_type == "market_thrust_opening_burst"

    priority_params = params.model_copy(
        update={
            "market_thrust_liquid_continuation_no_thrust_rank_mode": None,
            "min_candidate_breadth": 1.1,
            "soft_day_fallback_on_breadth_skip": True,
            "soft_day_combined_size_scale_floor": 1.0,
            "soft_day_rank_before_time": True,
            (
                "market_thrust_liquid_continuation_no_thrust_"
                "priority_min_first_bar_return_pct"
            ): 0.03,
        }
    )
    priority_result = simulate_orb_day(
        bars,
        "2026-01-05",
        priority_params,
        enrichment,
        equity=10_000.0,
    )

    assert len(priority_result.trades) == 1
    assert priority_result.trades[0].ticker == "IMPULSE"


def test_nofill_vwap_reclaim_adds_trade_when_orb_never_breaks() -> None:
    bars = {
        "VWAP": [
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 98.0, "close": 99.0, "volume": 1000},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 99.0, "high": 99.2, "low": 98.5, "close": 98.8, "volume": 1000},
            {"timestamp": "2026-01-05T09:40:00-05:00", "open": 98.8, "high": 99.5, "low": 98.7, "close": 99.4, "volume": 1000},
            {"timestamp": "2026-01-05T09:45:00-05:00", "open": 99.4, "high": 99.9, "low": 99.2, "close": 99.8, "volume": 1000},
            {"timestamp": "2026-01-05T10:00:00-05:00", "open": 99.8, "high": 100.5, "low": 99.7, "close": 100.4, "volume": 2000},
            {"timestamp": "2026-01-05T15:55:00-05:00", "open": 100.4, "high": 100.9, "low": 100.2, "close": 100.8, "volume": 1000},
        ]
    }
    enrichment = _enrichment_for("VWAP")
    enrichment["VWAP"]["2026-01-05"]["prev_close"] = 98.0

    params = ORBStrategyParams(
        engine_family="gainers_leader",
        allow_red_to_green_breakout=True,
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        min_abs_gap_pct=0.0,
        max_candidates=10,
        min_candidates_to_trade=1,
        atr_stop_multiplier=10.0,
        trailing_at_r=99.0,
        breakeven_at_r=99.0,
        risk_per_trade_pct=0.01,
        max_position_pct=1.0,
        slippage_bps=0.0,
        nofill_vwap_reclaim_enabled=True,
        nofill_vwap_reclaim_min_score_pct=0.0,
        nofill_vwap_reclaim_size_scale=1.0,
        vwap_reclaim_window_start_min=30,
        vwap_reclaim_window_end_min=120,
        vwap_reclaim_require_prior_dip=True,
    )

    day_result = simulate_orb_day(
        bars,
        "2026-01-05",
        params,
        enrichment,
        equity=10_000.0,
    )

    assert len(day_result.trades) == 1
    assert day_result.trades[0].trigger_type == "vwap_reclaim"
    assert day_result.trades[0].entry_time == "2026-01-05T10:00:00-05:00"


def test_reclaim_entry_attention_can_gate_weak_nofill_vwap_reclaim() -> None:
    bars = {
        "VWAP": [
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 98.0, "close": 99.0, "volume": 1000},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 99.0, "high": 99.2, "low": 98.5, "close": 98.8, "volume": 1000},
            {"timestamp": "2026-01-05T09:40:00-05:00", "open": 98.8, "high": 99.5, "low": 98.7, "close": 99.4, "volume": 1000},
            {"timestamp": "2026-01-05T09:45:00-05:00", "open": 99.4, "high": 99.9, "low": 99.2, "close": 99.8, "volume": 1000},
            {"timestamp": "2026-01-05T10:00:00-05:00", "open": 99.8, "high": 100.5, "low": 99.7, "close": 100.4, "volume": 1200},
            {"timestamp": "2026-01-05T15:55:00-05:00", "open": 100.4, "high": 100.9, "low": 100.2, "close": 100.8, "volume": 1000},
        ]
    }
    enrichment = _enrichment_for("VWAP")
    enrichment["VWAP"]["2026-01-05"]["prev_close"] = 98.0

    base_params = ORBStrategyParams(
        engine_family="gainers_leader",
        allow_red_to_green_breakout=True,
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        min_abs_gap_pct=0.0,
        max_candidates=10,
        min_candidates_to_trade=1,
        atr_stop_multiplier=10.0,
        trailing_at_r=99.0,
        breakeven_at_r=99.0,
        risk_per_trade_pct=0.01,
        max_position_pct=1.0,
        slippage_bps=0.0,
        nofill_vwap_reclaim_enabled=True,
        nofill_vwap_reclaim_min_score_pct=0.0,
        nofill_vwap_reclaim_size_scale=1.0,
        vwap_reclaim_window_start_min=30,
        vwap_reclaim_window_end_min=120,
        vwap_reclaim_require_prior_dip=True,
        reclaim_entry_attention_enabled=True,
        reclaim_entry_attention_trigger_types=["vwap_reclaim"],
    )

    pass_result = simulate_orb_day(
        bars,
        "2026-01-05",
        base_params.model_copy(
            update={"reclaim_entry_attention_min_entry_rel_volume": 1.0}
        ),
        enrichment,
        equity=10_000.0,
    )
    assert len(pass_result.trades) == 1
    assert pass_result.trades[0].entry_rel_volume is not None
    assert pass_result.trades[0].entry_rel_volume > 1.0

    reject_result = simulate_orb_day(
        bars,
        "2026-01-05",
        base_params.model_copy(
            update={"reclaim_entry_attention_min_entry_rel_volume": 2.0}
        ),
        enrichment,
        equity=10_000.0,
    )
    assert reject_result.trades == []
    assert (
        reject_result.entry_diagnostics["entry_reject_stats"][
            "reclaim_entry_attention_relvol"
        ]
        == 1
    )


def test_late_breakout_adds_trade_after_primary_timeout() -> None:
    bars = {
        "LATE": [
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.6, "close": 100.6, "volume": 1000},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 100.6, "high": 100.8, "low": 100.0, "close": 100.2, "volume": 800},
            {"timestamp": "2026-01-05T09:40:00-05:00", "open": 100.2, "high": 100.7, "low": 99.9, "close": 100.5, "volume": 800},
            {"timestamp": "2026-01-05T09:50:00-05:00", "open": 100.5, "high": 100.9, "low": 100.2, "close": 100.8, "volume": 800},
            {"timestamp": "2026-01-05T10:00:00-05:00", "open": 100.8, "high": 101.6, "low": 95.0, "close": 101.4, "volume": 3000},
            {"timestamp": "2026-01-05T15:55:00-05:00", "open": 101.4, "high": 102.0, "low": 101.1, "close": 101.8, "volume": 1000},
        ]
    }
    enrichment = _enrichment_for("LATE")
    enrichment["LATE"]["2026-01-05"]["prev_close"] = 98.0

    params = ORBStrategyParams(
        engine_family="gainers_leader",
        allow_red_to_green_breakout=True,
        order_timeout_minutes=25,
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        min_abs_gap_pct=0.0,
        max_candidates=10,
        min_candidates_to_trade=1,
        atr_stop_multiplier=0.5,
        trailing_at_r=99.0,
        breakeven_at_r=99.0,
        risk_per_trade_pct=0.01,
        max_position_pct=1.0,
        slippage_bps=0.0,
        late_breakout_enabled=True,
        late_breakout_min_score_pct=0.0,
        late_breakout_size_scale=1.0,
        late_breakout_window_start_min=30,
        late_breakout_window_end_min=120,
        late_breakout_confirm_rel_vol=1.2,
    )

    day_result = simulate_orb_day(
        bars,
        "2026-01-05",
        params,
        enrichment,
        equity=10_000.0,
    )

    assert day_result.entry_diagnostics["primary_timed_candidates"] == 0
    assert day_result.entry_diagnostics["late_breakout_candidates"] == 1
    assert len(day_result.trades) == 1
    assert day_result.trades[0].trigger_type == "late_breakout"
    assert day_result.trades[0].entry_time == "2026-01-05T10:00:00-05:00"
    assert day_result.trades[0].exit_reason == "close"


def test_soft_day_vwap_reclaim_bypasses_market_regime_profile_block() -> None:
    bars = {
        "VWAP": [
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 98.0, "close": 99.0, "volume": 1000},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 99.0, "high": 99.2, "low": 98.5, "close": 98.8, "volume": 1000},
            {"timestamp": "2026-01-05T09:40:00-05:00", "open": 98.8, "high": 99.5, "low": 98.7, "close": 99.4, "volume": 1000},
            {"timestamp": "2026-01-05T09:45:00-05:00", "open": 99.4, "high": 99.9, "low": 99.2, "close": 99.8, "volume": 1000},
            {"timestamp": "2026-01-05T10:00:00-05:00", "open": 99.8, "high": 100.5, "low": 99.7, "close": 100.4, "volume": 2000},
            {"timestamp": "2026-01-05T15:55:00-05:00", "open": 100.4, "high": 100.9, "low": 100.2, "close": 100.8, "volume": 1000},
        ]
    }
    enrichment = _enrichment_for("VWAP", "QQQ")
    enrichment["VWAP"]["2026-01-05"]["prev_close"] = 98.0
    enrichment["QQQ"]["2026-01-05"]["prev_close"] = 100.0
    enrichment["QQQ"]["2026-01-05"]["today_open"] = 99.0

    params = ORBStrategyParams(
        engine_family="gainers_leader",
        allow_red_to_green_breakout=True,
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        min_abs_gap_pct=0.0,
        max_candidates=10,
        min_candidates_to_trade=1,
        market_regime_spy_threshold=0.001,
        market_regime_ticker="QQQ",
        soft_day_fallback_on_regime_skip=True,
        soft_day_regime_skip_size_scale=0.1,
        soft_day_setup_profile="skip_day_reclaim_v1",
        atr_stop_multiplier=10.0,
        trailing_at_r=99.0,
        breakeven_at_r=99.0,
        risk_per_trade_pct=0.01,
        max_position_pct=1.0,
        slippage_bps=0.0,
        soft_day_vwap_reclaim_enabled=True,
        soft_day_vwap_reclaim_min_score_pct=0.0,
        soft_day_vwap_reclaim_min_premarket_dollar_vol=0.0,
        soft_day_vwap_reclaim_size_scale=1.0,
        vwap_reclaim_window_start_min=30,
        vwap_reclaim_window_end_min=120,
        vwap_reclaim_require_prior_dip=True,
    )

    day_result = simulate_orb_day(
        bars,
        "2026-01-05",
        params,
        enrichment,
        equity=10_000.0,
    )

    assert day_result.is_soft_day
    assert day_result.soft_day_reason == "market_regime"
    assert len(day_result.trades) == 1
    assert day_result.trades[0].trigger_type == "soft_day_vwap_reclaim"


def test_soft_day_vwap_weak_participation_guard_rejects_low_rank_body() -> None:
    bars = {
        "ANCHOR": [
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 104.0, "low": 99.5, "close": 103.5, "volume": 20_000},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 103.5, "high": 104.0, "low": 103.0, "close": 103.7, "volume": 5_000},
            {"timestamp": "2026-01-05T09:40:00-05:00", "open": 103.7, "high": 104.2, "low": 103.5, "close": 103.9, "volume": 5_000},
            {"timestamp": "2026-01-05T10:00:00-05:00", "open": 103.9, "high": 104.5, "low": 103.8, "close": 104.1, "volume": 5_000},
            {"timestamp": "2026-01-05T15:55:00-05:00", "open": 104.1, "high": 104.5, "low": 103.9, "close": 104.2, "volume": 5_000},
        ],
        "VWAP": [
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 98.0, "close": 99.0, "volume": 1_000},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 99.0, "high": 99.2, "low": 98.5, "close": 98.8, "volume": 1_000},
            {"timestamp": "2026-01-05T09:40:00-05:00", "open": 98.8, "high": 99.5, "low": 98.7, "close": 99.4, "volume": 1_000},
            {"timestamp": "2026-01-05T09:45:00-05:00", "open": 99.4, "high": 99.9, "low": 99.2, "close": 99.8, "volume": 1_000},
            {"timestamp": "2026-01-05T10:00:00-05:00", "open": 99.8, "high": 100.5, "low": 99.7, "close": 100.4, "volume": 2_000},
            {"timestamp": "2026-01-05T15:55:00-05:00", "open": 100.4, "high": 100.9, "low": 100.2, "close": 100.8, "volume": 1_000},
        ],
    }
    enrichment = _enrichment_for("ANCHOR", "VWAP", "QQQ")
    enrichment["ANCHOR"]["2026-01-05"]["prev_close"] = 98.0
    enrichment["VWAP"]["2026-01-05"]["prev_close"] = 98.0
    enrichment["QQQ"]["2026-01-05"]["prev_close"] = 100.0
    enrichment["QQQ"]["2026-01-05"]["today_open"] = 99.0

    params = ORBStrategyParams(
        engine_family="gainers_leader",
        allow_red_to_green_breakout=True,
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        min_abs_gap_pct=0.0,
        max_candidates=10,
        min_candidates_to_trade=1,
        market_regime_spy_threshold=0.001,
        market_regime_ticker="QQQ",
        soft_day_fallback_on_regime_skip=True,
        soft_day_regime_skip_size_scale=0.1,
        soft_day_setup_profile="skip_day_reclaim_v1",
        atr_stop_multiplier=10.0,
        trailing_at_r=99.0,
        breakeven_at_r=99.0,
        risk_per_trade_pct=0.01,
        max_position_pct=1.0,
        slippage_bps=0.0,
        soft_day_vwap_reclaim_enabled=True,
        soft_day_vwap_reclaim_min_score_pct=0.0,
        soft_day_vwap_reclaim_min_premarket_dollar_vol=0.0,
        soft_day_vwap_reclaim_size_scale=1.0,
        vwap_reclaim_window_start_min=30,
        vwap_reclaim_window_end_min=120,
        vwap_reclaim_require_prior_dip=True,
    )

    unguarded = simulate_orb_day(bars, "2026-01-05", params, enrichment, equity=10_000.0)
    guarded = simulate_orb_day(
        bars,
        "2026-01-05",
        params.model_copy(
            update={
                "soft_day_vwap_reclaim_weak_participation_max_rvol_rank_pct": 0.65,
                "soft_day_vwap_reclaim_weak_participation_max_body_ratio": 0.75,
            }
        ),
        enrichment,
        equity=10_000.0,
    )

    assert len(unguarded.trades) == 1
    assert unguarded.trades[0].ticker == "VWAP"
    assert unguarded.trades[0].trigger_type == "soft_day_vwap_reclaim"
    assert unguarded.trades[0].rvol_rank_pct == 0.0
    assert len(guarded.trades) == 0
    assert guarded.entry_diagnostics["entry_reject_stats"][
        "soft_day_vwap_weak_participation"
    ] == 1


def test_soft_day_vwap_reclaim_can_be_limited_to_no_primary_trades() -> None:
    bars = {
        "ORB": [
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.8, "volume": 1000},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 100.8, "high": 102.0, "low": 100.8, "close": 101.8, "volume": 1000},
            {"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.8, "high": 102.0, "low": 101.6, "close": 101.9, "volume": 1000},
            {"timestamp": "2026-01-05T09:45:00-05:00", "open": 101.9, "high": 102.1, "low": 101.7, "close": 102.0, "volume": 1000},
            {"timestamp": "2026-01-05T15:55:00-05:00", "open": 101.8, "high": 102.0, "low": 101.6, "close": 101.9, "volume": 1000},
        ],
        "VWAP": [
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 98.0, "close": 99.0, "volume": 1000},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 99.0, "high": 99.2, "low": 98.5, "close": 98.8, "volume": 1000},
            {"timestamp": "2026-01-05T09:40:00-05:00", "open": 98.8, "high": 99.5, "low": 98.7, "close": 99.4, "volume": 1000},
            {"timestamp": "2026-01-05T09:45:00-05:00", "open": 99.4, "high": 99.9, "low": 99.2, "close": 99.8, "volume": 1000},
            {"timestamp": "2026-01-05T10:00:00-05:00", "open": 99.8, "high": 100.5, "low": 99.7, "close": 100.4, "volume": 2000},
            {"timestamp": "2026-01-05T15:55:00-05:00", "open": 100.4, "high": 100.9, "low": 100.2, "close": 100.8, "volume": 1000},
        ],
    }
    enrichment = _enrichment_for("ORB", "VWAP", "QQQ")
    enrichment["ORB"]["2026-01-05"]["prev_close"] = 98.0
    enrichment["VWAP"]["2026-01-05"]["prev_close"] = 98.0
    enrichment["QQQ"]["2026-01-05"]["prev_close"] = 100.0
    enrichment["QQQ"]["2026-01-05"]["today_open"] = 99.0

    params = ORBStrategyParams(
        engine_family="gainers_leader",
        allow_red_to_green_breakout=True,
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        min_abs_gap_pct=0.0,
        max_candidates=10,
        min_candidates_to_trade=1,
        max_trades_per_day=10,
        market_regime_spy_threshold=0.001,
        market_regime_ticker="QQQ",
        soft_day_fallback_on_regime_skip=True,
        soft_day_regime_skip_size_scale=0.1,
        soft_day_setup_profile="none",
        atr_stop_multiplier=10.0,
        trailing_at_r=99.0,
        breakeven_at_r=99.0,
        risk_per_trade_pct=0.01,
        max_position_pct=1.0,
        slippage_bps=0.0,
        soft_day_vwap_reclaim_enabled=True,
        soft_day_vwap_reclaim_only_when_no_primary_trades=True,
        soft_day_vwap_reclaim_min_score_pct=0.0,
        soft_day_vwap_reclaim_min_premarket_dollar_vol=0.0,
        soft_day_vwap_reclaim_size_scale=1.0,
        vwap_reclaim_window_start_min=30,
        vwap_reclaim_window_end_min=120,
        vwap_reclaim_require_prior_dip=True,
    )

    day_result = simulate_orb_day(
        bars,
        "2026-01-05",
        params,
        enrichment,
        equity=10_000.0,
    )

    assert day_result.is_soft_day
    assert day_result.entry_diagnostics["primary_timed_candidates"] == 1
    assert day_result.entry_diagnostics["soft_day_vwap_candidates"] == 1
    assert day_result.entry_diagnostics["timed_candidates"] == 2
    assert day_result.entry_diagnostics["soft_day_vwap_trades"] == 0
    assert day_result.entry_diagnostics["entry_reject_stats"] == {
        "soft_day_vwap_primary_trade_exists": 1
    }
    assert len(day_result.trades) == 1
    assert day_result.trades[0].trigger_type == "orb"

    no_existing_params = params.model_copy(
        update={
            "soft_day_vwap_reclaim_only_when_no_primary_trades": False,
            "soft_day_vwap_reclaim_only_when_no_existing_trades": True,
        }
    )
    no_existing_result = simulate_orb_day(
        bars,
        "2026-01-05",
        no_existing_params,
        enrichment,
        equity=10_000.0,
    )

    assert no_existing_result.entry_diagnostics["soft_day_vwap_trades"] == 0
    assert no_existing_result.entry_diagnostics["entry_reject_stats"] == {
        "soft_day_vwap_existing_trade": 1
    }


def test_hard_breadth_soft_fallback_converts_breadth_skip_to_soft_day() -> None:
    bars = {
        ticker: [
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.8, "volume": 1000},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 100.8, "high": 102.0, "low": 100.8, "close": 101.8, "volume": 1000},
            {"timestamp": "2026-01-05T15:55:00-05:00", "open": 101.8, "high": 102.2, "low": 101.6, "close": 102.0, "volume": 1000},
        ]
        for ticker in ("AAA", "BBB")
    }
    enrichment = _enrichment_for("AAA", "BBB")
    enrichment["AAA"]["2026-01-05"]["prev_close"] = 98.0
    enrichment["AAA"]["2026-01-05"]["today_open"] = 100.0
    enrichment["BBB"]["2026-01-05"]["prev_close"] = 102.0
    enrichment["BBB"]["2026-01-05"]["today_open"] = 100.0
    params = ORBStrategyParams(
        engine_family="gainers_leader",
        allow_red_to_green_breakout=True,
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        min_abs_gap_pct=0.0,
        max_candidates=10,
        min_candidates_to_trade=1,
        min_candidate_breadth=0.6,
        breadth_skip_below=0.6,
        hard_breadth_soft_fallback_enabled=True,
        hard_breadth_soft_fallback_min_breadth=0.4,
        hard_breadth_soft_fallback_size_scale=0.05,
        soft_day_setup_profile="none",
        atr_stop_multiplier=10.0,
        trailing_at_r=99.0,
        breakeven_at_r=99.0,
        risk_per_trade_pct=0.01,
        max_position_pct=1.0,
        slippage_bps=0.0,
    )

    day_result = simulate_orb_day(
        bars,
        "2026-01-05",
        params,
        enrichment,
        equity=10_000.0,
    )

    assert day_result.skip_reason != "breadth"
    assert day_result.is_soft_day
    assert day_result.soft_day_reason == "hard_breadth"
    assert day_result.breadth_scaler == 0.05


def test_synthetic_today_daily_breadth_uses_intraday_open() -> None:
    bars = {
        ticker: [
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 101.0, "high": 102.0, "low": 100.5, "close": 101.8, "volume": 1000},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.8, "high": 103.0, "low": 101.8, "close": 102.8, "volume": 1000},
            {"timestamp": "2026-01-05T15:55:00-05:00", "open": 102.8, "high": 103.2, "low": 102.6, "close": 103.0, "volume": 1000},
        ]
        for ticker in ("AAA", "BBB", "CCC")
    }
    enrichment = _enrichment_for("AAA", "BBB", "CCC")
    for ticker in enrichment:
        enrichment[ticker]["2026-01-05"]["prev_close"] = 100.0
        enrichment[ticker]["2026-01-05"]["today_open"] = 100.0
        enrichment[ticker]["2026-01-05"]["synthetic_today_daily"] = True
    params = ORBStrategyParams(
        engine_family="gainers_leader",
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        min_abs_gap_pct=0.0,
        max_candidates=10,
        min_candidates_to_trade=1,
        min_candidate_breadth=0.6,
        breadth_skip_below=0.25,
        hard_breadth_soft_fallback_enabled=True,
        hard_breadth_soft_fallback_min_breadth=0.0,
        hard_breadth_soft_fallback_size_scale=0.05,
        atr_stop_multiplier=10.0,
        trailing_at_r=99.0,
        breakeven_at_r=99.0,
        risk_per_trade_pct=0.01,
        max_position_pct=1.0,
        slippage_bps=0.0,
    )

    day_result = simulate_orb_day(
        bars,
        "2026-01-05",
        params,
        enrichment,
        equity=10_000.0,
    )

    assert day_result.skip_reason is None
    assert day_result.soft_day_reason is None
    assert day_result.breadth_scaler == 1.0
    assert day_result.breadth_ratio == 1.0
    assert day_result.breadth_positive_count == 3
    assert day_result.breadth_total_count == 3


def test_market_thrust_override_clears_breadth_only_soft_day() -> None:
    bars = {
        "SPY": [
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 102.0, "low": 99.8, "close": 101.9, "volume": 1000},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.9, "high": 102.5, "low": 101.7, "close": 102.2, "volume": 1000},
        ],
        "QQQ": [
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 101.0, "high": 103.0, "low": 100.8, "close": 102.8, "volume": 1000},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 102.8, "high": 103.4, "low": 102.7, "close": 103.2, "volume": 1000},
        ],
        "AAA": [
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 99.0, "high": 99.2, "low": 98.0, "close": 98.2, "volume": 1000},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 98.2, "high": 98.4, "low": 97.8, "close": 98.0, "volume": 1000},
        ],
        "BBB": [
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 99.0, "high": 99.1, "low": 98.1, "close": 98.3, "volume": 1000},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 98.3, "high": 98.5, "low": 98.0, "close": 98.1, "volume": 1000},
        ],
        "CCC": [
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 99.0, "high": 99.1, "low": 98.0, "close": 98.4, "volume": 1000},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 98.4, "high": 98.6, "low": 98.0, "close": 98.2, "volume": 1000},
        ],
    }
    enrichment = _enrichment_for("SPY", "QQQ", "AAA", "BBB", "CCC")
    for ticker in ("SPY", "QQQ"):
        enrichment[ticker]["2026-01-05"]["prev_close"] = 100.0
        enrichment[ticker]["2026-01-05"]["today_open"] = 101.0
    for ticker in ("AAA", "BBB", "CCC"):
        enrichment[ticker]["2026-01-05"]["prev_close"] = 100.0
        enrichment[ticker]["2026-01-05"]["today_open"] = 99.0

    params = ORBStrategyParams(
        engine_family="gainers_leader",
        market_regime_ticker="QQQ",
        market_regime_spy_threshold=0.001,
        market_orb_quality_ticker="SPY",
        market_orb_quality_secondary_ticker="QQQ",
        min_candidate_breadth=0.6,
        breadth_skip_below=0.25,
        soft_day_fallback_on_breadth_skip=True,
        soft_day_breadth_skip_size_scale=0.1,
        market_thrust_breadth_override_enabled=True,
        market_thrust_breadth_override_min_primary_close_location=0.85,
        market_thrust_breadth_override_min_secondary_close_location=0.85,
        market_thrust_breadth_override_min_primary_return_pct=0.001,
        market_thrust_breadth_override_min_secondary_return_pct=0.001,
        market_thrust_breadth_override_min_regime_gap_pct=0.001,
        market_thrust_breadth_override_min_breadth_ratio=0.35,
        market_thrust_breadth_override_size_scale_floor=1.0,
        market_thrust_breadth_override_clear_soft_day=True,
    )

    day_result = simulate_orb_day(
        bars,
        "2026-01-05",
        params,
        enrichment,
        equity=10_000.0,
    )

    assert day_result.breadth_ratio == pytest.approx(0.4)
    assert day_result.market_thrust_breadth_override_active
    assert day_result.breadth_scaler == 1.0
    assert day_result.is_soft_day is False
    assert day_result.soft_day_reason is None


def test_max_trades_per_day_caps_later_orb_fills() -> None:
    bars = {
        ticker: [
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.8, "volume": 1000},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.0, "high": 102.0, "low": 100.8, "close": 101.8, "volume": 1000},
            {"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.8, "high": 102.4, "low": 101.5, "close": 102.2, "volume": 1000},
            {"timestamp": "2026-01-05T09:45:00-05:00", "open": 102.2, "high": 102.5, "low": 102.0, "close": 102.3, "volume": 1000},
            {"timestamp": "2026-01-05T09:50:00-05:00", "open": 102.3, "high": 102.6, "low": 102.1, "close": 102.4, "volume": 1000},
            {"timestamp": "2026-01-05T15:55:00-05:00", "open": 102.2, "high": 102.5, "low": 102.0, "close": 102.4, "volume": 1000},
        ]
        for ticker in ("AAA", "BBB", "CCC")
    }
    enrichment = _enrichment_for("AAA", "BBB", "CCC")
    params = ORBStrategyParams(
        engine_family="gainers_leader",
        allow_red_to_green_breakout=True,
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        min_abs_gap_pct=0.0,
        max_candidates=10,
        min_candidates_to_trade=1,
        atr_stop_multiplier=10.0,
        trailing_at_r=99.0,
        breakeven_at_r=99.0,
        risk_per_trade_pct=0.01,
        max_position_pct=1.0,
        slippage_bps=0.0,
        max_trades_per_day=2,
    )

    day_result = simulate_orb_day(
        bars,
        "2026-01-05",
        params,
        enrichment,
        equity=10_000.0,
    )

    assert len(day_result.trades) == 2


def test_late_trade_size_scale_reduces_later_fills_without_skipping() -> None:
    bars = {
        ticker: [
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.8, "volume": 1000},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.0, "high": 102.0, "low": 100.8, "close": 101.8, "volume": 1000},
            {"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.8, "high": 102.4, "low": 101.5, "close": 102.2, "volume": 1000},
            {"timestamp": "2026-01-05T09:45:00-05:00", "open": 102.2, "high": 102.5, "low": 102.0, "close": 102.3, "volume": 1000},
            {"timestamp": "2026-01-05T09:50:00-05:00", "open": 102.3, "high": 102.6, "low": 102.1, "close": 102.4, "volume": 1000},
            {"timestamp": "2026-01-05T15:55:00-05:00", "open": 102.2, "high": 102.5, "low": 102.0, "close": 102.4, "volume": 1000},
        ]
        for ticker in ("AAA", "BBB", "CCC")
    }
    enrichment = _enrichment_for("AAA", "BBB", "CCC")
    params = ORBStrategyParams(
        engine_family="gainers_leader",
        allow_red_to_green_breakout=True,
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        min_abs_gap_pct=0.0,
        max_candidates=10,
        min_candidates_to_trade=1,
        atr_stop_multiplier=10.0,
        trailing_at_r=99.0,
        breakeven_at_r=99.0,
        risk_per_trade_pct=0.01,
        max_position_pct=1.0,
        slippage_bps=0.0,
        late_trade_size_scale_after_n=2,
        late_trade_size_scale=0.5,
    )

    day_result = simulate_orb_day(
        bars,
        "2026-01-05",
        params,
        enrichment,
        equity=10_000.0,
    )

    assert len(day_result.trades) == 3
    assert day_result.trades[2].shares < day_result.trades[1].shares
    assert day_result.trades[2].late_trade_size_scale == 0.5


def test_rank_rvol_pressure_size_scale_reduces_mid_rank_high_rvol_fills() -> None:
    bars = {
        ticker: [
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.8, "volume": 1000},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.0, "high": 102.0, "low": 100.8, "close": 101.8, "volume": 1000},
            {"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.8, "high": 102.4, "low": 101.5, "close": 102.2, "volume": 1000},
            {"timestamp": "2026-01-05T09:45:00-05:00", "open": 102.2, "high": 102.5, "low": 102.0, "close": 102.3, "volume": 1000},
            {"timestamp": "2026-01-05T09:50:00-05:00", "open": 102.3, "high": 102.6, "low": 102.1, "close": 102.4, "volume": 1000},
            {"timestamp": "2026-01-05T15:55:00-05:00", "open": 102.2, "high": 102.5, "low": 102.0, "close": 102.4, "volume": 1000},
        ]
        for ticker in ("AAA", "BBB", "CCC")
    }
    enrichment = _enrichment_for("AAA", "BBB", "CCC")
    for ticker in enrichment:
        enrichment[ticker]["2026-01-05"]["ret_5d"] = -0.01
    params = ORBStrategyParams(
        engine_family="gainers_leader",
        allow_red_to_green_breakout=True,
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        min_abs_gap_pct=0.0,
        max_candidates=10,
        min_candidates_to_trade=1,
        atr_stop_multiplier=10.0,
        trailing_at_r=99.0,
        breakeven_at_r=99.0,
        risk_per_trade_pct=0.01,
        max_position_pct=1.0,
        slippage_bps=0.0,
        rank_rvol_pressure_min_rvol=0.1,
        rank_rvol_pressure_max_score_rank_pct=0.5,
        rank_rvol_pressure_max_ret_5d=0.0,
        rank_rvol_pressure_size_scale=0.4,
    )

    day_result = simulate_orb_day(
        bars,
        "2026-01-05",
        params,
        enrichment,
        equity=10_000.0,
    )

    assert len(day_result.trades) == 3
    assert day_result.trades[0].rank_rvol_pressure_size_scale is None
    assert day_result.trades[1].rank_rvol_pressure_size_scale == 0.4
    assert day_result.trades[2].rank_rvol_pressure_size_scale == 0.4
    assert day_result.trades[1].shares < day_result.trades[0].shares


def test_mid_liquidity_fragility_scales_only_fragile_mid_premarket_profiles() -> None:
    def bars_for(premarket_dollar_vol: float) -> list[dict]:
        premarket_volume = int(premarket_dollar_vol / 100.0)
        return [
            {"timestamp": "2026-01-05T08:00:00-05:00", "open": 100.0, "high": 100.0, "low": 100.0, "close": 100.0, "volume": premarket_volume},
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.8, "volume": 1000},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.0, "high": 102.0, "low": 100.8, "close": 101.8, "volume": 1000},
            {"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.8, "high": 102.4, "low": 101.5, "close": 102.2, "volume": 1000},
            {"timestamp": "2026-01-05T09:45:00-05:00", "open": 102.2, "high": 102.5, "low": 102.0, "close": 102.3, "volume": 1000},
            {"timestamp": "2026-01-05T15:55:00-05:00", "open": 102.2, "high": 102.5, "low": 102.0, "close": 102.4, "volume": 1000},
        ]

    bars = {
        "FRAGILE": bars_for(30_000_000),
        "LEADER": bars_for(90_000_000),
    }
    enrichment = _enrichment_for("FRAGILE", "LEADER")
    params = ORBStrategyParams(
        engine_family="gainers_leader",
        allow_red_to_green_breakout=True,
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.0,
        min_abs_gap_pct=0.0,
        max_candidates=10,
        max_trades_per_day=2,
        min_candidates_to_trade=1,
        atr_stop_multiplier=10.0,
        trailing_at_r=99.0,
        breakeven_at_r=99.0,
        risk_per_trade_pct=0.01,
        max_position_pct=1.0,
        slippage_bps=0.0,
        mid_liquidity_fragility_size_scale=0.25,
        mid_liquidity_fragility_mid_min_premarket_dollar_vol=20_000_000,
        mid_liquidity_fragility_mid_max_premarket_dollar_vol=75_000_000,
        mid_liquidity_fragility_mid_min_body_ratio=0.5,
        mid_liquidity_fragility_mid_max_body_ratio=0.75,
    )

    day_result = simulate_orb_day(
        bars,
        "2026-01-05",
        params,
        enrichment,
        equity=10_000.0,
    )

    trades = {trade.ticker: trade for trade in day_result.trades}
    assert trades["FRAGILE"].mid_liquidity_fragility_active is True
    assert trades["FRAGILE"].mid_liquidity_fragility_size_scale == 0.25
    assert trades["LEADER"].mid_liquidity_fragility_active is False
    assert trades["LEADER"].mid_liquidity_fragility_size_scale is None
    assert trades["FRAGILE"].shares < trades["LEADER"].shares


def test_gap_exhaustion_pressure_scales_stretched_weak_orb_close() -> None:
    bars = {
        "STRONG": [
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.9, "volume": 1000},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 100.9, "high": 102.0, "low": 100.8, "close": 101.8, "volume": 1000},
            {"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.8, "high": 102.4, "low": 101.5, "close": 102.2, "volume": 1000},
            {"timestamp": "2026-01-05T09:45:00-05:00", "open": 102.2, "high": 102.5, "low": 102.0, "close": 102.3, "volume": 1000},
            {"timestamp": "2026-01-05T09:50:00-05:00", "open": 102.3, "high": 102.6, "low": 102.1, "close": 102.4, "volume": 1000},
            {"timestamp": "2026-01-05T15:55:00-05:00", "open": 102.2, "high": 102.5, "low": 102.0, "close": 102.4, "volume": 1000},
        ],
        "WEAK": [
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.2, "volume": 1000},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 100.2, "high": 102.0, "low": 100.1, "close": 101.8, "volume": 1000},
            {"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.8, "high": 102.4, "low": 101.5, "close": 102.2, "volume": 1000},
            {"timestamp": "2026-01-05T09:45:00-05:00", "open": 102.2, "high": 102.5, "low": 102.0, "close": 102.3, "volume": 1000},
            {"timestamp": "2026-01-05T09:50:00-05:00", "open": 102.3, "high": 102.6, "low": 102.1, "close": 102.4, "volume": 1000},
            {"timestamp": "2026-01-05T15:55:00-05:00", "open": 102.2, "high": 102.5, "low": 102.0, "close": 102.4, "volume": 1000},
        ],
    }
    enrichment = _enrichment_for("STRONG", "WEAK")
    for ticker in enrichment:
        enrichment[ticker]["2026-01-05"]["prev_close"] = 98.0
        enrichment[ticker]["2026-01-05"]["ret_5d"] = 0.05
        enrichment[ticker]["2026-01-05"]["gap_zscore_20d"] = 1.25
    params = ORBStrategyParams(
        engine_family="gainers_leader",
        allow_red_to_green_breakout=True,
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        min_abs_gap_pct=0.0,
        max_candidates=10,
        min_candidates_to_trade=1,
        atr_stop_multiplier=10.0,
        trailing_at_r=99.0,
        breakeven_at_r=99.0,
        risk_per_trade_pct=0.01,
        max_position_pct=1.0,
        slippage_bps=0.0,
        gap_exhaustion_pressure_min_gap_zscore=1.0,
        gap_exhaustion_pressure_max_close_location=0.65,
        gap_exhaustion_pressure_min_ret_5d=0.0,
        gap_exhaustion_pressure_min_gap_pct=0.02,
        gap_exhaustion_pressure_size_scale=0.25,
    )

    day_result = simulate_orb_day(
        bars,
        "2026-01-05",
        params,
        enrichment,
        equity=10_000.0,
    )

    trades = {trade.ticker: trade for trade in day_result.trades}
    assert trades["STRONG"].gap_exhaustion_pressure_size_scale is None
    assert trades["WEAK"].gap_exhaustion_pressure_size_scale == 0.25
    assert trades["WEAK"].shares < trades["STRONG"].shares


def test_stale_obv_rvol_pressure_scales_low_rvol_stale_obv_fill() -> None:
    bars = {
        ticker: [
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.8, "volume": 1000},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.0, "high": 102.0, "low": 100.8, "close": 101.8, "volume": 1000},
            {"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.8, "high": 102.4, "low": 101.5, "close": 102.2, "volume": 1000},
            {"timestamp": "2026-01-05T09:45:00-05:00", "open": 102.2, "high": 102.5, "low": 102.0, "close": 102.3, "volume": 1000},
            {"timestamp": "2026-01-05T09:50:00-05:00", "open": 102.3, "high": 102.6, "low": 102.1, "close": 102.4, "volume": 1000},
            {"timestamp": "2026-01-05T15:55:00-05:00", "open": 102.2, "high": 102.5, "low": 102.0, "close": 102.4, "volume": 1000},
        ]
        for ticker in ("FRESH", "STALE")
    }
    enrichment = _enrichment_for("FRESH", "STALE")
    enrichment["FRESH"]["2026-01-05"]["obv_slope_20"] = 0.2
    enrichment["STALE"]["2026-01-05"]["obv_slope_20"] = -0.3
    for ticker in enrichment:
        enrichment[ticker]["2026-01-05"]["ret_5d"] = -0.01
    params = ORBStrategyParams(
        engine_family="gainers_leader",
        allow_red_to_green_breakout=True,
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        min_abs_gap_pct=0.0,
        max_candidates=10,
        min_candidates_to_trade=1,
        atr_stop_multiplier=10.0,
        trailing_at_r=99.0,
        breakeven_at_r=99.0,
        risk_per_trade_pct=0.01,
        max_position_pct=1.0,
        slippage_bps=0.0,
        stale_obv_rvol_pressure_max_rvol=8.0,
        stale_obv_rvol_pressure_max_obv_slope_20d=-0.2,
        stale_obv_rvol_pressure_max_ret_5d=0.05,
        stale_obv_rvol_pressure_max_score_rank_pct=1.0,
        stale_obv_rvol_pressure_size_scale=0.4,
    )

    day_result = simulate_orb_day(
        bars,
        "2026-01-05",
        params,
        enrichment,
        equity=10_000.0,
    )

    trades = {trade.ticker: trade for trade in day_result.trades}
    assert trades["FRESH"].stale_obv_rvol_pressure_size_scale is None
    assert trades["STALE"].stale_obv_rvol_pressure_size_scale == 0.4
    assert trades["STALE"].shares < trades["FRESH"].shares


def test_red_to_green_acceleration_boosts_only_clean_downside_reclaims() -> None:
    bars = {
        ticker: [
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.8, "volume": 1000},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.0, "high": 102.0, "low": 100.8, "close": 101.8, "volume": 1000},
            {"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.8, "high": 102.4, "low": 101.5, "close": 102.2, "volume": 1000},
            {"timestamp": "2026-01-05T09:45:00-05:00", "open": 102.2, "high": 102.5, "low": 102.0, "close": 102.3, "volume": 1000},
            {"timestamp": "2026-01-05T09:50:00-05:00", "open": 102.3, "high": 102.6, "low": 102.1, "close": 102.4, "volume": 1000},
            {"timestamp": "2026-01-05T15:55:00-05:00", "open": 102.2, "high": 102.5, "low": 102.0, "close": 102.4, "volume": 1000},
        ]
        for ticker in ("BASE", "BOOST", "RISK")
    }
    enrichment = _enrichment_for("BASE", "BOOST", "RISK")
    enrichment["BASE"]["2026-01-05"]["prev_close"] = 98.0
    enrichment["BOOST"]["2026-01-05"]["prev_close"] = 106.0
    enrichment["BOOST"]["2026-01-05"]["ret_5d"] = 0.0
    enrichment["RISK"]["2026-01-05"]["prev_close"] = 106.0
    enrichment["RISK"]["2026-01-05"]["ret_5d"] = 0.5
    params = ORBStrategyParams(
        engine_family="gainers_leader",
        allow_red_to_green_breakout=True,
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        min_abs_gap_pct=0.0,
        max_candidates=10,
        min_candidates_to_trade=1,
        atr_stop_multiplier=10.0,
        trailing_at_r=99.0,
        breakeven_at_r=99.0,
        risk_per_trade_pct=0.01,
        max_position_pct=1.0,
        slippage_bps=0.0,
        hot_reclaim_min_abs_gap_pct=0.04,
        hot_reclaim_min_ret_5d=0.30,
        hot_reclaim_max_premarket_dollar_vol=10_000_000_000,
        hot_reclaim_min_body_ratio=0.50,
        hot_reclaim_min_close_location=0.80,
        hot_reclaim_action="scale",
        hot_reclaim_size_scale=0.35,
        red_to_green_acceleration_min_abs_gap_pct=0.04,
        red_to_green_acceleration_min_orb_return=0.005,
        red_to_green_acceleration_size_scale=1.5,
    )

    day_result = simulate_orb_day(
        bars,
        "2026-01-05",
        params,
        enrichment,
        equity=10_000.0,
    )

    trades = {trade.ticker: trade for trade in day_result.trades}
    assert trades["BASE"].red_to_green_acceleration_size_scale is None
    assert trades["BOOST"].red_to_green_acceleration_size_scale == 1.5
    assert trades["RISK"].hot_reclaim_size_scale == 0.35
    assert trades["RISK"].red_to_green_acceleration_size_scale is None
    assert trades["BOOST"].shares > trades["BASE"].shares
    assert trades["RISK"].shares < trades["BASE"].shares


def test_liquid_leader_conviction_boosts_only_unscaled_liquid_leaders() -> None:
    bars = {}
    for ticker, premarket_volume in (
        ("THIN", 1_000),
        ("LIQUID", 1_000_000),
        ("RISK", 1_000_000),
    ):
        bars[ticker] = [
            {"timestamp": "2026-01-05T08:00:00-05:00", "open": 100.0, "high": 100.1, "low": 99.9, "close": 100.0, "volume": premarket_volume},
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.8, "volume": 1000},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.0, "high": 102.0, "low": 100.8, "close": 101.8, "volume": 1000},
            {"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.8, "high": 102.4, "low": 101.5, "close": 102.2, "volume": 1000},
            {"timestamp": "2026-01-05T09:45:00-05:00", "open": 102.2, "high": 102.5, "low": 102.0, "close": 102.3, "volume": 1000},
            {"timestamp": "2026-01-05T09:50:00-05:00", "open": 102.3, "high": 102.6, "low": 102.1, "close": 102.4, "volume": 1000},
            {"timestamp": "2026-01-05T15:55:00-05:00", "open": 102.2, "high": 102.5, "low": 102.0, "close": 102.4, "volume": 1000},
        ]
    enrichment = _enrichment_for("THIN", "LIQUID", "RISK")
    enrichment["THIN"]["2026-01-05"]["prev_close"] = 98.0
    enrichment["LIQUID"]["2026-01-05"]["prev_close"] = 98.0
    enrichment["RISK"]["2026-01-05"]["prev_close"] = 106.0
    enrichment["RISK"]["2026-01-05"]["ret_5d"] = 0.5

    params = ORBStrategyParams(
        engine_family="gainers_leader",
        allow_red_to_green_breakout=True,
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        min_abs_gap_pct=0.0,
        min_premarket_dollar_vol=0.0,
        max_candidates=10,
        min_candidates_to_trade=1,
        atr_stop_multiplier=10.0,
        trailing_at_r=99.0,
        breakeven_at_r=99.0,
        risk_per_trade_pct=0.01,
        max_position_pct=1.0,
        slippage_bps=0.0,
        hot_reclaim_min_abs_gap_pct=0.04,
        hot_reclaim_min_ret_5d=0.30,
        hot_reclaim_max_premarket_dollar_vol=200_000_000,
        hot_reclaim_min_body_ratio=0.50,
        hot_reclaim_min_close_location=0.80,
        hot_reclaim_action="scale",
        hot_reclaim_size_scale=0.35,
        liquid_leader_conviction_min_premarket_dollar_vol=50_000_000,
        liquid_leader_conviction_size_scale=1.5,
    )

    day_result = simulate_orb_day(
        bars,
        "2026-01-05",
        params,
        enrichment,
        equity=10_000.0,
    )

    trades = {trade.ticker: trade for trade in day_result.trades}
    assert trades["THIN"].liquid_leader_conviction_size_scale is None
    assert trades["LIQUID"].liquid_leader_conviction_size_scale == 1.5
    assert trades["RISK"].hot_reclaim_size_scale == 0.35
    assert trades["RISK"].liquid_leader_conviction_size_scale is None
    assert trades["LIQUID"].shares > trades["THIN"].shares
    assert trades["RISK"].shares < trades["THIN"].shares

    trigger_blocked = simulate_orb_day(
        bars,
        "2026-01-05",
        params.model_copy(
            update={"liquid_leader_conviction_allowed_trigger_types": ["vwap_reclaim"]}
        ),
        enrichment,
        equity=10_000.0,
    )

    trigger_blocked_trades = {trade.ticker: trade for trade in trigger_blocked.trades}
    assert trigger_blocked_trades["LIQUID"].liquid_leader_conviction_size_scale is None


def test_liquid_leader_secondary_boost_can_require_sector_confirmation() -> None:
    params = ORBStrategyParams(
        liquid_leader_conviction_size_scale=2.0,
        liquid_leader_conviction_secondary_requires_sector_confirmation=True,
        liquid_leader_conviction_secondary_min_body_ratio=0.20,
    )
    cand = {"sector_confirmation_active": False, "body_ratio": 0.50}
    confirmed = {"sector_confirmation_active": True, "body_ratio": 0.50}
    weak_body = {"sector_confirmation_active": True, "body_ratio": 0.10}

    assert (
        _orb_liquid_leader_conviction_size_scale(
            params,
            cand,
            score_rank_pct=1.0,
            trigger_type="soft_day_vwap_reclaim",
            direction_str="long",
            risk_size_scales=(1.0,),
        )
        == 1.0
    )
    assert (
        _orb_liquid_leader_conviction_size_scale(
            params,
            confirmed,
            score_rank_pct=1.0,
            trigger_type="soft_day_vwap_reclaim",
            direction_str="long",
            risk_size_scales=(1.0,),
        )
        == 2.0
    )
    assert (
        _orb_liquid_leader_conviction_size_scale(
            params,
            weak_body,
            score_rank_pct=1.0,
            trigger_type="soft_day_vwap_reclaim",
            direction_str="long",
            risk_size_scales=(1.0,),
        )
        == 1.0
    )
    assert (
        _orb_liquid_leader_conviction_size_scale(
            params,
            cand,
            score_rank_pct=1.0,
            trigger_type="orb",
            direction_str="long",
            risk_size_scales=(1.0,),
        )
        == 2.0
    )


def test_unsupported_attention_governor_scales_unconfirmed_liquid_laggards() -> None:
    params = ORBStrategyParams(
        unsupported_attention_size_scale=0.35,
        unsupported_attention_allowed_trigger_types=["orb"],
        unsupported_attention_liquid_min_premarket_dollar_vol=50_000_000,
        unsupported_attention_liquid_max_candidate_score=0.65,
    )
    weak = {
        "premarket_dollar_vol": 75_000_000.0,
        "score": 0.60,
        "sector_confirmation_active": False,
    }
    confirmed = {**weak, "sector_confirmation_active": True}
    strong_score = {**weak, "score": 0.80}

    assert (
        _orb_unsupported_attention_size_scale(
            params,
            weak,
            trigger_type="orb",
            direction_str="long",
            high_conviction_size_scales=(1.0,),
        )
        == 0.35
    )
    assert (
        _orb_unsupported_attention_size_scale(
            params,
            confirmed,
            trigger_type="orb",
            direction_str="long",
            high_conviction_size_scales=(1.0,),
        )
        == 1.0
    )
    assert (
        _orb_unsupported_attention_size_scale(
            params,
            strong_score,
            trigger_type="orb",
            direction_str="long",
            high_conviction_size_scales=(1.0,),
        )
        == 1.0
    )


def test_unsupported_attention_governor_scales_thin_positive_gap_spikes() -> None:
    params = ORBStrategyParams(
        unsupported_attention_size_scale=0.25,
        unsupported_attention_thin_max_premarket_dollar_vol=10_000_000,
        unsupported_attention_thin_min_gap_pct=0.025,
        unsupported_attention_thin_min_rvol=20.0,
        unsupported_attention_thin_max_rvol=25.0,
    )
    thin_spike = {
        "premarket_dollar_vol": 8_000_000.0,
        "gap_pct": 0.03,
        "rvol": 22.0,
    }
    downside_reclaim = {**thin_spike, "gap_pct": -0.04}
    high_conviction = {**thin_spike}

    assert (
        _orb_unsupported_attention_size_scale(
            params,
            thin_spike,
            trigger_type="orb",
            direction_str="long",
            high_conviction_size_scales=(1.0,),
        )
        == 0.25
    )
    assert (
        _orb_unsupported_attention_size_scale(
            params,
            downside_reclaim,
            trigger_type="orb",
            direction_str="long",
            high_conviction_size_scales=(1.0,),
        )
        == 1.0
    )
    assert (
        _orb_unsupported_attention_size_scale(
            params,
            high_conviction,
            trigger_type="orb",
            direction_str="long",
            high_conviction_size_scales=(1.5,),
        )
        == 1.0
    )


def test_thin_gap_up_loss_cap_marks_only_low_premarket_gap_ups() -> None:
    bars = {}
    for ticker, premarket_volume in (
        ("THIN", 1_000),
        ("LIQUID", 2_000_000),
    ):
        bars[ticker] = [
            {"timestamp": "2026-01-05T08:00:00-05:00", "open": 100.0, "high": 100.1, "low": 99.9, "close": 100.0, "volume": premarket_volume},
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.8, "volume": 1000},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.0, "high": 102.0, "low": 100.8, "close": 101.8, "volume": 1000},
            {"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.8, "high": 102.4, "low": 101.5, "close": 102.2, "volume": 1000},
            {"timestamp": "2026-01-05T09:45:00-05:00", "open": 102.2, "high": 102.5, "low": 102.0, "close": 102.3, "volume": 1000},
            {"timestamp": "2026-01-05T09:50:00-05:00", "open": 102.3, "high": 102.6, "low": 102.1, "close": 102.4, "volume": 1000},
            {"timestamp": "2026-01-05T15:55:00-05:00", "open": 102.2, "high": 102.5, "low": 102.0, "close": 102.4, "volume": 1000},
        ]
    enrichment = _enrichment_for("THIN", "LIQUID")
    for ticker in ("THIN", "LIQUID"):
        enrichment[ticker]["2026-01-05"]["prev_close"] = 98.0
        enrichment[ticker]["2026-01-05"]["ret_5d"] = 0.03

    params = ORBStrategyParams(
        engine_family="gainers_leader",
        allow_red_to_green_breakout=True,
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        min_abs_gap_pct=0.0,
        min_premarket_dollar_vol=0.0,
        max_candidates=10,
        min_candidates_to_trade=1,
        atr_stop_multiplier=10.0,
        trailing_at_r=99.0,
        breakeven_at_r=99.0,
        risk_per_trade_pct=0.01,
        max_position_pct=1.0,
        slippage_bps=0.0,
        thin_gap_up_loss_cap_min_gap_pct=0.02,
        thin_gap_up_loss_cap_min_ret_5d=-0.10,
        thin_gap_up_loss_cap_max_ret_5d=0.10,
        thin_gap_up_loss_cap_max_premarket_dollar_vol=10_000_000,
        thin_gap_up_loss_cap_min_body_ratio=0.50,
        thin_gap_up_loss_cap_min_close_location=0.80,
        thin_gap_up_loss_cap_pct=0.02,
    )

    day_result = simulate_orb_day(
        bars,
        "2026-01-05",
        params,
        enrichment,
        equity=10_000.0,
    )

    trades = {trade.ticker: trade for trade in day_result.trades}
    assert trades["THIN"].thin_gap_up_loss_cap_active
    assert not trades["LIQUID"].thin_gap_up_loss_cap_active


def test_moderate_downside_loss_cap_marks_only_moderate_weak_reclaims() -> None:
    bars = {}
    for ticker in ("MOD", "DEEP", "STRONG"):
        bars[ticker] = [
            {"timestamp": "2026-01-05T08:00:00-05:00", "open": 100.0, "high": 100.1, "low": 99.9, "close": 100.0, "volume": 1000},
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.2, "volume": 1000},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.0, "high": 101.8, "low": 100.8, "close": 101.4, "volume": 1000},
            {"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.4, "high": 102.0, "low": 101.0, "close": 101.6, "volume": 1000},
            {"timestamp": "2026-01-05T09:45:00-05:00", "open": 101.6, "high": 102.1, "low": 101.4, "close": 101.8, "volume": 1000},
            {"timestamp": "2026-01-05T15:55:00-05:00", "open": 101.8, "high": 102.0, "low": 101.5, "close": 101.7, "volume": 1000},
        ]
    enrichment = _enrichment_for("MOD", "DEEP", "STRONG")
    enrichment["MOD"]["2026-01-05"]["prev_close"] = 106.0
    enrichment["MOD"]["2026-01-05"]["ret_5d"] = 0.10
    enrichment["DEEP"]["2026-01-05"]["prev_close"] = 116.0
    enrichment["DEEP"]["2026-01-05"]["ret_5d"] = 0.10
    enrichment["STRONG"]["2026-01-05"]["prev_close"] = 106.0
    enrichment["STRONG"]["2026-01-05"]["ret_5d"] = 0.35

    params = ORBStrategyParams(
        engine_family="gainers_leader",
        allow_red_to_green_breakout=True,
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        min_abs_gap_pct=0.0,
        min_premarket_dollar_vol=0.0,
        max_candidates=10,
        min_candidates_to_trade=1,
        atr_stop_multiplier=10.0,
        trailing_at_r=99.0,
        breakeven_at_r=99.0,
        risk_per_trade_pct=0.01,
        max_position_pct=1.0,
        slippage_bps=0.0,
        moderate_downside_loss_cap_min_abs_gap_pct=0.05,
        moderate_downside_loss_cap_max_abs_gap_pct=0.10,
        moderate_downside_loss_cap_min_ret_5d=0.0,
        moderate_downside_loss_cap_max_ret_5d=0.25,
        moderate_downside_loss_cap_max_premarket_dollar_vol=10_000_000,
        moderate_downside_loss_cap_max_body_ratio=0.30,
        moderate_downside_loss_cap_max_close_location=0.75,
        moderate_downside_loss_cap_pct=0.01,
    )

    day_result = simulate_orb_day(
        bars,
        "2026-01-05",
        params,
        enrichment,
        equity=10_000.0,
    )

    trades = {trade.ticker: trade for trade in day_result.trades}
    assert trades["MOD"].moderate_downside_loss_cap_active
    assert not trades["DEEP"].moderate_downside_loss_cap_active
    assert not trades["STRONG"].moderate_downside_loss_cap_active


def test_stale_obv_reversal_extra_gates_target_positive_gap_weak_open() -> None:
    bars = {}
    for ticker in ("WEAK", "NEG_GAP", "HIGH_RVOL", "STRONG_BODY"):
        first_volume = 500_000 if ticker == "HIGH_RVOL" else 1_000
        first_close = 100.8 if ticker == "STRONG_BODY" else 98.9
        bars[ticker] = [
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 99.0, "high": 101.0, "low": 98.0, "close": first_close, "volume": first_volume},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": first_close, "high": 101.6, "low": first_close - 0.2, "close": 101.2, "volume": 1_000},
            {"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.2, "high": 102.0, "low": 101.0, "close": 101.6, "volume": 1_000},
            {"timestamp": "2026-01-05T15:55:00-05:00", "open": 101.5, "high": 101.8, "low": 101.2, "close": 101.7, "volume": 1_000},
        ]
    enrichment = _enrichment_for("WEAK", "NEG_GAP", "HIGH_RVOL", "STRONG_BODY")
    for ticker in enrichment:
        enrichment[ticker]["2026-01-05"]["prev_close"] = 96.0
        enrichment[ticker]["2026-01-05"]["ret_5d"] = 0.10
        enrichment[ticker]["2026-01-05"]["obv_slope_20"] = 0.20
    enrichment["NEG_GAP"]["2026-01-05"]["prev_close"] = 103.0

    params = ORBStrategyParams(
        engine_family="gainers_leader",
        allow_red_to_green_breakout=True,
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.0,
        min_abs_gap_pct=0.0,
        min_premarket_dollar_vol=0.0,
        max_candidates=10,
        min_candidates_to_trade=1,
        atr_stop_multiplier=10.0,
        trailing_at_r=99.0,
        breakeven_at_r=99.0,
        risk_per_trade_pct=0.001,
        max_position_pct=0.25,
        slippage_bps=0.0,
        stale_obv_reversal_max_obv_slope_20d=0.35,
        stale_obv_reversal_min_ret_5d=0.0,
        stale_obv_reversal_max_ret_5d=0.35,
        stale_obv_reversal_min_gap_pct=0.02,
        stale_obv_reversal_max_rvol=15.0,
        stale_obv_reversal_max_body_ratio=0.20,
        stale_obv_reversal_max_close_location=0.40,
        stale_obv_reversal_max_orb_return=0.0,
        stale_obv_reversal_action="confirm_scale",
        stale_obv_reversal_size_scale=0.35,
        stale_obv_reversal_loss_cap_pct=0.015,
    )

    day_result = simulate_orb_day(
        bars,
        "2026-01-05",
        params,
        enrichment,
        equity=10_000.0,
    )

    trades = {trade.ticker: trade for trade in day_result.trades}
    assert trades["WEAK"].stale_obv_reversal_requires_confirmation
    assert trades["WEAK"].stale_obv_reversal_size_scale == 0.35
    assert not trades["NEG_GAP"].stale_obv_reversal_requires_confirmation
    assert not trades["HIGH_RVOL"].stale_obv_reversal_requires_confirmation
    assert not trades["STRONG_BODY"].stale_obv_reversal_requires_confirmation


def test_fixed_loss_can_preserve_trailing_stop_management() -> None:
    bars = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 99.0, "high": 100.0, "low": 98.5, "close": 99.5, "volume": 1_000},
        {"timestamp": "2026-01-05T09:35:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 101.0, "volume": 2_000},
        {"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.0, "high": 103.0, "low": 100.8, "close": 102.5, "volume": 2_000},
        {"timestamp": "2026-01-05T09:45:00-05:00", "open": 102.0, "high": 102.2, "low": 101.0, "close": 101.2, "volume": 2_000},
        {"timestamp": "2026-01-05T15:55:00-05:00", "open": 104.0, "high": 104.2, "low": 103.8, "close": 104.0, "volume": 1_000},
    ]
    base_params = ORBStrategyParams(
        orb_minutes=5,
        sim_bar_minutes=5,
        order_timeout_minutes=30,
        atr_stop_multiplier=0.1,
        breakeven_at_r=99.0,
        trailing_at_r=1.0,
        trailing_stop_atr_multiplier=0.15,
        trailing_tighten_at_r=None,
        fixed_loss_pct=0.05,
        risk_per_trade_pct=0.01,
        max_position_pct=1.0,
        slippage_bps=0.0,
    )

    legacy_trade = simulate_orb_trade(
        bars,
        bars[0],
        "long",
        atr=10.0,
        rvol=2.0,
        gap_pct=0.03,
        params=base_params,
        equity=10_000.0,
        date_str="2026-01-05",
        ticker="LEGACY",
    )
    preserve_trade = simulate_orb_trade(
        bars,
        bars[0],
        "long",
        atr=10.0,
        rvol=2.0,
        gap_pct=0.03,
        params=base_params.model_copy(update={"fixed_loss_preserve_trailing": True}),
        equity=10_000.0,
        date_str="2026-01-05",
        ticker="PRESERVE",
    )

    assert legacy_trade is not None
    assert preserve_trade is not None
    assert legacy_trade.exit_reason == "close"
    assert preserve_trade.exit_reason == "trailing_stop"
    assert preserve_trade.exit_price == pytest.approx(101.5)


def test_conviction_runner_trail_only_delays_qualified_orb_tightening() -> None:
    bars = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.8, "volume": 1_000},
        {"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.0, "high": 101.2, "low": 101.0, "close": 101.1, "volume": 2_000},
        {"timestamp": "2026-01-05T09:40:00-05:00", "open": 102.6, "high": 102.7, "low": 102.3, "close": 102.6, "volume": 2_000},
        {"timestamp": "2026-01-05T09:45:00-05:00", "open": 102.4, "high": 102.6, "low": 102.4, "close": 102.5, "volume": 2_000},
        {"timestamp": "2026-01-05T15:55:00-05:00", "open": 104.0, "high": 104.1, "low": 103.9, "close": 104.0, "volume": 1_000},
    ]
    base_params = ORBStrategyParams(
        orb_minutes=5,
        sim_bar_minutes=5,
        order_timeout_minutes=30,
        atr_stop_multiplier=1.0,
        breakeven_at_r=1.0,
        trailing_at_r=1.0,
        trailing_stop_atr_multiplier=0.6,
        trailing_tighten_at_r=1.5,
        trailing_stop_atr_multiplier_tight=0.2,
        risk_per_trade_pct=0.01,
        max_position_pct=1.0,
        slippage_bps=0.0,
    )
    runner_params = base_params.model_copy(
        update={
            "conviction_runner_trail_allowed_trigger_types": ["orb"],
            "conviction_runner_trail_tighten_at_r": 2.0,
            "conviction_runner_trail_gap_atr_multiplier": 0.8,
            "conviction_runner_trail_min_abs_gap_pct": 0.035,
            "conviction_runner_trail_min_candidate_score": 0.9,
            "conviction_runner_trail_min_score_rank_pct": 0.9,
        }
    )

    base_trade = simulate_orb_trade(
        bars,
        bars[0],
        "long",
        atr=1.0,
        rvol=8.0,
        gap_pct=0.04,
        params=base_params,
        equity=10_000.0,
        date_str="2026-01-05",
        ticker="BASE",
        score_rank_pct=1.0,
        candidate_score=0.95,
    )
    runner_trade = simulate_orb_trade(
        bars,
        bars[0],
        "long",
        atr=1.0,
        rvol=8.0,
        gap_pct=0.04,
        params=runner_params,
        equity=10_000.0,
        date_str="2026-01-05",
        ticker="RUNNER",
        score_rank_pct=1.0,
        candidate_score=0.95,
    )
    blocked_trade = simulate_orb_trade(
        bars,
        bars[0],
        "long",
        atr=1.0,
        rvol=8.0,
        gap_pct=0.04,
        params=runner_params,
        equity=10_000.0,
        date_str="2026-01-05",
        ticker="BLOCKED",
        score_rank_pct=1.0,
        candidate_score=0.50,
    )

    assert base_trade is not None
    assert runner_trade is not None
    assert blocked_trade is not None
    assert base_trade.exit_reason == "trailing_stop"
    assert base_trade.exit_price == pytest.approx(102.4)
    assert runner_trade.exit_reason == "close"
    assert runner_trade.exit_price == pytest.approx(104.0)
    assert blocked_trade.exit_reason == "trailing_stop"
    assert blocked_trade.exit_price == pytest.approx(base_trade.exit_price)


def test_aggregated_bar_entry_fills_on_next_raw_bar_after_signal() -> None:
    bars = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.8, "volume": 1000},
        {"timestamp": "2026-01-05T09:35:00-05:00", "open": 103.0, "high": 103.2, "low": 102.8, "close": 103.0, "volume": 1000},
        {"timestamp": "2026-01-05T09:40:00-05:00", "open": 103.0, "high": 103.0, "low": 102.9, "close": 102.95, "volume": 1000},
        {"timestamp": "2026-01-05T09:45:00-05:00", "open": 102.95, "high": 103.1, "low": 102.9, "close": 103.0, "volume": 1000},
        {"timestamp": "2026-01-05T09:50:00-05:00", "open": 103.0, "high": 103.1, "low": 102.8, "close": 102.9, "volume": 1000},
        {"timestamp": "2026-01-05T09:55:00-05:00", "open": 102.9, "high": 103.0, "low": 102.7, "close": 102.8, "volume": 1000},
        {"timestamp": "2026-01-05T10:00:00-05:00", "open": 102.8, "high": 103.3, "low": 102.7, "close": 103.1, "volume": 1000},
        {"timestamp": "2026-01-05T10:05:00-05:00", "open": 104.5, "high": 104.8, "low": 104.4, "close": 104.7, "volume": 1000},
        {"timestamp": "2026-01-05T10:10:00-05:00", "open": 104.7, "high": 104.9, "low": 104.6, "close": 104.8, "volume": 1000},
    ]
    params = ORBStrategyParams(
        sim_bar_minutes=30,
        orb_minutes=5,
        order_timeout_minutes=45,
        atr_stop_multiplier=10.0,
        trailing_at_r=99.0,
        breakeven_at_r=99.0,
        slippage_bps=0.0,
    )

    trade = simulate_orb_trade(
        bars,
        bars[0],
        "long",
        atr=1.0,
        rvol=2.0,
        gap_pct=0.01,
        params=params,
        equity=10_000.0,
        date_str="2026-01-05",
        ticker="TEST",
    )

    assert trade is not None
    assert trade.entry_time == "2026-01-05T10:05:00-05:00"
    assert trade.entry_price == 104.5


def test_same_bar_stop_confirmation_delays_ambiguous_entry_bar_stop() -> None:
    bars = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.8, "volume": 1000},
        {"timestamp": "2026-01-05T09:35:00-05:00", "open": 100.8, "high": 101.2, "low": 100.4, "close": 101.1, "volume": 1500},
        {"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.1, "high": 102.0, "low": 101.0, "close": 101.8, "volume": 1600},
        {"timestamp": "2026-01-05T15:55:00-05:00", "open": 101.1, "high": 101.4, "low": 100.9, "close": 101.2, "volume": 1000},
    ]
    base_params = ORBStrategyParams(
        orb_minutes=5,
        sim_bar_minutes=5,
        order_timeout_minutes=30,
        atr_stop_multiplier=0.5,
        trailing_at_r=99.0,
        breakeven_at_r=99.0,
        slippage_bps=0.0,
        risk_per_trade_pct=0.01,
        max_position_pct=1.0,
    )

    default_trade = simulate_orb_trade(
        bars,
        bars[0],
        "long",
        atr=1.0,
        rvol=8.0,
        gap_pct=0.03,
        params=base_params,
        equity=10_000.0,
        date_str="2026-01-05",
        ticker="STOP",
    )
    delayed_trade = simulate_orb_trade(
        bars,
        bars[0],
        "long",
        atr=1.0,
        rvol=8.0,
        gap_pct=0.03,
        params=base_params.model_copy(
            update={
                "same_bar_stop_confirmation_enabled": True,
                "same_bar_stop_confirmation_allowed_trigger_types": ["orb"],
            }
        ),
        equity=10_000.0,
        date_str="2026-01-05",
        ticker="DELAY",
    )
    blocked_by_trigger = simulate_orb_trade(
        bars,
        bars[0],
        "long",
        atr=1.0,
        rvol=8.0,
        gap_pct=0.03,
        params=base_params.model_copy(
            update={
                "same_bar_stop_confirmation_enabled": True,
                "same_bar_stop_confirmation_allowed_trigger_types": ["vwap_reclaim"],
            }
        ),
        equity=10_000.0,
        date_str="2026-01-05",
        ticker="ALLOWLIST",
    )

    assert default_trade is not None
    assert default_trade.exit_reason == "stop_loss"
    assert default_trade.entry_time == default_trade.exit_time
    assert default_trade.total_capital_deployed > 0
    assert delayed_trade is not None
    assert delayed_trade.entry_time == "2026-01-05T09:40:00-05:00"
    assert delayed_trade.entry_price == 101.8
    assert blocked_by_trigger is not None
    assert blocked_by_trigger.exit_reason == "stop_loss"
    assert blocked_by_trigger.total_capital_deployed > 0


def test_early_failure_exit_cuts_failed_breakout() -> None:
    bars = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.8, "volume": 1000},
        {"timestamp": "2026-01-05T09:35:00-05:00", "open": 100.8, "high": 101.2, "low": 100.7, "close": 101.0, "volume": 1200},
        {"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.0, "high": 101.1, "low": 100.6, "close": 100.8, "volume": 1500},
        {"timestamp": "2026-01-05T15:55:00-05:00", "open": 102.0, "high": 102.2, "low": 101.8, "close": 102.0, "volume": 1000},
    ]
    params = ORBStrategyParams(
        orb_minutes=5,
        sim_bar_minutes=5,
        order_timeout_minutes=30,
        atr_stop_multiplier=10.0,
        trailing_at_r=99.0,
        breakeven_at_r=99.0,
        slippage_bps=0.0,
        risk_per_trade_pct=0.01,
        max_position_pct=1.0,
        early_failure_exit_minutes=15,
        early_failure_exit_level="breakout",
        early_failure_exit_max_peak_r=0.50,
    )

    trade = simulate_orb_trade(
        bars,
        bars[0],
        "long",
        atr=1.0,
        rvol=8.0,
        gap_pct=0.03,
        params=params,
        equity=10_000.0,
        date_str="2026-01-05",
        ticker="FAIL",
    )

    assert trade is not None
    assert trade.exit_reason == "early_failure"
    assert trade.exit_time == "2026-01-05T09:40:00-05:00"
    assert trade.exit_price == 100.8


def test_early_failure_exit_allows_trade_after_positive_close_excursion() -> None:
    bars = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.8, "volume": 1000},
        {"timestamp": "2026-01-05T09:35:00-05:00", "open": 100.8, "high": 101.2, "low": 100.7, "close": 101.0, "volume": 1200},
        {"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.0, "high": 102.2, "low": 100.9, "close": 102.0, "volume": 1500},
        {"timestamp": "2026-01-05T09:45:00-05:00", "open": 102.0, "high": 102.1, "low": 100.5, "close": 100.8, "volume": 1500},
        {"timestamp": "2026-01-05T15:55:00-05:00", "open": 102.0, "high": 102.2, "low": 101.8, "close": 102.0, "volume": 1000},
    ]
    params = ORBStrategyParams(
        orb_minutes=5,
        sim_bar_minutes=5,
        order_timeout_minutes=30,
        atr_stop_multiplier=1.0,
        trailing_at_r=99.0,
        breakeven_at_r=99.0,
        slippage_bps=0.0,
        risk_per_trade_pct=0.01,
        max_position_pct=1.0,
        early_failure_exit_minutes=15,
        early_failure_exit_level="breakout",
        early_failure_exit_max_peak_r=0.50,
    )

    trade = simulate_orb_trade(
        bars,
        bars[0],
        "long",
        atr=1.0,
        rvol=8.0,
        gap_pct=0.03,
        params=params,
        equity=10_000.0,
        date_str="2026-01-05",
        ticker="HOLD",
    )

    assert trade is not None
    assert trade.exit_reason == "close"
    assert trade.exit_price == 102.0


def test_pyramid_quality_gates_control_add_on() -> None:
    bars = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.8, "volume": 1000},
        {"timestamp": "2026-01-05T09:35:00-05:00", "open": 100.8, "high": 101.2, "low": 100.7, "close": 101.0, "volume": 1200},
        {"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.0, "high": 102.4, "low": 101.0, "close": 102.2, "volume": 1500},
        {"timestamp": "2026-01-05T15:55:00-05:00", "open": 103.8, "high": 104.1, "low": 103.7, "close": 104.0, "volume": 1000},
    ]
    params = ORBStrategyParams(
        orb_minutes=5,
        sim_bar_minutes=5,
        order_timeout_minutes=30,
        atr_stop_multiplier=1.0,
        trailing_at_r=99.0,
        breakeven_at_r=99.0,
        slippage_bps=0.0,
        risk_per_trade_pct=0.02,
        max_position_pct=0.50,
        pyramid_at_r=1.0,
        pyramid_add_pct=0.50,
        pyramid_max_adds=1,
        pyramid_allowed_trigger_types=["orb"],
        pyramid_min_score_rank_pct=0.80,
        pyramid_min_rvol=6.0,
    )

    allowed_trade = simulate_orb_trade(
        bars,
        bars[0],
        "long",
        atr=1.0,
        rvol=8.0,
        gap_pct=0.03,
        params=params,
        equity=10_000.0,
        date_str="2026-01-05",
        ticker="ALLOW",
        score_rank_pct=0.90,
    )
    low_rank_trade = simulate_orb_trade(
        bars,
        bars[0],
        "long",
        atr=1.0,
        rvol=8.0,
        gap_pct=0.03,
        params=params,
        equity=10_000.0,
        date_str="2026-01-05",
        ticker="LOWRANK",
        score_rank_pct=0.70,
    )
    low_rvol_trade = simulate_orb_trade(
        bars,
        bars[0],
        "long",
        atr=1.0,
        rvol=5.0,
        gap_pct=0.03,
        params=params,
        equity=10_000.0,
        date_str="2026-01-05",
        ticker="LOWRVOL",
        score_rank_pct=0.90,
    )
    blocked_trigger_trade = simulate_orb_trade(
        bars,
        bars[0],
        "long",
        atr=1.0,
        rvol=8.0,
        gap_pct=0.03,
        params=params.model_copy(update={"pyramid_allowed_trigger_types": ["vwap_reclaim"]}),
        equity=10_000.0,
        date_str="2026-01-05",
        ticker="BLOCKED",
        score_rank_pct=0.90,
    )
    blocked_context_trade = simulate_orb_trade(
        bars,
        bars[0],
        "long",
        atr=1.0,
        rvol=8.0,
        gap_pct=0.03,
        params=params,
        equity=10_000.0,
        date_str="2026-01-05",
        ticker="CTXBLOCK",
        score_rank_pct=0.90,
        pyramid_allowed=False,
    )

    assert allowed_trade is not None
    assert low_rank_trade is not None
    assert low_rvol_trade is not None
    assert blocked_trigger_trade is not None
    assert blocked_context_trade is not None
    assert allowed_trade.pyramid_adds == 1
    assert allowed_trade.pyramid_pnl > 0
    assert low_rank_trade.pyramid_adds == 0
    assert low_rvol_trade.pyramid_adds == 0
    assert blocked_trigger_trade.pyramid_adds == 0
    assert blocked_context_trade.pyramid_adds == 0


def test_pyramid_add_on_respects_available_cash() -> None:
    bars = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.8, "volume": 1000},
        {"timestamp": "2026-01-05T09:35:00-05:00", "open": 100.8, "high": 101.2, "low": 100.7, "close": 101.0, "volume": 1200},
        {"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.0, "high": 102.4, "low": 101.0, "close": 102.2, "volume": 1500},
        {"timestamp": "2026-01-05T15:55:00-05:00", "open": 103.8, "high": 104.1, "low": 103.7, "close": 104.0, "volume": 1000},
    ]
    base_params = ORBStrategyParams(
        orb_minutes=5,
        sim_bar_minutes=5,
        order_timeout_minutes=30,
        atr_stop_multiplier=1.0,
        trailing_at_r=99.0,
        breakeven_at_r=99.0,
        slippage_bps=0.0,
        risk_per_trade_pct=0.02,
        pyramid_at_r=1.0,
        pyramid_add_pct=0.50,
        pyramid_max_adds=1,
    )

    capped_trade = simulate_orb_trade(
        bars,
        bars[0],
        "long",
        atr=1.0,
        rvol=8.0,
        gap_pct=0.03,
        params=base_params.model_copy(update={"max_position_pct": 0.50}),
        equity=10_000.0,
        date_str="2026-01-05",
        ticker="CAPPED",
        available_cash=6_000.0,
        score_rank_pct=0.90,
    )
    no_room_trade = simulate_orb_trade(
        bars,
        bars[0],
        "long",
        atr=1.0,
        rvol=8.0,
        gap_pct=0.03,
        params=base_params.model_copy(update={"max_position_pct": 1.00}),
        equity=10_000.0,
        date_str="2026-01-05",
        ticker="NOROOM",
        available_cash=6_000.0,
        score_rank_pct=0.90,
    )

    assert capped_trade is not None
    assert no_room_trade is not None
    assert capped_trade.pyramid_adds == 1
    assert capped_trade.total_capital_deployed <= 6_000.0
    assert no_room_trade.pyramid_adds == 0
    assert no_room_trade.total_capital_deployed <= 6_000.0


def test_entry_market_guard_uses_entry_bar_open_without_close_lookahead() -> None:
    bars = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.8, "close": 100.8, "volume": 10_000},
        {"timestamp": "2026-01-05T09:35:00-05:00", "open": 100.6, "high": 100.7, "low": 99.5, "close": 99.6, "volume": 12_000},
        {"timestamp": "2026-01-05T09:40:00-05:00", "open": 99.4, "high": 99.6, "low": 99.0, "close": 99.2, "volume": 12_000},
    ]
    entry_ts = _parse_test_ts("2026-01-05T09:35:00-05:00")

    market_return = _market_return_at_entry_open(bars, entry_ts, "2026-01-05")

    assert market_return == pytest.approx(0.006)


def test_entry_market_guard_scales_when_market_is_below_threshold() -> None:
    bars = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 100.2, "low": 99.7, "close": 99.8, "volume": 10_000},
        {"timestamp": "2026-01-05T09:35:00-05:00", "open": 99.7, "high": 99.8, "low": 99.0, "close": 99.1, "volume": 12_000},
    ]
    params = ORBStrategyParams(
        entry_market_guard_enabled=True,
        entry_market_guard_min_return_pct=-0.001,
        entry_market_guard_size_scale=0.4,
    )

    active, market_return, size_scale = _entry_market_guard_scale(
        params,
        bars,
        _parse_test_ts("2026-01-05T09:35:00-05:00"),
        "2026-01-05",
        is_soft_day=False,
    )

    assert active is True
    assert market_return == pytest.approx(-0.003)
    assert size_scale == pytest.approx(0.4)


def test_daily_loss_limit_counts_only_losses_realized_before_next_breakout() -> None:
    params = ORBStrategyParams(
        orb_minutes=5,
        sim_bar_minutes=5,
        order_timeout_minutes=45,
        atr_stop_multiplier=1.0,
        trailing_at_r=99.0,
        breakeven_at_r=99.0,
        slippage_bps=0.0,
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        max_candidates=20,
        min_candidates_to_trade=1,
        risk_per_trade_pct=1.0,
        max_position_pct=1.0,
        daily_max_loss_pct=0.005,
        max_stops_per_day=99,
    )

    early_close_loser = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.8, "volume": 1000},
        {"timestamp": "2026-01-05T09:35:00-05:00", "open": 100.8, "high": 101.2, "low": 100.5, "close": 101.1, "volume": 1000},
        {"timestamp": "2026-01-05T09:40:00-05:00", "open": 100.7, "high": 100.8, "low": 100.5, "close": 100.6, "volume": 1000},
        {"timestamp": "2026-01-05T09:45:00-05:00", "open": 100.6, "high": 100.7, "low": 100.4, "close": 100.6, "volume": 1000},
        {"timestamp": "2026-01-05T10:00:00-05:00", "open": 100.6, "high": 100.7, "low": 100.4, "close": 100.5, "volume": 1000},
        {"timestamp": "2026-01-05T15:55:00-05:00", "open": 100.5, "high": 100.6, "low": 100.4, "close": 100.4, "volume": 1000},
    ]
    late_winner = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.8, "volume": 1000},
        {"timestamp": "2026-01-05T09:35:00-05:00", "open": 100.8, "high": 100.9, "low": 100.5, "close": 100.7, "volume": 1000},
        {"timestamp": "2026-01-05T09:40:00-05:00", "open": 100.7, "high": 100.8, "low": 100.5, "close": 100.6, "volume": 1000},
        {"timestamp": "2026-01-05T09:45:00-05:00", "open": 100.6, "high": 100.7, "low": 100.4, "close": 100.6, "volume": 1000},
        {"timestamp": "2026-01-05T10:00:00-05:00", "open": 100.6, "high": 101.3, "low": 100.5, "close": 101.2, "volume": 1000},
        {"timestamp": "2026-01-05T15:55:00-05:00", "open": 101.2, "high": 102.0, "low": 101.0, "close": 101.8, "volume": 1000},
    ]

    day_result = simulate_orb_day(
        {
            "EARLY_CLOSE_LOSER": early_close_loser,
            "LATE_WINNER": late_winner,
        },
        "2026-01-05",
        params,
        _enrichment_for("EARLY_CLOSE_LOSER", "LATE_WINNER"),
        equity=10_000.0,
    )

    assert [trade.ticker for trade in day_result.trades] == ["EARLY_CLOSE_LOSER", "LATE_WINNER"]
    assert day_result.trades[0].exit_time == "2026-01-05T15:55:00-05:00"
    assert day_result.trades[1].entry_time == "2026-01-05T10:00:00-05:00"


def test_crowded_gap_reject_filters_extended_positive_gap_candidates() -> None:
    bars = {
        "CROWDED": [
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 105.0, "low": 99.0, "close": 104.0, "volume": 1000},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 104.0, "high": 106.0, "low": 103.0, "close": 105.0, "volume": 1000},
            {"timestamp": "2026-01-05T09:40:00-05:00", "open": 105.0, "high": 106.0, "low": 104.0, "close": 105.5, "volume": 1000},
            {"timestamp": "2026-01-05T09:45:00-05:00", "open": 105.5, "high": 106.0, "low": 105.0, "close": 105.7, "volume": 1000},
            {"timestamp": "2026-01-05T15:55:00-05:00", "open": 105.5, "high": 106.0, "low": 105.0, "close": 105.8, "volume": 1000},
        ],
        "FRESH": [
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 102.0, "low": 99.0, "close": 101.0, "volume": 1000},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.0, "high": 103.0, "low": 100.8, "close": 102.5, "volume": 1000},
            {"timestamp": "2026-01-05T09:40:00-05:00", "open": 102.5, "high": 103.0, "low": 102.0, "close": 102.8, "volume": 1000},
            {"timestamp": "2026-01-05T09:45:00-05:00", "open": 102.8, "high": 103.0, "low": 102.5, "close": 102.9, "volume": 1000},
            {"timestamp": "2026-01-05T15:55:00-05:00", "open": 102.8, "high": 103.0, "low": 102.5, "close": 102.9, "volume": 1000},
        ],
    }
    enrichment = _enrichment_for("CROWDED", "FRESH")
    enrichment["CROWDED"]["2026-01-05"]["prev_close"] = 97.0
    enrichment["CROWDED"]["2026-01-05"]["ret_5d"] = 0.30
    enrichment["FRESH"]["2026-01-05"]["prev_close"] = 97.0
    enrichment["FRESH"]["2026-01-05"]["ret_5d"] = 0.05
    stats: dict[str, int] = {}
    params = ORBStrategyParams(
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        min_abs_gap_pct=None,
        max_candidates=10,
        crowded_gap_reject_min_gap_pct=0.02,
        crowded_gap_reject_min_ret_5d=0.20,
        crowded_gap_reject_min_body_ratio=0.60,
        crowded_gap_reject_min_close_location=0.65,
    )

    candidates = compute_orb_candidates(
        bars, "2026-01-05", params, enrichment, _stats_out=stats
    )

    assert [cand["ticker"] for cand in candidates] == ["FRESH"]
    assert stats["crowded_gap"] == 1


def test_crowded_gap_confirm_keeps_candidate_with_confirmation_flag() -> None:
    bars = {
        "CROWDED": [
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 105.0, "low": 99.0, "close": 104.0, "volume": 1000},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 104.0, "high": 106.0, "low": 103.0, "close": 105.0, "volume": 1000},
            {"timestamp": "2026-01-05T09:40:00-05:00", "open": 105.0, "high": 106.0, "low": 104.0, "close": 105.5, "volume": 1000},
            {"timestamp": "2026-01-05T09:45:00-05:00", "open": 105.5, "high": 106.0, "low": 105.0, "close": 105.7, "volume": 1000},
            {"timestamp": "2026-01-05T15:55:00-05:00", "open": 105.5, "high": 106.0, "low": 105.0, "close": 105.8, "volume": 1000},
        ],
    }
    enrichment = _enrichment_for("CROWDED")
    enrichment["CROWDED"]["2026-01-05"]["prev_close"] = 97.0
    enrichment["CROWDED"]["2026-01-05"]["ret_5d"] = 0.30
    stats: dict[str, int] = {}
    params = ORBStrategyParams(
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        min_abs_gap_pct=None,
        max_candidates=10,
        crowded_gap_reject_min_gap_pct=0.02,
        crowded_gap_reject_min_ret_5d=0.20,
        crowded_gap_reject_min_body_ratio=0.60,
        crowded_gap_reject_min_close_location=0.65,
        crowded_gap_action="confirm",
    )

    candidates = compute_orb_candidates(
        bars, "2026-01-05", params, enrichment, _stats_out=stats
    )

    assert [cand["ticker"] for cand in candidates] == ["CROWDED"]
    assert candidates[0]["crowded_gap_requires_confirmation"] is True
    assert stats["crowded_gap"] == 1


def test_crowded_gap_scale_keeps_candidate_with_size_scale() -> None:
    bars = {
        "CROWDED": [
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 105.0, "low": 99.0, "close": 104.0, "volume": 1000},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 104.0, "high": 106.0, "low": 103.0, "close": 105.0, "volume": 1000},
            {"timestamp": "2026-01-05T09:40:00-05:00", "open": 105.0, "high": 106.0, "low": 104.0, "close": 105.5, "volume": 1000},
            {"timestamp": "2026-01-05T09:45:00-05:00", "open": 105.5, "high": 106.0, "low": 105.0, "close": 105.7, "volume": 1000},
            {"timestamp": "2026-01-05T15:55:00-05:00", "open": 105.5, "high": 106.0, "low": 105.0, "close": 105.8, "volume": 1000},
        ],
    }
    enrichment = _enrichment_for("CROWDED")
    enrichment["CROWDED"]["2026-01-05"]["prev_close"] = 97.0
    enrichment["CROWDED"]["2026-01-05"]["ret_5d"] = 0.30
    stats: dict[str, int] = {}
    params = ORBStrategyParams(
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        min_abs_gap_pct=None,
        max_candidates=10,
        crowded_gap_reject_min_gap_pct=0.02,
        crowded_gap_reject_min_ret_5d=0.20,
        crowded_gap_reject_min_body_ratio=0.60,
        crowded_gap_reject_min_close_location=0.65,
        crowded_gap_action="scale",
        crowded_gap_size_scale=0.4,
    )

    candidates = compute_orb_candidates(
        bars, "2026-01-05", params, enrichment, _stats_out=stats
    )

    assert [cand["ticker"] for cand in candidates] == ["CROWDED"]
    assert candidates[0]["crowded_gap_requires_confirmation"] is False
    assert candidates[0]["crowded_gap_size_scale"] == 0.4
    assert stats["crowded_gap"] == 1


def test_countertrend_gap_scale_keeps_candidate_with_size_scale() -> None:
    bars = {
        "BOUNCE": [
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 105.0, "low": 99.0, "close": 104.5, "volume": 1000},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 104.5, "high": 106.0, "low": 103.0, "close": 105.0, "volume": 1000},
            {"timestamp": "2026-01-05T09:40:00-05:00", "open": 105.0, "high": 106.0, "low": 104.0, "close": 105.5, "volume": 1000},
            {"timestamp": "2026-01-05T09:45:00-05:00", "open": 105.5, "high": 106.0, "low": 105.0, "close": 105.7, "volume": 1000},
            {"timestamp": "2026-01-05T15:55:00-05:00", "open": 105.5, "high": 106.0, "low": 105.0, "close": 105.8, "volume": 1000},
        ],
    }
    enrichment = _enrichment_for("BOUNCE")
    enrichment["BOUNCE"]["2026-01-05"]["prev_close"] = 97.0
    enrichment["BOUNCE"]["2026-01-05"]["ret_5d"] = -0.08
    stats: dict[str, int] = {}
    params = ORBStrategyParams(
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        min_abs_gap_pct=None,
        max_candidates=10,
        countertrend_gap_min_gap_pct=0.02,
        countertrend_gap_max_ret_5d=0.0,
        countertrend_gap_min_body_ratio=0.60,
        countertrend_gap_min_close_location=0.65,
        countertrend_gap_action="scale",
        countertrend_gap_size_scale=0.45,
    )

    candidates = compute_orb_candidates(
        bars, "2026-01-05", params, enrichment, _stats_out=stats
    )

    assert [cand["ticker"] for cand in candidates] == ["BOUNCE"]
    assert candidates[0]["countertrend_gap_requires_confirmation"] is False
    assert candidates[0]["countertrend_gap_size_scale"] == 0.45
    assert stats["countertrend_gap"] == 1


def test_red_to_green_gap_weight_prioritizes_down_gap_reclaim_candidates() -> None:
    bars = {
        ticker: [
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 102.0, "low": 99.0, "close": 101.0, "volume": 1000},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.0, "high": 103.0, "low": 100.8, "close": 102.5, "volume": 1000},
            {"timestamp": "2026-01-05T09:40:00-05:00", "open": 102.5, "high": 103.0, "low": 102.0, "close": 102.8, "volume": 1000},
            {"timestamp": "2026-01-05T09:45:00-05:00", "open": 102.8, "high": 103.0, "low": 102.5, "close": 102.9, "volume": 1000},
            {"timestamp": "2026-01-05T15:55:00-05:00", "open": 102.8, "high": 103.0, "low": 102.5, "close": 102.9, "volume": 1000},
        ]
        for ticker in ("DOWN_RECLAIM", "UP_GAP")
    }
    enrichment = _enrichment_for("DOWN_RECLAIM", "UP_GAP")
    enrichment["DOWN_RECLAIM"]["2026-01-05"]["prev_close"] = 103.0
    enrichment["UP_GAP"]["2026-01-05"]["prev_close"] = 97.0
    params = ORBStrategyParams(
        engine_family="gainers_leader",
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        min_abs_gap_pct=None,
        weight_rvol=0.0,
        weight_gap=0.0,
        weight_dollar_vol=0.0,
        weight_premarket_dollar_vol=0.0,
        weight_red_to_green_gap=1.0,
        max_candidates=10,
    )

    candidates = compute_orb_candidates(bars, "2026-01-05", params, enrichment)

    assert [cand["ticker"] for cand in candidates] == ["DOWN_RECLAIM", "UP_GAP"]
    assert candidates[0]["score"] > candidates[1]["score"]


def test_red_to_green_reserved_slot_replaces_lowest_normal_candidate() -> None:
    bars = {
        "UP_HIGH": [
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 102.0, "low": 99.0, "close": 101.0, "volume": 3000},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.0, "high": 103.0, "low": 100.8, "close": 102.5, "volume": 1000},
            {"timestamp": "2026-01-05T09:40:00-05:00", "open": 102.5, "high": 103.0, "low": 102.0, "close": 102.8, "volume": 1000},
            {"timestamp": "2026-01-05T09:45:00-05:00", "open": 102.8, "high": 103.0, "low": 102.5, "close": 102.9, "volume": 1000},
            {"timestamp": "2026-01-05T15:55:00-05:00", "open": 102.8, "high": 103.0, "low": 102.5, "close": 102.9, "volume": 1000},
        ],
        "UP_SECOND": [
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 102.0, "low": 99.0, "close": 101.0, "volume": 2000},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.0, "high": 103.0, "low": 100.8, "close": 102.5, "volume": 1000},
            {"timestamp": "2026-01-05T09:40:00-05:00", "open": 102.5, "high": 103.0, "low": 102.0, "close": 102.8, "volume": 1000},
            {"timestamp": "2026-01-05T09:45:00-05:00", "open": 102.8, "high": 103.0, "low": 102.5, "close": 102.9, "volume": 1000},
            {"timestamp": "2026-01-05T15:55:00-05:00", "open": 102.8, "high": 103.0, "low": 102.5, "close": 102.9, "volume": 1000},
        ],
        "DOWN_RECLAIM": [
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 102.0, "low": 99.0, "close": 101.0, "volume": 1000},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.0, "high": 103.0, "low": 100.8, "close": 102.5, "volume": 1000},
            {"timestamp": "2026-01-05T09:40:00-05:00", "open": 102.5, "high": 103.0, "low": 102.0, "close": 102.8, "volume": 1000},
            {"timestamp": "2026-01-05T09:45:00-05:00", "open": 102.8, "high": 103.0, "low": 102.5, "close": 102.9, "volume": 1000},
            {"timestamp": "2026-01-05T15:55:00-05:00", "open": 102.8, "high": 103.0, "low": 102.5, "close": 102.9, "volume": 1000},
        ],
    }
    enrichment = _enrichment_for("UP_HIGH", "UP_SECOND", "DOWN_RECLAIM")
    enrichment["UP_HIGH"]["2026-01-05"]["prev_close"] = 97.0
    enrichment["UP_SECOND"]["2026-01-05"]["prev_close"] = 97.0
    enrichment["DOWN_RECLAIM"]["2026-01-05"]["prev_close"] = 103.0
    params = ORBStrategyParams(
        engine_family="gainers_leader",
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        min_abs_gap_pct=None,
        weight_rvol=0.0,
        weight_gap=0.0,
        weight_dollar_vol=1.0,
        weight_premarket_dollar_vol=0.0,
        max_candidates=2,
        red_to_green_reserved_slots=1,
        red_to_green_min_abs_gap_pct=0.02,
        red_to_green_min_close_location=0.60,
    )

    candidates = compute_orb_candidates(bars, "2026-01-05", params, enrichment)

    assert [cand["ticker"] for cand in candidates] == ["UP_HIGH", "DOWN_RECLAIM"]
    assert candidates[1]["red_to_green_reserved"] is True


def test_red_to_green_reserved_slot_can_require_opening_acceleration() -> None:
    bars = {
        "UP_HIGH": [
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 102.0, "low": 99.0, "close": 101.0, "volume": 3000},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.0, "high": 103.0, "low": 100.8, "close": 102.5, "volume": 1000},
            {"timestamp": "2026-01-05T09:40:00-05:00", "open": 102.5, "high": 103.0, "low": 102.0, "close": 102.8, "volume": 1000},
        ],
        "DOWN_WEAK_RECLAIM": [
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 102.0, "low": 99.0, "close": 101.0, "volume": 2000},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.0, "high": 103.0, "low": 100.8, "close": 102.5, "volume": 1000},
            {"timestamp": "2026-01-05T09:40:00-05:00", "open": 102.5, "high": 103.0, "low": 102.0, "close": 102.8, "volume": 1000},
        ],
        "DOWN_ACCEL": [
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 112.0, "low": 99.0, "close": 111.0, "volume": 1000},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 111.0, "high": 113.0, "low": 110.8, "close": 112.5, "volume": 1000},
            {"timestamp": "2026-01-05T09:40:00-05:00", "open": 112.5, "high": 113.0, "low": 112.0, "close": 112.8, "volume": 1000},
        ],
    }
    enrichment = _enrichment_for("UP_HIGH", "DOWN_WEAK_RECLAIM", "DOWN_ACCEL")
    enrichment["UP_HIGH"]["2026-01-05"]["prev_close"] = 97.0
    enrichment["DOWN_WEAK_RECLAIM"]["2026-01-05"]["prev_close"] = 103.0
    enrichment["DOWN_ACCEL"]["2026-01-05"]["prev_close"] = 113.0
    params = ORBStrategyParams(
        engine_family="gainers_leader",
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        min_abs_gap_pct=None,
        weight_rvol=0.0,
        weight_gap=0.0,
        weight_dollar_vol=1.0,
        weight_premarket_dollar_vol=0.0,
        max_candidates=2,
        red_to_green_reserved_slots=1,
        red_to_green_min_abs_gap_pct=0.02,
        red_to_green_min_close_location=0.60,
        red_to_green_min_orb_return=0.08,
    )

    candidates = compute_orb_candidates(bars, "2026-01-05", params, enrichment)

    assert [cand["ticker"] for cand in candidates] == ["UP_HIGH", "DOWN_ACCEL"]
    assert candidates[1]["red_to_green_reserved"] is True


def test_daily_loss_limit_is_not_tightened_by_day_size_scalers() -> None:
    params = ORBStrategyParams(
        orb_minutes=5,
        sim_bar_minutes=5,
        order_timeout_minutes=45,
        atr_stop_multiplier=0.5,
        trailing_at_r=99.0,
        breakeven_at_r=99.0,
        slippage_bps=0.0,
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        max_candidates=20,
        min_candidates_to_trade=1,
        risk_per_trade_pct=0.06,
        max_position_pct=1.0,
        daily_max_loss_pct=0.05,
        max_stops_per_day=99,
        market_regime_ticker="SPY",
        regime_size_scale_low=0.0,
        regime_size_scale_high=0.02,
        regime_size_scale_min=0.5,
    )

    early_loser = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.8, "volume": 1000},
        {"timestamp": "2026-01-05T09:35:00-05:00", "open": 100.8, "high": 101.2, "low": 100.7, "close": 101.1, "volume": 1000},
        {"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.0, "high": 101.0, "low": 100.2, "close": 100.3, "volume": 1000},
        {"timestamp": "2026-01-05T09:45:00-05:00", "open": 100.3, "high": 100.4, "low": 100.2, "close": 100.3, "volume": 1000},
        {"timestamp": "2026-01-05T15:55:00-05:00", "open": 100.3, "high": 100.4, "low": 100.2, "close": 100.3, "volume": 1000},
    ]
    late_winner = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.6, "volume": 1000},
        {"timestamp": "2026-01-05T09:35:00-05:00", "open": 100.6, "high": 100.9, "low": 100.4, "close": 100.7, "volume": 1000},
        {"timestamp": "2026-01-05T09:40:00-05:00", "open": 100.7, "high": 100.8, "low": 100.5, "close": 100.6, "volume": 1000},
        {"timestamp": "2026-01-05T09:45:00-05:00", "open": 100.6, "high": 100.7, "low": 100.5, "close": 100.6, "volume": 1000},
        {"timestamp": "2026-01-05T10:00:00-05:00", "open": 100.6, "high": 101.3, "low": 100.7, "close": 101.2, "volume": 1000},
        {"timestamp": "2026-01-05T15:55:00-05:00", "open": 101.2, "high": 102.2, "low": 101.1, "close": 102.0, "volume": 1000},
    ]

    enrichment = _enrichment_for("EARLY_LOSER", "LATE_WINNER")
    enrichment["SPY"] = {
        "2026-01-05": {
            "prev_close": 100.0,
            "today_open": 100.0,
        }
    }

    day_result = simulate_orb_day(
        {
            "EARLY_LOSER": early_loser,
            "LATE_WINNER": late_winner,
        },
        "2026-01-05",
        params,
        enrichment,
        equity=10_000.0,
    )

    assert day_result.regime_scaler == pytest.approx(0.5)
    assert [trade.ticker for trade in day_result.trades] == ["EARLY_LOSER", "LATE_WINNER"]


def test_sparse_day_scaler_reduces_orb_day_budget_without_skipping_trade() -> None:
    bars = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.8, "volume": 1000},
        {"timestamp": "2026-01-05T09:35:00-05:00", "open": 100.8, "high": 101.4, "low": 100.7, "close": 101.2, "volume": 1000},
        {"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.2, "high": 101.6, "low": 101.1, "close": 101.5, "volume": 1000},
        {"timestamp": "2026-01-05T09:45:00-05:00", "open": 101.5, "high": 101.8, "low": 101.4, "close": 101.7, "volume": 1000},
        {"timestamp": "2026-01-05T09:50:00-05:00", "open": 101.7, "high": 101.9, "low": 101.6, "close": 101.8, "volume": 1000},
        {"timestamp": "2026-01-05T15:55:00-05:00", "open": 101.5, "high": 102.0, "low": 101.4, "close": 101.9, "volume": 1000},
    ]
    baseline = ORBStrategyParams(
        orb_minutes=5,
        sim_bar_minutes=5,
        order_timeout_minutes=20,
        atr_stop_multiplier=1.0,
        trailing_at_r=99.0,
        breakeven_at_r=99.0,
        slippage_bps=0.0,
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        min_abs_gap_pct=None,
        max_candidates=5,
        min_candidates_to_trade=1,
        risk_per_trade_pct=0.1,
        max_position_pct=1.0,
        daily_max_loss_pct=1.0,
        max_stops_per_day=99,
    )
    scaled = baseline.model_copy(update={
        "full_size_positions_threshold": 2,
        "sparse_day_size_floor": 0.5,
    })
    enrichment = _enrichment_for("AAA")

    base_day = simulate_orb_day(
        {"AAA": bars},
        "2026-01-05",
        baseline,
        enrichment,
        equity=10_000.0,
    )
    scaled_day = simulate_orb_day(
        {"AAA": bars},
        "2026-01-05",
        scaled,
        enrichment,
        equity=10_000.0,
    )

    assert len(base_day.trades) == 1
    assert len(scaled_day.trades) == 1
    assert scaled_day.sparse_day_scaler == 0.5
    assert scaled_day.capital_deployed < base_day.capital_deployed
    assert scaled_day.trades[0].shares < base_day.trades[0].shares


def test_chunked_orb_simulation_matches_single_run_statefully() -> None:
    params = ORBStrategyParams(
        orb_minutes=5,
        sim_bar_minutes=5,
        order_timeout_minutes=20,
        atr_stop_multiplier=1.0,
        trailing_at_r=99.0,
        breakeven_at_r=99.0,
        slippage_bps=0.0,
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        max_candidates=20,
        min_candidates_to_trade=1,
        risk_per_trade_pct=0.01,
        max_position_pct=1.0,
        daily_max_loss_pct=1.0,
        max_stops_per_day=99,
        ticker_cooldown_days=1,
        settlement_days=1,
        compound_returns=False,
    )

    def bars(day: str) -> list[dict]:
        return [
            {"timestamp": f"{day}T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.8, "volume": 1000},
            {"timestamp": f"{day}T09:35:00-05:00", "open": 100.8, "high": 101.4, "low": 100.7, "close": 101.2, "volume": 1000},
            {"timestamp": f"{day}T09:40:00-05:00", "open": 101.2, "high": 101.6, "low": 101.1, "close": 101.5, "volume": 1000},
            {"timestamp": f"{day}T15:55:00-05:00", "open": 101.5, "high": 102.0, "low": 101.4, "close": 101.9, "volume": 1000},
        ]

    trading_days = ["2026-01-05", "2026-01-06", "2026-01-07"]
    all_intraday = {
        day: {"AAA": bars(day)}
        for day in trading_days
    }
    enrichment = {
        "AAA": {
            day: {
                "atr_14": 1.0,
                "avg_dollar_vol_30d": 1_000_000_000.0,
                "avg_daily_vol_14d": 10_000.0,
                "prev_close": 99.0,
                "today_open": 100.0,
            }
            for day in trading_days
        }
    }

    single = run_orb_simulation(all_intraday, trading_days, params, enrichment)
    chunk1, state = run_orb_simulation_with_state(
        {day: all_intraday[day] for day in trading_days[:2]},
        trading_days[:2],
        params,
        enrichment,
    )
    chunk2, _ = run_orb_simulation_with_state(
        {trading_days[2]: all_intraday[trading_days[2]]},
        trading_days[2:],
        params,
        enrichment,
        state=state,
    )
    combined = chunk1 + chunk2

    assert [len(day.trades) for day in combined] == [len(day.trades) for day in single]
    assert [round(day.daily_pnl, 6) for day in combined] == [round(day.daily_pnl, 6) for day in single]
    assert [trade.ticker for day in combined for trade in day.trades] == [
        trade.ticker for day in single for trade in day.trades
    ]


def test_chunked_orb_simulation_preserves_streak_sizing_state() -> None:
    params = ORBStrategyParams(
        orb_minutes=5,
        sim_bar_minutes=5,
        order_timeout_minutes=20,
        atr_stop_multiplier=1.0,
        trailing_at_r=99.0,
        breakeven_at_r=99.0,
        slippage_bps=0.0,
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        max_candidates=20,
        min_candidates_to_trade=1,
        risk_per_trade_pct=0.1,
        max_position_pct=1.0,
        daily_max_loss_pct=1.0,
        max_stops_per_day=99,
        settlement_days=0,
        daily_budget_reset=True,
        streak_sizing_win_bonus=1.0,
        streak_sizing_max=3.0,
        single_trade_loss_cap_pct=0.5,
    )

    def bars(day: str) -> list[dict]:
        return [
            {"timestamp": f"{day}T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.8, "volume": 1000},
            {"timestamp": f"{day}T09:35:00-05:00", "open": 100.8, "high": 101.4, "low": 100.7, "close": 101.2, "volume": 1000},
            {"timestamp": f"{day}T09:40:00-05:00", "open": 101.2, "high": 101.6, "low": 101.1, "close": 101.5, "volume": 1000},
            {"timestamp": f"{day}T15:55:00-05:00", "open": 101.5, "high": 103.0, "low": 101.4, "close": 102.8, "volume": 1000},
        ]

    trading_days = ["2026-01-05", "2026-01-06", "2026-01-07"]
    all_intraday = {day: {"AAA": bars(day)} for day in trading_days}
    enrichment = {
        "AAA": {
            day: {
                "atr_14": 1.0,
                "avg_dollar_vol_30d": 1_000_000_000.0,
                "avg_daily_vol_14d": 10_000.0,
                "prev_close": 99.0,
                "today_open": 100.0,
            }
            for day in trading_days
        }
    }

    single = run_orb_simulation(all_intraday, trading_days, params, enrichment)
    chunk1, state = run_orb_simulation_with_state(
        {day: all_intraday[day] for day in trading_days[:2]},
        trading_days[:2],
        params,
        enrichment,
    )
    chunk2, _ = run_orb_simulation_with_state(
        {trading_days[2]: all_intraday[trading_days[2]]},
        trading_days[2:],
        params,
        enrichment,
        state=state,
    )

    combined = chunk1 + chunk2

    assert round(combined[2].capital_deployed, 6) == round(single[2].capital_deployed, 6)
    assert round(combined[2].daily_pnl, 6) == round(single[2].daily_pnl, 6)


def test_daily_budget_reset_caps_buying_power_to_initial_capital() -> None:
    params = ORBStrategyParams(
        orb_minutes=5,
        sim_bar_minutes=5,
        order_timeout_minutes=20,
        atr_stop_multiplier=1.0,
        trailing_at_r=99.0,
        breakeven_at_r=99.0,
        slippage_bps=0.0,
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        min_abs_gap_pct=0.0,
        max_candidates=20,
        min_candidates_to_trade=1,
        risk_per_trade_pct=1.0,
        max_position_pct=1.0,
        daily_max_loss_pct=1.0,
        max_stops_per_day=99,
        settlement_days=1,
        daily_budget_reset=True,
        initial_capital=10_000.0,
        streak_sizing_win_bonus=1.0,
        streak_sizing_max=3.0,
    )

    def bars(day: str) -> list[dict]:
        return [
            {"timestamp": f"{day}T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.8, "volume": 1000},
            {"timestamp": f"{day}T09:35:00-05:00", "open": 100.8, "high": 102.0, "low": 100.7, "close": 101.8, "volume": 1000},
            {"timestamp": f"{day}T09:40:00-05:00", "open": 101.8, "high": 102.5, "low": 101.7, "close": 102.2, "volume": 1000},
            {"timestamp": f"{day}T09:45:00-05:00", "open": 102.2, "high": 103.0, "low": 102.1, "close": 102.8, "volume": 1000},
            {"timestamp": f"{day}T15:55:00-05:00", "open": 104.0, "high": 105.0, "low": 103.8, "close": 104.8, "volume": 1000},
        ]

    trading_days = ["2026-01-05", "2026-01-06", "2026-01-07"]
    all_intraday = {
        "2026-01-05": {"AAA": bars("2026-01-05")},
        "2026-01-06": {"AAA": bars("2026-01-06")},
        "2026-01-07": {
            "AAA": bars("2026-01-07"),
            "BBB": bars("2026-01-07"),
        },
    }
    enrichment = {
        ticker: {
            day: {
                "atr_14": 1.0,
                "avg_dollar_vol_30d": 1_000_000_000.0,
                "avg_daily_vol_14d": 10_000.0,
                "prev_close": 99.0,
                "today_open": 100.0,
            }
            for day in trading_days
        }
        for ticker in ("AAA", "BBB")
    }

    results = run_orb_simulation(all_intraday, trading_days, params, enrichment)

    assert all(day.available_cash_start == params.initial_capital for day in results)
    assert results[2].trades
    assert results[2].capital_deployed <= params.initial_capital
    assert results[2].daily_return_pct == pytest.approx(
        results[2].daily_pnl / params.initial_capital
    )


def test_single_trade_loss_cap_equity_basis_scales_with_compound_equity() -> None:
    bars = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.8, "volume": 1000},
        {"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.0, "high": 102.0, "low": 100.8, "close": 101.8, "volume": 1000},
        {"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.8, "high": 102.5, "low": 101.7, "close": 102.0, "volume": 1000},
        {"timestamp": "2026-01-05T09:45:00-05:00", "open": 102.0, "high": 102.4, "low": 101.9, "close": 102.2, "volume": 1000},
        {"timestamp": "2026-01-05T09:50:00-05:00", "open": 102.2, "high": 102.7, "low": 102.0, "close": 102.4, "volume": 1000},
        {"timestamp": "2026-01-05T15:55:00-05:00", "open": 102.0, "high": 103.0, "low": 101.9, "close": 102.8, "volume": 1000},
    ]
    all_intraday = {"2026-01-05": {"AAA": bars}}
    enrichment = _enrichment_for("AAA")
    base_kwargs = dict(
        orb_minutes=5,
        sim_bar_minutes=5,
        order_timeout_minutes=20,
        atr_stop_multiplier=1.0,
        trailing_at_r=99.0,
        breakeven_at_r=99.0,
        slippage_bps=0.0,
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        min_abs_gap_pct=None,
        max_candidates=5,
        min_candidates_to_trade=1,
        risk_per_trade_pct=0.1,
        max_position_pct=1.0,
        daily_max_loss_pct=1.0,
        max_stops_per_day=99,
        compound_returns=True,
        daily_budget_reset=False,
        settlement_days=0,
        single_trade_loss_cap_pct=0.05,
    )
    grown_state = ORBSimulationState(equity=20_000.0, peak_equity=20_000.0)

    initial_results, _ = run_orb_simulation_with_state(
        all_intraday,
        ["2026-01-05"],
        ORBStrategyParams(**base_kwargs, single_trade_loss_cap_basis="initial"),
        enrichment,
        state=grown_state,
    )
    equity_results, _ = run_orb_simulation_with_state(
        all_intraday,
        ["2026-01-05"],
        ORBStrategyParams(**base_kwargs, single_trade_loss_cap_basis="equity"),
        enrichment,
        state=grown_state,
    )

    assert equity_results[0].capital_deployed > initial_results[0].capital_deployed * 1.8


def test_chunked_orb_simulation_preserves_drawdown_governor_peak() -> None:
    params = ORBStrategyParams(
        orb_minutes=5,
        sim_bar_minutes=5,
        order_timeout_minutes=20,
        atr_stop_multiplier=1.0,
        trailing_at_r=99.0,
        breakeven_at_r=99.0,
        slippage_bps=0.0,
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        max_candidates=20,
        min_candidates_to_trade=1,
        risk_per_trade_pct=0.1,
        max_position_pct=1.0,
        daily_max_loss_pct=1.0,
        max_stops_per_day=99,
        settlement_days=0,
        daily_budget_reset=True,
        drawdown_governor_threshold=0.05,
        drawdown_governor_min_scale=0.5,
    )

    def winner_bars(day: str) -> list[dict]:
        return [
            {"timestamp": f"{day}T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.8, "volume": 1000},
            {"timestamp": f"{day}T09:35:00-05:00", "open": 100.8, "high": 101.4, "low": 100.7, "close": 101.2, "volume": 1000},
            {"timestamp": f"{day}T09:40:00-05:00", "open": 101.2, "high": 101.6, "low": 101.1, "close": 101.5, "volume": 1000},
            {"timestamp": f"{day}T15:55:00-05:00", "open": 101.5, "high": 103.0, "low": 101.4, "close": 102.8, "volume": 1000},
        ]

    def loser_bars(day: str) -> list[dict]:
        return [
            {"timestamp": f"{day}T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.8, "volume": 1000},
            {"timestamp": f"{day}T09:35:00-05:00", "open": 100.8, "high": 101.0, "low": 99.8, "close": 100.0, "volume": 1000},
            {"timestamp": f"{day}T09:40:00-05:00", "open": 100.0, "high": 100.1, "low": 98.8, "close": 99.0, "volume": 1000},
            {"timestamp": f"{day}T15:55:00-05:00", "open": 99.0, "high": 99.2, "low": 98.9, "close": 99.1, "volume": 1000},
        ]

    trading_days = ["2026-01-05", "2026-01-06", "2026-01-07"]
    all_intraday = {
        "2026-01-05": {"AAA": winner_bars("2026-01-05")},
        "2026-01-06": {"AAA": loser_bars("2026-01-06")},
        "2026-01-07": {"AAA": winner_bars("2026-01-07")},
    }
    enrichment = {
        "AAA": {
            day: {
                "atr_14": 1.0,
                "avg_dollar_vol_30d": 1_000_000_000.0,
                "avg_daily_vol_14d": 10_000.0,
                "prev_close": 99.0,
                "today_open": 100.0,
            }
            for day in trading_days
        }
    }

    single = run_orb_simulation(all_intraday, trading_days, params, enrichment)
    chunk1, state = run_orb_simulation_with_state(
        {day: all_intraday[day] for day in trading_days[:2]},
        trading_days[:2],
        params,
        enrichment,
    )
    chunk2, _ = run_orb_simulation_with_state(
        {trading_days[2]: all_intraday[trading_days[2]]},
        trading_days[2:],
        params,
        enrichment,
        state=state,
    )

    combined = chunk1 + chunk2

    assert round(combined[2].capital_deployed, 6) == round(single[2].capital_deployed, 6)
    assert round(combined[2].daily_pnl, 6) == round(single[2].daily_pnl, 6)


def test_market_orb_quality_scaler_adjusts_day_sizing() -> None:
    params = ORBStrategyParams(
        engine_family="gainers_leader",
        orb_minutes=5,
        sim_bar_minutes=5,
        order_timeout_minutes=20,
        atr_stop_multiplier=1.0,
        trailing_at_r=99.0,
        breakeven_at_r=99.0,
        slippage_bps=0.0,
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        min_abs_gap_pct=0.0,
        min_premarket_dollar_vol=0.0,
        max_gap_pct=None,
        max_candidates=20,
        min_candidates_to_trade=1,
        risk_per_trade_pct=0.1,
        max_position_pct=1.0,
        daily_max_loss_pct=1.0,
        max_stops_per_day=99,
        daily_budget_reset=True,
        market_orb_quality_ticker="SPY",
        market_orb_quality_size_scale_low=0.4,
        market_orb_quality_size_scale_high=0.6,
        market_orb_quality_size_scale_min=0.8,
        market_orb_quality_size_scale_max=1.2,
        allow_doji_breakout=True,
    )

    aaa_bars = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.8, "close": 100.9, "volume": 2000},
        {"timestamp": "2026-01-05T09:35:00-05:00", "open": 100.9, "high": 101.4, "low": 100.8, "close": 101.3, "volume": 2000},
        {"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.3, "high": 101.5, "low": 101.1, "close": 101.4, "volume": 2000},
        {"timestamp": "2026-01-05T09:45:00-05:00", "open": 101.4, "high": 101.6, "low": 101.2, "close": 101.5, "volume": 2000},
        {"timestamp": "2026-01-05T15:55:00-05:00", "open": 101.5, "high": 103.0, "low": 101.4, "close": 102.8, "volume": 2000},
    ]
    strong_spy = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 500.0, "high": 501.0, "low": 499.5, "close": 500.9, "volume": 10000},
    ]
    weak_spy = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 500.0, "high": 501.0, "low": 499.5, "close": 499.8, "volume": 10000},
    ]
    enrichment = _enrichment_for("AAA")

    strong = simulate_orb_day(
        {"AAA": aaa_bars, "SPY": strong_spy},
        "2026-01-05",
        params,
        enrichment,
        equity=10_000.0,
    )
    weak = simulate_orb_day(
        {"AAA": aaa_bars, "SPY": weak_spy},
        "2026-01-05",
        params,
        enrichment,
        equity=10_000.0,
    )

    assert strong.market_orb_quality_scaler == pytest.approx(1.2)
    assert weak.market_orb_quality_scaler == pytest.approx(0.8)
    assert strong.market_orb_quality_close_location == pytest.approx((500.9 - 499.5) / (501.0 - 499.5))
    assert weak.market_orb_quality_close_location == pytest.approx((499.8 - 499.5) / (501.0 - 499.5))
    assert strong.capital_deployed > weak.capital_deployed
    assert strong.daily_pnl > weak.daily_pnl


def test_conditional_confirmation_activates_only_on_red_market_open() -> None:
    params = ORBStrategyParams(
        engine_family="gainers_leader",
        orb_minutes=5,
        sim_bar_minutes=5,
        order_timeout_minutes=20,
        atr_stop_multiplier=1.0,
        trailing_at_r=99.0,
        breakeven_at_r=99.0,
        slippage_bps=0.0,
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        min_abs_gap_pct=0.0,
        min_premarket_dollar_vol=0.0,
        max_gap_pct=None,
        max_candidates=20,
        min_candidates_to_trade=1,
        risk_per_trade_pct=0.1,
        max_position_pct=1.0,
        daily_max_loss_pct=1.0,
        max_stops_per_day=99,
        daily_budget_reset=True,
        allow_doji_breakout=True,
        conditional_confirmation_ticker="QQQ",
        conditional_confirmation_below_return_pct=0.0,
    )

    aaa_bars = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.8, "volume": 2000},
        {"timestamp": "2026-01-05T09:35:00-05:00", "open": 100.8, "high": 101.2, "low": 100.7, "close": 101.1, "volume": 2000},
        {"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.1, "high": 101.2, "low": 100.7, "close": 100.9, "volume": 2000},
        {"timestamp": "2026-01-05T09:45:00-05:00", "open": 100.9, "high": 101.0, "low": 100.8, "close": 100.95, "volume": 2000},
        {"timestamp": "2026-01-05T09:50:00-05:00", "open": 100.95, "high": 101.0, "low": 100.85, "close": 100.9, "volume": 2000},
        {"timestamp": "2026-01-05T15:55:00-05:00", "open": 100.9, "high": 101.0, "low": 100.8, "close": 100.9, "volume": 2000},
    ]
    green_qqq = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 500.0, "high": 501.0, "low": 499.5, "close": 500.8, "volume": 10000},
    ]
    red_qqq = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 500.0, "high": 501.0, "low": 499.5, "close": 499.8, "volume": 10000},
    ]
    enrichment = _enrichment_for("AAA")

    green = simulate_orb_day(
        {"AAA": aaa_bars, "QQQ": green_qqq},
        "2026-01-05",
        params,
        enrichment,
        equity=10_000.0,
    )
    red = simulate_orb_day(
        {"AAA": aaa_bars, "QQQ": red_qqq},
        "2026-01-05",
        params,
        enrichment,
        equity=10_000.0,
    )

    assert green.conditional_confirmation_active is False
    assert len(green.trades) == 1
    assert red.conditional_confirmation_active is True
    assert red.trades == []


def test_market_orb_quality_divergence_guard_reduces_day_sizing() -> None:
    params = ORBStrategyParams(
        engine_family="gainers_leader",
        orb_minutes=5,
        sim_bar_minutes=5,
        order_timeout_minutes=20,
        atr_stop_multiplier=1.0,
        trailing_at_r=99.0,
        breakeven_at_r=99.0,
        slippage_bps=0.0,
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        min_abs_gap_pct=0.0,
        min_premarket_dollar_vol=0.0,
        max_gap_pct=None,
        max_candidates=20,
        min_candidates_to_trade=1,
        risk_per_trade_pct=0.1,
        max_position_pct=1.0,
        daily_max_loss_pct=1.0,
        max_stops_per_day=99,
        daily_budget_reset=True,
        allow_doji_breakout=True,
        market_orb_quality_ticker="SPY",
        market_orb_quality_secondary_ticker="QQQ",
        market_orb_quality_primary_strong_above=0.75,
        market_orb_quality_secondary_weak_below=0.5,
        market_orb_quality_divergence_scale=0.5,
    )

    aaa_bars = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.8, "volume": 2000},
        {"timestamp": "2026-01-05T09:35:00-05:00", "open": 100.8, "high": 101.4, "low": 100.7, "close": 101.2, "volume": 2000},
        {"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.2, "high": 101.6, "low": 101.1, "close": 101.5, "volume": 2000},
        {"timestamp": "2026-01-05T09:45:00-05:00", "open": 101.5, "high": 101.8, "low": 101.4, "close": 101.7, "volume": 2000},
        {"timestamp": "2026-01-05T09:50:00-05:00", "open": 101.7, "high": 101.9, "low": 101.6, "close": 101.8, "volume": 2000},
        {"timestamp": "2026-01-05T15:55:00-05:00", "open": 101.5, "high": 103.0, "low": 101.4, "close": 102.8, "volume": 2000},
    ]
    strong_spy = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 500.0, "high": 501.0, "low": 499.5, "close": 500.9, "volume": 10000},
    ]
    weak_qqq = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 400.0, "high": 400.8, "low": 399.9, "close": 400.1, "volume": 10000},
    ]
    strong_qqq = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 400.0, "high": 401.0, "low": 399.8, "close": 400.9, "volume": 10000},
    ]
    enrichment = _enrichment_for("AAA")

    aligned = simulate_orb_day(
        {"AAA": aaa_bars, "SPY": strong_spy, "QQQ": strong_qqq},
        "2026-01-05",
        params,
        enrichment,
        equity=10_000.0,
    )
    diverged = simulate_orb_day(
        {"AAA": aaa_bars, "SPY": strong_spy, "QQQ": weak_qqq},
        "2026-01-05",
        params,
        enrichment,
        equity=10_000.0,
    )

    assert aligned.market_orb_quality_divergence_active is False
    assert diverged.market_orb_quality_divergence_active is True
    assert diverged.market_orb_quality_scaler == pytest.approx(0.5)
    assert diverged.capital_deployed < aligned.capital_deployed


def test_market_orb_quality_veto_can_feed_rolling_loss_governor() -> None:
    params = ORBStrategyParams(
        engine_family="gainers_leader",
        orb_minutes=5,
        sim_bar_minutes=5,
        order_timeout_minutes=20,
        atr_stop_multiplier=1.0,
        trailing_at_r=99.0,
        breakeven_at_r=99.0,
        slippage_bps=0.0,
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        min_abs_gap_pct=0.0,
        min_premarket_dollar_vol=0.0,
        max_gap_pct=None,
        max_candidates=20,
        min_candidates_to_trade=1,
        risk_per_trade_pct=0.1,
        max_position_pct=1.0,
        daily_max_loss_pct=1.0,
        max_stops_per_day=99,
        daily_budget_reset=True,
        rolling_loss_days=1,
        rolling_loss_threshold=-0.01,
        market_orb_quality_ticker="SPY",
        market_orb_quality_secondary_ticker="QQQ",
        market_orb_quality_primary_strong_above=0.75,
        market_orb_quality_secondary_weak_below=0.50,
        market_orb_quality_divergence_scale=0.0,
        market_orb_quality_veto_rolling_loss_pct=-0.05,
    )

    bars = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.8, "volume": 2000},
        {"timestamp": "2026-01-05T09:35:00-05:00", "open": 100.8, "high": 101.2, "low": 100.7, "close": 101.1, "volume": 2000},
        {"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.1, "high": 101.5, "low": 101.0, "close": 101.4, "volume": 2000},
        {"timestamp": "2026-01-05T09:45:00-05:00", "open": 101.4, "high": 101.6, "low": 101.2, "close": 101.5, "volume": 2000},
        {"timestamp": "2026-01-05T09:50:00-05:00", "open": 101.5, "high": 101.8, "low": 101.4, "close": 101.7, "volume": 2000},
        {"timestamp": "2026-01-05T15:55:00-05:00", "open": 101.7, "high": 102.0, "low": 101.2, "close": 101.8, "volume": 2000},
    ]
    day2_bars = [
        {**bar, "timestamp": bar["timestamp"].replace("2026-01-05", "2026-01-06")}
        for bar in bars
    ]
    strong_spy = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 500.0, "high": 501.0, "low": 499.5, "close": 500.9, "volume": 10000},
    ]
    weak_qqq = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 400.0, "high": 401.0, "low": 399.8, "close": 400.2, "volume": 10000},
    ]
    calm_spy_day2 = [
        {"timestamp": "2026-01-06T09:30:00-05:00", "open": 500.0, "high": 501.0, "low": 499.5, "close": 500.6, "volume": 10000},
    ]
    calm_qqq_day2 = [
        {"timestamp": "2026-01-06T09:30:00-05:00", "open": 400.0, "high": 401.0, "low": 399.8, "close": 400.6, "volume": 10000},
    ]
    enrichment = {
        "AAA": {
            day: {
                "atr_14": 1.0,
                "avg_dollar_vol_30d": 1_000_000_000.0,
                "avg_daily_vol_14d": 10_000.0,
                "prev_close": 99.0,
                "today_open": 100.0,
            }
            for day in ("2026-01-05", "2026-01-06")
        }
    }

    results = run_orb_simulation(
        {
            "2026-01-05": {"AAA": bars, "SPY": strong_spy, "QQQ": weak_qqq},
            "2026-01-06": {"AAA": day2_bars, "SPY": calm_spy_day2, "QQQ": calm_qqq_day2},
        },
        ["2026-01-05", "2026-01-06"],
        params,
        enrichment,
    )

    assert results[0].daily_pnl == 0.0
    assert results[0].rolling_loss_synthetic_pnl == pytest.approx(-500.0)
    assert results[1].skip_reason == "rolling_loss"


def test_market_orb_quality_divergence_guard_can_cap_total_trades() -> None:
    params = ORBStrategyParams(
        engine_family="gainers_leader",
        orb_minutes=5,
        sim_bar_minutes=5,
        order_timeout_minutes=20,
        atr_stop_multiplier=1.0,
        trailing_at_r=99.0,
        breakeven_at_r=99.0,
        slippage_bps=0.0,
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        min_abs_gap_pct=0.0,
        min_premarket_dollar_vol=0.0,
        max_gap_pct=None,
        max_candidates=20,
        min_candidates_to_trade=1,
        risk_per_trade_pct=0.1,
        max_position_pct=1.0,
        daily_max_loss_pct=1.0,
        max_stops_per_day=99,
        daily_budget_reset=True,
        allow_doji_breakout=True,
        market_orb_quality_ticker="SPY",
        market_orb_quality_secondary_ticker="QQQ",
        market_orb_quality_primary_strong_above=0.75,
        market_orb_quality_secondary_weak_below=0.5,
        market_orb_quality_divergence_max_trades=1,
    )

    bars = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.8, "volume": 2000},
        {"timestamp": "2026-01-05T09:35:00-05:00", "open": 100.8, "high": 101.4, "low": 100.7, "close": 101.2, "volume": 2000},
        {"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.2, "high": 101.6, "low": 101.1, "close": 101.5, "volume": 2000},
        {"timestamp": "2026-01-05T09:45:00-05:00", "open": 101.5, "high": 101.8, "low": 101.4, "close": 101.7, "volume": 2000},
        {"timestamp": "2026-01-05T09:50:00-05:00", "open": 101.7, "high": 101.9, "low": 101.6, "close": 101.8, "volume": 2000},
        {"timestamp": "2026-01-05T15:55:00-05:00", "open": 101.8, "high": 102.5, "low": 101.7, "close": 102.2, "volume": 2000},
    ]
    strong_spy = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 500.0, "high": 501.0, "low": 499.5, "close": 500.9, "volume": 10000},
    ]
    weak_qqq = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 400.0, "high": 400.8, "low": 399.9, "close": 400.1, "volume": 10000},
    ]
    strong_qqq = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 400.0, "high": 401.0, "low": 399.8, "close": 400.9, "volume": 10000},
    ]
    enrichment = _enrichment_for("AAA", "BBB")

    aligned = simulate_orb_day(
        {"AAA": bars, "BBB": bars, "SPY": strong_spy, "QQQ": strong_qqq},
        "2026-01-05",
        params,
        enrichment,
        equity=10_000.0,
    )
    diverged = simulate_orb_day(
        {"AAA": bars, "BBB": bars, "SPY": strong_spy, "QQQ": weak_qqq},
        "2026-01-05",
        params,
        enrichment,
        equity=10_000.0,
    )

    assert aligned.market_orb_quality_divergence_max_trades_active is False
    assert len(aligned.trades) == 2
    assert diverged.market_orb_quality_divergence_max_trades_active is True
    assert len(diverged.trades) == 1


def test_market_orb_quality_primary_weak_secondary_strong_guard_can_veto_day() -> None:
    params = ORBStrategyParams(
        engine_family="gainers_leader",
        orb_minutes=5,
        sim_bar_minutes=5,
        order_timeout_minutes=20,
        atr_stop_multiplier=1.0,
        trailing_at_r=99.0,
        breakeven_at_r=99.0,
        slippage_bps=0.0,
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        min_abs_gap_pct=0.0,
        min_premarket_dollar_vol=0.0,
        max_gap_pct=None,
        max_candidates=20,
        min_candidates_to_trade=1,
        risk_per_trade_pct=0.1,
        max_position_pct=1.0,
        daily_max_loss_pct=1.0,
        max_stops_per_day=99,
        daily_budget_reset=True,
        allow_doji_breakout=True,
        market_orb_quality_ticker="SPY",
        market_orb_quality_secondary_ticker="QQQ",
        market_orb_quality_primary_weak_above=0.20,
        market_orb_quality_primary_weak_below=0.30,
        market_orb_quality_secondary_strong_above=0.60,
        market_orb_quality_primary_weak_secondary_strong_scale=0.0,
    )

    bars = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.8, "volume": 2000},
        {"timestamp": "2026-01-05T09:35:00-05:00", "open": 100.8, "high": 101.4, "low": 100.7, "close": 101.2, "volume": 2000},
        {"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.2, "high": 101.6, "low": 101.1, "close": 101.5, "volume": 2000},
        {"timestamp": "2026-01-05T09:45:00-05:00", "open": 101.5, "high": 101.8, "low": 101.4, "close": 101.7, "volume": 2000},
        {"timestamp": "2026-01-05T09:50:00-05:00", "open": 101.7, "high": 101.9, "low": 101.6, "close": 101.8, "volume": 2000},
        {"timestamp": "2026-01-05T15:55:00-05:00", "open": 101.5, "high": 103.0, "low": 101.4, "close": 102.8, "volume": 2000},
    ]
    neutral_spy = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 500.0, "high": 501.0, "low": 499.0, "close": 500.6, "volume": 10000},
    ]
    weak_spy = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 500.0, "high": 501.0, "low": 499.0, "close": 499.5, "volume": 10000},
    ]
    strong_qqq = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 400.0, "high": 401.0, "low": 399.0, "close": 400.4, "volume": 10000},
    ]
    enrichment = _enrichment_for("AAA")

    aligned = simulate_orb_day(
        {"AAA": bars, "SPY": neutral_spy, "QQQ": strong_qqq},
        "2026-01-05",
        params,
        enrichment,
        equity=10_000.0,
    )
    split_tape = simulate_orb_day(
        {"AAA": bars, "SPY": weak_spy, "QQQ": strong_qqq},
        "2026-01-05",
        params,
        enrichment,
        equity=10_000.0,
    )

    assert aligned.market_orb_quality_primary_weak_secondary_strong_active is False
    assert len(aligned.trades) == 1
    assert split_tape.market_orb_quality_primary_weak_secondary_strong_active is True
    assert split_tape.market_orb_quality_scaler == pytest.approx(0.0)
    assert split_tape.trades == []


def test_market_orb_quality_primary_lag_secondary_lead_guard_can_veto_day() -> None:
    params = ORBStrategyParams(
        engine_family="gainers_leader",
        orb_minutes=5,
        sim_bar_minutes=5,
        order_timeout_minutes=20,
        atr_stop_multiplier=1.0,
        trailing_at_r=99.0,
        breakeven_at_r=99.0,
        slippage_bps=0.0,
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        min_abs_gap_pct=0.0,
        min_premarket_dollar_vol=0.0,
        max_gap_pct=None,
        max_candidates=20,
        min_candidates_to_trade=1,
        risk_per_trade_pct=0.1,
        max_position_pct=1.0,
        daily_max_loss_pct=1.0,
        max_stops_per_day=99,
        daily_budget_reset=True,
        allow_doji_breakout=True,
        market_orb_quality_ticker="SPY",
        market_orb_quality_secondary_ticker="QQQ",
        market_orb_quality_primary_lag_above=0.40,
        market_orb_quality_primary_lag_below=0.50,
        market_orb_quality_secondary_lead_above=0.70,
        market_orb_quality_secondary_lead_below=0.80,
        market_orb_quality_primary_lag_secondary_lead_scale=0.0,
    )

    bars = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.8, "volume": 2000},
        {"timestamp": "2026-01-05T09:35:00-05:00", "open": 100.8, "high": 101.2, "low": 100.7, "close": 101.1, "volume": 2000},
        {"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.1, "high": 101.5, "low": 101.0, "close": 101.4, "volume": 2000},
        {"timestamp": "2026-01-05T09:45:00-05:00", "open": 101.4, "high": 101.6, "low": 101.2, "close": 101.5, "volume": 2000},
        {"timestamp": "2026-01-05T09:50:00-05:00", "open": 101.5, "high": 101.8, "low": 101.4, "close": 101.7, "volume": 2000},
        {"timestamp": "2026-01-05T15:55:00-05:00", "open": 101.7, "high": 102.0, "low": 101.2, "close": 101.8, "volume": 2000},
    ]
    aligned_spy = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 500.0, "high": 501.0, "low": 499.5, "close": 500.8, "volume": 10000},
    ]
    aligned_qqq = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 400.0, "high": 401.0, "low": 399.8, "close": 400.9, "volume": 10000},
    ]
    lag_spy = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 500.0, "high": 501.0, "low": 499.0, "close": 499.9, "volume": 10000},
    ]
    lead_qqq = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 400.0, "high": 401.0, "low": 399.0, "close": 400.5, "volume": 10000},
    ]
    enrichment = _enrichment_for("AAA", "BBB")

    aligned = simulate_orb_day(
        {"AAA": bars, "BBB": bars, "SPY": aligned_spy, "QQQ": aligned_qqq},
        "2026-01-05",
        params,
        enrichment,
        equity=10_000.0,
    )
    split_tape = simulate_orb_day(
        {"AAA": bars, "BBB": bars, "SPY": lag_spy, "QQQ": lead_qqq},
        "2026-01-05",
        params,
        enrichment,
        equity=10_000.0,
    )

    assert aligned.market_orb_quality_primary_lag_secondary_lead_active is False
    assert len(aligned.trades) == 2
    assert split_tape.market_orb_quality_primary_lag_secondary_lead_active is True
    assert split_tape.market_orb_quality_scaler == pytest.approx(0.0)
    assert split_tape.trades == []


def test_market_orb_quality_divergence_guard_can_target_mild_split_tape() -> None:
    params = ORBStrategyParams(
        engine_family="gainers_leader",
        orb_minutes=5,
        sim_bar_minutes=5,
        order_timeout_minutes=20,
        atr_stop_multiplier=1.0,
        trailing_at_r=99.0,
        breakeven_at_r=99.0,
        slippage_bps=0.0,
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        min_abs_gap_pct=0.0,
        min_premarket_dollar_vol=0.0,
        max_gap_pct=None,
        max_candidates=20,
        min_candidates_to_trade=1,
        risk_per_trade_pct=0.1,
        max_position_pct=1.0,
        daily_max_loss_pct=1.0,
        max_stops_per_day=99,
        daily_budget_reset=True,
        allow_doji_breakout=True,
        market_orb_quality_ticker="SPY",
        market_orb_quality_secondary_ticker="QQQ",
        market_orb_quality_primary_strong_above=0.75,
        market_orb_quality_secondary_weak_above=0.4,
        market_orb_quality_secondary_weak_below=0.5,
        market_orb_quality_divergence_scale=0.5,
    )

    aaa_bars = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.8, "volume": 2000},
        {"timestamp": "2026-01-05T09:35:00-05:00", "open": 100.8, "high": 101.4, "low": 100.7, "close": 101.2, "volume": 2000},
        {"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.2, "high": 101.6, "low": 101.1, "close": 101.5, "volume": 2000},
        {"timestamp": "2026-01-05T09:45:00-05:00", "open": 101.5, "high": 101.8, "low": 101.4, "close": 101.7, "volume": 2000},
        {"timestamp": "2026-01-05T09:50:00-05:00", "open": 101.7, "high": 101.9, "low": 101.6, "close": 101.8, "volume": 2000},
        {"timestamp": "2026-01-05T15:55:00-05:00", "open": 101.5, "high": 103.0, "low": 101.4, "close": 102.8, "volume": 2000},
    ]
    strong_spy = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 500.0, "high": 501.0, "low": 499.5, "close": 500.9, "volume": 10000},
    ]
    mild_weak_qqq = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 400.0, "high": 400.9, "low": 400.0, "close": 400.41, "volume": 10000},
    ]
    deep_weak_qqq = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 400.0, "high": 400.8, "low": 399.9, "close": 400.1, "volume": 10000},
    ]
    enrichment = _enrichment_for("AAA")

    mild = simulate_orb_day(
        {"AAA": aaa_bars, "SPY": strong_spy, "QQQ": mild_weak_qqq},
        "2026-01-05",
        params,
        enrichment,
        equity=10_000.0,
    )
    deep = simulate_orb_day(
        {"AAA": aaa_bars, "SPY": strong_spy, "QQQ": deep_weak_qqq},
        "2026-01-05",
        params,
        enrichment,
        equity=10_000.0,
    )

    assert mild.market_orb_quality_divergence_active is True
    assert deep.market_orb_quality_divergence_active is False


def test_market_orb_quality_joint_weak_guard_can_require_confirmation() -> None:
    params = ORBStrategyParams(
        engine_family="gainers_leader",
        orb_minutes=5,
        sim_bar_minutes=5,
        order_timeout_minutes=20,
        atr_stop_multiplier=1.0,
        trailing_at_r=99.0,
        breakeven_at_r=99.0,
        slippage_bps=0.0,
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        min_abs_gap_pct=0.0,
        min_premarket_dollar_vol=0.0,
        max_gap_pct=None,
        max_candidates=20,
        min_candidates_to_trade=1,
        risk_per_trade_pct=0.1,
        max_position_pct=1.0,
        daily_max_loss_pct=1.0,
        max_stops_per_day=99,
        daily_budget_reset=True,
        allow_doji_breakout=True,
        market_orb_quality_ticker="SPY",
        market_orb_quality_secondary_ticker="QQQ",
        market_orb_quality_joint_weak_primary_below=0.2,
        market_orb_quality_joint_weak_secondary_below=0.2,
        market_orb_quality_joint_weak_require_confirmation=True,
    )

    aaa_bars = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.8, "volume": 2000},
        {"timestamp": "2026-01-05T09:35:00-05:00", "open": 100.8, "high": 101.2, "low": 100.7, "close": 101.1, "volume": 2000},
        {"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.1, "high": 101.2, "low": 100.7, "close": 100.9, "volume": 2000},
        {"timestamp": "2026-01-05T09:45:00-05:00", "open": 100.9, "high": 101.0, "low": 100.8, "close": 100.95, "volume": 2000},
        {"timestamp": "2026-01-05T09:50:00-05:00", "open": 100.95, "high": 101.0, "low": 100.85, "close": 100.9, "volume": 2000},
        {"timestamp": "2026-01-05T15:55:00-05:00", "open": 100.9, "high": 101.0, "low": 100.8, "close": 100.9, "volume": 2000},
    ]
    strong_spy = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 500.0, "high": 501.0, "low": 499.5, "close": 500.8, "volume": 10000},
    ]
    strong_qqq = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 400.0, "high": 401.0, "low": 399.8, "close": 400.9, "volume": 10000},
    ]
    weak_spy = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 500.0, "high": 501.0, "low": 499.5, "close": 499.6, "volume": 10000},
    ]
    weak_qqq = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 400.0, "high": 400.8, "low": 399.9, "close": 399.95, "volume": 10000},
    ]
    enrichment = _enrichment_for("AAA")

    aligned = simulate_orb_day(
        {"AAA": aaa_bars, "SPY": strong_spy, "QQQ": strong_qqq},
        "2026-01-05",
        params,
        enrichment,
        equity=10_000.0,
    )
    joint_weak = simulate_orb_day(
        {"AAA": aaa_bars, "SPY": weak_spy, "QQQ": weak_qqq},
        "2026-01-05",
        params,
        enrichment,
        equity=10_000.0,
    )

    assert aligned.market_orb_quality_joint_weak_active is False
    assert aligned.conditional_confirmation_active is False
    assert len(aligned.trades) == 1
    assert joint_weak.market_orb_quality_joint_weak_active is True
    assert joint_weak.conditional_confirmation_active is True
    assert joint_weak.trades == []


def test_market_orb_quality_joint_weak_guard_can_reduce_day_sizing() -> None:
    params = ORBStrategyParams(
        engine_family="gainers_leader",
        orb_minutes=5,
        sim_bar_minutes=5,
        order_timeout_minutes=20,
        atr_stop_multiplier=1.0,
        trailing_at_r=99.0,
        breakeven_at_r=99.0,
        slippage_bps=0.0,
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        min_abs_gap_pct=0.0,
        min_premarket_dollar_vol=0.0,
        max_gap_pct=None,
        max_candidates=20,
        min_candidates_to_trade=1,
        risk_per_trade_pct=0.1,
        max_position_pct=1.0,
        daily_max_loss_pct=1.0,
        max_stops_per_day=99,
        daily_budget_reset=True,
        allow_doji_breakout=True,
        market_orb_quality_ticker="SPY",
        market_orb_quality_secondary_ticker="QQQ",
        market_orb_quality_joint_weak_primary_below=0.2,
        market_orb_quality_joint_weak_secondary_below=0.2,
        market_orb_quality_joint_weak_scale=0.5,
    )

    aaa_bars = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.8, "volume": 2000},
        {"timestamp": "2026-01-05T09:35:00-05:00", "open": 100.8, "high": 101.4, "low": 100.7, "close": 101.2, "volume": 2000},
        {"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.2, "high": 101.6, "low": 101.1, "close": 101.5, "volume": 2000},
        {"timestamp": "2026-01-05T09:45:00-05:00", "open": 101.5, "high": 101.8, "low": 101.4, "close": 101.7, "volume": 2000},
        {"timestamp": "2026-01-05T09:50:00-05:00", "open": 101.7, "high": 101.9, "low": 101.6, "close": 101.8, "volume": 2000},
        {"timestamp": "2026-01-05T15:55:00-05:00", "open": 101.5, "high": 103.0, "low": 101.4, "close": 102.8, "volume": 2000},
    ]
    strong_spy = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 500.0, "high": 501.0, "low": 499.5, "close": 500.8, "volume": 10000},
    ]
    strong_qqq = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 400.0, "high": 401.0, "low": 399.8, "close": 400.9, "volume": 10000},
    ]
    weak_spy = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 500.0, "high": 501.0, "low": 499.5, "close": 499.6, "volume": 10000},
    ]
    weak_qqq = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 400.0, "high": 400.8, "low": 399.9, "close": 399.95, "volume": 10000},
    ]
    enrichment = _enrichment_for("AAA")

    aligned = simulate_orb_day(
        {"AAA": aaa_bars, "SPY": strong_spy, "QQQ": strong_qqq},
        "2026-01-05",
        params,
        enrichment,
        equity=10_000.0,
    )
    joint_weak = simulate_orb_day(
        {"AAA": aaa_bars, "SPY": weak_spy, "QQQ": weak_qqq},
        "2026-01-05",
        params,
        enrichment,
        equity=10_000.0,
    )

    assert aligned.market_orb_quality_joint_weak_active is False
    assert joint_weak.market_orb_quality_joint_weak_active is True
    assert joint_weak.market_orb_quality_scaler == pytest.approx(0.5)
    assert joint_weak.capital_deployed < aligned.capital_deployed


def test_market_orb_quality_joint_weak_guard_can_cap_total_trades() -> None:
    params = ORBStrategyParams(
        engine_family="gainers_leader",
        orb_minutes=5,
        sim_bar_minutes=5,
        order_timeout_minutes=20,
        atr_stop_multiplier=1.0,
        trailing_at_r=99.0,
        breakeven_at_r=99.0,
        slippage_bps=0.0,
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        min_abs_gap_pct=0.0,
        min_premarket_dollar_vol=0.0,
        max_gap_pct=None,
        max_candidates=20,
        min_candidates_to_trade=1,
        risk_per_trade_pct=0.1,
        max_position_pct=1.0,
        daily_max_loss_pct=1.0,
        max_stops_per_day=99,
        daily_budget_reset=True,
        allow_doji_breakout=True,
        market_orb_quality_ticker="SPY",
        market_orb_quality_secondary_ticker="QQQ",
        market_orb_quality_joint_weak_primary_below=0.2,
        market_orb_quality_joint_weak_secondary_below=0.2,
        market_orb_quality_joint_weak_max_trades=1,
    )

    bars = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.8, "volume": 2000},
        {"timestamp": "2026-01-05T09:35:00-05:00", "open": 100.8, "high": 101.4, "low": 100.7, "close": 101.2, "volume": 2000},
        {"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.2, "high": 101.6, "low": 101.1, "close": 101.5, "volume": 2000},
        {"timestamp": "2026-01-05T09:45:00-05:00", "open": 101.5, "high": 101.8, "low": 101.4, "close": 101.7, "volume": 2000},
        {"timestamp": "2026-01-05T09:50:00-05:00", "open": 101.7, "high": 101.9, "low": 101.6, "close": 101.8, "volume": 2000},
        {"timestamp": "2026-01-05T15:55:00-05:00", "open": 101.8, "high": 102.5, "low": 101.7, "close": 102.2, "volume": 2000},
    ]
    strong_spy = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 500.0, "high": 501.0, "low": 499.5, "close": 500.8, "volume": 10000},
    ]
    strong_qqq = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 400.0, "high": 401.0, "low": 399.8, "close": 400.9, "volume": 10000},
    ]
    weak_spy = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 500.0, "high": 501.0, "low": 499.5, "close": 499.6, "volume": 10000},
    ]
    weak_qqq = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 400.0, "high": 400.8, "low": 399.9, "close": 399.95, "volume": 10000},
    ]
    enrichment = _enrichment_for("AAA", "BBB")

    aligned = simulate_orb_day(
        {"AAA": bars, "BBB": bars, "SPY": strong_spy, "QQQ": strong_qqq},
        "2026-01-05",
        params,
        enrichment,
        equity=10_000.0,
    )
    joint_weak = simulate_orb_day(
        {"AAA": bars, "BBB": bars, "SPY": weak_spy, "QQQ": weak_qqq},
        "2026-01-05",
        params,
        enrichment,
        equity=10_000.0,
    )

    assert aligned.market_orb_quality_joint_weak_max_trades_active is False
    assert len(aligned.trades) == 2
    assert joint_weak.market_orb_quality_joint_weak_active is True
    assert joint_weak.market_orb_quality_joint_weak_max_trades_active is True
    assert len(joint_weak.trades) == 1


def test_market_orb_quality_joint_weak_guard_can_target_mild_weak_band() -> None:
    params = ORBStrategyParams(
        engine_family="gainers_leader",
        orb_minutes=5,
        sim_bar_minutes=5,
        order_timeout_minutes=20,
        atr_stop_multiplier=1.0,
        trailing_at_r=99.0,
        breakeven_at_r=99.0,
        slippage_bps=0.0,
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        min_abs_gap_pct=0.0,
        min_premarket_dollar_vol=0.0,
        max_gap_pct=None,
        max_candidates=20,
        min_candidates_to_trade=1,
        risk_per_trade_pct=0.1,
        max_position_pct=1.0,
        daily_max_loss_pct=1.0,
        max_stops_per_day=99,
        daily_budget_reset=True,
        allow_doji_breakout=True,
        market_orb_quality_ticker="SPY",
        market_orb_quality_secondary_ticker="QQQ",
        market_orb_quality_joint_weak_primary_above=0.05,
        market_orb_quality_joint_weak_primary_below=0.15,
        market_orb_quality_joint_weak_secondary_above=0.10,
        market_orb_quality_joint_weak_secondary_below=0.15,
        market_orb_quality_joint_weak_max_trades=1,
    )

    bars = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.8, "volume": 2000},
        {"timestamp": "2026-01-05T09:35:00-05:00", "open": 100.8, "high": 101.4, "low": 100.7, "close": 101.2, "volume": 2000},
        {"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.2, "high": 101.6, "low": 101.1, "close": 101.5, "volume": 2000},
        {"timestamp": "2026-01-05T09:45:00-05:00", "open": 101.5, "high": 101.8, "low": 101.4, "close": 101.7, "volume": 2000},
        {"timestamp": "2026-01-05T09:50:00-05:00", "open": 101.7, "high": 101.9, "low": 101.6, "close": 101.8, "volume": 2000},
        {"timestamp": "2026-01-05T15:55:00-05:00", "open": 101.8, "high": 102.5, "low": 101.7, "close": 102.2, "volume": 2000},
    ]
    mild_weak_spy = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 500.0, "high": 500.5, "low": 499.5, "close": 499.62, "volume": 10000},
    ]
    mild_weak_qqq = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 400.0, "high": 400.4, "low": 399.8, "close": 399.88, "volume": 10000},
    ]
    panic_spy = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 500.0, "high": 500.5, "low": 499.5, "close": 499.50, "volume": 10000},
    ]
    panic_qqq = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 400.0, "high": 400.4, "low": 399.8, "close": 399.82, "volume": 10000},
    ]
    enrichment = _enrichment_for("AAA", "BBB")

    mild = simulate_orb_day(
        {"AAA": bars, "BBB": bars, "SPY": mild_weak_spy, "QQQ": mild_weak_qqq},
        "2026-01-05",
        params,
        enrichment,
        equity=10_000.0,
    )
    panic = simulate_orb_day(
        {"AAA": bars, "BBB": bars, "SPY": panic_spy, "QQQ": panic_qqq},
        "2026-01-05",
        params,
        enrichment,
        equity=10_000.0,
    )

    assert mild.market_orb_quality_joint_weak_active is True
    assert mild.market_orb_quality_joint_weak_max_trades_active is True
    assert len(mild.trades) == 1
    assert panic.market_orb_quality_joint_weak_active is False
    assert panic.market_orb_quality_joint_weak_max_trades_active is False
    assert len(panic.trades) == 2


def test_market_orb_quality_joint_panic_guard_can_require_confirmation() -> None:
    params = ORBStrategyParams(
        engine_family="gainers_leader",
        orb_minutes=5,
        sim_bar_minutes=5,
        order_timeout_minutes=20,
        atr_stop_multiplier=1.0,
        trailing_at_r=99.0,
        breakeven_at_r=99.0,
        slippage_bps=0.0,
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        min_abs_gap_pct=0.0,
        min_premarket_dollar_vol=0.0,
        max_gap_pct=None,
        max_candidates=20,
        min_candidates_to_trade=1,
        risk_per_trade_pct=0.1,
        max_position_pct=1.0,
        daily_max_loss_pct=1.0,
        max_stops_per_day=99,
        daily_budget_reset=True,
        allow_doji_breakout=True,
        market_orb_quality_ticker="SPY",
        market_orb_quality_secondary_ticker="QQQ",
        market_orb_quality_joint_panic_primary_below=0.1,
        market_orb_quality_joint_panic_secondary_below=0.1,
        market_orb_quality_joint_panic_require_confirmation=True,
    )

    aaa_bars = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.8, "volume": 2000},
        {"timestamp": "2026-01-05T09:35:00-05:00", "open": 100.8, "high": 101.2, "low": 100.7, "close": 101.1, "volume": 2000},
        {"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.1, "high": 101.2, "low": 100.7, "close": 100.9, "volume": 2000},
        {"timestamp": "2026-01-05T09:45:00-05:00", "open": 100.9, "high": 101.0, "low": 100.8, "close": 100.95, "volume": 2000},
        {"timestamp": "2026-01-05T15:55:00-05:00", "open": 100.95, "high": 101.0, "low": 100.85, "close": 100.9, "volume": 2000},
    ]
    calm_spy = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 500.0, "high": 501.0, "low": 499.5, "close": 500.8, "volume": 10000},
    ]
    calm_qqq = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 400.0, "high": 401.0, "low": 399.8, "close": 400.9, "volume": 10000},
    ]
    panic_spy = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 500.0, "high": 501.0, "low": 499.0, "close": 499.05, "volume": 10000},
    ]
    panic_qqq = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 400.0, "high": 401.0, "low": 399.0, "close": 399.1, "volume": 10000},
    ]
    enrichment = _enrichment_for("AAA")

    calm = simulate_orb_day(
        {"AAA": aaa_bars, "SPY": calm_spy, "QQQ": calm_qqq},
        "2026-01-05",
        params,
        enrichment,
        equity=10_000.0,
    )
    panic = simulate_orb_day(
        {"AAA": aaa_bars, "SPY": panic_spy, "QQQ": panic_qqq},
        "2026-01-05",
        params,
        enrichment,
        equity=10_000.0,
    )

    assert calm.market_orb_quality_joint_panic_active is False
    assert calm.conditional_confirmation_active is False
    assert len(calm.trades) == 1
    assert panic.market_orb_quality_joint_panic_active is True
    assert panic.conditional_confirmation_active is True
    assert panic.trades == []


def test_quality_breakout_min_body_ratio_filters_weak_candle() -> None:
    params = ORBStrategyParams(
        engine_family="quality_breakout",
        min_body_ratio=0.3,
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        max_candidates=10,
        min_candidates_to_trade=1,
    )
    bars_by_ticker = {
        "STRONG": [
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 102.0, "low": 99.8, "close": 101.8, "volume": 2000},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.8, "high": 102.2, "low": 101.7, "close": 102.0, "volume": 2000},
            {"timestamp": "2026-01-05T09:40:00-05:00", "open": 102.0, "high": 102.1, "low": 101.9, "close": 102.0, "volume": 2000},
            {"timestamp": "2026-01-05T09:45:00-05:00", "open": 102.0, "high": 102.1, "low": 101.9, "close": 102.0, "volume": 2000},
            {"timestamp": "2026-01-05T09:50:00-05:00", "open": 102.0, "high": 102.1, "low": 101.9, "close": 102.0, "volume": 2000},
        ],
        "WEAK": [
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 102.0, "low": 99.8, "close": 100.2, "volume": 2000},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 100.2, "high": 100.5, "low": 100.1, "close": 100.4, "volume": 2000},
            {"timestamp": "2026-01-05T09:40:00-05:00", "open": 100.4, "high": 100.5, "low": 100.3, "close": 100.4, "volume": 2000},
            {"timestamp": "2026-01-05T09:45:00-05:00", "open": 100.4, "high": 100.5, "low": 100.3, "close": 100.4, "volume": 2000},
            {"timestamp": "2026-01-05T09:50:00-05:00", "open": 100.4, "high": 100.5, "low": 100.3, "close": 100.4, "volume": 2000},
        ],
    }
    candidates = compute_orb_candidates(
        bars_by_ticker,
        "2026-01-05",
        params,
        {
            **_enrichment_for("STRONG", "WEAK"),
        },
    )

    assert [cand["ticker"] for cand in candidates] == ["STRONG"]


def test_compression_breakout_uses_entropy_weight_in_ranking() -> None:
    params = ORBStrategyParams(
        engine_family="compression_breakout",
        weight_rvol=0.0,
        weight_gap=0.0,
        weight_dollar_vol=0.0,
        weight_entropy=-0.15,
        weight_atr_ratio=0.0,
        weight_gap_zscore=0.0,
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        max_candidates=10,
        min_candidates_to_trade=1,
    )
    bars = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 102.0, "low": 99.8, "close": 101.8, "volume": 2000},
        {"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.8, "high": 102.2, "low": 101.7, "close": 102.0, "volume": 2000},
        {"timestamp": "2026-01-05T09:40:00-05:00", "open": 102.0, "high": 102.1, "low": 101.9, "close": 102.0, "volume": 2000},
        {"timestamp": "2026-01-05T09:45:00-05:00", "open": 102.0, "high": 102.1, "low": 101.9, "close": 102.0, "volume": 2000},
        {"timestamp": "2026-01-05T09:50:00-05:00", "open": 102.0, "high": 102.1, "low": 101.9, "close": 102.0, "volume": 2000},
    ]
    enrichment = _enrichment_for("LOW_ENT", "HIGH_ENT")
    enrichment["LOW_ENT"]["2026-01-05"]["entropy_20d"] = 0.1
    enrichment["HIGH_ENT"]["2026-01-05"]["entropy_20d"] = 0.9
    candidates = compute_orb_candidates(
        {"LOW_ENT": bars, "HIGH_ENT": bars},
        "2026-01-05",
        params,
        enrichment,
    )

    assert [cand["ticker"] for cand in candidates] == ["LOW_ENT", "HIGH_ENT"]


def test_candidates_can_rank_on_premarket_dollar_volume() -> None:
    params = ORBStrategyParams(
        engine_family="compression_breakout",
        weight_rvol=0.0,
        weight_gap=0.0,
        weight_dollar_vol=0.0,
        weight_premarket_dollar_vol=1.0,
        weight_entropy=0.0,
        weight_atr_ratio=0.0,
        weight_gap_zscore=0.0,
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        max_candidates=10,
        min_candidates_to_trade=1,
    )
    market_bars = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 102.0, "low": 99.8, "close": 101.8, "volume": 2000},
        {"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.8, "high": 102.2, "low": 101.7, "close": 102.0, "volume": 2000},
        {"timestamp": "2026-01-05T09:40:00-05:00", "open": 102.0, "high": 102.1, "low": 101.9, "close": 102.0, "volume": 2000},
        {"timestamp": "2026-01-05T09:45:00-05:00", "open": 102.0, "high": 102.1, "low": 101.9, "close": 102.0, "volume": 2000},
        {"timestamp": "2026-01-05T09:50:00-05:00", "open": 102.0, "high": 102.1, "low": 101.9, "close": 102.0, "volume": 2000},
    ]
    bars_by_ticker = {
        "HIGH_PM": [
            {"timestamp": "2026-01-05T08:00:00-05:00", "open": 100.0, "high": 100.2, "low": 99.9, "close": 100.0, "volume": 10_000},
            *market_bars,
        ],
        "LOW_PM": [
            {"timestamp": "2026-01-05T08:00:00-05:00", "open": 100.0, "high": 100.2, "low": 99.9, "close": 100.0, "volume": 1_000},
            *market_bars,
        ],
    }

    candidates = compute_orb_candidates(
        bars_by_ticker,
        "2026-01-05",
        params,
        _enrichment_for("HIGH_PM", "LOW_PM"),
    )

    assert [cand["ticker"] for cand in candidates] == ["HIGH_PM", "LOW_PM"]


def test_stocks_in_play_can_gate_on_attention_and_sector_relative_strength() -> None:
    params = ORBStrategyParams(
        engine_family="stocks_in_play_dual_regime",
        entry_direction="long_only",
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        min_candidates_to_trade=1,
        max_candidates=10,
        require_event_flag=True,
        attention_min_wiki_spike_10d=2.0,
        attention_min_article_count_3d=3,
        min_close_location=0.6,
        min_sector_relative_strength=0.01,
        require_vwap_confirmation=False,
    )
    strong_bars = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 103.0, "low": 99.8, "close": 102.8, "volume": 2000, "vwap": 101.5},
        {"timestamp": "2026-01-05T09:35:00-05:00", "open": 102.8, "high": 103.1, "low": 102.6, "close": 103.0, "volume": 2000, "vwap": 102.9},
        {"timestamp": "2026-01-05T09:40:00-05:00", "open": 103.0, "high": 103.2, "low": 102.9, "close": 103.1, "volume": 2000, "vwap": 103.0},
        {"timestamp": "2026-01-05T09:45:00-05:00", "open": 103.1, "high": 103.2, "low": 103.0, "close": 103.1, "volume": 2000, "vwap": 103.1},
        {"timestamp": "2026-01-05T09:50:00-05:00", "open": 103.1, "high": 103.2, "low": 103.0, "close": 103.1, "volume": 2000, "vwap": 103.1},
    ]
    weak_same_sector = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.2, "low": 99.8, "close": 100.7, "volume": 2000, "vwap": 100.5},
        {"timestamp": "2026-01-05T09:35:00-05:00", "open": 100.7, "high": 100.9, "low": 100.6, "close": 100.8, "volume": 2000, "vwap": 100.8},
        {"timestamp": "2026-01-05T09:40:00-05:00", "open": 100.8, "high": 100.9, "low": 100.7, "close": 100.8, "volume": 2000, "vwap": 100.8},
        {"timestamp": "2026-01-05T09:45:00-05:00", "open": 100.8, "high": 100.9, "low": 100.7, "close": 100.8, "volume": 2000, "vwap": 100.8},
        {"timestamp": "2026-01-05T09:50:00-05:00", "open": 100.8, "high": 100.9, "low": 100.7, "close": 100.8, "volume": 2000, "vwap": 100.8},
    ]
    no_attention = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 102.5, "low": 99.8, "close": 102.0, "volume": 2000, "vwap": 101.5},
        {"timestamp": "2026-01-05T09:35:00-05:00", "open": 102.0, "high": 102.2, "low": 101.9, "close": 102.1, "volume": 2000, "vwap": 102.0},
        {"timestamp": "2026-01-05T09:40:00-05:00", "open": 102.1, "high": 102.2, "low": 102.0, "close": 102.1, "volume": 2000, "vwap": 102.1},
        {"timestamp": "2026-01-05T09:45:00-05:00", "open": 102.1, "high": 102.2, "low": 102.0, "close": 102.1, "volume": 2000, "vwap": 102.1},
        {"timestamp": "2026-01-05T09:50:00-05:00", "open": 102.1, "high": 102.2, "low": 102.0, "close": 102.1, "volume": 2000, "vwap": 102.1},
    ]
    enrichment = _enrichment_for("STRONG", "WEAK", "NO_ATTN")
    enrichment["STRONG"]["2026-01-05"].update({
        "event_flag": True,
        "event_score": 1.0,
        "attention_wiki_spike_10d": 3.0,
        "attention_article_count_3d": 5,
    })
    enrichment["WEAK"]["2026-01-05"].update({
        "event_flag": True,
        "event_score": 1.0,
        "attention_wiki_spike_10d": 2.5,
        "attention_article_count_3d": 4,
    })
    enrichment["NO_ATTN"]["2026-01-05"].update({
        "event_flag": True,
        "event_score": 1.0,
        "attention_wiki_spike_10d": 1.2,
        "attention_article_count_3d": 0,
    })
    candidates = compute_orb_candidates(
        {"STRONG": strong_bars, "WEAK": weak_same_sector, "NO_ATTN": no_attention},
        "2026-01-05",
        params,
        enrichment,
        ticker_sectors={"STRONG": "TECH", "WEAK": "TECH", "NO_ATTN": "HEALTH"},
    )

    assert [cand["ticker"] for cand in candidates] == ["STRONG"]


def test_gainers_leader_attention_news_weight_affects_ranking() -> None:
    params = ORBStrategyParams(
        engine_family="gainers_leader",
        entry_direction="long_only",
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        min_candidates_to_trade=1,
        max_candidates=2,
        weight_rvol=0.0,
        weight_gap=0.0,
        weight_dollar_vol=0.0,
        weight_premarket_dollar_vol=0.0,
        weight_attention_news=1.0,
    )
    bars = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 102.0, "low": 99.8, "close": 101.5, "volume": 2000},
        {"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.5, "high": 102.1, "low": 101.4, "close": 102.0, "volume": 2000},
        {"timestamp": "2026-01-05T09:40:00-05:00", "open": 102.0, "high": 102.2, "low": 101.9, "close": 102.1, "volume": 2000},
        {"timestamp": "2026-01-05T09:45:00-05:00", "open": 102.1, "high": 102.3, "low": 102.0, "close": 102.2, "volume": 2000},
        {"timestamp": "2026-01-05T09:50:00-05:00", "open": 102.2, "high": 102.4, "low": 102.1, "close": 102.3, "volume": 2000},
    ]
    enrichment = _enrichment_for("LOW_NEWS", "HIGH_NEWS")
    enrichment["LOW_NEWS"]["2026-01-05"]["attention_article_count_3d"] = 0
    enrichment["HIGH_NEWS"]["2026-01-05"]["attention_article_count_3d"] = 8

    candidates = compute_orb_candidates(
        {"LOW_NEWS": bars, "HIGH_NEWS": bars},
        "2026-01-05",
        params,
        enrichment,
    )

    assert [cand["ticker"] for cand in candidates] == ["HIGH_NEWS", "LOW_NEWS"]


def test_gainers_leader_ownership_initial_weight_affects_ranking() -> None:
    params = ORBStrategyParams(
        engine_family="gainers_leader",
        entry_direction="long_only",
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        min_candidates_to_trade=1,
        max_candidates=2,
        weight_rvol=0.0,
        weight_gap=0.0,
        weight_dollar_vol=0.0,
        weight_premarket_dollar_vol=0.0,
        ownership_13dg_lookback_days=180,
        weight_ownership_initial_13dg=1.0,
    )
    bars = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 102.0, "low": 99.8, "close": 101.5, "volume": 2000},
        {"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.5, "high": 102.1, "low": 101.4, "close": 102.0, "volume": 2000},
        {"timestamp": "2026-01-05T09:40:00-05:00", "open": 102.0, "high": 102.2, "low": 101.9, "close": 102.1, "volume": 2000},
        {"timestamp": "2026-01-05T09:45:00-05:00", "open": 102.1, "high": 102.3, "low": 102.0, "close": 102.2, "volume": 2000},
        {"timestamp": "2026-01-05T09:50:00-05:00", "open": 102.2, "high": 102.4, "low": 102.1, "close": 102.3, "volume": 2000},
    ]
    enrichment = _enrichment_for("NO_OWNER", "OWNER")
    enrichment["OWNER"]["2026-01-05"].update(
        {
            "ownership_13dg_flag": True,
            "ownership_13dg_initial_flag": True,
            "ownership_13dg_days_since": 20,
            "ownership_13dg_score": 1.0,
            "ownership_13dg_initial_score": 1.0,
        }
    )

    candidates = compute_orb_candidates(
        {"NO_OWNER": bars, "OWNER": bars},
        "2026-01-05",
        params,
        enrichment,
    )

    assert [cand["ticker"] for cand in candidates] == ["OWNER", "NO_OWNER"]


def test_ownership_initial_size_scale_requires_clean_initial_owner_setup() -> None:
    params = ORBStrategyParams(
        ownership_initial_size_scale=1.2,
        ownership_initial_min_score_rank_pct=0.8,
        ownership_initial_allowed_trigger_types=["orb"],
    )
    cand = {"ownership_13dg_initial_flag": True}

    assert _orb_ownership_initial_size_scale(params, cand, 0.9, "orb", "long", (1.0,)) == 1.2
    assert _orb_ownership_initial_size_scale(params, cand, 0.7, "orb", "long", (1.0,)) == 1.0
    assert _orb_ownership_initial_size_scale(params, cand, 0.9, "vwap_reclaim", "long", (1.0,)) == 1.0
    assert _orb_ownership_initial_size_scale(params, cand, 0.9, "orb", "long", (0.5,)) == 1.0


def test_form4_size_scale_requires_cluster_or_csuite_setup() -> None:
    params = ORBStrategyParams(
        form4_size_scale=1.2,
        form4_min_total_value=100_000.0,
        form4_min_owner_count=2,
        form4_min_c_suite_count=1,
        form4_require_cluster_or_csuite=True,
        form4_allowed_trigger_types=["orb"],
    )
    cluster = {
        "form4_flag": True,
        "form4_total_value": 150_000.0,
        "form4_owner_count": 2,
        "form4_c_suite_count": 0,
    }
    csuite = {
        "form4_flag": True,
        "form4_total_value": 150_000.0,
        "form4_owner_count": 1,
        "form4_c_suite_count": 1,
    }
    weak = {
        "form4_flag": True,
        "form4_total_value": 150_000.0,
        "form4_owner_count": 1,
        "form4_c_suite_count": 0,
    }

    assert _orb_form4_size_scale(params, cluster, "orb", "long", (1.0,)) == 1.2
    assert _orb_form4_size_scale(params, csuite, "orb", "long", (1.0,)) == 1.2
    assert _orb_form4_size_scale(params, weak, "orb", "long", (1.0,)) == 1.0
    assert _orb_form4_size_scale(params, cluster, "vwap_reclaim", "long", (1.0,)) == 1.0
    assert _orb_form4_size_scale(params, cluster, "orb", "long", (0.5,)) == 1.0


def test_stocks_in_play_can_allow_red_to_green_reclaim() -> None:
    params = ORBStrategyParams(
        engine_family="stocks_in_play_dual_regime",
        entry_direction="long_only",
        allow_doji_breakout=True,
        allow_red_to_green_breakout=True,
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        min_candidates_to_trade=1,
        max_candidates=10,
        require_event_flag=True,
    )
    bars = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.8, "close": 99.9, "volume": 2000, "vwap": 100.0},
        {"timestamp": "2026-01-05T09:35:00-05:00", "open": 99.9, "high": 100.3, "low": 99.8, "close": 100.2, "volume": 2000, "vwap": 100.1},
        {"timestamp": "2026-01-05T09:40:00-05:00", "open": 100.2, "high": 100.4, "low": 100.1, "close": 100.3, "volume": 2000, "vwap": 100.3},
        {"timestamp": "2026-01-05T09:45:00-05:00", "open": 100.3, "high": 100.4, "low": 100.2, "close": 100.3, "volume": 2000, "vwap": 100.3},
        {"timestamp": "2026-01-05T09:50:00-05:00", "open": 100.3, "high": 100.4, "low": 100.2, "close": 100.3, "volume": 2000, "vwap": 100.3},
    ]
    enrichment = _enrichment_for("R2G")
    enrichment["R2G"]["2026-01-05"].update({"event_flag": True, "event_score": 1.0})
    candidates = compute_orb_candidates({"R2G": bars}, "2026-01-05", params, enrichment)

    assert [cand["ticker"] for cand in candidates] == ["R2G"]


def test_candidates_can_filter_on_min_abs_gap_pct() -> None:
    params = ORBStrategyParams(
        engine_family="compression_breakout",
        min_abs_gap_pct=0.02,
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        max_candidates=10,
        min_candidates_to_trade=1,
    )
    bars = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 102.0, "low": 99.8, "close": 101.8, "volume": 2000},
        {"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.8, "high": 102.2, "low": 101.7, "close": 102.0, "volume": 2000},
        {"timestamp": "2026-01-05T09:40:00-05:00", "open": 102.0, "high": 102.1, "low": 101.9, "close": 102.0, "volume": 2000},
        {"timestamp": "2026-01-05T09:45:00-05:00", "open": 102.0, "high": 102.1, "low": 101.9, "close": 102.0, "volume": 2000},
        {"timestamp": "2026-01-05T09:50:00-05:00", "open": 102.0, "high": 102.1, "low": 101.9, "close": 102.0, "volume": 2000},
    ]
    enrichment = _enrichment_for("BIG_GAP", "SMALL_GAP")
    enrichment["BIG_GAP"]["2026-01-05"]["prev_close"] = 95.0
    enrichment["SMALL_GAP"]["2026-01-05"]["prev_close"] = 99.5

    candidates = compute_orb_candidates(
        {"BIG_GAP": bars, "SMALL_GAP": bars},
        "2026-01-05",
        params,
        enrichment,
    )

    assert [cand["ticker"] for cand in candidates] == ["BIG_GAP"]


def test_candidates_can_cap_names_per_sector_after_ranking() -> None:
    params = ORBStrategyParams(
        weight_rvol=1.0,
        weight_gap=0.0,
        weight_dollar_vol=0.0,
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        max_candidates=3,
        max_candidates_per_sector=1,
        min_candidates_to_trade=1,
    )
    bars_by_ticker = {
        "TECH1": [
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 102.0, "low": 99.8, "close": 101.8, "volume": 4000},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.8, "high": 102.2, "low": 101.7, "close": 102.0, "volume": 2000},
            {"timestamp": "2026-01-05T09:40:00-05:00", "open": 102.0, "high": 102.1, "low": 101.9, "close": 102.0, "volume": 2000},
            {"timestamp": "2026-01-05T09:45:00-05:00", "open": 102.0, "high": 102.1, "low": 101.9, "close": 102.0, "volume": 2000},
            {"timestamp": "2026-01-05T09:50:00-05:00", "open": 102.0, "high": 102.1, "low": 101.9, "close": 102.0, "volume": 2000},
        ],
        "TECH2": [
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 102.0, "low": 99.8, "close": 101.8, "volume": 3000},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.8, "high": 102.2, "low": 101.7, "close": 102.0, "volume": 2000},
            {"timestamp": "2026-01-05T09:40:00-05:00", "open": 102.0, "high": 102.1, "low": 101.9, "close": 102.0, "volume": 2000},
            {"timestamp": "2026-01-05T09:45:00-05:00", "open": 102.0, "high": 102.1, "low": 101.9, "close": 102.0, "volume": 2000},
            {"timestamp": "2026-01-05T09:50:00-05:00", "open": 102.0, "high": 102.1, "low": 101.9, "close": 102.0, "volume": 2000},
        ],
        "HEALTH1": [
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 102.0, "low": 99.8, "close": 101.8, "volume": 2000},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.8, "high": 102.2, "low": 101.7, "close": 102.0, "volume": 2000},
            {"timestamp": "2026-01-05T09:40:00-05:00", "open": 102.0, "high": 102.1, "low": 101.9, "close": 102.0, "volume": 2000},
            {"timestamp": "2026-01-05T09:45:00-05:00", "open": 102.0, "high": 102.1, "low": 101.9, "close": 102.0, "volume": 2000},
            {"timestamp": "2026-01-05T09:50:00-05:00", "open": 102.0, "high": 102.1, "low": 101.9, "close": 102.0, "volume": 2000},
        ],
    }

    candidates = compute_orb_candidates(
        bars_by_ticker,
        "2026-01-05",
        params,
        _enrichment_for("TECH1", "TECH2", "HEALTH1"),
        ticker_sectors={"TECH1": "Technology", "TECH2": "Technology", "HEALTH1": "Healthcare"},
    )

    assert [cand["ticker"] for cand in candidates] == ["TECH1", "HEALTH1"]


def test_sector_confirmation_can_promote_same_sector_cluster() -> None:
    params = ORBStrategyParams(
        weight_rvol=1.0,
        weight_gap=0.0,
        weight_dollar_vol=0.0,
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        max_candidates=1,
        min_candidates_to_trade=1,
        sector_confirmation_enabled=True,
        sector_confirmation_min_members=2,
        sector_confirmation_score_weight=1.0,
    )

    def bars(volume: int) -> list[dict]:
        return [
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 102.0, "low": 99.8, "close": 101.8, "volume": volume},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.8, "high": 102.2, "low": 101.7, "close": 102.0, "volume": 2000},
            {"timestamp": "2026-01-05T09:40:00-05:00", "open": 102.0, "high": 102.1, "low": 101.9, "close": 102.0, "volume": 2000},
            {"timestamp": "2026-01-05T09:45:00-05:00", "open": 102.0, "high": 102.1, "low": 101.9, "close": 102.0, "volume": 2000},
            {"timestamp": "2026-01-05T09:50:00-05:00", "open": 102.0, "high": 102.1, "low": 101.9, "close": 102.0, "volume": 2000},
        ]

    candidates = compute_orb_candidates(
        {
            "LONE": bars(5000),
            "TECH1": bars(4600),
            "TECH2": bars(4500),
        },
        "2026-01-05",
        params,
        _enrichment_for("LONE", "TECH1", "TECH2"),
        ticker_sectors={
            "LONE": "Healthcare",
            "TECH1": "Technology",
            "TECH2": "Technology",
        },
    )

    assert [cand["ticker"] for cand in candidates] == ["TECH1"]
    assert candidates[0]["sector_confirmation_active"] is True
    assert candidates[0]["sector_confirmation_member_count"] == 2


def test_sector_confirmation_size_scale_supports_liquidity_tiers() -> None:
    params = ORBStrategyParams(
        sector_confirmation_enabled=True,
        sector_confirmation_size_scale=1.10,
        sector_confirmation_liquid_min_premarket_dollar_vol=20_000_000,
        sector_confirmation_liquid_size_scale=1.20,
        sector_confirmation_illiquid_size_scale=1.0,
        sector_confirmation_unconfirmed_size_scale=0.95,
    )

    liquid = {
        "sector_confirmation_active": True,
        "premarket_dollar_vol": 25_000_000,
    }
    thin = {
        "sector_confirmation_active": True,
        "premarket_dollar_vol": 5_000_000,
    }
    unconfirmed = {
        "sector_confirmation_active": False,
        "premarket_dollar_vol": 25_000_000,
    }

    assert _orb_sector_confirmation_size_scale(params, liquid) == 1.20
    assert _orb_sector_confirmation_size_scale(params, thin) == 1.0
    assert _orb_sector_confirmation_size_scale(params, unconfirmed) == 0.95


def test_opening_burst_liquid_size_scale_requires_time_liquidity_and_clean_risk() -> None:
    params = ORBStrategyParams(
        opening_burst_liquid_size_scale=1.4,
        opening_burst_liquid_max_entry_minutes_after_open=5,
        opening_burst_liquid_min_premarket_dollar_vol=100_000_000,
        opening_burst_liquid_min_gap_pct=0.0,
        opening_burst_liquid_min_candidate_score=0.65,
        opening_burst_liquid_allowed_trigger_types=["orb"],
    )
    cand = {"premarket_dollar_vol": 150_000_000, "gap_pct": 0.02, "score": 0.70}
    entry_ts = dt.datetime(
        2026,
        1,
        5,
        9,
        35,
        tzinfo=dt.timezone(dt.timedelta(hours=-5)),
    )
    late_ts = entry_ts + dt.timedelta(minutes=5)

    assert (
        _orb_opening_burst_liquid_size_scale(
            params,
            {"premarket_dollar_vol": 150_000_000, "gap_pct": 0.02, "score": 0.60},
            date_str="2026-01-05",
            entry_ts=entry_ts,
            trigger_type="orb",
            score_rank_pct=0.5,
            direction_str="long",
            risk_size_scales=(1.0, 1.0),
        )
        == 1.0
    )
    assert (
        _orb_opening_burst_liquid_size_scale(
            params,
            cand,
            date_str="2026-01-05",
            entry_ts=entry_ts,
            trigger_type="orb",
            score_rank_pct=0.5,
            direction_str="long",
            risk_size_scales=(1.0, 1.0),
        )
        == 1.4
    )
    assert (
        _orb_opening_burst_liquid_size_scale(
            params,
            {"premarket_dollar_vol": 150_000_000, "gap_pct": -0.01, "score": 0.70},
            date_str="2026-01-05",
            entry_ts=entry_ts,
            trigger_type="orb",
            score_rank_pct=0.5,
            direction_str="long",
            risk_size_scales=(1.0, 1.0),
        )
        == 1.0
    )
    assert (
        _orb_opening_burst_liquid_size_scale(
            params,
            cand,
            date_str="2026-01-05",
            entry_ts=late_ts,
            trigger_type="orb",
            score_rank_pct=0.5,
            direction_str="long",
            risk_size_scales=(1.0, 1.0),
        )
        == 1.0
    )
    assert (
        _orb_opening_burst_liquid_size_scale(
            params,
            cand,
            date_str="2026-01-05",
            entry_ts=entry_ts,
            trigger_type="soft_day_vwap_reclaim",
            score_rank_pct=0.5,
            direction_str="long",
            risk_size_scales=(1.0, 1.0),
        )
        == 1.0
    )
    assert (
        _orb_opening_burst_liquid_size_scale(
            params,
            cand,
            date_str="2026-01-05",
            entry_ts=entry_ts,
            trigger_type="orb",
            score_rank_pct=0.5,
            direction_str="long",
            risk_size_scales=(0.5, 1.0),
        )
        == 1.0
    )


def test_unboosted_primary_fragility_scales_only_unboosted_fragile_orb() -> None:
    params = ORBStrategyParams(
        unboosted_primary_fragility_size_scale=0.25,
        unboosted_primary_fragility_allowed_trigger_types=["orb"],
        unboosted_primary_fragility_crowded_min_gap_pct=0.02,
        unboosted_primary_fragility_crowded_min_premarket_dollar_vol=50_000_000,
        unboosted_primary_fragility_crowded_max_close_location=0.85,
        unboosted_primary_fragility_weak_max_rvol=5.0,
        unboosted_primary_fragility_weak_max_ret_5d=0.05,
    )
    crowded = {
        "gap_pct": 0.03,
        "premarket_dollar_vol": 75_000_000,
        "close_location": 0.70,
        "rvol": 8.0,
        "ret_5d": 0.20,
    }
    weak_attention = {
        "gap_pct": -0.03,
        "premarket_dollar_vol": 10_000_000,
        "close_location": 0.95,
        "rvol": 4.5,
        "ret_5d": -0.02,
    }
    clean = {
        "gap_pct": 0.03,
        "premarket_dollar_vol": 75_000_000,
        "close_location": 0.95,
        "rvol": 8.0,
        "ret_5d": 0.20,
    }

    assert (
        _orb_unboosted_primary_fragility_size_scale(
            params,
            crowded,
            {},
            trigger_type="orb",
            direction_str="long",
            high_conviction_size_scales=(1.0, 1.0),
        )
        == 0.25
    )
    assert (
        _orb_unboosted_primary_fragility_size_scale(
            params,
            weak_attention,
            {},
            trigger_type="orb",
            direction_str="long",
            high_conviction_size_scales=(1.0, 1.0),
        )
        == 0.25
    )
    assert (
        _orb_unboosted_primary_fragility_size_scale(
            params,
            crowded,
            {},
            trigger_type="orb",
            direction_str="long",
            high_conviction_size_scales=(1.5, 1.0),
        )
        == 1.0
    )
    assert (
        _orb_unboosted_primary_fragility_size_scale(
            params,
            crowded,
            {},
            trigger_type="soft_day_vwap_reclaim",
            direction_str="long",
            high_conviction_size_scales=(1.0, 1.0),
        )
        == 1.0
    )
    assert (
        _orb_unboosted_primary_fragility_size_scale(
            params,
            clean,
            {},
            trigger_type="orb",
            direction_str="long",
            high_conviction_size_scales=(1.0, 1.0),
        )
        == 1.0
    )


def test_soft_day_sector_confirmation_override_requires_sector_and_reason() -> None:
    params = ORBStrategyParams(
        soft_day_sector_confirmation_override_enabled=True,
        soft_day_sector_confirmation_override_min_score_pct=0.70,
        soft_day_sector_confirmation_override_min_premarket_dollar_vol=20_000_000,
        soft_day_sector_confirmation_override_allowed_reason_parts=["breadth"],
        soft_day_sector_confirmation_override_allowed_trigger_types=["orb"],
    )
    cand = {
        "sector_confirmation_active": True,
        "premarket_dollar_vol": 25_000_000,
    }

    assert _orb_soft_day_sector_confirmation_override_allows(
        params,
        "market_regime+breadth",
        cand,
        score_rank_pct=0.75,
        trigger_type="orb",
    )
    assert not _orb_soft_day_sector_confirmation_override_allows(
        params,
        "market_regime",
        cand,
        score_rank_pct=0.75,
        trigger_type="orb",
    )
    assert not _orb_soft_day_sector_confirmation_override_allows(
        params,
        "market_regime+breadth",
        {**cand, "sector_confirmation_active": False},
        score_rank_pct=0.75,
        trigger_type="orb",
    )
    assert not _orb_soft_day_sector_confirmation_override_allows(
        params,
        "market_regime+breadth",
        cand,
        score_rank_pct=0.65,
        trigger_type="orb",
    )
    assert not _orb_soft_day_sector_confirmation_override_allows(
        params,
        "market_regime+breadth",
        cand,
        score_rank_pct=0.75,
        trigger_type="soft_day_vwap_reclaim",
    )


def test_soft_day_sector_confirmation_override_base_sizing_floor_is_active_only() -> None:
    params = ORBStrategyParams(
        soft_day_sector_confirmation_override_min_day_size_scale=0.08,
    )

    assert _orb_soft_day_sector_confirmation_override_base_sizing(
        params,
        active=False,
        sizing_cap=10_000,
        adjusted_sizing=0.0,
    ) == (0.0, None)
    assert _orb_soft_day_sector_confirmation_override_base_sizing(
        params,
        active=True,
        sizing_cap=10_000,
        adjusted_sizing=0.0,
    ) == (800.0, 0.08)
    assert _orb_soft_day_sector_confirmation_override_base_sizing(
        params,
        active=True,
        sizing_cap=10_000,
        adjusted_sizing=1_200.0,
    ) == (1_200.0, 0.08)


def test_candidates_can_prune_weak_tail_with_relative_quality_floor() -> None:
    params = ORBStrategyParams(
        weight_rvol=0.0,
        weight_gap=0.0,
        weight_dollar_vol=1.0,
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        max_candidates=3,
        min_candidates_to_trade=1,
        basket_quality_relative_floor=0.6,
    )
    bars_by_ticker = {
        "LEADER": [
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 102.0, "low": 99.8, "close": 101.8, "volume": 4000},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.8, "high": 102.2, "low": 101.7, "close": 102.0, "volume": 2000},
            {"timestamp": "2026-01-05T09:40:00-05:00", "open": 102.0, "high": 102.1, "low": 101.9, "close": 102.0, "volume": 2000},
            {"timestamp": "2026-01-05T09:45:00-05:00", "open": 102.0, "high": 102.1, "low": 101.9, "close": 102.0, "volume": 2000},
            {"timestamp": "2026-01-05T09:50:00-05:00", "open": 102.0, "high": 102.1, "low": 101.9, "close": 102.0, "volume": 2000},
        ],
        "MID": [
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 102.0, "low": 99.8, "close": 101.8, "volume": 3000},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.8, "high": 102.2, "low": 101.7, "close": 102.0, "volume": 2000},
            {"timestamp": "2026-01-05T09:40:00-05:00", "open": 102.0, "high": 102.1, "low": 101.9, "close": 102.0, "volume": 2000},
            {"timestamp": "2026-01-05T09:45:00-05:00", "open": 102.0, "high": 102.1, "low": 101.9, "close": 102.0, "volume": 2000},
            {"timestamp": "2026-01-05T09:50:00-05:00", "open": 102.0, "high": 102.1, "low": 101.9, "close": 102.0, "volume": 2000},
        ],
        "TAIL": [
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 102.0, "low": 99.8, "close": 101.8, "volume": 2000},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.8, "high": 102.2, "low": 101.7, "close": 102.0, "volume": 2000},
            {"timestamp": "2026-01-05T09:40:00-05:00", "open": 102.0, "high": 102.1, "low": 101.9, "close": 102.0, "volume": 2000},
            {"timestamp": "2026-01-05T09:45:00-05:00", "open": 102.0, "high": 102.1, "low": 101.9, "close": 102.0, "volume": 2000},
            {"timestamp": "2026-01-05T09:50:00-05:00", "open": 102.0, "high": 102.1, "low": 101.9, "close": 102.0, "volume": 2000},
        ],
    }

    candidates = compute_orb_candidates(
        bars_by_ticker,
        "2026-01-05",
        params,
        _enrichment_for("LEADER", "MID", "TAIL"),
    )

    assert [cand["ticker"] for cand in candidates] == ["LEADER"]


def test_quality_floor_respects_min_count() -> None:
    params = ORBStrategyParams(
        weight_rvol=0.0,
        weight_gap=0.0,
        weight_dollar_vol=1.0,
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        max_candidates=3,
        min_candidates_to_trade=1,
        basket_quality_relative_floor=0.95,
        basket_quality_min_count=2,
    )
    bars_by_ticker = {
        "LEADER": [
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 102.0, "low": 99.8, "close": 101.8, "volume": 4000},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.8, "high": 102.2, "low": 101.7, "close": 102.0, "volume": 2000},
            {"timestamp": "2026-01-05T09:40:00-05:00", "open": 102.0, "high": 102.1, "low": 101.9, "close": 102.0, "volume": 2000},
            {"timestamp": "2026-01-05T09:45:00-05:00", "open": 102.0, "high": 102.1, "low": 101.9, "close": 102.0, "volume": 2000},
            {"timestamp": "2026-01-05T09:50:00-05:00", "open": 102.0, "high": 102.1, "low": 101.9, "close": 102.0, "volume": 2000},
        ],
        "MID": [
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 102.0, "low": 99.8, "close": 101.8, "volume": 3000},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.8, "high": 102.2, "low": 101.7, "close": 102.0, "volume": 2000},
            {"timestamp": "2026-01-05T09:40:00-05:00", "open": 102.0, "high": 102.1, "low": 101.9, "close": 102.0, "volume": 2000},
            {"timestamp": "2026-01-05T09:45:00-05:00", "open": 102.0, "high": 102.1, "low": 101.9, "close": 102.0, "volume": 2000},
            {"timestamp": "2026-01-05T09:50:00-05:00", "open": 102.0, "high": 102.1, "low": 101.9, "close": 102.0, "volume": 2000},
        ],
        "TAIL": [
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 102.0, "low": 99.8, "close": 101.8, "volume": 2000},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.8, "high": 102.2, "low": 101.7, "close": 102.0, "volume": 2000},
            {"timestamp": "2026-01-05T09:40:00-05:00", "open": 102.0, "high": 102.1, "low": 101.9, "close": 102.0, "volume": 2000},
            {"timestamp": "2026-01-05T09:45:00-05:00", "open": 102.0, "high": 102.1, "low": 101.9, "close": 102.0, "volume": 2000},
            {"timestamp": "2026-01-05T09:50:00-05:00", "open": 102.0, "high": 102.1, "low": 101.9, "close": 102.0, "volume": 2000},
        ],
    }

    candidates = compute_orb_candidates(
        bars_by_ticker,
        "2026-01-05",
        params,
        _enrichment_for("LEADER", "MID", "TAIL"),
    )

    assert [cand["ticker"] for cand in candidates] == ["LEADER", "MID"]


def test_gainers_leader_prefers_premarket_attention_and_abs_gap() -> None:
    params = ORBStrategyParams(
        engine_family="gainers_leader",
        weight_rvol=0.50,
        weight_gap=0.15,
        weight_dollar_vol=0.10,
        weight_premarket_dollar_vol=0.25,
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        min_abs_gap_pct=0.02,
        max_gap_pct=None,
        max_candidates=10,
        min_candidates_to_trade=1,
    )
    market_bars = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 102.0, "low": 99.8, "close": 101.8, "volume": 2000},
        {"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.8, "high": 102.2, "low": 101.7, "close": 102.0, "volume": 2000},
        {"timestamp": "2026-01-05T09:40:00-05:00", "open": 102.0, "high": 102.1, "low": 101.9, "close": 102.0, "volume": 2000},
        {"timestamp": "2026-01-05T09:45:00-05:00", "open": 102.0, "high": 102.1, "low": 101.9, "close": 102.0, "volume": 2000},
        {"timestamp": "2026-01-05T09:50:00-05:00", "open": 102.0, "high": 102.1, "low": 101.9, "close": 102.0, "volume": 2000},
    ]
    bars_by_ticker = {
        "LEADER": [
            {"timestamp": "2026-01-05T08:00:00-05:00", "open": 104.0, "high": 104.5, "low": 103.8, "close": 104.2, "volume": 20_000},
            *market_bars,
        ],
        "LAGGARD": [
            {"timestamp": "2026-01-05T08:00:00-05:00", "open": 101.0, "high": 101.2, "low": 100.8, "close": 101.1, "volume": 1_000},
            *market_bars,
        ],
    }
    enrichment = _enrichment_for("LEADER", "LAGGARD")
    enrichment["LEADER"]["2026-01-05"]["prev_close"] = 95.0
    enrichment["LAGGARD"]["2026-01-05"]["prev_close"] = 97.0

    candidates = compute_orb_candidates(
        bars_by_ticker,
        "2026-01-05",
        params,
        enrichment,
    )

    assert [cand["ticker"] for cand in candidates] == ["LEADER", "LAGGARD"]


def test_gainers_leader_can_allow_doji_followthrough_breakouts() -> None:
    params = ORBStrategyParams(
        engine_family="gainers_leader",
        entry_direction="long_only",
        allow_doji_breakout=True,
        weight_rvol=0.40,
        weight_gap=0.20,
        weight_dollar_vol=0.05,
        weight_premarket_dollar_vol=0.35,
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        min_abs_gap_pct=0.02,
        min_premarket_dollar_vol=100_000.0,
        max_gap_pct=None,
        max_candidates=5,
        min_candidates_to_trade=1,
    )
    bars_by_ticker = {
        "DOJI": [
            {"timestamp": "2026-01-05T08:15:00-05:00", "open": 103.0, "high": 103.5, "low": 102.8, "close": 103.2, "volume": 15_000},
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 105.0, "high": 106.0, "low": 104.0, "close": 105.02, "volume": 8_000},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 105.2, "high": 106.5, "low": 105.0, "close": 106.2, "volume": 6_000},
            {"timestamp": "2026-01-05T09:40:00-05:00", "open": 106.2, "high": 106.4, "low": 105.9, "close": 106.1, "volume": 3_000},
            {"timestamp": "2026-01-05T09:45:00-05:00", "open": 106.1, "high": 106.6, "low": 106.0, "close": 106.4, "volume": 2_000},
            {"timestamp": "2026-01-05T09:50:00-05:00", "open": 106.4, "high": 106.8, "low": 106.2, "close": 106.7, "volume": 2_000},
        ],
    }
    enrichment = _enrichment_for("DOJI")
    enrichment["DOJI"]["2026-01-05"]["prev_close"] = 100.0

    candidates = compute_orb_candidates(
        bars_by_ticker,
        "2026-01-05",
        params,
        enrichment,
    )

    assert [cand["ticker"] for cand in candidates] == ["DOJI"]


def test_gainers_leader_can_allow_red_to_green_followthrough_breakouts() -> None:
    params = ORBStrategyParams(
        engine_family="gainers_leader",
        entry_direction="long_only",
        allow_red_to_green_breakout=True,
        weight_rvol=0.40,
        weight_gap=0.20,
        weight_dollar_vol=0.05,
        weight_premarket_dollar_vol=0.35,
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        min_abs_gap_pct=0.02,
        min_premarket_dollar_vol=100_000.0,
        max_gap_pct=None,
        max_candidates=5,
        min_candidates_to_trade=1,
    )
    bars_by_ticker = {
        "RED_GREEN": [
            {"timestamp": "2026-01-05T08:15:00-05:00", "open": 103.0, "high": 103.5, "low": 102.8, "close": 103.2, "volume": 15_000},
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 105.0, "high": 105.4, "low": 103.8, "close": 104.3, "volume": 8_000},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 104.4, "high": 105.8, "low": 104.2, "close": 105.6, "volume": 6_000},
            {"timestamp": "2026-01-05T09:40:00-05:00", "open": 105.6, "high": 105.9, "low": 105.4, "close": 105.8, "volume": 3_000},
            {"timestamp": "2026-01-05T09:45:00-05:00", "open": 105.8, "high": 106.0, "low": 105.6, "close": 105.9, "volume": 2_000},
            {"timestamp": "2026-01-05T09:50:00-05:00", "open": 105.9, "high": 106.2, "low": 105.7, "close": 106.1, "volume": 2_000},
        ],
    }
    enrichment = _enrichment_for("RED_GREEN")
    enrichment["RED_GREEN"]["2026-01-05"]["prev_close"] = 100.0

    candidates = compute_orb_candidates(
        bars_by_ticker,
        "2026-01-05",
        params,
        enrichment,
    )

    assert [cand["ticker"] for cand in candidates] == ["RED_GREEN"]


def test_gainers_leader_can_conditionally_reject_high_gap_zscore_without_prior_trend_support() -> None:
    params = ORBStrategyParams(
        engine_family="gainers_leader",
        entry_direction="long_only",
        allow_red_to_green_breakout=True,
        conditional_gap_zscore_reject_above=2.0,
        conditional_gap_zscore_reject_ret_5d_below=0.0,
        weight_rvol=0.40,
        weight_gap=0.20,
        weight_dollar_vol=0.05,
        weight_premarket_dollar_vol=0.35,
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        min_abs_gap_pct=0.02,
        min_premarket_dollar_vol=100_000.0,
        max_gap_pct=None,
        max_candidates=5,
        min_candidates_to_trade=1,
    )
    bars = [
        {"timestamp": "2026-01-05T08:15:00-05:00", "open": 103.0, "high": 103.5, "low": 102.8, "close": 103.2, "volume": 15_000},
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 105.0, "high": 106.0, "low": 104.4, "close": 105.8, "volume": 3_000},
        {"timestamp": "2026-01-05T09:35:00-05:00", "open": 105.8, "high": 106.2, "low": 105.6, "close": 106.0, "volume": 2_000},
        {"timestamp": "2026-01-05T09:40:00-05:00", "open": 106.0, "high": 106.2, "low": 105.8, "close": 106.1, "volume": 2_000},
        {"timestamp": "2026-01-05T09:45:00-05:00", "open": 106.1, "high": 106.3, "low": 106.0, "close": 106.2, "volume": 2_000},
        {"timestamp": "2026-01-05T09:50:00-05:00", "open": 106.2, "high": 106.4, "low": 106.1, "close": 106.3, "volume": 2_000},
    ]
    enrichment = _enrichment_for("BAD_HIGH_NEG", "GOOD_HIGH_POS", "GOOD_LOW_NEG")
    for ticker in ("BAD_HIGH_NEG", "GOOD_HIGH_POS", "GOOD_LOW_NEG"):
        enrichment[ticker]["2026-01-05"]["prev_close"] = 100.0
    enrichment["BAD_HIGH_NEG"]["2026-01-05"].update({"gap_zscore_20d": 2.5, "ret_5d": -0.03})
    enrichment["GOOD_HIGH_POS"]["2026-01-05"].update({"gap_zscore_20d": 2.5, "ret_5d": 0.08})
    enrichment["GOOD_LOW_NEG"]["2026-01-05"].update({"gap_zscore_20d": 1.2, "ret_5d": -0.03})

    candidates = compute_orb_candidates(
        {"BAD_HIGH_NEG": bars, "GOOD_HIGH_POS": bars, "GOOD_LOW_NEG": bars},
        "2026-01-05",
        params,
        enrichment,
    )

    assert [cand["ticker"] for cand in candidates] == ["GOOD_HIGH_POS", "GOOD_LOW_NEG"]


def test_dual_regime_requires_actual_event_flag_and_filters_event_types() -> None:
    params = ORBStrategyParams(
        engine_family="stocks_in_play_dual_regime",
        entry_direction="candle",
        require_event_flag=True,
        allowed_event_types=["other_material_event"],
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        min_close_location=0.6,
        weight_event_catalyst=1.0,
        max_candidates=5,
        min_candidates_to_trade=1,
    )
    bars = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 105.0, "high": 107.0, "low": 104.9, "close": 106.8, "volume": 5000, "vwap": 106.1},
        {"timestamp": "2026-01-05T09:35:00-05:00", "open": 106.8, "high": 107.2, "low": 106.7, "close": 107.0, "volume": 3000, "vwap": 106.9},
        {"timestamp": "2026-01-05T09:40:00-05:00", "open": 107.0, "high": 107.2, "low": 106.8, "close": 107.1, "volume": 2000, "vwap": 107.0},
        {"timestamp": "2026-01-05T09:45:00-05:00", "open": 107.1, "high": 107.3, "low": 106.9, "close": 107.2, "volume": 2000, "vwap": 107.1},
        {"timestamp": "2026-01-05T09:50:00-05:00", "open": 107.2, "high": 107.4, "low": 107.0, "close": 107.3, "volume": 2000, "vwap": 107.2},
    ]
    enrichment = _enrichment_for("GOOD", "WRONG_TYPE", "NO_EVENT")
    for ticker in ("GOOD", "WRONG_TYPE", "NO_EVENT"):
        enrichment[ticker]["2026-01-05"]["prev_close"] = 100.0
    enrichment["GOOD"]["2026-01-05"]["event_flag"] = True
    enrichment["GOOD"]["2026-01-05"]["event_types"] = ["other_material_event"]
    enrichment["GOOD"]["2026-01-05"]["event_score"] = 1.0
    enrichment["WRONG_TYPE"]["2026-01-05"]["event_flag"] = True
    enrichment["WRONG_TYPE"]["2026-01-05"]["event_types"] = ["management_change"]
    enrichment["WRONG_TYPE"]["2026-01-05"]["event_score"] = 0.6

    candidates = compute_orb_candidates(
        {"GOOD": bars, "WRONG_TYPE": bars, "NO_EVENT": bars},
        "2026-01-05",
        params,
        enrichment,
    )

    assert [cand["ticker"] for cand in candidates] == ["GOOD"]


def test_dual_regime_can_take_failed_gap_up_short_below_vwap() -> None:
    params = ORBStrategyParams(
        engine_family="stocks_in_play_dual_regime",
        entry_direction="candle",
        require_event_flag=True,
        allow_failed_orb_short=True,
        require_vwap_confirmation=True,
        max_close_location_short=0.40,
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        max_candidates=5,
        min_candidates_to_trade=1,
    )
    bars_by_ticker = {
        "FAILED": [
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 105.0, "high": 105.3, "low": 103.8, "close": 104.0, "volume": 6000, "vwap": 104.7},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 104.0, "high": 104.2, "low": 103.6, "close": 103.8, "volume": 3000, "vwap": 103.9},
            {"timestamp": "2026-01-05T09:40:00-05:00", "open": 103.8, "high": 104.0, "low": 103.5, "close": 103.7, "volume": 2000, "vwap": 103.8},
            {"timestamp": "2026-01-05T09:45:00-05:00", "open": 103.7, "high": 103.9, "low": 103.4, "close": 103.5, "volume": 2000, "vwap": 103.6},
            {"timestamp": "2026-01-05T09:50:00-05:00", "open": 103.5, "high": 103.7, "low": 103.2, "close": 103.3, "volume": 2000, "vwap": 103.4},
        ],
    }
    enrichment = _enrichment_for("FAILED")
    enrichment["FAILED"]["2026-01-05"]["prev_close"] = 100.0
    enrichment["FAILED"]["2026-01-05"]["event_flag"] = True
    enrichment["FAILED"]["2026-01-05"]["event_types"] = ["other_material_event"]
    enrichment["FAILED"]["2026-01-05"]["event_score"] = 1.0

    candidates = compute_orb_candidates(
        bars_by_ticker,
        "2026-01-05",
        params,
        enrichment,
    )

    assert [cand["ticker"] for cand in candidates] == ["FAILED"]
    assert candidates[0]["direction"] == "bearish"


def test_gainers_leader_can_override_min_gap_for_high_attention_small_gap_names() -> None:
    params = ORBStrategyParams(
        engine_family="gainers_leader",
        entry_direction="long_only",
        allow_red_to_green_breakout=True,
        min_abs_gap_pct=0.02,
        small_gap_attention_override_premarket_dollar_vol=1_000_000.0,
        small_gap_attention_override_rvol=1.5,
        weight_rvol=0.40,
        weight_gap=0.20,
        weight_dollar_vol=0.05,
        weight_premarket_dollar_vol=0.35,
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        min_premarket_dollar_vol=100_000.0,
        max_gap_pct=None,
        max_candidates=5,
        min_candidates_to_trade=1,
    )
    bars_by_ticker = {
        "ATTN_SMALL_GAP": [
            {"timestamp": "2026-01-05T08:15:00-05:00", "open": 100.5, "high": 101.0, "low": 100.4, "close": 100.8, "volume": 20_000},
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 101.0, "high": 101.1, "low": 99.8, "close": 100.4, "volume": 3_500},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 100.5, "high": 101.6, "low": 100.4, "close": 101.5, "volume": 3_000},
            {"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.5, "high": 101.7, "low": 101.3, "close": 101.6, "volume": 2_000},
            {"timestamp": "2026-01-05T09:45:00-05:00", "open": 101.6, "high": 101.8, "low": 101.5, "close": 101.7, "volume": 2_000},
            {"timestamp": "2026-01-05T09:50:00-05:00", "open": 101.7, "high": 101.9, "low": 101.6, "close": 101.8, "volume": 2_000},
        ],
    }
    enrichment = _enrichment_for("ATTN_SMALL_GAP")
    enrichment["ATTN_SMALL_GAP"]["2026-01-05"]["prev_close"] = 100.5
    enrichment["ATTN_SMALL_GAP"]["2026-01-05"]["avg_daily_vol_14d"] = 100_000.0

    candidates = compute_orb_candidates(
        bars_by_ticker,
        "2026-01-05",
        params,
        enrichment,
    )

    assert [cand["ticker"] for cand in candidates] == ["ATTN_SMALL_GAP"]


def test_gainers_leader_can_cap_small_gap_attention_override_names_per_day() -> None:
    params = ORBStrategyParams(
        engine_family="gainers_leader",
        entry_direction="long_only",
        allow_red_to_green_breakout=True,
        min_abs_gap_pct=0.02,
        small_gap_attention_override_premarket_dollar_vol=1_000_000.0,
        small_gap_attention_override_rvol=1.5,
        max_small_gap_attention_candidates=1,
        weight_rvol=1.0,
        weight_gap=0.0,
        weight_dollar_vol=0.0,
        weight_premarket_dollar_vol=0.0,
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        min_premarket_dollar_vol=100_000.0,
        max_gap_pct=None,
        max_candidates=5,
        min_candidates_to_trade=1,
    )
    bars_by_ticker = {
        "ATTN1": [
            {"timestamp": "2026-01-05T08:15:00-05:00", "open": 100.5, "high": 101.0, "low": 100.4, "close": 100.8, "volume": 20_000},
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 101.0, "high": 101.1, "low": 99.8, "close": 100.4, "volume": 4_500},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 100.5, "high": 101.6, "low": 100.4, "close": 101.5, "volume": 3_000},
            {"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.5, "high": 101.7, "low": 101.3, "close": 101.6, "volume": 2_000},
            {"timestamp": "2026-01-05T09:45:00-05:00", "open": 101.6, "high": 101.8, "low": 101.5, "close": 101.7, "volume": 2_000},
            {"timestamp": "2026-01-05T09:50:00-05:00", "open": 101.7, "high": 101.9, "low": 101.6, "close": 101.8, "volume": 2_000},
        ],
        "ATTN2": [
            {"timestamp": "2026-01-05T08:15:00-05:00", "open": 100.5, "high": 101.0, "low": 100.4, "close": 100.8, "volume": 20_000},
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 101.0, "high": 101.1, "low": 99.8, "close": 100.4, "volume": 3_500},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 100.5, "high": 101.6, "low": 100.4, "close": 101.5, "volume": 3_000},
            {"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.5, "high": 101.7, "low": 101.3, "close": 101.6, "volume": 2_000},
            {"timestamp": "2026-01-05T09:45:00-05:00", "open": 101.6, "high": 101.8, "low": 101.5, "close": 101.7, "volume": 2_000},
            {"timestamp": "2026-01-05T09:50:00-05:00", "open": 101.7, "high": 101.9, "low": 101.6, "close": 101.8, "volume": 2_000},
        ],
        "BIG_GAP": [
            {"timestamp": "2026-01-05T08:15:00-05:00", "open": 103.0, "high": 103.5, "low": 102.8, "close": 103.2, "volume": 15_000},
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 105.0, "high": 106.0, "low": 104.4, "close": 105.8, "volume": 3_000},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 105.8, "high": 106.2, "low": 105.6, "close": 106.0, "volume": 2_000},
            {"timestamp": "2026-01-05T09:40:00-05:00", "open": 106.0, "high": 106.2, "low": 105.8, "close": 106.1, "volume": 2_000},
            {"timestamp": "2026-01-05T09:45:00-05:00", "open": 106.1, "high": 106.3, "low": 106.0, "close": 106.2, "volume": 2_000},
            {"timestamp": "2026-01-05T09:50:00-05:00", "open": 106.2, "high": 106.4, "low": 106.1, "close": 106.3, "volume": 2_000},
        ],
    }
    enrichment = _enrichment_for("ATTN1", "ATTN2", "BIG_GAP")
    enrichment["ATTN1"]["2026-01-05"]["prev_close"] = 100.5
    enrichment["ATTN1"]["2026-01-05"]["avg_daily_vol_14d"] = 100_000.0
    enrichment["ATTN2"]["2026-01-05"]["prev_close"] = 100.5
    enrichment["ATTN2"]["2026-01-05"]["avg_daily_vol_14d"] = 100_000.0
    enrichment["BIG_GAP"]["2026-01-05"]["prev_close"] = 100.0

    candidates = compute_orb_candidates(
        bars_by_ticker,
        "2026-01-05",
        params,
        enrichment,
    )

    assert [cand["ticker"] for cand in candidates] == ["BIG_GAP", "ATTN1"]


def test_leader_followthrough_requires_strong_close_location_for_red_to_green() -> None:
    params = ORBStrategyParams(
        engine_family="leader_followthrough",
        entry_direction="long_only",
        allow_red_to_green_breakout=True,
        min_close_location=0.55,
        weight_rvol=0.30,
        weight_gap=0.05,
        weight_dollar_vol=0.10,
        weight_premarket_dollar_vol=0.35,
        weight_close_location=0.20,
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        min_abs_gap_pct=0.005,
        min_premarket_dollar_vol=100_000.0,
        max_gap_pct=None,
        max_candidates=5,
        min_candidates_to_trade=1,
    )
    bars_by_ticker = {
        "STRONG_RECLAIM": [
            {"timestamp": "2026-01-05T08:15:00-05:00", "open": 102.0, "high": 102.4, "low": 101.8, "close": 102.3, "volume": 18_000},
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 105.0, "high": 105.4, "low": 103.8, "close": 104.85, "volume": 8_000},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 104.9, "high": 105.8, "low": 104.8, "close": 105.7, "volume": 6_000},
            {"timestamp": "2026-01-05T09:40:00-05:00", "open": 105.7, "high": 106.0, "low": 105.5, "close": 105.9, "volume": 3_000},
            {"timestamp": "2026-01-05T09:45:00-05:00", "open": 105.9, "high": 106.1, "low": 105.8, "close": 106.0, "volume": 2_000},
            {"timestamp": "2026-01-05T09:50:00-05:00", "open": 106.0, "high": 106.2, "low": 105.8, "close": 106.1, "volume": 2_000},
        ],
        "WEAK_RECLAIM": [
            {"timestamp": "2026-01-05T08:15:00-05:00", "open": 102.0, "high": 102.4, "low": 101.8, "close": 102.3, "volume": 18_000},
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 105.0, "high": 105.4, "low": 103.8, "close": 104.2, "volume": 8_000},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 104.2, "high": 104.6, "low": 104.0, "close": 104.3, "volume": 4_000},
            {"timestamp": "2026-01-05T09:40:00-05:00", "open": 104.3, "high": 104.5, "low": 104.1, "close": 104.4, "volume": 2_000},
            {"timestamp": "2026-01-05T09:45:00-05:00", "open": 104.4, "high": 104.5, "low": 104.2, "close": 104.4, "volume": 2_000},
            {"timestamp": "2026-01-05T09:50:00-05:00", "open": 104.4, "high": 104.6, "low": 104.2, "close": 104.5, "volume": 2_000},
        ],
    }
    enrichment = _enrichment_for("STRONG_RECLAIM", "WEAK_RECLAIM")
    enrichment["STRONG_RECLAIM"]["2026-01-05"]["prev_close"] = 104.0
    enrichment["WEAK_RECLAIM"]["2026-01-05"]["prev_close"] = 104.0

    candidates = compute_orb_candidates(
        bars_by_ticker,
        "2026-01-05",
        params,
        enrichment,
    )

    assert [cand["ticker"] for cand in candidates] == ["STRONG_RECLAIM"]


def test_leader_followthrough_ranking_rewards_close_location() -> None:
    params = ORBStrategyParams(
        engine_family="leader_followthrough",
        entry_direction="long_only",
        allow_red_to_green_breakout=True,
        min_close_location=0.0,
        weight_rvol=0.0,
        weight_gap=0.0,
        weight_dollar_vol=0.0,
        weight_premarket_dollar_vol=0.0,
        weight_close_location=1.0,
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        min_abs_gap_pct=0.005,
        min_premarket_dollar_vol=100_000.0,
        max_gap_pct=None,
        max_candidates=5,
        min_candidates_to_trade=1,
    )
    bars_by_ticker = {
        "HIGH_CLOSE": [
            {"timestamp": "2026-01-05T08:15:00-05:00", "open": 102.0, "high": 102.2, "low": 101.9, "close": 102.1, "volume": 12_000},
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 105.0, "high": 105.4, "low": 103.8, "close": 104.85, "volume": 8_000},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 104.95, "high": 105.8, "low": 104.9, "close": 105.7, "volume": 5_000},
            {"timestamp": "2026-01-05T09:40:00-05:00", "open": 105.7, "high": 105.9, "low": 105.5, "close": 105.8, "volume": 2_000},
            {"timestamp": "2026-01-05T09:45:00-05:00", "open": 105.8, "high": 105.9, "low": 105.6, "close": 105.8, "volume": 2_000},
            {"timestamp": "2026-01-05T09:50:00-05:00", "open": 105.8, "high": 106.0, "low": 105.6, "close": 105.9, "volume": 2_000},
        ],
        "LOW_CLOSE": [
            {"timestamp": "2026-01-05T08:15:00-05:00", "open": 102.0, "high": 102.2, "low": 101.9, "close": 102.1, "volume": 12_000},
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 105.0, "high": 105.4, "low": 103.8, "close": 104.3, "volume": 8_000},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 104.3, "high": 104.8, "low": 104.2, "close": 104.5, "volume": 5_000},
            {"timestamp": "2026-01-05T09:40:00-05:00", "open": 104.5, "high": 104.7, "low": 104.3, "close": 104.6, "volume": 2_000},
            {"timestamp": "2026-01-05T09:45:00-05:00", "open": 104.6, "high": 104.7, "low": 104.4, "close": 104.6, "volume": 2_000},
            {"timestamp": "2026-01-05T09:50:00-05:00", "open": 104.6, "high": 104.8, "low": 104.4, "close": 104.7, "volume": 2_000},
        ],
    }
    enrichment = _enrichment_for("HIGH_CLOSE", "LOW_CLOSE")
    enrichment["HIGH_CLOSE"]["2026-01-05"]["prev_close"] = 104.0
    enrichment["LOW_CLOSE"]["2026-01-05"]["prev_close"] = 104.0

    candidates = compute_orb_candidates(
        bars_by_ticker,
        "2026-01-05",
        params,
        enrichment,
    )

    assert [cand["ticker"] for cand in candidates] == ["HIGH_CLOSE", "LOW_CLOSE"]


def test_vwap_reclaim_ranking_can_prefer_weaker_opening_structure() -> None:
    params = ORBStrategyParams(
        engine_family="vwap_reclaim_v1",
        entry_direction="long_only",
        allow_red_to_green_breakout=True,
        weight_rvol=0.0,
        weight_gap=0.0,
        weight_dollar_vol=0.0,
        weight_premarket_dollar_vol=0.0,
        weight_close_location=-1.0,
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        min_abs_gap_pct=0.005,
        min_premarket_dollar_vol=100_000.0,
        max_gap_pct=None,
        max_candidates=5,
        min_candidates_to_trade=1,
    )
    bars_by_ticker = {
        "DEEP_PULLBACK": [
            {"timestamp": "2026-01-05T08:15:00-05:00", "open": 102.0, "high": 102.4, "low": 101.8, "close": 102.3, "volume": 18_000},
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 105.0, "high": 105.3, "low": 103.9, "close": 104.1, "volume": 8_000},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 104.2, "high": 104.8, "low": 104.0, "close": 104.6, "volume": 4_000},
            {"timestamp": "2026-01-05T09:40:00-05:00", "open": 104.6, "high": 104.8, "low": 104.4, "close": 104.7, "volume": 2_000},
            {"timestamp": "2026-01-05T09:45:00-05:00", "open": 104.7, "high": 104.9, "low": 104.6, "close": 104.8, "volume": 2_000},
            {"timestamp": "2026-01-05T09:50:00-05:00", "open": 104.8, "high": 105.0, "low": 104.7, "close": 104.9, "volume": 2_000},
        ],
        "STRONG_OPEN": [
            {"timestamp": "2026-01-05T08:15:00-05:00", "open": 102.0, "high": 102.4, "low": 101.8, "close": 102.3, "volume": 18_000},
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 105.0, "high": 105.5, "low": 104.7, "close": 105.4, "volume": 8_000},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 105.4, "high": 105.8, "low": 105.2, "close": 105.7, "volume": 4_000},
            {"timestamp": "2026-01-05T09:40:00-05:00", "open": 105.7, "high": 105.9, "low": 105.5, "close": 105.8, "volume": 2_000},
            {"timestamp": "2026-01-05T09:45:00-05:00", "open": 105.8, "high": 106.0, "low": 105.7, "close": 105.9, "volume": 2_000},
            {"timestamp": "2026-01-05T09:50:00-05:00", "open": 105.9, "high": 106.1, "low": 105.8, "close": 106.0, "volume": 2_000},
        ],
    }
    enrichment = _enrichment_for("DEEP_PULLBACK", "STRONG_OPEN")
    enrichment["DEEP_PULLBACK"]["2026-01-05"]["prev_close"] = 104.0
    enrichment["STRONG_OPEN"]["2026-01-05"]["prev_close"] = 104.0

    candidates = compute_orb_candidates(
        bars_by_ticker,
        "2026-01-05",
        params,
        enrichment,
    )

    assert [cand["ticker"] for cand in candidates] == ["DEEP_PULLBACK", "STRONG_OPEN"]


def test_orb_pullback_ranking_can_use_event_catalyst_score() -> None:
    params = ORBStrategyParams(
        engine_family="orb_pullback_v1",
        entry_direction="long_only",
        weight_rvol=0.0,
        weight_gap=0.0,
        weight_dollar_vol=0.0,
        weight_premarket_dollar_vol=0.0,
        weight_event_catalyst=1.0,
        min_price=10.0,
        min_avg_dollar_volume=0.0,
        min_atr_14=0.1,
        min_rvol=0.1,
        min_abs_gap_pct=0.005,
        min_premarket_dollar_vol=100_000.0,
        max_gap_pct=None,
        max_candidates=5,
        min_candidates_to_trade=1,
    )
    bars_by_ticker = {
        "PLAIN": [
            {"timestamp": "2026-01-05T08:15:00-05:00", "open": 102.0, "high": 102.3, "low": 101.9, "close": 102.2, "volume": 12_000},
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 105.0, "high": 105.5, "low": 104.4, "close": 105.2, "volume": 8_000},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 105.2, "high": 105.8, "low": 105.1, "close": 105.6, "volume": 5_000},
            {"timestamp": "2026-01-05T09:40:00-05:00", "open": 105.6, "high": 105.8, "low": 105.4, "close": 105.7, "volume": 2_000},
            {"timestamp": "2026-01-05T09:45:00-05:00", "open": 105.7, "high": 105.9, "low": 105.6, "close": 105.8, "volume": 2_000},
            {"timestamp": "2026-01-05T09:50:00-05:00", "open": 105.8, "high": 106.0, "low": 105.7, "close": 105.9, "volume": 2_000},
        ],
        "EVENTFUL": [
            {"timestamp": "2026-01-05T08:15:00-05:00", "open": 102.0, "high": 102.3, "low": 101.9, "close": 102.2, "volume": 12_000},
            {"timestamp": "2026-01-05T09:30:00-05:00", "open": 105.0, "high": 105.5, "low": 104.4, "close": 105.2, "volume": 8_000},
            {"timestamp": "2026-01-05T09:35:00-05:00", "open": 105.2, "high": 105.8, "low": 105.1, "close": 105.6, "volume": 5_000},
            {"timestamp": "2026-01-05T09:40:00-05:00", "open": 105.6, "high": 105.8, "low": 105.4, "close": 105.7, "volume": 2_000},
            {"timestamp": "2026-01-05T09:45:00-05:00", "open": 105.7, "high": 105.9, "low": 105.6, "close": 105.8, "volume": 2_000},
            {"timestamp": "2026-01-05T09:50:00-05:00", "open": 105.8, "high": 106.0, "low": 105.7, "close": 105.9, "volume": 2_000},
        ],
    }
    enrichment = _enrichment_for("PLAIN", "EVENTFUL")
    enrichment["PLAIN"]["2026-01-05"]["prev_close"] = 104.0
    enrichment["EVENTFUL"]["2026-01-05"]["prev_close"] = 104.0
    enrichment["PLAIN"]["2026-01-05"]["event_score"] = 0.0
    enrichment["EVENTFUL"]["2026-01-05"]["event_flag"] = True
    enrichment["EVENTFUL"]["2026-01-05"]["event_score"] = 3.0

    candidates = compute_orb_candidates(
        bars_by_ticker,
        "2026-01-05",
        params,
        enrichment,
    )

    assert [cand["ticker"] for cand in candidates] == ["EVENTFUL", "PLAIN"]


def test_vwap_reclaim_trade_waits_for_prior_dip_then_enters_on_reclaim() -> None:
    bars = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 105.0, "high": 105.2, "low": 103.8, "close": 104.0, "volume": 1_000},
        {"timestamp": "2026-01-05T09:35:00-05:00", "open": 104.0, "high": 104.2, "low": 103.6, "close": 103.8, "volume": 1_000},
        {"timestamp": "2026-01-05T09:40:00-05:00", "open": 103.8, "high": 104.0, "low": 103.5, "close": 103.7, "volume": 1_000},
        {"timestamp": "2026-01-05T09:45:00-05:00", "open": 103.7, "high": 103.9, "low": 103.6, "close": 103.8, "volume": 1_000},
        {"timestamp": "2026-01-05T09:50:00-05:00", "open": 103.8, "high": 104.8, "low": 103.7, "close": 104.7, "volume": 1_500},
        {"timestamp": "2026-01-05T09:55:00-05:00", "open": 104.7, "high": 105.0, "low": 104.5, "close": 104.9, "volume": 1_200},
        {"timestamp": "2026-01-05T15:55:00-05:00", "open": 104.9, "high": 105.1, "low": 104.8, "close": 105.0, "volume": 1_000},
    ]
    params = ORBStrategyParams(
        engine_family="vwap_reclaim_v1",
        orb_minutes=5,
        sim_bar_minutes=5,
        vwap_reclaim_window_start_min=20,
        vwap_reclaim_window_end_min=120,
        vwap_reclaim_require_prior_dip=True,
        vwap_reclaim_min_clearance_pct=0.0,
        atr_stop_multiplier=10.0,
        trailing_at_r=99.0,
        breakeven_at_r=99.0,
        slippage_bps=0.0,
    )

    trade = simulate_orb_trade(
        bars,
        bars[0],
        "long",
        atr=1.0,
        rvol=2.0,
        gap_pct=0.05,
        params=params,
        equity=10_000.0,
        date_str="2026-01-05",
        ticker="VWAP",
    )

    assert trade is not None
    assert trade.entry_time == "2026-01-05T09:50:00-05:00"
    assert trade.entry_price == pytest.approx(104.7)


def test_vwap_reclaim_trade_can_require_orb_open_retake_and_rel_volume() -> None:
    bars = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 105.0, "high": 105.2, "low": 103.8, "close": 104.0, "volume": 1_000},
        {"timestamp": "2026-01-05T09:35:00-05:00", "open": 104.0, "high": 104.2, "low": 103.6, "close": 103.8, "volume": 1_000},
        {"timestamp": "2026-01-05T09:40:00-05:00", "open": 103.8, "high": 104.0, "low": 103.5, "close": 103.7, "volume": 1_000},
        {"timestamp": "2026-01-05T09:45:00-05:00", "open": 103.7, "high": 103.9, "low": 103.6, "close": 103.8, "volume": 1_000},
        {"timestamp": "2026-01-05T09:50:00-05:00", "open": 103.8, "high": 104.9, "low": 103.7, "close": 104.7, "volume": 1_000},
        {"timestamp": "2026-01-05T09:55:00-05:00", "open": 104.7, "high": 105.4, "low": 104.6, "close": 105.2, "volume": 1_500},
        {"timestamp": "2026-01-05T15:55:00-05:00", "open": 105.2, "high": 105.4, "low": 105.1, "close": 105.3, "volume": 1_000},
    ]
    params = ORBStrategyParams(
        engine_family="vwap_reclaim_v1",
        orb_minutes=5,
        sim_bar_minutes=5,
        vwap_reclaim_window_start_min=20,
        vwap_reclaim_window_end_min=120,
        vwap_reclaim_require_prior_dip=True,
        vwap_reclaim_min_clearance_pct=0.0,
        vwap_reclaim_require_orb_open_retake=True,
        vwap_reclaim_confirm_rel_vol=1.2,
        atr_stop_multiplier=10.0,
        trailing_at_r=99.0,
        breakeven_at_r=99.0,
        slippage_bps=0.0,
    )

    trade = simulate_orb_trade(
        bars,
        bars[0],
        "long",
        atr=1.0,
        rvol=2.0,
        gap_pct=0.05,
        params=params,
        equity=10_000.0,
        date_str="2026-01-05",
        ticker="VWAP",
    )

    assert trade is not None
    assert trade.entry_time == "2026-01-05T09:55:00-05:00"
    assert trade.entry_price == pytest.approx(105.2)


def test_pullback_trade_waits_for_breakout_retake_and_reclaim_volume() -> None:
    bars = [
        {"timestamp": "2026-01-05T09:30:00-05:00", "open": 105.0, "high": 106.0, "low": 104.8, "close": 105.8, "volume": 1_000},
        {"timestamp": "2026-01-05T09:35:00-05:00", "open": 105.8, "high": 106.3, "low": 105.7, "close": 106.1, "volume": 1_500},
        {"timestamp": "2026-01-05T09:40:00-05:00", "open": 106.1, "high": 106.8, "low": 106.0, "close": 106.6, "volume": 1_300},
        {"timestamp": "2026-01-05T09:45:00-05:00", "open": 106.6, "high": 106.7, "low": 105.8, "close": 106.0, "volume": 700},
        {"timestamp": "2026-01-05T09:50:00-05:00", "open": 106.0, "high": 106.2, "low": 105.9, "close": 106.05, "volume": 700},
        {"timestamp": "2026-01-05T09:55:00-05:00", "open": 106.05, "high": 106.5, "low": 106.0, "close": 106.35, "volume": 1_600},
        {"timestamp": "2026-01-05T15:55:00-05:00", "open": 106.35, "high": 106.5, "low": 106.2, "close": 106.4, "volume": 1_000},
    ]
    params = ORBStrategyParams(
        engine_family="orb_pullback_v1",
        orb_minutes=5,
        sim_bar_minutes=5,
        pullback_entry=True,
        pullback_max_bars=6,
        pullback_min_retracement_pct=0.25,
        pullback_require_breakout_retake=True,
        pullback_breakout_retake_clearance_pct=0.002,
        pullback_reclaim_confirm_rel_vol=1.2,
        atr_stop_multiplier=10.0,
        trailing_at_r=99.0,
        breakeven_at_r=99.0,
        slippage_bps=0.0,
    )

    trade = simulate_orb_trade(
        bars,
        bars[0],
        "long",
        atr=1.0,
        rvol=2.0,
        gap_pct=0.05,
        params=params,
        equity=10_000.0,
        date_str="2026-01-05",
        ticker="PULL",
    )

    assert trade is not None
    assert trade.entry_time == "2026-01-05T09:55:00-05:00"
    assert trade.entry_price == pytest.approx(106.35)
