diff --git a/hummingbot/connector/exchange/wazirx/__init__.py b/hummingbot/connector/exchange/wazirx/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/hummingbot/connector/exchange/wazirx/wazirx_api_order_book_data_source.py b/hummingbot/connector/exchange/wazirx/wazirx_api_order_book_data_source.py new file mode 100755 index 00000000000..0a5e18b831d --- /dev/null +++ b/hummingbot/connector/exchange/wazirx/wazirx_api_order_book_data_source.py @@ -0,0 +1,143 @@ +import asyncio +import time +from typing import TYPE_CHECKING, Any, Dict, List, Optional + +from hummingbot.connector.exchange.wazirx import wazirx_constants as CONSTANTS, wazirx_web_utils as web_utils +from hummingbot.connector.exchange.wazirx.wazirx_order_book import WazirxOrderBook +from hummingbot.core.data_type.order_book_message import OrderBookMessage +from hummingbot.core.data_type.order_book_tracker_data_source import OrderBookTrackerDataSource +from hummingbot.core.web_assistant.connections.data_types import RESTMethod, WSJSONRequest +from hummingbot.core.web_assistant.web_assistants_factory import WebAssistantsFactory +from hummingbot.core.web_assistant.ws_assistant import WSAssistant +from hummingbot.logger import HummingbotLogger + +if TYPE_CHECKING: + from hummingbot.connector.exchange.wazirx.wazirx_exchange import WazirxExchange + + +class WazirxAPIOrderBookDataSource(OrderBookTrackerDataSource): + HEARTBEAT_TIME_INTERVAL = 30.0 + ONE_HOUR = 60 * 60 + + _logger: Optional[HummingbotLogger] = None + + def __init__(self, + trading_pairs: List[str], + connector: 'WazirxExchange', + api_factory: WebAssistantsFactory, + domain: str = CONSTANTS.DEFAULT_DOMAIN): + super().__init__(trading_pairs) + self._connector = connector + self._trade_messages_queue_key = CONSTANTS.TRADE_EVENT_TYPE + self._diff_messages_queue_key = CONSTANTS.DIFF_EVENT_TYPE + self._domain = domain + self._api_factory = api_factory + + async def get_last_traded_prices(self, + trading_pairs: List[str], + domain: Optional[str] = None) -> Dict[str, float]: + return await self._connector.get_last_traded_prices(trading_pairs=trading_pairs) + + async def _request_order_book_snapshot(self, trading_pair: str) -> Dict[str, Any]: + """ + Retrieves a copy of the full order book from the exchange, for a particular trading pair. + + :param trading_pair: the trading pair for which the order book will be retrieved + + :return: the response from the exchange (JSON dictionary) + """ + params = { + "symbol": await self._connector.exchange_symbol_associated_to_pair(trading_pair=trading_pair), + "limit": "1000" + } + + rest_assistant = await self._api_factory.get_rest_assistant() + data = await rest_assistant.execute_request( + url=web_utils.public_rest_url(path_url=CONSTANTS.SNAPSHOT_PATH_URL, domain=self._domain), + params=params, + method=RESTMethod.GET, + throttler_limit_id=CONSTANTS.SNAPSHOT_PATH_URL, + ) + + return data + + async def _subscribe_channels(self, ws: WSAssistant): + """ + Subscribes to the trade events and diff orders events through the provided websocket connection. + :param ws: the websocket assistant used to connect to the exchange + """ + try: + trade_params = [] + depth_params = [] + for trading_pair in self._trading_pairs: + symbol = await self._connector.exchange_symbol_associated_to_pair(trading_pair=trading_pair) + trade_params.append(f"{symbol.lower()}@trades") + depth_params.append(f"{symbol.lower()}@depth10@100ms") + payload = { + "event": "subscribe", + "streams": trade_params + } + subscribe_trade_request: WSJSONRequest = WSJSONRequest(payload=payload) + + payload = { + "event": "subscribe", + "streams": depth_params + } + subscribe_orderbook_request: WSJSONRequest = WSJSONRequest(payload=payload) + + await ws.send(subscribe_trade_request) + await ws.send(subscribe_orderbook_request) + + self.logger().info("Subscribed to public order book and trade channels...") + except asyncio.CancelledError: + raise + except Exception: + self.logger().error( + "Unexpected error occurred subscribing to order book trading and delta streams...", + exc_info=True + ) + raise + + async def _connected_websocket_assistant(self) -> WSAssistant: + ws: WSAssistant = await self._api_factory.get_ws_assistant() + await ws.connect(ws_url=CONSTANTS.WSS_URL.format(self._domain), + ping_timeout=CONSTANTS.WS_HEARTBEAT_TIME_INTERVAL) + return ws + + async def _order_book_snapshot(self, trading_pair: str) -> OrderBookMessage: + snapshot: Dict[str, Any] = await self._request_order_book_snapshot(trading_pair) + snapshot_timestamp: float = time.time() + snapshot_msg: OrderBookMessage = WazirxOrderBook.snapshot_message_from_exchange( + snapshot, + snapshot_timestamp, + metadata={"trading_pair": trading_pair} + ) + return snapshot_msg + + async def _parse_trade_message(self, raw_message: Dict[str, Any], message_queue: asyncio.Queue): + if "code" not in raw_message: + trading_pair = await self._connector.trading_pair_associated_to_exchange_symbol(symbol=raw_message["data"]["trades"][0]["s"]) + trade_message = WazirxOrderBook.trade_message_from_exchange( + raw_message, {"trading_pair": trading_pair}) + message_queue.put_nowait(trade_message) + + async def _parse_order_book_diff_message(self, raw_message: Dict[str, Any], message_queue: asyncio.Queue): + if "code" not in raw_message: + trading_pair = await self._connector.trading_pair_associated_to_exchange_symbol(symbol=raw_message["data"]["s"]) + order_book_message: OrderBookMessage = WazirxOrderBook.diff_message_from_exchange( + raw_message, time.time(), {"trading_pair": trading_pair}) + message_queue.put_nowait(order_book_message) + + def _channel_originating_message(self, event_message: Dict[str, Any]) -> str: + channel = "" + if "stream" in event_message: + event_type = event_message["stream"] + if "@depth10@100ms" in event_type: + symbol = event_message["data"]["s"] + elif "trades" in event_type: + symbol = event_message["data"]["trades"][0]["s"] + if event_type == CONSTANTS.TRADE_EVENT_TYPE.format(symbol): + channel = self._trade_messages_queue_key + if event_type == CONSTANTS.DIFF_EVENT_TYPE.format(symbol): + channel = self._diff_messages_queue_key + return channel diff --git a/hummingbot/connector/exchange/wazirx/wazirx_api_user_stream_data_source.py b/hummingbot/connector/exchange/wazirx/wazirx_api_user_stream_data_source.py new file mode 100755 index 00000000000..5f2a6c2a4f8 --- /dev/null +++ b/hummingbot/connector/exchange/wazirx/wazirx_api_user_stream_data_source.py @@ -0,0 +1,97 @@ +import asyncio +from typing import TYPE_CHECKING, Any, Dict, Optional + +from hummingbot.connector.exchange.wazirx import wazirx_constants as CONSTANTS, wazirx_web_utils as web_utils +from hummingbot.connector.exchange.wazirx.wazirx_auth import WazirxAuth +from hummingbot.core.data_type.user_stream_tracker_data_source import UserStreamTrackerDataSource +from hummingbot.core.web_assistant.connections.data_types import RESTMethod, WSJSONRequest +from hummingbot.core.web_assistant.web_assistants_factory import WebAssistantsFactory +from hummingbot.core.web_assistant.ws_assistant import WSAssistant +from hummingbot.logger import HummingbotLogger + +if TYPE_CHECKING: + from hummingbot.connector.exchange.wazirx.wazirx_exchange import WazirxExchange + + +class WazirxAPIUserStreamDataSource(UserStreamTrackerDataSource): + _logger: Optional[HummingbotLogger] = None + + def __init__(self, + auth: WazirxAuth, + connector: 'WazirxExchange', + api_factory: WebAssistantsFactory, + domain: str = CONSTANTS.DEFAULT_DOMAIN): + + super().__init__() + self._auth: WazirxAuth = auth + self._api_factory = api_factory + self._connector = connector + self._domain = domain + self._current_auth_token: Optional[str] = None + self._api_factory = api_factory + + self._listen_key_initialized_event: asyncio.Event = asyncio.Event() + self._last_listen_key_ping_ts = 0 + + async def _connected_websocket_assistant(self) -> WSAssistant: + ws: WSAssistant = await self._api_factory.get_ws_assistant() + await ws.connect(ws_url=CONSTANTS.WSS_URL, ping_timeout=CONSTANTS.PING_TIMEOUT) + return ws + + @property + def last_recv_time(self): + if self._ws_assistant is None: + return 0 + else: + return self._ws_assistant.last_recv_time + + async def get_auth_token(self) -> str: + rest_assistant = await self._api_factory.get_rest_assistant() + try: + response_json = await rest_assistant.execute_request( + url=web_utils.public_rest_url(path_url=CONSTANTS.WAZIRX_USER_STREAM_PATH_URL, domain=self._domain), + method=RESTMethod.POST, + params={"recvWindow": 10000}, + is_auth_required=True, + throttler_limit_id=CONSTANTS.WAZIRX_USER_STREAM_PATH_URL, + headers=self._auth.header_for_authentication() + ) + except Exception: + raise + return response_json["auth_key"] + + async def _subscribe_channels(self, websocket_assistant: WSAssistant): + try: + self._current_listen_key = await self.get_auth_token() + payload = {"event": "subscribe", "streams": ["orderUpdate", "ownTrade", "outboundAccountPosition"], "auth_key": self._current_listen_key} + subscribe_request = WSJSONRequest(payload) + await websocket_assistant.send(subscribe_request) + + self.logger().info("Subscribed to private order changes and trades updates channels...") + except asyncio.CancelledError: + raise + except Exception: + self.logger().exception("Unexpected error occurred subscribing to user streams...") + raise + + async def _process_event_message(self, event_message: Dict[str, Any], queue: asyncio.Queue): + if "streams" or "data" in event_message or event_message["stream"] in [ + CONSTANTS.USER_TRADES_ENDPOINT_NAME, + CONSTANTS.USER_ORDERS_ENDPOINT_NAME, + CONSTANTS.USER_BALANCE_ENDPOINT_NAME + ]: + queue.put_nowait(event_message) + else: + if event_message.get("errorMessage") is not None: + err_msg = event_message.get("errorMessage") + raise IOError({ + "label": "WSS_ERROR", + "message": f"Error received via websocket - {err_msg}." + }) + + async def _send_ping(self, websocket_assistant: WSAssistant): + payload = { + "event": "ping", + } + ping_request: WSJSONRequest = WSJSONRequest(payload=payload) + await websocket_assistant.send(ping_request) diff --git a/hummingbot/connector/exchange/wazirx/wazirx_auth.py b/hummingbot/connector/exchange/wazirx/wazirx_auth.py new file mode 100644 index 00000000000..ff7581eae3a --- /dev/null +++ b/hummingbot/connector/exchange/wazirx/wazirx_auth.py @@ -0,0 +1,67 @@ +import hashlib +import hmac + +# import time +import json +from collections import OrderedDict +from typing import Any, Dict +from urllib.parse import urlencode + +from hummingbot.connector.time_synchronizer import TimeSynchronizer +from hummingbot.core.web_assistant.auth import AuthBase +from hummingbot.core.web_assistant.connections.data_types import RESTMethod, RESTRequest, WSRequest + + +class WazirxAuth(AuthBase): + def __init__(self, api_key: str, secret_key: str, time_provider: TimeSynchronizer): + self.api_key = api_key + self.secret_key = secret_key + self.time_provider = time_provider + + async def rest_authenticate(self, request: RESTRequest) -> RESTRequest: + """ + Adds the server time and the signature to the request, required for authenticated interactions. It also adds + the required parameter in the request header. + :param request: the request to be configured for authenticated interaction + """ + if request.method == RESTMethod.POST and request.data is not None: + request.data = self.add_auth_to_params(params=json.loads(request.data)) + else: + request.params = self.add_auth_to_params(params=request.params) + + headers = {} + if request.headers is not None: + headers.update(request.headers) + headers.update(self.header_for_authentication()) + request.headers = headers + + return request + + async def ws_authenticate(self, request: WSRequest) -> WSRequest: + """ + This method is intended to configure a websocket request to be authenticated. Wazirx does not use this + functionality + """ + return request # pass-through + + def add_auth_to_params(self, + params: Dict[str, Any]): + timestamp = int(self.time_provider.time() * 1e3) + + request_params = OrderedDict(params or {}) + request_params["timestamp"] = timestamp + + signature = self._generate_signature(params=request_params) + request_params["signature"] = signature + + return request_params + + def header_for_authentication(self) -> Dict[str, str]: + return {"X-API-KEY": self.api_key} + + def _generate_signature(self, params: Dict[str, Any]) -> str: + + encoded_params_str = urlencode(params) + signature = hmac.new(bytes(self.secret_key, 'latin-1'), msg=bytes(encoded_params_str, 'latin-1'), + digestmod=hashlib.sha256).hexdigest() + return signature diff --git a/hummingbot/connector/exchange/wazirx/wazirx_constants.py b/hummingbot/connector/exchange/wazirx/wazirx_constants.py new file mode 100644 index 00000000000..dde067e4ea4 --- /dev/null +++ b/hummingbot/connector/exchange/wazirx/wazirx_constants.py @@ -0,0 +1,83 @@ +from hummingbot.core.api_throttler.data_types import RateLimit +from hummingbot.core.data_type.common import OrderType +from hummingbot.core.data_type.in_flight_order import OrderState + +DEFAULT_DOMAIN = "wazirx" + +HBOT_ORDER_ID_PREFIX = "x-XEKWYICX" +MAX_ORDER_ID_LEN = 32 + +# Base URL +REST_URL = "https://api.wazirx.com/sapi/" +WSS_URL = "wss://stream.wazirx.com/stream" + +PUBLIC_API_VERSION = "v1" +PRIVATE_API_VERSION = "v1" + +# Public API endpoints or WazirxClient function +TICKER_PRICE_CHANGE_PATH_URL = "/ticker/24hr" +TICKER_BOOK_PATH_URL = "/tickers/24hr" +EXCHANGE_INFO_PATH_URL = "/exchangeInfo" +PING_PATH_URL = "/ping" +SNAPSHOT_PATH_URL = "/depth" +SERVER_TIME_PATH_URL = "/time" + +# Private API endpoints or WazirxClient function +ACCOUNTS_PATH_URL = "/funds" +MY_TRADES_PATH_URL = "/myTrades" +ORDER_PATH_URL = "/order" +WAZIRX_USER_STREAM_PATH_URL = "/create_auth_token" + +WS_HEARTBEAT_TIME_INTERVAL = 30 +PING_TIMEOUT = 10 + +ORDER_TYPE_MAP = { + OrderType.LIMIT: "limit", + OrderType.LIMIT_MAKER: "limit_maker" +} +# Wazirx params + +SIDE_BUY = "buy" +SIDE_SELL = "sell" + +# Rate Limit time intervals +ONE_MINUTE = 60 +ONE_SECOND = 1 +ONE_DAY = 86400 + +MAX_REQUEST = 5000 + +# Order States +ORDER_STATE = { + "idle": OrderState.PENDING_CREATE, + "wait": OrderState.PARTIALLY_FILLED, + "done": OrderState.FILLED, + "cancel": OrderState.CANCELED, + "failed": OrderState.FAILED +} + +# Websocket event types +DIFF_EVENT_TYPE = "{}@depth10@100ms" +TRADE_EVENT_TYPE = "{}@trades" + +USER_TRADES_ENDPOINT_NAME = "ownTrade" +USER_ORDERS_ENDPOINT_NAME = "orderUpdate" +USER_BALANCE_ENDPOINT_NAME = "outboundAccountPosition" + +RATE_LIMITS = [ + RateLimit(limit_id=PING_PATH_URL, limit=1, time_interval=ONE_SECOND), + RateLimit(limit_id=EXCHANGE_INFO_PATH_URL, limit=1, time_interval=ONE_SECOND), + RateLimit(limit_id=TICKER_PRICE_CHANGE_PATH_URL, limit=1, time_interval=ONE_SECOND), + RateLimit(limit_id=TICKER_BOOK_PATH_URL, limit=1, time_interval=ONE_SECOND), + RateLimit(limit_id=SNAPSHOT_PATH_URL, limit=1, time_interval=ONE_SECOND), + RateLimit(limit_id=WAZIRX_USER_STREAM_PATH_URL, limit=1, time_interval=ONE_SECOND), + RateLimit(limit_id=SERVER_TIME_PATH_URL, limit=1, time_interval=ONE_SECOND), + RateLimit(limit_id=ACCOUNTS_PATH_URL, limit=10, time_interval=ONE_SECOND), + RateLimit(limit_id=MY_TRADES_PATH_URL, limit=2, time_interval=ONE_SECOND), + RateLimit(limit_id=ORDER_PATH_URL, limit=10, time_interval=ONE_SECOND), +] + +ORDER_NOT_EXIST_ERROR_CODE = -2013 +ORDER_NOT_EXIST_MESSAGE = "Order does not exist" +UNKNOWN_ORDER_ERROR_CODE = -2011 +UNKNOWN_ORDER_MESSAGE = "Unknown order sent" diff --git a/hummingbot/connector/exchange/wazirx/wazirx_exchange.py b/hummingbot/connector/exchange/wazirx/wazirx_exchange.py new file mode 100755 index 00000000000..68a32d07ca4 --- /dev/null +++ b/hummingbot/connector/exchange/wazirx/wazirx_exchange.py @@ -0,0 +1,615 @@ +import asyncio +from decimal import Decimal +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple + +from bidict import bidict + +from hummingbot.connector.constants import s_decimal_NaN +from hummingbot.connector.exchange.wazirx import ( + wazirx_constants as CONSTANTS, + wazirx_utils, + wazirx_web_utils as web_utils, +) +from hummingbot.connector.exchange.wazirx.wazirx_api_order_book_data_source import WazirxAPIOrderBookDataSource +from hummingbot.connector.exchange.wazirx.wazirx_api_user_stream_data_source import WazirxAPIUserStreamDataSource +from hummingbot.connector.exchange.wazirx.wazirx_auth import WazirxAuth +from hummingbot.connector.exchange_py_base import ExchangePyBase +from hummingbot.connector.trading_rule import TradingRule +from hummingbot.connector.utils import combine_to_hb_trading_pair +from hummingbot.core.data_type.common import OrderType, TradeType +from hummingbot.core.data_type.in_flight_order import InFlightOrder, OrderUpdate, TradeUpdate +from hummingbot.core.data_type.order_book_tracker_data_source import OrderBookTrackerDataSource +from hummingbot.core.data_type.trade_fee import DeductedFromReturnsTradeFee, TokenAmount, TradeFeeBase +from hummingbot.core.data_type.user_stream_tracker_data_source import UserStreamTrackerDataSource + +# from hummingbot.core.event.events import MarketEvent, OrderFilledEvent +# from hummingbot.core.utils.async_utils import safe_gather +from hummingbot.core.web_assistant.connections.data_types import RESTMethod +from hummingbot.core.web_assistant.web_assistants_factory import WebAssistantsFactory + +if TYPE_CHECKING: + from hummingbot.client.config.config_helpers import ClientConfigAdapter + + +class WazirxExchange(ExchangePyBase): + UPDATE_ORDER_STATUS_MIN_INTERVAL = 10.0 + + web_utils = web_utils + + def __init__(self, + client_config_map: "ClientConfigAdapter", + wazirx_api_key: str, + wazirx_api_secret: str, + trading_pairs: Optional[List[str]] = None, + trading_required: bool = True, + domain: str = CONSTANTS.DEFAULT_DOMAIN, + ): + self.api_key = wazirx_api_key + self.secret_key = wazirx_api_secret + self._domain = domain + self._trading_required = trading_required + self._trading_pairs = trading_pairs + self._last_trades_poll_wazirx_timestamp = 1.0 + + self._max_trade_id_by_symbol: Dict[str, int] = dict() + super().__init__(client_config_map) + + @staticmethod + def wazirx_order_type(order_type: OrderType) -> str: + return CONSTANTS.ORDER_TYPE_MAP[order_type] + + @staticmethod + def to_hb_order_type(wazirx_type: str) -> OrderType: + return OrderType[wazirx_type] + + @property + def authenticator(self): + return WazirxAuth( + api_key=self.api_key, + secret_key=self.secret_key, + time_provider=self._time_synchronizer) + + @property + def name(self) -> str: + return "wazirx" + + @property + def rate_limits_rules(self): + return CONSTANTS.RATE_LIMITS + + @property + def domain(self): + return self._domain + + @property + def client_order_id_max_length(self): + return CONSTANTS.MAX_ORDER_ID_LEN + + @property + def client_order_id_prefix(self): + return CONSTANTS.HBOT_ORDER_ID_PREFIX + + @property + def trading_rules_request_path(self): + return CONSTANTS.EXCHANGE_INFO_PATH_URL + + @property + def trading_pairs_request_path(self): + return CONSTANTS.EXCHANGE_INFO_PATH_URL + + @property + def check_network_request_path(self): + return CONSTANTS.PING_PATH_URL + + @property + def trading_pairs(self): + return self._trading_pairs + + @property + def is_cancel_request_in_exchange_synchronous(self) -> bool: + return True + + @property + def is_trading_required(self) -> bool: + return self._trading_required + + def supported_order_types(self): + return [OrderType.LIMIT, OrderType.LIMIT_MAKER] + + async def get_all_pairs_prices(self) -> List[Dict[str, str]]: + pairs_prices = await self._api_get(path_url=CONSTANTS.TICKER_BOOK_PATH_URL) + return pairs_prices + + def _is_request_exception_related_to_time_synchronizer(self, request_exception: Exception): + error_description = str(request_exception) + is_time_synchronizer_related = ("-1021" in error_description + and "Timestamp for this request" in error_description) + return is_time_synchronizer_related + + def _is_order_not_found_during_status_update_error(self, status_update_exception: Exception) -> bool: + return str(CONSTANTS.ORDER_NOT_EXIST_ERROR_CODE) in str( + status_update_exception + ) and CONSTANTS.ORDER_NOT_EXIST_MESSAGE in str(status_update_exception) + + def _is_order_not_found_during_cancelation_error(self, cancelation_exception: Exception) -> bool: + return str(CONSTANTS.UNKNOWN_ORDER_ERROR_CODE) in str( + cancelation_exception + ) and CONSTANTS.UNKNOWN_ORDER_MESSAGE in str(cancelation_exception) + + def _create_web_assistants_factory(self) -> WebAssistantsFactory: + return web_utils.build_api_factory( + throttler=self._throttler, + time_synchronizer=self._time_synchronizer, + domain=self._domain, + auth=self._auth) + + def _create_order_book_data_source(self) -> OrderBookTrackerDataSource: + return WazirxAPIOrderBookDataSource( + trading_pairs=self._trading_pairs, + connector=self, + domain=self.domain, + api_factory=self._web_assistants_factory) + + def _create_user_stream_data_source(self) -> UserStreamTrackerDataSource: + return WazirxAPIUserStreamDataSource( + auth=self._auth, + connector=self, + api_factory=self._web_assistants_factory, + domain=self.domain, + ) + + def _get_fee(self, + base_currency: str, + quote_currency: str, + order_type: OrderType, + order_side: TradeType, + amount: Decimal, + price: Decimal = s_decimal_NaN, + is_maker: Optional[bool] = None) -> TradeFeeBase: + is_maker = order_type is OrderType.LIMIT_MAKER + return DeductedFromReturnsTradeFee(percent=self.estimate_fee_pct(is_maker)) + + async def _place_order(self, + order_id: str, + trading_pair: str, + amount: Decimal, + trade_type: TradeType, + order_type: OrderType, + price: Decimal, + **kwargs) -> Tuple[str, float]: + order_result = None + amount_str = f"{amount:f}" + type_str = CONSTANTS.ORDER_TYPE_MAP[order_type] + side_str = CONSTANTS.SIDE_BUY if trade_type is TradeType.BUY else CONSTANTS.SIDE_SELL + symbol = await self.exchange_symbol_associated_to_pair(trading_pair=trading_pair) + api_params = {"symbol": symbol, + "side": side_str, + "quantity": amount_str, + "type": type_str, + "clientOrderId": order_id} + if order_type.is_limit_type(): + price_str = f"{price:f}" + api_params["price"] = price_str + + try: + order_result = await self._api_post( + path_url=CONSTANTS.ORDER_PATH_URL, + data=api_params, + is_auth_required=True) + o_id = str(order_result["id"]) + transact_time = order_result["updatedTime"] * 1e-3 + except IOError as e: + error_description = str(e) + is_server_overloaded = ("status is 503" in error_description + and "Unknown error, please check your request or try again later." in error_description) + if is_server_overloaded: + o_id = "UNKNOWN" + transact_time = self._time_synchronizer.time() + else: + raise + return o_id, transact_time + + async def _place_cancel(self, order_id: str, tracked_order: InFlightOrder): + symbol = await self.exchange_symbol_associated_to_pair(trading_pair=tracked_order.trading_pair) + api_params = { + "symbol": symbol, + "clientOrderId": order_id, + } + cancel_result = await self._api_delete( + path_url=CONSTANTS.ORDER_PATH_URL, + params=api_params, + is_auth_required=True) + if cancel_result.get("status") == "wait": + return True + return False + + async def _format_trading_rules(self, exchange_info_dict: Dict[str, Any]) -> List[TradingRule]: + """ + Example: + { + "symbol": "btcinr", + "status": "trading", + "baseAsset": "btc", + "quoteAsset": "inr", + "baseAssetPrecision": 5, + "quoteAssetPrecision": 0, + "orderTypes": [ + "limit", + "stop_limit", + "limit_maker" + ], + "isSpotTradingAllowed": true, + "filters": [ + { + "filterType": "PRICE_FILTER", + "minPrice": "1.000000", + "tickSize": "1.000000" + } + ] + } + """ + trading_pair_rules = exchange_info_dict.get("symbols", []) + retval = [] + for rule in filter(wazirx_utils.is_exchange_information_valid, trading_pair_rules): + try: + trading_pair = await self.trading_pair_associated_to_exchange_symbol(symbol=rule.get("symbol")) + filters = rule.get("filters") + price_filter = [f for f in filters if f.get("filterType") == "PRICE_FILTER"][0] + + min_order_size = Decimal(0.0001) + tick_size = price_filter.get("minPrice") + step_size = Decimal(price_filter.get("tickSize")) + + retval.append( + TradingRule(trading_pair, + min_order_size=min_order_size, + min_price_increment=Decimal(tick_size), + min_base_amount_increment=Decimal(step_size))) + + except Exception: + self.logger().exception(f"Error parsing the trading pair rule {rule}. Skipping.") + return retval + + async def _update_trading_fees(self): + """ + Update fees information from the exchange + """ + pass + + async def _user_stream_event_listener(self): + """ + Listens to messages from _user_stream_tracker.user_stream queue. + Traders, Orders, and Balance updates from the WS. + """ + async for event_message in self._iter_user_event_queue(): + try: + channels: List[str] = event_message.get("data", {}).get("streams", []) + channel: str = event_message.get("stream", None) + events: Dict[str, Any] = event_message.get("data", {}).get("event", {}) + results: Dict[str, Any] = event_message.get("data", {}) + if "subscribed" in events and channel not in channels: + self.logger().error( + f"Unexpected message in user stream: {event_message}.", exc_info=True) + continue + if channel == CONSTANTS.USER_TRADES_ENDPOINT_NAME: + self._process_trade_message(results) + elif channel == CONSTANTS.USER_ORDERS_ENDPOINT_NAME: + self._process_order_message(event_message) + elif channel == CONSTANTS.USER_BALANCE_ENDPOINT_NAME: + self._process_balance_message_ws(results) + + except asyncio.CancelledError: + raise + except Exception: + self.logger().error( + "Unexpected error in user stream listener loop.", exc_info=True) + await self._sleep(5.0) + + def _process_balance_message_ws(self, account): + balances = account["B"] + for balance_entry in balances: + asset_name = balance_entry["a"] + free_balance = Decimal(balance_entry["b"]) + total_balance = Decimal(balance_entry["b"]) + Decimal(balance_entry["l"]) + self._account_available_balances[asset_name] = free_balance + self._account_balances[asset_name] = total_balance + + def _create_trade_update_with_order_fill_data( + self, + order_fill: Dict[str, Any], + order: InFlightOrder): + + fee = TradeFeeBase.new_spot_fee( + fee_schema=self.trade_fee_schema(), + trade_type=order.trade_type, + percent_token=order_fill["U"], + flat_fees=[TokenAmount(amount=Decimal(order_fill["f"]), token=order_fill["U"])] + ) + trade_update = TradeUpdate( + trade_id=str(order_fill["t"]), + client_order_id=order.client_order_id, + exchange_order_id=str(order_fill["o"]), + trading_pair=order.trading_pair, + fee=fee, + fill_base_amount=Decimal(order_fill["q"]), + fill_quote_amount=Decimal(order_fill["q"]) * Decimal(order_fill["p"]), + fill_price=Decimal(order_fill["p"]), + fill_timestamp=order_fill["E"] * 1e-3, + ) + return trade_update + + def _process_trade_message(self, trade: Dict[str, Any], client_order_id: Optional[str] = None): + client_order_id = client_order_id or str(trade["c"]) + tracked_order = self._order_tracker.all_fillable_orders.get(client_order_id) + if tracked_order is None: + self.logger().debug(f"Ignoring trade message with id {client_order_id}: not in in_flight_orders.") + else: + trade_update = self._create_trade_update_with_order_fill_data( + order_fill=trade, + order=tracked_order) + self._order_tracker.process_trade_update(trade_update) + + def _create_order_update_with_order_status_data(self, order_status: Dict[str, Any], order: InFlightOrder): + client_order_id = str(order_status["data"].get("c", "")) + order_update = OrderUpdate( + trading_pair=order.trading_pair, + update_timestamp=order_status["data"]["E"] * 1e-3, + new_state=CONSTANTS.ORDER_STATE[order_status["data"]["X"]], + client_order_id=client_order_id, + exchange_order_id=str(order_status["data"]["i"]), + ) + return order_update + + def _process_order_message(self, raw_msg: Dict[str, Any]): + # order_msg = raw_msg.get("data", {}) + client_order_id = raw_msg["data"].get("c", "") + tracked_order = self._order_tracker.all_updatable_orders.get(client_order_id) + if not tracked_order: + self.logger().debug(f"Ignoring order message with id {client_order_id}: not in in_flight_orders.") + return + + order_update = self._create_order_update_with_order_status_data(order_status=raw_msg, order=tracked_order) + self._order_tracker.process_order_update(order_update=order_update) + + # async def _update_order_fills_from_trades(self): + # """ + # This is intended to be a backup measure to get filled events with trade ID for orders, + # in case Wazirx's user stream events are not working. + # NOTE: It is not required to copy this functionality in other connectors. + # This is separated from _update_order_status which only updates the order status without producing filled + # events, since Wazirx's get order endpoint does not return trade IDs. + # The minimum poll interval for order status is 10 seconds. + # """ + # small_interval_last_tick = self._last_poll_timestamp / self.UPDATE_ORDER_STATUS_MIN_INTERVAL + # small_interval_current_tick = self.current_timestamp / self.UPDATE_ORDER_STATUS_MIN_INTERVAL + # long_interval_last_tick = self._last_poll_timestamp / self.LONG_POLL_INTERVAL + # long_interval_current_tick = self.current_timestamp / self.LONG_POLL_INTERVAL + + # if (long_interval_current_tick > long_interval_last_tick + # or (self.in_flight_orders and small_interval_current_tick > small_interval_last_tick)): + # query_time = int(self._last_trades_poll_wazirx_timestamp * 1e3) + # self._last_trades_poll_wazirx_timestamp = self._time_synchronizer.time() + # order_by_exchange_id_map = {} + # for order in self._order_tracker.all_fillable_orders.values(): + # order_by_exchange_id_map[order.exchange_order_id] = order + + # tasks = [] + # trading_pairs = self.trading_pairs + # for trading_pair in trading_pairs: + # params = { + # "symbol": await self.exchange_symbol_associated_to_pair(trading_pair=trading_pair) + # } + # if self._last_poll_timestamp > 0: + # params["startTime"] = query_time + # tasks.append(self._api_get( + # path_url=CONSTANTS.MY_TRADES_PATH_URL, + # params=params, + # is_auth_required=True)) + + # self.logger().debug(f"Polling for order fills of {len(tasks)} trading pairs.") + # results = await safe_gather(*tasks, return_exceptions=True) + + # for trades, trading_pair in zip(results, trading_pairs): + + # if isinstance(trades, Exception): + # self.logger().network( + # f"Error fetching trades update for the order {trading_pair}: {trades}.", + # app_warning_msg=f"Failed to fetch trade update for {trading_pair}." + # ) + # continue + # for trade in trades: + # exchange_order_id = str(trade["orderId"]) + # if exchange_order_id in order_by_exchange_id_map: + # # This is a fill for a tracked order + # tracked_order = order_by_exchange_id_map[exchange_order_id] + # fee = TradeFeeBase.new_spot_fee( + # fee_schema=self.trade_fee_schema(), + # trade_type=tracked_order.trade_type, + # percent_token=trade["feeCurrency"], + # flat_fees=[TokenAmount(amount=Decimal(trade["fee"]), token=trade["feeCurrency"])] + # ) + # trade_update = TradeUpdate( + # trade_id=str(trade["id"]), + # client_order_id=tracked_order.client_order_id, + # exchange_order_id=exchange_order_id, + # trading_pair=trading_pair, + # fee=fee, + # fill_base_amount=Decimal(trade["qty"]), + # fill_quote_amount=Decimal(trade["quoteQty"]), + # fill_price=Decimal(trade["price"]), + # fill_timestamp=trade["time"] * 1e-3, + # ) + # self._order_tracker.process_trade_update(trade_update) + # elif self.is_confirmed_new_order_filled_event(str(trade["id"]), exchange_order_id, trading_pair): + # # This is a fill of an order registered in the DB but not tracked any more + # self._current_trade_fills.add(TradeFillOrderDetails( + # market=self.display_name, + # exchange_trade_id=str(trade["id"]), + # symbol=trading_pair)) + # self.trigger_event( + # MarketEvent.OrderFilled, + # OrderFilledEvent( + # timestamp=float(trade["time"]) * 1e-3, + # order_id=self._exchange_order_ids.get(str(trade["orderId"]), None), + # trading_pair=trading_pair, + # trade_type=TradeType.BUY if trade["side"] == "buy" else TradeType.SELL, + # order_type=OrderType.LIMIT if trade["isBuyerMaker"] else OrderType.LIMIT_MAKER, + # price=Decimal(trade["price"]), + # amount=Decimal(trade["qty"]), + # trade_fee=DeductedFromReturnsTradeFee( + # flat_fees=[ + # TokenAmount( + # trade["feeCurrency"], + # Decimal(trade["fee"]) + # ) + # ] + # ), + # exchange_trade_id=str(trade["id"]) + # )) + # self.logger().info(f"Recreating missing trade in TradeFill: {trade}") + + async def _all_trade_updates_for_orders(self, orders: List[InFlightOrder]) -> List[TradeUpdate]: + # This endpoint is the only one on v2 for some reason + url = CONSTANTS.REST_URL + CONSTANTS.MY_TRADES_PATH_URL + symbols = {await self.exchange_symbol_associated_to_pair(trading_pair=o.trading_pair) for o in orders} + trade_updates = [] + orders_to_process = {order.client_order_id: order for order in orders} + for symbol in symbols: + for _ in range(2): + params = {"symbol": symbol, "limit": 1000} + if symbol in self._max_trade_id_by_symbol: + params["fromId"] = self._max_trade_id_by_symbol[symbol] + result = await self._api_get( + path_url=CONSTANTS.MY_TRADES_PATH_URL, + params=params, + is_auth_required=True, + overwrite_url=url, + ) + + for trade_data in result: + if str(trade_data["orderId"]) in orders_to_process: + order = orders_to_process[str(trade_data["orderId"])] + fee_token = trade_data["feeCurrency"].upper() # typo in the json by the exchange + fee = TradeFeeBase.new_spot_fee( + fee_schema=self.trade_fee_schema(), + trade_type=order.trade_type, + percent_token=fee_token, + flat_fees=[TokenAmount(amount=Decimal(trade_data["fee"]), token=fee_token)], + ) + trade_update = TradeUpdate( + trade_id=str(trade_data["id"]), + client_order_id=order.client_order_id, + exchange_order_id=str(trade_data["id"]), + trading_pair=order.trading_pair, + fee=fee, + fill_base_amount=Decimal(trade_data["qty"]), + fill_quote_amount=Decimal(trade_data["qty"]) * Decimal(trade_data["price"]), + fill_price=Decimal(trade_data["price"]), + fill_timestamp=trade_data["time"] * 1e-3, + ) + trade_updates.append(trade_update) + if len(result) > 0: + self._max_trade_id_by_symbol[symbol] = max(int(t["id"]) for t in result) + if len(result) < 1000: + break + + return trade_updates + + async def _all_trade_updates_for_order(self, order: InFlightOrder) -> List[TradeUpdate]: + trade_updates = [] + + if order.exchange_order_id is not None: + exchange_order_id = int(order.exchange_order_id) + trading_pair = await self.exchange_symbol_associated_to_pair(trading_pair=order.trading_pair) + all_fills_response = await self._api_get( + path_url=CONSTANTS.MY_TRADES_PATH_URL, + params={ + "symbol": trading_pair, + "orderId": exchange_order_id + }, + is_auth_required=True, + limit_id=CONSTANTS.MY_TRADES_PATH_URL) + + for trade in all_fills_response: + exchange_order_id = str(trade["orderId"]) + fee = TradeFeeBase.new_spot_fee( + fee_schema=self.trade_fee_schema(), + trade_type=order.trade_type, + percent_token=trade["feeCurrency"], + flat_fees=[TokenAmount(amount=Decimal(trade["fee"]), token=trade["feeCurrency"])] + ) + trade_update = TradeUpdate( + trade_id=str(trade["id"]), + client_order_id=order.client_order_id, + exchange_order_id=exchange_order_id, + trading_pair=trading_pair, + fee=fee, + fill_base_amount=Decimal(trade["qty"]), + fill_quote_amount=Decimal(trade["quoteQty"]), + fill_price=Decimal(trade["price"]), + fill_timestamp=trade["time"] * 1e-3, + ) + trade_updates.append(trade_update) + + return trade_updates + + async def _request_order_status(self, tracked_order: InFlightOrder) -> OrderUpdate: + updated_order_data = await self._api_get( + path_url=CONSTANTS.ORDER_PATH_URL, + params={ + "clientOrderId": tracked_order.client_order_id}, + is_auth_required=True) + + new_state = CONSTANTS.ORDER_STATE[updated_order_data["status"]] + + order_update = OrderUpdate( + client_order_id=tracked_order.client_order_id, + exchange_order_id=str(updated_order_data["id"]), + trading_pair=tracked_order.trading_pair, + update_timestamp=updated_order_data["updatedTime"] * 1e-3, + new_state=new_state, + ) + + return order_update + + async def _update_balances(self): + local_asset_names = set(self._account_balances.keys()) + remote_asset_names = set() + + account_info = await self._api_get( + path_url=CONSTANTS.ACCOUNTS_PATH_URL, + is_auth_required=True) + + balances: list = account_info + for balance_entry in balances: + asset_name = balance_entry['asset'] + free_balance = Decimal(balance_entry["free"]) + total_balance = Decimal(balance_entry["free"]) + Decimal(balance_entry["locked"]) + self._account_available_balances[asset_name] = free_balance + self._account_balances[asset_name] = total_balance + remote_asset_names.add(asset_name) + + asset_names_to_remove = local_asset_names.difference(remote_asset_names) + for asset_name in asset_names_to_remove: + del self._account_available_balances[asset_name] + del self._account_balances[asset_name] + + def _initialize_trading_pair_symbols_from_exchange_info(self, exchange_info: Dict[str, Any]): + mapping = bidict() + for symbol_data in filter(wazirx_utils.is_exchange_information_valid, exchange_info["symbols"]): + mapping[symbol_data["symbol"]] = combine_to_hb_trading_pair(base=symbol_data["baseAsset"].upper(), + quote=symbol_data["quoteAsset"].upper()) + self._set_trading_pair_symbol_map(mapping) + + async def _get_last_traded_price(self, trading_pair: str) -> float: + params = { + "symbol": await self.exchange_symbol_associated_to_pair(trading_pair=trading_pair) + } + + resp_json = await self._api_request( + method=RESTMethod.GET, + path_url=CONSTANTS.TICKER_PRICE_CHANGE_PATH_URL, + params=params + ) + + return float(resp_json["lastPrice"]) diff --git a/hummingbot/connector/exchange/wazirx/wazirx_order_book.py b/hummingbot/connector/exchange/wazirx/wazirx_order_book.py new file mode 100644 index 00000000000..e9569dcb769 --- /dev/null +++ b/hummingbot/connector/exchange/wazirx/wazirx_order_book.py @@ -0,0 +1,70 @@ +from typing import Dict, Optional + +from hummingbot.core.data_type.common import TradeType +from hummingbot.core.data_type.order_book import OrderBook +from hummingbot.core.data_type.order_book_message import OrderBookMessage, OrderBookMessageType + + +class WazirxOrderBook(OrderBook): + + @classmethod + def snapshot_message_from_exchange(cls, + msg: Dict[str, any], + timestamp: float, + metadata: Optional[Dict] = None) -> OrderBookMessage: + """ + Creates a snapshot message with the order book snapshot message + :param msg: the response from the exchange when requesting the order book snapshot + :param timestamp: the snapshot timestamp + :param metadata: a dictionary with extra information to add to the snapshot data + :return: a snapshot message with the snapshot information received from the exchange + """ + if metadata: + msg.update(metadata) + return OrderBookMessage(OrderBookMessageType.SNAPSHOT, { + "trading_pair": msg["trading_pair"], + "update_id": msg["timestamp"], + "bids": msg["bids"], + "asks": msg["asks"] + }, timestamp=timestamp) + + @classmethod + def diff_message_from_exchange(cls, + msg: Dict[str, any], + timestamp: Optional[float] = None, + metadata: Optional[Dict] = None) -> OrderBookMessage: + """ + Creates a diff message with the changes in the order book received from the exchange + :param msg: the changes in the order book + :param timestamp: the timestamp of the difference + :param metadata: a dictionary with extra information to add to the difference data + :return: a diff message with the changes in the order book notified by the exchange + """ + if metadata: + msg.update(metadata) + return OrderBookMessage(OrderBookMessageType.DIFF, { + "trading_pair": msg["data"]["s"], + "update_id": msg["data"]["E"], + "bids": msg["data"]["b"], + "asks": msg["data"]["a"] + }, timestamp=timestamp) + + @classmethod + def trade_message_from_exchange(cls, msg: Dict[str, any], metadata: Optional[Dict] = None): + """ + Creates a trade message with the information from the trade event sent by the exchange + :param msg: the trade event details sent by the exchange + :param metadata: a dictionary with extra information to add to trade message + :return: a trade message with the details of the trade as provided by the exchange + """ + if metadata: + msg.update(metadata) + ts = msg["data"]["trades"][0]["E"] + return OrderBookMessage(OrderBookMessageType.TRADE, { + "trading_pair": msg["data"]["trades"][0]["s"], + "trade_type": float(TradeType.SELL.value) if msg["data"]["trades"][0]["m"] else float(TradeType.BUY.value), + "trade_id": msg["data"]["trades"][0]["t"], + "update_id": ts, + "price": msg["data"]["trades"][0]["p"], + "amount": msg["data"]["trades"][0]["q"] + }, timestamp=ts * 1e-3) diff --git a/hummingbot/connector/exchange/wazirx/wazirx_utils.py b/hummingbot/connector/exchange/wazirx/wazirx_utils.py new file mode 100644 index 00000000000..d927f0fea60 --- /dev/null +++ b/hummingbot/connector/exchange/wazirx/wazirx_utils.py @@ -0,0 +1,62 @@ +from decimal import Decimal +from typing import Any, Dict + +from pydantic import Field, SecretStr + +from hummingbot.client.config.config_data_types import BaseConnectorConfigMap, ClientFieldData +from hummingbot.core.data_type.trade_fee import TradeFeeSchema + +CENTRALIZED = True +EXAMPLE_PAIR = "ZRX-ETH" + +DEFAULT_FEES = TradeFeeSchema( + maker_percent_fee_decimal=Decimal("0.001"), + taker_percent_fee_decimal=Decimal("0.001"), + buy_percent_fee_deducted_from_returns=True +) + + +def is_exchange_information_valid(exchange_info: Dict[str, Any]) -> bool: + """ + Verifies if a trading pair is enabled to operate with based on its exchange information + :param exchange_info: the exchange information for a trading pair + :return: True if the trading pair is enabled, False otherwise + """ + is_spot = False + is_trading = False + + if exchange_info.get("status", None) == "trading": + is_trading = True + + if exchange_info.get("isSpotTradingAllowed", None) is True: + is_spot = True + + return is_trading and is_spot + + +class WazirxConfigMap(BaseConnectorConfigMap): + connector: str = Field(default="wazirx", const=True, client_data=None) + wazirx_api_key: SecretStr = Field( + default=..., + client_data=ClientFieldData( + prompt=lambda cm: "Enter your Wazirx API key", + is_secure=True, + is_connect_key=True, + prompt_on_new=True, + ) + ) + wazirx_api_secret: SecretStr = Field( + default=..., + client_data=ClientFieldData( + prompt=lambda cm: "Enter your Wazirx API secret", + is_secure=True, + is_connect_key=True, + prompt_on_new=True, + ) + ) + + class Config: + title = "wazirx" + + +KEYS = WazirxConfigMap.construct() diff --git a/hummingbot/connector/exchange/wazirx/wazirx_web_utils.py b/hummingbot/connector/exchange/wazirx/wazirx_web_utils.py new file mode 100644 index 00000000000..2b8194584a2 --- /dev/null +++ b/hummingbot/connector/exchange/wazirx/wazirx_web_utils.py @@ -0,0 +1,75 @@ +from typing import Callable, Optional + +import hummingbot.connector.exchange.wazirx.wazirx_constants as CONSTANTS +from hummingbot.connector.time_synchronizer import TimeSynchronizer +from hummingbot.connector.utils import TimeSynchronizerRESTPreProcessor +from hummingbot.core.api_throttler.async_throttler import AsyncThrottler +from hummingbot.core.web_assistant.auth import AuthBase +from hummingbot.core.web_assistant.connections.data_types import RESTMethod +from hummingbot.core.web_assistant.web_assistants_factory import WebAssistantsFactory + + +def public_rest_url(path_url: str, domain: str = CONSTANTS.DEFAULT_DOMAIN) -> str: + """ + Creates a full URL for provided public REST endpoint + :param path_url: a public REST endpoint + :param domain: the Wazirx domain to connect to ("com" or "us"). The default value is "com" + :return: the full URL to the endpoint + """ + return CONSTANTS.REST_URL.format(domain) + CONSTANTS.PUBLIC_API_VERSION + path_url + + +def private_rest_url(path_url: str, domain: str = CONSTANTS.DEFAULT_DOMAIN) -> str: + """ + Creates a full URL for provided private REST endpoint + :param path_url: a private REST endpoint + :param domain: the Wazirx domain to connect to ("com" or "us"). The default value is "com" + :return: the full URL to the endpoint + """ + return CONSTANTS.REST_URL.format(domain) + CONSTANTS.PRIVATE_API_VERSION + path_url + + +def build_api_factory( + throttler: Optional[AsyncThrottler] = None, + time_synchronizer: Optional[TimeSynchronizer] = None, + domain: str = CONSTANTS.DEFAULT_DOMAIN, + time_provider: Optional[Callable] = None, + auth: Optional[AuthBase] = None, ) -> WebAssistantsFactory: + throttler = throttler or create_throttler() + time_synchronizer = time_synchronizer or TimeSynchronizer() + time_provider = time_provider or (lambda: get_current_server_time( + throttler=throttler, + domain=domain, + )) + api_factory = WebAssistantsFactory( + throttler=throttler, + auth=auth, + rest_pre_processors=[ + TimeSynchronizerRESTPreProcessor(synchronizer=time_synchronizer, time_provider=time_provider), + ]) + return api_factory + + +def build_api_factory_without_time_synchronizer_pre_processor(throttler: AsyncThrottler) -> WebAssistantsFactory: + api_factory = WebAssistantsFactory(throttler=throttler) + return api_factory + + +def create_throttler() -> AsyncThrottler: + return AsyncThrottler(CONSTANTS.RATE_LIMITS) + + +async def get_current_server_time( + throttler: Optional[AsyncThrottler] = None, + domain: str = CONSTANTS.DEFAULT_DOMAIN, +) -> float: + throttler = throttler or create_throttler() + api_factory = build_api_factory_without_time_synchronizer_pre_processor(throttler=throttler) + rest_assistant = await api_factory.get_rest_assistant() + response = await rest_assistant.execute_request( + url=public_rest_url(path_url=CONSTANTS.SERVER_TIME_PATH_URL, domain=domain), + method=RESTMethod.GET, + throttler_limit_id=CONSTANTS.SERVER_TIME_PATH_URL, + ) + server_time = response["serverTime"] + return server_time diff --git a/test/hummingbot/connector/exchange/wazirx/__init__.py b/test/hummingbot/connector/exchange/wazirx/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/test/hummingbot/connector/exchange/wazirx/test_wazirx_api_order_book_data_source.py b/test/hummingbot/connector/exchange/wazirx/test_wazirx_api_order_book_data_source.py new file mode 100644 index 00000000000..a146d6cb082 --- /dev/null +++ b/test/hummingbot/connector/exchange/wazirx/test_wazirx_api_order_book_data_source.py @@ -0,0 +1,429 @@ +import asyncio +import json +import re +import unittest +from typing import Awaitable +from unittest.mock import AsyncMock, MagicMock, patch + +from aioresponses.core import aioresponses +from bidict import bidict + +from hummingbot.client.config.client_config_map import ClientConfigMap +from hummingbot.client.config.config_helpers import ClientConfigAdapter +from hummingbot.connector.exchange.wazirx import wazirx_constants as CONSTANTS, wazirx_web_utils as web_utils +from hummingbot.connector.exchange.wazirx.wazirx_api_order_book_data_source import WazirxAPIOrderBookDataSource +from hummingbot.connector.exchange.wazirx.wazirx_exchange import WazirxExchange +from hummingbot.connector.test_support.network_mocking_assistant import NetworkMockingAssistant +from hummingbot.core.data_type.order_book import OrderBook +from hummingbot.core.data_type.order_book_message import OrderBookMessage + + +class WazirxAPIOrderBookDataSourceUnitTests(unittest.TestCase): + # logging.Level required to receive logs from the data source logger + level = 0 + + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + cls.ev_loop = asyncio.get_event_loop() + cls.base_asset = "COINALPHA" + cls.quote_asset = "HBOT" + cls.trading_pair = f"{cls.base_asset}-{cls.quote_asset}" + cls.ex_trading_pair = cls.base_asset + cls.quote_asset + cls.domain = "wazirx" + + def setUp(self) -> None: + super().setUp() + self.log_records = [] + self.listening_task = None + self.mocking_assistant = NetworkMockingAssistant() + + client_config_map = ClientConfigAdapter(ClientConfigMap()) + self.connector = WazirxExchange( + client_config_map=client_config_map, + wazirx_api_key="", + wazirx_api_secret="", + trading_pairs=[], + trading_required=False, + domain=self.domain) + self.data_source = WazirxAPIOrderBookDataSource(trading_pairs=[self.trading_pair], + connector=self.connector, + api_factory=self.connector._web_assistants_factory, + domain=self.domain) + self.data_source.logger().setLevel(1) + self.data_source.logger().addHandler(self) + + self._original_full_order_book_reset_time = self.data_source.FULL_ORDER_BOOK_RESET_DELTA_SECONDS + self.data_source.FULL_ORDER_BOOK_RESET_DELTA_SECONDS = -1 + + self.resume_test_event = asyncio.Event() + + self.connector._set_trading_pair_symbol_map(bidict({self.ex_trading_pair: self.trading_pair})) + + def tearDown(self) -> None: + self.listening_task and self.listening_task.cancel() + self.data_source.FULL_ORDER_BOOK_RESET_DELTA_SECONDS = self._original_full_order_book_reset_time + super().tearDown() + + def handle(self, record): + self.log_records.append(record) + + def _is_logged(self, log_level: str, message: str) -> bool: + return any(record.levelname == log_level and record.getMessage() == message + for record in self.log_records) + + def _create_exception_and_unlock_test_with_event(self, exception): + self.resume_test_event.set() + raise exception + + def async_run_with_timeout(self, coroutine: Awaitable, timeout: float = 1): + ret = self.ev_loop.run_until_complete(asyncio.wait_for(coroutine, timeout)) + return ret + + def _successfully_subscribed_event(self): + resp = { + "result": None, + "id": 1 + } + return resp + + def _trade_update_event(self): + resp = { + "data": + { + "trades": + [ + { + "E": 1631681323000, + "S": "buy", + "a": 26946138, + "b": 26946169, + "m": True, + "p": "7.0", + "q": "15.0", + "s": self.ex_trading_pair, + "t": 17376030 + } + ] + }, + "stream": f"{self.ex_trading_pair}@trades" + } + return resp + + def _order_diff_event(self): + resp = { + "data": + { + "E": 1631682370000, + "a": [ + [ + "10.0", + "75.0" + ], + ], + "b": [ + [ + "6.0", + "50.0" + ], + ], + "s": self.ex_trading_pair + }, + "stream": f"{self.ex_trading_pair}@depth" + } + return resp + + def _snapshot_response(self): + resp = { + "timestamp": 1588831243, + "asks": [ + [ + "9291.0", + "0.0119" + ] + ], + "bids": [ + [ + "9253.0", + "1.0456" + ] + ] + + } + return resp + + @aioresponses() + def test_get_new_order_book_successful(self, mock_api): + url = web_utils.public_rest_url(path_url=CONSTANTS.SNAPSHOT_PATH_URL, domain=self.domain) + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + + resp = self._snapshot_response() + + mock_api.get(regex_url, body=json.dumps(resp)) + + order_book: OrderBook = self.async_run_with_timeout( + self.data_source.get_new_order_book(self.trading_pair) + ) + + expected_update_id = resp["timestamp"] + + self.assertEqual(expected_update_id, order_book.snapshot_uid) + bids = list(order_book.bid_entries()) + asks = list(order_book.ask_entries()) + self.assertEqual(1, len(bids)) + self.assertEqual(9253.0, bids[0].price) + self.assertEqual(1.0456, bids[0].amount) + self.assertEqual(expected_update_id, bids[0].update_id) + self.assertEqual(1, len(asks)) + self.assertEqual(9291.0, asks[0].price) + self.assertEqual(0.0119, asks[0].amount) + self.assertEqual(expected_update_id, asks[0].update_id) + + @aioresponses() + def test_get_new_order_book_raises_exception(self, mock_api): + url = web_utils.public_rest_url(path_url=CONSTANTS.SNAPSHOT_PATH_URL, domain=self.domain) + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + + mock_api.get(regex_url, status=400) + with self.assertRaises(IOError): + self.async_run_with_timeout( + self.data_source.get_new_order_book(self.trading_pair) + ) + + @patch("aiohttp.ClientSession.ws_connect", new_callable=AsyncMock) + def test_listen_for_subscriptions_subscribes_to_trades_and_order_diffs(self, ws_connect_mock): + ws_connect_mock.return_value = self.mocking_assistant.create_websocket_mock() + + result_subscribe_trades = { + "code": 500, + "message": "Invalid request: could not parse message", "id": 0 + } + + result_subscribe_diffs = { + "code": 500, + "message": "Invalid request: could not parse message", "id": 0 + } + + self.mocking_assistant.add_websocket_aiohttp_message( + websocket_mock=ws_connect_mock.return_value, + message=json.dumps(result_subscribe_trades)) + self.mocking_assistant.add_websocket_aiohttp_message( + websocket_mock=ws_connect_mock.return_value, + message=json.dumps(result_subscribe_diffs)) + + self.listening_task = self.ev_loop.create_task(self.data_source.listen_for_subscriptions()) + + self.mocking_assistant.run_until_all_aiohttp_messages_delivered(ws_connect_mock.return_value) + + sent_subscription_messages = self.mocking_assistant.json_messages_sent_through_websocket( + websocket_mock=ws_connect_mock.return_value) + + self.assertEqual(2, len(sent_subscription_messages)) + expected_trade_subscription = { + "event": "subscribe", + "streams": [f"{self.ex_trading_pair.lower()}@trades"]} + self.assertEqual(expected_trade_subscription, sent_subscription_messages[0]) + expected_diff_subscription = { + "event": "subscribe", + "streams": [f"{self.ex_trading_pair.lower()}@depth10@100ms"]} + self.assertEqual(expected_diff_subscription, sent_subscription_messages[1]) + + self.assertTrue(self._is_logged( + "INFO", + "Subscribed to public order book and trade channels..." + )) + + @patch("hummingbot.core.data_type.order_book_tracker_data_source.OrderBookTrackerDataSource._sleep") + @patch("aiohttp.ClientSession.ws_connect") + def test_listen_for_subscriptions_raises_cancel_exception(self, mock_ws, _: AsyncMock): + mock_ws.side_effect = asyncio.CancelledError + + with self.assertRaises(asyncio.CancelledError): + self.listening_task = self.ev_loop.create_task(self.data_source.listen_for_subscriptions()) + self.async_run_with_timeout(self.listening_task) + + @patch("hummingbot.core.data_type.order_book_tracker_data_source.OrderBookTrackerDataSource._sleep") + @patch("aiohttp.ClientSession.ws_connect", new_callable=AsyncMock) + def test_listen_for_subscriptions_logs_exception_details(self, mock_ws, sleep_mock): + mock_ws.side_effect = Exception("TEST ERROR.") + sleep_mock.side_effect = lambda _: self._create_exception_and_unlock_test_with_event(asyncio.CancelledError()) + + self.listening_task = self.ev_loop.create_task(self.data_source.listen_for_subscriptions()) + + self.async_run_with_timeout(self.resume_test_event.wait()) + + self.assertTrue( + self._is_logged( + "ERROR", + "Unexpected error occurred when listening to order book streams. Retrying in 5 seconds...")) + + def test_subscribe_channels_raises_cancel_exception(self): + mock_ws = MagicMock() + mock_ws.send.side_effect = asyncio.CancelledError + + with self.assertRaises(asyncio.CancelledError): + self.listening_task = self.ev_loop.create_task(self.data_source._subscribe_channels(mock_ws)) + self.async_run_with_timeout(self.listening_task) + + def test_subscribe_channels_raises_exception_and_logs_error(self): + mock_ws = MagicMock() + mock_ws.send.side_effect = Exception("Test Error") + + with self.assertRaises(Exception): + self.listening_task = self.ev_loop.create_task(self.data_source._subscribe_channels(mock_ws)) + self.async_run_with_timeout(self.listening_task) + + self.assertTrue( + self._is_logged("ERROR", "Unexpected error occurred subscribing to order book trading and delta streams...") + ) + + def test_listen_for_trades_cancelled_when_listening(self): + mock_queue = MagicMock() + mock_queue.get.side_effect = asyncio.CancelledError() + self.data_source._message_queue[CONSTANTS.TRADE_EVENT_TYPE] = mock_queue + + msg_queue: asyncio.Queue = asyncio.Queue() + + with self.assertRaises(asyncio.CancelledError): + self.listening_task = self.ev_loop.create_task( + self.data_source.listen_for_trades(self.ev_loop, msg_queue) + ) + self.async_run_with_timeout(self.listening_task) + + def test_listen_for_trades_logs_exception(self): + incomplete_resp = { + "m": 1, + "i": 2, + } + + mock_queue = AsyncMock() + mock_queue.get.side_effect = [incomplete_resp, asyncio.CancelledError()] + self.data_source._message_queue[CONSTANTS.TRADE_EVENT_TYPE] = mock_queue + + msg_queue: asyncio.Queue = asyncio.Queue() + + self.listening_task = self.ev_loop.create_task( + self.data_source.listen_for_trades(self.ev_loop, msg_queue) + ) + + try: + self.async_run_with_timeout(self.listening_task) + except asyncio.CancelledError: + pass + + self.assertTrue( + self._is_logged("ERROR", "Unexpected error when processing public trade updates from exchange")) + + def test_listen_for_trades_successful(self): + mock_queue = AsyncMock() + mock_queue.get.side_effect = [self._trade_update_event(), asyncio.CancelledError()] + self.data_source._message_queue[CONSTANTS.TRADE_EVENT_TYPE] = mock_queue + + msg_queue: asyncio.Queue = asyncio.Queue() + + self.listening_task = self.ev_loop.create_task( + self.data_source.listen_for_trades(self.ev_loop, msg_queue)) + + msg: OrderBookMessage = self.async_run_with_timeout(msg_queue.get()) + + self.assertEqual(17376030, msg.trade_id) + + def test_listen_for_order_book_diffs_cancelled(self): + mock_queue = AsyncMock() + mock_queue.get.side_effect = asyncio.CancelledError() + self.data_source._message_queue[CONSTANTS.DIFF_EVENT_TYPE] = mock_queue + + msg_queue: asyncio.Queue = asyncio.Queue() + + with self.assertRaises(asyncio.CancelledError): + self.listening_task = self.ev_loop.create_task( + self.data_source.listen_for_order_book_diffs(self.ev_loop, msg_queue) + ) + self.async_run_with_timeout(self.listening_task) + + def test_listen_for_order_book_diffs_logs_exception(self): + incomplete_resp = { + "m": 1, + "i": 2, + } + + mock_queue = AsyncMock() + mock_queue.get.side_effect = [incomplete_resp, asyncio.CancelledError()] + self.data_source._message_queue[CONSTANTS.DIFF_EVENT_TYPE] = mock_queue + + msg_queue: asyncio.Queue = asyncio.Queue() + + self.listening_task = self.ev_loop.create_task( + self.data_source.listen_for_order_book_diffs(self.ev_loop, msg_queue) + ) + + try: + self.async_run_with_timeout(self.listening_task) + except asyncio.CancelledError: + pass + + self.assertTrue( + self._is_logged("ERROR", "Unexpected error when processing public order book updates from exchange")) + + def test_listen_for_order_book_diffs_successful(self): + mock_queue = AsyncMock() + diff_event = self._order_diff_event() + mock_queue.get.side_effect = [diff_event, asyncio.CancelledError()] + self.data_source._message_queue[CONSTANTS.DIFF_EVENT_TYPE] = mock_queue + + msg_queue: asyncio.Queue = asyncio.Queue() + + self.listening_task = self.ev_loop.create_task( + self.data_source.listen_for_order_book_diffs(self.ev_loop, msg_queue)) + + msg: OrderBookMessage = self.async_run_with_timeout(msg_queue.get()) + + self.assertEqual(diff_event["data"]["E"], msg.update_id) + + @aioresponses() + def test_listen_for_order_book_snapshots_cancelled_when_fetching_snapshot(self, mock_api): + url = web_utils.public_rest_url(path_url=CONSTANTS.SNAPSHOT_PATH_URL, domain=self.domain) + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + + mock_api.get(regex_url, exception=asyncio.CancelledError, repeat=True) + + with self.assertRaises(asyncio.CancelledError): + self.async_run_with_timeout( + self.data_source.listen_for_order_book_snapshots(self.ev_loop, asyncio.Queue()) + ) + + @aioresponses() + @patch("hummingbot.connector.exchange.wazirx.wazirx_api_order_book_data_source" + ".WazirxAPIOrderBookDataSource._sleep") + def test_listen_for_order_book_snapshots_log_exception(self, mock_api, sleep_mock): + msg_queue: asyncio.Queue = asyncio.Queue() + sleep_mock.side_effect = lambda _: self._create_exception_and_unlock_test_with_event(asyncio.CancelledError()) + + url = web_utils.public_rest_url(path_url=CONSTANTS.SNAPSHOT_PATH_URL, domain=self.domain) + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + + mock_api.get(regex_url, exception=Exception, repeat=True) + + self.listening_task = self.ev_loop.create_task( + self.data_source.listen_for_order_book_snapshots(self.ev_loop, msg_queue) + ) + self.async_run_with_timeout(self.resume_test_event.wait()) + + self.assertTrue( + self._is_logged("ERROR", f"Unexpected error fetching order book snapshot for {self.trading_pair}.")) + + @aioresponses() + def test_listen_for_order_book_snapshots_successful(self, mock_api, ): + msg_queue: asyncio.Queue = asyncio.Queue() + url = web_utils.public_rest_url(path_url=CONSTANTS.SNAPSHOT_PATH_URL, domain=self.domain) + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + + mock_api.get(regex_url, body=json.dumps(self._snapshot_response())) + + self.listening_task = self.ev_loop.create_task( + self.data_source.listen_for_order_book_snapshots(self.ev_loop, msg_queue) + ) + + msg: OrderBookMessage = self.async_run_with_timeout(msg_queue.get()) + + self.assertEqual(1588831243, msg.update_id) diff --git a/test/hummingbot/connector/exchange/wazirx/test_wazirx_auth.py b/test/hummingbot/connector/exchange/wazirx/test_wazirx_auth.py new file mode 100644 index 00000000000..ce7da8be672 --- /dev/null +++ b/test/hummingbot/connector/exchange/wazirx/test_wazirx_auth.py @@ -0,0 +1,50 @@ +import asyncio +import hashlib +import hmac +from copy import copy +from unittest import TestCase +from unittest.mock import MagicMock + +from typing_extensions import Awaitable + +from hummingbot.connector.exchange.wazirx.wazirx_auth import WazirxAuth +from hummingbot.core.web_assistant.connections.data_types import RESTMethod, RESTRequest + + +class WazirxAuthTests(TestCase): + + def setUp(self) -> None: + self._api_key = "testApiKey" + self._secret = "testSecret" + + def async_run_with_timeout(self, coroutine: Awaitable, timeout: float = 1): + ret = asyncio.get_event_loop().run_until_complete(asyncio.wait_for(coroutine, timeout)) + return ret + + def test_rest_authenticate(self): + now = 1234567890.000 + mock_time_provider = MagicMock() + mock_time_provider.time.return_value = now + + params = { + "symbol": "ltcbtc", + "side": "BUY", + "type": "limit", + "quantity": 1, + "price": "0.1", + } + full_params = copy(params) + + auth = WazirxAuth(api_key=self._api_key, secret_key=self._secret, time_provider=mock_time_provider) + request = RESTRequest(method=RESTMethod.GET, params=params, is_auth_required=True) + configured_request = self.async_run_with_timeout(auth.rest_authenticate(request)) + + full_params.update({"timestamp": 1234567890000}) + encoded_params = "&".join([f"{key}={value}" for key, value in full_params.items()]) + expected_signature = hmac.new( + self._secret.encode("utf-8"), + encoded_params.encode("utf-8"), + hashlib.sha256).hexdigest() + self.assertEqual(now * 1e3, configured_request.params["timestamp"]) + self.assertEqual(expected_signature, configured_request.params["signature"]) + self.assertEqual({"X-API-KEY": self._api_key}, configured_request.headers) diff --git a/test/hummingbot/connector/exchange/wazirx/test_wazirx_exchange.py b/test/hummingbot/connector/exchange/wazirx/test_wazirx_exchange.py new file mode 100644 index 00000000000..85602e774af --- /dev/null +++ b/test/hummingbot/connector/exchange/wazirx/test_wazirx_exchange.py @@ -0,0 +1,1157 @@ +import asyncio +import json +import re +from decimal import Decimal +from typing import Any, Callable, Dict, List, Optional, Tuple +from unittest.mock import AsyncMock, patch + +from aioresponses import aioresponses +from aioresponses.core import RequestCall + +from hummingbot.client.config.client_config_map import ClientConfigMap +from hummingbot.client.config.config_helpers import ClientConfigAdapter +from hummingbot.connector.exchange.wazirx import wazirx_constants as CONSTANTS, wazirx_web_utils as web_utils +from hummingbot.connector.exchange.wazirx.wazirx_exchange import WazirxExchange +from hummingbot.connector.test_support.exchange_connector_test import AbstractExchangeConnectorTests +from hummingbot.connector.trading_rule import TradingRule +from hummingbot.connector.utils import get_new_client_order_id +from hummingbot.core.data_type.common import OrderType, TradeType +from hummingbot.core.data_type.in_flight_order import InFlightOrder, OrderState +from hummingbot.core.data_type.trade_fee import DeductedFromReturnsTradeFee, TokenAmount, TradeFeeBase + +# from hummingbot.core.event.events import OrderFilledEvent +from hummingbot.core.event.events import MarketOrderFailureEvent + + +class WazirxExchangeTests(AbstractExchangeConnectorTests.ExchangeConnectorTests): + + @property + def all_symbols_url(self): + return web_utils.public_rest_url(path_url=CONSTANTS.EXCHANGE_INFO_PATH_URL, domain=self.exchange._domain) + + @property + def latest_prices_url(self): + url = web_utils.public_rest_url(path_url=CONSTANTS.TICKER_PRICE_CHANGE_PATH_URL, domain=self.exchange._domain) + url = f"{url}?symbol={self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset)}" + return url + + @property + def network_status_url(self): + url = web_utils.private_rest_url(CONSTANTS.PING_PATH_URL, domain=self.exchange._domain) + return url + + @property + def trading_rules_url(self): + url = web_utils.private_rest_url(CONSTANTS.EXCHANGE_INFO_PATH_URL, domain=self.exchange._domain) + return url + + @property + def order_creation_url(self): + url = web_utils.private_rest_url(CONSTANTS.ORDER_PATH_URL, domain=self.exchange._domain) + return url + + @property + def balance_url(self): + url = web_utils.private_rest_url(CONSTANTS.ACCOUNTS_PATH_URL, domain=self.exchange._domain) + return url + + @property + def all_symbols_request_mock_response(self): + return { + "symbols": [ + { + "symbol": '{}{}'.format(self.base_asset.lower(), self.quote_asset.lower()), + "status": "trading", + "baseAsset": self.base_asset.lower(), + "baseAssetPrecision": 8, + "quoteAsset": self.quote_asset.lower(), + "quotePrecision": 8, + "quoteAssetPrecision": 8, + "baseCommissionPrecision": 8, + "quoteCommissionPrecision": 8, + "orderTypes": [ + "limit", + "stop_limit", + "limit_maker" + ], + "isSpotTradingAllowed": True, + "filters": [], + }, + ], + "timezone": "UTC", + "serverTime": 1639598493658, + } + + @property + def latest_prices_request_mock_response(self): + return { + "symbol": self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), + "baseAsset": self.base_asset, + "quoteAsset": self.quote_asset, + "openPrice": "99.00000000", + "lowPrice": "0.10000000", + "highPrice": "100.00000000", + "lastPrice": str(self.expected_latest_price), + "volume": "8913.30000000", + "bidPrice": "100.00000000", + "askPrice": "4.00000200", + "at": 1588829734 + } + + @property + def all_symbols_including_invalid_pair_mock_response(self) -> Tuple[str, Any]: + response = { + "symbols": [ + { + "symbol": self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), + "status": "trading", + "baseAsset": self.base_asset, + "baseAssetPrecision": 8, + "quoteAsset": self.quote_asset, + "quotePrecision": 8, + "quoteAssetPrecision": 8, + "baseCommissionPrecision": 8, + "quoteCommissionPrecision": 8, + "orderTypes": [ + "limit", + "stop_limit", + "limit_maker" + ], + "isSpotTradingAllowed": True, + "filters": [] + }, + { + "symbol": self.exchange_symbol_for_tokens("invalid", "pair"), + "status": "trading", + "baseAsset": "invalid", + "baseAssetPrecision": 8, + "quoteAsset": "pair", + "quotePrecision": 8, + "quoteAssetPrecision": 8, + "baseCommissionPrecision": 8, + "quoteCommissionPrecision": 8, + "orderTypes": [ + "limit", + "stop_limit", + "limit_maker" + ], + "isSpotTradingAllowed": True, + "filters": [] + }, + ], + "timezone": "UTC", + "serverTime": 1639598493658, + } + + return "invalid-pair", response + + @property + def network_status_request_successful_mock_response(self): + return {} + + @property + def trading_rules_request_mock_response(self): + return { + "symbols": [ + { + "symbol": self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), + "status": "trading", + "baseAsset": self.base_asset, + "baseAssetPrecision": 8, + "quoteAsset": self.quote_asset, + "quotePrecision": 8, + "quoteAssetPrecision": 8, + "orderTypes": [ + "limit", + "stop_limit", + "limit_maker" + ], + "isSpotTradingAllowed": True, + "filters": [ + { + "filterType": "PRICE_FILTER", + "minPrice": "0.00000100", + "maxPrice": "100000.00000000", + "tickSize": "0.00000100" + } + ] + } + ], + "timezone": "UTC", + "serverTime": 1565246363776 + } + + @property + def trading_rules_request_erroneous_mock_response(self): + return { + "symbols": [ + { + "symbol": self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), + "status": "trading", + "baseAsset": self.base_asset, + "baseAssetPrecision": 8, + "quoteAsset": self.quote_asset, + "quotePrecision": 8, + "quoteAssetPrecision": 8, + "orderTypes": [ + "limit", + "stop_limit", + "limit_maker" + ], + "isSpotTradingAllowed": True + } + ], + "timezone": "UTC", + "serverTime": 1565246363776, + } + + @property + def order_creation_request_successful_mock_response(self): + return { + "id": self.expected_exchange_order_id, + "clientOrderId": "OID1", + "symbol": self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), + "price": "9293.0", + "origQty": "99.0", + "executedQty": "8.2", + "status": "wait", + "type": "limit", + "side": "sell", + "createdTime": 1499827319559, + "updatedTime": 1499827319559 + } + + @property + def balance_request_mock_response_for_base_and_quote(self): + return [ + { + "asset": self.base_asset, + "free": "10.0", + "locked": "5.0" + }, + { + "asset": self.quote_asset, + "free": "2000", + "locked": "0.00000000" + } + ] + + @property + def balance_request_mock_response_only_base(self): + return [ + { + "asset": self.base_asset, + "free": "10.0", + "locked": "5.0" + } + ] + + @property + def balance_event_websocket_update(self): + return { + "data": + { + "B": [{"a": self.base_asset, "b": "10.0", "l": "5.0"}], + "E": 1631683058909 + }, + "stream": "outboundAccountPosition" + } + + @property + def expected_latest_price(self): + return 9999.9 + + @property + def expected_supported_order_types(self): + return [OrderType.LIMIT, OrderType.LIMIT_MAKER] + + @property + def expected_trading_rule(self): + return TradingRule( + trading_pair=self.trading_pair, + min_order_size=Decimal(0.0001), + min_price_increment=Decimal( + self.trading_rules_request_mock_response["symbols"][0]["filters"][0]["minPrice"]), + min_base_amount_increment=Decimal( + self.trading_rules_request_mock_response["symbols"][0]["filters"][0]["tickSize"]) + ) + + @property + def expected_logged_error_for_erroneous_trading_rule(self): + erroneous_rule = self.trading_rules_request_erroneous_mock_response["symbols"][0] + return f"Error parsing the trading pair rule {erroneous_rule}. Skipping." + + @property + def expected_exchange_order_id(self): + return 28 + + @property + def is_order_fill_http_update_included_in_status_update(self) -> bool: + return True + + @property + def is_order_fill_http_update_executed_during_websocket_order_event_processing(self) -> bool: + return False + + @property + def expected_partial_fill_price(self) -> Decimal: + return Decimal(10500) + + @property + def expected_partial_fill_amount(self) -> Decimal: + return Decimal("0.5") + + @property + def expected_fill_fee(self) -> TradeFeeBase: + return DeductedFromReturnsTradeFee( + percent_token=self.quote_asset, + flat_fees=[TokenAmount(token=self.quote_asset, amount=Decimal("30"))]) + + @property + def expected_fill_trade_id(self) -> str: + return str(30000) + + def exchange_symbol_for_tokens(self, base_token: str, quote_token: str) -> str: + return f"{base_token}{quote_token}" + + def create_exchange_instance(self): + client_config_map = ClientConfigAdapter(ClientConfigMap()) + return WazirxExchange( + client_config_map=client_config_map, + wazirx_api_key="testAPIKey", + wazirx_api_secret="testSecret", + trading_pairs=[self.trading_pair], + ) + + def validate_auth_credentials_present(self, request_call: RequestCall): + self._validate_auth_credentials_taking_parameters_from_argument( + request_call_tuple=request_call, + params=request_call.kwargs["params"] or request_call.kwargs["data"] + ) + + def validate_order_creation_request(self, order: InFlightOrder, request_call: RequestCall): + request_data = dict(request_call.kwargs["data"]) + self.assertEqual(self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), request_data["symbol"]) + self.assertEqual(order.trade_type.name.lower(), request_data["side"]) + self.assertEqual(WazirxExchange.wazirx_order_type(OrderType.LIMIT), request_data["type"]) + self.assertEqual(Decimal("100"), Decimal(request_data["quantity"])) + self.assertEqual(Decimal("10000"), Decimal(request_data["price"])) + self.assertEqual(order.client_order_id, request_data["clientOrderId"]) + + def validate_order_cancelation_request(self, order: InFlightOrder, request_call: RequestCall): + request_data = dict(request_call.kwargs["params"]) + self.assertEqual(self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), + request_data["symbol"]) + self.assertEqual(order.client_order_id, request_data["clientOrderId"]) + + def validate_order_status_request(self, order: InFlightOrder, request_call: RequestCall): + request_params = request_call.kwargs["params"] + self.assertEqual(order.client_order_id, request_params["clientOrderId"]) + + def validate_trades_request(self, order: InFlightOrder, request_call: RequestCall): + request_params = request_call.kwargs["params"] + self.assertEqual(self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), + request_params["symbol"]) + self.assertEqual(order.exchange_order_id, str(request_params["orderId"])) + + def configure_successful_cancelation_response( + self, + order: InFlightOrder, + mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None) -> str: + url = web_utils.private_rest_url(CONSTANTS.ORDER_PATH_URL) + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + response = self._order_cancelation_request_successful_mock_response(order=order) + mock_api.delete(regex_url, body=json.dumps(response), callback=callback) + return url + + def configure_erroneous_cancelation_response( + self, + order: InFlightOrder, + mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None) -> str: + url = web_utils.private_rest_url(CONSTANTS.ORDER_PATH_URL) + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + mock_api.delete(regex_url, status=400, callback=callback) + return url + + def configure_order_not_found_error_cancelation_response( + self, order: InFlightOrder, mock_api: aioresponses, callback: Optional[Callable] = lambda *args, **kwargs: None + ) -> str: + url = web_utils.private_rest_url(CONSTANTS.ORDER_PATH_URL) + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + response = {"code": -2011, "msg": "Unknown order sent."} + mock_api.delete(regex_url, status=400, body=json.dumps(response), callback=callback) + return url + + def configure_one_successful_one_erroneous_cancel_all_response( + self, + successful_order: InFlightOrder, + erroneous_order: InFlightOrder, + mock_api: aioresponses) -> List[str]: + """ + :return: a list of all configured URLs for the cancelations + """ + all_urls = [] + url = self.configure_successful_cancelation_response(order=successful_order, mock_api=mock_api) + all_urls.append(url) + url = self.configure_erroneous_cancelation_response(order=erroneous_order, mock_api=mock_api) + all_urls.append(url) + return all_urls + + def configure_completely_filled_order_status_response( + self, + order: InFlightOrder, + mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None) -> str: + url = web_utils.private_rest_url(CONSTANTS.ORDER_PATH_URL) + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + response = self._order_status_request_completely_filled_mock_response(order=order) + mock_api.get(regex_url, body=json.dumps(response), callback=callback) + return url + + def configure_canceled_order_status_response( + self, + order: InFlightOrder, + mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None) -> str: + url = web_utils.private_rest_url(CONSTANTS.ORDER_PATH_URL) + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + response = self._order_status_request_canceled_mock_response(order=order) + mock_api.get(regex_url, body=json.dumps(response), callback=callback) + return url + + def configure_erroneous_http_fill_trade_response( + self, + order: InFlightOrder, + mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None) -> str: + url = web_utils.private_rest_url(path_url=CONSTANTS.MY_TRADES_PATH_URL) + regex_url = re.compile(url + r"\?.*") + mock_api.get(regex_url, status=400, callback=callback) + return url + + def configure_open_order_status_response( + self, + order: InFlightOrder, + mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None) -> str: + """ + :return: the URL configured + """ + url = web_utils.private_rest_url(CONSTANTS.ORDER_PATH_URL) + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + response = self._order_status_request_open_mock_response(order=order) + mock_api.get(regex_url, body=json.dumps(response), callback=callback) + return url + + def configure_http_error_order_status_response( + self, + order: InFlightOrder, + mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None) -> str: + url = web_utils.private_rest_url(CONSTANTS.ORDER_PATH_URL) + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + mock_api.get(regex_url, status=401, callback=callback) + return url + + def configure_partially_filled_order_status_response( + self, + order: InFlightOrder, + mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None) -> str: + url = web_utils.private_rest_url(CONSTANTS.ORDER_PATH_URL) + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + response = self._order_status_request_partially_filled_mock_response(order=order) + mock_api.get(regex_url, body=json.dumps(response), callback=callback) + return url + + def configure_order_not_found_error_order_status_response( + self, order: InFlightOrder, mock_api: aioresponses, callback: Optional[Callable] = lambda *args, **kwargs: None + ) -> List[str]: + url = web_utils.private_rest_url(CONSTANTS.ORDER_PATH_URL) + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + response = {"code": -2013, "msg": "Order does not exist."} + mock_api.get(regex_url, body=json.dumps(response), status=400, callback=callback) + return [url] + + def configure_partial_fill_trade_response( + self, + order: InFlightOrder, + mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None) -> str: + url = web_utils.private_rest_url(path_url=CONSTANTS.MY_TRADES_PATH_URL) + regex_url = re.compile(url + r"\?.*") + response = self._order_fills_request_partial_fill_mock_response(order=order) + mock_api.get(regex_url, body=json.dumps(response), callback=callback) + return url + + def configure_full_fill_trade_response( + self, + order: InFlightOrder, + mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None) -> str: + url = web_utils.private_rest_url(path_url=CONSTANTS.MY_TRADES_PATH_URL) + regex_url = re.compile(url + r"\?.*") + response = self._order_fills_request_full_fill_mock_response(order=order) + mock_api.get(regex_url, body=json.dumps(response), callback=callback) + return url + + def order_event_for_new_order_websocket_update(self, order: InFlightOrder): + return { + "data": + { + "E": 1499405658658, + "O": 1499405658658, + "S": order.order_type.name.lower(), + "V": "0.00000000", + "X": "wait", + "i": order.exchange_order_id, + "c": order.client_order_id, + "m": True, + "o": order.trade_type.name.lower(), + "p": str(order.price), + "q": str(order.amount), + "s": self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), + "v": "0.00000000", + "z": "0.00000000" + }, + "stream": "orderUpdate" + } + + def order_event_for_canceled_order_websocket_update(self, order: InFlightOrder): + return { + "data": + { + "E": 1499405658658, + "O": 1499405658658, + "S": order.order_type.name.lower(), + "V": "0.00000000", + "X": "cancel", + "i": order.exchange_order_id, + "c": order.client_order_id, + "m": True, + "o": order.trade_type.name.lower(), + "p": str(order.price), + "q": str(order.amount), + "s": self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), + "v": "0.00000000", + "z": "10000.00000000" + }, + "stream": "orderUpdate" + } + + def order_event_for_full_fill_websocket_update(self, order: InFlightOrder): + return { + "data": + { + "E": 1499405658658, + "O": 1499405658658, + "S": order.order_type.name.lower(), + "V": "10050.00000000", + "X": "done", + "i": order.exchange_order_id, + "c": order.client_order_id, + "m": True, + "o": order.trade_type.name.lower(), + "p": str(order.price), + "q": str(order.amount), + "s": self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), + "v": "10000.00000000", + "z": "10000.00000000" + }, + "stream": "orderUpdate" + } + + def trade_event_for_full_fill_websocket_update(self, order: InFlightOrder): + return { + "data": + { + "E": 1499405658658, + "S": order.order_type.name.lower(), + "U": self.expected_fill_fee.flat_fees[0].token, + "a": 114144050, + "b": 114144121, + "f": str(self.expected_fill_fee.flat_fees[0].amount), + "m": True, + "o": order.exchange_order_id, + "c": order.client_order_id, + "p": str(order.price), + "q": str(order.amount), + "s": self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), + "t": 1, + "w": "10000.0", + }, + "stream": "ownTrade" + } + + @aioresponses() + @patch("hummingbot.connector.time_synchronizer.TimeSynchronizer._current_seconds_counter") + def test_update_time_synchronizer_successfully(self, mock_api, seconds_counter_mock): + request_sent_event = asyncio.Event() + seconds_counter_mock.side_effect = [0, 0, 0] + + self.exchange._time_synchronizer.clear_time_offset_ms_samples() + url = web_utils.private_rest_url(CONSTANTS.SERVER_TIME_PATH_URL) + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + + response = {"serverTime": 1640000003000} + + mock_api.get(regex_url, + body=json.dumps(response), + callback=lambda *args, **kwargs: request_sent_event.set()) + + self.async_run_with_timeout(self.exchange._update_time_synchronizer()) + + self.assertEqual(response["serverTime"] * 1e-3, self.exchange._time_synchronizer.time()) + + @aioresponses() + def test_update_time_synchronizer_failure_is_logged(self, mock_api): + request_sent_event = asyncio.Event() + + url = web_utils.private_rest_url(CONSTANTS.SERVER_TIME_PATH_URL) + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + + response = {"code": -1121, "msg": "Dummy error"} + + mock_api.get(regex_url, + body=json.dumps(response), + callback=lambda *args, **kwargs: request_sent_event.set()) + + self.async_run_with_timeout(self.exchange._update_time_synchronizer()) + + self.assertTrue(self.is_logged("NETWORK", "Error getting server time.")) + + @aioresponses() + def test_update_time_synchronizer_raises_cancelled_error(self, mock_api): + url = web_utils.private_rest_url(CONSTANTS.SERVER_TIME_PATH_URL) + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + + mock_api.get(regex_url, + exception=asyncio.CancelledError) + + self.assertRaises( + asyncio.CancelledError, + self.async_run_with_timeout, self.exchange._update_time_synchronizer()) + + # @aioresponses() + # def test_update_order_fills_from_trades_triggers_filled_event(self, mock_api): + # self.exchange._set_current_timestamp(1640780000) + # self.exchange._last_poll_timestamp = (self.exchange.current_timestamp - + # self.exchange.UPDATE_ORDER_STATUS_MIN_INTERVAL - 1) + + # self.exchange.start_tracking_order( + # order_id="OID1", + # exchange_order_id="100234", + # trading_pair=self.trading_pair, + # order_type=OrderType.LIMIT, + # trade_type=TradeType.BUY, + # price=Decimal("10000"), + # amount=Decimal("1"), + # ) + # order = self.exchange.in_flight_orders["OID1"] + + # url = web_utils.private_rest_url(CONSTANTS.MY_TRADES_PATH_URL) + # regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + + # trade_fill = { + # "id": 28457, + # "symbol": self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), + # "fee": "10.10000000", + # "feeCurrency": self.quote_asset, + # "quoteQty": "48.000012", + # "price": "9999", + # "qty": "1", + # "orderId": int(order.exchange_order_id), + # "side": "buy", + # "isBuyerMaker": True, + # "time": 1499865549590 + # } + + # trade_fill_non_tracked_order = { + # "id": 30000, + # "symbol": self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), + # "fee": "10.10000000", + # "feeCurrency": "inr", + # "quoteQty": "48.000012", + # "price": "4.00000100", + # "qty": "12.00000000", + # "orderId": 99999, + # "side": "buy", + # "isBuyerMaker": True, + # "time": 1499865549590 + # } + + # mock_response = [trade_fill, trade_fill_non_tracked_order] + # mock_api.get(regex_url, body=json.dumps(mock_response)) + + # self.exchange.add_exchange_order_ids_from_market_recorder( + # {str(trade_fill_non_tracked_order["orderId"]): "OID99"}) + + # self.async_run_with_timeout(self.exchange._update_order_fills_from_trades()) + + # request = self._all_executed_requests(mock_api, url)[0] + # self.validate_auth_credentials_present(request) + # request_params = request.kwargs["params"] + # self.assertEqual(self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), request_params["symbol"]) + + # fill_event: OrderFilledEvent = self.order_filled_logger.event_log[0] + # self.assertEqual(self.exchange.current_timestamp, fill_event.timestamp) + # self.assertEqual(order.client_order_id, fill_event.order_id) + # self.assertEqual(order.trading_pair, fill_event.trading_pair) + # self.assertEqual(order.trade_type, fill_event.trade_type) + # self.assertEqual(order.order_type, fill_event.order_type) + # self.assertEqual(Decimal(trade_fill["price"]), fill_event.price) + # self.assertEqual(Decimal(trade_fill["qty"]), fill_event.amount) + # self.assertEqual(0.0, fill_event.trade_fee.percent) + # self.assertEqual([TokenAmount(trade_fill["feeCurrency"], Decimal(trade_fill["fee"]))], + # fill_event.trade_fee.flat_fees) + + # fill_event: OrderFilledEvent = self.order_filled_logger.event_log[1] + # self.assertEqual(float(trade_fill_non_tracked_order["time"]) * 1e-3, fill_event.timestamp) + # self.assertEqual("OID99", fill_event.order_id) + # self.assertEqual(self.trading_pair, fill_event.trading_pair) + # self.assertEqual(TradeType.BUY, fill_event.trade_type) + # self.assertEqual(OrderType.LIMIT, fill_event.order_type) + # self.assertEqual(Decimal(trade_fill_non_tracked_order["price"]), fill_event.price) + # self.assertEqual(Decimal(trade_fill_non_tracked_order["qty"]), fill_event.amount) + # self.assertEqual(0.0, fill_event.trade_fee.percent) + # self.assertEqual([ + # TokenAmount( + # trade_fill_non_tracked_order["feeCurrency"], + # Decimal(trade_fill_non_tracked_order["fee"]))], + # fill_event.trade_fee.flat_fees) + # self.assertTrue(self.is_logged( + # "INFO", + # f"Recreating missing trade in TradeFill: {trade_fill_non_tracked_order}" + # )) + + # @aioresponses() + # def test_update_order_fills_request_parameters(self, mock_api): + # self.exchange._set_current_timestamp(0) + # self.exchange._last_poll_timestamp = -1 + + # url = web_utils.private_rest_url(CONSTANTS.MY_TRADES_PATH_URL) + # regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + + # mock_response = [] + # mock_api.get(regex_url, body=json.dumps(mock_response)) + + # self.async_run_with_timeout(self.exchange._update_order_fills_from_trades()) + + # request = self._all_executed_requests(mock_api, url)[0] + # self.validate_auth_credentials_present(request) + # request_params = request.kwargs["params"] + # self.assertEqual(self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), request_params["symbol"]) + # self.assertNotIn("startTime", request_params) + + # self.exchange._set_current_timestamp(1640780000) + # self.exchange._last_poll_timestamp = (self.exchange.current_timestamp - + # self.exchange.UPDATE_ORDER_STATUS_MIN_INTERVAL - 1) + # self.exchange._last_trades_poll_wazirx_timestamp = 10 + # self.async_run_with_timeout(self.exchange._update_order_fills_from_trades()) + + # request = self._all_executed_requests(mock_api, url)[1] + # self.validate_auth_credentials_present(request) + # request_params = request.kwargs["params"] + # self.assertEqual(self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), request_params["symbol"]) + # self.assertEqual(10 * 1e3, request_params["startTime"]) + + # @aioresponses() + # def test_update_order_fills_from_trades_with_repeated_fill_triggers_only_one_event(self, mock_api): + # self.exchange._set_current_timestamp(1640780000) + # self.exchange._last_poll_timestamp = (self.exchange.current_timestamp - + # self.exchange.UPDATE_ORDER_STATUS_MIN_INTERVAL - 1) + + # url = web_utils.private_rest_url(CONSTANTS.MY_TRADES_PATH_URL) + # regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + + # trade_fill_non_tracked_order = { + # "id": 30000, + # "symbol": self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), + # "fee": "10.10000000", + # "feeCurrency": "inr", + # "quoteQty": "48.000012", + # "price": "4.00000100", + # "qty": "12.00000000", + # "orderId": 99999, + # "side": "buy", + # "isBuyerMaker": True, + # "time": 1499865549590 + # } + + # mock_response = [trade_fill_non_tracked_order, trade_fill_non_tracked_order] + # mock_api.get(regex_url, body=json.dumps(mock_response)) + + # self.exchange.add_exchange_order_ids_from_market_recorder( + # {str(trade_fill_non_tracked_order["orderId"]): "OID99"}) + + # self.async_run_with_timeout(self.exchange._update_order_fills_from_trades()) + + # request = self._all_executed_requests(mock_api, url)[0] + # self.validate_auth_credentials_present(request) + # request_params = request.kwargs["params"] + # self.assertEqual(self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), request_params["symbol"]) + + # self.assertEqual(1, len(self.order_filled_logger.event_log)) + # fill_event: OrderFilledEvent = self.order_filled_logger.event_log[0] + # self.assertEqual(float(trade_fill_non_tracked_order["time"]) * 1e-3, fill_event.timestamp) + # self.assertEqual("OID99", fill_event.order_id) + # self.assertEqual(self.trading_pair, fill_event.trading_pair) + # self.assertEqual(TradeType.BUY, fill_event.trade_type) + # self.assertEqual(OrderType.LIMIT, fill_event.order_type) + # self.assertEqual(Decimal(trade_fill_non_tracked_order["price"]), fill_event.price) + # self.assertEqual(Decimal(trade_fill_non_tracked_order["qty"]), fill_event.amount) + # self.assertEqual(0.0, fill_event.trade_fee.percent) + # self.assertEqual([ + # TokenAmount(trade_fill_non_tracked_order["feeCurrency"], + # Decimal(trade_fill_non_tracked_order["fee"]))], + # fill_event.trade_fee.flat_fees) + # self.assertTrue(self.is_logged( + # "INFO", + # f"Recreating missing trade in TradeFill: {trade_fill_non_tracked_order}" + # )) + + @aioresponses() + def test_update_order_status_when_failed(self, mock_api): + self.exchange._set_current_timestamp(1640780000) + self.exchange._last_poll_timestamp = (self.exchange.current_timestamp - + self.exchange.UPDATE_ORDER_STATUS_MIN_INTERVAL - 1) + + self.exchange.start_tracking_order( + order_id="OID1", + exchange_order_id="100234", + trading_pair=self.trading_pair, + order_type=OrderType.LIMIT, + trade_type=TradeType.BUY, + price=Decimal("10000"), + amount=Decimal("1"), + ) + order = self.exchange.in_flight_orders["OID1"] + + url = web_utils.private_rest_url(CONSTANTS.ORDER_PATH_URL) + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + + order_status = { + "id": int(order.exchange_order_id), + "clientOrderId": order.client_order_id, + "symbol": self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), + "price": "10000.0", + "stopPrice": "0.0", + "origQty": "1.0", + "executedQty": "0.0", + "status": "failed", + "type": "limit", + "side": "buy", + "createdTime": 1499827319559, + "updatedTime": 1499827319559 + } + + mock_response = order_status + mock_api.get(regex_url, body=json.dumps(mock_response)) + + self.async_run_with_timeout(self.exchange._update_order_status()) + + request = self._all_executed_requests(mock_api, url)[0] + self.validate_auth_credentials_present(request) + request_params = request.kwargs["params"] + self.assertEqual(order.client_order_id, request_params["clientOrderId"]) + + failure_event: MarketOrderFailureEvent = self.order_failure_logger.event_log[0] + self.assertEqual(self.exchange.current_timestamp, failure_event.timestamp) + self.assertEqual(order.client_order_id, failure_event.order_id) + self.assertEqual(order.order_type, failure_event.order_type) + self.assertNotIn(order.client_order_id, self.exchange.in_flight_orders) + self.assertTrue( + self.is_logged( + "INFO", + f"Order {order.client_order_id} has failed. Order Update: OrderUpdate(trading_pair='{self.trading_pair}'," + f" update_timestamp={order_status['updatedTime'] * 1e-3}, new_state={repr(OrderState.FAILED)}, " + f"client_order_id='{order.client_order_id}', exchange_order_id='{order.exchange_order_id}', " + "misc_updates=None)") + ) + + def test_user_stream_update_for_order_failure(self): + self.exchange._set_current_timestamp(1640780000) + self.exchange.start_tracking_order( + order_id="OID1", + exchange_order_id="100234", + trading_pair=self.trading_pair, + order_type=OrderType.LIMIT, + trade_type=TradeType.BUY, + price=Decimal("10000"), + amount=Decimal("1"), + ) + order = self.exchange.in_flight_orders["OID1"] + + event_message = { + "data": + { + "E": 1499405658658, + "O": 1499405658658, + "S": "buy", + "V": "0.00000000", + "X": "failed", + "i": int(order.exchange_order_id), + "c": order.client_order_id, + "m": True, + "o": "limit", + "p": "1000.00000000", + "q": "1.00000000", + "s": self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), + "v": "0.0", + "z": "0.0", + }, + "stream": "orderUpdate" + } + + mock_queue = AsyncMock() + mock_queue.get.side_effect = [event_message, asyncio.CancelledError] + self.exchange._user_stream_tracker._user_stream = mock_queue + + try: + self.async_run_with_timeout(self.exchange._user_stream_event_listener()) + except asyncio.CancelledError: + pass + + failure_event: MarketOrderFailureEvent = self.order_failure_logger.event_log[0] + self.assertEqual(self.exchange.current_timestamp, failure_event.timestamp) + self.assertEqual(order.client_order_id, failure_event.order_id) + self.assertEqual(order.order_type, failure_event.order_type) + self.assertNotIn(order.client_order_id, self.exchange.in_flight_orders) + self.assertTrue(order.is_failure) + self.assertTrue(order.is_done) + + @patch("hummingbot.connector.utils.get_tracking_nonce") + def test_client_order_id_on_order(self, mocked_nonce): + mocked_nonce.return_value = 7 + + result = self.exchange.buy( + trading_pair=self.trading_pair, + amount=Decimal("1"), + order_type=OrderType.LIMIT, + price=Decimal("2"), + ) + expected_client_order_id = get_new_client_order_id( + is_buy=True, + trading_pair=self.trading_pair, + hbot_order_id_prefix=CONSTANTS.HBOT_ORDER_ID_PREFIX, + max_id_len=CONSTANTS.MAX_ORDER_ID_LEN, + ) + + self.assertEqual(result, expected_client_order_id) + + result = self.exchange.sell( + trading_pair=self.trading_pair, + amount=Decimal("1"), + order_type=OrderType.LIMIT, + price=Decimal("2"), + ) + expected_client_order_id = get_new_client_order_id( + is_buy=False, + trading_pair=self.trading_pair, + hbot_order_id_prefix=CONSTANTS.HBOT_ORDER_ID_PREFIX, + max_id_len=CONSTANTS.MAX_ORDER_ID_LEN, + ) + + self.assertEqual(result, expected_client_order_id) + + def test_time_synchronizer_related_request_error_detection(self): + exception = IOError("Error executing request POST https://api.wazirx.com/api/v3/order. HTTP status is 400. " + "Error: {'code':-1021,'msg':'Timestamp for this request is outside of the recvWindow.'}") + self.assertTrue(self.exchange._is_request_exception_related_to_time_synchronizer(exception)) + + exception = IOError("Error executing request POST https://api.wazirx.com/api/v3/order. HTTP status is 400. " + "Error: {'code':-1021,'msg':'Timestamp for this request was 1000ms ahead of the server's " + "time.'}") + self.assertTrue(self.exchange._is_request_exception_related_to_time_synchronizer(exception)) + + exception = IOError("Error executing request POST https://api.wazirx.com/api/v3/order. HTTP status is 400. " + "Error: {'code':-1022,'msg':'Timestamp for this request was 1000ms ahead of the server's " + "time.'}") + self.assertFalse(self.exchange._is_request_exception_related_to_time_synchronizer(exception)) + + exception = IOError("Error executing request POST https://api.wazirx.com/api/v3/order. HTTP status is 400. " + "Error: {'code':-1021,'msg':'Other error.'}") + self.assertFalse(self.exchange._is_request_exception_related_to_time_synchronizer(exception)) + + @aioresponses() + def test_place_order_manage_server_overloaded_error_unkown_order(self, mock_api): + self.exchange._set_current_timestamp(1640780000) + self.exchange._last_poll_timestamp = (self.exchange.current_timestamp - + self.exchange.UPDATE_ORDER_STATUS_MIN_INTERVAL - 1) + url = web_utils.private_rest_url(CONSTANTS.ORDER_PATH_URL) + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + mock_response = {"code": -1003, "msg": "Unknown error, please check your request or try again later."} + mock_api.post(regex_url, body=json.dumps(mock_response), status=503) + + o_id, transact_time = self.async_run_with_timeout(self.exchange._place_order( + order_id="test_order_id", + trading_pair=self.trading_pair, + amount=Decimal("1"), + trade_type=TradeType.BUY, + order_type=OrderType.LIMIT, + price=Decimal("2"), + )) + self.assertEqual(o_id, "UNKNOWN") + + @aioresponses() + def test_place_order_manage_server_overloaded_error_failure(self, mock_api): + self.exchange._set_current_timestamp(1640780000) + self.exchange._last_poll_timestamp = (self.exchange.current_timestamp - + self.exchange.UPDATE_ORDER_STATUS_MIN_INTERVAL - 1) + + url = web_utils.private_rest_url(CONSTANTS.ORDER_PATH_URL) + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + mock_response = {"code": -1003, "msg": "Service Unavailable."} + mock_api.post(regex_url, body=json.dumps(mock_response), status=503) + + self.assertRaises( + IOError, + self.async_run_with_timeout, + self.exchange._place_order( + order_id="test_order_id", + trading_pair=self.trading_pair, + amount=Decimal("1"), + trade_type=TradeType.BUY, + order_type=OrderType.LIMIT, + price=Decimal("2"), + )) + + mock_response = {"code": -1003, "msg": "Internal error; unable to process your request. Please try again."} + mock_api.post(regex_url, body=json.dumps(mock_response), status=503) + + self.assertRaises( + IOError, + self.async_run_with_timeout, + self.exchange._place_order( + order_id="test_order_id", + trading_pair=self.trading_pair, + amount=Decimal("1"), + trade_type=TradeType.BUY, + order_type=OrderType.LIMIT, + price=Decimal("2"), + )) + + def _validate_auth_credentials_taking_parameters_from_argument(self, + request_call_tuple: RequestCall, + params: Dict[str, Any]): + self.assertIn("timestamp", params) + self.assertIn("signature", params) + request_headers = request_call_tuple.kwargs["headers"] + self.assertIn("X-API-KEY", request_headers) + self.assertEqual("testAPIKey", request_headers["X-API-KEY"]) + + def _order_cancelation_request_successful_mock_response(self, order: InFlightOrder) -> Any: + return { + "id": 4, + "clientOrderId": order.client_order_id, + "symbol": self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), + "price": str(order.price), + "origQty": str(order.amount), + "executedQty": str(Decimal("0")), + "status": "wait", + "type": "limit", + "side": "buy", + "createdTime": 1499827319559, + "updatedTime": 1499827319559 + } + + def _order_status_request_completely_filled_mock_response(self, order: InFlightOrder) -> Any: + return { + "id": order.exchange_order_id, + "clientOrderId": order.client_order_id, + "symbol": self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), + "price": str(order.price), + "stopPrice": "0.0", + "origQty": str(order.amount), + "executedQty": str(order.amount), + "status": "done", + "type": "limit", + "side": "buy", + "createdTime": 1499827319559, + "updatedTime": 1499827319559 + } + + def _order_status_request_canceled_mock_response(self, order: InFlightOrder) -> Any: + return { + "id": order.exchange_order_id, + "clientOrderId": order.client_order_id, + "symbol": self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), + "price": str(order.price), + "stopPrice": "0.0", + "origQty": str(order.amount), + "executedQty": str(order.amount), + "status": "cancel", + "type": order.trade_type.name.lower(), + "side": order.order_type.name.lower(), + "createdTime": 1499827319559, + "updatedTime": 1499827319559 + } + + def _order_status_request_open_mock_response(self, order: InFlightOrder) -> Any: + return { + "id": order.exchange_order_id, + "clientOrderId": order.client_order_id, + "symbol": self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), + "price": str(order.price), + "stopPrice": "0.0", + "origQty": str(order.amount), + "executedQty": str(order.amount), + "status": "idle", + "type": order.trade_type.name.lower(), + "side": order.order_type.name.lower(), + "createdTime": 1499827319559, + "updatedTime": 1499827319559 + } + + def _order_status_request_partially_filled_mock_response(self, order: InFlightOrder) -> Any: + return { + "id": order.exchange_order_id, + "clientOrderId": order.client_order_id, + "symbol": self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), + "price": str(order.price), + "stopPrice": "0.0", + "origQty": str(order.amount), + "executedQty": str(order.amount), + "status": "wait", + "type": order.trade_type.name.lower(), + "side": order.order_type.name.lower(), + "createdTime": 1499827319559, + "updatedTime": 1499827319559 + } + + def _order_fills_request_partial_fill_mock_response(self, order: InFlightOrder): + return [ + { + "id": self.expected_fill_trade_id, + "symbol": self.exchange_symbol_for_tokens(order.base_asset, order.quote_asset), + "fee": str(self.expected_fill_fee.flat_fees[0].amount), + "feeCurrency": self.expected_fill_fee.flat_fees[0].token, + "quoteQty": str(self.expected_partial_fill_amount * self.expected_partial_fill_price), + "price": str(self.expected_partial_fill_price), + "qty": str(self.expected_partial_fill_amount), + "orderId": int(order.exchange_order_id), + "side": order.order_type.name.lower(), + "isBuyerMaker": True, + "time": 1499865549590 + } + + ] + + def _order_fills_request_full_fill_mock_response(self, order: InFlightOrder): + return [ + { + "id": self.expected_fill_trade_id, + "symbol": self.exchange_symbol_for_tokens(order.base_asset, order.quote_asset), + "fee": str(self.expected_fill_fee.flat_fees[0].amount), + "feeCurrency": self.expected_fill_fee.flat_fees[0].token, + "quoteQty": str(order.amount * order.price), + "price": str(order.price), + "qty": str(order.amount), + "orderId": int(order.exchange_order_id), + "side": order.order_type.name.lower(), + "isBuyerMaker": True, + "time": 1499865549590 + } + ] diff --git a/test/hummingbot/connector/exchange/wazirx/test_wazirx_order_book.py b/test/hummingbot/connector/exchange/wazirx/test_wazirx_order_book.py new file mode 100644 index 00000000000..55871682585 --- /dev/null +++ b/test/hummingbot/connector/exchange/wazirx/test_wazirx_order_book.py @@ -0,0 +1,111 @@ +from unittest import TestCase + +from hummingbot.connector.exchange.wazirx.wazirx_order_book import WazirxOrderBook +from hummingbot.core.data_type.order_book_message import OrderBookMessageType + + +class WazirxOrderBookTests(TestCase): + + def test_snapshot_message_from_exchange(self): + snapshot_message = WazirxOrderBook.snapshot_message_from_exchange( + msg={ + "timestamp": 1588831243, + "bids": [ + ["9253.0", "1.0456"] + ], + "asks": [ + ["9291.0", "0.0119"] + ] + }, + timestamp=1640000000.0, + metadata={"trading_pair": "COINALPHA-HBOT"} + ) + + self.assertEqual("COINALPHA-HBOT", snapshot_message.trading_pair) + self.assertEqual(OrderBookMessageType.SNAPSHOT, snapshot_message.type) + self.assertEqual(1640000000.0, snapshot_message.timestamp) + self.assertEqual(1588831243, snapshot_message.update_id) + self.assertEqual(-1, snapshot_message.trade_id) + self.assertEqual(1, len(snapshot_message.bids)) + self.assertEqual(9253.0, snapshot_message.bids[0].price) + self.assertEqual(1.0456, snapshot_message.bids[0].amount) + self.assertEqual(1588831243, snapshot_message.bids[0].update_id) + self.assertEqual(1, len(snapshot_message.asks)) + self.assertEqual(9291.0, snapshot_message.asks[0].price) + self.assertEqual(0.0119, snapshot_message.asks[0].amount) + self.assertEqual(1588831243, snapshot_message.asks[0].update_id) + + def test_diff_message_from_exchange(self): + diff_msg = WazirxOrderBook.diff_message_from_exchange( + msg={ + "data": + { + "E": 1631682370000, + "a": [ + [ + "10.0", + "75.0" + ] + ], + "b": [ + [ + "6.0", + "50.0" + ] + ], + "s": "COINALPHA-HBOT", + }, + "stream": "COINALPHA-HBOT@depth" + }, + timestamp=1640000000.0, + metadata={"trading_pair": "COINALPHA-HBOT"} + ) + + self.assertEqual("COINALPHA-HBOT", diff_msg.trading_pair) + self.assertEqual(OrderBookMessageType.DIFF, diff_msg.type) + self.assertEqual(1640000000.0, diff_msg.timestamp) + self.assertEqual(1631682370000, diff_msg.update_id) + self.assertEqual(1631682370000, diff_msg.first_update_id) + self.assertEqual(-1, diff_msg.trade_id) + self.assertEqual(1, len(diff_msg.bids)) + self.assertEqual(6.0, diff_msg.bids[0].price) + self.assertEqual(50.0, diff_msg.bids[0].amount) + self.assertEqual(1631682370000, diff_msg.bids[0].update_id) + self.assertEqual(1, len(diff_msg.asks)) + self.assertEqual(10.0, diff_msg.asks[0].price) + self.assertEqual(75.0, diff_msg.asks[0].amount) + self.assertEqual(1631682370000, diff_msg.asks[0].update_id) + + def test_trade_message_from_exchange(self): + trade_update = { + "data": + { + "trades": + [ + { + "E": 1631681323000, + "S": "buy", + "a": 26946138, + "b": 26946169, + "m": True, + "p": "7.0", + "q": "15.0", + "s": "COINALPHA-HBOT", + "t": 17376030 + } + ] + }, + "stream": "COINALPHA-HBOT@trades" + } + + trade_message = WazirxOrderBook.trade_message_from_exchange( + msg=trade_update, + metadata={"trading_pair": "COINALPHA-HBOT"} + ) + + self.assertEqual("COINALPHA-HBOT", trade_message.trading_pair) + self.assertEqual(OrderBookMessageType.TRADE, trade_message.type) + self.assertEqual(1631681323.0, trade_message.timestamp) + self.assertEqual(-1, trade_message.update_id) + self.assertEqual(-1, trade_message.first_update_id) + self.assertEqual(17376030, trade_message.trade_id) diff --git a/test/hummingbot/connector/exchange/wazirx/test_wazirx_user_stream_data_source.py b/test/hummingbot/connector/exchange/wazirx/test_wazirx_user_stream_data_source.py new file mode 100644 index 00000000000..4cf67eb612e --- /dev/null +++ b/test/hummingbot/connector/exchange/wazirx/test_wazirx_user_stream_data_source.py @@ -0,0 +1,176 @@ + +import asyncio +import json +import re +import unittest +from typing import Awaitable, Dict, List, Optional +from unittest.mock import AsyncMock, MagicMock, patch + +from aioresponses import aioresponses +from bidict import bidict + +from hummingbot.client.config.client_config_map import ClientConfigMap +from hummingbot.client.config.config_helpers import ClientConfigAdapter +from hummingbot.connector.exchange.wazirx import wazirx_constants as CONSTANTS, wazirx_web_utils as web_utils +from hummingbot.connector.exchange.wazirx.wazirx_api_user_stream_data_source import WazirxAPIUserStreamDataSource +from hummingbot.connector.exchange.wazirx.wazirx_auth import WazirxAuth +from hummingbot.connector.exchange.wazirx.wazirx_exchange import WazirxExchange +from hummingbot.connector.test_support.network_mocking_assistant import NetworkMockingAssistant +from hummingbot.core.api_throttler.async_throttler import AsyncThrottler + + +class WazirxAPIUserStreamDataSourceTest(unittest.TestCase): + level = 0 + + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + cls.ev_loop = asyncio.get_event_loop() + cls.base_asset = "COINALPHA" + cls.quote_asset = "HBOT" + cls.trading_pair = f"{cls.base_asset}-{cls.quote_asset}" + cls.ex_trading_pair = f"{cls.base_asset}{cls.quote_asset}" + cls.listen_key = "TEST_LISTEN_KEY" + cls.domain = "wazirx" + + def setUp(self) -> None: + super().setUp() + self.log_records = [] + self.listening_task: Optional[asyncio.Task] = None + self.mocking_assistant = NetworkMockingAssistant() + + self.throttler = AsyncThrottler(rate_limits=CONSTANTS.RATE_LIMITS) + self.mock_time_provider = MagicMock() + + client_config_map = ClientConfigAdapter(ClientConfigMap()) + self.connector = WazirxExchange( + client_config_map=client_config_map, + wazirx_api_key="", + wazirx_api_secret="", + trading_pairs=[self.trading_pair], + trading_required=False) + + not_a_real_secret = "kQH5HW/8p1uGOVjbgWA7FunAmGO8lsSUXNsu3eow76sz84Q18fWxnyRzBHCd3pd5nE9qa99HAZtuZuj6F1huXg==" + self.auth = WazirxAuth(api_key="someKey", secret_key=not_a_real_secret, time_provider=self.mock_time_provider) + + self.connector._web_assistants_factory._auth = self.auth + self.data_source = WazirxAPIUserStreamDataSource(self.auth, + self.connector, + api_factory=self.connector._web_assistants_factory, + ) + + self.data_source.logger().setLevel(1) + self.data_source.logger().addHandler(self) + + self.resume_test_event = asyncio.Event() + + self.connector._set_trading_pair_symbol_map(bidict({self.ex_trading_pair: self.trading_pair})) + + def tearDown(self) -> None: + self.listening_task and self.listening_task.cancel() + super().tearDown() + + def handle(self, record): + self.log_records.append(record) + + def _is_logged(self, log_level: str, message: str) -> bool: + return any(record.levelname == log_level and record.getMessage() == message + for record in self.log_records) + + def async_run_with_timeout(self, coroutine: Awaitable, timeout: float = 1): + ret = self.ev_loop.run_until_complete(asyncio.wait_for(coroutine, timeout)) + return ret + + @staticmethod + def get_auth_response_mock() -> Dict: + auth_resp = { + "auth_key": "1Dwc4lzSwNWOAwkMdqhssNNFhs1ed606d1WcF3XfEMw", + "timeout_duration": 900 + } + return auth_resp + + @staticmethod + def get_open_orders_mock() -> List: + open_orders = { + "data": + { + "E": 1631683058904, + "O": 1631683058000, + "S": "sell", + "V": "70.0", + "X": "wait", + "i": 26946170, + "c": "my_clientorder_1", + "m": True, + "o": "limit", + "p": "5.0", + "q": "70.0", + "s": "wrxinr", + "v": "0.0", + "z": "0.0" + }, + "stream": "orderUpdate" + } + return open_orders + + @staticmethod + def get_own_trades_mock() -> List: + own_trades = { + "data": + { + "E": 1631683058000, + "S": "ask", + "U": "inr", + "a": 114144050, + "b": 114144121, + "f": "0.2", + "m": True, + "o": 26946170, + "c": "my_clientorder_1", + "p": "5.0", + "q": "20.0", + "s": "btcinr", + "t": 17376032, + "w": "100.0" + }, + "stream": "ownTrade" + } + return own_trades + + @aioresponses() + def test_get_auth_token(self, mocked_api): + url = web_utils.private_rest_url(path_url=CONSTANTS.WAZIRX_USER_STREAM_PATH_URL, domain=self.domain) + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + resp = self.get_auth_response_mock() + mocked_api.post(regex_url, body=json.dumps(resp)) + + ret = self.async_run_with_timeout(self.data_source.get_auth_token()) + + self.assertEqual(ret, resp["auth_key"]) + + @aioresponses() + @patch("aiohttp.ClientSession.ws_connect", new_callable=AsyncMock) + def test_listen_for_user_stream(self, mocked_api, ws_connect_mock): + url = web_utils.private_rest_url(path_url=CONSTANTS.WAZIRX_USER_STREAM_PATH_URL, domain=self.domain) + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + resp = self.get_auth_response_mock() + mocked_api.post(regex_url, body=json.dumps(resp)) + ws_connect_mock.return_value = self.mocking_assistant.create_websocket_mock() + output_queue = asyncio.Queue() + self.ev_loop.create_task(self.data_source.listen_for_user_stream(output_queue)) + + resp = self.get_open_orders_mock() + self.mocking_assistant.add_websocket_aiohttp_message( + websocket_mock=ws_connect_mock.return_value, message=json.dumps(resp) + ) + ret = self.async_run_with_timeout(coroutine=output_queue.get()) + + self.assertEqual(ret, resp) + + resp = self.get_own_trades_mock() + self.mocking_assistant.add_websocket_aiohttp_message( + websocket_mock=ws_connect_mock.return_value, message=json.dumps(resp) + ) + ret = self.async_run_with_timeout(coroutine=output_queue.get()) + + self.assertEqual(ret, resp) diff --git a/test/hummingbot/connector/exchange/wazirx/test_wazirx_utils.py b/test/hummingbot/connector/exchange/wazirx/test_wazirx_utils.py new file mode 100644 index 00000000000..e2e3ac3adf5 --- /dev/null +++ b/test/hummingbot/connector/exchange/wazirx/test_wazirx_utils.py @@ -0,0 +1,42 @@ +import unittest + +from hummingbot.connector.exchange.wazirx import wazirx_utils as utils + + +class WazirxUtilTestCases(unittest.TestCase): + + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + cls.base_asset = "COINALPHA" + cls.quote_asset = "HBOT" + cls.trading_pair = f"{cls.base_asset}-{cls.quote_asset}" + cls.hb_trading_pair = f"{cls.base_asset}-{cls.quote_asset}" + cls.ex_trading_pair = f"{cls.base_asset}{cls.quote_asset}" + + def test_is_exchange_information_valid(self): + invalid_info_1 = { + "status": "BREAK", + } + + self.assertFalse(utils.is_exchange_information_valid(invalid_info_1)) + + invalid_info_2 = { + "status": "BREAK", + } + + self.assertFalse(utils.is_exchange_information_valid(invalid_info_2)) + + invalid_info_3 = { + "status": "trading", + "isSpotTradingAllowed": False, + } + + self.assertFalse(utils.is_exchange_information_valid(invalid_info_3)) + + invalid_info_4 = { + "status": "trading", + "isSpotTradingAllowed": True, + } + + self.assertTrue(utils.is_exchange_information_valid(invalid_info_4)) diff --git a/test/hummingbot/connector/exchange/wazirx/test_wazirx_web_utils.py b/test/hummingbot/connector/exchange/wazirx/test_wazirx_web_utils.py new file mode 100644 index 00000000000..f280f4dd51c --- /dev/null +++ b/test/hummingbot/connector/exchange/wazirx/test_wazirx_web_utils.py @@ -0,0 +1,19 @@ +import unittest + +import hummingbot.connector.exchange.wazirx.wazirx_constants as CONSTANTS +from hummingbot.connector.exchange.wazirx import wazirx_web_utils as web_utils + + +class WazirxUtilTestCases(unittest.TestCase): + + def test_public_rest_url(self): + path_url = "/TEST_PATH" + domain = "wazirx" + expected_url = CONSTANTS.REST_URL.format(domain) + CONSTANTS.PUBLIC_API_VERSION + path_url + self.assertEqual(expected_url, web_utils.public_rest_url(path_url, domain)) + + def test_private_rest_url(self): + path_url = "/TEST_PATH" + domain = "wazirx" + expected_url = CONSTANTS.REST_URL.format(domain) + CONSTANTS.PRIVATE_API_VERSION + path_url + self.assertEqual(expected_url, web_utils.private_rest_url(path_url, domain))