feat: multi-ticker bulk bars endpoints + BF-B ticker normalization

- GET /api/v1/price/data?tickers=AAPL,MSFT,BF-B&start_date=...&end_date=...
  → multi-ticker daily OHLCV via Alpaca (ORB engine daily bars interface)
- GET /api/v1/alpaca/intraday?tickers=...&interval=5min&start_date=...&end_date=...
  → multi-ticker intraday OHLCV via Alpaca (ORB engine ORB-window interface)
- AlpacaMultiBarsResponse schema: {source, interval, count, bars: {sym → [bar]}}
- normalize_ticker(): BF-B→BF.B, BRK-B→BRK.B applied in get_bars/get_multi_bars/get_snapshot(s)
- get_multi_bars: transparent batching (200 symbols/request) + INTERVAL_MAP aliases (5min, 15min, 60min, …)
- Response re-keys Alpaca normalized symbols back to original input names

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
I Luk Kim 4 months ago
parent ea36a4287a
commit fbb42edfc2

@ -21,9 +21,10 @@ from app.schemas.financial import (
AlpacaIntradayResponse, AlpacaIntradayResponse,
AlpacaSnapshotResponse, AlpacaSnapshotResponse,
AlpacaMultiSnapshotResponse, AlpacaMultiSnapshotResponse,
AlpacaMultiBarsResponse,
ErrorType, 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.services.alpaca_price_service import AlpacaPriceService
from app.utils.cache import build_cache_key, get_cached_response, set_cached_response, with_cache 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) # 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( @router.get(
"/intraday/{ticker}", "/intraday/{ticker}",
response_model=AlpacaIntradayResponse, response_model=AlpacaIntradayResponse,

@ -23,6 +23,8 @@ from app.schemas.financial import (
TodayOHLCResponse, TodayOHLCResponse,
) )
from app.services.price_data_service import PriceDataService 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.core.config import settings
from app.utils.date_utils import quarters_to_date_range from app.utils.date_utils import quarters_to_date_range
from app.utils.cache import ( 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( @router.get(
"/data/{ticker}", "/data/{ticker}",
response_model=PriceDataResponse, response_model=PriceDataResponse,

@ -573,6 +573,19 @@ class AlpacaMultiSnapshotResponse(BaseModel):
snapshots: List[AlpacaSnapshotResponse] 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): class NewsOnlyResponse(BaseModel):
ticker: str ticker: str
retrieved_at: str retrieved_at: str

@ -16,17 +16,31 @@ logger = logging.getLogger(__name__)
# Interval mapping: internal format → Alpaca API format # Interval mapping: internal format → Alpaca API format
INTERVAL_MAP = { INTERVAL_MAP = {
"1m": "1Min", "1m": "1Min",
"1min": "1Min",
"2m": "2Min", "2m": "2Min",
"5m": "5Min", "5m": "5Min",
"5min": "5Min",
"15m": "15Min", "15m": "15Min",
"15min": "15Min",
"30m": "30Min", "30m": "30Min",
"30min": "30Min",
"1h": "1Hour", "1h": "1Hour",
"60min": "1Hour",
"1d": "1Day", "1d": "1Day",
"1w": "1Week", "1w": "1Week",
"1mo": "1Month", "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: class AlpacaClient:
"""Alpaca Market Data API client (v2)""" """Alpaca Market Data API client (v2)"""
@ -160,7 +174,7 @@ class AlpacaClient:
params["end"] = end params["end"] = end
all_bars: List[Dict] = [] all_bars: List[Dict] = []
path = f"/v2/stocks/{symbol.upper()}/bars" path = f"/v2/stocks/{normalize_ticker(symbol).upper()}/bars"
while True: while True:
data = await self._request("GET", path, params=params) data = await self._request("GET", path, params=params)
@ -181,16 +195,38 @@ class AlpacaClient:
start: Optional[str] = None, start: Optional[str] = None,
end: Optional[str] = None, end: Optional[str] = None,
limit: int = 10000, limit: int = 10000,
batch_size: int = 200,
) -> Dict[str, List[Dict]]: ) -> 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: Returns:
Dict mapping symbol list of bar dicts Dict mapping normalized Alpaca symbol list of bar dicts
""" """
alpaca_tf = INTERVAL_MAP.get(timeframe, timeframe) alpaca_tf = INTERVAL_MAP.get(timeframe, timeframe)
path = "/v2/stocks/bars"
# 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 = { params: Dict = {
"symbols": ",".join(s.upper() for s in symbols), "symbols": ",".join(batch),
"timeframe": alpaca_tf, "timeframe": alpaca_tf,
"limit": min(limit, 10000), "limit": min(limit, 10000),
} }
@ -199,9 +235,6 @@ class AlpacaClient:
if end: if end:
params["end"] = end params["end"] = end
result: Dict[str, List[Dict]] = {s.upper(): [] for s in symbols}
path = "/v2/stocks/bars"
while True: while True:
data = await self._request("GET", path, params=params) data = await self._request("GET", path, params=params)
bars_map = data.get("bars") or {} bars_map = data.get("bars") or {}
@ -223,7 +256,7 @@ class AlpacaClient:
Returns Alpaca's snapshot object with keys: Returns Alpaca's snapshot object with keys:
latestTrade, latestQuote, minuteBar, dailyBar, prevDailyBar 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 return data
async def get_snapshots(self, symbols: List[str]) -> Dict[str, Dict]: async def get_snapshots(self, symbols: List[str]) -> Dict[str, Dict]:
@ -232,7 +265,7 @@ class AlpacaClient:
Returns dict mapping symbol snapshot object. 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) data = await self._request("GET", "/v2/stocks/snapshots", params=params)
return data # {SYMBOL: {...snapshot...}, ...} return data # {SYMBOL: {...snapshot...}, ...}

Loading…
Cancel
Save