"""Core intraday simulation engine. DST-aware (uses zoneinfo America/New_York throughout). Pure functions — no API calls, no disk I/O. run_simulation() takes pre-loaded data and returns DayResult list, making sweep mode trivial (call once per parameter combination). """ from __future__ import annotations import datetime as dt from collections import deque from zoneinfo import ZoneInfo from libs.intraday.domain import DayResult, IntradayTrade, StrategyParams _ET = ZoneInfo("America/New_York") _MARKET_OPEN = dt.time(9, 30) # ET _MARKET_CLOSE = dt.time(16, 0) # ET _MIN_BARS = 5 # minimum market-hours bars required to simulate a stock # ── Timestamp Parsing ────────────────────────────────────────────────────── def _parse_ts(ts_str: str) -> dt.datetime: """Parse Alpaca ISO 8601 timestamp to timezone-aware ET datetime.""" s = ts_str.replace("Z", "+00:00") return dt.datetime.fromisoformat(s).astimezone(_ET) # ── Market Hours Filtering ───────────────────────────────────────────────── def filter_market_hours(bars: list[dict]) -> list[dict]: """Return only bars that fall within regular trading hours (9:30-16:00 ET). Handles DST transitions correctly via zoneinfo. """ result = [] for b in bars: ts = _parse_ts(b["timestamp"]) t = ts.time() if _MARKET_OPEN <= t < _MARKET_CLOSE: result.append(b) return result def _bar_at_offset( bars: list[dict], market_open_ts: dt.datetime, offset_minutes: int, tolerance_minutes: int = 7, ) -> dict | None: """Find the bar closest to (market_open + offset_minutes). Returns None if no bar is within tolerance_minutes of the target. """ target = market_open_ts + dt.timedelta(minutes=offset_minutes) best: dict | None = None best_diff = float("inf") for b in bars: ts = _parse_ts(b["timestamp"]) diff = abs((ts - target).total_seconds()) if diff < best_diff and diff <= tolerance_minutes * 60: best = b best_diff = diff return best def _market_open_ts(date_str: str) -> dt.datetime: """Return 9:30 AM ET datetime for the given date string.""" d = dt.date.fromisoformat(date_str) naive = dt.datetime.combine(d, _MARKET_OPEN) return naive.replace(tzinfo=_ET) def _volume_up_to_bar(bars: list[dict], entry_ts: dt.datetime) -> float: """Sum volume of all bars up to and including entry_ts.""" total = 0.0 for b in bars: ts = _parse_ts(b["timestamp"]) if ts <= entry_ts: total += b.get("volume", 0) or 0 return total # ── Trade Simulation ─────────────────────────────────────────────────────── def _apply_slippage_entry(price: float, slippage_bps: float) -> float: """Long entry fill: price × (1 + bps/10000).""" return price * (1.0 + slippage_bps / 10_000) def _apply_slippage_exit(price: float, slippage_bps: float) -> float: """Long exit fill: price × (1 - bps/10000).""" return price * (1.0 - slippage_bps / 10_000) def simulate_trade( bars: list[dict], entry_bar: dict, entry_price_raw: float, exit_offset_minutes: int, stop_loss_pct: float | None, trailing_stop_pct: float | None, slippage_bps: float, date_str: str, ) -> tuple[float, str, str]: """Simulate a single intraday trade. Supports both fixed stop-loss and trailing stop. When trailing_stop_pct is set, it takes precedence over stop_loss_pct. Returns: (exit_price_after_slippage, exit_time_str, exit_reason) """ entry_price = _apply_slippage_entry(entry_price_raw, slippage_bps) entry_ts = _parse_ts(entry_bar["timestamp"]) # Compute exit target time market_close = _market_open_ts(date_str).replace(hour=16, minute=0) exit_target = market_close - dt.timedelta(minutes=exit_offset_minutes) exit_price_raw = entry_price_raw exit_time_str = entry_bar["timestamp"] exit_reason = "close" # Trailing stop state peak_price = entry_price_raw for b in bars: ts = _parse_ts(b["timestamp"]) if ts <= entry_ts: continue # Update peak for trailing stop if b["high"] > peak_price: peak_price = b["high"] # Determine effective stop level if trailing_stop_pct is not None: # Trailing: stop = peak × (1 + trailing_pct), trails upward stop_price = peak_price * (1.0 + trailing_stop_pct) # trailing_pct is negative low_price = b["low"] if low_price <= stop_price: exit_price_raw = stop_price exit_time_str = b["timestamp"] exit_reason = "trailing_stop" break elif stop_loss_pct is not None: # Fixed stop: relative to entry low_return = (b["low"] - entry_price_raw) / entry_price_raw if low_return <= stop_loss_pct: exit_price_raw = entry_price_raw * (1.0 + stop_loss_pct) exit_time_str = b["timestamp"] exit_reason = "stop_loss" break # Check scheduled exit time if ts >= exit_target: exit_price_raw = b["close"] exit_time_str = b["timestamp"] exit_reason = "close" break # Update running exit (last bar before exit time) exit_price_raw = b["close"] exit_time_str = b["timestamp"] exit_price = _apply_slippage_exit(exit_price_raw, slippage_bps) return exit_price, exit_time_str, exit_reason # ── Morning Gain Computation ─────────────────────────────────────────────── def compute_morning_gains( bars_by_ticker: dict[str, list[dict]], strategy: StrategyParams, date_str: str, blacklisted_tickers: set[str] | None = None, spy_bars: list[dict] | None = None, ) -> dict[str, dict]: """Compute each ticker's gain from open to entry time, applying all filters. Filters applied: - Minimum market-hours bars (_MIN_BARS) - min_morning_gain_pct: stock must be up enough to qualify - max_morning_gain_pct: cap extreme gap-ups that tend to mean-revert - min_entry_volume: require sufficient trading activity by entry time - blacklisted_tickers: tickers in cooldown period (recently traded) - market_regime_spy_threshold: skip if SPY is down too much Returns: {ticker: {gain_pct, entry_price_raw, entry_bar, mkt_bars, entry_volume}} """ market_open = _market_open_ts(date_str) # Market regime check: compute SPY's morning return if strategy.market_regime_spy_threshold is not None and spy_bars: spy_mkt = filter_market_hours(spy_bars) if len(spy_mkt) >= 2: spy_open = spy_mkt[0]["open"] spy_entry_bar = _bar_at_offset(spy_mkt, market_open, strategy.entry_minutes_after_open) if spy_open > 0 and spy_entry_bar is not None: spy_gain = (spy_entry_bar["close"] - spy_open) / spy_open if spy_gain < strategy.market_regime_spy_threshold: return {} # Skip this day entirely result = {} for ticker, all_bars in bars_by_ticker.items(): # Skip blacklisted tickers (cooldown) if blacklisted_tickers and ticker in blacklisted_tickers: continue mkt_bars = filter_market_hours(all_bars) if len(mkt_bars) < _MIN_BARS: continue open_price = mkt_bars[0]["open"] if open_price <= 0: continue entry_bar = _bar_at_offset(mkt_bars, market_open, strategy.entry_minutes_after_open) if entry_bar is None: continue entry_price_raw = entry_bar["close"] if entry_price_raw <= 0: continue gain_pct = (entry_price_raw - open_price) / open_price # min gain filter if gain_pct < strategy.min_morning_gain_pct: continue # max gain filter (avoid extreme gap-ups that tend to mean-revert) if strategy.max_morning_gain_pct is not None and gain_pct > strategy.max_morning_gain_pct: continue # volume filter: cumulative volume up to entry time entry_ts = _parse_ts(entry_bar["timestamp"]) entry_vol = _volume_up_to_bar(mkt_bars, entry_ts) if strategy.min_entry_volume is not None and entry_vol < strategy.min_entry_volume: continue result[ticker] = { "gain_pct": gain_pct, "entry_price_raw": entry_price_raw, "entry_bar": entry_bar, "mkt_bars": mkt_bars, "entry_volume": entry_vol, } return result # ── Day Simulation ───────────────────────────────────────────────────────── def simulate_day( bars_by_ticker: dict[str, list[dict]], date_str: str, strategy: StrategyParams, blacklisted_tickers: set[str] | None = None, spy_bars: list[dict] | None = None, ) -> DayResult: """Simulate one full trading day. 1. Apply all filters to find qualified morning gainers. 2. Rank by gain, pick top N. 3. Simulate each trade with stop-loss / trailing stop. 4. Compute daily P&L. """ result = DayResult(date=date_str) morning_gains = compute_morning_gains( bars_by_ticker, strategy, date_str, blacklisted_tickers=blacklisted_tickers, spy_bars=spy_bars, ) result.candidates_found = len(morning_gains) if not morning_gains: return result # Pick top N by morning gain top_tickers = sorted( morning_gains.keys(), key=lambda t: morning_gains[t]["gain_pct"], reverse=True, )[: strategy.top_n] capital_per_trade = strategy.initial_capital / strategy.top_n for ticker in top_tickers: info = morning_gains[ticker] entry_price_raw = info["entry_price_raw"] entry_bar = info["entry_bar"] mkt_bars = info["mkt_bars"] exit_price, exit_time_str, exit_reason = simulate_trade( mkt_bars, entry_bar, entry_price_raw, strategy.exit_minutes_before_close, strategy.stop_loss_pct, strategy.trailing_stop_pct, strategy.slippage_bps, date_str, ) entry_price_filled = _apply_slippage_entry(entry_price_raw, strategy.slippage_bps) shares = capital_per_trade / entry_price_filled pnl_pct = (exit_price - entry_price_filled) / entry_price_filled pnl = pnl_pct * capital_per_trade slippage_cost = ( (entry_price_filled - entry_price_raw) + (entry_price_raw * strategy.slippage_bps / 10_000) ) * shares trade = IntradayTrade( date=date_str, ticker=ticker, entry_price=round(entry_price_filled, 4), exit_price=round(exit_price, 4), entry_time=entry_bar["timestamp"], exit_time=exit_time_str, shares=round(shares, 4), pnl=round(pnl, 4), pnl_pct=round(pnl_pct, 6), exit_reason=exit_reason, morning_gain_pct=round(info["gain_pct"], 6), slippage_cost=round(slippage_cost, 4), ) result.trades.append(trade) result.daily_pnl += trade.pnl if result.trades: total_deployed = capital_per_trade * len(result.trades) result.daily_return_pct = result.daily_pnl / total_deployed return result # ── Full Backtest Simulation ─────────────────────────────────────────────── def run_simulation( all_intraday: dict[str, dict[str, list[dict]]], trading_days: list[str], strategy: StrategyParams, ) -> list[DayResult]: """Run the full backtest simulation across all trading days. Pure computation — no API calls, no disk I/O. Safe to call repeatedly with different strategy params for sweep mode. Implements: - Ticker cooldown (blackout period after trading a ticker) - Market regime filter via SPY bars - All strategy filters (max gain, min volume, trailing stop, etc.) Args: all_intraday: {date: {ticker: [bars]}} — pre-loaded intraday data. trading_days: Ordered list of dates to simulate. strategy: Strategy parameters. Returns: List of DayResult objects (one per day that had intraday data). """ results: list[DayResult] = [] # Ticker cooldown: map ticker -> last traded date ticker_last_traded: dict[str, dt.date] = {} for date_str in trading_days: bars_by_ticker = all_intraday.get(date_str) if not bars_by_ticker: continue # Build blacklist from cooldown blacklisted: set[str] = set() if strategy.ticker_cooldown_days > 0: current_date = dt.date.fromisoformat(date_str) for ticker, last_dt in ticker_last_traded.items(): days_since = (current_date - last_dt).days if days_since <= strategy.ticker_cooldown_days: blacklisted.add(ticker) # Extract SPY bars for regime filter spy_bars = bars_by_ticker.get("SPY") if strategy.market_regime_spy_threshold is not None else None day_result = simulate_day( bars_by_ticker, date_str, strategy, blacklisted_tickers=blacklisted if blacklisted else None, spy_bars=spy_bars, ) results.append(day_result) # Update cooldown tracker if strategy.ticker_cooldown_days > 0: current_date = dt.date.fromisoformat(date_str) for trade in day_result.trades: ticker_last_traded[trade.ticker] = current_date return results