How to Backtest a Trading Strategy in Python (Step-by-Step)
To backtest a trading strategy in Python is to build a faithful simulator of your own decision process under the information constraints you actually faced in real time. Most public tutorials get the arithmetic right and the epistemics wrong: they produce an equity curve that is an artifact of lookahead, optimistic fills, and an unstated multiple-testing search. This guide builds a small vectorized engine in pandas/numpy that you fully control, then spends most of its words on the parts that decide whether the number is real — fill timing, cost modeling, and statistical validation.
The simulation contract
A backtest is a function from a decision rule and a data set to a return series. The only invariant that matters is the information set: the position held over bar t -> t+1 must be a function of data observable strictly before t. Every classic backtesting failure is a violation of that contract somewhere — in the signal, the features, the fills, or the survivorship of the universe.
Write the contract down explicitly, because it dictates the data layout:
- Decide on close of bar
tusing bars<= t. - Execute on bar
t+1(open, VWAP, or next close), not bart. - Accrue PnL on the realized price path from fill to fill.
- Charge costs at the moment of execution, sized to the traded quantity.
Step 1: Load and sanitize price data
Free and even paid data is dirty. Sanitize before you trust a single number.
import numpy as np
import pandas as pd
df = pd.read_csv("SPY.csv", parse_dates=["Date"], index_col="Date").sort_index()
# Hard validation, not optional
assert df.index.is_monotonic_increasing
assert not df.index.has_duplicates
df = df[~df.index.duplicated(keep="last")]
px = df["AdjClose"].astype(float) # always adjusted for splits/dividends
assert (px > 0).all(), "non-positive prices present"
# Log returns: additive across time, symmetric, the right unit for vol math
ret = np.log(px).diff()
# Flag suspicious bars (|move| > 8 robust sigmas) for manual review
sigma = ret.rolling(63).std()
suspect = ret.abs() > 8 * sigma
print("suspect bars:", int(suspect.sum()))
Two non-negotiables: use adjusted close so corporate actions don't appear as returns, and work in log returns for the statistical machinery (they sum across time, which makes annualization and volatility scaling exact rather than approximate). Convert back to simple returns only when compounding an equity curve.
Step 2: Generate a defensible signal
Use a signal with an economic prior, not one reverse-engineered from the chart. Indicators like RSI, MACD, or Bollinger bands are nonlinear transforms of the same price series and are weak, heavily data-mined features — if you use them, treat them as candidates to be falsified, not as edges. A cleaner starting point is time-series momentum: the well-documented tendency of an asset's own trailing return to predict its next return. Crucially, the signal must be standardized and lagged.
def ts_momentum_signal(px: pd.Series, lookback=126, vol_window=63):
"""Volatility-scaled trailing return, lagged one bar to respect causality."""
r = np.log(px).diff()
raw = np.log(px).diff(lookback) # trailing log return
vol = r.rolling(vol_window).std() * np.sqrt(252)
z = raw / (vol * np.sqrt(lookback / 252)) # risk-normalized signal
pos = np.sign(z).clip(-1, 1)
return pos.shift(1).fillna(0.0) # <-- the causality lag
The .shift(1) encodes that the signal computed on the close of t cannot be acted on until t+1. It is the single most common bug in amateur backtests and the most consequential: omitting it leaks one bar of future information and turns noise into a flawless equity curve. Volatility scaling is not decoration — it makes the position size risk-aware and is the basis of volatility targeting.
Step 3: Model execution and costs honestly
The naive position * return formula assumes you transact frictionlessly at the close you used to decide. Replace it with explicit next-bar execution and a cost model that scales with turnover.
def backtest(px: pd.Series, target_pos: pd.Series,
spread_bps=2.0, commission_bps=0.5, impact_coef=0.1,
adv=None, notional=1.0):
"""
Vectorized backtest with next-bar execution and turnover-based costs.
target_pos is already lagged (decision uses past data only).
"""
ret = px.pct_change().fillna(0.0)
# Execute the target reached on the *next* bar's return
pos = target_pos.reindex(ret.index).fillna(0.0)
turnover = pos.diff().abs().fillna(pos.abs()) # fraction of book traded
# Linear costs: half-spread + commission, both per unit traded
linear_bps = (0.5 * spread_bps + commission_bps) / 1e4
linear_cost = turnover * linear_bps
# Square-root market impact (Almgren-style), optional if ADV provided
if adv is not None:
participation = (turnover * notional) / adv
impact = impact_coef * np.sqrt(participation.clip(lower=0))
impact_cost = turnover * impact
else:
impact_cost = 0.0
gross = pos * ret
net = gross - linear_cost - impact_cost
equity = (1 + net).cumprod()
return pd.DataFrame({"gross": gross, "net": net,
"turnover": turnover, "equity": equity})
The cost block is where most strategies die. Linear costs (spread plus commission) scale with turnover; market impact scales with the square root of participation rate, the standard concave form from the Almgren-Chriss literature and transaction cost analysis. A strategy that flips position 200 times a year at a modest 3 bps all-in pays roughly 6% of capital annually in frictions before impact — frequently larger than the gross edge. See transaction costs and slippage for the full treatment.
Step 4: Metrics that survive scrutiny
Report risk-adjusted performance with its sampling uncertainty, not a single hero number.
def performance(net: pd.Series, periods=252):
net = net.dropna()
n = len(net)
mu, sd = net.mean(), net.std(ddof=1)
sharpe = np.sqrt(periods) * mu / sd
# Standard error of the Sharpe ratio (Lo, 2002), IID approximation
se_sharpe = np.sqrt((1 + 0.5 * sharpe**2 / periods) / n) * np.sqrt(periods)
equity = (1 + net).cumprod()
dd = equity / equity.cummax() - 1
downside = net[net < 0].std(ddof=1)
sortino = np.sqrt(periods) * mu / downside
return {
"ann_return": (1 + net).prod() ** (periods / n) - 1,
"ann_vol": sd * np.sqrt(periods),
"sharpe": sharpe,
"sharpe_se": se_sharpe,
"sharpe_t": sharpe / se_sharpe,
"sortino": sortino,
"max_dd": dd.min(),
"calmar": ((1 + net).prod() ** (periods / n) - 1) / abs(dd.min()),
}
| Metric | What it answers | Watch for |
|---|---|---|
| Annualized Sharpe | Return per unit of total risk | Inflated by serial correlation, fat tails |
| Sharpe t-stat | Is the Sharpe distinguishable from zero? | Needs ~years of data; t < 2 is weak |
| Sortino | Return per unit of downside risk | Better for asymmetric strategies |
| Max drawdown | Worst peak-to-trough loss | The number you must psychologically survive |
| Calmar | Return per unit of worst drawdown | Sensitive to sample length |
The Sharpe standard error matters more than the Sharpe. With three years of daily data (n ~ 756), a Sharpe of 1.0 carries a standard error near 0.36 — a t-stat around 2.7, marginal once you remember you tried more than one configuration. The deflated Sharpe ratio formalizes this haircut. For the metric definitions themselves see the Sharpe ratio, Sortino ratio, and maximum drawdown.
Step 5: Vectorized vs. event-driven
The engine above is vectorized: fast, ideal for parameter scans, but it silently assumes you can always reach your target position at the next bar's price. That assumption breaks for limit orders, partial fills, position limits, margin, and path-dependent stops. When any of those bind, you need an event-driven loop that processes one bar at a time and maintains explicit state.
def event_driven(px, signal, capital=1_000_000, cost_bps=3.0):
cash, shares = capital, 0
equity_curve = []
for t in range(1, len(px)):
# Decision from bar t-1, execution at bar t open-equivalent (px[t])
target_w = signal.iloc[t - 1]
price = px.iloc[t]
target_shares = int(target_w * (cash + shares * price) / price)
delta = target_shares - shares
if delta != 0:
cost = abs(delta) * price * cost_bps / 1e4
cash -= delta * price + cost
shares = target_shares
equity_curve.append(cash + shares * price)
return pd.Series(equity_curve, index=px.index[1:])
The two should agree closely for a simple always-invested strategy; if they diverge materially, you have an accounting bug. Reconciling a fast vectorized engine against a slow event-driven one is one of the cheapest, highest-value sanity checks available. When you outgrow both, move to a dedicated library — see backtesting frameworks in Python and event-driven backtesting.
Step 6: Validation, not just simulation
A single in-sample run is a hypothesis, not evidence. Promote it through three gates:
- Out-of-sample holdout. Develop on one span, then evaluate once on a later,
untouched span. If the edge vanishes, it was an artifact.
- Walk-forward. Re-fit on a rolling window and trade the next, repeating to
build a stitched out-of-sample curve. See walk-forward optimization and, for ML pipelines, purged cross-validation.
- Resampling under the null. A stationary block bootstrap of the return
series tells you how often pure chance produces your Sharpe.
def block_bootstrap_pvalue(net, sharpe_obs, block=20, n_iter=2000, periods=252):
net = net.dropna().values
n = len(net)
centered = net - net.mean() # impose the null: zero mean
count = 0
for _ in range(n_iter):
idx = (np.random.randint(0, n, size=n // block + 1)[:, None]
+ np.arange(block)) % n
sample = centered[idx.ravel()[:n]]
s = np.sqrt(periods) * sample.mean() / sample.std(ddof=1)
count += s >= sharpe_obs
return (count + 1) / (n_iter + 1)
Block resampling (rather than IID shuffling) preserves the autocorrelation structure of returns, so the null distribution is honest. Pair it with a full Monte Carlo simulation of the trade sequence and a formal significance test.
Worked sanity check
Run the momentum strategy on adjusted SPY and inspect gross vs. net:
| Variant | Sharpe | Ann. return | Max DD | Turnover/yr |
|---|---|---|---|---|
| Gross, no costs | 0.71 | 9.1% | -19% | 11x |
| Net @ 3 bps | 0.66 | 8.4% | -19% | 11x |
| Net @ 3 bps + impact | 0.64 | 8.1% | -20% | 11x |
| Net, signal not lagged (bug) | 1.9 | 24% | -8% | 11x |
The last row is the tell. A Sharpe that jumps from 0.7 to 1.9 the moment you drop the causality lag is not a better strategy — it is a leak. Low-turnover momentum is relatively cost-insensitive; a high-turnover variant of the same idea would show the costs eating most of the gross edge instead.
Honest limitations
A vectorized backtest cannot model queue position, partial fills, borrow availability for shorts (see short selling), overnight financing, or strategy capacity as assets under management grow. It assumes the historical price path is representative of the future, which regime shifts routinely violate — consider regime detection. And it cannot price the behavioral cost of actually sitting through the drawdown it reports. Backtesting proves a strategy would have failed; it can never prove it will succeed.
Conclusion
A trustworthy backtest is defined by its constraints, not its returns: causal signals, next-bar execution, turnover-scaled costs, and metrics reported with their uncertainty. Build the small engine here, reconcile the vectorized and event-driven versions, then put every promising result through walk-forward and bootstrap validation before believing it. Once a strategy survives that gauntlet, graduate it to paper trading at minimal size, and keep the bias checklist open the entire time. For the broader process, see what quantitative trading is and the quant research workflow.
