A fee calculator is mostly arithmetic, and the arithmetic is mostly correct. The part that produces support tickets is the last step: rounding. It looks like formatting. It is actually a business rule that decides whether the number on screen matches the number that was charged, and in a fee-heavy product those two disagree more often than anyone expects.
Here is what rounding actually does in a fee pipeline, and the three places it silently changes the answer.
Rounding direction is a decision, not a default
Every language gives you several: half-up, half-even (banker's), ceiling, floor, truncation. They differ only on ties and on negatives, which is exactly where money lands when a refund is involved.
The important thing is that this is not a technical choice. Ceiling on a fee means the platform always collects a little more than the formula says; floor means it collects a little less. Over ten thousand small trades, "a little" is a line item. So the rounding mode belongs in the fee schedule as data, next to the rates — not hardcoded in the formatter where nobody will look for it.
schedule = {
"rate": Decimal("0.0008"),
"rounding": "ceiling", # business decision, stored with the rate
"precision": 8, # asset precision, not display precision
}
If the mode lives in code and the rates live in the database, then a venue changing its rounding rule is a deploy, and until that deploy your numbers are wrong by a fraction of a basis point that nobody can see.
The order of operations changes the result
Fees are frequently computed as amount * rate, then converted, then rounded, then summed. Each of those steps is a place to round, and rounding early loses money in one direction and invents it in the other.
Two rules that hold up:
- Keep full precision through the pipeline, round once at the boundary where the number becomes money.
- Round per line item, then sum — not sum then round — if the venue charges per fill. The difference is small per trade and systematic over a month.
The failure mode is a total that does not equal the sum of its parts, which is the single most common complaint about any fee report.
Floating point is not the villain, but it is a trap
0.1 + 0.2 != 0.3 is famous enough that most people avoid floats for money. The subtler trap is mixing: a Decimal rate applied to a float amount silently becomes a float, and the precision you carefully preserved is gone. If the amount arrives from JSON, it arrives as a float or a string, and which one it is depends on the client.
So the boundary matters: parse to Decimal at the edge, from the string, and never let a float into the calculation. Decimal(str(value)), not Decimal(value).
Testing rounding is different from testing arithmetic
You cannot test rounding with assertEqual on a computed value alone, because the interesting cases are the ties. Three tests that catch almost everything:
- the tie case for each mode you support, with a value that lands exactly on the boundary;
- the negative case, because refunds round differently from charges under half-up;
- the reconciliation case: the sum of rounded line items equals the reported total, for a random batch of a thousand rows.
That third one is the one that finds real bugs, because it tests the invariant rather than a number. I have never written it and had it pass first time.
Why this matters more than the rate
Users compare rates and then check the total. If the rate is 0.08% and the total implies 0.083%, the conversation is not about rounding — it is about trust. The fee page is where a trading product is most likely to be accused of something it did not do, and rounding is usually the actual explanation.
The working version of this — schedules with the rounding mode stored as data, Decimal end to end, per-line rounding reconciled against the total — is what runs the spot fee calculator I use: the spot fee calculator with per-venue rounding rules.
Two earlier notes in this series cover the adjacent problems: modelling fee schedules as data rather than constants and keeping that data consistent when it changes rarely.
The short version
Rounding is a business rule, so store it with the rates. Keep full precision until the money boundary, round per line item, reconcile the sum, and test the ties. The rate is the part everyone argues about; the rounding is the part that is actually wrong.
Top comments (4)
Rounding errors are often reconciliation errors in disguise. Keeping the rounding mode, precision, tax basis, and ordering of operations in a versioned policy lets finance reproduce a charge months later and makes rule changes testable before they affect invoices.
The line-item invariant (sum of rounded items equals rounded total) is where fee distribution hits the largest remainder problem. When allocating proportional fees or discounts across multiple sub-fills, independent per-item rounding inevitably drifts off by a cent or satoshi against the parent order total.
In financial clearing, the standard fix is largest remainder allocation (Hamilton/Hare-Niemeyer): truncate each item to the base unit, sum the remaining fractions, and assign the residual pennies to the rows with the largest fractional remainders. That keeps child transactions reconciled to the parent total without inventing money out of thin air.
Another trap in Python's decimal module is concurrency context leakage. Setting getcontext().rounding = ROUND_CEILING modifies the thread-local context, which bleeds across coroutines sharing an asyncio event loop worker. Passing rounding=ROUND_CEILING explicitly to Decimal.quantize() or executing inside a local decimal.Context() prevents silent mode contamination between concurrent fee evaluations.
The quantity axis has the same trap, and in token billing it is the bigger one: round the token count before applying the rate, and every small request is charged for tokens that were never used. A $0.0001-per-token rate with counts rounded to the nearest 1,000 turns a 1,500-token call into a $0.20 charge instead of $0.15 - 33% over, per request, with no error message anywhere.
So the business rule is: price exact quantities, then round the money once at the invoice line, not per request. Otherwise the "fraction of a basis point" in the article becomes a fraction of a dollar, ten thousand times a day.
Both rounding axes belong in the same versioned schedule, as alexshev says.
The reconciliation test deserves one refinement: reconcile per bucket, not just in total. If a batch mixes currencies or fee assets, a systematic error in one bucket can hide inside a matching grand total. Sum of rounded lines equals reported total, per currency, is the version that can't silently pass.
On tie tests: with an 8-decimal rate, exact ties are rare in nature - but fee tiers and minimum-fee quantization manufacture them. A tier boundary makes many amounts land on exactly the same rounding boundary, so the tie case stops being exotic. Worth generating the tie test from the actual tier table rather than from an arbitrary decimal.
And versioning the schedule matters as much as storing it: the first time a user disputes a fee from three months ago, you need the rounding mode that was in effect at charge time, not the current one. Schedule as data, with an effective_from - or the dispute answer becomes "we can't reproduce what we charged."