"""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 to initial_capital,
    ignoring prior-day PnL entirely (no compounding, no drawdown cap).
    Useful for isolating strategy alpha from capital-path effects.
    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."""

    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_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.
    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."""

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

    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."""

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

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

    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_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."""

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

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

    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 only."""

    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 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."""

    # 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)."""

    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 to initial_capital,
    ignoring prior-day PnL entirely (no compounding, no drawdown cap).
    Useful for isolating strategy alpha from capital-path effects.
    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."""

    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."""

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

    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."""

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

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

    # 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)."""

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

    # 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)."""

    # ── 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."""

    # ── 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."""

    # ── 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_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%)."""

    # ── 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."""

    # ── 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."""

    # ── 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."""

    # ── 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."""

    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."""

    # 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) or 'momentum_confirm' (09:45 momentum gate).
    Always 'orb' for non-hybrid strategies."""

    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."""


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)
    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)."""

    # V20 diagnostics
    regime_scaler: float | None = None
    """Regime size scaler for this day (1.0 = full size or V19 path)."""
    breadth_scaler: float | None = None
    """Breadth size scaler for this day (1.0 = full size or V19 path)."""
    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)."""
    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

    # Trade counts
    total_trades: int = 0
    stop_loss_exits: int = 0

    # 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
