You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
267 lines
8.4 KiB
267 lines
8.4 KiB
import os |
|
import json |
|
import time |
|
import hmac |
|
import random |
|
import asyncio |
|
import hashlib |
|
|
|
from urllib.parse import urljoin |
|
|
|
import httpx |
|
import websockets |
|
import async_timeout |
|
import aiolimiter |
|
|
|
|
|
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 |
|
|
|
|
|
class ApiListenAsyncIterable: |
|
def __init__(self, api, ws, channels, sign): |
|
self.api = api |
|
self.ws = ws |
|
self.channels = channels |
|
|
|
self.sign = sign |
|
self.sub_data_sent = False |
|
self.auth_sent = False |
|
self.connected = False |
|
self.auth_data = None |
|
|
|
async def connect(self): |
|
self.auth_data = {} |
|
if self.sign: |
|
self.auth_data = self.api.sign('public/auth', self.auth_data) |
|
|
|
# sleep because too many requests from docs |
|
await asyncio.sleep(1) |
|
|
|
self.connected = True |
|
|
|
def __aiter__(self): |
|
return self |
|
|
|
async def __anext__(self): |
|
if not self.connected: |
|
await asyncio.sleep(1) |
|
|
|
if not self.sub_data_sent: |
|
sub_data = { |
|
'id': random.randint(1000, 10000), |
|
"method": "subscribe", |
|
'params': {"channels": self.channels}, |
|
"nonce": int(time.time()) |
|
} |
|
|
|
# [0] if not sign start connection with subscription |
|
if not self.sub_data_sent and not self.sign: |
|
await self.ws.send(json.dumps(sub_data)) |
|
self.sub_data_sent = True |
|
|
|
data = await self.ws.recv() |
|
|
|
if data: |
|
data = json.loads(data) |
|
result = data.get('result') |
|
|
|
# [1] send heartbeat to keep connection alive |
|
if data['method'] == 'public/heartbeat': |
|
await self.ws.send(json.dumps({ |
|
'id': data['id'], |
|
'method': 'public/respond-heartbeat' |
|
})) |
|
elif self.sub_data_sent and result: |
|
# [4] consume data |
|
return result |
|
|
|
# [2] sign auth request to listen private methods |
|
if self.sign and not self.auth_sent: |
|
await self.ws.send(json.dumps(self.auth_data)) |
|
self.auth_sent = True |
|
|
|
# [3] subscribe to channels |
|
if ( |
|
data['method'] == 'public/auth' and |
|
data['code'] == 0 and not self.sub_data_sent |
|
): |
|
await self.ws.send(json.dumps(sub_data)) |
|
self.sub_data_sent = True |
|
|
|
|
|
|
|
class ApiProvider: |
|
"""Provides HTTP-api requests and websocket requests.""" |
|
def __init__( |
|
self, *, api_key='', api_secret='', from_env=False, |
|
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 = {} |
|
|
|
for urls in RATE_LIMITS: |
|
for url in urls: |
|
self.rate_limiters[url] =\ |
|
aiolimiter.AsyncLimiter(*RATE_LIMITS[urls]) |
|
|
|
# limits for not matched methods |
|
self.general_private_limit = aiolimiter.AsyncLimiter(3, 0.1) |
|
self.general_public_limit = aiolimiter.AsyncLimiter(100, 1) |
|
|
|
if not auth_required: |
|
return |
|
|
|
if from_env: |
|
self.read_keys_from_env() |
|
|
|
if not self.api_key or not self.api_secret: |
|
raise ValueError('Provide api_key and api_secret') |
|
|
|
def read_keys_from_env(self): |
|
self.api_key = os.environ.get('CRYPTOCOM_API_KEY', '') |
|
if not self.api_key: |
|
raise ValueError('Provide CRYPTOCOM_API_KEY env value') |
|
self.api_secret = os.environ.get('CRYPTOCOM_API_SECRET', '') |
|
if not self.api_secret: |
|
raise ValueError('Provide CRYPTOCOM_API_SECRET env value') |
|
|
|
def sign(self, path, data): |
|
data = data or {} |
|
data['method'] = path |
|
|
|
sign_time = int(time.time() * 1000) |
|
data.update({'nonce': sign_time, 'api_key': self.api_key}) |
|
|
|
data['id'] = random.randint(1000, 10000) |
|
data_params = data.get('params', {}) |
|
params = ''.join( |
|
f'{key}{data_params[key]}' |
|
for key in sorted(data_params) |
|
) |
|
|
|
payload = f"{path}{data['id']}" \ |
|
f"{self.api_key}{params}{data['nonce']}" |
|
|
|
data['sig'] = hmac.new( |
|
self.api_secret.encode('utf-8'), |
|
payload.encode('utf-8'), |
|
hashlib.sha256 |
|
).hexdigest() |
|
return data |
|
|
|
def get_limiter(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 |
|
limiter = self.get_limiter(path) |
|
|
|
for count in range(self.retries + 1): |
|
client = httpx.AsyncClient( |
|
timeout=httpx.Timeout(timeout=self.timeout)) |
|
if sign: |
|
data = self.sign(path, original_data) |
|
try: |
|
async with limiter: |
|
resp = await client.request( |
|
method, urljoin(self.root_url, path), |
|
params=params, json=data, |
|
headers={'content-type': 'application/json'} |
|
) |
|
resp_json = resp.json() |
|
if resp.status_code != 200: |
|
raise ApiError( |
|
f"Error: {resp_json}. " |
|
f"Status: {resp.status_code}. Json params: {data}") |
|
except httpx.ConnectError: |
|
raise ApiError(f"Cannot connect to host {self.root_url}") |
|
except asyncio.TimeoutError as exc: |
|
if count == self.retries: |
|
raise ApiError( |
|
f"Timeout error, retries: {self.retries}. " |
|
f"Path: {path}. Data: {data}" |
|
) from exc |
|
continue |
|
except json.JSONDecodeError: |
|
if resp.status_code == 429: |
|
continue |
|
raise ApiError( |
|
f"Can't decode json, content: {resp.text()}. " |
|
f"Code: {resp.status_code}") |
|
finally: |
|
await client.aclose() |
|
|
|
if resp_json['code'] == 0: |
|
result = resp_json.get('result', {}) |
|
return result.get('data', {}) or result |
|
|
|
if count == self.retries: |
|
raise ApiError( |
|
f"System error, retries: {self.retries}. " |
|
f"Code: {resp.status_code}. Json: {resp_json}. " |
|
f"Data: {data}" |
|
) |
|
|
|
async def get(self, path, params=None, sign=False): |
|
return await self.request('get', path, params=params, sign=sign) |
|
|
|
async def post(self, path, data=None, sign=True): |
|
return await self.request('post', path, data=data, sign=sign) |
|
|
|
async def listen(self, url, *channels, sign=False): |
|
url = urljoin(self.ws_root_url, url) |
|
async for ws in websockets.connect(url, open_timeout=self.timeout): |
|
try: |
|
dataiterator = ApiListenAsyncIterable(self, ws, channels, sign) |
|
async with async_timeout.timeout(60) as tm: |
|
async for data in dataiterator: |
|
if data: |
|
tm.shift(60) |
|
yield data |
|
except (websockets.ConnectionClosed, asyncio.TimeoutError): |
|
continue
|
|
|