Skip to content

Commit e3b011a

Browse files
feat: expand async client to the full resource tree (#142) (#150)
Follow-up to the candle+ticker pilot. Restructure datamaxi/aio into a package and mirror the entire sync surface in async: - _core.py: AsyncAPI, AsyncResource (moved from __init__) - cex.py: candle, ticker, fee, wallet_status, announcement, token, symbol - funding_rate, forex, premium, liquidation, open_interest, margin_borrow, index_price; AsyncTelegram + AsyncNaver as standalone top-level clients - AsyncDatamaxi exposes the full tree over one shared AsyncAPI/httpx client Every method is async def + await request_endpoint, preserving sync op_ids, params, validation, and return shaping; pagination next_request closures are async; pandas/convert imports stay lazy. No sync code changed. httpx remains the optional [async] extra. Tests (test_async_resources.py, httpx.MockTransport): one method per resource + async pagination closures. Suite: 172 passed.
1 parent 1313f4f commit e3b011a

13 files changed

Lines changed: 1353 additions & 227 deletions

datamaxi/aio/__init__.py

Lines changed: 55 additions & 227 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Async client (pilot) — ``httpx``-based, mirrors a slice of the sync surface.
1+
"""Async client — ``httpx``-based mirror of the sync DataMaxi+ client.
22
33
Requires the ``async`` extra::
44
@@ -14,236 +14,41 @@
1414
ticker = await client.cex.ticker.get(exchange="binance", market="spot",
1515
symbol="BTC-USDT")
1616
17-
This is a deliberately small pilot (candle + ticker). It reuses the sync
18-
client's endpoint resolution and error handling (``datamaxi._dispatch``) and
19-
the shared DataFrame / ResponseMeta helpers, so the two clients can't drift on
20-
request building or error semantics.
17+
Mirrors the full sync surface (``cex.*``, ``funding_rate``, ``forex``,
18+
``premium``, ``liquidation``, ``open_interest``, ``margin_borrow``,
19+
``index_price``, plus standalone ``AsyncTelegram`` / ``AsyncNaver``). Reuses
20+
the sync client's endpoint resolution and error handling (``datamaxi._dispatch``)
21+
and the shared DataFrame / ResponseMeta helpers, so the two clients can't drift
22+
on request building or error semantics.
2123
"""
2224

23-
from __future__ import annotations
24-
25-
import asyncio
26-
import os
27-
from typing import Any, Union, TYPE_CHECKING
28-
29-
from datamaxi.__version__ import __version__
30-
from datamaxi.api import ResponseMeta
31-
from datamaxi._dispatch import resolve_endpoint, raise_for_error, extract_limit_usage
32-
from datamaxi.lib.constants import (
33-
BASE_URL,
34-
SPOT,
35-
FUTURES,
36-
USD,
37-
INTERVAL_1D,
38-
Market,
39-
Interval,
25+
from typing import Any
26+
27+
from datamaxi.lib.constants import BASE_URL
28+
from datamaxi.aio._core import AsyncAPI, AsyncResource
29+
from datamaxi.aio.cex import (
30+
AsyncCex,
31+
AsyncCexCandle,
32+
AsyncCexTicker,
33+
AsyncCexFee,
34+
AsyncCexWalletStatus,
35+
AsyncCexAnnouncement,
36+
AsyncCexToken,
37+
AsyncCexSymbol,
4038
)
41-
from datamaxi.lib.utils import check_required_parameters
42-
from datamaxi.resources.responses import CandleResponse, TickerResponse
43-
44-
if TYPE_CHECKING:
45-
import pandas as pd
46-
47-
48-
def _import_httpx():
49-
try:
50-
import httpx
51-
except ImportError as exc: # pragma: no cover - exercised via extra
52-
raise ImportError(
53-
"The async client requires httpx. Install it with: "
54-
"pip install 'datamaxi[async]'"
55-
) from exc
56-
return httpx
57-
58-
59-
class AsyncAPI:
60-
"""Async transport built on ``httpx.AsyncClient``.
61-
62-
Mirrors the sync ``API``: shared endpoint resolution, bounded retry of
63-
transient gateway 5xx, the same ``ClientError`` / ``ServerError`` contract,
64-
and ``last_response`` metadata.
65-
"""
66-
67-
def __init__(
68-
self,
69-
api_key=None,
70-
base_url=None,
71-
timeout=10,
72-
max_retries=3,
73-
retry_backoff=0.5,
74-
retry_statuses=(502, 503, 504),
75-
transport=None,
76-
):
77-
httpx = _import_httpx()
78-
self.api_key = api_key or os.environ.get("DATAMAXI_API_KEY")
79-
self.base_url = base_url
80-
self.timeout = timeout
81-
self.max_retries = max_retries
82-
self.retry_backoff = retry_backoff
83-
self.retry_statuses = tuple(retry_statuses)
84-
self.last_response = None
85-
self._client = httpx.AsyncClient(
86-
base_url=base_url or "",
87-
timeout=timeout,
88-
transport=transport,
89-
headers={
90-
"Content-Type": "application/json;charset=utf-8",
91-
"User-Agent": "datamaxi/" + __version__,
92-
"X-DTMX-APIKEY": str(self.api_key),
93-
},
94-
)
95-
96-
async def request_endpoint(self, op_id, **params):
97-
method, url_path, query_params = resolve_endpoint(op_id, **params)
98-
return await self.send_request(method, url_path, payload=query_params)
99-
100-
async def send_request(self, method, url_path, payload=None):
101-
# str()-encode scalars so bools match the sync client's urlencode
102-
# output (e.g. include_source -> "True", not httpx's "true").
103-
params = {k: str(v) for k, v in (payload or {}).items() if v is not None}
104-
for attempt in range(self.max_retries + 1):
105-
response = await self._client.request(method, url_path, params=params)
106-
if (
107-
response.status_code in self.retry_statuses
108-
and attempt < self.max_retries
109-
):
110-
await asyncio.sleep(self.retry_backoff * (attempt + 1))
111-
continue
112-
break
113-
114-
raise_for_error(response.status_code, response.text, response.headers)
115-
116-
try:
117-
data = response.json()
118-
except ValueError:
119-
data = response.text
120-
121-
self.last_response = ResponseMeta(
122-
status_code=response.status_code,
123-
headers=response.headers,
124-
limit_usage=extract_limit_usage(response.headers),
125-
data=data,
126-
)
127-
return data
128-
129-
async def aclose(self):
130-
await self._client.aclose()
131-
132-
async def __aenter__(self):
133-
return self
134-
135-
async def __aexit__(self, *exc):
136-
await self.aclose()
137-
138-
139-
class AsyncResource:
140-
"""Base for async resources — composes a shared ``AsyncAPI``."""
141-
142-
def __init__(self, api: "AsyncAPI"):
143-
self._api = api
144-
145-
async def request_endpoint(self, op_id, **params):
146-
return await self._api.request_endpoint(op_id, **params)
147-
148-
@property
149-
def last_response(self):
150-
return self._api.last_response
151-
152-
153-
class AsyncCexCandle(AsyncResource):
154-
async def __call__(
155-
self,
156-
exchange: str,
157-
market: Market,
158-
symbol: str,
159-
currency: str = USD,
160-
interval: Interval = INTERVAL_1D,
161-
from_unix: str = None,
162-
to_unix: str = None,
163-
pandas: bool = True,
164-
) -> Union[pd.DataFrame, CandleResponse]:
165-
"""Fetch candle data (async). See ``datamaxi.Datamaxi.cex.candle``."""
166-
check_required_parameters(
167-
[
168-
[exchange, "exchange"],
169-
[symbol, "symbol"],
170-
[interval, "interval"],
171-
[market, "market"],
172-
[currency, "currency"],
173-
]
174-
)
175-
if market not in [SPOT, FUTURES]:
176-
raise ValueError("market must be either spot or futures")
177-
178-
res = await self.request_endpoint(
179-
"cex_candle",
180-
exchange=exchange,
181-
market=market,
182-
symbol=symbol,
183-
interval=interval,
184-
currency=currency,
185-
**{"from": from_unix, "to": to_unix},
186-
)
187-
if res["data"] is None or len(res["data"]) == 0:
188-
raise ValueError("no data found")
189-
190-
if pandas:
191-
from datamaxi.resources.utils import convert_data_to_data_frame
192-
193-
return convert_data_to_data_frame(res["data"])
194-
return res
195-
196-
197-
class AsyncCexTicker(AsyncResource):
198-
async def get(
199-
self,
200-
exchange: str,
201-
symbol: str,
202-
market: Market,
203-
currency: str = None,
204-
conversion_base: str = None,
205-
include_source: bool = False,
206-
pandas: bool = True,
207-
) -> Union[pd.DataFrame, TickerResponse]:
208-
"""Fetch ticker data (async). See ``datamaxi.Datamaxi.cex.ticker``."""
209-
check_required_parameters(
210-
[
211-
[exchange, "exchange"],
212-
[symbol, "symbol"],
213-
[market, "market"],
214-
]
215-
)
216-
if market not in [SPOT, FUTURES]:
217-
raise ValueError("market must be either spot or futures")
218-
219-
res = await self.request_endpoint(
220-
"ticker",
221-
exchange=exchange,
222-
symbol=symbol,
223-
market=market,
224-
currency=currency,
225-
conversion_base=conversion_base,
226-
include_source=include_source,
227-
)
228-
229-
if pandas:
230-
import pandas as pd
231-
232-
df = pd.DataFrame([res["data"]])
233-
df = df.set_index("d")
234-
return df
235-
return res
236-
237-
238-
class AsyncCex(AsyncResource):
239-
def __init__(self, api: "AsyncAPI"):
240-
super().__init__(api)
241-
self.candle = AsyncCexCandle(api)
242-
self.ticker = AsyncCexTicker(api)
39+
from datamaxi.aio.funding_rate import AsyncFundingRate
40+
from datamaxi.aio.forex import AsyncForex
41+
from datamaxi.aio.premium import AsyncPremium
42+
from datamaxi.aio.liquidation import AsyncLiquidation
43+
from datamaxi.aio.open_interest import AsyncOpenInterest
44+
from datamaxi.aio.margin_borrow import AsyncMarginBorrow
45+
from datamaxi.aio.index_price import AsyncIndexPrice
46+
from datamaxi.aio.telegram import AsyncTelegram
47+
from datamaxi.aio.naver import AsyncNaver
24348

24449

24550
class AsyncDatamaxi:
246-
"""Async entrypoint (pilot). Exposes ``cex.candle`` and ``cex.ticker``.
51+
"""Async entrypoint — full mirror of the sync :class:`datamaxi.Datamaxi`.
24752
24853
Use as an async context manager so the underlying ``httpx`` client is
24954
closed, or call :meth:`aclose` explicitly.
@@ -252,8 +57,17 @@ class AsyncDatamaxi:
25257
def __init__(self, api_key=None, **kwargs: Any):
25358
if "base_url" not in kwargs:
25459
kwargs["base_url"] = BASE_URL
255-
self._api = AsyncAPI(api_key, **kwargs)
256-
self.cex = AsyncCex(self._api)
60+
api = AsyncAPI(api_key, **kwargs)
61+
self._api = api
62+
63+
self.cex = AsyncCex(api)
64+
self.funding_rate = AsyncFundingRate(api)
65+
self.forex = AsyncForex(api)
66+
self.premium = AsyncPremium(api)
67+
self.liquidation = AsyncLiquidation(api)
68+
self.open_interest = AsyncOpenInterest(api)
69+
self.margin_borrow = AsyncMarginBorrow(api)
70+
self.index_price = AsyncIndexPrice(api)
25771

25872
async def aclose(self):
25973
await self._api.aclose()
@@ -272,9 +86,23 @@ def __repr__(self):
27286

27387
__all__ = [
27488
"AsyncDatamaxi",
89+
"AsyncTelegram",
90+
"AsyncNaver",
27591
"AsyncAPI",
27692
"AsyncResource",
27793
"AsyncCex",
27894
"AsyncCexCandle",
27995
"AsyncCexTicker",
96+
"AsyncCexFee",
97+
"AsyncCexWalletStatus",
98+
"AsyncCexAnnouncement",
99+
"AsyncCexToken",
100+
"AsyncCexSymbol",
101+
"AsyncFundingRate",
102+
"AsyncForex",
103+
"AsyncPremium",
104+
"AsyncLiquidation",
105+
"AsyncOpenInterest",
106+
"AsyncMarginBorrow",
107+
"AsyncIndexPrice",
280108
]

0 commit comments

Comments
 (0)