diff --git a/README.md b/README.md index 8ec256c..8ebbb75 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ Exchange original API docs: [https://exchange-docs.crypto.com](https://exchange- ### Changelog +- **0.9.3** - added RPS limiter by @Irishery - **0.9.2** - fixed event loop import level - **0.9.1** - fixed Windows bug with asyncio event loop - **0.9.0** - updated coins, refactored wallet transactions diff --git a/setup.py b/setup.py index 8d8790f..aafc76e 100644 --- a/setup.py +++ b/setup.py @@ -38,7 +38,8 @@ setup( packages=find_packages('src'), install_requires=[ 'aiohttp', - 'cached-property' + 'cached-property', + 'aiolimiter' ], extras_require={ 'dev': [ diff --git a/src/cryptocom/exchange/__init__.py b/src/cryptocom/exchange/__init__.py index 30a3ba4..4d94fae 100644 --- a/src/cryptocom/exchange/__init__.py +++ b/src/cryptocom/exchange/__init__.py @@ -23,4 +23,4 @@ __all__ = [ 'ApiError', 'ApiProvider' ] -__version__ = '0.9.2' +__version__ = '0.9.3' diff --git a/src/cryptocom/exchange/api.py b/src/cryptocom/exchange/api.py index ac1cd9e..1543e2d 100644 --- a/src/cryptocom/exchange/api.py +++ b/src/cryptocom/exchange/api.py @@ -7,12 +7,41 @@ import asyncio import hashlib from urllib.parse import urljoin +from aiolimiter import AsyncLimiter + import aiohttp from aiohttp.client_exceptions import ContentTypeError +RATE_LIMITS = { + # order methods + ( + 'private/create-order', + 'private/cancel-order', + 'private/cancel-all-orders', + 'private/margin/create-order', + 'private/margin/cancel-order', + 'private/margin/cancel-all-orders', + ): (14, 0.1), + + # order detail methods + ( + 'private/get-order-detail', + 'private/margin/get-order-detail', + ): (29, 0.1), + + # general trade methods + ( + 'private/get-trades', + 'private/margin/get-trades', + 'private/get-order-history', + 'private/margin/get-order-history' + ): (1, 1) +} + + class ApiError(Exception): pass @@ -24,16 +53,24 @@ class ApiProvider: auth_required=True, timeout=25, retries=6, root_url='https://api.crypto.com/v2/', ws_root_url='wss://stream.crypto.com/v2/', logger=None): + self.api_key = api_key self.api_secret = api_secret self.root_url = root_url self.ws_root_url = ws_root_url self.timeout = timeout self.retries = retries + self.last_request_path = '' + + self.rate_limiters = {} - # NOTE: do not change this, due to crypto.com rate-limits - # TODO: add more strict settings, req/per second or milliseconds - self.semaphore = asyncio.Semaphore(20) + for urls in RATE_LIMITS: + for url in urls: + self.rate_limiters[url] = AsyncLimiter(*RATE_LIMITS[urls]) + + # limits for not matched methods + self.general_private_limit = AsyncLimiter(3, 0.1) + self.general_public_limit = AsyncLimiter(100, 1) if not auth_required: return @@ -76,15 +113,29 @@ class ApiProvider: ).hexdigest() return data + def get_limit(self, path): + if path in self.rate_limiters.keys(): + return self.rate_limiters[path] + else: + if path.startswith('private'): + return self.general_private_limit + elif path.startswith('public'): + return self.general_public_limit + else: + raise ApiError(f'Wrong path: {path}') + async def request(self, method, path, params=None, data=None, sign=False): original_data = data timeout = aiohttp.ClientTimeout(total=self.timeout) + + limiter = self.get_limit(path) + for count in range(self.retries + 1): if sign: data = self._sign(path, original_data) try: async with aiohttp.ClientSession(timeout=timeout) as session: - async with self.semaphore: + async with limiter: resp = await session.request( method, urljoin(self.root_url, path), params=params, json=data, diff --git a/tests/test_api.py b/tests/test_api.py index 541cc4e..be4e34f 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,3 +1,4 @@ +import asyncio import os import time import pytest @@ -55,3 +56,45 @@ async def test_wrong_api_response(): api = cro.ApiProvider(auth_required=False) with pytest.raises(cro.ApiError): await api.post('account') + + +@pytest.mark.asyncio +async def test_api_rate_limits(): + api = cro.ApiProvider(from_env=True) + pair = cro.pairs.CRO_USDT + + page = 0 + page_size = 50 + + params = {'page_size': page_size, 'page': page} + + if pair: + params['instrument_name'] = pair.name + + start_time = time.time() + tasks = [api.post('private/get-order-history', {'params': params}) for i in range(2)] + await asyncio.gather(*tasks) + + finish_time = (time.time() - start_time) + assert finish_time > 1 + + start_time = time.time() + tasks = [api.post('private/get-order-history', {'params': params}) for _ in range(5)] + await asyncio.gather(*tasks) + + finish_time = time.time() - start_time + assert finish_time > 4 + + start_time = time.time() + tasks = [api.get('public/get-instruments') for _ in range(200)] + await asyncio.gather(*tasks) + + finish_time = time.time() - start_time + assert finish_time > 1 + + start_time = time.time() + tasks = [api.post('private/get-order-history', {'params': params}) for _ in range(4)] + await asyncio.gather(*tasks) + + finish_time = time.time() - start_time + assert finish_time > 3