"""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"
    TRAILING = "TRAILING"
    KILL_SWITCH = "KILL_SWITCH"
    MISSING_BAR = "MISSING_BAR"
    NO_FOLLOW_THROUGH = "NO_FOLLOW_THROUGH"
    EARLY_FAILURE = "EARLY_FAILURE"
    NO_PROGRESS = "NO_PROGRESS"
    GIVEBACK = "GIVEBACK"
    RECYCLE = "RECYCLE"


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
    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_risk_budget_pct: float = 1.0
    engine_per_trade_risk_pct: 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_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_direction: str = "long"  # "long" or "short"
    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
    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
    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


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

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


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


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


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
    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
    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
    event_types: list[str] = Field(default_factory=list)
    entry_conventions: 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
    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"
    entry_timing_policy: str = "next_open"  # "next_open", "reaction_close"
    max_holding_days: int | None = None
    max_positions_per_sector_override: int | None = None
    engine_risk_budget_pct: float = 1.0
    per_trade_risk_pct_override: 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_positive_pnl_only: bool = True
    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
    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
    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
    parse_confidence_overall_min: float | None = None
    parse_confidence_overall_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
    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
    enabled: bool = True


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 BacktestConfig(BaseModel):
    strategy_name: str
    dataset_snapshot_id: str
    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)
    event_type_profiles: dict[str, EventTypeProfile] = Field(default_factory=dict)
    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 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 get_strategy_engines(self) -> list[StrategyEngineConfig]:
        """Enabled strategy engines in manifest order."""
        return [engine for engine in self.strategy_engines if engine.enabled]

    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


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 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
    sqs_score: float | None = None
    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)
    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_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
    walk_forward_summary: WalkForwardSummary | None = None
    robustness_matrix_summary: RobustnessMatrixSummary | None = None
    out_of_time_robustness_summary: RobustnessMatrixSummary | 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 = ""
