diff --git a/README.md b/README.md index 0df018c..782f061 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ ## Welcome to the python-currencycom -This is an unofficial Python wrapper for the Currency.com exchange REST API v1. +This is an unofficial Python wrapper for the Currency.com exchange REST API v1 and Websockets API. I am in no way affiliated with Currency.com, use at your own risk. ### Documentation @@ -24,7 +24,7 @@ Let's retrieve tradable symbols on the market ```python from pprint import pprint -from currencycom.client import Client +from currencycom.client import CurrencycomClient as Client client = Client('API_KEY', 'SECRET_KEY') @@ -35,4 +35,46 @@ pprint(tradable_symbols, indent=2) ``` +### Hybrid = Websockets + REST API + +Python3.6+ is required for the websockets support + +```python +import time +import asyncio + +from pprint import pprint + +from currencycom.hybrid import CurrencycomHybridClient + + +def your_handler(message): + pprint(message, indent=2) + + +async def keep_waiting(): + while True: + await asyncio.sleep(20) + + +client = CurrencycomHybridClient(api_key='YOUR_API_KEY', api_secret='YOUR_API_SECRET', + handler=your_handler, demo=True) + +# Subscribe to market data +client.subscribe("BTC/USD_LEVERAGE", "ETH/USD_LEVERAGE") + +# Run the client in a thread +client.run() +time.sleep(3) + +# Also you can use REST API +pprint(client.rest.get_24h_price_change("BTC/USD_LEVERAGE")) + +loop = asyncio.get_event_loop() +loop.run_until_complete(keep_waiting()) +``` + +Default symbol price handler is provided for you, you can use it or write your own. + For more check out [the documentation](https://exchange.currency.com/api) and [Swagger](https://apitradedoc.currency.com/swagger-ui.html#/). + diff --git a/currencycom/asyncio/__init__.py b/currencycom/asyncio/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/currencycom/asyncio/websockets.py b/currencycom/asyncio/websockets.py new file mode 100644 index 0000000..9d44dc6 --- /dev/null +++ b/currencycom/asyncio/websockets.py @@ -0,0 +1,241 @@ +import asyncio +import json +import logging +import time +import websockets + +from random import random +from datetime import datetime +from typing import Optional + +from ..client import CurrencycomClient + + +class ReconnectingWebsocket: + MAX_RECONNECTS = 5 + MAX_RECONNECT_SECONDS = 60 + MIN_RECONNECT_WAIT = 0.5 + TIMEOUT = 10 + PING_TIMEOUT = 5 + + def __init__(self, loop, client, coro): + self._loop = loop + self._log = logging.getLogger(__name__) + self._coro = coro + self._reconnect_attempts = 0 + self._conn = None + self._connect_id = None + self._socket = None + self._request = { + "destination": 'ping', + "correlationId": 0, + "payload": {} + } + self._client: CurrencycomClient = client + self._last_ping = None + + self._connect() + + def _connect(self): + self._conn = asyncio.ensure_future(self._run(), loop=self._loop) + + async def _run(self): + keep_waiting = True + self._last_ping = time.time() + + async with websockets.connect(self._client.constants.BASE_WSS_URL) as socket: + self._socket = socket + self._reconnect_attempts = 0 + + try: + while keep_waiting: + if time.time() - self._last_ping > self.PING_TIMEOUT: + await self.send_ping() + try: + evt = await asyncio.wait_for(self._socket.recv(), timeout=self.PING_TIMEOUT) + except asyncio.TimeoutError: + self._log.debug("Ping timeout in {} seconds".format(self.PING_TIMEOUT)) + await self.send_ping() + except asyncio.CancelledError: + self._log.debug("Websocket cancelled error") + await self._socket.ping() + else: + try: + evt_obj = json.loads(evt) + except ValueError: + pass + else: + await self._coro(evt_obj) + except websockets.ConnectionClosed: + keep_waiting = False + await self._reconnect() + except Exception as e: + self._log.debug('Websocket exception:{}'.format(e)) + keep_waiting = False + await self._reconnect() + + async def _reconnect(self): + await self.cancel() + self._reconnect_attempts += 1 + if self._reconnect_attempts < self.MAX_RECONNECTS: + self._log.debug(f"Websocket reconnecting {self.MAX_RECONNECTS - self._reconnect_attempts} attempts left") + reconnect_wait = self._get_reconnect_wait(self._reconnect_attempts) + await asyncio.sleep(reconnect_wait) + self._connect() + else: + self._log.error(f"Websocket could not reconnect after {self._reconnect_attempts} attempts") + pass + + def _get_reconnect_wait(self, attempts): + expo = 2 ** attempts + return round(random() * min(self.MAX_RECONNECT_SECONDS, expo - 1) + 1) + + async def send_message(self, destination, payload, access: Optional[str] = None, retry_count=0): + if not self._socket: + if retry_count < 5: + await asyncio.sleep(1) + await self.send_message(destination, payload, access, retry_count + 1) + else: + self._request["destination"] = destination + self._request["payload"] = payload + self._request["correlationId"] += 1 + + if access == 'private': + self._log.error('Private access not implemented') + + message = json.dumps(self._request) + await self._socket.send(message) + + async def send_ping(self): + await self.send_message('ping', {}, access='public') + self._last_ping = time.time() + + async def cancel(self): + try: + self._conn.cancel() + except asyncio.CancelledError: + pass + + +class CurrencycomSocketManager: + """ + A class to manage the websocket connection to Currencycom. + + Use the following methods to subscribe to Currencycom events: + - subscribe_market_data(symbols) + - subscribe_depth_market_data(symbols) + - subscribe_OHLC_market_data(symbols) + - subscribe_trades(symbols) + """ + + def __init__(self): + """ + Initialise the Currencycom Socket Manager + """ + self._callback = None + self._conn: Optional[ReconnectingWebsocket] = None + self._loop = None + self._client = None + self._log = logging.getLogger(__name__) + + @classmethod + async def create(cls, loop, client, callback): + self = CurrencycomSocketManager() + self._loop = loop + self._client = client + self._callback = callback + self._conn = ReconnectingWebsocket(loop, client, self._callback) + return self + + async def subscribe_market_data(self, symbols: [str]): + """ + Market data stream + + This subscription produces the following events: + { + "status":"OK", + "Destination":"internal.quote", + "Payload":{ + "symbolName":"TXN", + "bid":139.85, + "bidQty":2500, + "ofr":139.92000000000002, + "ofrQty":2500, + "timestamp":1597850971558 + } + } + """ + await self._conn.send_message("marketData.subscribe", {"symbols": symbols}, 'public') + + async def subscribe_depth_market_data(self, symbols: [str]): + """ + Depth market data stream + + This subscription produces the following events: + { + "status":"OK", + "Destination":"marketdepth.event", + "Payload":{ + "Data":{ + "ts":1597849462575, + "Bid":{ + "2":25, + "1.94":25.9 + }, + "Ofr":{ + "3.3":1, + "2.627":6.1 + } + }, + "symbol":"Natural Gas" + } + } + """ + await self._conn.send_message("depthMarketData.subscribe", {"symbols": symbols}) + + async def subscribe_OHLC_market_data(self, intervals: [str], symbols: [str]): + """ + OHLC market data stream + + This subscription produces the following events: + { + "status":"OK", + "correlationId":"2", + "payload":{ + "status":"OK", + "Destination":"ohlc.event", + "Payload":{ + "interval":"1m", + "symbol":"TS", + "T":1597850100000, + "H":11.89, + "L":11.88, + "O":11.89, + "C":11.89 + } + } + } + """ + await self._conn.send_message("OHLCMarketData.subscribe", {"intervals": intervals, "symbols": symbols}) + + async def subscribe_trades(self, symbols: [str]): + """ + Trades stream + + This subscription produces the following events: + { + "status":"OK", + "destination":"internal.trade", + "payload":{ + "price":11400.95, + "size":0.058, + "id":1616651347, + "ts":1596625079952, + "symbol":"BTC/USD", + "orderId":"00a02503-0079-54c4-0000-00004020316a", + "clientOrderId":"00a02503-0079-54c4-0000-482f00003a06", + "buyer":true + } + } + """ + await self._conn.send_message("trades.subscribe", {"symbols": symbols}) diff --git a/currencycom/client.py b/currencycom/client.py index f0135c9..cf1da3d 100644 --- a/currencycom/client.py +++ b/currencycom/client.py @@ -1,47 +1,11 @@ import hashlib import hmac +import requests + from datetime import datetime, timedelta from enum import Enum - -import requests from requests.models import RequestEncodingMixin - - -class CurrencyComConstants(object): - HEADER_API_KEY_NAME = 'X-MBX-APIKEY' - API_VERSION = 'v1' - BASE_URL = 'https://api-adapter.backend.currency.com/api/{}/'.format( - API_VERSION - ) - - AGG_TRADES_MAX_LIMIT = 1000 - KLINES_MAX_LIMIT = 1000 - RECV_WINDOW_MAX_LIMIT = 60000 - - # Public API Endpoints - SERVER_TIME_ENDPOINT = BASE_URL + 'time' - EXCHANGE_INFORMATION_ENDPOINT = BASE_URL + 'exchangeInfo' - - # Market data Endpoints - ORDER_BOOK_ENDPOINT = BASE_URL + 'depth' - AGGREGATE_TRADE_LIST_ENDPOINT = BASE_URL + 'aggTrades' - KLINES_DATA_ENDPOINT = BASE_URL + 'klines' - PRICE_CHANGE_24H_ENDPOINT = BASE_URL + 'ticker/24hr' - - # Account Endpoints - ACCOUNT_INFORMATION_ENDPOINT = BASE_URL + 'account' - ACCOUNT_TRADE_LIST_ENDPOINT = BASE_URL + 'myTrades' - - # Order Endpoints - ORDER_ENDPOINT = BASE_URL + 'order' - CURRENT_OPEN_ORDERS_ENDPOINT = BASE_URL + 'openOrders' - - # Leverage Endpoints - CLOSE_TRADING_POSITION_ENDPOINT = BASE_URL + 'closeTradingPosition' - TRADING_POSITIONS_ENDPOINT = BASE_URL + 'tradingPositions' - LEVERAGE_SETTINGS_ENDPOINT = BASE_URL + 'leverageSettings' - UPDATE_TRADING_ORDERS_ENDPOINT = BASE_URL + 'updateTradingOrder' - UPDATE_TRADING_POSITION_ENDPOINT = BASE_URL + 'updateTradingPosition' +from .constants import CurrencycomConstants class OrderStatus(Enum): @@ -62,7 +26,7 @@ class OrderSide(Enum): SELL = 'SELL' -class CandlesticksChartInervals(Enum): +class CandlesticksChartIntervals(Enum): MINUTE = '1m' FIVE_MINUTES = '5m' FIFTEEN_MINUTES = '15m' @@ -77,22 +41,30 @@ class TimeInForce(Enum): GTC = 'GTC' +class ExpireTimestamp(Enum): + DEFAULT = 0 + GTC = 'GTC' + FOK = 'FOK' + + class NewOrderResponseType(Enum): ACK = 'ACK' RESULT = 'RESULT' FULL = 'FULL' -class Client(object): +class CurrencycomClient: """ This is API for market Currency.com Please find documentation by https://exchange.currency.com/api Swagger UI: https://apitradedoc.currency.com/swagger-ui.html#/ """ - def __init__(self, api_key, api_secret): + def __init__(self, api_key, api_secret, demo=True): self.api_key = api_key self.api_secret = bytes(api_secret, 'utf-8') + self.demo = demo + self.constants = CurrencycomConstants(demo=demo) @staticmethod def _validate_limit(limit): @@ -108,21 +80,12 @@ def _validate_limit(limit): )) @staticmethod - def _to_epoch_miliseconds(dttm: datetime): + def _to_epoch_milliseconds(dttm: datetime): if dttm: return int(dttm.timestamp() * 1000) else: return dttm - def _validate_recv_window(self, recv_window): - max_value = CurrencyComConstants.RECV_WINDOW_MAX_LIMIT - if recv_window and recv_window > max_value: - raise ValueError( - 'recvValue cannot be greater than {}. Got {}.'.format( - max_value, - recv_window - )) - @staticmethod def _validate_new_order_resp_type(new_order_resp_type: NewOrderResponseType, order_type: OrderType @@ -142,8 +105,17 @@ def _validate_new_order_resp_type(new_order_resp_type: NewOrderResponseType, "new_order_resp_type for LIMIT order can be only RESULT." f" Got {new_order_resp_type.value}") + def _validate_recv_window(self, recv_window): + max_value = self.constants.RECV_WINDOW_MAX_LIMIT + if recv_window and recv_window > max_value: + raise ValueError( + 'recvValue cannot be greater than {}. Got {}.'.format( + max_value, + recv_window + )) + def _get_params_with_signature(self, **kwargs): - t = self._to_epoch_miliseconds(datetime.now()) + t = self._to_epoch_milliseconds(datetime.now()) kwargs['timestamp'] = t body = RequestEncodingMixin._encode_params(kwargs) sign = hmac.new(self.api_secret, bytes(body, 'utf-8'), @@ -153,7 +125,7 @@ def _get_params_with_signature(self, **kwargs): def _get_header(self, **kwargs): return { **kwargs, - CurrencyComConstants.HEADER_API_KEY_NAME: self.api_key + self.constants.HEADER_API_KEY_NAME: self.api_key } def _get(self, url, **kwargs): @@ -203,7 +175,7 @@ def get_account_info(self, recv_window=None): } """ self._validate_recv_window(recv_window) - r = self._get(CurrencyComConstants.ACCOUNT_INFORMATION_ENDPOINT, + r = self._get(self.constants.ACCOUNT_INFORMATION_ENDPOINT, recvWindow=recv_window) return r.json() @@ -235,9 +207,9 @@ def get_agg_trades(self, symbol, } ] """ - if limit > CurrencyComConstants.AGG_TRADES_MAX_LIMIT: + if limit > self.constants.AGG_TRADES_MAX_LIMIT: raise ValueError('Limit should not exceed {}'.format( - CurrencyComConstants.AGG_TRADES_MAX_LIMIT + self.constants.AGG_TRADES_MAX_LIMIT )) if start_time and end_time \ @@ -250,12 +222,12 @@ def get_agg_trades(self, symbol, params = {'symbol': symbol, 'limit': limit} if start_time: - params['startTime'] = self._to_epoch_miliseconds(start_time) + params['startTime'] = self._to_epoch_milliseconds(start_time) if end_time: - params['endTime'] = self._to_epoch_miliseconds(end_time) + params['endTime'] = self._to_epoch_milliseconds(end_time) - r = requests.get(CurrencyComConstants.AGGREGATE_TRADE_LIST_ENDPOINT, + r = requests.get(self.constants.AGGREGATE_TRADE_LIST_ENDPOINT, params=params) return r.json() @@ -286,7 +258,7 @@ def close_trading_position(self, position_id, recv_window=None): self._validate_recv_window(recv_window) r = self._post( - CurrencyComConstants.CLOSE_TRADING_POSITION_ENDPOINT, + self.constants.CLOSE_TRADING_POSITION_ENDPOINT, positionId=position_id, recvWindow=recv_window ) @@ -318,12 +290,11 @@ def get_order_book(self, symbol, limit=100): } """ self._validate_limit(limit) - r = requests.get(CurrencyComConstants.ORDER_BOOK_ENDPOINT, + r = requests.get(self.constants.ORDER_BOOK_ENDPOINT, params={'symbol': symbol, 'limit': limit}) return r.json() - @staticmethod - def get_exchange_info(): + def get_exchange_info(self): """ Current exchange trading rules and symbol information. @@ -360,11 +331,11 @@ def get_exchange_info(): ] } """ - r = requests.get(CurrencyComConstants.EXCHANGE_INFORMATION_ENDPOINT) + r = requests.get(self.constants.EXCHANGE_INFORMATION_ENDPOINT) return r.json() def get_klines(self, symbol, - interval: CandlesticksChartInervals, + interval: CandlesticksChartIntervals, start_time: datetime = None, end_time: datetime = None, limit=500): @@ -393,9 +364,9 @@ def get_klines(self, symbol, ] ] """ - if limit > CurrencyComConstants.KLINES_MAX_LIMIT: + if limit > self.constants.KLINES_MAX_LIMIT: raise ValueError('Limit should not exceed {}'.format( - CurrencyComConstants.KLINES_MAX_LIMIT + self.constants.KLINES_MAX_LIMIT )) params = {'symbol': symbol, @@ -403,10 +374,10 @@ def get_klines(self, symbol, 'limit': limit} if start_time: - params['startTime'] = self._to_epoch_miliseconds(start_time) + params['startTime'] = self._to_epoch_milliseconds(start_time) if end_time: - params['endTime'] = self._to_epoch_miliseconds(end_time) - r = requests.get(CurrencyComConstants.KLINES_DATA_ENDPOINT, + params['endTime'] = self._to_epoch_milliseconds(end_time) + r = requests.get(self.constants.KLINES_DATA_ENDPOINT, params=params) return r.json() @@ -436,7 +407,7 @@ def get_leverage_settings(self, symbol, recv_window=None): self._validate_recv_window(recv_window) r = self._get( - CurrencyComConstants.LEVERAGE_SETTINGS_ENDPOINT, + self.constants.LEVERAGE_SETTINGS_ENDPOINT, symbol=symbol, recvWindow=recv_window ) @@ -488,12 +459,12 @@ def get_account_trade_list(self, symbol, params = {'symbol': symbol, 'limit': limit, 'recvWindow': recv_window} if start_time: - params['startTime'] = self._to_epoch_miliseconds(start_time) + params['startTime'] = self._to_epoch_milliseconds(start_time) if end_time: - params['endTime'] = self._to_epoch_miliseconds(end_time) + params['endTime'] = self._to_epoch_milliseconds(end_time) - r = self._get(CurrencyComConstants.ACCOUNT_TRADE_LIST_ENDPOINT, + r = self._get(self.constants.ACCOUNT_TRADE_LIST_ENDPOINT, **params) return r.json() @@ -542,7 +513,7 @@ def get_open_orders(self, symbol=None, recv_window=None): self._validate_recv_window(recv_window) - r = self._get(CurrencyComConstants.CURRENT_OPEN_ORDERS_ENDPOINT, + r = self._get(self.constants.CURRENT_OPEN_ORDERS_ENDPOINT, symbol=symbol, recvWindow=recv_window) return r.json() @@ -559,8 +530,7 @@ def new_order(self, take_profit: float = None, leverage: int = None, price: float = None, - new_order_resp_type: NewOrderResponseType \ - = NewOrderResponseType.FULL, + new_order_resp_type: NewOrderResponseType = NewOrderResponseType.RESULT, recv_window=None ): """ @@ -639,10 +609,10 @@ def new_order(self, raise ValueError('For LIMIT orders price is required or ' 'should be greater than 0. Got '.format(price)) - expire_timestamp_epoch = self._to_epoch_miliseconds(expire_timestamp) + expire_timestamp_epoch = self._to_epoch_milliseconds(expire_timestamp) r = self._post( - CurrencyComConstants.ORDER_ENDPOINT, + self.constants.ORDER_ENDPOINT, accountId=account_id, expireTimestamp=expire_timestamp_epoch, guaranteedStopLoss=guaranteed_stop_loss, @@ -691,15 +661,14 @@ def cancel_order(self, symbol, self._validate_recv_window(recv_window) r = self._delete( - CurrencyComConstants.ORDER_ENDPOINT, + self.constants.ORDER_ENDPOINT, symbol=symbol, orderId=order_id, recvWindow=recv_window ) return r.json() - @staticmethod - def get_24h_price_change(symbol=None): + def get_24h_price_change(self, symbol=None): """ 24 hour rolling window price change statistics. Careful when accessing this with no symbol. @@ -755,12 +724,11 @@ def get_24h_price_change(symbol=None): "count": 0 } """ - r = requests.get(CurrencyComConstants.PRICE_CHANGE_24H_ENDPOINT, + r = requests.get(self.constants.PRICE_CHANGE_24H_ENDPOINT, params={'symbol': symbol} if symbol else {}) return r.json() - @staticmethod - def get_server_time(): + def get_server_time(self): """ Test connectivity to the API and get the current server time. @@ -770,11 +738,11 @@ def get_server_time(): "serverTime": 1499827319559 } """ - r = requests.get(CurrencyComConstants.SERVER_TIME_ENDPOINT) + r = requests.get(self.constants.SERVER_TIME_ENDPOINT) return r.json() - def list_leverage_trades(self, recv_window=None): + def get_trading_positions(self, recv_window=None): """ :param recv_window:recvWindow cannot be greater than 60000 @@ -815,7 +783,7 @@ def list_leverage_trades(self, recv_window=None): """ self._validate_recv_window(recv_window) r = self._get( - CurrencyComConstants.TRADING_POSITIONS_ENDPOINT, + self.constants.TRADING_POSITIONS_ENDPOINT, recvWindow=recv_window ) return r.json() @@ -838,10 +806,25 @@ def update_trading_position(self, """ self._validate_recv_window(recv_window) r = self._post( - CurrencyComConstants.UPDATE_TRADING_POSITION_ENDPOINT, + self.constants.UPDATE_TRADING_POSITION_ENDPOINT, positionId=position_id, guaranteedStopLoss=guaranteed_stop_loss, stopLoss=stop_loss, takeProfit=take_profit ) return r.json() + + def get_trading_position_id(self, order_id): + """ + Returns order's position_id in TradingPositions using its order_id + If order doesn't exist in TradingPositions will return None + + :param order_id: + + :return: str + """ + trading_positions = self.get_trading_positions()['positions'] + for item in trading_positions: + if item["orderId"] == order_id: + return item["id"] + return None diff --git a/currencycom/constants.py b/currencycom/constants.py new file mode 100644 index 0000000..20c7934 --- /dev/null +++ b/currencycom/constants.py @@ -0,0 +1,93 @@ +class CurrencycomConstants(object): + HEADER_API_KEY_NAME = 'X-MBX-APIKEY' + API_VERSION = 'v1' + + _BASE_URL = 'https://api-adapter.backend.currency.com/api/{}/'.format( + API_VERSION + ) + _DEMO_BASE_URL = 'https://demo-api-adapter.backend.currency.com/api/{}/'.format( + API_VERSION + ) + + _BASE_WSS_URL = "wss://api-adapter.backend.currency.com/connect" + _DEMO_BASE_WSS_URL = "wss://demo-api-adapter.backend.currency.com/connect" + + AGG_TRADES_MAX_LIMIT = 1000 + KLINES_MAX_LIMIT = 1000 + RECV_WINDOW_MAX_LIMIT = 60000 + + def __init__(self, demo=True): + self.demo = demo + + @property + def BASE_URL(self): + return self._DEMO_BASE_URL if self.demo else self._BASE_URL + + @property + def BASE_WSS_URL(self): + return self._DEMO_BASE_WSS_URL if self.demo else self._BASE_WSS_URL + + # Public API Endpoints + @property + def SERVER_TIME_ENDPOINT(self): + return self.BASE_URL + 'time' + + @property + def EXCHANGE_INFORMATION_ENDPOINT(self): + return self.BASE_URL + 'exchangeInfo' + + # Market data Endpoints + @property + def ORDER_BOOK_ENDPOINT(self): + return self.BASE_URL + 'depth' + + @property + def AGGREGATE_TRADE_LIST_ENDPOINT(self): + return self.BASE_URL + 'aggTrades' + + @property + def KLINES_DATA_ENDPOINT(self): + return self.BASE_URL + 'klines' + + @property + def PRICE_CHANGE_24H_ENDPOINT(self): + return self.BASE_URL + 'ticker/24hr' + + # Account Endpoints + @property + def ACCOUNT_INFORMATION_ENDPOINT(self): + return self.BASE_URL + 'account' + + @property + def ACCOUNT_TRADE_LIST_ENDPOINT(self): + return self.BASE_URL + 'myTrades' + + # Order Endpoints + @property + def ORDER_ENDPOINT(self): + return self.BASE_URL + 'order' + + @property + def CURRENT_OPEN_ORDERS_ENDPOINT(self): + return self.BASE_URL + 'openOrders' + + # Leverage Endpoints + @property + def CLOSE_TRADING_POSITION_ENDPOINT(self): + return self.BASE_URL + 'closeTradingPosition' + + @property + def TRADING_POSITIONS_ENDPOINT(self): + return self.BASE_URL + 'tradingPositions' + + @property + def LEVERAGE_SETTINGS_ENDPOINT(self): + return self.BASE_URL + 'leverageSettings' + + @property + def UPDATE_TRADING_ORDER_ENDPOINT(self): + return self.BASE_URL + 'updateTradingOrder' + + @property + def UPDATE_TRADING_POSITION_ENDPOINT(self): + return self.BASE_URL + 'updateTradingPosition' diff --git a/currencycom/hybrid.py b/currencycom/hybrid.py new file mode 100644 index 0000000..3402701 --- /dev/null +++ b/currencycom/hybrid.py @@ -0,0 +1,115 @@ +import asyncio +import logging +import threading +from datetime import datetime +from typing import Optional, Any + +from .asyncio.websockets import CurrencycomSocketManager +from .client import CurrencycomClient + + +class CurrencycomHybridClient: + """ + This is Hybrid (REST + Websockets) API for market Currency.com + + Please find documentation by https://exchange.currency.com/api + Swagger UI: https://apitradedoc.currency.com/swagger-ui.html#/ + """ + MAX_MARKET_DATA_TIMEOUT = 10 * 1000 # 10 seconds timeout + + def __init__(self, api_key=None, api_secret=None, handler=None, demo=True): + """ + Initialise the hybrid client + + :param api_key: API key + :param api_secret: API secret + :param handler: Your Handler for messages (default is provided) + :param demo: Use demo API (default is True) + """ + self._loop = asyncio.get_event_loop() + self.rest = CurrencycomClient(api_key, api_secret, demo=demo) + self.csm: Optional[CurrencycomSocketManager] = None + self.handler = handler + self.internal_quote_list: [dict] = [] + self.__subscriptions: [str] = [] + self._log = logging.getLogger(__name__) + + def subscribe(self, *args): + for arg in args: + if arg not in self.__subscriptions: + self.__subscriptions.append(arg) + + def __get_last_internal_quote_list_update(self): + if len(self.internal_quote_list) == 0: + return None + last = 0 + for item in self.internal_quote_list: + last = max(last, item["timestamp"]) + return last + + async def _check_market_data_timeout(self): + while True: + last = self.__get_last_internal_quote_list_update() + if last is not None and datetime.now().timestamp() - last > self.MAX_MARKET_DATA_TIMEOUT: + self._log.error("Market data timeout") + await self.csm.subscribe_market_data(self.__subscriptions) + await asyncio.sleep(self.MAX_MARKET_DATA_TIMEOUT) + + async def __run_wss(self): + self.csm = await CurrencycomSocketManager.create(self._loop, self.rest, self._handle_evt) + await self.csm.subscribe_market_data(self.__subscriptions) + + # Check market data timeout + asyncio.ensure_future(self._check_market_data_timeout(), loop=self._loop) + + self._log.debug("Fully connected to CurrencyCom") + + async def subscribe_depth_market_data(self, symbols: [str]): + await self.csm.subscribe_depth_market_data(symbols) + + async def subscribe_market_data(self, symbols: [str]): + await self.csm.subscribe_market_data(symbols) + + async def subscribe_OHLC_market_data(self, intervals: [str], symbols: [str]): + await self.csm.subscribe_OHLC_market_data(intervals, symbols) + + def __run_async_loop(self): + self._loop.run_until_complete(self.__run_wss()) + + def run(self): + """ + Run the client in a thread + """ + t = threading.Thread(target=self.__run_async_loop) + t.start() + + def __update_internal_quote_list(self, payload: dict[str, Any]): + if not any(item for item in self.internal_quote_list + if item['symbolName'] == payload['symbolName']): + self.internal_quote_list.append(payload) + return + for current in self.internal_quote_list: + if current['symbolName'] == payload['symbolName']: + self.internal_quote_list.remove(current) + self.internal_quote_list.append(payload) + return + + @property + def subscriptions(self): + return self.__subscriptions + + def get_symbol_price(self, symbol: str): + if not any(item for item in self.internal_quote_list + if item['symbolName'] == symbol): + self._log.warning("There is no {} in working_symbols yet".format(symbol)) + raise ValueError + else: + return next(item for item in self.internal_quote_list + if item["symbolName"] == symbol) + + async def _handle_evt(self, msg): + if msg["destination"] == "internal.quote": + self.__update_internal_quote_list(msg["payload"]) + if self.handler is not None: + self.handler(msg) + diff --git a/requirements.txt b/requirements.txt index 6a7050a..bef9169 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,4 @@ -requests==2.23.0 -pytest==5.3.5 \ No newline at end of file +requests==2.28.1 +pytest==7.1.2 +setuptools==63.2.0 +websockets==10.3 \ No newline at end of file diff --git a/tests/test_client.py b/tests/test_client.py index 05394c8..bb30b3e 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -4,12 +4,13 @@ import pytest from currencycom.client import * +from currencycom.constants import CurrencycomConstants class TestClient(object): @pytest.fixture(autouse=True) def set_client(self, mock_requests): - self.client = Client('', '') + self.client = CurrencycomClient('', '', demo=False) self.mock_requests = mock_requests def test_not_called(self): @@ -18,13 +19,13 @@ def test_not_called(self): def test_get_server_time(self, monkeypatch): self.client.get_server_time() self.mock_requests.assert_called_once_with( - CurrencyComConstants.SERVER_TIME_ENDPOINT + CurrencycomConstants.SERVER_TIME_ENDPOINT ) def test_get_exchange_info(self): self.client.get_exchange_info() self.mock_requests.assert_called_once_with( - CurrencyComConstants.EXCHANGE_INFORMATION_ENDPOINT + CurrencycomConstants.EXCHANGE_INFORMATION_ENDPOINT ) def test_get_order_book_default(self, monkeypatch): @@ -33,7 +34,7 @@ def test_get_order_book_default(self, monkeypatch): symbol = 'TEST' self.client.get_order_book(symbol) self.mock_requests.assert_called_once_with( - CurrencyComConstants.ORDER_BOOK_ENDPOINT, + CurrencycomConstants.ORDER_BOOK_ENDPOINT, params={'symbol': symbol, 'limit': 100} ) val_lim_mock.assert_called_once_with(100) @@ -45,7 +46,7 @@ def test_get_order_book_with_limit(self, monkeypatch): symbol = 'TEST' self.client.get_order_book(symbol, limit) self.mock_requests.assert_called_once_with( - CurrencyComConstants.ORDER_BOOK_ENDPOINT, + CurrencycomConstants.ORDER_BOOK_ENDPOINT, params={'symbol': symbol, 'limit': limit} ) val_lim_mock.assert_called_once_with(limit) @@ -54,7 +55,7 @@ def test_get_agg_trades_default(self): symbol = 'TEST' self.client.get_agg_trades(symbol) self.mock_requests.assert_called_once_with( - CurrencyComConstants.AGGREGATE_TRADE_LIST_ENDPOINT, + CurrencycomConstants.AGGREGATE_TRADE_LIST_ENDPOINT, params={'symbol': symbol, 'limit': 500} ) @@ -63,22 +64,22 @@ def test_get_agg_trades_limit_set(self): limit = 20 self.client.get_agg_trades(symbol, limit=limit) self.mock_requests.assert_called_once_with( - CurrencyComConstants.AGGREGATE_TRADE_LIST_ENDPOINT, + CurrencycomConstants.AGGREGATE_TRADE_LIST_ENDPOINT, params={'symbol': symbol, 'limit': limit} ) def test_get_agg_trades_max_limit(self): symbol = 'TEST' - limit = CurrencyComConstants.AGG_TRADES_MAX_LIMIT + limit = CurrencycomConstants.AGG_TRADES_MAX_LIMIT self.client.get_agg_trades(symbol, limit=limit) self.mock_requests.assert_called_once_with( - CurrencyComConstants.AGGREGATE_TRADE_LIST_ENDPOINT, + CurrencycomConstants.AGGREGATE_TRADE_LIST_ENDPOINT, params={'symbol': symbol, 'limit': limit} ) def test_get_agg_trades_exceed_limit(self): symbol = 'TEST' - limit = CurrencyComConstants.AGG_TRADES_MAX_LIMIT + 1 + limit = CurrencycomConstants.AGG_TRADES_MAX_LIMIT + 1 with pytest.raises(ValueError): self.client.get_agg_trades(symbol, limit=limit) self.mock_requests.assert_not_called() @@ -88,7 +89,7 @@ def test_get_agg_trades_only_start_time_set(self): start_time = datetime(2019, 1, 1, 1, 1, 1) self.client.get_agg_trades(symbol, start_time=start_time) self.mock_requests.assert_called_once_with( - CurrencyComConstants.AGGREGATE_TRADE_LIST_ENDPOINT, + CurrencycomConstants.AGGREGATE_TRADE_LIST_ENDPOINT, params={'symbol': symbol, 'limit': 500, 'startTime': start_time.timestamp() * 1000} ) @@ -98,7 +99,7 @@ def test_get_agg_trades_only_end_time_set(self): end_time = datetime(2019, 1, 1, 1, 1, 1) self.client.get_agg_trades(symbol, end_time=end_time) self.mock_requests.assert_called_once_with( - CurrencyComConstants.AGGREGATE_TRADE_LIST_ENDPOINT, + CurrencycomConstants.AGGREGATE_TRADE_LIST_ENDPOINT, params={'symbol': symbol, 'limit': 500, 'endTime': end_time.timestamp() * 1000} ) @@ -111,7 +112,7 @@ def test_get_agg_trades_both_time_set(self): start_time=start_time, end_time=end_time) self.mock_requests.assert_called_once_with( - CurrencyComConstants.AGGREGATE_TRADE_LIST_ENDPOINT, + CurrencycomConstants.AGGREGATE_TRADE_LIST_ENDPOINT, params={'symbol': symbol, 'limit': 500, 'startTime': start_time.timestamp() * 1000, 'endTime': end_time.timestamp() * 1000} @@ -129,43 +130,43 @@ def test_get_agg_trades_both_time_set_exceed_max_range(self): def test_get_klines_default(self): symbol = 'TEST' - self.client.get_klines(symbol, CandlesticksChartInervals.DAY) + self.client.get_klines(symbol, CandlesticksChartIntervals.DAY) self.mock_requests.assert_called_once_with( - CurrencyComConstants.KLINES_DATA_ENDPOINT, + CurrencycomConstants.KLINES_DATA_ENDPOINT, params={'symbol': symbol, - 'interval': CandlesticksChartInervals.DAY.value, + 'interval': CandlesticksChartIntervals.DAY.value, 'limit': 500} ) def test_get_klines_with_limit(self): symbol = 'TEST' limit = 123 - self.client.get_klines(symbol, CandlesticksChartInervals.DAY, + self.client.get_klines(symbol, CandlesticksChartIntervals.DAY, limit=limit) self.mock_requests.assert_called_once_with( - CurrencyComConstants.KLINES_DATA_ENDPOINT, + CurrencycomConstants.KLINES_DATA_ENDPOINT, params={'symbol': symbol, - 'interval': CandlesticksChartInervals.DAY.value, + 'interval': CandlesticksChartIntervals.DAY.value, 'limit': limit} ) def test_get_klines_max_limit(self): symbol = 'TEST' - limit = CurrencyComConstants.KLINES_MAX_LIMIT - self.client.get_klines(symbol, CandlesticksChartInervals.DAY, + limit = CurrencycomConstants.KLINES_MAX_LIMIT + self.client.get_klines(symbol, CandlesticksChartIntervals.DAY, limit=limit) self.mock_requests.assert_called_once_with( - CurrencyComConstants.KLINES_DATA_ENDPOINT, + CurrencycomConstants.KLINES_DATA_ENDPOINT, params={'symbol': symbol, - 'interval': CandlesticksChartInervals.DAY.value, + 'interval': CandlesticksChartIntervals.DAY.value, 'limit': limit} ) def test_get_klines_exceed_max_limit(self): symbol = 'TEST' - limit = CurrencyComConstants.KLINES_MAX_LIMIT + 1 + limit = CurrencycomConstants.KLINES_MAX_LIMIT + 1 with pytest.raises(ValueError): - self.client.get_klines(symbol, CandlesticksChartInervals.DAY, + self.client.get_klines(symbol, CandlesticksChartIntervals.DAY, limit=limit) self.mock_requests.assert_not_called() @@ -173,12 +174,12 @@ def test_get_klines_with_startTime(self): symbol = 'TEST' start_date = datetime(2020, 1, 1) self.client.get_klines(symbol, - CandlesticksChartInervals.DAY, + CandlesticksChartIntervals.DAY, start_time=start_date) self.mock_requests.assert_called_once_with( - CurrencyComConstants.KLINES_DATA_ENDPOINT, + CurrencycomConstants.KLINES_DATA_ENDPOINT, params={'symbol': symbol, - 'interval': CandlesticksChartInervals.DAY.value, + 'interval': CandlesticksChartIntervals.DAY.value, 'startTime': int(start_date.timestamp() * 1000), 'limit': 500} ) @@ -187,12 +188,12 @@ def test_get_klines_with_endTime(self): symbol = 'TEST' end_time = datetime(2020, 1, 1) self.client.get_klines(symbol, - CandlesticksChartInervals.DAY, + CandlesticksChartIntervals.DAY, end_time=end_time) self.mock_requests.assert_called_once_with( - CurrencyComConstants.KLINES_DATA_ENDPOINT, + CurrencycomConstants.KLINES_DATA_ENDPOINT, params={'symbol': symbol, - 'interval': CandlesticksChartInervals.DAY.value, + 'interval': CandlesticksChartIntervals.DAY.value, 'endTime': int(end_time.timestamp() * 1000), 'limit': 500} ) @@ -202,13 +203,13 @@ def test_get_klines_with_startTime_and_endTime(self): start_time = datetime(2020, 1, 1) end_time = datetime(2021, 1, 1) self.client.get_klines(symbol, - CandlesticksChartInervals.DAY, + CandlesticksChartIntervals.DAY, start_time=start_time, end_time=end_time) self.mock_requests.assert_called_once_with( - CurrencyComConstants.KLINES_DATA_ENDPOINT, + CurrencycomConstants.KLINES_DATA_ENDPOINT, params={'symbol': symbol, - 'interval': CandlesticksChartInervals.DAY.value, + 'interval': CandlesticksChartIntervals.DAY.value, 'startTime': int(start_time.timestamp() * 1000), 'endTime': int(end_time.timestamp() * 1000), 'limit': 500} @@ -217,7 +218,7 @@ def test_get_klines_with_startTime_and_endTime(self): def test_get_24h_price_change_default(self): self.client.get_24h_price_change() self.mock_requests.assert_called_once_with( - CurrencyComConstants.PRICE_CHANGE_24H_ENDPOINT, + CurrencycomConstants.PRICE_CHANGE_24H_ENDPOINT, params={} ) @@ -225,7 +226,7 @@ def test_get_24h_price_change_with_symbol(self): symbol = 'TEST' self.client.get_24h_price_change(symbol) self.mock_requests.assert_called_once_with( - CurrencyComConstants.PRICE_CHANGE_24H_ENDPOINT, + CurrencycomConstants.PRICE_CHANGE_24H_ENDPOINT, params={'symbol': symbol} ) @@ -238,7 +239,7 @@ def test_new_order_default_buy(self, monkeypatch): amount = 1 self.client.new_order(symbol, side, ord_type, amount) post_mock.assert_called_once_with( - CurrencyComConstants.ORDER_ENDPOINT, + CurrencycomConstants.ORDER_ENDPOINT, symbol=symbol, side=side.value, type=ord_type.value, @@ -258,7 +259,7 @@ def test_new_order_default_sell(self, monkeypatch): amount = 1 self.client.new_order(symbol, side, ord_type, amount) post_mock.assert_called_once_with( - CurrencyComConstants.ORDER_ENDPOINT, + CurrencycomConstants.ORDER_ENDPOINT, symbol=symbol, side=side.value, type=ord_type.value, @@ -277,7 +278,7 @@ def test_new_order_invalid_recv_window(self, monkeypatch): with pytest.raises(ValueError): self.client.new_order( symbol, side, ord_type, amount, - recv_window=CurrencyComConstants.RECV_WINDOW_MAX_LIMIT + 1) + recv_window=CurrencycomConstants.RECV_WINDOW_MAX_LIMIT + 1) self.mock_requests.assert_not_called() def test_new_order_default_limit(self, monkeypatch): @@ -293,10 +294,9 @@ def test_new_order_default_limit(self, monkeypatch): side, ord_type, price=price, - time_in_force=time_in_force, quantity=amount) post_mock.assert_called_once_with( - CurrencyComConstants.ORDER_ENDPOINT, + CurrencycomConstants.ORDER_ENDPOINT, symbol=symbol, side=side.value, type=ord_type.value, @@ -319,7 +319,6 @@ def test_new_order_incorrect_limit_no_price(self, monkeypatch): self.client.new_order(symbol, side, ord_type, - time_in_force=time_in_force, quantity=amount) post_mock.assert_not_called() @@ -346,7 +345,7 @@ def test_cancel_order_default_order_id(self, monkeypatch): order_id = 'TEST_ORDER_ID' self.client.cancel_order(symbol, order_id) delete_mock.assert_called_once_with( - CurrencyComConstants.ORDER_ENDPOINT, + CurrencycomConstants.ORDER_ENDPOINT, symbol=symbol, orderId=order_id, origClientOrderId=None, @@ -358,9 +357,9 @@ def test_cancel_order_default_client_order_id(self, monkeypatch): monkeypatch.setattr(self.client, '_delete', delete_mock) symbol = 'TEST' order_id = 'TEST_ORDER_ID' - self.client.cancel_order(symbol, orig_client_order_id=order_id) + self.client.cancel_order(symbol, order_id=order_id) delete_mock.assert_called_once_with( - CurrencyComConstants.ORDER_ENDPOINT, + CurrencycomConstants.ORDER_ENDPOINT, symbol=symbol, orderId=None, origClientOrderId=order_id, @@ -382,7 +381,7 @@ def test_cancel_order_invalid_recv_window(self, monkeypatch): with pytest.raises(ValueError): self.client.cancel_order( symbol, 'id', - recv_window=CurrencyComConstants.RECV_WINDOW_MAX_LIMIT + 1) + recv_window=CurrencycomConstants.RECV_WINDOW_MAX_LIMIT + 1) delete_mock.assert_not_called() def test_get_open_orders_default(self, monkeypatch): @@ -390,7 +389,7 @@ def test_get_open_orders_default(self, monkeypatch): monkeypatch.setattr(self.client, '_get', get_mock) self.client.get_open_orders() get_mock.assert_called_once_with( - CurrencyComConstants.CURRENT_OPEN_ORDERS_ENDPOINT, + CurrencycomConstants.CURRENT_OPEN_ORDERS_ENDPOINT, symbol=None, recvWindow=None ) @@ -401,7 +400,7 @@ def test_get_open_orders_with_symbol(self, monkeypatch): monkeypatch.setattr(self.client, '_get', get_mock) self.client.get_open_orders(symbol) get_mock.assert_called_once_with( - CurrencyComConstants.CURRENT_OPEN_ORDERS_ENDPOINT, + CurrencycomConstants.CURRENT_OPEN_ORDERS_ENDPOINT, symbol=symbol, recvWindow=None ) @@ -409,7 +408,7 @@ def test_get_open_orders_with_symbol(self, monkeypatch): def test_get_open_orders_invalid_recv_window(self): with pytest.raises(ValueError): self.client.get_open_orders( - recv_window=CurrencyComConstants.RECV_WINDOW_MAX_LIMIT + 1) + recv_window=CurrencycomConstants.RECV_WINDOW_MAX_LIMIT + 1) self.mock_requests.assert_not_called() def test_get_account_info_default(self, monkeypatch): @@ -417,14 +416,14 @@ def test_get_account_info_default(self, monkeypatch): monkeypatch.setattr(self.client, '_get', get_mock) self.client.get_account_info() get_mock.assert_called_once_with( - CurrencyComConstants.ACCOUNT_INFORMATION_ENDPOINT, + CurrencycomConstants.ACCOUNT_INFORMATION_ENDPOINT, recvWindow=None ) def test_get_account_info_invalid_recv_window(self): with pytest.raises(ValueError): self.client.get_account_info( - recv_window=CurrencyComConstants.RECV_WINDOW_MAX_LIMIT + 1) + recv_window=CurrencycomConstants.RECV_WINDOW_MAX_LIMIT + 1) self.mock_requests.assert_not_called() def test_get_account_trade_list_default(self, monkeypatch): @@ -433,7 +432,7 @@ def test_get_account_trade_list_default(self, monkeypatch): monkeypatch.setattr(self.client, '_get', get_mock) self.client.get_account_trade_list(symbol) get_mock.assert_called_once_with( - CurrencyComConstants.ACCOUNT_TRADE_LIST_ENDPOINT, + CurrencycomConstants.ACCOUNT_TRADE_LIST_ENDPOINT, symbol=symbol, limit=500, recvWindow=None @@ -446,7 +445,7 @@ def test_get_account_trade_list_with_start_time(self, monkeypatch): monkeypatch.setattr(self.client, '_get', get_mock) self.client.get_account_trade_list(symbol, start_time=start_time) get_mock.assert_called_once_with( - CurrencyComConstants.ACCOUNT_TRADE_LIST_ENDPOINT, + CurrencycomConstants.ACCOUNT_TRADE_LIST_ENDPOINT, symbol=symbol, limit=500, recvWindow=None, @@ -460,7 +459,7 @@ def test_get_account_trade_list_with_end_time(self, monkeypatch): monkeypatch.setattr(self.client, '_get', get_mock) self.client.get_account_trade_list(symbol, end_time=end_time) get_mock.assert_called_once_with( - CurrencyComConstants.ACCOUNT_TRADE_LIST_ENDPOINT, + CurrencycomConstants.ACCOUNT_TRADE_LIST_ENDPOINT, symbol=symbol, limit=500, recvWindow=None, @@ -477,7 +476,7 @@ def test_get_account_trade_list_with_start_and_end_times(self, monkeypatch): start_time=start_time, end_time=end_time) get_mock.assert_called_once_with( - CurrencyComConstants.ACCOUNT_TRADE_LIST_ENDPOINT, + CurrencycomConstants.ACCOUNT_TRADE_LIST_ENDPOINT, symbol=symbol, limit=500, recvWindow=None, @@ -489,7 +488,7 @@ def test_get_account_trade_list_incorrect_recv_window(self): with pytest.raises(ValueError): self.client.get_account_trade_list( 'TEST', - recv_window=CurrencyComConstants.RECV_WINDOW_MAX_LIMIT + 1) + recv_window=CurrencycomConstants.RECV_WINDOW_MAX_LIMIT + 1) self.mock_requests.assert_not_called() def test_get_account_trade_list_incorrect_limit(self): @@ -501,5 +500,5 @@ def test_get_account_trade_list_incorrect_limit(self): def test__to_epoch_miliseconds_default(self): dttm = datetime(1999, 1, 1, 1, 1, 1) - assert self.client._to_epoch_miliseconds(dttm) \ + assert self.client._to_epoch_milliseconds(dttm) \ == int(dttm.timestamp() * 1000)