[BE-31] 비용 모델 프로덕션 경로 연결·요율 단일화
작업 내용 (설계 의도)
왜 필요한가 — 닫혔다고 보고한 결함이 닫히지 않았다
BE-06 리뷰에서 “요율 드리프트를 닫았다”고 보고했다. codex 교차 리뷰 결과 닫히지 않았다. 리뷰가 검증한 질문은 “run_engine 이 cost_model 을 필수 인자로 받는가”였고, 필요한 질문은 “프로덕션이 그 함수를 부르는가”였다. 약한 질문에 참이라 통과시켰다.
전략을 비용 미반영으로 검증하고 비용을 물고 집행하면, 검증한 엣지가 실전에서 사라진다. ADR-004 가 코드 중복보다 파라미터 드리프트를 진짜 위험으로 본 이유가 그것이고, 그 위험이 지금 그대로 살아 있다.
결함 1 — 프로덕션 백테스트가 비용 모델을 통과하지 않는다
| 경로 | 비용 모델 | 호출부 |
|---|---|---|
ml/app/main.py:459 → run_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:25 는 stock·alert·watchlist 도메인 Kotlin 테스트만 실행한다. autotrading parity 테스트와 ml pytest 가 전부 제외되고, CI workflow 도 없다. “드리프트는 머지 전에 자동 차단된다”는 주장이 성립하지 않는다.
변경 사항
| # | 작업 |
|---|---|
| 1 | run_backtest() 가 CostModel 을 받아 진입·청산 수익률을 실효 단가로 계산하게 한다. main.py 의 /backtest/{symbol} 이 krx_default() 를 넘긴다 |
| 2 | 비용 미반영 경로를 남기지 않는다 — 인자를 선택형으로 두면 같은 결함이 재발한다. 기본값 없는 필수 인자로 둔다 |
| 3 | 요율의 실제 단일 출처를 만든다. contracts/autotrading/cost-model-parity.json 의 rates 를 양쪽 런타임이 기동 시 읽거나, 그 JSON 에서 두 언어 상수를 생성한다. 사람이 세 곳을 맞추는 구조를 없앤다 |
| 4 | 왕복 비용 정의를 위 표에서 확정하고 PRD·TDD 를 정정한다 |
| 5 | parity 테스트를 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()와 PythonCostModel.krx_default()의 세 요율이cost-model-parity.json의rates와 완전히 같다. - JSON 의
slippage_rate를 바꾸면 양쪽 런타임 값이 함께 바뀐다(사본이 아니라 파생임을 검증 — 로딩 방식 채택 시). - 수량 0 매수·매도의 비용이 0이고 실효 단가가 의도가와 같다(경계값).
- 슬리피지가 매수에서 체결가를 올리고 매도에서 내린다(방향 검증 — 부호 반전이 왕복 비용을 0으로 만드는 사고 차단).
- pre-push 훅이
autotradingparity 스펙과mlparity pytest 를 실제로 실행한다(실행 건수 0건인데 성공으로 끝나지 않는지 확인).