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