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

"""
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(10)
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()