202 lines
7.5 KiB
Python
202 lines
7.5 KiB
Python
"""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,
|
|
}
|