diff --git a/Makefile b/Makefile index d0999474ba..7b59ca52fa 100644 --- a/Makefile +++ b/Makefile @@ -16,7 +16,6 @@ test: --exclude-dir="test/mock" \ --exclude-dir="test/hummingbot/connector/gateway/amm" \ --exclude-dir="test/hummingbot/connector/exchange/coinbase_pro" \ - --exclude-dir="test/hummingbot/connector/exchange/kraken" \ --exclude-dir="test/hummingbot/connector/exchange/hitbtc" \ --exclude-dir="test/hummingbot/connector/exchange/foxbit" \ --exclude-dir="test/hummingbot/connector/gateway/clob_spot/data_sources/dexalot" \ diff --git a/hummingbot/connector/exchange/kraken/kraken_api_order_book_data_source.py b/hummingbot/connector/exchange/kraken/kraken_api_order_book_data_source.py index 7c165667fd..89fbc329b1 100755 --- a/hummingbot/connector/exchange/kraken/kraken_api_order_book_data_source.py +++ b/hummingbot/connector/exchange/kraken/kraken_api_order_book_data_source.py @@ -1,290 +1,168 @@ import asyncio -import logging import time -from typing import Any, Dict, List, Optional +from typing import TYPE_CHECKING, Any, Dict, List, Optional -import pandas as pd - -from hummingbot.connector.exchange.kraken import kraken_constants as CONSTANTS +from hummingbot.connector.exchange.kraken import kraken_constants as CONSTANTS, kraken_web_utils as web_utils from hummingbot.connector.exchange.kraken.kraken_order_book import KrakenOrderBook from hummingbot.connector.exchange.kraken.kraken_utils import ( - build_api_factory, - build_rate_limits_by_tier, convert_from_exchange_trading_pair, convert_to_exchange_trading_pair, ) -from hummingbot.core.api_throttler.async_throttler import AsyncThrottler from hummingbot.core.data_type.order_book import OrderBook 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.utils.async_utils import safe_gather -from hummingbot.core.web_assistant.connections.data_types import RESTMethod, RESTRequest, WSJSONRequest +from hummingbot.core.web_assistant.connections.data_types import RESTMethod, WSJSONRequest from hummingbot.core.web_assistant.rest_assistant import RESTAssistant from hummingbot.core.web_assistant.web_assistants_factory import WebAssistantsFactory from hummingbot.core.web_assistant.ws_assistant import WSAssistant from hummingbot.logger import HummingbotLogger +if TYPE_CHECKING: + from hummingbot.connector.exchange.kraken.kraken_exchange import KrakenExchange + class KrakenAPIOrderBookDataSource(OrderBookTrackerDataSource): MESSAGE_TIMEOUT = 30.0 - PING_TIMEOUT = 10.0 - - _kraobds_logger: Optional[HummingbotLogger] = None - @classmethod - def logger(cls) -> HummingbotLogger: - if cls._kraobds_logger is None: - cls._kraobds_logger = logging.getLogger(__name__) - return cls._kraobds_logger + # PING_TIMEOUT = 10.0 def __init__(self, - throttler: Optional[AsyncThrottler] = None, - trading_pairs: List[str] = None, - api_factory: Optional[WebAssistantsFactory] = None): + trading_pairs: List[str], + connector: 'KrakenExchange', + api_factory: WebAssistantsFactory, + # throttler: Optional[AsyncThrottler] = None + ): super().__init__(trading_pairs) - self._throttler = throttler or self._get_throttler_instance() - self._api_factory = api_factory or build_api_factory(throttler=throttler) + self._connector = connector + self._api_factory = api_factory self._rest_assistant = None self._ws_assistant = None self._order_book_create_function = lambda: OrderBook() - @classmethod - def _get_throttler_instance(cls) -> AsyncThrottler: - throttler = AsyncThrottler(build_rate_limits_by_tier()) - return throttler + _kraobds_logger: Optional[HummingbotLogger] = None async def _get_rest_assistant(self) -> RESTAssistant: if self._rest_assistant is None: self._rest_assistant = await self._api_factory.get_rest_assistant() return self._rest_assistant - @classmethod - async def get_last_traded_prices( - cls, trading_pairs: List[str], throttler: Optional[AsyncThrottler] = None - ) -> Dict[str, float]: - throttler = throttler or cls._get_throttler_instance() - tasks = [cls._get_last_traded_price(t_pair, throttler) for t_pair in trading_pairs] - results = await safe_gather(*tasks) - return {t_pair: result for t_pair, result in zip(trading_pairs, results)} - - @classmethod - async def _get_last_traded_price(cls, trading_pair: str, throttler: AsyncThrottler) -> float: - url = ( - f"{CONSTANTS.BASE_URL}{CONSTANTS.TICKER_PATH_URL}" - f"?pair={convert_to_exchange_trading_pair(trading_pair)}" - ) - - request = RESTRequest( - method=RESTMethod.GET, - url=url - ) - rest_assistant = await build_api_factory(throttler=throttler).get_rest_assistant() - - async with throttler.execute_task(CONSTANTS.TICKER_PATH_URL): - resp = await rest_assistant.call(request) - resp_json = await resp.json() - record = list(resp_json["result"].values())[0] - return float(record["c"][0]) + 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) - @classmethod - async def get_snapshot( - cls, - rest_assistant: RESTAssistant, - trading_pair: str, - limit: int = 1000, - throttler: Optional[AsyncThrottler] = None, - ) -> Dict[str, Any]: - throttler = throttler or cls._get_throttler_instance() - original_trading_pair: str = trading_pair - if limit != 0: - params = { - "count": str(limit), - "pair": convert_to_exchange_trading_pair(trading_pair) - } - else: - params = {"pair": convert_to_exchange_trading_pair(trading_pair)} - async with throttler.execute_task(CONSTANTS.SNAPSHOT_PATH_URL): - url = f"{CONSTANTS.BASE_URL}{CONSTANTS.SNAPSHOT_PATH_URL}" - - request = RESTRequest( - method=RESTMethod.GET, - url=url, - params=params - ) - - response = await rest_assistant.call(request) - - if response.status != 200: - raise IOError(f"Error fetching Kraken market snapshot for {original_trading_pair}. " - f"HTTP status is {response.status}.") - response_json = await response.json() - if len(response_json["error"]) > 0: - raise IOError(f"Error fetching Kraken market snapshot for {original_trading_pair}. " - f"Error is {response_json['error']}.") - data: Dict[str, Any] = next(iter(response_json["result"].values())) - data = {"trading_pair": trading_pair, **data} - data["latest_update"] = max([*map(lambda x: x[2], data["bids"] + data["asks"])], default=0.) - - return data - - async def get_new_order_book(self, trading_pair: str) -> OrderBook: - rest_assistant = await self._get_rest_assistant() - snapshot: Dict[str, Any] = await self.get_snapshot( - rest_assistant, trading_pair, limit=1000, throttler=self._throttler - ) + async def _order_book_snapshot(self, trading_pair: str) -> OrderBook: + snapshot: Dict[str, Any] = await self._request_order_book_snapshot(trading_pair) snapshot_timestamp: float = time.time() snapshot_msg: OrderBookMessage = KrakenOrderBook.snapshot_message_from_exchange( snapshot, snapshot_timestamp, metadata={"trading_pair": trading_pair} ) - order_book: OrderBook = self.order_book_create_function() - order_book.apply_snapshot(snapshot_msg.bids, snapshot_msg.asks, snapshot_msg.update_id) - return order_book - - @classmethod - async def fetch_trading_pairs(cls, throttler: Optional[AsyncThrottler] = None) -> List[str]: - throttler = throttler or cls._get_throttler_instance() - try: - async with throttler.execute_task(CONSTANTS.ASSET_PAIRS_PATH_URL): - url = f"{CONSTANTS.BASE_URL}{CONSTANTS.ASSET_PAIRS_PATH_URL}" - request = RESTRequest( - method=RESTMethod.GET, - url=url - ) - rest_assistant = await build_api_factory(throttler=throttler).get_rest_assistant() - response = await rest_assistant.call(request, timeout=5) + return snapshot_msg - if response.status == 200: - data: Dict[str, Any] = await response.json() - raw_pairs = data.get("result", []) - converted_pairs: List[str] = [] - for pair, details in raw_pairs.items(): - if "." not in pair: - try: - wsname = details["wsname"] # pair in format BASE/QUOTE - converted_pairs.append(convert_from_exchange_trading_pair(wsname)) - except IOError: - pass - return [item for item in converted_pairs] - except Exception: - pass - # Do nothing if the request fails -- there will be no autocomplete for kraken trading pairs - return [] - - async def listen_for_trades(self, ev_loop: asyncio.AbstractEventLoop, output: asyncio.Queue): - while True: - try: - ws_message: str = await self.get_ws_subscription_message("trade") - - async with self._throttler.execute_task(CONSTANTS.WS_CONNECTION_LIMIT_ID): - ws: WSAssistant = await self._api_factory.get_ws_assistant() - await ws.connect(ws_url=CONSTANTS.WS_URL, ping_timeout=self.PING_TIMEOUT) - - await ws.send(ws_message) - async for ws_response in ws.iter_messages(): - msg = ws_response.data - if not (type(msg) is dict and "event" in msg.keys() and - msg["event"] in ["heartbeat", "systemStatus", "subscriptionStatus"]): - trades = [ - {"pair": convert_from_exchange_trading_pair(msg[-1]), "trade": trade} - for trade in msg[1] - ] - for trade in trades: - trade_msg: OrderBookMessage = KrakenOrderBook.trade_message_from_exchange(trade) - output.put_nowait(trade_msg) - ws.disconnect() - except asyncio.CancelledError: - raise - except Exception: - self.logger().error("Unexpected error with WebSocket connection. Retrying after 30 seconds...", - exc_info=True) - await asyncio.sleep(30.0) - - async def listen_for_order_book_diffs(self, ev_loop: asyncio.BaseEventLoop, output: asyncio.Queue): - while True: - try: - ws_message: str = await self.get_ws_subscription_message("book") - async with self._throttler.execute_task(CONSTANTS.WS_CONNECTION_LIMIT_ID): - ws: WSAssistant = await self._api_factory.get_ws_assistant() - await ws.connect(ws_url=CONSTANTS.WS_URL, ping_timeout=self.PING_TIMEOUT) + 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. - await ws.send(ws_message) - async for ws_response in ws.iter_messages(): - msg = ws_response.data - if not (type(msg) is dict and "event" in msg.keys() and - msg["event"] in ["heartbeat", "systemStatus", "subscriptionStatus"]): - msg_dict = {"trading_pair": convert_from_exchange_trading_pair(msg[-1]), - "asks": msg[1].get("a", []) or msg[1].get("as", []) or [], - "bids": msg[1].get("b", []) or msg[1].get("bs", []) or []} - msg_dict["update_id"] = max( - [*map(lambda x: float(x[2]), msg_dict["bids"] + msg_dict["asks"])], default=0. - ) - if "as" in msg[1] and "bs" in msg[1]: - order_book_message: OrderBookMessage = ( - KrakenOrderBook.snapshot_ws_message_from_exchange(msg_dict, time.time()) - ) - else: - order_book_message: OrderBookMessage = KrakenOrderBook.diff_message_from_exchange( - msg_dict, time.time()) - output.put_nowait(order_book_message) - ws.disconnect() - except asyncio.CancelledError: - raise - except Exception: - self.logger().error("Unexpected error with WebSocket connection. Retrying after 30 seconds...", - exc_info=True) - await asyncio.sleep(30.0) + :param trading_pair: the trading pair for which the order book will be retrieved - async def listen_for_order_book_snapshots(self, ev_loop: asyncio.BaseEventLoop, output: asyncio.Queue): - rest_assistant = await self._get_rest_assistant() - while True: - try: - for trading_pair in self._trading_pairs: - try: - snapshot: Dict[str, Any] = await self.get_snapshot( - rest_assistant, trading_pair, throttler=self._throttler - ) - snapshot_timestamp: float = time.time() - snapshot_msg: OrderBookMessage = KrakenOrderBook.snapshot_message_from_exchange( - snapshot, - snapshot_timestamp, - metadata={"trading_pair": trading_pair} - ) - output.put_nowait(snapshot_msg) - self.logger().debug(f"Saved order book snapshot for {trading_pair}") - await asyncio.sleep(5.0) - except asyncio.CancelledError: - raise - except Exception: - self.logger().error("Unexpected error. ", exc_info=True) - await asyncio.sleep(5.0) - this_hour: pd.Timestamp = pd.Timestamp.utcnow().replace(minute=0, second=0, microsecond=0) - next_hour: pd.Timestamp = this_hour + pd.Timedelta(hours=1) - delta: float = next_hour.timestamp() - time.time() - await asyncio.sleep(delta) - except asyncio.CancelledError: - raise - except Exception: - self.logger().error("Unexpected error. ", exc_info=True) - await asyncio.sleep(5.0) + :return: the response from the exchange (JSON dictionary) + """ + params = { + "pair": await self._connector.exchange_symbol_associated_to_pair(trading_pair=trading_pair) + } + + rest_assistant = await self._api_factory.get_rest_assistant() + response_json = await rest_assistant.execute_request( + url=web_utils.public_rest_url(path_url=CONSTANTS.SNAPSHOT_PATH_URL), + params=params, + method=RESTMethod.GET, + throttler_limit_id=CONSTANTS.SNAPSHOT_PATH_URL, + ) + if len(response_json["error"]) > 0: + raise IOError(f"Error fetching Kraken market snapshot for {trading_pair}. " + f"Error is {response_json['error']}.") + data: Dict[str, Any] = next(iter(response_json["result"].values())) + data = {"trading_pair": trading_pair, **data} + data["latest_update"] = max([*map(lambda x: x[2], data["bids"] + data["asks"])], default=0.) + return data + + async def _subscribe_channels(self, ws: WSAssistant): + """ + Subscribes to the trade events and diff orders events through the provided websocket connection. - async def get_ws_subscription_message(self, subscription_type: str): - trading_pairs: List[str] = [] - for tp in self._trading_pairs: - trading_pairs.append(convert_to_exchange_trading_pair(tp, '/')) + :param ws: the websocket assistant used to connect to the exchange + """ + try: + trading_pairs: List[str] = [] + for tp in self._trading_pairs: + # trading_pairs.append(convert_to_exchange_trading_pair(tp, '/')) + symbol = convert_to_exchange_trading_pair(tp, '/') + trading_pairs.append(symbol) + trades_payload = { + "event": "subscribe", + "pair": trading_pairs, + "subscription": {"name": 'trade'}, + } + subscribe_trade_request: WSJSONRequest = WSJSONRequest(payload=trades_payload) - ws_message: WSJSONRequest = WSJSONRequest({ - "event": "subscribe", - "pair": trading_pairs, - "subscription": {"name": subscription_type, "depth": 1000}}) + order_book_payload = { + "event": "subscribe", + "pair": trading_pairs, + "subscription": {"name": 'book', "depth": 1000}, + } + subscribe_orderbook_request: WSJSONRequest = WSJSONRequest(payload=order_book_payload) - return ws_message + await ws.send(subscribe_trade_request) + await ws.send(subscribe_orderbook_request) - async def listen_for_subscriptions(self): - """ - Connects to the trade events and order diffs websocket endpoints and listens to the messages sent by the - exchange. Each message is stored in its own queue. - """ - # This connector does not use this base class method and needs a refactoring - pass + self.logger().info("Subscribed to public order book and trade channels...") + except asyncio.CancelledError: + raise + except Exception: + self.logger().error("Unexpected error occurred subscribing to order book data streams.") + raise + + def _channel_originating_message(self, event_message) -> str: + channel = "" + if type(event_message) is list: + channel = self._trade_messages_queue_key if event_message[-2] == CONSTANTS.TRADE_EVENT_TYPE \ + else self._diff_messages_queue_key + else: + if event_message.get("errorMessage") is not None: + err_msg = event_message.get("errorMessage") + raise IOError(f"Error event received from the server ({err_msg})") + return channel + + async def _connected_websocket_assistant(self) -> WSAssistant: + ws: WSAssistant = await self._api_factory.get_ws_assistant() + await ws.connect(ws_url=CONSTANTS.WS_URL, + ping_timeout=CONSTANTS.PING_TIMEOUT) + return ws + + async def _parse_trade_message(self, raw_message: Dict[str, Any], message_queue: asyncio.Queue): + + trades = [ + {"pair": convert_from_exchange_trading_pair(raw_message[-1]), "trade": trade} + for trade in raw_message[1] + ] + for trade in trades: + trade_msg: OrderBookMessage = KrakenOrderBook.trade_message_from_exchange(trade) + message_queue.put_nowait(trade_msg) + + async def _parse_order_book_diff_message(self, raw_message: Dict[str, Any], message_queue: asyncio.Queue): + msg_dict = {"trading_pair": convert_from_exchange_trading_pair(raw_message[-1]), + "asks": raw_message[1].get("a", []) or raw_message[1].get("as", []) or [], + "bids": raw_message[1].get("b", []) or raw_message[1].get("bs", []) or []} + msg_dict["update_id"] = max( + [*map(lambda x: float(x[2]), msg_dict["bids"] + msg_dict["asks"])], default=0. + ) + if "as" in raw_message[1] and "bs" in raw_message[1]: + order_book_message: OrderBookMessage = ( + KrakenOrderBook.snapshot_ws_message_from_exchange(msg_dict, time.time()) + ) + else: + order_book_message: OrderBookMessage = KrakenOrderBook.diff_message_from_exchange( + msg_dict, time.time()) + message_queue.put_nowait(order_book_message) diff --git a/hummingbot/connector/exchange/kraken/kraken_api_user_stream_data_source.py b/hummingbot/connector/exchange/kraken/kraken_api_user_stream_data_source.py index c3484c2d7e..9f436560cf 100755 --- a/hummingbot/connector/exchange/kraken/kraken_api_user_stream_data_source.py +++ b/hummingbot/connector/exchange/kraken/kraken_api_user_stream_data_source.py @@ -1,48 +1,33 @@ import asyncio -import logging -from typing import Any, Dict, Optional +from typing import TYPE_CHECKING, Any, Dict, Optional from hummingbot.connector.exchange.kraken import kraken_constants as CONSTANTS -from hummingbot.connector.exchange.kraken.kraken_auth import KrakenAuth -from hummingbot.connector.exchange.kraken.kraken_order_book import KrakenOrderBook -from hummingbot.connector.exchange.kraken.kraken_utils import build_api_factory -from hummingbot.core.api_throttler.async_throttler import AsyncThrottler from hummingbot.core.data_type.user_stream_tracker_data_source import UserStreamTrackerDataSource -from hummingbot.core.web_assistant.connections.data_types import RESTMethod, RESTRequest, WSJSONRequest -from hummingbot.core.web_assistant.rest_assistant import RESTAssistant +from hummingbot.core.web_assistant.connections.data_types import WSJSONRequest from hummingbot.core.web_assistant.web_assistants_factory import WebAssistantsFactory from hummingbot.core.web_assistant.ws_assistant import WSAssistant from hummingbot.logger import HummingbotLogger -MESSAGE_TIMEOUT = 3.0 -PING_TIMEOUT = 5.0 +if TYPE_CHECKING: + from hummingbot.connector.exchange.kraken.kraken_exchange import KrakenExchange class KrakenAPIUserStreamDataSource(UserStreamTrackerDataSource): - - _krausds_logger: Optional[HummingbotLogger] = None - - @classmethod - def logger(cls) -> HummingbotLogger: - if cls._krausds_logger is None: - cls._krausds_logger = logging.getLogger(__name__) - return cls._krausds_logger + _logger: Optional[HummingbotLogger] = None def __init__(self, - throttler: AsyncThrottler, - kraken_auth: KrakenAuth, + connector: 'KrakenExchange', api_factory: Optional[WebAssistantsFactory] = None): - self._throttler = throttler - self._api_factory = api_factory or build_api_factory(throttler=throttler) - self._rest_assistant = None - self._ws_assistant = None - self._kraken_auth: KrakenAuth = kraken_auth - self._current_auth_token: Optional[str] = None + super().__init__() + self._api_factory = api_factory + self._connector = connector + self._current_auth_token: Optional[str] = None - @property - def order_book_class(self): - return KrakenOrderBook + async def _connected_websocket_assistant(self) -> WSAssistant: + ws: WSAssistant = await self._api_factory.get_ws_assistant() + await ws.connect(ws_url=CONSTANTS.WS_AUTH_URL, ping_timeout=CONSTANTS.PING_TIMEOUT) + return ws @property def last_recv_time(self): @@ -51,76 +36,63 @@ def last_recv_time(self): else: return self._ws_assistant.last_recv_time - async def _get_rest_assistant(self) -> RESTAssistant: - if self._rest_assistant is None: - self._rest_assistant = await self._api_factory.get_rest_assistant() - return self._rest_assistant - async def get_auth_token(self) -> str: - api_auth: Dict[str, Any] = self._kraken_auth.generate_auth_dict(uri=CONSTANTS.GET_TOKEN_PATH_URL) - - url = f"{CONSTANTS.BASE_URL}{CONSTANTS.GET_TOKEN_PATH_URL}" - - request = RESTRequest( - method=RESTMethod.POST, - url=url, - headers=api_auth["headers"], - data=api_auth["postDict"] - ) - rest_assistant = await self._get_rest_assistant() - - async with self._throttler.execute_task(CONSTANTS.GET_TOKEN_PATH_URL): - response = await rest_assistant.call(request=request, timeout=100) - if response.status != 200: - raise IOError(f"Error fetching Kraken user stream listen key. HTTP status is {response.status}.") - - try: - response_json: Dict[str, Any] = await response.json() - except Exception: - raise IOError(f"Error parsing data from {url}.") - - err = response_json["error"] - if "EAPI:Invalid nonce" in err: - self.logger().error(f"Invalid nonce error from {url}. " + - "Please ensure your Kraken API key nonce window is at least 10, " + - "and if needed reset your API key.") - raise IOError({"error": response_json}) - - return response_json["result"]["token"] - - async def listen_for_user_stream(self, output: asyncio.Queue): - ws = None - while True: - try: - async with self._throttler.execute_task(CONSTANTS.WS_CONNECTION_LIMIT_ID): - ws: WSAssistant = await self._api_factory.get_ws_assistant() - await ws.connect(ws_url=CONSTANTS.WS_AUTH_URL, ping_timeout=PING_TIMEOUT) - - if self._current_auth_token is None: - self._current_auth_token = await self.get_auth_token() - - for subscription_type in ["openOrders", "ownTrades"]: - subscribe_request: WSJSONRequest = WSJSONRequest({ - "event": "subscribe", - "subscription": { - "name": subscription_type, - "token": self._current_auth_token - } - }) - await ws.send(subscribe_request) - - async for ws_response in ws.iter_messages(): - msg = ws_response.data - if not (type(msg) is dict and "event" in msg.keys() and - msg["event"] in ["heartbeat", "systemStatus", "subscriptionStatus"]): - output.put_nowait(msg) - except asyncio.CancelledError: - raise - except Exception: - self.logger().error("Unexpected error with Kraken WebSocket connection. " - "Retrying after 30 seconds...", exc_info=True) - self._current_auth_token = None - await asyncio.sleep(30.0) - finally: - if ws is not None: - await ws.disconnect() + try: + response_json = await self._connector._api_post(path_url=CONSTANTS.GET_TOKEN_PATH_URL, params={}, + is_auth_required=True) + except Exception: + raise + return response_json["token"] + + async def _subscribe_channels(self, websocket_assistant: WSAssistant): + """ + Subscribes to order events and balance events. + + :param websocket_assistant: the websocket assistant used to connect to the exchange + """ + try: + + if self._current_auth_token is None: + self._current_auth_token = await self.get_auth_token() + + orders_change_payload = { + "event": "subscribe", + "subscription": { + "name": "openOrders", + "token": self._current_auth_token + } + } + subscribe_order_change_request: WSJSONRequest = WSJSONRequest(payload=orders_change_payload) + + trades_payload = { + "event": "subscribe", + "subscription": { + "name": "ownTrades", + "token": self._current_auth_token + } + } + subscribe_trades_request: WSJSONRequest = WSJSONRequest(payload=trades_payload) + + await websocket_assistant.send(subscribe_order_change_request) + await websocket_assistant.send(subscribe_trades_request) + + self.logger().info("Subscribed to private order changes and trades updates channels...") + except asyncio.CancelledError: + raise + except Exception: + self.logger().exception("Unexpected error occurred subscribing to user streams...") + raise + + async def _process_event_message(self, event_message: Dict[str, Any], queue: asyncio.Queue): + if type(event_message) is list and event_message[-2] in [ + CONSTANTS.USER_TRADES_ENDPOINT_NAME, + CONSTANTS.USER_ORDERS_ENDPOINT_NAME, + ]: + queue.put_nowait(event_message) + else: + if event_message.get("errorMessage") is not None: + err_msg = event_message.get("errorMessage") + raise IOError({ + "label": "WSS_ERROR", + "message": f"Error received via websocket - {err_msg}." + }) diff --git a/hummingbot/connector/exchange/kraken/kraken_auth.py b/hummingbot/connector/exchange/kraken/kraken_auth.py index 1359d6d8a3..574401a8f6 100755 --- a/hummingbot/connector/exchange/kraken/kraken_auth.py +++ b/hummingbot/connector/exchange/kraken/kraken_auth.py @@ -1,20 +1,48 @@ -from typing import ( - Optional, - Dict, - Any -) import base64 import hashlib import hmac -from hummingbot.connector.exchange.kraken.kraken_tracking_nonce import get_tracking_nonce +import json +import time +from typing import Any, Dict, Optional +from urllib.parse import urlparse +from hummingbot.connector.time_synchronizer import TimeSynchronizer +from hummingbot.core.web_assistant.auth import AuthBase +from hummingbot.core.web_assistant.connections.data_types import RESTRequest, WSRequest -class KrakenAuth: - def __init__(self, api_key: str, secret_key: str): + +class KrakenAuth(AuthBase): + _last_tracking_nonce: int = 0 + + 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 + + @classmethod + def get_tracking_nonce(self) -> str: + nonce = int(time.time()) + self._last_tracking_nonce = nonce if nonce > self._last_tracking_nonce else self._last_tracking_nonce + 1 + return str(self._last_tracking_nonce) + + async def rest_authenticate(self, request: RESTRequest) -> RESTRequest: + + data = json.loads(request.data) if request.data is not None else {} + _path = urlparse(request.url).path + + auth_dict: Dict[str, Any] = self._generate_auth_dict(_path, data) + request.headers = auth_dict["headers"] + request.data = auth_dict["postDict"] + return request + + async def ws_authenticate(self, request: WSRequest) -> WSRequest: + """ + This method is intended to configure a websocket request to be authenticated. Mexc does not use this + functionality + """ + return request # pass-through - def generate_auth_dict(self, uri: str, data: Optional[Dict[str, str]] = None) -> Dict[str, Any]: + def _generate_auth_dict(self, uri: str, data: Optional[Dict[str, str]] = None) -> Dict[str, Any]: """ Generates authentication signature and returns it in a dictionary :return: a dictionary of request info including the request signature and post data @@ -25,7 +53,7 @@ def generate_auth_dict(self, uri: str, data: Optional[Dict[str, str]] = None) -> # Variables (API method, nonce, and POST data) api_path: bytes = bytes(uri, 'utf-8') - api_nonce: str = get_tracking_nonce() + api_nonce: str = self.get_tracking_nonce() api_post: str = "nonce=" + api_nonce if data is not None: diff --git a/hummingbot/connector/exchange/kraken/kraken_constants.py b/hummingbot/connector/exchange/kraken/kraken_constants.py index f30dca5323..d8c00ffbc8 100644 --- a/hummingbot/connector/exchange/kraken/kraken_constants.py +++ b/hummingbot/connector/exchange/kraken/kraken_constants.py @@ -1,9 +1,14 @@ from enum import Enum -from typing import ( - Dict, - Tuple, -) -from hummingbot.core.api_throttler.data_types import RateLimit, LinkedLimitWeightPair +from typing import Dict, Tuple + +from hummingbot.core.api_throttler.data_types import LinkedLimitWeightPair, RateLimit +from hummingbot.core.data_type.in_flight_order import OrderState + +DEFAULT_DOMAIN = "kraken" +MAX_ORDER_ID_LEN = 32 +HBOT_ORDER_ID_PREFIX = "HBOT" + +MAX_ID_BIT_COUNT = 31 class KrakenAPITier(Enum): @@ -48,10 +53,29 @@ class KrakenAPITier(Enum): BALANCE_PATH_URL = "/0/private/Balance" OPEN_ORDERS_PATH_URL = "/0/private/OpenOrders" QUERY_ORDERS_PATH_URL = "/0/private/QueryOrders" +QUERY_TRADES_PATH_URL = "/0/private/QueryTrades" + + +UNKNOWN_ORDER_MESSAGE = "Unknown order" +# Order States +ORDER_STATE = { + "pending": OrderState.OPEN, + "open": OrderState.OPEN, + "closed": OrderState.FILLED, + "canceled": OrderState.CANCELED, + "expired": OrderState.FAILED, +} +ORDER_NOT_EXIST_ERROR_CODE = "Error fetching status update for the order" WS_URL = "wss://ws.kraken.com" WS_AUTH_URL = "wss://ws-auth.kraken.com/" +DIFF_EVENT_TYPE = "book" +TRADE_EVENT_TYPE = "trade" +PING_TIMEOUT = 10 +USER_TRADES_ENDPOINT_NAME = "ownTrades" +USER_ORDERS_ENDPOINT_NAME = "openOrders" + PUBLIC_ENDPOINT_LIMIT_ID = "PublicEndpointLimitID" PUBLIC_ENDPOINT_LIMIT = 1 PUBLIC_ENDPOINT_LIMIT_INTERVAL = 1 diff --git a/hummingbot/connector/exchange/kraken/kraken_exchange.pxd b/hummingbot/connector/exchange/kraken/kraken_exchange.pxd deleted file mode 100755 index accc4a92c1..0000000000 --- a/hummingbot/connector/exchange/kraken/kraken_exchange.pxd +++ /dev/null @@ -1,43 +0,0 @@ -from hummingbot.connector.exchange_base cimport ExchangeBase -from hummingbot.core.data_type.transaction_tracker cimport TransactionTracker -from libc.stdint cimport int32_t - - -cdef class KrakenExchange(ExchangeBase): - cdef: - object _user_stream_tracker - object _ev_loop - object _poll_notifier - double _last_timestamp - double _poll_interval - double _last_pull_timestamp - dict _in_flight_orders - dict _order_not_found_records - TransactionTracker _tx_tracker - dict _trading_rules - dict _trade_fees - double _last_update_trade_fees_timestamp - public object _status_polling_task - public object _user_stream_event_listener_task - public object _user_stream_tracker_task - public object _trading_rules_polling_task - object _async_scheduler - object _set_server_time_offset_task - public object _kraken_auth - object _api_factory - object _rest_assistant - dict _asset_pairs - int32_t _last_userref - object _throttler - object _kraken_api_tier - - cdef c_did_timeout_tx(self, str tracking_id) - cdef c_start_tracking_order(self, - str order_id, - str exchange_order_id, - str trading_pair, - object trade_type, - object price, - object amount, - object order_type, - int userref) diff --git a/hummingbot/connector/exchange/kraken/kraken_exchange.py b/hummingbot/connector/exchange/kraken/kraken_exchange.py new file mode 100644 index 0000000000..2888e88edc --- /dev/null +++ b/hummingbot/connector/exchange/kraken/kraken_exchange.py @@ -0,0 +1,642 @@ +import asyncio +import re +from collections import defaultdict +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.kraken import kraken_constants as CONSTANTS, kraken_web_utils as web_utils +from hummingbot.connector.exchange.kraken.kraken_api_order_book_data_source import KrakenAPIOrderBookDataSource +from hummingbot.connector.exchange.kraken.kraken_api_user_stream_data_source import KrakenAPIUserStreamDataSource +from hummingbot.connector.exchange.kraken.kraken_auth import KrakenAuth +from hummingbot.connector.exchange.kraken.kraken_constants import KrakenAPITier +from hummingbot.connector.exchange.kraken.kraken_utils import ( + build_rate_limits_by_tier, + convert_from_exchange_symbol, + convert_from_exchange_trading_pair, +) +from hummingbot.connector.exchange_py_base import ExchangePyBase +from hummingbot.connector.trading_rule import TradingRule +from hummingbot.connector.utils import get_new_numeric_client_order_id +from hummingbot.core.api_throttler.async_throttler import AsyncThrottler +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 TokenAmount, TradeFeeBase +from hummingbot.core.data_type.user_stream_tracker_data_source import UserStreamTrackerDataSource +from hummingbot.core.utils.async_utils import safe_ensure_future +from hummingbot.core.utils.estimate_fee import build_trade_fee +from hummingbot.core.utils.tracking_nonce import NonceCreator +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 KrakenExchange(ExchangePyBase): + UPDATE_ORDER_STATUS_MIN_INTERVAL = 10.0 + SHORT_POLL_INTERVAL = 30.0 + + web_utils = web_utils + REQUEST_ATTEMPTS = 5 + + def __init__(self, + client_config_map: "ClientConfigAdapter", + kraken_api_key: str, + kraken_secret_key: str, + trading_pairs: Optional[List[str]] = None, + trading_required: bool = True, + domain: str = CONSTANTS.DEFAULT_DOMAIN, + kraken_api_tier: str = "starter" + ): + self.api_key = kraken_api_key + self.secret_key = kraken_secret_key + self._domain = domain + self._trading_required = trading_required + self._trading_pairs = trading_pairs + self._kraken_api_tier = KrakenAPITier(kraken_api_tier.upper()) + self._asset_pairs = {} + self._client_config = client_config_map + self._client_order_id_nonce_provider = NonceCreator.for_microseconds() + self._throttler = self._build_async_throttler(api_tier=self._kraken_api_tier) + + super().__init__(client_config_map) + + @staticmethod + def kraken_order_type(order_type: OrderType) -> str: + return order_type.name.lower() + + @staticmethod + def to_hb_order_type(kraken_type: str) -> OrderType: + return OrderType[kraken_type] + + @property + def authenticator(self): + return KrakenAuth( + api_key=self.api_key, + secret_key=self.secret_key, + time_provider=self._time_synchronizer) + + @property + def name(self) -> str: + return "kraken" + + # not used + @property + def rate_limits_rules(self): + return build_rate_limits_by_tier(self._kraken_api_tier) + + @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.ASSET_PAIRS_PATH_URL + + @property + def trading_pairs_request_path(self): + return CONSTANTS.ASSET_PAIRS_PATH_URL + + @property + def check_network_request_path(self): + return CONSTANTS.TICKER_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] + + def _build_async_throttler(self, api_tier: KrakenAPITier) -> AsyncThrottler: + limits_pct = self._client_config.rate_limits_share_pct + if limits_pct < Decimal("100"): + self.logger().warning( + f"The Kraken API does not allow enough bandwidth for a reduced rate-limit share percentage." + f" Current percentage: {limits_pct}." + ) + throttler = AsyncThrottler(build_rate_limits_by_tier(api_tier)) + return throttler + + def _is_request_exception_related_to_time_synchronizer(self, request_exception: Exception): + return False + + def _is_order_not_found_during_status_update_error(self, status_update_exception: Exception) -> bool: + return False + + def _is_order_not_found_during_cancelation_error(self, cancelation_exception: Exception) -> bool: + return CONSTANTS.UNKNOWN_ORDER_MESSAGE in str(cancelation_exception) + + def _create_web_assistants_factory(self) -> WebAssistantsFactory: + return web_utils.build_api_factory( + throttler=self._throttler, + auth=self._auth) + + def _create_order_book_data_source(self) -> OrderBookTrackerDataSource: + return KrakenAPIOrderBookDataSource( + trading_pairs=self._trading_pairs, + connector=self, + api_factory=self._web_assistants_factory) + + def _create_user_stream_data_source(self) -> UserStreamTrackerDataSource: + return KrakenAPIUserStreamDataSource( + connector=self, + 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 + trade_base_fee = build_trade_fee( + exchange=self.name, + is_maker=is_maker, + order_side=order_side, + order_type=order_type, + amount=amount, + price=price, + base_currency=base_currency, + quote_currency=quote_currency + ) + return trade_base_fee + + async def _api_get(self, *args, **kwargs): + kwargs["method"] = RESTMethod.GET + return await self._api_request_with_retry(*args, **kwargs) + + async def _api_post(self, *args, **kwargs): + kwargs["method"] = RESTMethod.POST + return await self._api_request_with_retry(*args, **kwargs) + + async def _api_put(self, *args, **kwargs): + kwargs["method"] = RESTMethod.PUT + return await self._api_request_with_retry(*args, **kwargs) + + async def _api_delete(self, *args, **kwargs): + kwargs["method"] = RESTMethod.DELETE + return await self._api_request_with_retry(*args, **kwargs) + + @staticmethod + def is_cloudflare_exception(exception: Exception): + """ + Error status 5xx or 10xx are related to Cloudflare. + https://support.kraken.com/hc/en-us/articles/360001491786-API-error-messages#6 + """ + return bool(re.search(r"HTTP status is (5|10)\d\d\.", str(exception))) + + async def get_open_orders_with_userref(self, userref: int): + data = {'userref': userref} + return await self._api_request_with_retry(RESTMethod.POST, + CONSTANTS.OPEN_ORDERS_PATH_URL, + is_auth_required=True, + data=data) + + # === Orders placing === + + def buy(self, + trading_pair: str, + amount: Decimal, + order_type=OrderType.LIMIT, + price: Decimal = s_decimal_NaN, + **kwargs) -> str: + """ + Creates a promise to create a buy order using the parameters + + :param trading_pair: the token pair to operate with + :param amount: the order amount + :param order_type: the type of order to create (MARKET, LIMIT, LIMIT_MAKER) + :param price: the order price + + :return: the id assigned by the connector to the order (the client id) + """ + order_id = str(get_new_numeric_client_order_id( + nonce_creator=self._client_order_id_nonce_provider, + max_id_bit_count=CONSTANTS.MAX_ID_BIT_COUNT, + )) + safe_ensure_future(self._create_order( + trade_type=TradeType.BUY, + order_id=order_id, + trading_pair=trading_pair, + amount=amount, + order_type=order_type, + price=price)) + return order_id + + def sell(self, + trading_pair: str, + amount: Decimal, + order_type: OrderType = OrderType.LIMIT, + price: Decimal = s_decimal_NaN, + **kwargs) -> str: + """ + Creates a promise to create a sell order using the parameters. + :param trading_pair: the token pair to operate with + :param amount: the order amount + :param order_type: the type of order to create (MARKET, LIMIT, LIMIT_MAKER) + :param price: the order price + :return: the id assigned by the connector to the order (the client id) + """ + order_id = str(get_new_numeric_client_order_id( + nonce_creator=self._client_order_id_nonce_provider, + max_id_bit_count=CONSTANTS.MAX_ID_BIT_COUNT, + )) + safe_ensure_future(self._create_order( + trade_type=TradeType.SELL, + order_id=order_id, + trading_pair=trading_pair, + amount=amount, + order_type=order_type, + price=price)) + return order_id + + async def get_asset_pairs(self) -> Dict[str, Any]: + if not self._asset_pairs: + asset_pairs = await self._api_request_with_retry(method=RESTMethod.GET, + path_url=CONSTANTS.ASSET_PAIRS_PATH_URL) + self._asset_pairs = {f"{details['base']}-{details['quote']}": details + for _, details in asset_pairs.items() if + web_utils.is_exchange_information_valid(details)} + return self._asset_pairs + + 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]: + trading_pair = await self.exchange_symbol_associated_to_pair(trading_pair=trading_pair) + data = { + "pair": trading_pair, + "type": "buy" if trade_type is TradeType.BUY else "sell", + "ordertype": "market" if order_type is OrderType.MARKET else "limit", + "volume": str(amount), + "userref": order_id, + "price": str(price) + } + + if order_type is OrderType.MARKET: + del data["price"] + if order_type is OrderType.LIMIT_MAKER: + data["oflags"] = "post" + order_result = await self._api_request_with_retry(RESTMethod.POST, + CONSTANTS.ADD_ORDER_PATH_URL, + data=data, + is_auth_required=True) + + o_id = order_result["txid"][0] + return (o_id, self.current_timestamp) + + async def _api_request_with_retry(self, + method: RESTMethod, + path_url: str, + params: Optional[Dict[str, Any]] = None, + data: Optional[Dict[str, Any]] = None, + is_auth_required: bool = False, + retry_interval=2.0) -> Dict[str, Any]: + response_json = None + result = None + for retry_attempt in range(self.REQUEST_ATTEMPTS): + try: + response_json = await self._api_request(path_url=path_url, method=method, params=params, data=data, + is_auth_required=is_auth_required) + + if response_json.get("error") and "EAPI:Invalid nonce" in response_json.get("error", ""): + self.logger().error(f"Invalid nonce error from {path_url}. " + + "Please ensure your Kraken API key nonce window is at least 10, " + + "and if needed reset your API key.") + result = response_json.get("result") + if not result or response_json.get("error"): + raise IOError({"error": response_json}) + break + except IOError as e: + if self.is_cloudflare_exception(e): + if path_url == CONSTANTS.ADD_ORDER_PATH_URL: + self.logger().info(f"Retrying {path_url}") + # Order placement could have been successful despite the IOError, so check for the open order. + response = await self.get_open_orders_with_userref(data.get('userref')) + if any(response.get("open").values()): + return response + self.logger().warning( + f"Cloudflare error. Attempt {retry_attempt + 1}/{self.REQUEST_ATTEMPTS}" + f" API command {method}: {path_url}" + ) + await asyncio.sleep(retry_interval ** retry_attempt) + continue + else: + raise e + if not result: + raise IOError(f"Error fetching data from {path_url}, msg is {response_json}.") + return result + + async def _place_cancel(self, order_id: str, tracked_order: InFlightOrder): + exchange_order_id = await tracked_order.get_exchange_order_id() + api_params = { + "txid": exchange_order_id, + } + cancel_result = await self._api_request_with_retry( + method=RESTMethod.POST, + path_url=CONSTANTS.CANCEL_ORDER_PATH_URL, + data=api_params, + is_auth_required=True) + if isinstance(cancel_result, dict) and ( + cancel_result.get("count") == 1 or + cancel_result.get("error") is not None): + return True + return False + + async def _format_trading_rules(self, exchange_info_dict: Dict[str, Any]) -> List[TradingRule]: + """ + Example: + { + "XBTUSDT": { + "altname": "XBTUSDT", + "wsname": "XBT/USDT", + "aclass_base": "currency", + "base": "XXBT", + "aclass_quote": "currency", + "quote": "USDT", + "lot": "unit", + "pair_decimals": 1, + "lot_decimals": 8, + "lot_multiplier": 1, + "leverage_buy": [2, 3], + "leverage_sell": [2, 3], + "fees": [ + [0, 0.26], + [50000, 0.24], + [100000, 0.22], + [250000, 0.2], + [500000, 0.18], + [1000000, 0.16], + [2500000, 0.14], + [5000000, 0.12], + [10000000, 0.1] + ], + "fees_maker": [ + [0, 0.16], + [50000, 0.14], + [100000, 0.12], + [250000, 0.1], + [500000, 0.08], + [1000000, 0.06], + [2500000, 0.04], + [5000000, 0.02], + [10000000, 0] + ], + "fee_volume_currency": "ZUSD", + "margin_call": 80, + "margin_stop": 40, + "ordermin": "0.0002" + } + } + """ + retval: list = [] + trading_pair_rules = exchange_info_dict.values() + for rule in filter(web_utils.is_exchange_information_valid, trading_pair_rules): + try: + trading_pair = await self.trading_pair_associated_to_exchange_symbol(symbol=rule.get("altname")) + min_order_size = Decimal(rule.get('ordermin', 0)) + min_price_increment = Decimal(f"1e-{rule.get('pair_decimals')}") + min_base_amount_increment = Decimal(f"1e-{rule.get('lot_decimals')}") + retval.append( + TradingRule( + trading_pair, + min_order_size=min_order_size, + min_price_increment=min_price_increment, + min_base_amount_increment=min_base_amount_increment, + ) + ) + except Exception: + self.logger().error(f"Error parsing the trading pair rule {rule}. Skipping.", exc_info=True) + return retval + + async def _update_trading_fees(self): + """ + Update fees information from the exchange + """ + pass + + async def _user_stream_event_listener(self): + """ + Listens to messages from _user_stream_tracker.user_stream queue. + Traders, Orders, and Balance updates from the WS. + """ + async for event_message in self._iter_user_event_queue(): + try: + if isinstance(event_message, list): + channel: str = event_message[-2] + results: List[Any] = event_message[0] + if channel == CONSTANTS.USER_TRADES_ENDPOINT_NAME: + self._process_trade_message(results) + elif channel == CONSTANTS.USER_ORDERS_ENDPOINT_NAME: + self._process_order_message(event_message) + elif event_message is asyncio.CancelledError: + raise asyncio.CancelledError + else: + raise Exception(event_message) + except asyncio.CancelledError: + raise + except Exception: + self.logger().error( + "Unexpected error in user stream listener loop.", exc_info=True) + await self._sleep(5.0) + + def _create_trade_update_with_order_fill_data( + self, + order_fill: Dict[str, Any], + order: InFlightOrder): + fee_asset = order.quote_asset + + fee = TradeFeeBase.new_spot_fee( + fee_schema=self.trade_fee_schema(), + trade_type=order.trade_type, + percent_token=fee_asset, + flat_fees=[TokenAmount( + amount=Decimal(order_fill["fee"]), + token=fee_asset + )] + ) + trade_update = TradeUpdate( + trade_id=str(order_fill["trade_id"]), + client_order_id=order.client_order_id, + exchange_order_id=order_fill.get("ordertxid"), + trading_pair=order.trading_pair, + fee=fee, + fill_base_amount=Decimal(order_fill["vol"]), + fill_quote_amount=Decimal(order_fill["vol"]) * Decimal(order_fill["price"]), + fill_price=Decimal(order_fill["price"]), + fill_timestamp=order_fill["time"], + ) + return trade_update + + def _process_trade_message(self, trades: List): + for update in trades: + trade_id: str = next(iter(update)) + trade: Dict[str, str] = update[trade_id] + trade["trade_id"] = trade_id + exchange_order_id = trade.get("ordertxid") + client_order_id = str(trade.get("userref", "")) + tracked_order = self._order_tracker.all_fillable_orders.get(client_order_id) + + if not tracked_order: + self.logger().debug(f"Ignoring trade message with id {exchange_order_id}: not in in_flight_orders.") + else: + trade_update = self._create_trade_update_with_order_fill_data( + order_fill=trade, + order=tracked_order) + self._order_tracker.process_trade_update(trade_update) + + def _create_order_update_with_order_status_data(self, order_status: Dict[str, Any], order: InFlightOrder): + order_update = OrderUpdate( + trading_pair=order.trading_pair, + update_timestamp=self.current_timestamp, + new_state=CONSTANTS.ORDER_STATE[order_status["status"]], + client_order_id=order.client_order_id, + exchange_order_id=order.exchange_order_id, + ) + return order_update + + def _process_order_message(self, orders: List): + update = orders[0] + for message in update: + for exchange_order_id, order_msg in message.items(): + client_order_id = str(order_msg.get("userref", "")) + tracked_order = self._order_tracker.all_updatable_orders.get(client_order_id) + if not tracked_order: + self.logger().debug( + f"Ignoring order message with id {order_msg}: not in in_flight_orders.") + return + if "status" in order_msg: + order_update = self._create_order_update_with_order_status_data(order_status=order_msg, + order=tracked_order) + self._order_tracker.process_order_update(order_update=order_update) + + async def _all_trade_updates_for_order(self, order: InFlightOrder) -> List[TradeUpdate]: + trade_updates = [] + + try: + exchange_order_id = await order.get_exchange_order_id() + all_fills_response = await self._api_request_with_retry( + method=RESTMethod.POST, + path_url=CONSTANTS.QUERY_TRADES_PATH_URL, + data={"txid": exchange_order_id}, + is_auth_required=True) + + for trade_id, trade_fill in all_fills_response.items(): + trade: Dict[str, str] = all_fills_response[trade_id] + trade["trade_id"] = trade_id + trade_update = self._create_trade_update_with_order_fill_data( + order_fill=trade, + order=order) + trade_updates.append(trade_update) + + except asyncio.TimeoutError: + raise IOError(f"Skipped order update with order fills for {order.client_order_id} " + "- waiting for exchange order id.") + except Exception as e: + if "EOrder:Unknown order" in str(e) or "EOrder:Invalid order" in str(e): + return trade_updates + return trade_updates + + async def _request_order_status(self, tracked_order: InFlightOrder) -> OrderUpdate: + exchange_order_id = await tracked_order.get_exchange_order_id() + updated_order_data = await self._api_request_with_retry( + method=RESTMethod.POST, + path_url=CONSTANTS.QUERY_ORDERS_PATH_URL, + data={"txid": exchange_order_id}, + is_auth_required=True) + + update = updated_order_data.get(exchange_order_id) + new_state = CONSTANTS.ORDER_STATE[update["status"]] + + order_update = OrderUpdate( + client_order_id=tracked_order.client_order_id, + exchange_order_id=exchange_order_id, + trading_pair=tracked_order.trading_pair, + update_timestamp=self.current_timestamp, + new_state=new_state, + ) + + return order_update + + async def _update_balances(self): + local_asset_names = set(self._account_balances.keys()) + remote_asset_names = set() + balances = await self._api_request_with_retry(RESTMethod.POST, CONSTANTS.BALANCE_PATH_URL, + is_auth_required=True) + open_orders = await self._api_request_with_retry(RESTMethod.POST, CONSTANTS.OPEN_ORDERS_PATH_URL, + is_auth_required=True) + + locked = defaultdict(Decimal) + + for order in open_orders.get("open").values(): + if order.get("status") == "open": + details = order.get("descr") + if details.get("ordertype") == "limit": + pair = convert_from_exchange_trading_pair( + details.get("pair"), tuple((await self.get_asset_pairs()).keys()) + ) + (base, quote) = self.split_trading_pair(pair) + vol_locked = Decimal(order.get("vol", 0)) - Decimal(order.get("vol_exec", 0)) + if details.get("type") == "sell": + locked[convert_from_exchange_symbol(base)] += vol_locked + elif details.get("type") == "buy": + locked[convert_from_exchange_symbol(quote)] += vol_locked * Decimal(details.get("price")) + + for asset_name, balance in balances.items(): + cleaned_name = convert_from_exchange_symbol(asset_name).upper() + total_balance = Decimal(balance) + free_balance = total_balance - Decimal(locked[cleaned_name]) + self._account_available_balances[cleaned_name] = free_balance + self._account_balances[cleaned_name] = total_balance + remote_asset_names.add(cleaned_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(web_utils.is_exchange_information_valid, exchange_info.values()): + mapping[symbol_data["altname"]] = convert_from_exchange_trading_pair(symbol_data["wsname"]) + self._set_trading_pair_symbol_map(mapping) + + async def _get_last_traded_price(self, trading_pair: str) -> float: + params = { + "pair": await self.exchange_symbol_associated_to_pair(trading_pair=trading_pair) + } + resp_json = await self._api_request_with_retry( + method=RESTMethod.GET, + path_url=CONSTANTS.TICKER_PATH_URL, + params=params + ) + record = list(resp_json.values())[0] + return float(record["c"][0]) diff --git a/hummingbot/connector/exchange/kraken/kraken_exchange.pyx b/hummingbot/connector/exchange/kraken/kraken_exchange.pyx deleted file mode 100755 index e383426a32..0000000000 --- a/hummingbot/connector/exchange/kraken/kraken_exchange.pyx +++ /dev/null @@ -1,1160 +0,0 @@ -import asyncio -import copy -import logging -import re -from collections import defaultdict -from decimal import Decimal -from typing import ( - Any, - AsyncIterable, - Dict, - List, - Optional, - TYPE_CHECKING, -) - -from async_timeout import timeout -from libc.stdint cimport int32_t, int64_t - -from hummingbot.connector.exchange.kraken import kraken_constants as CONSTANTS -from hummingbot.connector.exchange.kraken.kraken_api_order_book_data_source import KrakenAPIOrderBookDataSource -from hummingbot.connector.exchange.kraken.kraken_auth import KrakenAuth -from hummingbot.connector.exchange.kraken.kraken_constants import KrakenAPITier -from hummingbot.connector.exchange.kraken.kraken_in_flight_order import ( - KrakenInFlightOrder, - KrakenInFlightOrderNotCreated, -) -from hummingbot.connector.exchange.kraken.kraken_order_book_tracker import KrakenOrderBookTracker -from hummingbot.connector.exchange.kraken.kraken_user_stream_tracker import KrakenUserStreamTracker -from hummingbot.connector.exchange.kraken.kraken_utils import ( - build_api_factory, - build_rate_limits_by_tier, - convert_from_exchange_symbol, - convert_from_exchange_trading_pair, - convert_to_exchange_trading_pair, - is_dark_pool, - split_to_base_quote, -) -from hummingbot.connector.exchange_base import ExchangeBase -from hummingbot.connector.trading_rule cimport TradingRule -from hummingbot.core.api_throttler.async_throttler import AsyncThrottler -from hummingbot.core.clock cimport Clock -from hummingbot.core.data_type.cancellation_result import CancellationResult -from hummingbot.core.data_type.common import OrderType, TradeType -from hummingbot.core.data_type.limit_order import LimitOrder -from hummingbot.core.data_type.order_book cimport OrderBook -from hummingbot.core.data_type.trade_fee import AddedToCostTradeFee, TokenAmount -from hummingbot.core.data_type.transaction_tracker import TransactionTracker -from hummingbot.core.event.events import ( - BuyOrderCompletedEvent, - BuyOrderCreatedEvent, - MarketEvent, - MarketOrderFailureEvent, - MarketTransactionFailureEvent, - OrderCancelledEvent, - OrderFilledEvent, - SellOrderCompletedEvent, - SellOrderCreatedEvent, -) -from hummingbot.core.network_iterator import NetworkStatus -from hummingbot.core.utils.async_call_scheduler import AsyncCallScheduler -from hummingbot.core.utils.async_utils import safe_ensure_future, safe_gather -from hummingbot.core.utils.tracking_nonce import get_tracking_nonce -from hummingbot.core.web_assistant.connections.data_types import RESTMethod, RESTRequest -from hummingbot.core.web_assistant.rest_assistant import RESTAssistant -from hummingbot.logger import HummingbotLogger - -if TYPE_CHECKING: - from hummingbot.client.config.config_helpers import ClientConfigAdapter - -s_logger = None -s_decimal_0 = Decimal(0) -s_decimal_NaN = Decimal("NaN") - - -cdef class KrakenExchangeTransactionTracker(TransactionTracker): - cdef: - KrakenExchange _owner - - def __init__(self, owner: KrakenExchange): - super().__init__() - self._owner = owner - - cdef c_did_timeout_tx(self, str tx_id): - TransactionTracker.c_did_timeout_tx(self, tx_id) - self._owner.c_did_timeout_tx(tx_id) - - -cdef class KrakenExchange(ExchangeBase): - MARKET_RECEIVED_ASSET_EVENT_TAG = MarketEvent.ReceivedAsset.value - MARKET_BUY_ORDER_COMPLETED_EVENT_TAG = MarketEvent.BuyOrderCompleted.value - MARKET_SELL_ORDER_COMPLETED_EVENT_TAG = MarketEvent.SellOrderCompleted.value - MARKET_ORDER_CANCELED_EVENT_TAG = MarketEvent.OrderCancelled.value - MARKET_TRANSACTION_FAILURE_EVENT_TAG = MarketEvent.TransactionFailure.value - MARKET_ORDER_FAILURE_EVENT_TAG = MarketEvent.OrderFailure.value - MARKET_ORDER_FILLED_EVENT_TAG = MarketEvent.OrderFilled.value - MARKET_BUY_ORDER_CREATED_EVENT_TAG = MarketEvent.BuyOrderCreated.value - MARKET_SELL_ORDER_CREATED_EVENT_TAG = MarketEvent.SellOrderCreated.value - - REQUEST_ATTEMPTS = 5 - - ORDER_NOT_EXIST_CONFIRMATION_COUNT = 3 - - @classmethod - def logger(cls) -> HummingbotLogger: - global s_logger - if s_logger is None: - s_logger = logging.getLogger(__name__) - return s_logger - - def __init__(self, - client_config_map: "ClientConfigAdapter", - kraken_api_key: str, - kraken_secret_key: str, - poll_interval: float = 30.0, - trading_pairs: Optional[List[str]] = None, - trading_required: bool = True, - kraken_api_tier: str = "starter"): - - super().__init__(client_config_map) - self._trading_required = trading_required - self._kraken_api_tier = KrakenAPITier(kraken_api_tier.upper()) - self._throttler = self._build_async_throttler(api_tier=self._kraken_api_tier) - self._api_factory = build_api_factory(throttler=self._throttler) - self._rest_assistant = None - self._set_order_book_tracker(KrakenOrderBookTracker(trading_pairs=trading_pairs, throttler=self._throttler)) - self._kraken_auth = KrakenAuth(kraken_api_key, kraken_secret_key) - self._user_stream_tracker = KrakenUserStreamTracker(self._throttler, self._kraken_auth, self._api_factory) - self._ev_loop = asyncio.get_event_loop() - self._poll_notifier = asyncio.Event() - self._last_timestamp = 0 - self._poll_interval = poll_interval - self._in_flight_orders = {} # Dict[client_order_id:str, KrakenInFlightOrder] - self._order_not_found_records = {} # Dict[client_order_id:str, count:int] - self._tx_tracker = KrakenExchangeTransactionTracker(self) - self._trading_rules = {} # Dict[trading_pair:str, TradingRule] - self._trade_fees = {} # Dict[trading_pair:str, (maker_fee_percent:Decimal, taken_fee_percent:Decimal)] - self._last_update_trade_fees_timestamp = 0 - self._status_polling_task = None - self._user_stream_tracker_task = None - self._user_stream_event_listener_task = None - self._trading_rules_polling_task = None - self._async_scheduler = AsyncCallScheduler(call_interval=0.5) - self._last_pull_timestamp = 0 - self._asset_pairs = {} - self._last_userref = 0 - self._real_time_balance_update = False - - @property - def name(self) -> str: - return "kraken" - - @property - def order_books(self) -> Dict[str, OrderBook]: - return self.order_book_tracker.order_books - - @property - def kraken_auth(self) -> KrakenAuth: - return self._kraken_auth - - @property - def trading_rules(self) -> Dict[str, TradingRule]: - return self._trading_rules - - @property - def in_flight_orders(self) -> Dict[str, KrakenInFlightOrder]: - return self._in_flight_orders - - @property - def limit_orders(self) -> List[LimitOrder]: - return [ - in_flight_order.to_limit_order() - for in_flight_order in self._in_flight_orders.values() - ] - - @property - def tracking_states(self) -> Dict[str, Any]: - return { - order_id: value.to_json() - for order_id, value in self._in_flight_orders.items() - } - - def restore_tracking_states(self, saved_states: Dict[str, Any]): - in_flight_orders: Dict[str, KrakenInFlightOrder] = {} - for key, value in saved_states.items(): - in_flight_orders[key] = KrakenInFlightOrder.from_json(value) - self._last_userref = max(int(value["userref"]), self._last_userref) - self._in_flight_orders.update(in_flight_orders) - - async def get_asset_pairs(self) -> Dict[str, Any]: - if not self._asset_pairs: - url = f"{CONSTANTS.BASE_URL}{CONSTANTS.ASSET_PAIRS_PATH_URL}" - asset_pairs = await self._api_request(method="get", endpoint=CONSTANTS.ASSET_PAIRS_PATH_URL) - self._asset_pairs = {f"{details['base']}-{details['quote']}": details - for _, details in asset_pairs.items() if not is_dark_pool(details)} - return self._asset_pairs - - async def _get_rest_assistant(self) -> RESTAssistant: - if self._rest_assistant is None: - self._rest_assistant = await self._api_factory.get_rest_assistant() - return self._rest_assistant - - async def _update_balances(self): - cdef: - dict open_orders - dict balances - str asset_name - str balance - str base - str quote - set local_asset_names = set(self._account_balances.keys()) - set remote_asset_names = set() - set asset_names_to_remove - - balances = await self._api_request_with_retry("POST", CONSTANTS.BALANCE_PATH_URL, is_auth_required=True) - open_orders = await self._api_request_with_retry("POST", CONSTANTS.OPEN_ORDERS_PATH_URL, is_auth_required=True) - - locked = defaultdict(Decimal) - - for order in open_orders.get("open").values(): - if order.get("status") == "open": - details = order.get("descr") - if details.get("ordertype") == "limit": - pair = convert_from_exchange_trading_pair( - details.get("pair"), tuple((await self.get_asset_pairs()).keys()) - ) - (base, quote) = self.split_trading_pair(pair) - vol_locked = Decimal(order.get("vol", 0)) - Decimal(order.get("vol_exec", 0)) - if details.get("type") == "sell": - locked[convert_from_exchange_symbol(base)] += vol_locked - elif details.get("type") == "buy": - locked[convert_from_exchange_symbol(quote)] += vol_locked * Decimal(details.get("price")) - - for asset_name, balance in balances.items(): - cleaned_name = convert_from_exchange_symbol(asset_name).upper() - total_balance = Decimal(balance) - free_balance = total_balance - Decimal(locked[cleaned_name]) - self._account_available_balances[cleaned_name] = free_balance - self._account_balances[cleaned_name] = total_balance - remote_asset_names.add(cleaned_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] - - self._in_flight_orders_snapshot = {k: copy.copy(v) for k, v in self._in_flight_orders.items()} - self._in_flight_orders_snapshot_timestamp = self._current_timestamp - - cdef object c_get_fee(self, - str base_currency, - str quote_currency, - object order_type, - object order_side, - object amount, - object price, - object is_maker = None): - """ - To get trading fee, this function is simplified by using fee override configuration. Most parameters to this - function are ignore except order_type. Use OrderType.LIMIT_MAKER to specify you want trading fee for - maker order. - """ - is_maker = order_type is OrderType.LIMIT_MAKER - return AddedToCostTradeFee(percent=self.estimate_fee_pct(is_maker)) - - async def _update_trading_rules(self): - cdef: - # The poll interval for withdraw rules is 60 seconds. - int64_t last_tick = (self._last_timestamp / 60.0) - int64_t current_tick = (self._current_timestamp / 60.0) - if current_tick > last_tick or len(self._trading_rules) < 1: - asset_pairs = await self.get_asset_pairs() - trading_rules_list = self._format_trading_rules(asset_pairs) - self._trading_rules.clear() - for trading_rule in trading_rules_list: - self._trading_rules[convert_from_exchange_trading_pair(trading_rule.trading_pair)] = trading_rule - - def _format_trading_rules(self, asset_pairs_dict: Dict[str, Any]) -> List[TradingRule]: - """ - Example: - { - "XBTUSDT": { - "altname": "XBTUSDT", - "wsname": "XBT/USDT", - "aclass_base": "currency", - "base": "XXBT", - "aclass_quote": "currency", - "quote": "USDT", - "lot": "unit", - "pair_decimals": 1, - "lot_decimals": 8, - "lot_multiplier": 1, - "leverage_buy": [2, 3], - "leverage_sell": [2, 3], - "fees": [ - [0, 0.26], - [50000, 0.24], - [100000, 0.22], - [250000, 0.2], - [500000, 0.18], - [1000000, 0.16], - [2500000, 0.14], - [5000000, 0.12], - [10000000, 0.1] - ], - "fees_maker": [ - [0, 0.16], - [50000, 0.14], - [100000, 0.12], - [250000, 0.1], - [500000, 0.08], - [1000000, 0.06], - [2500000, 0.04], - [5000000, 0.02], - [10000000, 0] - ], - "fee_volume_currency": "ZUSD", - "margin_call": 80, - "margin_stop": 40, - "ordermin": "0.0002" - } - } - """ - cdef: - list retval = [] - for trading_pair, rule in asset_pairs_dict.items(): - try: - base, quote = split_to_base_quote(trading_pair) - base = convert_from_exchange_symbol(base) - min_order_size = Decimal(rule.get('ordermin', 0)) - min_price_increment = Decimal(f"1e-{rule.get('pair_decimals')}") - min_base_amount_increment = Decimal(f"1e-{rule.get('lot_decimals')}") - retval.append( - TradingRule( - trading_pair, - min_order_size=min_order_size, - min_price_increment=min_price_increment, - min_base_amount_increment=min_base_amount_increment, - ) - ) - except Exception: - self.logger().error(f"Error parsing the trading pair rule {rule}. Skipping.", exc_info=True) - return retval - - async def _update_order_status(self): - cdef: - # This is intended to be a backup measure to close straggler orders, in case Kraken's user stream events - # are not working. - # The poll interval for order status is 10 seconds. - int64_t last_tick = (self._last_pull_timestamp / 10.0) - int64_t current_tick = (self._current_timestamp / 10.0) - - if len(self._in_flight_orders) > 0: - tracked_orders = list(self._in_flight_orders.values()) - tasks = [self._api_request_with_retry("POST", - CONSTANTS.QUERY_ORDERS_PATH_URL, - data={"txid": o.exchange_order_id}, - is_auth_required=True) - for o in tracked_orders] - results = await safe_gather(*tasks, return_exceptions=True) - - for order_update, tracked_order in zip(results, tracked_orders): - client_order_id = tracked_order.client_order_id - - # If the order has already been cancelled or has failed do nothing - if client_order_id not in self._in_flight_orders: - continue - - if isinstance(order_update, Exception): - self.logger().network( - f"Error fetching status update for the order {client_order_id}: {order_update}.", - app_warning_msg=f"Failed to fetch status update for the order {client_order_id}." - ) - continue - - if order_update.get("error") is not None and "EOrder:Invalid order" not in order_update["error"]: - self.logger().debug(f"Error in fetched status update for order {client_order_id}: " - f"{order_update['error']}") - self.c_cancel(tracked_order.trading_pair, tracked_order.client_order_id) - continue - - update = order_update.get(tracked_order.exchange_order_id) - - if not update: - self._order_not_found_records[client_order_id] = \ - self._order_not_found_records.get(client_order_id, 0) + 1 - if self._order_not_found_records[client_order_id] < self.ORDER_NOT_EXIST_CONFIRMATION_COUNT: - # Wait until the order not found error have repeated a few times before actually treating - # it as failed. See: https://github.com/CoinAlpha/hummingbot/issues/601 - continue - self.c_trigger_event( - self.MARKET_ORDER_FAILURE_EVENT_TAG, - MarketOrderFailureEvent(self._current_timestamp, client_order_id, tracked_order.order_type) - ) - self.c_stop_tracking_order(client_order_id) - continue - - # Update order execution status - tracked_order.last_state = update["status"] - executed_amount_base = Decimal(update["vol_exec"]) - executed_amount_quote = executed_amount_base * Decimal(update["price"]) - - if tracked_order.is_done: - if not tracked_order.is_failure: - if tracked_order.trade_type is TradeType.BUY: - self.logger().info(f"The market buy order {tracked_order.client_order_id} has completed " - f"according to order status API.") - self.c_trigger_event(self.MARKET_BUY_ORDER_COMPLETED_EVENT_TAG, - BuyOrderCompletedEvent(self._current_timestamp, - client_order_id, - tracked_order.base_asset, - tracked_order.quote_asset, - executed_amount_base, - executed_amount_quote, - tracked_order.order_type)) - else: - self.logger().info(f"The market sell order {client_order_id} has completed " - f"according to order status API.") - self.c_trigger_event(self.MARKET_SELL_ORDER_COMPLETED_EVENT_TAG, - SellOrderCompletedEvent(self._current_timestamp, - client_order_id, - tracked_order.base_asset, - tracked_order.quote_asset, - executed_amount_base, - executed_amount_quote, - tracked_order.order_type)) - else: - # check if its a cancelled order - # if its a cancelled order, issue cancel and stop tracking order - if tracked_order.is_cancelled: - self.logger().info(f"Successfully canceled order {client_order_id}.") - self.c_trigger_event(self.MARKET_ORDER_CANCELED_EVENT_TAG, - OrderCancelledEvent( - self._current_timestamp, - client_order_id)) - else: - self.logger().info(f"The market order {client_order_id} has failed according to " - f"order status API.") - self.c_trigger_event(self.MARKET_ORDER_FAILURE_EVENT_TAG, - MarketOrderFailureEvent( - self._current_timestamp, - client_order_id, - tracked_order.order_type - )) - self.c_stop_tracking_order(client_order_id) - - async def _iter_user_event_queue(self) -> AsyncIterable[Dict[str, Any]]: - while True: - try: - yield await self._user_stream_tracker.user_stream.get() - except asyncio.CancelledError: - raise - except Exception: - self.logger().network( - "Unknown error. Retrying after 1 seconds.", - exc_info=True, - app_warning_msg="Could not fetch user events from Kraken. Check API key and network connection." - ) - await asyncio.sleep(1.0) - - async def _user_stream_event_listener(self): - async for event_message in self._iter_user_event_queue(): - try: - # Event type is second from last, there is newly added sequence number (last item). - # https://docs.kraken.com/websockets/#sequence-numbers - event_type: str = event_message[-2] - updates: List[Any] = event_message[0] - if event_type == "ownTrades": - for update in updates: - trade_id: str = next(iter(update)) - trade: Dict[str, str] = update[trade_id] - trade["trade_id"] = trade_id - exchange_order_id = trade.get("ordertxid") - try: - client_order_id = next(key for key, value in self._in_flight_orders.items() - if value.exchange_order_id == exchange_order_id) - except StopIteration: - continue - - tracked_order = self._in_flight_orders.get(client_order_id) - - if tracked_order is None: - # Hiding the messages for now. Root cause to be investigated in later sprints. - self.logger().debug(f"Unrecognized order ID from user stream: {client_order_id}.") - self.logger().debug(f"Event: {event_message}") - self.logger().debug(f"Order Event: {update}") - continue - - updated: bool = tracked_order.update_with_trade_update(trade) - if updated: - self.c_trigger_event(self.MARKET_ORDER_FILLED_EVENT_TAG, - OrderFilledEvent(self._current_timestamp, - tracked_order.client_order_id, - tracked_order.trading_pair, - tracked_order.trade_type, - tracked_order.order_type, - Decimal(trade.get("price")), - Decimal(trade.get("vol")), - AddedToCostTradeFee( - flat_fees=[ - TokenAmount( - tracked_order.fee_asset, - Decimal((trade.get("fee"))), - ) - ] - ), - trade.get("trade_id"))) - - if tracked_order.is_done: - if not tracked_order.is_failure: - if tracked_order.trade_type is TradeType.BUY: - self.logger().info(f"The market buy order {tracked_order.client_order_id} has completed " - f"according to user stream.") - self.c_trigger_event(self.MARKET_BUY_ORDER_COMPLETED_EVENT_TAG, - BuyOrderCompletedEvent(self._current_timestamp, - tracked_order.client_order_id, - tracked_order.base_asset, - tracked_order.quote_asset, - tracked_order.executed_amount_base, - tracked_order.executed_amount_quote, - tracked_order.order_type)) - else: - self.logger().info(f"The market sell order {tracked_order.client_order_id} has completed " - f"according to user stream.") - self.c_trigger_event(self.MARKET_SELL_ORDER_COMPLETED_EVENT_TAG, - SellOrderCompletedEvent(self._current_timestamp, - tracked_order.client_order_id, - tracked_order.base_asset, - tracked_order.quote_asset, - tracked_order.executed_amount_base, - tracked_order.executed_amount_quote, - tracked_order.order_type)) - else: - # check if its a cancelled order - # if its a cancelled order, check in flight orders - # if present in in flight orders issue cancel and stop tracking order - if tracked_order.is_cancelled: - if tracked_order.client_order_id in self._in_flight_orders: - self.logger().info(f"Successfully canceled order {tracked_order.client_order_id}.") - self.c_trigger_event(self.MARKET_ORDER_CANCELED_EVENT_TAG, - OrderCancelledEvent(self._current_timestamp, - tracked_order.client_order_id)) - else: - self.logger().info(f"The market order {tracked_order.client_order_id} has failed according to " - f"order status API.") - self.c_trigger_event(self.MARKET_ORDER_FAILURE_EVENT_TAG, - MarketOrderFailureEvent(self._current_timestamp, - tracked_order.client_order_id, - tracked_order.order_type)) - - self.c_stop_tracking_order(tracked_order.client_order_id) - - except asyncio.CancelledError: - raise - except Exception: - self.logger().error("Unexpected error in user stream listener loop.", exc_info=True) - await asyncio.sleep(5.0) - - async def _status_polling_loop(self): - while True: - try: - self._poll_notifier = asyncio.Event() - await self._poll_notifier.wait() - await safe_gather( - self._update_balances(), - self._update_order_status(), - ) - self._last_pull_timestamp = self._current_timestamp - except asyncio.CancelledError: - raise - except Exception: - self.logger().network("Unexpected error while fetching account updates.", exc_info=True, - app_warning_msg="Could not fetch account updates from Kraken. " - "Check API key and network connection.") - await asyncio.sleep(0.5) - - async def _trading_rules_polling_loop(self): - while True: - try: - await safe_gather( - self._update_trading_rules(), - ) - await asyncio.sleep(60) - except asyncio.CancelledError: - raise - except Exception: - self.logger().network("Unexpected error while fetching trading rules.", exc_info=True, - app_warning_msg="Could not fetch new trading rules from Kraken. " - "Check network connection.") - await asyncio.sleep(0.5) - - @property - def status_dict(self) -> Dict[str, bool]: - return { - "order_books_initialized": self.order_book_tracker.ready, - "account_balance": len(self._account_balances) > 0 if self._trading_required else True, - "trading_rule_initialized": len(self._trading_rules) > 0, - } - - @property - def ready(self) -> bool: - return all(self.status_dict.values()) - - cdef c_start(self, Clock clock, double timestamp): - self._tx_tracker.c_start(clock, timestamp) - ExchangeBase.c_start(self, clock, timestamp) - - cdef c_stop(self, Clock clock): - ExchangeBase.c_stop(self, clock) - self._async_scheduler.stop() - - async def start_network(self): - self._stop_network() - self.order_book_tracker.start() - self._trading_rules_polling_task = safe_ensure_future(self._trading_rules_polling_loop()) - if self._trading_required: - self._status_polling_task = safe_ensure_future(self._status_polling_loop()) - self._user_stream_tracker_task = safe_ensure_future(self._user_stream_tracker.start()) - self._user_stream_event_listener_task = safe_ensure_future(self._user_stream_event_listener()) - - def _stop_network(self): - self.order_book_tracker.stop() - if self._status_polling_task is not None: - self._status_polling_task.cancel() - if self._user_stream_tracker_task is not None: - self._user_stream_tracker_task.cancel() - if self._user_stream_event_listener_task is not None: - self._user_stream_event_listener_task.cancel() - if self._trading_rules_polling_task is not None: - self._trading_rules_polling_task.cancel() - self._status_polling_task = self._user_stream_tracker_task = \ - self._user_stream_event_listener_task = None - - async def stop_network(self): - self._stop_network() - - async def check_network(self) -> NetworkStatus: - try: - url = f"{CONSTANTS.BASE_URL}{CONSTANTS.TIME_PATH_URL}" - request = RESTRequest( - method=RESTMethod.GET, - url=url - ) - rest_assistant = await self._get_rest_assistant() - async with self._throttler.execute_task(CONSTANTS.TIME_PATH_URL): - resp = await rest_assistant.call(request=request) - if resp.status != 200: - raise ConnectionError - except asyncio.CancelledError: - raise - except Exception: - return NetworkStatus.NOT_CONNECTED - return NetworkStatus.CONNECTED - - cdef c_tick(self, double timestamp): - cdef: - int64_t last_tick = (self._last_timestamp / self._poll_interval) - int64_t current_tick = (timestamp / self._poll_interval) - ExchangeBase.c_tick(self, timestamp) - self._tx_tracker.c_tick(timestamp) - if current_tick > last_tick: - if not self._poll_notifier.is_set(): - self._poll_notifier.set() - self._last_timestamp = timestamp - - def generate_userref(self): - self._last_userref += 1 - return self._last_userref - - @staticmethod - def is_cloudflare_exception(exception: Exception): - """ - Error status 5xx or 10xx are related to Cloudflare. - https://support.kraken.com/hc/en-us/articles/360001491786-API-error-messages#6 - """ - return bool(re.search(r"HTTP status is (5|10)\d\d\.", str(exception))) - - async def get_open_orders_with_userref(self, userref: int): - data = {'userref': userref} - return await self._api_request_with_retry("POST", - CONSTANTS.OPEN_ORDERS_PATH_URL, - is_auth_required=True, - data=data) - - async def _api_request_with_retry(self, - method: str, - endpoint: str, - params: Optional[Dict[str, Any]] = None, - data: Optional[Dict[str, Any]] = None, - is_auth_required: bool = False, - retry_interval = 2.0) -> Dict[str, Any]: - result = None - for retry_attempt in range(self.REQUEST_ATTEMPTS): - try: - result= await self._api_request(method, endpoint, params, data, is_auth_required) - break - except IOError as e: - if self.is_cloudflare_exception(e): - if endpoint == CONSTANTS.ADD_ORDER_PATH_URL: - self.logger().info(f"Retrying {endpoint}") - # Order placement could have been successful despite the IOError, so check for the open order. - response = self.get_open_orders_with_userref(data.get('userref')) - if any(response.get("open").values()): - return response - self.logger().warning( - f"Cloudflare error. Attempt {retry_attempt+1}/{self.REQUEST_ATTEMPTS}" - f" API command {method}: {endpoint}" - ) - await asyncio.sleep(retry_interval ** retry_attempt) - continue - else: - raise e - if result is None: - raise IOError(f"Error fetching data from {endpoint}.") - return result - - async def _api_request(self, - method: str, - endpoint: str, - params: Optional[Dict[str, Any]] = None, - data: Optional[Dict[str, Any]] = None, - is_auth_required: bool = False) -> Dict[str, Any]: - async with self._throttler.execute_task(endpoint): - url = f"{CONSTANTS.BASE_URL}{endpoint}" - headers = {} - data_dict = data if data is not None else {} - - if is_auth_required: - auth_dict: Dict[str, Any] = self._kraken_auth.generate_auth_dict(endpoint, data=data) - headers.update(auth_dict["headers"]) - data_dict = auth_dict["postDict"] - - request = RESTRequest( - method=RESTMethod[method.upper()], - url=url, - headers=headers, - params=params, - data=data_dict - ) - rest_assistant = await self._get_rest_assistant() - response = await rest_assistant.call(request=request, timeout=100) - - if response.status != 200: - raise IOError(f"Error fetching data from {url}. HTTP status is {response.status}.") - try: - response_json = await response.json() - except Exception: - raise IOError(f"Error parsing data from {url}.") - - try: - err = response_json["error"] - if "EOrder:Unknown order" in err or "EOrder:Invalid order" in err: - return {"error": err} - elif "EAPI:Invalid nonce" in err: - self.logger().error(f"Invalid nonce error from {url}. " + - "Please ensure your Kraken API key nonce window is at least 10, " + - "and if needed reset your API key.") - raise IOError({"error": response_json}) - except IOError: - raise - except Exception: - pass - - data = response_json.get("result") - if data is None: - self.logger().error(f"Error received from {url}. Response is {response_json}.") - raise IOError({"error": response_json}) - return data - - async def get_order(self, client_order_id: str) -> Dict[str, Any]: - o = self._in_flight_orders.get(client_order_id) - result = await self._api_request_with_retry("POST", - CONSTANTS.QUERY_ORDERS_PATH_URL, - data={"txid": o.exchange_order_id}, - is_auth_required=True) - return result - - def supported_order_types(self): - return [OrderType.LIMIT, OrderType.LIMIT_MAKER, OrderType.MARKET] - - async def place_order(self, - userref: int, - trading_pair: str, - amount: Decimal, - order_type: OrderType, - is_buy: bool, - price: Optional[Decimal] = s_decimal_NaN): - - trading_pair = convert_to_exchange_trading_pair(trading_pair) - data = { - "pair": trading_pair, - "type": "buy" if is_buy else "sell", - "ordertype": "market" if order_type is OrderType.MARKET else "limit", - "volume": str(amount), - "userref": userref, - "price": str(price) - } - if order_type is OrderType.LIMIT_MAKER: - data["oflags"] = "post" - return await self._api_request_with_retry("post", - CONSTANTS.ADD_ORDER_PATH_URL, - data=data, - is_auth_required=True) - - async def execute_buy(self, - order_id: str, - trading_pair: str, - amount: Decimal, - order_type: OrderType, - price: Optional[Decimal] = s_decimal_NaN, - userref: int = 0): - cdef: - TradingRule trading_rule = self._trading_rules[trading_pair] - str base_currency = self.split_trading_pair(trading_pair)[0] - str quote_currency = self.split_trading_pair(trading_pair)[1] - - decimal_amount = self.c_quantize_order_amount(trading_pair, amount) - decimal_price = self.c_quantize_order_price(trading_pair, price) - if decimal_amount < trading_rule.min_order_size: - raise ValueError(f"Buy order amount {decimal_amount} is lower than the minimum order size " - f"{trading_rule.min_order_size}.") - - try: - order_result = None - order_decimal_amount = f"{decimal_amount:f}" - if order_type in self.supported_order_types(): - order_decimal_price = f"{decimal_price:f}" - self.c_start_tracking_order( - order_id, - "", - trading_pair, - TradeType.BUY, - decimal_price, - decimal_amount, - order_type, - userref - ) - order_result = await self.place_order(userref=userref, - trading_pair=trading_pair, - amount=order_decimal_amount, - order_type=order_type, - is_buy=True, - price=order_decimal_price) - else: - raise ValueError(f"Invalid OrderType {order_type}. Aborting.") - - exchange_order_id = order_result["txid"][0] - tracked_order = self._in_flight_orders.get(order_id) - if tracked_order is not None: - self.logger().info(f"Created {order_type} buy order {order_id} for " - f"{decimal_amount} {trading_pair}.") - tracked_order.update_exchange_order_id(exchange_order_id) - self.c_trigger_event(self.MARKET_BUY_ORDER_CREATED_EVENT_TAG, - BuyOrderCreatedEvent( - self._current_timestamp, - order_type, - trading_pair, - decimal_amount, - decimal_price, - order_id, - tracked_order.creation_timestamp - )) - - except asyncio.CancelledError: - raise - - except Exception as e: - self.c_stop_tracking_order(order_id) - if order_type is OrderType.LIMIT: - order_type_str = 'LIMIT' - elif order_type is OrderType.LIMIT_MAKER: - order_type_str = 'LIMIT_MAKER' - else: - order_type_str = 'MARKET' - self.logger().network( - f"Error submitting buy {order_type_str} order to Kraken for " - f"{decimal_amount} {trading_pair}" - f" {decimal_price}.", - exc_info=True, - app_warning_msg=f"Failed to submit buy order to Kraken. Check API key and network connection." - ) - self.c_trigger_event(self.MARKET_ORDER_FAILURE_EVENT_TAG, - MarketOrderFailureEvent(self._current_timestamp, order_id, order_type)) - - cdef str c_buy(self, str trading_pair, object amount, object order_type=OrderType.LIMIT, object price=s_decimal_NaN, - dict kwargs={}): - cdef: - int64_t tracking_nonce = get_tracking_nonce() - int32_t userref = self.generate_userref() - str order_id = str(f"buy-{trading_pair}-{tracking_nonce}") - safe_ensure_future(self.execute_buy(order_id, trading_pair, amount, order_type, price=price, userref=userref)) - return order_id - - async def execute_sell(self, - order_id: str, - trading_pair: str, - amount: Decimal, - order_type: OrderType, - price: Optional[Decimal] = Decimal("NaN"), - userref: int = 0): - cdef: - TradingRule trading_rule = self._trading_rules[trading_pair] - - decimal_amount = self.quantize_order_amount(trading_pair, amount) - decimal_price = self.c_quantize_order_price(trading_pair, price) - - if decimal_amount < trading_rule.min_order_size: - raise ValueError(f"Sell order amount {decimal_amount} is lower than the minimum order size " - f"{trading_rule.min_order_size}.") - - try: - order_result = None - order_decimal_amount = f"{decimal_amount:f}" - if order_type in self.supported_order_types(): - order_decimal_price = f"{decimal_price:f}" - self.c_start_tracking_order( - order_id, - "", - trading_pair, - TradeType.SELL, - decimal_price, - decimal_amount, - order_type, - userref - ) - order_result = await self.place_order(userref=userref, - trading_pair=trading_pair, - amount=order_decimal_amount, - order_type=order_type, - is_buy=False, - price=order_decimal_price) - else: - raise ValueError(f"Invalid OrderType {order_type}. Aborting.") - - exchange_order_id = order_result["txid"][0] - tracked_order = self._in_flight_orders.get(order_id) - if tracked_order is not None: - self.logger().info(f"Created {order_type} sell order {order_id} for " - f"{decimal_amount} {trading_pair}.") - tracked_order.update_exchange_order_id(exchange_order_id) - self.c_trigger_event(self.MARKET_SELL_ORDER_CREATED_EVENT_TAG, - SellOrderCreatedEvent( - self._current_timestamp, - order_type, - trading_pair, - decimal_amount, - decimal_price, - order_id, - tracked_order.creation_timestamp, - )) - except asyncio.CancelledError: - raise - except Exception: - self.c_stop_tracking_order(order_id) - if order_type is OrderType.LIMIT: - order_type_str = 'LIMIT' - elif order_type is OrderType.LIMIT_MAKER: - order_type_str = 'LIMIT_MAKER' - else: - order_type_str = 'MAKER' - self.logger().network( - f"Error submitting sell {order_type_str} order to Kraken for " - f"{decimal_amount} {trading_pair} " - f"{decimal_price}.", - exc_info=True, - app_warning_msg=f"Failed to submit sell order to Kraken. Check API key and network connection." - ) - self.c_trigger_event(self.MARKET_ORDER_FAILURE_EVENT_TAG, - MarketOrderFailureEvent(self._current_timestamp, order_id, order_type)) - - cdef str c_sell(self, - str trading_pair, - object amount, - object order_type=OrderType.LIMIT, - object price=s_decimal_NaN, - dict kwargs={}): - cdef: - int64_t tracking_nonce = get_tracking_nonce() - int32_t userref = self.generate_userref() - str order_id = str(f"sell-{trading_pair}-{tracking_nonce}") - safe_ensure_future(self.execute_sell(order_id, trading_pair, amount, order_type, price=price, userref=userref)) - return order_id - - async def execute_cancel(self, trading_pair: str, order_id: str): - try: - tracked_order = self._in_flight_orders.get(order_id) - if tracked_order is None: - raise ValueError(f"Failed to cancel order – {order_id}. Order not found.") - if tracked_order.is_local: - raise KrakenInFlightOrderNotCreated(f"Failed to cancel order - {order_id}. Order not yet created.") - - data: Dict[str, str] = {"txid": tracked_order.exchange_order_id} - cancel_result = await self._api_request_with_retry("POST", - CONSTANTS.CANCEL_ORDER_PATH_URL, - data=data, - is_auth_required=True) - - if isinstance(cancel_result, dict) and (cancel_result.get("count") == 1 or cancel_result.get("error") is not None): - self.logger().info(f"Successfully canceled order {order_id}.") - self.c_stop_tracking_order(order_id) - self.c_trigger_event(self.MARKET_ORDER_CANCELED_EVENT_TAG, - OrderCancelledEvent(self._current_timestamp, order_id)) - return { - "origClientOrderId": order_id - } - except KrakenInFlightOrderNotCreated: - raise - except Exception as e: - self.logger().warning(f"Error canceling order on Kraken", - exc_info=True) - - cdef c_cancel(self, str trading_pair, str order_id): - safe_ensure_future(self.execute_cancel(trading_pair, order_id)) - return order_id - - async def cancel_all(self, timeout_seconds: float) -> List[CancellationResult]: - incomplete_orders = [(key, o) for (key, o) in self._in_flight_orders.items() if not o.is_done] - tasks = [self.execute_cancel(o.trading_pair, key) for (key, o) in incomplete_orders] - order_id_set = set([key for (key, o) in incomplete_orders]) - successful_cancellations = [] - - try: - async with timeout(timeout_seconds): - cancellation_results = await safe_gather(*tasks, return_exceptions=True) - for cr in cancellation_results: - if isinstance(cr, Exception): - continue - if isinstance(cr, dict) and "origClientOrderId" in cr: - client_order_id = cr.get("origClientOrderId") - order_id_set.remove(client_order_id) - successful_cancellations.append(CancellationResult(client_order_id, True)) - except Exception: - self.logger().network( - f"Unexpected error canceling orders.", - exc_info=True, - app_warning_msg="Failed to cancel order with Kraken. Check API key and network connection." - ) - - failed_cancellations = [CancellationResult(oid, False) for oid in order_id_set] - return successful_cancellations + failed_cancellations - - cdef OrderBook c_get_order_book(self, str trading_pair): - cdef: - dict order_books = self.order_book_tracker.order_books - - if trading_pair not in order_books: - raise ValueError(f"No order book exists for '{trading_pair}'.") - return order_books[trading_pair] - - cdef c_did_timeout_tx(self, str tracking_id): - self.c_trigger_event(self.MARKET_TRANSACTION_FAILURE_EVENT_TAG, - MarketTransactionFailureEvent(self._current_timestamp, tracking_id)) - - def start_tracking_order(self, - order_id: str, - exchange_order_id: str, - trading_pair: str, - trade_type: TradeType, - price: float, - amount: float, - order_type: OrderType, - userref: int): - """Used for testing.""" - self.c_start_tracking_order( - order_id, exchange_order_id, trading_pair, trade_type, price, amount, order_type, userref - ) - - cdef c_start_tracking_order(self, - str order_id, - str exchange_order_id, - str trading_pair, - object trade_type, - object price, - object amount, - object order_type, - int userref): - self._in_flight_orders[order_id] = KrakenInFlightOrder( - client_order_id=order_id, - exchange_order_id=exchange_order_id, - trading_pair=trading_pair, - trade_type=trade_type, - price=price, - amount=amount, - order_type=order_type, - creation_timestamp=self.current_timestamp, - userref=userref - ) - - cdef c_stop_tracking_order(self, str order_id): - if order_id in self._in_flight_orders: - del self._in_flight_orders[order_id] - if order_id in self._order_not_found_records: - del self._order_not_found_records[order_id] - - cdef object c_get_order_price_quantum(self, str trading_pair, object price): - cdef: - TradingRule trading_rule = self._trading_rules[trading_pair] - return trading_rule.min_price_increment - - cdef object c_get_order_size_quantum(self, str trading_pair, object order_size): - cdef: - TradingRule trading_rule = self._trading_rules[trading_pair] - return Decimal(trading_rule.min_base_amount_increment) - - cdef object c_quantize_order_amount(self, str trading_pair, object amount, object price=s_decimal_0): - cdef: - TradingRule trading_rule = self._trading_rules[trading_pair] - object quantized_amount = ExchangeBase.c_quantize_order_amount(self, trading_pair, amount) - - global s_decimal_0 - if quantized_amount < trading_rule.min_order_size: - return s_decimal_0 - - return quantized_amount - - def get_price(self, trading_pair: str, is_buy: bool) -> Decimal: - return self.c_get_price(trading_pair, is_buy) - - def buy(self, trading_pair: str, amount: Decimal, order_type=OrderType.MARKET, - price: Decimal = s_decimal_NaN, **kwargs) -> str: - return self.c_buy(trading_pair, amount, order_type, price, kwargs) - - def sell(self, trading_pair: str, amount: Decimal, order_type=OrderType.MARKET, - price: Decimal = s_decimal_NaN, **kwargs) -> str: - return self.c_sell(trading_pair, amount, order_type, price, kwargs) - - def cancel(self, trading_pair: str, client_order_id: str): - return self.c_cancel(trading_pair, client_order_id) - - 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) -> AddedToCostTradeFee: - return self.c_get_fee(base_currency, quote_currency, order_type, order_side, amount, price, is_maker) - - def get_order_book(self, trading_pair: str) -> OrderBook: - return self.c_get_order_book(trading_pair) - - def _build_async_throttler(self, api_tier: KrakenAPITier) -> AsyncThrottler: - limits_pct = self._client_config.rate_limits_share_pct - if limits_pct < Decimal("100"): - self.logger().warning( - f"The Kraken API does not allow enough bandwidth for a reduced rate-limit share percentage." - f" Current percentage: {limits_pct}." - ) - throttler = AsyncThrottler(build_rate_limits_by_tier(api_tier)) - return throttler - - async def all_trading_pairs(self) -> List[str]: - # This method should be removed and instead we should implement _initialize_trading_pair_symbol_map - return await KrakenAPIOrderBookDataSource.fetch_trading_pairs(throttler=self._throttler) - - async def get_last_traded_prices(self, trading_pairs: List[str]) -> Dict[str, float]: - # This method should be removed and instead we should implement _get_last_traded_price - return await KrakenAPIOrderBookDataSource.get_last_traded_prices( - trading_pairs=trading_pairs, - throttler=self._throttler) diff --git a/hummingbot/connector/exchange/kraken/kraken_in_flight_order.pxd b/hummingbot/connector/exchange/kraken/kraken_in_flight_order.pxd deleted file mode 100644 index 7cf591a66e..0000000000 --- a/hummingbot/connector/exchange/kraken/kraken_in_flight_order.pxd +++ /dev/null @@ -1,6 +0,0 @@ -from hummingbot.connector.in_flight_order_base cimport InFlightOrderBase - -cdef class KrakenInFlightOrder(InFlightOrderBase): - cdef: - public object trade_id_set - public int userref diff --git a/hummingbot/connector/exchange/kraken/kraken_in_flight_order.pyx b/hummingbot/connector/exchange/kraken/kraken_in_flight_order.pyx deleted file mode 100644 index 111e9bd125..0000000000 --- a/hummingbot/connector/exchange/kraken/kraken_in_flight_order.pyx +++ /dev/null @@ -1,100 +0,0 @@ -import math -from decimal import Decimal -from typing import ( - Any, - Dict, - List, -) - -from hummingbot.connector.in_flight_order_base import InFlightOrderBase -from hummingbot.core.data_type.common import OrderType, TradeType - -s_decimal_0 = Decimal(0) - - -class KrakenInFlightOrderNotCreated(Exception): - pass - - -cdef class KrakenInFlightOrder(InFlightOrderBase): - def __init__(self, - client_order_id: str, - exchange_order_id: str, - trading_pair: str, - order_type: OrderType, - trade_type: TradeType, - price: Decimal, - amount: Decimal, - creation_timestamp: float, - userref: int, - initial_state: str = "local"): - super().__init__( - client_order_id, - exchange_order_id, - trading_pair, - order_type, - trade_type, - price, - amount, - creation_timestamp, - initial_state, - ) - self.trade_id_set = set() - self.userref = userref - - @property - def is_local(self) -> bool: - return self.last_state in {"local"} - - @property - def is_done(self) -> bool: - return self.last_state in {"closed", "canceled", "expired"} - - @property - def is_failure(self) -> bool: - return self.last_state in {"canceled", "expired"} - - @property - def is_cancelled(self) -> bool: - return self.last_state in {"canceled"} - - @classmethod - def _instance_creation_parameters_from_json(cls, data: Dict[str, Any]) -> List[Any]: - arguments: List[Any] = super()._instance_creation_parameters_from_json(data) - arguments.insert(-1, data["userref"]) - return arguments - - def to_json(self): - json = super().to_json() - json.update({"userref": self.userref}) - return json - - def update_exchange_order_id(self, exchange_id: str): - super().update_exchange_order_id(exchange_id) - self.last_state = "new" - - def _mark_as_filled(self): - """ - Updates the status of the InFlightOrder as filled. - Note: Should only be called when order is completely filled. - """ - self.last_state = "closed" - - def update_with_trade_update(self, trade_update: Dict[str, Any]) -> bool: - """ - Updartes the InFlightOrder with the trade update (from WebSocket API ownTrades stream) - :return: True if the order gets updated otherwise False - """ - trade_id = trade_update["trade_id"] - if str(trade_update["ordertxid"]) != self.exchange_order_id or trade_id in self.trade_id_set: - # trade already recorded - return False - self.trade_id_set.add(trade_id) - self.executed_amount_base += Decimal(trade_update["vol"]) - self.fee_paid += Decimal(trade_update["fee"]) - self.executed_amount_quote += Decimal(trade_update["vol"]) * Decimal(trade_update["price"]) - if not self.fee_asset: - self.fee_asset = self.quote_asset - if (math.isclose(self.executed_amount_base, self.amount) or self.executed_amount_base >= self.amount): - self._mark_as_filled() - return True diff --git a/hummingbot/connector/exchange/kraken/kraken_order_book.pxd b/hummingbot/connector/exchange/kraken/kraken_order_book.pxd deleted file mode 100644 index 69517f8165..0000000000 --- a/hummingbot/connector/exchange/kraken/kraken_order_book.pxd +++ /dev/null @@ -1,4 +0,0 @@ -from hummingbot.core.data_type.order_book cimport OrderBook - -cdef class KrakenOrderBook(OrderBook): - pass diff --git a/hummingbot/connector/exchange/kraken/kraken_order_book.pyx b/hummingbot/connector/exchange/kraken/kraken_order_book.py similarity index 78% rename from hummingbot/connector/exchange/kraken/kraken_order_book.pyx rename to hummingbot/connector/exchange/kraken/kraken_order_book.py index 08b080302b..0c17770a12 100644 --- a/hummingbot/connector/exchange/kraken/kraken_order_book.pyx +++ b/hummingbot/connector/exchange/kraken/kraken_order_book.py @@ -1,27 +1,11 @@ -import logging -from typing import ( - Dict, - Optional -) +from typing import Dict, Optional from hummingbot.core.data_type.common import TradeType -from hummingbot.core.data_type.order_book cimport OrderBook -from hummingbot.core.data_type.order_book_message import ( - OrderBookMessage, - OrderBookMessageType -) -from hummingbot.logger import HummingbotLogger +from hummingbot.core.data_type.order_book import OrderBook +from hummingbot.core.data_type.order_book_message import OrderBookMessage, OrderBookMessageType -_krob_logger = None - -cdef class KrakenOrderBook(OrderBook): - @classmethod - def logger(cls) -> HummingbotLogger: - global _krob_logger - if _krob_logger is None: - _krob_logger = logging.getLogger(__name__) - return _krob_logger +class KrakenOrderBook(OrderBook): @classmethod def snapshot_message_from_exchange(cls, @@ -35,7 +19,7 @@ def snapshot_message_from_exchange(cls, "update_id": msg["latest_update"], "bids": msg["bids"], "asks": msg["asks"] - }, timestamp=timestamp * 1e-3) + }, timestamp=timestamp) @classmethod def diff_message_from_exchange(cls, @@ -49,7 +33,7 @@ def diff_message_from_exchange(cls, "update_id": msg["update_id"], "bids": msg["bids"], "asks": msg["asks"] - }, timestamp=timestamp * 1e-3) + }, timestamp=timestamp) @classmethod def snapshot_ws_message_from_exchange(cls, @@ -63,7 +47,7 @@ def snapshot_ws_message_from_exchange(cls, "update_id": msg["update_id"], "bids": msg["bids"], "asks": msg["asks"] - }, timestamp=timestamp * 1e-3) + }, timestamp=timestamp) @classmethod def trade_message_from_exchange(cls, msg: Dict[str, any], metadata: Optional[Dict] = None): @@ -77,7 +61,7 @@ def trade_message_from_exchange(cls, msg: Dict[str, any], metadata: Optional[Dic "update_id": ts, "price": msg["trade"][0], "amount": msg["trade"][1] - }, timestamp=ts * 1e-3) + }, timestamp=ts) @classmethod def from_snapshot(cls, msg: OrderBookMessage) -> "OrderBook": diff --git a/hummingbot/connector/exchange/kraken/kraken_order_book_tracker.py b/hummingbot/connector/exchange/kraken/kraken_order_book_tracker.py deleted file mode 100644 index d41c2139d4..0000000000 --- a/hummingbot/connector/exchange/kraken/kraken_order_book_tracker.py +++ /dev/null @@ -1,98 +0,0 @@ -#!/usr/bin/env python - -import asyncio -from collections import deque, defaultdict -import logging -import time -from typing import ( - Deque, - Dict, - List, - Optional -) - -from hummingbot.core.api_throttler.async_throttler import AsyncThrottler -from hummingbot.logger import HummingbotLogger -from hummingbot.core.data_type.order_book_tracker import ( - OrderBookTracker -) -from hummingbot.connector.exchange.kraken.kraken_api_order_book_data_source import KrakenAPIOrderBookDataSource -from hummingbot.core.data_type.order_book import OrderBook -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.utils.async_utils import wait_til -from hummingbot.core.web_assistant.web_assistants_factory import WebAssistantsFactory - - -class KrakenOrderBookTracker(OrderBookTracker): - _krobt_logger: Optional[HummingbotLogger] = None - - @classmethod - def logger(cls) -> HummingbotLogger: - if cls._krobt_logger is None: - cls._krobt_logger = logging.getLogger(__name__) - return cls._krobt_logger - - def __init__(self, - trading_pairs: List[str], - throttler: Optional[AsyncThrottler] = None, - api_factory: Optional[WebAssistantsFactory] = None, - ): - super().__init__(KrakenAPIOrderBookDataSource(throttler, trading_pairs), trading_pairs, api_factory) - self._api_factory = api_factory - self._order_book_diff_stream: asyncio.Queue = asyncio.Queue() - self._order_book_snapshot_stream: asyncio.Queue = asyncio.Queue() - self._ev_loop: asyncio.BaseEventLoop = asyncio.get_event_loop() - self._saved_message_queues: Dict[str, Deque[OrderBookMessage]] = defaultdict(lambda: deque(maxlen=1000)) - - @property - def data_source(self) -> OrderBookTrackerDataSource: - if not self._data_source: - self._data_source = KrakenAPIOrderBookDataSource(trading_pairs=self._trading_pairs, - api_factory=self._api_factory) - return self._data_source - - @property - def exchange_name(self) -> str: - return "kraken" - - async def _order_book_diff_router(self): - """ - Route the real-time order book diff messages to the correct order book. - """ - last_message_timestamp: float = time.time() - messages_accepted: int = 0 - messages_rejected: int = 0 - - await wait_til(lambda: len(self.data_source._trading_pairs) == len(self._order_books.keys())) - while True: - try: - ob_message: OrderBookMessage = await self._order_book_diff_stream.get() - trading_pair: str = ob_message.trading_pair - - if trading_pair not in self._tracking_message_queues: - messages_rejected += 1 - continue - message_queue: asyncio.Queue = self._tracking_message_queues[trading_pair] - # Check the order book's initial update ID. If it's larger, don't bother. - order_book: OrderBook = self._order_books[trading_pair] - - if order_book.snapshot_uid > ob_message.update_id: - messages_rejected += 1 - continue - await message_queue.put(ob_message) - messages_accepted += 1 - - # Log some statistics. - now: float = time.time() - if int(now / 60.0) > int(last_message_timestamp / 60.0): - self.logger().debug(f"Diff messages processed: {messages_accepted}, rejected: {messages_rejected}") - messages_accepted = 0 - messages_rejected = 0 - - last_message_timestamp = now - except asyncio.CancelledError: - raise - except Exception: - self.logger().error("Unknown error. Retrying after 5 seconds.", exc_info=True) - await asyncio.sleep(5.0) diff --git a/hummingbot/connector/exchange/kraken/kraken_tracking_nonce.py b/hummingbot/connector/exchange/kraken/kraken_tracking_nonce.py deleted file mode 100644 index 78f0069c64..0000000000 --- a/hummingbot/connector/exchange/kraken/kraken_tracking_nonce.py +++ /dev/null @@ -1,10 +0,0 @@ -import time - -_last_tracking_nonce: int = 0 - - -def get_tracking_nonce() -> str: - global _last_tracking_nonce - nonce = int(time.time()) - _last_tracking_nonce = nonce if nonce > _last_tracking_nonce else _last_tracking_nonce + 1 - return str(_last_tracking_nonce) diff --git a/hummingbot/connector/exchange/kraken/kraken_user_stream_tracker.py b/hummingbot/connector/exchange/kraken/kraken_user_stream_tracker.py deleted file mode 100644 index 4660d7c02e..0000000000 --- a/hummingbot/connector/exchange/kraken/kraken_user_stream_tracker.py +++ /dev/null @@ -1,52 +0,0 @@ -import logging -from typing import Optional - -from hummingbot.connector.exchange.kraken.kraken_api_user_stream_data_source import KrakenAPIUserStreamDataSource -from hummingbot.connector.exchange.kraken.kraken_auth import KrakenAuth -from hummingbot.core.api_throttler.async_throttler import AsyncThrottler -from hummingbot.core.data_type.user_stream_tracker import UserStreamTracker -from hummingbot.core.data_type.user_stream_tracker_data_source import UserStreamTrackerDataSource -from hummingbot.core.utils.async_utils import ( - safe_ensure_future, - safe_gather, -) -from hummingbot.core.web_assistant.web_assistants_factory import WebAssistantsFactory -from hummingbot.logger import HummingbotLogger - - -class KrakenUserStreamTracker(UserStreamTracker): - _krust_logger: Optional[HummingbotLogger] = None - - @classmethod - def logger(cls) -> HummingbotLogger: - if cls._krust_logger is None: - cls._krust_logger = logging.getLogger(__name__) - return cls._krust_logger - - def __init__(self, - throttler: AsyncThrottler, - kraken_auth: KrakenAuth, - api_factory: Optional[WebAssistantsFactory] = None): - self._throttler = throttler - self._api_factory = api_factory - self._kraken_auth: KrakenAuth = kraken_auth - super().__init__(data_source=KrakenAPIUserStreamDataSource( - self._throttler, - self._kraken_auth, - self._api_factory)) - - @property - def data_source(self) -> UserStreamTrackerDataSource: - if not self._data_source: - self._data_source = KrakenAPIUserStreamDataSource(self._throttler, self._kraken_auth, self._api_factory) - return self._data_source - - @property - def exchange_name(self) -> str: - return "kraken" - - async def start(self): - self._user_stream_tracking_task = safe_ensure_future( - self.data_source.listen_for_user_stream(self._user_stream) - ) - await safe_gather(self._user_stream_tracking_task) diff --git a/hummingbot/connector/exchange/kraken/kraken_utils.py b/hummingbot/connector/exchange/kraken/kraken_utils.py index d00c27842b..2c93bcc353 100644 --- a/hummingbot/connector/exchange/kraken/kraken_utils.py +++ b/hummingbot/connector/exchange/kraken/kraken_utils.py @@ -1,23 +1,23 @@ -from typing import Any, Dict, List, Optional, Tuple +from decimal import Decimal +from typing import List, Optional, Tuple from pydantic import Field, SecretStr +from pydantic.class_validators import validator import hummingbot.connector.exchange.kraken.kraken_constants as CONSTANTS from hummingbot.client.config.config_data_types import BaseConnectorConfigMap, ClientFieldData from hummingbot.connector.exchange.kraken.kraken_constants import KrakenAPITier -from hummingbot.core.api_throttler.async_throttler import AsyncThrottler from hummingbot.core.api_throttler.data_types import LinkedLimitWeightPair, RateLimit -from hummingbot.core.web_assistant.web_assistants_factory import WebAssistantsFactory +from hummingbot.core.data_type.trade_fee import TradeFeeSchema CENTRALIZED = True EXAMPLE_PAIR = "ETH-USDC" -DEFAULT_FEES = [0.16, 0.26] - - -def split_trading_pair(trading_pair: str) -> Tuple[str, str]: - return tuple(convert_from_exchange_trading_pair(trading_pair).split("-")) +DEFAULT_FEES = TradeFeeSchema( + maker_percent_fee_decimal=Decimal("0.16"), + taker_percent_fee_decimal=Decimal("0.26"), +) def convert_from_exchange_symbol(symbol: str) -> str: @@ -37,7 +37,8 @@ def split_to_base_quote(exchange_trading_pair: str) -> Tuple[Optional[str], Opti return base, quote -def convert_from_exchange_trading_pair(exchange_trading_pair: str, available_trading_pairs: Optional[Tuple] = None) -> Optional[str]: +def convert_from_exchange_trading_pair(exchange_trading_pair: str, available_trading_pairs: Optional[Tuple] = None) -> \ + Optional[str]: base, quote = "", "" if "-" in exchange_trading_pair: base, quote = split_to_base_quote(exchange_trading_pair) @@ -46,7 +47,8 @@ def convert_from_exchange_trading_pair(exchange_trading_pair: str, available_tra elif len(available_trading_pairs) > 0: # If trading pair has no spaces (i.e. ETHUSDT). Then it will have to match with the existing pairs # Option 1: Using traditional naming convention - connector_trading_pair = {''.join(convert_from_exchange_trading_pair(tp).split('-')): tp for tp in available_trading_pairs}.get( + connector_trading_pair = {''.join(convert_from_exchange_trading_pair(tp).split('-')): tp for tp in + available_trading_pairs}.get( exchange_trading_pair) if not connector_trading_pair: # Option 2: Using kraken naming convention ( XXBT for Bitcoin, XXDG for Doge, ZUSD for USD, etc) @@ -84,17 +86,6 @@ def convert_to_exchange_trading_pair(hb_trading_pair: str, delimiter: str = "") return exchange_trading_pair -def is_dark_pool(trading_pair_details: Dict[str, Any]): - ''' - Want to filter out dark pool trading pairs from the list of trading pairs - For more info, please check - https://support.kraken.com/hc/en-us/articles/360001391906-Introducing-the-Kraken-Dark-Pool - ''' - if trading_pair_details.get('altname'): - return trading_pair_details.get('altname').endswith('.d') - return False - - def _build_private_rate_limits(tier: KrakenAPITier = KrakenAPITier.STARTER) -> List[RateLimit]: private_rate_limits = [] @@ -136,6 +127,13 @@ def _build_private_rate_limits(tier: KrakenAPITier = KrakenAPITier.STARTER) -> L weight=2, linked_limits=[LinkedLimitWeightPair(CONSTANTS.PRIVATE_ENDPOINT_LIMIT_ID)], ), + RateLimit( + limit_id=CONSTANTS.QUERY_TRADES_PATH_URL, + limit=PRIVATE_ENDPOINT_LIMIT, + time_interval=CONSTANTS.PRIVATE_ENDPOINT_LIMIT_INTERVAL, + weight=2, + linked_limits=[LinkedLimitWeightPair(CONSTANTS.PRIVATE_ENDPOINT_LIMIT_ID)], + ), ]) # Matching Engine Limits @@ -166,16 +164,6 @@ def build_rate_limits_by_tier(tier: KrakenAPITier = KrakenAPITier.STARTER) -> Li return rate_limits -def _api_tier_validator(value: str) -> Optional[str]: - """ - Determines if input value is a valid API tier - """ - try: - KrakenAPITier(value.upper()) - except ValueError: - return "No such Kraken API Tier." - - class KrakenConfigMap(BaseConnectorConfigMap): connector: str = Field(default="kraken", client_data=None) kraken_api_key: SecretStr = Field( @@ -208,10 +196,16 @@ class KrakenConfigMap(BaseConnectorConfigMap): class Config: title = "kraken" - -KEYS = KrakenConfigMap.construct() + @validator("kraken_api_tier", pre=True) + def _api_tier_validator(cls, value: str) -> Optional[str]: + """ + Determines if input value is a valid API tier + """ + try: + KrakenAPITier(value.upper()) + return value + except ValueError: + raise ValueError("No such Kraken API Tier.") -def build_api_factory(throttler: AsyncThrottler) -> WebAssistantsFactory: - api_factory = WebAssistantsFactory(throttler=throttler) - return api_factory +KEYS = KrakenConfigMap.construct() diff --git a/hummingbot/connector/exchange/kraken/kraken_web_utils.py b/hummingbot/connector/exchange/kraken/kraken_web_utils.py new file mode 100644 index 0000000000..5f5011d406 --- /dev/null +++ b/hummingbot/connector/exchange/kraken/kraken_web_utils.py @@ -0,0 +1,54 @@ +import time +from typing import Optional + +import hummingbot.connector.exchange.kraken.kraken_constants as CONSTANTS +from hummingbot.core.api_throttler.async_throttler import AsyncThrottler +from hummingbot.core.web_assistant.auth import AuthBase +from hummingbot.core.web_assistant.web_assistants_factory import WebAssistantsFactory + + +def private_rest_url(*args, **kwargs) -> str: + return rest_url(*args, **kwargs) + + +def public_rest_url(*args, **kwargs) -> str: + return rest_url(*args, **kwargs) + + +def rest_url(path_url: str, domain: str = "kraken"): + base_url = CONSTANTS.BASE_URL + return base_url + path_url + + +def build_api_factory( + throttler: Optional[AsyncThrottler] = None, + auth: Optional[AuthBase] = None, ) -> WebAssistantsFactory: + throttler = throttler + api_factory = WebAssistantsFactory( + throttler=throttler, + auth=auth + ) + return api_factory + + +def is_exchange_information_valid(trading_pair_details) -> 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 + Want to filter out dark pool trading pairs from the list of trading pairs + For more info, please check + https://support.kraken.com/hc/en-us/articles/360001391906-Introducing-the-Kraken-Dark-Pool + """ + if trading_pair_details.get('altname'): + return not trading_pair_details.get('altname').endswith('.d') + return True + + +async def get_current_server_time( + throttler, + domain +) -> float: + return time.time() diff --git a/test/hummingbot/connector/exchange/kraken/test_kraken_api_order_book_data_source.py b/test/hummingbot/connector/exchange/kraken/test_kraken_api_order_book_data_source.py index 55538894aa..65f040071f 100644 --- a/test/hummingbot/connector/exchange/kraken/test_kraken_api_order_book_data_source.py +++ b/test/hummingbot/connector/exchange/kraken/test_kraken_api_order_book_data_source.py @@ -2,15 +2,18 @@ import json import re import unittest -from decimal import Decimal -from typing import Awaitable, Dict, List -from unittest.mock import AsyncMock, patch +from typing import Awaitable +from unittest.mock import AsyncMock, MagicMock, patch from aioresponses import aioresponses +from bidict import bidict -from hummingbot.connector.exchange.kraken import kraken_constants as CONSTANTS +from hummingbot.client.config.client_config_map import ClientConfigMap +from hummingbot.client.config.config_helpers import ClientConfigAdapter +from hummingbot.connector.exchange.kraken import kraken_constants as CONSTANTS, kraken_web_utils as web_utils from hummingbot.connector.exchange.kraken.kraken_api_order_book_data_source import KrakenAPIOrderBookDataSource from hummingbot.connector.exchange.kraken.kraken_constants import KrakenAPITier +from hummingbot.connector.exchange.kraken.kraken_exchange import KrakenExchange from hummingbot.connector.exchange.kraken.kraken_utils import build_rate_limits_by_tier from hummingbot.connector.test_support.network_mocking_assistant import NetworkMockingAssistant from hummingbot.core.api_throttler.async_throttler import AsyncThrottler @@ -18,6 +21,8 @@ class KrakenAPIOrderBookDataSourceTest(unittest.TestCase): + level = 0 + @classmethod def setUpClass(cls) -> None: super().setUpClass() @@ -25,65 +30,101 @@ def setUpClass(cls) -> None: 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.ws_ex_trading_pairs = cls.base_asset + "/" + cls.quote_asset cls.api_tier = KrakenAPITier.STARTER def setUp(self) -> None: super().setUp() + self.log_records = [] + self.listening_task = None self.mocking_assistant = NetworkMockingAssistant() + self.throttler = AsyncThrottler(build_rate_limits_by_tier(self.api_tier)) - self.data_source = KrakenAPIOrderBookDataSource(self.throttler, trading_pairs=[self.trading_pair]) + client_config_map = ClientConfigAdapter(ClientConfigMap()) + self.connector = KrakenExchange( + client_config_map=client_config_map, + kraken_api_key="", + kraken_secret_key="", + trading_pairs=[], + trading_required=False) + self.data_source = KrakenAPIOrderBookDataSource( + connector=self.connector, + api_factory=self.connector._web_assistants_factory, + trading_pairs=[self.trading_pair]) + + 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.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() + 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: int = 1): ret = self.ev_loop.run_until_complete(asyncio.wait_for(coroutine, timeout)) return ret - def get_last_traded_prices_mock(self, last_trade_close: Decimal) -> Dict: - last_traded_prices = { - "error": [], - "result": { - f"X{self.base_asset}{self.quote_asset}": { - "a": [ - "52609.60000", - "1", - "1.000" - ], - "b": [ - "52609.50000", - "1", - "1.000" - ], - "c": [ - str(last_trade_close), - "0.00080000" - ], - "v": [ - "1920.83610601", - "7954.00219674" - ], - "p": [ - "52389.94668", - "54022.90683" - ], - "t": [ - 23329, - 80463 - ], - "l": [ - "51513.90000", - "51513.90000" - ], - "h": [ - "53219.90000", - "57200.00000" + def _trade_update_event(self): + resp = [ + 0, + [ + [ + "5541.20000", + "0.15850568", + "1534614057.321597", + "s", + "l", + "" + ] + ], + "trade", + f"{self.base_asset}/{self.quote_asset}" + ] + return resp + + def _order_diff_event(self): + resp = [ + 1234, + { + "a": [ + [ + "5541.30000", + "2.50700000", + "1534614248.456738" ], - "o": "52280.40000" - } - } - } - return last_traded_prices + [ + "5542.50000", + "0.40100000", + "1534614248.456738" + ] + ], + "c": "974942666" + }, + "book-10", + "XBT/USD" + ] + return resp - def get_depth_mock(self) -> Dict: - depth = { + def _snapshot_response(self): + resp = { "error": [], "result": { f"X{self.base_asset}{self.quote_asset}": { @@ -114,113 +155,16 @@ def get_depth_mock(self) -> Dict: } } } - return depth - - def get_public_asset_pair_mock(self) -> Dict: - asset_pairs = { - "error": [], - "result": { - f"X{self.base_asset}{self.quote_asset}": { - "altname": f"{self.base_asset}{self.quote_asset}", - "wsname": f"{self.base_asset}/{self.quote_asset}", - "aclass_base": "currency", - "base": self.base_asset, - "aclass_quote": "currency", - "quote": self.quote_asset, - "lot": "unit", - "pair_decimals": 5, - "lot_decimals": 8, - "lot_multiplier": 1, - "leverage_buy": [ - 2, - 3, - 4, - 5 - ], - "leverage_sell": [ - 2, - 3, - 4, - 5 - ], - "fees": [ - [ - 0, - 0.26 - ], - [ - 50000, - 0.24 - ], - ], - "fees_maker": [ - [ - 0, - 0.16 - ], - [ - 50000, - 0.14 - ], - ], - "fee_volume_currency": "ZUSD", - "margin_call": 80, - "margin_stop": 40, - "ordermin": "0.005" - }, - } - } - return asset_pairs - - def get_trade_data_mock(self) -> List: - trade_data = [ - 0, - [ - [ - "5541.20000", - "0.15850568", - "1534614057.321597", - "s", - "l", - "" - ], - [ - "6060.00000", - "0.02455000", - "1534614057.324998", - "b", - "l", - "" - ] - ], - "trade", - f"{self.base_asset}/{self.quote_asset}" - ] - return trade_data + return resp @aioresponses() - def test_get_last_traded_prices(self, mocked_api): - url = f"{CONSTANTS.BASE_URL}{CONSTANTS.TICKER_PATH_URL}" - regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) - last_traded_price = Decimal("52641.10000") - resp = self.get_last_traded_prices_mock(last_trade_close=last_traded_price) - mocked_api.get(regex_url, body=json.dumps(resp)) - - ret = self.async_run_with_timeout( - KrakenAPIOrderBookDataSource.get_last_traded_prices( - trading_pairs=[self.trading_pair], throttler=self.throttler - ) - ) + def test_get_new_order_book_successful(self, mock_api): + url = web_utils.public_rest_url(path_url=CONSTANTS.SNAPSHOT_PATH_URL) + regex_url = re.compile(f"^{url}?pair={self.ex_trading_pair}".replace(".", r"\.").replace("?", r"\?")) - self.assertIn(self.trading_pair, ret) - self.assertEqual(float(last_traded_price), ret[self.trading_pair]) + resp = self._snapshot_response() - @aioresponses() - def test_get_new_order_book(self, mocked_api): - url = f"{CONSTANTS.BASE_URL}{CONSTANTS.SNAPSHOT_PATH_URL}" - regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) - resp = self.get_depth_mock() - mocked_api.get(regex_url, body=json.dumps(resp)) + mock_api.get(regex_url, body=json.dumps(resp)) ret = self.async_run_with_timeout(self.data_source.get_new_order_book(self.trading_pair)) @@ -234,38 +178,254 @@ def test_get_new_order_book(self, mocked_api): self.assertEqual(first_bid_price, bids_df.iloc[0]["price"]) self.assertEqual(first_ask_price, asks_df.iloc[0]["price"]) - # @aioresponses() - # def test_fetch_trading_pairs(self, mocked_api): - # url = f"{CONSTANTS.BASE_URL}{CONSTANTS.ASSET_PAIRS_PATH_URL}" - # regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) - # resp = self.get_public_asset_pair_mock() - # mocked_api.get(regex_url, body=json.dumps(resp)) - # - # resp = self.async_run_with_timeout(KrakenAPIOrderBookDataSource.fetch_trading_pairs(), 2) - # - # self.assertTrue(len(resp) == 1) - # self.assertIn(self.trading_pair, resp) + @aioresponses() + def test_get_new_order_book_raises_exception(self, mock_api): + url = web_utils.public_rest_url(path_url=CONSTANTS.SNAPSHOT_PATH_URL) + regex_url = re.compile(f"^{url}?pair={self.ex_trading_pair}".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_trades(self, ws_connect_mock): + 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() - resp = self.get_trade_data_mock() + + result_subscribe_trades = { + "code": None, + "id": 1 + } + result_subscribe_diffs = { + "code": 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(resp) + websocket_mock=ws_connect_mock.return_value, + message=json.dumps(result_subscribe_diffs)) + + self.listening_task = self.ev_loop.create_task(self.data_source.listen_for_subscriptions()) + + self.mocking_assistant.run_until_all_aiohttp_messages_delivered(ws_connect_mock.return_value) + + sent_subscription_messages = self.mocking_assistant.json_messages_sent_through_websocket( + websocket_mock=ws_connect_mock.return_value) + + self.assertEqual(2, len(sent_subscription_messages)) + expected_trade_subscription = { + "event": "subscribe", + "pair": [self.ws_ex_trading_pairs], + "subscription": {"name": 'trade'}, + } + self.assertEqual(expected_trade_subscription, sent_subscription_messages[0]) + expected_diff_subscription = { + "event": "subscribe", + "pair": [self.ws_ex_trading_pairs], + "subscription": {"name": 'book', "depth": 1000}, + } + 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 data 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[self.data_source._trade_messages_queue_key] = 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[self.data_source._trade_messages_queue_key] = 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[self.data_source._trade_messages_queue_key] = 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(1534614057.321597, 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[self.data_source._diff_messages_queue_key] = 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[self.data_source._diff_messages_queue_key] = 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[self.data_source._diff_messages_queue_key] = 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[1]["a"][0][2], str(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) + regex_url = re.compile(f"^{url}?pair={self.ex_trading_pair}".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.kraken.kraken_api_order_book_data_source" + ".KrakenAPIOrderBookDataSource._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) + regex_url = re.compile(f"^{url}?pair={self.ex_trading_pair}".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) + regex_url = re.compile(f"^{url}?pair={self.ex_trading_pair}".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) ) - output_queue = asyncio.Queue() - - self.ev_loop.create_task(self.data_source.listen_for_trades(self.ev_loop, output_queue)) - self.mocking_assistant.run_until_all_aiohttp_messages_delivered(websocket_mock=ws_connect_mock.return_value) - - self.assertTrue(not output_queue.empty()) - msg = output_queue.get_nowait() - self.assertTrue(isinstance(msg, OrderBookMessage)) - first_trade_price = resp[1][0][0] - self.assertEqual(msg.content["price"], first_trade_price) - - self.assertTrue(not output_queue.empty()) - msg = output_queue.get_nowait() - self.assertTrue(isinstance(msg, OrderBookMessage)) - second_trade_price = resp[1][1][0] - self.assertEqual(msg.content["price"], second_trade_price) + + msg: OrderBookMessage = self.async_run_with_timeout(msg_queue.get()) + + self.assertEqual(1616663113, msg.update_id) diff --git a/test/hummingbot/connector/exchange/kraken/test_kraken_api_user_stream_data_source.py b/test/hummingbot/connector/exchange/kraken/test_kraken_api_user_stream_data_source.py index 7786f2ffa6..57cb2b717c 100644 --- a/test/hummingbot/connector/exchange/kraken/test_kraken_api_user_stream_data_source.py +++ b/test/hummingbot/connector/exchange/kraken/test_kraken_api_user_stream_data_source.py @@ -2,21 +2,27 @@ import json import re import unittest -from typing import Awaitable, Dict, List -from unittest.mock import AsyncMock, patch +from typing import Awaitable, Dict, List, Optional +from unittest.mock import AsyncMock, MagicMock, patch from aioresponses import aioresponses +from bidict import bidict +from hummingbot.client.config.client_config_map import ClientConfigMap +from hummingbot.client.config.config_helpers import ClientConfigAdapter from hummingbot.connector.exchange.kraken import kraken_constants as CONSTANTS from hummingbot.connector.exchange.kraken.kraken_api_user_stream_data_source import KrakenAPIUserStreamDataSource from hummingbot.connector.exchange.kraken.kraken_auth import KrakenAuth from hummingbot.connector.exchange.kraken.kraken_constants import KrakenAPITier +from hummingbot.connector.exchange.kraken.kraken_exchange import KrakenExchange from hummingbot.connector.exchange.kraken.kraken_utils import build_rate_limits_by_tier from hummingbot.connector.test_support.network_mocking_assistant import NetworkMockingAssistant from hummingbot.core.api_throttler.async_throttler import AsyncThrottler class KrakenAPIUserStreamDataSourceTest(unittest.TestCase): + level = 0 + @classmethod def setUpClass(cls) -> None: super().setUpClass() @@ -24,17 +30,54 @@ def setUpClass(cls) -> None: cls.base_asset = "COINALPHA" cls.quote_asset = "HBOT" cls.trading_pair = f"{cls.base_asset}-{cls.quote_asset}" + cls.ex_trading_pair = f"{cls.base_asset}{cls.quote_asset}" + cls.ws_ex_trading_pair = cls.base_asset + "/" + cls.quote_asset cls.api_tier = KrakenAPITier.STARTER def setUp(self) -> None: super().setUp() + self.log_records = [] + self.listening_task: Optional[asyncio.Task] = None self.mocking_assistant = NetworkMockingAssistant() + self.throttler = AsyncThrottler(build_rate_limits_by_tier(self.api_tier)) + self.mock_time_provider = MagicMock() + + client_config_map = ClientConfigAdapter(ClientConfigMap()) + self.connector = KrakenExchange( + client_config_map=client_config_map, + kraken_api_key="", + kraken_secret_key="", + trading_pairs=[self.trading_pair], + trading_required=False) + not_a_real_secret = "kQH5HW/8p1uGOVjbgWA7FunAmGO8lsSUXNsu3eow76sz84Q18fWxnyRzBHCd3pd5nE9qa99HAZtuZuj6F1huXg==" - kraken_auth = KrakenAuth(api_key="someKey", secret_key=not_a_real_secret) - self.data_source = KrakenAPIUserStreamDataSource(self.throttler, kraken_auth) + self.auth = KrakenAuth(api_key="someKey", secret_key=not_a_real_secret, time_provider=self.mock_time_provider) + + self.connector._web_assistants_factory._auth = self.auth + self.data_source = KrakenAPIUserStreamDataSource(self.connector, + api_factory=self.connector._web_assistants_factory, + ) + + self.data_source.logger().setLevel(1) + self.data_source.logger().addHandler(self) - def async_run_with_timeout(self, coroutine: Awaitable, timeout: int = 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() + super().tearDown() + + def handle(self, record): + self.log_records.append(record) + + def _is_logged(self, log_level: str, message: str) -> bool: + return any(record.levelname == log_level and record.getMessage() == message + for record in self.log_records) + + def async_run_with_timeout(self, coroutine: Awaitable, timeout: float = 1): ret = self.ev_loop.run_until_complete(asyncio.wait_for(coroutine, timeout)) return ret @@ -116,7 +159,6 @@ def test_listen_for_user_stream(self, mocked_api, ws_connect_mock): regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) resp = self.get_auth_response_mock() mocked_api.post(regex_url, body=json.dumps(resp)) - ws_connect_mock.return_value = self.mocking_assistant.create_websocket_mock() output_queue = asyncio.Queue() self.ev_loop.create_task(self.data_source.listen_for_user_stream(output_queue)) diff --git a/test/hummingbot/connector/exchange/kraken/test_kraken_auth.py b/test/hummingbot/connector/exchange/kraken/test_kraken_auth.py new file mode 100644 index 0000000000..290ee5d7e1 --- /dev/null +++ b/test/hummingbot/connector/exchange/kraken/test_kraken_auth.py @@ -0,0 +1,65 @@ +import asyncio +import base64 +import hashlib +import hmac +import json +from unittest import TestCase +from unittest.mock import MagicMock, patch + +from typing_extensions import Awaitable + +from hummingbot.connector.exchange.kraken.kraken_auth import KrakenAuth +from hummingbot.core.web_assistant.connections.data_types import RESTMethod, RESTRequest + + +class KrakenAuthTests(TestCase): + + def setUp(self) -> None: + self._api_key = "testApiKey" + self._secret = "kQH5HW/8p1uGOVjbgWA7FunAmGO8lsSUXNsu3eow76sz84Q18fWxnyRzBHCd3pd5nE9qa99HAZtuZuj6F1huXg==" # noqa: mock + + 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 + + @patch("hummingbot.connector.exchange.kraken.kraken_auth.KrakenAuth.get_tracking_nonce") + def test_rest_authenticate(self, mocked_nonce): + mocked_nonce.return_value = "1" + now = 1234567890.000 + mock_time_provider = MagicMock() + mock_time_provider.time.return_value = now + test_url = "/test" + params = { + "symbol": "LTCBTC", + "side": "BUY", + "type": "LIMIT", + "timeInForce": "GTC", + "quantity": 1, + "price": "0.1", + } + + auth = KrakenAuth(api_key=self._api_key, secret_key=self._secret, time_provider=mock_time_provider) + request = RESTRequest(method=RESTMethod.GET, data=json.dumps(params), is_auth_required=True) + request.url = test_url + configured_request = self.async_run_with_timeout(auth.rest_authenticate(request)) + + # full_params.update({"timestamp": 1234567890000}) + api_secret = base64.b64decode(self._secret) + api_path: bytes = bytes(request.url, 'utf-8') + api_nonce: str = "1" + api_post: str = "nonce=" + api_nonce + + for key, value in params.items(): + api_post += f"&{key}={value}" + + api_sha256: bytes = hashlib.sha256(bytes(api_nonce + api_post, 'utf-8')).digest() + api_hmac: hmac.HMAC = hmac.new(api_secret, api_path + api_sha256, hashlib.sha512) + expected_signature: bytes = base64.b64encode(api_hmac.digest()) + # + # 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(str(expected_signature, 'utf-8'), configured_request.headers["API-Sign"]) + self.assertEqual(self._api_key, configured_request.headers["API-Key"]) diff --git a/test/hummingbot/connector/exchange/kraken/test_kraken_exchange.py b/test/hummingbot/connector/exchange/kraken/test_kraken_exchange.py index 6096c57f47..0175602391 100644 --- a/test/hummingbot/connector/exchange/kraken/test_kraken_exchange.py +++ b/test/hummingbot/connector/exchange/kraken/test_kraken_exchange.py @@ -1,123 +1,994 @@ -import asyncio import json +import logging import re -import unittest from decimal import Decimal -from functools import partial -from typing import Awaitable, Dict +from typing import Any, Callable, Dict, List, Optional, Tuple +from unittest.mock import 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.kraken import kraken_constants as CONSTANTS +from hummingbot.connector.exchange.kraken import kraken_constants as CONSTANTS, kraken_web_utils as web_utils from hummingbot.connector.exchange.kraken.kraken_exchange import KrakenExchange -from hummingbot.connector.exchange.kraken.kraken_in_flight_order import KrakenInFlightOrderNotCreated -from hummingbot.connector.test_support.network_mocking_assistant import NetworkMockingAssistant -from hummingbot.core.clock import Clock, ClockMode +from hummingbot.connector.test_support.exchange_connector_test import AbstractExchangeConnectorTests +from hummingbot.connector.trading_rule import TradingRule from hummingbot.core.data_type.common import OrderType, TradeType -from hummingbot.core.event.event_logger import EventLogger -from hummingbot.core.event.events import ( - BuyOrderCompletedEvent, - BuyOrderCreatedEvent, - MarketEvent, - OrderCancelledEvent, - SellOrderCreatedEvent, -) +from hummingbot.core.data_type.in_flight_order import InFlightOrder, OrderState +from hummingbot.core.data_type.trade_fee import AddedToCostTradeFee, TokenAmount, TradeFeeBase +from hummingbot.core.event.events import MarketOrderFailureEvent from hummingbot.core.network_iterator import NetworkStatus -class KrakenExchangeTest(unittest.TestCase): +class KrakenExchangeTests(AbstractExchangeConnectorTests.ExchangeConnectorTests): + _logger = logging.getLogger(__name__) + @classmethod def setUpClass(cls) -> None: super().setUpClass() - cls.ev_loop = asyncio.get_event_loop() - cls.base_asset = "COINALPHA" - cls.quote_asset = "HBOT" + cls.api_key = "someKey" + cls.api_secret = "kQH5HW/8p1uGOVjbgWA7FunAmGO8lsSUXNsu3eow76sz84Q18fWxnyRzBHCd3pd5nE9qa99HAZtuZuj6F1huXg==" # noqa: mock + cls.base_asset = "ETH" + cls.ex_base_asset = "ETH" + cls.quote_asset = "USDT" # linear cls.trading_pair = f"{cls.base_asset}-{cls.quote_asset}" + cls.ex_trading_pair = cls.ex_base_asset + cls.quote_asset + cls.ws_ex_trading_pairs = cls.ex_base_asset + "/" + cls.quote_asset + + @property + def all_symbols_url(self): + return web_utils.public_rest_url(path_url=CONSTANTS.ASSET_PAIRS_PATH_URL) + + @property + def latest_prices_url(self): + url = web_utils.public_rest_url(path_url=CONSTANTS.TICKER_PATH_URL) + url = f"{url}?pair={self.ex_trading_pair}" + return url + + @property + def network_status_url(self): + url = web_utils.private_rest_url(CONSTANTS.TICKER_PATH_URL) + return url + + @property + def trading_rules_url(self): + url = web_utils.private_rest_url(CONSTANTS.ASSET_PAIRS_PATH_URL) + return url + + @property + def order_creation_url(self): + url = web_utils.private_rest_url(CONSTANTS.ADD_ORDER_PATH_URL) + return url + + @property + def balance_url(self): + url = web_utils.private_rest_url(CONSTANTS.BALANCE_PATH_URL) + return url + + @property + def latest_prices_request_mock_response(self): + return { + "error": [], + "result": { + self.ex_trading_pair: { + "a": [ + "30300.10000", + "1", + "1.000" + ], + "b": [ + "30300.00000", + "1", + "1.000" + ], + "c": [ + self.expected_latest_price, + "0.00067643" + ], + "v": [ + "4083.67001100", + "4412.73601799" + ], + "p": [ + "30706.77771", + "30689.13205" + ], + "t": [ + 34619, + 38907 + ], + "l": [ + "29868.30000", + "29868.30000" + ], + "h": [ + "31631.00000", + "31631.00000" + ], + "o": "30502.80000" + } + } + } - def setUp(self) -> None: - super().setUp() - self.mocking_assistant = NetworkMockingAssistant() - self.event_listener = EventLogger() - not_a_real_secret = "kQH5HW/8p1uGOVjbgWA7FunAmGO8lsSUXNsu3eow76sz84Q18fWxnyRzBHCd3pd5nE9qa99HAZtuZuj6F1huXg==" - self.client_config_map = ClientConfigAdapter(ClientConfigMap()) - - self.exchange = KrakenExchange( - client_config_map=self.client_config_map, - kraken_api_key="someKey", - kraken_secret_key=not_a_real_secret, - trading_pairs=[self.trading_pair], - ) - self.start_time = 1 - self.clock = Clock(clock_mode=ClockMode.BACKTEST, start_time=self.start_time) - - def async_run_with_timeout(self, coroutine: Awaitable, timeout: int = 1): - ret = self.ev_loop.run_until_complete(asyncio.wait_for(coroutine, timeout)) - return ret - - def simulate_trading_rules_initialized(self, mocked_api): - url = f"{CONSTANTS.BASE_URL}{CONSTANTS.ASSET_PAIRS_PATH_URL}" - resp = self.get_asset_pairs_mock() - mocked_api.get(url, body=json.dumps(resp)) - - self.async_run_with_timeout(self.exchange._update_trading_rules(), timeout=2) + @property + def balance_event_websocket_update(self): + pass + + @property + def all_symbols_request_mock_response(self): + response = { + self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset): { + "altname": self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), + "wsname": f"{self.base_asset}/{self.quote_asset}", + "aclass_base": "currency", + "base": self.base_asset, + "aclass_quote": "currency", + "quote": self.quote_asset, + "lot": "unit", + "pair_decimals": 1, + "lot_decimals": 8, + "lot_multiplier": 1, + "leverage_buy": [2, 3], + "leverage_sell": [2, 3], + "fees": [ + [0, 0.26], + [50000, 0.24], + [100000, 0.22], + [250000, 0.2], + [500000, 0.18], + [1000000, 0.16], + [2500000, 0.14], + [5000000, 0.12], + [10000000, 0.1] + ], + "fees_maker": [ + [0, 0.16], + [50000, 0.14], + [100000, 0.12], + [250000, 0.1], + [500000, 0.08], + [1000000, 0.06], + [2500000, 0.04], + [5000000, 0.02], + [10000000, 0] + ], + "fee_volume_currency": "ZUSD", + "margin_call": 80, + "margin_stop": 40, + "ordermin": "0.0002" + } + } + result = { + "error": [], + "result": response + } + return result + + @property + def all_symbols_including_invalid_pair_mock_response(self) -> Tuple[str, Any]: + response = { + self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset): { + "altname": self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), + "wsname": f"{self.base_asset}/{self.quote_asset}", + "aclass_base": "currency", + "base": self.base_asset, + "aclass_quote": "currency", + "quote": self.quote_asset, + "lot": "unit", + "pair_decimals": 1, + "lot_decimals": 8, + "lot_multiplier": 1, + "leverage_buy": [2, 3], + "leverage_sell": [2, 3], + "fees": [ + [0, 0.26], + [50000, 0.24], + [100000, 0.22], + [250000, 0.2], + [500000, 0.18], + [1000000, 0.16], + [2500000, 0.14], + [5000000, 0.12], + [10000000, 0.1] + ], + "fees_maker": [ + [0, 0.16], + [50000, 0.14], + [100000, 0.12], + [250000, 0.1], + [500000, 0.08], + [1000000, 0.06], + [2500000, 0.04], + [5000000, 0.02], + [10000000, 0] + ], + "fee_volume_currency": "ZUSD", + "margin_call": 80, + "margin_stop": 40, + "ordermin": "0.0002" + }, + "ETHUSDT.d": { + "altname": "ETHUSDT.d", + "wsname": "XBT/USDT", + "aclass_base": "currency", + "base": "XXBT", + "aclass_quote": "currency", + "quote": "USDT", + "lot": "unit", + "pair_decimals": 1, + "lot_decimals": 8, + "lot_multiplier": 1, + "leverage_buy": [2, 3], + "leverage_sell": [2, 3], + "fees": [ + [0, 0.26], + [50000, 0.24], + [100000, 0.22], + [250000, 0.2], + [500000, 0.18], + [1000000, 0.16], + [2500000, 0.14], + [5000000, 0.12], + [10000000, 0.1] + ], + "fees_maker": [ + [0, 0.16], + [50000, 0.14], + [100000, 0.12], + [250000, 0.1], + [500000, 0.08], + [1000000, 0.06], + [2500000, 0.04], + [5000000, 0.02], + [10000000, 0] + ], + "fee_volume_currency": "ZUSD", + "margin_call": 80, + "margin_stop": 40, + "ordermin": "0.0002" + } + } + return "INVALID-PAIR", response - @staticmethod - def register_sent_request(requests_list, url, **kwargs): - requests_list.append((url, kwargs)) + @property + def network_status_request_successful_mock_response(self): + return { + "error": [], + "result": { + "a": [ + "30300.10000", + "1", + "1.000" + ], + "b": [ + "30300.00000", + "1", + "1.000" + ], + "c": [ + "30303.20000", + "0.00067643" + ], + "v": [ + "4083.67001100", + "4412.73601799" + ], + "p": [ + "30706.77771", + "30689.13205" + ], + "t": [ + 34619, + 38907 + ], + "l": [ + "29868.30000", + "29868.30000" + ], + "h": [ + "31631.00000", + "31631.00000" + ], + "o": "30502.80000" + } + } - def get_asset_pairs_mock(self) -> Dict: - asset_pairs = { + @property + def trading_rules_request_mock_response(self): + return { "error": [], "result": { - f"X{self.base_asset}{self.quote_asset}": { - "altname": f"{self.base_asset}{self.quote_asset}", + self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset): { + "altname": self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), "wsname": f"{self.base_asset}/{self.quote_asset}", "aclass_base": "currency", - "base": f"{self.base_asset}", + "base": self.base_asset, "aclass_quote": "currency", - "quote": f"{self.quote_asset}", + "quote": self.quote_asset, "lot": "unit", - "pair_decimals": 5, + "pair_decimals": 1, "lot_decimals": 8, "lot_multiplier": 1, - "leverage_buy": [ - 2, - 3, + "leverage_buy": [2, 3], + "leverage_sell": [2, 3], + "fees": [ + [0, 0.26], + [50000, 0.24], + [100000, 0.22], + [250000, 0.2], + [500000, 0.18], + [1000000, 0.16], + [2500000, 0.14], + [5000000, 0.12], + [10000000, 0.1] ], - "leverage_sell": [ - 2, - 3, + "fees_maker": [ + [0, 0.16], + [50000, 0.14], + [100000, 0.12], + [250000, 0.1], + [500000, 0.08], + [1000000, 0.06], + [2500000, 0.04], + [5000000, 0.02], + [10000000, 0] ], + "fee_volume_currency": "ZUSD", + "margin_call": 80, + "margin_stop": 40, + "ordermin": "0.0002" + } + } + } + + @property + def trading_rules_request_erroneous_mock_response(self): + return { + "error": [], + "result": { + "XBTUSDT": { + "altname": "XBTUSDT", + "wsname": "XBT/USDT", + "aclass_base": "currency", + "base": "XXBT", + "aclass_quote": "currency", + "quote": "USDT", + "lot": "unit", + "leverage_buy": [2, 3], + "leverage_sell": [2, 3], "fees": [ - [ - 0, - 0.26 - ], - [ - 50000, - 0.24 - ], + [0, 0.26], + [50000, 0.24], + [100000, 0.22], + [250000, 0.2], + [500000, 0.18], + [1000000, 0.16], + [2500000, 0.14], + [5000000, 0.12], + [10000000, 0.1] ], "fees_maker": [ - [ - 0, - 0.16 - ], - [ - 50000, - 0.14 - ], + [0, 0.16], + [50000, 0.14], + [100000, 0.12], + [250000, 0.1], + [500000, 0.08], + [1000000, 0.06], + [2500000, 0.04], + [5000000, 0.02], + [10000000, 0] ], "fee_volume_currency": "ZUSD", "margin_call": 80, "margin_stop": 40, - "ordermin": "0.005" + } + } + } + + @property + def order_creation_request_successful_mock_response(self): + return { + "error": [], + "result": { + "descr": { + "order": "", }, + "txid": [ + self.expected_exchange_order_id, + ] + } + } + + @property + def balance_request_mock_response_for_base_and_quote(self): + return { + "error": [], + "result": { + self.base_asset: str(10), + self.quote_asset: str(2000), + } + } + + @property + def balance_request_mock_response_only_base(self): + return { + self.base_asset: str(10), + } + + @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): + rule = list(self.trading_rules_request_mock_response["result"].values())[0] + min_order_size = Decimal(rule.get('ordermin', 0)) + min_price_increment = Decimal(f"1e-{rule.get('pair_decimals')}") + min_base_amount_increment = Decimal(f"1e-{rule.get('lot_decimals')}") + return TradingRule( + trading_pair=self.trading_pair, + min_order_size=min_order_size, + min_price_increment=min_price_increment, + min_base_amount_increment=min_base_amount_increment, + ) + + @property + def expected_logged_error_for_erroneous_trading_rule(self): + erroneous_rule = list(self.trading_rules_request_erroneous_mock_response["result"].values())[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 False + + @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 AddedToCostTradeFee( + 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 KrakenExchange( + client_config_map=client_config_map, + kraken_api_key=self.api_key, + kraken_secret_key=self.api_secret, + 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["data"] + ) + + def validate_order_creation_request(self, order: InFlightOrder, request_call: RequestCall): + request_data = request_call.kwargs["data"] + self.assertEqual(self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), request_data["pair"]) + self.assertEqual(order.trade_type.name.lower(), request_data["type"]) + self.assertEqual(KrakenExchange.kraken_order_type(OrderType.LIMIT), request_data["ordertype"]) + self.assertEqual(Decimal("100"), Decimal(request_data["volume"])) + self.assertEqual(Decimal("10000"), Decimal(request_data["price"])) + + def validate_order_cancelation_request(self, order: InFlightOrder, request_call: RequestCall): + request_data = request_call.kwargs["data"] + self.assertEqual(order.exchange_order_id, request_data["txid"]) + + def validate_order_status_request(self, order: InFlightOrder, request_call: RequestCall): + request_params = request_call.kwargs["data"] + self.assertEqual(order.exchange_order_id, request_params["txid"]) + + def validate_trades_request(self, order: InFlightOrder, request_call: RequestCall): + request_params = request_call.kwargs["data"] + self.assertEqual(order.exchange_order_id, str(request_params["txid"])) + + def configure_order_not_found_error_cancelation_response( + self, order: InFlightOrder, mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None + ) -> str: + # Implement the expected not found response when enabling test_cancel_order_not_found_in_the_exchange + raise NotImplementedError + + 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.CANCEL_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.post(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.CANCEL_ORDER_PATH_URL) + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + mock_api.post(regex_url, status=400, 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.QUERY_ORDERS_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.post(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.QUERY_ORDERS_PATH_URL) + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?") + ".*") + response = self._order_status_request_canceled_mock_response(order=order) + mock_api.post(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.QUERY_TRADES_PATH_URL) + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + mock_api.post(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.QUERY_ORDERS_PATH_URL) + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + response = self._order_status_request_open_mock_response(order=order) + mock_api.post(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.QUERY_ORDERS_PATH_URL) + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + mock_api.post(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.QUERY_ORDERS_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.post(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.QUERY_ORDERS_PATH_URL) + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + response = {"code": -2013, "msg": "Order does not exist."} + mock_api.post(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.QUERY_TRADES_PATH_URL) + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + response = self._order_fills_request_partial_fill_mock_response(order=order) + mock_api.post(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.QUERY_TRADES_PATH_URL) + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + response = self._order_fills_request_full_fill_mock_response(order=order) + mock_api.post(regex_url, body=json.dumps(response), callback=callback) + return url + + def order_event_for_new_order_websocket_update(self, order: InFlightOrder): + return [ + [ + { + order.exchange_order_id: { + "avg_price": "34.50000", + "cost": "0.00000", + "descr": { + "close": "", + "leverage": "0:1", + "order": "sell 10.00345345 XBT/EUR @ limit 34.50000 with 0:1 leverage", + "ordertype": "limit", + "pair": self.ws_ex_trading_pairs, + "price": str(order.price), + "price2": "0.00000", + "type": "sell" + }, + "expiretm": "0.000000", + "fee": "0.00000", + "limitprice": "34.50000", + "misc": "", + "oflags": "fcib", + "opentm": "0.000000", + "refid": "OKIVMP-5GVZN-Z2D2UA", + "starttm": "0.000000", + "status": "open", + "stopprice": "0.000000", + "userref": order.client_order_id, + "vol": str(order.amount, ), + "vol_exec": "0.00000000" + } + } + ], + "openOrders", + { + "sequence": 234 + } + ] + + def order_event_for_canceled_order_websocket_update(self, order: InFlightOrder): + return [ + [ + { + order.exchange_order_id: { + "avg_price": "34.50000", + "cost": "0.00000", + "descr": { + "close": "", + "leverage": "0:1", + "order": "sell 10.00345345 XBT/EUR @ limit 34.50000 with 0:1 leverage", + "ordertype": "limit", + "pair": "XBT/EUR", + "price": "34.50000", + "price2": "0.00000", + "type": "sell" + }, + "expiretm": "0.000000", + "fee": "0.00000", + "limitprice": "34.50000", + "misc": "", + "oflags": "fcib", + "opentm": "0.000000", + "refid": "OKIVMP-5GVZN-Z2D2UA", + "starttm": "0.000000", + "status": "canceled", + "stopprice": "0.000000", + "userref": order.client_order_id, + "vol": "10.00345345", + "vol_exec": "0.00000000" + } + } + ], + "openOrders", + { + "sequence": 234 + } + ] + + def order_event_for_full_fill_websocket_update(self, order: InFlightOrder): + return [ + [ + { + order.exchange_order_id: { + "avg_price": "34.50000", + "cost": "0.00000", + "descr": { + "close": "", + "leverage": "0:1", + "order": "sell 10.00345345 XBT/EUR @ limit 34.50000 with 0:1 leverage", + "ordertype": "limit", + "pair": "XBT/EUR", + "price": order.price, + "price2": "0.00000", + "type": "sell" + }, + "expiretm": "0.000000", + "fee": "0.00000", + "limitprice": "34.50000", + "misc": "", + "oflags": "fcib", + "opentm": "0.000000", + "refid": "OKIVMP-5GVZN-Z2D2UA", + "starttm": "0.000000", + "status": "closed", + "stopprice": "0.000000", + "userref": order.client_order_id, + "vol": order.amount, + "vol_exec": "0.00000000" + } + } + ], + "openOrders", + { + "sequence": 234 + } + ] + + def trade_event_for_full_fill_websocket_update(self, order: InFlightOrder): + return [ + [ + { + self.expected_fill_trade_id: { + "cost": "1000000.00000", + "fee": Decimal(self.expected_fill_fee.flat_fees[0].amount), + "margin": "0.00000", + "ordertxid": order.exchange_order_id, + "ordertype": "limit", + "pair": "XBT/EUR", + "postxid": "OGTT3Y-C6I3P-XRI6HX", + "price": str(order.price), + "time": "1560516023.070651", + "type": "sell", + "userref": order.client_order_id, + "vol": str(order.amount) + } + } + ], + "ownTrades", + { + "sequence": 2948 + } + ] + + @aioresponses() + def test_lost_order_removed_if_not_found_during_order_status_update(self, mock_api): + # Disabling this test because the connector has not been updated yet to validate + # order not found during status update (check _is_order_not_found_during_status_update_error) + pass + + @aioresponses() + @patch("hummingbot.connector.time_synchronizer.TimeSynchronizer._current_seconds_counter") + def test_update_time_synchronizer_successfully(self, mock_api, seconds_counter_mock): + pass + + @aioresponses() + def test_cancel_order_not_found_in_the_exchange(self, mock_api): + # Disabling this test because the connector has not been updated yet to validate + # order not found during cancellation (check _is_order_not_found_during_cancelation_error) + pass + + def test_user_stream_balance_update(self): + pass + + @aioresponses() + def test_update_time_synchronizer_failure_is_logged(self, mock_api): + pass + + @aioresponses() + def test_update_time_synchronizer_raises_cancelled_error(self, mock_api): + pass + + @aioresponses() + def test_update_order_status_when_order_has_not_changed_and_one_partial_fill(self, mock_api): + pass + + @aioresponses() + def test_check_network_failure(self, mock_api): + url = self.network_status_url + mock_api.get(url, status=600) + + ret = self.async_run_with_timeout(coroutine=self.exchange.check_network()) + + self.assertEqual(ret, NetworkStatus.NOT_CONNECTED) + + @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.QUERY_ORDERS_PATH_URL) + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + + order_status = { + order.exchange_order_id: { + "refid": "None", + "userref": order.client_order_id, + "status": "expired", + "opentm": 1499827319.559, + "starttm": 0, + "expiretm": 0, + "descr": {}, + "vol": "1.0", + "vol_exec": "0.0", + "cost": "11253.7", + "fee": "0.00000", + "price": "10000.0", + "stopprice": "0.00000", + "limitprice": "0.00000", + "misc": "", + "oflags": "fciq", + "trades": [] } } - return asset_pairs + + mock_response = { + "error": [], + "result": order_status + } + mock_api.post(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["data"] + self.assertEqual(order.exchange_order_id, request_params["txid"]) + + 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={self.exchange.current_timestamp}, new_state={repr(OrderState.FAILED)}, " + f"client_order_id='{order.client_order_id}', exchange_order_id='{order.exchange_order_id}', " + "misc_updates=None)") + ) + + @patch("hummingbot.connector.exchange.kraken.kraken_exchange.get_new_numeric_client_order_id") + def test_client_order_id_on_order(self, mock_ts): + mock_ts.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 = str(7) + + 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 = str(7) + + self.assertEqual(result, expected_client_order_id) + + def _validate_auth_credentials_taking_parameters_from_argument(self, + request_call_tuple: RequestCall, + params: Dict[str, Any]): + self.assertIn("nonce", params) + request_headers = request_call_tuple.kwargs["headers"] + self.assertIn("API-Sign", request_headers) + self.assertIn("API-Key", request_headers) + self.assertEqual("someKey", request_headers["API-Key"]) + + def get_asset_pairs_mock(self) -> Dict: + asset_pairs = { + f"X{self.base_asset}{self.quote_asset}": { + "altname": f"{self.base_asset}{self.quote_asset}", + "wsname": f"{self.base_asset}/{self.quote_asset}", + "aclass_base": "currency", + "base": f"{self.base_asset}", + "aclass_quote": "currency", + "quote": f"{self.quote_asset}", + "lot": "unit", + "pair_decimals": 5, + "lot_decimals": 8, + "lot_multiplier": 1, + "leverage_buy": [ + 2, + 3, + ], + "leverage_sell": [ + 2, + 3, + ], + "fees": [ + [ + 0, + 0.26 + ], + [ + 50000, + 0.24 + ], + ], + "fees_maker": [ + [ + 0, + 0.16 + ], + [ + 50000, + 0.14 + ], + ], + "fee_volume_currency": "ZUSD", + "margin_call": 80, + "margin_stop": 40, + "ordermin": "0.005" + }, + } + result = { + "error": [], + "result": asset_pairs + } + return result def get_balances_mock(self, base_asset_balance: float, quote_asset_balance: float) -> Dict: balances = { @@ -132,25 +1003,15 @@ def get_balances_mock(self, base_asset_balance: float, quote_asset_balance: floa def get_open_orders_mock(self, quantity: float, price: float, order_type: str) -> Dict: open_orders = { - "error": [], - "result": { - "open": { - "OQCLML-BW3P3-BUCMWZ": self.get_order_status_mock(quantity, price, order_type, status="open"), - } + "open": { + "OQCLML-BW3P3-BUCMWZ": self.get_order_status_mock(quantity, price, order_type, status="open"), } } - return open_orders - - def get_query_orders_mock( - self, exchange_id: str, quantity: float, price: float, order_type: str, status: str - ) -> Dict: - query_orders = { + result = { "error": [], - "result": { - exchange_id: self.get_order_status_mock(quantity, price, order_type, status) - } + "result": open_orders } - return query_orders + return result def get_order_status_mock(self, quantity: float, price: float, order_type: str, status: str) -> Dict: order_status = { @@ -185,34 +1046,6 @@ def get_order_status_mock(self, quantity: float, price: float, order_type: str, } return order_status - def get_order_placed_mock(self, exchange_id: str, quantity: float, price: float, order_type: str) -> Dict: - order_placed = { - "error": [], - "result": { - "descr": { - "order": f"{order_type} {quantity} {self.base_asset}{self.quote_asset}" - f" @ limit {price} with 2:1 leverage", - }, - "txid": [ - exchange_id - ] - } - } - return order_placed - - @aioresponses() - def test_get_asset_pairs(self, mocked_api): - url = f"{CONSTANTS.BASE_URL}{CONSTANTS.ASSET_PAIRS_PATH_URL}" - resp = self.get_asset_pairs_mock() - mocked_api.get(url, body=json.dumps(resp)) - - ret = self.async_run_with_timeout(self.exchange.get_asset_pairs()) - - self.assertIn(self.trading_pair, ret) - self.assertEqual( - ret[self.trading_pair], resp["result"][f"X{self.base_asset}{self.quote_asset}"] # shallow comparison is ok - ) - @aioresponses() def test_update_balances(self, mocked_api): url = f"{CONSTANTS.BASE_URL}{CONSTANTS.ASSET_PAIRS_PATH_URL}" @@ -231,224 +1064,153 @@ def test_update_balances(self, mocked_api): self.async_run_with_timeout(self.exchange._update_balances()) - self.assertEqual(self.exchange.available_balances[self.quote_asset], Decimal("18")) - - @aioresponses() - def test_update_order_status_order_closed(self, mocked_api): - order_id = "someId" - exchange_id = "someExchangeId" - - url = f"{CONSTANTS.BASE_URL}{CONSTANTS.QUERY_ORDERS_PATH_URL}" - regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) - resp = self.get_query_orders_mock(exchange_id, quantity=1, price=2, order_type="buy", status="closed") - mocked_api.post(regex_url, body=json.dumps(resp)) - - self.exchange.start_tracking_order( - order_id=order_id, - exchange_order_id=exchange_id, - trading_pair=self.trading_pair, - trade_type=TradeType.BUY, - price=2, - amount=1, - order_type=OrderType.LIMIT, - userref=1, - ) - self.exchange.add_listener(MarketEvent.BuyOrderCompleted, self.event_listener) - - self.async_run_with_timeout(self.exchange._update_order_status()) - - self.assertEqual(len(self.event_listener.event_log), 1) - self.assertTrue(isinstance(self.event_listener.event_log[0], BuyOrderCompletedEvent)) - self.assertNotIn(order_id, self.exchange.in_flight_orders) - - @aioresponses() - def test_check_network_success(self, mock_api): - url = f"{CONSTANTS.BASE_URL}{CONSTANTS.TIME_PATH_URL}" - resp = {"status": 200, "result": []} - mock_api.get(url, body=json.dumps(resp)) - - ret = self.async_run_with_timeout(coroutine=self.exchange.check_network()) - - self.assertEqual(ret, NetworkStatus.CONNECTED) - - @aioresponses() - def test_check_network_raises_cancelled_error(self, mock_api): - url = f"{CONSTANTS.BASE_URL}{CONSTANTS.TIME_PATH_URL}" - mock_api.get(url, exception=asyncio.CancelledError) - - with self.assertRaises(asyncio.CancelledError): - self.async_run_with_timeout(coroutine=self.exchange.check_network()) - - @aioresponses() - def test_check_network_not_connected_for_error_status(self, mock_api): - url = f"{CONSTANTS.BASE_URL}{CONSTANTS.TIME_PATH_URL}" - resp = {"status": 405, "result": []} - mock_api.get(url, status=405, body=json.dumps(resp)) - - ret = self.async_run_with_timeout(coroutine=self.exchange.check_network()) - - self.assertEqual(ret, NetworkStatus.NOT_CONNECTED) - - @aioresponses() - def test_get_open_orders_with_userref(self, mocked_api): - sent_messages = [] - url = f"{CONSTANTS.BASE_URL}{CONSTANTS.OPEN_ORDERS_PATH_URL}" - regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) - resp = self.get_open_orders_mock(quantity=1, price=2, order_type="buy") - mocked_api.post(regex_url, body=json.dumps(resp), callback=partial(self.register_sent_request, sent_messages)) - userref = 1 - - ret = self.async_run_with_timeout(self.exchange.get_open_orders_with_userref(userref)) + self.assertEqual(self.exchange.available_balances[self.quote_asset], Decimal("171286.6158")) - self.assertEqual(len(sent_messages), 1) - - sent_message = sent_messages[0][1]["data"] - - self.assertEqual(sent_message["userref"], userref) - self.assertEqual(ret, resp["result"]) # shallow comparison ok - - @aioresponses() - def test_get_order(self, mocked_api): - sent_messages = [] - order_id = "someId" - exchange_id = "someExchangeId" - - url = f"{CONSTANTS.BASE_URL}{CONSTANTS.QUERY_ORDERS_PATH_URL}" - regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) - resp = self.get_query_orders_mock(exchange_id, quantity=1, price=2, order_type="buy", status="closed") - mocked_api.post(regex_url, body=json.dumps(resp), callback=partial(self.register_sent_request, sent_messages)) - - self.exchange.start_tracking_order( - order_id=order_id, - exchange_order_id=exchange_id, - trading_pair=self.trading_pair, - trade_type=TradeType.BUY, - price=2, - amount=1, - order_type=OrderType.LIMIT, - userref=1, - ) - ret = self.async_run_with_timeout(self.exchange.get_order(client_order_id=order_id)) - - self.assertEqual(len(sent_messages), 1) - - sent_message = sent_messages[0][1]["data"] - - self.assertEqual(sent_message["txid"], exchange_id) - self.assertEqual(ret, resp["result"]) # shallow comparison ok - - @aioresponses() - def test_execute_buy(self, mocked_api): - self.exchange.start(self.clock, self.start_time) - self.simulate_trading_rules_initialized(mocked_api) - - order_id = "someId" - exchange_id = "someExchangeId" - userref = 1 - quantity = 1 - price = 2 - - url = f"{CONSTANTS.BASE_URL}{CONSTANTS.ADD_ORDER_PATH_URL}" - regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) - resp = self.get_order_placed_mock(exchange_id, quantity, price, order_type="buy") - mocked_api.post(regex_url, body=json.dumps(resp)) - - self.exchange.add_listener(MarketEvent.BuyOrderCreated, self.event_listener) - self.async_run_with_timeout( - self.exchange.execute_buy( - order_id, - self.trading_pair, - amount=Decimal(quantity), - order_type=OrderType.LIMIT, - price=Decimal(price), - userref=userref, - ) - ) - - self.assertEqual(len(self.event_listener.event_log), 1) - self.assertTrue(isinstance(self.event_listener.event_log[0], BuyOrderCreatedEvent)) - self.assertIn(order_id, self.exchange.in_flight_orders) - - @aioresponses() - def test_execute_sell(self, mocked_api): - order_id = "someId" - exchange_id = "someExchangeId" - userref = 1 - quantity = 1 - price = 2 - - url = f"{CONSTANTS.BASE_URL}{CONSTANTS.ADD_ORDER_PATH_URL}" - regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) - resp = self.get_order_placed_mock(exchange_id, quantity, price, order_type="sell") - mocked_api.post(regex_url, body=json.dumps(resp)) - - self.exchange.start(self.clock, self.start_time) - self.simulate_trading_rules_initialized(mocked_api) - self.exchange.add_listener(MarketEvent.SellOrderCreated, self.event_listener) - self.async_run_with_timeout( - self.exchange.execute_sell( - order_id, - self.trading_pair, - amount=Decimal(quantity), - order_type=OrderType.LIMIT, - price=Decimal(price), - userref=userref, - ) - ) - - self.assertEqual(len(self.event_listener.event_log), 1) - self.assertTrue(isinstance(self.event_listener.event_log[0], SellOrderCreatedEvent)) - self.assertIn(order_id, self.exchange.in_flight_orders) - - @aioresponses() - def test_execute_cancel(self, mocked_api): - order_id = "someId" - exchange_id = "someExchangeId" - - url = f"{CONSTANTS.BASE_URL}{CONSTANTS.CANCEL_ORDER_PATH_URL}" - regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) - resp = { + def _order_cancelation_request_successful_mock_response(self, order: InFlightOrder) -> Any: + return { "error": [], "result": { "count": 1 } } - mocked_api.post(regex_url, body=json.dumps(resp)) - self.exchange.start_tracking_order( - order_id=order_id, - exchange_order_id=exchange_id, - trading_pair=self.trading_pair, - trade_type=TradeType.BUY, - price=2, - amount=1, - order_type=OrderType.LIMIT, - userref=1, - ) - self.exchange.in_flight_orders[order_id].update_exchange_order_id(exchange_id) - self.exchange.in_flight_orders[order_id].last_state = "pending" - self.exchange.add_listener(MarketEvent.OrderCancelled, self.event_listener) - ret = self.async_run_with_timeout(self.exchange.execute_cancel(self.trading_pair, order_id)) + def _order_status_request_completely_filled_mock_response(self, order: InFlightOrder) -> Any: + return { + "error": [], + "result": { + order.exchange_order_id: { + "refid": "None", + "userref": order.client_order_id, + "status": "closed", + "opentm": 1688666559.8974, + "starttm": 0, + "expiretm": 0, + "descr": {}, + "vol": str(order.amount), + "vol_exec": str(order.amount), + "cost": "11253.7", + "fee": "0.00000", + "price": str(order.price), + "stopprice": "0.00000", + "limitprice": "0.00000", + "misc": "", + "oflags": "fciq", + "trades": [] + } + } + } + + def _order_status_request_canceled_mock_response(self, order: InFlightOrder) -> Any: + return { + "error": [], + "result": { + order.exchange_order_id: { + "refid": "None", + "userref": order.client_order_id, + "status": "canceled", + "opentm": 1688666559.8974, + "starttm": 0, + "expiretm": 0, + "descr": {}, + "vol": str(order.amount), + "vol_exec": "0", + "cost": "11253.7", + "fee": "0.00000", + "price": str(order.price), + "stopprice": "0.00000", + "limitprice": "0.00000", + "misc": "", + "oflags": "fciq", + "trades": [] + } + } + } - self.assertEqual(len(self.event_listener.event_log), 1) - self.assertTrue(isinstance(self.event_listener.event_log[0], OrderCancelledEvent)) - self.assertNotIn(order_id, self.exchange.in_flight_orders) - self.assertEqual(ret["origClientOrderId"], order_id) + def _order_status_request_open_mock_response(self, order: InFlightOrder) -> Any: + return { + order.exchange_order_id: { + "refid": "None", + "userref": order.client_order_id, + "status": "open", + "opentm": 1688666559.8974, + "starttm": 0, + "expiretm": 0, + "descr": {}, + "vol": str(order.amount), + "vol_exec": "0", + "cost": "11253.7", + "fee": "0.00000", + "price": str(order.price), + "stopprice": "0.00000", + "limitprice": "0.00000", + "misc": "", + "oflags": "fciq", + "trades": [] + } + } - def test_execute_cancel_ignores_local_orders(self): - order_id = "someId" - exchange_id = "someExchangeId" + def _order_status_request_partially_filled_mock_response(self, order: InFlightOrder) -> Any: + return { + order.exchange_order_id: { + "refid": "None", + "userref": order.client_order_id, + "status": "open", + "opentm": 1688666559.8974, + "starttm": 0, + "expiretm": 0, + "descr": {}, + "vol": str(order.amount), + "vol_exec": str(order.amount / 2), + "cost": "11253.7", + "fee": "0.00000", + "price": str(order.price), + "stopprice": "0.00000", + "limitprice": "0.00000", + "misc": "", + "oflags": "fciq", + "trades": [] + } + } - self.exchange.start_tracking_order( - order_id=order_id, - exchange_order_id=exchange_id, - trading_pair=self.trading_pair, - trade_type=TradeType.BUY, - price=2, - amount=1, - order_type=OrderType.LIMIT, - userref=1, - ) + def _order_fills_request_partial_fill_mock_response(self, order: InFlightOrder): + return { + self.expected_fill_trade_id: { + "ordertxid": order.exchange_order_id, + "postxid": "TKH2SE-M7IF5-CFI7LT", + "pair": "XXBTZUSD", + "time": 1499865549.590, + "type": "buy", + "ordertype": "limit", + "price": str(self.expected_partial_fill_price), + "cost": "600.20000", + "fee": str(self.expected_fill_fee.flat_fees[0].amount), + "vol": str(self.expected_partial_fill_amount), + "margin": "0.00000", + "misc": "", + "trade_id": 93748276, + "maker": "true" + } + } - with self.assertRaises(KrakenInFlightOrderNotCreated): - self.async_run_with_timeout(self.exchange.execute_cancel(self.trading_pair, order_id)) + def _order_fills_request_full_fill_mock_response(self, order: InFlightOrder): + return { + "error": [], + "result": { + self.expected_fill_trade_id: { + "ordertxid": order.exchange_order_id, + "postxid": "TKH2SE-M7IF5-CFI7LT", + "pair": "XXBTZUSD", + "time": 1499865549.590, + "type": "buy", + "ordertype": "limit", + "price": str(order.price), + "cost": "600.20000", + "fee": str(self.expected_fill_fee.flat_fees[0].amount), + "vol": str(order.amount), + "margin": "0.00000", + "misc": "", + "trade_id": 93748276, + "maker": "true" + } + } + } diff --git a/test/hummingbot/connector/exchange/kraken/test_kraken_in_flight_order.py b/test/hummingbot/connector/exchange/kraken/test_kraken_in_flight_order.py deleted file mode 100644 index 6de2297f5f..0000000000 --- a/test/hummingbot/connector/exchange/kraken/test_kraken_in_flight_order.py +++ /dev/null @@ -1,90 +0,0 @@ -from decimal import Decimal -from unittest import TestCase - -from hummingbot.connector.exchange.kraken.kraken_in_flight_order import KrakenInFlightOrder -from hummingbot.core.data_type.common import OrderType, TradeType - - -class KrakenInFlightOrderTests(TestCase): - def test_order_is_local_after_creation(self): - order = KrakenInFlightOrder( - client_order_id="someId", - exchange_order_id=None, - trading_pair="BTC-USDT", - order_type=OrderType.LIMIT, - trade_type=TradeType.BUY, - price=Decimal(45000), - amount=Decimal(1), - creation_timestamp=1640001112.0, - userref=1, - ) - - self.assertTrue(order.is_local) - - def test_serialize_order_to_json(self): - order = KrakenInFlightOrder( - client_order_id="OID1", - exchange_order_id="EOID1", - trading_pair="COINALPHA-HBOT", - order_type=OrderType.LIMIT, - trade_type=TradeType.BUY, - price=Decimal(1000), - amount=Decimal(1), - creation_timestamp=1640001112.0, - userref=2, - initial_state="OPEN", - ) - - expected_json = { - "client_order_id": order.client_order_id, - "exchange_order_id": order.exchange_order_id, - "trading_pair": order.trading_pair, - "order_type": order.order_type.name, - "trade_type": order.trade_type.name, - "price": str(order.price), - "amount": str(order.amount), - "last_state": order.last_state, - "executed_amount_base": str(order.executed_amount_base), - "executed_amount_quote": str(order.executed_amount_quote), - "fee_asset": order.fee_asset, - "fee_paid": str(order.fee_paid), - "creation_timestamp": 1640001112.0, - "userref": order.userref, - } - - self.assertEqual(expected_json, order.to_json()) - - def test_deserialize_order_from_json(self): - json = { - "client_order_id": "OID1", - "exchange_order_id": "EOID1", - "trading_pair": "COINALPHA-HBOT", - "order_type": OrderType.LIMIT.name, - "trade_type": TradeType.BUY.name, - "price": "1000", - "amount": "1", - "last_state": "OPEN", - "executed_amount_base": "0.1", - "executed_amount_quote": "110", - "fee_asset": "BNB", - "fee_paid": "10", - "creation_timestamp": 1640001112.0, - "userref": 2, - } - - order: KrakenInFlightOrder = KrakenInFlightOrder.from_json(json) - - self.assertEqual(json["client_order_id"], order.client_order_id) - self.assertEqual(json["exchange_order_id"], order.exchange_order_id) - self.assertEqual(json["trading_pair"], order.trading_pair) - self.assertEqual(OrderType.LIMIT, order.order_type) - self.assertEqual(TradeType.BUY, order.trade_type) - self.assertEqual(Decimal(json["price"]), order.price) - self.assertEqual(Decimal(json["amount"]), order.amount) - self.assertEqual(Decimal(json["executed_amount_base"]), order.executed_amount_base) - self.assertEqual(Decimal(json["executed_amount_quote"]), order.executed_amount_quote) - self.assertEqual(json["fee_asset"], order.fee_asset) - self.assertEqual(Decimal(json["fee_paid"]), order.fee_paid) - self.assertEqual(json["last_state"], order.last_state) - self.assertEqual(json["creation_timestamp"], order.creation_timestamp) - self.assertEqual(json["userref"], order.userref) diff --git a/test/hummingbot/connector/exchange/kraken/test_kraken_order_book.py b/test/hummingbot/connector/exchange/kraken/test_kraken_order_book.py new file mode 100644 index 0000000000..52e3718677 --- /dev/null +++ b/test/hummingbot/connector/exchange/kraken/test_kraken_order_book.py @@ -0,0 +1,95 @@ +from unittest import TestCase + +from hummingbot.connector.exchange.kraken.kraken_order_book import KrakenOrderBook +from hummingbot.core.data_type.order_book_message import OrderBookMessageType + + +class KrakenOrderBookTests(TestCase): + + def test_snapshot_message_from_exchange(self): + snapshot_message = KrakenOrderBook.snapshot_message_from_exchange( + msg={ + "latest_update": 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 = KrakenOrderBook.diff_message_from_exchange( + msg={ + "trading_pair": "COINALPHA-HBOT", + "asks": [ + [ + "5541.30000", + "2.50700000", + "1534614248.123678" + ], + ], + "bids": [ + [ + "5541.20000", + "1.52900000", + "1534614248.765567" + ], + ], + "update_id": 3407459756 + }, + timestamp=1640000000, + ) + + self.assertEqual("COINALPHA-HBOT", diff_msg.trading_pair) + self.assertEqual(1640000000.0, diff_msg.timestamp) + self.assertEqual(3407459756, diff_msg.update_id) + self.assertEqual(OrderBookMessageType.DIFF, diff_msg.type) + self.assertEqual(-1, diff_msg.trade_id) + self.assertEqual(1, len(diff_msg.bids)) + self.assertEqual(5541.2, diff_msg.bids[0].price) + self.assertEqual(1.529, diff_msg.bids[0].amount) + self.assertEqual(1, len(diff_msg.asks)) + self.assertEqual(5541.3, diff_msg.asks[0].price) + self.assertEqual(2.507, diff_msg.asks[0].amount) + + def test_trade_message_from_exchange(self): + trade_update = { + "pair": "COINALPHA-HBOT", + "trade": [ + "5541.20000", + "0.15850568", + "1534614057.321597", + "s", + "l", + "" + ] + } + + trade_message = KrakenOrderBook.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(1534614057.321597, trade_message.timestamp) + self.assertEqual(-1, trade_message.update_id) + self.assertEqual(1534614057.321597, trade_message.trade_id) diff --git a/test/hummingbot/connector/exchange/kraken/test_kraken_utils.py b/test/hummingbot/connector/exchange/kraken/test_kraken_utils.py new file mode 100644 index 0000000000..73eeaf088a --- /dev/null +++ b/test/hummingbot/connector/exchange/kraken/test_kraken_utils.py @@ -0,0 +1,42 @@ +import unittest + +from hummingbot.connector.exchange.kraken import kraken_utils as utils + + +class KrakenUtilTestCases(unittest.TestCase): + + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + cls.base_asset = "XBT" + cls.hb_base_asset = "BTC" + cls.quote_asset = "USDT" + cls.trading_pair = f"{cls.hb_base_asset}-{cls.quote_asset}" + cls.hb_trading_pair = f"{cls.hb_base_asset}-{cls.quote_asset}" + cls.ex_trading_pair = f"{cls.base_asset}{cls.quote_asset}" + cls.ex_ws_trading_pair = f"{cls.base_asset}/{cls.quote_asset}" + + def test_convert_from_exchange_symbol(self): + self.assertEqual(self.hb_base_asset, utils.convert_from_exchange_symbol(self.base_asset)) + self.assertEqual(self.quote_asset, utils.convert_from_exchange_symbol(self.quote_asset)) + + def test_convert_to_exchange_symbol(self): + self.assertEqual(self.base_asset, utils.convert_to_exchange_symbol(self.hb_base_asset)) + self.assertEqual(self.quote_asset, utils.convert_to_exchange_symbol(self.quote_asset)) + + def test_convert_to_exchange_trading_pair(self): + self.assertEqual(self.ex_trading_pair, utils.convert_to_exchange_trading_pair(self.hb_trading_pair)) + self.assertEqual(self.ex_trading_pair, utils.convert_to_exchange_trading_pair(self.ex_ws_trading_pair)) + self.assertEqual(self.ex_trading_pair, utils.convert_to_exchange_trading_pair(self.ex_trading_pair)) + + def test_split_to_base_quote(self): + self.assertEqual((self.hb_base_asset, self.quote_asset), utils.split_to_base_quote(self.trading_pair)) + + def test_convert_from_exchange_trading_pair(self): + self.assertEqual(self.trading_pair, utils.convert_from_exchange_trading_pair(self.trading_pair)) + self.assertEqual(self.trading_pair, + utils.convert_from_exchange_trading_pair(self.ex_trading_pair, ("BTC-USDT", "ETH-USDT"))) + self.assertEqual(self.trading_pair, utils.convert_from_exchange_trading_pair(self.ex_ws_trading_pair)) + + def test_build_rate_limits_by_tier(self): + self.assertIsNotNone(utils.build_rate_limits_by_tier()) diff --git a/test/hummingbot/connector/exchange/kraken/test_kraken_web_utils.py b/test/hummingbot/connector/exchange/kraken/test_kraken_web_utils.py new file mode 100644 index 0000000000..ce0af60fd7 --- /dev/null +++ b/test/hummingbot/connector/exchange/kraken/test_kraken_web_utils.py @@ -0,0 +1,43 @@ +import unittest + +import hummingbot.connector.exchange.kraken.kraken_constants as CONSTANTS +from hummingbot.connector.exchange.kraken import kraken_web_utils as web_utils + + +class KrakenUtilTestCases(unittest.TestCase): + + def test_public_rest_url(self): + path_url = "/TEST_PATH" + expected_url = CONSTANTS.BASE_URL + path_url + self.assertEqual(expected_url, web_utils.public_rest_url(path_url)) + + def test_private_rest_url(self): + path_url = "/TEST_PATH" + expected_url = CONSTANTS.BASE_URL + path_url + self.assertEqual(expected_url, web_utils.private_rest_url(path_url)) + + def test_is_exchange_information_valid(self): + invalid_info_1 = { + "XBTUSDT": { + "altname": "XBTUSDT.d", + "wsname": "XBT/USDT", + "aclass_base": "currency", + "base": "XXBT", + "aclass_quote": "currency", + "quote": "USDT", + } + } + + self.assertFalse(web_utils.is_exchange_information_valid(invalid_info_1["XBTUSDT"])) + valid_info_1 = { + "XBTUSDT": { + "altname": "XBTUSDT", + "wsname": "XBT/USDT", + "aclass_base": "currency", + "base": "XXBT", + "aclass_quote": "currency", + "quote": "USDT", + } + } + + self.assertTrue(web_utils.is_exchange_information_valid(valid_info_1["XBTUSDT"]))