fe7f8a152b
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>
551 lines
18 KiB
Python
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()
|