diff --git a/app/api/v1/endpoints/alpaca.py b/app/api/v1/endpoints/alpaca.py index 03d5771..ba0c405 100644 --- a/app/api/v1/endpoints/alpaca.py +++ b/app/api/v1/endpoints/alpaca.py @@ -2,7 +2,7 @@ Alpaca Market Data endpoints — standalone price data via Alpaca API """ -from datetime import date, datetime, timezone +from datetime import date, datetime, timezone, timedelta from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Query @@ -223,24 +223,24 @@ async def get_alpaca_price_data( @router.get( "/intraday", response_model=AlpacaMultiBarsResponse, - summary="Get intraday bars for multiple tickers via Alpaca (DB-backed)", + summary="Get historical intraday bars for multiple tickers via Alpaca SIP (DB-backed)", description=( - "Fetch intraday OHLCV bars for up to ~500 tickers via Alpaca IEX feed. " + "Fetch historical intraday OHLCV bars for up to ~500 tickers via Alpaca **SIP 피드**. " "Results are stored in DB so subsequent calls for the same period skip Alpaca.\n\n" - "**⚠️ Alpaca IEX 피드 분봉 데이터 한계**\n\n" + "**⚠️ 날짜 제한: 어제(yesterday)까지만 조회 가능**\n\n" + "Alpaca 무료 플랜에서 SIP 피드는 15분 이상 지난 데이터만 접근 가능합니다. " + "당일(오늘) 데이터가 필요하면 → **`GET /api/v1/alpaca/intraday/now`** 사용\n\n" + "**SIP 피드 특성**\n\n" "| 항목 | 내용 |\n" "|------|------|\n" - "| 피드 | **IEX 피드** (무료 플랜 기본값) |\n" - "| 지연 | **실시간** (지연 없음) |\n" - "| 거래량 커버리지 | 미국 전체 시장의 약 **2~5%** (IEX 거래소 거래만 집계) |\n" - "| OHLCV 정확도 | SIP 대비 **거래량 대폭 낮게** 표시됨 (가격 레벨은 유사) |\n" - "| 당일 장 중 재요청 | max_date < end_dt 조건으로 **항상 Alpaca 재호출** |\n" - "| SIP 피드 사용 시 | 유료 구독 + `feed=sip` 파라미터 추가 필요 |\n" - "| 히스토리 한계 | 과거 데이터는 SIP 기준이므로 무료 플랜 접근 범위 제한 없음 |\n\n" - "**권장 용도**: 당일 실시간 분봉 조회, ORB 전략 (가격 레벨 기준), 실시간 장 중 모니터링\n\n" - "**거래량 정확도가 중요하면** → 유료 Alpaca 플랜 + `feed=sip` 사용\n\n" + "| 피드 | **SIP 피드** (전체 미국 거래소 통합) |\n" + "| 거래량 커버리지 | **100%** (NYSE, NASDAQ, BATS 등 전체) |\n" + "| OHLCV 정확도 | **정확** — 백테스트에 적합 |\n" + "| 조회 가능 범위 | **2016년~어제** (당일 조회 시 400 에러) |\n" + "| 히스토리 | 2016년부터 제공 (무료 플랜 기준) |\n\n" + "**권장 용도**: 백테스트, 과거 분봉 분석, ORB 전략 히스토리컬 검증\n\n" "- `tickers`: comma-separated list, e.g. `AAPL,MSFT,BF-B`\n" - "- `interval`: `1m`, `5m`, `15m`, `30m`, `1h` (또는 `5min`, `15min` 등)\n" + "- `interval`: `1m`, `5m`, `15m`, `30m`, `1h`\n" "- DB 저장 후 재요청 시 Alpaca 미사용\n" "- Requires `ALPACA_API_KEY` / `ALPACA_SECRET_KEY`.\n\n" "**⚠️ Alpaca 배치 제한**\n\n" @@ -252,30 +252,118 @@ async def get_alpaca_price_data( ) 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)"), + interval: str = Query("5m", description="Interval: 1m, 5m, 15m, 30m, 1h"), + start_date: Optional[date] = Query(None, description="Start date (YYYY-MM-DD). Default: yesterday"), + end_date: Optional[date] = Query(None, description="End date (YYYY-MM-DD). Must be before today. Default: yesterday"), force_refresh: bool = Query(False, description="Re-fetch from Alpaca even if DB has data"), db: AsyncSession = Depends(get_db), ): - """Multi-ticker intraday bars via Alpaca with DB storage (ORB engine interface).""" + """Multi-ticker historical intraday bars via Alpaca SIP (up to yesterday).""" 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.") + yesterday = date.today() - timedelta(days=1) + _start = start_date or yesterday + _end = end_date or yesterday + + if _end >= date.today(): + raise HTTPException( + status_code=400, + detail="이 엔드포인트는 어제(yesterday)까지의 과거 데이터만 조회 가능합니다. " + "당일 실시간 데이터는 GET /api/v1/alpaca/intraday/today 를 사용하세요.", + ) + svc = _require_alpaca() - # Default date range: today - _start = start_date or date.today() - _end = end_date or date.today() start_dt = datetime.combine(_start, datetime.min.time()).replace(tzinfo=timezone.utc) end_dt = datetime.combine(_end, datetime.max.time()).replace(tzinfo=timezone.utc) try: data = await svc.get_or_fetch_multi_bars( - db, symbols, start_dt, end_dt, interval, force_refresh + db, symbols, start_dt, end_dt, interval, force_refresh, feed="sip" + ) + 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: [ + { + "timestamp": row.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() + } + + return AlpacaMultiBarsResponse( + interval=interval, + count=len(symbols), + bars=bars, + ) + + +@router.get( + "/intraday/today", + response_model=AlpacaMultiBarsResponse, + summary="Get today's real-time intraday bars via Alpaca IEX (DB-backed)", + description=( + "당일(오늘) 실시간 분봉 데이터를 Alpaca **IEX 피드**로 가져옵니다. " + "DB에 저장되며, 장 중 재요청 시 항상 Alpaca에서 최신 데이터를 가져옵니다.\n\n" + "**IEX 피드 특성**\n\n" + "| 항목 | 내용 |\n" + "|------|------|\n" + "| 피드 | **IEX 피드** (IEX 거래소 단일) |\n" + "| 지연 | **실시간** (지연 없음) |\n" + "| 거래량 커버리지 | 미국 전체 시장의 약 **2~5%** |\n" + "| 가격 방향성 | **신뢰 가능** (대형주 기준) |\n" + "| High/Low range | SIP 대비 **좁게** 표시될 수 있음 |\n" + "| 조회 가능 범위 | **오늘만** (어제 이전 데이터는 `/intraday` 사용) |\n\n" + "**권장 용도**: 당일 ORB 전략, 실시간 장 중 모니터링\n\n" + "- `tickers`: comma-separated list, e.g. `AAPL,MSFT,BF-B`\n" + "- `interval`: `1m`, `5m`, `15m`, `30m`, `1h`\n" + "- 과거 분봉 히스토리가 필요하면 → `GET /api/v1/alpaca/intraday` 사용\n" + "- Requires `ALPACA_API_KEY` / `ALPACA_SECRET_KEY`.\n\n" + "**⚠️ Alpaca 배치 제한**: 내부적으로 100개 단위 자동 분할 처리." + ), +) +async def get_alpaca_intraday_today( + tickers: str = Query(..., description="Comma-separated tickers, e.g. AAPL,MSFT,BF-B"), + interval: str = Query("5m", description="Interval: 1m, 5m, 15m, 30m, 1h"), + db: AsyncSession = Depends(get_db), +): + """Today's real-time intraday bars via Alpaca IEX (always re-fetches latest).""" + 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.") + + svc = _require_alpaca() + + today = date.today() + start_dt = datetime.combine(today, datetime.min.time()).replace(tzinfo=timezone.utc) + end_dt = datetime.combine(today, datetime.max.time()).replace(tzinfo=timezone.utc) + + try: + data = await svc.get_or_fetch_multi_bars( + db, symbols, start_dt, end_dt, interval, force_refresh=True, feed="iex" ) except Exception as e: err = str(e) diff --git a/app/services/alpaca_price_service.py b/app/services/alpaca_price_service.py index 23c4777..f6bbf63 100644 --- a/app/services/alpaca_price_service.py +++ b/app/services/alpaca_price_service.py @@ -94,6 +94,7 @@ class AlpacaPriceService: end_dt: datetime, interval: str = "1d", force_refresh: bool = False, + feed: Optional[str] = None, ) -> Dict[str, List[AlpacaPriceData]]: """ DB-first multi-ticker daily bars. @@ -139,6 +140,7 @@ class AlpacaPriceService: timeframe=interval, start=start_str, end=end_str, + feed=feed, ) rows = []