From 5ef4d1790d532ec04ce417020fc51bb104a1433e Mon Sep 17 00:00:00 2001 From: I Luk Kim Date: Mon, 13 Apr 2026 11:29:27 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20GET=20/price/intraday=20=E2=80=94=20mul?= =?UTF-8?q?ti-ticker=20intraday=20via=20Yahoo=20Finance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/api/v1/endpoints/price.py | 63 ++++++++++++++++++ app/services/price_data_service.py | 103 +++++++++++++++++++++++++++++ 2 files changed, 166 insertions(+) diff --git a/app/api/v1/endpoints/price.py b/app/api/v1/endpoints/price.py index 1f75be2..47e8f02 100644 --- a/app/api/v1/endpoints/price.py +++ b/app/api/v1/endpoints/price.py @@ -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, diff --git a/app/services/price_data_service.py b/app/services/price_data_service.py index b2716b9..1e80751 100644 --- a/app/services/price_data_service.py +++ b/app/services/price_data_service.py @@ -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: