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