feat: add messaging transport rollout

This commit is contained in:
2026-06-06 00:02:52 +09:00
parent 4b91343ee8
commit 6c7a1cf6f6
383 changed files with 99045 additions and 487 deletions
+24 -3
View File
@@ -20,13 +20,19 @@ from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Optional
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
# 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
@@ -46,11 +52,13 @@ class ExecutionService:
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:
@@ -112,23 +120,36 @@ class ExecutionService:
broker_order_id = slot.broker_order_id # captured BEFORE clear_dispatch
try:
if slot.status is SlotState.PENDING_BUY:
self._svc.transit_slot(
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
self._svc.transit_slot(
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:
+121
View File
@@ -0,0 +1,121 @@
"""Messaging transport port + DTOs (REQ-021, REQ-022, dcy-006).
Application-layer contract for trex-core ↔ 전략 모듈 통신. Domain stays
free of AMQP / pika dependencies (NFR-1); concrete adapters live in
trex_core.adapters.messaging.
Three patterns (dcy-006 OQ-2):
1. snapshot RPC (request-reply) — read-only, transient.
2. intent admission RPC (request-reply) — durable, correlation_id dedup.
3. event pub/sub — topic exchange, per-strategy queue, last-wins.
Request payloads carry view/strategy_id in the message body; events carry
strategy_id/symbol in the routing key (REQ-021 mapping principle).
"""
from __future__ import annotations
import threading
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Callable, Optional, Protocol
class TransportHealth(str, Enum):
"""Connection state exposed to application for degrade branches (REQ-024)."""
CONNECTED = "connected"
DISCONNECTED = "disconnected"
RECONNECTING = "reconnecting"
@dataclass(frozen=True)
class RpcRequest:
"""Inbound RPC message handed to a consumer handler."""
payload: dict
correlation_id: str
reply_to: Optional[str] = None
properties: dict[str, Any] = field(default_factory=dict)
@dataclass(frozen=True)
class RpcResponse:
"""Outbound RPC response returned from a consumer handler."""
payload: dict
class TransportError(Exception):
"""Base class for messaging transport failures."""
class TransportUnavailableError(TransportError):
"""Raised when transport.health() is not CONNECTED and the caller
cannot proceed (REQ-024: no local buffering, conservative halt)."""
class RpcTimeoutError(TransportError):
"""RPC reply not received within the configured timeout."""
# Type aliases for handlers
RpcHandler = Callable[[RpcRequest], RpcResponse]
EventHandler = Callable[[str, dict], None] # (routing_key, payload)
class MessagingTransport(Protocol):
"""Transport boundary for messaging-based communication (REQ-022)."""
def declare_topology(self) -> None:
"""Declare exchanges, queues, bindings (REQ-021)."""
...
def publish_event(
self,
routing_key: str,
payload: dict,
*,
mandatory: bool = True,
) -> None:
"""Publish to the events topic exchange. Uses publisher confirms;
unroutable or NACKed messages are routed to the DLQ (REQ-023).
Raises TransportUnavailableError if not connected."""
...
def call_rpc(
self,
queue: str,
payload: dict,
*,
correlation_id: str,
timeout: float,
) -> dict:
"""Client-side RPC: publish to `queue`, wait for reply.
Raises RpcTimeoutError on timeout, TransportUnavailableError on outage."""
...
def consume_rpc_forever(
self,
queue: str,
handler: RpcHandler,
stop: threading.Event,
) -> None:
"""Server-side RPC consumption. Blocks until `stop` is set.
Auto-reconnects on connection drop (REQ-022 AC-4)."""
...
def consume_events_forever(
self,
queue: str,
binding_pattern: str,
handler: EventHandler,
stop: threading.Event,
) -> None:
"""Subscribe to events matching `binding_pattern` on the events
exchange, dispatch to handler. Blocks until `stop` is set."""
...
def health(self) -> TransportHealth:
"""Current connection state (REQ-024)."""
...
def close(self) -> None:
"""Release connection resources."""
...
+201
View File
@@ -0,0 +1,201 @@
"""RPC handlers + event payload builders for messaging transport (REQ-021/022).
Handlers are transport-agnostic: they accept a parsed `RpcRequest` payload
and return an `RpcResponse`. Wiring them onto `MessagingTransport.consume_*`
happens in the runtime layer.
"""
from __future__ import annotations
import logging
from datetime import date as date_cls
from datetime import datetime, timezone
from typing import Optional
from uuid import UUID
from trex_core.application.intents import BuyIntent, IntentService, SellIntent
from trex_core.application.messaging import RpcRequest, RpcResponse
from trex_core.domain.slot import SlotRepository, SlotService
_log = logging.getLogger(__name__)
# ---- intent RPC handler -----------------------------------------------------
def make_intent_rpc_handler(intent_service: IntentService):
"""Build the RPC handler for `trex.intent.req`.
Payload shape:
{
"intent_type": "buy" | "sell",
"intent_id": "<idempotency key, = correlation_id>",
... fields per BuyIntent / SellIntent ...
}
Response shape:
{"intent_id": str, "status": "admitted"|"rejected", "slot_id": str|None, "reason": str|None}
Dedup uses `intent_log.try_insert(intent_id, ...)` (REQ-007 gate). The
request's `correlation_id` is reused as `intent_id` so the messaging
layer and the application share a single idempotency source of truth
(REQ-023 AC-1).
"""
def handler(req: RpcRequest) -> RpcResponse:
body = req.payload or {}
intent_id = str(body.get("intent_id") or req.correlation_id)
intent_type = str(body.get("intent_type") or "").lower()
try:
if intent_type == "buy":
buy = BuyIntent(
intent_id=intent_id,
trading_date=_parse_date(body.get("trading_date")),
symbol=str(body.get("symbol") or ""),
qty=_opt_float(body.get("qty")),
allocated_capital=_opt_float(body.get("allocated_capital")),
entry_price=_opt_float(body.get("entry_price")),
strategy_id=_opt_str(body.get("strategy_id")),
sell_strategy_id=_opt_str(body.get("sell_strategy_id")),
portfolio_id=_opt_str(body.get("portfolio_id")),
forced_close_at=_opt_dt(body.get("forced_close_at")),
)
d = intent_service.submit_buy(buy)
elif intent_type == "sell":
sell = SellIntent(
intent_id=intent_id,
slot_id=UUID(str(body["slot_id"])),
reason=str(body.get("reason") or "discretionary_sell"),
sell_strategy_id=_opt_str(body.get("sell_strategy_id")),
)
d = intent_service.submit_sell(sell)
else:
return RpcResponse(payload={
"intent_id": intent_id, "status": "rejected",
"slot_id": None, "reason": f"unknown_intent_type:{intent_type}",
})
except (KeyError, ValueError) as exc:
return RpcResponse(payload={
"intent_id": intent_id, "status": "rejected",
"slot_id": None, "reason": f"bad_request:{exc}",
})
return RpcResponse(payload={
"intent_id": d.intent_id,
"status": d.status,
"slot_id": (str(d.slot_id) if d.slot_id else None),
"reason": d.reason,
})
return handler
# ---- snapshot RPC handler ---------------------------------------------------
def make_snapshot_rpc_handler(slot_repo: SlotRepository, slot_service: SlotService):
"""Build the RPC handler for `trex.snapshot.req`.
Payload shape (stub for BAT-007 — dcy-005 logical contract is not yet
REQ-normalized; this returns a transport-shape evidence equivalent of
`GET /v1/slots` + `GET /v1/capacity`):
{
"strategy_id": "<filter or null>",
"view": "open" | "all" (default "all"),
"trading_date": "YYYY-MM-DD" (optional, default today UTC)
}
"""
def handler(req: RpcRequest) -> RpcResponse:
body = req.payload or {}
view = str(body.get("view") or "all")
strategy_filter = _opt_str(body.get("strategy_id"))
day = _parse_date(body.get("trading_date")) if body.get("trading_date") else _today()
slots = slot_repo.list_slots(day)
if view == "open":
slots = [s for s in slots if s.status.value in ("pending_buy", "holding", "pending_sell")]
if strategy_filter is not None:
slots = [s for s in slots if s.strategy_id == strategy_filter]
cap = slot_service.compute_slot_capacity(day, max(1, len(slot_repo.list_slots(day))))
return RpcResponse(payload={
"view": view,
"strategy_id": strategy_filter,
"trading_date": day.isoformat(),
"slots": [_slot_to_dict(s) for s in slots],
"capacity": {
"idle_count": cap.idle_count,
"holding_count": cap.holding_count,
"pending_count": cap.pending_count,
"total_slots": cap.total_slots,
"scarcity_ratio": cap.scarcity_ratio,
},
})
return handler
# ---- event payload builder --------------------------------------------------
def slot_transition_event_payload(slot, *, event_type: str) -> dict:
"""Build the event payload for a slot transition (REQ-023 AC-5).
Carries the *current state* + a monotonic sequence (`updated_at` iso) so
receivers can apply last-wins without ordering guarantees.
"""
return {
"event_type": event_type,
"slot_id": str(slot.slot_id),
"strategy_id": slot.strategy_id,
"symbol": slot.symbol,
"status": slot.status.value,
"qty": float(slot.qty) if slot.qty is not None else None,
"avg_price": float(slot.avg_price) if slot.avg_price is not None else None,
"portfolio_id": slot.portfolio_id,
"trading_date": slot.trading_date.isoformat(),
"sequence_at": (slot.updated_at or datetime.now(tz=timezone.utc)).isoformat(),
}
# ---- helpers ----------------------------------------------------------------
def _opt_str(v) -> Optional[str]:
if v is None or v == "":
return None
return str(v)
def _opt_float(v) -> Optional[float]:
if v is None:
return None
return float(v)
def _opt_dt(v) -> Optional[datetime]:
if v is None:
return None
if isinstance(v, datetime):
return v
return datetime.fromisoformat(str(v))
def _parse_date(v) -> date_cls:
if isinstance(v, date_cls):
return v
return date_cls.fromisoformat(str(v))
def _today() -> date_cls:
return datetime.now(tz=timezone.utc).date()
def _slot_to_dict(s) -> dict:
return {
"slot_id": str(s.slot_id),
"trading_date": s.trading_date.isoformat(),
"status": s.status.value,
"symbol": s.symbol,
"qty": float(s.qty) if s.qty is not None else None,
"avg_price": float(s.avg_price) if s.avg_price is not None else None,
"entry_price": float(s.entry_price) if s.entry_price is not None else None,
"allocated_capital": float(s.allocated_capital) if s.allocated_capital is not None else None,
"strategy_id": s.strategy_id,
"sell_strategy_id": s.sell_strategy_id,
"portfolio_id": s.portfolio_id,
"forced_close_at": s.forced_close_at.isoformat() if s.forced_close_at else None,
}