Bottom line: on the ES RTH daily chart, the next day closes up 55.0% of the time after any day; after 2+ up days in a row it’s 54.6%, almost the same. “Up days lead to more up days” doesn’t hold on the daily chart.
After 2+ down days in a row, the next day closes up 57.4% of the time, 2.4 points above normal. There’s a slight tendency to bounce, but the gap is within the margin of error, so I can’t say it’s really there.
How I use it: I don’t assume tomorrow will continue, or reverse, just because the market has gone up or down a few days in a row. Looking only at streaks, both the daily and weekly charts are close to a coin flip, just tilted bullish overall (because ES has risen over the long run).
After three up days in a row, a lot of people think “it’s due for a pullback”; after three down days, “it’s due for a bounce”. Others think the opposite: up days lead to more up days.
Using ES data from 2010 to 2026, I asked a purely statistical question: after N bull (bear) bars in a row, what are the odds the next bar closes up? No indicators, no context, just the color of the bars. I looked at both daily and weekly bars, and at two kinds of bars: RTH and full day (24-hour).
2010–2026 · after 2+ bear bars in a row, the next bar closes up
57.4%
Sample 712 · unconditional 55.0% · after 2+ bull bars 54.6%
After N bars in a row, odds the next bar closes upX axis: bars in a row. Dot: the rate; line: 95% interval (longer with fewer cases). Dashed line: the unconditional rate after any bar. Fewer than 20 cases are drawn faded.
How to read it: the vertical line is the 95% interval. Given this many cases, the true rate most likely sits inside it, and the fewer the cases, the longer the line. Only when the whole line sits on one side of the dashed line can you say this case differs from normal; if the line crosses the dashed line, the gap may just be luck. Faded points have fewer than 20 cases, so their rates swing wildly; mostly ignore them.
Bull bar = close > open, bear bar = close < open; dojis are skipped. RTH bars open at 09:30 and close at 16:15; full-day bars open at 18:00 the evening before and close at 17:00 (like ES1! daily bars on TradingView). Hover (or tap) the chart to see the numbers.
After up streaks: about the same as usual
RTH daily bars, 3,787 cases:
- After any day, the next day closes up 55.0% of the time. That’s the “base rate”: ES rose for most of these 16 years, so up days were already more common than down days.
- After 1 up day it’s 53.2%, after 2 up days 55.5%, 3 days 53.6%, 4 days 53.2%, 5 days 58.0%… all hovering around 55%.
Takeaway: on the daily chart, “it’s already been up for a few days” tells you nothing about tomorrow.
After down streaks: a slightly better chance of a bounce, but not significant
- After 1 down day, the next day closes up 55.7% of the time; after 2 down days 58.6%, after 3 days 58.3%.
- After 2+ down days combined: 57.4% of 712 cases closed up, 2.4 points above the 55.0% base rate.
- But the 95% interval for those 712 cases is roughly 53.8%–61.0%, which includes 55%. In other words, 16 years of data can’t rule out that the gap is just luck.
- After 4+ down days the samples get smaller (67 cases at 4 days, 32 at 5), and the rate actually drops to around 50%, with long intervals on the chart.
So “down streaks tend to bounce” shows up faintly in the data, but not convincingly. Most of these 16 years were a bull market (the base rate is already 55%), and getting bought back after a few down days is common anyway. Confirming it would take a longer history, ideally one that includes a bear market like 2000–2010.
Full-day bars and weekly bars
- Full-day bars (18:00 the evening before to 17:00): base rate 55.7%, 55.7% after 2+ up days, 58.3% after 2+ down days. Same conclusion as RTH.
- Weekly (RTH): after any week, the next week closes up 58.9% of the time. After 2+ down weeks it’s 63.8%, after 2+ up weeks 59.7%. After 3 down weeks it’s 69.7%, but that’s only 33 cases, with a wide interval (about 53%–83%).
- The weekly chart has only 845 cases, and most streaks of 4+ weeks have fewer than 20 cases, so they’re drawn faded on the chart and should only be read loosely.
Definition and how to reproduce it
After consecutive bull / bear bars
- Data: cleaned 1-minute ES bars, 2010-06 to 2026-09. See how I cleaned it.
- RTH bars: open at 09:30, close at 16:15. Full-day bars: open at 18:00 the evening before, close at 17:00, like the ES1! daily bars on TradingView.
- Weekly bars: from the open of the first day of the week (Monday to Friday) to the close of the last day.
- Bull / bear bars: close > open is a bull bar, close < open is a bear bar; equal is a doji (only 29 RTH daily bars in 16 years).
- Streak count: bull bars count +1, +2, +3, …; bear bars −1, −2, −3, …; a change in direction restarts at ±1, and a doji resets to 0.
- Stats: after each “N in a row”, the share of next bars that close up; cases where the next bar is a doji are skipped. 8 or more in a row are grouped together.
- Missing days: cleaning removed half-day sessions and the days around contract rolls. On the daily chart, “in a row” follows stock market trading days: if the previous trading day was removed, counting starts over and that pair isn’t counted.
- 95% interval: calculated with the Wilson method; it means “given this many cases, the true rate most likely falls in this range”.
I have a CSV of cleaned 1-minute ES futures bars (Databento's ES.c.0 continuous contract, with columns ts_event, open, close; timestamps in UTC; half-day sessions and contract-roll days already removed). Please write Python code that measures the odds the next bar closes up after N bull (bear) bars in a row. Rules: 1. Convert timestamps to US Eastern time (US/Eastern), handling daylight saving time automatically. Build two kinds of daily bars: - RTH: open = the open of the 09:30 1-minute bar, close = the close of the 16:14 bar (09:30–16:15). - Full day: open at 18:00 the evening before, close at 17:00 (1-minute bars after 18:00 belong to the next trading day), like the ES1! daily bars on TradingView. Keep only trading days that have an RTH session; the first day's overnight session is incomplete, so drop it. 2. Weekly bars: combine the daily bars of the same week (Monday to Friday); open = the first day's open, close = the last day's close. 3. Bull bar: close > open; bear bar: close < open; equal is a doji. 4. Streak count: bull bars count +1, +2, +3, …; bear bars −1, −2, −3, …; when the direction changes, restart at ±1; a doji resets to 0. 5. On the daily chart, "in a row" follows stock market trading days: use 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) to find the previous trading day. If it isn't in the data (it was removed in cleaning), start counting over and don't count that pair. On the weekly chart, the two weeks must be consecutive. 6. Stats: after each "N in a row", check whether the next bar closes up; skip cases where the next bar is a doji. Put 8 or more in a row into one group. 7. For each of the four combinations (RTH / full day × daily / weekly), print: the total number of cases, the unconditional share of next bars closing up, the share after 2+ bull bars and after 2+ bear bars, and the number of cases and the share closing up for each group from 1 to 8+ bull bars and 1 to 8+ bear bars. Add a 95% interval (Wilson method) to every share. Please add comments to every step explaining why it's done that way.
# After consecutive bull / bear bars: after N bars up (down) in a row, what are the odds the next bar closes up?
# Input: cleaned 1-minute ES bars (Databento ES.c.0, timestamps in UTC)
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
df = pd.read_csv("ES-1min_clean.csv", usecols=["ts_event", "open", "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
# RTH bars: open at 09:30, close at 16:15
rth = df[(m >= 570) & (m < 975)].groupby(t.dt.normalize()).agg(o=("open", "first"), c=("close", "last"))
# Full-day bars: open at 18:00 the evening before, close at 17:00 (anything after 18:00 belongs to the next trading day)
full = df.groupby((t + pd.Timedelta(hours=6)).dt.normalize()).agg(o=("open", "first"), c=("close", "last"))
full = full.loc[rth.index[1:]] # keep only days with an RTH session; the first day's overnight session is incomplete, so drop it
# On the daily chart, "in a row" follows stock market trading days: if the previous trading day was removed in cleaning, start counting over
cal = pd.bdate_range(rth.index.min() - pd.Timedelta(days=10), rth.index.max())
cal = cal[~cal.isin(NYSE().holidays(cal.min(), cal.max()).union(CLOSED))]
prev = pd.Series(cal[:-1], index=cal[1:])
def wilson(k, n, z=1.96):
"""95% interval for a proportion (Wilson method), more reliable than "rate ± 2 standard errors" with small samples"""
p = k / n
c = (p + z * z / (2 * n)) / (1 + z * z / n)
h = z * np.sqrt(p * (1 - p) / n + z * z / (4 * n * n)) / (1 + z * z / n)
return f"{100 * (c - h):.1f}%–{100 * (c + h):.1f}%"
def study(bars, linked, name):
d = np.sign(bars.c.values - bars.o.values).astype(int) # bull bar +1, bear bar −1, doji 0
streak = np.zeros(len(d), int) # bull streak +1, +2, …; bear streak −1, −2, …
for i, x in enumerate(d):
same = i > 0 and linked[i] and x != 0 and np.sign(streak[i - 1]) == x
streak[i] = streak[i - 1] + x if same else x
rows = pd.DataFrame({"streak": streak[:-1], "next_up": d[1:] > 0})[(d[1:] != 0) & linked[1:]] # skip when the next bar is a doji or isn't adjacent
rows["group"] = rows["streak"].clip(-8, 8) # 8 or more in a row go into one group
print(f"== {name}: {len(rows)} cases, next bar closes up {100 * rows['next_up'].mean():.1f}% unconditionally")
for lab, g in [("After 2+ bull bars", rows[rows["streak"] >= 2]), ("After 2+ bear bars", rows[rows["streak"] <= -2])]:
print(f"{lab}: {len(g)} cases, next bar closes up {100 * g['next_up'].mean():.1f}% (95% interval {wilson(g['next_up'].sum(), len(g))})")
tab = rows[rows["group"] != 0].groupby("group")["next_up"].agg(["size", "mean", "sum"])
tab["95% interval"] = [wilson(k, n) for k, n in zip(tab["sum"], tab["size"])]
tab.index = [f"{abs(k)}{'+' if abs(k) == 8 else ''} {'bull' if k > 0 else 'bear'} in a row" for k in tab.index]
print(tab.drop(columns="sum").rename(columns={"size": "cases", "mean": "next up"}).round(3).to_string())
for bars, name in [(rth, "Daily · RTH"), (full, "Daily · full day")]:
study(bars, np.r_[False, prev.reindex(bars.index).values[1:] == bars.index.values[:-1]], name)
wk = bars.index - pd.to_timedelta(bars.index.dayofweek, unit="D") # Monday of the week
w = bars.groupby(wk).agg(o=("o", "first"), c=("c", "last"))
study(w, np.r_[False, np.diff(w.index.values) == np.timedelta64(7, "D")], name.replace("Daily", "Weekly"))Limitations
- It only looks at whether bars close up or down, not by how much: a small bull bar after three down days still counts as a “bounce”.
- The samples aren’t big enough: streaks of 4+ bars have only a few dozen cases on the daily chart, and single digits to the teens on the weekly chart, so the rates swing a lot.
- Most of these 16 years were a bull market: the 55% base rate is already tilted bullish. In a bear market like 2000–2010, the results might be different.