c94312d79f
Introduce common/candles.py with a Pydantic Candle model enforcing OHLC consistency (high≥max, low≤min), non-negative volume and positive timestamp. validate_candles() coerces upstream rows, sorts by timestamp and raises HTTPException(502) on malformed data — surfacing upstream data corruption as a retryable envelope instead of silently returning nonsense. Wired into all five exchange historical endpoints (Bybit, Hyperliquid, Deribit, Alpaca, IBKR). BREAKING: Alpaca get_bars and IBKR get_bars now return 'candles' (was 'bars') to align with the other exchanges. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
520 lines
18 KiB
Python
520 lines
18 KiB
Python
"""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 []
|