---
title: "2.4.2 Backtest por eventos desde cero"
description: "Un motor de backtest por eventos en Python: cola, cartera, órdenes limitadas y stop, fills con huecos y costes; reconciliado con el vectorizado."
url: "https://pythonparatrading.com/curso-python-trading/nivel-2/backtest-por-eventos-desde-cero/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-2/backtest-por-eventos-desde-cero.png"
---

# 2.4.2 · Backtest por eventos desde cero: bucle, cartera, órdenes y fills

> Un motor por eventos recorre el tiempo como lo haría el sistema en real: llega una barra, la estrategia decide, la cartera pide órdenes, el broker las ejecuta. Esta lección escribe uno en Python puro, lo reconcilia al céntimo con el backtest vectorizado de la lección 2.4.1 y lo usa para lo que el vectorizado no puede: contratos reales con rolls, contratos enteros y un trailing stop dentro de la barra.

- Curso: [Nivel 2: Estrategias sistemáticas y backtesting riguroso](https://pythonparatrading.com/curso-python-trading/nivel-2/)
- Módulo: 2.4 · Backtesting: motores y paradigmas
- Duración: 75 min
- Requisitos: [2.4.1 · Backtest vectorizado desde cero: matrices, desfase, costes y límites](https://pythonparatrading.com/curso-python-trading/nivel-2/backtest-vectorizado-desde-cero/), [0.4.1 · Programación orientada a objetos: clases, herencia, composición y dataclasses](https://pythonparatrading.com/curso-python-trading/nivel-0/clases-herencia-y-dataclasses/), [1.1.4 · Tipos de órdenes](https://pythonparatrading.com/curso-python-trading/nivel-1/tipos-de-ordenes/)

## Al terminar esta lección

- Programar un motor por eventos con cuatro tipos de evento (mercado, señal, orden, ejecución), una cola, una cartera y un broker simulado con comisiones, deslizamiento y órdenes limitadas y stop.
- Reconciliar el motor con el backtest vectorizado: resultados idénticos cuando las hipótesis coinciden y diferencias explicadas, término a término, cuando no.
- Simular contratos de futuros reales: rolls con los dos precios del día, contratos enteros y coste fijo por contrato.
- Ejecutar sobre barras de 5 minutos una estrategia con trailing stop que depende del camino y comparar su versión al cierre con la intradía.

## Del cálculo por matrices al bucle de eventos

La lección 2.4.1 terminó con una lista de cosas que un backtest vectorizado no puede hacer bien: reglas con memoria,
lo que ocurre dentro de una barra, contratos enteros. Todas tienen la misma causa: la decisión de hoy depende del
estado (posición, órdenes vivas, capital) que dejó la de ayer. Un motor **orientado a eventos** resuelve eso
recorriendo el tiempo en orden, una barra cada vez, y dejando que cada componente reaccione solo a lo que ya ha
ocurrido. Es la misma estructura que tendrá el sistema cuando opere en real, y por eso los motores profesionales
(NautilusTrader, LEAN o Zipline, lecciones 2.4.5 y 2.4.6) están construidos así.

El motor de esta lección tiene cuatro piezas y cuatro tipos de mensaje:

```mermaid
flowchart TB
  D[Datos] --> M[MarketEvent]
  M --> E[Estrategia]
  E --> S[SignalEvent]
  S --> C[Cartera]
  C --> O[OrderEvent]
  O --> B[Broker]
  B --> F[FillEvent]
  F --> C
  F --> E
```

| Evento | Lo emite | Lo recibe | Qué dice |
|---|---|---|---|
| `MarketEvent` | Los datos | Broker, cartera, estrategia | Ha llegado una barra: precios de cada instrumento |
| `SignalEvent` | La estrategia | La cartera | Quiero este peso en este mercado |
| `OrderEvent` | La cartera o la estrategia | El broker | Compra o vende esta cantidad, de este tipo, a este precio |
| `FillEvent` | El broker | La cartera y la estrategia | Se ha ejecutado esta cantidad a este precio, con esta comisión |

La separación tiene una razón práctica: la estrategia no sabe cuánto capital hay ni cuántos contratos son un peso del
13 %; la cartera no sabe nada de medias ni de momentum; el broker no sabe por qué le llega una orden. Cada pieza se
puede cambiar sin tocar las otras, y en real el broker simulado se sustituye por la conexión con el broker de verdad.

## Los eventos y el broker simulado

Los eventos son `dataclasses` (lección 0.4.1): estructuras de datos con nombre, sin lógica. Las inmutables
(`frozen=True`) son hechos que ya ocurrieron; la orden es mutable porque tiene un ciclo de vida.

```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           # instrumento -> Barra
    continuas: dict        # mercado -> precio de la serie continua
    negociar: dict         # mercado -> instrumento que se negocia
    fin_sesion: bool = True

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

_ids = count(1)

@dataclass(slots=True)
class OrderEvent:
    fecha: pd.Timestamp
    instrumento: str
    cantidad: float                 # + compra, - venta
    tipo: str = "cierre"            # cierre, mercado, limitada, stop
    precio: float | None = None     # límite o disparo
    reemplaza: int | None = None    # id de la orden que cancela
    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
```

El `MarketEvent` lleva tres diccionarios porque un futuro tiene dos caras. La estrategia necesita la serie continua
ajustada para calcular su señal sin saltos (lección 1.4.6); la cartera necesita saber qué contrato concreto se negocia
hoy (`negociar`), y el broker, el precio de cada contrato (`barras`). En un activo sin vencimiento, las tres cosas
coinciden.

El broker es la pieza que decide **a qué precio** se ejecuta cada orden, y por tanto la que más pesa en el resultado.
Las reglas de este son las de la lección 1.1.4 aplicadas a barras:

- **Al cierre** (`cierre`): se ejecuta al cierre de la barra en la que se envía. Es la hipótesis del backtest
  vectorizado y la de una orden al cierre real.
- **A mercado** (`mercado`): se ejecuta a la apertura de la barra siguiente.
- **Limitada**: una compra se ejecuta si el mínimo de la barra llega al límite, al límite o a la apertura si esta ya
  es mejor. Es una regla optimista: tocar el precio no garantiza la ejecución si hay cola delante (lección 2.4.8).
- **Stop**: una venta se dispara si el mínimo llega al nivel y se ejecuta a ese nivel o, si la barra **abre por
  debajo**, a la apertura. Ese hueco es la diferencia entre un stop y una garantía.

```mermaid
stateDiagram-v2
  [*] --> Pendiente: OrderEvent
  [*] --> Ejecutada: al cierre
  Pendiente --> Ejecutada: la barra la cruza
  Pendiente --> Cancelada: reemplazo
  Ejecutada --> [*]
  Cancelada --> [*]
```

```python
class Broker:
    """Ejecuta órdenes contra las barras que llegan."""

    def __init__(self, comision, deslizamiento=0.0):
        self.comision = comision    # f(fecha, instrumento, q, precio)
        self.desliz = deslizamiento # fracción del precio, en contra
        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 []                       # solo cancelaba
        if o.tipo == "cierre":              # al cierre de esta barra
            return [self._fill(o, self.barra.barras[o.instrumento]
                               .cierre)]
        self.pendientes[o.id] = o           # esperan a otra barra
        return []

    @staticmethod
    def _precio(o, b):
        compra = o.cantidad > 0
        if o.tipo == "mercado":             # apertura siguiente
            return b.apertura
        if o.tipo == "limitada":            # al límite o mejor
            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":                # se dispara: a mercado
            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":            # deslizamiento en contra
            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)
```

La comisión es una función que recibe el broker al crearse: así el mismo motor sirve para un coste proporcional al
nocional, uno fijo por contrato o los esquemas por tramos de la lección 2.4.7. El deslizamiento mueve el precio en
contra de quien envía la orden, salvo en las limitadas, que por definición no se ejecutan peor que su límite.

## La cartera y el bucle

La cartera traduce pesos en cantidades y lleva la contabilidad: efectivo, posiciones y su valor a precios de cierre.
Para futuros simplifica una cosa: compra y vende el nocional contra efectivo, como si fueran acciones, en lugar de
liquidar pérdidas y ganancias cada día contra el margen (lección 1.2.3). El valor de la cuenta es el mismo; lo que
cambia es el saldo de efectivo, y aquí no se le aplican intereses, igual que en el vectorizado.

```python
class Cartera:
    """Efectivo, posiciones y valoración a precios de cierre."""

    def __init__(self, capital, mult, enteros=False,
                 mercado_de=lambda i: i[:3]):
        self.efectivo = capital
        self.pos = {}                # instrumento -> cantidad
        self.precio = {}             # último cierre conocido
        self.mult = mult             # mercado -> USD por punto
        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)      # contrato que vence
                   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()))
```

Dos detalles de `al_recibir_senal` hacen el trabajo de los futuros. Si hay una posición en un contrato que ya no es el
que se negocia (el día del roll), sale una orden que la cierra. Y la cantidad objetivo se calcula con el precio del
contrato de destino, que el día del roll es el siguiente. Con `enteros=True`, se redondea al contrato.

El bucle es lo más corto del motor y lo que más cuidado pide, porque fija **el orden de las cosas dentro de una
barra**: primero se marcan los precios de cierre, después el broker ejecuta las órdenes que estaban vivas (un stop que
salta a media sesión) y solo entonces la estrategia ve la barra y decide. Si la estrategia decidiera antes de saber que
su stop se ha ejecutado, creería seguir en posición.

```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)            # precios de cierre
        cola.extend(broker.al_recibir_barra(barra))  # órdenes vivas
        cola.append(barra)                     # y luego la estrategia
        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 []
```

La cola es una `deque` del módulo `collections`: se añade por un extremo y se saca por el otro, así que los eventos se procesan
en el orden en que se generan. Como todas las señales de una barra entran en la cola antes que cualquiera de sus
órdenes, la cartera calcula todas las cantidades con el mismo capital, el de antes de negociar. Es una decisión; otra
igual de válida daría un resultado ligeramente distinto, y por eso hay que saber cuál toma cada motor.

Las piezas se prueban por separado, con barras construidas a mano cuya respuesta se sabe:

```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                  # abre por debajo: apertura

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              # solo cambia la comisión

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 superados")
```
```output
5 tests superados
```

## Reconciliación con el backtest vectorizado

La prueba de fuego de un motor nuevo es reproducir un resultado conocido. La estrategia es la de la lección 2.4.1
(signo del rendimiento de 252 sesiones con objetivo de volatilidad) sobre los mismos 24 futuros sintéticos, con una
diferencia para que el cálculo incremental sea sencillo: la volatilidad es la desviación típica de las últimas 60
sesiones en lugar de una EWMA. La estrategia guarda su propia historia y solo ve las barras que ya han llegado; no
hay ninguna tabla calculada de antemano.

```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):
    """Signo del rendimiento de 252 sesiones y peso inverso a la
    volatilidad de 60. Solo conoce las barras que ya han llegado."""

    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                     # aún sin historia
            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}
```

Los datos llegan como un generador (lección 0.4.3): una barra cada vez, sin que el motor pueda mirar la siguiente. Con
24 mercados y 6.539 sesiones, el motor procesa 6.539 barras, 150.888 señales, otras tantas órdenes y otros tantos
fills: cada mercado se rebalancea cada día. El cálculo vectorizado equivalente:

```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"diferencia máxima: {dif.max():.1e}")
print(f"capital final: eventos {eq_ev.iloc[-1]:.6f}, "
      f"vectorizado {eq_vec.iloc[-1]:.6f}")
```
```output
diferencia máxima: 3.5e-14
capital final: eventos 31.561693, vectorizado 31.561693
```

Idénticos: la mayor diferencia relativa en 26 años es del orden de $10^{-14}$, el redondeo de la coma flotante
(lección 0.3.1). No es casualidad. El motor compra en cada cierre $q_{i,t} = w_{i,t} E_t / P_{i,t}$ unidades, y al
cierre siguiente gana $q_{i,t}(P_{i,t+1} - P_{i,t}) = w_{i,t} E_t\, r_{i,t+1}$: exactamente el término de la fórmula
vectorizada. Que coincidan al céntimo demuestra que los dos implementan las mismas hipótesis. Desde aquí, cada
diferencia que aparezca tendrá una causa concreta.

La primera aparece con los costes. El motor paga sobre lo que negocia de verdad, la diferencia entre la cantidad
objetivo y la que tiene. Entre dos cierres, el peso de cada posición se ha desplazado con su precio y con el del resto
de la cartera: $w_{i,t}\,(1 + r_{i,t+1}) / (1 + R_{t+1})$. La rotación de la lección 2.4.1,
$\lvert w_{t+1} - w_t \rvert$, ignora esa deriva.

```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"{'motor por eventos':34s}"
      f"{anual(eq_ev_c.pct_change()):.3f} %")
for nombre, rot in [("rotación simple", rot_simple),
                    ("rotación con deriva", rot_deriva)]:
    neto = bruto_d - (rot * coste_unit).sum(axis=1)
    print(f"{'vectorizado, ' + nombre:34s}{anual(neto):.3f} %")
```
```output
motor por eventos                 12.835 %
vectorizado, rotación simple      13.034 %
vectorizado, rotación con deriva  12.833 %
```

Rentabilidad anual de 2001 a 2025, neta de costes: con la rotación simple, el vectorizado rinde un 13,034 % y el motor
un 12,835 %, 20 puntos básicos menos, porque la rotación simple infravalora lo que hay que negociar. Con la rotación
corregida por la deriva, el vectorizado da un 12,833 %: la diferencia se queda en 0,2 puntos básicos. Lo que queda es de segundo orden: el motor dimensiona con el capital de antes de pagar los
costes del día y el vectorizado, con el de después. La figura de la cabecera resume la escalera completa de esta
lección, incluida la sección siguiente.

## Contratos reales: rolls, contratos enteros y coste fijo

El precio ajustado es una ficción útil para la señal, pero no se negocia. Un futuro real se mantiene contrato a
contrato: el día del roll se vende el que vence y se compra el siguiente, cada uno a su precio, y la cantidad tiene que
ser un número entero de contratos. Los datos del nivel 2 traen lo necesario: el contrato que se tiene cada día
(`futuros2_continuos`) y el siguiente con su precio (`futuros2_carry`). El calendario de rolls se fija de antemano por
regla (5 sesiones antes del vencimiento, 15 en metales y agrícolas), así que saber hoy que mañana cambia el contrato no
es mirar al futuro.

```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: se negocia el siguiente
                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 [("fraccionarios", CAPITAL, False),
                                 ("enteros", CAPITAL, True),
                                 ("enteros, 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']} órdenes, "
          f"comisiones {cartera.comisiones / 1e6:.1f} M USD")
```
```output
fraccionarios: 154138 órdenes, comisiones 31.8 M USD
enteros: 102070 órdenes, comisiones 31.9 M USD
enteros, 1 M: 35233 órdenes, comisiones 3.3 M USD
```

El mismo motor, con otra fuente de eventos y otra función de comisión: ni la estrategia ni el broker cambian. Con
contratos fraccionarios hay más órdenes que señales (los rolls añaden una orden de cierre del contrato viejo); con
contratos enteros y 10 millones, un tercio de las señales no mueve el número de contratos y no genera orden; con 1
millón, casi cuatro de cada cinco. La comparación con
el vectorizado de la lección 2.4.1, el que añade los rolls a la rotación simple:

```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"vectorizado (2.4.1)      {anual(neto_v):5.2f} % anual")
for nombre, eq in resultados.items():
    d = eq.pct_change().loc["2001":]
    te = 100 * (d - d_v).std() * np.sqrt(252)
    print(f"eventos, {nombre:14s} {anual(eq.pct_change()):5.2f} % "
          f"anual, seguimiento {te:.2f} %, correlación "
          f"{d.corr(d_v):.3f}")
```
```output
vectorizado (2.4.1)      11.62 % anual
eventos, fraccionarios  11.47 % anual, seguimiento 0.48 %, correlación 0.999
eventos, enteros        11.47 % anual, seguimiento 0.49 %, correlación 0.999
eventos, enteros, 1 M   11.60 % anual, seguimiento 1.09 %, correlación 0.997
```

*Fig. — Cartera de 24 futuros sintéticos, 2001–2025: capital del backtest vectorizado de la lección 2.4.1 y del motor por eventos con contratos reales, rolls y contratos enteros (1 millón de USD), y la distancia entre los dos.* Arriba, dos curvas de capital en escala logarítmica que se mueven casi a la par: la vectorizada termina en ×15,54 y la del motor con contratos enteros y 1 millón (destacada) en ×15,46. Abajo, la distancia relativa entre las dos, que cambia de signo: llega a −6,9 % en 2004, a +2,6 % en 2020 y termina en −0,5 %.

La diferencia se puede atribuir por partes. Con contratos fraccionarios y rolls reales, el motor rinde 0,15 puntos
menos que el vectorizado (11,47 % frente a 11,62 %), algo menos que el efecto de la deriva de los pesos de la sección
anterior (20 puntos básicos); el resto lo mueven el redondeo de los precios al tick y la mecánica del roll con dos
precios. Con contratos enteros y 10 millones de dólares no cambia nada medible: la misma rentabilidad y un error de
seguimiento del 0,49 % frente al 0,48 %. Con ese capital prácticamente ninguna posición se queda en cero contratos
(la lección 2.4.1 daba un 0,0 % con 10 millones) y redondear mueve poco cada peso.

Con 1 millón, el redondeo pesa más. La rentabilidad apenas cambia (11,60 %), pero el error de seguimiento se duplica,
hasta el 1,09 %, y la correlación diaria baja a 0,997: los índices, con contratos de cientos de miles de dólares, entran
y salen de cero contratos (en 2.4.1, el de `IDC` estaba a cero un 34 % de los días). La figura enseña cómo se acumula
ese ruido: las dos curvas suben y caen juntas, y la distancia entre ellas cambia de signo, del −6,9 % de 2004 al +2,6 %
de 2020, hasta terminar en un −0,5 %. El redondeo no es un coste fijo que se pueda restar: es una cartera distinta de
la planeada, y cuanto menor es el capital, más distinta. Ninguna de esas cifras sale de un vectorizado.

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

Los datos son sintéticos y llevan un momentum y un carry plantados: el 11,47 % anual es la respuesta de un motor a un
efecto que sabemos que existe, no la estimación de lo que rendiría esta estrategia en futuros reales. Y antes de creer
cualquier rentabilidad de un backtest faltan los costes realistas (lección 2.4.7), la validación fuera de muestra
(lección 2.5.2) y la corrección por el número de pruebas (lección 2.5.4).

</aside>

## Un trailing stop que solo cabe en un bucle

La segunda prueba del motor es la regla con memoria de la lección 2.4.1: comprar al cierre en cada cruce al alza de la
media de 20 sesiones de KAPA, salir con un trailing stop del 4 % desde el máximo cierre alcanzado y no volver a entrar
hasta un nuevo cruce. Aquí el motor recibe las **barras de 5 minutos**, 78 por sesión, y el stop es una orden viva en
el broker que se comprueba en cada una. La estrategia sube el stop (cancela la orden y envía otra) cuando el precio
marca un máximo nuevo: al cierre de cada sesión o, con `intradia=True`, en cada barra de 5 minutos.

```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):
    """Compra al cierre en cada cruce al alza de la media de 20
    sesiones; vende con una orden stop a `stop` del máximo desde la
    entrada. intradia=False: el stop se sube al cierre de cada
    sesión; True: con cada barra de 5 minutos."""

    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:                  # entrada: primer stop
            return self._subir_stop(f.fecha, f.precio)
        self.orden = None                   # salida por el stop
        return []
```

La estrategia necesita a la cartera para saber si está en posición, y recibe los fills para colocar el primer stop
justo después de la entrada, al cierre del mismo día. Sin ese aviso, el stop no estaría vivo hasta la segunda barra de
la sesión siguiente y no vería un hueco en la apertura.

```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 = "stop intradía" if intradia else "stop al cierre"
    salidas[nombre] = eq
    print(f"{nombre:15s} capital {eq.iloc[-1]:.3f}  "
          f"órdenes {cuenta_k['OrderEvent']:5d}  "
          f"fills {cuenta_k['FillEvent']}")
print(cuenta_k)
```
```output
stop al cierre  capital 1.535  órdenes   259  fills 57
stop intradía   capital 1.724  órdenes  1944  fills 59
{'MarketEvent': 77910, 'SignalEvent': 30, 'OrderEvent': 1944, 'FillEvent': 59}
```

Con el stop subido al cierre de cada sesión, el motor da exactamente el resultado del bucle de la lección 2.4.1:
capital final de 1,535 con 28 salidas (57 fills: 29 entradas y 28 salidas, porque la última posición sigue abierta). No
es una coincidencia aproximada: con barras de 5 minutos, el primer stop que se dispara lo hace a la apertura de la
sesión (si hay hueco) o exactamente en el nivel, que son las dos reglas del bucle diario. Las dos implementaciones,
escritas de forma distinta, responden lo mismo a la misma pregunta.

La versión que sube el stop con cada barra de 5 minutos ya no es reproducible con barras diarias: el máximo que fija el
stop puede alcanzarse y perderse dentro de la sesión. Envía 1.944 órdenes en lugar de 259 (cada subida del stop es una
cancelación y una orden nueva) y termina con un capital de 1,724. La figura enseña la última operación de las dos,
donde la diferencia se ve entera:

*Fig. — La última operación del trailing stop de KAPA por eventos, del 2 al 12 de septiembre de 2025: precio cada 30 minutos y nivel del stop subido al cierre de cada sesión o con cada barra de 5 minutos.* Precio de KAPA de nueve sesiones, entre 418 y 446, con dos escalones de stop debajo. El stop al cierre (naranja) sube por saltos una vez al día; el intradía (destacado) sigue más de cerca los máximos. El 12 de septiembre el precio cae: el stop intradía salta antes y vende a 427,92; el stop al cierre vende más tarde a 422,45. La entrada fue el 29 de agosto a 425,55.

Las dos entran al cierre del 29 de agosto de 2025 a 425,55. El stop intradía sigue a los máximos de 5 minutos y sale el
12 de septiembre a 427,92, con ganancia; el stop al cierre, que solo se mueve con los cierres diarios, sale ese mismo
día más tarde a 422,45, con pérdida. Ni una ni otra es «la correcta»: son dos reglas distintas, y la intradía ha salido
mejor en este tramo de datos sintéticos. Lo que importa es que el motor permite escribirlas, probarlas y compararlas
con la misma estrategia y el mismo broker, y que cada resultado se puede reconciliar con una versión independiente.

## Lo que cuesta y lo que da

| | Vectorizado (2.4.1) | Por eventos (2.4.2) |
|---|---|---|
| Velocidad | Milisegundos para 24 mercados y 26 años | Segundos para lo mismo; mucho más con barras de 1 minuto |
| Reglas con memoria | Aproximación sin aviso | Naturales |
| Dentro de la barra | Supuesto arbitrario | Con datos de más resolución, exacto |
| Contratos enteros, rolls, capital | Aproximación | Exacto según el modelo del broker |
| Errores típicos | `shift` olvidado, rotación sin deriva | Orden de los eventos, estado mal reiniciado |
| Paso a real | Hay que reescribir | Se sustituye el broker simulado |

Un motor propio de 200 líneas basta para entender qué hace cada pieza y para validar una estrategia sencilla. Le
faltan muchas cosas de un motor profesional: varias divisas, calendario de sesiones por mercado, márgenes y llamadas
de margen, fills parciales y colas (lección 2.4.8), latencias, órdenes con validez, eventos corporativos. Las librerías
de las lecciones 2.4.3 a 2.4.6 resuelven parte de eso, cada una con sus propias decisiones sobre el orden de los
eventos y los precios de ejecución: después de esta lección se pueden leer con ojo crítico.

## Ejercicios

1. Cambia la estrategia de futuros para que ejecute con órdenes `mercado` (a la apertura de la barra siguiente) en
   lugar de al cierre. Como los futuros del nivel 2 solo tienen cierre, la apertura de la barra siguiente es su cierre:
   ¿a qué equivale en el backtest vectorizado? Compruébalo con `shift(2)`.

**Solución.**

```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"eventos, a mercado: {anual(eq_m.pct_change()):.3f} % anual")
print(f"vectorizado shift(2): {anual(d_v2):.3f} % anual")
print(f"correlación diaria: {d_ev.corr(d_v2.loc['2001':]):.4f}")
```
```output
eventos, a mercado: 15.710 % anual
vectorizado shift(2): 15.682 % anual
correlación diaria: 0.9998
```

La orden enviada al cierre de $t$ se ejecuta al cierre de $t+1$, así que la posición gana a partir de $t+2$: es el
`shift(2)` del vectorizado. No coinciden al céntimo porque la cantidad se calcula con el capital y el precio de $t$ y
se ejecuta a otro precio: el peso real que se obtiene no es exactamente el objetivo. Es la misma lección de la
reconciliación con costes: cada hipótesis cambiada deja una huella que hay que poder explicar.

2. Añade al broker una regla de **volumen máximo**: ninguna orden puede ejecutar más del 10 % del volumen de la barra;
   el resto se queda pendiente para la barra siguiente (un fill parcial). Escribe un test con una barra de 1.000
   acciones y una orden de 300.

**Solución.**

```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("fill parcial: 3 barras de 100")
```
```output
fill parcial: 3 barras de 100
```

La orden de 300 acciones se ejecuta en tres barras de 100. En un backtest eso cambia el precio medio (cada parte se
ejecuta a un precio distinto) y la exposición (durante dos barras solo hay una parte de la posición). La lección 2.4.8
desarrolla el modelo con la cola del libro.

3. En el bucle, el broker ejecuta las órdenes vivas antes de que la estrategia vea la barra. Explica qué pasaría con el
   trailing stop de KAPA si el orden fuera el contrario (estrategia primero, fills después) en una sesión en la que el
   stop salta en la última barra y además hay un cruce al alza.

**Solución.**

La estrategia vería la barra creyendo seguir en posición: no podría volver a entrar con el cruce de ese día (perdería
la entrada que el bucle de 2.4.1 sí hace) y, si el cierre marcara un máximo nuevo, enviaría un stop nuevo para vender
una posición que ya no existe; cuando ese stop se disparara, abriría un corto que la regla nunca pidió. Es un error que
no rompe nada a la vista: el backtest sigue y da un número. La única forma de verlo es reconciliar con otra
implementación o tener un test para ese caso.

## Autoevaluación

1. Sin costes, el motor por eventos y el backtest vectorizado de la cartera de futuros terminan con el mismo capital y una diferencia relativa máxima de 3,5e-14. ¿Qué demuestra eso?

   - A. Que el motor por eventos no aporta nada
   - B. Que los dos implementan las mismas hipótesis (pesos decididos al cierre, ejecución a ese cierre, rebalanceo diario), y que la diferencia que aparezca después se deberá a una hipótesis distinta, no a un error
   - C. Que el resultado de la estrategia es fiable en mercados reales
   - D. Que el vectorizado es más preciso

   *Respuesta B: Que los dos implementan las mismas hipótesis (pesos decididos al cierre, ejecución a ese cierre, rebalanceo diario), y que la diferencia que aparezca después se deberá a una hipótesis distinta, no a un error.* Una reconciliación exacta es la prueba de que los dos motores hacen lo mismo con las mismas reglas. A partir de ahí, cada hipótesis que se cambia (costes con deriva, rolls reales, contratos enteros) produce una diferencia que se puede medir y atribuir. Sobre la estrategia no dice nada: los datos son sintéticos.

2. Con costes, el vectorizado con rotación `|w_t − w_(t−1)|` rinde unos 20 puntos básicos al año más que el motor por eventos. ¿De dónde salen?

   - A. De un error de redondeo del motor
   - B. De que entre dos cierres el peso real se desplaza con el precio: lo que hay que negociar para volver al objetivo no es la diferencia de pesos, sino la diferencia entre el objetivo y el peso desplazado
   - C. De que el motor cobra dos veces la comisión
   - D. De las órdenes stop

   *Respuesta B: De que entre dos cierres el peso real se desplaza con el precio: lo que hay que negociar para volver al objetivo no es la diferencia de pesos, sino la diferencia entre el objetivo y el peso desplazado.* Si un mercado sube un 2 % y el resto de la cartera no, su peso sobre el capital ha cambiado aunque nadie haya negociado. El motor, que negocia cantidades, paga por llevarlo de vuelta al objetivo. Con la rotación corregida por la deriva, el vectorizado queda a una fracción de punto básico del motor.

3. En el día de roll, ¿qué hace la cartera del motor con un futuro?

   - A. Nada: el precio ajustado ya incluye el roll
   - B. Vende el contrato que vence a su cierre y compra el siguiente al cierre de ese otro contrato, con su propio precio y, si se pide, en número entero de contratos
   - C. Compra el siguiente al día siguiente
   - D. Cierra la posición y espera una nueva señal

   *Respuesta B: Vende el contrato que vence a su cierre y compra el siguiente al cierre de ese otro contrato, con su propio precio y, si se pide, en número entero de contratos.* El `MarketEvent` de ese día trae los dos contratos con su precio. La cartera envía una orden que cierra el viejo y otra que abre el nuevo con la cantidad objetivo calculada con el precio del nuevo. Cada orden paga su coste por contrato. Es lo que el precio ajustado esconde.

4. Con contratos enteros, la cartera rinde un 11,47 % anual con 10 millones de USD y un 11,60 % con 1 millón, frente al 11,62 % del vectorizado; el error de seguimiento pasa del 0,49 % al 1,09 %. ¿Qué indica eso?

   - A. Que con poco capital los contratos enteros mejoran la estrategia
   - B. Que el redondeo a contratos enteros es ruido más que un coste fijo: casi no mueve la rentabilidad, pero separa la cartera real de la planeada, y más cuanto menor es el capital
   - C. Que el motor por eventos tiene un error con 1 millón
   - D. Que el error de seguimiento no importa si la rentabilidad coincide

   *Respuesta B: Que el redondeo a contratos enteros es ruido más que un coste fijo: casi no mueve la rentabilidad, pero separa la cartera real de la planeada, y más cuanto menor es el capital.* Separar las hipótesis de una en una es lo que permite atribuirlas. Con 10 millones, redondear a contratos enteros no cambia nada medible respecto a los contratos fraccionarios (11,47 % y 0,48 % de seguimiento). Con 1 millón, la rentabilidad de 25 años casi coincide, pero cada día la cartera real se aparta más de la de pesos: en la lección 2.4.1, con ese capital, un 4,2 % de las posiciones se quedaba a cero contratos.

5. ¿Por qué el motor procesa los fills de las órdenes vivas antes de que la estrategia vea la barra?

   - A. Por rendimiento
   - B. Porque un stop puede ejecutarse dentro de esa misma barra: si la estrategia decidiera al cierre sin saberlo, creería seguir en posición, subiría un stop que ya no existe y no podría volver a entrar ese día
   - C. Porque así lo exige pandas
   - D. Da igual el orden

   *Respuesta B: Porque un stop puede ejecutarse dentro de esa misma barra: si la estrategia decidiera al cierre sin saberlo, creería seguir en posición, subiría un stop que ya no existe y no podría volver a entrar ese día.* El orden de los eventos dentro de una barra es parte de las reglas. En el motor de la lección, primero se marcan los precios, después el broker ejecuta lo que estaba pendiente y solo entonces decide la estrategia. Con ese orden, el trailing stop por eventos reproduce exactamente el bucle de la lección 2.4.1.

## Ideas clave

- Un motor por eventos es un bucle sobre una cola: `MarketEvent` a la estrategia, `SignalEvent` a la cartera, `OrderEvent` al broker y `FillEvent` de vuelta a la cartera y a la estrategia.
- El broker simulado concentra las reglas de ejecución: al cierre, a la apertura siguiente, limitadas al límite o mejor y stops que, con hueco, se ejecutan a la apertura.
- Con las mismas hipótesis, el motor y el vectorizado coinciden al céntimo (3,5e-14 sin costes); cada hipótesis distinta deja una diferencia que se puede medir: unos 20 pb al año por la deriva de los pesos y 0,15 puntos con contratos reales y rolls.
- El motor modela lo que el precio ajustado esconde: dos precios el día del roll, coste fijo por contrato y contratos enteros, que con 10 millones no cambian nada medible y con 1 millón duplican el error de seguimiento (1,09 %).
- Las reglas con memoria son naturales en un motor por eventos: el trailing stop de KAPA reproduce el bucle de 2.4.1 y, subiendo el stop con cada barra de 5 minutos, cambia el resultado de 1,535 a 1,724.
- Velocidad a cambio de fidelidad: la cartera de futuros tarda segundos por eventos frente a milisegundos vectorizada; por eso se explora vectorizado y se valida por eventos.

## Recursos

### Research

- [Máquinas de Estado Finito para Ejecución de Trading Cuantitativo en Python](https://pythonparatrading.com/maquinas-estado-finito-trading-python/) · Research-0029 · 49 min

### Documentación

- [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

### Libros

- [Ernest P. Chan: Quantitative Trading, 2.ª edición (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

### Datos

- [Datos del nivel 2: futuros continuos sintéticos (Parquet)](https://pythonparatrading.com/curso/datos/nivel-2/futuros2_continuos.parquet) · Descarga · futuros2_continuos.parquet
- [Datos del nivel 2: contrato siguiente y carry de cada futuro (Parquet)](https://pythonparatrading.com/curso/datos/nivel-2/futuros2_carry.parquet) · Descarga · futuros2_carry.parquet
- [Datos del nivel 2: barras de 5 minutos de KAPA (Parquet)](https://pythonparatrading.com/curso/datos/nivel-2/indice2_5min.parquet) · Descarga · indice2_5min.parquet

Lección anterior: [2.4.1 · Backtest vectorizado desde cero: matrices, desfase, costes y límites](https://pythonparatrading.com/curso-python-trading/nivel-2/backtest-vectorizado-desde-cero/)

Lección siguiente: [2.4.3 · vectorbt: barridos masivos de parámetros y simulación de carteras](https://pythonparatrading.com/curso-python-trading/nivel-2/vectorbt-barridos-de-parametros/)
