One bar at a time
An event-driven backtester behaves like a live system: time advances bar by bar, and at each bar the program sees only the past, decides, sends an order, and updates its account. It is slower than a vectorised backtest, but it carries state (cash, shares), it can model orders and fills, and the same strategy code can later run on a live feed. The loop is the point: a for over the bars, with the account carried from one iteration to the next.
def crossover_signal(history, fast, slow):
if len(history) < slow:
return 0 # not enough history yet
fast_mean = sum(history[-fast:]) / fast
slow_mean = sum(history[-slow:]) / slow
return 1 if fast_mean > slow_mean else 0
prices = [100, 101, 103, 102, 105, 107, 104, 103, 99, 98]
print(crossover_signal(prices[:3], 2, 4)) # 0: only 3 prices
print(crossover_signal(prices[:6], 2, 4)) # 1: the 2-bar mean is above the 4-bar mean
print(crossover_signal(prices[:9], 2, 4)) # 0: the trend has turnedOrders change the account
An order turns cash into shares or the reverse. With whole shares, buying int(cash // price) leaves a little cash over, and the account value is always cash + shares * price. Keeping the order logic in its own small function, execute_order, makes the loop short and lets you add fees and slippage later in one place. Selling everything is simply cash + shares * price and zero shares.
def execute_order(cash, shares, price, target):
if target == 1:
bought = int(cash // price) # whole shares only
return cash - bought * price, shares + bought
return cash + shares * price, 0
cash, shares = execute_order(1000.0, 0, 103.0, 1)
print(cash, shares, cash + shares * 103.0) # 73.0 9 1000.0
cash, shares = execute_order(cash, shares, 99.0, 0)
print(cash, shares) # 964.0 0The loop, and what it may know
At bar t the strategy may use prices[:t], which ends at the previous close, and trades at prices[t]. Slicing prices[:t] is what keeps the future out: the next price simply is not in the list. A good test of an event-driven engine follows: run it on the first k bars only, and the results must equal the first k results of the full run. If they differ, the strategy peeked.
def crossover_signal(history, fast, slow):
if len(history) < slow:
return 0
return 1 if sum(history[-fast:]) / fast > sum(history[-slow:]) / slow else 0
def execute_order(cash, shares, price, target):
if target == 1:
bought = int(cash // price)
return cash - bought * price, shares + bought
return cash + shares * price, 0
def run_backtest(prices, fast, slow, cash):
shares = 0
values = []
for t, price in enumerate(prices):
if t > 0:
target = crossover_signal(prices[:t], fast, slow) # only what was known at the last close
cash, shares = execute_order(cash, shares, price, target)
values.append(cash + shares * price)
return values
prices = [100, 101, 103, 102, 105, 107, 104, 103, 99, 98]
full = run_backtest(prices, 2, 4, 1000.0)
print([round(v, 2) for v in full])
print(run_backtest(prices[:6], 2, 4, 1000.0) == full[:6]) # True: the future never leaks# The benchmark: buy once at the start and hold
import random
def buy_and_hold(prices, cash):
shares = int(cash // prices[0])
left = cash - shares * prices[0]
return [left + shares * price for price in prices]
random.seed(3)
prices = [100.0]
for _ in range(99):
prices.append(round(prices[-1] * (1 + random.gauss(0.0004, 0.012)), 2))
held = buy_and_hold(prices, 10000.0)
print(round(held[0], 2), round(held[-1], 2))- An event-driven backtester loops over the bars with state (cash, shares); at bar
tit may only useprices[:t]. - Orders move between cash and whole shares: the account value is
cash + shares * price; keep order logic in one function. - Test for look-ahead: a run on the first
kbars must equal the firstkresults of the full run; compare against buy-and-hold.
Next up: look-ahead bias, the quiet mistake that makes a backtest too good to be true.
Lab · what's graded
- Compare two averages with
crossover_signal25 pts - Fill the order with
execute_order25 pts - Walk through the bars with
run_backtest25 pts - Set the benchmark with
buy_and_hold25 pts