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.

Fig. — Equity of the live account in the data minus that of the backtest of the same rule in NautilusTrader (no costs, execution at the close), from 13 October to 31 December 2025, and cumulative commissions of the live account
Level 4 lessons
  1. 4.1 · Portfolio theory and optimisation

    1. 4.1.1 Markowitz: efficient frontier and real constraints
    2. 4.1.2 Covariance estimation: shrinkage, factor models and denoising
    3. 4.1.3 Black-Litterman: from market equilibrium to views
    4. 4.1.4 Risk parity, minimum variance, HRP and HERC
    5. 4.1.5 Optimization with CVaR, drawdown, turnover and costs
    6. 4.1.6 Factor models: CAPM, Fama-French, PCA and attribution
    7. 4.1.7 Rebalancing and strategy portfolios
    8. 4.1.8 Portfolio tools: Riskfolio-Lib, PyPortfolioOpt, skfolio and bt
  2. 4.2 · Risk management and position sizing

    1. 4.2.1 Risk per trade, stops and risk of ruin
    2. 4.2.2 Kelly criterion and volatility targeting: how much to risk
    3. 4.2.3 VaR, Expected Shortfall and VaR backtesting
    4. 4.2.4 Stress testing and correlation, liquidity, gap and counterparty risk
    5. 4.2.5 Drawdown control and kill switch
    6. 4.2.6 Derivatives risk: portfolio Greeks, margin and liquidations
  3. 4.3 · Microstructure and execution

    1. 4.3.1 Implementation shortfall, VWAP, TWAP and Almgren-Chriss
    2. 4.3.2 Order flow imbalance, microstructure signals and latency
  4. 4.4 · Brokers and APIs

    1. 4.4.1 Choosing a broker from Spain or the EU: regulation, assets, costs and API
    2. 4.4.2 Interactive Brokers: TWS and IB Gateway, ib_async, contracts, data and orders
    3. 4.4.3 Alpaca, Tradier, MetaTrader 5 with Python and OANDA v20
    4. 4.4.4 Crypto exchange APIs: ccxt, Binance, Bybit, Kraken and Coinbase
    5. 4.4.5 Secure credentials: environment variables, secret managers and least privilege
  5. 4.5 · Live trading system

    1. 4.5.1 Live system architecture: from data to reconciliation
    2. 4.5.2 Order management: states, partial fills, cancellations and reconnections
    3. 4.5.3 Paper trading, shadow trading and slippage monitoring
    4. 4.5.4 Task scheduling and alerts: cron, APScheduler, Prefect, Telegram, Slack and email
    5. 4.5.5 Fault tolerance: idempotency, persistent state and reconciliation at startup
    6. 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:

BacktestSandboxLive
EngineBacktestEngineTradingNodeTradingNode
Clockthe data's (simulated)the machine'sthe machine's
Datahistorical listlive data clientlive data client
Executionsimulated marketsimulated market (SandboxExecutionClient)broker client
Reconciliation at startupdoes not existwith the simulated one (empty)with the broker
Network and broker failuresnonenonethose 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).

IN [01]python
import math
import warnings

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

# 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())
OUT [01]Instruments and daily bars stamped at the close
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_filled until 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.
IN [02]python
from nautilus_trader.config import StrategyConfig
from nautilus_trader.model.enums import OrderSide
from nautilus_trader.trading.strategy import Strategy


class MomVolConfig(StrategyConfig, frozen=True):
    tickers: tuple
    desde: str = "2025-10-13"        # 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:

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

def informe_fills(trader):
    f = trader.generate_order_fills_report()
    f["fecha"] = (pd.to_datetime(f["ts_last"])
                  .dt.tz_convert("America/New_York")
                  .dt.strftime("%Y-%m-%d"))
    f["sym"] = f["instrument_id"].str.split(".").str[0]
    f["q"] = (f["filled_qty"].astype(float)
              * np.where(f["side"] == "BUY", 1, -1))
    f["px"] = f["avg_px"].astype(float)
    return f.reset_index()           # client_order_id, 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))
OUT [03]Backtest with the BacktestEngine
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.08

The 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):

IN [04]python
pos_bt = (fb.pivot_table(index="fecha", columns="sym", values="q",
                         aggfunc="sum").fillna(0).cumsum())
ext = pd.read_csv("vivo4_extracto_posiciones.csv")
pos_re = ext.pivot(index="fecha", columns="instrumento",
                   values="cantidad").fillna(0)
pos_re.loc[pos_re.index >= "2025-11-17", "ORAU"] /= 3
pos_bt = (pos_bt.reindex(pos_re.index).ffill().fillna(0)
          .reindex(columns=pos_re.columns, fill_value=0))
dif = (pos_bt - pos_re).abs()
titulos = dif.sum(axis=1)
valor = (dif * precios.set_axis(precios.index.strftime("%Y-%m-%d"))
         .reindex(dif.index)[dif.columns]).sum(axis=1)
print("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})")
OUT [04]Backtest positions versus the live account statement
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:

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

efe = pd.read_csv("vivo4_extracto_efectivo.csv")
caja_re = efe.groupby("fecha")["saldo"].last()
pat_re = ext.groupby("fecha")["valor"].sum() + caja_re.reindex(
    pos_re.index).ffill()
brecha = pat_re - pat_bt
ops = pd.read_csv("vivo4_extracto_operaciones.csv")
otros = efe[efe["concepto"].isin(["intereses",
                                  "tasa_datos_mercado"])]
print(f"backtest: {pat_bt.iloc[-1]:,.2f} | 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}")
OUT [05]Backtest equity versus live account equity
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.77

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

Replay clientDataEngineMomVolSandboxRiskEngineExecEngine
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:

  1. 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 to data.*.{venue}.* and on_data() passes quotes, trades, book and bars to the simulated market; the bus topics for bars are data.bars.{bar_type}. Official examples: examples/sandbox/interactive_brokers_sandbox.py (strategy with subscribe_quote_ticks=True). Verified by running the lesson's code..
  2. The live engine works with queues. Data, commands and events go into asyncio queues 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.
  3. The risk engine measures with the real clock. LiveRiskEngineConfig limits submission to 100 orders per second by default. Replaying months in seconds, the limit trips and orders come out DENIED; in the backtest it does not happen because the clock is the data's. The cell raises the limit for the replay only.
IN [06]python
import asyncio

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


class ReplayConfig(LiveDataClientConfig, frozen=True):
    pass


class Replay(LiveMarketDataClient):
    """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()))
OUT [06]A TradingNode with replayed data and sandbox execution
228 fills in the sandbox; the same as in the backtest? True
{'FILLED': 228}
order ids shared with the backtest: 0

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

IN [07]python
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}")
OUT [07]What the live node does by default
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                       False

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

Fig. — Start-up of a NautilusTrader 1.231.0 TradingNode with the default configuration: client connection, reconciliation with the venue before the strategies are started and in-flight order checks every 2 seconds, querying the venue after 5 seconds without a reply (schematic)

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.

IN [08]python
from decimal import Decimal

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


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


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


class Quieta(Strategy):
    arrancada = None

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


async def arrancar(estrategia):
    cfg = TradingNodeConfig(
        trader_id="CURSO-002",
        logging=LoggingConfig(log_level="ERROR"),
        exec_engine=LiveExecEngineConfig(
            reconciliation=True, reconciliation_startup_delay_secs=0),
        exec_clients={"XNYS": SandboxExecutionClientConfig(
            venue="XNYS", starting_balances=["1000000 USD"],
            base_currency="USD", account_type="CASH")},
        timeout_post_stop=0.5)
    n = TradingNode(config=cfg)
    for i in inst.values():
        n.cache.add_instrument(i)
    n.add_exec_client_factory("XNYS", SandboxLiveExecClientFactory)
    n.trader.add_strategy(estrategia)
    n.build()
    Quieta.arrancada = asyncio.Event()
    t = asyncio.create_task(n.run_async())
    await Quieta.arrancada.wait()       # 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)
OUT [08]A position the system did not know about
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:

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

reclama = QuietaQueReclama(StrategyConfig(
    strategy_id="MomVol", order_id_tag="001",
    external_order_claims=["ORAU.XNYS"]))
pos, ordenes = await arrancar(reclama)
print("positions:", pos)
print("order:", ordenes[0][:4])
OUT [09]The strategy claims the position
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:

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

real = TradingNodeConfig(
    trader_id="CURSO-003",
    logging=LoggingConfig(log_level="INFO"),
    exec_engine=LiveExecEngineConfig(reconciliation=True,
                                     inflight_check_interval_ms=2000),
    cache=CacheConfig(database=DatabaseConfig(type="redis"),
                      flush_on_start=False),
    data_clients={BINANCE: BinanceDataClientConfig(
        account_type=BinanceAccountType.SPOT,
        environment=BinanceEnvironment.TESTNET)},
    exec_clients={BINANCE: BinanceExecClientConfig(
        account_type=BinanceAccountType.SPOT,
        environment=BinanceEnvironment.TESTNET,
        max_retries=3)},
)
c = real.exec_clients[BINANCE]
print(BINANCE, c.account_type.value, c.environment.value,
      "| keys in the config:", c.api_key is not None)
print("cache in", real.cache.database.type,
      "| reconciliation:", real.exec_engine.reconciliation)
OUT [10]Configuring a node for the Binance testnet
BINANCE SPOT TESTNET | keys in the config: False
cache in redis | reconciliation: True

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

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

nodo = TradingNode(config=real)
nodo.add_data_client_factory(BINANCE, BinanceLiveDataClientFactory)
nodo.add_exec_client_factory(BINANCE, BinanceLiveExecClientFactory)
nodo.trader.add_strategy(MomVol(MomVolConfig(
    tickers=("BTCUSDT", "ETHUSDT"),   # 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 --open

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

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


class MomVol(QCAlgorithm):

    def initialize(self) -> None:
        self.set_cash(1_000_000)
        self._etfs = [self.add_equity(t, Resolution.DAILY).symbol
                      for t in ["SPY", "IWM", "EFA", "EEM", "IEF",
                                "TLT", "LQD", "GLD", "DBC", "VNQ"]]
        self.schedule.on(
            self.date_rules.every_day("SPY"),
            self.time_rules.before_market_close("SPY", 15),
            self._rebalanceo)
        # 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.0LEAN
Engine languageRust and Cython; strategy in PythonC#; algorithm in C# or Python
Local deploymenta Python process (TradingNode)Docker container (lean live deploy)
Simulated execution with real dataSandboxExecutionClient“Paper Trading” broker
Reconciliation at startuporders, fills and positions; unknowns as EXTERNALthe broker's portfolio and open orders
Own state between restartsRedis cache (CacheConfig)Object Store
Cost of the toolLGPL-3.0, no feeApache-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:

StepWhat it provesDone in the lesson
Backtest with the rulethe logic decides as intendedyes: positions like those of the live account
Backtest versus live (or paper)how much the unmodeled costs costyes: 0.22% over 56 sessions
Sandbox with replayed datathe strategy works inside the live engineyes: 228 identical fills
Reconciliation with foreign positionsstartup picks up what the broker saysyes: 187 ORAU as EXTERNAL and claimed
Sandbox with the broker's real datadata in the real order and formatno: needs a network
Broker paper accountreal orders, rejections and fillsno: needs an account
Live with minimum sizeall of the above with moneyno
Restart mid-sessionpersistent state and reconciliationpartial: 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

  1. 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
IN [13]python
def backtest(**kw):
    m = BacktestEngine(BacktestEngineConfig(
        logging=LoggingConfig(log_level="ERROR")))
    m.add_venue(venue=VENUE, oms_type=OmsType.NETTING,
                account_type=AccountType.CASH, base_currency=USD,
                starting_balances=[Money(1_000_000, USD)])
    for i in inst.values():
        m.add_instrument(i)
    m.add_data(barras)
    m.add_strategy(MomVol(MomVolConfig(tickers=tuple(ETF), **kw)))
    m.run()
    f = informe_fills(m.trader)
    p = (f.pivot_table(index="fecha", columns="sym", values="q",
                       aggfunc="sum").fillna(0).cumsum())
    caja = m.trader.generate_account_report(VENUE)["total"]
    fin = (p.iloc[-1] * precios.iloc[-1][p.columns]).sum()
    return len(f), fin + float(caja.iloc[-1])

for umbral in (0.004, 0.01):
    n, pat = backtest(umbral=umbral)
    print(f"threshold {umbral:.1%}: {n} fills, "
          f"equity {pat:,.2f}")
OUT [13]A higher rebalancing threshold
threshold 0.4%: 228 fills, equity 1,044,525.05
threshold 1.0%: 195 fills, equity 1,046,976.45

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

  1. Run the sandbox node with the default submit limit (remove risk_engine from TradingNodeConfig). 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.

  1. Write on_stop for MomVol so that, when the node stops, it leaves no live orders but does not close the portfolio either. Why is the close_all_positions used by the strategies of lesson 2.4.5 not a good idea?
Solution
IN [14]python
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))
OUT [14]Stopping without unwinding the portfolio
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

  1. NautilusTrader 1.231.0, nautilus_trader/adapters/sandbox/execution.py: connect() subscribes to data.*.{venue}.* and on_data() passes quotes, trades, book and bars to the simulated market; the bus topics for bars are data.bars.{bar_type}. Official examples: examples/sandbox/interactive_brokers_sandbox.py (strategy with subscribe_quote_ticks=True). Verified by running the lesson's code. ↩

  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. ↩ ↩2 ↩3

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

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

Each answer is folded under its question

Self-assessment

  1. The lesson's strategy, run in the BacktestEngine with 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.

  2. NautilusTrader 1.231.0's SandboxExecutionClient receives 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.

  3. Replaying 128 sessions in a live node without waiting between them, many orders come out DENIED with “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.

  4. 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 EXTERNAL so that the cache reflects the broker's position; with external_order_claims, a strategy can claim it as its own
    Show answer

    Answer D. Generates an order and a fill with strategy EXTERNAL so that the cache reflects the broker's position; with external_order_claims, a strategy can claim it as its own

    Reconciliation 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 in external_order_claims, the order and the position become its own.

  5. 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 TradingNode with a data client that replays history and the SandboxExecutionClient runs 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 EXTERNAL and can be claimed.
  • LEAN deploys the same algorithm with lean live deploy in Docker; it loads portfolio and orders from the broker, signals disconnections with on_brokerage_disconnect and leaves its own state to the algorithm.

Resources

Data