refactor(V2): hyperliquid client da SDK a httpx + eth-account EIP-712 (parità V1)

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) <noreply@anthropic.com>
This commit is contained in:
AdrianoDev
2026-05-01 01:39:23 +02:00
parent 44c7a18d3e
commit c0b4cb5d5c
4 changed files with 619 additions and 127 deletions
+3
View File
@@ -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]
+335 -110
View File
@@ -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)}
+275 -17
View File
@@ -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()
Generated
+6
View File
@@ -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" },