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:
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user