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.

427 lines
16 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,
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"
"**주의**: 이 엔드포인트는 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",
response_model=AlpacaMultiBarsResponse,
summary="Get intraday bars for multiple tickers via Alpaca (DB-backed)",
description=(
"Fetch intraday OHLCV bars for up to ~500 tickers. Results are stored in DB "
"so subsequent calls for the same period skip Alpaca.\n\n"
"- `tickers`: comma-separated list, e.g. `AAPL,MSFT,BF-B`\n"
"- `interval`: `1m`, `5m`, `15m`, `30m`, `1h` (also accepts `5min`, `15min`, etc.)\n"
"- Ticker normalization: `BF-B` → `BF.B` handled automatically.\n"
"- During market hours, same-day requests always fetch from Alpaca (new bars forming).\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 (or 5min, 15min, etc.)"),
start_date: Optional[date] = Query(None, description="Start date (YYYY-MM-DD)"),
end_date: Optional[date] = Query(None, description="End date (YYYY-MM-DD)"),
force_refresh: bool = Query(False, description="Re-fetch from Alpaca even if DB has data"),
db: AsyncSession = Depends(get_db),
):
"""Multi-ticker intraday bars via Alpaca with DB storage (ORB engine interface)."""
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()
# Default date range: today
_start = start_date or date.today()
_end = end_date or date.today()
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
)
except Exception as e:
raise HTTPException(status_code=502, detail=f"Alpaca API error: {e}")
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",
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()