From c6be7d50d6da64e60a9a21f4d013fbb34225e535 Mon Sep 17 00:00:00 2001 From: Martin Kersner Date: Fri, 3 Jul 2026 16:07:34 +0900 Subject: [PATCH] feat: typed response-model pilot for candle + ticker (#141) Closes #141 Add hint-only TypedDicts (CandleRow/CandleResponse, TickerData/TickerResponse) describing the raw pandas=False JSON of the candle and ticker endpoints, and annotate those two methods' return types with them. Fields sourced from the live API docs; typed as str because numeric values arrive as strings on the wire (missing -> "NaN", coerced only on the pandas=True path). Hint-only: no runtime cost, no validation, no behavior change. Exported from the top level so callers can annotate their own code. Deliberately small pilot; other endpoints can be typed the same way incrementally. --- datamaxi/__init__.py | 10 +++++ datamaxi/resources/cex_candle.py | 3 +- datamaxi/resources/cex_ticker.py | 5 ++- datamaxi/resources/responses.py | 58 +++++++++++++++++++++++++++ tests/test_response_models.py | 67 ++++++++++++++++++++++++++++++++ 5 files changed, 140 insertions(+), 3 deletions(-) create mode 100644 datamaxi/resources/responses.py create mode 100644 tests/test_response_models.py diff --git a/datamaxi/__init__.py b/datamaxi/__init__.py index 794be65..d990536 100644 --- a/datamaxi/__init__.py +++ b/datamaxi/__init__.py @@ -20,6 +20,12 @@ Interval, SortOrder, ) +from datamaxi.resources.responses import ( # noqa: F401 + CandleRow, + CandleResponse, + TickerData, + TickerResponse, +) __all__ = [ "Datamaxi", @@ -42,4 +48,8 @@ "Market", "Interval", "SortOrder", + "CandleRow", + "CandleResponse", + "TickerData", + "TickerResponse", ] diff --git a/datamaxi/resources/cex_candle.py b/datamaxi/resources/cex_candle.py index 300412c..6b4e3e4 100644 --- a/datamaxi/resources/cex_candle.py +++ b/datamaxi/resources/cex_candle.py @@ -4,6 +4,7 @@ from datamaxi.lib.utils import check_required_parameter from datamaxi.lib.utils import check_required_parameters from datamaxi.resources.utils import convert_data_to_data_frame +from datamaxi.resources.responses import CandleResponse from datamaxi.lib.constants import SPOT, FUTURES, INTERVAL_1D, USD, Market, Interval @@ -32,7 +33,7 @@ def __call__( from_unix: str = None, to_unix: str = None, pandas: bool = True, - ) -> Union[Dict, pd.DataFrame]: + ) -> Union[pd.DataFrame, CandleResponse]: """Fetch candle data `GET /api/v1/cex/candle` diff --git a/datamaxi/resources/cex_ticker.py b/datamaxi/resources/cex_ticker.py index d939124..24797cb 100644 --- a/datamaxi/resources/cex_ticker.py +++ b/datamaxi/resources/cex_ticker.py @@ -1,7 +1,8 @@ -from typing import Any, List, Dict, Union +from typing import Any, List, Union import pandas as pd from datamaxi.api import Resource from datamaxi.lib.utils import check_required_parameters +from datamaxi.resources.responses import TickerResponse from datamaxi.lib.constants import SPOT, FUTURES, Market @@ -26,7 +27,7 @@ def get( conversion_base: str = None, include_source: bool = False, pandas: bool = True, - ) -> Union[Dict, pd.DataFrame]: + ) -> Union[pd.DataFrame, TickerResponse]: """Fetch ticker data `GET /api/v1/ticker` diff --git a/datamaxi/resources/responses.py b/datamaxi/resources/responses.py new file mode 100644 index 0000000..a1acae4 --- /dev/null +++ b/datamaxi/resources/responses.py @@ -0,0 +1,58 @@ +"""Typed response models (pilot — see #141). + +These describe the raw JSON returned on the ``pandas=False`` path of the +candle and ticker endpoints. They are hint-only ``TypedDict``s (no runtime +cost, no validation) so callers get IDE autocomplete / mypy checking on the +dict shape without any behavior change. + +Wire note: numeric fields arrive as **strings** (e.g. ``"105.5"``), and a +missing value arrives as the literal string ``"NaN"``. The ``pandas=True`` +path coerces these to numbers; the raw dict below preserves them as strings. + +This is a deliberately small pilot; other endpoints can be typed the same way +incrementally. +""" + +from typing import List, TypedDict + + +class CandleRow(TypedDict): + """One candle from the ``data`` array of ``GET /api/v1/cex/candle``.""" + + d: str # candle open time, UTC milliseconds + o: str # open price + h: str # high price + l: str # low price + c: str # close price + v: str # trading volume (base token) + + +class CandleResponse(TypedDict): + """Raw envelope returned by ``cex.candle(..., pandas=False)``.""" + + data: List[CandleRow] + + +class TickerData(TypedDict): + """The ``data`` object of ``GET /api/v1/ticker``.""" + + b: str # base token + d: str # timestamp, UTC milliseconds + e: str # exchange name + hb: str # highest bid (orderbook) + la: str # lowest ask (orderbook) + ld: str # lower depth (2%) + m: str # market type (spot/futures) + p: str # latest price + p24h: str # price 24 hours ago + pc: str # price change vs 24h ago + q: str # quote token + s: str # symbol (base-quote) + ud: str # upper depth (2%) + v: str # 24h trading volume + + +class TickerResponse(TypedDict): + """Raw envelope returned by ``cex.ticker.get(..., pandas=False)``.""" + + data: TickerData diff --git a/tests/test_response_models.py b/tests/test_response_models.py new file mode 100644 index 0000000..97bc305 --- /dev/null +++ b/tests/test_response_models.py @@ -0,0 +1,67 @@ +"""Tests for the typed response-model pilot (#141). + +TypedDicts are hint-only, so these assert the documented field sets and that +the pandas=False return still matches the typed envelope shape at runtime. +""" + +import re +import responses +import pandas as pd + +from datamaxi import CandleRow, CandleResponse, TickerData, TickerResponse +from datamaxi.resources.cex_candle import CexCandle +from datamaxi.resources.cex_ticker import CexTicker + +BASE_URL = "https://api.datamaxiplus.com" + + +def test_candle_row_fields(): + assert set(CandleRow.__annotations__) == {"d", "o", "h", "l", "c", "v"} + assert set(CandleResponse.__annotations__) == {"data"} + + +def test_ticker_data_fields(): + assert set(TickerData.__annotations__) == { + "b", + "d", + "e", + "hb", + "la", + "ld", + "m", + "p", + "p24h", + "q", + "s", + "ud", + "v", + "pc", + } + assert set(TickerResponse.__annotations__) == {"data"} + + +@responses.activate +def test_candle_pandas_false_matches_envelope_shape(): + payload = { + "data": [{"d": "1700000000", "o": "1", "h": "2", "l": "1", "c": "2", "v": "9"}] + } + responses.add( + responses.GET, re.compile(".*/api/v1/cex/candle.*"), json=payload, status=200 + ) + res = CexCandle(api_key="k", base_url=BASE_URL)( + exchange="binance", market="spot", symbol="BTC-USDT", pandas=False + ) + assert set(res.keys()) == {"data"} + assert set(res["data"][0].keys()) == set(CandleRow.__annotations__) + + +@responses.activate +def test_ticker_pandas_true_still_dataframe(): + payload = {"data": {"d": "1700000000", "p": "105.5"}} + responses.add( + responses.GET, re.compile(".*/api/v1/ticker.*"), json=payload, status=200 + ) + df = CexTicker(api_key="k", base_url=BASE_URL).get( + exchange="binance", market="spot", symbol="BTC-USDT" + ) + assert isinstance(df, pd.DataFrame) # annotation change is hint-only