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.

  1. Order rejection — exchanges require quantity and price to be multiples of a defined unit. 0.30000000000000004 is not a multiple of 0.1, so it gets rejected.
  2. Equality comparison never firesif balance == target: is never true, so your exit logic never runs.
  3. 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/bigbig.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_SIZEstepSize (quantity increment), minQty (minimum quantity)
  • PRICE_FILTERtickSize (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 minNotionalnotional), 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 step right 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.

댓글

이 블로그의 인기 게시물

한국투자증권 KIS API로 실시간 시세 받기 (WebSocket 실전)

파이썬으로 업비트 API 연동하기 — 시세 조회부터 주문까지 기초

Go로 자동매매 신호봇 프레임워크 설계하기