From 8dbaf3a0e4c0ea2bbd22bb16530afd085d1a0adb Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Thu, 30 Apr 2026 18:35:46 +0200 Subject: [PATCH] feat(V2): migrazione hyperliquid completa - exchanges/hyperliquid/{client,leverage_cap,tools}.py - routers/hyperliquid.py con 16 endpoint /mcp-hyperliquid/tools/* - builder hyperliquid in exchanges/__init__.py - test migrati: test_client, test_leverage_cap (skip V1: server_acl, environment_info) - test builder hyperliquid (testnet vs mainnet base_url) Co-Authored-By: Claude Opus 4.7 (1M context) --- src/cerbero_mcp/exchanges/__init__.py | 9 + .../exchanges/hyperliquid/__init__.py | 0 .../exchanges/hyperliquid/client.py | 577 ++++++++++++++++++ .../exchanges/hyperliquid/leverage_cap.py | 56 ++ .../exchanges/hyperliquid/tools.py | 329 ++++++++++ src/cerbero_mcp/routers/hyperliquid.py | 169 +++++ tests/unit/exchanges/hyperliquid/__init__.py | 0 .../unit/exchanges/hyperliquid/test_client.py | 227 +++++++ .../hyperliquid/test_leverage_cap.py | 50 ++ tests/unit/test_exchanges_builder.py | 21 + 10 files changed, 1438 insertions(+) create mode 100644 src/cerbero_mcp/exchanges/hyperliquid/__init__.py create mode 100644 src/cerbero_mcp/exchanges/hyperliquid/client.py create mode 100644 src/cerbero_mcp/exchanges/hyperliquid/leverage_cap.py create mode 100644 src/cerbero_mcp/exchanges/hyperliquid/tools.py create mode 100644 src/cerbero_mcp/routers/hyperliquid.py create mode 100644 tests/unit/exchanges/hyperliquid/__init__.py create mode 100644 tests/unit/exchanges/hyperliquid/test_client.py create mode 100644 tests/unit/exchanges/hyperliquid/test_leverage_cap.py diff --git a/src/cerbero_mcp/exchanges/__init__.py b/src/cerbero_mcp/exchanges/__init__.py index 9e06d84..d68e489 100644 --- a/src/cerbero_mcp/exchanges/__init__.py +++ b/src/cerbero_mcp/exchanges/__init__.py @@ -27,4 +27,13 @@ async def build_client( api_secret=settings.bybit.api_secret.get_secret_value(), testnet=(env == "testnet"), ) + if exchange == "hyperliquid": + from cerbero_mcp.exchanges.hyperliquid.client import HyperliquidClient + + return HyperliquidClient( + wallet_address=settings.hyperliquid.wallet_address, + private_key=settings.hyperliquid.private_key.get_secret_value(), + testnet=(env == "testnet"), + api_wallet_address=settings.hyperliquid.api_wallet_address, + ) raise ValueError(f"unsupported exchange: {exchange}") diff --git a/src/cerbero_mcp/exchanges/hyperliquid/__init__.py b/src/cerbero_mcp/exchanges/hyperliquid/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/cerbero_mcp/exchanges/hyperliquid/client.py b/src/cerbero_mcp/exchanges/hyperliquid/client.py new file mode 100644 index 0000000..03d85bb --- /dev/null +++ b/src/cerbero_mcp/exchanges/hyperliquid/client.py @@ -0,0 +1,577 @@ +"""Hyperliquid REST API client for perpetual futures trading.""" + +from __future__ import annotations + +import asyncio +import datetime as _dt +from typing import Any + +from cerbero_mcp.common import indicators as ind +from cerbero_mcp.common.http import async_client + +BASE_LIVE = "https://api.hyperliquid.xyz" +BASE_TESTNET = "https://api.hyperliquid-testnet.xyz" + +RESOLUTION_MAP = { + "1m": "1m", + "5m": "5m", + "15m": "15m", + "1h": "1h", + "4h": "4h", + "1d": "1d", +} + +try: + from eth_account import Account + from hyperliquid.exchange import Exchange + from hyperliquid.utils import constants as hl_constants + + _SDK_AVAILABLE = True +except ImportError: # pragma: no cover + _SDK_AVAILABLE = False + + +def _to_ms(date_str: str) -> int: + try: + dt = _dt.datetime.fromisoformat(date_str) + except ValueError: + dt = _dt.datetime.strptime(date_str, "%Y-%m-%d") + return int(dt.timestamp() * 1000) + + +class HyperliquidClient: + """Async client for the Hyperliquid API. + + Read operations use direct HTTP calls via httpx against /info. + Write operations delegate to hyperliquid-python-sdk for EIP-712 signing. + """ + + def __init__( + self, + wallet_address: str, + private_key: str, + testnet: bool = True, + api_wallet_address: str | None = None, + ): + self.wallet_address = wallet_address + self.private_key = private_key + self.testnet = testnet + self.api_wallet_address = api_wallet_address or wallet_address + self.base_url = BASE_TESTNET if testnet else BASE_LIVE + self._exchange: Any | None = None + + # ── SDK exchange (lazy) ──────────────────────────────────── + + def _get_exchange(self) -> Any: + """Return (and cache) an SDK Exchange instance for write ops.""" + if not _SDK_AVAILABLE: + raise RuntimeError( + "hyperliquid-python-sdk is not installed; write operations unavailable." + ) + if self._exchange is None: + account = Account.from_key(self.private_key) + base_url = ( + hl_constants.TESTNET_API_URL if self.testnet else hl_constants.MAINNET_API_URL + ) + empty_spot_meta: dict[str, Any] = {"universe": [], "tokens": []} + self._exchange = Exchange( + account, + base_url, + account_address=self.wallet_address, + spot_meta=empty_spot_meta, + ) + return self._exchange + + # ── Internal helpers ─────────────────────────────────────── + + async def _post(self, payload: dict[str, Any]) -> Any: + """POST JSON to the /info endpoint.""" + async with async_client(timeout=15.0) as http: + resp = await http.post(f"{self.base_url}/info", json=payload) + resp.raise_for_status() + return resp.json() + + @staticmethod + async def _run_sync(func: Any, *args: Any, **kwargs: Any) -> Any: + """Run a synchronous SDK call in the default executor.""" + loop = asyncio.get_event_loop() + return await loop.run_in_executor(None, lambda: func(*args, **kwargs)) + + # ── Read tools ───────────────────────────────────────────── + + async def get_markets(self) -> list[dict[str, Any]]: + """List all perp markets with metadata and current stats.""" + data = await self._post({"type": "metaAndAssetCtxs"}) + universe = data[0]["universe"] + ctx_list = data[1] + markets = [] + for meta, ctx in zip(universe, ctx_list, strict=True): + markets.append( + { + "asset": meta["name"], + "mark_price": float(ctx.get("markPx", 0)), + "funding_rate": float(ctx.get("funding", 0)), + "open_interest": float(ctx.get("openInterest", 0)), + "volume_24h": float(ctx.get("dayNtlVlm", 0)), + "max_leverage": int(meta.get("maxLeverage", 1)), + } + ) + return markets + + async def get_ticker(self, instrument: str) -> dict[str, Any]: + """Get ticker information for a specific asset.""" + markets = await self.get_markets() + for m in markets: + if m["asset"].upper() == instrument.upper(): + return { + "asset": m["asset"], + "mark_price": m["mark_price"], + "mid_price": m["mark_price"], + "funding_rate": m["funding_rate"], + "open_interest": m["open_interest"], + "volume_24h": m["volume_24h"], + "premium": 0.0, + } + return {"error": f"Asset {instrument} not found"} + + async def get_orderbook(self, instrument: str, depth: int = 10) -> dict[str, Any]: + """Get L2 order book for an asset.""" + data = await self._post({"type": "l2Book", "coin": instrument.upper()}) + levels = data.get("levels", [[], []]) + bids = [{"price": float(b["px"]), "size": float(b["sz"])} for b in levels[0][:depth]] + asks = [{"price": float(a["px"]), "size": float(a["sz"])} for a in levels[1][:depth]] + return {"asset": instrument, "bids": bids, "asks": asks} + + async def get_positions(self) -> list[dict[str, Any]]: + """Get open positions for the wallet.""" + data = await self._post( + {"type": "clearinghouseState", "user": self.wallet_address} + ) + positions = [] + for ap in data.get("assetPositions", []): + pos = ap.get("position", {}) + size = float(pos.get("szi", 0)) + if size == 0: + continue + leverage_data = pos.get("leverage", {}) + lev_value = ( + leverage_data.get("value", "1") + if isinstance(leverage_data, dict) + else str(leverage_data) + ) + positions.append( + { + "asset": pos.get("coin", ""), + "size": abs(size), + "direction": "long" if size > 0 else "short", + "entry_price": float(pos.get("entryPx", 0) or 0), + "unrealized_pnl": float(pos.get("unrealizedPnl", 0)), + "leverage": float(lev_value), + "liquidation_price": float(pos.get("liquidationPx", 0) or 0), + } + ) + return positions + + async def get_account_summary(self) -> dict[str, Any]: + """Get account summary (equity, balance, margin) including spot balances. + + Con Unified Account, spot USDC e perps condividono collaterale. + `spot_fetch_ok` / `perps_fetch_ok` indicano se i due lati sono stati + letti correttamente: se uno dei due è False il chiamante dovrebbe + considerare `equity`/`available_balance` un lower bound. + """ + perps_fetch_ok = True + perps_equity = 0.0 + perps_available = 0.0 + margin_used = 0.0 + unrealized_pnl = 0.0 + try: + data = await self._post( + {"type": "clearinghouseState", "user": self.wallet_address} + ) + margin = data.get("marginSummary") or {} + perps_equity = float(margin.get("accountValue", 0) or 0) + perps_available = float(margin.get("totalRawUsd", 0) or 0) + margin_used = float(margin.get("totalMarginUsed", 0) or 0) + unrealized_pnl = float(margin.get("totalNtlPos", 0) or 0) + except Exception: + perps_fetch_ok = False + + spot_fetch_ok = True + spot_usdc = 0.0 + try: + spot_data = await self._post( + {"type": "spotClearinghouseState", "user": self.wallet_address} + ) + for b in spot_data.get("balances", []) or []: + if b.get("coin") == "USDC": + spot_usdc = float(b.get("total", 0) or 0) + except Exception: + spot_fetch_ok = False + + total_equity = perps_equity + spot_usdc + total_available = perps_available + spot_usdc + return { + "equity": total_equity, + "perps_equity": perps_equity, + "perps_available": perps_available, + "spot_usdc": spot_usdc, + "available_balance": total_available, + "margin_used": margin_used, + "unrealized_pnl": unrealized_pnl, + "perps_fetch_ok": perps_fetch_ok, + "spot_fetch_ok": spot_fetch_ok, + } + + async def get_trade_history(self, limit: int = 100) -> list[dict[str, Any]]: + """Get recent trade fills.""" + data = await self._post({"type": "userFills", "user": self.wallet_address}) + trades = [] + for t in data[:limit]: + trades.append( + { + "asset": t.get("coin", ""), + "side": t.get("side", ""), + "size": float(t.get("sz", 0)), + "price": float(t.get("px", 0)), + "fee": float(t.get("fee", 0)), + "timestamp": t.get("time", ""), + } + ) + return trades + + async def get_historical( + self, instrument: str, start_date: str, end_date: str, resolution: str = "1h" + ) -> dict[str, Any]: + """Get OHLCV candles for an asset.""" + start_ms = _to_ms(start_date) + end_ms = _to_ms(end_date) + interval = RESOLUTION_MAP.get(resolution, resolution) + data = await self._post( + { + "type": "candleSnapshot", + "req": { + "coin": instrument.upper(), + "interval": interval, + "startTime": start_ms, + "endTime": end_ms, + }, + } + ) + candles = [] + for c in data: + candles.append( + { + "timestamp": c.get("t", 0), + "open": float(c.get("o", 0)), + "high": float(c.get("h", 0)), + "low": float(c.get("l", 0)), + "close": float(c.get("c", 0)), + "volume": float(c.get("v", 0)), + } + ) + return {"candles": candles} + + async def get_open_orders(self) -> list[dict[str, Any]]: + """Get all open orders for the wallet.""" + data = await self._post({"type": "openOrders", "user": self.wallet_address}) + orders = [] + for o in data: + orders.append( + { + "oid": o.get("oid"), + "asset": o.get("coin", ""), + "side": o.get("side", ""), + "size": float(o.get("sz", 0)), + "price": float(o.get("limitPx", 0)), + "order_type": o.get("orderType", ""), + } + ) + return orders + + async def basis_spot_perp(self, asset: str) -> dict[str, Any]: + asset = asset.upper() + # Spot reference price from Coinbase (mainnet reference, anche se HL è testnet) + spot_price: float | None = None + spot_source = "coinbase" + try: + async with async_client(timeout=8.0) as c: + resp = await c.get(f"https://api.coinbase.com/v2/prices/{asset}-USD/spot") + if resp.status_code == 200: + spot_price = float(resp.json().get("data", {}).get("amount")) + except Exception: + spot_price = None + if spot_price is None: + try: + async with async_client(timeout=8.0) as c: + resp = await c.get( + "https://api.kraken.com/0/public/Ticker", params={"pair": f"{asset}USD"} + ) + if resp.status_code == 200: + res = resp.json().get("result") or {} + first = next(iter(res.values()), {}) + price = (first.get("c") or [None])[0] + spot_price = float(price) if price else None + spot_source = "kraken" + except Exception: + pass + + # Perp price + funding from HL + try: + ctx = await self._post({"type": "metaAndAssetCtxs"}) + universe = ctx[0]["universe"] + ctx_list = ctx[1] + perp_price = None + funding = None + for meta, c in zip(universe, ctx_list, strict=True): + if meta["name"].upper() == asset: + perp_price = float(c.get("markPx", 0)) + funding = float(c.get("funding", 0)) + break + except Exception: + perp_price = None + funding = None + + if spot_price is None or perp_price is None: + return { + "asset": asset, + "spot_price": spot_price, + "perp_price": perp_price, + "error": "missing spot or perp price", + } + + basis_abs = perp_price - spot_price + basis_pct = round(basis_abs / spot_price * 100, 4) + basis_ann_funding = ( + round(funding * 24 * 365 * 100, 2) if funding is not None else None + ) + carry_opp = bool( + basis_ann_funding is not None + and basis_ann_funding > 5 + and (funding or 0) > 0.0001 + ) + + return { + "asset": asset, + "spot_price": spot_price, + "spot_source": spot_source, + "perp_price": perp_price, + "basis_absolute": round(basis_abs, 4), + "basis_pct": basis_pct, + "current_funding_hourly": funding, + "basis_annualized_funding_only": basis_ann_funding, + "carry_opportunity": carry_opp, + "testnet": self.testnet, + "data_timestamp": _dt.datetime.now(_dt.UTC).isoformat(), + } + + async def get_funding_rate(self, instrument: str) -> dict[str, Any]: + """Get current and recent historical funding rates for an asset.""" + data = await self._post({"type": "metaAndAssetCtxs"}) + universe = data[0]["universe"] + ctx_list = data[1] + current_rate = None + for meta, ctx in zip(universe, ctx_list, strict=True): + if meta["name"].upper() == instrument.upper(): + current_rate = float(ctx.get("funding", 0)) + break + if current_rate is None: + return {"error": f"Asset {instrument} not found"} + + # Fetch funding history (last 7 days) + end_ms = int(_dt.datetime.utcnow().timestamp() * 1000) + start_ms = end_ms - 7 * 24 * 3600 * 1000 + history_data = await self._post( + { + "type": "fundingHistory", + "coin": instrument.upper(), + "startTime": start_ms, + "endTime": end_ms, + } + ) + history = [] + for entry in history_data: + history.append( + { + "timestamp": entry.get("time", 0), + "funding_rate": float(entry.get("fundingRate", 0)), + } + ) + return { + "asset": instrument, + "current_funding_rate": current_rate, + "history": history, + } + + async def get_indicators( + self, + instrument: str, + indicators: list[str], + start_date: str, + end_date: str, + resolution: str = "1h", + ) -> dict[str, Any]: + """Compute technical indicators over OHLCV data.""" + historical = await self.get_historical(instrument, start_date, end_date, resolution) + candles = historical.get("candles", []) + closes = [c["close"] for c in candles] + highs = [c["high"] for c in candles] + lows = [c["low"] for c in candles] + + result: dict[str, Any] = {} + for indicator in indicators: + name = indicator.lower() + if name == "sma": + result["sma"] = ind.sma(closes, 20) + elif name == "rsi": + result["rsi"] = ind.rsi(closes) + elif name == "atr": + result["atr"] = ind.atr(highs, lows, closes) + elif name == "macd": + result["macd"] = ind.macd(closes) + elif name == "adx": + result["adx"] = ind.adx(highs, lows, closes) + else: + result[name] = None + return result + + # ── Write tools (via SDK) ────────────────────────────────── + + async def place_order( + self, + instrument: str, + side: str, + amount: float, + type: str = "limit", + price: float | None = None, + reduce_only: bool = False, + ) -> dict[str, Any]: + """Place an order on Hyperliquid using the SDK for EIP-712 signing.""" + exchange = self._get_exchange() + is_buy = side.lower() in ("buy", "long") + coin = instrument.upper() + + if type == "market": + ot: dict[str, Any] = {"limit": {"tif": "Ioc"}} + if price is None: + ticker = await self.get_ticker(coin) + mark = ticker.get("mark_price", 0) + price = round(mark * 1.03, 1) if is_buy else round(mark * 0.97, 1) + elif type in ("stop_market", "stop_loss"): + ot = {"trigger": {"triggerPx": float(price), "isMarket": True, "tpsl": "sl"}} + elif type == "take_profit": + ot = {"trigger": {"triggerPx": float(price), "isMarket": True, "tpsl": "tp"}} + else: + ot = {"limit": {"tif": "Gtc"}} + + if price is None: + return {"error": "price is required for limit orders"} + + result = await self._run_sync( + exchange.order, coin, is_buy, amount, price, ot, reduce_only + ) + + status = result.get("status", "unknown") + response = result.get("response", {}) + if isinstance(response, str): + return { + "status": status, + "error": response, + "order_id": "", + "filled_size": 0, + "avg_fill_price": 0, + } + + statuses = response.get("data", {}).get("statuses", [{}]) + first = statuses[0] if statuses else {} + if isinstance(first, str): + return { + "status": status, + "error": first, + "order_id": "", + "filled_size": 0, + "avg_fill_price": 0, + } + return { + "order_id": first.get("resting", {}).get( + "oid", first.get("filled", {}).get("oid", "") + ), + "status": status, + "filled_size": float(first.get("filled", {}).get("totalSz", 0)), + "avg_fill_price": float(first.get("filled", {}).get("avgPx", 0)), + } + + async def cancel_order(self, order_id: str, instrument: str) -> dict[str, Any]: + """Cancel an existing order using the SDK.""" + exchange = self._get_exchange() + result = await self._run_sync( + exchange.cancel, instrument.upper(), int(order_id) + ) + status = result.get("status", "unknown") + response = result.get("response", "") + if isinstance(response, str) and status == "err": + return {"order_id": order_id, "status": status, "error": response} + return {"order_id": order_id, "status": status} + + async def set_stop_loss( + self, instrument: str, stop_price: float, size: float + ) -> dict[str, Any]: + """Set a stop-loss trigger order.""" + # Determine direction by checking open position + positions = await self.get_positions() + direction = "sell" # default: assume long + for pos in positions: + if pos["asset"].upper() == instrument.upper(): + direction = "sell" if pos["direction"] == "long" else "buy" + if size == 0: + size = pos["size"] + break + return await self.place_order( + instrument=instrument, + side=direction, + amount=size, + type="stop_loss", + price=stop_price, + reduce_only=True, + ) + + async def set_take_profit( + self, instrument: str, tp_price: float, size: float + ) -> dict[str, Any]: + """Set a take-profit trigger order.""" + positions = await self.get_positions() + direction = "sell" # default: assume long + for pos in positions: + if pos["asset"].upper() == instrument.upper(): + direction = "sell" if pos["direction"] == "long" else "buy" + if size == 0: + size = pos["size"] + break + return await self.place_order( + instrument=instrument, + side=direction, + amount=size, + type="take_profit", + price=tp_price, + reduce_only=True, + ) + + async def close_position(self, instrument: str) -> dict[str, Any]: + """Close an open position for the given asset using market_close.""" + exchange = self._get_exchange() + try: + result = await self._run_sync(exchange.market_close, instrument.upper()) + return { + "status": result.get("status", "unknown"), + "asset": instrument, + } + except Exception as exc: + return {"error": str(exc), "asset": instrument} + + async def health(self) -> dict[str, Any]: + """Health check — ping /info for server status.""" + try: + await self._post({"type": "meta"}) + return {"status": "ok", "testnet": self.testnet} + except Exception as exc: + return {"status": "error", "error": str(exc)} diff --git a/src/cerbero_mcp/exchanges/hyperliquid/leverage_cap.py b/src/cerbero_mcp/exchanges/hyperliquid/leverage_cap.py new file mode 100644 index 0000000..d04dd51 --- /dev/null +++ b/src/cerbero_mcp/exchanges/hyperliquid/leverage_cap.py @@ -0,0 +1,56 @@ +"""Leverage cap server-side per place_order. + +Cap letto dal secret JSON via campo `max_leverage`. Default 1 (cash) se assente. +""" +from __future__ import annotations + +from fastapi import HTTPException + + +def get_max_leverage(creds: dict) -> int: + """Legge max_leverage dal secret. Default 1 se mancante.""" + raw = creds.get("max_leverage", 1) + try: + value = int(raw) + except (TypeError, ValueError): + value = 1 + return max(1, value) + + +def enforce_leverage( + requested: int | float | None, + *, + creds: dict, + exchange: str, +) -> int: + """Verifica e applica leverage cap. Ritorna leverage applicabile. + + Solleva HTTPException(403, LEVERAGE_CAP_EXCEEDED) se requested > cap. + Se requested is None, applica il cap come default. + """ + cap = get_max_leverage(creds) + if requested is None: + return cap + lev = int(requested) + if lev < 1: + raise HTTPException( + status_code=403, + detail={ + "error": "LEVERAGE_CAP_EXCEEDED", + "exchange": exchange, + "requested": lev, + "max": cap, + "reason": "leverage must be >= 1", + }, + ) + if lev > cap: + raise HTTPException( + status_code=403, + detail={ + "error": "LEVERAGE_CAP_EXCEEDED", + "exchange": exchange, + "requested": lev, + "max": cap, + }, + ) + return lev diff --git a/src/cerbero_mcp/exchanges/hyperliquid/tools.py b/src/cerbero_mcp/exchanges/hyperliquid/tools.py new file mode 100644 index 0000000..496196d --- /dev/null +++ b/src/cerbero_mcp/exchanges/hyperliquid/tools.py @@ -0,0 +1,329 @@ +"""Tool hyperliquid V2: pydantic schemas + async functions. + +Ogni funzione prende (client: HyperliquidClient, params: ) e restituisce +un dict (o un model Pydantic). Pure logica, no FastAPI dependency, no ACL. +L'autenticazione bearer è gestita dal middleware in cerbero_mcp.auth; +l'audit verrà cablato dal router via request.state.environment. +""" +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, field_validator, model_validator + +from cerbero_mcp.exchanges.hyperliquid.client import HyperliquidClient +from cerbero_mcp.exchanges.hyperliquid.leverage_cap import ( + enforce_leverage as _enforce_leverage, +) +from cerbero_mcp.exchanges.hyperliquid.leverage_cap import get_max_leverage + +# === Schemas: reads === + + +class GetMarketsReq(BaseModel): + pass + + +class GetTickerReq(BaseModel): + instrument: str + + +class GetOrderbookReq(BaseModel): + instrument: str + depth: int = 10 + + +class GetPositionsReq(BaseModel): + pass + + +class GetAccountSummaryReq(BaseModel): + pass + + +class GetTradeHistoryReq(BaseModel): + limit: int = 100 + + +class GetHistoricalReq(BaseModel): + instrument: str | None = None + asset: str | None = None + start_date: str | None = None + end_date: str | None = None + resolution: str = "1h" + interval: str | None = None + limit: int = 50 + + model_config = {"extra": "allow"} + + @model_validator(mode="after") + def _normalize(self): + from datetime import UTC, datetime, timedelta + sym = self.instrument or self.asset + if not sym: + raise ValueError("instrument (or asset) is required") + self.instrument = sym + if self.interval: + self.resolution = self.interval + if not self.end_date: + self.end_date = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S") + if not self.start_date: + days = max(1, self.limit // 6) + self.start_date = ( + datetime.now(UTC) - timedelta(days=days) + ).strftime("%Y-%m-%dT%H:%M:%S") + return self + + +class GetOpenOrdersReq(BaseModel): + pass + + +class GetFundingRateReq(BaseModel): + instrument: str + + +class BasisSpotPerpReq(BaseModel): + asset: str + + +class GetIndicatorsReq(BaseModel): + instrument: str | None = None + asset: str | None = None + indicators: list[str] = ["rsi", "atr", "macd", "adx"] + start_date: str | None = None + end_date: str | None = None + resolution: str = "1h" + interval: str | None = None + limit: int = 50 + + model_config = {"extra": "allow"} + + @model_validator(mode="after") + def _normalize(self): + from datetime import UTC, datetime, timedelta + sym = self.instrument or self.asset + if not sym: + raise ValueError("instrument (or asset) is required") + self.instrument = sym + if self.interval: + self.resolution = self.interval + if not self.end_date: + self.end_date = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S") + if not self.start_date: + days = max(2, self.limit // 6) + self.start_date = ( + datetime.now(UTC) - timedelta(days=days) + ).strftime("%Y-%m-%dT%H:%M:%S") + return self + + @field_validator("indicators", mode="before") + @classmethod + def _coerce_indicators(cls, v): + if isinstance(v, str): + import json + s = v.strip() + if s.startswith("["): + try: + parsed = json.loads(s) + if isinstance(parsed, list): + return [str(x).strip() for x in parsed if str(x).strip()] + except json.JSONDecodeError: + pass + return [x.strip() for x in s.split(",") if x.strip()] + if isinstance(v, list): + return v + raise ValueError( + "indicators must be a list like ['rsi','atr','macd'] " + "or a comma-separated string like 'rsi,atr,macd'" + ) + + +# === Schemas: writes === + + +class PlaceOrderReq(BaseModel): + instrument: str + side: str # "buy" | "sell" + amount: float + type: str = "limit" + price: float | None = None + reduce_only: bool = False + leverage: int | None = None # CER-016: None → default cap (3x) + + model_config = { + "json_schema_extra": { + "examples": [ + { + "summary": "Market buy 0.01 BTC perp", + "value": { + "instrument": "BTC", + "side": "buy", + "amount": 0.01, + "type": "market", + }, + } + ] + } + } + + +class CancelOrderReq(BaseModel): + order_id: str + instrument: str + + +class SetStopLossReq(BaseModel): + instrument: str + stop_price: float + size: float + + +class SetTakeProfitReq(BaseModel): + instrument: str + tp_price: float + size: float + + +class ClosePositionReq(BaseModel): + instrument: str + + +# === Tools (reads) === + + +async def environment_info( + client: HyperliquidClient, *, creds: dict, env_info: Any | None = None +) -> dict: + if env_info is None: + return { + "exchange": "hyperliquid", + "environment": "testnet" if getattr(client, "testnet", True) else "mainnet", + "source": "credentials", + "env_value": None, + "base_url": getattr(client, "base_url", None), + "max_leverage": get_max_leverage(creds), + } + return { + "exchange": env_info.exchange, + "environment": env_info.environment, + "source": env_info.source, + "env_value": env_info.env_value, + "base_url": env_info.base_url, + "max_leverage": get_max_leverage(creds), + } + + +async def get_markets(client: HyperliquidClient, params: GetMarketsReq) -> list[dict]: + return await client.get_markets() + + +async def get_ticker(client: HyperliquidClient, params: GetTickerReq) -> dict: + return await client.get_ticker(params.instrument) + + +async def get_orderbook(client: HyperliquidClient, params: GetOrderbookReq) -> dict: + return await client.get_orderbook(params.instrument, params.depth) + + +async def get_positions( + client: HyperliquidClient, params: GetPositionsReq +) -> list[dict]: + return await client.get_positions() + + +async def get_account_summary( + client: HyperliquidClient, params: GetAccountSummaryReq +) -> dict: + return await client.get_account_summary() + + +async def get_trade_history( + client: HyperliquidClient, params: GetTradeHistoryReq +) -> list[dict]: + return await client.get_trade_history(params.limit) + + +async def get_historical( + client: HyperliquidClient, params: GetHistoricalReq +) -> dict: + return await client.get_historical( + params.instrument, params.start_date, params.end_date, params.resolution + ) + + +async def get_open_orders( + client: HyperliquidClient, params: GetOpenOrdersReq +) -> list[dict]: + return await client.get_open_orders() + + +async def get_funding_rate( + client: HyperliquidClient, params: GetFundingRateReq +) -> dict: + return await client.get_funding_rate(params.instrument) + + +async def basis_spot_perp( + client: HyperliquidClient, params: BasisSpotPerpReq +) -> dict: + return await client.basis_spot_perp(params.asset) + + +async def get_indicators( + client: HyperliquidClient, params: GetIndicatorsReq +) -> dict: + return await client.get_indicators( + params.instrument, + params.indicators, + params.start_date, + params.end_date, + params.resolution, + ) + + +# === Tools (writes) === + + +async def place_order( + client: HyperliquidClient, params: PlaceOrderReq, *, creds: dict +) -> dict: + _enforce_leverage(params.leverage, creds=creds, exchange="hyperliquid") + result = await client.place_order( + instrument=params.instrument, + side=params.side, + amount=params.amount, + type=params.type, + price=params.price, + reduce_only=params.reduce_only, + ) + # TODO V2: wire audit via request.state.environment in router + return result + + +async def cancel_order( + client: HyperliquidClient, params: CancelOrderReq +) -> dict: + return await client.cancel_order(params.order_id, params.instrument) + + +async def set_stop_loss( + client: HyperliquidClient, params: SetStopLossReq +) -> dict: + return await client.set_stop_loss( + params.instrument, params.stop_price, params.size + ) + + +async def set_take_profit( + client: HyperliquidClient, params: SetTakeProfitReq +) -> dict: + return await client.set_take_profit( + params.instrument, params.tp_price, params.size + ) + + +async def close_position( + client: HyperliquidClient, params: ClosePositionReq +) -> dict: + return await client.close_position(params.instrument) diff --git a/src/cerbero_mcp/routers/hyperliquid.py b/src/cerbero_mcp/routers/hyperliquid.py new file mode 100644 index 0000000..d546795 --- /dev/null +++ b/src/cerbero_mcp/routers/hyperliquid.py @@ -0,0 +1,169 @@ +"""Router /mcp-hyperliquid/* — DI per env, client e (write) creds. + +Mappa 1:1 i tool di `cerbero_mcp.exchanges.hyperliquid.tools` a endpoint +`POST /mcp-hyperliquid/tools/{tool_name}`. L'autenticazione bearer è gestita +dal middleware in `cerbero_mcp.auth`; qui leggiamo solo `request.state.environment`. +""" +from __future__ import annotations + +from typing import Literal + +from fastapi import APIRouter, Depends, Request + +from cerbero_mcp.client_registry import ClientRegistry +from cerbero_mcp.exchanges.hyperliquid import tools as t +from cerbero_mcp.exchanges.hyperliquid.client import HyperliquidClient + +Environment = Literal["testnet", "mainnet"] + + +def get_environment(request: Request) -> Environment: + return request.state.environment + + +async def get_hyperliquid_client( + request: Request, env: Environment = Depends(get_environment) +) -> HyperliquidClient: + registry: ClientRegistry = request.app.state.registry + return await registry.get("hyperliquid", env) + + +def _build_creds(request: Request) -> dict: + """Costruisce dict `creds` minimale per leverage cap / metadata.""" + settings = request.app.state.settings + return { + "max_leverage": settings.hyperliquid.max_leverage, + "wallet_address": settings.hyperliquid.wallet_address, + } + + +def make_router() -> APIRouter: + r = APIRouter(prefix="/mcp-hyperliquid", tags=["hyperliquid"]) + + # === READ tools === + + @r.post("/tools/environment_info") + async def _environment_info( + request: Request, + client: HyperliquidClient = Depends(get_hyperliquid_client), + ): + creds = _build_creds(request) + return await t.environment_info(client, creds=creds) + + @r.post("/tools/get_markets") + async def _get_markets( + params: t.GetMarketsReq, + client: HyperliquidClient = Depends(get_hyperliquid_client), + ): + return await t.get_markets(client, params) + + @r.post("/tools/get_ticker") + async def _get_ticker( + params: t.GetTickerReq, + client: HyperliquidClient = Depends(get_hyperliquid_client), + ): + return await t.get_ticker(client, params) + + @r.post("/tools/get_orderbook") + async def _get_orderbook( + params: t.GetOrderbookReq, + client: HyperliquidClient = Depends(get_hyperliquid_client), + ): + return await t.get_orderbook(client, params) + + @r.post("/tools/get_positions") + async def _get_positions( + params: t.GetPositionsReq, + client: HyperliquidClient = Depends(get_hyperliquid_client), + ): + return await t.get_positions(client, params) + + @r.post("/tools/get_account_summary") + async def _get_account_summary( + params: t.GetAccountSummaryReq, + client: HyperliquidClient = Depends(get_hyperliquid_client), + ): + return await t.get_account_summary(client, params) + + @r.post("/tools/get_trade_history") + async def _get_trade_history( + params: t.GetTradeHistoryReq, + client: HyperliquidClient = Depends(get_hyperliquid_client), + ): + return await t.get_trade_history(client, params) + + @r.post("/tools/get_historical") + async def _get_historical( + params: t.GetHistoricalReq, + client: HyperliquidClient = Depends(get_hyperliquid_client), + ): + return await t.get_historical(client, params) + + @r.post("/tools/get_open_orders") + async def _get_open_orders( + params: t.GetOpenOrdersReq, + client: HyperliquidClient = Depends(get_hyperliquid_client), + ): + return await t.get_open_orders(client, params) + + @r.post("/tools/get_funding_rate") + async def _get_funding_rate( + params: t.GetFundingRateReq, + client: HyperliquidClient = Depends(get_hyperliquid_client), + ): + return await t.get_funding_rate(client, params) + + @r.post("/tools/basis_spot_perp") + async def _basis_spot_perp( + params: t.BasisSpotPerpReq, + client: HyperliquidClient = Depends(get_hyperliquid_client), + ): + return await t.basis_spot_perp(client, params) + + @r.post("/tools/get_indicators") + async def _get_indicators( + params: t.GetIndicatorsReq, + client: HyperliquidClient = Depends(get_hyperliquid_client), + ): + return await t.get_indicators(client, params) + + # === WRITE tools (richiedono creds per leverage cap / audit) === + + @r.post("/tools/place_order") + async def _place_order( + params: t.PlaceOrderReq, + request: Request, + client: HyperliquidClient = Depends(get_hyperliquid_client), + ): + creds = _build_creds(request) + return await t.place_order(client, params, creds=creds) + + @r.post("/tools/cancel_order") + async def _cancel_order( + params: t.CancelOrderReq, + client: HyperliquidClient = Depends(get_hyperliquid_client), + ): + return await t.cancel_order(client, params) + + @r.post("/tools/set_stop_loss") + async def _set_stop_loss( + params: t.SetStopLossReq, + client: HyperliquidClient = Depends(get_hyperliquid_client), + ): + return await t.set_stop_loss(client, params) + + @r.post("/tools/set_take_profit") + async def _set_take_profit( + params: t.SetTakeProfitReq, + client: HyperliquidClient = Depends(get_hyperliquid_client), + ): + return await t.set_take_profit(client, params) + + @r.post("/tools/close_position") + async def _close_position( + params: t.ClosePositionReq, + client: HyperliquidClient = Depends(get_hyperliquid_client), + ): + return await t.close_position(client, params) + + return r diff --git a/tests/unit/exchanges/hyperliquid/__init__.py b/tests/unit/exchanges/hyperliquid/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/exchanges/hyperliquid/test_client.py b/tests/unit/exchanges/hyperliquid/test_client.py new file mode 100644 index 0000000..83942bc --- /dev/null +++ b/tests/unit/exchanges/hyperliquid/test_client.py @@ -0,0 +1,227 @@ +from __future__ import annotations + +import re + +import pytest +from cerbero_mcp.exchanges.hyperliquid.client import HyperliquidClient +from pytest_httpx import HTTPXMock + + +@pytest.fixture +def client(): + return HyperliquidClient( + wallet_address="0xDeadBeef", + private_key="0x" + "a" * 64, + testnet=True, + ) + + +# Shared mock responses + +META_AND_CTX = [ + { + "universe": [ + {"name": "BTC", "maxLeverage": 50}, + {"name": "ETH", "maxLeverage": 25}, + ] + }, + [ + { + "markPx": "50000.0", + "funding": "0.0001", + "openInterest": "1000.0", + "dayNtlVlm": "500000.0", + }, + { + "markPx": "3000.0", + "funding": "0.00005", + "openInterest": "500.0", + "dayNtlVlm": "200000.0", + }, + ], +] + +CLEARINGHOUSE_STATE = { + "marginSummary": { + "accountValue": "1500.0", + "totalRawUsd": "1200.0", + "totalMarginUsed": "300.0", + "totalNtlPos": "50.0", + }, + "assetPositions": [ + { + "position": { + "coin": "BTC", + "szi": "0.1", + "entryPx": "48000.0", + "unrealizedPnl": "200.0", + "leverage": {"value": "10"}, + "liquidationPx": "40000.0", + } + } + ], +} + +SPOT_STATE = {"balances": [{"coin": "USDC", "total": "500.0"}]} + + +@pytest.mark.asyncio +async def test_get_markets(httpx_mock: HTTPXMock, client: HyperliquidClient): + httpx_mock.add_response( + url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"), + json=META_AND_CTX, + ) + markets = await client.get_markets() + assert len(markets) == 2 + assert markets[0]["asset"] == "BTC" + assert markets[0]["mark_price"] == 50000.0 + assert markets[0]["max_leverage"] == 50 + + +@pytest.mark.asyncio +async def test_get_ticker(httpx_mock: HTTPXMock, client: HyperliquidClient): + httpx_mock.add_response( + url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"), + json=META_AND_CTX, + ) + result = await client.get_ticker("BTC") + assert result["asset"] == "BTC" + assert result["mark_price"] == 50000.0 + assert result["funding_rate"] == 0.0001 + + +@pytest.mark.asyncio +async def test_get_ticker_not_found(httpx_mock: HTTPXMock, client: HyperliquidClient): + httpx_mock.add_response( + url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"), + json=META_AND_CTX, + ) + result = await client.get_ticker("SOL") + assert "error" in result + + +@pytest.mark.asyncio +async def test_get_orderbook(httpx_mock: HTTPXMock, client: HyperliquidClient): + httpx_mock.add_response( + url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"), + json={ + "levels": [ + [{"px": "49990.0", "sz": "0.5"}, {"px": "49980.0", "sz": "1.0"}], + [{"px": "50010.0", "sz": "0.3"}, {"px": "50020.0", "sz": "0.8"}], + ] + }, + ) + result = await client.get_orderbook("BTC", depth=2) + assert result["asset"] == "BTC" + assert len(result["bids"]) == 2 + assert len(result["asks"]) == 2 + assert result["bids"][0]["price"] == 49990.0 + + +@pytest.mark.asyncio +async def test_get_positions(httpx_mock: HTTPXMock, client: HyperliquidClient): + httpx_mock.add_response( + url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"), + json=CLEARINGHOUSE_STATE, + ) + positions = await client.get_positions() + assert len(positions) == 1 + assert positions[0]["asset"] == "BTC" + assert positions[0]["direction"] == "long" + assert positions[0]["size"] == 0.1 + assert positions[0]["leverage"] == 10.0 + + +@pytest.mark.asyncio +async def test_get_account_summary(httpx_mock: HTTPXMock, client: HyperliquidClient): + # get_account_summary calls /info twice (perp + spot) + httpx_mock.add_response( + url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"), + json=CLEARINGHOUSE_STATE, + ) + httpx_mock.add_response( + url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"), + json=SPOT_STATE, + ) + result = await client.get_account_summary() + assert result["perps_equity"] == 1500.0 + assert result["spot_usdc"] == 500.0 + assert result["equity"] == 2000.0 + + +@pytest.mark.asyncio +async def test_get_trade_history(httpx_mock: HTTPXMock, client: HyperliquidClient): + httpx_mock.add_response( + url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"), + json=[ + {"coin": "BTC", "side": "B", "sz": "0.1", "px": "50000", "fee": "0.5", "time": 1000}, + {"coin": "ETH", "side": "A", "sz": "1.0", "px": "3000", "fee": "0.3", "time": 2000}, + ], + ) + trades = await client.get_trade_history(limit=10) + assert len(trades) == 2 + assert trades[0]["asset"] == "BTC" + assert trades[0]["price"] == 50000.0 + + +@pytest.mark.asyncio +async def test_get_open_orders(httpx_mock: HTTPXMock, client: HyperliquidClient): + httpx_mock.add_response( + url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"), + json=[ + { + "oid": 12345, + "coin": "BTC", + "side": "B", + "sz": "0.05", + "limitPx": "49000", + "orderType": "Limit", + } + ], + ) + orders = await client.get_open_orders() + assert len(orders) == 1 + assert orders[0]["oid"] == 12345 + assert orders[0]["asset"] == "BTC" + + +@pytest.mark.asyncio +async def test_get_historical(httpx_mock: HTTPXMock, client: HyperliquidClient): + httpx_mock.add_response( + url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"), + json=[ + {"t": 1000000, "o": "49000", "h": "51000", "l": "48500", "c": "50000", "v": "100"}, + ], + ) + result = await client.get_historical("BTC", "2024-01-01", "2024-01-02", "1h") + assert len(result["candles"]) == 1 + assert result["candles"][0]["close"] == 50000.0 + + +@pytest.mark.asyncio +async def test_health_ok(httpx_mock: HTTPXMock, client: HyperliquidClient): + httpx_mock.add_response( + url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"), + json={"universe": []}, + ) + result = await client.health() + assert result["status"] in ("ok", "healthy") + assert result["testnet"] is True + + +@pytest.mark.asyncio +async def test_place_order_sdk_unavailable(client: HyperliquidClient): + """place_order raises RuntimeError when SDK is not available (mocked).""" + import cerbero_mcp.exchanges.hyperliquid.client as mod + + original = mod._SDK_AVAILABLE + mod._SDK_AVAILABLE = False + client._exchange = None + try: + result = await client.place_order("BTC", "buy", 0.1, price=50000.0) + # Should return error dict or raise RuntimeError + assert "error" in result or result.get("status") == "error" + except RuntimeError as exc: + assert "not installed" in str(exc).lower() or "sdk" in str(exc).lower() + finally: + mod._SDK_AVAILABLE = original diff --git a/tests/unit/exchanges/hyperliquid/test_leverage_cap.py b/tests/unit/exchanges/hyperliquid/test_leverage_cap.py new file mode 100644 index 0000000..c181892 --- /dev/null +++ b/tests/unit/exchanges/hyperliquid/test_leverage_cap.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +import pytest +from fastapi import HTTPException +from cerbero_mcp.exchanges.hyperliquid.leverage_cap import enforce_leverage, get_max_leverage + + +def test_get_max_leverage_returns_creds_value(): + creds = {"max_leverage": 5} + assert get_max_leverage(creds) == 5 + + +def test_get_max_leverage_default_when_missing(): + """Default 1 (cash) se il secret non ha max_leverage.""" + assert get_max_leverage({}) == 1 + + +def test_enforce_leverage_pass_under_cap(): + creds = {"max_leverage": 3} + enforce_leverage(2, creds=creds, exchange="hyperliquid") # no raise + + +def test_enforce_leverage_pass_at_cap(): + creds = {"max_leverage": 3} + enforce_leverage(3, creds=creds, exchange="hyperliquid") # no raise + + +def test_enforce_leverage_reject_over_cap(): + creds = {"max_leverage": 3} + with pytest.raises(HTTPException) as exc: + enforce_leverage(10, creds=creds, exchange="hyperliquid") + assert exc.value.status_code == 403 + assert exc.value.detail["error"] == "LEVERAGE_CAP_EXCEEDED" + assert exc.value.detail["exchange"] == "hyperliquid" + assert exc.value.detail["requested"] == 10 + assert exc.value.detail["max"] == 3 + + +def test_enforce_leverage_reject_when_below_one(): + creds = {"max_leverage": 3} + with pytest.raises(HTTPException) as exc: + enforce_leverage(0, creds=creds, exchange="hyperliquid") + assert exc.value.status_code == 403 + + +def test_enforce_leverage_default_when_none(): + """Se requested è None, applica il cap come default.""" + creds = {"max_leverage": 3} + result = enforce_leverage(None, creds=creds, exchange="hyperliquid") + assert result == 3 diff --git a/tests/unit/test_exchanges_builder.py b/tests/unit/test_exchanges_builder.py index cd829e3..bc8f1c1 100644 --- a/tests/unit/test_exchanges_builder.py +++ b/tests/unit/test_exchanges_builder.py @@ -50,6 +50,27 @@ async def test_build_client_bybit_returns_correct_env(monkeypatch): assert c_live.testnet is False +@pytest.mark.asyncio +async def test_build_client_hyperliquid_returns_correct_env(monkeypatch): + from tests.unit.test_settings import _minimal_env + + for k, v in _minimal_env().items(): + monkeypatch.setenv(k, v) + + from cerbero_mcp.settings import Settings + from cerbero_mcp.exchanges import build_client + + s = Settings() + c_test = await build_client(s, "hyperliquid", "testnet") + c_live = await build_client(s, "hyperliquid", "mainnet") + + assert c_test is not c_live + assert c_test.testnet is True + assert c_live.testnet is False + assert "test" in c_test.base_url.lower() + assert "test" not in c_live.base_url.lower() + + @pytest.mark.asyncio async def test_build_client_unknown_exchange_raises(monkeypatch): from tests.unit.test_settings import _minimal_env