SPY First 15-Minute Breakout Trading Strat

Real-time, single-run intraday backtest that builds the first 15-minute range from SPY quotes, then momentum trades the breakout.

FinanceData ScienceS&P 500SPYIntradayStrategyMomentumBreakoutPythonpandasCronMySQL
October 2025

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.