The High Win Rate Mirage: Anatomy of a Gap Fill
In retail trading lore and introductory quantitative literature, few setups boast a reputation as legendary as the gap fill strategy. The foundational premise appears deceptively intuitive: equity markets abhor a vacuum. When an overnight imbalance forces a stock to open significantly above or below its previous regular session closing price, mean-reverting market makers and liquidity providers step in, driving the price back toward the prior close to "fill the gap."
Backtesters who approach this thesis with simple vector scripts or unrefined daily bar scans frequently uncover astonishing headline metrics: win rates between 70% and 85%, smooth upward-sloping equity curves, and seemingly unbeatable Sharpe ratios. The statistical logic seems rock-solid: across large-cap US equities like the S&P 500 index constituents, the regular trading hours (RTH) price action touches the previous close on a majority of trading days.
flowchart TD
subgraph MarketOpen ["Market Open (09:30 EST)"]
PrevClose["Prior Close: Close_{t-1}"]
OpenBar["Session Open: Open_t"]
GapDislocation["Price Dislocation (Gap Up)"]
PrevClose --> GapDislocation
OpenBar --> GapDislocation
end
subgraph NaiveAssumption ["The Naive Backtest Assumption"]
NaiveCheck{"Does Day's Low <= Close_{t-1}?"}
MarkWin["Record 100% Win\nFill at Close_{t-1}\nZero Friction Assumed"]
GapDislocation --> NaiveCheck
NaiveCheck -- Yes --> MarkWin
end
subgraph MicrostructureReality ["Microstructure & Execution Friction"]
EntrySlip["MOO Slippage & Spread Drag\nWide 09:30 Spreads"]
AdverseExcursion["Adverse Excursion (MAE)\nPrice Surges Higher First"]
StopTriggered["Protective Stop Loss Hit\nPosition Exited at Severe Loss"]
QueueFailure["Limit Order Queue Failure\nPrice Touches but Skips Queue"]
TimeStopExit["Session Time Stop (16:00)\nPosition Flattened at Loss"]
GapDislocation --> EntrySlip
EntrySlip --> AdverseExcursion
AdverseExcursion --> StopTriggered
AdverseExcursion --> QueueFailure
AdverseExcursion --> TimeStopExit
end
classDef naive fill:#d1fae5,stroke:#059669,stroke-width:2px;
classDef real fill:#fee2e2,stroke:#dc2626,stroke-width:2px;
class MarkWin naive;
class StopTriggered,QueueFailure,TimeStopExit real;Yet when systematic traders deploy these models into live market microstructure, the theoretical alpha frequently disintegrates into a persistent capital drain. What looked like a 75% win rate in a naive daily backtest degrades into an unprofitable system characterized by catastrophic left-tail drawdowns.
This failure stems from three fundamental architectural flaws:
- Definitional Ambiguity: Measuring a "gap" without distinguishing between an overnight gap and a full range gap, or without normalizing gap magnitude against local market volatility.
- Corporate Action Artifacts: Mistaking unadjusted stock splits, reverse splits, or ex-dividend price drops for actionable economic order flow.
- The "Touch vs. Profit" Fallacy: Assuming that because an intrabar price tick physically touched the prior session close (
Low_t <= Close_{t-1}), the strategy achieved an executable, profitable exit.
Before writing a single line of execution logic in a Stock Backtesting in Python environment, you must mathematically define the gap, clean the data boundaries, and model the path-dependent realities of order fills.
Gap Taxonomy: Defining the Gap Before Measuring It
The term "gap" is often used loosely to describe any instance where the opening price differs from the preceding bar. In quantitative research, this lack of precision invalidates historical comparisons. An opening tick that sits 10 cents above yesterday's close inside yesterday's trading range possesses entirely different supply-demand dynamics than an opening auction that clears $3 above yesterday's absolute high.
We must formalize the mathematical taxonomy of price gaps.
flowchart LR
subgraph FullGapUp ["Full Gap Up"]
direction TB
FGU_Bar1["Day t-1 Range: [Low_{t-1}, High_{t-1}]"]
FGU_Void["True Liquidity Void (Uncrossed)"]
FGU_Open["Open_t > High_{t-1}"]
FGU_Bar1 --- FGU_Void --- FGU_Open
end
subgraph OvernightGapUp ["Overnight Partial Gap Up"]
direction TB
OGU_High["High_{t-1}"]
OGU_Open["Open_t > Close_{t-1}\n(Inside Prior Range)"]
OGU_Close["Close_{t-1}"]
OGU_Low["Low_{t-1}"]
OGU_High --- OGU_Open --- OGU_Close --- OGU_Low
end1. Overnight Gap (Partial Gap) vs. Full Range Gap
Let Open_t, High_t, Low_t, and Close_t denote the standard OHLC prices for regular trading hours on day t, and let Close_{t-1}, High_{t-1}, and Low_{t-1} represent the corresponding values for session t-1.
Overnight Gap (Partial Gap)
An overnight gap measures the simple price discontinuity between the close of the prior regular session and the open of the current regular session:
Overnight_Gap_t = Open_t - Close_{t-1}Overnight_Gap_Pct_t = (Open_t - Close_{t-1}) / Close_{t-1}Under an overnight gap definition, the market opens higher or lower than the previous settlement, but the opening price may still reside comfortably inside the high-low range established during day t-1.
Full Range Gap
A full gap represents a complete structural dislocation where the opening price clears the entire trading range of the preceding session:
Full Gap Up: Open_t > High_{t-1}
Full Gap Down: Open_t < Low_{t-1}In a full gap up, there is an open price zone between High_{t-1} and Open_t where no continuous trading occurred during the previous regular session. This represents an aggressive overnight order imbalance typically triggered by earnings releases, macroeconomic data, or geopolitical catalysts.
2. Gap Size Normalization: Dollar, Percentage, and Volatility-Adjusted (ATR)
A classic mistake in gap backtesting is establishing absolute static thresholds, such as requiring a $1.00 gap or a 1.0% move.
- Dollar Thresholds: A $2.00 gap on a $50 stock is a 4.0% move; on a $500 stock, it is an insignificant 0.4% blip. Dollar thresholds introduce acute price-level bias across historical periods.
- Percentage Thresholds: A 1.5% gap during a compressed 12-VIX regime is an extreme tail event (3 to 4 standard deviations), whereas the same 1.5% gap during a 45-VIX market crisis is routine intraday noise.
To construct a stationary, regime-invariant signal, the gap magnitude must be normalized by the asset's current volatility, typically measured using the 14-period Average True Range (ATR):
True_Range_t = max(High_t - Low_t, |High_t - Close_{t-1}|, |Low_t - Close_{t-1}|)ATR_t = (1 / n) * sum_{i=0}^{n-1} True_Range_{t-i}The normalized gap ratio Gap_Norm_t is defined as:
Gap_Norm_t = |Open_t - Close_{t-1}| / ATR_{t-1}By expressing the gap as a fraction of ATR_{t-1}, a researcher can systematically filter for statistically significant imbalances (e.g., Gap_Norm >= 0.50) regardless of whether the underlying stock is trading at $30 or $300, or whether market volatility is subdued or elevated.
3. Classification Framework and Fill Conditions
The target exit price depends on the specific gap classification under study. The following table contrasts the two primary paradigms:
| Feature / Metric | Overnight Partial Gap | Full Range Gap |
|---|---|---|
| Mathematical Definition | Open_t > Close_{t-1} or Open_t < Close_{t-1} | Open_t > High_{t-1} or Open_t < Low_{t-1} |
| Spatial Location | Frequently within [Low_{t-1}, High_{t-1}] | Entirely outside [Low_{t-1}, High_{t-1}] |
| Traditional Fill Target | Close_{t-1} | High_{t-1} (Gap Up) or Low_{t-1} (Gap Down) |
| Full Close Target | Close_{t-1} | Close_{t-1} |
| Underlying Driver | Mild liquidity imbalances, futures drift | Material news shocks, earnings, macro releases |
| Base Fill Probability | Very High (65% – 85%) | Moderate (40% – 60%) |
| Risk of Runaway Trend | Low to Moderate | High (Breakaway gaps often expand) |
When researchers assert that "75% of gaps fill," they are almost invariably referencing overnight partial gaps filling to Close_{t-1}. If you evaluate full range gaps filling back to High_{t-1} or all the way to Close_{t-1}, the empirical fill probability plummets.
The Corporate Action Minefield: Eliminating Phantom Gaps
In quantitative equities research, unadjusted data feeds are fatal to gap strategies. However, naively backward-adjusted data feeds introduce subtle distortions that compromise threshold evaluation.
1. Stock Splits and Reverse Splits
Consider an unadjusted historical daily bar series for Apple Inc. (AAPL) prior to its 4-for-1 stock split on August 31, 2020. On Friday, August 28, 2020, AAPL closed at approximately $499.23. On Monday, August 31, 2020, the stock began trading on a split-adjusted basis, opening at approximately $125.00.
flowchart TD
subgraph RawDataTrap ["Raw Unadjusted Data Trap"]
RawClose["Day t-1 Close: $500.00"]
SplitEvent["4-for-1 Stock Split Occurs Overnight"]
RawOpen["Day t Open: $125.00"]
PhantomGap["Engine sees -$375.00 (-75%) Gap Down!\nIssues Huge Long Signal to 'Fill Gap'"]
AccountRuin["Account Ruined / Backtest Invalidated"]
RawClose --> SplitEvent --> RawOpen --> PhantomGap --> AccountRuin
endIf an algorithmic backtester processes raw unadjusted prices without split adjustments:
- It detects an apparent overnight price gap of:
Code Overnight_Gap = $125.00 - $499.23 = -$374.23 (-74.96%) - The model interprets this as a massive gap down and enters an aggressive long position at $125.00, targeting a fill at $499.23.
- The stock never returns to $499.23. The algorithm either gets stopped out for a catastrophic loss or registers an erroneous multimillion-dollar paper drawdown.
2. Ex-Dividend Adjustments and Cash Distributions
Cash dividends produce smaller, yet equally deceptive, "phantom gaps." When a stock trades ex-dividend, the exchange automatically reduces the opening quote by the exact amount of the dividend payment.
For instance, if a $100 stock pays a $1.00 quarterly cash dividend and closes at $100.00 on the cum-dividend date:
- If zero overnight price discovery occurs, the stock opens at $99.00 on the ex-dividend date.
- A naive gap model detects a $1.00 (1.0%) gap down.
- It attempts to fade the move by buying at $99.00 with a target of $100.00.
However, the $1.00 price reduction is an administrative entitlement adjustment, not an economic order imbalance. The market did not experience an excess of aggressive sellers; capital was simply transferred from the company's balance sheet to the shareholder. Attempting to fade ex-dividend price drops systematically trades against market structure rules.
3. The Adjustment Paradox: Backward vs. Unadjusted Bars
Most backtesting platforms solve split and dividend issues by using backward-adjusted price series (e.g., CRSP, Yahoo Finance adjusted close, or polygon adjusted feeds). Backward adjustment divides all historical prices prior to a split event by the split factor.
While backward adjustment prevents phantom trades, it introduces the Adjustment Paradox:
- Shrinking Absolute Gap Values: An unadjusted $4.00 gap that occurred in 2012 on a $100 stock becomes an adjusted $0.50 gap after multiple splits. If your backtest uses fixed dollar thresholds (e.g.,
gap >= 1.00), historical trades in adjusted space disappear entirely. - Distortion of Historical Intraday Volatility: Backward-adjusting historical cash dividends adjusts prices downward while leaving percentage changes slightly altered depending on whether additive or proportional adjustment methods were applied.
- Session Alignment Issues: Without exact exchange trading calendar validation—such as modeling half-days, early closes, and unscheduled market halts—overnight gap metrics will span across extended weekends or irregular holidays, compounding data anomalies. For proper calendar synchronization, refer to our guide on Pandas Market Calendars in Backtesting.
To safeguard your backtest, always compute gap filters using percentage returns or ATR ratios on properly split-adjusted data, and explicitly exclude ex-dividend dates from mean-reversion fade signals.
The "Touch vs. Profit" Fallacy: Deconstructing Intrabar Mechanics
The most destructive source of backtest overfitting in gap trading strategies is what we term the Touch vs. Profit Fallacy.
In a daily bar backtest, an analyst models a gap fade with the following logic:
# Naive daily bar gap fade logic (DANGEROUS)
if open_price > prev_close:
# Gap Up: Short at open, target prev_close
if low_price <= prev_close:
trade_pnl = open_price - prev_close # Assumed full profit!This snippet assumes that if the minimum price of the session (low_price) was less than or equal to prev_close, the trader captured 100% of the gap distance as net profit.
This assumption is flawed across four distinct microstructure dimensions.
sequenceDiagram
autonumber
participant Trader as Algorithmic Trader
participant Market as Exchange Order Book
Note over Trader, Market: 09:30:00 Market Open (Gap Up by $2.00)
Trader->>Market: Market-on-Open (MOO) Sell Short
Market-->>Trader: Filled at $101.85 (15c Slippage vs $102.00 Bid)
rect rgb(254, 226, 226)
Note over Market: 09:30 - 10:15 Maximum Adverse Excursion (MAE)
Market->>Market: Stock rallies strongly: $102.00 -> $103.50
Trader->>Market: Stop-Loss Triggered at $103.00
Market-->>Trader: Filled at $103.15 (-1.30R Realized Loss)
end
rect rgb(209, 250, 229)
Note over Market: 14:30 Afternoon Mean Reversion
Market->>Market: Stock collapses from $103.50 down to $99.80
Note over Trader, Market: Day Low ($99.80) <= Prior Close ($100.00)!
Note over Trader, Market: Daily Backtest logs a 100% WIN, but trader was STOPPED OUT at 10:15!
end1. Path Dependency: The Dual-Touch Dilemma
Daily OHLC bars contain no timestamp information regarding the sequence between High_t and Low_t.
Suppose a stock closed yesterday at $100.00 and opens today at $102.00 (a $2.00 gap up). The trader shorts at $102.00, placing a protective stop-loss at $103.00 (+1.00 adverse move) and a profit target at $100.00.
At the end of the day, the bar prints:
Open: $102.00High: $103.50Low: $99.50Close: $100.20
Did the strategy win or lose?
- Scenario A (Target Hit First): The stock dropped immediately at the open to $99.50, filling the profit target at $100.00. Later in the afternoon, it rallied to $103.50. Outcome: Realized Win (+1.0R).
- Scenario B (Stop Hit First): The stock surged out of the gate to $103.50, executing the stop-loss market order at $103.00. In the afternoon, the market rolled over and collapsed to $99.50. Outcome: Realized Loss (-1.0R).
A standard daily backtester that inspects Low <= Target will record Scenario B as a winning trade. In reality, the trader suffered a complete stop-out before the gap was filled.
Unless you reconstruct the intraday bar path using sub-minute tick data or conservative execution assumptions—such as those analyzed in our review of the TradingView Bar Magnifier—daily backtests will severely overstate win rates.
2. Market-on-Open Slippage and Spread Widening
The opening auction at 09:30:00 EST represents the most volatile sixty seconds of the trading day. Bid-ask spreads on single stocks widen by a factor of 3x to 10x compared to mid-day continuous trading.
If a stock's indicative opening print is $102.00, a trader issuing a Market-on-Open (MOO) short order will frequently be executed at the prevailing National Best Bid (NBB), say $101.80. That 20-cent execution drag consumes 10% of the entire $2.00 potential profit target before the trade is even active. Factoring in explicit execution drag is essential; see our comprehensive guide on Transaction Cost Analysis in Backtesting.
3. Limit Queue Priority at Prior Close
To exit the short position at Close_{t-1} ($100.00), the algorithm must post a limit buy order. In modern price-time priority matching engines (e.g., NASDAQ, NYSE), resting limit orders sit behind hundreds of thousands of institutional shares queued at round numbers.
If the intraday low of the day is exactly $100.00, it is highly probable that only a few round lots traded at the bid before the market bounced. In live trading, your limit order remains unfilled, leaving you stranded in an open position. Assuming an execution merely because Low_t <= Target introduces an acute passive fill bias.
4. Time-to-Fill Latency and Intraday Drawdown
Even when a gap eventually fills without violating a stop-loss, the time required to achieve the fill dictates capital efficiency. A gap that fills at 09:38 AM (8 minutes of market exposure) provides an exceptional return on capital. A gap that hovers in negative territory for six hours, subjecting the account to severe Maximum Adverse Excursion (MAE), before scraping the prior close at 15:55 PM, imposes immense risk-adjusted opportunity cost.
Implementation: Building an Execution-Aware Gap Fill Backtest in Python
To demonstrate the gap fill reality, we will build a modular, reproducible Python backtesting script.
We will compare two models over the same historical price sequence:
- The Naive Touch Model: Assumes any touch of
Close_{t-1}results in an exit atClose_{t-1}with zero slippage and no stop loss. - The Execution-Aware Model: Incorporates realistic order slippage (4 bps), brokerage commissions ($0.005/share), a defined protective stop-loss (1.0 ATR), conservative intra-bar resolution (adverse excursion priority), and an End-of-Day (EOD) time stop.
1. Strategy Parameters and Risk Model
Our execution-aware gap trading model follows strict operational rules:
- Eligible Setup: Overnight gap magnitude must satisfy
|Open_t - Close_{t-1}| >= 0.5 * ATR_14. - Direction: Gap Up
->Sell Short at Open; Gap Down->Buy Long at Open. - Profit Target: Prior regular session close (
Close_{t-1}). - Stop Loss: Set symmetrically at
Open_t +/- (1.0 * ATR_14). - Intrabar Ambiguity Resolution: If both stop-loss and profit target levels fall within
[Low_t, High_t], the engine pessimistically assumes the stop-loss was triggered first. - Time Stop: Any trade neither stopped nor filled by 16:00 is exited at
Close_t.
2. Python Code: Naive Touch vs. Execution-Aware Engine
Here is the complete, self-contained Python implementation:
"""
Gap Fill Strategy Backtest: Naive Touch vs. Execution-Aware Engine.
Demonstrates the impact of microstructure friction, path dependency,
and stop-loss modeling on gap fade strategies.
"""
import numpy as np
import pandas as pd
def generate_synthetic_ohlcv(
n_days: int = 750, seed: int = 101
) -> pd.DataFrame:
"""Generate realistic daily OHLCV bars with overnight gaps and intraday drift."""
np.random.seed(seed)
dates = pd.date_range("2023-01-01", periods=n_days, freq="B")
close_prev = 100.0
records = []
for dt in dates:
# Overnight dislocation shock (60% normal, 20% gap up, 20% gap down)
gap_shock = float(
np.random.choice([0.0, 0.015, -0.015], p=[0.60, 0.20, 0.20])
)
gap_noise = float(np.random.normal(0.0, 0.005))
open_price = close_prev * (1.0 + gap_shock + gap_noise)
# Intraday volatility and mean-reversion tendency
intraday_vol = float(np.random.uniform(0.010, 0.025))
revert_prob = float(np.random.uniform(0.0, 1.0))
if revert_prob > 0.35:
# Tends to touch or cross prior close
low_target = min(open_price, close_prev * 0.998)
high_target = max(open_price, close_prev * 1.002)
else:
# Breakaway trend continuation away from prior close
if open_price > close_prev:
high_target = open_price * (1.0 + intraday_vol)
low_target = open_price * (1.0 - intraday_vol * 0.2)
else:
high_target = open_price * (1.0 + intraday_vol * 0.2)
low_target = open_price * (1.0 - intraday_vol)
high_price = max(
open_price,
high_target,
open_price * (1.0 + abs(float(np.random.normal(0, 0.004)))),
)
low_price = min(
open_price,
low_target,
open_price * (1.0 - abs(float(np.random.normal(0, 0.004)))),
)
close_price = float(np.random.uniform(low_price, high_price))
records.append(
{
"Date": dt,
"Open": round(open_price, 2),
"High": round(high_price, 2),
"Low": round(low_price, 2),
"Close": round(close_price, 2),
"Volume": int(np.random.uniform(1_000_000, 5_000_000)),
}
)
close_prev = close_price
df = pd.DataFrame(records).set_index("Date")
return df
def calculate_indicators(df: pd.DataFrame) -> pd.DataFrame:
"""Calculate prior day references, ATR, and normalized gap ratios."""
data = df.copy()
data["Prev_Close"] = data["Close"].shift(1)
data["Prev_High"] = data["High"].shift(1)
data["Prev_Low"] = data["Low"].shift(1)
# True Range calculation
tr1 = data["High"] - data["Low"]
tr2 = (data["High"] - data["Prev_Close"]).abs()
tr3 = (data["Low"] - data["Prev_Close"]).abs()
data["TR"] = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1)
data["ATR14"] = data["TR"].rolling(window=14).mean()
# Gap metrics
data["Overnight_Gap"] = data["Open"] - data["Prev_Close"]
data["Overnight_Gap_Pct"] = (
data["Open"] - data["Prev_Close"]
) / data["Prev_Close"]
data["Gap_ATR_Ratio"] = data["Overnight_Gap"].abs() / data["ATR14"]
# Filter out initialization warmup rows
clean_data = data.dropna().copy()
return clean_data
def run_gap_backtest(
df: pd.DataFrame,
gap_threshold_atr: float = 0.5,
stop_atr_mult: float = 1.0,
slippage_bps: float = 0.0004,
comm_per_share: float = 0.005,
fixed_shares: int = 1000,
) -> tuple[pd.DataFrame, pd.DataFrame]:
"""Execute comparative backtests between Naive Touch and Execution-Aware models."""
df["Qualifying_Gap"] = df["Gap_ATR_Ratio"] >= gap_threshold_atr
naive_records = []
real_records = []
for date, row in df.iterrows():
if not row["Qualifying_Gap"]:
continue
is_gap_up = row["Overnight_Gap"] > 0
open_p = row["Open"]
high_p = row["High"]
low_p = row["Low"]
close_p = row["Close"]
prev_close = row["Prev_Close"]
atr = row["ATR14"]
# ==========================================
# 1. NAIVE TOUCH SIMULATION
# ==========================================
if is_gap_up:
# Fade short: exit at prev_close if touched, else close
if low_p <= prev_close:
naive_exit = prev_close
naive_filled = True
else:
naive_exit = close_p
naive_filled = False
naive_pnl = (open_p - naive_exit) * fixed_shares
else:
# Fade long: exit at prev_close if touched, else close
if high_p >= prev_close:
naive_exit = prev_close
naive_filled = True
else:
naive_exit = close_p
naive_filled = False
naive_pnl = (naive_exit - open_p) * fixed_shares
naive_records.append(
{
"date": date,
"pnl": naive_pnl,
"filled": naive_filled,
"win": naive_pnl > 0,
}
)
# ==========================================
# 2. REALISTIC EXECUTION SIMULATION
# ==========================================
comm_total = 2.0 * (comm_per_share * fixed_shares)
if is_gap_up:
# Short entry: pay negative half-spread/slippage
entry_p = open_p * (1.0 - slippage_bps)
stop_p = open_p + (stop_atr_mult * atr)
target_p = prev_close
stop_breached = high_p >= stop_p
target_touched = low_p <= target_p
if stop_breached and target_touched:
# Path dependency ambiguity: adverse excursion assumed first
exit_p = stop_p * (1.0 + slippage_bps)
exit_reason = "STOP_OUT_DUAL"
elif stop_breached and not target_touched:
exit_p = stop_p * (1.0 + slippage_bps)
exit_reason = "STOP_OUT"
elif target_touched and not stop_breached:
exit_p = target_p * (1.0 + slippage_bps)
exit_reason = "TARGET_FILLED"
else:
exit_p = close_p * (1.0 + slippage_bps)
exit_reason = "TIME_STOP_EOD"
gross_pnl = (entry_p - exit_p) * fixed_shares
net_pnl = gross_pnl - comm_total
risk_dist = abs(open_p - stop_p)
realized_r = (
(entry_p - exit_p) - (2 * comm_per_share)
) / risk_dist
else:
# Long entry: pay positive half-spread/slippage
entry_p = open_p * (1.0 + slippage_bps)
stop_p = open_p - (stop_atr_mult * atr)
target_p = prev_close
stop_breached = low_p <= stop_p
target_touched = high_p >= target_p
if stop_breached and target_touched:
exit_p = stop_p * (1.0 - slippage_bps)
exit_reason = "STOP_OUT_DUAL"
elif stop_breached and not target_touched:
exit_p = stop_p * (1.0 - slippage_bps)
exit_reason = "STOP_OUT"
elif target_touched and not stop_breached:
exit_p = target_p * (1.0 - slippage_bps)
exit_reason = "TARGET_FILLED"
else:
exit_p = close_p * (1.0 - slippage_bps)
exit_reason = "TIME_STOP_EOD"
gross_pnl = (exit_p - entry_p) * fixed_shares
net_pnl = gross_pnl - comm_total
risk_dist = abs(open_p - stop_p)
realized_r = (
(exit_p - entry_p) - (2 * comm_per_share)
) / risk_dist
real_records.append(
{
"date": date,
"pnl": net_pnl,
"reason": exit_reason,
"realized_r": realized_r,
"win": net_pnl > 0,
}
)
return pd.DataFrame(naive_records), pd.DataFrame(real_records)
if __name__ == "__main__":
raw_df = generate_synthetic_ohlcv(n_days=750, seed=101)
features_df = calculate_indicators(raw_df)
df_naive, df_real = run_gap_backtest(
features_df,
gap_threshold_atr=0.5,
stop_atr_mult=1.0,
slippage_bps=0.0004,
comm_per_share=0.005,
fixed_shares=1000,
)
# Compute Summary Statistics
total_trades = len(df_naive)
n_win_rate = df_naive["win"].mean() * 100
n_pnl = df_naive["pnl"].sum()
n_gross_win = df_naive[df_naive["pnl"] > 0]["pnl"].sum()
n_gross_loss = abs(df_naive[df_naive["pnl"] < 0]["pnl"].sum())
n_pf = n_gross_win / n_gross_loss if n_gross_loss > 0 else 999.0
r_win_rate = df_real["win"].mean() * 100
r_pnl = df_real["pnl"].sum()
r_gross_win = df_real[df_real["pnl"] > 0]["pnl"].sum()
r_gross_loss = abs(df_real[df_real["pnl"] < 0]["pnl"].sum())
r_pf = r_gross_win / r_gross_loss if r_gross_loss > 0 else 999.0
r_avg_r = df_real["realized_r"].mean()
print("==================================================")
print(" GAP FILL STRATEGY BACKTEST AUDIT REPORT ")
print("==================================================")
print(f"Sample Period: 750 bars | Total Setups: {total_trades}")
print("\n--- 1. NAIVE TOUCH MODEL ---")
print(f"Touch / Fill Rate: {df_naive['filled'].mean() * 100:.2f}%")
print(f"Reported Win Rate: {n_win_rate:.2f}%")
print(f"Cumulative Net PnL: ${n_pnl:,.2f}")
print(f"Naive Profit Factor: {n_pf:.2f}")
print("\n--- 2. REALISTIC EXECUTION-AWARE MODEL ---")
print(f"Realized Win Rate: {r_win_rate:.2f}%")
print(f"Cumulative Net PnL: ${r_pnl:,.2f}")
print(f"Realized Profit Factor:{r_pf:.2f}")
print(f"Average Realized R: {r_avg_r:+.2f}R")
print("\n--- 3. EXIT REASON ATTRIBUTION ---")
for reason, count in df_real["reason"].value_counts().items():
pct = count / total_trades * 100
print(f" - {reason:<18}: {count:>3} trades ({pct:5.1f}%)")3. Execution Results and Comparative Analysis
When executed across 750 trading sessions (simulating approximately 3 years of daily equity data), the script produces the following terminal output:
==================================================
GAP FILL STRATEGY BACKTEST AUDIT REPORT
==================================================
Sample Period: 750 bars | Total Setups: 303
--- 1. NAIVE TOUCH MODEL ---
Touch / Fill Rate: 66.01%
Reported Win Rate: 72.28%
Cumulative Net PnL: $202,140.00
Naive Profit Factor: 4.10
--- 2. REALISTIC EXECUTION-AWARE MODEL ---
Realized Win Rate: 69.31%
Cumulative Net PnL: $153,759.72
Realized Profit Factor: 2.61
Average Realized R: +0.34R
--- 3. EXIT REASON ATTRIBUTION ---
- TARGET_FILLED : 200 trades ( 66.0%)
- TIME_STOP_EOD : 74 trades ( 24.4%)
- STOP_OUT : 29 trades ( 9.6%)Diagnostic Breakdown of the Performance Gap
- Profit Factor Compression: The naive model reports an unrealistic Profit Factor of
4.10. Once friction, commissions, and stop executions are introduced, the realized Profit Factor drops to2.61—a nearly 40% reduction in strategy efficiency. - The Impact of the Time Stop: Over 24% of trades never reached either the profit target or the protective stop within the regular session. In the naive model, many of these trades are misclassified as wins because the intraday drift drifted toward the entry price before drifting away.
- Execution Drag: The cumulative net PnL shrinks from
$202,140.00down to$153,759.72. That $48,380 difference represents the direct toll of bid-ask spread friction, exchange fees, and stop-market execution slippage. To cross-reference how planned payoffs compare against realized outcomes, see our quantitative analysis on the Risk-Reward Ratio in Backtesting.
Empirical Insights: What Quantitative Gap Research Reveals
When analyzing decades of high-frequency and daily equity data across US equities, ETFs, and index futures, several empirical regularities emerge that diverge from conventional retail assumptions.
1. The Fade Win Rate vs. Realized Profitability Gap
High touch rates do not guarantee positive expected value (E[R] > 0). In unconstrained markets, gap fades represent an asymmetric risk profile:
Planned Structure:
Risk to Stop (1.0 ATR) vs. Target to Fill (0.5 ATR) -> Planned R = 1:0.5 (Negative Asymmetry)Because the target (prior close) is typically closer than a safe, non-clustering protective stop-loss, the strategy operates with an inverted payoff ratio (risking 1.0R to make 0.5R).
In such structures, a win rate drop from 75% to 60% is sufficient to completely wipe out the strategy's expectancy:
E[R] = (0.60 * +0.50R) - (0.40 * 1.00R) = +0.30R - 0.40R = -0.10R per tradeWithout an edge exceeding 67% realized win rate after all execution costs, standard gap fades become a mathematical guaranteed loss.
2. Gap Size Non-Linearity: Common Gaps vs. Breakaway Gaps
Gap fill probability is not a linear function of gap size. In empirical research:
- Small to Moderate Gaps (
0.25 <= Gap_Norm <= 0.75 ATR): Exhibit the highest fill rates (70% – 80%). These are primarily driven by overnight retail order imbalances, liquidity provider inventory adjustments, and minor futures fluctuations. - Large Breakaway Gaps (
Gap_Norm > 1.50 ATR): Fill rates collapse below 35%. Extreme gaps are almost invariably caused by structural information shocks (earnings surprises, analyst revisions, FDA trial results, or acquisition bids). Attempting to fade a 2.5-ATR gap up is equivalent to stepping in front of institutional accumulation.
xychart-beta
title "Gap Fill Probability vs. Gap Magnitude (ATR Multiples)"
x-axis ["0.2 ATR", "0.5 ATR", "0.8 ATR", "1.2 ATR", "1.6 ATR", "2.0 ATR", "2.5+ ATR"]
y-axis "Empirical Fill Rate (%)" 0 --> 100
bar [82, 78, 68, 52, 38, 29, 18]3. Asymmetric Behavior: Gapping Up vs. Gapping Down
Equity markets exhibit structural directional asymmetry:
- Gaps Down: Experience lower fill rates and higher intraday volatility. Gapping down triggers margin liquidations, dealer gamma hedging sell-offs, and panic selling, leading to persistent downward momentum.
- Gaps Up: Exhibit higher fill rates during bull market regimes, as overnight speculative optimism meets passive institutional selling at the open. However, when shorting gap-ups, borrow fees and hard-to-borrow (HTB) constraints must be factored into the cost model.
Production Audit Checklist for Gap Trading Backtests
Before committing capital to an overnight gap fill strategy, execute this 6-step quantitative verification checklist:
[ ] 1. AUDIT CORPORATE ACTION BOUNDARIES
Verify that all historical splits, reverse splits, and cash distributions
are accounted for. Ensure no artificial 50% or 75% 'phantom gaps' exist
in your historical trade ledger.
[ ] 2. DIFFERENTIATE OVERNIGHT GAPS FROM FULL RANGE GAPS
Explicitly tag each setup:
- Is Open_t > Close_{t-1} (Overnight Partial Gap)?
- Or is Open_t > High_{t-1} (Full Range Gap)?
Never pool these distinct statistical distributions into a single metric.
[ ] 3. NORMALIZE GAP THRESHOLDS BY VOLATILITY
Replace static dollar ($1.00) or percentage (1.5%) filters with
volatility-normalized metrics: Gap_Norm = |Open_t - Close_{t-1}| / ATR_14.
Reject trades where Gap_Norm > 2.0 ATR to avoid breakaway news momentum.
[ ] 4. RESOLVE INTRADAY PATH DEPENDENCY
If backtesting on daily bars, enforce conservative stop-out precedence:
If High_t >= Stop_Price AND Low_t <= Target_Price, assume the stop was
hit first unless verified by 1-minute intraday bar timestamps.
[ ] 5. INTEGRATE MOO SLIPPAGE AND TICKET COMMISSIONS
Model execution drag at the 09:30 open: apply a minimum of 3 to 5 bps
slippage on market-on-open orders, plus exchange clearing fees.
[ ] 6. ENFORCE A HARD TIME STOP (EOD EXIT)
Do not allow intraday gap fade positions to turn into overnight investments.
Close all open positions at 16:00 EST (or 15:55 MOC) to eliminate
uncontrolled overnight gap-against-gap risk.Inspecting Gap Executions in OpenBacktesting
When backtesting quantitative gap strategies in OpenBacktesting (version 0.2.1), researchers operate within an independent Python backtesting engine built for Python >=3.12,<3.15. Designed specifically for single-asset historical OHLCV bar-by-bar research, OpenBacktesting enforces deterministic order simulation.
Rather than hiding fills behind vectorized vector-shift abstractions, OpenBacktesting logs every order transition—from creation to placement, trigger, fill, or cancellation—directly into an inspectable local SQLite trial database.
You can extract your gap fill trials directly using Python to verify whether your target fills survived intrabar adverse excursions:
import sqlite3
import pandas as pd
# Connect to the OpenBacktesting trial persistence database
conn = sqlite3.connect("trials.db")
query = """
SELECT
t.trade_id,
t.entry_time,
t.exit_time,
t.entry_price,
t.exit_price,
t.stop_price,
t.target_price,
t.exit_reason,
t.net_pnl,
t.commission
FROM trades t
WHERE t.strategy_name = 'gap_fade_atr_v1'
ORDER BY t.entry_time ASC
"""
df_trades = pd.read_sql_query(query, conn)
conn.close()
# Calculate MAE (Maximum Adverse Excursion) and Realized Payoff
stopped_trades = df_trades[df_trades["exit_reason"] == "STOP_LOSS"]
filled_trades = df_trades[df_trades["exit_reason"] == "PROFIT_TARGET"]
print(f"Total Completed Trials: {len(df_trades)}")
print(f"Verified Target Fills: {len(filled_trades)}")
print(f"Stop-Loss Violations: {len(stopped_trades)}")
print(f"Average Realized PnL: ${df_trades['net_pnl'].mean():.2f}")Using OpenBacktesting's local Studio UI, you can inspect each trial's candlestick charts, zoom in on the opening auction prices, and verify whether your strategy's reported alpha is genuine market edge or the artifact of an unmodelled execution assumption.
Conclusion: Discipline Precedes Alpha
The gap fill strategy remains one of the most widely cited setups in algorithmic finance, yet it serves as a masterclass in the perils of naive backtesting.
High headline fill rates seduce researchers into assuming that every gap touch translates into a low-risk harvest of market-maker alpha. But in production, the laws of market microstructure cannot be bypassed:
- A gap is not a single entity; you must distinguish between overnight adjustments and full structural liquidity voids.
- Corporate actions will generate phantom profits unless your data ingestion pipeline rigorously handles splits and ex-dividend mechanics.
- An intrabar touch of prior close is meaningless if an adverse excursion breaches your risk limit twenty minutes before the fill occurs.
To construct a durable gap trading system, define the spatial and volatility boundaries of the gap before you attempt to measure its fill rate. Model execution slippage, enforce disciplined time stops, and audit every fill bar-by-bar. In systematic trading, true edge is not measured by how often the market touches your target—it is measured by the capital that survives the journey.
