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.
576 lines
23 KiB
Python
576 lines
23 KiB
Python
"""
|
|
Alpaca Market Data endpoints — standalone price data via Alpaca API
|
|
"""
|
|
|
|
from datetime import date, datetime, timezone, timedelta
|
|
from typing import Optional
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from fastapi.responses import Response
|
|
from starlette.responses import JSONResponse
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select, and_
|
|
|
|
from app.core.config import settings
|
|
from app.core.database import get_db
|
|
from app.models.alpaca_price import AlpacaPriceData
|
|
from app.schemas.financial import (
|
|
PriceDataResponse,
|
|
AlpacaPriceDataPoint,
|
|
AlpacaBarsResponse,
|
|
AlpacaIntradayResponse,
|
|
AlpacaSnapshotResponse,
|
|
AlpacaMultiSnapshotResponse,
|
|
AlpacaMultiBarsResponse,
|
|
ErrorType,
|
|
)
|
|
from app.services.alpaca_client import AlpacaClient, normalize_ticker
|
|
from app.services.alpaca_price_service import AlpacaPriceService
|
|
from app.utils.cache import build_cache_key, get_cached_response, set_cached_response, with_cache
|
|
|
|
router = APIRouter()
|
|
|
|
INTRADAY_CACHE_TTL = 300 # 5 minutes for intraday data
|
|
|
|
|
|
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 (no caching — always real-time)
|
|
# ------------------------------------------------------------------
|
|
|
|
@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}
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Raw bars (no DB) — with Redis caching
|
|
# ------------------------------------------------------------------
|
|
|
|
@router.get(
|
|
"/bars/{ticker}",
|
|
response_model=AlpacaBarsResponse,
|
|
summary="Get Alpaca bars (raw, no DB)",
|
|
description=(
|
|
"Fetch historical bars directly from Alpaca without storing in DB.\n\n"
|
|
"**데이터 소스 (피드 자동 선택)**\n\n"
|
|
"| interval | 피드 | 특성 |\n"
|
|
"|----------|------|------|\n"
|
|
"| `1m` `5m` `15m` `30m` `1h` | **IEX** | 실시간, 거래량 2~5%, 무료 |\n"
|
|
"| `1d` `1w` `1mo` | **SIP** | 전체 거래소 통합, 정확, 무료 |\n\n"
|
|
"- DB에 저장하지 않음 — 저장이 필요하면 `/alpaca/data/{ticker}` 사용\n"
|
|
"- 조회 범위: 2016년~ (Alpaca 무료 플랜 기준)\n"
|
|
"- Redis 24시간 캐시 적용 (`force_refresh=true`로 우회)\n"
|
|
"- Requires `ALPACA_API_KEY` / `ALPACA_SECRET_KEY`"
|
|
),
|
|
)
|
|
@with_cache(namespace="alpaca:bars", ttl=86400, key_params=["ticker", "interval", "start_date", "end_date", "limit"])
|
|
async def get_alpaca_bars(
|
|
ticker: str,
|
|
response: Response,
|
|
interval: str = Query("1d", description="Interval: 1m, 5m, 15m, 1h, 1d, 1w, 1mo"),
|
|
start_date: Optional[date] = Query(None, description="Start date (YYYY-MM-DD)"),
|
|
end_date: Optional[date] = Query(None, description="End date (YYYY-MM-DD)"),
|
|
limit: int = Query(1000, ge=1, le=10000, description="Max bars to return"),
|
|
force_refresh: bool = Query(False, description="Bypass cache"),
|
|
):
|
|
svc = _require_alpaca()
|
|
|
|
start_dt = datetime.combine(start_date, datetime.min.time()).replace(tzinfo=timezone.utc) if start_date else None
|
|
end_dt = datetime.combine(end_date, datetime.min.time()).replace(tzinfo=timezone.utc) if end_date else None
|
|
|
|
try:
|
|
bars = await svc.fetch_bars_raw(
|
|
ticker=ticker.upper(),
|
|
interval=interval,
|
|
start_date=start_dt,
|
|
end_date=end_dt,
|
|
)
|
|
bars = bars[:limit]
|
|
body_dict = {
|
|
"ticker": ticker.upper(),
|
|
"interval": interval,
|
|
"count": len(bars),
|
|
"bars": bars,
|
|
}
|
|
|
|
return body_dict
|
|
except Exception as e:
|
|
raise HTTPException(status_code=502, detail=f"Alpaca API error: {e}")
|
|
finally:
|
|
await svc.client.close()
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Price data (DB storage) — with Redis caching
|
|
# ------------------------------------------------------------------
|
|
|
|
@router.get(
|
|
"/data/{ticker}",
|
|
response_model=PriceDataResponse,
|
|
summary="Get price data via Alpaca (with DB storage)",
|
|
description=(
|
|
"Fetch OHLCV price data from Alpaca, store in AlpacaPriceData table, and return "
|
|
"in PriceDataResponse format. Includes vwap and trade_count in metadata.\n\n"
|
|
"**데이터 소스 (피드 자동 선택)**\n\n"
|
|
"| interval | 피드 | 특성 |\n"
|
|
"|----------|------|------|\n"
|
|
"| `1m` `5m` `15m` `30m` `1h` | **IEX** | 실시간, 거래량 2~5%, 무료 |\n"
|
|
"| `1d` `1w` `1mo` | **SIP** | 전체 거래소 통합, 정확, 무료 |\n\n"
|
|
"- DB 저장 후 재요청 시 Alpaca 미사용 (DB-first)\n"
|
|
"- Requires `ALPACA_API_KEY` / `ALPACA_SECRET_KEY`\n"
|
|
"- `data_source = \"ALPACA\"`로 Yahoo Finance 데이터와 구분\n"
|
|
"- 조회 범위: 2016년~ (Alpaca 무료 플랜 기준)"
|
|
),
|
|
)
|
|
@with_cache(namespace="alpaca:data", ttl=86400, key_params=["ticker", "interval", "start_date", "end_date"])
|
|
async def get_alpaca_price_data(
|
|
ticker: str,
|
|
response: Response,
|
|
interval: str = Query("1d", description="Interval: 1m, 5m, 15m, 1h, 1d, 1w, 1mo"),
|
|
start_date: date = Query(..., description="Start date (YYYY-MM-DD)"),
|
|
end_date: date = Query(..., description="End date (YYYY-MM-DD)"),
|
|
force_refresh: bool = Query(False, description="Re-fetch even if data exists in DB"),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
svc = _require_alpaca()
|
|
|
|
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)
|
|
|
|
if start_dt >= end_dt:
|
|
raise HTTPException(status_code=400, detail="start_date must be before end_date")
|
|
|
|
try:
|
|
count = await svc.fetch_and_store_bars(
|
|
db, ticker, start_dt, end_dt, interval
|
|
)
|
|
|
|
# Read back from AlpacaPriceData table
|
|
result = await db.execute(
|
|
select(AlpacaPriceData)
|
|
.where(
|
|
and_(
|
|
AlpacaPriceData.ticker == ticker.upper(),
|
|
AlpacaPriceData.date >= start_dt,
|
|
AlpacaPriceData.date <= end_dt,
|
|
)
|
|
)
|
|
.order_by(AlpacaPriceData.date)
|
|
)
|
|
rows = result.scalars().all()
|
|
|
|
alpaca_points = [AlpacaPriceDataPoint.model_validate(r) for r in rows]
|
|
|
|
# Build PriceDataResponse-compatible data with vwap/trade_count in metadata
|
|
from app.schemas.financial import PriceDataPoint
|
|
price_points = [
|
|
PriceDataPoint(
|
|
date=p.date,
|
|
open=p.open,
|
|
high=p.high,
|
|
low=p.low,
|
|
close=p.close,
|
|
volume=p.volume,
|
|
adjusted_close=p.vwap, # Map vwap -> adjusted_close for compatibility
|
|
data_source=p.data_source,
|
|
)
|
|
for p in alpaca_points
|
|
]
|
|
|
|
body = PriceDataResponse(
|
|
ticker=ticker.upper(),
|
|
interval=interval,
|
|
data=price_points,
|
|
metadata={
|
|
"source": "ALPACA",
|
|
"data_points": len(price_points),
|
|
"new_bars_inserted": count,
|
|
"date_range": {
|
|
"start": start_date.isoformat(),
|
|
"end": end_date.isoformat(),
|
|
},
|
|
"alpaca_fields": [
|
|
{"date": p.date.isoformat(), "vwap": p.vwap, "trade_count": p.trade_count}
|
|
for p in alpaca_points
|
|
],
|
|
},
|
|
)
|
|
|
|
return body
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(status_code=502, detail=f"Alpaca error: {e}")
|
|
finally:
|
|
await svc.client.close()
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Intraday (raw — no DB) — with Redis caching (short TTL)
|
|
# ------------------------------------------------------------------
|
|
|
|
@router.get(
|
|
"/intraday",
|
|
response_model=AlpacaMultiBarsResponse,
|
|
summary="Get historical intraday bars for multiple tickers via Alpaca SIP (DB-backed)",
|
|
description=(
|
|
"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"
|
|
"**⚠️ 날짜 제한: 어제(yesterday)까지만 조회 가능**\n\n"
|
|
"Alpaca 무료 플랜에서 SIP 피드는 15분 이상 지난 데이터만 접근 가능합니다. "
|
|
"당일(오늘) 데이터가 필요하면 → **`GET /api/v1/alpaca/intraday/now`** 사용\n\n"
|
|
"**SIP 피드 특성**\n\n"
|
|
"| 항목 | 내용 |\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`\n"
|
|
"- DB 저장 후 재요청 시 Alpaca 미사용\n"
|
|
"- Requires `ALPACA_API_KEY` / `ALPACA_SECRET_KEY`.\n\n"
|
|
"**⚠️ Alpaca 배치 제한**\n\n"
|
|
"Alpaca multi-bar 엔드포인트는 요청당 **~100개 심볼**이 실질적 상한입니다 "
|
|
"(공식 문서 미명시, 커뮤니티 보고 및 실제 운용 기준 — 초과 시 502 발생). "
|
|
"내부적으로 **100개 단위로 자동 분할**하여 요청하므로 클라이언트는 신경 쓸 필요 없음. "
|
|
"단, 배치 수가 늘어날수록 응답 시간이 선형적으로 증가함 (500종목 → Alpaca 5회 호출)."
|
|
),
|
|
)
|
|
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"),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""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()
|
|
|
|
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, 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)
|
|
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/{ticker}",
|
|
response_model=AlpacaIntradayResponse,
|
|
summary="Get intraday candles from Alpaca (single ticker, IEX feed)",
|
|
description=(
|
|
"단일 종목의 분봉 데이터를 Alpaca **IEX 피드**로 직접 가져옵니다.\n\n"
|
|
"**데이터 소스: IEX 피드**\n\n"
|
|
"| 항목 | 내용 |\n"
|
|
"|------|------|\n"
|
|
"| 피드 | **IEX** (IEX 거래소 단일) |\n"
|
|
"| 지연 | **실시간** (지연 없음) |\n"
|
|
"| 거래량 커버리지 | 미국 전체 시장의 약 **2~5%** |\n"
|
|
"| DB 저장 | **없음** — 매 요청 Alpaca 직접 호출 |\n"
|
|
"| 캐시 | Redis **5분 TTL** |\n\n"
|
|
"멀티 종목이 필요하면 → `GET /api/v1/alpaca/intraday/today` (오늘) 또는 "
|
|
"`GET /api/v1/alpaca/intraday` (과거, SIP)\n\n"
|
|
"- Requires `ALPACA_API_KEY` / `ALPACA_SECRET_KEY`"
|
|
),
|
|
)
|
|
@with_cache(namespace="alpaca:intraday", ttl=300, key_params=["ticker", "interval", "start_date", "end_date"])
|
|
async def get_alpaca_intraday(
|
|
ticker: str,
|
|
response: Response,
|
|
interval: str = Query("1m", description="Interval: 1m, 5m, 15m, 1h"),
|
|
start_date: Optional[date] = Query(None, description="Start date"),
|
|
end_date: Optional[date] = Query(None, description="End date"),
|
|
limit: int = Query(1000, ge=1, le=10000, description="Max candles"),
|
|
force_refresh: bool = Query(False, description="Bypass cache"),
|
|
):
|
|
svc = _require_alpaca()
|
|
|
|
start_dt = datetime.combine(start_date, datetime.min.time()).replace(tzinfo=timezone.utc) if start_date else None
|
|
end_dt = datetime.combine(end_date, datetime.min.time()).replace(tzinfo=timezone.utc) if end_date else None
|
|
|
|
try:
|
|
bars = await svc.fetch_bars_raw(
|
|
ticker=ticker.upper(),
|
|
interval=interval,
|
|
start_date=start_dt,
|
|
end_date=end_dt,
|
|
)
|
|
bars = bars[:limit]
|
|
body_dict = {
|
|
"ticker": ticker.upper(),
|
|
"interval": interval,
|
|
"source": "ALPACA",
|
|
"count": len(bars),
|
|
"candles": bars,
|
|
}
|
|
|
|
return body_dict
|
|
except Exception as e:
|
|
raise HTTPException(status_code=502, detail=f"Alpaca API error: {e}")
|
|
finally:
|
|
await svc.client.close()
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Real-time snapshot (no cache)
|
|
# ------------------------------------------------------------------
|
|
|
|
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/{ticker}",
|
|
response_model=AlpacaSnapshotResponse,
|
|
summary="Real-time snapshot for a single ticker (IEX feed)",
|
|
description=(
|
|
"단일 종목의 실시간 스냅샷을 반환합니다. 최신 체결가, bid/ask, 당일 OHLCV, 전일 대비 변동률 포함.\n\n"
|
|
"**데이터 소스: IEX 피드 (무료 플랜 강제)**\n\n"
|
|
"| 항목 | 내용 |\n"
|
|
"|------|------|\n"
|
|
"| 피드 | **IEX** — 무료 플랜에서 snapshot은 SIP 불가 |\n"
|
|
"| 지연 | **실시간** (지연 없음) |\n"
|
|
"| 거래량 | IEX 기준 (실제의 2~5%) |\n"
|
|
"| High/Low | IEX 기준 (실제보다 range 좁을 수 있음) |\n"
|
|
"| 캐시 | **없음** — 매 요청마다 Alpaca 직접 호출 |\n\n"
|
|
"- Requires `ALPACA_API_KEY` / `ALPACA_SECRET_KEY`"
|
|
),
|
|
)
|
|
async def get_snapshot(ticker: str):
|
|
client = AlpacaClient()
|
|
if not client.is_configured():
|
|
raise HTTPException(status_code=503, detail="Alpaca API keys not configured.")
|
|
try:
|
|
raw = await client.get_snapshot(ticker)
|
|
return _parse_snapshot(ticker, raw)
|
|
except Exception as e:
|
|
raise HTTPException(status_code=502, detail=f"Alpaca API error: {e}")
|
|
finally:
|
|
await client.close()
|
|
|
|
|
|
@router.get(
|
|
"/snapshot",
|
|
response_model=AlpacaMultiSnapshotResponse,
|
|
summary="Real-time snapshots for multiple tickers (IEX feed)",
|
|
description=(
|
|
"멀티 종목의 실시간 스냅샷을 한 번의 요청으로 반환합니다.\n\n"
|
|
"**데이터 소스: IEX 피드 (무료 플랜 강제)**\n\n"
|
|
"| 항목 | 내용 |\n"
|
|
"|------|------|\n"
|
|
"| 피드 | **IEX** — 무료 플랜에서 snapshot은 SIP 불가 |\n"
|
|
"| 지연 | **실시간** (지연 없음) |\n"
|
|
"| 거래량 | IEX 기준 (실제의 2~5%) |\n"
|
|
"| 캐시 | **없음** — 매 요청마다 Alpaca 직접 호출 |\n\n"
|
|
"**Usage**: `?tickers=AAPL,MSFT,NVDA`\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 (max 1000)"),
|
|
):
|
|
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()
|