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.
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 = 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()| Date | Close | Fast MA | Slow MA | Signal | Position | Asset return | Trade | Net return |
|---|---|---|---|---|---|---|---|---|
| 2024-01-15 | 105.2 | 103.82 | 102.48 | 1 | 0 | +0.670 | 0 | 0.000 |
| 2024-01-16 | 104.9 | 104.20 | 102.97 | 1 | 1 | -0.285 | 1 | -0.385 |
| 2024-01-25 | 102.8 | 104.72 | 105.13 | 0 | 1 | -0.868 | 0 | -0.868 |
| 2024-01-26 | 103.5 | 104.14 | 105.03 | 0 | 0 | +0.681 | 1 | -0.100 |
| 2024-02-09 | 103.9 | 101.58 | 101.35 | 1 | 0 | +1.366 | 0 | 0.000 |
| 2024-02-12 | 104.6 | 102.60 | 101.60 | 1 | 1 | +0.674 | 1 | +0.574 |
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.
| Cost per unit traded | Total return | Annualised Sharpe | Max drawdown |
|---|---|---|---|
| 0 bps (gross) | 4.40% | 2.66 | -3.93% |
| 10 bps | 4.08% | 2.47 | -4.02% |
| 50 bps | 2.84% | 1.72 | -4.41% |
| 100 bps | 1.30% | 0.78 | -5.20% |
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.
- 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
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%}")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%
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.
| Step | Code | Failure it prevents |
|---|---|---|
| Lag the position | signal.shift(1) | Earning the return of the day that generated the signal |
| Charge on changes | position.diff().abs() * COST | Free trading, which flatters high turnover rules |
| Fill the first values | fillna(0) | NaN silently propagating through cumprod |
| Compound, do not add | (1 + net).cumprod() | Overstating or understating long run growth |
| Sample std | r.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