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
+71 -7
View File
@@ -228,8 +228,51 @@ class ExecutionService:
"""Current broker balance as {symbol: hldg_qty} (REQ-062 ground truth)."""
return {b.symbol: int(b.hldg_qty) for b in self._broker.get_balance()}
def _resolve_fill_price(self, fs, slot) -> tuple[float | None, bool]:
"""Resolve the fill price from FillStatus, with limit_price backfill.
REQ-095 / REQ-096: KIS may return avg_price=None or 0.0 on a snapshot
race. Rather than silently storing 0 we try the order's limit_price as
a reasonable approximation, and surface the substitution via WARN so
ops can audit the cost-basis estimate.
Returns:
(fill_price, was_backfilled)
fill_price=None → skip signal: no resolvable price (strong WARN
already emitted). Caller must not transition slot
or write ledger — slot stays PENDING so the
watchdog / stale_pending path fires for ops.
"""
avg = fs.avg_price
if avg is not None and float(avg) > 0:
return float(avg), False
# avg_price None or ≤0: attempt limit_price backfill (REQ-095 AC-1/2)
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
# limit_price also absent/≤0: skip + strong WARN (REQ-095 AC-6 / REQ-096 AC-2)
# Slot stays PENDING — watchdog/stale_pending fires for operator attention.
_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
def _on_filled(self, slot, fs, side: str, r: ExecutionResult) -> None:
broker_order_id = slot.broker_order_id # captured BEFORE clear_dispatch
# REQ-095: resolve fill price with limit_price backfill guard.
fill_price, _was_backfilled = self._resolve_fill_price(fs, slot)
if fill_price is None:
return # skip: unresolvable price — WARN already emitted, slot stays PENDING
try:
if slot.status is SlotState.PENDING_BUY:
# dcy-021/REQ-066: held qty = broker balance (authority, includes
@@ -240,7 +283,6 @@ class ExecutionService:
new_qty = float(total) if total > 0 else filled
old_qty = max(0.0, new_qty - filled)
old_avg = float(slot.avg_price) if slot.avg_price else 0.0
fill_price = float(fs.avg_price) if fs.avg_price is not None else old_avg
new_avg = (
(old_qty * old_avg + filled * fill_price) / new_qty
if new_qty > 0 else fill_price
@@ -278,7 +320,7 @@ class ExecutionService:
)
# REQ-018: enqueue ledger work item AFTER the slot transition is durable.
# Slot transition is the critical path; ledger write is async (REQ-019).
self._record_ledger(slot, fs, side, broker_order_id, r)
self._record_ledger(slot, fs, side, broker_order_id, r, resolved_price=fill_price)
# REQ-021/023: emit event AFTER transition + ledger enqueue (single
# record subject). Best-effort: a publish failure must not roll back.
self._emit_event(event_type, updated)
@@ -294,6 +336,10 @@ class ExecutionService:
portion that actually filled.
"""
broker_order_id = slot.broker_order_id # captured BEFORE clear_dispatch
# REQ-095: resolve fill price with limit_price backfill guard.
fill_price, _was_backfilled = self._resolve_fill_price(fs, slot)
if fill_price is None:
return # skip: unresolvable price — WARN already emitted, slot stays PENDING
filled = float(fs.filled_qty)
try:
if slot.status is SlotState.PENDING_BUY:
@@ -303,7 +349,6 @@ class ExecutionService:
new_qty = float(total) if total > 0 else filled
old_qty = max(0.0, new_qty - filled)
old_avg = float(slot.avg_price) if slot.avg_price else 0.0
fill_price = float(fs.avg_price) if fs.avg_price is not None else old_avg
new_avg = (
(old_qty * old_avg + filled * fill_price) / new_qty
if new_qty > 0 else fill_price
@@ -330,7 +375,7 @@ class ExecutionService:
event_type = "slot_filled_sell"
except SlotStateMismatchError:
return # slot moved concurrently; next tick reconciles
self._record_ledger(slot, fs, side, broker_order_id, r)
self._record_ledger(slot, fs, side, broker_order_id, r, resolved_price=fill_price)
self._emit_event(event_type, updated)
self._repo.clear_dispatch(slot.slot_id)
@@ -398,8 +443,23 @@ class ExecutionService:
except Exception: # noqa: BLE001 - event publish failure is non-critical
return
def _record_ledger(self, slot, fs, side: str, broker_order_id: str, r: ExecutionResult) -> None:
"""Append a LedgerWorkItem for the just-filled trade (REQ-018 + REQ-019)."""
def _record_ledger(
self,
slot,
fs,
side: str,
broker_order_id: str,
r: ExecutionResult,
*,
resolved_price: float | None = None,
) -> None:
"""Append a LedgerWorkItem for the just-filled trade (REQ-018 + REQ-019).
resolved_price: pre-computed fill price from _resolve_fill_price (REQ-095 KD-3).
When provided, it takes priority over fs.avg_price so that a backfilled
limit_price is consistently used in both the slot avg and the ledger record.
Defaults to None for backward compatibility (falls back to fs.avg_price).
"""
if self._ledger_outbox is None:
return
if not slot.portfolio_id:
@@ -421,7 +481,11 @@ class ExecutionService:
slot.slot_id, slot.portfolio_id, side,
)
return
price = float(fs.avg_price) if fs.avg_price is not None else 0.0
# REQ-095 KD-3: use pre-resolved price when available; fall back to
# fs.avg_price for any direct callers that do not pass resolved_price.
price = resolved_price if resolved_price is not None else (
float(fs.avg_price) if fs.avg_price is not None else 0.0
)
trade = LedgerTrade(
asset_id=str(slot.portfolio_id),
order_id=str(broker_order_id),