Skip to content
GetProfitable
Search

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:

  1. No commissions, no spread, no slippage. Every trade is free.
  2. Fills at the exact close. Real fills happen at the next open or later.
  3. Position is 0 or 100% of equity. No sizing by risk.
  4. One instrument, one parameter set, one path of history. No idea of variance.
  5. 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_vectorized returns 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.

An equity curve and its drawdownAn account balance rising over a year, falling from a peak to a trough, then climbing back to the old peak.ACCOUNT EQUITY$20k$12k$8k024681012TIME (MONTHS)PEAK $16,000TROUGH $12,000DRAWDOWN−25%RECOVERY
Equity curve and drawdown. An account balance plotted month by month. The fall from the $16,000 peak to the $12,000 trough is a 25% drawdown, and the shaded area lasts until the balance climbs back to the old peak.
A fast and a slow moving average crossingA jagged price line with two smoother average lines through it; the fast average dips below the slow one on the left and cuts back above it in the middle, where a circle marks the crossing.pricefast averageslow averagefast crosses belowfast crosses abovethe slow averageAverages of recent closes; the fast one reacts sooner than the slow one.
Fast and slow moving averages crossing. A moving average is the average of the last few closing prices, redrawn each period. An average over fewer periods turns sooner than one over many, so the two lines cross whenever the recent pace of the market changes.
Bid-ask spread in an order bookSell orders stacked above buy orders with a gap between the best of each.SELLERS (asks)50.0690050.051,40050.0460050.011,10050.002,30049.99800spread = 0.03BUYERS (bids)
The bid-ask spread. Buy orders sit below, sell orders above, and the gap between the best bid (50.01) and best ask (50.04) is the spread you pay to cross. Bar length shows the size resting at each price.

Finished this module? Take the module quiz.