From d6c2e2d28a5cd92f17f837613611919cf497a6c2 Mon Sep 17 00:00:00 2001 From: jordan Date: Tue, 28 Jul 2026 13:29:43 +0900 Subject: [PATCH] fix: reconcile KIS exchange rejections --- docs/interface-contract.md | 2 +- docs/operations.md | 1 + tests/adapters/broker/test_kis_cancel.py | 37 ++++++++++++-- .../test_kis_submission_recovery_virtual.py | 49 ++++++++++++++++++- trex_core/adapters/broker/kis.py | 7 +++ 5 files changed, 89 insertions(+), 7 deletions(-) diff --git a/docs/interface-contract.md b/docs/interface-contract.md index 735463b..fc6242e 100644 --- a/docs/interface-contract.md +++ b/docs/interface-contract.md @@ -461,7 +461,7 @@ get_current_price(symbol: str, market: str = "J") -> Optional[Decimal] - `filled` :: 전량 체결 → `pending_buy→holding` / `pending_sell→inactive` + 원장 1건. - `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`) 유지. -- 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) diff --git a/docs/operations.md b/docs/operations.md index 0ab5cf5..7cdcd2d 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -219,6 +219,7 @@ Loki LogQL: | 매도 미발생 + WARN `sell dispatch skipped` | 브로커 잔고 0(divergence) | reconcile(운영자) — 미러 fallback 없음(설계) | | `RpcTimeoutError` | 데몬 미기동/소비 스레드 크래시 | `/tmp/trex-core.log` traceback 확인 | | 미체결 `pending_*` 고착 | 취소 감지 누락/timeout 미설정/fill_price_unresolvable | `trex-core cancel ` 백스톱; 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 점검 절차 참고 | ### DB 직접 조회 diff --git a/tests/adapters/broker/test_kis_cancel.py b/tests/adapters/broker/test_kis_cancel.py index a08eba6..16bb3ea 100644 --- a/tests/adapters/broker/test_kis_cancel.py +++ b/tests/adapters/broker/test_kis_cancel.py @@ -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 -the HTTP layer (token pre-seeded, httpx.get faked) and assert the FillStatus -mapping for full cancel / partial-fill-then-cancel / not-cancelled. +inquire-daily-ccld marks a cancelled order with cncl_yn="Y" and an +exchange-rejected order with rjct_qty. These tests stub the HTTP layer (token +pre-seeded, httpx.get faked) and assert the FillStatus mapping. """ from __future__ import annotations @@ -49,6 +49,7 @@ def _row(**kw): "tot_ccld_qty": "0", "avg_prvs": "0", "cncl_yn": "N", + "rjct_qty": "0", "ord_dt": "20260608", "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 +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): monkeypatch.setattr( kis_mod.httpx, diff --git a/tests/integration/test_kis_submission_recovery_virtual.py b/tests/integration/test_kis_submission_recovery_virtual.py index ab3526c..9b222ee 100644 --- a/tests/integration/test_kis_submission_recovery_virtual.py +++ b/tests/integration/test_kis_submission_recovery_virtual.py @@ -28,6 +28,7 @@ class _ExchangeState: self.submit_attempts = 0 self.orders: list[dict[str, str]] = [] self.fill_orders = False + self.reject_orders = False def submit(self, body: dict[str, str]) -> tuple[bool, dict[str, object]]: self.submit_attempts += 1 @@ -41,6 +42,7 @@ class _ExchangeState: "ord_gno_brno": "91258", "odno": f"{self.submit_attempts:010d}", "tot_ccld_qty": "0", + "rjct_qty": "0", "avg_prvs": "0", "cncl_yn": "N", } @@ -58,15 +60,22 @@ class _ExchangeState: def daily_orders(self) -> dict[str, object]: rows = [] 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) - if self.fill_orders: + if self.fill_orders and order["rjct_qty"] == "0": row["tot_ccld_qty"] = row["ord_qty"] row["avg_prvs"] = row["ord_unpr"] rows.append(row) return {"rt_cd": "0", "output1": rows} 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 [] return {"rt_cd": "0", "output1": rows} @@ -204,3 +213,39 @@ def test_timeout_before_exchange_accept_retries_only_after_confirmed_absence(mon finally: server.shutdown() 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() diff --git a/trex_core/adapters/broker/kis.py b/trex_core/adapters/broker/kis.py index cccc85f..e251184 100644 --- a/trex_core/adapters/broker/kis.py +++ b/trex_core/adapters/broker/kis.py @@ -496,6 +496,7 @@ class KISBroker: ) -> FillStatus: ord_qty = _safe_int(row.get("ord_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")) # dcy-013 / REQ-012 (issue #9): external cancel detection. KIS # 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 = str(row.get("cncl_yn") or "").strip().upper() == "Y" 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: return FillStatus(state="cancelled", raw=row) # full external cancel return FillStatus(state="accepted", raw=row)