From fe7f8a152bc1a25c6e4755497fa0356cea88ac83 Mon Sep 17 00:00:00 2001 From: Adriano Date: Sat, 20 Jun 2026 12:07:02 +0000 Subject: [PATCH] fix(hyperliquid): page+trim get_historical past 5000-candle cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../exchanges/hyperliquid/client.py | 63 +++++++++++++++++-- .../unit/exchanges/hyperliquid/test_client.py | 63 ++++++++++++++++++- 2 files changed, 119 insertions(+), 7 deletions(-) diff --git a/src/cerbero_mcp/exchanges/hyperliquid/client.py b/src/cerbero_mcp/exchanges/hyperliquid/client.py index c88eaa6..5caaed7 100644 --- a/src/cerbero_mcp/exchanges/hyperliquid/client.py +++ b/src/cerbero_mcp/exchanges/hyperliquid/client.py @@ -41,6 +41,24 @@ RESOLUTION_MAP = { "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). DEFAULT_SLIPPAGE = 0.05 @@ -393,10 +411,46 @@ class HyperliquidClient: async def get_historical( self, instrument: str, start_date: str, end_date: str, resolution: str = "1h" ) -> 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) end_ms = _to_ms(end_date) 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( { "type": "candleSnapshot", @@ -408,7 +462,7 @@ class HyperliquidClient: }, } ) - candles = validate_candles([ + return [ { "timestamp": c.get("t"), "open": c.get("o"), @@ -417,9 +471,8 @@ class HyperliquidClient: "close": c.get("c"), "volume": c.get("v"), } - for c in data - ]) - return {"candles": candles} + for c in (data or []) + ] async def get_open_orders(self) -> list[dict[str, Any]]: """Get all open orders for the wallet.""" diff --git a/tests/unit/exchanges/hyperliquid/test_client.py b/tests/unit/exchanges/hyperliquid/test_client.py index f7ed7d5..f5dea3c 100644 --- a/tests/unit/exchanges/hyperliquid/test_client.py +++ b/tests/unit/exchanges/hyperliquid/test_client.py @@ -1,11 +1,18 @@ from __future__ import annotations +import json import re import pytest -from cerbero_mcp.exchanges.hyperliquid.client import HyperliquidClient 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 @@ -201,10 +208,11 @@ async def test_get_open_orders(httpx_mock: HTTPXMock, client: HyperliquidClient) @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": 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") @@ -212,6 +220,57 @@ async def test_get_historical(httpx_mock: HTTPXMock, client: HyperliquidClient): 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(