From 7366cadf03fc6002bfd01ecf0cba7c24486bb618 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Wed, 19 Aug 2026 12:48:05 +0900 Subject: [PATCH] fix: preserve SOR execution venue separately --- docs/data-model.md | 2 +- docs/interface-contract.md | 6 +-- tests/adapters/broker/test_venue.py | 38 +++++++++++++++++++ .../test_postgresql_ledger_outbox.py | 4 +- tests/application/test_execution_cancel.py | 3 +- .../test_execution_fill_price_backfill.py | 2 + tests/application/test_execution_ledger.py | 3 +- tests/application/test_venue_intent_flow.py | 8 ++-- trex_core/adapters/broker/kis.py | 25 ++++++++++++ trex_core/adapters/broker/paper.py | 5 +++ trex_core/adapters/persistence/postgresql.py | 8 ++-- .../sql/024_add_ledger_order_venue.sql | 12 ++++++ trex_core/application/broker_port.py | 5 ++- trex_core/application/execution.py | 17 ++++++++- trex_core/application/ledger_port.py | 5 ++- 15 files changed, 126 insertions(+), 17 deletions(-) create mode 100644 trex_core/adapters/persistence/sql/024_add_ledger_order_venue.sql diff --git a/docs/data-model.md b/docs/data-model.md index 681c09e..5d01846 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -49,7 +49,7 @@ - limit_price :: numeric? :: 현재 pending 주문 지정가(side 무관, dcy-012/REQ-002 — 과거 `entry_price`에서 rename). pending 진입(매수/매도 지정가) 시 set, pending 이탈(→holding/→closed) 시 None 으로 clear → 매수 지정가가 매도로 누수되지 않음(issue #10). None 이면 dispatch 시 시장가. - allocated_capital :: numeric? :: 배정 자본 - order_id / broker_order_id :: str? :: 주문 식별자(자유 주석 / 브로커 핸들) -- venue :: TEXT NOT NULL DEFAULT 'KRX' :: 현재 주문 거래소 구분(KRX/NXT/SOR). venue 누락 intent는 SOR로 저장하고, 명시 venue는 보존한다. pending 주문·취소까지 보존하며 HOLDING 진입 시 다음 주문의 SOR, INACTIVE 진입 시 KRX로 초기화한다. +- venue :: TEXT NOT NULL DEFAULT 'KRX' :: 원주문/라우팅 거래소(KRX/NXT/SOR). venue 누락 intent는 SOR로 저장하고, pending 주문·재시작·취소까지 보존한다. 실제 KRX/NXT 체결 거래소는 `FillStatus.execution_venue`에서 별도로 확정해 `ledger_outbox.venue`에 기록하고 원주문은 `ledger_outbox.order_venue`에 보존한다. - cancel_state :: TEXT? :: 취소 요청의 즉시 결과(requested/rejected/unknown). 최종 취소 상태가 아니다. requested는 재시도하지 않고, rejected/unknown만 authoritative open-row 확인과 bounded backoff 뒤 재시도할 수 있다. - cancel_requested_at :: TIMESTAMPTZ? :: 마지막 취소 시도 예약 시각. 주문 종료·dispatch clear 때 함께 제거한다. - order_submitted_at :: TIMESTAMPTZ? :: timeout deadline의 immutable 주문 제출 기준점. poll/cancel bookkeeping 및 daemon 재시작에서 보존하고 terminal dispatch clear 때 제거한다. diff --git a/docs/interface-contract.md b/docs/interface-contract.md index 10b813d..e7d98d0 100644 --- a/docs/interface-contract.md +++ b/docs/interface-contract.md @@ -396,7 +396,7 @@ resume_kill_switch 요청·응답 (REQ-046): ### Persistence -- PostgreSQL: `ledger_outbox(correlation_id PK, asset_id, order_id, execution_id, side, qty, price, fee, tax, executed_at, venue, status, attempts, last_outcome, last_error, created_at, updated_at)` — `ON CONFLICT (correlation_id) DO NOTHING` 이 outbox dedup 게이트. `venue`는 Core 내부 실제 제출 거래소이며 Portfolio Hub wire에는 아직 보내지 않는다. +- PostgreSQL: `ledger_outbox(..., venue, order_venue, ...)` — `ON CONFLICT (correlation_id) DO NOTHING` 이 outbox dedup 게이트. `order_venue`는 원주문 KRX/NXT/SOR, `venue`는 broker fill에서 확인한 실제 KRX/NXT 체결 거래소다. Portfolio Hub wire에는 아직 보내지 않는다. ## `OrderRequest` DTO (BAT-004 / REQ-011, BAT-056 / REQ-117) @@ -417,7 +417,7 @@ resume_kill_switch 요청·응답 (REQ-046): - `venue` 필드는 BAT-056에서 additive 추가. 기존 orb/tsl 미지정 호출 → 기본값 `"KRX"` 자동 적용 → 하위 호환. - `OrderRequest`는 AMQP wire 위를 직접 흐르지 않는다. buy/sell intent의 선택적 `venue` 누락은 `SOR`이고, 명시한 KRX/NXT/SOR는 그대로 슬롯에 보존한다. `AUTO`와 그 밖의 값은 `invalid_venue`로 admission 전 거부한다. - KIS 어댑터는 KRX/NXT/SOR를 시간과 무관하게 보존해 `EXCG_ID_DVSN_CD`로 전송한다. -- `OrderAck.venue`의 실제 제출 거래소는 `broker_order_id`와 함께 슬롯에 원자 저장되며 재시작·취소·Core 원장 outbox에서 재사용한다. +- `OrderAck.venue`는 원주문 KRX/NXT/SOR를 `broker_order_id`와 함께 슬롯에 원자 저장하며 재시작·취소에 재사용한다. 실제 체결 거래소는 `FillStatus.execution_venue`의 KRX/NXT로 별도 전달해 outbox `venue`에 기록한다. SOR 또는 미지원 코드는 실제 거래소로 추론하지 않는다. ## `BrokerPort` Protocol 메서드 (BAT-056 / REQ-118) @@ -634,7 +634,7 @@ trex-core가 `source="trex-core"`, `severity="critical"`로 발행하는 초기 - 2026-06-05 :: BAT-007 — HTTP `/v1` API 인터페이스 제거(adapters/api + fastapi/uvicorn 의존 정리), RabbitMQ messaging 계약 추가(스냅샷 RPC `trex.snapshot.req`, intent RPC `trex.intent.req`, 이벤트 topic `trex.events`, 전략별 durable queue + TTL/max-length, DLQ, publisher confirms, ack-after-commit), `MessagingTransport` Protocol + `TransportHealth` 노출 (REQ-021~026). - 2026-06-06 :: BAT-008 — `trex.snapshot.req` envelope 계약 정의(`assets[]/view/available_slots/degraded/reason/message`, no price field), `trex.pause.req` 제어 RPC 추가, `PortfolioHubReadPort`/`SnapshotStore`/`PauseStore`/`SnapshotScheduler` 인터페이스, `BrokerPort.get_balance()` 확장, `trex-core` CLI 추가 (REQ-027~033; REQ-030 옵션 D 는 portfolio-hub#8 배포 후). - 2026-06-10 :: BAT-030 (dcy-020 마감) — buy intent 계약 키 `slot_id` 정본(+`portfolio_id` dual-accept, `trading_date` 제거), 거부 어휘(`unknown_slot`/`already_active`/`capacity_full`/`slot_id_or_portfolio_required`), `slots[]` 5-state + `universe_detached`(trading_date 키 제거), 이벤트 payload trading_date 제거, `available_slots`=admission headroom, SlotService/SlotRepository 자산 중심 계약, 매도 체결 `pending_sell→inactive` (REQ-056~065). -- 2026-06-15 :: BAT-043 (issue #27, REQ-007 CHG-20260615-01) — rejected SellIntent 응답에 additive `retryable: bool`(core 소유 reason→retryable 매핑, 미인식 기본 true, `bad_request:*`/terminal false). 손절매 재시도 안전 경로 decoupling. 완전 additive(admitted/processing/buy·기존 필드 불변). +- 2026-06-15 :: BAT-043 (issue #27, REQ-007 CHG-20260615-01) — rejected SellIntent 응답에 additive `retryable: bool`을 도입했으나, GitHub #60 승인 계약으로 폐기됐다. 현재 strategy RPC는 retryability·broker attempt·KIS failure를 전달하지 않는다. - 2026-06-15 :: BAT-042 (dcy-028) — slot strategy-state 계약: 신규 write RPC `trex.slots.strategy_state.set`(occupancy_seq+version CAS, reject reason 집합 `occupancy_changed`/`version_conflict`/`slot_inactive`/`state_too_large`/`bad_request`), `trex.slots.req` 행에 additive `occupancy_seq`+`strategy_state` 맵(REQ-073 AC-7/REQ-074 AC-6, opaque UTF-8+size cap, strategy_* reasoning 필드와 구분). close(`→INACTIVE`) 시 state 자동 삭제 (REQ-079~082). - 2026-06-11 :: BAT-035 (dcy-025) — `trex.snapshot.req` → `trex.slots.req` lockstep 교체: 요청 `{filter, spy}`, 응답 `{slots[], degraded, reason, message}`. 슬롯 row 에 hub `asset_quantity`/`cash_balance` + `warned`/`cash_estimated`/`watchdog`/`paused` 병합(REQ-073/074). `trex.snapshot.req` envelope(`assets[]`/`available_slots`/view enum) 제거(REQ-028 Deprecated). strategy 필드 제거(REQ-074 AC-5). CLI `universe`/`slots` → `trex.slots.req`(spy=true). `trex.direct` channel_type `snapshot` → `slots`. - 2026-06-11 :: BAT-036 (dcy-024) — `safety_sell_enabled: bool=True` per-instance TOML 플래그 추가(REQ-048 CHG-01). `false` 시 `trex-safety-sell` worker 미기동 + WARNING 로그(REQ-005 CHG-01). diff --git a/tests/adapters/broker/test_venue.py b/tests/adapters/broker/test_venue.py index 84835e9..e7e3da8 100644 --- a/tests/adapters/broker/test_venue.py +++ b/tests/adapters/broker/test_venue.py @@ -137,6 +137,44 @@ def test_submit_sor_preserves_sor(monkeypatch): assert captured["body"]["EXCG_ID_DVSN_CD"] == "SOR" +@pytest.mark.parametrize( + ("row", "expected"), + [ + ({"ord_exg_gb": "1"}, "KRX"), + ({"ord_exg_gb": "2"}, "NXT"), + ({"ord_exg_gb": "3"}, "KRX"), + ({"ord_exg_gb": "4"}, "NXT"), + ({"excg_id_dvsn_cd": "KRX"}, "KRX"), + ({"excg_id_dvsn_cd": "NXT"}, "NXT"), + ({"excg_id_dvsn_cd": "SOR-KRX"}, "KRX"), + ({"excg_id_dvsn_Cd": "SOR-NXT"}, "NXT"), + ({"excg_id_dvsn_cd": "SOR"}, None), + ({"excg_dvsn_cd": "02"}, None), + ({}, None), + ], +) +def test_kis_execution_venue_parser_is_allowlisted(row, expected): + assert KISBroker._execution_venue_from_row(row) == expected + + +def test_kis_fill_status_carries_sor_actual_execution_venue(): + status = KISBroker._fill_status_from_row( + { + "ord_qty": "10", + "tot_ccld_qty": "10", + "avg_prvs": "70000", + "ord_dt": "20260819", + "ord_gno_brno": "12345", + "odno": "0000000001", + "excg_id_dvsn_cd": "NXT", + }, + side="buy", + today="20260819", + fallback_odno="0000000001", + ) + assert status.execution_venue == "NXT" + + # --------------------------------------------------------------------------- # REQ-117 AC-4: 기존 위치 인수 순서 불변 # --------------------------------------------------------------------------- diff --git a/tests/adapters/persistence/test_postgresql_ledger_outbox.py b/tests/adapters/persistence/test_postgresql_ledger_outbox.py index f2bff2e..ef65a3c 100644 --- a/tests/adapters/persistence/test_postgresql_ledger_outbox.py +++ b/tests/adapters/persistence/test_postgresql_ledger_outbox.py @@ -20,7 +20,8 @@ def _trade(execution_id="paper:1:10") -> LedgerTrade: return LedgerTrade( asset_id="PF-1", order_id="ORD-1", execution_id=execution_id, side="buy", qty=10, price=1000.0, fee=1.5, tax=0.0, - executed_at=datetime(2026, 6, 4, tzinfo=timezone.utc), venue="NXT", + executed_at=datetime(2026, 6, 4, tzinfo=timezone.utc), + venue="NXT", order_venue="SOR", ) @@ -33,6 +34,7 @@ def test_enqueue_persists_and_is_idempotent(pg_clean): assert len(pending) == 1 assert pending[0].status is WorkItemStatus.PENDING assert pending[0].trade.venue == "NXT" + assert pending[0].trade.order_venue == "SOR" def test_writer_completes_persisted_item(pg_clean): diff --git a/tests/application/test_execution_cancel.py b/tests/application/test_execution_cancel.py index df77fff..1fae52f 100644 --- a/tests/application/test_execution_cancel.py +++ b/tests/application/test_execution_cancel.py @@ -116,7 +116,8 @@ def test_partial_fill_then_cancel_records_and_keeps_remainder(): broker.balance = {"005930": 6} broker.responder = lambda side: FillStatus( state="partially_filled", filled_qty=4, avg_price=1100.0, - execution_id="exec-partial", fee=0.5, tax=0.0, cancelled=True, + execution_id="exec-partial", fee=0.5, tax=0.0, + execution_venue="KRX", cancelled=True, ) r = execution.run_once() slot = repo.fetch_slot(sid) diff --git a/tests/application/test_execution_fill_price_backfill.py b/tests/application/test_execution_fill_price_backfill.py index 6503e5a..6bb6ac7 100644 --- a/tests/application/test_execution_fill_price_backfill.py +++ b/tests/application/test_execution_fill_price_backfill.py @@ -148,6 +148,7 @@ def _make_filled_status( execution_id=execution_id, fee=fee, tax=tax, + execution_venue="KRX", ) @@ -167,6 +168,7 @@ def _make_partial_cancel_status( execution_id=execution_id, fee=fee, tax=tax, + execution_venue="KRX", cancelled=True, ) diff --git a/tests/application/test_execution_ledger.py b/tests/application/test_execution_ledger.py index c641ed9..d2bc75b 100644 --- a/tests/application/test_execution_ledger.py +++ b/tests/application/test_execution_ledger.py @@ -59,7 +59,8 @@ def test_buy_fill_enqueues_one_ledger_item_and_writer_records_it(): assert (t.asset_id, t.side, t.qty, t.price) == ("PF-1", "buy", 10, 1000.0) assert t.execution_id.startswith("paper:PAPER-") assert t.fee > 0.0 and t.tax == 0.0 - assert t.venue == "SOR" + assert t.order_venue == "SOR" + assert t.venue == "KRX" # paper's deterministic SOR execution exchange def test_sell_fill_enqueues_sell_ledger_item(): diff --git a/tests/application/test_venue_intent_flow.py b/tests/application/test_venue_intent_flow.py index b943e2a..95363ef 100644 --- a/tests/application/test_venue_intent_flow.py +++ b/tests/application/test_venue_intent_flow.py @@ -33,7 +33,7 @@ class RecordingBroker: return OrderAck( accepted=True, broker_order_id=f"order-{len(self.orders)}", - venue=self.actual_venue or req.venue, + venue=req.venue, ) def poll_fill(self, broker_order_id: str, *, side: str, symbol: str) -> FillStatus: @@ -121,7 +121,7 @@ def test_invalid_venue_is_rejected_before_slot_transition(invalid_venue: str) -> assert repo.list_slots("default")[0].status is SlotState.INACTIVE -def test_omitted_wire_venue_is_sor_then_actual_venue_is_persisted() -> None: +def test_omitted_wire_venue_remains_sor_for_cancel_and_restart() -> None: repo = InMemorySlotRepository() service = SlotService(repo) intents = IntentService( @@ -153,7 +153,9 @@ def test_omitted_wire_venue_is_sor_then_actual_venue_is_persisted() -> None: execution.run_once() dispatched = repo.fetch_slot(slot_id) assert broker.orders[0].venue == "SOR" - assert dispatched is not None and dispatched.venue == "NXT" + # Ack cannot turn SOR routing provenance into an execution venue. Actual + # KRX/NXT is established by fill polling and stored on the ledger trade. + assert dispatched is not None and dispatched.venue == "SOR" def test_inactive_transition_resets_venue_to_krx() -> None: diff --git a/trex_core/adapters/broker/kis.py b/trex_core/adapters/broker/kis.py index 228cf5a..8a9e824 100644 --- a/trex_core/adapters/broker/kis.py +++ b/trex_core/adapters/broker/kis.py @@ -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, ) diff --git a/trex_core/adapters/broker/paper.py b/trex_core/adapters/broker/paper.py index 8b3582c..9f04f71 100644 --- a/trex_core/adapters/broker/paper.py +++ b/trex_core/adapters/broker/paper.py @@ -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}, ) diff --git a/trex_core/adapters/persistence/postgresql.py b/trex_core/adapters/persistence/postgresql.py index 07c0a31..06bd717 100644 --- a/trex_core/adapters/persistence/postgresql.py +++ b/trex_core/adapters/persistence/postgresql.py @@ -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], diff --git a/trex_core/adapters/persistence/sql/024_add_ledger_order_venue.sql b/trex_core/adapters/persistence/sql/024_add_ledger_order_venue.sql new file mode 100644 index 0000000..a093d69 --- /dev/null +++ b/trex_core/adapters/persistence/sql/024_add_ledger_order_venue.sql @@ -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'; diff --git a/trex_core/application/broker_port.py b/trex_core/application/broker_port.py index 6ab70e4..b4a404b 100644 --- a/trex_core/application/broker_port.py +++ b/trex_core/application/broker_port.py @@ -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 diff --git a/trex_core/application/execution.py b/trex_core/application/execution.py index a3792a3..a10744e 100644 --- a/trex_core/application/execution.py +++ b/trex_core/application/execution.py @@ -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 diff --git a/trex_core/application/ledger_port.py b/trex_core/application/ledger_port.py index ee37273..8cd2924 100644 --- a/trex_core/application/ledger_port.py +++ b/trex_core/application/ledger_port.py @@ -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: