소수점 계산 함정 — 금액 계산에 float 쓰면 안 되는 이유

🌐 English

주문 API가 LOT_SIZE 오류를 뱉습니다. 수량을 찍어보니 0.30000000000000004. 분명히 0.1 * 3을 했을 뿐인데 말이죠. 이 글은 그 뒤에 있는 원리와, 실제 매매 코드에서 어떻게 처리하는지를 정리합니다.

⚠️ 이 글은 교육·정보 제공 목적입니다. 투자 권유가 아니며, 투자 손실 책임은 본인에게 있습니다.

왜 이런 일이 생기나

컴퓨터의 float(부동소수점)은 2진법으로 숫자를 저장합니다. 10진수 0.1은 2진법에서 무한소수라서, 유한한 비트에 담는 순간 반드시 반올림됩니다. 우리가 10진수로 1/3을 정확히 못 적는 것과 정확히 같은 상황입니다.

>>> 0.1 + 0.2

0.30000000000000004

>>> 0.1 + 0.2 == 0.3

False

>>> 1.1 * 3

3.3000000000000003

이건 파이썬 버그가 아닙니다. Go, 자바, C, 자바스크립트 전부 똑같습니다(IEEE 754 표준). 오차는 대략 15~16번째 유효숫자에서 발생합니다. 평소엔 안 보이다가, 나눗셈·곱셈이 몇 번 겹치면 표면으로 올라옵니다.

실제로 언제 터지나

매매 코드에서 이 오차가 실제 손해로 이어지는 지점은 세 곳입니다.

  1. 주문 거부 — 거래소는 수량·가격이 정해진 단위의 배수여야 받아줍니다. 0.300000000000000040.1의 배수가 아니라서 거부됩니다.
  2. 등호 비교 실패if balance == target:이 영원히 성립하지 않아 청산 로직이 안 돕니다.
  3. 누적 오차 — 손익을 계속 더하다 보면 원 단위가 조금씩 어긋나 장부가 안 맞습니다.

세 번째는 특히 조용합니다. 하루에 몇 십 원씩 벌어지다가 월말 정산에서야 드러납니다.

해법 1 — 정수로 계산한다 (가장 강력)

가장 확실한 방법은 소수를 아예 안 쓰는 것입니다. 국내 주식은 이게 자연스럽습니다. 가격은 원 단위 정수, 수량도 정수이기 때문입니다.

def tick_size(price: int) -> int:

    """KRX 일반 주식 호가단위 (ETF·ELW는 별도)"""

    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:

    """호가단위로 올림 — 전부 정수 연산, float 오차 없음"""

    t = tick_size(int(price))

    return ((int(price) + t - 1) // t) * t

여기서 방향이 중요합니다. 매도 지정가는 올림, 매수 지정가는 내림으로 맞추는 게 일반적입니다. 반대로 하면 체결 가능성이 낮은 가격이 나갑니다. 어느 쪽이든 내 손해 방향으로 반올림하는 것이 안전한 기본값입니다.

코인에서도 같은 발상을 씁니다. 비트코인 잔고를 사토시(1억분의 1) 정수로, 이더리움 금액을 wei(10의 18제곱분의 1) 정수로 다루면 오차가 원천 차단됩니다. 온체인 코드가 전부 정수를 쓰는 이유가 이것입니다.

// Go — 온체인 금액은 처음부터 끝까지 big.Int

profit := new(big.Int).Sub(amountOut, amountIn)

total := new(big.Int).Add(totalProfit, profit)

해법 2 — Decimal (10진 소수)

파이썬 표준 라이브러리 decimal10진법 그대로 계산합니다. 그래서 사람이 기대하는 결과가 나옵니다.

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" — 지수 표기 없음

딱 하나 함정이 있습니다. Decimal(0.1)처럼 float을 넘기면 오차가 그대로 따라옵니다. 반드시 문자열로 만드세요.

Decimal(0.1)      # Decimal('0.1000000000000000055511151231257827...')  ❌

Decimal("0.1")    # Decimal('0.1')                                       ✅

Go 표준 라이브러리에는 Decimal이 없습니다. math/bigbig.Float(고정밀 2진)나 big.Rat(분수)로 대신하거나, 외부 decimal 패키지를 씁니다. 다만 big.Float도 2진 기반이라 10진 소수의 정확한 표현을 보장하지는 않습니다 — 정밀도가 훨씬 높을 뿐입니다. 정확한 10진 연산이 필요하면 big.Rat이나 decimal 패키지 쪽이 맞습니다.

해법 3 — float 유지 + 계산 직후 정리

현실적으로 많은 봇이 이 방식을 씁니다. 성능과 코드 단순함을 얻는 대신, 거래소로 나가기 직전에 반드시 정리하는 규율을 지킵니다.

// 값을 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   // ← 이 줄이 핵심

}

마지막 줄이 왜 필요한지가 이 글의 요점입니다. math.Floor(v/step) * step만 하면 그 곱셈에서 다시 오차가 생겨 0.30000000000000004가 나옵니다. 정해진 자릿수로 한 번 더 반올림해서 찌꺼기를 털어내야 합니다.

전송할 때는 문자열 포맷도 신경 써야 합니다. 작은 수는 1e-05 같은 지수 표기로 직렬화되어 거래소가 거부하는 일이 있습니다.

// 자릿수를 명시해 지수 표기를 원천 차단

strconv.FormatFloat(v, 'f', decimalsOf(step), 64)   // "0.300"
# 파이썬도 동일

f"{qty:.8f}".rstrip("0").rstrip(".")    # 0.001 → "0.001"

거래소 규칙 — 하드코딩하지 말 것

단위는 종목마다 다르고 거래소가 예고 없이 바꿉니다. 시작할 때 API에서 받아와 캐시하세요. 바이낸스 계열은 exchangeInfo에서 필터로 내려옵니다.

  • LOT_SIZEstepSize(수량 단위), minQty(최소 수량)
  • PRICE_FILTERtickSize(가격 단위)
  • MIN_NOTIONAL → 최소 주문 금액(수량 × 가격)

세 개를 순서대로 적용하는 게 정석입니다.

price = round_step(raw_price, f.tick_size)      # 가격 단위 맞춤

qty   = round_step(raw_qty,   f.step_size)      # 수량 단위 맞춤(내림)

if qty < f.min_qty:

    return skip("최소 수량 미달")

if qty * price < f.min_notional:

    return skip("최소 주문 금액 미달")

수량을 내림으로 맞추는 건 잔고 초과 주문을 피하기 위해서입니다. 올림했다가 잔고보다 1틱 많아지면 그대로 거부됩니다. 그리고 필드 이름이 API 버전에 따라 바뀌는 경우도 있으니(예: minNotionalnotional), 파싱할 때 폴백을 두면 업데이트 때 덜 다칩니다.

비교는 절대 ==로 하지 말 것

# ❌ 영원히 False일 수 있음

if position_qty == 0:

    ...

# ✅ 허용 오차(epsilon)로 비교

EPS = 1e-9

if abs(position_qty) < EPS:

    ...

거래소마다 8자리, 6자리 등 표현 한계가 있으니 EPS는 다루는 자산의 최소 단위보다 작게 잡습니다. 아주 작은 잔량(dust)이 남아 청산 로직이 안 도는 상황을 이 한 줄이 막아줍니다.

정리

  • float 오차는 언어 버그가 아니라 2진 표현의 구조적 한계
  • 가능하면 정수로 — 원 단위, 사토시, wei. 온체인은 전부 정수 연산
  • 10진 정확도가 필요하면 Decimal. 단 문자열로 생성
  • float을 쓴다면 거래소로 나가기 직전 step 단위 정리 + 자릿수 반올림 한 번 더
  • 수량은 내림, 가격은 유리한 방향이 아니라 안전한 방향으로
  • stepSize·tickSize·최소 주문 금액은 API에서 받아 캐시. 하드코딩 금지
  • 등호 비교 금지 — 허용 오차 사용

이 문제는 어렵지 않습니다. 다만 모르면 반드시 한 번은 당하고, 실서버에서 당하면 주문이 거부되거나 장부가 안 맞습니다. 규칙 몇 줄을 미리 정해두는 것으로 충분히 피할 수 있습니다.

댓글

이 블로그의 인기 게시물

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

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

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