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>
98 lines
2.9 KiB
Python
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 == ""
|