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.
fithia2/scripts/morning_momentum_backtest.py

498 lines
19 KiB
Python

#!/usr/bin/env python3
"""
Morning Momentum Day-Trade Backtest
====================================
Strategy:
- At ENTRY_TIME (e.g. 10:00 AM ET), find top N gainers from market open
- Filter: S&P 500 (liquid, large-cap)
- Buy top N at ENTRY_TIME price (equal weight)
- Sell at EXIT_TIME (e.g. 3:30 PM ET) OR on stop-loss (e.g. -2%)
- Daily P&L tracking, no overnight holds
Uses Oracle API (Alpaca intraday) for historical 5-min bars.
"""
from __future__ import annotations
import asyncio
import sys
from collections import defaultdict
from dataclasses import dataclass, field
from datetime import datetime, timedelta, date
import httpx
# ── Configuration ──────────────────────────────────────────────
ORACLE_URL = "http://localhost:18001"
# Strategy parameters
ENTRY_MINUTES_AFTER_OPEN = 30 # minutes after 9:30 AM ET
EXIT_MINUTES_BEFORE_CLOSE = 30 # minutes before 4:00 PM ET
TOP_N = 3 # number of stocks to buy
STOP_LOSS_PCT = -0.02 # -2% stop loss (None to disable)
MIN_MORNING_GAIN_PCT = 0.01 # minimum 1% gain to qualify as "gainer"
INITIAL_CAPITAL = 10_000.0
# Backtest period
LOOKBACK_TRADING_DAYS = 40 # ~2 months
# Market hours in UTC (ET + 4 in EDT / ET + 5 in EST)
# April = EDT, so 9:30 AM ET = 13:30 UTC, 4:00 PM ET = 20:00 UTC
MARKET_OPEN_UTC_HOUR = 13
MARKET_OPEN_UTC_MIN = 30
MARKET_CLOSE_UTC_HOUR = 20
MARKET_CLOSE_UTC_MIN = 0
INTERVAL = "5min"
INTERVAL_MINUTES = 5
@dataclass
class Trade:
date: str
ticker: str
entry_price: float
exit_price: float
entry_time: str
exit_time: str
shares: float
pnl: float
pnl_pct: float
exit_reason: str # "close" or "stop_loss"
morning_gain_pct: float # gain at entry from open
@dataclass
class DayResult:
date: str
trades: list[Trade] = field(default_factory=list)
daily_pnl: float = 0.0
daily_pnl_pct: float = 0.0
candidates_found: int = 0
async def fetch_json(client: httpx.AsyncClient, url: str, params: dict = None) -> dict:
resp = await client.get(url, params=params, timeout=30.0)
resp.raise_for_status()
return resp.json()
async def get_sp500(client: httpx.AsyncClient) -> list[str]:
"""Get S&P 500 ticker list."""
data = await fetch_json(client, f"{ORACLE_URL}/api/v1/stocks/index/sp500")
return [s["symbol"] for s in data.get("constituents", [])]
async def get_trading_days(client: httpx.AsyncClient, ticker: str = "SPY",
days: int = 60) -> list[str]:
"""Get recent trading days from SPY daily bars."""
end = date.today()
start = end - timedelta(days=days + 30) # extra buffer for weekends/holidays
data = await fetch_json(client, f"{ORACLE_URL}/api/v1/price/data/{ticker}", {
"start_date": start.isoformat(),
"end_date": end.isoformat(),
})
bars = data.get("data", [])
dates = sorted(b["date"][:10] for b in bars)
return dates[-days:] # last N trading days
async def get_intraday(client: httpx.AsyncClient, ticker: str, dt: str,
semaphore: asyncio.Semaphore) -> tuple[str, list[dict]]:
"""Fetch 5-min intraday bars for ticker on date, with concurrency limit."""
async with semaphore:
try:
data = await fetch_json(client, f"{ORACLE_URL}/api/v1/alpaca/intraday/{ticker}", {
"interval": INTERVAL,
"start_date": dt,
"end_date": dt,
"limit": 500,
})
return ticker, data.get("candles", [])
except Exception as e:
return ticker, []
def parse_utc_ts(ts_str: str) -> datetime:
"""Parse timestamp string to datetime (UTC)."""
# Handle both 2026-04-09T08:00:00Z and 2026-04-09T08:00:00+00:00
ts_str = ts_str.replace("Z", "+00:00")
if "+" not in ts_str and "-" not in ts_str[10:]:
ts_str += "+00:00"
dt = datetime.fromisoformat(ts_str)
return dt.replace(tzinfo=None) # work in UTC naively
def filter_market_hours(candles: list[dict]) -> list[dict]:
"""Keep only regular trading hours candles (9:30-16:00 ET = 13:30-20:00 UTC)."""
filtered = []
for c in candles:
ts = parse_utc_ts(c["timestamp"])
market_open = ts.replace(hour=MARKET_OPEN_UTC_HOUR, minute=MARKET_OPEN_UTC_MIN, second=0)
market_close = ts.replace(hour=MARKET_CLOSE_UTC_HOUR, minute=MARKET_CLOSE_UTC_MIN, second=0)
if market_open <= ts < market_close:
filtered.append(c)
return filtered
def get_candle_at_time(candles: list[dict], target_hour_utc: int, target_min_utc: int,
tolerance_minutes: int = 10) -> dict | None:
"""Find candle closest to target time within tolerance."""
best = None
best_diff = float("inf")
for c in candles:
ts = parse_utc_ts(c["timestamp"])
target = ts.replace(hour=target_hour_utc, minute=target_min_utc)
diff = abs((ts - target).total_seconds())
if diff < best_diff and diff <= tolerance_minutes * 60:
best = c
best_diff = diff
return best
def simulate_day(candles_by_ticker: dict[str, list[dict]], day: str,
capital_per_trade: float) -> DayResult:
"""Simulate one day of trading."""
result = DayResult(date=day)
# Entry time in UTC: 9:30 + ENTRY_MINUTES_AFTER_OPEN = e.g. 10:00 AM ET = 14:00 UTC
entry_h = MARKET_OPEN_UTC_HOUR
entry_m = MARKET_OPEN_UTC_MIN + ENTRY_MINUTES_AFTER_OPEN
entry_h += entry_m // 60
entry_m = entry_m % 60
# Exit time in UTC: 16:00 - EXIT_MINUTES_BEFORE_CLOSE = e.g. 15:30 ET = 19:30 UTC
exit_total_min = (MARKET_CLOSE_UTC_HOUR * 60 + MARKET_CLOSE_UTC_MIN) - EXIT_MINUTES_BEFORE_CLOSE
exit_h = exit_total_min // 60
exit_m = exit_total_min % 60
# Step 1: Calculate morning gain for each ticker
morning_gains = {}
for ticker, candles in candles_by_ticker.items():
mkt_candles = filter_market_hours(candles)
if len(mkt_candles) < 5:
continue
# Open price = first candle's open
open_price = mkt_candles[0]["open"]
if open_price <= 0:
continue
# Entry candle
entry_candle = get_candle_at_time(mkt_candles, entry_h, entry_m)
if not entry_candle:
continue
entry_price = entry_candle["close"]
gain_pct = (entry_price - open_price) / open_price
if gain_pct >= MIN_MORNING_GAIN_PCT:
morning_gains[ticker] = {
"gain_pct": gain_pct,
"entry_price": entry_price,
"entry_time": entry_candle["timestamp"],
"candles": mkt_candles,
}
result.candidates_found = len(morning_gains)
if not morning_gains:
return result
# Step 2: Pick top N
top_tickers = sorted(morning_gains.keys(), key=lambda t: morning_gains[t]["gain_pct"],
reverse=True)[:TOP_N]
# Step 3: Simulate each trade
for ticker in top_tickers:
info = morning_gains[ticker]
entry_price = info["entry_price"]
shares = capital_per_trade / entry_price
candles = info["candles"]
# Find exit: iterate candles after entry to check stop loss
exit_price = entry_price
exit_time = info["entry_time"]
exit_reason = "close"
entry_ts = parse_utc_ts(info["entry_time"])
for c in candles:
ts = parse_utc_ts(c["timestamp"])
if ts <= entry_ts:
continue
# Check stop loss on low
if STOP_LOSS_PCT is not None:
low_pnl = (c["low"] - entry_price) / entry_price
if low_pnl <= STOP_LOSS_PCT:
# Stop loss triggered - assume exit at stop price
exit_price = entry_price * (1 + STOP_LOSS_PCT)
exit_time = c["timestamp"]
exit_reason = "stop_loss"
break
# Check if at/past exit time
exit_target = ts.replace(hour=exit_h, minute=exit_m)
if ts >= exit_target:
exit_price = c["close"]
exit_time = c["timestamp"]
exit_reason = "close"
break
else:
# Update running exit (last seen candle)
exit_price = c["close"]
exit_time = c["timestamp"]
pnl = (exit_price - entry_price) * shares
pnl_pct = (exit_price - entry_price) / entry_price
trade = Trade(
date=day,
ticker=ticker,
entry_price=round(entry_price, 2),
exit_price=round(exit_price, 2),
entry_time=info["entry_time"],
exit_time=exit_time,
shares=round(shares, 4),
pnl=round(pnl, 2),
pnl_pct=round(pnl_pct, 4),
exit_reason=exit_reason,
morning_gain_pct=round(info["gain_pct"], 4),
)
result.trades.append(trade)
result.daily_pnl += trade.pnl
if result.trades:
total_invested = capital_per_trade * len(result.trades)
result.daily_pnl_pct = result.daily_pnl / total_invested
return result
async def run_backtest():
"""Main backtest loop."""
print("=" * 70)
print(" Morning Momentum Day-Trade Backtest")
print("=" * 70)
print(f"\n📋 Parameters:")
print(f" Entry: {ENTRY_MINUTES_AFTER_OPEN} min after open (10:00 AM ET)")
print(f" Exit: {EXIT_MINUTES_BEFORE_CLOSE} min before close (3:30 PM ET)")
print(f" Top N: {TOP_N} stocks per day")
print(f" Stop Loss: {STOP_LOSS_PCT*100 if STOP_LOSS_PCT else 'None'}%")
print(f" Min Gain: {MIN_MORNING_GAIN_PCT*100}% to qualify")
print(f" Capital: ${INITIAL_CAPITAL:,.0f}")
print(f" Period: last {LOOKBACK_TRADING_DAYS} trading days")
print()
async with httpx.AsyncClient() as client:
# Step 1: Get universe
print("⏳ Fetching S&P 500 constituents...")
sp500 = await get_sp500(client)
print(f" Got {len(sp500)} tickers")
# Step 2: Get trading days
print("⏳ Fetching trading days...")
trading_days = await get_trading_days(client, days=LOOKBACK_TRADING_DAYS + 10)
trading_days = trading_days[-LOOKBACK_TRADING_DAYS:]
print(f" Period: {trading_days[0]} to {trading_days[-1]} ({len(trading_days)} days)")
# Step 3: Pre-screen using daily bars to find "big mover" days
# Instead of fetching intraday for ALL 500 stocks every day,
# first get daily bars to identify which stocks had big moves
print("\n⏳ Phase 1: Fetching daily bars for pre-screening...")
daily_by_ticker: dict[str, list[dict]] = {}
batch_size = 50
semaphore = asyncio.Semaphore(20)
async def fetch_daily(ticker):
async with semaphore:
try:
data = await fetch_json(client, f"{ORACLE_URL}/api/v1/price/data/{ticker}", {
"start_date": trading_days[0],
"end_date": trading_days[-1],
})
return ticker, data.get("data", [])
except Exception:
return ticker, []
for i in range(0, len(sp500), batch_size):
batch = sp500[i:i + batch_size]
tasks = [fetch_daily(t) for t in batch]
results = await asyncio.gather(*tasks)
for ticker, bars in results:
if bars:
daily_by_ticker[ticker] = bars
pct = min(100, (i + batch_size) / len(sp500) * 100)
sys.stdout.write(f"\r Progress: {pct:.0f}% ({len(daily_by_ticker)} tickers with data)")
sys.stdout.flush()
print()
# Step 4: For each day, identify potential big movers using daily data
# Heuristic: stocks where (close - open)/open > 1% OR (high - open)/open > 2%
print("\n⏳ Phase 2: Identifying daily big movers...")
day_candidates: dict[str, list[str]] = defaultdict(list) # date -> tickers
for ticker, bars in daily_by_ticker.items():
bar_by_date = {b["date"][:10]: b for b in bars}
for day in trading_days:
if day not in bar_by_date:
continue
b = bar_by_date[day]
open_p = b["open"]
if open_p <= 0:
continue
# Use (high - open) / open as proxy for morning momentum potential
intraday_range = (b["high"] - open_p) / open_p
if intraday_range >= 0.015: # at least 1.5% above open at some point
day_candidates[day].append(ticker)
total_candidates = sum(len(v) for v in day_candidates.values())
print(f" Found {total_candidates} ticker-day candidates across {len(day_candidates)} days")
# Cap to top 20 per day to limit API calls
for day in day_candidates:
tickers = day_candidates[day]
# Sort by daily range (high-open)/open descending
bar_map = {}
for t in tickers:
for b in daily_by_ticker.get(t, []):
if b["date"][:10] == day:
bar_map[t] = (b["high"] - b["open"]) / b["open"] if b["open"] > 0 else 0
day_candidates[day] = sorted(tickers, key=lambda t: bar_map.get(t, 0), reverse=True)[:20]
capped_total = sum(len(v) for v in day_candidates.values())
print(f" Capped to top 20 per day: {capped_total} total intraday fetches needed")
# Step 5: Fetch intraday data for candidates
print("\n⏳ Phase 3: Fetching intraday data for candidates...")
intraday_sem = asyncio.Semaphore(10) # lower concurrency for intraday
day_intraday: dict[str, dict[str, list[dict]]] = defaultdict(dict)
fetch_count = 0
for day_idx, day in enumerate(trading_days):
if day not in day_candidates or not day_candidates[day]:
continue
tasks = [get_intraday(client, t, day, intraday_sem) for t in day_candidates[day]]
results = await asyncio.gather(*tasks)
for ticker, candles in results:
if candles:
day_intraday[day][ticker] = candles
fetch_count += len(tasks)
sys.stdout.write(f"\r Progress: day {day_idx+1}/{len(trading_days)} | {fetch_count} API calls")
sys.stdout.flush()
print()
# Step 6: Simulate trades
print("\n⏳ Phase 4: Simulating trades...")
capital_per_trade = INITIAL_CAPITAL / TOP_N
all_results: list[DayResult] = []
all_trades: list[Trade] = []
for day in trading_days:
candles = day_intraday.get(day, {})
if not candles:
continue
day_result = simulate_day(candles, day, capital_per_trade)
all_results.append(day_result)
all_trades.extend(day_result.trades)
# ── Results ──────────────────────────────────────────────────
print("\n" + "=" * 70)
print(" RESULTS")
print("=" * 70)
trading_days_with_trades = [r for r in all_results if r.trades]
total_trades = len(all_trades)
if total_trades == 0:
print("\n❌ No trades executed. Try relaxing MIN_MORNING_GAIN_PCT.")
return
# Basic stats
wins = [t for t in all_trades if t.pnl > 0]
losses = [t for t in all_trades if t.pnl <= 0]
stop_losses = [t for t in all_trades if t.exit_reason == "stop_loss"]
total_pnl = sum(t.pnl for t in all_trades)
avg_pnl_per_trade = total_pnl / total_trades
win_rate = len(wins) / total_trades
avg_win = sum(t.pnl_pct for t in wins) / len(wins) if wins else 0
avg_loss = sum(t.pnl_pct for t in losses) / len(losses) if losses else 0
# Daily stats
daily_returns = [r.daily_pnl_pct for r in all_results if r.trades]
avg_daily_return = sum(daily_returns) / len(daily_returns) if daily_returns else 0
# Cumulative equity
equity = INITIAL_CAPITAL
equity_curve = [equity]
for r in all_results:
equity += r.daily_pnl
equity_curve.append(equity)
max_equity = INITIAL_CAPITAL
max_dd = 0
for eq in equity_curve:
max_equity = max(max_equity, eq)
dd = (eq - max_equity) / max_equity
max_dd = min(max_dd, dd)
# Annualized return (rough)
total_return_pct = (equity - INITIAL_CAPITAL) / INITIAL_CAPITAL
n_days = len(trading_days)
ann_factor = 252 / n_days if n_days > 0 else 1
ann_return = total_return_pct * ann_factor
print(f"\n📊 Summary ({trading_days[0]}{trading_days[-1]})")
print(f" Trading days: {n_days}")
print(f" Days with trades: {len(trading_days_with_trades)}")
print(f" Total trades: {total_trades}")
print(f" Stop-loss exits: {len(stop_losses)}")
print(f"\n💰 P&L:")
print(f" Total P&L: ${total_pnl:+,.2f} ({total_return_pct:+.2%})")
print(f" Avg P&L/trade: ${avg_pnl_per_trade:+,.2f}")
print(f" Annualized return: {ann_return:+.1%}")
print(f"\n📈 Win/Loss:")
print(f" Win rate: {win_rate:.1%} ({len(wins)}W / {len(losses)}L)")
print(f" Avg winner: {avg_win:+.2%}")
print(f" Avg loser: {avg_loss:+.2%}")
profit_factor = abs(sum(t.pnl for t in wins) / sum(t.pnl for t in losses)) if losses and sum(t.pnl for t in losses) != 0 else float("inf")
print(f" Profit factor: {profit_factor:.2f}")
print(f"\n📉 Risk:")
print(f" Max drawdown: {max_dd:.2%}")
print(f" Avg daily return: {avg_daily_return:+.4%}")
print(f" Final equity: ${equity:,.2f}")
# Show top 10 best and worst trades
sorted_trades = sorted(all_trades, key=lambda t: t.pnl_pct, reverse=True)
print(f"\n🏆 Top 5 Winners:")
for t in sorted_trades[:5]:
print(f" {t.date} {t.ticker:6} entry={t.entry_price:8.2f} exit={t.exit_price:8.2f}"
f" {t.pnl_pct:+.2%} (${t.pnl:+.2f}) morn_gain={t.morning_gain_pct:+.2%}")
print(f"\n💀 Top 5 Losers:")
for t in sorted_trades[-5:]:
print(f" {t.date} {t.ticker:6} entry={t.entry_price:8.2f} exit={t.exit_price:8.2f}"
f" {t.pnl_pct:+.2%} (${t.pnl:+.2f}) morn_gain={t.morning_gain_pct:+.2%} [{t.exit_reason}]")
# Daily breakdown
print(f"\n📅 Daily P&L (days with trades):")
print(f" {'Date':12} {'#Trades':>8} {'P&L':>10} {'Return':>8} {'Tickers'}")
print(f" {'-'*12} {'-'*8} {'-'*10} {'-'*8} {'-'*30}")
for r in all_results:
if not r.trades:
continue
tickers = ", ".join(f"{t.ticker}({t.pnl_pct:+.1%})" for t in r.trades)
print(f" {r.date:12} {len(r.trades):>8} ${r.daily_pnl:>+9.2f} {r.daily_pnl_pct:>+7.2%} {tickers}")
# Also test variations
print(f"\n{'='*70}")
print(" SENSITIVITY: Entry at 1 hour after open")
print(f"{'='*70}")
if __name__ == "__main__":
asyncio.run(run_backtest())