Files
Cerbero-mcp/tests/unit/exchanges/hyperliquid/test_client.py
T
Adriano fe7f8a152b fix(hyperliquid): page+trim get_historical past 5000-candle cap
candleSnapshot caps each response at ~5000 candles and keeps the newest
slice, silently dropping older candles when the range needs more — so a
wide start_date was not honored. Mirror Deribit's approach: page the range
in windows of interval_ms * 5000, trim each page back to [start_ms, end_ms],
dedupe by timestamp, with a 500-page hard stop. Resolutions without a known
interval fall back to the original single-call path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 12:07:02 +00:00

551 lines
18 KiB
Python

from __future__ import annotations
import json
import re
import pytest
from pytest_httpx import HTTPXMock
from cerbero_mcp.exchanges.hyperliquid.client import (
_MAX_CANDLES_PER_REQUEST,
_RESOLUTION_MS,
HyperliquidClient,
_to_ms,
)
# 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):
ts = _to_ms("2024-01-01") + _RESOLUTION_MS["1h"] # one candle inside the range
httpx_mock.add_response(
url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"),
json=[
{"t": ts, "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_get_historical_trims_out_of_range(
httpx_mock: HTTPXMock, client: HyperliquidClient
):
# candleSnapshot can return candles outside the requested window; trim them.
in_range = _to_ms("2024-01-01") + _RESOLUTION_MS["1h"]
before = _to_ms("2024-01-01") - _RESOLUTION_MS["1h"]
httpx_mock.add_response(
url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"),
json=[
{"t": before, "o": "1", "h": "1", "l": "1", "c": "1", "v": "1"},
{"t": in_range, "o": "49000", "h": "51000", "l": "48500", "c": "50000",
"v": "100"},
],
)
result = await client.get_historical("BTC", "2024-01-01", "2024-01-02", "1h")
assert [c["timestamp"] for c in result["candles"]] == [in_range]
@pytest.mark.asyncio
async def test_get_historical_pages_wide_range(
httpx_mock: HTTPXMock, client: HyperliquidClient
):
# A span wider than the per-call cap must be paged across multiple requests.
interval_ms = _RESOLUTION_MS["1h"]
window_span = interval_ms * _MAX_CANDLES_PER_REQUEST
start_ms = _to_ms("2024-01-01")
page1_ts = start_ms + interval_ms
page2_ts = start_ms + window_span # start of the second window
httpx_mock.add_response(
url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"),
json=[
{"t": page1_ts, "o": "100", "h": "110", "l": "90", "c": "105", "v": "1"},
],
)
httpx_mock.add_response(
url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"),
json=[
{"t": page2_ts, "o": "200", "h": "210", "l": "190", "c": "205", "v": "1"},
],
)
# 2024 is a leap year (366 d = 8784 h > 5000), so the range spans 2 windows.
result = await client.get_historical("BTC", "2024-01-01", "2025-01-01", "1h")
requests = httpx_mock.get_requests()
assert len(requests) == 2
starts = [json.loads(r.read())["req"]["startTime"] for r in requests]
assert starts[1] > starts[0] # paging advanced the cursor
assert [c["timestamp"] for c in result["candles"]] == [page1_ts, page2_ts]
@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"}
# r/s are serialized with eth_utils.to_hex (same as the Hyperliquid SDK),
# which emits minimal-length hex: a leading zero byte yields < 66 chars
# (~1/256 of signatures). Assert a valid <= 32-byte big-endian int, not a
# fixed length.
assert sig["r"].startswith("0x") and 2 < len(sig["r"]) <= 66
assert sig["s"].startswith("0x") and 2 < len(sig["s"]) <= 66
assert 0 < int(sig["r"], 16) < 2**256
assert 0 < int(sig["s"], 16) < 2**256
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()