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.

Fig. — Cartera de 24 futuros sintéticos: diferencia de rentabilidad anual entre el backtest vectorizado y el motor por eventos según las hipótesis de cada comparación
Lecciones del nivel 2
  1. 2.1 · Estadística y econometría para trading

    1. 2.1.1 Contrastes de hipótesis, p-valores, bootstrap y regresión con errores robustos
    2. 2.1.2 Estacionariedad: ADF, KPSS, diferenciación y diferenciación fraccional
    3. 2.1.3 Modelos ARMA y ARIMA: autocorrelación simple (ACF) y parcial (PACF)
    4. 2.1.4 Modelos de volatilidad: ARCH, GARCH, EGARCH y GJR con la librería arch
    5. 2.1.5 Cointegración: Engle-Granger, Johansen y vida media de la reversión
    6. 2.1.6 Filtro de Kalman para hedge ratios dinámicos
    7. 2.1.7 Detección de regímenes: modelos de Markov ocultos y cambios estructurales
    8. 2.1.8 El problema de las pruebas múltiples: Bonferroni, FDR y el «factor zoo»
  2. 2.2 · Anatomía de un sistema de trading

    1. 2.2.1 Hipótesis de ventaja: riesgo, comportamiento, estructura e información
    2. 2.2.2 Universo, señal, filtro, entrada, salida, sizing y gestión del riesgo
    3. 2.2.3 Frecuencias: intradía, swing y posicional, y la capacidad de una estrategia
    4. 2.2.4 Diario de investigación y registro de experimentos contra el data snooping
  3. 2.3 · Familias de estrategias

    1. 2.3.1 Seguimiento de tendencia y momentum
    2. 2.3.2 Reversión a la media: RSI y Bollinger, reversión intradía y huecos
    3. 2.3.3 Pairs trading y arbitraje estadístico: distancia, cointegración y Kalman
    4. 2.3.4 Carry en divisas, futuros y bonos; estacionalidad y efectos calendario
    5. 2.3.5 Factores (value, calidad, baja volatilidad, tamaño) y estrategias de eventos
    6. 2.3.6 Introducción al market making: inventario, spread y selección adversa (Avellaneda-Stoikov)
    7. 2.3.7 Estrategias específicas de cripto: momentum, arbitraje de funding y basis
  4. 2.4 · Backtesting: motores y paradigmas

    1. 2.4.1 Backtest vectorizado desde cero: matrices, desfase, costes y límites
    2. 2.4.2 Backtest por eventos desde cero: bucle, cartera, órdenes y fills
    3. 2.4.3 vectorbt: barridos masivos de parámetros y simulación de carteras
    4. 2.4.4 backtesting.py y backtrader: dos motores didácticos y su estado
    5. 2.4.5 NautilusTrader: arquitectura, datos, estrategias y el mismo código en backtest y en real
    6. 2.4.6 LEAN/QuantConnect y Zipline Reloaded; bt para asignación de activos
    7. 2.4.7 Modelado de costes: comisiones, horquilla, impacto, financiación y préstamo de títulos
    8. 2.4.8 Modelado de fills: órdenes limitadas, cola, fills parciales, huecos y stops
  5. 2.5 · Sesgos y validación

    1. 2.5.1 Sesgos de backtest: anticipación, supervivencia, selección y narrativa
    2. 2.5.2 In-sample y out-of-sample: walk-forward anclado y móvil
    3. 2.5.3 Validación cruzada combinatoria purgada para estrategias
    4. 2.5.4 Deflated Sharpe Ratio y Probability of Backtest Overfitting (PBO)
    5. 2.5.5 Monte Carlo: remuestreo de trades, bootstrap de bloques y permutaciones de señales
    6. 2.5.6 Sensibilidad de parámetros: mesetas frente a picos, robustez entre activos y periodos
    7. 2.5.7 Datos sintéticos para pruebas de estrés: GBM, GARCH y bootstrapping
    8. 2.5.8 Checklist de «backtest honesto» antes de pasar a paper trading
  6. 2.6 · Métricas de rendimiento

    1. 2.6.1 CAGR, Sharpe, Sortino, Calmar, Omega, drawdown y Ulcer Index
    2. 2.6.2 Win rate, payoff, expectativa, alpha, beta, information ratio, turnover y capacidad
    3. 2.6.3 Informes con QuantStats o pyfolio-reloaded y tearsheets propios

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:

DatosMarketEventEstrategiaSignalEventCarteraOrderEventBrokerFillEvent
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
EventoLo emiteLo recibeQué dice
MarketEventLos datosBroker, cartera, estrategiaHa llegado una barra: precios de cada instrumento
SignalEventLa estrategiaLa carteraQuiero este peso en este mercado
OrderEventLa cartera o la estrategiaEl brokerCompra o vende esta cantidad, de este tipo, a este precio
FillEventEl brokerLa cartera y la estrategiaSe 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.

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

import numpy as np
import pandas as pd

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

@dataclass(frozen=True, slots=True)
class MarketEvent:
    fecha: pd.Timestamp
    barras: dict           # 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.
OrderEvental cierrela barra la cruzareemplazoPendienteEjecutadaCancelada
Fuente Mermaid
stateDiagram-v2
  [*] --> Pendiente: OrderEvent
  [*] --> Ejecutada: al cierre
  Pendiente --> Ejecutada: la barra la cruza
  Pendiente --> Cancelada: reemplazo
  Ejecutada --> [*]
  Cancelada --> [*]
IN [02]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.

IN [03]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.

IN [04]python
def ejecutar(eventos, estrategia, cartera, broker):
    cola = deque()
    cuenta = dict.fromkeys(["MarketEvent", "SignalEvent",
                            "OrderEvent", "FillEvent"], 0)
    for barra in eventos:
        cartera.al_recibir_barra(barra)            # 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:

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

def test_stop_con_hueco():
    b = Broker(lambda *a: 0.0)
    b.barra = barra(100, 101, 99, 100)
    b.al_recibir_orden(OrderEvent(None, "X", -1, "stop", 95.0))
    (f,) = b.al_recibir_barra(barra(92, 93, 90, 91))
    assert f.precio == 92                  # 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")
OUT [05]Tests del broker y de la cartera
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.

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

class MomentumVol(Estrategia):
    """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)
OUT [06]Datos y estrategia por eventos
{'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:

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

r = px.pct_change(fill_method=None)
w = pesos_vectorizados(px).fillna(0.0)
eq_vec = (1 + (w.shift(1, fill_value=0.0) * r.fillna(0.0))
          .sum(axis=1)).cumprod()
dif = (eq_ev / eq_vec - 1).abs()
print(f"diferencia máxima: {dif.max():.1e}")
print(f"capital final: eventos {eq_ev.iloc[-1]:.6f}, "
      f"vectorizado {eq_vec.iloc[-1]:.6f}")
OUT [07]La misma cartera, vectorizada
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−1410^{-14}, el redondeo de la coma flotante (lección 0.3.1). No es casualidad. El motor compra en cada cierre qi,t=wi,tEt/Pi,tq_{i,t} = w_{i,t} E_t / P_{i,t} unidades, y al cierre siguiente gana qi,t(Pi,t+1−Pi,t)=wi,tEt ri,t+1q_{i,t}(P_{i,t+1} - P_{i,t}) = w_{i,t} E_t\, r_{i,t+1}: 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: wi,t (1+ri,t+1)/(1+Rt+1)w_{i,t}\,(1 + r_{i,t+1}) / (1 + R_{t+1}). La rotación de la lección 2.4.1, ∣wt+1−wt∣\lvert w_{t+1} - w_t \rvert, ignora esa deriva.

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

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

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

eq_ev_c, _ = ejecutar(eventos_continuos(px), MomentumVol(mercados),
                      Cartera(1.0, uno), Broker(comision_pct))
pos = w.shift(1, fill_value=0.0)
bruto_d = (pos * r.fillna(0.0)).sum(axis=1)
rot_simple = (w - pos).abs()
deriva = (pos * (1 + r.fillna(0.0))).div(1 + bruto_d, axis=0)
rot_deriva = (w - deriva).abs()
print(f"{'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} %")
OUT [08]Con costes: la rotación que se paga
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.

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

def eventos_contratos(tabla):
    for fecha, g in tabla.groupby("fecha", sort=True):
        barras, negociar, continuas = {}, {}, {}
        for f in g.itertuples():
            barras[f.contrato] = Barra(*[f.cierre] * 4)
            continuas[f.mercado] = f.cierre_ajustado
            negociar[f.mercado] = f.contrato
            if f.ultimo:            # roll: 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")
OUT [09]Contratos reales: rolls y contratos enteros
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:

IN [10]python
contrato = fut.pivot(index="fecha", columns="mercado",
                     values="contrato")
rolls = (contrato != contrato.shift(1)) & contrato.shift(1).notna()
rot_v = rot_simple + 2 * pos.abs().where(rolls, 0.0)
neto_v = bruto_d - (rot_v * coste_unit).sum(axis=1)
d_v = neto_v.loc["2001":]
print(f"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}")
OUT [10]Contratos reales frente al vectorizado
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

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.

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.

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

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

class TrailingStop(Estrategia):
    """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.

IN [12]python
salidas = {}
for intradia in (False, True):
    cartera = Cartera(1.0, {"KAPA": 1.0}, mercado_de=lambda i: i)
    estr = TrailingStop(cartera, intradia=intradia)
    eq, cuenta_k = ejecutar(eventos_5min(b5), estr, cartera,
                            Broker(lambda *a: 0.0))
    nombre = "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)
OUT [12]El trailing stop de la lección 2.4.1, por eventos
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

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)
VelocidadMilisegundos para 24 mercados y 26 añosSegundos para lo mismo; mucho más con barras de 1 minuto
Reglas con memoriaAproximación sin avisoNaturales
Dentro de la barraSupuesto arbitrarioCon datos de más resolución, exacto
Contratos enteros, rolls, capitalAproximaciónExacto según el modelo del broker
Errores típicosshift olvidado, rotación sin derivaOrden de los eventos, estado mal reiniciado
Paso a realHay que reescribirSe 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
IN [13]python
class CarteraMercado(Cartera):
    def al_recibir_senal(self, s):
        ordenes = super().al_recibir_senal(s)
        for o in ordenes:
            o.tipo = "mercado"
        return ordenes

eq_m, _ = ejecutar(eventos_continuos(px), MomentumVol(mercados),
                   CarteraMercado(1.0, uno), Broker(lambda *a: 0.0))
d_ev = eq_m.pct_change().loc["2001":]
d_v2 = (w.shift(2, fill_value=0.0) * r.fillna(0.0)).sum(axis=1)
print(f"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}")
OUT [13]Órdenes a la apertura siguiente
eventos, a mercado: 15.710 % anual
vectorizado shift(2): 15.682 % anual
correlación diaria: 0.9998

La orden enviada al cierre de tt se ejecuta al cierre de t+1t+1, así que la posición gana a partir de t+2t+2: es el shift(2) del vectorizado. No coinciden al céntimo porque la cantidad se calcula con el capital y el precio de tt 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.

  1. 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
IN [14]python
@dataclass(frozen=True, slots=True)
class BarraV(Barra):
    volumen: float = 0.0

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

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

def test_fill_parcial():
    b = BrokerVolumen(lambda *a: 0.0)
    b.barra = barra(10, 10, 10, 10)
    b.al_recibir_orden(OrderEvent(None, "X", 300, "mercado"))
    ev = MarketEvent(pd.Timestamp("2024-01-03"),
                     {"X": BarraV(10, 10, 10, 10, 1000)}, {}, {})
    tamaños = [b.al_recibir_barra(ev)[0].cantidad for _ in range(3)]
    assert tamaños == [100, 100, 100] and not b.pendientes
test_fill_parcial()
print("fill parcial: 3 barras de 100")
OUT [14]Fills parciales por volumen
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.

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

  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?

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

  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?

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

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

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

  5. ¿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: 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

Documentación