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>
This commit is contained in:
@@ -41,6 +41,24 @@ RESOLUTION_MAP = {
|
|||||||
"1d": "1d",
|
"1d": "1d",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Hyperliquid's candleSnapshot returns at most ~5000 candles per call and, when
|
||||||
|
# the range needs more, keeps the newest slice and silently drops the oldest.
|
||||||
|
# We page through wide ranges in windows sized to this cap so start_date is honored.
|
||||||
|
_MAX_CANDLES_PER_REQUEST = 5000
|
||||||
|
|
||||||
|
# Hard stop on pagination so a tiny resolution over a huge span can't loop forever.
|
||||||
|
_MAX_HISTORY_PAGES = 500
|
||||||
|
|
||||||
|
# Candle interval in milliseconds, keyed by the Hyperliquid interval code.
|
||||||
|
_RESOLUTION_MS = {
|
||||||
|
"1m": 60_000,
|
||||||
|
"5m": 300_000,
|
||||||
|
"15m": 900_000,
|
||||||
|
"1h": 3_600_000,
|
||||||
|
"4h": 14_400_000,
|
||||||
|
"1d": 86_400_000,
|
||||||
|
}
|
||||||
|
|
||||||
# Slippage usato per market order / market_close (parità con SDK).
|
# Slippage usato per market order / market_close (parità con SDK).
|
||||||
DEFAULT_SLIPPAGE = 0.05
|
DEFAULT_SLIPPAGE = 0.05
|
||||||
|
|
||||||
@@ -393,10 +411,46 @@ class HyperliquidClient:
|
|||||||
async def get_historical(
|
async def get_historical(
|
||||||
self, instrument: str, start_date: str, end_date: str, resolution: str = "1h"
|
self, instrument: str, start_date: str, end_date: str, resolution: str = "1h"
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Get OHLCV candles for an asset."""
|
"""Get OHLCV candles for an asset.
|
||||||
|
|
||||||
|
``candleSnapshot`` caps each response at ``_MAX_CANDLES_PER_REQUEST``
|
||||||
|
candles and keeps the newest slice, so a wide range is paged in windows
|
||||||
|
sized to that cap and each page is trimmed back to ``[start_ms, end_ms]``
|
||||||
|
to keep start_date honored.
|
||||||
|
"""
|
||||||
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)
|
||||||
|
interval_ms = _RESOLUTION_MS.get(interval)
|
||||||
|
|
||||||
|
by_ts: dict[int, dict[str, Any]] = {}
|
||||||
|
if interval_ms and end_ms >= start_ms:
|
||||||
|
window_span = interval_ms * _MAX_CANDLES_PER_REQUEST
|
||||||
|
cursor = start_ms
|
||||||
|
pages = 0
|
||||||
|
while cursor <= end_ms and pages < _MAX_HISTORY_PAGES:
|
||||||
|
window_end = min(cursor + window_span - interval_ms, end_ms)
|
||||||
|
for c in await self._fetch_candle_window(
|
||||||
|
instrument, interval, cursor, window_end
|
||||||
|
):
|
||||||
|
if start_ms <= c["timestamp"] <= end_ms:
|
||||||
|
by_ts[c["timestamp"]] = c
|
||||||
|
pages += 1
|
||||||
|
if window_end >= end_ms:
|
||||||
|
break
|
||||||
|
cursor = window_end + interval_ms
|
||||||
|
else:
|
||||||
|
for c in await self._fetch_candle_window(
|
||||||
|
instrument, interval, start_ms, end_ms
|
||||||
|
):
|
||||||
|
by_ts[c["timestamp"]] = c
|
||||||
|
|
||||||
|
# validate_candles sorts by timestamp, so insertion order is irrelevant.
|
||||||
|
return {"candles": validate_candles(list(by_ts.values()))}
|
||||||
|
|
||||||
|
async def _fetch_candle_window(
|
||||||
|
self, instrument: str, interval: str, start_ms: int, end_ms: int
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
data = await self._post_info(
|
data = await self._post_info(
|
||||||
{
|
{
|
||||||
"type": "candleSnapshot",
|
"type": "candleSnapshot",
|
||||||
@@ -408,7 +462,7 @@ class HyperliquidClient:
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
candles = validate_candles([
|
return [
|
||||||
{
|
{
|
||||||
"timestamp": c.get("t"),
|
"timestamp": c.get("t"),
|
||||||
"open": c.get("o"),
|
"open": c.get("o"),
|
||||||
@@ -417,9 +471,8 @@ class HyperliquidClient:
|
|||||||
"close": c.get("c"),
|
"close": c.get("c"),
|
||||||
"volume": c.get("v"),
|
"volume": c.get("v"),
|
||||||
}
|
}
|
||||||
for c in data
|
for c in (data or [])
|
||||||
])
|
]
|
||||||
return {"candles": candles}
|
|
||||||
|
|
||||||
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."""
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import re
|
import re
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from cerbero_mcp.exchanges.hyperliquid.client import HyperliquidClient
|
|
||||||
from pytest_httpx import HTTPXMock
|
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.
|
# Chiave privata fissa: rende deterministica la firma EIP-712 per i test write.
|
||||||
DUMMY_PRIVATE_KEY = "0x" + "01" * 32
|
DUMMY_PRIVATE_KEY = "0x" + "01" * 32
|
||||||
DUMMY_WALLET = "0x1a642f0E3c3aF545E7AcBD38b07251B3990914F1" # derived from key above
|
DUMMY_WALLET = "0x1a642f0E3c3aF545E7AcBD38b07251B3990914F1" # derived from key above
|
||||||
@@ -201,10 +208,11 @@ async def test_get_open_orders(httpx_mock: HTTPXMock, client: HyperliquidClient)
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_historical(httpx_mock: HTTPXMock, client: HyperliquidClient):
|
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(
|
httpx_mock.add_response(
|
||||||
url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"),
|
url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"),
|
||||||
json=[
|
json=[
|
||||||
{"t": 1000000, "o": "49000", "h": "51000", "l": "48500", "c": "50000", "v": "100"},
|
{"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")
|
result = await client.get_historical("BTC", "2024-01-01", "2024-01-02", "1h")
|
||||||
@@ -212,6 +220,57 @@ async def test_get_historical(httpx_mock: HTTPXMock, client: HyperliquidClient):
|
|||||||
assert result["candles"][0]["close"] == 50000.0
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_health_ok(httpx_mock: HTTPXMock, client: HyperliquidClient):
|
async def test_health_ok(httpx_mock: HTTPXMock, client: HyperliquidClient):
|
||||||
httpx_mock.add_response(
|
httpx_mock.add_response(
|
||||||
|
|||||||
Reference in New Issue
Block a user