fix: preserve SOR execution venue separately

This commit is contained in:
Hermes Agent
2026-08-19 12:48:05 +09:00
parent 15aeae6930
commit 7366cadf03
15 changed files with 126 additions and 17 deletions
+25
View File
@@ -573,6 +573,27 @@ class KISBroker:
delta = (ordered_at - submitted_at).total_seconds()
return -5.0 <= delta <= 120.0
@staticmethod
def _execution_venue_from_row(row: dict) -> str | None:
"""Parse broker-authoritative KRX/NXT without time inference."""
notice_code = str(row.get("ord_exg_gb") or row.get("ORD_EXG_GB") or "").strip()
notice_map = {"1": "KRX", "2": "NXT", "3": "KRX", "4": "NXT"}
if notice_code in notice_map:
return notice_map[notice_code]
for key in (
"excg_id_dvsn_cd",
"excg_id_dvsn_Cd",
"EXCG_ID_DVSN_CD",
"execution_venue",
):
value = str(row.get(key) or "").strip().upper()
if value in {"KRX", "SOR-KRX"}:
return "KRX"
if value in {"NXT", "SOR-NXT"}:
return "NXT"
# SOR is routing provenance, not an execution exchange.
return None
@staticmethod
def _fill_status_from_row(
row: dict,
@@ -581,6 +602,7 @@ class KISBroker:
today: str,
fallback_odno: str,
) -> FillStatus:
execution_venue = KISBroker._execution_venue_from_row(row)
ord_qty = _safe_int(row.get("ord_qty")) or 0
ccld_qty = _safe_int(row.get("tot_ccld_qty")) or 0
rejected_qty = _safe_int(row.get("rjct_qty")) or 0
@@ -623,6 +645,7 @@ class KISBroker:
execution_id=execution_id,
fee=fee,
tax=tax,
execution_venue=execution_venue,
raw=row,
)
# 0 < ccld_qty < ord_qty
@@ -646,6 +669,7 @@ class KISBroker:
execution_id=execution_id,
fee=fee,
tax=tax,
execution_venue=execution_venue,
cancelled=True,
raw=row,
)
@@ -653,6 +677,7 @@ class KISBroker:
state="partially_filled",
filled_qty=ccld_qty,
avg_price=float(avg) if avg else None,
execution_venue=execution_venue,
raw=row,
)
+5
View File
@@ -31,6 +31,9 @@ class _Order:
self.side = req.side
self.symbol = req.symbol
self.qty = req.qty
# Deterministic SOR simulation: paper routes SOR to KRX while retaining
# req.venue on the acknowledgement as submission provenance.
self.execution_venue = "KRX" if req.venue == "SOR" else req.venue
self.price = float(req.limit_price) if req.limit_price is not None else 1000.0
self.polls = 0
self.filled = 0
@@ -118,11 +121,13 @@ class PaperBroker:
return FillStatus(
state="filled", filled_qty=o.filled, avg_price=o.price,
execution_id=execution_id, fee=fee, tax=tax,
execution_venue=o.execution_venue,
)
return FillStatus(
state="partially_filled",
filled_qty=o.filled,
avg_price=o.price,
execution_venue=o.execution_venue,
raw={"order_open_confirmed": True},
)
+5 -3
View File
@@ -548,7 +548,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, venue"
"created_at, updated_at, next_retry_at, venue, order_venue"
)
@@ -565,8 +565,8 @@ class PostgreSQLLedgerOutbox:
cur.execute(
"INSERT INTO ledger_outbox "
"(correlation_id, asset_id, order_id, execution_id, side, "
" qty, price, fee, tax, executed_at, venue) "
"VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) "
" qty, price, fee, tax, executed_at, venue, order_venue) "
"VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) "
"ON CONFLICT (correlation_id) DO NOTHING",
(
trade.correlation_id,
@@ -580,6 +580,7 @@ class PostgreSQLLedgerOutbox:
trade.tax,
trade.executed_at,
trade.venue,
trade.order_venue,
),
)
conn.commit()
@@ -684,6 +685,7 @@ class PostgreSQLLedgerOutbox:
tax=f(row[8]),
executed_at=row[9],
venue=row[17],
order_venue=row[18],
)
return LedgerWorkItem(
correlation_id=row[0],
@@ -0,0 +1,12 @@
-- GitHub #61: keep the original submission venue separate from actual execution venue.
ALTER TABLE ledger_outbox
ADD COLUMN IF NOT EXISTS order_venue TEXT NOT NULL DEFAULT 'KRX';
ALTER TABLE ledger_outbox
DROP CONSTRAINT IF EXISTS ledger_outbox_order_venue_check;
ALTER TABLE ledger_outbox
ADD CONSTRAINT ledger_outbox_order_venue_check
CHECK (order_venue IN ('KRX', 'NXT', 'SOR'));
-- Historical rows used venue for both concepts. Preserve that provenance.
UPDATE ledger_outbox SET order_venue = venue WHERE order_venue = 'KRX' AND venue <> 'KRX';
+4 -1
View File
@@ -104,7 +104,10 @@ class FillStatus:
execution_id: Optional[str] = None
fee: float = 0.0
tax: float = 0.0
# dcy-013 / REQ-012 (issue #9): an external cancel was detected. When the
# Broker-authoritative execution exchange. For SOR orders this is KRX or
# NXT; None means the polling surface did not establish it.
execution_venue: Optional[str] = None
# dcy-013 / REQ-012: an external cancel was detected.
# order had a partial fill before the cancel, state stays "partially_filled"
# and this flag is set so execution records the filled portion and returns
# the remainder to holding (is_partial_cancel). A zero-fill external cancel
+16 -1
View File
@@ -773,6 +773,20 @@ class ExecutionService:
if resolved_price is not None
else (float(fs.avg_price) if fs.avg_price is not None else 0.0)
)
execution_venue = fs.execution_venue
if execution_venue is None and slot.venue in {"KRX", "NXT"}:
execution_venue = slot.venue
if execution_venue not in {"KRX", "NXT"}:
r.ledger_skipped += 1
_log.warning(
"ledger skip: slot=%s portfolio_id=%s side=%s "
"reason=missing_execution_venue order_venue=%s",
slot.slot_id,
slot.portfolio_id,
side,
slot.venue,
)
return
trade = LedgerTrade(
asset_id=str(slot.portfolio_id),
order_id=str(broker_order_id),
@@ -783,7 +797,8 @@ class ExecutionService:
fee=float(fs.fee),
tax=float(fs.tax),
executed_at=datetime.now(tz=timezone.utc),
venue=slot.venue,
venue=execution_venue,
order_venue=slot.venue,
)
self._ledger_outbox.enqueue(trade)
r.ledger_enqueued += 1
+3 -2
View File
@@ -57,9 +57,10 @@ 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.
# Broker-authoritative execution exchange (KRX/NXT).
venue: str = "KRX"
# Original submission/routing venue (KRX/NXT/SOR), retained for audit.
order_venue: str = "KRX"
@property
def correlation_id(self) -> str: