NautilusTrader — the Rust engine that wrote down, itself, where backtest and live diverge

A 29,000-star Rust trading engine installed in 29 seconds, 13,650 nanosecond ticks replayed in 48 milliseconds, and the fill-assumption knobs turned to see how

Part 10. NautilusTrader — the engine that wrote down, itself, where backtest and live diverge

Done when: you have run a backtest on nanosecond quote ticks without issuing a single key, then turned only the fill-assumption knobs — without changing one character of the strategy — to measure how far the result moves.

What it does Takes quotes, trades and bars as events carrying nanosecond timestamps, hands them to a strategy, fills them on a simulated exchange and manages positions. Backtest, sandbox and live all live inside one engine
Who it fits Anyone with tick data who wants to measure how far fill assumptions move a result. Anyone who has given up debugging because a backtest would not reproduce
Who it doesn't fit Anyone with only bars who wants a fast answer · anyone who wants to drive it by conversation · anyone who wants a screen to leave running and watch (there is no dashboard)
What you end up with 12,000 nanosecond quote ticks pulled with no key, a backtest that produced the same hash five times, a table of nine fill assumptions, and a record of fills dropping from 45 to 19 once the book was allowed to be consumed
What you need · cost Code free (LGPL-3.0) · Model none (this tool does not use an LLM) · Market data free, 0 API keys · weltiq only if you follow section 5
Time · difficulty 29 seconds to install, a minute to the first backtest. Difficulty high — get a bar timestamp wrong and you get a wrong result with no error
Relationship to weltiq This overlaps rather than connects — the engine already holds backtest, paper and live inside itself. The one seam is comparing candles, and the real meeting point is putting the two "differences documents" side by side (section 5)
Test environment · date macOS 26.6.2 · arm64 · CPython 3.11.16 · nautilus_trader 1.221.0 · 2026-09-22. Only the last knob ran on 3.14.7 · 1.231.0, and that is marked where it appears
Disclosure weltiq pays me to write this series. Everything said about the tool comes from runs I did myself

I created no exchange account and no API key. The only thing I asked an exchange for was public market data, and every order here happened inside a local backtest. The tickers and P&L numbers are examples used to verify behaviour, not trading opinions.


1. What this is

nautechsystems/nautilus_trader is an event-driven trading engine written in Rust. You write strategies in Python and the Rust engine runs them. It started in 2018 and is maintained with a rare "built for institutions to use" attitude, so what there is to look at is not "how easy is it" but "how far does its honesty go".

Item Value How it was checked
URL github.com/nautechsystems/nautilus_trader
License LGPL-3.0 — linking against it is not infectious, but modify and distribute the library and you must publish those changes GitHub API
Stars ★29,234 · 3,860 forks (observed 2026-09-21) GitHub API
Last push 2026-09-21 GitHub API
Created 2018-06-25 · 128 open issues · not archived · default branch develop GitHub API
Language split Rust 93.6% (70.8 MB) · Python 5.0% (3.8 MB) GitHub API
Version PyPI nautilus_trader 1.221.0 · repo is at v2.0.0rc5 (2026-09-15) pip install · GitHub API

The heart of this part is one page of this project's docs

The first paragraph of the overview says the same strategy and execution-algorithm code runs across backtest and live systems. Plenty of projects write that sentence. What is different is that the very next line points at a page saying "except here is where they differ". The first section of docs/concepts/live.md is titled "Backtest and live differences", and it lists six axes (summarized).

Axis What is new in live
Venue Exchange rules and adapter capability decide which order types and instructions are possible at all
Transport When the network drops, a command's outcome can remain unknown
Timing Independent inputs interleave, and the runner does not guarantee one global FIFO order
Persistence Cache-backend writes run separately from sending to the exchange
External activity Exchange reports include orders created outside this node
Reconciliation At startup and while running, local state is corrected against exchange reports

The page Transport points to splits evidence three ways — definitively failed locally / explicitly confirmed by the venue / outcome unknown — and says of the third that you must not invent an outcome, only log it.

weltiq wrote the same thing after reading this. weltiq had also claimed "mode branching only at adapter boundaries", but the claim had no list behind it. What came out of that is MODE_DIFFERENCES.md, and section 5 puts the two side by side. Not listing your differences does not make you more honest, it makes you less verifiable.


2. Install

One Python is all you need, and no API key or exchange account. Do make a dedicated virtualenv — it installs 108 compiled extension modules, 257 MB.

python3 -m venv ~/venvs/nautilus          # 3.12 or newer
~/venvs/nautilus/bin/pip install nautilus_trader

29.3 seconds, 15 packages, of which 20 seconds is downloading a 93.2 MB wheel. There are only fifteen dependencies because most of it is already inside, in Rust, and the backtest engine alone is a 63.8 MB compiled binary (next is nautilus_pyo3 at 62.3 MB). That is a Rust event core rather than a Python loop, and it is the reason for the speed and determinism later on.

Choosing a Python version is choosing a feature set

I got this wrong, and I am writing it down as it happened. Seeing that this Mac's default Python was 3.14, I assumed "new version, no wheels yet" and installed on 3.11 without checking. Asking pip index versions later, 3.11 only sees up to 1.221.0 and 3.14 starts at 1.231.0 — the two lists do not overlap by a single entry. Support for 3.11 was dropped at 1.222.0. Pick an old Python and you silently get something ten releases behind, with no error and no warning.

3.11 → 1.221.0 3.14 → 1.231.0
FillModel(prob_fill_on_stop=…) present gone (removed in 1.222.0)
add_venue(liquidity_consumption=…) absent present ← the key knob in section 4
create_tearsheet() (section 3) ImportError present
latency_model · bar_adaptive_high_low_ordering present present

The nautilus_trader versions visible under Python 3.11 and under 3.14

The same command asked in two virtualenvs. Look at the first line of each of the two middle blocks — 3.11 says nautilus_trader (1.221.0), 3.14 says (1.231.0). In the two Available versions lines below them not one number is shared. The Python 3.14.7 at the top is this Mac's default Python — the one I assumed had no wheels.

The body of this article ran on 3.11 · 1.221.0, and only the last knob in section 4 ran on 3.14 · 1.231.0. I did not mix the numbers.

Where it gets stuck

The docs track the develop branch (2.x rc) while pip install gives you 1.x. Read today's docs, install today, and things will not line up — and how badly depends on your Python version.

Symptom Cause Workaround
An argument in the docs does not exist your Python is old, so you got an old release check with pip index versions nautilus_trader
ImportError: … 'DefaultFillModel' the docs are ahead. Both versions fail nautilus_trader.backtest.models.FillModel
The 11 fill models (BestPriceFillModel etc.) are missing docs only. Neither 1.x wheel has them wait for the 2.0 release — unverified
TypeError: … 'prob_fill_on_stop' that argument is gone after 1.222.0 drop it from the call
invalid tick.bid_price.precision=4 … price precision differs from the instrument definition Price(float(x), inst.price_precision)
iterations runs but zero fills bar timestamps are the interval's start, or book_type does not match the data set ts_init to the interval's close. Order-book deltas fed into L1 are ignored
Cannot check MARKET order risk: no prices you fed bars but no quotes, so the risk engine skips its pre-trade check with bars only you can leave it; the engine just told you what it is not looking at

Conversely, there is something in the wheel that is not in the docs: latency_model=LatencyModel(base_latency_nanos=…), which injects nanosecond-level latency on submit, modify and cancel. It is in both versions, and it comes back in section 6.


3. First result

The smallest backtest is 89 daily BTCUSDT bars · one venue · one strategy. Pull 90 bars from the keyless public API (api.binance.com/api/v3/klines), drop the last one because it has not closed, and run the remaining 89. With a 20-day high breakout strategy attached:

BUY  MARKET 0.010000 @ 65043.98  2026-07-14 23:59:59.999999999+00:00
SELL MARKET 0.010000 @ 75644.48  2026-09-15 23:59:59.999999999+00:00
position FLAT  65043.98 → 75644.48  realized_pnl 104.59811540 USDT  (0.16297)
iterations = 89

First backtest result — two fills and one position

The smallest backtest, finished. iterations = 89 at the bottom means it walked 89 bars, and the two lines BUY MARKET / SELL MARKET in the fills table above mean the engine ran properly. The .999999999 at the end of the timestamps is nanosecond resolution. (Log lines were stripped and only the results kept.)

That is what success looks like. It took 0.53 seconds. The P&L number is evidence that the engine ran, not a recommendation of any strategy.

There is a trap in bar timestamps. To use fill simulation, ts_init must be the close of that interval. Otherwise you see bars before they complete, which is lookahead. In Binance klines [0] is the open and [6] the close, so you must use [6]. The full script is in run01_first.py.

There is no dashboard — two ways to see results

No resident dashboard, no web UI, no TUI. This is a library, not an application. Do not spend time looking. There are two alternatives.

① Print tables. eng.trader.generate_account_report(venue) · generate_order_fills_report() · generate_positions_report() return pandas frames. Nothing extra to install, and this is the one you will reach for most.

② A tearsheet. create_tearsheet(engine=eng, output_path="tearsheet.html") writes a performance summary as self-contained HTML. It needs 1.231.0 or newer plus pip install "nautilus_trader[visualization]" (3.98 seconds). Built from 2,159 hourly bars it produces a single 4,868,898-byte file — Plotly is embedded whole, which is why it is that big and why it opens with no server, no internet, no login and no port: open tearsheet.html, and it opens the same way for anyone you send it to.

Tearsheet, first screen — run info and performance stats tables, equity curve and drawdown

The tearsheet opened in a browser. The two tables at the top are run info (left) and performance stats (right), with the equity curve and drawdown below. Iterations 2_159 · Total orders 61 · Total positions 31 on the left give the size of this backtest, and Elapsed time 0 days 00:00:00.106934 is how long the engine actually ran (0.107 s).

Lower part of the tearsheet — monthly returns, return distribution, rolling Sharpe, annual returns

The same sheet scrolled down. Six charts (equity curve, drawdown, monthly returns, return distribution, rolling Sharpe, annual returns), all Plotly, so hovering shows values. Note that "Rolling Sharpe Ratio (60-day)" at the bottom left is empty — the window is only 89 days, so a 60-day rolling figure barely draws. An empty panel there is normal.

One thing to check before trusting the sheet. On the same screen PnL (total USDT) is −3,542.5035 while the equity curve ends at 1.24, up from 1.0. The directions disagree. The run ended holding 0.05 BTC and the metrics appear to count that open piece differently, but I did not chase it to the end — unverified. If you quote a number from here, first check what each metric counts.


4. Getting the most out of it — leave the strategy alone and turn only the fill assumptions

First, what changes when the engine is fed ticks instead of bars. From Binance's public WebSocket, with no key, I captured 12,552 quote ticks and 1,098 trade ticks over 90 seconds (139 per second) and poured them in as they were.

total ticks    : 13650  (quotes 12552 · trades 1098)
unique ns      : 13650
unique ms      : 3788
rounded to ms  : 9862 become ties (72.2%)
fill ts_event=1790009103313999000 px=85857.19 qty=0.002000
     liquidity=<LiquiditySide.MAKER: 1> commission=0.17171438 USDT

Those two lines say why nanoseconds matter. All 13,650 ticks are distinct instants at nanosecond resolution, but rounded to milliseconds the distinct count falls to 3,788 and 9,862 (72.2%) become ties. Once they tie, the engine picks the order arbitrarily — if "the quote touched my limit" and "a trade printed" land in the same millisecond, the processing order decides whether my order filled. That happens on 70% of the ticks.

Stated plainly. Those nanosecond timestamps are our receive time (time.time_ns()), not the exchange's publication time; Binance's bookTicker payload has no event-time field. So "72.2%" means "had this stream been recorded in milliseconds, this much ordering information would have been lost", not that this much happened simultaneously at the exchange.

A limit one tick below best bid (85,857.19) filled as maker 2.298 seconds later (slippage 0.0, commission 0.17171438 USDT). And it is fast — eng.run() took 0.0479 seconds, 285,114 ticks per second, replaying 90 seconds of market in 48 milliseconds, 1,880× real time (measured twice: 1,880× and 1,779×). That means a day of ticks in 46 seconds.

Nanosecond tick backtest and throughput measurement

The tick backtest on top, the throughput measurement below. Read the two lines unique ns : 13650 and unique ms : 3788 together — the same batch of ticks, and dropping to milliseconds makes 9,862 of them ties. In the order log, liquidity=<LiquiditySide.MAKER: 1> means the limit filled as maker. (The fills report was too wide, so it is cropped.)

I also checked by hash whether the same input really gives the same output. I SHA-256'd the fills and orders reports with only the execution timestamps and UUIDs removed, and ran twice in the same process plus three times in separate processes. All five produced ac16db29…f15256, down to identical order IDs — because those IDs come from the data clock, not the wall clock. If a result does not reproduce, you cannot ask why it turned out that way.

The same backtest run five times, producing the same hash

The top two lines are two runs in the same process, the bottom three are three separate processes. All five hash values are ac16db29…f15256. You can verify by eye from the first eight and last six characters.

That only holds for "same data, same result". Turn on a fill model that uses randomness and you have to pin the seed, and in live it does not hold at all. That is exactly what the project means when its own docs say the live runner does not define a global FIFO order.

And here is where this tool belongs

Backtests usually become untrustworthy not because the strategy is wrong but because fills were treated too generously, and there is normally no way to ask how generously. NautilusTrader exposes those assumptions as settings.

I fixed the conditions and did not change one character of the strategy. Bars: 2,159 hourly BTCUSDT bars (90 days, in-progress bar excluded); rule: break the 20-hour high → resting limit buy at that bar's close → a ±0.4% bracket; capital 1,000,000 USDT. The bracket is that tight because both the stop and the target have to fall inside a single bar for "which one was touched first" to decide the outcome.

          label            limit-p  stop-p  path adaptive  submitted fills exits wins  final USDT   net
baseline — everything fills   1.00   1.00       False        97    194   97    52  999415.20 -584.80
  (limit 0.75/0.5/0.25 and stop 0.75/0.5/0.25: six rows, all identical to the above)
      intrabar path adaptive  1.00   1.00        True        97    194   97    50  999359.55 -640.45
        all three conservative 0.50   0.50       True        97    194   97    50  999359.55 -640.45

Results table for nine fill assumptions

Strategy fixed, only the fill assumptions varied across nine runs. The rightmost numbers on the top seven rows are all identical at 52 999415.20 −584.80 — dropping the probability from 1.00 to 0.25 changed nothing. Yet only the two bottom rows, with path adaptive set to True, split off at 50 999359.55 −640.45. That is the only knob that moved. (All nine rows are in the run log.)

Finding 1 — the probability knobs did not move anything on bars. Not a bug. These probabilities apply only when the market touches your price without trading through it, and on bars the engine turns each bar into a synthetic book of four points, open → high → low → close, skipping everything between. An exact "touch" almost never happens. If you backtest on bars, this knob effectively does not exist for you — that is a limit of bars as data rather than of the tool, and the docs say so.

Finding 2 — the intrabar path did move. Setting bar_adaptive_high_low_ordering to True shifted winning exits from 52 to 50 and net P&L from −584.80 to −640.45 USDT. The default always walks open → high → low → close; True walks whichever extreme is closer to the open first. With both the stop and the target inside one bar, whichever is touched first fills first, and that flipped the direction of two exits. The docs describe the heuristic themselves as "a deterministic heuristic, not a reconstruction of the actual fill order". They give you the knob without claiming accuracy.

So I turned the same knob on ticks. I had to settle whether finding 1 meant "broken knob" or "bars are the limit". One limit order on best bid every 200 quote ticks — exactly the "touch only" case.

prob_fill_on_limit   submitted   fills   fill rate %
              1.00      62        68       109.7
              0.75      62        67       108.1
              0.50      62        67       108.1
              0.25      62        68       109.7
              0.00      62        49        79.0

On ticks it bites. The shape is not clean, so here it is as observed. ① Fill rates exceed 100% — one order draws several fill events, i.e. partial fills, which the bar backtest did not have at all. ② Even at 0.0 (never fill on touch) 49 still fill — those are cases where the market traded through the limit, which this probability does not govern. So of the 68, only 19 (28%) were subject to the "touch" rule. ③ The 1.0 → 0.25 range is not monotonic (67 · 67 · 68). N = 62 is a small sample, so do not read a direction into it.

The most important knob — does my fill consume the book

I could not use this one at first — 1.221.0 does not have it. After finding the version mistake in section 2 I reinstalled on Python 3.14 · 1.231.0 and ran this one cell separately.

liquidity_consumption controls whether my fill decrements that price level's size. Off (the default), the same book is reused by consecutive orders as many times as they come; on, each level records what has been consumed and only what remains fills until a new quote arrives. On the same quote ticks I threw five consecutive 0.5 BTC market buys.

python 3.14.7 · nautilus_trader 1.231.0

liquidity_consumption :          False   |             True
                 fills :             45   |               19
       distinct prices :              9   |               11
         lowest fill   :       85842.14   |         85842.14
        highest fill   :       85883.78   |         85883.78
      weighted average :     85865.0224   |       85872.9493

difference in average fill price for the same burst: +7.9269 USDT (+0.92 bp)

Fills with book consumption on versus off

The default (no consumption) on the left, liquidity_consumption=True on the right. Look at the line fills : 45 | 19 — the same burst of orders, and 26 of them never filled at all. The +0.92 bp on the bottom line is the difference in average fill price among the ones that did. The top line python 3.14.7 · nautilus_trader 1.231.0 marks this cell as having run on a different version.**

Fills dropped from 45 to 19. Twenty-six of them never filled at all because the book ran out, and the ones that did filled 0.92 bp worse on average. This is the number that collides hardest with the weltiq story — item 1 on weltiq's "what we do not imitate" list in MODE_DIFFERENCES.md is exactly this (the book is read but never decremented), and that gap is papered over with a constant β bps (1 bp on the Binance spot profile). The 0.92 bp here is a similar size to that constant, but this is one measurement under one condition, not a distribution. And the bigger story is the fills halving — weltiq is currently filling all 26 of those.

If you take one thing from this section: a backtest P&L number needs "under which fill assumptions" attached to it. Because this tool exposes those assumptions as settings, it can answer with a range instead of a single number.

What this part did not do

Putting two venues (BINANCE spot + BYBIT perpetual), three instruments and three strategies into one engine did work — iterations : 267 (89×3), different account types running inside one engine, with only configuration growing and no structural change. But there is no cross-venue transfer or arbitrage settlement. The rest, written down rather than quietly dropped:


5. Moving it to weltiq — this overlaps rather than connects

NautilusTrader holds backtest, sandbox and live inside itself. Of the four stages (analysis → systematizing → paper → trade analysis) it packs the last three into one engine and then goes on to live by itself. weltiq offers those three as a service and has not opened live. So rather than stretching this section, here are just two things.

5-1. What I actually did — checking the candles match

1m   weltiq n=348 | binance n=400   labels as-is → 0 match · close→open shift (-60s)   → 348/348
1h   weltiq n=711 | binance n=2160  labels as-is → 0 match · shift (-3600s)            → 711/711
     (open, close and volume all)

Shift the label by one and 348 one-minute bars and 711 hourly bars match exactly on open, close and volume; leave it and nothing matches. Binance klines label a bar with its opening time, weltiq /v1/bars with its closing time. And NautilusTrader itself nails this trap into a warning box — in fill simulation ts_init must be the interval's close. weltiq's convention is the same as what this engine wants, so weltiq bars can be used with no conversion.

What this combination does not give you. weltiq's /v1/bars only serves up to 720 hours (30 days). It is not the right source for pulling 90 days to backtest against; that belongs to the exchange's public API or a paid data vendor.

Comparing them turned up something on the weltiq side. Asked at the same moment across timeframes, one-minute bars were filled to 0.4 minutes ago while hourly bars were 592.4 minutes behind, daily 1,012.4 minutes behind, and five-minute returned n=0. One-minute collection is real time while the higher timeframes were behind at that moment. I did not establish why, so it stands as unverified and is written down as observed. The 711-bar hourly comparison above held on what remained after excluding the stale stretch.

5-2. The part unique to this piece — two "differences documents" side by side

This is the document from section 1. The two answer the same question but the shape of the answer differs.

NautilusTrader docs/concepts/live.md weltiq MODE_DIFFERENCES.md
What it splits backtest ↔ live, two segments backtest ↔ paper ↔ live, three segments
Number of axes 6 9
Verification marking none — design prose measured · designed · unverified per cell
"What we do not imitate" scattered through the fill-model docs 15 items in one section, with the direction of bias (optimistic/conservative)
The live segment it runs — there is a live node and exchange adapters unverified — the real-money order path is closed

Theirs is operational guidance for "know this before you go live"; weltiq's is a list laid out in advance for people trying to verify it.

Putting them together exposed one blank. Of their six axes, Venue — "which order types and instructions are possible at all" — had no independent place among weltiq's nine. weltiq supports market, limit and stop_market identically for every venue, while real venues differ from one another: post-only, OCO, trailing, iceberg and time-in-force all vary, and an order that worked in simulation can be refused live with "that venue has no such order". weltiq's document records that as a failure event (venue rejection) but never stands it up as an axis of "what is possible in the first place". That blank surfaced while writing this part, and it is written down as is. Conversely, the axis weltiq has and they do not is cost — fees, funding and daily settlement pass through the same function in all three segments, with each profile's direction of bias written as a constant. They leave a fee_model slot open and hand the contents to the user. Not better or worse, a difference in design philosophy.

So the best way to use this tool. If you have ticks, measure the range of fill assumptions first (section 4) and decide where to run once you know that range. And that is something you cannot do with weltiq alone right now — weltiq does not expose those knobs to users.


6. Having used it

What was good

  1. Determinism is confirmed by hash, not by assertion. Five runs, three of them in separate processes, produced the same hash, down to identical order IDs.
  2. The nanoseconds are real nanoseconds, and it is fast. All 13,650 ticks are distinct instants at nanosecond resolution (72.2% become ties at millisecond), and it replays 90 seconds of market in 48 milliseconds.
  3. It exposes fill assumptions as settings. You can ask how generous your own backtest is. That alone is a reason to use it — and it still does not claim accuracy.

What was disappointing

  1. Docs and package do not line up. The docs track 2.x while pip install gives 1.x. The DefaultFillModel import breaks immediately and the 11 fill models are absent.
  2. Your Python version decides your feature set, invisibly. The versions visible under 3.11 and under 3.14 do not overlap at all. That is why I could not use section 4's most important knob at first.
  3. It is heavy, has no dashboard, and you supply your own data. 93 MB wheel, 257 MB installed, 108 .so files. Its headline feature, L2/L3 order-book backtesting, only opens with paid data.
  4. There are several places where a wrong result comes back without an error. Bar timestamps not set to the close silently become lookahead, and a mismatched book_type produces zero fills with no error at all.

And honestly, about the weltiq side. Three things this tool has that weltiq does not. ① Book consumption (section 4) — the biggest. ② Fill-assumption knobs in general — weltiq pins fill rules into profiles and does not open them; writing down the direction of bias is not the same as measuring it. ③ A submission-latency model — LatencyModel, present in both wheels, is absent from weltiq. It was removed because it was a dead field that was declared and read by no code, which is better than declaring something you do not have, but not better than having it.

Cost

Item Value
Code · market data Free (LGPL-3.0 · 0 API keys)
Model None — this tool does not use an LLM
Time · disk Install 29.3 s · 257 MB · first backtest 0.53 s

7. Coming up next

Next is kyky2347/ALTA (★602), and its character is the opposite. NautilusTrader is one solid block where backtest, live and reconciliation are all its own, which is why section 5 here was about overlap. ALTA instead splits Shadow (shadow execution) on the inside and a Broker API on the outside, so its execution layer is divided from the start and meant to be swapped. The question next time becomes "what changes if the inner Shadow is pulled out to weltiq".

weltiq setup — 3 lines 1. Sign up: {{CTA_URL}} 2. My → Connections → issue an API key 3. claude mcp add weltiq --header "Authorization: Bearer <key>" https://weltiq.ai/mcp

Once you have measured the range of fill assumptions with this engine, that is where you run the strategy unattended around the clock.


Every command, output and number in this article came from runs actually performed on 2026-09-22. No real money moved in any order; all of it was a local backtest. None of it is a trading opinion. The trial and error, raw logs and defect list not written up here are in the run log.