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.
- Módulo
- 4.5 · Sistema de trading en vivo
- Duración
- 75 min
Lecciones del nivel 4
4.1 · Teoría y optimización de carteras
- 4.1.1 Markowitz: frontera eficiente y restricciones reales
- 4.1.2 Estimación de covarianzas: shrinkage, modelos factoriales y denoising
- 4.1.3 Black-Litterman: del equilibrio del mercado a las visiones
- 4.1.4 Risk parity, mínima varianza, HRP y HERC
- 4.1.5 Optimización con CVaR, drawdown, rotación y costes
- 4.1.6 Modelos factoriales: CAPM, Fama-French, PCA y atribución
- 4.1.7 Rebalanceo y carteras de estrategias
- 4.1.8 Herramientas de carteras: Riskfolio-Lib, PyPortfolioOpt, skfolio y bt
4.2 · Gestión del riesgo y tamaño de posición
- 4.2.1 Riesgo por operación, stops y riesgo de ruina
- 4.2.2 Criterio de Kelly y volatility targeting: cuánto arriesgar
- 4.2.3 VaR, Expected Shortfall y backtesting del VaR
- 4.2.4 Stress testing y riesgos de correlación, liquidez, gap y contraparte
- 4.2.5 Control del drawdown y kill switch
- 4.2.6 Riesgo de derivados: griegas de cartera, margen y liquidaciones
4.3 · Microestructura y ejecución
4.4 · Brokers y APIs
- 4.4.1 Elegir bróker desde España o la UE: regulación, activos, costes y API
- 4.4.2 Interactive Brokers: TWS e IB Gateway, ib_async, contratos, datos y órdenes
- 4.4.3 Alpaca, Tradier, MetaTrader 5 con Python y OANDA v20
- 4.4.4 APIs de exchanges cripto: ccxt, Binance, Bybit, Kraken y Coinbase
- 4.4.5 Credenciales seguras: variables de entorno, gestores de secretos y permisos mínimos
4.5 · Sistema de trading en vivo
- 4.5.1 Arquitectura de un sistema en vivo: de los datos a la reconciliación
- 4.5.2 Gestión de órdenes: estados, fills parciales, cancelaciones y reconexiones
- 4.5.3 Paper trading, shadow trading y seguimiento del slippage
- 4.5.4 Programación de tareas y alertas: cron, APScheduler, Prefect, Telegram, Slack y email
- 4.5.5 Tolerancia a fallos: idempotencia, estado persistente y reconciliación al arrancar
- 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:
| Backtest | Sandbox | Real | |
|---|---|---|---|
| Motor | BacktestEngine | TradingNode | TradingNode |
| Reloj | el de los datos (simulado) | el de la máquina | el de la máquina |
| Datos | lista histórica | cliente de datos en vivo | cliente de datos en vivo |
| Ejecución | mercado simulado | mercado simulado (SandboxExecutionClient) | cliente del broker |
| Reconciliación al arrancar | no existe | con el simulado (vacío) | con el broker |
| Fallos de red y del broker | no hay | no hay | los 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).
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())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_filleda 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.
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:
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))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.08El 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):
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})")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:
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}")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.77La 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:
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:
- 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 adata.*.{venue}.*yon_data()pasa cotizaciones, operaciones, libro y barras al mercado simulado; los temas de barras del bus sondata.bars.{bar_type}. Ejemplos oficiales:examples/sandbox/interactive_brokers_sandbox.py(estrategia consubscribe_quote_ticks=True). Comprobado ejecutando el código de la lección.. - El motor en vivo trabaja con colas. Datos, comandos y eventos entran en colas
asyncioy 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. - El motor de riesgo mide con el reloj real.
LiveRiskEngineConfiglimita por defecto el envío a 100 órdenes por segundo. Reproduciendo meses en segundos, el límite salta y las órdenes salenDENIED; en el backtest no pasa porque el reloj es el de los datos. La celda sube el límite solo para la reproducción.
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()))228 ejecuciones en sandbox; ¿las mismas que en el backtest? True
{'FILLED': 228}
ids de orden comunes con el backtest: 0Las 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:
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}")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 FalseReconciliació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.
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.
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)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:
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])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:
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)BINANCE SPOT TESTNET | claves en la config: False
caché en redis | reconciliación: TrueLas 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:
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 --openLa 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:
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.0 | LEAN | |
|---|---|---|
| Lenguaje del motor | Rust y Cython; estrategia en Python | C#; algoritmo en C# o Python |
| Despliegue en local | un proceso Python (TradingNode) | contenedor Docker (lean live deploy) |
| Ejecución simulada con datos reales | SandboxExecutionClient | broker «Paper Trading» |
| Reconciliación al arrancar | órdenes, ejecuciones y posiciones; lo desconocido como EXTERNAL | cartera y órdenes abiertas del broker |
| Estado propio entre reinicios | caché en Redis (CacheConfig) | Object Store |
| Coste de la herramienta | LGPL-3.0, sin cuota | motor 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:
| Paso | Qué demuestra | Hecho en la lección |
|---|---|---|
| Backtest con la regla | la lógica decide lo previsto | sí: posiciones como las de la cuenta real |
| Backtest frente a real (o paper) | cuánto cuestan los costes no modelados | sí: 0,22 % en 56 sesiones |
| Sandbox con datos reproducidos | la estrategia funciona dentro del motor en vivo | sí: 228 ejecuciones idénticas |
| Reconciliación con posiciones ajenas | el arranque recoge lo que dice el broker | sí: 187 ORAU como EXTERNAL y reclamadas |
| Sandbox con datos reales del broker | datos en el orden y el formato reales | no: necesita red |
| Cuenta de papel del broker | órdenes, rechazos y ejecuciones reales | no: necesita cuenta |
| Real con tamaño mínimo | todo lo anterior con dinero | no |
| Reinicio a mitad de sesión | estado persistente y reconciliación | parcial: 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
- 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
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}")umbral 0.4%: 228 ejecuciones, patrimonio 1,044,525.05
umbral 1.0%: 195 ejecuciones, patrimonio 1,046,976.45Con 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.
- Ejecuta el nodo de sandbox con el límite de envío por defecto (quita
risk_enginedeTradingNodeConfig). ¿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.
- Escribe
on_stopparaMomVolde forma que, al parar el nodo, no deje órdenes vivas pero tampoco cierre la cartera. ¿Por qué no conviene elclose_all_positionsque usaban las estrategias de la lección 2.4.5?
Solución
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))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
-
NautilusTrader 1.231.0,
nautilus_trader/adapters/sandbox/execution.py:connect()se suscribe adata.*.{venue}.*yon_data()pasa cotizaciones, operaciones, libro y barras al mercado simulado; los temas de barras del bus sondata.bars.{bar_type}. Ejemplos oficiales:examples/sandbox/interactive_brokers_sandbox.py(estrategia consubscribe_quote_ticks=True). Comprobado ejecutando el código de la lección. ↩ -
NautilusTrader 1.231.0,
docs/concepts/reconciliation.md: «Leavereconciliation_lookback_minsunset», órdenesEXTERNALcon etiquetaRECONCILIATION,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 -
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. ↩ -
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_reconnectyon_brokerage_message; Object Store en https://www.quantconnect.com/docs/v2/writing-algorithms/object-store. ↩
Respuesta plegada en cada pregunta
Autoevaluación
La estrategia de la lección, ejecutada en el
BacktestEnginecon 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.
El
SandboxExecutionClientde 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.Reproduciendo 128 sesiones en un nodo en vivo sin esperar entre ellas, muchas órdenes salen
DENIEDcon «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.
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
EXTERNALpara que la caché refleje la posición del broker; conexternal_order_claims, una estrategia puede reclamarla como suya
Ver respuesta
Respuesta D. Genera una orden y una ejecución con estrategia
EXTERNALpara que la caché refleje la posición del broker; conexternal_order_claims, una estrategia puede reclamarla como suyaLa 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 enexternal_order_claims, la orden y la posición pasan a ser suyas.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
TradingNodecon un cliente de datos que reproduce historia y elSandboxExecutionClientejecuta 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
EXTERNALy puede reclamarse. - LEAN despliega el mismo algoritmo con
lean live deployen Docker; carga cartera y órdenes del broker, avisa de desconexiones conon_brokerage_disconnecty deja al algoritmo su propio estado.
Recursos
Research
Documentación
- NautilusTrader 1.231.0: Live Trading (guía en el repositorio, etiqueta v1.231.0)
- NautilusTrader 1.231.0: Execution Reconciliation
- NautilusTrader 1.231.0: ejemplos de sandbox (Interactive Brokers, Binance, Bybit…)
- LEAN CLI: lean live deploy (opciones)
- QuantConnect: Live Trading, Key Concepts (estado y reinicios)