All research
8 min readEngineeringMethodology

Fast enough to trade, too slow to learn: the case against Python prototypes

Latency is not the reason to avoid Python in a trading system — for crypto it is comfortably fast enough to quote. The reason is everything that happens after the strategy works: concurrency across tokens, and a parameter fit that turns hours into weeks.

Every argument about Python in trading systems starts with latency, and that argument is a distraction. For crypto market making, Python is fast enough to quote. The reason not to write a strategy in it — even a throwaway prototype — has nothing to do with how quickly it can put an order on the wire.

Python is fast enough for the trade

On a liquid crypto perpetual, top-of-book updates arrive a few milliseconds apart. Round-trip order acknowledgement from a colocated box to a major venue lands in the 3–5 ms range. Against those numbers, a quoting decision that takes one or two milliseconds of interpreted Python is not the bottleneck. The network is.

This matters because it is where most people stop arguing. They benchmark the hot path, find that Python clears the bar, and conclude the language question is settled. It isn't settled. It has only been asked about the one part of the system where Python happens to be adequate.

The parts where it isn't adequate are the parts you reach after the strategy starts working.

Break one: concurrency across instruments

A single-symbol strategy in Python is comfortable. The moment the same strategy quotes ten or fifteen tokens, the shape of the problem changes.

The obvious answer is asyncio, and it is the wrong one, because asyncio provides concurrency, not parallelism. It is a single-threaded event loop. It excels at waiting on many sockets at once, which is genuinely most of what a trading process does. It does nothing at all for CPU work: fifteen tokens each recomputing a quote, each evaluating gates, each updating inventory state, are serialised behind one interpreter. Add a slow tick and everything else waits behind it.

Real threads don't rescue this either, because the global interpreter lock serialises bytecode execution regardless of how many threads you create. Free-threaded builds are landing, but they are not yet the default deployment target and they do not retroactively make the ecosystem thread-safe.

Which leaves process-per-token. That works, and it is what most Python shops end up doing, and it is a real cost: each process carries its own market data subscription, its own connection, its own memory footprint, its own copy of shared reference state. Fifteen tokens becomes fifteen of everything. You are paying infrastructure cost to work around a language limitation, and you have made every piece of genuinely shared state — account-level exposure, portfolio delta, a global kill gate — into a distributed systems problem that did not need to exist.

Break two: the fit

This is the one that actually decides the language, and it is invisible at prototype time.

A strategy that trades is not a strategy that is understood. To know which parameters matter you need a simulator; to trust the simulator you need it validated against recorded tapes; and to extract parameters from it you need to run it thousands of times over months of history. That last step is where Python stops being a tradeoff and becomes a wall.

The instinct is that this is a NumPy problem — vectorise it and the interpreter overhead disappears. It is not a NumPy problem, because a fill simulator does not vectorise.

Vectorisation works when the same operation applies independently across an array. A queue-position simulator is the opposite: each event mutates state that the next event reads. Your position in the queue after this cancel determines whether you fill on the next trade, which determines your inventory, which determines your next quote, which determines your queue position. The dependency chain is the simulation. You cannot batch it away, because there is nothing to batch — there is one long sequence of dependent steps.

So you are left executing a branchy, stateful, pointer-chasing loop in the interpreter, which is precisely the workload where the gap between Python and a compiled language is at its widest — one to two orders of magnitude, not the modest factor you see in vectorised code.

Multiply that by the search. A population-based optimiser evaluating hundreds of candidate configurations, each over months of tape, each needing to be re-scored on held-out windows: the difference between a run that finishes overnight and one that finishes in six weeks is not a performance detail. It decides whether the experiment happens.

Why the GPU does not save you

The reasonable next thought is that parameter search is embarrassingly parallel — every configuration is independent — so it should map onto a GPU and the language becomes irrelevant.

Embarrassingly parallel and GPU-friendly are different properties, and conflating them is one of the more expensive mistakes available here. A GPU is not a large pile of independent cores. It is a machine that runs thousands of threads in lockstep over regular memory. It wins when every thread executes the same instruction on adjacent data.

A fill simulator violates all three requirements:

  • Branch divergence. Gates, regime checks, inventory conditionals and venue-specific handling mean neighbouring threads take different paths. On SIMT hardware divergent branches are executed serially, so the parallelism you were paying for evaporates.
  • Sequential dependence. Within a single configuration the simulation is a strict chain. The parallelism exists only across configurations, so each GPU thread must carry an entire independent simulation — complete with its own order book state, which does not fit the register and shared-memory budget a thread gets.
  • Irregular memory access. Order books are trees and linked structures. Pointer chasing defeats coalesced memory access, which is where most of a GPU's advantage lives.

It is not that a GPU implementation is impossible. It is that the rewrite is enormous, the achievable occupancy is poor, and the same engineering effort spent on a multi-core CPU implementation in a compiled language returns far more. The parallelism you want is coarse-grained across cores, not fine-grained across lanes — and that is exactly what a work-stealing thread pool over compiled code gives you.

The rewrite tax

The standard rebuttal to all of this is that prototypes are different: write it in Python to find out whether the idea works, then port the winner. This assumes the port is free. It is not free, and the cost is not the engineering time.

The moment you have two implementations, you have two implementations that must agree. Every difference between them — a rounding convention, a tie-break rule on queue priority, an off-by-one in how a cancel is sequenced against a trade — is a silent divergence between what your research believed and what your production system does. You will not find it by reading the code. You will find it when live results fail to reproduce a backtest, months later, and you cannot tell whether the strategy decayed or the port was wrong.

The prototype is also not thrown away. Prototypes that work get deployed, because the alternative is delaying revenue to rewrite something that already functions. The Python version becomes production by default, and the rewrite is scheduled for a quarter that never arrives.

What changed

The historical case for prototyping in Python was that the alternative cost too much. Writing a correct, fast, memory-safe simulator in C++ was a serious undertaking, and Python bought you weeks of iteration for a performance bill you could pay later.

That tradeoff has moved, for two reasons. Rust removed most of the category of bug that made systems languages expensive to prototype in — you are not debugging use-after-free at two in the morning because the compiler declined to build it. And working in a language you reach for less often is materially faster with AI assistance, which absorbs much of the friction that used to make the switch costly.

Which inverts the old advice. Getting to a fast simulator in Rust is now a smaller investment than learning enough Cython, Numba, memory-layout discipline and profiling to make Python almost fast — and the almost still leaves you an order of magnitude short, with a codebase that has become harder to read than the Rust would have been.

Where Python is still the right answer

None of this is an argument against Python. It is an argument about which part of the system it belongs in. Python remains the correct tool for:

  • Analysis of results that have already been computed. Once the engine has emitted a fill tape or an attribution table, pandas and a notebook are unbeatable.
  • Orchestration. Launching runs, sweeping configurations, collecting outputs, managing deployment. This is I/O-bound glue and it should be in the language that writes glue best.
  • Plotting and reporting. No serious competition.
  • Genuine one-offs. A question you will ask once and never again does not need to be fast.

The line is not Python versus Rust. It is: anything that runs once per event, or thousands of times in a search, is compiled. Anything that runs once per human question is Python.

The actual argument

The reason to skip the Python prototype is not that the production system will be too slow. It is that the research will be too slow, and research speed compounds in a way that execution speed does not.

A fit that takes three hours means several experiments a day. A fit that takes a week means four a month. That is not a factor of forty in compute; it is a factor of forty in the number of hypotheses you can test, and it decides what you are capable of finding out. Every question that would take a week to answer simply goes unasked — and you never see the list of things you didn't learn.

Choose the language that lets you run the experiment, not the one that lets you write it fastest.