feat: Alpaca intraday 엔드포인트 분리 — /intraday (SIP, 과거) + /intraday/today (IEX, 당일)

- GET /alpaca/intraday: SIP 피드로 변경, end_date >= 오늘이면 400 에러, 백테스트용
- GET /alpaca/intraday/today: 신규, IEX 피드, 오늘 당일 실시간 전용, force_refresh=True
- AlpacaPriceService.get_or_fetch_multi_bars()에 feed 파라미터 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
I Luk Kim 4 months ago
parent 73bf6115fa
commit 8e58fdffa5

@ -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)

@ -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 = []

Loading…
Cancel
Save