"""Alpaca real-time snapshot service via Stock Oracle API. Provides synchronous helpers for real-time price snapshots, suitable for use in threaded engine code (which already runs in asyncio.to_thread). """ from __future__ import annotations import logging import os from dataclasses import dataclass from typing import Any log = logging.getLogger(__name__) @dataclass class AlpacaSnapshot: """Real-time Alpaca snapshot for a single ticker.""" ticker: str price: float | None = None # last trade price bid: float | None = None ask: float | None = None bid_size: int | None = None ask_size: int | None = None open: float | None = None high: float | None = None low: float | None = None volume: int | None = None vwap: float | None = None prev_close: float | None = None change: float | None = None change_pct: float | None = None timestamp: str | None = None @property def mid(self) -> float | None: """Midpoint between bid and ask, or last trade if unavailable.""" if self.bid is not None and self.ask is not None: return (self.bid + self.ask) / 2 return self.price def _base_url() -> str: return os.environ.get("ORACLE_URL", "http://localhost:18001").rstrip("/") def _parse_snapshot(data: dict[str, Any]) -> AlpacaSnapshot: return AlpacaSnapshot( ticker=data["ticker"], price=data.get("price"), bid=data.get("bid"), ask=data.get("ask"), bid_size=data.get("bid_size"), ask_size=data.get("ask_size"), open=data.get("open"), high=data.get("high"), low=data.get("low"), volume=data.get("volume"), vwap=data.get("vwap"), prev_close=data.get("prev_close"), change=data.get("change"), change_pct=data.get("change_pct"), timestamp=data.get("timestamp"), ) def get_multi_daily_bars( tickers: list[str], start_date: str, end_date: str, base_url: str | None = None, ) -> dict[str, list[dict]]: """Fetch daily OHLCV bars for multiple tickers via Oracle API. Oracle handles symbol normalisation (e.g. BF-B → BF.B) and Alpaca batching internally. Returns {ticker: [{date, open, high, low, close, volume}, ...]}. Missing/errored tickers are omitted. """ import httpx if not tickers: return {} result: dict[str, list[dict]] = {} url = (base_url or _base_url()) + "/api/v1/price/data" # Alpaca rejects requests with ~100+ tickers in URL params (502 Bad Gateway). # Keep chunks at 75 to stay well under the limit. chunk_size = 75 for i in range(0, len(tickers), chunk_size): chunk = tickers[i : i + chunk_size] try: resp = httpx.get( url, params={"tickers": ",".join(chunk), "start_date": start_date, "end_date": end_date}, timeout=90.0, ) resp.raise_for_status() for ticker, bars in resp.json().get("bars", {}).items(): result[ticker] = bars except Exception as exc: log.warning("Oracle multi_daily_bars chunk %d failed: %s", i // chunk_size, exc) return result def get_multi_intraday_bars( tickers: list[str], start_date: str, end_date: str, interval: str = "5min", base_url: str | None = None, ) -> dict[str, list[dict]]: """Fetch intraday bars for multiple tickers via Oracle API. Returns {ticker: [{timestamp (ISO8601), open, high, low, close, volume}, ...]}. """ import httpx if not tickers: return {} result: dict[str, list[dict]] = {} url = (base_url or _base_url()) + "/api/v1/alpaca/intraday" chunk_size = 75 for i in range(0, len(tickers), chunk_size): chunk = tickers[i : i + chunk_size] try: resp = httpx.get( url, params={ "tickers": ",".join(chunk), "interval": interval, "start_date": start_date, "end_date": end_date, }, timeout=90.0, ) resp.raise_for_status() for ticker, bars in resp.json().get("bars", {}).items(): result[ticker] = bars except Exception as exc: log.warning("Oracle multi_intraday_bars chunk %d failed: %s", i // chunk_size, exc) return result def get_multi_intraday_bars_today( tickers: list[str], interval: str = "5min", base_url: str | None = None, ) -> dict[str, list[dict]]: """Fetch today's intraday bars (IEX real-time) via Oracle /alpaca/intraday/today. Uses IEX feed with force_refresh=True — suitable for live paper trading. Returns {ticker: [{timestamp (ISO8601), open, high, low, close, volume}, ...]}. """ import httpx if not tickers: return {} result: dict[str, list[dict]] = {} url = (base_url or _base_url()) + "/api/v1/alpaca/intraday/today" chunk_size = 75 for i in range(0, len(tickers), chunk_size): chunk = tickers[i : i + chunk_size] try: resp = httpx.get( url, params={"tickers": ",".join(chunk), "interval": interval}, timeout=90.0, ) resp.raise_for_status() for ticker, bars in resp.json().get("bars", {}).items(): result[ticker] = bars except Exception as exc: log.warning("Oracle intraday_today chunk %d failed: %s", i // chunk_size, exc) return result def get_snapshot(ticker: str, base_url: str | None = None) -> AlpacaSnapshot | None: """Fetch real-time snapshot for a single ticker (synchronous). Returns None on any error. """ import httpx url = (base_url or _base_url()) + f"/api/v1/alpaca/snapshot/{ticker}" try: resp = httpx.get(url, timeout=10.0) resp.raise_for_status() return _parse_snapshot(resp.json()) except Exception as exc: log.warning("Oracle snapshot(%s) failed: %s", ticker, exc) return None def get_snapshots( tickers: list[str], base_url: str | None = None, batch_size: int = 200, ) -> dict[str, AlpacaSnapshot]: """Fetch real-time snapshots for multiple tickers (synchronous). Batches requests and returns {ticker: AlpacaSnapshot}. Missing/errored tickers are omitted. """ import httpx if not tickers: return {} result: dict[str, AlpacaSnapshot] = {} url = (base_url or _base_url()) + "/api/v1/alpaca/snapshot" for i in range(0, len(tickers), batch_size): chunk = tickers[i : i + batch_size] try: resp = httpx.get(url, params={"tickers": ",".join(chunk)}, timeout=15.0) resp.raise_for_status() data = resp.json() for snap_data in data.get("snapshots", []): snap = _parse_snapshot(snap_data) result[snap.ticker] = snap except Exception as exc: log.warning("Oracle snapshots(batch %d) failed: %s", i // batch_size, exc) return result