feat: add messaging transport rollout
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
...
|
||||
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user