Lección 2.4.2 · 75 min
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.
- Módulo
- 2.4 · Backtesting: motores y paradigmas
- Duración
- 75 min
Lecciones del nivel 2
2.1 · Estadística y econometría para trading
- 2.1.1 Contrastes de hipótesis, p-valores, bootstrap y regresión con errores robustos
- 2.1.2 Estacionariedad: ADF, KPSS, diferenciación y diferenciación fraccional
- 2.1.3 Modelos ARMA y ARIMA: autocorrelación simple (ACF) y parcial (PACF)
- 2.1.4 Modelos de volatilidad: ARCH, GARCH, EGARCH y GJR con la librería arch
- 2.1.5 Cointegración: Engle-Granger, Johansen y vida media de la reversión
- 2.1.6 Filtro de Kalman para hedge ratios dinámicos
- 2.1.7 Detección de regímenes: modelos de Markov ocultos y cambios estructurales
- 2.1.8 El problema de las pruebas múltiples: Bonferroni, FDR y el «factor zoo»
2.2 · Anatomía de un sistema de trading
- 2.2.1 Hipótesis de ventaja: riesgo, comportamiento, estructura e información
- 2.2.2 Universo, señal, filtro, entrada, salida, sizing y gestión del riesgo
- 2.2.3 Frecuencias: intradía, swing y posicional, y la capacidad de una estrategia
- 2.2.4 Diario de investigación y registro de experimentos contra el data snooping
2.3 · Familias de estrategias
- 2.3.1 Seguimiento de tendencia y momentum
- 2.3.2 Reversión a la media: RSI y Bollinger, reversión intradía y huecos
- 2.3.3 Pairs trading y arbitraje estadístico: distancia, cointegración y Kalman
- 2.3.4 Carry en divisas, futuros y bonos; estacionalidad y efectos calendario
- 2.3.5 Factores (value, calidad, baja volatilidad, tamaño) y estrategias de eventos
- 2.3.6 Introducción al market making: inventario, spread y selección adversa (Avellaneda-Stoikov)
- 2.3.7 Estrategias específicas de cripto: momentum, arbitraje de funding y basis
2.4 · Backtesting: motores y paradigmas
- 2.4.1 Backtest vectorizado desde cero: matrices, desfase, costes y límites
- 2.4.2 Backtest por eventos desde cero: bucle, cartera, órdenes y fills
- 2.4.3 vectorbt: barridos masivos de parámetros y simulación de carteras
- 2.4.4 backtesting.py y backtrader: dos motores didácticos y su estado
- 2.4.5 NautilusTrader: arquitectura, datos, estrategias y el mismo código en backtest y en real
- 2.4.6 LEAN/QuantConnect y Zipline Reloaded; bt para asignación de activos
- 2.4.7 Modelado de costes: comisiones, horquilla, impacto, financiación y préstamo de títulos
- 2.4.8 Modelado de fills: órdenes limitadas, cola, fills parciales, huecos y stops
2.5 · Sesgos y validación
- 2.5.1 Sesgos de backtest: anticipación, supervivencia, selección y narrativa
- 2.5.2 In-sample y out-of-sample: walk-forward anclado y móvil
- 2.5.3 Validación cruzada combinatoria purgada para estrategias
- 2.5.4 Deflated Sharpe Ratio y Probability of Backtest Overfitting (PBO)
- 2.5.5 Monte Carlo: remuestreo de trades, bootstrap de bloques y permutaciones de señales
- 2.5.6 Sensibilidad de parámetros: mesetas frente a picos, robustez entre activos y periodos
- 2.5.7 Datos sintéticos para pruebas de estrés: GBM, GARCH y bootstrapping
- 2.5.8 Checklist de «backtest honesto» antes de pasar a paper trading
2.6 · Métricas de rendimiento
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:
Fuente 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.
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: intEl 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.
Fuente Mermaid
stateDiagram-v2 [*] --> Pendiente: OrderEvent [*] --> Ejecutada: al cierre Pendiente --> Ejecutada: la barra la cruza Pendiente --> Cancelada: reemplazo Ejecutada --> [*] Cancelada --> [*]
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.
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.
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:
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")5 tests superadosReconciliació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.
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){'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:
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}")diferencia máxima: 3.5e-14
capital final: eventos 31.561693, vectorizado 31.561693Idénticos: la mayor diferencia relativa en 26 años es del orden de , el redondeo de la coma flotante (lección 0.3.1). No es casualidad. El motor compra en cada cierre unidades, y al cierre siguiente gana : 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: . La rotación de la lección 2.4.1, , ignora esa deriva.
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} %")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.
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")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 USDEl 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:
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}")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.997La 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.
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.
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.
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)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:
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
- 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 conshift(2).
Solución
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}")eventos, a mercado: 15.710 % anual
vectorizado shift(2): 15.682 % anual
correlación diaria: 0.9998La orden enviada al cierre de se ejecuta al cierre de , así que la posición gana a partir de : es el
shift(2) del vectorizado. No coinciden al céntimo porque la cantidad se calcula con el capital y el precio de 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.
- 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
@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")fill parcial: 3 barras de 100La 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.
- 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.
Respuesta plegada en cada pregunta
Autoevaluación
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?
- AQue el motor por eventos no aporta nada
- BQue 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
- CQue el resultado de la estrategia es fiable en mercados reales
- DQue el vectorizado es más preciso
Ver respuesta
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.
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?- ADe un error de redondeo del motor
- BDe 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
- CDe que el motor cobra dos veces la comisión
- DDe las órdenes stop
Ver respuesta
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.
En el día de roll, ¿qué hace la cartera del motor con un futuro?
- ANada: el precio ajustado ya incluye el roll
- BVende 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
- CCompra el siguiente al día siguiente
- DCierra la posición y espera una nueva señal
Ver respuesta
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
MarketEventde 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.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?
- AQue con poco capital los contratos enteros mejoran la estrategia
- BQue 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
- CQue el motor por eventos tiene un error con 1 millón
- DQue el error de seguimiento no importa si la rentabilidad coincide
Ver respuesta
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.
¿Por qué el motor procesa los fills de las órdenes vivas antes de que la estrategia vea la barra?
- APor rendimiento
- BPorque 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
- CPorque así lo exige pandas
- DDa igual el orden
Ver respuesta
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:
MarketEventa la estrategia,SignalEventa la cartera,OrderEvental broker yFillEventde 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.