Why You Should Never Use Float for Money Calculations
The order API spits back a LOT_SIZE error. You print the quantity and see 0.30000000000000004. All you did was 0.1 * 3. This article covers the mechanism behind that, and how to handle it in real trading code.
⚠️ This article is for educational and informational purposes. It is not investment advice, and you alone are responsible for any losses.
Why This Happens
A computer's float (floating point) stores numbers in binary. Decimal 0.1 is a repeating fraction in binary, so the moment it lands in a finite number of bits it must be rounded. It's exactly the same situation as us being unable to write 1/3 precisely in decimal.
>>> 0.1 + 0.2
0.30000000000000004
>>> 0.1 + 0.2 == 0.3
False
>>> 1.1 * 3
3.3000000000000003
This is not a Python bug. Go, Java, C, and JavaScript all behave identically (the IEEE 754 standard). The error shows up around the 15th to 16th significant digit. It stays invisible day to day, then surfaces once a few divisions and multiplications stack up.
Where It Actually Bites
In trading code there are three places where this error turns into real money lost.
- Order rejection — exchanges require quantity and price to be multiples of a defined unit.
0.30000000000000004is not a multiple of0.1, so it gets rejected. - Equality comparison never fires —
if balance == target:is never true, so your exit logic never runs. - Accumulated drift — keep summing P&L and the smallest unit slowly diverges until your ledger stops balancing.
The third one is especially quiet. It builds up by pennies a day and only surfaces at month-end reconciliation.
Fix 1 — Compute in Integers (Strongest)
The most reliable approach is not using decimals at all. For Korean equities this is natural: prices are whole won and quantities are whole shares.
def tick_size(price: int) -> int:
"""KRX tick size for ordinary stocks (ETFs and ELWs differ)"""
if price < 1_000: return 1
if price < 5_000: return 5
if price < 10_000: return 10
if price < 50_000: return 50
if price < 100_000: return 100
if price < 500_000: return 500
return 1_000
def round_up_tick(price: float) -> int:
"""Round up to the tick size — pure integer math, no float error"""
t = tick_size(int(price))
return ((int(price) + t - 1) // t) * t
Direction matters here. The convention is to round sell limit prices up and buy limit prices down. Do it backwards and you send a price unlikely to fill. Either way, rounding toward your own disadvantage is the safe default.
Crypto uses the same idea. Hold a Bitcoin balance as an integer count of satoshis (one hundred-millionth), or an Ethereum amount as an integer count of wei (10^-18), and the error is eliminated at the source. This is precisely why on-chain code is entirely integer-based.
// Go — on-chain amounts stay big.Int from beginning to end
profit := new(big.Int).Sub(amountOut, amountIn)
total := new(big.Int).Add(totalProfit, profit)
Fix 2 — Decimal (Base-10 Fractions)
Python's standard library decimal computes in base 10 directly, so you get the result a human expects.
from decimal import Decimal, ROUND_DOWN
Decimal("0.1") + Decimal("0.2") # Decimal('0.3') ✅
qty = Decimal("0.30000000000000004")
step = Decimal("0.001")
adj = qty.quantize(step, rounding=ROUND_DOWN) # Decimal('0.300')
print(format(adj, "f")) # "0.300" — no exponent notation
There's exactly one trap. Pass a float, as in Decimal(0.1), and the error comes along for the ride. Always build from a string.
Decimal(0.1) # Decimal('0.1000000000000000055511151231257827...') ❌
Decimal("0.1") # Decimal('0.1') ✅
Go's standard library has no Decimal. You substitute math/big—big.Float (high-precision binary) or big.Rat (rationals)—or reach for an external decimal package. Note that big.Float is still binary-based, so it does not guarantee exact representation of decimal fractions; it is merely far more precise. If you need exact base-10 arithmetic, big.Rat or a decimal package is the right call.
Fix 3 — Keep Float, Then Clean Up Immediately After
Realistically, plenty of bots take this route. You get performance and simpler code, in exchange for the discipline of always normalizing right before the value leaves for the exchange.
// Round a value down to a multiple of step
func RoundStep(v, step float64) float64 {
if step <= 0 {
return v
}
p := decimalsOf(step) // step "0.001" → 3
factor := math.Pow(10, float64(p))
floored := math.Floor(v/step) * step
return math.Round(floored*factor) / factor // ← this line is the point
}
Why that last line is needed is the crux of this article. Do only math.Floor(v/step) * step and the multiplication reintroduces the error, handing you 0.30000000000000004 again. You have to round once more at a fixed number of digits to shake off the residue.
Serialization needs attention too. Small numbers can be emitted in exponent notation like 1e-05, which exchanges reject.
// Specify the digit count to eliminate exponent notation entirely
strconv.FormatFloat(v, 'f', decimalsOf(step), 64) // "0.300"
# Same idea in Python
f"{qty:.8f}".rstrip("0").rstrip(".") # 0.001 → "0.001"
Exchange Rules — Don't Hardcode Them
Units differ per symbol and exchanges change them without notice. Fetch them from the API at startup and cache. Binance-family exchanges expose them as filters under exchangeInfo.
- LOT_SIZE →
stepSize(quantity increment),minQty(minimum quantity) - PRICE_FILTER →
tickSize(price increment) - MIN_NOTIONAL → minimum order value (quantity × price)
Applying the three in order is the standard pattern.
price = round_step(raw_price, f.tick_size) # align to price increment
qty = round_step(raw_qty, f.step_size) # align to quantity increment (round down)
if qty < f.min_qty:
return skip("below minimum quantity")
if qty * price < f.min_notional:
return skip("below minimum notional")
Quantity is rounded down to avoid ordering more than your balance covers. Round up and end up one tick over your balance and the order is rejected outright. Field names also shift between API versions (for example minNotional → notional), so adding a fallback in your parser means less pain on update day.
Never Compare with ==
# ❌ may be False forever
if position_qty == 0:
...
# ✅ compare with a tolerance (epsilon)
EPS = 1e-9
if abs(position_qty) < EPS:
...
Exchanges cap representation at 8 digits, 6 digits, and so on, so set EPS smaller than the minimum unit of the asset you're handling. That single line prevents the situation where a sliver of dust remains and your exit logic refuses to run.
Summary
- Float error isn't a language bug; it's a structural limit of binary representation
- Use integers where you can — whole won, satoshis, wei. On-chain is integer arithmetic throughout
- Need base-10 accuracy? Use
Decimal—but construct it from a string - If you use float, normalize to
stepright before sending, plus one extra rounding at a fixed digit count - Round quantity down, and round price in the safe direction rather than the favorable one
- Pull
stepSize,tickSize, and minimum notional from the API and cache them. No hardcoding - No equality comparisons — use a tolerance
This problem isn't hard. But if you don't know about it you will get burned exactly once, and getting burned in production means rejected orders or a ledger that won't balance. Deciding on a few rules up front is enough to avoid it entirely.
댓글
댓글 쓰기