"""Domain models for the Morning Momentum Intraday Backtester.

All models use Pydantic for validation and serialization.
No dependencies on the existing backtest system.
"""
from __future__ import annotations

from typing import Any

from pydantic import BaseModel, Field


# ── Strategy Parameters ────────────────────────────────────────────────────


class StrategyParams(BaseModel):
    """Core strategy parameters controlling when to enter/exit."""

    compound_returns: bool = False
    """When True, position sizing scales with current equity (compounding).
    When False, position sizing uses min(initial_capital, current_equity) (simple returns,
    capped at actual equity to avoid trading money that doesn't exist after drawdowns).
    Momentum research defaults to simple returns to avoid late-period overweighting.
    Ignored when daily_budget_reset is True."""

    daily_budget_reset: bool = False
    """Research-only mode: every day resets sizing_capital and buying power to
    initial_capital, so prior-day PnL does not compound into position budget.
    Stateful strategy governors still apply when explicitly configured.
    When True, takes precedence over compound_returns."""

    entry_minutes_after_open: int = 30
    """Minutes after 9:30 AM ET to evaluate morning gainers and enter trades."""

    confirmation_minutes_after_entry: int = 0
    """Optional extra confirmation delay after the primary entry time.
    Example: 5 means evaluate leaders at +10min but only enter at +15min if
    the confirmation rule is still satisfied."""

    min_confirmation_return_pct: float | None = None
    """Minimum return between the primary entry bar close and the confirmation
    bar close. Example: 0.0 = no fade allowed; 0.002 = require +0.2% follow-through.
    Ignored when confirmation_minutes_after_entry <= 0."""

    exit_minutes_before_close: int = 30
    """Minutes before 4:00 PM ET to force-close all positions."""

    stop_loss_pct: float | None = -0.02
    """Fixed stop-loss threshold (e.g. -0.02 = -2%). None to disable."""

    trailing_stop_pct: float | None = None
    """Trailing stop: if set, ratchet stop up as price rises. e.g. -0.03 = trail 3% below peak.
    When both stop_loss_pct and trailing_stop_pct are set, trailing_stop_pct is used."""

    atr_stop_multiplier: float | None = None
    """Catastrophic stop distance in ATR(14) units from the actual entry price.
    Example: 0.5 means stop at entry - 0.5 x ATR. When set, this overrides
    stop_loss_pct as the initial stop anchor."""

    opening_range_stop_multiplier: float | None = None
    """Catastrophic stop distance in opening-range-width units from the actual
    entry price. Example: 1.0 means stop at entry - 1.0 x opening range width.
    Used as an alternative to ATR when the opening range itself is the better
    volatility proxy."""

    trailing_activation_gain_pct: float | None = None
    """Optional delayed trailing activation threshold.
    When set, trailing_stop_pct does not turn on until peak return from entry
    reaches this gain threshold. Before activation, only the catastrophic /
    fixed stop is active."""

    overextended_trailing_gain_pct: float | None = None
    """If set together with overextended_trailing_stop_pct, trades whose morning gain
    at entry is at least this large use the alternate trailing stop instead of the
    baseline trailing_stop_pct."""

    overextended_trailing_stop_pct: float | None = None
    """Alternate trailing stop for overextended morning leaders.
    Example: base trail -0.075 with overextended trail -0.065 tightens risk only for
    names already up sharply by entry time."""

    min_morning_gain_pct: float = 0.01
    """Minimum gain from open to entry time for a stock to qualify (e.g. 0.01 = 1%)."""

    max_morning_gain_pct: float | None = None
    """Maximum morning gain allowed (e.g. 0.10 = 10%). Filters out extreme gap-ups
    that tend to mean-revert quickly. None = no cap."""

    min_entry_volume: int | None = None
    """Minimum cumulative volume by entry time (shares). Filters illiquid stocks.
    E.g. 50000 = must have traded 50K shares in first 30 minutes."""

    min_entry_dollar_volume: float | None = None
    """Minimum cumulative dollar volume by entry time.
    Helps reject low-priced names that pass a raw share-volume filter but still
    trade too little notional size for reliable execution."""

    ticker_cooldown_days: int = 0
    """Blackout period after trading a ticker (calendar days).
    E.g. 5 = same ticker can't be selected again within 5 days. 0 = disabled."""

    top_n: int = 3
    """Number of top gainers to buy each day (equal-weight allocation)."""

    min_positions_to_trade: int = 1
    """Minimum number of qualified picks required to trade the day at all.
    Event-driven leader strategies often degrade when only one or two names pass.
    Use this to explicitly allow no-trade days instead of forcing sparse baskets."""

    max_positions_per_sector: int | None = None
    """Optional basket diversification cap.
    When set, at most this many positions may be opened from the same sector
    in the day's momentum basket. Unknown sectors are left uncapped."""

    full_size_positions_threshold: int | None = None
    """When set, sparse days scale down total deployed capital instead of always
    using the full daily book. Example: threshold=4 means 1-3 position days are
    sized below 100% of the daily budget, while 4+ position days use full size."""

    sparse_day_size_floor: float = 1.0
    """Minimum day-level size scaler when full_size_positions_threshold is active.
    0.5 means even a 1-position day still deploys 50% of the normal daily budget."""

    initial_capital: float = 10_000.0
    """Starting capital in USD."""

    slippage_bps: float = 5.0
    """One-way slippage in basis points (applied to both entry and exit fills)."""

    market_regime_spy_threshold: float | None = None
    """Skip trading if SPY's morning return (open to entry time) is below this threshold.
    E.g. -0.005 = skip if SPY is down more than -0.5% by entry time. None = disabled."""

    market_regime_gap_threshold: float | None = None
    """Skip trading if regime ticker's opening gap versus prior close is below this threshold.
    This is a day-level guard that remains available even when the regime ticker is not
    part of the intraday candidate set."""

    market_regime_gap_ticker: str = "SPY"
    """Ticker used for the day-level opening-gap regime check. Default 'SPY'."""

    min_candidate_breadth: float | None = None
    """Skip the day if fewer than this fraction of intraday candidates opened above
    their prior close. None = disabled."""

    min_gap_pct: float | None = None
    """Minimum opening gap versus prior close. None = disabled."""

    max_gap_pct: float | None = None
    """Maximum opening gap versus prior close. None = disabled."""

    min_volume_ratio_14d: float | None = None
    """Minimum cumulative volume by entry time divided by 14-day average daily volume.
    Helps reject low-attention names that are up but not truly in play."""

    min_ret_5d: float | None = None
    """Minimum prior 5-day return. None = disabled."""

    min_entropy_20d: float | None = None
    """Minimum allowed entropy(20d). None = disabled."""

    max_entropy_20d: float | None = None
    """Maximum allowed entropy(20d). None = disabled."""

    max_vix: float | None = None
    """Skip the whole day if VIX closes above this threshold. None = disabled."""

    vix_size_scale_low: float | None = None
    """VIX level where size scaling starts. None = disabled."""

    vix_size_scale_high: float | None = None
    """VIX level where the day-size scaler reaches vix_size_scale_min."""

    vix_size_scale_min: float = 1.0
    """Minimum position-size scaler once VIX reaches vix_size_scale_high."""

    regime_size_scale_low: float | None = None
    """Regime gap at which the day-size scaler bottoms out. None = disabled."""

    regime_size_scale_high: float | None = None
    """Regime gap at which the day-size scaler returns to 1.0."""

    regime_size_scale_min: float = 1.0
    """Minimum regime day-size scaler once regime_size_scale_low is breached."""

    regime_skip_below: float | None = None
    """Hard skip floor below regime_size_scale_low. None = no extra skip."""

    soft_day_fallback_on_regime_skip: bool = False
    """When True, a legacy market-regime skip can become a small soft-day
    fallback instead of a full no-trade day. Disabled by default to preserve
    existing strategy behavior."""

    soft_day_regime_skip_size_scale: float = 1.0
    """Day-level size scale used when soft_day_fallback_on_regime_skip converts
    a market-regime skip into a fallback trading day."""

    breadth_size_scale_low: float | None = None
    """Breadth ratio at which the day-size scaler bottoms out. None = disabled."""

    breadth_size_scale_high: float | None = None
    """Breadth ratio at which the day-size scaler returns to 1.0."""

    breadth_size_scale_min: float = 1.0
    """Minimum breadth day-size scaler once breadth_size_scale_low is breached."""

    breadth_skip_below: float | None = None
    """Hard skip floor on candidate breadth. None = no extra skip."""

    sector_concentration_scale_low: float | None = None
    """Sector concentration ratio where basket-level size scaling starts.

    Concentration is measured as max_sector_count / selected_count using only
    selected picks with known sectors.
    """

    sector_concentration_scale_high: float | None = None
    """Sector concentration ratio where the basket-level sector scaler reaches
    sector_concentration_scale_min."""

    sector_concentration_scale_min: float = 1.0
    """Minimum basket-level sector-concentration scaler."""

    soft_day_scaler_threshold: float = 1.0
    """Days whose combined regime/breadth scaler falls below this threshold are treated
    as soft days for extra basket throttles."""

    soft_day_max_trades: int | None = None
    """Maximum number of trades allowed on soft days. None = no extra cap."""

    soft_day_sparse_max_trades: int | None = None
    """Optional extra scaler for sparse baskets on soft days.

    When set, the soft-day sparse defense only considers days whose final
    selected basket size is at or below this count.
    """

    soft_day_sparse_require_no_event: bool = False
    """When True, do not apply the soft-day sparse scaler if the basket already
    contains a supported event-qualified name."""

    soft_day_sparse_exempt_largecap: bool = False
    """When True, do not apply the soft-day sparse scaler when the basket
    includes a liquid large-cap candidate."""

    soft_day_sparse_exempt_moderate_gap_liquid: bool = False
    """When True, do not apply the soft-day sparse scaler when the basket
    includes a moderate-gap liquid follow-through candidate."""

    soft_day_sparse_scale: float = 1.0
    """Extra day-size scaler applied to sparse soft-day baskets."""

    event_sleeve_soft_day_max_trades: int | None = None
    """When set, only enable the soft-day event sleeve if the pre-event basket
    has at most this many selected names."""

    event_sleeve_soft_day_max_avg_quality: float | None = None
    """When set, only enable the soft-day event sleeve if the pre-event basket's
    average quality score is at or below this threshold."""

    event_sleeve_soft_day_require_no_existing_event: bool = False
    """When True, only enable the soft-day event sleeve if the pre-event basket
    does not already contain an event-qualified pick."""

    tail_risk_day_max_trades: int | None = None
    """Optional meta-layer for sparse, high-extension basket risk.

    When set, the tail-risk defense only considers days whose final basket size
    is at or below this count.
    """

    tail_risk_day_min_max_gain_pct: float | None = None
    """Minimum maximum morning gain required to trigger the tail-risk day defense."""

    tail_risk_day_max_avg_quality: float | None = None
    """Maximum average basket quality allowed to trigger the tail-risk day defense."""

    tail_risk_day_max_support_score: float | None = None
    """Maximum same-day support score allowed to trigger the tail-risk defense.

    Support blends prior liquidity, entry-time liquidity, and same-day
    catalyst/attention proxies. Lower values indicate thin, weakly supported
    moves more prone to failure on sparse single-name days.
    """

    tail_risk_day_min_max_entropy_20d: float | None = None
    """Minimum highest selected entropy_20d required to trigger the tail-risk defense."""

    tail_risk_day_min_max_confirmation_return_pct: float | None = None
    """Minimum highest selected confirmation return required to trigger the tail-risk defense."""

    tail_risk_day_require_no_event: bool = False
    """When True, the tail-risk day defense only triggers if no selected pick has
    an event-qualified catalyst."""

    tail_risk_day_event_exemption_min_support_score: float | None = None
    """Minimum support score required for an event-qualified pick to exempt the
    day from tail-risk defense.

    This prevents weak catalysts on thin, single-name days from disabling the
    sparse-day defense merely because an event flag exists.
    """

    tail_risk_day_exempt_largecap: bool = False
    """When True, skip the tail-risk day defense whenever the selected basket
    contains a liquid large-cap candidate."""

    tail_risk_day_scale: float = 1.0
    """Minimum extra day-size scaler applied when the tail-risk defense triggers."""

    low_momentum_single_name_max_gain_pct: float | None = None
    """Scale sparse single-name days when the only pick has weak morning gain.

    This catches low-conviction continuation attempts that are not high-extension
    tail-risk days but still concentrate the full day budget in one marginal name.
    """

    low_momentum_single_name_require_no_event: bool = False
    """When True, do not apply the low-momentum single-name scaler if the pick
    has a supported event-qualified catalyst."""

    low_momentum_single_name_exempt_largecap: bool = False
    """When True, do not apply the low-momentum single-name scaler to liquid
    large-cap candidates."""

    low_momentum_single_name_scale: float = 1.0
    """Extra day-size scaler applied to low-momentum single-name days."""

    basket_quality_relative_floor: float | None = None
    """Optional dynamic floor applied after basket selection.

    When set, keep only picks whose day-level quality score is at least this
    fraction of the best selected pick's score. This turns a fixed top-N basket
    into an adaptive basket that can shrink on weak tail names.
    """

    basket_quality_min_count: int = 0
    """Minimum number of picks to keep even when basket_quality_relative_floor prunes
    weak tail names. 0 means no forced minimum beyond surviving the floor."""

    basket_quality_prune_blend_only: bool = False
    """When True, apply the basket quality floor only to 'blend' tail fills and keep
    sleeve-forced picks intact. This is useful when the goal is to stop overfilling
    weak residual names without undoing the intended sleeve diversification."""

    rolling_loss_days: int | None = None
    """Lookback window for a self-referential rolling loss pause. None = disabled."""

    rolling_loss_threshold: float | None = None
    """Pause trading when the strategy's rolling return over rolling_loss_days drops
    below this threshold. None = disabled."""

    entropy_size_scale_low: float | None = None
    """Entropy level where trade-size scaling starts. None = disabled."""

    entropy_size_scale_high: float | None = None
    """Entropy level where the trade-size scaler reaches entropy_size_scale_min."""

    entropy_size_scale_min: float = 1.0
    """Minimum per-trade size scaler once entropy reaches entropy_size_scale_high."""

    use_five_sleeves: bool = False
    """When True, build the daily basket from five sleeve rankings instead of one raw top-N list.
    Sleeves: core gain, gap, volume surprise, low entropy, and prior trend."""

    momentum_selection_mode: str = "standard"
    """How to build the execution basket from eligible momentum candidates.

    - standard: existing sleeve/blend selection path
    - liquid_continuation: prioritize moderate-gap liquid, liquid large-cap,
      and sector breadth-confirmed continuation names in the core basket
    """

    five_sleeve_force_count: int = 5
    """How many sleeve-specific picks to force before the weighted blend fill starts.
    5 preserves the original behavior of taking one pick from each sleeve.
    Lower values let the weighted blend dominate sooner and reduce quota-style overfitting."""

    five_sleeve_core_weight: float = 0.40
    """Blend weight for the core momentum sleeve (morning gain)."""

    five_sleeve_gap_weight: float = 0.20
    """Blend weight for the opening-gap sleeve."""

    five_sleeve_volume_weight: float = 0.25
    """Blend weight for the volume-surprise sleeve."""

    five_sleeve_entropy_weight: float = 0.05
    """Blend weight for the low-entropy sleeve."""

    five_sleeve_trend_weight: float = 0.10
    """Blend weight for the prior-trend sleeve."""

    use_event_sleeve: bool = False
    """When True, allow a dedicated same-day filing catalyst sleeve.

    This sleeve only changes basket selection among already-tradable
    intraday names; it does not bypass the base execution filters.
    """

    event_weight: float = 0.0
    """Blend weight for the same-day filing catalyst sleeve."""

    event_min_score: float | None = None
    """Minimum same-day filing event score required for event-sleeve eligibility."""

    event_sleeve_soft_day_only: bool = False
    """Enable the event sleeve only on soft days.

    Soft-day detection reuses the existing regime/breadth/sector scaler
    machinery so event-driven substitutions only happen when the normal
    basket looks weak.
    """

    use_slow_ignite_sleeve: bool = False
    """When True, allow one extra sleeve for slower-starting but still high-attention leaders.
    This is intended for names that are not yet above the primary morning-gain floor by the
    standard entry time, but are showing strong confirmation, liquidity, and prior trend."""

    slow_ignite_weight: float = 0.0
    """Blend weight for the slow-ignite sleeve. Ignored when use_slow_ignite_sleeve is False."""

    slow_ignite_min_gain_pct: float | None = None
    """Lower gain floor for slow-ignite candidates. Typically below min_morning_gain_pct."""

    slow_ignite_max_gain_pct: float | None = None
    """Upper gain cap for slow-ignite candidates. Keeps the sleeve focused on slower starters."""

    slow_ignite_min_entry_dollar_volume: float | None = None
    """Minimum entry-time dollar volume required for slow-ignite candidates."""

    slow_ignite_min_volume_ratio_14d: float | None = None
    """Minimum entry-time volume ratio required for slow-ignite candidates."""

    slow_ignite_min_ret_5d: float | None = None
    """Minimum prior 5-day return required for slow-ignite candidates."""

    slow_ignite_max_entropy_20d: float | None = None
    """Maximum entropy allowed for slow-ignite candidates."""

    use_liquid_largecap_sleeve: bool = False
    """When True, allow a dedicated sleeve for liquid large-cap leaders.
    This is designed for slower large-cap continuation names that may not rank highly
    in the small/midcap-oriented momentum sleeves despite strong notional liquidity."""

    liquid_largecap_weight: float = 0.0
    """Blend weight for the liquid large-cap sleeve. Ignored when disabled."""

    liquid_largecap_min_gain_pct: float | None = None
    """Lower gain floor for liquid large-cap candidates."""

    liquid_largecap_max_gain_pct: float | None = None
    """Upper gain cap for liquid large-cap candidates."""

    liquid_largecap_min_confirmation_return_pct: float | None = None
    """Minimum follow-through required for liquid large-cap candidates."""

    liquid_largecap_min_entry_dollar_volume: float | None = None
    """Minimum entry-time dollar volume required for liquid large-cap candidates."""

    liquid_largecap_min_avg_dollar_vol_30d: float | None = None
    """Minimum prior 30-day average daily dollar volume required for liquid large-cap candidates."""

    liquid_largecap_max_entropy_20d: float | None = None
    """Maximum entropy allowed for liquid large-cap candidates."""

    use_moderate_gap_liquid_sleeve: bool = False
    """When True, enable a dedicated sleeve for moderate-gap liquid follow-through names.

    This targets names that are too moderate to win the raw gap/momentum rank,
    but have enough same-day follow-through and institutional liquidity to be
    distinct from thin small-cap attention spikes.
    """

    moderate_gap_liquid_weight: float = 0.0
    """Blend weight for the moderate-gap liquid follow-through sleeve."""

    moderate_gap_liquid_min_gap_pct: float | None = None
    """Minimum opening gap for moderate-gap liquid candidates."""

    moderate_gap_liquid_max_gap_pct: float | None = None
    """Maximum opening gap for moderate-gap liquid candidates."""

    moderate_gap_liquid_min_gain_pct: float | None = None
    """Minimum entry-time gain for moderate-gap liquid candidates."""

    moderate_gap_liquid_max_gain_pct: float | None = None
    """Maximum entry-time gain for moderate-gap liquid candidates."""

    moderate_gap_liquid_min_confirmation_return_pct: float | None = None
    """Minimum confirmation-bar follow-through for moderate-gap liquid candidates."""

    moderate_gap_liquid_min_entry_dollar_volume: float | None = None
    """Minimum entry-time cumulative dollar volume for moderate-gap liquid candidates."""

    moderate_gap_liquid_min_avg_dollar_vol_30d: float | None = None
    """Minimum prior 30-day average daily dollar volume for moderate-gap liquid candidates."""

    moderate_gap_liquid_max_avg_dollar_vol_30d: float | None = None
    """Optional upper bound on prior 30-day dollar volume.

    This keeps the sleeve from duplicating the liquid-largecap sleeve when the
    intended target is mid/liquid follow-through names such as TER/CRDO/CDNS/CPNG.
    """

    moderate_gap_liquid_min_volume_ratio_14d: float | None = None
    """Minimum entry-time volume ratio for moderate-gap liquid candidates."""

    moderate_gap_liquid_min_atr_pct: float | None = None
    """Minimum ATR(14) divided by today's open for moderate-gap liquid candidates."""

    moderate_gap_liquid_max_entropy_20d: float | None = None
    """Maximum entropy allowed for moderate-gap liquid candidates."""

    use_sector_thrust_sleeve: bool = False
    """When True, enable a sector breadth-confirmed thrust sleeve.

    This is a PEAD-style synthetic breadth idea adapted to intraday momentum:
    the sleeve only boosts names whose own early trend is supported by multiple
    same-sector leaders showing synchronous confirmation and liquidity.
    """

    sector_thrust_weight: float = 0.0
    """Blend weight for the sector breadth-confirmed thrust sleeve."""

    sector_thrust_min_members: int = 2
    """Minimum number of same-sector names that must pass the thrust gate."""

    sector_thrust_min_gain_pct: float | None = None
    """Minimum morning gain required for a ticker to contribute to sector thrust."""

    sector_thrust_min_confirmation_return_pct: float | None = None
    """Minimum confirmation return required for sector thrust contributors."""

    sector_thrust_min_entry_dollar_volume: float | None = None
    """Minimum entry-time dollar volume required for sector thrust contributors."""

    sector_thrust_min_avg_dollar_vol_30d: float | None = None
    """Minimum prior 30-day average dollar volume required for sector thrust contributors."""

    sector_thrust_min_sector_avg_confirmation_return_pct: float | None = None
    """Minimum average confirmation return across same-sector contributors."""

    sector_thrust_min_sector_total_entry_dollar_volume: float | None = None
    """Minimum total entry-time dollar volume across same-sector contributors."""

    use_liquid_cluster_engine: bool = False
    """When True, enable a separate post-allocation liquid-cluster stock engine.

    Unlike sector_thrust, this does not change the main basket rank. It uses a
    reserved fraction of the day budget to add a small number of liquid,
    same-sector follow-through names after the core basket is selected.
    """

    liquid_cluster_capital_fraction: float = 0.0
    """Fraction of the day budget reserved for the liquid-cluster engine."""

    liquid_cluster_max_positions: int = 0
    """Maximum number of liquid-cluster stock positions to add."""

    liquid_cluster_max_positions_per_sector: int = 1
    """Maximum number of liquid-cluster stock picks per sector."""

    liquid_cluster_min_members: int = 2
    """Minimum number of same-sector names required to activate a cluster."""

    liquid_cluster_min_gain_pct: float | None = None
    """Minimum morning gain required for a name to contribute to a liquid cluster."""

    liquid_cluster_max_gain_pct: float | None = None
    """Maximum morning gain allowed for liquid-cluster contributors."""

    liquid_cluster_min_confirmation_return_pct: float | None = None
    """Minimum confirmation return required for liquid-cluster contributors."""

    liquid_cluster_min_entry_dollar_volume: float | None = None
    """Minimum entry-time dollar volume required for liquid-cluster contributors."""

    liquid_cluster_min_avg_dollar_vol_30d: float | None = None
    """Minimum prior 30-day average dollar volume required for liquid-cluster contributors."""

    liquid_cluster_max_avg_dollar_vol_30d: float | None = None
    """Optional upper bound on prior 30-day average dollar volume for cluster contributors."""

    liquid_cluster_min_volume_ratio_14d: float | None = None
    """Minimum entry-time volume ratio required for liquid-cluster contributors."""

    liquid_cluster_max_entropy_20d: float | None = None
    """Maximum entropy allowed for liquid-cluster contributors."""

    liquid_cluster_min_sector_avg_confirmation_return_pct: float | None = None
    """Minimum average confirmation return across the activated liquid cluster."""

    liquid_cluster_min_sector_total_entry_dollar_volume: float | None = None
    """Minimum combined entry-time dollar volume across the activated liquid cluster."""

    liquid_cluster_require_special_liquidity_gate: bool = False
    """When True, contributors must already qualify as moderate-gap liquid or liquid large-cap."""

    use_event_day_liquid_sleeve: bool = False
    """When True, activate a separate post-allocation liquid continuation sleeve on event-backed days.

    This engine does not alter the core basket rank. It reserves a small slice
    of the day budget to add liquid continuation names only when at least one
    approved same-day event is also visible in the morning tape.
    """

    event_day_liquid_capital_fraction: float = 0.0
    """Fraction of the day budget reserved for the event-day liquid sleeve."""

    event_day_liquid_max_positions: int = 0
    """Maximum number of event-day liquid continuation names to add."""

    event_day_liquid_soft_day_only: bool = False
    """Only activate the event-day liquid sleeve on soft days."""

    event_day_liquid_min_event_names: int = 1
    """Minimum number of event-backed morning names required to activate the sleeve."""

    event_day_liquid_allowed_event_types: list[str] = Field(default_factory=list)
    """Optional event types used only for event-day sleeve activation.

    When empty, activation reuses the filtered event state already applied to
    the core momentum strategy. When set, activation can see a broader set of
    raw filing types without contaminating the core event sleeves.
    """

    event_day_liquid_min_event_score: float | None = None
    """Minimum same-day event score required for activation contributors."""

    event_day_liquid_min_event_support_score: float | None = None
    """Minimum support score required for activation contributors."""

    event_day_liquid_min_total_event_entry_dollar_volume: float | None = None
    """Minimum combined entry-time dollar volume across activation contributors."""

    event_day_liquid_min_gain_pct: float | None = None
    """Minimum morning gain required for added liquid continuation names."""

    event_day_liquid_max_gain_pct: float | None = None
    """Maximum morning gain allowed for added liquid continuation names."""

    event_day_liquid_min_confirmation_return_pct: float | None = None
    """Minimum confirmation return required for added liquid continuation names."""

    event_day_liquid_min_entry_dollar_volume: float | None = None
    """Minimum entry-time dollar volume required for added liquid continuation names."""

    event_day_liquid_min_avg_dollar_vol_30d: float | None = None
    """Minimum prior 30-day average dollar volume required for added liquid names."""

    event_day_liquid_max_entropy_20d: float | None = None
    """Maximum 20-day entropy allowed for added liquid continuation names."""

    event_day_liquid_min_support_score: float | None = None
    """Minimum blended support score required for added liquid continuation names."""

    use_sector_etf_sleeve: bool = False
    """When True, allow a post-allocation sector ETF proxy sleeve.

    This sleeve uses the same activated liquid-cluster sectors, but deploys a
    reserved capital slice into sector ETFs instead of additional single-name
    positions.
    """

    sector_etf_capital_fraction: float = 0.0
    """Fraction of the day budget reserved for the sector ETF sleeve."""

    sector_etf_max_positions: int = 1
    """Maximum number of sector ETF proxy positions to add."""

    sector_etf_min_sector_score: float | None = None
    """Minimum liquid-cluster sector score required for ETF sleeve activation."""

    use_gap_reclaim_sleeve: bool = False
    """Enable a high-gap reclaim sleeve for early flushes that stabilize below the open."""

    gap_reclaim_weight: float = 0.0
    """Blend weight for the high-gap reclaim sleeve."""

    gap_reclaim_min_gap_pct: float | None = None
    """Minimum opening gap required for a high-gap reclaim candidate."""

    gap_reclaim_min_gain_pct: float | None = None
    """Minimum allowed entry-time gain for the reclaim sleeve (can be negative)."""

    gap_reclaim_max_gain_pct: float | None = None
    """Maximum allowed entry-time gain for the reclaim sleeve."""

    gap_reclaim_min_confirmation_return_pct: float | None = None
    """Minimum confirmation-bar return required for the reclaim sleeve."""

    gap_reclaim_min_entry_dollar_volume: float | None = None
    """Minimum entry-time cumulative dollar volume required for the reclaim sleeve."""

    gap_reclaim_min_recovery_from_opening_low_pct: float | None = None
    """Minimum rebound from the opening-range low required for the reclaim sleeve."""

    fallback_liquid_largecap_slots: int = 0
    """Number of liquid large-cap fallback seats available after regular selection.

    This does not change the main basket on normal days. It only allows a small
    number of highly liquid large-cap names to fill otherwise sparse baskets.
    """

    fallback_liquid_largecap_trigger_below: int = 0
    """Enable the liquid large-cap fallback only when regular picks are below this count.

    Example: 2 means "only consider fallback seats when the main selection
    found fewer than 2 names."
    """

    candidate_source_mode: str = "daily_gap"
    """How to build the candidate universe before the final basket is selected.

    - daily_gap: existing point-in-time opening-gap shortlist from daily bars
    - intraday_first: build a broader daily seed list, fetch intraday for that
      seed, then rank the final candidate shortlist using same-day entry-time
      information only (still lookahead-free).
    """

    candidate_seed_threshold: float = 0.0
    """Opening-gap threshold used only for the broader seed list when
    candidate_source_mode='intraday_first'. Lower values widen the intraday
    fetch universe without using same-day highs/closes."""

    candidate_seed_max_per_day: int = 150
    """Maximum seed shortlist size per day when candidate_source_mode is
    'intraday_first'. This bounds intraday fetch cost before the final
    entry-time rerank."""

    candidate_seed_liquid_overlay_slots: int = 0
    """Optional number of extra prior-day liquid large-cap seeds to add per day.

    This is designed for names like TSLA/AVGO/NVDA that may not clear the main
    opening-gap seed threshold but still deserve intraday-first evaluation
    because of exceptional prior-day liquidity.
    """

    candidate_seed_liquid_min_gap_pct: float | None = None
    """Minimum opening gap for the liquid overlay seed list.

    Uses today's open vs prior close only, so it remains lookahead-free.
    """

    candidate_seed_liquid_max_gap_pct: float | None = None
    """Maximum opening gap for the liquid overlay seed list."""

    candidate_seed_liquid_min_avg_dollar_vol_30d: float | None = None
    """Minimum prior 30-day average dollar volume required for overlay seeds."""

    candidate_seed_liquid_min_ret_5d: float | None = None
    """Minimum prior 5-day return required for overlay seeds."""

    candidate_seed_liquid_max_entropy_20d: float | None = None
    """Maximum prior 20-day entropy allowed for overlay seeds."""

    candidate_seed_leader_overlay_slots: int = 0
    """Optional number of extra liquid trend-leader seeds to add per day.

    Unlike the liquid gap overlay, this path is meant to catch strong same-day
    continuation names that did not gap enough to enter the main seed list but
    already have exceptional prior trend, volatility, and liquidity.
    """

    candidate_seed_leader_min_gap_pct: float | None = None
    """Minimum opening gap allowed for the trend-leader overlay."""

    candidate_seed_leader_max_gap_pct: float | None = None
    """Maximum opening gap allowed for the trend-leader overlay."""

    candidate_seed_leader_min_avg_dollar_vol_30d: float | None = None
    """Minimum prior 30-day average dollar volume for trend-leader overlay seeds."""

    candidate_seed_leader_min_ret_5d: float | None = None
    """Minimum prior 5-day return required for trend-leader overlay seeds."""

    candidate_seed_leader_min_atr_pct: float | None = None
    """Minimum ATR/open ratio required for trend-leader overlay seeds."""

    candidate_seed_leader_max_entropy_20d: float | None = None
    """Maximum prior 20-day entropy allowed for trend-leader overlay seeds."""

    candidate_seed_moderate_liquid_overlay_slots: int = 0
    """Number of extra moderate-gap liquid follow-through seeds to append per day.

    This is intentionally separate from the base candidate rank. It widens the
    intraday fetch set only for a bounded sleeve-specific profile, rather than
    diluting the main gapper seed list.
    """

    candidate_seed_moderate_liquid_min_gap_pct: float | None = None
    """Minimum opening gap for moderate-liquid overlay seeds."""

    candidate_seed_moderate_liquid_max_gap_pct: float | None = None
    """Maximum opening gap for moderate-liquid overlay seeds."""

    candidate_seed_moderate_liquid_min_avg_dollar_vol_30d: float | None = None
    """Minimum prior 30-day average dollar volume for moderate-liquid overlay seeds."""

    candidate_seed_moderate_liquid_max_avg_dollar_vol_30d: float | None = None
    """Optional upper bound on prior 30-day dollar volume for moderate-liquid seeds."""

    candidate_seed_moderate_liquid_min_ret_5d: float | None = None
    """Minimum prior 5-day return for moderate-liquid overlay seeds."""

    candidate_seed_moderate_liquid_max_entropy_20d: float | None = None
    """Maximum prior 20-day entropy for moderate-liquid overlay seeds."""

    candidate_seed_event_overlay_slots: int = 0
    """Number of actual filing-catalyst names to force into the seed shortlist.

    This is a candidate-stage overlay, not a final-rank weight. It exists to
    make sure same-day catalyst names are present in the intraday fetch set
    even when pure gap/ret/entropy seed ranking would miss them.
    """

    candidate_seed_event_min_score: float | None = None
    """Minimum same-day filing event score required for the event overlay."""

    candidate_seed_event_min_gap_pct: float | None = None
    """Minimum opening gap for event-overlay names."""

    candidate_seed_event_max_gap_pct: float | None = None
    """Maximum opening gap for event-overlay names."""

    candidate_seed_event_min_avg_dollar_vol_30d: float | None = None
    """Minimum prior 30-day average dollar volume for event-overlay names."""

    candidate_seed_event_min_ret_5d: float | None = None
    """Minimum prior 5-day return for event-overlay names."""

    candidate_seed_event_max_entropy_20d: float | None = None
    """Maximum prior 20-day entropy allowed for event-overlay names."""

    candidate_seed_ownership_overlay_slots: int = 0
    """Number of PIT-safe 13D/13G ownership-event names to add to the seed list.

    This is a bounded candidate-stage overlay. It only makes eligible ownership
    names available to the intraday engine; final ORB structure, ranking, and
    risk gates still decide whether a trade is taken.
    """

    candidate_seed_ownership_initial_only: bool = True
    """When True, ownership seed overlay only accepts initial-owner filings."""

    candidate_seed_ownership_min_strength_score: float | None = None
    """Minimum ownership_strength_score required for ownership seed overlay."""

    candidate_seed_ownership_min_gap_pct: float | None = None
    """Minimum opening gap for ownership-overlay names."""

    candidate_seed_ownership_max_gap_pct: float | None = None
    """Maximum opening gap for ownership-overlay names."""

    candidate_seed_ownership_min_avg_dollar_vol_30d: float | None = None
    """Minimum prior 30-day dollar volume for ownership-overlay names."""

    candidate_seed_ownership_min_ret_5d: float | None = None
    """Minimum prior 5-day return for ownership-overlay names."""

    candidate_seed_ownership_max_entropy_20d: float | None = None
    """Maximum prior 20-day entropy for ownership-overlay names."""

    candidate_final_max_per_day: int = 30
    """Final candidate shortlist size per day after intraday-first reranking."""

    candidate_intraday_rank_mode: str = "sleeves"
    """Final intraday-first shortlist ranking mode.

    - sleeves: reuse the same five-sleeve / top-N basket logic used by the
      execution engine, but at a wider candidate cutoff.
    - weighted: rank entry-time candidates with a weighted quality score using
      only same-day information known by the entry/confirmation bar.
    """

    candidate_intraday_weight_gain: float = 0.0
    """Weighted-mode contribution from same-day morning gain at entry."""

    candidate_intraday_weight_confirmation: float = 0.0
    """Weighted-mode contribution from confirmation-bar follow-through."""

    candidate_intraday_weight_volume_ratio: float = 0.0
    """Weighted-mode contribution from entry-time volume ratio vs 14-day ADV."""

    candidate_intraday_weight_entry_dollar_volume: float = 0.0
    """Weighted-mode contribution from entry-time cumulative dollar volume."""

    candidate_intraday_weight_avg_dollar_vol_30d: float = 0.0
    """Weighted-mode contribution from prior 30-day average dollar volume."""

    candidate_intraday_weight_support_score: float = 0.0
    """Weighted-mode contribution from same-day blended support score."""

    candidate_intraday_weight_liquid_largecap: float = 0.0
    """Weighted-mode contribution from qualifying as a liquid large-cap name."""

    candidate_intraday_weight_moderate_gap_liquid: float = 0.0
    """Weighted-mode contribution from qualifying as a moderate-gap liquid name."""

    candidate_intraday_weight_gap: float = 0.0
    """Weighted-mode contribution from opening gap vs prior close."""

    candidate_intraday_weight_ret_5d: float = 0.0
    """Weighted-mode contribution from prior 5-day return."""

    candidate_intraday_weight_low_entropy: float = 0.0
    """Weighted-mode contribution from lower 20-day entropy."""

    candidate_intraday_weight_sector_thrust: float = 0.0
    """Weighted-mode contribution from sector breadth-confirmed thrust."""

    candidate_intraday_weight_event_score: float = 0.0
    """Weighted-mode contribution from same-day filing/event score."""

    candidate_intraday_weight_attention_wiki: float = 0.0
    """Weighted-mode contribution from same-day wiki attention."""

    candidate_intraday_weight_attention_news: float = 0.0
    """Weighted-mode contribution from same-day news/article attention."""

    candidate_intraday_event_reserve_slots: int = 0
    """Number of same-day actual catalyst names to reserve inside the final shortlist.

    Unlike candidate_seed_event_overlay_slots, this does not expand the
    shortlist. It replaces weak tail picks inside candidate_final_max_per_day.
    """

    candidate_intraday_event_reserve_min_score: float | None = None
    """Minimum same-day filing event score required for reserve-slot eligibility."""

    candidate_intraday_event_reserve_soft_day_only: bool = False
    """Apply the final-shortlist catalyst reserve only on soft days."""

    candidate_intraday_moderate_liquid_reserve_slots: int = 0
    """Number of moderate-gap liquid names to reserve inside the final intraday shortlist.

    This reserve is applied after normal intraday ranking and replaces weak
    non-event tail names. It ensures the downstream sleeve can see qualified
    moderate-liquid follow-through names without changing the base rank formula.
    """

    candidate_intraday_moderate_liquid_reserve_trigger_below: int = 0
    """Only apply moderate-liquid final reserve when the base shortlist would
    produce fewer than this many execution picks. 0 means always allow reserve
    when candidate_intraday_moderate_liquid_reserve_slots > 0.
    """

    recent_live_scan_days: int = 0
    """When > 0, very recent backtests (window length <= this many trading days and ending
    within this many calendar days of the latest completed backtest date) bypass the static
    universe + daily pre-screen path and instead use a broad screener universe with
    intraday-first candidate generation. Designed for same-day / recent sanity checks where
    names like current Yahoo top gainers may not exist in the static research universe."""

    recent_live_scan_min_price: float = 2.0
    """Minimum price for the recent live screener universe."""

    recent_live_scan_avg_volume_min: int = 200_000
    """Minimum 3-month average volume for the recent live screener universe."""

    recent_live_scan_market_cap_min: float = 100_000_000.0
    """Minimum market cap for the recent live screener universe."""

    recent_live_scan_max_candidates_per_day: int = 150
    """Maximum daily shortlist size produced by the intraday-first recent scan."""

    recent_live_scan_top_n: int | None = None
    """Optional top-N override used only for recent live-scan windows."""

    recent_live_scan_min_morning_gain_pct: float | None = None
    """Optional morning-gain floor override used only for recent live-scan windows."""

    recent_live_scan_max_morning_gain_pct: float | None = None
    """Optional morning-gain cap override used only for recent live-scan windows."""

    recent_live_scan_min_confirmation_return_pct: float | None = None
    """Optional confirmation-return override used only for recent live-scan windows."""

    recent_live_scan_min_entry_dollar_volume: float | None = None
    """Optional entry dollar-volume override used only for recent live-scan windows."""

    recent_live_scan_max_gap_pct: float | None = None
    """Optional opening-gap cap override used only for recent live-scan windows."""

    recent_live_scan_max_entropy_20d: float | None = None
    """Optional entropy cap override used only for recent live-scan windows."""

    recent_live_scan_use_slow_ignite_sleeve: bool | None = None
    """Optional recent-window override for enabling the slow-ignite sleeve."""

    recent_live_scan_slow_ignite_weight: float | None = None
    """Optional recent-window override for slow-ignite sleeve weight."""

    recent_live_scan_slow_ignite_min_gain_pct: float | None = None
    """Optional recent-window override for slow-ignite minimum gain."""

    recent_live_scan_slow_ignite_max_gain_pct: float | None = None
    """Optional recent-window override for slow-ignite maximum gain."""

    recent_live_scan_slow_ignite_min_entry_dollar_volume: float | None = None
    """Optional recent-window override for slow-ignite minimum entry dollar volume."""

    recent_live_scan_slow_ignite_max_entropy_20d: float | None = None
    """Optional recent-window override for slow-ignite maximum entropy."""

    recent_live_scan_use_liquid_largecap_sleeve: bool | None = None
    """Optional recent-window override for enabling the liquid large-cap sleeve."""

    recent_live_scan_liquid_largecap_weight: float | None = None
    """Optional recent-window override for liquid large-cap sleeve weight."""

    recent_live_scan_liquid_largecap_min_gain_pct: float | None = None
    """Optional recent-window override for liquid large-cap minimum gain."""

    recent_live_scan_liquid_largecap_max_gain_pct: float | None = None
    """Optional recent-window override for liquid large-cap maximum gain."""

    recent_live_scan_liquid_largecap_min_confirmation_return_pct: float | None = None
    """Optional recent-window override for liquid large-cap confirmation return."""

    recent_live_scan_liquid_largecap_min_entry_dollar_volume: float | None = None
    """Optional recent-window override for liquid large-cap minimum entry dollar volume."""

    recent_live_scan_liquid_largecap_min_avg_dollar_vol_30d: float | None = None
    """Optional recent-window override for liquid large-cap minimum average dollar volume."""

    recent_live_scan_liquid_largecap_max_entropy_20d: float | None = None
    """Optional recent-window override for liquid large-cap maximum entropy."""

    candidate_require_event_flag: bool = False
    """When True, the daily candidate shortlist only includes names with a same-day
    filing-based catalyst flag. This is stricter than using attention proxies alone
    and is intended for event-driven research variants."""

    candidate_min_event_score: float | None = None
    """Minimum same-day filing event score required at the candidate stage.
    Ignored when no same-day event features are present."""

    candidate_allowed_event_types: list[str] = Field(default_factory=list)
    """Optional same-day filing event types allowed at the candidate stage.

    When non-empty, candidate-stage catalyst gates only treat these filing
    types as valid. This lets momentum variants use actual catalysts such as
    earnings/material events while excluding weaker attention-like filings.
    """

    candidate_weight_event_score: float = 0.0
    """Ranking weight for same-day filing event score in momentum candidate selection."""

    candidate_weight_attention_wiki: float = 0.0
    """Ranking weight for same-day Wikipedia attention in momentum candidate selection."""

    candidate_weight_attention_news: float = 0.0
    """Ranking weight for same-day news/article attention in momentum candidate selection."""

    candidate_min_attention_wiki_spike_10d: float | None = None
    """Minimum same-day wiki spike required at the candidate stage."""

    candidate_min_attention_article_count_3d: int | None = None
    """Minimum same-day 3-day article count required at the candidate stage."""

    candidate_min_attention_us_article_count_3d: int | None = None
    """Minimum same-day 3-day US article count required at the candidate stage."""

    candidate_min_attention_resolver_confidence: float | None = None
    """Minimum same-day entity resolver confidence required at the candidate stage."""


class ORBStrategyParams(BaseModel):
    """Parameters for the Opening Range Breakout (ORB) strategy."""

    engine_family: str = "quality_breakout"
    """Candidate engine family: classic_breakout | quality_breakout | compression_breakout | gainers_leader | leader_followthrough | stocks_in_play_dual_regime | orb_pullback_v1 | vwap_reclaim_v1 | hypergap_failure_v1.
    quality_breakout is the backward-compatible default because it supports the
    existing body-ratio / momentum extensions while leaving them disabled at 0 weight."""

    live_readiness: str = "live_ready"
    """Research classification for the signal family: live_ready | research_only."""

    # ORB window
    orb_minutes: int = 5
    """Duration of the opening range in minutes. 5 = first 5-min candle (9:30–9:35 ET)."""

    sim_bar_minutes: int = 5
    """Bar interval for breakout detection and stop management after the ORB candle.
    5 = use raw 5-min bars (default). 30 = aggregate to 30-min bars (more realistic, fewer whipsaws).
    The ORB candle itself always uses the first 5-min bar regardless of this setting."""

    # Entry
    entry_direction: str = "long_only"
    """Entry direction filter: 'long_only' (bullish candle only), 'candle' (both), 'both' (always)."""

    order_timeout_minutes: int = 45
    """Cancel unfilled breakout order after this many minutes from open. Default = 45min = 10:15 ET."""

    live_breakout_use_bar_high: bool = False
    """LIVE-ONLY toggle (no effect on backtest, which already uses bar high).

    When True, the live ORB engine's run_breakout_check additionally fetches today's
    5-min intraday bars and triggers an entry if any post-ORB bar's high (low for
    short) crossed the breakout level inside order_timeout_minutes — even if the
    current snapshot price has since retraced below the level. This catches
    spike-and-retrace breakouts that the default snapshot-only check misses
    (e.g., ARM 2026-05-07 09:35 ET high 232.19 with retrace to 224 by 09:40).

    Entry price is still the snapshot price (market order), so a retraced fill
    will be lower than backtest's assumed entry at max(breakout_level, bar.open).
    Stops are placed relative to the actual fill, so live R-multiples may diverge
    from backtest R-multiples on the same trade. The engine logs
    `breakout_level - fill_price` on each bar-high-triggered entry for auditability.
    """

    # Universe quality filters (applied during ORB pre-screening)
    min_price: float = 10.0
    """Minimum stock price. $10 is the ORB paper's practical minimum."""

    min_avg_dollar_volume: float = 25_000_000.0
    """Minimum 30-day average daily dollar volume ($25M). Ensures sufficient liquidity."""

    min_atr_14: float = 0.50
    """Minimum ATR(14) in dollars ($0.50). Ensures sufficient intraday range to trade."""

    min_atr_pct: float | None = None
    """Minimum ATR(14) as a fraction of prev_close (e.g. 0.04 = 4%). Filters out
    low-volatility names where the ORB setup lacks explosive follow-through potential.
    None disables (legacy behavior)."""

    max_atr_pct: float | None = None
    """Maximum ATR(14) as a fraction of prev_close (e.g. 0.10 = 10%). Caps extreme-volatility
    names that have large individual losses (MSTR, crypto stocks, micro-caps). None disables."""

    # RVOL-based candidate selection
    min_rvol: float | None = 1.0
    """Minimum approximate RVOL at open. RVOL = first_5min_vol / (avg_daily_vol / 78).
    Note: this is an approximation — actual morning volume is 2–3× uniform rate,
    so calibrate relative to that systematic bias. None disables the filter."""

    iex_live_intraday_volume_multiplier: float = 1.0
    """LIVE-ONLY multiplier for IEX 5-minute intraday volume-derived filters.

    Alpaca IEX bars usually report a small fraction of consolidated SIP volume,
    while ORB research thresholds are calibrated on SIP-like historical bars.
    Values above 1.0 scale IEX opening RVOL, first-bar dollar volume, and
    premarket dollar volume for live filter decisions only. Stored diagnostics
    keep the raw IEX values.
    """

    max_candidates: int = 20
    """Maximum candidates to pass to intraday fetch and simulate per day."""

    max_candidates_per_sector: int | None = None
    """Optional diversification cap after ranking.
    Example: 2 = at most two names from the same sector in the day's final ORB list.
    None disables the cap."""

    sector_confirmation_enabled: bool = False
    """When True, compute a same-sector confirmation signal for ORB candidates.

    The signal is based only on contemporaneous opening-range candidates from the
    same sector and direction. It is disabled by default so existing strategies
    are unchanged unless a config explicitly opts in.
    """

    sector_confirmation_min_members: int = 2
    """Minimum same-sector, same-direction ORB candidates required to confirm a
    sector cluster."""

    sector_confirmation_min_avg_orb_return_pct: float | None = 0.0
    """Minimum average ORB candle return across the confirming sector cluster.
    None disables the average-return gate."""

    sector_confirmation_min_total_first_bar_dollar_vol: float | None = None
    """Minimum combined first-bar dollar volume across the confirming sector
    cluster. None disables the liquidity gate."""

    sector_confirmation_score_weight: float = 0.0
    """Additional ranking weight for sector-confirmed ORB clusters.
    Values at 0.0 leave ranking unchanged."""

    sector_confirmation_size_scale: float = 1.0
    """Position-size multiplier for sector-confirmed candidates.
    Values above 1.0 boost confirmed clusters; values below 1.0 reduce them."""

    sector_confirmation_liquid_min_premarket_dollar_vol: float | None = None
    """Optional candidate-level liquidity floor for a higher-quality
    sector-confirmation sizing tier. None disables tiered sizing."""

    sector_confirmation_liquid_size_scale: float | None = None
    """Position-size multiplier for sector-confirmed candidates that also pass
    sector_confirmation_liquid_min_premarket_dollar_vol. None falls back to
    sector_confirmation_size_scale."""

    sector_confirmation_illiquid_size_scale: float | None = None
    """Position-size multiplier for sector-confirmed candidates that fail the
    candidate-level liquidity tier. None falls back to sector_confirmation_size_scale."""

    sector_confirmation_unconfirmed_size_scale: float = 1.0
    """Position-size multiplier for candidates without sector confirmation when
    the sector-confirmation engine is enabled. 1.0 leaves them unchanged."""

    soft_day_sector_confirmation_override_enabled: bool = False
    """When True, allow sector-confirmed primary ORB candidates to bypass the
    normal soft-day primary gates. This keeps soft-day participation tied to
    cross-sectional sector breadth instead of relaxing regime filters globally."""

    soft_day_sector_confirmation_override_max_trades: int | None = 1
    """Maximum soft-day primary trades admitted through sector-confirmation override."""

    soft_day_sector_confirmation_override_min_score_pct: float | None = None
    """Minimum daily candidate rank percentile for the soft-day sector override."""

    soft_day_sector_confirmation_override_min_premarket_dollar_vol: float | None = None
    """Minimum candidate premarket dollar volume for the soft-day sector override."""

    soft_day_sector_confirmation_override_allowed_reason_parts: list[str] | None = None
    """Optional soft-day reason allowlist. A reason matches when any listed part
    appears in the '+'-separated soft_day_reason, e.g. 'breadth'."""

    soft_day_sector_confirmation_override_allowed_trigger_types: list[str] | None = None
    """Optional trigger allowlist for the soft-day sector override."""

    soft_day_sector_confirmation_override_size_scale: float = 1.0
    """Additional position-size multiplier for trades admitted by the soft-day
    sector-confirmation override. Day-level soft regime scalers still apply."""

    soft_day_sector_confirmation_override_min_day_size_scale: float | None = None
    """Optional day-level sizing floor for trades admitted by the soft-day
    sector-confirmation override. This lets a confirmed sector sleeve deploy a
    small probe even when market-ORB quality would otherwise scale to zero."""

    soft_day_sector_confirmation_override_loss_cap_pct: float | None = None
    """Optional fixed stop distance for trades admitted by the soft-day sector
    override. This contains weak-regime probes without changing normal ORB exits."""

    entry_market_guard_enabled: bool = False
    """When True, scale or skip entries if the selected market ETF has weakened
    by the candidate's entry time. Uses only market data available at the entry
    bar open to avoid same-bar close lookahead."""

    entry_market_guard_ticker: str | None = None
    """Market ETF used for the entry-time guard. None falls back to
    market_regime_ticker, then QQQ."""

    entry_market_guard_min_return_pct: float | None = None
    """Minimum intraday market return from the first regular-session open to
    the candidate entry time. Below this level the guard becomes active."""

    entry_market_guard_size_scale: float = 1.0
    """Defensive position-size multiplier when the entry-time market guard is
    active and entry_market_guard_skip_trade is False."""

    entry_market_guard_skip_trade: bool = False
    """When True, skip guarded entries instead of scaling them down."""

    entry_market_guard_apply_to_soft_day: bool = True
    """Whether the entry-time market guard also applies to soft-day fallback
    entries."""

    min_candidates_to_trade: int = 3
    """Skip the day entirely if fewer than this many candidates pass all filters."""

    full_size_positions_threshold: int | None = None
    """When set, sparse ORB days scale down total deployed capital instead of
    always using the full day budget.

    Example: threshold=3 means days with only 1-2 entry-ready names are sized
    below 100% of the normal day budget, while 3+ names use full size.
    """

    sparse_day_size_floor: float = 1.0
    """Minimum day-level size scaler when full_size_positions_threshold is active.
    0.5 means even a one-name day still deploys 50% of the normal day budget."""

    basket_quality_relative_floor: float | None = None
    """Optional dynamic floor applied after ORB candidate ranking.

    When set, keep only names whose composite score is at least this fraction
    of the day's best score. This lets the basket shrink on weak residual tails
    instead of always carrying the full ranked list into chronological entry
    allocation.
    """

    basket_quality_min_count: int = 0
    """Minimum number of ranked names to keep even when the relative floor prunes
    the tail. 0 means no forced minimum beyond the score floor survivors."""

    # Composite ranking weights
    weight_rvol: float = 0.60
    """RVOL weight in composite ranking score (50% from paper + 10% from spread, which is unavailable)."""

    weight_gap: float = 0.25
    """Gap% weight (proxy for premarket activity, which is unavailable)."""

    weight_dollar_vol: float = 0.15
    """First-5-min dollar volume weight."""

    weight_premarket_dollar_vol: float = 0.0
    """Premarket dollar-volume weight. Serves as a same-day catalyst / attention proxy
    when dedicated news data is unavailable."""

    min_volume_attention_rank_pct: float | None = None
    """Minimum same-day cross-sectional volume-attention percentile.
    This uses percentile ranks of opening-range dollar volume, opening RVOL, and
    premarket dollar volume instead of absolute SIP-calibrated dollar thresholds,
    so it remains usable when live intraday bars come from IEX."""

    volume_attention_rank_weight_rvol: float = 0.45
    """Blend weight for opening RVOL inside volume_attention_rank_pct."""

    volume_attention_rank_weight_opening_dollar_vol: float = 0.45
    """Blend weight for opening-range dollar-volume percentile inside
    volume_attention_rank_pct."""

    volume_attention_rank_weight_premarket_dollar_vol: float = 0.10
    """Blend weight for premarket dollar-volume percentile inside
    volume_attention_rank_pct. Keep this small for IEX live mode because
    premarket prints can be sparse."""

    volume_attention_rank_weight_global_context: float = 1.0
    """Blend weight for the normal all-candidate volume-attention percentile."""

    volume_attention_rank_weight_sector_context: float = 0.0
    """Blend weight for sector-relative volume-attention percentile.
    This helps a ticker that is unusually active for its sector without relying
    on absolute SIP-calibrated volume thresholds."""

    volume_attention_rank_weight_price_context: float = 0.0
    """Blend weight for price-bucket-relative volume-attention percentile.
    This prevents very high-dollar-price leaders from dominating purely because
    equal share volume creates larger dollar volume."""

    volume_attention_context_min_bucket_size: int = 3
    """Minimum candidates required before sector/price contextual ranks replace
    the global fallback rank for that bucket."""

    weight_volume_attention_rank: float = 0.0
    """Composite ranking weight for the cross-sectional volume-attention rank.
    Unlike raw dollar volume, this is scale-insensitive across SIP backtests and
    IEX live bars."""

    weight_body_ratio: float = 0.0
    """ORB candle directional conviction: (close-open)/(high-low) for longs, reversed for shorts.
    High value = first candle decisively moved in the breakout direction."""

    weight_close_location: float = 0.0
    """First ORB candle close location within its range: (close-low)/(high-low).
    leader_followthrough typically rewards closes that finish near the candle high,
    even if the opening bar is slightly red (red-to-green reclaim behavior)."""

    weight_momentum: float = 0.0
    """5-day prior price momentum weight. Positive = stock already trending in breakout direction."""

    weight_entropy: float = 0.0
    """Entropy(20d) ranking weight. compression_breakout typically rewards lower entropy."""

    weight_atr_ratio: float = 0.0
    """Recent ATR(10) / ATR(60) ranking weight."""

    weight_obv_slope: float = 0.0
    """OBV accumulation slope (20d) ranking weight. Positive OBV = smart-money accumulation pre-breakout."""

    weight_obv_slope_5: float = 0.0
    """OBV accumulation slope (5d) ranking weight. Short-term accumulation signal, orthogonal to 20d slope."""

    weight_gap_zscore: float = 0.0
    """Opening-gap z-score ranking weight relative to prior 20 sessions."""

    weight_red_to_green_gap: float = 0.0
    """Extra ranking weight for downside gaps that reclaim into long ORB breakouts.
    This is separate from weight_gap, which uses absolute gap size in gainers
    engines. A positive value prioritizes red-to-green continuation candidates."""

    red_to_green_reserved_slots: int = 0
    """Reserve up to this many final ORB basket slots for downside-gap reclaim
    candidates. Unlike weight_red_to_green_gap, this is a basket-construction
    overlay: it can include a qualifying red-to-green setup even when its
    composite score would otherwise fall just outside max_candidates."""

    candidate_seed_overlay_reserved_slots: int = 0
    """Reserve up to this many final ORB basket slots for candidate-seed overlay
    names. The overlay still has to pass the ORB candle direction and breakout
    simulation, but this lets explicitly seeded leaders avoid being buried by
    the standard gap/RVOL-heavy rank."""

    red_to_green_min_abs_gap_pct: float | None = None
    """Minimum absolute downside gap required for a reserved red-to-green slot.
    None accepts any negative gap."""

    red_to_green_min_body_ratio: float | None = None
    """Minimum ORB body ratio required for a reserved red-to-green slot."""

    red_to_green_min_close_location: float | None = None
    """Minimum ORB close location required for a reserved red-to-green slot."""

    red_to_green_min_orb_return: float | None = None
    """Minimum first ORB-bar return required for a reserved red-to-green slot.

    This separates true opening acceleration from downside gaps that merely
    close well inside a narrow first range.
    """

    red_to_green_min_rvol: float | None = None
    """Minimum opening RVOL required for a reserved red-to-green slot."""

    weight_event_catalyst: float = 0.0
    """Same-day catalyst weight from actual filing events.
    Used by stocks_in_play_dual_regime to reward names with a concrete event
    instead of relying only on attention proxies."""

    prior_event_lookback_days: int = 0
    """Calendar days to look back for prior earnings/guidance events in DB.
    0=off (default, V24 parity). 7=V46. When >0 AND weight_event_catalyst>0,
    uses DB events table path instead of Oracle REST API for event_flag/event_score.
    Marks each trading day within this window after qualifying events as
    event_flag=True, event_score=1.0. Event types controlled by prior_event_types."""

    prior_event_types: list[str] = Field(
        default_factory=lambda: ["earnings_release", "guidance_update"]
    )
    """DB event types to include in prior_event_lookback_days signal.
    Default matches V46 (earnings_release + guidance_update).
    Set to ['earnings_release'] for earnings-only variant."""

    prior_event_decay_half_life_days: float | None = None
    """Optional prior-event recency half-life.
    None preserves the legacy binary prior-event score. When set, D-1 events
    keep full score and older events decay by this half-life before ranking."""

    prior_event_decay_min_score: float | None = None
    """Optional floor for decayed prior-event scores.
    When set, prior events below the floor no longer count as event_flag=True."""

    prior_event_guidance_score_scale: float = 1.0
    """Type score multiplier for guidance_update prior events when event decay
    is enabled. Earnings events remain at 1.0."""

    prior_event_snapshot_id: str | None = None
    """Optional frozen dataset id for prior-event enrichment.
    When set, runs materialize/read request shards under a stable snapshot
    family instead of an anonymous ad-hoc cache path. This is the reproducible
    mode for ORB research that depends on prior_event_lookback_days."""

    daily_bar_snapshot_id: str | None = None
    """Optional frozen dataset id for daily OHLCV bars.
    When set, runs read ticker parquet files from a stable daily snapshot
    directory instead of the mutable shared daily cache."""

    daily_bar_snapshot_overlay_enabled: bool = False
    """Allow a mutable daily overlay on top of daily_bar_snapshot_id.
    This is disabled by default because frozen research runs must not change
    when cache overlays are rebuilt later."""

    weight_attention_wiki: float = 0.0
    """Wikipedia attention weight for actual stocks-in-play / gainers ranking."""

    weight_attention_news: float = 0.0
    """News/article attention weight for actual stocks-in-play / gainers ranking."""

    ownership_13dg_lookback_days: int = 0
    """Calendar days to look back for PIT-safe SC 13D/13G ownership events.
    0 disables the ownership overlay. Filing dates must be strictly before the
    trade date, so same-day filings never affect same-day ORB entries."""

    ownership_13dg_reference_path: str = "data/reference/ownership_13d13g_events_pit.parquet"
    """Local PIT parquet used for 13D/13G ownership enrichment."""

    weight_ownership_13dg: float = 0.0
    """Ranking weight for any recent 13D/13G ownership filing."""

    weight_ownership_initial_13dg: float = 0.0
    """Ranking weight for recent initial-owner 13D/13G filings."""

    ownership_initial_size_scale: float = 1.0
    """Position-size multiplier for recent initial-owner 13D/13G setups.
    Values at or below 1.0 disable the sizing overlay."""

    ownership_initial_min_score_rank_pct: float | None = None
    """Optional minimum ORB candidate rank percentile before applying the
    initial-owner ownership size boost."""

    ownership_initial_allowed_trigger_types: list[str] | None = None
    """Optional trigger allowlist for the initial-owner ownership size boost."""

    ownership_initial_ignore_scaled_risk_overlays: bool = True
    """When True, do not boost ownership names already reduced by defensive
    risk overlays."""

    form4_lookback_days: int = 0
    """Calendar days to look back for PIT-safe SEC Form 4 purchase clusters.
    0 disables the Form 4 overlay. Filing dates must be strictly before the
    trade date."""

    form4_reference_path: str = "data/reference/form4_daily_events_pit.parquet"
    """Local PIT parquet used for Form 4 insider purchase enrichment."""

    form4_size_scale: float = 1.0
    """Position-size multiplier for qualified recent Form 4 insider-buy setups."""

    form4_min_total_value: float | None = None
    """Minimum aggregated purchase value inside the Form 4 lookback window."""

    form4_min_owner_count: int | None = None
    """Minimum max owner_count inside the Form 4 lookback window."""

    form4_min_c_suite_count: int | None = None
    """Minimum max C-suite buyer count inside the Form 4 lookback window."""

    form4_require_cluster_or_csuite: bool = False
    """When True, accept Form 4 sizing only when owner_count or C-suite gates pass."""

    form4_allowed_trigger_types: list[str] | None = None
    """Optional trigger allowlist for Form 4 sizing."""

    form4_ignore_scaled_risk_overlays: bool = True
    """When True, do not boost Form 4 names already reduced by defensive overlays."""

    min_body_ratio: float = 0.0
    """Minimum ORB candle body/range conviction. 0 disables the filter."""

    min_close_location: float = 0.0
    """Minimum ORB candle close-location filter for leader_followthrough.
    Example: 0.50 means the candle must close in the upper half of its range."""

    max_close_location_short: float = 1.0
    """Maximum ORB candle close-location filter for short setups.
    Example: 0.40 means a failed-ORB short must close in the lower 40% of the
    opening range. 1.0 disables the filter."""

    allow_doji_breakout: bool = False
    """When True, doji first bars are still allowed to trade via ORB high/low breakout.
    Useful for gainers-style leader chasing or leader followthrough setups where the
    opening 5-min candle can pause before a strong trend day. Default False to preserve
    classic ORB behavior."""

    allow_red_to_green_breakout: bool = False
    """When True, gainers_leader / leader_followthrough may trade long ORB-high breakouts
    even if the first ORB candle closes red. This is meant for leader-followthrough days
    where a strong name briefly dips after the open before reclaiming the ORB high."""

    require_event_flag: bool = False
    """Require a same-day filing-based catalyst flag for candidate inclusion.
    Designed for stocks_in_play_dual_regime, where attention alone is not enough."""

    allowed_event_types: list[str] = Field(default_factory=list)
    """Optional whitelist of filing event types that count as catalysts.
    Empty list means any same-day filing event is accepted."""

    allow_failed_orb_short: bool = False
    """Allow failed gap-up ORB shorts in dual-regime mode."""

    require_vwap_confirmation: bool = False
    """Require the ORB candle close to confirm against VWAP:
    longs must close above VWAP, failed-ORB shorts must close below VWAP."""

    attention_min_wiki_spike_10d: float | None = None
    """Minimum wiki spike to accept a stocks-in-play candidate."""

    attention_min_wiki_zscore_20d: float | None = None
    """Minimum wiki z-score to accept a stocks-in-play candidate."""

    attention_min_article_count_3d: int | None = None
    """Minimum article count to accept a stocks-in-play candidate."""

    attention_min_us_article_count_3d: int | None = None
    """Minimum US article count to accept a stocks-in-play candidate."""

    attention_min_resolver_confidence: float | None = None
    """Minimum entity-resolution confidence for attention data usage."""

    min_sector_relative_strength: float | None = None
    """Minimum ORB return minus sector ORB return for continuation longs.
    Positive values force the name to outperform its own sector in the opening range."""

    min_entropy: float | None = None
    """Minimum allowed entropy_20d. None disables the lower bound."""

    max_entropy: float | None = None
    """Maximum allowed entropy_20d. None disables the upper bound."""

    compression_ratio_max: float | None = None
    """Maximum allowed recent range compression ratio (10d / 60d). Lower = tighter setup.
    None disables the filter."""

    max_gap_zscore_20d: float | None = None
    """Maximum allowed gap z-score (relative to prior 20 sessions). Rejects anomalous gap-up days
    where short-sellers are already leaning against the name. Low gap_zscore = routine gap = better ORB.
    None disables the filter. Gainers_leader / vwap_reclaim_v1 only."""

    conditional_gap_zscore_reject_above: float | None = None
    """Conditional stretched-gap rejection threshold.
    When set with conditional_gap_zscore_reject_ret_5d_below, reject candidates whose
    gap_zscore_20d exceeds this value only if their prior 5-day return is also weak."""

    conditional_gap_zscore_reject_ret_5d_below: float | None = None
    """Prior 5-day return threshold paired with conditional_gap_zscore_reject_above.
    Example: 0.0 rejects unusually stretched gap-ups only when ret_5d is negative."""

    crowded_gap_reject_min_gap_pct: float | None = None
    """Reject crowded positive gap continuation candidates when the opening setup
    is already extended. This guards against late ORB chase entries in names that
    have already run before the open. None disables the filter."""

    crowded_gap_reject_min_ret_5d: float | None = None
    """Minimum prior 5-day return paired with crowded_gap_reject_min_gap_pct."""

    crowded_gap_reject_min_body_ratio: float | None = None
    """Minimum ORB body ratio paired with the crowded-gap exhaustion reject."""

    crowded_gap_reject_min_close_location: float | None = None
    """Minimum ORB close location paired with the crowded-gap exhaustion reject."""

    crowded_gap_max_premarket_dollar_vol: float | None = None
    """Optional upper premarket-dollar-volume bound for crowded-gap handling.
    When set, the crowded-gap overlay only applies to lower-attention crowded
    setups; high-participation crowded gaps remain eligible for normal sizing."""

    crowded_gap_action: str = "reject"
    """Action for crowded positive-gap exhaustion candidates.
    'reject' removes them from the ORB basket; 'confirm' keeps them but forces
    a confirmation-bar entry before capital is committed; 'scale' keeps the
    setup but applies crowded_gap_size_scale to position sizing; 'confirm_scale'
    combines confirmation-bar entry with the size scale."""

    crowded_gap_size_scale: float = 1.0
    """Position-size multiplier for crowded positive-gap candidates when
    crowded_gap_action='scale'. Values below 1.0 reduce exposure without
    changing ranking or entry/exit logic."""

    countertrend_gap_min_gap_pct: float | None = None
    """Minimum positive gap for weak-prior-trend gap-up risk handling.
    Targets bullish gap-up breakouts after weak 5-day momentum."""

    countertrend_gap_max_ret_5d: float | None = None
    """Maximum prior 5-day return for countertrend-gap risk handling.
    Example: 0.0 targets gap-up breakouts after a negative 5-day return."""

    countertrend_gap_min_body_ratio: float | None = None
    """Minimum ORB body ratio paired with the countertrend-gap risk overlay."""

    countertrend_gap_min_close_location: float | None = None
    """Minimum ORB close location paired with the countertrend-gap risk overlay."""

    countertrend_gap_action: str = "reject"
    """Action for weak-prior-trend positive-gap candidates.
    'reject' removes them; 'confirm' forces a confirmation-bar entry; 'scale'
    keeps the setup but applies countertrend_gap_size_scale to position sizing;
    'confirm_scale' combines confirmation-bar entry with the size scale."""

    countertrend_gap_size_scale: float = 1.0
    """Position-size multiplier for countertrend-gap candidates when
    countertrend_gap_action='scale'."""

    distressed_reclaim_min_abs_gap_pct: float | None = None
    """Minimum downside gap magnitude for distressed red-to-green reclaim handling.
    Example: 0.05 targets stocks gapping down at least 5%."""

    distressed_reclaim_max_ret_5d: float | None = None
    """Maximum prior 5-day return for distressed reclaim handling.
    Example: -0.10 targets names already down at least 10% over five days."""

    distressed_reclaim_min_premarket_dollar_vol: float | None = None
    """Minimum premarket dollar volume for distressed reclaim handling.
    This keeps the overlay focused on crowded, high-attention downside gaps."""

    distressed_reclaim_max_close_location: float | None = None
    """Maximum ORB close location for distressed reclaim handling.
    Values below 1.0 identify weak reclaims that did not close near the ORB high."""

    distressed_reclaim_max_obv_slope_20d: float | None = None
    """Optional 20-day OBV slope ceiling for distressed reclaim handling.
    Example: 0.0 limits the overlay to downside-gap reclaims with recent
    distribution rather than accumulation."""

    distressed_reclaim_max_obv_slope_5d: float | None = None
    """Optional 5-day OBV slope ceiling for distressed reclaim handling.
    This can separate weak distribution-driven reclaims from high-attention
    washouts that already show short-term accumulation."""

    distressed_reclaim_action: str = "reject"
    """Action for crowded downside-gap reclaim candidates.
    'reject' removes them; 'confirm' forces a confirmation-bar entry; 'scale'
    keeps the setup but applies distressed_reclaim_size_scale to position sizing;
    'confirm_scale' combines confirmation-bar entry with the size scale;
    'skip' keeps the candidate in the selected basket but skips the trade, so
    lower-ranked replacement candidates do not backfill the slot;
    'skip_reserve' also reserves the candidate's intended cash for the day so
    skipped risk slots are not reallocated to later candidates;
    'scale_reserve' and 'confirm_scale_reserve' trade the scaled position while
    reserving the unscaled slot cash for the rest of the day;
    'loss_cap' keeps normal entry/sizing but applies distressed_reclaim_loss_cap_pct
    as a fixed intraday stop."""

    distressed_reclaim_size_scale: float = 1.0
    """Position-size multiplier for distressed downside-gap reclaim candidates
    when distressed_reclaim_action='scale' or 'confirm_scale'."""

    distressed_reclaim_loss_cap_pct: float | None = None
    """Fixed stop distance as a fraction of entry price for distressed reclaim
    candidates when distressed_reclaim_action='loss_cap' or when otherwise set.
    Example: 0.02 uses a 2% fixed stop from entry."""

    distressed_reclaim_streak_loss_on_trigger: bool = False
    """When True, a distressed reclaim overlay trigger appends a synthetic losing
    outcome to streak sizing after the day. This preserves the post-risk-event
    sizing reset without booking synthetic PnL."""

    isolated_downside_loss_cap_min_abs_gap_pct: float | None = None
    """Minimum downside gap magnitude for isolated downside-reclaim loss capping.
    The overlay is applied after sector confirmation and only targets candidates
    without same-sector confirmation."""

    isolated_downside_loss_cap_max_ret_5d: float | None = None
    """Optional maximum prior 5-day return for isolated downside loss capping."""

    isolated_downside_loss_cap_min_premarket_dollar_vol: float | None = None
    """Optional minimum premarket dollar volume for isolated downside loss capping."""

    isolated_downside_loss_cap_max_premarket_dollar_vol: float | None = None
    """Optional maximum premarket dollar volume for isolated downside loss capping."""

    isolated_downside_loss_cap_max_body_ratio: float | None = None
    """Optional maximum ORB body ratio for isolated downside loss capping."""

    isolated_downside_loss_cap_min_body_ratio: float | None = None
    """Optional minimum ORB body ratio for isolated downside loss capping."""

    isolated_downside_loss_cap_max_close_location: float | None = None
    """Optional maximum ORB close location for isolated downside loss capping."""

    isolated_downside_loss_cap_min_close_location: float | None = None
    """Optional minimum ORB close location for isolated downside loss capping."""

    isolated_downside_loss_cap_min_orb_return: float | None = None
    """Optional minimum first ORB-bar return for isolated downside loss capping."""

    isolated_downside_loss_cap_max_score_rank_pct: float | None = None
    """Optional maximum same-day score-rank percentile for isolated downside loss capping."""

    isolated_downside_loss_cap_allowed_trigger_types: list[str] | None = None
    """Optional trigger allowlist for isolated downside loss capping.
    None keeps the overlay available to every trigger type."""

    isolated_downside_size_scale: float = 1.0
    """Position-size multiplier for isolated downside reclaim candidates.
    Values below 1.0 reduce exposure and suppress high-conviction boost overlays."""

    isolated_downside_loss_cap_pct: float | None = None
    """Fixed stop distance as a fraction of entry price for isolated downside
    reclaim candidates. Example: 0.02 uses a 2% fixed stop from entry."""

    isolated_downside_pressure_min_abs_gap_pct: float | None = None
    """Minimum downside gap magnitude for isolated high-volume opening-pressure scaling."""

    isolated_downside_pressure_max_ret_5d: float | None = None
    """Optional maximum prior 5-day return for isolated opening-pressure scaling."""

    isolated_downside_pressure_min_premarket_dollar_vol: float | None = None
    """Minimum premarket dollar volume for isolated opening-pressure scaling."""

    isolated_downside_pressure_max_body_ratio: float | None = None
    """Optional maximum ORB body ratio for isolated opening-pressure scaling."""

    isolated_downside_pressure_max_close_location: float | None = None
    """Optional maximum ORB close location for isolated opening-pressure scaling."""

    isolated_downside_pressure_allowed_trigger_types: list[str] | None = None
    """Optional trigger allowlist for isolated opening-pressure scaling."""

    isolated_downside_pressure_size_scale: float = 1.0
    """Position-size multiplier for isolated high-volume opening-pressure candidates."""

    overextended_downside_reclaim_min_abs_gap_pct: float | None = None
    """Minimum downside gap magnitude for overextended downside-reclaim scaling.
    This targets names that were already crowded over the prior week and then
    attempt an ORB reclaim after a deep gap down."""

    overextended_downside_reclaim_min_ret_5d: float | None = None
    """Minimum prior 5-day return for overextended downside-reclaim scaling."""

    overextended_downside_reclaim_min_premarket_dollar_vol: float | None = None
    """Optional minimum premarket dollar volume for overextended downside reclaim."""

    overextended_downside_reclaim_allowed_trigger_types: list[str] | None = None
    """Optional trigger allowlist for overextended downside-reclaim scaling."""

    overextended_downside_reclaim_size_scale: float = 1.0
    """Position-size multiplier for overextended downside-reclaim candidates."""

    mid_attention_exhaustion_min_rvol: float | None = None
    """Minimum opening RVOL for mid-attention exhaustion scaling.
    This targets crowded intraday attention that is elevated, but not extreme
    enough to indicate a broad liquid repricing event."""

    mid_attention_exhaustion_max_rvol: float | None = None
    """Maximum opening RVOL for mid-attention exhaustion scaling."""

    mid_attention_exhaustion_min_premarket_dollar_vol: float | None = None
    """Minimum premarket dollar volume for mid-attention exhaustion scaling."""

    mid_attention_exhaustion_max_premarket_dollar_vol: float | None = None
    """Maximum premarket dollar volume for mid-attention exhaustion scaling."""

    mid_attention_exhaustion_allowed_trigger_types: list[str] | None = None
    """Optional trigger allowlist for mid-attention exhaustion scaling."""

    mid_attention_exhaustion_size_scale: float = 1.0
    """Position-size multiplier for mid-attention exhaustion candidates."""

    mid_liquidity_fragility_size_scale: float = 1.0
    """Position-size multiplier for fragile mid-liquidity participation setups.
    This governor targets candidates that are liquid enough to trade but lack
    the broad participation profile of true liquid leaders."""

    mid_liquidity_fragility_allowed_trigger_types: list[str] | None = None
    """Optional trigger allowlist for mid-liquidity fragility scaling."""

    mid_liquidity_fragility_thin_min_premarket_dollar_vol: float | None = None
    """Lower premarket-dollar-volume bound for the thin mid-liquidity profile."""

    mid_liquidity_fragility_thin_max_premarket_dollar_vol: float | None = None
    """Upper premarket-dollar-volume bound for the thin mid-liquidity profile."""

    mid_liquidity_fragility_thin_max_rvol: float | None = None
    """Maximum opening RVOL that marks weak participation in the thin profile."""

    mid_liquidity_fragility_thin_min_ret_5d: float | None = None
    """Minimum prior 5-day return for the thin-profile drift clause."""

    mid_liquidity_fragility_thin_max_ret_5d: float | None = None
    """Maximum prior 5-day return for the thin-profile drift clause."""

    mid_liquidity_fragility_thin_min_body_ratio: float | None = None
    """Minimum ORB body ratio for the thin-profile exhaustion clause."""

    mid_liquidity_fragility_mid_min_premarket_dollar_vol: float | None = None
    """Lower premarket-dollar-volume bound for the mid-liquidity body profile."""

    mid_liquidity_fragility_mid_max_premarket_dollar_vol: float | None = None
    """Upper premarket-dollar-volume bound for the mid-liquidity body profile."""

    mid_liquidity_fragility_mid_min_body_ratio: float | None = None
    """Minimum ORB body ratio for the mid-liquidity body profile."""

    mid_liquidity_fragility_mid_max_body_ratio: float | None = None
    """Maximum ORB body ratio for the mid-liquidity body profile."""

    orphan_thin_attention_max_premarket_dollar_vol: float | None = None
    """Maximum premarket dollar volume for unsupported thin-attention scaling.
    This targets ORB candidates that only survived via attention/rank overrides
    while lacking sector confirmation."""

    orphan_thin_attention_allowed_trigger_types: list[str] | None = None
    """Optional trigger allowlist for unsupported thin-attention scaling."""

    orphan_thin_attention_size_scale: float = 1.0
    """Position-size multiplier for unsupported thin-attention candidates."""

    orphan_thin_attention_reserve_full_cash: bool = False
    """When True, reserve the unscaled slot after scaling unsupported thin names.
    This lowers exposure without allowing weaker lower-ranked candidates to use
    the freed cash."""

    gap_up_fill_trap_max_orb_return: float | None = None
    """Maximum ORB-bar return for positive-gap candidates that already qualify
    for gap-fill exit protection. These setups have gap-fill risk but did not
    show exceptional first-bar expansion, so they can be downscaled before
    entry instead of relying only on an exit after the gap starts filling."""

    gap_up_fill_trap_allowed_trigger_types: list[str] | None = None
    """Optional trigger allowlist for positive-gap fill-trap scaling."""

    gap_up_fill_trap_size_scale: float = 1.0
    """Position-size multiplier for positive-gap fill-trap candidates."""

    gap_up_fill_trap_reserve_full_cash: bool = False
    """When True, reserve the unscaled slot after scaling gap-fill traps.
    This prevents the released cash from being recycled into weaker alternates."""

    low_candidate_quality_max_score: float | None = None
    """Maximum composite candidate score eligible for low-quality scaling.
    This cross-sectional quality governor reduces weak-ranked setups even when
    they have sector confirmation or raw liquidity."""

    low_candidate_quality_allowed_trigger_types: list[str] | None = None
    """Optional trigger allowlist for low-candidate-quality scaling."""

    low_candidate_quality_size_scale: float = 1.0
    """Position-size multiplier for low composite-score candidates."""

    low_candidate_quality_reserve_full_cash: bool = False
    """When True, reserve the unscaled slot after scaling low-quality candidates."""

    hot_reclaim_min_abs_gap_pct: float | None = None
    """Minimum downside gap magnitude for hot-pullback reclaim handling.
    Example: 0.05 targets stocks gapping down at least 5%."""

    hot_reclaim_min_ret_5d: float | None = None
    """Minimum prior 5-day return for hot-pullback reclaim handling.
    Example: 0.30 targets stocks already up at least 30% over five days."""

    hot_reclaim_max_premarket_dollar_vol: float | None = None
    """Optional maximum premarket dollar volume for hot-pullback reclaim handling.
    This keeps the overlay focused on thinner gap-down reclaims rather than
    broad, highly liquid washouts."""

    hot_reclaim_min_body_ratio: float | None = None
    """Minimum ORB directional body ratio for hot-pullback reclaim handling."""

    hot_reclaim_min_close_location: float | None = None
    """Minimum ORB close location for hot-pullback reclaim handling.
    Values near 1.0 identify candles closing at the ORB high."""

    hot_reclaim_action: str = "reject"
    """Action for hot-pullback downside-gap reclaim candidates.
    'reject' removes them; 'confirm' forces a confirmation-bar entry; 'scale'
    keeps the setup but applies hot_reclaim_size_scale to position sizing;
    'confirm_scale' combines confirmation-bar entry with the size scale;
    'skip' keeps the selected basket slot but skips the trade;
    'skip_reserve' also reserves the intended cash for the day;
    'scale_reserve' and 'confirm_scale_reserve' trade the scaled position while
    reserving the unscaled slot cash;
    'loss_cap' keeps normal sizing but applies hot_reclaim_loss_cap_pct as a
    fixed intraday stop."""

    hot_reclaim_size_scale: float = 1.0
    """Position-size multiplier for hot-pullback reclaim candidates when
    hot_reclaim_action='scale' or 'confirm_scale'."""

    hot_reclaim_loss_cap_pct: float | None = None
    """Fixed stop distance as a fraction of entry price for hot-pullback reclaim
    candidates. Example: 0.02 uses a 2% fixed stop from entry."""

    weak_downside_reclaim_min_abs_gap_pct: float | None = None
    """Minimum downside gap magnitude for weak low-participation reclaim handling.
    Targets red-to-green attempts that gap down materially but fail to show a
    strong opening-range candle."""

    weak_downside_reclaim_min_ret_5d: float | None = None
    """Optional lower bound for prior 5-day return in weak downside reclaim handling.
    This avoids conflating quiet weak reclaims with severe multi-day panic moves."""

    weak_downside_reclaim_max_ret_5d: float | None = None
    """Optional upper bound for prior 5-day return in weak downside reclaim handling."""

    weak_downside_reclaim_max_premarket_dollar_vol: float | None = None
    """Maximum premarket dollar volume for weak downside reclaim handling.
    Low premarket participation makes weak ORB reclaim candles less reliable."""

    weak_downside_reclaim_max_body_ratio: float | None = None
    """Maximum ORB body ratio for weak downside reclaim handling.
    Low values isolate doji-like or indecisive reclaim attempts."""

    weak_downside_reclaim_max_close_location: float | None = None
    """Maximum close location inside the ORB range for weak downside reclaim handling."""

    weak_downside_reclaim_max_obv_slope_5d: float | None = None
    """Optional 5-day OBV slope ceiling for weak downside reclaim handling."""

    weak_downside_reclaim_action: str = "reject"
    """Action for weak low-participation downside-gap reclaim candidates.
    'reject' removes them; 'confirm' forces a confirmation-bar entry; 'scale'
    keeps the setup but applies weak_downside_reclaim_size_scale to position
    sizing; 'confirm_scale' combines confirmation-bar entry with the size scale;
    'skip' keeps the selected basket slot but skips the trade; 'skip_reserve'
    also reserves the intended cash for the day; 'scale_reserve' and
    'confirm_scale_reserve' trade the scaled position while reserving the
    unscaled slot cash; 'loss_cap' keeps normal sizing but applies
    weak_downside_reclaim_loss_cap_pct as a fixed intraday stop."""

    weak_downside_reclaim_size_scale: float = 1.0
    """Position-size multiplier for weak downside reclaim candidates when
    weak_downside_reclaim_action='scale' or 'confirm_scale'."""

    weak_downside_reclaim_loss_cap_pct: float | None = None
    """Fixed stop distance as a fraction of entry price for weak downside
    reclaim candidates. Example: 0.02 uses a 2% fixed stop from entry."""

    quiet_downside_reclaim_min_abs_gap_pct: float | None = None
    """Minimum downside gap magnitude for quiet weak-reclaim handling.
    This is independent of weak_downside_reclaim so hot-pullback guards can be
    combined with low-participation downside-gap guards."""

    quiet_downside_reclaim_min_ret_5d: float | None = None
    """Optional lower bound for prior 5-day return in quiet downside reclaim handling."""

    quiet_downside_reclaim_max_ret_5d: float | None = None
    """Optional upper bound for prior 5-day return in quiet downside reclaim handling."""

    quiet_downside_reclaim_max_premarket_dollar_vol: float | None = None
    """Optional maximum premarket dollar volume for quiet downside reclaim handling."""

    quiet_downside_reclaim_max_body_ratio: float | None = None
    """Optional maximum ORB body ratio for quiet downside reclaim handling."""

    quiet_downside_reclaim_max_close_location: float | None = None
    """Maximum ORB close location for quiet downside reclaim handling."""

    quiet_downside_reclaim_max_obv_slope_5d: float | None = None
    """Optional 5-day OBV slope ceiling for quiet downside reclaim handling."""

    quiet_downside_reclaim_action: str = "reject"
    """Action for quiet low-participation downside-gap reclaim candidates.
    Supports the same actions as weak_downside_reclaim, including 'loss_cap'."""

    quiet_downside_reclaim_size_scale: float = 1.0
    """Position-size multiplier for quiet downside reclaim candidates when
    quiet_downside_reclaim_action='scale' or 'confirm_scale'."""

    quiet_downside_reclaim_loss_cap_pct: float | None = None
    """Fixed stop distance as a fraction of entry price for quiet downside
    reclaim candidates. Example: 0.03 uses a 3% fixed stop from entry."""

    stale_obv_reversal_min_ret_5d: float | None = None
    """Optional lower bound for prior 5-day return in stale-OBV reversal handling.
    This targets names that are not deeply washed out but still have persistent
    20-day distribution."""

    stale_obv_reversal_max_ret_5d: float | None = None
    """Optional upper bound for prior 5-day return in stale-OBV reversal handling."""

    stale_obv_reversal_max_obv_slope_20d: float | None = None
    """Maximum 20-day OBV slope for stale-OBV reversal handling.
    Negative values isolate breakouts fighting persistent distribution."""

    stale_obv_reversal_min_gap_pct: float | None = None
    """Optional minimum positive gap_pct for stale-OBV reversal handling."""

    stale_obv_reversal_max_rvol: float | None = None
    """Optional maximum opening RVOL for stale-OBV reversal handling."""

    stale_obv_reversal_max_body_ratio: float | None = None
    """Optional maximum ORB body_ratio for stale-OBV reversal handling."""

    stale_obv_reversal_max_close_location: float | None = None
    """Optional maximum ORB close location for stale-OBV reversal handling."""

    stale_obv_reversal_max_orb_return: float | None = None
    """Optional maximum first ORB candle return for stale-OBV reversal handling."""

    stale_obv_reversal_action: str = "reject"
    """Action for stale-OBV reversal candidates.
    Supports the same actions as weak_downside_reclaim, including 'loss_cap'."""

    stale_obv_reversal_size_scale: float = 1.0
    """Position-size multiplier for stale-OBV reversal candidates when
    stale_obv_reversal_action='scale' or 'confirm_scale'."""

    stale_obv_reversal_loss_cap_pct: float | None = None
    """Fixed stop distance as a fraction of entry price for stale-OBV reversal
    candidates. Example: 0.025 uses a 2.5% fixed stop from entry."""

    stalled_gap_up_min_gap_pct: float | None = None
    """Minimum positive gap for stalled gap-up risk handling.
    Targets names that gap up but fail to show strong opening confirmation."""

    stalled_gap_up_min_ret_5d: float | None = None
    """Optional lower bound for prior 5-day return in stalled gap-up handling.
    Use this to avoid catching deep countertrend rebound setups."""

    stalled_gap_up_max_ret_5d: float | None = None
    """Optional upper bound for prior 5-day return in stalled gap-up handling.
    This keeps the overlay focused on weak-to-neutral continuation attempts."""

    stalled_gap_up_max_premarket_dollar_vol: float | None = None
    """Optional maximum premarket dollar volume for stalled gap-up handling.
    Low premarket participation makes weak ORB confirmation less trustworthy."""

    stalled_gap_up_max_close_location: float | None = None
    """Maximum ORB close location for stalled gap-up handling.
    Values below 1.0 identify gap-ups that did not close near the ORB high."""

    stalled_gap_up_max_obv_slope_5d: float | None = None
    """Optional 5-day OBV slope ceiling for stalled gap-up handling.
    Negative values require recent short-term distribution."""

    stalled_gap_up_action: str = "reject"
    """Action for stalled positive-gap candidates.
    'reject' removes them; 'confirm' forces a confirmation-bar entry; 'scale'
    keeps the setup but applies stalled_gap_up_size_scale to position sizing;
    'confirm_scale' combines confirmation-bar entry with the size scale;
    'skip' keeps the selected basket slot but skips the trade;
    'skip_reserve' also reserves the intended cash for the day;
    'scale_reserve' and 'confirm_scale_reserve' trade the scaled position while
    reserving the unscaled slot cash;
    'loss_cap' keeps normal sizing but applies stalled_gap_up_loss_cap_pct as a
    fixed intraday stop."""

    stalled_gap_up_size_scale: float = 1.0
    """Position-size multiplier for stalled gap-up candidates when
    stalled_gap_up_action='scale' or 'confirm_scale'."""

    stalled_gap_up_loss_cap_pct: float | None = None
    """Fixed stop distance as a fraction of entry price for stalled gap-up
    candidates. Example: 0.02 uses a 2% fixed stop from entry."""

    liquid_stalled_gap_up_min_gap_pct: float | None = None
    """Minimum positive gap for liquid stalled gap-up loss handling.
    This overlay is separate from stalled_gap_up so the existing thin-liquidity
    guard can remain active while high-participation stalls get their own rule."""

    liquid_stalled_gap_up_min_ret_5d: float | None = None
    """Optional lower bound for prior 5-day return in liquid stalled gap-up handling."""

    liquid_stalled_gap_up_max_ret_5d: float | None = None
    """Optional upper bound for prior 5-day return in liquid stalled gap-up handling."""

    liquid_stalled_gap_up_min_premarket_dollar_vol: float | None = None
    """Optional minimum premarket dollar volume for liquid stalled gap-up handling."""

    liquid_stalled_gap_up_max_premarket_dollar_vol: float | None = None
    """Optional maximum premarket dollar volume for liquid stalled gap-up handling."""

    liquid_stalled_gap_up_max_body_ratio: float | None = None
    """Optional maximum ORB body ratio for liquid stalled gap-up handling."""

    liquid_stalled_gap_up_max_close_location: float | None = None
    """Maximum ORB close location for liquid stalled gap-up handling."""

    liquid_stalled_gap_up_max_obv_slope_5d: float | None = None
    """Optional 5-day OBV slope ceiling for liquid stalled gap-up handling."""

    liquid_stalled_gap_up_action: str = "reject"
    """Action for liquid stalled positive-gap candidates.
    Supports the same actions as stalled_gap_up, including 'loss_cap'."""

    liquid_stalled_gap_up_size_scale: float = 1.0
    """Position-size multiplier for liquid stalled gap-up candidates when
    liquid_stalled_gap_up_action='scale' or 'confirm_scale'."""

    liquid_stalled_gap_up_loss_cap_pct: float | None = None
    """Fixed stop distance as a fraction of entry price for liquid stalled
    gap-up candidates. Example: 0.025 uses a 2.5% fixed stop from entry."""

    thin_gap_up_loss_cap_min_gap_pct: float | None = None
    """Minimum positive gap for thin gap-up fixed-loss handling.
    This targets low-premarket-participation gap-ups that can look strong on
    the ORB candle but fail hard intraday."""

    thin_gap_up_loss_cap_min_ret_5d: float | None = None
    """Optional lower bound for prior 5-day return in thin gap-up loss handling."""

    thin_gap_up_loss_cap_max_ret_5d: float | None = None
    """Optional upper bound for prior 5-day return in thin gap-up loss handling."""

    thin_gap_up_loss_cap_max_premarket_dollar_vol: float | None = None
    """Maximum premarket dollar volume for thin gap-up loss handling."""

    thin_gap_up_loss_cap_min_body_ratio: float | None = None
    """Minimum ORB body ratio for thin gap-up loss handling."""

    thin_gap_up_loss_cap_min_close_location: float | None = None
    """Minimum close location inside the ORB range for thin gap-up loss handling."""

    thin_gap_up_loss_cap_pct: float | None = None
    """Fixed stop distance as a fraction of entry price for qualifying thin
    gap-up candidates. Example: 0.02 uses a 2% fixed stop from entry."""

    moderate_downside_loss_cap_min_abs_gap_pct: float | None = None
    """Minimum downside gap magnitude for moderate downside fixed-loss handling."""

    moderate_downside_loss_cap_max_abs_gap_pct: float | None = None
    """Maximum downside gap magnitude for moderate downside fixed-loss handling.
    This avoids applying the overlay to deep washout leaders that have historically
    carried much of the strategy's upside."""

    moderate_downside_loss_cap_min_ret_5d: float | None = None
    """Optional lower bound for prior 5-day return in moderate downside handling."""

    moderate_downside_loss_cap_max_ret_5d: float | None = None
    """Optional upper bound for prior 5-day return in moderate downside handling."""

    moderate_downside_loss_cap_max_premarket_dollar_vol: float | None = None
    """Optional maximum premarket dollar volume for moderate downside handling."""

    moderate_downside_loss_cap_max_body_ratio: float | None = None
    """Maximum ORB body ratio for moderate downside handling."""

    moderate_downside_loss_cap_max_close_location: float | None = None
    """Maximum close location inside the ORB range for moderate downside handling."""

    moderate_downside_loss_cap_pct: float | None = None
    """Fixed stop distance as a fraction of entry price for qualifying moderate
    downside-gap candidates. Example: 0.01 uses a 1% fixed stop from entry."""

    min_obv_slope_20d: float | None = None
    """Minimum allowed OBV slope (20d). 0.0 = require positive accumulation (net up-volume days).
    Negative OBV slope = institutional distribution — filters those out when set.
    None disables the filter. Gainers_leader / vwap_reclaim_v1 only."""

    # ATR-based stop management
    atr_stop_multiplier: float = 0.10
    """Initial stop distance = ATR(14) × this multiplier. Paper uses 10% (0.10)."""

    breakeven_at_r: float = 1.0
    """Move stop to breakeven (entry price) when trade reaches this R-multiple."""

    trailing_at_r: float = 2.0
    """Activate trailing stop (using recent bar lows) when trade reaches this R-multiple."""

    # Risk-based position sizing
    risk_per_trade_pct: float = 0.0025
    """Risk dollars per trade = equity × this. 0.0025 = 0.25% per trade."""

    max_position_pct: float = 0.20
    """Maximum single position as fraction of equity. 0.20 = 20%."""

    daily_max_loss_pct: float = 0.0125
    """Stop trading for the day if cumulative loss exceeds this. 0.0125 = 1.25%."""

    max_stops_per_day: int = 3
    """Stop trading for the day after this many full-R stop losses."""

    same_bar_stop_confirmation_enabled: bool = False
    """When True, ambiguous 5-minute ORB bars whose range contains both the
    breakout entry and initial stop must confirm on the next bar. The trade then
    enters at the confirmation bar close, avoiding same-bar low/high lookahead."""

    same_bar_stop_confirmation_allowed_trigger_types: list[str] | None = None
    """Optional trigger allowlist for same_bar_stop_confirmation_enabled.
    Example: ['orb'] limits the confirmation rule to primary ORB breakouts."""

    # Exit
    exit_minutes_before_close: int = 5
    """Minutes before 4:00 PM ET to force-close. Default 5 = 15:55 ET."""

    # Execution
    slippage_bps: float = 5.0
    """One-way slippage in basis points (applied to both entry and exit fills)."""

    initial_capital: float = 10_000.0
    """Starting capital in USD."""

    ticker_cooldown_days: int = 0
    """Blackout period after trading a ticker (same as momentum strategy). 0 = disabled."""

    settlement_days: int = 0
    """Cash account settlement delay (trading days).
    0 = disabled (all equity always available — original behavior, allows over-deployment).
    1 = T+1 (sale proceeds settle next trading day; also enforces within-day settled-cash cap).
    2 = T+2 (legacy US rule pre-May 2024).
    GFV context: unsettled proceeds can buy but not same-day sell (ORB always exits same day,
    so only settled cash is usable)."""

    orb_idle_sleeve_enabled: bool = False
    """When True, deploy a fallback basket only on days where the primary ORB
    book opens no positions. The entry/exit schedule is controlled by the idle
    sleeve timing fields so strategies can test same-day or overnight sleeves."""

    orb_idle_sleeve_total_budget_pct: float = 0.25
    """Fraction of the ORB sizing capital allocated to the idle fallback basket."""

    orb_idle_sleeve_normalize_weights: bool = True
    """When True, active idle-sleeve weights are normalized to consume the
    full idle budget. When False, each weight is treated as an absolute fraction
    of the idle budget and unused capital remains idle."""

    orb_idle_sleeve_max_positions: int = 5
    """Maximum number of fallback positions to open across all idle sleeves."""

    orb_idle_sleeve_exit_timing: str = "next_open"
    """Exit timing for idle fallback positions: 'same_day_close', 'next_open',
    or 'next_close'."""

    orb_idle_sleeve_entry_timing: str = "close"
    """Entry timing for idle fallback positions: 'close' or 'minutes_after_open'.
    'minutes_after_open' is intended for morning no-ORB fallback books."""

    orb_idle_sleeve_entry_minutes_after_open: int = 60
    """Entry bar offset for orb_idle_sleeve_entry_timing='minutes_after_open'."""

    orb_idle_sleeve_same_day_stop_loss_pct: float | None = None
    """Optional stop-loss for same-day idle fallback positions.
    Example: -0.03 exits an intraday-only idle sleeve when it falls 3% below
    the idle entry price. Only applies when orb_idle_sleeve_exit_timing is
    same_day_close/day_close/close."""

    orb_idle_sleeve_weights: dict[str, float] = Field(
        default_factory=lambda: {
            "parking": 0.25,
            "idle_alpha": 0.35,
            "form4": 0.15,
            "ownership": 0.15,
            "risk_off_alpha": 0.10,
        }
    )
    """Capital weights for the five PEAD-inspired ORB idle sleeves."""

    orb_idle_sleeve_parking_symbols: list[str] = Field(default_factory=lambda: ["QQQ", "SPY", "IWM"])
    """Risk-on ETF candidates for the parking sleeve."""

    orb_idle_sleeve_parking_mode: str = "best"
    """Parking sleeve selector. 'best' picks the strongest configured ETF by
    same-day close stats. 'pead_like' uses QQQM as the default risk-on parking
    asset, TQQQ only on strong market closes, and SGOV as the defensive
    fallback."""

    orb_idle_sleeve_parking_base_symbol: str = "QQQM"
    """Default risk-on parking symbol for orb_idle_sleeve_parking_mode='pead_like'."""

    orb_idle_sleeve_parking_overlay_symbol: str | None = "TQQQ"
    """Aggressive parking overlay symbol used only when the market close is strong."""

    orb_idle_sleeve_parking_defensive_symbol: str = "SGOV"
    """Defensive parking fallback when risk-on parking is not allowed or unavailable."""

    orb_idle_sleeve_parking_overlay_min_market_return_pct: float = 0.005
    """Minimum same-day market return required to use the aggressive overlay."""

    orb_idle_sleeve_parking_overlay_min_market_close_location: float = 0.70
    """Minimum same-day market close location required to use the aggressive overlay."""

    orb_idle_sleeve_force_defensive_fallback: bool = False
    """When True, open the defensive parking symbol if no other idle sleeve
    produces an order, so completely idle days still park capital."""

    orb_idle_sleeve_defensive_fill_unused_budget: bool = False
    """When True, any idle-sleeve budget not used by active risky sleeves is
    filled with the defensive parking symbol. This lets ORB no-trade days stay
    mostly invested without forcing weak single-name exposure."""

    orb_idle_sleeve_risk_off_symbols: list[str] = Field(default_factory=lambda: ["GLD", "SGOV"])
    """Defensive candidates for the risk-off alpha sleeve."""

    orb_idle_sleeve_sector_rotation_symbols: list[str] = Field(default_factory=list)
    """Sector/theme ETF candidates for ORB idle-day rotation."""

    orb_idle_sleeve_sector_rotation_min_day_return_pct: float | None = 0.0
    """Minimum same-day return required for sector/theme ETF rotation."""

    orb_idle_sleeve_sector_rotation_min_close_location: float | None = 0.55
    """Minimum close location required for sector/theme ETF rotation."""

    orb_idle_sleeve_market_ticker: str = "QQQ"
    """Market ticker used to decide whether the risk-on parking sleeve is allowed."""

    orb_idle_sleeve_min_market_day_return_pct: float | None = -0.005
    """Minimum open-to-close return of orb_idle_sleeve_market_ticker required
    for the risk-on parking sleeve. None disables this gate."""

    orb_idle_sleeve_min_market_close_location: float | None = None
    """Minimum open-to-entry close location of orb_idle_sleeve_market_ticker
    required for risk-on idle sleeves. This avoids buying QQQM/TQQQ when the
    market is positive but fading from its morning range."""

    orb_idle_sleeve_idle_min_day_return_pct: float = 0.01
    """Minimum regular-session open-to-close return for the liquid idle-alpha stock sleeve."""

    orb_idle_sleeve_idle_min_close_location: float = 0.60
    """Minimum regular-session close location for the liquid idle-alpha stock sleeve."""

    orb_idle_sleeve_idle_min_day_dollar_vol: float = 10_000_000.0
    """Minimum same-day regular-session dollar volume for idle-alpha stock candidates."""

    orb_idle_sleeve_idle_min_avg_dollar_vol: float = 40_000_000.0
    """Minimum prior average daily dollar volume for idle-alpha stock candidates."""

    orb_idle_sleeve_idle_min_score: float | None = None
    """Minimum idle-alpha conviction score required before carrying a single
    stock overnight. None disables the score gate."""

    orb_idle_sleeve_idle_max_day_return_pct: float | None = None
    """Maximum regular-session open-to-entry return for idle-alpha stock candidates.
    Useful for avoiding already-overextended morning runners."""

    orb_idle_sleeve_idle_min_gap_pct: float | None = None
    """Minimum opening gap for idle-alpha stock candidates."""

    orb_idle_sleeve_idle_max_gap_pct: float | None = None
    """Maximum opening gap for idle-alpha stock candidates."""

    orb_idle_sleeve_idle_min_ret_5d: float | None = None
    """Minimum prior 5-day return for idle-alpha stock candidates."""

    orb_idle_sleeve_idle_max_ret_5d: float | None = None
    """Maximum prior 5-day return for idle-alpha stock candidates."""

    orb_idle_sleeve_reclaim_checkpoint_minutes: int = 60
    """Minutes after the open used to identify weak-open/late-reclaim setups."""

    orb_idle_sleeve_reclaim_min_day_return_pct: float = 0.004
    """Minimum open-to-entry return for late-reclaim stock candidates."""

    orb_idle_sleeve_reclaim_max_early_return_pct: float = 0.004
    """Maximum open-to-checkpoint return for late-reclaim stock candidates.
    This keeps the sleeve focused on names that were not already morning ORB
    runners."""

    orb_idle_sleeve_reclaim_min_late_return_pct: float = 0.006
    """Minimum checkpoint-to-entry return for late-reclaim stock candidates."""

    orb_idle_sleeve_reclaim_min_close_location: float = 0.70
    """Minimum open-to-entry close location for late-reclaim candidates."""

    orb_idle_sleeve_reclaim_min_day_dollar_vol: float = 20_000_000.0
    """Minimum same-day dollar volume for late-reclaim candidates."""

    orb_idle_sleeve_reclaim_min_avg_dollar_vol: float = 80_000_000.0
    """Minimum prior average daily dollar volume for late-reclaim candidates."""

    orb_idle_sleeve_reclaim_min_score: float | None = None
    """Minimum late-reclaim conviction score required before carrying a single
    stock overnight. None disables the score gate."""

    orb_idle_sleeve_reclaim_min_gap_pct: float | None = None
    """Minimum opening gap for late-reclaim candidates."""

    orb_idle_sleeve_reclaim_max_gap_pct: float | None = None
    """Maximum opening gap for late-reclaim candidates."""

    orb_idle_sleeve_reclaim_min_ret_5d: float | None = None
    """Minimum prior 5-day return for late-reclaim candidates."""

    orb_idle_sleeve_reclaim_max_ret_5d: float | None = None
    """Maximum prior 5-day return for late-reclaim candidates."""

    orb_idle_sleeve_event_min_day_return_pct: float = -0.005
    """Minimum day return for Form 4 and ownership fallback candidates."""

    orb_idle_sleeve_event_min_close_location: float = 0.50
    """Minimum open-to-entry close location for Form 4 and ownership fallback
    candidates. ORB uses event flags only when the tape confirms intraday."""

    orb_idle_sleeve_event_min_day_dollar_vol: float = 0.0
    """Minimum same-day regular-session dollar volume for Form 4 and ownership
    fallback candidates."""

    orb_idle_sleeve_event_min_avg_dollar_vol: float = 0.0
    """Minimum prior average daily dollar volume for Form 4 and ownership
    fallback candidates."""

    orb_idle_sleeve_event_min_gap_pct: float | None = None
    """Minimum opening gap for Form 4 and ownership fallback candidates."""

    orb_idle_sleeve_event_max_gap_pct: float | None = None
    """Maximum opening gap for Form 4 and ownership fallback candidates."""

    orb_idle_sleeve_event_min_ret_5d: float | None = None
    """Minimum prior 5-day return for Form 4 and ownership fallback candidates."""

    orb_idle_sleeve_event_max_ret_5d: float | None = None
    """Maximum prior 5-day return for Form 4 and ownership fallback candidates."""

    orb_idle_sleeve_event_max_days_since: int | None = None
    """Maximum age of Form 4 / ownership event flags for ORB idle sleeves."""

    orb_idle_sleeve_min_market_day_return_for_stock_sleeves_pct: float | None = None
    """Minimum market-ticker open-to-close return required before the stock
    sleeves (idle_alpha, Form 4, ownership) can deploy. This keeps the close
    fallback from buying single-name strength against a weak tape."""

    max_gap_pct: float | None = None
    """Maximum opening gap (open vs prev_close) allowed for ORB candidates.
    Stocks that gap >10% at open are over-extended and prone to reversal — they have a low
    ORB breakout continuation rate. None = no cap (allow any gap). E.g. 0.10 = 10% cap."""

    min_abs_gap_pct: float | None = None
    """Minimum absolute opening gap required to treat the session as catalyst-like.
    Useful proxy when explicit same-day news labels are unavailable. None = disabled."""

    min_premarket_dollar_vol: float | None = None
    """Minimum premarket dollar volume (04:00-09:30 ET) required for candidate inclusion.
    Acts as a same-day attention/liquidity filter. None = disabled."""

    small_gap_attention_override_premarket_dollar_vol: float | None = None
    """For gainers_leader / leader_followthrough: allow candidates below min_abs_gap_pct when premarket
    dollar volume is exceptionally high. This is meant for mega-cap / leader followthrough
    days that do not gap much but clearly dominate premarket attention."""

    small_gap_attention_override_rvol: float | None = None
    """Optional RVOL floor paired with small_gap_attention_override_premarket_dollar_vol.
    When set, small-gap candidates must also show at least this opening-range RVOL to
    bypass min_abs_gap_pct."""

    max_small_gap_attention_candidates: int | None = None
    """Optional daily cap on candidates admitted via the small-gap attention override.
    Useful to keep exceptional followthrough names from diluting the core gap-driven
    gainers basket. None disables the cap."""

    # Market regime
    market_regime_spy_threshold: float | None = None
    """Skip trading if regime ticker's opening gap is below this threshold. None = disabled.
    E.g. -0.005 = skip if regime ticker gaps down >0.5% at open."""

    market_regime_ticker: str = "SPY"
    """Ticker used for the gap-based market regime check. Default 'SPY'.
    IWM often works better for ORB (mid/small-cap universe matches ORB candidates).
    Only used when market_regime_spy_threshold is not None."""

    min_candidate_breadth: float | None = None
    """Skip day if fewer than this fraction of intraday tickers opened above prev close.
    E.g. 0.30 = skip if <30% of day's candidates gapped up.
    Sweep result: 0.30 gives Sharpe 19.86 (vs 18.31 no filter), 0.50 gives 20.27.
    This is more robust than single-ETF regime checks because it measures the actual
    candidate pool's sentiment. None = disabled."""

    compound_returns: bool = True
    """When True (default), position sizing scales with current equity (compounding).
    When False, position sizing always uses initial_capital (simple/단리 mode).
    Simple mode prevents late-period bias where larger equity dominates the return metric.
    Ignored when daily_budget_reset is True."""

    daily_budget_reset: bool = False
    """Research-only mode: every day resets sizing_capital and buying power to
    initial_capital, so prior-day PnL does not compound into position budget.
    Stateful strategy governors still apply when explicitly configured.
    When True, takes precedence over compound_returns."""

    trailing_stop_atr_multiplier: float = 0.0
    """ATR-based trailing stop distance from peak price. 0 = disabled (use swing-low mode).
    When > 0: trailing_stop = peak_price - atr * this_value. Bar-size independent.
    E.g. 1.5 = trail 1.5×ATR(14) below the running peak. Activates at trailing_at_r.
    Swing-low mode (0.0) ties trailing sensitivity to sim_bar_minutes — ATR mode removes that dependency."""

    trailing_tighten_at_r: float | None = None
    """Two-stage trailing: when R reaches this level, switch to trailing_stop_atr_multiplier_tight.
    None = single-stage trailing (no tightening). Requires trailing_stop_atr_multiplier > 0.
    E.g. trailing_at_r=1.5 (wide trail) + trailing_tighten_at_r=3.0 (tight trail)."""

    trailing_stop_atr_multiplier_tight: float = 0.0
    """ATR multiplier for the tighter second-stage trailing stop (used when trailing_tighten_at_r is hit).
    0.0 = fall back to trailing_stop_atr_multiplier (effectively disables tightening)."""

    max_simultaneous_entries: int | None = None
    """Maximum trades that can enter on the same bar timestamp. None = unlimited.
    Prevents the 9:35 burst where all candidates break out simultaneously, overwhelming
    the kill switch and creating uncontrolled correlated risk. Top-ranked candidates are taken first."""

    entry_tie_break_rank_by_score: bool = False
    """When multiple candidates enter on the same timestamp, rank them by the
    candidate score before applying cash and simultaneous-entry constraints.
    This is especially important when auxiliary sleeves are merged after the
    primary list, otherwise high-score auxiliary leaders can be starved by
    lower-score primary candidates on the same opening breakout bar."""

    max_trades_per_day: int | None = None
    """Maximum total ORB trades allowed per day. None = unlimited.
    This is a day-level concentration cap, distinct from max_simultaneous_entries:
    it stops lower-priority later fills after the day has already deployed enough
    names, even when those fills occur on different bars."""

    late_trade_size_scale_after_n: int | None = None
    """Apply late_trade_size_scale after this many accepted trades on the same day.
    None = disabled. For example, 4 scales the 5th and later ORB fills without
    hard-skipping them, preserving more path information than max_trades_per_day."""

    late_trade_size_scale: float = 1.0
    """Position-size multiplier for trades after late_trade_size_scale_after_n.
    Values are clamped to [0, 1]."""

    rank_rvol_pressure_min_rvol: float | None = None
    """Minimum opening RVOL for the rank/RVOL pressure size overlay.
    None disables the overlay. This targets noisy high-attention names that
    are not top-ranked enough to justify full risk."""

    rank_rvol_pressure_max_score_rank_pct: float | None = None
    """Maximum score_rank_pct eligible for the rank/RVOL pressure overlay.
    1.0 is top ranked; lower values target weaker-ranked candidates."""

    rank_rvol_pressure_max_ret_5d: float | None = None
    """Optional maximum prior 5-day return for the rank/RVOL pressure overlay.
    Useful for isolating high-RVOL repair attempts without penalizing true
    positive-momentum leaders. None ignores prior 5-day return."""

    rank_rvol_pressure_size_scale: float = 1.0
    """Position-size multiplier for rank/RVOL pressure candidates.
    Values are clamped to [0, 1]."""

    gap_exhaustion_pressure_min_gap_zscore: float | None = None
    """Minimum gap_zscore_20d for the gap-exhaustion pressure overlay.
    None disables the overlay. This targets stretched positive gaps whose
    opening-range close does not confirm strong control."""

    gap_exhaustion_pressure_max_close_location: float | None = None
    """Maximum ORB close_location eligible for the gap-exhaustion pressure overlay."""

    gap_exhaustion_pressure_min_ret_5d: float | None = None
    """Optional minimum prior 5-day return for the gap-exhaustion pressure overlay.
    Use this to isolate already-advanced names rather than fresh repair gaps."""

    gap_exhaustion_pressure_min_gap_pct: float | None = None
    """Optional minimum positive gap_pct for the gap-exhaustion pressure overlay."""

    gap_exhaustion_pressure_size_scale: float = 1.0
    """Position-size multiplier for gap-exhaustion pressure candidates.
    Values are clamped to [0, 1]."""

    stale_obv_rvol_pressure_max_rvol: float | None = None
    """Maximum opening RVOL for the stale-OBV/RVOL pressure overlay.
    None disables the overlay. This targets stale OBV candidates that are
    breaking out without enough relative-volume confirmation."""

    stale_obv_rvol_pressure_max_obv_slope_20d: float | None = None
    """Maximum 20-day OBV slope for the stale-OBV/RVOL pressure overlay."""

    stale_obv_rvol_pressure_max_ret_5d: float | None = None
    """Optional maximum prior 5-day return for stale-OBV/RVOL pressure."""

    stale_obv_rvol_pressure_max_score_rank_pct: float | None = None
    """Optional maximum score_rank_pct for stale-OBV/RVOL pressure.
    1.0 is top ranked; lower values target weaker-ranked candidates."""

    stale_obv_rvol_pressure_size_scale: float = 1.0
    """Position-size multiplier for stale-OBV/RVOL pressure candidates.
    Values are clamped to [0, 1]."""

    stale_obv_rvol_pressure_reserve_full_cash: bool = False
    """When True, scaled stale-OBV/RVOL trades reserve the unscaled cash slot.
    This reduces exposure without allowing freed cash to backfill into lower
    priority same-day trades."""

    unboosted_primary_fragility_size_scale: float = 1.0
    """Position-size multiplier for weak primary ORB setups that did not qualify
    for any high-conviction booster. Values are clamped to [0, 1]."""

    unboosted_primary_fragility_allowed_trigger_types: list[str] | None = None
    """Optional entry trigger allowlist for unboosted-primary fragility scaling.
    None keeps the overlay available to every primary trigger."""

    unboosted_primary_fragility_crowded_min_gap_pct: float | None = None
    """Minimum positive gap for the crowded-liquid stall fragility profile."""

    unboosted_primary_fragility_crowded_min_premarket_dollar_vol: float | None = None
    """Minimum premarket dollar volume for the crowded-liquid stall profile."""

    unboosted_primary_fragility_crowded_max_close_location: float | None = None
    """Maximum ORB close_location for the crowded-liquid stall profile."""

    unboosted_primary_fragility_weak_max_rvol: float | None = None
    """Maximum opening RVOL for the weak-attention fragility profile."""

    unboosted_primary_fragility_weak_max_ret_5d: float | None = None
    """Maximum prior 5-day return for the weak-attention fragility profile."""

    unsupported_attention_size_scale: float = 1.0
    """Position-size multiplier for unsupported attention spikes. This defensive
    governor targets candidates with heavy attention/liquidity but without
    sector confirmation or leader-quality support. Values are clamped to [0, 1]."""

    unsupported_attention_reserve_full_cash: bool = False
    """When True, scaled unsupported-attention trades reserve the unscaled cash
    slot. This reduces exposure without letting freed cash backfill into weaker
    same-day candidates."""

    unsupported_attention_allowed_trigger_types: list[str] | None = None
    """Optional trigger allowlist for the unsupported-attention governor."""

    unsupported_attention_ignore_high_conviction: bool = True
    """When True, do not scale trades that already received a high-conviction
    allocator boost such as liquid-leader or opening-burst sizing."""

    unsupported_attention_liquid_min_premarket_dollar_vol: float | None = None
    """Minimum premarket dollar volume for the liquid unsupported-attention profile."""

    unsupported_attention_liquid_max_candidate_score: float | None = None
    """Maximum candidate score for the liquid unsupported-attention profile."""

    unsupported_attention_liquid_require_no_sector_confirmation: bool = True
    """When True, the liquid unsupported-attention profile only applies to
    candidates without same-sector confirmation."""

    unsupported_attention_thin_max_premarket_dollar_vol: float | None = None
    """Maximum premarket dollar volume for the thin positive-gap attention profile."""

    unsupported_attention_thin_max_first_bar_dollar_vol: float | None = None
    """Optional maximum first-bar dollar volume for the thin positive-gap profile.
    This narrows the guard to names that are thin both before the open and in
    the opening range, instead of penalizing every low-premarket setup."""

    unsupported_attention_thin_min_gap_pct: float | None = None
    """Minimum positive opening gap for the thin attention profile."""

    unsupported_attention_thin_min_rvol: float | None = None
    """Minimum opening RVOL for the thin attention profile."""

    unsupported_attention_thin_max_rvol: float | None = None
    """Optional RVOL ceiling for the thin attention profile."""

    positive_gap_rebound_failure_size_scale: float = 1.0
    """Position-size multiplier for high-attention positive-gap rebound failures.

    This defensive governor targets long ORB candidates that gap up after a
    sharp 5-day selloff but fail to produce a positive opening-range return.
    Values are clamped to [0, 1].
    """

    positive_gap_rebound_failure_allowed_trigger_types: list[str] | None = None
    """Optional trigger allowlist for the positive-gap rebound-failure governor."""

    positive_gap_rebound_failure_reserve_full_cash: bool = False
    """When True, scaled rebound-failure trades reserve the unscaled cash slot.
    This reduces trap exposure without letting freed cash backfill into lower
    priority same-day candidates.
    """

    positive_gap_rebound_failure_min_gap_pct: float | None = None
    """Minimum positive opening gap for the rebound-failure profile."""

    positive_gap_rebound_failure_max_ret_5d: float | None = None
    """Maximum prior 5-day return for the rebound-failure profile."""

    positive_gap_rebound_failure_max_orb_return: float | None = None
    """Maximum first ORB candle return for the rebound-failure profile."""

    positive_gap_rebound_failure_min_volume_attention_rank_pct: float | None = None
    """Minimum volume-attention percentile for the rebound-failure profile."""

    red_to_green_acceleration_min_abs_gap_pct: float | None = None
    """Minimum downside gap magnitude for the red-to-green acceleration booster.
    Example: 0.05 targets candidates opening at least 5% below prev close."""

    red_to_green_acceleration_min_orb_return: float | None = None
    """Minimum first ORB candle return required for the acceleration booster."""

    red_to_green_acceleration_min_score_rank_pct: float | None = None
    """Minimum score_rank_pct required for the acceleration booster."""

    red_to_green_acceleration_min_candidate_score: float | None = None
    """Minimum composite candidate score required for the acceleration booster."""

    red_to_green_acceleration_min_premarket_dollar_vol: float | None = None
    """Optional minimum premarket dollar volume required for the booster."""

    red_to_green_acceleration_ignore_scaled_risk_overlays: bool = True
    """When True, do not boost candidates already reduced by defensive overlays."""

    red_to_green_acceleration_size_scale: float = 1.0
    """Position-size multiplier for qualifying red-to-green acceleration setups.
    Values at or below 1.0 disable the booster; values above 1.0 increase size."""

    liquid_leader_conviction_min_candidate_score: float | None = None
    """Minimum composite score for the liquid-leader conviction booster.
    None disables the candidate-score gate."""

    liquid_leader_conviction_min_score_rank_pct: float | None = None
    """Minimum score_rank_pct for the liquid-leader conviction booster."""

    liquid_leader_conviction_min_premarket_dollar_vol: float | None = None
    """Minimum premarket dollar volume for the liquid-leader conviction booster."""

    liquid_leader_conviction_min_rvol: float | None = None
    """Optional minimum opening RVOL for the liquid-leader conviction booster."""

    liquid_leader_conviction_min_abs_gap_pct: float | None = None
    """Optional minimum absolute gap magnitude for the liquid-leader conviction booster."""

    liquid_leader_conviction_allowed_trigger_types: list[str] | None = None
    """Optional entry trigger allowlist for the liquid-leader conviction booster.
    None keeps the prior behavior and allows every trigger type."""

    liquid_leader_conviction_secondary_requires_sector_confirmation: bool = False
    """When True, non-ORB liquid-leader boosts require sector confirmation.
    ORB entries can still qualify on single-name leadership."""

    liquid_leader_conviction_secondary_min_body_ratio: float | None = None
    """Optional minimum opening-bar body ratio for non-ORB liquid-leader boosts.
    ORB entries are not affected by this secondary-entry quality gate."""

    liquid_leader_conviction_ignore_scaled_risk_overlays: bool = True
    """When True, do not boost candidates already reduced by defensive overlays."""

    liquid_leader_conviction_size_scale: float = 1.0
    """Position-size multiplier for qualifying liquid high-conviction leaders.
    Values at or below 1.0 disable the booster; values above 1.0 increase size."""

    opening_burst_liquid_size_scale: float = 1.0
    """Position-size multiplier for liquid leaders that trigger in the opening burst.
    This is a time-of-entry allocator: it only boosts names that both attract
    substantial premarket dollar volume and break out immediately after the
    opening range, where same-day attention is most concentrated."""

    opening_burst_liquid_max_entry_minutes_after_open: int | None = None
    """Latest entry timestamp, in minutes after 09:30 ET, eligible for the
    opening-burst liquid allocator. None disables the time gate."""

    opening_burst_liquid_min_premarket_dollar_vol: float | None = None
    """Minimum premarket dollar volume required for opening-burst liquid sizing."""

    opening_burst_liquid_min_gap_pct: float | None = None
    """Optional minimum opening gap required for opening-burst liquid sizing.
    None keeps the legacy behavior. Use 0.0 to restrict the boost to flat/up-gap
    names and avoid allocating extra capital to downside-gap reclaim attempts."""

    opening_burst_liquid_min_score_rank_pct: float | None = None
    """Optional minimum daily candidate rank percentile for opening-burst liquid sizing."""

    opening_burst_liquid_min_candidate_score: float | None = None
    """Optional minimum composite candidate score for opening-burst liquid sizing.
    This prevents a name from receiving extra early-burst capital solely because
    it is top-ranked on a weak candidate day."""

    opening_burst_liquid_allowed_trigger_types: list[str] | None = None
    """Optional trigger allowlist for opening-burst liquid sizing.
    None = allow all trigger types, though ORB-only is usually cleaner."""

    opening_burst_liquid_ignore_scaled_risk_overlays: bool = True
    """When True, do not boost candidates already reduced by defensive overlays."""

    partial_exit_at_r: float | None = None
    """Lock in partial profits when R-multiple reaches this level. None = disabled.
    E.g. 1.0 = sell partial_exit_pct of the position at 1R, let remainder run."""

    partial_exit_pct: float = 0.50
    """Fraction of position to exit at partial_exit_at_r. 0.50 = sell half the position."""

    market_regime_spy_trend_days: int | None = None
    """Multi-day SPY trend filter: look back this many trading days for cumulative return.
    None = disabled. Requires market_regime_spy_trend_threshold to also be set.
    E.g. 5 = look at SPY's 5-day cumulative return ending yesterday."""

    market_regime_spy_trend_threshold: float | None = None
    """Skip day if SPY's cumulative return over market_regime_spy_trend_days is below this.
    E.g. -0.03 = skip if SPY down >3% over the past N days.
    Protects against sustained bear-market weeks (single-day gap filter misses these)."""

    rolling_loss_days: int | None = None
    """Self-referential rolling loss filter: look back this many trading days for strategy P&L.
    None = disabled. Requires rolling_loss_threshold to also be set.
    E.g. 5 = compute strategy's cumulative return over past 5 trading days."""

    rolling_loss_threshold: float | None = None
    """Skip day if strategy's own rolling return (past rolling_loss_days) is below this.
    E.g. -0.05 = pause trading if strategy lost >5% of initial capital in past 5 days.
    Protects against cascading losses in regimes where the strategy stops working."""

    market_orb_quality_veto_rolling_loss_pct: float | None = None
    """Synthetic rolling-loss PnL applied only to the rolling loss governor when
    a market ORB quality veto blocks all trades for the day.
    This does not change reported PnL/equity; it prevents an avoided high-risk
    day from immediately re-enabling risk after the veto suppressed real losses.
    E.g. -0.05 records -5% of initial_capital in the rolling loss window."""

    # VIX regime filter and position size scaling
    max_vix: float | None = None
    """Skip the whole day if VIX closes above this threshold. None = disabled.
    E.g. 30.0 = skip days when VIX > 30 (high fear regime)."""

    vix_size_scale_low: float | None = None
    """VIX level where position size scaling starts (scaler = 1.0 below this). None = disabled."""

    vix_size_scale_high: float | None = None
    """VIX level where the position-size scaler reaches vix_size_scale_min."""

    vix_size_scale_min: float = 1.0
    """Minimum position-size scaler once VIX reaches vix_size_scale_high.
    E.g. 0.50 = halve position sizes when VIX is at or above vix_size_scale_high."""

    # V20: soft regime/breadth scalers (all default to V19 binary-skip behavior)
    regime_size_scale_low: float | None = None
    """QQQ gap at which regime scaler bottoms out. None = V19 binary skip."""
    regime_size_scale_high: float | None = None
    """QQQ gap at which regime scaler = 1.0. Binary skip fires when gap < market_regime_spy_threshold."""
    regime_size_scale_min: float = 1.0
    """Minimum regime scaler. 1.0 = V19 behavior."""
    regime_skip_below: float | None = None
    """Hard skip floor below regime_size_scale_low. None = no extra skip."""

    soft_day_fallback_on_regime_skip: bool = False
    """When True, a legacy market-regime skip can become a small soft-day
    fallback instead of a full no-trade day. Disabled by default to preserve
    existing ORB strategy behavior."""

    soft_day_regime_skip_size_scale: float = 1.0
    """Day-level size scale used when soft_day_fallback_on_regime_skip converts
    a market-regime skip into a fallback trading day."""

    market_orb_quality_ticker: str | None = None
    """Ticker used for the market first-5min ORB quality scaler.
    None = fall back to market_regime_ticker, then SPY."""

    market_orb_quality_secondary_ticker: str | None = None
    """Optional second ticker used for divergence-aware market ORB quality guards."""

    market_orb_quality_size_scale_low: float | None = None
    """First-bar close_location where the market ORB quality scaler bottoms out.
    None disables the scaler."""

    market_orb_quality_size_scale_high: float | None = None
    """First-bar close_location where the market ORB quality scaler reaches
    market_orb_quality_size_scale_max."""

    market_orb_quality_size_scale_min: float = 1.0
    """Minimum market ORB quality scaler when close_location is at or below
    market_orb_quality_size_scale_low."""

    market_orb_quality_size_scale_max: float = 1.0
    """Maximum market ORB quality scaler when close_location is at or above
    market_orb_quality_size_scale_high."""

    market_orb_quality_primary_strong_above: float | None = None
    """Primary market ORB close_location threshold that counts as 'strong' for the
    divergence guard. Used with market_orb_quality_secondary_weak_below."""

    market_orb_quality_secondary_weak_below: float | None = None
    """Secondary market ORB close_location threshold that counts as 'weak' for the
    divergence guard. Used with market_orb_quality_primary_strong_above."""

    market_orb_quality_secondary_weak_above: float | None = None
    """Optional lower bound for the secondary market ORB close_location on
    divergence-guard days. Useful for targeting mildly weak split-tape opens
    without affecting deeply risk-off openings."""

    market_orb_quality_divergence_scale: float | None = None
    """Extra day-level size multiplier applied when the primary market ORB is strong
    but the secondary market ORB is weak. None = disabled."""

    market_orb_quality_divergence_max_trades: int | None = None
    """Optional total trade cap applied only on divergence-guard days.
    Useful for preventing multi-entry opening fades when the market tape is split."""

    market_orb_quality_primary_weak_below: float | None = None
    """Primary market ORB close_location threshold for weak-primary / strong-secondary split tape.
    Used with market_orb_quality_secondary_strong_above."""

    market_orb_quality_primary_weak_above: float | None = None
    """Optional lower bound for weak-primary / strong-secondary split-tape days.
    Useful for targeting weak but non-panic primary-index opens."""

    market_orb_quality_secondary_strong_above: float | None = None
    """Secondary market ORB close_location threshold that counts as strong when the
    primary market proxy is weak."""

    market_orb_quality_primary_weak_secondary_strong_scale: float | None = None
    """Extra day-level size multiplier applied when the primary market ORB is weak
    while the secondary market ORB is strong. None = disabled."""

    market_orb_quality_primary_weak_secondary_strong_max_trades: int | None = None
    """Optional total trade cap for weak-primary / strong-secondary split-tape days."""

    market_orb_quality_primary_lag_above: float | None = None
    """Optional lower bound for primary-lag / secondary-lead split-tape days.
    This targets non-confirmed secondary-index leadership where the primary
    market proxy is neither deeply weak nor strongly confirming."""

    market_orb_quality_primary_lag_below: float | None = None
    """Upper bound for the primary market ORB close_location on primary-lag /
    secondary-lead split-tape days."""

    market_orb_quality_secondary_lead_above: float | None = None
    """Lower bound for the secondary market ORB close_location on primary-lag /
    secondary-lead split-tape days."""

    market_orb_quality_secondary_lead_below: float | None = None
    """Optional upper bound for the secondary market ORB close_location.
    Useful for targeting unconfirmed but not euphoric secondary-index leadership."""

    market_orb_quality_primary_lag_secondary_lead_scale: float | None = None
    """Extra day-level size multiplier for primary-lag / secondary-lead split tape."""

    market_orb_quality_primary_lag_secondary_lead_max_trades: int | None = None
    """Optional total trade cap for primary-lag / secondary-lead split-tape days."""

    market_orb_quality_joint_weak_primary_below: float | None = None
    """Primary market ORB close_location threshold that counts as jointly weak.
    Used with market_orb_quality_joint_weak_secondary_below to target broad
    risk-off opens where both market proxies finish their first bar near the low."""

    market_orb_quality_joint_weak_primary_above: float | None = None
    """Optional lower bound for the primary market ORB close_location on jointly weak days.
    Useful for targeting mildly weak opens without also firing on panic-gap flushes."""

    market_orb_quality_joint_weak_secondary_below: float | None = None
    """Secondary market ORB close_location threshold that counts as jointly weak.
    Requires market_orb_quality_secondary_ticker and the paired primary threshold."""

    market_orb_quality_joint_weak_secondary_above: float | None = None
    """Optional lower bound for the secondary market ORB close_location on jointly weak days.
    Useful for narrowing the guard to a mild weak-open band."""

    market_orb_quality_joint_weak_scale: float | None = None
    """Extra day-level size multiplier applied when both primary and secondary
    market ORB bars are weak. None = disabled."""

    market_orb_quality_joint_weak_max_trades: int | None = None
    """Optional total trade cap applied only on jointly weak market-ORB days."""

    market_orb_quality_joint_weak_require_confirmation: bool = False
    """Force require_confirmation_bar on jointly weak market-ORB days.
    Useful for filtering early false-breakouts during broad weak opens."""

    market_orb_quality_joint_panic_primary_below: float | None = None
    """Primary market ORB close_location threshold for panic-low opens.
    Unlike the joint-weak band, this has no lower bound and targets deeply
    weak first bars that may still produce tradable single-name leaders."""

    market_orb_quality_joint_panic_secondary_below: float | None = None
    """Secondary market ORB close_location threshold for panic-low opens.
    Requires market_orb_quality_secondary_ticker and the paired primary threshold."""

    market_orb_quality_joint_panic_scale: float | None = None
    """Extra day-level size multiplier applied on joint panic-low market ORB days.
    None leaves sizing unchanged while confirmation/max-trade guards can still apply."""

    market_orb_quality_joint_panic_max_trades: int | None = None
    """Optional total trade cap applied only on joint panic-low market ORB days."""

    market_orb_quality_joint_panic_require_confirmation: bool = False
    """Force require_confirmation_bar on joint panic-low market ORB days.
    This keeps panic-open winners eligible but requires post-breakout repair."""

    market_thrust_breadth_override_enabled: bool = False
    """When True, a strong index opening thrust can override breadth-only
    soft-day sizing. This is intended for days where the candidate universe
    opens mixed but SPY/QQQ show clear opening-range risk-on confirmation."""

    market_thrust_breadth_override_min_primary_close_location: float | None = None
    """Minimum primary market ORB close_location required for breadth override."""

    market_thrust_breadth_override_min_secondary_close_location: float | None = None
    """Minimum secondary market ORB close_location required for breadth override."""

    market_thrust_breadth_override_min_primary_return_pct: float | None = None
    """Minimum primary market first-bar return required for breadth override."""

    market_thrust_breadth_override_min_secondary_return_pct: float | None = None
    """Minimum secondary market first-bar return required for breadth override."""

    market_thrust_breadth_override_min_regime_gap_pct: float | None = None
    """Minimum configured regime-ticker opening gap required for breadth override."""

    market_thrust_breadth_override_min_breadth_ratio: float | None = None
    """Minimum opening breadth ratio required before the override can fire.
    This prevents a strong index bar from overriding genuinely broken breadth."""

    market_thrust_breadth_override_size_scale_floor: float = 1.0
    """Minimum breadth scaler applied when the market-thrust override fires."""

    market_thrust_breadth_override_clear_soft_day: bool = False
    """When True, remove breadth-only soft-day status after the override.
    Default False keeps soft-day selection/caps and only raises sizing."""

    market_thrust_opening_breadth_override_enabled: bool = False
    """When True, use same-day first-bar universe breadth as an additional
    market-thrust override. This is a point-in-time proxy for broad intraday
    participation when index gaps or open-gap breadth understate the tape."""

    market_thrust_opening_breadth_override_min_total_count: int = 100
    """Minimum number of liquid first bars needed before opening-breadth override can fire."""

    market_thrust_opening_breadth_override_min_first_bar_dollar_vol: float | None = None
    """Optional minimum first-bar dollar volume per ticker included in opening-breadth stats."""

    market_thrust_opening_breadth_override_min_positive_ratio: float | None = None
    """Minimum fraction of included tickers with positive first-bar returns."""

    market_thrust_opening_breadth_override_min_avg_return_pct: float | None = None
    """Minimum average first-bar return across included tickers."""

    market_thrust_opening_breadth_override_strong_close_location: float = 0.65
    """Close-location threshold used to count strong first bars in the universe."""

    market_thrust_opening_breadth_override_min_strong_close_location_ratio: float | None = None
    """Minimum fraction of included tickers closing above the strong close-location threshold."""

    market_thrust_opening_breadth_override_allow_regime_soft_day: bool = True
    """Allow opening-breadth thrust to override market-regime soft days."""

    market_thrust_opening_breadth_override_allow_breadth_soft_day: bool = True
    """Allow opening-breadth thrust to override ordinary breadth soft days."""

    market_thrust_opening_breadth_override_allow_hard_breadth: bool = False
    """Allow opening-breadth thrust on hard-breadth fallback days. Disabled by
    default because hard-breadth days are usually broad risk-off gaps."""

    market_thrust_opening_breadth_override_regime_size_scale_floor: float = 1.0
    """Minimum regime scaler applied when opening-breadth override fires."""

    market_thrust_opening_breadth_override_breadth_size_scale_floor: float = 1.0
    """Minimum breadth scaler applied when opening-breadth override fires."""

    market_thrust_opening_breadth_override_clear_soft_day: bool = False
    """When True, remove soft-day reason parts allowed by the opening-breadth override."""

    market_thrust_opening_breadth_override_activate_liquid_continuation: bool = True
    """When False, opening-breadth-only thrust does not activate the existing
    market_thrust_liquid_continuation sleeve. This lets a strategy use broad
    first-bar participation only for specialized auxiliary sleeves such as
    opening-impulse reclaim, while keeping the liquid opening-burst sleeve tied
    to explicit SPY/QQQ thrust."""

    conditional_confirmation_ticker: str | None = None
    """Ticker used for day-level conditional confirmation-bar activation.
    None falls back to market_regime_ticker, then market_orb_quality_ticker, then QQQ."""

    conditional_confirmation_below_return_pct: float | None = None
    """Force require_confirmation_bar for the day when the conditional_confirmation_ticker's
    first regular-session bar return is at or below this threshold.
    None = disabled. E.g. 0.0 = require confirmation when the market opens red."""

    breadth_size_scale_low: float | None = None
    """Breadth ratio at which breadth scaler bottoms out. None = V19 binary skip."""
    breadth_size_scale_high: float | None = None
    """Breadth ratio at which breadth scaler = 1.0."""
    breadth_size_scale_min: float = 1.0
    """Minimum breadth scaler. 1.0 = V19 behavior."""
    breadth_skip_below: float | None = None
    """Hard skip floor on breadth. None = no extra skip."""

    hard_breadth_soft_fallback_enabled: bool = False
    """When True, a breadth_skip_below day can become a tiny soft-day fallback
    instead of a full skip if the breadth ratio is still above
    hard_breadth_soft_fallback_min_breadth. Disabled by default."""

    hard_breadth_soft_fallback_min_breadth: float | None = None
    """Lowest candidate breadth ratio allowed for hard-breadth soft fallback.
    None allows all breadth_skip_below days to fall back when enabled."""

    hard_breadth_soft_fallback_size_scale: float = 0.03
    """Day-level size scale used when hard-breadth soft fallback converts a
    hard breadth skip into a defensive micro-exposure day."""

    soft_day_fallback_on_breadth_skip: bool = False
    """When True, a legacy candidate-breadth skip can become a small soft-day
    fallback instead of a full no-trade day. Disabled by default to preserve
    existing strategy behavior."""

    soft_day_breadth_skip_size_scale: float = 1.0
    """Day-level size scale used when soft_day_fallback_on_breadth_skip converts
    a candidate-breadth skip into a fallback trading day."""

    soft_day_combined_size_scale_floor: float | None = None
    """Minimum combined regime*breadth size scale for explicit skip-day fallback.
    None preserves raw multiplicative scaling."""

    # V20: regime-adaptive stops
    soft_day_scaler_threshold: float = 1.0
    """combined_scaler (regime*breadth) below this triggers soft-day stop adjustments."""
    atr_stop_multiplier_weak: float | None = None
    """Replaces atr_stop_multiplier on soft days. None = no change."""
    breakeven_at_r_weak: float | None = None
    """Replaces breakeven_at_r on soft days. None = no change."""

    # V20: soft-day selection bar
    soft_day_max_trades: int | None = None
    """Max trades per soft day (top-N by rank). None = no cap."""
    soft_day_min_score_pct: float | None = None
    """Min composite score rank_pct on soft days. None = no filter."""

    soft_day_min_candidate_score: float | None = None
    """Minimum absolute ORB candidate score on soft days. None = no filter.
    Unlike soft_day_min_score_pct, this rejects weak one-name fallback baskets
    even when the candidate is rank #1 by default."""

    soft_day_min_ret_5d: float | None = None
    """Minimum prior 5-day return for soft-day fallback trades. None = no filter."""

    soft_day_max_rvol: float | None = None
    """Maximum opening-range RVOL for soft-day fallback trades. None = no filter."""

    soft_day_setup_profile: str = "none"
    """Optional named setup filter for soft-day fallback trades.
    'none' keeps scalar threshold behavior. Named profiles can apply
    reason-specific, disjunctive filters for former no-trade days."""

    soft_day_rank_before_time: bool = False
    """When True, soft-day candidates are allocated by score before entry time.
    This avoids a low-conviction early trigger consuming the one-trade fallback
    sleeve ahead of a stronger later trigger."""

    soft_day_exclude_from_streak: bool = False
    """When True, trades taken on soft days do not update streak sizing state.
    This lets defensive fallback sleeves add small exposure without contaminating
    the main strong-regime allocator."""

    soft_day_exclude_from_settlement: bool = False
    """When True, soft-day fallback trades do not update settled-cash state.
    This lets auxiliary fallback sleeves add PnL without changing the next
    normal day's capital availability in daily-reset research mode."""

    # Breakout volume confirmation
    min_breakout_rel_vol: float | None = None
    """Minimum relative volume on the breakout bar vs average post-ORB bar volume.
    Filters low-conviction breakouts where price touches the level on thin volume.
    None = disabled (any volume accepted). E.g. 1.5 = breakout bar must have 1.5× avg bar volume."""

    # Time-decay trailing stop tightening
    time_decay_start_minutes: int | None = None
    """Minutes after market open (9:30 ET) to start tightening the trailing stop.
    None = disabled. E.g. 180 = start tightening at 12:30 PM ET."""

    time_decay_factor: float = 0.5
    """By close, the trailing ATR multiplier shrinks to this fraction of its base value.
    E.g. 0.5 = trail width halves linearly from time_decay_start_minutes to close."""

    # Running VWAP trailing exit
    vwap_exit_mode: str = "none"
    """VWAP-based exit mode:
    - 'none': disabled (default)
    - 'exit': exit when bar close crosses below running VWAP (longs) or above (shorts)
    - 'floor': use VWAP - buffer as trailing stop floor (can't trail above VWAP for longs)
    Running VWAP is computed from cumulative (typical_price × volume) / cumulative(volume)
    starting from market open."""

    vwap_exit_buffer_atr: float = 0.0
    """Buffer below VWAP (in ATR units) for 'floor' mode.
    E.g. 0.3 = trailing stop can't go below VWAP - 0.3×ATR.
    For 'exit' mode: exit only when close < VWAP - buffer×ATR (allows noise)."""

    vwap_exit_after_r: float = 0.0
    """Only activate VWAP exit after reaching this R-multiple.
    0.0 = active from entry. 1.0 = only after trade reaches 1R.
    Prevents premature VWAP exits on initial pullbacks after breakout."""

    # Score-based position sizing
    score_sizing_multiplier: float | None = None
    """Scale risk_per_trade_pct by candidate rank. Top candidate gets this multiplier,
    bottom gets 1.0x (linear interpolation). None = disabled (equal sizing).
    E.g. 2.0 = top pick risks 2× base, bottom pick risks 1×. Requires score rank
    to be passed from simulate_orb_day."""

    score_sizing_floor: float | None = None
    """Scale lower-ranked ORB candidates down by score rank.
    Top candidate keeps full base size; bottom candidate receives this fraction.
    None disables rank-downsizing. E.g. 0.50 means the weakest ranked name uses
    half size while the top ranked name uses full size."""

    ranked_downside_gap_min_abs_gap_pct: float | None = None
    """Minimum downside gap magnitude for the rank-aware downside-gap overlay.
    This overlay runs after candidate ranking, so it can target lower-ranked
    red-to-green attempts without changing the candidate list."""

    ranked_downside_gap_max_premarket_dollar_vol: float | None = None
    """Maximum premarket dollar volume for the rank-aware downside-gap overlay."""

    ranked_downside_gap_max_score_rank_pct: float | None = None
    """Maximum score_rank_pct eligible for the rank-aware downside-gap overlay.
    1.0 is the top candidate; lower values target weaker-ranked candidates."""

    ranked_downside_gap_min_market_secondary_close_location: float | None = None
    """Minimum secondary market ORB close location required for the overlay.
    This is used to isolate stock-specific weak downside gaps when QQQ's own
    opening range is not in panic mode."""

    ranked_downside_gap_action: str = "none"
    """Action for the rank-aware downside-gap overlay.
    'none' disables it; 'scale' applies ranked_downside_gap_size_scale;
    'scale_reserve' scales the trade while reserving the unscaled cash slot;
    'loss_cap' keeps normal sizing but applies ranked_downside_gap_loss_cap_pct."""

    ranked_downside_gap_size_scale: float = 1.0
    """Position-size multiplier for rank-aware downside-gap candidates."""

    ranked_downside_gap_loss_cap_pct: float | None = None
    """Fixed stop distance as a fraction of entry price for rank-aware
    downside-gap candidates when ranked_downside_gap_action='loss_cap'."""

    # Confirmation bar requirement
    require_confirmation_bar: bool = False
    """After breakout, require the NEXT bar to close above entry price (long) or
    below (short) to confirm. If the confirmation bar fails, skip the trade.
    Filters false breakouts where price barely touches the level and reverses."""

    # Gap fill protection
    exit_on_gap_fill: bool = False
    """Exit immediately if price drops below prev_close (long) or rises above (short).
    A gap fill means the original catalyst is being rejected by the market.
    Uses bar close for the check (not intra-bar low)."""

    gap_up_fill_exit_min_gap_pct: float | None = None
    """Enable candidate-level gap-fill exit for long positive-gap candidates
    whose opening gap is at least this value. None disables the overlay."""

    gap_up_fill_exit_max_gap_pct: float | None = None
    """Optional upper bound for candidate-level positive-gap fill exits."""

    gap_up_fill_exit_min_ret_5d: float | None = None
    """Optional minimum prior 5-day return for positive-gap fill exits."""

    gap_up_fill_exit_max_ret_5d: float | None = None
    """Optional maximum prior 5-day return for positive-gap fill exits."""

    gap_up_fill_exit_max_premarket_dollar_vol: float | None = None
    """Optional premarket dollar-volume ceiling for positive-gap fill exits."""

    gap_up_fill_exit_min_body_ratio: float | None = None
    """Optional minimum ORB body ratio for positive-gap fill exits."""

    gap_up_fill_exit_max_body_ratio: float | None = None
    """Optional maximum ORB body ratio for positive-gap fill exits."""

    gap_up_fill_exit_max_close_location: float | None = None
    """Optional ORB close-location ceiling for positive-gap fill exits."""

    # Max hold time
    max_hold_minutes: int | None = None
    """Maximum minutes to hold a position. None = hold until exit_minutes_before_close.
    E.g. 120 = exit 2 hours after entry regardless of profit/loss.
    Useful for capturing morning momentum without afternoon reversal risk."""

    time_stop_minutes: int | None = None
    """Minutes after entry to evaluate a conditional time stop.
    None disables it. Unlike max_hold_minutes, this exits only if the trade has
    not made enough progress."""

    time_stop_exit_below_r: float = 0.0
    """Exit at the time stop when current R-multiple is at or below this value."""

    time_stop_max_peak_r: float | None = None
    """Optional peak-R ceiling for time-stop exits.
    When set, a trade that already reached this R-multiple is allowed to continue."""

    early_failure_exit_minutes: int | None = None
    """Minutes after entry to monitor for immediate structural failure.
    None disables it. This exits false breakouts that quickly lose their
    breakout/entry/VWAP support before enough favorable excursion develops."""

    early_failure_exit_level: str = "breakout"
    """Support level used by the early-failure exit:
    'breakout' = original ORB breakout level, 'entry' = filled entry anchor,
    'vwap' = running session VWAP at the evaluation bar."""

    early_failure_exit_buffer_pct: float = 0.0
    """Tolerance around the selected support level before early-failure exit.
    Longs exit below level × (1 - buffer); shorts exit above level × (1 + buffer)."""

    early_failure_exit_max_peak_r: float | None = None
    """Optional peak-R ceiling for early-failure exits.
    When set, a trade that already reached this R-multiple is allowed to continue."""

    early_failure_exit_trigger_types: list[str] | None = None
    """Optional trigger allowlist for early-failure exits.
    None = apply to all trigger types."""

    # Bar close confirmation entry
    entry_on_bar_close: bool = False
    """Require breakout bar's CLOSE to be above breakout level (long) or below (short),
    not just the bar's HIGH/LOW. Enter at the bar's close price.
    Filters wick-only breakouts where price barely touches the ORB level and reverses.
    The trader waits for the 5-min bar to complete, then enters at the close price.
    Same-bar stop is skipped (trader was not in position during the bar)."""

    # ── Pyramiding (add to winners) ──
    pyramid_at_r: float | None = None
    """Add to winning position when R-multiple reaches this level. None = disabled.
    E.g. 1.0 = add pyramid_add_pct of original position when trade reaches 1R.
    Stop is moved to at least breakeven on the blended cost after adding."""

    pyramid_add_pct: float = 0.50
    """Fraction of original position size to add at each pyramid level.
    0.50 = add 50% of original shares (100 shares → add 50 → 150 total)."""

    pyramid_max_adds: int = 1
    """Maximum number of pyramid additions per trade. 1 = single add-on.
    Each subsequent add triggers at pyramid_at_r + n * pyramid_at_r (staggered)."""

    pyramid_allowed_trigger_types: list[str] | None = None
    """Optional trigger allowlist for pyramid additions.
    None = allow all trigger types that can open trades."""

    pyramid_min_score_rank_pct: float | None = None
    """Optional minimum candidate rank percentile required before pyramiding.
    1.0 = top-ranked candidate of the day, 0.0 = bottom-ranked candidate."""

    pyramid_min_rvol: float | None = None
    """Optional minimum opening relative volume required before pyramiding."""

    pyramid_max_rvol: float | None = None
    """Optional maximum opening relative volume allowed before pyramiding."""

    pyramid_require_sector_confirmation: bool = False
    """When True, pyramid add-ons are allowed only for candidates that passed
    the sector-confirmation engine. This keeps add-on risk reserved for winners
    with both price follow-through and cross-sectional sector participation."""

    # ── Re-entry after stop-out ──
    reentry_after_stop: bool = False
    """Allow re-entry on a ticker that was stopped out earlier in the same day.
    The ticker must re-break the ORB level with volume >= reentry_min_volume_ratio
    times the original breakout volume. Simulates the 'shakeout then real move' pattern."""

    reentry_min_volume_ratio: float = 1.5
    """Minimum volume ratio (vs original breakout bar) required for re-entry.
    1.5 = re-breakout bar must have 50% more volume than original breakout bar."""

    reentry_max_per_ticker: int = 1
    """Maximum re-entries allowed per ticker per day."""

    # ── Portfolio deployment cap ──
    max_total_deployment_pct: float | None = None
    """Maximum total capital deployed across all concurrent positions as fraction of equity.
    None = no limit (original behavior). E.g. 0.80 = never deploy more than 80% of equity.
    Prevents over-concentration when settlement_days=0 allows unlimited deployment."""

    # ── Drawdown governor ──
    drawdown_governor_threshold: float | None = None
    """Enable drawdown governor when equity drops this fraction below peak.
    None = disabled. E.g. 0.05 = start reducing sizing when equity is 5% below peak.
    Linearly scales sizing from 1.0 at threshold to drawdown_governor_min_scale at 2× threshold."""

    drawdown_governor_min_scale: float = 0.30
    """Minimum sizing scale at maximum drawdown governor activation.
    0.30 = reduce position sizes to 30% of normal at 2× drawdown_governor_threshold."""

    # ── Streak-based sizing ──
    streak_sizing_win_bonus: float | None = None
    """Bonus sizing multiplier per consecutive win in recent trade history.
    None = disabled. E.g. 0.15 = add 15% sizing per consecutive win.
    3 consecutive wins → 1.0 + 3*0.15 = 1.45x sizing.
    Computed at start of each day from previous days' trade outcomes."""

    streak_sizing_loss_penalty: float | None = None
    """Reduce sizing per consecutive loss. None = no penalty (only reward wins).
    E.g. 0.10 = subtract 10% per consecutive loss.
    2 consecutive losses → 1.0 - 2*0.10 = 0.80x sizing."""

    streak_sizing_max: float = 2.0
    """Cap on streak-based sizing multiplier. Prevents excessive leverage on long streaks."""

    streak_sizing_min: float = 0.50
    """Floor on streak-based sizing multiplier. Prevents sizing from going too low."""

    # ── Rolling performance sizing ──
    rolling_wr_sizing_window: int | None = None
    """Window of recent trades for rolling win-rate sizing bonus.
    None = disabled. E.g. 15 = compute WR over last 15 trades.
    Applied AFTER streak sizing (multiplicative)."""

    rolling_wr_sizing_threshold: float = 0.55
    """WR above this threshold triggers the bonus multiplier.
    E.g. 0.55 = if rolling WR > 55%, apply rolling_wr_sizing_bonus."""

    rolling_wr_sizing_bonus: float = 0.30
    """Bonus multiplier when rolling WR exceeds threshold.
    E.g. 0.30 = size 1.30x when rolling WR is above threshold."""

    rolling_wr_sizing_penalty_threshold: float | None = None
    """WR below this triggers a sizing reduction. None = no penalty.
    E.g. 0.40 = if rolling WR < 40%, reduce sizing by rolling_wr_sizing_penalty."""

    rolling_wr_sizing_penalty: float = 0.20
    """Penalty reduction when rolling WR is below penalty threshold.
    E.g. 0.20 = size 0.80x when rolling WR is below penalty threshold."""

    # ── Gap-adaptive trailing ──
    gap_trail_wide_threshold: float | None = None
    """Gap% above which trailing uses wider ATR multiplier. None = disabled.
    E.g. 0.05 = gaps > 5% get wider trailing (strong catalyst = longer trend).
    Uses gap_trail_wide_atr_multiplier instead of trailing_stop_atr_multiplier."""

    gap_trail_wide_atr_multiplier: float = 1.2
    """Trailing ATR multiplier for large-gap stocks (gap > gap_trail_wide_threshold).
    Wider trail lets strong catalyst stocks run further before stopping out."""

    gap_trail_tight_atr_multiplier: float | None = None
    """Optional tighter trailing for small-gap stocks (gap <= gap_trail_wide_threshold).
    None = use default trailing_stop_atr_multiplier. E.g. 0.5 = tight trail for small gaps."""

    conviction_runner_trail_tighten_at_r: float | None = None
    """Optional second-stage trailing threshold for high-conviction runner setups.
    When active, it overrides trailing_tighten_at_r only for trades that pass the
    conviction runner gates below."""

    conviction_runner_trail_gap_atr_multiplier: float | None = None
    """Optional ATR trailing multiplier for high-conviction runner setups before
    second-stage tightening. None keeps the normal/gap-adaptive ATR multiplier."""

    conviction_runner_trail_min_abs_gap_pct: float | None = None
    """Minimum absolute opening gap required for the conviction runner trail."""

    conviction_runner_trail_min_candidate_score: float | None = None
    """Minimum absolute composite candidate score for conviction runner trailing."""

    conviction_runner_trail_min_score_rank_pct: float | None = None
    """Minimum daily score rank percentile for conviction runner trailing."""

    conviction_runner_trail_allowed_trigger_types: list[str] | None = None
    """Optional trigger allowlist for conviction runner trailing."""

    # ── Pullback continuation entry ──
    pullback_entry: bool = False
    """Enable pullback continuation entry mode. Instead of entering immediately on
    ORB breakout, wait for a pullback after breakout and enter on continuation.
    Filters false breakouts and gives better entry prices with tighter stops."""

    pullback_max_bars: int = 6
    """Maximum bars to wait for pullback-continuation pattern after initial breakout.
    If no valid pullback+continuation within this window, skip the trade."""

    pullback_min_retracement_pct: float = 0.30
    """Minimum retracement of the breakout move to qualify as a pullback.
    0.30 = price must pull back at least 30% of (post-breakout peak - breakout level)."""

    pullback_stop_at_low: bool = True
    """Set stop at the pullback low instead of ATR-based stop.
    Gives naturally tighter stops based on actual price structure."""

    # ── orb_pullback_v1 extended pullback controls ──
    pullback_impulse_window_end_min: int | None = None
    """Minutes from market open (9:30 ET) by which the post-breakout impulse peak
    must form. E.g. 25 = peak must occur by 9:55 ET. None disables (legacy behavior)."""

    pullback_impulse_min_move_atr: float | None = None
    """Minimum impulse size from breakout level to peak as a multiple of ATR(14).
    E.g. 0.5 = peak must be at least 0.5 × ATR above breakout level. None disables."""

    pullback_depth_max_pct: float | None = None
    """Maximum pullback depth as a fraction of the impulse move. Works in conjunction
    with pullback_min_retracement_pct. E.g. 0.50 = pullback must retrace at most 50%
    of (peak - breakout). None disables (only min floor applied)."""

    pullback_volume_contraction_ratio: float | None = None
    """Require pullback-phase average bar volume < impulse-phase average × this ratio.
    E.g. 0.7 = pullback must occur on 70% or less of impulse volume. None disables."""

    pullback_vwap_floor: bool = False
    """If True, abort the pullback setup if any bar during pullback breaches below
    the running session VWAP by more than pullback_vwap_floor_tolerance_pct."""

    pullback_vwap_floor_tolerance_pct: float = 0.003
    """Tolerance below running VWAP before pullback_vwap_floor fires. 0.003 = -0.3%."""

    pullback_stop_mode: str = "atr"
    """Stop distance mode after pullback continuation entry.
    'atr' = use ATR × atr_stop_multiplier (or pullback_low if pullback_stop_at_low=True),
    'pullback_low' = always use pullback extreme as stop,
    'vwap_lower' = use running VWAP − pullback_stop_vwap_buffer_pct as stop."""

    pullback_stop_vwap_buffer_pct: float = 0.002
    """Buffer below running VWAP for 'vwap_lower' stop mode. 0.002 = −0.2%."""

    pullback_reclaim_confirm_rel_vol: float | None = None
    """Minimum relative volume on the reclaim/continuation bar. Computed as
    bar_volume / (avg_post_orb_bar_vol). E.g. 1.2 = bar must have 1.2× average
    post-ORB volume. None disables."""

    pullback_require_breakout_retake: bool = False
    """If True, the continuation bar must close back through the original breakout
    level after the pullback. This prevents entering on weak green bars that still
    sit below the ORB high / above the ORB low."""

    pullback_breakout_retake_clearance_pct: float = 0.0
    """Optional clearance beyond the breakout level required for the pullback
    continuation entry. 0.001 = close must reclaim the breakout by 0.1%."""

    # ── vwap_reclaim_v1 engine ──
    vwap_reclaim_window_start_min: int = 30
    """Minutes from market open (9:30 ET) when to start scanning for VWAP reclaim entries.
    30 = 10:00 ET. Only used by engine_family: vwap_reclaim_v1."""

    vwap_reclaim_window_end_min: int = 120
    """Minutes from market open when to stop accepting new VWAP reclaim entries.
    120 = 11:30 ET. Only used by engine_family: vwap_reclaim_v1."""

    vwap_reclaim_require_prior_dip: bool = False
    """If True, require that at least one bar before the reclaim window had close < running VWAP
    (for long). Selects only true VWAP reclaim setups (failed ORB then recovered), not stocks
    that drifted above VWAP all morning. Creates orthogonality with V23 (V23 winners never dip)."""

    vwap_reclaim_min_clearance_pct: float = 0.0
    """Minimum % that entry bar's close must be above the running VWAP (for long).
    E.g. 0.003 = close must be at least 0.3% above VWAP. Filters marginal reclaims."""

    vwap_reclaim_require_orb_open_retake: bool = False
    """If True, the reclaim bar must also recover the ORB open (for long) or lose it
    again (for short). Filters weak VWAP-only bounces that never repair the opening fade."""

    vwap_reclaim_confirm_rel_vol: float | None = None
    """Minimum relative volume on the reclaim bar, computed versus the average volume
    of prior post-ORB bars seen so far. E.g. 1.2 = reclaim bar volume must be at
    least 20% above the earlier post-ORB average. None disables."""

    vwap_reclaim_stop_mode: str = "atr"
    """Stop distance mode for vwap_reclaim_v1 entries.
    'atr' = ATR × atr_stop_multiplier (default),
    'vwap' = distance from entry_price to VWAP − buffer (structural floor stop)."""

    vwap_reclaim_stop_vwap_buffer_pct: float = 0.002
    """Buffer below VWAP for vwap stop mode. 0.002 = stop at VWAP × (1 - 0.2%)."""

    reclaim_entry_attention_enabled: bool = False
    """When True, require VWAP/reclaim-style fallback entries to show same-day
    entry-bar participation relative to other timed candidates. The signal uses
    percentile ranks, not absolute SIP-calibrated volume, so it is compatible
    with IEX live intraday bars."""

    reclaim_entry_attention_trigger_types: list[str] | None = None
    """Trigger types guarded by reclaim_entry_attention_enabled.
    None defaults to ['vwap_reclaim', 'soft_day_vwap_reclaim']."""

    reclaim_entry_attention_min_rank_pct: float | None = None
    """Minimum blended entry-participation percentile required for guarded
    reclaim triggers. None disables the hard gate."""

    reclaim_entry_attention_min_entry_rel_volume: float | None = None
    """Minimum entry-bar volume relative to earlier post-ORB bars for guarded
    reclaim triggers. This is scale-free and can be used without SIP volume."""

    reclaim_entry_attention_weight_entry_bar_dollar_vol: float = 0.45
    """Blend weight for the entry signal bar's dollar-volume percentile."""

    reclaim_entry_attention_weight_cumulative_dollar_vol: float = 0.35
    """Blend weight for cumulative same-day dollar-volume percentile through
    the entry signal bar."""

    reclaim_entry_attention_weight_entry_rel_vol: float = 0.20
    """Blend weight for entry-bar relative volume versus earlier post-ORB bars."""

    # ── No-fill fallback trigger ──
    nofill_vwap_reclaim_enabled: bool = False
    """Enable a VWAP-reclaim fallback trigger for days/candidates that do not produce
    a normal ORB/momentum entry. This is intended to attack traded_no_fill days
    without changing the primary ORB signal path."""

    nofill_vwap_reclaim_only_when_no_primary_entries: bool = True
    """When True, only add VWAP fallback candidates if the day has zero primary
    ORB/momentum timed entries. This preserves existing normal trade days."""

    nofill_vwap_reclaim_max_trades: int | None = 1
    """Maximum VWAP fallback trades per day. None disables the cap."""

    nofill_vwap_reclaim_min_score_pct: float | None = 0.8
    """Minimum rank percentile for VWAP fallback candidates. 1.0 = top ranked."""

    nofill_vwap_reclaim_size_scale: float = 0.25
    """Position-size multiplier applied only to VWAP fallback trades."""

    # ── Late breakout fallback trigger ──
    late_breakout_enabled: bool = False
    """Enable a late ORB-high/low breakout fallback after the normal order timeout.

    This is a separate auxiliary engine for candidates that never filled the
    primary ORB/momentum path. It waits for a later bar close through the opening
    range with optional VWAP and volume confirmation, then enters at that close.
    """

    late_breakout_only_when_no_primary_entries: bool = True
    """When True, add late-breakout candidates only if the day has zero primary
    ORB/momentum timed entries."""

    late_breakout_only_when_no_primary_trades: bool = False
    """When True, process late-breakout candidates after primary candidates and
    reject them if any primary ORB/momentum trade was actually taken."""

    late_breakout_only_when_no_existing_trades: bool = False
    """When True, reject late-breakout candidates if any earlier trade was
    already taken that day. This makes the sleeve a true no-trade fallback."""

    late_breakout_max_trades: int | None = 1
    """Maximum late-breakout fallback trades per day. None disables the cap."""

    late_breakout_min_score_pct: float | None = 0.85
    """Minimum rank percentile for late-breakout fallback candidates."""

    late_breakout_size_scale: float = 0.15
    """Position-size multiplier applied only to late-breakout fallback trades."""

    late_breakout_sizing_floor_pct: float | None = None
    """Optional minimum sizing-capital floor for late-breakout fallback trades,
    expressed as a fraction of the day's unscaled sizing capital.

    This lets the auxiliary engine take a small controlled probe on heavily
    scaled soft days where the normal day-level scaler would otherwise round
    the position down to zero shares. None keeps the inherited day scaler only.
    """

    late_breakout_window_start_min: int = 30
    """Earliest late-breakout evaluation time, minutes after market open."""

    late_breakout_window_end_min: int = 150
    """Latest late-breakout evaluation time, minutes after market open."""

    late_breakout_min_clearance_pct: float = 0.0
    """Minimum close-through clearance beyond the ORB breakout level."""

    late_breakout_require_vwap_confirmation: bool = True
    """When True, late-breakout entries must also close on the correct side of
    running session VWAP."""

    late_breakout_confirm_rel_vol: float | None = 1.2
    """Minimum late-breakout bar volume relative to prior post-ORB average volume."""

    # ── Soft-day auxiliary VWAP trigger ──
    soft_day_vwap_reclaim_enabled: bool = False
    """Enable a separate VWAP-reclaim micro sleeve only on soft days. This sleeve
    bypasses the named soft_day_setup_profile and uses its own stricter gates, so
    former no-trade days can be probed without relaxing the primary ORB path."""

    soft_day_vwap_reclaim_only_when_no_primary_entries: bool = False
    """When True, only add soft-day VWAP auxiliary candidates if the day has zero
    primary ORB/momentum timed entries. This lets the auxiliary sleeve target
    missed no-trade days without changing normal ORB allocation."""

    soft_day_vwap_reclaim_only_when_no_primary_trades: bool = False
    """When True, process soft-day VWAP auxiliary candidates only after the primary
    ORB/momentum path and reject them if any primary trade was actually taken.
    This targets days that the normal ORB path ultimately did not trade."""

    soft_day_vwap_reclaim_only_when_no_existing_trades: bool = False
    """When True, process soft-day VWAP auxiliary candidates only after all
    non-soft-day-VWAP triggers and reject them if any trade was already taken.
    This makes the sleeve a true no-trade-day fallback."""

    soft_day_vwap_reclaim_max_trades: int | None = 1
    """Maximum soft-day auxiliary VWAP trades per day. None disables the cap."""

    soft_day_vwap_reclaim_min_score_pct: float | None = 0.9
    """Minimum rank percentile for soft-day auxiliary VWAP candidates."""

    soft_day_vwap_reclaim_min_score_pct_market_regime: float | None = None
    """Optional score floor override for market-regime-only soft-day VWAP probes."""

    soft_day_vwap_reclaim_min_score_pct_breadth: float | None = None
    """Optional score floor override for breadth-only soft-day VWAP probes."""

    soft_day_vwap_reclaim_min_score_pct_joint: float | None = None
    """Optional score floor override when both market regime and breadth are soft."""

    soft_day_vwap_reclaim_min_score_pct_hard_breadth: float | None = None
    """Optional score floor override for hard-breadth fallback VWAP probes."""

    soft_day_vwap_reclaim_allowed_reason_parts: list[str] | None = None
    """Optional allowlist of soft-day reason parts for auxiliary VWAP probes.
    When set, at least one part of soft_day_reason must be present. Example:
    ['hard_breadth'] enables the sleeve only on hard-breadth fallback days."""

    soft_day_vwap_reclaim_min_premarket_dollar_vol: float | None = 10_000_000.0
    """Minimum premarket dollar volume for soft-day auxiliary VWAP candidates."""

    soft_day_vwap_reclaim_min_premarket_dollar_vol_market_regime: float | None = None
    """Optional premarket dollar-volume floor override for market-regime-only probes."""

    soft_day_vwap_reclaim_min_premarket_dollar_vol_breadth: float | None = None
    """Optional premarket dollar-volume floor override for breadth-only probes."""

    soft_day_vwap_reclaim_min_premarket_dollar_vol_joint: float | None = None
    """Optional premarket dollar-volume floor override when both regime and breadth are soft."""

    soft_day_vwap_reclaim_min_premarket_dollar_vol_hard_breadth: float | None = None
    """Optional premarket dollar-volume floor override for hard-breadth fallback probes."""

    soft_day_vwap_reclaim_min_ret_5d: float | None = None
    """Minimum prior 5-day return for soft-day auxiliary VWAP candidates."""

    soft_day_vwap_reclaim_min_ret_5d_market_regime: float | None = None
    """Optional prior 5-day return floor override for market-regime-only probes."""

    soft_day_vwap_reclaim_min_ret_5d_breadth: float | None = None
    """Optional prior 5-day return floor override for breadth-only probes."""

    soft_day_vwap_reclaim_min_ret_5d_joint: float | None = None
    """Optional prior 5-day return floor override when both regime and breadth are soft."""

    soft_day_vwap_reclaim_min_ret_5d_hard_breadth: float | None = None
    """Optional prior 5-day return floor override for hard-breadth fallback probes."""

    soft_day_vwap_reclaim_gap_down_max_ret_5d: float | None = None
    """Reject soft-day VWAP probes that gap down after exceeding this prior 5-day return."""

    soft_day_vwap_reclaim_weak_participation_max_rvol_rank_pct: float | None = None
    """Reject soft-day VWAP probes whose opening-range RVOL rank is at or below
    this percentile when paired with a weak ORB body. This targets fallback
    reclaims that are not participating strongly versus the same-day candidate
    slate."""

    soft_day_vwap_reclaim_weak_participation_max_body_ratio: float | None = None
    """Maximum ORB body ratio paired with
    soft_day_vwap_reclaim_weak_participation_max_rvol_rank_pct."""

    soft_day_vwap_reclaim_min_rvol: float | None = None
    """Minimum opening-range RVOL for soft-day auxiliary VWAP candidates."""

    soft_day_vwap_reclaim_min_rvol_market_regime: float | None = None
    """Optional opening-range RVOL floor override for market-regime-only probes."""

    soft_day_vwap_reclaim_min_rvol_breadth: float | None = None
    """Optional opening-range RVOL floor override for breadth-only probes."""

    soft_day_vwap_reclaim_min_rvol_joint: float | None = None
    """Optional opening-range RVOL floor override when both regime and breadth are soft."""

    soft_day_vwap_reclaim_min_rvol_hard_breadth: float | None = None
    """Optional opening-range RVOL floor override for hard-breadth fallback probes."""

    soft_day_vwap_reclaim_max_rvol: float | None = 35.0
    """Maximum opening-range RVOL for soft-day auxiliary VWAP candidates."""

    soft_day_vwap_reclaim_max_rvol_market_regime: float | None = None
    """Optional opening-range RVOL cap override for market-regime-only probes."""

    soft_day_vwap_reclaim_max_rvol_breadth: float | None = None
    """Optional opening-range RVOL cap override for breadth-only probes."""

    soft_day_vwap_reclaim_max_rvol_joint: float | None = None
    """Optional opening-range RVOL cap override when both regime and breadth are soft."""

    soft_day_vwap_reclaim_max_rvol_hard_breadth: float | None = None
    """Optional opening-range RVOL cap override for hard-breadth fallback probes."""

    soft_day_vwap_reclaim_min_body_ratio: float | None = None
    """Minimum ORB body ratio for soft-day auxiliary VWAP candidates."""

    soft_day_vwap_reclaim_min_body_ratio_market_regime: float | None = None
    """Optional ORB body-ratio floor override for market-regime-only probes."""

    soft_day_vwap_reclaim_min_body_ratio_breadth: float | None = None
    """Optional ORB body-ratio floor override for breadth-only probes."""

    soft_day_vwap_reclaim_min_body_ratio_joint: float | None = None
    """Optional ORB body-ratio floor override when both regime and breadth are soft."""

    soft_day_vwap_reclaim_min_body_ratio_hard_breadth: float | None = None
    """Optional ORB body-ratio floor override for hard-breadth fallback probes."""

    soft_day_vwap_reclaim_min_close_location: float | None = None
    """Minimum ORB close location for soft-day auxiliary VWAP candidates."""

    soft_day_vwap_reclaim_min_close_location_market_regime: float | None = None
    """Optional ORB close-location floor override for market-regime-only probes."""

    soft_day_vwap_reclaim_min_close_location_breadth: float | None = None
    """Optional ORB close-location floor override for breadth-only probes."""

    soft_day_vwap_reclaim_min_close_location_joint: float | None = None
    """Optional ORB close-location floor override when both regime and breadth are soft."""

    soft_day_vwap_reclaim_min_close_location_hard_breadth: float | None = None
    """Optional ORB close-location floor override for hard-breadth fallback probes."""

    soft_day_vwap_reclaim_size_scale: float = 0.05
    """Additional position-size multiplier applied only to soft-day auxiliary
    VWAP trades after the day-level soft-day scaler."""

    soft_day_vwap_reclaim_size_scale_market_regime: float | None = None
    """Optional size-scale override for market-regime-only soft-day VWAP probes."""

    soft_day_vwap_reclaim_size_scale_breadth: float | None = None
    """Optional size-scale override for breadth-only soft-day VWAP probes."""

    soft_day_vwap_reclaim_size_scale_joint: float | None = None
    """Optional size-scale override when both market regime and breadth are soft."""

    soft_day_vwap_reclaim_size_scale_hard_breadth: float | None = None
    """Optional size-scale override for hard-breadth fallback VWAP probes."""

    # ── Broad gap-up continuation engine ──
    broad_gapup_continuation_enabled: bool = False
    """Enable a bounded high-gap continuation sleeve.

    This does not relax the normal ORB max_gap_pct globally. It only admits
    positive-gap candidates that exceed max_gap_pct when they pass separate
    liquidity, opening-structure, and prior-trend gates.
    """

    broad_gapup_continuation_min_gap_pct: float | None = None
    """Minimum positive opening gap required for the high-gap continuation sleeve."""

    broad_gapup_continuation_max_gap_pct: float | None = None
    """Maximum positive opening gap accepted by the high-gap continuation sleeve."""

    broad_gapup_continuation_min_rvol: float | None = None
    """Minimum opening-range RVOL required for high-gap continuation candidates."""

    broad_gapup_continuation_min_premarket_dollar_vol: float | None = None
    """Minimum premarket dollar volume required for high-gap continuation candidates."""

    broad_gapup_continuation_min_first_bar_dollar_vol: float | None = None
    """Minimum opening-range dollar volume required for high-gap continuation candidates."""

    broad_gapup_continuation_min_avg_dollar_vol: float | None = None
    """Minimum 30-day average dollar volume required for high-gap continuation candidates."""

    broad_gapup_continuation_min_body_ratio: float | None = None
    """Minimum ORB candle body ratio required for high-gap continuation candidates."""

    broad_gapup_continuation_min_close_location: float | None = None
    """Minimum ORB close location required for high-gap continuation candidates."""

    broad_gapup_continuation_min_ret_5d: float | None = None
    """Minimum prior 5-day return required for high-gap continuation candidates."""

    broad_gapup_continuation_max_ret_5d: float | None = None
    """Maximum prior 5-day return allowed for high-gap continuation candidates."""

    broad_gapup_continuation_max_gap_zscore_20d: float | None = None
    """Maximum 20-day gap z-score allowed for high-gap continuation candidates."""

    broad_gapup_continuation_max_candidates: int | None = None
    """Maximum selected candidates from the high-gap continuation sleeve per day."""

    broad_gapup_continuation_max_trades: int | None = None
    """Maximum filled trades from the high-gap continuation sleeve per day."""

    broad_gapup_continuation_only_when_no_primary_entries: bool = False
    """Only use high-gap continuation when no normal primary ORB entry is available."""

    broad_gapup_continuation_only_when_no_primary_trades: bool = False
    """Skip high-gap continuation after any normal primary ORB trade fills."""

    broad_gapup_continuation_min_score_pct: float | None = None
    """Minimum selected-candidate rank percentile for high-gap continuation trades."""

    broad_gapup_continuation_size_scale: float = 0.25
    """Position-size multiplier applied only to high-gap continuation trades."""

    broad_gapup_continuation_fixed_loss_pct: float | None = None
    """Optional fixed stop distance for high-gap continuation trades.
    Example: 0.015 uses a 1.5% fixed loss cap if tighter than the ATR stop.
    """

    broad_gapup_continuation_entry_mode: str = "breakout"
    """Entry mode for high-gap continuation candidates.
    'breakout' keeps the original immediate ORB-breakout path; 'late_breakout'
    waits for a later close-confirmed ORB reclaim using the late_breakout_* gates;
    'vwap_reclaim' waits for the vwap_reclaim_* recovery path.
    """

    # ── Market-thrust liquid continuation engine ──
    market_thrust_liquid_continuation_enabled: bool = False
    """Enable a separate liquid normal-gap continuation sleeve on strong
    SPY/QQQ opening-thrust days. The sleeve is activated by the day-level
    market_thrust_breadth_override signal and does not relax RVOL globally."""

    market_thrust_liquid_continuation_require_market_thrust: bool = True
    """Require the day-level market-thrust signal before scanning this sleeve.
    Set False only for stock-specific liquid leader impulse probes that should
    be independent of the index/opening-breadth thrust gate.
    """

    market_thrust_liquid_continuation_only_when_no_primary_entries: bool = False
    """Only keep liquid-continuation entries when no normal primary entry exists.
    This lets the sleeve act as a no-trade-day repair path instead of competing
    with the baseline ORB book.
    """

    market_thrust_liquid_continuation_only_when_no_primary_trades: bool = False
    """Skip liquid-continuation fills after any normal primary trade has filled."""

    market_thrust_liquid_continuation_no_thrust_min_gap_pct: float | None = None
    """Optional gap floor used only by the independent no-thrust repair scan."""

    market_thrust_liquid_continuation_no_thrust_max_gap_pct: float | None = None
    """Optional gap ceiling used only by the independent no-thrust repair scan."""

    market_thrust_liquid_continuation_no_thrust_min_first_bar_return_pct: float | None = None
    """Optional first-bar return floor used only by the no-thrust repair scan."""

    market_thrust_liquid_continuation_no_thrust_min_first_bar_dollar_vol: float | None = None
    """Optional first-bar dollar-volume floor used only by the no-thrust repair scan."""

    market_thrust_liquid_continuation_no_thrust_min_avg_dollar_vol: float | None = None
    """Optional prior ADV floor used only by the independent no-thrust repair scan."""

    market_thrust_liquid_continuation_no_thrust_max_candidates: int | None = None
    """Optional candidate cap used only by the independent no-thrust repair scan."""

    market_thrust_liquid_continuation_no_thrust_max_trades: int | None = None
    """Optional trade cap used only by independent no-thrust liquid repair fills."""

    market_thrust_liquid_continuation_no_thrust_size_scale: float | None = None
    """Optional position-size multiplier for independent no-thrust repair fills."""

    market_thrust_liquid_continuation_no_thrust_rank_mode: str | None = None
    """Optional candidate ranking mode for independent no-thrust repair scans.
    None preserves the normal ORB score order. 'opening_impulse' ranks by
    first-bar return, then first-bar dollar volume.
    """

    market_thrust_liquid_continuation_no_thrust_priority_min_first_bar_return_pct: float | None = None
    """Optional priority tier for independent no-thrust repair scans.
    Candidates at or above this first-bar return are ranked ahead of lower-
    impulse repair candidates, while preserving normal score order within each
    tier.
    """

    market_thrust_liquid_continuation_min_gap_pct: float | None = None
    """Minimum positive opening gap required for market-thrust liquid candidates."""

    market_thrust_liquid_continuation_max_gap_pct: float | None = None
    """Maximum positive opening gap accepted by the market-thrust liquid sleeve."""

    market_thrust_liquid_continuation_min_rvol: float | None = None
    """Optional opening-range RVOL floor for market-thrust liquid candidates."""

    market_thrust_liquid_continuation_min_first_bar_return_pct: float | None = None
    """Minimum first ORB bar open-to-close return for market-thrust liquid candidates."""

    market_thrust_liquid_continuation_min_first_bar_dollar_vol: float | None = None
    """Minimum opening-range dollar volume for market-thrust liquid candidates."""

    market_thrust_liquid_continuation_min_avg_dollar_vol: float | None = None
    """Minimum 30-day average dollar volume for market-thrust liquid candidates."""

    market_thrust_liquid_continuation_min_body_ratio: float | None = None
    """Minimum ORB candle body ratio for market-thrust liquid candidates."""

    market_thrust_liquid_continuation_min_close_location: float | None = None
    """Minimum ORB close location for market-thrust liquid candidates."""

    market_thrust_liquid_continuation_min_ret_5d: float | None = None
    """Minimum prior 5-day return for market-thrust liquid candidates."""

    market_thrust_liquid_continuation_max_ret_5d: float | None = None
    """Maximum prior 5-day return for market-thrust liquid candidates."""

    market_thrust_liquid_continuation_max_candidates: int | None = None
    """Maximum selected candidates from the market-thrust liquid sleeve per day."""

    market_thrust_liquid_continuation_max_trades: int | None = None
    """Maximum filled trades from the market-thrust liquid sleeve per day."""

    market_thrust_liquid_continuation_entry_mode: str = "breakout"
    """Entry mode for market-thrust liquid candidates.
    'breakout' waits for a normal ORB breakout; 'opening_burst' enters on the
    first post-ORB bar open after the ORB candle has closed; 'opening_followthrough'
    waits for the first post-ORB bar to confirm continuation and enters at the
    next bar open.
    """

    market_thrust_liquid_continuation_followthrough_min_return_pct: float | None = 0.0
    """Minimum first post-ORB bar open-to-close return for opening_followthrough mode."""

    market_thrust_liquid_continuation_followthrough_min_close_location: float | None = 0.5
    """Minimum first post-ORB bar close location for opening_followthrough mode."""

    market_thrust_liquid_continuation_followthrough_require_orb_breakout: bool = True
    """Require first post-ORB bar to close through the ORB breakout level before entry."""

    market_thrust_liquid_continuation_min_score_pct: float | None = None
    """Minimum selected-candidate rank percentile for market-thrust liquid trades."""

    market_thrust_liquid_continuation_size_scale: float = 0.20
    """Position-size multiplier applied only to market-thrust liquid trades."""

    market_thrust_liquid_continuation_fixed_loss_pct: float | None = None
    """Optional fixed stop distance for market-thrust liquid trades."""

    # ── Market-thrust opening impulse reclaim engine ──
    market_thrust_opening_impulse_reclaim_enabled: bool = False
    """Enable a market-thrust auxiliary sleeve for stocks with small/negative
    opening gaps but a strong first ORB-bar impulse. The sleeve is meant to catch
    broad-market up days where leaders emerge after the open rather than via gap.
    """

    market_thrust_opening_impulse_reclaim_require_market_thrust: bool = True
    """Require the day-level market-thrust signal before scanning this sleeve.
    Set False only for independent no-thrust repair probes on no-primary days."""

    market_thrust_opening_impulse_reclaim_only_when_no_primary_entries: bool = False
    """Only keep no-thrust impulse-reclaim entries when no normal primary entry exists."""

    market_thrust_opening_impulse_reclaim_only_when_no_primary_trades: bool = False
    """Skip no-thrust impulse-reclaim fills after any normal primary trade has filled."""

    market_thrust_opening_impulse_reclaim_no_thrust_min_gap_pct: float | None = None
    """Optional gap floor used only by the independent no-thrust impulse scan."""

    market_thrust_opening_impulse_reclaim_no_thrust_max_gap_pct: float | None = None
    """Optional gap ceiling used only by the independent no-thrust impulse scan."""

    market_thrust_opening_impulse_reclaim_no_thrust_min_first_bar_return_pct: float | None = None
    """Optional first-bar return floor used only by the no-thrust impulse scan."""

    market_thrust_opening_impulse_reclaim_no_thrust_min_first_bar_dollar_vol: float | None = None
    """Optional first-bar dollar-volume floor used only by the no-thrust impulse scan."""

    market_thrust_opening_impulse_reclaim_no_thrust_min_avg_dollar_vol: float | None = None
    """Optional prior ADV floor used only by the no-thrust impulse scan."""

    market_thrust_opening_impulse_reclaim_no_thrust_min_body_ratio: float | None = None
    """Optional ORB-body floor used only by the no-thrust impulse scan."""

    market_thrust_opening_impulse_reclaim_no_thrust_min_close_location: float | None = None
    """Optional ORB close-location floor used only by the no-thrust impulse scan."""

    market_thrust_opening_impulse_reclaim_no_thrust_min_ret_5d: float | None = None
    """Optional prior 5-day return floor used only by the no-thrust impulse scan."""

    market_thrust_opening_impulse_reclaim_no_thrust_max_ret_5d: float | None = None
    """Optional prior 5-day return ceiling used only by the no-thrust impulse scan."""

    market_thrust_opening_impulse_reclaim_no_thrust_max_candidates: int | None = None
    """Optional candidate cap used only by the independent no-thrust impulse scan."""

    market_thrust_opening_impulse_reclaim_no_thrust_max_trades: int | None = None
    """Optional trade cap used only by independent no-thrust impulse fills."""

    market_thrust_opening_impulse_reclaim_no_thrust_size_scale: float | None = None
    """Optional position-size multiplier for independent no-thrust impulse fills."""

    market_thrust_opening_impulse_reclaim_min_gap_pct: float | None = None
    """Minimum opening gap accepted by the impulse-reclaim sleeve."""

    market_thrust_opening_impulse_reclaim_max_gap_pct: float | None = None
    """Maximum opening gap accepted by the impulse-reclaim sleeve."""

    market_thrust_opening_impulse_reclaim_min_first_bar_return_pct: float | None = None
    """Minimum first ORB bar open-to-close return for impulse-reclaim candidates."""

    market_thrust_opening_impulse_reclaim_min_first_bar_dollar_vol: float | None = None
    """Minimum opening-range dollar volume for impulse-reclaim candidates."""

    market_thrust_opening_impulse_reclaim_min_avg_dollar_vol: float | None = None
    """Minimum 30-day average dollar volume for impulse-reclaim candidates."""

    market_thrust_opening_impulse_reclaim_min_body_ratio: float | None = None
    """Minimum ORB candle body ratio for impulse-reclaim candidates."""

    market_thrust_opening_impulse_reclaim_min_close_location: float | None = None
    """Minimum ORB close location for impulse-reclaim candidates."""

    market_thrust_opening_impulse_reclaim_min_ret_5d: float | None = None
    """Minimum prior 5-day return for impulse-reclaim candidates."""

    market_thrust_opening_impulse_reclaim_max_ret_5d: float | None = None
    """Maximum prior 5-day return for impulse-reclaim candidates."""

    market_thrust_opening_impulse_reclaim_max_candidates: int | None = None
    """Maximum selected candidates from the impulse-reclaim sleeve per day."""

    market_thrust_opening_impulse_reclaim_max_trades: int | None = None
    """Maximum filled trades from the impulse-reclaim sleeve per day."""

    market_thrust_opening_impulse_reclaim_entry_mode: str = "vwap_reclaim"
    """Entry mode for impulse-reclaim candidates.
    'vwap_reclaim' waits for a later VWAP reclaim; 'late_breakout' waits for a
    close-confirmed ORB-high reclaim; 'opening_burst' enters on the first post-ORB open.
    """

    market_thrust_opening_impulse_reclaim_min_score_pct: float | None = None
    """Minimum selected-candidate rank percentile for impulse-reclaim trades."""

    market_thrust_opening_impulse_reclaim_size_scale: float = 0.10
    """Position-size multiplier applied only to impulse-reclaim trades."""

    market_thrust_opening_impulse_reclaim_fixed_loss_pct: float | None = None
    """Optional fixed stop distance for impulse-reclaim trades."""

    # ── Intraday continuation reclaim engine ──
    intraday_continuation_reclaim_enabled: bool = False
    """Enable a late-morning continuation sleeve for stocks that were not clean
    opening-gap ORB candidates but became intraday top gainers after the first
    30-60 minutes. The sleeve uses only bars available by its signal time and
    enters at the next bar open."""

    intraday_continuation_reclaim_signal_minutes: int = 30
    """Number of minutes after the open used to confirm continuation strength."""

    intraday_continuation_reclaim_min_signal_return_pct: float | None = None
    """Minimum open-to-signal close return required for continuation candidates."""

    intraday_continuation_reclaim_min_signal_dollar_vol: float | None = None
    """Minimum cumulative dollar volume through the signal window."""

    intraday_continuation_reclaim_min_signal_close_location: float | None = None
    """Minimum signal-window close location inside the window high-low range."""

    intraday_continuation_reclaim_require_signal_above_vwap: bool = True
    """Require the signal-window close to be above running session VWAP."""

    intraday_continuation_reclaim_min_first_bar_return_pct: float | None = None
    """Optional minimum first ORB bar open-to-close return."""

    intraday_continuation_reclaim_min_first_bar_dollar_vol: float | None = None
    """Optional minimum first ORB bar dollar volume."""

    intraday_continuation_reclaim_min_gap_pct: float | None = None
    """Minimum opening gap accepted by the intraday continuation sleeve."""

    intraday_continuation_reclaim_max_gap_pct: float | None = None
    """Maximum opening gap accepted by the intraday continuation sleeve."""

    intraday_continuation_reclaim_min_avg_dollar_vol: float | None = None
    """Minimum 30-day average dollar volume for continuation candidates."""

    intraday_continuation_reclaim_min_atr_pct: float | None = None
    """Optional ATR/previous-close floor for continuation candidates.
    This is separate from the base ORB min_atr_pct so the continuation sleeve can
    explicitly admit low-volatility liquid leaders without weakening primary ORB.
    """

    intraday_continuation_reclaim_max_atr_pct: float | None = None
    """Optional ATR/previous-close ceiling for continuation candidates."""

    intraday_continuation_reclaim_min_ret_5d: float | None = None
    """Minimum prior 5-day return for continuation candidates."""

    intraday_continuation_reclaim_max_ret_5d: float | None = None
    """Maximum prior 5-day return for continuation candidates."""

    intraday_continuation_reclaim_max_gap_zscore_20d: float | None = None
    """Maximum 20-day opening gap z-score allowed for continuation candidates."""

    intraday_continuation_reclaim_max_candidates: int | None = None
    """Maximum selected candidates from the continuation sleeve per day."""

    intraday_continuation_reclaim_min_cluster_count: int | None = None
    """Minimum number of continuation-qualified names required before the
    sleeve can trade. This avoids reacting to a single isolated mover on weak
    market days."""

    intraday_continuation_reclaim_min_cluster_avg_signal_return_pct: float | None = None
    """Minimum average open-to-signal return across all continuation-qualified
    names before candidate caps are applied."""

    intraday_continuation_reclaim_min_cluster_total_signal_dollar_vol: float | None = None
    """Minimum combined signal-window dollar volume across continuation-
    qualified names before candidate caps are applied."""

    intraday_continuation_reclaim_max_trades: int | None = None
    """Maximum filled trades from the continuation sleeve per day."""

    intraday_continuation_reclaim_only_when_no_primary_trades: bool = True
    """Skip continuation trades after any normal primary ORB trade has filled."""

    intraday_continuation_reclaim_min_score_pct: float | None = None
    """Minimum selected-candidate rank percentile for continuation trades."""

    intraday_continuation_reclaim_size_scale: float = 0.05
    """Position-size multiplier applied only to continuation-reclaim trades."""

    intraday_continuation_reclaim_fixed_loss_pct: float | None = None
    """Optional fixed stop distance for continuation-reclaim trades."""

    # ── Profit target ──
    profit_target_r: float | None = None
    """Exit at market when R-multiple reaches this level. None = disabled.
    E.g. 3.0 = exit when trade reaches 3R profit. Locks in gains before
    trailing stop gives back profits."""

    # ── Fixed dollar exits ──
    fixed_profit_dollars: float | None = None
    """Exit when trade P&L reaches this profit in dollars. Overrides ATR-based profit target. None = disabled."""

    fixed_loss_dollars: float | None = None
    """Exit when trade loss reaches this amount in dollars (positive = max loss allowed). Overrides ATR stop. None = disabled."""

    fixed_loss_pct: float | None = None
    """Exit when trade loss reaches this fraction of entry price.
    Example: 0.02 uses a 2% fixed stop from entry. Overrides ATR stop when
    tighter than the ATR stop. None = disabled."""

    fixed_loss_preserve_trailing: bool = False
    """When fixed_loss_pct/fixed_loss_dollars is set, keep breakeven and
    trailing-stop management active instead of treating the fixed stop as the
    only active stop path. Defaults to False to preserve existing strategy
    behavior."""

    # ── ORB range quality filter ──
    orb_range_atr_min: float | None = None
    """Minimum ORB candle range as fraction of ATR(14). None = disabled.
    Filters stocks with too-narrow opening ranges (likely noise).
    E.g. 0.3 = ORB range must be at least 30% of ATR."""

    orb_range_atr_max: float | None = None
    """Maximum ORB candle range as fraction of ATR(14). None = disabled.
    Filters stocks whose opening range already consumed the day's move.
    E.g. 1.5 = ORB range must be at most 150% of ATR."""

    # ── SPY intraday guard ──
    spy_intraday_guard_pct: float | None = None
    """Tighten trailing stop when SPY drops this % from its open intraday. None = disabled.
    E.g. -0.005 = if SPY drops 0.5% from open, tighten trail.
    Applied during Phase 2 exit management."""

    spy_intraday_guard_tighten: float = 0.5
    """Factor to multiply trailing ATR multiplier when SPY guard triggers.
    0.5 = trail becomes 50% tighter (e.g., 0.8 ATR → 0.4 ATR)."""

    # ── Single-trade loss cap ──
    single_trade_loss_cap_pct: float | None = None
    """Maximum loss allowed from a single trade as a fraction of initial_capital.
    None = disabled (default; streak/governor boosts apply without loss ceiling).
    When set, sizing_capital is clamped after all boosts (drawdown governor, streak,
    rolling WR) so that risk_per_trade_pct × sizing_capital ≤ this cap × initial_capital.
    E.g. 0.05 with risk_per_trade_pct=0.05 → max single-trade risk = $500 on $10k initial,
    regardless of streak multiplier.
    Fixes the structural misalignment where streak_sizing_max=2.5 allows a single -1R
    trade to exceed daily_max_loss_pct when both are computed on different capital bases."""

    single_trade_loss_cap_basis: str = "initial"
    """Capital base for single_trade_loss_cap_pct: 'initial' or 'equity'.
    'initial' preserves historical behavior and caps loss against initial_capital.
    'equity' is intended for compound_returns=True strategies where the cap should
    grow and shrink with account equity instead of suppressing compounding after
    the account has grown."""

    # ── Dual-trigger momentum path (hybrid) ──
    dual_trigger_enabled: bool = False
    """Enable momentum-confirmation as an alternate entry trigger alongside ORB breakout.
    When True, for each candidate compute both the ORB breakout time and the 09:45
    momentum confirmation time; use whichever fires first within the entry window.
    Post-entry management (ATR stop, BE, trail, tighten) is identical regardless of
    which trigger fired. min_breakout_rel_vol gate applies only on the ORB path."""

    momo_entry_minutes_after_open: int = 10
    """Minutes after market open for the first momentum evaluation bar (09:40 close)."""

    momo_confirmation_minutes_after_entry: int = 5
    """Minutes after momo_entry_minutes_after_open for the confirmation close (09:45)."""

    momo_confirm_window_minutes: int = 55
    """Latest allowable momentum confirm, measured as minutes after ORB end (09:35).
    09:35 + 55 min = 10:30 ET. Confirmation signals after this are ignored."""

    momo_min_confirmation_return_pct: float = 0.005
    """Minimum return from 09:40 close to 09:45 close to count as momentum confirmed."""

    momo_min_morning_gain_pct: float = 0.015
    """Minimum gain from open to 09:45 close for momentum confirm trigger."""

    momo_max_morning_gain_pct: float = 0.06
    """Maximum gain from open to 09:45 close (rejects over-extended names)."""

    # ── Candidate overlay (Leader + Liquid) for ORB path ──
    candidate_seed_leader_overlay_slots: int = 0
    """Number of Leader overlay slots appended after ORB pre-screen.
    Leader overlay targets trend leaders with high ret_5d + low entropy + high ATR,
    even when their opening gap is muted (captured via momo confirm trigger).
    0 = disabled."""

    candidate_seed_leader_min_gap_pct: float | None = None
    """Minimum gap for Leader overlay candidates (can be negative for flat-open leaders)."""

    candidate_seed_leader_max_gap_pct: float | None = None
    """Maximum gap for Leader overlay candidates."""

    candidate_seed_leader_min_avg_dollar_vol_30d: float | None = None
    """Minimum 30-day avg dollar volume for Leader overlay."""

    candidate_seed_leader_min_ret_5d: float | None = None
    """Minimum 5-day prior return for Leader overlay (e.g. 0.15 = +15%)."""

    candidate_seed_leader_min_atr_pct: float | None = None
    """Minimum ATR/open for Leader overlay (e.g. 0.06 = 6%)."""

    candidate_seed_leader_max_entropy_20d: float | None = None
    """Maximum entropy_20d for Leader overlay (e.g. 0.75)."""

    candidate_seed_liquid_overlay_slots: int = 0
    """Number of Liquid overlay slots appended after ORB pre-screen.
    Targets highly liquid names with moderate gaps that ORB pre-screen may miss.
    0 = disabled."""

    candidate_seed_liquid_min_gap_pct: float | None = None
    """Minimum gap for Liquid overlay candidates."""

    candidate_seed_liquid_max_gap_pct: float | None = None
    """Maximum gap for Liquid overlay candidates."""

    candidate_seed_liquid_min_avg_dollar_vol_30d: float | None = None
    """Minimum 30-day avg dollar volume for Liquid overlay."""

    candidate_seed_liquid_min_ret_5d: float | None = None
    """Minimum 5-day prior return for Liquid overlay."""

    candidate_seed_liquid_max_entropy_20d: float | None = None
    """Maximum entropy_20d for Liquid overlay."""

    candidate_seed_ownership_overlay_slots: int = 0
    """Number of PIT-safe 13D/13G ownership-event names to add after ORB pre-screen.

    The overlay widens the intraday fetch set for ownership-shock names without
    changing the default strategy path. Candidates still need to pass ORB candle
    and execution/risk checks before trading.
    """

    candidate_seed_ownership_initial_only: bool = True
    """When True, ownership seed overlay only accepts initial-owner filings."""

    candidate_seed_ownership_min_strength_score: float | None = None
    """Minimum ownership_strength_score required for ownership seed overlay."""

    candidate_seed_ownership_min_gap_pct: float | None = None
    """Minimum opening gap for ownership-overlay names."""

    candidate_seed_ownership_max_gap_pct: float | None = None
    """Maximum opening gap for ownership-overlay names."""

    candidate_seed_ownership_min_avg_dollar_vol_30d: float | None = None
    """Minimum prior 30-day dollar volume for ownership-overlay names."""

    candidate_seed_ownership_min_ret_5d: float | None = None
    """Minimum prior 5-day return for ownership-overlay names."""

    candidate_seed_ownership_max_entropy_20d: float | None = None
    """Maximum prior 20-day entropy for ownership-overlay names."""

    candidate_seed_form4_overlay_slots: int = 0
    """Number of PIT-safe Form 4 insider-buy names to add after ORB pre-screen.

    This widens the intraday fetch set for insider-supported names without
    changing the normal daily pre-screen. Final ORB structure and risk gates
    still decide whether a trade is taken.
    """

    candidate_seed_form4_min_total_value: float | None = None
    """Minimum aggregated recent Form 4 open-market purchase value."""

    candidate_seed_form4_min_owner_count: int | None = None
    """Minimum unique owner count for Form 4 seed overlay."""

    candidate_seed_form4_min_c_suite_count: int | None = None
    """Minimum C-suite buyer count for Form 4 seed overlay."""

    candidate_seed_form4_require_cluster_or_csuite: bool = False
    """When True, accept Form 4 seed names with either owner cluster or C-suite support."""

    candidate_seed_form4_min_gap_pct: float | None = None
    """Minimum opening gap for Form 4 seed-overlay names."""

    candidate_seed_form4_max_gap_pct: float | None = None
    """Maximum opening gap for Form 4 seed-overlay names."""

    candidate_seed_form4_min_avg_dollar_vol_30d: float | None = None
    """Minimum prior 30-day dollar volume for Form 4 seed-overlay names."""

    candidate_seed_form4_min_ret_5d: float | None = None
    """Minimum prior 5-day return for Form 4 seed-overlay names."""

    candidate_seed_form4_max_entropy_20d: float | None = None
    """Maximum prior 20-day entropy for Form 4 seed-overlay names."""

    # ── Entropy-based per-candidate size scaler (from momentum strategy) ──
    entropy_size_scale_low: float | None = None
    """Entropy level below which the per-candidate size scaler is 1.0 (no reduction).
    None = disabled (entropy scaler inactive for ORB path)."""

    entropy_size_scale_high: float | None = None
    """Entropy level at or above which size scaler = entropy_size_scale_min."""

    entropy_size_scale_min: float = 0.6
    """Minimum size scaler at entropy_size_scale_high (e.g. 0.6 = 60% of normal size)."""


class UniverseParams(BaseModel):
    """Parameters controlling which stocks to scan."""

    source: str = "sp500"
    """Universe source: 'sp500', 'nasdaq100', 'broad', 'midlarge', 'largecap', 'midcap',
    'smallmid', 'yaml', or 'screener'."""

    symbols_file: str | None = None
    """Path to YAML symbols file (required if source='yaml')."""

    market_cap_min: float | None = None
    """Minimum market cap filter (USD). Overrides screener default when set."""

    avg_volume_min: int | None = None
    """Minimum 3-month average daily volume filter."""

    sector_exclude: list[str] = Field(default_factory=list)
    """Sectors to exclude (e.g. ['Energy', 'Utilities']). Not applied for index sources."""

    min_price: float = 5.0
    """Minimum stock price. Filters out very cheap stocks."""


class BacktestParams(BaseModel):
    """Backtest period and pre-screening parameters."""

    start_date: str | None = None
    """Backtest start date (YYYY-MM-DD). None = auto (today - lookback_trading_days)."""

    end_date: str | None = None
    """Backtest end date (YYYY-MM-DD). None = today."""

    lookback_trading_days: int = 200
    """Number of trading days to backtest when start_date is None."""

    pre_screen_threshold: float = 0.015
    """Phase 1 pre-screening threshold: (today_open - prev_close) / prev_close >= this.
    Uses only open-time information plus prior-day data (no lookahead)."""


class CacheParams(BaseModel):
    """Intraday data disk cache configuration."""

    enabled: bool = True
    """Whether to use the disk cache for intraday bars."""

    dir: str = "data/cache/intraday"
    """Root directory for Parquet cache files."""


class OutputParams(BaseModel):
    """Output and reporting configuration."""

    dir: str = "runs/intraday"
    """Directory for writing result JSON files."""

    verbose: bool = False
    """Show detailed per-day output during simulation."""


class IntradayConfig(BaseModel):
    """Full configuration for one intraday backtest run.

    Maps 1:1 to the YAML config file format.
    """

    strategy_mode: str = "momentum"
    """Strategy to use: 'momentum' (morning gainers) or 'orb' (opening range breakout)."""

    strategy: StrategyParams = Field(default_factory=StrategyParams)
    """Momentum strategy parameters (used when strategy_mode='momentum')."""

    orb_strategy: ORBStrategyParams | None = None
    """ORB strategy parameters (used when strategy_mode='orb'). None = use defaults."""

    universe: UniverseParams = Field(default_factory=UniverseParams)
    backtest: BacktestParams = Field(default_factory=BacktestParams)
    cache: CacheParams = Field(default_factory=CacheParams)
    output: OutputParams = Field(default_factory=OutputParams)


# ── Trade Results ──────────────────────────────────────────────────────────


class IntradayTrade(BaseModel):
    """One completed intraday trade."""

    date: str
    """Trading date (YYYY-MM-DD)."""

    ticker: str
    """Stock symbol."""

    entry_price: float
    """Fill price at entry (after slippage)."""

    exit_price: float
    """Fill price at exit (after slippage)."""

    entry_time: str
    """Entry bar timestamp (ISO 8601, ET)."""

    exit_time: str
    """Exit bar timestamp (ISO 8601, ET)."""

    shares: float
    """Number of shares held."""

    pnl: float
    """Dollar P&L (after slippage costs)."""

    pnl_pct: float
    """Percentage P&L: (exit_price - entry_price) / entry_price (before slippage adj)."""

    exit_reason: str
    """How the trade was closed: 'close' or 'stop_loss'."""

    morning_gain_pct: float = 0.0
    """Stock's gain from open to entry time (momentum signal). 0.0 for ORB trades."""

    slippage_cost: float = 0.0
    """Total slippage cost in USD (entry + exit)."""

    trade_sleeve: str | None = None
    """Selection sleeve label for momentum strategies. None for ORB trades."""

    gap_pct: float | None = None
    """Opening gap used by momentum candidate selection. None when unavailable."""

    confirmation_return_pct: float | None = None
    """Return from primary entry bar to confirmation bar for momentum confirmation."""

    entry_dollar_volume: float | None = None
    """Cumulative dollar volume through the momentum entry/confirmation bar."""

    avg_dollar_vol_30d: float | None = None
    """Prior 30-day average dollar volume used by liquidity/support gates."""

    entropy_20d: float | None = None
    """Prior 20-day entropy feature used by candidate and size scaling."""

    ret_5d: float | None = None
    """Prior 5-day return feature used by leader/continuation gates."""

    event_score: float | None = None
    """Same-day filing/event score when available."""

    ownership_13dg_flag: bool | None = None
    """True when a PIT-safe 13D/13G filing exists in the configured lookback."""

    ownership_13dg_initial_flag: bool | None = None
    """True when the recent 13D/13G set contains an initial-owner filing."""

    ownership_13dg_days_since: int | None = None
    """Calendar days since the most recent qualifying 13D/13G filing."""

    ownership_13dg_strength_score: float | None = None
    """Maximum ownership_strength_score among qualifying 13D/13G events."""

    ownership_initial_size_scale: float | None = None
    """Per-trade size boost applied by the 13D/13G initial-owner overlay."""

    form4_flag: bool | None = None
    """True when a PIT-safe Form 4 purchase cluster exists in the configured lookback."""

    form4_days_since: int | None = None
    """Calendar days since the most recent qualifying Form 4 filing."""

    form4_total_value: float | None = None
    """Aggregated purchase value in the Form 4 lookback window."""

    form4_owner_count: int | None = None
    """Maximum owner_count among qualifying Form 4 events."""

    form4_c_suite_count: int | None = None
    """Maximum C-suite buyer count among qualifying Form 4 events."""

    form4_size_scale: float | None = None
    """Per-trade size boost applied by the Form 4 overlay."""

    support_score: float | None = None
    """Blended liquidity/attention/catalyst support score used by tail defense."""

    is_liquid_largecap: bool | None = None
    """True when the trade qualified through the liquid large-cap sleeve/gate."""

    is_moderate_gap_liquid: bool | None = None
    """True when the trade qualified through the moderate-gap liquid sleeve/gate."""

    is_sector_thrust: bool | None = None
    """True when the trade qualified through the sector breadth-confirmed thrust sleeve/gate."""

    sector_thrust_member_count: int | None = None
    """Number of same-sector names supporting the trade's sector-thrust state."""

    sector_thrust_total_entry_dollar_volume: float | None = None
    """Combined entry-time dollar volume across supporting same-sector names."""

    is_liquid_cluster: bool | None = None
    """True when the trade qualified through the separate liquid-cluster engine."""

    liquid_cluster_member_count: int | None = None
    """Number of same-sector names supporting the liquid-cluster trade."""

    liquid_cluster_total_entry_dollar_volume: float | None = None
    """Combined entry-time dollar volume across the liquid cluster."""

    liquid_cluster_sector: str | None = None
    """Resolved sector label used by the liquid-cluster engine / ETF sleeve."""

    liquid_cluster_sector_score: float | None = None
    """Sector-level cluster score used for post-allocation overlays."""

    sector_proxy_ticker: str | None = None
    """Mapped sector ETF proxy ticker when the trade comes from ETF sleeve logic."""

    sector_confirmation_active: bool | None = None
    """True when an ORB trade belongs to a same-sector confirmation cluster."""

    sector_confirmation_member_count: int | None = None
    """Number of same-sector ORB candidates supporting the confirmation cluster."""

    sector_confirmation_avg_orb_return: float | None = None
    """Average ORB candle return across the same-sector confirmation cluster."""

    sector_confirmation_total_first_bar_dollar_vol: float | None = None
    """Combined first-bar dollar volume across the same-sector confirmation cluster."""

    sector_confirmation_score: float | None = None
    """Raw sector-confirmation score before cross-candidate normalization."""

    sector_confirmation_size_scale: float | None = None
    """Position-size multiplier applied by the ORB sector-confirmation engine."""

    entry_market_guard_active: bool | None = None
    """True when the entry-time market guard scaled this trade."""

    entry_market_guard_return_pct: float | None = None
    """Market ETF intraday return observed at the trade's entry bar."""

    entry_market_guard_size_scale: float | None = None
    """Position-size multiplier applied by the entry-time market guard."""

    # ORB-specific fields (optional, None for momentum trades)
    orb_direction: str | None = None
    """ORB trade direction: 'long' or 'short'. None for momentum trades."""

    rvol: float | None = None
    """Approximate RVOL at entry time. None for momentum trades."""

    atr_at_entry: float | None = None
    """ATR(14) value used for stop sizing. None for momentum trades."""

    r_multiple_at_exit: float | None = None
    """Final R-multiple at exit: (exit_price - entry_price) / initial_risk. None for momentum."""

    stop_level_at_exit: str | None = None
    """Stop level active when the trade exited: 'initial', 'breakeven', or 'trailing'.
    None for momentum trades. Helps diagnose whether winners were protected before exiting."""

    partial_exit_r: float | None = None
    """R-multiple at which the partial exit fired, if partial_exit_at_r was set. None otherwise."""

    pyramid_adds: int = 0
    """Number of pyramid additions executed during this trade. 0 = no pyramiding."""

    pyramid_pnl: float = 0.0
    """Dollar P&L contributed by pyramid add-on shares. 0.0 = no pyramid or no pyramid PnL."""

    is_reentry: bool = False
    """True if this trade is a re-entry after a prior stop-out on the same ticker same day."""

    trigger_type: str = "orb"
    """Entry trigger: 'orb' (ORB breakout), 'momentum_confirm' (09:45 momentum gate),
    'vwap_reclaim' (no-fill VWAP fallback), 'soft_day_vwap_reclaim', or
    'late_breakout' (post-timeout close-confirmed ORB breakout fallback), or
    'broad_gapup_continuation' (bounded high-gap ORB continuation sleeve)."""

    total_capital_deployed: float = 0.0
    """Total capital deployed including pyramid additions.
    Computed as original_shares * entry_price + sum(pyramid_shares * pyramid_entry).
    Used for accurate portfolio deployment tracking."""

    # ORB candidate diagnostics
    candidate_score: float | None = None
    """Composite ORB candidate score after cross-sectional normalization."""

    score_rank_pct: float | None = None
    """Candidate rank within the selected daily basket. 1.0 = top ranked, 0.0 = bottom."""

    premarket_dollar_vol: float | None = None
    """Premarket dollar volume used by ORB candidate filtering/ranking."""

    first_bar_dollar_vol: float | None = None
    """Dollar volume in the opening-range bar/window."""

    volume_attention_rank_pct: float | None = None
    """Same-day cross-sectional volume-attention percentile used by IEX-safe ORB ranking."""

    volume_attention_global_rank_pct: float | None = None
    """All-candidate volume-attention percentile before contextual blending."""

    volume_attention_sector_rank_pct: float | None = None
    """Sector-relative volume-attention percentile used by contextual blending."""

    volume_attention_price_rank_pct: float | None = None
    """Price-bucket-relative volume-attention percentile used by contextual blending."""

    opening_dollar_vol_rank_pct: float | None = None
    """Same-day percentile rank for opening-range dollar volume."""

    rvol_rank_pct: float | None = None
    """Same-day percentile rank for opening-range RVOL."""

    premarket_dollar_vol_rank_pct: float | None = None
    """Same-day percentile rank for premarket dollar volume."""

    reclaim_entry_attention_rank_pct: float | None = None
    """Blended entry-bar participation percentile for reclaim-style triggers."""

    entry_bar_dollar_vol_rank_pct: float | None = None
    """Same-day percentile rank for the actual entry signal bar's dollar volume."""

    entry_cumulative_dollar_vol_rank_pct: float | None = None
    """Same-day percentile rank for cumulative dollar volume through entry."""

    entry_rel_volume_rank_pct: float | None = None
    """Same-day percentile rank for entry-bar relative volume."""

    entry_rel_volume: float | None = None
    """Entry signal bar volume divided by earlier post-ORB average bar volume."""

    body_ratio: float | None = None
    """Directional body ratio of the ORB candle."""

    close_location: float | None = None
    """Close location inside the ORB candle range, where 1.0 is the high."""

    gap_zscore_20d: float | None = None
    """Today's opening gap z-score versus the prior 20 trading days."""

    obv_slope_20: float | None = None
    """Prior 20-day OBV slope feature used by ORB ranking."""

    obv_slope_5: float | None = None
    """Prior 5-day OBV slope feature used by ORB ranking."""

    orb_return: float | None = None
    """Return of the ORB candle itself."""

    crowded_gap_requires_confirmation: bool = False
    """True when the candidate matched the crowded-gap exhaustion setup and was
    allowed only after confirmation."""

    crowded_gap_size_scale: float | None = None
    """Per-trade size scale applied by the crowded-gap risk overlay, if any."""

    countertrend_gap_requires_confirmation: bool = False
    """True when the candidate matched the weak-prior-trend gap-up setup and
    was allowed only after confirmation."""

    countertrend_gap_size_scale: float | None = None
    """Per-trade size scale applied by the countertrend-gap risk overlay, if any."""

    distressed_reclaim_requires_confirmation: bool = False
    """True when the candidate matched the distressed downside-gap reclaim setup
    and was allowed only after confirmation."""

    distressed_reclaim_size_scale: float | None = None
    """Per-trade size scale applied by the distressed reclaim overlay, if any."""

    hot_reclaim_requires_confirmation: bool = False
    """True when the candidate matched the hot-pullback reclaim setup and was
    allowed only after confirmation."""

    hot_reclaim_size_scale: float | None = None
    """Per-trade size scale applied by the hot-pullback reclaim overlay, if any."""

    weak_downside_reclaim_requires_confirmation: bool = False
    """True when the candidate matched the weak downside reclaim setup and was
    allowed only after confirmation."""

    weak_downside_reclaim_size_scale: float | None = None
    """Per-trade size scale applied by the weak downside reclaim overlay, if any."""

    quiet_downside_reclaim_requires_confirmation: bool = False
    """True when the candidate matched the quiet downside reclaim setup and was
    allowed only after confirmation."""

    quiet_downside_reclaim_size_scale: float | None = None
    """Per-trade size scale applied by the quiet downside reclaim overlay, if any."""

    stalled_gap_up_requires_confirmation: bool = False
    """True when the candidate matched the stalled gap-up setup and was allowed
    only after confirmation."""

    stalled_gap_up_size_scale: float | None = None
    """Per-trade size scale applied by the stalled gap-up overlay, if any."""

    liquid_stalled_gap_up_requires_confirmation: bool = False
    """True when the candidate matched the liquid stalled gap-up setup and was
    allowed only after confirmation."""

    liquid_stalled_gap_up_size_scale: float | None = None
    """Per-trade size scale applied by the liquid stalled gap-up overlay, if any."""

    stale_obv_reversal_requires_confirmation: bool = False
    """True when the candidate matched the stale-OBV reversal setup and was
    allowed only after confirmation."""

    stale_obv_reversal_size_scale: float | None = None
    """Per-trade size scale applied by the stale-OBV reversal overlay, if any."""

    thin_gap_up_loss_cap_active: bool = False
    """True when the trade matched the thin gap-up fixed-loss overlay."""

    moderate_downside_loss_cap_active: bool = False
    """True when the trade matched the moderate downside-gap fixed-loss overlay."""

    isolated_downside_loss_cap_active: bool = False
    """True when the trade matched the isolated downside-reclaim fixed-loss overlay."""

    isolated_downside_loss_cap_pct: float | None = None
    """Fixed stop distance applied by the isolated downside-reclaim overlay."""

    isolated_downside_size_scale: float | None = None
    """Per-trade size scale applied by the isolated downside-reclaim overlay."""

    isolated_downside_pressure_active: bool = False
    """True when the trade matched the isolated high-volume downside pressure overlay."""

    isolated_downside_pressure_size_scale: float | None = None
    """Per-trade size scale applied by the isolated high-volume downside pressure overlay."""

    overextended_downside_reclaim_active: bool = False
    """True when the trade matched the overextended downside-reclaim overlay."""

    overextended_downside_reclaim_size_scale: float | None = None
    """Per-trade size scale applied by the overextended downside-reclaim overlay."""

    mid_attention_exhaustion_active: bool = False
    """True when the trade matched the mid-attention exhaustion overlay."""

    mid_attention_exhaustion_size_scale: float | None = None
    """Per-trade size scale applied by the mid-attention exhaustion overlay."""

    mid_liquidity_fragility_active: bool = False
    """True when the trade matched the mid-liquidity fragility governor."""

    mid_liquidity_fragility_size_scale: float | None = None
    """Per-trade size scale applied by the mid-liquidity fragility governor."""

    orphan_thin_attention_active: bool = False
    """True when the trade matched the unsupported thin-attention overlay."""

    orphan_thin_attention_size_scale: float | None = None
    """Per-trade size scale applied by the unsupported thin-attention overlay."""

    gap_up_fill_trap_active: bool = False
    """True when the trade matched the positive-gap fill-trap governor."""

    gap_up_fill_trap_size_scale: float | None = None
    """Per-trade size scale applied by the positive-gap fill-trap governor."""

    low_candidate_quality_active: bool = False
    """True when the trade matched the low composite-score quality governor."""

    low_candidate_quality_size_scale: float | None = None
    """Per-trade size scale applied by the low candidate-quality governor."""

    late_trade_size_scale: float | None = None
    """Per-trade size scale applied because this was a late same-day fill."""

    rank_rvol_pressure_size_scale: float | None = None
    """Per-trade size scale applied by the rank/RVOL pressure overlay."""

    gap_exhaustion_pressure_size_scale: float | None = None
    """Per-trade size scale applied by the gap-exhaustion pressure overlay."""

    stale_obv_rvol_pressure_size_scale: float | None = None
    """Per-trade size scale applied by the stale-OBV/RVOL pressure overlay."""

    unboosted_primary_fragility_size_scale: float | None = None
    """Per-trade size scale applied by the unboosted-primary fragility overlay."""

    unsupported_attention_size_scale: float | None = None
    """Per-trade size scale applied by the unsupported-attention governor."""

    positive_gap_rebound_failure_size_scale: float | None = None
    """Per-trade size scale applied by the positive-gap rebound-failure governor."""

    broad_gapup_continuation: bool = False
    """True when the trade came from the bounded high-gap continuation sleeve."""

    broad_gapup_continuation_size_scale: float | None = None
    """Per-trade size scale applied by the high-gap continuation sleeve."""

    intraday_continuation_reclaim: bool = False
    """True when the trade came from the intraday continuation-reclaim sleeve."""

    intraday_continuation_reclaim_size_scale: float | None = None
    """Per-trade size scale applied by the intraday continuation-reclaim sleeve."""

    market_thrust_liquid_continuation: bool = False
    """True when the trade came from the market-thrust liquid continuation sleeve."""

    market_thrust_liquid_continuation_size_scale: float | None = None
    """Per-trade size scale applied by the market-thrust liquid continuation sleeve."""

    market_thrust_opening_burst: bool = False
    """True when the trade used the market-thrust opening-burst entry mode."""

    market_thrust_opening_followthrough: bool = False
    """True when the trade used the delayed market-thrust opening-followthrough entry."""

    orb_idle_sleeve_overnight: bool = False
    """True when the trade came from the overnight idle fallback sleeve."""

    idle_entry_day_return_pct: float | None = None
    """Idle-sleeve entry-day open-to-entry return for diagnostics."""

    idle_entry_close_location: float | None = None
    """Idle-sleeve entry-day close location inside the regular-session range."""

    idle_reclaim_early_return_pct: float | None = None
    """Late-reclaim sleeve open-to-checkpoint return used for diagnostics."""

    idle_reclaim_late_return_pct: float | None = None
    """Late-reclaim sleeve checkpoint-to-entry return used for diagnostics."""

    idle_market_day_return_pct: float | None = None
    """Market ticker open-to-entry return observed when the idle sleeve opened."""

    idle_market_close_location: float | None = None
    """Market ticker close location observed when the idle sleeve opened."""

    red_to_green_acceleration_size_scale: float | None = None
    """Per-trade size scale applied by the red-to-green acceleration booster."""

    liquid_leader_conviction_size_scale: float | None = None
    """Per-trade size scale applied by the liquid-leader conviction booster."""

    opening_burst_liquid_size_scale: float | None = None
    """Per-trade size scale applied by the opening-burst liquid allocator."""

    soft_day_sector_confirmation_override_size_scale: float | None = None
    """Per-trade size scale applied by the soft-day sector-confirmation override."""

    soft_day_sector_confirmation_override_min_day_size_scale: float | None = None
    """Day-level sizing floor applied by the soft-day sector-confirmation override."""

    red_to_green_reserved: bool = False
    """True when the trade came from a reserved downside-gap reclaim basket slot."""

    candidate_seed_overlay: bool = False
    """True when the candidate matched a candidate-seed overlay profile."""

    candidate_seed_overlay_reserved: bool = False
    """True when the trade came from a reserved candidate-seed overlay basket slot."""

    soft_day_trade: bool = False
    """True when the trade was entered on a soft-regime day."""

    soft_day_reason: str | None = None
    """Why the trade was classified as soft-day exposure, if known."""

    gap_up_fill_exit_active: bool = False
    """True when a positive-gap candidate used candidate-level gap-fill exit."""


class DayResult(BaseModel):
    """Simulation result for one trading day."""

    date: str
    trades: list[IntradayTrade] = Field(default_factory=list)
    daily_pnl: float = 0.0
    daily_return_pct: float = 0.0
    candidates_found: int = 0
    """Number of stocks that met the morning gain threshold."""

    # Settlement / GFV tracking (ORB-only; 0 when settlement_days=0 or momentum)
    capital_deployed: float = 0.0
    """Total capital deployed in positions this day (sum of shares × entry_price)."""
    available_cash_start: float = 0.0
    """Settled cash available at start of this trading day (before any trades)."""
    skipped_insufficient_cash: int = 0
    """Candidates skipped because available settled cash was exhausted."""

    # Diagnostic fields (populated by simulate_orb_day / run_orb_simulation_with_state)
    rolling_loss_synthetic_pnl: float | None = None
    """Synthetic PnL inserted into the rolling loss governor for this day.
    Does not affect reported daily_pnl, equity, or metrics."""

    skip_reason: str | None = None
    """Why this day had no trades. One of: 'vix_gate', 'market_regime', 'breadth',
    'rolling_loss', 'spy_trend', 'no_candidates', 'below_min_candidates'. None = traded."""
    candidate_filter_stats: dict | None = None
    """Per-filter drop counts from compute_orb_candidates: {gap, rvol, atr, dolvol, dir,
    no_bars, late, price}. Present on all days (traded and skipped-after-candidates)."""
    entry_diagnostics: dict | None = None
    """Post-candidate entry diagnostics for no-trade analysis. Includes counts of
    primary, no-fill VWAP, soft-day VWAP, and pass-2 rejection reasons."""

    # V20 diagnostics
    regime_scaler: float | None = None
    """Regime size scaler for this day (1.0 = full size or V19 path)."""
    regime_gap_pct: float | None = None
    """Opening gap of the configured market regime ticker, when available."""
    breadth_scaler: float | None = None
    """Breadth size scaler for this day (1.0 = full size or V19 path)."""
    breadth_ratio: float | None = None
    """Fraction of intraday-loaded tickers opening above prior close."""
    breadth_positive_count: int | None = None
    """Number of intraday-loaded tickers opening above prior close."""
    breadth_total_count: int | None = None
    """Number of intraday-loaded tickers with enough data for breadth."""
    sparse_day_scaler: float | None = None
    """Sparse-basket size scaler for this day (1.0 = full size)."""
    market_orb_quality_scaler: float | None = None
    """Market first-bar ORB quality size scaler for this day (1.0 = disabled/no change)."""
    market_orb_quality_close_location: float | None = None
    """Close location of the market quality ticker's first regular-session bar."""
    market_orb_quality_return_pct: float | None = None
    """Return of the market quality ticker's first regular-session bar."""
    market_orb_quality_secondary_close_location: float | None = None
    """Close location of the secondary market quality ticker's first regular-session bar."""
    market_orb_quality_secondary_return_pct: float | None = None
    """Return of the secondary market quality ticker's first regular-session bar."""
    market_thrust_breadth_override_active: bool = False
    """True when strong market ORB thrust overrode breadth-only soft-day sizing."""
    market_thrust_opening_breadth_override_active: bool = False
    """True when universe first-bar breadth activated the market-thrust override."""
    market_thrust_opening_breadth_positive_ratio: float | None = None
    """Fraction of included tickers with positive first-bar returns."""
    market_thrust_opening_breadth_avg_return_pct: float | None = None
    """Average first-bar return across included tickers."""
    market_thrust_opening_breadth_strong_close_location_ratio: float | None = None
    """Fraction of included tickers with strong first-bar close location."""
    market_thrust_opening_breadth_total_count: int | None = None
    """Number of included tickers in first-bar breadth stats."""
    market_orb_quality_divergence_active: bool = False
    """True when the primary/secondary ORB divergence guard reduced day size."""
    market_orb_quality_divergence_max_trades_active: bool = False
    """True when a divergence-day trade cap was activated."""
    market_orb_quality_primary_weak_secondary_strong_active: bool = False
    """True when the weak-primary / strong-secondary split-tape guard changed day behavior."""
    market_orb_quality_primary_weak_secondary_strong_max_trades_active: bool = False
    """True when a weak-primary / strong-secondary split-tape trade cap was activated."""
    market_orb_quality_primary_lag_secondary_lead_active: bool = False
    """True when the primary-lag / secondary-lead split-tape guard changed day behavior."""
    market_orb_quality_primary_lag_secondary_lead_max_trades_active: bool = False
    """True when a primary-lag / secondary-lead split-tape trade cap was activated."""
    market_orb_quality_joint_weak_active: bool = False
    """True when the joint weak-open market ORB guard changed day behavior."""
    market_orb_quality_joint_weak_max_trades_active: bool = False
    """True when a joint weak-open trade cap was activated."""
    market_orb_quality_joint_panic_active: bool = False
    """True when the joint panic-low market ORB guard changed day behavior."""
    market_orb_quality_joint_panic_max_trades_active: bool = False
    """True when a joint panic-low trade cap was activated."""
    conditional_confirmation_active: bool = False
    """True when the day's market tape activated the conditional confirmation rule."""
    sector_scaler: float | None = None
    """Basket sector-concentration scaler for this day (1.0 = no extra concentration penalty)."""
    tail_risk_scaler: float | None = None
    """Extra meta-layer scaler for sparse high-extension tail-risk days."""
    soft_day_sparse_scaler: float | None = None
    """Extra meta-layer scaler for sparse soft-day baskets lacking supportive sleeves."""
    is_soft_day: bool = False
    """True when combined_scaler < soft_day_scaler_threshold (soft-regime day)."""
    soft_day_excluded_from_streak: bool = False
    """True when this soft day intentionally did not update streak sizing state."""
    soft_day_reason: str | None = None
    """Why the day was classified as soft-day exposure, if known."""
    event_day_liquid_active: bool = False
    """True when the event-day liquid sleeve activation gate passed for the day."""
    event_day_liquid_event_count: int | None = None
    """Number of morning event contributors that qualified the event-day liquid gate."""
    event_day_liquid_total_event_entry_dollar_volume: float | None = None
    """Combined entry-time dollar volume across event-day liquid activation contributors."""


# ── Aggregate Metrics ──────────────────────────────────────────────────────


class IntradayMetrics(BaseModel):
    """Summary performance metrics for a complete backtest run."""

    # Identity
    run_id: str = ""
    params_hash: str = ""

    # Period
    start_date: str = ""
    end_date: str = ""
    trading_days: int = 0
    days_with_trades: int = 0
    days_with_activity: int = 0
    """Days with either completed trades or same-day close-entry idle sleeve opens."""

    # Trade counts
    total_trades: int = 0
    stop_loss_exits: int = 0
    idle_sleeve_entry_days: int = 0
    """Days where ORB idle sleeve positions were opened at/near the close."""
    idle_sleeve_positions_opened: int = 0
    """Total close-entry ORB idle sleeve positions opened during the run."""

    # Trade-level metrics
    win_rate: float | None = None
    avg_win_pct: float | None = None
    avg_loss_pct: float | None = None
    profit_factor: float | None = None
    expectancy_pct: float | None = None

    # Return metrics
    total_return_pct: float | None = None
    annualized_return_pct: float | None = None
    avg_daily_return_pct: float | None = None

    # Risk metrics
    max_drawdown_pct: float | None = None
    sharpe_ratio: float | None = None
    sortino_ratio: float | None = None
    calmar_ratio: float | None = None
    loss_day_rate: float | None = None
    """Fraction of trading days with negative PnL."""
    avg_loss_day_pct: float | None = None
    """Average return across negative-PnL days only."""
    tail_loss_20_pct: float | None = None
    """Average return of the worst 20% of losing days."""
    worst_day_return_pct: float | None = None
    """Worst single-day return."""
    loss_containment_score: float | None = None
    """0-100 score favoring strategies that lose small amounts on bad days."""

    # Intraday-specific
    avg_hold_minutes: float | None = None
    stop_loss_exit_pct: float | None = None
    """Fraction of trades exited via stop loss."""

    # Capital
    initial_capital: float = 10_000.0
    final_equity: float = 0.0


class SweepResult(BaseModel):
    """One parameter combination result from a grid sweep."""

    params: dict[str, Any]
    metrics: IntradayMetrics
    objective_score: float | None = None
