Files
Apple c50843933f feat: market-data-service for SenpAI trading agent
New service: real-time market data collection with unified event model.

Architecture:
- Domain events: TradeEvent, QuoteEvent, BookL2Event, HeartbeatEvent
- Provider interface: MarketDataProvider ABC with connect/subscribe/stream/close
- Async EventBus with fan-out to multiple consumers

Providers:
- BinanceProvider: public WebSocket (trades + bookTicker), no API key needed,
  auto-reconnect with exponential backoff, heartbeat timeout detection
- AlpacaProvider: IEX real-time data + paper trading auth,
  dry-run mode when no keys configured (heartbeats only)

Consumers:
- StorageConsumer: SQLite (via SQLAlchemy async) + JSONL append-only log
- MetricsConsumer: Prometheus counters, latency histograms, events/sec gauge
- PrintConsumer: sampled structured logging (1/100 events)

CLI: python -m app run --provider binance --symbols BTCUSDT,ETHUSDT
HTTP: /health, /metrics (Prometheus), /latest?symbol=XXX

Tests: 19/19 passed (Binance parse, Alpaca parse, bus smoke tests)

Config: pydantic-settings + .env, all secrets via environment variables.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-09 11:19:00 -08:00

98 lines
2.9 KiB
Python

"""
Unit tests for Binance raw → domain event parsing.
"""
import pytest
from app.providers.binance import BinanceProvider
from app.domain.events import EventType
@pytest.fixture
def provider():
return BinanceProvider()
# ── Trade parsing ──────────────────────────────────────────────────────
BINANCE_TRADE_RAW = {
"e": "trade",
"E": 1672515782136,
"s": "BTCUSDT",
"t": 123456789,
"p": "42500.50",
"q": "0.015",
"T": 1672515782135,
"m": True,
}
def test_parse_trade_basic(provider):
event = provider._parse(BINANCE_TRADE_RAW)
assert event is not None
assert event.event_type == EventType.TRADE
assert event.symbol == "BTCUSDT"
assert event.price == 42500.50
assert event.size == 0.015
assert event.side == "sell" # m=True → seller is maker → trade is sell
assert event.trade_id == "123456789"
assert event.provider == "binance"
def test_parse_trade_ts_exchange(provider):
event = provider._parse(BINANCE_TRADE_RAW)
assert event.ts_exchange is not None
# 1672515782135 ms → 2022-12-31 or 2023-01-01 (depending on TZ)
assert event.ts_exchange.year >= 2022
def test_parse_trade_buy_side(provider):
raw = {**BINANCE_TRADE_RAW, "m": False}
event = provider._parse(raw)
assert event.side == "buy"
# ── BookTicker (Quote) parsing ─────────────────────────────────────────
BINANCE_BOOKTICKER_RAW = {
"u": 400900217,
"s": "ETHUSDT",
"b": "2150.25000000",
"B": "31.21000000",
"a": "2150.50000000",
"A": "40.66000000",
}
def test_parse_bookticker(provider):
event = provider._parse(BINANCE_BOOKTICKER_RAW)
assert event is not None
assert event.event_type == EventType.QUOTE
assert event.symbol == "ETHUSDT"
assert event.bid == 2150.25
assert event.ask == 2150.50
assert event.bid_size == 31.21
assert event.ask_size == 40.66
assert event.provider == "binance"
# ── Edge cases ─────────────────────────────────────────────────────────
def test_parse_unknown_event(provider):
raw = {"e": "aggTrade", "s": "BTCUSDT", "p": "100"}
event = provider._parse(raw)
assert event is None
def test_parse_subscription_confirmation(provider):
raw = {"result": None, "id": 1}
# This is handled in stream(), not _parse(), so _parse should return None
event = provider._parse(raw)
assert event is None
def test_parse_empty_values(provider):
raw = {"e": "trade", "s": "", "p": "0", "q": "0", "T": None, "m": True, "t": ""}
event = provider._parse(raw)
assert event is not None
assert event.price == 0.0
assert event.symbol == ""