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>
78 lines
3.1 KiB
Python
78 lines
3.1 KiB
Python
"""
|
|
SQLAlchemy async models for market data storage.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
|
|
from sqlalchemy import DateTime, Float, Index, Integer, String, Text
|
|
from sqlalchemy.ext.asyncio import AsyncAttrs, async_sessionmaker, create_async_engine
|
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
|
|
|
from app.config import settings
|
|
|
|
|
|
class Base(AsyncAttrs, DeclarativeBase):
|
|
pass
|
|
|
|
|
|
class TradeRecord(Base):
|
|
__tablename__ = "trades"
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
provider: Mapped[str] = mapped_column(String(32), nullable=False)
|
|
symbol: Mapped[str] = mapped_column(String(32), nullable=False, index=True)
|
|
price: Mapped[float] = mapped_column(Float, nullable=False)
|
|
size: Mapped[float] = mapped_column(Float, nullable=False)
|
|
side: Mapped[str | None] = mapped_column(String(8), nullable=True)
|
|
trade_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
ts_exchange: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
|
ts_recv: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
|
|
|
__table_args__ = (
|
|
Index("ix_trades_symbol_ts", "symbol", "ts_recv"),
|
|
)
|
|
|
|
|
|
class QuoteRecord(Base):
|
|
__tablename__ = "quotes"
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
provider: Mapped[str] = mapped_column(String(32), nullable=False)
|
|
symbol: Mapped[str] = mapped_column(String(32), nullable=False, index=True)
|
|
bid: Mapped[float] = mapped_column(Float, nullable=False)
|
|
ask: Mapped[float] = mapped_column(Float, nullable=False)
|
|
bid_size: Mapped[float] = mapped_column(Float, nullable=False)
|
|
ask_size: Mapped[float] = mapped_column(Float, nullable=False)
|
|
ts_exchange: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
|
ts_recv: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
|
|
|
__table_args__ = (
|
|
Index("ix_quotes_symbol_ts", "symbol", "ts_recv"),
|
|
)
|
|
|
|
|
|
class BookSnapshotRecord(Base):
|
|
__tablename__ = "book_snapshots"
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
provider: Mapped[str] = mapped_column(String(32), nullable=False)
|
|
symbol: Mapped[str] = mapped_column(String(32), nullable=False, index=True)
|
|
bids_json: Mapped[str] = mapped_column(Text, nullable=False) # JSON
|
|
asks_json: Mapped[str] = mapped_column(Text, nullable=False) # JSON
|
|
depth: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
ts_exchange: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
|
ts_recv: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
|
|
|
|
|
# ── Engine & Session factory ──────────────────────────────────────────
|
|
|
|
engine = create_async_engine(settings.sqlite_url, echo=False)
|
|
async_session = async_sessionmaker(engine, expire_on_commit=False)
|
|
|
|
|
|
async def init_db() -> None:
|
|
"""Create all tables."""
|
|
async with engine.begin() as conn:
|
|
await conn.run_sync(Base.metadata.create_all)
|