What the Rust compiler refused to let me avoid
Rewriting a trading bot from Python to Rust isn't a syntax change. It's a forced audit of every decision the dynamic version let you postpone.
- #Rust
- #Systems
- #Trading
I rewrote a Hyperliquid trading bot from Python to Rust. The headline framing is “language migration,” which is how this kind of project gets underestimated. The accurate framing is forced decision audit — because every ambiguity the Python version papered over became a compile error I had to resolve before the thing would run.
Here’s an incomplete list of the things I didn’t know I hadn’t decided.
Who owns this state
In the Python version, positions and open orders lived in a handful of module-level dicts. Anything could read them, anything could mutate them. The state was “somewhere in memory,” and it mostly worked because the hot path was single-threaded.
The Rust version wouldn’t compile until I answered: per symbol, who is the owner, and who is a reader? That resolved into a SymbolRunner per trading pair, each one owning its state machine, each one supervised by Tokio. Concurrency became a design decision instead of a thing that secretly happened at scale.
What does this return on partial failure
The Python REST client returned None on a failed request, or raised, or returned a dict with an error field, depending on the day. Callers handled maybe two of those three.
Rust made me pick one shape — Result<T, E> — and then made me handle both arms at every call site. Boring, tedious, correct. The first real outage avoided by this discipline would have been enough to justify the migration; there have been several.
The real shape of this JSON
Hyperliquid’s WebSocket and REST responses have a handful of fields that are sometimes present, sometimes nested inside an envelope, sometimes typed as strings instead of numbers. The Python version coped by sprinkling .get(..., default) and hoping.
serde wouldn’t let me hope. Every optional field got a Option<T>; every stringly-typed number got an explicit deserializer. The schema became a thing that lived in the type graph, not in my head and in the occasional runtime exception.
Where does observability live
In Python I reached for print. A production system needs structured logs, correlation IDs, and the ability to ask “what did this symbol do in the five minutes around the anomaly?” I didn’t want that work, so in Python I didn’t do it.
tracing forced the issue by making structured spans the path of least resistance. Every async task is a span. Every order is a span nested in a symbol span nested in a run span. The logs are now searchable, which means they’re now useful.
What the rewrite is really for
Performance was never the point. The bot wasn’t CPU-bound, it wasn’t memory-bound. The point was that it had to be trustworthy enough to run continuously while I slept — and the gap between “script” and “system” is not a Python-vs-Rust thing. It’s a “did you answer the questions above” thing.
Rust’s contribution is that it won’t let you skip them. The compiler is a checklist enforced by the build system. That is not a marginal productivity cost; that is the value proposition.
What I wouldn’t do again
Rewrite for the sake of rewriting. A bot that’s making money and you understand deeply is not automatically better off in Rust. The rewrite is only worth it when you’ve already decided the thing needs to be production-grade and you know the old version can’t get there. The Rust-vs-Python framing is downstream of that.