feat: persist skipped ledger fills
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
This commit is contained in:
co-authored by
genichin
parent
df0258bc36
commit
76cde2edd0
@@ -0,0 +1,89 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, date, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from trex_core.adapters.persistence.ledger_skip_store import PostgreSQLLedgerSkipStore
|
||||
|
||||
|
||||
def test_ledger_skip_store_round_trips_and_dedupes(pg_clean):
|
||||
store = PostgreSQLLedgerSkipStore(pg_clean)
|
||||
slot_id = uuid4()
|
||||
|
||||
record, notify = store.record_skip(
|
||||
instance_key="inst-1",
|
||||
slot_id=slot_id,
|
||||
portfolio_id="PF-1",
|
||||
symbol="005930",
|
||||
order_id="O-1",
|
||||
execution_id=None,
|
||||
side="buy",
|
||||
qty=10.0,
|
||||
price=1000.0,
|
||||
executed_at=datetime(2026, 8, 24, 0, 0, tzinfo=UTC),
|
||||
order_venue="SOR",
|
||||
execution_venue="unknown",
|
||||
skip_reason="missing_execution_id",
|
||||
)
|
||||
again, notify_again = store.record_skip(
|
||||
instance_key="inst-1",
|
||||
slot_id=slot_id,
|
||||
portfolio_id="PF-1",
|
||||
symbol="005930",
|
||||
order_id="O-1",
|
||||
execution_id=None,
|
||||
side="buy",
|
||||
qty=10.0,
|
||||
price=1000.0,
|
||||
executed_at=datetime(2026, 8, 24, 0, 0, tzinfo=UTC),
|
||||
order_venue="SOR",
|
||||
execution_venue="unknown",
|
||||
skip_reason="missing_execution_id",
|
||||
)
|
||||
|
||||
assert notify is True
|
||||
assert notify_again is False
|
||||
assert again.skip_key == record.skip_key
|
||||
listed = store.list_records(instance_key="inst-1", status="unresolved")
|
||||
assert len(listed) == 1
|
||||
assert listed[0].skip_reason == "missing_execution_id"
|
||||
|
||||
|
||||
def test_ledger_skip_store_filters_and_resolves(pg_clean):
|
||||
store = PostgreSQLLedgerSkipStore(pg_clean)
|
||||
slot_id = uuid4()
|
||||
record, _ = store.record_skip(
|
||||
instance_key="inst-1",
|
||||
slot_id=slot_id,
|
||||
portfolio_id="PF-2",
|
||||
symbol="005930",
|
||||
order_id="O-2",
|
||||
execution_id="E-2",
|
||||
side="sell",
|
||||
qty=5.0,
|
||||
price=2000.0,
|
||||
executed_at=datetime(2026, 8, 24, 2, 0, tzinfo=UTC),
|
||||
order_venue="KRX",
|
||||
execution_venue="NXTX",
|
||||
skip_reason="unsupported_execution_venue",
|
||||
)
|
||||
|
||||
rows = store.list_records(
|
||||
instance_key="inst-1",
|
||||
portfolio_id="PF-2",
|
||||
on_date=date(2026, 8, 24),
|
||||
status="unresolved",
|
||||
)
|
||||
assert [row.skip_key for row in rows] == [record.skip_key]
|
||||
|
||||
resolved = store.resolve_for_fill(
|
||||
instance_key="inst-1",
|
||||
slot_id=slot_id,
|
||||
side="sell",
|
||||
order_id="O-2",
|
||||
execution_id="E-2",
|
||||
resolution_reason="manual_ledger_backfill",
|
||||
)
|
||||
assert len(resolved) == 1
|
||||
assert resolved[0].status == "resolved"
|
||||
assert resolved[0].resolution_reason == "manual_ledger_backfill"
|
||||
@@ -26,6 +26,7 @@ _EXPECTED_TABLES = {
|
||||
"ledger_outbox", # BAT-006 / REQ-019
|
||||
"snapshot_pause", # BAT-008 / REQ-032
|
||||
"broker_account_snapshot", # dcy-041 / GitHub #45
|
||||
"ledger_skip", # GitHub #68 skipped-ledger durable audit
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -6,12 +6,16 @@ critical path while the ledger write is eventually-consistent.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC
|
||||
|
||||
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.messaging.in_memory import InMemoryTransport
|
||||
from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore
|
||||
from trex_core.application.execution import ExecutionService
|
||||
from trex_core.application.alarm_emitter import AlarmEmitter
|
||||
from trex_core.application.broker_port import FillStatus
|
||||
from trex_core.application.execution import ExecutionService
|
||||
from trex_core.application.intents import BuyIntent, IntentService, SellIntent
|
||||
from trex_core.application.ledger_outbox import (
|
||||
InMemoryLedgerOutbox,
|
||||
@@ -19,18 +23,62 @@ from trex_core.application.ledger_outbox import (
|
||||
OutboxOutcome,
|
||||
WorkItemStatus,
|
||||
)
|
||||
from trex_core.application.ledger_skip import LedgerSkipRecord
|
||||
from trex_core.domain.slot import SlotService, SlotState
|
||||
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
|
||||
|
||||
|
||||
class _SkipStore:
|
||||
def __init__(self):
|
||||
self.records = []
|
||||
self._by_key = {}
|
||||
|
||||
def _wire(broker, *, ledger=None, clock=None):
|
||||
def record_skip(self, **kwargs):
|
||||
record = LedgerSkipRecord(
|
||||
skip_key=f"{kwargs['slot_id']}:{kwargs['side']}:{kwargs['skip_reason']}",
|
||||
instance_key=kwargs["instance_key"],
|
||||
slot_id=kwargs["slot_id"],
|
||||
portfolio_id=kwargs["portfolio_id"],
|
||||
symbol=kwargs["symbol"],
|
||||
order_id=kwargs["order_id"],
|
||||
execution_id=kwargs["execution_id"],
|
||||
side=kwargs["side"],
|
||||
qty=kwargs["qty"],
|
||||
price=kwargs["price"],
|
||||
executed_at=kwargs["executed_at"],
|
||||
order_venue=kwargs["order_venue"],
|
||||
execution_venue=kwargs["execution_venue"],
|
||||
skip_reason=kwargs["skip_reason"],
|
||||
status="unresolved",
|
||||
resolution_reason=None,
|
||||
resolved_at=None,
|
||||
created_at=kwargs["executed_at"],
|
||||
updated_at=kwargs["executed_at"],
|
||||
)
|
||||
should_notify = record.skip_key not in self._by_key
|
||||
self._by_key[record.skip_key] = record
|
||||
self.records = list(self._by_key.values())
|
||||
return record, should_notify
|
||||
|
||||
def resolve_for_fill(self, **_kwargs):
|
||||
return []
|
||||
|
||||
|
||||
|
||||
def _wire(broker, *, ledger=None, clock=None, alarm_emitter=None, ledger_skip_store=None):
|
||||
repo = InMemorySlotRepository()
|
||||
svc = SlotService(repo)
|
||||
intents = IntentService(svc, repo, InMemoryIntentStore(),
|
||||
resolve_symbol=lambda pid: "005930")
|
||||
outbox = InMemoryLedgerOutbox(clock=clock) if clock else InMemoryLedgerOutbox()
|
||||
execution = ExecutionService(svc, repo, broker, ledger_outbox=outbox)
|
||||
execution = ExecutionService(
|
||||
svc,
|
||||
repo,
|
||||
broker,
|
||||
ledger_outbox=outbox,
|
||||
alarm_emitter=alarm_emitter,
|
||||
ledger_skip_store=ledger_skip_store,
|
||||
)
|
||||
writer = (
|
||||
LedgerWriter(outbox, ledger or InMemoryLedger(), clock=clock)
|
||||
if clock else LedgerWriter(outbox, ledger or InMemoryLedger())
|
||||
@@ -78,8 +126,10 @@ class MissingExecutionIdBroker(PaperBroker):
|
||||
|
||||
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),
|
||||
ledger=ledger)
|
||||
repo, _, intents, execution, _, writer = _wire(
|
||||
PaperBroker(fill_after_polls=0),
|
||||
ledger=ledger,
|
||||
)
|
||||
sid = _buy(intents).slot_id
|
||||
r = execution.run_once()
|
||||
assert r.filled == 1 and r.ledger_enqueued == 1
|
||||
@@ -127,8 +177,10 @@ def test_kis_sor_fill_without_actual_venue_enqueues_and_writes_once() -> None:
|
||||
|
||||
def test_sell_fill_enqueues_sell_ledger_item():
|
||||
ledger = InMemoryLedger()
|
||||
repo, svc, intents, execution, outbox, writer = _wire(PaperBroker(fill_after_polls=0),
|
||||
ledger=ledger)
|
||||
repo, _, intents, execution, _, writer = _wire(
|
||||
PaperBroker(fill_after_polls=0),
|
||||
ledger=ledger,
|
||||
)
|
||||
sid = _buy(intents).slot_id
|
||||
execution.run_once() # -> holding + buy ledger item
|
||||
writer.run_once() # drain buy
|
||||
@@ -143,13 +195,16 @@ def test_sell_fill_enqueues_sell_ledger_item():
|
||||
|
||||
def test_portfolio_id_absent_skips_ledger_but_transitions_slot():
|
||||
ledger = InMemoryLedger()
|
||||
repo, svc, intents, execution, outbox, writer = _wire(PaperBroker(fill_after_polls=0),
|
||||
ledger=ledger)
|
||||
repo, svc, _, execution, outbox, _ = _wire(
|
||||
PaperBroker(fill_after_polls=0),
|
||||
ledger=ledger,
|
||||
)
|
||||
# dcy-011: buy admission now requires portfolio_id, so create the
|
||||
# pending_buy slot directly (bypass admission) to exercise the
|
||||
# execution-loop ledger skip + visibility (REQ-018 FR-4).
|
||||
from trex_core.domain.slot.models import Slot
|
||||
from uuid import uuid4
|
||||
|
||||
from trex_core.domain.slot.models import Slot
|
||||
anon = Slot(slot_id=uuid4(), status=SlotState.INACTIVE)
|
||||
repo.add_slot(anon)
|
||||
svc.transit_slot(anon.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
@@ -176,14 +231,80 @@ def test_missing_execution_id_still_skips_malformed_ledger_work_item() -> None:
|
||||
assert outbox.all_items() == []
|
||||
|
||||
|
||||
def test_missing_portfolio_id_persists_skip_and_emits_alert() -> None:
|
||||
transport = InMemoryTransport()
|
||||
skip_store = _SkipStore()
|
||||
repo, svc, _, execution, outbox, _ = _wire(
|
||||
PaperBroker(fill_after_polls=0),
|
||||
alarm_emitter=AlarmEmitter(transport),
|
||||
ledger_skip_store=skip_store,
|
||||
)
|
||||
from uuid import uuid4
|
||||
|
||||
from trex_core.domain.slot.models import Slot
|
||||
|
||||
anon = Slot(slot_id=uuid4(), status=SlotState.INACTIVE)
|
||||
repo.add_slot(anon)
|
||||
svc.transit_slot(
|
||||
anon.slot_id,
|
||||
SlotState.INACTIVE,
|
||||
SlotState.PENDING_BUY,
|
||||
reason="buy",
|
||||
actor="test",
|
||||
symbol="005930",
|
||||
portfolio_id=None,
|
||||
)
|
||||
|
||||
result = execution.run_once()
|
||||
|
||||
assert result.ledger_skipped == 1
|
||||
assert outbox.all_items() == []
|
||||
assert len(skip_store.records) == 1
|
||||
record = skip_store.records[0]
|
||||
assert record.skip_reason == "missing_portfolio_id"
|
||||
assert record.order_venue == "KRX"
|
||||
alarms = transport.alarm_events()
|
||||
assert len(alarms) == 1
|
||||
_, payload = alarms[0]
|
||||
assert payload["code"] == "ledger_skip_unresolved"
|
||||
assert payload["fields"]["skip_reason"] == "missing_portfolio_id"
|
||||
|
||||
|
||||
def test_missing_execution_id_persists_skip_record() -> None:
|
||||
transport = InMemoryTransport()
|
||||
skip_store = _SkipStore()
|
||||
repo, _, intents, execution, outbox, _ = _wire(
|
||||
MissingExecutionIdBroker(),
|
||||
alarm_emitter=AlarmEmitter(transport),
|
||||
ledger_skip_store=skip_store,
|
||||
)
|
||||
|
||||
sid = _buy(intents, iid="missing-execution-id").slot_id
|
||||
assert sid is not None
|
||||
result = execution.run_once()
|
||||
|
||||
assert result.ledger_skipped == 1
|
||||
assert result.ledger_enqueued == 0
|
||||
assert repo.fetch_slot(sid).status is SlotState.HOLDING
|
||||
assert outbox.all_items() == []
|
||||
assert len(skip_store.records) == 1
|
||||
record = skip_store.records[0]
|
||||
assert record.skip_reason == "missing_execution_id"
|
||||
assert record.portfolio_id == "PF-1"
|
||||
assert record.execution_id is None
|
||||
|
||||
|
||||
def test_ledger_skip_emits_warning(caplog):
|
||||
"""dcy-011 / REQ-018 FR-4 (S-3): ledger skip is logged, not silent."""
|
||||
import logging
|
||||
ledger = InMemoryLedger()
|
||||
repo, svc, intents, execution, outbox, writer = _wire(PaperBroker(fill_after_polls=0),
|
||||
ledger=ledger)
|
||||
from trex_core.domain.slot.models import Slot
|
||||
repo, svc, _, execution, _, _ = _wire(
|
||||
PaperBroker(fill_after_polls=0),
|
||||
ledger=ledger,
|
||||
)
|
||||
from uuid import uuid4
|
||||
|
||||
from trex_core.domain.slot.models import Slot
|
||||
anon = Slot(slot_id=uuid4(), status=SlotState.INACTIVE)
|
||||
repo.add_slot(anon)
|
||||
svc.transit_slot(anon.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
@@ -197,8 +318,10 @@ def test_ledger_skip_emits_warning(caplog):
|
||||
def test_duplicate_execution_id_results_in_zero_duplicate_in_ledger():
|
||||
"""REQ-019 AC-1: same execution_id processed twice = 1 ledger entry."""
|
||||
ledger = InMemoryLedger()
|
||||
repo, svc, intents, execution, outbox, writer = _wire(PaperBroker(fill_after_polls=0),
|
||||
ledger=ledger)
|
||||
_, _, intents, execution, outbox, writer = _wire(
|
||||
PaperBroker(fill_after_polls=0),
|
||||
ledger=ledger,
|
||||
)
|
||||
_buy(intents)
|
||||
execution.run_once()
|
||||
writer.run_once()
|
||||
@@ -224,10 +347,10 @@ def test_retryable_failure_then_eventually_completes():
|
||||
dcy-015: retryable rows back off, so advance a shared clock past each
|
||||
next_retry_at between writer passes.
|
||||
"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
now = {"t": datetime(2026, 6, 4, tzinfo=timezone.utc)}
|
||||
from datetime import datetime, timedelta
|
||||
now = {"t": datetime(2026, 6, 4, tzinfo=UTC)}
|
||||
ledger = InMemoryLedger(fail_n_then_succeed=2)
|
||||
repo, svc, intents, execution, outbox, writer = _wire(
|
||||
repo, _, intents, execution, outbox, writer = _wire(
|
||||
PaperBroker(fill_after_polls=0), ledger=ledger, clock=lambda: now["t"],
|
||||
)
|
||||
sid = _buy(intents).slot_id
|
||||
@@ -253,8 +376,10 @@ def test_conflict_blocks_without_auto_retry():
|
||||
# order id is PAPER-000001 / qty=10 => execution_id = paper:PAPER-000001:10.
|
||||
conflict_id = "paper:PAPER-000001:10"
|
||||
ledger = InMemoryLedger(conflict_executions={conflict_id})
|
||||
repo, svc, intents, execution, outbox, writer = _wire(PaperBroker(fill_after_polls=0),
|
||||
ledger=ledger)
|
||||
_, _, intents, execution, outbox, writer = _wire(
|
||||
PaperBroker(fill_after_polls=0),
|
||||
ledger=ledger,
|
||||
)
|
||||
_buy(intents)
|
||||
execution.run_once()
|
||||
stats = writer.run_once()
|
||||
@@ -271,8 +396,10 @@ def test_conflict_blocks_without_auto_retry():
|
||||
def test_duplicate_completed_when_reprocessing_terminal_item():
|
||||
"""REQ-019 AC-4: a completed item reports duplicate_completed if re-listed."""
|
||||
ledger = InMemoryLedger()
|
||||
repo, svc, intents, execution, outbox, writer = _wire(PaperBroker(fill_after_polls=0),
|
||||
ledger=ledger)
|
||||
_, _, intents, execution, outbox, writer = _wire(
|
||||
PaperBroker(fill_after_polls=0),
|
||||
ledger=ledger,
|
||||
)
|
||||
_buy(intents)
|
||||
execution.run_once()
|
||||
writer.run_once()
|
||||
@@ -290,8 +417,10 @@ def test_duplicate_completed_when_reprocessing_terminal_item():
|
||||
def test_slot_transition_proceeds_when_ledger_is_down():
|
||||
"""REQ-019 AC-5: portfolio-hub outage does not block the fill transition."""
|
||||
ledger = InMemoryLedger(fail_n_then_succeed=100)
|
||||
repo, svc, intents, execution, outbox, writer = _wire(PaperBroker(fill_after_polls=0),
|
||||
ledger=ledger)
|
||||
repo, _, intents, execution, outbox, writer = _wire(
|
||||
PaperBroker(fill_after_polls=0),
|
||||
ledger=ledger,
|
||||
)
|
||||
sid = _buy(intents).slot_id
|
||||
execution.run_once()
|
||||
assert repo.fetch_slot(sid).status is SlotState.HOLDING
|
||||
|
||||
@@ -18,6 +18,7 @@ from trex_core.adapters.broker.kis import KISBroker
|
||||
|
||||
_PG_TABLES = (
|
||||
"broker_account_snapshot",
|
||||
"ledger_skip",
|
||||
"killswitch_exclusion",
|
||||
"snapshot_pause",
|
||||
"ledger_outbox",
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
from uuid import UUID
|
||||
|
||||
from trex_core.application.ledger_outbox import OutboxOutcome
|
||||
from trex_core.application.ledger_port import LedgerTrade
|
||||
from trex_core.application.ledger_skip import LedgerSkipRecord
|
||||
from trex_core.runtime import cli
|
||||
|
||||
|
||||
@@ -63,6 +66,33 @@ def _evidence():
|
||||
}
|
||||
|
||||
|
||||
def _skip_record():
|
||||
return LedgerSkipRecord(
|
||||
skip_key="ledger-skip:inst-1:slot-1:sell:12345678:0001234567:kis-daily-ccld:20260824:12345678:0001234567:10:missing_execution_id",
|
||||
instance_key="inst-1",
|
||||
slot_id=UUID(_SLOT_ID),
|
||||
portfolio_id="PF-1",
|
||||
symbol="005930",
|
||||
order_id="12345678:0001234567",
|
||||
execution_id="kis-daily-ccld:20260824:12345678:0001234567:10",
|
||||
side="sell",
|
||||
qty=10.0,
|
||||
price=70000.0,
|
||||
executed_at=datetime.fromisoformat("2026-08-24T00:01:02+00:00"),
|
||||
order_venue="SOR",
|
||||
execution_venue="unknown",
|
||||
skip_reason="missing_execution_id",
|
||||
status="resolved",
|
||||
resolution_reason="manual_ledger_backfill",
|
||||
resolved_at=datetime.fromisoformat("2026-08-24T00:03:02+00:00"),
|
||||
created_at=datetime.fromisoformat("2026-08-24T00:01:02+00:00"),
|
||||
updated_at=datetime.fromisoformat("2026-08-24T00:03:02+00:00"),
|
||||
)
|
||||
|
||||
|
||||
_SLOT_ID = "00000000-0000-0000-0000-000000000001"
|
||||
|
||||
|
||||
def test_ledger_backfill_parser_accepts_required_flags():
|
||||
parser = cli._build_parser()
|
||||
args = parser.parse_args([
|
||||
@@ -80,7 +110,7 @@ def test_ledger_backfill_dry_run_emits_trade_and_writes_audit(monkeypatch, tmp_p
|
||||
monkeypatch.setattr(cli, "_lookup_ledger_backfill_evidence",
|
||||
lambda cfg, **kwargs: (_evidence(), SimpleNamespace(mode="env", identity_label="kis-config")))
|
||||
monkeypatch.setattr(cli, "_resolve_ledger_backfill_slot",
|
||||
lambda dsn, **kwargs: {"slot_id": "slot-1", "portfolio_id": "PF-1", "status": "inactive", "symbol": "005930"})
|
||||
lambda dsn, **kwargs: {"slot_id": _SLOT_ID, "portfolio_id": "PF-1", "status": "inactive", "symbol": "005930"})
|
||||
monkeypatch.setattr(cli, "_lookup_outbox_by_execution_id", lambda dsn, **kwargs: [])
|
||||
|
||||
rc = cli._cmd_ledger_backfill(_args(tmp_path, dry_run=True), _cfg(), lambda _: True)
|
||||
@@ -99,7 +129,7 @@ def test_ledger_backfill_live_requires_live_flag(monkeypatch, tmp_path, capsys):
|
||||
monkeypatch.setattr(cli, "_lookup_ledger_backfill_evidence",
|
||||
lambda cfg, **kwargs: (_evidence(), SimpleNamespace(mode="env", identity_label="kis-config")))
|
||||
monkeypatch.setattr(cli, "_resolve_ledger_backfill_slot",
|
||||
lambda dsn, **kwargs: {"slot_id": "slot-1", "portfolio_id": "PF-1", "status": "inactive", "symbol": "005930"})
|
||||
lambda dsn, **kwargs: {"slot_id": _SLOT_ID, "portfolio_id": "PF-1", "status": "inactive", "symbol": "005930"})
|
||||
monkeypatch.setattr(cli, "_lookup_outbox_by_execution_id", lambda dsn, **kwargs: [])
|
||||
|
||||
rc = cli._cmd_ledger_backfill(_args(tmp_path, live=False), _cfg(live=True), lambda _: True)
|
||||
@@ -115,7 +145,7 @@ def test_ledger_backfill_existing_completed_row_is_idempotent(monkeypatch, tmp_p
|
||||
monkeypatch.setattr(cli, "_lookup_ledger_backfill_evidence",
|
||||
lambda cfg, **kwargs: (_evidence(), SimpleNamespace(mode="env", identity_label="kis-config")))
|
||||
monkeypatch.setattr(cli, "_resolve_ledger_backfill_slot",
|
||||
lambda dsn, **kwargs: {"slot_id": "slot-1", "portfolio_id": "PF-1", "status": "inactive", "symbol": "005930"})
|
||||
lambda dsn, **kwargs: {"slot_id": _SLOT_ID, "portfolio_id": "PF-1", "status": "inactive", "symbol": "005930"})
|
||||
existing = [{
|
||||
"correlation_id": "PF-1:12345678:0001234567:kis-daily-ccld:20260824:12345678:0001234567:10",
|
||||
"asset_id": "PF-1",
|
||||
@@ -147,12 +177,23 @@ def test_ledger_backfill_existing_completed_row_is_idempotent(monkeypatch, tmp_p
|
||||
def process_correlation_id(self, correlation_id):
|
||||
raise AssertionError("completed execution must not be re-processed")
|
||||
|
||||
class _Store:
|
||||
def __init__(self, *_args, **_kwargs):
|
||||
pass
|
||||
|
||||
def resolve_for_fill(self, **_kwargs):
|
||||
return []
|
||||
|
||||
import trex_core.adapters.ledger.portfolio_hub as hub_mod
|
||||
import trex_core.adapters.persistence.postgresql as pg_mod
|
||||
|
||||
monkeypatch.setattr(pg_mod, "PostgreSQLLedgerOutbox", lambda dsn: _Outbox())
|
||||
monkeypatch.setattr(hub_mod, "PortfolioHubLedger", lambda *args, **kwargs: object())
|
||||
monkeypatch.setattr(cli, "LedgerWriter", _Writer)
|
||||
monkeypatch.setattr(
|
||||
"trex_core.adapters.persistence.ledger_skip_store.PostgreSQLLedgerSkipStore",
|
||||
_Store,
|
||||
)
|
||||
|
||||
rc = cli._cmd_ledger_backfill(_args(tmp_path), _cfg(), lambda _: True)
|
||||
|
||||
@@ -165,7 +206,7 @@ def test_ledger_backfill_live_enqueue_and_processes_exact_trade(monkeypatch, tmp
|
||||
monkeypatch.setattr(cli, "_lookup_ledger_backfill_evidence",
|
||||
lambda cfg, **kwargs: (_evidence(), SimpleNamespace(mode="env", identity_label="kis-config")))
|
||||
monkeypatch.setattr(cli, "_resolve_ledger_backfill_slot",
|
||||
lambda dsn, **kwargs: {"slot_id": "slot-1", "portfolio_id": "PF-1", "status": "inactive", "symbol": "005930"})
|
||||
lambda dsn, **kwargs: {"slot_id": _SLOT_ID, "portfolio_id": "PF-1", "status": "inactive", "symbol": "005930"})
|
||||
monkeypatch.setattr(cli, "_lookup_outbox_by_execution_id", lambda dsn, **kwargs: [])
|
||||
|
||||
class _Outbox:
|
||||
@@ -195,12 +236,23 @@ def test_ledger_backfill_live_enqueue_and_processes_exact_trade(monkeypatch, tmp
|
||||
assert correlation_id == self.seen_outbox.trade.correlation_id
|
||||
return OutboxOutcome.COMPLETED
|
||||
|
||||
class _Store:
|
||||
def __init__(self, *_args, **_kwargs):
|
||||
pass
|
||||
|
||||
def resolve_for_fill(self, **_kwargs):
|
||||
return []
|
||||
|
||||
import trex_core.adapters.ledger.portfolio_hub as hub_mod
|
||||
import trex_core.adapters.persistence.postgresql as pg_mod
|
||||
|
||||
monkeypatch.setattr(pg_mod, "PostgreSQLLedgerOutbox", lambda dsn: outbox)
|
||||
monkeypatch.setattr(hub_mod, "PortfolioHubLedger", lambda *args, **kwargs: object())
|
||||
monkeypatch.setattr(cli, "LedgerWriter", _Writer)
|
||||
monkeypatch.setattr(
|
||||
"trex_core.adapters.persistence.ledger_skip_store.PostgreSQLLedgerSkipStore",
|
||||
_Store,
|
||||
)
|
||||
|
||||
rc = cli._cmd_ledger_backfill(_args(tmp_path), _cfg(), lambda _: True)
|
||||
|
||||
@@ -210,3 +262,67 @@ def test_ledger_backfill_live_enqueue_and_processes_exact_trade(monkeypatch, tmp
|
||||
assert outbox.trade is not None
|
||||
assert outbox.trade.order_venue == "SOR"
|
||||
assert outbox.trade.venue is None
|
||||
|
||||
|
||||
def test_ledger_backfill_resolves_matching_skip(monkeypatch, tmp_path, capsys):
|
||||
monkeypatch.setattr(cli, "_lookup_ledger_backfill_evidence",
|
||||
lambda cfg, **kwargs: (_evidence(), SimpleNamespace(mode="env", identity_label="kis-config")))
|
||||
monkeypatch.setattr(cli, "_resolve_ledger_backfill_slot",
|
||||
lambda dsn, **kwargs: {"slot_id": _SLOT_ID, "portfolio_id": "PF-1", "status": "inactive", "symbol": "005930"})
|
||||
monkeypatch.setattr(cli, "_lookup_outbox_by_execution_id", lambda dsn, **kwargs: [])
|
||||
|
||||
class _Outbox:
|
||||
def enqueue(self, trade: LedgerTrade):
|
||||
self.trade = trade
|
||||
|
||||
def get(self, correlation_id):
|
||||
return SimpleNamespace(
|
||||
status=SimpleNamespace(value="completed"),
|
||||
attempts=1,
|
||||
last_outcome=SimpleNamespace(value="completed"),
|
||||
last_error=None,
|
||||
)
|
||||
|
||||
class _Writer:
|
||||
def __init__(self, *_args, **_kwargs):
|
||||
pass
|
||||
|
||||
def process_correlation_id(self, _correlation_id):
|
||||
return OutboxOutcome.COMPLETED
|
||||
|
||||
class _Store:
|
||||
def __init__(self, *_args, **_kwargs):
|
||||
pass
|
||||
|
||||
def resolve_for_fill(self, **_kwargs):
|
||||
return [_skip_record()]
|
||||
|
||||
published = []
|
||||
|
||||
class _Emitter:
|
||||
def publish_alarm(self, code, title, *, fields=None, severity="critical"):
|
||||
published.append((code, severity, fields))
|
||||
|
||||
class _Transport:
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
import trex_core.adapters.ledger.portfolio_hub as hub_mod
|
||||
import trex_core.adapters.persistence.postgresql as pg_mod
|
||||
|
||||
monkeypatch.setattr(pg_mod, "PostgreSQLLedgerOutbox", lambda dsn: _Outbox())
|
||||
monkeypatch.setattr(hub_mod, "PortfolioHubLedger", lambda *args, **kwargs: object())
|
||||
monkeypatch.setattr(cli, "LedgerWriter", _Writer)
|
||||
monkeypatch.setattr(
|
||||
"trex_core.adapters.persistence.ledger_skip_store.PostgreSQLLedgerSkipStore",
|
||||
_Store,
|
||||
)
|
||||
monkeypatch.setattr(cli, "_default_alarm_emitter", lambda cfg: (_Emitter(), _Transport()))
|
||||
|
||||
rc = cli._cmd_ledger_backfill(_args(tmp_path), _cfg(), lambda _: True)
|
||||
|
||||
assert rc == 0
|
||||
out = json.loads(capsys.readouterr().out)
|
||||
assert len(out["resolved_skips"]) == 1
|
||||
assert out["resolved_skips"][0]["resolution_reason"] == "manual_ledger_backfill"
|
||||
assert published[0][0] == "ledger_skip_resolved"
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
from uuid import UUID
|
||||
|
||||
from trex_core.application.ledger_skip import LedgerSkipRecord
|
||||
from trex_core.runtime import cli
|
||||
|
||||
|
||||
def _cfg():
|
||||
return SimpleNamespace(dsn="postgresql://x/y", rabbitmq=None)
|
||||
|
||||
|
||||
def _record(*, status="unresolved", resolution_reason=None):
|
||||
return LedgerSkipRecord(
|
||||
skip_key="ledger-skip:inst-1:slot-1:sell:O-1:E-1:missing_execution_id",
|
||||
instance_key="inst-1",
|
||||
slot_id=UUID("00000000-0000-0000-0000-000000000001"),
|
||||
portfolio_id="PF-1",
|
||||
symbol="005930",
|
||||
order_id="O-1",
|
||||
execution_id="E-1",
|
||||
side="sell",
|
||||
qty=10.0,
|
||||
price=70000.0,
|
||||
executed_at=datetime(2026, 8, 24, 0, 1, 2, tzinfo=UTC),
|
||||
order_venue="SOR",
|
||||
execution_venue="unknown",
|
||||
skip_reason="missing_execution_id",
|
||||
status=status,
|
||||
resolution_reason=resolution_reason,
|
||||
resolved_at=(
|
||||
datetime(2026, 8, 24, 0, 5, 0, tzinfo=UTC)
|
||||
if status == "resolved"
|
||||
else None
|
||||
),
|
||||
created_at=datetime(2026, 8, 24, 0, 1, 2, tzinfo=UTC),
|
||||
updated_at=datetime(2026, 8, 24, 0, 5, 0, tzinfo=UTC),
|
||||
)
|
||||
|
||||
|
||||
def test_ledger_skips_parser_accepts_list_and_resolve():
|
||||
parser = cli._build_parser()
|
||||
args = parser.parse_args(["ledger", "skips", "list", "--status", "all"])
|
||||
assert args.ledger_cmd == "skips"
|
||||
assert args.ledger_skips_cmd == "list"
|
||||
args = parser.parse_args(["ledger", "skips", "resolve", "--skip-key", "abc"])
|
||||
assert args.ledger_skips_cmd == "resolve"
|
||||
|
||||
|
||||
def test_ledger_skips_list_outputs_records(monkeypatch, capsys):
|
||||
class _Store:
|
||||
def __init__(self, *_args, **_kwargs):
|
||||
pass
|
||||
|
||||
def list_records(self, **_kwargs):
|
||||
return [_record()]
|
||||
|
||||
monkeypatch.setattr(
|
||||
"trex_core.adapters.persistence.ledger_skip_store.PostgreSQLLedgerSkipStore",
|
||||
_Store,
|
||||
)
|
||||
args = SimpleNamespace(
|
||||
cmd="ledger",
|
||||
ledger_cmd="skips",
|
||||
ledger_skips_cmd="list",
|
||||
date="2026-08-24",
|
||||
portfolio_id="PF-1",
|
||||
status="unresolved",
|
||||
limit=10,
|
||||
instance="inst-1",
|
||||
config=None,
|
||||
config_path=None,
|
||||
env_file=None,
|
||||
json=True,
|
||||
timeout=10.0,
|
||||
)
|
||||
|
||||
rc = cli._cmd_ledger_skips_list(args, _cfg())
|
||||
|
||||
assert rc == 0
|
||||
out = json.loads(capsys.readouterr().out)
|
||||
assert out["count"] == 1
|
||||
assert out["records"][0]["skip_reason"] == "missing_execution_id"
|
||||
|
||||
|
||||
def test_ledger_skips_resolve_marks_record_and_emits_alert(monkeypatch, capsys):
|
||||
class _Store:
|
||||
def __init__(self, *_args, **_kwargs):
|
||||
pass
|
||||
|
||||
def resolve_by_key(self, skip_key, *, resolution_reason):
|
||||
assert skip_key == "abc"
|
||||
return _record(status="resolved", resolution_reason=resolution_reason)
|
||||
|
||||
published = []
|
||||
|
||||
class _Emitter:
|
||||
def publish_alarm(self, code, title, *, fields=None, severity="critical"):
|
||||
published.append((code, severity, fields))
|
||||
|
||||
class _Transport:
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(
|
||||
"trex_core.adapters.persistence.ledger_skip_store.PostgreSQLLedgerSkipStore",
|
||||
_Store,
|
||||
)
|
||||
monkeypatch.setattr(cli, "_default_alarm_emitter", lambda cfg: (_Emitter(), _Transport()))
|
||||
args = SimpleNamespace(
|
||||
cmd="ledger",
|
||||
ledger_cmd="skips",
|
||||
ledger_skips_cmd="resolve",
|
||||
skip_key="abc",
|
||||
reason="operator_reconciled",
|
||||
instance="inst-1",
|
||||
config=None,
|
||||
config_path=None,
|
||||
env_file=None,
|
||||
json=True,
|
||||
timeout=10.0,
|
||||
)
|
||||
|
||||
rc = cli._cmd_ledger_skips_resolve(args, _cfg())
|
||||
|
||||
assert rc == 0
|
||||
out = json.loads(capsys.readouterr().out)
|
||||
assert out["result"] == "resolved"
|
||||
assert published[0][0] == "ledger_skip_resolved"
|
||||
Reference in New Issue
Block a user