""" 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