ADR-004 손절 위임과 비용 모델 중복 처리

상태

결정 (확정)

맥락

두 가지 결정을 함께 다룬다. 둘 다 “이미 존재하는 것을 어떻게 쓸 것인가”의 문제다.

  1. 손절을 누가 지키는가 — 봇이 손절가를 감시하다가 도달하면 매도 주문을 내는 방식은, 봇이 죽어 있는 동안 손절이 작동하지 않는다. 앱 가동률이 100%가 아님은 이미 확인됐다(2026-08-27, 스택 다운으로 일봉 배치 24일 누락).
  2. 비용 모델이 Python에만 있다ml/app/cost_model.pyCostModel(43줄, 수수료·거래세·슬리피지 3파라미터)이 백테스트용으로 존재한다. 페이퍼 체결에도 같은 계산이 필요한데 집행은 Kotlin이다.

결정

1. 손절·익절을 토스 OCO 조건부 주문으로 브로커에 위임한다

토스가 /api/v1/conditional-orders로 OCO를 제공한다 — STOP(손절 트리거가) + PROFIT_RATE(목표 수익률)를 쌍으로 걸면 한쪽 체결 시 다른 쪽이 취소된다. live 매수 체결 직후 이를 등록한다.

OCO 등록에 실패하면 해당 포지션을 즉시 시장가로 청산한다. 손절 없는 포지션을 보유하지 않는다.

2. 비용 모델은 Kotlin에 재구현하되 파라미터를 단일 출처화한다

TradingCostModel 값 객체를 autotrading 도메인에 두고, 수수료율·거래세율·슬리피지율 3개를 설정으로 단일화한다. ml/app/cost_model.py동일 입력 → 동일 출력을 교차 검증 테스트로 강제한다.

근거

손절 위임

  • 봇 가용성과 손실 방어를 분리한다. 브로커에 걸린 주문은 우리 프로세스가 죽어도, 배포 중이어도, 네트워크가 끊겨도 작동한다. 이것이 이 설계에서 가장 값싼 안전장치다.
  • 봇이 감시하는 방식은 감시 주기만큼 손절이 늦고, 장 중 프로세스가 내려가면 방어가 통째로 사라진다.
  • 등록 실패 시 청산하는 규칙이 없으면 “손절이 걸린 줄 알았는데 아닌 포지션”이 생긴다. 이건 손절이 아예 없는 것보다 나쁘다 — 방어되고 있다고 착각하게 만든다.

비용 모델 재구현

  • 원본이 43줄·3파라미터·분기 없는 순수 함수다. 이걸 위해 체결 경로에 HTTP 홉을 만드는 비용이 중복 비용보다 크다.
  • 진짜 위험은 코드 중복이 아니라 파라미터 드리프트(한쪽 수수료율만 바뀌는 것)다. 그래서 파라미터를 단일화하고 동일 픽스처 교차 검증으로 막는다.
  • 젠트레이더가 “백테스트한 전략 정의를 그대로 실전에 배포”하는 것과 같은 목적 — 검증한 것과 집행하는 것이 같은 가정 위에 서야 한다.

고려한 대안

대안미채택 사유
봇이 손절가 감시 후 매도 주문봇이 죽으면 손절도 죽는다. 가동률에 손실 방어를 의존시키는 설계
OCO 등록 실패를 경고만 하고 보유 유지방어되고 있다는 착각을 만든다. 손절 없는 포지션이 조용히 쌓인다
비용 계산을 ml에 HTTP 위임체결 경로에 네트워크 장애점 추가. 산술 한 줄에 홉을 붙이는 과설계
Python 로직을 Kotlin으로 옮기고 ml 쪽 제거백테스트가 ml에 있어 그쪽도 계산이 필요하다. 제거하면 백테스트가 깨진다
언어 중립 공유 모듈 추출Kotlin·Python 간 실질적으로 불가능하거나 과도한 인프라(gRPC·WASM)를 요구한다

영향

  • order 도메인에 ConditionalOrderGateway interface + TossConditionalOrderGatewayImpl신규 파일로 추가된다. 기존 OrderGateway는 수정하지 않는다 (Single Writer 보호).
  • TradingPositionoco_order_id를 보유한다. 포지션 종료·킬 스위치 시 조건부 주문도 함께 취소해야 한다.
  • CONDITIONAL_ORDER 레이트리밋(초당 5회)에 맞춰 OCO 등록을 체결 이벤트 단위로 직렬화한다.
  • 교차 검증 테스트가 ml 픽스처와 Kotlin 픽스처 양쪽에 유지돼야 한다 — 비용 파라미터를 바꿀 때 두 테스트가 함께 갱신된다.
  • paper 모드는 OCO를 등록하지 않는다. 손절·익절 도달을 PaperTradeExecutor가 가격 조회로 판정한다 (돈이 나가지 않으므로 브로커 위임이 불가능하고 불필요하다).