diff --git a/datamaxi/__init__.py b/datamaxi/__init__.py index d990536..af0de33 100644 --- a/datamaxi/__init__.py +++ b/datamaxi/__init__.py @@ -25,6 +25,23 @@ CandleResponse, TickerData, TickerResponse, + AnnouncementRow, + AnnouncementResponse, + TokenUpdateRow, + TokenUpdateResponse, + WalletStatusRow, + ForexRow, + FundingRateRow, + FundingHistoryResponse, + LatestFundingRate, + PremiumDetail, + PremiumRow, + PremiumResponse, + TelegramChannel, + TelegramChannelsResponse, + TelegramMessage, + TelegramMessagesResponse, + NaverTrendRow, ) __all__ = [ @@ -52,4 +69,21 @@ "CandleResponse", "TickerData", "TickerResponse", + "AnnouncementRow", + "AnnouncementResponse", + "TokenUpdateRow", + "TokenUpdateResponse", + "WalletStatusRow", + "ForexRow", + "FundingRateRow", + "FundingHistoryResponse", + "LatestFundingRate", + "PremiumDetail", + "PremiumRow", + "PremiumResponse", + "TelegramChannel", + "TelegramChannelsResponse", + "TelegramMessage", + "TelegramMessagesResponse", + "NaverTrendRow", ] diff --git a/datamaxi/naver/__init__.py b/datamaxi/naver/__init__.py index c5996ad..0ed6f0d 100644 --- a/datamaxi/naver/__init__.py +++ b/datamaxi/naver/__init__.py @@ -1,6 +1,7 @@ from typing import Any, List, Union import pandas as pd from datamaxi.api import Resource +from datamaxi.resources.responses import NaverTrendRow from datamaxi.lib.utils import check_required_parameter from datamaxi.lib.constants import BASE_URL @@ -31,7 +32,9 @@ def symbols(self) -> List[str]: """ return self.request_endpoint("naver_trend_symbols") - def trend(self, symbol: str, pandas: bool = True) -> Union[List, pd.DataFrame]: + def trend( + self, symbol: str, pandas: bool = True + ) -> Union[pd.DataFrame, List[NaverTrendRow]]: """Get Naver trend for given token symbol `GET /api/v1/naver-trend` diff --git a/datamaxi/resources/cex_announcement.py b/datamaxi/resources/cex_announcement.py index 78420e2..04025a3 100644 --- a/datamaxi/resources/cex_announcement.py +++ b/datamaxi/resources/cex_announcement.py @@ -1,5 +1,6 @@ -from typing import Any, Dict, Optional, Tuple, Callable +from typing import Any, Optional, Tuple, Callable from datamaxi.api import Resource +from datamaxi.resources.responses import AnnouncementResponse from datamaxi.lib.constants import ASC, DESC, SortOrder @@ -23,7 +24,7 @@ def __call__( key: Optional[str] = None, exchange: Optional[str] = None, category: Optional[str] = None, - ) -> Tuple[Dict[str, Any], Callable]: + ) -> Tuple[AnnouncementResponse, Callable]: """Get exchange announcements `GET /api/v1/cex/announcements` diff --git a/datamaxi/resources/cex_token.py b/datamaxi/resources/cex_token.py index 5a266c1..00a6fcc 100644 --- a/datamaxi/resources/cex_token.py +++ b/datamaxi/resources/cex_token.py @@ -1,5 +1,6 @@ -from typing import Any, Dict, Optional, Tuple, Callable +from typing import Any, Optional, Tuple, Callable from datamaxi.api import Resource +from datamaxi.resources.responses import TokenUpdateResponse class CexToken(Resource): @@ -19,7 +20,7 @@ def updates( page: int = 1, limit: int = 1000, type: Optional[str] = None, - ) -> Tuple[Dict[str, Any], Callable]: + ) -> Tuple[TokenUpdateResponse, Callable]: """Get token update data `GET /api/v1/cex/token/updates` diff --git a/datamaxi/resources/cex_wallet_status.py b/datamaxi/resources/cex_wallet_status.py index 0be070e..dbd6f1c 100644 --- a/datamaxi/resources/cex_wallet_status.py +++ b/datamaxi/resources/cex_wallet_status.py @@ -1,6 +1,7 @@ -from typing import Any, List, Dict, Union +from typing import Any, List, Union import pandas as pd from datamaxi.api import Resource +from datamaxi.resources.responses import WalletStatusRow from datamaxi.lib.utils import check_required_parameters from datamaxi.lib.utils import check_required_parameter @@ -22,7 +23,7 @@ def __call__( exchange: str, asset: str, pandas: bool = True, - ) -> Union[Dict, pd.DataFrame]: + ) -> Union[pd.DataFrame, List[WalletStatusRow]]: """Fetch transfer status data `GET /api/v1/wallet-status` diff --git a/datamaxi/resources/forex.py b/datamaxi/resources/forex.py index 51a360c..ec808ec 100644 --- a/datamaxi/resources/forex.py +++ b/datamaxi/resources/forex.py @@ -1,6 +1,7 @@ -from typing import Any, List, Dict, Union +from typing import Any, List, Union import pandas as pd from datamaxi.api import Resource +from datamaxi.resources.responses import ForexRow from datamaxi.lib.utils import check_required_parameter @@ -23,7 +24,7 @@ def __call__( self, symbol: str, pandas: bool = True, - ) -> Union[Dict, pd.DataFrame]: + ) -> Union[pd.DataFrame, ForexRow]: """Fetch forex data `GET /api/v1/forex` diff --git a/datamaxi/resources/funding_rate.py b/datamaxi/resources/funding_rate.py index a795c43..d48aadc 100644 --- a/datamaxi/resources/funding_rate.py +++ b/datamaxi/resources/funding_rate.py @@ -1,9 +1,10 @@ -from typing import Any, Callable, Tuple, List, Dict, Union +from typing import Any, Callable, Tuple, List, Union import pandas as pd from datamaxi.api import Resource 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 FundingHistoryResponse, LatestFundingRate from datamaxi.lib.constants import ASC, DESC, SortOrder @@ -29,7 +30,7 @@ def history( toDateTime: str = None, sort: SortOrder = DESC, pandas: bool = True, - ) -> Union[Tuple[Dict, Callable], Tuple[pd.DataFrame, Callable]]: + ) -> Union[Tuple[pd.DataFrame, Callable], Tuple[FundingHistoryResponse, Callable]]: """Fetch historical funding rate data `GET /api/v1/funding-rate/history` @@ -105,7 +106,7 @@ def latest( exchange: str = None, symbol: str = None, pandas: bool = True, - ) -> Union[Dict, pd.DataFrame]: + ) -> Union[pd.DataFrame, LatestFundingRate]: """Fetch latest funding rate data `GET /api/v1/funding-rate/latest` diff --git a/datamaxi/resources/premium.py b/datamaxi/resources/premium.py index 959b23c..bea1981 100644 --- a/datamaxi/resources/premium.py +++ b/datamaxi/resources/premium.py @@ -1,6 +1,7 @@ from typing import Any, List, Union, Optional import pandas as pd from datamaxi.api import Resource +from datamaxi.resources.responses import PremiumResponse from datamaxi.lib.constants import Market, SortOrder @@ -43,7 +44,7 @@ def __call__( # noqa: C901 token_exclude: str = None, query: str = None, pandas: bool = True, - ) -> Union[List, pd.DataFrame]: + ) -> Union[pd.DataFrame, PremiumResponse]: """Fetch premium data `GET /api/v1/premium` diff --git a/datamaxi/resources/responses.py b/datamaxi/resources/responses.py index a1acae4..464f79d 100644 --- a/datamaxi/resources/responses.py +++ b/datamaxi/resources/responses.py @@ -1,23 +1,33 @@ -"""Typed response models (pilot — see #141). +"""Typed response models (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. +Hint-only ``TypedDict``s describing the raw ``pandas=False`` JSON returned by +the data endpoints, so callers get IDE autocomplete / mypy checking on the +dict shape with no runtime cost, no validation, and no 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. +Conventions +----------- +* Field names and presence are taken from the live API docs + (docs.datamaxiplus.com). +* Fields are typed ``str`` because this API serializes JSON values as strings + on the wire — verified for candle / ticker / funding (string mocks + the + ``convert_data_to_data_frame`` coercion, where a missing value arrives as + the literal string ``"NaN"``). The ``pandas=True`` path coerces numerics. + Two documented exceptions are typed natively: booleans (e.g. premium + ``t`` / ``sms`` / ``tms``) and Naver-trend ``v`` (an integer on the wire). +* These are hand-authored, so they can drift if the backend changes; a + drift-proof version would emit them from the upstream OpenAPI codegen. -This is a deliberately small pilot; other endpoints can be typed the same way -incrementally. +Envelope vs bare: some endpoints wrap rows in ``{"data": [...]}`` (typed as a +``*Response``), some return a bare list, and some a single object — each +method's annotation matches its actual ``pandas=False`` return. """ from typing import List, TypedDict +# --- CEX candle ------------------------------------------------------------- class CandleRow(TypedDict): - """One candle from the ``data`` array of ``GET /api/v1/cex/candle``.""" + """One candle from ``GET /api/v1/cex/candle``.""" d: str # candle open time, UTC milliseconds o: str # open price @@ -28,11 +38,12 @@ class CandleRow(TypedDict): class CandleResponse(TypedDict): - """Raw envelope returned by ``cex.candle(..., pandas=False)``.""" + """Raw envelope from ``cex.candle(..., pandas=False)``.""" data: List[CandleRow] +# --- CEX ticker ------------------------------------------------------------- class TickerData(TypedDict): """The ``data`` object of ``GET /api/v1/ticker``.""" @@ -53,6 +64,246 @@ class TickerData(TypedDict): class TickerResponse(TypedDict): - """Raw envelope returned by ``cex.ticker.get(..., pandas=False)``.""" + """Raw envelope from ``cex.ticker.get(..., pandas=False)``.""" data: TickerData + + +# --- CEX announcements ------------------------------------------------------ +class AnnouncementRow(TypedDict): + """One announcement from ``GET /api/v1/cex/announcement``.""" + + c: str # category + d: str # date (UTC ms) + e: str # exchange + s: str # summary + t: str # title + u: str # url + + +class AnnouncementResponse(TypedDict): + """Raw envelope from ``cex.announcement(...)`` (first tuple element).""" + + data: List[AnnouncementRow] + + +# --- CEX token updates ------------------------------------------------------ +class TokenUpdateRow(TypedDict): + """One token update from ``GET /api/v1/cex/token-updates``.""" + + b: str # base token + d: str # event timestamp (UTC ms) + e: str # exchange + m: str # market + q: str # quote token + t: str # update type (listed / delisted) + + +class TokenUpdateResponse(TypedDict): + """Raw envelope from ``cex.token.updates(...)`` (first tuple element).""" + + data: List[TokenUpdateRow] + + +# --- CEX wallet status ------------------------------------------------------ +class WalletStatusRow(TypedDict): + """One wallet/transfer status row from ``GET /api/v1/cex/wallet-status``.""" + + currency: str # asset + deposit_state: str # deposit functionality status + deposit_message: str # deposit status detail + withdraw_state: str # withdrawal functionality status + withdraw_message: str # withdrawal status detail + exchange: str # CEX name + network: str # blockchain network + updated_at: str # last-update timestamp + + +# cex.wallet_status(..., pandas=False) returns a bare List[WalletStatusRow]. + + +# --- Forex ------------------------------------------------------------------ +class ForexRow(TypedDict): + """The forex object from ``GET /api/v1/forex``.""" + + d: str # timestamp (UTC ms) + r: str # forex rate + s: str # forex symbol + + +# --- Funding rate ----------------------------------------------------------- +class FundingRateRow(TypedDict): + """One historical funding-rate point (``.funding_rate.history``).""" + + d: str # timestamp (UTC ms) + f: str # funding rate + + +class FundingHistoryResponse(TypedDict): + """Raw envelope from ``funding_rate.history(...)`` (first tuple element).""" + + data: List[FundingRateRow] + + +class LatestFundingRate(TypedDict): + """The single object from ``funding_rate.latest(...)``.""" + + b: str # base asset + d: str # timestamp + e: str # exchange + f: str # funding rate + i: str # interval (hours) + id: str # token identifier + p: str # processed timestamp + q: str # quote asset + s: str # symbol + + +# --- Premium ---------------------------------------------------------------- +class PremiumDetail(TypedDict, total=False): + """Per-pair premium detail (``item["detail"]``). + + ``total=False``: AMM-only fields (chain / pool address) and other optional + metrics are absent for many pairs. + """ + + bid: str # base token identifier + d: str # timestamp (UTC ms) + fg: str # source-minus-target funding-rate diff + nfr: str # net funding rate (interval-adjusted) + pdp: str # current price difference % + pdp1h: str # price difference % 1h ago + pdp4h: str # price difference % 4h ago + pdp5m: str # price difference % 5m ago + pdp15m: str # price difference % 15m ago + pdp24h: str # price difference % 24h ago + pdp30m: str # price difference % 30m ago + pmd: str # premium duration + sad: str # source ask depth within +2% + sad2p: str # source -2% volume depth + sadf: str # source ask depth within +2% (quote) + sb: str # source base token + sbd2p: str # source +2% volume depth + sc: str # source chain (AMM) + se: str # source exchange + sfr: str # source funding rate + sfri: str # source funding-rate interval (hours) + sfrt: str # source funding-rate timestamp (UTC ms) + shb: str # source highest bid + sla: str # source lowest ask + sm: str # source market type (spot/futures) + sms: bool # source margin support + snd: str # source next distribution time (UTC ms) + soi: str # source open interest (USD) + soich1h: str # source OI % change (1h) + soich4h: str # source OI % change (4h) + soich24h: str # source OI % change (24h) + soivr: str # source OI/volume ratio + sp: str # source latest price + spa: str # source pool address (AMM) + spdp1h: str # source price change (1h) + spdp4h: str # source price change (4h) + spdp5m: str # source price change (5m) + spdp15m: str # source price change (15m) + spdp24h: str # source price change (24h) + spdp30m: str # source price change (30m) + sq: str # source quote token + st: str # source ticker timestamp (UTC ms) + sv: str # source 24h volume + t: bool # transferable + tad2p: str # target -2% volume depth + tb: str # target base token + tbd: str # target bid depth within -2% + tbd2p: str # target +2% volume depth + tbdf: str # target bid depth within -2% (quote) + tc: str # target chain (AMM) + te: str # target exchange + tfr: str # target funding rate + tfri: str # target funding-rate interval (hours) + tfrt: str # target funding-rate timestamp (UTC ms) + thb: str # target highest bid + tla: str # target lowest ask + tm: str # target market type (spot/futures) + tms: bool # target margin support + tnd: str # target next distribution time (UTC ms) + toi: str # target open interest (USD) + toich1h: str # target OI % change (1h) + toich4h: str # target OI % change (4h) + toich24h: str # target OI % change (24h) + toivr: str # target OI/volume ratio + tp: str # target latest price + tpa: str # target pool address (AMM) + tpdp1h: str # target price change (1h) + tpdp4h: str # target price change (4h) + tpdp5m: str # target price change (5m) + tpdp15m: str # target price change (15m) + tpdp24h: str # target price change (24h) + tpdp30m: str # target price change (30m) + tq: str # target quote token + tt: str # target ticker timestamp (UTC ms) + tv: str # target 24h volume + + +class PremiumRow(TypedDict): + """One item from the premium ``data`` array.""" + + detail: PremiumDetail + source_annualized_funding_rate: str + target_annualized_funding_rate: str + + +class PremiumResponse(TypedDict): + """Raw envelope from ``premium(..., pandas=False)``.""" + + data: List[PremiumRow] + + +# --- Telegram --------------------------------------------------------------- +class TelegramChannel(TypedDict): + """One channel from ``GET /api/v1/telegram/channels``.""" + + category: str + channelName: str + channelTitle: str + createdAt: str # creation time + description: str + link: str + subscribers: str # subscriber count + + +class TelegramChannelsResponse(TypedDict): + """Raw envelope from ``telegram.channels(...)`` (first tuple element).""" + + data: List[TelegramChannel] + + +class TelegramMessage(TypedDict): + """One message from ``GET /api/v1/telegram/messages``.""" + + channelHandle: str + channelId: str + channelName: str + forwards: str # forward count + message: str # message text + messageId: str + messageLink: str + publishedAt: str # published date + reactions: str # reaction count + views: str # view count + + +class TelegramMessagesResponse(TypedDict): + """Raw envelope from ``telegram.messages(...)`` (first tuple element).""" + + data: List[TelegramMessage] + + +# --- Naver trend ------------------------------------------------------------ +class NaverTrendRow(TypedDict): + """One Naver-trend point from ``GET /api/v1/naver-trend``.""" + + d: str # date + v: int # trend value (native integer on the wire) + + +# naver.trend(..., pandas=False) returns a bare List[NaverTrendRow]. diff --git a/datamaxi/telegram/__init__.py b/datamaxi/telegram/__init__.py index adca269..7c2b3ac 100644 --- a/datamaxi/telegram/__init__.py +++ b/datamaxi/telegram/__init__.py @@ -1,5 +1,9 @@ -from typing import Any, Dict, Optional, Tuple, Callable +from typing import Any, Optional, Tuple, Callable from datamaxi.api import Resource +from datamaxi.resources.responses import ( + TelegramChannelsResponse, + TelegramMessagesResponse, +) from datamaxi.lib.constants import BASE_URL, SortOrder @@ -24,7 +28,7 @@ def channels( category: Optional[str] = None, key: Optional[str] = None, sort: SortOrder = "desc", - ) -> Tuple[Dict[str, Any], Callable]: + ) -> Tuple[TelegramChannelsResponse, Callable]: """Get Telegram supported channels `GET /api/v1/telegram/channels` @@ -80,7 +84,7 @@ def messages( sort: SortOrder = "desc", category: Optional[str] = None, search_query: Optional[str] = None, - ) -> Tuple[Dict[str, Any], Callable]: + ) -> Tuple[TelegramMessagesResponse, Callable]: """Get Telegram posts for given channel username `GET /api/v1/telegram/messages` diff --git a/tests/test_response_models.py b/tests/test_response_models.py index 97bc305..77fda43 100644 --- a/tests/test_response_models.py +++ b/tests/test_response_models.py @@ -1,14 +1,31 @@ -"""Tests for the typed response-model pilot (#141). +"""Tests for the typed response models (#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. +TypedDicts are hint-only, so these assert the documented field sets (guarding +against transcription drift) and that the pandas=False return still matches +the typed shape at runtime. """ import re import responses import pandas as pd -from datamaxi import CandleRow, CandleResponse, TickerData, TickerResponse +from datamaxi import ( + CandleRow, + CandleResponse, + TickerData, + TickerResponse, + AnnouncementRow, + TokenUpdateRow, + WalletStatusRow, + ForexRow, + FundingRateRow, + LatestFundingRate, + PremiumRow, + PremiumDetail, + TelegramChannel, + TelegramMessage, + NaverTrendRow, +) from datamaxi.resources.cex_candle import CexCandle from datamaxi.resources.cex_ticker import CexTicker @@ -55,6 +72,70 @@ def test_candle_pandas_false_matches_envelope_shape(): assert set(res["data"][0].keys()) == set(CandleRow.__annotations__) +def test_new_model_field_sets(): + assert set(AnnouncementRow.__annotations__) == {"c", "d", "e", "s", "t", "u"} + assert set(TokenUpdateRow.__annotations__) == {"b", "d", "e", "m", "q", "t"} + assert set(WalletStatusRow.__annotations__) == { + "currency", + "deposit_state", + "deposit_message", + "withdraw_state", + "withdraw_message", + "exchange", + "network", + "updated_at", + } + assert set(ForexRow.__annotations__) == {"d", "r", "s"} + assert set(FundingRateRow.__annotations__) == {"d", "f"} + assert set(LatestFundingRate.__annotations__) == { + "b", + "d", + "e", + "f", + "i", + "id", + "p", + "q", + "s", + } + assert set(PremiumRow.__annotations__) == { + "detail", + "source_annualized_funding_rate", + "target_annualized_funding_rate", + } + assert set(TelegramChannel.__annotations__) == { + "category", + "channelName", + "channelTitle", + "createdAt", + "description", + "link", + "subscribers", + } + assert set(TelegramMessage.__annotations__) == { + "channelHandle", + "channelId", + "channelName", + "forwards", + "message", + "messageId", + "messageLink", + "publishedAt", + "reactions", + "views", + } + assert set(NaverTrendRow.__annotations__) == {"d", "v"} + + +def test_type_exceptions_native_not_str(): + # Naver trend value is a native int; premium has real booleans. + assert NaverTrendRow.__annotations__["v"] is int + for f in ("t", "sms", "tms"): + assert PremiumDetail.__annotations__[f] is bool + # premium detail is optional-keyed (AMM-only fields absent for many pairs) + assert PremiumDetail.__total__ is False + + @responses.activate def test_ticker_pandas_true_still_dataframe(): payload = {"data": {"d": "1700000000", "p": "105.5"}}