SPY First 15-Minute Breakout
Deep in some threads on Reddit, someone mentioned a strategy that caught my interest.
A single-run, intraday strategy that waits for the market open, builds the first 15-minute range from 1-minute close bars, and then trades a breakout of that range, trading into the momentum.
Being interested in low risk intraday plays, I decided to backtest it, and this post is a summary of my findings.
The backtest
This is what needs to happen:
- Retrieve SPY minute to minute data.
- Identify first 15 minutes of trading 9:30 to 9:45.
- Find the high and low during that period.
- After 9:45, wait for a breakout above 15m high or below 15m low.
- Open a trade if breach buffer is reached:
long if 15m high + breach buffer,short if 15m low - breach buffer. - Manage trade, exit if target or stop is reached.
- Calculate profit/loss between both long and shorts.
- Calculate Sharpe
But first, an example with numbers
First 15m high = $600.00
First 15m low = $599.00
Breach buffer = $.25
Target = $.50
Stop offset = $.05
Stop = Entry - or + offset, depending on whether long or short, respectively.
Long scenario:
SPY moves above $600.00 and moves through breach buffer at $600.25, long initiated.
Trade exits with win if SPY hits target, at $600.75, loss if SPY drops to $600.20
Short Scenario
SPY moves below $599.00 and moves through breach buffer at $598.75.
Trade exits with win if SPY hits target at $598.25, loss if SPY increases to $598.80
Retrieving SPY data
Since I have this data available in a parquet file, let's pull the last few months of data, trimming to regular market hours:
query_quotes = """
SELECT symbol, last_price, FROM_UNIXTIME(quote_time / 1000) AS quote_datetime
FROM equities
WHERE symbol = 'SPY'
AND TIME(FROM_UNIXTIME(quote_time / 1000)) BETWEEN '09:30:00' AND '16:00:00'
AND FROM_UNIXTIME(quote_time / 1000) >= '2025-03-15'
ORDER BY quote_datetime ASC;
"""
df_quotes_processed = load_and_process_data(query_quotes, "spy_quotes_parquet")
print(f"Number of rows quotes: {df_quotes_processed.shape[0]}")
df_quotes_processed.head()
DataFrame loaded or queried:
symbol last_price quote_datetime
0 SPY 564.970 2025-03-18 09:30:02.469
1 SPY 564.530 2025-03-18 09:30:12.719
2 SPY 564.500 2025-03-18 09:30:22.981
3 SPY 564.500 2025-03-18 09:30:33.226
4 SPY 564.250 2025-03-18 09:30:43.459
... ... ... ...
209719 SPY 671.620 2025-10-21 13:30:42.875
209720 SPY 671.670 2025-10-21 13:30:53.149
209721 SPY 671.685 2025-10-21 13:31:02.582
209722 SPY 671.660 2025-10-21 13:31:12.660
209723 SPY 671.730 2025-10-21 13:31:23.047
[209724 rows x 3 columns]
Number of rows quotes: 209724
Figure: Notice data is for every 10 seconds - this will create slippage.
Curate the data a bit and then calculate the 15m high and low for each day, for possible use later
# Convert to datetime if not already
df_quotes_processed['quote_datetime'] = pd.to_datetime(df_quotes_processed['quote_datetime'])
df_quotes_processed['date'] = df_quotes_processed['quote_datetime'].dt.date
# Group by each trading day
days = [g for _, g in df_quotes_processed.groupby('date')]
# Compute first 15-minute high and low for each day
results = []
for day_df in days:
start_time = day_df['quote_datetime'].iloc[0].replace(hour=9, minute=30, second=0)
end_time = start_time + pd.Timedelta(minutes=15)
first_15 = day_df[
(day_df['quote_datetime'] >= start_time) &
(day_df['quote_datetime'] < end_time)
]
high_15 = first_15['last_price'].max()
low_15 = first_15['last_price'].min()
results.append({
'date': day_df['date'].iloc[0],
'high_15': high_15,
'low_15': low_15
})
df_first15 = pd.DataFrame(results)
print(df_first15.head())
Quick data curation.
Now the meat and potatoes, the actual strategy function
def simulate_breakout(df, breach_buffer=0.25, target=0.50, stop_offset=0.05):
"""
Simulates a breakout trading strategy based on the first 15 minutes of trading.
Parameters:
- breach_buffer: How far past the 15m high/low price must move to trigger entry
- target: Profit target (distance from entry price)
- stop_offset: Stop loss (distance from entry price)
"""
results = [] # Store all completed trades
# Process each trading day separately
for date_val, day_df in df.groupby('date'):
# === STEP 1: Define the first 15 minutes (9:30 AM - 9:45 AM) ===
start = pd.Timestamp.combine(date_val, pd.Timestamp("09:30").time())
end = start + pd.Timedelta(minutes=15) # 9:45 AM
# Filter data to get only the first 15 minutes
first_15 = day_df[(day_df['quote_datetime'] >= start) &
(day_df['quote_datetime'] < end)]
# Skip this day if there's no data in the first 15 minutes
if first_15.empty:
continue
# === STEP 2: Calculate the 15-minute range ===
high_15 = first_15['last_price'].max() # Highest price in first 15 min
low_15 = first_15['last_price'].min() # Lowest price in first 15 min
# Get all data after 9:45 AM (when we start looking for breakouts)
after_15 = day_df[day_df['quote_datetime'] >= end]
# === STEP 3: Initialize trade tracking variables ===
direction = None # 'long', 'short', or None (no trade yet)
entry_price = exit_price = entry_time = exit_time = None
# === STEP 4: Loop through each price tick after 9:45 AM ===
for _, row in after_15.iterrows():
price, t = row['last_price'], row['quote_datetime']
# --- Case A: No position yet, looking for entry ---
if direction is None:
# LONG ENTRY: Price breaks above the 15m high + buffer
if price >= high_15 + breach_buffer:
direction = 'long'
entry_price = high_15 + breach_buffer # Entry at breakout level
entry_time = t
stop = entry_price - stop_offset # Stop loss below entry
target_price = entry_price + target # Profit target above entry
# SHORT ENTRY: Price breaks below the 15m low - buffer
elif price <= low_15 - breach_buffer:
direction = 'short'
entry_price = low_15 - breach_buffer # Entry at breakdown level
entry_time = t
stop = entry_price + stop_offset # Stop loss above entry
target_price = entry_price - target # Profit target below entry
# --- Case B: Already in a LONG position, check for exit ---
elif direction == 'long':
# Exit if stop loss hit OR target reached
if price <= stop or price >= target_price:
exit_price, exit_time = price, t
break # Exit the loop, trade is done for the day
# --- Case C: Already in a SHORT position, check for exit ---
elif direction == 'short':
# Exit if stop loss hit OR target reached
if price >= stop or price <= target_price:
exit_price, exit_time = price, t
break # Exit the loop, trade is done for the day
# === STEP 5: Record the trade if both entry and exit occurred ===
if entry_price is not None and exit_price is not None:
# Calculate profit/loss
# Long: profit = exit - entry (sell higher than buy)
# Short: profit = entry - exit (buy back lower than sell)
pnl = (exit_price - entry_price) if direction == 'long' else (entry_price - exit_price)
# Store all trade details
results.append({
'date': date_val,
'strategy': 'breakout',
'direction': direction,
'entry_price': entry_price,
'exit_price': exit_price,
'pnl': pnl,
'entry_time': entry_time,
'exit_time': exit_time,
'high_15': high_15,
'low_15': low_15
})
# === STEP 6: Return all trades as a DataFrame ===
return pd.DataFrame(results)
Figure: Strategy, without slippage.
Run the function on the SPY dataframe
print(f"Total trades: {len(df_breakout)}")
print(f"Total PnL: ${df_breakout['pnl'].sum():.2f}")
print(f"Average PnL: ${df_breakout['pnl'].mean():.2f}")
print(f"Win rate: {(df_breakout['pnl'] > 0).sum() / len(df_breakout) * 100:.1f}%")
Total trades: 112
Total PnL: $16.85
Average PnL: $0.15
Win rate: 38.4%
This seems a little too good to be true, so I'm going to modify the strategy to include slippage:
def simulate_breakout(df, breach_buffer=0.25, target=0.50, stop_offset=0.05,
entry_slippage=0.02, exit_slippage=0.02):
"""
Simulates a breakout trading strategy with realistic slippage.
Parameters:
- breach_buffer: How far past the 15m high/low price must move to trigger entry
- target: Profit target (distance from entry price)
- stop_offset: Stop loss (distance from entry price)
- entry_slippage: Adverse slippage on entry (you get filled worse)
- exit_slippage: Adverse slippage on exit (you get filled worse)
"""
results = [] # Store all completed trades
# Process each trading day separately
for date_val, day_df in df.groupby('date'):
# === STEP 1: Define the first 15 minutes (9:30 AM - 9:45 AM) ===
start = pd.Timestamp.combine(date_val, pd.Timestamp("09:30").time())
end = start + pd.Timedelta(minutes=15) # 9:45 AM
# Filter data to get only the first 15 minutes
first_15 = day_df[(day_df['quote_datetime'] >= start) &
(day_df['quote_datetime'] < end)]
# Skip this day if there's no data in the first 15 minutes
if first_15.empty:
continue
# === STEP 2: Calculate the 15-minute range ===
high_15 = first_15['last_price'].max() # Highest price in first 15 min
low_15 = first_15['last_price'].min() # Lowest price in first 15 min
# Get all data after 9:45 AM (when we start looking for breakouts)
after_15 = day_df[day_df['quote_datetime'] >= end]
# === STEP 3: Initialize trade tracking variables ===
direction = None # 'long', 'short', or None (no trade yet)
entry_price = exit_price = entry_time = exit_time = None
# === STEP 4: Loop through each price tick after 9:45 AM ===
for _, row in after_15.iterrows():
price, t = row['last_price'], row['quote_datetime']
# --- Case A: No position yet, looking for entry ---
if direction is None:
# LONG ENTRY: Price breaks above the 15m high + buffer
if price >= high_15 + breach_buffer:
direction = 'long'
# Entry with slippage - you pay MORE than the breakout level
entry_price = high_15 + breach_buffer + entry_slippage
entry_time = t
stop = entry_price - stop_offset # Stop loss below entry
target_price = entry_price + target # Profit target above entry
# SHORT ENTRY: Price breaks below the 15m low - buffer
elif price <= low_15 - breach_buffer:
direction = 'short'
# Entry with slippage - you sell at LESS than the breakdown level
entry_price = low_15 - breach_buffer - entry_slippage
entry_time = t
stop = entry_price + stop_offset # Stop loss above entry
target_price = entry_price - target # Profit target below entry
# --- Case B: Already in a LONG position, check for exit ---
elif direction == 'long':
# Stop loss hit - exit with slippage (sell at worse price)
if price <= stop:
exit_price = price - exit_slippage # Slippage makes it worse
exit_time = t
break
# Target hit - exit with slippage (sell at worse price)
elif price >= target_price:
exit_price = target_price - exit_slippage # Get filled below target
exit_time = t
break
# --- Case C: Already in a SHORT position, check for exit ---
elif direction == 'short':
# Stop loss hit - exit with slippage (buy back at worse price)
if price >= stop:
exit_price = price + exit_slippage # Slippage makes it worse
exit_time = t
break
# Target hit - exit with slippage (buy back at worse price)
elif price <= target_price:
exit_price = target_price + exit_slippage # Get filled above target
exit_time = t
break
# === STEP 5: Record the trade if both entry and exit occurred ===
if entry_price is not None and exit_price is not None:
# Calculate profit/loss
# Long: profit = exit - entry (sell higher than buy)
# Short: profit = entry - exit (buy back lower than sell)
pnl = (exit_price - entry_price) if direction == 'long' else (entry_price - exit_price)
# Store all trade details
results.append({
'date': date_val,
'strategy': 'breakout',
'direction': direction,
'entry_price': entry_price,
'exit_price': exit_price,
'pnl': pnl,
'entry_time': entry_time,
'exit_time': exit_time,
'high_15': high_15,
'low_15': low_15
})
# === STEP 6: Return all trades as a DataFrame ===
return pd.DataFrame(results)
Figure: Strategy, with slippage.
# Run with default slippage of $0.02 on entry and exit
df_breakout = simulate_breakout(df_quotes_processed, entry_slippage=0.02, exit_slippage=0.02)
# Check results
wins = df_breakout[df_breakout['pnl'] > 0]['pnl']
losses = df_breakout[df_breakout['pnl'] < 0]['pnl']
print(f"Total trades: {len(df_breakout)}")
print(f"Total PnL: ${df_breakout['pnl'].sum():.2f}")
print(f"Average PnL: ${df_breakout['pnl'].mean():.2f}")
print(f"Win rate: {(df_breakout['pnl'] > 0).sum() / len(df_breakout) * 100:.1f}%")
print("")
print(f"Average win: ${wins.mean():.2f}")
print(f"Average loss: ${losses.mean():.2f}")
print(f"Win/Loss ratio: {abs(wins.mean() / losses.mean()):.2f}")
print(f"Standard deviation of returns: ${returns.std():.2f}")
Total trades: 112
Total PnL: $6.87
Average PnL: $0.06
Win rate: 33.0%
Average win: $0.48
Average loss: $-0.15
Win/Loss ratio: 3.31
Standard deviation of returns: $0.31
Checking the Sharpe, daily and annualized
#SHARPE RATIO
import numpy as np
returns = df_breakout['pnl']
risk_free = 0.0
# Sharpe ratio per trade
sharpe_ratio = (returns.mean() - risk_free) / returns.std(ddof=1) # Use ddof=1 for sample std
# Find out how many unique trading days you have
unique_days = df_breakout['date'].nunique()
print(f"Total trading days in dataset: {unique_days}")
# Calculate trades per day
trades_per_day = len(df_breakout) / unique_days
print(f"Average trades per day: {trades_per_day:.2f}")
# Annualize based on actual trading frequency
# Assuming 252 trading days per year
trades_per_year = trades_per_day * 252
annualized_sharpe = sharpe_ratio * np.sqrt(trades_per_year)
print(f"Sharpe ratio (per trade): {sharpe_ratio:.2f}")
print(f"Expected trades per year: {trades_per_year:.0f}")
print(f"Annualized Sharpe ratio: {annualized_sharpe:.2f}")
Total trading days in dataset: 106
Average trades per day: 1.00
Sharpe ratio (per trade): 0.13
Expected trades per year: 252
Annualized Sharpe ratio: 2.01
Summary
Based on prior analyses, the 2.01 annualized Sharpe is likely inflated by optimistic assumptions. Real-time slippage would be larger, and a $0.05 stop is too tight for SPY’s normal noise and will be hit frequently.
If iterating, I’d first widen the stop, then test a small set of filters/indicators. Also, I'd have to pull a larger dataset in from something like Alpaca or Polygon.io.
Still, it was worthwhile to validate the Reddit claim with actual data and exact parameters, and I might revisit the strategy in the near future.