"""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" ROTATION = "ROTATION" 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_max_position_value_pct: float | None = None engine_max_adv_fraction: float | 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_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_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 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 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 pre_event_momentum_20d_max: float | None = None # reject if 20d pre-event return > this 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) 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 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_symbol: str = "spy" # "spy", "qqq", "dynamic", "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 > SMA20 (skip downtrends) cash_parking_sgov_annual_rate: float = 0.05 # SGOV annualized yield (for "sgov" mode) 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 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 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 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 max_position_value_pct_override: float | None = None max_adv_fraction_override: float | 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_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 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 macro_vix_min: float | None = None macro_vix_max: float | None = None macro_hy_spread_min: float | None = None macro_hy_spread_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 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 = 100_000.0 run_id: str | None = None metrics: MetricsBundle class OverlayWindowSummary(BaseModel): """Compact overlay run summary for full-window / stress-window evaluation.""" window_name: str = "overlay_window" overlay_name: str = "" start_date: dt.date end_date: dt.date initial_equity: float = 10_000.0 final_equity: float | None = None return_pct: float | None = None annualized_return_pct: float | None = None max_drawdown_pct: float | None = None sharpe_ratio: float | None = None day_count: int = 0 books: list[str] = Field(default_factory=list) allocations: dict[str, dict[str, float]] = Field(default_factory=dict) regime_day_counts: dict[str, int] = Field(default_factory=dict) 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 overlay_common_window_summary: OverlayWindowSummary | None = None overlay_stress_window_summary: OverlayWindowSummary | 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) 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 overlay_common_window_summary: OverlayWindowSummary | None = None overlay_stress_window_summary: OverlayWindowSummary | 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 = ""