Files
microdao-daarion/services/senpai-md-consumer/senpai/md_consumer/models.py
Apple 09dee24342 feat: MD pipeline — market-data-service hardening + SenpAI NATS consumer
Producer (market-data-service):
- Backpressure: smart drop policy (heartbeats→quotes→trades preserved)
- Heartbeat monitor: synthetic HeartbeatEvent on provider silence
- Graceful shutdown: WS→bus→storage→DB engine cleanup sequence
- Bybit V5 public WS provider (backup for Binance, no API key needed)
- FailoverManager: health-based provider switching with recovery
- NATS output adapter: md.events.{type}.{symbol} for SenpAI
- /bus-stats endpoint for backpressure monitoring
- Dockerfile + docker-compose.node1.yml integration
- 36 tests (parsing + bus + failover), requirements.lock

Consumer (senpai-md-consumer):
- NATSConsumer: subscribe md.events.>, queue group senpai-md, backpressure
- State store: LatestState + RollingWindow (deque, 60s)
- Feature engine: 11 features (mid, spread, VWAP, return, vol, latency)
- Rule-based signals: long/short on return+volume+spread conditions
- Publisher: rate-limited features + signals + alerts to NATS
- HTTP API: /health, /metrics, /state/latest, /features/latest, /stats
- 10 Prometheus metrics
- Dockerfile + docker-compose.senpai.yml
- 41 tests (parsing + state + features + rate-limit), requirements.lock

CI: ruff + pytest + smoke import for both services
Tests: 77 total passed, lint clean
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-09 11:46:15 -08:00

140 lines
3.4 KiB
Python

"""
Domain models — mirrors market-data-service event contracts.
Tolerant parsing: unknown fields ignored, partial data accepted.
"""
from __future__ import annotations
import time
from datetime import datetime, timezone
from enum import Enum
from typing import Optional
from pydantic import BaseModel, Field
class EventType(str, Enum):
TRADE = "trade"
QUOTE = "quote"
BOOK_L2 = "book_l2"
HEARTBEAT = "heartbeat"
def _utc_now() -> datetime:
return datetime.now(timezone.utc)
def _mono_ns() -> int:
return time.monotonic_ns()
class BaseEvent(BaseModel, extra="ignore"):
"""Common fields — extra fields silently ignored."""
event_type: EventType
provider: str
ts_recv: datetime = Field(default_factory=_utc_now)
ts_recv_mono_ns: int = Field(default_factory=_mono_ns)
class TradeEvent(BaseEvent):
event_type: EventType = EventType.TRADE
symbol: str
price: float
size: float
ts_exchange: Optional[datetime] = None
side: Optional[str] = None
trade_id: Optional[str] = None
class QuoteEvent(BaseEvent):
event_type: EventType = EventType.QUOTE
symbol: str
bid: float
ask: float
bid_size: float
ask_size: float
ts_exchange: Optional[datetime] = None
class BookLevel(BaseModel, extra="ignore"):
price: float
size: float
class BookL2Event(BaseEvent):
event_type: EventType = EventType.BOOK_L2
symbol: str
bids: list[BookLevel] = Field(default_factory=list)
asks: list[BookLevel] = Field(default_factory=list)
ts_exchange: Optional[datetime] = None
class HeartbeatEvent(BaseEvent):
event_type: EventType = EventType.HEARTBEAT
# Union for parsing
Event = TradeEvent | QuoteEvent | BookL2Event | HeartbeatEvent
# ── Output models ──────────────────────────────────────────────────────
class FeatureSnapshot(BaseModel):
"""Published to senpai.features.{symbol}."""
symbol: str
ts: datetime = Field(default_factory=_utc_now)
features: dict[str, float | None]
class TradeSignal(BaseModel):
"""Published to senpai.signals.{symbol}."""
symbol: str
ts: datetime = Field(default_factory=_utc_now)
direction: str # "long" | "short"
confidence: float = 0.0 # 0..1
reason: str = ""
features: dict[str, float | None] = Field(default_factory=dict)
class AlertEvent(BaseModel):
"""Published to senpai.alerts."""
ts: datetime = Field(default_factory=_utc_now)
level: str = "warning" # "warning" | "critical"
alert_type: str # "latency" | "gap" | "backpressure"
message: str
details: dict = Field(default_factory=dict)
# ── Parsing helper ─────────────────────────────────────────────────────
_EVENT_MAP: dict[str, type[BaseEvent]] = {
"trade": TradeEvent,
"quote": QuoteEvent,
"book_l2": BookL2Event,
"heartbeat": HeartbeatEvent,
}
def parse_event(data: dict) -> Event | None:
"""
Parse a dict (from JSON) into the appropriate Event model.
Returns None if event_type is unknown or data is invalid.
"""
event_type = data.get("event_type")
if not event_type:
return None
cls = _EVENT_MAP.get(event_type)
if cls is None:
return None
try:
return cls.model_validate(data)
except Exception:
return None