Files
trex-core/trex_core/application/execution.py
T
genichinandClaude Opus 4.8 aee3512383 feat(intents): dcy-011 buy intent portfolio_id 필수 계약 + ledger skip 가시화 (BAT-015)
dcy-011 전 주기 delivery. issue #6(buy 식별자 정정) + issue #11(ledger silent skip) 흡수.
거버넌스: 기존 Implemented REQ는 change-req, 신규 기능만 신규 REQ(OQ-3).

- REQ-007 (change-req): buy admission portfolio_id 필수화 + SnapshotStore symbol
  resolve + unknown_portfolio 거부 (hard cutover, OQ-1/OQ-2)
- REQ-021 (change-req): buy 메시지 payload symbol 제거
- REQ-018 (change-req): _record_ledger skip 가시화 (WARN + ledger_skipped 카운터)
- REQ-055 (신규): closed↔ledger_outbox anti-join 운영 점검 find_ledger_gap_slots

코드: intents.py(BuyIntent portfolio_id 필수 + resolve_symbol 주입 + _admit_buy
reject 분기), messaging_handlers.py, runtime/daemon.py(_resolve_symbol 배선),
execution.py(skip WARN 로그), adapters/persistence/ops_checks.py(신규 anti-join).

검증: 단위 282 passed/32 skipped, 인프라 게이트(PG+RabbitMQ) 314 passed, ruff clean.
BAT-015(standard) release-candidate. srs/index Next ID → REQ-056.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 12:57:51 +09:00

209 lines
9.0 KiB
Python

"""Order execution service (REQ-013, REQ-014, REQ-018, dcy-003, dcy-004).
A single background execution loop (invariant A single writer, invariant B
single broker session) that, per tick:
dispatch: claim pending slots with broker_order_id IS NULL (option A queue),
submit the order, record broker_order_id (idempotency guard).
poll: observe fills on dispatched pending slots and transition the slot
(pending_buy->holding / pending_sell->closed), or roll back on reject.
On a successful fill, enqueue a LedgerWorkItem (slot transition is
kept ON the critical path; the ledger write is eventually-consistent
via a separate writer — REQ-019).
reconcile() re-polls dispatched slots on restart so a crash after dispatch
converges without duplicate orders (REQ-014).
All slot writes go through SlotService (advisory lock). The broker is touched
only here, never by the domain or the HTTP API. The ledger outbox is the
single producer for ledger work items (NFR-3, single record subject).
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Callable, Optional
from trex_core.application.broker_port import BrokerPort, OrderRequest
from trex_core.application.ledger_outbox import LedgerOutbox
from trex_core.application.ledger_port import LedgerTrade
from trex_core.domain.slot import SlotService, SlotState, SlotStateMismatchError
_log = logging.getLogger(__name__)
# REQ-021/023: event emission hook. Runtime wires a callable that publishes
# slot transition events through MessagingTransport. Application layer stays
# transport-agnostic (NFR-1). Slot type is duck-typed to keep the application
# layer free of domain re-imports (the slot is just used for attribute access).
SlotEventEmitter = Callable[[str, object], None] # (event_type, slot) -> None
_MAX_DISPATCH_PER_TICK = 64
@dataclass
class ExecutionResult:
dispatched: int = 0
filled: int = 0
rolled_back: int = 0
ledger_enqueued: int = 0 # REQ-019: ledger work items appended this tick
ledger_skipped: int = 0 # REQ-018 AC-4: fills without portfolio_id
class ExecutionService:
def __init__(
self,
slot_service: SlotService,
repo,
broker: BrokerPort,
ledger_outbox: Optional[LedgerOutbox] = None,
event_emitter: Optional[SlotEventEmitter] = None,
) -> None:
self._svc = slot_service
self._repo = repo
self._broker = broker
self._ledger_outbox = ledger_outbox
self._event_emitter = event_emitter
# ------------------------------------------------------------
def run_once(self) -> ExecutionResult:
r = ExecutionResult()
self._dispatch(r)
self._poll(r)
return r
def reconcile(self) -> ExecutionResult:
"""Restart recovery: re-poll dispatched-but-pending slots (REQ-014).
Undispatched pending slots (broker_order_id IS NULL) are simply picked
up by the next dispatch, so a crash before dispatch causes no duplicate.
"""
r = ExecutionResult()
self._poll(r)
return r
# ------------------------------------------------------------
def _dispatch(self, r: ExecutionResult) -> None:
for _ in range(_MAX_DISPATCH_PER_TICK):
slot = self._repo.find_pending_for_dispatch()
if slot is None:
return
side = "buy" if slot.status is SlotState.PENDING_BUY else "sell"
qty = int(slot.qty or 0)
limit = int(slot.entry_price) if slot.entry_price else None
ack = self._broker.submit_order(
OrderRequest(
slot_id=str(slot.slot_id),
side=side,
symbol=slot.symbol or "",
qty=qty,
limit_price=limit,
order_type="00" if limit is not None else "01",
)
)
if ack.accepted and ack.broker_order_id:
self._repo.mark_dispatched(slot.slot_id, ack.broker_order_id)
r.dispatched += 1
else:
self._rollback(slot, reason=ack.reason or "order_rejected")
r.rolled_back += 1
def _poll(self, r: ExecutionResult) -> None:
for slot in self._repo.list_dispatched_pending():
side = "buy" if slot.status is SlotState.PENDING_BUY else "sell"
fs = self._broker.poll_fill(slot.broker_order_id, side=side, symbol=slot.symbol or "")
if fs.is_filled:
self._on_filled(slot, fs, side, r)
r.filled += 1
elif fs.is_terminal_reject:
self._rollback(slot, reason=f"order_{fs.state}")
r.rolled_back += 1
# accepted / partially_filled -> leave for next tick
# ------------------------------------------------------------
def _on_filled(self, slot, fs, side: str, r: ExecutionResult) -> None:
broker_order_id = slot.broker_order_id # captured BEFORE clear_dispatch
try:
if slot.status is SlotState.PENDING_BUY:
updated = self._svc.transit_slot(
slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
reason="buy_filled", actor="execution_loop",
avg_price=fs.avg_price, qty=float(fs.filled_qty) or slot.qty,
)
event_type = "slot_filled_buy"
else: # PENDING_SELL
updated = self._svc.transit_slot(
slot.slot_id, SlotState.PENDING_SELL, SlotState.CLOSED,
reason="sell_filled", actor="execution_loop",
)
event_type = "slot_filled_sell"
except SlotStateMismatchError:
return # slot moved concurrently; next tick reconciles
# REQ-018: enqueue ledger work item AFTER the slot transition is durable.
# Slot transition is the critical path; ledger write is async (REQ-019).
self._record_ledger(slot, fs, side, broker_order_id, r)
# REQ-021/023: emit event AFTER transition + ledger enqueue (single
# record subject). Best-effort: a publish failure must not roll back.
self._emit_event(event_type, updated)
self._repo.clear_dispatch(slot.slot_id) # done with this order
def _emit_event(self, event_type: str, slot) -> None:
if self._event_emitter is None:
return
try:
self._event_emitter(event_type, slot)
except Exception: # noqa: BLE001 - event publish failure is non-critical
return
def _record_ledger(self, slot, fs, side: str, broker_order_id: str, r: ExecutionResult) -> None:
"""Append a LedgerWorkItem for the just-filled trade (REQ-018 + REQ-019)."""
if self._ledger_outbox is None:
return
if not slot.portfolio_id:
# dcy-011 / REQ-018 (issue #11): ledger skip must NOT be silent.
# Slot transition still proceeded; surface for ops (FR-4).
r.ledger_skipped += 1
_log.warning(
"ledger skip: slot=%s symbol=%s side=%s reason=missing_portfolio_id "
"(trade NOT recorded to portfolio-hub)",
slot.slot_id, slot.symbol, side,
)
return
if fs.execution_id is None:
# Broker contract regression — slot already transitioned; surface for ops.
r.ledger_skipped += 1
_log.warning(
"ledger skip: slot=%s portfolio_id=%s side=%s reason=missing_execution_id "
"(trade NOT recorded to portfolio-hub)",
slot.slot_id, slot.portfolio_id, side,
)
return
price = float(fs.avg_price) if fs.avg_price is not None else 0.0
trade = LedgerTrade(
asset_id=str(slot.portfolio_id),
order_id=str(broker_order_id),
execution_id=str(fs.execution_id),
side=side,
qty=float(fs.filled_qty),
price=price,
fee=float(fs.fee),
tax=float(fs.tax),
executed_at=datetime.now(tz=timezone.utc),
)
self._ledger_outbox.enqueue(trade)
r.ledger_enqueued += 1
def _rollback(self, slot, *, reason: str) -> None:
try:
if slot.status is SlotState.PENDING_BUY:
self._svc.transit_slot(
slot.slot_id, SlotState.PENDING_BUY, SlotState.IDLE,
reason=reason, actor="execution_loop",
) # idle reset clears broker_order_id in the domain
else: # PENDING_SELL rejected -> back to holding, clear order for re-dispatch
self._svc.transit_slot(
slot.slot_id, SlotState.PENDING_SELL, SlotState.HOLDING,
reason=reason, actor="execution_loop",
)
self._repo.clear_dispatch(slot.slot_id)
except SlotStateMismatchError:
return