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>
108 lines
3.1 KiB
Python
108 lines
3.1 KiB
Python
"""
|
|
Unit tests for Alpaca raw → domain event parsing.
|
|
"""
|
|
import pytest
|
|
from app.providers.alpaca import AlpacaProvider
|
|
from app.domain.events import EventType
|
|
|
|
|
|
@pytest.fixture
|
|
def provider():
|
|
return AlpacaProvider()
|
|
|
|
|
|
# ── Trade parsing ──────────────────────────────────────────────────────
|
|
|
|
ALPACA_TRADE_RAW = {
|
|
"T": "t",
|
|
"S": "AAPL",
|
|
"p": 185.50,
|
|
"s": 100,
|
|
"t": "2024-01-15T14:30:00.123456Z",
|
|
"i": 12345,
|
|
"x": "V",
|
|
"z": "C",
|
|
}
|
|
|
|
|
|
def test_parse_trade_basic(provider):
|
|
event = provider._parse(ALPACA_TRADE_RAW)
|
|
assert event is not None
|
|
assert event.event_type == EventType.TRADE
|
|
assert event.symbol == "AAPL"
|
|
assert event.price == 185.50
|
|
assert event.size == 100
|
|
assert event.trade_id == "12345"
|
|
assert event.provider == "alpaca"
|
|
|
|
|
|
def test_parse_trade_timestamp(provider):
|
|
event = provider._parse(ALPACA_TRADE_RAW)
|
|
assert event.ts_exchange is not None
|
|
assert event.ts_exchange.year == 2024
|
|
assert event.ts_exchange.month == 1
|
|
|
|
|
|
# ── Quote parsing ──────────────────────────────────────────────────────
|
|
|
|
ALPACA_QUOTE_RAW = {
|
|
"T": "q",
|
|
"S": "TSLA",
|
|
"bp": 250.10,
|
|
"bs": 200,
|
|
"ap": 250.25,
|
|
"as": 150,
|
|
"t": "2024-01-15T14:30:01.456789Z",
|
|
"x": "V",
|
|
"z": "C",
|
|
}
|
|
|
|
|
|
def test_parse_quote_basic(provider):
|
|
event = provider._parse(ALPACA_QUOTE_RAW)
|
|
assert event is not None
|
|
assert event.event_type == EventType.QUOTE
|
|
assert event.symbol == "TSLA"
|
|
assert event.bid == 250.10
|
|
assert event.ask == 250.25
|
|
assert event.bid_size == 200
|
|
assert event.ask_size == 150
|
|
assert event.provider == "alpaca"
|
|
|
|
|
|
# ── Control messages ───────────────────────────────────────────────────
|
|
|
|
def test_parse_success_message(provider):
|
|
raw = {"T": "success", "msg": "connected"}
|
|
event = provider._parse(raw)
|
|
assert event is None
|
|
|
|
|
|
def test_parse_subscription_message(provider):
|
|
raw = {"T": "subscription", "trades": ["AAPL"], "quotes": ["AAPL"]}
|
|
event = provider._parse(raw)
|
|
assert event is None
|
|
|
|
|
|
def test_parse_error_message(provider):
|
|
raw = {"T": "error", "code": 402, "msg": "auth failed"}
|
|
event = provider._parse(raw)
|
|
assert event is None
|
|
|
|
|
|
# ── Edge cases ─────────────────────────────────────────────────────────
|
|
|
|
def test_parse_trade_missing_timestamp(provider):
|
|
raw = {"T": "t", "S": "AAPL", "p": 100, "s": 10, "i": 1}
|
|
event = provider._parse(raw)
|
|
assert event is not None
|
|
assert event.ts_exchange is None
|
|
|
|
|
|
def test_parse_quote_zero_values(provider):
|
|
raw = {"T": "q", "S": "SPY", "bp": 0, "bs": 0, "ap": 0, "as": 0}
|
|
event = provider._parse(raw)
|
|
assert event is not None
|
|
assert event.bid == 0
|
|
assert event.ask == 0
|