fix(broker): dcy-013 KIS 외부 취소 감지 + 부분 체결 회계 + stale 워치독 (BAT-017)
KIS 계좌에서 직접 매도 주문을 취소하면 poll_fill 이 cncl_yn 을 파싱하지 않아 슬롯이 pending_sell 에 영구히 갇히고 죽은 주문을 무한 폴링하던 버그(issue #9) 정정. 거버넌스: 전부 change-req (신규 REQ 없음, dcy-013 OQ-3). - REQ-012: KIS poll_fill cncl_yn 파싱 → 외부 취소 감지. FillStatus.cancelled /is_partial_cancel 추가. 체결 0 취소→cancelled, 부분 체결 후 취소→partial+cancelled - REQ-013: 집행 _on_partial_cancel — 체결분 원장 1회 + qty 차감(매도 잔여/매수 체결분) + holding 복귀 (OQ-1 A안). 전량 취소는 기존 롤백 경로 재사용 - REQ-018: 부분 체결분 원장 1회 기록 (_record_ledger 재사용) - REQ-031: 잔여 포지션 정합 연계 (slot.qty ↔ 실 잔고, 과매도 방지) - REQ-014: pending stale 워치독 Alert-only (TREX_PENDING_STALE_THRESHOLD, WARN+카운터, 상태 미변경) — 취소 감지 누락 backstop - REQ-015: interface-contract/runtime-flows 정합 전량 취소는 holding 자동 복구(limit_price clear→재매도 시장가, dcy-012 연계). KIS cncl_yn 실 규약은 운영 검증(미지 값 보수 처리 + 워치독 backstop). 검증: 단위 294 passed/32 skipped, 인프라 게이트(PG+RabbitMQ) 326 passed, ruff clean. 신규 test_kis_cancel(httpx stub) + test_execution_cancel(programmable broker). BAT-017 release-candidate. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -188,7 +188,15 @@ class KISBroker:
|
||||
ord_qty = _safe_int(row.get("ord_qty")) or 0
|
||||
ccld_qty = _safe_int(row.get("tot_ccld_qty")) or 0
|
||||
avg = _safe_int(row.get("avg_prvs")) or _safe_int(row.get("ccld_prvs"))
|
||||
# dcy-013 / REQ-012 (issue #9): external cancel detection. KIS
|
||||
# inquire-daily-ccld marks a cancelled order with cncl_yn="Y". Without
|
||||
# this, a zero-fill cancel looked like "accepted" and the slot was stuck
|
||||
# in pending_sell forever. Unknown/absent values are treated as not
|
||||
# cancelled (conservative — the stale watchdog is the backstop, R-1).
|
||||
cancelled = str(row.get("cncl_yn") or "").strip().upper() == "Y"
|
||||
if ccld_qty <= 0:
|
||||
if cancelled:
|
||||
return FillStatus(state="cancelled", raw=row) # full external cancel
|
||||
return FillStatus(state="accepted", raw=row)
|
||||
if ccld_qty >= ord_qty and ord_qty > 0:
|
||||
# Deterministic execution_id (dcy-004 OQ-3, orbit kis.py rule):
|
||||
@@ -204,6 +212,24 @@ class KISBroker:
|
||||
avg_price=fill_price or None,
|
||||
execution_id=execution_id, fee=fee, tax=0.0, raw=row,
|
||||
)
|
||||
# 0 < ccld_qty < ord_qty
|
||||
if cancelled:
|
||||
# Partial fill then external cancel (dcy-013 FR-2): carry a
|
||||
# deterministic execution_id + fee so the filled portion is recorded
|
||||
# once, and flag cancelled so execution returns the remainder to
|
||||
# holding instead of waiting for a fill that will never come.
|
||||
ord_dt = str(row.get("ord_dt") or today)
|
||||
ord_gno_brno = str(row.get("ord_gno_brno") or "")
|
||||
row_odno = str(row.get("odno") or odno)
|
||||
execution_id = f"kis-daily-ccld:{ord_dt}:{ord_gno_brno}:{row_odno}:{ccld_qty}"
|
||||
fill_price = float(avg) if avg else 0.0
|
||||
fee = calculate_fee(side, ccld_qty * fill_price)
|
||||
return FillStatus(
|
||||
state="partially_filled", filled_qty=ccld_qty,
|
||||
avg_price=fill_price or None,
|
||||
execution_id=execution_id, fee=fee, tax=0.0,
|
||||
cancelled=True, raw=row,
|
||||
)
|
||||
return FillStatus(state="partially_filled", filled_qty=ccld_qty,
|
||||
avg_price=float(avg) if avg else None, raw=row)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user