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.

262 lines
9.4 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,
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()