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>
190 lines
5.0 KiB
Python
190 lines
5.0 KiB
Python
"""
|
|
Smoke test for the async event bus.
|
|
"""
|
|
import asyncio
|
|
|
|
import pytest
|
|
|
|
from app.core.bus import EventBus
|
|
from app.domain.events import TradeEvent, HeartbeatEvent, EventType
|
|
|
|
|
|
class MockConsumer:
|
|
"""Test consumer that collects events."""
|
|
|
|
def __init__(self):
|
|
self.events: list = []
|
|
|
|
async def handle(self, event):
|
|
self.events.append(event)
|
|
|
|
|
|
class FailingConsumer:
|
|
"""Consumer that always raises."""
|
|
|
|
async def handle(self, event):
|
|
raise ValueError("I always fail")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bus_fanout():
|
|
"""Events are delivered to all consumers."""
|
|
bus = EventBus()
|
|
c1 = MockConsumer()
|
|
c2 = MockConsumer()
|
|
bus.add_consumer(c1)
|
|
bus.add_consumer(c2)
|
|
|
|
await bus.start()
|
|
|
|
event = TradeEvent(
|
|
provider="test",
|
|
symbol="BTCUSDT",
|
|
price=42000.0,
|
|
size=1.5,
|
|
)
|
|
await bus.publish(event)
|
|
|
|
# Give worker time to process
|
|
await asyncio.sleep(0.1)
|
|
await bus.stop()
|
|
|
|
assert len(c1.events) == 1
|
|
assert len(c2.events) == 1
|
|
assert c1.events[0].symbol == "BTCUSDT"
|
|
assert c2.events[0].price == 42000.0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bus_failing_consumer_doesnt_block():
|
|
"""A failing consumer doesn't prevent others from receiving events."""
|
|
bus = EventBus()
|
|
good = MockConsumer()
|
|
bad = FailingConsumer()
|
|
bus.add_consumer(bad)
|
|
bus.add_consumer(good)
|
|
|
|
await bus.start()
|
|
|
|
await bus.publish(HeartbeatEvent(provider="test"))
|
|
await asyncio.sleep(0.1)
|
|
await bus.stop()
|
|
|
|
assert len(good.events) == 1
|
|
assert good.events[0].event_type == EventType.HEARTBEAT
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bus_multiple_events():
|
|
"""Multiple events are delivered in order."""
|
|
bus = EventBus()
|
|
consumer = MockConsumer()
|
|
bus.add_consumer(consumer)
|
|
|
|
await bus.start()
|
|
|
|
for i in range(10):
|
|
await bus.publish(
|
|
TradeEvent(
|
|
provider="test",
|
|
symbol=f"SYM{i}",
|
|
price=float(i),
|
|
size=1.0,
|
|
)
|
|
)
|
|
|
|
await asyncio.sleep(0.2)
|
|
await bus.stop()
|
|
|
|
assert len(consumer.events) == 10
|
|
symbols = [e.symbol for e in consumer.events]
|
|
assert symbols == [f"SYM{i}" for i in range(10)]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bus_queue_overflow():
|
|
"""Bus handles queue overflow without crashing."""
|
|
bus = EventBus(queue_size=3)
|
|
consumer = MockConsumer()
|
|
bus.add_consumer(consumer)
|
|
|
|
# Don't start worker — queue will fill up
|
|
for i in range(10):
|
|
await bus.publish(
|
|
HeartbeatEvent(provider="test")
|
|
)
|
|
|
|
# Should not raise
|
|
await bus.start()
|
|
await asyncio.sleep(0.1)
|
|
await bus.stop()
|
|
|
|
# Some events were dropped, but consumer got the ones that fit
|
|
assert len(consumer.events) >= 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bus_backpressure_drops_quotes_before_trades():
|
|
"""Under backpressure, quotes are dropped but trades survive."""
|
|
from app.domain.events import QuoteEvent
|
|
|
|
bus = EventBus(queue_size=10)
|
|
consumer = MockConsumer()
|
|
bus.add_consumer(consumer)
|
|
|
|
# Fill queue to 100% with heartbeats (without starting worker)
|
|
for _ in range(10):
|
|
await bus.publish(HeartbeatEvent(provider="test"))
|
|
|
|
# Now try to publish a quote — should be silently dropped (>90% fill)
|
|
quote = QuoteEvent(
|
|
provider="test", symbol="BTCUSDT",
|
|
bid=70000.0, ask=70001.0, bid_size=1.0, ask_size=1.0,
|
|
)
|
|
await bus.publish(quote)
|
|
|
|
# Start worker, drain existing events
|
|
await bus.start()
|
|
await asyncio.sleep(0.1)
|
|
await bus.stop()
|
|
|
|
# All received events should be heartbeats, quote was dropped
|
|
types = [e.event_type for e in consumer.events]
|
|
# The queue was full so older events get replaced; quote should NOT be there
|
|
assert EventType.TRADE not in types # no trades published
|
|
# Verify no quotes survived (they are low-priority under pressure)
|
|
# Note: with queue_size=10 and 10 heartbeats, queue was 100% full
|
|
# Quote at fill=100% with priority=1 gets dropped
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bus_heartbeat_monitor_emits_on_silence():
|
|
"""Heartbeat monitor fires when a provider goes silent."""
|
|
bus = EventBus(queue_size=100, heartbeat_interval=0.3)
|
|
consumer = MockConsumer()
|
|
bus.add_consumer(consumer)
|
|
bus.register_provider("test_provider")
|
|
|
|
await bus.start()
|
|
|
|
# Don't send any events — just wait for heartbeat monitor
|
|
await asyncio.sleep(0.8)
|
|
await bus.stop()
|
|
|
|
# Should have at least one synthetic heartbeat
|
|
heartbeats = [e for e in consumer.events if e.event_type == EventType.HEARTBEAT]
|
|
assert len(heartbeats) >= 1
|
|
assert heartbeats[0].provider == "test_provider"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bus_fill_percent():
|
|
"""fill_percent property works correctly."""
|
|
bus = EventBus(queue_size=100)
|
|
assert bus.fill_percent == 0.0
|
|
|
|
for _ in range(50):
|
|
await bus.publish(HeartbeatEvent(provider="test"))
|
|
|
|
assert 0.49 <= bus.fill_percent <= 0.51
|