"""Domain models for the Morning Momentum Intraday Backtester. All models use Pydantic for validation and serialization. No dependencies on the existing backtest system. """ from __future__ import annotations from typing import Any from pydantic import BaseModel, Field # ── Strategy Parameters ──────────────────────────────────────────────────── class StrategyParams(BaseModel): """Core strategy parameters controlling when to enter/exit.""" entry_minutes_after_open: int = 30 """Minutes after 9:30 AM ET to evaluate morning gainers and enter trades.""" exit_minutes_before_close: int = 30 """Minutes before 4:00 PM ET to force-close all positions.""" stop_loss_pct: float | None = -0.02 """Fixed stop-loss threshold (e.g. -0.02 = -2%). None to disable.""" trailing_stop_pct: float | None = None """Trailing stop: if set, ratchet stop up as price rises. e.g. -0.03 = trail 3% below peak. When both stop_loss_pct and trailing_stop_pct are set, trailing_stop_pct is used.""" min_morning_gain_pct: float = 0.01 """Minimum gain from open to entry time for a stock to qualify (e.g. 0.01 = 1%).""" max_morning_gain_pct: float | None = None """Maximum morning gain allowed (e.g. 0.10 = 10%). Filters out extreme gap-ups that tend to mean-revert quickly. None = no cap.""" min_entry_volume: int | None = None """Minimum cumulative volume by entry time (shares). Filters illiquid stocks. E.g. 50000 = must have traded 50K shares in first 30 minutes.""" ticker_cooldown_days: int = 0 """Blackout period after trading a ticker (calendar days). E.g. 5 = same ticker can't be selected again within 5 days. 0 = disabled.""" top_n: int = 3 """Number of top gainers to buy each day (equal-weight allocation).""" initial_capital: float = 10_000.0 """Starting capital in USD.""" slippage_bps: float = 5.0 """One-way slippage in basis points (applied to both entry and exit fills).""" market_regime_spy_threshold: float | None = None """Skip trading if SPY's morning return (open to entry time) is below this threshold. E.g. -0.005 = skip if SPY is down more than -0.5% by entry time. None = disabled.""" class ORBStrategyParams(BaseModel): """Parameters for the Opening Range Breakout (ORB) strategy.""" # ORB window orb_minutes: int = 5 """Duration of the opening range in minutes. 5 = first 5-min candle (9:30–9:35 ET).""" sim_bar_minutes: int = 5 """Bar interval for breakout detection and stop management after the ORB candle. 5 = use raw 5-min bars (default). 30 = aggregate to 30-min bars (more realistic, fewer whipsaws). The ORB candle itself always uses the first 5-min bar regardless of this setting.""" # Entry entry_direction: str = "long_only" """Entry direction filter: 'long_only' (bullish candle only), 'candle' (both), 'both' (always).""" order_timeout_minutes: int = 45 """Cancel unfilled breakout order after this many minutes from open. Default = 45min = 10:15 ET.""" # Universe quality filters (applied during ORB pre-screening) min_price: float = 10.0 """Minimum stock price. $10 is the ORB paper's practical minimum.""" min_avg_dollar_volume: float = 25_000_000.0 """Minimum 30-day average daily dollar volume ($25M). Ensures sufficient liquidity.""" min_atr_14: float = 0.50 """Minimum ATR(14) in dollars ($0.50). Ensures sufficient intraday range to trade.""" # RVOL-based candidate selection min_rvol: float = 1.0 """Minimum approximate RVOL at open. RVOL = first_5min_vol / (avg_daily_vol / 78). Note: this is an approximation — actual morning volume is 2–3× uniform rate, so calibrate relative to that systematic bias.""" max_candidates: int = 20 """Maximum candidates to pass to intraday fetch and simulate per day.""" min_candidates_to_trade: int = 3 """Skip the day entirely if fewer than this many candidates pass all filters.""" # Composite ranking weights weight_rvol: float = 0.60 """RVOL weight in composite ranking score (50% from paper + 10% from spread, which is unavailable).""" weight_gap: float = 0.25 """Gap% weight (proxy for premarket activity, which is unavailable).""" weight_dollar_vol: float = 0.15 """First-5-min dollar volume weight.""" weight_body_ratio: float = 0.0 """ORB candle directional conviction: (close-open)/(high-low) for longs, reversed for shorts. High value = first candle decisively moved in the breakout direction.""" weight_momentum: float = 0.0 """5-day prior price momentum weight. Positive = stock already trending in breakout direction.""" # ATR-based stop management atr_stop_multiplier: float = 0.10 """Initial stop distance = ATR(14) × this multiplier. Paper uses 10% (0.10).""" breakeven_at_r: float = 1.0 """Move stop to breakeven (entry price) when trade reaches this R-multiple.""" trailing_at_r: float = 2.0 """Activate trailing stop (using recent bar lows) when trade reaches this R-multiple.""" # Risk-based position sizing risk_per_trade_pct: float = 0.0025 """Risk dollars per trade = equity × this. 0.0025 = 0.25% per trade.""" max_position_pct: float = 0.20 """Maximum single position as fraction of equity. 0.20 = 20%.""" daily_max_loss_pct: float = 0.0125 """Stop trading for the day if cumulative loss exceeds this. 0.0125 = 1.25%.""" max_stops_per_day: int = 3 """Stop trading for the day after this many full-R stop losses.""" # Exit exit_minutes_before_close: int = 5 """Minutes before 4:00 PM ET to force-close. Default 5 = 15:55 ET.""" # Execution slippage_bps: float = 5.0 """One-way slippage in basis points (applied to both entry and exit fills).""" initial_capital: float = 10_000.0 """Starting capital in USD.""" ticker_cooldown_days: int = 0 """Blackout period after trading a ticker (same as momentum strategy). 0 = disabled.""" settlement_days: int = 0 """Cash account settlement delay (trading days). 0 = disabled (all equity always available — original behavior, allows over-deployment). 1 = T+1 (sale proceeds settle next trading day; also enforces within-day settled-cash cap). 2 = T+2 (legacy US rule pre-May 2024). GFV context: unsettled proceeds can buy but not same-day sell (ORB always exits same day, so only settled cash is usable).""" max_gap_pct: float | None = None """Maximum opening gap (open vs prev_close) allowed for ORB candidates. Stocks that gap >10% at open are over-extended and prone to reversal — they have a low ORB breakout continuation rate. None = no cap (allow any gap). E.g. 0.10 = 10% cap.""" # Market regime market_regime_spy_threshold: float | None = None """Skip trading if regime ticker's opening gap is below this threshold. None = disabled. E.g. -0.005 = skip if regime ticker gaps down >0.5% at open.""" market_regime_ticker: str = "SPY" """Ticker used for the gap-based market regime check. Default 'SPY'. IWM often works better for ORB (mid/small-cap universe matches ORB candidates). Only used when market_regime_spy_threshold is not None.""" min_candidate_breadth: float | None = None """Skip day if fewer than this fraction of intraday tickers opened above prev close. E.g. 0.30 = skip if <30% of day's candidates gapped up. Sweep result: 0.30 gives Sharpe 19.86 (vs 18.31 no filter), 0.50 gives 20.27. This is more robust than single-ETF regime checks because it measures the actual candidate pool's sentiment. None = disabled.""" compound_returns: bool = True """When True (default), position sizing scales with current equity (compounding). When False, position sizing always uses initial_capital (simple/단리 mode). Simple mode prevents late-period bias where larger equity dominates the return metric.""" trailing_stop_atr_multiplier: float = 0.0 """ATR-based trailing stop distance from peak price. 0 = disabled (use swing-low mode). When > 0: trailing_stop = peak_price - atr * this_value. Bar-size independent. E.g. 1.5 = trail 1.5×ATR(14) below the running peak. Activates at trailing_at_r. Swing-low mode (0.0) ties trailing sensitivity to sim_bar_minutes — ATR mode removes that dependency.""" class UniverseParams(BaseModel): """Parameters controlling which stocks to scan.""" source: str = "sp500" """Universe source: 'sp500', 'nasdaq100', 'midlarge', 'largecap', 'yaml', 'screener'.""" symbols_file: str | None = None """Path to YAML symbols file (required if source='yaml').""" market_cap_min: float | None = None """Minimum market cap filter (USD). Overrides screener default when set.""" avg_volume_min: int | None = None """Minimum 3-month average daily volume filter.""" sector_exclude: list[str] = Field(default_factory=list) """Sectors to exclude (e.g. ['Energy', 'Utilities']). Not applied for index sources.""" min_price: float = 5.0 """Minimum stock price. Filters out very cheap stocks.""" class BacktestParams(BaseModel): """Backtest period and pre-screening parameters.""" start_date: str | None = None """Backtest start date (YYYY-MM-DD). None = auto (today - lookback_trading_days).""" end_date: str | None = None """Backtest end date (YYYY-MM-DD). None = today.""" lookback_trading_days: int = 200 """Number of trading days to backtest when start_date is None.""" pre_screen_threshold: float = 0.015 """Phase 1 pre-screening threshold: (high - open) / open >= this to be a candidate. Conservative value to avoid missing morning runners.""" class CacheParams(BaseModel): """Intraday data disk cache configuration.""" enabled: bool = True """Whether to use the disk cache for intraday bars.""" dir: str = "data/cache/intraday" """Root directory for Parquet cache files.""" class OutputParams(BaseModel): """Output and reporting configuration.""" dir: str = "runs/intraday" """Directory for writing result JSON files.""" verbose: bool = False """Show detailed per-day output during simulation.""" class IntradayConfig(BaseModel): """Full configuration for one intraday backtest run. Maps 1:1 to the YAML config file format. """ strategy_mode: str = "momentum" """Strategy to use: 'momentum' (morning gainers) or 'orb' (opening range breakout).""" strategy: StrategyParams = Field(default_factory=StrategyParams) """Momentum strategy parameters (used when strategy_mode='momentum').""" orb_strategy: ORBStrategyParams | None = None """ORB strategy parameters (used when strategy_mode='orb'). None = use defaults.""" universe: UniverseParams = Field(default_factory=UniverseParams) backtest: BacktestParams = Field(default_factory=BacktestParams) cache: CacheParams = Field(default_factory=CacheParams) output: OutputParams = Field(default_factory=OutputParams) # ── Trade Results ────────────────────────────────────────────────────────── class IntradayTrade(BaseModel): """One completed intraday trade.""" date: str """Trading date (YYYY-MM-DD).""" ticker: str """Stock symbol.""" entry_price: float """Fill price at entry (after slippage).""" exit_price: float """Fill price at exit (after slippage).""" entry_time: str """Entry bar timestamp (ISO 8601, ET).""" exit_time: str """Exit bar timestamp (ISO 8601, ET).""" shares: float """Number of shares held.""" pnl: float """Dollar P&L (after slippage costs).""" pnl_pct: float """Percentage P&L: (exit_price - entry_price) / entry_price (before slippage adj).""" exit_reason: str """How the trade was closed: 'close' or 'stop_loss'.""" morning_gain_pct: float = 0.0 """Stock's gain from open to entry time (momentum signal). 0.0 for ORB trades.""" slippage_cost: float = 0.0 """Total slippage cost in USD (entry + exit).""" # ORB-specific fields (optional, None for momentum trades) orb_direction: str | None = None """ORB trade direction: 'long' or 'short'. None for momentum trades.""" rvol: float | None = None """Approximate RVOL at entry time. None for momentum trades.""" atr_at_entry: float | None = None """ATR(14) value used for stop sizing. None for momentum trades.""" r_multiple_at_exit: float | None = None """Final R-multiple at exit: (exit_price - entry_price) / initial_risk. None for momentum.""" class DayResult(BaseModel): """Simulation result for one trading day.""" date: str trades: list[IntradayTrade] = Field(default_factory=list) daily_pnl: float = 0.0 daily_return_pct: float = 0.0 candidates_found: int = 0 """Number of stocks that met the morning gain threshold.""" # Settlement / GFV tracking (ORB-only; 0 when settlement_days=0 or momentum) capital_deployed: float = 0.0 """Total capital deployed in positions this day (sum of shares × entry_price).""" available_cash_start: float = 0.0 """Settled cash available at start of this trading day (before any trades).""" skipped_insufficient_cash: int = 0 """Candidates skipped because available settled cash was exhausted.""" # ── Aggregate Metrics ────────────────────────────────────────────────────── class IntradayMetrics(BaseModel): """Summary performance metrics for a complete backtest run.""" # Identity run_id: str = "" params_hash: str = "" # Period start_date: str = "" end_date: str = "" trading_days: int = 0 days_with_trades: int = 0 # Trade counts total_trades: int = 0 stop_loss_exits: int = 0 # Trade-level metrics win_rate: float | None = None avg_win_pct: float | None = None avg_loss_pct: float | None = None profit_factor: float | None = None expectancy_pct: float | None = None # Return metrics total_return_pct: float | None = None annualized_return_pct: float | None = None avg_daily_return_pct: float | None = None # Risk metrics max_drawdown_pct: float | None = None sharpe_ratio: float | None = None sortino_ratio: float | None = None calmar_ratio: float | None = None # Intraday-specific avg_hold_minutes: float | None = None stop_loss_exit_pct: float | None = None """Fraction of trades exited via stop loss.""" # Capital initial_capital: float = 10_000.0 final_equity: float = 0.0 class SweepResult(BaseModel): """One parameter combination result from a grid sweep.""" params: dict[str, Any] metrics: IntradayMetrics