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.
358 lines
13 KiB
Python
358 lines
13 KiB
Python
"""
|
|
Alpaca Market Data endpoints — standalone price data via Alpaca API
|
|
"""
|
|
|
|
from datetime import date, datetime, timezone
|
|
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,
|
|
ErrorType,
|
|
)
|
|
from app.services.alpaca_client import AlpacaClient
|
|
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"
|
|
"**주의**: 이 엔드포인트는 DB에 저장하지 않음. 저장이 필요하면 `/alpaca/data/{ticker}` 사용.\n\n"
|
|
"**이론적 범위**: Alpaca API 제공 범위 (~20년). `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"
|
|
"- Requires `ALPACA_API_KEY` / `ALPACA_SECRET_KEY`\n"
|
|
"- Uses `data_source = \"ALPACA\"` to distinguish from Yahoo data\n"
|
|
"- Supports: 1m, 5m, 15m, 1h, 1d, 1w, 1mo intervals\n\n"
|
|
"**현재 DB 보유**: 현재 테스트 데이터만 존재 (AAPL 27일치). "
|
|
"백필은 이 엔드포인트를 원하는 날짜 범위로 호출하면 자동으로 DB에 누적됨."
|
|
),
|
|
)
|
|
@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/{ticker}",
|
|
response_model=AlpacaIntradayResponse,
|
|
summary="Get intraday candles from Alpaca",
|
|
description="Fetch intraday bars (1m, 5m, 15m, 1h) directly from Alpaca. Not stored in DB.",
|
|
)
|
|
@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",
|
|
description=(
|
|
"Returns the latest trade price, bid/ask, today's OHLCV, and change vs previous close "
|
|
"using Alpaca's `/v2/stocks/{symbol}/snapshot` endpoint.\n\n"
|
|
"**캐시 없음** — 매 요청마다 Alpaca API를 직접 호출."
|
|
),
|
|
)
|
|
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",
|
|
description=(
|
|
"Returns snapshots for up to 100 tickers in a single request.\n\n"
|
|
"**Usage**: `?tickers=AAPL,MSFT,NVDA`\n\n"
|
|
"**캐시 없음** — 매 요청마다 Alpaca API를 직접 호출."
|
|
),
|
|
)
|
|
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()
|