"""Alpaca client su httpx puro (V2.0.0). Riscrittura full-REST del client `alpaca-py` originale: 4 endpoint base (trading, stock data, crypto data, options data), auth via header APCA-API-KEY-ID / APCA-API-SECRET-KEY, parità completa con la versione V1 (stesse firme, stessa shape dei dict ritornati). - `base_url` parametro override applica SOLO al trading endpoint (coerente con `url_override` di alpaca-py.TradingClient). Gli endpoint data restano hardcoded su `https://data.alpaca.markets`. - I metodi ritornano `dict` / `list[dict]` direttamente dal JSON REST (al posto dei modelli pydantic alpaca-py serializzati). Le chiavi sono quelle restituite dall'API Alpaca; equivalgono al `model_dump()` dei modelli SDK precedenti. """ from __future__ import annotations import datetime as _dt from typing import Any import httpx from cerbero_mcp.common.candles import validate_candles from cerbero_mcp.common.http import async_client # ── Endpoint base ──────────────────────────────────────────────── _TRADING_LIVE = "https://api.alpaca.markets" _TRADING_PAPER = "https://paper-api.alpaca.markets" _DATA = "https://data.alpaca.markets" # ── Mappa timeframe → query param Alpaca ───────────────────────── # Alpaca v2 bars: timeframe = "1Min" / "5Min" / "15Min" / "30Min" / "1Hour" / "1Day" / "1Week" _TF_MAP = { "1min": "1Min", "5min": "5Min", "15min": "15Min", "30min": "30Min", "1h": "1Hour", "1d": "1Day", "1w": "1Week", } _ASSET_CLASS_MAP = { "stocks": "us_equity", "crypto": "crypto", "options": "us_option", } def _tf(interval: str) -> str: if interval in _TF_MAP: return _TF_MAP[interval] raise ValueError(f"unsupported timeframe: {interval}") def _asset_class_param(ac: str) -> str: ac = ac.lower() if ac in _ASSET_CLASS_MAP: return _ASSET_CLASS_MAP[ac] raise ValueError(f"invalid asset_class: {ac}") def _iso(value: _dt.datetime | _dt.date | None) -> str | None: if value is None: return None return value.isoformat() class AlpacaClient: """Client httpx-based per Alpaca REST API v2. Auth via header `APCA-API-KEY-ID` / `APCA-API-SECRET-KEY`. """ def __init__( self, api_key: str, secret_key: str, paper: bool = True, base_url: str | None = None, http: httpx.AsyncClient | None = None, ) -> None: self.api_key = api_key self.secret_key = secret_key self.paper = paper # `base_url` mantenuto come attributo pubblico (test/build_client lo # leggono). Override del solo endpoint trading; data endpoints sono # sempre `data.alpaca.markets` (Alpaca non offre paper data feed). self.base_url = base_url if base_url: self._trading_base = base_url else: self._trading_base = _TRADING_PAPER if paper else _TRADING_LIVE self._data_base = _DATA # Single long-lived AsyncClient → reuse connection pool. self._http = http or async_client(timeout=30.0) async def aclose(self) -> None: """Chiudi connessioni HTTP. Idempotente.""" if not self._http.is_closed: await self._http.aclose() async def health(self) -> dict[str, Any]: """Probe minimo per /health/ready: nessuna chiamata di rete.""" return {"status": "ok", "paper": self.paper} # ── Helpers ────────────────────────────────────────────────── @property def _headers(self) -> dict[str, str]: return { "APCA-API-KEY-ID": self.api_key, "APCA-API-SECRET-KEY": self.secret_key, "Accept": "application/json", } async def _request( self, method: str, base: str, path: str, *, params: dict[str, Any] | None = None, json_body: dict[str, Any] | None = None, ) -> Any: """Esegue una richiesta HTTP autenticata e ritorna il JSON parsato. Per response body vuoto (es. DELETE 204) ritorna `{}`. Solleva `httpx.HTTPStatusError` su 4xx/5xx tramite raise_for_status. """ url = f"{base}{path}" # httpx scarta i query params con valore None automaticamente solo se # passati come list of tuples; con dict dobbiamo filtrare a monte. clean_params: dict[str, Any] | None = None if params is not None: clean_params = {k: v for k, v in params.items() if v is not None} if not clean_params: clean_params = None resp = await self._http.request( method, url, params=clean_params, json=json_body, headers=self._headers, ) resp.raise_for_status() if not resp.content: return {} return resp.json() # ── Account / positions ────────────────────────────────────── async def get_account(self) -> dict: data = await self._request("GET", self._trading_base, "/v2/account") return dict(data) if data else {} async def get_positions(self) -> list[dict]: data = await self._request("GET", self._trading_base, "/v2/positions") return list(data) if data else [] async def get_activities(self, limit: int = 50) -> list[dict]: data = await self._request( "GET", self._trading_base, "/v2/account/activities", params={"page_size": limit}, ) items = list(data) if data else [] return items[:limit] # ── Assets ────────────────────────────────────────────────── async def get_assets( self, asset_class: str = "stocks", status: str = "active" ) -> list[dict]: data = await self._request( "GET", self._trading_base, "/v2/assets", params={ "status": status, "asset_class": _asset_class_param(asset_class), }, ) items = list(data) if data else [] return items[:500] # ── Market data ───────────────────────────────────────────── async def get_ticker(self, symbol: str, asset_class: str = "stocks") -> dict: ac = asset_class.lower() if ac == "stocks": trade_resp = await self._request( "GET", self._data_base, f"/v2/stocks/{symbol}/trades/latest", ) quote_resp = await self._request( "GET", self._data_base, f"/v2/stocks/{symbol}/quotes/latest", ) trade = (trade_resp or {}).get("trade") or {} quote = (quote_resp or {}).get("quote") or {} return { "symbol": symbol, "asset_class": "stocks", "last_price": trade.get("p"), "bid": quote.get("bp"), "ask": quote.get("ap"), "bid_size": quote.get("bs"), "ask_size": quote.get("as"), "timestamp": trade.get("t"), } if ac == "crypto": trade_resp = await self._request( "GET", self._data_base, "/v1beta3/crypto/us/latest/trades", params={"symbols": symbol}, ) quote_resp = await self._request( "GET", self._data_base, "/v1beta3/crypto/us/latest/quotes", params={"symbols": symbol}, ) trade = ((trade_resp or {}).get("trades") or {}).get(symbol) or {} quote = ((quote_resp or {}).get("quotes") or {}).get(symbol) or {} return { "symbol": symbol, "asset_class": "crypto", "last_price": trade.get("p"), "bid": quote.get("bp"), "ask": quote.get("ap"), "timestamp": trade.get("t"), } if ac == "options": quote_resp = await self._request( "GET", self._data_base, f"/v1beta1/options/{symbol}/quotes/latest", ) quote = (quote_resp or {}).get("quote") or {} return { "symbol": symbol, "asset_class": "options", "bid": quote.get("bp"), "ask": quote.get("ap"), "timestamp": quote.get("t"), } raise ValueError(f"invalid asset_class: {asset_class}") async def get_bars( self, symbol: str, asset_class: str = "stocks", interval: str = "1d", start: str | None = None, end: str | None = None, limit: int = 1000, ) -> dict: tf = _tf(interval) start_dt = ( _dt.datetime.fromisoformat(start) if start else (_dt.datetime.now(_dt.UTC) - _dt.timedelta(days=30)) ) end_dt = _dt.datetime.fromisoformat(end) if end else _dt.datetime.now(_dt.UTC) ac = asset_class.lower() params: dict[str, Any] = { "symbols": symbol, "timeframe": tf, "start": _iso(start_dt), "end": _iso(end_dt), "limit": limit, } if ac == "stocks": # IEX feed di default — coerente con default alpaca-py free tier. params["feed"] = "iex" data = await self._request( "GET", self._data_base, "/v2/stocks/bars", params=params ) elif ac == "crypto": data = await self._request( "GET", self._data_base, "/v1beta3/crypto/us/bars", params=params, ) elif ac == "options": data = await self._request( "GET", self._data_base, "/v1beta1/options/bars", params=params, ) else: raise ValueError(f"invalid asset_class: {asset_class}") bars_dict = (data or {}).get("bars") or {} rows = bars_dict.get(symbol) or [] def _iso_to_ms(ts: str | int | None) -> int | None: if ts is None or isinstance(ts, int): return ts return int(_dt.datetime.fromisoformat( ts.replace("Z", "+00:00") ).timestamp() * 1000) candles = validate_candles([ { "timestamp": _iso_to_ms(b.get("t")), "open": b.get("o"), "high": b.get("h"), "low": b.get("l"), "close": b.get("c"), "volume": b.get("v"), } for b in rows ]) return { "symbol": symbol, "asset_class": ac, "interval": interval, "candles": candles, } async def get_snapshot(self, symbol: str) -> dict: data = await self._request( "GET", self._data_base, "/v2/stocks/snapshots", params={"symbols": symbol}, ) # API ritorna {"AAPL": {snapshot}} o {"snapshots": {...}} — gestiamo # entrambi i formati; v2/stocks/snapshots ritorna dict top-level # symbol→snapshot. if data is None: return {} if symbol in data: return data[symbol] or {} snaps = data.get("snapshots") or {} return snaps.get(symbol) or {} async def get_option_chain( self, underlying: str, expiry: str | None = None, ) -> dict: params: dict[str, Any] = {} if expiry: # Validazione date (solleva ValueError su input invalido, # parità con V1 che usava _dt.date.fromisoformat). _dt.date.fromisoformat(expiry) params["expiration_date_gte"] = expiry params["expiration_date_lte"] = expiry data = await self._request( "GET", self._data_base, f"/v1beta1/options/snapshots/{underlying}", params=params or None, ) contracts = (data or {}).get("snapshots") if data else None return { "underlying": underlying, "expiry": expiry, "contracts": contracts if contracts is not None else (data or {}), } # ── Orders ────────────────────────────────────────────────── async def get_open_orders(self, limit: int = 50) -> list[dict]: data = await self._request( "GET", self._trading_base, "/v2/orders", params={"status": "open", "limit": limit}, ) return list(data) if data else [] async def place_order( self, symbol: str, side: str, qty: float | None = None, notional: float | None = None, order_type: str = "market", limit_price: float | None = None, stop_price: float | None = None, tif: str = "day", asset_class: str = "stocks", ) -> dict: ot = order_type.lower() body: dict[str, Any] = { "symbol": symbol, "side": side.lower(), "type": ot, "time_in_force": tif.lower(), } if qty is not None: body["qty"] = str(qty) if notional is not None: body["notional"] = str(notional) if ot == "market": pass elif ot == "limit": if limit_price is None: raise ValueError("limit_price required for limit order") body["limit_price"] = str(limit_price) elif ot == "stop": if stop_price is None: raise ValueError("stop_price required for stop order") body["stop_price"] = str(stop_price) else: raise ValueError(f"unsupported order_type: {order_type}") # `asset_class` non è un parametro REST; mantenuto in firma per parità # con V1 (era usato solo da SDK per scegliere il request model). _ = asset_class data = await self._request( "POST", self._trading_base, "/v2/orders", json_body=body, ) return dict(data) if data else {} async def amend_order( self, order_id: str, qty: float | None = None, limit_price: float | None = None, stop_price: float | None = None, tif: str | None = None, ) -> dict: body: dict[str, Any] = {} if qty is not None: body["qty"] = str(qty) if limit_price is not None: body["limit_price"] = str(limit_price) if stop_price is not None: body["stop_price"] = str(stop_price) if tif is not None: body["time_in_force"] = tif.lower() data = await self._request( "PATCH", self._trading_base, f"/v2/orders/{order_id}", json_body=body, ) return dict(data) if data else {} async def cancel_order(self, order_id: str) -> dict: # DELETE /v2/orders/{id} → 204 No Content su success. await self._request( "DELETE", self._trading_base, f"/v2/orders/{order_id}" ) return {"order_id": order_id, "canceled": True} async def cancel_all_orders(self) -> list[dict]: # DELETE /v2/orders → 207 Multi-Status con array di {id, status} data = await self._request( "DELETE", self._trading_base, "/v2/orders" ) return list(data) if data else [] # ── Position close ────────────────────────────────────────── async def close_position( self, symbol: str, qty: float | None = None, percentage: float | None = None ) -> dict: # DELETE /v2/positions/{symbol}?qty=... oppure ?percentage=... params: dict[str, Any] = {} if qty is not None: params["qty"] = str(qty) if percentage is not None: params["percentage"] = str(percentage) data = await self._request( "DELETE", self._trading_base, f"/v2/positions/{symbol}", params=params or None, ) return dict(data) if data else {} async def close_all_positions(self, cancel_orders: bool = True) -> list[dict]: data = await self._request( "DELETE", self._trading_base, "/v2/positions", params={"cancel_orders": "true" if cancel_orders else "false"}, ) return list(data) if data else [] # ── Clock / calendar ──────────────────────────────────────── async def get_clock(self) -> dict: data = await self._request("GET", self._trading_base, "/v2/clock") return dict(data) if data else {} async def get_calendar( self, start: str | None = None, end: str | None = None ) -> list[dict]: params: dict[str, Any] = {} if start: _dt.date.fromisoformat(start) # validazione, parità V1 params["start"] = start if end: _dt.date.fromisoformat(end) params["end"] = end data = await self._request( "GET", self._trading_base, "/v2/calendar", params=params or None, ) return list(data) if data else []