From 07a687c879a5d2c2421d55bcc6f1a36b5d4236ab Mon Sep 17 00:00:00 2001 From: Martin Kersner Date: Wed, 1 Jul 2026 17:23:58 +0900 Subject: [PATCH 1/4] feat(liquidation): add stats() for liquidation KPI stats (#126) --- datamaxi/datamaxi/liquidation.py | 24 ++++++++++++++++++++++++ docs/liquidation.md | 5 ++++- tests/test_liquidation_open_interest.py | 25 +++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 1 deletion(-) diff --git a/datamaxi/datamaxi/liquidation.py b/datamaxi/datamaxi/liquidation.py index fba19d6..f9c195a 100644 --- a/datamaxi/datamaxi/liquidation.py +++ b/datamaxi/datamaxi/liquidation.py @@ -101,6 +101,30 @@ def map( "liquidation_map", base=base, exchange=exchange, quote=quote ) + def stats( + self, + window: str = "1h", + exchange: Optional[str] = None, + min_volume_usd: Optional[float] = None, + ) -> Dict[str, Any]: + """Liquidation KPI stats over a rolling window. + + `GET /api/v1/liquidation/stats` + + Args: + window (str): Rolling window (``1h``, ``4h``, or ``24h``). + exchange (str): Optional exchange filter. + min_volume_usd (float): Minimum ``VolumeUsd`` filter. + """ + if window not in ("1h", "4h", "24h"): + raise ValueError("window must be one of 1h, 4h, or 24h") + return self.request_endpoint( + "liquidation_stats", + window=window, + exchange=exchange, + min_volume_usd=min_volume_usd, + ) + def symbol_history( self, symbol: str, diff --git a/docs/liquidation.md b/docs/liquidation.md index 771d7ea..023a8c9 100644 --- a/docs/liquidation.md +++ b/docs/liquidation.md @@ -18,6 +18,9 @@ feed = maxi.liquidation.feed(limit=100) # Token x exchange liquidation heatmap over a rolling window heatmap = maxi.liquidation.heatmap(window="1h", topN=10) +# Liquidation KPI stats over a rolling window +stats = maxi.liquidation.stats(window="1h") + # Coinglass-style liquidation map (price x leverage tier) liq_map = maxi.liquidation.map(base="BTC", exchange="binance", quote="USDT") @@ -33,7 +36,7 @@ history = maxi.liquidation.symbol_history( ## Notes -- `heatmap` accepts `window` of `1h`, `4h`, or `24h`; `topN` must be between 1 and 30. +- `heatmap` and `stats` accept `window` of `1h`, `4h`, or `24h`; `heatmap`'s `topN` must be between 1 and 30. - `symbol_history` accepts `interval` of `5m`, `15m`, or `1h` and `window` of `24h`, `72h`, or `7d`. ::: datamaxi.datamaxi.Liquidation diff --git a/tests/test_liquidation_open_interest.py b/tests/test_liquidation_open_interest.py index bd96993..203bd2e 100644 --- a/tests/test_liquidation_open_interest.py +++ b/tests/test_liquidation_open_interest.py @@ -70,6 +70,31 @@ def test_liquidation_feed_forwards_new_params(): assert qs["min_volume_usd"] == ["1000.0"] +@mock_http_response(responses.GET, "/api/v1/liquidation/stats", {"data": {}}) +def test_liquidation_stats_returns_dict(): + assert _liq().stats(window="1h") == {"data": {}} + + +@responses.activate +def test_liquidation_stats_forwards_params(): + responses.add( + responses.GET, + re.compile(".*/api/v1/liquidation/stats.*"), + json={"data": {}}, + status=200, + ) + _liq().stats(window="4h", exchange="binance", min_volume_usd=1000.0) + qs = _qs(responses.calls[0]) + assert qs["window"] == ["4h"] + assert qs["exchange"] == ["binance"] + assert qs["min_volume_usd"] == ["1000.0"] + + +def test_liquidation_stats_invalid_window_raises_value_error(): + with pytest.raises(ValueError): + _liq().stats(window="7d") + + def test_liquidation_invalid_limit_raises_value_error(): with pytest.raises(ValueError): _liq()(exchange="binance", symbol="BTC-USDT", limit=0) From 196cf6395105b1bc3824df73d0e7e73db484518f Mon Sep 17 00:00:00 2001 From: Martin Kersner Date: Wed, 1 Jul 2026 17:24:03 +0900 Subject: [PATCH 2/4] feat: expose margin_borrow + index_price top-level clients (#126) --- datamaxi/datamaxi/__init__.py | 7 ++++ datamaxi/datamaxi/index_price.py | 51 ++++++++++++++++++++++++++++++ datamaxi/datamaxi/margin_borrow.py | 33 +++++++++++++++++++ docs/index-price.md | 28 ++++++++++++++++ docs/margin-borrow.md | 18 +++++++++++ mkdocs.yml | 2 ++ tests/test_index_price.py | 46 +++++++++++++++++++++++++++ tests/test_margin_borrow.py | 43 +++++++++++++++++++++++++ 8 files changed, 228 insertions(+) create mode 100644 datamaxi/datamaxi/index_price.py create mode 100644 datamaxi/datamaxi/margin_borrow.py create mode 100644 docs/index-price.md create mode 100644 docs/margin-borrow.md create mode 100644 tests/test_index_price.py create mode 100644 tests/test_margin_borrow.py diff --git a/datamaxi/datamaxi/__init__.py b/datamaxi/datamaxi/__init__.py index cfd8b8d..eb26ca3 100644 --- a/datamaxi/datamaxi/__init__.py +++ b/datamaxi/datamaxi/__init__.py @@ -6,6 +6,8 @@ from datamaxi.datamaxi.premium import Premium from datamaxi.datamaxi.liquidation import Liquidation from datamaxi.datamaxi.open_interest import OpenInterest +from datamaxi.datamaxi.margin_borrow import MarginBorrow +from datamaxi.datamaxi.index_price import IndexPrice from datamaxi.datamaxi.cex_candle import CexCandle # used in documentation # noqa:F401 from datamaxi.datamaxi.cex_ticker import ( # used in documentation # noqa:F401 CexTicker, @@ -52,3 +54,8 @@ def __init__(self, api_key=None, **kwargs: Any): # (`datamaxi::generated::{Liquidation, OpenInterest}`). self.liquidation = Liquidation(api_key, **kwargs) self.open_interest = OpenInterest(api_key, **kwargs) + # Standalone `/api/v1/{margin-borrow,index-price}` top-level paths — + # callable clients reached via `client.margin_borrow(...)` / + # `client.index_price(...)`, mirroring the REST path grouping. + self.margin_borrow = MarginBorrow(api_key, **kwargs) + self.index_price = IndexPrice(api_key, **kwargs) diff --git a/datamaxi/datamaxi/index_price.py b/datamaxi/datamaxi/index_price.py new file mode 100644 index 0000000..d70d972 --- /dev/null +++ b/datamaxi/datamaxi/index_price.py @@ -0,0 +1,51 @@ +from typing import Any, Dict, Optional +from datamaxi.api import API +from datamaxi.lib.utils import check_required_parameter + + +class IndexPrice(API): + """Client to fetch historical index price data from DataMaxi+ API.""" + + def __init__(self, api_key=None, **kwargs: Any): + """Initialize index price client. + + Args: + api_key (str): The DataMaxi+ API key + **kwargs: Keyword arguments used by `datamaxi.api.API`. + """ + super().__init__(api_key, **kwargs) + + self.__module__ = __name__ + self.__qualname__ = self.__class__.__qualname__ + + def __call__( + self, + asset: str, + from_: Optional[str] = None, + to: Optional[str] = None, + interval: str = "5m", + ) -> Dict[str, Any]: + """Fetch historical index price data for a single asset. + + `GET /api/v1/index-price` + + Args: + asset (str): Asset (e.g. ``BTC``). + from_ (str): Start time. Defaults to ``now - 1 month``. + to (str): End time. Defaults to ``now``. + interval (str): Sampling interval (default ``5m``). + + Note: + ``from_`` is named with a trailing underscore because ``from`` + is a Python keyword. The wire-level query param remains ``from``. + + Returns: + Historical index price response. + """ + check_required_parameter(asset, "asset") + return self.request_endpoint( + "index_price", + asset=asset, + interval=interval, + **{"from": from_, "to": to}, + ) diff --git a/datamaxi/datamaxi/margin_borrow.py b/datamaxi/datamaxi/margin_borrow.py new file mode 100644 index 0000000..e1e8c78 --- /dev/null +++ b/datamaxi/datamaxi/margin_borrow.py @@ -0,0 +1,33 @@ +from typing import Any, Dict +from datamaxi.api import API +from datamaxi.lib.utils import check_required_parameter + + +class MarginBorrow(API): + """Client to fetch margin borrow data from DataMaxi+ API.""" + + def __init__(self, api_key=None, **kwargs: Any): + """Initialize margin borrow client. + + Args: + api_key (str): The DataMaxi+ API key + **kwargs: Keyword arguments used by `datamaxi.api.API`. + """ + super().__init__(api_key, **kwargs) + + self.__module__ = __name__ + self.__qualname__ = self.__class__.__qualname__ + + def __call__(self, asset: str) -> Dict[str, Any]: + """Fetch margin borrow data for a single asset. + + `GET /api/v1/margin-borrow` + + Args: + asset (str): Token base asset (e.g. ``BTC``). + + Returns: + Margin borrow response. + """ + check_required_parameter(asset, "asset") + return self.request_endpoint("margin_borrow", asset=asset) diff --git a/docs/index-price.md b/docs/index-price.md new file mode 100644 index 0000000..6221af2 --- /dev/null +++ b/docs/index-price.md @@ -0,0 +1,28 @@ +# Index Price + +Historical index price time series for a single asset. + +## Usage + +```python +from datamaxi import Datamaxi + +maxi = Datamaxi(api_key="YOUR_API_KEY") + +data = maxi.index_price( + asset="BTC", + from_="now - 1 month", + to="now", + interval="5m", +) +``` + +## Notes + +- `from_` is spelled with a trailing underscore because `from` is a Python + keyword; the wire-level query param remains `from`. + +::: datamaxi.datamaxi.IndexPrice + options: + show_submodules: true + show_source: false diff --git a/docs/margin-borrow.md b/docs/margin-borrow.md new file mode 100644 index 0000000..8732a0f --- /dev/null +++ b/docs/margin-borrow.md @@ -0,0 +1,18 @@ +# Margin Borrow + +Margin borrow data for a single asset. + +## Usage + +```python +from datamaxi import Datamaxi + +maxi = Datamaxi(api_key="YOUR_API_KEY") + +data = maxi.margin_borrow(asset="BTC") +``` + +::: datamaxi.datamaxi.MarginBorrow + options: + show_submodules: true + show_source: false diff --git a/mkdocs.yml b/mkdocs.yml index 49a9354..d0ea8b6 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -42,6 +42,8 @@ nav: - Funding Rate: funding-rate.md - Liquidation: liquidation.md - Open Interest: open-interest.md + - Margin Borrow: margin-borrow.md + - Index Price: index-price.md - Premium: premium.md - Forex: forex.md - Naver Trend: naver-trend.md diff --git a/tests/test_index_price.py b/tests/test_index_price.py new file mode 100644 index 0000000..c60b514 --- /dev/null +++ b/tests/test_index_price.py @@ -0,0 +1,46 @@ +"""Local (mocked) tests for the IndexPrice client.""" + +import re +import responses +import pytest +from urllib.parse import urlparse, parse_qs + +from datamaxi.datamaxi.index_price import IndexPrice +from datamaxi.error import ParameterRequiredError +from tests.util import mock_http_response + +BASE_URL = "https://api.datamaxiplus.com" + + +def _ip(): + return IndexPrice(api_key="key", base_url=BASE_URL) + + +def _qs(call): + return parse_qs(urlparse(call.request.url).query) + + +@mock_http_response(responses.GET, "/api/v1/index-price", {"data": []}) +def test_index_price_returns_dict(): + assert _ip()(asset="BTC") == {"data": []} + + +@responses.activate +def test_index_price_forwards_params_with_from_to(): + responses.add( + responses.GET, + re.compile(".*/api/v1/index-price.*"), + json={"data": []}, + status=200, + ) + _ip()(asset="BTC", from_="2024-01-01", to="2024-02-01", interval="15m") + qs = _qs(responses.calls[0]) + assert qs["asset"] == ["BTC"] + assert qs["from"] == ["2024-01-01"] + assert qs["to"] == ["2024-02-01"] + assert qs["interval"] == ["15m"] + + +def test_index_price_missing_asset_raises(): + with pytest.raises(ParameterRequiredError): + _ip()(asset="") diff --git a/tests/test_margin_borrow.py b/tests/test_margin_borrow.py new file mode 100644 index 0000000..31b7aa8 --- /dev/null +++ b/tests/test_margin_borrow.py @@ -0,0 +1,43 @@ +"""Local (mocked) tests for the MarginBorrow client.""" + +import re +import responses +import pytest +from urllib.parse import urlparse, parse_qs + +from datamaxi.datamaxi.margin_borrow import MarginBorrow +from datamaxi.error import ParameterRequiredError +from tests.util import mock_http_response + +BASE_URL = "https://api.datamaxiplus.com" + + +def _mb(): + return MarginBorrow(api_key="key", base_url=BASE_URL) + + +def _qs(call): + return parse_qs(urlparse(call.request.url).query) + + +@mock_http_response(responses.GET, "/api/v1/margin-borrow", {"data": {}}) +def test_margin_borrow_returns_dict(): + assert _mb()(asset="BTC") == {"data": {}} + + +@responses.activate +def test_margin_borrow_forwards_asset(): + responses.add( + responses.GET, + re.compile(".*/api/v1/margin-borrow.*"), + json={"data": {}}, + status=200, + ) + _mb()(asset="BTC") + qs = _qs(responses.calls[0]) + assert qs["asset"] == ["BTC"] + + +def test_margin_borrow_missing_asset_raises(): + with pytest.raises(ParameterRequiredError): + _mb()(asset="") From 02c360c7ac6d0c6cfcc16f199b9595c9aafdb39b Mon Sep 17 00:00:00 2001 From: Martin Kersner Date: Wed, 1 Jul 2026 17:24:08 +0900 Subject: [PATCH 3/4] test(audit): drop now-exposed allowlist entries; keep listings_historical excluded (#126) --- tests/test_endpoint_param_coverage.py | 31 ++++++--------------------- 1 file changed, 7 insertions(+), 24 deletions(-) diff --git a/tests/test_endpoint_param_coverage.py b/tests/test_endpoint_param_coverage.py index 2254186..660cc92 100644 --- a/tests/test_endpoint_param_coverage.py +++ b/tests/test_endpoint_param_coverage.py @@ -36,32 +36,15 @@ # allow-listed, so to exempt a whole endpoint every one of its params must be # named here (an empty dict exempts nothing). # -# NOTE FOR HUMAN REVIEW: the four endpoints below have NO client method in the -# SDK at all. Exposing them is not a param forward-through — it needs a brand -# new client method (name, return-shape handling, docs, dedicated tests), which -# is a product/design decision out of scope for this param-coverage audit. -# Tracked for follow-up; see PR body. +# NOTE FOR HUMAN REVIEW: #126 exposed index_price, margin_borrow, and +# liquidation_stats with dedicated client methods (they are no longer here). +# listings_historical remains intentionally SDK-excluded — its param is +# allow-listed below with that rationale. _ALLOWLIST = { - # No client method — needs a new OHLC-style method + response shaping. - "index_price": { - "asset": "no client method yet — needs new Index-Price client", - "from": "no client method yet — needs new Index-Price client", - "to": "no client method yet — needs new Index-Price client", - "interval": "no client method yet — needs new Index-Price client", - }, - # No client method — needs a new Margin-Borrow client. - "margin_borrow": { - "asset": "no client method yet — needs new Margin-Borrow client", - }, - # No client method — needs a new Liquidation.stats() method + shaping. - "liquidation_stats": { - "window": "no client method yet — needs new Liquidation.stats()", - "exchange": "no client method yet — needs new Liquidation.stats()", - "min_volume_usd": "no client method yet — needs new Liquidation.stats()", - }, - # No client method — needs a new Listings.historical() method + shaping. + # Intentionally SDK-excluded (#126): not surfaced in the public data-api. "listings_historical": { - "refresh": "no client method yet — needs new Listings.historical()", + "refresh": "intentionally SDK-excluded — not surfaced in the public " + "data-api (see #126)", }, } From 8df7ad4db852d067ce89926a0321f30a1c9191eb Mon Sep 17 00:00:00 2001 From: Martin Kersner Date: Wed, 1 Jul 2026 17:30:35 +0900 Subject: [PATCH 4/4] style: drop redundant wiring comment in Datamaxi.__init__ (#126) --- datamaxi/datamaxi/__init__.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/datamaxi/datamaxi/__init__.py b/datamaxi/datamaxi/__init__.py index eb26ca3..9212c63 100644 --- a/datamaxi/datamaxi/__init__.py +++ b/datamaxi/datamaxi/__init__.py @@ -54,8 +54,5 @@ def __init__(self, api_key=None, **kwargs: Any): # (`datamaxi::generated::{Liquidation, OpenInterest}`). self.liquidation = Liquidation(api_key, **kwargs) self.open_interest = OpenInterest(api_key, **kwargs) - # Standalone `/api/v1/{margin-borrow,index-price}` top-level paths — - # callable clients reached via `client.margin_borrow(...)` / - # `client.index_price(...)`, mirroring the REST path grouping. self.margin_borrow = MarginBorrow(api_key, **kwargs) self.index_price = IndexPrice(api_key, **kwargs)