def __init__(self, altmarkets_api_key: str, altmarkets_secret_key: str, trading_pairs: Optional[List[str]] = None, trading_required: bool = True ): """ :param altmarkets_api_key: The API key to connect to private AltMarkets.io APIs. :param altmarkets_secret_key: The API secret. :param trading_pairs: The market trading pairs which to track order book data. :param trading_required: Whether actual trading is needed. """ super().__init__() self._trading_required = trading_required self._trading_pairs = trading_pairs self._altmarkets_auth = AltmarketsAuth(altmarkets_api_key, altmarkets_secret_key) self._order_book_tracker = AltmarketsOrderBookTracker(trading_pairs=trading_pairs) self._user_stream_tracker = AltmarketsUserStreamTracker(self._altmarkets_auth, trading_pairs) self._ev_loop = asyncio.get_event_loop() self._shared_client = None self._poll_notifier = asyncio.Event() self._last_timestamp = 0 self._in_flight_orders = {} # Dict[client_order_id:str, AltmarketsInFlightOrder] self._order_not_found_records = {} # Dict[client_order_id:str, count:int] self._trading_rules = {} # Dict[trading_pair:str, TradingRule] self._status_polling_task = None self._user_stream_event_listener_task = None self._trading_rules_polling_task = None self._last_poll_timestamp = 0 self._throttler = Throttler(rate_limit = (10.0, 6.5))
def __init__(self, binance_perpetual_api_key: str = None, binance_perpetual_api_secret: str = None, trading_pairs: Optional[List[str]] = None, trading_required: bool = True, **domain): self._testnet = True if len(domain) > 0 else False super().__init__() self._api_key = binance_perpetual_api_key self._api_secret = binance_perpetual_api_secret self._trading_required = trading_required # self._account_balances = {} # self._account_available_balances = {} self._base_url = PERPETUAL_BASE_URL if self._testnet is False else TESTNET_BASE_URL self._stream_url = DIFF_STREAM_URL if self._testnet is False else TESTNET_STREAM_URL self._user_stream_tracker = BinancePerpetualUserStreamTracker(base_url=self._base_url, stream_url=self._stream_url, api_key=self._api_key) self._order_book_tracker = BinancePerpetualOrderBookTracker(trading_pairs=trading_pairs, **domain) self._ev_loop = asyncio.get_event_loop() self._poll_notifier = asyncio.Event() self._in_flight_orders = {} self._order_not_found_records = {} self._last_timestamp = 0 self._trading_rules = {} # self._trade_fees = {} # self._last_update_trade_fees_timestamp = 0 self._status_polling_task = None self._user_stream_event_listener_task = None self._trading_rules_polling_task = None self._last_poll_timestamp = 0 self._throttler = Throttler((10.0, 1.0)) self._funding_rate = 0 self._account_positions = {} self._position_mode = None
class AsyncioThrottleTest(unittest.TestCase): def setUp(self): self.throttler = Throttler(rate_limit=(20, 1), period_safety_margin=0, retry_interval=0) self.loop = asyncio.get_event_loop() async def task(self, task_id, weight): async with self.throttler.weighted_task(weight): print(int(time.time()), f"Cat {task_id}: Meow {weight}") def test_tasks_complete_without_delay_when_throttle_below_rate_limit(self): tasks = [ self.task(1, 5), self.task(3, 5), self.task(3, 5), self.task(4, 4) ] with patch('hummingbot.core.utils.asyncio_throttle.asyncio.sleep' ) as sleep_patch: self.loop.run_until_complete(asyncio.gather(*tasks)) self.assertEqual(sleep_patch.call_count, 0) def test_tasks_complete_with_delay_when_throttle_above_rate_limit(self): tasks = [ self.task(1, 5), self.task(3, 5), self.task(3, 5), self.task(4, 6) ] with patch('hummingbot.core.utils.asyncio_throttle.asyncio.sleep' ) as sleep_patch: self.loop.run_until_complete(asyncio.gather(*tasks)) self.assertGreater(sleep_patch.call_count, 0)
class AltmarketsExchange(ExchangeBase): """ AltmarketsExchange connects with AltMarkets.io exchange and provides order book pricing, user account tracking and trading functionality. """ ORDER_NOT_EXIST_CONFIRMATION_COUNT = 3 ORDER_NOT_EXIST_CANCEL_COUNT = 2 @classmethod def logger(cls) -> HummingbotLogger: global ctce_logger if ctce_logger is None: ctce_logger = logging.getLogger(__name__) return ctce_logger def __init__(self, altmarkets_api_key: str, altmarkets_secret_key: str, trading_pairs: Optional[List[str]] = None, trading_required: bool = True ): """ :param altmarkets_api_key: The API key to connect to private AltMarkets.io APIs. :param altmarkets_secret_key: The API secret. :param trading_pairs: The market trading pairs which to track order book data. :param trading_required: Whether actual trading is needed. """ super().__init__() self._trading_required = trading_required self._trading_pairs = trading_pairs self._altmarkets_auth = AltmarketsAuth(altmarkets_api_key, altmarkets_secret_key) self._order_book_tracker = AltmarketsOrderBookTracker(trading_pairs=trading_pairs) self._user_stream_tracker = AltmarketsUserStreamTracker(self._altmarkets_auth, trading_pairs) self._ev_loop = asyncio.get_event_loop() self._shared_client = None self._poll_notifier = asyncio.Event() self._last_timestamp = 0 self._in_flight_orders = {} # Dict[client_order_id:str, AltmarketsInFlightOrder] self._order_not_found_records = {} # Dict[client_order_id:str, count:int] self._trading_rules = {} # Dict[trading_pair:str, TradingRule] self._status_polling_task = None self._user_stream_event_listener_task = None self._trading_rules_polling_task = None self._last_poll_timestamp = 0 self._throttler = Throttler(rate_limit = (10.0, 6.5)) @property def name(self) -> str: return "altmarkets" @property def order_books(self) -> Dict[str, OrderBook]: return self._order_book_tracker.order_books @property def trading_rules(self) -> Dict[str, TradingRule]: return self._trading_rules @property def in_flight_orders(self) -> Dict[str, AltmarketsInFlightOrder]: return self._in_flight_orders @property def status_dict(self) -> Dict[str, bool]: """ A dictionary of statuses of various connector's components. """ 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, "user_stream_initialized": self._user_stream_tracker.data_source.last_recv_time > 0 if self._trading_required else True, } @property def ready(self) -> bool: """ :return True when all statuses pass, this might take 5-10 seconds for all the connector's components and services to be ready. """ return all(self.status_dict.values()) @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 active in-flight orders in json format, is used to save in sqlite db. """ return { key: value.to_json() for key, value in self._in_flight_orders.items() if not value.is_done } def restore_tracking_states(self, saved_states: Dict[str, any]): """ Restore in-flight orders from saved tracking states, this is st the connector can pick up on where it left off when it disconnects. :param saved_states: The saved tracking_states. """ self._in_flight_orders.update({ key: AltmarketsInFlightOrder.from_json(value) for key, value in saved_states.items() }) def supported_order_types(self) -> List[OrderType]: """ :return a list of OrderType supported by this connector. Note that Market order type is no longer required and will not be used. """ return [OrderType.LIMIT, OrderType.MARKET] def start(self, clock: Clock, timestamp: float): """ This function is called automatically by the clock. """ super().start(clock, timestamp) def stop(self, clock: Clock): """ This function is called automatically by the clock. """ super().stop(clock) async def start_network(self): """ This function is required by NetworkIterator base class and is called automatically. It starts tracking order book, polling trading rules, updating statuses and tracking user data. """ 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()) async def stop_network(self): """ This function is required by NetworkIterator base class and is called automatically. """ self._order_book_tracker.stop() if self._status_polling_task is not None: self._status_polling_task.cancel() self._status_polling_task = None if self._trading_rules_polling_task is not None: self._trading_rules_polling_task.cancel() self._trading_rules_polling_task = None if self._status_polling_task is not None: self._status_polling_task.cancel() self._status_polling_task = None if self._user_stream_tracker_task is not None: self._user_stream_tracker_task.cancel() self._user_stream_tracker_task = None if self._user_stream_event_listener_task is not None: self._user_stream_event_listener_task.cancel() self._user_stream_event_listener_task = None async def check_network(self) -> NetworkStatus: """ This function is required by NetworkIterator base class and is called periodically to check the network connection. Simply ping the network (or call any light weight public API). """ try: await self._api_request(method="GET", endpoint=Constants.ENDPOINT['TIMESTAMP']) except asyncio.CancelledError: raise except Exception: return NetworkStatus.NOT_CONNECTED return NetworkStatus.CONNECTED async def _http_client(self) -> aiohttp.ClientSession: """ :returns Shared client session instance """ if self._shared_client is None: self._shared_client = aiohttp.ClientSession() return self._shared_client async def _trading_rules_polling_loop(self): """ Periodically update trading rule. """ while True: try: await self._update_trading_rules() await asyncio.sleep(Constants.INTERVAL_TRADING_RULES) except asyncio.CancelledError: raise except Exception as e: self.logger().network(f"Unexpected error while fetching trading rules. Error: {str(e)}", exc_info=True, app_warning_msg=("Could not fetch new trading rules from " f"{Constants.EXCHANGE_NAME}. Check network connection.")) await asyncio.sleep(0.5) async def _update_trading_rules(self): symbols_info = await self._api_request("GET", endpoint=Constants.ENDPOINT['SYMBOL']) self._trading_rules.clear() self._trading_rules = self._format_trading_rules(symbols_info) def _format_trading_rules(self, symbols_info: Dict[str, Any]) -> Dict[str, TradingRule]: """ Converts json API response into a dictionary of trading rules. :param symbols_info: The json API response :return A dictionary of trading rules. Response Example: [ { id: "btcusdt", name: "BTC/USDT", base_unit: "btc", quote_unit: "usdt", min_price: "0.01", max_price: "200000.0", min_amount: "0.00000001", amount_precision: 8, price_precision: 2, state: "enabled" } ] """ result = {} for rule in symbols_info: try: trading_pair = convert_from_exchange_trading_pair(rule["id"]) min_amount = Decimal(rule["min_amount"]) min_notional = min(Decimal(rule["min_price"]) * min_amount, Decimal("0.00000001")) result[trading_pair] = TradingRule(trading_pair, min_order_size=min_amount, min_base_amount_increment=Decimal(f"1e-{rule['amount_precision']}"), min_notional_size=min_notional, min_price_increment=Decimal(f"1e-{rule['price_precision']}")) except Exception: self.logger().error(f"Error parsing the trading pair rule {rule}. Skipping.", exc_info=True) return result async def _api_request(self, method: str, endpoint: str, params: Optional[Dict[str, Any]] = None, is_auth_required: bool = False, try_count: int = 0) -> Dict[str, Any]: """ Sends an aiohttp request and waits for a response. :param method: The HTTP method, e.g. get or post :param endpoint: The path url or the API end point :param params: Additional get/post parameters :param is_auth_required: Whether an authentication is required, when True the function will add encrypted signature to the request. :returns A response in json format. """ async with self._throttler.weighted_task(request_weight=1): url = f"{Constants.REST_URL}/{endpoint}" shared_client = await self._http_client() # Turn `params` into either GET params or POST body data qs_params: dict = params if method.upper() == "GET" else None req_params = ujson.dumps(params) if method.upper() == "POST" and params is not None else None # Generate auth headers if needed. headers: dict = {"Content-Type": "application/json"} if is_auth_required: headers: dict = self._altmarkets_auth.get_headers() # Build request coro response_coro = shared_client.request(method=method.upper(), url=url, headers=headers, params=qs_params, data=req_params, timeout=Constants.API_CALL_TIMEOUT) http_status, parsed_response, request_errors = await aiohttp_response_with_errors(response_coro) if request_errors or parsed_response is None: if try_count < Constants.API_MAX_RETRIES: try_count += 1 time_sleep = retry_sleep_time(try_count) self.logger().info(f"Error fetching data from {url}. HTTP status is {http_status}. " f"Retrying in {time_sleep:.0f}s.") await asyncio.sleep(time_sleep) return await self._api_request(method=method, endpoint=endpoint, params=params, is_auth_required=is_auth_required, try_count=try_count) else: raise AltmarketsAPIError({"error": parsed_response, "status": http_status}) if "error" in parsed_response: raise AltmarketsAPIError(parsed_response) return parsed_response def get_order_price_quantum(self, trading_pair: str, price: Decimal): """ Returns a price step, a minimum price increment for a given trading pair. """ trading_rule = self._trading_rules[trading_pair] return trading_rule.min_price_increment def get_order_size_quantum(self, trading_pair: str, order_size: Decimal): """ Returns an order amount step, a minimum amount increment for a given trading pair. """ trading_rule = self._trading_rules[trading_pair] return Decimal(trading_rule.min_base_amount_increment) def get_order_book(self, trading_pair: str) -> OrderBook: if trading_pair not in self._order_book_tracker.order_books: raise ValueError(f"No order book exists for '{trading_pair}'.") return self._order_book_tracker.order_books[trading_pair] def buy(self, trading_pair: str, amount: Decimal, order_type=OrderType.MARKET, price: Decimal = s_decimal_NaN, **kwargs) -> str: """ Buys an amount of base asset (of the given trading pair). This function returns immediately. To see an actual order, you'll have to wait for BuyOrderCreatedEvent. :param trading_pair: The market (e.g. BTC-USDT) to buy from :param amount: The amount in base token value :param order_type: The order type :param price: The price (note: this is no longer optional) :returns A new internal order id """ order_id: str = get_new_client_order_id(True, trading_pair) safe_ensure_future(self._create_order(TradeType.BUY, order_id, trading_pair, amount, order_type, price)) return order_id def sell(self, trading_pair: str, amount: Decimal, order_type=OrderType.MARKET, price: Decimal = s_decimal_NaN, **kwargs) -> str: """ Sells an amount of base asset (of the given trading pair). This function returns immediately. To see an actual order, you'll have to wait for SellOrderCreatedEvent. :param trading_pair: The market (e.g. BTC-USDT) to sell from :param amount: The amount in base token value :param order_type: The order type :param price: The price (note: this is no longer optional) :returns A new internal order id """ order_id: str = get_new_client_order_id(False, trading_pair) safe_ensure_future(self._create_order(TradeType.SELL, order_id, trading_pair, amount, order_type, price)) return order_id def cancel(self, trading_pair: str, order_id: str): """ Cancel an order. This function returns immediately. To get the cancellation result, you'll have to wait for OrderCancelledEvent. :param trading_pair: The market (e.g. BTC-USDT) of the order. :param order_id: The internal order id (also called client_order_id) """ safe_ensure_future(self._execute_cancel(trading_pair, order_id)) return order_id async def _create_order(self, trade_type: TradeType, order_id: str, trading_pair: str, amount: Decimal, order_type: OrderType, price: Decimal): """ Calls create-order API end point to place an order, starts tracking the order and triggers order created event. :param trade_type: BUY or SELL :param order_id: Internal order id (also called client_order_id) :param trading_pair: The market to place order :param amount: The order amount (in base token value) :param order_type: The order type :param price: The order price """ if not order_type.is_limit_type(): raise Exception(f"Unsupported order type: {order_type}") trading_rule = self._trading_rules[trading_pair] amount = self.quantize_order_amount(trading_pair, amount) price = self.quantize_order_price(trading_pair, price) if amount < trading_rule.min_order_size: raise ValueError(f"Buy order amount {amount} is lower than the minimum order size " f"{trading_rule.min_order_size}.") order_type_str = order_type.name.lower().split("_")[0] api_params = {"market": convert_to_exchange_trading_pair(trading_pair), "side": trade_type.name.lower(), "ord_type": order_type_str, "price": f"{price:f}", "volume": f"{amount:f}", } # if order_type is OrderType.LIMIT_MAKER: # api_params["postOnly"] = "true" self.start_tracking_order(order_id, None, trading_pair, trade_type, price, amount, order_type) try: order_result = await self._api_request("POST", Constants.ENDPOINT["ORDER_CREATE"], api_params, True) exchange_order_id = str(order_result["id"]) tracked_order = self._in_flight_orders.get(order_id) if tracked_order is not None: self.logger().info(f"Created {order_type.name} {trade_type.name} order {order_id} for " f"{amount} {trading_pair}.") tracked_order.update_exchange_order_id(exchange_order_id) if trade_type is TradeType.BUY: event_tag = MarketEvent.BuyOrderCreated event_cls = BuyOrderCreatedEvent else: event_tag = MarketEvent.SellOrderCreated event_cls = SellOrderCreatedEvent self.trigger_event(event_tag, event_cls(self.current_timestamp, order_type, trading_pair, amount, price, order_id)) except asyncio.CancelledError: raise except AltmarketsAPIError as e: error_reason = e.error_payload.get('error', {}).get('message') self.stop_tracking_order(order_id) self.logger().network( f"Error submitting {trade_type.name} {order_type.name} order to {Constants.EXCHANGE_NAME} for " f"{amount} {trading_pair} {price} - {error_reason}.", exc_info=True, app_warning_msg=(f"Error submitting order to {Constants.EXCHANGE_NAME} - {error_reason}.") ) self.trigger_event(MarketEvent.OrderFailure, MarketOrderFailureEvent(self.current_timestamp, order_id, order_type)) def start_tracking_order(self, order_id: str, exchange_order_id: str, trading_pair: str, trade_type: TradeType, price: Decimal, amount: Decimal, order_type: OrderType): """ Starts tracking an order by simply adding it into _in_flight_orders dictionary. """ self._in_flight_orders[order_id] = AltmarketsInFlightOrder( client_order_id=order_id, exchange_order_id=exchange_order_id, trading_pair=trading_pair, order_type=order_type, trade_type=trade_type, price=price, amount=amount ) def stop_tracking_order(self, order_id: str): """ Stops tracking an order by simply removing it from _in_flight_orders dictionary. """ 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] async def _execute_cancel(self, trading_pair: str, order_id: str) -> str: """ Executes order cancellation process by first calling cancel-order API. The API result doesn't confirm whether the cancellation is successful, it simply states it receives the request. :param trading_pair: The market trading pair (Unused during cancel on AltMarkets.io) :param order_id: The internal order id order.last_state to change to CANCELED """ order_state, errors_found = None, {} 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.exchange_order_id is None: await tracked_order.get_exchange_order_id() exchange_order_id = tracked_order.exchange_order_id response = await self._api_request("POST", Constants.ENDPOINT["ORDER_DELETE"].format(id=exchange_order_id), is_auth_required=True) if isinstance(response, dict): order_state = response.get("state", None) except asyncio.CancelledError: raise except AltmarketsAPIError as e: errors_found = e.error_payload.get('error', e.error_payload) order_state = errors_found.get("state", None) if order_state is None: self._order_not_found_records[order_id] = self._order_not_found_records.get(order_id, 0) + 1 if order_state in Constants.ORDER_STATES['CANCEL_WAIT'] or \ self._order_not_found_records.get(order_id, 0) >= self.ORDER_NOT_EXIST_CANCEL_COUNT: self.logger().info(f"Successfully cancelled order {order_id} on {Constants.EXCHANGE_NAME}.") self.stop_tracking_order(order_id) self.trigger_event(MarketEvent.OrderCancelled, OrderCancelledEvent(self.current_timestamp, order_id)) tracked_order.cancelled_event.set() return CancellationResult(order_id, True) else: self.logger().network( f"Failed to cancel order {order_id}: {errors_found.get('message', errors_found)}", exc_info=True, app_warning_msg=f"Failed to cancel the order {order_id} on {Constants.EXCHANGE_NAME}. " f"Check API key and network connection." ) return CancellationResult(order_id, False) async def _status_polling_loop(self): """ Periodically update user balances and order status via REST API. This serves as a fallback measure for web socket API updates. """ 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_poll_timestamp = self.current_timestamp except asyncio.CancelledError: raise except Exception as e: self.logger().error(str(e), exc_info=True) warn_msg = (f"Could not fetch account updates from {Constants.EXCHANGE_NAME}. " "Check API key and network connection.") self.logger().network("Unexpected error while fetching account updates.", exc_info=True, app_warning_msg=warn_msg) await asyncio.sleep(0.5) async def _update_balances(self): """ Calls REST API to update total and available balances. """ local_asset_names = set(self._account_balances.keys()) remote_asset_names = set() account_info = await self._api_request("GET", Constants.ENDPOINT["USER_BALANCES"], is_auth_required=True) for account in account_info: asset_name = account["currency"].upper() self._account_available_balances[asset_name] = Decimal(str(account["balance"])) self._account_balances[asset_name] = Decimal(str(account["locked"])) + Decimal(str(account["balance"])) remote_asset_names.add(asset_name) asset_names_to_remove = local_asset_names.difference(remote_asset_names) for asset_name in asset_names_to_remove: del self._account_available_balances[asset_name] del self._account_balances[asset_name] async def _update_order_status(self): """ Calls REST API to get status update for each in-flight order. """ last_tick = int(self._last_poll_timestamp / Constants.UPDATE_ORDER_STATUS_INTERVAL) current_tick = int(self.current_timestamp / Constants.UPDATE_ORDER_STATUS_INTERVAL) if current_tick > last_tick and len(self._in_flight_orders) > 0: tracked_orders = list(self._in_flight_orders.values()) tasks = [] for tracked_order in tracked_orders: exchange_order_id = await tracked_order.get_exchange_order_id() tasks.append(self._api_request("GET", Constants.ENDPOINT["ORDER_STATUS"].format(id=exchange_order_id), is_auth_required=True)) self.logger().debug(f"Polling for order status updates of {len(tasks)} orders.") responses = await safe_gather(*tasks, return_exceptions=True) for response, tracked_order in zip(responses, tracked_orders): client_order_id = tracked_order.client_order_id if isinstance(response, AltmarketsAPIError): err = response.error_payload.get('error', response.error_payload).get('errors', None) if "record.not_found" in err: 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.trigger_event(MarketEvent.OrderFailure, MarketOrderFailureEvent( self.current_timestamp, client_order_id, tracked_order.order_type)) self.stop_tracking_order(client_order_id) else: continue elif "id" not in response: self.logger().info(f"_update_order_status order id not in resp: {response}") continue else: self._process_order_message(response) def _process_order_message(self, order_msg: Dict[str, Any]): """ Updates in-flight order and triggers cancellation or failure event if needed. :param order_msg: The order response from either REST or web socket API (they are of the same format) Example Order: { "id": 9401, "market": "rogerbtc", "kind": "ask", "side": "sell", "ord_type": "limit", "price": "0.00000099", "avg_price": "0.00000099", "state": "wait", "origin_volume": "7000.0", "remaining_volume": "2810.1", "executed_volume": "4189.9", "at": 1596481983, "created_at": 1596481983, "updated_at": 1596553643, "trades_count": 272 } """ exchange_order_id = str(order_msg["id"]) tracked_orders = list(self._in_flight_orders.values()) track_order = [o for o in tracked_orders if exchange_order_id == o.exchange_order_id] if not track_order: return tracked_order = track_order[0] # Estimate fee order_msg["trade_fee"] = self.estimate_fee_pct(tracked_order.order_type is OrderType.LIMIT_MAKER) updated = tracked_order.update_with_order_update(order_msg) if updated: safe_ensure_future(self._trigger_order_fill(tracked_order, order_msg)) elif tracked_order.is_cancelled: self.logger().info(f"Successfully cancelled order {tracked_order.client_order_id}.") self.stop_tracking_order(tracked_order.client_order_id) self.trigger_event(MarketEvent.OrderCancelled, OrderCancelledEvent(self.current_timestamp, tracked_order.client_order_id)) tracked_order.cancelled_event.set() elif tracked_order.is_failure: self.logger().info(f"The market order {tracked_order.client_order_id} has failed according to order status API. ") self.trigger_event(MarketEvent.OrderFailure, MarketOrderFailureEvent( self.current_timestamp, tracked_order.client_order_id, tracked_order.order_type)) self.stop_tracking_order(tracked_order.client_order_id) async def _process_trade_message(self, trade_msg: Dict[str, Any]): """ Updates in-flight order and trigger order filled event for trade message received. Triggers order completed event if the total executed amount equals to the specified order amount. """ exchange_order_id = str(trade_msg["order_id"]) tracked_orders = list(self._in_flight_orders.values()) for order in tracked_orders: await order.get_exchange_order_id() track_order = [o for o in tracked_orders if exchange_order_id == o.exchange_order_id] if not track_order: return tracked_order = track_order[0] # Estimate fee trade_msg["trade_fee"] = self.estimate_fee_pct(tracked_order.order_type is OrderType.LIMIT_MAKER) updated = tracked_order.update_with_trade_update(trade_msg) if not updated: return await self._trigger_order_fill(tracked_order, trade_msg) async def _trigger_order_fill(self, tracked_order: AltmarketsInFlightOrder, update_msg: Dict[str, Any]): self.trigger_event( MarketEvent.OrderFilled, OrderFilledEvent( self.current_timestamp, tracked_order.client_order_id, tracked_order.trading_pair, tracked_order.trade_type, tracked_order.order_type, Decimal(str(update_msg.get("price", "0"))), tracked_order.executed_amount_base, TradeFee(percent=update_msg["trade_fee"]), ) ) if math.isclose(tracked_order.executed_amount_base, tracked_order.amount) or \ tracked_order.executed_amount_base >= tracked_order.amount or \ tracked_order.is_done: tracked_order.last_state = "FILLED" self.logger().info(f"The {tracked_order.trade_type.name} order " f"{tracked_order.client_order_id} has completed " f"according to order status API.") event_tag = MarketEvent.BuyOrderCompleted if tracked_order.trade_type is TradeType.BUY \ else MarketEvent.SellOrderCompleted event_class = BuyOrderCompletedEvent if tracked_order.trade_type is TradeType.BUY \ else SellOrderCompletedEvent await asyncio.sleep(0.1) self.trigger_event(event_tag, event_class(self.current_timestamp, tracked_order.client_order_id, tracked_order.base_asset, tracked_order.quote_asset, tracked_order.fee_asset, tracked_order.executed_amount_base, tracked_order.executed_amount_quote, tracked_order.fee_paid, tracked_order.order_type)) self.stop_tracking_order(tracked_order.client_order_id) async def cancel_all(self, timeout_seconds: float) -> List[CancellationResult]: """ Cancels all in-flight orders and waits for cancellation results. Used by bot's top level stop and exit commands (cancelling outstanding orders on exit) :param timeout_seconds: The timeout at which the operation will be canceled. :returns List of CancellationResult which indicates whether each order is successfully cancelled. """ if self._trading_pairs is None: raise Exception("cancel_all can only be used when trading_pairs are specified.") open_orders = [o for o in self._in_flight_orders.values() if not o.is_done] if len(open_orders) == 0: return [] tasks = [self._execute_cancel(o.trading_pair, o.client_order_id) for o in open_orders] cancellation_results = [] try: async with timeout(timeout_seconds): cancellation_results = await safe_gather(*tasks, return_exceptions=False) except Exception: print("cancel all error") self.logger().network( "Unexpected error cancelling orders.", exc_info=True, app_warning_msg=(f"Failed to cancel all orders on {Constants.EXCHANGE_NAME}. " "Check API key and network connection.") ) return cancellation_results def tick(self, timestamp: float): """ Is called automatically by the clock for each clock's tick (1 second by default). It checks if status polling task is due for execution. """ now = time.time() poll_interval = (Constants.SHORT_POLL_INTERVAL if now - self._user_stream_tracker.last_recv_time > 120.0 else Constants.LONG_POLL_INTERVAL) last_tick = int(self._last_timestamp / poll_interval) current_tick = int(timestamp / poll_interval) if current_tick > last_tick: if not self._poll_notifier.is_set(): self._poll_notifier.set() self._last_timestamp = timestamp def get_fee(self, base_currency: str, quote_currency: str, order_type: OrderType, order_side: TradeType, amount: Decimal, price: Decimal = s_decimal_NaN) -> TradeFee: """ 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 TradeFee(percent=self.estimate_fee_pct(is_maker)) 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=(f"Could not fetch user events from {Constants.EXCHANGE_NAME}. " "Check API key and network connection.")) await asyncio.sleep(1.0) async def _user_stream_event_listener(self): """ Listens to message in _user_stream_tracker.user_stream queue. The messages are put in by AltmarketsAPIUserStreamDataSource. """ async for event_message in self._iter_user_event_queue(): try: event_methods = [ Constants.WS_METHODS["USER_ORDERS"], Constants.WS_METHODS["USER_TRADES"], ] for method in list(event_message.keys()): params: dict = event_message.get(method, None) if params is None or method not in event_methods: continue if method == Constants.WS_METHODS["USER_TRADES"]: await self._process_trade_message(params) elif method == Constants.WS_METHODS["USER_ORDERS"]: self._process_order_message(params) except asyncio.CancelledError: raise except Exception: self.logger().error("Unexpected error in user stream listener loop.", exc_info=True) await asyncio.sleep(5.0) # This is currently unused, but looks like a future addition. async def get_open_orders(self) -> List[OpenOrder]: tracked_orders = list(self._in_flight_orders.values()) # for order in tracked_orders: # await order.get_exchange_order_id() result = await self._api_request("GET", Constants.ENDPOINT["USER_ORDERS"], is_auth_required=True) ret_val = [] for order in result: if order["state"] in Constants.ORDER_STATES['DONE']: # Skip done orders continue exchange_order_id = str(order["id"]) # AltMarkets doesn't support client order ids yet so we must find it from the tracked orders. track_order = [o for o in tracked_orders if exchange_order_id == o.exchange_order_id] if not track_order or len(track_order) < 1: # Skip untracked orders continue client_order_id = track_order[0].client_order_id if order["ord_type"] != OrderType.LIMIT.name.lower(): self.logger().info(f"Unsupported order type found: {order['type']}") # Skip and report non-limit orders continue ret_val.append( OpenOrder( client_order_id=client_order_id, trading_pair=convert_from_exchange_trading_pair(order["market"]), price=Decimal(str(order["price"])), amount=Decimal(str(order["origin_volume"])), executed_amount=Decimal(str(order["executed_volume"])), status=order["state"], order_type=OrderType.LIMIT, is_buy=True if order["side"].lower() == TradeType.BUY.name.lower() else False, time=str_date_to_ts(order["created_at"]), exchange_order_id=exchange_order_id ) ) return ret_val
class BinancePerpetualDerivative(DerivativeBase): MARKET_RECEIVED_ASSET_EVENT_TAG = MarketEvent.ReceivedAsset MARKET_BUY_ORDER_COMPLETED_EVENT_TAG = MarketEvent.BuyOrderCompleted MARKET_SELL_ORDER_COMPLETED_EVENT_TAG = MarketEvent.SellOrderCompleted MARKET_ORDER_CANCELLED_EVENT_TAG = MarketEvent.OrderCancelled MARKET_TRANSACTION_FAILURE_EVENT_TAG = MarketEvent.TransactionFailure MARKET_ORDER_FAILURE_EVENT_TAG = MarketEvent.OrderFailure MARKET_ORDER_FILLED_EVENT_TAG = MarketEvent.OrderFilled MARKET_BUY_ORDER_CREATED_EVENT_TAG = MarketEvent.BuyOrderCreated MARKET_SELL_ORDER_CREATED_EVENT_TAG = MarketEvent.SellOrderCreated API_CALL_TIMEOUT = 10.0 SHORT_POLL_INTERVAL = 5.0 UPDATE_ORDER_STATUS_MIN_INTERVAL = 10.0 LONG_POLL_INTERVAL = 120.0 ORDER_NOT_EXIST_CONFIRMATION_COUNT = 3 @classmethod def logger(cls) -> HummingbotLogger: global bpm_logger if bpm_logger is None: bpm_logger = logging.getLogger(__name__) return bpm_logger def __init__(self, binance_perpetual_api_key: str = None, binance_perpetual_api_secret: str = None, trading_pairs: Optional[List[str]] = None, trading_required: bool = True, **domain): self._testnet = True if len(domain) > 0 else False super().__init__() self._api_key = binance_perpetual_api_key self._api_secret = binance_perpetual_api_secret self._trading_required = trading_required # self._account_balances = {} # self._account_available_balances = {} self._base_url = PERPETUAL_BASE_URL if self._testnet is False else TESTNET_BASE_URL self._stream_url = DIFF_STREAM_URL if self._testnet is False else TESTNET_STREAM_URL self._user_stream_tracker = BinancePerpetualUserStreamTracker(base_url=self._base_url, stream_url=self._stream_url, api_key=self._api_key) self._order_book_tracker = BinancePerpetualOrderBookTracker(trading_pairs=trading_pairs, **domain) self._ev_loop = asyncio.get_event_loop() self._poll_notifier = asyncio.Event() self._in_flight_orders = {} self._order_not_found_records = {} self._last_timestamp = 0 self._trading_rules = {} # self._trade_fees = {} # self._last_update_trade_fees_timestamp = 0 self._status_polling_task = None self._user_stream_event_listener_task = None self._trading_rules_polling_task = None self._last_poll_timestamp = 0 self._throttler = Throttler((10.0, 1.0)) self._funding_rate = 0 self._account_positions = {} self._position_mode = None @property def name(self) -> str: return "binance_perpetual_testnet" if self._testnet else "binance_perpetual" @property def order_books(self) -> Dict[str, OrderBook]: return self._order_book_tracker.order_books @property def ready(self): return all(self.status_dict.values()) @property def in_flight_orders(self) -> Dict[str, BinancePerpetualsInFlightOrder]: return self._in_flight_orders @property def status_dict(self): 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, # TODO: Uncomment when figured out trade fees # "trade_fees_initialized": len(self._trade_fees) > 0 } @property def limit_orders(self): return [in_flight_order.to_limit_order() for in_flight_order in self._in_flight_orders.values()] def start(self, clock: Clock, timestamp: float): super().start(clock, timestamp) def stop(self, clock: Clock): super().stop(clock) async def start_network(self): 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: await self.request("/fapi/v1/ping") except asyncio.CancelledError: raise except Exception: return NetworkStatus.NOT_CONNECTED return NetworkStatus.CONNECTED def supported_order_types(self) -> List[OrderType]: """ :return a list of OrderType supported by this connector. Note that Market order type is no longer required and will not be used. """ return [OrderType.LIMIT, OrderType.MARKET] # ORDER PLACE AND CANCEL EXECUTIONS --- async def create_order(self, trade_type: TradeType, order_id: str, trading_pair: str, amount: Decimal, order_type: OrderType, position_action: PositionAction, price: Optional[Decimal] = Decimal("NaN")): trading_rule: TradingRule = self._trading_rules[trading_pair] if position_action not in [PositionAction.OPEN, PositionAction.CLOSE]: raise ValueError("Specify either OPEN_POSITION or CLOSE_POSITION position_action.") amount = self.quantize_order_amount(trading_pair, amount) price = self.quantize_order_price(trading_pair, price) if amount < trading_rule.min_order_size: raise ValueError(f"Buy order amount {amount} is lower than the minimum order size " f"{trading_rule.min_order_size}") order_result = None api_params = {"symbol": convert_to_exchange_trading_pair(trading_pair), "side": "BUY" if trade_type is TradeType.BUY else "SELL", "type": "LIMIT" if order_type is OrderType.LIMIT else "MARKET", "quantity": f"{amount}", "newClientOrderId": order_id } if order_type == OrderType.LIMIT: api_params["price"] = f"{price}" api_params["timeInForce"] = "GTC" if self._position_mode == PositionMode.HEDGE: if position_action == PositionAction.OPEN: api_params["positionSide"] = "LONG" if trade_type is TradeType.BUY else "SHORT" else: api_params["positionSide"] = "SHORT" if trade_type is TradeType.BUY else "LONG" self.start_tracking_order(order_id, "", trading_pair, trade_type, price, amount, order_type) try: order_result = await self.request(path="/fapi/v1/order", params=api_params, method=MethodType.POST, add_timestamp = True, is_signed=True) exchange_order_id = str(order_result["orderId"]) tracked_order = self._in_flight_orders.get(order_id) if tracked_order is not None: self.logger().info(f"Created {order_type.name.lower()} {trade_type.name.lower()} order {order_id} for " f"{amount} {trading_pair}.") tracked_order.exchange_order_id = exchange_order_id event_tag = self.MARKET_BUY_ORDER_CREATED_EVENT_TAG if trade_type is TradeType.BUY \ else self.MARKET_SELL_ORDER_CREATED_EVENT_TAG event_class = BuyOrderCreatedEvent if trade_type is TradeType.BUY else SellOrderCreatedEvent self.trigger_event(event_tag, event_class(self.current_timestamp, order_type, trading_pair, amount, price, order_id)) return order_result except asyncio.CancelledError: raise except Exception as e: self.stop_tracking_order(order_id) self.logger().network( f"Error submitting order to Binance Perpetuals for {amount} {trading_pair} " f"{'' if order_type is OrderType.MARKET else price}.", exc_info=True, app_warning_msg=str(e) ) self.trigger_event(self.MARKET_ORDER_FAILURE_EVENT_TAG, MarketOrderFailureEvent(self.current_timestamp, order_id, order_type)) async def execute_buy(self, order_id: str, trading_pair: str, amount: Decimal, order_type: OrderType, position_action: PositionAction, price: Optional[Decimal] = s_decimal_NaN): return await self.create_order(TradeType.BUY, order_id, trading_pair, amount, order_type, position_action, price) def buy(self, trading_pair: str, amount: object, order_type: object = OrderType.MARKET, price: object = s_decimal_NaN, **kwargs) -> str: t_pair: str = trading_pair order_id: str = get_client_order_id("sell", t_pair) safe_ensure_future(self.execute_buy(order_id, trading_pair, amount, order_type, kwargs["position_action"], price)) return order_id async def execute_sell(self, order_id: str, trading_pair: str, amount: Decimal, order_type: OrderType, position_action: PositionAction, price: Optional[Decimal] = s_decimal_NaN): return await self.create_order(TradeType.SELL, order_id, trading_pair, amount, order_type, position_action, price) def sell(self, trading_pair: str, amount: object, order_type: object = OrderType.MARKET, price: object = s_decimal_NaN, **kwargs) -> str: t_pair: str = trading_pair order_id: str = get_client_order_id("sell", t_pair) safe_ensure_future(self.execute_sell(order_id, trading_pair, amount, order_type, kwargs["position_action"], price)) return order_id async def cancel_all(self, timeout_seconds: float): incomplete_orders = [order for order in self._in_flight_orders.values() if not order.is_done] tasks = [self.execute_cancel(order.trading_pair, order.client_order_id) for order in incomplete_orders] order_id_set = set([order.client_order_id for order in incomplete_orders]) successful_cancellations = [] try: async with timeout(timeout_seconds): cancellation_results = await safe_gather(*tasks, return_exceptions=True) for cancel_result in cancellation_results: # TODO: QUESTION --- SHOULD I CHECK FOR THE BinanceAPIException CONSIDERING WE ARE MOVING AWAY FROM BINANCE-CLIENT? if isinstance(cancel_result, dict) and "clientOrderId" in cancel_result: client_order_id = cancel_result.get("clientOrderId") order_id_set.remove(client_order_id) successful_cancellations.append(CancellationResult(client_order_id, True)) except Exception: self.logger().network( "Unexpected error cancelling orders.", exc_info=True, app_warning_msg="Failed to cancel order with Binance Perpetual. Check API key and network connection." ) failed_cancellations = [CancellationResult(order_id, False) for order_id in order_id_set] return successful_cancellations + failed_cancellations async def cancel_all_account_orders(self, trading_pair: str): try: params = { "symbol": trading_pair } response = await self.request( path="/fapi/v1/allOpenOrders", params=params, method=MethodType.DELETE, add_timestamp=True, is_signed=True ) if response.get("code") == 200: for order_id in list(self._in_flight_orders.keys()): self.stop_tracking_order(order_id) else: raise IOError(f"Error cancelling all account orders. Server Response: {response}") except Exception as e: self.logger().error("Could not cancel all account orders.") raise e def cancel(self, trading_pair: str, client_order_id: str): safe_ensure_future(self.execute_cancel(trading_pair, client_order_id)) return client_order_id async def execute_cancel(self, trading_pair: str, client_order_id: str): try: params = { "origClientOrderId": client_order_id, "symbol": convert_to_exchange_trading_pair(trading_pair) } response = await self.request( path="/fapi/v1/order", params=params, method=MethodType.DELETE, is_signed=True, add_timestamp = True, return_err=True ) if response.get("code") == -2011 or "Unknown order sent" in response.get("msg", ""): self.logger().debug(f"The order {client_order_id} does not exist on Binance Perpetuals. " f"No cancellation needed.") self.stop_tracking_order(client_order_id) self.trigger_event(self.MARKET_ORDER_CANCELLED_EVENT_TAG, OrderCancelledEvent(self.current_timestamp, client_order_id)) return { "origClientOrderId": client_order_id } except Exception as e: self.logger().error(f"Could not cancel order {client_order_id} (on Binance Perp. {trading_pair})") raise e if response.get("status", None) == "CANCELED": self.logger().info(f"Successfully canceled order {client_order_id}") self.stop_tracking_order(client_order_id) self.trigger_event(self.MARKET_ORDER_CANCELLED_EVENT_TAG, OrderCancelledEvent(self.current_timestamp, client_order_id)) return response # TODO: Implement async def close_position(self, trading_pair: str): pass def quantize_order_amount(self, trading_pair: str, amount: object, price: object = Decimal(0)): trading_rule: TradingRule = self._trading_rules[trading_pair] # current_price: object = self.get_price(trading_pair, False) notional_size: object quantized_amount = DerivativeBase.quantize_order_amount(self, trading_pair, amount) if quantized_amount < trading_rule.min_order_size: return Decimal(0) """ if price == Decimal(0): notional_size = current_price * quantized_amount else: notional_size = price * quantized_amount """ # TODO: NOTIONAL MIN SIZE DOES NOT EXIST # if notional_size < trading_rule.min_notional_size * Decimal("1.01"): # return Decimal(0) return quantized_amount def get_order_price_quantum(self, trading_pair: str, price: object): trading_rule: TradingRule = self._trading_rules[trading_pair] return trading_rule.min_price_increment def get_order_size_quantum(self, trading_pair: str, order_size: object): trading_rule: TradingRule = self._trading_rules[trading_pair] return Decimal(trading_rule.min_base_amount_increment) # ORDER TRACKING --- def start_tracking_order(self, order_id: str, exchange_order_id: str, trading_pair: str, trading_type: object, price: object, amount: object, order_type: object): self._in_flight_orders[order_id] = BinancePerpetualsInFlightOrder( client_order_id=order_id, exchange_order_id=exchange_order_id, trading_pair=trading_pair, order_type=order_type, trade_type=trading_type, price=price, amount=amount ) def stop_tracking_order(self, order_id: str): 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] 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 Binance. 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 = event_message.get("e") if event_type == "ORDER_TRADE_UPDATE": order_message = event_message.get("o") client_order_id = order_message.get("c") # If the order has already been cancelled if client_order_id not in self._in_flight_orders: continue tracked_order = self._in_flight_orders.get(client_order_id) tracked_order.update_with_execution_report(event_message) # Execution Type: Trade => Filled trade_type = TradeType.BUY if order_message.get("S") == "BUY" else TradeType.SELL if order_message.get("X") in ["PARTIALLY_FILLED", "FILLED"]: order_filled_event = OrderFilledEvent( timestamp=event_message.get("E") * 1e-3, order_id=client_order_id, trading_pair=convert_from_exchange_trading_pair(order_message.get("s")), trade_type=trade_type, order_type=OrderType.LIMIT if order_message.get("o") == "LIMIT" else OrderType.MARKET, price=Decimal(order_message.get("L")), amount=Decimal(order_message.get("l")), trade_fee=self.get_fee( base_currency=tracked_order.base_asset, quote_currency=tracked_order.quote_asset, order_type=tracked_order.order_type, order_side=trade_type, amount=Decimal(order_message.get("q")), price=Decimal(order_message.get("p")) ), exchange_trade_id=order_message.get("t") ) self.trigger_event(self.MARKET_ORDER_FILLED_EVENT_TAG, order_filled_event) if tracked_order.is_done: if not tracked_order.is_failure: event_tag = None event_class = None if trade_type is TradeType.BUY: event_tag = self.MARKET_BUY_ORDER_COMPLETED_EVENT_TAG event_class = BuyOrderCompletedEvent else: event_tag = self.MARKET_SELL_ORDER_COMPLETED_EVENT_TAG event_class = SellOrderCompletedEvent self.logger().info(f"The {tracked_order.order_type.name.lower()} {trade_type} order {client_order_id} has completed " f"according to websocket delta.") self.trigger_event(event_tag, event_class(self.current_timestamp, client_order_id, tracked_order.base_asset, tracked_order.quote_asset, (tracked_order.fee_asset or tracked_order.quote_asset), tracked_order.executed_amount_base, tracked_order.executed_amount_quote, tracked_order.fee_paid, tracked_order.order_type)) else: if tracked_order.is_cancelled: if tracked_order.client_order_id in self._in_flight_orders: self.logger().info(f"Successfully cancelled order {tracked_order.client_order_id} according to websocket delta.") self.trigger_event(self.MARKET_ORDER_CANCELLED_EVENT_TAG, OrderCancelledEvent(self.current_timestamp, tracked_order.client_order_id)) else: self.logger().info(f"The {tracked_order.order_type.name.lower()} order {tracked_order.client_order_id} has failed " f"according to websocket delta.") self.trigger_event(self.MARKET_ORDER_FAILURE_EVENT_TAG, MarketOrderFailureEvent(self.current_timestamp, tracked_order.client_order_id, tracked_order.order_type)) self.stop_tracking_order(tracked_order.client_order_id) elif event_type == "ACCOUNT_UPDATE": update_data = event_message.get("a", {}) # update balances for asset in update_data.get("B", []): asset_name = asset["a"] self._account_balances[asset_name] = Decimal(asset["wb"]) self._account_available_balances[asset_name] = Decimal(asset["cw"]) # update position for asset in update_data.get("P", []): position = self._account_positions.get(f"{asset['s']}{asset['ps']}", None) if position is not None: position.update_position(position_side=PositionSide[asset["ps"]], unrealized_pnl = Decimal(asset["up"]), entry_price = Decimal(asset["ep"]), amount = Decimal(asset["pa"])) else: await self._update_positions() elif event_type == "MARGIN_CALL": positions = event_message.get("p", []) total_maint_margin_required = 0 # total_pnl = 0 negative_pnls_msg = "" for position in positions: existing_position = self._account_positions.get(f"{asset['s']}{asset['ps']}", None) if existing_position is not None: existing_position.update_position(position_side=PositionSide[asset["ps"]], unrealized_pnl = Decimal(asset["up"]), amount = Decimal(asset["pa"])) total_maint_margin_required += position.get("mm", 0) if position.get("up", 0) < 1: negative_pnls_msg += f"{position.get('s')}: {position.get('up')}, " self.logger().warning("Margin Call: Your position risk is too high, and you are at risk of " "liquidation. Close your positions or add additional margin to your wallet.") self.logger().info(f"Margin Required: {total_maint_margin_required}. Total Unrealized PnL: " f"{negative_pnls_msg}. Negative PnL assets: {negative_pnls_msg}.") except asyncio.CancelledError: raise except Exception as e: self.logger().error(f"Unexpected error in user stream listener loop: {e}", exc_info=True) await asyncio.sleep(5.0) def tick(self, timestamp: float): """ Is called automatically by the clock for each clock's tick (1 second by default). It checks if status polling task is due for execution. """ now = time.time() poll_interval = (self.SHORT_POLL_INTERVAL if now - self._user_stream_tracker.last_recv_time > 60.0 else self.LONG_POLL_INTERVAL) last_tick = self._last_timestamp / poll_interval current_tick = timestamp / poll_interval if current_tick > last_tick: if not self._poll_notifier.is_set(): self._poll_notifier.set() self._last_timestamp = timestamp # MARKET AND ACCOUNT INFO --- def get_fee(self, base_currency: str, quote_currency: str, order_type: object, order_side: object, amount: object, price: object): is_maker = order_type is OrderType.LIMIT return estimate_fee("binance_perpetual", is_maker) def get_order_book(self, trading_pair: str) -> OrderBook: order_books: dict = 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] async def _update_trading_rules(self): last_tick = self._last_timestamp / 60.0 current_tick = self.current_timestamp / 60.0 if current_tick > last_tick or len(self._trading_rules) < 1: exchange_info = await self.request(path="/fapi/v1/exchangeInfo", method=MethodType.GET, is_signed=False) trading_rules_list = self._format_trading_rules(exchange_info) self._trading_rules.clear() for trading_rule in trading_rules_list: self._trading_rules[trading_rule.trading_pair] = trading_rule def _format_trading_rules(self, exchange_info_dict: Dict[str, Any]) -> List[TradingRule]: rules: list = exchange_info_dict.get("symbols", []) return_val: list = [] for rule in rules: try: trading_pair = convert_from_exchange_trading_pair(rule["symbol"]) filters = rule["filters"] filt_dict = {fil["filterType"]: fil for fil in filters} min_order_size = Decimal(filt_dict.get("LOT_SIZE").get("minQty")) step_size = Decimal(filt_dict.get("LOT_SIZE").get("stepSize")) tick_size = Decimal(filt_dict.get("PRICE_FILTER").get("tickSize")) # TODO: BINANCE PERPETUALS DOES NOT HAVE A MIN NOTIONAL VALUE, NEED TO CREATE NEW DERIVATIVES INFRASTRUCTURE # min_notional = 0 return_val.append( TradingRule(trading_pair, min_order_size=min_order_size, min_price_increment=Decimal(tick_size), min_base_amount_increment=Decimal(step_size), # min_notional_size=Decimal(min_notional)) ) ) except Exception as e: self.logger().error(f"Error parsing the trading pair rule {rule}. Error: {e}. Skipping...", exc_info=True) return return_val async def _trading_rules_polling_loop(self): while True: try: await safe_gather( self._update_trading_rules() # TODO: Uncomment when implemented # self._update_trade_fees() ) 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 Binance Perpetuals. " "Check network connection.") await asyncio.sleep(0.5) 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_positions(), self._update_order_fills_from_trades(), self._update_order_status() ) self._last_poll_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 Binance Perpetuals. " "Check API key and network connection.") await asyncio.sleep(0.5) async def _update_balances(self): local_asset_names = set(self._account_balances.keys()) remote_asset_names = set() account_info = await self.request(path="/fapi/v2/account", is_signed=True, add_timestamp=True) assets = account_info.get("assets") for asset in assets: asset_name = asset.get("asset") available_balance = Decimal(asset.get("availableBalance")) wallet_balance = Decimal(asset.get("walletBalance")) self._account_available_balances[asset_name] = available_balance self._account_balances[asset_name] = wallet_balance remote_asset_names.add(asset_name) asset_names_to_remove = local_asset_names.difference(remote_asset_names) for asset_name in asset_names_to_remove: del self._account_available_balances[asset_name] del self._account_balances[asset_name] # TODO: Note --- Data Structure Assumes One-way Position Mode [not hedge position mode] (see Binance Futures Docs) # Note --- Hedge Mode allows for Both Long and Short Positions on a trading pair async def _update_positions(self): # local_position_names = set(self._account_positions.keys()) # remote_position_names = set() positions = await self.request(path="/fapi/v2/positionRisk", add_timestamp=True, is_signed=True) for position in positions: trading_pair = position.get("symbol") position_side = PositionSide[position.get("positionSide")] unrealized_pnl = Decimal(position.get("unRealizedProfit")) entry_price = Decimal(position.get("entryPrice")) amount = Decimal(position.get("positionAmt")) leverage = Decimal(position.get("leverage")) if amount != 0: self._account_positions[trading_pair + position_side.name] = Position( trading_pair=convert_from_exchange_trading_pair(trading_pair), position_side=position_side, unrealized_pnl=unrealized_pnl, entry_price=entry_price, amount=amount, leverage=leverage ) else: if (trading_pair + position_side.name) in self._account_positions: del self._account_positions[trading_pair + position_side.name] async def _update_order_fills_from_trades(self): last_tick = self._last_poll_timestamp / self.UPDATE_ORDER_STATUS_MIN_INTERVAL current_tick = self.current_timestamp / self.UPDATE_ORDER_STATUS_MIN_INTERVAL if current_tick > last_tick and len(self._in_flight_orders) > 0: trading_pairs_to_order_map = defaultdict(lambda: {}) for order in self._in_flight_orders.values(): trading_pairs_to_order_map[order.trading_pair][order.exchange_order_id] = order trading_pairs = list(trading_pairs_to_order_map.keys()) tasks = [ self.request( path="/fapi/v1/userTrades", params={ "symbol": convert_to_exchange_trading_pair(trading_pair) }, is_signed=True, add_timestamp=True ) for trading_pair in trading_pairs] self.logger().debug(f"Polling for order fills of {len(tasks)} trading_pairs.") results = await safe_gather(*tasks, return_exceptions=True) for trades, trading_pair in zip(results, trading_pairs): order_map = trading_pairs_to_order_map.get(trading_pair) if isinstance(trades, Exception): self.logger().network( f"Error fetching trades update for the order {trading_pair}: {trades}.", app_warning_msg=f"Failed to fetch trade update for {trading_pair}." ) continue for trade in trades: order_id = str(trade.get("orderId")) if order_id in order_map: tracked_order = order_map.get(order_id) order_type = tracked_order.order_type applied_trade = tracked_order.update_with_trade_updates(trade) if applied_trade: self.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, order_type, Decimal(trade.get("price")), Decimal(trade.get("qty")), self.get_fee( tracked_order.base_asset, tracked_order.quote_asset, order_type, tracked_order.trade_type, Decimal(trade["price"]), Decimal(trade["qty"])), exchange_trade_id=trade["id"] ) ) async def _update_order_status(self): last_tick = self._last_poll_timestamp / self.UPDATE_ORDER_STATUS_MIN_INTERVAL current_tick = self.current_timestamp / self.UPDATE_ORDER_STATUS_MIN_INTERVAL if current_tick > last_tick and len(self._in_flight_orders) > 0: tracked_orders = list(self._in_flight_orders.values()) tasks = [self.request(path="/fapi/v1/order", params={ "symbol": convert_to_exchange_trading_pair(order.trading_pair), "origClientOrderId": order.client_order_id }, method=MethodType.GET, add_timestamp=True, is_signed=True, return_err=True) for order in tracked_orders] self.logger().debug(f"Polling for order status updates of {len(tasks)} 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 client_order_id not in self._in_flight_orders: continue if isinstance(order_update, Exception): # NO_SUCH_ORDER code if order_update["code"] == -2013 or order_update["msg"] == "Order does not exist.": 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: continue self.trigger_event( self.MARKET_ORDER_FAILURE_EVENT_TAG, MarketOrderFailureEvent(self.current_timestamp, client_order_id, tracked_order.order_type) ) self.stop_tracking_order(client_order_id) else: self.logger().network(f"Error fetching status update for the order {client_order_id}: " f"{order_update}.") continue tracked_order.last_state = order_update.get("status") order_type = OrderType.LIMIT if order_update.get("type") == "LIMIT" else OrderType.MARKET executed_amount_base = Decimal(order_update.get("executedQty", "0")) executed_amount_quote = Decimal(order_update.get("cumQuote", "0")) if tracked_order.is_done: if not tracked_order.is_failure: event_tag = None event_class = None if tracked_order.trade_type is TradeType.BUY: event_tag = self.MARKET_BUY_ORDER_COMPLETED_EVENT_TAG event_class = BuyOrderCompletedEvent else: event_tag = self.MARKET_SELL_ORDER_COMPLETED_EVENT_TAG event_class = SellOrderCompletedEvent self.logger().info(f"The {order_type.name.lower()} {tracked_order.trade_type.name.lower()} order {client_order_id} has " f"completed according to order status API.") self.trigger_event(event_tag, event_class(self.current_timestamp, client_order_id, tracked_order.base_asset, tracked_order.quote_asset, (tracked_order.fee_asset or tracked_order.base_asset), executed_amount_base, executed_amount_quote, tracked_order.fee_paid, order_type)) else: if tracked_order.is_cancelled: self.logger().info(f"Successfully cancelled order {client_order_id} according to order status API.") self.trigger_event(self.MARKET_ORDER_CANCELLED_EVENT_TAG, OrderCancelledEvent(self.current_timestamp, client_order_id)) else: self.logger().info(f"The {order_type.name.lower()} order {client_order_id} has failed according to " f"order status API.") self.trigger_event(self.MARKET_ORDER_FAILURE_EVENT_TAG, MarketOrderFailureEvent(self.current_timestamp, client_order_id, order_type)) self.stop_tracking_order(client_order_id) async def _set_margin(self, trading_pair: str, leverage: int = 1): params = { "symbol": convert_to_exchange_trading_pair(trading_pair), "leverage": leverage } set_leverage = await self.request( path="/fapi/v1/leverage", params=params, method=MethodType.POST, add_timestamp=True, is_signed=True ) if set_leverage["leverage"] == leverage: self.logger().info(f"Leverage Successfully set to {leverage}.") else: self.logger().error("Unable to set leverage.") return leverage def set_margin(self, trading_pair: str, leverage: int = 1): safe_ensure_future(self._set_margin(trading_pair, leverage)) """ async def get_position_pnl(self, trading_pair: str): await self._update_positions() return self._account_positions.get(trading_pair) """ async def _get_funding_rate(self, trading_pair): # TODO: Note --- the "premiumIndex" endpoint can get markPrice, indexPrice, and nextFundingTime as well prem_index = await self.request("/fapi/v1/premiumIndex", params={"symbol": convert_to_exchange_trading_pair(trading_pair)}) self._funding_rate = Decimal(prem_index.get("lastFundingRate", "0")) def get_funding_rate(self, trading_pair): safe_ensure_future(self._get_funding_rate(trading_pair)) return self._funding_rate async def _set_position_mode(self, position_mode: PositionMode): initial_mode = await self._get_position_mode() if initial_mode != position_mode: params = { "dualSidePosition": position_mode.value } mode = await self.request( path="/fapi/v1/positionSide/dual", params=params, method=MethodType.POST, add_timestamp=True, is_signed=True ) if mode["msg"] == "success" and mode["code"] == 200: self.logger().info(f"Using {position_mode.name} position mode.") else: self.logger().error(f"Unable to set postion mode to {position_mode.name}.") else: self.logger().info(f"Using {position_mode.name} position mode.") async def _get_position_mode(self): if self._position_mode is None: mode = await self.request( path="/fapi/v1/positionSide/dual", method=MethodType.GET, add_timestamp=True, is_signed=True ) self._position_mode = PositionMode.HEDGE if mode["dualSidePosition"] else PositionMode.ONEWAY return self._position_mode def set_position_mode(self, position_mode: PositionMode): safe_ensure_future(self._set_position_mode(position_mode)) async def request(self, path: str, params: Dict[str, Any] = {}, method: MethodType = MethodType.GET, add_timestamp: bool = False, is_signed: bool = False, request_weight: int = 1, return_err: bool = False): async with self._throttler.weighted_task(request_weight): try: # TODO: QUESTION --- SHOULD I ADD AN ASYNC TIMEOUT? (aync with timeout(API_CALL_TIMEOUT) # async with aiohttp.ClientSession() as client: if add_timestamp: params["timestamp"] = f"{int(time.time()) * 1000}" params["recvWindow"] = f"{20000}" query = urlencode(sorted(params.items())) if is_signed: secret = bytes(self._api_secret.encode("utf-8")) signature = hmac.new(secret, query.encode("utf-8"), hashlib.sha256).hexdigest() query += f"&signature={signature}" async with aiohttp.request( method=method.value, url=self._base_url + path + "?" + query, headers={"X-MBX-APIKEY": self._api_key}) as response: if response.status != 200: error_response = await response.json() if return_err: return error_response else: raise IOError(f"Error fetching data from {path}. HTTP status is {response.status}. " f"Request Error: {error_response}") return await response.json() except Exception as e: self.logger().error(f"Error fetching {path}", exc_info=True) self.logger().warning(f"{e}") raise e
class CoinzoomExchange(ExchangeBase): """ CoinzoomExchange connects with CoinZoom exchange and provides order book pricing, user account tracking and trading functionality. """ ORDER_NOT_EXIST_CONFIRMATION_COUNT = 3 ORDER_NOT_EXIST_CANCEL_COUNT = 2 @classmethod def logger(cls) -> HummingbotLogger: global ctce_logger if ctce_logger is None: ctce_logger = logging.getLogger(__name__) return ctce_logger def __init__(self, coinzoom_api_key: str, coinzoom_secret_key: str, coinzoom_username: str, trading_pairs: Optional[List[str]] = None, trading_required: bool = True ): """ :param coinzoom_api_key: The API key to connect to private CoinZoom APIs. :param coinzoom_secret_key: The API secret. :param coinzoom_username: The ZoomMe Username. :param trading_pairs: The market trading pairs which to track order book data. :param trading_required: Whether actual trading is needed. """ super().__init__() self._trading_required = trading_required self._trading_pairs = trading_pairs self._coinzoom_auth = CoinzoomAuth(coinzoom_api_key, coinzoom_secret_key, coinzoom_username) self._order_book_tracker = CoinzoomOrderBookTracker(trading_pairs=trading_pairs) self._user_stream_tracker = CoinzoomUserStreamTracker(self._coinzoom_auth, trading_pairs) self._ev_loop = asyncio.get_event_loop() self._shared_client = None self._poll_notifier = asyncio.Event() self._last_timestamp = 0 self._in_flight_orders = {} # Dict[client_order_id:str, CoinzoomInFlightOrder] self._order_not_found_records = {} # Dict[client_order_id:str, count:int] self._trading_rules = {} # Dict[trading_pair:str, TradingRule] self._status_polling_task = None self._user_stream_event_listener_task = None self._trading_rules_polling_task = None self._last_poll_timestamp = 0 self._throttler = Throttler(rate_limit = (8.0, 6)) @property def name(self) -> str: return "coinzoom" @property def order_books(self) -> Dict[str, OrderBook]: return self._order_book_tracker.order_books @property def trading_rules(self) -> Dict[str, TradingRule]: return self._trading_rules @property def in_flight_orders(self) -> Dict[str, CoinzoomInFlightOrder]: return self._in_flight_orders @property def status_dict(self) -> Dict[str, bool]: """ A dictionary of statuses of various connector's components. """ 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, "user_stream_initialized": self._user_stream_tracker.data_source.last_recv_time > 0 if self._trading_required else True, } @property def ready(self) -> bool: """ :return True when all statuses pass, this might take 5-10 seconds for all the connector's components and services to be ready. """ return all(self.status_dict.values()) @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 active in-flight orders in json format, is used to save in sqlite db. """ return { key: value.to_json() for key, value in self._in_flight_orders.items() if not value.is_done } def restore_tracking_states(self, saved_states: Dict[str, any]): """ Restore in-flight orders from saved tracking states, this is st the connector can pick up on where it left off when it disconnects. :param saved_states: The saved tracking_states. """ self._in_flight_orders.update({ key: CoinzoomInFlightOrder.from_json(value) for key, value in saved_states.items() }) def supported_order_types(self) -> List[OrderType]: """ :return a list of OrderType supported by this connector. Note that Market order type is no longer required and will not be used. """ return [OrderType.LIMIT, OrderType.LIMIT_MAKER] def start(self, clock: Clock, timestamp: float): """ This function is called automatically by the clock. """ super().start(clock, timestamp) def stop(self, clock: Clock): """ This function is called automatically by the clock. """ super().stop(clock) async def start_network(self): """ This function is required by NetworkIterator base class and is called automatically. It starts tracking order book, polling trading rules, updating statuses and tracking user data. """ 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()) async def stop_network(self): """ This function is required by NetworkIterator base class and is called automatically. """ self._order_book_tracker.stop() if self._status_polling_task is not None: self._status_polling_task.cancel() self._status_polling_task = None if self._trading_rules_polling_task is not None: self._trading_rules_polling_task.cancel() self._trading_rules_polling_task = None if self._status_polling_task is not None: self._status_polling_task.cancel() self._status_polling_task = None if self._user_stream_tracker_task is not None: self._user_stream_tracker_task.cancel() self._user_stream_tracker_task = None if self._user_stream_event_listener_task is not None: self._user_stream_event_listener_task.cancel() self._user_stream_event_listener_task = None async def check_network(self) -> NetworkStatus: """ This function is required by NetworkIterator base class and is called periodically to check the network connection. Simply ping the network (or call any light weight public API). """ try: # since there is no ping endpoint, the lowest rate call is to get BTC-USD symbol await self._api_request("GET", Constants.ENDPOINT['SYMBOL']) except asyncio.CancelledError: raise except Exception: return NetworkStatus.NOT_CONNECTED return NetworkStatus.CONNECTED async def _http_client(self) -> aiohttp.ClientSession: """ :returns Shared client session instance """ if self._shared_client is None: self._shared_client = aiohttp.ClientSession() return self._shared_client async def _trading_rules_polling_loop(self): """ Periodically update trading rule. """ while True: try: await self._update_trading_rules() await asyncio.sleep(Constants.INTERVAL_TRADING_RULES) except asyncio.CancelledError: raise except Exception as e: self.logger().network(f"Unexpected error while fetching trading rules. Error: {str(e)}", exc_info=True, app_warning_msg=("Could not fetch new trading rules from " f"{Constants.EXCHANGE_NAME}. Check network connection.")) await asyncio.sleep(0.5) async def _update_trading_rules(self): symbols_info = await self._api_request("GET", endpoint=Constants.ENDPOINT['SYMBOL']) self._trading_rules.clear() self._trading_rules = self._format_trading_rules(symbols_info) def _format_trading_rules(self, symbols_info: Dict[str, Any]) -> Dict[str, TradingRule]: """ Converts json API response into a dictionary of trading rules. :param symbols_info: The json API response :return A dictionary of trading rules. Response Example: [ { "symbol" : "BTC/USD", "baseCurrencyCode" : "BTC", "termCurrencyCode" : "USD", "minTradeAmt" : 0.0001, "maxTradeAmt" : 10, "maxPricePrecision" : 2, "maxQuantityPrecision" : 6, "issueOnly" : false } ] """ result = {} for rule in symbols_info: try: trading_pair = convert_from_exchange_trading_pair(rule["symbol"]) min_amount = Decimal(str(rule["minTradeAmt"])) min_price = Decimal(f"1e-{rule['maxPricePrecision']}") result[trading_pair] = TradingRule(trading_pair, min_order_size=min_amount, max_order_size=Decimal(str(rule["maxTradeAmt"])), min_price_increment=min_price, min_base_amount_increment=min_amount, min_notional_size=min(min_price * min_amount, Decimal("0.00000001")), max_price_significant_digits=Decimal(str(rule["maxPricePrecision"])), ) except Exception: self.logger().error(f"Error parsing the trading pair rule {rule}. Skipping.", exc_info=True) return result async def _api_request(self, method: str, endpoint: str, params: Optional[Dict[str, Any]] = None, is_auth_required: bool = False, try_count: int = 0) -> Dict[str, Any]: """ Sends an aiohttp request and waits for a response. :param method: The HTTP method, e.g. get or post :param endpoint: The path url or the API end point :param params: Additional get/post parameters :param is_auth_required: Whether an authentication is required, when True the function will add encrypted signature to the request. :returns A response in json format. """ async with self._throttler.weighted_task(request_weight=1): url = f"{Constants.REST_URL}/{endpoint}" shared_client = await self._http_client() # Turn `params` into either GET params or POST body data qs_params: dict = params if method.upper() == "GET" else None req_params = ujson.dumps(params) if method.upper() == "POST" and params is not None else None # Generate auth headers if needed. headers: dict = {"Content-Type": "application/json", "User-Agent": "hummingbot"} if is_auth_required: headers: dict = self._coinzoom_auth.get_headers() # Build request coro response_coro = shared_client.request(method=method.upper(), url=url, headers=headers, params=qs_params, data=req_params, timeout=Constants.API_CALL_TIMEOUT) http_status, parsed_response, request_errors = await aiohttp_response_with_errors(response_coro) if request_errors or parsed_response is None: if try_count < Constants.API_MAX_RETRIES: try_count += 1 time_sleep = retry_sleep_time(try_count) self.logger().info(f"Error fetching data from {url}. HTTP status is {http_status}. " f"Retrying in {time_sleep:.0f}s.") await asyncio.sleep(time_sleep) return await self._api_request(method=method, endpoint=endpoint, params=params, is_auth_required=is_auth_required, try_count=try_count) else: raise CoinzoomAPIError({"error": parsed_response, "status": http_status}) if "error" in parsed_response: raise CoinzoomAPIError(parsed_response) return parsed_response def get_order_price_quantum(self, trading_pair: str, price: Decimal): """ Returns a price step, a minimum price increment for a given trading pair. """ trading_rule = self._trading_rules[trading_pair] return trading_rule.min_price_increment def get_order_size_quantum(self, trading_pair: str, order_size: Decimal): """ Returns an order amount step, a minimum amount increment for a given trading pair. """ trading_rule = self._trading_rules[trading_pair] return Decimal(trading_rule.min_base_amount_increment) def get_order_book(self, trading_pair: str) -> OrderBook: if trading_pair not in self._order_book_tracker.order_books: raise ValueError(f"No order book exists for '{trading_pair}'.") return self._order_book_tracker.order_books[trading_pair] def buy(self, trading_pair: str, amount: Decimal, order_type=OrderType.MARKET, price: Decimal = s_decimal_NaN, **kwargs) -> str: """ Buys an amount of base asset (of the given trading pair). This function returns immediately. To see an actual order, you'll have to wait for BuyOrderCreatedEvent. :param trading_pair: The market (e.g. BTC-USDT) to buy from :param amount: The amount in base token value :param order_type: The order type :param price: The price (note: this is no longer optional) :returns A new internal order id """ order_id: str = get_new_client_order_id(True, trading_pair) safe_ensure_future(self._create_order(TradeType.BUY, order_id, trading_pair, amount, order_type, price)) return order_id def sell(self, trading_pair: str, amount: Decimal, order_type=OrderType.MARKET, price: Decimal = s_decimal_NaN, **kwargs) -> str: """ Sells an amount of base asset (of the given trading pair). This function returns immediately. To see an actual order, you'll have to wait for SellOrderCreatedEvent. :param trading_pair: The market (e.g. BTC-USDT) to sell from :param amount: The amount in base token value :param order_type: The order type :param price: The price (note: this is no longer optional) :returns A new internal order id """ order_id: str = get_new_client_order_id(False, trading_pair) safe_ensure_future(self._create_order(TradeType.SELL, order_id, trading_pair, amount, order_type, price)) return order_id def cancel(self, trading_pair: str, order_id: str): """ Cancel an order. This function returns immediately. To get the cancellation result, you'll have to wait for OrderCancelledEvent. :param trading_pair: The market (e.g. BTC-USDT) of the order. :param order_id: The internal order id (also called client_order_id) """ safe_ensure_future(self._execute_cancel(trading_pair, order_id)) return order_id async def _create_order(self, trade_type: TradeType, order_id: str, trading_pair: str, amount: Decimal, order_type: OrderType, price: Decimal): """ Calls create-order API end point to place an order, starts tracking the order and triggers order created event. :param trade_type: BUY or SELL :param order_id: Internal order id (also called client_order_id) :param trading_pair: The market to place order :param amount: The order amount (in base token value) :param order_type: The order type :param price: The order price """ if not order_type.is_limit_type(): raise Exception(f"Unsupported order type: {order_type}") trading_rule = self._trading_rules[trading_pair] amount = self.quantize_order_amount(trading_pair, amount) price = self.quantize_order_price(trading_pair, price) if amount < trading_rule.min_order_size: raise ValueError(f"Buy order amount {amount} is lower than the minimum order size " f"{trading_rule.min_order_size}.") order_type_str = order_type.name.upper().split("_")[0] api_params = {"symbol": convert_to_exchange_trading_pair(trading_pair), "orderType": order_type_str, "orderSide": trade_type.name.upper(), "quantity": f"{amount:f}", "price": f"{price:f}", "originType": Constants.HBOT_BROKER_ID, # CoinZoom doesn't support client order id yet # "clientOrderId": order_id, "payFeesWithZoomToken": "true", } self.start_tracking_order(order_id, None, trading_pair, trade_type, price, amount, order_type) try: order_result = await self._api_request("POST", Constants.ENDPOINT["ORDER_CREATE"], api_params, True) exchange_order_id = str(order_result) tracked_order = self._in_flight_orders.get(order_id) if tracked_order is not None: self.logger().info(f"Created {order_type.name} {trade_type.name} order {order_id} for " f"{amount} {trading_pair}.") tracked_order.update_exchange_order_id(exchange_order_id) if trade_type is TradeType.BUY: event_tag = MarketEvent.BuyOrderCreated event_cls = BuyOrderCreatedEvent else: event_tag = MarketEvent.SellOrderCreated event_cls = SellOrderCreatedEvent self.trigger_event(event_tag, event_cls(self.current_timestamp, order_type, trading_pair, amount, price, order_id)) except asyncio.CancelledError: raise except CoinzoomAPIError as e: error_reason = e.error_payload.get('error', {}).get('message') self.stop_tracking_order(order_id) self.logger().network( f"Error submitting {trade_type.name} {order_type.name} order to {Constants.EXCHANGE_NAME} for " f"{amount} {trading_pair} {price} - {error_reason}.", exc_info=True, app_warning_msg=(f"Error submitting order to {Constants.EXCHANGE_NAME} - {error_reason}.") ) self.trigger_event(MarketEvent.OrderFailure, MarketOrderFailureEvent(self.current_timestamp, order_id, order_type)) def start_tracking_order(self, order_id: str, exchange_order_id: str, trading_pair: str, trade_type: TradeType, price: Decimal, amount: Decimal, order_type: OrderType): """ Starts tracking an order by simply adding it into _in_flight_orders dictionary. """ self._in_flight_orders[order_id] = CoinzoomInFlightOrder( client_order_id=order_id, exchange_order_id=exchange_order_id, trading_pair=trading_pair, order_type=order_type, trade_type=trade_type, price=price, amount=amount ) def stop_tracking_order(self, order_id: str): """ Stops tracking an order by simply removing it from _in_flight_orders dictionary. """ 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] async def _execute_cancel(self, trading_pair: str, order_id: str) -> str: """ Executes order cancellation process by first calling cancel-order API. The API result doesn't confirm whether the cancellation is successful, it simply states it receives the request. :param trading_pair: The market trading pair (Unused during cancel on CoinZoom) :param order_id: The internal order id order.last_state to change to CANCELED """ order_was_cancelled = False 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.exchange_order_id is None: await tracked_order.get_exchange_order_id() ex_order_id = tracked_order.exchange_order_id api_params = { "orderId": ex_order_id, "symbol": convert_to_exchange_trading_pair(trading_pair) } await self._api_request("POST", Constants.ENDPOINT["ORDER_DELETE"], api_params, is_auth_required=True) order_was_cancelled = True except asyncio.CancelledError: raise except CoinzoomAPIError as e: err = e.error_payload.get('error', e.error_payload) self.logger().error(f"Order Cancel API Error: {err}") # CoinZoom doesn't report any error if the order wasn't found so we can only handle API failures here. self._order_not_found_records[order_id] = self._order_not_found_records.get(order_id, 0) + 1 if self._order_not_found_records[order_id] >= self.ORDER_NOT_EXIST_CANCEL_COUNT: order_was_cancelled = True if order_was_cancelled: self.logger().info(f"Successfully cancelled order {order_id} on {Constants.EXCHANGE_NAME}.") self.stop_tracking_order(order_id) self.trigger_event(MarketEvent.OrderCancelled, OrderCancelledEvent(self.current_timestamp, order_id)) tracked_order.cancelled_event.set() return CancellationResult(order_id, True) else: self.logger().network( f"Failed to cancel order {order_id}: {err.get('message', str(err))}", exc_info=True, app_warning_msg=f"Failed to cancel the order {order_id} on {Constants.EXCHANGE_NAME}. " f"Check API key and network connection." ) return CancellationResult(order_id, False) async def _status_polling_loop(self): """ Periodically update user balances and order status via REST API. This serves as a fallback measure for web socket API updates. """ 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_poll_timestamp = self.current_timestamp except asyncio.CancelledError: raise except Exception as e: self.logger().error(str(e), exc_info=True) warn_msg = (f"Could not fetch account updates from {Constants.EXCHANGE_NAME}. " "Check API key and network connection.") self.logger().network("Unexpected error while fetching account updates.", exc_info=True, app_warning_msg=warn_msg) await asyncio.sleep(0.5) async def _update_balances(self): """ Calls REST API to update total and available balances. """ account_info = await self._api_request("GET", Constants.ENDPOINT["USER_BALANCES"], is_auth_required=True) self._process_balance_message(account_info) async def _update_order_status(self): """ Calls REST API to get status update for each in-flight order. """ last_tick = int(self._last_poll_timestamp / Constants.UPDATE_ORDER_STATUS_INTERVAL) current_tick = int(self.current_timestamp / Constants.UPDATE_ORDER_STATUS_INTERVAL) if current_tick > last_tick and len(self._in_flight_orders) > 0: tracked_orders = list(self._in_flight_orders.values()) api_params = { 'symbol': None, 'orderSide': None, 'orderStatuses': ["NEW", "PARTIALLY_FILLED"], 'size': 500, 'bookmarkOrderId': None } self.logger().debug(f"Polling for order status updates of {len(tracked_orders)} orders.") open_orders = await self._api_request("POST", Constants.ENDPOINT["ORDER_STATUS"], api_params, is_auth_required=True) open_orders_dict = {o['id']: o for o in open_orders} found_ex_order_ids = list(open_orders_dict.keys()) for tracked_order in tracked_orders: client_order_id = tracked_order.client_order_id ex_order_id = tracked_order.exchange_order_id if ex_order_id not in found_ex_order_ids: 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 is not found a few times before actually treating it as failed. continue self.trigger_event(MarketEvent.OrderFailure, MarketOrderFailureEvent( self.current_timestamp, client_order_id, tracked_order.order_type)) self.stop_tracking_order(client_order_id) else: self._process_order_message(open_orders_dict[ex_order_id]) def _process_order_message(self, order_msg: Dict[str, Any]): """ Updates in-flight order and triggers cancellation or failure event if needed. :param order_msg: The order response from either REST or web socket API (they are of the same format) Example Orders: REST request { "id" : "977f82aa-23dc-4c8b-982c-2ee7d2002882", "clientOrderId" : null, "symbol" : "BTC/USD", "orderType" : "LIMIT", "orderSide" : "BUY", "quantity" : 0.1, "price" : 54570, "payFeesWithZoomToken" : false, "orderStatus" : "PARTIALLY_FILLED", "timestamp" : "2021-03-24T04:07:26.260253Z", "executions" : [ { "id" : "38761582-2b37-4e27-a561-434981d21a96", "executionType" : "PARTIAL_FILL", "orderStatus" : "PARTIALLY_FILLED", "lastPrice" : 54570, "averagePrice" : 54570, "lastQuantity" : 0.01, "leavesQuantity" : 0.09, "cumulativeQuantity" : 0.01, "rejectReason" : null, "timestamp" : "2021-03-24T04:07:44.503222Z" } ] } WS request { 'orderId': '962a2a54-fbcf-4d89-8f37-a8854020a823', 'symbol': 'BTC/USD', 'orderType': 'LIMIT', 'orderSide': 'BUY', 'price': 5000, 'quantity': 0.001, 'executionType': 'CANCEL', 'orderStatus': 'CANCELLED', 'lastQuantity': 0, 'leavesQuantity': 0, 'cumulativeQuantity': 0, 'transactTime': '2021-03-23T19:06:51.155520Z' ... Optional fields 'id': '4eb3f26c-91bd-4bd2-bacb-15b2f432c452', "orderType": "LIMIT", "lastPrice": 56518.7, "averagePrice": 56518.7, } """ # Looks like CoinZoom might support clientOrderId eventually so leaving this here for now. # if order_msg.get('clientOrderId') is not None: # client_order_id = order_msg["clientOrderId"] # if client_order_id not in self._in_flight_orders: # return # tracked_order = self._in_flight_orders[client_order_id] # else: if "orderId" not in order_msg: exchange_order_id = str(order_msg["id"]) else: exchange_order_id = str(order_msg["orderId"]) tracked_orders = list(self._in_flight_orders.values()) track_order = [o for o in tracked_orders if exchange_order_id == o.exchange_order_id] if not track_order: return tracked_order = track_order[0] # Estimate fee order_msg["trade_fee"] = self.estimate_fee_pct(tracked_order.order_type is OrderType.LIMIT_MAKER) updated = tracked_order.update_with_order_update(order_msg) # Call Update balances on every message to catch order create, fill and cancel. safe_ensure_future(self._update_balances()) if updated: safe_ensure_future(self._trigger_order_fill(tracked_order, order_msg)) elif tracked_order.is_cancelled: self.logger().info(f"Successfully cancelled order {tracked_order.client_order_id}.") self.stop_tracking_order(tracked_order.client_order_id) self.trigger_event(MarketEvent.OrderCancelled, OrderCancelledEvent(self.current_timestamp, tracked_order.client_order_id)) tracked_order.cancelled_event.set() elif tracked_order.is_failure: self.logger().info(f"The order {tracked_order.client_order_id} has failed according to order status API. ") self.trigger_event(MarketEvent.OrderFailure, MarketOrderFailureEvent( self.current_timestamp, tracked_order.client_order_id, tracked_order.order_type)) self.stop_tracking_order(tracked_order.client_order_id) async def _trigger_order_fill(self, tracked_order: CoinzoomInFlightOrder, update_msg: Dict[str, Any]): self.trigger_event( MarketEvent.OrderFilled, OrderFilledEvent( self.current_timestamp, tracked_order.client_order_id, tracked_order.trading_pair, tracked_order.trade_type, tracked_order.order_type, Decimal(str(update_msg.get("averagePrice", update_msg.get("price", "0")))), tracked_order.executed_amount_base, TradeFee(percent=update_msg["trade_fee"]), update_msg.get("exchange_trade_id", update_msg.get("id", update_msg.get("orderId"))) ) ) if math.isclose(tracked_order.executed_amount_base, tracked_order.amount) or \ tracked_order.executed_amount_base >= tracked_order.amount or \ tracked_order.is_done: tracked_order.last_state = "FILLED" self.logger().info(f"The {tracked_order.trade_type.name} order " f"{tracked_order.client_order_id} has completed " f"according to order status API.") event_tag = MarketEvent.BuyOrderCompleted if tracked_order.trade_type is TradeType.BUY \ else MarketEvent.SellOrderCompleted event_class = BuyOrderCompletedEvent if tracked_order.trade_type is TradeType.BUY \ else SellOrderCompletedEvent await asyncio.sleep(0.1) self.trigger_event(event_tag, event_class(self.current_timestamp, tracked_order.client_order_id, tracked_order.base_asset, tracked_order.quote_asset, tracked_order.fee_asset, tracked_order.executed_amount_base, tracked_order.executed_amount_quote, tracked_order.fee_paid, tracked_order.order_type)) self.stop_tracking_order(tracked_order.client_order_id) def _process_balance_message(self, balance_update): local_asset_names = set(self._account_balances.keys()) remote_asset_names = set() for account in balance_update: asset_name = account["currency"] total_bal = Decimal(str(account["totalBalance"])) self._account_available_balances[asset_name] = total_bal + Decimal(str(account["reservedBalance"])) self._account_balances[asset_name] = total_bal remote_asset_names.add(asset_name) asset_names_to_remove = local_asset_names.difference(remote_asset_names) for asset_name in asset_names_to_remove: del self._account_available_balances[asset_name] del self._account_balances[asset_name] async def cancel_all(self, timeout_seconds: float) -> List[CancellationResult]: """ Cancels all in-flight orders and waits for cancellation results. Used by bot's top level stop and exit commands (cancelling outstanding orders on exit) :param timeout_seconds: The timeout at which the operation will be canceled. :returns List of CancellationResult which indicates whether each order is successfully cancelled. """ # if self._trading_pairs is None: # raise Exception("cancel_all can only be used when trading_pairs are specified.") open_orders = [o for o in self._in_flight_orders.values() if not o.is_done] if len(open_orders) == 0: return [] tasks = [self._execute_cancel(o.trading_pair, o.client_order_id) for o in open_orders] cancellation_results = [] try: async with timeout(timeout_seconds): cancellation_results = await safe_gather(*tasks, return_exceptions=False) except Exception: self.logger().network( "Unexpected error cancelling orders.", exc_info=True, app_warning_msg=(f"Failed to cancel all orders on {Constants.EXCHANGE_NAME}. " "Check API key and network connection.") ) return cancellation_results def tick(self, timestamp: float): """ Is called automatically by the clock for each clock's tick (1 second by default). It checks if status polling task is due for execution. """ now = time.time() poll_interval = (Constants.SHORT_POLL_INTERVAL if now - self._user_stream_tracker.last_recv_time > 60.0 else Constants.LONG_POLL_INTERVAL) last_tick = int(self._last_timestamp / poll_interval) current_tick = int(timestamp / poll_interval) if current_tick > last_tick: if not self._poll_notifier.is_set(): self._poll_notifier.set() self._last_timestamp = timestamp def get_fee(self, base_currency: str, quote_currency: str, order_type: OrderType, order_side: TradeType, amount: Decimal, price: Decimal = s_decimal_NaN) -> TradeFee: """ 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 TradeFee(percent=self.estimate_fee_pct(is_maker)) 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=(f"Could not fetch user events from {Constants.EXCHANGE_NAME}. " "Check API key and network connection.")) await asyncio.sleep(1.0) async def _user_stream_event_listener(self): """ Listens to message in _user_stream_tracker.user_stream queue. The messages are put in by CoinzoomAPIUserStreamDataSource. """ async for event_message in self._iter_user_event_queue(): try: event_methods = [ Constants.WS_METHODS["USER_ORDERS"], Constants.WS_METHODS["USER_ORDERS_CANCEL"], ] msg_keys = list(event_message.keys()) if event_message is not None else [] method_key = [key for key in msg_keys if key in event_methods] if len(method_key) != 1: continue method: str = method_key[0] if method == Constants.WS_METHODS["USER_ORDERS"]: self._process_order_message(event_message[method]) elif method == Constants.WS_METHODS["USER_ORDERS_CANCEL"]: self._process_order_message(event_message[method]) except asyncio.CancelledError: raise except Exception: self.logger().error("Unexpected error in user stream listener loop.", exc_info=True) await asyncio.sleep(5.0) # This is currently unused, but looks like a future addition. async def get_open_orders(self) -> List[OpenOrder]: tracked_orders = list(self._in_flight_orders.values()) api_params = { 'symbol': None, 'orderSide': None, 'orderStatuses': ["NEW", "PARTIALLY_FILLED"], 'size': 500, 'bookmarkOrderId': None } result = await self._api_request("POST", Constants.ENDPOINT["USER_ORDERS"], api_params, is_auth_required=True) ret_val = [] for order in result: exchange_order_id = str(order["id"]) # CoinZoom doesn't support client order ids yet so we must find it from the tracked orders. track_order = [o for o in tracked_orders if exchange_order_id == o.exchange_order_id] if not track_order or len(track_order) < 1: # Skip untracked orders continue client_order_id = track_order[0].client_order_id # if Constants.HBOT_BROKER_ID not in order["clientOrderId"]: # continue if order["orderType"] != OrderType.LIMIT.name.upper(): self.logger().info(f"Unsupported order type found: {order['type']}") # Skip and report non-limit orders continue ret_val.append( OpenOrder( client_order_id=client_order_id, trading_pair=convert_from_exchange_trading_pair(order["symbol"]), price=Decimal(str(order["price"])), amount=Decimal(str(order["quantity"])), executed_amount=Decimal(str(order["cumQuantity"])), status=order["orderStatus"], order_type=OrderType.LIMIT, is_buy=True if order["orderSide"].lower() == TradeType.BUY.name.lower() else False, time=str_date_to_ts(order["timestamp"]), exchange_order_id=order["id"] ) ) return ret_val
def setUp(self): self.throttler = Throttler(rate_limit=(20, 1), period_safety_margin=0, retry_interval=0) self.loop = asyncio.get_event_loop()
Tuple, ) from hummingbot.core.utils.asyncio_throttle import Throttler from hummingbot.core.utils.tracking_nonce import get_tracking_nonce from hummingbot.client.config.config_var import ConfigVar from hummingbot.client.config.config_methods import using_exchange from .gate_io_constants import Constants CENTRALIZED = True EXAMPLE_PAIR = "BTC-USDT" DEFAULT_FEES = [0.2, 0.2] REQUEST_THROTTLER = Throttler(rate_limit=(18.0, 8.0)) class GateIoAPIError(IOError): def __init__(self, error_payload: Dict[str, Any]): super().__init__(str(error_payload)) self.error_payload = error_payload self.http_status = error_payload.get('status') if isinstance(error_payload, dict): self.error_message = error_payload.get('error', error_payload).get( 'message', error_payload) self.error_label = error_payload.get('error', error_payload).get( 'label', error_payload) else: self.error_message = error_payload self.error_label = error_payload