Does a large one-day move in the S&P 500 predict next week’s return?

Python
Backtesting
Analysis
Code
The S&P 500’s median next-week return was far from zero only after one-day moves of 5% or more, and opposite in sign to those moves.
Published

May 11, 2026

“Buy the dip, sell the rip” says to buy after a large fall and sell after a large rise. On the S&P 500 from 1988 to 2026, the median next-week return, the middle value when the returns are sorted, had the sign the rule expects after the largest one-day moves, and it was far from zero only after those moves. After a single day of −5% or worse, the S&P 500 returned a median of +3.81% over the next week. After a day of +5% or better, it returned −1.77%. After smaller moves, the median next-week return was close to zero.

I take every trading day in the S&P 500 Total Return Index from 1988 to 2026, bucket each day by its one-day return, compound the next five trading days, and take the median return in each bucket. That gives one number per bucket: the outcome in the middle of all the weeks that followed a move of that size.

The setup

  • Index: S&P 500 Total Return Index (^SP500TR)
  • Period: 1988 to 2026
  • Buckets: 1 percentage-point return buckets, with tail buckets at ≤ −5% and ≥ +5%
  • Forward window: next 5 trading days
  • Statistic: median next-week return

I use the median because the tail buckets are small and skewed. One extreme observation can change the mean too much.

Starting point

The analysis uses one series, which I download from Yahoo Finance with yfinance in a separate job. I pull Adj Close for the ticker ^SP500TR, the S&P-published total-return index, which reinvests dividends, take pct_change() and drop the leading NaN, the missing value on the first day, which has no earlier price. LSEG Workspace has the same index as .SPXTR.

  • daily, a Series of daily simple returns on the S&P 500 Total Return Index from 1988 to 2026, indexed by trading day. Total return, so dividends are reinvested back into the index level.

Parameters

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt

HORIZON_DAYS = 5              # forward window in trading days (~one calendar week)

Step 1. The return over the next week

Every day needs the return over the five trading days that follow it. rolling(5).apply(np.prod) multiplies 1 plus the return across the five days ending on each row, and shift(-5) moves that product back five rows, so it aligns with the day before those five start.

# the return over the five days ending on each row
week = (1 + daily).rolling(HORIZON_DAYS).apply(np.prod, raw=True) - 1

# the same number, moved onto the day before those five start
next_week = week.shift(-HORIZON_DAYS)

The last five days of the sample have no full week after them, so next_week is missing there. Those days drop out in Step 2, and every observation that remains has the same forward horizon. Filling those gaps with zero would bias the recent buckets toward zero.

Each day is paired with the returns that follow it and never with its own, the same timing rule as lagging a signal, which Lesson 30 of Python for Investing works through. Lesson 19 builds the rolling window.

Step 2. One bucket per day

Each day gets a bucket from its own return. The two tails take everything at or beyond 5%. Between them, the bucket is the whole percent the return is at or beyond: a −4.5% day is beyond −4% and is a “−4%” day, and a +1.7% day is beyond +1% and is a “+1%” day. Days inside −1% to +1% get no bucket.

def bucket(day_return):
    """The bucket a one-day return belongs to. None for days inside -1% to +1%."""
    if day_return <= -0.05:
        return "≤ -5%"
    if day_return >= 0.05:
        return "≥ +5%"

    # From the outside in, so a -4.5% day stops at 4 and takes the "-4%" bucket
    for percent in (4, 3, 2, 1):
        if day_return <= -percent / 100:
            return f"-{percent}%"          # (-5%, -4%] is "-4%", ..., (-2%, -1%] is "-1%"
        if day_return >= percent / 100:
            return f"+{percent}%"          # [+4%, +5%) is "+4%", ..., [+1%, +2%) is "+1%"
    return None                            # inside -1% to +1%


table = pd.DataFrame({"bucket": daily.apply(bucket), "next_week": next_week})

# dropna removes three kinds of row: days inside -1% to +1%, which get no bucket; the
# last five days, which have no full week after them; and any day whose week contains a
# missing return, which the Limitations section explains.
table = table.dropna()

One row remains for each day that has a bucket and a full week after it: the bucket, and the return over that week. Every such day is in exactly one bucket.

Step 3. The median in each bucket

For each bucket, the block collects the weeks that followed its days and records how many there are and their median return.

ORDER = ["≤ -5%", "-4%", "-3%", "-2%", "-1%", "+1%", "+2%", "+3%", "+4%", "≥ +5%"]

rows = []
for label in ORDER:
    # the weeks that followed this bucket's days
    weeks_after = table.loc[table["bucket"] == label, "next_week"]
    rows.append({"bucket": label, "n": len(weeks_after),
                 "median_pct": weeks_after.median() * 100})

medians = pd.DataFrame(rows).set_index("bucket")

Step 4. Chart

labels = medians.index.tolist()
y_vals = medians["median_pct"].tolist()
x_vals = list(range(len(labels)))
color  = "#d62728"

fig, ax = plt.subplots(figsize=(14, 8))
ax.plot(x_vals, y_vals, "o-", color=color,
        linewidth=2.5, markersize=9, zorder=5)

for xi, yi in zip(x_vals, y_vals):
    offset_y = 14 if yi >= 0 else -16
    ax.annotate(f"{yi:+.2f}%", xy=(xi, yi),
                xytext=(0, offset_y), textcoords="offset points",
                fontsize=10, ha="center",
                va="bottom" if yi >= 0 else "top",
                color=color, fontweight="bold")

ax.axhline(y=0, color="black", linewidth=1.0, zorder=1)
ax.set_xticks(x_vals)
ax.set_xticklabels(labels, fontsize=12)
ax.set_xlabel("Single Day Return", fontsize=14)
ax.set_ylabel("One Week Return (%)", fontsize=14)
ax.set_title("One Week After a Single Day Return",
             fontsize=18, fontweight="bold")
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()

Results

Median one-week S&P 500 return after each single-day move bucket, 1988 to 2026. The median is +3.81% after days of −5% or worse and −1.77% after days of +5% or better.

The red line connects the median next-week return in each bucket. In the two tail buckets it has the opposite sign to the one-day return: +3.81% after a day of −5% or worse, and −1.77% after a day of +5% or better. In the eight middle buckets it is between −0.25% and +0.65%, near the median of +0.39% over all five-day windows. Five of these middle medians also have the sign the rule expects, but none is more than 0.7 percentage points from +0.39%, so the large reversals appear only in the tails.

The reversal is larger after large falls than after large rises. The tail buckets hold few days, clustered in crises (see Limitations), so I do not draw a conclusion from that difference, and the reversal is not a signal to buy every large fall or sell every large rise.

Limitations

A day with an incomplete week is dropped. If any day inside the forward week has no return, that week’s return cannot be computed and the day is removed. The series used here has no missing returns, so this rule removes only the last five days of the sample, as Step 1 explains.

Thin tails. The extreme buckets are small. Over 1988 to 2026, the ≤ −5% bucket contains 23 events and the ≥ +5% bucket contains 21. These observations cluster around crisis periods such as 2008 and March 2020. Read the tail estimates as suggestive. Bootstrapping puts a range around a number estimated from so few observations.

Overlapping windows. Large moves often arrive in clusters. If two extreme days occur close together, their next-5-day windows overlap. That means the observations are not fully independent. Purging removes that overlap when the same problem appears in a model.

No execution assumption. This is not a tradable strategy. It does not specify whether the trade happens at the close, the next open, or some other price. It also ignores spreads, slippage, and implementation.

The chart shows conditional medians. Each point is the median next-week return conditional on the one-day return bucket. The return of a portfolio rule is a different quantity. A rule that only trades after ±5% days would trade rarely. Turning a rule like this into a tested strategy, with costs, is the subject of How to backtest a strategy.

One index, one sample. The result is for the S&P 500 Total Return Index from 1988 to 2026. Other markets, other horizons, or other samples may look different.

Conclusion

The takeaway is that only the rare extreme days were followed by the move the rule expects: after the 23 days of −5% or worse, the S&P 500’s median next-week return was +3.81%, after the 21 days of +5% or better −1.77%, and after smaller moves it was close to zero.