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>
271 lines
8.3 KiB
Python
271 lines
8.3 KiB
Python
"""
|
|
Alpaca Markets provider — paper trading + IEX real-time data.
|
|
|
|
Requires ALPACA_KEY + ALPACA_SECRET in .env for live mode.
|
|
Falls back to dry-run mode if keys are not configured.
|
|
|
|
Subscribes to:
|
|
- trades → TradeEvent
|
|
- quotes → QuoteEvent
|
|
|
|
Alpaca WebSocket protocol:
|
|
wss://stream.data.alpaca.markets/v2/iex
|
|
Auth → subscribe → stream messages
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
from datetime import datetime
|
|
from typing import AsyncIterator
|
|
|
|
import websockets
|
|
from websockets.exceptions import ConnectionClosed
|
|
|
|
from app.config import settings
|
|
from app.domain.events import (
|
|
Event,
|
|
HeartbeatEvent,
|
|
QuoteEvent,
|
|
TradeEvent,
|
|
)
|
|
from app.providers import MarketDataProvider
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _iso_to_dt(ts_str: str | None) -> datetime | None:
|
|
"""Parse Alpaca ISO-8601 timestamp to UTC datetime."""
|
|
if not ts_str:
|
|
return None
|
|
try:
|
|
# Alpaca uses RFC3339 with Z or +00:00
|
|
ts_str = ts_str.replace("Z", "+00:00")
|
|
return datetime.fromisoformat(ts_str)
|
|
except (ValueError, TypeError):
|
|
return None
|
|
|
|
|
|
class AlpacaProvider(MarketDataProvider):
|
|
"""
|
|
Alpaca IEX real-time data + paper trading integration.
|
|
|
|
In dry-run mode (no keys), generates synthetic heartbeats
|
|
and logs a warning — useful for testing the pipeline without keys.
|
|
"""
|
|
|
|
name = "alpaca"
|
|
|
|
def __init__(self) -> None:
|
|
self._ws: websockets.WebSocketClientProtocol | None = None
|
|
self._symbols: list[str] = []
|
|
self._connected = False
|
|
self._authenticated = False
|
|
self._reconnect_count = 0
|
|
self._dry_run = not settings.alpaca_configured or settings.alpaca_dry_run
|
|
|
|
async def connect(self) -> None:
|
|
"""Establish WebSocket connection and authenticate."""
|
|
if self._dry_run:
|
|
logger.warning(
|
|
"alpaca.dry_run_mode",
|
|
extra={"reason": "No ALPACA_KEY/ALPACA_SECRET or dry_run=True"},
|
|
)
|
|
self._connected = True
|
|
return
|
|
|
|
url = settings.alpaca_data_ws_url
|
|
logger.info("alpaca.connecting", extra={"url": url})
|
|
|
|
self._ws = await websockets.connect(
|
|
url,
|
|
ping_interval=20,
|
|
ping_timeout=10,
|
|
close_timeout=5,
|
|
)
|
|
|
|
# Read welcome message
|
|
welcome = await self._ws.recv()
|
|
welcome_data = json.loads(welcome)
|
|
logger.info("alpaca.welcome", extra={"msg": welcome_data})
|
|
|
|
# Authenticate
|
|
auth_msg = {
|
|
"action": "auth",
|
|
"key": settings.alpaca_key,
|
|
"secret": settings.alpaca_secret,
|
|
}
|
|
await self._ws.send(json.dumps(auth_msg))
|
|
|
|
auth_resp = await self._ws.recv()
|
|
auth_data = json.loads(auth_resp)
|
|
logger.info("alpaca.auth_response", extra={"msg": auth_data})
|
|
|
|
# Check auth result
|
|
if isinstance(auth_data, list):
|
|
for msg in auth_data:
|
|
if msg.get("T") == "error":
|
|
raise ConnectionError(f"Alpaca auth failed: {msg}")
|
|
if msg.get("T") == "success" and msg.get("msg") == "authenticated":
|
|
self._authenticated = True
|
|
|
|
self._connected = True
|
|
logger.info("alpaca.connected", extra={"authenticated": self._authenticated})
|
|
|
|
async def subscribe(self, symbols: list[str]) -> None:
|
|
"""Subscribe to trades + quotes for symbols."""
|
|
self._symbols = [s.upper() for s in symbols]
|
|
|
|
if self._dry_run:
|
|
logger.info(
|
|
"alpaca.dry_run_subscribe",
|
|
extra={"symbols": self._symbols},
|
|
)
|
|
return
|
|
|
|
if not self._ws:
|
|
raise RuntimeError("Not connected.")
|
|
|
|
sub_msg = {
|
|
"action": "subscribe",
|
|
"trades": self._symbols,
|
|
"quotes": self._symbols,
|
|
}
|
|
await self._ws.send(json.dumps(sub_msg))
|
|
|
|
# Read subscription confirmation
|
|
sub_resp = await self._ws.recv()
|
|
logger.info("alpaca.subscribed", extra={"response": json.loads(sub_resp)})
|
|
|
|
async def stream(self) -> AsyncIterator[Event]:
|
|
"""Yield domain events. Dry-run mode emits periodic heartbeats."""
|
|
if self._dry_run:
|
|
async for event in self._dry_run_stream():
|
|
yield event
|
|
return
|
|
|
|
backoff = settings.reconnect_base_delay
|
|
|
|
while True:
|
|
try:
|
|
if not self._connected or not self._ws:
|
|
await self._reconnect(backoff)
|
|
|
|
try:
|
|
raw = await asyncio.wait_for(
|
|
self._ws.recv(), # type: ignore
|
|
timeout=settings.heartbeat_timeout,
|
|
)
|
|
except asyncio.TimeoutError:
|
|
logger.warning("alpaca.heartbeat_timeout")
|
|
self._connected = False
|
|
continue
|
|
|
|
backoff = settings.reconnect_base_delay
|
|
messages = json.loads(raw)
|
|
|
|
# Alpaca sends arrays of messages
|
|
if not isinstance(messages, list):
|
|
messages = [messages]
|
|
|
|
for msg in messages:
|
|
event = self._parse(msg)
|
|
if event:
|
|
yield event
|
|
|
|
except ConnectionClosed as e:
|
|
logger.warning(
|
|
"alpaca.connection_closed",
|
|
extra={"code": e.code, "reason": str(e.reason)},
|
|
)
|
|
self._connected = False
|
|
backoff = min(backoff * 2, settings.reconnect_max_delay)
|
|
|
|
except Exception as e:
|
|
logger.error("alpaca.stream_error", extra={"error": str(e)})
|
|
self._connected = False
|
|
backoff = min(backoff * 2, settings.reconnect_max_delay)
|
|
|
|
async def _dry_run_stream(self) -> AsyncIterator[Event]:
|
|
"""Emit heartbeats in dry-run mode (no real data)."""
|
|
logger.info("alpaca.dry_run_stream_started")
|
|
while True:
|
|
yield HeartbeatEvent(provider=self.name)
|
|
await asyncio.sleep(5.0)
|
|
|
|
async def _reconnect(self, delay: float) -> None:
|
|
self._reconnect_count += 1
|
|
logger.info(
|
|
"alpaca.reconnecting",
|
|
extra={"delay": delay, "attempt": self._reconnect_count},
|
|
)
|
|
await asyncio.sleep(delay)
|
|
|
|
try:
|
|
if self._ws:
|
|
await self._ws.close()
|
|
except Exception:
|
|
pass
|
|
|
|
self._authenticated = False
|
|
await self.connect()
|
|
if self._symbols:
|
|
await self.subscribe(self._symbols)
|
|
|
|
def _parse(self, msg: dict) -> Event | None:
|
|
"""Parse single Alpaca message into domain event."""
|
|
msg_type = msg.get("T")
|
|
|
|
if msg_type == "t":
|
|
return self._parse_trade(msg)
|
|
elif msg_type == "q":
|
|
return self._parse_quote(msg)
|
|
elif msg_type in ("success", "subscription", "error"):
|
|
# Control messages — skip
|
|
return None
|
|
|
|
return None
|
|
|
|
def _parse_trade(self, data: dict) -> TradeEvent:
|
|
"""
|
|
Alpaca trade:
|
|
{"T":"t", "S":"AAPL", "p":150.25, "s":100, "t":"2024-01-15T...", "i":12345, ...}
|
|
"""
|
|
return TradeEvent(
|
|
provider=self.name,
|
|
symbol=data.get("S", "").upper(),
|
|
price=float(data.get("p", 0)),
|
|
size=float(data.get("s", 0)),
|
|
ts_exchange=_iso_to_dt(data.get("t")),
|
|
trade_id=str(data.get("i", "")),
|
|
)
|
|
|
|
def _parse_quote(self, data: dict) -> QuoteEvent:
|
|
"""
|
|
Alpaca quote:
|
|
{"T":"q", "S":"AAPL", "bp":150.24, "bs":200, "ap":150.26, "as":100,
|
|
"t":"2024-01-15T...", ...}
|
|
"""
|
|
return QuoteEvent(
|
|
provider=self.name,
|
|
symbol=data.get("S", "").upper(),
|
|
bid=float(data.get("bp", 0)),
|
|
ask=float(data.get("ap", 0)),
|
|
bid_size=float(data.get("bs", 0)),
|
|
ask_size=float(data.get("as", 0)),
|
|
ts_exchange=_iso_to_dt(data.get("t")),
|
|
)
|
|
|
|
async def close(self) -> None:
|
|
self._connected = False
|
|
if self._ws:
|
|
try:
|
|
await self._ws.close()
|
|
except Exception:
|
|
pass
|
|
logger.info(
|
|
"alpaca.closed",
|
|
extra={"reconnect_count": self._reconnect_count},
|
|
)
|