fix: reconcile ambiguous KIS order submissions

This commit is contained in:
jordan
2026-07-28 11:17:06 +09:00
parent b72bf9f394
commit 24c42d1c92
11 changed files with 1420 additions and 191 deletions
+338 -37
View File
@@ -8,10 +8,15 @@ Endpoints: /oauth2/tokenP (token), /uapi/domestic-stock/v1/trading/order-cash
(submit), /uapi/domestic-stock/v1/trading/inquire-daily-ccld (fill poll),
/uapi/domestic-stock/v1/trading/order-rvsecncl (cancel).
"""
from __future__ import annotations
import base64
import json
from dataclasses import replace
from datetime import datetime, timedelta, timezone
from decimal import Decimal, InvalidOperation
from time import monotonic
from typing import Optional
import httpx
@@ -27,7 +32,11 @@ from trex_core.application.broker_port import (
)
_KST = timezone(timedelta(hours=9))
_SUBMISSION_UNKNOWN_PREFIX = "submission-unknown:"
_MOCK_HINTS = ("openapivts", "virtual", "mock")
_FILL_POLL_MIN_INTERVAL_SECONDS = 5.0
_FILL_POLL_TRANSPORT_BACKOFF_SECONDS = 30.0
_SUBMISSION_LOOKUP_GRACE_SECONDS = 90.0
def _safe_int(v: object) -> Optional[int]:
@@ -37,6 +46,55 @@ def _safe_int(v: object) -> Optional[int]:
return None
def _encode_submission_unknown(req: OrderRequest, submitted_at: datetime) -> str:
payload = {
"v": 1,
"slot_id": req.slot_id,
"side": req.side,
"symbol": req.symbol,
"qty": req.qty,
"limit_price": req.limit_price,
"venue": req.venue,
"submitted_at": submitted_at.astimezone(_KST).isoformat(),
}
raw = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode()
encoded = base64.urlsafe_b64encode(raw).decode().rstrip("=")
return f"{_SUBMISSION_UNKNOWN_PREFIX}{encoded}"
def _decode_submission_unknown(broker_order_id: str) -> Optional[dict]:
if not broker_order_id.startswith(_SUBMISSION_UNKNOWN_PREFIX):
return None
encoded = broker_order_id[len(_SUBMISSION_UNKNOWN_PREFIX) :]
try:
raw = base64.urlsafe_b64decode(encoded + "=" * (-len(encoded) % 4))
payload = json.loads(raw)
except (ValueError, UnicodeDecodeError, json.JSONDecodeError):
return None
required = {
"v",
"slot_id",
"side",
"symbol",
"qty",
"venue",
"submitted_at",
}
if (
not isinstance(payload, dict)
or payload.get("v") != 1
or not required.issubset(payload)
or payload.get("side") not in {"buy", "sell"}
):
return None
try:
datetime.fromisoformat(str(payload["submitted_at"]))
int(payload["qty"])
except (TypeError, ValueError):
return None
return payload
class KISBroker:
"""KIS REST broker adapter. Live account is gated behind `allow_live`."""
@@ -59,6 +117,7 @@ class KISBroker:
self._token: Optional[str] = None
self._token_exp: datetime = datetime.now(tz=timezone.utc)
self._token_cache = token_cache # optional TokenCache (L2, REQ-016)
self._fill_poll_not_before: dict[str, float] = {}
is_mock = any(h in self._base_url for h in _MOCK_HINTS)
if not is_mock and not allow_live:
raise RuntimeError(
@@ -167,6 +226,7 @@ class KISBroker:
# --- BrokerPort ---
def submit_order(self, req: OrderRequest) -> OrderAck:
cano, product = self._account()
submitted_at = datetime.now(tz=_KST)
body = {
"CANO": cano,
"ACNT_PRDT_CD": product,
@@ -188,18 +248,44 @@ class KISBroker:
)
payload = resp.json()
except (httpx.HTTPError, ValueError) as exc:
return OrderAck(accepted=False, reason=f"submit_network_error:{type(exc).__name__}",
raw={"error": str(exc)})
# A transport failure after POST does not prove that KIS rejected
# the order. Keep the slot dispatched under a local sentinel so
# the execution loop cannot submit the same order again.
return OrderAck(
accepted=True,
broker_order_id=_encode_submission_unknown(req, submitted_at),
reason=f"submit_outcome_unknown:{type(exc).__name__}",
raw={"error_class": type(exc).__name__},
)
if getattr(resp, "status_code", 200) >= 500:
return OrderAck(
accepted=True,
broker_order_id=_encode_submission_unknown(req, submitted_at),
reason=f"submit_outcome_unknown:http_{resp.status_code}",
raw={"http_status": resp.status_code},
)
if str(payload.get("rt_cd")) == "0":
out = payload.get("output") or {}
odno = out.get("ODNO") or out.get("odno")
orgno = out.get("KRX_FWDG_ORD_ORGNO") or out.get("ord_gno_brno") or ""
bid = f"{orgno}:{odno}" if orgno else str(odno)
return OrderAck(accepted=True, broker_order_id=bid, raw=payload)
return OrderAck(accepted=False, reason=payload.get("msg1") or payload.get("msg_cd"),
raw=payload)
return OrderAck(
accepted=False, reason=payload.get("msg1") or payload.get("msg_cd"), raw=payload
)
def poll_fill(self, broker_order_id: str, *, side: str, symbol: str) -> FillStatus:
if broker_order_id.startswith(_SUBMISSION_UNKNOWN_PREFIX):
return self._poll_unknown_submission(broker_order_id, side=side, symbol=symbol)
now = monotonic()
if now < self._fill_poll_not_before.get(broker_order_id, 0.0):
return FillStatus(
state="accepted",
raw={"reason": "fill_poll_backoff"},
)
# Set the normal gate before I/O so concurrent execution ticks cannot
# open duplicate KIS inquiries for the same order.
self._fill_poll_not_before[broker_order_id] = now + _FILL_POLL_MIN_INTERVAL_SECONDS
cano, product = self._account()
odno = broker_order_id.split(":")[-1]
today = datetime.now(tz=_KST).strftime("%Y%m%d")
@@ -208,25 +294,206 @@ class KISBroker:
# 향후 NXT 체결 미포함 실측 확인 시 TTTC0081R(EXCG=ALL) 전환 별도 batch 처리.
tr_id = "VTTC8001R" if self._mock else "TTTC8001R"
params = {
"CANO": cano, "ACNT_PRDT_CD": product,
"INQR_STRT_DT": today, "INQR_END_DT": today,
"SLL_BUY_DVSN_CD": "00", "INQR_DVSN": "00", "PDNO": "",
"CCLD_DVSN": "00", "ORD_GNO_BRNO": "", "ODNO": odno,
"INQR_DVSN_3": "00", "INQR_DVSN_1": "",
"CTX_AREA_FK100": "", "CTX_AREA_NK100": "",
"CANO": cano,
"ACNT_PRDT_CD": product,
"INQR_STRT_DT": today,
"INQR_END_DT": today,
"SLL_BUY_DVSN_CD": "00",
"INQR_DVSN": "00",
"PDNO": "",
"CCLD_DVSN": "00",
"ORD_GNO_BRNO": "",
"ODNO": odno,
"INQR_DVSN_3": "00",
"INQR_DVSN_1": "",
"CTX_AREA_FK100": "",
"CTX_AREA_NK100": "",
}
try:
resp = httpx.get(
f"{self._base_url}/uapi/domestic-stock/v1/trading/inquire-daily-ccld",
headers=self._headers(tr_id), params=params, timeout=self._timeout,
headers=self._headers(tr_id),
params=params,
timeout=self._timeout,
)
payload = resp.json()
except (httpx.HTTPError, ValueError) as exc:
self._fill_poll_not_before[broker_order_id] = (
monotonic() + _FILL_POLL_TRANSPORT_BACKOFF_SECONDS
)
return FillStatus(state="unknown", raw={"error": str(exc)})
if getattr(resp, "status_code", 200) >= 400 or str(payload.get("rt_cd")) != "0":
self._fill_poll_not_before[broker_order_id] = (
monotonic() + _FILL_POLL_TRANSPORT_BACKOFF_SECONDS
)
return FillStatus(
state="unknown",
raw={"reason": "fill_inquiry_rejected"},
)
self._fill_poll_not_before[broker_order_id] = monotonic() + _FILL_POLL_MIN_INTERVAL_SECONDS
rows = payload.get("output1") or []
row = next((r for r in rows if str(r.get("odno")) == odno), rows[0] if rows else None)
normalized_odno = odno.lstrip("0") or "0"
row = next(
(
r
for r in rows
if (str(r.get("odno") or "").strip().lstrip("0") or "0") == normalized_odno
),
None,
)
if row is None:
return FillStatus(state="accepted", raw=payload)
return FillStatus(
state="accepted",
raw={"reason": "order_not_visible_in_fill_inquiry"},
)
return self._fill_status_from_row(row, side=side, today=today, fallback_odno=odno)
def _poll_unknown_submission(
self,
broker_order_id: str,
*,
side: str,
symbol: str,
) -> FillStatus:
metadata = _decode_submission_unknown(broker_order_id)
if metadata is None:
return FillStatus(
state="unknown",
raw={"reason": "submission_unknown_metadata_invalid"},
)
now_monotonic = monotonic()
if now_monotonic < self._fill_poll_not_before.get(broker_order_id, 0.0):
return FillStatus(
state="accepted",
raw={"reason": "submission_lookup_backoff"},
)
self._fill_poll_not_before[broker_order_id] = (
now_monotonic + _FILL_POLL_MIN_INTERVAL_SECONDS
)
cano, product = self._account()
submitted_at = datetime.fromisoformat(str(metadata["submitted_at"])).astimezone(_KST)
today = submitted_at.strftime("%Y%m%d")
tr_id = "VTTC8001R" if self._mock else "TTTC8001R"
params = {
"CANO": cano,
"ACNT_PRDT_CD": product,
"INQR_STRT_DT": today,
"INQR_END_DT": today,
"SLL_BUY_DVSN_CD": "00",
"INQR_DVSN": "00",
"PDNO": str(metadata["symbol"]),
"CCLD_DVSN": "00",
"ORD_GNO_BRNO": "",
"ODNO": "",
"INQR_DVSN_3": "00",
"INQR_DVSN_1": "",
"CTX_AREA_FK100": "",
"CTX_AREA_NK100": "",
}
try:
resp = httpx.get(
f"{self._base_url}/uapi/domestic-stock/v1/trading/inquire-daily-ccld",
headers=self._headers(tr_id),
params=params,
timeout=self._timeout,
)
payload = resp.json()
except (httpx.HTTPError, ValueError):
self._fill_poll_not_before[broker_order_id] = (
monotonic() + _FILL_POLL_TRANSPORT_BACKOFF_SECONDS
)
return FillStatus(
state="unknown",
raw={"reason": "submission_lookup_transport"},
)
if getattr(resp, "status_code", 200) >= 400 or str(payload.get("rt_cd")) != "0":
self._fill_poll_not_before[broker_order_id] = (
monotonic() + _FILL_POLL_TRANSPORT_BACKOFF_SECONDS
)
return FillStatus(
state="unknown",
raw={"reason": "submission_lookup_rejected"},
)
matches = [
row
for row in (payload.get("output1") or [])
if self._matches_unknown_submission(row, metadata, submitted_at)
]
if len(matches) != 1:
age = (datetime.now(tz=_KST) - submitted_at).total_seconds()
if not matches and age >= _SUBMISSION_LOOKUP_GRACE_SECONDS:
return FillStatus(
state="rejected",
raw={"reason": "submission_confirmed_absent"},
)
return FillStatus(
state="unknown",
raw={
"reason": (
"submission_lookup_ambiguous" if matches else "submission_lookup_pending"
)
},
)
row = matches[0]
resolved_order_id = self._broker_order_id_from_row(row)
status = self._fill_status_from_row(
row,
side=side,
today=today,
fallback_odno=resolved_order_id.split(":")[-1],
)
return replace(
status,
raw={
**(status.raw if isinstance(status.raw, dict) else {}),
"resolved_broker_order_id": resolved_order_id,
},
)
@staticmethod
def _broker_order_id_from_row(row: dict) -> str:
odno = str(row.get("odno") or "").strip()
orgno = str(row.get("ord_gno_brno") or "").strip()
return f"{orgno}:{odno}" if orgno else odno
@staticmethod
def _matches_unknown_submission(
row: dict,
metadata: dict,
submitted_at: datetime,
) -> bool:
if str(row.get("pdno") or "").strip() != str(metadata["symbol"]):
return False
expected_side = "02" if metadata["side"] == "buy" else "01"
if str(row.get("sll_buy_dvsn_cd") or "").strip() not in {"", expected_side}:
return False
if (_safe_int(row.get("ord_qty")) or 0) != int(metadata["qty"]):
return False
expected_price = int(metadata.get("limit_price") or 0)
row_price = _safe_int(row.get("ord_unpr"))
if row_price is not None and row_price != expected_price:
return False
order_date = str(row.get("ord_dt") or "").strip()
order_time = str(row.get("ord_tmd") or "").strip()[:6]
if len(order_date) != 8 or len(order_time) != 6:
return False
try:
ordered_at = datetime.strptime(order_date + order_time, "%Y%m%d%H%M%S").replace(
tzinfo=_KST
)
except ValueError:
return False
delta = (ordered_at - submitted_at).total_seconds()
return -5.0 <= delta <= 120.0
@staticmethod
def _fill_status_from_row(
row: dict,
*,
side: str,
today: str,
fallback_odno: str,
) -> FillStatus:
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"))
@@ -245,7 +512,7 @@ class KISBroker:
# kis-daily-ccld:{ord_dt}:{ord_gno_brno}:{odno}:{tot_ccld_qty}
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)
row_odno = str(row.get("odno") or fallback_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
# dcy-026: inquire-daily-ccld (output1) does not expose per-fill
@@ -256,9 +523,13 @@ class KISBroker:
fee = calculate_fee(side, amount)
tax = calculate_tax(side, amount)
return FillStatus(
state="filled", filled_qty=ccld_qty,
state="filled",
filled_qty=ccld_qty,
avg_price=fill_price or None,
execution_id=execution_id, fee=fee, tax=tax, raw=row,
execution_id=execution_id,
fee=fee,
tax=tax,
raw=row,
)
# 0 < ccld_qty < ord_qty
if cancelled:
@@ -268,20 +539,28 @@ class KISBroker:
# 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)
row_odno = str(row.get("odno") or fallback_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
amount = ccld_qty * fill_price
fee = calculate_fee(side, amount)
tax = calculate_tax(side, amount)
return FillStatus(
state="partially_filled", filled_qty=ccld_qty,
state="partially_filled",
filled_qty=ccld_qty,
avg_price=fill_price or None,
execution_id=execution_id, fee=fee, tax=tax,
cancelled=True, raw=row,
execution_id=execution_id,
fee=fee,
tax=tax,
cancelled=True,
raw=row,
)
return FillStatus(state="partially_filled", filled_qty=ccld_qty,
avg_price=float(avg) if avg else None, raw=row)
return FillStatus(
state="partially_filled",
filled_qty=ccld_qty,
avg_price=float(avg) if avg else None,
raw=row,
)
def get_balance(self) -> list[BalanceItem]:
"""KIS 주식잔고조회 (`TTTC8434R`/`VTTC8434R`).
@@ -294,12 +573,17 @@ class KISBroker:
cano, product = self._account()
tr_id = "VTTC8434R" if self._mock else "TTTC8434R"
params = {
"CANO": cano, "ACNT_PRDT_CD": product,
"AFHR_FLPR_YN": "N", "OFL_YN": "",
"INQR_DVSN": "02", "UNPR_DVSN": "01",
"FUND_STTL_ICLD_YN": "N", "FNCG_AMT_AUTO_RDPT_YN": "N",
"CANO": cano,
"ACNT_PRDT_CD": product,
"AFHR_FLPR_YN": "N",
"OFL_YN": "",
"INQR_DVSN": "02",
"UNPR_DVSN": "01",
"FUND_STTL_ICLD_YN": "N",
"FNCG_AMT_AUTO_RDPT_YN": "N",
"PRCS_DVSN": "01",
"CTX_AREA_FK100": "", "CTX_AREA_NK100": "",
"CTX_AREA_FK100": "",
"CTX_AREA_NK100": "",
}
try:
headers = self._headers(tr_id)
@@ -310,14 +594,18 @@ class KISBroker:
try:
resp = httpx.get(
f"{self._base_url}/uapi/domestic-stock/v1/trading/inquire-balance",
headers=headers, params=params, timeout=self._timeout,
headers=headers,
params=params,
timeout=self._timeout,
)
resp.raise_for_status()
payload = resp.json()
except httpx.HTTPStatusError as exc:
status = exc.response.status_code if exc.response is not None else None
code = "authentication_failed" if status == 401 else (
"authorization_failed" if status == 403 else "transport_failed"
code = (
"authentication_failed"
if status == 401
else ("authorization_failed" if status == 403 else "transport_failed")
)
raise BrokerBalanceError(code) from None
except httpx.HTTPError:
@@ -400,7 +688,9 @@ class KISBroker:
try:
resp = httpx.get(
f"{self._base_url}/uapi/domestic-stock/v1/quotations/inquire-price",
headers=self._headers(self._tr_price()), params=params, timeout=self._timeout,
headers=self._headers(self._tr_price()),
params=params,
timeout=self._timeout,
)
payload = resp.json()
except (httpx.HTTPError, ValueError):
@@ -416,21 +706,30 @@ class KISBroker:
except (InvalidOperation, ValueError):
return None
def cancel_order(self, broker_order_id: str, *, side: str, symbol: str, qty: int, venue: str = "KRX") -> bool:
def cancel_order(
self, broker_order_id: str, *, side: str, symbol: str, qty: int, venue: str = "KRX"
) -> bool:
"""Cancel a previously submitted order (REQ-118).
venue: exchange venue of the original order. Must match the EXCG_ID_DVSN_CD
used at submit time. Default "KRX" preserves backward compatibility.
Real-account NXT cancel (EXCG=NXT) confirmed rt_cd=0 on 2026-07-06.
"""
if broker_order_id.startswith(_SUBMISSION_UNKNOWN_PREFIX):
return False
cano, product = self._account()
parts = broker_order_id.split(":")
orgno, odno = (parts[0], parts[1]) if len(parts) == 2 else ("", parts[-1])
body = {
"CANO": cano, "ACNT_PRDT_CD": product,
"KRX_FWDG_ORD_ORGNO": orgno, "ORGN_ODNO": odno,
"ORD_DVSN": "00", "RVSE_CNCL_DVSN_CD": "02",
"ORD_QTY": str(qty), "ORD_UNPR": "0", "QTY_ALL_ORD_YN": "Y",
"CANO": cano,
"ACNT_PRDT_CD": product,
"KRX_FWDG_ORD_ORGNO": orgno,
"ORGN_ODNO": odno,
"ORD_DVSN": "00",
"RVSE_CNCL_DVSN_CD": "02",
"ORD_QTY": str(qty),
"ORD_UNPR": "0",
"QTY_ALL_ORD_YN": "Y",
# REQ-118: cancel must use the same EXCG_ID_DVSN_CD as the original submit.
"EXCG_ID_DVSN_CD": venue,
}
@@ -438,7 +737,9 @@ class KISBroker:
try:
resp = httpx.post(
f"{self._base_url}/uapi/domestic-stock/v1/trading/order-rvsecncl",
headers=self._headers(tr_id), json=body, timeout=self._timeout,
headers=self._headers(tr_id),
json=body,
timeout=self._timeout,
)
return str(resp.json().get("rt_cd")) == "0"
except (httpx.HTTPError, ValueError):