"""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": "", ... 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": "", "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, }