120 lines
3.9 KiB
Python
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.debug(
|
|
"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
|