From dd3e38fbf5d15c84ccd05314edc4a9fbdac16f4a Mon Sep 17 00:00:00 2001 From: I Luk Kim Date: Tue, 14 Apr 2026 14:35:59 -0700 Subject: [PATCH] =?UTF-8?q?fix:=20GET=20/price/data=20=E2=80=94=20Alpaca?= =?UTF-8?q?=20=E2=86=92=20yfinance=20=EA=B5=90=EC=B2=B4=20+=20bulk=20end?= =?UTF-8?q?=5Fdate=20=EB=B2=84=EA=B7=B8=202=EA=B1=B4=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /price/data를 AlpacaPriceService 대신 PriceDataService(yfinance)로 교체 - ?ticker= alias 추가 (기존 ?tickers= 유지, 인터페이스 호환) - _bulk_fetch_price_data: yfinance end 파라미터 exclusive 미반영 (+1일 누락) 수정 - get_multi_ticker_daily_bars: end_dt를 min.time(00:00) → max.time(23:59:59)으로 수정 (DB 쿼리 date <= end_dt 에서 당일 레코드가 필터링되던 버그) Co-Authored-By: Claude Sonnet 4.6 --- app/api/v1/endpoints/price.py | 96 +++++++++++++----------------- app/services/price_data_service.py | 3 +- 2 files changed, 45 insertions(+), 54 deletions(-) diff --git a/app/api/v1/endpoints/price.py b/app/api/v1/endpoints/price.py index 2285e80..995cebe 100644 --- a/app/api/v1/endpoints/price.py +++ b/app/api/v1/endpoints/price.py @@ -23,7 +23,6 @@ from app.schemas.financial import ( TodayOHLCResponse, ) from app.services.price_data_service import PriceDataService -from app.services.alpaca_price_service import AlpacaPriceService from app.schemas.financial import AlpacaMultiBarsResponse from app.core.config import settings from app.utils.date_utils import quarters_to_date_range @@ -289,78 +288,69 @@ async def get_price_data( @router.get( "/data", response_model=AlpacaMultiBarsResponse, - summary="Get daily bars for multiple tickers via Alpaca (DB-backed)", + summary="Get daily bars for multiple tickers via yfinance (DB-backed)", description=( - "Fetch OHLCV daily bars for up to ~500 tickers. Results are stored in DB so " - "subsequent calls only fetch new/missing dates from Alpaca.\n\n" - "- `tickers`: comma-separated list, e.g. `AAPL,MSFT,BF-B`\n" - "- Ticker normalization: `BF-B` → `BF.B` handled automatically; " - "response keys use the original symbol names.\n" - "- `force_refresh=true`: re-fetch all from Alpaca regardless of DB state.\n" - "- Requires `ALPACA_API_KEY` / `ALPACA_SECRET_KEY`.\n\n" - "**⚠️ Alpaca 배치 제한**\n\n" - "Alpaca multi-bar 엔드포인트는 요청당 **~100개 심볼**이 실질적 상한입니다 " - "(공식 문서 미명시, 커뮤니티 보고 및 실제 운용 기준). " - "내부적으로 **100개 단위로 자동 분할**하여 요청하므로 클라이언트는 신경 쓸 필요 없음. " - "단, 배치 수가 늘어날수록 응답 시간이 선형적으로 증가함 (500종목 → Alpaca 5회 호출)." + "Fetch OHLCV daily bars for one or more tickers via Yahoo Finance (yfinance-plus). " + "Results are stored in DB; subsequent calls for the same range skip the external API.\n\n" + "- `tickers` or `ticker`: comma-separated list, e.g. `AAPL,MSFT` or single `QQQ`\n" + "- `force_refresh=true`: re-fetch from Yahoo Finance even if DB has data\n" + "- Up to 1000 tickers per request (auto-chunked internally)\n\n" + "**경로 파라미터 대안**: 단일 종목은 `/data/{ticker}?start_date=...&end_date=...` 도 동일하게 동작합니다." ), - tags=["price", "alpaca"], + tags=["price"], ) async def get_multi_ticker_daily_bars( - tickers: str = Query(..., description="Comma-separated tickers, e.g. AAPL,MSFT,BF-B"), + tickers: Optional[str] = Query(None, description="Comma-separated tickers, e.g. AAPL,MSFT,QQQ"), + ticker: Optional[str] = Query(None, description="Alias for tickers (single ticker shorthand)"), 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="Re-fetch from Alpaca even if DB has data"), + interval: str = Query("1d", description="Bar interval: 1d, 1w, 1m"), + force_refresh: bool = Query(False, description="Re-fetch from Yahoo Finance even if DB has data"), ): - """Multi-ticker daily bars via Alpaca with DB storage (ORB engine interface).""" - symbols = [s.strip().upper() for s in tickers.split(",") if s.strip()] + """Multi-ticker daily bars via yfinance with DB storage.""" + raw = tickers or ticker + if not raw: + raise HTTPException(status_code=400, detail="No tickers provided. Use ?tickers=AAPL,MSFT or ?ticker=QQQ.") + symbols = [s.strip().upper() for s in raw.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.") - svc = AlpacaPriceService() - if not svc.is_available(): - raise HTTPException(status_code=503, detail="Alpaca API keys not configured.") - start_dt = datetime.combine(start_date, datetime.min.time()).replace(tzinfo=timezone.utc) - end_dt = datetime.combine(end_date, datetime.min.time()).replace(tzinfo=timezone.utc) + end_dt = datetime.combine(end_date, datetime.max.time()).replace(tzinfo=timezone.utc) + price_service = PriceDataService() try: - data = await svc.get_or_fetch_multi_bars( - symbols, start_dt, end_dt, interval, force_refresh + results, _, _ = await price_service.get_multiple_tickers_data_optimized( + tickers=symbols, + start_date=start_dt, + end_date=end_dt, + interval=interval, + force_refresh=force_refresh, ) except Exception as e: - err = str(e) - detail = f"Alpaca API error: {err}" - if "502" in err or "Bad Gateway" in err: - detail = ( - f"Alpaca 502 Bad Gateway — 요청당 심볼 수 초과 가능성. " - f"내부 배치 크기: 100개/요청. 원인: {err}" - ) - raise HTTPException(status_code=502, detail=detail) - finally: - await svc.client.close() - - bars = { - ticker: [ - { - "date": row.date.date().isoformat(), - "open": row.open, - "high": row.high, - "low": row.low, - "close": row.close, - "volume": row.volume, - } - for row in rows - ] - for ticker, rows in data.items() - } + raise HTTPException(status_code=500, detail=f"yfinance error: {e}") + + bars = {} + for item in results: + if item.success and item.data and item.data.data: + bars[item.ticker] = [ + { + "date": point.date.isoformat(), + "open": point.open, + "high": point.high, + "low": point.low, + "close": point.close, + "volume": point.volume, + } + for point in item.data.data + ] return AlpacaMultiBarsResponse( + source="YAHOO_FINANCE", interval=interval, - count=len(symbols), + count=len(bars), bars=bars, ) diff --git a/app/services/price_data_service.py b/app/services/price_data_service.py index ee3b020..a8f9afe 100644 --- a/app/services/price_data_service.py +++ b/app/services/price_data_service.py @@ -844,7 +844,8 @@ class PriceDataService: """ results = [] start_str = start_date.strftime('%Y-%m-%d') - end_str = end_date.strftime('%Y-%m-%d') + # yfinance end is exclusive — add +1 day to include end_date (same as _fetch_price_data) + end_str = (end_date + timedelta(days=1)).strftime('%Y-%m-%d') loop = asyncio.get_event_loop()