Vectorised backtesting from scratch
The fastest way to test an idea: turn signals into positions into returns with pure pandas — no loop, no engine, a whole strategy in a dozen lines.
A vectorised backtest treats your entire price history as one big array and computes the strategy in a handful of column operations — no for loop over bars. It is the single most useful tool in this course: you can test an idea across ten years of data in milliseconds, which means you can throw away bad ideas fast and cheaply.
The whole technique rests on one chain of thought: prices → signal → position → strategy returns → equity curve. Master that chain and you can backtest almost any rule-based strategy without writing an engine.
Step 1 — get prices and market returns
We start with a daily close series. The market return is the percentage change from one close to the next — this is what a buy-and-hold investor earns, and the yardstick every strategy is measured against.
import numpy as np
import pandas as pd
import yfinance as yf
class="c"># Daily closes for a liquid ETF; use any OHLCV source you like
df = yf.download(class="s">"SPY", start=class="s">"class="n">2015-class="n">01-class="n">01", end=class="s">"class="n">2024-class="n">12-class="n">31", auto_adjust=True)
px = df[class="s">"Close"].dropna()
class="c"># Simple (arithmetic) daily returns of the underlying market
market_ret = px.pct_change().fillna(class="n">0.0)
print(px.head())
print(market_ret.describe())Step 2 — the signal
Our strategy is the classic moving-average cross: go long when a fast average is above a slow average, flat otherwise. The signal is a boolean condition evaluated across the *whole* series at once — that is the vectorised part.
fast = px.rolling(class="n">20).mean()
slow = px.rolling(class="n">100).mean()
class="c"># Raw signal: +class="n">1 (long) when fast > slow, else class="n">0 (flat). No loop.
signal = (fast > slow).astype(int)
class="c"># How often are we in the market?
print(fclass="s">"Time in market: {signal.mean():.class="n">1%}")Step 3 — position, lagged correctly
We shift the signal forward by one bar to get the position we actually hold. Today we act on yesterday's completed signal. This single .shift(1) is the difference between an honest backtest and a fantasy.
class="c"># Hold today the position implied by yesterday's signal
position = signal.shift(class="n">1).fillna(class="n">0.0)Step 4 — strategy returns and the equity curve
Now the payoff. Multiply the position by the market return to get the strategy's daily return, then compound it into an equity curve — the running value of one unit of capital.
class="c"># Daily P&L of the strategy (costless for now — Lesson class="n">3 fixes that)
strategy_ret = position * market_ret
class="c"># Equity curves: compound the daily returns. Start at class="n">1.0 = class="n">100% of capital.
equity = (class="n">1 + strategy_ret).cumprod()
buy_hold = (class="n">1 + market_ret).cumprod()
total_return = equity.iloc[-class="n">1] - class="n">1
print(fclass="s">"Strategy total return: {total_return:.class="n">1%}")
print(f"Buy & hold total return: {buy_hold.iloc[-class="n">1] - class="n">1:.class="n">1%}")That is a full backtest in roughly fifteen lines. The equity curve below is the shape you will spend the rest of your trading life staring at — a strategy that sidesteps the worst drawdowns by going flat, at the cost of missing part of the raw upside.
Why vectorised is the right first tool
- Speed — no Python-level loop; pandas/NumPy run the whole thing in C. You can sweep hundreds of parameter sets in seconds.
- Clarity — the entire logic is visible as one chain of column operations. Bugs have nowhere to hide.
- Honesty is one method away — get
.shift(1)right and you have eliminated the most common source of fake edge.
Next we make this backtest *mean* something: a fifteen-line script can print a return, but a return alone tells you almost nothing about whether the strategy is any good.