""" 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.", ) @with_cache(namespace="alpaca:bars", ttl=None, 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. - Requires `ALPACA_API_KEY` / `ALPACA_SECRET_KEY` - Uses `data_source = "ALPACA"` to distinguish from Yahoo data - Supports: 1m, 5m, 15m, 1h, 1d, 1w, 1mo intervals """, ) @with_cache(namespace="alpaca:data", ttl=None, 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()