Bottom line: on the 5-minute ES RTH chart, 99.97% of days touch the 20 EMA. In 16 years and 3,857 days, only 2024-01-08 never touched it, the same day Al Brooks pointed out in his live room.
But it doesn’t necessarily happen right away: only 17.3% of days touch it on the first bar, 60.6% by 10:30 and 81.7% by 11:30.
How I use it: when the open is far from the EMA, I can be fairly sure price will come back to it at some point today, but it might take two or three hours. It works as a “will get there today” target, not as a reason to expect an immediate reversal.
“Price comes back to the moving average every day” is something you hear a lot in trading, and Al Brooks says it often in his live room. I used the 5-minute ES chart from 2010 to 2026 to check three things: how many days touch the 20 EMA? When does the first touch come? Once price leaves the EMA, how many bars in a row does it stay away?
2010–2026 · RTH days that touch the 20 EMA
99.97%
Sample 3,857 days · No touch: 2024-01-08
Which bar touches the EMA firstX axis: the close of each 5-minute RTH bar. Y axis: share of days that have touched the EMA by that bar.
Once away from the EMA, how many bars in a rowAway = the whole bar is above the EMA (low > EMA) or below it (high < EMA); consecutive bars form one streak, reset at each open. Y axis: streaks at least this long as a share of all streaks, on a log scale (each gridline down is 10× smaller). Counted by day instead: 56.9% of days have at least one streak of 20+ bars.
20 EMA = 20-period exponential moving average of 5-minute closes, RTH bars only, carried from day to day. Touch = the EMA sits between the bar's high and low. The streak chart counts streaks, not days: in the selected years there are 18,719 streaks above the EMA and 16,191 below, about 9.1 a day, and the percentage in the tooltip is the share of all streaks on that side that lasted at least that many bars. Hover (or tap) the charts to see the numbers.
Only one day in 16 years without a touch
- On 3,856 of 3,857 trading days, price touched the 20 EMA at some point during RTH: 99.97%.
- The only exception was 2024-01-08: a very strong bull day that rallied from the open to the close, with every 5-minute bar’s low above the EMA.
- Every year except 2024 is at 100%.
Al Brooks remembered that day too
I found two passages about it in transcripts of Al Brooks’ 2024 live sessions:
- 2024-03-21: he said 99.9%, or really 99.99%, of days touch the moving average at some point, and he could remember only one day that stayed above it all day without a single touch, “about a month or two ago”.
- 2024-01-09: he opened by saying the previous day was unusual, the first time in his 37 years of trading that he had seen the market go a whole day without touching the moving average.
Both passages refer to January 8, 2024. The data also has only that one day in 16 years, and 99.97% falls right between his 99.9% and 99.99%. What Al said matches the data.
When the first touch comes: not necessarily at the open
| By bar | Time | Already touched |
|---|---|---|
| Bar 1 | 9:35 | 17.3% |
| Bar 6 | 10:00 | 40.5% |
| Bar 12 | 10:30 | 60.6% |
| Bar 18 | 11:00 | 72.7% |
| Bar 24 | 11:30 | 81.7% |
| Bar 36 | 12:30 | 91.6% |
On many days the open is already near the EMA, so touching it on the first bar is common. Could most of the “touches” just be bar 1? No: bar 1 accounts for only about 17%. On more than 80% of days, price leaves the EMA after the open and then comes back to touch it.
But how long it takes to come back varies a lot: 40% of days still haven’t touched it by 10:30, and 10% by midday. So “price will touch the EMA today” is right, but “price will come back to the EMA right after the open” is not.
Away from the EMA: a streak usually lasts 3 bars
When a whole bar is above the EMA (low above the EMA) or below it (high below the EMA), price is “away from the EMA”; consecutive bars like this form a streak.
How streaks are counted: each day, start from 5-minute bar 1. Consecutive bars above the EMA form one streak above; once a bar touches the EMA or moves to the other side, the streak ends, and going back above starts a new streak. A single bar above, followed by a bar that touches the EMA, still counts as a streak (of length 1). A streak still running at the close is cut off there, and counting starts over at the next open. Streaks below work the same way.
For example, a day might look like this (an illustration, not real data):
| Bars | Relative to the EMA | Counted as |
|---|---|---|
| 1–3 | Touch | — |
| 4–9 | Above | Streak 1 above (6 bars) |
| 10 | Touch | — |
| 11–12 | Above | Streak 2 above (2 bars) |
| 13–20 | Below | Streak 1 below (8 bars) |
| 21 | Touch | — |
| 22 on | Above | Streak 3 above |
That day has 3 streaks above and 1 below. Adding up the streaks on all 3,857 days gives these totals:
- Streaks above: 18,719, about 4.9 a day.
- Streaks below: 16,191, about 4.2 a day.
- 34,910 in total, about 9 a day. All the percentages in the chart are based on these streaks. For example, “at least 10 bars in a row, above 21.9%” means 21.9% (4,103) of the 18,719 streaks above lasted at least 10 bars.
As for how long these streaks last:
- The median length is 3 bars.
- 93.5% of streaks are 20 bars or shorter.
- Streaks above the EMA last longer: 8.3% of streaks above reach 20 bars or more, versus 5.9% below; at 40 bars or more, 1.5% above versus 0.6% below. ES rose for most of these 16 years, and uptrends can “hang” above the EMA for longer.
A streak of 20+ bars without touching the EMA is rare as a single streak, but not rare by day: a day has many streaks, and it only takes one long enough, so 56.9% of days have at least one streak of 20 bars or more.
Al Brooks calls this “20 gap bars”: when price goes about 20 or more bars without touching the moving average, the first return to it usually draws buyers near the moving average (sellers in a downtrend). This post only counts how often it happens; it doesn’t test what comes next. That’s for a separate study.
Definition and how to reproduce it
20 EMA
- Data: cleaned 1-minute ES bars, 2010-06 to 2026-09. See how I cleaned it.
- 20 EMA: 20-period exponential moving average of the closes of 5-minute RTH bars (09:30–16:15 US Eastern), alpha = 2 / 21. RTH bars only, carried from day to day, no overnight session.
- 5-minute slots with no trades: during the March 2020 circuit-breaker halts, 7 five-minute bars had no trades; the EMA skips them and stays unchanged.
- Contract rolls: on the first day of a new contract the EMA restarts and that day isn’t counted (66 days, plus the first day of the data, which takes the sample from 3,924 days to 3,857).
- Touch: low ≤ EMA ≤ high, using the EMA’s value after the bar closes, i.e. the value drawn on the chart.
- Away from the EMA: low > EMA (above) or high < EMA (below).
- Streak: consecutive bars all above the EMA (or all below) form a streak, which ends when a bar touches the EMA or moves to the other side. A single bar counts as a streak. Counting restarts at each open, and a streak still running at the close is cut off there.
- Streaks above / below: the number of streaks above (below) the EMA, added up over all days. Over 16 years: 18,719 above and 16,191 below, 34,910 in total, about 9 a day. The note under the chart updates when you change the years.
- Streak share: the percentage for “at least L bars in a row” in the chart = streaks of length ≥ L ÷ all streaks on the same side. It’s counted by streak, not by day.
- By day: a day counts if it has at least one streak of 20 bars or more; 56.9% of days do.
I have a CSV of cleaned 1-minute ES futures bars (Databento's ES.c.0 continuous contract, with columns ts_event, instrument_id, high, low, close; timestamps in UTC). Please write Python code that measures how price touches the 20 EMA on the 5-minute RTH chart. Rules: 1. Convert timestamps to US Eastern time (US/Eastern), handling daylight saving time automatically. Use RTH only, 09:30–16:15, and build 5-minute bars from the 1-minute bars, 81 a day (09:30–09:35 is bar 1). If a 5-minute slot has no trades at all (for example during the March 2020 circuit-breaker halts), that bar is empty. 2. 20 EMA = 20-period exponential moving average of 5-minute closes, alpha = 2 / 21: EMA = alpha × this bar's close + (1 − alpha) × the previous bar's EMA. RTH bars only, carried from day to day, no overnight session. Skip empty bars and leave the EMA unchanged. 3. On a day when the contract rolls (instrument_id changes), restart the EMA from that day's first close and leave the day out of the stats, because old and new contract prices don't line up; same for the first day of the data. 4. Touch: the EMA (its value after this bar closes, i.e. the value drawn on the chart) sits between the bar's high and low, i.e. low ≤ EMA ≤ high. 5. Print: the sample size, the share of days that touched the EMA, the share that touched it on bar 1, and which days never touched it; the cumulative share of days whose first touch came by bars 3, 6, 12, 18, 24 and 36; and the share of days touched in each year. 6. Away from the EMA: the whole bar is above the EMA (low > EMA) or below it (high < EMA). Consecutive bars like this form one streak, reset at each open. Print the total number of streaks, the number above and below, the average per day, the median length, the share of streaks 20 bars or shorter, the share of streaks above and below lasting at least 10, 20 and 40 bars (as a share of all streaks on the same side), and the share of days with at least one streak of 20+ bars. Please add comments to every step explaining why it's done that way.
# 20 EMA: on the 5-minute RTH chart, how many days touch the 20 EMA? Which bar touches it first? How many bars in a row does price stay away from it?
# 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
df = pd.read_csv("ES-1min_clean.csv", usecols=["ts_event", "instrument_id", "high", "low", "close"])
t = pd.to_datetime(df.ts_event, utc=True).dt.tz_convert("US/Eastern").dt.tz_localize(None)
m = t.dt.hour * 60 + t.dt.minute - 570 # minutes since 09:30
rth = df[(m >= 0) & (m < 405)].assign(date=t.dt.normalize(), bar=m // 5) # RTH 09:30–16:15, 81 five-minute bars a day
# Build 5-minute bars as a table with one row per day and 81 columns; a 5-minute slot with no trades (the March 2020 circuit breakers) is empty
bars = rth.groupby(["date", "bar"]).agg(h=("high", "max"), l=("low", "min"), c=("close", "last"))
H, L, C = (bars[k].unstack().reindex(columns=range(81)).values for k in ["h", "l", "c"])
days = bars.index.get_level_values(0).unique()
inst = rth.groupby("date").instrument_id.first().values
# 20 EMA: alpha = 2/21, RTH bars only, carried from day to day; empty 5-minute slots are skipped
# On the first day of a new contract the EMA restarts and that day isn't counted (old and new contract prices don't line up); same for the first day of the data
ema = np.full(H.shape, np.nan)
use = np.ones(len(days), bool)
e = None
for i in range(len(days)):
if i == 0 or inst[i] != inst[i - 1]:
e, use[i] = None, False
for k in range(81):
if not np.isnan(C[i, k]):
e = C[i, k] if e is None else 2 / 21 * C[i, k] + 19 / 21 * e
ema[i, k] = e
touch = (L <= ema) & (ema <= H) # the EMA sits between the bar's high and low
first = np.where(touch.any(1), touch.argmax(1) + 1, 0)[use] # which bar touches first; 0 if never
d = days[use]
print(f"Sample {len(first)} days, touched the 20 EMA {100 * (first > 0).mean():.2f}%, touched on bar 1 {100 * (first == 1).mean():.2f}%")
print("Days with no touch:", list(d[first == 0].strftime("%Y-%m-%d")))
for n in [3, 6, 12, 18, 24, 36]:
print(f"Touched by bar {n}: {100 * ((first > 0) & (first <= n)).mean():.1f}%")
print((pd.Series(first > 0, index=d).groupby(d.year).mean() * 100).round(2).to_string()) # share of days touched, by year
# Away from the EMA: the whole bar above it (low > EMA) or below it (high < EMA); consecutive bars form one streak, reset at each open
streaks, longest = {"above": [], "below": []}, []
for i in np.flatnonzero(use):
best = 0
for side, off in [("above", L[i] > ema[i]), ("below", H[i] < ema[i])]:
run = 0
for x in list(off) + [False]: # append a False so the last streak before the close is counted too
if x:
run += 1
elif run:
streaks[side].append(run) # a streak ended; record its length
best = max(best, run)
run = 0
longest.append(best)
up, down = np.array(streaks["above"]), np.array(streaks["below"])
both, longest = np.r_[up, down], np.array(longest)
print(f"{len(both)} streaks away from the EMA ({len(up)} above, {len(down)} below), {len(both) / use.sum():.1f} a day on average")
print(f"Median length {np.median(both):.0f} bars, 20 bars or fewer {100 * (both <= 20).mean():.1f}%")
for n in [10, 20, 40]:
print(f"At least {n} bars in a row: above {100 * (up >= n).mean():.1f}% ({(up >= n).sum()} streaks), below {100 * (down >= n).mean():.1f}% ({(down >= n).sum()} streaks)")
print(f"Days with at least one streak of 20+ bars: {100 * (longest >= 20).mean():.1f}%")Limitations
- It only checks whether price touched the EMA, not what happened next: whether price bounces off the EMA or slices through isn’t measured here.
- The EMA uses RTH bars only: an EMA on the 24-hour chart (including the overnight session) would sit somewhere else at the open, and the share of days touching it on bar 1 would differ.
- Streaks are cut off at the close: a streak that runs into the close ends there and counting restarts the next day, so a long trend spanning two days is split into two streaks.