Event-driven backtesting
Vectorised backtests break the moment your logic depends on the path. Learn why, then build a minimal but real event-driven engine — the same shape as a live trading bot.
The vectorised approach has a hard ceiling. Its power comes from computing every day at once — but that is exactly why it cannot handle logic where *today's decision depends on what happened along the way*. The instant you add a stop-loss, a trailing exit, position sizing that scales with current equity, or a rule like "only take the next signal if the last trade lost", the clean position * return identity falls apart.
Why the path matters
Consider a stop-loss at -5%. Whether it triggers on a given day depends on the entry price, which depends on when you entered, which depends on prior signals — a chain that only makes sense walked forward in time. You cannot express "exit if we are 5% below wherever we happened to get in" as a single column operation, because "wherever we got in" is itself an outcome of the simulation.
The event loop
An event-driven backtester walks through history one bar at a time. On each bar it runs the same cycle a live system runs — which is the entire point: the same code shape that backtests your strategy can trade it live.
A minimal event-driven engine
Here is a real, runnable engine — small enough to read in one sitting, complete enough to backtest a strategy with a stop-loss and proper per-trade accounting. It is built from three pieces: a Broker that holds cash and one position and fills orders with costs, a Strategy that decides what to do on each bar, and an Engine that drives the loop.
import numpy as np
import pandas as pd
class Broker:
class="s">"""Holds cash and a single long/flat position. Fills at the given
price plus a cost, and records every closed trade's P&L.class="s">"""
def __init__(self, cash=10_000.class="n">0, cost_bps=class="n">6.0):
self.cash = cash
self.cost = cost_bps / 1e4 class="c"># fraction charged per fill
self.units = class="n">0.0 class="c"># units of the asset held
self.entry_price = None class="c"># price we bought at (for trade P&L)
self.trades = [] class="c"># list of realised P&L per closed trade
self.equity_curve = [] class="c"># (timestamp, equity) snapshots
def value(self, price):
return self.cash + self.units * price
def buy(self, price, cash_to_spend):
fill = price * (class="n">1 + self.cost) class="c"># pay up on the way in
units = cash_to_spend / fill
self.units += units
self.cash -= units * fill
self.entry_price = price
def sell_all(self, price):
if self.units <= class="n">0:
return
fill = price * (class="n">1 - self.cost) class="c"># receive less on the way out
proceeds = self.units * fill
class="c"># Record the round-trip return for trade stats
self.trades.append(fill / self.entry_price - class="n">1)
self.cash += proceeds
self.units = class="n">0.0
self.entry_price = None
def mark(self, ts, price):
self.equity_curve.append((ts, self.value(price)))class MACrossStop:
class="s">"""Long when fast MA > slow MA. Exit on the opposite cross OR if price
falls class="s">'stop' below the entry — the path-dependent rule vectorising can't do.class="s">"""
def __init__(self, fast=class="n">20, slow=class="n">100, stop=class="n">0.05):
self.fast, self.slow, self.stop = fast, slow, stop
def warmup(self):
return self.slow class="c"># bars needed before signals are valid
def on_bar(self, i, prices, broker):
fast = prices[i - self.fast:i].mean()
slow = prices[i - self.slow:i].mean()
price = prices[i]
in_position = broker.units > class="n">0
if in_position:
class="c"># Stop-loss check — needs the entry price, i.e. the path
if price <= broker.entry_price * (class="n">1 - self.stop):
return class="s">"SELL"
if fast < slow: class="c"># trend broke: exit
return class="s">"SELL"
else:
if fast > slow: class="c"># trend turned up: enter
return class="s">"BUY"
return class="s">"HOLD"class Engine:
def __init__(self, prices, index, strategy, broker):
self.prices = np.asarray(prices, dtype=float)
self.index = index
self.strategy = strategy
self.broker = broker
def run(self):
start = self.strategy.warmup()
for i in range(start, len(self.prices)):
price, ts = self.prices[i], self.index[i]
action = self.strategy.on_bar(i, self.prices, self.broker)
if action == class="s">"BUY" and self.broker.units == class="n">0:
self.broker.buy(price, self.broker.cash) class="c"># go all-in long
elif action == class="s">"SELL" and self.broker.units > class="n">0:
self.broker.sell_all(price)
self.broker.mark(ts, price) class="c"># snapshot equity
equity = pd.Series(dict(self.broker.equity_curve))
return equity, self.broker.tradesAnd running it is three lines. Notice how the pieces snap together exactly like the five-component blueprint from Part 1 — data, signal, risk, execution, state.
import yfinance as yf
df = yf.download(class="s">"SPY", start=class="s">"class="n">2015-class="n">01-class="n">01", end=class="s">"class="n">2024-class="n">12-class="n">31", auto_adjust=True)
prices = df[class="s">"Close"].dropna()
broker = Broker(cash=10_000, cost_bps=class="n">6)
engine = Engine(prices.values, prices.index, MACrossStop(class="n">20, class="n">100, stop=class="n">0.05), broker)
equity, trades = engine.run()
trades = pd.Series(trades)
print(fclass="s">"Final equity: {equity.iloc[-class="n">1]:,.0f}")
print(f"Trades: {len(trades)} | win rate: {(trades > class="n">0).mean():.class="n">1%}class="s">")
print(f"Profit factor: {trades[trades>class="n">0].sum() / -trades[trades<class="n">0].sum():.2f}")You could extend this engine forever — multiple positions, short selling, limit orders, a portfolio of instruments. At some point, though, you are rebuilding a library that already exists and has been battle-tested by thousands of users. That is the final lesson.