---
title: "2.4.5 NautilusTrader: architecture and strategies"
description: "NautilusTrader inside: Rust core, Python strategies, bars, a simulated venue with costs and the same code from backtest to live trading."
url: "https://pythonparatrading.com/en/python-trading-course/level-2/nautilustrader-architecture-and-strategies/"
lang: "en"
image: "https://pythonparatrading.com/og/en/python-trading-course/level-2/nautilustrader-architecture-and-strategies.png"
---

# 2.4.5 · 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.

- Course: [Level 2: Systematic strategies and rigorous backtesting](https://pythonparatrading.com/en/python-trading-course/level-2/)
- Module: 2.4 · Backtesting: engines and paradigms
- Duration: 70 min
- Prerequisites: [0.4.1 · Object-oriented programming: classes, inheritance, composition and dataclasses](https://pythonparatrading.com/en/python-trading-course/level-0/python-classes-inheritance-dataclasses/), [1.1.4 · Order types](https://pythonparatrading.com/en/python-trading-course/level-1/order-types-explained/), [1.4.5 · Building bars: time, tick, volume, dollar and imbalance](https://pythonparatrading.com/en/python-trading-course/level-1/time-tick-volume-and-dollar-bars/)

## By the end of this lesson

- Describe the architecture of NautilusTrader (kernel, message bus, cache, data, risk and execution engines, adapters) and what changes between the 1.x series and 2.0.
- Convert your own bars to the Nautilus format with the timestamp at the close and define an instrument without precision errors.
- Write a strategy as a subclass of `Strategy`, with its configuration, indicators and orders, and run it in a `BacktestEngine` with commission and slippage models.
- Read the reports of filled orders, positions and account, and separate the gross result from the costs.
- Explain what it takes to bring the same strategy to a live `TradingNode` and what differences remain between simulation and market.

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

```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)")
```
```output
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
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.

```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))
```
```output
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.

```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"))
```
```output
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).* Two KAPA 5-minute bars, 09:30 to 09:35 and 09:35 to 09:40, drawn as the open, high, low and close path the engine simulates. A dashed line at 09:30 marks the mistake of using the start time, when the bar does not exist yet. The highlighted line at 09:35 is ts_init, the close: the engine hands the bar to on_bar and an order sent there fills against the book at that close. With a latency model, the order is matched against the next bar's book, at 09:40.

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.

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

```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)
```
```output
                             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:

```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())
```
```output
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
```

```mermaid
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:

```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)
```
```output
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.

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

```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)
```
```output
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.* Horizontal bars in USD. Buying and holding 300 shares makes 95,127. The moving-average cross makes 40,812 gross, pays 2,859 in commissions and 5,718 in slippage and keeps 32,235 net (highlighted): less than half of doing nothing. The gap strategy makes 29,367 gross, pays 1,869 and 3,738 and keeps 23,760.

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

```mermaid
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.

```python skip title="hueco_en_real.py"
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

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

```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}")
```
```output
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.

2. 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.**

```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())
```
```output
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.

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

## 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?

   - A. Nothing: Nautilus reinterprets them on its own
   - B. Subtract 5 minutes
   - C. Add 5 minutes so that the timestamp is the bar's close, the instant at which the complete bar exists
   - D. Convert them to the New York time zone

   *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…

   - A. Each fill comes out one tick worse than the bar's price
   - B. Half the orders do not execute
   - C. Limit orders always execute
   - D. Market impact is simulated with the square root of the size

   *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?

   - A. Because `Money` does not accept decimals
   - B. Because commissions are given as a percentage
   - C. Because `Price` has to be used
   - D. Because the precision of USD in Nautilus is 2 decimals and the amount is rounded to 0.01 USD: double the fee

   *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?

   - A. Nothing: the results will be identical
   - B. The data and execution clients (adapters), latency, partial fills and the reconciliation of state with the broker; the strategy's logic does not
   - C. The strategy, which has to be rewritten in Rust
   - D. Only the starting capital

   *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?

   - A. That the crossover has an edge that is lost to costs
   - B. That the engine has a bug
   - C. That the moving-average periods need optimizing
   - 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%

   *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

### Research

- [Quantitative Models and Algorithmic Execution](https://pythonparatrading.com/en/quantitative-models-algorithmic-execution/) · Research-0009 · 11 min

### Documentation

- [NautilusTrader: architecture (official documentation)](https://nautilustrader.io/docs/latest/concepts/architecture) · nautilustrader.io
- [NautilusTrader: bar execution (timestamp and OHLC order)](https://nautilustrader.io/docs/latest/concepts/backtesting/bar-execution/) · nautilustrader.io
- [NautilusTrader: fill models](https://nautilustrader.io/docs/latest/concepts/backtesting/fill-models/) · nautilustrader.io
- [NautilusTrader 2.0.0rc3: release notes (removal of the Cython package)](https://github.com/nautechsystems/nautilus_trader/releases/tag/v2.0.0rc3) · github.com
- [Official TradingNode example with Interactive Brokers (v1.231.0)](https://github.com/nautechsystems/nautilus_trader/blob/v1.231.0/examples/live/interactive_brokers/connect_with_tws.py) · github.com

### Data

- [Level 2 data: 5-minute bars of the KAPA ETF (Parquet)](https://pythonparatrading.com/curso/datos/nivel-2/indice2_5min.parquet) · Download · indice2_5min.parquet

Previous lesson: [2.4.4 · backtesting.py and backtrader: two teaching engines and their status](https://pythonparatrading.com/en/python-trading-course/level-2/backtesting-py-and-backtrader/)

Next lesson: [2.4.6 · LEAN/QuantConnect and Zipline Reloaded; bt for asset allocation](https://pythonparatrading.com/en/python-trading-course/level-2/lean-zipline-reloaded-and-bt/)
