---
title: "2.4.5 NautilusTrader: arquitectura y estrategias"
description: "NautilusTrader por dentro: núcleo en Rust, estrategias en Python, barras, venue simulado con costes y el mismo código del backtest al real."
url: "https://pythonparatrading.com/curso-python-trading/nivel-2/nautilustrader-arquitectura-y-estrategias/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-2/nautilustrader-arquitectura-y-estrategias.png"
---

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

- Curso: [Nivel 2: Estrategias sistemáticas y backtesting riguroso](https://pythonparatrading.com/curso-python-trading/nivel-2/)
- Módulo: 2.4 · Backtesting: motores y paradigmas
- Duración: 70 min
- Requisitos: [0.4.1 · Programación orientada a objetos: clases, herencia, composición y dataclasses](https://pythonparatrading.com/curso-python-trading/nivel-0/clases-herencia-y-dataclasses/), [1.1.4 · Tipos de órdenes](https://pythonparatrading.com/curso-python-trading/nivel-1/tipos-de-ordenes/), [1.4.5 · Construcción de barras: tiempo, ticks, volumen, dólar y desequilibrio](https://pythonparatrading.com/curso-python-trading/nivel-1/barras-de-tiempo-ticks-volumen-y-dolar/)

## Al terminar esta lección

- Describir la arquitectura de NautilusTrader (núcleo, bus de mensajes, caché, motores de datos, riesgo y ejecución, adaptadores) y qué cambia entre la serie 1.x y la 2.0.
- Convertir barras propias al formato de Nautilus con la marca de tiempo al cierre y definir un instrumento sin errores de precisión.
- Escribir una estrategia como subclase de `Strategy`, con su configuración, indicadores y órdenes, y ejecutarla en un `BacktestEngine` con modelos de comisión y deslizamiento.
- Leer los informes de órdenes ejecutadas, posiciones y cuenta, y separar el resultado bruto de los costes.
- Explicar qué hace falta para llevar la misma estrategia a un `TradingNode` real y qué diferencias quedan entre simulación y mercado.

## 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/latest` ya describe esa
  arquitectura 2.0 (Rust con enlaces PyO3) y algunas rutas de importación de sus ejemplos (`nautilus_trader.backtest`
  en vez de `nautilus_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:

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

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

```python
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))
```
```output
KAPA.XNYS 0.01 USD
0.01 USD 1.50 USD
```

La ú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.

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

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

*Fig. — Cómo ejecuta NautilusTrader con barras: cuatro puntos sintéticos por barra, la barra entregada a la estrategia en su cierre y la orden casada con el libro de ese cierre (esquema).* Dos barras de 5 minutos de KAPA, de 09:30 a 09:35 y de 09:35 a 09:40, dibujadas como el recorrido apertura, máximo, mínimo y cierre que simula el motor. Una línea discontinua a las 09:30 marca el error de usar la hora de inicio, cuando la barra aún no existe. La línea destacada a las 09:35 es ts_init, el cierre: el motor entrega la barra a on_bar y una orden enviada ahí se ejecuta con el libro de ese cierre. Con un modelo de latencia, la orden se casa con el libro de la barra siguiente, a las 09:40.

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.

```python
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**: `FixedFeeModel` de 1,50 USD por orden, la de `Alcor` para 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_seed` fija 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.

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

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

```python
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())
```
```output
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: FILLED
```

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

```python
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)
```
```output
ó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 USD
```

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

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

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

*Fig. — Del beneficio bruto al neto en NautilusTrader: comisión fija de 1,50 USD por orden y un tick de deslizamiento por ejecución, KAPA 2022-2025.* Barras horizontales en USD. Comprar y mantener 300 acciones gana 95.127. El cruce de medias gana 40.812 brutos, paga 2.859 de comisiones y 5.718 de deslizamiento y deja 32.235 netos (destacado): menos de la mitad que no hacer nada. La estrategia contra el hueco gana 29.367 brutos, paga 1.869 y 3.738 y deja 23.760.

La 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).

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

```python skip title="hueco_en_real.py"
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

1. Añade al venue de `backtest` una latencia de un segundo (`LatencyModel(base_latency_nanos=1_000_000_000)` de
   `nautilus_trader.backtest.models`) y vuelve a ejecutar `Hueco` con 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.**

```python
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}")
```
```output
sin latencia  primeras ejecuciones ['14:35', '15:00'] · neto 23,760
con 1 s       primeras ejecuciones ['14:40', '15:05'] · neto 23,622
```

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

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

```python
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())
```
```output
viajes: 1925 · neto: 24942.0
```

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

3. Un compañero lleva `Hueco` a 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.

## Autoevaluación

1. Las barras de `KAPA` vienen marcadas con la hora de inicio (09:30 para la barra 09:30-09:35). ¿Qué hay que hacer antes de pasarlas a Nautilus?

   - A. Nada: Nautilus las reinterpreta solo
   - B. Sumar 5 minutos para que la marca sea el cierre de la barra, el instante en que la barra completa existe
   - C. Restar 5 minutos
   - D. Pasarlas a la zona horaria de Nueva York

   *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_init` sea el cierre del intervalo.

2. En el venue simulado, `FillModel(prob_slippage=1.0)` con barras (libro L1) significa que…

   - A. Cada ejecución sale un tick peor que el precio de la barra
   - B. La mitad de las órdenes no se ejecutan
   - C. Las órdenes limitadas se ejecutan siempre
   - D. Se simula el impacto de mercado con la raíz cuadrada del tamaño

   *Respuesta A: Cada ejecución sale un tick peor que el precio de la barra.* Con libro L1, `prob_slippage` es 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.

3. ¿Por qué `Money(0.005, USD)` no sirve para cobrar 0,005 USD por acción?

   - A. Porque `Money` no admite decimales
   - 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
   - C. Porque las comisiones van en porcentaje
   - D. Porque hay que usar `Price`

   *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 (`FixedFeeModel` con el importe de la orden completa) o como porcentaje del efectivo.

4. ¿Qué cambia entre el backtest y el `TradingNode` real si la estrategia es la misma clase?

   - A. Nada: los resultados serán idénticos
   - B. La estrategia, que hay que reescribir en Rust
   - 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
   - D. Solo el capital inicial

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

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

   - A. Que el cruce tiene una ventaja que se pierde con los costes
   - 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 %
   - C. Que el motor tiene un error
   - D. Que hay que optimizar los periodos de las medias

   *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.parquet` los 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: `FillModel` para el deslizamiento y la probabilidad de ejecución de las limitadas, `FeeModel` para las comisiones, `LatencyModel` para 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 `Strategy` corre en un `TradingNode` con adaptadores reales; la latencia, los fills parciales y la reconciliación son las diferencias que quedan.

## Recursos

### Research

- [Modelos Cuantitativos y Ejecución Algorítmica](https://pythonparatrading.com/modelos-cuantitativos-ejecucion-algoritmica/) · Research-0009 · 11 min

### Documentación

- [NautilusTrader: arquitectura (documentación oficial)](https://nautilustrader.io/docs/latest/concepts/architecture) · nautilustrader.io
- [NautilusTrader: ejecución con barras (marca de tiempo y orden OHLC)](https://nautilustrader.io/docs/latest/concepts/backtesting/bar-execution/) · nautilustrader.io
- [NautilusTrader: modelos de ejecución (fill models)](https://nautilustrader.io/docs/latest/concepts/backtesting/fill-models/) · nautilustrader.io
- [NautilusTrader 2.0.0rc3: notas de la versión (retirada del paquete Cython)](https://github.com/nautechsystems/nautilus_trader/releases/tag/v2.0.0rc3) · github.com
- [Ejemplo oficial de TradingNode con Interactive Brokers (v1.231.0)](https://github.com/nautechsystems/nautilus_trader/blob/v1.231.0/examples/live/interactive_brokers/connect_with_tws.py) · github.com

### Datos

- [Datos del nivel 2: barras de 5 minutos del ETF KAPA (Parquet)](https://pythonparatrading.com/curso/datos/nivel-2/indice2_5min.parquet) · Descarga · indice2_5min.parquet

Lección anterior: [2.4.4 · backtesting.py y backtrader: dos motores didácticos y su estado](https://pythonparatrading.com/curso-python-trading/nivel-2/backtesting-py-y-backtrader/)

Lección siguiente: [2.4.6 · LEAN/QuantConnect y Zipline Reloaded; bt para asignación de activos](https://pythonparatrading.com/curso-python-trading/nivel-2/lean-zipline-reloaded-y-bt/)
