부분 체결이 여러 번 일어나도 최종 금액이 한 번에 체결한 것과 정확히 같아야 한다. 그래서 회차마다 수수료를 따로 계산해 더하지 않고, 매번 “지금까지 전부”를 다시 구한 뒤 이전 누적을 뺀다.
매수는 지정가 이하 호가부터 채우므로, 실제 체결가는 74,300 → 74,400 → 74,500으로 올라간다. 아래에서 색이 진한 구간이 그 회차에 새로 더해진 대금이고, 오른쪽 두 숫자가 누적 수수료와 거기서 뽑아낸 이번 회차 수수료다.
수수료율은 0.01%, 원 단위 반올림이다. 두 방식이 갈리는 건 “무엇에 요율을 곱하느냐” 하나뿐이다.
쓰지 않는 방식. 그 회차의 자기 대금에만 요율을 곱하고 각각 반올림한다.
| 회차 | 그 회차 대금 | × 0.01% | 반올림 |
|---|---|---|---|
| 1 | 148,600 | 14.86 | 15 |
| 2 | 223,200 | 22.32 | 22 |
| 3 | 74,500 | 7.45 | 7 |
| 합계 | 44 |
지금까지 전부에 요율을 곱해 반올림하고, 이전까지 부과한 걸 뺀다.
| 회차 | 누적 대금 | × 0.01% | 누적 | 이번 |
|---|---|---|---|---|
| 1 | 148,600 | 14.86 | 15 | 15 |
| 2 | 371,800 | 37.18 | 37 | 22 |
| 3 | 446,300 | 44.63 | 45 | 8 |
| 합계 | 45 |
7.45를 반올림해서 7. B는 44.63을 반올림한 45에서 이미 낸 37을 빼서 8.
소수점 조각이 회차마다 잘려나가느냐, 끝까지 누적됐다가 마지막에 한 번만 잘리느냐의 차이다.
A는 .86 → .32 → .45를 세 번 판단하고, B는 .63 하나만 판단한다.
446,300 × 0.01% = 44.63
45원
방식 B로 나눠 사면
45원 — 같다
방식 A로 나눠 사면
44원 — 1원 덜 냄
나눠 사는 게 유리해지면 안 된다. 회차 수는 워커가 언제 돌았는지, 그때 호가에 물량이 얼마나 있었는지로 결정된다 — 사용자가 고른 게 아니다. 그것 때문에 수수료가 달라지면 설명할 방법이 없다. 지금은 1원이지만 금액이 커지고 분할 횟수가 늘면 오차도 같이 커진다.
| 회차 | 체결 | 누적 대금 | 누적 수수료 | 이번 수수료 | 이번 결제액 | 잔여 동결액 |
|---|---|---|---|---|---|---|
| 접수 | — | — | — | — | — | 447,045 |
| 1회차 | 74,300 × 2 | 148,600 | 15 | 15 | 148,615 | 298,430 |
| 2회차 | 74,400 × 3 | 371,800 | 37 | 22 | 223,222 | 75,208 |
| 3회차 | 74,500 × 1 | 446,300 | 45 | 8 | 74,508 | 0 |
| 합계 | 6주 | 446,300 | 45 | 45 | 446,345 | 700 환급 |
전량 체결이라 남은 동결액 700원은 예수금으로 환급된다. 지정가 74,500원으로 잡아둔 6주분(447,000원)보다 실제로 700원 싸게 샀기 때문이다. 부분 체결로 끝났다면 남은 동결액은 그대로 묶인 채 다음 체결이나 만료를 기다린다.
누적이 반올림 경계를 넘지 않으면 차액이 0이다. 체결은 됐는데 그 회차 수수료가 0원으로 기록될 수 있다.
미국 매도 SEC 비용에는 최소액이 있다. 첫 회차에서 최소액을 채우고 나면, 이후 회차의 secDelta는 0인 경우가 흔하다.
previous.unroundedTaxKrw().add(secDelta.multiply(rate)) — 새 환율로 이미 부과한 SEC를 재환산하지 않는다. 환율이 움직여도 지난 회차 금액은 바뀌지 않는다.
매도인데 수수료·세금이 대금보다 큰 경우다. 그러면 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도 같은 태도다 —
요율과 저장된 누적이 어긋나면 과거 금액을 덮어 맞추지 않고 중단한다.