Build an Event-Driven Backtester in Python
Design and implement a small event-driven backtesting engine in Python that avoids lookahead bias, models costs, and reports core metrics.
Vectorized backtests are quick but easy to get subtly wrong. An event-driven engine processes one bar at a time, which makes the decision/execution boundary explicit and keeps lookahead bias out by construction. This guide builds a small one and explains the design choices.
Educational engineering content. The example strategy is a placeholder to exercise the engine — it is not a recommendation, and results from any backtest do not guarantee future outcomes.
Why event-driven
A vectorized backtest computes signals over the whole series at once, which makes it tempting to accidentally use future data. An event-driven loop steps through bars in order, so a decision at bar t can only see bars up to t. The cost is speed; the benefit is correctness and realism.
Core components
- Data handler — yields bars in chronological order.
- Strategy — receives each bar and emits signals from past data only.
- Portfolio — turns signals into orders and tracks positions and equity.
- Execution — fills orders on the next bar, applying costs.
The engine loop
from dataclasses import dataclass, field
import pandas as pd
@dataclass
class Backtester:
data: pd.DataFrame # columns: open, high, low, close
fee: float = 0.0005 # 5 bps per trade, round-trip approximated
equity: float = 10_000.0
position: float = 0.0 # units held
pending_signal: int = 0 # decided at close[t], executed at open[t+1]
curve: list = field(default_factory=list)
def run(self, strategy):
for i, (ts, bar) in enumerate(self.data.iterrows()):
# 1. Execute yesterday's decision at today's open.
if self.pending_signal != 0:
self._fill(self.pending_signal, bar["open"])
self.pending_signal = 0
# 2. Mark-to-market equity on the close.
self.curve.append((ts, self.equity + self.position * bar["close"]))
# 3. Decide using data up to and including this close.
self.pending_signal = strategy(self.data.iloc[: i + 1])
return pd.Series(dict(self.curve))
def _fill(self, signal: int, price: float):
target = signal # +1 long one unit, -1 flat/short, 0 no change
delta = target - self.position
if delta == 0:
return
cost = abs(delta) * price * self.fee
self.equity -= delta * price + cost
self.position = targetThe key line is step 3 happening after step 1: today's decision can only be acted on at the next bar's open. That single ordering rule is what prevents lookahead bias.
A placeholder strategy
def strategy(history):
# Toy rule purely to exercise the engine — NOT a recommendation.
if len(history) < 21:
return 0
fast = history["close"].tail(9).mean()
slow = history["close"].tail(21).mean()
return 1 if fast > slow else 0Reporting metrics
Reuse honest metrics rather than eyeballing the curve:
import numpy as np
def cagr(curve, periods_per_year=252):
total = curve.iloc[-1] / curve.iloc[0]
years = len(curve) / periods_per_year
return total ** (1 / years) - 1
def max_drawdown(curve):
peak = curve.cummax()
return float(((peak - curve) / peak).max())Pair return with risk: a high CAGR alongside a deep max drawdown tells a very different story than the return alone.
Where to take it
- Add slippage and per-instrument cost models.
- Support multiple instruments and position sizing (see the Position Size Calculator).
- Walk-forward evaluation to avoid tuning on the same data you report.
For the theory behind the decision/execution split, read Avoiding Lookahead Bias in Backtests.