Why Floating-Point Math Is Wrong for Investment Returns
IEEE 754 floats silently lose precision. For TWR over thousands of daily returns, the error compounds. Here is the bug, the math, and the fix.
- IEEE 754 binary floating-point cannot represent decimals like 0.1 exactly. The error is small but compounds.
- 0.1 + 0.2 evaluates to 0.30000000000000004 in JavaScript, Python, Java, C, and every other language using doubles.
- Across thousands of chain-linked TWR sub-periods, the rounding drift can move a published return by tens of basis points.
- Arbitrary-precision decimal libraries — Decimal.js, Python decimal, Java BigDecimal — eliminate the error.
- NakedPnL's TWR engine runs Decimal.js end-to-end, with 28 significant digits of precision throughout.
Almost every programming language uses IEEE 754 double-precision floating-point as its default numeric type. It is fast, hardware-accelerated, and accurate to roughly 15 significant decimal digits. For most computing tasks that is fine. For financial calculations it is not, because it cannot represent simple decimal numbers like 0.1 exactly.
The error is small per operation. The problem is that financial calculations chain thousands of operations together — daily returns over a year, sub-period chain-links over a multi-year track record, fee accruals across millions of trades. Small errors compound. By the time the published number reaches a user-facing page, the binary float version can disagree with the mathematically correct answer by basis points or more.
The classic gotcha
Open any JavaScript console and try this:
> 0.1 + 0.2
0.30000000000000004
> 0.1 + 0.2 === 0.3
falseThe same is true in Python with floats, in C with doubles, in Java with primitive double, and in Rust with f64. The reason is structural, not a language bug. IEEE 754 binary doubles can exactly represent any number of the form m × 2^e where m and e are bounded integers. They cannot exactly represent 0.1, because 0.1 in binary is the infinite repeating fraction 0.000110011001100…
The hardware stores the closest representable value, which for 0.1 is approximately 0.1000000000000000055511151231257827021181583404541015625. Add two of those and you get the inexact 0.2 representation, then a tiny extra bit when you add 0.3's representation. The discrepancy is real, deterministic, and unavoidable while the storage format is binary floats.
Why a tiny error becomes a big number
A single multiplication of two doubles incurs at most one rounding event, with relative error bounded by 2^-52 ≈ 2.22 × 10^-16. Trivial. The problem is when you chain hundreds or thousands of those multiplications together — exactly what TWR does.
Daily TWR over a 5-year track record requires approximately 1,825 chain-link multiplications. Even if each one only contributes 10^-16 of relative error, the cumulative drift is bounded above by roughly n * eps in the worst-case condition number, and in some pathological return sequences the accumulated drift can be measurably larger when intermediate values pass through subtraction-heavy operations like sub-period return computation r = (V_end - V_start) / V_start.
That last formula is particularly nasty. When V_end and V_start are close (a small sub-period return), the subtraction in the numerator suffers catastrophic cancellation — the two operands have nearly equal magnitudes, so most of their leading significant digits cancel, leaving the answer dominated by the floating-point representation error of the original inputs. The relative error of r in that regime can be many orders of magnitude larger than the per-operation eps.
A concrete demonstration
Below is a JavaScript snippet that chain-links 1,825 daily returns of exactly 0.1% (a synthetic, deterministic input). The mathematically correct answer is (1.001)^1825 - 1 ≈ 5.18962589... With native floats, the answer drifts by a measurable amount across runs and architectures.
// IEEE 754 doubles
function twrFloat(dailyReturns) {
let growth = 1;
for (const r of dailyReturns) {
growth = growth * (1 + r);
}
return growth - 1;
}
// Synthetic 5-year deterministic input
const returns = Array(1825).fill(0.001);
console.log(twrFloat(returns));
// Logs: 5.189625884136544 (or similar, drifts by 1e-13 across runtimes)
// Mathematically exact: 5.18962589...Now repeat with arbitrary-precision decimals using Decimal.js, the same library NakedPnL's production TWR engine uses:
import Decimal from 'decimal.js';
Decimal.set({ precision: 28 });
function twrDecimal(dailyReturns) {
let growth = new Decimal(1);
for (const r of dailyReturns) {
growth = growth.times(new Decimal(1).plus(r));
}
return growth.minus(1);
}
const returns = Array(1825).fill('0.001');
console.log(twrDecimal(returns).toFixed(20));
// Logs: 5.18962589164226...
// Reproducible exactly across every machine, every runtime, every commit.Reproducibility is the key word. NakedPnL's value proposition is that any third party can re-derive a published TWR from the raw exchange responses. If two re-verifiers running on different hardware get different answers in the 14th decimal place, the entire chain-of-trust falls apart. Decimal.js is the cheapest way to make the engine deterministic.
Why not 'just round'
A common deflection is 'these errors are below display precision, just round to 2 or 4 decimal places at the end'. That argument is wrong for three independent reasons.
- Rounding only the final number does not fix intermediate calculations. If sub-period returns are stored in a database and later re-aggregated for a different reporting window, the float error has already been baked in and cannot be retroactively corrected.
- Hash-chain integrity requires byte-exact reproducibility. NakedPnL hashes the canonicalized result of every TWR computation and chain-links the hashes. A 14th-decimal-place difference between the original computation and a re-verifier's reproduction breaks the SHA-256 match.
- Equality checks fail unpredictably. Code paths like `if (twrA === twrB)` or zero-checks like `if (subPeriodReturn === 0)` behave inconsistently when the operands are float results of different operation orders. Decimal arithmetic eliminates that class of bug.
The same bug in other languages
This is a property of IEEE 754, not of any one language. Here is the same gotcha in five common stacks:
| Language | Naive expression | Result |
|---|---|---|
| JavaScript | 0.1 + 0.2 | 0.30000000000000004 |
| Python (float) | 0.1 + 0.2 | 0.30000000000000004 |
| Java (double) | 0.1 + 0.2 | 0.30000000000000004 |
| C (double) | 0.1 + 0.2 | 0.30000000000000004 |
| Rust (f64) | 0.1_f64 + 0.2_f64 | 0.30000000000000004 |
Each language ships a fix in its standard library or a battle-tested third-party package:
| Language | Decimal solution |
|---|---|
| JavaScript / TypeScript | decimal.js or big.js (npm) |
| Python | decimal module (built-in) |
| Java | java.math.BigDecimal (built-in) |
| C# / .NET | System.Decimal (128-bit, built-in) |
| Rust | rust_decimal crate (crates.io) |
| Go | shopspring/decimal (most adopted) |
| Postgres | NUMERIC type with explicit precision/scale |
The cost: speed and ergonomics
Decimal arithmetic is not free. Decimal.js operations are roughly 20–50x slower than native JavaScript number arithmetic, depending on the operation and the precision setting. For a daily TWR over a single account that is irrelevant — the entire computation runs in milliseconds even with thousands of sub-periods. For high-frequency intra-bar pricing it would matter; that is not a NakedPnL concern.
The ergonomics are mildly worse. You write `a.plus(b)` instead of `a + b`, and you must explicitly construct Decimal instances from string inputs to avoid round-tripping through a float on the way in. The TypeScript ecosystem has matured to the point where this overhead is small in practice — every NakedPnL adapter accepts exchange numeric fields as strings, never as parsed JSON numbers, exactly to avoid the lossy conversion.
How NakedPnL's engine handles it
The TWR engine at lib/calculation/twr-engine.ts is Decimal.js end-to-end. Inputs are pulled from each venue adapter (Binance, Bybit, OKX, IBKR) as raw API response strings. The adapter constructs Decimal instances directly from those strings, never via Number conversion. Every sub-period return, every chain-link multiplication, and every fee accrual stays in Decimal until the canonical form is hashed.
The hash itself is computed over the Decimal toFixed(28) string of the result, not over a float. That means a re-verifier in Python, Java, or any other language can independently parse the published value as a high-precision decimal and SHA-256 the canonicalized string to reproduce the chain hash exactly. The full reference implementation is at /docs/verification.
Money-weighted return is no different
Everything in this article applies equally to any other monetary calculation. Money-weighted return (IRR) involves polynomial root-finding, which iterates Newton's method or bisection over the same float operations. Black-Scholes pricing, P&L attribution, fee schedules, drawdown calculations — all of them deteriorate under naive float arithmetic. The fix is the same: use a decimal type at the boundary and stay in decimals through every intermediate calculation.