Lección 4.5.6 · 75 min

NautilusTrader o LEAN en real: del backtest a producción con el mismo código

La promesa de NautilusTrader y LEAN es que la estrategia del backtest es la que opera. Esta lección la pone a prueba con la regla del sistema en vivo del nivel 4: la escribe una vez como estrategia de NautilusTrader, la ejecuta en el motor de backtest, la compara con la cuenta real de los datos y la vuelve a ejecutar en un nodo en vivo con ejecución simulada, sin red. Después repasa lo que cambia al conectar un broker de verdad y cómo se hace lo mismo con LEAN.

Fig. — Patrimonio de la cuenta real de los datos menos el del backtest de la misma regla en NautilusTrader (sin costes, ejecución al cierre), del 13 de octubre al 31 de diciembre de 2025, y comisiones acumuladas de la cuenta real
Lecciones del nivel 4
  1. 4.1 · Teoría y optimización de carteras

    1. 4.1.1 Markowitz: frontera eficiente y restricciones reales
    2. 4.1.2 Estimación de covarianzas: shrinkage, modelos factoriales y denoising
    3. 4.1.3 Black-Litterman: del equilibrio del mercado a las visiones
    4. 4.1.4 Risk parity, mínima varianza, HRP y HERC
    5. 4.1.5 Optimización con CVaR, drawdown, rotación y costes
    6. 4.1.6 Modelos factoriales: CAPM, Fama-French, PCA y atribución
    7. 4.1.7 Rebalanceo y carteras de estrategias
    8. 4.1.8 Herramientas de carteras: Riskfolio-Lib, PyPortfolioOpt, skfolio y bt
  2. 4.2 · Gestión del riesgo y tamaño de posición

    1. 4.2.1 Riesgo por operación, stops y riesgo de ruina
    2. 4.2.2 Criterio de Kelly y volatility targeting: cuánto arriesgar
    3. 4.2.3 VaR, Expected Shortfall y backtesting del VaR
    4. 4.2.4 Stress testing y riesgos de correlación, liquidez, gap y contraparte
    5. 4.2.5 Control del drawdown y kill switch
    6. 4.2.6 Riesgo de derivados: griegas de cartera, margen y liquidaciones
  3. 4.3 · Microestructura y ejecución

    1. 4.3.1 Implementation shortfall, VWAP, TWAP y Almgren-Chriss
    2. 4.3.2 Order flow imbalance, señales de microestructura y latencia
  4. 4.4 · Brokers y APIs

    1. 4.4.1 Elegir bróker desde España o la UE: regulación, activos, costes y API
    2. 4.4.2 Interactive Brokers: TWS e IB Gateway, ib_async, contratos, datos y órdenes
    3. 4.4.3 Alpaca, Tradier, MetaTrader 5 con Python y OANDA v20
    4. 4.4.4 APIs de exchanges cripto: ccxt, Binance, Bybit, Kraken y Coinbase
    5. 4.4.5 Credenciales seguras: variables de entorno, gestores de secretos y permisos mínimos
  5. 4.5 · Sistema de trading en vivo

    1. 4.5.1 Arquitectura de un sistema en vivo: de los datos a la reconciliación
    2. 4.5.2 Gestión de órdenes: estados, fills parciales, cancelaciones y reconexiones
    3. 4.5.3 Paper trading, shadow trading y seguimiento del slippage
    4. 4.5.4 Programación de tareas y alertas: cron, APScheduler, Prefect, Telegram, Slack y email
    5. 4.5.5 Tolerancia a fallos: idempotencia, estado persistente y reconciliación al arrancar
    6. 4.5.6 NautilusTrader o LEAN en real: del backtest a producción con el mismo código

Qué significa «el mismo código»

La lección 2.4.5 montó NautilusTrader pieza a pieza y acabó con un esqueleto de TradingNode para Interactive Brokers, sin ejecutar. La 2.4.6 hizo lo mismo con LEAN y su CLI. Las dos herramientas prometen lo mismo: la estrategia que se prueba en el backtest es la que opera en real, sin reescribirla. Esta lección comprueba esa promesa con una regla concreta, la del sistema en vivo de las lecciones 4.5.1 a 4.5.5, y la lleva tan lejos como se puede sin red ni cuenta: hasta un nodo en vivo de verdad, con su reloj real, sus colas y su motor de riesgo, y una ejecución simulada.

Lo que cambia entre entornos no es la estrategia, sino lo que la rodea:

BacktestSandboxReal
MotorBacktestEngineTradingNodeTradingNode
Relojel de los datos (simulado)el de la máquinael de la máquina
Datoslista históricacliente de datos en vivocliente de datos en vivo
Ejecuciónmercado simuladomercado simulado (SandboxExecutionClient)cliente del broker
Reconciliación al arrancarno existecon el simulado (vacío)con el broker
Fallos de red y del brokerno hayno haylos de las lecciones 4.5.2 y 4.5.5

El paso intermedio, el sandbox, es lo que la documentación de NautilusTrader llama datos en vivo con ejecución simulada: el nodo de producción entero, salvo que las órdenes no salen de la máquina. Aquí, además, los datos en vivo los pone un cliente propio que reproduce historia, para que la lección se pueda ejecutar sin red y dé siempre lo mismo.

La regla del sistema, como estrategia

La regla del sistema (README de los datos del nivel 4): cada sesión, con los precios hasta la víspera, peso min(2,5 % / vol_63, 25 %) en los ETF con momentum de 20 sesiones positivo, normalizados si suman más del 100 %; se opera la diferencia con la posición si supera el 0,4 % del patrimonio. Los diez ETF son ficticios y su precio es un índice del universo reescalado, como en el generador de los datos. Las barras diarias llevan la marca de tiempo del cierre real de cada sesión según el calendario de la NYSE (lección 4.5.4): 20:00 UTC en verano, 21:00 en invierno y 17:00 o 18:00 en las tres sesiones cortas del periodo (3 de julio, 28 de noviembre y 24 de diciembre).

IN [01]python
import math
import warnings

import exchange_calendars as xc
import numpy as np
import pandas as pd
from pandas.errors import Pandas4Warning

# el motor llama a Timestamp.utcnow(), obsoleto en pandas 3:
# un aviso por ejecución, fuera de nuestro control (lección 2.4.5)
warnings.filterwarnings("ignore", message="Timestamp.utcnow",
                        category=Pandas4Warning)

from nautilus_trader.model.currencies import USD
from nautilus_trader.model.data import Bar, BarType
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

ETF = {"ORUS": ("RV_EEUU", 640.0), "ORSC": ("RV_EEUU_P", 232.0),
       "OREU": ("RV_EUR", 61.0), "OREM": ("RV_EMG", 49.0),
       "ORTN": ("RF_T710", 96.0), "ORTL": ("RF_T20", 88.0),
       "ORIG": ("RF_CIG", 109.0), "ORAU": ("MP_ORO", 312.0),
       "ORCM": ("MP_AMP", 24.0), "ORRE": ("INM_EEUU", 93.0)}
u = pd.read_parquet("universo4_precios.parquet").set_index("fecha")
base = u.loc["2025-08-29"]
precios = pd.DataFrame({t: u[c] * p / base[c]
                        for t, (c, p) in ETF.items()})
precios = precios.loc["2025-07-01":"2025-12-31"].round(2)
cierres = (xc.get_calendar("XNYS").schedule
           .loc[precios.index[0]:precios.index[-1], "close"])

VENUE = Venue("XNYS")
inst = {t: Equity(InstrumentId(Symbol(t), VENUE), Symbol(t), USD,
                  2, Price.from_str("0.01"), Quantity.from_int(1),
                  0, 0) for t in ETF}
TIPO = {t: BarType.from_str(f"{t}.XNYS-1-DAY-LAST-EXTERNAL")
        for t in ETF}
barras = []
for d, ts in zip(precios.index, cierres):
    for t in ETF:
        px = Price(precios.loc[d, t], 2)
        barras.append(Bar(TIPO[t], px, px, px, px,
                          Quantity.from_int(10_000_000),
                          ts.value, ts.value))
print(len(precios), "sesiones ·", len(barras), "barras")
print(pd.Series(cierres.dt.strftime("%H:%M UTC")).value_counts()
      .to_dict())
OUT [01]Instrumentos y barras diarias marcadas al cierre
128 sesiones · 1280 barras
{'20:00 UTC': 86, '21:00 UTC': 39, '18:00 UTC': 2, '17:00 UTC': 1}

Ciento veintiocho sesiones desde el 1 de julio (las primeras sirven para calentar la volatilidad de 63 sesiones) y una barra por ETF y sesión. Las barras se construyen directamente con Bar(...) en vez de con BarDataWrangler porque, con pandas 3, el wrangler falla sobre un DataFrame con todas las columnas del mismo tipo (buffer source array is read-only, por la copia en escritura); el de la lección 2.4.5 funcionaba porque mezclaba tipos.

La estrategia es una subclase de Strategy como las de la 2.4.5, con dos decisiones que vienen de pensar en real desde el principio:

  • Las compras esperan a las ventas. Con una cuenta de efectivo, el dinero de una venta no está disponible hasta que se ejecuta. La estrategia envía las ventas, espera en on_order_filled a que todas estén cerradas y entonces calcula las compras con el efectivo libre que dice la cuenta. En el backtest, todo pasa en el mismo instante; en real, entre una cosa y otra pasan segundos, y una estrategia que compre a la vez con el dinero de ventas sin ejecutar se queda sin saldo.
  • La posición se pregunta, no se guarda. El tamaño de cada orden sale de self.portfolio.net_position(...) y del saldo de la cuenta, que en real vienen del broker tras la reconciliación (lección 4.5.5), no de un contador propio.
IN [02]python
from nautilus_trader.config import StrategyConfig
from nautilus_trader.model.enums import OrderSide
from nautilus_trader.trading.strategy import Strategy


class MomVolConfig(StrategyConfig, frozen=True):
    tickers: tuple
    desde: str = "2025-10-13"        # primera sesión que opera
    vol_obj: float = 0.025
    tope: float = 0.25
    umbral: float = 0.004


class MomVol(Strategy):
    def __init__(self, config: MomVolConfig) -> None:
        super().__init__(config)
        self.cierres = {t: [] for t in config.tickers}
        self.recibidas, self.ventas, self.compras = 0, set(), []

    def iid(self, t):
        return InstrumentId(Symbol(t), VENUE)

    def on_start(self) -> None:
        for t in self.config.tickers:
            self.subscribe_bars(TIPO[t])

    def on_bar(self, bar: Bar) -> None:
        t = bar.bar_type.instrument_id.symbol.value
        self.cierres[t].append(float(bar.close))
        self.recibidas += 1
        fecha = pd.Timestamp(bar.ts_event, unit="ns", tz="UTC")
        if (self.recibidas % len(self.config.tickers) == 0
                and fecha.strftime("%Y-%m-%d") >= self.config.desde):
            self.rebalancear()          # ya están las 10 barras

    def pesos(self):
        w = {}
        for t in self.config.tickers:
            h = np.array(self.cierres[t][:-1])     # hasta la víspera
            mom = h[-1] / h[-21] - 1
            vol = np.std(h[-64:][1:] / h[-64:][:-1] - 1,
                         ddof=1) * math.sqrt(252)
            w[t] = (min(self.config.vol_obj / vol, self.config.tope)
                    if mom > 0 else 0.0)
        s = sum(w.values())
        return {t: x / s for t, x in w.items()} if s > 1 else w

    def rebalancear(self) -> None:
        hoy = {t: c[-1] for t, c in self.cierres.items()}
        pos = {t: float(self.portfolio.net_position(self.iid(t)))
               for t in hoy}
        cuenta = self.portfolio.account(VENUE)
        nav = float(cuenta.balance_total(USD)) + sum(
            pos[t] * hoy[t] for t in hoy)
        self.ventas, self.compras = set(), []
        for t, w in self.pesos().items():
            dif = math.floor(w * nav / hoy[t]) - pos[t]
            if abs(dif) * hoy[t] < self.config.umbral * nav:
                continue
            if dif < 0:
                o = self.order_factory.market(
                    self.iid(t), OrderSide.SELL,
                    Quantity.from_int(int(-dif)))
                self.ventas.add(o.client_order_id)
                self.submit_order(o)
            else:
                self.compras.append((t, int(dif), hoy[t]))
        if not self.ventas:
            self.comprar()

    def on_order_filled(self, event) -> None:
        cid = event.client_order_id
        if cid in self.ventas and self.cache.order(cid).is_closed:
            self.ventas.discard(cid)
            if not self.ventas:          # todas las ventas cerradas
                self.comprar()

    def comprar(self) -> None:
        libre = float(self.portfolio.account(VENUE).balance_free(USD))
        for t, dif, px in self.compras:
            q = min(dif, int(libre / px))
            if q > 0:
                libre -= q * px
                self.submit_order(self.order_factory.market(
                    self.iid(t), OrderSide.BUY, Quantity.from_int(q)))
        self.compras = []

Backtest: la misma regla frente a la cuenta real

El motor de backtest con una cuenta de efectivo de 1.000.000 USD, como la cuenta real de los datos, que empezó a operar el 13 de octubre de 2025:

IN [03]python
from nautilus_trader.backtest.engine import BacktestEngine
from nautilus_trader.config import BacktestEngineConfig, LoggingConfig
from nautilus_trader.model.enums import AccountType, OmsType

def informe_fills(trader):
    f = trader.generate_order_fills_report()
    f["fecha"] = (pd.to_datetime(f["ts_last"])
                  .dt.tz_convert("America/New_York")
                  .dt.strftime("%Y-%m-%d"))
    f["sym"] = f["instrument_id"].str.split(".").str[0]
    f["q"] = (f["filled_qty"].astype(float)
              * np.where(f["side"] == "BUY", 1, -1))
    f["px"] = f["avg_px"].astype(float)
    return f.reset_index()           # client_order_id, una columna

motor = BacktestEngine(BacktestEngineConfig(
    logging=LoggingConfig(log_level="ERROR")))
motor.add_venue(venue=VENUE, oms_type=OmsType.NETTING,
                account_type=AccountType.CASH, base_currency=USD,
                starting_balances=[Money(1_000_000, USD)])
for i in inst.values():
    motor.add_instrument(i)
motor.add_data(barras)
motor.add_strategy(MomVol(MomVolConfig(tickers=tuple(ETF))))
motor.run()
fb = informe_fills(motor.trader)
print(len(fb), "ejecuciones del", fb["fecha"].min(),
      "al", fb["fecha"].max())
print(fb[["fecha", "sym", "side", "q", "px"]].head(4)
      .to_string(index=False))
OUT [03]Backtest con el BacktestEngine
228 ejecuciones del 2025-10-13 al 2025-12-31
     fecha  sym side      q     px
2025-10-13 ORUS  BUY  212.0 665.07
2025-10-13 ORSC  BUY  435.0 235.23
2025-10-13 OREU  BUY 1920.0  63.15
2025-10-13 ORTN  BUY 1442.0  98.08

El sistema real de los datos ejecutó la misma regla con otra maquinaria: decidía a las 15:45 con el precio medio de ese momento, enviaba órdenes a mercado, límite o al cierre y, los lunes, la mitad de las señales a la apertura siguiente. Comparando la posición de cierre de cada día del backtest con el extracto de la cuenta real (con el desdoblamiento de ORAU deshecho, lección 4.5.5):

IN [04]python
pos_bt = (fb.pivot_table(index="fecha", columns="sym", values="q",
                         aggfunc="sum").fillna(0).cumsum())
ext = pd.read_csv("vivo4_extracto_posiciones.csv")
pos_re = ext.pivot(index="fecha", columns="instrumento",
                   values="cantidad").fillna(0)
pos_re.loc[pos_re.index >= "2025-11-17", "ORAU"] /= 3
pos_bt = (pos_bt.reindex(pos_re.index).ffill().fillna(0)
          .reindex(columns=pos_re.columns, fill_value=0))
dif = (pos_bt - pos_re).abs()
titulos = dif.sum(axis=1)
valor = (dif * precios.set_axis(precios.index.strftime("%Y-%m-%d"))
         .reindex(dif.index)[dif.columns]).sum(axis=1)
print("sesiones:", len(dif), "| títulos distintos al día, mediana:",
      int(titulos.median()))
print("valor distinto al día, mediana: "
      f"{valor.median():,.0f} USD; máximo {valor.max():,.0f} USD "
      f"({valor.idxmax()}, {pd.Timestamp(valor.idxmax()):%A})")
OUT [04]Posiciones del backtest frente al extracto de la cuenta real
sesiones: 56 | títulos distintos al día, mediana: 17
valor distinto al día, mediana: 1,443 USD; máximo 494,437 USD (2025-10-13, Monday)

La misma regla en NautilusTrader reproduce la cuenta real: la mediana de diferencia es de 17 títulos al día entre los diez ETF, unos 1.400 USD de valor en una cartera de un millón. Salen del precio con que se calcula cada orden (el medio a las 15:45 en el sistema real; el cierre en el backtest), que cambia el redondeo de la cantidad. Las diferencias grandes caen en lunes, y la mayor, de casi 500.000 USD, el primer día: el lunes 13 de octubre el sistema real mandó la mitad de las órdenes de compra a la apertura del martes, y durante un día su cartera fue la mitad de la del backtest.

Lo que separa el dinero es otra cosa:

IN [05]python
cuenta = motor.trader.generate_account_report(VENUE)
cuenta.index = (cuenta.index.tz_convert("America/New_York")
                .strftime("%Y-%m-%d"))
caja_bt = cuenta["total"].astype(float).groupby(level=0).last()
px_dia = (precios.set_axis(precios.index.strftime("%Y-%m-%d"))
          .reindex(pos_bt.index)[pos_bt.columns])
pat_bt = (pos_bt * px_dia).sum(axis=1) + caja_bt.reindex(
    pos_bt.index).ffill()

efe = pd.read_csv("vivo4_extracto_efectivo.csv")
caja_re = efe.groupby("fecha")["saldo"].last()
pat_re = ext.groupby("fecha")["valor"].sum() + caja_re.reindex(
    pos_re.index).ffill()
brecha = pat_re - pat_bt
ops = pd.read_csv("vivo4_extracto_operaciones.csv")
otros = efe[efe["concepto"].isin(["intereses",
                                  "tasa_datos_mercado"])]
print(f"backtest: {pat_bt.iloc[-1]:,.2f} | real: "
      f"{pat_re.iloc[-1]:,.2f} | brecha: {brecha.iloc[-1]:,.2f}")
print(f"comisiones: {-ops['comision'].sum():,.2f} | intereses y "
      f"tasas: {otros['importe'].sum():+,.2f}")
resto = (brecha.iloc[-1] + ops["comision"].sum()
         - otros["importe"].sum())
print(f"resto (precio y hora de ejecución): {resto:,.2f}")
OUT [05]Patrimonio del backtest frente al de la cuenta real
backtest: 1,044,525.05 | real: 1,042,306.50 | brecha: -2,218.55
comisiones: -596.38 | intereses y tasas: +483.60
resto (precio y hora de ejecución): -2,105.77

La cuenta real acaba 2.218,55 USD por debajo del backtest, un 0,22 % del capital en 56 sesiones. Las comisiones son 596,38 y los intereses netos de la tasa de datos suman 483,60 a favor; el resto, unos 2.100 USD, es el precio: la horquilla y el deslizamiento de cada ejecución a mercado, el precio límite de las pasivas y la diferencia entre ejecutar a las 15:50, a la apertura del martes o al cierre. Es la medida de la lección 4.5.3 vista desde el patrimonio. La figura de la cabecera sigue la brecha día a día: crece a escalones, uno por cada rebalanceo. Dos días recupera una parte (el martes 4 de noviembre y el martes 23 de diciembre): son días en que el sistema real ejecutó a la apertura las órdenes del lunes y su cartera fue otra durante un día, para bien. La tendencia es una: el coste de ejecutar se paga en cada operación y no se devuelve.

El backtest, por tanto, ha validado la lógica (decide lo mismo que el sistema real) y ha medido lo que no modela. Para que acierte también en dinero hay que darle un modelo de comisiones con la tarifa del broker y un deslizamiento como el medido (lecciones 2.4.5 y 2.4.7). Lo que sigue es la otra mitad: que el código que decide funcione igual dentro del motor en vivo.

Sandbox: la misma clase en un nodo en vivo

Un TradingNode necesita un cliente de datos y uno de ejecución. Para la ejecución, NautilusTrader trae el SandboxExecutionClient, que mete un mercado simulado (el SimulatedExchange del backtest) detrás de la interfaz de un cliente en vivo. Para los datos, un cliente propio, subclase de LiveMarketDataClient, que cuando la estrategia se suscribe reproduce las barras de la lección como si llegaran del mercado:

Cliente ReplayDataEngineMomVolSandboxRiskEngineExecEngine
Fuente Mermaid
flowchart TB
  R[Cliente Replay] --> DE[DataEngine]
  DE --> ST[MomVol]
  DE --> SX[Sandbox]
  ST --> RE[RiskEngine]
  RE --> EE[ExecEngine]
  EE --> SX
  SX --> EE
  EE --> ST

Tres cosas aparecieron al montarlo con la versión 1.231.0, y las tres son diferencias reales entre el backtest y el modo en vivo:

  1. El sandbox no ve las barras. Se suscribe en el bus a los temas data.*.XNYS.*, que casan con cotizaciones (data.quotes.XNYS.ORUS) y operaciones, pero no con barras (data.bars.ORUS.XNYS-1-DAY-…). Con solo barras, todas las órdenes se rechazan con «no market». El cliente de la lección publica antes de cada barra una cotización con compra y venta al cierre; los ejemplos oficiales de sandbox, por lo mismo, se suscriben a cotizaciones1Nota 1 · NautilusTrader 1.231.0, nautilus_trader/adapters/sandbox/execution.py: connect() se suscribe a data.*.{venue}.* y on_data() pasa cotizaciones, operaciones, libro y barras al mercado simulado; los temas de barras del bus son data.bars.{bar_type}. Ejemplos oficiales: examples/sandbox/interactive_brokers_sandbox.py (estrategia con subscribe_quote_ticks=True). Comprobado ejecutando el código de la lección..
  2. El motor en vivo trabaja con colas. Datos, comandos y eventos entran en colas asyncio y se procesan por turnos. Si el cliente publica las sesiones de golpe, las órdenes de un día llegan al simulador cuando ya tiene los precios de días posteriores. El cliente espera a que el sistema termine con cada sesión (barras procesadas y ninguna orden sin cerrar) antes de publicar la siguiente: eventos, no esperas de tiempo. En real, entre un cierre y el siguiente pasa un día.
  3. El motor de riesgo mide con el reloj real. LiveRiskEngineConfig limita por defecto el envío a 100 órdenes por segundo. Reproduciendo meses en segundos, el límite salta y las órdenes salen DENIED; en el backtest no pasa porque el reloj es el de los datos. La celda sube el límite solo para la reproducción.
IN [06]python
import asyncio

from nautilus_trader.adapters.sandbox.config import (
    SandboxExecutionClientConfig)
from nautilus_trader.adapters.sandbox.factory import (
    SandboxLiveExecClientFactory)
from nautilus_trader.common.providers import InstrumentProvider
from nautilus_trader.config import (
    LiveDataClientConfig, LiveRiskEngineConfig, TradingNodeConfig)
from nautilus_trader.live.data_client import LiveMarketDataClient
from nautilus_trader.live.factories import LiveDataClientFactory
from nautilus_trader.live.node import TradingNode
from nautilus_trader.model.data import QuoteTick
from nautilus_trader.model.identifiers import ClientId


class ReplayConfig(LiveDataClientConfig, frozen=True):
    pass


class Replay(LiveMarketDataClient):
    """Publica las barras de la lección, una sesión cada vez que
    el nodo ha terminado con la anterior."""
    nodo, fin = None, None

    def __init__(self, loop, msgbus, cache, clock):
        super().__init__(loop=loop, client_id=ClientId("REPLAY"),
                         venue=VENUE, msgbus=msgbus, cache=cache,
                         clock=clock,
                         instrument_provider=InstrumentProvider())
        self.suscritas = 0

    async def _connect(self):
        pass

    async def _disconnect(self):
        pass

    async def _subscribe_bars(self, command):
        self.suscritas += 1
        if self.suscritas == len(ETF):       # la estrategia ya está
            self.create_task(self.reproducir())

    def ocupado(self, n):
        est = Replay.nodo.trader.strategies()[0]
        c = Replay.nodo.cache
        return (est.recibidas < n or est.compras
                or c.orders_total_count() > c.orders_closed_count())

    async def reproducir(self):
        unidades = Quantity.from_int(1_000_000)
        for n, b in enumerate(barras, 1):
            iid = b.bar_type.instrument_id
            self._handle_data(QuoteTick(iid, b.close, b.close,
                                        unidades, unidades,
                                        b.ts_event, b.ts_init))
            self._handle_data(b)
            while self.ocupado(n):         # eventos, no tiempo
                await asyncio.sleep(0)
        Replay.fin.set()


class ReplayFactory(LiveDataClientFactory):
    @staticmethod
    def create(loop, name, config, msgbus, cache, clock):
        return Replay(loop, msgbus, cache, clock)


config = TradingNodeConfig(
    trader_id="CURSO-001",
    logging=LoggingConfig(log_level="ERROR"),
    risk_engine=LiveRiskEngineConfig(
        max_order_submit_rate="100000/00:00:01"),  # solo al reproducir
    data_clients={"REPLAY": ReplayConfig()},
    exec_clients={"XNYS": SandboxExecutionClientConfig(
        venue="XNYS", starting_balances=["1000000 USD"],
        base_currency="USD", account_type="CASH")},
    timeout_post_stop=0.5,
)
nodo = TradingNode(config=config)
for i in inst.values():
    nodo.cache.add_instrument(i)
nodo.add_data_client_factory("REPLAY", ReplayFactory)
nodo.add_exec_client_factory("XNYS", SandboxLiveExecClientFactory)
nodo.trader.add_strategy(MomVol(MomVolConfig(tickers=tuple(ETF))))
nodo.build()
Replay.nodo, Replay.fin = nodo, asyncio.Event()

tarea = asyncio.create_task(nodo.run_async())
await Replay.fin.wait()
await nodo.stop_async()
await asyncio.wait_for(tarea, 30)

fs = informe_fills(nodo.trader)
cols = ["fecha", "sym", "q", "px"]
igual = (fs[cols].sort_values(cols).reset_index(drop=True)
         .equals(fb[cols].sort_values(cols).reset_index(drop=True)))
print(len(fs), "ejecuciones en sandbox; ¿las mismas que en el "
      "backtest?", igual)
estados = pd.Series([o.status_string() for o in nodo.cache.orders()])
print(estados.value_counts().to_dict())
print("ids de orden comunes con el backtest:",
      int(fs["client_order_id"].isin(fb["client_order_id"]).sum()))
OUT [06]Un TradingNode con datos reproducidos y ejecución en sandbox
228 ejecuciones en sandbox; ¿las mismas que en el backtest? True
{'FILLED': 228}
ids de orden comunes con el backtest: 0

Las mismas ejecuciones, título a título y precio a precio, con la estrategia corriendo dentro del nodo en vivo: su DataEngine con cola, su RiskEngine en tiempo real, su ExecutionEngine y un cliente de ejecución que se comporta como uno de broker. Lo que no es igual son los identificadores: el client_order_id que genera NautilusTrader lleva la fecha y la hora del reloj (O-20251013-… en el backtest, con la hora de los datos; en el nodo, con la hora de la máquina que lo ejecuta). Por eso la celda no los imprime: cambian en cada ejecución. Es un detalle con consecuencias: una clave de idempotencia que salga del reloj no sobrevive a un reinicio (lección 4.5.5); para eso NautilusTrader ofrece order_id_tag y la persistencia de la caché.

La configuración por defecto del modo en vivo enseña qué vigila el nodo además de la estrategia:

IN [07]python
from nautilus_trader.config import LiveExecEngineConfig

ex, rk, nd = LiveExecEngineConfig(), LiveRiskEngineConfig(), \
    TradingNodeConfig()
for nombre, valor in [
        ("reconciliación al arrancar", ex.reconciliation),
        ("ventana de historia (min)", ex.reconciliation_lookback_mins),
        ("generar órdenes que falten", ex.generate_missing_orders),
        ("revisar órdenes en vuelo cada (ms)",
         ex.inflight_check_interval_ms),
        ("…y darlas por perdidas tras (ms)",
         ex.inflight_check_threshold_ms),
        ("límite de envío de órdenes", rk.max_order_submit_rate),
        ("espera máxima de reconciliación (s)",
         nd.timeout_reconciliation),
        ("cargar estado guardado", nd.load_state)]:
    print(f"{nombre:38} {valor}")
OUT [07]Lo que el nodo en vivo hace por defecto
reconciliación al arrancar             True
ventana de historia (min)              None
generar órdenes que falten             True
revisar órdenes en vuelo cada (ms)     2000
…y darlas por perdidas tras (ms)       5000
límite de envío de órdenes             100/00:00:01
espera máxima de reconciliación (s)    30.0
cargar estado guardado                 False

Reconciliación al arrancar activada y sin límite de historia (la documentación recomienda dejarlo así para pedir al venue todo lo que tenga2Nota 2 · NautilusTrader 1.231.0, docs/concepts/reconciliation.md: «Leave reconciliation_lookback_mins unset», órdenes EXTERNAL con etiqueta RECONCILIATION, external_order_claims, jerarquía de precios de las órdenes generadas («MARKET order (last resort)») y recomendación de persistir los eventos en la base de datos de la caché.), órdenes en vuelo revisadas cada 2 segundos y consultadas si pasan 5 sin respuesta, 100 órdenes por segundo como freno, y 30 segundos de margen para reconciliar antes de rendirse. Sin estado guardado: la persistencia (caché en Redis) hay que configurarla aparte.

Fig. — Arranque de un TradingNode de NautilusTrader 1.231.0 con la configuración por defecto: conexión de clientes, reconciliación con el venue antes de activar las estrategias y revisión de las órdenes en vuelo cada 2 segundos, con consulta al venue si pasan 5 sin respuesta (esquema)

La figura pone esos valores en un cronograma (las duraciones de las fases son ilustrativas): las estrategias no arrancan hasta que la reconciliación termina, y una orden enviada que no recibe respuesta no se da por buena ni por perdida, sino que se revisa cada 2 segundos y, pasados 5, se pregunta al venue por ella. Es la regla de la lección 4.5.2 («consultar antes de reenviar»), hecha configuración.

Reconciliación al arrancar

La lección 4.5.5 concilió a mano. NautilusTrader lo hace solo al arrancar el nodo, antes de activar las estrategias: pide al cliente de ejecución tres informes (estado de órdenes, ejecuciones y posiciones) y alinea su caché con ellos. Para probarlo sin broker, basta un cliente de sandbox cuyo informe de posiciones diga algo que el sistema no sabe: que la cuenta tiene 187 ORAU, como la cuenta real de los datos antes del desdoblamiento.

Un detalle de la versión 1.231.0: el constructor del nodo solo pasa la cartera (portfolio) a una fábrica de clientes que se llame exactamente SandboxLiveExecClientFactory (compara el nombre de la clase), así que la fábrica propia lleva ese nombre.

IN [08]python
from decimal import Decimal

from nautilus_trader.adapters.sandbox.execution import (
    SandboxExecutionClient)
from nautilus_trader.core.uuid import UUID4
from nautilus_trader.execution.reports import PositionStatusReport
from nautilus_trader.live.factories import LiveExecClientFactory
from nautilus_trader.model.enums import PositionSide


class BrokerConPosicion(SandboxExecutionClient):
    async def generate_position_status_reports(self, command):
        ahora = self._clock.timestamp_ns()
        return [PositionStatusReport(
            account_id=self.account_id,
            instrument_id=InstrumentId(Symbol("ORAU"), VENUE),
            position_side=PositionSide.LONG,
            quantity=Quantity.from_int(187), report_id=UUID4(),
            ts_last=ahora, ts_init=ahora,
            avg_px_open=Decimal("366.98"))]


class SandboxLiveExecClientFactory(LiveExecClientFactory):
    @staticmethod
    def create(loop, name, config, portfolio, msgbus, cache, clock):
        return BrokerConPosicion(loop=loop, clock=clock, cache=cache,
                                 portfolio=portfolio, msgbus=msgbus,
                                 config=config)


class Quieta(Strategy):
    arrancada = None

    def on_start(self):
        Quieta.arrancada.set()


async def arrancar(estrategia):
    cfg = TradingNodeConfig(
        trader_id="CURSO-002",
        logging=LoggingConfig(log_level="ERROR"),
        exec_engine=LiveExecEngineConfig(
            reconciliation=True, reconciliation_startup_delay_secs=0),
        exec_clients={"XNYS": SandboxExecutionClientConfig(
            venue="XNYS", starting_balances=["1000000 USD"],
            base_currency="USD", account_type="CASH")},
        timeout_post_stop=0.5)
    n = TradingNode(config=cfg)
    for i in inst.values():
        n.cache.add_instrument(i)
    n.add_exec_client_factory("XNYS", SandboxLiveExecClientFactory)
    n.trader.add_strategy(estrategia)
    n.build()
    Quieta.arrancada = asyncio.Event()
    t = asyncio.create_task(n.run_async())
    await Quieta.arrancada.wait()       # tras la reconciliación
    filas = [(p.instrument_id.value, p.side.name,
              p.quantity.as_double(), p.strategy_id.value)
             for p in n.cache.positions()]
    ords = [(o.strategy_id.value, o.type_string(),
             o.side_string(), o.status_string(),
             [type(e).__name__ for e in o.events])
            for o in n.cache.orders()]
    await n.stop_async()
    await asyncio.wait_for(t, 30)
    return filas, ords

pos, ordenes = await arrancar(Quieta())
print("posiciones:", pos)
for o in ordenes:
    print("orden:", o)
OUT [08]Una posición que el sistema no conocía
posiciones: [('ORAU.XNYS', 'LONG', 187.0, 'EXTERNAL')]
orden: ('EXTERNAL', 'LIMIT', 'BUY', 'FILLED', ['OrderInitialized', 'OrderAccepted', 'OrderFilled'])

El nodo arranca, pide los informes y encuentra una posición de 187 ORAU que su caché no tenía. Para que la caché refleje al broker, genera una orden con estrategia EXTERNAL (inicializada, aceptada y ejecutada en el mismo acto) y la posición correspondiente; solo después llama a on_start de la estrategia. Según la documentación, cuando hay precio con que hacerlo la orden generada es límite, y a mercado solo como último recurso2.

Una posición EXTERNAL no es de ninguna estrategia: cuenta para la cartera y el riesgo, pero nadie la gestiona. Si la estrategia tiene que hacerse cargo de lo que el broker dice que hay (lo normal tras un reinicio sin estado guardado), lo declara con external_order_claims:

IN [09]python
class QuietaQueReclama(Quieta):
    pass

reclama = QuietaQueReclama(StrategyConfig(
    strategy_id="MomVol", order_id_tag="001",
    external_order_claims=["ORAU.XNYS"]))
pos, ordenes = await arrancar(reclama)
print("posiciones:", pos)
print("orden:", ordenes[0][:4])
OUT [09]La estrategia reclama la posición
posiciones: [('ORAU.XNYS', 'LONG', 187.0, 'MomVol-001')]
orden: ('MomVol-001', 'LIMIT', 'BUY', 'FILLED')

Con la reclamación, la orden y la posición generadas pasan a ser de MomVol-001, y la estrategia las gestiona como si las hubiera abierto ella. Es la versión automática de la lección 4.5.5, con sus mismos límites: la reconciliación de NautilusTrader compara órdenes, ejecuciones y posiciones que el broker informa, pero no sabe nada de una señal que pedía 7 títulos y produjo 14 (la orden duplicada) ni de un desdoblamiento si el adaptador no lo informa. Esas comprobaciones siguen siendo del sistema.

Paso a real con NautilusTrader

Con la estrategia probada en backtest y en sandbox, el paso a real cambia dos cosas: los clientes de datos y de ejecución pasan a ser los del adaptador del broker, y el nodo necesita estado persistente. La configuración se puede construir y comprobar sin conectarse; esta es la de Binance en su red de pruebas (testnet), con las clases de la versión instalada:

IN [10]python
from nautilus_trader.adapters.binance import (
    BINANCE, BinanceAccountType, BinanceDataClientConfig,
    BinanceExecClientConfig)
from nautilus_trader.adapters.binance.common.enums import (
    BinanceEnvironment)
from nautilus_trader.config import CacheConfig, DatabaseConfig

real = TradingNodeConfig(
    trader_id="CURSO-003",
    logging=LoggingConfig(log_level="INFO"),
    exec_engine=LiveExecEngineConfig(reconciliation=True,
                                     inflight_check_interval_ms=2000),
    cache=CacheConfig(database=DatabaseConfig(type="redis"),
                      flush_on_start=False),
    data_clients={BINANCE: BinanceDataClientConfig(
        account_type=BinanceAccountType.SPOT,
        environment=BinanceEnvironment.TESTNET)},
    exec_clients={BINANCE: BinanceExecClientConfig(
        account_type=BinanceAccountType.SPOT,
        environment=BinanceEnvironment.TESTNET,
        max_retries=3)},
)
c = real.exec_clients[BINANCE]
print(BINANCE, c.account_type.value, c.environment.value,
      "| claves en la config:", c.api_key is not None)
print("caché en", real.cache.database.type,
      "| reconciliación:", real.exec_engine.reconciliation)
OUT [10]Configuración de un nodo para la testnet de Binance
BINANCE SPOT TESTNET | claves en la config: False
caché en redis | reconciliación: True

Las claves no van en la configuración (api_key es None): el adaptador las lee de variables de entorno al crear el cliente, y para la testnet son claves de la testnet, distintas de las de la cuenta real (lección 4.4.5). La caché en Redis guarda órdenes, posiciones y cuentas, de modo que un reinicio parte del estado anterior y la reconciliación solo tiene que completar lo que pasó mientras el nodo estaba parado; la documentación recomienda persistir todos los eventos para no depender de la historia que guarde el venue2. Arrancarlo ya exige red, Redis y claves:

IN [11]momvol_en_real.pypython
from nautilus_trader.adapters.binance import (
    BinanceLiveDataClientFactory, BinanceLiveExecClientFactory)

nodo = TradingNode(config=real)
nodo.add_data_client_factory(BINANCE, BinanceLiveDataClientFactory)
nodo.add_exec_client_factory(BINANCE, BinanceLiveExecClientFactory)
nodo.trader.add_strategy(MomVol(MomVolConfig(
    tickers=("BTCUSDT", "ETHUSDT"),   # con venue y barras por config
    external_order_claims=["BTCUSDT.BINANCE", "ETHUSDT.BINANCE"])))
nodo.build()
try:
    nodo.run()           # bloquea; Ctrl+C para parar con orden
finally:
    nodo.dispose()

La clase MomVol de la lección no serviría tal cual para cripto: su VENUE y sus tipos de barra están fijados a la NYSE, y la decisión diaria supone un cierre que en cripto no existe. Eso no rompe la promesa del mismo código; dice que la estrategia debe recibir de su configuración todo lo que depende del mercado (instrumentos, tipo de barra, hora de decisión). Para acciones con Interactive Brokers, el esqueleto de la lección 2.4.5 y el ejemplo oficial de sandbox con datos de IB y ejecución simulada son el camino: primero sandbox con datos reales del broker, después cuenta de papel, después dinero.

LEAN en real

LEAN sigue el mismo camino con otra maquinaria. El algoritmo (subclase de QCAlgorithm, lección 2.4.6) es el mismo; lo que cambia es el comando y el broker. En local, lean live deploy arranca el motor en un contenedor Docker con la imagen quantconnect/lean y, si no se le dan opciones, pregunta con un asistente qué broker y qué proveedor de datos usar. La CLI exige pertenecer a una organización de pago de QuantConnect. Este curso no lo ejecuta (no hay Docker en su entorno):

lean live deploy "MomVol" --brokerage "Paper Trading" --data-provider-live "Interactive Brokers"
lean live deploy "MomVol" --environment "ib-papel" --detach
lean cloud live deploy "MomVol" --push --open

La primera usa el broker de papel de QuantConnect con datos de Interactive Brokers (la página de la CLI documenta el resto de opciones de IB: usuario, cuenta y datos diferidos). La segunda toma toda la configuración de un entorno de lean.json con live-mode activado y deja el contenedor en segundo plano. La tercera despliega en la nube de QuantConnect. Los resultados se escriben en tiempo real, en JSON, en MomVol/live/<fecha y hora>3Nota 3 · LEAN CLI, «lean live deploy» (https://www.quantconnect.com/docs/v2/lean-cli/api-reference/lean-live-deploy) y «QuantConnect Paper Trading» (https://www.quantconnect.com/docs/v2/lean-cli/live-trading/brokerages/quantconnect-paper-trading): contenedor con la imagen quantconnect/lean, asistente por defecto, opciones --brokerage, --data-provider-live, --environment, --detach, resultados en <proyecto>/live/<fecha> y requisito de organización de pago. Consultadas el 22 de octubre de 2026..

Lo específico del modo real está en el algoritmo:

IN [12]main.pypython
from AlgorithmImports import *
import json


class MomVol(QCAlgorithm):

    def initialize(self) -> None:
        self.set_cash(1_000_000)
        self._etfs = [self.add_equity(t, Resolution.DAILY).symbol
                      for t in ["SPY", "IWM", "EFA", "EEM", "IEF",
                                "TLT", "LQD", "GLD", "DBC", "VNQ"]]
        self.schedule.on(
            self.date_rules.every_day("SPY"),
            self.time_rules.before_market_close("SPY", 15),
            self._rebalanceo)
        # estado propio: LEAN no lo guarda entre despliegues
        self._clave = f"{self.project_id}/momvol.json"
        self._estado = {"rebalanceos": 0}
        almacen = self.object_store
        if self.live_mode and almacen.contains_key(self._clave):
            self._estado = json.loads(almacen.read(self._clave))

    def _rebalanceo(self) -> None:
        ...                                   # la misma regla
        self._estado["rebalanceos"] += 1
        if self.live_mode:
            self.object_store.save(self._clave,
                                   json.dumps(self._estado))

    def on_brokerage_disconnect(self) -> None:
        self.debug("Sin conexión con el broker")

    def on_brokerage_reconnect(self) -> None:
        self.debug("Conexión restablecida: LEAN sincroniza las órdenes")

    def on_brokerage_message(self, message_event) -> None:
        self.debug(f"Broker: {message_event.message}")

Tres frases de la documentación de QuantConnect resumen el modelo4Nota 4 · QuantConnect, «Live Trading > Key Concepts» (https://www.quantconnect.com/docs/v2/writing-algorithms/live-trading/key-concepts) y «Live Trading > Brokerages» (https://www.quantconnect.com/docs/v2/writing-algorithms/live-trading/brokerages): estado, carga de la cartera desde el broker, on_brokerage_disconnect, on_brokerage_reconnect y on_brokerage_message; Object Store en https://www.quantconnect.com/docs/v2/writing-algorithms/object-store.: «There is no automatic state management of live strategies»; la cartera en real se carga de las posiciones del broker, incluidas las órdenes abiertas; y el algoritmo no debe suponer que empieza sin posiciones, porque puede tener las de un despliegue anterior. Al reconectar, LEAN sincroniza el estado de las órdenes, pero no genera eventos de lo que pasó durante el corte (una límite ejecutada sin conexión aparece en la cartera sin su on_order_event). Es el mismo problema de las lecciones 4.5.2 y 4.5.5, resuelto a medias por el motor: lo que el broker informa se alinea solo; el estado propio del algoritmo (aquí, un contador en el Object Store) es responsabilidad de quien lo escribe.

NautilusTrader 1.231.0LEAN
Lenguaje del motorRust y Cython; estrategia en PythonC#; algoritmo en C# o Python
Despliegue en localun proceso Python (TradingNode)contenedor Docker (lean live deploy)
Ejecución simulada con datos realesSandboxExecutionClientbroker «Paper Trading»
Reconciliación al arrancarórdenes, ejecuciones y posiciones; lo desconocido como EXTERNALcartera y órdenes abiertas del broker
Estado propio entre reinicioscaché en Redis (CacheConfig)Object Store
Coste de la herramientaLGPL-3.0, sin cuotamotor Apache-2.0; la CLI pide organización de pago

Antes de poner dinero

Lo que la lección ha probado y lo que queda, como lista de comprobación para cualquier motor:

PasoQué demuestraHecho en la lección
Backtest con la reglala lógica decide lo previstosí: posiciones como las de la cuenta real
Backtest frente a real (o paper)cuánto cuestan los costes no modeladossí: 0,22 % en 56 sesiones
Sandbox con datos reproducidosla estrategia funciona dentro del motor en vivosí: 228 ejecuciones idénticas
Reconciliación con posiciones ajenasel arranque recoge lo que dice el brokersí: 187 ORAU como EXTERNAL y reclamadas
Sandbox con datos reales del brokerdatos en el orden y el formato realesno: necesita red
Cuenta de papel del brokerórdenes, rechazos y ejecuciones realesno: necesita cuenta
Real con tamaño mínimotodo lo anterior con dinerono
Reinicio a mitad de sesiónestado persistente y reconciliaciónparcial: lección 4.5.5

Cada paso puede revelar algo que el anterior no podía, igual que el sandbox reveló aquí tres diferencias que el backtest escondía. Saltarse uno es aceptar que lo encuentre el dinero.

Ejercicios

  1. Cambia el umbral de la estrategia del 0,4 % al 1 % del patrimonio y vuelve a ejecutar el backtest. ¿Cuántas ejecuciones menos hay y cuánto cambia el patrimonio final? ¿Qué no puedes saber con este backtest sobre el efecto en la cuenta real?
Solución
IN [13]python
def backtest(**kw):
    m = BacktestEngine(BacktestEngineConfig(
        logging=LoggingConfig(log_level="ERROR")))
    m.add_venue(venue=VENUE, oms_type=OmsType.NETTING,
                account_type=AccountType.CASH, base_currency=USD,
                starting_balances=[Money(1_000_000, USD)])
    for i in inst.values():
        m.add_instrument(i)
    m.add_data(barras)
    m.add_strategy(MomVol(MomVolConfig(tickers=tuple(ETF), **kw)))
    m.run()
    f = informe_fills(m.trader)
    p = (f.pivot_table(index="fecha", columns="sym", values="q",
                       aggfunc="sum").fillna(0).cumsum())
    caja = m.trader.generate_account_report(VENUE)["total"]
    fin = (p.iloc[-1] * precios.iloc[-1][p.columns]).sum()
    return len(f), fin + float(caja.iloc[-1])

for umbral in (0.004, 0.01):
    n, pat = backtest(umbral=umbral)
    print(f"umbral {umbral:.1%}: {n} ejecuciones, "
          f"patrimonio {pat:,.2f}")
OUT [13]Un umbral de rebalanceo más alto
umbral 0.4%: 228 ejecuciones, patrimonio 1,044,525.05
umbral 1.0%: 195 ejecuciones, patrimonio 1,046,976.45

Con un umbral del 1 % hay 33 ejecuciones menos (195 frente a 228) y el patrimonio sin costes sube 2.451 USD. Esa diferencia no es una ventaja del umbral: en 56 sesiones y con una sola trayectoria, que la cartera menos ajustada acabe arriba es tan probable como que acabe abajo. Lo que este backtest no puede decir es cuánto ahorra en la cuenta real, porque no tiene costes: con la brecha medida en la lección (unos 2.100 USD de precio y 596 de comisiones en 228 ejecuciones), cada ejecución evitada vale del orden de 10 USD, y eso solo se convierte en una cifra fiable con un modelo de costes calibrado o con otra temporada de cuenta real.

  1. Ejecuta el nodo de sandbox con el límite de envío por defecto (quita risk_engine de TradingNodeConfig). ¿Qué pasa? ¿Es determinista el resultado?
Solución

Una parte de las órdenes sale DENIED con «Exceeded MAX_ORDER_SUBMIT_RATE» y la estrategia acaba con otra cartera. No es determinista: cuántas se deniegan depende de cuántas órdenes caben en un segundo de reloj real, es decir, de la velocidad de la máquina y de su carga en ese momento. Por eso la lección sube el límite para la reproducción (y no se ejecuta aquí este ejercicio): el resultado de una lección, como el de una prueba, no puede depender del reloj. En real, la estrategia envía como mucho diez órdenes al día y el límite por defecto es una protección útil contra un error que dispare órdenes en bucle.

  1. Escribe on_stop para MomVol de forma que, al parar el nodo, no deje órdenes vivas pero tampoco cierre la cartera. ¿Por qué no conviene el close_all_positions que usaban las estrategias de la lección 2.4.5?
Solución
IN [14]python
class MomVolConParada(MomVol):
    def on_stop(self) -> None:
        for t in self.config.tickers:
            self.cancel_all_orders(self.iid(t))   # nada vivo

m = BacktestEngine(BacktestEngineConfig(
    logging=LoggingConfig(log_level="ERROR")))
m.add_venue(venue=VENUE, oms_type=OmsType.NETTING,
            account_type=AccountType.CASH, base_currency=USD,
            starting_balances=[Money(1_000_000, USD)])
for i in inst.values():
    m.add_instrument(i)
m.add_data(barras)
m.add_strategy(MomVolConParada(MomVolConfig(tickers=tuple(ETF))))
m.run()
abiertas = m.cache.positions_open()
print(len(abiertas), "posiciones abiertas tras parar:",
      sorted(p.instrument_id.symbol.value for p in abiertas))
OUT [14]Parar sin deshacer la cartera
1 posiciones abiertas tras parar: ['ORAU']

Tras parar queda abierta la posición de ORAU (el 31 de diciembre la regla había vendido todo lo demás, igual que la cuenta real) y ninguna orden viva. En un backtest, cerrar todo al final sirve para medir un resultado realizado; en real, on_stop se ejecuta en cada parada del nodo (una actualización, un reinicio programado) y cerrar la cartera cada vez supondría vender y volver a comprar, con sus costes y su riesgo, por un motivo que no tiene que ver con la estrategia. Lo que sí debe hacer la parada es no dejar órdenes que se ejecuten sin nadie mirando; al arrancar, la reconciliación recupera la cartera.

Footnotes

  1. NautilusTrader 1.231.0, nautilus_trader/adapters/sandbox/execution.py: connect() se suscribe a data.*.{venue}.* y on_data() pasa cotizaciones, operaciones, libro y barras al mercado simulado; los temas de barras del bus son data.bars.{bar_type}. Ejemplos oficiales: examples/sandbox/interactive_brokers_sandbox.py (estrategia con subscribe_quote_ticks=True). Comprobado ejecutando el código de la lección. ↩

  2. NautilusTrader 1.231.0, docs/concepts/reconciliation.md: «Leave reconciliation_lookback_mins unset», órdenes EXTERNAL con etiqueta RECONCILIATION, external_order_claims, jerarquía de precios de las órdenes generadas («MARKET order (last resort)») y recomendación de persistir los eventos en la base de datos de la caché. ↩ ↩2 ↩3

  3. LEAN CLI, «lean live deploy» (https://www.quantconnect.com/docs/v2/lean-cli/api-reference/lean-live-deploy) y «QuantConnect Paper Trading» (https://www.quantconnect.com/docs/v2/lean-cli/live-trading/brokerages/quantconnect-paper-trading): contenedor con la imagen quantconnect/lean, asistente por defecto, opciones --brokerage, --data-provider-live, --environment, --detach, resultados en <proyecto>/live/<fecha> y requisito de organización de pago. Consultadas el 22 de octubre de 2026. ↩

  4. QuantConnect, «Live Trading > Key Concepts» (https://www.quantconnect.com/docs/v2/writing-algorithms/live-trading/key-concepts) y «Live Trading > Brokerages» (https://www.quantconnect.com/docs/v2/writing-algorithms/live-trading/brokerages): estado, carga de la cartera desde el broker, on_brokerage_disconnect, on_brokerage_reconnect y on_brokerage_message; Object Store en https://www.quantconnect.com/docs/v2/writing-algorithms/object-store. ↩

Respuesta plegada en cada pregunta

Autoevaluación

  1. La estrategia de la lección, ejecutada en el BacktestEngine con la regla del sistema en vivo, termina el 31 de diciembre con 1.044.525 USD; la cuenta real de los datos, con 1.042.307. ¿De dónde sale sobre todo la diferencia?

    • ADe los costes que el backtest no tiene (comisiones, horquilla y deslizamiento) y de ejecutar a otra hora y a otro precio que el cierre
    • BDe un error del backtest
    • CDe la orden duplicada
    • DDel desdoblamiento de ORAU
    Ver respuesta

    Respuesta A. De los costes que el backtest no tiene (comisiones, horquilla y deslizamiento) y de ejecutar a otra hora y a otro precio que el cierre

    Las posiciones del backtest y de la cuenta real difieren en unos pocos títulos al día, por el precio de referencia (medio a las 15:45 frente al cierre) y las órdenes de los lunes. Lo que separa el dinero son los 596 USD de comisiones, la horquilla y el deslizamiento de cada ejecución. El desdoblamiento no cambia el valor de la posición.

  2. El SandboxExecutionClient de NautilusTrader 1.231.0 recibe los datos que publica el nodo. Si el cliente de datos solo publica barras, ¿qué pasa con las órdenes a mercado?

    • ASe ejecutan al cierre de la barra, como en el backtest
    • BSe rechazan con «no market»: el sandbox escucha cotizaciones, operaciones y libro, no barras
    • CSe quedan pendientes hasta la barra siguiente
    • DEl nodo no arranca
    Ver respuesta

    Respuesta B. Se rechazan con «no market»: el sandbox escucha cotizaciones, operaciones y libro, no barras

    El cliente del sandbox se suscribe a data.*.{venue}.*, que casa con los temas de cotizaciones y operaciones (data.quotes.XNYS.ORUS) pero no con los de barras (data.bars.ORUS.XNYS-1-DAY-…). Por eso los ejemplos oficiales de sandbox se suscriben a cotizaciones; en la lección, el cliente de datos publica una cotización con cada barra.

  3. Reproduciendo 128 sesiones en un nodo en vivo sin esperar entre ellas, muchas órdenes salen DENIED con «Exceeded MAX_ORDER_SUBMIT_RATE». ¿Por qué no pasa en el backtest?

    • APorque el backtest no tiene motor de riesgo
    • BPorque el sandbox tiene otro límite
    • CPorque el límite (100 órdenes por segundo por defecto) se mide con el reloj: en el backtest el reloj es el de los datos y las órdenes están a días de distancia; en vivo es el reloj real y la reproducción acelerada mete cientos de órdenes en un segundo
    • DPorque las órdenes del backtest son límite
    Ver respuesta

    Respuesta C. Porque el límite (100 órdenes por segundo por defecto) se mide con el reloj: en el backtest el reloj es el de los datos y las órdenes están a días de distancia; en vivo es el reloj real y la reproducción acelerada mete cientos de órdenes en un segundo

    El motor de riesgo es el mismo en los dos modos, pero su reloj no. Una reproducción acelerada no es un mercado real; en real, esta estrategia envía como mucho 10 órdenes al día. La lección sube el límite para la reproducción y lo dice.

  4. Al arrancar el nodo en vivo, el broker informa de una posición de 187 ORAU que la caché del sistema no tiene. ¿Qué hace NautilusTrader con la reconciliación activada?

    • AIgnora la posición
    • BVende la posición
    • CSe niega a arrancar
    • DGenera una orden y una ejecución con estrategia EXTERNAL para que la caché refleje la posición del broker; con external_order_claims, una estrategia puede reclamarla como suya
    Ver respuesta

    Respuesta D. Genera una orden y una ejecución con estrategia EXTERNAL para que la caché refleje la posición del broker; con external_order_claims, una estrategia puede reclamarla como suya

    La reconciliación alinea la caché con los informes del venue antes de arrancar las estrategias. Lo que no reconoce lo crea con estrategia EXTERNAL; si una estrategia declara el instrumento en external_order_claims, la orden y la posición pasan a ser suyas.

  5. Un algoritmo de LEAN guarda en una variable el número de días que lleva en una posición. Se reinicia el despliegue en real. ¿Qué pasa?

    • ALEAN restaura la variable
    • BLEAN cierra las posiciones al arrancar
    • CEl algoritmo no puede reiniciarse
    • DLa cartera se carga de las posiciones del broker, pero la variable empieza de cero: el estado propio hay que guardarlo (por ejemplo, en el Object Store) y reconstruirlo
    Ver respuesta

    Respuesta D. La cartera se carga de las posiciones del broker, pero la variable empieza de cero: el estado propio hay que guardarlo (por ejemplo, en el Object Store) y reconstruirlo

    La documentación de QuantConnect lo dice: no hay gestión automática del estado de una estrategia en real, la cartera se carga de las posiciones del broker y el algoritmo no debe suponer que empieza sin posiciones. Lo demás debe persistirlo el propio algoritmo.

Ideas clave

  • «El mismo código» se cumple para la estrategia: la clase que decide no cambia entre backtest, sandbox y real. Cambian el reloj, los clientes de datos y de ejecución, el motor de riesgo en tiempo real y la reconciliación.
  • En la cuenta real de los datos, el backtest de la misma regla acierta las posiciones con diferencias de pocos títulos; la diferencia de dinero (2.219 USD en 56 sesiones, un 0,22 % del capital) son los costes y la hora de ejecución.
  • Un TradingNode con un cliente de datos que reproduce historia y el SandboxExecutionClient ejecuta la estrategia con el motor en vivo y da las mismas 228 ejecuciones que el backtest, si los datos llegan a su ritmo.
  • El sandbox enseña lo que el backtest esconde: colas y reloj real, límites de envío por segundo, identificadores con la hora de la máquina y un simulador que necesita cotizaciones.
  • Al arrancar, NautilusTrader concilia la caché con el broker antes de activar las estrategias; lo desconocido aparece como EXTERNAL y puede reclamarse.
  • LEAN despliega el mismo algoritmo con lean live deploy en Docker; carga cartera y órdenes del broker, avisa de desconexiones con on_brokerage_disconnect y deja al algoritmo su propio estado.

Recursos

Datos