feat: add messaging transport rollout
This commit is contained in:
@@ -1,114 +0,0 @@
|
||||
"""FastAPI app factory for the slot-core daemon (REQ-005/007/008).
|
||||
|
||||
`create_app` wires the HTTP /v1 surface to injected services so the same app
|
||||
runs against the in-memory adapters (tests) or PostgreSQL adapters (daemon).
|
||||
All slot writes go through IntentService → SlotService (single writer).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date as date_cls
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Query
|
||||
|
||||
from trex_core.adapters.api.schemas import (
|
||||
BuyIntentRequest,
|
||||
CapacityResponse,
|
||||
HealthResponse,
|
||||
IntentResponse,
|
||||
SellIntentRequest,
|
||||
SlotSnapshot,
|
||||
)
|
||||
from trex_core.application.intents import BuyIntent, IntentService, SellIntent
|
||||
from trex_core.domain.slot import SlotRepository, SlotService
|
||||
|
||||
|
||||
def create_app(
|
||||
*,
|
||||
slot_service: SlotService,
|
||||
slot_repo: SlotRepository,
|
||||
intent_service: IntentService,
|
||||
default_total_slots: int,
|
||||
) -> FastAPI:
|
||||
app = FastAPI(title="trex-core slot daemon", version="0.1.0")
|
||||
|
||||
@app.get("/v1/healthz", response_model=HealthResponse)
|
||||
def healthz() -> HealthResponse:
|
||||
return HealthResponse()
|
||||
|
||||
@app.post("/v1/intents/buy", response_model=IntentResponse)
|
||||
def submit_buy(req: BuyIntentRequest) -> IntentResponse:
|
||||
d = intent_service.submit_buy(
|
||||
BuyIntent(
|
||||
intent_id=req.intent_id,
|
||||
trading_date=req.trading_date,
|
||||
symbol=req.symbol,
|
||||
qty=req.qty,
|
||||
allocated_capital=req.allocated_capital,
|
||||
entry_price=req.entry_price,
|
||||
strategy_id=req.strategy_id,
|
||||
sell_strategy_id=req.sell_strategy_id,
|
||||
portfolio_id=req.portfolio_id,
|
||||
forced_close_at=req.forced_close_at,
|
||||
)
|
||||
)
|
||||
return IntentResponse(intent_id=d.intent_id, status=d.status, slot_id=d.slot_id, reason=d.reason)
|
||||
|
||||
@app.post("/v1/intents/sell", response_model=IntentResponse)
|
||||
def submit_sell(req: SellIntentRequest) -> IntentResponse:
|
||||
d = intent_service.submit_sell(
|
||||
SellIntent(
|
||||
intent_id=req.intent_id,
|
||||
slot_id=req.slot_id,
|
||||
reason=req.reason,
|
||||
sell_strategy_id=req.sell_strategy_id,
|
||||
)
|
||||
)
|
||||
return IntentResponse(intent_id=d.intent_id, status=d.status, slot_id=d.slot_id, reason=d.reason)
|
||||
|
||||
@app.get("/v1/intents/{intent_id}", response_model=IntentResponse)
|
||||
def get_intent(intent_id: str) -> IntentResponse:
|
||||
d = intent_service.lookup(intent_id)
|
||||
if d is None:
|
||||
raise HTTPException(status_code=404, detail="intent not found")
|
||||
return IntentResponse(intent_id=d.intent_id, status=d.status, slot_id=d.slot_id, reason=d.reason)
|
||||
|
||||
@app.get("/v1/slots", response_model=list[SlotSnapshot])
|
||||
def list_slots(
|
||||
status: Optional[str] = Query(default=None),
|
||||
trading_date: Optional[date_cls] = Query(default=None),
|
||||
) -> list[SlotSnapshot]:
|
||||
day = trading_date or _today()
|
||||
slots = slot_repo.list_slots(day)
|
||||
if status is not None:
|
||||
slots = [s for s in slots if s.status.value == status]
|
||||
return [
|
||||
SlotSnapshot(
|
||||
slot_id=s.slot_id, trading_date=s.trading_date, status=s.status.value,
|
||||
symbol=s.symbol, qty=s.qty, avg_price=s.avg_price, entry_price=s.entry_price,
|
||||
allocated_capital=s.allocated_capital, strategy_id=s.strategy_id,
|
||||
sell_strategy_id=s.sell_strategy_id, forced_close_at=s.forced_close_at,
|
||||
)
|
||||
for s in slots
|
||||
]
|
||||
|
||||
@app.get("/v1/capacity", response_model=CapacityResponse)
|
||||
def capacity(
|
||||
trading_date: Optional[date_cls] = Query(default=None),
|
||||
total_slots: Optional[int] = Query(default=None),
|
||||
) -> CapacityResponse:
|
||||
day = trading_date or _today()
|
||||
cap = slot_service.compute_slot_capacity(day, total_slots or default_total_slots)
|
||||
return CapacityResponse(
|
||||
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 app
|
||||
|
||||
|
||||
def _today() -> date_cls:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
return datetime.now(tz=timezone.utc).date()
|
||||
@@ -1,61 +0,0 @@
|
||||
"""Pydantic request/response schemas for the /v1 HTTP API (REQ-007/008)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class BuyIntentRequest(BaseModel):
|
||||
intent_id: str
|
||||
trading_date: date
|
||||
symbol: str
|
||||
qty: Optional[float] = None
|
||||
allocated_capital: Optional[float] = None
|
||||
entry_price: Optional[float] = None
|
||||
strategy_id: Optional[str] = None
|
||||
sell_strategy_id: Optional[str] = None
|
||||
portfolio_id: Optional[str] = None
|
||||
forced_close_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class SellIntentRequest(BaseModel):
|
||||
intent_id: str
|
||||
slot_id: UUID
|
||||
reason: str = "discretionary_sell"
|
||||
sell_strategy_id: Optional[str] = None
|
||||
|
||||
|
||||
class IntentResponse(BaseModel):
|
||||
intent_id: str
|
||||
status: str
|
||||
slot_id: Optional[UUID] = None
|
||||
reason: Optional[str] = None
|
||||
|
||||
|
||||
class SlotSnapshot(BaseModel):
|
||||
slot_id: UUID
|
||||
trading_date: date
|
||||
status: str
|
||||
symbol: Optional[str] = None
|
||||
qty: Optional[float] = None
|
||||
avg_price: Optional[float] = None
|
||||
entry_price: Optional[float] = None
|
||||
allocated_capital: Optional[float] = None
|
||||
strategy_id: Optional[str] = None
|
||||
sell_strategy_id: Optional[str] = None
|
||||
forced_close_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class CapacityResponse(BaseModel):
|
||||
idle_count: int
|
||||
holding_count: int
|
||||
pending_count: int
|
||||
total_slots: int
|
||||
scarcity_ratio: float
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
status: str = "ok"
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Messaging adapters (REQ-021, REQ-022, dcy-006).
|
||||
|
||||
- InMemoryTransport: deterministic stub for tests (no broker).
|
||||
- RabbitMQTransport: pika BlockingConnection adapter for real RabbitMQ.
|
||||
|
||||
Topology constants live in `topology` and are shared by both adapters.
|
||||
"""
|
||||
from trex_core.adapters.messaging.in_memory import InMemoryTransport
|
||||
from trex_core.adapters.messaging.rabbitmq import RabbitMQTransport
|
||||
from trex_core.adapters.messaging import topology
|
||||
|
||||
__all__ = ["InMemoryTransport", "RabbitMQTransport", "topology"]
|
||||
@@ -0,0 +1,177 @@
|
||||
"""In-memory messaging transport — deterministic stub for tests (REQ-022 AC-1).
|
||||
|
||||
Implements `MessagingTransport` with process-local pub/sub + sync RPC.
|
||||
RPC handlers and event subscribers are registered in shared state; calls
|
||||
dispatch directly to the registered handler in the caller's thread for
|
||||
deterministic tests (no broker, no extra threads).
|
||||
|
||||
`set_available(False)` simulates a broker outage so degrade-policy tests
|
||||
(REQ-024) can exercise the conservative-halt branch without RabbitMQ.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import uuid
|
||||
|
||||
from trex_core.application.messaging import (
|
||||
EventHandler,
|
||||
RpcHandler,
|
||||
RpcRequest,
|
||||
RpcTimeoutError,
|
||||
TransportHealth,
|
||||
TransportUnavailableError,
|
||||
)
|
||||
|
||||
|
||||
class InMemoryTransport:
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self._rpc_handlers: dict[str, RpcHandler] = {}
|
||||
self._event_subscribers: list[tuple[str, EventHandler]] = [] # (pattern, handler)
|
||||
self._dlq: list[tuple[str, dict]] = [] # (routing_key, payload)
|
||||
self._available = True
|
||||
self._health = TransportHealth.CONNECTED
|
||||
|
||||
# --- topology ---
|
||||
def declare_topology(self) -> None:
|
||||
"""No-op: in-memory does not need broker declarations."""
|
||||
return
|
||||
|
||||
# --- availability toggle (test affordance) ---
|
||||
def set_available(self, available: bool) -> None:
|
||||
with self._lock:
|
||||
self._available = available
|
||||
self._health = (
|
||||
TransportHealth.CONNECTED if available else TransportHealth.DISCONNECTED
|
||||
)
|
||||
|
||||
def health(self) -> TransportHealth:
|
||||
with self._lock:
|
||||
return self._health
|
||||
|
||||
def dlq_messages(self) -> list[tuple[str, dict]]:
|
||||
with self._lock:
|
||||
return list(self._dlq)
|
||||
|
||||
# --- events ---
|
||||
def publish_event(
|
||||
self,
|
||||
routing_key: str,
|
||||
payload: dict,
|
||||
*,
|
||||
mandatory: bool = True,
|
||||
) -> None:
|
||||
with self._lock:
|
||||
if not self._available:
|
||||
# No buffering: unroutable goes to DLQ, caller may also reject (REQ-023/024).
|
||||
self._dlq.append((routing_key, dict(payload)))
|
||||
raise TransportUnavailableError(
|
||||
"transport disconnected; event routed to DLQ collector"
|
||||
)
|
||||
subscribers = [
|
||||
handler for pattern, handler in self._event_subscribers
|
||||
if _routing_match(pattern, routing_key)
|
||||
]
|
||||
if mandatory and not subscribers:
|
||||
# Unroutable -> DLQ (REQ-023 AC-6)
|
||||
self._dlq.append((routing_key, dict(payload)))
|
||||
for handler in subscribers:
|
||||
try:
|
||||
handler(routing_key, dict(payload))
|
||||
except Exception: # noqa: BLE001 - subscriber failures must not poison publisher
|
||||
continue
|
||||
|
||||
# --- RPC client ---
|
||||
def call_rpc(
|
||||
self,
|
||||
queue: str,
|
||||
payload: dict,
|
||||
*,
|
||||
correlation_id: str,
|
||||
timeout: float,
|
||||
) -> dict:
|
||||
with self._lock:
|
||||
if not self._available:
|
||||
raise TransportUnavailableError(
|
||||
"transport disconnected; cannot call RPC"
|
||||
)
|
||||
handler = self._rpc_handlers.get(queue)
|
||||
if handler is None:
|
||||
raise RpcTimeoutError(f"no consumer for queue {queue}")
|
||||
# Direct synchronous dispatch (stub).
|
||||
req = RpcRequest(
|
||||
payload=dict(payload),
|
||||
correlation_id=correlation_id,
|
||||
reply_to=f"inmem-reply-{uuid.uuid4().hex[:8]}",
|
||||
)
|
||||
resp = handler(req)
|
||||
return dict(resp.payload)
|
||||
|
||||
# --- RPC server ---
|
||||
def consume_rpc_forever(
|
||||
self,
|
||||
queue: str,
|
||||
handler: RpcHandler,
|
||||
stop: threading.Event,
|
||||
) -> None:
|
||||
with self._lock:
|
||||
self._rpc_handlers[queue] = handler
|
||||
try:
|
||||
# Block until stop. Dispatch happens synchronously in call_rpc.
|
||||
while not stop.is_set():
|
||||
stop.wait(0.05)
|
||||
finally:
|
||||
with self._lock:
|
||||
if self._rpc_handlers.get(queue) is handler:
|
||||
del self._rpc_handlers[queue]
|
||||
|
||||
# --- Event subscriber ---
|
||||
def consume_events_forever(
|
||||
self,
|
||||
queue: str,
|
||||
binding_pattern: str,
|
||||
handler: EventHandler,
|
||||
stop: threading.Event,
|
||||
) -> None:
|
||||
entry = (binding_pattern, handler)
|
||||
with self._lock:
|
||||
self._event_subscribers.append(entry)
|
||||
try:
|
||||
while not stop.is_set():
|
||||
stop.wait(0.05)
|
||||
finally:
|
||||
with self._lock:
|
||||
if entry in self._event_subscribers:
|
||||
self._event_subscribers.remove(entry)
|
||||
|
||||
def close(self) -> None:
|
||||
with self._lock:
|
||||
self._available = False
|
||||
self._health = TransportHealth.DISCONNECTED
|
||||
|
||||
|
||||
def _routing_match(pattern: str, routing_key: str) -> bool:
|
||||
"""AMQP topic match (`*` = one word, `#` = zero-or-more words). Stub
|
||||
keeps it simple via fnmatch on word-aware translation."""
|
||||
# Convert AMQP wildcards to fnmatch: `*` -> any single token (no dots),
|
||||
# `#` -> any sequence including empty.
|
||||
parts = pattern.split(".")
|
||||
key_parts = routing_key.split(".")
|
||||
|
||||
def _match(p: list[str], k: list[str]) -> bool:
|
||||
if not p:
|
||||
return not k
|
||||
head, *rest = p
|
||||
if head == "#":
|
||||
# match zero or more words
|
||||
for i in range(len(k) + 1):
|
||||
if _match(rest, k[i:]):
|
||||
return True
|
||||
return False
|
||||
if not k:
|
||||
return False
|
||||
if head == "*" or head == k[0]:
|
||||
return _match(rest, k[1:])
|
||||
return False
|
||||
|
||||
return _match(parts, key_parts)
|
||||
@@ -0,0 +1,380 @@
|
||||
"""RabbitMQ (AMQP) transport adapter — pika BlockingConnection (REQ-022/023/024).
|
||||
|
||||
pika is NOT thread-safe across channels/connections, so every consumer or
|
||||
publisher uses its own connection/channel. The adapter owns one connection
|
||||
for *its* thread; the daemon creates multiple adapter instances (one per
|
||||
consumer/publisher worker thread).
|
||||
|
||||
Design highlights:
|
||||
- Reconnect backoff (1s -> 2s -> 4s -> 8s -> 16s -> 30s cap), implemented
|
||||
in the consume_*_forever loops. (pika has no built-in auto-reconnect.)
|
||||
- Publisher confirms (`channel.confirm_delivery`) + mandatory=True so an
|
||||
unroutable message NACKs via UnroutableError → DLQ.
|
||||
- Manual ack only AFTER the handler returns its response. If the handler
|
||||
raises, the message is NACKed with requeue=False so it goes to the DLQ
|
||||
instead of poison-looping (REQ-023 AC-6).
|
||||
- intent RPC consumer uses prefetch=1 (REQ-023 AC-3) for FIFO single-writer
|
||||
alignment.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
import pika
|
||||
import pika.exceptions
|
||||
|
||||
from trex_core.adapters.messaging.topology import (
|
||||
EVENTS_DLQ_EXCHANGE,
|
||||
EVENTS_DLQ_QUEUE,
|
||||
EVENTS_EXCHANGE,
|
||||
INTENT_REQ_QUEUE,
|
||||
SNAPSHOT_REQ_QUEUE,
|
||||
STRATEGY_QUEUE_ARGS,
|
||||
)
|
||||
from trex_core.application.messaging import (
|
||||
EventHandler,
|
||||
RpcHandler,
|
||||
RpcRequest,
|
||||
RpcTimeoutError,
|
||||
TransportHealth,
|
||||
TransportUnavailableError,
|
||||
)
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
_BACKOFF_SEQ = (1.0, 2.0, 4.0, 8.0, 16.0, 30.0)
|
||||
|
||||
|
||||
class RabbitMQTransport:
|
||||
def __init__(
|
||||
self,
|
||||
url: str,
|
||||
*,
|
||||
timeout: float = 5.0,
|
||||
intent_prefetch: int = 1,
|
||||
snapshot_prefetch: int = 8,
|
||||
) -> None:
|
||||
self._url = url
|
||||
self._timeout = timeout
|
||||
self._intent_prefetch = intent_prefetch
|
||||
self._snapshot_prefetch = snapshot_prefetch
|
||||
self._lock = threading.Lock()
|
||||
self._conn: Optional[pika.BlockingConnection] = None
|
||||
self._channel: Optional[pika.adapters.blocking_connection.BlockingChannel] = None
|
||||
self._health = TransportHealth.DISCONNECTED
|
||||
|
||||
# ------------------------------------------------------------ lifecycle
|
||||
def _connect(self) -> None:
|
||||
params = pika.URLParameters(self._url)
|
||||
params.socket_timeout = self._timeout
|
||||
params.blocked_connection_timeout = self._timeout
|
||||
params.heartbeat = 30
|
||||
with self._lock:
|
||||
self._health = TransportHealth.RECONNECTING
|
||||
conn = pika.BlockingConnection(params)
|
||||
ch = conn.channel()
|
||||
ch.confirm_delivery() # publisher confirms (REQ-023 AC-6)
|
||||
with self._lock:
|
||||
self._conn = conn
|
||||
self._channel = ch
|
||||
self._health = TransportHealth.CONNECTED
|
||||
|
||||
def _ensure_connected(self) -> None:
|
||||
with self._lock:
|
||||
ok = (
|
||||
self._conn is not None
|
||||
and self._conn.is_open
|
||||
and self._channel is not None
|
||||
and self._channel.is_open
|
||||
)
|
||||
if not ok:
|
||||
self._connect()
|
||||
|
||||
def close(self) -> None:
|
||||
with self._lock:
|
||||
ch, conn = self._channel, self._conn
|
||||
self._channel = None
|
||||
self._conn = None
|
||||
self._health = TransportHealth.DISCONNECTED
|
||||
try:
|
||||
if ch is not None and ch.is_open:
|
||||
ch.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
try:
|
||||
if conn is not None and conn.is_open:
|
||||
conn.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
def health(self) -> TransportHealth:
|
||||
with self._lock:
|
||||
return self._health
|
||||
|
||||
# ------------------------------------------------------------ topology
|
||||
def declare_topology(self) -> None:
|
||||
self._ensure_connected()
|
||||
ch = self._channel
|
||||
assert ch is not None
|
||||
# Events topic + DLQ
|
||||
ch.exchange_declare(EVENTS_EXCHANGE, exchange_type="topic", durable=True)
|
||||
ch.exchange_declare(EVENTS_DLQ_EXCHANGE, exchange_type="fanout", durable=True)
|
||||
ch.queue_declare(EVENTS_DLQ_QUEUE, durable=True)
|
||||
ch.queue_bind(EVENTS_DLQ_QUEUE, EVENTS_DLQ_EXCHANGE)
|
||||
# RPC queues (durable for intent + snapshot)
|
||||
ch.queue_declare(
|
||||
INTENT_REQ_QUEUE,
|
||||
durable=True,
|
||||
arguments={"x-dead-letter-exchange": EVENTS_DLQ_EXCHANGE},
|
||||
)
|
||||
ch.queue_declare(
|
||||
SNAPSHOT_REQ_QUEUE,
|
||||
durable=True,
|
||||
arguments={"x-dead-letter-exchange": EVENTS_DLQ_EXCHANGE},
|
||||
)
|
||||
|
||||
def declare_strategy_queue(self, queue: str, binding_pattern: str) -> None:
|
||||
"""Per-strategy durable queue (REQ-023 AC-4) bound to events exchange."""
|
||||
self._ensure_connected()
|
||||
ch = self._channel
|
||||
assert ch is not None
|
||||
ch.queue_declare(queue, durable=True, arguments=dict(STRATEGY_QUEUE_ARGS))
|
||||
ch.queue_bind(queue, EVENTS_EXCHANGE, routing_key=binding_pattern)
|
||||
|
||||
# ------------------------------------------------------------ publish
|
||||
def publish_event(
|
||||
self,
|
||||
routing_key: str,
|
||||
payload: dict,
|
||||
*,
|
||||
mandatory: bool = True,
|
||||
) -> None:
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
try:
|
||||
self._ensure_connected()
|
||||
ch = self._channel
|
||||
assert ch is not None
|
||||
ch.basic_publish(
|
||||
exchange=EVENTS_EXCHANGE,
|
||||
routing_key=routing_key,
|
||||
body=body,
|
||||
properties=pika.BasicProperties(
|
||||
content_type="application/json",
|
||||
delivery_mode=2, # persistent
|
||||
),
|
||||
mandatory=mandatory,
|
||||
)
|
||||
except pika.exceptions.UnroutableError:
|
||||
# No matching queue subscribers → push to DLQ collector (REQ-023 AC-6).
|
||||
self._publish_dlq(routing_key, payload, reason="unroutable")
|
||||
except (pika.exceptions.AMQPError, OSError) as exc:
|
||||
self._mark_disconnected()
|
||||
raise TransportUnavailableError(
|
||||
f"publish_event failed: {exc.__class__.__name__}: {exc}"
|
||||
) from exc
|
||||
|
||||
def _publish_dlq(self, routing_key: str, payload: dict, *, reason: str) -> None:
|
||||
try:
|
||||
self._ensure_connected()
|
||||
ch = self._channel
|
||||
assert ch is not None
|
||||
dlq_body = json.dumps(
|
||||
{"reason": reason, "original_routing_key": routing_key, "payload": payload}
|
||||
).encode("utf-8")
|
||||
ch.basic_publish(
|
||||
exchange=EVENTS_DLQ_EXCHANGE,
|
||||
routing_key="",
|
||||
body=dlq_body,
|
||||
properties=pika.BasicProperties(
|
||||
content_type="application/json", delivery_mode=2
|
||||
),
|
||||
)
|
||||
except Exception: # noqa: BLE001 - DLQ best-effort
|
||||
_log.warning("DLQ publish failed", exc_info=True)
|
||||
|
||||
# ------------------------------------------------------------ RPC client
|
||||
def call_rpc(
|
||||
self,
|
||||
queue: str,
|
||||
payload: dict,
|
||||
*,
|
||||
correlation_id: str,
|
||||
timeout: float,
|
||||
) -> dict:
|
||||
try:
|
||||
self._ensure_connected()
|
||||
except (pika.exceptions.AMQPError, OSError) as exc:
|
||||
self._mark_disconnected()
|
||||
raise TransportUnavailableError(f"call_rpc connect failed: {exc}") from exc
|
||||
|
||||
# We open a dedicated short-lived channel for this RPC to keep the
|
||||
# caller-thread reply isolated (pika BlockingConnection requires
|
||||
# consume from same thread that owns the channel).
|
||||
params = pika.URLParameters(self._url)
|
||||
params.socket_timeout = self._timeout
|
||||
try:
|
||||
conn = pika.BlockingConnection(params)
|
||||
except (pika.exceptions.AMQPError, OSError) as exc:
|
||||
self._mark_disconnected()
|
||||
raise TransportUnavailableError(f"call_rpc connect failed: {exc}") from exc
|
||||
|
||||
try:
|
||||
ch = conn.channel()
|
||||
reply_q = ch.queue_declare(queue="", exclusive=True, auto_delete=True).method.queue
|
||||
|
||||
holder: dict[str, dict] = {}
|
||||
|
||||
def on_reply(_ch, method, props, body):
|
||||
if props.correlation_id == correlation_id:
|
||||
holder["payload"] = json.loads(body)
|
||||
_ch.basic_ack(delivery_tag=method.delivery_tag)
|
||||
_ch.stop_consuming()
|
||||
|
||||
ch.basic_consume(queue=reply_q, on_message_callback=on_reply, auto_ack=False)
|
||||
ch.basic_publish(
|
||||
exchange="",
|
||||
routing_key=queue,
|
||||
body=json.dumps(payload).encode("utf-8"),
|
||||
properties=pika.BasicProperties(
|
||||
content_type="application/json",
|
||||
delivery_mode=2,
|
||||
correlation_id=correlation_id,
|
||||
reply_to=reply_q,
|
||||
),
|
||||
mandatory=True,
|
||||
)
|
||||
deadline = time.monotonic() + timeout
|
||||
while "payload" not in holder and time.monotonic() < deadline:
|
||||
conn.process_data_events(time_limit=min(0.2, deadline - time.monotonic()))
|
||||
if "payload" not in holder:
|
||||
raise RpcTimeoutError(
|
||||
f"RPC to {queue} (correlation_id={correlation_id}) timed out after {timeout}s"
|
||||
)
|
||||
return holder["payload"]
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
# ------------------------------------------------------------ RPC server
|
||||
def consume_rpc_forever(
|
||||
self,
|
||||
queue: str,
|
||||
handler: RpcHandler,
|
||||
stop: threading.Event,
|
||||
) -> None:
|
||||
prefetch = self._intent_prefetch if queue == INTENT_REQ_QUEUE else self._snapshot_prefetch
|
||||
backoff_idx = 0
|
||||
while not stop.is_set():
|
||||
try:
|
||||
self._ensure_connected()
|
||||
ch = self._channel
|
||||
assert ch is not None
|
||||
ch.basic_qos(prefetch_count=prefetch)
|
||||
# Declare in case adapter restarted before daemon-level declare.
|
||||
ch.queue_declare(
|
||||
queue,
|
||||
durable=True,
|
||||
arguments={"x-dead-letter-exchange": EVENTS_DLQ_EXCHANGE},
|
||||
)
|
||||
|
||||
def on_message(_ch, method, props, body):
|
||||
try:
|
||||
payload = json.loads(body) if body else {}
|
||||
req = RpcRequest(
|
||||
payload=payload,
|
||||
correlation_id=props.correlation_id or "",
|
||||
reply_to=props.reply_to,
|
||||
properties={"headers": props.headers or {}},
|
||||
)
|
||||
resp = handler(req)
|
||||
if props.reply_to:
|
||||
_ch.basic_publish(
|
||||
exchange="",
|
||||
routing_key=props.reply_to,
|
||||
body=json.dumps(resp.payload).encode("utf-8"),
|
||||
properties=pika.BasicProperties(
|
||||
content_type="application/json",
|
||||
correlation_id=props.correlation_id,
|
||||
),
|
||||
)
|
||||
_ch.basic_ack(delivery_tag=method.delivery_tag)
|
||||
except Exception: # noqa: BLE001
|
||||
# Handler failure: NACK without requeue -> DLQ (REQ-023 AC-6)
|
||||
_log.exception("RPC handler raised; routing to DLQ")
|
||||
try:
|
||||
_ch.basic_nack(delivery_tag=method.delivery_tag, requeue=False)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
ch.basic_consume(queue=queue, on_message_callback=on_message, auto_ack=False)
|
||||
backoff_idx = 0
|
||||
while not stop.is_set() and ch.is_open:
|
||||
self._conn.process_data_events(time_limit=0.2)
|
||||
try:
|
||||
ch.stop_consuming()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
except (pika.exceptions.AMQPError, OSError) as exc:
|
||||
_log.warning("RPC consumer reconnecting: %s", exc)
|
||||
self._mark_disconnected()
|
||||
self._sleep_backoff(stop, backoff_idx)
|
||||
backoff_idx = min(backoff_idx + 1, len(_BACKOFF_SEQ) - 1)
|
||||
|
||||
# ------------------------------------------------------------ Events
|
||||
def consume_events_forever(
|
||||
self,
|
||||
queue: str,
|
||||
binding_pattern: str,
|
||||
handler: EventHandler,
|
||||
stop: threading.Event,
|
||||
) -> None:
|
||||
backoff_idx = 0
|
||||
while not stop.is_set():
|
||||
try:
|
||||
self._ensure_connected()
|
||||
self.declare_strategy_queue(queue, binding_pattern)
|
||||
ch = self._channel
|
||||
assert ch is not None
|
||||
|
||||
def on_message(_ch, method, props, body):
|
||||
try:
|
||||
payload = json.loads(body) if body else {}
|
||||
handler(method.routing_key, payload)
|
||||
_ch.basic_ack(delivery_tag=method.delivery_tag)
|
||||
except Exception: # noqa: BLE001
|
||||
_log.exception("event handler raised; routing to DLQ")
|
||||
try:
|
||||
_ch.basic_nack(delivery_tag=method.delivery_tag, requeue=False)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
ch.basic_consume(queue=queue, on_message_callback=on_message, auto_ack=False)
|
||||
backoff_idx = 0
|
||||
while not stop.is_set() and ch.is_open:
|
||||
self._conn.process_data_events(time_limit=0.2)
|
||||
try:
|
||||
ch.stop_consuming()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
except (pika.exceptions.AMQPError, OSError) as exc:
|
||||
_log.warning("event consumer reconnecting: %s", exc)
|
||||
self._mark_disconnected()
|
||||
self._sleep_backoff(stop, backoff_idx)
|
||||
backoff_idx = min(backoff_idx + 1, len(_BACKOFF_SEQ) - 1)
|
||||
|
||||
# ------------------------------------------------------------ helpers
|
||||
def _mark_disconnected(self) -> None:
|
||||
with self._lock:
|
||||
self._health = TransportHealth.DISCONNECTED
|
||||
self._conn = None
|
||||
self._channel = None
|
||||
|
||||
def _sleep_backoff(self, stop: threading.Event, idx: int) -> None:
|
||||
delay = _BACKOFF_SEQ[min(idx, len(_BACKOFF_SEQ) - 1)]
|
||||
stop.wait(delay)
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Messaging topology constants + builders (REQ-021, dcy-006 OQ-2).
|
||||
|
||||
Three patterns:
|
||||
1. snapshot RPC — direct queue `trex.snapshot.req`, reply via amq.rabbitmq.reply-to
|
||||
2. intent RPC — direct queue `trex.intent.req`, reply via amq.rabbitmq.reply-to
|
||||
3. events — topic exchange `trex.events`, routing key {event_type}.{strategy_id}.{symbol}
|
||||
|
||||
Mapping principle: requests carry view/strategy_id in *payload*; events carry
|
||||
strategy_id/symbol in the *routing key* so brokers can filter per-strategy
|
||||
queues with patterns like `*.{strategy_id}.#`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
# --- RPC queues (durable for intent; transient-ish for snapshot but kept
|
||||
# durable for simplicity since the queue itself is shared) ---
|
||||
SNAPSHOT_REQ_QUEUE = "trex.snapshot.req"
|
||||
INTENT_REQ_QUEUE = "trex.intent.req"
|
||||
|
||||
# --- Events ---
|
||||
EVENTS_EXCHANGE = "trex.events" # topic, durable
|
||||
EVENTS_DLQ_EXCHANGE = "trex.events.dlq" # fanout, durable; receives unroutable/NACKed
|
||||
EVENTS_DLQ_QUEUE = "trex.events.dlq" # one collector queue bound to DLQ exchange
|
||||
|
||||
|
||||
def event_routing_key(event_type: str, strategy_id: str, symbol: str) -> str:
|
||||
"""Build the routing key for an event (dcy-006 OQ-2 mapping)."""
|
||||
# Sanitize tokens — AMQP routing keys cannot contain '.', '*', '#'.
|
||||
safe = lambda s: str(s or "_").replace(".", "_").replace("*", "_").replace("#", "_") # noqa: E731
|
||||
return f"{safe(event_type)}.{safe(strategy_id)}.{safe(symbol)}"
|
||||
|
||||
|
||||
def strategy_event_queue_name(strategy_id: str) -> str:
|
||||
"""Per-strategy durable queue name (catch-up + last-wins)."""
|
||||
return f"trex.events.strategy.{strategy_id}"
|
||||
|
||||
|
||||
def strategy_event_binding_pattern(strategy_id: str) -> str:
|
||||
"""Bind pattern for a strategy queue — all event types, all symbols.
|
||||
|
||||
Wildcards: `*` matches one word, `#` matches zero-or-more words.
|
||||
"""
|
||||
return f"*.{strategy_id}.#"
|
||||
|
||||
|
||||
# --- per-queue arguments (REQ-023 TTL / max-length / drop-oldest) ---
|
||||
STRATEGY_QUEUE_ARGS: dict = {
|
||||
"x-message-ttl": 3_600_000, # 1h
|
||||
"x-max-length": 10_000,
|
||||
"x-overflow": "drop-head", # drop-oldest, then accept new (REQ-023 AC-7)
|
||||
"x-dead-letter-exchange": EVENTS_DLQ_EXCHANGE,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -40,12 +40,18 @@ class PortfolioHubConfig:
|
||||
timeout: float = 5.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RabbitMQConfig:
|
||||
"""RabbitMQ transport adapter config (REQ-022, dcy-006 OQ-6)."""
|
||||
|
||||
url: str
|
||||
timeout: float = 5.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DaemonConfig:
|
||||
dsn: str
|
||||
total_slots: int
|
||||
host: str
|
||||
port: int
|
||||
broker: str # 'paper' | 'kis'
|
||||
execution_interval_sec: float
|
||||
kis: Optional[KISConfig] = None
|
||||
@@ -53,6 +59,9 @@ class DaemonConfig:
|
||||
# appropriate for slice-1 paper-broker integration tests).
|
||||
portfolio_hub: Optional[PortfolioHubConfig] = None
|
||||
ledger_interval_sec: float = 1.0
|
||||
# REQ-022 (BAT-007): RabbitMQ transport config. Required by the new
|
||||
# sync+thread daemon — HTTP API has been removed (REQ-025).
|
||||
rabbitmq: Optional[RabbitMQConfig] = None
|
||||
|
||||
@staticmethod
|
||||
def from_env() -> "DaemonConfig":
|
||||
@@ -82,14 +91,20 @@ class DaemonConfig:
|
||||
token=os.environ.get("TREX_PORTFOLIO_HUB_TOKEN", "").strip(),
|
||||
timeout=float(os.environ.get("TREX_PORTFOLIO_HUB_TIMEOUT", "5")),
|
||||
)
|
||||
rabbitmq: Optional[RabbitMQConfig] = None
|
||||
mq_url = os.environ.get("TREX_RABBITMQ_URL", "").strip()
|
||||
if mq_url:
|
||||
rabbitmq = RabbitMQConfig(
|
||||
url=mq_url,
|
||||
timeout=float(os.environ.get("TREX_RABBITMQ_TIMEOUT", "5")),
|
||||
)
|
||||
return DaemonConfig(
|
||||
dsn=dsn,
|
||||
total_slots=int(os.environ.get("TREX_TOTAL_SLOTS", "10")),
|
||||
host=os.environ.get("TREX_API_HOST", "127.0.0.1"),
|
||||
port=int(os.environ.get("TREX_API_PORT", "8080")),
|
||||
broker=broker,
|
||||
execution_interval_sec=float(os.environ.get("TREX_EXECUTION_INTERVAL_SEC", "1.0")),
|
||||
kis=kis,
|
||||
portfolio_hub=portfolio_hub,
|
||||
ledger_interval_sec=float(os.environ.get("TREX_LEDGER_INTERVAL_SEC", "1.0")),
|
||||
rabbitmq=rabbitmq,
|
||||
)
|
||||
|
||||
+155
-59
@@ -1,22 +1,32 @@
|
||||
"""Slot-core daemon entrypoint (REQ-005, REQ-012/013/014).
|
||||
"""Slot-core daemon entrypoint (REQ-005, REQ-012/013/014, BAT-007).
|
||||
|
||||
Builds the FastAPI app wired to PostgreSQL adapters + a broker (paper|KIS),
|
||||
applies migrations and preallocates slots on startup, runs a background
|
||||
execution loop (claim -> submit -> poll -> transition, with restart
|
||||
reconciliation), and serves the /v1 API via uvicorn.
|
||||
Sync + threaded runtime. HTTP `/v1` API has been removed (REQ-025); all
|
||||
strategy-facing communication runs over RabbitMQ (REQ-021/022/023).
|
||||
|
||||
Threads (started in `run()`):
|
||||
- intent RPC consumer — `trex.intent.req`, prefetch=1, FIFO, dedup via intent_log
|
||||
- snapshot RPC consumer — `trex.snapshot.req`
|
||||
- execution loop — broker dispatch + poll + slot transitions + event emit
|
||||
- ledger writer — ledger outbox drain (REQ-019)
|
||||
|
||||
Each consumer/publisher uses its OWN RabbitMQTransport instance because
|
||||
pika BlockingConnection is not thread-safe across channels.
|
||||
Console script: `trex-core-daemon`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import signal
|
||||
import threading
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from trex_core.adapters.api.app import create_app
|
||||
from trex_core.adapters.broker.paper import PaperBroker
|
||||
from trex_core.adapters.ledger import InMemoryLedger, PortfolioHubLedger
|
||||
from trex_core.adapters.messaging import (
|
||||
InMemoryTransport,
|
||||
RabbitMQTransport,
|
||||
topology,
|
||||
)
|
||||
from trex_core.adapters.persistence.migrate import apply_migrations
|
||||
from trex_core.adapters.persistence.postgresql import (
|
||||
PostgreSQLIntentStore,
|
||||
@@ -28,9 +38,17 @@ from trex_core.application.execution import ExecutionService
|
||||
from trex_core.application.intents import IntentService
|
||||
from trex_core.application.ledger_outbox import LedgerWriter
|
||||
from trex_core.application.ledger_port import LedgerPort
|
||||
from trex_core.application.messaging import MessagingTransport
|
||||
from trex_core.application.messaging_handlers import (
|
||||
make_intent_rpc_handler,
|
||||
make_snapshot_rpc_handler,
|
||||
slot_transition_event_payload,
|
||||
)
|
||||
from trex_core.domain.slot import SlotService
|
||||
from trex_core.runtime.config import DaemonConfig
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _build_broker(config: DaemonConfig) -> BrokerPort:
|
||||
if config.broker == "kis":
|
||||
@@ -58,67 +76,145 @@ def _build_ledger(config: DaemonConfig) -> LedgerPort:
|
||||
return PortfolioHubLedger(pf.base_url, token=pf.token or None, timeout=pf.timeout)
|
||||
|
||||
|
||||
def build_app(config: DaemonConfig) -> FastAPI:
|
||||
slot_repo = PostgreSQLSlotRepository(config.dsn)
|
||||
intent_store = PostgreSQLIntentStore(config.dsn)
|
||||
slot_service = SlotService(slot_repo)
|
||||
intent_service = IntentService(slot_service, slot_repo, intent_store)
|
||||
broker = _build_broker(config)
|
||||
outbox = PostgreSQLLedgerOutbox(config.dsn)
|
||||
execution = ExecutionService(slot_service, slot_repo, broker, ledger_outbox=outbox)
|
||||
ledger_writer = LedgerWriter(outbox, _build_ledger(config))
|
||||
def _build_transport(config: DaemonConfig) -> MessagingTransport:
|
||||
"""RabbitMQ when configured; otherwise in-memory stub (REQ-022)."""
|
||||
mq = config.rabbitmq
|
||||
if mq is None:
|
||||
# Stub: lets the daemon boot in environments without a broker (CI,
|
||||
# local dev). REQ-024 degrade policy still applies — call_rpc raises.
|
||||
return InMemoryTransport()
|
||||
return RabbitMQTransport(mq.url, timeout=mq.timeout)
|
||||
|
||||
base = create_app(
|
||||
slot_service=slot_service,
|
||||
slot_repo=slot_repo,
|
||||
intent_service=intent_service,
|
||||
default_total_slots=config.total_slots,
|
||||
)
|
||||
|
||||
stop = threading.Event()
|
||||
class Daemon:
|
||||
"""Thread-orchestrating daemon (no HTTP)."""
|
||||
|
||||
def _execution_loop() -> None:
|
||||
while not stop.wait(config.execution_interval_sec):
|
||||
try:
|
||||
execution.run_once()
|
||||
except Exception: # noqa: BLE001 - loop must survive transient errors
|
||||
continue
|
||||
def __init__(self, config: DaemonConfig) -> None:
|
||||
self.config = config
|
||||
self.stop = threading.Event()
|
||||
self._threads: list[threading.Thread] = []
|
||||
self._transports: list[MessagingTransport] = []
|
||||
|
||||
def _ledger_loop() -> None:
|
||||
while not stop.wait(config.ledger_interval_sec):
|
||||
try:
|
||||
ledger_writer.run_once()
|
||||
except Exception: # noqa: BLE001 - loop must survive transient errors
|
||||
continue
|
||||
# Persistence + services
|
||||
self.slot_repo = PostgreSQLSlotRepository(config.dsn)
|
||||
self.intent_store = PostgreSQLIntentStore(config.dsn)
|
||||
self.slot_service = SlotService(self.slot_repo)
|
||||
self.intent_service = IntentService(self.slot_service, self.slot_repo, self.intent_store)
|
||||
self.broker = _build_broker(config)
|
||||
self.outbox = PostgreSQLLedgerOutbox(config.dsn)
|
||||
self.ledger_writer = LedgerWriter(self.outbox, _build_ledger(config))
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI):
|
||||
apply_migrations(config.dsn)
|
||||
slot_service.preallocate_daily_slots(
|
||||
datetime.now(tz=timezone.utc).date(), config.total_slots
|
||||
# Event publisher transport — one dedicated instance for the
|
||||
# execution-loop thread (pika is not thread-safe).
|
||||
self.event_publisher: MessagingTransport = _build_transport(config)
|
||||
self._transports.append(self.event_publisher)
|
||||
|
||||
self.execution = ExecutionService(
|
||||
self.slot_service, self.slot_repo, self.broker,
|
||||
ledger_outbox=self.outbox,
|
||||
event_emitter=self._make_event_emitter(),
|
||||
)
|
||||
execution.reconcile() # restart recovery before serving (REQ-014)
|
||||
exec_worker = threading.Thread(target=_execution_loop, name="trex-execution", daemon=True)
|
||||
ledger_worker = threading.Thread(target=_ledger_loop, name="trex-ledger", daemon=True)
|
||||
exec_worker.start()
|
||||
ledger_worker.start()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
stop.set()
|
||||
exec_worker.join(timeout=config.execution_interval_sec * 3)
|
||||
ledger_worker.join(timeout=config.ledger_interval_sec * 3)
|
||||
|
||||
base.router.lifespan_context = lifespan
|
||||
return base
|
||||
# ------------------------------------------------------------ event hook
|
||||
def _make_event_emitter(self):
|
||||
def emit(event_type: str, slot) -> None:
|
||||
payload = slot_transition_event_payload(slot, event_type=event_type)
|
||||
rk = topology.event_routing_key(
|
||||
event_type, slot.strategy_id or "_", slot.symbol or "_",
|
||||
)
|
||||
try:
|
||||
self.event_publisher.publish_event(rk, payload)
|
||||
except Exception: # noqa: BLE001 - publish failure must not roll back
|
||||
_log.warning("event publish failed (rk=%s)", rk, exc_info=True)
|
||||
return emit
|
||||
|
||||
# ------------------------------------------------------------ workers
|
||||
def _execution_loop(self) -> None:
|
||||
while not self.stop.wait(self.config.execution_interval_sec):
|
||||
try:
|
||||
self.execution.run_once()
|
||||
except Exception: # noqa: BLE001 - loop must survive transient errors
|
||||
continue
|
||||
|
||||
def _ledger_loop(self) -> None:
|
||||
while not self.stop.wait(self.config.ledger_interval_sec):
|
||||
try:
|
||||
self.ledger_writer.run_once()
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
|
||||
def _intent_rpc_loop(self) -> None:
|
||||
transport = _build_transport(self.config)
|
||||
self._transports.append(transport)
|
||||
try:
|
||||
transport.declare_topology()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
handler = make_intent_rpc_handler(self.intent_service)
|
||||
transport.consume_rpc_forever(topology.INTENT_REQ_QUEUE, handler, self.stop)
|
||||
|
||||
def _snapshot_rpc_loop(self) -> None:
|
||||
transport = _build_transport(self.config)
|
||||
self._transports.append(transport)
|
||||
try:
|
||||
transport.declare_topology()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
handler = make_snapshot_rpc_handler(self.slot_repo, self.slot_service)
|
||||
transport.consume_rpc_forever(topology.SNAPSHOT_REQ_QUEUE, handler, self.stop)
|
||||
|
||||
# ------------------------------------------------------------ lifecycle
|
||||
def setup(self) -> None:
|
||||
apply_migrations(self.config.dsn)
|
||||
self.slot_service.preallocate_daily_slots(
|
||||
datetime.now(tz=timezone.utc).date(), self.config.total_slots
|
||||
)
|
||||
self.execution.reconcile() # REQ-014 restart recovery
|
||||
try:
|
||||
self.event_publisher.declare_topology()
|
||||
except Exception: # noqa: BLE001
|
||||
_log.warning("event publisher topology declare failed", exc_info=True)
|
||||
|
||||
def start(self) -> None:
|
||||
workers = [
|
||||
("trex-execution", self._execution_loop),
|
||||
("trex-ledger", self._ledger_loop),
|
||||
("trex-intent-rpc", self._intent_rpc_loop),
|
||||
("trex-snapshot-rpc", self._snapshot_rpc_loop),
|
||||
]
|
||||
for name, target in workers:
|
||||
t = threading.Thread(target=target, name=name, daemon=True)
|
||||
t.start()
|
||||
self._threads.append(t)
|
||||
|
||||
def shutdown(self) -> None:
|
||||
self.stop.set()
|
||||
for t in self._threads:
|
||||
t.join(
|
||||
timeout=max(self.config.execution_interval_sec, self.config.ledger_interval_sec) * 3
|
||||
)
|
||||
for tr in self._transports:
|
||||
try:
|
||||
tr.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
def run(self) -> None: # pragma: no cover - process entrypoint
|
||||
self.setup()
|
||||
self.start()
|
||||
signal.signal(signal.SIGINT, lambda *_: self.stop.set())
|
||||
signal.signal(signal.SIGTERM, lambda *_: self.stop.set())
|
||||
try:
|
||||
while not self.stop.is_set():
|
||||
self.stop.wait(1.0)
|
||||
finally:
|
||||
self.shutdown()
|
||||
|
||||
|
||||
def main() -> None: # pragma: no cover - process entrypoint
|
||||
import uvicorn
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
config = DaemonConfig.from_env()
|
||||
app = build_app(config)
|
||||
uvicorn.run(app, host=config.host, port=config.port)
|
||||
Daemon(config).run()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
Reference in New Issue
Block a user