|
|
"""
|
|
|
Alpaca Market Data API client (v2)
|
|
|
"""
|
|
|
|
|
|
import asyncio
|
|
|
import time
|
|
|
import logging
|
|
|
from typing import Dict, List, Optional
|
|
|
|
|
|
import httpx
|
|
|
|
|
|
from app.core.config import settings
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# Interval mapping: internal format → Alpaca API format
|
|
|
INTERVAL_MAP = {
|
|
|
"1m": "1Min",
|
|
|
"2m": "2Min",
|
|
|
"5m": "5Min",
|
|
|
"15m": "15Min",
|
|
|
"30m": "30Min",
|
|
|
"1h": "1Hour",
|
|
|
"1d": "1Day",
|
|
|
"1w": "1Week",
|
|
|
"1mo": "1Month",
|
|
|
}
|
|
|
|
|
|
|
|
|
class AlpacaClient:
|
|
|
"""Alpaca Market Data API client (v2)"""
|
|
|
|
|
|
def __init__(
|
|
|
self,
|
|
|
api_key: Optional[str] = None,
|
|
|
secret_key: Optional[str] = None,
|
|
|
base_url: Optional[str] = None,
|
|
|
max_requests_per_min: int = 200,
|
|
|
):
|
|
|
self.api_key = api_key or settings.ALPACA_API_KEY
|
|
|
self.secret_key = secret_key or settings.ALPACA_SECRET_KEY
|
|
|
self.base_url = (base_url or settings.ALPACA_BASE_URL).rstrip("/")
|
|
|
self._semaphore = asyncio.Semaphore(max_requests_per_min)
|
|
|
self._request_times: List[float] = []
|
|
|
self._max_rpm = max_requests_per_min
|
|
|
self._client: Optional[httpx.AsyncClient] = None
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
# Lifecycle
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
def is_configured(self) -> bool:
|
|
|
"""Return True if both API key and secret are set."""
|
|
|
return bool(self.api_key) and bool(self.secret_key)
|
|
|
|
|
|
async def _get_client(self) -> httpx.AsyncClient:
|
|
|
if self._client is None or self._client.is_closed:
|
|
|
self._client = httpx.AsyncClient(
|
|
|
base_url=self.base_url,
|
|
|
headers={
|
|
|
"APCA-API-KEY-ID": self.api_key,
|
|
|
"APCA-API-SECRET-KEY": self.secret_key,
|
|
|
},
|
|
|
timeout=30.0,
|
|
|
)
|
|
|
return self._client
|
|
|
|
|
|
async def close(self):
|
|
|
if self._client and not self._client.is_closed:
|
|
|
await self._client.aclose()
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
# Rate limiting
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
async def _wait_for_rate_limit(self):
|
|
|
"""Simple token-bucket style rate limiter (200 req/min)."""
|
|
|
now = time.monotonic()
|
|
|
# Remove entries older than 60 seconds
|
|
|
self._request_times = [t for t in self._request_times if now - t < 60]
|
|
|
if len(self._request_times) >= self._max_rpm:
|
|
|
sleep_for = 60 - (now - self._request_times[0]) + 0.1
|
|
|
if sleep_for > 0:
|
|
|
logger.debug(f"Alpaca rate limit reached, sleeping {sleep_for:.1f}s")
|
|
|
await asyncio.sleep(sleep_for)
|
|
|
self._request_times.append(time.monotonic())
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
# Core HTTP
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
async def _request(
|
|
|
self, method: str, path: str, params: Optional[Dict] = None, retries: int = 3
|
|
|
) -> Dict:
|
|
|
"""Make an authenticated request with retry + rate limiting."""
|
|
|
await self._wait_for_rate_limit()
|
|
|
client = await self._get_client()
|
|
|
|
|
|
last_exc: Optional[Exception] = None
|
|
|
for attempt in range(retries):
|
|
|
try:
|
|
|
resp = await client.request(method, path, params=params)
|
|
|
if resp.status_code == 429:
|
|
|
wait = 2 ** attempt
|
|
|
logger.warning(f"Alpaca 429 – retrying in {wait}s (attempt {attempt + 1})")
|
|
|
await asyncio.sleep(wait)
|
|
|
continue
|
|
|
if resp.status_code >= 500:
|
|
|
wait = 2 ** attempt
|
|
|
logger.warning(f"Alpaca {resp.status_code} – retrying in {wait}s (attempt {attempt + 1})")
|
|
|
await asyncio.sleep(wait)
|
|
|
continue
|
|
|
resp.raise_for_status()
|
|
|
return resp.json()
|
|
|
except httpx.HTTPStatusError as exc:
|
|
|
last_exc = exc
|
|
|
if attempt < retries - 1 and exc.response.status_code in (429, 500, 502, 503, 504):
|
|
|
await asyncio.sleep(2 ** attempt)
|
|
|
continue
|
|
|
raise
|
|
|
except (httpx.ConnectError, httpx.ReadTimeout) as exc:
|
|
|
last_exc = exc
|
|
|
if attempt < retries - 1:
|
|
|
await asyncio.sleep(2 ** attempt)
|
|
|
continue
|
|
|
raise
|
|
|
|
|
|
raise last_exc # type: ignore[misc]
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
# Public API methods
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
async def get_bars(
|
|
|
self,
|
|
|
symbol: str,
|
|
|
timeframe: str = "1d",
|
|
|
start: Optional[str] = None,
|
|
|
end: Optional[str] = None,
|
|
|
limit: int = 10000,
|
|
|
) -> List[Dict]:
|
|
|
"""
|
|
|
Fetch bars for a single symbol with automatic pagination.
|
|
|
|
|
|
Args:
|
|
|
symbol: Ticker symbol (e.g. "AAPL")
|
|
|
timeframe: Internal interval string (e.g. "1d", "1h", "1m")
|
|
|
start: RFC-3339 date/datetime (e.g. "2024-01-01")
|
|
|
end: RFC-3339 date/datetime
|
|
|
limit: Max bars per page (Alpaca max 10000)
|
|
|
|
|
|
Returns:
|
|
|
List of bar dicts with keys: t, o, h, l, c, v, n, vw
|
|
|
"""
|
|
|
alpaca_tf = INTERVAL_MAP.get(timeframe, timeframe)
|
|
|
params: Dict = {"timeframe": alpaca_tf, "limit": min(limit, 10000)}
|
|
|
if start:
|
|
|
params["start"] = start
|
|
|
if end:
|
|
|
params["end"] = end
|
|
|
|
|
|
all_bars: List[Dict] = []
|
|
|
path = f"/v2/stocks/{symbol.upper()}/bars"
|
|
|
|
|
|
while True:
|
|
|
data = await self._request("GET", path, params=params)
|
|
|
bars = data.get("bars") or []
|
|
|
all_bars.extend(bars)
|
|
|
next_token = data.get("next_page_token")
|
|
|
if not next_token or not bars:
|
|
|
break
|
|
|
params["page_token"] = next_token
|
|
|
|
|
|
logger.info(f"Alpaca: fetched {len(all_bars)} bars for {symbol} ({alpaca_tf})")
|
|
|
return all_bars
|
|
|
|
|
|
async def get_multi_bars(
|
|
|
self,
|
|
|
symbols: List[str],
|
|
|
timeframe: str = "1d",
|
|
|
start: Optional[str] = None,
|
|
|
end: Optional[str] = None,
|
|
|
limit: int = 10000,
|
|
|
) -> Dict[str, List[Dict]]:
|
|
|
"""
|
|
|
Fetch bars for multiple symbols in one request with auto-pagination.
|
|
|
|
|
|
Returns:
|
|
|
Dict mapping symbol → list of bar dicts
|
|
|
"""
|
|
|
alpaca_tf = INTERVAL_MAP.get(timeframe, timeframe)
|
|
|
params: Dict = {
|
|
|
"symbols": ",".join(s.upper() for s in symbols),
|
|
|
"timeframe": alpaca_tf,
|
|
|
"limit": min(limit, 10000),
|
|
|
}
|
|
|
if start:
|
|
|
params["start"] = start
|
|
|
if end:
|
|
|
params["end"] = end
|
|
|
|
|
|
result: Dict[str, List[Dict]] = {s.upper(): [] for s in symbols}
|
|
|
path = "/v2/stocks/bars"
|
|
|
|
|
|
while True:
|
|
|
data = await self._request("GET", path, params=params)
|
|
|
bars_map = data.get("bars") or {}
|
|
|
for sym, bars in bars_map.items():
|
|
|
result.setdefault(sym, []).extend(bars)
|
|
|
next_token = data.get("next_page_token")
|
|
|
if not next_token:
|
|
|
break
|
|
|
params["page_token"] = next_token
|
|
|
|
|
|
total = sum(len(v) for v in result.values())
|
|
|
logger.info(f"Alpaca: fetched {total} bars for {len(symbols)} symbols ({alpaca_tf})")
|
|
|
return result
|
|
|
|
|
|
async def get_snapshot(self, symbol: str) -> Dict:
|
|
|
"""
|
|
|
Fetch a real-time snapshot for a single symbol.
|
|
|
|
|
|
Returns Alpaca's snapshot object with keys:
|
|
|
latestTrade, latestQuote, minuteBar, dailyBar, prevDailyBar
|
|
|
"""
|
|
|
data = await self._request("GET", f"/v2/stocks/{symbol.upper()}/snapshot")
|
|
|
return data
|
|
|
|
|
|
async def get_snapshots(self, symbols: List[str]) -> Dict[str, Dict]:
|
|
|
"""
|
|
|
Fetch real-time snapshots for multiple symbols in one request.
|
|
|
|
|
|
Returns dict mapping symbol → snapshot object.
|
|
|
"""
|
|
|
params = {"symbols": ",".join(s.upper() for s in symbols)}
|
|
|
data = await self._request("GET", "/v2/stocks/snapshots", params=params)
|
|
|
return data # {SYMBOL: {...snapshot...}, ...}
|
|
|
|
|
|
async def check_connection(self) -> Dict:
|
|
|
"""Verify API key validity by requesting a small amount of data."""
|
|
|
try:
|
|
|
bars = await self.get_bars("AAPL", timeframe="1d", limit=1)
|
|
|
return {
|
|
|
"connected": True,
|
|
|
"bars_returned": len(bars),
|
|
|
"base_url": self.base_url,
|
|
|
}
|
|
|
except Exception as e:
|
|
|
return {
|
|
|
"connected": False,
|
|
|
"error": str(e),
|
|
|
"base_url": self.base_url,
|
|
|
}
|