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:
@@ -19,6 +19,9 @@ dependencies = [
|
|||||||
"pybit>=5.7",
|
"pybit>=5.7",
|
||||||
"alpaca-py>=0.30",
|
"alpaca-py>=0.30",
|
||||||
"hyperliquid-python-sdk>=0.6",
|
"hyperliquid-python-sdk>=0.6",
|
||||||
|
"eth-account>=0.13.7",
|
||||||
|
"msgpack>=1.1.2",
|
||||||
|
"eth-utils>=5.3.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|||||||
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import datetime as _dt
|
import datetime as _dt
|
||||||
|
import time as _time
|
||||||
|
from decimal import Decimal
|
||||||
from typing import Any
|
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 import indicators as ind
|
||||||
from cerbero_mcp.common.http import async_client
|
from cerbero_mcp.common.http import async_client
|
||||||
|
|
||||||
@@ -21,14 +41,8 @@ RESOLUTION_MAP = {
|
|||||||
"1d": "1d",
|
"1d": "1d",
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
# Slippage usato per market order / market_close (parità con SDK).
|
||||||
from eth_account import Account
|
DEFAULT_SLIPPAGE = 0.05
|
||||||
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:
|
def _to_ms(date_str: str) -> int:
|
||||||
@@ -39,11 +53,91 @@ def _to_ms(date_str: str) -> int:
|
|||||||
return int(dt.timestamp() * 1000)
|
return int(dt.timestamp() * 1000)
|
||||||
|
|
||||||
|
|
||||||
class HyperliquidClient:
|
def _float_to_wire(x: float) -> str:
|
||||||
"""Async client for the Hyperliquid API.
|
"""Convert a price/size float to Hyperliquid wire string format.
|
||||||
|
|
||||||
Read operations use direct HTTP calls via httpx against /info.
|
8 decimal places, no trailing zeros (matching SDK ``float_to_wire``).
|
||||||
Write operations delegate to hyperliquid-python-sdk for EIP-712 signing.
|
"""
|
||||||
|
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__(
|
def __init__(
|
||||||
@@ -63,53 +157,99 @@ class HyperliquidClient:
|
|||||||
self.base_url = base_url
|
self.base_url = base_url
|
||||||
else:
|
else:
|
||||||
self.base_url = BASE_TESTNET if testnet else BASE_LIVE
|
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) ────────────────────────────────────
|
async def aclose(self) -> None:
|
||||||
|
"""Close the underlying HTTP client (if any)."""
|
||||||
def _get_exchange(self) -> Any:
|
if self._http is not None:
|
||||||
"""Return (and cache) an SDK Exchange instance for write ops."""
|
await self._http.aclose()
|
||||||
if not _SDK_AVAILABLE:
|
self._http = None
|
||||||
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
|
|
||||||
|
|
||||||
# ── Internal helpers ───────────────────────────────────────
|
# ── Internal helpers ───────────────────────────────────────
|
||||||
|
|
||||||
async def _post(self, payload: dict[str, Any]) -> Any:
|
async def _post_info(self, payload: dict[str, Any]) -> Any:
|
||||||
"""POST JSON to the /info endpoint."""
|
"""POST a JSON payload to ``/info`` (read-only, no auth)."""
|
||||||
async with async_client(timeout=15.0) as http:
|
async with async_client(timeout=15.0) as http:
|
||||||
resp = await http.post(f"{self.base_url}/info", json=payload)
|
resp = await http.post(f"{self.base_url}/info", json=payload)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
return resp.json()
|
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
|
@staticmethod
|
||||||
async def _run_sync(func: Any, *args: Any, **kwargs: Any) -> Any:
|
def _order_type_to_wire(order_type: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""Run a synchronous SDK call in the default executor."""
|
if "limit" in order_type:
|
||||||
loop = asyncio.get_event_loop()
|
return {"limit": order_type["limit"]}
|
||||||
return await loop.run_in_executor(None, lambda: func(*args, **kwargs))
|
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 ─────────────────────────────────────────────
|
# ── Read tools ─────────────────────────────────────────────
|
||||||
|
|
||||||
async def get_markets(self) -> list[dict[str, Any]]:
|
async def get_markets(self) -> list[dict[str, Any]]:
|
||||||
"""List all perp markets with metadata and current stats."""
|
"""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"]
|
universe = data[0]["universe"]
|
||||||
ctx_list = data[1]
|
ctx_list = data[1]
|
||||||
markets = []
|
markets = []
|
||||||
@@ -144,7 +284,7 @@ class HyperliquidClient:
|
|||||||
|
|
||||||
async def get_orderbook(self, instrument: str, depth: int = 10) -> dict[str, Any]:
|
async def get_orderbook(self, instrument: str, depth: int = 10) -> dict[str, Any]:
|
||||||
"""Get L2 order book for an asset."""
|
"""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", [[], []])
|
levels = data.get("levels", [[], []])
|
||||||
bids = [{"price": float(b["px"]), "size": float(b["sz"])} for b in levels[0][:depth]]
|
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]]
|
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]]:
|
async def get_positions(self) -> list[dict[str, Any]]:
|
||||||
"""Get open positions for the wallet."""
|
"""Get open positions for the wallet."""
|
||||||
data = await self._post(
|
data = await self._post_info(
|
||||||
{"type": "clearinghouseState", "user": self.wallet_address}
|
{"type": "clearinghouseState", "user": self.wallet_address}
|
||||||
)
|
)
|
||||||
positions = []
|
positions = []
|
||||||
@@ -184,9 +324,9 @@ class HyperliquidClient:
|
|||||||
"""Get account summary (equity, balance, margin) including spot balances.
|
"""Get account summary (equity, balance, margin) including spot balances.
|
||||||
|
|
||||||
Con Unified Account, spot USDC e perps condividono collaterale.
|
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
|
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_fetch_ok = True
|
||||||
perps_equity = 0.0
|
perps_equity = 0.0
|
||||||
@@ -194,7 +334,7 @@ class HyperliquidClient:
|
|||||||
margin_used = 0.0
|
margin_used = 0.0
|
||||||
unrealized_pnl = 0.0
|
unrealized_pnl = 0.0
|
||||||
try:
|
try:
|
||||||
data = await self._post(
|
data = await self._post_info(
|
||||||
{"type": "clearinghouseState", "user": self.wallet_address}
|
{"type": "clearinghouseState", "user": self.wallet_address}
|
||||||
)
|
)
|
||||||
margin = data.get("marginSummary") or {}
|
margin = data.get("marginSummary") or {}
|
||||||
@@ -208,7 +348,7 @@ class HyperliquidClient:
|
|||||||
spot_fetch_ok = True
|
spot_fetch_ok = True
|
||||||
spot_usdc = 0.0
|
spot_usdc = 0.0
|
||||||
try:
|
try:
|
||||||
spot_data = await self._post(
|
spot_data = await self._post_info(
|
||||||
{"type": "spotClearinghouseState", "user": self.wallet_address}
|
{"type": "spotClearinghouseState", "user": self.wallet_address}
|
||||||
)
|
)
|
||||||
for b in spot_data.get("balances", []) or []:
|
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]]:
|
async def get_trade_history(self, limit: int = 100) -> list[dict[str, Any]]:
|
||||||
"""Get recent trade fills."""
|
"""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 = []
|
trades = []
|
||||||
for t in data[:limit]:
|
for t in data[:limit]:
|
||||||
trades.append(
|
trades.append(
|
||||||
@@ -255,7 +397,7 @@ class HyperliquidClient:
|
|||||||
start_ms = _to_ms(start_date)
|
start_ms = _to_ms(start_date)
|
||||||
end_ms = _to_ms(end_date)
|
end_ms = _to_ms(end_date)
|
||||||
interval = RESOLUTION_MAP.get(resolution, resolution)
|
interval = RESOLUTION_MAP.get(resolution, resolution)
|
||||||
data = await self._post(
|
data = await self._post_info(
|
||||||
{
|
{
|
||||||
"type": "candleSnapshot",
|
"type": "candleSnapshot",
|
||||||
"req": {
|
"req": {
|
||||||
@@ -282,7 +424,9 @@ class HyperliquidClient:
|
|||||||
|
|
||||||
async def get_open_orders(self) -> list[dict[str, Any]]:
|
async def get_open_orders(self) -> list[dict[str, Any]]:
|
||||||
"""Get all open orders for the wallet."""
|
"""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 = []
|
orders = []
|
||||||
for o in data:
|
for o in data:
|
||||||
orders.append(
|
orders.append(
|
||||||
@@ -326,7 +470,7 @@ class HyperliquidClient:
|
|||||||
|
|
||||||
# Perp price + funding from HL
|
# Perp price + funding from HL
|
||||||
try:
|
try:
|
||||||
ctx = await self._post({"type": "metaAndAssetCtxs"})
|
ctx = await self._post_info({"type": "metaAndAssetCtxs"})
|
||||||
universe = ctx[0]["universe"]
|
universe = ctx[0]["universe"]
|
||||||
ctx_list = ctx[1]
|
ctx_list = ctx[1]
|
||||||
perp_price = None
|
perp_price = None
|
||||||
@@ -375,7 +519,7 @@ class HyperliquidClient:
|
|||||||
|
|
||||||
async def get_funding_rate(self, instrument: str) -> dict[str, Any]:
|
async def get_funding_rate(self, instrument: str) -> dict[str, Any]:
|
||||||
"""Get current and recent historical funding rates for an asset."""
|
"""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"]
|
universe = data[0]["universe"]
|
||||||
ctx_list = data[1]
|
ctx_list = data[1]
|
||||||
current_rate = None
|
current_rate = None
|
||||||
@@ -389,7 +533,7 @@ class HyperliquidClient:
|
|||||||
# Fetch funding history (last 7 days)
|
# Fetch funding history (last 7 days)
|
||||||
end_ms = int(_dt.datetime.utcnow().timestamp() * 1000)
|
end_ms = int(_dt.datetime.utcnow().timestamp() * 1000)
|
||||||
start_ms = end_ms - 7 * 24 * 3600 * 1000
|
start_ms = end_ms - 7 * 24 * 3600 * 1000
|
||||||
history_data = await self._post(
|
history_data = await self._post_info(
|
||||||
{
|
{
|
||||||
"type": "fundingHistory",
|
"type": "fundingHistory",
|
||||||
"coin": instrument.upper(),
|
"coin": instrument.upper(),
|
||||||
@@ -443,44 +587,10 @@ class HyperliquidClient:
|
|||||||
result[name] = None
|
result[name] = None
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# ── Write tools (via SDK) ──────────────────────────────────
|
# ── Write tools (signed) ───────────────────────────────────
|
||||||
|
|
||||||
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
|
|
||||||
)
|
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_order_response(result: dict[str, Any]) -> dict[str, Any]:
|
||||||
status = result.get("status", "unknown")
|
status = result.get("status", "unknown")
|
||||||
response = result.get("response", {})
|
response = result.get("response", {})
|
||||||
if isinstance(response, str):
|
if isinstance(response, str):
|
||||||
@@ -491,7 +601,6 @@ class HyperliquidClient:
|
|||||||
"filled_size": 0,
|
"filled_size": 0,
|
||||||
"avg_fill_price": 0,
|
"avg_fill_price": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
statuses = response.get("data", {}).get("statuses", [{}])
|
statuses = response.get("data", {}).get("statuses", [{}])
|
||||||
first = statuses[0] if statuses else {}
|
first = statuses[0] if statuses else {}
|
||||||
if isinstance(first, str):
|
if isinstance(first, str):
|
||||||
@@ -511,12 +620,95 @@ class HyperliquidClient:
|
|||||||
"avg_fill_price": float(first.get("filled", {}).get("avgPx", 0)),
|
"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]:
|
async def cancel_order(self, order_id: str, instrument: str) -> dict[str, Any]:
|
||||||
"""Cancel an existing order using the SDK."""
|
"""Cancel an existing order via signed ``cancel`` action."""
|
||||||
exchange = self._get_exchange()
|
try:
|
||||||
result = await self._run_sync(
|
asset_id = await self._name_to_asset_id(instrument)
|
||||||
exchange.cancel, instrument.upper(), int(order_id)
|
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")
|
status = result.get("status", "unknown")
|
||||||
response = result.get("response", "")
|
response = result.get("response", "")
|
||||||
if isinstance(response, str) and status == "err":
|
if isinstance(response, str) and status == "err":
|
||||||
@@ -526,8 +718,7 @@ class HyperliquidClient:
|
|||||||
async def set_stop_loss(
|
async def set_stop_loss(
|
||||||
self, instrument: str, stop_price: float, size: float
|
self, instrument: str, stop_price: float, size: float
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Set a stop-loss trigger order."""
|
"""Set a stop-loss trigger order (reduce-only)."""
|
||||||
# Determine direction by checking open position
|
|
||||||
positions = await self.get_positions()
|
positions = await self.get_positions()
|
||||||
direction = "sell" # default: assume long
|
direction = "sell" # default: assume long
|
||||||
for pos in positions:
|
for pos in positions:
|
||||||
@@ -548,7 +739,7 @@ class HyperliquidClient:
|
|||||||
async def set_take_profit(
|
async def set_take_profit(
|
||||||
self, instrument: str, tp_price: float, size: float
|
self, instrument: str, tp_price: float, size: float
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Set a take-profit trigger order."""
|
"""Set a take-profit trigger order (reduce-only)."""
|
||||||
positions = await self.get_positions()
|
positions = await self.get_positions()
|
||||||
direction = "sell" # default: assume long
|
direction = "sell" # default: assume long
|
||||||
for pos in positions:
|
for pos in positions:
|
||||||
@@ -567,21 +758,55 @@ class HyperliquidClient:
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def close_position(self, instrument: str) -> dict[str, Any]:
|
async def close_position(self, instrument: str) -> dict[str, Any]:
|
||||||
"""Close an open position for the given asset using market_close."""
|
"""Close an open position using an aggressive IOC reduce-only order."""
|
||||||
exchange = self._get_exchange()
|
coin = instrument.upper()
|
||||||
try:
|
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 {
|
return {
|
||||||
"status": result.get("status", "unknown"),
|
"status": result.get("status", "ok"),
|
||||||
"asset": instrument,
|
"asset": instrument,
|
||||||
|
**{k: v for k, v in result.items() if k != "status"},
|
||||||
}
|
}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return {"error": str(exc), "asset": instrument}
|
return {"error": str(exc), "asset": instrument}
|
||||||
|
|
||||||
async def health(self) -> dict[str, Any]:
|
async def health(self) -> dict[str, Any]:
|
||||||
"""Health check — ping /info for server status."""
|
"""Health check — ping ``/info`` for server status."""
|
||||||
try:
|
try:
|
||||||
await self._post({"type": "meta"})
|
await self._post_info({"type": "meta"})
|
||||||
return {"status": "ok", "testnet": self.testnet}
|
return {"status": "ok", "testnet": self.testnet}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return {"status": "error", "error": str(exc)}
|
return {"status": "error", "error": str(exc)}
|
||||||
|
|||||||
@@ -6,12 +6,16 @@ import pytest
|
|||||||
from cerbero_mcp.exchanges.hyperliquid.client import HyperliquidClient
|
from cerbero_mcp.exchanges.hyperliquid.client import HyperliquidClient
|
||||||
from pytest_httpx import HTTPXMock
|
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
|
@pytest.fixture
|
||||||
def client():
|
def client():
|
||||||
return HyperliquidClient(
|
return HyperliquidClient(
|
||||||
wallet_address="0xDeadBeef",
|
wallet_address=DUMMY_WALLET,
|
||||||
private_key="0x" + "a" * 64,
|
private_key=DUMMY_PRIVATE_KEY,
|
||||||
testnet=True,
|
testnet=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -41,6 +45,13 @@ META_AND_CTX = [
|
|||||||
],
|
],
|
||||||
]
|
]
|
||||||
|
|
||||||
|
META = {
|
||||||
|
"universe": [
|
||||||
|
{"name": "BTC", "maxLeverage": 50},
|
||||||
|
{"name": "ETH", "maxLeverage": 25},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
CLEARINGHOUSE_STATE = {
|
CLEARINGHOUSE_STATE = {
|
||||||
"marginSummary": {
|
"marginSummary": {
|
||||||
"accountValue": "1500.0",
|
"accountValue": "1500.0",
|
||||||
@@ -65,6 +76,9 @@ CLEARINGHOUSE_STATE = {
|
|||||||
SPOT_STATE = {"balances": [{"coin": "USDC", "total": "500.0"}]}
|
SPOT_STATE = {"balances": [{"coin": "USDC", "total": "500.0"}]}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Read endpoints ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_markets(httpx_mock: HTTPXMock, client: HyperliquidClient):
|
async def test_get_markets(httpx_mock: HTTPXMock, client: HyperliquidClient):
|
||||||
httpx_mock.add_response(
|
httpx_mock.add_response(
|
||||||
@@ -209,19 +223,263 @@ async def test_health_ok(httpx_mock: HTTPXMock, client: HyperliquidClient):
|
|||||||
assert result["testnet"] is True
|
assert result["testnet"] is True
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
# ── Write endpoints (signed via EIP-712) ───────────────────────
|
||||||
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
|
@pytest.mark.asyncio
|
||||||
client._exchange = None
|
async def test_place_order_limit(httpx_mock: HTTPXMock, client: HyperliquidClient):
|
||||||
try:
|
"""Limit order: signs and POSTs to /exchange with correct payload shape."""
|
||||||
result = await client.place_order("BTC", "buy", 0.1, price=50000.0)
|
# 1. /info type=meta per asset id
|
||||||
# Should return error dict or raise RuntimeError
|
httpx_mock.add_response(
|
||||||
assert "error" in result or result.get("status") == "error"
|
url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"),
|
||||||
except RuntimeError as exc:
|
json=META,
|
||||||
assert "not installed" in str(exc).lower() or "sdk" in str(exc).lower()
|
)
|
||||||
finally:
|
# 2. /exchange firmato
|
||||||
mod._SDK_AVAILABLE = original
|
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()
|
||||||
|
|||||||
@@ -141,9 +141,12 @@ version = "2.0.0"
|
|||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "alpaca-py" },
|
{ name = "alpaca-py" },
|
||||||
|
{ name = "eth-account" },
|
||||||
|
{ name = "eth-utils" },
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
{ name = "hyperliquid-python-sdk" },
|
{ name = "hyperliquid-python-sdk" },
|
||||||
|
{ name = "msgpack" },
|
||||||
{ name = "numpy" },
|
{ name = "numpy" },
|
||||||
{ name = "pandas" },
|
{ name = "pandas" },
|
||||||
{ name = "pybit" },
|
{ name = "pybit" },
|
||||||
@@ -168,9 +171,12 @@ dev = [
|
|||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "alpaca-py", specifier = ">=0.30" },
|
{ 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 = "fastapi", specifier = ">=0.115" },
|
||||||
{ name = "httpx", specifier = ">=0.27" },
|
{ name = "httpx", specifier = ">=0.27" },
|
||||||
{ name = "hyperliquid-python-sdk", specifier = ">=0.6" },
|
{ name = "hyperliquid-python-sdk", specifier = ">=0.6" },
|
||||||
|
{ name = "msgpack", specifier = ">=1.1.2" },
|
||||||
{ name = "numpy", specifier = ">=1.26" },
|
{ name = "numpy", specifier = ">=1.26" },
|
||||||
{ name = "pandas", specifier = ">=2.2" },
|
{ name = "pandas", specifier = ">=2.2" },
|
||||||
{ name = "pybit", specifier = ">=5.7" },
|
{ name = "pybit", specifier = ">=5.7" },
|
||||||
|
|||||||
Reference in New Issue
Block a user