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.
- Module
- 2.4 · Backtesting: engines and paradigms
- Duration
- 70 min
- Prerequisites
- 0.4.1 · Object-oriented programming: classes, inheritance, composition and dataclasses, 1.1.4 · Order types, 1.4.5 · Building bars: time, tick, volume, dollar and imbalance
- Author
- Jesús Cuesta
- Updated
Level 2 lessons
2.1 · Statistics and econometrics for trading
- 2.1.1 Hypothesis tests, p-values, bootstrap and regression with robust standard errors
- 2.1.2 Stationarity: ADF, KPSS, differencing and fractional differencing
- 2.1.3 ARMA and ARIMA models: autocorrelation (ACF) and partial autocorrelation (PACF)
- 2.1.4 Volatility models: ARCH, GARCH, EGARCH and GJR with the arch library
- 2.1.5 Cointegration: Engle-Granger, Johansen and the half-life of mean reversion
- 2.1.6 Kalman filter for dynamic hedge ratios
- 2.1.7 Regime detection: hidden Markov models and structural breaks
- 2.1.8 The multiple testing problem: Bonferroni, FDR and the “factor zoo”
2.2 · Anatomy of a trading system
2.3 · Strategy families
- 2.3.1 Trend following and momentum
- 2.3.2 Mean reversion: RSI and Bollinger, intraday reversal and gaps
- 2.3.3 Pairs trading and statistical arbitrage: distance, cointegration and Kalman
- 2.3.4 Carry in currencies, futures and bonds; seasonality and calendar effects
- 2.3.5 Factors (value, quality, low volatility, size) and event strategies
- 2.3.6 Introduction to market making: inventory, spread and adverse selection (Avellaneda-Stoikov)
- 2.3.7 Crypto-specific strategies: momentum, funding arbitrage and basis
2.4 · Backtesting: engines and paradigms
- 2.4.1 Vectorized backtest from scratch: matrices, lag, costs and limits
- 2.4.2 Event-driven backtest from scratch: loop, portfolio, orders and fills
- 2.4.3 vectorbt: massive parameter sweeps and portfolio simulation
- 2.4.4 backtesting.py and backtrader: two teaching engines and their status
- 2.4.5 NautilusTrader: architecture, data, strategies and the same code in backtest and live
- 2.4.6 LEAN/QuantConnect and Zipline Reloaded; bt for asset allocation
- 2.4.7 Cost modeling: commissions, bid-ask spread, impact, financing and securities borrowing
- 2.4.8 Fill modeling: limit orders, queue, partial fills, gaps and stops
2.5 · Biases and validation
- 2.5.1 Backtest biases: look-ahead, survivorship, selection and narrative
- 2.5.2 In-sample and out-of-sample: anchored and rolling walk-forward
- 2.5.3 Combinatorial purged cross-validation for strategies
- 2.5.4 Deflated Sharpe Ratio and Probability of Backtest Overfitting (PBO)
- 2.5.5 Monte Carlo: trade resampling, block bootstrap and signal permutations
- 2.5.6 Parameter sensitivity: plateaus versus peaks, robustness across assets and periods
- 2.5.7 Synthetic data for stress testing: GBM, GARCH and bootstrapping
- 2.5.8 “Honest backtest” checklist before moving to paper trading
2.6 · Performance metrics
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/latestalready describes that 2.0 architecture (Rust with PyO3 bindings) and some import paths in its examples (nautilus_trader.backtestinstead ofnautilus_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:
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)")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.
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
| Component | What it does |
|---|---|
NautilusKernel | Creates and configures the components, chooses the clock (simulated or real) and manages the life cycle |
MessageBus | Publish/subscribe, request/response and commands between components |
Cache | In-memory state: instruments, accounts, orders, positions, latest data |
DataEngine | Receives quotes, trades, bars and books; manages subscriptions and distributes them |
RiskEngine | Validates each order (quantities, notionals, balance, rate limits) before sending it |
ExecutionEngine | Routes orders to the client of their venue and processes the events coming back |
Portfolio | Balances, net positions, margin, and realized and unrealized P&L |
Trader | Registers 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.
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))KAPA.XNYS 0.01 USD
0.01 USD 1.50 USDThe 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.
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"))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:00The 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.
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.
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:
FixedFeeModelof 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_seedfixes 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.
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) 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:00The 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:
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())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: FILLEDMermaid 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:
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)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 USDFour 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.
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:
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)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.0The 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).
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.
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 backtest | Live |
|---|---|
| The order executes at the bar's close in the same nanosecond | It arrives with latency (network, broker, venue) and the price has already changed |
| 300 shares always available at the bar's price, plus one tick | Partial fills, queue and liquidity gaps (lesson 2.4.8) |
| Bars stamped at the close by whoever prepares them | Each vendor stamps them its own way (IB, at the start) |
| A single process, with no crashes | Disconnections, reconnections and state reconciliation |
| Fixed commission of 1.50 USD | The 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
- Add a one-second latency to the
backtestvenue (LatencyModel(base_latency_nanos=1_000_000_000)fromnautilus_trader.backtest.models) and runHuecowith 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
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}")no latency first fills ['14:35', '15:00'] · net 23,760
with 1 s first fills ['14:40', '15:05'] · net 23,622With 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.
- 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
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())round trips: 1925 · net: 24942.0With 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.
- A colleague takes
Huecolive 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
The
KAPAbars 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_initto be the close of the interval.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_slippageis 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.Why does
Money(0.005, USD)not work to charge 0.005 USD per share?- ABecause
Moneydoes not accept decimals - BBecause commissions are given as a percentage
- CBecause
Pricehas 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 (FixedFeeModelwith the amount for the full order) or as a percentage of the cash.- ABecause
What changes between the backtest and the live
TradingNodeif 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.
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.parquetthe 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:
FillModelfor slippage and the fill probability of limit orders,FeeModelfor commissions,LatencyModelfor 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
Strategyruns in aTradingNodewith real adapters; latency, partial fills and reconciliation are the differences that remain.