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>
This commit is contained in:
110
services/market-data-service/app/db/repo.py
Normal file
110
services/market-data-service/app/db/repo.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""
|
||||
Repository: thin async wrapper over SQLAlchemy for read/write.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import desc, select
|
||||
|
||||
from app.db.schema import (
|
||||
BookSnapshotRecord,
|
||||
QuoteRecord,
|
||||
TradeRecord,
|
||||
async_session,
|
||||
)
|
||||
from app.domain.events import BookL2Event, QuoteEvent, TradeEvent
|
||||
|
||||
|
||||
async def save_trade(event: TradeEvent) -> None:
|
||||
async with async_session() as session:
|
||||
record = TradeRecord(
|
||||
provider=event.provider,
|
||||
symbol=event.symbol,
|
||||
price=event.price,
|
||||
size=event.size,
|
||||
side=event.side,
|
||||
trade_id=event.trade_id,
|
||||
ts_exchange=event.ts_exchange,
|
||||
ts_recv=event.ts_recv,
|
||||
)
|
||||
session.add(record)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def save_quote(event: QuoteEvent) -> None:
|
||||
async with async_session() as session:
|
||||
record = QuoteRecord(
|
||||
provider=event.provider,
|
||||
symbol=event.symbol,
|
||||
bid=event.bid,
|
||||
ask=event.ask,
|
||||
bid_size=event.bid_size,
|
||||
ask_size=event.ask_size,
|
||||
ts_exchange=event.ts_exchange,
|
||||
ts_recv=event.ts_recv,
|
||||
)
|
||||
session.add(record)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def save_book_snapshot(event: BookL2Event) -> None:
|
||||
async with async_session() as session:
|
||||
record = BookSnapshotRecord(
|
||||
provider=event.provider,
|
||||
symbol=event.symbol,
|
||||
bids_json=json.dumps([{"price": b.price, "size": b.size} for b in event.bids]),
|
||||
asks_json=json.dumps([{"price": a.price, "size": a.size} for a in event.asks]),
|
||||
depth=max(len(event.bids), len(event.asks)),
|
||||
ts_exchange=event.ts_exchange,
|
||||
ts_recv=event.ts_recv,
|
||||
)
|
||||
session.add(record)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def get_latest_trade(symbol: str) -> Optional[dict]:
|
||||
async with async_session() as session:
|
||||
stmt = (
|
||||
select(TradeRecord)
|
||||
.where(TradeRecord.symbol == symbol.upper())
|
||||
.order_by(desc(TradeRecord.ts_recv))
|
||||
.limit(1)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
row = result.scalar_one_or_none()
|
||||
if row is None:
|
||||
return None
|
||||
return {
|
||||
"symbol": row.symbol,
|
||||
"price": row.price,
|
||||
"size": row.size,
|
||||
"side": row.side,
|
||||
"provider": row.provider,
|
||||
"ts_recv": row.ts_recv.isoformat() if row.ts_recv else None,
|
||||
"ts_exchange": row.ts_exchange.isoformat() if row.ts_exchange else None,
|
||||
}
|
||||
|
||||
|
||||
async def get_latest_quote(symbol: str) -> Optional[dict]:
|
||||
async with async_session() as session:
|
||||
stmt = (
|
||||
select(QuoteRecord)
|
||||
.where(QuoteRecord.symbol == symbol.upper())
|
||||
.order_by(desc(QuoteRecord.ts_recv))
|
||||
.limit(1)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
row = result.scalar_one_or_none()
|
||||
if row is None:
|
||||
return None
|
||||
return {
|
||||
"symbol": row.symbol,
|
||||
"bid": row.bid,
|
||||
"ask": row.ask,
|
||||
"bid_size": row.bid_size,
|
||||
"ask_size": row.ask_size,
|
||||
"provider": row.provider,
|
||||
"ts_recv": row.ts_recv.isoformat() if row.ts_recv else None,
|
||||
}
|
||||
Reference in New Issue
Block a user