feat: resolve and persist actual order venue

This commit is contained in:
jordan
2026-07-29 17:10:32 +09:00
parent 6398ff250c
commit a656350a35
18 changed files with 304 additions and 47 deletions
+54 -13
View File
@@ -14,10 +14,10 @@ from __future__ import annotations
import base64
import json
from dataclasses import replace
from datetime import datetime, timedelta, timezone
from datetime import datetime, time, timedelta, timezone
from decimal import Decimal, InvalidOperation
from time import monotonic
from typing import Optional
from typing import Callable, Optional
import httpx
@@ -37,6 +37,30 @@ _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
_SUPPORTED_ORDER_VENUES = frozenset({"AUTO", "KRX", "NXT", "SOR"})
def resolve_order_venue(venue: str, at: datetime) -> str:
"""Resolve AUTO to the venue that accepts domestic-stock orders at `at`.
The gaps are intentional fail-closed intervals. They prevent an AUTO order
from silently falling back to KRX while the opening venue is indeterminate.
Explicit KRX/NXT/SOR values are preserved.
"""
normalized = str(venue).strip().upper()
if normalized not in _SUPPORTED_ORDER_VENUES:
raise ValueError("unsupported_order_venue")
if normalized != "AUTO":
return normalized
current = at.astimezone(_KST).time().replace(tzinfo=None)
if time(8, 0) <= current < time(8, 50):
return "NXT"
if time(9, 0, 30) <= current < time(15, 30):
return "KRX"
if time(15, 30) <= current <= time(20, 0):
return "NXT"
raise ValueError("auto_venue_session_closed")
def _safe_int(v: object) -> Optional[int]:
@@ -108,6 +132,7 @@ class KISBroker:
timeout: float = 5.0,
allow_live: bool = False,
token_cache=None,
clock: Optional[Callable[[], datetime]] = None,
) -> None:
self._account_ref = account_ref
self._app_key = app_key
@@ -117,6 +142,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._clock = clock or (lambda: datetime.now(tz=_KST))
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:
@@ -226,23 +252,28 @@ class KISBroker:
# --- BrokerPort ---
def submit_order(self, req: OrderRequest) -> OrderAck:
cano, product = self._account()
submitted_at = datetime.now(tz=_KST)
submitted_at = self._clock().astimezone(_KST)
try:
actual_venue = resolve_order_venue(req.venue, submitted_at)
except ValueError as exc:
return OrderAck(accepted=False, reason=str(exc))
resolved_req = replace(req, venue=actual_venue)
body = {
"CANO": cano,
"ACNT_PRDT_CD": product,
"PDNO": req.symbol,
"ORD_DVSN": req.order_type,
"ORD_QTY": str(req.qty),
"ORD_UNPR": str(req.limit_price or 0),
"PDNO": resolved_req.symbol,
"ORD_DVSN": resolved_req.order_type,
"ORD_QTY": str(resolved_req.qty),
"ORD_UNPR": str(resolved_req.limit_price or 0),
# REQ-118: always send EXCG_ID_DVSN_CD from req.venue (default "KRX").
# Previously omitted (server defaulted to KRX); explicit is more robust.
# Real-account NXT order confirmed rt_cd=0 on 2026-07-06.
"EXCG_ID_DVSN_CD": req.venue,
"EXCG_ID_DVSN_CD": actual_venue,
}
try:
resp = httpx.post(
f"{self._base_url}/uapi/domestic-stock/v1/trading/order-cash",
headers=self._headers(self._tr_submit(req.side)),
headers=self._headers(self._tr_submit(resolved_req.side)),
json=body,
timeout=self._timeout,
)
@@ -253,15 +284,17 @@ class KISBroker:
# the execution loop cannot submit the same order again.
return OrderAck(
accepted=True,
broker_order_id=_encode_submission_unknown(req, submitted_at),
broker_order_id=_encode_submission_unknown(resolved_req, submitted_at),
reason=f"submit_outcome_unknown:{type(exc).__name__}",
venue=actual_venue,
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),
broker_order_id=_encode_submission_unknown(resolved_req, submitted_at),
reason=f"submit_outcome_unknown:http_{resp.status_code}",
venue=actual_venue,
raw={"http_status": resp.status_code},
)
if str(payload.get("rt_cd")) == "0":
@@ -269,9 +302,17 @@ class KISBroker:
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=True,
broker_order_id=bid,
venue=actual_venue,
raw=payload,
)
return OrderAck(
accepted=False, reason=payload.get("msg1") or payload.get("msg_cd"), raw=payload
accepted=False,
reason=payload.get("msg1") or payload.get("msg_cd"),
venue=actual_venue,
raw=payload,
)
def poll_fill(self, broker_order_id: str, *, side: str, symbol: str) -> FillStatus:
+1 -1
View File
@@ -76,7 +76,7 @@ class PaperBroker:
seq = self._seq
oid = f"PAPER-{seq:06d}"
self._orders[oid] = _Order(oid, req)
return OrderAck(accepted=True, broker_order_id=oid)
return OrderAck(accepted=True, broker_order_id=oid, venue=req.venue)
def poll_fill(self, broker_order_id: str, *, side: str, symbol: str) -> FillStatus:
with self._lock: