diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index a0111f7..2a5849a 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -71,6 +71,18 @@ richiede `X-Bot-Tag`. - `get_historical` (chiave `candles` uniforme) - `get_trade_history` +> **`get_historical` — semantica di `start_date` / `end_date`** (vale anche per +> `get_dvol` e `get_technical_indicators`). Tutti i timestamp sono in **UTC**. +> - Date nude `YYYY-MM-DD`: `start_date` = `00:00:00`, `end_date` = +> `23:59:59.999` dello stesso giorno (inclusivo dell'intero giorno). Quindi +> `end_date = oggi` restituisce l'intraday fino all'ultima candela chiusa, non +> solo fino a mezzanotte. +> - Sono accettati anche timestamp con orario (`2026-05-28T14:00:00`), onorati +> così come sono per finestre intraday precise; un valore *naive* è trattato +> come UTC. +> - Nessun cap a ~5000 righe: i range ampi sono **paginati** internamente +> finestra-per-finestra, così `start_date` è sempre rispettato. + ### Account - `get_positions` - `get_account_summary` diff --git a/src/cerbero_mcp/exchanges/deribit/client.py b/src/cerbero_mcp/exchanges/deribit/client.py index 81c8e0b..8f72bef 100644 --- a/src/cerbero_mcp/exchanges/deribit/client.py +++ b/src/cerbero_mcp/exchanges/deribit/client.py @@ -44,6 +44,53 @@ RESOLUTION_MAP = { "1d": "1D", } +# Deribit's get_tradingview_chart_data returns at most ~5000 candles per call and, +# when the range needs more, silently drops the oldest and keeps the newest slice. +# 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 Deribit resolution code. +_RESOLUTION_MS = { + "1": 60_000, + "3": 180_000, + "5": 300_000, + "10": 600_000, + "15": 900_000, + "30": 1_800_000, + "60": 3_600_000, + "120": 7_200_000, + "180": 10_800_000, + "360": 21_600_000, + "720": 43_200_000, + "1D": 86_400_000, +} + + +def _date_to_ms(date_str: str, *, boundary: str) -> int: + """Parse a date/datetime string to an epoch-millisecond UTC timestamp. + + Bare dates (YYYY-MM-DD) are anchored in UTC: a ``start`` boundary maps to + 00:00:00 of that day, an ``end`` boundary to 23:59:59.999 so the whole day's + intraday candles are included (asking end_date=today returns data up to now). + Strings carrying an explicit time are honored as given (naive values are UTC). + """ + import datetime as _dt + + s = date_str.strip() + has_time = "t" in s.lower() or " " in s + try: + dt = _dt.datetime.fromisoformat(s) + except ValueError: + dt = _dt.datetime.strptime(s, "%Y-%m-%d") + if dt.tzinfo is None: + dt = dt.replace(tzinfo=_dt.UTC) + if boundary == "end" and not has_time: + dt = dt.replace(hour=23, minute=59, second=59, microsecond=999_000) + return int(dt.timestamp() * 1000) + class DeribitAuthError(Exception): """Deribit auth failed (bad credentials, missing scope, env mismatch).""" @@ -397,19 +444,39 @@ class DeribitClient: async def get_historical( self, instrument: str, start_date: str, end_date: str, resolution: str ) -> dict: - # Convert ISO date strings to millisecond timestamps - import datetime as _dt - - def _to_ms(date_str: str) -> int: - try: - dt = _dt.datetime.fromisoformat(date_str) - except ValueError: - dt = _dt.datetime.strptime(date_str, "%Y-%m-%d") - return int(dt.timestamp() * 1000) - - start_ms = _to_ms(start_date) - end_ms = _to_ms(end_date) + start_ms = _date_to_ms(start_date, boundary="start") + end_ms = _date_to_ms(end_date, boundary="end") res = RESOLUTION_MAP.get(resolution, resolution) + interval_ms = _RESOLUTION_MS.get(res) + + by_ts: dict[int, dict] = {} + if interval_ms and end_ms >= start_ms: + # Page the range in fixed windows of <= _MAX_CANDLES_PER_REQUEST so + # Deribit's per-call cap can't silently truncate older candles. + 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_chart_window( + instrument, cursor, window_end, res + ): + 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_chart_window(instrument, start_ms, end_ms, res): + 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_chart_window( + self, instrument: str, start_ms: int, end_ms: int, res: str + ) -> list[dict]: raw = await self._request( "public/get_tradingview_chart_data", { @@ -427,7 +494,7 @@ class DeribitClient: closes = r.get("close", []) or [] volumes = r.get("volume", []) or [] n = min(len(ticks), len(opens), len(highs), len(lows), len(closes), len(volumes)) - candles = validate_candles([ + return [ { "timestamp": ticks[i], "open": opens[i], @@ -437,8 +504,7 @@ class DeribitClient: "volume": volumes[i], } for i in range(n) - ]) - return {"candles": candles} + ] async def get_dvol( self, @@ -447,17 +513,8 @@ class DeribitClient: end_date: str, resolution: str = "1D", ) -> dict: - import datetime as _dt - - def _to_ms(date_str: str) -> int: - try: - dt = _dt.datetime.fromisoformat(date_str) - except ValueError: - dt = _dt.datetime.strptime(date_str, "%Y-%m-%d") - return int(dt.timestamp() * 1000) - - start_ms = _to_ms(start_date) - end_ms = _to_ms(end_date) + start_ms = _date_to_ms(start_date, boundary="start") + end_ms = _date_to_ms(end_date, boundary="end") res = RESOLUTION_MAP.get(resolution, resolution) raw = await self._request( "public/get_volatility_index_data", diff --git a/tests/unit/exchanges/deribit/test_client.py b/tests/unit/exchanges/deribit/test_client.py index 1092eda..6ed2f5f 100644 --- a/tests/unit/exchanges/deribit/test_client.py +++ b/tests/unit/exchanges/deribit/test_client.py @@ -177,6 +177,140 @@ async def test_upstream_5xx_raises_clean_http_error( assert "Deribit upstream" in str(exc_info.value.detail) +@pytest.mark.asyncio +async def test_get_historical_end_date_is_day_inclusive( + httpx_mock: HTTPXMock, client: DeribitClient +): + """Bare end_date covers the whole UTC day (23:59:59.999), not just midnight, + so 'end_date = today' returns intraday up to now instead of stopping at 00:00.""" + import datetime as dt + + start = dt.datetime(2026, 5, 20, tzinfo=dt.UTC) + start_ms = int(start.timestamp() * 1000) + httpx_mock.add_response( + url=re.compile(r"https://test\.deribit\.com/api/v2/public/get_tradingview_chart_data"), + json={ + "result": { + "ticks": [start_ms, start_ms + 3_600_000], + "open": [100.0, 100.5], + "high": [101.0, 101.5], + "low": [99.0, 99.5], + "close": [100.5, 101.0], + "volume": [10.0, 11.0], + } + }, + ) + result = await client.get_historical( + instrument="BTC-PERPETUAL", + start_date="2026-05-20", + end_date="2026-05-28", + resolution="1h", + ) + assert len(result["candles"]) == 2 + + expected_start = start_ms + expected_end = int( + dt.datetime(2026, 5, 28, 23, 59, 59, 999_000, tzinfo=dt.UTC).timestamp() + * 1000 + ) + params = httpx_mock.get_requests()[0].url.params + assert int(params["start_timestamp"]) == expected_start + assert int(params["end_timestamp"]) == expected_end + + +@pytest.mark.asyncio +async def test_get_historical_explicit_time_is_honored( + httpx_mock: HTTPXMock, client: DeribitClient +): + """An end_date carrying an explicit time is passed through as-is, not bumped + to end-of-day — clients can request precise intraday windows.""" + import datetime as dt + + httpx_mock.add_response( + url=re.compile(r"https://test\.deribit\.com/api/v2/public/get_tradingview_chart_data"), + json={"result": {"ticks": [], "open": [], "high": [], "low": [], + "close": [], "volume": []}}, + ) + await client.get_historical( + instrument="BTC-PERPETUAL", + start_date="2026-05-28T09:00:00", + end_date="2026-05-28T14:00:00", + resolution="1h", + ) + params = httpx_mock.get_requests()[0].url.params + expected_end = int( + dt.datetime(2026, 5, 28, 14, 0, 0, tzinfo=dt.UTC).timestamp() * 1000 + ) + assert int(params["end_timestamp"]) == expected_end + + +@pytest.mark.asyncio +async def test_get_historical_paginates_beyond_cap( + httpx_mock: HTTPXMock, client: DeribitClient +): + """A range wider than Deribit's ~5000-candle cap is paged window-by-window so + start_date is honored instead of being silently dropped.""" + import datetime as dt + + import httpx + + interval = 3_600_000 # 1h + + def chart_callback(request: httpx.Request) -> httpx.Response: + params = request.url.params + start = int(params["start_timestamp"]) + end = int(params["end_timestamp"]) + ticks = list(range(start, end + 1, interval)) + # Mimic Deribit truncating to the most recent slice if a single call + # ever exceeds the cap (it shouldn't, because windows are sized to fit). + ticks = ticks[-5000:] + n = len(ticks) + return httpx.Response( + 200, + json={ + "result": { + "ticks": ticks, + "open": [100.0] * n, + "high": [101.0] * n, + "low": [99.0] * n, + "close": [100.5] * n, + "volume": [10.0] * n, + } + }, + ) + + httpx_mock.add_callback( + chart_callback, + url=re.compile(r"https://test\.deribit\.com/api/v2/public/get_tradingview_chart_data"), + is_reusable=True, + ) + + result = await client.get_historical( + instrument="BTC-PERPETUAL", + start_date="2025-01-01", + end_date="2025-12-30", + resolution="1h", + ) + candles = result["candles"] + # ~363 days * 24h ≈ 8712 candles → more than one ~5000 window. + assert len(candles) > 5000 + assert len(httpx_mock.get_requests()) >= 2 + + expected_start = int( + dt.datetime(2025, 1, 1, tzinfo=dt.UTC).timestamp() * 1000 + ) + expected_end_bound = int( + dt.datetime(2025, 12, 30, 23, 59, 59, 999_000, tzinfo=dt.UTC).timestamp() + * 1000 + ) + assert candles[0]["timestamp"] == expected_start + assert candles[-1]["timestamp"] <= expected_end_bound + # No duplicate timestamps across window boundaries. + tss = [c["timestamp"] for c in candles] + assert len(tss) == len(set(tss)) + assert tss == sorted(tss) + + @pytest.mark.asyncio async def test_private_call_with_bad_auth_returns_error_envelope( httpx_mock: HTTPXMock, client: DeribitClient