"""Core domain models for the ACE-F backtester."""
from __future__ import annotations

import datetime as dt
from enum import Enum
from typing import Any

from pydantic import BaseModel, ConfigDict, Field


class PositionStatus(str, Enum):
    PLANNED = "PLANNED"
    ENTERED = "ENTERED"
    PARTIALLY_EXITED = "PARTIALLY_EXITED"
    OPEN = "OPEN"
    EXIT_PENDING = "EXIT_PENDING"
    CLOSED = "CLOSED"
    ARCHIVED = "ARCHIVED"


class ExitReason(str, Enum):
    STOP = "STOP"
    TARGET = "TARGET"
    TIME = "TIME"
    DECAY = "DECAY"
    TRAILING = "TRAILING"
    KILL_SWITCH = "KILL_SWITCH"
    END_OF_BACKTEST = "END_OF_BACKTEST"
    MISSING_BAR = "MISSING_BAR"
    NO_FOLLOW_THROUGH = "NO_FOLLOW_THROUGH"
    EARLY_FAILURE = "EARLY_FAILURE"
    NO_PROGRESS = "NO_PROGRESS"
    GIVEBACK = "GIVEBACK"
    RECYCLE = "RECYCLE"
    ROTATION = "ROTATION"
    PARKING = "PARKING"
    DIVIDEND_CAPTURE = "DIVIDEND_CAPTURE"


class BacktestMode(str, Enum):
    RESEARCH = "research"
    LIVE = "live"


class Candidate(BaseModel):
    """An eligible trade candidate derived from a Parquet snapshot row."""
    model_config = ConfigDict(frozen=True)

    event_id: str
    symbol: str
    source_symbol: str | None = None
    issuer_id: str | None = None
    score: float
    sector: str  # "UNKNOWN" if unavailable
    event_type: str
    event_timestamp: dt.datetime  # must be timezone-aware
    event_date: dt.date | None = None
    filing_time_bucket: str
    timing_class: str = "unknown"  # "same_day", "after_close", "unknown"
    reaction_date: dt.date
    execution_date: dt.date  # mapped from Parquet entry_date at SnapshotStore boundary
    entry_price_est: float   # reaction-day close price
    avg_dollar_volume: float  # 20-day mean(volume * close)
    atr_14: float | None = None
    score_bucket: str
    engine_id: str = "default"
    entry_timing_policy: str = "next_open"
    shadow_only: bool = False
    engine_min_entry_price: float | None = None
    engine_max_entry_price: float | None = None
    engine_max_holding_days: int | None = None
    engine_max_positions_per_sector: int | None = None
    engine_max_position_value_pct: float | None = None
    engine_max_adv_fraction: float | None = None
    engine_risk_budget_pct: float = 1.0
    engine_capital_bucket_id: str | None = None
    engine_capital_bucket_allocation_pct: float | None = None
    engine_per_trade_risk_pct: float | None = None
    engine_macro_vix_size_scaler_low: float | None = None
    engine_macro_vix_size_scaler_high: float | None = None
    engine_macro_vix_size_scaler_min: float | None = None
    engine_macro_hy_spread_size_scaler_low: float | None = None
    engine_macro_hy_spread_size_scaler_high: float | None = None
    engine_macro_hy_spread_size_scaler_min: float | None = None
    engine_score_size_scaler_low: float | None = None
    engine_score_size_scaler_high: float | None = None
    engine_score_size_scaler_min: float | None = None
    engine_entropy_size_scaler_low: float | None = None
    engine_entropy_size_scaler_high: float | None = None
    engine_entropy_size_scaler_min: float | None = None
    engine_target_atr_multiplier: float | None = None
    engine_stop_atr_multiplier: float | None = None
    engine_target_1_r: float | None = None
    engine_target_1_fraction: float | None = None
    engine_trailing_model: str | None = None
    engine_trailing_warmup_days: int | None = None
    engine_use_reaction_day_low_stop: bool | None = None
    engine_early_failure_close_below_entry_and_reaction_close: bool | None = None
    engine_early_failure_no_progress_days: int | None = None
    engine_early_failure_no_progress_r: float | None = None
    engine_early_failure_no_progress_fraction: float | None = None
    engine_dynamic_hold_checkpoints: list[list[float]] | None = None
    engine_dynamic_hold_extend_day: int | None = None
    engine_dynamic_hold_extend_r: float | None = None
    engine_dynamic_hold_extend_to: int | None = None
    engine_veto_oneoff_penalty: float | None = None
    engine_allow_oneoff_downsizing: bool | None = None
    engine_oneoff_downsize_floor: float | None = None
    engine_veto_parse_confidence_min: float | None = None
    engine_allow_unknown_direction: bool | None = None
    engine_next_open_gap_cap_pct: float | None = None
    engine_add_on_max_count: int | None = None
    engine_add_on_size_fraction: float | None = None
    trade_symbol_mode: str = "event"  # "event", "sector_etf", "peer_proxy"
    trade_direction: str = "long"  # "long" or "short"
    engine_forced_trade_direction: str | None = None  # explicit engine override, e.g. contrarian long on bearish
    parent_position_id: str | None = None
    is_add_on: bool = False
    forced_shares: int | None = None
    features: dict[str, Any] = Field(default_factory=dict)


class PlannedOrder(BaseModel):
    """A sized, gated order plan for a candidate."""
    model_config = ConfigDict(frozen=True)

    candidate: Candidate
    shares: int
    entry_price_limit: float
    stop_price: float
    target_price: float
    risk_dollars: float
    event_date: dt.date | None = None
    timing_class: str = "unknown"
    engine_id: str = "default"
    entry_timing_policy: str = "next_open"
    shadow_only: bool = False
    parent_position_id: str | None = None
    is_add_on: bool = False
    skip_reason: str | None = None  # non-None means the order was rejected


class FilledTrade(BaseModel):
    """A completed (closed) trade leg."""
    model_config = ConfigDict(frozen=True)

    trade_id: str
    position_id: str
    event_id: str
    symbol: str
    source_symbol: str | None = None
    event_date: dt.date | None = None
    event_type: str = ""
    score: float = 0.0
    timing_class: str = "unknown"
    engine_id: str = "default"
    entry_timing_policy: str = "next_open"
    shadow_only: bool = False
    parent_position_id: str | None = None
    is_add_on: bool = False
    trade_symbol_mode: str = "event"
    entry_date: dt.date
    exit_date: dt.date
    entry_price: float
    exit_price: float
    exit_reason: ExitReason
    shares: int
    commission: float
    slippage_bps: float
    gross_pnl: float
    net_pnl: float
    pnl_pct: float
    r_multiple: float
    holding_days: int


class OpenPosition(BaseModel):
    """A live open position (mutable throughout its lifetime)."""

    position_id: str
    plan: PlannedOrder
    entry_date: dt.date
    entry_price: float
    entry_fill_slippage_bps: float
    current_stop: float
    target_price: float
    peak_price: float
    shares_open: int
    shares_total: int
    parent_position_id: str | None = None
    is_add_on: bool = False
    days_held: int = 0
    status: PositionStatus = PositionStatus.ENTERED
    partial_fills: list[FilledTrade] = Field(default_factory=list)


class DailyPortfolioState(BaseModel):
    """Immutable snapshot of portfolio state at end of a trading day."""
    model_config = ConfigDict(frozen=True)

    date: dt.date
    equity: float
    sizing_equity: float | None = None  # equity used for position sizing; defaults to equity when None
    cash_available: float
    gross_exposure: float
    net_exposure: float
    reserved_risk_budget: float
    unrealized_pnl: float
    realized_pnl: float
    open_positions: list[str] = Field(default_factory=list)  # position_ids
    daily_new_risk_used: float
    peak_equity: float
    current_drawdown_pct: float
    # Idle capital decomposition (optional; None when not instrumented)
    raw_cash: float | None = None           # self._cash (actual uninvested cash)
    parking_value: float | None = None      # market value of parked ETF positions
    idle_alpha_exposure: float | None = None  # notional in idle-alpha-sleeve positions
    primary_exposure: float | None = None   # notional in primary engine positions


class MetricsBundle(BaseModel):
    """21 performance metrics for a completed backtest run."""

    # Trade metrics (7)
    trade_count: int = 0
    win_rate: float | None = None
    avg_win_pct: float | None = None
    avg_loss_pct: float | None = None
    profit_factor: float | None = None
    expectancy_r: float | None = None
    avg_r_multiple: float | None = None

    # Portfolio metrics (8)
    total_return_pct: float | None = None
    annualized_return_pct: float | None = None
    max_drawdown_pct: float | None = None
    calmar_ratio: float | None = None
    sharpe_ratio: float | None = None
    sortino_ratio: float | None = None
    avg_daily_pnl: float | None = None
    avg_positions_held: float | None = None
    avg_gross_exposure_pct: float | None = None
    avg_net_exposure_pct: float | None = None
    days_in_market_pct: float | None = None

    # Stability metrics (4)
    trade_skewness: float | None = None
    trade_kurtosis: float | None = None
    monthly_win_rate: float | None = None
    equity_curve_r_squared: float | None = None

    # Practicality metrics (4)
    avg_holding_days: float | None = None
    stop_exit_rate: float | None = None
    target_exit_rate: float | None = None
    no_follow_through_exit_rate: float | None = None
    score_bucket_hit_rate: dict[str, float] = Field(default_factory=dict)
    qqq_benchmark_return_pct: float | None = None
    excess_vs_qqq_pct: float | None = None
    long_net_pnl: float | None = None
    short_net_pnl: float | None = None
    long_pnl_contribution_pct: float | None = None
    short_pnl_contribution_pct: float | None = None

    # Simple (non-compounding) return
    simple_return_pct: float | None = None  # sum(net_pnl) / initial_equity * 100
    initial_equity: float | None = None  # starting capital used for simple return calc

    # Bootstrap confidence intervals (95%)
    bootstrap_cis: dict[str, tuple[float, float] | None] = Field(default_factory=dict)

    # Idle capital decomposition (populated only when raw_cash/parking_value tracked)
    avg_idle_fraction_pct: float | None = None        # mean((raw_cash + parking_value) / equity)
    avg_primary_utilization_pct: float | None = None  # mean(primary_exposure / equity)
    avg_ia_utilization_pct: float | None = None       # mean(idle_alpha_exposure / equity)


# ---------------------------------------------------------------------------
# Config models (mirror JSON Schema)
# ---------------------------------------------------------------------------

class UniverseConfig(BaseModel):
    min_price: float = 5.0
    min_avg_dollar_volume: float = 1_000_000.0
    min_market_cap_proxy: float | None = None
    exclude_asset_types: list[str] = Field(default_factory=list)
    allowed_exchanges: list[str] | None = None


class SignalConfig(BaseModel):
    score_threshold: float = 0.5
    max_candidates_per_day: int = 5
    execution_timing: str = "next_open"
    decision_timing: str = "reaction_close"
    ranking_fields: list[str] = Field(default_factory=list)
    ranking_model_path: str | None = None
    scoring_model: str = "default"  # "default" or "pead"
    pead_reaction_threshold: float = 0.05
    pead_volume_threshold: float = 1.5
    a_tier_score_threshold: float | None = None
    prior_drift_min: float | None = None
    pre_event_momentum_20d_max: float | None = None  # reject if 20d pre-event return > this


# ---------------------------------------------------------------------------
# Named cash-parking presets — referenced by RiskConfig.apply_parking_preset()
# ---------------------------------------------------------------------------
PARKING_PRESETS: dict[str, dict] = {
    # ── Volatility Only (aggressive) ────────────────────────────────────────
    "vol_20_24": {
        "cash_parking_enabled": True,
        "cash_parking_symbol": "qqq",
        "cash_parking_gate_mode": "volatility",
        "cash_parking_gate_vol_lookback": 20,
        "cash_parking_gate_vol_threshold": 0.24,
    },
    "vol_20_25": {
        "cash_parking_enabled": True,
        "cash_parking_symbol": "qqq",
        "cash_parking_gate_mode": "volatility",
        "cash_parking_gate_vol_lookback": 20,
        "cash_parking_gate_vol_threshold": 0.25,
    },
    "vol_30_24": {
        "cash_parking_enabled": True,
        "cash_parking_symbol": "qqq",
        "cash_parking_gate_mode": "volatility",
        "cash_parking_gate_vol_lookback": 30,
        "cash_parking_gate_vol_threshold": 0.24,
    },
    # ── Vol + Momentum (recommended) ────────────────────────────────────────
    "vm_24_m20": {
        "cash_parking_enabled": True,
        "cash_parking_symbol": "qqq",
        "cash_parking_gate_mode": "volatility",
        "cash_parking_gate_vol_lookback": 20,
        "cash_parking_gate_vol_threshold": 0.24,
        "cash_parking_require_trend": True,
        "cash_parking_trend_mode": "momentum",
        "cash_parking_trend_sma_period": 20,
        "cash_parking_trend_reentry_pct": 0.02,
    },
    "vm_25_m20": {
        "cash_parking_enabled": True,
        "cash_parking_symbol": "qqq",
        "cash_parking_gate_mode": "volatility",
        "cash_parking_gate_vol_lookback": 20,
        "cash_parking_gate_vol_threshold": 0.25,
        "cash_parking_require_trend": True,
        "cash_parking_trend_mode": "momentum",
        "cash_parking_trend_sma_period": 20,
        "cash_parking_trend_reentry_pct": 0.02,
    },
    "vm_24_m20_r1": {
        "cash_parking_enabled": True,
        "cash_parking_symbol": "qqq",
        "cash_parking_gate_mode": "volatility",
        "cash_parking_gate_vol_lookback": 20,
        "cash_parking_gate_vol_threshold": 0.24,
        "cash_parking_require_trend": True,
        "cash_parking_trend_mode": "momentum",
        "cash_parking_trend_sma_period": 20,
        "cash_parking_trend_reentry_pct": 0.01,
    },
    "vm_24_m10": {
        "cash_parking_enabled": True,
        "cash_parking_symbol": "qqq",
        "cash_parking_gate_mode": "volatility",
        "cash_parking_gate_vol_lookback": 20,
        "cash_parking_gate_vol_threshold": 0.24,
        "cash_parking_require_trend": True,
        "cash_parking_trend_mode": "momentum",
        "cash_parking_trend_sma_period": 10,
        "cash_parking_trend_reentry_pct": 0.02,
    },
    # ── Entropy (lowest DD) ─────────────────────────────────────────────────
    "ve_10_10": {
        "cash_parking_enabled": True,
        "cash_parking_symbol": "qqq",
        "cash_parking_gate_mode": "volatility",
        "cash_parking_gate_vol_lookback": 20,
        "cash_parking_gate_vol_threshold": 0.24,
        "cash_parking_entropy_lookback": 10,
        "cash_parking_entropy_threshold": 1.0,
    },
    "ve_10_12": {
        "cash_parking_enabled": True,
        "cash_parking_symbol": "qqq",
        "cash_parking_gate_mode": "volatility",
        "cash_parking_gate_vol_lookback": 20,
        "cash_parking_gate_vol_threshold": 0.24,
        "cash_parking_entropy_lookback": 10,
        "cash_parking_entropy_threshold": 1.2,
    },
    "vme_24_e14": {
        "cash_parking_enabled": True,
        "cash_parking_symbol": "qqq",
        "cash_parking_gate_mode": "volatility",
        "cash_parking_gate_vol_lookback": 20,
        "cash_parking_gate_vol_threshold": 0.24,
        "cash_parking_require_trend": True,
        "cash_parking_trend_mode": "momentum",
        "cash_parking_trend_sma_period": 20,
        "cash_parking_trend_reentry_pct": 0.02,
        "cash_parking_entropy_lookback": 20,
        "cash_parking_entropy_threshold": 1.4,
    },
    # ── VRP — Volatility Risk Premium ────────────────────────────────────────
    "vv_24_vrp8": {
        "cash_parking_enabled": True,
        "cash_parking_symbol": "qqq",
        "cash_parking_gate_mode": "volatility",
        "cash_parking_gate_vol_lookback": 20,
        "cash_parking_gate_vol_threshold": 0.24,
        "cash_parking_vrp_threshold": 8.0,
    },
    "vmv_24_m20_vrp8": {
        "cash_parking_enabled": True,
        "cash_parking_symbol": "qqq",
        "cash_parking_gate_mode": "volatility",
        "cash_parking_gate_vol_lookback": 20,
        "cash_parking_gate_vol_threshold": 0.24,
        "cash_parking_require_trend": True,
        "cash_parking_trend_mode": "momentum",
        "cash_parking_trend_sma_period": 20,
        "cash_parking_trend_reentry_pct": 0.02,
        "cash_parking_vrp_threshold": 8.0,
    },
    # ── Temperature — Vol Acceleration ──────────────────────────────────────
    "vt_24_t13": {
        "cash_parking_enabled": True,
        "cash_parking_symbol": "qqq",
        "cash_parking_gate_mode": "volatility",
        "cash_parking_gate_vol_lookback": 20,
        "cash_parking_gate_vol_threshold": 0.24,
        "cash_parking_temperature_threshold": 1.3,
    },
    "vte_24_t12_e12_m20": {
        "cash_parking_enabled": True,
        "cash_parking_symbol": "qqq",
        "cash_parking_gate_mode": "volatility",
        "cash_parking_gate_vol_lookback": 20,
        "cash_parking_gate_vol_threshold": 0.24,
        "cash_parking_temperature_threshold": 1.2,
        "cash_parking_entropy_lookback": 10,
        "cash_parking_entropy_threshold": 1.2,
        "cash_parking_require_trend": True,
        "cash_parking_trend_mode": "momentum",
        "cash_parking_trend_sma_period": 20,
        "cash_parking_trend_reentry_pct": 0.02,
    },
    # ── Hurst Exponent ───────────────────────────────────────────────────────
    "vh_24_h50": {
        "cash_parking_enabled": True,
        "cash_parking_symbol": "qqq",
        "cash_parking_gate_mode": "volatility",
        "cash_parking_gate_vol_lookback": 20,
        "cash_parking_gate_vol_threshold": 0.24,
        "cash_parking_hurst_threshold": 0.50,
    },
    "vmh_24_m20_h50": {
        "cash_parking_enabled": True,
        "cash_parking_symbol": "qqq",
        "cash_parking_gate_mode": "volatility",
        "cash_parking_gate_vol_lookback": 20,
        "cash_parking_gate_vol_threshold": 0.24,
        "cash_parking_require_trend": True,
        "cash_parking_trend_mode": "momentum",
        "cash_parking_trend_sma_period": 20,
        "cash_parking_trend_reentry_pct": 0.02,
        "cash_parking_hurst_threshold": 0.50,
    },
    # ── Multi-Signal ─────────────────────────────────────────────────────────
    "vmeh_24_e14_h50": {
        "cash_parking_enabled": True,
        "cash_parking_symbol": "qqq",
        "cash_parking_gate_mode": "volatility",
        "cash_parking_gate_vol_lookback": 20,
        "cash_parking_gate_vol_threshold": 0.24,
        "cash_parking_require_trend": True,
        "cash_parking_trend_mode": "momentum",
        "cash_parking_trend_sma_period": 20,
        "cash_parking_trend_reentry_pct": 0.02,
        "cash_parking_entropy_lookback": 20,
        "cash_parking_entropy_threshold": 1.4,
        "cash_parking_hurst_threshold": 0.50,
    },
    "composite_v1": {
        "cash_parking_enabled": True,
        "cash_parking_symbol": "qqq",
        "cash_parking_gate_mode": "composite",
        "cash_parking_composite_exit_score": 40,
        "cash_parking_composite_enter_score": 20,
    },
    "composite_v2": {
        "cash_parking_enabled": True,
        "cash_parking_symbol": "qqq",
        "cash_parking_gate_mode": "composite",
        "cash_parking_composite_exit_score": 35,
        "cash_parking_composite_enter_score": 18,
    },
    # ── Drawdown / Combo ─────────────────────────────────────────────────────
    "dd100_8": {
        "cash_parking_enabled": True,
        "cash_parking_symbol": "qqq",
        "cash_parking_gate_mode": "drawdown",
        "cash_parking_gate_drawdown_lookback": 100,
        "cash_parking_gate_drawdown_pct": 0.08,
    },
    "dd100_10": {
        "cash_parking_enabled": True,
        "cash_parking_symbol": "qqq",
        "cash_parking_gate_mode": "drawdown",
        "cash_parking_gate_drawdown_lookback": 100,
        "cash_parking_gate_drawdown_pct": 0.10,
    },
    "vd_24_dd10": {
        "cash_parking_enabled": True,
        "cash_parking_symbol": "qqq",
        "cash_parking_gate_mode": "vol_dd",
        "cash_parking_gate_vol_lookback": 20,
        "cash_parking_gate_vol_threshold": 0.24,
        "cash_parking_gate_drawdown_lookback": 100,
        "cash_parking_gate_drawdown_pct": 0.10,
    },
    # ── Simple / Baseline ────────────────────────────────────────────────────
    "sgov": {
        "cash_parking_enabled": True,
        "cash_parking_symbol": "sgov",
    },
    "qqq_no_gate": {
        "cash_parking_enabled": True,
        "cash_parking_symbol": "qqq",
        "cash_parking_gate_mode": "volatility",
        "cash_parking_gate_vol_lookback": 20,
        "cash_parking_gate_vol_threshold": 0.99,  # effectively never gates to SGOV
    },
    "qqqm_low_dd": {
        "cash_parking_enabled": True,
        "cash_parking_symbol": "qqqm",
        "cash_parking_gate_mode": "volatility",
        "cash_parking_gate_vol_lookback": 20,
        "cash_parking_gate_vol_threshold": 0.275,
        "cash_parking_temperature_threshold": 1.2,
        "cash_parking_entropy_lookback": 20,
        "cash_parking_entropy_threshold": 1.45,
        "cash_parking_require_trend": True,
        "cash_parking_trend_mode": "momentum",
        "cash_parking_trend_sma_period": 20,
        "cash_parking_trend_reentry_pct": 0.001,
        "cash_parking_autocorr_threshold": 0.0,
        "cash_parking_topup_max_peak_drawdown_pct": 0.02,
        "cash_parking_reserve_pct": 0.0,
    },
    "qqqm_low_dd_risk25": {
        "cash_parking_enabled": True,
        "cash_parking_symbol": "qqqm",
        "cash_parking_gate_mode": "volatility",
        "cash_parking_gate_vol_lookback": 20,
        "cash_parking_gate_vol_threshold": 0.275,
        "cash_parking_temperature_threshold": 1.2,
        "cash_parking_entropy_lookback": 20,
        "cash_parking_entropy_threshold": 1.45,
        "cash_parking_require_trend": True,
        "cash_parking_trend_mode": "momentum",
        "cash_parking_trend_sma_period": 20,
        "cash_parking_trend_reentry_pct": 0.001,
        "cash_parking_autocorr_threshold": 0.0,
        "cash_parking_topup_max_peak_drawdown_pct": 0.02,
        "cash_parking_topup_risk_score_max": 25.0,
        "cash_parking_reserve_pct": 0.0,
    },
    "qqqm_low_dd_gld": {
        "cash_parking_enabled": True,
        "cash_parking_symbol": "qqqm",
        "cash_parking_gate_mode": "volatility",
        "cash_parking_gate_vol_lookback": 20,
        "cash_parking_gate_vol_threshold": 0.275,
        "cash_parking_temperature_threshold": 1.2,
        "cash_parking_entropy_lookback": 20,
        "cash_parking_entropy_threshold": 1.45,
        "cash_parking_require_trend": True,
        "cash_parking_trend_mode": "momentum",
        "cash_parking_trend_sma_period": 20,
        "cash_parking_trend_reentry_pct": 0.001,
        "cash_parking_autocorr_threshold": 0.0,
        "cash_parking_topup_max_peak_drawdown_pct": 0.02,
        "cash_parking_reserve_pct": 0.0,
        "cash_parking_defensive_symbol": "gld",
        "cash_parking_defensive_relay_enabled": True,
        "cash_parking_defensive_relay_trigger_mode": "always",
        "cash_parking_defensive_relay_risk_score_max": 100.0,
        "cash_parking_defensive_momentum_min": 0.05,
    },
    "qqqm_low_dd_tqqq_calm": {
        "cash_parking_enabled": True,
        "cash_parking_symbol": "qqqm",
        "cash_parking_gate_mode": "volatility",
        "cash_parking_gate_vol_lookback": 20,
        "cash_parking_gate_vol_threshold": 0.275,
        "cash_parking_temperature_threshold": 1.2,
        "cash_parking_entropy_lookback": 20,
        "cash_parking_entropy_threshold": 1.45,
        "cash_parking_require_trend": True,
        "cash_parking_trend_mode": "momentum",
        "cash_parking_trend_sma_period": 20,
        "cash_parking_trend_reentry_pct": 0.001,
        "cash_parking_autocorr_threshold": 0.0,
        "cash_parking_topup_max_peak_drawdown_pct": 0.02,
        "cash_parking_reserve_pct": 0.0,
        "cash_parking_low_vol_overlay_symbol": "tqqq",
        "cash_parking_low_vol_overlay_vol_threshold": 0.17,
        "cash_parking_low_vol_overlay_temperature_max": 0.92,
        "cash_parking_low_vol_overlay_entropy_max": 1.15,
    },
    "qqqm_low_dd_tqqq_calm_v2": {
        "cash_parking_enabled": True,
        "cash_parking_symbol": "qqqm",
        "cash_parking_gate_mode": "volatility",
        "cash_parking_gate_vol_lookback": 20,
        "cash_parking_gate_vol_threshold": 0.35,
        "cash_parking_temperature_threshold": 1.2,
        "cash_parking_entropy_lookback": 20,
        "cash_parking_entropy_threshold": 1.45,
        "cash_parking_require_trend": True,
        "cash_parking_trend_mode": "momentum",
        "cash_parking_trend_sma_period": 20,
        "cash_parking_trend_reentry_pct": 0.001,
        "cash_parking_autocorr_threshold": 0.0,
        "cash_parking_topup_max_peak_drawdown_pct": 0.02,
        "cash_parking_reserve_pct": 0.0,
        "cash_parking_low_vol_overlay_symbol": "tqqq",
        "cash_parking_low_vol_overlay_vol_threshold": 0.22,
        "cash_parking_low_vol_overlay_temperature_max": 1.0,
        "cash_parking_low_vol_overlay_entropy_max": 1.45,
    },
    "qqqm_low_dd_tqqq_calm_v2_gld": {
        "cash_parking_enabled": True,
        "cash_parking_symbol": "qqqm",
        "cash_parking_gate_mode": "volatility",
        "cash_parking_gate_vol_lookback": 20,
        "cash_parking_gate_vol_threshold": 0.35,
        "cash_parking_temperature_threshold": 1.2,
        "cash_parking_entropy_lookback": 20,
        "cash_parking_entropy_threshold": 1.45,
        "cash_parking_require_trend": True,
        "cash_parking_trend_mode": "momentum",
        "cash_parking_trend_sma_period": 20,
        "cash_parking_trend_reentry_pct": 0.001,
        "cash_parking_autocorr_threshold": 0.0,
        "cash_parking_topup_max_peak_drawdown_pct": 0.02,
        "cash_parking_reserve_pct": 0.0,
        "cash_parking_low_vol_overlay_symbol": "tqqq",
        "cash_parking_low_vol_overlay_vol_threshold": 0.22,
        "cash_parking_low_vol_overlay_temperature_max": 1.0,
        "cash_parking_low_vol_overlay_entropy_max": 1.45,
        "cash_parking_defensive_symbol": "gld",
        "cash_parking_defensive_relay_enabled": True,
        "cash_parking_defensive_relay_trigger_mode": "always",
        "cash_parking_defensive_relay_risk_score_max": 100.0,
        "cash_parking_defensive_momentum_min": 0.05,
    },
    "qqqm_low_dd_tqqq_active": {
        "cash_parking_enabled": True,
        "cash_parking_symbol": "qqqm",
        "cash_parking_gate_mode": "volatility",
        "cash_parking_gate_vol_lookback": 20,
        "cash_parking_gate_vol_threshold": 0.275,
        "cash_parking_temperature_threshold": 1.2,
        "cash_parking_entropy_lookback": 20,
        "cash_parking_entropy_threshold": 1.45,
        "cash_parking_require_trend": True,
        "cash_parking_trend_mode": "momentum",
        "cash_parking_trend_sma_period": 20,
        "cash_parking_trend_reentry_pct": 0.001,
        "cash_parking_autocorr_threshold": 0.0,
        "cash_parking_topup_max_peak_drawdown_pct": 0.02,
        "cash_parking_reserve_pct": 0.0,
        "cash_parking_low_vol_overlay_symbol": "tqqq",
        "cash_parking_low_vol_overlay_vol_threshold": 0.22,
        "cash_parking_low_vol_overlay_temperature_max": 1.05,
        "cash_parking_low_vol_overlay_entropy_max": 1.30,
    },
    "qqqm_low_dd_tqqq_active_v2": {
        "cash_parking_enabled": True,
        "cash_parking_symbol": "qqqm",
        "cash_parking_gate_mode": "volatility",
        "cash_parking_gate_vol_lookback": 20,
        "cash_parking_gate_vol_threshold": 0.35,
        "cash_parking_temperature_threshold": 1.2,
        "cash_parking_entropy_lookback": 20,
        "cash_parking_entropy_threshold": 1.45,
        "cash_parking_require_trend": True,
        "cash_parking_trend_mode": "momentum",
        "cash_parking_trend_sma_period": 20,
        "cash_parking_trend_reentry_pct": 0.001,
        "cash_parking_autocorr_threshold": 0.0,
        "cash_parking_topup_max_peak_drawdown_pct": 0.02,
        "cash_parking_reserve_pct": 0.0,
        "cash_parking_low_vol_overlay_symbol": "tqqq",
        "cash_parking_low_vol_overlay_vol_threshold": 0.22,
        "cash_parking_low_vol_overlay_temperature_max": 1.05,
        "cash_parking_low_vol_overlay_entropy_max": 1.45,
    },
    "qqqm_low_dd_tqqq_active_v2_gld": {
        "cash_parking_enabled": True,
        "cash_parking_symbol": "qqqm",
        "cash_parking_gate_mode": "volatility",
        "cash_parking_gate_vol_lookback": 20,
        "cash_parking_gate_vol_threshold": 0.35,
        "cash_parking_temperature_threshold": 1.2,
        "cash_parking_entropy_lookback": 20,
        "cash_parking_entropy_threshold": 1.45,
        "cash_parking_require_trend": True,
        "cash_parking_trend_mode": "momentum",
        "cash_parking_trend_sma_period": 20,
        "cash_parking_trend_reentry_pct": 0.001,
        "cash_parking_autocorr_threshold": 0.0,
        "cash_parking_topup_max_peak_drawdown_pct": 0.02,
        "cash_parking_reserve_pct": 0.0,
        "cash_parking_low_vol_overlay_symbol": "tqqq",
        "cash_parking_low_vol_overlay_vol_threshold": 0.22,
        "cash_parking_low_vol_overlay_temperature_max": 1.05,
        "cash_parking_low_vol_overlay_entropy_max": 1.45,
        "cash_parking_defensive_symbol": "gld",
        "cash_parking_defensive_relay_enabled": True,
        "cash_parking_defensive_relay_trigger_mode": "always",
        "cash_parking_defensive_relay_risk_score_max": 100.0,
        "cash_parking_defensive_momentum_min": 0.05,
    },
    "qqqm_low_dd_tqqq_active_v2_gld_brake_v2": {
        "cash_parking_enabled": True,
        "cash_parking_symbol": "qqqm",
        "cash_parking_gate_mode": "volatility",
        "cash_parking_gate_vol_lookback": 20,
        "cash_parking_gate_vol_threshold": 0.35,
        "cash_parking_temperature_threshold": 1.2,
        "cash_parking_entropy_lookback": 20,
        "cash_parking_entropy_threshold": 1.45,
        "cash_parking_require_trend": True,
        "cash_parking_trend_mode": "momentum",
        "cash_parking_trend_sma_period": 20,
        "cash_parking_trend_reentry_pct": 0.001,
        "cash_parking_autocorr_threshold": 0.0,
        "cash_parking_topup_max_peak_drawdown_pct": 0.02,
        "cash_parking_reserve_pct": 0.0,
        "cash_parking_low_vol_overlay_symbol": "tqqq",
        "cash_parking_low_vol_overlay_vol_threshold": 0.22,
        "cash_parking_low_vol_overlay_temperature_max": 1.05,
        "cash_parking_low_vol_overlay_entropy_max": 1.45,
        "cash_parking_defensive_symbol": "gld",
        "cash_parking_defensive_relay_enabled": True,
        "cash_parking_defensive_relay_trigger_mode": "always",
        "cash_parking_defensive_relay_risk_score_max": 100.0,
        "cash_parking_defensive_momentum_min": 0.05,
        # Shock brake: Signal 4 (near-SMA buffer) ONLY — Signals 1-3 disabled
        # Signals 1-3 (rv_ratio, dd5_pct, sma_cross) over-trigger 2023-2024, compounding equity loss
        # Signal 4 correctly fires Dec 11 (SmaGap=0.37%, vol5/vol20=0.284>0.25) without false alarms
        "cash_parking_overlay_shock_brake_enabled": True,
        "cash_parking_overlay_shock_brake_rv_ratio": 99.0,    # Signal 1: effectively disabled
        "cash_parking_overlay_shock_brake_dd5_pct": 1.0,      # Signal 3: effectively disabled
        "cash_parking_overlay_shock_brake_sma_cross": False,   # Signal 2: disabled
        "cash_parking_overlay_shock_brake_cooldown_days": 2,
        # Signal 4: exit TQQQ when QQQ within 0.5% above SMA10 + vol5/vol20 ∈ (0.25, 0.45)
        # Upper bound 0.45 filters out high-vol days (regular gate handles those) and false alarms.
        # Targets "barely elevated" pre-crash vol signature; fires Dec 11 (0.284) not May 21 (0.529).
        # Same-day re-buy is skipped after brake fires; next day gate decides (SGOV on crash days)
        "cash_parking_overlay_shock_brake_sma_buffer": 0.005,
        "cash_parking_overlay_shock_brake_rv_ratio_upper": 0.45,
        # Dwell cap: disabled (max_hold=4 caused Sep-26 bottom-exit; default 0 = unlimited)
    },
    # ── Bearish Parking (SH inverse ETF) ────────────────────────────────────
    # Two-stage: normal→QQQ, mild stress→SGOV, deep stress→SH
    # Uses composite risk score: < exit_score=QQQ, exit_score→SGOV, > bearish_threshold→SH
    "composite_sh_60": {
        "cash_parking_enabled": True,
        "cash_parking_symbol": "qqq",
        "cash_parking_gate_mode": "composite",
        "cash_parking_composite_exit_score": 40,
        "cash_parking_composite_enter_score": 20,
        "cash_parking_bearish_symbol": "sh",
        "cash_parking_bearish_threshold": 60,
    },
    "composite_sh_65": {
        "cash_parking_enabled": True,
        "cash_parking_symbol": "qqq",
        "cash_parking_gate_mode": "composite",
        "cash_parking_composite_exit_score": 40,
        "cash_parking_composite_enter_score": 20,
        "cash_parking_bearish_symbol": "sh",
        "cash_parking_bearish_threshold": 65,
    },
    "composite_sh_70": {
        "cash_parking_enabled": True,
        "cash_parking_symbol": "qqq",
        "cash_parking_gate_mode": "composite",
        "cash_parking_composite_exit_score": 40,
        "cash_parking_composite_enter_score": 20,
        "cash_parking_bearish_symbol": "sh",
        "cash_parking_bearish_threshold": 70,
    },
    # Option A: strict QQQ re-entry (enter_score=10) — prevents premature QQQ re-entry during bear market rallies
    "composite_sh_60_strict": {
        "cash_parking_enabled": True,
        "cash_parking_symbol": "qqq",
        "cash_parking_gate_mode": "composite",
        "cash_parking_composite_exit_score": 40,
        "cash_parking_composite_enter_score": 10,  # much harder to re-enter QQQ
        "cash_parking_bearish_symbol": "sh",
        "cash_parking_bearish_threshold": 60,
    },
    # Option B: SGOV+SH only — no QQQ parking; SGOV when safe, SH when deep stress
    "sgov_sh_60": {
        "cash_parking_enabled": True,
        "cash_parking_symbol": "sgov",   # base is always SGOV, never QQQ
        "cash_parking_gate_mode": "composite",
        "cash_parking_composite_exit_score": 40,
        "cash_parking_composite_enter_score": 20,
        "cash_parking_bearish_symbol": "sh",
        "cash_parking_bearish_threshold": 60,
    },
    # ── Multi-tier Regime Parking ────────────────────────────────────────────
    # VIX-driven 3-tier rotation: Risk-On→JEPQ, Neutral→QQQM, Risk-Off→SGOV
    # Requires JEPQ price data in Oracle. JEPQ dividend income not modeled in backtest.
    "regime_tiered_jepq": {
        "cash_parking_enabled": True,
        "cash_parking_symbol": "qqqm",              # default fallback symbol
        "cash_parking_gate_mode": "regime_tiered",
        "cash_parking_regime_risk_on_symbol": "jepq",
        "cash_parking_regime_neutral_symbol": "qqqm",
        "cash_parking_regime_vix_low_threshold": 20.0,
        "cash_parking_regime_vix_high_threshold": 25.0,
        "cash_parking_regime_hysteresis_buffer": 1.0,
        "cash_parking_reserve_pct": 0.02,
        "cash_parking_stop_pct": 0.045,
        "cash_parking_stop_recovery_days": 5,
        "cash_parking_stop_recovery_pct": 0.02,
    },
}


# ---------------------------------------------------------------------------
# Named idle-alpha sleeve presets — referenced by BacktestConfig
# ---------------------------------------------------------------------------
_MICRO_EVENT_ALPHA_ENGINES: list[dict[str, Any]] = [
    {
        "engine_id": "next_open_long_material_contract_mixed_micro_postmarket",
        "selection_priority": -1,
        "event_types": ["material_contract"],
        "event_directions": ["mixed"],
        "guidance_statuses": ["not_provided"],
        "filing_time_buckets": ["post_market"],
        "timing_class": "after_close",
        "direction": "long_only",
        "entry_timing_policy": "next_open",
        "max_holding_days": 12,
        "engine_risk_budget_pct": 0.165,
        "reaction_day_return_min": -0.01,
        "reaction_day_return_max": 0.04,
        "close_location_min": 0.6,
        "close_location_max": 1.0,
        "gap_size_min": -0.02,
        "gap_size_max": 0.02,
        "volume_ratio_min": 0.5,
        "volume_ratio_max": 1.9,
        "max_market_cap_proxy": 15000000000.0,
        "document_quality_score_min": 0.5,
        "parse_confidence_overall_min": 0.45,
        "score_threshold_override": 0.0,
        "residual_reserve_selected": True,
        "post_allocation_idle_only": True,
        "next_open_gap_cap_pct": 0.02,
        "early_failure_close_below_entry_and_reaction_close_override": False,
        "early_failure_no_progress_days_override": 12,
        "early_failure_no_progress_r_override": 0.0,
        "early_failure_no_progress_fraction_override": 1.0,
        "target_1_r_override": 3.0,
        "target_1_fraction_override": 0.1,
        "trailing_warmup_days_override": 12,
        "enabled": True,
        "veto_parse_confidence_min_override": 0.45,
        "per_trade_risk_pct_override": 0.12375,
        "stop_atr_multiplier_override": 3.0,
        "use_reaction_day_low_stop_override": False,
        "pre_event_entropy_60d_max": 2.05,
        "pre_event_market_temperature_max": 1.85,
        "macro_vix_max": 30.0,
    },
    {
        "engine_id": "next_open_long_guidance_mixed_micro_postmarket",
        "selection_priority": -1,
        "event_types": ["guidance_update"],
        "event_directions": ["mixed"],
        "guidance_statuses": ["not_provided"],
        "filing_time_buckets": ["post_market"],
        "timing_class": "after_close",
        "direction": "long_only",
        "entry_timing_policy": "next_open",
        "max_holding_days": 12,
        "engine_risk_budget_pct": 0.12375,
        "per_trade_risk_pct_override": 0.1155,
        "reaction_day_return_min": -0.02,
        "reaction_day_return_max": 0.05,
        "close_location_min": 0.55,
        "close_location_max": 1.0,
        "gap_size_min": -0.02,
        "gap_size_max": 0.025,
        "volume_ratio_min": 0.5,
        "volume_ratio_max": 2.3,
        "max_market_cap_proxy": 15000000000.0,
        "document_quality_score_min": 0.5,
        "parse_confidence_overall_min": 0.45,
        "score_threshold_override": 0.0,
        "residual_reserve_selected": True,
        "post_allocation_idle_only": True,
        "next_open_gap_cap_pct": 0.025,
        "early_failure_close_below_entry_and_reaction_close_override": False,
        "early_failure_no_progress_days_override": 12,
        "early_failure_no_progress_r_override": 0.0,
        "early_failure_no_progress_fraction_override": 1.0,
        "target_1_r_override": 5.0,
        "target_1_fraction_override": 0.1,
        "trailing_warmup_days_override": 12,
        "enabled": True,
        "veto_parse_confidence_min_override": 0.45,
        "stop_atr_multiplier_override": 3.0,
        "use_reaction_day_low_stop_override": False,
        "pre_event_entropy_60d_max": 1.7,
        "pre_event_market_temperature_max": 0.8,
        "pre_event_gravitational_pull_min": 1.0,
        "macro_vix_max": 30.0,
    },
    {
        "engine_id": "next_open_long_earnings_unknown_inline_postmarket_strict",
        "selection_priority": -1,
        "event_types": ["earnings_release"],
        "event_directions": ["unknown"],
        "guidance_statuses": ["inline_or_maintained"],
        "filing_time_buckets": ["post_market"],
        "timing_class": "after_close",
        "direction": "long_only",
        "entry_timing_policy": "next_open",
        "max_holding_days": 12,
        "engine_risk_budget_pct": 0.12375,
        "per_trade_risk_pct_override": 0.066,
        "reaction_day_return_min": -0.02,
        "reaction_day_return_max": 0.04,
        "close_location_min": 0.1,
        "close_location_max": 1.0,
        "gap_size_min": -0.05,
        "gap_size_max": 0.05,
        "volume_ratio_min": 1.2,
        "volume_ratio_max": 3.0,
        "min_market_cap_proxy": 4000000000.0,
        "max_market_cap_proxy": 15000000000.0,
        "document_quality_score_min": 0.5,
        "parse_confidence_overall_min": 0.45,
        "score_threshold_override": 0.0,
        "residual_reserve_selected": True,
        "post_allocation_idle_only": True,
        "veto_parse_confidence_min_override": 0.45,
        "next_open_gap_cap_pct": 0.05,
        "early_failure_close_below_entry_and_reaction_close_override": False,
        "early_failure_no_progress_days_override": 10,
        "early_failure_no_progress_r_override": 0.0,
        "early_failure_no_progress_fraction_override": 1.0,
        "target_1_r_override": 5.0,
        "target_1_fraction_override": 0.1,
        "trailing_warmup_days_override": 10,
        "enabled": True,
        "stop_atr_multiplier_override": 3.0,
        "use_reaction_day_low_stop_override": False,
        "pre_event_entropy_60d_max": 1.9,
        "pre_event_market_temperature_max": 1.1,
        "macro_vix_max": 30.0,
    },
    {
        "engine_id": "next_open_long_bullish_raised_strong",
        "selection_priority": -1,
        "event_types": ["earnings_release"],
        "event_directions": ["bullish"],
        "guidance_statuses": ["raised"],
        "filing_time_buckets": ["post_market"],
        "timing_class": "after_close",
        "direction": "long_only",
        "entry_timing_policy": "next_open",
        "max_holding_days": 20,
        "engine_risk_budget_pct": 0.165,
        "reaction_day_return_min": 0.03,
        "reaction_day_return_max": 0.2,
        "close_location_min": 0.96,
        "volume_ratio_min": 1.2,
        "volume_ratio_max": 6.0,
        "min_market_cap_proxy": 5000000000.0,
        "document_quality_score_min": 0.6,
        "parse_confidence_overall_min": 0.6,
        "score_threshold_override": 0.0,
        "residual_reserve_selected": True,
        "post_allocation_idle_only": True,
        "next_open_gap_cap_pct": 0.1,
        "early_failure_close_below_entry_and_reaction_close_override": False,
        "early_failure_no_progress_days_override": 12,
        "early_failure_no_progress_r_override": 0.0,
        "early_failure_no_progress_fraction_override": 1.0,
        "target_1_r_override": 5.0,
        "target_1_fraction_override": 0.1,
        "trailing_warmup_days_override": 10,
        "enabled": True,
        "per_trade_risk_pct_override": 0.12375,
        "stop_atr_multiplier_override": 3.9,
        "use_reaction_day_low_stop_override": False,
        "pre_event_entropy_60d_min": 1.97,
    },
]

_MICRO_EVENT_ALPHA_BREADTH_ENGINE: dict[str, Any] = {
    "engine_id": "idle_macro_breadth_smh_postalloc",
    "selection_priority": -2,
    "timing_class": "after_close",
    "direction": "long_only",
    "entry_timing_policy": "next_open",
    "max_holding_days": 2,
    "engine_risk_budget_pct": 0.022,
    "per_trade_risk_pct_override": 0.0033,
    "stop_atr_multiplier_override": 1.9,
    "target_1_r_override": 99.0,
    "target_1_fraction_override": 0.0,
    "trailing_warmup_days_override": 1,
    "early_failure_close_below_entry_and_reaction_close_override": False,
    "next_open_gap_cap_pct": 0.018,
    "macro_long_symbol": "SMH",
    "macro_long_trade_symbol_mode": "fixed",
    "macro_long_reaction_day_return_min": 0.018,
    "macro_long_volume_ratio_min": 1.1,
    "macro_long_gap_size_min": 0.0,
    "macro_long_close_location_min": 0.64,
    "macro_long_breadth_symbols": ["QQQ", "XLK", "SMH"],
    "macro_long_min_breadth_count": 2,
    "macro_long_breadth_reaction_day_return_min": 0.01,
    "macro_long_breadth_close_location_min": 0.6,
    "macro_long_leadership_vs_spy_min": 0.004,
    "macro_vix_max": 27.0,
    "enabled": True,
    "synthetic_only": True,
    "post_allocation_idle_only": True,
}

IDLE_ALPHA_SLEEVE_PRESETS: dict[str, dict[str, Any]] = {
    "micro_event_alpha": {
        "strategy_engines": [
            *(_MICRO_EVENT_ALPHA_ENGINES),
        ],
    },
    "micro_event_alpha_plus_event_plus": {
        "strategy_engines": [
            *(_MICRO_EVENT_ALPHA_ENGINES),
            {**_MICRO_EVENT_ALPHA_BREADTH_ENGINE},
        ],
        "idle_alpha": {
            "dynamic_allocator_enabled": True,
            "dynamic_allocator_cash_ratio_low": 0.04,
            "dynamic_allocator_cash_ratio_high": 0.16,
            "dynamic_allocator_cash_scale_low": 0.8,
            "dynamic_allocator_cash_scale_high": 1.025,
            "dynamic_allocator_crowded_primary_candidate_count": 6,
            "dynamic_allocator_crowded_primary_unique_sector_count": 4,
            "dynamic_allocator_crowded_scale": 0.79,
            "dynamic_allocator_synthetic_scale_multiplier": 1.03,
            "dynamic_allocator_snapshot_scale_multiplier": 1.02,
            "dynamic_allocator_synthetic_reentry_cooldown_days": 2,
            "dynamic_allocator_min_scale": 0.6,
            "dynamic_allocator_max_scale": 1.045,
        },
    },
}


# ---------------------------------------------------------------------------
# Named dividend-capture sleeve presets — referenced by BacktestConfig
# ---------------------------------------------------------------------------
DIVIDEND_CAPTURE_SLEEVE_PRESETS: dict[str, dict[str, Any]] = {}


FORM4_CAPTURE_SLEEVE_PRESETS: dict[str, dict[str, Any]] = {
    "reserve_form4_cluster": {
        "enabled": True,
        "pit_events_path": "data/reference/form4_daily_events_pit.parquet",
        "reserve_pct": 0.04,
        "min_owner_count": 2,
        "min_total_value": 5_000_000.0,
        "min_purchase_pct": 0.0,
        "max_lag_days": None,
        "hold_days": 20,
        "max_positions": 6,
        "max_new_per_day": 2,
    },
    "reserve_form4_cluster_plus": {
        "enabled": True,
        "pit_events_path": "data/reference/form4_daily_events_pit.parquet",
        "reserve_pct": 0.06,
        "min_owner_count": 2,
        "min_total_value": 5_000_000.0,
        "min_purchase_pct": 0.0,
        "max_lag_days": None,
        "hold_days": 20,
        "max_positions": 6,
        "max_new_per_day": 2,
    },
    "reserve_form4_cluster_plus_fresh": {
        "enabled": True,
        "pit_events_path": "data/reference/form4_daily_events_pit.parquet",
        "reserve_pct": 0.06,
        "min_owner_count": 2,
        "min_total_value": 5_000_000.0,
        "min_purchase_pct": 0.0,
        "min_transaction_count": 2,
        "max_lag_days": 2,
        "max_min_lag_days": 1,
        "hold_days": 20,
        "max_positions": 6,
        "max_new_per_day": 2,
    },
    "reserve_form4_cluster_plus_fresh_same_day": {
        "enabled": True,
        "pit_events_path": "data/reference/form4_daily_events_pit.parquet",
        "reserve_pct": 0.06,
        "min_owner_count": 2,
        "min_total_value": 5_000_000.0,
        "min_purchase_pct": 0.0,
        "min_transaction_count": 2,
        "max_lag_days": 2,
        "max_min_lag_days": 1,
        "max_transaction_span_days": 0,
        "hold_days": 20,
        "max_positions": 6,
        "max_new_per_day": 2,
    },
}


OWNERSHIP_CAPTURE_SLEEVE_PRESETS: dict[str, dict[str, Any]] = {
    "ownership_13d_raise_reserve_plus_strict": {
        "enabled": True,
        "pit_events_path": "data/reference/ownership_13d13g_events_pit.parquet",
        "reserve_pct": 0.15,
        "form_groups": ["13D"],
        "min_percent_owned": 5.0,
        "min_percent_delta_points": 2.0,
        "require_amendment": True,
        "require_initial": False,
        "require_activist": False,
        "require_13g_to_13d_transition": False,
        "hold_days": 30,
        "min_avg_dollar_volume": 10_000_000.0,
        "max_positions": 6,
        "max_new_per_day": 2,
        "min_cash_ratio_for_overlay": 0.25,
        "max_idle_deploy_pct": 0.0,
    },
    "ownership_13d_raise_reserve_ultra_balanced_purpose_plus_cooldown90_r95": {
        "enabled": True,
        "pit_events_path": "data/reference/ownership_13d13g_events_pit.parquet",
        "reserve_pct": 0.95,
        "form_groups": ["13D"],
        "min_percent_owned": 5.0,
        "min_percent_delta_points": 2.0,
        "exclude_housekeeping_purpose": True,
        "exclude_structural_exchange_purpose": True,
        "require_amendment": True,
        "require_initial": False,
        "require_activist": False,
        "require_13g_to_13d_transition": False,
        "symbol_cooldown_days_after_loss": 90,
        "hold_days": 30,
        "min_avg_dollar_volume": 10_000_000.0,
        "max_positions": 6,
        "max_new_per_day": 2,
        "min_cash_ratio_for_overlay": 0.25,
        "max_idle_deploy_pct": 0.0,
    },
}

RISK_OFF_ALPHA_SLEEVE_PRESETS: dict[str, dict[str, Any]] = {
    "risk_off_alpha_gld_crisis65_balanced_refined": {
        "enabled": True,
        "reserve_pct": 0.37,
        "symbols": ["gld"],
        "momentum_lookback_days": 20,
        "min_symbol_momentum": 0.05,
        "min_consecutive_sgov_days": 3,
        "min_parking_risk_score": 65.0,
        "rotation_momentum_gap": 0.02,
        "max_holding_days": 0,
        "min_cash_ratio_for_overlay": 0.0,
        "max_idle_deploy_pct": 0.0,
    },
    "risk_off_alpha_gld_crisis60": {
        "enabled": True,
        "reserve_pct": 0.60,
        "symbols": ["gld"],
        "momentum_lookback_days": 20,
        "min_symbol_momentum": 0.05,
        "min_consecutive_sgov_days": 3,
        "min_parking_risk_score": 60.0,
        "rotation_momentum_gap": 0.02,
        "max_holding_days": 0,
        "min_cash_ratio_for_overlay": 0.0,
        "max_idle_deploy_pct": 0.0,
    },
    "risk_off_alpha_gld_crisis65_heavy": {
        "enabled": True,
        "reserve_pct": 0.70,
        "symbols": ["gld"],
        "momentum_lookback_days": 20,
        "min_symbol_momentum": 0.05,
        "min_consecutive_sgov_days": 3,
        "min_parking_risk_score": 65.0,
        "rotation_momentum_gap": 0.02,
        "max_holding_days": 0,
        "min_cash_ratio_for_overlay": 0.0,
        "max_idle_deploy_pct": 0.0,
    },
}


class RiskConfig(BaseModel):
    per_trade_risk_pct: float = 0.01       # 1% of equity per trade
    per_trade_risk_pct_a_tier: float | None = None
    max_daily_new_risk_pct: float = 0.03   # 3% of equity per day
    allow_budget_downsizing: bool = False  # when True, clip order size to remaining daily/engine risk budget
    allow_oneoff_downsizing: bool = False  # when True, clip risk for high one-off candidates instead of hard veto
    oneoff_downsize_floor: float = 0.25    # minimum risk scaler when oneoff_downsizing is enabled
    max_positions: int = 10
    max_positions_per_sector: int = 3
    buying_power_multiplier: float = 1.0   # max gross notional / equity for new long exposure
    max_position_value_pct: float | None = None  # max fraction of equity in one position
    max_adv_fraction: float | None = None        # max fraction of avg daily volume
    cooldown_after_loss_streak: int = 0          # consecutive losses to trigger cooldown
    cooldown_days: int = 0                       # days to sit out after streak
    macro_regime_enabled: bool = False           # block entries when SPY < SMA
    macro_regime_size_scaler: float = 1.0        # size scaler when SPY < SMA (< 1.0 = scale down instead of block)
    macro_regime_mode: str = "legacy_spy"        # "legacy_spy" or "spy_qqq_scaler"
    macro_regime_neutral_size_scaler: float | None = None
    macro_regime_risk_off_size_scaler: float | None = None
    macro_regime_risk_off_a_tier_only: bool = False
    macro_sma_period: int = 20                   # SMA lookback for macro regime
    stop_atr_multiplier: float = 1.5             # ATR multiplier for stop distance
    dynamic_stop_enabled: bool = False           # scale ATR multiplier by reaction size & entropy
    dynamic_stop_reaction_low: float = 0.03      # reaction_day_return below this -> tighter stop
    dynamic_stop_reaction_high: float = 0.12     # reaction_day_return above this -> wider stop
    dynamic_stop_reaction_scaler_low: float = 0.8    # ATR scaler for calm reactions
    dynamic_stop_reaction_scaler_high: float = 1.3   # ATR scaler for extreme reactions
    dynamic_stop_entropy_low: float = 1.4        # pre_event_entropy_60d below this -> tighter stop
    dynamic_stop_entropy_high: float = 2.0       # pre_event_entropy_60d above this -> wider stop
    dynamic_stop_entropy_scaler_low: float = 0.85    # ATR scaler for low-entropy stocks
    dynamic_stop_entropy_scaler_high: float = 1.25   # ATR scaler for high-entropy stocks
    dynamic_stop_combined_floor: float = 0.7     # minimum combined scaler
    dynamic_stop_combined_ceiling: float = 1.5   # maximum combined scaler
    backtest_mode: str = "research"              # "research" or "live"
    kill_switch_cooldown_days: int = 20          # trading days before reset (research only)
    kill_switch_log_only: bool = False            # log-only mode (don't trigger, just observe)
    veto_oneoff_penalty: float = 0.5             # block if oneoff_penalty >= this
    veto_parse_confidence_min: float = 0.4       # block if parse_confidence < this
    veto_unknown_direction: bool = True           # block if event_direction == "unknown"
    veto_bearish_direction: bool = True           # block if event_direction == "bearish"
    fixed_capital_sizing: bool = False            # when True, position sizing uses initial_capital instead of current equity
    reaction_size_cap_threshold: float | None = None  # abs(reaction)% above which size scales down (e.g. 0.08)
    momentum_size_scaler_threshold: float | None = None  # pre-event mom_20d above which size scales down (e.g. 0.10)
    momentum_size_scaler_floor: float = 0.3  # minimum scaler for high-momentum entries
    momentum_size_scaler_feature: str = "pre_event_momentum_20d"  # or "price_vs_sma20"
    contrarian_boost_threshold: float | None = None  # pre-event mom_20d below which size scales UP (e.g. -0.03)
    contrarian_boost_max: float = 1.5  # max scaler for low-momentum entries
    high_momentum_max_holding_days: int | None = None  # override max_holding_days when mom > threshold
    high_momentum_holding_threshold: float | None = None  # momentum threshold for holding day reduction
    vix_size_scaler_low: float = 15.0            # VIX level below which sizing is 1.0 (full)
    vix_size_scaler_high: float = 30.0           # VIX level above which sizing is at minimum
    vix_size_scaler_min: float = 0.35            # minimum size scaler at high VIX
    volatility_size_scaler_enabled: bool = False   # inverse-vol position sizing
    volatility_size_scaler_low: float = 0.01      # daily vol below this = full size (1.0)
    volatility_size_scaler_high: float = 0.04     # daily vol above this = min size
    volatility_size_scaler_min: float = 0.5       # floor scaler at high vol
    breadth_throttle_enabled: bool = False        # scale down when the daily selected slate is crowded
    breadth_throttle_candidate_count_threshold: int = 8
    breadth_throttle_min: float = 0.7
    sector_crowding_penalty_enabled: bool = False  # scale down when too many same-sector candidates compete
    sector_crowding_candidate_count_threshold: int = 3
    sector_crowding_penalty_min: float = 0.65
    tail_risk_adjuster_enabled: bool = False      # combined left-tail penalty using reaction/oneoff/orderliness features
    tail_risk_penalty_threshold: float = 0.62
    tail_risk_penalty_min: float = 0.65
    tail_risk_min_signals: int = 2
    tail_exit_adjuster_enabled: bool = False      # shorten holds / tighten no-progress exits for high tail candidates
    tail_exit_threshold: float = 0.62
    tail_exit_min_signals: int = 2
    tail_exit_max_holding_days: int | None = None
    tail_exit_no_progress_days: int | None = None
    tail_exit_no_progress_r: float | None = None
    tail_exit_no_progress_fraction: float | None = None
    technical_conviction_boost_enabled: bool = False  # boost sizing for favorable technicals
    technical_conviction_boost_max: float = 1.3       # max size multiplier for conviction trades
    seasonal_reset_enabled: bool = False          # close all positions before peak event season
    seasonal_reset_month: int = 1                 # month to trigger reset (1=Jan → free capital for Feb earnings)
    seasonal_reset_day: int = 20                  # day of month to trigger reset
    cash_parking_enabled: bool = False            # park idle cash in index when no event positions
    cash_parking_preset: str | None = None        # named preset (overrides all other parking params)
    cash_parking_account_type: str = "cash"       # "cash" (no PDT, GFV only) or "margin" (PDT day trade rules)
    cash_parking_symbol: str = "spy"              # "spy", "spym", "qual", "qqq", "qqqm", "dynamic", "sgov"
    cash_parking_defensive_symbol: str = "spy"    # fallback defensive ETF for pair/regime parking ("spy", "spym", "qual")
    cash_parking_defensive_alt_symbol: str | None = None  # optional alternate defensive ETF; pick the stronger valid candidate when set
    cash_parking_defensive_relay_enabled: bool = False  # when primary gate says SGOV, allow defensive ETF instead if it is still healthy
    cash_parking_defensive_relay_trigger_mode: str = "always"  # "always", "turn_of_month", "recovery", "turn_or_recovery"
    cash_parking_defensive_relay_turn_strength_min: float = 0.0  # minimum month-turn strength to allow relay
    cash_parking_defensive_relay_recovery_momentum_days: int = 5  # lookback for rebound confirmation
    cash_parking_defensive_relay_recovery_momentum_min: float = 0.0  # minimum rebound return to allow relay
    cash_parking_defensive_relay_drawdown_accel_max: float = 999.0  # require drawdown damage to be stabilizing/improving
    cash_parking_defensive_relay_risk_score_max: float = 100.0  # hard cap for relay even if exit threshold is wider
    cash_parking_defensive_momentum_min: float = -0.01  # require defensive ETF momentum above this to replace SGOV
    cash_parking_defensive_vol_max: float = 0.0  # optional tighter vol cap for defensive ETF (0 = use parking vol threshold)
    cash_parking_defensive_alloc_pct: float = 1.0  # if <1, allocate this fraction to defensive_symbol and keep the rest in SGOV
    cash_parking_reserve_pct: float = 0.02        # keep this % of equity as cash reserve
    cash_parking_trend_gate: bool = False          # only park when price > SMA (skip downtrends)
    cash_parking_gate_sma_period: int = 20        # SMA period for trend gate (10, 20, 50, etc.)
    cash_parking_gate_mode: str = "price_above"   # "price_above", "dual_sma", "drawdown", "momentum", "pct_threshold", "combo", "hysteresis", "slope", "breakout", "volatility", "recovery", "vol_trend", "vol_dd", "vol_regime", "guarded_regime", "composite", "science_regime", "science_blend", "relative_strength", "vt_blend", "vt_pair_blend", "regime_tiered"
    cash_parking_composite_exit_score: int = 40   # risk score >= this → SGOV
    cash_parking_composite_enter_score: int = 20  # risk score <= this → QQQ (hysteresis)
    cash_parking_composite_spy_score: int = 28    # science_regime: mid-risk parking goes to defensive ETF below this score
    cash_parking_gate_sma_long: int = 50          # long SMA period for dual_sma mode
    cash_parking_gate_drawdown_lookback: int = 50 # rolling high lookback days for drawdown gate
    cash_parking_gate_drawdown_pct: float = 0.07  # max drawdown from rolling high before SGOV
    cash_parking_gate_momentum_days: int = 20     # N-day return for momentum gate
    cash_parking_gate_pct_threshold: float = 0.02 # must be X% above SMA for pct_threshold gate
    cash_parking_gate_combo_require: int = 2      # how many sub-gates must agree for combo
    # Hysteresis: different entry/exit thresholds
    cash_parking_gate_hyst_enter_pct: float = 0.05  # enter QQQ when dd < this from high
    cash_parking_gate_hyst_exit_pct: float = 0.12   # exit to SGOV when dd > this from high
    # Slope: SMA slope direction
    cash_parking_gate_slope_period: int = 5       # days to measure SMA slope
    # Breakout: new N-day high
    cash_parking_gate_breakout_lookback: int = 50 # new high within N days → QQQ
    # Volatility: realized vol threshold
    cash_parking_gate_vol_lookback: int = 20      # days for vol calculation
    cash_parking_gate_vol_threshold: float = 0.25 # annualized vol threshold (above → SGOV)
    # Recovery: after SGOV, require N-day return > X% to re-enter QQQ
    cash_parking_gate_recovery_days: int = 10     # confirm recovery over N days
    cash_parking_gate_recovery_pct: float = 0.05  # require X% gain to re-enter
    # Proportional vol parking: scale QQQ allocation by vol level
    cash_parking_gate_vol_full_pct: float = 0.15  # vol below this → 100% QQQ
    cash_parking_gate_vol_zero_pct: float = 0.24  # vol above this → 0% QQQ (100% SGOV)
    # Vol TQQQ: ultra-low vol → TQQQ, normal → QQQ, high → SGOV
    cash_parking_gate_vol_tqqq_pct: float = 0.15  # vol below this → TQQQ instead of QQQ
    cash_parking_gate_vol_spy_threshold: float = 0.28  # guarded_regime: vol below this → defensive ETF fallback
    cash_parking_sgov_annual_rate: float = 0.05   # SGOV annualized yield (for "sgov" mode)
    cash_parking_stop_pct: float = 0.0           # parking trailing stop: exit if down X% from peak since entry (0=disabled)
    cash_parking_stop_recovery_days: int = 5     # after stop, require N-day positive return to re-enter
    cash_parking_stop_recovery_pct: float = 0.02 # require X% gain over recovery_days to confirm bounce
    cash_parking_require_trend: bool = False      # also require trend confirmation to stay in QQQ
    cash_parking_trend_sma_period: int = 50       # SMA period for trend (sma mode) or lookback days (momentum mode)
    cash_parking_trend_mode: str = "sma"          # "sma" (close > SMA) or "momentum" (N-day return > 0)
    cash_parking_trend_reentry_pct: float = 0.0   # momentum must exceed this to re-enter QQQ (0 = same as exit)
    cash_parking_exit_confirm_days: int = 1       # require N consecutive days before risk asset -> SGOV switch
    cash_parking_entry_confirm_days: int = 1      # require N consecutive days before SGOV -> risk asset switch
    cash_parking_stress_reentry_vol_mult: float = 1.0  # after a gate-driven SGOV exit, require vol < threshold * this
    cash_parking_stress_reentry_temperature_mult: float = 1.0  # stricter temperature threshold on re-entry (<1 = tighter)
    cash_parking_stress_reentry_entropy_mult: float = 1.0  # stricter entropy threshold on re-entry (<1 = tighter)
    cash_parking_stress_reentry_autocorr_buffer: float = 0.0  # require autocorr >= base threshold + buffer after gate exit
    cash_parking_topup_min_gain_pct: float = -999.0  # only add new idle cash to an existing risk parking sleeve when current price is above avg by this pct
    cash_parking_topup_min_days_held: int = 0  # require an existing risk parking sleeve to age N days before adding more cash
    cash_parking_topup_risk_score_max: float = 0.0  # maximum composite parking risk score allowed for pullback top-up (0 = disabled)
    cash_parking_topup_max_peak_drawdown_pct: float = 0.02  # block adding new cash when current price is more than this pct below the sleeve's post-entry peak
    cash_parking_vix_reentry_max: float = 0.0    # VIX must be below this to re-enter QQQ (0 = disabled)
    cash_parking_entropy_lookback: int = 20     # days for entropy calculation
    cash_parking_entropy_threshold: float = 0.0 # entropy above this → SGOV (0 = disabled)
    # Novel signals (physics/information theory/financial economics)
    cash_parking_vrp_threshold: float = 0.0        # VRP (VIX - realized_vol*100) above this → SGOV (0=disabled, academic: 8)
    cash_parking_temperature_threshold: float = 0.0 # vol_15/vol_50 ratio above this → SGOV (0=disabled, academic: 1.3)
    cash_parking_hurst_threshold: float = 0.0      # Hurst exponent below this → SGOV (0=disabled, academic: 0.45)
    cash_parking_efficiency_threshold: float = 0.0  # efficiency below this → SGOV (0=disabled)
    cash_parking_downside_vol_threshold: float = 0.0  # downside semivol above this → SGOV (0=disabled)
    cash_parking_ulcer_threshold: float = 0.0  # ulcer index above this → SGOV (0=disabled)
    cash_parking_drawdown_accel_threshold: float = 0.0  # drawdown acceleration above this → SGOV (0=disabled)
    cash_parking_kurtosis_threshold: float = 0.0   # excess kurtosis above this → SGOV (0=disabled, academic: 3.0)
    cash_parking_autocorr_threshold: float = -99.0 # autocorrelation below this → SGOV (-99=disabled, academic: -0.1)
    cash_parking_corr_threshold: float = 0.0       # SPY-QQQ corr below this → SGOV (0=disabled, academic: 0.80)
    cash_parking_low_vol_overlay_symbol: str | None = None  # optional overlay symbol (e.g. TQQQ) when regime is ultra-calm
    cash_parking_low_vol_overlay_vol_threshold: float = 0.0  # require QQQ realized vol below this to use overlay
    cash_parking_low_vol_overlay_temperature_max: float = 0.0  # require vol_15/vol_50 <= this to use overlay
    cash_parking_low_vol_overlay_entropy_max: float = 0.0  # require entropy <= this to use overlay
    cash_parking_low_vol_overlay_hurst_min: float = 0.0  # require Hurst >= this to use overlay
    # TQQQ overlay shock brake: demote overlay → base symbol on acceleration signals
    cash_parking_overlay_shock_brake_enabled: bool = False
    cash_parking_overlay_shock_brake_rv_ratio: float = 1.35      # rv5/rv20 > this → brake
    cash_parking_overlay_shock_brake_dd5_pct: float = 0.0225     # QQQ 5-day drawdown > this → brake
    cash_parking_overlay_shock_brake_sma_cross: bool = True       # QQQ < SMA10 AND smh_mom_5 < 0 → brake
    cash_parking_overlay_shock_brake_cooldown_days: int = 2       # days before TQQQ re-entry
    cash_parking_overlay_shock_brake_sma_buffer: float = 0.0      # 0=disabled; 0.005=exit when QQQ within 0.5% ABOVE SMA10 (pre-emptive)
    cash_parking_overlay_shock_brake_rv_ratio_upper: float = 0.0  # Signal 4 upper vol bound; 0=disabled; 0.45=only fire when vol5/vol20 in (0.25,0.45)
    # TQQQ overlay dwell cap: limit consecutive hold days and periodic revalidation
    cash_parking_overlay_max_hold_days: int = 0       # max overlay days (0=unlimited)
    cash_parking_overlay_revalidation_days: int = 0   # re-check every N days (0=disabled)
    # TQQQ overlay continuous leverage blend: QQQM+TQQQ dual-position
    cash_parking_overlay_blend_enabled: bool = False       # QQQM+TQQQ continuous blend
    cash_parking_overlay_blend_target_vol: float = 0.30    # target parking vol
    cash_parking_overlay_blend_max_leverage: float = 2.3   # max effective leverage
    cash_parking_rotation_fast_momentum_days: int = 10  # fast leadership lookback for relative-strength parking
    cash_parking_rotation_slow_momentum_days: int = 20  # slow leadership lookback for relative-strength parking
    cash_parking_rotation_lead_threshold: float = 0.03  # minimum QQQ score edge vs SPY to prefer QQQM/QQQ
    cash_parking_rotation_strong_threshold: float = 0.08  # strong QQQ score edge threshold
    cash_parking_turn_of_month_lead_days: int = 2  # include the last N trading days of the month
    cash_parking_turn_of_month_lag_days: int = 3   # include the first N trading days of the month
    cash_parking_turn_of_month_boost: float = 0.08  # extra QQQ score boost during turn-of-month
    # Bearish parking override: when gate would return SGOV and composite risk is very high,
    # use this inverse ETF instead (e.g. "sh") for active bear-market alpha.
    # None = disabled (default, existing behavior unchanged).
    cash_parking_bearish_symbol: str | None = None   # "sh", "sds", etc. — inverse ETF for deep stress
    cash_parking_bearish_threshold: int = 60          # composite risk score >= this → use bearish_symbol
    cash_parking_bearish_alloc_pct: float = 1.0      # if <1, allocate this fraction to bearish_symbol and keep the rest in SGOV
    cash_parking_crisis_symbol: str | None = None    # optional crisis safe-haven ETF for deep stress (e.g. "ief", "iei")
    cash_parking_crisis_threshold: int = 75          # composite risk score >= this enables crisis_symbol evaluation
    cash_parking_crisis_momentum_days: int = 20      # lookback for crisis symbol momentum confirmation
    cash_parking_crisis_momentum_min: float = 0.0    # require crisis symbol momentum above this to replace SGOV
    cash_parking_crisis_vol_max: float = 0.0         # optional max realized vol allowed for crisis symbol (0 = disabled)
    # Multi-tier regime parking: VIX-driven 3-symbol rotation (used with gate_mode="regime_tiered")
    cash_parking_regime_risk_on_symbol: str = "jepq"    # symbol when VIX < vix_low_threshold
    cash_parking_regime_neutral_symbol: str = "qqqm"    # symbol when VIX between thresholds
    cash_parking_regime_vix_low_threshold: float = 20.0  # VIX below this → risk-on symbol
    cash_parking_regime_vix_high_threshold: float = 25.0  # VIX above this → SGOV
    cash_parking_regime_hysteresis_buffer: float = 1.0   # extra VIX margin to prevent whipsaw on tier transitions

    # HY Credit spread size scaler (uses macro_hy_spread from candidate features)
    credit_spread_size_scaler_enabled: bool = False
    credit_spread_tight_threshold: float = 4.0    # spread below this = full size
    credit_spread_wide_threshold: float = 5.5     # spread above this = stress scaler
    credit_spread_wide_scaler: float = 0.75       # scaler for spread in [tight, wide) range
    credit_spread_stress_scaler: float = 0.50     # scaler for spread >= wide_threshold
    # Yield curve size scaler (uses macro_t10y2y from candidate features)
    yield_curve_size_scaler_enabled: bool = False
    yield_curve_normal_threshold: float = 0.5     # T10Y2Y above this = normal (full size)
    yield_curve_flat_scaler: float = 0.75         # scaler when T10Y2Y in [0, normal_threshold)
    yield_curve_inverted_scaler: float = 0.50     # scaler when T10Y2Y < 0 (inverted)

    def apply_parking_preset(self) -> None:
        """Apply named parking preset, overriding individual params."""
        if not self.cash_parking_preset:
            return
        preset = PARKING_PRESETS.get(self.cash_parking_preset)
        if preset is None:
            raise ValueError(f"Unknown parking preset: {self.cash_parking_preset}. Available: {list(PARKING_PRESETS.keys())}")
        for k, v in preset.items():
            setattr(self, k, v)


class ExecutionConfig(BaseModel):
    entry_fill_model: str = "next_open"
    exit_fill_model: str = "daily_bar_approximation"
    slippage_bps_base: float = 10.0
    commission_per_share: float = 0.005
    same_bar_priority: str = "stop_first_conservative"
    stop_model: str | None = None
    target_model: str = "fixed_r"             # "fixed_r" or "atr_multiple"
    target_1_r: float | None = None           # R-multiple for first target (fixed_r model)
    target_atr_multiplier: float = 1.5        # ATR multiplier for target (atr_multiple model)
    target_1_fraction: float | None = None    # fraction to exit at target_1 (partial exit)
    use_tiered_targets: bool = False
    a_tier_target_1_r: float | None = None
    a_tier_target_1_fraction: float | None = None
    non_a_tier_target_1_r: float | None = None
    non_a_tier_target_1_fraction: float | None = None
    trailing_model: str | None = None
    trailing_warmup_days: int = 0              # days after entry before trailing activates
    max_holding_days: int = 10
    lookback_entry_enabled: bool = False       # enter positions for pre-start events still within holding window
    no_follow_through_exit: bool = False       # exit at D+1 close if close < entry price
    early_failure_close_below_entry_and_reaction_close: bool = False
    early_failure_no_progress_days: int | None = None
    early_failure_no_progress_r: float | None = None
    early_failure_no_progress_fraction: float | None = None
    early_pop_giveback_days_min: int | None = None
    early_pop_giveback_days_max: int | None = None
    early_pop_giveback_trigger_r: float | None = None
    early_pop_giveback_min_r: float | None = None
    early_pop_giveback_from_peak_pct: float | None = None
    early_pop_giveback_fraction: float | None = None
    expected_decay_exit_enabled: bool = False
    expected_decay_lambda: float | None = None
    expected_decay_score_floor: float | None = None
    expected_decay_min_days_held: int = 1
    dynamic_hold_enabled: bool = False        # adaptive mhd: extend for winners, cut losers early
    dynamic_hold_checkpoints: list[tuple[int, float]] | None = None  # [(day, min_r), ...] cut if R below threshold
    dynamic_hold_extend_day: int = 8         # check day for extending mhd
    dynamic_hold_extend_r: float = 0.3       # min R to qualify for extension
    dynamic_hold_extend_to: int = 20         # extended mhd for qualifying positions
    adaptive_exit_enabled: bool = False
    adaptive_exit_exhaustion_close_min: float = 0.90
    adaptive_exit_exhaustion_max_hold: int = 7
    adaptive_exit_exhaustion_trailing_warmup: int = 1
    adaptive_exit_orderly_close_min: float = 0.70
    adaptive_exit_orderly_close_max: float = 0.88
    adaptive_exit_orderly_max_hold: int = 25
    adaptive_exit_orderly_trailing_warmup: int = 12


class StrategyEngineConfig(BaseModel):
    """Specialist engine routing and execution policy."""

    engine_id: str
    inherits_from_engine_id: str | None = None
    exclude_if_matches_engine_id: str | None = None
    selection_priority: int = 0
    ranking_fields_override: list[str] | None = None
    event_types: list[str] = Field(default_factory=list)
    entry_conventions: list[str] | None = None
    allowed_macro_regimes: list[str] | None = None
    event_directions: list[str] | None = None
    guidance_statuses: list[str] | None = None
    filing_time_buckets: list[str] | None = None
    allowed_exchanges: list[str] | None = None
    allowed_sectors: list[str] | None = None
    excluded_symbols: list[str] | None = None
    timing_class: str = "any"  # "same_day", "after_close", "any"
    direction: str = "any"  # "long_only", "short_only", "any"
    forced_trade_direction_override: str | None = None  # "long" or "short"
    trade_symbol_mode: str = "event"  # "event", "sector_etf", "peer_proxy"
    entry_timing_policy: str = "next_open"  # "next_open", "reaction_close"
    max_holding_days: int | None = None
    max_positions_per_sector_override: int | None = None
    max_position_value_pct_override: float | None = None
    max_adv_fraction_override: float | None = None
    engine_risk_budget_pct: float = 1.0
    capital_bucket_id: str | None = None
    capital_bucket_allocation_pct: float | None = None
    per_trade_risk_pct_override: float | None = None
    macro_vix_size_scaler_low: float | None = None
    macro_vix_size_scaler_high: float | None = None
    macro_vix_size_scaler_min: float | None = None
    macro_hy_spread_size_scaler_low: float | None = None
    macro_hy_spread_size_scaler_high: float | None = None
    macro_hy_spread_size_scaler_min: float | None = None
    score_size_scaler_low: float | None = None
    score_size_scaler_high: float | None = None
    score_size_scaler_min: float | None = None
    entropy_size_scaler_low: float | None = None
    entropy_size_scaler_high: float | None = None
    entropy_size_scaler_min: float | None = None
    stop_atr_multiplier_override: float | None = None
    target_atr_multiplier_override: float | None = None
    target_1_r_override: float | None = None
    target_1_fraction_override: float | None = None
    recycle_on_cash_block: bool = False
    recycle_min_days_held: int | None = None
    recycle_min_score_delta: float | None = None
    recycle_allowed_victim_engine_ids: list[str] | None = None
    recycle_allow_any_victim_engine: bool = False
    recycle_allow_cross_timing: bool = False
    recycle_positive_pnl_only: bool = True
    recycle_max_victim_fitness: float | None = None
    recycle_max_victim_unrealized_r: float | None = None
    rotation_enabled: bool = False
    rotation_min_days_held: int = 5
    rotation_fitness_threshold: float = 0.20
    rotation_min_candidate_score: float = 0.40
    rotation_max_unrealized_r: float | None = None
    trailing_model_override: str | None = None
    trailing_warmup_days_override: int | None = None
    use_reaction_day_low_stop_override: bool | None = None
    early_failure_close_below_entry_and_reaction_close_override: bool | None = None
    early_failure_no_progress_days_override: int | None = None
    early_failure_no_progress_r_override: float | None = None
    early_failure_no_progress_fraction_override: float | None = None
    dynamic_hold_checkpoints_override: list[list[float]] | None = None  # [[day, min_r], ...]
    dynamic_hold_extend_day_override: int | None = None
    dynamic_hold_extend_r_override: float | None = None
    dynamic_hold_extend_to_override: int | None = None
    veto_oneoff_penalty_override: float | None = None
    allow_oneoff_downsizing_override: bool | None = None
    oneoff_downsize_floor_override: float | None = None
    veto_parse_confidence_min_override: float | None = None
    score_threshold_override: float | None = None
    residual_reserve_selected: bool = False
    pead_reaction_threshold_override: float | None = None
    pead_volume_threshold_override: float | None = None
    reaction_day_return_min: float | None = None
    reaction_day_return_max: float | None = None
    close_location_min: float | None = None
    close_location_max: float | None = None
    volume_ratio_min: float | None = None
    volume_ratio_max: float | None = None
    min_entry_price_override: float | None = None
    max_entry_price_override: float | None = None
    avg_dollar_volume_min: float | None = None
    avg_dollar_volume_max: float | None = None
    gap_size_min: float | None = None
    gap_size_max: float | None = None
    proxy_reaction_day_return_min: float | None = None
    proxy_reaction_day_return_max: float | None = None
    proxy_close_location_min: float | None = None
    proxy_close_location_max: float | None = None
    proxy_volume_ratio_min: float | None = None
    proxy_volume_ratio_max: float | None = None
    proxy_gap_size_min: float | None = None
    proxy_gap_size_max: float | None = None
    proxy_avg_dollar_volume_min: float | None = None
    proxy_avg_dollar_volume_max: float | None = None
    reaction_day_range_pct_min: float | None = None
    reaction_day_range_pct_max: float | None = None
    upper_wick_pct_min: float | None = None
    upper_wick_pct_max: float | None = None
    min_market_cap_proxy: float | None = None
    max_market_cap_proxy: float | None = None
    document_quality_score_min: float | None = None
    document_quality_score_max: float | None = None
    signal_strength_score_min: float | None = None
    signal_strength_score_max: float | None = None
    oneoff_penalty_min: float | None = None
    oneoff_penalty_max: float | None = None
    parse_confidence_overall_min: float | None = None
    parse_confidence_overall_max: float | None = None
    prior_event_fwd5d_min: float | None = None
    prior_event_fwd5d_max: float | None = None
    lm_net_sentiment_min: float | None = None
    lm_net_sentiment_max: float | None = None
    earnings_surprise_pct_min: float | None = None
    earnings_surprise_pct_max: float | None = None
    peer_sector_event_count_365d_min: float | None = None
    peer_sector_event_count_365d_max: float | None = None
    sector_recent_event_count_3d_min: float | None = None
    sector_recent_event_count_3d_max: float | None = None
    sector_recent_leader_count_3d_min: float | None = None
    sector_recent_leader_count_3d_max: float | None = None
    sector_recent_leader_reaction_max_3d_min: float | None = None
    sector_recent_leader_reaction_max_3d_max: float | None = None
    peer_relative_surprise_pct_365d_min: float | None = None
    peer_relative_surprise_pct_365d_max: float | None = None
    peer_relative_sue_hist_mean_4q_365d_min: float | None = None
    peer_relative_sue_hist_mean_4q_365d_max: float | None = None
    prior_catalyst_count_20d_min: float | None = None
    prior_catalyst_count_20d_max: float | None = None
    prior_catalyst_count_60d_min: float | None = None
    prior_catalyst_count_60d_max: float | None = None
    prior_catalyst_type_diversity_20d_min: float | None = None
    prior_catalyst_type_diversity_20d_max: float | None = None
    prior_catalyst_type_diversity_60d_min: float | None = None
    prior_catalyst_type_diversity_60d_max: float | None = None
    sentiment_surprise_min: float | None = None
    sentiment_surprise_max: float | None = None
    price_text_dislocation_min: float | None = None
    price_text_dislocation_max: float | None = None
    positive_price_text_dislocation_min: float | None = None
    positive_price_text_dislocation_max: float | None = None
    positive_price_text_dislocation_rank_min: float | None = None
    positive_price_text_dislocation_rank_max: float | None = None
    macro_vix_min: float | None = None
    macro_vix_max: float | None = None
    volatility_crush_only: bool = False
    volatility_crush_vix_drop_pct_min: float | None = None
    volatility_crush_spy_return_min: float | None = None
    volatility_crush_score_threshold_override: float | None = None
    volatility_crush_per_trade_risk_pct_override: float | None = None
    volatility_crush_engine_risk_budget_pct_override: float | None = None
    volatility_crush_macro_vix_max_override: float | None = None
    macro_hy_spread_min: float | None = None
    macro_hy_spread_max: float | None = None
    pre_event_hurst_60d_min: float | None = None
    pre_event_hurst_60d_max: float | None = None
    pre_event_entropy_60d_min: float | None = None
    pre_event_entropy_60d_max: float | None = None
    pre_event_short_ratio_min: float | None = None
    pre_event_short_ratio_max: float | None = None
    pre_event_sector_momentum_20d_min: float | None = None
    pre_event_sector_momentum_20d_max: float | None = None
    pre_event_bb_position_min: float | None = None
    pre_event_bb_position_max: float | None = None
    pre_event_gravitational_pull_min: float | None = None
    pre_event_gravitational_pull_max: float | None = None
    pre_event_market_temperature_min: float | None = None
    pre_event_market_temperature_max: float | None = None
    weak_reaction_threshold: float | None = None
    weak_reaction_gap_max: float | None = None
    unknown_direction_reaction_min: float | None = None
    unknown_direction_close_location_min: float | None = None
    unknown_direction_close_location_max: float | None = None
    unknown_direction_gap_size_min: float | None = None
    unknown_inline_exit_close_location_min: float | None = None
    unknown_inline_exit_gap_size_max: float | None = None
    unknown_inline_early_failure_close_below_entry_and_reaction_close_override: bool | None = None
    unknown_inline_early_failure_no_progress_days_override: int | None = None
    unknown_inline_early_failure_no_progress_r_override: float | None = None
    unknown_inline_early_failure_no_progress_fraction_override: float | None = None
    mixed_inline_close_location_min: float | None = None
    mixed_inline_close_location_max: float | None = None
    mixed_inline_gap_size_max: float | None = None
    mixed_inline_early_failure_close_below_entry_and_reaction_close_override: bool | None = None
    mixed_inline_early_failure_no_progress_days_override: int | None = None
    mixed_inline_early_failure_no_progress_r_override: float | None = None
    mixed_inline_early_failure_no_progress_fraction_override: float | None = None
    next_open_gap_cap_pct: float | None = None
    add_on_min_parent_days_held: int | None = None
    add_on_max_parent_days_held: int | None = None
    add_on_schedule_days: list[int] | None = None
    add_on_close_location_min: float | None = None
    add_on_progress_r_min: float | None = None
    add_on_progress_r_levels: list[float] | None = None
    add_on_parent_score_min: float | None = None
    add_on_parent_engine_ids: list[str] | None = None
    add_on_max_count: int = 1
    add_on_size_fraction: float = 0.5
    add_on_require_above_reaction_high: bool = False
    delayed_entry_lookback_days: int | None = None  # e.g. 3 = look at events from 3 trading days ago
    delayed_entry_source_engine_ids: list[str] | None = None  # which engines' candidates to consider
    delayed_entry_min_drift_pct: float | None = None  # min price change since reaction close
    delayed_entry_close_location_min: float | None = None  # today's close location requirement
    leader_follower_lookahead_days: int | None = None  # trading-day window to upcoming follower earnings
    leader_follower_min_days_to_event: int | None = None  # minimum trading days until follower event
    leader_follower_hold_buffer_days: int = 1  # exit before follower event by this many trading days
    leader_follower_calendar_mode: str = "future_row"  # "future_row", "pit_calendar", "pit_then_fallback"
    leader_follower_extra_peer_symbols_by_leader: dict[str, list[str]] | None = None
    leader_follower_extra_peer_symbols_by_sector: dict[str, list[str]] | None = None
    leader_follower_allowed_peer_symbols: list[str] | None = None
    attention_min_wiki_spike_10d: float | None = None
    attention_min_wiki_zscore_20d: float | None = None
    attention_max_wiki_spike_10d: float | None = None
    attention_max_wiki_zscore_20d: float | None = None
    attention_min_article_count_3d: int | None = None
    attention_min_us_article_count_3d: int | None = None
    attention_min_resolver_confidence: float | None = None
    shadow_only: bool = False
    synthetic_only: bool = False
    post_allocation_idle_only: bool = False
    enabled: bool = True
    # Macro short engine: generate SH candidate when composite risk score >= threshold
    macro_short_risk_threshold: int | None = None
    # Macro long engine: generate ETF long candidate when leadership/breadth trigger fires
    macro_long_symbol: str | None = None
    macro_long_reaction_day_return_min: float | None = None
    macro_long_reaction_day_return_max: float | None = None
    macro_long_volume_ratio_min: float | None = None
    macro_long_volume_ratio_max: float | None = None
    macro_long_gap_size_min: float | None = None
    macro_long_gap_size_max: float | None = None
    macro_long_close_location_min: float | None = None
    macro_long_close_location_max: float | None = None
    macro_long_trade_symbol_mode: str | None = None
    macro_long_breadth_symbols: list[str] | None = None
    macro_long_min_breadth_count: int | None = None
    macro_long_breadth_reaction_day_return_min: float | None = None
    macro_long_breadth_reaction_day_return_max: float | None = None
    macro_long_breadth_volume_ratio_min: float | None = None
    macro_long_breadth_volume_ratio_max: float | None = None
    macro_long_breadth_gap_size_min: float | None = None
    macro_long_breadth_gap_size_max: float | None = None
    macro_long_breadth_close_location_min: float | None = None
    macro_long_breadth_close_location_max: float | None = None
    macro_long_leadership_vs_spy_min: float | None = None
    macro_long_min_daily_candidate_count: int | None = None
    macro_long_min_unique_sector_count: int | None = None


class EventTypeProfile(BaseModel):
    """Per-event-type overrides for scoring, risk, and exit parameters."""
    enabled: bool = True
    score_threshold_override: float | None = None
    max_holding_days_override: int | None = None
    stop_atr_multiplier_override: float | None = None
    target_atr_multiplier_override: float | None = None
    direction_filter: str = "any"  # "bullish_only", "bearish_only", "any"


class ReportingConfig(BaseModel):
    write_trade_blotter: bool = True
    write_equity_curve: bool = True
    write_metrics_summary: bool = True
    generate_plots: bool = False
    attribution_buckets: list[str] = Field(default_factory=list)


class DividendCaptureConfig(BaseModel):
    enabled: bool = False
    pit_calendar_path: str | None = None
    reserve_pct: float = 0.0
    min_dividend_yield_pct: float = 0.0025
    max_dividend_yield_pct: float | None = 0.02
    min_avg_dollar_volume: float = 20_000_000.0
    max_positions: int = 5


class Form4CaptureConfig(BaseModel):
    enabled: bool = False
    pit_events_path: str | None = None
    reserve_pct: float = 0.0
    min_owner_count: int = 2
    min_transaction_count: int = 1
    min_c_suite_count: int = 0
    min_cfo_count: int = 0
    min_role_weight_score: float = 0.0
    min_total_value: float = 5_000_000.0
    min_purchase_pct: float = 0.0
    min_event_day_count: int = 1
    max_lag_days: int | None = None
    max_min_lag_days: int | None = None
    max_transaction_span_days: int | None = None
    require_officer_or_director: bool = False
    disable_day1_early_failure: bool = False
    no_progress_days_override: int | None = None
    no_progress_r_override: float | None = None
    no_progress_fraction_override: float | None = None
    hold_days: int = 20
    max_positions: int = 6
    max_new_per_day: int = 2


class OwnershipCaptureConfig(BaseModel):
    enabled: bool = False
    pit_events_path: str | None = None
    reserve_pct: float = 0.0
    form_groups: list[str] = Field(default_factory=lambda: ["13D"])
    min_percent_owned: float = 5.0
    min_percent_delta_points: float = 0.0
    exclude_housekeeping_purpose: bool = False
    exclude_structural_exchange_purpose: bool = False
    min_strength_score: int | None = None
    require_amendment: bool = False
    require_initial: bool = False
    require_activist: bool = False
    require_13g_to_13d_transition: bool = False
    symbol_cooldown_days_after_loss: int = 0
    symbol_max_entries_in_lookback: int | None = None
    symbol_entry_lookback_days: int = 365
    extra_idle_deploy_pct_above_reserve: float = 0.0
    disable_day1_early_failure: bool = False
    no_progress_days_override: int | None = None
    no_progress_r_override: float | None = None
    no_progress_fraction_override: float | None = None
    hold_days: int = 30
    min_avg_dollar_volume: float = 10_000_000.0
    max_positions: int = 6
    max_new_per_day: int = 2
    min_cash_ratio_for_overlay: float = 0.25
    max_idle_deploy_pct: float = 1.0


class RiskOffAlphaConfig(BaseModel):
    enabled: bool = False
    reserve_pct: float = 0.0
    symbols: list[str] = Field(default_factory=lambda: ["gld", "dbc"])
    momentum_lookback_days: int = 20
    min_symbol_momentum: float = 0.05
    min_consecutive_sgov_days: int = 3
    min_parking_risk_score: float = 0.0
    rotation_momentum_gap: float = 0.02
    max_holding_days: int = 0
    min_cash_ratio_for_overlay: float = 0.0
    max_idle_deploy_pct: float = 0.0
    adaptive_reserve_enabled: bool = False
    adaptive_reserve_score_mid: float = 0.0
    adaptive_reserve_score_high: float = 0.0
    adaptive_reserve_pct_low: float = 0.0
    adaptive_reserve_pct_mid: float = 0.0
    adaptive_reserve_pct_high: float = 0.0


class IdleAlphaConfig(BaseModel):
    dynamic_allocator_enabled: bool = False
    dynamic_allocator_cash_ratio_low: float = 0.04
    dynamic_allocator_cash_ratio_high: float = 0.16
    dynamic_allocator_cash_scale_low: float = 0.7
    dynamic_allocator_cash_scale_high: float = 1.1
    dynamic_allocator_crowded_primary_candidate_count: int | None = None
    dynamic_allocator_crowded_primary_unique_sector_count: int | None = None
    dynamic_allocator_crowded_scale: float = 0.85
    dynamic_allocator_synthetic_scale_multiplier: float = 1.0
    dynamic_allocator_snapshot_scale_multiplier: float = 1.0
    dynamic_allocator_synthetic_reentry_cooldown_days: int = 0
    dynamic_allocator_min_scale: float = 0.55
    dynamic_allocator_max_scale: float = 1.2


class NonCoreAllocatorWeightsConfig(BaseModel):
    native_strength: float = 1.00
    hold_penalty: float = 0.20
    liquidity_penalty: float = 0.25
    overlap_penalty: float = 0.20
    parking_opportunity_penalty: float = 0.35


class NonCoreAllocatorConfig(BaseModel):
    enabled: bool = False
    mode: str = "shadow"  # "shadow" or "live"
    scope: str = "non_core"
    benchmark_mode: str = "current_effective_parking"
    weights: NonCoreAllocatorWeightsConfig = Field(default_factory=NonCoreAllocatorWeightsConfig)


class BacktestConfig(BaseModel):
    strategy_name: str
    dataset_snapshot_id: str
    requested_snapshot_id: str | None = None
    canonical_snapshot_id: str | None = None
    earnings_calendar_pit_path: str | None = None
    dividend_capture_sleeve_preset: str | None = None
    form4_capture_sleeve_preset: str | None = None
    ownership_capture_sleeve_preset: str | None = None
    risk_off_alpha_sleeve_preset: str | None = None
    universe: UniverseConfig = Field(default_factory=UniverseConfig)
    signal: SignalConfig = Field(default_factory=SignalConfig)
    risk: RiskConfig = Field(default_factory=RiskConfig)
    execution: ExecutionConfig = Field(default_factory=ExecutionConfig)
    reporting: ReportingConfig = Field(default_factory=ReportingConfig)
    dividend_capture: DividendCaptureConfig = Field(default_factory=DividendCaptureConfig)
    form4_capture: Form4CaptureConfig = Field(default_factory=Form4CaptureConfig)
    ownership_capture: OwnershipCaptureConfig = Field(default_factory=OwnershipCaptureConfig)
    risk_off_alpha: RiskOffAlphaConfig = Field(default_factory=RiskOffAlphaConfig)
    idle_alpha: IdleAlphaConfig = Field(default_factory=IdleAlphaConfig)
    non_core_allocator_v2: NonCoreAllocatorConfig = Field(default_factory=NonCoreAllocatorConfig)
    event_type_profiles: dict[str, EventTypeProfile] = Field(default_factory=dict)
    idle_alpha_sleeve_preset: str | None = None
    idle_alpha_dedup_mode: str = "skip"  # "skip" (default, backward-compat) or "rename" (suffix __ia_sleeve on conflict)
    strategy_engines: list[StrategyEngineConfig] = Field(default_factory=list)
    strategy_engine_selection_mode: str = "interleave"  # "interleave", "interleave_head_score", "global_score", "interleave_cap_efficiency_soft", "interleave_cap_efficiency_strict", or "interleave_cash_tiebreak"

    def model_post_init(self, __context: Any) -> None:
        self.risk.apply_parking_preset()
        self.apply_dividend_capture_sleeve_preset()
        self.apply_form4_capture_sleeve_preset()
        self.apply_ownership_capture_sleeve_preset()
        self.apply_risk_off_alpha_sleeve_preset()
        self.apply_idle_alpha_sleeve_preset()

    def apply_dividend_capture_sleeve_preset(self) -> None:
        if not self.dividend_capture_sleeve_preset:
            return
        preset = DIVIDEND_CAPTURE_SLEEVE_PRESETS.get(self.dividend_capture_sleeve_preset)
        if preset is None:
            raise ValueError(
                "Unknown dividend capture sleeve preset: "
                f"{self.dividend_capture_sleeve_preset}. Available: {list(DIVIDEND_CAPTURE_SLEEVE_PRESETS.keys())}"
            )
        current = self.dividend_capture.model_dump()
        current.update(preset)
        self.dividend_capture = DividendCaptureConfig.model_validate(current)

    def apply_form4_capture_sleeve_preset(self) -> None:
        if not self.form4_capture_sleeve_preset:
            return
        preset = FORM4_CAPTURE_SLEEVE_PRESETS.get(self.form4_capture_sleeve_preset)
        if preset is None:
            raise ValueError(
                "Unknown Form 4 capture sleeve preset: "
                f"{self.form4_capture_sleeve_preset}. Available: {list(FORM4_CAPTURE_SLEEVE_PRESETS.keys())}"
            )
        current = self.form4_capture.model_dump()
        current.update(preset)
        self.form4_capture = Form4CaptureConfig.model_validate(current)

    def apply_ownership_capture_sleeve_preset(self) -> None:
        if not self.ownership_capture_sleeve_preset:
            return
        preset = OWNERSHIP_CAPTURE_SLEEVE_PRESETS.get(self.ownership_capture_sleeve_preset)
        if preset is None:
            raise ValueError(
                "Unknown ownership capture sleeve preset: "
                f"{self.ownership_capture_sleeve_preset}. Available: {list(OWNERSHIP_CAPTURE_SLEEVE_PRESETS.keys())}"
            )
        current = self.ownership_capture.model_dump()
        current.update(preset)
        self.ownership_capture = OwnershipCaptureConfig.model_validate(current)

    def apply_risk_off_alpha_sleeve_preset(self) -> None:
        if not self.risk_off_alpha_sleeve_preset:
            return
        preset = RISK_OFF_ALPHA_SLEEVE_PRESETS.get(self.risk_off_alpha_sleeve_preset)
        if preset is None:
            raise ValueError(
                "Unknown risk-off alpha sleeve preset: "
                f"{self.risk_off_alpha_sleeve_preset}. Available: {list(RISK_OFF_ALPHA_SLEEVE_PRESETS.keys())}"
            )
        current = self.risk_off_alpha.model_dump()
        current.update(preset)
        self.risk_off_alpha = RiskOffAlphaConfig.model_validate(current)

    def apply_idle_alpha_sleeve_preset(self) -> None:
        """Append named idle-alpha sleeve engines without touching risk config."""
        if not self.idle_alpha_sleeve_preset:
            return
        preset = IDLE_ALPHA_SLEEVE_PRESETS.get(self.idle_alpha_sleeve_preset)
        if preset is None:
            raise ValueError(
                "Unknown idle alpha sleeve preset: "
                f"{self.idle_alpha_sleeve_preset}. Available: {list(IDLE_ALPHA_SLEEVE_PRESETS.keys())}"
            )
        existing_engine_ids = {engine.engine_id for engine in self.strategy_engines}
        appended_engines: list[StrategyEngineConfig] = []
        for engine_payload in preset["strategy_engines"]:
            engine = StrategyEngineConfig.model_validate(engine_payload)
            if engine.engine_id in existing_engine_ids:
                if self.idle_alpha_dedup_mode == "rename":
                    engine = engine.model_copy(update={"engine_id": f"{engine.engine_id}__ia_sleeve"})
                else:
                    continue
            appended_engines.append(engine)
            existing_engine_ids.add(engine.engine_id)
        if appended_engines:
            self.strategy_engines.extend(appended_engines)
        idle_alpha_payload = preset.get("idle_alpha")
        if idle_alpha_payload:
            current_idle_alpha = self.idle_alpha.model_dump()
            current_idle_alpha.update(idle_alpha_payload)
            self.idle_alpha = IdleAlphaConfig.model_validate(current_idle_alpha)

    def get_event_profile(self, event_type: str) -> EventTypeProfile | None:
        """Look up event-type-specific profile. Returns None if no override."""
        return self.event_type_profiles.get(event_type)

    def _build_strategy_engine_lookup(self) -> dict[str, StrategyEngineConfig]:
        return {engine.engine_id: engine for engine in self.strategy_engines}

    def resolve_strategy_engine(
        self,
        engine: StrategyEngineConfig,
        *,
        _seen: set[str] | None = None,
    ) -> StrategyEngineConfig:
        parent_id = engine.inherits_from_engine_id
        if not parent_id:
            return engine

        parent = self._build_strategy_engine_lookup().get(parent_id)
        if parent is None:
            return engine

        seen = set(_seen or set())
        if engine.engine_id in seen or parent_id in seen:
            raise ValueError(f"Cyclic strategy engine inheritance detected for {engine.engine_id}")
        seen.add(engine.engine_id)

        resolved_parent = self.resolve_strategy_engine(parent, _seen=seen)
        merged = resolved_parent.model_dump()
        for field_name in engine.model_fields_set:
            merged[field_name] = getattr(engine, field_name)
        return StrategyEngineConfig.model_validate(merged)

    def get_strategy_engines(self) -> list[StrategyEngineConfig]:
        """Enabled strategy engines ordered by priority, then manifest order."""
        resolved_engines: list[tuple[int, StrategyEngineConfig]] = []
        for index, engine in enumerate(self.strategy_engines):
            resolved = self.resolve_strategy_engine(engine)
            if resolved.enabled:
                resolved_engines.append((index, resolved))
        resolved_engines.sort(
            key=lambda item: (-int(item[1].selection_priority), item[0]),
        )
        return [engine for _, engine in resolved_engines]

    def get_active_strategy_engines(self) -> list[StrategyEngineConfig]:
        """Enabled engines that participate in the live portfolio."""
        return [engine for engine in self.get_strategy_engines() if not engine.shadow_only]

    def get_shadow_strategy_engines(self) -> list[StrategyEngineConfig]:
        """Enabled engines that run in paper/shadow mode only."""
        return [engine for engine in self.get_strategy_engines() if engine.shadow_only]


# ---------------------------------------------------------------------------
# Experiment models
# ---------------------------------------------------------------------------

class SplitSpec(BaseModel):
    kind: str
    params: dict[str, Any] = Field(default_factory=dict)


class ExperimentManifest(BaseModel):
    experiment_name: str
    dataset_snapshot_id: str
    description: str | None = None
    base_config: str  # path to base config JSON file
    overrides: dict[str, Any] = Field(default_factory=dict)
    strategy_engines: list[StrategyEngineConfig] = Field(default_factory=list)
    splits: list[SplitSpec] = Field(default_factory=list)
    tags: list[str] = Field(default_factory=list)
    notes: str | None = None
    # --- metadata ---
    id: int | None = None  # unique sequential experiment ID
    aliases: list[str] = Field(default_factory=list)
    parent: str | None = None
    created_at: str | None = None
    created_by: str | None = None
    status: str = "active"  # draft | active | promoted | retired
    generation: int | None = None
    version_family: str | None = None
    changelog: str | None = None
    performance_summary: dict[str, Any] | None = None


class ExperimentResult(BaseModel):
    model_config = ConfigDict(frozen=True)

    run_id: str
    manifest: ExperimentManifest
    resolved_config: BacktestConfig
    metrics: MetricsBundle
    artifact_paths: dict[str, str] = Field(default_factory=dict)
    started_at: dt.datetime
    finished_at: dt.datetime
    total_trading_days: int
    total_candidates_seen: int
    total_orders_rejected: int


# ---------------------------------------------------------------------------
# Improvement Tracking models
# ---------------------------------------------------------------------------


class SQSWeights(BaseModel):
    """Weights for Strategy Quality Score computation."""

    profitability: float = 0.40
    risk: float = 0.25
    consistency: float = 0.20
    robustness: float = 0.15
    low_trade_penalty_threshold: int = 20
    low_trade_penalty_factor: float = 0.5


class SQSv2Weights(BaseModel):
    """Weights for Strategy Quality Score v2 with capital efficiency."""

    profitability: float = 0.35
    risk: float = 0.25
    consistency: float = 0.20
    robustness: float = 0.10
    capital_efficiency: float = 0.10
    low_trade_penalty_threshold: int = 20
    low_trade_penalty_factor: float = 0.5


class PromotionScoreWeights(BaseModel):
    """Weights for promotion scoring across valid/test splits."""

    valid_quality: float = 0.55
    test_quality: float = 0.15
    floor_quality: float = 0.30


class UnifiedScoreWeights(BaseModel):
    """Weights for a stricter single ranking score across valid/test splits."""

    split_profitability: float = 0.25
    split_risk: float = 0.20
    split_consistency: float = 0.15
    split_robustness: float = 0.20
    split_capital_efficiency: float = 0.20
    valid_quality: float = 0.45
    test_quality: float = 0.20
    floor_quality: float = 0.20
    gap_quality: float = 0.15


class ReturnScoreWeights(BaseModel):
    """Weights for return-max ranking across train/valid/test splits."""

    split_total_return: float = 0.28
    split_annualized_return: float = 0.12
    split_profitability: float = 0.12
    split_sharpe: float = 0.08
    split_drawdown: float = 0.12
    split_return_on_gross: float = 0.18
    split_gross_exposure: float = 0.05
    split_days_in_market: float = 0.05
    train_quality: float = 0.35
    valid_quality: float = 0.30
    test_quality: float = 0.35
    floor_quality: float = 0.15
    gap_quality: float = 0.10
    missing_train_penalty: float = 0.85
    low_trade_penalty_threshold: int = 10
    low_trade_penalty_factor: float = 0.85


class WalkForwardScoreWeights(BaseModel):
    """Weights for walk-forward robustness scoring."""

    median_return: float = 0.25
    mean_return: float = 0.15
    worst_return: float = 0.15
    positive_fold_rate: float = 0.15
    profit_factor: float = 0.10
    drawdown: float = 0.10
    train_test_gap: float = 0.05
    fold_count: float = 0.05
    low_fold_penalty_threshold: int = 6
    low_fold_penalty_factor: float = 0.85


class WFQSv2Weights(BaseModel):
    """Weights for walk-forward quality score v2 with multiplicative penalties."""

    median_return: float = 0.25
    mean_return: float = 0.15
    worst_return: float = 0.20
    positive_fold_rate: float = 0.15
    profit_factor: float = 0.10
    drawdown: float = 0.10
    fold_count: float = 0.05
    low_fold_penalty_threshold: int = 6
    low_fold_penalty_factor: float = 0.85
    recent_fold_quality: float = 0.20
    recent_lookback_days: int = 365
    recent_min_folds: int = 2


class DeploymentScoreWeights(BaseModel):
    """Weights for deployment-oriented scoring."""

    rqs_quality: float = 0.45
    wfqs_quality: float = 0.55


class SplitResult(BaseModel):
    """Metrics for a single backtest split (train/valid/test)."""

    run_id: str
    trade_count: int = 0
    profit_factor: float | None = None
    total_return_pct: float | None = None
    annualized_return_pct: float | None = None
    win_rate: float | None = None
    max_drawdown_pct: float | None = None
    sharpe_ratio: float | None = None
    monthly_win_rate: float | None = None
    equity_curve_r_squared: float | None = None
    avg_gross_exposure_pct: float | None = None
    avg_net_exposure_pct: float | None = None
    days_in_market_pct: float | None = None


class WalkForwardFoldResult(BaseModel):
    """Metrics and run metadata for one walk-forward fold."""

    fold_index: int
    train_start: dt.date
    train_end: dt.date
    test_start: dt.date
    test_end: dt.date
    train_run_id: str
    test_run_id: str
    train_metrics: SplitResult
    test_metrics: SplitResult


class WalkForwardAggregate(BaseModel):
    """Aggregate statistics over walk-forward folds."""

    mean_return_pct: float | None = None
    median_return_pct: float | None = None
    worst_return_pct: float | None = None
    positive_fold_rate_pct: float | None = None
    mean_profit_factor: float | None = None
    mean_max_drawdown_pct: float | None = None
    mean_trade_count: float | None = None
    mean_win_rate: float | None = None


class WalkForwardGapStats(BaseModel):
    """Train vs test drift statistics over walk-forward folds."""

    mean_train_test_return_gap_pct: float | None = None
    worst_train_test_return_gap_pct: float | None = None
    fold_return_cv: float | None = None


class WalkForwardSummary(BaseModel):
    """Full walk-forward validation summary."""

    window_mode: str = "rolling_fixed"
    train_days: int
    test_days: int
    step_days: int
    fold_count: int
    folds: list[WalkForwardFoldResult] = Field(default_factory=list)
    train_aggregate: WalkForwardAggregate = Field(default_factory=WalkForwardAggregate)
    test_aggregate: WalkForwardAggregate = Field(default_factory=WalkForwardAggregate)
    gap_stats: WalkForwardGapStats = Field(default_factory=WalkForwardGapStats)
    engine_reliability_ratio: float | None = None


class RobustnessHorizonSummary(BaseModel):
    """Aggregate statistics for one rolling horizon in the robustness matrix."""

    horizon_days: int
    window_count: int
    mean_return_pct: float | None = None
    median_return_pct: float | None = None
    worst_return_pct: float | None = None
    positive_window_rate_pct: float | None = None
    mean_max_drawdown_pct: float | None = None


class RobustnessMatrixSummary(BaseModel):
    """Compact summary of horizon/start-date robustness validation."""

    window_mode: str = "rolling_horizon"
    horizons_days: list[int] = Field(default_factory=list)
    step_days: int
    overall_window_count: int = 0
    overall_positive_window_rate_pct: float | None = None
    overall_worst_return_pct: float | None = None
    horizon_summaries: list[RobustnessHorizonSummary] = Field(default_factory=list)


class CommonWindowSummary(BaseModel):
    """Continuous full-cycle run summary used for capital-growth comparisons."""

    window_name: str = "common_window"
    snapshot_id: str = ""
    start_date: dt.date
    end_date: dt.date
    initial_equity: float = 10_000.0
    run_id: str | None = None
    metrics: MetricsBundle


class MultiCapitalCommonWindowSummary(BaseModel):
    """Comparable common-window summaries across several initial capital levels."""

    capital_summaries: list[CommonWindowSummary] = Field(default_factory=list)


class ResetCommonWindowSummary(BaseModel):
    """Path-neutral common-window summary built from reset-capital segments."""

    window_name: str = "reset_common_window"
    snapshot_id: str = ""
    start_date: dt.date
    end_date: dt.date
    reset_initial_equity: float = 10_000.0
    segment_days: int | None = None
    segment_summaries: list[CommonWindowSummary] = Field(default_factory=list)


class ConfigDelta(BaseModel):
    """Records what changed from a baseline experiment."""

    base_experiment: str
    changes: dict[str, str] = Field(default_factory=dict)


class JournalEntry(BaseModel):
    """One improvement cycle entry in the journal."""

    entry_id: str
    timestamp: str
    experiment_name: str
    hypothesis: str
    config_delta: ConfigDelta | None = None
    results: dict[str, SplitResult] = Field(default_factory=dict)  # split_name → SplitResult
    walk_forward_summary: WalkForwardSummary | None = None
    robustness_matrix_summary: RobustnessMatrixSummary | None = None
    out_of_time_robustness_summary: RobustnessMatrixSummary | None = None
    common_window_summary: CommonWindowSummary | None = None
    reset_common_window_summary: ResetCommonWindowSummary | None = None
    multi_capital_common_window_summary: MultiCapitalCommonWindowSummary | None = None
    sqs_score: float | None = None
    sqs_breakdown: dict[str, float] = Field(default_factory=dict)
    sqs_v3_score: float | None = None
    sqs_v3_breakdown: dict[str, float] = Field(default_factory=dict)
    stress_sqs_score: float | None = None
    stress_sqs_breakdown: dict[str, float] = Field(default_factory=dict)
    sqs_v2_score: float | None = None
    sqs_v2_breakdown: dict[str, float] = Field(default_factory=dict)
    promotion_score: float | None = None
    promotion_breakdown: dict[str, float] = Field(default_factory=dict)
    unified_score: float | None = None
    unified_breakdown: dict[str, float] = Field(default_factory=dict)
    rqs_score: float | None = None
    rqs_breakdown: dict[str, float] = Field(default_factory=dict)
    wfqs_score: float | None = None
    wfqs_breakdown: dict[str, float] = Field(default_factory=dict)
    wfqs_v2_score: float | None = None
    wfqs_v2_breakdown: dict[str, float] = Field(default_factory=dict)
    deployment_score: float | None = None
    deployment_breakdown: dict[str, float] = Field(default_factory=dict)
    common_window_score: float | None = None
    common_window_breakdown: dict[str, float] = Field(default_factory=dict)
    reset_common_window_score: float | None = None
    reset_common_window_breakdown: dict[str, float] = Field(default_factory=dict)
    multi_capital_common_window_score: float | None = None
    multi_capital_common_window_breakdown: dict[str, float] = Field(default_factory=dict)
    scenario_robustness_score: float | None = None
    scenario_robustness_breakdown: dict[str, float] = Field(default_factory=dict)
    overfit_check_score: float | None = None
    overfit_check_breakdown: dict[str, float] = Field(default_factory=dict)
    verdict: str = "unknown"  # better / worse / neutral / unknown
    verdict_reasoning: str = ""
    next_direction: str = ""
    tags: list[str] = Field(default_factory=list)


class RegistryEntry(BaseModel):
    """A leaderboard row derived from a JournalEntry."""

    entry_id: str
    experiment_name: str
    strategy_family: str = "other"
    is_retired: bool = False
    sqs_score: float | None = None
    sqs_v3_score: float | None = None
    stress_sqs_score: float | None = None
    sqs_v2_score: float | None = None
    promotion_score: float | None = None
    unified_score: float | None = None
    rqs_score: float | None = None
    wfqs_score: float | None = None
    wfqs_v2_score: float | None = None
    deployment_score: float | None = None
    common_window_score: float | None = None
    common_window_summary: CommonWindowSummary | None = None
    reset_common_window_score: float | None = None
    reset_common_window_summary: ResetCommonWindowSummary | None = None
    multi_capital_common_window_score: float | None = None
    multi_capital_common_window_summary: MultiCapitalCommonWindowSummary | None = None
    walk_forward_summary: WalkForwardSummary | None = None
    robustness_matrix_summary: RobustnessMatrixSummary | None = None
    out_of_time_robustness_summary: RobustnessMatrixSummary | None = None
    scenario_robustness_score: float | None = None
    overfit_check_score: float | None = None
    # train split metrics
    train_total_return_pct: float | None = None
    train_annualized_return_pct: float | None = None
    # test split metrics
    profit_factor: float | None = None
    total_return_pct: float | None = None
    annualized_return_pct: float | None = None
    win_rate: float | None = None
    sharpe_ratio: float | None = None
    max_drawdown_pct: float | None = None
    trade_count: int = 0
    avg_gross_exposure_pct: float | None = None
    avg_net_exposure_pct: float | None = None
    days_in_market_pct: float | None = None
    # valid split metrics
    valid_profit_factor: float | None = None
    valid_total_return_pct: float | None = None
    valid_annualized_return_pct: float | None = None
    valid_win_rate: float | None = None
    valid_sharpe_ratio: float | None = None
    valid_max_drawdown_pct: float | None = None
    valid_trade_count: int = 0
    valid_avg_gross_exposure_pct: float | None = None
    valid_avg_net_exposure_pct: float | None = None
    valid_days_in_market_pct: float | None = None
    timestamp: str = ""


class ExperimentRegistry(BaseModel):
    """Full leaderboard data (regenerated from journal)."""

    entries: list[RegistryEntry] = Field(default_factory=list)
    updated_at: str = ""
