diff --git a/app/api/v1/endpoints/alpaca.py b/app/api/v1/endpoints/alpaca.py index 88aa4b5..1c5e238 100644 --- a/app/api/v1/endpoints/alpaca.py +++ b/app/api/v1/endpoints/alpaca.py @@ -21,9 +21,10 @@ from app.schemas.financial import ( AlpacaIntradayResponse, AlpacaSnapshotResponse, AlpacaMultiSnapshotResponse, + AlpacaMultiBarsResponse, ErrorType, ) -from app.services.alpaca_client import AlpacaClient +from app.services.alpaca_client import AlpacaClient, normalize_ticker from app.services.alpaca_price_service import AlpacaPriceService from app.utils.cache import build_cache_key, get_cached_response, set_cached_response, with_cache @@ -219,6 +220,84 @@ async def get_alpaca_price_data( # Intraday (raw — no DB) — with Redis caching (short TTL) # ------------------------------------------------------------------ +@router.get( + "/intraday", + response_model=AlpacaMultiBarsResponse, + summary="Get intraday bars for multiple tickers via Alpaca", + description=( + "Fetch intraday OHLCV bars for up to ~500 tickers in one call.\n\n" + "- `tickers`: comma-separated list, e.g. `AAPL,MSFT,BF-B`\n" + "- `interval`: `1m`, `5m`, `15m`, `30m`, `1h` (also accepts `5min`, `15min`, etc.)\n" + "- Ticker normalization: `BF-B` → `BF.B` handled automatically; " + "response keys use the original symbol names.\n" + "- **No cache** — always fetches from Alpaca.\n" + "- Requires `ALPACA_API_KEY` / `ALPACA_SECRET_KEY`." + ), +) +async def get_alpaca_intraday_multi( + tickers: str = Query(..., description="Comma-separated tickers, e.g. AAPL,MSFT,BF-B"), + interval: str = Query("5m", description="Interval: 1m, 5m, 15m, 30m, 1h (or 5min, 15min, etc.)"), + start_date: Optional[date] = Query(None, description="Start date (YYYY-MM-DD)"), + end_date: Optional[date] = Query(None, description="End date (YYYY-MM-DD)"), + limit: int = Query(10000, ge=1, le=50000, description="Max bars per symbol"), +): + """Multi-ticker intraday bars via Alpaca (ORB engine interface).""" + symbols = [s.strip().upper() for s in tickers.split(",") if s.strip()] + if not symbols: + raise HTTPException(status_code=400, detail="No tickers provided.") + if len(symbols) > 1000: + raise HTTPException(status_code=400, detail="Maximum 1000 tickers per request.") + + client = AlpacaClient() + if not client.is_configured(): + raise HTTPException(status_code=503, detail="Alpaca API keys not configured.") + + # Build reverse-map: normalized_alpaca_symbol → original_input_symbol + reverse_map = {normalize_ticker(s): s for s in symbols} + + start_str = start_date.isoformat() if start_date else None + end_str = end_date.isoformat() if end_date else None + + try: + raw = await client.get_multi_bars( + symbols=symbols, + timeframe=interval, + start=start_str, + end=end_str, + limit=min(limit, 10000), + ) + except Exception as e: + raise HTTPException(status_code=502, detail=f"Alpaca API error: {e}") + finally: + await client.close() + + # Re-key results back to original input symbols and reshape bar dicts + bars: dict = {} + for alpaca_sym, bar_list in raw.items(): + original_sym = reverse_map.get(alpaca_sym, alpaca_sym) + bars[original_sym] = [ + { + "timestamp": b["t"], + "open": b.get("o"), + "high": b.get("h"), + "low": b.get("l"), + "close": b.get("c"), + "volume": b.get("v"), + } + for b in bar_list + ] + + # Ensure every requested symbol appears in the response (empty list if no data) + for sym in symbols: + bars.setdefault(sym, []) + + return AlpacaMultiBarsResponse( + interval=interval, + count=len(symbols), + bars=bars, + ) + + @router.get( "/intraday/{ticker}", response_model=AlpacaIntradayResponse, diff --git a/app/api/v1/endpoints/price.py b/app/api/v1/endpoints/price.py index 65735e6..9353e48 100644 --- a/app/api/v1/endpoints/price.py +++ b/app/api/v1/endpoints/price.py @@ -23,6 +23,8 @@ from app.schemas.financial import ( TodayOHLCResponse, ) from app.services.price_data_service import PriceDataService +from app.services.alpaca_client import AlpacaClient, normalize_ticker +from app.schemas.financial import AlpacaMultiBarsResponse from app.core.config import settings from app.utils.date_utils import quarters_to_date_range from app.utils.cache import ( @@ -286,6 +288,83 @@ async def get_price_data( } ) +@router.get( + "/data", + response_model=AlpacaMultiBarsResponse, + summary="Get daily bars for multiple tickers via Alpaca", + description=( + "Fetch OHLCV daily bars for up to ~500 tickers in one call using Alpaca's " + "multi-bar endpoint. Data is **not** cached (live trading use-case).\n\n" + "- `tickers`: comma-separated list, e.g. `AAPL,MSFT,BF-B`\n" + "- Ticker normalization: `BF-B` → `BF.B` is handled automatically; " + "response keys use the *original* symbol names you passed in.\n" + "- Requires `ALPACA_API_KEY` / `ALPACA_SECRET_KEY`." + ), + tags=["price", "alpaca"], +) +async def get_multi_ticker_daily_bars( + tickers: str = Query(..., description="Comma-separated tickers, e.g. AAPL,MSFT,BF-B"), + start_date: date = Query(..., description="Start date (YYYY-MM-DD)"), + end_date: date = Query(..., description="End date (YYYY-MM-DD)"), + interval: str = Query("1d", description="Bar interval: 1d, 1w, 1mo"), + force_refresh: bool = Query(False, description="Ignored — no caching for this endpoint"), +): + """Multi-ticker daily bars via Alpaca (ORB engine interface).""" + symbols = [s.strip().upper() for s in tickers.split(",") if s.strip()] + if not symbols: + raise HTTPException(status_code=400, detail="No tickers provided.") + if len(symbols) > 1000: + raise HTTPException(status_code=400, detail="Maximum 1000 tickers per request.") + + client = AlpacaClient() + if not client.is_configured(): + raise HTTPException(status_code=503, detail="Alpaca API keys not configured.") + + # Build reverse-map: normalized_alpaca_symbol → original_input_symbol + reverse_map = {normalize_ticker(s): s for s in symbols} + + start_str = start_date.isoformat() + end_str = end_date.isoformat() + + try: + raw = await client.get_multi_bars( + symbols=symbols, + timeframe=interval, + start=start_str, + end=end_str, + ) + except Exception as e: + raise HTTPException(status_code=502, detail=f"Alpaca API error: {e}") + finally: + await client.close() + + # Re-key results back to original input symbols and reshape bar dicts + bars: dict = {} + for alpaca_sym, bar_list in raw.items(): + original_sym = reverse_map.get(alpaca_sym, alpaca_sym) + bars[original_sym] = [ + { + "date": b["t"][:10], # "2026-02-07T05:00:00Z" → "2026-02-07" + "open": b.get("o"), + "high": b.get("h"), + "low": b.get("l"), + "close": b.get("c"), + "volume": b.get("v"), + } + for b in bar_list + ] + + # Include symbols with no data as empty lists + for sym in symbols: + bars.setdefault(sym, []) + + return AlpacaMultiBarsResponse( + interval=interval, + count=len(symbols), + bars=bars, + ) + + @router.get( "/data/{ticker}", response_model=PriceDataResponse, diff --git a/app/schemas/financial.py b/app/schemas/financial.py index ffa83cb..80085ba 100644 --- a/app/schemas/financial.py +++ b/app/schemas/financial.py @@ -573,6 +573,19 @@ class AlpacaMultiSnapshotResponse(BaseModel): snapshots: List[AlpacaSnapshotResponse] +class AlpacaMultiBarsResponse(BaseModel): + """Multi-ticker OHLCV bars from Alpaca (daily or intraday). + + ``bars`` maps each ticker (using the original input symbol, e.g. BF-B) + to a list of bar dicts. Daily bars include a ``date`` field; intraday + bars include a ``timestamp`` field. + """ + source: str = "ALPACA" + interval: str + count: int # number of symbols returned + bars: Dict[str, List[Dict[str, Any]]] + + class NewsOnlyResponse(BaseModel): ticker: str retrieved_at: str diff --git a/app/services/alpaca_client.py b/app/services/alpaca_client.py index 3ca45ae..14ee2e9 100644 --- a/app/services/alpaca_client.py +++ b/app/services/alpaca_client.py @@ -16,17 +16,31 @@ logger = logging.getLogger(__name__) # Interval mapping: internal format → Alpaca API format INTERVAL_MAP = { "1m": "1Min", + "1min": "1Min", "2m": "2Min", "5m": "5Min", + "5min": "5Min", "15m": "15Min", + "15min": "15Min", "30m": "30Min", + "30min": "30Min", "1h": "1Hour", + "60min": "1Hour", "1d": "1Day", "1w": "1Week", "1mo": "1Month", } +def normalize_ticker(symbol: str) -> str: + """Normalize ticker symbol for Alpaca API. + + US stock tickers use dots for share classes (BF.B, BRK.B) while + Yahoo Finance and other sources use hyphens (BF-B, BRK-B). + """ + return symbol.replace("-", ".") + + class AlpacaClient: """Alpaca Market Data API client (v2)""" @@ -160,7 +174,7 @@ class AlpacaClient: params["end"] = end all_bars: List[Dict] = [] - path = f"/v2/stocks/{symbol.upper()}/bars" + path = f"/v2/stocks/{normalize_ticker(symbol).upper()}/bars" while True: data = await self._request("GET", path, params=params) @@ -181,36 +195,55 @@ class AlpacaClient: start: Optional[str] = None, end: Optional[str] = None, limit: int = 10000, + batch_size: int = 200, ) -> Dict[str, List[Dict]]: """ - Fetch bars for multiple symbols in one request with auto-pagination. + Fetch bars for multiple symbols with auto-pagination and transparent batching. + + Normalizes tickers (BF-B → BF.B) before calling Alpaca and returns results + keyed by the *Alpaca* symbol (normalized). Callers that need the original + symbol names should build their own reverse-map before calling. + + Args: + symbols: List of ticker symbols (hyphens are auto-normalized to dots) + timeframe: Internal interval string (e.g. "1d", "1h", "5min") + start: RFC-3339 date/datetime string + end: RFC-3339 date/datetime string + limit: Max bars per page (Alpaca max 10000) + batch_size: Max symbols per Alpaca request (default 200, ~1KB URL) Returns: - Dict mapping symbol → list of bar dicts + Dict mapping normalized Alpaca 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 + # Normalize all symbols for Alpaca (BF-B → BF.B) + normalized = [normalize_ticker(s).upper() for s in symbols] + result: Dict[str, List[Dict]] = {s: [] for s in normalized} + + # Process in batches to stay within URL length limits + for batch_start in range(0, len(normalized), batch_size): + batch = normalized[batch_start: batch_start + batch_size] + params: Dict = { + "symbols": ",".join(batch), + "timeframe": alpaca_tf, + "limit": min(limit, 10000), + } + if start: + params["start"] = start + if end: + params["end"] = end + + 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})") @@ -223,7 +256,7 @@ class AlpacaClient: Returns Alpaca's snapshot object with keys: latestTrade, latestQuote, minuteBar, dailyBar, prevDailyBar """ - data = await self._request("GET", f"/v2/stocks/{symbol.upper()}/snapshot") + data = await self._request("GET", f"/v2/stocks/{normalize_ticker(symbol).upper()}/snapshot") return data async def get_snapshots(self, symbols: List[str]) -> Dict[str, Dict]: @@ -232,7 +265,7 @@ class AlpacaClient: Returns dict mapping symbol → snapshot object. """ - params = {"symbols": ",".join(s.upper() for s in symbols)} + params = {"symbols": ",".join(normalize_ticker(s).upper() for s in symbols)} data = await self._request("GET", "/v2/stocks/snapshots", params=params) return data # {SYMBOL: {...snapshot...}, ...}