Lesson 2.4.2 · 75 min

Event-driven backtest from scratch: loop, portfolio, orders and fills

An event-driven engine walks through time the way the live system would: a bar arrives, the strategy decides, the portfolio requests orders, the broker executes them. This lesson writes one in pure Python, reconciles it to the cent with the vectorized backtest of lesson 2.4.1 and uses it for what the vectorized one cannot do: real contracts with rolls, whole contracts and a trailing stop inside the bar.

Fig. — Portfolio of 24 synthetic futures: annual return gap between the vectorised backtest and the event-driven engine under each comparison's assumptions
Level 2 lessons
  1. 2.1 · Statistics and econometrics for trading

    1. 2.1.1 Hypothesis tests, p-values, bootstrap and regression with robust standard errors
    2. 2.1.2 Stationarity: ADF, KPSS, differencing and fractional differencing
    3. 2.1.3 ARMA and ARIMA models: autocorrelation (ACF) and partial autocorrelation (PACF)
    4. 2.1.4 Volatility models: ARCH, GARCH, EGARCH and GJR with the arch library
    5. 2.1.5 Cointegration: Engle-Granger, Johansen and the half-life of mean reversion
    6. 2.1.6 Kalman filter for dynamic hedge ratios
    7. 2.1.7 Regime detection: hidden Markov models and structural breaks
    8. 2.1.8 The multiple testing problem: Bonferroni, FDR and the “factor zoo”
  2. 2.2 · Anatomy of a trading system

    1. 2.2.1 Edge hypotheses: risk, behavior, structure and information
    2. 2.2.2 Universe, signal, filter, entry, exit, sizing and risk management
    3. 2.2.3 Frequencies: intraday, swing and position trading, and the capacity of a strategy
    4. 2.2.4 Research journal and experiment tracking against data snooping
  3. 2.3 · Strategy families

    1. 2.3.1 Trend following and momentum
    2. 2.3.2 Mean reversion: RSI and Bollinger, intraday reversal and gaps
    3. 2.3.3 Pairs trading and statistical arbitrage: distance, cointegration and Kalman
    4. 2.3.4 Carry in currencies, futures and bonds; seasonality and calendar effects
    5. 2.3.5 Factors (value, quality, low volatility, size) and event strategies
    6. 2.3.6 Introduction to market making: inventory, spread and adverse selection (Avellaneda-Stoikov)
    7. 2.3.7 Crypto-specific strategies: momentum, funding arbitrage and basis
  4. 2.4 · Backtesting: engines and paradigms

    1. 2.4.1 Vectorized backtest from scratch: matrices, lag, costs and limits
    2. 2.4.2 Event-driven backtest from scratch: loop, portfolio, orders and fills
    3. 2.4.3 vectorbt: massive parameter sweeps and portfolio simulation
    4. 2.4.4 backtesting.py and backtrader: two teaching engines and their status
    5. 2.4.5 NautilusTrader: architecture, data, strategies and the same code in backtest and live
    6. 2.4.6 LEAN/QuantConnect and Zipline Reloaded; bt for asset allocation
    7. 2.4.7 Cost modeling: commissions, bid-ask spread, impact, financing and securities borrowing
    8. 2.4.8 Fill modeling: limit orders, queue, partial fills, gaps and stops
  5. 2.5 · Biases and validation

    1. 2.5.1 Backtest biases: look-ahead, survivorship, selection and narrative
    2. 2.5.2 In-sample and out-of-sample: anchored and rolling walk-forward
    3. 2.5.3 Combinatorial purged cross-validation for strategies
    4. 2.5.4 Deflated Sharpe Ratio and Probability of Backtest Overfitting (PBO)
    5. 2.5.5 Monte Carlo: trade resampling, block bootstrap and signal permutations
    6. 2.5.6 Parameter sensitivity: plateaus versus peaks, robustness across assets and periods
    7. 2.5.7 Synthetic data for stress testing: GBM, GARCH and bootstrapping
    8. 2.5.8 “Honest backtest” checklist before moving to paper trading
  6. 2.6 · Performance metrics

    1. 2.6.1 CAGR, Sharpe, Sortino, Calmar, Omega, drawdown and Ulcer Index
    2. 2.6.2 Win rate, payoff, expectancy, alpha, beta, information ratio, turnover and capacity
    3. 2.6.3 Reports with QuantStats or pyfolio-reloaded and custom tearsheets

From matrix calculations to the event loop

Lesson 2.4.1 ended with a list of things a vectorized backtest cannot do well: rules with memory, what happens inside a bar, whole contracts. They all have the same cause: today's decision depends on the state (position, live orders, capital) that yesterday's left behind. An event-driven engine solves that by walking through time in order, one bar at a time, and letting each component react only to what has already happened. It is the same structure the system will have when it trades live, and that is why professional engines (NautilusTrader, LEAN or Zipline, lessons 2.4.5 and 2.4.6) are built this way.

The engine in this lesson has four pieces and four message types:

DataMarketEventStrategySignalEventPortfolioOrderEventBrokerFillEvent
Mermaid source
flowchart TB
  D[Data] --> M[MarketEvent]
  M --> E[Strategy]
  E --> S[SignalEvent]
  S --> C[Portfolio]
  C --> O[OrderEvent]
  O --> B[Broker]
  B --> F[FillEvent]
  F --> C
  F --> E
EventEmitted byReceived byWhat it says
MarketEventThe dataBroker, portfolio, strategyA bar has arrived: prices of each instrument
SignalEventThe strategyThe portfolioI want this weight in this market
OrderEventThe portfolio or the strategyThe brokerBuy or sell this quantity, of this type, at this price
FillEventThe brokerThe portfolio and the strategyThis quantity has been executed at this price, with this commission

The separation has a practical reason: the strategy does not know how much capital there is or how many contracts make a 13% weight; the portfolio knows nothing about moving averages or momentum; the broker does not know why an order reaches it. Each piece can be changed without touching the others, and live the simulated broker is replaced by the connection to the real broker.

The events and the simulated broker

The events are dataclasses (lesson 0.4.1): named data structures, with no logic. The immutable ones (frozen=True) are facts that have already happened; the order is mutable because it has a life cycle.

IN [01]python
from collections import deque
from dataclasses import dataclass, field
from itertools import count

import numpy as np
import pandas as pd

@dataclass(frozen=True, slots=True)
class Barra:
    apertura: float
    maximo: float
    minimo: float
    cierre: float

@dataclass(frozen=True, slots=True)
class MarketEvent:
    fecha: pd.Timestamp
    barras: dict           # instrument -> Barra
    continuas: dict        # market -> price of the continuous series
    negociar: dict         # market -> instrument being traded
    fin_sesion: bool = True

@dataclass(frozen=True, slots=True)
class SignalEvent:
    fecha: pd.Timestamp
    mercado: str
    peso: float            # target notional / capital

_ids = count(1)

@dataclass(slots=True)
class OrderEvent:
    fecha: pd.Timestamp
    instrumento: str
    cantidad: float                 # + buy, - sell
    tipo: str = "cierre"            # cierre, mercado, limitada, stop
    precio: float | None = None     # limit or trigger
    reemplaza: int | None = None    # id of the order it cancels
    id: int = field(default_factory=lambda: next(_ids))

@dataclass(frozen=True, slots=True)
class FillEvent:
    fecha: pd.Timestamp
    instrumento: str
    cantidad: float
    precio: float
    comision: float
    orden: int

The MarketEvent carries three dictionaries because a future has two faces. The strategy needs the adjusted continuous series to compute its signal without jumps (lesson 1.4.6); the portfolio needs to know which specific contract is traded today (negociar), and the broker, the price of each contract (barras). In an asset with no expiry, the three coincide.

The broker is the piece that decides at what price each order executes, and therefore the one that weighs most on the result. This one's rules are those of lesson 1.1.4 applied to bars:

  • At the close (cierre): it executes at the close of the bar in which it is sent. It is the vectorized backtest's assumption and that of a real market-on-close order.
  • At market (mercado): it executes at the open of the next bar.
  • Limit: a buy executes if the bar's low reaches the limit, at the limit or at the open if that is already better. It is an optimistic rule: touching the price does not guarantee execution if there is a queue ahead (lesson 2.4.8).
  • Stop: a sell is triggered if the low reaches the level and executes at that level or, if the bar opens below, at the open. That gap is the difference between a stop and a guarantee.
OrderEventat the closethe bar crosses itreplacementPendingFilledCancelled
Mermaid source
stateDiagram-v2
  [*] --> Pending: OrderEvent
  [*] --> Filled: at the close
  Pending --> Filled: the bar crosses it
  Pending --> Cancelled: replacement
  Filled --> [*]
  Cancelled --> [*]
IN [02]python
class Broker:
    """Executes orders against the incoming bars."""

    def __init__(self, comision, deslizamiento=0.0):
        self.comision = comision    # f(date, instrument, q, price)
        self.desliz = deslizamiento # fraction of the price, adverse
        self.pendientes = {}        # id -> OrderEvent
        self.barra = None

    def al_recibir_barra(self, ev):
        self.barra = ev
        fills = []
        for o in list(self.pendientes.values()):
            b = ev.barras.get(o.instrumento)
            precio = None if b is None else self._precio(o, b)
            if precio is not None:
                del self.pendientes[o.id]
                fills.append(self._fill(o, precio))
        return fills

    def al_recibir_orden(self, o):
        if o.reemplaza is not None:
            self.pendientes.pop(o.reemplaza, None)
        if o.cantidad == 0:
            return []                       # it only cancelled
        if o.tipo == "cierre":              # at the close of this bar
            return [self._fill(o, self.barra.barras[o.instrumento]
                               .cierre)]
        self.pendientes[o.id] = o           # they wait for another bar
        return []

    @staticmethod
    def _precio(o, b):
        compra = o.cantidad > 0
        if o.tipo == "mercado":             # next open
            return b.apertura
        if o.tipo == "limitada":            # at the limit or better
            if compra and b.minimo <= o.precio:
                return min(b.apertura, o.precio)
            if not compra and b.maximo >= o.precio:
                return max(b.apertura, o.precio)
        if o.tipo == "stop":                # triggered: at market
            if compra and b.maximo >= o.precio:
                return max(b.apertura, o.precio)
            if not compra and b.minimo <= o.precio:
                return min(b.apertura, o.precio)
        return None

    def _fill(self, o, precio):
        if o.tipo != "limitada":            # adverse slippage
            precio *= 1 + np.sign(o.cantidad) * self.desliz
        fecha = self.barra.fecha
        return FillEvent(fecha, o.instrumento, o.cantidad, precio,
                         self.comision(fecha, o.instrumento,
                                       o.cantidad, precio), o.id)

The commission is a function the broker receives when it is created: that way the same engine works for a cost proportional to the notional, a fixed one per contract or the tiered schemes of lesson 2.4.7. Slippage moves the price against whoever sends the order, except for limit orders, which by definition do not execute worse than their limit.

The portfolio and the loop

The portfolio translates weights into quantities and keeps the books: cash, positions and their value at closing prices. For futures it simplifies one thing: it buys and sells the notional against cash, as if they were stocks, instead of settling profit and loss each day against the margin (lesson 1.2.3). The account value is the same; what changes is the cash balance, and here no interest is applied to it, just as in the vectorized backtest.

IN [03]python
class Cartera:
    """Cash, positions and valuation at closing prices."""

    def __init__(self, capital, mult, enteros=False,
                 mercado_de=lambda i: i[:3]):
        self.efectivo = capital
        self.pos = {}                # instrument -> quantity
        self.precio = {}             # last known close
        self.mult = mult             # market -> USD per point
        self.enteros = enteros
        self.mercado_de = mercado_de
        self.negociar = {}
        self.historia, self.comisiones = [], 0.0

    def valor(self):
        return self.efectivo + sum(
            q * self.precio[i] * self.mult[self.mercado_de(i)]
            for i, q in self.pos.items())

    def al_recibir_barra(self, ev):
        for i, b in ev.barras.items():
            self.precio[i] = b.cierre
        self.negociar = ev.negociar

    def al_recibir_senal(self, s):
        destino = self.negociar[s.mercado]
        ordenes = [OrderEvent(s.fecha, i, -q)      # expiring contract
                   for i, q in self.pos.items()
                   if i != destino and self.mercado_de(i) == s.mercado]
        nocional = self.precio[destino] * self.mult[s.mercado]
        objetivo = s.peso * self.valor() / nocional
        if self.enteros:
            objetivo = float(round(objetivo))
        dq = objetivo - self.pos.get(destino, 0.0)
        if dq:
            ordenes.append(OrderEvent(s.fecha, destino, dq))
        return ordenes

    def al_recibir_fill(self, f):
        m = self.mult[self.mercado_de(f.instrumento)]
        q = self.pos.get(f.instrumento, 0.0) + f.cantidad
        if q:
            self.pos[f.instrumento] = q
        else:
            self.pos.pop(f.instrumento, None)
        self.efectivo -= f.cantidad * f.precio * m + f.comision
        self.comisiones += f.comision

    def cerrar_barra(self, ev):
        if ev.fin_sesion:
            self.historia.append((ev.fecha, self.valor()))

Two details of al_recibir_senal do the futures work. If there is a position in a contract that is no longer the one being traded (the roll day), an order goes out that closes it. And the target quantity is computed with the price of the destination contract, which on the roll day is the next one. With enteros=True, it is rounded to the contract.

The loop is the shortest part of the engine and the one that needs the most care, because it fixes the order of things within a bar: first the closing prices are marked, then the broker executes the orders that were live (a stop that triggers mid-session) and only then does the strategy see the bar and decide. If the strategy decided before knowing that its stop had executed, it would believe it was still in position.

IN [04]python
def ejecutar(eventos, estrategia, cartera, broker):
    cola = deque()
    cuenta = dict.fromkeys(["MarketEvent", "SignalEvent",
                            "OrderEvent", "FillEvent"], 0)
    for barra in eventos:
        cartera.al_recibir_barra(barra)            # closing prices
        cola.extend(broker.al_recibir_barra(barra))  # live orders
        cola.append(barra)                     # and then the strategy
        while cola:
            ev = cola.popleft()
            cuenta[type(ev).__name__] += 1
            match ev:
                case MarketEvent():
                    cola.extend(estrategia.al_recibir_barra(ev))
                case SignalEvent():
                    cola.extend(cartera.al_recibir_senal(ev))
                case OrderEvent():
                    cola.extend(broker.al_recibir_orden(ev))
                case FillEvent():
                    cartera.al_recibir_fill(ev)
                    cola.extend(estrategia.al_recibir_fill(ev))
        cartera.cerrar_barra(barra)
    return pd.Series(dict(cartera.historia)), cuenta


class Estrategia:
    def al_recibir_barra(self, ev):
        return []

    def al_recibir_fill(self, f):
        return []

The queue is a deque from the collections module: items are added at one end and taken from the other, so events are processed in the order in which they are generated. Since all the signals of a bar enter the queue before any of its orders, the portfolio computes all the quantities with the same capital, the one before trading. It is a decision; another equally valid one would give a slightly different result, and that is why you need to know which one each engine takes.

The pieces are tested separately, with hand-built bars whose answer is known:

IN [05]python
def barra(o, h, l, c, i="X", fecha="2024-01-02"):
    return MarketEvent(pd.Timestamp(fecha), {i: Barra(o, h, l, c)},
                       {}, {"X": "X"})

def test_stop_con_hueco():
    b = Broker(lambda *a: 0.0)
    b.barra = barra(100, 101, 99, 100)
    b.al_recibir_orden(OrderEvent(None, "X", -1, "stop", 95.0))
    (f,) = b.al_recibir_barra(barra(92, 93, 90, 91))
    assert f.precio == 92                  # opens below: the open

def test_limitada_al_limite_o_mejor():
    b = Broker(lambda *a: 0.0)
    b.barra = barra(100, 101, 99, 100)
    b.al_recibir_orden(OrderEvent(None, "X", 1, "limitada", 98.0))
    assert b.al_recibir_barra(barra(99, 100, 98.5, 99)) == []
    (f,) = b.al_recibir_barra(barra(99, 99, 97, 98))
    assert f.precio == 98.0

def test_reemplazo_cancela():
    b = Broker(lambda *a: 0.0)
    b.barra = barra(100, 101, 99, 100)
    o = OrderEvent(None, "X", -1, "stop", 95.0)
    b.al_recibir_orden(o)
    b.al_recibir_orden(OrderEvent(None, "X", -1, "stop", 97.0,
                                  reemplaza=o.id))
    assert [p.precio for p in b.pendientes.values()] == [97.0]

def test_deslizamiento_en_contra():
    b = Broker(lambda *a: 0.0, deslizamiento=0.001)
    b.barra = barra(100, 101, 99, 100)
    b.al_recibir_orden(OrderEvent(None, "X", 1, "mercado"))
    (f,) = b.al_recibir_barra(barra(100, 101, 99, 100))
    assert np.isclose(f.precio, 100.1)

def test_cartera_conserva_valor():
    c = Cartera(1000.0, {"X": 1.0}, mercado_de=lambda i: i)
    c.al_recibir_barra(barra(10, 10, 10, 10))
    (o,) = c.al_recibir_senal(SignalEvent(None, "X", 0.5))
    assert o.cantidad == 50
    c.al_recibir_fill(FillEvent(None, "X", 50, 10.0, 1.0, o.id))
    assert c.valor() == 999.0              # only the commission changes

for t in (test_stop_con_hueco, test_limitada_al_limite_o_mejor,
          test_reemplazo_cancela, test_deslizamiento_en_contra,
          test_cartera_conserva_valor):
    t()
print("5 tests passed")
OUT [05]Broker and portfolio tests
5 tests passed

Reconciliation with the vectorized backtest

The acid test of a new engine is reproducing a known result. The strategy is the one from lesson 2.4.1 (sign of the 252-session return with a volatility target) on the same 24 synthetic futures, with one difference so that the incremental calculation is simple: volatility is the standard deviation of the last 60 sessions instead of an EWMA. The strategy keeps its own history and only sees the bars that have already arrived; there is no table computed in advance.

IN [06]python
fut = pd.read_parquet("futuros2_continuos.parquet")
spec = pd.read_csv("futuros2_especificaciones.csv",
                   index_col="mercado")
px = fut.pivot(index="fecha", columns="mercado",
               values="cierre_ajustado")
mercados = list(px.columns)

class MomentumVol(Estrategia):
    """Sign of the 252-session return and weight inverse to the
    60-session volatility. It only knows the bars that have arrived."""

    def __init__(self, mercados, ventana=252, n_vol=60, vol=0.10):
        self.k = vol / np.sqrt(len(mercados))
        self.ventana, self.n_vol = ventana, n_vol
        self.hist = {m: deque(maxlen=ventana + 1) for m in mercados}

    def al_recibir_barra(self, ev):
        senales = []
        for m, p in ev.continuas.items():
            h = self.hist[m]
            h.append(p)
            if len(h) <= self.ventana:
                continue                     # no history yet
            ult = np.array(h)[-(self.n_vol + 1):]
            vol = np.std(np.diff(ult) / ult[:-1], ddof=1) * np.sqrt(252)
            senal = np.sign(h[-1] / h[0] - 1)
            senales.append(SignalEvent(ev.fecha, m,
                                       senal * self.k / vol))
        return senales

def eventos_continuos(px):
    for fecha, fila in zip(px.index, px.to_numpy()):
        precios = dict(zip(mercados, fila))
        yield MarketEvent(fecha,
                          {m: Barra(p, p, p, p)
                           for m, p in precios.items()},
                          precios, {m: m for m in mercados})

uno = {m: 1.0 for m in mercados}
eq_ev, cuenta = ejecutar(eventos_continuos(px), MomentumVol(mercados),
                         Cartera(1.0, uno), Broker(lambda *a: 0.0))
print(cuenta)
OUT [06]Data and event-driven strategy
{'MarketEvent': 6539, 'SignalEvent': 150888, 'OrderEvent': 150888, 'FillEvent': 150888}

The data arrives as a generator (lesson 0.4.3): one bar at a time, without the engine being able to look at the next one. With 24 markets and 6,539 sessions, the engine processes 6,539 bars, 150,888 signals, as many orders and as many fills: each market is rebalanced every day. The equivalent vectorized calculation:

IN [07]python
def pesos_vectorizados(px, ventana=252, n_vol=60, vol=0.10):
    r = px.pct_change(fill_method=None)
    senal = np.sign(px / px.shift(ventana) - 1)
    sigma = r.rolling(n_vol).std() * np.sqrt(252)
    return senal * vol / np.sqrt(px.shape[1]) / sigma

r = px.pct_change(fill_method=None)
w = pesos_vectorizados(px).fillna(0.0)
eq_vec = (1 + (w.shift(1, fill_value=0.0) * r.fillna(0.0))
          .sum(axis=1)).cumprod()
dif = (eq_ev / eq_vec - 1).abs()
print(f"maximum difference: {dif.max():.1e}")
print(f"final capital: events {eq_ev.iloc[-1]:.6f}, "
      f"vectorized {eq_vec.iloc[-1]:.6f}")
OUT [07]The same portfolio, vectorized
maximum difference: 3.5e-14
final capital: events 31.561693, vectorized 31.561693

Identical: the largest relative difference in 26 years is of the order of 10−1410^{-14}, floating-point rounding (lesson 0.3.1). It is not a coincidence. At each close the engine buys qi,t=wi,tEt/Pi,tq_{i,t} = w_{i,t} E_t / P_{i,t} units, and at the next close it earns qi,t(Pi,t+1−Pi,t)=wi,tEt ri,t+1q_{i,t}(P_{i,t+1} - P_{i,t}) = w_{i,t} E_t\, r_{i,t+1}: exactly the term of the vectorized formula. That they match to the cent proves that both implement the same assumptions. From here on, each difference that appears will have a concrete cause.

The first one appears with costs. The engine pays on what it actually trades, the difference between the target quantity and the one it holds. Between two closes, the weight of each position has drifted with its price and with that of the rest of the portfolio: wi,t (1+ri,t+1)/(1+Rt+1)w_{i,t}\,(1 + r_{i,t+1}) / (1 + R_{t+1}). The turnover of lesson 2.4.1, ∣wt+1−wt∣\lvert w_{t+1} - w_t \rvert, ignores that drift.

IN [08]python
bruto = fut.pivot(index="fecha", columns="mercado", values="cierre")
coste_usd = (spec["coste_tipico_ticks"] * spec["valor_tick"]
             + spec["comision_usd"])
coste_unit = coste_usd / (bruto * spec["multiplicador"])
tabla_coste = coste_unit.to_dict("index")

def comision_pct(fecha, instrumento, q, precio):
    return abs(q) * precio * tabla_coste[fecha][instrumento]

def anual(d):
    d = d.loc["2001":]
    return 100 * ((1 + d).prod() ** (252 / len(d)) - 1)

eq_ev_c, _ = ejecutar(eventos_continuos(px), MomentumVol(mercados),
                      Cartera(1.0, uno), Broker(comision_pct))
pos = w.shift(1, fill_value=0.0)
bruto_d = (pos * r.fillna(0.0)).sum(axis=1)
rot_simple = (w - pos).abs()
deriva = (pos * (1 + r.fillna(0.0))).div(1 + bruto_d, axis=0)
rot_deriva = (w - deriva).abs()
print(f"{'event-driven engine':34s}"
      f"{anual(eq_ev_c.pct_change()):.3f} %")
for nombre, rot in [("simple turnover", rot_simple),
                    ("turnover with drift", rot_deriva)]:
    neto = bruto_d - (rot * coste_unit).sum(axis=1)
    print(f"{'vectorized, ' + nombre:34s}{anual(neto):.3f} %")
OUT [08]With costs: the turnover that gets paid
event-driven engine               12.835 %
vectorized, simple turnover       13.034 %
vectorized, turnover with drift   12.833 %

Annual return from 2001 to 2025, net of costs: with simple turnover, the vectorized backtest returns 13.034% and the engine 12.835%, 20 basis points less, because simple turnover underestimates what has to be traded. With turnover corrected for drift, the vectorized one gives 12.833%: the difference stays at 0.2 basis points. What remains is second order: the engine sizes with the capital before paying the day's costs and the vectorized one, with the capital after. The header figure summarizes this lesson's full ladder, including the next section.

Real contracts: rolls, whole contracts and fixed cost

The adjusted price is a useful fiction for the signal, but it is not traded. A real future is held contract by contract: on the roll day the expiring one is sold and the next one bought, each at its own price, and the quantity has to be a whole number of contracts. The level 2 data brings what is needed: the contract held each day (futuros2_continuos) and the next one with its price (futuros2_carry). The roll calendar is fixed in advance by rule (5 sessions before expiry, 15 in metals and agriculturals), so knowing today that the contract changes tomorrow is not looking into the future.

IN [09]python
carry = pd.read_parquet("futuros2_carry.parquet")
tabla = fut.merge(carry, on=["fecha", "mercado"])
mañana = tabla.groupby("mercado")["contrato"].shift(-1)
tabla["ultimo"] = mañana.notna() & (mañana != tabla["contrato"])

def eventos_contratos(tabla):
    for fecha, g in tabla.groupby("fecha", sort=True):
        barras, negociar, continuas = {}, {}, {}
        for f in g.itertuples():
            barras[f.contrato] = Barra(*[f.cierre] * 4)
            continuas[f.mercado] = f.cierre_ajustado
            negociar[f.mercado] = f.contrato
            if f.ultimo:            # roll: the next one is traded
                barras[f.contrato_siguiente] = Barra(
                    *[f.cierre_siguiente] * 4)
                negociar[f.mercado] = f.contrato_siguiente
        yield MarketEvent(fecha, barras, continuas, negociar)

def comision_contrato(fecha, instrumento, q, precio):
    return abs(q) * coste_usd[instrumento[:3]]

mult = spec["multiplicador"].to_dict()
CAPITAL = 10_000_000
resultados = {}
for nombre, capital, enteros in [("fractional", CAPITAL, False),
                                 ("whole", CAPITAL, True),
                                 ("whole, 1 M", 1_000_000, True)]:
    cartera = Cartera(capital, mult, enteros=enteros)
    eq, cuenta_r = ejecutar(eventos_contratos(tabla),
                            MomentumVol(mercados), cartera,
                            Broker(comision_contrato))
    resultados[nombre] = eq
    print(f"{nombre}: {cuenta_r['OrderEvent']} orders, "
          f"commissions {cartera.comisiones / 1e6:.1f} M USD")
OUT [09]Real contracts: rolls and whole contracts
fractional: 154138 orders, commissions 31.8 M USD
whole: 102070 orders, commissions 31.9 M USD
whole, 1 M: 35233 orders, commissions 3.3 M USD

The same engine, with another event source and another commission function: neither the strategy nor the broker changes. With fractional contracts there are more orders than signals (the rolls add an order closing the old contract); with whole contracts and 10 million, a third of the signals do not move the number of contracts and generate no order; with 1 million, almost four out of five. The comparison with the vectorized backtest of lesson 2.4.1, the one that adds the rolls to simple turnover:

IN [10]python
contrato = fut.pivot(index="fecha", columns="mercado",
                     values="contrato")
rolls = (contrato != contrato.shift(1)) & contrato.shift(1).notna()
rot_v = rot_simple + 2 * pos.abs().where(rolls, 0.0)
neto_v = bruto_d - (rot_v * coste_unit).sum(axis=1)
d_v = neto_v.loc["2001":]
print(f"vectorized (2.4.1)       {anual(neto_v):5.2f} % annual")
for nombre, eq in resultados.items():
    d = eq.pct_change().loc["2001":]
    te = 100 * (d - d_v).std() * np.sqrt(252)
    print(f"events, {nombre:15s} {anual(eq.pct_change()):5.2f} % "
          f"annual, tracking {te:.2f} %, correlation "
          f"{d.corr(d_v):.3f}")
OUT [10]Real contracts versus the vectorized backtest
vectorized (2.4.1)       11.62 % annual
events, fractional      11.47 % annual, tracking 0.48 %, correlation 0.999
events, whole           11.47 % annual, tracking 0.49 %, correlation 0.999
events, whole, 1 M      11.60 % annual, tracking 1.09 %, correlation 0.997
Fig. — Portfolio of 24 synthetic futures, 2001–2025: equity from the vectorised backtest of lesson 2.4.1 and from the event-driven engine with real contracts, rolls and whole contracts (USD 1 million), and the distance between them

The difference can be attributed piece by piece. With fractional contracts and real rolls, the engine returns 0.15 points less than the vectorized backtest (11.47% versus 11.62%), somewhat less than the effect of the weight drift in the previous section (20 basis points); the rest is moved by the rounding of prices to the tick and the mechanics of the roll with two prices. With whole contracts and 10 million dollars nothing measurable changes: the same return and a tracking error of 0.49% versus 0.48%. With that capital practically no position is left at zero contracts (lesson 2.4.1 gave 0.0% with 10 million) and rounding moves each weight little.

With 1 million, rounding weighs more. The return barely changes (11.60%), but the tracking error doubles, to 1.09%, and the daily correlation drops to 0.997: the indices, with contracts worth hundreds of thousands of dollars, go in and out of zero contracts (in 2.4.1, the IDC one was at zero on 34% of the days). The figure shows how that noise accumulates: the two curves rise and fall together, and the gap between them changes sign, from −6.9% in 2004 to +2.6% in 2020, to finish at −0.5%. Rounding is not a fixed cost that can be subtracted: it is a portfolio different from the planned one, and the smaller the capital, the more different. None of those figures comes out of a vectorized backtest.

A trailing stop that only fits in a loop

The engine's second test is the rule with memory from lesson 2.4.1: buy at the close on each upward cross of KAPA's 20-session moving average, exit with a 4% trailing stop from the highest close reached and do not re-enter until a new cross. Here the engine receives the 5-minute bars, 78 per session, and the stop is a live order at the broker that is checked on each one. The strategy raises the stop (cancels the order and sends another) when the price makes a new high: at the close of each session or, with intradia=True, on each 5-minute bar.

IN [11]python
b5 = pd.read_parquet("indice2_5min.parquet")
b5["sesion"] = (b5["ts"].dt.tz_convert("America/New_York")
                .dt.tz_localize(None).dt.normalize())
b5["fin"] = b5["sesion"] != b5["sesion"].shift(-1)

def eventos_5min(b5):
    for f in b5.itertuples():
        yield MarketEvent(f.sesion if f.fin else f.ts,
                          {"KAPA": Barra(f.apertura, f.maximo,
                                         f.minimo, f.cierre)},
                          {"KAPA": f.cierre}, {"KAPA": "KAPA"}, f.fin)

class TrailingStop(Estrategia):
    """Buys at the close on each upward cross of the 20-session
    moving average; sells with a stop order at `stop` from the high
    since entry. intradia=False: the stop is raised at the close of
    each session; True: with each 5-minute bar."""

    def __init__(self, cartera, stop=0.04, n=20, intradia=False):
        self.cartera, self.stop, self.n = cartera, stop, n
        self.intradia = intradia
        self.cierres = deque(maxlen=n)
        self.encima, self.pico, self.orden = False, None, None

    def _subir_stop(self, fecha, precio):
        self.pico = precio
        o = OrderEvent(fecha, "KAPA", -self.cartera.pos["KAPA"],
                       "stop", precio * (1 - self.stop),
                       reemplaza=self.orden)
        self.orden = o.id
        return [o]

    def al_recibir_barra(self, ev):
        c = ev.barras["KAPA"].cierre
        dentro = "KAPA" in self.cartera.pos
        out = []
        if dentro and c > self.pico and (self.intradia
                                         or ev.fin_sesion):
            out = self._subir_stop(ev.fecha, c)
        if not ev.fin_sesion:
            return out
        self.cierres.append(c)
        antes = self.encima
        self.encima = (len(self.cierres) == self.n
                       and c > np.mean(self.cierres))
        if not dentro and self.encima and not antes:
            out.append(SignalEvent(ev.fecha, "KAPA", 1.0))
        return out

    def al_recibir_fill(self, f):
        if f.cantidad > 0:                  # entry: first stop
            return self._subir_stop(f.fecha, f.precio)
        self.orden = None                   # exit through the stop
        return []

The strategy needs the portfolio to know whether it is in position, and it receives the fills to place the first stop right after the entry, at the close of the same day. Without that notice, the stop would not be live until the second bar of the next session and would not see a gap at the open.

IN [12]python
salidas = {}
for intradia in (False, True):
    cartera = Cartera(1.0, {"KAPA": 1.0}, mercado_de=lambda i: i)
    estr = TrailingStop(cartera, intradia=intradia)
    eq, cuenta_k = ejecutar(eventos_5min(b5), estr, cartera,
                            Broker(lambda *a: 0.0))
    nombre = "intraday stop" if intradia else "close stop"
    salidas[nombre] = eq
    print(f"{nombre:15s} capital {eq.iloc[-1]:.3f}  "
          f"orders {cuenta_k['OrderEvent']:5d}  "
          f"fills {cuenta_k['FillEvent']}")
print(cuenta_k)
OUT [12]The trailing stop of lesson 2.4.1, event-driven
close stop      capital 1.535  orders   259  fills 57
intraday stop   capital 1.724  orders  1944  fills 59
{'MarketEvent': 77910, 'SignalEvent': 30, 'OrderEvent': 1944, 'FillEvent': 59}

With the stop raised at the close of each session, the engine gives exactly the result of the loop of lesson 2.4.1: a final capital of 1.535 with 28 exits (57 fills: 29 entries and 28 exits, because the last position is still open). It is not an approximate coincidence: with 5-minute bars, the first stop that triggers does so at the session open (if there is a gap) or exactly at the level, which are the two rules of the daily loop. The two implementations, written differently, give the same answer to the same question.

The version that raises the stop with each 5-minute bar can no longer be reproduced with daily bars: the high that sets the stop can be reached and lost within the session. It sends 1,944 orders instead of 259 (each raise of the stop is a cancellation and a new order) and ends with a capital of 1.724. The figure shows the last trade of the two, where the whole difference can be seen:

Fig. — The last trade of the event-driven KAPA trailing stop, 2 to 12 September 2025: price every 30 minutes and stop level raised at each session close or with every 5-minute bar

Both enter at the close of August 29, 2025 at 425.55. The intraday stop follows the 5-minute highs and exits on September 12 at 427.92, with a gain; the close stop, which only moves with the daily closes, exits later that same day at 422.45, with a loss. Neither one is “the correct one”: they are two different rules, and the intraday one has come out better in this stretch of synthetic data. What matters is that the engine lets you write them, test them and compare them with the same strategy and the same broker, and that each result can be reconciled with an independent version.

What it costs and what it gives

Vectorized (2.4.1)Event-driven (2.4.2)
SpeedMilliseconds for 24 markets and 26 yearsSeconds for the same; much more with 1-minute bars
Rules with memoryApproximation without warningNatural
Inside the barArbitrary assumptionWith higher-resolution data, exact
Whole contracts, rolls, capitalApproximationExact according to the broker model
Typical bugsForgotten shift, turnover without driftOrder of events, state badly reset
Going liveHas to be rewrittenThe simulated broker is replaced

A 200-line engine of your own is enough to understand what each piece does and to validate a simple strategy. It lacks many things of a professional engine: multiple currencies, a session calendar per market, margins and margin calls, partial fills and queues (lesson 2.4.8), latencies, orders with time in force, corporate actions. The libraries of lessons 2.4.3 to 2.4.6 solve part of that, each with its own decisions on the order of events and execution prices: after this lesson they can be read with a critical eye.

Exercises

  1. Change the futures strategy so that it executes with mercado orders (at the open of the next bar) instead of at the close. Since the level 2 futures only have a close, the open of the next bar is its close: what does it correspond to in the vectorized backtest? Check it with shift(2).
Solution
IN [13]python
class CarteraMercado(Cartera):
    def al_recibir_senal(self, s):
        ordenes = super().al_recibir_senal(s)
        for o in ordenes:
            o.tipo = "mercado"
        return ordenes

eq_m, _ = ejecutar(eventos_continuos(px), MomentumVol(mercados),
                   CarteraMercado(1.0, uno), Broker(lambda *a: 0.0))
d_ev = eq_m.pct_change().loc["2001":]
d_v2 = (w.shift(2, fill_value=0.0) * r.fillna(0.0)).sum(axis=1)
print(f"events, at market: {anual(eq_m.pct_change()):.3f} % annual")
print(f"vectorized shift(2): {anual(d_v2):.3f} % annual")
print(f"daily correlation: {d_ev.corr(d_v2.loc['2001':]):.4f}")
OUT [13]Orders at the next open
events, at market: 15.710 % annual
vectorized shift(2): 15.682 % annual
daily correlation: 0.9998

The order sent at the close of tt executes at the close of t+1t+1, so the position earns from t+2t+2 onward: it is the vectorized backtest's shift(2). They do not match to the cent because the quantity is computed with the capital and the price of tt and executed at another price: the actual weight obtained is not exactly the target. It is the same lesson as the reconciliation with costs: each changed assumption leaves a trace that you must be able to explain.

  1. Add a maximum volume rule to the broker: no order can execute more than 10% of the bar's volume; the rest stays pending for the next bar (a partial fill). Write a test with a bar of 1,000 shares and an order of 300.
Solution
IN [14]python
@dataclass(frozen=True, slots=True)
class BarraV(Barra):
    volumen: float = 0.0

class BrokerVolumen(Broker):
    def __init__(self, comision, max_vol=0.10):
        super().__init__(comision)
        self.max_vol = max_vol

    def al_recibir_barra(self, ev):
        self.barra = ev
        fills = []
        for o in list(self.pendientes.values()):
            b = ev.barras[o.instrumento]
            precio = self._precio(o, b)
            if precio is None:
                continue
            q = np.sign(o.cantidad) * min(abs(o.cantidad),
                                          self.max_vol * b.volumen)
            fills.append(FillEvent(ev.fecha, o.instrumento, q, precio,
                                   0.0, o.id))
            o.cantidad -= q
            if o.cantidad == 0:
                del self.pendientes[o.id]
        return fills

def test_fill_parcial():
    b = BrokerVolumen(lambda *a: 0.0)
    b.barra = barra(10, 10, 10, 10)
    b.al_recibir_orden(OrderEvent(None, "X", 300, "mercado"))
    ev = MarketEvent(pd.Timestamp("2024-01-03"),
                     {"X": BarraV(10, 10, 10, 10, 1000)}, {}, {})
    tamaños = [b.al_recibir_barra(ev)[0].cantidad for _ in range(3)]
    assert tamaños == [100, 100, 100] and not b.pendientes
test_fill_parcial()
print("partial fill: 3 bars of 100")
OUT [14]Partial fills by volume
partial fill: 3 bars of 100

The 300-share order executes in three bars of 100. In a backtest that changes the average price (each part executes at a different price) and the exposure (for two bars only part of the position is held). Lesson 2.4.8 develops the model with the book queue.

  1. In the loop, the broker executes the live orders before the strategy sees the bar. Explain what would happen to KAPA's trailing stop if the order were the opposite (strategy first, fills afterwards) in a session in which the stop triggers on the last bar and there is also an upward cross.
Solution

The strategy would see the bar believing it was still in position: it could not re-enter on that day's cross (it would miss the entry that the loop of 2.4.1 does make) and, if the close made a new high, it would send a new stop to sell a position that no longer exists; when that stop triggered, it would open a short the rule never asked for. It is a bug that breaks nothing visibly: the backtest carries on and gives a number. The only way to see it is to reconcile with another implementation or to have a test for that case.

Each answer is folded under its question

Self-assessment

  1. Without costs, the event-driven engine and the vectorized backtest of the futures portfolio end with the same capital and a maximum relative difference of 3.5e-14. What does that prove?

    • AThat both implement the same assumptions (weights decided at the close, execution at that close, daily rebalancing), and that any difference that appears later will come from a different assumption, not from a bug
    • BThat the event-driven engine adds nothing
    • CThat the strategy's result is reliable in real markets
    • DThat the vectorized one is more accurate
    Show answer

    Answer A. That both implement the same assumptions (weights decided at the close, execution at that close, daily rebalancing), and that any difference that appears later will come from a different assumption, not from a bug

    An exact reconciliation is the proof that both engines do the same thing with the same rules. From there, each assumption that changes (costs with drift, real rolls, whole contracts) produces a difference that can be measured and attributed. About the strategy it says nothing: the data is synthetic.

  2. With costs, the vectorized backtest with turnover |w_t − w_(t−1)| returns about 20 basis points a year more than the event-driven engine. Where do they come from?

    • AFrom a rounding error in the engine
    • BFrom the engine charging the commission twice
    • CFrom the stop orders
    • DFrom the fact that between two closes the actual weight drifts with the price: what has to be traded to get back to the target is not the difference in weights, but the difference between the target and the drifted weight
    Show answer

    Answer D. From the fact that between two closes the actual weight drifts with the price: what has to be traded to get back to the target is not the difference in weights, but the difference between the target and the drifted weight

    If one market rises 2% and the rest of the portfolio does not, its weight in the capital has changed even though nobody traded. The engine, which trades quantities, pays to bring it back to the target. With turnover corrected for drift, the vectorized backtest lands within a fraction of a basis point of the engine.

  3. On the roll day, what does the engine's portfolio do with a future?

    • ANothing: the adjusted price already includes the roll
    • BIt buys the next one the following day
    • CIt sells the expiring contract at its close and buys the next one at that other contract's close, with its own price and, if requested, in a whole number of contracts
    • DIt closes the position and waits for a new signal
    Show answer

    Answer C. It sells the expiring contract at its close and buys the next one at that other contract's close, with its own price and, if requested, in a whole number of contracts

    That day's MarketEvent carries both contracts with their prices. The portfolio sends one order that closes the old one and another that opens the new one with the target quantity computed from the new one's price. Each order pays its cost per contract. It is what the adjusted price hides.

  4. With whole contracts, the portfolio returns 11.47% a year with 10 million USD and 11.60% with 1 million, versus 11.62% for the vectorized one; the tracking error goes from 0.49% to 1.09%. What does that indicate?

    • AThat with little capital whole contracts improve the strategy
    • BThat rounding to whole contracts is noise more than a fixed cost: it barely moves the return, but it pulls the actual portfolio away from the planned one, and more so the smaller the capital
    • CThat the event-driven engine has a bug with 1 million
    • DThat tracking error does not matter if the return matches
    Show answer

    Answer B. That rounding to whole contracts is noise more than a fixed cost: it barely moves the return, but it pulls the actual portfolio away from the planned one, and more so the smaller the capital

    Separating the assumptions one at a time is what allows them to be attributed. With 10 million, rounding to whole contracts changes nothing measurable relative to fractional contracts (11.47% and 0.48% tracking). With 1 million, the 25-year return almost matches, but each day the actual portfolio drifts further from the weights portfolio: in lesson 2.4.1, with that capital, 4.2% of the positions were left at zero contracts.

  5. Why does the engine process the fills of the live orders before the strategy sees the bar?

    • AFor performance
    • BBecause a stop can execute within that same bar: if the strategy decided at the close without knowing it, it would believe it was still in position, raise a stop that no longer exists and could not re-enter that day
    • CBecause pandas requires it
    • DThe order does not matter
    Show answer

    Answer B. Because a stop can execute within that same bar: if the strategy decided at the close without knowing it, it would believe it was still in position, raise a stop that no longer exists and could not re-enter that day

    The order of events within a bar is part of the rules. In the lesson's engine, prices are marked first, then the broker executes whatever was pending and only then does the strategy decide. With that order, the event-driven trailing stop reproduces the loop of lesson 2.4.1 exactly.

Key points

  • An event-driven engine is a loop over a queue: MarketEvent to the strategy, SignalEvent to the portfolio, OrderEvent to the broker and FillEvent back to the portfolio and the strategy.
  • The simulated broker concentrates the execution rules: at the close, at the next open, limit orders at the limit or better, and stops that, with a gap, execute at the open.
  • With the same assumptions, the engine and the vectorized backtest match to the cent (3.5e-14 without costs); each different assumption leaves a difference that can be measured: about 20 bp a year from weight drift and 0.15 points with real contracts and rolls.
  • The engine models what the adjusted price hides: two prices on the roll day, a fixed cost per contract and whole contracts, which with 10 million change nothing measurable and with 1 million double the tracking error (1.09%).
  • Rules with memory are natural in an event-driven engine: KAPA's trailing stop reproduces the loop of 2.4.1 and, raising the stop with each 5-minute bar, changes the result from 1.535 to 1.724.
  • Speed in exchange for fidelity: the futures portfolio takes seconds event-driven versus milliseconds vectorized; that is why you explore vectorized and validate event-driven.

Resources

Documentation

Data