If you consume a market-data API, you will add, multiply and round prices. A common cause of wrong totals in that code is that the price was already a binary float before the developer's own code ran.
This post shows where that happens, with examples I ran, and the fix: an API that sends prices as decimal strings, and a client that parses them on purpose.
The one-line version
>>> 0.1 + 0.2
0.30000000000000004
Binary floating point cannot store most decimal fractions exactly. For a game that is invisible. For money it is the reason accounting software uses decimal types.
Three places it bites in a price feed
1. Arithmetic on a price
Take a plausible Bitcoin price and multiply it by 3, as a floating-point library would, and as a decimal library would:
>>> from decimal import Decimal
>>> float("63520.780763913") * 3
190562.34229173898
>>> Decimal("63520.780763913") * 3
Decimal('190562.342291739')
The float answer carries digits the input never had. Nothing is wrong enough to crash, which is the problem: the error only appears when you compare totals, reconcile against another system, or store the result and read it back.
Accumulation makes it worse:
>>> total = 0.0
>>> for _ in range(10):
... total += 0.1
>>> total
0.9999999999999999
>>> total == 1.0
False
Summing a thousand ticks or fills is the same loop with more room for the error to grow.
2. Integers above 2^53
JavaScript numbers are IEEE 754 doubles. Every integer up to Number.MAX_SAFE_INTEGER, 9007199254740991, is exact; past that, some integers cannot be represented and get rounded to a neighbour. Token amounts in the smallest unit (wei, for example) go far beyond that:
> Number.MAX_SAFE_INTEGER
9007199254740991
> Number("1234567890123456789")
1234567890123456800
The last digits are gone, silently. JSON.parse does the same thing to any number in a response, because JSON has a single number type and the parser chooses a double:
> JSON.parse('{"supply":1234567890123456789}').supply
1234567890123456800
Python's json keeps large integers exact, but it still turns 0.1 into a float unless you tell it otherwise.
3. Rounding at display time
>>> Decimal(2.675)
Decimal('2.67499999999999982236431605997495353221893310546875')
>>> round(2.675, 2)
2.67
>>> Decimal("2.675").quantize(Decimal("0.01"))
Decimal('2.68')
The float 2.675 is really 2.67499999999999982..., so it rounds down. The decimal 2.675 is exactly what you wrote, so the rounding rule applies to your number. Note that Decimal rounds half to even by default (Decimal("0.125") becomes 0.12), so choose the rule you want on purpose; see the checklist.
A fair caveat: a single ordinary price such as 63520.780763913 survives a trip through a float. The damage shows up in arithmetic, large integers and accumulated sums, as above. That is why it goes unnoticed in testing.
Why the API is the right place to fix it
An API that sends this:
{ "price": 63520.780763913 }
has already made the choice for you. By default, your JSON parser turns the number into a double, and your code never sees the original digits. (Some parsers can be told otherwise, as the Python example below shows, but you have to know to ask.)
An API that sends this:
{ "price": "63520.780763913" }
hands the decision to the developer. The string cannot lose precision in transit. It costs one extra step on the client, parsing it into a decimal type, and that step is the point: you now do it deliberately.
The Bitculator Data API, which I work on, sends prices, rates and supplies as decimal strings for this reason. I will use it only as the example below.
The client side, in four languages
Python. Parse with Decimal. If you must read numbers from a response that sends floats, json.loads can do it for you:
>>> import json
>>> from decimal import Decimal
>>> json.loads('{"p": 0.1}', parse_float=Decimal)["p"]
Decimal('0.1')
With a string field, Decimal(response["price"]) is enough. The official Python SDK returns prices as strings and its README says to use Decimal for any arithmetic.
JavaScript / TypeScript. There is no built-in decimal type. Keep the value as a string until you need math, then use a decimal library, or BigInt when the quantity is a whole number of smallest units. Never Number(price) for something you will sum.
Go. Decode into a string field, not a float64:
// raw is the json.RawMessage the SDK returns
var coin struct {
Symbol string `json:"symbol"`
Price string `json:"price"` // decimal string, never rounded
}
json.Unmarshal(raw, &coin)
Then calculate with big.Rat, which parses and holds a decimal string exactly (SetString("63520.780763913")), or with a decimal package. Avoid big.Float for this: it is still binary floating point, just with more bits.
Java. new BigDecimal(priceString), and beware new BigDecimal(someDouble), which captures the float's error too.
A short checklist for API consumers
- Check the API's docs: is a price a number or a string? If it is a number, your parser has already chosen a double.
- Keep prices as strings or decimal types from the wire to the database. Convert to float only for charts, where a pixel is the resolution.
- Never sum floats for anything that must reconcile. Sum decimals, or integers in the smallest unit.
- Decide your rounding rule explicitly (half up, half even) and apply it with a decimal type, once, at the edge.
- Put one test in your suite with a value that breaks floats, such as 0.1 + 0.2 or an integer above 2^53.
Docs and source code
The API reference is at https://bitculator.com/en/documentation/api/v1. The SDKs I mentioned are MIT-licensed and have no dependencies. The Python and Go ones are at https://github.com/Bitculator/bitculator-python-sdk and https://github.com/Bitculator/bitculator-go-sdk. You do not need an API key to read the code.
Top comments (0)