Mean reversion is one of the oldest and most empirically grounded ideas in quantitative finance: prices that deviate significantly from their historical average tend to snap back. When combined with a rigorous backtesting framework, this intuition becomes a testable, deployable strategy. Lumibot is a Python-based algorithmic trading framework that makes it straightforward to move from signal logic to a fully instrumented backtest — with realistic broker simulation, position sizing, and performance reporting built in.
In this article, we will build a complete mean reversion strategy from scratch. We will use Bollinger Bands as the entry signal, implement the strategy class inside Lumibot's backtesting engine, pull historical price data via yfinance, and visualize both the signals and the equity curve. By the end, you will have a working template you can extend to any ticker or parameter set.
Most algo trading content gives you theory.
This gives you the code.3 Python strategies. Fully backtested. Colab notebook included.
Plus a free ebook with 5 more strategies the moment you subscribe.5,000 quant traders already run these:
Subscribe | AlgoEdge Insights
This article covers:
- Section 1 — Mean Reversion Theory**: What mean reversion is, how Bollinger Bands operationalize it, and the statistical intuition behind the signal
- Section 2 — Python Implementation**: Full setup, Lumibot strategy class, signal generation logic, and visualization code
- Section 3 — Interpreting the Results**: Reading the backtest output, what the metrics mean, and realistic performance expectations
- Section 4 — Use Cases**: Where this strategy class applies in practice
- Section 5 — Limitations and Edge Cases**: Honest assessment of where the approach breaks down
1. Mean Reversion and Bollinger Bands
Mean reversion is grounded in a simple statistical observation: many financial time series exhibit stationarity around a moving average, meaning large deviations from that average are more likely to correct than to persist indefinitely. Think of a rubber band — the further it stretches, the stronger the force pulling it back. This is not a guaranteed law of physics, but across liquid equities and certain macro regimes, it holds often enough to be tradeable.
Bollinger Bands formalize this idea. You compute a rolling mean (typically 20 periods) and a rolling standard deviation over the same window. The upper band is the mean plus two standard deviations; the lower band is the mean minus two standard deviations. Statistically, roughly 95% of price observations should fall within those bands under a normal distribution assumption. When price breaks below the lower band, it signals an unusual downward deviation — a candidate for a long entry. When it crosses back above the moving average, you exit.
The entry logic translates directly to code: price < lower_band triggers a buy; price > moving_average triggers a sell. The simplicity is intentional. Before adding complexity, you want to know whether the core mean reversion edge is present in the data at all. Lumibot's backtesting infrastructure handles the broker simulation — slippage, cash management, and order execution timing — so your strategy class stays clean and focused on signal logic.
One important nuance: mean reversion strategies tend to perform best in range-bound, low-trend environments. Strong directional trends — a stock in a sustained breakout or collapse — will generate repeated false entries. Section 5 addresses this directly, but keep it in mind as you interpret results.
2. Python Implementation
2.1 Setup and Parameters
Before writing the strategy, install the required libraries. Lumibot handles backtesting and order simulation; yfinance supplies historical OHLCV data; pandas and numpy handle computation; matplotlib handles visualization.
Key parameters to understand:
-
SYMBOL: The ticker under test. Start with a liquid large-cap likeSPY. -
BB_WINDOW: The lookback period for the rolling mean and standard deviation. 20 is the conventional default. -
BB_STD: The number of standard deviations for band width. 2.0 captures roughly 95% of price under normality. -
CASH_AT_RISK: Fraction of portfolio allocated per trade. Controls position sizing.
# Install dependencies (run once)
# pip install lumibot yfinance pandas numpy matplotlib
import pandas as pd
import numpy as np
import yfinance as yf
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from datetime import datetime
from lumibot.brokers import Alpaca
from lumibot.backtesting import YahooDataBacktesting
from lumibot.strategies import Strategy
from lumibot.traders import Trader
# ── Strategy Parameters ──────────────────────────────────────────
SYMBOL = "SPY"
BB_WINDOW = 20 # Rolling window for Bollinger Bands
BB_STD = 2.0 # Standard deviation multiplier
CASH_AT_RISK = 0.95 # Fraction of portfolio used per trade
BACKTEST_START = datetime(2020, 1, 1)
BACKTEST_END = datetime(2024, 1, 1)
2.2 Lumibot Strategy Class
The strategy class inherits from Strategy and implements two lifecycle methods: initialize sets one-time configuration, and on_trading_iteration is called at each bar. Inside the iteration, we pull recent price history, compute the Bollinger Bands, and issue buy or sell orders based on the current price's position relative to the bands.
class MeanReversionBB(Strategy):
def initialize(self):
self.symbol = SYMBOL
self.bb_window = BB_WINDOW
self.bb_std = BB_STD
self.cash_at_risk = CASH_AT_RISK
self.sleeptime = "1D" # Daily bars
def on_trading_iteration(self):
# ── Pull historical closes ────────────────────────────────
bars = self.get_historical_prices(
self.symbol,
self.bb_window + 1, # extra bar for shift alignment
"day"
)
if bars is None:
return
df = bars.df.copy()
closes = df["close"]
# ── Compute Bollinger Bands ───────────────────────────────
rolling_mean = closes.rolling(self.bb_window).mean()
rolling_std = closes.rolling(self.bb_window).std()
upper_band = rolling_mean + self.bb_std * rolling_std
lower_band = rolling_mean - self.bb_std * rolling_std
current_price = closes.iloc[-1]
current_lower = lower_band.iloc[-1]
current_mean = rolling_mean.iloc[-1]
if pd.isna(current_lower) or pd.isna(current_mean):
return
position = self.get_position(self.symbol)
# ── Entry: price below lower band, no open position ───────
if current_price < current_lower and position is None:
cash = self.get_cash()
qty = int((cash * self.cash_at_risk) / current_price)
if qty > 0:
order = self.create_order(self.symbol, qty, "buy")
self.submit_order(order)
self.log_message(
f"BUY {qty} {self.symbol} @ {current_price:.2f} "
f"| Lower Band: {current_lower:.2f}"
)
# ── Exit: price crosses above moving average ───────────────
elif current_price > current_mean and position is not None:
self.sell_all()
self.log_message(
f"SELL {self.symbol} @ {current_price:.2f} "
f"| MA: {current_mean:.2f}"
)
2.3 Running the Backtest
Lumibot's YahooDataBacktesting class pulls data from yfinance internally and runs the simulation. Pass the strategy class, the date range, and an initial cash balance. The engine handles trade execution, cash accounting, and performance logging automatically.
def run_backtest():
MeanReversionBB.backtest(
YahooDataBacktesting,
BACKTEST_START,
BACKTEST_END,
parameters={},
show_plot=False, # We will plot manually below
save_logfile=False,
budget=100_000, # Starting portfolio value in USD
)
if __name__ == "__main__":
run_backtest()
2.4 Visualization
Before running the full Lumibot backtest, it is useful to visualize the raw signals on historical price data. The chart below plots the close price alongside the Bollinger Bands, and marks entry and exit signals. This is your first sanity check — signals should appear at genuine extremes, not randomly throughout the series.
def plot_signals(symbol=SYMBOL, start="2020-01-01", end="2024-01-01"):
df = yf.download(symbol, start=start, end=end, progress=False)
df = df[["Close"]].copy()
df.columns = ["close"]
df["ma"] = df["close"].rolling(BB_WINDOW).mean()
df["std"] = df["close"].rolling(BB_WINDOW).std()
df["upper"] = df["ma"] + BB_STD * df["std"]
df["lower"] = df["ma"] - BB_STD * df["std"]
# Mark signals
df["buy_signal"] = (df["close"] < df["lower"]).astype(int)
df["sell_signal"] = (df["close"] > df["ma"]).astype(int)
plt.style.use("dark_background")
fig, ax = plt.subplots(figsize=(14, 6))
ax.plot(df.index, df["close"], color="#FFFFFF", linewidth=1.2,
label="Close Price")
ax.plot(df.index, df["ma"], color="#FFD700", linewidth=1.0,
linestyle="--", label=f"{BB_WINDOW}-Day MA")
ax.fill_between(df.index, df["lower"], df["upper"],
alpha=0.15, color="#1E90FF", label="Bollinger Bands")
ax.plot(df.index, df["upper"], color="#1E90FF", linewidth=0.7)
ax.plot(df.index, df["lower"], color="#1E90FF", linewidth=0.7)
buys = df[df["buy_signal"] == 1]
sells = df[df["sell_signal"] == 1]
ax.scatter(buys.index, buys["close"], marker="^", color="#00FF7F",
s=40, zorder=5, label="Buy Signal")
ax.scatter(sells.index, sells["close"], marker="v", color="#FF4500",
s=20, zorder=5, label="Sell Signal", alpha=0.5)
ax.set_title(f"{symbol} — Bollinger Band Mean Reversion Signals",
fontsize=14, color="white")
ax.set_xlabel("Date")
ax.set_ylabel("Price (USD)")
ax.legend(fontsize=9)
plt.tight_layout()
plt.savefig("mean_reversion_signals.png", dpi=150)
plt.close()
print("Chart saved: mean_reversion_signals.png")
plot_signals()
Figure 1. SPY daily close price (white) with 20-day Bollinger Bands (blue fill), buy signals at lower-band touches (green triangles), and sell signals at mean crossovers (red triangles) — dense sell markers reflect frequent mean-crossings during trending periods.
Enjoying this strategy so far? This is only a taste of what's possible.
Go deeper with my newsletter: longer, more detailed articles + full Google Colab implementations for every approach.
Or get everything in one powerful package with AlgoEdge Insights: 30+ Python-Powered Trading Strategies — The Complete 2026 Playbook — it comes with detailed write-ups + dedicated Google Colab code/links for each of the 30+ strategies, so you can code, test, and trade them yourself immediately.
Exclusive for readers: 20% off the book with code
MEDIUM20.Join newsletter for free or Claim Your Discounted Book and take your trading to the next level!
3. Interpreting the Backtest Results
When you run the Lumibot backtest on SPY from 2020 to 2024, the engine prints a performance summary to the console. Key metrics to focus on: total return, Sharpe ratio, maximum drawdown, and trade count. For a mean reversion strategy on SPY over this window, you can reasonably expect moderate total returns (roughly in the range of buy-and-hold minus friction), a Sharpe ratio between 0.6 and 1.1 depending on parameter tuning, and relatively low trade frequency — the lower band is rarely breached on a liquid index.
Maximum drawdown is the most important risk metric here. Because mean reversion entries occur precisely when price is falling, you will sometimes buy into the beginning of a sustained downtrend — as happened in March 2020. The strategy has no stop-loss logic in its current form, which means a single tail event can produce a large drawdown. This is the primary area for enhancement.
Trade count is also informative. A well-parameterized Bollinger Band strategy on SPY at the 2-sigma level should generate somewhere between 15 and 40 trades over a four-year period. If you see hundreds of trades, your window or standard deviation multiplier is too tight; the bands are too narrow and firing on noise. If you see fewer than 10, the parameters may be too conservative to generate meaningful results — consider expanding the lookback or testing on a more volatile underlying.
4. Use Cases
Single-stock mean reversion on liquid large-caps: Tickers like AAPL, MSFT, and GOOGL show reasonable mean-reverting behavior over multi-year periods, especially during sideways market regimes. The same strategy class runs on any ticker by changing the
SYMBOLparameter.Sector ETF rotation: Apply the strategy across a basket of sector ETFs (XLK, XLE, XLF, etc.) and enter the one farthest below its Bollinger lower band. This diversifies the signal and reduces the impact of a single sector's trending behavior.
Volatility-adjusted parameter tuning: Use a higher
BB_STDmultiplier (2.5 or 3.0) during high-VIX regimes to avoid catching falling knives, and tighten it during low-volatility periods when bands compress. This regime filter is a natural next extension.Pairs trading foundation: Mean reversion applies not just to price levels but to price spreads. Replace the single-asset price series with the spread between two cointegrated stocks, and the same Bollinger Band logic becomes a statistical arbitrage signal.
5. Limitations and Edge Cases
Trend sensitivity: Mean reversion strategies perform poorly in trending markets. A stock that breaks below its lower band during a genuine earnings collapse or macro downturn is not reverting — it is discovering a new fair value. Without a trend filter (e.g., a 200-day moving average direction check), the strategy will enter these situations repeatedly.
No stop-loss: The current implementation holds a losing position until price crosses back above the moving average, which may never happen on a short time horizon. In practice, a hard stop at 1.5x to 2x the entry deviation is standard risk management.
Lookahead bias risk: Lumibot's get_historical_prices method is designed to prevent lookahead by aligning data to the current bar. If you adapt this code to a raw pandas backtest, be careful with .shift() alignment — failing to shift signals by one bar is the most common source of inflated backtest returns.
Transaction costs and slippage: The default Lumibot paper broker applies minimal friction. Real execution on less liquid tickers will incur wider spreads and market impact, particularly for the larger position sizes that CASH_AT_RISK = 0.95 implies.
Non-stationarity: Bollinger Bands assume the price process is stationary around its rolling mean over the chosen window. For growth stocks in persistent uptrends, this assumption breaks down, and the lower band will systematically underestimate fair value.
Concluding Thoughts
Mean reversion via Bollinger Bands is a useful baseline strategy precisely because its logic is transparent and its failure modes are predictable. A backtest that shows modest risk-adjusted returns on SPY is not a disappointment — it confirms the signal is real and provides a clean starting point for enhancement. The most productive next steps are adding a trend filter, implementing a stop-loss, and testing across multiple tickers simultaneously to assess robustness.
The Lumibot framework keeps the strategy code readable and the backtesting infrastructure realistic. As you extend this template — adding regime detection, volatility scaling, or a pairs spread — the same class structure and backtest runner apply without modification. That separation between signal logic and execution machinery is what makes the framework worth learning.
If you found this walkthrough useful, consider exploring more advanced strategy architectures: Hidden Markov Model volatility regimes, Kalman filter trend tracking, or ARIMA-GARCH forecasting all pair naturally with the backtesting pipeline built here. Each adds a layer of statistical rigor on top of the same execution foundation.
Most algo trading content gives you theory.
This gives you the code.3 Python strategies. Fully backtested. Colab notebook included.
Plus a free ebook with 5 more strategies the moment you subscribe.5,000 quant traders already run these:
Subscribe | AlgoEdge Insights


Top comments (0)