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:
@@ -71,6 +71,18 @@ richiede `X-Bot-Tag`.
|
|||||||
- `get_historical` (chiave `candles` uniforme)
|
- `get_historical` (chiave `candles` uniforme)
|
||||||
- `get_trade_history`
|
- `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
|
### Account
|
||||||
- `get_positions`
|
- `get_positions`
|
||||||
- `get_account_summary`
|
- `get_account_summary`
|
||||||
|
|||||||
@@ -44,6 +44,53 @@ RESOLUTION_MAP = {
|
|||||||
"1d": "1D",
|
"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):
|
class DeribitAuthError(Exception):
|
||||||
"""Deribit auth failed (bad credentials, missing scope, env mismatch)."""
|
"""Deribit auth failed (bad credentials, missing scope, env mismatch)."""
|
||||||
@@ -397,19 +444,39 @@ class DeribitClient:
|
|||||||
async def get_historical(
|
async def get_historical(
|
||||||
self, instrument: str, start_date: str, end_date: str, resolution: str
|
self, instrument: str, start_date: str, end_date: str, resolution: str
|
||||||
) -> dict:
|
) -> dict:
|
||||||
# Convert ISO date strings to millisecond timestamps
|
start_ms = _date_to_ms(start_date, boundary="start")
|
||||||
import datetime as _dt
|
end_ms = _date_to_ms(end_date, boundary="end")
|
||||||
|
|
||||||
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)
|
|
||||||
res = RESOLUTION_MAP.get(resolution, resolution)
|
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(
|
raw = await self._request(
|
||||||
"public/get_tradingview_chart_data",
|
"public/get_tradingview_chart_data",
|
||||||
{
|
{
|
||||||
@@ -427,7 +494,7 @@ class DeribitClient:
|
|||||||
closes = r.get("close", []) or []
|
closes = r.get("close", []) or []
|
||||||
volumes = r.get("volume", []) or []
|
volumes = r.get("volume", []) or []
|
||||||
n = min(len(ticks), len(opens), len(highs), len(lows), len(closes), len(volumes))
|
n = min(len(ticks), len(opens), len(highs), len(lows), len(closes), len(volumes))
|
||||||
candles = validate_candles([
|
return [
|
||||||
{
|
{
|
||||||
"timestamp": ticks[i],
|
"timestamp": ticks[i],
|
||||||
"open": opens[i],
|
"open": opens[i],
|
||||||
@@ -437,8 +504,7 @@ class DeribitClient:
|
|||||||
"volume": volumes[i],
|
"volume": volumes[i],
|
||||||
}
|
}
|
||||||
for i in range(n)
|
for i in range(n)
|
||||||
])
|
]
|
||||||
return {"candles": candles}
|
|
||||||
|
|
||||||
async def get_dvol(
|
async def get_dvol(
|
||||||
self,
|
self,
|
||||||
@@ -447,17 +513,8 @@ class DeribitClient:
|
|||||||
end_date: str,
|
end_date: str,
|
||||||
resolution: str = "1D",
|
resolution: str = "1D",
|
||||||
) -> dict:
|
) -> dict:
|
||||||
import datetime as _dt
|
start_ms = _date_to_ms(start_date, boundary="start")
|
||||||
|
end_ms = _date_to_ms(end_date, boundary="end")
|
||||||
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)
|
|
||||||
res = RESOLUTION_MAP.get(resolution, resolution)
|
res = RESOLUTION_MAP.get(resolution, resolution)
|
||||||
raw = await self._request(
|
raw = await self._request(
|
||||||
"public/get_volatility_index_data",
|
"public/get_volatility_index_data",
|
||||||
|
|||||||
@@ -177,6 +177,140 @@ async def test_upstream_5xx_raises_clean_http_error(
|
|||||||
assert "Deribit upstream" in str(exc_info.value.detail)
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_private_call_with_bad_auth_returns_error_envelope(
|
async def test_private_call_with_bad_auth_returns_error_envelope(
|
||||||
httpx_mock: HTTPXMock, client: DeribitClient
|
httpx_mock: HTTPXMock, client: DeribitClient
|
||||||
|
|||||||
Reference in New Issue
Block a user