[BE-30] 게이트 입력 불변식·총 노출 상한

작업 내용 (설계 의도)

왜 필요한가

RiskGate 는 “모든 주문이 반드시 통과하는 단일 관문”으로 설계했다(BE-07). 그런데 관문이 자기 입력을 믿는다. 입력이 오염되면 관문은 오염된 값으로 정상 판정을 내리고 APPROVED 를 돌려준다. 게이트를 우회하는 경로를 막아 놓고, 게이트를 무력화하는 입력을 열어 둔 상태다.

codex 교차 리뷰에서 실제로 관문이 뚫리는 조합 3개가 나왔다.

#오염 입력게이트 판정왜 뚫리는가
1marketPrice = 0, plannedQuantity = 1APPROVEDplannedAmount = 0 × 1 = 0 이라 종목당 상한·총 노출 상한을 둘 다 0원으로 통과한다 (RiskGate.kt:76~84)
2totalExposureLimit = 800,000 (T1 실제 상한 500,000)APPROVED상한이 검증 없는 외부 입력이다 (RiskGate.kt:12). 기존 노출 150,000×4 + 신규 90,000 = 690,000 은 T1 상한을 19만원 초과하는데 승인된다
3exposureAmount 에 음수APPROVED노출 합계가 fold 덧셈이라(RiskGate.kt:81) 음수 한 건이 다른 포지션의 노출을 상쇄한다

세 건 다 PRD FR-8(총 노출 상한 초과 주문 거부, P0)이 코드로 보장되지 않는다는 같은 결론으로 모인다. 현재 상한값의 출처는 코드 어디에도 없고, 게이트 테스트조차 T1 확정 상한 50만원 대신 임의값 80만원을 기본으로 쓴다 — 테스트가 상한 자체를 검증하지 않으니 드리프트를 잡을 장치가 없다.

변경 사항

1. TradeCandidate.marketPrice > 0 불변식

TradeCandidate.kt:11data class 로 생성자 검증이 없다. 현재가 0 은 “값을 못 구했다”는 뜻이지 “공짜”라는 뜻이 아니다. 후보 자체를 만들 수 없게 막아 게이트까지 도달하지 못하게 한다. 음수도 같이 거부한다.

signalScore 는 검증하지 않는다 — 음수 점수가 유효 입력이다(약한 신호). 검증 대상은 금액 계산에 곱해지는 값에 한정한다.

2. RiskTier.totalExposureRatio 도입 — 외부 주입 제거

상한을 밖에서 받는 대신 enum 이 스스로 계산하게 한다. 종목당 비율·일일 손실 비율이 이미 RiskTier 소유이므로 세 한도가 한곳에서 대칭을 이룬다. 값은 이론상 최대치인 positionRatio × maxOpenPositions 다 (2026-08-28 확정, TDD “RiskGate 입력 계약”).

단계종목당보유 상한총 노출 상한
T110%5종목50%
T215%5종목75%
T320%4종목80%

종목당 한도와 보유 수 한도가 이미 같은 상한을 강제하므로 이 검사는 구속력 있는 제약이 아니라 회계 드리프트를 잡는 안전망이다 — 포지션 종료 누락 등으로 장부 노출이 실제보다 크게 잡히면 걸린다. 정책 테이블에 별도 컬럼을 두지 않는다: 단계와 무관하게 움직일 이유가 없고, 컬럼을 두면 세 한도의 정합을 사람이 손으로 지켜야 한다.

RiskPolicySnapshottotalExposureLimit 생성자 파라미터를 없애고 riskTier.totalExposureLimit(tradingCapital) 파생 메서드로 바꾼다. 호출부가 값을 지어낼 표면 자체를 제거하는 것이 목적이다.

3. 스냅샷 값 객체 불변식

값 객체불변식근거
RiskPolicySnapshottradingCapital >= 0음수 자본이면 상한이 음수가 돼 모든 후보가 거부된다 — 조용한 전건 차단은 킬 스위치와 구분되지 않아 원인 추적이 불가능하다
OpenPositionSnapshotexposureAmount >= 0, symbol 공백 아님음수 노출이 합계를 낮춰 총 노출 검사를 무력화한다 (결함 #3)

0 은 허용한다 — 자본 0 은 운영상 가능한 상태이고(정지 상태), 노출 0 인 포지션도 청산 직후 잠깐 존재할 수 있다.

4. plannedQuantityPositionSizer 의 관계를 타입으로 강제

현재 이 관계는 KDoc 산문뿐이다 — RiskGate.kt:57 의 “PositionSizer 가 산출한 매수 주수” 한 줄이 전부다. 산문 계약은 이번 과제에서 이미 한 번 실패했다(BE-10 protectOrLiquidate 가 문서상 필수인데 호출되지 않았다). 같은 실패를 반복하지 않으려면 시그니처가 관계를 들고 있어야 한다.

채택: PositionSizer.sizeFor() 만 생성할 수 있는 PlannedQuantity 값 객체를 도입하고 RiskGate.evaluate() 시그니처를 Int 대신 이 타입으로 바꾼다. 생성자를 internal/private 로 닫고 PositionSizer 를 유일한 팩토리로 둔다. 임의 정수를 넘길 방법이 컴파일 타임에 사라진다.

미채택 대안

대안미채택 사유
게이트가 PositionSizer 를 직접 호출게이트가 순수 판정기라는 성질을 잃는다. 사이징 실패와 판정 실패가 한 반환값에 섞여 감사 사유가 흐려진다
require(plannedQuantity == PositionSizer.sizeFor(...)) 런타임 검증게이트가 사이저를 알아야 하므로 위와 같은 결합이 생기고, 검증만을 위해 계산을 두 번 한다
KDoc 유지 + 리뷰로 강제이미 실패한 방식이다. 리뷰 2회를 통과한 산문 계약 위반이 이 과제에 존재한다

흡수한 기존 티켓

BE-18 을 이 티켓이 흡수한다. 같은 파일(RiskTier.kt·RiskGate.kt)을 건드려 분리하면 Single Writer per File 위반이다.

소유 파일

autotrading/domain/RiskGate.kt·RiskTier.kt·TradeCandidate.kt·PositionSizer.kt + 각 테스트. wave B 의 다른 5티켓과 교집합 ∅ 다.

롤백

값 객체 불변식과 enum 파생 메서드만 추가하므로 스키마·영속 데이터 변경이 없다. 되돌리려면 커밋을 되돌리는 것으로 충분하다 — 다만 RiskPolicySnapshot 시그니처가 바뀌므로 BE-11 호출부가 함께 되돌아가야 한다.

의존

  • 없음 (wave B 선두 — 스키마와 무관하다)

다이어그램

처리 흐름

sequenceDiagram
    participant Svc as AutoTradingDomainService
    participant Sizer as PositionSizer
    participant Gate as RiskGate
    participant Tier as RiskTier
    Svc->>Sizer: sizeFor(capital, tier, marketPrice)
    Sizer-->>Svc: PlannedQuantity
    Svc->>Gate: evaluate(candidate, PlannedQuantity, 스냅샷 3종)
    Gate->>Tier: totalExposureLimit(capital)
    Tier-->>Gate: 자본 x totalExposureRatio
    Gate-->>Svc: RiskDecision(거부, TOTAL_EXPOSURE_EXCEEDED)

클래스 의존

flowchart LR
    Sizer[PositionSizer] --> Qty[PlannedQuantity]
    Gate[RiskGate] --> Qty
    Gate --> Snap[RiskPolicySnapshot]
    Gate --> Pos[OpenPositionSnapshot]
    Gate --> Cand[TradeCandidate]
    Snap --> Tier[RiskTier]
    Tier --> Ratio[totalExposureRatio]

테스트 케이스

  • marketPrice 가 0 인 TradeCandidate 생성이 예외로 거부된다(관문 도달 이전 차단).
  • marketPrice 가 음수인 TradeCandidate 생성이 예외로 거부된다.
  • T1 자본 100만원에서 기존 노출 60만원 + 신규 9만원이면 TOTAL_EXPOSURE_EXCEEDED 로 거부된다(외부 주입 상한이 없어도 T1 50만원이 강제된다).
  • RiskTier.T1.totalExposureLimit(100만원) 이 50만원, T2 가 75만원, T3 가 80만원을 반환한다.
  • 모든 단계에서 totalExposureLimit == positionLimit × maxOpenPositions 가 성립한다(파생 관계 불변식).
  • 자본이 0이면 총 노출 상한도 0이고, 어떤 후보도 금액 한도를 통과하지 못한다(경계값).
  • OpenPositionSnapshot 에 음수 exposureAmount 를 넣으면 생성이 거부된다.
  • RiskPolicySnapshot 에 음수 tradingCapital 을 넣으면 생성이 거부된다.
  • PlannedQuantityPositionSizer 밖에서 생성하는 코드가 컴파일되지 않는다(가시성 검증 — 컴파일 실패를 확인하는 소스 세트 또는 ArchUnit 규칙).
  • 노출 합계 경계에서 한도와 정확히 같은 금액은 통과하고 1원 초과는 거부된다(경계값).