Lesson 2.4.5 · 70 min

NautilusTrader: architecture, data, strategies and the same code in backtest and live

NautilusTrader is an event-driven engine built for production: the backtest uses the same core, the same cache and the same message bus as live trading. This lesson assembles it piece by piece with the 5-minute bars of the fictitious ETF KAPA, runs two strategies with and without costs and reads their order, position and account reports.

Fig. — Cumulative P&L of the two strategies in NautilusTrader on KAPA 5-minute bars, 2022-2025, with 300 shares, against buying and holding 300 shares
Level 2 lessons
  1. 2.1 · Statistics and econometrics for trading

    1. 2.1.1 Hypothesis tests, p-values, bootstrap and regression with robust standard errors
    2. 2.1.2 Stationarity: ADF, KPSS, differencing and fractional differencing
    3. 2.1.3 ARMA and ARIMA models: autocorrelation (ACF) and partial autocorrelation (PACF)
    4. 2.1.4 Volatility models: ARCH, GARCH, EGARCH and GJR with the arch library
    5. 2.1.5 Cointegration: Engle-Granger, Johansen and the half-life of mean reversion
    6. 2.1.6 Kalman filter for dynamic hedge ratios
    7. 2.1.7 Regime detection: hidden Markov models and structural breaks
    8. 2.1.8 The multiple testing problem: Bonferroni, FDR and the “factor zoo”
  2. 2.2 · Anatomy of a trading system

    1. 2.2.1 Edge hypotheses: risk, behavior, structure and information
    2. 2.2.2 Universe, signal, filter, entry, exit, sizing and risk management
    3. 2.2.3 Frequencies: intraday, swing and position trading, and the capacity of a strategy
    4. 2.2.4 Research journal and experiment tracking against data snooping
  3. 2.3 · Strategy families

    1. 2.3.1 Trend following and momentum
    2. 2.3.2 Mean reversion: RSI and Bollinger, intraday reversal and gaps
    3. 2.3.3 Pairs trading and statistical arbitrage: distance, cointegration and Kalman
    4. 2.3.4 Carry in currencies, futures and bonds; seasonality and calendar effects
    5. 2.3.5 Factors (value, quality, low volatility, size) and event strategies
    6. 2.3.6 Introduction to market making: inventory, spread and adverse selection (Avellaneda-Stoikov)
    7. 2.3.7 Crypto-specific strategies: momentum, funding arbitrage and basis
  4. 2.4 · Backtesting: engines and paradigms

    1. 2.4.1 Vectorized backtest from scratch: matrices, lag, costs and limits
    2. 2.4.2 Event-driven backtest from scratch: loop, portfolio, orders and fills
    3. 2.4.3 vectorbt: massive parameter sweeps and portfolio simulation
    4. 2.4.4 backtesting.py and backtrader: two teaching engines and their status
    5. 2.4.5 NautilusTrader: architecture, data, strategies and the same code in backtest and live
    6. 2.4.6 LEAN/QuantConnect and Zipline Reloaded; bt for asset allocation
    7. 2.4.7 Cost modeling: commissions, bid-ask spread, impact, financing and securities borrowing
    8. 2.4.8 Fill modeling: limit orders, queue, partial fills, gaps and stops
  5. 2.5 · Biases and validation

    1. 2.5.1 Backtest biases: look-ahead, survivorship, selection and narrative
    2. 2.5.2 In-sample and out-of-sample: anchored and rolling walk-forward
    3. 2.5.3 Combinatorial purged cross-validation for strategies
    4. 2.5.4 Deflated Sharpe Ratio and Probability of Backtest Overfitting (PBO)
    5. 2.5.5 Monte Carlo: trade resampling, block bootstrap and signal permutations
    6. 2.5.6 Parameter sensitivity: plateaus versus peaks, robustness across assets and periods
    7. 2.5.7 Synthetic data for stress testing: GBM, GARCH and bootstrapping
    8. 2.5.8 “Honest backtest” checklist before moving to paper trading
  6. 2.6 · Performance metrics

    1. 2.6.1 CAGR, Sharpe, Sortino, Calmar, Omega, drawdown and Ulcer Index
    2. 2.6.2 Win rate, payoff, expectancy, alpha, beta, information ratio, turnover and capacity
    3. 2.6.3 Reports with QuantStats or pyfolio-reloaded and custom tearsheets

A production engine for running backtests

The engines of lessons 2.4.1 and 2.4.2 were written to understand a backtest. NautilusTrader is written to trade: its author, Nautech Systems, describes it as a “Rust-native” engine for multi-asset, multi-venue systems, in which research, simulation and live trading share the same event-driven architecture. Python is the control plane: the strategy, the configuration and the orchestration are written in Python; order matching, the cache, the clocks and the message bus run compiled.

Two pieces of context worth having clear before installing it:

  • LGPL-3.0 license (on PyPI, LGPL-3.0-or-later). It can be used from your own closed-source code; if a modified version of the library itself is distributed, those modifications must be published under the same license.
  • Two series at once. The latest stable version on PyPI is 1.231.0 (August 2, 2026), which is the one this course uses. In parallel, the 2.0 release candidates are being published (2.0.0rc7, October 15, 2026), whose rc3 note says: “Removed legacy v1 Cython package”. The documentation at nautilustrader.io/docs/latest already describes that 2.0 architecture (Rust with PyO3 bindings) and some import paths in its examples (nautilus_trader.backtest instead of nautilus_trader.backtest.engine) do not exist in 1.231.0. The concepts in this lesson are the same in both; the code is that of 1.231.0 and has been run as is.

The Cython layer of 1.x can be seen in the installed package itself:

IN [01]python
import importlib.metadata as md
from pathlib import Path
import nautilus_trader

meta = md.metadata("nautilus-trader")
print(meta["Name"], meta["Version"], "·", meta["License"])
raiz = Path(nautilus_trader.__file__).parent
print("Cython modules (.pyx):", len(list(raiz.rglob("*.pyx"))))
nucleo = next(raiz.glob("core/nautilus_pyo3*.so"))
print("Rust core:", nucleo.name.split(".")[0],
      f"({nucleo.stat().st_size / 2**20:.0f} MiB)")
OUT [01]What is inside the package
nautilus_trader 1.231.0 · LGPL-3.0-or-later
Cython modules (.pyx): 110
Rust core: nautilus_pyo3 (159 MiB)

A hundred and ten Cython modules (the Strategy, BacktestEngine and Bar classes, the indicators…) and a library compiled from Rust with the core. For someone writing strategies, the difference is barely noticeable: you import nautilus_trader and inherit from Python classes. What you do notice is the speed, and the price is rigidity: value types with fixed precision, immutable configuration and a strict life cycle for each component.

The architecture: a kernel, a bus and adapters

The official documentation describes the system as a kernel (NautilusKernel) that creates and connects the shared components, and adapters that talk to the outside world at the boundaries of the engines (the “ports and adapters” style). No component calls another directly: everything goes through the message bus.

Data clientDataEngineCacheMessageBusStrategyRiskEngineExecutionEngineExecution clientPortfolio
Mermaid source
flowchart TB
  AD[Data client] --> DE[DataEngine]
  DE --> CA[Cache]
  DE --> MB[MessageBus]
  MB --> ST[Strategy]
  ST --> RE[RiskEngine]
  RE --> EE[ExecutionEngine]
  EE --> AE[Execution client]
  AE --> EE
  EE --> CA
  EE --> PF[Portfolio]
  PF --> ST
ComponentWhat it does
NautilusKernelCreates and configures the components, chooses the clock (simulated or real) and manages the life cycle
MessageBusPublish/subscribe, request/response and commands between components
CacheIn-memory state: instruments, accounts, orders, positions, latest data
DataEngineReceives quotes, trades, bars and books; manages subscriptions and distributes them
RiskEngineValidates each order (quantities, notionals, balance, rate limits) before sending it
ExecutionEngineRoutes orders to the client of their venue and processes the events coming back
PortfolioBalances, net positions, margin, and realized and unrealized P&L
TraderRegisters strategies and actors and manages their life cycle

Three design ideas that matter for a backtest. The first: a single thread processes all the kernel's messages, in order, so there are no races between the strategy and execution (networking and logging run on other threads). The second: the environment changes only at the edges: in a backtest, the data adapter is a list of historical data and the execution adapter is a simulated market (SimulatedExchange); live, they are a broker's clients. The third: time is the data's time. Each object carries two UNIX nanosecond timestamps, ts_event (when it happened) and ts_init (when the system learned of it), and the backtest clock advances with them.

Instruments and bars in the Nautilus format

Before adding data, the instrument has to be described: what is traded, in what currency, with what tick and what lot. KAPA is a fictitious ETF on an index, with 5-minute bars from 2022 to 2025 (indice2_5min.parquet). The data is synthetic: it is not market data.

IN [02]python
import warnings
from decimal import Decimal

import pandas as pd
from pandas.errors import Pandas4Warning

# The engine calls Timestamp.utcnow(), which pandas 3 flags as
# deprecated: one warning per run, outside our control
warnings.filterwarnings("ignore", message="Timestamp.utcnow",
                        category=Pandas4Warning)

from nautilus_trader.model.currencies import USD
from nautilus_trader.model.identifiers import (
    InstrumentId, Symbol, Venue)
from nautilus_trader.model.instruments import Equity
from nautilus_trader.model.objects import Money, Price, Quantity

VENUE = Venue("XNYS")
kapa = Equity(
    instrument_id=InstrumentId(Symbol("KAPA"), VENUE),
    raw_symbol=Symbol("KAPA"),
    currency=USD,
    price_precision=2,
    price_increment=Price.from_str("0.01"),
    lot_size=Quantity.from_int(1),
    ts_event=0,
    ts_init=0,
)
print(kapa.id, kapa.price_increment, kapa.quote_currency)
print(Money(0.005, USD), Money(1.50, USD))
OUT [02]The instrument: an Equity with a one-cent tick
KAPA.XNYS 0.01 USD
0.01 USD 1.50 USD

The last line is the first trap. Price, Quantity and Money are value types with fixed precision, and that of USD is two decimals: a fee of 0.005 USD per share is rounded to 0.01, double. To charge the per-share fee of the fictitious broker Alcor from lesson 2.4.7 (0.005 USD per share, minimum 1 USD per order) with orders of 300 shares, the right thing is a fixed commission of 1.50 USD per order.

Bars need a bar type, a string that says which instrument they belong to, what period, what price and who aggregates them: KAPA.XNYS-5-MINUTE-LAST-EXTERNAL are 5-minute bars of the last price, aggregated outside the system (by the vendor). Only EXTERNAL bars move the venue's simulated book; those the system aggregates from ticks (INTERNAL) reach the strategy, but do not execute orders.

IN [03]python
from nautilus_trader.model.data import BarType
from nautilus_trader.persistence.wranglers import BarDataWrangler

barras = pd.read_parquet("indice2_5min.parquet")
df = barras.rename(columns={
    "apertura": "open", "maximo": "high", "minimo": "low",
    "cierre": "close", "volumen": "volume"})
# ts is the START of the bar; Nautilus needs it at the CLOSE
df.index = df.pop("ts") + pd.Timedelta(minutes=5)

bar_type = BarType.from_str("KAPA.XNYS-5-MINUTE-LAST-EXTERNAL")
bars = BarDataWrangler(bar_type, kapa).process(df)
print(len(bars), "bars")
b = bars[0]
print(b.open, b.high, b.low, b.close, b.volume)
print("ts_event:", pd.Timestamp(b.ts_event, tz="UTC"))
print("ts_init: ", pd.Timestamp(b.ts_init, tz="UTC"))
OUT [03]From DataFrame to Bar objects, timestamped at the close
77910 bars
251.30 252.14 251.30 252.11 1416300
ts_event: 2022-01-03 14:35:00+00:00
ts_init:  2022-01-03 14:35:00+00:00

The first bar of the series is the one from 09:30 to 09:35 New York time on January 3, 2022 (14:30 UTC in winter), and here it is stamped at 14:35: the instant at which the high, the low and the close are already known. If it were passed with the start timestamp, the engine would consider it known at 14:30, and a strategy deciding with a clock at 14:32 would see a close that did not yet exist. It is the same look-ahead bias of lesson 1.7.4, now inside an event-driven engine: the engine cannot know how the vendor stamped the bars; you have to tell it.

Fig. — How NautilusTrader executes on bars: four synthetic points per bar, the bar delivered to the strategy at its close and the order matched against the book at that close (schematic)

The figure shows what the simulated venue does with each bar. Since a bar does not say in which order the high and the low were touched, the engine turns it into four synthetic updates of the L1 book (open, high, low and close, with a quarter of the volume each) and matches the orders that were already waiting after each one. Only then does it deliver the complete bar to the strategy. An order sent from on_bar arrives with the book at that bar's close and, with no latency model, executes at that price. In KAPA the open of each bar is the close of the previous one (except for the first of the session), so executing at the observed close is equivalent to executing at the next open; with data that does not meet that condition, the difference is an optimistic bias worth measuring. The venue's bar_adaptive_high_low_ordering=True option goes first through the extreme closest to the open; it matters when a stop and a target fall within the same bar (lesson 2.4.8).

A strategy is a class

A Nautilus strategy is a subclass of Strategy with a separate configuration, a subclass of StrategyConfig (immutable: frozen=True). The strategy has no loop: the system calls its methods when something happens. The most used are on_start (subscribe to data and register indicators), on_bar, on_quote_tick and on_trade_tick (data), on_order_filled and on_position_closed (execution events) and on_stop.

The first strategy is a long-only crossover of 12- and 48-bar exponential moving averages (one hour and four hours): it buys 300 shares when the fast one crosses above the slow one and closes when it crosses below. In this data it is a control: according to the level 2 README, outside the first half hour the 5-minute returns of KAPA have no autocorrelation, so no intraday trend rule has an edge.

IN [04]python
from nautilus_trader.config import StrategyConfig
from nautilus_trader.indicators import ExponentialMovingAverage
from nautilus_trader.model.enums import OrderSide
from nautilus_trader.trading.strategy import Strategy


class CruceConfig(StrategyConfig, frozen=True):
    instrument_id: InstrumentId
    bar_type: BarType
    rapida: int = 12
    lenta: int = 48
    acciones: int = 300


class Cruce(Strategy):
    def __init__(self, config: CruceConfig) -> None:
        super().__init__(config)
        self.rapida = ExponentialMovingAverage(config.rapida)
        self.lenta = ExponentialMovingAverage(config.lenta)

    def on_start(self) -> None:
        bt = self.config.bar_type
        # the system updates the indicators before on_bar
        self.register_indicator_for_bars(bt, self.rapida)
        self.register_indicator_for_bars(bt, self.lenta)
        self.subscribe_bars(bt)

    def on_bar(self, bar) -> None:
        if not self.indicators_initialized():
            return
        iid = self.config.instrument_id
        largo = self.portfolio.is_net_long(iid)
        if self.rapida.value > self.lenta.value and not largo:
            orden = self.order_factory.market(
                iid, OrderSide.BUY,
                Quantity.from_int(self.config.acciones))
            self.submit_order(orden)
        elif self.rapida.value < self.lenta.value and largo:
            self.close_all_positions(iid)

    def on_stop(self) -> None:
        self.close_all_positions(self.config.instrument_id)

Three details. register_indicator_for_bars makes the data engine update the indicators with each bar before calling on_bar, so inside on_bar the moving averages already include the bar that has just closed, and nothing more. The strategy does not keep its position in a variable: it asks the Portfolio, which is the source of truth in the backtest as well as live. And the order does not execute in submit_order: it is a command that travels along the bus to the risk engine and the execution engine, and what comes back are events.

The backtest engine: venue, costs and execution

BacktestEngine is the low-level interface: you create the engine, add a simulated venue with its account and its models, the instrument, the data and the strategy, and run it. (The high-level one, BacktestNode, does the same from configuration objects and a Parquet catalog, and is useful for launching many runs.) The function below builds a new engine on each call, with or without costs:

  • Commission: FixedFeeModel of 1.50 USD per order, Alcor's fee for 300 shares.
  • Slippage: FillModel(prob_slippage=1.0): each fill comes out one tick (0.01 USD) worse. With an L1 book, according to the documentation, the probability is evaluated on each fill and the shift is always one tick. random_seed fixes the random sequence even though with probability 1 it is not needed.
  • Margin account with leverage 1, to be able to sell short in the second strategy.
IN [05]python
from nautilus_trader.backtest.engine import BacktestEngine
from nautilus_trader.backtest.models import (
    FillModel, FixedFeeModel)
from nautilus_trader.config import (
    BacktestEngineConfig, LoggingConfig)
from nautilus_trader.model.enums import AccountType, OmsType


def backtest(estrategia, costes=True):
    motor = BacktestEngine(BacktestEngineConfig(
        logging=LoggingConfig(log_level="ERROR")))
    modelos = {}
    if costes:
        modelos = dict(
            fill_model=FillModel(prob_slippage=1.0, random_seed=7),
            fee_model=FixedFeeModel(Money(1.50, USD)))
    motor.add_venue(
        venue=VENUE,
        oms_type=OmsType.NETTING,        # one net position
        account_type=AccountType.MARGIN,
        base_currency=USD,
        starting_balances=[Money(100_000, USD)],
        default_leverage=Decimal(1),
        **modelos)
    motor.add_instrument(kapa)
    motor.add_data(bars)
    motor.add_strategy(estrategia)
    motor.run()
    return motor


cfg = CruceConfig(instrument_id=kapa.id, bar_type=bar_type)
bruto = backtest(Cruce(cfg), costes=False)
fills = bruto.trader.generate_order_fills_report()
fills[["side", "quantity", "avg_px", "ts_last"]].head(4)
OUT [05]One engine per run
                             side quantity  avg_px                   ts_last
client_order_id
O-20220103-194000-001-000-1   BUY      300  252.57 2022-01-03 19:40:00+00:00
O-20220103-195500-001-000-2  SELL      300  252.31 2022-01-03 19:55:00+00:00
O-20220103-200000-001-000-3   BUY      300  252.44 2022-01-03 20:00:00+00:00
O-20220103-201000-001-000-4  SELL      300  252.34 2022-01-03 20:10:00+00:00

The filled orders report has one row per order, with the side, the quantity, the average execution price, the commissions and the timestamps. The first buy comes at 19:40 UTC on the first day, when the 48-bar moving average already has enough data and the fast one crosses above. The index (client_order_id) is generated by the engine with the simulated date.

Each order is a state machine. The events of the first order show it:

IN [06]python
orden = bruto.cache.orders()[0]
for evento in orden.events:
    print(f"{type(evento).__name__:16}",
          pd.Timestamp(evento.ts_event, tz="UTC"))
print("final status:", orden.status_string())
OUT [06]The life cycle of an order
OrderInitialized 2022-01-03 19:40:00+00:00
OrderSubmitted   2022-01-03 19:40:00+00:00
OrderFilled      2022-01-03 19:40:00+00:00
final status: FILLED
INITIALIZEDDENIEDSUBMITTEDREJECTEDACCEPTEDPARTIALLY_FILLEDFILLEDCANCELED
Mermaid source
stateDiagram-v2
  [*] --> INITIALIZED
  INITIALIZED --> DENIED
  INITIALIZED --> SUBMITTED
  SUBMITTED --> REJECTED
  SUBMITTED --> ACCEPTED
  ACCEPTED --> PARTIALLY_FILLED
  PARTIALLY_FILLED --> FILLED
  ACCEPTED --> FILLED
  ACCEPTED --> CANCELED
  FILLED --> [*]

The backtest's market order goes from SUBMITTED to FILLED without ACCEPTED: the simulated venue does not acknowledge market orders unless asked to (use_market_order_acks=True in add_venue); limit orders do go through ACCEPTED while they wait in the book. DENIED is set by the risk engine before the order goes out (quantity out of range, insufficient balance); REJECTED, by the venue. In the backtest everything happens in the same nanosecond because there is no latency; live, milliseconds pass between SUBMITTED and ACCEPTED, and between ACCEPTED and FILLED anything can happen, including several PARTIALLY_FILLED.

The positions report summarizes each complete round trip, and the account report, the balance after each movement:

IN [07]python
def usd(serie):
    """'-78.00 USD' -> -78.0"""
    return serie.astype(str).str.split().str[0].astype(float)

pos = bruto.trader.generate_positions_report()
cuenta = bruto.trader.generate_account_report(VENUE)
print("filled orders:", len(fills),
      "· closed positions:", len(pos))
print("winners:", f"{(usd(pos['realized_pnl']) > 0).mean():.1%}")
print("final balance:", cuenta["total"].iloc[-1])
pos[["entry", "quantity", "avg_px_open", "avg_px_close",
     "realized_pnl"]].reset_index(drop=True).head(3)
OUT [07]Positions and account
filled orders: 1906 · closed positions: 953
winners: 28.5%
final balance: 140812.00
  entry quantity  avg_px_open  avg_px_close realized_pnl
0   BUY        0       252.57        252.31   -78.00 USD
1   BUY        0       252.44        252.34   -30.00 USD
2   BUY        0       253.06        251.52  -462.00 USD

Four years of 5-minute bars give 953 round trips, less than a third of them winners, and 40,812 USD of gross profit on 100,000. Before thinking about optimizing anything, two comparisons: with costs, and against doing nothing.

Results: a control with no edge and a planted effect

The second strategy uses the only effect these bars have. The level 2 README documents it: in the first six bars (09:30-10:00) the expected return is −0.10 times the opening gap, a small tendency to close it. The Hueco strategy waits for the close of the first bar, enters against the gap if it is at least 0.2% (sells if the open jumps up, buys if it jumps down) and closes at the close of the 09:55 bar, at 10:00. It is the same idea as in lesson 2.3.2, now inside the engine.

IN [08]python
NY = "America/New_York"


class HuecoConfig(StrategyConfig, frozen=True):
    instrument_id: InstrumentId
    bar_type: BarType
    acciones: int = 300
    hueco_min: float = 0.002


class Hueco(Strategy):
    def on_start(self) -> None:
        self.cierre_previo = None
        self.subscribe_bars(self.config.bar_type)

    def on_bar(self, bar) -> None:
        cierre = pd.Timestamp(bar.ts_event, tz="UTC").tz_convert(NY)
        iid = self.config.instrument_id
        if cierre.strftime("%H:%M") == "09:35" and self.cierre_previo:
            hueco = float(bar.open) / self.cierre_previo - 1
            if abs(hueco) >= self.config.hueco_min:
                lado = OrderSide.SELL if hueco > 0 else OrderSide.BUY
                self.submit_order(self.order_factory.market(
                    iid, lado,
                    Quantity.from_int(self.config.acciones)))
        elif cierre.strftime("%H:%M") == "10:00":
            self.close_all_positions(iid)
        self.cierre_previo = float(bar.close)

The time is read from ts_event in the New York time zone: that way daylight saving time does not move the rule (the first bar closes at 14:35 UTC in winter and at 13:35 in summer). With the two strategies, three more runs:

IN [09]python
neto = backtest(Cruce(cfg))
hcfg = HuecoConfig(instrument_id=kapa.id, bar_type=bar_type)
hueco_bruto = backtest(Hueco(hcfg), costes=False)
hueco_neto = backtest(Hueco(hcfg))


def resumen(b, n):
    pb = b.trader.generate_positions_report()
    pn = n.trader.generate_positions_report()
    f = n.trader.generate_order_fills_report()
    comision = usd(f["commissions"].map(lambda c: c[0])).sum()
    bruto_usd = usd(pb["realized_pnl"]).sum()
    neto_usd = usd(pn["realized_pnl"]).sum()
    return {"viajes": len(pn), "bruto": bruto_usd,
            "comisiones": comision,
            "deslizamiento": bruto_usd - neto_usd - comision,
            "neto": neto_usd}

tabla = pd.DataFrame({"cruce": resumen(bruto, neto),
                      "hueco": resumen(hueco_bruto, hueco_neto)})
ba = barras["cierre"].iloc[-1] - barras["apertura"].iloc[0]
print(f"buy and hold 300 shares: {300 * ba:,.0f} USD")
tabla.round(0)
OUT [09]Gross, costs and net of the two strategies
buy and hold 300 shares: 95,127 USD
                 cruce    hueco
viajes           953.0    623.0
bruto          40812.0  29367.0
comisiones      2859.0   1869.0
deslizamiento   5718.0   3738.0
neto           32235.0  23760.0
Fig. — From gross to net P&L in NautilusTrader: fixed 1.50 USD commission per order and one tick of slippage per fill, KAPA 2022-2025

The table and the figure tell two different stories.

  • The crossover (cruce) makes 40,812 USD gross, less than half of buying and holding (95,127): it is long part of the time in an asset that in this data rises 126% in four years, and what it makes comes from there, not from anticipating anything. Its 1,906 fills cost 2,859 USD in commissions and 5,718 in slippage: 21% of the gross. Net, 32,235 USD, a third of what doing nothing would have given.
  • The gap (hueco) makes 29,367 USD gross while being in the market only 25 minutes in 623 sessions, with or against the market depending on the sign of the gap. Costs take 5,607 USD (19%) and 23,760 remain.

The header figure shows the cumulative curve of each version month by month. That the gap curve rises steadily proves nothing about any real market: the effect is planted in the data with a known coefficient (−0.10), and the strategy recovers it. That is the correct use of this data: checking that the engine and the strategy find an effect known to exist, and that the control with no effect (the crossover) does not look good once costs are subtracted and it is compared with the trivial alternative. If the crossover had come out very profitable, the first thing to do would be to look for the bug.

The figures of a single backtest, moreover, do not say how much of that result is luck. That is module 2.5: separating samples, counting the attempts and deflating the Sharpe.

The same code, from backtest to live

NautilusTrader's main promise is that Cruce and Hueco do not change to trade live. What changes is the node that runs them and its adapters. There are three environments with the same kernel: backtest (historical data and simulated execution), sandbox (real-time data and simulated execution) and live (real data and execution, on a paper or real-money account).

StrategyBacktestEngineTradingNodeHistorical dataSimulated venueData clientExecution clientBroker or exchange
Mermaid source
flowchart TB
  S[Strategy] --> BE[BacktestEngine]
  S --> TN[TradingNode]
  BE --> HD[Historical data]
  BE --> SX[Simulated venue]
  TN --> DC[Data client]
  TN --> EC[Execution client]
  DC --> BR[Broker or exchange]
  EC --> BR

The code below follows the official example of version 1.231.0 for Interactive Brokers (link in the resources): a TradingNode with a data client and an execution client that connect to TWS or IB Gateway, and the same Hueco strategy on a real ETF. It is not executed in the course: it needs the adapter extra (uv add "nautilus-trader[ib]==1.231.0", which installs the IB API), an account and TWS open.

IN [10]hueco_en_real.pypython
from nautilus_trader.adapters.interactive_brokers.common import IB
from nautilus_trader.adapters.interactive_brokers.config import (
    IBMarketDataTypeEnum,
    InteractiveBrokersDataClientConfig,
    InteractiveBrokersExecClientConfig,
    InteractiveBrokersInstrumentProviderConfig,
    SymbologyMethod,
)
from nautilus_trader.adapters.interactive_brokers.factories import (
    InteractiveBrokersLiveDataClientFactory,
    InteractiveBrokersLiveExecClientFactory,
)
from nautilus_trader.config import (
    LiveDataEngineConfig, LoggingConfig, RoutingConfig,
    TradingNodeConfig)
from nautilus_trader.live.node import TradingNode

proveedor = InteractiveBrokersInstrumentProviderConfig(
    symbology_method=SymbologyMethod.IB_SIMPLIFIED,
    load_ids=frozenset(["SPY.ARCA"]),
)
config = TradingNodeConfig(
    trader_id="CURSO-001",
    logging=LoggingConfig(log_level="INFO"),
    data_clients={IB: InteractiveBrokersDataClientConfig(
        ibg_host="127.0.0.1", ibg_port=7497,  # paper TWS
        ibg_client_id=1,
        use_regular_trading_hours=True,
        market_data_type=IBMarketDataTypeEnum.REALTIME,
        instrument_provider=proveedor)},
    exec_clients={IB: InteractiveBrokersExecClientConfig(
        ibg_host="127.0.0.1", ibg_port=7497,
        ibg_client_id=2, account_id="DU0000000",
        instrument_provider=proveedor,
        routing=RoutingConfig(default=True))},
    # bars aggregated by the system stamped at the START, like
    # IB's (as the official example's comment says)
    data_engine=LiveDataEngineConfig(
        time_bars_timestamp_on_close=False,
        validate_data_sequence=True),
)
node = TradingNode(config=config)
node.trader.add_strategy(Hueco(HuecoConfig(
    instrument_id=InstrumentId.from_str("SPY.ARCA"),
    bar_type=BarType.from_str("SPY.ARCA-5-MINUTE-LAST-EXTERNAL"))))
node.add_data_client_factory(
    IB, InteractiveBrokersLiveDataClientFactory)
node.add_exec_client_factory(
    IB, InteractiveBrokersLiveExecClientFactory)
node.build()
try:
    node.run()          # blocks until Ctrl+C
finally:
    node.dispose()

On startup, the node reconciles its state with the broker: it aligns the orders and positions in its cache with the venue's reports before starting the strategies. It is what does not exist in a backtest and is essential live: if the process crashes with a live order, when it comes back it has to know what happened.

The documentation itself warns that the same code does not guarantee the same result. The differences that remain between hueco_neto and a real account:

In the backtestLive
The order executes at the bar's close in the same nanosecondIt arrives with latency (network, broker, venue) and the price has already changed
300 shares always available at the bar's price, plus one tickPartial fills, queue and liquidity gaps (lesson 2.4.8)
Bars stamped at the close by whoever prepares themEach vendor stamps them its own way (IB, at the start)
A single process, with no crashesDisconnections, reconnections and state reconciliation
Fixed commission of 1.50 USDThe broker's real fee, with minimums and maximums (lesson 2.4.7)

To bring the simulation closer, the venue accepts a LatencyModel and, with book data (L2 or L3) instead of bars, it matches orders against the recorded depth; with trades (trade_execution=True) and queue_position=True in add_venue, it also estimates how much quantity is ahead of each limit order in its queue (lesson 2.4.8). The reasonable intermediate step before trading with money is the sandbox: real live data and simulated execution, to check that the strategy receives what it expects and in the order it expects.

Exercises

  1. Add a one-second latency to the backtest venue (LatencyModel(base_latency_nanos=1_000_000_000) from nautilus_trader.backtest.models) and run Hueco with costs again. How much does the net change and why? (Hint: with bars only, the delayed order executes with the book of the next bar.)
Solution
IN [11]python
from nautilus_trader.backtest.models import LatencyModel


def backtest_lento(estrategia):
    motor = BacktestEngine(BacktestEngineConfig(
        logging=LoggingConfig(log_level="ERROR")))
    motor.add_venue(
        venue=VENUE, oms_type=OmsType.NETTING,
        account_type=AccountType.MARGIN, base_currency=USD,
        starting_balances=[Money(100_000, USD)],
        default_leverage=Decimal(1),
        fill_model=FillModel(prob_slippage=1.0, random_seed=7),
        fee_model=FixedFeeModel(Money(1.50, USD)),
        latency_model=LatencyModel(
            base_latency_nanos=1_000_000_000))
    motor.add_instrument(kapa)
    motor.add_data(bars)
    motor.add_strategy(estrategia)
    motor.run()
    return motor

lento = backtest_lento(Hueco(hcfg))
for nombre, m in [("no latency", hueco_neto), ("with 1 s", lento)]:
    f = m.trader.generate_order_fills_report()
    p = m.trader.generate_positions_report()
    horas = f["ts_last"].dt.strftime("%H:%M").iloc[:2].tolist()
    print(f"{nombre:13} first fills {horas}",
          f"· net {usd(p['realized_pnl']).sum():,.0f}")
OUT [11]Hueco with one second of latency
no latency    first fills ['14:35', '15:00'] · net 23,760
with 1 s      first fills ['14:40', '15:05'] · net 23,622

With one second of latency, the entry sent at the close of the 09:30 bar no longer finds that book: it executes with the book of the next bar, at 09:40 New York time (14:40 UTC in January), and the exit at 10:05 instead of 10:00. The strategy loses the first of the five bars with an effect it had left and adds one without it: in expectation, a fifth of what it captures. In this sample the net barely changes (138 USD less), because the lost bar and the added one have noise of the same order as that effect, and a single run does not separate one from the other (that is what the resampling of lesson 2.5.5 is for). What the run does show is the mechanism: with 5-minute bars, any delay turns into a whole bar.

  1. The moving-average crossover is a control with no edge in this data. Run it with 6- and 24-bar moving averages, with costs, and compare the number of round trips and the net with those of 12 and 48. What does it tell you about “optimizing” the periods?
Solution
IN [12]python
rapido = backtest(Cruce(CruceConfig(
    instrument_id=kapa.id, bar_type=bar_type, rapida=6, lenta=24)))
pr = rapido.trader.generate_positions_report()
print("round trips:", len(pr), "· net:", usd(pr["realized_pnl"]).sum())
OUT [12]Faster moving averages
round trips: 1925 · net: 24942.0

With faster moving averages there are twice as many round trips (1,925 versus 953) and twice the costs: the net falls from 32,235 to 24,942 USD. With no underlying edge, changing the periods only moves two things: how much time the system is long (and therefore how much of the drift it captures) and how much it pays in costs. Searching for the best pair of periods on this data would be choosing the most favorable noise, which is exactly the overfitting that lesson 2.5.4 and the experiment log of 2.2.4 measure.

  1. A colleague takes Hueco live with 5-minute bars from Interactive Brokers and compares its trades with those of the backtest. The entry times do not match: live, the strategy enters at 09:40. What has happened and how is it fixed?
Solution

The strategy compares the ts_event time with “09:35” because in the backtest the bars were stamped at the close. IB bars carry the start time (the comment in the official configuration example warns about it): the 09:30 to 09:35 bar has ts_event 09:30, and the one that carries “09:35” is the 09:35 to 09:40 bar, which only exists at 09:40. The strategy does what it is told, one bar late. There are two consistent fixes: normalize the timestamps at data entry (add the bar's duration, as was done here with indice2_5min.parquet) or write the rule on the computed close time (ts_event plus the bar_type period) and check it with the first bars that arrive. The general lesson: the timestamp convention is part of the data specification, and it has to be the same in the backtest and live.

Each answer is folded under its question

Self-assessment

  1. The KAPA bars are stamped with the start time (09:30 for the 09:30-09:35 bar). What has to be done before passing them to Nautilus?

    • ANothing: Nautilus reinterprets them on its own
    • BSubtract 5 minutes
    • CAdd 5 minutes so that the timestamp is the bar's close, the instant at which the complete bar exists
    • DConvert them to the New York time zone
    Show answer

    Answer C. Add 5 minutes so that the timestamp is the bar's close, the instant at which the complete bar exists

    The engine considers a bar known at its timestamp. With the timestamp at the start, the whole bar, high and close included, would be visible five minutes before it had formed. The Nautilus documentation asks for ts_init to be the close of the interval.

  2. In the simulated venue, FillModel(prob_slippage=1.0) with bars (L1 book) means that…

    • AEach fill comes out one tick worse than the bar's price
    • BHalf the orders do not execute
    • CLimit orders always execute
    • DMarket impact is simulated with the square root of the size
    Show answer

    Answer A. Each fill comes out one tick worse than the bar's price

    With an L1 book, prob_slippage is the probability that each fill is shifted one tick against you. With 1.0, all of them: in the moving-average crossover, 1,906 fills of 300 shares at 0.01 USD add up to 5,718 USD of slippage.

  3. Why does Money(0.005, USD) not work to charge 0.005 USD per share?

    • ABecause Money does not accept decimals
    • BBecause commissions are given as a percentage
    • CBecause Price has to be used
    • DBecause the precision of USD in Nautilus is 2 decimals and the amount is rounded to 0.01 USD: double the fee
    Show answer

    Answer D. Because the precision of USD in Nautilus is 2 decimals and the amount is rounded to 0.01 USD: double the fee

    Nautilus value types (Price, Quantity, Money) have fixed precision. A per-share fee smaller than a cent is modeled per order (FixedFeeModel with the amount for the full order) or as a percentage of the cash.

  4. What changes between the backtest and the live TradingNode if the strategy is the same class?

    • ANothing: the results will be identical
    • BThe data and execution clients (adapters), latency, partial fills and the reconciliation of state with the broker; the strategy's logic does not
    • CThe strategy, which has to be rewritten in Rust
    • DOnly the starting capital
    Show answer

    Answer B. The data and execution clients (adapters), latency, partial fills and the reconciliation of state with the broker; the strategy's logic does not

    The kernel, the cache, the bus and the strategy are the same; what changes are the pieces that talk to the outside world and the behavior of the real market, which no simulation fully reproduces. The documentation says so explicitly.

  5. The moving-average crossover makes 40,812 USD gross in 2022-2025 with 300 shares; buying and holding 300 shares would have made 95,127. What does that indicate?

    • AThat the crossover has an edge that is lost to costs
    • BThat the engine has a bug
    • CThat the moving-average periods need optimizing
    • DThat the crossover adds nothing in this data: it makes money by being long part of the time in an asset that rises, and costs take away another 21%
    Show answer

    Answer D. That the crossover adds nothing in this data: it makes money by being long part of the time in an asset that rises, and costs take away another 21%

    In indice2_5min.parquet the 5-minute returns have no autocorrelation outside the first half hour (it is a control with no effect). A moving-average crossover cannot anticipate anything; what it makes comes from the asset's drift while it is in. Optimizing the periods would be fitting noise.

Key points

  • NautilusTrader separates a compiled core (Rust; in 1.x with a Cython layer) from a control plane in Python: the strategy is a Python class that receives events.
  • Everything goes through the message bus: data goes from the adapter to the data engine, to the cache and to the strategy; orders, from the strategy to the risk engine, to the execution engine and to the venue.
  • Bars are timestamped at the close. With timestamps at the start, the engine would see each bar before it existed.
  • The simulated venue defines the costs: FillModel for slippage and the fill probability of limit orders, FeeModel for commissions, LatencyModel for delay.
  • In the course data, the moving-average crossover is a control with no edge and the opening gap has a planted effect; the engine does not tell one from the other: the validation of module 2.5 does.
  • The same Strategy runs in a TradingNode with real adapters; latency, partial fills and reconciliation are the differences that remain.

Resources

Data