"""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" 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 filing_time_bucket: str 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 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 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 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 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 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 # 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 score_bucket_hit_rate: dict[str, float] = Field(default_factory=dict) # 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 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) class RiskConfig(BaseModel): per_trade_risk_pct: float = 0.01 # 1% of equity per trade max_daily_new_risk_pct: float = 0.03 # 3% of equity per day max_positions: int = 10 max_positions_per_sector: int = 3 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_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) 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" 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) trailing_model: str | None = None trailing_warmup_days: int = 0 # days after entry before trailing activates max_holding_days: int = 10 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) 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) # --------------------------------------------------------------------------- # 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) 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