How to Backtest a Trading Strategy in Python Without Fooling Yourself
A backtest is a simulation: what would have happened if you'd run this strategy on historical data instead of just having the idea? It's the main tool quants use to vet a systematic, rules-based strategy before risking real money on it — and it's also one of the easiest things in finance to accidentally rig in your own favor without realizing it.
What every backtest needs to define
- Universe — which assets is the strategy even allowed to trade?
- Signal generation — what triggers a buy or sell? (e.g., 50-day moving average crossing above the 200-day)
- Position sizing — how much capital per position?
- Execution assumptions — what price do you assume you actually got? Next-day open is realistic. Assuming you traded at the same-day close is a classic, quiet way to cheat without meaning to.
- Transaction costs — commissions, bid-ask spread, market impact.
- Performance metrics — total return, Sharpe ratio, max drawdown.
The metrics that actually matter
Total return is the headline number, and close to meaningless on its own — it says nothing about how much risk was taken to get there.
Sharpe ratio fixes that: return adjusted for volatility.
Sharpe = (Strategy Return − Risk-Free Rate) / Strategy Volatility
A strategy worth taking seriously usually targets a Sharpe above 1.0 before costs. Elite hedge funds typically run 1–2 after real trading costs, in live markets — a backtest claiming a Sharpe above 3 is a red flag, not a green one.
Maximum drawdown is the worst peak-to-trough decline during the test:
Max Drawdown = max(Peak − Trough) / Peak
A strategy compounding at 20%/year that occasionally craters 60% is far harder to actually hold through than the headline return suggests — most people panic-sell at exactly the wrong moment. The Calmar ratio (annual return ÷ max drawdown) captures the tradeoff between the two in a single number; anything above 1 is considered respectable.
The five ways backtests lie
- Look-ahead bias — letting a signal see data it wouldn't actually have had yet. Generating today's signal off today's closing price only works if you own a time machine.
- Survivorship bias — testing only on companies still around today. Most historical databases quietly drop names that went bankrupt or got delisted, which skews everything upward: you're only ever grading the survivors.
- Overfitting — running thousands of parameter combinations and reporting whichever won. Test enough variations and something looks brilliant purely by chance. A real edge should survive nudging one parameter slightly, not collapse the instant you do.
- Transaction cost underestimation — a high-frequency strategy can look wildly profitable on paper and get entirely eaten by spread and market impact once it meets reality.
- Regime dependence — a strategy tuned to one stretch of market history (say, 2010–2020's near-zero rates) can quietly stop working once conditions shift. Always test across more than one regime.
Walk-forward testing
The standard defense against overfitting: split history into an in-sample period (build and tune the strategy) and an out-of-sample period you don't touch until you're already satisfied. Peek at the out-of-sample data even once while tuning and you've unconsciously started fitting to it too — the whole point of the split quietly evaporates. Walk-forward optimization takes it further: train on a rolling window, trade forward one period with those parameters, slide the window ahead, repeat — closer to how live trading actually works.
A minimal backtest in Python
def backtest_sma_crossover(prices, short_window=10, long_window=30):
"""Buy when the short SMA crosses above the long SMA, sell on the reverse."""
equity = [1.0] # start with $1, track growth
position = 0 # 0 = flat, 1 = long
for i in range(long_window, len(prices)):
short_sma = sum(prices[i - short_window:i]) / short_window
long_sma = sum(prices[i - long_window:i]) / long_window
if short_sma > long_sma and position == 0:
position = 1
elif short_sma < long_sma and position == 1:
position = 0
daily_return = (prices[i] / prices[i - 1]) - 1
equity.append(equity[-1] * (1 + daily_return * position))
return equity
def max_drawdown(equity):
peak = equity[0]
worst = 0
for value in equity:
peak = max(peak, value)
worst = min(worst, (value - peak) / peak)
return abs(worst)
This version deliberately only uses moving averages computed from prices
before index i — never prices[i] itself in the signal — which is the
concrete, line-of-code version of avoiding look-ahead bias, not just a rule
to remember.