""" 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", description=( "Fetch intraday OHLCV bars for up to ~500 tickers in one call.\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; " "response keys use the original symbol names.\n" "- **No cache** — always fetches from Alpaca.\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)"), limit: int = Query(10000, ge=1, le=50000, description="Max bars per symbol"), ): """Multi-ticker intraday bars via Alpaca (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.") client = AlpacaClient() if not client.is_configured(): raise HTTPException(status_code=503, detail="Alpaca API keys not configured.") # Build reverse-map: normalized_alpaca_symbol → original_input_symbol reverse_map = {normalize_ticker(s): s for s in symbols} start_str = start_date.isoformat() if start_date else None end_str = end_date.isoformat() if end_date else None try: raw = await client.get_multi_bars( symbols=symbols, timeframe=interval, start=start_str, end=end_str, limit=min(limit, 10000), ) except Exception as e: raise HTTPException(status_code=502, detail=f"Alpaca API error: {e}") finally: await client.close() # Re-key results back to original input symbols and reshape bar dicts bars: dict = {} for alpaca_sym, bar_list in raw.items(): original_sym = reverse_map.get(alpaca_sym, alpaca_sym) bars[original_sym] = [ { "timestamp": b["t"], "open": b.get("o"), "high": b.get("h"), "low": b.get("l"), "close": b.get("c"), "volume": b.get("v"), } for b in bar_list ] # Ensure every requested symbol appears in the response (empty list if no data) for sym in symbols: bars.setdefault(sym, []) 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()