Lección 2.4.5 · 70 min
NautilusTrader: arquitectura, datos, estrategias y el mismo código en backtest y en real
NautilusTrader es un motor por eventos pensado para producción: el backtest usa el mismo núcleo, la misma caché y el mismo bus de mensajes que la operativa real. Esta lección lo monta pieza a pieza con las barras de 5 minutos del ETF ficticio KAPA, ejecuta dos estrategias con y sin costes y lee sus informes de órdenes, posiciones y cuenta.
- Módulo
- 2.4 · Backtesting: motores y paradigmas
- Duración
- 70 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
Un motor de producción para hacer backtests
Los motores de las lecciones 2.4.1 y 2.4.2 se escribieron para entender un backtest. NautilusTrader está escrito para operar: su autor, Nautech Systems, lo describe como un motor «nativo en Rust» para sistemas de varios activos y varios mercados, en el que la investigación, la simulación y la operativa real comparten la misma arquitectura por eventos. Python es el plano de control: la estrategia, la configuración y la orquestación se escriben en Python; el emparejamiento de órdenes, la caché, los relojes y el bus de mensajes corren compilados.
Dos datos de contexto que conviene tener claros antes de instalarlo:
- Licencia LGPL-3.0 (en PyPI,
LGPL-3.0-or-later). Se puede usar desde código propio cerrado; si se distribuye una versión modificada de la propia librería, esas modificaciones deben publicarse con la misma licencia. - Dos series a la vez. La última versión estable en PyPI es la 1.231.0 (2 de agosto de 2026), que es la que usa
este curso. En paralelo se publican las candidatas de la 2.0 (2.0.0rc7, 15 de octubre de 2026), cuya nota de la
rc3 dice: «Removed legacy v1 Cython package». La documentación de
nautilustrader.io/docs/latestya describe esa arquitectura 2.0 (Rust con enlaces PyO3) y algunas rutas de importación de sus ejemplos (nautilus_trader.backtesten vez denautilus_trader.backtest.engine) no existen en la 1.231.0. Los conceptos de esta lección son los mismos en las dos; el código es el de la 1.231.0 y se ha ejecutado tal cual.
La capa Cython de la 1.x se ve en el propio paquete instalado:
import importlib.metadata as md
from pathlib import Path
import nautilus_trader
meta = md.metadata("nautilus-trader")
print(meta["Name"], meta["Version"], "·", meta["License"])
raiz = Path(nautilus_trader.__file__).parent
print("módulos Cython (.pyx):", len(list(raiz.rglob("*.pyx"))))
nucleo = next(raiz.glob("core/nautilus_pyo3*.so"))
print("núcleo Rust:", nucleo.name.split(".")[0],
f"({nucleo.stat().st_size / 2**20:.0f} MiB)")nautilus_trader 1.231.0 · LGPL-3.0-or-later
módulos Cython (.pyx): 110
núcleo Rust: nautilus_pyo3 (159 MiB)Ciento diez módulos Cython (las clases Strategy, BacktestEngine, Bar, los indicadores…) y una biblioteca
compilada desde Rust con el núcleo. Para quien escribe estrategias, la diferencia apenas se nota: se importa
nautilus_trader y se hereda de clases Python. Lo que se nota es la velocidad, y el precio es la rigidez: tipos de
valor con precisión fija, configuración inmutable y un ciclo de vida estricto para cada componente.
La arquitectura: un núcleo, un bus y adaptadores
La documentación oficial describe el sistema como un núcleo (NautilusKernel) que crea y conecta los
componentes compartidos, y adaptadores que hablan con el exterior por las fronteras de los motores (el estilo
«puertos y adaptadores»). Ningún componente llama directamente a otro: todo pasa por el bus de mensajes.
Fuente Mermaid
flowchart TB AD[Cliente de datos] --> DE[DataEngine] DE --> CA[Cache] DE --> MB[MessageBus] MB --> ST[Strategy] ST --> RE[RiskEngine] RE --> EE[ExecutionEngine] EE --> AE[Cliente ejecución] AE --> EE EE --> CA EE --> PF[Portfolio] PF --> ST
| Componente | Qué hace |
|---|---|
NautilusKernel | Crea y configura los componentes, elige el reloj (simulado o real) y gestiona el ciclo de vida |
MessageBus | Publicación/suscripción, petición/respuesta y comandos entre componentes |
Cache | Estado en memoria: instrumentos, cuentas, órdenes, posiciones, últimos datos |
DataEngine | Recibe cotizaciones, operaciones, barras y libros; gestiona suscripciones y los reparte |
RiskEngine | Valida cada orden (cantidades, nocionales, saldo, límites de frecuencia) antes de enviarla |
ExecutionEngine | Encamina las órdenes al cliente de su venue y procesa los eventos de vuelta |
Portfolio | Saldos, posiciones netas, margen y resultado realizado y no realizado |
Trader | Registra estrategias y actores y gestiona su ciclo de vida |
Tres ideas de diseño que importan para un backtest. La primera: un solo hilo procesa todos los mensajes del
núcleo, en orden, así que no hay carreras entre la estrategia y la ejecución (la red y el registro van en otros
hilos). La segunda: el entorno cambia solo en los bordes: en un backtest, el adaptador de datos es una lista de
datos históricos y el de ejecución es un mercado simulado (SimulatedExchange); en real, son clientes de un broker.
La tercera: el tiempo es el de los datos. Cada objeto lleva dos marcas en nanosegundos UNIX, ts_event (cuándo
ocurrió) y ts_init (cuándo lo conoció el sistema), y el reloj del backtest avanza con ellas.
Instrumentos y barras en el formato de Nautilus
Antes de añadir datos hay que describir el instrumento: qué se negocia, en qué divisa, con qué tick y con qué
lote. KAPA es un ETF ficticio sobre un índice, con barras de 5 minutos de 2022 a 2025 (indice2_5min.parquet).
Los datos son sintéticos: no son datos de mercado.
import warnings
from decimal import Decimal
import pandas as pd
from pandas.errors import Pandas4Warning
# El motor llama a Timestamp.utcnow(), que pandas 3 marca como
# obsoleto: un aviso por ejecución, fuera de nuestro control
warnings.filterwarnings("ignore", message="Timestamp.utcnow",
category=Pandas4Warning)
from nautilus_trader.model.currencies import USD
from nautilus_trader.model.identifiers import (
InstrumentId, Symbol, Venue)
from nautilus_trader.model.instruments import Equity
from nautilus_trader.model.objects import Money, Price, Quantity
VENUE = Venue("XNYS")
kapa = Equity(
instrument_id=InstrumentId(Symbol("KAPA"), VENUE),
raw_symbol=Symbol("KAPA"),
currency=USD,
price_precision=2,
price_increment=Price.from_str("0.01"),
lot_size=Quantity.from_int(1),
ts_event=0,
ts_init=0,
)
print(kapa.id, kapa.price_increment, kapa.quote_currency)
print(Money(0.005, USD), Money(1.50, USD))KAPA.XNYS 0.01 USD
0.01 USD 1.50 USDLa última línea es la primera trampa. Price, Quantity y Money son tipos de valor con precisión fija, y la
del USD es de dos decimales: una tarifa de 0,005 USD por acción se redondea a 0,01, el doble. Para cobrar la tarifa
por acción del broker ficticio Alcor de la lección 2.4.7 (0,005 USD por acción, mínimo 1 USD por orden) con
órdenes de 300 acciones, lo correcto es una comisión fija de 1,50 USD por orden.
Las barras necesitan un tipo de barra, una cadena que dice de qué instrumento son, de qué periodo, con qué precio
y quién las agrega: KAPA.XNYS-5-MINUTE-LAST-EXTERNAL son barras de 5 minutos del último precio, agregadas fuera
del sistema (por el proveedor). Solo las barras EXTERNAL mueven el libro simulado del venue; las que el sistema
agrega a partir de ticks (INTERNAL) llegan a la estrategia, pero no ejecutan órdenes.
from nautilus_trader.model.data import BarType
from nautilus_trader.persistence.wranglers import BarDataWrangler
barras = pd.read_parquet("indice2_5min.parquet")
df = barras.rename(columns={
"apertura": "open", "maximo": "high", "minimo": "low",
"cierre": "close", "volumen": "volume"})
# ts es el INICIO de la barra; Nautilus la necesita al CIERRE
df.index = df.pop("ts") + pd.Timedelta(minutes=5)
bar_type = BarType.from_str("KAPA.XNYS-5-MINUTE-LAST-EXTERNAL")
bars = BarDataWrangler(bar_type, kapa).process(df)
print(len(bars), "barras")
b = bars[0]
print(b.open, b.high, b.low, b.close, b.volume)
print("ts_event:", pd.Timestamp(b.ts_event, tz="UTC"))
print("ts_init: ", pd.Timestamp(b.ts_init, tz="UTC"))77910 barras
251.30 252.14 251.30 252.11 1416300
ts_event: 2022-01-03 14:35:00+00:00
ts_init: 2022-01-03 14:35:00+00:00La primera barra de la serie es la de 09:30 a 09:35 de Nueva York del 3 de enero de 2022 (14:30 UTC en invierno), y aquí queda marcada a las 14:35: el instante en que el máximo, el mínimo y el cierre ya se conocen. Si se pasara con la marca del inicio, el motor la consideraría conocida a las 14:30, y una estrategia que decidiera con un reloj a las 14:32 vería un cierre que todavía no existía. Es el mismo sesgo de anticipación de la lección 1.7.4, ahora dentro de un motor por eventos: el motor no puede saber cómo marcó las barras el proveedor; hay que decírselo.
La figura muestra qué hace el venue simulado con cada barra. Como una barra no dice en qué orden se tocaron el máximo
y el mínimo, el motor la convierte en cuatro actualizaciones sintéticas del libro L1 (apertura, máximo, mínimo y
cierre, con un cuarto del volumen cada una) y casa las órdenes que ya esperaban después de cada una. Solo entonces
entrega la barra completa a la estrategia. Una orden enviada desde on_bar llega con el libro en el cierre de esa
barra y, sin modelo de latencia, se ejecuta a ese precio. En KAPA la apertura de cada barra es el cierre de la
anterior (salvo la primera de la sesión), así que ejecutar al cierre visto equivale a ejecutar a la apertura
siguiente; con datos que no cumplan eso, la diferencia es un sesgo optimista que conviene medir. La opción
bar_adaptive_high_low_ordering=True del venue recorre primero el extremo más cercano a la apertura; importa cuando
un stop y un objetivo caen dentro de la misma barra (lección 2.4.8).
Una estrategia es una clase
Una estrategia de Nautilus es una subclase de Strategy con una configuración aparte, una subclase de
StrategyConfig (inmutable: frozen=True). La estrategia no tiene bucle: el sistema llama a sus métodos cuando
ocurre algo. Los más usados son on_start (suscribirse a datos y registrar indicadores), on_bar, on_quote_tick
y on_trade_tick (datos), on_order_filled y on_position_closed (eventos de ejecución) y on_stop.
La primera estrategia es un cruce de medias exponenciales de 12 y 48 barras (una hora y cuatro horas) solo largo: compra
300 acciones cuando la rápida cruza por encima de la lenta y cierra cuando cruza por debajo. En estos datos es un
control: según el README del nivel 2, fuera de la primera media hora los rendimientos de 5 minutos de KAPA no
tienen autocorrelación, así que ninguna regla de tendencia intradía tiene ventaja.
from nautilus_trader.config import StrategyConfig
from nautilus_trader.indicators import ExponentialMovingAverage
from nautilus_trader.model.enums import OrderSide
from nautilus_trader.trading.strategy import Strategy
class CruceConfig(StrategyConfig, frozen=True):
instrument_id: InstrumentId
bar_type: BarType
rapida: int = 12
lenta: int = 48
acciones: int = 300
class Cruce(Strategy):
def __init__(self, config: CruceConfig) -> None:
super().__init__(config)
self.rapida = ExponentialMovingAverage(config.rapida)
self.lenta = ExponentialMovingAverage(config.lenta)
def on_start(self) -> None:
bt = self.config.bar_type
# el sistema actualiza los indicadores antes de on_bar
self.register_indicator_for_bars(bt, self.rapida)
self.register_indicator_for_bars(bt, self.lenta)
self.subscribe_bars(bt)
def on_bar(self, bar) -> None:
if not self.indicators_initialized():
return
iid = self.config.instrument_id
largo = self.portfolio.is_net_long(iid)
if self.rapida.value > self.lenta.value and not largo:
orden = self.order_factory.market(
iid, OrderSide.BUY,
Quantity.from_int(self.config.acciones))
self.submit_order(orden)
elif self.rapida.value < self.lenta.value and largo:
self.close_all_positions(iid)
def on_stop(self) -> None:
self.close_all_positions(self.config.instrument_id)Tres detalles. register_indicator_for_bars hace que el motor de datos actualice los indicadores con cada barra
antes de llamar a on_bar, así que dentro de on_bar las medias ya incluyen la barra que acaba de cerrar, y
nada más. La estrategia no guarda su posición en una variable: la pregunta al Portfolio, que es la fuente de
verdad tanto en el backtest como en real. Y la orden no se ejecuta en submit_order: es un comando que viaja por el
bus hasta el motor de riesgo y el de ejecución, y lo que vuelve son eventos.
El motor de backtest: venue, costes y ejecución
BacktestEngine es la interfaz de bajo nivel: se crea el motor, se añade un venue simulado con su cuenta y sus
modelos, el instrumento, los datos y la estrategia, y se ejecuta. (La de alto nivel, BacktestNode, hace lo mismo
a partir de objetos de configuración y de un catálogo Parquet, y sirve para lanzar muchas ejecuciones.) La función
de abajo monta un motor nuevo en cada llamada, con o sin costes:
- Comisión:
FixedFeeModelde 1,50 USD por orden, la deAlcorpara 300 acciones. - Deslizamiento:
FillModel(prob_slippage=1.0): cada ejecución sale un tick (0,01 USD) peor. Con libro L1, según la documentación, la probabilidad se evalúa en cada ejecución y el desplazamiento es siempre de un tick.random_seedfija la secuencia aleatoria aunque con probabilidad 1 no haga falta. - Cuenta de margen con apalancamiento 1, para poder vender en corto en la segunda estrategia.
from nautilus_trader.backtest.engine import BacktestEngine
from nautilus_trader.backtest.models import (
FillModel, FixedFeeModel)
from nautilus_trader.config import (
BacktestEngineConfig, LoggingConfig)
from nautilus_trader.model.enums import AccountType, OmsType
def backtest(estrategia, costes=True):
motor = BacktestEngine(BacktestEngineConfig(
logging=LoggingConfig(log_level="ERROR")))
modelos = {}
if costes:
modelos = dict(
fill_model=FillModel(prob_slippage=1.0, random_seed=7),
fee_model=FixedFeeModel(Money(1.50, USD)))
motor.add_venue(
venue=VENUE,
oms_type=OmsType.NETTING, # una posición neta
account_type=AccountType.MARGIN,
base_currency=USD,
starting_balances=[Money(100_000, USD)],
default_leverage=Decimal(1),
**modelos)
motor.add_instrument(kapa)
motor.add_data(bars)
motor.add_strategy(estrategia)
motor.run()
return motor
cfg = CruceConfig(instrument_id=kapa.id, bar_type=bar_type)
bruto = backtest(Cruce(cfg), costes=False)
fills = bruto.trader.generate_order_fills_report()
fills[["side", "quantity", "avg_px", "ts_last"]].head(4) side quantity avg_px ts_last
client_order_id
O-20220103-194000-001-000-1 BUY 300 252.57 2022-01-03 19:40:00+00:00
O-20220103-195500-001-000-2 SELL 300 252.31 2022-01-03 19:55:00+00:00
O-20220103-200000-001-000-3 BUY 300 252.44 2022-01-03 20:00:00+00:00
O-20220103-201000-001-000-4 SELL 300 252.34 2022-01-03 20:10:00+00:00El informe de órdenes ejecutadas tiene una fila por orden, con el lado, la cantidad, el precio medio de ejecución, las
comisiones y las marcas de tiempo. La primera compra llega a las 19:40 UTC del primer día, cuando la media de 48
barras ya tiene datos suficientes y la rápida cruza por encima. El índice (client_order_id) lo genera el motor con
la fecha simulada.
Cada orden es una máquina de estados. Los eventos de la primera orden lo muestran:
orden = bruto.cache.orders()[0]
for evento in orden.events:
print(f"{type(evento).__name__:16}",
pd.Timestamp(evento.ts_event, tz="UTC"))
print("estado final:", orden.status_string())OrderInitialized 2022-01-03 19:40:00+00:00
OrderSubmitted 2022-01-03 19:40:00+00:00
OrderFilled 2022-01-03 19:40:00+00:00
estado final: FILLEDFuente Mermaid
stateDiagram-v2 [*] --> INITIALIZED INITIALIZED --> DENIED INITIALIZED --> SUBMITTED SUBMITTED --> REJECTED SUBMITTED --> ACCEPTED ACCEPTED --> PARTIALLY_FILLED PARTIALLY_FILLED --> FILLED ACCEPTED --> FILLED ACCEPTED --> CANCELED FILLED --> [*]
La orden a mercado del backtest pasa de SUBMITTED a FILLED sin ACCEPTED: el venue simulado no confirma las
órdenes a mercado salvo que se le pida (use_market_order_acks=True en add_venue); las limitadas sí pasan por
ACCEPTED mientras esperan en el libro. DENIED lo pone el motor de riesgo antes de que la orden salga (cantidad
fuera de rango, saldo insuficiente); REJECTED, el venue. En el backtest todo ocurre en el mismo nanosegundo porque no
hay latencia; en real, entre SUBMITTED y ACCEPTED pasan milisegundos y entre ACCEPTED y FILLED puede pasar
cualquier cosa, también varios PARTIALLY_FILLED.
El informe de posiciones resume cada viaje completo, y el de la cuenta, el saldo después de cada movimiento:
def usd(serie):
"""'-78.00 USD' -> -78.0"""
return serie.astype(str).str.split().str[0].astype(float)
pos = bruto.trader.generate_positions_report()
cuenta = bruto.trader.generate_account_report(VENUE)
print("órdenes ejecutadas:", len(fills),
"· posiciones cerradas:", len(pos))
print("ganadoras:", f"{(usd(pos['realized_pnl']) > 0).mean():.1%}")
print("saldo final:", cuenta["total"].iloc[-1])
pos[["entry", "quantity", "avg_px_open", "avg_px_close",
"realized_pnl"]].reset_index(drop=True).head(3)órdenes ejecutadas: 1906 · posiciones cerradas: 953
ganadoras: 28.5%
saldo final: 140812.00
entry quantity avg_px_open avg_px_close realized_pnl
0 BUY 0 252.57 252.31 -78.00 USD
1 BUY 0 252.44 252.34 -30.00 USD
2 BUY 0 253.06 251.52 -462.00 USDCuatro años de barras de 5 minutos dan 953 viajes, menos de un tercio ganadores, y 40.812 USD de beneficio bruto sobre 100.000. Antes de pensar en optimizar nada, dos comparaciones: con costes, y frente a no hacer nada.
Resultados: un control sin ventaja y un efecto plantado
La segunda estrategia usa el único efecto que tienen estas barras. El README del nivel 2 lo documenta: en las seis
primeras barras (09:30-10:00) el rendimiento esperado es −0,10 veces el hueco de apertura, una tendencia pequeña a
cerrarlo. La estrategia Hueco espera al cierre de la primera barra, entra contra el hueco si es de al menos un
0,2 % (vende si la apertura salta hacia arriba, compra si salta hacia abajo) y cierra al cierre de la barra de las
09:55, a las 10:00. Es la misma idea de la lección 2.3.2, ahora dentro del motor.
NY = "America/New_York"
class HuecoConfig(StrategyConfig, frozen=True):
instrument_id: InstrumentId
bar_type: BarType
acciones: int = 300
hueco_min: float = 0.002
class Hueco(Strategy):
def on_start(self) -> None:
self.cierre_previo = None
self.subscribe_bars(self.config.bar_type)
def on_bar(self, bar) -> None:
cierre = pd.Timestamp(bar.ts_event, tz="UTC").tz_convert(NY)
iid = self.config.instrument_id
if cierre.strftime("%H:%M") == "09:35" and self.cierre_previo:
hueco = float(bar.open) / self.cierre_previo - 1
if abs(hueco) >= self.config.hueco_min:
lado = OrderSide.SELL if hueco > 0 else OrderSide.BUY
self.submit_order(self.order_factory.market(
iid, lado,
Quantity.from_int(self.config.acciones)))
elif cierre.strftime("%H:%M") == "10:00":
self.close_all_positions(iid)
self.cierre_previo = float(bar.close)La hora se lee de ts_event en la zona de Nueva York: así el horario de verano no mueve la regla (la primera barra
cierra a las 14:35 UTC en invierno y a las 13:35 en verano). Con las dos estrategias, tres ejecuciones más:
neto = backtest(Cruce(cfg))
hcfg = HuecoConfig(instrument_id=kapa.id, bar_type=bar_type)
hueco_bruto = backtest(Hueco(hcfg), costes=False)
hueco_neto = backtest(Hueco(hcfg))
def resumen(b, n):
pb = b.trader.generate_positions_report()
pn = n.trader.generate_positions_report()
f = n.trader.generate_order_fills_report()
comision = usd(f["commissions"].map(lambda c: c[0])).sum()
bruto_usd = usd(pb["realized_pnl"]).sum()
neto_usd = usd(pn["realized_pnl"]).sum()
return {"viajes": len(pn), "bruto": bruto_usd,
"comisiones": comision,
"deslizamiento": bruto_usd - neto_usd - comision,
"neto": neto_usd}
tabla = pd.DataFrame({"cruce": resumen(bruto, neto),
"hueco": resumen(hueco_bruto, hueco_neto)})
ba = barras["cierre"].iloc[-1] - barras["apertura"].iloc[0]
print(f"comprar y mantener 300 acciones: {300 * ba:,.0f} USD")
tabla.round(0)comprar y mantener 300 acciones: 95,127 USD
cruce hueco
viajes 953.0 623.0
bruto 40812.0 29367.0
comisiones 2859.0 1869.0
deslizamiento 5718.0 3738.0
neto 32235.0 23760.0La tabla y la figura cuentan dos historias distintas.
- El cruce gana 40.812 USD brutos, menos de la mitad que comprar y mantener (95.127): está comprado una parte del tiempo en un activo que en estos datos sube un 126 % en cuatro años, y lo que gana sale de ahí, no de anticipar nada. Sus 1.906 ejecuciones cuestan 2.859 USD de comisiones y 5.718 de deslizamiento: el 21 % del bruto. Neto, 32.235 USD, un tercio de lo que habría dado no hacer nada.
- El hueco gana 29.367 USD brutos estando en el mercado solo 25 minutos en 623 sesiones, a favor o en contra del mercado según el signo del hueco. Los costes se llevan 5.607 USD (el 19 %) y quedan 23.760.
La figura de la cabecera muestra la curva acumulada de cada versión mes a mes. Que la del hueco suba con regularidad no demuestra nada sobre ningún mercado real: el efecto está plantado en los datos con un coeficiente conocido (−0,10), y la estrategia lo recupera. Ese es el uso correcto de estos datos: comprobar que el motor y la estrategia encuentran un efecto que se sabe que existe, y que el control sin efecto (el cruce) no parece bueno cuando se le restan los costes y se compara con la alternativa trivial. Si el cruce hubiera salido muy rentable, lo primero sería buscar el error.
Las cifras de un solo backtest, además, no dicen cuánto de ese resultado es suerte. Eso es el módulo 2.5: separar muestras, contar los intentos y deflactar el Sharpe.
El mismo código, del backtest al real
La promesa principal de NautilusTrader es que Cruce y Hueco no cambian para operar en real. Lo que cambia es el
nodo que las ejecuta y sus adaptadores. Hay tres entornos con el mismo núcleo: backtest (datos históricos y
ejecución simulada), sandbox (datos en tiempo real y ejecución simulada) y live (datos y ejecución reales,
en cuenta de papel o de dinero).
Fuente Mermaid
flowchart TB S[Strategy] --> BE[BacktestEngine] S --> TN[TradingNode] BE --> HD[Datos históricos] BE --> SX[Venue simulado] TN --> DC[Cliente de datos] TN --> EC[Cliente ejecución] DC --> BR[Broker o exchange] EC --> BR
El código de abajo sigue el ejemplo oficial de la versión 1.231.0 para Interactive Brokers (enlace en los recursos):
un TradingNode con un cliente de datos y otro de ejecución que se conectan a TWS o a IB Gateway, y la misma
estrategia Hueco sobre un ETF real. No se ejecuta en el curso: necesita el extra del adaptador
(uv add "nautilus-trader[ib]==1.231.0", que instala la API de IB), una cuenta y TWS abierto.
from nautilus_trader.adapters.interactive_brokers.common import IB
from nautilus_trader.adapters.interactive_brokers.config import (
IBMarketDataTypeEnum,
InteractiveBrokersDataClientConfig,
InteractiveBrokersExecClientConfig,
InteractiveBrokersInstrumentProviderConfig,
SymbologyMethod,
)
from nautilus_trader.adapters.interactive_brokers.factories import (
InteractiveBrokersLiveDataClientFactory,
InteractiveBrokersLiveExecClientFactory,
)
from nautilus_trader.config import (
LiveDataEngineConfig, LoggingConfig, RoutingConfig,
TradingNodeConfig)
from nautilus_trader.live.node import TradingNode
proveedor = InteractiveBrokersInstrumentProviderConfig(
symbology_method=SymbologyMethod.IB_SIMPLIFIED,
load_ids=frozenset(["SPY.ARCA"]),
)
config = TradingNodeConfig(
trader_id="CURSO-001",
logging=LoggingConfig(log_level="INFO"),
data_clients={IB: InteractiveBrokersDataClientConfig(
ibg_host="127.0.0.1", ibg_port=7497, # TWS en papel
ibg_client_id=1,
use_regular_trading_hours=True,
market_data_type=IBMarketDataTypeEnum.REALTIME,
instrument_provider=proveedor)},
exec_clients={IB: InteractiveBrokersExecClientConfig(
ibg_host="127.0.0.1", ibg_port=7497,
ibg_client_id=2, account_id="DU0000000",
instrument_provider=proveedor,
routing=RoutingConfig(default=True))},
# barras agregadas por el sistema marcadas al INICIO, como
# las de IB (así lo comenta el ejemplo oficial)
data_engine=LiveDataEngineConfig(
time_bars_timestamp_on_close=False,
validate_data_sequence=True),
)
node = TradingNode(config=config)
node.trader.add_strategy(Hueco(HuecoConfig(
instrument_id=InstrumentId.from_str("SPY.ARCA"),
bar_type=BarType.from_str("SPY.ARCA-5-MINUTE-LAST-EXTERNAL"))))
node.add_data_client_factory(
IB, InteractiveBrokersLiveDataClientFactory)
node.add_exec_client_factory(
IB, InteractiveBrokersLiveExecClientFactory)
node.build()
try:
node.run() # bloquea hasta Ctrl+C
finally:
node.dispose()Al arrancar, el nodo reconcilia su estado con el broker: alinea las órdenes y las posiciones de su caché con los informes del venue antes de arrancar las estrategias. Es lo que en un backtest no existe y en real es imprescindible: si el proceso se cae con una orden viva, al volver tiene que saber qué pasó.
La propia documentación advierte de que el mismo código no garantiza el mismo resultado. Las diferencias que quedan
entre hueco_neto y una cuenta real:
| En el backtest | En real |
|---|---|
| La orden se ejecuta al cierre de la barra en el mismo nanosegundo | Llega con latencia (red, broker, venue) y el precio ya ha cambiado |
| 300 acciones siempre disponibles al precio de la barra, más un tick | Fills parciales, cola y huecos de liquidez (lección 2.4.8) |
| Barras marcadas al cierre por quien las prepara | Cada proveedor las marca a su manera (IB, al inicio) |
| Un solo proceso, sin caídas | Desconexiones, reconexiones y reconciliación del estado |
| Comisión fija de 1,50 USD | La tarifa real del broker, con mínimos y máximos (lección 2.4.7) |
Para acercar la simulación, el venue admite un LatencyModel y, con datos de libro (L2 o L3) en vez de barras,
casa las órdenes contra la profundidad registrada; con operaciones (trade_execution=True) y queue_position=True
en add_venue, estima además cuánta cantidad tiene delante cada orden limitada en su cola (lección 2.4.8). El paso intermedio razonable antes de operar con dinero es el sandbox: datos reales en directo y
ejecución simulada, para comprobar que la estrategia recibe lo que espera y en el orden que espera.
Ejercicios
- Añade al venue de
backtestuna latencia de un segundo (LatencyModel(base_latency_nanos=1_000_000_000)denautilus_trader.backtest.models) y vuelve a ejecutarHuecocon costes. ¿Cuánto cambia el neto y por qué? (Pista: con solo barras, la orden retrasada se ejecuta con el libro de la barra siguiente.)
Solución
from nautilus_trader.backtest.models import LatencyModel
def backtest_lento(estrategia):
motor = BacktestEngine(BacktestEngineConfig(
logging=LoggingConfig(log_level="ERROR")))
motor.add_venue(
venue=VENUE, oms_type=OmsType.NETTING,
account_type=AccountType.MARGIN, base_currency=USD,
starting_balances=[Money(100_000, USD)],
default_leverage=Decimal(1),
fill_model=FillModel(prob_slippage=1.0, random_seed=7),
fee_model=FixedFeeModel(Money(1.50, USD)),
latency_model=LatencyModel(
base_latency_nanos=1_000_000_000))
motor.add_instrument(kapa)
motor.add_data(bars)
motor.add_strategy(estrategia)
motor.run()
return motor
lento = backtest_lento(Hueco(hcfg))
for nombre, m in [("sin latencia", hueco_neto), ("con 1 s", lento)]:
f = m.trader.generate_order_fills_report()
p = m.trader.generate_positions_report()
horas = f["ts_last"].dt.strftime("%H:%M").iloc[:2].tolist()
print(f"{nombre:13} primeras ejecuciones {horas}",
f"· neto {usd(p['realized_pnl']).sum():,.0f}")sin latencia primeras ejecuciones ['14:35', '15:00'] · neto 23,760
con 1 s primeras ejecuciones ['14:40', '15:05'] · neto 23,622Con un segundo de latencia, la entrada enviada al cierre de la barra de las 09:30 ya no encuentra ese libro: se ejecuta con el de la barra siguiente, a las 09:40 de Nueva York (14:40 UTC en enero), y la salida a las 10:05 en vez de a las 10:00. La estrategia pierde la primera de las cinco barras con efecto que le quedaban y añade una sin él: en esperanza, una quinta parte de lo que captura. En esta muestra el neto apenas cambia (138 USD menos), porque la barra perdida y la añadida tienen un ruido del mismo orden que ese efecto, y una sola ejecución no separa lo uno de lo otro (para eso está el remuestreo de la lección 2.5.5). Lo que sí enseña la ejecución es el mecanismo: con barras de 5 minutos, cualquier retraso se convierte en una barra entera.
- El cruce de medias es un control sin ventaja en estos datos. Ejecútalo con medias de 6 y 24 barras, con costes, y compara el número de viajes y el neto con los de 12 y 48. ¿Qué te dice sobre «optimizar» los periodos?
Solución
rapido = backtest(Cruce(CruceConfig(
instrument_id=kapa.id, bar_type=bar_type, rapida=6, lenta=24)))
pr = rapido.trader.generate_positions_report()
print("viajes:", len(pr), "· neto:", usd(pr["realized_pnl"]).sum())viajes: 1925 · neto: 24942.0Con medias más rápidas hay el doble de viajes (1.925 frente a 953) y el doble de costes: el neto cae de 32.235 a 24.942 USD. Sin ninguna ventaja de fondo, cambiar los periodos solo mueve dos cosas: cuánto tiempo está comprado el sistema (y por tanto cuánto de la deriva captura) y cuánto paga en costes. Buscar el mejor par de periodos sobre estos datos sería elegir el ruido más favorable, que es exactamente el sobreajuste que miden la lección 2.5.4 y el registro de experimentos de la 2.2.4.
- Un compañero lleva
Huecoa real con barras de 5 minutos de Interactive Brokers y compara sus operaciones con las del backtest. Las horas de entrada no coinciden: en real la estrategia entra a las 09:40. ¿Qué ha pasado y cómo se arregla?
Solución
La estrategia compara la hora de ts_event con «09:35» porque en el backtest las barras se marcaron al cierre. Las
barras de IB llevan la hora de inicio (lo advierte el comentario del ejemplo oficial de la configuración): la barra de
09:30 a 09:35 tiene ts_event 09:30, y la que lleva «09:35» es la de 09:35 a 09:40, que solo existe a las 09:40. La
estrategia hace lo que le dicen, con una barra de retraso. Hay dos arreglos coherentes: normalizar las marcas en la
entrada de datos (sumar la duración de la barra, como se hizo aquí con indice2_5min.parquet) o escribir la regla
sobre la hora de cierre calculada (ts_event más el periodo del bar_type) y comprobarla con las primeras barras que
lleguen. La lección general: la convención de marcas de tiempo es parte de la especificación de los datos, y tiene que
ser la misma en el backtest y en real.
Respuesta plegada en cada pregunta
Autoevaluación
Las barras de
KAPAvienen marcadas con la hora de inicio (09:30 para la barra 09:30-09:35). ¿Qué hay que hacer antes de pasarlas a Nautilus?- ANada: Nautilus las reinterpreta solo
- BSumar 5 minutos para que la marca sea el cierre de la barra, el instante en que la barra completa existe
- CRestar 5 minutos
- DPasarlas a la zona horaria de Nueva York
Ver respuesta
Respuesta B. Sumar 5 minutos para que la marca sea el cierre de la barra, el instante en que la barra completa existe
El motor considera conocida una barra en su marca de tiempo. Con la marca al inicio, la barra entera, máximo y cierre incluidos, sería visible cinco minutos antes de haberse formado. La documentación de Nautilus pide que
ts_initsea el cierre del intervalo.En el venue simulado,
FillModel(prob_slippage=1.0)con barras (libro L1) significa que…- ACada ejecución sale un tick peor que el precio de la barra
- BLa mitad de las órdenes no se ejecutan
- CLas órdenes limitadas se ejecutan siempre
- DSe simula el impacto de mercado con la raíz cuadrada del tamaño
Ver respuesta
Respuesta A. Cada ejecución sale un tick peor que el precio de la barra
Con libro L1,
prob_slippagees la probabilidad de que cada ejecución se desplace un tick en contra. Con 1,0, todas: en el cruce de medias, 1.906 ejecuciones de 300 acciones a 0,01 USD suman 5.718 USD de deslizamiento.¿Por qué
Money(0.005, USD)no sirve para cobrar 0,005 USD por acción?- APorque
Moneyno admite decimales - BPorque la precisión del USD en Nautilus es de 2 decimales y el importe se redondea a 0,01 USD: el doble de la tarifa
- CPorque las comisiones van en porcentaje
- DPorque hay que usar
Price
Ver respuesta
Respuesta B. Porque la precisión del USD en Nautilus es de 2 decimales y el importe se redondea a 0,01 USD: el doble de la tarifa
Los tipos de valor de Nautilus (
Price,Quantity,Money) tienen precisión fija. Una tarifa por acción menor que un céntimo se modela por orden (FixedFeeModelcon el importe de la orden completa) o como porcentaje del efectivo.- APorque
¿Qué cambia entre el backtest y el
TradingNodereal si la estrategia es la misma clase?- ANada: los resultados serán idénticos
- BLa estrategia, que hay que reescribir en Rust
- CLos clientes de datos y de ejecución (adaptadores), la latencia, los fills parciales y la reconciliación del estado con el broker; la lógica de la estrategia no
- DSolo el capital inicial
Ver respuesta
Respuesta C. Los clientes de datos y de ejecución (adaptadores), la latencia, los fills parciales y la reconciliación del estado con el broker; la lógica de la estrategia no
El núcleo, la caché, el bus y la estrategia son los mismos; lo que cambia son las piezas que hablan con el exterior y el comportamiento del mercado real, que ninguna simulación reproduce entero. La documentación lo dice expresamente.
El cruce de medias gana 40.812 USD brutos en 2022-2025 con 300 acciones; comprar y mantener 300 acciones habría ganado 95.127. ¿Qué indica?
- AQue el cruce tiene una ventaja que se pierde con los costes
- BQue el cruce no aporta nada en estos datos: gana por estar comprado parte del tiempo en un activo que sube, y los costes le quitan otro 21 %
- CQue el motor tiene un error
- DQue hay que optimizar los periodos de las medias
Ver respuesta
Respuesta B. Que el cruce no aporta nada en estos datos: gana por estar comprado parte del tiempo en un activo que sube, y los costes le quitan otro 21 %
En
indice2_5min.parquetlos rendimientos de 5 minutos no tienen autocorrelación fuera de la primera media hora (es un control sin efecto). Un cruce de medias no puede anticipar nada; lo que gana viene de la deriva del activo mientras está dentro. Optimizar los periodos sería ajustar ruido.
Ideas clave
- NautilusTrader separa un núcleo compilado (Rust; en la 1.x con una capa Cython) de un plano de control en Python: la estrategia es una clase Python que recibe eventos.
- Todo pasa por el bus de mensajes: los datos van del adaptador al motor de datos, a la caché y a la estrategia; las órdenes, de la estrategia al motor de riesgo, al de ejecución y al venue.
- Las barras se marcan al cierre. Con marcas al inicio, el motor vería cada barra antes de que existiera.
- El venue simulado define los costes:
FillModelpara el deslizamiento y la probabilidad de ejecución de las limitadas,FeeModelpara las comisiones,LatencyModelpara el retraso. - En los datos del curso, el cruce de medias es un control sin ventaja y el hueco de apertura tiene un efecto plantado; el motor no distingue una cosa de otra: lo hace la validación del módulo 2.5.
- El mismo
Strategycorre en unTradingNodecon adaptadores reales; la latencia, los fills parciales y la reconciliación son las diferencias que quedan.
Recursos
Documentación
- NautilusTrader: arquitectura (documentación oficial)
- NautilusTrader: ejecución con barras (marca de tiempo y orden OHLC)
- NautilusTrader: modelos de ejecución (fill models)
- NautilusTrader 2.0.0rc3: notas de la versión (retirada del paquete Cython)
- Ejemplo oficial de TradingNode con Interactive Brokers (v1.231.0)