From 4b399243c1cb85f2a138ca6526bc4298fae71927 Mon Sep 17 00:00:00 2001 From: Martin Kersner Date: Tue, 30 Jun 2026 13:55:46 +0900 Subject: [PATCH 1/3] test: add mocked local coverage for cex-family modules Cover cex aggregator wiring + candle/ticker/fee/wallet_status/ announcement/token/symbol: happy-path shape, wire query params, ClientError/ServerError. Offline, no API key (responses-mocked). --- tests/test_cex.py | 35 +++++++++ tests/test_cex_announcement.py | 74 ++++++++++++++++++ tests/test_cex_candle.py | 88 ++++++++++++++++++++++ tests/test_cex_fee.py | 68 +++++++++++++++++ tests/test_cex_symbol.py | 129 ++++++++++++++++++++++++++++++++ tests/test_cex_ticker.py | 84 +++++++++++++++++++++ tests/test_cex_token.py | 69 +++++++++++++++++ tests/test_cex_wallet_status.py | 83 ++++++++++++++++++++ 8 files changed, 630 insertions(+) create mode 100644 tests/test_cex.py create mode 100644 tests/test_cex_announcement.py create mode 100644 tests/test_cex_candle.py create mode 100644 tests/test_cex_fee.py create mode 100644 tests/test_cex_symbol.py create mode 100644 tests/test_cex_ticker.py create mode 100644 tests/test_cex_token.py create mode 100644 tests/test_cex_wallet_status.py diff --git a/tests/test_cex.py b/tests/test_cex.py new file mode 100644 index 0000000..7081816 --- /dev/null +++ b/tests/test_cex.py @@ -0,0 +1,35 @@ +"""Local (mocked) tests for the Cex aggregator wiring. No API key / network.""" + +import responses + +from datamaxi.datamaxi.cex import Cex +from datamaxi.datamaxi.cex_candle import CexCandle +from datamaxi.datamaxi.cex_ticker import CexTicker +from datamaxi.datamaxi.cex_fee import CexFee +from datamaxi.datamaxi.cex_wallet_status import CexWalletStatus +from datamaxi.datamaxi.cex_announcement import CexAnnouncement +from datamaxi.datamaxi.cex_token import CexToken +from datamaxi.datamaxi.cex_symbol import CexSymbol +from tests.util import mock_http_response + +BASE_URL = "https://api.datamaxiplus.com" + + +def _client(): + return Cex(api_key="key", base_url=BASE_URL) + + +def test_cex_wires_subclients(): + cex = _client() + assert isinstance(cex.candle, CexCandle) + assert isinstance(cex.ticker, CexTicker) + assert isinstance(cex.fee, CexFee) + assert isinstance(cex.wallet_status, CexWalletStatus) + assert isinstance(cex.announcement, CexAnnouncement) + assert isinstance(cex.token, CexToken) + assert isinstance(cex.symbol, CexSymbol) + + +@mock_http_response(responses.GET, "/api/v1/cex/candle/intervals", ["1m", "1h", "1d"]) +def test_cex_candle_call_through_facade(): + assert _client().candle.intervals() == ["1m", "1h", "1d"] diff --git a/tests/test_cex_announcement.py b/tests/test_cex_announcement.py new file mode 100644 index 0000000..00ce693 --- /dev/null +++ b/tests/test_cex_announcement.py @@ -0,0 +1,74 @@ +"""Local (mocked) tests for the CexAnnouncement client. No API key / network.""" + +import re +import responses +import pytest +from urllib.parse import urlparse, parse_qs + +from datamaxi.datamaxi.cex_announcement import CexAnnouncement +from datamaxi.error import ClientError, ServerError +from tests.util import mock_http_response + +BASE_URL = "https://api.datamaxiplus.com" + +_ANNOUNCE = {"data": [{"title": "New listing", "exchange": "binance"}]} + + +def _client(): + return CexAnnouncement(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/cex/announcements", _ANNOUNCE) +def test_announcement_returns_response_and_next(): + res, next_request = _client()() + assert res == _ANNOUNCE + assert callable(next_request) + + +@responses.activate +def test_announcement_sends_query_params(): + responses.add( + responses.GET, + re.compile(".*/api/v1/cex/announcements.*"), + json=_ANNOUNCE, + status=200, + ) + _client()(page=2, limit=50, exchange="binance") + qs = _qs(responses.calls[0]) + assert qs["page"] == ["2"] + assert qs["limit"] == ["50"] + assert qs["sort"] == ["desc"] + assert qs["exchange"] == ["binance"] + + +def test_announcement_invalid_sort_raises_value_error(): + with pytest.raises(ValueError): + _client()(sort="bogus") + + +def test_announcement_invalid_page_raises_value_error(): + with pytest.raises(ValueError): + _client()(page=0) + + +@mock_http_response( + responses.GET, + "/api/v1/cex/announcements", + {"error": "bad request"}, + http_status=400, +) +def test_announcement_client_error(): + with pytest.raises(ClientError): + _client()() + + +@mock_http_response( + responses.GET, "/api/v1/cex/announcements", {"error": "boom"}, http_status=500 +) +def test_announcement_server_error(): + with pytest.raises(ServerError): + _client()() diff --git a/tests/test_cex_candle.py b/tests/test_cex_candle.py new file mode 100644 index 0000000..bcb290e --- /dev/null +++ b/tests/test_cex_candle.py @@ -0,0 +1,88 @@ +"""Local (mocked) tests for the CexCandle client. No API key / network.""" + +import re +import responses +import pandas as pd +import pytest +from urllib.parse import urlparse, parse_qs + +from datamaxi.datamaxi.cex_candle import CexCandle +from datamaxi.error import ClientError, ServerError +from tests.util import mock_http_response + +BASE_URL = "https://api.datamaxiplus.com" + +_CANDLE = {"data": [{"d": "1700000000", "o": "100", "h": "110", "l": "90", "c": "105"}]} + + +def _client(): + return CexCandle(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/cex/candle", _CANDLE) +def test_candle_returns_dataframe(): + df = _client()(exchange="binance", market="spot", symbol="BTC-USDT") + assert isinstance(df, pd.DataFrame) + assert "c" in df.columns + assert len(df) == 1 + + +@responses.activate +def test_candle_sends_query_params(): + responses.add( + responses.GET, + re.compile(".*/api/v1/cex/candle.*"), + json=_CANDLE, + status=200, + ) + _client()(exchange="binance", market="spot", symbol="BTC-USDT", interval="1h") + qs = _qs(responses.calls[0]) + assert qs["exchange"] == ["binance"] + assert qs["market"] == ["spot"] + assert qs["symbol"] == ["BTC-USDT"] + assert qs["interval"] == ["1h"] + assert qs["currency"] == ["USD"] + assert "from" not in qs and "to" not in qs + + +def test_candle_invalid_market_raises_value_error(): + with pytest.raises(ValueError): + _client()(exchange="binance", market="bogus", symbol="BTC-USDT") + + +@mock_http_response(responses.GET, "/api/v1/cex/candle/exchanges", ["binance", "okx"]) +def test_candle_exchanges_returns_list(): + assert _client().exchanges(market="spot") == ["binance", "okx"] + + +@mock_http_response( + responses.GET, "/api/v1/cex/candle/symbols", [{"symbol": "BTC-USDT"}] +) +def test_candle_symbols_returns_list(): + res = _client().symbols(exchange="binance", market="spot") + assert res == [{"symbol": "BTC-USDT"}] + + +@mock_http_response(responses.GET, "/api/v1/cex/candle/intervals", ["1m", "1h", "1d"]) +def test_candle_intervals_returns_list(): + assert _client().intervals() == ["1m", "1h", "1d"] + + +@mock_http_response( + responses.GET, "/api/v1/cex/candle", {"error": "bad request"}, http_status=400 +) +def test_candle_client_error(): + with pytest.raises(ClientError): + _client()(exchange="binance", market="spot", symbol="BTC-USDT") + + +@mock_http_response( + responses.GET, "/api/v1/cex/candle", {"error": "boom"}, http_status=500 +) +def test_candle_server_error(): + with pytest.raises(ServerError): + _client()(exchange="binance", market="spot", symbol="BTC-USDT") diff --git a/tests/test_cex_fee.py b/tests/test_cex_fee.py new file mode 100644 index 0000000..ca1865a --- /dev/null +++ b/tests/test_cex_fee.py @@ -0,0 +1,68 @@ +"""Local (mocked) tests for the CexFee client. No API key / network.""" + +import re +import responses +import pytest +from urllib.parse import urlparse, parse_qs + +from datamaxi.datamaxi.cex_fee import CexFee +from datamaxi.error import ClientError, ServerError +from tests.util import mock_http_response + +BASE_URL = "https://api.datamaxiplus.com" + +_FEE = [{"exchange": "binance", "symbol": "BTC-USDT", "maker": "0.001"}] + + +def _client(): + return CexFee(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/cex/fees", _FEE) +def test_fee_call_returns_list(): + res = _client()(exchange="binance", symbol="BTC-USDT") + assert res == _FEE + + +@responses.activate +def test_fee_call_sends_query_params(): + responses.add( + responses.GET, + re.compile(".*/api/v1/cex/fees.*"), + json=_FEE, + status=200, + ) + _client()(exchange="binance", symbol="BTC-USDT") + qs = _qs(responses.calls[0]) + assert qs["exchange"] == ["binance"] + assert qs["symbol"] == ["BTC-USDT"] + + +@mock_http_response(responses.GET, "/api/v1/cex/fees/exchanges", ["binance", "okx"]) +def test_fee_exchanges_returns_list(): + assert _client().exchanges() == ["binance", "okx"] + + +@mock_http_response(responses.GET, "/api/v1/cex/fees/symbols", ["BTC-USDT", "ETH-USDT"]) +def test_fee_symbols_returns_list(): + assert _client().symbols(exchange="binance") == ["BTC-USDT", "ETH-USDT"] + + +@mock_http_response( + responses.GET, "/api/v1/cex/fees", {"error": "bad request"}, http_status=400 +) +def test_fee_client_error(): + with pytest.raises(ClientError): + _client()(exchange="binance") + + +@mock_http_response( + responses.GET, "/api/v1/cex/fees", {"error": "boom"}, http_status=500 +) +def test_fee_server_error(): + with pytest.raises(ServerError): + _client()(exchange="binance") diff --git a/tests/test_cex_symbol.py b/tests/test_cex_symbol.py new file mode 100644 index 0000000..3587178 --- /dev/null +++ b/tests/test_cex_symbol.py @@ -0,0 +1,129 @@ +"""Local (mocked) tests for the CexSymbol client. No API key / network.""" + +import re +import responses +import pytest +from urllib.parse import urlparse, parse_qs + +from datamaxi.datamaxi.cex_symbol import CexSymbol +from datamaxi.error import ClientError, ServerError +from tests.util import mock_http_response + +BASE_URL = "https://api.datamaxiplus.com" + + +def _client(): + return CexSymbol(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/cex/symbol/metadata", {"BTC": {"status": "trading"}} +) +def test_metadata_returns_dict(): + res = _client().metadata(exchange="binance", base="BTC") + assert res == {"BTC": {"status": "trading"}} + + +@responses.activate +def test_metadata_sends_query_params(): + responses.add( + responses.GET, + re.compile(".*/api/v1/cex/symbol/metadata.*"), + json={}, + status=200, + ) + _client().metadata(exchange="binance", base="BTC") + qs = _qs(responses.calls[0]) + assert qs["exchange"] == ["binance"] + assert qs["base"] == ["BTC"] + + +@mock_http_response(responses.GET, "/api/v1/cex/symbol/tags", {"BTC": ["seed"]}) +def test_tags_returns_dict(): + assert _client().tags(base="BTC") == {"BTC": ["seed"]} + + +@mock_http_response(responses.GET, "/api/v1/cex/symbol/cautions", {"BTC": []}) +def test_cautions_returns_dict(): + assert _client().cautions(base="BTC") == {"BTC": []} + + +@mock_http_response(responses.GET, "/api/v1/cex/symbol/delistings", {"BTC": []}) +def test_delistings_returns_dict(): + assert _client().delistings(exchange="binance") == {"BTC": []} + + +@responses.activate +def test_volume_sends_base_param(): + responses.add( + responses.GET, + re.compile(".*/api/v1/cex/symbol/volume.*"), + json={"BTC": {"binance": "100"}}, + status=200, + ) + res = _client().volume(base="BTC", exchange="binance") + qs = _qs(responses.calls[0]) + assert qs["base"] == ["BTC"] + assert qs["exchange"] == ["binance"] + assert res == {"BTC": {"binance": "100"}} + + +@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"}} + + +@responses.activate +def test_oi_stats_sends_currency_param(): + responses.add( + responses.GET, + re.compile(".*/api/v1/cex/symbol/oi-stats.*"), + json={"BTC": {}}, + status=200, + ) + _client().oi_stats(base="BTC", currency="KRW") + qs = _qs(responses.calls[0]) + assert qs["base"] == ["BTC"] + assert qs["currency"] == ["KRW"] + + +def test_oi_stats_invalid_currency_raises_value_error(): + with pytest.raises(ValueError): + _client().oi_stats(base="BTC", currency="EUR") + + +@responses.activate +def test_liquidation_sends_window_param(): + responses.add( + responses.GET, + re.compile(".*/api/v1/cex/symbol/liquidation.*"), + json={"BTC": {}}, + status=200, + ) + _client().liquidation(base="BTC", window="7d") + qs = _qs(responses.calls[0]) + assert qs["base"] == ["BTC"] + assert qs["window"] == ["7d"] + + +@mock_http_response( + responses.GET, + "/api/v1/cex/symbol/metadata", + {"error": "bad request"}, + http_status=400, +) +def test_metadata_client_error(): + with pytest.raises(ClientError): + _client().metadata(base="BTC") + + +@mock_http_response( + responses.GET, "/api/v1/cex/symbol/metadata", {"error": "boom"}, http_status=500 +) +def test_metadata_server_error(): + with pytest.raises(ServerError): + _client().metadata(base="BTC") diff --git a/tests/test_cex_ticker.py b/tests/test_cex_ticker.py new file mode 100644 index 0000000..6cb9ef1 --- /dev/null +++ b/tests/test_cex_ticker.py @@ -0,0 +1,84 @@ +"""Local (mocked) tests for the CexTicker client. No API key / network.""" + +import re +import responses +import pandas as pd +import pytest +from urllib.parse import urlparse, parse_qs + +from datamaxi.datamaxi.cex_ticker import CexTicker +from datamaxi.error import ClientError, ServerError +from tests.util import mock_http_response + +BASE_URL = "https://api.datamaxiplus.com" + +_TICKER = {"data": {"d": "1700000000", "p": "105.5"}} + + +def _client(): + return CexTicker(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/ticker", _TICKER) +def test_ticker_get_returns_dataframe(): + df = _client().get(exchange="binance", market="spot", symbol="BTC-USDT") + assert isinstance(df, pd.DataFrame) + assert "p" in df.columns + assert len(df) == 1 + + +@responses.activate +def test_ticker_get_sends_query_params(): + responses.add( + responses.GET, + re.compile(".*/api/v1/ticker.*"), + json=_TICKER, + status=200, + ) + _client().get( + exchange="binance", + market="spot", + symbol="BTC-USDT", + conversion_base="USDT", + ) + qs = _qs(responses.calls[0]) + assert qs["exchange"] == ["binance"] + assert qs["market"] == ["spot"] + assert qs["symbol"] == ["BTC-USDT"] + assert qs["conversion_base"] == ["USDT"] + + +def test_ticker_invalid_market_raises_value_error(): + with pytest.raises(ValueError): + _client().get(exchange="binance", market="bogus", symbol="BTC-USDT") + + +@mock_http_response(responses.GET, "/api/v1/ticker/exchanges", ["binance", "okx"]) +def test_ticker_exchanges_returns_list(): + assert _client().exchanges(market="spot") == ["binance", "okx"] + + +@mock_http_response(responses.GET, "/api/v1/ticker/symbols", ["BTC-USDT", "ETH-USDT"]) +def test_ticker_symbols_returns_list(): + assert _client().symbols(exchange="binance", market="spot") == [ + "BTC-USDT", + "ETH-USDT", + ] + + +@mock_http_response( + responses.GET, "/api/v1/ticker", {"error": "bad request"}, http_status=400 +) +def test_ticker_client_error(): + with pytest.raises(ClientError): + _client().get(exchange="binance", market="spot", symbol="BTC-USDT") + + +@mock_http_response(responses.GET, "/api/v1/ticker", {"error": "boom"}, http_status=500) +def test_ticker_server_error(): + with pytest.raises(ServerError): + _client().get(exchange="binance", market="spot", symbol="BTC-USDT") diff --git a/tests/test_cex_token.py b/tests/test_cex_token.py new file mode 100644 index 0000000..7f8c42c --- /dev/null +++ b/tests/test_cex_token.py @@ -0,0 +1,69 @@ +"""Local (mocked) tests for the CexToken client. No API key / network.""" + +import re +import responses +import pytest +from urllib.parse import urlparse, parse_qs + +from datamaxi.datamaxi.cex_token import CexToken +from datamaxi.error import ClientError, ServerError +from tests.util import mock_http_response + +BASE_URL = "https://api.datamaxiplus.com" + +_UPDATES = {"data": [{"token": "BTC", "type": "listed"}]} + + +def _client(): + return CexToken(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/cex/token/updates", _UPDATES) +def test_token_updates_returns_response_and_next(): + res, next_request = _client().updates() + assert res == _UPDATES + assert callable(next_request) + + +@responses.activate +def test_token_updates_sends_query_params(): + responses.add( + responses.GET, + re.compile(".*/api/v1/cex/token/updates.*"), + json=_UPDATES, + status=200, + ) + _client().updates(page=3, limit=10, type="listed") + qs = _qs(responses.calls[0]) + assert qs["page"] == ["3"] + assert qs["limit"] == ["10"] + assert qs["type"] == ["listed"] + assert qs["sort"] == ["desc"] + + +def test_token_updates_invalid_type_raises_value_error(): + with pytest.raises(ValueError): + _client().updates(type="bogus") + + +@mock_http_response( + responses.GET, + "/api/v1/cex/token/updates", + {"error": "bad request"}, + http_status=400, +) +def test_token_updates_client_error(): + with pytest.raises(ClientError): + _client().updates() + + +@mock_http_response( + responses.GET, "/api/v1/cex/token/updates", {"error": "boom"}, http_status=500 +) +def test_token_updates_server_error(): + with pytest.raises(ServerError): + _client().updates() diff --git a/tests/test_cex_wallet_status.py b/tests/test_cex_wallet_status.py new file mode 100644 index 0000000..4dc8188 --- /dev/null +++ b/tests/test_cex_wallet_status.py @@ -0,0 +1,83 @@ +"""Local (mocked) tests for the CexWalletStatus client. No API key / network.""" + +import re +import responses +import pandas as pd +import pytest +from urllib.parse import urlparse, parse_qs + +from datamaxi.datamaxi.cex_wallet_status import CexWalletStatus +from datamaxi.error import ClientError, ServerError +from tests.util import mock_http_response + +BASE_URL = "https://api.datamaxiplus.com" + +_STATUS = [ + {"network": "BSC", "depositEnable": True, "withdrawEnable": True}, + {"network": "ETH", "depositEnable": False, "withdrawEnable": True}, +] + + +def _client(): + return CexWalletStatus(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/wallet-status", _STATUS) +def test_wallet_status_returns_dataframe(): + df = _client()(exchange="binance", asset="BTC") + assert isinstance(df, pd.DataFrame) + assert df.index.name == "network" + assert "depositEnable" in df.columns + assert len(df) == 2 + + +@mock_http_response(responses.GET, "/api/v1/wallet-status", _STATUS) +def test_wallet_status_pandas_false_returns_raw(): + res = _client()(exchange="binance", asset="BTC", pandas=False) + assert res == _STATUS + + +@responses.activate +def test_wallet_status_sends_query_params(): + responses.add( + responses.GET, + re.compile(".*/api/v1/wallet-status.*"), + json=_STATUS, + status=200, + ) + _client()(exchange="binance", asset="BTC") + qs = _qs(responses.calls[0]) + assert qs["exchange"] == ["binance"] + assert qs["asset"] == ["BTC"] + + +@mock_http_response( + responses.GET, "/api/v1/wallet-status/exchanges", ["binance", "okx"] +) +def test_wallet_status_exchanges_returns_list(): + assert _client().exchanges() == ["binance", "okx"] + + +@mock_http_response(responses.GET, "/api/v1/wallet-status/assets", ["BTC", "ETH"]) +def test_wallet_status_assets_returns_list(): + assert _client().assets(exchange="binance") == ["BTC", "ETH"] + + +@mock_http_response( + responses.GET, "/api/v1/wallet-status", {"error": "bad request"}, http_status=400 +) +def test_wallet_status_client_error(): + with pytest.raises(ClientError): + _client()(exchange="binance", asset="BTC") + + +@mock_http_response( + responses.GET, "/api/v1/wallet-status", {"error": "boom"}, http_status=500 +) +def test_wallet_status_server_error(): + with pytest.raises(ServerError): + _client()(exchange="binance", asset="BTC") From daf5e53599a2ab46da4c950ae686a017686e624f Mon Sep 17 00:00:00 2001 From: Martin Kersner Date: Tue, 30 Jun 2026 13:55:51 +0900 Subject: [PATCH 2/3] test: add mocked local coverage for top-level + naver/telegram modules Cover forex/premium/funding_rate/naver/telegram plus return-shape and error coverage for liquidation/open_interest: happy-path shape, wire query params, ClientError/ServerError. Offline, no API key. Local coverage 34% -> 76%. --- tests/test_forex.py | 73 ++++++++++++++ tests/test_funding_rate.py | 89 +++++++++++++++++ tests/test_liquidation_open_interest.py | 127 ++++++++++++++++++++++++ tests/test_naver.py | 70 +++++++++++++ tests/test_premium.py | 84 ++++++++++++++++ tests/test_telegram.py | 90 +++++++++++++++++ 6 files changed, 533 insertions(+) create mode 100644 tests/test_forex.py create mode 100644 tests/test_funding_rate.py create mode 100644 tests/test_liquidation_open_interest.py create mode 100644 tests/test_naver.py create mode 100644 tests/test_premium.py create mode 100644 tests/test_telegram.py diff --git a/tests/test_forex.py b/tests/test_forex.py new file mode 100644 index 0000000..4d60904 --- /dev/null +++ b/tests/test_forex.py @@ -0,0 +1,73 @@ +"""Local (mocked) tests for the Forex client. No API key / network.""" + +import re +import responses +import pandas as pd +import pytest +from urllib.parse import urlparse, parse_qs + +from datamaxi.datamaxi.forex import Forex +from datamaxi.error import ClientError, ServerError +from tests.util import mock_http_response + +BASE_URL = "https://api.datamaxiplus.com" + + +def _client(): + return Forex(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/forex", {"symbol": "USD-KRW", "price": "1380.5"} +) +def test_forex_call_returns_dataframe(): + df = _client()(symbol="USD-KRW") + assert isinstance(df, pd.DataFrame) + assert len(df) == 1 + assert df.iloc[0]["symbol"] == "USD-KRW" + + +@mock_http_response( + responses.GET, "/api/v1/forex", {"symbol": "USD-KRW", "price": "1380.5"} +) +def test_forex_call_pandas_false_returns_dict(): + res = _client()(symbol="USD-KRW", pandas=False) + assert isinstance(res, dict) + assert res["symbol"] == "USD-KRW" + + +@responses.activate +def test_forex_call_sends_symbol_query(): + responses.add( + responses.GET, + re.compile(".*/api/v1/forex.*"), + json={"symbol": "USD-KRW"}, + status=200, + ) + _client()(symbol="USD-KRW") + qs = _qs(responses.calls[0]) + assert qs["symbol"] == ["USD-KRW"] + + +@mock_http_response(responses.GET, "/api/v1/forex/symbols", ["USD-KRW", "EUR-USD"]) +def test_forex_symbols_returns_list(): + res = _client().symbols() + assert res == ["USD-KRW", "EUR-USD"] + + +@mock_http_response( + responses.GET, "/api/v1/forex", {"error": "bad request"}, http_status=400 +) +def test_forex_client_error(): + with pytest.raises(ClientError): + _client()(symbol="USD-KRW") + + +@mock_http_response(responses.GET, "/api/v1/forex", {"error": "boom"}, http_status=500) +def test_forex_server_error(): + with pytest.raises(ServerError): + _client()(symbol="USD-KRW") diff --git a/tests/test_funding_rate.py b/tests/test_funding_rate.py new file mode 100644 index 0000000..9e383d3 --- /dev/null +++ b/tests/test_funding_rate.py @@ -0,0 +1,89 @@ +"""Local (mocked) tests for the FundingRate client. No API key / network.""" + +import re +import responses +import pandas as pd +import pytest +from urllib.parse import urlparse, parse_qs + +from datamaxi.datamaxi.funding_rate import FundingRate +from datamaxi.error import ClientError, ServerError +from tests.util import mock_http_response + +BASE_URL = "https://api.datamaxiplus.com" + +_HISTORY = {"data": [{"d": "1700000000", "r": "0.0001"}]} +_LATEST = {"d": "1700000000", "r": "0.0001", "symbol": "BTC-USDT"} + + +def _client(): + return FundingRate(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/funding-rate/history", _HISTORY) +def test_history_returns_dataframe_and_next(): + df, next_request = _client().history(exchange="binance", symbol="BTC-USDT") + assert isinstance(df, pd.DataFrame) + assert callable(next_request) + assert "r" in df.columns + + +@responses.activate +def test_history_sends_query_params(): + responses.add( + responses.GET, + re.compile(".*/api/v1/funding-rate/history.*"), + json=_HISTORY, + status=200, + ) + _client().history(exchange="binance", symbol="BTC-USDT", limit=50) + qs = _qs(responses.calls[0]) + assert qs["exchange"] == ["binance"] + assert qs["symbol"] == ["BTC-USDT"] + assert qs["limit"] == ["50"] + assert qs["sort"] == ["desc"] + assert "from" not in qs and "to" not in qs + + +@mock_http_response(responses.GET, "/api/v1/funding-rate/latest", _LATEST) +def test_latest_returns_dataframe(): + df = _client().latest(exchange="binance", symbol="BTC-USDT") + assert isinstance(df, pd.DataFrame) + assert df.iloc[0]["symbol"] == "BTC-USDT" + + +@mock_http_response( + responses.GET, "/api/v1/funding-rate/exchanges", ["binance", "bybit"] +) +def test_exchanges_returns_list(): + assert _client().exchanges() == ["binance", "bybit"] + + +@mock_http_response( + responses.GET, "/api/v1/funding-rate/symbols", ["BTC-USDT", "ETH-USDT"] +) +def test_symbols_returns_list(): + assert _client().symbols(exchange="binance") == ["BTC-USDT", "ETH-USDT"] + + +@mock_http_response( + responses.GET, + "/api/v1/funding-rate/history", + {"error": "bad request"}, + http_status=400, +) +def test_history_client_error(): + with pytest.raises(ClientError): + _client().history(exchange="binance", symbol="BTC-USDT") + + +@mock_http_response( + responses.GET, "/api/v1/funding-rate/latest", {"error": "boom"}, http_status=500 +) +def test_latest_server_error(): + with pytest.raises(ServerError): + _client().latest(exchange="binance", symbol="BTC-USDT") diff --git a/tests/test_liquidation_open_interest.py b/tests/test_liquidation_open_interest.py new file mode 100644 index 0000000..ab80fcd --- /dev/null +++ b/tests/test_liquidation_open_interest.py @@ -0,0 +1,127 @@ +"""Local (mocked) happy-path + error tests for Liquidation / OpenInterest. + +Query-param translation (topN -> top_n) is covered in test_query_params.py; +this file adds return-shape and error-handling coverage. +""" + +import re +import responses +import pytest +from urllib.parse import urlparse, parse_qs + +from datamaxi.datamaxi.liquidation import Liquidation +from datamaxi.datamaxi.open_interest import OpenInterest +from datamaxi.error import ClientError, ServerError +from tests.util import mock_http_response + +BASE_URL = "https://api.datamaxiplus.com" + + +def _liq(): + return Liquidation(api_key="key", base_url=BASE_URL) + + +def _oi(): + return OpenInterest(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/liquidation", {"data": [{"id": 1}]}) +def test_liquidation_call_returns_dict(): + res = _liq()(exchange="binance", symbol="BTC-USDT") + assert res == {"data": [{"id": 1}]} + + +@responses.activate +def test_liquidation_call_sends_query_params(): + responses.add( + responses.GET, + re.compile(".*/api/v1/liquidation.*"), + json={"data": []}, + status=200, + ) + _liq()(exchange="binance", symbol="BTC-USDT", limit=5) + qs = _qs(responses.calls[0]) + assert qs["exchange"] == ["binance"] + assert qs["symbol"] == ["BTC-USDT"] + assert qs["limit"] == ["5"] + + +@mock_http_response(responses.GET, "/api/v1/liquidation/feed", {"data": []}) +def test_liquidation_feed_returns_dict(): + assert _liq().feed(limit=10) == {"data": []} + + +def test_liquidation_invalid_limit_raises_value_error(): + with pytest.raises(ValueError): + _liq()(exchange="binance", symbol="BTC-USDT", limit=0) + + +def test_liquidation_heatmap_invalid_top_n_raises_value_error(): + with pytest.raises(ValueError): + _liq().heatmap(topN=99) + + +@mock_http_response( + responses.GET, "/api/v1/liquidation", {"error": "bad request"}, http_status=400 +) +def test_liquidation_client_error(): + with pytest.raises(ClientError): + _liq()(exchange="binance", symbol="BTC-USDT") + + +@mock_http_response( + responses.GET, "/api/v1/liquidation", {"error": "boom"}, http_status=500 +) +def test_liquidation_server_error(): + with pytest.raises(ServerError): + _liq()(exchange="binance", symbol="BTC-USDT") + + +@mock_http_response(responses.GET, "/api/v1/open-interest", {"oi": "100"}) +def test_open_interest_call_returns_dict(): + assert _oi()(exchange="binance", symbol="BTC-USDT") == {"oi": "100"} + + +@responses.activate +def test_open_interest_history_aggregated_sends_from_to(): + responses.add( + responses.GET, + re.compile(".*/api/v1/open-interest/history-aggregated.*"), + json={"data": []}, + status=200, + ) + _oi().history_aggregated(token_id="bitcoin", from_=111, to=222) + qs = _qs(responses.calls[0]) + assert qs["token_id"] == ["bitcoin"] + assert qs["from"] == ["111"] + assert qs["to"] == ["222"] + + +@mock_http_response(responses.GET, "/api/v1/open-interest/list", {"data": []}) +def test_open_interest_list_returns_dict(): + assert _oi().list(exchange="binance") == {"data": []} + + +def test_open_interest_overview_invalid_sort_raises_value_error(): + with pytest.raises(ValueError): + _oi().overview(sort="bogus") + + +@mock_http_response( + responses.GET, "/api/v1/open-interest", {"error": "bad request"}, http_status=400 +) +def test_open_interest_client_error(): + with pytest.raises(ClientError): + _oi()(exchange="binance", symbol="BTC-USDT") + + +@mock_http_response( + responses.GET, "/api/v1/open-interest", {"error": "boom"}, http_status=500 +) +def test_open_interest_server_error(): + with pytest.raises(ServerError): + _oi()(exchange="binance", symbol="BTC-USDT") diff --git a/tests/test_naver.py b/tests/test_naver.py new file mode 100644 index 0000000..a70d46a --- /dev/null +++ b/tests/test_naver.py @@ -0,0 +1,70 @@ +"""Local (mocked) tests for the Naver client. No API key / network.""" + +import re +import responses +import pandas as pd +import pytest +from urllib.parse import urlparse, parse_qs + +from datamaxi.naver import Naver +from datamaxi.error import ClientError, ServerError +from tests.util import mock_http_response + +BASE_URL = "https://api.datamaxiplus.com" + +_TREND = [{"d": "2024-01-01", "v": 10}, {"d": "2024-01-02", "v": 20}] + + +def _client(): + return Naver(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/naver-trend/symbols", ["BTC", "ETH"]) +def test_naver_symbols_returns_list(): + assert _client().symbols() == ["BTC", "ETH"] + + +@mock_http_response(responses.GET, "/api/v1/naver-trend", _TREND) +def test_naver_trend_returns_dataframe(): + df = _client().trend("BTC") + assert isinstance(df, pd.DataFrame) + assert len(df) == 2 + assert "v" in df.columns + + +@mock_http_response(responses.GET, "/api/v1/naver-trend", _TREND) +def test_naver_trend_pandas_false_returns_raw(): + assert _client().trend("BTC", pandas=False) == _TREND + + +@responses.activate +def test_naver_trend_sends_symbol_query(): + responses.add( + responses.GET, + re.compile(".*/api/v1/naver-trend.*"), + json=_TREND, + status=200, + ) + _client().trend("BTC") + qs = _qs(responses.calls[0]) + assert qs["symbol"] == ["BTC"] + + +@mock_http_response( + responses.GET, "/api/v1/naver-trend", {"error": "bad request"}, http_status=400 +) +def test_naver_trend_client_error(): + with pytest.raises(ClientError): + _client().trend("BTC") + + +@mock_http_response( + responses.GET, "/api/v1/naver-trend", {"error": "boom"}, http_status=500 +) +def test_naver_trend_server_error(): + with pytest.raises(ServerError): + _client().trend("BTC") diff --git a/tests/test_premium.py b/tests/test_premium.py new file mode 100644 index 0000000..5ba5669 --- /dev/null +++ b/tests/test_premium.py @@ -0,0 +1,84 @@ +"""Local (mocked) tests for the Premium client. No API key / network.""" + +import re +import responses +import pandas as pd +import pytest +from urllib.parse import urlparse, parse_qs + +from datamaxi.datamaxi.premium import Premium +from datamaxi.error import ClientError, ServerError +from tests.util import mock_http_response + +BASE_URL = "https://api.datamaxiplus.com" + +_RESPONSE = { + "data": [ + { + "detail": {"asset": "BTC", "pdp": "1.5"}, + "source_annualized_funding_rate": "0.1", + "target_annualized_funding_rate": "0.2", + } + ] +} + + +def _client(): + return Premium(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/premium", _RESPONSE) +def test_premium_call_returns_dataframe(): + df = _client()() + assert isinstance(df, pd.DataFrame) + assert len(df) == 1 + assert df.iloc[0]["asset"] == "BTC" + assert "source_annualized_funding_rate" in df.columns + + +@mock_http_response(responses.GET, "/api/v1/premium", _RESPONSE) +def test_premium_call_pandas_false_returns_raw(): + res = _client()(pandas=False) + assert isinstance(res, dict) + assert res["data"][0]["detail"]["asset"] == "BTC" + + +@responses.activate +def test_premium_call_param_name_translation(): + responses.add( + responses.GET, + re.compile(".*/api/v1/premium.*"), + json=_RESPONSE, + status=200, + ) + _client()(source_exchange="binance", conversion_base="USDT", page=2) + qs = _qs(responses.calls[0]) + # camelCase wire keys for exchange params, snake_case for conversion_base + assert qs["sourceExchange"] == ["binance"] + assert qs["conversion_base"] == ["USDT"] + assert qs["page"] == ["2"] + + +@mock_http_response(responses.GET, "/api/v1/premium/exchanges", ["binance", "upbit"]) +def test_premium_exchanges_returns_list(): + assert _client().exchanges() == ["binance", "upbit"] + + +@mock_http_response( + responses.GET, "/api/v1/premium", {"error": "bad request"}, http_status=400 +) +def test_premium_client_error(): + with pytest.raises(ClientError): + _client()() + + +@mock_http_response( + responses.GET, "/api/v1/premium", {"error": "boom"}, http_status=500 +) +def test_premium_server_error(): + with pytest.raises(ServerError): + _client()() diff --git a/tests/test_telegram.py b/tests/test_telegram.py new file mode 100644 index 0000000..8323434 --- /dev/null +++ b/tests/test_telegram.py @@ -0,0 +1,90 @@ +"""Local (mocked) tests for the Telegram client. No API key / network.""" + +import re +import responses +import pytest +from urllib.parse import urlparse, parse_qs + +from datamaxi.telegram import Telegram +from datamaxi.error import ClientError, ServerError +from tests.util import mock_http_response + +BASE_URL = "https://api.datamaxiplus.com" + +_CHANNELS = {"data": [{"name": "alpha", "category": "news"}]} +_MESSAGES = {"data": [{"channel": "alpha", "text": "gm"}]} + + +def _client(): + return Telegram(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/telegram/channels", _CHANNELS) +def test_channels_returns_response_and_next(): + res, next_request = _client().channels() + assert res == _CHANNELS + assert callable(next_request) + + +@responses.activate +def test_channels_sends_query_params(): + responses.add( + responses.GET, + re.compile(".*/api/v1/telegram/channels.*"), + json=_CHANNELS, + status=200, + ) + _client().channels(page=2, limit=50, category="news") + qs = _qs(responses.calls[0]) + assert qs["page"] == ["2"] + assert qs["limit"] == ["50"] + assert qs["sort"] == ["desc"] + assert qs["category"] == ["news"] + + +@mock_http_response(responses.GET, "/api/v1/telegram/messages", _MESSAGES) +def test_messages_returns_response_and_next(): + res, next_request = _client().messages(channel_name="alpha") + assert res == _MESSAGES + assert callable(next_request) + + +@responses.activate +def test_messages_sends_channel_param(): + responses.add( + responses.GET, + re.compile(".*/api/v1/telegram/messages.*"), + json=_MESSAGES, + status=200, + ) + _client().messages(channel_name="alpha") + qs = _qs(responses.calls[0]) + assert qs["channel"] == ["alpha"] + + +def test_channels_invalid_sort_raises_value_error(): + with pytest.raises(ValueError): + _client().channels(sort="bogus") + + +@mock_http_response( + responses.GET, + "/api/v1/telegram/channels", + {"error": "bad request"}, + http_status=400, +) +def test_channels_client_error(): + with pytest.raises(ClientError): + _client().channels() + + +@mock_http_response( + responses.GET, "/api/v1/telegram/messages", {"error": "boom"}, http_status=500 +) +def test_messages_server_error(): + with pytest.raises(ServerError): + _client().messages(channel_name="alpha") From 25592d927cc40f9947c77a5792345f2fedc974a5 Mon Sep 17 00:00:00 2001 From: Martin Kersner Date: Tue, 30 Jun 2026 14:01:05 +0900 Subject: [PATCH 3/3] ci: run offline mocked tests in CI Add a keyless pytest step after flake8 running `pytest tests/ -k "not integration"`. Excludes the live test_integration.py; keyed test_call.py tests skip with no key. --- .github/workflows/python-package.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index d83a333..4ba13d3 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -31,6 +31,11 @@ jobs: - name: Run flake8 to check specific rules not covered by black run: | flake8 . --statistics - # Tests hit live production endpoints and depend on prod data - # availability, so they are not run in CI. Run them locally with + # Offline mocked tests run keyless (no DATAMAXI_API_KEY, no network). + # `-k "not integration"` excludes the live test_integration.py; the + # keyed test_call.py tests skip cleanly when no key is set. + - name: Run offline mocked tests + run: python -m pytest tests/ -k "not integration" -q + # The live test_integration.py hits prod endpoints and depends on prod + # data availability, so it is not run in CI. Run it locally with # `DATAMAXI_API_KEY=... python -m pytest tests/`.