120 lines
4.6 KiB
Python
120 lines
4.6 KiB
Python
"""BybitOpenInterest adapter — the OPEN INTEREST (deleveraging-flow vehicle) from Bybit v5 market data.
|
|
|
|
ALL HTTP is injected/mocked — no network in these tests. The adapter takes a `fetch(url)->dict` callable
|
|
(the JSON endpoint), so the tests drive it with canned paginated responses and assert: the {epoch_day:
|
|
open_interest} mapping, backward pagination via endTime to a floor, the floor stop, dedup, and rate-limit
|
|
backoff.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from fxhnt.adapters.data.bybit_open_interest import BYBIT_OI_FLOOR_MS, BybitOpenInterest
|
|
|
|
_DAY_MS = 86_400_000
|
|
|
|
|
|
def _oi_row(ts_ms: int, oi: float) -> dict:
|
|
# open-interest rows carry openInterest + a ms timestamp.
|
|
return {"openInterest": str(oi), "timestamp": str(ts_ms)}
|
|
|
|
|
|
class _PagedOI:
|
|
"""Mock of /v5/market/open-interest: serves rows newest-first, honoring the `endTime` cursor exactly like
|
|
Bybit (returns up to `limit` rows with timestamp <= endTime, descending). When the caller walks endTime
|
|
back past the oldest row, it returns an empty list (stops the loop)."""
|
|
|
|
def __init__(self, rows: list[dict], *, limit: int = 200) -> None:
|
|
self._rows = sorted(rows, key=lambda r: int(r["timestamp"]), reverse=True)
|
|
self._limit = limit
|
|
self.calls: list[int | None] = []
|
|
|
|
def fetch(self, url: str) -> dict:
|
|
end = _parse_qs_int(url, "endTime")
|
|
self.calls.append(end)
|
|
cand = self._rows if end is None else [r for r in self._rows
|
|
if int(r["timestamp"]) <= end]
|
|
page = cand[: self._limit]
|
|
return {"retCode": 0, "result": {"list": page}}
|
|
|
|
|
|
def _parse_qs_int(url: str, key: str) -> int | None:
|
|
from urllib.parse import parse_qs, urlparse
|
|
q = parse_qs(urlparse(url).query)
|
|
return int(q[key][0]) if key in q else None
|
|
|
|
|
|
def test_oi_history_maps_epoch_day_to_open_interest_and_paginates_backward():
|
|
# intervalTime=1d → one row per day. Force paging with limit=2 across 4 days.
|
|
rows = [
|
|
_oi_row(100 * _DAY_MS, 1_000_000.0),
|
|
_oi_row(99 * _DAY_MS, 900_000.0),
|
|
_oi_row(98 * _DAY_MS, 1_100_000.0),
|
|
_oi_row(97 * _DAY_MS, 1_050_000.0),
|
|
]
|
|
paged = _PagedOI(rows, limit=2)
|
|
bb = BybitOpenInterest(fetch=paged.fetch, sleep=lambda _: None)
|
|
daily = bb.oi_history("BTCUSDT", start_ms=0) # tiny synthetic epoch-days < the floor
|
|
assert daily == {100: 1_000_000.0, 99: 900_000.0, 98: 1_100_000.0, 97: 1_050_000.0}
|
|
assert len(paged.calls) >= 2 # it paginated, walking endTime backward
|
|
|
|
|
|
def test_oi_history_interval_1d_in_url():
|
|
seen: list[str] = []
|
|
|
|
def fetch(url: str) -> dict:
|
|
seen.append(url)
|
|
return {"retCode": 0, "result": {"list": []}}
|
|
|
|
bb = BybitOpenInterest(fetch=fetch, sleep=lambda _: None)
|
|
bb.oi_history("BTCUSDT")
|
|
assert "open-interest" in seen[0]
|
|
assert "category=linear" in seen[0]
|
|
assert "intervalTime=1d" in seen[0]
|
|
assert "symbol=BTCUSDT" in seen[0]
|
|
|
|
|
|
def test_oi_history_empty_page_stops_loop():
|
|
paged = _PagedOI([], limit=200)
|
|
bb = BybitOpenInterest(fetch=paged.fetch, sleep=lambda _: None)
|
|
assert bb.oi_history("NEWUSDT") == {}
|
|
assert len(paged.calls) == 1
|
|
|
|
|
|
def test_oi_history_respects_start_ms_floor():
|
|
rows = [_oi_row(10 * _DAY_MS, 5.0), _oi_row(20 * _DAY_MS, 6.0)]
|
|
paged = _PagedOI(rows, limit=200)
|
|
bb = BybitOpenInterest(fetch=paged.fetch, sleep=lambda _: None)
|
|
daily = bb.oi_history("BTCUSDT", start_ms=15 * _DAY_MS)
|
|
assert 20 in daily and 10 not in daily # only the row at/after the floor survives
|
|
|
|
|
|
def test_default_floor_is_2023_01():
|
|
import datetime as dt
|
|
assert int(dt.datetime(2023, 1, 1, tzinfo=dt.UTC).timestamp() * 1000) == BYBIT_OI_FLOOR_MS
|
|
|
|
|
|
def test_oi_history_retries_on_rate_limit_retcode_then_succeeds():
|
|
d = 30 * _DAY_MS
|
|
real = _PagedOI([_oi_row(d, 7.0)], limit=200)
|
|
state = {"n": 0}
|
|
|
|
def flaky(url: str) -> dict:
|
|
state["n"] += 1
|
|
if state["n"] <= 2:
|
|
return {"retCode": 10006, "retMsg": "too many visits", "result": {}}
|
|
return real.fetch(url)
|
|
|
|
waited: list[float] = []
|
|
bb = BybitOpenInterest(fetch=flaky, sleep=waited.append)
|
|
daily = bb.oi_history("BTCUSDT", start_ms=0)
|
|
assert abs(daily[30] - 7.0) < 1e-12
|
|
assert state["n"] >= 3
|
|
assert waited[:2] == [0.5, 1.0] # exponential backoff (no real sleep)
|
|
|
|
|
|
def test_oi_history_dedups_across_page_boundaries():
|
|
rows = [_oi_row(d * _DAY_MS, 0.5 + d) for d in range(50, 55)]
|
|
paged = _PagedOI(rows, limit=2)
|
|
bb = BybitOpenInterest(fetch=paged.fetch, sleep=lambda _: None)
|
|
daily = bb.oi_history("BTCUSDT", start_ms=0)
|
|
assert set(daily) == {50, 51, 52, 53, 54}
|