Back to blog
FinanceMarch 19, 2026 13 min read

NautilusTrader in Production: A Field Report

What happens when you move an event-driven trading stack from Python to a Rust core — and the specific pain you save (and don't).

HJ
Hasan Javed
senior full-stack & ai engineer

Eighteen months ago I migrated a working FX and crypto execution stack off a Backtrader-plus-custom-glue setup and onto NautilusTrader. The pitch that sold me: the same strategy code runs in backtest and in live trading. Not similar code. Not “porting is straightforward.” The same file, the same class, the same event handlers. Eighteen months in, that promise has held. A lot of other things have not held quite as cleanly. This is the honest field report.

NautilusTrader

Rust core, Python API, event-driven architecture built around an in-memory message bus and a cache-backed state machine.

Event-drivenRust + Python 3.11+Multi-venueRedis-backed cache
Core
Rust
API
Python
Venues
15+

If you’re weighing whether to make the jump, this essay is the thing I wish someone had handed me. It’s structured as three wins the Rust core actually delivers, three places the Python API still leaks, and three configuration mistakes that will cost you P&L if you don’t know about them upfront.

What NautilusTrader actually is, in one paragraph

NautilusTrader is an event-driven trading platform with a Rust core and a Python API. The core handles the matching engine, order book, clocks, message bus, and data feed; your strategy code is Python, organized as and subclasses that react to events. The venue adapter — backtest simulator, Interactive Brokers, Binance, Bybit, whoever — is swappable at configuration time. That’s the architecture. Everything below is commentary on what happens when you actually live with it.

architecture.py
python
# your code (unchanged in backtest or live)
class MyStrategy(Strategy):
    def on_quote_tick(self, tick: QuoteTick) -> None: ...
    def on_order_filled(self, event: OrderFilled) -> None: ...
    def on_position_closed(self, event: PositionClosed) -> None: ...

# backtest wiring
engine = BacktestEngine(config=BacktestEngineConfig(...))
engine.add_venue(venue=SIM, oms_type=OmsType.NETTING, ...)
engine.add_strategy(MyStrategy(config=...))
engine.run()

# live wiring — same strategy class
node = TradingNode(config=TradingNodeConfig(
    data_clients={"BINANCE": BinanceDataClientConfig(...)},
    exec_clients={"BINANCE": BinanceExecClientConfig(...)},
))
node.trader.add_strategy(MyStrategy(config=...))
node.run()
The conceptual surface: your Strategy subclass receives events from the bus and emits orders back to it. What's on the other end of the bus is the only thing that changes between backtest and live.

What the Rust core actually earns you

Win #1 — backtest speed that changes how you work

The first thing that hits you is the speed of a realistic backtest. A tick-level simulation of a multi-venue crypto strategy that took my Backtrader setup most of an afternoon runs in under six minutes on Nautilus. Not because I’m a better engineer than I was before — the same logic, ported almost verbatim — but because the event loop, order book, and clock are in compiled code.

The behavioral consequence of that speedup is the thing worth noting. When a backtest costs an afternoon, you run it once and you’re married to the result. When it costs six minutes, you run it twenty times with different assumptions. You stop trusting single numbers and start trusting distributions. That is a qualitatively different research workflow and it has been, for me, the largest P&L-adjacent effect of the migration.

Win #2 — the matching engine is actually honest

Most backtesters handle limit orders with a lie. The lie is some variation of “if the bar’s low touched your price, you filled.” That lie is the reason so many quant strategies print beautiful backtests and lose money live: the real matching engine has a queue, and you were behind everyone who placed their order before yours.

Nautilus’s simulated venue models queue position, partial fills, and — critically — order-book events that happen between your decision and the next tick. It is not perfect; no simulator is. But it is the first open-source backtester I’ve used where the difference between backtest P&L and live P&L is usually within my ability to explain.

venue_config.py
python
from nautilus_trader.backtest.models import (
    FillModel, LatencyModel, FeeModel,
)

engine.add_venue(
    venue=BINANCE,
    oms_type=OmsType.NETTING,
    account_type=AccountType.MARGIN,
    base_currency=USDT,
    starting_balances=[Money(100_000, USDT)],
    fill_model=FillModel(
        prob_fill_on_limit=0.85,        # not 1.0
        prob_fill_on_stop=0.95,
        prob_slippage=0.25,
        random_seed=42,
    ),
    latency_model=LatencyModel(
        base_latency_nanos=25_000_000,   # 25ms — measure yours
        insert_latency_nanos=5_000_000,
        update_latency_nanos=5_000_000,
        cancel_latency_nanos=5_000_000,
    ),
    fee_model=FeeModel(
        maker_fee=Decimal("0.0002"),
        taker_fee=Decimal("0.0005"),
    ),
)
The venue config where you opt into realistic fills. Every field here is something most backtesters silently hardcode.

Win #3 — one codebase, two worlds

This is the headline and I’ll still underrate it. The strategy class I wrote for a GBP/USD mean-reversion model in June 2025 is, today, the same class running live on a prime broker account and the same class I use for nightly re-backtests against the previous 30 days. The imports are the same. The event handlers are the same. The only thing that changes is the engine configuration — backtest engine vs. live engine — and that’s a YAML diff.

The reason this is such a big deal: the most dangerous bugs in algo trading live in the gap between “backtest logic” and “live logic,” because the two were written by the same person three months apart with subtle differences nobody remembers. When that gap disappears, an entire category of production incidents disappears with it.

Where the Python API still leaks

Leak #1 — error messages from the Rust side

When something goes wrong inside the Rust core — a malformed order, a bad instrument ID, a subtle timestamp-ordering violation — the Python error message is not always friendly. You get the error type and a partial trace, but the root cause often lives in Rust code you don’t immediately have visibility into.

The workaround is to read the Rust source. It’s fine, honestly. The crate is legible Rust. But if the sentence “read the Rust source” makes you want to put this essay down, you should know this is a real ongoing tax of using the framework.

Leak #2 — documentation lags the code

Nautilus ships meaningful changes every few weeks. The docs do not always keep up. I’ve had more than one afternoon consumed by an API that was documented one way in the current docs and behaved another way because a refactor had landed three PRs ago.

The Discord is active and helpful; the maintainers answer real questions. But factor this into the learning curve. The shrink-wrapped documentation experience of a mature library like is not where you are.

Leak #3 — instrument definitions

The instrument model — the object that describes what you’re trading, its precision, tick size, margin, lot rules — is the single part of Nautilus I’ve tripped over most. The API exposes a lot of structure because real instruments are that complicated, and getting the instrument definition wrong produces failures that are sometimes loud and sometimes silent.

instrument.py
python
from nautilus_trader.model.instruments import CurrencyPair
from nautilus_trader.model.objects import Price, Quantity, Currency

GBPUSD = CurrencyPair(
    instrument_id=InstrumentId.from_str("GBP/USD.SIM"),
    raw_symbol=Symbol("GBP/USD"),
    base_currency=GBP,
    quote_currency=USD,
    price_precision=5,            # 1.23456 — verify against the venue
    size_precision=0,
    price_increment=Price(1e-5, 5),
    size_increment=Quantity(1, 0),
    lot_size=Quantity(1_000, 0),  # micro lot
    max_quantity=Quantity(50_000_000, 0),
    min_quantity=Quantity(1_000, 0),
    max_price=Price(10.0, 5),
    min_price=Price(0.0001, 5),
    margin_init=Decimal("0.03"),
    margin_maint=Decimal("0.03"),
    maker_fee=Decimal("0"),
    taker_fee=Decimal("0"),
    ts_event=0,
    ts_init=0,
)
Every field here is load-bearing. A tick size off by 10× is a silent bug that will look almost right until it doesn't.

The silent version is the one that matters. A tick size off by one order of magnitude will fill your orders but with prices that round to the wrong decimal, and your P&L will look almost right until it doesn’t. Be paranoid about instrument definitions. I do not say this lightly.

Three configuration mistakes that will cost you money

Mistake #1 — backtest latency set to zero

The default simulated venue has near-zero round-trip latency between the strategy emitting an order and the matching engine accepting it. This is wrong for almost any real venue. The correct number is whatever you measure from your live deployment: typically 5–50ms for colocated FX, 80–200ms for a retail crypto exchange over the public internet.

Real fills include an adversity term Δ that grows with latency ℓ — the market moves against you during your round trip. Your backtester must model it or it's lying.

Set it explicitly via the shown above. A strategy whose edge comes from reacting to fast moves will look brilliant at zero latency and die in production. Ask me how I know.

Mistake #2 — using system time in strategies

Nautilus ships a clock abstraction. The reason this exists is that in backtest mode it returns the simulated event time, and in live mode it returns wall-clock time. If you reach for inside a strategy — because you’re used to it, because it’s shorter — your backtest and your live trading silently diverge, and the bugs that follow are horrifying.

time_handling.py
python
# GOOD — uses the framework clock (simulated in backtest, wall-clock live)
def on_quote_tick(self, tick: QuoteTick) -> None:
    now_ns = self.clock.timestamp_ns()
    if now_ns - self.last_trade_ns > self.cooldown_ns:
        self._maybe_trade(tick)

# BAD — silently correct in live, silently wrong in backtest
import time
def on_quote_tick(self, tick: QuoteTick) -> None:
    now = time.time()          # ← wall clock during a 2022 replay? no
    ...
The right way (top) vs. the dangerous way (bottom). Grep your codebase for time.time() before every deployment.

Mistake #3 — persisting state carelessly

Live strategies accumulate state: open positions, partially filled orders, risk limits, feature buffers. When you restart the process — scheduled restart, deploy, or crash — that state has to come back consistently. Nautilus offers a cache and a message bus that can be persisted via Redis, but the correct persistence configuration is strategy-specific and underdocumented.

cache_config.py
python
from nautilus_trader.cache.config import CacheConfig
from nautilus_trader.common.config import DatabaseConfig

cache = CacheConfig(
    database=DatabaseConfig(
        type="redis",
        host="redis",
        port=6379,
        timeout=20,
    ),
    # Critical: persist to disk across restarts.
    # Without these two, a restart thinks it owns no positions.
    tick_capacity=10_000,
    bar_capacity=10_000,
    flush_on_start=False,   # ← if True, your state is gone
    drop_instruments_on_reset=False,
)
The persistence config that has to be right, with comments on what bites.

The failure mode if you get it wrong is not a crash. It’s a restarted process that believes it has no open positions and cheerfully opens new ones on top of the old. This is an expensive class of bug to learn about empirically.

The honest cost-benefit, eighteen months in

I came in expecting the Rust core to be the big thing. It’s been a medium thing. The genuinely big thing has been the collapse of the backtest-vs-live gap. That collapse made me a better researcher and a safer operator, and it is the reason I can’t see myself going back.

Against that: the learning curve is real, the documentation lags the code, and the instrument model will trip you up at least once. Budget three weeks to feel productive, not three days. Expect to read Rust source. Expect to hang out on the project Discord for the first month.

If you’re running daily-bar equity strategies, NautilusTrader is more machinery than your problem deserves. Stick with Backtrader or Zipline-Reloaded. If you’re running anything intraday in FX, crypto, or futures — anywhere the shape of your fills actually determines your edge — Nautilus is the framework I’d pick today, knowing what I now know, without hesitation.

The same code, in backtest and in live. That’s not a feature. That’s an entire category of bug that no longer exists in your codebase.

Everything else is tooling. Everything else you can fix. The one-codebase property, if you care about it, is the rarest and most valuable thing any trading framework can give you. Nautilus is, right now, the only one in Python that actually delivers it. That is, by itself, enough.

#nautilustrader#rust#event-driven#execution#live-trading#trading#production#devops
Subscribe

Get the next essay in your inbox.

Tuesday weekly. Mathematics, finance, and AI — written like an engineer, not a marketer.

Free. Weekly. One click to unsubscribe. Hosted on Buttondown.

Found this useful?
Share it — it helps the next person find this work.
XLinkedIn
the end · over to you

If this resonated, let's talk.

I help startups ship production-grade systems — fintech, AI, high-throughput APIs — from MVP to 100K users. If something here sparked an idea for your stack, I'd be glad to hear it.

Continue reading

All essays