fix(ledger): retain SOR fills with unknown venue
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user