Files
fxhnt/tests/integration/test_bybit_open_interest.py

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}