fix: reconcile KIS exchange rejections
This commit is contained in:
@@ -461,7 +461,7 @@ get_current_price(symbol: str, market: str = "J") -> Optional[Decimal]
|
|||||||
- `filled` :: 전량 체결 → `pending_buy→holding` / `pending_sell→inactive` + 원장 1건.
|
- `filled` :: 전량 체결 → `pending_buy→holding` / `pending_sell→inactive` + 원장 1건.
|
||||||
- `cancelled`(체결 0 외부 취소) :: `is_terminal_reject` → 롤백(`pending_sell→holding` / `pending_buy→inactive`), `clear_dispatch` → 자동 재매도 가능.
|
- `cancelled`(체결 0 외부 취소) :: `is_terminal_reject` → 롤백(`pending_sell→holding` / `pending_buy→inactive`), `clear_dispatch` → 자동 재매도 가능.
|
||||||
- `partially_filled` + `cancelled=True`(부분 체결 후 외부 취소) :: `is_partial_cancel` → 체결분 원장 1회 기록 + 슬롯 qty 차감(매도: 잔여 held / 매수: 체결분만) + `holding` 복귀. 매도는 cost basis(`avg_price`) 유지.
|
- `partially_filled` + `cancelled=True`(부분 체결 후 외부 취소) :: `is_partial_cancel` → 체결분 원장 1회 기록 + 슬롯 qty 차감(매도: 잔여 held / 매수: 체결분만) + `holding` 복귀. 매도는 cost basis(`avg_price`) 유지.
|
||||||
- KIS 매핑: `inquire-daily-ccld` 응답 row 의 `cncl_yn="Y"` 를 취소로 해석한다(미지/부재 값은 보수적으로 비취소 처리, stale 워치독이 backstop).
|
- KIS 매핑: `inquire-daily-ccld` 응답 row 의 `cncl_yn="Y"` 를 취소로 해석한다(미지/부재 값은 보수적으로 비취소 처리, stale 워치독이 backstop). 체결수량이 0이고 `rjct_qty >= ord_qty > 0`이면 브로커 접수 뒤 거래소 전량거부로 해석해 `rejected`로 종료한다. `rjct_qty` 일부만으로는 미체결 잔여 상태를 추정하지 않는다.
|
||||||
|
|
||||||
## Interface: `MessagingTransport` (Protocol, BAT-007)
|
## Interface: `MessagingTransport` (Protocol, BAT-007)
|
||||||
|
|
||||||
|
|||||||
@@ -219,6 +219,7 @@ Loki LogQL:
|
|||||||
| 매도 미발생 + WARN `sell dispatch skipped` | 브로커 잔고 0(divergence) | reconcile(운영자) — 미러 fallback 없음(설계) |
|
| 매도 미발생 + WARN `sell dispatch skipped` | 브로커 잔고 0(divergence) | reconcile(운영자) — 미러 fallback 없음(설계) |
|
||||||
| `RpcTimeoutError` | 데몬 미기동/소비 스레드 크래시 | `/tmp/trex-core.log` traceback 확인 |
|
| `RpcTimeoutError` | 데몬 미기동/소비 스레드 크래시 | `/tmp/trex-core.log` traceback 확인 |
|
||||||
| 미체결 `pending_*` 고착 | 취소 감지 누락/timeout 미설정/fill_price_unresolvable | `trex-core cancel <slot_id>` 백스톱; pending stale WARN + `fill_price_unresolvable` WARN 확인(§5) |
|
| 미체결 `pending_*` 고착 | 취소 감지 누락/timeout 미설정/fill_price_unresolvable | `trex-core cancel <slot_id>` 백스톱; pending stale WARN + `fill_price_unresolvable` WARN 확인(§5) |
|
||||||
|
| KIS 접수 뒤 전량 거부인데 `pending_buy` 유지 | 일별 주문조회에서 `tot_ccld_qty=0`, `rjct_qty=ord_qty` 확인 | Core가 `rejected`로 롤백하는지 `event=order_reject`와 슬롯 `inactive`를 확인. 재주문 전 브로커 잔고·미체결을 먼저 대조 |
|
||||||
| `avg_price_backfill` WARN | KIS avg_price 순간 레이스(None/0 반환) | 슬롯 평단·원장 price는 지정가로 보정됨 — §5 점검 절차 참고 |
|
| `avg_price_backfill` WARN | KIS avg_price 순간 레이스(None/0 반환) | 슬롯 평단·원장 price는 지정가로 보정됨 — §5 점검 절차 참고 |
|
||||||
|
|
||||||
### DB 직접 조회
|
### DB 직접 조회
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"""KIS poll_fill external-cancel detection (dcy-013 / REQ-012, issue #9).
|
"""KIS poll_fill terminal-order detection (dcy-013 / REQ-012, issue #9).
|
||||||
|
|
||||||
inquire-daily-ccld marks a cancelled order with cncl_yn="Y". These tests stub
|
inquire-daily-ccld marks a cancelled order with cncl_yn="Y" and an
|
||||||
the HTTP layer (token pre-seeded, httpx.get faked) and assert the FillStatus
|
exchange-rejected order with rjct_qty. These tests stub the HTTP layer (token
|
||||||
mapping for full cancel / partial-fill-then-cancel / not-cancelled.
|
pre-seeded, httpx.get faked) and assert the FillStatus mapping.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -49,6 +49,7 @@ def _row(**kw):
|
|||||||
"tot_ccld_qty": "0",
|
"tot_ccld_qty": "0",
|
||||||
"avg_prvs": "0",
|
"avg_prvs": "0",
|
||||||
"cncl_yn": "N",
|
"cncl_yn": "N",
|
||||||
|
"rjct_qty": "0",
|
||||||
"ord_dt": "20260608",
|
"ord_dt": "20260608",
|
||||||
"ord_gno_brno": "06010",
|
"ord_gno_brno": "06010",
|
||||||
}
|
}
|
||||||
@@ -68,6 +69,34 @@ def test_zero_fill_not_cancelled_stays_accepted(monkeypatch):
|
|||||||
assert fs.state == "accepted" and not fs.is_terminal_reject
|
assert fs.state == "accepted" and not fs.is_terminal_reject
|
||||||
|
|
||||||
|
|
||||||
|
def test_zero_fill_full_rejection_maps_to_rejected(monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
kis_mod.httpx,
|
||||||
|
"get",
|
||||||
|
_fake_get(
|
||||||
|
_row(
|
||||||
|
ord_qty="4",
|
||||||
|
tot_ccld_qty="0",
|
||||||
|
rmn_qty="0",
|
||||||
|
rjct_qty="4",
|
||||||
|
cncl_yn="",
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
fs = _broker().poll_fill(f"06010:{ODNO}", side="buy", symbol="005930")
|
||||||
|
assert fs.state == "rejected" and fs.is_terminal_reject
|
||||||
|
|
||||||
|
|
||||||
|
def test_zero_fill_partial_rejection_does_not_infer_terminal(monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
kis_mod.httpx,
|
||||||
|
"get",
|
||||||
|
_fake_get(_row(ord_qty="10", tot_ccld_qty="0", rjct_qty="4", cncl_yn="")),
|
||||||
|
)
|
||||||
|
fs = _broker().poll_fill(f"06010:{ODNO}", side="buy", symbol="005930")
|
||||||
|
assert fs.state == "accepted" and not fs.is_terminal_reject
|
||||||
|
|
||||||
|
|
||||||
def test_partial_fill_then_cancel_flags_partial_cancel(monkeypatch):
|
def test_partial_fill_then_cancel_flags_partial_cancel(monkeypatch):
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
kis_mod.httpx,
|
kis_mod.httpx,
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ class _ExchangeState:
|
|||||||
self.submit_attempts = 0
|
self.submit_attempts = 0
|
||||||
self.orders: list[dict[str, str]] = []
|
self.orders: list[dict[str, str]] = []
|
||||||
self.fill_orders = False
|
self.fill_orders = False
|
||||||
|
self.reject_orders = False
|
||||||
|
|
||||||
def submit(self, body: dict[str, str]) -> tuple[bool, dict[str, object]]:
|
def submit(self, body: dict[str, str]) -> tuple[bool, dict[str, object]]:
|
||||||
self.submit_attempts += 1
|
self.submit_attempts += 1
|
||||||
@@ -41,6 +42,7 @@ class _ExchangeState:
|
|||||||
"ord_gno_brno": "91258",
|
"ord_gno_brno": "91258",
|
||||||
"odno": f"{self.submit_attempts:010d}",
|
"odno": f"{self.submit_attempts:010d}",
|
||||||
"tot_ccld_qty": "0",
|
"tot_ccld_qty": "0",
|
||||||
|
"rjct_qty": "0",
|
||||||
"avg_prvs": "0",
|
"avg_prvs": "0",
|
||||||
"cncl_yn": "N",
|
"cncl_yn": "N",
|
||||||
}
|
}
|
||||||
@@ -58,15 +60,22 @@ class _ExchangeState:
|
|||||||
def daily_orders(self) -> dict[str, object]:
|
def daily_orders(self) -> dict[str, object]:
|
||||||
rows = []
|
rows = []
|
||||||
for order in self.orders:
|
for order in self.orders:
|
||||||
|
if self.reject_orders and order["rjct_qty"] == "0":
|
||||||
|
order["rjct_qty"] = order["ord_qty"]
|
||||||
|
order["rmn_qty"] = "0"
|
||||||
row = dict(order)
|
row = dict(order)
|
||||||
if self.fill_orders:
|
if self.fill_orders and order["rjct_qty"] == "0":
|
||||||
row["tot_ccld_qty"] = row["ord_qty"]
|
row["tot_ccld_qty"] = row["ord_qty"]
|
||||||
row["avg_prvs"] = row["ord_unpr"]
|
row["avg_prvs"] = row["ord_unpr"]
|
||||||
rows.append(row)
|
rows.append(row)
|
||||||
return {"rt_cd": "0", "output1": rows}
|
return {"rt_cd": "0", "output1": rows}
|
||||||
|
|
||||||
def balance(self) -> dict[str, object]:
|
def balance(self) -> dict[str, object]:
|
||||||
quantity = sum(int(order["ord_qty"]) for order in self.orders) if self.fill_orders else 0
|
quantity = (
|
||||||
|
sum(int(order["ord_qty"]) for order in self.orders if order["rjct_qty"] == "0")
|
||||||
|
if self.fill_orders
|
||||||
|
else 0
|
||||||
|
)
|
||||||
rows = [{"pdno": SYMBOL, "hldg_qty": str(quantity)}] if quantity else []
|
rows = [{"pdno": SYMBOL, "hldg_qty": str(quantity)}] if quantity else []
|
||||||
return {"rt_cd": "0", "output1": rows}
|
return {"rt_cd": "0", "output1": rows}
|
||||||
|
|
||||||
@@ -204,3 +213,39 @@ def test_timeout_before_exchange_accept_retries_only_after_confirmed_absence(mon
|
|||||||
finally:
|
finally:
|
||||||
server.shutdown()
|
server.shutdown()
|
||||||
server.server_close()
|
server.server_close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_exchange_reject_after_broker_ack_rolls_back_and_allows_one_retry():
|
||||||
|
state = _ExchangeState(first_attempt_reaches_exchange=True)
|
||||||
|
state.reject_orders = True
|
||||||
|
server = _serve(state)
|
||||||
|
try:
|
||||||
|
repo, _slots, intents, execution, slot_id = _wire(_broker(server))
|
||||||
|
|
||||||
|
execution.run_once()
|
||||||
|
rejected = repo.fetch_slot(slot_id)
|
||||||
|
assert rejected.status is SlotState.INACTIVE
|
||||||
|
assert rejected.broker_order_id is None
|
||||||
|
assert state.submit_attempts == 1
|
||||||
|
|
||||||
|
state.reject_orders = False
|
||||||
|
state.fill_orders = True
|
||||||
|
retry = intents.submit_buy(
|
||||||
|
BuyIntent(
|
||||||
|
intent_id="virtual-buy-after-reject",
|
||||||
|
portfolio_id=PORTFOLIO,
|
||||||
|
qty=2,
|
||||||
|
entry_price=238_500,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert retry.status == "admitted"
|
||||||
|
execution.run_once()
|
||||||
|
|
||||||
|
holding = repo.fetch_slot(slot_id)
|
||||||
|
assert holding.status is SlotState.HOLDING
|
||||||
|
assert holding.qty == 2
|
||||||
|
assert state.submit_attempts == 2
|
||||||
|
assert len(state.orders) == 2
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
server.server_close()
|
||||||
|
|||||||
@@ -496,6 +496,7 @@ class KISBroker:
|
|||||||
) -> FillStatus:
|
) -> FillStatus:
|
||||||
ord_qty = _safe_int(row.get("ord_qty")) or 0
|
ord_qty = _safe_int(row.get("ord_qty")) or 0
|
||||||
ccld_qty = _safe_int(row.get("tot_ccld_qty")) or 0
|
ccld_qty = _safe_int(row.get("tot_ccld_qty")) or 0
|
||||||
|
rejected_qty = _safe_int(row.get("rjct_qty")) or 0
|
||||||
avg = _safe_int(row.get("avg_prvs")) or _safe_int(row.get("ccld_prvs"))
|
avg = _safe_int(row.get("avg_prvs")) or _safe_int(row.get("ccld_prvs"))
|
||||||
# dcy-013 / REQ-012 (issue #9): external cancel detection. KIS
|
# dcy-013 / REQ-012 (issue #9): external cancel detection. KIS
|
||||||
# inquire-daily-ccld marks a cancelled order with cncl_yn="Y". Without
|
# inquire-daily-ccld marks a cancelled order with cncl_yn="Y". Without
|
||||||
@@ -504,6 +505,12 @@ class KISBroker:
|
|||||||
# cancelled (conservative — the stale watchdog is the backstop, R-1).
|
# cancelled (conservative — the stale watchdog is the backstop, R-1).
|
||||||
cancelled = str(row.get("cncl_yn") or "").strip().upper() == "Y"
|
cancelled = str(row.get("cncl_yn") or "").strip().upper() == "Y"
|
||||||
if ccld_qty <= 0:
|
if ccld_qty <= 0:
|
||||||
|
# KIS can acknowledge an order submission and later report the
|
||||||
|
# whole quantity as exchange-rejected without setting cncl_yn.
|
||||||
|
# Treating that row as accepted leaves the slot pending forever
|
||||||
|
# and suppresses the strategy's next valid signal.
|
||||||
|
if rejected_qty > 0 and (ord_qty <= 0 or rejected_qty >= ord_qty):
|
||||||
|
return FillStatus(state="rejected", raw=row)
|
||||||
if cancelled:
|
if cancelled:
|
||||||
return FillStatus(state="cancelled", raw=row) # full external cancel
|
return FillStatus(state="cancelled", raw=row) # full external cancel
|
||||||
return FillStatus(state="accepted", raw=row)
|
return FillStatus(state="accepted", raw=row)
|
||||||
|
|||||||
Reference in New Issue
Block a user