feat: resolve and persist actual order venue
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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'));
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user