fix: GET /price/data — Alpaca → yfinance 교체 + bulk end_date 버그 2건 수정

- 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 <noreply@anthropic.com>
main
I Luk Kim 4 months ago
parent c203f3920b
commit dd3e38fbf5

@ -23,7 +23,6 @@ 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_price_service import AlpacaPriceService
from app.schemas.financial import AlpacaMultiBarsResponse 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
@ -289,78 +288,69 @@ async def get_price_data(
@router.get( @router.get(
"/data", "/data",
response_model=AlpacaMultiBarsResponse, 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=( description=(
"Fetch OHLCV daily bars for up to ~500 tickers. Results are stored in DB so " "Fetch OHLCV daily bars for one or more tickers via Yahoo Finance (yfinance-plus). "
"subsequent calls only fetch new/missing dates from Alpaca.\n\n" "Results are stored in DB; subsequent calls for the same range skip the external API.\n\n"
"- `tickers`: comma-separated list, e.g. `AAPL,MSFT,BF-B`\n" "- `tickers` or `ticker`: comma-separated list, e.g. `AAPL,MSFT` or single `QQQ`\n"
"- Ticker normalization: `BF-B` → `BF.B` handled automatically; " "- `force_refresh=true`: re-fetch from Yahoo Finance even if DB has data\n"
"response keys use the original symbol names.\n" "- Up to 1000 tickers per request (auto-chunked internally)\n\n"
"- `force_refresh=true`: re-fetch all from Alpaca regardless of DB state.\n" "**경로 파라미터 대안**: 단일 종목은 `/data/{ticker}?start_date=...&end_date=...` 도 동일하게 동작합니다."
"- Requires `ALPACA_API_KEY` / `ALPACA_SECRET_KEY`.\n\n"
"**⚠️ Alpaca 배치 제한**\n\n"
"Alpaca multi-bar 엔드포인트는 요청당 **~100개 심볼**이 실질적 상한입니다 "
"(공식 문서 미명시, 커뮤니티 보고 및 실제 운용 기준). "
"내부적으로 **100개 단위로 자동 분할**하여 요청하므로 클라이언트는 신경 쓸 필요 없음. "
"단, 배치 수가 늘어날수록 응답 시간이 선형적으로 증가함 (500종목 → Alpaca 5회 호출)."
), ),
tags=["price", "alpaca"], tags=["price"],
) )
async def get_multi_ticker_daily_bars( 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)"), start_date: date = Query(..., description="Start date (YYYY-MM-DD)"),
end_date: date = Query(..., description="End date (YYYY-MM-DD)"), end_date: date = Query(..., description="End date (YYYY-MM-DD)"),
interval: str = Query("1d", description="Bar interval: 1d, 1w, 1mo"), interval: str = Query("1d", description="Bar interval: 1d, 1w, 1m"),
force_refresh: bool = Query(False, description="Re-fetch from Alpaca even if DB has data"), 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).""" """Multi-ticker daily bars via yfinance with DB storage."""
symbols = [s.strip().upper() for s in tickers.split(",") if s.strip()] 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: if not symbols:
raise HTTPException(status_code=400, detail="No tickers provided.") raise HTTPException(status_code=400, detail="No tickers provided.")
if len(symbols) > 1000: if len(symbols) > 1000:
raise HTTPException(status_code=400, detail="Maximum 1000 tickers per request.") 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) 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: try:
data = await svc.get_or_fetch_multi_bars( results, _, _ = await price_service.get_multiple_tickers_data_optimized(
symbols, start_dt, end_dt, interval, force_refresh tickers=symbols,
start_date=start_dt,
end_date=end_dt,
interval=interval,
force_refresh=force_refresh,
) )
except Exception as e: except Exception as e:
err = str(e) raise HTTPException(status_code=500, detail=f"yfinance error: {e}")
detail = f"Alpaca API error: {err}"
if "502" in err or "Bad Gateway" in err: bars = {}
detail = ( for item in results:
f"Alpaca 502 Bad Gateway — 요청당 심볼 수 초과 가능성. " if item.success and item.data and item.data.data:
f"내부 배치 크기: 100개/요청. 원인: {err}" bars[item.ticker] = [
) {
raise HTTPException(status_code=502, detail=detail) "date": point.date.isoformat(),
finally: "open": point.open,
await svc.client.close() "high": point.high,
"low": point.low,
bars = { "close": point.close,
ticker: [ "volume": point.volume,
{ }
"date": row.date.date().isoformat(), for point in item.data.data
"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()
}
return AlpacaMultiBarsResponse( return AlpacaMultiBarsResponse(
source="YAHOO_FINANCE",
interval=interval, interval=interval,
count=len(symbols), count=len(bars),
bars=bars, bars=bars,
) )

@ -844,7 +844,8 @@ class PriceDataService:
""" """
results = [] results = []
start_str = start_date.strftime('%Y-%m-%d') 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() loop = asyncio.get_event_loop()

Loading…
Cancel
Save