[BE-27] 호가 사다리 산술 수정

작업 내용 (설계 의도)

변경 사항

order/domain/KoreanPriceTick.kt:77-80stepDown()N호가 아래를 구하지 못한다. 출발 가격대의 호가 단위를 한 번에 N배로 곱해 빼기 때문에, 결과가 아래 가격대로 넘어가면 그 가격대의 호가 단위를 무시한 값이 나온다.

val steppedPrice = alignDown(price).subtract(tickSizeOf(price).multiply(BigDecimal(ticks)))

가격대 경계에서 구현값과 실제 3호가를 대조한 결과다.

출발 가격구현 결과실제 3호가차이
2,0001,9851,99712원
5,0004,9704,98515원
20,00019,85019,970120원
50,00049,70049,850150원
200,000198,500199,7001,200원
500,000497,000498,5001,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 이 잘못된 값을 기대값으로 박아 버그를 승인했다.

작업 범위

  1. stepDown() 이 호가 사다리를 한 칸씩 N회 내려가게 고친다. 각 단계에서 “내려간 뒤의 위치”가 속한 밴드의 호가 단위로 다음 칸을 정한다. ticks 는 3 수준이라 반복 비용은 문제가 되지 않는다.
  2. 최소 호가(1원) 하한과 음수 틱 거부는 현재 동작을 유지한다.
  3. KoreanPriceTickTest.kt:118-120 의 잘못된 기대값(1,985 / 49,700 / 198,500)을 정정한다.
  4. 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.ktBE-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 가 생성한 사다리와 일치한다(표 자체의 회귀 가드).