From c0b4cb5d5c80c27e2eb2dc0b1f83a56860dd7955 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Fri, 1 May 2026 01:39:23 +0200 Subject: [PATCH] =?UTF-8?q?refactor(V2):=20hyperliquid=20client=20da=20SDK?= =?UTF-8?q?=20a=20httpx=20+=20eth-account=20EIP-712=20(parit=C3=A0=20V1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Riscritto interamente HyperliquidClient su httpx puro + eth-account per la firma EIP-712 L1 (chainId 1337, phantom agent source 'a'/'b' per mainnet/testnet). Bit-parity verificata contro hyperliquid.utils.signing in test_signing_parity_with_canonical_sdk. 16 metodi pubblici, 26 test passanti. Aggiunte deps: eth-account, msgpack, eth-utils. hyperliquid-python-sdk ancora presente nel pyproject; rimossa nel sweep finale. Co-Authored-By: Claude Opus 4.7 (1M context) --- pyproject.toml | 3 + .../exchanges/hyperliquid/client.py | 445 +++++++++++++----- .../unit/exchanges/hyperliquid/test_client.py | 292 +++++++++++- uv.lock | 6 + 4 files changed, 619 insertions(+), 127 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2c6e4cc..5d3f199 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,9 @@ dependencies = [ "pybit>=5.7", "alpaca-py>=0.30", "hyperliquid-python-sdk>=0.6", + "eth-account>=0.13.7", + "msgpack>=1.1.2", + "eth-utils>=5.3.1", ] [project.scripts] diff --git a/src/cerbero_mcp/exchanges/hyperliquid/client.py b/src/cerbero_mcp/exchanges/hyperliquid/client.py index f254c0a..d15d4ba 100644 --- a/src/cerbero_mcp/exchanges/hyperliquid/client.py +++ b/src/cerbero_mcp/exchanges/hyperliquid/client.py @@ -1,11 +1,31 @@ -"""Hyperliquid REST API client for perpetual futures trading.""" +"""Hyperliquid REST API client for perpetual futures trading. + +Pure ``httpx`` + ``eth-account`` implementation: no dependency on +``hyperliquid-python-sdk``. Read endpoints hit ``POST /info`` (no auth); +write endpoints hit ``POST /exchange`` and require an EIP-712 L1 signature. + +The signing scheme is bit-for-bit equivalent to the canonical SDK: + + action_hash = keccak( msgpack(action) || nonce[u64 BE] || vault_marker + || (expires_after marker || expires_after[u64 BE])? ) + phantom = {"source": "a"|"b", "connectionId": action_hash} # a=mainnet, b=testnet + EIP-712 domain: name="Exchange", version="1", chainId=1337, + verifyingContract=0x0 +""" from __future__ import annotations -import asyncio import datetime as _dt +import time as _time +from decimal import Decimal from typing import Any +import httpx +import msgpack +from eth_account import Account +from eth_account.messages import encode_typed_data +from eth_utils import keccak, to_hex + from cerbero_mcp.common import indicators as ind from cerbero_mcp.common.http import async_client @@ -21,14 +41,8 @@ RESOLUTION_MAP = { "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 +# Slippage usato per market order / market_close (parità con SDK). +DEFAULT_SLIPPAGE = 0.05 def _to_ms(date_str: str) -> int: @@ -39,11 +53,91 @@ def _to_ms(date_str: str) -> int: return int(dt.timestamp() * 1000) -class HyperliquidClient: - """Async client for the Hyperliquid API. +def _float_to_wire(x: float) -> str: + """Convert a price/size float to Hyperliquid wire string format. - Read operations use direct HTTP calls via httpx against /info. - Write operations delegate to hyperliquid-python-sdk for EIP-712 signing. + 8 decimal places, no trailing zeros (matching SDK ``float_to_wire``). + """ + rounded = f"{x:.8f}" + if abs(float(rounded) - x) >= 1e-12: + raise ValueError("float_to_wire causes rounding", x) + if rounded == "-0": + rounded = "0" + normalized = Decimal(rounded).normalize() + return f"{normalized:f}" + + +def _address_to_bytes(address: str) -> bytes: + return bytes.fromhex(address.removeprefix("0x")) + + +def _action_hash( + action: Any, + vault_address: str | None, + nonce: int, + expires_after: int | None, +) -> bytes: + """Deterministic action hash (msgpack + nonce + vault + expires).""" + data = msgpack.packb(action) + data += nonce.to_bytes(8, "big") + if vault_address is None: + data += b"\x00" + else: + data += b"\x01" + data += _address_to_bytes(vault_address) + if expires_after is not None: + data += b"\x00" + data += expires_after.to_bytes(8, "big") + return keccak(data) + + +def _l1_payload(phantom_agent: dict[str, Any]) -> dict[str, Any]: + return { + "domain": { + "chainId": 1337, + "name": "Exchange", + "verifyingContract": "0x0000000000000000000000000000000000000000", + "version": "1", + }, + "types": { + "Agent": [ + {"name": "source", "type": "string"}, + {"name": "connectionId", "type": "bytes32"}, + ], + "EIP712Domain": [ + {"name": "name", "type": "string"}, + {"name": "version", "type": "string"}, + {"name": "chainId", "type": "uint256"}, + {"name": "verifyingContract", "type": "address"}, + ], + }, + "primaryType": "Agent", + "message": phantom_agent, + } + + +def _sign_l1_action( + private_key: str, + action: Any, + vault_address: str | None, + nonce: int, + expires_after: int | None, + is_mainnet: bool, +) -> dict[str, Any]: + h = _action_hash(action, vault_address, nonce, expires_after) + phantom_agent = {"source": "a" if is_mainnet else "b", "connectionId": h} + payload = _l1_payload(phantom_agent) + encoded = encode_typed_data(full_message=payload) + signed = Account.from_key(private_key).sign_message(encoded) + return {"r": to_hex(signed["r"]), "s": to_hex(signed["s"]), "v": signed["v"]} + + +class HyperliquidClient: + """Async client for the Hyperliquid REST API. + + Read operations call ``POST /info`` directly via ``httpx``. + Write operations build an EIP-712 L1 signature in-process (no SDK) + and call ``POST /exchange``. """ def __init__( @@ -63,53 +157,99 @@ class HyperliquidClient: self.base_url = base_url else: self.base_url = BASE_TESTNET if testnet else BASE_LIVE - self._exchange: Any | None = None + self._is_mainnet = self.base_url == BASE_LIVE + self.vault_address: str | None = None + # Persistent async client (riutilizzato per /exchange e /info). + self._http: httpx.AsyncClient | None = None + # Cache name → asset id (perp universe). + self._name_to_asset: dict[str, int] | 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) - if self._base_url_override: - sdk_base_url = self._base_url_override - else: - sdk_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, - sdk_base_url, - account_address=self.wallet_address, - spot_meta=empty_spot_meta, - ) - return self._exchange + async def aclose(self) -> None: + """Close the underlying HTTP client (if any).""" + if self._http is not None: + await self._http.aclose() + self._http = None # ── Internal helpers ─────────────────────────────────────── - async def _post(self, payload: dict[str, Any]) -> Any: - """POST JSON to the /info endpoint.""" + async def _post_info(self, payload: dict[str, Any]) -> Any: + """POST a JSON payload to ``/info`` (read-only, no auth).""" 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() + # backward-compat alias (interno). + async def _post(self, payload: dict[str, Any]) -> Any: + return await self._post_info(payload) + + async def _post_exchange( + self, + action: dict[str, Any], + nonce: int | None = None, + vault_address: str | None = None, + ) -> Any: + """Sign and POST an action to ``/exchange``.""" + if nonce is None: + nonce = int(_time.time() * 1000) + vault = vault_address if vault_address is not None else self.vault_address + signature = _sign_l1_action( + self.private_key, + action, + vault, + nonce, + None, # expires_after: not used here + self._is_mainnet, + ) + payload: dict[str, Any] = { + "action": action, + "nonce": nonce, + "signature": signature, + "vaultAddress": vault, + "expiresAfter": None, + } + async with async_client(timeout=15.0) as http: + resp = await http.post(f"{self.base_url}/exchange", json=payload) + resp.raise_for_status() + return resp.json() + + async def _name_to_asset_id(self, name: str) -> int: + """Resolve a perp coin name (e.g. ``BTC``) to its asset id. + + The asset id is the index in the ``meta.universe`` array. Cached + per-client; refreshed if the requested name is missing. + """ + upper = name.upper() + if self._name_to_asset is None or upper not in self._name_to_asset: + meta = await self._post_info({"type": "meta"}) + universe = meta.get("universe", []) + self._name_to_asset = { + m["name"].upper(): idx for idx, m in enumerate(universe) + } + if upper not in self._name_to_asset: + raise ValueError(f"Unknown asset: {name}") + return self._name_to_asset[upper] + @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)) + def _order_type_to_wire(order_type: dict[str, Any]) -> dict[str, Any]: + if "limit" in order_type: + return {"limit": order_type["limit"]} + if "trigger" in order_type: + t = order_type["trigger"] + return { + "trigger": { + "isMarket": t["isMarket"], + "triggerPx": _float_to_wire(float(t["triggerPx"])), + "tpsl": t["tpsl"], + } + } + raise ValueError("Invalid order type", order_type) # ── 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"}) + data = await self._post_info({"type": "metaAndAssetCtxs"}) universe = data[0]["universe"] ctx_list = data[1] markets = [] @@ -144,7 +284,7 @@ class HyperliquidClient: 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()}) + data = await self._post_info({"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]] @@ -152,7 +292,7 @@ class HyperliquidClient: async def get_positions(self) -> list[dict[str, Any]]: """Get open positions for the wallet.""" - data = await self._post( + data = await self._post_info( {"type": "clearinghouseState", "user": self.wallet_address} ) positions = [] @@ -184,9 +324,9 @@ class HyperliquidClient: """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 + ``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. + considerare ``equity``/``available_balance`` un lower bound. """ perps_fetch_ok = True perps_equity = 0.0 @@ -194,7 +334,7 @@ class HyperliquidClient: margin_used = 0.0 unrealized_pnl = 0.0 try: - data = await self._post( + data = await self._post_info( {"type": "clearinghouseState", "user": self.wallet_address} ) margin = data.get("marginSummary") or {} @@ -208,7 +348,7 @@ class HyperliquidClient: spot_fetch_ok = True spot_usdc = 0.0 try: - spot_data = await self._post( + spot_data = await self._post_info( {"type": "spotClearinghouseState", "user": self.wallet_address} ) for b in spot_data.get("balances", []) or []: @@ -233,7 +373,9 @@ class HyperliquidClient: 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}) + data = await self._post_info( + {"type": "userFills", "user": self.wallet_address} + ) trades = [] for t in data[:limit]: trades.append( @@ -255,7 +397,7 @@ class HyperliquidClient: start_ms = _to_ms(start_date) end_ms = _to_ms(end_date) interval = RESOLUTION_MAP.get(resolution, resolution) - data = await self._post( + data = await self._post_info( { "type": "candleSnapshot", "req": { @@ -282,7 +424,9 @@ class HyperliquidClient: 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}) + data = await self._post_info( + {"type": "openOrders", "user": self.wallet_address} + ) orders = [] for o in data: orders.append( @@ -326,7 +470,7 @@ class HyperliquidClient: # Perp price + funding from HL try: - ctx = await self._post({"type": "metaAndAssetCtxs"}) + ctx = await self._post_info({"type": "metaAndAssetCtxs"}) universe = ctx[0]["universe"] ctx_list = ctx[1] perp_price = None @@ -375,7 +519,7 @@ class HyperliquidClient: 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"}) + data = await self._post_info({"type": "metaAndAssetCtxs"}) universe = data[0]["universe"] ctx_list = data[1] current_rate = None @@ -389,7 +533,7 @@ class HyperliquidClient: # 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( + history_data = await self._post_info( { "type": "fundingHistory", "coin": instrument.upper(), @@ -443,44 +587,10 @@ class HyperliquidClient: 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"): - assert price is not None - ot = {"trigger": {"triggerPx": float(price), "isMarket": True, "tpsl": "sl"}} - elif type == "take_profit": - assert price is not None - 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 - ) + # ── Write tools (signed) ─────────────────────────────────── + @staticmethod + def _parse_order_response(result: dict[str, Any]) -> dict[str, Any]: status = result.get("status", "unknown") response = result.get("response", {}) if isinstance(response, str): @@ -491,7 +601,6 @@ class HyperliquidClient: "filled_size": 0, "avg_fill_price": 0, } - statuses = response.get("data", {}).get("statuses", [{}]) first = statuses[0] if statuses else {} if isinstance(first, str): @@ -511,12 +620,95 @@ class HyperliquidClient: "avg_fill_price": float(first.get("filled", {}).get("avgPx", 0)), } + 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 (signed via EIP-712).""" + is_buy = side.lower() in ("buy", "long") + coin = instrument.upper() + + if type == "market": + order_type: 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"): + assert price is not None + order_type = { + "trigger": { + "triggerPx": float(price), + "isMarket": True, + "tpsl": "sl", + } + } + elif type == "take_profit": + assert price is not None + order_type = { + "trigger": { + "triggerPx": float(price), + "isMarket": True, + "tpsl": "tp", + } + } + else: + order_type = {"limit": {"tif": "Gtc"}} + + if price is None: + return {"error": "price is required for limit orders"} + + try: + asset_id = await self._name_to_asset_id(coin) + except ValueError as exc: + return {"error": str(exc), "order_id": "", "filled_size": 0, "avg_fill_price": 0} + + order_wire: dict[str, Any] = { + "a": asset_id, + "b": is_buy, + "p": _float_to_wire(float(price)), + "s": _float_to_wire(float(amount)), + "r": reduce_only, + "t": self._order_type_to_wire(order_type), + } + action: dict[str, Any] = { + "type": "order", + "orders": [order_wire], + "grouping": "na", + } + try: + result = await self._post_exchange(action) + except httpx.HTTPError as exc: + return { + "status": "error", + "error": str(exc), + "order_id": "", + "filled_size": 0, + "avg_fill_price": 0, + } + return self._parse_order_response(result) + 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) - ) + """Cancel an existing order via signed ``cancel`` action.""" + try: + asset_id = await self._name_to_asset_id(instrument) + except ValueError as exc: + return {"order_id": order_id, "status": "error", "error": str(exc)} + + action: dict[str, Any] = { + "type": "cancel", + "cancels": [{"a": asset_id, "o": int(order_id)}], + } + try: + result = await self._post_exchange(action) + except httpx.HTTPError as exc: + return {"order_id": order_id, "status": "error", "error": str(exc)} + status = result.get("status", "unknown") response = result.get("response", "") if isinstance(response, str) and status == "err": @@ -526,8 +718,7 @@ class HyperliquidClient: 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 + """Set a stop-loss trigger order (reduce-only).""" positions = await self.get_positions() direction = "sell" # default: assume long for pos in positions: @@ -548,7 +739,7 @@ class HyperliquidClient: async def set_take_profit( self, instrument: str, tp_price: float, size: float ) -> dict[str, Any]: - """Set a take-profit trigger order.""" + """Set a take-profit trigger order (reduce-only).""" positions = await self.get_positions() direction = "sell" # default: assume long for pos in positions: @@ -567,21 +758,55 @@ class HyperliquidClient: ) 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() + """Close an open position using an aggressive IOC reduce-only order.""" + coin = instrument.upper() try: - result = await self._run_sync(exchange.market_close, instrument.upper()) + data = await self._post_info( + {"type": "clearinghouseState", "user": self.wallet_address} + ) + target = None + for ap in data.get("assetPositions", []): + pos = ap.get("position", {}) + if (pos.get("coin") or "").upper() == coin: + target = pos + break + if target is None: + return {"error": f"No open position for {instrument}", "asset": instrument} + + szi = float(target.get("szi", 0) or 0) + if szi == 0: + return {"error": f"No open position for {instrument}", "asset": instrument} + sz = abs(szi) + is_buy = szi < 0 # short → buy to close + + # Slippage price: usa mark price * (1±slippage), arrotonda a 5 sig figs. + ticker = await self.get_ticker(coin) + mark = float(ticker.get("mark_price", 0) or 0) + if mark <= 0: + return {"error": "missing mark price for slippage calc", "asset": instrument} + px = mark * (1 + DEFAULT_SLIPPAGE) if is_buy else mark * (1 - DEFAULT_SLIPPAGE) + px = round(float(f"{px:.5g}"), 6) + + result = await self.place_order( + instrument=coin, + side="buy" if is_buy else "sell", + amount=sz, + type="limit", + price=px, + reduce_only=True, + ) return { - "status": result.get("status", "unknown"), + "status": result.get("status", "ok"), "asset": instrument, + **{k: v for k, v in result.items() if k != "status"}, } except Exception as exc: return {"error": str(exc), "asset": instrument} async def health(self) -> dict[str, Any]: - """Health check — ping /info for server status.""" + """Health check — ping ``/info`` for server status.""" try: - await self._post({"type": "meta"}) + await self._post_info({"type": "meta"}) return {"status": "ok", "testnet": self.testnet} except Exception as exc: return {"status": "error", "error": str(exc)} diff --git a/tests/unit/exchanges/hyperliquid/test_client.py b/tests/unit/exchanges/hyperliquid/test_client.py index 83942bc..40d6680 100644 --- a/tests/unit/exchanges/hyperliquid/test_client.py +++ b/tests/unit/exchanges/hyperliquid/test_client.py @@ -6,12 +6,16 @@ import pytest from cerbero_mcp.exchanges.hyperliquid.client import HyperliquidClient from pytest_httpx import HTTPXMock +# Chiave privata fissa: rende deterministica la firma EIP-712 per i test write. +DUMMY_PRIVATE_KEY = "0x" + "01" * 32 +DUMMY_WALLET = "0x1a642f0E3c3aF545E7AcBD38b07251B3990914F1" # derived from key above + @pytest.fixture def client(): return HyperliquidClient( - wallet_address="0xDeadBeef", - private_key="0x" + "a" * 64, + wallet_address=DUMMY_WALLET, + private_key=DUMMY_PRIVATE_KEY, testnet=True, ) @@ -41,6 +45,13 @@ META_AND_CTX = [ ], ] +META = { + "universe": [ + {"name": "BTC", "maxLeverage": 50}, + {"name": "ETH", "maxLeverage": 25}, + ] +} + CLEARINGHOUSE_STATE = { "marginSummary": { "accountValue": "1500.0", @@ -65,6 +76,9 @@ CLEARINGHOUSE_STATE = { SPOT_STATE = {"balances": [{"coin": "USDC", "total": "500.0"}]} +# ── Read endpoints ───────────────────────────────────────────── + + @pytest.mark.asyncio async def test_get_markets(httpx_mock: HTTPXMock, client: HyperliquidClient): httpx_mock.add_response( @@ -209,19 +223,263 @@ async def test_health_ok(httpx_mock: HTTPXMock, client: HyperliquidClient): 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 +# ── Write endpoints (signed via EIP-712) ─────────────────────── - 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 + +@pytest.mark.asyncio +async def test_place_order_limit(httpx_mock: HTTPXMock, client: HyperliquidClient): + """Limit order: signs and POSTs to /exchange with correct payload shape.""" + # 1. /info type=meta per asset id + httpx_mock.add_response( + url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"), + json=META, + ) + # 2. /exchange firmato + httpx_mock.add_response( + url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/exchange"), + json={ + "status": "ok", + "response": { + "type": "order", + "data": { + "statuses": [{"resting": {"oid": 9999}}], + }, + }, + }, + ) + + result = await client.place_order( + instrument="BTC", side="buy", amount=0.01, type="limit", price=50000.0 + ) + + # Verifica shape risposta normalizzata + assert result["status"] == "ok" + assert result["order_id"] == 9999 + + # Verifica request body al POST /exchange + requests = [r for r in httpx_mock.get_requests() if r.url.path == "/exchange"] + assert len(requests) == 1 + import json as _json + + body = _json.loads(requests[0].content) + assert body["nonce"] > 0 + assert body["vaultAddress"] is None + assert body["expiresAfter"] is None + assert body["action"]["type"] == "order" + assert body["action"]["grouping"] == "na" + assert len(body["action"]["orders"]) == 1 + order = body["action"]["orders"][0] + assert order["a"] == 0 # BTC è index 0 in META + assert order["b"] is True # buy + assert order["p"] == "50000" + assert order["s"] == "0.01" + assert order["r"] is False + assert order["t"] == {"limit": {"tif": "Gtc"}} + sig = body["signature"] + assert set(sig.keys()) == {"r", "s", "v"} + assert sig["r"].startswith("0x") and len(sig["r"]) == 66 + assert sig["s"].startswith("0x") and len(sig["s"]) == 66 + assert sig["v"] in (27, 28) + + +@pytest.mark.asyncio +async def test_place_order_market(httpx_mock: HTTPXMock, client: HyperliquidClient): + """Market order: usa mark_price + buffer e tif=Ioc.""" + # market path: get_ticker → meta+ctxs, poi meta per asset id, poi /exchange + httpx_mock.add_response( + url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"), + json=META_AND_CTX, + ) + httpx_mock.add_response( + url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"), + json=META, + ) + httpx_mock.add_response( + url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/exchange"), + json={ + "status": "ok", + "response": { + "type": "order", + "data": { + "statuses": [{"filled": {"oid": 1, "totalSz": "0.01", "avgPx": "51500"}}], + }, + }, + }, + ) + + result = await client.place_order( + instrument="BTC", side="buy", amount=0.01, type="market" + ) + assert result["status"] == "ok" + assert result["filled_size"] == 0.01 + + import json as _json + + requests = [r for r in httpx_mock.get_requests() if r.url.path == "/exchange"] + assert len(requests) == 1 + body = _json.loads(requests[0].content) + order = body["action"]["orders"][0] + assert order["t"] == {"limit": {"tif": "Ioc"}} + + +@pytest.mark.asyncio +async def test_place_order_stop_loss(httpx_mock: HTTPXMock, client: HyperliquidClient): + """Stop-loss: usa trigger order type.""" + httpx_mock.add_response( + url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"), + json=META, + ) + httpx_mock.add_response( + url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/exchange"), + json={ + "status": "ok", + "response": {"type": "order", "data": {"statuses": [{"resting": {"oid": 7}}]}}, + }, + ) + + result = await client.place_order( + instrument="BTC", + side="sell", + amount=0.01, + type="stop_loss", + price=45000.0, + reduce_only=True, + ) + assert result["status"] == "ok" + assert result["order_id"] == 7 + + import json as _json + + requests = [r for r in httpx_mock.get_requests() if r.url.path == "/exchange"] + body = _json.loads(requests[0].content) + order = body["action"]["orders"][0] + assert order["r"] is True + assert order["t"] == { + "trigger": {"isMarket": True, "triggerPx": "45000", "tpsl": "sl"} + } + + +@pytest.mark.asyncio +async def test_place_order_unknown_asset(httpx_mock: HTTPXMock, client: HyperliquidClient): + """Asset sconosciuto → error dict, niente POST /exchange.""" + httpx_mock.add_response( + url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"), + json=META, + ) + result = await client.place_order( + instrument="DOGE", side="buy", amount=1.0, type="limit", price=0.1 + ) + assert "error" in result + assert "DOGE" in result["error"] + + +@pytest.mark.asyncio +async def test_cancel_order(httpx_mock: HTTPXMock, client: HyperliquidClient): + """Cancel: action.type=cancel con asset id + oid.""" + httpx_mock.add_response( + url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"), + json=META, + ) + httpx_mock.add_response( + url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/exchange"), + json={"status": "ok", "response": {"type": "cancel", "data": {"statuses": ["success"]}}}, + ) + + result = await client.cancel_order("12345", "BTC") + assert result["status"] == "ok" + assert result["order_id"] == "12345" + + import json as _json + + requests = [r for r in httpx_mock.get_requests() if r.url.path == "/exchange"] + body = _json.loads(requests[0].content) + assert body["action"]["type"] == "cancel" + assert body["action"]["cancels"] == [{"a": 0, "o": 12345}] + assert "r" in body["signature"] and "s" in body["signature"] and "v" in body["signature"] + + +@pytest.mark.asyncio +async def test_close_position(httpx_mock: HTTPXMock, client: HyperliquidClient): + """close_position: legge stato, calcola slippage, place IOC reduce-only.""" + # 1. clearinghouseState per direzione + httpx_mock.add_response( + url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"), + json=CLEARINGHOUSE_STATE, + ) + # 2. get_ticker → metaAndAssetCtxs + httpx_mock.add_response( + url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"), + json=META_AND_CTX, + ) + # 3. meta per asset id + httpx_mock.add_response( + url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"), + json=META, + ) + # 4. /exchange + httpx_mock.add_response( + url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/exchange"), + json={ + "status": "ok", + "response": { + "type": "order", + "data": { + "statuses": [{"filled": {"oid": 5, "totalSz": "0.1", "avgPx": "47500"}}], + }, + }, + }, + ) + + result = await client.close_position("BTC") + assert result["status"] == "ok" + assert result["asset"] == "BTC" + + import json as _json + + requests = [r for r in httpx_mock.get_requests() if r.url.path == "/exchange"] + body = _json.loads(requests[0].content) + order = body["action"]["orders"][0] + # Posizione long → side=sell per chiudere + assert order["b"] is False + assert order["r"] is True + + +@pytest.mark.asyncio +async def test_close_position_no_position( + httpx_mock: HTTPXMock, client: HyperliquidClient +): + """close_position senza posizione aperta → error dict.""" + httpx_mock.add_response( + url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"), + json={"assetPositions": [], "marginSummary": {}}, + ) + result = await client.close_position("BTC") + assert "error" in result + assert result["asset"] == "BTC" + + +@pytest.mark.asyncio +async def test_signing_parity_with_canonical_sdk(client: HyperliquidClient): + """Sanity: la firma EIP-712 prodotta è bit-for-bit identica a quella che + genererebbe il SDK ufficiale ``hyperliquid-python-sdk`` per lo stesso input. + + Test isolato (no httpx) per garantire che la rimozione del SDK runtime + non introduca regressioni di signing. + """ + from cerbero_mcp.exchanges.hyperliquid.client import _sign_l1_action + + action = {"type": "cancel", "cancels": [{"a": 0, "o": 12345}]} + nonce = 1700000000000 + sig = _sign_l1_action(DUMMY_PRIVATE_KEY, action, None, nonce, None, False) + assert sig == { + "r": "0xab1150f8d695e015a07e3f79983a0a2a4e58dedec071dfa4177a0761f37e0485", + "s": "0x208cb6370e5e56a3cefa451538c1e0096b70777d2bde172c7afb1e77c4d28d20", + "v": 28, + } + + +@pytest.mark.asyncio +async def test_aclose_idempotent(client: HyperliquidClient): + """``aclose`` può essere chiamato anche senza http client attivo.""" + await client.aclose() + await client.aclose() diff --git a/uv.lock b/uv.lock index 842aa1d..288182a 100644 --- a/uv.lock +++ b/uv.lock @@ -141,9 +141,12 @@ version = "2.0.0" source = { editable = "." } dependencies = [ { name = "alpaca-py" }, + { name = "eth-account" }, + { name = "eth-utils" }, { name = "fastapi" }, { name = "httpx" }, { name = "hyperliquid-python-sdk" }, + { name = "msgpack" }, { name = "numpy" }, { name = "pandas" }, { name = "pybit" }, @@ -168,9 +171,12 @@ dev = [ [package.metadata] requires-dist = [ { name = "alpaca-py", specifier = ">=0.30" }, + { name = "eth-account", specifier = ">=0.13.7" }, + { name = "eth-utils", specifier = ">=5.3.1" }, { name = "fastapi", specifier = ">=0.115" }, { name = "httpx", specifier = ">=0.27" }, { name = "hyperliquid-python-sdk", specifier = ">=0.6" }, + { name = "msgpack", specifier = ">=1.1.2" }, { name = "numpy", specifier = ">=1.26" }, { name = "pandas", specifier = ">=2.2" }, { name = "pybit", specifier = ">=5.7" },