Lesson 4.5.6 · 75 min
NautilusTrader or LEAN live: from backtest to production with the same code
The promise of NautilusTrader and LEAN is that the backtest strategy is the one that trades. This lesson puts it to the test with the rule of the level 4 live system: it writes it once as a NautilusTrader strategy, runs it in the backtest engine, compares it with the live account in the data and runs it again in a live node with simulated execution, with no network. Then it reviews what changes when connecting a real broker and how to do the same with LEAN.
- Module
- 4.5 · Live trading system
- Duration
- 75 min
- Prerequisites
- 2.4.5 · NautilusTrader: architecture, data, strategies and the same code in backtest and live, 2.4.6 · LEAN/QuantConnect and Zipline Reloaded; bt for asset allocation, 4.5.5 · Fault tolerance: idempotency, persistent state and reconciliation at startup
- Author
- Jesús Cuesta
- Updated
Level 4 lessons
4.1 · Portfolio theory and optimisation
- 4.1.1 Markowitz: efficient frontier and real constraints
- 4.1.2 Covariance estimation: shrinkage, factor models and denoising
- 4.1.3 Black-Litterman: from market equilibrium to views
- 4.1.4 Risk parity, minimum variance, HRP and HERC
- 4.1.5 Optimization with CVaR, drawdown, turnover and costs
- 4.1.6 Factor models: CAPM, Fama-French, PCA and attribution
- 4.1.7 Rebalancing and strategy portfolios
- 4.1.8 Portfolio tools: Riskfolio-Lib, PyPortfolioOpt, skfolio and bt
4.2 · Risk management and position sizing
- 4.2.1 Risk per trade, stops and risk of ruin
- 4.2.2 Kelly criterion and volatility targeting: how much to risk
- 4.2.3 VaR, Expected Shortfall and VaR backtesting
- 4.2.4 Stress testing and correlation, liquidity, gap and counterparty risk
- 4.2.5 Drawdown control and kill switch
- 4.2.6 Derivatives risk: portfolio Greeks, margin and liquidations
4.3 · Microstructure and execution
4.4 · Brokers and APIs
- 4.4.1 Choosing a broker from Spain or the EU: regulation, assets, costs and API
- 4.4.2 Interactive Brokers: TWS and IB Gateway, ib_async, contracts, data and orders
- 4.4.3 Alpaca, Tradier, MetaTrader 5 with Python and OANDA v20
- 4.4.4 Crypto exchange APIs: ccxt, Binance, Bybit, Kraken and Coinbase
- 4.4.5 Secure credentials: environment variables, secret managers and least privilege
4.5 · Live trading system
- 4.5.1 Live system architecture: from data to reconciliation
- 4.5.2 Order management: states, partial fills, cancellations and reconnections
- 4.5.3 Paper trading, shadow trading and slippage monitoring
- 4.5.4 Task scheduling and alerts: cron, APScheduler, Prefect, Telegram, Slack and email
- 4.5.5 Fault tolerance: idempotency, persistent state and reconciliation at startup
- 4.5.6 NautilusTrader or LEAN live: from backtest to production with the same code
What “the same code” means
Lesson 2.4.5 assembled NautilusTrader piece by piece and ended with a TradingNode skeleton for Interactive Brokers,
not run. Lesson 2.4.6 did the same with LEAN and its CLI. Both tools promise the same thing: the strategy tested in
the backtest is the one that trades live, without rewriting it. This lesson checks that promise with a concrete rule,
the one of the live system in lessons 4.5.1 to 4.5.5, and takes it as far as possible without a network or an
account: all the way to a real live node, with its real clock, its queues and its risk engine, and simulated
execution.
What changes between environments is not the strategy, but what surrounds it:
| Backtest | Sandbox | Live | |
|---|---|---|---|
| Engine | BacktestEngine | TradingNode | TradingNode |
| Clock | the data's (simulated) | the machine's | the machine's |
| Data | historical list | live data client | live data client |
| Execution | simulated market | simulated market (SandboxExecutionClient) | broker client |
| Reconciliation at startup | does not exist | with the simulated one (empty) | with the broker |
| Network and broker failures | none | none | those of lessons 4.5.2 and 4.5.5 |
The intermediate step, the sandbox, is what the NautilusTrader documentation calls live data with simulated execution: the whole production node, except that orders do not leave the machine. Here, in addition, the live data are supplied by a custom client that replays history, so that the lesson can run without a network and always give the same result.
The system's rule, as a strategy
The system's rule (README of the level 4 data): each session, with prices up to the previous day, weight
min(2.5% / vol_63, 25%) in the ETFs with positive 20-session momentum, normalized if they add up to more than 100%;
the difference with the position is traded if it exceeds 0.4% of equity. The ten ETFs are fictional and their price is
a rescaled index of the universe, as in the data generator. The daily bars carry the timestamp of each session's
actual close according to the NYSE calendar (lesson 4.5.4): 20:00 UTC in summer, 21:00 in winter and 17:00 or 18:00
on the three early closes in the period (July 3, November 28 and December 24).
import math
import warnings
import exchange_calendars as xc
import numpy as np
import pandas as pd
from pandas.errors import Pandas4Warning
# the engine calls Timestamp.utcnow(), deprecated in pandas 3:
# one warning per run, beyond our control (lesson 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), "sessions ·", len(barras), "bars")
print(pd.Series(cierres.dt.strftime("%H:%M UTC")).value_counts()
.to_dict())128 sessions · 1280 bars
{'20:00 UTC': 86, '21:00 UTC': 39, '18:00 UTC': 2, '17:00 UTC': 1}One hundred twenty-eight sessions from July 1 (the first ones serve to warm up the 63-session volatility) and one bar
per ETF and session. The bars are built directly with Bar(...) instead of with BarDataWrangler because, with
pandas 3, the wrangler fails on a DataFrame whose columns all have the same type (buffer source array is read-only, because of copy-on-write); the one in lesson 2.4.5 worked because it mixed types.
The strategy is a subclass of Strategy like those in 2.4.5, with two decisions that come from thinking about live
trading from the start:
- Buys wait for sells. With a cash account, the money from a sale is not available until it is executed. The
strategy sends the sells, waits in
on_order_filleduntil all of them are closed and then computes the buys with the free cash the account reports. In the backtest, everything happens at the same instant; live, seconds pass between one and the other, and a strategy that buys at the same time with money from unexecuted sales runs out of balance. - The position is queried, not stored. The size of each order comes from
self.portfolio.net_position(...)and from the account balance, which live come from the broker after reconciliation (lesson 4.5.5), not from a counter of its own.
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" # first trading session
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() # all 10 bars are in
def pesos(self):
w = {}
for t in self.config.tickers:
h = np.array(self.cierres[t][:-1]) # up to the previous day
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: # all sells closed
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: the same rule versus the live account
The backtest engine with a cash account of 1,000,000 USD, like the live account in the data, which started trading on October 13, 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, as a column
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), "fills from", fb["fecha"].min(),
"to", fb["fecha"].max())
print(fb[["fecha", "sym", "side", "q", "px"]].head(4)
.to_string(index=False))228 fills from 2025-10-13 to 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.08The live system in the data ran the same rule with different machinery: it decided at 15:45 with the mid price at that moment, sent market, limit or market-on-close orders and, on Mondays, half of the signals at the next open. Comparing each day's closing position in the backtest with the statement of the live account (with the ORAU split undone, lesson 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("sessions:", len(dif), "| differing shares per day, median:",
int(titulos.median()))
print("differing value per day, median: "
f"{valor.median():,.0f} USD; maximum {valor.max():,.0f} USD "
f"({valor.idxmax()}, {pd.Timestamp(valor.idxmax()):%A})")sessions: 56 | differing shares per day, median: 17
differing value per day, median: 1,443 USD; maximum 494,437 USD (2025-10-13, Monday)The same rule in NautilusTrader reproduces the live account: the median difference is 17 shares a day across the ten ETFs, some 1,400 USD of value in a portfolio of one million. They come from the price used to compute each order (the mid at 15:45 in the live system; the close in the backtest), which changes how the quantity is rounded. The large differences fall on Mondays, and the largest, almost 500,000 USD, on the first day: on Monday, October 13, the live system sent half of the buy orders to Tuesday's open, and for one day its portfolio was half the backtest's.
What separates the money is something else:
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} | live: "
f"{pat_re.iloc[-1]:,.2f} | gap: {brecha.iloc[-1]:,.2f}")
print(f"commissions: {-ops['comision'].sum():,.2f} | interest and "
f"fees: {otros['importe'].sum():+,.2f}")
resto = (brecha.iloc[-1] + ops["comision"].sum()
- otros["importe"].sum())
print(f"remainder (execution price and timing): {resto:,.2f}")backtest: 1,044,525.05 | live: 1,042,306.50 | gap: -2,218.55
commissions: -596.38 | interest and fees: +483.60
remainder (execution price and timing): -2,105.77The live account ends 2,218.55 USD below the backtest, 0.22% of capital over 56 sessions. Commissions are 596.38, and interest net of the data fee adds 483.60 in its favor; the rest, about 2,100 USD, is price: the spread and slippage of each market execution, the limit price of the passive orders and the difference between executing at 15:50, at Tuesday's open or at the close. It is the measurement of lesson 4.5.3 seen from the equity side. The header figure follows the gap day by day: it grows in steps, one for each rebalance. On two days it recovers part of it (Tuesday, November 4 and Tuesday, December 23): those are days when the live system executed Monday's orders at the open and its portfolio was different for a day, for the better. The trend is one-way: the cost of executing is paid on every trade and is not given back.
The backtest, then, has validated the logic (it decides the same as the live system) and has measured what it does not model. For it to be right in money as well, it needs a commission model with the broker's fee schedule and slippage like the one measured (lessons 2.4.5 and 2.4.7). What follows is the other half: that the code that decides works the same inside the live engine.
Sandbox: the same class in a live node
A TradingNode needs a data client and an execution client. For execution, NautilusTrader ships the
SandboxExecutionClient, which puts a simulated market (the backtest's SimulatedExchange) behind the interface of a
live client. For data, a custom client, a subclass of LiveMarketDataClient, which, when the strategy subscribes,
replays the lesson's bars as if they came from the market:
Mermaid source
flowchart TB R[Replay client] --> DE[DataEngine] DE --> ST[MomVol] DE --> SX[Sandbox] ST --> RE[RiskEngine] RE --> EE[ExecEngine] EE --> SX SX --> EE EE --> ST
Three things came up when setting it up with version 1.231.0, and all three are real differences between the backtest and live mode:
- The sandbox does not see the bars. It subscribes on the bus to the
data.*.XNYS.*topics, which match quotes (data.quotes.XNYS.ORUS) and trades, but not bars (data.bars.ORUS.XNYS-1-DAY-…). With bars only, all orders are rejected with “no market”. The lesson's client publishes, before each bar, a quote with bid and ask at the close; the official sandbox examples, for the same reason, subscribe to quotes1Note 1 · NautilusTrader 1.231.0,nautilus_trader/adapters/sandbox/execution.py:connect()subscribes todata.*.{venue}.*andon_data()passes quotes, trades, book and bars to the simulated market; the bus topics for bars aredata.bars.{bar_type}. Official examples:examples/sandbox/interactive_brokers_sandbox.py(strategy withsubscribe_quote_ticks=True). Verified by running the lesson's code.. - The live engine works with queues. Data, commands and events go into
asyncioqueues and are processed in turn. If the client publishes the sessions all at once, a day's orders reach the simulator when it already has the prices of later days. The client waits for the system to finish with each session (bars processed and no order left open) before publishing the next one: events, not timed waits. Live, a day passes between one close and the next. - The risk engine measures with the real clock.
LiveRiskEngineConfiglimits submission to 100 orders per second by default. Replaying months in seconds, the limit trips and orders come outDENIED; in the backtest it does not happen because the clock is the data's. The cell raises the limit for the replay only.
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):
"""Publishes the lesson's bars, one session each time the
node has finished with the previous one."""
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): # the strategy is ready
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): # events, not time
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"), # replay only
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), "fills in the sandbox; the same as in the "
"backtest?", igual)
estados = pd.Series([o.status_string() for o in nodo.cache.orders()])
print(estados.value_counts().to_dict())
print("order ids shared with the backtest:",
int(fs["client_order_id"].isin(fb["client_order_id"]).sum()))228 fills in the sandbox; the same as in the backtest? True
{'FILLED': 228}
order ids shared with the backtest: 0The same fills, share for share and price for price, with the strategy running inside the live node: its queued
DataEngine, its real-time RiskEngine, its ExecutionEngine and an execution client that behaves like a broker's.
What is not the same is the identifiers: the client_order_id that NautilusTrader generates carries the date and
time of the clock (O-20251013-… in the backtest, with the data's time; in the node, with the time of the machine
running it). That is why the cell does not print them: they change on every run. It is a detail with consequences: an
idempotency key derived from the clock does not survive a restart (lesson 4.5.5); for that, NautilusTrader offers
order_id_tag and cache persistence.
The default configuration of live mode shows what the node watches over besides the strategy:
from nautilus_trader.config import LiveExecEngineConfig
ex, rk, nd = LiveExecEngineConfig(), LiveRiskEngineConfig(), \
TradingNodeConfig()
for nombre, valor in [
("reconciliation at startup", ex.reconciliation),
("history window (min)", ex.reconciliation_lookback_mins),
("generate missing orders", ex.generate_missing_orders),
("check in-flight orders every (ms)",
ex.inflight_check_interval_ms),
("…and treat them as lost after (ms)",
ex.inflight_check_threshold_ms),
("order submit rate limit", rk.max_order_submit_rate),
("max reconciliation wait (s)",
nd.timeout_reconciliation),
("load saved state", nd.load_state)]:
print(f"{nombre:38} {valor}")reconciliation at startup True
history window (min) None
generate missing orders True
check in-flight orders every (ms) 2000
…and treat them as lost after (ms) 5000
order submit rate limit 100/00:00:01
max reconciliation wait (s) 30.0
load saved state FalseReconciliation at startup enabled and with no history limit (the documentation recommends leaving it that way to ask
the venue for everything it has2Note 2 · NautilusTrader 1.231.0, docs/concepts/reconciliation.md: “Leave reconciliation_lookback_mins unset”,
EXTERNAL orders with the RECONCILIATION tag, external_order_claims, price hierarchy of the generated orders
(“MARKET order (last resort)”) and the recommendation to persist events in the cache database.), in-flight orders checked every 2 seconds and queried if 5 pass with no
response, 100 orders per second as a brake, and 30 seconds of leeway to reconcile before giving up. No saved state:
persistence (a Redis cache) has to be configured separately.
The figure puts those values on a timeline (the phase durations are illustrative): the strategies do not start until reconciliation finishes, and a sent order that gets no response is neither taken as good nor as lost, but checked every 2 seconds and, after 5, the venue is asked about it. It is the rule of lesson 4.5.2 (“query before resending”), turned into configuration.
Reconciliation at startup
Lesson 4.5.5 reconciled by hand. NautilusTrader does it on its own when starting the node, before activating the strategies: it asks the execution client for three reports (order status, fills and positions) and aligns its cache with them. To test it without a broker, it is enough to have a sandbox client whose position report says something the system does not know: that the account holds 187 ORAU, like the live account in the data before the split.
A detail of version 1.231.0: the node's constructor only passes the portfolio (portfolio) to a client factory named
exactly SandboxLiveExecClientFactory (it compares the class name), so the custom factory carries that name.
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() # after reconciliation
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("positions:", pos)
for o in ordenes:
print("order:", o)positions: [('ORAU.XNYS', 'LONG', 187.0, 'EXTERNAL')]
order: ('EXTERNAL', 'LIMIT', 'BUY', 'FILLED', ['OrderInitialized', 'OrderAccepted', 'OrderFilled'])The node starts, requests the reports and finds a position of 187 ORAU that its cache did not have. For the cache to
reflect the broker, it generates an order with strategy EXTERNAL (initialized, accepted and filled in the same
step) and the corresponding position; only then does it call the strategy's on_start. According to the
documentation, when there is a price to do it with, the generated order is a limit order, and a market order only as
a last resort2.
An EXTERNAL position belongs to no strategy: it counts for the portfolio and risk, but nobody manages it. If the
strategy has to take charge of what the broker says is there (the normal case after a restart without saved state),
it declares so with 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("positions:", pos)
print("order:", ordenes[0][:4])positions: [('ORAU.XNYS', 'LONG', 187.0, 'MomVol-001')]
order: ('MomVol-001', 'LIMIT', 'BUY', 'FILLED')With the claim, the generated order and position become MomVol-001's, and the strategy manages them as if it had
opened them itself. It is the automatic version of lesson 4.5.5, with the same limits: NautilusTrader's
reconciliation compares orders, fills and positions that the broker reports, but knows nothing about a signal that
asked for 7 shares and produced 14 (the duplicate order) nor about a split if the adapter does not report it. Those
checks are still the system's job.
Going live with NautilusTrader
With the strategy tested in backtest and in sandbox, going live changes two things: the data and execution clients become those of the broker's adapter, and the node needs persistent state. The configuration can be built and checked without connecting; this is the one for Binance on its test network (testnet), with the classes of the installed version:
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,
"| keys in the config:", c.api_key is not None)
print("cache in", real.cache.database.type,
"| reconciliation:", real.exec_engine.reconciliation)BINANCE SPOT TESTNET | keys in the config: False
cache in redis | reconciliation: TrueThe keys do not go in the configuration (api_key is None): the adapter reads them from environment variables when
creating the client, and for the testnet they are testnet keys, different from those of the live account (lesson
4.4.5). The Redis cache stores orders, positions and accounts, so that a restart starts from the previous state and
reconciliation only has to fill in what happened while the node was stopped; the documentation recommends persisting
all events so as not to depend on the history the venue keeps2. Starting it already requires a network, Redis and
keys:
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"), # with venue and bars via config
external_order_claims=["BTCUSDT.BINANCE", "ETHUSDT.BINANCE"])))
nodo.build()
try:
nodo.run() # blocks; Ctrl+C for an orderly stop
finally:
nodo.dispose()The lesson's MomVol class would not work as is for crypto: its VENUE and its bar types are fixed to the NYSE, and
the daily decision assumes a close that does not exist in crypto. That does not break the promise of the same code;
it says that the strategy must receive from its configuration everything that depends on the market (instruments, bar
type, decision time). For equities with Interactive Brokers, the skeleton from lesson 2.4.5 and the official sandbox
example with IB data and simulated execution are the way: first sandbox with the broker's real data, then a paper
account, then money.
LEAN live
LEAN follows the same path with different machinery. The algorithm (a subclass of QCAlgorithm, lesson 2.4.6) is
the same; what changes is the command and the broker. Locally, lean live deploy starts the engine in a Docker
container with the quantconnect/lean image and, if given no options, asks through a wizard which broker and which
data provider to use. The CLI requires membership in a paid QuantConnect organization. This course does not run it
(there is no Docker in its environment):
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 --openThe first uses QuantConnect's paper broker with Interactive Brokers data (the CLI page documents the rest of the IB
options: user, account and delayed data). The second takes all its configuration from an environment in lean.json
with live-mode enabled and leaves the container in the background. The third deploys to the QuantConnect cloud.
Results are written in real time, in JSON, to MomVol/live/<date and time>3Note 3 · LEAN CLI, “lean live deploy” (https://www.quantconnect.com/docs/v2/lean-cli/api-reference/lean-live-deploy) and
“QuantConnect Paper Trading”
(https://www.quantconnect.com/docs/v2/lean-cli/live-trading/brokerages/quantconnect-paper-trading): container with
the quantconnect/lean image, default wizard, options --brokerage, --data-provider-live, --environment,
--detach, results in <project>/live/<date> and the paid organization requirement. Accessed October 22, 2026..
What is specific to live mode lives in the algorithm:
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)
# own state: LEAN does not keep it between deployments
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:
... # the same rule
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("No connection to the broker")
def on_brokerage_reconnect(self) -> None:
self.debug("Connection restored: LEAN syncs the orders")
def on_brokerage_message(self, message_event) -> None:
self.debug(f"Broker: {message_event.message}")Three sentences from QuantConnect's documentation sum up the model4Note 4 · QuantConnect, “Live Trading > Key Concepts”
(https://www.quantconnect.com/docs/v2/writing-algorithms/live-trading/key-concepts) and “Live Trading >
Brokerages” (https://www.quantconnect.com/docs/v2/writing-algorithms/live-trading/brokerages): state, loading the
portfolio from the broker, on_brokerage_disconnect, on_brokerage_reconnect and on_brokerage_message; Object
Store at https://www.quantconnect.com/docs/v2/writing-algorithms/object-store.: “There is no automatic state management of
live strategies”; the live portfolio is loaded from the broker's positions, including open orders; and the algorithm
must not assume it starts with no positions, because it may have those of a previous deployment. On reconnecting, LEAN
syncs the state of the orders, but does not generate events for what happened during the outage (a limit order
filled while disconnected appears in the portfolio without its on_order_event). It is the same problem as in
lessons 4.5.2 and 4.5.5, half solved by the engine: what the broker reports gets aligned automatically; the
algorithm's own state (here, a counter in the Object Store) is the responsibility of whoever writes it.
| NautilusTrader 1.231.0 | LEAN | |
|---|---|---|
| Engine language | Rust and Cython; strategy in Python | C#; algorithm in C# or Python |
| Local deployment | a Python process (TradingNode) | Docker container (lean live deploy) |
| Simulated execution with real data | SandboxExecutionClient | “Paper Trading” broker |
| Reconciliation at startup | orders, fills and positions; unknowns as EXTERNAL | the broker's portfolio and open orders |
| Own state between restarts | Redis cache (CacheConfig) | Object Store |
| Cost of the tool | LGPL-3.0, no fee | Apache-2.0 engine; the CLI requires a paid organization |
Before putting money in
What the lesson has tested and what remains, as a checklist for any engine:
| Step | What it proves | Done in the lesson |
|---|---|---|
| Backtest with the rule | the logic decides as intended | yes: positions like those of the live account |
| Backtest versus live (or paper) | how much the unmodeled costs cost | yes: 0.22% over 56 sessions |
| Sandbox with replayed data | the strategy works inside the live engine | yes: 228 identical fills |
| Reconciliation with foreign positions | startup picks up what the broker says | yes: 187 ORAU as EXTERNAL and claimed |
| Sandbox with the broker's real data | data in the real order and format | no: needs a network |
| Broker paper account | real orders, rejections and fills | no: needs an account |
| Live with minimum size | all of the above with money | no |
| Restart mid-session | persistent state and reconciliation | partial: lesson 4.5.5 |
Each step can reveal something the previous one could not, just as the sandbox here revealed three differences the backtest was hiding. Skipping one means accepting that the money will find it.
Exercises
- Change the strategy's threshold from 0.4% to 1% of equity and run the backtest again. How many fewer fills are there and how much does the final equity change? What can you not know from this backtest about the effect on the live account?
Solution
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"threshold {umbral:.1%}: {n} fills, "
f"equity {pat:,.2f}")threshold 0.4%: 228 fills, equity 1,044,525.05
threshold 1.0%: 195 fills, equity 1,046,976.45With a 1% threshold there are 33 fewer fills (195 versus 228) and the cost-free equity rises by 2,451 USD. That difference is not an advantage of the threshold: over 56 sessions and a single path, the less tightly adjusted portfolio ending up higher is as likely as it ending up lower. What this backtest cannot tell you is how much it saves on the live account, because it has no costs: with the gap measured in the lesson (about 2,100 USD of price and 596 of commissions over 228 fills), each avoided fill is worth on the order of 10 USD, and that only becomes a reliable figure with a calibrated cost model or with another season of live trading.
- Run the sandbox node with the default submit limit (remove
risk_enginefromTradingNodeConfig). What happens? Is the result deterministic?
Solution
Some of the orders come out DENIED with “Exceeded MAX_ORDER_SUBMIT_RATE” and the strategy ends up with a different
portfolio. It is not deterministic: how many are denied depends on how many orders fit in one second of real clock
time, that is, on the speed of the machine and its load at that moment. That is why the lesson raises the limit for
the replay (and this exercise is not run here): the result of a lesson, like that of a test, cannot depend on the
clock. Live, the strategy sends at most ten orders a day and the default limit is a useful protection against a bug
that fires orders in a loop.
- Write
on_stopforMomVolso that, when the node stops, it leaves no live orders but does not close the portfolio either. Why is theclose_all_positionsused by the strategies of lesson 2.4.5 not a good idea?
Solution
class MomVolConParada(MomVol):
def on_stop(self) -> None:
for t in self.config.tickers:
self.cancel_all_orders(self.iid(t)) # nothing live
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), "open positions after stopping:",
sorted(p.instrument_id.symbol.value for p in abiertas))1 open positions after stopping: ['ORAU']After stopping, the ORAU position remains open (on December 31 the rule had sold everything else, just like the live
account) and there are no live orders. In a backtest, closing everything at the end serves to measure a realized
result; live, on_stop runs at every stop of the node (an update, a scheduled restart), and closing the portfolio
each time would mean selling and buying back, with its costs and its risk, for a reason that has nothing to do with
the strategy. What the stop must do is leave no orders that could fill with nobody watching; on startup,
reconciliation recovers the portfolio.
Footnotes
-
NautilusTrader 1.231.0,
nautilus_trader/adapters/sandbox/execution.py:connect()subscribes todata.*.{venue}.*andon_data()passes quotes, trades, book and bars to the simulated market; the bus topics for bars aredata.bars.{bar_type}. Official examples:examples/sandbox/interactive_brokers_sandbox.py(strategy withsubscribe_quote_ticks=True). Verified by running the lesson's code. ↩ -
NautilusTrader 1.231.0,
docs/concepts/reconciliation.md: “Leavereconciliation_lookback_minsunset”,EXTERNALorders with theRECONCILIATIONtag,external_order_claims, price hierarchy of the generated orders (“MARKET order (last resort)”) and the recommendation to persist events in the cache database. ↩ ↩2 ↩3 -
LEAN CLI, “lean live deploy” (https://www.quantconnect.com/docs/v2/lean-cli/api-reference/lean-live-deploy) and “QuantConnect Paper Trading” (https://www.quantconnect.com/docs/v2/lean-cli/live-trading/brokerages/quantconnect-paper-trading): container with the
quantconnect/leanimage, default wizard, options--brokerage,--data-provider-live,--environment,--detach, results in<project>/live/<date>and the paid organization requirement. Accessed October 22, 2026. ↩ -
QuantConnect, “Live Trading > Key Concepts” (https://www.quantconnect.com/docs/v2/writing-algorithms/live-trading/key-concepts) and “Live Trading > Brokerages” (https://www.quantconnect.com/docs/v2/writing-algorithms/live-trading/brokerages): state, loading the portfolio from the broker,
on_brokerage_disconnect,on_brokerage_reconnectandon_brokerage_message; Object Store at https://www.quantconnect.com/docs/v2/writing-algorithms/object-store. ↩
Each answer is folded under its question
Self-assessment
The lesson's strategy, run in the
BacktestEnginewith the live system's rule, ends December 31 with 1,044,525 USD; the live account in the data, with 1,042,307. Where does most of the difference come from?- AFrom the costs the backtest does not have (commissions, bid-ask spread and slippage) and from executing at a different time and a different price than the close
- BFrom a bug in the backtest
- CFrom the duplicate order
- DFrom the ORAU split
Show answer
Answer A. From the costs the backtest does not have (commissions, bid-ask spread and slippage) and from executing at a different time and a different price than the close
The positions of the backtest and the live account differ by a few shares a day, because of the reference price (mid at 15:45 versus the close) and Monday's orders. What separates the money is the 596 USD of commissions, the spread and the slippage of each execution. The split does not change the value of the position.
NautilusTrader 1.231.0's
SandboxExecutionClientreceives the data the node publishes. If the data client only publishes bars, what happens to market orders?- AThey are filled at the bar's close, as in the backtest
- BThey are rejected with “no market”: the sandbox listens to quotes, trades and the book, not bars
- CThey stay pending until the next bar
- DThe node does not start
Show answer
Answer B. They are rejected with “no market”: the sandbox listens to quotes, trades and the book, not bars
The sandbox client subscribes to
data.*.{venue}.*, which matches the quote and trade topics (data.quotes.XNYS.ORUS) but not the bar topics (data.bars.ORUS.XNYS-1-DAY-…). That is why the official sandbox examples subscribe to quotes; in the lesson, the data client publishes a quote with each bar.Replaying 128 sessions in a live node without waiting between them, many orders come out
DENIEDwith “Exceeded MAX_ORDER_SUBMIT_RATE”. Why does it not happen in the backtest?- ABecause the backtest has no risk engine
- BBecause the sandbox has a different limit
- CBecause the limit (100 orders per second by default) is measured with the clock: in the backtest the clock is the data's and the orders are days apart; live, it is the real clock and the accelerated replay packs hundreds of orders into one second
- DBecause the backtest orders are limit orders
Show answer
Answer C. Because the limit (100 orders per second by default) is measured with the clock: in the backtest the clock is the data's and the orders are days apart; live, it is the real clock and the accelerated replay packs hundreds of orders into one second
The risk engine is the same in both modes, but its clock is not. An accelerated replay is not a real market; live, this strategy sends at most 10 orders a day. The lesson raises the limit for the replay and says so.
When the live node starts, the broker reports a position of 187 ORAU that the system's cache does not have. What does NautilusTrader do with reconciliation enabled?
- AIgnores the position
- BSells the position
- CRefuses to start
- DGenerates an order and a fill with strategy
EXTERNALso that the cache reflects the broker's position; withexternal_order_claims, a strategy can claim it as its own
Show answer
Answer D. Generates an order and a fill with strategy
EXTERNALso that the cache reflects the broker's position; withexternal_order_claims, a strategy can claim it as its ownReconciliation aligns the cache with the venue's reports before starting the strategies. What it does not recognize it creates with strategy
EXTERNAL; if a strategy declares the instrument inexternal_order_claims, the order and the position become its own.A LEAN algorithm keeps in a variable the number of days it has been in a position. The live deployment is restarted. What happens?
- ALEAN restores the variable
- BLEAN closes the positions on startup
- CThe algorithm cannot be restarted
- DThe portfolio is loaded from the broker's positions, but the variable starts from zero: the algorithm's own state has to be saved (for example, in the Object Store) and rebuilt
Show answer
Answer D. The portfolio is loaded from the broker's positions, but the variable starts from zero: the algorithm's own state has to be saved (for example, in the Object Store) and rebuilt
QuantConnect's documentation says so: there is no automatic state management of live strategies, the portfolio is loaded from the broker's positions and the algorithm must not assume it starts with no positions. Everything else must be persisted by the algorithm itself.
Key points
- “The same code” holds for the strategy: the class that decides does not change between backtest, sandbox and live. What changes is the clock, the data and execution clients, the real-time risk engine and the reconciliation.
- On the live account in the data, the backtest of the same rule gets the positions right to within a few shares; the money difference (2,219 USD over 56 sessions, 0.22% of capital) is costs and execution timing.
- A
TradingNodewith a data client that replays history and theSandboxExecutionClientruns the strategy with the live engine and gives the same 228 fills as the backtest, as long as the data arrive at its pace. - The sandbox shows what the backtest hides: queues and a real clock, per-second submit limits, identifiers with the machine's time and a simulator that needs quotes.
- On startup, NautilusTrader reconciles the cache with the broker before activating the strategies; anything unknown appears as
EXTERNALand can be claimed. - LEAN deploys the same algorithm with
lean live deployin Docker; it loads portfolio and orders from the broker, signals disconnections withon_brokerage_disconnectand leaves its own state to the algorithm.
Resources
Research
Documentation
- NautilusTrader 1.231.0: Live Trading (guide in the repository, tag v1.231.0)
- NautilusTrader 1.231.0: Execution Reconciliation
- NautilusTrader 1.231.0: sandbox examples (Interactive Brokers, Binance, Bybit…)
- LEAN CLI: lean live deploy (options)
- QuantConnect: Live Trading, Key Concepts (state and restarts)