A minimal event loop
Lesson 20 · about 14 min
An event-driven backtester is a loop over bars where, on each bar, pending orders are checked against the bar's prices, the portfolio is updated, and the strategy is asked what it wants to do next. The engine knows about orders, fills and cash; the strategy knows about signals. Neither knows the other's internals. This lesson builds that engine in about eighty lines with market orders only; the next lesson adds limits, stops and brackets.
The data types
# src/event_backtest.py
from dataclasses import dataclass, replace
from enum import Enum
import numpy as np
import pandas as pd
class OrderType(Enum):
MARKET = "market"
LIMIT = "limit"
STOP = "stop"
@dataclass(frozen=True)
class Order:
side: int # +1 buy, -1 sell
qty: float
order_type: OrderType = OrderType.MARKET
price: float | None = None # limit price or stop trigger
stop_loss: float | None = None # bracket: protective stop after fill
take_profit: float | None = None # bracket: limit exit after fill
oco_group: int | None = None # one-cancels-other group id
order_id: int = 0 # assigned by the engine
@dataclass(frozen=True)
class Fill:
time: pd.Timestamp
order_id: int
side: int
qty: float
price: float
cost: float
@dataclass(frozen=True)
class Portfolio:
cash: float
qty: float = 0.0
def equity(self, price: float) -> float:
return self.cash + self.qty * price
def apply(self, fill: Fill) -> "Portfolio":
return replace(
self,
cash=self.cash - fill.side * fill.qty * fill.price - fill.cost,
qty=self.qty + fill.side * fill.qty,
)
Everything is a frozen dataclass. A Fill cannot be edited after the fact; applying one to a Portfolio returns a new Portfolio. When you later debug "why is cash wrong on 14 March", you can replay the list of fills and find the exact one, because none of them changed after they were created.
The engine
class EventBacktester:
def __init__(self, bars: pd.DataFrame, strategy, cash: float = 100_000.0,
cost_bps: float = 5.0, slippage_bps: float = 2.0):
self.bars = bars
self.strategy = strategy
self.portfolio = Portfolio(cash=cash)
self.cost_rate = cost_bps / 10_000.0
self.slip = slippage_bps / 10_000.0
self.pending: list[Order] = []
self.fills: list[Fill] = []
self._next_id = 1
def submit(self, order: Order) -> Order:
assigned = replace(order, order_id=self._next_id)
self._next_id += 1
self.pending.append(assigned)
return assigned
def _fill_price(self, order: Order, bar: pd.Series) -> float | None:
if order.order_type is OrderType.MARKET:
return float(bar["open"]) * (1 + order.side * self.slip)
return None # limit / stop: next lesson
def _execute(self, t: pd.Timestamp, bar: pd.Series) -> None:
still_pending = []
for order in self.pending:
price = self._fill_price(order, bar)
if price is None:
still_pending.append(order)
continue
cost = abs(order.qty) * price * self.cost_rate
fill = Fill(t, order.order_id, order.side, order.qty, price, cost)
self.fills.append(fill)
self.portfolio = self.portfolio.apply(fill)
self.pending = still_pending
def run(self) -> pd.DataFrame:
equity = np.empty(len(self.bars))
for i, (t, bar) in enumerate(self.bars.iterrows()):
self._execute(t, bar) # 1. fill what was decided earlier
equity[i] = self.portfolio.equity(float(bar["close"])) # 2. mark to market at the close
for order in self.strategy.on_bar(i, self.bars, self.portfolio):
self.submit(order) # 3. new orders wait for the next bar
return pd.DataFrame({"equity": equity}, index=self.bars.index)
The three-step order inside the loop is the whole design. Orders submitted at bar i cannot fill until bar i + 1, because _execute runs before on_bar. That is next-bar execution enforced by structure, not by remembering to call shift. A strategy cannot look ahead through this engine, because the engine never hands it a bar that has not closed.
Slippage is applied as a fraction of price in the direction that hurts: buys fill slightly above the open, sells slightly below. Commission is a fraction of notional on every fill. Both are engine parameters, so every strategy pays the same costs.
A strategy for the engine
The strategy has one method, on_bar(i, bars, portfolio), and returns a list of orders. It may look at bars.iloc[:i + 1] and nothing later.
from src.indicators import sma
class CrossoverStrategy:
def __init__(self, fast: int = 20, slow: int = 50, target_fraction: float = 0.95):
self.fast, self.slow, self.target_fraction = fast, slow, target_fraction
self._signal: np.ndarray | None = None
def _prepare(self, bars: pd.DataFrame) -> None:
f, s = sma(bars["close"], self.fast), sma(bars["close"], self.slow)
self._signal = (f > s).to_numpy() # trailing windows only: safe to precompute
def on_bar(self, i: int, bars: pd.DataFrame, portfolio: Portfolio) -> list[Order]:
if self._signal is None:
self._prepare(bars)
want_long = bool(self._signal[i])
close = float(bars["close"].iloc[i])
if want_long and portfolio.qty == 0:
qty = np.floor(self.target_fraction * portfolio.equity(close) / close)
return [Order(side=+1, qty=qty)] if qty > 0 else []
if not want_long and portfolio.qty > 0:
return [Order(side=-1, qty=portfolio.qty)]
return []
Precomputing the signal is safe only because SMA uses trailing windows; self._signal[i] depends on bars up to i. If a strategy ever needs something that is not purely trailing, compute it inside on_bar from bars.iloc[:i + 1]. The 95% target fraction leaves cash for the slippage on the buy so the order does not exceed available cash.
Run it and compare with the vectorized version
from src.data import synthetic_ohlcv
bars = synthetic_ohlcv(1500, seed=42)
engine = EventBacktester(bars, CrossoverStrategy(), cash=100_000, cost_bps=5, slippage_bps=2)
curve = engine.run()
print(f"event-driven: final equity {curve['equity'].iloc[-1] / 100_000:.3f}, fills {len(engine.fills)}")
# vectorized, next-open execution, open-to-open returns (Module 5), 7 bps total per side
signal = (sma(bars["close"], 20) > sma(bars["close"], 50)).astype(float)
position = signal.shift(2).fillna(0.0)
net = (position * bars["open"].pct_change()).fillna(0.0) - position.diff().abs().fillna(0.0) * 7 / 10_000
print(f"vectorized: final equity {(1 + net).cumprod().iloc[-1]:.3f}, trades {int((position.diff().abs() > 0).sum())}")
The two numbers should be close but not identical: the event engine holds whole shares and 5% cash, and marks to market at the close rather than the open. If they differ by more than a few percent, one of them has a bug, and running both is the cheapest way to find it. This cross-check, vectorized against event-driven on a strategy both can express, is worth keeping as a test forever.
Key idea: The engine's loop order (execute pending, mark to market, ask the strategy) makes next-bar execution structural. Orders, fills and portfolios are immutable values, so any state can be reconstructed by replaying fills.
Inspecting fills
fills = pd.DataFrame([f.__dict__ for f in engine.fills]).set_index("time")
print(fills.head())
print(f"total commissions: {fills['cost'].sum():,.2f}")
A fills table is what a broker statement looks like, and being able to produce one from a backtest means you can later compare the backtest's fills with the live account's fills line by line. That comparison is how you find the gap between what you tested and what you traded.
Try it: Add a
max_position_valueparameter to the engine that rejects any order whose notional would push the position above it, logging the rejection instead of filling. Run the crossover with a cap of 50,000 on a 100,000 account and confirm from the fills table that no fill exceeds it.
Recap
- Frozen dataclasses for
Order,FillandPortfolio; every state change returns a new value. - The loop executes pending orders, marks to market, then asks the strategy; orders wait one bar by construction.
- Slippage moves the fill against you; commission is a fraction of notional on every fill.
- Strategies see only closed bars and return orders; precompute only trailing-window indicators.
- Cross-check the event engine against the vectorized backtest on a strategy both can express.
See it drawn
Original diagrams for the ideas on this page. Illustrative, not real market data.