A vectorized backtest and the equity curve
Lesson 12 · about 12 min
The previous lesson did a backtest by hand, one column at a time. This one wraps it into a function you will reuse for the rest of the course, adds the equity and drawdown chart every backtest should produce, and defines what "vectorized" means so that you understand its limits before Module 7 replaces it with something heavier.
What vectorized means
A vectorized backtest computes every bar's position and return as whole-column operations, with no loop over time. It works because a close-to-close strategy with a known position on each bar has returns that depend only on that bar: position[t] × asset_ret[t]. There is no state that needs to be carried forward bar by bar, so pandas can do it in one pass.
That is also its limitation. Anything where what happens inside a bar depends on a price path (a stop-loss hit intrabar, a limit order that may or may not fill) breaks the assumption. Module 7 handles those. For crossover-style and many mean-reversion strategies on daily bars, vectorized is exact and fast enough to run thousands of variations.
The function
# src/backtest.py
from dataclasses import dataclass
import numpy as np
import pandas as pd
@dataclass(frozen=True)
class BacktestResult:
frame: pd.DataFrame # per-bar columns: position, asset_ret, strat_ret, equity
final_equity: float
n_trades: int
def run_vectorized(close: pd.Series, signal: pd.Series, start_equity: float = 1.0) -> BacktestResult:
"""Close-to-close backtest. position[t] = signal[t-1]; returns = position * asset return."""
if not close.index.equals(signal.index):
raise ValueError("close and signal must share an index")
asset_ret = close.pct_change()
position = signal.shift(1).fillna(0.0)
strat_ret = (position * asset_ret).fillna(0.0)
equity = start_equity * (1 + strat_ret).cumprod()
turnover = position.diff().abs().fillna(0.0)
frame = pd.DataFrame(
{"close": close, "signal": signal, "position": position,
"asset_ret": asset_ret, "strat_ret": strat_ret, "turnover": turnover, "equity": equity}
)
n_trades = int((turnover > 0).sum())
return BacktestResult(frame=frame, final_equity=float(equity.iloc[-1]), n_trades=n_trades)
@dataclass(frozen=True) gives a small immutable container; you cannot accidentally overwrite final_equity later. The function refuses mismatched indexes rather than letting pandas align them silently, which would fill gaps with NaN and turn missing bars into zero-return days without telling you.
Use it:
from src.data import synthetic_ohlcv
from src.indicators import sma
def ma_crossover_signal(close, fast=20, slow=50):
f, s = sma(close, fast), sma(close, slow)
signal = (f > s).astype(float)
signal[s.isna()] = np.nan
return signal
bars = synthetic_ohlcv(1000, seed=42)
result = run_vectorized(bars["close"], ma_crossover_signal(bars["close"]))
print(f"final equity {result.final_equity:.3f}, trades {result.n_trades}")
print(result.frame.tail(3).round(4))
Drawdown
Drawdown at bar t is how far equity has fallen from its highest value so far.
def drawdown_series(equity: pd.Series) -> pd.Series:
peak = equity.cummax()
return equity / peak - 1.0
dd = drawdown_series(result.frame["equity"])
print(f"max drawdown: {dd.min():.2%}")
print(f"currently in drawdown of: {dd.iloc[-1]:.2%}")
cummax is the running peak. Drawdown is zero at every new high and negative otherwise; the minimum is the maximum drawdown. It is the number most traders care about second only to the return, because it is the one that determines whether they would have kept running the system.
The standard chart
Every backtest in the course produces this figure. Save it as a function so it is one call.
import matplotlib.pyplot as plt
def plot_backtest(result: BacktestResult, title: str = "Backtest") -> None:
f = result.frame
dd = drawdown_series(f["equity"])
bench = (1 + f["asset_ret"].fillna(0)).cumprod() * f["equity"].iloc[0]
fig, (ax1, ax2, ax3) = plt.subplots(
3, 1, figsize=(11, 8), sharex=True, gridspec_kw={"height_ratios": [3, 1, 1]}
)
f["equity"].plot(ax=ax1, label="strategy")
bench.plot(ax=ax1, label="buy & hold", alpha=0.6)
ax1.set_title(title)
ax1.legend()
dd.plot(ax=ax2, color="red")
ax2.fill_between(dd.index, dd, 0, color="red", alpha=0.3)
ax2.set_title("Drawdown")
f["position"].plot(ax=ax3, drawstyle="steps-post")
ax3.set_title("Position")
plt.tight_layout()
plt.show()
plot_backtest(result, "SMA 20/50 crossover, synthetic data, no costs")
The position panel at the bottom is not decoration. It shows you at a glance whether the strategy is in the market half the time or 95% of the time, whether trades cluster, and whether the last year was one long position or forty short ones. Two strategies with the same final equity and very different position panels are very different strategies.
Key idea: A vectorized backtest is exact for close-to-close strategies and wrong for anything that depends on the path inside a bar. Use it for speed, know its boundary, and always produce the equity, drawdown and position chart.
What this backtest still gets wrong
Write this list down, because Modules 5 and 6 work through it:
- No commissions, no spread, no slippage. Every trade is free.
- Fills at the exact close. Real fills happen at the next open or later.
- Position is 0 or 100% of equity. No sizing by risk.
- One instrument, one parameter set, one path of history. No idea of variance.
- No stops, no limits: exits only happen when the signal changes.
Item 1 alone can turn a profitable-looking crossover into a losing one, and it takes ten lines to fix.
Try it: Run the backtest on ten different seeds (
for seed in range(10)) and print final equity, number of trades and max drawdown for each. The spread across seeds, on data with no real edge at all, is your first look at how much variation pure noise produces. Keep that spread in mind every time a single backtest result impresses you.
Recap
- Vectorized backtests compute position and return as whole columns; they are exact only when returns depend on the bar, not the path within it.
run_vectorizedreturns a frozen result with the per-bar frame, final equity and trade count.- Drawdown is
equity / equity.cummax() - 1; its minimum is the maximum drawdown. - The standard chart has equity versus benchmark, drawdown, and position panels.
- This version has no costs, exact-close fills, all-or-nothing sizing and no stops; each is fixed in later modules.
See it drawn
Original diagrams for the ideas on this page. Illustrative, not real market data.