You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
310 lines
12 KiB
Python
310 lines
12 KiB
Python
"""
|
|
Alpaca Market Data endpoints — standalone price data via Alpaca API
|
|
"""
|
|
|
|
import asyncio
|
|
from datetime import date, datetime, timezone, timedelta
|
|
from typing import Optional
|
|
from zoneinfo import ZoneInfo
|
|
|
|
from fastapi import APIRouter, HTTPException, Query
|
|
|
|
_ET = ZoneInfo("America/New_York")
|
|
_MARKET_CLOSE_HOUR = 16 # 4:00 PM ET
|
|
|
|
# Limit concurrent Alpaca intraday processing to prevent event-loop saturation
|
|
# under bulk backfill workloads. Callers beyond this limit wait on the semaphore
|
|
# (cheap asyncio wait) rather than flooding httpx connections and DB sessions.
|
|
_INTRADAY_SEMAPHORE = asyncio.Semaphore(3)
|
|
|
|
|
|
def _market_closed_for(d: date) -> bool:
|
|
"""Return True if the US equity market session for date d has ended."""
|
|
now_et = datetime.now(_ET)
|
|
market_close_et = datetime(d.year, d.month, d.day, _MARKET_CLOSE_HOUR, 0, tzinfo=_ET)
|
|
return now_et >= market_close_et
|
|
|
|
from app.schemas.financial import (
|
|
AlpacaMultiBarsResponse,
|
|
AlpacaMultiSnapshotResponse,
|
|
AlpacaSnapshotResponse,
|
|
)
|
|
from app.services.alpaca_client import AlpacaClient
|
|
from app.services.alpaca_price_service import AlpacaPriceService
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
def _require_alpaca() -> AlpacaPriceService:
|
|
svc = AlpacaPriceService()
|
|
if not svc.is_available():
|
|
raise HTTPException(
|
|
status_code=503,
|
|
detail="Alpaca API keys not configured. Set ALPACA_API_KEY and ALPACA_SECRET_KEY.",
|
|
)
|
|
return svc
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Status
|
|
# ------------------------------------------------------------------
|
|
|
|
@router.get(
|
|
"/status",
|
|
summary="Alpaca connection status",
|
|
description="Check Alpaca API key validity and connection health.",
|
|
)
|
|
async def alpaca_status():
|
|
client = AlpacaClient()
|
|
if not client.is_configured():
|
|
return {
|
|
"configured": False,
|
|
"message": "ALPACA_API_KEY / ALPACA_SECRET_KEY not set",
|
|
}
|
|
status = await client.check_connection()
|
|
await client.close()
|
|
return {"configured": True, **status}
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Intraday bars (DB-backed)
|
|
# ------------------------------------------------------------------
|
|
|
|
@router.get(
|
|
"/intraday",
|
|
response_model=AlpacaMultiBarsResponse,
|
|
summary="Get historical intraday bars for multiple tickers (SIP feed, DB-backed)",
|
|
description=(
|
|
"멀티 종목 과거 분봉 데이터를 Alpaca **SIP 피드**로 가져옵니다. "
|
|
"DB에 저장되며 재요청 시 Alpaca 미호출.\n\n"
|
|
"**⚠️ 장 중 당일 데이터 불가** — 장 마감(오후 4시 ET) 후에는 당일 날짜도 조회 가능\n\n"
|
|
"| 항목 | 내용 |\n"
|
|
"|------|------|\n"
|
|
"| 피드 | **SIP** (전체 미국 거래소 통합) |\n"
|
|
"| 거래량 | **100%** 정확 |\n"
|
|
"| 조회 범위 | **2016년~오늘(장 마감 후)** |\n"
|
|
"| DB 저장 | 있음 (재요청 시 Alpaca 미사용) |\n\n"
|
|
"**권장 용도**: 백테스트, 과거 분봉 분석\n\n"
|
|
"- `tickers`: comma-separated, e.g. `AAPL,MSFT,BF-B`\n"
|
|
"- `interval`: `1m`, `5m`, `15m`, `30m`, `1h`\n"
|
|
"- 내부 100개 단위 자동 배치 분할 (500종목 → Alpaca 5회 호출)\n"
|
|
"- Requires `ALPACA_API_KEY` / `ALPACA_SECRET_KEY`"
|
|
),
|
|
)
|
|
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"),
|
|
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"),
|
|
):
|
|
"""Multi-ticker historical intraday bars via Alpaca SIP (up to today after market close)."""
|
|
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
|
|
|
|
today = date.today()
|
|
if _end > today:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="미래 날짜는 조회할 수 없습니다.",
|
|
)
|
|
if _end == today and not _market_closed_for(today):
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="장 중에는 당일 SIP 데이터를 조회할 수 없습니다. "
|
|
"실시간 데이터는 GET /api/v1/alpaca/intraday/today 를 사용하세요.",
|
|
)
|
|
|
|
svc = _require_alpaca()
|
|
start_dt = datetime.combine(_start, datetime.min.time()).replace(tzinfo=timezone.utc)
|
|
end_dt = datetime.combine(_end, datetime.max.time()).replace(tzinfo=timezone.utc)
|
|
|
|
async with _INTRADAY_SEMAPHORE:
|
|
try:
|
|
data = await svc.get_or_fetch_multi_bars(
|
|
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 for multiple tickers (IEX feed, DB-backed)",
|
|
description=(
|
|
"당일(오늘) 실시간 분봉 데이터를 Alpaca **IEX 피드**로 가져옵니다. "
|
|
"장 중 재요청 시 항상 Alpaca에서 최신 데이터를 가져옵니다.\n\n"
|
|
"**⚠️ 오늘 데이터만 조회 가능** — 과거 데이터는 `/intraday` 사용\n\n"
|
|
"| 항목 | 내용 |\n"
|
|
"|------|------|\n"
|
|
"| 피드 | **IEX** (IEX 거래소 단일) |\n"
|
|
"| 지연 | **실시간** (지연 없음) |\n"
|
|
"| 거래량 | 실제의 약 **2~5%** (IEX 거래소 거래만 집계) |\n"
|
|
"| High/Low range | SIP 대비 좁게 표시될 수 있음 |\n"
|
|
"| DB 저장 | 있음 (장 중 항상 재조회) |\n\n"
|
|
"**권장 용도**: 당일 ORB 전략, 실시간 장 중 모니터링\n\n"
|
|
"- `tickers`: comma-separated, e.g. `AAPL,MSFT,BF-B`\n"
|
|
"- `interval`: `1m`, `5m`, `15m`, `30m`, `1h`\n"
|
|
"- 내부 100개 단위 자동 배치 분할\n"
|
|
"- Requires `ALPACA_API_KEY` / `ALPACA_SECRET_KEY`"
|
|
),
|
|
)
|
|
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"),
|
|
):
|
|
"""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)
|
|
|
|
async with _INTRADAY_SEMAPHORE:
|
|
try:
|
|
data = await svc.get_or_fetch_multi_bars(
|
|
symbols, start_dt, end_dt, interval, force_refresh=True, feed="iex"
|
|
)
|
|
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)
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Real-time snapshot
|
|
# ------------------------------------------------------------------
|
|
|
|
def _parse_snapshot(ticker: str, raw: dict) -> AlpacaSnapshotResponse:
|
|
"""Convert raw Alpaca snapshot dict → AlpacaSnapshotResponse."""
|
|
trade = raw.get("latestTrade") or {}
|
|
quote = raw.get("latestQuote") or {}
|
|
daily = raw.get("dailyBar") or {}
|
|
prev = raw.get("prevDailyBar") or {}
|
|
|
|
price = trade.get("p")
|
|
prev_close = prev.get("c")
|
|
change = round(price - prev_close, 4) if price is not None and prev_close else None
|
|
change_pct = round(change / prev_close * 100, 4) if change is not None and prev_close else None
|
|
|
|
return AlpacaSnapshotResponse(
|
|
ticker=ticker.upper(),
|
|
timestamp=trade.get("t"),
|
|
price=price,
|
|
trade_size=trade.get("s"),
|
|
bid=quote.get("bp"),
|
|
ask=quote.get("ap"),
|
|
bid_size=quote.get("bs"),
|
|
ask_size=quote.get("as"),
|
|
open=daily.get("o"),
|
|
high=daily.get("h"),
|
|
low=daily.get("l"),
|
|
volume=daily.get("v"),
|
|
vwap=daily.get("vw"),
|
|
prev_close=prev_close,
|
|
change=change,
|
|
change_pct=change_pct,
|
|
)
|
|
|
|
|
|
@router.get(
|
|
"/snapshot",
|
|
response_model=AlpacaMultiSnapshotResponse,
|
|
summary="Real-time snapshots for multiple tickers (IEX feed)",
|
|
description=(
|
|
"멀티 종목 실시간 스냅샷. 최신 체결가, bid/ask, 당일 OHLCV, 전일 대비 변동률 포함.\n\n"
|
|
"단일 종목도 `?tickers=AAPL`로 조회 가능.\n\n"
|
|
"| 항목 | 내용 |\n"
|
|
"|------|------|\n"
|
|
"| 피드 | **IEX** — 무료 플랜에서 snapshot은 SIP 불가 |\n"
|
|
"| 지연 | **실시간** (지연 없음) |\n"
|
|
"| 거래량 | IEX 기준 (실제의 2~5%) |\n"
|
|
"| 캐시 | **없음** — 매 요청마다 Alpaca 직접 호출 |\n\n"
|
|
"- Requires `ALPACA_API_KEY` / `ALPACA_SECRET_KEY`"
|
|
),
|
|
)
|
|
async def get_snapshots(
|
|
tickers: str = Query(..., description="Comma-separated ticker symbols, e.g. AAPL,MSFT,NVDA"),
|
|
):
|
|
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.")
|
|
|
|
client = AlpacaClient()
|
|
if not client.is_configured():
|
|
raise HTTPException(status_code=503, detail="Alpaca API keys not configured.")
|
|
try:
|
|
raw_map = await client.get_snapshots(symbols)
|
|
results = [_parse_snapshot(sym, raw_map.get(sym, {})) for sym in symbols]
|
|
return AlpacaMultiSnapshotResponse(count=len(results), snapshots=results)
|
|
except Exception as e:
|
|
raise HTTPException(status_code=502, detail=f"Alpaca API error: {e}")
|
|
finally:
|
|
await client.close()
|