You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

406 lines
15 KiB
Python

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

"""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:309: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 23× 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