Backtest a Moving-Average Strategy in pandas
Lesson 16 of 16 · Data Manipulation
Backtesting a Moving-Average Rule Without Cheating
Scene: a student investment society pitch. A second-year presents a strategy: "Hold the index when it closes above its 50-day moving average, go to cash otherwise. Five years, Sharpe ratio 2.4, max drawdown 6%." The room is impressed. You ask one question: "On which day do you trade the signal?" In this lesson you build that backtest in plain pandas, find the line that makes it look brilliant, and fix it.
What it is: A backtest replays a trading rule on historical data to estimate how it would have performed. Look-ahead bias is using information in the backtest that would not have been available at the moment of the trade. It never raises an error; it just makes results look better than they could ever be.
The rule and the timeline
ret_t = close_t / close_(t-1) - 1
signal_t = 1 if close_t > SMA50_t, else 0
position_t = signal_(t-1)
strategy_t = position_t × ret_t
- cost × |position_t - position_(t-1)|
- ret_t is earned from yesterday's close to today's.
- signal_t is known only after today's close.
- position_t is what you can actually hold on day t.
The signal at day t uses the close at day t. If you multiply it by ret_t, you are betting on a move that has already happened, and that move is part of what produced the signal: a big up day pushes the close above its average and earns the strategy that same up day. In pandas the honest version is one method call: .shift(1).
The data: a market no rule can beat
The prices here are a random walk with a small upward drift (1,260 synthetic sessions, seeded). Tomorrow's return is independent of everything before it, so no rule built on past prices has a real edge. Any Sharpe ratio above buy-and-hold is, by construction, an artefact.
Worked numbers (this lesson's data)
Statistics are annualised from daily data: return = mean × 252, volatility = std × √252, Sharpe = return / volatility (risk-free rate taken as 0), max drawdown = the worst fall from a running peak of the equity curve.
| return | vol | Sharpe | max DD | |
|---|---|---|---|---|
| Buy and hold | 18.51% | 17.38% | 1.07 | -18.1% |
| SMA-50 rule, signal same day (look-ahead) | 34.13% | 14.19% | 2.41 | -6.4% |
| SMA-50 rule, signal lagged + 5 bp costs | 6.48% | 14.54% | 0.45 | -32.7% |
The look-ahead version nearly doubles buy-and-hold's return with a third of its drawdown, on a series that is unpredictable by construction. The honest version, which trades on the next day and pays 5 basis points per change of position (105 changes), trails buy-and-hold on return, Sharpe and drawdown. That is the expected result on a random walk, and it is what a credible backtest looks like.
Three habits that prevent it
- Shift every signal.
position = signal.shift(1): decide at the close, hold from the next session. - Charge costs on turnover.
turnover = position.diff().abs(); each change of position pays the spread and commission. - Be suspicious of smooth curves. A Sharpe above 2 from a one-line rule on a liquid index is a bug report, not a result.
Your Task
The starter computes everything. YOUR TURN marks the position line, which uses the signal on the same day it is computed.
Predict before you run: the starter reports a Sharpe of 2.41. After the fix, will the rule beat buy-and-hold's 1.07? Then lag the signal by one session with .shift(1) (and fill the first, empty position with 0).
Related terms in the glossary
Further reading
Where to go deeper. Free means a full, legal copy is online.
Chapter 11, 'The Dangers of Backtesting': look-ahead bias, selection bias and why a good-looking backtest is weak evidence.