QTQuant Terminal
D4-02D4·intermediate·~20 min

Building an honest backtest — anatomy, equity curve, drawdown, turnover

backtestingvectorizedequity-curvemax-drawdownturnover

▸ Pretest — guess, even if you don't know

A vectorized backtest computes daily P&L as position times return. To avoid look-ahead bias, which position must be multiplied by today's return?

The five-line backtest

Every vectorized backtest is a variation of this numpy skeleton. Each line is a place where the backtest can lie:

import numpy as np

returns = prices[1:] / prices[:-1] - 1   # r_t. Adjusted for splits/dividends? Point-in-time universe?
signal = signal_from(returns)            # one value per day. Does it peek at future bars? Tuned on this data?
position = np.sign(signal)               # what you hold: +1 long, -1 short, 0 flat
pnl = position[:-1] * returns[1:]        # THE SHIFT: yesterday's position earns today's return. Costs missing!
equity = np.cumprod(1 + pnl)             # compound growth of one unit of capital

The annotations are the Big Six from D4-01 mapped onto code. Look-ahead bias hides in lines 2–4, survivorship bias in line 1's universe, cost optimism in line 4, and overfitting in whatever signal_from does. The shift in line 3 is the same fix you implemented in the D4-01 exercise — signals lag returns by one period, always.

The equity curve

equity = np.cumprod(1 + pnl) turns per-period returns into the growth of one unit of capital:

Et=st(1+rs)E_t = \prod_{s \le t} (1 + r_s)

This is what compounding actually does to your account, and it is the input to the risk metric that matters most in practice.

Max drawdown, precisely

Max drawdown is the largest peak-to-trough fall of the equity curve — the worst loss you would have suffered buying at a peak. Define the running maximum Mt=maxstEsM_t = \max_{s \le t} E_s, then:

MDD=maxt(1EtMt)\text{MDD} = \max_t \left( 1 - \frac{E_t}{M_t} \right)

In numpy the running maximum is one call: np.maximum.accumulate(equity). Drawdown matters because it is what makes you (or your investors) quit. A strategy with Sharpe 1.2 and a 60% drawdown will not survive contact with a human being.

Turnover — the cost model's input

Turnover measures how much you trade: with position ptp_t,

turnovert=ptpt1\text{turnover}_t = |p_t - p_{t-1}|

Going from flat to long is 1 unit; flipping long to short is 2. Total turnover, summed over the backtest, is exactly what transaction costs multiply. A backtest that doesn't report turnover cannot be cost-adjusted, which means it cannot be believed. D4-03 builds the cost model on this number.

Vectorized vs event-driven — the honest trade-off

The honest workflow: research vectorized, then confirm the survivors event-driven before real money. Neither replaces the other.

Try it

▮ EXERCISE · d4-02-ex1

Implement max_drawdown(equity): the maximum peak-to-trough decline of an equity curve, as a fraction. Compute the running maximum with np.maximum.accumulate, then return the max of 1 - equity / running_max.

⧉ Review card
In a vectorized backtest, how do you pair positions with returns honestly?
Position for day t is decided with data through day t-1. In numpy: pnl = position[:-1] * returns[1:] (or shift the position array by one). Pairing same-index position and return is look-ahead bias.
⧉ Review card
How do you compute an equity curve from a return series?
equity = np.cumprod(1 + returns). Each element is the compounded growth of one unit of initial capital up to that period.
⧉ Review card
What is max drawdown and how is it computed?
The largest peak-to-trough decline of the equity curve: max over t of 1 - E_t / M_t, where M_t = running maximum of equity (np.maximum.accumulate). It measures the worst loss from buying at a peak.
⧉ Review card
What is turnover and why must a backtest report it?
Sum of absolute position changes: turnover_t = abs(p_t - p_t-minus-1). Flat to long = 1 unit, long to short = 2. It is the quantity transaction costs multiply — without it a backtest cannot be cost-adjusted.
⧉ Review card
Vectorized vs event-driven backtests — when to use which?
Vectorized: whole-history numpy arrays, fast, ideal for research iteration, but assumes idealized fills and invites look-ahead bugs. Event-driven: bar-by-bar loop, cannot see the future, models fills and latency — use before deploying real money.

Draw the machine

Your generative activity: on paper, draw the backtest pipeline as boxes and arrows — prices, signal, shifted position, P&L, equity curve — and mark with an X each spot where one of the Big Six can enter. Then sketch an equity curve that rises, dips, and recovers; draw its running maximum on top and shade the max drawdown. Don't peek at the lesson while drawing.

Predict before the next lesson

D4-03 puts numbers on transaction costs. Predict:

◈ Calibration check

Could you write the five-line vectorized backtest from memory, with the position shift in the right place, and compute max drawdown and turnover from its outputs?

1 = guessing · 5 = could teach it

⏻ End of lesson

Mark it read to book its 5 review cards into your deck.

Sources & further reading