fix(ledger): retain SOR fills with unknown venue

This commit is contained in:
Hermes Agent
2026-08-24 09:47:21 +09:00
parent f8ad591f0d
commit 9da8c27e52
3 changed files with 99 additions and 2 deletions
+20
View File
@@ -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: 기존 위치 인수 순서 불변
# ---------------------------------------------------------------------------
@@ -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
+3 -2
View File
@@ -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