feat: persist skipped ledger fills

Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-08-24 13:23:06 +00:00
committed by GitHub
co-authored by genichin
parent df0258bc36
commit 76cde2edd0
19 changed files with 1246 additions and 151 deletions
+98 -14
View File
@@ -25,9 +25,9 @@ from __future__ import annotations
import inspect
import logging
from collections.abc import Callable
from dataclasses import dataclass, replace
from datetime import datetime, timedelta, timezone
from typing import Callable, Optional
from datetime import UTC, datetime, timedelta
from trex_core.application.alarm_emitter import AlarmEmitter
from trex_core.application.broker_port import (
@@ -39,6 +39,7 @@ from trex_core.application.broker_port import (
)
from trex_core.application.ledger_outbox import LedgerOutbox
from trex_core.application.ledger_port import LedgerTrade
from trex_core.application.ledger_skip import publish_ledger_skip_alarm
from trex_core.domain.pricing import ceil_to_tick, floor_to_tick
from trex_core.domain.slot import SlotService, SlotState, SlotStateMismatchError
from trex_core.runtime.logging_setup import set_correlation_id
@@ -57,7 +58,7 @@ _log = logging.getLogger(__name__)
# `slot.strategy_id` so that slot_filled_sell events reach the correct
# per-strategy queue instead of being NO_ROUTEd to the DLQ.
SlotEventEmitter = Callable[
[str, object, Optional[str]], None
[str, object, str | None], None
] # (event_type, slot, routing_strategy_id) -> None
_MAX_DISPATCH_PER_TICK = 64
@@ -81,13 +82,14 @@ class ExecutionService:
slot_service: SlotService,
repo,
broker: BrokerPort,
ledger_outbox: Optional[LedgerOutbox] = None,
event_emitter: Optional[SlotEventEmitter] = None,
stale_pending_threshold_sec: Optional[int] = None,
ledger_outbox: LedgerOutbox | None = None,
event_emitter: SlotEventEmitter | None = None,
stale_pending_threshold_sec: int | None = None,
instance_key: str = "default",
alarm_emitter: Optional[AlarmEmitter] = None,
balance_reader: Optional[Callable[[], list[BalanceItem]]] = None,
clock: Optional[Callable[[], datetime]] = None,
alarm_emitter: AlarmEmitter | None = None,
ledger_skip_store=None,
balance_reader: Callable[[], list[BalanceItem]] | None = None,
clock: Callable[[], datetime] | None = None,
) -> None:
self._svc = slot_service
self._repo = repo
@@ -108,11 +110,12 @@ class ExecutionService:
)
# dcy-033 / REQ-100: best-effort alarm emitter (None = no alarms sent).
self._alarm_emitter = alarm_emitter
self._ledger_skip_store = ledger_skip_store
# One emission per durable unresolved state/attempt in this process.
# A restart intentionally emits once again, covering a crash between a
# durable cancel reservation and best-effort alarm publication.
self._order_alarm_keys: set[tuple[str, str, int]] = set()
self._clock = clock or (lambda: datetime.now(tz=timezone.utc))
self._clock = clock or (lambda: datetime.now(tz=UTC))
# dcy-041 / GitHub #45: pending-sell quantity may use a bounded
# account snapshot only through this injected reader. Reconciliation
# remains wired directly to the live broker.
@@ -137,7 +140,7 @@ class ExecutionService:
# ------------------------------------------------------------
def _dispatch(self, r: ExecutionResult) -> None:
sell_balance: Optional[dict[str, int]] = None # lazy, once per tick
sell_balance: dict[str, int] | None = None # lazy, once per tick
for slot in self._repo.list_undispatched_pending(
self._instance_key, _MAX_DISPATCH_PER_TICK
):
@@ -266,7 +269,7 @@ class ExecutionService:
r.rolled_back += 1
def _poll(self, r: ExecutionResult) -> None:
now = self._clock().astimezone(timezone.utc)
now = self._clock().astimezone(UTC)
for slot in self._repo.list_dispatched_pending(self._instance_key):
side = "buy" if slot.status is SlotState.PENDING_BUY else "sell"
set_correlation_id(str(slot.slot_id)) # REQ-084: same key as submit
@@ -716,7 +719,7 @@ class ExecutionService:
},
)
def _emit_event(self, event_type: str, slot, routing_strategy_id: Optional[str] = None) -> None:
def _emit_event(self, event_type: str, slot, routing_strategy_id: str | None = None) -> None:
if self._event_emitter is None:
return
try:
@@ -754,6 +757,14 @@ class ExecutionService:
slot.symbol,
side,
)
self._persist_ledger_skip(
slot,
fs,
side,
broker_order_id,
skip_reason="missing_portfolio_id",
price=resolved_price,
)
return
if fs.execution_id is None:
# Broker contract regression — slot already transitioned; surface for ops.
@@ -765,6 +776,14 @@ class ExecutionService:
slot.portfolio_id,
side,
)
self._persist_ledger_skip(
slot,
fs,
side,
broker_order_id,
skip_reason="missing_execution_id",
price=resolved_price,
)
return
# REQ-095 KD-3: use pre-resolved price when available; fall back to
# fs.avg_price for any direct callers that do not pass resolved_price.
@@ -787,6 +806,15 @@ class ExecutionService:
execution_venue,
slot.venue,
)
self._persist_ledger_skip(
slot,
fs,
side,
broker_order_id,
skip_reason="unsupported_execution_venue",
price=price,
execution_venue=execution_venue,
)
return
trade = LedgerTrade(
asset_id=str(slot.portfolio_id),
@@ -797,12 +825,68 @@ class ExecutionService:
price=price,
fee=float(fs.fee),
tax=float(fs.tax),
executed_at=datetime.now(tz=timezone.utc),
executed_at=fs.executed_at or self._clock(),
venue=execution_venue,
order_venue=slot.venue,
)
self._ledger_outbox.enqueue(trade)
r.ledger_enqueued += 1
self._resolve_ledger_skip(slot, side, broker_order_id, str(fs.execution_id))
def _persist_ledger_skip(
self,
slot,
fs,
side: str,
broker_order_id: str,
*,
skip_reason: str,
price: float | None,
execution_venue: str | None = None,
) -> None:
if self._ledger_skip_store is None:
return
record, should_notify = self._ledger_skip_store.record_skip(
instance_key=self._instance_key,
slot_id=slot.slot_id,
portfolio_id=slot.portfolio_id,
symbol=slot.symbol,
order_id=str(broker_order_id) if broker_order_id else None,
execution_id=str(fs.execution_id) if fs.execution_id is not None else None,
side=side,
qty=float(fs.filled_qty),
price=(
float(price)
if price is not None
else (float(fs.avg_price) if fs.avg_price is not None else 0.0)
),
executed_at=fs.executed_at or self._clock(),
order_venue=getattr(slot, "venue", None) or "unknown",
execution_venue=execution_venue or getattr(fs, "execution_venue", None) or "unknown",
skip_reason=skip_reason,
)
if should_notify:
publish_ledger_skip_alarm(self._alarm_emitter, record, resolved=False)
def _resolve_ledger_skip(
self,
slot,
side: str,
broker_order_id: str,
execution_id: str | None,
) -> None:
if self._ledger_skip_store is None:
return
records = self._ledger_skip_store.resolve_for_fill(
instance_key=self._instance_key,
slot_id=slot.slot_id,
side=side,
order_id=str(broker_order_id) if broker_order_id else None,
execution_id=execution_id,
resolution_reason="ledger_enqueued",
)
for record in records:
publish_ledger_skip_alarm(self._alarm_emitter, record, resolved=True)
def cancel_pending_slot(self, slot_id) -> str:
"""Operator backstop: send cancel_order for a pending slot (dcy-022/REQ-071).