Skip to content
GetProfitable
Search

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_value parameter 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, Fill and Portfolio; 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.

Slippage on a market orderA buy order clears four price levels, so the average price paid is worse than the price first quoted.Buy 1,000 shares at marketpricesell orders resting (bar length = size)20.04300 shares20.03200 shares20.01200 shares20.00300 sharesnothing resting at 20.02order sweeps up the bookaverage fill 20.02SLIPPAGE0.02 a share$20.00 in totalintended 20.00Each level fills at its own price; the average is what you really paid.
Slippage on a market order. You click at 20.00, but only 300 shares are resting there, so the rest of the order fills at 20.01, 20.03 and 20.04. The average price paid is 20.02, and that two-cent gap is slippage.
Risk and reward on one tradeA price scale showing an entry with a stop two points below and a target six points above, so the reward band is three times the risk band.PRICETARGET 106.00ENTRY 100.00STOP 98.00REWARDRISK6.00 pointsthree times the risk2.00 pointsthe most you loserisk : reward = 1 : 3
Risk and reward on one trade. One trade on a price scale: the entry sits 2.00 points above the stop and 6.00 points below the target, so the shaded reward band is three times the risk band. The ratio compares what is lost if the stop is hit with what is gained if the target is reached.