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.

417 lines
14 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.

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