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.
Rust core, Python API, event-driven architecture built around an in-memory message bus and a cache-backed state machine.
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.
# 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()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.
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"),
),
)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.
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,
)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.
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.
# 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
...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.
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 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.
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.