Skip to content
FactorQX
advancedPythonpandas

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.

2 min read

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

engine.py
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 = target

The 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

strategy.py
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 0

Reporting metrics

Reuse honest metrics rather than eyeballing the curve:

metrics.py
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.

Educational engineering guide. This walkthrough teaches how to build software. It is not investment advice, a trading signal, or a guarantee of results. See our disclaimer.