Open a JavaScript console and type 0.1 + 0.2. You get 0.30000000000000004. Most of the time nobody notices, because the rendering layer rounds it away before a human ever sees it. In a trading order form, that luxury disappears. The number you compute is the number you are about to send to an exchange, and an exchange will reject or silently mangle a quantity that has more decimal places than the instrument allows. The gap between "looks right" and "is right" is exactly where money leaks out.
This post is about closing that gap in a real React trading UI. I will walk through where floating-point error actually shows up in an order form, the realistic options for fixing it, and how the Hedge UI demo handles price, amount, and total maths with decimal.js plus precision derived from live exchange metadata. The goal is not academic correctness for its own sake. It is making sure the total a trader reads, the quantity they submit, and the figure the matching engine accepts are all the same number.
Why 0.1 + 0.2 !== 0.3 is unacceptable in finance
JavaScript numbers are IEEE 754 double-precision binary floats. They store values in base 2, and most decimal fractions that look tidy in base 10 have no exact binary representation. 0.1 is a repeating binary fraction in the same way 1/3 is a repeating decimal, so it gets rounded to the nearest representable double the moment you write it. Add two of these rounded values and the errors compound:
0.1 + 0.2; // 0.30000000000000004 0.1 + 0.2 === 0.3; // false 0.3 - 0.2; // 0.09999999999999998 (1.1 * 3).toString(); // "3.3000000000000003"
For a weather app this is noise. For an order form it is a correctness bug. Three things make finance unforgiving here:
- Quantities are constrained. An exchange publishes a step size for each market. If it is
0.001, you may submit1.234but not1.2345. A float that drifts to1.2340000000000002is now invalid. - Sums are audited. A portfolio total that is off by a fraction of a cent looks like a bug to a user and like a reconciliation failure to an accountant.
- Errors accumulate. One multiplication is usually survivable. A running total over hundreds of fills, or a percentage of a percentage, is where the drift becomes visible.
The fix is to stop pretending binary floats are decimal numbers and to carry precision explicitly through every calculation that touches an order.
Where float errors creep into a trading UI
Before reaching for a library it helps to map the blast radius. In a typical trading interface the danger zones are predictable:
| Calculation | Example | Failure mode |
|---|---|---|
| Order total | price * amount | Total shown to user differs from total charged |
| Cumulative portfolio value | Σ (amount * lastPrice) | Rounding drift across many holdings |
| Percentage P&L | (value - cost) / value | Tiny denominators amplify error |
| Quantity from a percentage slider | balance * 0.25 | Result has too many decimals for the step size |
| Average fill price | weighted mean of fills | Repeated division compounds error |
The order total is the one users stare at, so it is the highest-stakes spot. But the portfolio and balances panel is where the widest range of magnitudes collides: one screen has to show PEPE held in the billions next to a sub-cent unit price, and BTC held in fractions next to a five-figure price. The same arithmetic path serves 1,004,042,104 PEPE @ 0.00001162 USDT and 1.1 BTC @ 97,388.44 USDT. A naive approach that works for one will quietly misbehave for the other.
The options, and what they cost you
There are four common strategies. None is free, and the right choice depends on how much arithmetic you actually do client-side.
| Approach | How it works | Pros | Cons |
|---|---|---|---|
Number + toFixed | Compute in float, round at the edge | Zero dependencies, fast | Rounding hides drift, does not prevent it; breaks on extreme magnitudes |
| Integer minor units | Store everything as integer "satoshis" | Exact, fast | You must track the scale per asset; awkward for 18-decimal tokens |
BigInt | Native arbitrary-precision integers | Exact, built in | Integer only; you reimplement fractional scaling yourself |
decimal.js | Arbitrary-precision decimal type | Exact decimal maths, configurable rounding | Adds a dependency; values are objects, not primitives |
Number with rounding is the trap, because it looks like it works. The display is clean, the bug only surfaces at the boundaries, and by then it is in production. Integer minor units are genuinely excellent and are what a matching engine uses internally, but in a UI you are juggling dozens of assets with different scales, and the conversion code becomes its own source of bugs. BigInt is perfect for whole numbers and useless on its own for a price like 0.00001162.
We landed on decimal.js (version 10.6.0 in the demo) for the order maths. It gives an exact decimal type with explicit rounding control, the API reads close to ordinary arithmetic, and it interoperates cleanly with the string values that come off our number inputs. If you are also formalising the shapes that carry these numbers around your app, the patterns in TypeScript types for financial data pair well with this: a branded Decimal-backed price type stops a raw number ever reaching code that expects exact maths.
How the order form computes the total
The order form provider is where price and amount become a total. The key line is deliberately boring:
import Decimal from "decimal.js"; const calculateTotal = ( precision: number, limitPrice?: string, amount?: string, ) => { if (!limitPrice || !amount) { return; } const result = new Decimal(limitPrice).mul(amount); const fixedValue = fixTotalToPrecision(result.toFixed(precision), precision); return fixedValue; };
Two details matter. First, limitPrice and amount are passed in as strings, not numbers. They come straight from the input fields, so they never pass through a lossy parseFloat before the multiplication. new Decimal("0.00001162").mul("1004042104") is exact; 0.00001162 * 1004042104 is not. Second, result.toFixed(precision) produces a fixed-precision string for display, using the quote asset's precision rather than some arbitrary global default.
Notice the honest contrast elsewhere in the codebase. Our balances panel computes portfolio value and P&L with plain Number multiplication (balance.amount * Number(ticker.c)). That is a deliberate trade-off: those figures are display-only summaries that never get submitted anywhere, so a sub-cent rounding artefact is cosmetic, not financial. The rule we follow is that decimal precision is mandatory on the path that produces something the user can send, and optional on read-only dashboards. Knowing which is which is half the battle, and it is one of the distinctions covered in what trading UI projects get wrong.
Deriving precision from the exchange, not from a constant
A tempting shortcut is to hardcode "8 decimals for crypto" and move on. That is wrong per market. BTC/USDT prices to two decimals on Binance; a meme coin priced at 0.00001162 needs far more. The authoritative source is the exchange itself, which publishes filters in its exchange-info endpoint. We read two of them:
PRICE_FILTER.tickSizegives the smallest price increment, which sets the quote precision.LOT_SIZE.stepSizegives the smallest quantity increment, which sets the base precision.
A tick size arrives as a string like "0.01000000". We need to turn that into a count of meaningful decimal places. The helper in binance-mapper.ts (adapted from a well-worn Stack Overflow answer) counts decimals by scaling up by powers of ten until rounding stops changing the value:
const getPrecision = (a: number) => { if (!isFinite(a)) { return 0; } let e = 1; let p = 0; while (Math.round(a * e) / e !== a) { e *= 10; p++; } return p; };
So getPrecision(0.01) returns 2 and getPrecision(0.00001) returns 5. The mapper then attaches that precision to each side of the product:
quote: { code: symbol.quoteAsset, precision: getPrecision(Number(tickSize)), tickSize, }, base: { code: symbol.baseAsset, precision: getPrecision(Number(stepSize)), stepSize, },
The demo ships a hardcoded allow-list of base assets in product-options.ts (BTC, ETH, SOL, AAVE, PEPE and many more), but the precision for each is never hardcoded. It is always computed from that market's filters. The spread is wide in practice:
| Market | tickSize | quote precision | stepSize | base precision |
|---|---|---|---|---|
| BTC/USDT | 0.01 | 2 | 0.00001 | 5 |
| ETH/USDT | 0.01 | 2 | 0.0001 | 4 |
| PEPE/USDT | 0.00000001 | 8 | 1 | 0 |
That last row is the clincher. PEPE trades in whole-unit steps (precision 0) but to eight decimal places on price. A single hardcoded precision constant cannot serve both columns of that table, which is also why number and currency formatting deserves its own care, covered in internationalisation for crypto exchanges.
Sanitising what the user types
Exact maths is worthless if the input is already junk. A type="number" field in HTML is more permissive than it looks: it happily accepts 1e3, +5, and -0.5, and the scientific-notation form in particular will poison any string-based parsing downstream. The order form blocks those characters at the source:
const invalidChars = ["e", "E", "+", "-"]; const _onPriceChange = useCallback( (e: React.ChangeEvent<HTMLInputElement>) => { const lastChar = e.target.value.slice(-1); if (invalidChars.includes(lastChar)) { e.target.value = e.target.value.slice(0, -1); return; } onPriceChange(e); }, [onPriceChange], );
The amount field uses a slightly stricter variant that rejects the change if any forbidden character appears anywhere in the value, not just at the end. With the character set cleaned up, the next job is to stop the user entering more decimal places than the market allows. Rather than rejecting the keystroke, we slice the excess off:
const fixValueToPrecision = (value: string, precision: number) => { const [intPart, decPart = ""] = value.split("."); if (decPart) { const limitedDecPart = decPart.slice(0, precision); return `${intPart}.${limitedDecPart}`; } return value; };
There is a subtle sibling for the total. Because a total can be a very large number (think billions of PEPE times a price), the integer part is normalised through BigInt so it survives without precision loss before the decimal portion is sliced:
const fixTotalToPrecision = (value: string, precision: number) => { const [intPart, decPart = ""] = value.split("."); if (decPart) { const limitedDecPart = decPart.slice(0, precision); return `${BigInt(intPart).toString()}.${limitedDecPart}`; } return BigInt(value).toString(); };
Doing this work on every keystroke keeps the input and the model in lockstep. Because the handlers are memoised with useCallback and the maths is cheap, this does not become a render bottleneck, but if your form drives heavier downstream recomputation it is worth reviewing the techniques in performant React trading applications. And do not forget that an input that rejects characters silently can confuse users relying on assistive tech, so pair this with the guidance in accessibility for trading UIs to surface validation state properly.
The honest "≈" total
Even with exact arithmetic, the displayed total is a fixed-precision projection of the true product. When you write result.toFixed(precision), you are rounding to the quote precision, so the figure on screen can differ from the unrounded multiplication by a fraction below the smallest displayable unit. The order form is candid about this. The total field is read-only and prefixes the value with an approximation symbol:
<OrderFormInput prefix="Total" suffix={quote.code} disabled align="end" type="text" value={total ? `≈ ${total}` : ""} />
That little ≈ is doing real work. It tells the trader the displayed total is indicative, that the exact charge depends on fill price and rounding at submission time, and it spares us from claiming a precision we are not promising. Honesty in the UI is cheaper than a support ticket arguing about a one-cent discrepancy.
Rounding direction is a decision, not a default
The most overlooked detail is which way you round, and it is not symmetric. The two sides of an order want different behaviour:
- Quantity rounds down to the step size. Rounding a quantity up could ask to buy or sell more than the user can afford or holds.
decimal.jslets you set this explicitly:
import Decimal from "decimal.js"; // Round a quantity DOWN to the nearest step (e.g. 0.001). const quantizeToStep = (amount: string, stepSize: string) => { const steps = new Decimal(amount) .div(stepSize) .toDecimalPlaces(0, Decimal.ROUND_DOWN); return steps.mul(stepSize).toString(); }; quantizeToStep("1.23456", "0.001"); // "1.234"
- Price rounds to the tick size, and which direction can depend on side and order type, but it must always land exactly on a tick. A limit price between two ticks is not a real price the exchange can represent.
Get the direction wrong and you produce orders that are either rejected (too many decimals) or subtly larger than intended (rounded up). The matching engine will not negotiate.
Finally, the rule that ties everything together: never send a float to the API. Keep order values as strings or decimal objects from the input all the way to the request body. The moment a value passes through a Number, you have reintroduced the binary-rounding problem you spent all this effort avoiding, and you will not notice until an order with a malformed quantity bounces. This discipline is the same one that keeps a real-time order book consistent: parse once at the boundary, carry the precise representation everywhere internal, and serialise back to a string only at the edge.
Closing checklist
If you are auditing an order form for precision bugs, run through this:
- Inputs are read as strings and never round-tripped through
parseFloatbefore exact maths. - Scientific notation and sign characters (
e,E,+,-) are blocked at the input. - Decimal places are constrained to the market's precision on every change.
- Price precision comes from
tickSize, quantity precision fromstepSize, both read from exchange metadata, not hardcoded. - Order maths uses
decimal.js(or an equivalent exact decimal type), not nativeNumber. - Quantities round down to the step size; prices land exactly on a tick.
- Displayed totals that are rounded projections are labelled honestly (the
≈prefix). - Read-only summaries may use
Number, but anything submittable uses exact maths. - No float ever reaches the API request body.
Floating point is not a JavaScript flaw to route around; it is a property of binary doubles that every language shares. The trading-specific move is recognising that an order form is not a calculator, it is the last mile before real money changes hands, and treating precision as a first-class concern from the keystroke to the request body.
