diff --git a/datamaxi/datamaxi/cex_symbol.py b/datamaxi/datamaxi/cex_symbol.py index 76e7385..988c67c 100644 --- a/datamaxi/datamaxi/cex_symbol.py +++ b/datamaxi/datamaxi/cex_symbol.py @@ -15,45 +15,129 @@ def __init__(self, api_key=None, **kwargs: Any): super().__init__(api_key, **kwargs) def metadata( - self, exchange: Optional[str] = None, base: Optional[str] = None + self, + exchange: Optional[str] = None, + base: Optional[str] = None, + market: Optional[str] = None, + quote: Optional[str] = None, + status: Optional[str] = None, ) -> Dict[str, Any]: """Trading status + caution + tags + delisting metadata. `GET /api/v1/cex/symbol/metadata` + + Args: + exchange (str): Comma-separated exchange names (empty = all). + base (str): Base asset filter. + market (str): ``spot`` or ``futures`` (empty = both). + quote (str): Quote asset filter. + status (str): ``trading_status`` filter (comma-separated). """ return self.request_endpoint( - "cex_symbol_metadata", exchange=exchange, base=base + "cex_symbol_metadata", + exchange=exchange, + base=base, + market=market, + quote=quote, + status=status, ) def tags( - self, exchange: Optional[str] = None, base: Optional[str] = None + self, + exchange: Optional[str] = None, + base: Optional[str] = None, + tag: Optional[str] = None, + market: Optional[str] = None, + source: Optional[str] = None, + min_confidence: Optional[int] = None, ) -> Dict[str, Any]: """Exchange-assigned tags (e.g. seed, alpha) per symbol. `GET /api/v1/cex/symbol/tags` + + Args: + exchange (str): Exchange filter (comma-separated). + base (str): Base asset filter. + tag (str): Tag filter (comma-separated). + market (str): ``spot`` or ``futures``. + source (str): Tag source filter + (``rest_native``, ``announcement``, ``cmc``, ``manual``). + min_confidence (int): Minimum confidence (0-100). """ - return self.request_endpoint("cex_symbol_tags", exchange=exchange, base=base) + return self.request_endpoint( + "cex_symbol_tags", + exchange=exchange, + base=base, + tag=tag, + market=market, + source=source, + min_confidence=min_confidence, + ) - def cautions(self, exchange: Optional[str] = None) -> Dict[str, Any]: + def cautions( + self, + exchange: Optional[str] = None, + market: Optional[str] = None, + min_level: Optional[str] = None, + active_only: Optional[bool] = None, + ) -> Dict[str, Any]: """Active caution / investment-warning flags per symbol. `GET /api/v1/cex/symbol/cautions` + + Args: + exchange (str): Exchange filter (comma-separated, empty = all). + market (str): ``spot`` or ``futures``. + min_level (str): Minimum severity + (``caution``, ``warning``, ``danger``). + active_only (bool): Exclude rows whose ``end_at`` is in the past. """ - return self.request_endpoint("cex_symbol_cautions", exchange=exchange) + return self.request_endpoint( + "cex_symbol_cautions", + exchange=exchange, + market=market, + min_level=min_level, + active_only=active_only, + ) - def delistings(self, exchange: Optional[str] = None) -> Dict[str, Any]: + def delistings( + self, + exchange: Optional[str] = None, + market: Optional[str] = None, + from_ms: Optional[int] = None, + to_ms: Optional[int] = None, + include_past: Optional[bool] = None, + ) -> Dict[str, Any]: """Scheduled delistings with timestamps. `GET /api/v1/cex/symbol/delistings` + + Args: + exchange (str): Exchange filter (comma-separated). + market (str): ``spot`` or ``futures``. + from_ms (int): Lower bound for ``delisting_at`` (ms epoch). + to_ms (int): Upper bound for ``delisting_at`` (ms epoch). + include_past (bool): Include already-delisted rows. """ - return self.request_endpoint("cex_symbol_delistings", exchange=exchange) + return self.request_endpoint( + "cex_symbol_delistings", + exchange=exchange, + market=market, + from_ms=from_ms, + to_ms=to_ms, + include_past=include_past, + ) - def volume(self, base: str) -> Dict[str, Any]: + def volume(self, base: str, market: Optional[str] = None) -> Dict[str, Any]: """Per-exchange 24h volume for a single base asset. `GET /api/v1/cex/symbol/volume` + + Args: + base (str): Base asset (e.g. ``BTC``). + market (str): Filter to ``spot`` or ``futures``. """ - return self.request_endpoint("cex_symbol_volume", base=base) + return self.request_endpoint("cex_symbol_volume", base=base, market=market) def oi(self, base: str, exchange: Optional[str] = None) -> Dict[str, Any]: """Per-exchange Open Interest for a single base asset. diff --git a/datamaxi/datamaxi/cex_ticker.py b/datamaxi/datamaxi/cex_ticker.py index f0fb22c..5a28d09 100644 --- a/datamaxi/datamaxi/cex_ticker.py +++ b/datamaxi/datamaxi/cex_ticker.py @@ -24,6 +24,7 @@ def get( market: str, currency: str = None, conversion_base: str = None, + include_source: bool = False, pandas: bool = True, ) -> Union[Dict, pd.DataFrame]: """Fetch ticker data @@ -38,6 +39,8 @@ def get( market (str): Market type (spot/futures) currency (str): Price currency conversion_base (str): Conversion base currency + include_source (bool): Include the frame's transport source + (``ws``|``rest``) in the response pandas (bool): Return data as pandas DataFrame Returns: @@ -62,6 +65,7 @@ def get( market=market, currency=currency, conversion_base=conversion_base, + include_source=include_source, ) if pandas: diff --git a/datamaxi/datamaxi/liquidation.py b/datamaxi/datamaxi/liquidation.py index ff4260c..fba19d6 100644 --- a/datamaxi/datamaxi/liquidation.py +++ b/datamaxi/datamaxi/liquidation.py @@ -38,17 +38,32 @@ def __call__( "liquidation", exchange=exchange, symbol=symbol, limit=limit ) - def feed(self, limit: int = 100) -> Dict[str, Any]: + def feed( + self, + limit: int = 100, + exchange: Optional[str] = None, + base: Optional[str] = None, + min_volume_usd: Optional[float] = None, + ) -> Dict[str, Any]: """Firehose: most recent liquidation events across every symbol. `GET /api/v1/liquidation/feed` Args: limit (int): Max events to return. + exchange (str): Optional exchange filter. + base (str): Optional base asset filter (case-insensitive). + min_volume_usd (float): Minimum ``VolumeUsd`` filter. """ if limit < 1: raise ValueError("limit must be greater than 0") - return self.request_endpoint("liquidation_feed", limit=limit) + return self.request_endpoint( + "liquidation_feed", + limit=limit, + exchange=exchange, + base=base, + min_volume_usd=min_volume_usd, + ) def heatmap( self, diff --git a/datamaxi/datamaxi/premium.py b/datamaxi/datamaxi/premium.py index fef909c..23f0b9c 100644 --- a/datamaxi/datamaxi/premium.py +++ b/datamaxi/datamaxi/premium.py @@ -40,6 +40,7 @@ def __call__( # noqa: C901 premium_type: str = None, token_include: str = None, token_exclude: str = None, + query: str = None, pandas: bool = True, ) -> Union[List, pd.DataFrame]: """Fetch premium data @@ -68,6 +69,7 @@ def __call__( # noqa: C901 premium_type (str): Return based on matching premium_type token_include (str): Return results containing only specified token token_exclude (str): Return results not containing specified token + query (str): Search query for filtering assets pandas (bool): Return data as pandas DataFrame @@ -97,6 +99,9 @@ def __call__( # noqa: C901 if key is not None: params["key"] = key + if query is not None: + params["query"] = query + if page is not None: params["page"] = page diff --git a/datamaxi/telegram/__init__.py b/datamaxi/telegram/__init__.py index 96977dc..15edf84 100644 --- a/datamaxi/telegram/__init__.py +++ b/datamaxi/telegram/__init__.py @@ -79,6 +79,7 @@ def messages( key: Optional[str] = None, sort: str = "desc", category: Optional[str] = None, + search_query: Optional[str] = None, ) -> Tuple[Dict[str, Any], Callable]: """Get Telegram posts for given channel username @@ -93,6 +94,7 @@ def messages( key (str): Specifies key to sort by sort (str): Sort order category (str): Specifies category + search_query (str): Specifies search query Returns: Tuple of message response and next request function @@ -114,6 +116,7 @@ def messages( key=key, sort=sort, category=category, + search_query=search_query, ) if res["data"] is None: raise ValueError("no data found") diff --git a/tests/test_cex_symbol.py b/tests/test_cex_symbol.py index c3f5a99..d0038d9 100644 --- a/tests/test_cex_symbol.py +++ b/tests/test_cex_symbol.py @@ -71,6 +71,80 @@ def test_volume_sends_base_param(): assert res == {"BTC": {"binance": "100"}} +@responses.activate +def test_metadata_forwards_new_params(): + responses.add( + responses.GET, + re.compile(".*/api/v1/cex/symbol/metadata.*"), + json={}, + status=200, + ) + _client().metadata( + exchange="binance", base="BTC", market="spot", quote="USDT", status="trading" + ) + qs = _qs(responses.calls[0]) + assert qs["market"] == ["spot"] + assert qs["quote"] == ["USDT"] + assert qs["status"] == ["trading"] + + +@responses.activate +def test_tags_forwards_new_params(): + responses.add( + responses.GET, + re.compile(".*/api/v1/cex/symbol/tags.*"), + json={}, + status=200, + ) + _client().tags(base="BTC", tag="seed", source="manual", min_confidence=90) + qs = _qs(responses.calls[0]) + assert qs["tag"] == ["seed"] + assert qs["source"] == ["manual"] + assert qs["min_confidence"] == ["90"] + + +@responses.activate +def test_cautions_forwards_new_params(): + responses.add( + responses.GET, + re.compile(".*/api/v1/cex/symbol/cautions.*"), + json={}, + status=200, + ) + _client().cautions(exchange="binance", min_level="warning", active_only=True) + qs = _qs(responses.calls[0]) + assert qs["min_level"] == ["warning"] + assert qs["active_only"] == ["True"] + + +@responses.activate +def test_delistings_forwards_new_params(): + responses.add( + responses.GET, + re.compile(".*/api/v1/cex/symbol/delistings.*"), + json={}, + status=200, + ) + _client().delistings(exchange="binance", from_ms=1, to_ms=2, include_past=True) + qs = _qs(responses.calls[0]) + assert qs["from_ms"] == ["1"] + assert qs["to_ms"] == ["2"] + assert qs["include_past"] == ["True"] + + +@responses.activate +def test_volume_forwards_market_param(): + responses.add( + responses.GET, + re.compile(".*/api/v1/cex/symbol/volume.*"), + json={}, + status=200, + ) + _client().volume(base="BTC", market="futures") + qs = _qs(responses.calls[0]) + assert qs["market"] == ["futures"] + + @mock_http_response(responses.GET, "/api/v1/cex/symbol/oi", {"BTC": {"binance": "1"}}) def test_oi_returns_dict(): assert _client().oi(base="BTC") == {"BTC": {"binance": "1"}} diff --git a/tests/test_cex_ticker.py b/tests/test_cex_ticker.py index 6cb9ef1..0757a2c 100644 --- a/tests/test_cex_ticker.py +++ b/tests/test_cex_ticker.py @@ -52,6 +52,24 @@ def test_ticker_get_sends_query_params(): assert qs["conversion_base"] == ["USDT"] +@responses.activate +def test_ticker_get_forwards_include_source(): + responses.add( + responses.GET, + re.compile(".*/api/v1/ticker.*"), + json=_TICKER, + status=200, + ) + _client().get( + exchange="binance", + market="spot", + symbol="BTC-USDT", + include_source=True, + ) + qs = _qs(responses.calls[0]) + assert qs["include_source"] == ["True"] + + def test_ticker_invalid_market_raises_value_error(): with pytest.raises(ValueError): _client().get(exchange="binance", market="bogus", symbol="BTC-USDT") diff --git a/tests/test_endpoint_param_coverage.py b/tests/test_endpoint_param_coverage.py new file mode 100644 index 0000000..2254186 --- /dev/null +++ b/tests/test_endpoint_param_coverage.py @@ -0,0 +1,207 @@ +"""Reproducible audit: every registry param is reachable from the SDK. + +``datamaxi/_endpoints.py`` is the codegen registry mirroring the live backend +OpenAPI contract (data-api routes only; front-api ``/api/v1/front/*`` is +excluded from the spec). Client methods dispatch through +``API.request_endpoint(op_id, **params)`` — so a registry-declared param is +only reachable if some client call site forwards it as a keyword argument. + +This test statically extracts, per ``op_id``, the union of keyword arguments +forwarded at every ``request_endpoint("op_id", ...)`` call site (handling the +``**{"from": ...}`` dict-splat and the ``**params`` local-dict patterns), then +asserts that every registry param is either forwarded, globally ignored +(pagination), or explicitly allow-listed below with a rationale. + +Regenerating ``_endpoints.py`` (``make python`` upstream) that adds a new param +to any endpoint will fail this test until the client method forwards it or it +is allow-listed here — that is the point: it catches silent drift. + +Issue #123. +""" + +import ast +import os + +import datamaxi +from datamaxi._endpoints import ENDPOINTS + +# Pagination is handled per-method (some methods expose page/limit and their +# own next_request pager, aggregate endpoints return the raw page). The issue +# scopes the audit to non-pagination params, so ignore these globally. +_IGNORED_PARAMS = {"page", "limit"} + + +# Registry params intentionally NOT reachable from the SDK yet, with rationale. +# Shape: {op_id: {param_name: "why"}}. Only the params listed for an op_id are +# 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. +_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. + "listings_historical": { + "refresh": "no client method yet — needs new Listings.historical()", + }, +} + + +def _iter_py_files(pkg_dir): + for root, _dirs, files in os.walk(pkg_dir): + for fn in files: + if not fn.endswith(".py"): + continue + if fn in ("_endpoints.py", "api.py"): + # _endpoints.py is the registry itself; api.py defines the + # generic dispatcher, not per-endpoint call sites. + continue + yield os.path.join(root, fn) + + +def _enclosing_func(parents, node): + cur = node + while cur is not None: + cur = parents.get(cur) + if isinstance(cur, (ast.FunctionDef, ast.AsyncFunctionDef)): + return cur + return None + + +def _subscript_string_keys(func_node, name): + """Collect literal keys of ``name[] = ...`` assignments in ``func``.""" + keys = set() + if func_node is None: + return keys + for n in ast.walk(func_node): + if not isinstance(n, ast.Assign): + continue + for tgt in n.targets: + if ( + isinstance(tgt, ast.Subscript) + and isinstance(tgt.value, ast.Name) + and tgt.value.id == name + and isinstance(tgt.slice, ast.Constant) + and isinstance(tgt.slice.value, str) + ): + keys.add(tgt.slice.value) + return keys + + +def _is_request_endpoint_call(node): + return ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "request_endpoint" + and node.args + and isinstance(node.args[0], ast.Constant) + and isinstance(node.args[0].value, str) + ) + + +def _forwarded_names_of_call(node, parents): + """Param names a single ``request_endpoint(...)`` call forwards.""" + names = set() + for kw in node.keywords: + if kw.arg is not None: + names.add(kw.arg) + elif isinstance(kw.value, ast.Dict): + # request_endpoint(op, **{"from": x, "to": y}) + for key in kw.value.keys: + if isinstance(key, ast.Constant) and isinstance(key.value, str): + names.add(key.value) + elif isinstance(kw.value, ast.Name): + # request_endpoint(op, **params) — resolve params[...] = ... + func = _enclosing_func(parents, node) + names |= _subscript_string_keys(func, kw.value.id) + return names + + +def _collect_forwarded_params(): + """Map ``op_id`` -> set of param names forwarded by any client call site. + + Endpoints with no call site at all are absent from the returned mapping. + """ + pkg_dir = os.path.dirname(datamaxi.__file__) + forwarded = {} + + for path in _iter_py_files(pkg_dir): + with open(path, "r", encoding="utf-8") as fh: + tree = ast.parse(fh.read(), path) + + parents = {} + for parent in ast.walk(tree): + for child in ast.iter_child_nodes(parent): + parents[child] = parent + + for node in ast.walk(tree): + if not _is_request_endpoint_call(node): + continue + op_id = node.args[0].value + forwarded.setdefault(op_id, set()).update( + _forwarded_names_of_call(node, parents) + ) + + return forwarded + + +def test_audit_extraction_sees_known_call_sites(): + """Guardrail: the AST extractor actually resolves the tricky patterns. + + If these regress (e.g. extractor stops resolving ``**params``), the main + audit could pass vacuously — pin the two non-trivial patterns here. + """ + forwarded = _collect_forwarded_params() + # premium uses the **params local-dict pattern. + assert "query" in forwarded.get("premium", set()) + assert "source_exchange" in forwarded.get("premium", set()) + # open_interest history-aggregated uses the **{"from": ...} splat. + assert "from" in forwarded.get("open_interest_history_aggregated", set()) + + +def test_allowlist_has_no_stale_entries(): + """Allow-listed params must still exist in the registry (no dead entries).""" + for op_id, params in _ALLOWLIST.items(): + assert op_id in ENDPOINTS, f"allowlist op_id {op_id!r} not in registry" + reg = set(ENDPOINTS[op_id].get("params", {})) + stale = set(params) - reg + assert not stale, f"{op_id}: allowlist params no longer in registry: {stale}" + + +def test_every_registry_param_is_exposed_or_allowlisted(): + """Every registry param is forwarded by the SDK, ignored, or allow-listed.""" + forwarded = _collect_forwarded_params() + unreachable = {} + + for op_id, ep in ENDPOINTS.items(): + reg = set(ep.get("params", {})) + exposed = forwarded.get(op_id, set()) + allowed = set(_ALLOWLIST.get(op_id, {})) + missing = reg - exposed - _IGNORED_PARAMS - allowed + if missing: + unreachable[op_id] = sorted(missing) + + assert not unreachable, ( + "Registry params not reachable from the SDK (expose them in the client " + "method or add to _ALLOWLIST with a rationale):\n" + + "\n".join(f" {op}: {params}" for op, params in sorted(unreachable.items())) + ) diff --git a/tests/test_liquidation_open_interest.py b/tests/test_liquidation_open_interest.py index ab80fcd..bd96993 100644 --- a/tests/test_liquidation_open_interest.py +++ b/tests/test_liquidation_open_interest.py @@ -55,6 +55,21 @@ def test_liquidation_feed_returns_dict(): assert _liq().feed(limit=10) == {"data": []} +@responses.activate +def test_liquidation_feed_forwards_new_params(): + responses.add( + responses.GET, + re.compile(".*/api/v1/liquidation/feed.*"), + json={"data": []}, + status=200, + ) + _liq().feed(limit=10, exchange="binance", base="BTC", min_volume_usd=1000.0) + qs = _qs(responses.calls[0]) + assert qs["exchange"] == ["binance"] + assert qs["base"] == ["BTC"] + assert qs["min_volume_usd"] == ["1000.0"] + + def test_liquidation_invalid_limit_raises_value_error(): with pytest.raises(ValueError): _liq()(exchange="binance", symbol="BTC-USDT", limit=0) diff --git a/tests/test_premium.py b/tests/test_premium.py index 008b76e..16787b7 100644 --- a/tests/test_premium.py +++ b/tests/test_premium.py @@ -69,6 +69,19 @@ def test_premium_call_param_name_translation(): assert qs["page"] == ["2"] +@responses.activate +def test_premium_call_forwards_query(): + responses.add( + responses.GET, + re.compile(".*/api/v1/premium.*"), + json=_RESPONSE, + status=200, + ) + _client()(query="BTC") + qs = _qs(responses.calls[0]) + assert qs["query"] == ["BTC"] + + @mock_http_response(responses.GET, "/api/v1/premium/exchanges", ["binance", "upbit"]) def test_premium_exchanges_returns_list(): assert _client().exchanges() == ["binance", "upbit"] diff --git a/tests/test_telegram.py b/tests/test_telegram.py index 8323434..b8917bb 100644 --- a/tests/test_telegram.py +++ b/tests/test_telegram.py @@ -66,6 +66,19 @@ def test_messages_sends_channel_param(): assert qs["channel"] == ["alpha"] +@responses.activate +def test_messages_forwards_search_query(): + responses.add( + responses.GET, + re.compile(".*/api/v1/telegram/messages.*"), + json=_MESSAGES, + status=200, + ) + _client().messages(channel_name="alpha", search_query="airdrop") + qs = _qs(responses.calls[0]) + assert qs["search_query"] == ["airdrop"] + + def test_channels_invalid_sort_raises_value_error(): with pytest.raises(ValueError): _client().channels(sort="bogus")