A fee comparison showed a price that was wrong for about six minutes every night, and only for some exchanges. Not stale, not missing — wrong. The kind of wrong that makes a user screenshot it and ask why your numbers do not match the exchange's own page.
The cause was not the price feed. It was the day boundary.
Every exchange resets on its own midnight
Trading fee tiers are usually computed per rolling period, but several exchanges reset allowances and volume counters on a calendar day in the exchange's local time. So the question "what is my fee tier right now" depends on what time it is where the exchange's accounting lives, not where the user is and not where your server is.
An aggregator that normalises everything to UTC — which is the correct instinct for storage — quietly moves that boundary. For an exchange at UTC+8, the tier resets at 16:00 UTC. If your code resets at 00:00 UTC, then between 16:00 and 24:00 UTC you are computing today's tier against yesterday's volume, and the fee you display is off by whatever the tier step is.
Six minutes was not the error window. Six minutes was how long it took someone to notice.
Three timestamp shapes, one of them a trap
The same normalisation bug shows up in three forms, and only the third is obvious:
- Epoch milliseconds. Unambiguous. Convert and move on.
- ISO 8601 with an offset (
2026-11-11T09:00:00+08:00). Unambiguous if you keep the offset. - ISO 8601 without an offset (
2026-11-11T09:00:00). This is the trap. Every parser will assume something — server local, UTC, or browser local — and all three assumptions are wrong for at least one exchange. The string looks like a timestamp and behaves like a guess.
If you parse shape 3 with a default timezone, you get a value that is correct in one environment and wrong in another. That is the worst failure mode available: it passes local tests and fails in production, or passes in production and fails after a deploy moves the container's TZ.
Store the exchange's clock, not just the instant
The fix that held up was to stop treating time as a single instant and store two things:
- the instant, in UTC, for ordering and display;
- the exchange's local date, as a plain
YYYY-MM-DDstring, for anything that resets on a day boundary.
The second field is what makes tier logic testable. You are no longer asking "what time is it", you are asking "which of the exchange's days does this belong to", and that question has one answer regardless of where the code runs.
def exchange_day(ts_utc: datetime, tz: ZoneInfo) -> date: """The calendar date the exchange would print on a statement.""" return ts_utc.astimezone(tz).date() Two lines, and it removes an entire class of bug — the one where the same input produces different output depending on the machine.
Test the boundary, not the middle
The test that would have caught this is not "does the fee match for a sample trade". It is:
- freeze the clock at 23:59 exchange-local and assert tier A;
- freeze it at 00:01 exchange-local and assert tier B;
- run both with
TZ=UTC,TZ=Asia/ShanghaiandTZ=America/New_York.
If the answers differ between the three runs, you have a timezone dependency in business logic, and it will surface as a support ticket at the worst possible hour. If they agree, you have actually fixed it rather than moved it.
The same discipline applies to rounding, which has the same shape of problem — the value is fine, the rule about when to apply it is what breaks. Rounding is a business rule, not a formatting detail, and it fails in the same quiet way: correct on the happy path, wrong at the edge, invisible until someone adds up a month of trades.
Cache the answer, but not the boundary
One more thing worth saying, because it is how this bug survived a deploy: the tier was cached. The cache key was the exchange and the account, with a TTL of an hour. That is a perfectly reasonable cache — and it made the bug persist for up to an hour after the real value changed.
If a value has a day-boundary dependency, the boundary belongs in the cache key, not just the TTL. Otherwise the cache outlives the fact. The general case for rarely-changing data is the same problem in slower motion: a TTL is a guess about when data changes, and a guessed boundary is a bug waiting for the one day it matters.
None of this is exotic. It is the ordinary cost of aggregating numbers that other people computed under assumptions they never wrote down. The exchanges are not wrong to reset on their own day; we were wrong to assume they reset on ours.
If you want to see what the output looks like when the boundaries are handled correctly, the comparison this work feeds is at fipst.com — the tier figures there are computed in exchange-local time, and the timestamps carry the offset they were computed with.
![Robot Hand and Dexterous Grasping — Ch.09: Force and Torque Control for Hands [EN]](https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyh837tb5vp99ae1scr4c.png)
![Robot Hand and Dexterous Grasping — Ch.16: Checklists and Templates [EN]](https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fowhozp6neqiwwv1y561k.png)
