fix: reconcile KIS exchange rejections
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user