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
+1
View File
@@ -107,6 +107,7 @@ class FillStatus:
# Broker-authoritative execution exchange. For SOR orders this is KRX or
# NXT; None means the polling surface did not establish it.
execution_venue: Optional[str] = None
executed_at: Optional[datetime] = None
# dcy-013 / REQ-012: an external cancel was detected.
# order had a partial fill before the cancel, state stays "partially_filled"
# and this flag is set so execution records the filled portion and returns
+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).
+98
View File
@@ -0,0 +1,98 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from uuid import UUID
from trex_core.application.alarm_emitter import AlarmEmitter
@dataclass(frozen=True)
class LedgerSkipRecord:
skip_key: str
instance_key: str
slot_id: UUID
portfolio_id: str | None
symbol: str | None
order_id: str | None
execution_id: str | None
side: str
qty: float
price: float
executed_at: datetime
order_venue: str
execution_venue: str
skip_reason: str
status: str
resolution_reason: str | None
resolved_at: datetime | None
created_at: datetime
updated_at: datetime
def make_ledger_skip_key(
*,
instance_key: str,
slot_id: UUID,
side: str,
order_id: str | None,
execution_id: str | None,
skip_reason: str,
) -> str:
return ":".join(
[
"ledger-skip",
instance_key,
str(slot_id),
side,
order_id or "-",
execution_id or "-",
skip_reason,
]
)
def ledger_skip_alarm_fields(record: LedgerSkipRecord) -> dict[str, object]:
return {
"skip_key": record.skip_key,
"instance_key": record.instance_key,
"slot_id": str(record.slot_id),
"portfolio_id": record.portfolio_id,
"symbol": record.symbol,
"order_id": record.order_id,
"execution_id": record.execution_id,
"side": record.side,
"qty": record.qty,
"price": record.price,
"executed_at": record.executed_at.isoformat(),
"order_venue": record.order_venue,
"execution_venue": record.execution_venue,
"skip_reason": record.skip_reason,
"status": record.status,
"resolution_reason": record.resolution_reason,
"resolved_at": record.resolved_at.isoformat() if record.resolved_at else None,
"created_at": record.created_at.isoformat(),
"updated_at": record.updated_at.isoformat(),
}
def publish_ledger_skip_alarm(
alarm_emitter: AlarmEmitter | None,
record: LedgerSkipRecord,
*,
resolved: bool,
) -> None:
if alarm_emitter is None:
return
code = "ledger_skip_resolved" if resolved else "ledger_skip_unresolved"
title = (
f"원장 skip 해소: slot={record.slot_id} reason={record.resolution_reason or 'resolved'}"
if resolved
else f"원장 skip 발생: slot={record.slot_id} reason={record.skip_reason}"
)
alarm_emitter.publish_alarm(
code,
title,
fields=ledger_skip_alarm_fields(record),
severity="info" if resolved else "critical",
)