Bottom line: when there’s a gap between this week’s open and last week’s close, 79.2% of weeks trade back to last week’s close within the same week, and more than half (54.4%) do it on the first day.
The smaller the gap, the more likely it fills: gaps under 10% of the average weekly range fill the same week 95% of the time; gaps over 30% fill only a little more than half the time.
How I use it: when Monday opens not far from last week’s close, last week’s close is one of the most reliable targets for the week. If it hasn’t been touched by the close of the third trading day (usually Wednesday), it probably won’t be touched that week.
ES often opens on Monday not far from Friday’s close, leaving a gap in between. Does last week’s close get touched again this week? How long does it take? I ran the numbers on cleaned ES RTH data from 2010 to 2026, and added a daily version: does yesterday’s close get touched again today?
2010–2026 · last week's close is touched again this week
79.2%
Sample 768 gaps · filled on the first day: 54.4%
By gap sizeGap size = |this week's open − last week's close| ÷ average weekly range (high − low) of the last 8 weeks. The number on each bar is the share filled the same week.
How long after the open the gap fillsX axis: time since the week's open (81 five-minute RTH bars a day). Y axis: share of all gaps already filled. Dashed line: end of day 1.
RTH only (09:30–16:15 ET). Filled = some bar's high and low straddle last week's (yesterday's) close. Pairs that span a contract roll are skipped. Hover (or tap) the charts to see the numbers.
Weeks with a gap: about 80% fill
- Over 16 years there were 768 weekly gaps (420 up, 348 down). Only 10 weeks opened exactly at last week’s close, with no gap.
- 79.2% traded back to last week’s close the same week.
- Of the ones that filled, 68.8% filled on the first day and 84.7% within the first two days.
So in weeks with a gap, about 80% return to last week’s close within the week, and most do it quickly.
The bigger the gap, the less likely it fills
I measure gap size against the average weekly range over the last 8 weeks, so 2010 and 2026 prices can be compared directly.
| Gap size | Gaps | Filled same week |
|---|---|---|
| 0–5% | 149 | 94.0% |
| 5–10% | 162 | 95.7% |
| 10–15% | 121 | 77.7% |
| 15–20% | 89 | 76.4% |
| 20–30% | 121 | 67.8% |
| 30–50% | 91 | 57.1% |
| 50–100% | 24 | 54.2% |
| 100% and up | 11 | 36.4% |
- 10% is a dividing line: gaps under 10% almost always fill (95%); above 10%, the rate drops straight to about 77%.
- Past 30%, it’s only a little better than a coin flip: last week’s close is too far away to treat as a “very likely” target.
- The last two buckets are tiny (24 and 11 gaps), so read those rates loosely.
When gaps fill: mostly on day 1
- 54.4% of gaps fill on the first day, which is 68.8% of all fills. 12.6% fill on the very first 5-minute bar, and 36.7% within the first hour.
- By the close of day 3, 74.2% have filled, which is 93.8% of all fills.
- Of the gaps still open after day 3 (25.8%), only about one in five fills on Thursday or Friday.
So last week’s close “pulls” mainly in the first two or three days of the week. If it hasn’t been touched by the close of the third trading day, don’t count on it anymore.
Gaps down fill more often
Gaps down (lower opens) fill the same week 83.6% of the time; gaps up only 75.5%.
One likely reason is that ES rose for most of these 16 years: lower opens tend to get bought back, while higher opens sometimes just keep going and never look back.
Daily version: yesterday’s close
Switch to “Daily” and the rules stay the same, with “week” replaced by “day”: when there’s a gap between today’s open and yesterday’s close, does today’s RTH session trade back to yesterday’s close?
- Of 3,774 gaps, 61.6% fill the same day, well below the weekly rate.
- 16.2% fill on the first 5-minute bar, 41.3% within the first hour, and 49.3% by 11:30. After that, fills come more and more slowly.
- Again, smaller gaps fill more often: gaps under 10% of the average daily range fill the same day 95% of the time; gaps of 50–100% only 34.5%; gaps bigger than a whole day’s range only 11.7%.
- Gaps down fill 64.2% of the time and gaps up 59.5%, the same direction as the weekly result.
The daily fill rate is lower mainly because overnight gaps tend to be big relative to a day’s range: 1,865 gaps (almost half) are over 30% of the average daily range, versus only 126 (16%) on the weekly chart.
By year
Each year has only about 48 weekly gaps, so the yearly numbers jump around a lot: the lowest was 2020 at 59.6% and the highest 2023 at 91.7%. Keep the small sample in mind when looking at a single year.
Definition and how to reproduce it
Gap fill
- Data: cleaned 1-minute ES bars, 2010-06 to 2026-09. See how I cleaned it.
- RTH: 09:30–16:15 US Eastern, i.e. 1-minute bars from 09:30 to 16:14. No overnight session: if the overnight session were included, part of the gap would already be filled when trading reopens on Sunday evening, and weekly gaps would be much smaller.
- This week’s open: the 09:30 open of the week’s first trading day (Tuesday if Monday is a holiday). Last week’s close: the RTH close of last week’s last trading day.
- Gap: this week’s open − last week’s close. Gaps of exactly 0 are skipped (10 weeks).
- Filled: from this week’s open on, some bar’s high and low straddle last week’s close. For a gap up, check whether the low reaches last week’s close; for a gap down, the high.
- Gap size: |gap| ÷ the average weekly range (each week’s RTH high − low) over the last 8 weeks.
- Skipped: pairs of weeks on different contracts (the week of a contract roll).
- Daily version: yesterday = the previous stock market trading day, with the same rules as the prior-day breakout post; the average range uses the last 8 days; “filled right away” becomes “filled in the first hour” (09:30–10:30).
I have a CSV of cleaned 1-minute ES futures bars (Databento's ES.c.0 continuous contract, with columns ts_event, instrument_id, open, high, low, close; timestamps in UTC). Please write Python code that checks whether last week's (yesterday's) close gets touched again this week (today). Rules: 1. Convert timestamps to US Eastern time (US/Eastern), handling daylight saving time automatically. Use RTH only, 09:30–16:15, meaning 1-minute bars from 09:30 to 16:14; no overnight session. 2. Each day's open = the open of the 09:30 bar, close = the close of the 16:14 bar, high / low = the highest high / lowest low of the RTH bars. 3. Weekly: a week runs Monday to Friday. This week's open = the open of the week's first trading day; last week's close = the close of last week's last trading day. Gap = this week's open − last week's close; skip gaps of exactly 0. 4. Filled: from this week's open on, any day (any bar) whose high and low straddle last week's close. For a gap up, check whether the low ≤ last week's close; for a gap down, whether the high ≥ last week's close. Also record whether it filled on the first day. 5. Gap size = |gap| ÷ the average weekly range (each week's high − low) over the last 8 weeks, in 8 buckets: 0–5%, 5–10%, 10–15%, 15–20%, 20–30%, 30–50%, 50–100%, 100% and up. 6. Skip: weeks where last week is missing, and weeks where last week's last day and this week's first day have different instrument_id values (a contract roll). If the contract rolls mid-week, ignore the days after the roll. 7. Daily: replace "week" with "day". Yesterday = the most recent stock market trading day before today, based on the NYSE calendar (pandas' built-in holiday rules plus the unscheduled closures on 2012-10-29, 2012-10-30, 2018-12-05 and 2025-01-09). If that day isn't in the data, or the two days are on different contracts, skip today. Use the last 8 days for the average range, and change "filled right away" to "filled in the first hour (09:30–10:30)". 8. For both weekly and daily, print: the number of gaps, the share filled in the same period, the share filled right away, the fill rate for gaps up and gaps down, and the number of gaps and fill rate in each bucket. Please add comments to every step explaining why it's done that way.
# Gap fill: does this week (today) trade back to last week's (yesterday's) close?
# Input: cleaned 1-minute ES bars (Databento ES.c.0, timestamps in UTC, with an instrument_id column)
import numpy as np
import pandas as pd
from pandas.tseries.holiday import (
AbstractHolidayCalendar, Holiday, GoodFriday, USMartinLutherKingJr, USPresidentsDay,
USMemorialDay, USLaborDay, USThanksgivingDay, nearest_workday, sunday_to_monday,
)
class NYSE(AbstractHolidayCalendar): # NYSE full-day closures
rules = [
Holiday("New Year's Day", month=1, day=1, observance=sunday_to_monday),
USMartinLutherKingJr, USPresidentsDay, GoodFriday, USMemorialDay,
Holiday("Juneteenth", month=6, day=19, start_date="2022-01-01", observance=nearest_workday),
Holiday("Independence Day", month=7, day=4, observance=nearest_workday),
USLaborDay, USThanksgivingDay,
Holiday("Christmas", month=12, day=25, observance=nearest_workday),
]
CLOSED = pd.to_datetime(["2012-10-29", "2012-10-30", "2018-12-05", "2025-01-09"]) # unscheduled closures
EDGES = [0, 5, 10, 15, 20, 30, 50, 100, np.inf] # gap-size buckets (% of average range)
df = pd.read_csv("ES-1min_clean.csv", usecols=["ts_event", "instrument_id", "open", "high", "low", "close"])
t = pd.to_datetime(df.ts_event, utc=True).dt.tz_convert("US/Eastern").dt.tz_localize(None)
hm = t.dt.hour * 100 + t.dt.minute
rth = df[(hm >= 930) & (hm < 1615)].assign(date=t.dt.normalize(), h1=hm < 1030) # RTH 09:30–16:15
g = rth.groupby("date")
day = g.agg(o=("open", "first"), h=("high", "max"), l=("low", "min"), c=("close", "last"), inst=("instrument_id", "first"))
first_hour = rth[rth.h1].groupby("date").agg(h=("high", "max"), l=("low", "min")) # first hour of RTH, 09:30–10:30
def report(name, gaps):
gaps["bucket"] = pd.cut(gaps["size"], EDGES, right=False)
print(f"== {name}: {len(gaps)} gaps, filled in the same period {100 * gaps['filled'].mean():.1f}%, "
f"filled right away {100 * gaps['early'].mean():.1f}%")
by_dir = gaps.groupby("up")["filled"].mean() * 100
print(f"Gap up {by_dir[True]:.1f}%, gap down {by_dir[False]:.1f}%")
print(gaps.groupby("bucket", observed=True).agg(gaps=("filled", "size"), filled=("filled", "mean"), early=("early", "mean")).round(3).to_string())
# —— Weekly: this week's open vs last week's close ——
day["wk"] = day.index - pd.to_timedelta(day.index.dayofweek, unit="D") # Monday of this week
weeks = []
for wk, d in day.groupby("wk"):
d = d[d.inst == d.inst.iloc[0]] # if the contract rolls mid-week, ignore the days after the roll
weeks.append(dict(wk=wk, d=d, o=d.o.iloc[0], c=d.c.iloc[-1], rng=d.h.max() - d.l.min(),
i0=d.inst.iloc[0], i1=d.inst.iloc[-1]))
rows = []
for i in range(8, len(weeks)):
a, b = weeks[i - 1], weeks[i]
if (b["wk"] - a["wk"]).days != 7 or a["i1"] != b["i0"]:
continue # last week is missing, or the two weeks are on different contracts
gap = b["o"] - a["c"]
if gap == 0:
continue
abr = np.mean([w["rng"] for w in weeks[i - 8:i]]) # average weekly range over the last 8 weeks
hit = (b["d"].l <= a["c"]) if gap > 0 else (b["d"].h >= a["c"]) # which days traded back to last week's close
rows.append({"up": gap > 0, "size": abs(gap) / abr * 100, "filled": hit.any(), "early": hit.iloc[0]})
report("Weekly", pd.DataFrame(rows))
# —— Daily: today's open vs yesterday's close (yesterday = the previous stock market trading day) ——
cal = pd.bdate_range(day.index.min() - pd.Timedelta(days=10), day.index.max())
cal = cal[~cal.isin(NYSE().holidays(cal.min(), cal.max()).union(CLOSED))]
prev = pd.Series(cal[:-1], index=cal[1:]).reindex(day.index)
adr = (day.h - day.l).rolling(8).mean().shift(1) # average daily range over the last 8 days
rows = []
for i in range(8, len(day)):
today, yday = day.iloc[i], day.iloc[i - 1]
if prev.iloc[i] != day.index[i - 1] or today.inst != yday.inst:
continue # yesterday was removed in cleaning, or the two days are on different contracts
gap = today.o - yday.c
if gap == 0:
continue
fh = first_hour.loc[day.index[i]]
up = gap > 0
rows.append({"up": up, "size": abs(gap) / adr.iloc[i] * 100,
"filled": today.l <= yday.c if up else today.h >= yday.c,
"early": fh.l <= yday.c if up else fh.h >= yday.c})
report("Daily", pd.DataFrame(rows))Limitations
- It only checks whether price got there, not what happened next: touching last week’s close doesn’t mean price reverses there; it can just as easily run straight through.
- The weekly sample is small: only 768 gaps, fewer still once split by year or by size.
- Half-day sessions were removed: cleaning removed half days like the Friday after Thanksgiving, so for that week “last week’s close” is Wednesday’s close rather than Friday’s half-day close.
- Roll weeks are skipped: the continuous contract isn’t back-adjusted, so prices don’t line up across a roll, and one week per quarter is dropped.
- Holidays aren’t separated: gaps after a long weekend might behave differently from ordinary weekends; I didn’t split them out.