Building a backtest in Python

Premieur InstituteRead time: 16 minutes

A moving average crossover on one asset, end to end in pandas: signal, lagged position, returns, trading costs, equity curve, Sharpe ratio and maximum drawdown.

The best way to understand what a backtest can and cannot tell you is to build one. This topic takes the simplest systematic rule there is, a moving average crossover on a single asset, and implements it end to end in a few dozen lines of pandas. Long when the five day average is above the ten day average, flat otherwise.

The point is not the rule, which has no particular edge. The point is the plumbing that every backtest shares: compute a signal from information available at the close, act on it only from the next bar, charge for every change in position, compound the returns into an equity curve and summarise it with a Sharpe ratio and a maximum drawdown. The listings run on a 40 day toy price series defined inline, and the printed results were reproduced independently.

Key takeaways

  • A signal computed at the close of day t can only earn returns from day t plus 1; shift(1) on the position is the line that prevents look ahead bias.
  • Trading costs are charged on the change in position, abs(position.diff()), times a cost per unit traded.
  • The equity curve is the cumulative product of one plus the net daily return; drawdown is equity divided by its running maximum, minus one.
  • On the toy series, removing the one day lag raises the total return from 4.08% to 7.14%, the clearest demonstration of how much look ahead is worth.
  • Statistics from 40 days, and especially annualised ones, are illustrations of the method, not evidence about the rule.

The data and the signal

The series is 40 daily closes for one hypothetical asset: a rise from 100 to 107, a fall to 99.5, then a recovery to 111. It is short enough to check by hand and long enough to produce a few crossovers. Prices are indexed by business days starting on 2 January 2024.

The rule compares two simple moving averages of the close: a fast one over 5 days and a slow one over 10. When the fast average is above the slow one, the trend is up and the signal is 1 (long); otherwise it is 0 (flat). The first nine days have no 10 day average, the comparison returns False and the signal starts at 0, which is the right default: no position until the rule has enough data.

Both averages at day t use the close of day t. That is fine for a signal, as long as the trade happens after the close is known, which the next step enforces.

Toy prices and the crossover signalPython
import numpy as np
import pandas as pd

close = pd.Series(
    [100.0, 100.8, 101.5, 101.1, 102.3, 103.0, 102.6, 103.8, 104.5, 105.2,
     104.9, 106.1, 107.0, 106.4, 105.8, 104.9, 103.7, 102.8, 103.5, 102.1,
     101.4, 100.6, 101.2, 100.3, 99.5, 100.4, 101.6, 102.5, 103.9, 104.6,
     105.8, 105.1, 106.7, 107.9, 108.4, 107.6, 108.9, 110.2, 109.5, 111.0],
    index=pd.bdate_range("2024-01-02", periods=40),
    name="close",
)

FAST, SLOW = 5, 10
fast = close.rolling(FAST).mean()
slow = close.rolling(SLOW).mean()

# 1 = long, 0 = flat, decided once the day's close is known
signal = (fast > slow).astype(int)

Lagging the position to avoid look ahead

The signal on day t is known only after day t closes. The earliest the strategy can hold the position is from that close to the next one, so it earns day t plus 1's return. In code that is position = signal.shift(1): the position on each day is yesterday's signal. Forget this line and the backtest earns the return of the very day whose close produced the signal, which is look ahead bias in its purest form.

The daily asset return is the close over the previous close, minus one. The strategy's gross return each day is the position times the asset return. On the toy series, the signal first turns on at the close of 15 January (day 10 of the series), so the strategy is first long on 16 January.

Position, returns and trading costsPython
position = signal.shift(1).fillna(0)    # trade on the next bar, never the same one
asset_ret = close.pct_change().fillna(0)
gross = position * asset_ret

COST = 0.0010                             # 10 bps per unit of position traded
trades = position.diff().abs().fillna(0)  # 1 when entering or exiting, else 0
net = gross - trades * COST

equity = (1 + net).cumprod()
For a long-only, all-in or all-out rule, trades is 1 on each entry or exit. For fractional positions or long-short rules, the same expression charges for the size of each change.
The days the position changes (returns and costs in percent)
DateCloseFast MASlow MASignalPositionAsset returnTradeNet return
2024-01-15105.2103.82102.4810+0.67000.000
2024-01-16104.9104.20102.9711-0.2851-0.385
2024-01-25102.8104.72105.1301-0.8680-0.868
2024-01-26103.5104.14105.0300+0.6811-0.100
2024-02-09103.9101.58101.3510+1.36600.000
2024-02-12104.6102.60101.6011+0.6741+0.574
On 16 January the strategy enters and loses the day's 0.285% plus 0.10% cost. On 26 January it exits: it misses the 0.681% rebound but still pays 0.10% to sell. Three trades in total over the 40 days.

Costs per trade

Costs are charged on the change in position. Entering a full position from flat trades one unit, exiting trades one unit, and holding trades nothing. Multiplying that by a cost per unit, here 10 basis points to cover commission, spread and impact, and subtracting it from the gross return gives the net return. The cost lands on the day of the trade, which matches when the money is actually spent.

Ten basis points is generous for a liquid large cap stock or index future and stingy for a small cap or a large order. The right habit is not to pick one number but to show the result across a range, and to find the cost at which the strategy stops working. On this series the rule trades only three times, so costs matter less than they would for a high turnover rule, but even here they take a visible bite.

The crossover rule on the toy series at different cost levels
Cost per unit tradedTotal returnAnnualised SharpeMax drawdown
0 bps (gross)4.40%2.66-3.93%
10 bps4.08%2.47-4.02%
50 bps2.84%1.72-4.41%
100 bps1.30%0.78-5.20%
Three trades at 100 bps cost about 3 percentage points of return, enough to take away most of the gross 4.40%.

Equity curve, Sharpe and maximum drawdown

The equity curve is the growth of one unit of capital: the cumulative product of one plus each day's net return. Its final value minus one is the total return. The Sharpe ratio here is the mean daily net return over its standard deviation, multiplied by the square root of 252 to annualise, with the risk-free rate taken as zero for simplicity. Maximum drawdown is the worst fall from a running peak: divide the equity curve by its running maximum, subtract one and take the minimum.

A small function that takes any daily return series and returns these three figures lets you compare the strategy with buy and hold, with itself before costs, and with a deliberately broken version that skips the lag. Seeing the broken version side by side is the most useful part of the exercise.

Summary statistics from daily returns
Sharpe = mean(r) / std(r) x sqrt(252); drawdown(t) = equity(t) / max(equity up to t) - 1; max DD = min over t of drawdown(t)
r
Daily net strategy returns (here, with a zero risk-free rate)
std
Sample standard deviation (pandas default, dividing by n minus 1)
equity(t)
Cumulative product of (1 + r) up to day t
Summarising the runs, including a look ahead versionPython
def summarise(r: pd.Series) -> dict:
    equity = (1 + r).cumprod()
    drawdown = equity / equity.cummax() - 1
    return {
        "total": equity.iloc[-1] - 1,
        "sharpe": r.mean() / r.std() * np.sqrt(252),
        "max_dd": drawdown.min(),
    }

# The broken version: earns the return of the same day that set the signal
look_ahead = signal * asset_ret - signal.diff().abs().fillna(0) * COST

runs = {
    "buy_and_hold": asset_ret,
    "gross": gross,
    "net_10bps": net,
    "look_ahead": look_ahead,
}

print(f"{'strategy':<14}{'total':>9}{'sharpe':>8}{'max_dd':>9}")
for name, r in runs.items():
    s = summarise(r)
    print(f"{name:<14}{s['total']:>9.2%}{s['sharpe']:>8.2f}{s['max_dd']:>9.2%}")
Output
strategy          total  sharpe   max_dd
buy_and_hold     11.00%    4.79   -7.01%
gross             4.40%    2.66   -3.93%
net_10bps         4.08%    2.47   -4.02%
look_ahead        7.14%    4.18   -3.18%
The look ahead version uses the same signal and pays the same costs. The only difference is the missing shift(1), and it adds more than 3 points of return and raises the Sharpe from 2.47 to 4.18.
Plotting the equity curve against buy and holdPython
import matplotlib.pyplot as plt

curves = pd.DataFrame({
    "crossover, net": (1 + net).cumprod(),
    "buy and hold": (1 + asset_ret).cumprod(),
})

ax = curves.plot(figsize=(9, 4), title="Growth of 1")
ax.fill_between(position.index, curves.min().min(), curves.max().max(),
                where=position > 0, alpha=0.1, step="pre", label="long")
ax.legend()
plt.tight_layout()
plt.savefig("crossover.png", dpi=150)

Reading the results honestly

The crossover lost to buy and hold on this series: 4.08 percent against 11.00 percent. It sat out the early rise until the averages confirmed it, held through the start of the fall, and re-entered after the recovery was under way. That is how trend rules behave: they give up the beginning and end of each move in exchange for avoiding part of the middle of a decline. Its drawdown was smaller, 4.02 percent against 7.01 percent, which is the trade-off it offers.

None of these numbers say anything about whether the rule works. Forty days is far too short: the annualised Sharpe ratios of 2.47 and 4.79 are artefacts of scaling a few weeks of a smooth toy series up to a year. A real test needs many years of point in time data, costs calibrated to the actual instrument and size, and, if the 5 and 10 day windows were chosen by trying alternatives, an adjustment for how many were tried. The value of this listing is the structure, which carries over unchanged to a real dataset.

What each line of the backtest protects against
StepCodeFailure it prevents
Lag the positionsignal.shift(1)Earning the return of the day that generated the signal
Charge on changesposition.diff().abs() * COSTFree trading, which flatters high turnover rules
Fill the first valuesfillna(0)NaN silently propagating through cumprod
Compound, do not add(1 + net).cumprod()Overstating or understating long run growth
Sample stdr.std()Inconsistent volatility versus other reports

Templates for this lesson

Included with a Premieur membership.

  • NotebookBacktest notebookA moving average crossover end to end: lagged position, trading costs, Sharpe, maximum drawdown and the look ahead comparison.Download
  • CSVSample pricesDaily closes for three hypothetical tickers over the 252 trading days of 2024, the data the notebooks load.Download
Go deeper with Membership →