"""Alpaca Markets OHLCV provider using the alpaca-py SDK.""" from __future__ import annotations import asyncio import logging from datetime import date, datetime, time, timedelta, timezone from alpaca.data.historical import StockHistoricalDataClient from alpaca.data.requests import StockBarsRequest from alpaca.data.timeframe import TimeFrame from alpaca.data.enums import Adjustment from app.exceptions import ProviderError, RateLimitError from app.providers.protocol import OHLCVData logger = logging.getLogger(__name__) # Free plans may not query data from the most recent ~15 minutes, and a window # reaching into it fails the *entire* request — which would silently leave the # near-close scan on yesterday's close. Margin over the documented boundary. _RECENT_DATA_CUTOFF = timedelta(minutes=20) class AlpacaOHLCVProvider: """Fetches daily OHLCV bars from Alpaca Markets Data API.""" def __init__(self, api_key: str, api_secret: str) -> None: if not api_key or not api_secret: raise ProviderError("Alpaca API key and secret are required") self._client = StockHistoricalDataClient(api_key, api_secret) @staticmethod def _resolve_window(start_date: date, end_date: date) -> tuple[datetime, datetime]: """Return the instants covering ``start_date``..``end_date`` inclusive. Two boundaries have to be right or today's bar disappears: * Daily bars are stamped at the session start in UTC (04:00Z under EDT), so an ``end`` of midnight on ``end_date`` lands *before* that day's bar and silently drops it — extend to the following midnight instead. * The window must stay out of the delayed-data period, otherwise the request is rejected outright with "subscription does not permit querying recent SIP data". Clamping keeps today's in-progress bar available, roughly 20 minutes behind live. """ start = datetime.combine(start_date, time.min, tzinfo=timezone.utc) end = datetime.combine( end_date + timedelta(days=1), time.min, tzinfo=timezone.utc ) return start, min(end, datetime.now(timezone.utc) - _RECENT_DATA_CUTOFF) @staticmethod def _to_alpaca_symbol(symbol: str) -> str: """Convert internal symbol format (BRK-B) to Alpaca format (BRK.B).""" return symbol.replace("-", ".") @staticmethod def _from_alpaca_symbol(symbol: str) -> str: """Convert Alpaca symbol format (BRK.B) back to internal format (BRK-B).""" return symbol.replace(".", "-") async def fetch_ohlcv( self, ticker: str, start_date: date, end_date: date ) -> list[OHLCVData]: """Fetch daily OHLCV bars for *ticker* between *start_date* and *end_date*.""" alpaca_symbol = self._to_alpaca_symbol(ticker) start, end = self._resolve_window(start_date, end_date) if end <= start: return [] try: request = StockBarsRequest( symbol_or_symbols=alpaca_symbol, timeframe=TimeFrame.Day, start=start, end=end, adjustment=Adjustment.SPLIT, ) # alpaca-py's client is synchronous — run in a thread bars = await asyncio.to_thread(self._client.get_stock_bars, request) results: list[OHLCVData] = [] bar_set = bars.get(alpaca_symbol, []) if hasattr(bars, "get") else getattr(bars, "data", {}).get(alpaca_symbol, []) for bar in bar_set: results.append( OHLCVData( ticker=ticker, # use original internal symbol date=bar.timestamp.date(), open=float(bar.open), high=float(bar.high), low=float(bar.low), close=float(bar.close), volume=int(bar.volume), ) ) return results except Exception as exc: msg = str(exc).lower() if "rate" in msg and "limit" in msg: raise RateLimitError(f"Alpaca rate limit hit for {ticker}") from exc logger.error("Alpaca provider error for %s: %s", ticker, exc) raise ProviderError(f"Alpaca provider error for {ticker}: {exc}") from exc