[BE-31] 비용 모델 프로덕션 경로 연결·요율 단일화

작업 내용 (설계 의도)

왜 필요한가 — 닫혔다고 보고한 결함이 닫히지 않았다

BE-06 리뷰에서 “요율 드리프트를 닫았다”고 보고했다. codex 교차 리뷰 결과 닫히지 않았다. 리뷰가 검증한 질문은 “run_enginecost_model 을 필수 인자로 받는가”였고, 필요한 질문은 “프로덕션이 그 함수를 부르는가”였다. 약한 질문에 참이라 통과시켰다.

전략을 비용 미반영으로 검증하고 비용을 물고 집행하면, 검증한 엣지가 실전에서 사라진다. ADR-004 가 코드 중복보다 파라미터 드리프트를 진짜 위험으로 본 이유가 그것이고, 그 위험이 지금 그대로 살아 있다.

결함 1 — 프로덕션 백테스트가 비용 모델을 통과하지 않는다

경로비용 모델호출부
ml/app/main.py:459run_backtest()없음GET /backtest/{symbol} — 실제 API
ml/app/backtest.py:31 run_backtest() 시그니처비용 인자 없음
ml/app/backtest_engine.py run_engine(cost_model=...)필수 인자테스트뿐

cost_model.krx_default() 는 BE-06 에서 추가됐지만 프로덕션 백테스트에서 쓰이지 않는다. 비용을 강제하는 함수는 존재하되 아무도 부르지 않는다.

결함 2 — 요율이 사본 3개다

위치성격
TradingCostModel.kt:86 krxDefault()Kotlin 하드코딩
ml/app/cost_model.py:24 krx_default()Python 하드코딩
contracts/autotrading/cost-model-parity.json:2 rates테스트만 읽는 3번째 복사본

JSON 은 프로덕션이 읽는 설정이 아니다. 두 런타임이 각자 요율을 박아 두고, 셋을 사람이 손으로 동기화한다. 단일 진실원천이라고 KDoc·docstring 에 적혀 있지만 코드가 그 주장을 뒷받침하지 않는다.

결함 3 — 왕복 비용 스펙이 구현과 어긋난다 (수치 확정 필요)

test_cost_model_parity.py:63 의 “왕복 0.31%” 테스트는 실제 왕복 체결을 돌리지 않는다. fee×2 + tax + slippage 를 더한 산술식을 검증할 뿐이다. 구현은 슬리피지 0.1% 를 매수·매도 각각 적용한다.

의도가 P 로 사서 의도가 P 로 되판 경우:

구간실효 단가
매수P × 1.001 × 1.00015 = P × 1.00115015
매도P × 0.999 × 0.99805 = P × 0.99705195

진입 실효단가 대비 왕복 손실은 0.409349% 다. PRD 가 적은 0.31% 는 슬리피지를 1회만 센 값이라 실제와 0.099349%p 어긋난다 — 10만원 포지션당 310원이 아니라 409원이다. 이 수치는 백테스트 엣지 판정 기준선을 직접 움직인다(PRD 성공 지표 “실현 슬리피지 ±0.3%p 이내”보다 큰 오차다).

착수 전 확정할 것: 슬리피지 0.1% 가 편도인지 왕복인지를 정한다.

선택조치결과 왕복
A. 편도 0.1% (현 구현 유지)PRD “왕복 0.31%” 를 0.409349% 로 정정0.409349%
B. 왕복 0.1% (현 문서 유지)편도 요율을 0.05% 로 낮춰 구현 정정약 0.30975%

A 를 권고한다 — 슬리피지는 체결 1회당 발생하는 물리량이고, 백테스트가 실제보다 비용을 낮게 잡는 방향(B) 이 더 위험하다. 확정 결과를 PRD “거래비용” 항목과 TDD 에 반영한다.

결함 4 — 드리프트를 막는 게이트가 없다

.githooks/pre-push:25stock·alert·watchlist 도메인 Kotlin 테스트만 실행한다. autotrading parity 테스트와 ml pytest 가 전부 제외되고, CI workflow 도 없다. “드리프트는 머지 전에 자동 차단된다”는 주장이 성립하지 않는다.

변경 사항

#작업
1run_backtest()CostModel 을 받아 진입·청산 수익률을 실효 단가로 계산하게 한다. main.py/backtest/{symbol}krx_default() 를 넘긴다
2비용 미반영 경로를 남기지 않는다 — 인자를 선택형으로 두면 같은 결함이 재발한다. 기본값 없는 필수 인자로 둔다
3요율의 실제 단일 출처를 만든다. contracts/autotrading/cost-model-parity.jsonrates양쪽 런타임이 기동 시 읽거나, 그 JSON 에서 두 언어 상수를 생성한다. 사람이 세 곳을 맞추는 구조를 없앤다
4왕복 비용 정의를 위 표에서 확정하고 PRD·TDD 를 정정한다
5parity 테스트를 pre-push 또는 CI 에 편입한다 — autotrading Kotest 스펙과 ml pytest 양쪽

3번의 방식 선택

방안설명평가
런타임 로딩양쪽이 기동 시 JSON 을 읽어 요율을 채운다진짜 단일 출처가 된다. 다만 JSON 파일 경로가 배포 산출물에 포함돼야 하고, 파일 부재 시 기동 실패 처리가 필요하다
코드 생성JSON 에서 Kotlin object·Python 모듈을 생성해 커밋한다런타임 의존이 없다. 생성물 커밋을 잊으면 드리프트가 되살아나므로 생성 결과와 커밋본의 일치를 CI 가 검사해야 한다
현행 유지 + parity 테스트 강화사본 3개를 두고 테스트로 대조미채택 — 테스트가 게이트에 없어 결함 4가 그대로다. 게이트에 넣어도 “사본을 맞추라”는 규율을 사람에게 남긴다

구현자가 배포 산출물 구조를 확인해 둘 중 하나를 고르고 근거를 PR 에 남긴다.

5번 게이트 범위 — pre-push 를 무겁게 만들지 않는다

pre-push 는 현재 13초다. ml pytest 전량을 붙이면 게이트가 느려져 우회 유혹이 생긴다. parity 스펙만 지목해서 추가한다 — Kotest 는 스펙 단위 필터(-Dkotest.filter.specs)를 쓰고, pytest 는 test_cost_model_parity.py 만 지정한다. --tests 필터는 Kotest 에서 무시돼 스펙 전량이 도는 함정이 있으므로 쓰지 않는다.

소유 파일

ml/app/main.py·backtest.py·cost_model.py, autotrading/domain/TradingCostModel.kt, contracts/autotrading/*, .githooks/pre-push + 각 테스트. CostModelParityFixture 는 BE-06 소유를 유지하고 이 티켓이 확장한다(프로덕션 경로 연결 검증 추가).

롤백

백테스트 반환 수치가 바뀌므로 과거 백테스트 결과와 직접 비교할 수 없다. 되돌리려면 run_backtest() 의 비용 인자를 제거하는 역방향 커밋이 필요하다. 요율 단일화를 런타임 로딩으로 구현했다면 JSON 파일 배포 누락이 기동 실패로 이어지므로, 배포 순서는 JSON 포함 확인 → 코드 배포 다.

의존

  • 없음 (wave B)

다이어그램

처리 흐름

sequenceDiagram
    participant API as GET /backtest/{symbol}
    participant BT as run_backtest
    participant CM as CostModel
    participant Src as cost-model-parity.json
    CM->>Src: 요율 로딩 (fee/tax/slippage)
    API->>BT: closes, horizon, cost_model
    BT->>CM: buy(entry_price, qty)
    BT->>CM: sell(exit_price, qty)
    CM-->>BT: 실효 단가
    BT-->>API: edge (비용 차감 후)

클래스 의존

flowchart LR
    Json[cost-model-parity.json rates] --> Py[CostModel py]
    Json --> Kt[TradingCostModel kt]
    Py --> BT[run_backtest]
    BT --> Api[backtest endpoint]
    Kt --> Paper[PaperTradeExecutor]
    Json --> Parity[parity 테스트]
    Parity --> Hook[pre-push 게이트]

테스트 케이스

  • /backtest/{symbol} 응답의 엣지가 비용 미반영 계산값보다 작다(프로덕션 경로가 비용을 통과한다).
  • run_backtest() 를 비용 모델 없이 호출하면 TypeError 로 실패한다(비용 미반영 경로가 남아 있지 않다).
  • 의도가 100,000원 왕복 1주의 총비용이 확정한 왕복 정의와 일치한다(A 채택 시 409.349원, 오차 0.001원 이내).
  • Kotlin TradingCostModel.krxDefault() 와 Python CostModel.krx_default() 의 세 요율이 cost-model-parity.jsonrates 와 완전히 같다.
  • JSON 의 slippage_rate 를 바꾸면 양쪽 런타임 값이 함께 바뀐다(사본이 아니라 파생임을 검증 — 로딩 방식 채택 시).
  • 수량 0 매수·매도의 비용이 0이고 실효 단가가 의도가와 같다(경계값).
  • 슬리피지가 매수에서 체결가를 올리고 매도에서 내린다(방향 검증 — 부호 반전이 왕복 비용을 0으로 만드는 사고 차단).
  • pre-push 훅이 autotrading parity 스펙과 ml parity pytest 를 실제로 실행한다(실행 건수 0건인데 성공으로 끝나지 않는지 확인).