refactor: Alpaca 엔드포인트 정리 — 4개로 통합

제거: /bars/{ticker}, /data/{ticker}, /intraday/{ticker}, /snapshot/{ticker}
유지:
  - GET /alpaca/status
  - GET /alpaca/intraday        (SIP, 과거, 멀티 종목)
  - GET /alpaca/intraday/today  (IEX, 당일, 멀티 종목)
  - GET /alpaca/snapshot        (IEX, 단일/멀티 통합)

openapi.json 업데이트 (95 → 91 endpoints)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
I Luk Kim 4 months ago
parent 800bb9b4a6
commit f528ac751b

@ -6,32 +6,19 @@ from datetime import date, datetime, timezone, timedelta
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query 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.ext.asyncio import AsyncSession
from sqlalchemy import select, and_
from app.core.config import settings
from app.core.database import get_db from app.core.database import get_db
from app.models.alpaca_price import AlpacaPriceData
from app.schemas.financial import ( from app.schemas.financial import (
PriceDataResponse,
AlpacaPriceDataPoint,
AlpacaBarsResponse,
AlpacaIntradayResponse,
AlpacaSnapshotResponse,
AlpacaMultiSnapshotResponse,
AlpacaMultiBarsResponse, AlpacaMultiBarsResponse,
ErrorType, AlpacaMultiSnapshotResponse,
AlpacaSnapshotResponse,
) )
from app.services.alpaca_client import AlpacaClient, normalize_ticker from app.services.alpaca_client import AlpacaClient
from app.services.alpaca_price_service import AlpacaPriceService 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() router = APIRouter()
INTRADAY_CACHE_TTL = 300 # 5 minutes for intraday data
def _require_alpaca() -> AlpacaPriceService: def _require_alpaca() -> AlpacaPriceService:
svc = AlpacaPriceService() svc = AlpacaPriceService()
@ -44,7 +31,7 @@ def _require_alpaca() -> AlpacaPriceService:
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Status (no caching — always real-time) # Status
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@router.get( @router.get(
@ -65,200 +52,28 @@ async def alpaca_status():
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Raw bars (no DB) — with Redis caching # Intraday bars (DB-backed)
# ------------------------------------------------------------------
@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( @router.get(
"/intraday", "/intraday",
response_model=AlpacaMultiBarsResponse, response_model=AlpacaMultiBarsResponse,
summary="Get historical intraday bars for multiple tickers via Alpaca SIP (DB-backed)", summary="Get historical intraday bars for multiple tickers (SIP feed, DB-backed)",
description=( description=(
"Fetch historical intraday OHLCV bars for up to ~500 tickers via Alpaca **SIP 피드**. " "멀티 종목 과거 분봉 데이터를 Alpaca **SIP 피드**로 가져옵니다. "
"Results are stored in DB so subsequent calls for the same period skip Alpaca.\n\n" "DB에 저장되며 재요청 시 Alpaca 미호출.\n\n"
"**⚠️ 날짜 제한: 어제(yesterday)까지만 조회 가능**\n\n" "**⚠️ 어제(yesterday)까지만 조회 가능** — 당일 데이터는 `/intraday/today` 사용\n\n"
"Alpaca 무료 플랜에서 SIP 피드는 15분 이상 지난 데이터만 접근 가능합니다. "
"당일(오늘) 데이터가 필요하면 → **`GET /api/v1/alpaca/intraday/now`** 사용\n\n"
"**SIP 피드 특성**\n\n"
"| 항목 | 내용 |\n" "| 항목 | 내용 |\n"
"|------|------|\n" "|------|------|\n"
"| 피드 | **SIP 피드** (전체 미국 거래소 통합) |\n" "| 피드 | **SIP** (전체 미국 거래소 통합) |\n"
"| 거래량 커버리지 | **100%** (NYSE, NASDAQ, BATS 등 전체) |\n" "| 거래량 | **100%** 정확 |\n"
"| OHLCV 정확도 | **정확** — 백테스트에 적합 |\n" "| 조회 범위 | **2016년~어제** |\n"
"| 조회 가능 범위 | **2016년~어제** (당일 조회 시 400 에러) |\n" "| DB 저장 | 있음 (재요청 시 Alpaca 미사용) |\n\n"
"| 히스토리 | 2016년부터 제공 (무료 플랜 기준) |\n\n" "**권장 용도**: 백테스트, 과거 분봉 분석\n\n"
"**권장 용도**: 백테스트, 과거 분봉 분석, ORB 전략 히스토리컬 검증\n\n" "- `tickers`: comma-separated, e.g. `AAPL,MSFT,BF-B`\n"
"- `tickers`: comma-separated list, e.g. `AAPL,MSFT,BF-B`\n"
"- `interval`: `1m`, `5m`, `15m`, `30m`, `1h`\n" "- `interval`: `1m`, `5m`, `15m`, `30m`, `1h`\n"
"- DB 저장 후 재요청 시 Alpaca 미사용\n" "- 내부 100개 단위 자동 배치 분할 (500종목 → Alpaca 5회 호출)\n"
"- Requires `ALPACA_API_KEY` / `ALPACA_SECRET_KEY`.\n\n" "- Requires `ALPACA_API_KEY` / `ALPACA_SECRET_KEY`"
"**⚠️ Alpaca 배치 제한**\n\n"
"Alpaca multi-bar 엔드포인트는 요청당 **~100개 심볼**이 실질적 상한입니다 "
"(공식 문서 미명시, 커뮤니티 보고 및 실제 운용 기준 — 초과 시 502 발생). "
"내부적으로 **100개 단위로 자동 분할**하여 요청하므로 클라이언트는 신경 쓸 필요 없음. "
"단, 배치 수가 늘어날수록 응답 시간이 선형적으로 증가함 (500종목 → Alpaca 5회 호출)."
), ),
) )
async def get_alpaca_intraday_multi( async def get_alpaca_intraday_multi(
@ -288,7 +103,6 @@ async def get_alpaca_intraday_multi(
) )
svc = _require_alpaca() svc = _require_alpaca()
start_dt = datetime.combine(_start, datetime.min.time()).replace(tzinfo=timezone.utc) start_dt = datetime.combine(_start, datetime.min.time()).replace(tzinfo=timezone.utc)
end_dt = datetime.combine(_end, datetime.max.time()).replace(tzinfo=timezone.utc) end_dt = datetime.combine(_end, datetime.max.time()).replace(tzinfo=timezone.utc)
@ -323,35 +137,29 @@ async def get_alpaca_intraday_multi(
for ticker, rows in data.items() for ticker, rows in data.items()
} }
return AlpacaMultiBarsResponse( return AlpacaMultiBarsResponse(interval=interval, count=len(symbols), bars=bars)
interval=interval,
count=len(symbols),
bars=bars,
)
@router.get( @router.get(
"/intraday/today", "/intraday/today",
response_model=AlpacaMultiBarsResponse, response_model=AlpacaMultiBarsResponse,
summary="Get today's real-time intraday bars via Alpaca IEX (DB-backed)", summary="Get today's real-time intraday bars for multiple tickers (IEX feed, DB-backed)",
description=( description=(
"당일(오늘) 실시간 분봉 데이터를 Alpaca **IEX 피드**로 가져옵니다. " "당일(오늘) 실시간 분봉 데이터를 Alpaca **IEX 피드**로 가져옵니다. "
"DB에 저장되며, 장 중 재요청 시 항상 Alpaca에서 최신 데이터를 가져옵니다.\n\n" "장 중 재요청 시 항상 Alpaca에서 최신 데이터를 가져옵니다.\n\n"
"**IEX 피드 특성**\n\n" "**⚠️ 오늘 데이터만 조회 가능** — 과거 데이터는 `/intraday` 사용\n\n"
"| 항목 | 내용 |\n" "| 항목 | 내용 |\n"
"|------|------|\n" "|------|------|\n"
"| 피드 | **IEX 피드** (IEX 거래소 단일) |\n" "| 피드 | **IEX** (IEX 거래소 단일) |\n"
"| 지연 | **실시간** (지연 없음) |\n" "| 지연 | **실시간** (지연 없음) |\n"
"| 거래량 커버리지 | 미국 전체 시장의 약 **2~5%** |\n" "| 거래량 | 실제의 약 **2~5%** (IEX 거래소 거래만 집계) |\n"
"| 가격 방향성 | **신뢰 가능** (대형주 기준) |\n" "| High/Low range | SIP 대비 좁게 표시될 수 있음 |\n"
"| High/Low range | SIP 대비 **좁게** 표시될 수 있음 |\n" "| DB 저장 | 있음 (장 중 항상 재조회) |\n\n"
"| 조회 가능 범위 | **오늘만** (어제 이전 데이터는 `/intraday` 사용) |\n\n"
"**권장 용도**: 당일 ORB 전략, 실시간 장 중 모니터링\n\n" "**권장 용도**: 당일 ORB 전략, 실시간 장 중 모니터링\n\n"
"- `tickers`: comma-separated list, e.g. `AAPL,MSFT,BF-B`\n" "- `tickers`: comma-separated, e.g. `AAPL,MSFT,BF-B`\n"
"- `interval`: `1m`, `5m`, `15m`, `30m`, `1h`\n" "- `interval`: `1m`, `5m`, `15m`, `30m`, `1h`\n"
"- 과거 분봉 히스토리가 필요하면 → `GET /api/v1/alpaca/intraday` 사용\n" "- 내부 100개 단위 자동 배치 분할\n"
"- Requires `ALPACA_API_KEY` / `ALPACA_SECRET_KEY`.\n\n" "- Requires `ALPACA_API_KEY` / `ALPACA_SECRET_KEY`"
"**⚠️ Alpaca 배치 제한**: 내부적으로 100개 단위 자동 분할 처리."
), ),
) )
async def get_alpaca_intraday_today( async def get_alpaca_intraday_today(
@ -403,84 +211,11 @@ async def get_alpaca_intraday_today(
for ticker, rows in data.items() for ticker, rows in data.items()
} }
return AlpacaMultiBarsResponse( return AlpacaMultiBarsResponse(interval=interval, count=len(symbols), bars=bars)
interval=interval,
count=len(symbols),
bars=bars,
)
@router.get(
"/intraday/{ticker}",
response_model=AlpacaIntradayResponse,
summary="Get historical intraday candles from Alpaca (single ticker, SIP feed)",
description=(
"단일 종목의 과거 분봉 데이터를 Alpaca **SIP 피드**로 가져옵니다.\n\n"
"**데이터 소스: SIP 피드**\n\n"
"| 항목 | 내용 |\n"
"|------|------|\n"
"| 피드 | **SIP** (전체 미국 거래소 통합) |\n"
"| 거래량 커버리지 | **100%** |\n"
"| OHLCV 정확도 | **정확** |\n"
"| 조회 가능 범위 | **2016년~어제** (당일 조회 시 400 에러) |\n"
"| DB 저장 | **없음** — 매 요청 Alpaca 직접 호출 |\n"
"| 캐시 | Redis **24시간 TTL** |\n\n"
"당일 실시간 데이터가 필요하면 → `GET /api/v1/alpaca/intraday/today`\n\n"
"- Requires `ALPACA_API_KEY` / `ALPACA_SECRET_KEY`"
),
)
@with_cache(namespace="alpaca:intraday", ttl=86400, key_params=["ticker", "interval", "start_date", "end_date"])
async def get_alpaca_intraday(
ticker: str,
response: Response,
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"),
limit: int = Query(1000, ge=1, le=10000, description="Max candles"),
force_refresh: bool = Query(False, description="Bypass cache"),
):
yesterday = date.today() - timedelta(days=1)
_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 = start_date or yesterday
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:
bars = await svc.fetch_bars_raw(
ticker=ticker.upper(),
interval=interval,
start_date=start_dt,
end_date=end_dt,
feed="sip",
)
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) # Real-time snapshot
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def _parse_snapshot(ticker: str, raw: dict) -> AlpacaSnapshotResponse: def _parse_snapshot(ticker: str, raw: dict) -> AlpacaSnapshotResponse:
@ -515,55 +250,24 @@ def _parse_snapshot(ticker: str, raw: dict) -> AlpacaSnapshotResponse:
) )
@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( @router.get(
"/snapshot", "/snapshot",
response_model=AlpacaMultiSnapshotResponse, response_model=AlpacaMultiSnapshotResponse,
summary="Real-time snapshots for multiple tickers (IEX feed)", summary="Real-time snapshots for multiple tickers (IEX feed)",
description=( description=(
"멀티 종목의 실시간 스냅샷을 한 번의 요청으로 반환합니다.\n\n" "멀티 종목 실시간 스냅샷. 최신 체결가, bid/ask, 당일 OHLCV, 전일 대비 변동률 포함.\n\n"
"**데이터 소스: IEX 피드 (무료 플랜 강제)**\n\n" "단일 종목도 `?tickers=AAPL`로 조회 가능.\n\n"
"| 항목 | 내용 |\n" "| 항목 | 내용 |\n"
"|------|------|\n" "|------|------|\n"
"| 피드 | **IEX** — 무료 플랜에서 snapshot은 SIP 불가 |\n" "| 피드 | **IEX** — 무료 플랜에서 snapshot은 SIP 불가 |\n"
"| 지연 | **실시간** (지연 없음) |\n" "| 지연 | **실시간** (지연 없음) |\n"
"| 거래량 | IEX 기준 (실제의 2~5%) |\n" "| 거래량 | IEX 기준 (실제의 2~5%) |\n"
"| 캐시 | **없음** — 매 요청마다 Alpaca 직접 호출 |\n\n" "| 캐시 | **없음** — 매 요청마다 Alpaca 직접 호출 |\n\n"
"**Usage**: `?tickers=AAPL,MSFT,NVDA`\n\n"
"- Requires `ALPACA_API_KEY` / `ALPACA_SECRET_KEY`" "- Requires `ALPACA_API_KEY` / `ALPACA_SECRET_KEY`"
), ),
) )
async def get_snapshots( async def get_snapshots(
tickers: str = Query(..., description="Comma-separated ticker symbols, e.g. AAPL,MSFT,NVDA (max 1000)"), 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()] symbols = [s.strip().upper() for s in tickers.split(",") if s.strip()]
if not symbols: if not symbols:
@ -576,10 +280,7 @@ async def get_snapshots(
raise HTTPException(status_code=503, detail="Alpaca API keys not configured.") raise HTTPException(status_code=503, detail="Alpaca API keys not configured.")
try: try:
raw_map = await client.get_snapshots(symbols) raw_map = await client.get_snapshots(symbols)
results = [ results = [_parse_snapshot(sym, raw_map.get(sym, {})) for sym in symbols]
_parse_snapshot(sym, raw_map.get(sym, {}))
for sym in symbols
]
return AlpacaMultiSnapshotResponse(count=len(results), snapshots=results) return AlpacaMultiSnapshotResponse(count=len(results), snapshots=results)
except Exception as e: except Exception as e:
raise HTTPException(status_code=502, detail=f"Alpaca API error: {e}") raise HTTPException(status_code=502, detail=f"Alpaca API error: {e}")

File diff suppressed because one or more lines are too long
Loading…
Cancel
Save