Files
microdao-daarion/services/senpai-md-consumer/senpai/md_consumer/publisher.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

120 lines
3.9 KiB
Python

"""
Signal bus publisher — publishes features, signals, and alerts to NATS.
Rate-limiting: max N publishes per second per symbol (configurable).
"""
from __future__ import annotations
import logging
import time
from nats.aio.client import Client as NatsClient
from senpai.md_consumer.config import settings
from senpai.md_consumer.models import AlertEvent, FeatureSnapshot, TradeSignal
from senpai.md_consumer import metrics as m
logger = logging.getLogger(__name__)
class Publisher:
"""
Publishes FeatureSnapshots and TradeSignals to NATS.
Built-in per-symbol rate limiter.
"""
def __init__(self, nc: NatsClient) -> None:
self._nc = nc
self._last_publish: dict[str, float] = {} # symbol → monotonic time
self._min_interval = (
1.0 / settings.features_pub_rate_hz
if settings.features_pub_rate_hz > 0
else 0.1
)
def _rate_ok(self, symbol: str) -> bool:
"""Check if we can publish for this symbol (rate limiter)."""
now = time.monotonic()
last = self._last_publish.get(symbol, 0.0)
if now - last >= self._min_interval:
self._last_publish[symbol] = now
return True
return False
async def publish_features(self, snapshot: FeatureSnapshot) -> bool:
"""
Publish feature snapshot if rate limit allows.
Returns True if published, False if rate-limited or error.
"""
if not settings.features_enabled:
return False
symbol = snapshot.symbol.upper()
if not self._rate_ok(symbol):
return False
subject = f"{settings.features_pub_subject}.{symbol}"
try:
payload = snapshot.model_dump_json().encode("utf-8")
await self._nc.publish(subject, payload)
m.FEATURE_PUBLISH.labels(symbol=symbol).inc()
return True
except Exception as e:
m.FEATURE_PUBLISH_ERRORS.labels(symbol=symbol).inc()
logger.warning(
"publisher.feature_error",
extra={"symbol": symbol, "error": str(e)},
)
return False
async def publish_signal(self, signal: TradeSignal) -> bool:
"""Publish trade signal (no rate limit — signals are rare)."""
subject = f"{settings.signals_pub_subject}.{signal.symbol}"
try:
payload = signal.model_dump_json().encode("utf-8")
await self._nc.publish(subject, payload)
m.SIGNALS_EMITTED.labels(
symbol=signal.symbol,
direction=signal.direction,
).inc()
logger.info(
"publisher.signal_emitted",
extra={
"symbol": signal.symbol,
"direction": signal.direction,
"confidence": f"{signal.confidence:.2f}",
"reason": signal.reason,
},
)
return True
except Exception as e:
logger.error(
"publisher.signal_error",
extra={"symbol": signal.symbol, "error": str(e)},
)
return False
async def publish_alert(self, alert: AlertEvent) -> bool:
"""Publish alert event."""
subject = settings.alerts_pub_subject
try:
payload = alert.model_dump_json().encode("utf-8")
await self._nc.publish(subject, payload)
m.ALERTS_EMITTED.labels(alert_type=alert.alert_type).inc()
logger.warning(
"publisher.alert",
extra={
"type": alert.alert_type,
"level": alert.level,
"message": alert.message,
},
)
return True
except Exception as e:
logger.error(
"publisher.alert_error",
extra={"error": str(e)},
)
return False