Files
trex-core/docs/batches/bat-048_20260621_fill-price-backfill-guard-logging-alert-spike/design.md
T
genichinandClaude Opus 4.8 f1b649cf2a feat(execution): 체결가 0/None 시 지정가 백필 가드 + WARN 로깅 (dcy-032/BAT-048)
REQ-095(슬롯 평단·원장 price 지정가 백필, 0 오염 방지) + REQ-096(백필/미해결 WARN 로깅) 구현.
지정가 부재 시 skip+강WARN. application 단일화(_resolve_fill_price), wire/LedgerTrade 스키마 불변.
575 passed/0 fail, opus QC PASS. REQ-097 알람=스파이크 산출물만(impl 후속).

- _resolve_fill_price 헬퍼 추가: avg_price 검증, limit_price 백필, 미해결 WARN
- _on_filled/_on_partial_cancel 통합: 백필 적용, 슬롯/원장 일관성
- _record_ledger 시그니처: resolved_price 파라미터 (backward compat)
- test_execution_fill_price_backfill.py: 21 시나리오 (avg=None/0, limit=있음/없음 등)
- docs: dcy-032, req-095/096/097, bat-048 설계/검증

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VHtM5Ws1HKCKQ4JGi6GRb5
2026-06-21 13:34:31 +09:00

21 KiB

BAT-048 Design

  • Status: draft
  • Batch: BAT-048

Design Review 게이트: 이 문서의 Key Decisions(KD-1, KD-2)와 REQ-097 알람 스파이크 분석(섹션 "알람 능력 스파이크 — REQ-097")은 팀장·사장님의 design review 승인 후 run-batch-implementation으로 이어진다. 아래 권고안은 trex-core-dev가 코드 분석 근거로 작성한 것이며 팀장이 KD-1/KD-2를, 사장님이 알람 M2 포함 여부(OQ-1)를 최종 확정한다.


Architecture Summary

이 batch의 핵심 변경은 application/execution.py 내 체결가 결정 로직에 국소화된다. 아키텍처 계층상 변경 위치는 application 레이어 전용이며, wire 계약·adapter·domain은 불변이다.

현재 문제 흐름:

KIS.poll_fill()
  → FillStatus(avg_price=None or 0.0)
      → _on_filled / _on_partial_cancel
            fill_price = 0.0  [← 현재: avg_price=None이면 old_avg(=0) 사용]
          → slot.avg_price = 0  [슬롯 평단 0 오염, 영구 — clear_dispatch 이후 재폴링 없음]
      → _record_ledger
            price = 0.0  [← 현재: avg_price=None이면 0.0 통과]
          → LedgerWorkItem(price=0.0)  [원장 cost basis 0 오염]

변경 후 흐름:

KIS.poll_fill()
  → FillStatus(avg_price=None or 0.0)
      → _resolve_fill_price(fs, slot)          [신규 헬퍼, application 레이어]
            → slot.limit_price 백필             [지정가 양수이면 사용]
            → 폴백 정책 적용(KD-2 확정 후)     [지정가마저 없으면]
            → _log.warning(백필 WARN)           [REQ-096 AC-1]
      → _on_filled / _on_partial_cancel
            fill_price = _resolve_fill_price 결과   [0 아닌 값]
          → slot.avg_price = 정상 가중평균
      → _record_ledger
            price = 동일 결과값                 [0 아닌 값]
          → LedgerWorkItem(price > 0)

변경 계층:

  • trex_core/application/execution.py (application 레이어) — 백필 헬퍼 + 분기 + WARN 로그
  • tests/application/test_execution.py — 신규 AC 테스트
  • docs/runtime-flows.md, docs/operations.md — 갱신 (implementation 후)

불변 영역:

  • trex_core/adapters/broker/kis.py — adapter 불변 (KD-1 근거)
  • trex_core/adapters/broker/paper.py — adapter 불변 (paper는 항상 양수 avg_price 반환)
  • AMQP wire 계약 (slots RPC payload / trex.events 스키마) — NFR-1

Changed Areas

  • Docs:
    • docs/runtime-flows.md — 체결 처리 흐름에 백필 분기 추가 (implementation 완료 후)
    • docs/operations.md — 백필 WARN 발생 시 운영자 점검 절차 추가 (implementation 완료 후)
  • Modules:
    • trex_core/application/execution.py — _resolve_fill_price(신규 헬퍼), _on_filled·_on_partial_cancel·_record_ledger에 분기 삽입
    • tests/application/test_execution.py — 신규 AC 커버 테스트
  • Interfaces:
    • LedgerTrade.price 필드: 0 → 양수 값으로 정정. 필드 타입/이름 변경 없음(NFR-1).
    • slots RPC avg_price 필드: 0 → 양수 값으로 정정. 스키마 변경 없음(NFR-1).

Key Decisions

KD-1: 백필 결정 지점 — application 레이어 단일화 권고

권고: application/execution.py의 _resolve_fill_price 헬퍼로 단일화. adapters/broker/kis.py는 불변.

분석 근거

현재 kis.py의 avg_price 처리 패턴 (:209, :219, :232, :238, :243):

# 완전체결(:209)
fill_price = float(avg) if avg else 0.0
# ...
avg_price=fill_price or None,   # 0.0 → None으로 변환(:219)

# 부분취소(:232)
fill_price = float(avg) if avg else 0.0
# ...
avg_price=fill_price or None,   # 동일 패턴(:238)

# 부분체결(아직 pending, :243)
avg_price=float(avg) if avg else None   # 미체결분은 그대로

kis.py는 이미 avg_price=None을 FillStatus로 정직하게 전달하는 구조다. 이 층에서 백필을 하려면 kis.py가 slot.limit_price를 알아야 하는데, poll_fill(broker_order_id, side, symbol) 시그니처에 슬롯 컨텍스트(slot)가 없다. 따라서 adapter에서 백필하려면 시그니처 변경(BrokerPort.poll_fill 확장)이 필요하고 이는 포트 계약 변경이다.

paper.py 일관성: paper 브로커는 _Order.__init__에서 self.price = float(req.limit_price) if req.limit_price is not None else 1000.0으로 항상 양수 avg_price를 반환한다. paper에는 백필 분기가 필요 없어 adapter 통일 구현의 이점이 없다.

dcy-021 잔고 재구성 경로: reconcile()은 _poll(r)을 호출 → 동일한 _on_filled/_on_partial_cancel을 통과한다. application에 백필 헬퍼를 두면 재구성 경로도 동일 헬퍼를 자동으로 탄다. adapter에 두면 재구성 경로에서 kis.py → FillStatus(avg_price=None) → application으로 None이 그대로 전달되는 차이가 생긴다(일관성 깨짐).

결론: application 단일화가 유일하게 porter 계약을 건드리지 않고, 모든 경로(KIS 완전체결/부분취소/재구성/paper)에서 동일 헬퍼를 탄다.

adapter 방식의 장단점 (기각 이유):

  • 장점: adapter 경계에서 일찍 정정 → application이 항상 양수 fill_price를 받는다는 불변식 성립.
  • 단점: BrokerPort.poll_fill 시그니처에 slot 컨텍스트 추가 필요(포트 계약 변경) / paper도 수정 필요 / paper는 avg_price가 이미 양수여서 불필요한 중복 코드.

application 방식의 장단점 (권고 이유):

  • 장점: BrokerPort/adapter 불변 / paper 불변 / reconcile 자동 일관성 / slot.limit_price 직접 접근 가능.
  • 단점: _on_filled/_on_partial_cancel/_record_ledger 세 지점에 분기 삽입 필요 → 헬퍼로 추출해 DRY 유지.

팀장 승인 필요: 이 결정이 확정되어야 Phase A 구현이 시작된다.


KD-2: 지정가 부재/≤0 최종 폴백 정책 — skip+강 WARN 권고

권고: slot.limit_price마저 None 또는 ≤0인 극단 케이스는 "체결 처리를 skip하되 강 WARN 로그 발행" 정책.

분석 대상 후보

후보 설명 장점 단점 안전성 구현비용
A: 재폴링 (체결 처리 보류) avg_price=None + limit_price=None 이면 이번 틱 처리를 skip, 다음 틱 재폴링 KIS가 다음 틱에 정상 avg를 줄 수 있음 완전체결 후 clear_dispatch 전에 재폴링이 막힘 — 완전체결 케이스에서 재폴링 경로가 없어 영구 대기. _on_filled 내부에서 skip하면 슬롯이 PENDING_BUY에 잠기고 타임아웃/watchdog 경로가 구조에 의존. 낮음 (영구 교착 가능) 높음 (complete fill 케이스 구분 필요)
B: 직전가/현재가 조회 KIS에 현재가(get_current_price) 추가 조회 → 해당 가격으로 백필 지정가보다 실제 시세에 가까운 근사 (1) BrokerPort에 get_current_price 추가 필요(포트 계약 변경) (2) KIS 추가 API 호출(타임아웃, 네트워크 실패) (3) 매우 드문 케이스를 위한 복잡도 증가 (4) safety_sell.py의 get_current_price(paper 전용 구현)가 있지만 KIS에는 미구현 중간 높음
C: skip + 강 WARN (권고) avg_price=None + limit_price=None 이면 체결 처리를 skip하되 slot_id·symbol·reason을 포함한 WARN 이상 로그 발행. 슬롯 전이 미수행, 다음 틱 재폴링 단순·안전 / 무경고 통과 0(REQ-096 AC-2 충족) / 추가 포트 계약 변경 없음 / "0 저장도 없음 + 강한 WARN"으로 REQ-095 AC-6 최소 기준 충족 (1) 완전체결 상황에서 슬롯이 PENDING 상태에 잠길 수 있음 (2) 운영자 개입 필요(수기 확인 후 trex-core cancel·재진입) 높음 낮음

권고 근거

  • A(재폴링)은 완전체결(is_filled=True) 케이스에서 실행 불가다. _on_filled 내부에서 skip하면 clear_dispatch가 호출되지 않아 슬롯은 PENDING_BUY로 잠기고, 다음 틱에 동일 주문을 다시 poll → is_filled=True 반복 → 루프. 이는 dcy-003 R-1(pending order stuck)과 동일한 패턴이다.
  • B(현재가 조회)는 BrokerPort 포트 계약 변경이며 구현비용 대비 발생 빈도가 낮다. Discovery A-2: "KIS 평균가 누락은 드문 순간 레이스". 또한 KIS get_current_price 구현은 현재 없어 신규 adapter 작업이 필요하다.
  • C(skip + 강 WARN)은 REQ-095 AC-6 최소 기준("이 케이스에서 최소한 0 저장은 발생하지 않거나 강한 WARN이 남아야 한다")을 충족하고, REQ-096 AC-2("무경고 통과 없음")도 충족한다. 슬롯이 PENDING에 잠기는 것은 watchdog/stale_pending 경보가 탐지해 운영자 개입을 유도하므로 안전망이 있다.

구현 스펙 (C 채택 시):

def _resolve_fill_price(self, fs: FillStatus, slot) -> tuple[float | None, bool]:
    """
    Returns (fill_price, was_backfilled).
    fill_price=None → skip signal (폴백 불가, 강 WARN 이미 발행).
    """
    avg = fs.avg_price
    if avg is not None and float(avg) > 0:
        return float(avg), False
    # avg_price None or ≤0: 지정가 백필 시도
    limit = slot.limit_price
    if limit is not None and float(limit) > 0:
        _log.warning(
            "fill price backfill: slot=%s symbol=%s broker_order_id=%s "
            "avg_price=%s backfill_price=%s reason=avg_price_backfill",
            slot.slot_id, slot.symbol, slot.broker_order_id,
            avg, limit,
        )
        return float(limit), True
    # 지정가마저 부재/≤0: skip + 강 WARN
    _log.warning(
        "fill price unresolvable: slot=%s symbol=%s broker_order_id=%s "
        "avg_price=%s limit_price=%s reason=fill_price_unresolvable "
        "(slot stays PENDING — operator attention required)",
        slot.slot_id, slot.symbol, slot.broker_order_id,
        avg, limit,
    )
    return None, False

_resolve_fill_price가 None을 반환하면 호출자(_on_filled, _on_partial_cancel, _record_ledger)에서 해당 체결 처리를 skip한다.

팀장 승인 필요: 폴백 정책 확정 후 REQ-095 AC-6 구체화. 다른 후보 채택 시 설계가 변경된다.


KD-3: _record_ledger 컨텍스트 패싱 — 파라미터 확장 권고

권고: _on_filled/_on_partial_cancel에서 산출한 fill_price(백필 결과)를 _record_ledger에 직접 전달.

현재 흐름 분석

_on_filled(:231~285)는 fill_price(avg_price 가공값)를 계산해 slot.avg_price 갱신에만 사용한다. 이후 :281에서 self._record_ledger(slot, fs, side, broker_order_id, r)를 호출하는데, _record_ledger(:401~437)는 :424에서 fs.avg_price를 다시 꺼내 쓴다.

즉 같은 fs.avg_price를 두 곳에서 독립적으로 해석한다. 백필 헬퍼를 추가하면 _on_filled에서 한 번 계산한 결과를 _record_ledger에서 재계산 없이 전달하는 것이 일관성 있는 설계다.

구현 스펙:

# _on_filled 내부:
fill_price, was_backfilled = self._resolve_fill_price(fs, slot)
if fill_price is None:
    return  # skip: 폴백 불가, WARN 이미 발행됨
# ... slot avg_price 계산 ...
self._record_ledger(slot, fs, side, broker_order_id, r, resolved_price=fill_price)

# _record_ledger 시그니처 변경:
def _record_ledger(self, slot, fs, side: str, broker_order_id: str,
                   r: ExecutionResult, *, resolved_price: float | None = None) -> None:
    # ...
    price = resolved_price if resolved_price is not None else (
        float(fs.avg_price) if fs.avg_price is not None else 0.0
    )

resolved_price 기본값을 None으로 두면 기존 _record_ledger 호출 코드(다른 경로가 있다면)의 하위 호환성이 유지된다. 현재 코드베이스에서 _record_ledger는 _on_filled와 _on_partial_cancel에서만 호출된다.


알람 능력 스파이크 — REQ-097

REQ-097 AC-1: 후보 A·B·C 각각 장점·단점·최소 구현 범위·M2 일정 적합성 명시. REQ-097 AC-2: 후보 B wire 계약 변경 여부와 blast radius 명시.

후보 A: 구조화 로그 기반

개요: FR-3 WARN 로그(REQ-096)를 알람 소스로 삼아 외부 알림 파이프라인(Loki → Grafana Alert / PagerDuty / etc.)에서 소비.

장점:

  • trex-core 코드 변경 최소 — REQ-096(WARN 로그)만 구현하면 코어 측 작업 완료.
  • dcy-029(BAT-045/046) 구조화 로그 인프라(json formatter, correlation_id) 이미 구축됨.
  • wire 계약 불변, blast radius 없음.
  • M2 일정 내 코어 변경 없음.

단점:

  • 운영자가 로그 파이프라인(Loki + 알림 규칙)을 별도로 구성해야 함(인프라 의존).
  • 알림 지연이 로그 수집 파이프라인 지연에 종속된다(수십 초 ~ 분 단위).
  • trex-core 자체에는 알람을 "보내는" 능력이 없음 — 알람은 로그 소비자가 담당.

최소 구현 범위: REQ-096 WARN 로그 구현만으로 충분. 알람 파이프라인 구축은 인프라 작업(trex-core 범위 밖).

M2 일정 적합성: 코어 측 추가 작업 없음 → M2 적합.


후보 B: AMQP 이벤트 기반

개요: trex.events exchange 또는 신규 trex.alerts exchange에 알람 이벤트(예: fill_price_backfill, fill_price_unresolvable)를 publish.

wire 계약 변경 여부 분석:

현재 trex.events 토폴로지 (interface-contract.md):

  • routing key 패턴: {event_type}.{strategy_id}.{symbol}
  • 소비자: trex.events.strategy.{strategy_id} 큐가 *.{strategy_id}.# 패턴으로 바인딩
  • trex-orb-v1, trex-tsl-v1이 이 큐를 소비

알람 이벤트를 trex.events에 추가하면:

  • alert.fill_backfill.{strategy_id}.{symbol} 형태의 routing key를 사용할 경우 → 기존 *.{strategy_id}.# 바인딩에 매칭 → orb/tsl에 알람 이벤트가 전달됨. orb/tsl이 미처리 메시지를 어떻게 처리하는지 불명확.
  • 신규 trex.alerts exchange를 추가할 경우 → exchange 신설 + 큐 + 바인딩 추가 = AMQP 토폴로지 변경 = wire 계약 변경.
  • 기존 slot_filled_buy 등 이벤트 스키마에 알람 관련 필드를 추가하는 것도 payload 스키마 변경 = wire 계약 변경.

결론: 후보 B는 어떤 구현 형태를 취하더라도 wire 계약(AMQP 토폴로지 또는 payload 스키마) 변경을 수반한다.

blast radius: trex-orb-v1, trex-tsl-v1 양쪽 모두 영향. 특히 trex-tsl-v1은 M2 라이브 목표(6/28) 직전 wire 변경은 위험 수준 High.

wire 계약 변경 필요 여부: 예 (후보 B는 Out of Scope)

CLAUDE.md 교차 영향 사안 정책에 따라: 후보 B 분석에서 wire 계약 변경 필요성이 확인됨. 이 사안을 팀장에게 보고한다. 후보 B는 본 batch 범위 밖이며 구현 후보에서 제외 권고.


후보 C: 외부 푸시 직접 연동

개요: trex-core 내에서 직접 Slack webhook / Telegram API / 이메일 등 외부 채널로 알람 전송.

장점:

  • 운영자가 로그 파이프라인 없이 즉시 수신.
  • 지연 최소(수 초 이내).

단점:

  • trex-core에 외부 HTTP 의존성 추가(Slack/webhook URL 환경변수, 인증 토큰).
  • 외부 채널 장애 시 trex-core 알람 발행 실패가 체결 처리에 영향 줄 수 있음(best-effort 설계 필요).
  • 채널별 구현(Slack vs Telegram vs email)에 따라 코드 복잡도 증가.
  • 자격증명(webhook URL / API key) 관리가 운영 절차에 추가됨.
  • 알람 어댑터 포트 신규 정의 필요 + 구체 어댑터 구현 + 테스트.

최소 구현 범위: AlertPort (application 레이어 포트) + SlackWebhookAlertAdapter (adapter 레이어) + DaemonConfig에 webhook URL 추가 + execution.py에서 포트 호출. 최소 5개 파일 변경.

M2 일정 적합성: 구현 범위가 상당하고 자격증명 운영 절차가 추가됨 → M2 일정 압박 (R-3). M3 후보 더 적합.


알람 스파이크 권고 요약

후보 M2 포함 권고 이유
A: 구조화 로그 기반 권고 (코어 작업 불필요) REQ-096 WARN 구현만으로 완결, wire 계약 불변, M2 적합
B: AMQP 이벤트 기반 Out of Scope wire 계약 변경 수반, orb/tsl blast radius, M2 위험 High
C: 외부 푸시 직접 연동 이월 권고 (M3 후보) 구현 범위 크고 자격증명 관리 추가, M2 일정 압박

trex-core-dev 권고: 후보 A(구조화 로그 기반)를 기반 경로로 채택. REQ-096 WARN 로그 → Loki → 알람 파이프라인 연결은 인프라 작업으로 분리. 후보 C의 실제 구현은 M3에서 별도 Discovery로 정의.

FR-3 WARN 로깅(REQ-096)은 알람 채택 여부와 무관하게 M2 필수이며 본 batch에 포함된다.

사장님 결정 필요 (OQ-1): 알람 실제 구현(후보 A 인프라 파이프라인 구축 or 후보 C 코어 구현)의 M2 포함 여부. trex-core-dev는 이 결정을 임의로 내리지 않는다.


Edge Cases

EC-1: 완전체결 + avg_price=None + limit_price=None

  • 발생 경로: KIS가 완전체결 보고 + avg=0 + 슬롯에 limit_price가 None(드문 케이스, 예: orb가 market 주문 시)
  • 현재: fill_price=0.0 → slot.avg_price=0 영구 오염.
  • KD-2 C 채택 시: _resolve_fill_price → None 반환 → _on_filled skip → clear_dispatch 미호출 → 슬롯이 PENDING_BUY 유지. 다음 틱 재폴링 → 동일 is_filled=True → skip 반복.
  • 안전망: _check_stale_pending이 stale_threshold_sec 경과 후 WARN을 발생시키고, watchdog 만료 후 safety-sell이 개입한다.
  • 운영자 조치: trex-core cancel <slot_id> → 슬롯 롤백 → 재진입.
  • 이 케이스는 Discovery A-1(orb 매수는 지정가 주문)에 위배되는 예외 케이스이므로 저빈도라고 가정.

EC-2: 부분취소 + avg_price=None + limit_price > 0

  • _on_partial_cancel에서 _resolve_fill_price → slot.limit_price 백필 + WARN.
  • 백필 가격으로 new_avg 계산 + _record_ledger price 기록 → 정상 흐름.

EC-3: 매도 체결 + avg_price=None

  • _on_filled에서 side=PENDING_SELL 분기: 이 분기는 fill_price/avg_price를 슬롯 평단 계산에 사용하지 않는다(매도는 avg_price 보존). 따라서 매도 체결에서는 _on_filled 내 slot 전이에 백필 영향 없음.
  • 단, _record_ledger의 price(매도 체결가)는 여전히 fs.avg_price를 사용 → 여기서도 백필 헬퍼 적용 필요.
  • 매도 limit_price는 _dispatch에서 HOLDING 진입 시 None으로 클리어되므로, 매도 시 slot.limit_price=None이 정상이다. 따라서 매도 체결에서 avg_price=None이면 EC-1과 유사한 skip+강 WARN 경로를 탄다(KD-2 C 확정 시).

EC-4: happy path 회귀

  • fs.avg_price가 정상 양수이면 _resolve_fill_price는 (fs.avg_price, False) 반환 → 기존과 동일 경로. WARN 미발생, 분기 overhead 없음.

EC-5: paper 브로커에서 avg_price=None 발생 가능성

  • paper 브로커(paper.py:92,103,106)는 avg_price=o.price를 반환하며 o.price는 __init__에서 float(req.limit_price) if req.limit_price is not None else 1000.0로 항상 양수다.
  • 따라서 paper 브로커 사용 시 백필 경로 진입 없음 → paper 관련 테스트에서 WARN 미발생 단언 가능.

Architecture Impact

  • runtime-flows (체결 처리 흐름 변경 — _on_filled/_on_partial_cancel/_record_ledger에 백필 분기 추가)

Reference Doc Update Plan

  • docs/runtime-flows.md: implementation 완료 후 "Flow: 체결 처리" 섹션에 백필 분기 flowchart 추가. _resolve_fill_price 헬퍼 경로(avg_price=None/≤0 → limit_price 백필 → WARN 로그) 명시.
  • docs/operations.md: implementation 완료 후 "백필 WARN 발생 시 운영자 점검 절차" 추가. 포함 내용: WARN 로그 식별 방법(reason=avg_price_backfill or reason=fill_price_unresolvable), 수기 점검 항목(slotID·원장 확인), trex-core cancel 조치 절차.
  • docs/project-structure.md: _resolve_fill_price 헬퍼 추가는 execution.py 내 사적 메서드로 project-structure 변경 없음.
  • docs/interface-contract.md: AMQP wire 계약 불변 — 변경 없음.

Design Review 확정 항목 (팀장·사장님)

# 항목 권고 결정 권한 상태
D-1 백필 결정 지점: application 단일화 vs adapter application 권고(KD-1) 팀장 미확정
D-2 지정가 부재/≤0 최종 폴백: skip+강WARN vs 재폴링 vs 현재가 조회 skip+강WARN 권고(KD-2) 팀장 미확정
D-3 알람 구현 M2 포함 여부 후보 A(로그 기반) + 실제 파이프라인은 후속 이월 권고 사장님 미확정
D-4 (D-3 M2 포함 시) 후보 A/C 중 무엇을 코어에서 구현 후보 C는 M3, 후보 A는 인프라 작업으로 분리 권고 사장님 미확정