JavaScript
3.05 rounds down in Python and up in JavaScript
Our browser demo has to compute the same statistics as our Python backend, digit for digit. Getting there meant reimplementing round() — and finding three separate reasons the two disagreed.
We put a version of our statistics engine in a browser tab. No signup, no upload, nothing leaves the machine — you drop in a CSV of your own fills and the page computes the same behavioral statistics our backend computes. The point of the page is that you can check the arithmetic yourself instead of taking our word for it.
Which creates an obligation: the numbers in the tab have to match the numbers from the engine. Not approximately. Digit for digit. The engine is Python. The tab is JavaScript.
They disagreed about 3.05.
Two deterministic languages, two different answers
Python: round(3.05, 1) → 3.0
JavaScript: Math.round(3.05 * 10) / 10 → 3.1
The reflex here is to say “floating point is fuzzy.” It isn’t. Both of those results are correct, both are deterministic, and they differ for two independent reasons stacked on top of each other.
First: 3.05 is not 3.05. The nearest double to the decimal 3.05 is
3.04999999999999982236431605997495353221893310546875
Python’s round() rounds that value. It is below the midpoint between 3.0 and 3.1, so it
goes down. Python is right.
Second: the JavaScript idiom does not round that value. Math.round(x * 10) / 10
multiplies first, and 3.05 * 10 is not 30.4999…. It is exactly 30.5. The product of two
doubles gets rounded to the nearest double on the way out, and 30.5 is exactly
representable, so the multiplication lands right on it. The scaling step repairs a
near-tie into a genuine tie. Then Math.round breaks that tie upward and gives 3.1.
So the two languages are not rounding the same number. Python rounds the value. The JavaScript idiom rounds a product that no longer carries the information that decided the question.
And there is a third disagreement underneath, which shows up even when both sides genuinely are looking at a tie:
Python: round(2.5) → 2 round(3.5) → 4 (half to even)
JavaScript: Math.round(2.5) → 3 Math.round(-2.5) → -2 (half up, toward +∞)
Three separate rules, all reasonable in isolation. Any one of them is enough to make a percentage on the page differ from the same percentage in the app.
Two fixes we did not take
A decimal library would be correct and would take five minutes. We didn’t, and the reason is narrow rather than ideological: the page is a single static file whose entire premise is that it runs in a tab with nothing behind it. Pulling in a dependency to serve one function was more than the problem was worth. If the page had needed decimal arithmetic anywhere else, this paragraph would read differently.
Epsilon nudging — adding 1e-9 before rounding, or rounding to 12 places first — is the
tempting one, and it is worse than the bug. It makes the examples you thought of pass and
the examples you didn’t think of fail silently, at a magnitude you can’t predict from
reading the code. A wrong digit that announces itself is cheaper than a wrong digit that
doesn’t.
The fix: stop rounding a product
The information that settles the question is inside the double already. You just have to ask for it in a form that survives.
Math.abs(x).toFixed(20) hands you the first 20 decimal places of the double, correctly
rounded, zero-padded when the double terminates early. For 3.05 you get
3.04999999999999982236
That string, on its own, ends the argument. Split it at the digit you’re keeping, and look at what’s left:
function round(x, digits) {
if (!isFinite(x)) return x;
var neg = x < 0;
var s = Math.abs(x).toFixed(20);
var dot = s.indexOf('.');
var kept = s.slice(0, dot) + s.slice(dot + 1, dot + 1 + digits);
var rest = s.slice(dot + 1 + digits);
var up = false;
if (rest) {
var half = '5';
while (half.length < rest.length) half += '0';
if (rest > half) up = true;
else if (rest === half) up = parseInt(kept.charAt(kept.length - 1), 10) % 2 === 1;
}
var v = (Number(kept) + (up ? 1 : 0)) / Math.pow(10, digits);
return neg && v !== 0 ? -v : v;
}
rest > half is a string comparison, not a numeric one, which is the part that does the
work: it never reconstitutes a float, so there is nothing left to drift. Only when rest is
exactly "5000…0" — a real tie, not a near one — does half-to-even get applied.
Why 20 places is enough, and where it stops being enough
A genuine tie means the double’s exact decimal expansion terminates at digit d+1. Every
double is a dyadic rational, so its expansion is finite, and for the digit counts we use
(0 through 4) a real tie always fits inside 20 places. Near-ties declare themselves much
earlier than that: adjacent doubles near 3 are about 2.2e-16 apart, so 3.05 and the true
midpoint separate at the 17th significant digit, well inside the window.
The real bound is elsewhere, in Number(kept). That conversion is exact only while the
digit string stays under 2^53 ≈ 9.007e15. We generated values across every decimal exponent
from 1e0 to 1e21 and compared against CPython; the first mismatches appear around |x| ≈ 1e13
for digits = 2, which is exactly where |x| × 10^d starts crowding 2^53. Above 1e21 it
fails outright and loudly: toFixed switches to exponential notation, indexOf('.') gives
−1, and the result is NaN.
Our inputs are win rates in [0, 1], percentages in [0, 100], holding periods in days, and arithmetic means of per-trade percentage P&L. None of them come within nine orders of magnitude of the bound. That is a statement worth making precisely, because “it’s fine for our data” is only worth anything if you know where it stops being fine.
What we checked
The harness pulls the function out of the deployed file rather than a copy pasted into a
test, generates cases, runs them through Node, and compares against CPython round():
- every
wins / nfornin 1..100 andwinsin 0..n, at 4 digits — the win-rate domain - every
k / 200forkin 0..2000, positive and negative, at 2 digits — exact.005steps, i.e. the adversarial set, since those are the values that look like ties - 5,000 seeded random means of 2-decimal per-trade percentages, at 2 digits
- the usual suspects: 2.5, −2.5, 0.125, 1.005, 2.675, ±0, at 0 through 3 digits
14,204 cases, 0 mismatches.
The part that generalizes
A rounding rule is a specification, not a detail. Half-up versus half-to-even, and rounding the value versus rounding a product, are both part of that specification, and neither is visible in the phrase “round to two decimal places.” When you port a calculation to a second runtime, the formula is the easy half. The arithmetic conventions underneath it are the half that produces a support email six months later about a number that is off by one in the last digit.
And the test for two runtimes agreeing is not “do the outputs look the same.” It’s a generated adversarial set — the values that sit exactly on the boundary — producing zero mismatches. Ties are rare in random data and constant in real data, because real data is full of two-decimal prices and small integer counts that divide into exact halves.
The sample tape on the demo page is a deliberately constructed demo portfolio — not real fills. The arithmetic above is the arithmetic the page actually ships.