Floating Point Will Cost You Money: Precision in Trading Systems
Rounding sounds like a solved problem. You have a number, you have a step size, you round to the nearest valid value. One utility function, twenty lines, done forever.
It is not one function. In a system that sends orders to multiple exchanges, precision is a domain, and the bugs it produces share a nasty property: they do not throw. They produce an order that is slightly wrong, or an order that gets rejected for a reason the user cannot see.
Here is what the work actually looks like, using three real problems from our order path.
Key Takeaways
- IEEE-754 makes step alignment unreliable: scaling 0.29 by 100 yields 28.999999999999996, which floors to a value one full step too small.
- Three exchanges enforce three incompatible minimum-order rules. One checks USDT value, one checks contract count, one checks both.
- Our worst bug in this area came from inventing a rule an exchange does not have, then rejecting valid orders against it.
- Never synthesise a limit the API did not give you. If a venue has no notional minimum, computing one from other fields is fabrication, not defensiveness.
- Missing exchange metadata should raise an error, not fall back to a default. A wrong default silently produces wrong orders.
1. The arithmetic problem: rounding moves your number
Start with the simplest case, because it is the one people assume is safe.
An exchange accepts quantities in multiples of a step size. You have 0.29 and a step of 0.01. That value is already valid, so flooring it should be a no-op. The standard implementation scales up, floors, and scales back down:
0.29 / 0.01 = 28.999999999999996 ← IEEE-754, not 29
floor(...) = 28
28 × 0.01 = 0.28
A value that was already aligned moved one full step down, silently, in the direction of a smaller order. No exception, no warning, and it only happens for certain combinations of value and step, which is exactly what makes it hard to notice.
The fix is an epsilon applied during scaling, large enough to absorb representation noise but small enough that a genuinely-below value like 0.289999 still floors correctly. I wrote about the discovery of this in what I underestimated building this platform; what matters here is the category it belongs to.
Because once you accept that arithmetic on quantities is unreliable, you start looking at everything else that touches a quantity. That is where the real surprises are.
2. Three exchanges, three incompatible minimums
Every venue enforces a minimum order. You would expect the rule to differ in its value, not its shape. It differs in shape:
| Exchange | Rule |
|---|---|
| Binance | qty × price >= minNotional (a USDT value check) |
| Bybit | qty >= minOrderQty and notional >= minNotional (two checks) |
| OKX perpetuals | qty >= minSz (contract count, no notional check at all) |
Read that table as three different questions being asked about the same order. Binance asks what it is worth. OKX asks how many contracts it is. Bybit asks both, and an order can satisfy either one alone and still be rejected.
There is a further wrinkle on OKX: quantities are in contracts, not coins, so a contract size has to convert between the two before any of this is meaningful. A quantity that means "0.5 BTC" on one venue means "50 contracts" on another.
The architectural consequence we settled on: each exchange validates its own orders. The strategy engine does not contain a single conditional about which venue it is talking to. It hands the order to an adapter and the adapter applies its own rules. Every time we tried to unify these rules behind one abstraction, the abstraction became a lie the moment a fourth case appeared.
3. The expensive one: inventing a rule that does not exist
This is the bug I would most want another engineer to avoid, because the mistake looks like diligence.
OKX perpetuals have no notional minimum. We did not know that. Reasoning by analogy from the other venues, we computed one: contract count minimum times contract size times price. It looked reasonable. It produced a plausible USDT figure. And it was fiction.
Where it broke is specific and worth walking through. A DCA strategy places its safety orders below the entry price. Our synthetic minimum was computed at one price and then compared against orders priced lower, so the same quantity that passed at the top of the ladder failed further down.
The result: orders of exactly 0.1 contracts, which is the exchange minimum and would have been accepted, rejected by our own validator before they ever left our system. The exchange never saw them. From the user's side the strategy simply did not do what it was configured to do.
The lesson generalises well past trading:
Do not synthesise a constraint the API did not give you. If a venue exposes a minimum in contracts, the minimum is in contracts. Deriving a value-based equivalent is not being careful, it is inventing a rule and then enforcing it against the user.
The tell, in hindsight, was that our computed value depended on price while the exchange's actual rule did not. Any time your version of a constraint has an input the real one lacks, you have built something different from what you meant to model.
4. Missing metadata should be an error, not a default
The last piece is a policy rather than a bug, and it is what keeps the first three from recurring quietly.
Every exchange gives us per-symbol metadata: step size, tick size, minimum quantity, contract size. Any of it can be absent, and the tempting move is a fallback. Step size missing? Assume 0.001. Minimum quantity missing? Assume zero and let the exchange decide.
We do the opposite: a required field that is missing throws, immediately, before any order is constructed. Nothing downstream gets a chance to build on a guess.
The reasoning is asymmetry of outcomes. A thrown error is a loud failure that shows up at once, in front of someone who can fix it. A default is a silent wrong answer that ships an order at the wrong size and reveals itself later, in money. Between an error you cannot ignore and a number you cannot verify, the error is cheaper every time.
This also means the metadata layer is honest about what it knows. Code reading a required field gets a real value or an exception. It never gets a placeholder wearing the shape of real data.
What to carry away
- Treat step and tick alignment as arithmetic that can move your value, not as formatting.
- Do not unify rules that are genuinely different. Per-venue validation stays correct as venues are added; a shared abstraction over incompatible rules does not.
- Never derive a limit the API did not state. If your version of a constraint takes an input the real one lacks, it is a different constraint.
- Fail loudly on missing metadata. Defaults convert a visible error into an invisible mispriced order.
Precision work is unglamorous and it never appears on a feature list. But every one of these bugs produced an order that was wrong or an order that never existed, and in a system that trades on someone's behalf, those are the same category of failure as losing their money outright.
