diff --git a/hummingbot/connector/exchange/genius_yield/__init__.py b/hummingbot/connector/exchange/genius_yield/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/hummingbot/connector/exchange/genius_yield/dummy.pxd b/hummingbot/connector/exchange/genius_yield/dummy.pxd new file mode 100644 index 0000000000..4b098d6f59 --- /dev/null +++ b/hummingbot/connector/exchange/genius_yield/dummy.pxd @@ -0,0 +1,2 @@ +cdef class dummy(): + pass diff --git a/hummingbot/connector/exchange/genius_yield/dummy.pyx b/hummingbot/connector/exchange/genius_yield/dummy.pyx new file mode 100644 index 0000000000..4b098d6f59 --- /dev/null +++ b/hummingbot/connector/exchange/genius_yield/dummy.pyx @@ -0,0 +1,2 @@ +cdef class dummy(): + pass diff --git a/hummingbot/connector/exchange/genius_yield/genius_yield_api_order_book_data_source.py b/hummingbot/connector/exchange/genius_yield/genius_yield_api_order_book_data_source.py new file mode 100644 index 0000000000..f83a9fd260 --- /dev/null +++ b/hummingbot/connector/exchange/genius_yield/genius_yield_api_order_book_data_source.py @@ -0,0 +1,90 @@ +import asyncio +import time +from typing import TYPE_CHECKING, Any, Dict, List, Optional + +from hummingbot.connector.exchange.genius_yield import genius_yield_constants as CONSTANTS, genius_yield_web_utils as web_utils +from hummingbot.connector.exchange.genius_yield.genius_yield_order_book import GeniusYieldOrderBook +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 +from hummingbot.core.web_assistant.web_assistants_factory import WebAssistantsFactory +from hummingbot.logger import HummingbotLogger + +if TYPE_CHECKING: + from hummingbot.connector.exchange.genius_yield.genius_yield_exchange import GeniusYieldExchange + + +class GeniusYieldAPIOrderBookDataSource(OrderBookTrackerDataSource): + HEARTBEAT_TIME_INTERVAL = 30.0 + + _logger: Optional[HummingbotLogger] = None + + def __init__(self, + trading_pairs: List[str], + connector: 'GeniusYieldExchange', + api_factory: WebAssistantsFactory, + domain: str = CONSTANTS.DEFAULT_DOMAIN): + super().__init__(trading_pairs) + self._connector = connector + 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 _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 = GeniusYieldOrderBook.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 "result" not in raw_message: + trading_pair = await self._connector.trading_pair_associated_to_exchange_symbol(symbol=raw_message["s"]) + trade_message = GeniusYieldOrderBook.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 "result" not in raw_message: + trading_pair = await self._connector.trading_pair_associated_to_exchange_symbol(symbol=raw_message["s"]) + order_book_message: OrderBookMessage = GeniusYieldOrderBook.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 "result" not in event_message: + event_type = event_message.get("e") + channel = (self._diff_messages_queue_key if event_type == CONSTANTS.DIFF_EVENT_TYPE + else self._trade_messages_queue_key) + return channel diff --git a/hummingbot/connector/exchange/genius_yield/genius_yield_api_user_stream_data_source.py b/hummingbot/connector/exchange/genius_yield/genius_yield_api_user_stream_data_source.py new file mode 100644 index 0000000000..fe521b6b16 --- /dev/null +++ b/hummingbot/connector/exchange/genius_yield/genius_yield_api_user_stream_data_source.py @@ -0,0 +1,57 @@ +import asyncio +from typing import TYPE_CHECKING, List, Optional + +from hummingbot.connector.exchange.genius_yield import genius_yield_constants as CONSTANTS, genius_yield_web_utils as web_utils +from hummingbot.connector.exchange.genius_yield.genius_yield_auth import GeniusYieldAuth +from hummingbot.core.data_type.user_stream_tracker_data_source import UserStreamTrackerDataSource +from hummingbot.core.web_assistant.connections.data_types import RESTMethod +from hummingbot.core.web_assistant.web_assistants_factory import WebAssistantsFactory +from hummingbot.logger import HummingbotLogger + +if TYPE_CHECKING: + from hummingbot.connector.exchange.genius_yield.genius_yield_exchange import GeniusYieldExchange + + +class GeniusYieldAPIUserStreamDataSource(UserStreamTrackerDataSource): + _logger: Optional[HummingbotLogger] = None + + def __init__(self, + auth: GeniusYieldAuth, + trading_pairs: List[str], + connector: 'GeniusYieldExchange', + api_factory: WebAssistantsFactory, + domain: str = CONSTANTS.DEFAULT_DOMAIN): + super().__init__() + self._auth: GeniusYieldAuth = auth + self._domain = domain + self._api_factory = api_factory + + async def _request_user_stream(self): + rest_assistant = await self._api_factory.get_rest_assistant() + try: + data = await rest_assistant.execute_request( + url=web_utils.private_rest_url(path_url=CONSTANTS.GENIUS_YIELD_USER_STREAM_PATH_URL, domain=self._domain), + method=RESTMethod.POST, + throttler_limit_id=CONSTANTS.GENIUS_YIELD_USER_STREAM_PATH_URL, + headers=self._auth.header_for_authentication() + ) + except asyncio.CancelledError: + raise + except Exception as exception: + raise IOError(f"Error fetching user stream. Error: {exception}") + + return data + + async def _listen_for_user_stream(self): + """ + This method listens to the user stream. + """ + while True: + try: + data = await self._request_user_stream() + # Process the user stream data + except asyncio.CancelledError: + raise + except Exception as e: + self.logger().error(f"Unexpected error while listening to user stream. Error: {str(e)}") + await self._sleep(5.0) diff --git a/hummingbot/connector/exchange/genius_yield/genius_yield_auth.py b/hummingbot/connector/exchange/genius_yield/genius_yield_auth.py new file mode 100644 index 0000000000..cb3aa99bc1 --- /dev/null +++ b/hummingbot/connector/exchange/genius_yield/genius_yield_auth.py @@ -0,0 +1,55 @@ +import hashlib +import hmac +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 + + +class GeniusYieldAuth(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: + 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 + + def add_auth_to_params(self, params: Dict[str, Any]) -> 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-MBX-APIKEY": self.api_key} + + def _generate_signature(self, params: Dict[str, Any]) -> str: + encoded_params_str = urlencode(params) + digest = hmac.new(self.secret_key.encode("utf8"), encoded_params_str.encode("utf8"), hashlib.sha256).hexdigest() + return digest diff --git a/hummingbot/connector/exchange/genius_yield/genius_yield_constants.py b/hummingbot/connector/exchange/genius_yield/genius_yield_constants.py new file mode 100644 index 0000000000..a04d52d44c --- /dev/null +++ b/hummingbot/connector/exchange/genius_yield/genius_yield_constants.py @@ -0,0 +1,107 @@ +from hummingbot.core.api_throttler.data_types import LinkedLimitWeightPair, RateLimit +from hummingbot.core.data_type.in_flight_order import OrderState + +DEFAULT_DOMAIN = "com" + +HBOT_ORDER_ID_PREFIX = "x-XEKWYICX" +MAX_ORDER_ID_LEN = 32 + +# Base URL +REST_URL = "https://api.genius_yield.{}/api/" +WSS_URL = "wss://stream.genius_yield.{}:9443/ws" + +PUBLIC_API_VERSION = "v0" +PRIVATE_API_VERSION = "v0" + +# Public API endpoints +TICKER_PRICE_CHANGE_PATH_URL = "/historical-prices/tap-tools/{asset}" +TICKER_BOOK_PATH_URL = "/order-books/{market-id}" +EXCHANGE_INFO_PATH_URL = "/markets" +PING_PATH_URL = "/settings" +SNAPSHOT_PATH_URL = "/order-books/{market-id}" +SERVER_TIME_PATH_URL = "/settings" + +# Private API endpoints +ACCOUNTS_PATH_URL = "/balances/{address}" +MY_TRADES_PATH_URL = "/orders/details/{nft-token}" +ORDER_PATH_URL = "/orders" +GENIUS_YIELD_USER_STREAM_PATH_URL = "/userDataStream" + +WS_HEARTBEAT_TIME_INTERVAL = 30 + +# Genius Yield params +SIDE_BUY = "BUY" +SIDE_SELL = "SELL" + +TIME_IN_FORCE_GTC = "GTC" # Good till cancelled +TIME_IN_FORCE_IOC = "IOC" # Immediate or cancel +TIME_IN_FORCE_FOK = "FOK" # Fill or kill + +# Rate Limit Type +REQUEST_WEIGHT = "REQUEST_WEIGHT" +ORDERS = "ORDERS" +ORDERS_24HR = "ORDERS_24HR" +RAW_REQUESTS = "RAW_REQUESTS" + +# Rate Limit time intervals +ONE_MINUTE = 60 +ONE_SECOND = 1 +ONE_DAY = 86400 + +MAX_REQUEST = 5000 + +# Order States +ORDER_STATE = { + "PENDING": OrderState.PENDING_CREATE, + "NEW": OrderState.OPEN, + "FILLED": OrderState.FILLED, + "PARTIALLY_FILLED": OrderState.PARTIALLY_FILLED, + "PENDING_CANCEL": OrderState.OPEN, + "CANCELED": OrderState.CANCELED, + "REJECTED": OrderState.FAILED, + "EXPIRED": OrderState.FAILED, +} + +# Rate limits +RATE_LIMITS = [ + # Pools + RateLimit(limit_id=REQUEST_WEIGHT, limit=6000, time_interval=ONE_MINUTE), + RateLimit(limit_id=ORDERS, limit=50, time_interval=10 * ONE_SECOND), + RateLimit(limit_id=ORDERS_24HR, limit=160000, time_interval=ONE_DAY), + RateLimit(limit_id=RAW_REQUESTS, limit=61000, time_interval=5 * ONE_MINUTE), + # Weighted Limits + RateLimit(limit_id=TICKER_PRICE_CHANGE_PATH_URL, limit=MAX_REQUEST, time_interval=ONE_MINUTE, + linked_limits=[LinkedLimitWeightPair(REQUEST_WEIGHT, 2), + LinkedLimitWeightPair(RAW_REQUESTS, 1)]), + RateLimit(limit_id=TICKER_BOOK_PATH_URL, limit=MAX_REQUEST, time_interval=ONE_MINUTE, + linked_limits=[LinkedLimitWeightPair(REQUEST_WEIGHT, 4), + LinkedLimitWeightPair(RAW_REQUESTS, 1)]), + RateLimit(limit_id=EXCHANGE_INFO_PATH_URL, limit=MAX_REQUEST, time_interval=ONE_MINUTE, + linked_limits=[LinkedLimitWeightPair(REQUEST_WEIGHT, 20), + LinkedLimitWeightPair(RAW_REQUESTS, 1)]), + RateLimit(limit_id=SNAPSHOT_PATH_URL, limit=MAX_REQUEST, time_interval=ONE_MINUTE, + linked_limits=[LinkedLimitWeightPair(REQUEST_WEIGHT, 100), + LinkedLimitWeightPair(RAW_REQUESTS, 1)]), + RateLimit(limit_id=SERVER_TIME_PATH_URL, limit=MAX_REQUEST, time_interval=ONE_MINUTE, + linked_limits=[LinkedLimitWeightPair(REQUEST_WEIGHT, 1), + LinkedLimitWeightPair(RAW_REQUESTS, 1)]), + RateLimit(limit_id=PING_PATH_URL, limit=MAX_REQUEST, time_interval=ONE_MINUTE, + linked_limits=[LinkedLimitWeightPair(REQUEST_WEIGHT, 1), + LinkedLimitWeightPair(RAW_REQUESTS, 1)]), + RateLimit(limit_id=ACCOUNTS_PATH_URL, limit=MAX_REQUEST, time_interval=ONE_MINUTE, + linked_limits=[LinkedLimitWeightPair(REQUEST_WEIGHT, 20), + LinkedLimitWeightPair(RAW_REQUESTS, 1)]), + RateLimit(limit_id=MY_TRADES_PATH_URL, limit=MAX_REQUEST, time_interval=ONE_MINUTE, + linked_limits=[LinkedLimitWeightPair(REQUEST_WEIGHT, 20), + LinkedLimitWeightPair(RAW_REQUESTS, 1)]), + RateLimit(limit_id=ORDER_PATH_URL, limit=MAX_REQUEST, time_interval=ONE_MINUTE, + linked_limits=[LinkedLimitWeightPair(REQUEST_WEIGHT, 4), + LinkedLimitWeightPair(ORDERS, 1), + LinkedLimitWeightPair(ORDERS_24HR, 1), + LinkedLimitWeightPair(RAW_REQUESTS, 1)]) +] + +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/genius_yield/genius_yield_exchange.py b/hummingbot/connector/exchange/genius_yield/genius_yield_exchange.py new file mode 100644 index 0000000000..b879d13635 --- /dev/null +++ b/hummingbot/connector/exchange/genius_yield/genius_yield_exchange.py @@ -0,0 +1,336 @@ +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.genius_yield import ( + genius_yield_constants as CONSTANTS, + genius_yield_utils, + genius_yield_web_utils as web_utils, +) +from hummingbot.connector.exchange.genius_yield.genius_yield_api_order_book_data_source import GeniusYieldAPIOrderBookDataSource +from hummingbot.connector.exchange.genius_yield.genius_yield_auth import GeniusYieldAuth +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.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 GeniusYieldExchange(ExchangePyBase): + UPDATE_ORDER_STATUS_MIN_INTERVAL = 10.0 + + web_utils = web_utils + + def __init__(self, + client_config_map: "ClientConfigAdapter", + genius_yield_api_key: str, + genius_yield_api_secret: str, + trading_pairs: Optional[List[str]] = None, + trading_required: bool = True, + domain: str = CONSTANTS.DEFAULT_DOMAIN, + ): + self.api_key = genius_yield_api_key + self.secret_key = genius_yield_api_secret + self._domain = domain + self._trading_required = trading_required + self._trading_pairs = trading_pairs + self._last_trades_poll_genius_yield_timestamp = 1.0 + super().__init__(client_config_map) + + @staticmethod + def genius_yield_order_type(order_type: OrderType) -> str: + return order_type.name.upper() + + @staticmethod + def to_hb_order_type(genius_yield_type: str) -> OrderType: + return OrderType[genius_yield_type] + + @property + def authenticator(self): + return GeniusYieldAuth( + api_key=self.api_key, + secret_key=self.secret_key, + time_provider=self._time_synchronizer) + + @property + def name(self) -> str: + return "genius_yield" + + @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, OrderType.MARKET] + + 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.authenticator) + + def _create_order_book_data_source(self) -> OrderBookTrackerDataSource: + return GeniusYieldAPIOrderBookDataSource( + trading_pairs=self._trading_pairs, + connector=self, + domain=self.domain, + api_factory=self._web_assistants_factory) + + 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 = GeniusYieldExchange.genius_yield_order_type(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, + "newClientOrderId": order_id} + if order_type is OrderType.LIMIT or order_type is OrderType.LIMIT_MAKER: + price_str = f"{price:f}" + api_params["price"] = price_str + if order_type == OrderType.LIMIT: + api_params["timeInForce"] = CONSTANTS.TIME_IN_FORCE_GTC + + 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["orderId"]) + transact_time = order_result["transactTime"] * 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, + "origClientOrderId": 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") == "CANCELED": + return True + return False + + async def _format_trading_rules(self, exchange_info_dict: Dict[str, Any]) -> List[TradingRule]: + """ + Example: + { + "symbol": "ETHBTC", + "baseAssetPrecision": 8, + "quotePrecision": 8, + "orderTypes": ["LIMIT", "MARKET"], + "filters": [ + { + "filterType": "PRICE_FILTER", + "minPrice": "0.00000100", + "maxPrice": "100000.00000000", + "tickSize": "0.00000100" + }, { + "filterType": "LOT_SIZE", + "minQty": "0.00100000", + "maxQty": "100000.00000000", + "stepSize": "0.00100000" + }, { + "filterType": "MIN_NOTIONAL", + "minNotional": "0.00100000" + } + ] + } + """ + trading_pair_rules = exchange_info_dict.get("symbols", []) + retval = [] + for rule in filter(genius_yield_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] + lot_size_filter = [f for f in filters if f.get("filterType") == "LOT_SIZE"][0] + min_notional_filter = [f for f in filters if f.get("filterType") in ["MIN_NOTIONAL", "NOTIONAL"]][0] + + min_order_size = Decimal(lot_size_filter.get("minQty")) + tick_size = price_filter.get("tickSize") + step_size = Decimal(lot_size_filter.get("stepSize")) + min_notional = Decimal(min_notional_filter.get("minNotional")) + + retval.append( + TradingRule(trading_pair, + min_order_size=min_order_size, + min_price_increment=Decimal(tick_size), + min_base_amount_increment=Decimal(step_size), + min_notional_size=Decimal(min_notional))) + + except Exception: + self.logger().exception(f"Error parsing the trading pair rule {rule}. Skipping.") + return retval + + async def _status_polling_loop_fetch_updates(self): + await self._update_order_fills_from_trades() + await super()._status_polling_loop_fetch_updates() + + async def _update_trading_fees(self): + """ + Update fees information from the exchange + """ + pass + + async def _request_order_status(self, tracked_order: InFlightOrder) -> OrderUpdate: + trading_pair = await self.exchange_symbol_associated_to_pair(trading_pair=tracked_order.trading_pair) + updated_order_data = await self._api_get( + path_url=CONSTANTS.ORDER_PATH_URL, + params={ + "symbol": trading_pair, + "origClientOrderId": 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["orderId"]), + trading_pair=tracked_order.trading_pair, + update_timestamp=updated_order_data["updateTime"] * 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 = account_info["balances"] + 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(genius_yield_utils.is_exchange_information_valid, exchange_info["symbols"]): + mapping[symbol_data["symbol"]] = combine_to_hb_trading_pair(base=symbol_data["baseAsset"], + quote=symbol_data["quoteAsset"]) + 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/genius_yield/genius_yield_order_book.py b/hummingbot/connector/exchange/genius_yield/genius_yield_order_book.py new file mode 100644 index 0000000000..0e953475c5 --- /dev/null +++ b/hummingbot/connector/exchange/genius_yield/genius_yield_order_book.py @@ -0,0 +1,74 @@ +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 GeniusYieldOrderBook(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["lastUpdateId"], + "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["trading_pair"], + "first_update_id": msg["U"], + "update_id": msg["u"], + "bids": msg["b"], + "asks": msg["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["E"] + return OrderBookMessage(OrderBookMessageType.TRADE, { + "trading_pair": msg["trading_pair"], + "trade_type": float(TradeType.SELL.value) if msg["m"] else float(TradeType.BUY.value), + "trade_id": msg["t"], + "update_id": ts, + "price": msg["p"], + "amount": msg["q"] + }, timestamp=ts * 1e-3) diff --git a/hummingbot/connector/exchange/genius_yield/genius_yield_utils.py b/hummingbot/connector/exchange/genius_yield/genius_yield_utils.py new file mode 100644 index 0000000000..228d98e01e --- /dev/null +++ b/hummingbot/connector/exchange/genius_yield/genius_yield_utils.py @@ -0,0 +1,66 @@ +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 + + permissions_sets = exchange_info.get("permissionSets", list()) + for permission_set in permissions_sets: + # PermissionSet is a list, find if in this list we have "SPOT" value or not + if "SPOT" in permission_set: + is_spot = True + break + + return is_trading and is_spot + + +class GeniusYieldConfigMap(BaseConnectorConfigMap): + connector: str = Field(default="genius_yield", const=True, client_data=None) + genius_yield_api_key: SecretStr = Field( + default=..., + client_data=ClientFieldData( + prompt=lambda cm: "Enter your Genius Yield API key", + is_secure=True, + is_connect_key=True, + prompt_on_new=True, + ) + ) + genius_yield_api_secret: SecretStr = Field( + default=..., + client_data=ClientFieldData( + prompt=lambda cm: "Enter your Genius Yield API secret", + is_secure=True, + is_connect_key=True, + prompt_on_new=True, + ) + ) + + class Config: + title = "genius_yield" + + +KEYS = GeniusYieldConfigMap.construct() diff --git a/hummingbot/connector/exchange/genius_yield/genius_yield_web_utils.py b/hummingbot/connector/exchange/genius_yield/genius_yield_web_utils.py new file mode 100644 index 0000000000..3f66c544fa --- /dev/null +++ b/hummingbot/connector/exchange/genius_yield/genius_yield_web_utils.py @@ -0,0 +1,74 @@ +from typing import Callable, Optional + +import hummingbot.connector.exchange.genius_yield.genius_yield_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 Genius Yield 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 Genius Yield 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/genius_yield/__init__.py b/test/hummingbot/connector/exchange/genius_yield/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/hummingbot/connector/exchange/genius_yield/test_genius_yield_api_order_book_data_source.py b/test/hummingbot/connector/exchange/genius_yield/test_genius_yield_api_order_book_data_source.py new file mode 100644 index 0000000000..8d5369a15d --- /dev/null +++ b/test/hummingbot/connector/exchange/genius_yield/test_genius_yield_api_order_book_data_source.py @@ -0,0 +1,411 @@ +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.genius_yield import genius_yield_constants as CONSTANTS, genius_yield_web_utils as web_utils +from hummingbot.connector.exchange.genius_yield.genius_yield_api_order_book_data_source import GeniusYieldAPIOrderBookDataSource +from hummingbot.connector.exchange.genius_yield.genius_yield_exchange import GeniusYieldExchange +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 GeniusYieldAPIOrderBookDataSourceUnitTests(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 = "com" + + def setUp(self) -> None: + super().setUp() + self.log_records = [] + self.listening_task = None + self.mocking_assistant = NetworkMockingAssistant() + + client_config_map = ClientConfigAdapter(ClientConfigMap()) + self.connector = GeniusYieldExchange( + client_config_map=client_config_map, + genius_yield_api_key="", + genius_yield_api_secret="", + trading_pairs=[], + trading_required=False, + domain=self.domain) + self.data_source = GeniusYieldAPIOrderBookDataSource(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 = { + "e": "trade", + "E": 123456789, + "s": self.ex_trading_pair, + "t": 12345, + "p": "0.001", + "q": "100", + "b": 88, + "a": 50, + "T": 123456785, + "m": True, + "M": True + } + return resp + + def _order_diff_event(self): + resp = { + "e": "depthUpdate", + "E": 123456789, + "s": self.ex_trading_pair, + "U": 157, + "u": 160, + "b": [["0.0024", "10"]], + "a": [["0.0026", "100"]] + } + return resp + + def _snapshot_response(self): + resp = { + "lastUpdateId": 1027024, + "bids": [ + [ + "4.00000000", + "431.00000000" + ] + ], + "asks": [ + [ + "4.00000200", + "12.00000000" + ] + ] + } + 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["lastUpdateId"] + + 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(4, bids[0].price) + self.assertEqual(431, bids[0].amount) + self.assertEqual(expected_update_id, bids[0].update_id) + self.assertEqual(1, len(asks)) + self.assertEqual(4.000002, asks[0].price) + self.assertEqual(12, 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 = { + "result": None, + "id": 1 + } + result_subscribe_diffs = { + "result": None, + "id": 2 + } + + 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 = { + "method": "SUBSCRIBE", + "params": [f"{self.ex_trading_pair.lower()}@trade"], + "id": 1} + self.assertEqual(expected_trade_subscription, sent_subscription_messages[0]) + expected_diff_subscription = { + "method": "SUBSCRIBE", + "params": [f"{self.ex_trading_pair.lower()}@depth@100ms"], + "id": 2} + 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(12345, 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["u"], 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.genius_yield.genius_yield_api_order_book_data_source" + ".GeniusYieldAPIOrderBookDataSource._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(1027024, msg.update_id) diff --git a/test/hummingbot/connector/exchange/genius_yield/test_genius_yield_api_user_stream_data_source.py b/test/hummingbot/connector/exchange/genius_yield/test_genius_yield_api_user_stream_data_source.py new file mode 100644 index 0000000000..a107dbb2c6 --- /dev/null +++ b/test/hummingbot/connector/exchange/genius_yield/test_genius_yield_api_user_stream_data_source.py @@ -0,0 +1,313 @@ +import asyncio +import json +import re +import unittest +from typing import Any, Awaitable, Dict, 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.genius_yield import genius_yield_constants as CONSTANTS, genius_yield_web_utils as web_utils +from hummingbot.connector.exchange.genius_yield.genius_yield_api_user_stream_data_source import GeniusYieldAPIUserStreamDataSource +from hummingbot.connector.exchange.genius_yield.genius_yield_auth import GeniusYieldAuth +from hummingbot.connector.exchange.genius_yield.genius_yield_exchange import GeniusYieldExchange +from hummingbot.connector.test_support.network_mocking_assistant import NetworkMockingAssistant +from hummingbot.connector.time_synchronizer import TimeSynchronizer +from hummingbot.core.api_throttler.async_throttler import AsyncThrottler + + +class GeniusYieldUserStreamDataSourceUnitTests(unittest.TestCase): + # the level is 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 = "com" + + cls.listen_key = "TEST_LISTEN_KEY" + + 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() + self.mock_time_provider.time.return_value = 1000 + self.auth = GeniusYieldAuth(api_key="TEST_API_KEY", secret_key="TEST_SECRET", time_provider=self.mock_time_provider) + self.time_synchronizer = TimeSynchronizer() + self.time_synchronizer.add_time_offset_ms_sample(0) + + client_config_map = ClientConfigAdapter(ClientConfigMap()) + self.connector = GeniusYieldExchange( + client_config_map=client_config_map, + genius_yield_api_key="", + genius_yield_api_secret="", + trading_pairs=[], + trading_required=False, + domain=self.domain) + self.connector._web_assistants_factory._auth = self.auth + + self.data_source = GeniusYieldAPIUserStreamDataSource( + auth=self.auth, + 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.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 _raise_exception(self, exception_class): + raise exception_class + + def _create_exception_and_unlock_test_with_event(self, exception): + self.resume_test_event.set() + raise exception + + def _create_return_value_and_unlock_test_with_event(self, value): + self.resume_test_event.set() + return value + + 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 _error_response(self) -> Dict[str, Any]: + resp = { + "code": "ERROR CODE", + "msg": "ERROR MESSAGE" + } + + return resp + + def _user_update_event(self): + # Balance Update + resp = { + "e": "balanceUpdate", + "E": 1573200697110, + "a": "BTC", + "d": "100.00000000", + "T": 1573200697068 + } + return json.dumps(resp) + + def _successfully_subscribed_event(self): + resp = { + "result": None, + "id": 1 + } + return resp + + @aioresponses() + def test_get_listen_key_log_exception(self, mock_api): + url = web_utils.private_rest_url(path_url=CONSTANTS.BINANCE_USER_STREAM_PATH_URL, domain=self.domain) + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + + mock_api.post(regex_url, status=400, body=json.dumps(self._error_response())) + + with self.assertRaises(IOError): + self.async_run_with_timeout(self.data_source._get_listen_key()) + + @aioresponses() + def test_get_listen_key_successful(self, mock_api): + url = web_utils.private_rest_url(path_url=CONSTANTS.BINANCE_USER_STREAM_PATH_URL, domain=self.domain) + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + + mock_response = { + "listenKey": self.listen_key + } + mock_api.post(regex_url, body=json.dumps(mock_response)) + + result: str = self.async_run_with_timeout(self.data_source._get_listen_key()) + + self.assertEqual(self.listen_key, result) + + @aioresponses() + def test_ping_listen_key_log_exception(self, mock_api): + url = web_utils.private_rest_url(path_url=CONSTANTS.BINANCE_USER_STREAM_PATH_URL, domain=self.domain) + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + + mock_api.put(regex_url, status=400, body=json.dumps(self._error_response())) + + self.data_source._current_listen_key = self.listen_key + result: bool = self.async_run_with_timeout(self.data_source._ping_listen_key()) + + self.assertTrue(self._is_logged("WARNING", f"Failed to refresh the listen key {self.listen_key}: " + f"{self._error_response()}")) + self.assertFalse(result) + + @aioresponses() + def test_ping_listen_key_successful(self, mock_api): + url = web_utils.private_rest_url(path_url=CONSTANTS.BINANCE_USER_STREAM_PATH_URL, domain=self.domain) + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + mock_api.put(regex_url, body=json.dumps({})) + + self.data_source._current_listen_key = self.listen_key + result: bool = self.async_run_with_timeout(self.data_source._ping_listen_key()) + self.assertTrue(result) + + @patch("hummingbot.connector.exchange.genius_yield.genius_yield_api_user_stream_data_source.GeniusYieldAPIUserStreamDataSource" + "._ping_listen_key", + new_callable=AsyncMock) + def test_manage_listen_key_task_loop_keep_alive_failed(self, mock_ping_listen_key): + mock_ping_listen_key.side_effect = (lambda *args, **kwargs: + self._create_return_value_and_unlock_test_with_event(False)) + + self.data_source._current_listen_key = self.listen_key + + # Simulate LISTEN_KEY_KEEP_ALIVE_INTERVAL reached + self.data_source._last_listen_key_ping_ts = 0 + + self.listening_task = self.ev_loop.create_task(self.data_source._manage_listen_key_task_loop()) + + self.async_run_with_timeout(self.resume_test_event.wait()) + + self.assertTrue(self._is_logged("ERROR", "Error occurred renewing listen key ...")) + self.assertIsNone(self.data_source._current_listen_key) + self.assertFalse(self.data_source._listen_key_initialized_event.is_set()) + + @patch("hummingbot.connector.exchange.genius_yield.genius_yield_api_user_stream_data_source.GeniusYieldAPIUserStreamDataSource." + "_ping_listen_key", + new_callable=AsyncMock) + def test_manage_listen_key_task_loop_keep_alive_successful(self, mock_ping_listen_key): + mock_ping_listen_key.side_effect = (lambda *args, **kwargs: + self._create_return_value_and_unlock_test_with_event(True)) + + # Simulate LISTEN_KEY_KEEP_ALIVE_INTERVAL reached + self.data_source._current_listen_key = self.listen_key + self.data_source._listen_key_initialized_event.set() + self.data_source._last_listen_key_ping_ts = 0 + + self.listening_task = self.ev_loop.create_task(self.data_source._manage_listen_key_task_loop()) + + self.async_run_with_timeout(self.resume_test_event.wait()) + + self.assertTrue(self._is_logged("INFO", f"Refreshed listen key {self.listen_key}.")) + self.assertGreater(self.data_source._last_listen_key_ping_ts, 0) + + @aioresponses() + @patch("aiohttp.ClientSession.ws_connect", new_callable=AsyncMock) + def test_listen_for_user_stream_get_listen_key_successful_with_user_update_event(self, mock_api, mock_ws): + url = web_utils.private_rest_url(path_url=CONSTANTS.BINANCE_USER_STREAM_PATH_URL, domain=self.domain) + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + + mock_response = { + "listenKey": self.listen_key + } + mock_api.post(regex_url, body=json.dumps(mock_response)) + + mock_ws.return_value = self.mocking_assistant.create_websocket_mock() + self.mocking_assistant.add_websocket_aiohttp_message(mock_ws.return_value, self._user_update_event()) + + msg_queue = asyncio.Queue() + self.listening_task = self.ev_loop.create_task( + self.data_source.listen_for_user_stream(msg_queue) + ) + + msg = self.async_run_with_timeout(msg_queue.get()) + self.assertEqual(json.loads(self._user_update_event()), msg) + mock_ws.return_value.ping.assert_called() + + @aioresponses() + @patch("aiohttp.ClientSession.ws_connect", new_callable=AsyncMock) + def test_listen_for_user_stream_does_not_queue_empty_payload(self, mock_api, mock_ws): + url = web_utils.private_rest_url(path_url=CONSTANTS.BINANCE_USER_STREAM_PATH_URL, domain=self.domain) + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + + mock_response = { + "listenKey": self.listen_key + } + mock_api.post(regex_url, body=json.dumps(mock_response)) + + mock_ws.return_value = self.mocking_assistant.create_websocket_mock() + self.mocking_assistant.add_websocket_aiohttp_message(mock_ws.return_value, "") + + msg_queue = asyncio.Queue() + self.listening_task = self.ev_loop.create_task( + self.data_source.listen_for_user_stream(msg_queue) + ) + + self.mocking_assistant.run_until_all_aiohttp_messages_delivered(mock_ws.return_value) + + self.assertEqual(0, msg_queue.qsize()) + + @aioresponses() + @patch("aiohttp.ClientSession.ws_connect", new_callable=AsyncMock) + def test_listen_for_user_stream_connection_failed(self, mock_api, mock_ws): + url = web_utils.private_rest_url(path_url=CONSTANTS.BINANCE_USER_STREAM_PATH_URL, domain=self.domain) + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + + mock_response = { + "listenKey": self.listen_key + } + mock_api.post(regex_url, body=json.dumps(mock_response)) + + mock_ws.side_effect = lambda *arg, **kwars: self._create_exception_and_unlock_test_with_event( + Exception("TEST ERROR.")) + + msg_queue = asyncio.Queue() + self.listening_task = self.ev_loop.create_task( + self.data_source.listen_for_user_stream(msg_queue) + ) + + self.async_run_with_timeout(self.resume_test_event.wait()) + + self.assertTrue( + self._is_logged("ERROR", + "Unexpected error while listening to user stream. Retrying after 5 seconds...")) + + @aioresponses() + @patch("aiohttp.ClientSession.ws_connect", new_callable=AsyncMock) + def test_listen_for_user_stream_iter_message_throws_exception(self, mock_api, mock_ws): + url = web_utils.private_rest_url(path_url=CONSTANTS.BINANCE_USER_STREAM_PATH_URL, domain=self.domain) + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + + mock_response = { + "listenKey": self.listen_key + } + mock_api.post(regex_url, body=json.dumps(mock_response)) + + msg_queue: asyncio.Queue = asyncio.Queue() + mock_ws.return_value = self.mocking_assistant.create_websocket_mock() + mock_ws.return_value.receive.side_effect = (lambda *args, **kwargs: + self._create_exception_and_unlock_test_with_event( + Exception("TEST ERROR"))) + mock_ws.close.return_value = None + + self.listening_task = self.ev_loop.create_task( + self.data_source.listen_for_user_stream(msg_queue) + ) + + self.async_run_with_timeout(self.resume_test_event.wait()) + + self.assertTrue( + self._is_logged( + "ERROR", + "Unexpected error while listening to user stream. Retrying after 5 seconds...")) diff --git a/test/hummingbot/connector/exchange/genius_yield/test_genius_yield_auth.py b/test/hummingbot/connector/exchange/genius_yield/test_genius_yield_auth.py new file mode 100644 index 0000000000..3631a46521 --- /dev/null +++ b/test/hummingbot/connector/exchange/genius_yield/test_genius_yield_auth.py @@ -0,0 +1,51 @@ +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.genius_yield.genius_yield_auth import GeniusYieldAuth +from hummingbot.core.web_assistant.connections.data_types import RESTMethod, RESTRequest + + +class GeniusYieldAuthTests(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", + "timeInForce": "GTC", + "quantity": 1, + "price": "0.1", + } + full_params = copy(params) + + auth = GeniusYieldAuth(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-MBX-APIKEY": self._api_key}, configured_request.headers) diff --git a/test/hummingbot/connector/exchange/genius_yield/test_genius_yield_exchange.py b/test/hummingbot/connector/exchange/genius_yield/test_genius_yield_exchange.py new file mode 100644 index 0000000000..5cd228b947 --- /dev/null +++ b/test/hummingbot/connector/exchange/genius_yield/test_genius_yield_exchange.py @@ -0,0 +1,1380 @@ +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.genius_yield import genius_yield_constants as CONSTANTS, genius_yield_web_utils as web_utils +from hummingbot.connector.exchange.genius_yield.genius_yield_exchange import GeniusYieldExchange +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 MarketOrderFailureEvent, OrderFilledEvent + + +class GeniusYieldExchangeTests(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 { + "timezone": "UTC", + "serverTime": 1639598493658, + "rateLimits": [], + "exchangeFilters": [], + "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", + "LIMIT_MAKER", + "MARKET", + "STOP_LOSS_LIMIT", + "TAKE_PROFIT_LIMIT" + ], + "icebergAllowed": True, + "ocoAllowed": True, + "quoteOrderQtyMarketAllowed": True, + "isSpotTradingAllowed": True, + "isMarginTradingAllowed": True, + "filters": [], + "permissionSets": [[ + "SPOT", + "MARGIN" + ]] + }, + ] + } + + @property + def latest_prices_request_mock_response(self): + return { + "symbol": self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), + "priceChange": "-94.99999800", + "priceChangePercent": "-95.960", + "weightedAvgPrice": "0.29628482", + "prevClosePrice": "0.10002000", + "lastPrice": str(self.expected_latest_price), + "lastQty": "200.00000000", + "bidPrice": "4.00000000", + "bidQty": "100.00000000", + "askPrice": "4.00000200", + "askQty": "100.00000000", + "openPrice": "99.00000000", + "highPrice": "100.00000000", + "lowPrice": "0.10000000", + "volume": "8913.30000000", + "quoteVolume": "15.30000000", + "openTime": 1499783499040, + "closeTime": 1499869899040, + "firstId": 28385, + "lastId": 28460, + "count": 76, + } + + @property + def all_symbols_including_invalid_pair_mock_response(self) -> Tuple[str, Any]: + response = { + "timezone": "UTC", + "serverTime": 1639598493658, + "rateLimits": [], + "exchangeFilters": [], + "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", + "LIMIT_MAKER", + "MARKET", + "STOP_LOSS_LIMIT", + "TAKE_PROFIT_LIMIT" + ], + "icebergAllowed": True, + "ocoAllowed": True, + "quoteOrderQtyMarketAllowed": True, + "isSpotTradingAllowed": True, + "isMarginTradingAllowed": True, + "filters": [], + "permissionSets": [[ + "MARGIN" + ]] + }, + { + "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", + "LIMIT_MAKER", + "MARKET", + "STOP_LOSS_LIMIT", + "TAKE_PROFIT_LIMIT" + ], + "icebergAllowed": True, + "ocoAllowed": True, + "quoteOrderQtyMarketAllowed": True, + "isSpotTradingAllowed": True, + "isMarginTradingAllowed": True, + "filters": [], + "permissionSets": [[ + "MARGIN" + ]] + }, + ] + } + + return "INVALID-PAIR", response + + @property + def network_status_request_successful_mock_response(self): + return {} + + @property + def trading_rules_request_mock_response(self): + return { + "timezone": "UTC", + "serverTime": 1565246363776, + "rateLimits": [{}], + "exchangeFilters": [], + "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", "LIMIT_MAKER"], + "icebergAllowed": True, + "ocoAllowed": True, + "isSpotTradingAllowed": True, + "isMarginTradingAllowed": True, + "filters": [ + { + "filterType": "PRICE_FILTER", + "minPrice": "0.00000100", + "maxPrice": "100000.00000000", + "tickSize": "0.00000100" + }, { + "filterType": "LOT_SIZE", + "minQty": "0.00100000", + "maxQty": "200000.00000000", + "stepSize": "0.00100000" + }, { + "filterType": "MIN_NOTIONAL", + "minNotional": "0.00100000" + } + ], + "permissionSets": [[ + "SPOT", + "MARGIN" + ]] + } + ] + } + + @property + def trading_rules_request_erroneous_mock_response(self): + return { + "timezone": "UTC", + "serverTime": 1565246363776, + "rateLimits": [{}], + "exchangeFilters": [], + "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", "LIMIT_MAKER"], + "icebergAllowed": True, + "ocoAllowed": True, + "isSpotTradingAllowed": True, + "isMarginTradingAllowed": True, + "permissionSets": [[ + "SPOT", + "MARGIN" + ]] + } + ] + } + + @property + def order_creation_request_successful_mock_response(self): + return { + "symbol": self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), + "orderId": self.expected_exchange_order_id, + "orderListId": -1, + "clientOrderId": "OID1", + "transactTime": 1507725176595 + } + + @property + def balance_request_mock_response_for_base_and_quote(self): + return { + "makerCommission": 15, + "takerCommission": 15, + "buyerCommission": 0, + "sellerCommission": 0, + "canTrade": True, + "canWithdraw": True, + "canDeposit": True, + "updateTime": 123456789, + "accountType": "SPOT", + "balances": [ + { + "asset": self.base_asset, + "free": "10.0", + "locked": "5.0" + }, + { + "asset": self.quote_asset, + "free": "2000", + "locked": "0.00000000" + } + ], + "permissionSets": [[ + "SPOT" + ]] + } + + @property + def balance_request_mock_response_only_base(self): + return { + "makerCommission": 15, + "takerCommission": 15, + "buyerCommission": 0, + "sellerCommission": 0, + "canTrade": True, + "canWithdraw": True, + "canDeposit": True, + "updateTime": 123456789, + "accountType": "SPOT", + "balances": [{"asset": self.base_asset, "free": "10.0", "locked": "5.0"}], + "permissionSets": [["SPOT"]], + } + + @property + def balance_event_websocket_update(self): + return { + "e": "outboundAccountPosition", + "E": 1564034571105, + "u": 1564034571073, + "B": [{"a": self.base_asset, "f": "10", "l": "5"}], + } + + @property + def expected_latest_price(self): + return 9999.9 + + @property + def expected_supported_order_types(self): + return [OrderType.LIMIT, OrderType.LIMIT_MAKER, OrderType.MARKET] + + @property + def expected_trading_rule(self): + return TradingRule( + trading_pair=self.trading_pair, + min_order_size=Decimal(self.trading_rules_request_mock_response["symbols"][0]["filters"][1]["minQty"]), + min_price_increment=Decimal( + self.trading_rules_request_mock_response["symbols"][0]["filters"][0]["tickSize"]), + min_base_amount_increment=Decimal( + self.trading_rules_request_mock_response["symbols"][0]["filters"][1]["stepSize"]), + min_notional_size=Decimal( + self.trading_rules_request_mock_response["symbols"][0]["filters"][2]["minNotional"]), + ) + + @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 GeniusYieldExchange( + client_config_map=client_config_map, + genius_yield_api_key="testAPIKey", + genius_yield_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.upper(), request_data["side"]) + self.assertEqual(GeniusYieldExchange.genius_yield_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["newClientOrderId"]) + + 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["origClientOrderId"]) + + def validate_order_status_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.client_order_id, request_params["origClientOrderId"]) + + 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 { + "e": "executionReport", + "E": 1499405658658, + "s": self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), + "c": order.client_order_id, + "S": order.trade_type.name.upper(), + "o": order.order_type.name.upper(), + "f": "GTC", + "q": str(order.amount), + "p": str(order.price), + "P": "0.00000000", + "F": "0.00000000", + "g": -1, + "C": "", + "x": "NEW", + "X": "NEW", + "r": "NONE", + "i": order.exchange_order_id, + "l": "0.00000000", + "z": "0.00000000", + "L": "0.00000000", + "n": "0", + "N": None, + "T": 1499405658657, + "t": -1, + "I": 8641984, + "w": True, + "m": False, + "M": False, + "O": 1499405658657, + "Z": "0.00000000", + "Y": "0.00000000", + "Q": "0.00000000" + } + + def order_event_for_canceled_order_websocket_update(self, order: InFlightOrder): + return { + "e": "executionReport", + "E": 1499405658658, + "s": self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), + "c": "dummyText", + "S": order.trade_type.name.upper(), + "o": order.order_type.name.upper(), + "f": "GTC", + "q": str(order.amount), + "p": str(order.price), + "P": "0.00000000", + "F": "0.00000000", + "g": -1, + "C": order.client_order_id, + "x": "CANCELED", + "X": "CANCELED", + "r": "NONE", + "i": order.exchange_order_id, + "l": "0.00000000", + "z": "0.00000000", + "L": "0.00000000", + "n": "0", + "N": None, + "T": 1499405658657, + "t": -1, + "I": 8641984, + "w": True, + "m": False, + "M": False, + "O": 1499405658657, + "Z": "0.00000000", + "Y": "0.00000000", + "Q": "0.00000000" + } + + def order_event_for_full_fill_websocket_update(self, order: InFlightOrder): + return { + "e": "executionReport", + "E": 1499405658658, + "s": self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), + "c": order.client_order_id, + "S": order.trade_type.name.upper(), + "o": order.order_type.name.upper(), + "f": "GTC", + "q": str(order.amount), + "p": str(order.price), + "P": "0.00000000", + "F": "0.00000000", + "g": -1, + "C": "", + "x": "TRADE", + "X": "FILLED", + "r": "NONE", + "i": order.exchange_order_id, + "l": str(order.amount), + "z": str(order.amount), + "L": str(order.price), + "n": str(self.expected_fill_fee.flat_fees[0].amount), + "N": self.expected_fill_fee.flat_fees[0].token, + "T": 1499405658657, + "t": 1, + "I": 8641984, + "w": True, + "m": False, + "M": False, + "O": 1499405658657, + "Z": "10050.00000000", + "Y": "10050.00000000", + "Q": "10000.00000000" + } + + def trade_event_for_full_fill_websocket_update(self, order: InFlightOrder): + return None + + @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 = { + "symbol": self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), + "id": 28457, + "orderId": int(order.exchange_order_id), + "orderListId": -1, + "price": "9999", + "qty": "1", + "quoteQty": "48.000012", + "commission": "10.10000000", + "commissionAsset": self.quote_asset, + "time": 1499865549590, + "isBuyer": True, + "isMaker": False, + "isBestMatch": True + } + + trade_fill_non_tracked_order = { + "symbol": self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), + "id": 30000, + "orderId": 99999, + "orderListId": -1, + "price": "4.00000100", + "qty": "12.00000000", + "quoteQty": "48.000012", + "commission": "10.10000000", + "commissionAsset": "BNB", + "time": 1499865549590, + "isBuyer": True, + "isMaker": False, + "isBestMatch": True + } + + 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["commissionAsset"], Decimal(trade_fill["commission"]))], + 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["commissionAsset"], + Decimal(trade_fill_non_tracked_order["commission"]))], + 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_genius_yield_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 = { + "symbol": self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), + "id": 30000, + "orderId": 99999, + "orderListId": -1, + "price": "4.00000100", + "qty": "12.00000000", + "quoteQty": "48.000012", + "commission": "10.10000000", + "commissionAsset": "BNB", + "time": 1499865549590, + "isBuyer": True, + "isMaker": False, + "isBestMatch": True + } + + 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["commissionAsset"], + Decimal(trade_fill_non_tracked_order["commission"]))], + 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 = { + "symbol": self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), + "orderId": int(order.exchange_order_id), + "orderListId": -1, + "clientOrderId": order.client_order_id, + "price": "10000.0", + "origQty": "1.0", + "executedQty": "0.0", + "cummulativeQuoteQty": "0.0", + "status": "REJECTED", + "timeInForce": "GTC", + "type": "LIMIT", + "side": "BUY", + "stopPrice": "0.0", + "icebergQty": "0.0", + "time": 1499827319559, + "updateTime": 1499827319559, + "isWorking": True, + "origQuoteOrderQty": "10000.000000" + } + + 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(self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), request_params["symbol"]) + self.assertEqual(order.client_order_id, request_params["origClientOrderId"]) + + 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['updateTime'] * 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 = { + "e": "executionReport", + "E": 1499405658658, + "s": self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), + "c": order.client_order_id, + "S": "BUY", + "o": "LIMIT", + "f": "GTC", + "q": "1.00000000", + "p": "1000.00000000", + "P": "0.00000000", + "F": "0.00000000", + "g": -1, + "C": "", + "x": "REJECTED", + "X": "REJECTED", + "r": "NONE", + "i": int(order.exchange_order_id), + "l": "0.00000000", + "z": "0.00000000", + "L": "0.00000000", + "n": "0", + "N": None, + "T": 1499405658657, + "t": 1, + "I": 8641984, + "w": True, + "m": False, + "M": False, + "O": 1499405658657, + "Z": "0.00000000", + "Y": "0.00000000", + "Q": "0.00000000" + } + + 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.genius_yield.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.genius_yield.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.genius_yield.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.genius_yield.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 test_format_trading_rules__min_notional_present(self): + trading_rules = [{ + "symbol": "COINALPHAHBOT", + "baseAssetPrecision": 8, + "status": "TRADING", + "quotePrecision": 8, + "orderTypes": ["LIMIT", "MARKET"], + "filters": [ + { + "filterType": "PRICE_FILTER", + "minPrice": "0.00000100", + "maxPrice": "100000.00000000", + "tickSize": "0.00000100" + }, { + "filterType": "LOT_SIZE", + "minQty": "0.00100000", + "maxQty": "100000.00000000", + "stepSize": "0.00100000" + }, { + "filterType": "MIN_NOTIONAL", + "minNotional": "0.00100000" + } + ], + "permissionSets": [[ + "SPOT" + ]] + }] + exchange_info = {"symbols": trading_rules} + + result = self.async_run_with_timeout(self.exchange._format_trading_rules(exchange_info)) + + self.assertEqual(result[0].min_notional_size, Decimal("0.00100000")) + + def test_format_trading_rules__notional_but_no_min_notional_present(self): + trading_rules = [{ + "symbol": "COINALPHAHBOT", + "baseAssetPrecision": 8, + "status": "TRADING", + "quotePrecision": 8, + "orderTypes": ["LIMIT", "MARKET"], + "filters": [ + { + "filterType": "PRICE_FILTER", + "minPrice": "0.00000100", + "maxPrice": "100000.00000000", + "tickSize": "0.00000100" + }, { + "filterType": "LOT_SIZE", + "minQty": "0.00100000", + "maxQty": "100000.00000000", + "stepSize": "0.00100000" + }, { + "filterType": "NOTIONAL", + "minNotional": "10.00000000", + "applyMinToMarket": False, + "maxNotional": "10000.00000000", + "applyMaxToMarket": False, + "avgPriceMins": 5 + } + ], + "permissionSets": [[ + "SPOT" + ]] + }] + exchange_info = {"symbols": trading_rules} + + result = self.async_run_with_timeout(self.exchange._format_trading_rules(exchange_info)) + + self.assertEqual(result[0].min_notional_size, Decimal("10")) + + 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-MBX-APIKEY", request_headers) + self.assertEqual("testAPIKey", request_headers["X-MBX-APIKEY"]) + + def _order_cancelation_request_successful_mock_response(self, order: InFlightOrder) -> Any: + return { + "symbol": self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), + "origClientOrderId": order.exchange_order_id or "dummyOrdId", + "orderId": 4, + "orderListId": -1, + "clientOrderId": order.client_order_id, + "price": str(order.price), + "origQty": str(order.amount), + "executedQty": str(Decimal("0")), + "cummulativeQuoteQty": str(Decimal("0")), + "status": "CANCELED", + "timeInForce": "GTC", + "type": "LIMIT", + "side": "BUY" + } + + def _order_status_request_completely_filled_mock_response(self, order: InFlightOrder) -> Any: + return { + "symbol": self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), + "orderId": order.exchange_order_id, + "orderListId": -1, + "clientOrderId": order.client_order_id, + "price": str(order.price), + "origQty": str(order.amount), + "executedQty": str(order.amount), + "cummulativeQuoteQty": str(order.price + Decimal(2)), + "status": "FILLED", + "timeInForce": "GTC", + "type": "LIMIT", + "side": "BUY", + "stopPrice": "0.0", + "icebergQty": "0.0", + "time": 1499827319559, + "updateTime": 1499827319559, + "isWorking": True, + "origQuoteOrderQty": str(order.price * order.amount) + } + + def _order_status_request_canceled_mock_response(self, order: InFlightOrder) -> Any: + return { + "symbol": self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), + "orderId": order.exchange_order_id, + "orderListId": -1, + "clientOrderId": order.client_order_id, + "price": str(order.price), + "origQty": str(order.amount), + "executedQty": "0.0", + "cummulativeQuoteQty": "10000.0", + "status": "CANCELED", + "timeInForce": "GTC", + "type": order.order_type.name.upper(), + "side": order.trade_type.name.upper(), + "stopPrice": "0.0", + "icebergQty": "0.0", + "time": 1499827319559, + "updateTime": 1499827319559, + "isWorking": True, + "origQuoteOrderQty": str(order.price * order.amount) + } + + def _order_status_request_open_mock_response(self, order: InFlightOrder) -> Any: + return { + "symbol": self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), + "orderId": order.exchange_order_id, + "orderListId": -1, + "clientOrderId": order.client_order_id, + "price": str(order.price), + "origQty": str(order.amount), + "executedQty": "0.0", + "cummulativeQuoteQty": "10000.0", + "status": "NEW", + "timeInForce": "GTC", + "type": order.order_type.name.upper(), + "side": order.trade_type.name.upper(), + "stopPrice": "0.0", + "icebergQty": "0.0", + "time": 1499827319559, + "updateTime": 1499827319559, + "isWorking": True, + "origQuoteOrderQty": str(order.price * order.amount) + } + + def _order_status_request_partially_filled_mock_response(self, order: InFlightOrder) -> Any: + return { + "symbol": self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), + "orderId": order.exchange_order_id, + "orderListId": -1, + "clientOrderId": order.client_order_id, + "price": str(order.price), + "origQty": str(order.amount), + "executedQty": str(order.amount), + "cummulativeQuoteQty": str(self.expected_partial_fill_amount * order.price), + "status": "PARTIALLY_FILLED", + "timeInForce": "GTC", + "type": order.order_type.name.upper(), + "side": order.trade_type.name.upper(), + "stopPrice": "0.0", + "icebergQty": "0.0", + "time": 1499827319559, + "updateTime": 1499827319559, + "isWorking": True, + "origQuoteOrderQty": str(order.price * order.amount) + } + + def _order_fills_request_partial_fill_mock_response(self, order: InFlightOrder): + return [ + { + "symbol": self.exchange_symbol_for_tokens(order.base_asset, order.quote_asset), + "id": self.expected_fill_trade_id, + "orderId": int(order.exchange_order_id), + "orderListId": -1, + "price": str(self.expected_partial_fill_price), + "qty": str(self.expected_partial_fill_amount), + "quoteQty": str(self.expected_partial_fill_amount * self.expected_partial_fill_price), + "commission": str(self.expected_fill_fee.flat_fees[0].amount), + "commissionAsset": self.expected_fill_fee.flat_fees[0].token, + "time": 1499865549590, + "isBuyer": True, + "isMaker": False, + "isBestMatch": True + } + ] + + def _order_fills_request_full_fill_mock_response(self, order: InFlightOrder): + return [ + { + "symbol": self.exchange_symbol_for_tokens(order.base_asset, order.quote_asset), + "id": self.expected_fill_trade_id, + "orderId": int(order.exchange_order_id), + "orderListId": -1, + "price": str(order.price), + "qty": str(order.amount), + "quoteQty": str(order.amount * order.price), + "commission": str(self.expected_fill_fee.flat_fees[0].amount), + "commissionAsset": self.expected_fill_fee.flat_fees[0].token, + "time": 1499865549590, + "isBuyer": True, + "isMaker": False, + "isBestMatch": True + } + ] diff --git a/test/hummingbot/connector/exchange/genius_yield/test_genius_yield_order_book.py b/test/hummingbot/connector/exchange/genius_yield/test_genius_yield_order_book.py new file mode 100644 index 0000000000..f3d55150b0 --- /dev/null +++ b/test/hummingbot/connector/exchange/genius_yield/test_genius_yield_order_book.py @@ -0,0 +1,103 @@ +from unittest import TestCase + +from hummingbot.connector.exchange.genius_yield.genius_yield_order_book import GeniusYieldOrderBook +from hummingbot.core.data_type.order_book_message import OrderBookMessageType + + +class GeniusYieldOrderBookTests(TestCase): + + def test_snapshot_message_from_exchange(self): + snapshot_message = GeniusYieldOrderBook.snapshot_message_from_exchange( + msg={ + "lastUpdateId": 1, + "bids": [ + ["4.00000000", "431.00000000"] + ], + "asks": [ + ["4.00000200", "12.00000000"] + ] + }, + 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(1, snapshot_message.update_id) + self.assertEqual(-1, snapshot_message.trade_id) + self.assertEqual(1, len(snapshot_message.bids)) + self.assertEqual(4.0, snapshot_message.bids[0].price) + self.assertEqual(431.0, snapshot_message.bids[0].amount) + self.assertEqual(1, snapshot_message.bids[0].update_id) + self.assertEqual(1, len(snapshot_message.asks)) + self.assertEqual(4.000002, snapshot_message.asks[0].price) + self.assertEqual(12.0, snapshot_message.asks[0].amount) + self.assertEqual(1, snapshot_message.asks[0].update_id) + + def test_diff_message_from_exchange(self): + diff_msg = GeniusYieldOrderBook.diff_message_from_exchange( + msg={ + "e": "depthUpdate", + "E": 123456789, + "s": "COINALPHAHBOT", + "U": 1, + "u": 2, + "b": [ + [ + "0.0024", + "10" + ] + ], + "a": [ + [ + "0.0026", + "100" + ] + ] + }, + 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(2, diff_msg.update_id) + self.assertEqual(1, diff_msg.first_update_id) + self.assertEqual(-1, diff_msg.trade_id) + self.assertEqual(1, len(diff_msg.bids)) + self.assertEqual(0.0024, diff_msg.bids[0].price) + self.assertEqual(10.0, diff_msg.bids[0].amount) + self.assertEqual(2, diff_msg.bids[0].update_id) + self.assertEqual(1, len(diff_msg.asks)) + self.assertEqual(0.0026, diff_msg.asks[0].price) + self.assertEqual(100.0, diff_msg.asks[0].amount) + self.assertEqual(2, diff_msg.asks[0].update_id) + + def test_trade_message_from_exchange(self): + trade_update = { + "e": "trade", + "E": 1234567890123, + "s": "COINALPHAHBOT", + "t": 12345, + "p": "0.001", + "q": "100", + "b": 88, + "a": 50, + "T": 123456785, + "m": True, + "M": True + } + + trade_message = GeniusYieldOrderBook.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(1234567890.123, trade_message.timestamp) + self.assertEqual(-1, trade_message.update_id) + self.assertEqual(-1, trade_message.first_update_id) + self.assertEqual(12345, trade_message.trade_id) diff --git a/test/hummingbot/connector/exchange/genius_yield/test_genius_yield_utils.py b/test/hummingbot/connector/exchange/genius_yield/test_genius_yield_utils.py new file mode 100644 index 0000000000..38fb15d618 --- /dev/null +++ b/test/hummingbot/connector/exchange/genius_yield/test_genius_yield_utils.py @@ -0,0 +1,44 @@ +import unittest + +from hummingbot.connector.exchange.genius_yield import genius_yield_utils as utils + + +class GeniusYieldUtilTestCases(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", + "permissionSets": [["MARGIN"]], + } + + self.assertFalse(utils.is_exchange_information_valid(invalid_info_1)) + + invalid_info_2 = { + "status": "BREAK", + "permissionSets": [["SPOT"]], + } + + self.assertFalse(utils.is_exchange_information_valid(invalid_info_2)) + + invalid_info_3 = { + "status": "TRADING", + "permissionSets": [["MARGIN"]], + } + + self.assertFalse(utils.is_exchange_information_valid(invalid_info_3)) + + invalid_info_4 = { + "status": "TRADING", + "permissionSets": [["SPOT"]], + } + + self.assertTrue(utils.is_exchange_information_valid(invalid_info_4)) diff --git a/test/hummingbot/connector/exchange/genius_yield/test_genius_yield_web_utils.py b/test/hummingbot/connector/exchange/genius_yield/test_genius_yield_web_utils.py new file mode 100644 index 0000000000..5b848cc954 --- /dev/null +++ b/test/hummingbot/connector/exchange/genius_yield/test_genius_yield_web_utils.py @@ -0,0 +1,19 @@ +import unittest + +import hummingbot.connector.exchange.genius_yield.genius_yield_constants as CONSTANTS +from hummingbot.connector.exchange.genius_yield import genius_yield_web_utils as web_utils + + +class GeniusYieldUtilTestCases(unittest.TestCase): + + def test_public_rest_url(self): + path_url = "/TEST_PATH" + domain = "com" + 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 = "com" + 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))