Replaying history
A backtest asks what a trading rule would have earned on past data. The vectorised version works on whole arrays at once: no loop over days, just a signal per day, a position per day, and one multiplication with the returns. It is short, fast and easy to check, which makes it the first tool for screening ideas. Its limits (no orders, no cash, no partial fills) are exactly what the next topic adds.
import numpy as np
returns = np.array([0.01, 0.02, -0.01, 0.03]) # the asset's daily returns
signal = np.array([2.5, 0.4, -1.0, 0.0]) # any score: positive = buy, negative = sell
position = np.sign(signal) # +1, +1, -1, 0
print(position)The one-day delay
A signal computed from today's close can only be traded after today's close, so it earns tomorrow's return, not today's. In arrays that is a shift: strategy[t] = position[t - 1] * returns[t], with no position before the first day. Forgetting it lets the strategy trade on a return it has already seen, and the backtest looks wonderful. The shift is one line, and the most important one in the whole function.
# Yesterday's position earns today's return
import numpy as np
returns = np.array([0.01, 0.02, -0.01, 0.03])
position = np.array([1.0, 1.0, -1.0, 0.0])
strategy = np.zeros(len(returns))
strategy[1:] = position[:-1] * returns[1:]
print(strategy) # [ 0. 0.02 -0.01 -0.03]
print(position * returns) # the same-day version: [ 0.01 0.02 0.01 0. ] looks better, and is cheatingEquity and statistics
Daily returns compound: the value after each day is the start times the running product of 1 + return. From that equity curve three numbers summarise a run. The total return is the final value minus 1. The Sharpe ratio is the mean daily return over its standard deviation, times sqrt(periods) to annualise. The maximum drawdown is the worst fall from a previous peak, equity / running max - 1, which is the loss an investor would have lived through.
# Equity curve and the three statistics for a short run
import numpy as np
strategy = np.array([0.0, 0.02, -0.01, -0.03, 0.04])
equity = np.cumprod(1 + strategy)
print(np.round(equity, 5))
print(round(float(equity[-1] - 1), 5)) # total return
print(round(float(strategy.mean() / strategy.std(ddof=1) * np.sqrt(252)), 3)) # annualised Sharpe
print(round(float((equity / np.maximum.accumulate(equity) - 1).min()), 5)) # maximum drawdown# A moving-average rule on a simulated price path, with its statistics
import numpy as np
rng = np.random.default_rng(7)
returns = rng.normal(0.0004, 0.01, size=500)
prices = 100 * np.cumprod(1 + returns)
fast = np.convolve(prices, np.ones(10) / 10, mode="valid")[-(len(prices) - 49):] # 10-day mean, aligned
slow = np.convolve(prices, np.ones(50) / 50, mode="valid") # 50-day mean
position = np.zeros(len(prices))
position[49:] = np.sign(fast - slow)
strategy = np.zeros(len(returns))
strategy[1:] = position[:-1] * returns[1:]
equity = np.cumprod(1 + strategy)
drawdown = equity / np.maximum.accumulate(equity) - 1
print(round(float(equity[-1] - 1), 4), round(float(strategy.mean() / strategy.std(ddof=1) * np.sqrt(252)), 4), round(float(drawdown.min()), 4))- Position = sign of the signal; the strategy return is
position[t - 1] * returns[t]: today's return belongs to yesterday's decision. - Equity is
start * cumprod(1 + r); total return, annualised Sharpe and maximum drawdown summarise it. - Vectorised backtests are fast screens: they ignore costs and fills, and many trials on one history produce luck.
Next up: an event-driven backtester, replaying history one bar at a time with cash, orders and fills.
Lab · what's graded
- Turn a signal into a position with
signal_to_position25 pts - Earn yesterday's position with
strategy_returns25 pts - Compound the days with
equity_curve25 pts - Summarise the run with
backtest_stats25 pts