Trend-following gets all the attention, but anyone who has run a moving-average strategy through a sideways market knows the other half of the story: in a range, a trend system buys every false breakout and sells every false breakdown until your account looks like it has been through a paper shredder.
A mean-reversion strategy does the opposite. It assumes that after an unusually sharp move, price tends to snap back toward its recent average. In this article we build one in Python using two of the most popular indicators in technical analysis — Bollinger Bands and the RSI — and, more importantly, we backtest it honestly so you can see when it works and when it quietly bleeds.
By the end you will have:
- A clear, no-nonsense explanation of what Bollinger Bands and the RSI actually measure.
- A runnable
pandasimplementation of both, from scratch. - A complete mean-reversion backtest on SPY, with a fair comparison to buy & hold.
- A frank list of the ways this strategy can fool you.
1. Why mean-reversion, and when it works
Markets alternate between two behaviors. In a trending phase, today’s move predicts tomorrow’s: strength begets strength. In a mean-reverting phase, today’s move predicts the opposite: an overextended drop tends to be followed by a bounce.
A mean-reversion strategy is a bet on the second behavior. The logic:
- Price has dropped sharply below its recent average.
- We assume the drop is an overreaction.
- We buy, expecting a snap back to the mean.
- We sell once price has reverted.
This works beautifully in choppy, range-bound markets — exactly the environment where trend systems struggle. The flip side, which we will return to in the traps section, is that the same strategy is dangerous in a strong downtrend, where every dip looks like a buying opportunity right up until the bottom falls out. Knowing which regime you are in matters, which is why this pairs naturally with regime detection — see the HMM market regimes article.
We need two things: a way to measure “how far is price from its average” (Bollinger Bands) and a confirmation that the move is genuinely stretched (RSI).
2. Bollinger Bands and the RSI in plain English
Bollinger Bands wrap a price chart in a statistical envelope. Three lines:
- A middle band — a simple moving average, typically 20 days.
- An upper band — the middle band plus two standard deviations of price.
- A lower band — the middle band minus two standard deviations.
Because the bands are built from a rolling standard deviation, they widen when volatility rises and contract when it falls. When price touches the lower band, it sits roughly two standard deviations below its recent mean — a statistically unusual place to be. That is our “stretched to the downside” signal.
The RSI (Relative Strength Index) is a momentum oscillator bounded between 0 and 100. It compares the size of recent up-moves to recent down-moves over a lookback window, classically 14 days. Convention:
- RSI below 30 → “oversold” — selling pressure has been dominant and may be exhausted.
- RSI above 70 → “overbought”.
Neither indicator is magic, and either one alone produces a lot of false signals. The point of combining them is confirmation: we only act when the price says stretched (lower Bollinger Band) and the momentum says exhausted (low RSI). Two weak signals that agree are stronger than one.
3. Setting up the environment
pip install yfinance pandas numpy matplotlibImports:
import numpy as np import pandas as pd import yfinance as yf import matplotlib.pyplot as plt
4. Getting the data and computing Bollinger Bands
We use the SPY ETF as a proxy for the S&P 500, on daily bars.
data = yf.download("SPY", start="2005-01-01", end="2025-01-01", auto_adjust=True) data = data[["Close"]].dropna() data["log_return"] = np.log(data["Close"] / data["Close"].shift(1))Bollinger Bands are a few lines of
pandas:window = 20 n_std = 2 data["mid_band"] = data["Close"].rolling(window).mean() rolling_std = data["Close"].rolling(window).std() data["upper_band"] = data["mid_band"] + n_std * rolling_std data["lower_band"] = data["mid_band"] - n_std * rolling_stdA useful derived quantity is the %B, which expresses where price sits inside the bands on a 0–1 scale (0 = on the lower band, 1 = on the upper band):
data["pct_b"] = (data["Close"] - data["lower_band"]) / (data["upper_band"] - data["lower_band"])
5. Computing the RSI from scratch
Plenty of libraries ship an RSI, but it is worth implementing once so you know exactly what you are trading. We use Wilder’s smoothing, the original definition, which is an exponential moving average with
alpha = 1 / period:def rsi(series: pd.Series, period: int = 14) -> pd.Series: delta = series.diff() gain = delta.clip(lower=0) loss = -delta.clip(upper=0) avg_gain = gain.ewm(alpha=1 / period, adjust=False).mean() avg_loss = loss.ewm(alpha=1 / period, adjust=False).mean() rs = avg_gain / avg_loss return 100 - (100 / (1 + rs)) data["rsi"] = rsi(data["Close"], period=14) data = data.dropna()Quick sanity check — the RSI should spend most of its life between 30 and 70, with brief excursions to the extremes:
print(data["rsi"].describe())If your RSI is pinned near 0 or 100, you have a bug — usually a sign you forgot to separate gains from losses correctly.
6. From indicators to entry and exit rules
Mean-reversion is a stateful strategy: you are either in a position or you are not, and a single day’s data is not enough to know which. So we define an entry condition and a separate exit condition, then walk through time tracking the state.
The rules:
- Enter long when the close is below the lower Bollinger Band and the RSI is below 30.
- Exit when the close climbs back to the middle band (the mean has been reached) or the RSI rises above 50.
- Otherwise, hold whatever position we already have.
entry = (data["Close"] < data["lower_band"]) & (data["rsi"] < 30) exit_ = (data["Close"] >= data["mid_band"]) | (data["rsi"] > 50) position = np.zeros(len(data)) in_position = False for i in range(len(data)): if not in_position and entry.iloc[i]: in_position = True elif in_position and exit_.iloc[i]: in_position = False position[i] = 1.0 if in_position else 0.0 data["position"] = positionThe explicit loop is not the fastest code in the world, but for 5,000 daily bars it runs instantly and it is impossible to misread. Clarity beats cleverness when a subtle bug means losing real money.
One non-negotiable step: shift the position by one bar before computing returns. The decision to be in the market today is made from yesterday’s close, because you cannot trade on a price that has not printed yet.
data["position"] = data["position"].shift(1).fillna(0)Skip that line and you build look-ahead bias straight into the backtest — the single most common way a mean-reversion strategy looks brilliant on screen and fails live.
7. Backtesting the strategy
With a clean position series, the backtest is one multiplication:
data["strategy_ret"] = data["position"] * data["log_return"] data["bh_ret"] = data["log_return"] equity = np.exp(data[["strategy_ret", "bh_ret"]].cumsum()) equity.columns = ["Bollinger+RSI mean-reversion", "Buy & Hold"] equity.plot(figsize=(14, 6), title="Mean-reversion strategy vs Buy & Hold — SPY") plt.ylabel("Equity (cumulative, log-return based)") plt.tight_layout() plt.show()Mean-reversion strategies are, by construction, out of the market most of the time — they only hold a position during the recovery from an oversold dip. So do not expect the equity curve to track buy & hold. Expect a flatter line that steps up occasionally and, crucially, sidesteps a chunk of the worst drawdowns.
8. Measuring performance honestly
A picture is not a result. We need numbers, and we need the same numbers for buy & hold so the comparison is fair.
def sharpe(r): r = r.dropna() return np.sqrt(252) * r.mean() / r.std() def max_drawdown(equity_curve): peak = equity_curve.cummax() return (equity_curve / peak - 1).min() def time_in_market(position): return position.mean() strat = data["strategy_ret"] bh = data["bh_ret"] print(f"Sharpe strategy : {sharpe(strat):.2f}") print(f"Sharpe buy & hold : {sharpe(bh):.2f}") print(f"Max DD strategy : {max_drawdown(np.exp(strat.cumsum())):.2%}") print(f"Max DD buy & hold : {max_drawdown(np.exp(bh.cumsum())):.2%}") print(f"Time in market : {time_in_market(data['position']):.1%}")The pattern you will usually see: the strategy earns a fraction of buy & hold’s total return — because it is invested only a small share of the time — but its drawdowns are far shallower, and the return per day actually invested is high. That last point is what makes mean-reversion useful as a building block: it produces a stream of returns that is largely uncorrelated with a trend system, so the two combine well in a portfolio.
Judge the strategy on risk-adjusted terms and on what it adds to a blend, not on whether it beats the index outright. It will not, and it is not trying to.
9. The traps you must not ignore
Mean-reversion is genuinely useful, but it has sharp edges. Name them or they will find you.
- It catches falling knives. The strategy buys oversold dips. In a sustained crash — 2008, March 2020 — “oversold” gets more oversold for weeks. Every backtest of a long-only mean-reversion system will show ugly losses in those windows. A regime filter or a hard stop-loss is not optional for live trading.
- Look-ahead bias. Covered above, and worth repeating: the
shift(1)is the difference between a real backtest and a fantasy. Any indicator computed on the same bar you act on must be lagged. - Parameter overfitting. The 20-day window, 2 standard deviations, RSI 14, the 30/50 thresholds — every one of those is a knob. Tune them all on the full history and you are curve-fitting. Validate with walk-forward optimization so the parameters are chosen out-of-sample.
- Transaction costs. This strategy trades fairly often. Add a realistic per-trade cost — subtract
cost * abs(data["position"].diff())from the returns — and re-run. Edges that survive on paper sometimes do not survive 5 basis points of friction. - Survivorship bias on single stocks. SPY is an index and reasonably safe. Run the same logic on individual stocks and a name that mean-reverted nicely for years can also simply go to zero — the ultimate failed reversion.
A strategy whose failure modes you can recite is one you can manage. A strategy that “just works” in the backtest is one you do not understand yet.
10. Where to go next
A few directions to push this further:
- Add a regime filter. Only take mean-reversion trades when a regime model says the market is ranging, not trending down. This directly addresses the falling-knife problem — a natural pairing with the HMM market regimes article.
- Size by conviction. Instead of a binary 0/1 position, scale exposure with how stretched %B and the RSI are. A deeper dip gets more capital.
- Add a short side. Mirror the rules — short when price pierces the upper band with RSI above 70. Be warned: shorting an index with a long-term upward drift is a structural headwind.
- Test the indicators as features. Feed %B and the RSI into a classifier rather than hand-coding thresholds — see the feature selection article for how to do that without drowning in noise.
Conclusion
Bollinger Bands and the RSI are two of the most-Googled indicators in trading, and most of what is written about them is either hand-wavy chart astrology or an equity curve with the look-ahead bias quietly left in. Built carefully in Python and backtested honestly, they form a real, if modest, mean-reversion strategy: not a market-beater on total return, but a low-drawdown, low-correlation return stream that earns its place as a component of a larger book.
Trade safe, and remember: the market can stay oversold a lot longer than your stop-loss can stay untriggered.
