[BE-27] 호가 사다리 산술 수정
작업 내용 (설계 의도)
변경 사항
order/domain/KoreanPriceTick.kt:77-80 의 stepDown() 이 N호가 아래를 구하지 못한다. 출발 가격대의 호가 단위를 한 번에 N배로 곱해 빼기 때문에, 결과가 아래 가격대로 넘어가면 그 가격대의 호가 단위를 무시한 값이 나온다.
val steppedPrice = alignDown(price).subtract(tickSizeOf(price).multiply(BigDecimal(ticks)))
가격대 경계에서 구현값과 실제 3호가를 대조한 결과다.
| 출발 가격 | 구현 결과 | 실제 3호가 | 차이 |
|---|---|---|---|
| 2,000 | 1,985 | 1,997 | 12원 |
| 5,000 | 4,970 | 4,985 | 15원 |
| 20,000 | 19,850 | 19,970 | 120원 |
| 50,000 | 49,700 | 49,850 | 150원 |
| 200,000 | 198,500 | 199,700 | 1,200원 |
| 500,000 | 497,000 | 498,500 | 1,500원 |
200,000원은 500원 단위 밴드의 시작점이지만 그 아래는 100원 단위 밴드다. 한 칸씩 내려가면 199,900 → 199,800 → 199,700 이다. 코드는 500 × 3 = 1,500원을 통째로 빼 198,500원을 낸다.
왜 산술 오차로 끝나지 않는가
stepDown() 의 유일한 호출자는 OcoOrderSpec#stopLossLeg 이고, 그 결과가 손절 leg 의 지정가로 그대로 토스에 나간다. 200,000원 종목의 손절이 발동하면 지정가가 199,700원이 아니라 198,500원으로 걸리므로, 그 가격까지 밀려 체결되면 주당 1,200원을 더 잃는다. 100주면 12만원이다.
버퍼의 정의도 무너진다. ADR-004 가 정한 버퍼는 “발동가보다 3호가 아래”다. 200,000원 바로 아래의 실제 호가 그리드는 100원이므로 3호가는 300원(0.15%)이다. 구현은 1,500원(0.75%)을 빼므로 실제로는 15호가를 양보한다 — 정의의 5배다. 밴드 경계 바로 위에서만 이렇게 벌어져 눈에 잘 띄지도 않는다.
리뷰가 통과시킨 이유 — 질문의 강도
Claude 리뷰는 이 지점을 명시적으로 검증하고 통과시켰다. “stepDown 밴드 횡단 — 항상 유효합니다. 2,000→1,985 / 200,000→198,500 이 테스트로 고정돼 있습니다.” 1,985원은 1원 단위 밴드에서 유효한 호가이므로 “유효한가?” 라는 질문에는 참이다. 필요한 질문은 “3호가인가?” 였고 아무도 묻지 않았다. KoreanPriceTickTest.kt:118-120 이 잘못된 값을 기대값으로 박아 버그를 승인했다.
작업 범위
stepDown()이 호가 사다리를 한 칸씩 N회 내려가게 고친다. 각 단계에서 “내려간 뒤의 위치”가 속한 밴드의 호가 단위로 다음 칸을 정한다.ticks는 3 수준이라 반복 비용은 문제가 되지 않는다.- 최소 호가(1원) 하한과 음수 틱 거부는 현재 동작을 유지한다.
KoreanPriceTickTest.kt:118-120의 잘못된 기대값(1,985 / 49,700 / 198,500)을 정정한다.- KRX 호가 단위 표의 1차 출처를 확보한다. 현재 KDoc 은 “출처 미확인 — 재조사 필요”로 남아 있고 교차 근거는 토스 스펙의
orderPrice설명 한 줄(50,000~200,000원 구간만 확인) 뿐이다. 확보하면 KDoc 에 조항·URL·확인일을 남기고, 확보하지 못하면 “출처 미확인” 문구를 그대로 유지한다 — 조용히 지우지 않는다. 표가 틀리면 정렬은 안 하느니만 못하다(무효 호가 →400 invalid-request→ ADR-004 규칙상 포지션 즉시 청산).
공용 테스트 헬퍼 선점 소유 — KoreanPriceTickLadder
이 티켓이 KoreanPriceTickLadder 를 만들고 소유한다. 밴드표를 1원부터 훑어 유효 호가를 순서대로 생성하는 독립 구현이며, 테스트는 이 사다리에서 “K칸 아래”를 뽑아 stepDown 결과와 대조한다.
- 판정 기준: 사다리의 인접 두 값 사이에는 유효 호가가 없어야 하고,
stepDown(p, n)은 사다리에서p이하 첫 값으로부터 정확히n칸 아래여야 한다. - 근거: 손으로 박은 기대값이 이 버그를 승인했다. 프로덕션과 다른 방식으로 계산한 기대값이어야 대조에 의미가 있다.
- 후행 티켓(BE-19)은 소비만 한다 — 같은 헬퍼를 새로 만들지 않는다. 새 판정이 필요해지면 직접 추가하지 말고 이 티켓으로 되돌려 보고한다.
파일 경계
order/domain/KoreanPriceTick.kt 와 그 테스트만 소유한다. OcoOrderSpec.kt·OcoOrderSpecTest.kt 는 BE-28 소유이므로 건드리지 않는다 — 손절 leg 관점의 검증은 BE-28 이 맡고, 이 티켓은 KoreanPriceTick 테스트 안에서 사다리 산술만 고정한다.
롤백: 산술 변경만이라 이전 계산식으로 되돌리면 즉시 원복된다(신규 프로덕션 파일 없음). 다만 되돌리면 손절 지정가가 다시 과도하게 낮아진다.
근거: ADR-004 “1-1. 지정가는 호가 단위(tick)에 정렬한다”.
의존
- 없음 (wave B 선두)
다이어그램
처리 흐름
sequenceDiagram participant Spec as OcoOrderSpec participant Tick as KoreanPriceTick participant Band as 밴드 판정 Spec->>Tick: stepDown(발동가, 3) Tick->>Band: 현재 가격의 밴드 조회 Band-->>Tick: 호가 단위 Tick->>Tick: 한 칸 내림 (3회 반복) Tick->>Band: 내려간 위치의 밴드 재조회 Band-->>Tick: 바뀐 호가 단위 Tick-->>Spec: 정확히 3호가 아래 가격
클래스 의존
flowchart LR Spec[OcoOrderSpec.stopLossLeg] --> Tick[KoreanPriceTick.stepDown] Tick --> Band[bandOf 밴드 판정] Tick --> Min[최소 호가 1원 하한] Ladder[KoreanPriceTickLadder 헬퍼] -.->|기대값 대조| Tick BE19[BE-19 일반 주문 정렬] -.->|소비만| Ladder
테스트 케이스
- 가격대 안에서 3호가 아래를 구하면 64,000원이 63,700원이 된다(기존 동작 보존).
- 200,000원의 3호가 아래는 199,700원이다 — 500원 밴드에서 출발해도 아래 100원 밴드 단위로 내려간다.
- 경계값 6개(2,000 / 5,000 / 20,000 / 50,000 / 200,000 / 500,000)에서 결과가
KoreanPriceTickLadder가 생성한 기대값과 일치한다. - 1호가 아래는 “그 가격보다 작은 가장 큰 유효 호가”다 — 2,000원의 1호가 아래는 1,999원이다.
- 호가 배수가 아닌 가격에서 출발하면 먼저 내림 정렬한 뒤 센다 — 13,575원의 3호가 아래는 13,540원이다.
ticks = 0이면 내림 정렬 결과만 반환한다(멱등).- 1원 밑으로 내려가는 요청은 1원에서 멈춘다 — 2원의 3호가 아래는 1원이다.
- 음수 틱 수는
IllegalArgumentException으로 거부한다. - 0 이하 가격은 밴드 판정에서 거부한다(상태 보호 — 정렬 대상이 될 수 없다).
- 밴드표의 모든 인접 호가 간격이
KoreanPriceTickLadder가 생성한 사다리와 일치한다(표 자체의 회귀 가드).