From 9da8c27e52e627e138e1e28c477d7551b99c8db6 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Mon, 24 Aug 2026 09:47:21 +0900 Subject: [PATCH] fix(ledger): retain SOR fills with unknown venue --- tests/adapters/broker/test_venue.py | 20 ++++++ tests/application/test_execution_ledger.py | 76 ++++++++++++++++++++++ trex_core/application/execution.py | 5 +- 3 files changed, 99 insertions(+), 2 deletions(-) diff --git a/tests/adapters/broker/test_venue.py b/tests/adapters/broker/test_venue.py index e7e3da8..737f31c 100644 --- a/tests/adapters/broker/test_venue.py +++ b/tests/adapters/broker/test_venue.py @@ -175,6 +175,26 @@ def test_kis_fill_status_carries_sor_actual_execution_venue(): assert status.execution_venue == "NXT" +def test_kis_sor_fill_without_actual_exchange_keeps_execution_venue_unknown(): + status = KISBroker._fill_status_from_row( + { + "ord_qty": "10", + "tot_ccld_qty": "10", + "avg_prvs": "70000", + "ord_dt": "20260824", + "ord_gno_brno": "12345", + "odno": "0000000002", + "excg_id_dvsn_cd": "SOR", + }, + side="buy", + today="20260824", + fallback_odno="0000000002", + ) + + assert status.is_filled + assert status.execution_venue is None + + # --------------------------------------------------------------------------- # REQ-117 AC-4: 기존 위치 인수 순서 불변 # --------------------------------------------------------------------------- diff --git a/tests/application/test_execution_ledger.py b/tests/application/test_execution_ledger.py index d2bc75b..ce15c44 100644 --- a/tests/application/test_execution_ledger.py +++ b/tests/application/test_execution_ledger.py @@ -6,10 +6,12 @@ critical path while the ledger write is eventually-consistent. """ from __future__ import annotations +from trex_core.adapters.broker.kis import KISBroker from trex_core.adapters.broker.paper import PaperBroker from trex_core.adapters.ledger.in_memory import InMemoryLedger from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore from trex_core.application.execution import ExecutionService +from trex_core.application.broker_port import FillStatus from trex_core.application.intents import BuyIntent, IntentService, SellIntent from trex_core.application.ledger_outbox import ( InMemoryLedgerOutbox, @@ -44,6 +46,36 @@ def _buy(intents, *, iid="b1", portfolio_id="PF-1"): )) +class KisSorUnknownVenueBroker(PaperBroker): + """KIS daily-ccld fixture with SOR routing but no actual exchange evidence.""" + + def poll_fill(self, broker_order_id, *, side, symbol, **kwargs): + return KISBroker._fill_status_from_row( + { + "ord_qty": "10", + "tot_ccld_qty": "10", + "avg_prvs": "1000", + "ord_dt": "20260824", + "ord_gno_brno": "12345678", + "odno": broker_order_id, + "excg_id_dvsn_cd": "SOR", + }, + side=side, + today="20260824", + fallback_odno=broker_order_id, + ) + + +class MissingExecutionIdBroker(PaperBroker): + def poll_fill(self, broker_order_id, *, side, symbol, **kwargs): + return FillStatus( + state="filled", + filled_qty=10, + avg_price=1000, + execution_venue="KRX", + ) + + def test_buy_fill_enqueues_one_ledger_item_and_writer_records_it(): ledger = InMemoryLedger() repo, svc, intents, execution, outbox, writer = _wire(PaperBroker(fill_after_polls=0), @@ -63,6 +95,36 @@ def test_buy_fill_enqueues_one_ledger_item_and_writer_records_it(): assert t.venue == "KRX" # paper's deterministic SOR execution exchange +def test_kis_sor_fill_without_actual_venue_enqueues_and_writes_once() -> None: + """Issues #65/#66: SOR provenance must not suppress an unknown actual venue fill.""" + ledger = InMemoryLedger() + repo, _, intents, execution, outbox, writer = _wire(KisSorUnknownVenueBroker(), ledger=ledger) + + sid = _buy(intents, iid="sor-unknown").slot_id + result = execution.run_once() + + assert result.filled == 1 + assert result.ledger_enqueued == 1 + assert result.ledger_skipped == 0 + assert repo.fetch_slot(sid).status is SlotState.HOLDING + [item] = outbox.all_items() + assert item.trade.venue is None + assert item.trade.order_venue == "SOR" + + assert writer.run_once().completed == 1 + assert ledger.count() == 1 + [trade] = ledger.trades() + assert trade.execution_id == item.trade.execution_id + assert trade.venue is None + assert trade.order_venue == "SOR" + + # Replay remains idempotent at the durable outbox and portfolio ledger boundaries. + outbox.enqueue(item.trade) + assert len(outbox.all_items()) == 1 + assert writer.run_once().completed == 0 + assert ledger.count() == 1 + + def test_sell_fill_enqueues_sell_ledger_item(): ledger = InMemoryLedger() repo, svc, intents, execution, outbox, writer = _wire(PaperBroker(fill_after_polls=0), @@ -100,6 +162,20 @@ def test_portfolio_id_absent_skips_ledger_but_transitions_slot(): assert ledger.count() == 0 +def test_missing_execution_id_still_skips_malformed_ledger_work_item() -> None: + repo, _, intents, execution, outbox, _ = _wire(MissingExecutionIdBroker()) + + sid = _buy(intents, iid="missing-execution-id").slot_id + assert sid is not None + result = execution.run_once() + + assert result.filled == 1 + assert result.ledger_enqueued == 0 + assert result.ledger_skipped == 1 + assert repo.fetch_slot(sid).status is SlotState.HOLDING + assert outbox.all_items() == [] + + def test_ledger_skip_emits_warning(caplog): """dcy-011 / REQ-018 FR-4 (S-3): ledger skip is logged, not silent.""" import logging diff --git a/trex_core/application/execution.py b/trex_core/application/execution.py index a10744e..ce84d14 100644 --- a/trex_core/application/execution.py +++ b/trex_core/application/execution.py @@ -776,14 +776,15 @@ class ExecutionService: 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"}: + if execution_venue is not None and 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", + "reason=unsupported_execution_venue execution_venue=%s order_venue=%s", slot.slot_id, slot.portfolio_id, side, + execution_venue, slot.venue, ) return