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'));
+4
View File
@@ -45,6 +45,10 @@ class OrderAck:
accepted: bool
broker_order_id: Optional[str] = None
reason: Optional[str] = None
# Actual venue used for submission. KIS resolves AUTO before the HTTP call;
# execution persists this value with the broker order id so restart/cancel
# never has to infer the original venue.
venue: Optional[str] = None
raw: dict[str, Any] = field(default_factory=dict)
+6 -1
View File
@@ -201,7 +201,11 @@ class ExecutionService:
)
)
if ack.accepted and ack.broker_order_id:
self._repo.mark_dispatched(slot.slot_id, ack.broker_order_id)
self._repo.mark_dispatched(
slot.slot_id,
ack.broker_order_id,
venue=ack.venue,
)
r.dispatched += 1
if ack.reason and ack.reason.startswith("submit_outcome_unknown:"):
_log.warning(
@@ -643,6 +647,7 @@ class ExecutionService:
fee=float(fs.fee),
tax=float(fs.tax),
executed_at=datetime.now(tz=timezone.utc),
venue=slot.venue,
)
self._ledger_outbox.enqueue(trade)
r.ledger_enqueued += 1
+6 -4
View File
@@ -75,7 +75,9 @@ class BuyIntent:
# slippage_pct: effective limit = entry_price*(1+pct/100) buy / *(1-pct/100) sell.
timeout_sec: Optional[int] = None
slippage_pct: Optional[float] = None
venue: str = "KRX"
# Strategy wire default. AUTO is resolved at the broker boundary so
# strategies remain session-agnostic while explicit KRX/NXT/SOR is kept.
venue: str = "AUTO"
extra: dict[str, Any] = field(default_factory=dict)
@@ -94,7 +96,7 @@ class SellIntent:
# dcy-022 / REQ-068: execution policy (optional, backward-compat).
timeout_sec: Optional[int] = None
slippage_pct: Optional[float] = None
venue: str = "KRX"
venue: str = "AUTO"
extra: dict[str, Any] = field(default_factory=dict)
@@ -211,7 +213,7 @@ class IntentService:
dcy-036 / REQ-114: entry gate — if entry_allowed_fn returns False,
BuyIntent is rejected before any slot lookup."""
if intent.venue not in {"KRX", "NXT", "SOR"}:
if intent.venue not in {"AUTO", "KRX", "NXT", "SOR"}:
return IntentDecision(intent.intent_id, "rejected", None, "invalid_venue")
# dcy-036 / REQ-114: day-start entry gate (fail-closed).
@@ -308,7 +310,7 @@ class IntentService:
return IntentDecision(intent.intent_id, "rejected", None, "already_active")
def _admit_sell(self, intent: SellIntent) -> IntentDecision:
if intent.venue not in {"KRX", "NXT", "SOR"}:
if intent.venue not in {"AUTO", "KRX", "NXT", "SOR"}:
return IntentDecision(intent.intent_id, "rejected", None, "invalid_venue")
slot = self._repo.fetch_slot(intent.slot_id)
+3
View File
@@ -57,6 +57,9 @@ class LedgerTrade:
fee: float = 0.0
tax: float = 0.0
executed_at: Optional[datetime] = None
# Actual order venue persisted in the Core outbox. Portfolio Hub remains
# unchanged until its API contract explicitly accepts this additive field.
venue: str = "KRX"
@property
def correlation_id(self) -> str:
+2 -3
View File
@@ -117,7 +117,7 @@ def make_intent_rpc_handler(
# dcy-022 / REQ-068: execution policy (optional, backward-compat).
timeout_sec=_opt_int(body.get("timeout_sec")),
slippage_pct=_opt_float(body.get("slippage_pct")),
venue=_opt_str(body.get("venue")) or "KRX",
venue=_opt_str(body.get("venue")) or "AUTO",
)
d = intent_service.submit_buy(buy)
elif intent_type == "sell":
@@ -133,7 +133,7 @@ def make_intent_rpc_handler(
# dcy-022 / REQ-068: execution policy (optional, backward-compat).
timeout_sec=_opt_int(body.get("timeout_sec")),
slippage_pct=_opt_float(body.get("slippage_pct")),
venue=_opt_str(body.get("venue")) or "KRX",
venue=_opt_str(body.get("venue")) or "AUTO",
)
d = intent_service.submit_sell(sell)
else:
@@ -627,4 +627,3 @@ def _parse_date(v) -> date_cls:
def _today() -> date_cls:
return datetime.now(tz=timezone.utc).date()
+12 -3
View File
@@ -185,13 +185,22 @@ class InMemorySlotRepository:
]
return out[:limit]
def mark_dispatched(self, slot_id: UUID, broker_order_id: str) -> bool:
"""Atomically set broker_order_id iff still undispatched (idempotency guard)."""
def mark_dispatched(
self,
slot_id: UUID,
broker_order_id: str,
*,
venue: Optional[str] = None,
) -> bool:
"""Atomically persist dispatch identity and the actual broker venue."""
s = self._slots.get(slot_id)
if s is None or s.broker_order_id is not None:
return False
self._slots[slot_id] = replace(
s, broker_order_id=broker_order_id, updated_at=datetime.now(tz=timezone.utc)
s,
broker_order_id=broker_order_id,
venue=venue if venue is not None else s.venue,
updated_at=datetime.now(tz=timezone.utc),
)
return True
+6 -1
View File
@@ -215,10 +215,15 @@ class SlotService:
new_limit_price = limit_price if limit_price is not None else slot.limit_price
new_timeout_sec = timeout_sec if timeout_sec is not None else slot.timeout_sec
new_slippage_pct = slippage_pct if slippage_pct is not None else slot.slippage_pct
new_venue = venue if venue is not None else slot.venue
else: # HOLDING / ERROR — pending order policy no longer applies.
new_limit_price = None
new_timeout_sec = None
new_slippage_pct = None
# A completed order's venue must not leak into a later autonomous
# sell. The next order starts session-aware unless its intent
# explicitly chooses KRX/NXT/SOR.
new_venue = "AUTO" if to_state is SlotState.HOLDING else slot.venue
# occupancy_seq (dcy-028/REQ-081): bump when a slot begins a new
# occupancy (inactive→pending_buy). Stable across the rest of the
@@ -271,7 +276,7 @@ class SlotService:
timeout_sec=new_timeout_sec,
slippage_pct=new_slippage_pct,
occupancy_seq=new_occupancy_seq,
venue=venue if venue is not None else slot.venue,
venue=new_venue,
updated_at=datetime.now(tz=timezone.utc),
)