NautilusTrader + candle: A Rust AI Trading Stack
Your model says buy. Your risk engine says no. Who wins?
In most trading stacks the honest answer is "whoever is louder." A Python notebook model outshouts a config-file risk limit by default. But there is a sharper question hiding behind that one, and it changes the outcome: what happens when the model is wrong, and the architecture is built so it cannot hide?
This is the story of a two-plane algorithmic trading stack in Rust — four crates, 388 tests, zero failures — where an ML model fitted with Hugging Face's candle was given every chance to earn its place, and then lost to a momentum factor on out-of-sample data. The model did not fail because someone judged it unworthy. It failed because it could not show a number, and the thing that checked the number lived in a different process and a different dependency graph.
That is the design. Everything else in this build follows from it.
How they work together, in short: NautilusTrader's Rust crates run execution; Hugging Face's candle fits the model. They sit in separate crates with no shared dependency, so the model cannot place an order and the engine cannot call the model. The two exchange a ranked book, and the engine decides.
A naming note first, because it causes endless confusion: candle is Hugging Face's minimalist ML framework for Rust — the thing that fit the model here — not a candlestick charting library. Nothing in this article is about doji, hammers or engulfing patterns. The crate names are candle-core and candle-nn, both at 0.11.0.
The Model Did Not Earn Its Place
Start with the result, because it is the reason the architecture is worth studying at all.
The research plane fitted models with candle on a complete two-year panel (fingerprint 42d5d64c6b0cd259): 500 trading days, ~10,600 tickers scanned per day, 490 names surviving a survivorship-disciplined universe screen, 226 periods, five features. The model was scored on held-out periods. The factors were scored on exactly the same periods. No asymmetry, no "the model just needs more data" escape hatch.
| signal | net (bps) | t |
|---|---|---|
rev_1m (−1) | +14.851 | 0.84 |
amihud (+1) | +10.911 | 1.36 |
log_dollar_volume (−1) | +7.261 | 0.69 |
mom_12_1 (+1) | −3.857 | −0.15 |
vol_60d (−1) | −5.462 | −0.35 |
| candle hidden=0 | −9.124 | −0.52 |
| candle hidden=4 | −9.330 | −0.57 |
| candle hidden=8 | −12.516 | −0.70 |
Across five disjoint regimes, the model's mean was −2.505 bps, with only 2 of 5 folds positive. Momentum averaged +9.945 bps. The model won exactly 1 of 5 blocks head-to-head.
And the capacity sweep was monotonic in the wrong direction: hidden=0 beat hidden=4 beat hidden=8. Adding capacity made the fit worse, consistently, by 3.392 bps from the smallest net to the largest. That ordering is the useful part. A model that is merely unlucky scatters; a model that degrades in step with its own parameter count is overfitting the training block and paying for it out of sample — and the cheapest version of the architecture, a linear map with no hidden layer at all, was the least bad of the three.
The build then ran the removal test: Leg A — delete every learned model. The system lost nothing it could demonstrate. It survived, so by definition it is not AI-native. That is a measured property, not an assertion, and it is the kind of claim most AI-trading write-ups never put themselves in a position to make.
The Plane Ban Is the Cargo Dependency Graph
The central architectural claim of this stack is worth stating bluntly: in Rust, the plane ban is the dependency graph, not a source scan.
Four crates, each locked to a plane:
quant-signal— PLANE_NEUTRAL. TheRankedBookschema, the bounded abstain vocabulary, the run-report schema. Depends onserdeand nothing else.quant-alpha— AI_CONTROL. Fits models withcandle(candle-core,candle-nn). Depends on nonautilus-*crate.quant-strategy— EXECUTION. Four strategies on NautilusTrader's Rust crates (nautilus-model,nautilus-trading,nautilus-backtest, all0.62.0). Depends on no candle crate.quant-planes— VERIFICATION. Audits the other three transitively overCargo.lock.
Not by convention, not by lint, not by a "please don't import this" comment: quant-strategy cannot call candle because candle-core is not in its dependency graph, so the symbol is not linked into the binary. quant-alpha cannot reach OrderApi for the same reason. A single-process composition root where the model reaches into the execution engine is not discouraged here — it is unrepresentable.
This is the part that does not port to Python. A Python plane ban is a source scan plus a code review plus a promise; someone adds one import and the boundary is gone with no build failure. In Cargo the boundary is a property of the artifact. You cannot violate it and still link.
quant-planes adds the two things the compiler cannot check: that the ban holds transitively through Cargo.lock (a dependency-of-a-dependency can smuggle a banned crate in), and that no crate joins the workspace without declaring its plane. make lint-planes-rust reports 4 members, 0 violations.
What Crosses the Boundary: Ranked Book, Zero Weights
Because the two halves cannot call each other, they communicate through a file. quant-alpha writes ranked_book.json; quant-strategy reads it. What crosses the plane is target weights — plain numbers summing to zero. Not a model, not a score, not a tensor. Nothing on the execution side needs to know that a neural network existed, and nothing on the research side gets to name an instrument the engine has not admitted.
The fit itself is unremarkable on purpose: a small MLP with tanh activations trained with AdamW, the decoupled-weight-decay variant of Adam. The interesting engineering is not the optimiser. It is everything downstream of the JSON file.
A real run: 98 names, 49 long, 49 short, dollar-neutral to 3.5e-18, executed on a simulated venue as 98 positions. The engine held whole units; the book scored on continuous weights — a gap the stack explicitly tracks with weight_tracking_error, because scoring what you cannot hold quietly manufactures fake alpha.
The Engine Refuses Its Own Model
BookEvidence carries what the producer measured: net_bps, n, se_bps, folds, positive_folds. Note what is absent — no t, no p-value, no verdict, no admissible. Those are conclusions, and a producer that ships its own conclusion is self-certifying. The engine forms the t itself and applies its own floor.
MIN_EVIDENCE_T = 2.0 is a module constant, not a config field, because a strategy that can lower its own evidence bar has no evidence bar at all. MIN_EVIDENCE_FOLDS = 2, because one block cannot separate skill from a regime.
Live output from the backtest:
book state book has no demonstrated skill: t = 0.49 on 3.477 bps over n=84,
below the 2.0 floor
rebalances 0
positions 0 opened at the venue
That is Leg A made mechanical. The model does not fail to trade because someone judged it unworthy. It fails because it cannot show a number, and the thing that checks lives in a different process and a different dependency graph.
Which answers the only governance question that matters in an AI system: who decides? Here the model proposes and the execution engine disposes. The ML layer's authority ends at the JSON file. In the standing argument between "the model knows best" and "the risk layer must win," this stack does not hold an opinion — the dependency graph votes, and it votes for the risk layer every time, including the times the model happens to be right.
Five Defects That Only Existed at the Seam
Each of these was invisible to both sides alone. Connecting things that had never met found all five.
-
Market holidays emptied the universe. Six of 117 grouped files list no tickers — Labor Day, Thanksgiving, Christmas, New Year, the Carter day of mourning, MLK Day. The reader called that "a bad file." But a holiday file lists no tickers, so every selected name is missing that day, so every name is dropped for incompleteness, and the universe empties completely. One Labor Day reduced a 500-name panel to nothing — and the error blamed delistings.
-
Bare tickers vs. venue-qualified IDs. The producer wrote
"AEP"; the engine identifies instruments asSYMBOL.VENUE. Every name in the first real book would have been refused asOutsideUniverse. The alpha plane had never seen anInstrumentId, and the engine had only ever been handed books it built itself — which it agrees with by construction. -
The risk limit was the position size.
target_unitsmultiplied the weight bymax_units_per_name— the bound, not a notional scale. At a decile book's 0.01 weights,0.01 * 3truncates to zero: the strategy computed no targets for any book with more than three names a side. Four existing tests encoded this as intended — one widened the risk limit in order to make the strategy trade more. -
A per-name cap bounding the whole basket. With sizing fixed, only 36 of 100 orders went through: 3 buys + 3 shorts × 6 rebalances. A cap written to bound one position's risk was silently bounding the portfolio's breadth, and the test suite celebrated it.
-
The census pointed at the wrong problem. Every book refusal recorded
NoAdmissibleModel, so a book that was present, valid, venue-sourced, and refused at t = 0.49 came back as "no admissible model" — which reads as a missing file. The instrumentation conflated "file absent" with "model inadequate," and the wrong bug got fixed first.
Note what defects 3 and 4 have in common: the tests passed. Four of them actively encoded the bug as the specification. A test suite written by the same person who wrote the misunderstanding will ratify it, which is the strongest argument in this whole build for making the two planes disagree at the artifact level rather than the assertion level.
Seven Inversions, and the Fingerprint That Named Them
The dataset grew during the session. The model's fold mean read +6.527, +3.477, +1.427, −7.528 across four runs of unchanged code. Momentum's across-fold t went 2.21 → 1.63 → 2.00 → 1.44 — crossing the engine's floor, then falling back under it. Same code, same "backtest," four different answers.
CrossPanel::fingerprint() — SHA-256 over assets, both matrices, spec, and horizon — made the drift attributable. Baseline made it checkable, and the distinction is the entire value:
- Different fingerprint → not the same panel. The numbers are not comparable; calling a difference a regression would be wrong.
- Same fingerprint, numbers moved → the code moved. That is the only case that is a regression.
Tolerance is 0.1 bps rather than zero, because a float sum reassociated by a different compiler optimisation is not bit-identical, and a zero tolerance would report a compiler flag as a regression. The stack refuses to cry wolf over 0.0000001 bps so it can actually bite at 0.11.
The same discipline caught the loop in its own work repeatedly: an asserted 39% turnover-horizon link that the direct scan did not support (README corrected); an expected Ledoit–Wolf headroom that turned out to be 31.94% — a real margin, not the dead end assumed; two-sided denoising expected to inflate R², which measured ±0.00000; and n = 50 and |t| = 2.50 written as literals that were already stale by two runs. All four now derive from the panel. The instrument caught the author as often as it caught the papers.
Instruments Built for Honesty
The statistical toolkit is where this build stops being clever and starts being trustworthy:
required_periods()— how much data could settle a factor. Three of five factors need 32 to 512 years of history. The function returnsNonefor a wrong-signed effect, never a large number, because a factor pointing the wrong way cannot be powered into significance by waiting.bonferroni_z(n)— derives the significance bar from the declared-test count via Acklam's inverse normal. A hardcoded 2.50 goes stale the moment a fifth factor is added, in the direction that makes results look better. Multiple comparisons is the one bias that gets worse the harder you work.walk_forward_cross— five disjoint blocks, because a single test block is a coin flip: fold 2 read −23.6 bps at t = −2.63 while fold 3 read +5.0 bps. Same strategy, adjacent blocks, opposite verdicts.sign_test_p— distribution-free head-to-head, which also exposes that the comparison cannot be won cheaply: a clean 5/5 sweep is p = 0.0625, and 6 folds is the smallest count that clears 0.05. Derived, not written down. It is worth sitting with that: even a perfect sweep across five windows does not clear a conventional bar, so any single-window result in anyone's backtest is a coin flip wearing a decimal point.select_buffer/select_config— out-of-sample parameter selection. The hold band that looked best in-sample ranked 3 of 4 out of sample and lost money.permutation_null— shuffles forward returns within each date, preserving the market's own cross-sectional structure while destroying only the feature-return link. The p-value is(exceeded + 1) / (draws + 1), because zero exceedances in 200 draws is not evidence that p is zero; it is evidence that p is below about 0.005.
None of these instruments is exotic. Each exists because an earlier, lazier version of the number let somebody believe something false.
The Parallel Paper Loop: Twenty Papers, Every One a Dead End
A second, never-stopping loop reads 2026 research papers, tests one claim each, and records the result in JSON + LanceDB. Twenty papers so far: 13 TESTED_NULL, 6 NOT_IMPLEMENTABLE, 1 PAPER_UNREADABLE. 96 measurements, every one carrying an n and a baseline.
The recurring finding, confirmed across five papers and three metrics: any accuracy statistic computed on a price level is settled by one persistence pass.
- R² = 0.9975 claimed → persistence gives 0.99979 on 1,447,520 real minute bars, across all four names.
- Scaled MAE 0.0271 claimed → persistence gives 0.0222, beating the claimed model on 8 of 12 names.
- 94.97% three-class accuracy → a constant majority-class predictor scores 96.51%.
- "Highest Sharpe 0.091757 of eight strategies" → equal weight scores 0.09343, and separating two Sharpes that differ by 0.01 requires 306 years of data.
- "Monotonicity rules out overfitting" over 186,000 combinations → the same monotone structure appears in shuffled returns, and the best of 186,000 zero-edge strategies sits 4.93 standard errors above zero by construction.
That last one is the trap most worth naming, because it is the exact argument a well-meaning researcher reaches for. Monotonicity is a claim about shape; overfitting is a claim about selection. A smooth, orderly, beautifully monotone surface over 186,000 candidates tells you nothing about whether the peak of it is real — and if you pick the maximum, it never is.
The candlestick-pattern literature surveyed alongside this build has zero citations across the board and no benchmark data attached. The recurring academic interest in fitting AI to chart patterns is real; the empirical case is effectively nonexistent. The loop's verdict on most of it is the honest one: TESTED_NULL.
The loop even caught a thesis declaring its own leak — "uses the entire dataset, including future stock prices." Rarer than it should be, and credited.
Decision Framework: When This Stack Earns Its Keep
The obvious conclusion is "the model lost, so don't bother." That is wrong. The right conclusion is that this architecture is the one you want when the model might be wrong — which is always. The evidence points to a concrete decision framework:
Use this two-plane design when:
- A survivorship-disciplined universe exists. 500 trading days and ~10,600 tickers/day scanned gave the comparison room to be honest. Without that, the "model vs. factor" result is just another backtest artifact.
- The model must be removable. Leg A — delete every learned model — is the acceptance test. If the system breaks, the AI is load-bearing and you are maintaining a black box you cannot audit.
- The evidence gate lives outside the producer's process. A strategy that can lower its own evidence bar has no evidence bar.
MIN_EVIDENCE_T = 2.0is a compiled constant, not a config knob. - Data changes are distinguishable from code changes. The SHA-256 panel fingerprint plus a 0.1 bps tolerance converts "the results moved" from a panic into a diagnosis.
- The comparison includes simple alternatives. Momentum's +9.945 bps against the model's −2.505 bps is the number that matters. No model should ship without naming the factor it beat and showing the same-period table.
Do not use this stack when:
- Your edge is a simple moving-average crossover. Four crates, a plane ban and a fingerprint will not make it profitable; they will make it slow to change.
- You have small data and a tight deadline. The overhead exists to catch mistakes that only appear at scale — holidays emptying universes, 36 of 100 orders vanishing, t = 0.49 books mislabeled as missing files.
- You expect the AI to decide. Here, candle proposes and NautilusTrader disposes. If your thesis requires the model to overrule risk, you are building a different architecture, and the "who decides?" question has already been answered for you — by whoever holds the order API.
The practical takeaways: separate the fitting plane from the execution plane at the dependency level, not the convention level; communicate through files carrying measurements, never conclusions; make the evidence floor a compiled constant; fingerprint every dataset before comparing any two runs; and always run the removal test. The model that cannot be deleted without breaking the system is not an asset — it is a hostage.
Frequently Asked Questions
Is Rust good for AI trading?
For the execution half, yes — no GC pauses, no GIL, and a dependency graph strict enough to make architectural boundaries unforgeable. For the research half it is a genuine trade-off: candle is capable but the surrounding ecosystem is far thinner than Python's, and you will write things you would have imported. The reason to accept that here is not speed; it is that putting the fitting plane and the execution plane in one Cargo workspace lets the linker enforce a separation that Python can only ask for politely.
Does NautilusTrader require Python?
No. It is best known through its Python API, but its Rust crates are published independently — nautilus-model, nautilus-trading and nautilus-backtest at 0.62.0. This build uses those crates directly and contains no Python and no PyO3 bridge. See the documentation for the full API surface.
Is candle a candlestick library?
No, and the name collision wastes a lot of people's time. candle is Hugging Face's minimalist ML framework for Rust — tensors, autograd, neural network layers. Candlestick chart patterns are unrelated, and this build contains none.
How do you connect a candle model to NautilusTrader?
Deliberately, not directly. The research crate writes a RankedBook JSON file of target weights summing to zero, plus a BookEvidence record of what it measured. The strategy crate reads that file, forms its own t-statistic and applies its own floor. There is no FFI, no shared process and no shared dependency — which is exactly what lets the engine refuse the model's book, as it did here at t = 0.49.
Did the model beat the baseline? No. Mean net of −2.505 bps against momentum's +9.945 bps, 2 of 5 folds positive, 1 of 5 blocks won head-to-head, and monotonically worse with more capacity. The result is reported because it is the result.
Let the Engine Decide
The deeper implication is not about Rust, or candle, or even trading. It is about where decision authority lives in an AI system. The two-plane stack answers it mechanically: the model proposes, the engine disposes, and the boundary is enforced by the linker rather than by policy. When the model is right, it passes evidence and trades. When it is wrong — as it was here, by 12.45 bps of mean net against momentum — it is refused by a number it cannot argue with, in a process it cannot reach.
The honest architecture is the one where the AI can lose gracefully. This build measured that loss: model mean −2.505 bps, 2 of 5 folds positive, 1 of 5 blocks won, monotonically worse with more capacity. And the stack did the one thing most AI-trading systems never do — it let the result stand.
Let candle fit. Let NautilusTrader decide. And build the system so the decision can be no.
This is an educational architecture write-up, not financial advice. Every number in it is measured from the build described and from historical data; none of it is a forecast. Backtested results do not predict future returns, the model discussed here was refused by its own evidence gate and never traded live, and no strategy in this article was deployed with real capital.
