Skip to content

Building a Mean-Reversion Backtest in Python

Original question

Can you help me build a simple mean-reversion trading strategy in Python using historical stock price data?
Standardise the closing price against a trailing mean and volatility, then define a rule that buys an unusually low close and exits when the price recovers to the mean. Generate signals after the close, simulate execution at the following open, and charge costs when the position changes. Compare the result with cash and buy-and-hold over the same dates.

Separate the signal, order and held position

For a 20-day window ending at close tt, compute

zt=Ct−Cˉt,20st,20.z_t=\frac{C_t-\bar C_{t,20}}{s_{t,20}}.
Enter at the next open when zt<−2z_t<-2 and the strategy is flat. Exit at the next open when zt≥0z_t\ge 0. Otherwise keep the current state. Let wtw_t be the position entered at open tt. With a cost fraction cc per unit of turnover, an approximate daily net return is
rtnet=wt(Ot+1Ot−1)−c∣wt−wt−1∣.r^{\text{net}}_t=w_t\left(\frac{O_{t+1}}{O_t}-1\right)-c|w_t-w_{t-1}|.

A close-to-open gap changes the actual entry price. Including it through the open-based return prevents the simulation from buying at the price that generated the signal. Calculate cumulative performance by compounding daily returns and account for a terminal liquidation if the strategy still holds the stock.

Trace one trade from entry signal to exit

Suppose the rolling mean is $100 and its standard deviation is $2. A closing price of $95 gives z=−2.5z=-2.5, so the model queues a buy. The next open is $96. Later, a close at the rolling mean triggers an exit at the following open of $99. The gross price return is 99/96−1=3.125%99/96-1=3.125\%. At five basis points each way, the trade earns about 3.02% after costs. A model that bought at $95 would attribute the overnight move to the strategy even though the order had yet to fill.

The move from the signal close of 95 to the entry open of 96 happens while you're flat. The same is true of any move after your exit fill. In a daily implementation with open-to-open returns, a trade at today's open changes the position that earns the interval from today's open to tomorrow's open. Charge entry costs at today's execution. Then inspect the first and last rows explicitly. An open position at the end needs either a mark-to-market policy or a stated liquidation rule.

For a cash account buying one share, an explicit proportional fee of 0.05% raises the purchase cash outflow to 96(1.0005)96(1.0005) and lowers the sale proceeds to 99(0.9995)99(0.9995). Taking the ratio and subtracting one gives about 3.02%. This is a useful test because you can verify both fees without relying on the vectorised return calculation. Write down whether the model uses all-in share purchases, proportional deductions from portfolio returns, or both. A model that already embeds costs in fills needs to avoid charging the same component again.

A stateful next-open backtest

Supply a sorted daily CSV with columns named Date, Open and Close. Use open and close prices on the same corporate-action adjustment basis.

import pandas as pd prices = pd.read_csv("daily_prices.csv", parse_dates=["Date"]) prices = prices.set_index("Date").sort_index() close, opening = prices["Close"], prices["Open"] window = close.rolling(20) z = (close - window.mean()) / window.std() target = pd.Series(float("nan"), index=prices.index) target[z < -2] = 1.0 target[z >= 0] = 0.0 weight = target.ffill().shift(1, fill_value=0.0).fillna(0.0) forward = opening.shift(-1).div(opening).sub(1) weight = weight.loc[forward.notna()] forward = forward.loc[weight.index] turnover = weight.diff().abs().fillna(weight.abs()) net = weight * forward - 0.0005 * turnover net.iloc[-1] -= 0.0005 * weight.iloc[-1] # liquidate at the final open print(pd.DataFrame({"strategy": (1 + net).cumprod(), "buy_hold_gross": (1 + forward).cumprod()}).tail())

What to check

  • Choosing the most attractive stock after inspecting its full return history creates selection bias. Record the universe and selection rule before evaluating performance.
  • Recomputing thresholds on the final test segment turns it into another development sample. Select parameters earlier or follow a fixed rolling selection rule.
  • A large price drop may reflect new information. Include trades that continue to fall when assessing the rule and inspect their contribution to drawdown.
For a larger implementation to compare with the state transitions here, browse the runnable backtests in Strategy Lab.

Interactive example

Find the execution cost that consumes a trading edge

A synthetic strategy earns 12 basis points per period before costs. Costs apply to the absolute change in position on each traded side.

Net edge 5.00 bps per period · break-even cost 17.14 bps per unit
Expected net return declines as execution cost rises-10010010203040Cost (bps per unit of turnover)Net edge (bps)
  • Net edge
  • Break-even