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