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:
+20 -6
View File
@@ -285,13 +285,25 @@ class PostgreSQLSlotRepository:
)
return [self._row_to_slot(r) for r in cur.fetchall()]
def mark_dispatched(self, slot_id: UUID, broker_order_id: str) -> bool:
def mark_dispatched(
self,
slot_id: UUID,
broker_order_id: str,
*,
venue: Optional[str] = None,
) -> bool:
with self._psycopg.connect(self._dsn, autocommit=False) as conn:
with conn.cursor() as cur:
cur.execute(
"UPDATE daily_slot SET broker_order_id=%s, updated_at=%s "
"UPDATE daily_slot SET broker_order_id=%s, "
"venue=COALESCE(%s, venue), updated_at=%s "
"WHERE slot_id=%s AND broker_order_id IS NULL",
(broker_order_id, datetime.now(tz=timezone.utc), slot_id),
(
broker_order_id,
venue,
datetime.now(tz=timezone.utc),
slot_id,
),
)
ok = cur.rowcount == 1
conn.commit()
@@ -456,7 +468,7 @@ class _PgIntentRecord:
_OUTBOX_COLUMNS = (
"correlation_id, asset_id, order_id, execution_id, side, qty, price, "
"fee, tax, executed_at, status, attempts, last_outcome, last_error, "
"created_at, updated_at, next_retry_at"
"created_at, updated_at, next_retry_at, venue"
)
@@ -473,8 +485,8 @@ class PostgreSQLLedgerOutbox:
cur.execute(
"INSERT INTO ledger_outbox "
"(correlation_id, asset_id, order_id, execution_id, side, "
" qty, price, fee, tax, executed_at) "
"VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) "
" qty, price, fee, tax, executed_at, venue) "
"VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) "
"ON CONFLICT (correlation_id) DO NOTHING",
(
trade.correlation_id,
@@ -487,6 +499,7 @@ class PostgreSQLLedgerOutbox:
trade.fee,
trade.tax,
trade.executed_at,
trade.venue,
),
)
conn.commit()
@@ -590,6 +603,7 @@ class PostgreSQLLedgerOutbox:
fee=f(row[7]),
tax=f(row[8]),
executed_at=row[9],
venue=row[17],
)
return LedgerWorkItem(
correlation_id=row[0],
@@ -0,0 +1,20 @@
-- dcy-041 / GitHub #44: AUTO may exist while an intent is pending, but the
-- actual submitted KRX/NXT value replaces it atomically with broker_order_id.
ALTER TABLE daily_slot
DROP CONSTRAINT IF EXISTS daily_slot_venue_check;
ALTER TABLE daily_slot
ADD CONSTRAINT daily_slot_venue_check
CHECK (venue IN ('AUTO', 'KRX', 'NXT', 'SOR'));
-- Core keeps the actual venue in its durable ledger outbox. Portfolio Hub's
-- HTTP schema is intentionally unchanged in this migration.
ALTER TABLE ledger_outbox
ADD COLUMN IF NOT EXISTS venue TEXT NOT NULL DEFAULT 'KRX';
ALTER TABLE ledger_outbox
DROP CONSTRAINT IF EXISTS ledger_outbox_venue_check;
ALTER TABLE ledger_outbox
ADD CONSTRAINT ledger_outbox_venue_check
CHECK (venue IN ('AUTO', 'KRX', 'NXT', 'SOR'));