feat: GET /price/intraday — multi-ticker intraday via Yahoo Finance

Alpaca free plan blocks recent SIP data (403 on same-day requests).
Replace with Yahoo Finance which has no subscription requirement.

- PriceDataService.get_multi_intraday(): yf.download() in chunks of 50,
  handles both single (flat DataFrame) and multi-ticker (MultiIndex) cases
- GET /price/intraday?tickers=...&interval=5m&start_date=...&end_date=...
  → same AlpacaMultiBarsResponse format (bars: {sym → [{timestamp,ohlcv}]})
  → source="YAHOO_FINANCE", Redis 5-min TTL cache
- /alpaca/intraday still exists for historical data (works on free plan)

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

@ -651,6 +651,69 @@ async def get_quote(
data = await svc.get_quote(ticker, use_prepost=use_prepost)
return QuoteResponse(**data)
@router.get(
"/intraday",
response_model=AlpacaMultiBarsResponse,
summary="Get intraday bars for multiple tickers via Yahoo Finance",
description=(
"Fetch intraday OHLCV bars for up to ~500 tickers using Yahoo Finance.\n\n"
"- `tickers`: comma-separated, e.g. `AAPL,MSFT,BF-B`\n"
"- `interval`: `1m` (7 days), `5m`/`15m`/`30m` (60 days), `1h` (730 days)\n"
"- No subscription required — uses yfinance free data.\n"
"- **No DB cache** — Redis 5-min TTL for live data."
),
)
async def get_multi_ticker_intraday(
tickers: str = Query(..., description="Comma-separated tickers"),
interval: str = Query("5m", description="Interval: 1m, 5m, 15m, 30m, 1h"),
start_date: Optional[date] = Query(None, description="Start date (YYYY-MM-DD)"),
end_date: Optional[date] = Query(None, description="End date (YYYY-MM-DD)"),
response: Response = None,
):
"""Multi-ticker intraday bars via Yahoo Finance (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.")
# Redis cache (5-min TTL for intraday data)
import hashlib
tickers_hash = hashlib.sha256(",".join(sorted(symbols)).encode()).hexdigest()[:16]
cache_key = build_cache_key("price:intraday", tickers_hash, interval,
start_date.isoformat() if start_date else "none",
end_date.isoformat() if end_date else "none")
cached = await get_cached_response(cache_key)
if cached:
cached_body, etag = cached
if response is not None:
response.headers["X-Cache"] = "HIT"
return cached_body
svc = PriceDataService()
try:
data = await svc.get_multi_intraday(
tickers=symbols,
interval=interval,
start_date=start_date,
end_date=end_date or date.today(),
)
except Exception as e:
raise HTTPException(status_code=502, detail=f"Yahoo Finance error: {e}")
body = AlpacaMultiBarsResponse(
source="YAHOO_FINANCE",
interval=interval,
count=len(symbols),
bars=data,
)
body_dict = body.model_dump()
await set_cached_response(cache_key, body_dict, ttl_seconds=300) # 5분 TTL
if response is not None:
response.headers["X-Cache"] = "MISS"
return body
@router.get(
"/intraday/{ticker}",
response_model=IntradayResponse,

@ -19,6 +19,11 @@ from app.schemas.financial import DataSource, ErrorType
from app.utils.date_utils import parse_period, quarters_to_date_range, resolve_time_parameters
from app.core.config import settings
try:
import pandas as pd
except ImportError:
pd = None # type: ignore
logger = logging.getLogger(__name__)
@ -303,6 +308,104 @@ class PriceDataService:
logger.error(f"Error fetching intraday for {ticker}: {str(e)}")
raise
async def get_multi_intraday(
self,
tickers: List[str],
interval: str = "5m",
start_date: Optional[date] = None,
end_date: Optional[date] = None,
chunk_size: int = 50,
) -> Dict[str, List[Dict]]:
"""
Fetch intraday bars for multiple tickers via yf.download().
Returns:
Dict mapping ticker list of {timestamp, open, high, low, close, volume}
"""
if not self.yf_available:
raise ValueError("Yahoo Finance data source not available")
# end date for yfinance download must be exclusive (day after)
from datetime import timedelta
start_str = start_date.isoformat() if start_date else None
end_str = (end_date + timedelta(days=1)).isoformat() if end_date else None
result: Dict[str, List[Dict]] = {t.upper(): [] for t in tickers}
loop = asyncio.get_event_loop()
for i in range(0, len(tickers), chunk_size):
chunk = [t.upper() for t in tickers[i : i + chunk_size]]
_tickers_str = " ".join(chunk)
try:
bulk_data = await _run_with_timeout(
loop.run_in_executor(
None,
lambda ts=_tickers_str: yf.download(
tickers=ts,
start=start_str,
end=end_str,
interval=interval,
auto_adjust=True,
prepost=False,
group_by="ticker",
threads=True,
progress=False,
),
),
timeout_seconds=120,
description=f"multi_intraday chunk {i//chunk_size+1}",
)
except Exception as e:
logger.error(f"multi_intraday chunk error: {e}")
continue
if bulk_data is None or bulk_data.empty:
continue
def _parse_row(row):
def _f(v):
try:
return None if pd.isna(v) else float(v)
except Exception:
return None
return {
"open": _f(row.get("Open")),
"high": _f(row.get("High")),
"low": _f(row.get("Low")),
"close": _f(row.get("Close")) or 0.0,
"volume": _f(row.get("Volume")),
}
if len(chunk) == 1:
# Single-ticker: flat DataFrame
ticker = chunk[0]
for ts, row in bulk_data.iterrows():
dt = ts.to_pydatetime()
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
result[ticker].append({"timestamp": dt.isoformat(), **_parse_row(row)})
else:
# Multi-ticker: MultiIndex columns grouped by ticker
for ticker in chunk:
try:
lvl0 = bulk_data.columns.get_level_values(0)
if ticker not in lvl0:
continue
ticker_df = bulk_data[ticker]
for ts, row in ticker_df.iterrows():
dt = ts.to_pydatetime()
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
result[ticker].append({"timestamp": dt.isoformat(), **_parse_row(row)})
except Exception as e:
logger.error(f"multi_intraday parse error for {ticker}: {e}")
await asyncio.sleep(0.1) # rate-limit courtesy
return result
async def get_today_ohlc(self, ticker: str) -> Dict:
"""Get today's OHLC. If daily not yet finalized, aggregate from intraday 1m."""
if not self.yf_available:

Loading…
Cancel
Save