← 전체 구조
PR #168 · LimitOrderSettlementCalculator

누적 차액 정산

부분 체결이 여러 번 일어나도 최종 금액이 한 번에 체결한 것과 정확히 같아야 한다. 그래서 회차마다 수수료를 따로 계산해 더하지 않고, 매번 “지금까지 전부”를 다시 구한 뒤 이전 누적을 뺀다.

예시 하나로 따라가기

지정가 주문
74,500원 · 6주
접수 시 동결액
447,045원
수수료율
0.01%
체결
3회 분할

매수는 지정가 이하 호가부터 채우므로, 실제 체결가는 74,300 → 74,400 → 74,500으로 올라간다. 아래에서 색이 진한 구간이 그 회차에 새로 더해진 대금이고, 오른쪽 두 숫자가 누적 수수료와 거기서 뽑아낸 이번 회차 수수료다.

누적 체결 대금 누적 수수료 이번 회차 1회차 148,600 74,300 × 2주 15 15 2회차 371,800 + 74,400 × 3주 37 22 3회차 446,300 + 74,500 × 1주 45 8 3회차를 따로 계산했다면 7원이다. 74,500 × 0.01% = 7.45 → 반올림 7. 하지만 누적으로 보면 44.63 → 45이고, 이전 37을 빼서 8이 된다.
회차별로 따로 반올림하면 합이 44원, 누적 차액으로 하면 45원. 1원 차이가 별것 아닌 것 같지만, 이게 “6주를 한 번에 산 사람”과 “나눠서 산 사람”이 다른 수수료를 내게 되는 지점이다.

44원과 45원은 어디서 나왔나

수수료율은 0.01%, 원 단위 반올림이다. 두 방식이 갈리는 건 “무엇에 요율을 곱하느냐” 하나뿐이다.

방식 A — 회차마다 따로

쓰지 않는 방식. 그 회차의 자기 대금에만 요율을 곱하고 각각 반올림한다.

회차그 회차 대금× 0.01%반올림
1148,60014.8615
2223,20022.3222
374,5007.457
합계44

방식 B — 누적 차액 (실제 코드)

지금까지 전부에 요율을 곱해 반올림하고, 이전까지 부과한 걸 뺀다.

회차누적 대금× 0.01%누적이번
1148,60014.861515
2371,80037.183722
3446,30044.63458
합계45
차이는 3회차 한 곳이다 — 7원 대 8원. A는 7.45를 반올림해서 7. B는 44.63을 반올림한 45에서 이미 낸 37을 빼서 8. 소수점 조각이 회차마다 잘려나가느냐, 끝까지 누적됐다가 마지막에 한 번만 잘리느냐의 차이다. A는 .86 → .32 → .45를 세 번 판단하고, B는 .63 하나만 판단한다.
같은 6주를 한 번에 샀다면  446,300 × 0.01% = 44.63 45원 방식 B로 나눠 사면 45원 — 같다 방식 A로 나눠 사면 44원 — 1원 덜 냄

나눠 사는 게 유리해지면 안 된다. 회차 수는 워커가 언제 돌았는지, 그때 호가에 물량이 얼마나 있었는지로 결정된다 — 사용자가 고른 게 아니다. 그것 때문에 수수료가 달라지면 설명할 방법이 없다. 지금은 1원이지만 금액이 커지고 분할 횟수가 늘면 오차도 같이 커진다.

숫자 전체

회차체결누적 대금 누적 수수료이번 수수료이번 결제액잔여 동결액
접수447,045
1회차74,300 × 2148,600 1515148,615298,430
2회차74,400 × 3371,800 3722223,22275,208
3회차74,500 × 1446,300 45874,5080
합계6주446,3004545446,345700 환급

전량 체결이라 남은 동결액 700원은 예수금으로 환급된다. 지정가 74,500원으로 잡아둔 6주분(447,000원)보다 실제로 700원 싸게 샀기 때문이다. 부분 체결로 끝났다면 남은 동결액은 그대로 묶인 채 다음 체결이나 만료를 기다린다.

여기서 나오는 반직관적 결과

수수료 0원인 회차

누적이 반올림 경계를 넘지 않으면 차액이 0이다. 체결은 됐는데 그 회차 수수료가 0원으로 기록될 수 있다.

SEC 수수료가 1회차에 몰림

미국 매도 SEC 비용에는 최소액이 있다. 첫 회차에서 최소액을 채우고 나면, 이후 회차의 secDelta는 0인 경우가 흔하다.

과거분은 그때 환율 그대로

previous.unroundedTaxKrw().add(secDelta.multiply(rate)) — 새 환율로 이미 부과한 SEC를 재환산하지 않는다. 환율이 움직여도 지난 회차 금액은 바뀌지 않는다.

결제액이 0 이하일 수 있다

매도인데 수수료·세금이 대금보다 큰 경우다. 그러면 NON_POSITIVE_SETTLEMENT로 그 주문의 이번 탐색을 멈춘다 — 체결시키면 손실이 확정되므로.

그래서 수량을 이진 탐색한다

동결액이 모자랄 때 “얼마나 살 수 있나”를 역산할 수 없다. 수량이 늘면 결제액도 늘긴 하는데, 누적 반올림 때문에 그 관계가 매끄러운 식이 아니다. 그래서 1주부터 최대 수량 사이를 재보면서 들어가는 최댓값을 찾는다.

long low = 1, high = quantity.longValueExact(), best = 0;
while (low <= high) {
    long mid = low + (high - low) / 2;
    LimitOrderSettlementResult candidate = calculate(..., BigDecimal.valueOf(mid), ...);
    if (candidate == null || candidate.netAmountKrw().compareTo(cash.subtract(BigDecimal.ONE)) > 0) {
        high = mid - 1;
    } else {
        if (candidate.isExecutable()) best = mid;
        low = mid + 1;
    }
}
cash − 1인가. 부분 체결인데 동결액이 정확히 0이 되면, 남은 수량은 있는데 살 돈이 0인 좀비 주문이 된다. 그래서 부분 체결 후보는 최소 1원을 남긴다. 전량 체결일 때만 동결액을 0으로 떨어뜨리고 잔액을 환급한다.

확정 계층이 깐깐한 이유

이 방식은 previous, 즉 지금까지의 누적이 정확할 때만 성립한다. 한 번 틀어지면 이후 모든 회차의 차액이 틀어진다. 그래서 락을 잡은 직후, 체결 이력을 합산한 값과 주문에 저장된 누적 컬럼을 하나하나 대조한다.

if (previous.quantity().compareTo(order.getFilledQuantity()) != 0
        || previous.grossAmountKrw().compareTo(order.getGrossAmount()) != 0
        || previous.feeKrw().compareTo(order.getFee()) != 0
        || previous.taxKrw().compareTo(order.getTax()) != 0
        || executions.countByOrderId(...) != order.getExecutionCount()) throw internal("누적 체결 불일치");
이건 보류가 아니라 예외다. 다른 실패들은 Reason으로 되돌려 다음 기회를 노리지만, 누적 불일치는 이미 저장된 데이터가 깨졌다는 뜻이라 롤백하고 멈춘다. LimitOrderSettlementCalculator.validateState도 같은 태도다 — 요율과 저장된 누적이 어긋나면 과거 금액을 덮어 맞추지 않고 중단한다.