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
This commit is contained in:
2026-06-21 13:34:31 +09:00
co-authored by Claude Opus 4.8
parent d182464b7a
commit f1b649cf2a
15 changed files with 2134 additions and 11 deletions
+2 -1
View File
@@ -4,7 +4,7 @@
- Baseline Seed: .stage-pilot/bootstrap/baseline.yaml
- Status: draft
- Owner: project maintainers
- Last Updated (KST): 2026-06-19 (BAT-047 — dcy-031 이벤트 발행 연결 신뢰성)
- Last Updated (KST): 2026-06-21 (BAT-048 — dcy-032 체결가 0/None 백필 가드 + WARN 로깅)
- Source Discovery / Batch: bootstrap-baseline (pre-Discovery); 데몬/HTTP 흐름 = dcy-002 / BAT-002
- Primary Runtime: daemon (HTTP API) + library (slot 도메인)
@@ -248,6 +248,7 @@
- 가격/주문유형 결정(dcy-012/REQ-013, issue #10): `_dispatch` 는 side 분기 없이 슬롯 `limit_price` **한 필드**만 읽는다 — 값이 있으면 지정가(`order_type="00"`, 그 가격), 없으면 시장가(`order_type="01"`). `limit_price` 는 pending 진입 시 set 되고 pending 이탈(→holding/→closed) 시 clear 되므로, 매수 지정가(과거 `entry_price`)가 매도 주문에 재사용되지 않는다. 자율 매도(safety-sell/kill-switch)는 가격 미전달 → 시장가로 즉시 체결(미체결 `pending_sell` 갇힘 방지, issue #9 연계).
- **슬리피지 보호 지정가 + KRX 호가단위 스냅(dcy-022/REQ-070, dcy-030/REQ-083)**: 슬롯에 `slippage_pct` 가 있고 `limit_price` 도 있으면, raw 실효가 = `limit_price×(1±pct/100)` 를 KRX 호가단위(REQ-083 `domain/pricing.py`)로 **스냅**해 제출한다. 방향 = **상한 준수**: 매수 = `floor_to_tick(raw)`(slippage 상한 미초과), 매도 = `ceil_to_tick(raw)`(매도 하한 미초과). band<1tick 엣지에서 미체결을 막기 위해 매수 = `max(floor_to_tick(raw), ceil_to_tick(limit_price))`, 매도 = `min(ceil_to_tick(raw), floor_to_tick(limit_price))` 로 entry 유효 호가를 보장한다. `limit_price` 없으면(시장가) 스냅 무시. 미지정 슬롯은 기존 동작(회귀 없음). 주식 전용(ETF/ETN·2,000원 미만 비주식 미지원, dcy-030 OQ-1). 이전 구현은 `int(ceil/floor)` 무스냅이라 호가단위 위반으로 KIS 거부(issue #28) — CHG-20260616-01 에서 스냅·방향(체결 보장→상한 준수) 정정.
2. poll: `list_dispatched_pending(instance_key)`(broker_order_id NOT NULL) → `BrokerPort.poll_fill` → 체결 시 `pending_buy→holding`(avg_price/qty) / `pending_sell→inactive`(점유 초기화·자산 정체성 보존), 거부 시 롤백. 전이 후 `clear_dispatch`.
- **체결가 백필 가드(dcy-032/REQ-095/096, BAT-048)**: `_resolve_fill_price(fs, slot)` 헬퍼가 체결가를 결정. (1) `fs.avg_price`가 양수이면 그대로 사용(happy path — WARN 없음). (2) `fs.avg_price=None` 또는 `≤0`이고 `slot.limit_price>0`이면 지정가로 백필 + `reason=avg_price_backfill` **WARN 1건** 발행(슬롯 평단·원장 price에 백필값 사용, 0 저장 방지). (3) 지정가마저 None/≤0이면 체결 처리를 **skip**(슬롯 전이·원장 기록 미수행) + `reason=fill_price_unresolvable` 강 WARN 발행 — 슬롯이 PENDING 상태로 남아 stale_pending 경보가 운영자 개입을 유도. 이 가드는 `_on_filled`·`_on_partial_cancel`·`_record_ledger` 세 경로에 공통 적용된다(`reconcile()` 경로도 동일 헬퍼를 탄다).
- 외부 취소 감지(dcy-013/REQ-012/013, issue #9): 계좌에서 직접 취소된 주문을 KIS `poll_fill` 이 `cncl_yn="Y"` 로 파싱한다. **체결 0 취소** → `FillStatus(state="cancelled")` → 기존 롤백 경로(`pending_sell→holding`, `clear_dispatch`)로 자동 복구(영구 갇힘 제거). **부분 체결 후 취소**(`is_partial_cancel`) → 체결분 원장 1회 기록 + 슬롯 qty 차감(매도: 잔여 held, cost basis 유지 / 매수: 체결분만) + `holding` 복귀(OQ-1 A안, 과매도·이중기록 방지).
- pending stale 워치독(dcy-013/REQ-014 FR-3, Alert-only): `accepted`/`partially_filled`(취소 아님)로 다음 tick 대기 중인 슬롯이 `TREX_PENDING_STALE_THRESHOLD`(초, 0=비활성)를 초과해 머물면 WARN 로그 + `ExecutionResult.stale_pending` 카운터로 가시화한다. **상태는 변경하지 않는다**(auto-remediation 후속) — 취소 감지 누락(R-1) 등 미지 원인의 무한 갇힘이 silent 로 남지 않게 하는 안전망.
- **timeout 자동 취소(dcy-022/REQ-069)**: `accepted`/`partially_filled` 상태 슬롯에 `timeout_sec` 가 있고 경과 시간(`now - slot.updated_at`)이 초과하면 `broker.cancel_order` 를 best-effort 로 송신하고 `ExecutionResult.timeout_cancelled` 를 증가시킨다. **슬롯 상태는 이 시점에 변경하지 않는다** — 다음 poll 에서 `is_terminal_reject`(전량 취소) → rollback / `is_partial_cancel`(부분 체결 후 취소) → 부분 보존(기존 경로 재사용). 취소-체결 race: `cancel_order` 가 이미 체결된 주문을 받으면 브로커가 거부 → 다음 poll `is_filled` → 정상 체결 경로 수렴(임의 롤백 없음, FR-4). 기존 `timeout_sec` 없는 슬롯은 stale-alert 만 실행(REQ-014 회귀 없음).