---
title: "2.4.2 Event-driven backtest from scratch"
description: "An event-driven backtest engine in Python: queue, portfolio, limit and stop orders, fills with gaps and costs; reconciled with the vectorized one."
url: "https://pythonparatrading.com/en/python-trading-course/level-2/event-driven-backtest-from-scratch/"
lang: "en"
image: "https://pythonparatrading.com/og/en/python-trading-course/level-2/event-driven-backtest-from-scratch.png"
---

# 2.4.2 · 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.

- Course: [Level 2: Systematic strategies and rigorous backtesting](https://pythonparatrading.com/en/python-trading-course/level-2/)
- Module: 2.4 · Backtesting: engines and paradigms
- Duration: 75 min
- Prerequisites: [2.4.1 · Vectorized backtest from scratch: matrices, lag, costs and limits](https://pythonparatrading.com/en/python-trading-course/level-2/vectorized-backtest-from-scratch/), [0.4.1 · Object-oriented programming: classes, inheritance, composition and dataclasses](https://pythonparatrading.com/en/python-trading-course/level-0/python-classes-inheritance-dataclasses/), [1.1.4 · Order types](https://pythonparatrading.com/en/python-trading-course/level-1/order-types-explained/)

## By the end of this lesson

- Program an event-driven engine with four event types (market, signal, order, fill), a queue, a portfolio and a simulated broker with commissions, slippage, and limit and stop orders.
- Reconcile the engine with the vectorized backtest: identical results when the assumptions match and differences explained, term by term, when they do not.
- Simulate real futures contracts: rolls with both prices of the day, whole contracts and a fixed cost per contract.
- Run on 5-minute bars a path-dependent strategy with a trailing stop and compare its end-of-day version with the intraday one.

## 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:

```mermaid
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
```

| Event | Emitted by | Received by | What it says |
|---|---|---|---|
| `MarketEvent` | The data | Broker, portfolio, strategy | A bar has arrived: prices of each instrument |
| `SignalEvent` | The strategy | The portfolio | I want this weight in this market |
| `OrderEvent` | The portfolio or the strategy | The broker | Buy or sell this quantity, of this type, at this price |
| `FillEvent` | The broker | The portfolio and the strategy | This 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.

```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.

```mermaid
stateDiagram-v2
  [*] --> Pending: OrderEvent
  [*] --> Filled: at the close
  Pending --> Filled: the bar crosses it
  Pending --> Cancelled: replacement
  Filled --> [*]
  Cancelled --> [*]
```

```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.

```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.

```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:

```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")
```
```output
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.

```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)
```
```output
{'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:

```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}")
```
```output
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^{-14}$, floating-point rounding (lesson
0.3.1). It is not a coincidence. At each close the engine buys $q_{i,t} = w_{i,t} E_t / P_{i,t}$ units, and at the
next close it earns $q_{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: $w_{i,t}\,(1 + r_{i,t+1}) / (1 + R_{t+1})$. The turnover of lesson 2.4.1,
$\lvert w_{t+1} - w_t \rvert$, ignores that drift.

```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} %")
```
```output
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.

```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")
```
```output
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:

```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}")
```
```output
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.* Top, two equity curves on a log scale that move almost together: the vectorised one ends at ×15.54 and the engine with whole contracts and USD 1 million (highlighted) at ×15.46. Bottom, the relative distance between them, which changes sign: it reaches −6.9% in 2004, +2.6% in 2020 and ends at −0.5%.

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.

<aside class="callout" data-kind="aviso">

The data is synthetic and carries planted momentum and carry: the 11.47% a year is an engine's answer to an effect we
know exists, not an estimate of what this strategy would return in real futures. And before believing any backtest
return you still need realistic costs (lesson 2.4.7), out-of-sample validation (lesson 2.5.2) and the correction for
the number of trials (lesson 2.5.4).

</aside>

## 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.

```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.

```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)
```
```output
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.* KAPA price over nine sessions, between 418 and 446, with two stop step lines below. The close-only stop (orange) steps up once a day; the intraday stop (highlighted) follows the highs more closely. On 12 September the price falls: the intraday stop triggers first and sells at 427.92; the close-only stop sells later at 422.45. Entry was on 29 August at 425.55.

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) |
|---|---|---|
| Speed | Milliseconds for 24 markets and 26 years | Seconds for the same; much more with 1-minute bars |
| Rules with memory | Approximation without warning | Natural |
| Inside the bar | Arbitrary assumption | With higher-resolution data, exact |
| Whole contracts, rolls, capital | Approximation | Exact according to the broker model |
| Typical bugs | Forgotten `shift`, turnover without drift | Order of events, state badly reset |
| Going live | Has to be rewritten | The 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.**

```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}")
```
```output
events, at market: 15.710 % annual
vectorized shift(2): 15.682 % annual
daily correlation: 0.9998
```

The order sent at the close of $t$ executes at the close of $t+1$, so the position earns from $t+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 $t$ 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.

2. 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.**

```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")
```
```output
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.

3. 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.

## 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?

   - 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
   - B. That the event-driven engine adds nothing
   - C. That the strategy's result is reliable in real markets
   - D. That the vectorized one is more accurate

   *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?

   - A. From a rounding error in the engine
   - B. From the engine charging the commission twice
   - C. From the stop orders
   - 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

   *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?

   - A. Nothing: the adjusted price already includes the roll
   - B. It buys the next one the following day
   - 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
   - D. It closes the position and waits for a new signal

   *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?

   - A. That with little capital whole contracts improve the strategy
   - 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
   - C. That the event-driven engine has a bug with 1 million
   - D. That tracking error does not matter if the return matches

   *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?

   - A. For performance
   - 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
   - C. Because pandas requires it
   - D. The order does not matter

   *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

### Research

- [Finite-State Machines for Quantitative Trading Execution in Python: From Knight Capital to LangGraph](https://pythonparatrading.com/en/finite-state-machines-trading-python/) · Research-0029 · 47 min

### Documentation

- [Python: dataclasses](https://docs.python.org/3/library/dataclasses.html) · docs.python.org
- [Python: collections.deque](https://docs.python.org/3/library/collections.html) · docs.python.org

### Books

- [Ernest P. Chan: Quantitative Trading, 2nd edition (Wiley, 2021)](https://www.wiley.com/en-us/Quantitative+Trading%3A+How+to+Build+Your+Own+Algorithmic+Trading+Business%2C+2nd+Edition-p-9781119800064) · wiley.com

### Data

- [Level 2 data: synthetic continuous futures (Parquet)](https://pythonparatrading.com/curso/datos/nivel-2/futuros2_continuos.parquet) · Download · futuros2_continuos.parquet
- [Level 2 data: next contract and carry of each future (Parquet)](https://pythonparatrading.com/curso/datos/nivel-2/futuros2_carry.parquet) · Download · futuros2_carry.parquet
- [Level 2 data: KAPA 5-minute bars (Parquet)](https://pythonparatrading.com/curso/datos/nivel-2/indice2_5min.parquet) · Download · indice2_5min.parquet

Previous lesson: [2.4.1 · Vectorized backtest from scratch: matrices, lag, costs and limits](https://pythonparatrading.com/en/python-trading-course/level-2/vectorized-backtest-from-scratch/)

Next lesson: [2.4.3 · vectorbt: massive parameter sweeps and portfolio simulation](https://pythonparatrading.com/en/python-trading-course/level-2/vectorbt-parameter-sweeps/)
