from __future__ import annotations import re 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=DUMMY_WALLET, private_key=DUMMY_PRIVATE_KEY, 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", }, ], ] META = { "universe": [ {"name": "BTC", "maxLeverage": 50}, {"name": "ETH", "maxLeverage": 25}, ] } 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"}]} # ── Read endpoints ───────────────────────────────────────────── @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 # ── Write endpoints (signed via EIP-712) ─────────────────────── @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()