""" Alpaca Market Data endpoints — standalone price data via Alpaca API """ from datetime import date, datetime, timezone, timedelta from typing import Optional from zoneinfo import ZoneInfo from fastapi import APIRouter, HTTPException, Query _ET = ZoneInfo("America/New_York") _MARKET_CLOSE_HOUR = 16 # 4:00 PM ET def _market_closed_for(d: date) -> bool: """Return True if the US equity market session for date d has ended.""" now_et = datetime.now(_ET) market_close_et = datetime(d.year, d.month, d.day, _MARKET_CLOSE_HOUR, 0, tzinfo=_ET) return now_et >= market_close_et from app.schemas.financial import ( AlpacaMultiBarsResponse, AlpacaMultiSnapshotResponse, AlpacaSnapshotResponse, ) from app.services.alpaca_client import AlpacaClient from app.services.alpaca_price_service import AlpacaPriceService router = APIRouter() 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 # ------------------------------------------------------------------ @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} # ------------------------------------------------------------------ # Intraday bars (DB-backed) # ------------------------------------------------------------------ @router.get( "/intraday", response_model=AlpacaMultiBarsResponse, summary="Get historical intraday bars for multiple tickers (SIP feed, DB-backed)", description=( "멀티 종목 과거 분봉 데이터를 Alpaca **SIP 피드**로 가져옵니다. " "DB에 저장되며 재요청 시 Alpaca 미호출.\n\n" "**⚠️ 장 중 당일 데이터 불가** — 장 마감(오후 4시 ET) 후에는 당일 날짜도 조회 가능\n\n" "| 항목 | 내용 |\n" "|------|------|\n" "| 피드 | **SIP** (전체 미국 거래소 통합) |\n" "| 거래량 | **100%** 정확 |\n" "| 조회 범위 | **2016년~오늘(장 마감 후)** |\n" "| DB 저장 | 있음 (재요청 시 Alpaca 미사용) |\n\n" "**권장 용도**: 백테스트, 과거 분봉 분석\n\n" "- `tickers`: comma-separated, e.g. `AAPL,MSFT,BF-B`\n" "- `interval`: `1m`, `5m`, `15m`, `30m`, `1h`\n" "- 내부 100개 단위 자동 배치 분할 (500종목 → Alpaca 5회 호출)\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"), start_date: Optional[date] = Query(None, description="Start date (YYYY-MM-DD). Default: yesterday"), end_date: Optional[date] = Query(None, description="End date (YYYY-MM-DD). Must be before today. Default: yesterday"), force_refresh: bool = Query(False, description="Re-fetch from Alpaca even if DB has data"), ): """Multi-ticker historical intraday bars via Alpaca SIP (up to today after market close).""" 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.") yesterday = date.today() - timedelta(days=1) _start = start_date or yesterday _end = end_date or yesterday today = date.today() if _end > today: raise HTTPException( status_code=400, detail="미래 날짜는 조회할 수 없습니다.", ) if _end == today and not _market_closed_for(today): raise HTTPException( status_code=400, detail="장 중에는 당일 SIP 데이터를 조회할 수 없습니다. " "실시간 데이터는 GET /api/v1/alpaca/intraday/today 를 사용하세요.", ) svc = _require_alpaca() 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( symbols, start_dt, end_dt, interval, force_refresh, feed="sip" ) except Exception as e: err = str(e) detail = f"Alpaca API error: {err}" if "502" in err or "Bad Gateway" in err: detail = ( f"Alpaca 502 Bad Gateway — 요청당 심볼 수 초과 가능성. " f"내부 배치 크기: 100개/요청. 원인: {err}" ) raise HTTPException(status_code=502, detail=detail) 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/today", response_model=AlpacaMultiBarsResponse, summary="Get today's real-time intraday bars for multiple tickers (IEX feed, DB-backed)", description=( "당일(오늘) 실시간 분봉 데이터를 Alpaca **IEX 피드**로 가져옵니다. " "장 중 재요청 시 항상 Alpaca에서 최신 데이터를 가져옵니다.\n\n" "**⚠️ 오늘 데이터만 조회 가능** — 과거 데이터는 `/intraday` 사용\n\n" "| 항목 | 내용 |\n" "|------|------|\n" "| 피드 | **IEX** (IEX 거래소 단일) |\n" "| 지연 | **실시간** (지연 없음) |\n" "| 거래량 | 실제의 약 **2~5%** (IEX 거래소 거래만 집계) |\n" "| High/Low range | SIP 대비 좁게 표시될 수 있음 |\n" "| DB 저장 | 있음 (장 중 항상 재조회) |\n\n" "**권장 용도**: 당일 ORB 전략, 실시간 장 중 모니터링\n\n" "- `tickers`: comma-separated, e.g. `AAPL,MSFT,BF-B`\n" "- `interval`: `1m`, `5m`, `15m`, `30m`, `1h`\n" "- 내부 100개 단위 자동 배치 분할\n" "- Requires `ALPACA_API_KEY` / `ALPACA_SECRET_KEY`" ), ) async def get_alpaca_intraday_today( tickers: str = Query(..., description="Comma-separated tickers, e.g. AAPL,MSFT,BF-B"), interval: str = Query("5m", description="Interval: 1m, 5m, 15m, 30m, 1h"), ): """Today's real-time intraday bars via Alpaca IEX (always re-fetches latest).""" 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() today = date.today() start_dt = datetime.combine(today, datetime.min.time()).replace(tzinfo=timezone.utc) end_dt = datetime.combine(today, datetime.max.time()).replace(tzinfo=timezone.utc) try: data = await svc.get_or_fetch_multi_bars( symbols, start_dt, end_dt, interval, force_refresh=True, feed="iex" ) except Exception as e: err = str(e) detail = f"Alpaca API error: {err}" if "502" in err or "Bad Gateway" in err: detail = ( f"Alpaca 502 Bad Gateway — 요청당 심볼 수 초과 가능성. " f"내부 배치 크기: 100개/요청. 원인: {err}" ) raise HTTPException(status_code=502, detail=detail) 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) # ------------------------------------------------------------------ # Real-time snapshot # ------------------------------------------------------------------ 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", response_model=AlpacaMultiSnapshotResponse, summary="Real-time snapshots for multiple tickers (IEX feed)", description=( "멀티 종목 실시간 스냅샷. 최신 체결가, bid/ask, 당일 OHLCV, 전일 대비 변동률 포함.\n\n" "단일 종목도 `?tickers=AAPL`로 조회 가능.\n\n" "| 항목 | 내용 |\n" "|------|------|\n" "| 피드 | **IEX** — 무료 플랜에서 snapshot은 SIP 불가 |\n" "| 지연 | **실시간** (지연 없음) |\n" "| 거래량 | IEX 기준 (실제의 2~5%) |\n" "| 캐시 | **없음** — 매 요청마다 Alpaca 직접 호출 |\n\n" "- Requires `ALPACA_API_KEY` / `ALPACA_SECRET_KEY`" ), ) async def get_snapshots( tickers: str = Query(..., description="Comma-separated ticker symbols, e.g. AAPL,MSFT,NVDA"), ): 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()