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
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
Reference in New Issue
Block a user