fix: harden skipped ledger resolution paths
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
This commit is contained in:
co-authored by
genichin
parent
b88b21b772
commit
48dfc3e76f
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from datetime import UTC, date, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
@@ -87,3 +88,76 @@ def test_ledger_skip_store_filters_and_resolves(pg_clean):
|
||||
assert len(resolved) == 1
|
||||
assert resolved[0].status == "resolved"
|
||||
assert resolved[0].resolution_reason == "manual_ledger_backfill"
|
||||
|
||||
|
||||
def test_ledger_skip_store_resolves_null_execution_id_on_backfill(pg_clean):
|
||||
store = PostgreSQLLedgerSkipStore(pg_clean)
|
||||
slot_id = uuid4()
|
||||
record, _ = store.record_skip(
|
||||
instance_key="inst-1",
|
||||
slot_id=slot_id,
|
||||
portfolio_id="PF-1",
|
||||
symbol="005930",
|
||||
order_id="O-3",
|
||||
execution_id=None,
|
||||
side="buy",
|
||||
qty=1.0,
|
||||
price=1010.0,
|
||||
executed_at=datetime(2026, 8, 24, 3, 0, tzinfo=UTC),
|
||||
order_venue="SOR",
|
||||
execution_venue="unknown",
|
||||
skip_reason="missing_execution_id",
|
||||
)
|
||||
|
||||
resolved = store.resolve_for_fill(
|
||||
instance_key="inst-1",
|
||||
slot_id=slot_id,
|
||||
side="buy",
|
||||
order_id="O-3",
|
||||
execution_id="E-3",
|
||||
resolution_reason="manual_ledger_backfill",
|
||||
)
|
||||
|
||||
assert [row.skip_key for row in resolved] == [record.skip_key]
|
||||
assert resolved[0].execution_id == "E-3"
|
||||
assert resolved[0].status == "resolved"
|
||||
|
||||
|
||||
def test_ledger_skip_store_retries_unique_violation_during_concurrent_dedupe(pg_clean):
|
||||
slot_id = uuid4()
|
||||
start = threading.Barrier(2)
|
||||
results = []
|
||||
|
||||
def worker():
|
||||
store = PostgreSQLLedgerSkipStore(pg_clean)
|
||||
start.wait()
|
||||
results.append(
|
||||
store.record_skip(
|
||||
instance_key="inst-1",
|
||||
slot_id=slot_id,
|
||||
portfolio_id="PF-1",
|
||||
symbol="005930",
|
||||
order_id="O-4",
|
||||
execution_id=None,
|
||||
side="buy",
|
||||
qty=1.0,
|
||||
price=1111.0,
|
||||
executed_at=datetime(2026, 8, 24, 4, 0, tzinfo=UTC),
|
||||
order_venue="SOR",
|
||||
execution_venue="unknown",
|
||||
skip_reason="missing_execution_id",
|
||||
)
|
||||
)
|
||||
|
||||
t1 = threading.Thread(target=worker)
|
||||
t2 = threading.Thread(target=worker)
|
||||
t1.start()
|
||||
t2.start()
|
||||
t1.join()
|
||||
t2.join()
|
||||
|
||||
assert len(results) == 2
|
||||
assert len({record.skip_key for record, _notify in results}) == 1
|
||||
assert sorted(notify for _record, notify in results) == [False, True]
|
||||
listed = PostgreSQLLedgerSkipStore(pg_clean).list_records(instance_key="inst-1", status="unresolved")
|
||||
assert len(listed) == 1
|
||||
|
||||
@@ -6,7 +6,7 @@ critical path while the ledger write is eventually-consistent.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from trex_core.adapters.broker.kis import KISBroker
|
||||
from trex_core.adapters.broker.paper import PaperBroker
|
||||
@@ -63,6 +63,55 @@ class _SkipStore:
|
||||
def resolve_for_fill(self, **_kwargs):
|
||||
return []
|
||||
|
||||
def resolve_for_delivery(
|
||||
self,
|
||||
*,
|
||||
instance_key,
|
||||
portfolio_id,
|
||||
side,
|
||||
order_id,
|
||||
execution_id,
|
||||
resolution_reason,
|
||||
):
|
||||
resolved = []
|
||||
updated = {}
|
||||
for key, record in self._by_key.items():
|
||||
if (
|
||||
record.instance_key == instance_key
|
||||
and record.portfolio_id == portfolio_id
|
||||
and record.side == side
|
||||
and (
|
||||
record.execution_id == execution_id
|
||||
or (record.execution_id is None and record.order_id == order_id)
|
||||
)
|
||||
):
|
||||
record = LedgerSkipRecord(
|
||||
skip_key=record.skip_key,
|
||||
instance_key=record.instance_key,
|
||||
slot_id=record.slot_id,
|
||||
portfolio_id=record.portfolio_id,
|
||||
symbol=record.symbol,
|
||||
order_id=record.order_id,
|
||||
execution_id=record.execution_id or execution_id,
|
||||
side=record.side,
|
||||
qty=record.qty,
|
||||
price=record.price,
|
||||
executed_at=record.executed_at,
|
||||
order_venue=record.order_venue,
|
||||
execution_venue=record.execution_venue,
|
||||
skip_reason=record.skip_reason,
|
||||
status="resolved",
|
||||
resolution_reason=resolution_reason,
|
||||
resolved_at=record.executed_at,
|
||||
created_at=record.created_at,
|
||||
updated_at=record.executed_at,
|
||||
)
|
||||
resolved.append(record)
|
||||
updated[key] = record
|
||||
self._by_key = updated
|
||||
self.records = list(self._by_key.values())
|
||||
return resolved
|
||||
|
||||
|
||||
|
||||
def _wire(broker, *, ledger=None, clock=None, alarm_emitter=None, ledger_skip_store=None):
|
||||
@@ -79,9 +128,13 @@ def _wire(broker, *, ledger=None, clock=None, alarm_emitter=None, ledger_skip_st
|
||||
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())
|
||||
writer = LedgerWriter(
|
||||
outbox,
|
||||
ledger or InMemoryLedger(),
|
||||
clock=clock,
|
||||
alarm_emitter=alarm_emitter,
|
||||
ledger_skip_store=ledger_skip_store,
|
||||
instance_key="default",
|
||||
)
|
||||
repo.reconcile_universe("default", {"PF-1": "SYM"})
|
||||
return repo, svc, intents, execution, outbox, writer
|
||||
@@ -294,6 +347,50 @@ def test_missing_execution_id_persists_skip_record() -> None:
|
||||
assert record.execution_id is None
|
||||
|
||||
|
||||
def test_skip_resolves_only_after_writer_completes_delivery() -> None:
|
||||
transport = InMemoryTransport()
|
||||
skip_store = _SkipStore()
|
||||
ledger = InMemoryLedger()
|
||||
_repo, _, intents, execution, outbox, writer = _wire(
|
||||
PaperBroker(fill_after_polls=0),
|
||||
ledger=ledger,
|
||||
alarm_emitter=AlarmEmitter(transport),
|
||||
ledger_skip_store=skip_store,
|
||||
)
|
||||
sid = _buy(intents, iid="delayed-skip-resolution").slot_id
|
||||
record, _ = skip_store.record_skip(
|
||||
instance_key="default",
|
||||
slot_id=sid,
|
||||
portfolio_id="PF-1",
|
||||
symbol="005930",
|
||||
order_id="PAPER-000001",
|
||||
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",
|
||||
)
|
||||
|
||||
result = execution.run_once()
|
||||
|
||||
assert result.ledger_enqueued == 1
|
||||
assert outbox.all_items()
|
||||
assert skip_store._by_key[record.skip_key].status == "unresolved"
|
||||
assert transport.alarm_events() == []
|
||||
|
||||
stats = writer.run_once()
|
||||
|
||||
assert stats.completed == 1
|
||||
resolved = skip_store._by_key[record.skip_key]
|
||||
assert resolved.status == "resolved"
|
||||
assert resolved.execution_id is not None
|
||||
assert resolved.resolution_reason == "ledger_recorded"
|
||||
assert [payload["code"] for _, payload in transport.alarm_events()] == ["ledger_skip_resolved"]
|
||||
|
||||
|
||||
def test_ledger_skip_emits_warning(caplog):
|
||||
"""dcy-011 / REQ-018 FR-4 (S-3): ledger skip is logged, not silent."""
|
||||
import logging
|
||||
|
||||
@@ -9,8 +9,13 @@ 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 _cfg(*, live: bool = False):
|
||||
return SimpleNamespace(
|
||||
dsn="postgresql://x/y",
|
||||
rabbitmq=None,
|
||||
broker="kis",
|
||||
kis=SimpleNamespace(allow_live=live),
|
||||
)
|
||||
|
||||
|
||||
def _record(*, status="unresolved", resolution_reason=None):
|
||||
@@ -46,7 +51,7 @@ def test_ledger_skips_parser_accepts_list_and_resolve():
|
||||
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"])
|
||||
args = parser.parse_args(["ledger", "skips", "resolve", "--skip-key", "abc", "--yes"])
|
||||
assert args.ledger_skips_cmd == "resolve"
|
||||
|
||||
|
||||
@@ -91,8 +96,9 @@ def test_ledger_skips_resolve_marks_record_and_emits_alert(monkeypatch, capsys):
|
||||
def __init__(self, *_args, **_kwargs):
|
||||
pass
|
||||
|
||||
def resolve_by_key(self, skip_key, *, resolution_reason):
|
||||
def resolve_by_key(self, skip_key, *, instance_key, resolution_reason):
|
||||
assert skip_key == "abc"
|
||||
assert instance_key == "inst-1"
|
||||
return _record(status="resolved", resolution_reason=resolution_reason)
|
||||
|
||||
published = []
|
||||
@@ -117,6 +123,9 @@ def test_ledger_skips_resolve_marks_record_and_emits_alert(monkeypatch, capsys):
|
||||
skip_key="abc",
|
||||
reason="operator_reconciled",
|
||||
instance="inst-1",
|
||||
yes=True,
|
||||
live=False,
|
||||
audit_dir="audit-dir",
|
||||
config=None,
|
||||
config_path=None,
|
||||
env_file=None,
|
||||
@@ -124,9 +133,42 @@ def test_ledger_skips_resolve_marks_record_and_emits_alert(monkeypatch, capsys):
|
||||
timeout=10.0,
|
||||
)
|
||||
|
||||
rc = cli._cmd_ledger_skips_resolve(args, _cfg())
|
||||
rc = cli._cmd_ledger_skips_resolve(args, _cfg(), lambda _: True)
|
||||
|
||||
assert rc == 0
|
||||
out = json.loads(capsys.readouterr().out)
|
||||
assert out["result"] == "resolved"
|
||||
assert out["audit_path"].endswith(".json")
|
||||
assert published[0][0] == "ledger_skip_resolved"
|
||||
|
||||
|
||||
def test_ledger_skips_resolve_live_requires_live_flag(monkeypatch, tmp_path, capsys):
|
||||
monkeypatch.setattr(
|
||||
"trex_core.adapters.persistence.ledger_skip_store.PostgreSQLLedgerSkipStore",
|
||||
lambda *_args, **_kwargs: object(),
|
||||
)
|
||||
args = SimpleNamespace(
|
||||
cmd="ledger",
|
||||
ledger_cmd="skips",
|
||||
ledger_skips_cmd="resolve",
|
||||
skip_key="abc",
|
||||
reason="operator_reconciled",
|
||||
instance="inst-1",
|
||||
yes=True,
|
||||
live=False,
|
||||
audit_dir=str(tmp_path),
|
||||
config=None,
|
||||
config_path=None,
|
||||
env_file=None,
|
||||
json=True,
|
||||
timeout=10.0,
|
||||
)
|
||||
|
||||
rc = cli._cmd_ledger_skips_resolve(args, _cfg(live=True), lambda _: True)
|
||||
|
||||
assert rc == 2
|
||||
err = capsys.readouterr().err
|
||||
assert "--live" in err
|
||||
audit = json.loads(next(tmp_path.glob("*.json")).read_text(encoding="utf-8"))
|
||||
assert audit["request"]["instance"] == "inst-1"
|
||||
assert audit["result"]["status"] == "rejected"
|
||||
|
||||
Reference in New Issue
Block a user