diff --git a/app/api/v1/endpoints/alpaca.py b/app/api/v1/endpoints/alpaca.py index c8eb409..50374ac 100644 --- a/app/api/v1/endpoints/alpaca.py +++ b/app/api/v1/endpoints/alpaca.py @@ -19,6 +19,8 @@ from app.schemas.financial import ( AlpacaPriceDataPoint, AlpacaBarsResponse, AlpacaIntradayResponse, + AlpacaSnapshotResponse, + AlpacaMultiSnapshotResponse, ErrorType, ) from app.services.alpaca_client import AlpacaClient @@ -259,3 +261,97 @@ async def get_alpaca_intraday( 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"), +): + 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) > 100: + raise HTTPException(status_code=400, detail="Maximum 100 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() diff --git a/app/schemas/financial.py b/app/schemas/financial.py index 5e42772..ffa83cb 100644 --- a/app/schemas/financial.py +++ b/app/schemas/financial.py @@ -537,6 +537,42 @@ class AlpacaIntradayResponse(BaseModel): candles: List[Dict[str, Any]] +class AlpacaSnapshotResponse(BaseModel): + """Real-time snapshot for a single ticker via Alpaca.""" + ticker: str + source: str = "ALPACA" + timestamp: Optional[str] = None # latestTrade.t + + # Latest trade + price: Optional[float] = None + trade_size: Optional[int] = None + + # Latest quote (bid/ask) + bid: Optional[float] = None + ask: Optional[float] = None + bid_size: Optional[int] = None + ask_size: Optional[int] = None + + # Today's session (dailyBar) + open: Optional[float] = None + high: Optional[float] = None + low: Optional[float] = None + volume: Optional[int] = None + vwap: Optional[float] = None + + # Change vs previous close (prevDailyBar.c) + prev_close: Optional[float] = None + change: Optional[float] = None + change_pct: Optional[float] = None + + +class AlpacaMultiSnapshotResponse(BaseModel): + """Real-time snapshots for multiple tickers.""" + source: str = "ALPACA" + count: int + snapshots: List[AlpacaSnapshotResponse] + + class NewsOnlyResponse(BaseModel): ticker: str retrieved_at: str diff --git a/app/services/alpaca_client.py b/app/services/alpaca_client.py index 3783746..3ca45ae 100644 --- a/app/services/alpaca_client.py +++ b/app/services/alpaca_client.py @@ -216,6 +216,26 @@ class AlpacaClient: logger.info(f"Alpaca: fetched {total} bars for {len(symbols)} symbols ({alpaca_tf})") return result + async def get_snapshot(self, symbol: str) -> Dict: + """ + Fetch a real-time snapshot for a single symbol. + + Returns Alpaca's snapshot object with keys: + latestTrade, latestQuote, minuteBar, dailyBar, prevDailyBar + """ + data = await self._request("GET", f"/v2/stocks/{symbol.upper()}/snapshot") + return data + + async def get_snapshots(self, symbols: List[str]) -> Dict[str, Dict]: + """ + Fetch real-time snapshots for multiple symbols in one request. + + Returns dict mapping symbol → snapshot object. + """ + params = {"symbols": ",".join(s.upper() for s in symbols)} + data = await self._request("GET", "/v2/stocks/snapshots", params=params) + return data # {SYMBOL: {...snapshot...}, ...} + async def check_connection(self) -> Dict: """Verify API key validity by requesting a small amount of data.""" try: diff --git a/docs/PYTHON_CLIENT.md b/docs/PYTHON_CLIENT.md index 155bf7b..85208a9 100644 --- a/docs/PYTHON_CLIENT.md +++ b/docs/PYTHON_CLIENT.md @@ -195,6 +195,16 @@ candles = requests.get(f"{BASE}/alpaca/intraday/NVDA", params={ "end_date": "2025-03-10", }).json() print(f"Intraday candles: {candles['count']}") + +# Real-time snapshot — single ticker (no cache, direct Alpaca call) +snap = requests.get(f"{BASE}/alpaca/snapshot/SPY").json() +print(f"SPY price: {snap['price']}, change: {snap['change_pct']}%") +print(f" bid={snap['bid']} ask={snap['ask']}") + +# Real-time snapshots — multiple tickers +multi = requests.get(f"{BASE}/alpaca/snapshot", params={"tickers": "SPY,QQQ,AAPL"}).json() +for s in multi["snapshots"]: + print(f" {s['ticker']}: {s['price']} ({s['change_pct']:+.2f}%)") ``` ### 7. FINRA Short Volume