fix(V2): Deribit get_historical end_date day-inclusive + pagination

end_date as a bare date now covers the whole UTC day (23:59:59.999)
instead of stopping at midnight, so end_date=today returns intraday up
to the last closed candle. Dates are parsed as explicit UTC and inline
timestamps (YYYY-MM-DDTHH:MM:SS) are honored for precise windows.

Wide ranges are paged in <=5000-candle windows so Deribit's per-call
cap can no longer silently drop start_date and the oldest candles.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-05-28 14:25:55 +00:00
parent c5e48bf081
commit eb5f0148ad
3 changed files with 229 additions and 26 deletions
+12
View File
@@ -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`
+83 -26
View File
@@ -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",
+134
View File
@@ -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