Quant Memo

Getting Started

← All guides

Your First Backtest, Done Right

Build a vectorized backtest in pandas from first principles, signal, position, costs, and metrics, then see exactly how a one-line lookahead bug manufactures fake alpha.

Almost every retail backtest is wrong, and almost always in the same direction: it looks better than reality. The reason is rarely exotic, it's a subtle information leak or a forgotten cost. This guide builds a backtest small enough to hold in your head (about 20 lines), so that when the bugs appear you can see them. We'll use a simple moving-average crossover, not because it's good, it isn't, but because it makes the mechanics obvious.

Everything here is plain pandas/numpy. If you followed getting market data you can swap the synthetic series for real prices in one line.

The mental model

A vectorized backtest is four aligned time series:

  1. a signal, what you believe right now,
  2. a position, what you actually hold, which can only use information available before the bar,
  3. asset returns, what the market did,
  4. strategy returns, position times asset return, minus costs.

The entire discipline of backtesting is keeping the timing of those four series honest. Get the alignment wrong by one bar and you are trading on tomorrow's news.

Step 1, a price series

We'll generate a deterministic geometric-Brownian-motion series so the code is reproducible and needs no network. (To use real data, replace this block with price = yf.download("SPY")["Close"].)

import numpy as np
import pandas as pd

rng = np.random.default_rng(42)
n = 1000
rets = rng.normal(0.0003, 0.01, n)          # ~7.5% drift, 16% annual vol
price = pd.Series(
    100 * np.exp(np.cumsum(rets)),
    index=pd.bdate_range("2020-01-01", periods=n),
    name="close",
)

Step 2, the signal

Go long when the fast moving average is above the slow one, flat otherwise:

fast = price.rolling(20).mean()
slow = price.rolling(100).mean()
signal = (fast > slow).astype(int)          # 1 = long, 0 = flat

At this point signal[t] uses price[t], it is only known at the close of bar t. So you cannot trade on it during bar t. This is the single most important sentence in this guide.

Step 3, the position (where lookahead lives)

You act on the signal on the next bar:

position = signal.shift(1).fillna(0)

That shift(1) is the entire ballgame. It says: "the position I hold during bar t was decided using information available at the end of bar t-1." Delete it, use position = signal, and your backtest silently starts using the closing price of a bar to trade that same bar, which is impossible. We'll measure exactly how much free money that fabricates in a moment.

Step 4, returns and costs

asset_ret = price.pct_change().fillna(0)
gross = position * asset_ret

Now costs. Amateurs apply a flat haircut to returns; that's wrong, costs are paid on trades, not on holdings. You pay when the position changes. Turnover is the absolute change in position, and each unit of turnover costs you the round-trip spread plus impact (here, a flat 5 bps as a stand-in, see Transaction Costs for why real impact is nonlinear):

cost_per_turn = 0.0005                       # 5 bps per unit traded
turnover = position.diff().abs().fillna(0)
net = gross - turnover * cost_per_turn

Charging on turnover, not on position, is what makes a high-frequency signal look as expensive as it really is.

Step 5, equity curves and metrics

equity_gross = (1 + gross).cumprod()
equity_net = (1 + net).cumprod()

def sharpe(r, ppy=252):
    mu, sd = r.mean(), r.std()
    return np.nan if sd == 0 else (mu / sd) * np.sqrt(ppy)

def max_drawdown(equity):
    return (equity / equity.cummax() - 1).min()

The 252\sqrt{252} annualizes the daily Sharpe Ratio under the (wrong but standard) assumption of i.i.d. daily returns; the drawdown is the worst peak-to-trough decline of the equity curve. Running it on our series gives a net Sharpe of −0.46 and a max drawdown of −30%, the crossover loses money net of costs. That is the honest, common result: a naive technical rule on a nearly-efficient series has no edge, and costs finish it off.

The lookahead experiment

Here is why the shift(1) matters, made quantitative. Compute the strategy two ways, correctly, and with the same-bar bug:

gross_correct = position * asset_ret                 # position = signal.shift(1)
gross_bug     = signal * asset_ret                    # uses today's close to trade today

print(sharpe(gross_correct))   # -0.44
print(sharpe(gross_bug))       # -0.27  <-- "better", and entirely fake

The bug improves the Sharpe by using information that did not exist at trade time. On a series with real autocorrelation or on a higher-frequency signal, this single missing shift can turn a losing strategy into a spectacular one, which is exactly how thousands of "profitable" retail backtests are born. If your backtest looks amazing, suspect lookahead first. See Look-Ahead Bias for the full taxonomy (restated fundamentals, survivor-adjusted indices, full-sample normalization).

What this backtest still isn't telling you

Even done correctly, this is a lower bound on honesty, not an upper bound on realism. It still assumes:

  • You trade at the close you measured. Real fills happen after your signal, at a worse price. Model at least a one-bar execution lag and slippage.
  • Costs are linear and small. Real impact grows roughly with the square root of size; a strategy that works at $10k can be uninvestable at $10m, see Portfolio Capacity.
  • The universe is fixed and survived. Backtesting today's index constituents bakes in Survivorship Bias.
  • You only tried this once. If you sweep 500 parameter pairs and report the best, the winner's Sharpe is inflated by multiple testing; deflate it (see The Deflated Sharpe Ratio).

A backtest is not a forecast. It is the most optimistic thing that could have happened to a strategy that already knew it was being tested. Treat every number as an upper bound and spend your effort trying to break it.

Full script

import numpy as np
import pandas as pd

rng = np.random.default_rng(42)
n = 1000
price = pd.Series(
    100 * np.exp(np.cumsum(rng.normal(0.0003, 0.01, n))),
    index=pd.bdate_range("2020-01-01", periods=n), name="close",
)

signal = (price.rolling(20).mean() > price.rolling(100).mean()).astype(int)
position = signal.shift(1).fillna(0)                  # no lookahead
asset_ret = price.pct_change().fillna(0)
turnover = position.diff().abs().fillna(0)
net = position * asset_ret - turnover * 0.0005
equity = (1 + net).cumprod()

sharpe = (net.mean() / net.std()) * np.sqrt(252)
maxdd = (equity / equity.cummax() - 1).min()
print(f"Net Sharpe {sharpe:.2f} | MaxDD {maxdd:.1%} | Final {equity.iloc[-1]:.3f}")

Once this is second nature, the natural next step is a strategy with an actual thesis. The dual-momentum code-along in the Build It Yourself series reuses exactly this skeleton, signal, shift, turnover, metrics, on a cross-section of assets.

The theory behind it

Related strategies

Further reading

  • Marcos López de Prado, Advances in Financial Machine Learning
  • Bailey et al., Pseudo-Mathematics and Financial Charlatanism (Notices of the AMS, 2014)

Code is for education, not investment advice. Test everything on paper before risking capital.