From 818131a189cac5364882cdd248a678fa9ee1534a Mon Sep 17 00:00:00 2001 From: Nick Fraser Date: Sat, 30 Jan 2021 15:28:20 +0000 Subject: [PATCH 001/131] [script-base] Set maximum length of mid-prices list to avoid accumulating memory. --- hummingbot/script/script_base.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/hummingbot/script/script_base.py b/hummingbot/script/script_base.py index 8d581212dc..9df844f353 100644 --- a/hummingbot/script/script_base.py +++ b/hummingbot/script/script_base.py @@ -22,6 +22,7 @@ def __init__(self): self._child_queue: Queue = None self._queue_check_interval: float = 0.0 self.mid_prices: List[Decimal] = [] + self.max_mid_prices_length: int = 86400 # 60 * 60 * 24 = 1 day of prices self.pmm_parameters: PMMParameters = None self.pmm_market_info: PmmMarketInfo = None # all_total_balances stores balances in {exchange: {token: balance}} format @@ -59,6 +60,8 @@ async def listen_to_parent(self): break if isinstance(item, OnTick): self.mid_prices.append(item.mid_price) + if len(self.mid_prices) > self.max_mid_prices_length: + self.mid_prices = self.mid_prices[len(self.mid_prices) - self.max_mid_prices_length:] self.pmm_parameters = item.pmm_parameters self.all_total_balances = item.all_total_balances self.all_available_balances = item.all_available_balances From bbbc2c9fd490d2546deda38907807761de120ea2 Mon Sep 17 00:00:00 2001 From: Daniel Tan Date: Mon, 1 Feb 2021 12:58:39 +0800 Subject: [PATCH 002/131] (refactor) remove MockAPI from test_crypto_com_exchange.py --- .../crypto_com/test_crypto_com_exchange.py | 80 +------------------ 1 file changed, 4 insertions(+), 76 deletions(-) diff --git a/test/connector/exchange/crypto_com/test_crypto_com_exchange.py b/test/connector/exchange/crypto_com/test_crypto_com_exchange.py index cee9f376e0..e997587c44 100644 --- a/test/connector/exchange/crypto_com/test_crypto_com_exchange.py +++ b/test/connector/exchange/crypto_com/test_crypto_com_exchange.py @@ -8,7 +8,6 @@ import time import os from typing import List -from unittest import mock import conf import math @@ -35,15 +34,11 @@ from hummingbot.model.trade_fill import TradeFill from hummingbot.connector.markets_recorder import MarketsRecorder from hummingbot.connector.exchange.crypto_com.crypto_com_exchange import CryptoComExchange -from hummingbot.connector.exchange.crypto_com.crypto_com_constants import WSS_PUBLIC_URL, WSS_PRIVATE_URL -from test.integration.humming_web_app import HummingWebApp -from test.integration.humming_ws_server import HummingWsServerFactory from . import fixture logging.basicConfig(level=METRICS_LOG_LEVEL) -API_MOCK_ENABLED = conf.mock_api_enabled is not None and conf.mock_api_enabled.lower() in ['true', 'yes', '1'] -API_KEY = "XXX" if API_MOCK_ENABLED else conf.crypto_com_api_key -API_SECRET = "YYY" if API_MOCK_ENABLED else conf.crypto_com_secret_key +API_KEY = conf.crypto_com_api_key +API_SECRET = conf.crypto_com_secret_key BASE_API_URL = "api.crypto.com" @@ -70,26 +65,6 @@ def setUpClass(cls): cls.ev_loop = asyncio.get_event_loop() - if API_MOCK_ENABLED: - cls.web_app = HummingWebApp.get_instance() - cls.web_app.add_host_to_mock(BASE_API_URL, []) - cls.web_app.start() - cls.ev_loop.run_until_complete(cls.web_app.wait_til_started()) - cls._patcher = mock.patch("aiohttp.client.URL") - cls._url_mock = cls._patcher.start() - cls._url_mock.side_effect = cls.web_app.reroute_local - cls.web_app.update_response("get", BASE_API_URL, "/v2/public/get-ticker", fixture.TICKERS) - cls.web_app.update_response("get", BASE_API_URL, "/v2/public/get-instruments", fixture.INSTRUMENTS) - cls.web_app.update_response("get", BASE_API_URL, "/v2/public/get-book", fixture.GET_BOOK) - cls.web_app.update_response("post", BASE_API_URL, "/v2/private/get-account-summary", fixture.BALANCES) - cls.web_app.update_response("post", BASE_API_URL, "/v2/private/cancel-order", fixture.CANCEL) - - HummingWsServerFactory.start_new_server(WSS_PRIVATE_URL) - HummingWsServerFactory.start_new_server(WSS_PUBLIC_URL) - cls._ws_patcher = unittest.mock.patch("websockets.connect", autospec=True) - cls._ws_mock = cls._ws_patcher.start() - cls._ws_mock.side_effect = HummingWsServerFactory.reroute_ws_connect - cls.clock: Clock = Clock(ClockMode.REALTIME) cls.connector: CryptoComExchange = CryptoComExchange( crypto_com_api_key=API_KEY, @@ -101,20 +76,12 @@ def setUpClass(cls): cls.clock.add_iterator(cls.connector) cls.stack: contextlib.ExitStack = contextlib.ExitStack() cls._clock = cls.stack.enter_context(cls.clock) - if API_MOCK_ENABLED: - HummingWsServerFactory.send_json_threadsafe(WSS_PRIVATE_URL, fixture.WS_INITIATED, delay=0.5) - HummingWsServerFactory.send_json_threadsafe(WSS_PRIVATE_URL, fixture.WS_SUBSCRIBE, delay=0.51) - HummingWsServerFactory.send_json_threadsafe(WSS_PRIVATE_URL, fixture.WS_HEARTBEAT, delay=0.52) cls.ev_loop.run_until_complete(cls.wait_til_ready()) print("Ready.") @classmethod def tearDownClass(cls) -> None: cls.stack.close() - if API_MOCK_ENABLED: - cls.web_app.stop() - cls._patcher.stop() - cls._ws_patcher.stop() @classmethod async def wait_til_ready(cls, connector = None): @@ -165,37 +132,14 @@ def test_estimate_fee(self): def _place_order(self, is_buy, amount, order_type, price, ex_order_id, get_order_fixture=None, ws_trade_fixture=None, ws_order_fixture=None) -> str: - if API_MOCK_ENABLED: - data = fixture.PLACE_ORDER.copy() - data["result"]["order_id"] = str(ex_order_id) - self.web_app.update_response("post", BASE_API_URL, "/v2/private/create-order", data) if is_buy: cl_order_id = self.connector.buy(self.trading_pair, amount, order_type, price) else: cl_order_id = self.connector.sell(self.trading_pair, amount, order_type, price) - if API_MOCK_ENABLED: - if get_order_fixture is not None: - data = get_order_fixture.copy() - data["result"]["order_info"]["client_oid"] = cl_order_id - data["result"]["order_info"]["order_id"] = ex_order_id - self.web_app.update_response("post", BASE_API_URL, "/v2/private/get-order-detail", data) - if ws_trade_fixture is not None: - data = ws_trade_fixture.copy() - data["result"]["data"][0]["order_id"] = str(ex_order_id) - HummingWsServerFactory.send_json_threadsafe(WSS_PRIVATE_URL, data, delay=0.1) - if ws_order_fixture is not None: - data = ws_order_fixture.copy() - data["result"]["data"][0]["order_id"] = str(ex_order_id) - data["result"]["data"][0]["client_oid"] = cl_order_id - HummingWsServerFactory.send_json_threadsafe(WSS_PRIVATE_URL, data, delay=0.12) return cl_order_id def _cancel_order(self, cl_order_id): self.connector.cancel(self.trading_pair, cl_order_id) - if API_MOCK_ENABLED: - data = fixture.WS_ORDER_CANCELLED.copy() - data["result"]["data"][0]["client_oid"] = cl_order_id - HummingWsServerFactory.send_json_threadsafe(WSS_PRIVATE_URL, data, delay=0.1) def test_buy_and_sell(self): price = self.connector.get_price(self.trading_pair, True) * Decimal("1.05") @@ -291,12 +235,8 @@ def test_limit_makers_unfilled(self): self.assertEqual(cl_order_id, event.order_id) def _mock_ws_bal_update(self, token, available): - if API_MOCK_ENABLED: - available = float(available) - data = fixture.WS_BALANCE.copy() - data["result"]["data"][0]["currency"] = token - data["result"]["data"][0]["available"] = available - HummingWsServerFactory.send_json_threadsafe(WSS_PRIVATE_URL, fixture.WS_BALANCE, delay=0.1) + # TODO: Determine best way to test balance via ws + pass def test_limit_maker_rejections(self): price = self.connector.get_price(self.trading_pair, True) * Decimal("1.2") @@ -327,16 +267,6 @@ def test_cancel_all(self): self.ev_loop.run_until_complete(asyncio.sleep(1)) asyncio.ensure_future(self.connector.cancel_all(3)) - if API_MOCK_ENABLED: - data = fixture.WS_ORDER_CANCELLED.copy() - data["result"]["data"][0]["client_oid"] = buy_id - data["result"]["data"][0]["order_id"] = 1 - HummingWsServerFactory.send_json_threadsafe(WSS_PRIVATE_URL, data, delay=0.1) - self.ev_loop.run_until_complete(asyncio.sleep(1)) - data = fixture.WS_ORDER_CANCELLED.copy() - data["result"]["data"][0]["client_oid"] = sell_id - data["result"]["data"][0]["order_id"] = 2 - HummingWsServerFactory.send_json_threadsafe(WSS_PRIVATE_URL, data, delay=0.11) self.ev_loop.run_until_complete(asyncio.sleep(3)) cancel_events = [t for t in self.event_logger.event_log if isinstance(t, OrderCancelledEvent)] self.assertEqual({buy_id, sell_id}, {o.order_id for o in cancel_events}) @@ -434,8 +364,6 @@ def test_orders_saving_and_restoration(self): recorder.start() saved_market_states = recorder.get_market_states(config_path, new_connector) self.clock.add_iterator(new_connector) - if not API_MOCK_ENABLED: - self.ev_loop.run_until_complete(self.wait_til_ready(new_connector)) self.assertEqual(0, len(new_connector.limit_orders)) self.assertEqual(0, len(new_connector.tracking_states)) new_connector.restore_tracking_states(saved_market_states.saved_state) From 33c8c01c0fe546ab49b0960b2ee9cdc907267a33 Mon Sep 17 00:00:00 2001 From: Daniel Tan Date: Mon, 1 Feb 2021 19:04:41 +0800 Subject: [PATCH 003/131] (fix) fix setUpClass in CryptoComExchangeUnitTest --- test/connector/exchange/crypto_com/test_crypto_com_exchange.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/connector/exchange/crypto_com/test_crypto_com_exchange.py b/test/connector/exchange/crypto_com/test_crypto_com_exchange.py index e997587c44..eb0e9f697f 100644 --- a/test/connector/exchange/crypto_com/test_crypto_com_exchange.py +++ b/test/connector/exchange/crypto_com/test_crypto_com_exchange.py @@ -68,7 +68,7 @@ def setUpClass(cls): cls.clock: Clock = Clock(ClockMode.REALTIME) cls.connector: CryptoComExchange = CryptoComExchange( crypto_com_api_key=API_KEY, - crypto_com_api_secret=API_SECRET, + crypto_com_secret_key=API_SECRET, trading_pairs=[cls.trading_pair], trading_required=True ) From 487833d2938e432745fb2c1f8dd596ce06fff2ff Mon Sep 17 00:00:00 2001 From: vic-en Date: Thu, 4 Feb 2021 12:45:09 +0100 Subject: [PATCH 004/131] (feat) initial part of perpetual protocol connector --- .../derivative/perpetual_finance/__init__.py | 0 .../derivative/perpetual_finance/dummy.pxd | 2 + .../derivative/perpetual_finance/dummy.pyx | 2 + .../perpetual_finance_derivative.py | 557 ++++++++++++++++++ .../perpetual_finance_in_flight_order.py | 58 ++ .../perpetual_finance_utils.py | 34 ++ 6 files changed, 653 insertions(+) create mode 100644 hummingbot/connector/derivative/perpetual_finance/__init__.py create mode 100644 hummingbot/connector/derivative/perpetual_finance/dummy.pxd create mode 100644 hummingbot/connector/derivative/perpetual_finance/dummy.pyx create mode 100644 hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py create mode 100644 hummingbot/connector/derivative/perpetual_finance/perpetual_finance_in_flight_order.py create mode 100644 hummingbot/connector/derivative/perpetual_finance/perpetual_finance_utils.py diff --git a/hummingbot/connector/derivative/perpetual_finance/__init__.py b/hummingbot/connector/derivative/perpetual_finance/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/hummingbot/connector/derivative/perpetual_finance/dummy.pxd b/hummingbot/connector/derivative/perpetual_finance/dummy.pxd new file mode 100644 index 0000000000..4b098d6f59 --- /dev/null +++ b/hummingbot/connector/derivative/perpetual_finance/dummy.pxd @@ -0,0 +1,2 @@ +cdef class dummy(): + pass diff --git a/hummingbot/connector/derivative/perpetual_finance/dummy.pyx b/hummingbot/connector/derivative/perpetual_finance/dummy.pyx new file mode 100644 index 0000000000..4b098d6f59 --- /dev/null +++ b/hummingbot/connector/derivative/perpetual_finance/dummy.pyx @@ -0,0 +1,2 @@ +cdef class dummy(): + pass diff --git a/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py new file mode 100644 index 0000000000..181b277da7 --- /dev/null +++ b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py @@ -0,0 +1,557 @@ +import logging +from decimal import Decimal +import asyncio +import aiohttp +from typing import Dict, Any, List, Optional +import json +import time +import ssl +import copy +from hummingbot.logger.struct_logger import METRICS_LOG_LEVEL +from hummingbot.core.utils import async_ttl_cache +from hummingbot.core.network_iterator import NetworkStatus +from hummingbot.core.utils.async_utils import safe_ensure_future, safe_gather +from hummingbot.logger import HummingbotLogger +from hummingbot.core.utils.tracking_nonce import get_tracking_nonce +from hummingbot.core.data_type.limit_order import LimitOrder +from hummingbot.core.data_type.cancellation_result import CancellationResult +from hummingbot.core.event.events import ( + MarketEvent, + BuyOrderCreatedEvent, + SellOrderCreatedEvent, + BuyOrderCompletedEvent, + SellOrderCompletedEvent, + MarketOrderFailureEvent, + OrderFilledEvent, + OrderType, + TradeType, + TradeFee, + # PositionSide, PositionMode, + PositionAction +) +from hummingbot.connector.derivative_base import DerivativeBase +from hummingbot.connector.derivative.perpetual_finance.perpetual_finance_in_flight_order import PerpetualFinanceInFlightOrder +from hummingbot.connector.derivative.perpetual_finance.perpetual_finance_utils import convert_to_exchange_trading_pair # convert_from_exchange_trading_pair +from hummingbot.client.settings import GATEAWAY_CA_CERT_PATH, GATEAWAY_CLIENT_CERT_PATH, GATEAWAY_CLIENT_KEY_PATH +from hummingbot.core.utils.eth_gas_station_lookup import get_gas_price +from hummingbot.client.config.global_config_map import global_config_map +# from hummingbot.connector.derivative.position import Position + + +s_logger = None +s_decimal_0 = Decimal("0") +s_decimal_NaN = Decimal("nan") +logging.basicConfig(level=METRICS_LOG_LEVEL) + + +class PerpetualFinanceDerivative(DerivativeBase): + """ + PerpetualFinanceConnector connects with perpetual_finance gateway APIs and provides pricing, user account tracking and trading + functionality. + """ + API_CALL_TIMEOUT = 10.0 + POLL_INTERVAL = 1.0 + UPDATE_BALANCE_INTERVAL = 30.0 + + @classmethod + def logger(cls) -> HummingbotLogger: + global s_logger + if s_logger is None: + s_logger = logging.getLogger(__name__) + return s_logger + + def __init__(self, + trading_pairs: List[str], + wallet_private_key: str, + ethereum_rpc_url: str, + trading_required: bool = True + ): + """ + :param trading_pairs: a list of trading pairs + :param wallet_private_key: a private key for eth wallet + :param ethereum_rpc_url: this is usually infura RPC URL + :param trading_required: Whether actual trading is needed. + """ + super().__init__() + self._trading_pairs = trading_pairs + self._wallet_private_key = wallet_private_key + self._trading_required = trading_required + self._ev_loop = asyncio.get_event_loop() + self._shared_client = None + self._last_poll_timestamp = 0.0 + self._last_balance_poll_timestamp = time.time() + self._in_flight_orders = {} + self._allowances = {} + self._status_polling_task = None + self._auto_approve_task = None + self._real_time_balance_update = False + self._poll_notifier = None + + @property + def name(self): + return "perpetual_finance" + + """@staticmethod + async def fetch_trading_pairs() -> List[str]: + resp = await self._api_request("get", "perpfi/get-pairs") + pairs = resp.get("pairs", []) + if len(pairs) == 0: + await self.load_metadata() + trading_pairs = [] + for pair in pairs: + trading_pairs.append(convert_from_exchange_trading_pair(pair)) + return trading_pairs""" + + @property + def limit_orders(self) -> List[LimitOrder]: + return [ + in_flight_order.to_limit_order() + for in_flight_order in self._in_flight_orders.values() + ] + + async def load_metadata(self): + status = await self._api_request("get", "perpfi/") + loadedMetadata = status["loadedMetadata"] + while (not loadedMetadata): + resp = await self._api_request("get", "perpfi/load-metadata") + loadedMetadata = resp.get("loadedMetadata", False) + return + + async def auto_approve(self): + """ + Automatically approves PerpetualFinance contract as a spender for token in trading pairs. + It first checks if there are any already approved amount (allowance) + """ + self.logger().info("Checking for allowances...") + self._allowances = await self.get_allowances() + for token, amount in self._allowances.items(): + if amount <= s_decimal_0: + amount_approved = await self.approve_perpetual_finance_spender() + if amount_approved > 0: + self._allowances[token] = amount_approved + await asyncio.sleep(2) + else: + break + + async def approve_perpetual_finance_spender(self) -> Decimal: + """ + Approves PerpetualFinance contract as a spender for default USDC token. + """ + resp = await self._api_request("post", "perpfi/approve") + amount_approved = Decimal(str(resp["amount"])) + if amount_approved > 0: + self.logger().info("Approved PerpetualFinance spender contract.") + else: + self.logger().info("PerpetualFinance spender contract approval failed.") + return amount_approved + + async def get_allowances(self) -> Dict[str, Decimal]: + """ + Retrieves allowances for token in trading_pairs + :return: A dictionary of token and its allowance (how much PerpetualFinance can spend). + """ + ret_val = {} + resp = await self._api_request("post", "perpfi/allowances") + for asset, amount in resp["approvals"].items(): + ret_val[asset] = Decimal(str(amount)) + return ret_val + + @async_ttl_cache(ttl=5, maxsize=10) + async def get_quote_price(self, trading_pair: str, is_buy: bool, amount: Decimal) -> Optional[Decimal]: + """ + Retrieves a quote price. + :param trading_pair: The market trading pair + :param is_buy: True for an intention to buy, False for an intention to sell + :param amount: The amount required (in base token unit) + :return: The quote price. + """ + + try: + side = "buy" if is_buy else "sell" + resp = await self._api_request("post", + "perpfi/get-price", + {"side": side, + "pair": convert_to_exchange_trading_pair(trading_pair), + "amount": amount}) + if resp["price"] is not None: + return Decimal(str(resp["price"])) + except asyncio.CancelledError: + raise + except Exception as e: + self.logger().network( + f"Error getting quote price for {trading_pair} {side} order for {amount} amount.", + exc_info=True, + app_warning_msg=str(e) + ) + + async def get_order_price(self, trading_pair: str, is_buy: bool, amount: Decimal) -> Decimal: + """ + This is simply the quote price + """ + return await self.get_quote_price(trading_pair, is_buy, amount) + + def buy(self, trading_pair: str, amount: Decimal, order_type: OrderType, price: Decimal, position_action: PositionAction) -> str: + """ + Buys an amount of base token for a given price (or cheaper). + :param trading_pair: The market trading pair + :param amount: The order amount (in base token unit) + :param order_type: Any order type is fine, not needed for this. + :param price: The maximum price for the order. + :param position_action: Either OPEN or CLOSE position action. + :return: A newly created order id (internal). + """ + return self.place_order(True, trading_pair, amount, price, position_action) + + def sell(self, trading_pair: str, amount: Decimal, order_type: OrderType, price: Decimal, position_action: PositionAction) -> str: + """ + Sells an amount of base token for a given price (or at a higher price). + :param trading_pair: The market trading pair + :param amount: The order amount (in base token unit) + :param order_type: Any order type is fine, not needed for this. + :param price: The minimum price for the order. + :param position_action: Either OPEN or CLOSE position action. + :return: A newly created order id (internal). + """ + return self.place_order(False, trading_pair, amount, price, position_action) + + def place_order(self, is_buy: bool, trading_pair: str, amount: Decimal, price: Decimal, position_action: PositionAction) -> str: + """ + Places an order. + :param is_buy: True for buy order + :param trading_pair: The market trading pair + :param amount: The order amount (in base token unit) + :param price: The minimum price for the order. + :param position_action: Either OPEN or CLOSE position action. + :return: A newly created order id (internal). + """ + side = TradeType.BUY if is_buy else TradeType.SELL + order_id = f"{side.name.lower()}-{trading_pair}-{get_tracking_nonce()}" + safe_ensure_future(self._create_order(side, order_id, trading_pair, amount, price, position_action)) + return order_id + + async def _create_order(self, + trade_type: TradeType, + order_id: str, + trading_pair: str, + amount: Decimal, + price: Decimal, + position_action: PositionAction): + """ + Calls buy or sell API end point to place an order, starts tracking the order and triggers relevant order events. + :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 price: The order price + :param position_action: Either OPEN or CLOSE position action. + """ + + amount = self.quantize_order_amount(trading_pair, amount) + price = self.quantize_order_price(trading_pair, price) + base, quote = trading_pair.split("-") + gas_price = get_gas_price() + api_params = {"pair": convert_to_exchange_trading_pair(trading_pair)} + if position_action == PositionAction.OPEN: + api_params.update({"side": 0 if trade_type == TradeType.BUY else 1, + "margin": str(amount / self._leverage), + "leverage": self._leverage, + "minBaseAssetAmount": amount}) + else: + api_params.update({"minimalQuoteAsset": price * amount}) + self.start_tracking_order(order_id, None, trading_pair, trade_type, price, amount, gas_price) + try: + order_result = await self._api_request("post", f"perpfi/{trade_type.name.lower()}", api_params) + hash = order_result.get("txHash") + tracked_order = self._in_flight_orders.get(order_id) + if tracked_order is not None: + self.logger().info(f"Created {trade_type.name} order {order_id} txHash: {hash} " + f"for {amount} {trading_pair}.") + tracked_order.update_exchange_order_id(hash) + tracked_order.gas_price = gas_price + if hash is not None: + tracked_order.fee_asset = "ETH" + tracked_order.executed_amount_base = amount + tracked_order.executed_amount_quote = amount * price + event_tag = MarketEvent.BuyOrderCreated if trade_type is TradeType.BUY else MarketEvent.SellOrderCreated + event_class = BuyOrderCreatedEvent if trade_type is TradeType.BUY else SellOrderCreatedEvent + self.trigger_event(event_tag, event_class(self.current_timestamp, OrderType.LIMIT, trading_pair, amount, + price, order_id, hash)) + else: + self.trigger_event(MarketEvent.OrderFailure, + MarketOrderFailureEvent(self.current_timestamp, order_id, OrderType.LIMIT)) + except asyncio.CancelledError: + raise + except Exception as e: + self.stop_tracking_order(order_id) + self.logger().network( + f"Error submitting {trade_type.name} order to PerpetualFinance for " + f"{amount} {trading_pair} " + f"{price}.", + exc_info=True, + app_warning_msg=str(e) + ) + self.trigger_event(MarketEvent.OrderFailure, + MarketOrderFailureEvent(self.current_timestamp, order_id, OrderType.LIMIT)) + + def start_tracking_order(self, + order_id: str, + exchange_order_id: str, + trading_pair: str, + trade_type: TradeType, + price: Decimal, + amount: Decimal, + gas_price: Decimal, + leverage: int, + position: str,): + """ + Starts tracking an order by simply adding it into _in_flight_orders dictionary. + """ + self._in_flight_orders[order_id] = PerpetualFinanceInFlightOrder( + client_order_id=order_id, + exchange_order_id=exchange_order_id, + trading_pair=trading_pair, + order_type=OrderType.LIMIT, + trade_type=trade_type, + price=price, + amount=amount, + gas_price=gas_price, + leverage=leverage, + position=position + ) + + 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] + + async def _update_order_status(self): + """ + Calls REST API to get status update for each in-flight order. + """ + if len(self._in_flight_orders) > 0: + tracked_orders = list(self._in_flight_orders.values()) + + tasks = [] + for tracked_order in tracked_orders: + order_id = await tracked_order.get_exchange_order_id() + tasks.append(self._api_request("post", + "eth/get-receipt", + {"txHash": order_id})) + update_results = await safe_gather(*tasks, return_exceptions=True) + for update_result in update_results: + self.logger().info(f"Polling for order status updates of {len(tasks)} orders.") + if isinstance(update_result, Exception): + raise update_result + if "txHash" not in update_result: + self.logger().info(f"_update_order_status txHash not in resp: {update_result}") + continue + if update_result["confirmed"] is True: + if update_result["receipt"]["status"] == 1: + gas_used = update_result["receipt"]["gasUsed"] + gas_price = tracked_order.gas_price + fee = Decimal(str(gas_used)) * Decimal(str(gas_price)) / Decimal(str(1e9)) + 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(tracked_order.price)), + Decimal(str(tracked_order.amount)), + TradeFee(0.0, [(tracked_order.fee_asset, Decimal(str(fee)))]), + exchange_trade_id=order_id + ) + ) + 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 + 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, + float(fee), + tracked_order.order_type)) + self.stop_tracking_order(tracked_order.client_order_id) + else: + 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) + + def get_taker_order_type(self): + return OrderType.LIMIT + + def get_order_price_quantum(self, trading_pair: str, price: Decimal) -> Decimal: + return Decimal("1e-15") + + def get_order_size_quantum(self, trading_pair: str, order_size: Decimal) -> Decimal: + return Decimal("1e-15") + + @property + def ready(self): + return all(self.status_dict.values()) + + def has_allowances(self) -> bool: + """ + Checks if all tokens have allowance (an amount approved) + """ + return len(self._allowances.values()) == len(self._token_addresses.values()) and \ + all(amount > s_decimal_0 for amount in self._allowances.values()) + + @property + def status_dict(self) -> Dict[str, bool]: + return { + "account_balance": len(self._account_balances) > 0 if self._trading_required else True, + "allowances": self.has_allowances() if self._trading_required else True + } + + async def start_network(self): + if self._trading_required: + self._status_polling_task = safe_ensure_future(self._status_polling_loop()) + self._auto_approve_task = safe_ensure_future(self.auto_approve()) + + async def stop_network(self): + if self._status_polling_task is not None: + self._status_polling_task.cancel() + self._status_polling_task = None + if self._auto_approve_task is not None: + self._auto_approve_task.cancel() + self._auto_approve_task = None + + async def check_network(self) -> NetworkStatus: + try: + response = await self._api_request("get", "api") + if response["status"] != "ok": + raise Exception(f"Error connecting to Gateway API. HTTP status is {response.status}.") + except asyncio.CancelledError: + raise + except Exception: + return NetworkStatus.NOT_CONNECTED + return NetworkStatus.CONNECTED + + 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. + """ + if time.time() - self._last_poll_timestamp > self.POLL_INTERVAL: + if self._poll_notifier is not None and not self._poll_notifier.is_set(): + self._poll_notifier.set() + + async def _status_polling_loop(self): + while True: + try: + self._poll_notifier = asyncio.Event() + await self._poll_notifier.wait() + await safe_gather( + self._update_balances(), + self._update_order_status(), + ) + self._last_poll_timestamp = self.current_timestamp + except asyncio.CancelledError: + raise + except Exception as e: + self.logger().error(str(e), exc_info=True) + self.logger().network("Unexpected error while fetching account updates.", + exc_info=True, + app_warning_msg="Could not fetch balances from Gateway API.") + await asyncio.sleep(0.5) + + async def _update_balances(self): + """ + Calls Eth API to update total and available balances. + """ + last_tick = self._last_balance_poll_timestamp + current_tick = self.current_timestamp + if (current_tick - last_tick) > self.UPDATE_BALANCE_INTERVAL: + self._last_balance_poll_timestamp = current_tick + local_asset_names = set(self._account_balances.keys()) + remote_asset_names = set() + resp_json = await self._api_request("post", "perpfi/balances") + for token, bal in resp_json["balances"].items(): + if len(token) > 4: + token = self.get_token(token) + self._account_available_balances[token] = Decimal(str(bal)) + self._account_balances[token] = Decimal(str(bal)) + remote_asset_names.add(token) + + asset_names_to_remove = local_asset_names.difference(remote_asset_names) + for asset_name in asset_names_to_remove: + del self._account_available_balances[asset_name] + del self._account_balances[asset_name] + + self._in_flight_orders_snapshot = {k: copy.copy(v) for k, v in self._in_flight_orders.items()} + self._in_flight_orders_snapshot_timestamp = self.current_timestamp + + async def _http_client(self) -> aiohttp.ClientSession: + """ + :returns Shared client session instance + """ + if self._shared_client is None: + ssl_ctx = ssl.create_default_context(cafile=GATEAWAY_CA_CERT_PATH) + ssl_ctx.load_cert_chain(GATEAWAY_CLIENT_CERT_PATH, GATEAWAY_CLIENT_KEY_PATH) + conn = aiohttp.TCPConnector(ssl_context=ssl_ctx) + self._shared_client = aiohttp.ClientSession(connector=conn) + return self._shared_client + + async def _api_request(self, + method: str, + path_url: str, + params: Dict[str, Any] = {}) -> Dict[str, Any]: + """ + Sends an aiohttp request and waits for a response. + :param method: The HTTP method, e.g. get or post + :param path_url: The path url or the API end point + :param params: A dictionary of required params for the end point + :returns A response in json format. + """ + base_url = f"https://{global_config_map['gateway_api_host'].value}:" \ + f"{global_config_map['gateway_api_port'].value}" + url = f"{base_url}/{path_url}" + client = await self._http_client() + if method == "get": + if len(params) > 0: + response = await client.get(url, params=params) + else: + response = await client.get(url) + elif method == "post": + params["privateKey"] = self._wallet_private_key + if params["privateKey"][:2] != "0x": + params["privateKey"] = "0x" + params["privateKey"] + response = await client.post(url, data=params) + + parsed_response = json.loads(await response.text()) + if response.status != 200: + err_msg = "" + if "error" in parsed_response: + err_msg = f" Message: {parsed_response['error']}" + raise IOError(f"Error fetching data from {url}. HTTP status is {response.status}.{err_msg}") + if "error" in parsed_response: + raise Exception(f"Error: {parsed_response['error']}") + + return parsed_response + + async def cancel_all(self, timeout_seconds: float) -> List[CancellationResult]: + return [] + + @property + def in_flight_orders(self) -> Dict[str, PerpetualFinanceInFlightOrder]: + return self._in_flight_orders diff --git a/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_in_flight_order.py b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_in_flight_order.py new file mode 100644 index 0000000000..d7dcc0fc1f --- /dev/null +++ b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_in_flight_order.py @@ -0,0 +1,58 @@ +from decimal import Decimal +from typing import ( + Optional, +) +from hummingbot.core.event.events import ( + OrderType, + TradeType +) +from hummingbot.connector.in_flight_order_base import InFlightOrderBase + + +class PerpetualFinanceInFlightOrder(InFlightOrderBase): + def __init__(self, + client_order_id: str, + exchange_order_id: Optional[str], + trading_pair: str, + order_type: OrderType, + trade_type: TradeType, + price: Decimal, + amount: Decimal, + gas_price: Decimal, + leverage: int, + position: str, + initial_state: str = "OPEN"): + super().__init__( + client_order_id, + exchange_order_id, + trading_pair, + order_type, + trade_type, + price, + amount, + initial_state, + ) + self.trade_id_set = set() + self._gas_price = gas_price + self.leverage = leverage + self.position = position + + @property + def is_done(self) -> bool: + return self.last_state in {"FILLED", "CANCELED", "REJECTED", "EXPIRED"} + + @property + def is_failure(self) -> bool: + return self.last_state in {"REJECTED"} + + @property + def is_cancelled(self) -> bool: + return self.last_state in {"CANCELED", "EXPIRED"} + + @property + def gas_price(self) -> Decimal: + return self._gas_price + + @gas_price.setter + def gas_price(self, gas_price) -> Decimal: + self._gas_price = gas_price diff --git a/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_utils.py b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_utils.py new file mode 100644 index 0000000000..665a0a34e1 --- /dev/null +++ b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_utils.py @@ -0,0 +1,34 @@ +import re +from typing import Optional, Tuple + +CENTRALIZED = False +EXAMPLE_PAIR = "ETH-USDC" +DEFAULT_FEES = [0.1, 0.1] + +USE_ETHEREUM_WALLET = True +FEE_TYPE = "FlatFee" +FEE_TOKEN = "XDAI" + +USE_ETH_GAS_LOOKUP = False + +QUOTE = re.compile(r"^(\w+)(USDC|USDT)$") + + +# Helper Functions --- +def split_trading_pair(trading_pair: str) -> Optional[Tuple[str, str]]: + try: + m = QUOTE.match(trading_pair) + return m.group(1), m.group(2) + except Exception as e: + raise e + + +def convert_from_exchange_trading_pair(exchange_trading_pair: str) -> Optional[str]: + if split_trading_pair(exchange_trading_pair) is None: + return None + base_asset, quote_asset = split_trading_pair(exchange_trading_pair) + return f"{base_asset}-{quote_asset}" + + +def convert_to_exchange_trading_pair(hb_trading_pair: str) -> str: + return hb_trading_pair.replace("-", "") From dade418c6abf805b3934edbe7896772e8d1615a8 Mon Sep 17 00:00:00 2001 From: Daniel Tan Date: Mon, 8 Feb 2021 11:59:15 +0800 Subject: [PATCH 005/131] (fix) crypto_com intermittent _update_order_status error --- .../crypto_com/crypto_com_exchange.py | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/hummingbot/connector/exchange/crypto_com/crypto_com_exchange.py b/hummingbot/connector/exchange/crypto_com/crypto_com_exchange.py index 4f44727264..dcaba47b8d 100644 --- a/hummingbot/connector/exchange/crypto_com/crypto_com_exchange.py +++ b/hummingbot/connector/exchange/crypto_com/crypto_com_exchange.py @@ -602,16 +602,20 @@ async def _update_order_status(self): {"order_id": order_id}, True)) self.logger().debug(f"Polling for order status updates of {len(tasks)} orders.") - update_results = await safe_gather(*tasks, return_exceptions=True) - for update_result in update_results: - if isinstance(update_result, Exception): - raise update_result - if "result" not in update_result: - self.logger().info(f"_update_order_status result not in resp: {update_result}") + responses = await safe_gather(*tasks, return_exceptions=True) + for response in responses: + if isinstance(response, Exception): + raise response + if "result" not in response: + self.logger().info(f"_update_order_status result not in resp: {response}") continue - for trade_msg in update_result["result"]["trade_list"]: + result = response["result"] + if "trade_list" not in result: + self.logger().info(f"{__name__}: trade_list not in result: {result}") + continue + for trade_msg in result["trade_list"]: await self._process_trade_message(trade_msg) - self._process_order_message(update_result["result"]["order_info"]) + self._process_order_message(result["order_info"]) def _process_order_message(self, order_msg: Dict[str, Any]): """ From 16e03b3853c83ce087ac1a682b9eac24d95b60e9 Mon Sep 17 00:00:00 2001 From: vic-en Date: Mon, 8 Feb 2021 12:55:16 +0100 Subject: [PATCH 006/131] (refactor) refactor existing derivative functions and populate DerivativeBase class --- .../binance_perpetual_derivative.py | 35 ++++++---------- hummingbot/connector/derivative_base.py | 42 +++++++++++++++++++ .../perpetual_market_making.pyx | 2 +- 3 files changed, 55 insertions(+), 24 deletions(-) diff --git a/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_derivative.py b/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_derivative.py index 910f88b93d..5670407a0c 100644 --- a/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_derivative.py +++ b/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_derivative.py @@ -131,10 +131,6 @@ def __init__(self, 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 - self._leverage = 1 @property def name(self) -> str: @@ -408,10 +404,6 @@ async def execute_cancel(self, trading_pair: str, client_order_id: str): 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) @@ -883,7 +875,7 @@ async def _update_order_status(self): order_type)) self.stop_tracking_order(client_order_id) - async def _set_margin(self, trading_pair: str, leverage: int = 1): + async def _set_leverage(self, trading_pair: str, leverage: int = 1): params = { "symbol": convert_to_exchange_trading_pair(trading_pair), "leverage": leverage @@ -902,23 +894,16 @@ async def _set_margin(self, trading_pair: str, leverage: int = 1): 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) - """ + def set_leverage(self, trading_pair: str, leverage: int = 1): + safe_ensure_future(self._set_leverage(trading_pair, leverage)) - async def _get_funding_rate(self, trading_pair): - # TODO: Note --- the "premiumIndex" endpoint can get markPrice, indexPrice, and nextFundingTime as well + async def _get_funding_info(self, trading_pair): 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")) + self._funding_info = 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 + def get_funding_info(self, trading_pair): + safe_ensure_future(self._get_funding_info(trading_pair)) + return self._funding_info async def _set_position_mode(self, position_mode: PositionMode): initial_mode = await self._get_position_mode() @@ -941,6 +926,7 @@ async def _set_position_mode(self, position_mode: PositionMode): self.logger().info(f"Using {position_mode.name} position mode.") async def _get_position_mode(self): + # To-do: ensure there's no active order or contract before changing position mode if self._position_mode is None: mode = await self.request( path="/fapi/v1/positionSide/dual", @@ -955,6 +941,9 @@ async def _get_position_mode(self): def set_position_mode(self, position_mode: PositionMode): safe_ensure_future(self._set_position_mode(position_mode)) + def supported_position_modes(self): + return [PositionMode.ONEWAY, PositionMode.HEDGE] + 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): diff --git a/hummingbot/connector/derivative_base.py b/hummingbot/connector/derivative_base.py index 668d568156..59e4d7b64c 100644 --- a/hummingbot/connector/derivative_base.py +++ b/hummingbot/connector/derivative_base.py @@ -1,5 +1,6 @@ from decimal import Decimal from hummingbot.connector.exchange_base import ExchangeBase +from hummingbot.core.event.events import PositionMode NaN = float("nan") @@ -14,3 +15,44 @@ class DerivativeBase(ExchangeBase): def __init__(self): super().__init__() + self._funding_info = {} + self._account_positions = {} + self._position_mode = None + self._leverage = 1 + + def set_position_mode(self, position_mode: PositionMode): + """ + Should set the _position_mode parameter. i.e self._position_mode = position_mode + This should also be overwritten if the derivative exchange requires interraction to set mode, + in addition to setting the _position_mode object. + :param position_mode: ONEWAY or HEDGE position mode + """ + self._position_mode = position_mode + return + + def set_leverage(self, trading_pair: str, leverage: int = 1): + """ + Should set the _leverage parameter. i.e self._leverage = leverage + This should also be overwritten if the derivative exchange requires interraction to set leverage, + in addition to setting the _leverage object. + :param _leverage: leverage to be used + """ + self._leverage = leverage + return + + def supported_position_modes(self): + """ + returns a list containing the modes supported by the derivative + ONEWAY and/or HEDGE modes + """ + return [PositionMode.ONEWAY] + + def get_funding_info(self, trading_pair): + """ + return a dictionary containing: + "indexPrice": (i.e "21.169488483519444444") + "markPrice": price used for both pnl on most derivatives (i.e "21.210103847902463671") + "nextFundingTime": next funding time in unix timestamp (i.e "1612780270") + "rate": next funding rate as a decimal and not percentage (i.e 0.00007994084744229488) + """ + raise NotImplementedError diff --git a/hummingbot/strategy/perpetual_market_making/perpetual_market_making.pyx b/hummingbot/strategy/perpetual_market_making/perpetual_market_making.pyx index 5eea14483d..5f7b879d21 100644 --- a/hummingbot/strategy/perpetual_market_making/perpetual_market_making.pyx +++ b/hummingbot/strategy/perpetual_market_making/perpetual_market_making.pyx @@ -555,7 +555,7 @@ cdef class PerpetualMarketMakingStrategy(StrategyBase): cdef c_apply_initial_settings(self, str trading_pair, object position, int64_t leverage): cdef: ExchangeBase market = self._market_info.market - market.set_margin(trading_pair, leverage) + market.set_leverage(trading_pair, leverage) market.set_position_mode(position) cdef c_tick(self, double timestamp): From 1f2dd8c49eb413394d53459efb08f097802368a9 Mon Sep 17 00:00:00 2001 From: Kirill Stroukov Date: Mon, 8 Feb 2021 13:44:18 +0100 Subject: [PATCH 007/131] new exchange Beaxy --- .../connector/exchange/beaxy/__init__.py | 0 .../beaxy/beaxy_active_order_tracker.pxd | 9 + .../beaxy/beaxy_active_order_tracker.pyx | 203 +++ .../beaxy/beaxy_api_order_book_data_source.py | 286 ++++ .../beaxy_api_user_stream_data_source.py | 106 ++ .../connector/exchange/beaxy/beaxy_auth.py | 106 ++ .../exchange/beaxy/beaxy_constants.py | 22 + .../exchange/beaxy/beaxy_exchange.pxd | 37 + .../exchange/beaxy/beaxy_exchange.pyx | 1094 ++++++++++++++ .../exchange/beaxy/beaxy_in_flight_order.pxd | 4 + .../exchange/beaxy/beaxy_in_flight_order.pyx | 77 + .../connector/exchange/beaxy/beaxy_misc.py | 59 + .../exchange/beaxy/beaxy_order_book.pxd | 6 + .../exchange/beaxy/beaxy_order_book.pyx | 81 + .../beaxy/beaxy_order_book_message.py | 75 + .../beaxy/beaxy_order_book_tracker.py | 190 +++ .../beaxy/beaxy_order_book_tracker_entry.py | 28 + .../exchange/beaxy/beaxy_stomp_message.py | 41 + .../beaxy/beaxy_user_stream_tracker.py | 52 + .../connector/exchange/beaxy/beaxy_utils.py | 26 + .../templates/conf_fee_overrides_TEMPLATE.yml | 5 +- hummingbot/templates/conf_global_TEMPLATE.yml | 5 +- setup.py | 1 + .../assets/mock_data/fixture_beaxy.py | 1346 +++++++++++++++++ .../test_beaxy_active_order_tracker.py | 172 +++ .../test_beaxy_api_order_book_data_source.py | 72 + test/integration/test_beaxy_auth.py | 18 + test/integration/test_beaxy_market.py | 515 +++++++ .../test_beaxy_order_book_tracker.py | 153 ++ .../test_beaxy_user_stream_tracker.py | 41 + 30 files changed, 4828 insertions(+), 2 deletions(-) create mode 100644 hummingbot/connector/exchange/beaxy/__init__.py create mode 100644 hummingbot/connector/exchange/beaxy/beaxy_active_order_tracker.pxd create mode 100644 hummingbot/connector/exchange/beaxy/beaxy_active_order_tracker.pyx create mode 100644 hummingbot/connector/exchange/beaxy/beaxy_api_order_book_data_source.py create mode 100644 hummingbot/connector/exchange/beaxy/beaxy_api_user_stream_data_source.py create mode 100644 hummingbot/connector/exchange/beaxy/beaxy_auth.py create mode 100644 hummingbot/connector/exchange/beaxy/beaxy_constants.py create mode 100644 hummingbot/connector/exchange/beaxy/beaxy_exchange.pxd create mode 100644 hummingbot/connector/exchange/beaxy/beaxy_exchange.pyx create mode 100644 hummingbot/connector/exchange/beaxy/beaxy_in_flight_order.pxd create mode 100644 hummingbot/connector/exchange/beaxy/beaxy_in_flight_order.pyx create mode 100644 hummingbot/connector/exchange/beaxy/beaxy_misc.py create mode 100644 hummingbot/connector/exchange/beaxy/beaxy_order_book.pxd create mode 100644 hummingbot/connector/exchange/beaxy/beaxy_order_book.pyx create mode 100644 hummingbot/connector/exchange/beaxy/beaxy_order_book_message.py create mode 100644 hummingbot/connector/exchange/beaxy/beaxy_order_book_tracker.py create mode 100644 hummingbot/connector/exchange/beaxy/beaxy_order_book_tracker_entry.py create mode 100644 hummingbot/connector/exchange/beaxy/beaxy_stomp_message.py create mode 100644 hummingbot/connector/exchange/beaxy/beaxy_user_stream_tracker.py create mode 100644 hummingbot/connector/exchange/beaxy/beaxy_utils.py create mode 100644 test/integration/assets/mock_data/fixture_beaxy.py create mode 100644 test/integration/test_beaxy_active_order_tracker.py create mode 100644 test/integration/test_beaxy_api_order_book_data_source.py create mode 100644 test/integration/test_beaxy_auth.py create mode 100644 test/integration/test_beaxy_market.py create mode 100644 test/integration/test_beaxy_order_book_tracker.py create mode 100644 test/integration/test_beaxy_user_stream_tracker.py diff --git a/hummingbot/connector/exchange/beaxy/__init__.py b/hummingbot/connector/exchange/beaxy/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/hummingbot/connector/exchange/beaxy/beaxy_active_order_tracker.pxd b/hummingbot/connector/exchange/beaxy/beaxy_active_order_tracker.pxd new file mode 100644 index 0000000000..d933b5f43d --- /dev/null +++ b/hummingbot/connector/exchange/beaxy/beaxy_active_order_tracker.pxd @@ -0,0 +1,9 @@ +# distutils: language=c++ +cimport numpy as np + +cdef class BeaxyActiveOrderTracker: + cdef dict _active_bids + cdef dict _active_asks + + cdef tuple c_convert_diff_message_to_np_arrays(self, object message) + cdef tuple c_convert_snapshot_message_to_np_arrays(self, object message) diff --git a/hummingbot/connector/exchange/beaxy/beaxy_active_order_tracker.pyx b/hummingbot/connector/exchange/beaxy/beaxy_active_order_tracker.pyx new file mode 100644 index 0000000000..f0914ce841 --- /dev/null +++ b/hummingbot/connector/exchange/beaxy/beaxy_active_order_tracker.pyx @@ -0,0 +1,203 @@ +# -*- coding: utf-8 -*- + +# distutils: language=c++ +# distutils: sources=hummingbot/core/cpp/OrderBookEntry.cpp + +import logging +import numpy as np +from decimal import Decimal +from typing import Dict + +from hummingbot.logger import HummingbotLogger +from hummingbot.core.data_type.order_book_row import OrderBookRow + +_bxaot_logger = None +s_empty_diff = np.ndarray(shape=(0, 4), dtype='float64') + +BeaxyOrderBookTrackingDictionary = Dict[Decimal, Decimal] + +ACTION_UPDATE = 'update' +ACTION_INSERT = 'insert' +ACTION_DELETE = 'delete' +ACTION_DELETE_THROUGH = 'delete_through' +ACTION_DELETE_FROM = 'delete_from' +SIDE_BID = 'BID' +SIDE_ASK = 'ASK' + +cdef class BeaxyActiveOrderTracker: + def __init__(self, + active_asks: BeaxyOrderBookTrackingDictionary = None, + active_bids: BeaxyOrderBookTrackingDictionary = None): + super().__init__() + self._active_asks = active_asks or {} + self._active_bids = active_bids or {} + + @classmethod + def logger(cls) -> HummingbotLogger: + global _bxaot_logger + if _bxaot_logger is None: + _bxaot_logger = logging.getLogger(__name__) + return _bxaot_logger + + @property + def active_asks(self) -> BeaxyOrderBookTrackingDictionary: + """ + Get all asks on the order book in dictionary format + :returns: Dict[price, Dict[order_id, order_book_message]] + """ + return self._active_asks + + @property + def active_bids(self) -> BeaxyOrderBookTrackingDictionary: + """ + Get all bids on the order book in dictionary format + :returns: Dict[price, Dict[order_id, order_book_message]] + """ + return self._active_bids + + def volume_for_ask_price(self, price) -> float: + return sum([float(msg['remaining_size']) for msg in self._active_asks[price].values()]) + + def volume_for_bid_price(self, price) -> float: + return sum([float(msg['remaining_size']) for msg in self._active_bids[price].values()]) + + def get_rates_and_quantities(self, entry) -> tuple: + return float(entry['rate']), float(entry['quantity']) + + cdef tuple c_convert_diff_message_to_np_arrays(self, object message): + """ + Interpret an incoming diff message and apply changes to the order book accordingly + :returns: new order book rows: Tuple(np.array (bids), np.array (asks)) + """ + + cdef: + dict content = message.content + str msg_action = content['action'].lower() + str order_side = content['side'] + str price_raw = str(content['price']) + double timestamp = message.timestamp + str quantity_raw = str(content['quantity']) + object price + object quantity + + if order_side not in [SIDE_BID, SIDE_ASK]: + raise ValueError(f'Unknown order side for message - "{message}". Aborting.') + + price = Decimal(price_raw) + quantity = Decimal(quantity_raw) + + if msg_action == ACTION_UPDATE: + if order_side == SIDE_BID: + self._active_bids[price] = quantity + return np.array([[timestamp, float(price), quantity, message.update_id]], dtype='float64'), s_empty_diff + else: + self._active_asks[price] = quantity + return s_empty_diff, np.array([[timestamp, float(price), quantity, message.update_id]], dtype='float64') + + elif msg_action == ACTION_INSERT: + if price in self._active_bids or price in self._active_asks: + raise ValueError(f'Got INSERT action in message - "{message}" but there already was an item with same price. Aborting.') + + if order_side == SIDE_BID: + self._active_bids[price] = quantity + return np.array([[timestamp, float(price), quantity, message.update_id]], dtype='float64'), s_empty_diff + else: + self._active_asks[price] = quantity + return s_empty_diff, np.array([[timestamp, float(price), quantity, message.update_id]], dtype='float64') + elif msg_action == ACTION_DELETE: + # in case of DELETE action we need to substract the provided quantity from existing one + if price not in self._active_bids and price not in self._active_asks: + raise ValueError(f'Got DELETE action in message - "{message}" but there was not entry with that price. Aborting.') + + if order_side == SIDE_BID: + new_quantity = self._active_bids[price] - quantity + self._active_bids[price] = new_quantity + return np.array([[timestamp, float(price), new_quantity, message.update_id]], dtype='float64'), s_empty_diff + else: + new_quantity = self._active_asks[price] - quantity + self._active_asks[price] = new_quantity + return s_empty_diff, np.array([[timestamp, float(price), new_quantity, message.update_id]], dtype='float64') + elif msg_action == ACTION_DELETE_THROUGH: + # Remove all levels from the specified and below (all the worst prices). + if order_side == SIDE_BID: + self._active_bids = {key: value for (key, value) in self._active_bids.items() if key < price} + return s_empty_diff, s_empty_diff + else: + self._active_asks = {key: value for (key, value) in self._active_asks.items() if key < price} + return s_empty_diff, s_empty_diff + elif msg_action == ACTION_DELETE_FROM: + # Remove all levels from the specified and above (all the better prices). + if order_side == SIDE_BID: + self._active_bids = {key: value for (key, value) in self._active_bids.items() if key > price} + return s_empty_diff, s_empty_diff + else: + self._active_asks = {key: value for (key, value) in self._active_asks.items() if key > price} + return s_empty_diff, s_empty_diff + else: + raise ValueError(f'Unknown message action "{msg_action}" - {message}. Aborting.') + + cdef tuple c_convert_snapshot_message_to_np_arrays(self, object message): + """ + Interpret an incoming snapshot message and apply changes to the order book accordingly + :returns: new order book rows: Tuple(np.array (bids), np.array (asks)) + """ + + # Refresh all order tracking. + self._active_bids.clear() + self._active_asks.clear() + + for entry in message.content['entries']: + quantity = Decimal(str(entry['quantity'])) + price = Decimal(str(entry['price'])) + side = entry['side'] + + if side == SIDE_ASK: + self.active_asks[price] = quantity + elif side == SIDE_BID: + self.active_bids[price] = quantity + else: + raise ValueError(f'Unknown order side for message - "{message}". Aborting.') + + # Return the sorted snapshot tables. + cdef: + np.ndarray[np.float64_t, ndim=2] bids = np.array( + [[message.timestamp, + float(price), + float(quantity), + message.update_id] + for price in sorted(self._active_bids.keys(), reverse=True)], dtype='float64', ndmin=2) + np.ndarray[np.float64_t, ndim=2] asks = np.array( + [[message.timestamp, + float(price), + float(quantity), + message.update_id] + for price in sorted(self._active_asks.keys(), reverse=True)], dtype='float64', ndmin=2) + + # If there're no rows, the shape would become (1, 0) and not (0, 4). + # Reshape to fix that. + if bids.shape[1] != 4: + bids = bids.reshape((0, 4)) + if asks.shape[1] != 4: + asks = asks.reshape((0, 4)) + + return bids, asks + + def convert_diff_message_to_order_book_row(self, message): + """ + Convert an incoming diff message to Tuple of np.arrays, and then convert to OrderBookRow + :returns: Tuple(List[bids_row], List[asks_row]) + """ + np_bids, np_asks = self.c_convert_diff_message_to_np_arrays(message) + bids_row = [OrderBookRow(price, qty, update_id) for ts, price, qty, update_id in np_bids] + asks_row = [OrderBookRow(price, qty, update_id) for ts, price, qty, update_id in np_asks] + return bids_row, asks_row + + def convert_snapshot_message_to_order_book_row(self, message): + """ + Convert an incoming snapshot message to Tuple of np.arrays, and then convert to OrderBookRow + :returns: Tuple(List[bids_row], List[asks_row]) + """ + np_bids, np_asks = self.c_convert_snapshot_message_to_np_arrays(message) + bids_row = [OrderBookRow(price, qty, update_id) for ts, price, qty, update_id in np_bids] + asks_row = [OrderBookRow(price, qty, update_id) for ts, price, qty, update_id in np_asks] + return bids_row, asks_row diff --git a/hummingbot/connector/exchange/beaxy/beaxy_api_order_book_data_source.py b/hummingbot/connector/exchange/beaxy/beaxy_api_order_book_data_source.py new file mode 100644 index 0000000000..f17fd94e8a --- /dev/null +++ b/hummingbot/connector/exchange/beaxy/beaxy_api_order_book_data_source.py @@ -0,0 +1,286 @@ +# -*- coding: utf-8 -*- + +import logging +import aiohttp +import asyncio +import ujson +import cachetools.func +from typing import Any, AsyncIterable, Optional, List, Dict +from decimal import Decimal + +import pandas as pd +import websockets +import requests + +from websockets.exceptions import ConnectionClosed +from hummingbot.logger import HummingbotLogger +from hummingbot.core.utils import async_ttl_cache +from hummingbot.core.data_type.order_book_tracker_entry import OrderBookTrackerEntry +from hummingbot.core.data_type.order_book_tracker_data_source import OrderBookTrackerDataSource +from hummingbot.core.data_type.order_book_message import OrderBookMessage +from hummingbot.core.data_type.order_book import OrderBook + +from hummingbot.connector.exchange.beaxy.beaxy_constants import BeaxyConstants +from hummingbot.connector.exchange.beaxy.beaxy_active_order_tracker import BeaxyActiveOrderTracker +from hummingbot.connector.exchange.beaxy.beaxy_order_book import BeaxyOrderBook +from hummingbot.connector.exchange.beaxy.beaxy_misc import split_market_pairs, trading_pair_to_symbol +from hummingbot.connector.exchange.beaxy.beaxy_order_book_tracker_entry import BeaxyOrderBookTrackerEntry + + +ORDERBOOK_MESSAGE_SNAPSHOT = 'SNAPSHOT_FULL_REFRESH' +ORDERBOOK_MESSAGE_DIFF = 'INCREMENTAL_UPDATE' + + +class BeaxyAPIOrderBookDataSource(OrderBookTrackerDataSource): + + MESSAGE_TIMEOUT = 30.0 + PING_TIMEOUT = 10.0 + _bxyaobds_logger: Optional[HummingbotLogger] = None + + @classmethod + def logger(cls) -> HummingbotLogger: + if cls._bxyaobds_logger is None: + cls._bxyaobds_logger = logging.getLogger(__name__) + return cls._bxyaobds_logger + + def __init__(self, trading_pairs: List[str]): + super().__init__(trading_pairs) + + @classmethod + @async_ttl_cache(ttl=60 * 30, maxsize=1) + async def get_active_exchange_markets(cls) -> pd.DataFrame: + async with aiohttp.ClientSession() as client: + + symbols_response: aiohttp.ClientResponse = await client.get(BeaxyConstants.PublicApi.SYMBOLS_URL) + rates_response: aiohttp.ClientResponse = await client.get(BeaxyConstants.PublicApi.RATES_URL) + + if symbols_response.status != 200: + raise IOError(f'Error fetching Beaxy markets information. ' + f'HTTP status is {symbols_response.status}.') + if rates_response.status != 200: + raise IOError(f'Error fetching Beaxy exchange information. ' + f'HTTP status is {symbols_response.status}.') + + symbols_data = await symbols_response.json() + rates_data = await rates_response.json() + + market_data: List[Dict[str, Any]] = [{'pair': pair, **rates_data[pair], **item} + for pair in rates_data + for item in symbols_data + if item['suspendedForTrading'] is False + if pair in item['symbol']] + + all_markets: pd.DataFrame = pd.DataFrame.from_records(data=market_data, index='pair') + + btc_price: float = float(all_markets.loc['BTCUSDC'].price) + eth_price: float = float(all_markets.loc['ETHUSDC'].price) + + usd_volume: List[float] = [ + ( + volume * quote_price if trading_pair.endswith(('USDC')) else + volume * quote_price * btc_price if trading_pair.endswith('BTC') else + volume * quote_price * eth_price if trading_pair.endswith('ETH') else + volume + ) + for trading_pair, volume, quote_price in zip( + all_markets.index, + all_markets.volume24.astype('float'), + all_markets.price.astype('float') + ) + ] + + all_markets.loc[:, 'USDVolume'] = usd_volume + del all_markets['volume'] + all_markets.rename(columns={'baseCurrency': 'baseAsset', + 'termCurrency': 'quoteAsset', + 'volume24': 'volume'}, inplace=True) + + return all_markets.sort_values('USDVolume', ascending=False) + + @staticmethod + async def fetch_trading_pairs() -> Optional[List[str]]: + try: + async with aiohttp.ClientSession() as client: + async with client.get(BeaxyConstants.PublicApi.SYMBOLS_URL, timeout=5) as response: + if response.status == 200: + all_trading_pairs: List[Dict[str, Any]] = await response.json() + return ['{}-{}'.format(*p) for p in + split_market_pairs([i['symbol'] for i in all_trading_pairs])] + except Exception: # nopep8 + # Do nothing if the request fails -- there will be no autocomplete for beaxy trading pairs + pass + return [] + + @staticmethod + @cachetools.func.ttl_cache(ttl=10) + def get_mid_price(trading_pair: str) -> Optional[Decimal]: + + symbol = trading_pair_to_symbol(trading_pair) + resp = requests.get(url=BeaxyConstants.PublicApi.RATE_URL.format(symbol=symbol)) + record = resp.json() + + return (Decimal(record['bid']) + Decimal(record['ask'])) / Decimal('2') + + @classmethod + async def get_last_traded_prices(cls, trading_pairs: List[str]) -> Dict[str, float]: + + async def last_price_for_pair(trading_pair): + symbol = trading_pair_to_symbol(trading_pair) + async with aiohttp.ClientSession() as client: + async with client.get(BeaxyConstants.PublicApi.RATE_URL.format(symbol=symbol)) as response: + response: aiohttp.ClientResponse + if response.status != 200: + raise IOError(f'Error fetching Beaxy market trade for {trading_pair}. ' + f'HTTP status is {response.status}.') + data: Dict[str, Any] = await response.json() + return trading_pair, float(data['price']) + + fetches = [last_price_for_pair(p) for p in trading_pairs] + + prices = await asyncio.gather(*fetches) + + return {pair: price for pair, price in prices} + + @staticmethod + async def get_snapshot(client: aiohttp.ClientSession, trading_pair: str, depth: int = 20) -> Dict[str, Any]: + assert depth in [5, 10, 20] + + # at Beaxy all pairs listed without splitter + symbol = trading_pair_to_symbol(trading_pair) + + async with client.get(BeaxyConstants.PublicApi.ORDER_BOOK_URL.format(symbol=symbol, depth=depth)) as response: + response: aiohttp.ClientResponse + if response.status != 200: + raise IOError(f'Error fetching Beaxy market snapshot for {trading_pair}. ' + f'HTTP status is {response.status}.') + data: Dict[str, Any] = await response.json() + return data + + async def get_new_order_book(self, trading_pair: str) -> OrderBook: + async with aiohttp.ClientSession() as client: + snapshot: Dict[str, Any] = await self.get_snapshot(client, trading_pair, 20) + snapshot_timestamp = snapshot['timestamp'] + snapshot_msg: OrderBookMessage = BeaxyOrderBook.snapshot_message_from_exchange( + snapshot, + snapshot_timestamp, + metadata={'trading_pair': trading_pair} + ) + order_book: OrderBook = self.order_book_create_function() + active_order_tracker: BeaxyActiveOrderTracker = BeaxyActiveOrderTracker() + bids, asks = active_order_tracker.convert_snapshot_message_to_order_book_row(snapshot_msg) + order_book.apply_snapshot(bids, asks, snapshot_msg.update_id) + return order_book + + async def get_tracking_pairs(self) -> Dict[str, OrderBookTrackerEntry]: + async with aiohttp.ClientSession() as client: + trading_pairs: Optional[List[str]] = await self.get_trading_pairs() + assert trading_pairs is not None + retval: Dict[str, OrderBookTrackerEntry] = {} + number_of_pairs: int = len(trading_pairs) + for index, trading_pair in enumerate(trading_pairs): + try: + snapshot: Dict[str, Any] = await self.get_snapshot(client, trading_pair, 20) + snapshot_timestamp = snapshot['timestamp'] + snapshot_msg: OrderBookMessage = BeaxyOrderBook.snapshot_message_from_exchange( + snapshot, + snapshot_timestamp, + metadata={'trading_pair': trading_pair} + ) + order_book: OrderBook = self.order_book_create_function() + active_order_tracker: BeaxyActiveOrderTracker = BeaxyActiveOrderTracker() + bids, asks = active_order_tracker.convert_snapshot_message_to_order_book_row(snapshot_msg) + order_book.apply_snapshot(bids, asks, snapshot_msg.update_id) + retval[trading_pair] = BeaxyOrderBookTrackerEntry( + trading_pair, + snapshot_timestamp, + order_book, + active_order_tracker + ) + + self.logger().info(f'Initialized order book for {trading_pair}. ' + f'{index+1}/{number_of_pairs} completed.') + except Exception: + self.logger().error(f'Error getting snapshot for {trading_pair}. ', exc_info=True) + await asyncio.sleep(5.0) + return retval + + async def _inner_messages( + self, + ws: websockets.WebSocketClientProtocol + ) -> AsyncIterable[str]: + # Terminate the recv() loop as soon as the next message timed out, so the outer loop can reconnect. + try: + while True: + try: + msg: str = await asyncio.wait_for(ws.recv(), timeout=self.MESSAGE_TIMEOUT) + yield msg + except asyncio.TimeoutError: + try: + pong_waiter = await ws.ping() + await asyncio.wait_for(pong_waiter, timeout=self.PING_TIMEOUT) + except asyncio.TimeoutError: + raise + except asyncio.TimeoutError: + self.logger().warning('WebSocket ping timed out. Going to reconnect...') + return + except ConnectionClosed: + return + finally: + await ws.close() + + async def listen_for_order_book_diffs(self, ev_loop: asyncio.BaseEventLoop, output: asyncio.Queue): + # Due of Beaxy api structure it is impossible to process diffs + pass + + async def listen_for_order_book_snapshots(self, ev_loop: asyncio.BaseEventLoop, output: asyncio.Queue): + while True: + try: + trading_pairs: Optional[List[str]] = await self.get_trading_pairs() + assert trading_pairs is not None + + # at Beaxy all pairs listed without splitter + trading_pairs = [trading_pair_to_symbol(p) for p in trading_pairs] + + ws_path: str = '/'.join([f'{trading_pair}@depth20' for trading_pair in trading_pairs]) + stream_url: str = f'{BeaxyConstants.PublicApi.WS_BASE_URL}/book/{ws_path}' + + async with websockets.connect(stream_url) as ws: + ws: websockets.WebSocketClientProtocol = ws + async for raw_msg in self._inner_messages(ws): + msg = ujson.loads(raw_msg) + msg_type = msg['type'] + if msg_type.lower() == ORDERBOOK_MESSAGE_SNAPSHOT.lower(): + order_book_message: OrderBookMessage = BeaxyOrderBook.snapshot_message_from_exchange( + msg, msg['timestamp']) + output.put_nowait(order_book_message) + except asyncio.CancelledError: + raise + except Exception: + self.logger().error('Unexpected error with WebSocket connection. Retrying after 30 seconds...', + exc_info=True) + await asyncio.sleep(30.0) + + async def listen_for_trades(self, ev_loop: asyncio.BaseEventLoop, output: asyncio.Queue): + while True: + try: + trading_pairs: Optional[List[str]] = await self.get_trading_pairs() + assert trading_pairs is not None + + # at Beaxy all pairs listed without splitter + trading_pairs = [trading_pair_to_symbol(p) for p in trading_pairs] + + ws_path: str = '/'.join([trading_pair for trading_pair in trading_pairs]) + stream_url: str = f'{BeaxyConstants.PublicApi.WS_BASE_URL}/trades/{ws_path}' + + async with websockets.connect(stream_url) as ws: + ws: websockets.WebSocketClientProtocol = ws + async for raw_msg in self._inner_messages(ws): + msg = ujson.loads(raw_msg) + trade_msg: OrderBookMessage = BeaxyOrderBook.trade_message_from_exchange(msg) + output.put_nowait(trade_msg) + except asyncio.CancelledError: + raise + except Exception: + self.logger().error('Unexpected error with WebSocket connection. Retrying after 30 seconds...', + exc_info=True) + await asyncio.sleep(30.0) diff --git a/hummingbot/connector/exchange/beaxy/beaxy_api_user_stream_data_source.py b/hummingbot/connector/exchange/beaxy/beaxy_api_user_stream_data_source.py new file mode 100644 index 0000000000..053795f5ac --- /dev/null +++ b/hummingbot/connector/exchange/beaxy/beaxy_api_user_stream_data_source.py @@ -0,0 +1,106 @@ +# -*- coding: utf-8 -*- + +import logging +import asyncio +import time +import ujson +import websockets + +from typing import AsyncIterable, Optional, List +from websockets.exceptions import ConnectionClosed + +from hummingbot.core.data_type.user_stream_tracker_data_source import UserStreamTrackerDataSource +from hummingbot.core.utils.tracking_nonce import get_tracking_nonce + +from hummingbot.connector.exchange.beaxy.beaxy_auth import BeaxyAuth +from hummingbot.connector.exchange.beaxy.beaxy_constants import BeaxyConstants +from hummingbot.connector.exchange.beaxy.beaxy_stomp_message import BeaxyStompMessage + + +class BeaxyAPIUserStreamDataSource(UserStreamTrackerDataSource): + MESSAGE_TIMEOUT = 30.0 + PING_TIMEOUT = 10.0 + + _bxyausds_logger: Optional[logging.Logger] = None + + @classmethod + def logger(cls) -> logging.Logger: + if cls._bxyausds_logger is None: + cls._bxyausds_logger = logging.getLogger(__name__) + return cls._bxyausds_logger + + def __init__(self, beaxy_auth: BeaxyAuth, trading_pairs: Optional[List[str]] = []): + self._beaxy_auth: BeaxyAuth = beaxy_auth + self._trading_pairs = trading_pairs + self._current_listen_key = None + self._listen_for_user_stream_task = None + self._last_recv_time: float = 0 + super().__init__() + + @property + def last_recv_time(self) -> float: + return self._last_recv_time + + async def listen_for_user_stream(self, ev_loop: asyncio.BaseEventLoop, output: asyncio.Queue): + """ + *required + Subscribe to user stream via web socket, and keep the connection open for incoming messages + :param ev_loop: ev_loop to execute this function in + :param output: an async queue where the incoming messages are stored + """ + while True: + try: + async with websockets.connect(BeaxyConstants.TradingApi.WS_BASE_URL) as ws: + ws: websockets.WebSocketClientProtocol = ws + connect_request = BeaxyStompMessage('CONNECT') + connect_request.headers = await self._beaxy_auth.generate_ws_auth_dict() + await ws.send(connect_request.serialize()) + + orders_sub_request = BeaxyStompMessage('SUBSCRIBE') + orders_sub_request.headers['id'] = f'sub-humming-{get_tracking_nonce()}' + orders_sub_request.headers['destination'] = '/user/v1/orders' + orders_sub_request.headers['X-Deltix-Nonce'] = str(get_tracking_nonce()) + await ws.send(orders_sub_request.serialize()) + + async for raw_msg in self._inner_messages(ws): + stomp_message = BeaxyStompMessage.deserialize(raw_msg) + if stomp_message.has_error(): + raise Exception(f'Got error from ws. Headers - {stomp_message.headers}') + + msg = ujson.loads(stomp_message.body) + output.put_nowait(msg) + except asyncio.CancelledError: + raise + except Exception: + self.logger().error('Unexpected error with Beaxy connection. ' + 'Retrying after 30 seconds...', exc_info=True) + await asyncio.sleep(30.0) + + async def _inner_messages(self, + ws: websockets.WebSocketClientProtocol) -> AsyncIterable[str]: + """ + Generator function that returns messages from the web socket stream + :param ws: current web socket connection + :returns: message in AsyncIterable format + """ + # Terminate the recv() loop as soon as the next message timed out, so the outer loop can reconnect. + try: + while True: + try: + msg: str = await asyncio.wait_for(ws.recv(), timeout=self.MESSAGE_TIMEOUT) + self._last_recv_time = time.time() + yield msg + except asyncio.TimeoutError: + try: + pong_waiter = await ws.ping() + self._last_recv_time = time.time() + await asyncio.wait_for(pong_waiter, timeout=self.PING_TIMEOUT) + except asyncio.TimeoutError: + raise + except asyncio.TimeoutError: + self.logger().warning('WebSocket ping timed out. Going to reconnect...') + return + except ConnectionClosed: + return + finally: + await ws.close() diff --git a/hummingbot/connector/exchange/beaxy/beaxy_auth.py b/hummingbot/connector/exchange/beaxy/beaxy_auth.py new file mode 100644 index 0000000000..4abb723209 --- /dev/null +++ b/hummingbot/connector/exchange/beaxy/beaxy_auth.py @@ -0,0 +1,106 @@ +# -*- coding: utf-8 -*- + +import logging +import base64 +import random +from typing import Dict, Any + +import aiohttp +from Crypto.PublicKey import RSA +from Crypto.Signature import PKCS1_v1_5 +from Crypto.Hash import HMAC, SHA384, SHA256 + +from hummingbot.core.utils.tracking_nonce import get_tracking_nonce +from hummingbot.logger import HummingbotLogger + +from hummingbot.connector.exchange.beaxy.beaxy_constants import BeaxyConstants + +s_logger = None + + +class BeaxyAuth: + + def __init__(self, api_key: str, api_secret: str): + self.api_key = api_key + self.api_secret = api_secret + self._session_data_cache: Dict[str, Any] = {} + + @classmethod + def logger(cls) -> HummingbotLogger: + global s_logger + if s_logger is None: + s_logger = logging.getLogger(__name__) + return s_logger + + async def generate_auth_dict(self, http_method: str, path: str, body: str = "") -> Dict[str, Any]: + session_data = await self.__get_session_data() + headers = {'X-Deltix-Nonce': str(get_tracking_nonce()), 'X-Deltix-Session-Id': session_data['session_id']} + payload = self.__build_payload(http_method, path, {}, headers, body) + hmac = HMAC.new(key= self.__int_to_bytes(session_data['sign_key'], signed=True), msg=bytes(payload, 'utf-8'), digestmod=SHA384) + digestb64 = base64.b64encode(hmac.digest()) + headers['X-Deltix-Signature'] = digestb64.decode('utf-8') + return headers + + async def generate_ws_auth_dict(self) -> Dict[str, Any]: + session_data = await self.__get_session_data() + headers = {'X-Deltix-Nonce': str(get_tracking_nonce()), 'X-Deltix-Session-Id': session_data['session_id']} + payload = self.__build_ws_payload(headers) + hmac = HMAC.new(key= self.__int_to_bytes(session_data['sign_key'], signed=True), msg=bytes(payload, 'utf-8'), digestmod=SHA384) + digestb64 = base64.b64encode(hmac.digest()) + headers['X-Deltix-Signature'] = digestb64.decode('utf-8') + return headers + + async def __get_session_data(self) -> Dict[str, Any]: + if not self._session_data_cache: + dh_number = random.getrandbits(64 * 8) + login_attempt = await self.__login_attempt() + sign_key = await self.__login_confirm(login_attempt, dh_number) + retval = {'sign_key': sign_key, 'session_id': login_attempt['session_id']} + self._session_data_cache = retval + + return self._session_data_cache + + async def __login_confirm(self, login_attempt: Dict[str, str], dh_number: int) -> int: + dh_modulus = int.from_bytes(base64.b64decode(login_attempt['dh_modulus']), 'big', signed= False) + dh_base = int.from_bytes(base64.b64decode(login_attempt['dh_base']), 'big', signed= False) + msg = base64.b64decode(login_attempt['challenge']) + digest = SHA256.new(msg) + pem = f'-----BEGIN PRIVATE KEY-----\n{self.api_secret}\n-----END PRIVATE KEY-----' + privateKey = RSA.importKey(pem) + encryptor = PKCS1_v1_5.new(privateKey) + encrypted_msg = base64.b64encode(encryptor.sign(digest)).decode('utf-8') + dh_key_raw = pow(dh_base, dh_number, dh_modulus) + dh_key_bytes = self.__int_to_bytes(dh_key_raw, signed=True) + dh_key = base64.b64encode(dh_key_bytes).decode('utf-8') + + async with aiohttp.ClientSession() as client: + async with client.post( + f'{BeaxyConstants.TradingApi.BASE_URL}{BeaxyConstants.TradingApi.LOGIN_CONFIRM_ENDPOINT}', json = {'session_id': login_attempt['session_id'], 'signature': encrypted_msg, 'dh_key': dh_key}) as response: + response: aiohttp.ClientResponse = response + if response.status != 200: + raise IOError(f'Error while connecting to login confirm endpoint. HTTP status is {response.status}.') + data: Dict[str, str] = await response.json() + dh_key_result = int.from_bytes(base64.b64decode(data['dh_key']), 'big', signed= False) + return pow(dh_key_result, dh_number, dh_modulus) + + def __int_to_bytes(self, i: int, *, signed: bool = False) -> bytes: + length = ((i + ((i * signed) < 0)).bit_length() + 7 + signed) // 8 + return i.to_bytes(length, byteorder='big', signed=signed) + + async def __login_attempt(self) -> Dict[str, str]: + async with aiohttp.ClientSession() as client: + async with client.post(f'{BeaxyConstants.TradingApi.BASE_URL}{BeaxyConstants.TradingApi.LOGIN_ATTEMT_ENDPOINT}', json = {'api_key_id': self.api_key}) as response: + response: aiohttp.ClientResponse = response + if response.status != 200: + raise IOError(f'Error while connecting to login attempt endpoint. HTTP status is {response.status}.') + data: Dict[str, str] = await response.json() + return data + + def __build_payload(self, http_method: str, path: str, query_params: Dict[str, str], headers: Dict[str, str], body: str = ""): + query_params_stringified = '&'.join([f'{k}={query_params[k]}' for k in sorted(query_params)]) + headers_stringified = '&'.join([f'{k}={headers[k]}' for k in sorted(headers)]) + return f'{http_method.upper()}{path.lower()}{query_params_stringified}{headers_stringified}{body}' + + def __build_ws_payload(self, headers: Dict[str, str]) -> str: + headers_stringified = '&'.join([f'{k}={headers[k]}' for k in sorted(headers)]) + return f'CONNECT/websocket/v1{headers_stringified}' diff --git a/hummingbot/connector/exchange/beaxy/beaxy_constants.py b/hummingbot/connector/exchange/beaxy/beaxy_constants.py new file mode 100644 index 0000000000..5a3d07c88d --- /dev/null +++ b/hummingbot/connector/exchange/beaxy/beaxy_constants.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- + + +class BeaxyConstants: + class TradingApi: + BASE_URL = 'https://tradingapi.beaxy.com' + WS_BASE_URL = 'wss://tradingapi.beaxy.com/websocket/v1' + SECURITIES_ENDPOINT = '/api/v1/securities' + LOGIN_ATTEMT_ENDPOINT = '/api/v1/login/attempt' + LOGIN_CONFIRM_ENDPOINT = '/api/v1/login/confirm' + HEALTH_ENDPOINT = '/api/v1/trader/health' + ACOUNTS_ENDPOINT = '/api/v1/accounts' + ORDERS_ENDPOINT = '/api/v1/orders' + KEEP_ALIVE_ENDPOINT = '/api/v1/login/keepalive' + + class PublicApi: + BASE_URL = 'https://services.beaxy.com' + SYMBOLS_URL = BASE_URL + '/api/v2/symbols' + RATE_URL = BASE_URL + '/api/v2/symbols/{symbol}/rate' + RATES_URL = BASE_URL + '/api/v2/symbols/rates' + ORDER_BOOK_URL = BASE_URL + '/api/v2/symbols/{symbol}/book?depth={depth}' + WS_BASE_URL = 'wss://services.beaxy.com/ws/v2' diff --git a/hummingbot/connector/exchange/beaxy/beaxy_exchange.pxd b/hummingbot/connector/exchange/beaxy/beaxy_exchange.pxd new file mode 100644 index 0000000000..8904bf71fc --- /dev/null +++ b/hummingbot/connector/exchange/beaxy/beaxy_exchange.pxd @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- + +from hummingbot.connector.exchange_base cimport ExchangeBase +from hummingbot.core.data_type.transaction_tracker cimport TransactionTracker + + +cdef class BeaxyExchange(ExchangeBase): + cdef: + object _user_stream_tracker + object _beaxy_auth + object _ev_loop + object _poll_notifier + double _last_timestamp + double _last_order_update_timestamp + double _last_fee_percentage_update_timestamp + object _maker_fee_percentage + object _taker_fee_percentage + double _poll_interval + dict _in_flight_orders + TransactionTracker _tx_tracker + dict _trading_rules + object _coro_queue + public object _status_polling_task + public object _coro_scheduler_task + public object _user_stream_tracker_task + public object _user_stream_event_listener_task + public object _trading_rules_polling_task + public object _shared_client + + cdef c_start_tracking_order(self, + str order_id, + str trading_pair, + object trade_type, + object order_type, + object price, + object amount) + cdef c_did_timeout_tx(self, str tracking_id) diff --git a/hummingbot/connector/exchange/beaxy/beaxy_exchange.pyx b/hummingbot/connector/exchange/beaxy/beaxy_exchange.pyx new file mode 100644 index 0000000000..95b7dd498f --- /dev/null +++ b/hummingbot/connector/exchange/beaxy/beaxy_exchange.pyx @@ -0,0 +1,1094 @@ +# -*- coding: utf-8 -*- + +import asyncio +import logging +import json + +from typing import Any, Dict, List, AsyncIterable, Optional, Tuple +from async_timeout import timeout +from decimal import Decimal +from libc.stdint cimport int64_t + +import aiohttp +import pandas as pd + +from aiohttp.client_exceptions import ContentTypeError + +from hummingbot.logger import HummingbotLogger +from hummingbot.core.network_iterator import NetworkStatus +from hummingbot.core.data_type.order_book cimport OrderBook +from hummingbot.core.data_type.cancellation_result import CancellationResult +from hummingbot.core.data_type.limit_order import LimitOrder +from hummingbot.core.clock cimport Clock +from hummingbot.core.utils.tracking_nonce import get_tracking_nonce +from hummingbot.core.utils.estimate_fee import estimate_fee +from hummingbot.core.utils.async_utils import safe_ensure_future, safe_gather +from hummingbot.connector.exchange_base cimport ExchangeBase +from hummingbot.connector.trading_rule cimport TradingRule +from hummingbot.core.event.events import MarketEvent, BuyOrderCompletedEvent, SellOrderCompletedEvent, \ + OrderFilledEvent, OrderCancelledEvent, BuyOrderCreatedEvent, OrderExpiredEvent, SellOrderCreatedEvent, \ + MarketTransactionFailureEvent, MarketOrderFailureEvent, OrderType, TradeType, TradeFee + +from hummingbot.connector.exchange.beaxy.beaxy_api_order_book_data_source import BeaxyAPIOrderBookDataSource +from hummingbot.connector.exchange.beaxy.beaxy_constants import BeaxyConstants +from hummingbot.connector.exchange.beaxy.beaxy_auth import BeaxyAuth +from hummingbot.connector.exchange.beaxy.beaxy_order_book_tracker import BeaxyOrderBookTracker +from hummingbot.connector.exchange.beaxy.beaxy_in_flight_order import BeaxyInFlightOrder +from hummingbot.connector.exchange.beaxy.beaxy_user_stream_tracker import BeaxyUserStreamTracker +from hummingbot.connector.exchange.beaxy.beaxy_misc import split_trading_pair, trading_pair_to_symbol, BeaxyIOError + +s_logger = None +s_decimal_0 = Decimal('0.0') +s_decimal_NaN = Decimal('NaN') + +cdef class BeaxyExchangeTransactionTracker(TransactionTracker): + cdef: + BeaxyExchange _owner + + def __init__(self, owner: BeaxyExchange): + super().__init__() + self._owner = owner + + cdef c_did_timeout_tx(self, str tx_id): + TransactionTracker.c_did_timeout_tx(self, tx_id) + self._owner.c_did_timeout_tx(tx_id) + +cdef class BeaxyExchange(ExchangeBase): + MARKET_BUY_ORDER_COMPLETED_EVENT_TAG = MarketEvent.BuyOrderCompleted.value + MARKET_SELL_ORDER_COMPLETED_EVENT_TAG = MarketEvent.SellOrderCompleted.value + MARKET_ORDER_CANCELLED_EVENT_TAG = MarketEvent.OrderCancelled.value + MARKET_ORDER_FAILURE_EVENT_TAG = MarketEvent.OrderFailure.value + MARKET_ORDER_EXPIRED_EVENT_TAG = MarketEvent.OrderExpired.value + MARKET_ORDER_FILLED_EVENT_TAG = MarketEvent.OrderFilled.value + MARKET_BUY_ORDER_CREATED_EVENT_TAG = MarketEvent.BuyOrderCreated.value + MARKET_SELL_ORDER_CREATED_EVENT_TAG = MarketEvent.SellOrderCreated.value + + API_CALL_TIMEOUT = 60.0 + UPDATE_ORDERS_INTERVAL = 10.0 + UPDATE_FEE_PERCENTAGE_INTERVAL = 60.0 + + @classmethod + def logger(cls) -> HummingbotLogger: + global s_logger + if s_logger is None: + s_logger = logging.getLogger(__name__) + return s_logger + + def __init__( + self, + beaxy_api_key: str, + beaxy_secret_key: str, + poll_interval: float = 5.0, # interval which the class periodically pulls status from the rest API + trading_pairs: Optional[List[str]] = None, + trading_required: bool = True + ): + super().__init__() + self._trading_required = trading_required + self._beaxy_auth = BeaxyAuth(beaxy_api_key, beaxy_secret_key) + self._order_book_tracker = BeaxyOrderBookTracker(trading_pairs=trading_pairs) + self._user_stream_tracker = BeaxyUserStreamTracker(beaxy_auth=self._beaxy_auth) + self._ev_loop = asyncio.get_event_loop() + self._poll_notifier = asyncio.Event() + self._last_timestamp = 0 + self._last_order_update_timestamp = 0 + self._last_fee_percentage_update_timestamp = 0 + self._poll_interval = poll_interval + self._in_flight_orders: Dict[str, BeaxyInFlightOrder] = {} + self._tx_tracker = BeaxyExchangeTransactionTracker(self) + self._trading_rules = {} + self._status_polling_task = None + self._user_stream_tracker_task = None + self._user_stream_event_listener_task = None + self._trading_rules_polling_task = None + self._shared_client = None + self._maker_fee_percentage = 0 + self._taker_fee_percentage = 0 + + @staticmethod + def split_trading_pair(trading_pair: str) -> Optional[Tuple[str, str]]: + return split_trading_pair(trading_pair) + + @staticmethod + def convert_from_exchange_trading_pair(exchange_trading_pair: str) -> Optional[str]: + if BeaxyExchange.split_trading_pair(exchange_trading_pair) is None: + return None + base_asset, quote_asset = BeaxyExchange.split_trading_pair(exchange_trading_pair) + return f'{base_asset}-{quote_asset}' + + @staticmethod + def convert_to_exchange_trading_pair(hb_trading_pair: str) -> str: + return hb_trading_pair + + @property + def name(self) -> str: + """ + *required + :return: A lowercase name / id for the market. Must stay consistent with market name in global settings. + """ + return 'beaxy' + + @property + def order_books(self) -> Dict[str, OrderBook]: + """ + *required + Get mapping of all the order books that are being tracked. + :return: Dict[trading_pair : OrderBook] + """ + return self._order_book_tracker.order_books + + @property + def beaxy_auth(self) -> BeaxyAuth: + """ + :return: BeaxyAuth class + """ + return self._beaxy_auth + + @property + def trading_rules(self) -> Dict[str, Any]: + return self._trading_rules + + @property + def status_dict(self) -> Dict[str, bool]: + """ + *required + :return: a dictionary of relevant status checks. + This is used by `ready` method below to determine if a market is ready for trading. + """ + 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 if self._trading_required else True + } + + @property + def ready(self) -> bool: + """ + *required + :return: a boolean value that indicates if the market is ready for trading + """ + return all(self.status_dict.values()) + + @property + def limit_orders(self) -> List[LimitOrder]: + """ + *required + :return: list of active limit orders + """ + return [ + in_flight_order.to_limit_order() + for in_flight_order in self._in_flight_orders.values() + ] + + @property + def in_flight_orders(self) -> Dict[str, BeaxyInFlightOrder]: + return self._in_flight_orders + + @property + def tracking_states(self) -> Dict[str, any]: + """ + *required + :return: Dict[client_order_id: InFlightOrder] + This is used by the MarketsRecorder class to orchestrate market classes at a higher level. + """ + return { + key: value.to_json() + for key, value in self._in_flight_orders.items() + } + + def restore_tracking_states(self, saved_states: Dict[str, any]): + """ + *required + Updates inflight order statuses from API results + This is used by the MarketsRecorder class to orchestrate market classes at a higher level. + """ + self._in_flight_orders.update({ + key: BeaxyInFlightOrder.from_json(value) + for key, value in saved_states.items() + }) + + async def get_active_exchange_markets(self) -> pd.DataFrame: + """ + *required + Used by the discovery strategy to read order books of all actively trading markets, + and find opportunities to profit + """ + return await BeaxyAPIOrderBookDataSource.get_active_exchange_markets() + + cdef c_start(self, Clock clock, double timestamp): + """ + *required + c_start function used by top level Clock to orchestrate components of the bot + """ + self._tx_tracker.c_start(clock, timestamp) + ExchangeBase.c_start(self, clock, timestamp) + + async def start_network(self): + """ + *required + Async function used by NetworkBase class to handle when a single market goes online + """ + self.logger().debug(f'Starting beaxy network. Trading required is {self._trading_required}') + self._stop_network() + self._order_book_tracker.start() + self.logger().debug(f'OrderBookTracker started, starting polling tasks.') + if self._trading_required: + self._status_polling_task = safe_ensure_future(self._status_polling_loop()) + self._trading_rules_polling_task = safe_ensure_future(self._trading_rules_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 check_network(self) -> NetworkStatus: + try: + res = await self._api_request(http_method='GET', path_url=BeaxyConstants.TradingApi.HEALTH_ENDPOINT, is_auth_required=False) + if not res['is_alive']: + return NetworkStatus.STOPPED + except asyncio.CancelledError: + raise + except Exception: + self.logger().network(f'Error fetching Beaxy network status.', exc_info=True) + return NetworkStatus.NOT_CONNECTED + return NetworkStatus.CONNECTED + + cdef c_tick(self, double timestamp): + """ + *required + Used by top level Clock to orchestrate components of the bot. + This function is called frequently with every clock tick + """ + cdef: + int64_t last_tick = (self._last_timestamp / self._poll_interval) + int64_t current_tick = (timestamp / self._poll_interval) + + ExchangeBase.c_tick(self, timestamp) + if current_tick > last_tick: + if not self._poll_notifier.is_set(): + self._poll_notifier.set() + self._last_timestamp = timestamp + + def _stop_network(self): + """ + Synchronous function that handles when a single market goes offline + """ + 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 list_orders(self) -> List[Any]: + """ + Gets a list of the user's active orders via rest API + :returns: json response + """ + path_url = BeaxyConstants.TradingApi.ORDERS_ENDPOINT + result = await self._api_request('get', path_url=path_url) + return result + + async def _update_order_status(self): + """ + Pulls the rest API for for latest order statuses and update local order statuses. + """ + cdef: + double current_timestamp = self._current_timestamp + + if current_timestamp - self._last_order_update_timestamp <= self.UPDATE_ORDERS_INTERVAL: + return + + tracked_orders = list(self._in_flight_orders.values()) + open_orders = await self.list_orders() + order_dict = {entry['id']: entry for entry in open_orders} + + for tracked_order in tracked_orders: + exchange_order_id = await tracked_order.get_exchange_order_id() + order_update = order_dict.get(exchange_order_id) + client_order_id = tracked_order.client_order_id + if order_update is None: + self.logger().info( + f'The tracked order {client_order_id} does not exist on Beaxy.' + f'Removing from tracking.' + ) + tracked_order.last_state = "CLOSED" + self.c_trigger_event( + self.MARKET_ORDER_CANCELLED_EVENT_TAG, + OrderCancelledEvent(self._current_timestamp, client_order_id) + ) + self.c_stop_tracking_order(client_order_id) + continue + + # Calculate the newly executed amount for this update. + new_confirmed_amount = Decimal(order_update['cumulative_quantity']) + execute_amount_diff = new_confirmed_amount - tracked_order.executed_amount_base + execute_price = Decimal(order_update['average_price']) + + order_type_description = tracked_order.order_type_description + # Emit event if executed amount is greater than 0. + if execute_amount_diff > s_decimal_0: + order_filled_event = OrderFilledEvent( + self._current_timestamp, + tracked_order.client_order_id, + tracked_order.trading_pair, + tracked_order.trade_type, + tracked_order.order_type, + execute_price, + execute_amount_diff, + self.c_get_fee( + tracked_order.base_asset, + tracked_order.quote_asset, + tracked_order.order_type, + tracked_order.trade_type, + execute_price, + execute_amount_diff, + ), + exchange_trade_id=exchange_order_id, + ) + self.logger().info(f'Filled {execute_amount_diff} out of {tracked_order.amount} of the ' + f'{order_type_description} order {client_order_id}.') + self.c_trigger_event(self.MARKET_ORDER_FILLED_EVENT_TAG, order_filled_event) + + # Update the tracked order + tracked_order.last_state = order_update['status'] + tracked_order.executed_amount_base = new_confirmed_amount + tracked_order.executed_amount_quote = new_confirmed_amount * execute_price + if tracked_order.is_done: + if not tracked_order.is_failure: + if tracked_order.trade_type == TradeType.BUY: + self.logger().info(f'The market buy order {tracked_order.client_order_id} has completed ' + f'according to order status API.') + self.c_trigger_event(self.MARKET_BUY_ORDER_COMPLETED_EVENT_TAG, + BuyOrderCompletedEvent(self._current_timestamp, + tracked_order.client_order_id, + tracked_order.base_asset, + tracked_order.quote_asset, + (tracked_order.fee_asset + or tracked_order.base_asset), + tracked_order.executed_amount_base, + tracked_order.executed_amount_quote, + tracked_order.fee_paid, + tracked_order.order_type)) + else: + self.logger().info(f'The market sell order {tracked_order.client_order_id} has completed ' + f'according to order status API.') + self.c_trigger_event(self.MARKET_SELL_ORDER_COMPLETED_EVENT_TAG, + SellOrderCompletedEvent(self._current_timestamp, + tracked_order.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: + self.logger().info(f'The market order {tracked_order.client_order_id} has failed/been cancelled ' + f'according to order status API.') + tracked_order.last_state = "cancelled" + self.c_trigger_event(self.MARKET_ORDER_CANCELLED_EVENT_TAG, + OrderCancelledEvent( + self._current_timestamp, + tracked_order.client_order_id + )) + self.c_stop_tracking_order(tracked_order.client_order_id) + self._last_order_update_timestamp = current_timestamp + + async def place_order(self, order_id: str, trading_pair: str, amount: Decimal, is_buy: bool, order_type: OrderType, + price: Decimal): + """ + Async wrapper for placing orders through the rest API. + :returns: json response from the API + """ + path_url = BeaxyConstants.TradingApi.ORDERS_ENDPOINT + trading_pair = trading_pair_to_symbol(trading_pair) # at Beaxy all pairs listed without splitter + is_limit_type = order_type.is_limit_type() + + data = { + 'text': order_id, + 'security_id': trading_pair, + 'type': 'limit' if is_limit_type else 'market', + 'side': 'buy' if is_buy else 'sell', + 'quantity': f'{amount:f}', + # https://beaxyapiv2trading.docs.apiary.io/#/data-structures/0/time-in-force?mc=reference%2Frest%2Forder%2Fcreate-order%2F200 + 'time_in_force': 'gtc' if is_limit_type else 'ioc', + 'destination': 'MAXI', + } + if is_limit_type: + data['price'] = f'{price:f}' + order_result = await self._api_request('POST', path_url=path_url, data=data) + self.logger().debug(f'Set order result {order_result}') + return order_result + + cdef object c_get_fee( + self, + str base_currency, + str quote_currency, + object order_type, + object order_side, + object amount, + object price + ): + """ + *required + function to calculate fees for a particular order + :returns: TradeFee class that includes fee percentage and flat fees + """ + # There is no API for checking user's fee tier + """ + cdef: + object maker_fee = self._maker_fee_percentage + object taker_fee = self._taker_fee_percentage + if order_type is OrderType.LIMIT and fee_overrides_config_map['beaxy_maker_fee'].value is not None: + return TradeFee(percent=fee_overrides_config_map['beaxy_maker_fee'].value / Decimal('100')) + if order_type is OrderType.MARKET and fee_overrides_config_map['beaxy_taker_fee'].value is not None: + return TradeFee(percent=fee_overrides_config_map['beaxy_taker_fee'].value / Decimal('100')) + """ + + is_maker = order_type is OrderType.LIMIT_MAKER + return estimate_fee('beaxy', is_maker) + + async def execute_buy( + self, + order_id: str, + trading_pair: str, + amount: Decimal, + order_type: OrderType, + price: Optional[Decimal] = s_decimal_0 + ): + """ + Function that takes strategy inputs, auto corrects itself with trading rule, + and submit an API request to place a buy order + """ + cdef: + TradingRule trading_rule = self._trading_rules[trading_pair] + + decimal_amount = self.quantize_order_amount(trading_pair, amount) + decimal_price = self.quantize_order_price(trading_pair, price) + if decimal_amount < trading_rule.min_order_size: + raise ValueError(f'Buy order amount {decimal_amount} is lower than the minimum order size ' + f'{trading_rule.min_order_size}.') + + try: + self.c_start_tracking_order(order_id, trading_pair, order_type, TradeType.BUY, decimal_price, decimal_amount) + order_result = await self.place_order(order_id, trading_pair, decimal_amount, True, order_type, decimal_price) + exchange_order_id = 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} buy order {order_id} for {decimal_amount} {trading_pair}.') + tracked_order.update_exchange_order_id(exchange_order_id) + + self.c_trigger_event(self.MARKET_BUY_ORDER_CREATED_EVENT_TAG, + BuyOrderCreatedEvent(self._current_timestamp, + order_type, + trading_pair, + decimal_amount, + decimal_price, + order_id)) + except asyncio.CancelledError: + raise + except Exception: + self.logger().error(1) + tracked_order = self._in_flight_orders.get(order_id) + tracked_order.last_state = "FAILURE" + self.c_stop_tracking_order(order_id) + order_type_str = order_type.name.lower() + self.logger().network( + f"Error submitting buy {order_type_str} order to Beaxy for " + f"{decimal_amount} {trading_pair} " + f"{decimal_price}.", + exc_info=True, + app_warning_msg=f"Failed to submit buy order to Beaxy. Check API key and network connection." + ) + self.logger().error(2) + self.c_trigger_event(self.MARKET_ORDER_FAILURE_EVENT_TAG, + MarketOrderFailureEvent( + self._current_timestamp, + order_id, + order_type + )) + + cdef str c_buy(self, str trading_pair, object amount, object order_type=OrderType.MARKET, object price=s_decimal_0, + dict kwargs={}): + """ + *required + Synchronous wrapper that generates a client-side order ID and schedules the buy order. + """ + cdef: + int64_t tracking_nonce = get_tracking_nonce() + str order_id = str(f'HBOT-buy-{trading_pair}-{tracking_nonce}') + + safe_ensure_future(self.execute_buy(order_id, trading_pair, amount, order_type, price)) + return order_id + + async def execute_sell( + self, + order_id: str, + trading_pair: str, + amount: Decimal, + order_type: OrderType, + price: Optional[Decimal] = s_decimal_0 + ): + """ + Function that takes strategy inputs, auto corrects itself with trading rule, + and submit an API request to place a sell order + """ + cdef: + TradingRule trading_rule = self._trading_rules[trading_pair] + + decimal_amount = self.quantize_order_amount(trading_pair, amount) + decimal_price = self.quantize_order_price(trading_pair, price) + if decimal_amount < trading_rule.min_order_size: + raise ValueError(f'Sell order amount {decimal_amount} is lower than the minimum order size ' + f'{trading_rule.min_order_size}.') + + try: + self.c_start_tracking_order(order_id, trading_pair, order_type, TradeType.SELL, decimal_price, decimal_amount) + order_result = await self.place_order(order_id, trading_pair, decimal_amount, False, order_type, decimal_price) + + exchange_order_id = 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} sell order {order_id} for {decimal_amount} {trading_pair}.') + tracked_order.update_exchange_order_id(exchange_order_id) + + self.c_trigger_event(self.MARKET_SELL_ORDER_CREATED_EVENT_TAG, + SellOrderCreatedEvent(self._current_timestamp, + order_type, + trading_pair, + decimal_amount, + decimal_price, + order_id)) + except asyncio.CancelledError: + raise + except Exception: + tracked_order = self._in_flight_orders.get(order_id) + tracked_order.last_state = "FAILURE" + self.c_stop_tracking_order(order_id) + order_type_str = order_type.name.lower() + self.logger().network( + f"Error submitting sell {order_type_str} order to Beaxy for " + f"{decimal_amount} {trading_pair} " + f"{decimal_price if order_type is OrderType.LIMIT else ''}.", + exc_info=True, + app_warning_msg=f"Failed to submit sell order to Beaxy. Check API key and network connection." + ) + self.c_trigger_event(self.MARKET_ORDER_FAILURE_EVENT_TAG, + MarketOrderFailureEvent(self._current_timestamp, order_id, order_type)) + + cdef str c_sell( + self, + str trading_pair, + object amount, + object order_type=OrderType.MARKET, + object price=s_decimal_0, + dict kwargs={} + ): + """ + *required + Synchronous wrapper that generates a client-side order ID and schedules the sell order. + """ + cdef: + int64_t tracking_nonce = get_tracking_nonce() + str order_id = str(f'HBOT-sell-{trading_pair}-{tracking_nonce}') + safe_ensure_future(self.execute_sell(order_id, trading_pair, amount, order_type, price)) + return order_id + + async def execute_cancel(self, trading_pair: str, order_id: str): + """ + Function that makes API request to cancel an active order + """ + 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.') + path_url = BeaxyConstants.TradingApi.ORDERS_ENDPOINT + cancel_result = await self._api_request('delete', path_url=path_url, custom_headers={'X-Deltix-Order-ID': tracked_order.exchange_order_id.lower()}) + return order_id + except asyncio.CancelledError: + raise + except IOError as ioe: + self.logger().warning(ioe) + except BeaxyIOError as e: + if e.response.status == 404: + # The order was never there to begin with. So cancelling it is a no-op but semantically successful. + self.logger().info(f"The order {order_id} does not exist on Beaxy. No cancellation needed.") + self.c_stop_tracking_order(order_id) + self.c_trigger_event(self.MARKET_ORDER_CANCELLED_EVENT_TAG, + OrderCancelledEvent(self._current_timestamp, order_id)) + return order_id + except Exception as e: + self.logger().network( + f'Failed to cancel order {order_id}: ', + exc_info=True, + app_warning_msg=f'Failed to cancel the order {order_id} on Beaxy. ' + f'Check API key and network connection.' + ) + return None + + cdef c_cancel(self, str trading_pair, str order_id): + """ + *required + Synchronous wrapper that schedules cancelling an order. + """ + safe_ensure_future(self.execute_cancel(trading_pair, order_id)) + return order_id + + async def cancel_all(self, timeout_seconds: float) -> List[CancellationResult]: + """ + *required + Async function that cancels all active orders. + Used by bot's top level stop and exit commands (cancelling outstanding orders on exit) + :returns: List of CancellationResult which indicates whether each order is successfully cancelled. + """ + incomplete_orders = [o for o in self._in_flight_orders.values() if not o.is_done] + tasks = [self.execute_cancel(o.trading_pair, o.client_order_id) for o in incomplete_orders] + order_id_set = set([o.client_order_id for o in incomplete_orders]) + successful_cancellations = [] + + try: + async with timeout(timeout_seconds): + results = await safe_gather(*tasks, return_exceptions=True) + for client_order_id in results: + if type(client_order_id) is str: + order_id_set.remove(client_order_id) + successful_cancellations.append(CancellationResult(client_order_id, True)) + else: + self.logger().warning( + f'failed to cancel order with error: ' + f'{repr(client_order_id)}' + ) + except Exception as e: + self.logger().network( + f'Unexpected error cancelling orders.', + exc_info=True, + app_warning_msg='Failed to cancel order on Coinbase Pro. Check API key and network connection.' + ) + + failed_cancellations = [CancellationResult(oid, False) for oid in order_id_set] + return successful_cancellations + failed_cancellations + + async def _update_trade_fees(self): + + cdef: + double current_timestamp = self._current_timestamp + + if current_timestamp - self._last_fee_percentage_update_timestamp <= self.UPDATE_FEE_PERCENTAGE_INTERVAL: + return + + try: + res = await self._api_request('get', BeaxyConstants.TradingApi.SECURITIES_ENDPOINT) + first_security = res[0] + self._maker_fee_percentage = Decimal(first_security['buyer_maker_commission_progressive']) + self._taker_fee_percentage = Decimal(first_security['buyer_taker_commission_progressive']) + self._last_fee_percentage_update_timestamp = current_timestamp + except asyncio.CancelledError: + self.logger().warning('Got cancelled error fetching beaxy fees.') + raise + except Exception: + self.logger().network('Error fetching Beaxy trade fees.', exc_info=True, + app_warning_msg=f'Could not fetch Beaxy trading fees. ' + f'Check network connection.') + raise + + async def _update_balances(self): + cdef: + dict account_info + list balances + str asset_name + set local_asset_names = set(self._account_balances.keys()) + set remote_asset_names = set() + set asset_names_to_remove + + account_balances = await self._api_request('get', path_url=BeaxyConstants.TradingApi.ACOUNTS_ENDPOINT) + + for balance_entry in account_balances: + asset_name = balance_entry['currency_id'] + available_balance = Decimal(balance_entry['available_for_trading']) + total_balance = Decimal(balance_entry['balance']) + self._account_available_balances[asset_name] = available_balance + self._account_balances[asset_name] = total_balance + remote_asset_names.add(asset_name) + + asset_names_to_remove = local_asset_names.difference(remote_asset_names) + for asset_name in asset_names_to_remove: + del self._account_available_balances[asset_name] + del self._account_balances[asset_name] + + async def _update_trading_rules(self): + """ + Pulls the API for trading rules (min / max order size, etc) + """ + cdef: + int64_t last_tick = (self._last_timestamp / 60.0) + int64_t current_tick = (self._current_timestamp / 60.0) + + try: + if current_tick > last_tick or len(self._trading_rules) <= 0: + product_info = await self._api_request(http_method='get', url=BeaxyConstants.PublicApi.SYMBOLS_URL, is_auth_required=False) + trading_rules_list = self._format_trading_rules(product_info) + self._trading_rules.clear() + for trading_rule in trading_rules_list: + + # at Beaxy all pairs listed without splitter, so there is need to convert it + trading_pair = '{}-{}'.format(*BeaxyExchange.split_trading_pair(trading_rule.trading_pair)) + + self._trading_rules[trading_pair] = trading_rule + except Exception: + self.logger().warning(f'Got exception while updating trading rules.', exc_info=True) + + def _format_trading_rules(self, market_dict: Dict[str, Any]) -> List[TradingRule]: + """ + Turns json data from API into TradingRule instances + :returns: List of TradingRule + """ + cdef: + list retval = [] + + for rule in market_dict: + try: + trading_pair = rule.get('symbol') + # Parsing from string doesn't mess up the precision + retval.append(TradingRule(trading_pair, + min_price_increment=Decimal(str(rule.get('tickSize'))), + min_order_size=Decimal(str(rule.get('minimumQuantity'))), + max_order_size=Decimal(str(rule.get('maximumQuantity'))), + min_base_amount_increment=Decimal(str(rule.get('quantityIncrement'))), + min_quote_amount_increment=Decimal(str(rule.get('quantityIncrement'))), + max_price_significant_digits=Decimal(str(rule.get('pricePrecision'))))) + except Exception: + self.logger().error(f'Error parsing the trading_pair rule {rule}. Skipping.', exc_info=True) + return retval + + 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 Beaxy. 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: + order = event_message['order'] + exchange_order_id = order['id'] + client_order_id = order['text'] + order_status = order['status'] + + if client_order_id is None: + continue + + tracked_order = self._in_flight_orders.get(client_order_id) + + if tracked_order is None: + self.logger().debug(f'Didn`rt find order with id {client_order_id}') + continue + + execute_price = s_decimal_0 + execute_amount_diff = s_decimal_0 + + if event_message['events']: + order_event = event_message['events'][0] + event_type = order_event['type'] + + if event_type == 'trade': + execute_price = Decimal(order_event.get('trade_price', 0.0)) + execute_amount_diff = Decimal(order_event.get('trade_quantity', 0.0)) + tracked_order.executed_amount_base = Decimal(order['cumulative_quantity']) + tracked_order.executed_amount_quote += execute_amount_diff * execute_price + + if execute_amount_diff > s_decimal_0: + self.logger().info(f'Filled {execute_amount_diff} out of {tracked_order.amount} of the ' + f'{tracked_order.order_type_description} order {tracked_order.client_order_id}') + exchange_order_id = tracked_order.exchange_order_id + + self.c_trigger_event(self.MARKET_ORDER_FILLED_EVENT_TAG, + OrderFilledEvent( + self._current_timestamp, + tracked_order.client_order_id, + tracked_order.trading_pair, + tracked_order.trade_type, + tracked_order.order_type, + execute_price, + execute_amount_diff, + self.c_get_fee( + tracked_order.base_asset, + tracked_order.quote_asset, + tracked_order.order_type, + tracked_order.trade_type, + execute_price, + execute_amount_diff, + ), + exchange_trade_id=exchange_order_id + )) + + if order_status == 'completely_filled': + if tracked_order.trade_type == TradeType.BUY: + self.logger().info(f'The market buy order {tracked_order.client_order_id} has completed ' + f'according to Beaxy user stream.') + self.c_trigger_event(self.MARKET_BUY_ORDER_COMPLETED_EVENT_TAG, + BuyOrderCompletedEvent(self._current_timestamp, + tracked_order.client_order_id, + tracked_order.base_asset, + tracked_order.quote_asset, + (tracked_order.fee_asset + or tracked_order.base_asset), + tracked_order.executed_amount_base, + tracked_order.executed_amount_quote, + tracked_order.fee_paid, + tracked_order.order_type)) + else: + self.logger().info(f'The market sell order {tracked_order.client_order_id} has completed ' + f'according to Beaxy user stream.') + self.c_trigger_event(self.MARKET_SELL_ORDER_COMPLETED_EVENT_TAG, + SellOrderCompletedEvent(self._current_timestamp, + tracked_order.client_order_id, + tracked_order.base_asset, + tracked_order.quote_asset, + (tracked_order.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)) + + self.c_stop_tracking_order(tracked_order.client_order_id) + + elif order_status == 'canceled': + tracked_order.last_state = 'canceled' + self.c_trigger_event(self.MARKET_ORDER_CANCELLED_EVENT_TAG, + OrderCancelledEvent(self._current_timestamp, tracked_order.client_order_id)) + self.c_stop_tracking_order(tracked_order.client_order_id) + elif order_status in ['rejected', 'replaced', 'suspended']: + tracked_order.last_state = order_status + self.c_trigger_event(self.MARKET_ORDER_FAILURE_EVENT_TAG, + MarketOrderFailureEvent(self._current_timestamp, tracked_order.client_order_id, tracked_order.order_type)) + self.c_stop_tracking_order(tracked_order.client_order_id) + elif order_status == 'expired': + tracked_order.last_state = 'expired' + self.c_trigger_event(self.MARKET_ORDER_EXPIRED_EVENT_TAG, + OrderExpiredEvent(self._current_timestamp, tracked_order.client_order_id)) + self.c_stop_tracking_order(tracked_order.client_order_id) + + except Exception: + self.logger().error('Unexpected error in user stream listener loop.', exc_info=True) + await asyncio.sleep(5.0) + + 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 _api_request( + self, + http_method: str, + path_url: str = None, + url: str = None, + is_auth_required: bool = True, + data: Optional[Dict[str, Any]] = None, + custom_headers: [Optional[Dict[str, str]]] = None + ) -> Dict[str, Any]: + """ + A wrapper for submitting API requests to Beaxy + :returns: json data from the endpoints + """ + try: + assert path_url is not None or url is not None + + url = f'{BeaxyConstants.TradingApi.BASE_URL}{path_url}' if url is None else url + data_str = "" if data is None else json.dumps(data, separators=(',', ':')) + + if is_auth_required: + headers = await self.beaxy_auth.generate_auth_dict(http_method, path_url, data_str) + else: + headers = {'Content-Type': 'application/json'} + + if custom_headers: + headers = {**custom_headers, **headers} + + if http_method.upper() == 'POST': + headers['Content-Type'] = 'application/json; charset=utf-8' + + self.logger().debug(f'Submitting {http_method} request to {url} with headers {headers}') + + client = await self._http_client() + async with client.request(http_method.upper(), url=url, timeout=self.API_CALL_TIMEOUT, data=data_str, headers=headers) as response: + result = None + if response.status != 200: + raise BeaxyIOError( + f'Error during api request with body {data_str}. HTTP status is {response.status}. Response - {await response.text()} - Request {response.request_info}', + response=response, + ) + try: + result = await response.json() + except ContentTypeError: + pass + + self.logger().debug(f'Got response status {response.status}') + self.logger().debug(f'Got response {result}') + return result + except Exception: + self.logger().warning(f'Exception while making api request.', exc_info=True) + raise + + async def _status_polling_loop(self): + """ + Background process that periodically pulls for changes from the rest API + """ + while True: + try: + + self._poll_notifier = asyncio.Event() + await self._poll_notifier.wait() + await safe_gather(self._update_balances()) + await asyncio.sleep(60) + await safe_gather(self._update_trade_fees()) + await asyncio.sleep(60) + await safe_gather(self._update_order_status()) + except asyncio.CancelledError: + raise + except Exception: + self.logger().network( + 'Unexpected error while fetching account updates.', + exc_info=True, + app_warning_msg=f'Could not fetch account updates on Beaxy.' + f'Check API key and network connection.' + ) + await asyncio.sleep(0.5) + + async def _trading_rules_polling_loop(self): + """ + Separate background process that periodically pulls for trading rule changes + (Since trading rules don't get updated often, it is pulled less often.) + """ + while True: + try: + await safe_gather(self._update_trading_rules()) + await asyncio.sleep(6000) + except asyncio.CancelledError: + raise + except Exception: + self.logger().network( + 'Unexpected error while fetching trading rules.', + exc_info=True, + app_warning_msg=f'Could not fetch trading rule updates on Beaxy. ' + f'Check network connection.' + ) + await asyncio.sleep(0.5) + + cdef OrderBook c_get_order_book(self, str trading_pair): + """ + :returns: OrderBook for a specific trading pair + """ + cdef: + dict order_books = self._order_book_tracker.order_books + + if trading_pair not in order_books: + raise ValueError(f'No order book exists for "{trading_pair}".') + return order_books[trading_pair] + + cdef c_start_tracking_order(self, + str client_order_id, + str trading_pair, + object order_type, + object trade_type, + object price, + object amount): + """ + Add new order to self._in_flight_orders mapping + """ + self._in_flight_orders[client_order_id] = BeaxyInFlightOrder( + client_order_id, + None, + trading_pair, + order_type, + trade_type, + price, + amount, + ) + + cdef c_did_timeout_tx(self, str tracking_id): + self.c_trigger_event( + self.MARKET_TRANSACTION_FAILURE_EVENT_TAG, + MarketTransactionFailureEvent(self._current_timestamp, tracking_id) + ) + + cdef object c_get_order_price_quantum(self, str trading_pair, object price): + cdef: + TradingRule trading_rule = self._trading_rules[trading_pair] + + return trading_rule.min_price_increment + + cdef object c_get_order_size_quantum(self, str trading_pair, object order_size): + cdef: + TradingRule trading_rule = self._trading_rules[trading_pair] + return Decimal(trading_rule.min_base_amount_increment) + + cdef object c_quantize_order_amount(self, str trading_pair, object amount, object price=s_decimal_0): + """ + *required + Check current order amount against trading rule, and correct any rule violations + :return: Valid order amount in Decimal format + """ + cdef: + TradingRule trading_rule = self._trading_rules[trading_pair] + object quantized_amount = ExchangeBase.c_quantize_order_amount(self, trading_pair, amount) + + # Check against min_order_size. If not passing either check, return 0. + if quantized_amount < trading_rule.min_order_size: + return s_decimal_0 + + # Check against max_order_size. If not passing either check, return 0. + if quantized_amount > trading_rule.max_order_size: + return s_decimal_0 + + return quantized_amount + + def supported_order_types(self): + return [OrderType.LIMIT, OrderType.LIMIT_MAKER, OrderType.MARKET] + + cdef c_stop_tracking_order(self, str order_id): + """ + Delete an order from self._in_flight_orders mapping + """ + if order_id in self._in_flight_orders: + del self._in_flight_orders[order_id] + + def get_price(self, trading_pair: str, is_buy: bool) -> Decimal: + return self.c_get_price(trading_pair, is_buy) + + def buy(self, trading_pair: str, amount: Decimal, order_type=OrderType.MARKET, + price: Decimal = s_decimal_NaN, **kwargs) -> str: + return self.c_buy(trading_pair, amount, order_type, price, kwargs) + + def sell(self, trading_pair: str, amount: Decimal, order_type=OrderType.MARKET, + price: Decimal = s_decimal_NaN, **kwargs) -> str: + return self.c_sell(trading_pair, amount, order_type, price, kwargs) + + def cancel(self, trading_pair: str, client_order_id: str): + return self.c_cancel(trading_pair, client_order_id) + + def get_fee(self, + base_currency: str, + quote_currency: str, + order_type: OrderType, + order_side: TradeType, + amount: Decimal, + price: Decimal = s_decimal_NaN) -> TradeFee: + return self.c_get_fee(base_currency, quote_currency, order_type, order_side, amount, price) + + def get_order_book(self, trading_pair: str) -> OrderBook: + return self.c_get_order_book(trading_pair) diff --git a/hummingbot/connector/exchange/beaxy/beaxy_in_flight_order.pxd b/hummingbot/connector/exchange/beaxy/beaxy_in_flight_order.pxd new file mode 100644 index 0000000000..c2b41e4a7c --- /dev/null +++ b/hummingbot/connector/exchange/beaxy/beaxy_in_flight_order.pxd @@ -0,0 +1,4 @@ +from hummingbot.connector.in_flight_order_base cimport InFlightOrderBase + +cdef class BeaxyInFlightOrder(InFlightOrderBase): + pass diff --git a/hummingbot/connector/exchange/beaxy/beaxy_in_flight_order.pyx b/hummingbot/connector/exchange/beaxy/beaxy_in_flight_order.pyx new file mode 100644 index 0000000000..79b1340a78 --- /dev/null +++ b/hummingbot/connector/exchange/beaxy/beaxy_in_flight_order.pyx @@ -0,0 +1,77 @@ +# -*- coding: utf-8 -*- + +from decimal import Decimal +from typing import Any, Dict, Optional + +from hummingbot.core.event.events import OrderType, TradeType +from hummingbot.connector.in_flight_order_base import InFlightOrderBase + + +cdef class BeaxyInFlightOrder(InFlightOrderBase): + def __init__( + self, + client_order_id: str, + exchange_order_id: Optional[str], + trading_pair: str, + order_type: OrderType, + trade_type: TradeType, + price: Decimal, + amount: Decimal, + initial_state: str = 'new' + ): + super().__init__( + client_order_id, + exchange_order_id, + trading_pair, + order_type, + trade_type, + price, + amount, + initial_state + ) + + @property + def is_done(self) -> bool: + return self.last_state in {'completely_filled', 'cancelled', 'rejected', 'replaced', 'expired', 'pending_cancel', 'suspended', 'pending_replace'} + + @property + def is_failure(self) -> bool: + # This is the only known canceled state + return self.last_state in {'cancelled', 'pending_cancel', 'rejected', 'expired', 'suspended'} + + @property + def is_cancelled(self) -> bool: + return self.last_state == 'cancelled' + + @property + def order_type_description(self) -> str: + """ + :return: Order description string . One of ['limit buy' / 'limit sell' / 'market buy' / 'market sell'] + """ + order_type = 'market' if self.order_type is OrderType.MARKET else 'limit' + side = 'buy' if self.trade_type == TradeType.BUY else 'sell' + return f'{order_type} {side}' + + @classmethod + def from_json(cls, data: Dict[str, Any]) -> InFlightOrderBase: + """ + :param data: json data from API + :return: formatted InFlightOrder + """ + cdef: + BeaxyInFlightOrder retval = BeaxyInFlightOrder( + data['client_order_id'], + data['exchange_order_id'], + data['trading_pair'], + getattr(OrderType, data['order_type']), + getattr(TradeType, data['trade_type']), + Decimal(data['price']), + Decimal(data['amount']), + data['last_state'] + ) + retval.executed_amount_base = Decimal(data['executed_amount_base']) + retval.executed_amount_quote = Decimal(data['executed_amount_quote']) + retval.fee_asset = data['fee_asset'] + retval.fee_paid = Decimal(data['fee_paid']) + retval.last_state = data['last_state'] + return retval diff --git a/hummingbot/connector/exchange/beaxy/beaxy_misc.py b/hummingbot/connector/exchange/beaxy/beaxy_misc.py new file mode 100644 index 0000000000..7173851942 --- /dev/null +++ b/hummingbot/connector/exchange/beaxy/beaxy_misc.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- + +import re +from typing import Optional, Tuple, List + +TRADING_PAIR_SPLITTER = re.compile(r'^(\w+)?(BTC|ETH|BXY|USDT|USDC|USD)(\w+)?$') + + +def split_trading_pair(trading_pair: str) -> Optional[Tuple[str, str]]: + """ + Converts Beaxy exchange API format to Hummingbot trading pair format + example: BTCUSDC -> BTC-USDC + """ + + try: + if '-' in trading_pair: + return trading_pair.split('-') + + m = TRADING_PAIR_SPLITTER.match(trading_pair) + # determine the main asset + sub_pre, main, sub_post = m.group(1), m.group(2), m.group(3) + # keep ordering like in pair + if sub_pre: + return sub_pre, main + elif sub_post: + return main, sub_post + + # Exceptions are now logged as warnings in trading pair fetcher + except Exception: # nopep8 + return None + + +def symbol_to_trading_pair(trading_pair: str) -> str: + return '{}-{}'.format(*split_trading_pair(trading_pair)) + + +def split_market_pairs(pairs: List[str]): + """ + formats list of Beaxy pairs to Hummingbot trading pair format + """ + for pair in pairs: + formatted = split_trading_pair(pair) + if formatted: + yield formatted + + +def trading_pair_to_symbol(trading_pair: str) -> str: + """ + Converts Hummingbot trading pair format to Beaxy exchange API format + example: BTC-USDC -> BTCUSDC + """ + return trading_pair.replace('-', '') + + +class BeaxyIOError(IOError): + + def __init__(self, msg, response, *args, **kwargs): + self.response = response + super(BeaxyIOError, self).__init__(msg, *args, **kwargs) diff --git a/hummingbot/connector/exchange/beaxy/beaxy_order_book.pxd b/hummingbot/connector/exchange/beaxy/beaxy_order_book.pxd new file mode 100644 index 0000000000..2a1984af41 --- /dev/null +++ b/hummingbot/connector/exchange/beaxy/beaxy_order_book.pxd @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- + +from hummingbot.core.data_type.order_book cimport OrderBook + +cdef class BeaxyOrderBook(OrderBook): + pass diff --git a/hummingbot/connector/exchange/beaxy/beaxy_order_book.pyx b/hummingbot/connector/exchange/beaxy/beaxy_order_book.pyx new file mode 100644 index 0000000000..98a749bdca --- /dev/null +++ b/hummingbot/connector/exchange/beaxy/beaxy_order_book.pyx @@ -0,0 +1,81 @@ +# -*- coding: utf-8 -*- + +import logging +from typing import Dict, Optional, Any, List +from decimal import Decimal + +from hummingbot.logger import HummingbotLogger +from hummingbot.core.event.events import TradeType +from hummingbot.core.data_type.order_book cimport OrderBook +from hummingbot.core.data_type.order_book_message import OrderBookMessage, OrderBookMessageType + +from hummingbot.connector.exchange.beaxy.beaxy_order_book_message import BeaxyOrderBookMessage +from hummingbot.connector.exchange.beaxy.beaxy_misc import symbol_to_trading_pair + + +_bxob_logger = None + + +cdef class BeaxyOrderBook(OrderBook): + @classmethod + def logger(cls) -> HummingbotLogger: + global _bxob_logger + if _bxob_logger is None: + _bxob_logger = logging.getLogger(__name__) + return _bxob_logger + + @classmethod + def snapshot_message_from_exchange( + cls, + msg: Dict[str, any], + timestamp: float, + metadata: Optional[Dict] = None + ) -> OrderBookMessage: + if metadata: + msg.update(metadata) + return BeaxyOrderBookMessage( + message_type=OrderBookMessageType.SNAPSHOT, + content=msg, + timestamp=timestamp + ) + + @classmethod + def diff_message_from_exchange( + cls, + msg: Dict[str, any], + timestamp: Optional[float] = None, + metadata: Optional[Dict] = None + ) -> OrderBookMessage: + if metadata: + msg.update(metadata) + return BeaxyOrderBookMessage( + message_type=OrderBookMessageType.DIFF, + content=msg, + timestamp=timestamp + ) + + @classmethod + def trade_message_from_exchange( + cls, + msg: Dict[str, Any], + timestamp: Optional[float] = None, + metadata: Optional[Dict] = None + ) -> OrderBookMessage: + if metadata: + msg.update(metadata) + ts = msg['timestamp'] + return OrderBookMessage(OrderBookMessageType.TRADE, { + 'trading_pair': symbol_to_trading_pair(msg['symbol']), + 'trade_type': float(TradeType.SELL.value) if msg['side'] == 'SELL' else float(TradeType.BUY.value), + 'price': Decimal(str(msg['price'])), + 'update_id': ts, + 'amount': msg['size'] + }, timestamp=ts * 1e-3) + + @classmethod + def from_snapshot(cls, snapshot: OrderBookMessage): + raise NotImplementedError('Beaxy order book needs to retain individual order data.') + + @classmethod + def restore_from_snapshot_and_diffs(self, snapshot: OrderBookMessage, diffs: List[OrderBookMessage]): + raise NotImplementedError('Beaxy order book needs to retain individual order data.') diff --git a/hummingbot/connector/exchange/beaxy/beaxy_order_book_message.py b/hummingbot/connector/exchange/beaxy/beaxy_order_book_message.py new file mode 100644 index 0000000000..9a3eaef1cc --- /dev/null +++ b/hummingbot/connector/exchange/beaxy/beaxy_order_book_message.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- + +import time +from typing import Dict, List, Optional + +from hummingbot.core.data_type.order_book_row import OrderBookRow +from hummingbot.core.data_type.order_book_message import OrderBookMessage, OrderBookMessageType + +from hummingbot.connector.exchange.beaxy.beaxy_misc import symbol_to_trading_pair + + +class BeaxyOrderBookMessage(OrderBookMessage): + def __new__( + cls, + message_type: OrderBookMessageType, + content: Dict[str, any], + timestamp: Optional[float] = None, + *args, + **kwargs, + ): + if timestamp is None: + if message_type is OrderBookMessageType.SNAPSHOT: + raise ValueError('timestamp must not be None when initializing snapshot messages.') + timestamp = int(time.time()) + return super(BeaxyOrderBookMessage, cls).__new__( + cls, message_type, content, timestamp=timestamp, *args, **kwargs + ) + + @property + def update_id(self) -> int: + return int(str(self.content['sequenceNumber'])) + + @property + def trade_id(self) -> int: + return int(self.timestamp * 1e3) + + @property + def trading_pair(self) -> str: + return symbol_to_trading_pair(str(self.content.get('security'))) + + @property + def asks(self) -> List[OrderBookRow]: + return [ + OrderBookRow(entry['price'], entry['quantity'], self.update_id) + for entry in self.content.get('entries', []) + if entry['side'] == 'ASK' and entry['action'] == 'INSERT' + ] + + @property + def bids(self) -> List[OrderBookRow]: + return [ + OrderBookRow(entry['price'], entry['quantity'], self.update_id) + for entry in self.content.get('entries', []) + if entry['side'] == 'BID' and entry['action'] == 'INSERT' + ] + + @property + def has_update_id(self) -> bool: + return True + + @property + def has_trade_id(self) -> bool: + return True + + def __eq__(self, other) -> bool: + return self.type == other.type and self.timestamp == other.timestamp + + def __lt__(self, other) -> bool: + if self.timestamp != other.timestamp: + return self.timestamp < other.timestamp + else: + """ + If timestamp is the same, the ordering is snapshot < diff < trade + """ + return self.type.value < other.type.value diff --git a/hummingbot/connector/exchange/beaxy/beaxy_order_book_tracker.py b/hummingbot/connector/exchange/beaxy/beaxy_order_book_tracker.py new file mode 100644 index 0000000000..d730dc97c7 --- /dev/null +++ b/hummingbot/connector/exchange/beaxy/beaxy_order_book_tracker.py @@ -0,0 +1,190 @@ +# -*- coding: utf-8 -*- + +import asyncio +import time +import bisect +import logging + +from collections import defaultdict, deque +from typing import Deque, Dict, List, Optional, Set + +from hummingbot.logger import HummingbotLogger +from hummingbot.core.data_type.order_book_tracker import OrderBookTracker +from hummingbot.connector.exchange.beaxy.beaxy_api_order_book_data_source import BeaxyAPIOrderBookDataSource +from hummingbot.connector.exchange.beaxy.beaxy_order_book_message import BeaxyOrderBookMessage +from hummingbot.core.utils.async_utils import safe_ensure_future +from hummingbot.core.data_type.order_book_message import OrderBookMessageType +from hummingbot.core.data_type.order_book import OrderBook + +from hummingbot.connector.exchange.beaxy.beaxy_order_book import BeaxyOrderBook +from hummingbot.connector.exchange.beaxy.beaxy_active_order_tracker import BeaxyActiveOrderTracker +from hummingbot.connector.exchange.beaxy.beaxy_order_book_tracker_entry import BeaxyOrderBookTrackerEntry + + +class BeaxyOrderBookTracker(OrderBookTracker): + _bxobt_logger: Optional[HummingbotLogger] = None + + @classmethod + def logger(cls) -> HummingbotLogger: + if cls._bxobt_logger is None: + cls._bxobt_logger = logging.getLogger(__name__) + return cls._bxobt_logger + + def __init__(self, trading_pairs: List[str]): + super().__init__(BeaxyAPIOrderBookDataSource(trading_pairs), trading_pairs) + + self._ev_loop: asyncio.BaseEventLoop = asyncio.get_event_loop() + self._order_book_snapshot_stream: asyncio.Queue = asyncio.Queue() + self._order_book_diff_stream: asyncio.Queue = asyncio.Queue() + self._process_msg_deque_task: Optional[asyncio.Task] = None + self._past_diffs_windows: Dict[str, Deque] = {} + self._order_books: Dict[str, BeaxyOrderBook] = {} + self._saved_message_queues: Dict[str, Deque[BeaxyOrderBookMessage]] = defaultdict(lambda: deque(maxlen=1000)) + self._active_order_trackers: Dict[str, BeaxyActiveOrderTracker] = defaultdict(BeaxyActiveOrderTracker) + + @property + def exchange_name(self) -> str: + """ + *required + Name of the current exchange + """ + return 'beaxy' + + async def _order_book_diff_router(self): + """ + Route the real-time order book diff messages to the correct order book. + """ + last_message_timestamp: float = time.time() + messages_queued: int = 0 + messages_accepted: int = 0 + messages_rejected: int = 0 + + while True: + try: + ob_message: BeaxyOrderBookMessage = await self._order_book_diff_stream.get() + trading_pair: str = ob_message.trading_pair + + if trading_pair not in self._tracking_message_queues: + messages_queued += 1 + # Save diff messages received before snapshots are ready + self._saved_message_queues[trading_pair].append(ob_message) + continue + message_queue: asyncio.Queue = self._tracking_message_queues[trading_pair] + # Check the order book's initial update ID. If it's larger, don't bother. + order_book: OrderBook = self._order_books[trading_pair] + + if order_book.snapshot_uid > ob_message.update_id: + messages_rejected += 1 + continue + await message_queue.put(ob_message) + messages_accepted += 1 + + # Log some statistics. + now: float = time.time() + if int(now / 60.0) > int(last_message_timestamp / 60.0): + self.logger().debug('Messages processed: %d, rejected: %d, queued: %d', + messages_accepted, + messages_rejected, + messages_queued) + messages_accepted = 0 + messages_rejected = 0 + messages_queued = 0 + + last_message_timestamp = now + except asyncio.CancelledError: + raise + except Exception: + self.logger().network( + 'Unexpected error routing order book messages.', + exc_info=True, + app_warning_msg='Unexpected error routing order book messages. Retrying after 5 seconds.' + ) + await asyncio.sleep(5.0) + + async def _refresh_tracking_tasks(self): + """ + Starts tracking for any new trading pairs, and stop tracking for any inactive trading pairs. + """ + tracking_trading_pairs: Set[str] = set([key for key in self._tracking_tasks.keys() + if not self._tracking_tasks[key].done()]) + available_pairs: Dict[str, BeaxyOrderBookTrackerEntry] = await self.data_source.get_tracking_pairs() + available_trading_pairs: Set[str] = set(available_pairs.keys()) + new_trading_pairs: Set[str] = available_trading_pairs - tracking_trading_pairs + deleted_trading_pairs: Set[str] = tracking_trading_pairs - available_trading_pairs + + for trading_pair in new_trading_pairs: + order_book_tracker_entry: BeaxyOrderBookTrackerEntry = available_pairs[trading_pair] + self._active_order_trackers[trading_pair] = order_book_tracker_entry.active_order_tracker + self._order_books[trading_pair] = order_book_tracker_entry.order_book + self._tracking_message_queues[trading_pair] = asyncio.Queue() + self._tracking_tasks[trading_pair] = safe_ensure_future(self._track_single_book(trading_pair)) + self.logger().info('Started order book tracking for %s.' % trading_pair) + + for trading_pair in deleted_trading_pairs: + self._tracking_tasks[trading_pair].cancel() + del self._tracking_tasks[trading_pair] + del self._order_books[trading_pair] + del self._active_order_trackers[trading_pair] + del self._tracking_message_queues[trading_pair] + self.logger().info('Stopped order book tracking for %s.' % trading_pair) + + async def _track_single_book(self, trading_pair: str): + """ + Update an order book with changes from the latest batch of received messages + """ + past_diffs_window: Deque[BeaxyOrderBookMessage] = deque() + self._past_diffs_windows[trading_pair] = past_diffs_window + + message_queue: asyncio.Queue = self._tracking_message_queues[trading_pair] + order_book: BeaxyOrderBook = self._order_books[trading_pair] + active_order_tracker: BeaxyActiveOrderTracker = self._active_order_trackers[trading_pair] + + last_message_timestamp: float = time.time() + diff_messages_accepted: int = 0 + + while True: + try: + message: BeaxyOrderBookMessage = None + saved_messages: Deque[BeaxyOrderBookMessage] = self._saved_message_queues[trading_pair] + # Process saved messages first if there are any + if len(saved_messages) > 0: + message = saved_messages.popleft() + else: + message = await message_queue.get() + + if message.type is OrderBookMessageType.DIFF: + bids, asks = active_order_tracker.convert_diff_message_to_order_book_row(message) + order_book.apply_diffs(bids, asks, message.update_id) + past_diffs_window.append(message) + while len(past_diffs_window) > self.PAST_DIFF_WINDOW_SIZE: + past_diffs_window.popleft() + diff_messages_accepted += 1 + + # Output some statistics periodically. + now: float = time.time() + if int(now / 60.0) > int(last_message_timestamp / 60.0): + self.logger().debug('Processed %d order book diffs for %s.', + diff_messages_accepted, trading_pair) + diff_messages_accepted = 0 + last_message_timestamp = now + elif message.type is OrderBookMessageType.SNAPSHOT: + past_diffs: List[BeaxyOrderBookMessage] = list(past_diffs_window) + # only replay diffs later than snapshot, first update active order with snapshot then replay diffs + replay_position = bisect.bisect_right(past_diffs, message) + replay_diffs = past_diffs[replay_position:] + s_bids, s_asks = active_order_tracker.convert_snapshot_message_to_order_book_row(message) + order_book.apply_snapshot(s_bids, s_asks, message.update_id) + for diff_message in replay_diffs: + d_bids, d_asks = active_order_tracker.convert_diff_message_to_order_book_row(diff_message) + order_book.apply_diffs(d_bids, d_asks, diff_message.update_id) + + self.logger().debug('Processed order book snapshot for %s.', trading_pair) + except asyncio.CancelledError: + raise + except Exception: + self.logger().network( + f'Unexpected error processing order book messages for {trading_pair}.', + exc_info=True, + app_warning_msg='Unexpected error processing order book messages. Retrying after 5 seconds.' + ) + await asyncio.sleep(5.0) diff --git a/hummingbot/connector/exchange/beaxy/beaxy_order_book_tracker_entry.py b/hummingbot/connector/exchange/beaxy/beaxy_order_book_tracker_entry.py new file mode 100644 index 0000000000..1d3fb7bc59 --- /dev/null +++ b/hummingbot/connector/exchange/beaxy/beaxy_order_book_tracker_entry.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- + +from hummingbot.core.data_type.order_book import OrderBook +from hummingbot.core.data_type.order_book_tracker_entry import OrderBookTrackerEntry + +from hummingbot.connector.exchange.beaxy.beaxy_active_order_tracker import BeaxyActiveOrderTracker + + +class BeaxyOrderBookTrackerEntry(OrderBookTrackerEntry): + def __init__( + self, + trading_pair: str, + timestamp: float, + order_book: OrderBook, + active_order_tracker: BeaxyActiveOrderTracker + ): + self._active_order_tracker = active_order_tracker + super(BeaxyOrderBookTrackerEntry, self).__init__(trading_pair, timestamp, order_book) + + def __repr__(self) -> str: + return ( + f'BeaxyOrderBookTrackerEntry(trading_pair="{self._trading_pair}", timestamp="{self._timestamp}", ' + f'order_book="{self._order_book}")' + ) + + @property + def active_order_tracker(self) -> BeaxyActiveOrderTracker: + return self._active_order_tracker diff --git a/hummingbot/connector/exchange/beaxy/beaxy_stomp_message.py b/hummingbot/connector/exchange/beaxy/beaxy_stomp_message.py new file mode 100644 index 0000000000..981eae75cb --- /dev/null +++ b/hummingbot/connector/exchange/beaxy/beaxy_stomp_message.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- + +from typing import Dict + + +class BeaxyStompMessage: + def __init__(self, command: str = "") -> None: + self.command = command + self.body: str = "" + self.headers: Dict[str, str] = {} + + def serialize(self) -> str: + result = self.command + '\n' + result += ''.join([f'{k}:{self.headers[k]}\n' for k in self.headers]) + result += '\n' + result += self.body + result += '\0' + return result + + def has_error(self) -> bool: + return self.headers.get('status') != '200' + + @staticmethod + def deserialize(raw_message: str) -> 'BeaxyStompMessage': + lines = raw_message.splitlines() + retval = BeaxyStompMessage() + for index, line in enumerate(lines): + if index == 0: + retval.command = line + else: + split = line.split(':') + if len(split) == 2: + retval.headers[split[0].strip()] = split[1].strip() + else: + if line: + line_index = raw_message.index(line) + retval.body = raw_message[line_index:] + retval.body = "".join(c for c in retval.body if c not in ['\r', '\n', '\0']) + break + + return retval diff --git a/hummingbot/connector/exchange/beaxy/beaxy_user_stream_tracker.py b/hummingbot/connector/exchange/beaxy/beaxy_user_stream_tracker.py new file mode 100644 index 0000000000..c9696fbfc0 --- /dev/null +++ b/hummingbot/connector/exchange/beaxy/beaxy_user_stream_tracker.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- + +import asyncio +import logging + +from typing import Optional, List + +from hummingbot.core.data_type.user_stream_tracker_data_source import UserStreamTrackerDataSource +from hummingbot.core.data_type.user_stream_tracker import UserStreamTracker +from hummingbot.core.utils.async_utils import safe_ensure_future, safe_gather + +from hummingbot.connector.exchange.beaxy.beaxy_api_user_stream_data_source import BeaxyAPIUserStreamDataSource +from hummingbot.connector.exchange.beaxy.beaxy_auth import BeaxyAuth + + +class BeaxyUserStreamTracker(UserStreamTracker): + _bxyust_logger: Optional[logging.Logger] = None + + @classmethod + def logger(cls) -> logging.Logger: + if cls._bxyust_logger is None: + cls._bxyust_logger = logging.getLogger(__name__) + return cls._bxyust_logger + + def __init__( + self, + beaxy_auth: BeaxyAuth, + trading_pairs: Optional[List[str]] = [], + ): + super().__init__() + self._beaxy_auth = beaxy_auth + self._trading_pairs: List[str] = trading_pairs + self._ev_loop: asyncio.BaseEventLoop = asyncio.get_event_loop() + self._data_source: Optional[UserStreamTrackerDataSource] = None + self._user_stream_tracking_task = None + + @property + def data_source(self) -> UserStreamTrackerDataSource: + if not self._data_source: + self._data_source = BeaxyAPIUserStreamDataSource( + beaxy_auth=self._beaxy_auth, trading_pairs=self._trading_pairs) + return self._data_source + + @property + def exchange_name(self) -> str: + return 'beaxy' + + async def start(self): + self._user_stream_tracking_task = safe_ensure_future( + self.data_source.listen_for_user_stream(self._ev_loop, self._user_stream) + ) + await safe_gather(self._user_stream_tracking_task) diff --git a/hummingbot/connector/exchange/beaxy/beaxy_utils.py b/hummingbot/connector/exchange/beaxy/beaxy_utils.py new file mode 100644 index 0000000000..c7f6aace79 --- /dev/null +++ b/hummingbot/connector/exchange/beaxy/beaxy_utils.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- + +from hummingbot.client.config.config_var import ConfigVar +from hummingbot.client.config.config_methods import using_exchange + + +CENTRALIZED = True + +EXAMPLE_PAIR = 'BTC-USDC' + +DEFAULT_FEES = [0.15, 0.25] + +KEYS = { + 'beaxy_api_key': + ConfigVar(key='beaxy_api_key', + prompt='Enter your Beaxy API key >>> ', + required_if=using_exchange('beaxy'), + is_secure=True, + is_connect_key=True), + 'beaxy_secret_key': + ConfigVar(key='beaxy_secret_key', + prompt='Enter your Beaxy secret key >>> ', + required_if=using_exchange('beaxy'), + is_secure=True, + is_connect_key=True), +} diff --git a/hummingbot/templates/conf_fee_overrides_TEMPLATE.yml b/hummingbot/templates/conf_fee_overrides_TEMPLATE.yml index 506eb80b09..27dc3d3d4f 100644 --- a/hummingbot/templates/conf_fee_overrides_TEMPLATE.yml +++ b/hummingbot/templates/conf_fee_overrides_TEMPLATE.yml @@ -3,7 +3,7 @@ ######################################## # For more detailed information: https://docs.hummingbot.io -template_version: 5 +template_version: 6 # Exchange trading fees, the values are in percentage value, e.g. 0.1 for 0.1%. # If the value is left blank, the default value (from corresponding market connector) will be used. @@ -11,6 +11,9 @@ template_version: 5 binance_maker_fee: binance_taker_fee: +beaxy_maker_fee: +beaxy_taker_fee: + coinbase_pro_maker_fee: coinbase_pro_taker_fee: diff --git a/hummingbot/templates/conf_global_TEMPLATE.yml b/hummingbot/templates/conf_global_TEMPLATE.yml index 8f77ed6df7..bb54338453 100644 --- a/hummingbot/templates/conf_global_TEMPLATE.yml +++ b/hummingbot/templates/conf_global_TEMPLATE.yml @@ -3,12 +3,15 @@ ################################# # For more detailed information: https://docs.hummingbot.io -template_version: 17 +template_version: 18 # Exchange configs bamboo_relay_use_coordinator: false bamboo_relay_pre_emptive_soft_cancels: false +beaxy_api_key: null +beaxy_secret_key: null + binance_api_key: null binance_api_secret: null diff --git a/setup.py b/setup.py index 538f026963..930334f845 100755 --- a/setup.py +++ b/setup.py @@ -55,6 +55,7 @@ def main(): "hummingbot.connector.exchange.liquid", "hummingbot.connector.exchange.dolomite", "hummingbot.connector.exchange.eterbase", + "hummingbot.connector.exchange.beaxy", "hummingbot.connector.exchange.bitmax", "hummingbot.connector.derivative", "hummingbot.connector.derivative.binance_perpetual", diff --git a/test/integration/assets/mock_data/fixture_beaxy.py b/test/integration/assets/mock_data/fixture_beaxy.py new file mode 100644 index 0000000000..03291bdf09 --- /dev/null +++ b/test/integration/assets/mock_data/fixture_beaxy.py @@ -0,0 +1,1346 @@ +class FixtureBeaxy: + + BALANCES = [ + { + "symbol": "BXYBTC", + "name": "BXYBTC", + "minimumQuantity": 2500.0, + "maximumQuantity": 250000.0, + "quantityIncrement": 1.0, + "quantityPrecision": 0, + "tickSize": 1e-08, + "baseCurrency": "BXY", + "termCurrency": "BTC", + "pricePrecision": 8, + "buyerTakerCommissionProgressive": 0.25, + "buyerMakerCommissionProgressive": 0.15, + "sellerTakerCommissionProgressive": 0.25, + "sellerMakerCommissionProgressive": 0.15, + "type": "crypto", + "suspendedForTrading": False, + }, + { + "symbol": "DASHBTC", + "name": "DASHBTC", + "minimumQuantity": 0.01, + "maximumQuantity": 218.32, + "quantityIncrement": 1e-05, + "quantityPrecision": 3, + "tickSize": 1e-06, + "baseCurrency": "DASH", + "termCurrency": "BTC", + "pricePrecision": 6, + "buyerTakerCommissionProgressive": 0.25, + "buyerMakerCommissionProgressive": 0.15, + "sellerTakerCommissionProgressive": 0.25, + "sellerMakerCommissionProgressive": 0.15, + "type": "crypto", + "suspendedForTrading": False, + }, + { + "symbol": "ETCBTC", + "name": "ETCBTC", + "minimumQuantity": 0.01, + "maximumQuantity": 2509.41, + "quantityIncrement": 1e-07, + "quantityPrecision": 7, + "tickSize": 1e-07, + "baseCurrency": "ETC", + "termCurrency": "BTC", + "pricePrecision": 7, + "buyerTakerCommissionProgressive": 0.25, + "buyerMakerCommissionProgressive": 0.15, + "sellerTakerCommissionProgressive": 0.25, + "sellerMakerCommissionProgressive": 0.15, + "type": "crypto", + "suspendedForTrading": False, + }, + { + "symbol": "GOBTC", + "name": "GOBTC", + "minimumQuantity": 50.0, + "maximumQuantity": 2000000.0, + "quantityIncrement": 0.001, + "quantityPrecision": 0, + "tickSize": 1e-08, + "baseCurrency": "GO", + "termCurrency": "BTC", + "pricePrecision": 8, + "buyerTakerCommissionProgressive": 0.25, + "buyerMakerCommissionProgressive": 0.15, + "sellerTakerCommissionProgressive": 0.25, + "sellerMakerCommissionProgressive": 0.15, + "type": "crypto", + "suspendedForTrading": False, + }, + { + "symbol": "BTCUSDC", + "name": "BTCUSDC", + "minimumQuantity": 0.001, + "maximumQuantity": 25.0, + "quantityIncrement": 1e-08, + "quantityPrecision": 8, + "tickSize": 0.01, + "baseCurrency": "BTC", + "termCurrency": "USDC", + "pricePrecision": 2, + "buyerTakerCommissionProgressive": 0.25, + "buyerMakerCommissionProgressive": 0.15, + "sellerTakerCommissionProgressive": 0.25, + "sellerMakerCommissionProgressive": 0.15, + "type": "crypto", + "suspendedForTrading": False, + }, + { + "symbol": "ZECBTC", + "name": "ZECBTC", + "minimumQuantity": 0.001, + "maximumQuantity": 369.21, + "quantityIncrement": 1e-05, + "quantityPrecision": 3, + "tickSize": 1e-06, + "baseCurrency": "ZEC", + "termCurrency": "BTC", + "pricePrecision": 6, + "buyerTakerCommissionProgressive": 0.25, + "buyerMakerCommissionProgressive": 0.15, + "sellerTakerCommissionProgressive": 0.25, + "sellerMakerCommissionProgressive": 0.15, + "type": "crypto", + "suspendedForTrading": False, + }, + { + "symbol": "BATBTC", + "name": "BATBTC", + "minimumQuantity": 0.1, + "maximumQuantity": 90909.09, + "quantityIncrement": 0.001, + "quantityPrecision": 6, + "tickSize": 1e-08, + "baseCurrency": "BAT", + "termCurrency": "BTC", + "pricePrecision": 8, + "buyerTakerCommissionProgressive": 0.25, + "buyerMakerCommissionProgressive": 0.15, + "sellerTakerCommissionProgressive": 0.25, + "sellerMakerCommissionProgressive": 0.15, + "type": "crypto", + "suspendedForTrading": False, + }, + { + "symbol": "XRPBTC", + "name": "XRPBTC", + "minimumQuantity": 0.1, + "maximumQuantity": 81632.65, + "quantityIncrement": 0.0001, + "quantityPrecision": 4, + "tickSize": 1e-08, + "baseCurrency": "XRP", + "termCurrency": "BTC", + "pricePrecision": 8, + "buyerTakerCommissionProgressive": 0.25, + "buyerMakerCommissionProgressive": 0.15, + "sellerTakerCommissionProgressive": 0.25, + "sellerMakerCommissionProgressive": 0.15, + "type": "crypto", + "suspendedForTrading": True, + }, + { + "symbol": "ALEPHETH", + "name": "ALEPHETH", + "minimumQuantity": 20.0, + "maximumQuantity": 50000.0, + "quantityIncrement": 1.0, + "quantityPrecision": 0, + "tickSize": 1e-08, + "baseCurrency": "ALEPH", + "termCurrency": "ETH", + "pricePrecision": 8, + "buyerTakerCommissionProgressive": 0.25, + "buyerMakerCommissionProgressive": 0.15, + "sellerTakerCommissionProgressive": 0.25, + "sellerMakerCommissionProgressive": 0.15, + "type": "crypto", + "suspendedForTrading": False, + }, + { + "symbol": "BTCUSD", + "name": "BTCUSD", + "minimumQuantity": 0.001, + "maximumQuantity": 25.0, + "quantityIncrement": 1e-08, + "quantityPrecision": 8, + "tickSize": 0.01, + "baseCurrency": "BTC", + "termCurrency": "USD", + "pricePrecision": 2, + "buyerTakerCommissionProgressive": 0.25, + "buyerMakerCommissionProgressive": 0.15, + "sellerTakerCommissionProgressive": 0.25, + "sellerMakerCommissionProgressive": 0.15, + "type": "fiat", + "suspendedForTrading": False, + }, + ] + + TRADE_BOOK = { + "type": "SNAPSHOT_FULL_REFRESH", + "security": "DASHBTC", + "timestamp": 1612206339765, + "sequenceNumber": 213148, + "entries": [ + { + "action": "INSERT", + "side": "ASK", + "level": 0, + "numberOfOrders": None, + "quantity": 0.5, + "price": 0.003065, + }, + { + "action": "INSERT", + "side": "ASK", + "level": 1, + "numberOfOrders": None, + "quantity": 1.25, + "price": 0.003066, + }, + { + "action": "INSERT", + "side": "ASK", + "level": 2, + "numberOfOrders": None, + "quantity": 2.5, + "price": 0.003067, + }, + { + "action": "INSERT", + "side": "ASK", + "level": 3, + "numberOfOrders": None, + "quantity": 5.0, + "price": 0.003068, + }, + { + "action": "INSERT", + "side": "ASK", + "level": 4, + "numberOfOrders": None, + "quantity": 7.5, + "price": 0.003069, + }, + { + "action": "INSERT", + "side": "BID", + "level": 0, + "numberOfOrders": None, + "quantity": 0.5, + "price": 0.003053, + }, + { + "action": "INSERT", + "side": "BID", + "level": 1, + "numberOfOrders": None, + "quantity": 1.25, + "price": 0.003052, + }, + { + "action": "INSERT", + "side": "BID", + "level": 2, + "numberOfOrders": None, + "quantity": 2.5, + "price": 0.003051, + }, + { + "action": "INSERT", + "side": "BID", + "level": 3, + "numberOfOrders": None, + "quantity": 5.0, + "price": 0.00305, + }, + { + "action": "INSERT", + "side": "BID", + "level": 4, + "numberOfOrders": None, + "quantity": 7.5, + "price": 0.003049, + }, + ], + } + + EXCHANGE_RATE = { + "ask": 0.003264, + "bid": 0.003256, + "low24": 0.00152, + "high24": 0.0055755, + "volume24": 0.0326, + "change24": -36.43365506483377, + "price": 0.003236, + "volume": 0.01, + "timestamp": 1612345560000, + } + + ACCOUNTS = [ + { + "measurement_currency_id": None, + "available_for_trading": "0", + "enter_average_price": None, + "current_price": None, + "unrealized_pnl": None, + "realized_pnl": None, + "balance": "0", + "status": "active", + "available_for_withdrawal": "0", + "total_statistics": None, + "currency_id": "ETH", + "id": "6F0C6353-B145-41A1-B695-2DCF4DFD8ADD", + "properties": None, + }, + { + "measurement_currency_id": None, + "available_for_trading": "0", + "enter_average_price": None, + "current_price": None, + "unrealized_pnl": None, + "realized_pnl": None, + "balance": "0", + "status": "active", + "available_for_withdrawal": "0", + "total_statistics": None, + "currency_id": "NRG", + "id": "FD739CEF-3FB7-4885-9A52-41A0A1C570EA", + "properties": None, + }, + { + "measurement_currency_id": None, + "available_for_trading": "0", + "enter_average_price": None, + "current_price": None, + "unrealized_pnl": None, + "realized_pnl": None, + "balance": "0", + "status": "active", + "available_for_withdrawal": "0", + "total_statistics": None, + "currency_id": "NEO", + "id": "20E33E7E-6940-4521-B3CB-DC75FD9DE0EA", + "properties": None, + }, + { + "measurement_currency_id": None, + "available_for_trading": "2500.19579418", + "enter_average_price": None, + "current_price": None, + "unrealized_pnl": None, + "realized_pnl": None, + "balance": "2500.19579418", + "status": "active", + "available_for_withdrawal": "2500.19579418", + "total_statistics": [ + { + "total": "0", + "total_this_day": "0", + "total_this_week": "0", + "total_this_month": "0", + "type": "deposit", + }, + { + "total": "0", + "total_this_day": "0", + "total_this_week": "0", + "total_this_month": "0", + "type": "withdrawal", + }, + { + "total": "7500", + "total_this_day": None, + "total_this_week": None, + "total_this_month": None, + "type": "credit", + }, + { + "total": "-4722", + "total_this_day": None, + "total_this_week": None, + "total_this_month": None, + "type": "execution", + }, + { + "total": "-277.80420582", + "total_this_day": None, + "total_this_week": None, + "total_this_month": None, + "type": "trading_commission", + }, + ], + "currency_id": "BXY", + "id": "1FB1DD29-406B-4227-AE2A-700D8EDCE001", + "properties": None, + }, + { + "measurement_currency_id": None, + "available_for_trading": "0", + "enter_average_price": None, + "current_price": None, + "unrealized_pnl": None, + "realized_pnl": None, + "balance": "0", + "status": "active", + "available_for_withdrawal": "0", + "total_statistics": None, + "currency_id": "AION", + "id": "64BFCB9A-1E19-4A71-AF2E-FE8344F66B56", + "properties": None, + }, + { + "measurement_currency_id": None, + "available_for_trading": "0", + "enter_average_price": None, + "current_price": None, + "unrealized_pnl": None, + "realized_pnl": None, + "balance": "0", + "status": "active", + "available_for_withdrawal": "0", + "total_statistics": None, + "currency_id": "WAVES", + "id": "F801485E-774A-472C-A4D4-CCEEF4AC4C21", + "properties": None, + }, + { + "measurement_currency_id": None, + "available_for_trading": "0", + "enter_average_price": None, + "current_price": None, + "unrealized_pnl": None, + "realized_pnl": None, + "balance": "0", + "status": "active", + "available_for_withdrawal": "0", + "total_statistics": None, + "currency_id": "ETC", + "id": "6D555386-2091-4C9E-B4FD-7F575F0D9753", + "properties": None, + }, + { + "measurement_currency_id": None, + "available_for_trading": "0", + "enter_average_price": None, + "current_price": None, + "unrealized_pnl": None, + "realized_pnl": None, + "balance": "0", + "status": "active", + "available_for_withdrawal": "0", + "total_statistics": None, + "currency_id": "ZRX", + "id": "C9C2C810-44AB-48FE-A81F-02B72F9FC87D", + "properties": None, + }, + { + "measurement_currency_id": None, + "available_for_trading": "0", + "enter_average_price": None, + "current_price": None, + "unrealized_pnl": None, + "realized_pnl": None, + "balance": "0", + "status": "active", + "available_for_withdrawal": "0", + "total_statistics": None, + "currency_id": "TOMO", + "id": "54F5AAB9-3608-46F1-BBA8-F0996FFACE0A", + "properties": None, + }, + { + "measurement_currency_id": None, + "available_for_trading": "0", + "enter_average_price": None, + "current_price": None, + "unrealized_pnl": None, + "realized_pnl": None, + "balance": "0", + "status": "active", + "available_for_withdrawal": "0", + "total_statistics": None, + "currency_id": "USDT", + "id": "A7383484-784E-48AB-8F8A-62304FF7C37A", + "properties": None, + }, + { + "measurement_currency_id": None, + "available_for_trading": "0.00011809", + "enter_average_price": None, + "current_price": None, + "unrealized_pnl": None, + "realized_pnl": None, + "balance": "0.00011809", + "status": "active", + "available_for_withdrawal": "0.00011809", + "total_statistics": [ + { + "total": "0", + "total_this_day": "0", + "total_this_week": "0", + "total_this_month": "0", + "type": "deposit", + }, + { + "total": "0", + "total_this_day": "0", + "total_this_week": "0", + "total_this_month": "0", + "type": "withdrawal", + }, + { + "total": "0.00017202", + "total_this_day": None, + "total_this_week": None, + "total_this_month": None, + "type": "execution", + }, + { + "total": "-0.00005393", + "total_this_day": None, + "total_this_week": None, + "total_this_month": None, + "type": "trading_commission", + }, + ], + "currency_id": "BTC", + "id": "4C49C9F6-A594-43F1-A351-22C8712596CA", + "properties": None, + }, + { + "measurement_currency_id": None, + "available_for_trading": "0", + "enter_average_price": None, + "current_price": None, + "unrealized_pnl": None, + "realized_pnl": None, + "balance": "0", + "status": "active", + "available_for_withdrawal": "0", + "total_statistics": None, + "currency_id": "BEAM", + "id": "B916B506-177A-4359-86F2-80D1845B22B5", + "properties": None, + }, + { + "measurement_currency_id": None, + "available_for_trading": "0", + "enter_average_price": None, + "current_price": None, + "unrealized_pnl": None, + "realized_pnl": None, + "balance": "0", + "status": "active", + "available_for_withdrawal": "0", + "total_statistics": None, + "currency_id": "BAT", + "id": "2200FC9C-B559-4C70-9605-926E28193605", + "properties": None, + }, + { + "measurement_currency_id": None, + "available_for_trading": "35.67478", + "enter_average_price": None, + "current_price": None, + "unrealized_pnl": None, + "realized_pnl": None, + "balance": "35.67478", + "status": "active", + "available_for_withdrawal": "35.67478", + "total_statistics": [ + { + "total": "0", + "total_this_day": "0", + "total_this_week": "0", + "total_this_month": "0", + "type": "deposit", + }, + { + "total": "0", + "total_this_day": "0", + "total_this_week": "0", + "total_this_month": "0", + "type": "withdrawal", + }, + { + "total": "80", + "total_this_day": None, + "total_this_week": None, + "total_this_month": None, + "type": "credit", + }, + { + "total": "-44.32522", + "total_this_day": None, + "total_this_week": None, + "total_this_month": None, + "type": "execution", + }, + ], + "currency_id": "USDC", + "id": "9B2ADE73-CE5D-45EE-9788-3587DDCF58F2", + "properties": None, + }, + { + "measurement_currency_id": None, + "available_for_trading": "0", + "enter_average_price": None, + "current_price": None, + "unrealized_pnl": None, + "realized_pnl": None, + "balance": "0", + "status": "active", + "available_for_withdrawal": "0", + "total_statistics": None, + "currency_id": "XRP", + "id": "71AF49AC-54A4-4F5A-A3A1-97CFEFE40430", + "properties": None, + }, + { + "measurement_currency_id": None, + "available_for_trading": "0", + "enter_average_price": None, + "current_price": None, + "unrealized_pnl": None, + "realized_pnl": None, + "balance": "0", + "status": "active", + "available_for_withdrawal": "0", + "total_statistics": None, + "currency_id": "LTC", + "id": "8FA31F5A-7A94-4CF6-BA95-9990E9B4C76A", + "properties": None, + }, + { + "measurement_currency_id": None, + "available_for_trading": "0", + "enter_average_price": None, + "current_price": None, + "unrealized_pnl": None, + "realized_pnl": None, + "balance": "0", + "status": "active", + "available_for_withdrawal": "0", + "total_statistics": None, + "currency_id": "GUNTHY", + "id": "974AB4C0-6EDE-4EFF-BCA1-1F8FAEE93AA6", + "properties": None, + }, + { + "measurement_currency_id": None, + "available_for_trading": "0", + "enter_average_price": None, + "current_price": None, + "unrealized_pnl": None, + "realized_pnl": None, + "balance": "0", + "status": "active", + "available_for_withdrawal": "0", + "total_statistics": None, + "currency_id": "ICX", + "id": "D2CF87AB-754E-4FCD-BB42-7FC812931402", + "properties": None, + }, + { + "measurement_currency_id": None, + "available_for_trading": "0", + "enter_average_price": None, + "current_price": None, + "unrealized_pnl": None, + "realized_pnl": None, + "balance": "0", + "status": "active", + "available_for_withdrawal": "0", + "total_statistics": None, + "currency_id": "DRGN", + "id": "53BDA6C6-5C66-4BD7-BE63-D547699525A4", + "properties": None, + }, + { + "measurement_currency_id": None, + "available_for_trading": "0", + "enter_average_price": None, + "current_price": None, + "unrealized_pnl": None, + "realized_pnl": None, + "balance": "0", + "status": "active", + "available_for_withdrawal": "0", + "total_statistics": None, + "currency_id": "ALEPH", + "id": "3D0863E2-7AE9-4478-ABED-1FECAF86B639", + "properties": None, + }, + { + "measurement_currency_id": None, + "available_for_trading": "0", + "enter_average_price": None, + "current_price": None, + "unrealized_pnl": None, + "realized_pnl": None, + "balance": "0", + "status": "active", + "available_for_withdrawal": "0", + "total_statistics": None, + "currency_id": "POLY", + "id": "91A87666-6590-45A8-B70F-FA28EFD32716", + "properties": None, + }, + { + "measurement_currency_id": None, + "available_for_trading": "0.05", + "enter_average_price": None, + "current_price": None, + "unrealized_pnl": None, + "realized_pnl": None, + "balance": "0.05", + "status": "active", + "available_for_withdrawal": "0.05", + "total_statistics": [ + { + "total": "0", + "total_this_day": "0", + "total_this_week": "0", + "total_this_month": "0", + "type": "deposit", + }, + { + "total": "0", + "total_this_day": "0", + "total_this_week": "0", + "total_this_month": "0", + "type": "withdrawal", + }, + { + "total": "0.05", + "total_this_day": None, + "total_this_week": None, + "total_this_month": None, + "type": "execution", + }, + ], + "currency_id": "DASH", + "id": "0B0C8512-A113-4D57-B4C6-D059746B302D", + "properties": None, + }, + { + "measurement_currency_id": None, + "available_for_trading": "0", + "enter_average_price": None, + "current_price": None, + "unrealized_pnl": None, + "realized_pnl": None, + "balance": "0", + "status": "active", + "available_for_withdrawal": "0", + "total_statistics": None, + "currency_id": "LINK", + "id": "4B4E0955-4DA7-404F-98AE-D0AFF654A0E7", + "properties": None, + }, + { + "measurement_currency_id": None, + "available_for_trading": "0", + "enter_average_price": None, + "current_price": None, + "unrealized_pnl": None, + "realized_pnl": None, + "balance": "0", + "status": "active", + "available_for_withdrawal": "0", + "total_statistics": None, + "currency_id": "FTM", + "id": "90030F73-36FE-4651-9A59-5E57464F64E6", + "properties": None, + }, + { + "measurement_currency_id": None, + "available_for_trading": "0", + "enter_average_price": None, + "current_price": None, + "unrealized_pnl": None, + "realized_pnl": None, + "balance": "0", + "status": "active", + "available_for_withdrawal": "0", + "total_statistics": None, + "currency_id": "ZEC", + "id": "44ED1E64-BC45-45DF-B186-2444B8279354", + "properties": None, + }, + { + "measurement_currency_id": None, + "available_for_trading": "0", + "enter_average_price": None, + "current_price": None, + "unrealized_pnl": None, + "realized_pnl": None, + "balance": "0", + "status": "active", + "available_for_withdrawal": "0", + "total_statistics": None, + "currency_id": "BSV", + "id": "35A5F10B-1A9E-47E4-BA27-8918D1A16F8E", + "properties": None, + }, + { + "measurement_currency_id": None, + "available_for_trading": "0", + "enter_average_price": None, + "current_price": None, + "unrealized_pnl": None, + "realized_pnl": None, + "balance": "0", + "status": "active", + "available_for_withdrawal": "0", + "total_statistics": None, + "currency_id": "HIVE", + "id": "0E525961-F6D2-422F-87C1-1A82E325DC8A", + "properties": None, + }, + { + "measurement_currency_id": None, + "available_for_trading": "0", + "enter_average_price": None, + "current_price": None, + "unrealized_pnl": None, + "realized_pnl": None, + "balance": "0", + "status": "active", + "available_for_withdrawal": "0", + "total_statistics": None, + "currency_id": "BCH", + "id": "8A409D82-D049-41C1-A4D2-5F4BA3AF0432", + "properties": None, + }, + { + "measurement_currency_id": None, + "available_for_trading": "0", + "enter_average_price": None, + "current_price": None, + "unrealized_pnl": None, + "realized_pnl": None, + "balance": "0", + "status": "active", + "available_for_withdrawal": "0", + "total_statistics": None, + "currency_id": "GO", + "id": "DA08BE6A-83B7-481A-BD06-5A2BE79E80E6", + "properties": None, + }, + { + "measurement_currency_id": None, + "available_for_trading": "0", + "enter_average_price": None, + "current_price": None, + "unrealized_pnl": None, + "realized_pnl": None, + "balance": "0", + "status": "active", + "available_for_withdrawal": "0", + "total_statistics": None, + "currency_id": "XSN", + "id": "03DD4CB5-3BB7-4C74-9FEA-C2CC73123ECE", + "properties": None, + }, + { + "measurement_currency_id": None, + "available_for_trading": "0", + "enter_average_price": None, + "current_price": None, + "unrealized_pnl": None, + "realized_pnl": None, + "balance": "0", + "status": "active", + "available_for_withdrawal": "0", + "total_statistics": None, + "currency_id": "EOS", + "id": "441649A4-C8A8-445F-8B91-182B5EC9F20D", + "properties": None, + }, + { + "measurement_currency_id": None, + "available_for_trading": "0", + "enter_average_price": None, + "current_price": None, + "unrealized_pnl": None, + "realized_pnl": None, + "balance": "0", + "status": "active", + "available_for_withdrawal": "0", + "total_statistics": None, + "currency_id": "XMR", + "id": "81444BA4-EC69-42FE-AD54-09500335788B", + "properties": None, + }, + ] + + HEALTH = {"logins": 204, "is_alive": True, "users": 10, "timestamp": 1612207143392} + + SNAPSHOT_MSG = { + "type": "SNAPSHOT_FULL_REFRESH", + "security": "DASHBTC", + "timestamp": 1612345351563, + "sequenceNumber": 42820, + "entries": [ + { + "action": "INSERT", + "side": "ASK", + "level": 0, + "numberOfOrders": None, + "quantity": 0.5, + "price": 0.003251, + }, + { + "action": "INSERT", + "side": "ASK", + "level": 1, + "numberOfOrders": None, + "quantity": 1.25, + "price": 0.003252, + }, + { + "action": "INSERT", + "side": "ASK", + "level": 2, + "numberOfOrders": None, + "quantity": 2.5, + "price": 0.003253, + }, + { + "action": "INSERT", + "side": "ASK", + "level": 3, + "numberOfOrders": None, + "quantity": 5.0, + "price": 0.003255, + }, + { + "action": "INSERT", + "side": "ASK", + "level": 4, + "numberOfOrders": None, + "quantity": 7.5, + "price": 0.003256, + }, + { + "action": "INSERT", + "side": "ASK", + "level": 5, + "numberOfOrders": None, + "quantity": 10.0, + "price": 0.003257, + }, + { + "action": "INSERT", + "side": "ASK", + "level": 6, + "numberOfOrders": None, + "quantity": 12.5, + "price": 0.003259, + }, + { + "action": "INSERT", + "side": "ASK", + "level": 7, + "numberOfOrders": None, + "quantity": 20.466, + "price": 0.003264, + }, + { + "action": "INSERT", + "side": "ASK", + "level": 8, + "numberOfOrders": None, + "quantity": 0.5, + "price": 0.00808, + }, + { + "action": "INSERT", + "side": "BID", + "level": 0, + "numberOfOrders": None, + "quantity": 0.4994, + "price": 0.00324, + }, + { + "action": "INSERT", + "side": "BID", + "level": 1, + "numberOfOrders": None, + "quantity": 1.25, + "price": 0.003238, + }, + { + "action": "INSERT", + "side": "BID", + "level": 2, + "numberOfOrders": None, + "quantity": 2.5, + "price": 0.003237, + }, + { + "action": "INSERT", + "side": "BID", + "level": 3, + "numberOfOrders": None, + "quantity": 5.0, + "price": 0.003236, + }, + { + "action": "INSERT", + "side": "BID", + "level": 4, + "numberOfOrders": None, + "quantity": 7.5, + "price": 0.003235, + }, + { + "action": "INSERT", + "side": "BID", + "level": 5, + "numberOfOrders": None, + "quantity": 10.0, + "price": 0.003234, + }, + { + "action": "INSERT", + "side": "BID", + "level": 6, + "numberOfOrders": None, + "quantity": 12.5, + "price": 0.003233, + }, + { + "action": "INSERT", + "side": "BID", + "level": 7, + "numberOfOrders": None, + "quantity": 0.533, + "price": 0.003229, + }, + { + "action": "INSERT", + "side": "BID", + "level": 8, + "numberOfOrders": None, + "quantity": 0.55068, + "price": 0.002249, + }, + { + "action": "INSERT", + "side": "BID", + "level": 9, + "numberOfOrders": None, + "quantity": 134.24942, + "price": 1.4e-5, + }, + { + "action": "INSERT", + "side": "BID", + "level": 10, + "numberOfOrders": None, + "quantity": 60.0, + "price": 1.3e-5, + }, + { + "action": "INSERT", + "side": "BID", + "level": 11, + "numberOfOrders": None, + "quantity": 6.0, + "price": 1.0e-5, + }, + { + "action": "INSERT", + "side": "BID", + "level": 12, + "numberOfOrders": None, + "quantity": 200.0, + "price": 6.0e-6, + }, + ], + } + + TEST_LIMIT_BUY_ORDER = { + "average_price": "0", + "receipt_time": 1612351106032, + "close_time": 0, + "reason": None, + "cumulative_quantity": "0", + "remaining_quantity": "0.01", + "status": "pending_new", + "id": "1D37D726-E162-484B-8816-A43B43549CDD", + "timestamp": 1612351106032, + "stop_price": None, + "leverage": None, + "submission_time": None, + "quantity": "0.01", + "limit_price": "0.003266", + "client_order_id": "1D37D726-E162-484B-8816-A43B43549CDD", + "time_in_force": "gtc", + "price": "0.003266", + "expire_time": None, + "text": "HBOT-buy-DASH-BTC-1", + "destination": "MAXI", + "security_id": "DASHBTC", + "side": "buy", + "type": "limit", + "source": "CWUI", + "currency": None, + "properties": None, + } + TEST_LIMIT_BUY_WS_ORDER_CREATED = """MESSAGE\nstatus:200\ndestination:/v1/orders-user8ea365d0-24a6-83ed-ec03-59bac214fe97\ncontent-type:application/json\nsubscription:sub-humming-1612351104275342\nmessage-id:8ea365d0-24a6-83ed-ec03-59bac214fe97-18\ncontent-length:1397\n\n{"events":[{"trade_quantity":null,"trade_price":null,"commission":null,"original_client_order_id":null,"is_agressor":null,"order_side":"buy","event_id":"13215876","average_price":"0","stop_price":null,"leverage":null,"quantity":"0.01","limit_price":"0.003266","client_order_id":"1D37D726-E162-484B-8816-A43B43549CDD","time_in_force":"gtc","price":"0.003266","expire_time":null,"reason":null,"order_id":"1D37D726-E162-484B-8816-A43B43549CDD","cumulative_quantity":"0","remaining_quantity":"0.01","order_type":"limit","order_status":"new","commission_currency":null,"text":"HBOT-buy-DASH-BTC-1","exchange_id":null,"destination":"MAXI","security_id":"DASHBTC","id":"BTC-8965","type":"submitted","sequence_number":8965,"currency":"DASH","timestamp":1612351106037,"properties":null}],"order":{"average_price":"0","receipt_time":1612351106025,"close_time":0,"reason":null,"cumulative_quantity":"0","remaining_quantity":"0.01","status":"new","id":"1D37D726-E162-484B-8816-A43B43549CDD","timestamp":1612351106037,"stop_price":null,"leverage":null,"submission_time":null,"quantity":"0.01","limit_price":"0.003266","client_order_id":"1D37D726-E162-484B-8816-A43B43549CDD","time_in_force":"gtc","price":"0.003266","expire_time":null,"text":"HBOT-buy-DASH-BTC-1","destination":"MAXI","security_id":"DASHBTC","side":"buy","type":"limit","source":"CWUI","currency":"DASH","properties":null}}""" + TEST_LIMIT_BUY_WS_ORDER_COMPLETED = """MESSAGE\nstatus:200\ndestination:/v1/orders-user9d827cdb-52b2-3303-6eba-615683c76ddf\ncontent-type:application/json\nsubscription:sub-humming-1612347898789940\nmessage-id:9d827cdb-52b2-3303-6eba-615683c76ddf-17\ncontent-length:1535\n\n{"events":[{"trade_quantity":"0.01","trade_price":"0.003297","commission":"-0.00000008","original_client_order_id":null,"is_agressor":true,"order_side":"buy","event_id":"0000000000BDBED4","average_price":"0.003297","stop_price":null,"leverage":null,"quantity":"0.01","limit_price":"0.003297","client_order_id":"C715ECA9-D13C-42AE-A978-083F62974B85","time_in_force":"gtc","price":"0.003297","expire_time":null,"reason":null,"order_id":"C715ECA9-D13C-42AE-A978-083F62974B85","cumulative_quantity":"0.01","remaining_quantity":"0","order_type":"limit","order_status":"completely_filled","commission_currency":"BTC","text":"HBOT-buy-DASH-BTC-1","exchange_id":null,"destination":"MAXI","security_id":"DASHBTC","id":"BTC-8938","type":"trade","sequence_number":8938,"currency":"DASH","timestamp":1612347915748,"properties":[{"key":"1115","value":"Order"}]}],"order":{"average_price":"0.003297","receipt_time":1612347915627,"close_time":1612347915748,"reason":null,"cumulative_quantity":"0.01","remaining_quantity":"0","status":"completely_filled","id":"C715ECA9-D13C-42AE-A978-083F62974B85","timestamp":1612347915748,"stop_price":null,"leverage":null,"submission_time":null,"quantity":"0.01","limit_price":"0.003297","client_order_id":"C715ECA9-D13C-42AE-A978-083F62974B85","time_in_force":"gtc","price":"0.003297","expire_time":null,"text":"HBOT-buy-DASH-BTC-1","destination":"MAXI","security_id":"DASHBTC","side":"buy","type":"limit","source":"CWUI","currency":"DASH","properties":[{"key":"7224","value":"-0.00000008"}]}}""" + + TEST_LIMIT_SELL_ORDER = { + "average_price": "0", + "receipt_time": 1612367046013, + "close_time": 0, + "reason": None, + "cumulative_quantity": "0", + "remaining_quantity": "0.01", + "status": "pending_new", + "id": "E5BA149A-9DD7-4C2E-A2E8-1C37DF6C5393", + "timestamp": 1612367046013, + "stop_price": None, + "leverage": None, + "submission_time": None, + "quantity": "0.01", + "limit_price": "0.003118", + "client_order_id": "E5BA149A-9DD7-4C2E-A2E8-1C37DF6C5393", + "time_in_force": "gtc", + "price": "0.003118", + "expire_time": None, + "text": "HBOT-sell-DASH-BTC-1", + "destination": "MAXI", + "security_id": "DASHBTC", + "side": "sell", + "type": "limit", + "source": "CWUI", + "currency": None, + "properties": None, + } + TEST_LIMIT_SELL_WS_ORDER_CREATED = """MESSAGE\nstatus:200\ndestination:/v1/orders-user9cb6b7b3-b47b-97ac-59dd-76e5b33b5953\ncontent-type:application/json\nsubscription:sub-humming-1612367042528182\nmessage-id:9cb6b7b3-b47b-97ac-59dd-76e5b33b5953-20\ncontent-length:1402\n\n{"events":[{"trade_quantity":null,"trade_price":null,"commission":null,"original_client_order_id":null,"is_agressor":null,"order_side":"sell","event_id":"18102008","average_price":"0","stop_price":null,"leverage":null,"quantity":"0.01","limit_price":"0.003118","client_order_id":"E5BA149A-9DD7-4C2E-A2E8-1C37DF6C5393","time_in_force":"gtc","price":"0.003118","expire_time":null,"reason":null,"order_id":"E5BA149A-9DD7-4C2E-A2E8-1C37DF6C5393","cumulative_quantity":"0","remaining_quantity":"0.01","order_type":"limit","order_status":"new","commission_currency":null,"text":"HBOT-sell-DASH-BTC-1","exchange_id":null,"destination":"MAXI","security_id":"DASHBTC","id":"DASH-8998","type":"submitted","sequence_number":8998,"currency":"DASH","timestamp":1612367046019,"properties":null}],"order":{"average_price":"0","receipt_time":1612367046011,"close_time":0,"reason":null,"cumulative_quantity":"0","remaining_quantity":"0.01","status":"new","id":"E5BA149A-9DD7-4C2E-A2E8-1C37DF6C5393","timestamp":1612367046019,"stop_price":null,"leverage":null,"submission_time":null,"quantity":"0.01","limit_price":"0.003118","client_order_id":"E5BA149A-9DD7-4C2E-A2E8-1C37DF6C5393","time_in_force":"gtc","price":"0.003118","expire_time":null,"text":"HBOT-sell-DASH-BTC-1","destination":"MAXI","security_id":"DASHBTC","side":"sell","type":"limit","source":"CWUI","currency":"DASH","properties":null}}""" + TEST_LIMIT_SELL_WS_ORDER_COMPLETED = """MESSAGE\nstatus:200\ndestination:/v1/orders-user9cb6b7b3-b47b-97ac-59dd-76e5b33b5953\ncontent-type:application/json\nsubscription:sub-humming-1612367042528182\nmessage-id:9cb6b7b3-b47b-97ac-59dd-76e5b33b5953-21\ncontent-length:1552\n\n{"events":[{"trade_quantity":"0.01","trade_price":"0.003118","commission":"-0.00000008","original_client_order_id":null,"is_agressor":true,"order_side":"sell","event_id":"0000000001143A1A","average_price":"0.003118","stop_price":null,"leverage":null,"quantity":"0.01","limit_price":"0.003118","client_order_id":"E5BA149A-9DD7-4C2E-A2E8-1C37DF6C5393","time_in_force":"gtc","price":"0.003118","expire_time":null,"reason":null,"order_id":"E5BA149A-9DD7-4C2E-A2E8-1C37DF6C5393","cumulative_quantity":"0.01","remaining_quantity":"0","order_type":"limit","order_status":"completely_filled","commission_currency":"BTC","text":"HBOT-sell-DASH-BTC-1","exchange_id":null,"destination":"MAXI","security_id":"DASHBTC","id":"DASH-9004","type":"trade","sequence_number":9004,"currency":"DASH","timestamp":1612367046020,"properties":[{"key":"1115","value":"Order"}]}],"order":{"average_price":"0.003118","receipt_time":1612367046011,"close_time":1612367046020,"reason":null,"cumulative_quantity":"0.01","remaining_quantity":"0","status":"completely_filled","id":"E5BA149A-9DD7-4C2E-A2E8-1C37DF6C5393","timestamp":1612367046020,"stop_price":null,"leverage":null,"submission_time":null,"quantity":"0.01","limit_price":"0.003118","client_order_id":"E5BA149A-9DD7-4C2E-A2E8-1C37DF6C5393","time_in_force":"gtc","price":"0.003118","expire_time":null,"text":"HBOT-sell-DASH-BTC-1","destination":"MAXI","security_id":"DASHBTC","side":"sell","type":"limit","source":"CWUI","currency":"DASH","properties":[{"key":"7224","value":"-0.00002565747273893521"}]}}""" + + TEST_UNFILLED_ORDER1 = { + "average_price": "0", + "receipt_time": 1612368513007, + "close_time": 0, + "reason": None, + "cumulative_quantity": "0", + "remaining_quantity": "0.01", + "status": "pending_new", + "id": "3DE4D10B-6882-4BF8-958A-EA689C145065", + "timestamp": 1612368513007, + "stop_price": None, + "leverage": None, + "submission_time": None, + "quantity": "0.01", + "limit_price": "0.002496", + "client_order_id": "3DE4D10B-6882-4BF8-958A-EA689C145065", + "time_in_force": "gtc", + "price": "0.002496", + "expire_time": None, + "text": "HBOT-buy-DASH-BTC-1612368513006601", + "destination": "MAXI", + "security_id": "DASHBTC", + "side": "buy", + "type": "limit", + "source": "CWUI", + "currency": None, + "properties": None, + } + TEST_UNFILLED_ORDER2 = { + "average_price": "0", + "receipt_time": 1612368513007, + "close_time": 0, + "reason": None, + "cumulative_quantity": "0", + "remaining_quantity": "0.01", + "status": "pending_new", + "id": "3DE4D10B-6882-4BF8-958A-EA689C145065", + "timestamp": 1612368513007, + "stop_price": None, + "leverage": None, + "submission_time": None, + "quantity": "0.01", + "limit_price": "0.002496", + "client_order_id": "3DE4D10B-6882-4BF8-958A-EA689C145065", + "time_in_force": "gtc", + "price": "0.002496", + "expire_time": None, + "text": "HBOT-buy-DASH-BTC-1612368513006601", + "destination": "MAXI", + "security_id": "DASHBTC", + "side": "buy", + "type": "limit", + "source": "CWUI", + "currency": None, + "properties": None, + } + TEST_UNFILLED_ORDER1_WS_ORDER_CREATED = """MESSAGE\nstatus:200\ndestination:/v1/orders-userb0ad4644-7c10-565c-84d0-e1dc576a06f2\ncontent-type:application/json\nsubscription:sub-humming-1612368511313960\nmessage-id:b0ad4644-7c10-565c-84d0-e1dc576a06f2-26\ncontent-length:1397\n\n{"events":[{"trade_quantity":null,"trade_price":null,"commission":null,"original_client_order_id":null,"is_agressor":null,"order_side":"buy","event_id":"18699442","average_price":"0","stop_price":null,"leverage":null,"quantity":"0.01","limit_price":"0.002496","client_order_id":"3DE4D10B-6882-4BF8-958A-EA689C145065","time_in_force":"gtc","price":"0.002496","expire_time":null,"reason":null,"order_id":"3DE4D10B-6882-4BF8-958A-EA689C145065","cumulative_quantity":"0","remaining_quantity":"0.01","order_type":"limit","order_status":"new","commission_currency":null,"text":"HBOT-buy-DASH-BTC-1612368513006601","exchange_id":null,"destination":"MAXI","security_id":"DASHBTC","id":"BTC-9067","type":"submitted","sequence_number":9067,"currency":"DASH","timestamp":1612368513011,"properties":null}],"order":{"average_price":"0","receipt_time":1612368512949,"close_time":0,"reason":null,"cumulative_quantity":"0","remaining_quantity":"0.01","status":"new","id":"3DE4D10B-6882-4BF8-958A-EA689C145065","timestamp":1612368513011,"stop_price":null,"leverage":null,"submission_time":null,"quantity":"0.01","limit_price":"0.002496","client_order_id":"3DE4D10B-6882-4BF8-958A-EA689C145065","time_in_force":"gtc","price":"0.002496","expire_time":null,"text":"HBOT-buy-DASH-BTC-1612368513006601","destination":"MAXI","security_id":"DASHBTC","side":"buy","type":"limit","source":"CWUI","currency":"DASH","properties":null}}""" + TEST_UNFILLED_ORDER2_WS_ORDER_CREATED = """MESSAGE\nstatus:200\ndestination:/v1/orders-userb0ad4644-7c10-565c-84d0-e1dc576a06f2\ncontent-type:application/json\nsubscription:sub-humming-1612368511313960\nmessage-id:b0ad4644-7c10-565c-84d0-e1dc576a06f2-27\ncontent-length:1398\n\n{"events":[{"trade_quantity":null,"trade_price":null,"commission":null,"original_client_order_id":null,"is_agressor":null,"order_side":"sell","event_id":"18699800","average_price":"0","stop_price":null,"leverage":null,"quantity":"0.01","limit_price":"0.00311","client_order_id":"9C4B5661-7C66-4AEF-AD5D-F6E813228F2A","time_in_force":"gtc","price":"0.00311","expire_time":null,"reason":null,"order_id":"9C4B5661-7C66-4AEF-AD5D-F6E813228F2A","cumulative_quantity":"0","remaining_quantity":"0.01","order_type":"limit","order_status":"new","commission_currency":null,"text":"HBOT-sell-DASH-BTC-1612368514001761","exchange_id":null,"destination":"MAXI","security_id":"DASHBTC","id":"DASH-9078","type":"submitted","sequence_number":9078,"currency":"DASH","timestamp":1612368514021,"properties":null}],"order":{"average_price":"0","receipt_time":1612368513974,"close_time":0,"reason":null,"cumulative_quantity":"0","remaining_quantity":"0.01","status":"new","id":"9C4B5661-7C66-4AEF-AD5D-F6E813228F2A","timestamp":1612368514021,"stop_price":null,"leverage":null,"submission_time":null,"quantity":"0.01","limit_price":"0.00311","client_order_id":"9C4B5661-7C66-4AEF-AD5D-F6E813228F2A","time_in_force":"gtc","price":"0.00311","expire_time":null,"text":"HBOT-sell-DASH-BTC-1612368514001761","destination":"MAXI","security_id":"DASHBTC","side":"sell","type":"limit","source":"CWUI","currency":"DASH","properties":null}}""" + TEST_UNFILLED_ORDER1_WS_ORDER_CANCELED = """MESSAGE\nstatus:200\ndestination:/v1/orders-userb0ad4644-7c10-565c-84d0-e1dc576a06f2\ncontent-type:application/json\nsubscription:sub-humming-1612368511313960\nmessage-id:b0ad4644-7c10-565c-84d0-e1dc576a06f2-28\ncontent-length:1497\n\n{"events":[{"trade_quantity":null,"trade_price":null,"commission":null,"original_client_order_id":null,"is_agressor":null,"order_side":"buy","event_id":"18701450","average_price":"0","stop_price":null,"leverage":null,"quantity":"0.01","limit_price":"0.002496","client_order_id":"3DE4D10B-6882-4BF8-958A-EA689C145065","time_in_force":"gtc","price":"0.002496","expire_time":null,"reason":"Manual cancelation from tradingapi.beaxy.com","order_id":"3DE4D10B-6882-4BF8-958A-EA689C145065","cumulative_quantity":"0","remaining_quantity":"0","order_type":"limit","order_status":"canceled","commission_currency":null,"text":"HBOT-buy-DASH-BTC-1612368513006601","exchange_id":null,"destination":"MAXI","security_id":"DASHBTC","id":"BTC-9089","type":"cancel","sequence_number":9089,"currency":"DASH","timestamp":1612368517010,"properties":null}],"order":{"average_price":"0","receipt_time":1612368512949,"close_time":1612368517010,"reason":"Manual cancelation from tradingapi.beaxy.com","cumulative_quantity":"0","remaining_quantity":"0.01","status":"canceled","id":"3DE4D10B-6882-4BF8-958A-EA689C145065","timestamp":1612368517010,"stop_price":null,"leverage":null,"submission_time":null,"quantity":"0.01","limit_price":"0.002496","client_order_id":"3DE4D10B-6882-4BF8-958A-EA689C145065","time_in_force":"gtc","price":"0.002496","expire_time":null,"text":"HBOT-buy-DASH-BTC-1612368513006601","destination":"MAXI","security_id":"DASHBTC","side":"buy","type":"limit","source":"CWUI","currency":"DASH","properties":null}}""" + TEST_UNFILLED_ORDER2_WS_ORDER_CANCELED = """MESSAGE\nstatus:200\ndestination:/v1/orders-userb0ad4644-7c10-565c-84d0-e1dc576a06f2\ncontent-type:application/json\nsubscription:sub-humming-1612368511313960\nmessage-id:b0ad4644-7c10-565c-84d0-e1dc576a06f2-29\ncontent-length:1498\n\n{"events":[{"trade_quantity":null,"trade_price":null,"commission":null,"original_client_order_id":null,"is_agressor":null,"order_side":"sell","event_id":"18701522","average_price":"0","stop_price":null,"leverage":null,"quantity":"0.01","limit_price":"0.00311","client_order_id":"9C4B5661-7C66-4AEF-AD5D-F6E813228F2A","time_in_force":"gtc","price":"0.00311","expire_time":null,"reason":"Manual cancelation from tradingapi.beaxy.com","order_id":"9C4B5661-7C66-4AEF-AD5D-F6E813228F2A","cumulative_quantity":"0","remaining_quantity":"0","order_type":"limit","order_status":"canceled","commission_currency":null,"text":"HBOT-sell-DASH-BTC-1612368514001761","exchange_id":null,"destination":"MAXI","security_id":"DASHBTC","id":"DASH-9100","type":"cancel","sequence_number":9100,"currency":"DASH","timestamp":1612368517226,"properties":null}],"order":{"average_price":"0","receipt_time":1612368513974,"close_time":1612368517226,"reason":"Manual cancelation from tradingapi.beaxy.com","cumulative_quantity":"0","remaining_quantity":"0.01","status":"canceled","id":"9C4B5661-7C66-4AEF-AD5D-F6E813228F2A","timestamp":1612368517226,"stop_price":null,"leverage":null,"submission_time":null,"quantity":"0.01","limit_price":"0.00311","client_order_id":"9C4B5661-7C66-4AEF-AD5D-F6E813228F2A","time_in_force":"gtc","price":"0.00311","expire_time":null,"text":"HBOT-sell-DASH-BTC-1612368514001761","destination":"MAXI","security_id":"DASHBTC","side":"sell","type":"limit","source":"CWUI","currency":"DASH","properties":null}}""" + + TEST_MARKET_BUY_ORDER = { + "average_price": "0", + "receipt_time": 1612371213996, + "close_time": 0, + "reason": None, + "cumulative_quantity": "0", + "remaining_quantity": "0.01", + "status": "pending_new", + "id": "E7444777-4AAC-43D9-9AF2-61BE027D42ED", + "timestamp": 1612371213996, + "stop_price": None, + "leverage": None, + "submission_time": None, + "quantity": "0.01", + "limit_price": None, + "client_order_id": "E7444777-4AAC-43D9-9AF2-61BE027D42ED", + "time_in_force": "ioc", + "price": None, + "expire_time": None, + "text": "HBOT-buy-DASH-BTC-1", + "destination": "MAXI", + "security_id": "DASHBTC", + "side": "buy", + "type": "market", + "source": "CWUI", + "currency": None, + "properties": None, + } + TEST_MARKET_BUY_WS_ORDER_CREATED = """MESSAGE\nstatus:200\ndestination:/v1/orders-userc2959dab-c524-e54c-b973-1aec61426cce\ncontent-type:application/json\nsubscription:sub-humming-1612371211987894\nmessage-id:c2959dab-c524-e54c-b973-1aec61426cce-35\ncontent-length:1375\n\n{"events":[{"trade_quantity":null,"trade_price":null,"commission":null,"original_client_order_id":null,"is_agressor":null,"order_side":"buy","event_id":"19742404","average_price":"0","stop_price":null,"leverage":null,"quantity":"0.01","limit_price":null,"client_order_id":"E7444777-4AAC-43D9-9AF2-61BE027D42ED","time_in_force":"ioc","price":null,"expire_time":null,"reason":null,"order_id":"E7444777-4AAC-43D9-9AF2-61BE027D42ED","cumulative_quantity":"0","remaining_quantity":"0.01","order_type":"market","order_status":"new","commission_currency":null,"text":"HBOT-buy-DASH-BTC-1","exchange_id":null,"destination":"MAXI","security_id":"DASHBTC","id":"BTC-9229","type":"submitted","sequence_number":9229,"currency":"DASH","timestamp":1612371214001,"properties":null}],"order":{"average_price":"0","receipt_time":1612371213978,"close_time":0,"reason":null,"cumulative_quantity":"0","remaining_quantity":"0.01","status":"new","id":"E7444777-4AAC-43D9-9AF2-61BE027D42ED","timestamp":1612371214001,"stop_price":null,"leverage":null,"submission_time":null,"quantity":"0.01","limit_price":null,"client_order_id":"E7444777-4AAC-43D9-9AF2-61BE027D42ED","time_in_force":"ioc","price":null,"expire_time":null,"text":"HBOT-buy-DASH-BTC-1","destination":"MAXI","security_id":"DASHBTC","side":"buy","type":"market","source":"CWUI","currency":"DASH","properties":null}}""" + TEST_MARKET_BUY_WS_ORDER_COMPLETED = """MESSAGE\nstatus:200\ndestination:/v1/orders-userc2959dab-c524-e54c-b973-1aec61426cce\ncontent-type:application/json\nsubscription:sub-humming-1612371211987894\nmessage-id:c2959dab-c524-e54c-b973-1aec61426cce-36\ncontent-length:1513\n\n{"events":[{"trade_quantity":"0.01","trade_price":"0.003075","commission":"-0.00000008","original_client_order_id":null,"is_agressor":true,"order_side":"buy","event_id":"00000000012D41E6","average_price":"0.003075","stop_price":null,"leverage":null,"quantity":"0.01","limit_price":null,"client_order_id":"E7444777-4AAC-43D9-9AF2-61BE027D42ED","time_in_force":"ioc","price":null,"expire_time":null,"reason":null,"order_id":"E7444777-4AAC-43D9-9AF2-61BE027D42ED","cumulative_quantity":"0.01","remaining_quantity":"0","order_type":"market","order_status":"completely_filled","commission_currency":"BTC","text":"HBOT-buy-DASH-BTC-1","exchange_id":null,"destination":"MAXI","security_id":"DASHBTC","id":"BTC-9235","type":"trade","sequence_number":9235,"currency":"DASH","timestamp":1612371214001,"properties":[{"key":"1115","value":"Order"}]}],"order":{"average_price":"0.003075","receipt_time":1612371213978,"close_time":1612371214001,"reason":null,"cumulative_quantity":"0.01","remaining_quantity":"0","status":"completely_filled","id":"E7444777-4AAC-43D9-9AF2-61BE027D42ED","timestamp":1612371214001,"stop_price":null,"leverage":null,"submission_time":null,"quantity":"0.01","limit_price":null,"client_order_id":"E7444777-4AAC-43D9-9AF2-61BE027D42ED","time_in_force":"ioc","price":null,"expire_time":null,"text":"HBOT-buy-DASH-BTC-1","destination":"MAXI","security_id":"DASHBTC","side":"buy","type":"market","source":"CWUI","currency":"DASH","properties":[{"key":"7224","value":"-0.00000008"}]}}""" + + TEST_MARKET_SELL_ORDER = { + "average_price": "0", + "receipt_time": 1612371349993, + "close_time": 0, + "reason": None, + "cumulative_quantity": "0", + "remaining_quantity": "0.01", + "status": "pending_new", + "id": "770AE260-895C-41DF-A5E9-E67904E7F8C0", + "timestamp": 1612371349993, + "stop_price": None, + "leverage": None, + "submission_time": None, + "quantity": "0.01", + "limit_price": None, + "client_order_id": "770AE260-895C-41DF-A5E9-E67904E7F8C0", + "time_in_force": "ioc", + "price": None, + "expire_time": None, + "text": "HBOT-sell-DASH-BTC-1", + "destination": "MAXI", + "security_id": "DASHBTC", + "side": "sell", + "type": "market", + "source": "CWUI", + "currency": None, + "properties": None, + } + TEST_MARKET_SELL_WS_ORDER_CREATED = """MESSAGE\nstatus:200\ndestination:/v1/orders-user178e98a8-ccf2-0706-1d57-e30f5e9b91ba\ncontent-type:application/json\nsubscription:sub-humming-1612371349761545\nmessage-id:178e98a8-ccf2-0706-1d57-e30f5e9b91ba-37\ncontent-length:1380\n\n{"events":[{"trade_quantity":null,"trade_price":null,"commission":null,"original_client_order_id":null,"is_agressor":null,"order_side":"sell","event_id":"19803703","average_price":"0","stop_price":null,"leverage":null,"quantity":"0.01","limit_price":null,"client_order_id":"770AE260-895C-41DF-A5E9-E67904E7F8C0","time_in_force":"ioc","price":null,"expire_time":null,"reason":null,"order_id":"770AE260-895C-41DF-A5E9-E67904E7F8C0","cumulative_quantity":"0","remaining_quantity":"0.01","order_type":"market","order_status":"new","commission_currency":null,"text":"HBOT-sell-DASH-BTC-1","exchange_id":null,"destination":"MAXI","security_id":"DASHBTC","id":"DASH-9262","type":"submitted","sequence_number":9262,"currency":"DASH","timestamp":1612371349999,"properties":null}],"order":{"average_price":"0","receipt_time":1612371349955,"close_time":0,"reason":null,"cumulative_quantity":"0","remaining_quantity":"0.01","status":"new","id":"770AE260-895C-41DF-A5E9-E67904E7F8C0","timestamp":1612371349999,"stop_price":null,"leverage":null,"submission_time":null,"quantity":"0.01","limit_price":null,"client_order_id":"770AE260-895C-41DF-A5E9-E67904E7F8C0","time_in_force":"ioc","price":null,"expire_time":null,"text":"HBOT-sell-DASH-BTC-1","destination":"MAXI","security_id":"DASHBTC","side":"sell","type":"market","source":"CWUI","currency":"DASH","properties":null}}""" + TEST_MARKET_SELL_WS_ORDER_COMPLETED = """MESSAGE\nstatus:200\ndestination:/v1/orders-user178e98a8-ccf2-0706-1d57-e30f5e9b91ba\ncontent-type:application/json\nsubscription:sub-humming-1612371349761545\nmessage-id:178e98a8-ccf2-0706-1d57-e30f5e9b91ba-38\ncontent-length:1530\n\n{"events":[{"trade_quantity":"0.01","trade_price":"0.003057","commission":"-0.00000008","original_client_order_id":null,"is_agressor":true,"order_side":"sell","event_id":"00000000012E3159","average_price":"0.003057","stop_price":null,"leverage":null,"quantity":"0.01","limit_price":null,"client_order_id":"770AE260-895C-41DF-A5E9-E67904E7F8C0","time_in_force":"ioc","price":null,"expire_time":null,"reason":null,"order_id":"770AE260-895C-41DF-A5E9-E67904E7F8C0","cumulative_quantity":"0.01","remaining_quantity":"0","order_type":"market","order_status":"completely_filled","commission_currency":"BTC","text":"HBOT-sell-DASH-BTC-1","exchange_id":null,"destination":"MAXI","security_id":"DASHBTC","id":"DASH-9268","type":"trade","sequence_number":9268,"currency":"DASH","timestamp":1612371350000,"properties":[{"key":"1115","value":"Order"}]}],"order":{"average_price":"0.003057","receipt_time":1612371349955,"close_time":1612371350000,"reason":null,"cumulative_quantity":"0.01","remaining_quantity":"0","status":"completely_filled","id":"770AE260-895C-41DF-A5E9-E67904E7F8C0","timestamp":1612371350000,"stop_price":null,"leverage":null,"submission_time":null,"quantity":"0.01","limit_price":null,"client_order_id":"770AE260-895C-41DF-A5E9-E67904E7F8C0","time_in_force":"ioc","price":null,"expire_time":null,"text":"HBOT-sell-DASH-BTC-1","destination":"MAXI","security_id":"DASHBTC","side":"sell","type":"market","source":"CWUI","currency":"DASH","properties":[{"key":"7224","value":"-0.00002616944717042852"}]}}""" + + TEST_CANCEL_BUY_ORDER = { + "average_price": "0", + "receipt_time": 1612373138029, + "close_time": 0, + "reason": None, + "cumulative_quantity": "0", + "remaining_quantity": "0.01", + "status": "pending_new", + "id": "C24200B6-A16B-46C6-88A2-6D348671B25E", + "timestamp": 1612373138029, + "stop_price": None, + "leverage": None, + "submission_time": None, + "quantity": "0.01", + "limit_price": "0.001544", + "client_order_id": "C24200B6-A16B-46C6-88A2-6D348671B25E", + "time_in_force": "gtc", + "price": "0.001544", + "expire_time": None, + "text": "HBOT-buy-DASH-BTC-1", + "destination": "MAXI", + "security_id": "DASHBTC", + "side": "buy", + "type": "limit", + "source": "CWUI", + "currency": None, + "properties": None, + } + TEST_CANCEL_BUY_WS_ORDER_COMPLETED = """MESSAGE\nstatus:200\ndestination:/v1/orders-user1b8f7a06-858e-c0dc-8665-a99fb892c363\ncontent-type:application/json\nsubscription:sub-humming-1612373137250051\nmessage-id:1b8f7a06-858e-c0dc-8665-a99fb892c363-45\ncontent-length:1397\n\n{"events":[{"trade_quantity":null,"trade_price":null,"commission":null,"original_client_order_id":null,"is_agressor":null,"order_side":"buy","event_id":"20424624","average_price":"0","stop_price":null,"leverage":null,"quantity":"0.01","limit_price":"0.001544","client_order_id":"C24200B6-A16B-46C6-88A2-6D348671B25E","time_in_force":"gtc","price":"0.001544","expire_time":null,"reason":null,"order_id":"C24200B6-A16B-46C6-88A2-6D348671B25E","cumulative_quantity":"0","remaining_quantity":"0.01","order_type":"limit","order_status":"new","commission_currency":null,"text":"HBOT-buy-DASH-BTC-1","exchange_id":null,"destination":"MAXI","security_id":"DASHBTC","id":"BTC-9389","type":"submitted","sequence_number":9389,"currency":"DASH","timestamp":1612373138034,"properties":null}],"order":{"average_price":"0","receipt_time":1612373137964,"close_time":0,"reason":null,"cumulative_quantity":"0","remaining_quantity":"0.01","status":"new","id":"C24200B6-A16B-46C6-88A2-6D348671B25E","timestamp":1612373138034,"stop_price":null,"leverage":null,"submission_time":null,"quantity":"0.01","limit_price":"0.001544","client_order_id":"C24200B6-A16B-46C6-88A2-6D348671B25E","time_in_force":"gtc","price":"0.001544","expire_time":null,"text":"HBOT-buy-DASH-BTC-1","destination":"MAXI","security_id":"DASHBTC","side":"buy","type":"limit","source":"CWUI","currency":"DASH","properties":null}}""" + TEST_CANCEL_BUY_WS_ORDER_CANCELED = """MESSAGE\nstatus:200\ndestination:/v1/orders-user1b8f7a06-858e-c0dc-8665-a99fb892c363\ncontent-type:application/json\nsubscription:sub-humming-1612373137250051\nmessage-id:1b8f7a06-858e-c0dc-8665-a99fb892c363-46\ncontent-length:1497\n\n{"events":[{"trade_quantity":null,"trade_price":null,"commission":null,"original_client_order_id":null,"is_agressor":null,"order_side":"buy","event_id":"20425042","average_price":"0","stop_price":null,"leverage":null,"quantity":"0.01","limit_price":"0.001544","client_order_id":"C24200B6-A16B-46C6-88A2-6D348671B25E","time_in_force":"gtc","price":"0.001544","expire_time":null,"reason":"Manual cancelation from tradingapi.beaxy.com","order_id":"C24200B6-A16B-46C6-88A2-6D348671B25E","cumulative_quantity":"0","remaining_quantity":"0","order_type":"limit","order_status":"canceled","commission_currency":null,"text":"HBOT-buy-DASH-BTC-1","exchange_id":null,"destination":"MAXI","security_id":"DASHBTC","id":"BTC-9400","type":"cancel","sequence_number":9400,"currency":"DASH","timestamp":1612373139033,"properties":null}],"order":{"average_price":"0","receipt_time":1612373137964,"close_time":1612373139033,"reason":"Manual cancelation from tradingapi.beaxy.com","cumulative_quantity":"0","remaining_quantity":"0.01","status":"canceled","id":"C24200B6-A16B-46C6-88A2-6D348671B25E","timestamp":1612373139033,"stop_price":null,"leverage":null,"submission_time":null,"quantity":"0.01","limit_price":"0.001544","client_order_id":"C24200B6-A16B-46C6-88A2-6D348671B25E","time_in_force":"gtc","price":"0.001544","expire_time":null,"text":"HBOT-buy-DASH-BTC-1","destination":"MAXI","security_id":"DASHBTC","side":"buy","type":"limit","source":"CWUI","currency":"DASH","properties":null}}""" + + TEST_CANCEL_ALL_ORDER1 = { + "average_price": "0", + "receipt_time": 1612372497032, + "close_time": 0, + "reason": None, + "cumulative_quantity": "0", + "remaining_quantity": "0.01", + "status": "pending_new", + "id": "3F5C5F5D-A4BE-4E7A-903C-B8FADB708050", + "timestamp": 1612372497032, + "stop_price": None, + "leverage": None, + "submission_time": None, + "quantity": "0.01", + "limit_price": None, + "client_order_id": "3F5C5F5D-A4BE-4E7A-903C-B8FADB708050", + "time_in_force": "ioc", + "price": None, + "expire_time": None, + "text": "HBOT-buy-DASH-BTC-1612372497005401", + "destination": "MAXI", + "security_id": "DASHBTC", + "side": "buy", + "type": "market", + "source": "CWUI", + "currency": None, + "properties": None, + } + TEST_CANCEL_ALL_ORDER2 = { + "average_price": "0", + "receipt_time": 1612372497226, + "close_time": 0, + "reason": None, + "cumulative_quantity": "0", + "remaining_quantity": "0.01", + "status": "pending_new", + "id": "5484A8B3-8C06-4C83-AFC3-B01EB500AD09", + "timestamp": 1612372497226, + "stop_price": None, + "leverage": None, + "submission_time": None, + "quantity": "0.01", + "limit_price": None, + "client_order_id": "5484A8B3-8C06-4C83-AFC3-B01EB500AD09", + "time_in_force": "ioc", + "price": None, + "expire_time": None, + "text": "HBOT-sell-DASH-BTC-1612372497005441", + "destination": "MAXI", + "security_id": "DASHBTC", + "side": "sell", + "type": "market", + "source": "CWUI", + "currency": None, + "properties": None, + } + TEST_CANCEL_BUY_WS_ORDER1_CANCELED = """MESSAGE\nstatus:200\ndestination:/v1/orders-usere53ecdd6-383c-ef5d-1d1f-5471624e0ad9\ncontent-type:application/json\nsubscription:sub-humming-1612372490957013\nmessage-id:e53ecdd6-383c-ef5d-1d1f-5471624e0ad9-43\ncontent-length:1380\n\n{"events":[{"trade_quantity":null,"trade_price":null,"commission":null,"original_client_order_id":null,"is_agressor":null,"order_side":"sell","event_id":"20206349","average_price":"0","stop_price":null,"leverage":null,"quantity":"0.01","limit_price":null,"client_order_id":"5484A8B3-8C06-4C83-AFC3-B01EB500AD09","time_in_force":"ioc","price":null,"expire_time":null,"reason":null,"order_id":"5484A8B3-8C06-4C83-AFC3-B01EB500AD09","cumulative_quantity":"0","remaining_quantity":"0.01","order_type":"market","order_status":"new","commission_currency":null,"text":"HBOT-sell-DASH-BTC-1612372497005441","exchange_id":null,"destination":"MAXI","security_id":"DASHBTC","id":"DASH-9356","type":"submitted","sequence_number":9356,"currency":"DASH","timestamp":1612372497234,"properties":null}],"order":{"average_price":"0","receipt_time":1612372497184,"close_time":0,"reason":null,"cumulative_quantity":"0","remaining_quantity":"0.01","status":"new","id":"5484A8B3-8C06-4C83-AFC3-B01EB500AD09","timestamp":1612372497234,"stop_price":null,"leverage":null,"submission_time":null,"quantity":"0.01","limit_price":null,"client_order_id":"5484A8B3-8C06-4C83-AFC3-B01EB500AD09","time_in_force":"ioc","price":null,"expire_time":null,"text":"HBOT-sell-DASH-BTC-1612372497005441","destination":"MAXI","security_id":"DASHBTC","side":"sell","type":"market","source":"CWUI","currency":"DASH","properties":null}}""" + TEST_CANCEL_BUY_WS_ORDER2_CANCELED = """MESSAGE\nstatus:200\ndestination:/v1/orders-usere53ecdd6-383c-ef5d-1d1f-5471624e0ad9\ncontent-type:application/json\nsubscription:sub-humming-1612372490957013\nmessage-id:e53ecdd6-383c-ef5d-1d1f-5471624e0ad9-44\ncontent-length:1530\n\n{"events":[{"trade_quantity":"0.01","trade_price":"0.003068","commission":"-0.00000008","original_client_order_id":null,"is_agressor":true,"order_side":"sell","event_id":"000000000134562F","average_price":"0.003068","stop_price":null,"leverage":null,"quantity":"0.01","limit_price":null,"client_order_id":"5484A8B3-8C06-4C83-AFC3-B01EB500AD09","time_in_force":"ioc","price":null,"expire_time":null,"reason":null,"order_id":"5484A8B3-8C06-4C83-AFC3-B01EB500AD09","cumulative_quantity":"0.01","remaining_quantity":"0","order_type":"market","order_status":"completely_filled","commission_currency":"BTC","text":"HBOT-sell-DASH-BTC-1612372497005441","exchange_id":null,"destination":"MAXI","security_id":"DASHBTC","id":"DASH-9362","type":"trade","sequence_number":9362,"currency":"DASH","timestamp":1612372497236,"properties":[{"key":"1115","value":"Order"}]}],"order":{"average_price":"0.003068","receipt_time":1612372497184,"close_time":1612372497236,"reason":null,"cumulative_quantity":"0.01","remaining_quantity":"0","status":"completely_filled","id":"5484A8B3-8C06-4C83-AFC3-B01EB500AD09","timestamp":1612372497236,"stop_price":null,"leverage":null,"submission_time":null,"quantity":"0.01","limit_price":null,"client_order_id":"5484A8B3-8C06-4C83-AFC3-B01EB500AD09","time_in_force":"ioc","price":null,"expire_time":null,"text":"HBOT-sell-DASH-BTC-1612372497005441","destination":"MAXI","security_id":"DASHBTC","side":"sell","type":"market","source":"CWUI","currency":"DASH","properties":[{"key":"7224","value":"-0.00002607561929595828"}]}}""" diff --git a/test/integration/test_beaxy_active_order_tracker.py b/test/integration/test_beaxy_active_order_tracker.py new file mode 100644 index 0000000000..59d9a095f6 --- /dev/null +++ b/test/integration/test_beaxy_active_order_tracker.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python + +from os.path import join, realpath +import sys; sys.path.insert(0, realpath(join(__file__, "../../../"))) + +import unittest +from typing import Any, Dict + +from hummingbot.core.data_type.order_book_row import OrderBookRow +from hummingbot.connector.exchange.beaxy.beaxy_order_book_message import BeaxyOrderBookMessage +from hummingbot.connector.exchange.beaxy.beaxy_order_book import BeaxyOrderBook +from hummingbot.connector.exchange.beaxy.beaxy_active_order_tracker import BeaxyActiveOrderTracker + +from test.integration.assets.mock_data.fixture_beaxy import FixtureBeaxy + + +test_trading_pair = "BTC-USDC" + + +class BeaxyOrderBookTrackerUnitTest(unittest.TestCase): + def test_insert_update_delete_messages(self): + active_tracker = BeaxyActiveOrderTracker() + + # receive INSERT message to be added to active orders + side = "BID" + price = 1337.4423423404 + quantity: float = 1 + update_id = 123 + message_dict: Dict[str, Any] = { + "action": "INSERT", + "quantity": quantity, + "price": price, + "side": side, + "sequenceNumber": update_id, + "sequrity": test_trading_pair + } + insert_message = BeaxyOrderBook.diff_message_from_exchange(message_dict, float(12345)) + insert_ob_row: OrderBookRow = active_tracker.convert_diff_message_to_order_book_row(insert_message) + self.assertEqual(insert_ob_row[0], [OrderBookRow(price, quantity, update_id)]) + + # receive UPDATE message + updated_quantity: float = 3.2 + update_message_dict: Dict[str, Any] = { + "action": "UPDATE", + "quantity": updated_quantity, + "price": price, + "side": side, + "sequenceNumber": update_id + 1, + "sequrity": test_trading_pair + } + change_message = BeaxyOrderBook.diff_message_from_exchange(update_message_dict, float(12345)) + change_ob_row: OrderBookRow = active_tracker.convert_diff_message_to_order_book_row(change_message) + self.assertEqual(change_ob_row[0], [OrderBookRow(price, float(updated_quantity), update_id + 1)]) + + # receive DELETE message + delete_quantity = 1 + delete_message_dict: Dict[str, Any] = { + "action": "DELETE", + "quantity": delete_quantity, + "price": price, + "side": side, + "sequenceNumber": update_id + 1 + 1, + "sequrity": test_trading_pair + } + + delete_message: BeaxyOrderBookMessage = BeaxyOrderBook.diff_message_from_exchange(delete_message_dict, float(12345)) + delete_ob_row: OrderBookRow = active_tracker.convert_diff_message_to_order_book_row(delete_message) + self.assertEqual(delete_ob_row[0], [OrderBookRow(price, float(updated_quantity) - float(delete_quantity), update_id + 1 + 1)]) + + def test_delete_through(self): + active_tracker = BeaxyActiveOrderTracker() + + # receive INSERT message to be added to active orders + first_insert: Dict[str, Any] = { + "action": "INSERT", + "quantity": 1, + "price": 133, + "side": "BID", + "sequenceNumber": 1, + "sequrity": test_trading_pair + } + second_insert: Dict[str, Any] = { + "action": "INSERT", + "quantity": 2, + "price": 134, + "side": "BID", + "sequenceNumber": 2, + "sequrity": test_trading_pair + } + third_insert: Dict[str, Any] = { + "action": "INSERT", + "quantity": 3, + "price": 135, + "side": "BID", + "sequenceNumber": 1, + "sequrity": test_trading_pair + } + + inserts = [first_insert, second_insert, third_insert] + for msg in inserts: + insert_message = BeaxyOrderBook.diff_message_from_exchange(msg, float(12345)) + active_tracker.convert_diff_message_to_order_book_row(insert_message) + + delete_through_dict: Dict[str, Any] = { + "action": "DELETE_THROUGH", + "quantity": 3, + "price": 134, + "side": "BID", + "sequenceNumber": 1, + "sequrity": test_trading_pair + } + + msg = BeaxyOrderBook.diff_message_from_exchange(delete_through_dict, float(12345)) + active_tracker.convert_diff_message_to_order_book_row(msg) + self.assertEqual(len(active_tracker.active_bids), 1) + self.assertEqual(next(iter(active_tracker.active_bids)), 133) + + def test_delete_from(self): + active_tracker = BeaxyActiveOrderTracker() + + # receive INSERT message to be added to active orders + first_insert: Dict[str, Any] = { + "action": "INSERT", + "quantity": 1, + "price": 133, + "side": "ASK", + "sequenceNumber": 1, + "sequrity": test_trading_pair + } + second_insert: Dict[str, Any] = { + "action": "INSERT", + "quantity": 2, + "price": 134, + "side": "ASK", + "sequenceNumber": 2, + "sequrity": test_trading_pair + } + third_insert: Dict[str, Any] = { + "action": "INSERT", + "quantity": 3, + "price": 135, + "side": "ASK", + "sequenceNumber": 1, + "sequrity": test_trading_pair + } + + inserts = [first_insert, second_insert, third_insert] + for msg in inserts: + insert_message = BeaxyOrderBook.diff_message_from_exchange(msg, float(12345)) + active_tracker.convert_diff_message_to_order_book_row(insert_message) + + delete_through_dict: Dict[str, Any] = { + "action": "DELETE_FROM", + "quantity": 3, + "price": 134, + "side": "ASK", + "sequenceNumber": 1, + "sequrity": test_trading_pair + } + + msg = BeaxyOrderBook.diff_message_from_exchange(delete_through_dict, float(12345)) + active_tracker.convert_diff_message_to_order_book_row(msg) + self.assertEqual(len(active_tracker.active_asks), 1) + self.assertEqual(next(iter(active_tracker.active_asks)), 135) + + def test_snapshot(self): + active_tracker = BeaxyActiveOrderTracker() + insert_message = BeaxyOrderBook.snapshot_message_from_exchange(FixtureBeaxy.SNAPSHOT_MSG, float(12345)) + + active_tracker.convert_snapshot_message_to_order_book_row(insert_message) + + self.assertEqual(len(active_tracker.active_asks), 9) diff --git a/test/integration/test_beaxy_api_order_book_data_source.py b/test/integration/test_beaxy_api_order_book_data_source.py new file mode 100644 index 0000000000..2006d936e7 --- /dev/null +++ b/test/integration/test_beaxy_api_order_book_data_source.py @@ -0,0 +1,72 @@ +from hummingbot.connector.exchange.beaxy.beaxy_auth import BeaxyAuth +import unittest +import conf +import asyncio +import aiohttp +import pandas as pd +from typing import ( + List, + Optional, + Any, + Dict +) +from hummingbot.connector.exchange.beaxy.beaxy_api_order_book_data_source import BeaxyAPIOrderBookDataSource +from hummingbot.core.data_type.order_book_tracker_entry import OrderBookTrackerEntry + + +class BeaxyApiOrderBookDataSourceUnitTest(unittest.TestCase): + + trading_pairs: List[str] = [ + "BTC-USDC", + ] + + @classmethod + def setUpClass(cls): + cls.ev_loop = asyncio.get_event_loop() + cls._auth: BeaxyAuth = BeaxyAuth(conf.beaxy_api_key, conf.beaxy_secret_key) + cls.data_source: BeaxyAPIOrderBookDataSource = BeaxyAPIOrderBookDataSource(cls.trading_pairs) + + def run_async(self, task): + return self.ev_loop.run_until_complete(task) + + def test_get_active_exchange_markets(self): + all_markets_df: pd.DataFrame = self.run_async(self.data_source.get_active_exchange_markets()) + + # Check DF type + self.assertIsInstance(all_markets_df, pd.DataFrame) + + # Check DF order, make sure it's sorted by USDVolume col in desending order + usd_volumes = all_markets_df.loc[:, 'USDVolume'].to_list() + self.assertListEqual( + usd_volumes, + sorted(usd_volumes, reverse=True), + "The output usd volumes should remain the same after being sorted again") + + def test_get_trading_pairs(self): + trading_pairs: Optional[List[str]] = self.run_async(self.data_source.get_trading_pairs()) + assert trading_pairs is not None + self.assertIn("ETC-BTC", trading_pairs) + self.assertNotIn("NRG-BTC", trading_pairs) + + async def get_snapshot(self): + async with aiohttp.ClientSession() as client: + trading_pairs: Optional[List[str]] = await self.data_source.get_trading_pairs() + assert trading_pairs is not None + trading_pair: str = trading_pairs[0] + try: + snapshot: Dict[str, Any] = await self.data_source.get_snapshot(client, trading_pair, 20) + return snapshot + except Exception: + return None + + def test_get_snapshot(self): + snapshot: Optional[Dict[str, Any]] = self.run_async(self.get_snapshot()) + assert snapshot is not None + self.assertIsNotNone(snapshot) + trading_pairs = self.run_async(self.data_source.get_trading_pairs()) + assert trading_pairs is not None + self.assertIn(snapshot["security"], [p.replace('-', '') for p in trading_pairs]) + + def test_get_tracking_pairs(self): + tracking_pairs: Dict[str, OrderBookTrackerEntry] = self.run_async(self.data_source.get_tracking_pairs()) + self.assertIsInstance(tracking_pairs["BTC-USDC"], OrderBookTrackerEntry) diff --git a/test/integration/test_beaxy_auth.py b/test/integration/test_beaxy_auth.py new file mode 100644 index 0000000000..2024dcee9a --- /dev/null +++ b/test/integration/test_beaxy_auth.py @@ -0,0 +1,18 @@ +import unittest +import asyncio +import conf +from typing import ( + Dict +) +from hummingbot.connector.exchange.beaxy.beaxy_auth import BeaxyAuth + + +class BeaxyAuthUnitTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.ev_loop = asyncio.get_event_loop() + cls.beaxy_auth: BeaxyAuth = BeaxyAuth(conf.beaxy_api_key, conf.beaxy_secret_key) + + def test_get_auth_session(self): + result: Dict[str, str] = self.ev_loop.run_until_complete(self.beaxy_auth.generate_auth_dict("GET", "/api/staff")) + self.assertIsNotNone(result) diff --git a/test/integration/test_beaxy_market.py b/test/integration/test_beaxy_market.py new file mode 100644 index 0000000000..fa38fdd343 --- /dev/null +++ b/test/integration/test_beaxy_market.py @@ -0,0 +1,515 @@ +import os +from os.path import join, realpath +import sys; sys.path.insert(0, realpath(join(__file__, "../../../"))) +import logging +from hummingbot.logger.struct_logger import METRICS_LOG_LEVEL +import asyncio +import json +import contextlib +import time +import unittest +from unittest import mock +import conf +from decimal import Decimal +from hummingbot.core.clock import ( + Clock, + ClockMode +) +from hummingbot.core.utils.async_utils import ( + safe_ensure_future, + safe_gather, +) +from hummingbot.client.config.fee_overrides_config_map import fee_overrides_config_map +from test.integration.humming_web_app import HummingWebApp +from test.integration.humming_ws_server import HummingWsServerFactory +from hummingbot.market.market_base import OrderType +from hummingbot.core.event.event_logger import EventLogger +from hummingbot.core.event.events import ( + MarketEvent, SellOrderCreatedEvent, + TradeFee, + TradeType, + BuyOrderCompletedEvent, + OrderFilledEvent, + OrderCancelledEvent, + BuyOrderCreatedEvent, + SellOrderCompletedEvent, + MarketOrderFailureEvent, +) +from hummingbot.connector.exchange.beaxy.beaxy_exchange import BeaxyExchange +from hummingbot.connector.exchange.beaxy.beaxy_constants import BeaxyConstants +from typing import ( + List, +) +from test.integration.assets.mock_data.fixture_beaxy import FixtureBeaxy + +logging.basicConfig(level=METRICS_LOG_LEVEL) +API_MOCK_ENABLED = conf.mock_api_enabled is not None and conf.mock_api_enabled.lower() in ['true', 'yes', '1'] +API_KEY = "XXX" if API_MOCK_ENABLED else conf.beaxy_api_key +API_SECRET = "YYY" if API_MOCK_ENABLED else conf.beaxy_secret_key + + +def _transform_raw_message_patch(self, msg): + return json.loads(msg) + + +PUBLIC_API_BASE_URL = "services.beaxy.com" +PRIVET_API_BASE_URL = "tradingapi.beaxy.com" + + +class BeaxyExchangeUnitTest(unittest.TestCase): + events: List[MarketEvent] = [ + MarketEvent.ReceivedAsset, + MarketEvent.BuyOrderCompleted, + MarketEvent.SellOrderCompleted, + MarketEvent.WithdrawAsset, + MarketEvent.OrderFilled, + MarketEvent.TransactionFailure, + MarketEvent.BuyOrderCreated, + MarketEvent.SellOrderCreated, + MarketEvent.OrderCancelled, + MarketEvent.OrderFailure, + ] + market: BeaxyExchange + market_logger: EventLogger + stack: contextlib.ExitStack + + @classmethod + def setUpClass(cls): + + cls.ev_loop: asyncio.BaseEventLoop = asyncio.get_event_loop() + + if API_MOCK_ENABLED: + + cls.web_app = HummingWebApp.get_instance() + cls.web_app.add_host_to_mock(PRIVET_API_BASE_URL, []) + cls.web_app.add_host_to_mock(PUBLIC_API_BASE_URL, []) + cls.web_app.start() + cls.ev_loop.run_until_complete(cls.web_app.wait_til_started()) + cls._patcher = mock.patch("aiohttp.client.URL") + cls._url_mock = cls._patcher.start() + cls._url_mock.side_effect = cls.web_app.reroute_local + cls.web_app.update_response("get", PUBLIC_API_BASE_URL, "/api/v2/symbols", FixtureBeaxy.BALANCES) + cls.web_app.update_response("get", PUBLIC_API_BASE_URL, "/api/v2/symbols/DASHBTC/book", + FixtureBeaxy.TRADE_BOOK) + cls.web_app.update_response("get", PUBLIC_API_BASE_URL, "/api/v2/symbols/DASHBTC/rate", + FixtureBeaxy.EXCHANGE_RATE) + cls.web_app.update_response("get", PRIVET_API_BASE_URL, "/api/v1/accounts", + FixtureBeaxy.ACCOUNTS) + cls.web_app.update_response("get", PRIVET_API_BASE_URL, "/api/v1/trader/health", + FixtureBeaxy.HEALTH) + + cls._t_nonce_patcher = unittest.mock.patch( + "hummingbot.connector.exchange.beaxy.beaxy_exchange.get_tracking_nonce") + cls._t_nonce_mock = cls._t_nonce_patcher.start() + + HummingWsServerFactory.url_host_only = True + HummingWsServerFactory.start_new_server(BeaxyConstants.TradingApi.WS_BASE_URL) + HummingWsServerFactory.start_new_server(BeaxyConstants.PublicApi.WS_BASE_URL) + + cls._ws_patcher = unittest.mock.patch("websockets.connect", autospec=True) + cls._ws_mock = cls._ws_patcher.start() + cls._ws_mock.side_effect = HummingWsServerFactory.reroute_ws_connect + + cls._auth_confirm_patcher = unittest.mock.patch( + "hummingbot.connector.exchange.beaxy.beaxy_auth.BeaxyAuth._BeaxyAuth__login_confirm") + cls._auth_confirm_mock = cls._auth_confirm_patcher.start() + cls._auth_session_patcher = unittest.mock.patch( + "hummingbot.connector.exchange.beaxy.beaxy_auth.BeaxyAuth._BeaxyAuth__get_session_data") + cls._auth_session_mock = cls._auth_session_patcher.start() + cls._auth_session_mock.return_value = {"sign_key": 123, "session_id": '123'} + + cls.clock: Clock = Clock(ClockMode.REALTIME) + cls.market: BeaxyExchange = BeaxyExchange( + API_KEY, API_SECRET, + trading_pairs=["DASH-BTC"] + ) + + print("Initializing Beaxy market... this will take about a minute.") + cls.clock.add_iterator(cls.market) + cls.stack: contextlib.ExitStack = contextlib.ExitStack() + cls._clock = cls.stack.enter_context(cls.clock) + cls.ev_loop.run_until_complete(cls.wait_til_ready()) + print("Ready.") + + @classmethod + async def wait_til_ready(cls): + while True: + now = time.time() + next_iteration = now // 1.0 + 1 + if cls.market.ready: + break + else: + await cls._clock.run_til(next_iteration) + await asyncio.sleep(1.0) + + def run_parallel(self, *tasks): + return self.ev_loop.run_until_complete(self.run_parallel_async(*tasks)) + + async def run_parallel_async(self, *tasks): + future: asyncio.Future = safe_ensure_future(safe_gather(*tasks)) + while not future.done(): + now = time.time() + next_iteration = now // 1.0 + 1 + await self.clock.run_til(next_iteration) + return future.result() + + def setUp(self): + self.db_path: str = realpath(join(__file__, "../beaxy_test.sqlite")) + try: + os.unlink(self.db_path) + except FileNotFoundError: + pass + + self.market_logger = EventLogger() + for event_tag in self.events: + self.market.add_listener(event_tag, self.market_logger) + + def test_balances(self): + balances = self.market.get_all_balances() + self.assertGreater(len(balances), 0) + + def test_get_fee(self): + limit_fee: TradeFee = self.market.get_fee("ETH", "USDC", OrderType.LIMIT, TradeType.BUY, 1, 1) + self.assertGreater(limit_fee.percent, 0) + self.assertEqual(len(limit_fee.flat_fees), 0) + market_fee: TradeFee = self.market.get_fee("ETH", "USDC", OrderType.MARKET, TradeType.BUY, 1) + self.assertGreater(market_fee.percent, 0) + self.assertEqual(len(market_fee.flat_fees), 0) + + def test_fee_overrides_config(self): + fee_overrides_config_map["beaxy_taker_fee"].value = None + taker_fee: TradeFee = self.market.get_fee("BTC", "ETH", OrderType.MARKET, TradeType.BUY, Decimal(1), + Decimal('0.1')) + self.assertAlmostEqual(Decimal("0.0025"), taker_fee.percent) + fee_overrides_config_map["beaxy_taker_fee"].value = Decimal('0.2') + taker_fee: TradeFee = self.market.get_fee("BTC", "ETH", OrderType.MARKET, TradeType.BUY, Decimal(1), + Decimal('0.1')) + self.assertAlmostEqual(Decimal("0.002"), taker_fee.percent) + fee_overrides_config_map["beaxy_maker_fee"].value = None + maker_fee: TradeFee = self.market.get_fee("BTC", "ETH", OrderType.LIMIT, TradeType.BUY, Decimal(1), + Decimal('0.1')) + self.assertAlmostEqual(Decimal("0.002"), maker_fee.percent) + fee_overrides_config_map["beaxy_maker_fee"].value = Decimal('0.75') + maker_fee: TradeFee = self.market.get_fee("BTC", "ETH", OrderType.LIMIT, TradeType.BUY, Decimal(1), + Decimal('0.1')) + self.assertAlmostEqual(Decimal("0.002"), maker_fee.percent) + + def place_order(self, is_buy, trading_pair, amount, order_type, price, ws_resps=[]): + global EXCHANGE_ORDER_ID + order_id, exch_order_id = None, None + + if is_buy: + order_id = self.market.buy(trading_pair, amount, order_type, price) + else: + order_id = self.market.sell(trading_pair, amount, order_type, price) + if API_MOCK_ENABLED: + for delay, ws_resp in ws_resps: + HummingWsServerFactory.send_str_threadsafe(BeaxyConstants.TradingApi.WS_BASE_URL, ws_resp, delay=delay) + return order_id, exch_order_id + + def cancel_order(self, trading_pair, order_id, exch_order_id): + self.market.cancel(trading_pair, order_id) + + def test_limit_buy(self): + + if API_MOCK_ENABLED: + self.web_app.update_response("post", PRIVET_API_BASE_URL, "/api/v1/orders", + FixtureBeaxy.TEST_LIMIT_BUY_ORDER) + + amount: Decimal = Decimal("0.01") + + self.assertGreater(self.market.get_balance("BTC"), 0.00005) + trading_pair = "DASH-BTC" + + price: Decimal = self.market.get_price(trading_pair, True) * Decimal(1.1) + quantized_amount: Decimal = self.market.quantize_order_amount(trading_pair, amount) + + order_id, _ = self.place_order( + True, trading_pair, quantized_amount, OrderType.LIMIT, price, + [(2, FixtureBeaxy.TEST_LIMIT_BUY_WS_ORDER_CREATED), (3, FixtureBeaxy.TEST_LIMIT_BUY_WS_ORDER_COMPLETED)] + ) + [order_completed_event] = self.run_parallel(self.market_logger.wait_for(BuyOrderCompletedEvent)) + order_completed_event: BuyOrderCompletedEvent = order_completed_event + trade_events: List[OrderFilledEvent] = [t for t in self.market_logger.event_log + if isinstance(t, OrderFilledEvent)] + base_amount_traded: Decimal = sum(t.amount for t in trade_events) + quote_amount_traded: Decimal = sum(t.amount * t.price for t in trade_events) + + self.assertTrue([evt.order_type == OrderType.LIMIT for evt in trade_events]) + self.assertEqual(order_id, order_completed_event.order_id) + self.assertAlmostEqual(quantized_amount, order_completed_event.base_asset_amount) + self.assertEqual("DASH", order_completed_event.base_asset) + self.assertEqual("BTC", order_completed_event.quote_asset) + self.assertAlmostEqual(base_amount_traded, order_completed_event.base_asset_amount) + self.assertAlmostEqual(quote_amount_traded, order_completed_event.quote_asset_amount) + self.assertTrue(any([isinstance(event, BuyOrderCreatedEvent) and event.order_id == order_id + for event in self.market_logger.event_log])) + # Reset the logs + self.market_logger.clear() + + def test_limit_sell(self): + + if API_MOCK_ENABLED: + self.web_app.update_response("post", PRIVET_API_BASE_URL, "/api/v1/orders", + FixtureBeaxy.TEST_LIMIT_SELL_ORDER) + + trading_pair = "DASH-BTC" + self.assertGreater(self.market.get_balance("DASH"), 0.01) + + price: Decimal = self.market.get_price(trading_pair, False) * Decimal(0.9) + amount: Decimal = Decimal("0.01") + quantized_amount: Decimal = self.market.quantize_order_amount(trading_pair, amount) + + order_id, _ = self.place_order( + False, trading_pair, quantized_amount, OrderType.LIMIT, price, + [(2, FixtureBeaxy.TEST_LIMIT_SELL_WS_ORDER_CREATED), (3, FixtureBeaxy.TEST_LIMIT_SELL_WS_ORDER_COMPLETED)] + ) + [order_completed_event] = self.run_parallel(self.market_logger.wait_for(SellOrderCompletedEvent)) + order_completed_event: SellOrderCompletedEvent = order_completed_event + trade_events = [t for t in self.market_logger.event_log if isinstance(t, OrderFilledEvent)] + base_amount_traded = sum(t.amount for t in trade_events) + quote_amount_traded = sum(t.amount * t.price for t in trade_events) + + self.assertTrue([evt.order_type == OrderType.LIMIT for evt in trade_events]) + self.assertEqual(order_id, order_completed_event.order_id) + self.assertAlmostEqual(quantized_amount, order_completed_event.base_asset_amount) + self.assertEqual("DASH", order_completed_event.base_asset) + self.assertEqual("BTC", order_completed_event.quote_asset) + self.assertAlmostEqual(base_amount_traded, order_completed_event.base_asset_amount) + self.assertAlmostEqual(quote_amount_traded, order_completed_event.quote_asset_amount) + self.assertTrue(any([isinstance(event, SellOrderCreatedEvent) and event.order_id == order_id + for event in self.market_logger.event_log])) + # Reset the logs + self.market_logger.clear() + + def test_limit_maker_rejections(self): + if API_MOCK_ENABLED: + return + trading_pair = "DASH-BTC" + + # Try to put a buy limit maker order that is going to match, this should triggers order failure event. + price: Decimal = self.market.get_price(trading_pair, True) * Decimal('1.02') + price: Decimal = self.market.quantize_order_price(trading_pair, price) + amount = self.market.quantize_order_amount(trading_pair, 1) + + order_id = self.market.buy(trading_pair, amount, OrderType.LIMIT_MAKER, price) + [order_failure_event] = self.run_parallel(self.market_logger.wait_for(MarketOrderFailureEvent)) + self.assertEqual(order_id, order_failure_event.order_id) + + self.market_logger.clear() + + # Try to put a sell limit maker order that is going to match, this should triggers order failure event. + price: Decimal = self.market.get_price(trading_pair, True) * Decimal('0.98') + price: Decimal = self.market.quantize_order_price(trading_pair, price) + amount = self.market.quantize_order_amount(trading_pair, 1) + + order_id = self.market.sell(trading_pair, amount, OrderType.LIMIT_MAKER, price) + [order_failure_event] = self.run_parallel(self.market_logger.wait_for(MarketOrderFailureEvent)) + self.assertEqual(order_id, order_failure_event.order_id) + + def test_limit_makers_unfilled(self): + + if API_MOCK_ENABLED: + self.web_app.update_response("post", PRIVET_API_BASE_URL, "/api/v1/orders", + FixtureBeaxy.TEST_UNFILLED_ORDER1) + + self.assertGreater(self.market.get_balance("BTC"), 0.00005) + trading_pair = "DASH-BTC" + + current_bid_price: Decimal = self.market.get_price(trading_pair, True) * Decimal('0.8') + quantize_bid_price: Decimal = self.market.quantize_order_price(trading_pair, current_bid_price) + bid_amount: Decimal = Decimal('0.01') + quantized_bid_amount: Decimal = self.market.quantize_order_amount(trading_pair, bid_amount) + + current_ask_price: Decimal = self.market.get_price(trading_pair, False) + quantize_ask_price: Decimal = self.market.quantize_order_price(trading_pair, current_ask_price) + ask_amount: Decimal = Decimal('0.01') + quantized_ask_amount: Decimal = self.market.quantize_order_amount(trading_pair, ask_amount) + + order_id1, exch_order_id_1 = self.place_order( + True, trading_pair, quantized_bid_amount, OrderType.LIMIT, quantize_bid_price, + [(2, FixtureBeaxy.TEST_UNFILLED_ORDER1_WS_ORDER_CREATED)] + ) + [order_created_event] = self.run_parallel(self.market_logger.wait_for(BuyOrderCreatedEvent)) + order_created_event: BuyOrderCreatedEvent = order_created_event + self.assertEqual(order_id1, order_created_event.order_id) + + if API_MOCK_ENABLED: + self.web_app.update_response("post", PRIVET_API_BASE_URL, "/api/v1/orders", + FixtureBeaxy.TEST_UNFILLED_ORDER2) + + order_id2, exch_order_id_2 = self.place_order( + False, trading_pair, quantized_ask_amount, OrderType.LIMIT, quantize_ask_price, + [(2, FixtureBeaxy.TEST_UNFILLED_ORDER2_WS_ORDER_CREATED)] + ) + [order_created_event] = self.run_parallel(self.market_logger.wait_for(SellOrderCreatedEvent)) + order_created_event: BuyOrderCreatedEvent = order_created_event + self.assertEqual(order_id2, order_created_event.order_id) + + if API_MOCK_ENABLED: + HummingWsServerFactory.send_str_threadsafe(BeaxyConstants.TradingApi.WS_BASE_URL, + FixtureBeaxy.TEST_UNFILLED_ORDER1_WS_ORDER_CANCELED, delay=3) + HummingWsServerFactory.send_str_threadsafe(BeaxyConstants.TradingApi.WS_BASE_URL, + FixtureBeaxy.TEST_UNFILLED_ORDER2_WS_ORDER_CANCELED, delay=3) + + self.web_app.update_response("delete", PRIVET_API_BASE_URL, "/api/v1/orders", "") + + self.run_parallel(asyncio.sleep(1)) + [cancellation_results] = self.run_parallel(self.market.cancel_all(5)) + for cr in cancellation_results: + self.assertEqual(cr.success, True) + + def test_market_buy(self): + + if API_MOCK_ENABLED: + self.web_app.update_response("post", PRIVET_API_BASE_URL, "/api/v1/orders", + FixtureBeaxy.TEST_MARKET_BUY_ORDER) + + amount: Decimal = Decimal("0.01") + + self.assertGreater(self.market.get_balance("BTC"), 0.00005) + trading_pair = "DASH-BTC" + + price: Decimal = self.market.get_price(trading_pair, True) + quantized_amount: Decimal = self.market.quantize_order_amount(trading_pair, amount) + + order_id, _ = self.place_order( + True, trading_pair, quantized_amount, OrderType.MARKET, price, + [(2, FixtureBeaxy.TEST_MARKET_BUY_WS_ORDER_CREATED), (3, FixtureBeaxy.TEST_MARKET_BUY_WS_ORDER_COMPLETED)] + ) + [order_completed_event] = self.run_parallel(self.market_logger.wait_for(BuyOrderCompletedEvent)) + order_completed_event: BuyOrderCompletedEvent = order_completed_event + trade_events: List[OrderFilledEvent] = [t for t in self.market_logger.event_log + if isinstance(t, OrderFilledEvent)] + base_amount_traded: Decimal = sum(t.amount for t in trade_events) + quote_amount_traded: Decimal = sum(t.amount * t.price for t in trade_events) + + self.assertTrue([evt.order_type == OrderType.LIMIT for evt in trade_events]) + self.assertEqual(order_id, order_completed_event.order_id) + self.assertAlmostEqual(quantized_amount, order_completed_event.base_asset_amount) + self.assertEqual("DASH", order_completed_event.base_asset) + self.assertEqual("BTC", order_completed_event.quote_asset) + self.assertAlmostEqual(base_amount_traded, order_completed_event.base_asset_amount) + self.assertAlmostEqual(quote_amount_traded, order_completed_event.quote_asset_amount) + self.assertTrue(any([isinstance(event, BuyOrderCreatedEvent) and event.order_id == order_id + for event in self.market_logger.event_log])) + # Reset the logs + self.market_logger.clear() + + def test_market_sell(self): + + if API_MOCK_ENABLED: + self.web_app.update_response("post", PRIVET_API_BASE_URL, "/api/v1/orders", + FixtureBeaxy.TEST_MARKET_SELL_ORDER) + + trading_pair = "DASH-BTC" + self.assertGreater(self.market.get_balance("DASH"), 0.01) + + price: Decimal = self.market.get_price(trading_pair, False) + amount: Decimal = Decimal("0.01") + quantized_amount: Decimal = self.market.quantize_order_amount(trading_pair, amount) + + order_id, _ = self.place_order( + False, trading_pair, quantized_amount, OrderType.MARKET, price, + [(2, FixtureBeaxy.TEST_MARKET_SELL_WS_ORDER_CREATED), (3, FixtureBeaxy.TEST_MARKET_SELL_WS_ORDER_COMPLETED)] + ) + [order_completed_event] = self.run_parallel(self.market_logger.wait_for(SellOrderCompletedEvent)) + order_completed_event: SellOrderCompletedEvent = order_completed_event + trade_events = [t for t in self.market_logger.event_log if isinstance(t, OrderFilledEvent)] + base_amount_traded = sum(t.amount for t in trade_events) + quote_amount_traded = sum(t.amount * t.price for t in trade_events) + + self.assertTrue([evt.order_type == OrderType.LIMIT for evt in trade_events]) + self.assertEqual(order_id, order_completed_event.order_id) + self.assertAlmostEqual(quantized_amount, order_completed_event.base_asset_amount) + self.assertEqual("DASH", order_completed_event.base_asset) + self.assertEqual("BTC", order_completed_event.quote_asset) + self.assertAlmostEqual(base_amount_traded, order_completed_event.base_asset_amount) + self.assertAlmostEqual(quote_amount_traded, order_completed_event.quote_asset_amount) + self.assertTrue(any([isinstance(event, SellOrderCreatedEvent) and event.order_id == order_id + for event in self.market_logger.event_log])) + # Reset the logs + self.market_logger.clear() + + def test_cancel_order(self): + + if API_MOCK_ENABLED: + self.web_app.update_response("post", PRIVET_API_BASE_URL, "/api/v1/orders", + FixtureBeaxy.TEST_CANCEL_BUY_ORDER) + + self.web_app.update_response("delete", PRIVET_API_BASE_URL, "/api/v1/orders", '') + + amount: Decimal = Decimal("0.01") + + self.assertGreater(self.market.get_balance("BTC"), 0.00005) + trading_pair = "DASH-BTC" + + # make worst price so order wont be executed + price: Decimal = self.market.get_price(trading_pair, True) * Decimal('0.5') + quantized_amount: Decimal = self.market.quantize_order_amount(trading_pair, amount) + + order_id, exch_order_id = self.place_order( + True, trading_pair, quantized_amount, OrderType.LIMIT, price, + [(3, FixtureBeaxy.TEST_CANCEL_BUY_WS_ORDER_COMPLETED)] + ) + [order_completed_event] = self.run_parallel(self.market_logger.wait_for(BuyOrderCreatedEvent)) + + if API_MOCK_ENABLED: + HummingWsServerFactory.send_str_threadsafe(BeaxyConstants.TradingApi.WS_BASE_URL, + FixtureBeaxy.TEST_CANCEL_BUY_WS_ORDER_CANCELED, delay=3) + + self.cancel_order(trading_pair, order_id, exch_order_id) + [order_cancelled_event] = self.run_parallel(self.market_logger.wait_for(OrderCancelledEvent)) + order_cancelled_event: OrderCancelledEvent = order_cancelled_event + self.assertEqual(order_cancelled_event.order_id, order_id) + + def test_cancel_all(self): + + if API_MOCK_ENABLED: + self.web_app.update_response("delete", PRIVET_API_BASE_URL, "/api/v1/orders", '') + + self.assertGreater(self.market.get_balance("BTC"), 0.00005) + self.assertGreater(self.market.get_balance("DASH"), 0.01) + trading_pair = "DASH-BTC" + + # make worst price so order wont be executed + current_bid_price: Decimal = self.market.get_price(trading_pair, True) * Decimal('0.5') + quantize_bid_price: Decimal = self.market.quantize_order_price(trading_pair, current_bid_price) + bid_amount: Decimal = Decimal('0.01') + quantized_bid_amount: Decimal = self.market.quantize_order_amount(trading_pair, bid_amount) + + # make worst price so order wont be executed + current_ask_price: Decimal = self.market.get_price(trading_pair, False) * Decimal('2') + quantize_ask_price: Decimal = self.market.quantize_order_price(trading_pair, current_ask_price) + ask_amount: Decimal = Decimal('0.01') + quantized_ask_amount: Decimal = self.market.quantize_order_amount(trading_pair, ask_amount) + + if API_MOCK_ENABLED: + self.web_app.update_response("post", PRIVET_API_BASE_URL, "/api/v1/orders", + FixtureBeaxy.TEST_CANCEL_ALL_ORDER1) + + _, exch_order_id_1 = self.place_order(True, trading_pair, quantized_bid_amount, OrderType.LIMIT_MAKER, + quantize_bid_price) + + if API_MOCK_ENABLED: + self.web_app.update_response("post", PRIVET_API_BASE_URL, "/api/v1/orders", + FixtureBeaxy.TEST_CANCEL_ALL_ORDER2) + + _, exch_order_id_2 = self.place_order(False, trading_pair, quantized_ask_amount, OrderType.LIMIT_MAKER, + quantize_ask_price) + self.run_parallel(asyncio.sleep(1)) + + [cancellation_results] = self.run_parallel(self.market.cancel_all(5)) + + if API_MOCK_ENABLED: + HummingWsServerFactory.send_str_threadsafe(BeaxyConstants.TradingApi.WS_BASE_URL, + FixtureBeaxy.TEST_CANCEL_BUY_WS_ORDER1_CANCELED, delay=3) + HummingWsServerFactory.send_str_threadsafe(BeaxyConstants.TradingApi.WS_BASE_URL, + FixtureBeaxy.TEST_CANCEL_BUY_WS_ORDER2_CANCELED, delay=3) + + for cr in cancellation_results: + self.assertEqual(cr.success, True) + + def test_cancel_empty(self): + trading_pair = "DASH-BTC" + self.cancel_order(trading_pair, '123', '123') diff --git a/test/integration/test_beaxy_order_book_tracker.py b/test/integration/test_beaxy_order_book_tracker.py new file mode 100644 index 0000000000..8beac70df8 --- /dev/null +++ b/test/integration/test_beaxy_order_book_tracker.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python +import math +from os.path import ( + join, + realpath +) +import sys; sys.path.insert(0, realpath(join(__file__, "../../../"))) +from hummingbot.core.event.event_logger import EventLogger +from hummingbot.core.event.events import ( + OrderBookTradeEvent, + TradeType, + OrderBookEvent +) +import asyncio +import logging +import unittest +from typing import Dict, Optional, List + +from hummingbot.connector.exchange.beaxy.beaxy_order_book_tracker import BeaxyOrderBookTracker +from hummingbot.core.data_type.order_book import OrderBook +from hummingbot.core.data_type.order_book_tracker import OrderBookTrackerDataSource +from hummingbot.core.utils.async_utils import safe_ensure_future, safe_gather + + +class BeaxyOrderBookTrackerUnitTest(unittest.TestCase): + order_book_tracker: Optional[BeaxyOrderBookTracker] = None + events: List[OrderBookEvent] = [ + OrderBookEvent.TradeEvent + ] + trading_pairs: List[str] = [ + "BXY-BTC", + ] + + integrity_test_max_volume = 5 # Max volume in asks and bids for the book to be ready for tests + daily_volume = 2500 # Approximate total daily volume in BTC for this exchange for sanity test + book_enties = 5 # Number of asks and bids (each) for the book to be ready for tests + + @classmethod + def setUpClass(cls): + cls.ev_loop: asyncio.BaseEventLoop = asyncio.get_event_loop() + cls.order_book_tracker: BeaxyOrderBookTracker = BeaxyOrderBookTracker(cls.trading_pairs) + cls.order_book_tracker_task: asyncio.Task = safe_ensure_future(cls.order_book_tracker.start()) + cls.ev_loop.run_until_complete(cls.wait_til_tracker_ready()) + + @classmethod + async def wait_til_tracker_ready(cls): + print("Waiting for order book to fill...") + while True: + book_present = cls.trading_pairs[0] in cls.order_book_tracker.order_books + enough_asks = False + enough_bids = False + enough_ask_rows = False + enough_bid_rows = False + if book_present: + ask_volume = sum(i.amount for i in cls.order_book_tracker.order_books[cls.trading_pairs[0]].ask_entries()) + ask_count = sum(1 for i in cls.order_book_tracker.order_books[cls.trading_pairs[0]].ask_entries()) + + bid_volume = sum(i.amount for i in cls.order_book_tracker.order_books[cls.trading_pairs[0]].bid_entries()) + bid_count = sum(1 for i in cls.order_book_tracker.order_books[cls.trading_pairs[0]].bid_entries()) + + enough_asks = ask_volume >= cls.integrity_test_max_volume + enough_bids = bid_volume >= cls.integrity_test_max_volume + + enough_ask_rows = ask_count >= cls.book_enties + enough_bid_rows = bid_count >= cls.book_enties + + print("Bid volume in book: %f (in %d bids), ask volume in book: %f (in %d asks)" % (bid_volume, bid_count, ask_volume, ask_count)) + + if book_present and enough_asks and enough_bids and enough_ask_rows and enough_bid_rows: + print("Initialized real-time order books.") + return + await asyncio.sleep(1) + + async def run_parallel_async(self, *tasks, timeout=None): + future: asyncio.Future = safe_ensure_future(safe_gather(*tasks)) + timer = 0 + while not future.done(): + if timeout and timer > timeout: + raise Exception("Time out running parallel async task in tests.") + timer += 1 + await asyncio.sleep(1.0) + return future.result() + + def run_parallel(self, *tasks): + return self.ev_loop.run_until_complete(self.run_parallel_async(*tasks)) + + def setUp(self): + self.event_logger = EventLogger() + for event_tag in self.events: + for trading_pair, order_book in self.order_book_tracker.order_books.items(): + order_book.add_listener(event_tag, self.event_logger) + + def test_order_book_trade_event_emission(self): + """ + Test if order book tracker is able to retrieve order book trade message from exchange and + emit order book trade events after correctly parsing the trade messages + """ + self.run_parallel(self.event_logger.wait_for(OrderBookTradeEvent)) + for ob_trade_event in self.event_logger.event_log: + self.assertTrue(type(ob_trade_event) == OrderBookTradeEvent) + self.assertTrue(ob_trade_event.trading_pair in self.trading_pairs) + self.assertTrue(type(ob_trade_event.timestamp) == float) + self.assertTrue(type(ob_trade_event.amount) == float) + self.assertTrue(type(ob_trade_event.price) == float) + self.assertTrue(type(ob_trade_event.type) == TradeType) + self.assertTrue(math.ceil(math.log10(ob_trade_event.timestamp)) == 10) + self.assertTrue(ob_trade_event.amount > 0) + self.assertTrue(ob_trade_event.price > 0) + + def test_tracker_integrity(self): + order_books: Dict[str, OrderBook] = self.order_book_tracker.order_books + sut_book: OrderBook = order_books[self.trading_pairs[0]] + + self.assertGreater(sut_book.get_price(True), sut_book.get_price(False)) + + self.assertGreaterEqual(sut_book.get_price_for_volume(True, self.integrity_test_max_volume).result_price, + sut_book.get_price(True)) + + self.assertLessEqual(sut_book.get_price_for_volume(False, self.integrity_test_max_volume).result_price, + sut_book.get_price(False)) + + previous_price = sys.float_info.max + for bid_row in sut_book.bid_entries(): + self.assertTrue(previous_price >= bid_row.price) + previous_price = bid_row.price + + previous_price = 0 + for ask_row in sut_book.ask_entries(): + self.assertTrue(previous_price <= ask_row.price) + previous_price = ask_row.price + + def test_order_book_data_source(self): + self.assertTrue(isinstance(self.order_book_tracker.data_source, OrderBookTrackerDataSource)) + + def test_get_active_exchange_markets(self): + [active_markets_df] = self.run_parallel(self.order_book_tracker.data_source.get_active_exchange_markets()) + self.assertGreater(active_markets_df.size, 0) + self.assertTrue("baseAsset" in active_markets_df) + self.assertTrue("quoteAsset" in active_markets_df) + self.assertTrue("USDVolume" in active_markets_df) + + def test_get_trading_pairs(self): + [trading_pairs] = self.run_parallel(self.order_book_tracker.data_source.get_trading_pairs()) + self.assertGreater(len(trading_pairs), 0) + + +def main(): + logging.basicConfig(level=logging.INFO) + unittest.main() + + +if __name__ == "__main__": + main() diff --git a/test/integration/test_beaxy_user_stream_tracker.py b/test/integration/test_beaxy_user_stream_tracker.py new file mode 100644 index 0000000000..f27cbf6fea --- /dev/null +++ b/test/integration/test_beaxy_user_stream_tracker.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python + +from os.path import join, realpath +import sys + +import conf +from hummingbot.connector.exchange.beaxy.beaxy_auth import BeaxyAuth + +from hummingbot.connector.exchange.beaxy.beaxy_user_stream_tracker import BeaxyUserStreamTracker + +import asyncio +import logging +import unittest + +sys.path.insert(0, realpath(join(__file__, "../../../"))) + +logging.basicConfig(level=logging.DEBUG) + + +class BeaxyUserStreamTrackerUnitTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls.ev_loop: asyncio.AbstractEventLoop = asyncio.get_event_loop() + beaxy_auth = BeaxyAuth(conf.beaxy_api_key, + conf.beaxy_api_secret) + cls.user_stream_tracker: BeaxyUserStreamTracker = BeaxyUserStreamTracker(beaxy_auth) + cls.user_stream_tracker_task = asyncio.ensure_future(cls.user_stream_tracker.start()) + + def test_user_stream(self): + # Wait process some msgs. + self.ev_loop.run_until_complete(asyncio.sleep(120.0)) + print(self.user_stream_tracker.user_stream) + + +def main(): + unittest.main() + + +if __name__ == "__main__": + main() From b9bcd277288eb0aeea1f6b908d5709e8bcae0bcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dennis=20Oca=C3=B1a?= <50150287+dennisocana@users.noreply.github.com> Date: Tue, 9 Feb 2021 10:09:23 +0800 Subject: [PATCH 008/131] (release) update to dev-0.37.0 --- hummingbot/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hummingbot/VERSION b/hummingbot/VERSION index 93d4c1ef06..ebb3ff6b9d 100644 --- a/hummingbot/VERSION +++ b/hummingbot/VERSION @@ -1 +1 @@ -0.36.0 +dev-0.37.0 From c72fc464f9dbd5f5d4d1c783a12a1ae21db98cd5 Mon Sep 17 00:00:00 2001 From: Daniel Tan Date: Wed, 10 Feb 2021 19:35:41 +0800 Subject: [PATCH 009/131] (fix) remove excessive logs when dealing with empty trade_list in _update_order_status --- .../connector/exchange/crypto_com/crypto_com_exchange.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/hummingbot/connector/exchange/crypto_com/crypto_com_exchange.py b/hummingbot/connector/exchange/crypto_com/crypto_com_exchange.py index dcaba47b8d..2f41b996da 100644 --- a/hummingbot/connector/exchange/crypto_com/crypto_com_exchange.py +++ b/hummingbot/connector/exchange/crypto_com/crypto_com_exchange.py @@ -610,11 +610,9 @@ async def _update_order_status(self): self.logger().info(f"_update_order_status result not in resp: {response}") continue result = response["result"] - if "trade_list" not in result: - self.logger().info(f"{__name__}: trade_list not in result: {result}") - continue - for trade_msg in result["trade_list"]: - await self._process_trade_message(trade_msg) + if "trade_list" in result: + for trade_msg in result["trade_list"]: + await self._process_trade_message(trade_msg) self._process_order_message(result["order_info"]) def _process_order_message(self, order_msg: Dict[str, Any]): From cf3de6740e7501181cfc01f6bc1d893d2282280b Mon Sep 17 00:00:00 2001 From: vic-en Date: Wed, 10 Feb 2021 15:13:23 +0100 Subject: [PATCH 010/131] (feat) add derivative features and fix fetch_trading_pair --- .../binance_perpetual_derivative.py | 4 - .../perpetual_finance_derivative.py | 105 ++++++++++++------ .../perpetual_finance_in_flight_order.py | 20 ++-- 3 files changed, 86 insertions(+), 43 deletions(-) diff --git a/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_derivative.py b/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_derivative.py index 5670407a0c..b3b132693a 100644 --- a/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_derivative.py +++ b/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_derivative.py @@ -711,11 +711,7 @@ async def _update_balances(self): 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") diff --git a/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py index 181b277da7..03626b6dd0 100644 --- a/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py +++ b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py @@ -13,6 +13,7 @@ from hummingbot.core.utils.async_utils import safe_ensure_future, safe_gather from hummingbot.logger import HummingbotLogger from hummingbot.core.utils.tracking_nonce import get_tracking_nonce +from hummingbot.core.utils.estimate_fee import estimate_fee from hummingbot.core.data_type.limit_order import LimitOrder from hummingbot.core.data_type.cancellation_result import CancellationResult from hummingbot.core.event.events import ( @@ -25,17 +26,15 @@ OrderFilledEvent, OrderType, TradeType, - TradeFee, - # PositionSide, PositionMode, + PositionSide, PositionAction ) from hummingbot.connector.derivative_base import DerivativeBase from hummingbot.connector.derivative.perpetual_finance.perpetual_finance_in_flight_order import PerpetualFinanceInFlightOrder -from hummingbot.connector.derivative.perpetual_finance.perpetual_finance_utils import convert_to_exchange_trading_pair # convert_from_exchange_trading_pair +from hummingbot.connector.derivative.perpetual_finance.perpetual_finance_utils import convert_to_exchange_trading_pair, convert_from_exchange_trading_pair from hummingbot.client.settings import GATEAWAY_CA_CERT_PATH, GATEAWAY_CLIENT_CERT_PATH, GATEAWAY_CLIENT_KEY_PATH -from hummingbot.core.utils.eth_gas_station_lookup import get_gas_price from hummingbot.client.config.global_config_map import global_config_map -# from hummingbot.connector.derivative.position import Position +from hummingbot.connector.derivative.position import Position s_logger = None @@ -51,7 +50,7 @@ class PerpetualFinanceDerivative(DerivativeBase): """ API_CALL_TIMEOUT = 10.0 POLL_INTERVAL = 1.0 - UPDATE_BALANCE_INTERVAL = 30.0 + UPDATE_BALANCE_INTERVAL = 5.0 @classmethod def logger(cls) -> HummingbotLogger: @@ -91,16 +90,38 @@ def __init__(self, def name(self): return "perpetual_finance" - """@staticmethod + @staticmethod async def fetch_trading_pairs() -> List[str]: - resp = await self._api_request("get", "perpfi/get-pairs") - pairs = resp.get("pairs", []) - if len(pairs) == 0: - await self.load_metadata() + ssl_ctx = ssl.create_default_context(cafile=GATEAWAY_CA_CERT_PATH) + ssl_ctx.load_cert_chain(GATEAWAY_CLIENT_CERT_PATH, GATEAWAY_CLIENT_KEY_PATH) + conn = aiohttp.TCPConnector(ssl_context=ssl_ctx) + client = aiohttp.ClientSession(connector=conn) + + base_url = f"https://{global_config_map['gateway_api_host'].value}:" \ + f"{global_config_map['gateway_api_port'].value}/perpfi/" + response = await client.get(base_url + "pairs") + parsed_response = json.loads(await response.text()) + if response.status != 200: + err_msg = "" + if "error" in parsed_response: + err_msg = f" Message: {parsed_response['error']}" + raise IOError(f"Error fetching pairs from gateway. HTTP status is {response.status}.{err_msg}") + pairs = parsed_response.get("pairs", []) + if "error" in parsed_response or len(pairs) == 0: + raise Exception(f"Error: {parsed_response['error']}") + else: + status = await client.get(base_url) + status = json.loads(await status.text()) + loadedMetadata = status["loadedMetadata"] + while (not loadedMetadata): + resp = await client.get(base_url + "load-metadata") + resp = json.loads(await resp.text()) + loadedMetadata = resp.get("loadedMetadata", False) + return PerpetualFinanceDerivative.fetch_trading_pairs() trading_pairs = [] for pair in pairs: trading_pairs.append(convert_from_exchange_trading_pair(pair)) - return trading_pairs""" + return trading_pairs @property def limit_orders(self) -> List[LimitOrder]: @@ -169,7 +190,7 @@ async def get_quote_price(self, trading_pair: str, is_buy: bool, amount: Decimal try: side = "buy" if is_buy else "sell" resp = await self._api_request("post", - "perpfi/get-price", + "perpfi/price", {"side": side, "pair": convert_to_exchange_trading_pair(trading_pair), "amount": amount}) @@ -249,7 +270,6 @@ async def _create_order(self, amount = self.quantize_order_amount(trading_pair, amount) price = self.quantize_order_price(trading_pair, price) base, quote = trading_pair.split("-") - gas_price = get_gas_price() api_params = {"pair": convert_to_exchange_trading_pair(trading_pair)} if position_action == PositionAction.OPEN: api_params.update({"side": 0 if trade_type == TradeType.BUY else 1, @@ -258,18 +278,17 @@ async def _create_order(self, "minBaseAssetAmount": amount}) else: api_params.update({"minimalQuoteAsset": price * amount}) - self.start_tracking_order(order_id, None, trading_pair, trade_type, price, amount, gas_price) + self.start_tracking_order(order_id, None, trading_pair, trade_type, price, amount, self._leverage, position_action.name) try: - order_result = await self._api_request("post", f"perpfi/{trade_type.name.lower()}", api_params) + order_result = await self._api_request("post", f"perpfi/{position_action.name.lower()}", api_params) hash = order_result.get("txHash") tracked_order = self._in_flight_orders.get(order_id) if tracked_order is not None: self.logger().info(f"Created {trade_type.name} order {order_id} txHash: {hash} " f"for {amount} {trading_pair}.") tracked_order.update_exchange_order_id(hash) - tracked_order.gas_price = gas_price if hash is not None: - tracked_order.fee_asset = "ETH" + tracked_order.fee_asset = "XDAI" tracked_order.executed_amount_base = amount tracked_order.executed_amount_quote = amount * price event_tag = MarketEvent.BuyOrderCreated if trade_type is TradeType.BUY else MarketEvent.SellOrderCreated @@ -300,7 +319,6 @@ def start_tracking_order(self, trade_type: TradeType, price: Decimal, amount: Decimal, - gas_price: Decimal, leverage: int, position: str,): """ @@ -314,7 +332,6 @@ def start_tracking_order(self, trade_type=trade_type, price=price, amount=amount, - gas_price=gas_price, leverage=leverage, position=position ) @@ -337,7 +354,7 @@ async def _update_order_status(self): for tracked_order in tracked_orders: order_id = await tracked_order.get_exchange_order_id() tasks.append(self._api_request("post", - "eth/get-receipt", + "perpfi/receipt", {"txHash": order_id})) update_results = await safe_gather(*tasks, return_exceptions=True) for update_result in update_results: @@ -349,9 +366,8 @@ async def _update_order_status(self): continue if update_result["confirmed"] is True: if update_result["receipt"]["status"] == 1: - gas_used = update_result["receipt"]["gasUsed"] - gas_price = tracked_order.gas_price - fee = Decimal(str(gas_used)) * Decimal(str(gas_price)) / Decimal(str(1e9)) + fee = estimate_fee("perpetual_finance", False) + fee.flat_fees = [(tracked_order.fee_asset, Decimal(str(update_result["receipt"]["gasUsed"])))] self.trigger_event( MarketEvent.OrderFilled, OrderFilledEvent( @@ -362,7 +378,7 @@ async def _update_order_status(self): tracked_order.order_type, Decimal(str(tracked_order.price)), Decimal(str(tracked_order.amount)), - TradeFee(0.0, [(tracked_order.fee_asset, Decimal(str(fee)))]), + fee, exchange_trade_id=order_id ) ) @@ -413,8 +429,7 @@ def has_allowances(self) -> bool: """ Checks if all tokens have allowance (an amount approved) """ - return len(self._allowances.values()) == len(self._token_addresses.values()) and \ - all(amount > s_decimal_0 for amount in self._allowances.values()) + return all(amount > s_decimal_0 for amount in self._allowances.values()) @property def status_dict(self) -> Dict[str, bool]: @@ -485,12 +500,12 @@ async def _update_balances(self): self._last_balance_poll_timestamp = current_tick local_asset_names = set(self._account_balances.keys()) remote_asset_names = set() - resp_json = await self._api_request("post", "perpfi/balances") - for token, bal in resp_json["balances"].items(): - if len(token) > 4: - token = self.get_token(token) + balances_resp = await self._api_request("post", "perpfi/balances") + margin_resp = await self._api_request("post", "perpfi/margin") + for token, bal in balances_resp["balances"].items(): self._account_available_balances[token] = Decimal(str(bal)) - self._account_balances[token] = Decimal(str(bal)) + self._account_balances[token] = Decimal(str(bal)) + Decimal(str(margin_resp["margin"])) if token == "USDC" \ + else Decimal(str(bal)) remote_asset_names.add(token) asset_names_to_remove = local_asset_names.difference(remote_asset_names) @@ -501,6 +516,32 @@ async def _update_balances(self): self._in_flight_orders_snapshot = {k: copy.copy(v) for k, v in self._in_flight_orders.items()} self._in_flight_orders_snapshot_timestamp = self.current_timestamp + async def _update_positions(self): + tasks = [] + for pair in self._trading_pairs: + tasks.append(self._api_request("post", + "perpfi/position", + {"pair": convert_to_exchange_trading_pair(pair)})) + positions = await safe_gather(*tasks, return_exceptions=True) + for trading_pair, position in zip(self._trading_pairs, positions.get("position", {})): + position_side = PositionSide.LONG if position.get("size", 0) > 0 else PositionSide.SHORT + unrealized_pnl = Decimal(position.get("pnl")) + entry_price = Decimal(position.get("entryPrice")) + amount = Decimal(position.get("size")) + leverage = self._leverage + if amount != 0: + self._account_positions[trading_pair + position_side.name] = Position( + 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 _http_client(self) -> aiohttp.ClientSession: """ :returns Shared client session instance diff --git a/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_in_flight_order.py b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_in_flight_order.py index d7dcc0fc1f..3dbaad3128 100644 --- a/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_in_flight_order.py +++ b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_in_flight_order.py @@ -18,7 +18,6 @@ def __init__(self, trade_type: TradeType, price: Decimal, amount: Decimal, - gas_price: Decimal, leverage: int, position: str, initial_state: str = "OPEN"): @@ -33,7 +32,6 @@ def __init__(self, initial_state, ) self.trade_id_set = set() - self._gas_price = gas_price self.leverage = leverage self.position = position @@ -50,9 +48,17 @@ def is_cancelled(self) -> bool: return self.last_state in {"CANCELED", "EXPIRED"} @property - def gas_price(self) -> Decimal: - return self._gas_price + def leverage(self) -> Decimal: + return self.leverage - @gas_price.setter - def gas_price(self, gas_price) -> Decimal: - self._gas_price = gas_price + @leverage.setter + def leverage(self, leverage) -> Decimal: + self.leverage = leverage + + @property + def position(self) -> Decimal: + return self.position + + @position.setter + def position(self, position) -> Decimal: + self.position = position From 9fa551290a0a92363966a4495439e6078c790c4f Mon Sep 17 00:00:00 2001 From: Paul Widden Date: Fri, 12 Feb 2021 13:13:03 -0800 Subject: [PATCH 011/131] (feat) Add 'Exchange Total' to 'balance' command --- hummingbot/client/command/balance_command.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/hummingbot/client/command/balance_command.py b/hummingbot/client/command/balance_command.py index cd4a1c0508..05772cb075 100644 --- a/hummingbot/client/command/balance_command.py +++ b/hummingbot/client/command/balance_command.py @@ -84,6 +84,8 @@ async def show_balances(self): if all_ex_limits is None: all_ex_limits = {} + exchanges_total = 0 + for exchange, bals in all_ex_bals.items(): self._notify(f"\n{exchange}:") # df = await self.exchange_balances_df(bals, all_ex_limits.get(exchange, {})) @@ -95,6 +97,9 @@ async def show_balances(self): self._notify("\n".join(lines)) self._notify(f"\n Total: $ {df['Total ($)'].sum():.0f} " f"Allocated: {allocated_total / df['Total ($)'].sum():.2%}") + exchanges_total += df['Total ($)'].sum() + + self._notify(f"\n\nExchanges Total: $ {exchanges_total:.0f} ") celo_address = global_config_map["celo_address"].value if celo_address is not None: From 6841127af9a72a73991ea4396a712e32ac88a06b Mon Sep 17 00:00:00 2001 From: Daniel Tan Date: Sun, 14 Feb 2021 13:50:25 +0800 Subject: [PATCH 012/131] (add) probit orderbook tracker [WIP] --- .../connector/exchange/probit/__init__.py | 0 .../probit_api_order_book_data_source.py | 204 ++++++++++++++++++ .../exchange/probit/probit_constants.py | 13 ++ .../exchange/probit/probit_order_book.py | 146 +++++++++++++ .../probit/probit_order_book_message.py | 0 .../probit/probit_order_book_tracker.py | 0 .../probit/probit_order_book_tracker_entry.py | 0 .../connector/exchange/probit/probit_utils.py | 18 ++ 8 files changed, 381 insertions(+) create mode 100644 hummingbot/connector/exchange/probit/__init__.py create mode 100644 hummingbot/connector/exchange/probit/probit_api_order_book_data_source.py create mode 100644 hummingbot/connector/exchange/probit/probit_constants.py create mode 100644 hummingbot/connector/exchange/probit/probit_order_book.py create mode 100644 hummingbot/connector/exchange/probit/probit_order_book_message.py create mode 100644 hummingbot/connector/exchange/probit/probit_order_book_tracker.py create mode 100644 hummingbot/connector/exchange/probit/probit_order_book_tracker_entry.py create mode 100644 hummingbot/connector/exchange/probit/probit_utils.py diff --git a/hummingbot/connector/exchange/probit/__init__.py b/hummingbot/connector/exchange/probit/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/hummingbot/connector/exchange/probit/probit_api_order_book_data_source.py b/hummingbot/connector/exchange/probit/probit_api_order_book_data_source.py new file mode 100644 index 0000000000..0bdb8ed737 --- /dev/null +++ b/hummingbot/connector/exchange/probit/probit_api_order_book_data_source.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python +import asyncio +import logging +import time +import aiohttp +import pandas as pd +import hummingbot.connector.exchange.probit.probit_constants as constants + +from typing import Optional, List, Dict, Any +from hummingbot.core.data_type.order_book import OrderBook +from hummingbot.core.data_type.order_book_message import OrderBookMessage +from hummingbot.core.data_type.order_book_tracker_data_source import OrderBookTrackerDataSource +from hummingbot.logger import HummingbotLogger +from . import probit_utils +from .probit_order_book import ProbitOrderBook +from .probit_websocket import ProbitWebsocket +from .probit_utils import ms_timestamp_to_s + + +class ProbitAPIOrderBookDataSource(OrderBookTrackerDataSource): + MAX_RETRIES = 20 + MESSAGE_TIMEOUT = 30.0 + SNAPSHOT_TIMEOUT = 10.0 + + _logger: Optional[HummingbotLogger] = None + + @classmethod + def logger(cls) -> HummingbotLogger: + if cls._logger is None: + cls._logger = logging.getLogger(__name__) + return cls._logger + + def __init__(self, trading_pairs: List[str] = None): + super().__init__(trading_pairs) + self._trading_pairs: List[str] = trading_pairs + self._snapshot_msg: Dict[str, any] = {} + + @classmethod + async def get_last_traded_prices(cls, trading_pairs: List[str]) -> Dict[str, float]: + result = {} + async with aiohttp.ClientSession() as client: + async with client.get(f"{constants.TICKER_PATH_URL}") as response: + if response.status == 200: + resp_json = await response.json() + if "data" in resp_json: + for trading_pair in resp_json["data"]: + result[trading_pair["market_id"]] = trading_pair["last"] + return result + + @staticmethod + async def fetch_trading_pairs() -> List[str]: + async with aiohttp.ClientSession() as client: + async with client.get(f"{constants.MARKETS_PATH_URL}") as response: + if response.status == 200: + resp_json: Dict[str, Any] = await response.json() + return [market["market_id"] for market in resp_json["data"]] + return [] + + @staticmethod + async def get_order_book_data(trading_pair: str) -> Dict[str, any]: + """ + Get whole orderbook + """ + async with aiohttp.ClientSession() as client: + async with client.get(url=f"{constants.ORDER_BOOK_PATH_URL}", + params={"market_id": trading_pair}) as response: + if response.status != 200: + raise IOError( + f"Error fetching OrderBook for {trading_pair} at {constants.ORDER_BOOK_PATH_URL}. " + f"HTTP {response.status}. Response: {await response.json()}" + ) + return await response.json() + + async def get_new_order_book(self, trading_pair: str) -> OrderBook: + snapshot: Dict[str, Any] = await self.get_order_book_data(trading_pair) + snapshot_timestamp: float = time.time() + snapshot_msg: OrderBookMessage = ProbitOrderBook.snapshot_message_from_exchange( + snapshot, + snapshot_timestamp, + metadata={"trading_pair": trading_pair} + ) + order_book = self.order_book_create_function() + bids, asks = probit_utils.convert_snapshot_message_to_order_book_row(snapshot_msg) + order_book.apply_snapshot(bids, asks, snapshot_msg.update_id) + return order_book + + async def listen_for_trades(self, ev_loop: asyncio.BaseEventLoop, output: asyncio.Queue): + """ + Listen for trades using websocket trade channel + """ + while True: + try: + ws = ProbitWebsocket() + await ws.connect() + + await ws.subscribe(list(map( + lambda pair: f"trade.{probit_utils.convert_to_exchange_trading_pair(pair)}", + self._trading_pairs + ))) + + async for response in ws.on_message(): + if response.get("result") is None: + continue + + for trade in response["result"]["data"]: + trade: Dict[Any] = trade + trade_timestamp: int = ms_timestamp_to_s(trade["t"]) + trade_msg: OrderBookMessage = ProbitOrderBook.trade_message_from_exchange( + trade, + trade_timestamp, + metadata={"trading_pair": probit_utils.convert_from_exchange_trading_pair(trade["i"])} + ) + output.put_nowait(trade_msg) + + except asyncio.CancelledError: + raise + except Exception: + self.logger().error("Unexpected error.", exc_info=True) + await asyncio.sleep(5.0) + finally: + await ws.disconnect() + + async def listen_for_order_book_diffs(self, ev_loop: asyncio.BaseEventLoop, output: asyncio.Queue): + """ + Listen for orderbook diffs using websocket book channel + """ + while True: + try: + ws = ProbitWebsocket() + await ws.connect() + + await ws.subscribe(list(map( + lambda pair: f"book.{probit_utils.convert_to_exchange_trading_pair(pair)}.150", + self._trading_pairs + ))) + + async for response in ws.on_message(): + if response.get("result") is None: + continue + + order_book_data = response["result"]["data"][0] + timestamp: int = ms_timestamp_to_s(order_book_data["t"]) + # data in this channel is not order book diff but the entire order book (up to depth 150). + # so we need to convert it into a order book snapshot. + # Crypto.com does not offer order book diff ws updates. + orderbook_msg: OrderBookMessage = ProbitOrderBook.snapshot_message_from_exchange( + order_book_data, + timestamp, + metadata={"trading_pair": probit_utils.convert_from_exchange_trading_pair( + response["result"]["instrument_name"])} + ) + output.put_nowait(orderbook_msg) + + except asyncio.CancelledError: + raise + except Exception: + self.logger().network( + "Unexpected error with WebSocket connection.", + exc_info=True, + app_warning_msg="Unexpected error with WebSocket connection. Retrying in 30 seconds. " + "Check network connection." + ) + await asyncio.sleep(30.0) + finally: + await ws.disconnect() + + async def listen_for_order_book_snapshots(self, ev_loop: asyncio.BaseEventLoop, output: asyncio.Queue): + """ + Listen for orderbook snapshots by fetching orderbook + """ + while True: + try: + for trading_pair in self._trading_pairs: + try: + snapshot: Dict[str, any] = await self.get_order_book_data(trading_pair) + snapshot_timestamp: int = ms_timestamp_to_s(snapshot["t"]) + snapshot_msg: OrderBookMessage = ProbitOrderBook.snapshot_message_from_exchange( + snapshot, + snapshot_timestamp, + metadata={"trading_pair": trading_pair} + ) + output.put_nowait(snapshot_msg) + self.logger().debug(f"Saved order book snapshot for {trading_pair}") + # Be careful not to go above API rate limits. + await asyncio.sleep(5.0) + except asyncio.CancelledError: + raise + except Exception: + self.logger().network( + "Unexpected error with WebSocket connection.", + exc_info=True, + app_warning_msg="Unexpected error with WebSocket connection. Retrying in 5 seconds. " + "Check network connection." + ) + await asyncio.sleep(5.0) + this_hour: pd.Timestamp = pd.Timestamp.utcnow().replace(minute=0, second=0, microsecond=0) + next_hour: pd.Timestamp = this_hour + pd.Timedelta(hours=1) + delta: float = next_hour.timestamp() - time.time() + await asyncio.sleep(delta) + except asyncio.CancelledError: + raise + except Exception: + self.logger().error("Unexpected error.", exc_info=True) + await asyncio.sleep(5.0) diff --git a/hummingbot/connector/exchange/probit/probit_constants.py b/hummingbot/connector/exchange/probit/probit_constants.py new file mode 100644 index 0000000000..5486551910 --- /dev/null +++ b/hummingbot/connector/exchange/probit/probit_constants.py @@ -0,0 +1,13 @@ +# A single source of truth for constant variables related to the exchange + +EXCHANGE_NAME = "probit" + +REST_URL = "https://api.probit.com/api/exchange/" +WSS_URL = "wss://api.probit.com/api/exchange/v1/ws" + +API_VERSON = "v1" + +TICKER_PATH_URL = f"{REST_URL+API_VERSON}/ticker" +MARKETS_PATH_URL = f"{REST_URL+API_VERSON}/market" +ORDER_BOOK_PATH_URL = f"{REST_URL+API_VERSON}/order_book" +NEW_ORDER_PATH_URL = f"{REST_URL+API_VERSON}/new_order" diff --git a/hummingbot/connector/exchange/probit/probit_order_book.py b/hummingbot/connector/exchange/probit/probit_order_book.py new file mode 100644 index 0000000000..7217094899 --- /dev/null +++ b/hummingbot/connector/exchange/probit/probit_order_book.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python + +import logging +import hummingbot.connector.exchange.probit.probit_constants as constants + +from sqlalchemy.engine import RowProxy +from typing import ( + Optional, + Dict, + List, Any) +from hummingbot.logger import HummingbotLogger +from hummingbot.core.data_type.order_book import OrderBook +from hummingbot.core.data_type.order_book_message import ( + OrderBookMessage, OrderBookMessageType +) +from hummingbot.connector.exchange.probit.probit_order_book_message import ProbitOrderBookMessage + +_logger = None + + +class ProbitOrderBook(OrderBook): + @classmethod + def logger(cls) -> HummingbotLogger: + global _logger + if _logger is None: + _logger = logging.getLogger(__name__) + return _logger + + @classmethod + def snapshot_message_from_exchange(cls, + msg: Dict[str, any], + timestamp: float, + metadata: Optional[Dict] = None): + """ + Convert json snapshot data into standard OrderBookMessage format + :param msg: json snapshot data from live web socket stream + :param timestamp: timestamp attached to incoming data + :return: ProbitOrderBookMessage + """ + + if metadata: + msg.update(metadata) + + return ProbitOrderBookMessage( + message_type=OrderBookMessageType.SNAPSHOT, + content=msg, + timestamp=timestamp + ) + + @classmethod + def snapshot_message_from_db(cls, record: RowProxy, metadata: Optional[Dict] = None): + """ + *used for backtesting + Convert a row of snapshot data into standard OrderBookMessage format + :param record: a row of snapshot data from the database + :return: ProbitOrderBookMessage + """ + return ProbitOrderBookMessage( + message_type=OrderBookMessageType.SNAPSHOT, + content=record.json, + timestamp=record.timestamp + ) + + @classmethod + def diff_message_from_exchange(cls, + msg: Dict[str, any], + timestamp: Optional[float] = None, + metadata: Optional[Dict] = None): + """ + Convert json diff data into standard OrderBookMessage format + :param msg: json diff data from live web socket stream + :param timestamp: timestamp attached to incoming data + :return: ProbitOrderBookMessage + """ + + if metadata: + msg.update(metadata) + + return ProbitOrderBookMessage( + message_type=OrderBookMessageType.DIFF, + content=msg, + timestamp=timestamp + ) + + @classmethod + def diff_message_from_db(cls, record: RowProxy, metadata: Optional[Dict] = None): + """ + *used for backtesting + Convert a row of diff data into standard OrderBookMessage format + :param record: a row of diff data from the database + :return: ProbitOrderBookMessage + """ + return ProbitOrderBookMessage( + message_type=OrderBookMessageType.DIFF, + content=record.json, + timestamp=record.timestamp + ) + + @classmethod + def trade_message_from_exchange(cls, + msg: Dict[str, Any], + timestamp: Optional[float] = None, + metadata: Optional[Dict] = None): + """ + Convert a trade data into standard OrderBookMessage format + :param record: a trade data from the database + :return: ProbitOrderBookMessage + """ + + if metadata: + msg.update(metadata) + + msg.update({ + "exchange_order_id": msg.get("d"), + "trade_type": msg.get("s"), + "price": msg.get("p"), + "amount": msg.get("q"), + }) + + return ProbitOrderBookMessage( + message_type=OrderBookMessageType.TRADE, + content=msg, + timestamp=timestamp + ) + + @classmethod + def trade_message_from_db(cls, record: RowProxy, metadata: Optional[Dict] = None): + """ + *used for backtesting + Convert a row of trade data into standard OrderBookMessage format + :param record: a row of trade data from the database + :return: ProbitOrderBookMessage + """ + return ProbitOrderBookMessage( + message_type=OrderBookMessageType.TRADE, + content=record.json, + timestamp=record.timestamp + ) + + @classmethod + def from_snapshot(cls, snapshot: OrderBookMessage): + raise NotImplementedError(constants.EXCHANGE_NAME + " order book needs to retain individual order data.") + + @classmethod + def restore_from_snapshot_and_diffs(cls, snapshot: OrderBookMessage, diffs: List[OrderBookMessage]): + raise NotImplementedError(constants.EXCHANGE_NAME + " order book needs to retain individual order data.") diff --git a/hummingbot/connector/exchange/probit/probit_order_book_message.py b/hummingbot/connector/exchange/probit/probit_order_book_message.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/hummingbot/connector/exchange/probit/probit_order_book_tracker.py b/hummingbot/connector/exchange/probit/probit_order_book_tracker.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/hummingbot/connector/exchange/probit/probit_order_book_tracker_entry.py b/hummingbot/connector/exchange/probit/probit_order_book_tracker_entry.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/hummingbot/connector/exchange/probit/probit_utils.py b/hummingbot/connector/exchange/probit/probit_utils.py new file mode 100644 index 0000000000..f070728dc7 --- /dev/null +++ b/hummingbot/connector/exchange/probit/probit_utils.py @@ -0,0 +1,18 @@ +from typing import ( + Any, + Dict, +) + +CENTRALIZED = True + +EXAMPLE_PAIR = "ETH-USDT" + +DEFAULT_FEES = [0.2, 0.2] + + +def convert_snapshot_message_to_order_book_row(message: Dict[str, Any]): + pass + + +def convert_diff_message_to_order_book_row(message: Dict[str, Any]): + pass From d911935f362687684201ed60b97d7661440729b4 Mon Sep 17 00:00:00 2001 From: Daniel Tan Date: Mon, 15 Feb 2021 16:47:18 +0800 Subject: [PATCH 013/131] (add) probit dummy files --- hummingbot/connector/exchange/probit/dummy.pxd | 2 ++ hummingbot/connector/exchange/probit/dummy.pyx | 2 ++ 2 files changed, 4 insertions(+) create mode 100644 hummingbot/connector/exchange/probit/dummy.pxd create mode 100644 hummingbot/connector/exchange/probit/dummy.pyx diff --git a/hummingbot/connector/exchange/probit/dummy.pxd b/hummingbot/connector/exchange/probit/dummy.pxd new file mode 100644 index 0000000000..4b098d6f59 --- /dev/null +++ b/hummingbot/connector/exchange/probit/dummy.pxd @@ -0,0 +1,2 @@ +cdef class dummy(): + pass diff --git a/hummingbot/connector/exchange/probit/dummy.pyx b/hummingbot/connector/exchange/probit/dummy.pyx new file mode 100644 index 0000000000..4b098d6f59 --- /dev/null +++ b/hummingbot/connector/exchange/probit/dummy.pyx @@ -0,0 +1,2 @@ +cdef class dummy(): + pass From 637c8e63bd65574ca6369e2c23f168e97ac89ca7 Mon Sep 17 00:00:00 2001 From: Daniel Tan Date: Mon, 15 Feb 2021 20:39:58 +0800 Subject: [PATCH 014/131] (add) add ProbitOrderBookTracker[WIP] --- .../probit_api_order_book_data_source.py | 157 +++++++++++------- .../exchange/probit/probit_constants.py | 27 ++- .../exchange/probit/probit_order_book.py | 8 +- .../probit/probit_order_book_message.py | 89 ++++++++++ .../probit/probit_order_book_tracker.py | 108 ++++++++++++ .../connector/exchange/probit/probit_utils.py | 67 +++++++- 6 files changed, 381 insertions(+), 75 deletions(-) diff --git a/hummingbot/connector/exchange/probit/probit_api_order_book_data_source.py b/hummingbot/connector/exchange/probit/probit_api_order_book_data_source.py index 0bdb8ed737..33e41b86cb 100644 --- a/hummingbot/connector/exchange/probit/probit_api_order_book_data_source.py +++ b/hummingbot/connector/exchange/probit/probit_api_order_book_data_source.py @@ -1,20 +1,27 @@ #!/usr/bin/env python +import aiohttp import asyncio import logging -import time -import aiohttp import pandas as pd +import time +import ujson +import websockets + import hummingbot.connector.exchange.probit.probit_constants as constants -from typing import Optional, List, Dict, Any +from typing import ( + Any, + AsyncIterable, + Dict, + List, + Optional, +) from hummingbot.core.data_type.order_book import OrderBook from hummingbot.core.data_type.order_book_message import OrderBookMessage from hummingbot.core.data_type.order_book_tracker_data_source import OrderBookTrackerDataSource from hummingbot.logger import HummingbotLogger -from . import probit_utils -from .probit_order_book import ProbitOrderBook -from .probit_websocket import ProbitWebsocket -from .probit_utils import ms_timestamp_to_s +from hummingbot.connector.exchange.probit import probit_utils +from hummingbot.connector.exchange.probit.probit_order_book import ProbitOrderBook class ProbitAPIOrderBookDataSource(OrderBookTrackerDataSource): @@ -39,7 +46,7 @@ def __init__(self, trading_pairs: List[str] = None): async def get_last_traded_prices(cls, trading_pairs: List[str]) -> Dict[str, float]: result = {} async with aiohttp.ClientSession() as client: - async with client.get(f"{constants.TICKER_PATH_URL}") as response: + async with client.get(f"{constants.TICKER_URL}") as response: if response.status == 200: resp_json = await response.json() if "data" in resp_json: @@ -50,7 +57,7 @@ async def get_last_traded_prices(cls, trading_pairs: List[str]) -> Dict[str, flo @staticmethod async def fetch_trading_pairs() -> List[str]: async with aiohttp.ClientSession() as client: - async with client.get(f"{constants.MARKETS_PATH_URL}") as response: + async with client.get(f"{constants.MARKETS_URL}") as response: if response.status == 200: resp_json: Dict[str, Any] = await response.json() return [market["market_id"] for market in resp_json["data"]] @@ -62,7 +69,7 @@ async def get_order_book_data(trading_pair: str) -> Dict[str, any]: Get whole orderbook """ async with aiohttp.ClientSession() as client: - async with client.get(url=f"{constants.ORDER_BOOK_PATH_URL}", + async with client.get(url=f"{constants.ORDER_BOOK_URL}", params={"market_id": trading_pair}) as response: if response.status != 200: raise IOError( @@ -84,41 +91,65 @@ async def get_new_order_book(self, trading_pair: str) -> OrderBook: order_book.apply_snapshot(bids, asks, snapshot_msg.update_id) return order_book + async def _inner_messages(self, + ws: websockets.WebSocketClientProtocol) -> AsyncIterable[str]: + try: + while True: + msg: str = await asyncio.wait_for(ws.recv(), timeout=self.MESSAGE_TIMEOUT) + yield msg + except asyncio.TimeoutError: + try: + pong_waiter = await ws.ping() + await asyncio.wait_for(pong_waiter, timeout=self.PING_TIMEOUT) + except asyncio.TimeoutError: + raise + except websockets.exceptions.ConnectionClosed: + return + finally: + await ws.close() + + async def listen_for_order_book_diffs_trades(self, ev_loop: asyncio.BaseEventLoop, output: asyncio.Queue): + # TODO: Combine both trades and order_book_diffs + # params: Dict[str, Any] = { + # "channel": "marketdata", + # "filter": ["order_books","recent_trades"], + # "interval": 100, + # "market_id": trading_pair, + # "type": "subscribe" + # } + pass + async def listen_for_trades(self, ev_loop: asyncio.BaseEventLoop, output: asyncio.Queue): """ Listen for trades using websocket trade channel """ while True: try: - ws = ProbitWebsocket() - await ws.connect() - - await ws.subscribe(list(map( - lambda pair: f"trade.{probit_utils.convert_to_exchange_trading_pair(pair)}", - self._trading_pairs - ))) - - async for response in ws.on_message(): - if response.get("result") is None: - continue - - for trade in response["result"]["data"]: - trade: Dict[Any] = trade - trade_timestamp: int = ms_timestamp_to_s(trade["t"]) - trade_msg: OrderBookMessage = ProbitOrderBook.trade_message_from_exchange( - trade, - trade_timestamp, - metadata={"trading_pair": probit_utils.convert_from_exchange_trading_pair(trade["i"])} - ) - output.put_nowait(trade_msg) - + async with websockets.connect(uri=constants.WSS_URL) as ws: + ws: websockets.WebSocketClientProtocol = ws + for trading_pair in self._trading_pairs: + params: Dict[str, Any] = { + "channel": "marketdata", + "filter": ["recent_trades"], + "interval": 100, + "market_id": trading_pair, + "type": "subscribe" + } + await ws.send(ujson.dumps(params)) + async for raw_msg in self._inner_messages(ws): + msg = ujson.loads(raw_msg) + if "recent_trades" not in msg: + continue + for trade_entry in msg["recent_trades"]: + trade_msg: OrderBookMessage = ProbitOrderBook.trade_message_from_exchange(trade_entry) + output.put_nowait(trade_msg) except asyncio.CancelledError: raise except Exception: self.logger().error("Unexpected error.", exc_info=True) await asyncio.sleep(5.0) finally: - await ws.disconnect() + await ws.close() async def listen_for_order_book_diffs(self, ev_loop: asyncio.BaseEventLoop, output: asyncio.Queue): """ @@ -126,31 +157,33 @@ async def listen_for_order_book_diffs(self, ev_loop: asyncio.BaseEventLoop, outp """ while True: try: - ws = ProbitWebsocket() - await ws.connect() - - await ws.subscribe(list(map( - lambda pair: f"book.{probit_utils.convert_to_exchange_trading_pair(pair)}.150", - self._trading_pairs - ))) - - async for response in ws.on_message(): - if response.get("result") is None: - continue - - order_book_data = response["result"]["data"][0] - timestamp: int = ms_timestamp_to_s(order_book_data["t"]) - # data in this channel is not order book diff but the entire order book (up to depth 150). - # so we need to convert it into a order book snapshot. - # Crypto.com does not offer order book diff ws updates. - orderbook_msg: OrderBookMessage = ProbitOrderBook.snapshot_message_from_exchange( - order_book_data, - timestamp, - metadata={"trading_pair": probit_utils.convert_from_exchange_trading_pair( - response["result"]["instrument_name"])} - ) - output.put_nowait(orderbook_msg) - + async with websockets.connect(uri=constants.WSS_URL) as ws: + ws: websockets.WebSocketClientProtocol = ws + for trading_pair in self._trading_pairs: + params: Dict[str, Any] = { + "channel": "marketdata", + "filter": ["order_books"], + "interval": 100, + "market_id": trading_pair, + "type": "subscribe" + } + await ws.send(ujson.dumps(params)) + async for raw_msg in self._inner_messages(ws): + msg_timestamp: int = int(time.time() * 1e3) + msg: Dict[str, Any] = ujson.loads(raw_msg) + if "order_books" not in msg: + continue + if "reset" in msg and msg["reset"] is True: + # First response from websocket is a snapshot. This is only when reset = True + snapshot_msg: OrderBookMessage = ProbitOrderBook.snapshot_message_from_exchange( + msg=msg, + timestamp=msg_timestamp, + ) + output.put_nowait(snapshot_msg) + for diff_entry in msg["order_books"]: + diff_msg: OrderBookMessage = ProbitOrderBook.diff_message_from_exchange(diff_entry, + msg_timestamp) + output.put_nowait(diff_msg) except asyncio.CancelledError: raise except Exception: @@ -162,7 +195,7 @@ async def listen_for_order_book_diffs(self, ev_loop: asyncio.BaseEventLoop, outp ) await asyncio.sleep(30.0) finally: - await ws.disconnect() + await ws.close() async def listen_for_order_book_snapshots(self, ev_loop: asyncio.BaseEventLoop, output: asyncio.Queue): """ @@ -173,10 +206,10 @@ async def listen_for_order_book_snapshots(self, ev_loop: asyncio.BaseEventLoop, for trading_pair in self._trading_pairs: try: snapshot: Dict[str, any] = await self.get_order_book_data(trading_pair) - snapshot_timestamp: int = ms_timestamp_to_s(snapshot["t"]) + snapshot_timestamp: int = int(time.time() * 1e3) snapshot_msg: OrderBookMessage = ProbitOrderBook.snapshot_message_from_exchange( - snapshot, - snapshot_timestamp, + msg=snapshot, + timestamp=snapshot_timestamp, metadata={"trading_pair": trading_pair} ) output.put_nowait(snapshot_msg) diff --git a/hummingbot/connector/exchange/probit/probit_constants.py b/hummingbot/connector/exchange/probit/probit_constants.py index 5486551910..a471e48296 100644 --- a/hummingbot/connector/exchange/probit/probit_constants.py +++ b/hummingbot/connector/exchange/probit/probit_constants.py @@ -5,9 +5,26 @@ REST_URL = "https://api.probit.com/api/exchange/" WSS_URL = "wss://api.probit.com/api/exchange/v1/ws" -API_VERSON = "v1" +REST_API_VERSON = "v1" -TICKER_PATH_URL = f"{REST_URL+API_VERSON}/ticker" -MARKETS_PATH_URL = f"{REST_URL+API_VERSON}/market" -ORDER_BOOK_PATH_URL = f"{REST_URL+API_VERSON}/order_book" -NEW_ORDER_PATH_URL = f"{REST_URL+API_VERSON}/new_order" +# REST API Public Endpoints +TICKER_URL = f"{REST_URL+REST_API_VERSON}/ticker" +MARKETS_URL = f"{REST_URL+REST_API_VERSON}/market" +ORDER_BOOK_URL = f"{REST_URL+REST_API_VERSON}/order_book" +NEW_ORDER_URL = f"{REST_URL+REST_API_VERSON}/new_order" + +# REST API Private Endpoints +NEW_ORDER_URL = f"{REST_URL+REST_API_VERSON}/new_order" +CANCEL_ORDER_URL = f"{REST_URL+REST_API_VERSON}/cancel_order" +ORDER_HISTORY_URL = f"{REST_URL+REST_API_VERSON}/order_history" +TRADE_HISTORY_URL = f"{REST_URL+REST_API_VERSON}/trade_history" +BALANCE_URL = f"{REST_URL+REST_API_VERSON}/balance" +ORDER_URL = f"{REST_URL+REST_API_VERSON}/order" +OPEN_ORDER_URL = f"{REST_URL+REST_API_VERSON}/open_order" + +# Order Status Definitions +ORDER_STATUS = [ + "open", + "filled", + "cancelled", +] diff --git a/hummingbot/connector/exchange/probit/probit_order_book.py b/hummingbot/connector/exchange/probit/probit_order_book.py index 7217094899..7d9ae36e93 100644 --- a/hummingbot/connector/exchange/probit/probit_order_book.py +++ b/hummingbot/connector/exchange/probit/probit_order_book.py @@ -111,10 +111,10 @@ def trade_message_from_exchange(cls, msg.update(metadata) msg.update({ - "exchange_order_id": msg.get("d"), - "trade_type": msg.get("s"), - "price": msg.get("p"), - "amount": msg.get("q"), + "exchange_order_id": msg.get("id"), + "trade_type": msg.get("side"), + "price": msg.get("price"), + "amount": msg.get("quantity"), }) return ProbitOrderBookMessage( diff --git a/hummingbot/connector/exchange/probit/probit_order_book_message.py b/hummingbot/connector/exchange/probit/probit_order_book_message.py index e69de29bb2..d2ee511b65 100644 --- a/hummingbot/connector/exchange/probit/probit_order_book_message.py +++ b/hummingbot/connector/exchange/probit/probit_order_book_message.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python + +from typing import ( + Dict, + List, + Optional, +) + +from hummingbot.core.data_type.order_book_row import OrderBookRow +from hummingbot.core.data_type.order_book_message import ( + OrderBookMessage, + OrderBookMessageType, +) + + +class ProbitOrderBookMessage(OrderBookMessage): + def __new__( + cls, + message_type: OrderBookMessageType, + content: Dict[str, any], + timestamp: Optional[float] = None, + *args, + **kwargs, + ): + if timestamp is None: + if message_type is OrderBookMessageType.SNAPSHOT: + raise ValueError("timestamp must not be None when initializing snapshot messages.") + timestamp = content["timestamp"] + + return super(ProbitOrderBookMessage, cls).__new__( + cls, message_type, content, timestamp=timestamp, *args, **kwargs + ) + + @property + def update_id(self) -> int: + if self.type in [OrderBookMessageType.DIFF, OrderBookMessageType.SNAPSHOT]: + return int(self.timestamp) + else: + return -1 + + @property + def trade_id(self) -> int: + if self.type is OrderBookMessageType.TRADE: + return int(self.timestamp) + return -1 + + @property + def trading_pair(self) -> str: + if "market_id" in self.content: + return self.content["market_id"] + elif "trading_pair" in self.content: + # Response for REST API does not include market_id. Instead we manually insert the trading_pair in listen_for_order_book_snapshots + return self.content["trading_pair"] + + @property + def asks(self) -> List[OrderBookRow]: + entries = [] + if "order_books" in self.content: # WS API response + entries = self.content["order_books"] + elif "data" in self.content: # REST API response + entries = self.content["data"] + + return [ + OrderBookRow(float(entry["price"]), float(entry["quantity"]), self.update_id) for entry in entries if entry["side"] == "sell" + ] + + @property + def bids(self) -> List[OrderBookRow]: + entries = [] + if "order_books" in self.content: # WS API response + entries = self.content["order_books"] + elif "data" in self.content: # REST API response + entries = self.content["data"] + + return [ + OrderBookRow(float(entry["price"]), float(entry["quantity"]), self.update_id) for entry in entries if entry["side"] == "buy" + ] + + def __eq__(self, other) -> bool: + return self.type == other.type and self.timestamp == other.timestamp + + def __lt__(self, other) -> bool: + if self.timestamp != other.timestamp: + return self.timestamp < other.timestamp + else: + """ + If timestamp is the same, the ordering is snapshot < diff < trade + """ + return self.type.value < other.type.value diff --git a/hummingbot/connector/exchange/probit/probit_order_book_tracker.py b/hummingbot/connector/exchange/probit/probit_order_book_tracker.py index e69de29bb2..8c0c1d0fad 100644 --- a/hummingbot/connector/exchange/probit/probit_order_book_tracker.py +++ b/hummingbot/connector/exchange/probit/probit_order_book_tracker.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python +import asyncio +import bisect +import logging +import hummingbot.connector.exchange.probit.probit_constants as constants +import time + +from collections import defaultdict, deque +from typing import Optional, Dict, List, Deque +from hummingbot.core.data_type.order_book_message import OrderBookMessageType +from hummingbot.core.data_type.order_book_tracker import OrderBookTracker +from hummingbot.connector.exchange.probit import probit_utils +from hummingbot.connector.exchange.probit.probit_order_book_message import ProbitOrderBookMessage +from hummingbot.connector.exchange.probit.probit_api_order_book_data_source import ProbitAPIOrderBookDataSource +from hummingbot.connector.exchange.probit.probit_order_book import ProbitOrderBook +from hummingbot.logger import HummingbotLogger + + +class ProbitOrderBookTracker(OrderBookTracker): + _logger: Optional[HummingbotLogger] = None + + @classmethod + def logger(cls) -> HummingbotLogger: + if cls._logger is None: + cls._logger = logging.getLogger(__name__) + return cls._logger + + def __init__(self, trading_pairs: Optional[List[str]] = None,): + super().__init__(ProbitAPIOrderBookDataSource(trading_pairs), trading_pairs) + + self._ev_loop: asyncio.BaseEventLoop = asyncio.get_event_loop() + self._order_book_snapshot_stream: asyncio.Queue = asyncio.Queue() + self._order_book_diff_stream: asyncio.Queue = asyncio.Queue() + self._order_book_trade_stream: asyncio.Queue = asyncio.Queue() + self._process_msg_deque_task: Optional[asyncio.Task] = None + self._past_diffs_windows: Dict[str, Deque] = {} + self._order_books: Dict[str, ProbitOrderBook] = {} + self._saved_message_queues: Dict[str, Deque[ProbitOrderBookMessage]] = \ + defaultdict(lambda: deque(maxlen=1000)) + self._order_book_stream_listener_task: Optional[asyncio.Task] = None + self._order_book_trade_listener_task: Optional[asyncio.Task] = None + + @property + def exchange_name(self) -> str: + """ + Name of the current exchange + """ + return constants.EXCHANGE_NAME + + async def _track_single_book(self, trading_pair: str): + """ + Update an order book with changes from the latest batch of received messages + """ + past_diffs_window: Deque[ProbitOrderBookMessage] = deque() + self._past_diffs_windows[trading_pair] = past_diffs_window + + message_queue: asyncio.Queue = self._tracking_message_queues[trading_pair] + order_book: ProbitOrderBook = self._order_books[trading_pair] + + last_message_timestamp: float = time.time() + diff_messages_accepted: int = 0 + + while True: + try: + message: ProbitOrderBookMessage = None + saved_messages: Deque[ProbitOrderBookMessage] = self._saved_message_queues[trading_pair] + # Process saved messages first if there are any + if len(saved_messages) > 0: + message = saved_messages.popleft() + else: + message = await message_queue.get() + + if message.type is OrderBookMessageType.DIFF: + bids, asks = probit_utils.convert_diff_message_to_order_book_row(message) + order_book.apply_diffs(bids, asks, message.update_id) + past_diffs_window.append(message) + while len(past_diffs_window) > self.PAST_DIFF_WINDOW_SIZE: + past_diffs_window.popleft() + diff_messages_accepted += 1 + + # Output some statistics periodically. + now: float = time.time() + if int(now / 60.0) > int(last_message_timestamp / 60.0): + self.logger().debug("Processed %d order book diffs for %s.", + diff_messages_accepted, trading_pair) + diff_messages_accepted = 0 + last_message_timestamp = now + elif message.type is OrderBookMessageType.SNAPSHOT: + past_diffs: List[ProbitOrderBookMessage] = list(past_diffs_window) + # only replay diffs later than snapshot, first update active order with snapshot then replay diffs + replay_position = bisect.bisect_right(past_diffs, message) + replay_diffs = past_diffs[replay_position:] + s_bids, s_asks = probit_utils.convert_snapshot_message_to_order_book_row(message) + order_book.apply_snapshot(s_bids, s_asks, message.update_id) + for diff_message in replay_diffs: + d_bids, d_asks = probit_utils.convert_diff_message_to_order_book_row(diff_message) + order_book.apply_diffs(d_bids, d_asks, diff_message.update_id) + + self.logger().debug("Processed order book snapshot for %s.", trading_pair) + except asyncio.CancelledError: + raise + except Exception: + self.logger().network( + f"Unexpected error processing order book messages for {trading_pair}.", + exc_info=True, + app_warning_msg="Unexpected error processing order book messages. Retrying after 5 seconds." + ) + await asyncio.sleep(5.0) diff --git a/hummingbot/connector/exchange/probit/probit_utils.py b/hummingbot/connector/exchange/probit/probit_utils.py index f070728dc7..f1ad3911a2 100644 --- a/hummingbot/connector/exchange/probit/probit_utils.py +++ b/hummingbot/connector/exchange/probit/probit_utils.py @@ -1,8 +1,19 @@ +#!/usr/bin/env python + from typing import ( Any, Dict, + List, + Tuple ) +from hummingbot.core.utils.tracking_nonce import get_tracking_nonce +from hummingbot.core.data_type.order_book_row import OrderBookRow +from hummingbot.core.data_type.order_book_message import OrderBookMessage + +from hummingbot.client.config.config_var import ConfigVar +from hummingbot.client.config.config_methods import using_exchange + CENTRALIZED = True EXAMPLE_PAIR = "ETH-USDT" @@ -10,9 +21,57 @@ DEFAULT_FEES = [0.2, 0.2] -def convert_snapshot_message_to_order_book_row(message: Dict[str, Any]): - pass +def get_new_client_order_id(is_buy: bool, trading_pair: str) -> str: + side = "B" if is_buy else "S" + return f"{side}-{trading_pair}-{get_tracking_nonce()}" + + +def convert_snapshot_message_to_order_book_row(message: OrderBookMessage) -> Tuple[List[OrderBookRow], List[OrderBookRow]]: + update_id = message.update_id + data = [] + if "data" in message.content: # From REST API + data: List[Dict[str, Any]] = message.content["data"] + elif "order_books" in message.content: # From Websocket API + data: List[Dict[str, Any]] = message.content["order_books"] + bids, asks = [], [] + + for entry in data: + order_row = OrderBookRow(entry["price"], entry["quantity"], update_id) + if entry["side"] == "buy": + bids.append(order_row) + else: # entry["type"] == "Sell": + asks.append(order_row) + + return bids, asks + + +def convert_diff_message_to_order_book_row(message: OrderBookMessage) -> Tuple[List[OrderBookRow], List[OrderBookRow]]: + update_id = message.update_id + data = message.content["order_books"] + bids = [] + asks = [] + + for entry in data: + order_row = OrderBookRow(entry["price"], entry["quantity"], update_id) + if entry["side"] == "buy": + bids.append(order_row) + elif entry["side"] == "sell": + asks.append(order_row) + + return bids, asks -def convert_diff_message_to_order_book_row(message: Dict[str, Any]): - pass +KEYS = { + "probit_api_key": + ConfigVar(key="probit_api_key", + prompt="Enter your ProBit API key >>> ", + required_if=using_exchange("probit"), + is_secure=True, + is_connect_key=True), + "probit_secret_key": + ConfigVar(key="probit_secret_key", + prompt="Enter your ProBit secret key >>> ", + required_if=using_exchange("probit"), + is_secure=True, + is_connect_key=True), +} From a55ded574cca7d687b52e6a6e8cf16839b08abf1 Mon Sep 17 00:00:00 2001 From: Daniel Tan Date: Mon, 15 Feb 2021 21:22:30 +0800 Subject: [PATCH 015/131] (fix) include additional handling of ws messages and fix timestamp issue in listen_for_trades --- .../probit/probit_api_order_book_data_source.py | 17 ++++++++++++++--- .../probit/probit_order_book_message.py | 5 ++--- .../connector/exchange/probit/probit_utils.py | 2 +- 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/hummingbot/connector/exchange/probit/probit_api_order_book_data_source.py b/hummingbot/connector/exchange/probit/probit_api_order_book_data_source.py index 33e41b86cb..23a1a536f0 100644 --- a/hummingbot/connector/exchange/probit/probit_api_order_book_data_source.py +++ b/hummingbot/connector/exchange/probit/probit_api_order_book_data_source.py @@ -80,7 +80,7 @@ async def get_order_book_data(trading_pair: str) -> Dict[str, any]: async def get_new_order_book(self, trading_pair: str) -> OrderBook: snapshot: Dict[str, Any] = await self.get_order_book_data(trading_pair) - snapshot_timestamp: float = time.time() + snapshot_timestamp: int = int(time.time() * 1e3) snapshot_msg: OrderBookMessage = ProbitOrderBook.snapshot_message_from_exchange( snapshot, snapshot_timestamp, @@ -137,11 +137,20 @@ async def listen_for_trades(self, ev_loop: asyncio.BaseEventLoop, output: asynci } await ws.send(ujson.dumps(params)) async for raw_msg in self._inner_messages(ws): + msg_timestamp: int = int(time.time() * 1e3) msg = ujson.loads(raw_msg) if "recent_trades" not in msg: + # Unrecognized response from "recent_trades" channel + continue + + if "reset" in msg and msg["reset"] is True: + # Ignores first response from "recent_trades" channel. This response details of the last 100 trades. continue + for trade_entry in msg["recent_trades"]: - trade_msg: OrderBookMessage = ProbitOrderBook.trade_message_from_exchange(trade_entry) + trade_msg: OrderBookMessage = ProbitOrderBook.trade_message_from_exchange( + msg=trade_entry, + timestamp=msg_timestamp) output.put_nowait(trade_msg) except asyncio.CancelledError: raise @@ -172,6 +181,7 @@ async def listen_for_order_book_diffs(self, ev_loop: asyncio.BaseEventLoop, outp msg_timestamp: int = int(time.time() * 1e3) msg: Dict[str, Any] = ujson.loads(raw_msg) if "order_books" not in msg: + # Unrecognized response from "order_books" channel continue if "reset" in msg and msg["reset"] is True: # First response from websocket is a snapshot. This is only when reset = True @@ -180,6 +190,7 @@ async def listen_for_order_book_diffs(self, ev_loop: asyncio.BaseEventLoop, outp timestamp=msg_timestamp, ) output.put_nowait(snapshot_msg) + continue for diff_entry in msg["order_books"]: diff_msg: OrderBookMessage = ProbitOrderBook.diff_message_from_exchange(diff_entry, msg_timestamp) @@ -210,7 +221,7 @@ async def listen_for_order_book_snapshots(self, ev_loop: asyncio.BaseEventLoop, snapshot_msg: OrderBookMessage = ProbitOrderBook.snapshot_message_from_exchange( msg=snapshot, timestamp=snapshot_timestamp, - metadata={"trading_pair": trading_pair} + metadata={"market_id": trading_pair} # Manually insert trading_pair here since API response does include trading pair ) output.put_nowait(snapshot_msg) self.logger().debug(f"Saved order book snapshot for {trading_pair}") diff --git a/hummingbot/connector/exchange/probit/probit_order_book_message.py b/hummingbot/connector/exchange/probit/probit_order_book_message.py index d2ee511b65..8325736e96 100644 --- a/hummingbot/connector/exchange/probit/probit_order_book_message.py +++ b/hummingbot/connector/exchange/probit/probit_order_book_message.py @@ -48,9 +48,8 @@ def trade_id(self) -> int: def trading_pair(self) -> str: if "market_id" in self.content: return self.content["market_id"] - elif "trading_pair" in self.content: - # Response for REST API does not include market_id. Instead we manually insert the trading_pair in listen_for_order_book_snapshots - return self.content["trading_pair"] + else: + raise ValueError("market_id not found in message content") @property def asks(self) -> List[OrderBookRow]: diff --git a/hummingbot/connector/exchange/probit/probit_utils.py b/hummingbot/connector/exchange/probit/probit_utils.py index f1ad3911a2..03a75b1b52 100644 --- a/hummingbot/connector/exchange/probit/probit_utils.py +++ b/hummingbot/connector/exchange/probit/probit_utils.py @@ -36,7 +36,7 @@ def convert_snapshot_message_to_order_book_row(message: OrderBookMessage) -> Tup bids, asks = [], [] for entry in data: - order_row = OrderBookRow(entry["price"], entry["quantity"], update_id) + order_row = OrderBookRow(float(entry["price"]), float(entry["quantity"]), update_id) if entry["side"] == "buy": bids.append(order_row) else: # entry["type"] == "Sell": From d60a9b3831b9af621787436756e2c1a8af63e9c6 Mon Sep 17 00:00:00 2001 From: vic-en Date: Mon, 15 Feb 2021 14:26:42 +0100 Subject: [PATCH 016/131] (feat) add _funding_payment_span to derivative connectors --- .../derivative/binance_perpetual/binance_perpetual_derivative.py | 1 + .../derivative/perpetual_finance/perpetual_finance_derivative.py | 1 + hummingbot/connector/derivative_base.py | 1 + 3 files changed, 3 insertions(+) diff --git a/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_derivative.py b/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_derivative.py index b3b132693a..6e285a8d99 100644 --- a/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_derivative.py +++ b/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_derivative.py @@ -131,6 +131,7 @@ def __init__(self, self._trading_rules_polling_task = None self._last_poll_timestamp = 0 self._throttler = Throttler((10.0, 1.0)) + self._funding_payment_span = [0, 15] @property def name(self) -> str: diff --git a/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py index 03626b6dd0..7e86a486d5 100644 --- a/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py +++ b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py @@ -85,6 +85,7 @@ def __init__(self, self._auto_approve_task = None self._real_time_balance_update = False self._poll_notifier = None + self._funding_payment_span = [1800, 0] @property def name(self): diff --git a/hummingbot/connector/derivative_base.py b/hummingbot/connector/derivative_base.py index 59e4d7b64c..ff4750017a 100644 --- a/hummingbot/connector/derivative_base.py +++ b/hummingbot/connector/derivative_base.py @@ -19,6 +19,7 @@ def __init__(self): self._account_positions = {} self._position_mode = None self._leverage = 1 + self._funding_payment_span = [0, 0] # time span(in seconds) before and after funding period when exchanges consider active positions eligible for funding payment def set_position_mode(self, position_mode: PositionMode): """ From 541b86a4e46adcf512ce007f12fd0698c2826bc6 Mon Sep 17 00:00:00 2001 From: Daniel Tan Date: Mon, 15 Feb 2021 21:30:47 +0800 Subject: [PATCH 017/131] (fix) include missing PING_TIMEOUT in ProbitAPIOrderBookDataSource --- .../exchange/probit/probit_api_order_book_data_source.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/hummingbot/connector/exchange/probit/probit_api_order_book_data_source.py b/hummingbot/connector/exchange/probit/probit_api_order_book_data_source.py index 23a1a536f0..005600c462 100644 --- a/hummingbot/connector/exchange/probit/probit_api_order_book_data_source.py +++ b/hummingbot/connector/exchange/probit/probit_api_order_book_data_source.py @@ -27,6 +27,7 @@ class ProbitAPIOrderBookDataSource(OrderBookTrackerDataSource): MAX_RETRIES = 20 MESSAGE_TIMEOUT = 30.0 + PING_TIMEOUT = 10.0 SNAPSHOT_TIMEOUT = 10.0 _logger: Optional[HummingbotLogger] = None @@ -144,7 +145,7 @@ async def listen_for_trades(self, ev_loop: asyncio.BaseEventLoop, output: asynci continue if "reset" in msg and msg["reset"] is True: - # Ignores first response from "recent_trades" channel. This response details of the last 100 trades. + # Ignores first response from "recent_trades" channel. This response details the last 100 trades. continue for trade_entry in msg["recent_trades"]: From c745a7bc617bbcff8e6ac40ba2ee158cd3cd6137 Mon Sep 17 00:00:00 2001 From: Daniel Tan Date: Mon, 15 Feb 2021 21:35:28 +0800 Subject: [PATCH 018/131] (add) add connector status for Probit Connector --- hummingbot/connector/connector_status.py | 1 + 1 file changed, 1 insertion(+) diff --git a/hummingbot/connector/connector_status.py b/hummingbot/connector/connector_status.py index ccf1d44cfc..31b83553aa 100644 --- a/hummingbot/connector/connector_status.py +++ b/hummingbot/connector/connector_status.py @@ -20,6 +20,7 @@ 'kucoin': 'green', 'liquid': 'green', 'loopring': 'yellow', + 'probit': 'yellow', 'okex': 'green', 'terra': 'green' } From a82d378997a20abc1085b1a106ea87e6008a8685 Mon Sep 17 00:00:00 2001 From: Daniel Tan Date: Tue, 16 Feb 2021 02:59:14 +0800 Subject: [PATCH 019/131] (add) ProbitAuth --- .../connector/exchange/probit/probit_auth.py | 88 +++++++++++++++++++ .../exchange/probit/probit_constants.py | 2 +- 2 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 hummingbot/connector/exchange/probit/probit_auth.py diff --git a/hummingbot/connector/exchange/probit/probit_auth.py b/hummingbot/connector/exchange/probit/probit_auth.py new file mode 100644 index 0000000000..c7dbda7912 --- /dev/null +++ b/hummingbot/connector/exchange/probit/probit_auth.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python + +import aiohttp +import base64 +import time +import ujson + +import hummingbot.connector.exchange.probit.probit_constants as constants + +from typing import Dict, Any + + +class ProbitAuth(): + """ + Auth class required by ProBit API + Learn more at https://docs-en.probit.com/docs/authorization-1 + """ + def __init__(self, api_key: str, secret_key: str): + self.api_key: str = api_key + self.secret_key: str = secret_key + self._oauth_token: str = None + self._oauth_token_expiration_time: int = -1 + self._http_client: aiohttp.ClientSession = aiohttp.ClientSession() + + def _token_has_expired(self): + now: int = int(time.time()) + return now >= self._oauth_token_expiration_time + + def _update_expiration_time(self, expiration_time: int): + self._oauth_token_expiration_time = expiration_time + + async def _generate_oauth_token(self) -> str: + try: + now: int = int(time.time()) + headers: Dict[str, Any] = self.get_headers() + payload = f"{self.api_key}:{self.secret_key}".encode() + b64_payload = base64.b64encode(payload).decode() + headers.update({ + "Authorization": f"Basic {b64_payload}" + }) + body = ujson.dumps({ + "grant_type": "client_credentials" + }) + resp = await self._http_client.post(url=constants.TOKEN_URL, + headers=headers, + data=body) + if resp.status != 200: + raise ValueError(f"{__name__}: Error occurred retrieving new OAuth Token. Response: {resp}") + + token_resp = await resp.json() + + # POST /token endpoint returns both access_token and expires_in + # Updates _oauth_token_expiration_time + + self._update_expiration_time(now + token_resp["expires_in"]) + return token_resp["access_token"] + except Exception as e: + raise e + + async def _get_oauth_token(self) -> str: + if self._oauth_token is None or self._token_has_expired(): + self._oauth_token = await self._generate_oauth_token() + return self._oauth_token + + async def generate_auth_dict(self): + """ + Generates authentication signature and return it in a dictionary along with other inputs + :return: a dictionary of request info including the request signature + """ + + headers = self.get_headers() + + access_token = await self._get_oauth_token() + headers.update({ + "Authorization": f"Bearer {access_token}" + }) + + return headers + + def get_headers(self) -> Dict[str, Any]: + """ + Generates authentication headers required by ProBit + :return: a dictionary of auth headers + """ + + return { + "Content-Type": 'application/json', + } diff --git a/hummingbot/connector/exchange/probit/probit_constants.py b/hummingbot/connector/exchange/probit/probit_constants.py index a471e48296..13991e6a11 100644 --- a/hummingbot/connector/exchange/probit/probit_constants.py +++ b/hummingbot/connector/exchange/probit/probit_constants.py @@ -11,7 +11,7 @@ TICKER_URL = f"{REST_URL+REST_API_VERSON}/ticker" MARKETS_URL = f"{REST_URL+REST_API_VERSON}/market" ORDER_BOOK_URL = f"{REST_URL+REST_API_VERSON}/order_book" -NEW_ORDER_URL = f"{REST_URL+REST_API_VERSON}/new_order" +TOKEN_URL = "https://accounts.probit.com/token" # REST API Private Endpoints NEW_ORDER_URL = f"{REST_URL+REST_API_VERSON}/new_order" From 5daf360cc002a50429d9efb6cb42bd8d63e46619 Mon Sep 17 00:00:00 2001 From: Daniel Tan Date: Tue, 16 Feb 2021 20:39:06 +0800 Subject: [PATCH 020/131] (add) add ProbitAPIUserStreamDataSource --- .../probit_api_user_stream_data_source.py | 156 ++++++++++++++++++ .../connector/exchange/probit/probit_auth.py | 10 +- .../exchange/probit/probit_constants.py | 8 + 3 files changed, 169 insertions(+), 5 deletions(-) create mode 100644 hummingbot/connector/exchange/probit/probit_api_user_stream_data_source.py diff --git a/hummingbot/connector/exchange/probit/probit_api_user_stream_data_source.py b/hummingbot/connector/exchange/probit/probit_api_user_stream_data_source.py new file mode 100644 index 0000000000..78596d685d --- /dev/null +++ b/hummingbot/connector/exchange/probit/probit_api_user_stream_data_source.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python + +import asyncio +import logging +import ujson +import websockets + +import hummingbot.connector.exchange.probit.probit_constants as CONSTANTS + +from typing import ( + Any, + AsyncIterable, + Dict, + List, + Optional, +) + +from hummingbot.connector.exchange.probit.probit_auth import ProbitAuth +from hummingbot.core.data_type.user_stream_tracker_data_source import UserStreamTrackerDataSource +from hummingbot.logger import HummingbotLogger + + +class ProbitAPIUserStreamDataSource(UserStreamTrackerDataSource): + MAX_RETRIES = 20 + MESSAGE_TIMEOUT = 30.0 + + _logger: Optional[HummingbotLogger] = None + + @classmethod + def logger(cls) -> HummingbotLogger: + if cls._logger is None: + cls._logger = logging.getLogger(__name__) + return cls._logger + + def __init__(self, probit_auth: ProbitAuth, trading_pairs: Optional[List[str]] = []): + self._websocket_client: websockets.WebSocketClientProtocol = None + self._probit_auth: ProbitAuth = probit_auth + self._trading_pairs = trading_pairs + + self._last_recv_time: float = 0 + super().__init__() + + @property + def last_recv_time(self) -> float: + return self._last_recv_time + + async def _init_websocket_connection(self) -> websockets.WebSocketClientProtocol: + """ + Initialize WebSocket client for UserStreamDataSource + """ + try: + if self._websocket_client is None: + self._websocket_client = await websockets.connect(CONSTANTS.WSS_URL) + return self._websocket_client + except Exception: + self.logger().network("Unexpected error occured with ProBit WebSocket Connection") + + async def _authenticate(self, ws: websockets.WebSocketClientProtocol): + """ + Authenticates user to websocket + """ + while True: + try: + access_token: str = self._probit_auth.get_oauth_token() + auth_payload: Dict[str, Any] = { + "type": "authorization", + "token": access_token + } + await ws.send(ujson.dumps(auth_payload)) + auth_resp = await ws.recv() + auth_resp: Dict[str, Any] = ujson.loads(auth_resp) + + if auth_resp["result"] != "ok": + raise + else: + return + except asyncio.CancelledError: + raise + except Exception: + self.logger().info(f"Error occurred when authenticating to user stream. Response: {auth_resp}", + exc_info=True) + raise + + async def _subscribe_to_channels(self, ws: websockets.WebSocketClientProtocol): + """ + Subscribes to Private User Channels + """ + try: + for channel in CONSTANTS.WS_PRIVATE_CHANNELS: + sub_payload = { + "type": "subscribe", + "channel": channel + } + await ws.send(ujson.dumps(sub_payload)) + sub_resp = await ws.recv() + sub_resp: Dict[str, Any] = ujson.loads(sub_resp) + + if "reset" in sub_resp and sub_resp["reset"] is True: + continue + else: + self.logger().error(f"Error occured subscribing to {channel}...") + raise + + except asyncio.CancelledError: + raise + except Exception: + self.logger().error(f"Error occured subscribing to {CONSTANTS.EXCHANGE_NAME} private channels. " + f"Payload: {sub_payload} " + f"Resp: {sub_resp}", + exc_info=True) + + async def _inner_messages(self, + ws: websockets.WebSocketClientProtocol) -> AsyncIterable[str]: + try: + while True: + msg: str = await asyncio.wait_for(ws.recv()) + yield msg + except asyncio.TimeoutError: + try: + pong_waiter = await ws.ping() + await asyncio.wait_for(pong_waiter, timeout=self.PING_TIMEOUT) + except asyncio.TimeoutError: + raise + except websockets.exceptions.ConnectionClosed: + return + finally: + await ws.close() + + async def listen_for_user_stream(self, ev_loop: asyncio.BaseEventLoop, output: asyncio.Queue) -> AsyncIterable[Any]: + """ + *required + Subscribe to user stream via web socket, and keep the connection open for incoming messages + :param ev_loop: ev_loop to execute this function in + :param output: an async queue where the incoming messages are stored + """ + + while True: + try: + ws: websockets.WebSocketClientProtocol = await self._init_websocket_connection() + self.logger().info("Authenticating to User Stream...") + await self._authenticate(ws) + self.logger().info("Successfully authenticated to User Stream.") + await self._subscribe_to_channels(ws) + self.logger().info("Successfully subscribed to all Private channels.") + + async for msg in self._inner_messages(ws): + print(f"{msg}") + output.put_nowait(msg) + except asyncio.CancelledError: + raise + except Exception: + self.logger().error( + "Unexpected error with Probit WebSocket connection. Retrying after 30 seconds...", + exc_info=True + ) + await asyncio.sleep(30.0) diff --git a/hummingbot/connector/exchange/probit/probit_auth.py b/hummingbot/connector/exchange/probit/probit_auth.py index c7dbda7912..d3a004f7fa 100644 --- a/hummingbot/connector/exchange/probit/probit_auth.py +++ b/hummingbot/connector/exchange/probit/probit_auth.py @@ -44,11 +44,11 @@ async def _generate_oauth_token(self) -> str: resp = await self._http_client.post(url=constants.TOKEN_URL, headers=headers, data=body) - if resp.status != 200: - raise ValueError(f"{__name__}: Error occurred retrieving new OAuth Token. Response: {resp}") - token_resp = await resp.json() + if resp.status != 200: + raise ValueError(f"{__name__}: Error occurred retrieving new OAuth Token. Response: {token_resp}") + # POST /token endpoint returns both access_token and expires_in # Updates _oauth_token_expiration_time @@ -57,7 +57,7 @@ async def _generate_oauth_token(self) -> str: except Exception as e: raise e - async def _get_oauth_token(self) -> str: + async def get_oauth_token(self) -> str: if self._oauth_token is None or self._token_has_expired(): self._oauth_token = await self._generate_oauth_token() return self._oauth_token @@ -70,7 +70,7 @@ async def generate_auth_dict(self): headers = self.get_headers() - access_token = await self._get_oauth_token() + access_token = await self.get_oauth_token() headers.update({ "Authorization": f"Bearer {access_token}" }) diff --git a/hummingbot/connector/exchange/probit/probit_constants.py b/hummingbot/connector/exchange/probit/probit_constants.py index 13991e6a11..95607932a6 100644 --- a/hummingbot/connector/exchange/probit/probit_constants.py +++ b/hummingbot/connector/exchange/probit/probit_constants.py @@ -22,6 +22,14 @@ ORDER_URL = f"{REST_URL+REST_API_VERSON}/order" OPEN_ORDER_URL = f"{REST_URL+REST_API_VERSON}/open_order" +# Websocket Private Channels +WS_PRIVATE_CHANNELS = [ + "open_order", + "order_history", + "trade_history", + "balance" +] + # Order Status Definitions ORDER_STATUS = [ "open", From 038a8cc9114de29c74a24bc1c30a3bb9dda3ca5e Mon Sep 17 00:00:00 2001 From: Daniel Tan Date: Tue, 16 Feb 2021 20:39:52 +0800 Subject: [PATCH 021/131] (remove) remove redundant prints --- .../exchange/probit/probit_api_user_stream_data_source.py | 1 - 1 file changed, 1 deletion(-) diff --git a/hummingbot/connector/exchange/probit/probit_api_user_stream_data_source.py b/hummingbot/connector/exchange/probit/probit_api_user_stream_data_source.py index 78596d685d..dc2adcd400 100644 --- a/hummingbot/connector/exchange/probit/probit_api_user_stream_data_source.py +++ b/hummingbot/connector/exchange/probit/probit_api_user_stream_data_source.py @@ -144,7 +144,6 @@ async def listen_for_user_stream(self, ev_loop: asyncio.BaseEventLoop, output: a self.logger().info("Successfully subscribed to all Private channels.") async for msg in self._inner_messages(ws): - print(f"{msg}") output.put_nowait(msg) except asyncio.CancelledError: raise From 298574c8947f9f0444463cb3b04c1a1f4caf139a Mon Sep 17 00:00:00 2001 From: Daniel Tan Date: Tue, 16 Feb 2021 21:09:16 +0800 Subject: [PATCH 022/131] (fix) resolve minor issues with maintaining websocket connection --- .../probit_api_user_stream_data_source.py | 52 +++++++++---------- 1 file changed, 25 insertions(+), 27 deletions(-) diff --git a/hummingbot/connector/exchange/probit/probit_api_user_stream_data_source.py b/hummingbot/connector/exchange/probit/probit_api_user_stream_data_source.py index dc2adcd400..91654c1178 100644 --- a/hummingbot/connector/exchange/probit/probit_api_user_stream_data_source.py +++ b/hummingbot/connector/exchange/probit/probit_api_user_stream_data_source.py @@ -23,6 +23,7 @@ class ProbitAPIUserStreamDataSource(UserStreamTrackerDataSource): MAX_RETRIES = 20 MESSAGE_TIMEOUT = 30.0 + PING_TIMEOUT = 10.0 _logger: Optional[HummingbotLogger] = None @@ -59,27 +60,26 @@ async def _authenticate(self, ws: websockets.WebSocketClientProtocol): """ Authenticates user to websocket """ - while True: - try: - access_token: str = self._probit_auth.get_oauth_token() - auth_payload: Dict[str, Any] = { - "type": "authorization", - "token": access_token - } - await ws.send(ujson.dumps(auth_payload)) - auth_resp = await ws.recv() - auth_resp: Dict[str, Any] = ujson.loads(auth_resp) - - if auth_resp["result"] != "ok": - raise - else: - return - except asyncio.CancelledError: - raise - except Exception: - self.logger().info(f"Error occurred when authenticating to user stream. Response: {auth_resp}", - exc_info=True) + try: + access_token: str = await self._probit_auth.get_oauth_token() + auth_payload: Dict[str, Any] = { + "type": "authorization", + "token": access_token + } + await ws.send(ujson.dumps(auth_payload)) + auth_resp = await ws.recv() + auth_resp: Dict[str, Any] = ujson.loads(auth_resp) + + if auth_resp["result"] != "ok": + self.logger().error(f"Response: {auth_resp}", + exc_info=True) raise + except asyncio.CancelledError: + raise + except Exception: + self.logger().info("Error occurred when authenticating to user stream. ", + exc_info=True) + raise async def _subscribe_to_channels(self, ws: websockets.WebSocketClientProtocol): """ @@ -113,14 +113,8 @@ async def _inner_messages(self, ws: websockets.WebSocketClientProtocol) -> AsyncIterable[str]: try: while True: - msg: str = await asyncio.wait_for(ws.recv()) + msg: str = await ws.recv() yield msg - except asyncio.TimeoutError: - try: - pong_waiter = await ws.ping() - await asyncio.wait_for(pong_waiter, timeout=self.PING_TIMEOUT) - except asyncio.TimeoutError: - raise except websockets.exceptions.ConnectionClosed: return finally: @@ -144,6 +138,7 @@ async def listen_for_user_stream(self, ev_loop: asyncio.BaseEventLoop, output: a self.logger().info("Successfully subscribed to all Private channels.") async for msg in self._inner_messages(ws): + print(f"{msg}") output.put_nowait(msg) except asyncio.CancelledError: raise @@ -152,4 +147,7 @@ async def listen_for_user_stream(self, ev_loop: asyncio.BaseEventLoop, output: a "Unexpected error with Probit WebSocket connection. Retrying after 30 seconds...", exc_info=True ) + if self._websocket_client is not None: + await self._websocket_client.close() + self._websocket_client = None await asyncio.sleep(30.0) From cc081fc68aeebfcea7db53f6d6a21527f69eddfa Mon Sep 17 00:00:00 2001 From: Daniel Tan Date: Tue, 16 Feb 2021 21:25:09 +0800 Subject: [PATCH 023/131] (add) add ProbitUserStreamTracker --- .../probit_api_user_stream_data_source.py | 1 - .../probit/probit_user_stream_tracker.py | 73 +++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 hummingbot/connector/exchange/probit/probit_user_stream_tracker.py diff --git a/hummingbot/connector/exchange/probit/probit_api_user_stream_data_source.py b/hummingbot/connector/exchange/probit/probit_api_user_stream_data_source.py index 91654c1178..e4484308e1 100644 --- a/hummingbot/connector/exchange/probit/probit_api_user_stream_data_source.py +++ b/hummingbot/connector/exchange/probit/probit_api_user_stream_data_source.py @@ -138,7 +138,6 @@ async def listen_for_user_stream(self, ev_loop: asyncio.BaseEventLoop, output: a self.logger().info("Successfully subscribed to all Private channels.") async for msg in self._inner_messages(ws): - print(f"{msg}") output.put_nowait(msg) except asyncio.CancelledError: raise diff --git a/hummingbot/connector/exchange/probit/probit_user_stream_tracker.py b/hummingbot/connector/exchange/probit/probit_user_stream_tracker.py new file mode 100644 index 0000000000..d1bcb1cbb8 --- /dev/null +++ b/hummingbot/connector/exchange/probit/probit_user_stream_tracker.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python + +import asyncio +import logging + +from typing import ( + Optional, + List, +) + +from hummingbot.connector.exchange.probit.probit_auth import ProbitAuth +from hummingbot.connector.exchange.probit.probit_constants import EXCHANGE_NAME +from hummingbot.connector.exchange.probit.probit_api_user_stream_data_source import \ + ProbitAPIUserStreamDataSource +from hummingbot.core.data_type.user_stream_tracker_data_source import UserStreamTrackerDataSource +from hummingbot.core.data_type.user_stream_tracker import UserStreamTracker +from hummingbot.core.utils.async_utils import ( + safe_ensure_future, + safe_gather, +) +from hummingbot.logger import HummingbotLogger + + +class ProbitUserStreamTracker(UserStreamTracker): + _logger: Optional[HummingbotLogger] = None + + @classmethod + def logger(cls) -> HummingbotLogger: + if cls._logger is None: + cls._logger = logging.getLogger(__name__) + return cls._logger + + def __init__(self, + probit_auth: Optional[ProbitAuth] = None, + trading_pairs: Optional[List[str]] = []): + super().__init__() + self._probit_auth: ProbitAuth = probit_auth + self._trading_pairs: List[str] = trading_pairs + self._ev_loop: asyncio.events.AbstractEventLoop = asyncio.get_event_loop() + self._data_source: Optional[UserStreamTrackerDataSource] = None + self._user_stream_tracking_task: Optional[asyncio.Task] = None + + @property + def data_source(self) -> UserStreamTrackerDataSource: + """ + *required + Initializes a user stream data source (user specific order diffs from live socket stream) + :return: OrderBookTrackerDataSource + """ + if not self._data_source: + self._data_source = ProbitAPIUserStreamDataSource( + probit_auth=self._probit_auth, + trading_pairs=self._trading_pairs + ) + return self._data_source + + @property + def exchange_name(self) -> str: + """ + *required + Name of the current exchange + """ + return EXCHANGE_NAME + + async def start(self): + """ + *required + Start all listeners and tasks + """ + self._user_stream_tracking_task = safe_ensure_future( + self.data_source.listen_for_user_stream(self._ev_loop, self._user_stream) + ) + await safe_gather(self._user_stream_tracking_task) From e4e2834990632365a0bba78a3399ef341e869bc1 Mon Sep 17 00:00:00 2001 From: Daniel Tan Date: Wed, 17 Feb 2021 00:05:00 +0800 Subject: [PATCH 024/131] (add) add ProbitInFlightOrder --- .../exchange/probit/probit_in_flight_order.py | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 hummingbot/connector/exchange/probit/probit_in_flight_order.py diff --git a/hummingbot/connector/exchange/probit/probit_in_flight_order.py b/hummingbot/connector/exchange/probit/probit_in_flight_order.py new file mode 100644 index 0000000000..4381ee8549 --- /dev/null +++ b/hummingbot/connector/exchange/probit/probit_in_flight_order.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python + +import asyncio + +from decimal import Decimal +from typing import ( + Any, + Dict, + Optional, +) + +from hummingbot.connector.in_flight_order_base import InFlightOrderBase +from hummingbot.core.event.events import ( + OrderType, + TradeType +) + + +class ProbitInFlightOrder(InFlightOrderBase): + def __init__(self, + client_order_id: str, + exchange_order_id: Optional[str], + trading_pair: str, + order_type: OrderType, + trade_type: TradeType, + price: Decimal, + amount: Decimal, + initial_state: str = "open"): + super().__init__( + client_order_id, + exchange_order_id, + trading_pair, + order_type, + trade_type, + price, + amount, + initial_state, + ) + self.trade_id_set = set() + self.cancelled_event = asyncio.Event() + + @property + def is_done(self) -> bool: + return self.last_state in {"filled", "cancelled"} + + @property + def is_failure(self) -> bool: + # TODO: Determine Order Status Definitions for failed orders + return self.last_state in {"REJECTED"} + + @property + def is_cancelled(self) -> bool: + return self.last_state in {"cancelled"} + + @classmethod + def from_json(cls, data: Dict[str, Any]) -> InFlightOrderBase: + """ + :param data: json data from API + :return: formatted InFlightOrder + """ + retval = ProbitInFlightOrder( + data["client_order_id"], + data["exchange_order_id"], + data["trading_pair"], + getattr(OrderType, data["order_type"]), + getattr(TradeType, data["trade_type"]), + Decimal(data["price"]), + Decimal(data["amount"]), + data["last_state"] + ) + retval.executed_amount_base = Decimal(data["executed_amount_base"]) + retval.executed_amount_quote = Decimal(data["executed_amount_quote"]) + retval.fee_asset = data["fee_asset"] + retval.fee_paid = Decimal(data["fee_paid"]) + retval.last_state = data["last_state"] + return retval + + def update_with_trade_update(self, trade_update: Dict[str, Any]) -> bool: + """ + Updates the in flight order with trade update (from private/get-order-detail end point) + return: True if the order gets updated otherwise False + """ + trade_id = trade_update["id"] + # trade_update["orderId"] is type int + if str(trade_update["order_id"]) != self.exchange_order_id or trade_id in self.trade_id_set: + # trade already recorded + return False + self.trade_id_set.add(trade_id) + self.executed_amount_base += Decimal(str(trade_update["quantity"])) + self.fee_paid += Decimal(str(trade_update["fee_amount"])) + self.executed_amount_quote += (Decimal(str(trade_update["price"])) * + Decimal(str(trade_update["quantity"]))) + if not self.fee_asset: + self.fee_asset = trade_update["fee_currency_id"] + return True From 8bd84df3831368543079171b72d5c2d8f4d81781 Mon Sep 17 00:00:00 2001 From: Daniel Tan Date: Wed, 17 Feb 2021 07:17:13 +0800 Subject: [PATCH 025/131] (add) ProbitExchange[WIP] --- .../exchange/probit/probit_exchange.py | 852 ++++++++++++++++++ 1 file changed, 852 insertions(+) create mode 100644 hummingbot/connector/exchange/probit/probit_exchange.py diff --git a/hummingbot/connector/exchange/probit/probit_exchange.py b/hummingbot/connector/exchange/probit/probit_exchange.py new file mode 100644 index 0000000000..eeeb3430f5 --- /dev/null +++ b/hummingbot/connector/exchange/probit/probit_exchange.py @@ -0,0 +1,852 @@ +#!/usr/bin/env python + +import aiohttp +import asyncio +import logging +import math +import time + +from decimal import Decimal +from typing import ( + Dict, + List, + Optional, + Any, + AsyncIterable, +) + +from hummingbot.connector.exchange_base import ExchangeBase +from hummingbot.connector.exchange.probit import probit_constants as CONSTANTS +from hummingbot.connector.exchange.probit import probit_utils +from hummingbot.connector.exchange.probit.probit_auth import ProbitAuth +from hummingbot.connector.exchange.probit.probit_in_flight_order import ProbitInFlightOrder +from hummingbot.connector.exchange.probit.probit_order_book_tracker import ProbitOrderBookTracker +from hummingbot.connector.exchange.probit.probit_user_stream_tracker import ProbitUserStreamTracker +from hummingbot.connector.trading_rule import TradingRule +from hummingbot.core.clock import Clock +from hummingbot.core.data_type.cancellation_result import CancellationResult +from hummingbot.core.data_type.common import OpenOrder +from hummingbot.core.data_type.order_book import OrderBook +from hummingbot.core.data_type.limit_order import LimitOrder +from hummingbot.core.event.events import ( + MarketEvent, + BuyOrderCompletedEvent, + SellOrderCompletedEvent, + OrderFilledEvent, + OrderCancelledEvent, + BuyOrderCreatedEvent, + SellOrderCreatedEvent, + MarketOrderFailureEvent, + OrderType, + TradeType, + TradeFee +) +from hummingbot.core.network_iterator import NetworkStatus +from hummingbot.core.utils.async_utils import safe_ensure_future, safe_gather +from hummingbot.logger import HummingbotLogger + +probit_logger = None +s_decimal_NaN = Decimal("nan") + + +class ProbitExchange(ExchangeBase): + """ + ProbitExchange connects with ProBit exchange and provides order book pricing, user account tracking and + trading functionality. + """ + API_CALL_TIMEOUT = 10.0 + SHORT_POLL_INTERVAL = 5.0 + UPDATE_ORDER_STATUS_MIN_INTERVAL = 10.0 + LONG_POLL_INTERVAL = 120.0 + + @classmethod + def logger(cls) -> HummingbotLogger: + global probit_logger + if probit_logger is None: + probit_logger = logging.getLogger(__name__) + return probit_logger + + def __init__(self, + probit_api_key: str, + probit_secret_key: str, + trading_pairs: Optional[List[str]] = None, + trading_required: bool = True + ): + """ + :param probit_api_key: The API key to connect to private ProBit APIs. + :param probit_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._probit_auth = ProbitAuth(probit_api_key, probit_secret_key) + self._order_book_tracker = ProbitOrderBookTracker(trading_pairs=trading_pairs) + self._user_stream_tracker = ProbitUserStreamTracker(self._probit_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, ProbitInFlightOrder] + 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 + + @property + def name(self) -> str: + return CONSTANTS.EXCHANGE_NAME + + @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, ProbitInFlightOrder]: + 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: ProbitInFlightOrder.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-USDT ticker + await self._api_request("get", "public/get-ticker?instrument_name=BTC_USDT") + 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(60) + 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 ProBit. " + "Check network connection.") + await asyncio.sleep(0.5) + + async def _update_trading_rules(self): + market_info = await self._api_request("GET", path_url="public/get-instruments") + self._trading_rules.clear() + self._trading_rules = self._format_trading_rules(market_info) + + def _format_trading_rules(self, market_info: Dict[str, Any]) -> Dict[str, TradingRule]: + """ + Converts json API response into a dictionary of trading rules. + :param market_info: The json API response + :return A dictionary of trading rules. + Response Example: + { + data: [ + { + "id":"BCH-BTC", + "base_currency_id":"BCH", + "quote_currency_id":"BTC", + "min_price":"0.00000001", + "max_price":"9999999999999999", + "price_increment":"0.00000001", + "min_quantity":"0.00000001", + "max_quantity":"9999999999999999", + "quantity_precision":8, + "min_cost":"0", + "max_cost":"9999999999999999", + "cost_precision": 8 + }, + ... + ] + } + """ + result = {} + for market in market_info["data"]: + try: + trading_pair = market["id"] + + quantity_decimals = Decimal(str(market["quantity_precision"])) + quantity_step = Decimal("1") / Decimal(str(math.pow(10, quantity_decimals))) + + result[trading_pair] = TradingRule(trading_pair=trading_pair, + min_order_size=Decimal(str(market["min_cost"])), + max_order_size=Decimal(str(market["max_cost"])), + min_price_increment=Decimal(str(market["price_increment"])), + min_base_amount_increment=quantity_step) + except Exception: + self.logger().error(f"Error parsing the trading pair rule {market}. Skipping.", exc_info=True) + return result + + async def _api_request(self, + method: str, + path_url: str, + params: Optional[Dict[str, Any]] = None, + data: Optional[Dict[str, Any]] = None, + is_auth_required: bool = False) -> Dict[str, Any]: + """ + Sends an aiohttp request and waits for a response. + :param method: The HTTP method, e.g. get or post + :param path_url: The path url or the API end point + :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. + """ + client = await self._http_client() + + if is_auth_required: + headers = self._probit_auth.generate_auth_dict() + else: + headers = self._probit_auth.get_headers() + + if method == "GET": + response = await client.get(path_url, headers=headers, params=params) + elif method == "POST": + response = await client.post(path_url, headers=headers, params=params, data=data) + else: + raise NotImplementedError(f"{method} HTTP Method not implemented. ") + + try: + parsed_response = await response.json() + except Exception as e: + raise IOError(f"Error parsing data from {path_url}. Error: {str(e)}") + if response.status != 200: + raise IOError(f"Error fetching data from {path_url}. HTTP status is {response.status}. " + f"Message: {parsed_response}") + if parsed_response["code"] != 0: + raise IOError(f"{path_url} API call failed, response: {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 = probit_utils.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 = probit_utils.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"{trade_type.name} order amount {amount} is lower than the minimum order size " + f"{trading_rule.min_order_size}.") + + body_params = { + "market_id": trading_pair, + "type": "limit", # ProBit Order Types ["limit", "market"} + "side": trade_type.name.lower(), # ProBit Order Sides ["buy", "sell"] + "time_in_force": "gtc", # gtc = Good-Til-Cancelled + "limit_price": price, + "quantity": amount, + "client_order_id": order_id + } + + self.start_tracking_order(order_id, + None, + trading_pair, + trade_type, + price, + amount, + order_type + ) + try: + order_result = await self._api_request( + method="POST", + path_url=CONSTANTS.NEW_ORDER_URL, + data=body_params, + is_auth_required=True + ) + exchange_order_id = str(order_result["data"]["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) + + event_tag = MarketEvent.BuyOrderCreated if trade_type is TradeType.BUY else MarketEvent.SellOrderCreated + 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 + )) + except asyncio.CancelledError: + raise + except Exception as e: + self.stop_tracking_order(order_id) + self.logger().network( + f"Error submitting {trade_type.name} {order_type.name} order to ProBit for " + f"{amount} {trading_pair} " + f"{price}.", + exc_info=True, + app_warning_msg=str(e) + ) + 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] = ProbitInFlightOrder( + 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] + + 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 + :param order_id: The internal order id + order.last_state to change to CANCELED + """ + 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 + + body_params = { + "market_id": trading_pair, + "order_id": ex_order_id + } + + await self._api_request( + method="POST", + path_url=CONSTANTS.CANCEL_ORDER_URL, + data=body_params, + is_auth_required=True + ) + return order_id + except asyncio.CancelledError: + raise + except Exception as e: + self.logger().network( + f"Failed to cancel order {order_id}: {str(e)}", + exc_info=True, + app_warning_msg=f"Failed to cancel the order {order_id} on Probit. " + f"Check API key and network connection." + ) + + 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) + self.logger().network("Unexpected error while fetching account updates.", + exc_info=True, + app_warning_msg="Could not fetch account updates from ProBit. " + "Check API key and network connection.") + 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() + balance_info = await self._api_request( + method="GET", + path_url=CONSTANTS.BALANCE_URL, + is_auth_required=True + ) + for currency in balance_info["data"]: + asset_name = currency["currency_id"] + self._account_available_balances[asset_name] = Decimal(str(currency["available"])) + self._account_balances[asset_name] = Decimal(str(currency["total"])) + 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 / self.UPDATE_ORDER_STATUS_MIN_INTERVAL) + current_tick = int(self.current_timestamp / self.UPDATE_ORDER_STATUS_MIN_INTERVAL) + + if current_tick > last_tick and len(self._in_flight_orders) > 0: + # TODO: Refactor _update_order_status + tracked_orders = list(self._in_flight_orders.values()) + + tasks = [] + for tracked_order in tracked_orders: + ex_order_id = await tracked_order.get_exchange_order_id() + + query_params = { + "market_id": tracked_order.trading_pair, + "order_id": ex_order_id + } + + tasks.append(self._api_request(method="POST", + path_url=CONSTANTS.ORDER_URL, + params=query_params, + is_auth_required=True) + ) + self.logger().debug(f"Polling for order status updates of {len(tasks)} orders.") + update_results = await safe_gather(*tasks, return_exceptions=True) + for update_result in update_results: + if isinstance(update_result, Exception): + raise update_result + if "data" not in update_result: + self.logger().info(f"_update_order_status data not in resp: {update_result}") + continue + + # TODO: Determine best way to determine that order has been partially/fully executed + for trade_msg in update_result["result"]["trade_list"]: + await self._process_trade_message(trade_msg) + self._process_order_message(update_result["result"]["order_info"]) + + 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) + """ + client_order_id = order_msg["client_oid"] + if client_order_id not in self._in_flight_orders: + return + tracked_order = self._in_flight_orders[client_order_id] + # Update order execution status + tracked_order.last_state = order_msg["status"] + if tracked_order.is_cancelled: + self.logger().info(f"Successfully cancelled order {client_order_id}.") + self.trigger_event(MarketEvent.OrderCancelled, + OrderCancelledEvent( + self.current_timestamp, + client_order_id)) + tracked_order.cancelled_event.set() + self.stop_tracking_order(client_order_id) + elif tracked_order.is_failure: + self.logger().info(f"The market order {client_order_id} has failed according to order status API. " + f"Reason: {probit_utils.get_api_reason(order_msg['reason'])}") + self.trigger_event(MarketEvent.OrderFailure, + MarketOrderFailureEvent( + self.current_timestamp, + client_order_id, + tracked_order.order_type + )) + self.stop_tracking_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. + """ + for order in self._in_flight_orders.values(): + await order.get_exchange_order_id() + track_order = [o for o in self._in_flight_orders.values() if trade_msg["order_id"] == o.exchange_order_id] + if not track_order: + return + tracked_order = track_order[0] + updated = tracked_order.update_with_trade_update(trade_msg) + if not updated: + return + 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(trade_msg["traded_price"])), + Decimal(str(trade_msg["traded_quantity"])), + TradeFee(0.0, [(trade_msg["fee_currency"], Decimal(str(trade_msg["fee"])))]), + exchange_trade_id=trade_msg["order_id"] + ) + ) + if math.isclose(tracked_order.executed_amount_base, tracked_order.amount) or \ + tracked_order.executed_amount_base >= tracked_order.amount: + 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 + 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): + """ + 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.") + cancellation_results = [] + try: + for trading_pair in self._trading_pairs: + await self._api_request( + "post", + "private/cancel-all-orders", + {"instrument_name": probit_utils.convert_to_exchange_trading_pair(trading_pair)}, + True + ) + open_orders = await self.get_open_orders() + for cl_order_id, tracked_order in self._in_flight_orders.items(): + open_order = [o for o in open_orders if o.client_order_id == cl_order_id] + if not open_order: + cancellation_results.append(CancellationResult(cl_order_id, True)) + self.trigger_event(MarketEvent.OrderCancelled, + OrderCancelledEvent(self.current_timestamp, cl_order_id)) + else: + cancellation_results.append(CancellationResult(cl_order_id, False)) + except Exception: + self.logger().network( + "Failed to cancel all orders.", + exc_info=True, + app_warning_msg="Failed to cancel all orders on ProBit. 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 = (self.SHORT_POLL_INTERVAL + if now - self._user_stream_tracker.last_recv_time > 60.0 + else self.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="Could not fetch user events from Probit. 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 + ProbitAPIUserStreamDataSource. + """ + async for event_message in self._iter_user_event_queue(): + try: + if "result" not in event_message or "channel" not in event_message["result"]: + continue + channel = event_message["result"]["channel"] + if "user.trade" in channel: + for trade_msg in event_message["result"]["data"]: + await self._process_trade_message(trade_msg) + elif "user.order" in channel: + for order_msg in event_message["result"]["data"]: + self._process_order_message(order_msg) + elif channel == "user.balance": + balances = event_message["result"]["data"] + for balance_entry in balances: + asset_name = balance_entry["currency"] + self._account_balances[asset_name] = Decimal(str(balance_entry["balance"])) + self._account_available_balances[asset_name] = Decimal(str(balance_entry["available"])) + except asyncio.CancelledError: + raise + except Exception: + self.logger().error("Unexpected error in user stream listener loop.", exc_info=True) + await asyncio.sleep(5.0) + + async def get_open_orders(self) -> List[OpenOrder]: + result = await self._api_request( + "post", + "private/get-open-orders", + {}, + True + ) + ret_val = [] + for order in result["result"]["order_list"]: + if probit_utils.HBOT_BROKER_ID not in order["client_oid"]: + continue + if order["type"] != "LIMIT": + raise Exception(f"Unsupported order type {order['type']}") + ret_val.append( + OpenOrder( + client_order_id=order["client_oid"], + trading_pair=probit_utils.convert_from_exchange_trading_pair(order["instrument_name"]), + price=Decimal(str(order["price"])), + amount=Decimal(str(order["quantity"])), + executed_amount=Decimal(str(order["cumulative_quantity"])), + status=order["status"], + order_type=OrderType.LIMIT, + is_buy=True if order["side"].lower() == "buy" else False, + time=int(order["create_time"]), + exchange_order_id=order["order_id"] + ) + ) + return ret_val From 1e4c9ec4057e83a9d966a43939454ecae38d4858 Mon Sep 17 00:00:00 2001 From: Daniel Tan Date: Wed, 17 Feb 2021 15:03:48 +0800 Subject: [PATCH 026/131] (fix) fix websocket authentication error when oauth access token contains forward slash --- .../exchange/probit/probit_api_user_stream_data_source.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hummingbot/connector/exchange/probit/probit_api_user_stream_data_source.py b/hummingbot/connector/exchange/probit/probit_api_user_stream_data_source.py index e4484308e1..9678c8fde2 100644 --- a/hummingbot/connector/exchange/probit/probit_api_user_stream_data_source.py +++ b/hummingbot/connector/exchange/probit/probit_api_user_stream_data_source.py @@ -68,7 +68,7 @@ async def _authenticate(self, ws: websockets.WebSocketClientProtocol): } await ws.send(ujson.dumps(auth_payload)) auth_resp = await ws.recv() - auth_resp: Dict[str, Any] = ujson.loads(auth_resp) + auth_resp: Dict[str, Any] = ujson.loads(auth_resp, escape_forward_slashes=False) if auth_resp["result"] != "ok": self.logger().error(f"Response: {auth_resp}", From 0b165562b1657bc942150bb1c215e6789a90ec06 Mon Sep 17 00:00:00 2001 From: Nullably <37262506+Nullably@users.noreply.github.com> Date: Wed, 17 Feb 2021 15:33:24 +0800 Subject: [PATCH 027/131] (fix) cancel all restored orders upto strategy resumes --- .../liquidity_mining/liquidity_mining.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/hummingbot/strategy/liquidity_mining/liquidity_mining.py b/hummingbot/strategy/liquidity_mining/liquidity_mining.py index d42e751009..a1b38364de 100644 --- a/hummingbot/strategy/liquidity_mining/liquidity_mining.py +++ b/hummingbot/strategy/liquidity_mining/liquidity_mining.py @@ -87,7 +87,7 @@ def tick(self, timestamp: float): :param timestamp: current tick timestamp """ if not self._ready_to_trade: - self._ready_to_trade = self._exchange.ready + self._ready_to_trade = self._exchange.ready and len(self._exchange.limit_orders) == 0 if not self._exchange.ready: self.logger().warning(f"{self._exchange.name} is not ready. Please wait...") return @@ -176,7 +176,16 @@ async def format_status(self) -> str: return "\n".join(lines) def start(self, clock: Clock, timestamp: float): - pass + restored_orders = self._exchange.limit_orders + for order in restored_orders: + self._exchange.cancel(order.trading_pair, order.client_order_id) + # restored_orders = self.track_restored_orders(self._exchange, list(self._market_infos.values())) + # for trading_pair in {o.trading_pair for o in restored_orders}: + # self._refresh_times[trading_pair] = self.current_timestamp + self._order_refresh_time + # for market_info in self._market_infos.values(): + # restored_order_ids = self.track_restored_orders(market_info) + # if restored_order_ids: + # self.logger().info(f"Restored orders: [{restored_order_ids}]") def stop(self, clock: Clock): pass @@ -202,15 +211,16 @@ def create_budget_allocation(self): # Equally assign buy and sell budgets to all markets self._sell_budgets = {m: s_decimal_zero for m in self._market_infos} self._buy_budgets = {m: s_decimal_zero for m in self._market_infos} + token_bal = self.adjusted_available_balances().get(self._token, s_decimal_zero) if self._token == list(self._market_infos.keys())[0].split("-")[0]: base_markets = [m for m in self._market_infos if m.split("-")[0] == self._token] - sell_size = self._exchange.get_available_balance(self._token) / len(base_markets) + sell_size = token_bal / len(base_markets) for market in base_markets: self._sell_budgets[market] = sell_size self._buy_budgets[market] = self._exchange.get_available_balance(market.split("-")[1]) else: quote_markets = [m for m in self._market_infos if m.split("-")[1] == self._token] - buy_size = self._exchange.get_available_balance(self._token) / len(quote_markets) + buy_size = token_bal / len(quote_markets) for market in quote_markets: self._buy_budgets[market] = buy_size self._sell_budgets[market] = self._exchange.get_available_balance(market.split("-")[0]) From 597a92c33e4d320df66ff64bd45ba66fa5bf408d Mon Sep 17 00:00:00 2001 From: Nullably <37262506+Nullably@users.noreply.github.com> Date: Wed, 17 Feb 2021 15:43:15 +0800 Subject: [PATCH 028/131] (fix) minor edit --- hummingbot/strategy/liquidity_mining/liquidity_mining.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/hummingbot/strategy/liquidity_mining/liquidity_mining.py b/hummingbot/strategy/liquidity_mining/liquidity_mining.py index a1b38364de..ad47b2e422 100644 --- a/hummingbot/strategy/liquidity_mining/liquidity_mining.py +++ b/hummingbot/strategy/liquidity_mining/liquidity_mining.py @@ -87,6 +87,7 @@ def tick(self, timestamp: float): :param timestamp: current tick timestamp """ if not self._ready_to_trade: + # Check if there are restored orders, they should be canceled before strategy starts. self._ready_to_trade = self._exchange.ready and len(self._exchange.limit_orders) == 0 if not self._exchange.ready: self.logger().warning(f"{self._exchange.name} is not ready. Please wait...") @@ -179,13 +180,6 @@ def start(self, clock: Clock, timestamp: float): restored_orders = self._exchange.limit_orders for order in restored_orders: self._exchange.cancel(order.trading_pair, order.client_order_id) - # restored_orders = self.track_restored_orders(self._exchange, list(self._market_infos.values())) - # for trading_pair in {o.trading_pair for o in restored_orders}: - # self._refresh_times[trading_pair] = self.current_timestamp + self._order_refresh_time - # for market_info in self._market_infos.values(): - # restored_order_ids = self.track_restored_orders(market_info) - # if restored_order_ids: - # self.logger().info(f"Restored orders: [{restored_order_ids}]") def stop(self, clock: Clock): pass From 897938ea754a34367d0af692e5eb06016ced37ed Mon Sep 17 00:00:00 2001 From: Nullably <37262506+Nullably@users.noreply.github.com> Date: Wed, 17 Feb 2021 18:29:30 +0800 Subject: [PATCH 029/131] (feat) update allocation calculation to account for existing base/quote assets --- .../liquidity_mining/liquidity_mining.py | 45 ++++++++++++------- 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/hummingbot/strategy/liquidity_mining/liquidity_mining.py b/hummingbot/strategy/liquidity_mining/liquidity_mining.py index ad47b2e422..ca7b0c6b48 100644 --- a/hummingbot/strategy/liquidity_mining/liquidity_mining.py +++ b/hummingbot/strategy/liquidity_mining/liquidity_mining.py @@ -135,13 +135,11 @@ async def active_orders_df(self) -> pd.DataFrame: def market_status_df(self) -> pd.DataFrame: data = [] columns = ["Exchange", "Market", "Mid Price", "Volatility", "Base Bal", "Quote Bal", " Base %"] - balances = self.adjusted_available_balances() for market, market_info in self._market_infos.items(): - base, quote = market.split("-") mid_price = market_info.get_mid_price() base_bal = self._sell_budgets[market] quote_bal = self._buy_budgets[market] - total_bal = (base_bal * mid_price) + balances[quote] + total_bal = (base_bal * mid_price) + quote_bal base_pct = (base_bal * mid_price) / total_bal if total_bal > 0 else s_decimal_zero data.append([ self._exchange.display_name, @@ -201,23 +199,36 @@ def create_base_proposals(self): proposals.append(Proposal(market, PriceSize(buy_price, buy_size), PriceSize(sell_price, sell_size))) return proposals + def total_port_value_in_token(self) -> Decimal: + all_bals = self.adjusted_available_balances() + port_value = all_bals.get(self._token, s_decimal_zero) + for market, market_info in self._market_infos.items(): + base, quote = market.split("-") + if self.is_token_a_quote_token(): + port_value += all_bals[base] * market_info.get_mid_price() + else: + port_value += all_bals[quote] / market_info.get_mid_price() + return port_value + def create_budget_allocation(self): - # Equally assign buy and sell budgets to all markets + # Create buy and sell budgets for every market self._sell_budgets = {m: s_decimal_zero for m in self._market_infos} self._buy_budgets = {m: s_decimal_zero for m in self._market_infos} - token_bal = self.adjusted_available_balances().get(self._token, s_decimal_zero) - if self._token == list(self._market_infos.keys())[0].split("-")[0]: - base_markets = [m for m in self._market_infos if m.split("-")[0] == self._token] - sell_size = token_bal / len(base_markets) - for market in base_markets: - self._sell_budgets[market] = sell_size - self._buy_budgets[market] = self._exchange.get_available_balance(market.split("-")[1]) - else: - quote_markets = [m for m in self._market_infos if m.split("-")[1] == self._token] - buy_size = token_bal / len(quote_markets) - for market in quote_markets: - self._buy_budgets[market] = buy_size - self._sell_budgets[market] = self._exchange.get_available_balance(market.split("-")[0]) + port_value = self.total_port_value_in_token() + market_portion = port_value / len(self._market_infos) + balances = self.adjusted_available_balances() + for market, market_info in self._market_infos.items(): + base, quote = market.split("-") + if self.is_token_a_quote_token(): + self._sell_budgets[market] = balances[base] + buy_budget = market_portion - (balances[base] * market_info.get_mid_price()) + if buy_budget > s_decimal_zero: + self._buy_budgets[market] = buy_budget + else: + self._buy_budgets[market] = balances[quote] + sell_budget = market_portion - (balances[quote] / market_info.get_mid_price()) + if sell_budget > s_decimal_zero: + self._sell_budgets[market] = sell_budget def base_order_size(self, trading_pair: str, price: Decimal = s_decimal_zero): base, quote = trading_pair.split("-") From fc147bb081a562bc9a12106b619e0a887711e4a7 Mon Sep 17 00:00:00 2001 From: Kirill Stroukov Date: Wed, 17 Feb 2021 14:43:55 +0100 Subject: [PATCH 030/131] feat/Bexy move to private APIv2 --- .../beaxy/beaxy_active_order_tracker.pyx | 134 ++++++------ .../beaxy/beaxy_api_order_book_data_source.py | 37 +++- .../connector/exchange/beaxy/beaxy_auth.py | 95 ++++++++- .../exchange/beaxy/beaxy_constants.py | 21 +- .../exchange/beaxy/beaxy_exchange.pxd | 1 + .../exchange/beaxy/beaxy_exchange.pyx | 195 ++++++++++-------- .../exchange/beaxy/beaxy_in_flight_order.pyx | 2 +- .../connector/exchange/beaxy/beaxy_misc.py | 3 +- .../beaxy/beaxy_order_book_message.py | 16 +- 9 files changed, 310 insertions(+), 194 deletions(-) diff --git a/hummingbot/connector/exchange/beaxy/beaxy_active_order_tracker.pyx b/hummingbot/connector/exchange/beaxy/beaxy_active_order_tracker.pyx index f0914ce841..16444e5836 100644 --- a/hummingbot/connector/exchange/beaxy/beaxy_active_order_tracker.pyx +++ b/hummingbot/connector/exchange/beaxy/beaxy_active_order_tracker.pyx @@ -16,11 +16,11 @@ s_empty_diff = np.ndarray(shape=(0, 4), dtype='float64') BeaxyOrderBookTrackingDictionary = Dict[Decimal, Decimal] -ACTION_UPDATE = 'update' -ACTION_INSERT = 'insert' -ACTION_DELETE = 'delete' -ACTION_DELETE_THROUGH = 'delete_through' -ACTION_DELETE_FROM = 'delete_from' +ACTION_UPDATE = 'UPDATE' +ACTION_INSERT = 'INSERT' +ACTION_DELETE = 'DELETE' +ACTION_DELETE_THROUGH = 'DELETE_THROUGH' +ACTION_DELETE_FROM = 'DELETE_FROM' SIDE_BID = 'BID' SIDE_ASK = 'ASK' @@ -70,71 +70,65 @@ cdef class BeaxyActiveOrderTracker: :returns: new order book rows: Tuple(np.array (bids), np.array (asks)) """ - cdef: - dict content = message.content - str msg_action = content['action'].lower() - str order_side = content['side'] - str price_raw = str(content['price']) - double timestamp = message.timestamp - str quantity_raw = str(content['quantity']) - object price - object quantity - - if order_side not in [SIDE_BID, SIDE_ASK]: - raise ValueError(f'Unknown order side for message - "{message}". Aborting.') - - price = Decimal(price_raw) - quantity = Decimal(quantity_raw) - - if msg_action == ACTION_UPDATE: - if order_side == SIDE_BID: - self._active_bids[price] = quantity - return np.array([[timestamp, float(price), quantity, message.update_id]], dtype='float64'), s_empty_diff - else: - self._active_asks[price] = quantity - return s_empty_diff, np.array([[timestamp, float(price), quantity, message.update_id]], dtype='float64') + def diff(side): - elif msg_action == ACTION_INSERT: - if price in self._active_bids or price in self._active_asks: - raise ValueError(f'Got INSERT action in message - "{message}" but there already was an item with same price. Aborting.') + for entry in message.content['entries']: - if order_side == SIDE_BID: - self._active_bids[price] = quantity - return np.array([[timestamp, float(price), quantity, message.update_id]], dtype='float64'), s_empty_diff - else: - self._active_asks[price] = quantity - return s_empty_diff, np.array([[timestamp, float(price), quantity, message.update_id]], dtype='float64') - elif msg_action == ACTION_DELETE: - # in case of DELETE action we need to substract the provided quantity from existing one - if price not in self._active_bids and price not in self._active_asks: - raise ValueError(f'Got DELETE action in message - "{message}" but there was not entry with that price. Aborting.') - - if order_side == SIDE_BID: - new_quantity = self._active_bids[price] - quantity - self._active_bids[price] = new_quantity - return np.array([[timestamp, float(price), new_quantity, message.update_id]], dtype='float64'), s_empty_diff - else: - new_quantity = self._active_asks[price] - quantity - self._active_asks[price] = new_quantity - return s_empty_diff, np.array([[timestamp, float(price), new_quantity, message.update_id]], dtype='float64') - elif msg_action == ACTION_DELETE_THROUGH: - # Remove all levels from the specified and below (all the worst prices). - if order_side == SIDE_BID: - self._active_bids = {key: value for (key, value) in self._active_bids.items() if key < price} - return s_empty_diff, s_empty_diff - else: - self._active_asks = {key: value for (key, value) in self._active_asks.items() if key < price} - return s_empty_diff, s_empty_diff - elif msg_action == ACTION_DELETE_FROM: - # Remove all levels from the specified and above (all the better prices). - if order_side == SIDE_BID: - self._active_bids = {key: value for (key, value) in self._active_bids.items() if key > price} - return s_empty_diff, s_empty_diff - else: - self._active_asks = {key: value for (key, value) in self._active_asks.items() if key > price} - return s_empty_diff, s_empty_diff - else: - raise ValueError(f'Unknown message action "{msg_action}" - {message}. Aborting.') + if entry['side'] != side: + continue + + msg_action = entry['action'] + order_side = entry['side'] + timestamp = message.timestamp + + price = Decimal(str(entry['price'])) + quantity = Decimal(str(entry['quantity'])) + + active_rows = self._active_bids if order_side == SIDE_BID else self._active_asks + + if msg_action in (ACTION_UPDATE, ACTION_INSERT): + active_rows[price] = quantity + yield [timestamp, float(price), quantity, message.update_id] + + elif msg_action == ACTION_DELETE: + # in case of DELETE action we need to substract the provided quantity from existing one + + if price not in active_rows: + continue + + new_quantity = active_rows[price] - quantity + if new_quantity < 0: + del active_rows[price] + yield [timestamp, float(price), float(0), message.update_id] + else: + active_rows[price] = new_quantity + yield [timestamp, float(price), new_quantity, message.update_id] + + elif msg_action == ACTION_DELETE_THROUGH: + # Remove all levels from the specified and below (all the worst prices). + for key in active_rows.keys(): + if key < price: + del active_rows[key] + yield [timestamp, float(price), float(0), message.update_id] + + elif msg_action == ACTION_DELETE_FROM: + # Remove all levels from the specified and above (all the better prices). + for key in active_rows.keys(): + if key > price: + del active_rows[key] + yield [timestamp, float(price), float(0), message.update_id] + + bids = np.array([r for r in diff(SIDE_BID)], dtype='float64', ndmin=2) + asks = np.array([r for r in diff(SIDE_ASK)], dtype='float64', ndmin=2) + + # If there're no rows, the shape would become (1, 0) and not (0, 4). + # Reshape to fix that. + if bids.shape[1] != 4: + bids = bids.reshape((0, 4)) + if asks.shape[1] != 4: + asks = asks.reshape((0, 4)) + + return bids, asks cdef tuple c_convert_snapshot_message_to_np_arrays(self, object message): """ @@ -163,13 +157,13 @@ cdef class BeaxyActiveOrderTracker: np.ndarray[np.float64_t, ndim=2] bids = np.array( [[message.timestamp, float(price), - float(quantity), + float(self._active_bids[price]), message.update_id] for price in sorted(self._active_bids.keys(), reverse=True)], dtype='float64', ndmin=2) np.ndarray[np.float64_t, ndim=2] asks = np.array( [[message.timestamp, float(price), - float(quantity), + float(self._active_asks[price]), message.update_id] for price in sorted(self._active_asks.keys(), reverse=True)], dtype='float64', ndmin=2) diff --git a/hummingbot/connector/exchange/beaxy/beaxy_api_order_book_data_source.py b/hummingbot/connector/exchange/beaxy/beaxy_api_order_book_data_source.py index f17fd94e8a..f577cca0b8 100644 --- a/hummingbot/connector/exchange/beaxy/beaxy_api_order_book_data_source.py +++ b/hummingbot/connector/exchange/beaxy/beaxy_api_order_book_data_source.py @@ -229,17 +229,35 @@ async def _inner_messages( await ws.close() async def listen_for_order_book_diffs(self, ev_loop: asyncio.BaseEventLoop, output: asyncio.Queue): - # Due of Beaxy api structure it is impossible to process diffs - pass + while True: + try: + # at Beaxy all pairs listed without splitter + trading_pairs = [trading_pair_to_symbol(p) for p in self._trading_pairs] + + ws_path: str = '/'.join([f'{trading_pair}@depth20' for trading_pair in trading_pairs]) + stream_url: str = f'{BeaxyConstants.PublicApi.WS_BASE_URL}/book/{ws_path}' + + async with websockets.connect(stream_url) as ws: + ws: websockets.WebSocketClientProtocol = ws + async for raw_msg in self._inner_messages(ws): + msg = ujson.loads(raw_msg) + msg_type = msg['type'] + if msg_type == ORDERBOOK_MESSAGE_DIFF: + order_book_message: OrderBookMessage = BeaxyOrderBook.diff_message_from_exchange( + msg, msg['timestamp']) + output.put_nowait(order_book_message) + except asyncio.CancelledError: + raise + except Exception: + self.logger().error('Unexpected error with WebSocket connection. Retrying after 30 seconds...', + exc_info=True) + await asyncio.sleep(30.0) async def listen_for_order_book_snapshots(self, ev_loop: asyncio.BaseEventLoop, output: asyncio.Queue): while True: try: - trading_pairs: Optional[List[str]] = await self.get_trading_pairs() - assert trading_pairs is not None - # at Beaxy all pairs listed without splitter - trading_pairs = [trading_pair_to_symbol(p) for p in trading_pairs] + trading_pairs = [trading_pair_to_symbol(p) for p in self._trading_pairs] ws_path: str = '/'.join([f'{trading_pair}@depth20' for trading_pair in trading_pairs]) stream_url: str = f'{BeaxyConstants.PublicApi.WS_BASE_URL}/book/{ws_path}' @@ -249,7 +267,7 @@ async def listen_for_order_book_snapshots(self, ev_loop: asyncio.BaseEventLoop, async for raw_msg in self._inner_messages(ws): msg = ujson.loads(raw_msg) msg_type = msg['type'] - if msg_type.lower() == ORDERBOOK_MESSAGE_SNAPSHOT.lower(): + if msg_type == ORDERBOOK_MESSAGE_SNAPSHOT: order_book_message: OrderBookMessage = BeaxyOrderBook.snapshot_message_from_exchange( msg, msg['timestamp']) output.put_nowait(order_book_message) @@ -263,11 +281,8 @@ async def listen_for_order_book_snapshots(self, ev_loop: asyncio.BaseEventLoop, async def listen_for_trades(self, ev_loop: asyncio.BaseEventLoop, output: asyncio.Queue): while True: try: - trading_pairs: Optional[List[str]] = await self.get_trading_pairs() - assert trading_pairs is not None - # at Beaxy all pairs listed without splitter - trading_pairs = [trading_pair_to_symbol(p) for p in trading_pairs] + trading_pairs = [trading_pair_to_symbol(p) for p in self._trading_pairs] ws_path: str = '/'.join([trading_pair for trading_pair in trading_pairs]) stream_url: str = f'{BeaxyConstants.PublicApi.WS_BASE_URL}/trades/{ws_path}' diff --git a/hummingbot/connector/exchange/beaxy/beaxy_auth.py b/hummingbot/connector/exchange/beaxy/beaxy_auth.py index 4abb723209..037c411c23 100644 --- a/hummingbot/connector/exchange/beaxy/beaxy_auth.py +++ b/hummingbot/connector/exchange/beaxy/beaxy_auth.py @@ -3,13 +3,16 @@ import logging import base64 import random -from typing import Dict, Any +import asyncio +from typing import Dict, Any, Optional +from time import monotonic import aiohttp from Crypto.PublicKey import RSA from Crypto.Signature import PKCS1_v1_5 from Crypto.Hash import HMAC, SHA384, SHA256 +from hummingbot.core.utils.async_utils import safe_gather from hummingbot.core.utils.tracking_nonce import get_tracking_nonce from hummingbot.logger import HummingbotLogger @@ -17,6 +20,10 @@ s_logger = None +SAFE_TIME_PERIOD_SECONDS = 10 +TOKEN_REFRESH_PERIOD_SECONDS = 10 * 60 +MIN_TOKEN_LIFE_TIME_SECONDS = 30 + class BeaxyAuth: @@ -25,6 +32,11 @@ def __init__(self, api_key: str, api_secret: str): self.api_secret = api_secret self._session_data_cache: Dict[str, Any] = {} + self.token: Optional[str] = None + self.token_obtain = asyncio.Event() + self.token_valid_to: float = 0 + self.token_next_refresh: float = 0 + @classmethod def logger(cls) -> HummingbotLogger: global s_logger @@ -32,14 +44,77 @@ def logger(cls) -> HummingbotLogger: s_logger = logging.getLogger(__name__) return s_logger + def is_token_valid(self): + return self.token_valid_to > monotonic() + + def invalidate_token(self): + self.token_valid_to = 0 + + async def get_token(self): + if self.is_token_valid(): + return self.token + + # token is invalid, waiting for a renew + if not self.token_obtain.is_set(): + # if process of refreshing is not started, start it + await self._update_token() + return self.token + + # waiting for fresh token + await self.token_obtain.wait() + return self.token + + async def _update_token(self): + + self.token_obtain.clear() + + async with aiohttp.ClientSession() as client: + async with client.post( + f'{BeaxyConstants.TradingApi.BASE_URL}{BeaxyConstants.TradingApi.TOKEN_ENDPOINT}', + json={'api_key_id': self.api_key, 'api_secret': self.api_secret} + ) as response: + response: aiohttp.ClientResponse = response + if response.status != 200: + raise IOError(f'Error while connecting to login token endpoint. HTTP status is {response.status}.') + data: Dict[str, str] = await response.json() + + if data['type'] != 'Bearer': + raise IOError(f'Error while connecting to login token endpoint. Token type is {data["type"]}.') + + if int(data['expires_in']) < MIN_TOKEN_LIFE_TIME_SECONDS: + raise IOError(f'Error while connecting to login token endpoint. Token lifetime to small {data["expires_in"]}.') + + self.token = data['access_token'] + current_time = monotonic() + + # include safe interval, e.g. time that approx network request can take + self.token_valid_to = current_time + int(data['expires_in']) - SAFE_TIME_PERIOD_SECONDS + self.token_next_refresh = current_time + TOKEN_REFRESH_PERIOD_SECONDS + + self.token_obtain.set() + + async def _auth_token_polling_loop(self): + """ + Separate background process that periodically regenerates auth token + """ + while True: + try: + await safe_gather(self._update_token()) + await asyncio.sleep(TOKEN_REFRESH_PERIOD_SECONDS) + except asyncio.CancelledError: + raise + except Exception: + self.logger().network( + 'Unexpected error while fetching auth token.', + exc_info=True, + app_warning_msg=f'Could not fetch trading rule updates on Beaxy. ' + f'Check network connection.' + ) + await asyncio.sleep(0.5) + async def generate_auth_dict(self, http_method: str, path: str, body: str = "") -> Dict[str, Any]: - session_data = await self.__get_session_data() - headers = {'X-Deltix-Nonce': str(get_tracking_nonce()), 'X-Deltix-Session-Id': session_data['session_id']} - payload = self.__build_payload(http_method, path, {}, headers, body) - hmac = HMAC.new(key= self.__int_to_bytes(session_data['sign_key'], signed=True), msg=bytes(payload, 'utf-8'), digestmod=SHA384) - digestb64 = base64.b64encode(hmac.digest()) - headers['X-Deltix-Signature'] = digestb64.decode('utf-8') - return headers + auth_token = await self.get_token() + return {'Authorization': f'Bearer {auth_token}'} async def generate_ws_auth_dict(self) -> Dict[str, Any]: session_data = await self.__get_session_data() @@ -75,7 +150,7 @@ async def __login_confirm(self, login_attempt: Dict[str, str], dh_number: int) - async with aiohttp.ClientSession() as client: async with client.post( - f'{BeaxyConstants.TradingApi.BASE_URL}{BeaxyConstants.TradingApi.LOGIN_CONFIRM_ENDPOINT}', json = {'session_id': login_attempt['session_id'], 'signature': encrypted_msg, 'dh_key': dh_key}) as response: + f'{BeaxyConstants.TradingApi.BASE_URL_V1}{BeaxyConstants.TradingApi.LOGIN_CONFIRM_ENDPOINT}', json = {'session_id': login_attempt['session_id'], 'signature': encrypted_msg, 'dh_key': dh_key}) as response: response: aiohttp.ClientResponse = response if response.status != 200: raise IOError(f'Error while connecting to login confirm endpoint. HTTP status is {response.status}.') @@ -89,7 +164,7 @@ def __int_to_bytes(self, i: int, *, signed: bool = False) -> bytes: async def __login_attempt(self) -> Dict[str, str]: async with aiohttp.ClientSession() as client: - async with client.post(f'{BeaxyConstants.TradingApi.BASE_URL}{BeaxyConstants.TradingApi.LOGIN_ATTEMT_ENDPOINT}', json = {'api_key_id': self.api_key}) as response: + async with client.post(f'{BeaxyConstants.TradingApi.BASE_URL_V1}{BeaxyConstants.TradingApi.LOGIN_ATTEMT_ENDPOINT}', json = {'api_key_id': self.api_key}) as response: response: aiohttp.ClientResponse = response if response.status != 200: raise IOError(f'Error while connecting to login attempt endpoint. HTTP status is {response.status}.') diff --git a/hummingbot/connector/exchange/beaxy/beaxy_constants.py b/hummingbot/connector/exchange/beaxy/beaxy_constants.py index 5a3d07c88d..638315e6cb 100644 --- a/hummingbot/connector/exchange/beaxy/beaxy_constants.py +++ b/hummingbot/connector/exchange/beaxy/beaxy_constants.py @@ -3,15 +3,21 @@ class BeaxyConstants: class TradingApi: - BASE_URL = 'https://tradingapi.beaxy.com' - WS_BASE_URL = 'wss://tradingapi.beaxy.com/websocket/v1' - SECURITIES_ENDPOINT = '/api/v1/securities' + BASE_URL_V1 = 'https://tradingapi.beaxy.com' + BASE_URL = 'https://tradewith.beaxy.com' + HEALTH_ENDPOINT = '/api/v2/health' + TOKEN_ENDPOINT = '/api/v2/auth' + WALLETS_ENDPOINT = '/api/v2/wallets' + OPEN_ORDERS_ENDPOINT = '/api/v2/orders/open' + CLOSED_ORDERS_ENDPOINT = '/api/v2/orders/closed?from_date={from_date}' + DELETE_ORDER_ENDPOINT = '/api/v2/orders/open/{id}' + CREATE_ORDER_ENDPOINT = '/api/v2/orders' + TRADE_SETTINGS_ENDPOINT = '/api/v2/tradingsettings' + LOGIN_ATTEMT_ENDPOINT = '/api/v1/login/attempt' LOGIN_CONFIRM_ENDPOINT = '/api/v1/login/confirm' - HEALTH_ENDPOINT = '/api/v1/trader/health' - ACOUNTS_ENDPOINT = '/api/v1/accounts' - ORDERS_ENDPOINT = '/api/v1/orders' - KEEP_ALIVE_ENDPOINT = '/api/v1/login/keepalive' + + WS_BASE_URL = 'wss://tradingapi.beaxy.com/websocket/v1' class PublicApi: BASE_URL = 'https://services.beaxy.com' @@ -19,4 +25,5 @@ class PublicApi: RATE_URL = BASE_URL + '/api/v2/symbols/{symbol}/rate' RATES_URL = BASE_URL + '/api/v2/symbols/rates' ORDER_BOOK_URL = BASE_URL + '/api/v2/symbols/{symbol}/book?depth={depth}' + WS_BASE_URL = 'wss://services.beaxy.com/ws/v2' diff --git a/hummingbot/connector/exchange/beaxy/beaxy_exchange.pxd b/hummingbot/connector/exchange/beaxy/beaxy_exchange.pxd index 8904bf71fc..30cee0999b 100644 --- a/hummingbot/connector/exchange/beaxy/beaxy_exchange.pxd +++ b/hummingbot/connector/exchange/beaxy/beaxy_exchange.pxd @@ -20,6 +20,7 @@ cdef class BeaxyExchange(ExchangeBase): TransactionTracker _tx_tracker dict _trading_rules object _coro_queue + public object _auth_polling_task public object _status_polling_task public object _coro_scheduler_task public object _user_stream_tracker_task diff --git a/hummingbot/connector/exchange/beaxy/beaxy_exchange.pyx b/hummingbot/connector/exchange/beaxy/beaxy_exchange.pyx index 95b7dd498f..efc85b7e66 100644 --- a/hummingbot/connector/exchange/beaxy/beaxy_exchange.pyx +++ b/hummingbot/connector/exchange/beaxy/beaxy_exchange.pyx @@ -5,6 +5,7 @@ import logging import json from typing import Any, Dict, List, AsyncIterable, Optional, Tuple +from datetime import datetime, timedelta from async_timeout import timeout from decimal import Decimal from libc.stdint cimport int64_t @@ -96,13 +97,14 @@ cdef class BeaxyExchange(ExchangeBase): self._in_flight_orders: Dict[str, BeaxyInFlightOrder] = {} self._tx_tracker = BeaxyExchangeTransactionTracker(self) self._trading_rules = {} + self._auth_polling_task = None self._status_polling_task = None self._user_stream_tracker_task = None self._user_stream_event_listener_task = None self._trading_rules_polling_task = None self._shared_client = None - self._maker_fee_percentage = 0 - self._taker_fee_percentage = 0 + self._maker_fee_percentage = {} + self._taker_fee_percentage = {} @staticmethod def split_trading_pair(trading_pair: str) -> Optional[Tuple[str, str]]: @@ -232,6 +234,7 @@ cdef class BeaxyExchange(ExchangeBase): self._order_book_tracker.start() self.logger().debug(f'OrderBookTracker started, starting polling tasks.') if self._trading_required: + self._auth_polling_task = safe_ensure_future(self._beaxy_auth._auth_token_polling_loop()) self._status_polling_task = safe_ensure_future(self._status_polling_loop()) self._trading_rules_polling_task = safe_ensure_future(self._trading_rules_polling_loop()) self._user_stream_tracker_task = safe_ensure_future(self._user_stream_tracker.start()) @@ -240,7 +243,7 @@ cdef class BeaxyExchange(ExchangeBase): async def check_network(self) -> NetworkStatus: try: res = await self._api_request(http_method='GET', path_url=BeaxyConstants.TradingApi.HEALTH_ENDPOINT, is_auth_required=False) - if not res['is_alive']: + if res['trading_server'] != 200 and res['historical_data_server'] != 200: return NetworkStatus.STOPPED except asyncio.CancelledError: raise @@ -286,8 +289,12 @@ cdef class BeaxyExchange(ExchangeBase): Gets a list of the user's active orders via rest API :returns: json response """ - path_url = BeaxyConstants.TradingApi.ORDERS_ENDPOINT - result = await self._api_request('get', path_url=path_url) + day_ago = (datetime.utcnow() - timedelta(days=1)).strftime("%Y-%m-%dT%H:%M:%SZ") + + result = await safe_gather( + self._api_request('get', path_url=BeaxyConstants.TradingApi.OPEN_ORDERS_ENDPOINT), + self._api_request('get', path_url=BeaxyConstants.TradingApi.CLOSED_ORDERS_ENDPOINT.format(from_date=day_ago)), + ) return result async def _update_order_status(self): @@ -301,16 +308,22 @@ cdef class BeaxyExchange(ExchangeBase): return tracked_orders = list(self._in_flight_orders.values()) - open_orders = await self.list_orders() - order_dict = {entry['id']: entry for entry in open_orders} + open_orders, closed_orders = await self.list_orders() + open_order_dict = {entry['order_id']: entry for entry in open_orders} + close_order_dict = {entry['order_id']: entry for entry in closed_orders} for tracked_order in tracked_orders: exchange_order_id = await tracked_order.get_exchange_order_id() - order_update = order_dict.get(exchange_order_id) + + open_order = open_order_dict.get(exchange_order_id) + closed_order = close_order_dict.get(exchange_order_id) + client_order_id = tracked_order.client_order_id - if order_update is None: + order_update = closed_order or open_order + + if not open_order and not closed_order: self.logger().info( - f'The tracked order {client_order_id} does not exist on Beaxy.' + f'The tracked order {client_order_id} does not exist on Beaxy for last day.' f'Removing from tracking.' ) tracked_order.last_state = "CLOSED" @@ -321,38 +334,41 @@ cdef class BeaxyExchange(ExchangeBase): self.c_stop_tracking_order(client_order_id) continue - # Calculate the newly executed amount for this update. - new_confirmed_amount = Decimal(order_update['cumulative_quantity']) - execute_amount_diff = new_confirmed_amount - tracked_order.executed_amount_base - execute_price = Decimal(order_update['average_price']) - - order_type_description = tracked_order.order_type_description - # Emit event if executed amount is greater than 0. - if execute_amount_diff > s_decimal_0: - order_filled_event = OrderFilledEvent( - self._current_timestamp, - tracked_order.client_order_id, - tracked_order.trading_pair, - tracked_order.trade_type, - tracked_order.order_type, - execute_price, - execute_amount_diff, - self.c_get_fee( - tracked_order.base_asset, - tracked_order.quote_asset, - tracked_order.order_type, + execute_price = Decimal(order_update['average_price'] if order_update['average_price'] else order_update['limit_price']) + + if order_update['filled_size']: + new_confirmed_amount = Decimal(order_update['filled_size']) + execute_amount_diff = new_confirmed_amount - tracked_order.executed_amount_base + + order_type_description = tracked_order.order_type_description + # Emit event if executed amount is greater than 0. + if execute_amount_diff > s_decimal_0: + order_filled_event = OrderFilledEvent( + self._current_timestamp, + tracked_order.client_order_id, + tracked_order.trading_pair, tracked_order.trade_type, + tracked_order.order_type, execute_price, execute_amount_diff, - ), - exchange_trade_id=exchange_order_id, - ) - self.logger().info(f'Filled {execute_amount_diff} out of {tracked_order.amount} of the ' - f'{order_type_description} order {client_order_id}.') - self.c_trigger_event(self.MARKET_ORDER_FILLED_EVENT_TAG, order_filled_event) + self.c_get_fee( + tracked_order.base_asset, + tracked_order.quote_asset, + tracked_order.order_type, + tracked_order.trade_type, + execute_price, + execute_amount_diff, + ), + exchange_trade_id=exchange_order_id, + ) + self.logger().info(f'Filled {execute_amount_diff} out of {tracked_order.amount} of the ' + f'{order_type_description} order {client_order_id}.') + self.c_trigger_event(self.MARKET_ORDER_FILLED_EVENT_TAG, order_filled_event) + else: + new_confirmed_amount = Decimal(order_update['size']) # Update the tracked order - tracked_order.last_state = order_update['status'] + tracked_order.last_state = order_update['order_status'] if not closed_order else 'closed' tracked_order.executed_amount_base = new_confirmed_amount tracked_order.executed_amount_quote = new_confirmed_amount * execute_price if tracked_order.is_done: @@ -403,18 +419,16 @@ cdef class BeaxyExchange(ExchangeBase): Async wrapper for placing orders through the rest API. :returns: json response from the API """ - path_url = BeaxyConstants.TradingApi.ORDERS_ENDPOINT + path_url = BeaxyConstants.TradingApi.CREATE_ORDER_ENDPOINT trading_pair = trading_pair_to_symbol(trading_pair) # at Beaxy all pairs listed without splitter is_limit_type = order_type.is_limit_type() data = { - 'text': order_id, - 'security_id': trading_pair, - 'type': 'limit' if is_limit_type else 'market', + 'comment': order_id, + 'symbol': trading_pair, + 'order_type': 'limit' if is_limit_type else 'market', 'side': 'buy' if is_buy else 'sell', - 'quantity': f'{amount:f}', - # https://beaxyapiv2trading.docs.apiary.io/#/data-structures/0/time-in-force?mc=reference%2Frest%2Forder%2Fcreate-order%2F200 - 'time_in_force': 'gtc' if is_limit_type else 'ioc', + 'size': f'{amount:f}', 'destination': 'MAXI', } if is_limit_type: @@ -437,19 +451,20 @@ cdef class BeaxyExchange(ExchangeBase): function to calculate fees for a particular order :returns: TradeFee class that includes fee percentage and flat fees """ - # There is no API for checking user's fee tier - """ + cdef: object maker_fee = self._maker_fee_percentage object taker_fee = self._taker_fee_percentage - if order_type is OrderType.LIMIT and fee_overrides_config_map['beaxy_maker_fee'].value is not None: - return TradeFee(percent=fee_overrides_config_map['beaxy_maker_fee'].value / Decimal('100')) - if order_type is OrderType.MARKET and fee_overrides_config_map['beaxy_taker_fee'].value is not None: - return TradeFee(percent=fee_overrides_config_map['beaxy_taker_fee'].value / Decimal('100')) - """ is_maker = order_type is OrderType.LIMIT_MAKER - return estimate_fee('beaxy', is_maker) + pair = f'{base_currency}-{quote_currency}' + fees = maker_fee if is_maker else taker_fee + + if pair not in fees: + self.logger().info(f'Fee for {pair} is not in fee cache') + return estimate_fee('beaxy', is_maker) + + return TradeFee(percent=fees[pair] / Decimal(100)) async def execute_buy( self, @@ -475,7 +490,7 @@ cdef class BeaxyExchange(ExchangeBase): try: self.c_start_tracking_order(order_id, trading_pair, order_type, TradeType.BUY, decimal_price, decimal_amount) order_result = await self.place_order(order_id, trading_pair, decimal_amount, True, order_type, decimal_price) - exchange_order_id = order_result['id'] + exchange_order_id = order_result['order_id'] tracked_order = self._in_flight_orders.get(order_id) if tracked_order is not None: self.logger().info(f'Created {order_type} buy order {order_id} for {decimal_amount} {trading_pair}.') @@ -491,7 +506,6 @@ cdef class BeaxyExchange(ExchangeBase): except asyncio.CancelledError: raise except Exception: - self.logger().error(1) tracked_order = self._in_flight_orders.get(order_id) tracked_order.last_state = "FAILURE" self.c_stop_tracking_order(order_id) @@ -503,7 +517,6 @@ cdef class BeaxyExchange(ExchangeBase): exc_info=True, app_warning_msg=f"Failed to submit buy order to Beaxy. Check API key and network connection." ) - self.logger().error(2) self.c_trigger_event(self.MARKET_ORDER_FAILURE_EVENT_TAG, MarketOrderFailureEvent( self._current_timestamp, @@ -549,7 +562,7 @@ cdef class BeaxyExchange(ExchangeBase): self.c_start_tracking_order(order_id, trading_pair, order_type, TradeType.SELL, decimal_price, decimal_amount) order_result = await self.place_order(order_id, trading_pair, decimal_amount, False, order_type, decimal_price) - exchange_order_id = order_result['id'] + exchange_order_id = order_result['order_id'] tracked_order = self._in_flight_orders.get(order_id) if tracked_order is not None: self.logger().info(f'Created {order_type} sell order {order_id} for {decimal_amount} {trading_pair}.') @@ -605,21 +618,20 @@ cdef class BeaxyExchange(ExchangeBase): 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.') - path_url = BeaxyConstants.TradingApi.ORDERS_ENDPOINT - cancel_result = await self._api_request('delete', path_url=path_url, custom_headers={'X-Deltix-Order-ID': tracked_order.exchange_order_id.lower()}) + path_url = BeaxyConstants.TradingApi.DELETE_ORDER_ENDPOINT.format(id=tracked_order.exchange_order_id) + cancel_result = await self._api_request('delete', path_url=path_url) return order_id except asyncio.CancelledError: raise - except IOError as ioe: - self.logger().warning(ioe) except BeaxyIOError as e: - if e.response.status == 404: + if e.result and 'Active order not found or already cancelled.' in e.result['items']: # The order was never there to begin with. So cancelling it is a no-op but semantically successful. - self.logger().info(f"The order {order_id} does not exist on Beaxy. No cancellation needed.") self.c_stop_tracking_order(order_id) self.c_trigger_event(self.MARKET_ORDER_CANCELLED_EVENT_TAG, OrderCancelledEvent(self._current_timestamp, order_id)) return order_id + except IOError as ioe: + self.logger().warning(ioe) except Exception as e: self.logger().network( f'Failed to cancel order {order_id}: ', @@ -653,14 +665,9 @@ cdef class BeaxyExchange(ExchangeBase): async with timeout(timeout_seconds): results = await safe_gather(*tasks, return_exceptions=True) for client_order_id in results: - if type(client_order_id) is str: + if client_order_id: order_id_set.remove(client_order_id) successful_cancellations.append(CancellationResult(client_order_id, True)) - else: - self.logger().warning( - f'failed to cancel order with error: ' - f'{repr(client_order_id)}' - ) except Exception as e: self.logger().network( f'Unexpected error cancelling orders.', @@ -680,10 +687,12 @@ cdef class BeaxyExchange(ExchangeBase): return try: - res = await self._api_request('get', BeaxyConstants.TradingApi.SECURITIES_ENDPOINT) - first_security = res[0] - self._maker_fee_percentage = Decimal(first_security['buyer_maker_commission_progressive']) - self._taker_fee_percentage = Decimal(first_security['buyer_taker_commission_progressive']) + res = await self._api_request('get', BeaxyConstants.TradingApi.TRADE_SETTINGS_ENDPOINT) + for symbol_data in res['symbols']: + symbol = self.convert_from_exchange_trading_pair(symbol_data['name']) + self._maker_fee_percentage[symbol] = Decimal(symbol_data['maker_fee']) + self._taker_fee_percentage[symbol] = Decimal(symbol_data['taker_fee']) + self._last_fee_percentage_update_timestamp = current_timestamp except asyncio.CancelledError: self.logger().warning('Got cancelled error fetching beaxy fees.') @@ -703,12 +712,12 @@ cdef class BeaxyExchange(ExchangeBase): set remote_asset_names = set() set asset_names_to_remove - account_balances = await self._api_request('get', path_url=BeaxyConstants.TradingApi.ACOUNTS_ENDPOINT) + account_balances = await self._api_request('get', path_url=BeaxyConstants.TradingApi.WALLETS_ENDPOINT) for balance_entry in account_balances: - asset_name = balance_entry['currency_id'] - available_balance = Decimal(balance_entry['available_for_trading']) - total_balance = Decimal(balance_entry['balance']) + asset_name = balance_entry['currency'] + available_balance = Decimal(balance_entry['available_balance']) + total_balance = Decimal(balance_entry['total_balance']) self._account_available_balances[asset_name] = available_balance self._account_balances[asset_name] = total_balance remote_asset_names.add(asset_name) @@ -909,6 +918,7 @@ cdef class BeaxyExchange(ExchangeBase): assert path_url is not None or url is not None url = f'{BeaxyConstants.TradingApi.BASE_URL}{path_url}' if url is None else url + data_str = "" if data is None else json.dumps(data, separators=(',', ':')) if is_auth_required: @@ -922,24 +932,35 @@ cdef class BeaxyExchange(ExchangeBase): if http_method.upper() == 'POST': headers['Content-Type'] = 'application/json; charset=utf-8' + if path_url == BeaxyConstants.TradingApi.TRADE_SETTINGS_ENDPOINT: + auth_token = await self._beaxy_auth.get_token() + headers['Authorization'] = f'Bearer {auth_token}' + self.logger().debug(f'Submitting {http_method} request to {url} with headers {headers}') client = await self._http_client() async with client.request(http_method.upper(), url=url, timeout=self.API_CALL_TIMEOUT, data=data_str, headers=headers) as response: result = None - if response.status != 200: - raise BeaxyIOError( - f'Error during api request with body {data_str}. HTTP status is {response.status}. Response - {await response.text()} - Request {response.request_info}', - response=response, - ) try: result = await response.json() except ContentTypeError: pass + if response.status not in [200, 204]: + + if response.status == 401: + self._beaxy_auth.invalidate_token() + + raise BeaxyIOError( + f'Error during api request with body {data_str}. HTTP status is {response.status}. Response - {await response.text()} - Request {response.request_info}', + response=response, + result=result, + ) self.logger().debug(f'Got response status {response.status}') self.logger().debug(f'Got response {result}') return result + except BeaxyIOError: + raise except Exception: self.logger().warning(f'Exception while making api request.', exc_info=True) raise @@ -953,11 +974,13 @@ cdef class BeaxyExchange(ExchangeBase): self._poll_notifier = asyncio.Event() await self._poll_notifier.wait() - await safe_gather(self._update_balances()) - await asyncio.sleep(60) - await safe_gather(self._update_trade_fees()) - await asyncio.sleep(60) - await safe_gather(self._update_order_status()) + + await safe_gather( + self._update_balances(), + self._update_trade_fees(), + self._update_order_status(), + ) + except asyncio.CancelledError: raise except Exception: diff --git a/hummingbot/connector/exchange/beaxy/beaxy_in_flight_order.pyx b/hummingbot/connector/exchange/beaxy/beaxy_in_flight_order.pyx index 79b1340a78..b682201de0 100644 --- a/hummingbot/connector/exchange/beaxy/beaxy_in_flight_order.pyx +++ b/hummingbot/connector/exchange/beaxy/beaxy_in_flight_order.pyx @@ -32,7 +32,7 @@ cdef class BeaxyInFlightOrder(InFlightOrderBase): @property def is_done(self) -> bool: - return self.last_state in {'completely_filled', 'cancelled', 'rejected', 'replaced', 'expired', 'pending_cancel', 'suspended', 'pending_replace'} + return self.last_state in {'closed', 'completely_filled', 'cancelled', 'rejected', 'replaced', 'expired', 'pending_cancel', 'suspended', 'pending_replace'} @property def is_failure(self) -> bool: diff --git a/hummingbot/connector/exchange/beaxy/beaxy_misc.py b/hummingbot/connector/exchange/beaxy/beaxy_misc.py index 7173851942..fa97435385 100644 --- a/hummingbot/connector/exchange/beaxy/beaxy_misc.py +++ b/hummingbot/connector/exchange/beaxy/beaxy_misc.py @@ -54,6 +54,7 @@ def trading_pair_to_symbol(trading_pair: str) -> str: class BeaxyIOError(IOError): - def __init__(self, msg, response, *args, **kwargs): + def __init__(self, msg, response, result, *args, **kwargs): self.response = response + self.result = result super(BeaxyIOError, self).__init__(msg, *args, **kwargs) diff --git a/hummingbot/connector/exchange/beaxy/beaxy_order_book_message.py b/hummingbot/connector/exchange/beaxy/beaxy_order_book_message.py index 9a3eaef1cc..c24887711e 100644 --- a/hummingbot/connector/exchange/beaxy/beaxy_order_book_message.py +++ b/hummingbot/connector/exchange/beaxy/beaxy_order_book_message.py @@ -38,21 +38,21 @@ def trade_id(self) -> int: def trading_pair(self) -> str: return symbol_to_trading_pair(str(self.content.get('security'))) - @property - def asks(self) -> List[OrderBookRow]: + def _entries(self, side): return [ OrderBookRow(entry['price'], entry['quantity'], self.update_id) + if entry['action'] != 'DELETE' else OrderBookRow(entry['price'], 0, self.update_id) for entry in self.content.get('entries', []) - if entry['side'] == 'ASK' and entry['action'] == 'INSERT' + if entry['side'] == side ] + @property + def asks(self) -> List[OrderBookRow]: + return self._entries('ASK') + @property def bids(self) -> List[OrderBookRow]: - return [ - OrderBookRow(entry['price'], entry['quantity'], self.update_id) - for entry in self.content.get('entries', []) - if entry['side'] == 'BID' and entry['action'] == 'INSERT' - ] + return self._entries('BID') @property def has_update_id(self) -> bool: From 9801e416eb8b1c1da52114ccb825bef964604c14 Mon Sep 17 00:00:00 2001 From: vic-en Date: Wed, 17 Feb 2021 15:39:41 +0100 Subject: [PATCH 031/131] (feat) add FundingPaymentCompletedEvent --- hummingbot/connector/markets_recorder.py | 30 ++++++++- hummingbot/core/event/events.py | 8 +++ hummingbot/model/funding_payment.py | 85 ++++++++++++++++++++++++ 3 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 hummingbot/model/funding_payment.py diff --git a/hummingbot/connector/markets_recorder.py b/hummingbot/connector/markets_recorder.py index 1b35fe333b..63fb3a1c93 100644 --- a/hummingbot/connector/markets_recorder.py +++ b/hummingbot/connector/markets_recorder.py @@ -27,6 +27,7 @@ MarketOrderFailureEvent, OrderCancelledEvent, OrderExpiredEvent, + FundingPaymentCompletedEvent, MarketEvent, TradeFee ) @@ -38,6 +39,7 @@ from hummingbot.model.order_status import OrderStatus from hummingbot.model.sql_connection_manager import SQLConnectionManager from hummingbot.model.trade_fill import TradeFill +from hummingbot.model.funding_payment import FundingPayment class MarketsRecorder: @@ -75,6 +77,7 @@ def __init__(self, self._fail_order_forwarder: SourceInfoEventForwarder = SourceInfoEventForwarder(self._did_fail_order) self._complete_order_forwarder: SourceInfoEventForwarder = SourceInfoEventForwarder(self._did_complete_order) self._expire_order_forwarder: SourceInfoEventForwarder = SourceInfoEventForwarder(self._did_expire_order) + self._funding_payment_forwarder: SourceInfoEventForwarder = SourceInfoEventForwarder(self._did_complete_funding_payment) self._event_pairs: List[Tuple[MarketEvent, SourceInfoEventForwarder]] = [ (MarketEvent.BuyOrderCreated, self._create_order_forwarder), @@ -84,7 +87,8 @@ def __init__(self, (MarketEvent.OrderFailure, self._fail_order_forwarder), (MarketEvent.BuyOrderCompleted, self._complete_order_forwarder), (MarketEvent.SellOrderCompleted, self._complete_order_forwarder), - (MarketEvent.OrderExpired, self._expire_order_forwarder) + (MarketEvent.OrderExpired, self._expire_order_forwarder), + (MarketEvent.FundingPaymentCompleted, self._funding_payment_forwarder) ] @property @@ -263,6 +267,30 @@ def _did_fill_order(self, market.add_trade_fills_from_market_recorder({TradeFillOrderDetails(trade_fill_record.market, trade_fill_record.exchange_trade_id, trade_fill_record.symbol)}) self.append_to_csv(trade_fill_record) + def _did_complete_funding_payment(self, + event_tag: int, + market: ConnectorBase, + evt: FundingPaymentCompletedEvent): + if threading.current_thread() != threading.main_thread(): + self._ev_loop.call_soon_threadsafe(self._did_complete_funding_payment, event_tag, market, evt) + return + + session: Session = self.session + timestamp: float = evt.timestamp + + # Try to find the funding payment has been recorded already. + payment_record: Optional[FundingPayment] = session.query(FundingPayment).filter(FundingPayment.timestamp == timestamp).one_or_none() + if payment_record is None: + funding_payment_record: FundingPayment = FundingPayment(timestamp=timestamp, + config_file_path=self.config_file_path, + market=market.display_name, + rate=evt.funding_rate, + symbol=evt.trading_pair, + amount=float(evt.amount)) + session.add(funding_payment_record) + session.commit() + # self.append_to_csv(funding_payment_record) + @staticmethod def _is_primitive_type(obj: object) -> bool: return not hasattr(obj, '__dict__') diff --git a/hummingbot/core/event/events.py b/hummingbot/core/event/events.py index 77a41a9689..a085e2ba32 100644 --- a/hummingbot/core/event/events.py +++ b/hummingbot/core/event/events.py @@ -203,6 +203,14 @@ class OrderExpiredEvent(NamedTuple): order_id: str +@dataclass +class FundingPaymentCompletedEvent: + timestamp: float + trading_pair: str + amount: Decimal + funding_rate: Decimal + + class MarketWithdrawAssetEvent(NamedTuple): timestamp: float tracking_id: str diff --git a/hummingbot/model/funding_payment.py b/hummingbot/model/funding_payment.py new file mode 100644 index 0000000000..99faa771a6 --- /dev/null +++ b/hummingbot/model/funding_payment.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python +import pandas as pd +from typing import ( + List, + Optional, +) +from sqlalchemy import ( + Column, + Text, + Index, + BigInteger, + Float, +) +from sqlalchemy.orm import ( + Session +) +from datetime import datetime + +from . import HummingbotBase + + +class FundingPayment(HummingbotBase): + __tablename__ = "FundingPayment" + __table_args__ = (Index("tf_config_timestamp_index", + "config_file_path", "timestamp"), + Index("tf_market_trading_pair_timestamp_index", + "market", "symbol", "timestamp") + ) + + timestamp = Column(BigInteger, primary_key=True, nullable=False) + config_file_path = Column(Text, nullable=False) + market = Column(Text, nullable=False) + rate = Column(Float, nullable=False) + symbol = Column(Text, nullable=False) + amount = Column(Float, nullable=False) + + def __repr__(self) -> str: + return f"FundingPayment(timestamp={self.timestamp}, config_file_path='{self.config_file_path}', " \ + f"market='{self.market}', rate='{self.rate}' symbol='{self.symbol}', amount={self.amount}" + + @staticmethod + def get_funding_payments(sql_session: Session, + timestamp: str = None, + market: str = None, + trading_pair: str = None, + ) -> Optional[List["FundingPayment"]]: + filters = [] + if timestamp is not None: + filters.append(FundingPayment.timestamp == timestamp) + if market is not None: + filters.append(FundingPayment.market == market) + if trading_pair is not None: + filters.append(FundingPayment.symbol == trading_pair) + + payments: Optional[List[FundingPayment]] = (sql_session + .query(FundingPayment) + .filter(*filters) + .order_by(FundingPayment.timestamp.asc()) + .all()) + return payments + + @classmethod + def to_pandas(cls, payments: List): + columns: List[str] = ["Index", + "Timestamp", + "Exchange", + "Market", + "Rate", + "Amount"] + data = [] + index = 0 + for payment in payments: + index += 1 + data.append([ + index, + datetime.fromtimestamp(int(payment.timestamp / 1e3)).strftime("%Y-%m-%d %H:%M:%S"), + payment.market, + payment.rate, + payment.symbol, + payment.amount + ]) + df = pd.DataFrame(data=data, columns=columns) + df.set_index('Index', inplace=True) + + return df From 255f0f76718506dc9f60ac9098bcabe463500dd9 Mon Sep 17 00:00:00 2001 From: Nullably <37262506+Nullably@users.noreply.github.com> Date: Wed, 17 Feb 2021 23:15:31 +0800 Subject: [PATCH 032/131] (feat) add inventory_skew_enabled param (on by default) --- hummingbot/strategy/liquidity_mining/liquidity_mining.py | 5 ++++- .../liquidity_mining/liquidity_mining_config_map.py | 9 ++++++++- hummingbot/strategy/liquidity_mining/start.py | 2 ++ .../conf_liquidity_mining_strategy_TEMPLATE.yml | 5 ++++- 4 files changed, 18 insertions(+), 3 deletions(-) diff --git a/hummingbot/strategy/liquidity_mining/liquidity_mining.py b/hummingbot/strategy/liquidity_mining/liquidity_mining.py index ca7b0c6b48..8045de06b2 100644 --- a/hummingbot/strategy/liquidity_mining/liquidity_mining.py +++ b/hummingbot/strategy/liquidity_mining/liquidity_mining.py @@ -39,6 +39,7 @@ def __init__(self, token: str, order_amount: Decimal, spread: Decimal, + inventory_skew_enabled: bool, target_base_pct: Decimal, order_refresh_time: float, order_refresh_tolerance_pct: Decimal, @@ -56,6 +57,7 @@ def __init__(self, self._spread = spread self._order_refresh_time = order_refresh_time self._order_refresh_tolerance_pct = order_refresh_tolerance_pct + self._inventory_skew_enabled = inventory_skew_enabled self._target_base_pct = target_base_pct self._inventory_range_multiplier = inventory_range_multiplier self._volatility_interval = volatility_interval @@ -100,7 +102,8 @@ def tick(self, timestamp: float): self.update_volatility() proposals = self.create_base_proposals() self._token_balances = self.adjusted_available_balances() - self.apply_inventory_skew(proposals) + if self._inventory_skew_enabled: + self.apply_inventory_skew(proposals) self.apply_budget_constraint(proposals) self.cancel_active_orders(proposals) self.execute_orders_proposal(proposals) diff --git a/hummingbot/strategy/liquidity_mining/liquidity_mining_config_map.py b/hummingbot/strategy/liquidity_mining/liquidity_mining_config_map.py index 891733234f..ece5a141de 100644 --- a/hummingbot/strategy/liquidity_mining/liquidity_mining_config_map.py +++ b/hummingbot/strategy/liquidity_mining/liquidity_mining_config_map.py @@ -4,7 +4,8 @@ from hummingbot.client.config.config_validators import ( validate_exchange, validate_decimal, - validate_int + validate_int, + validate_bool ) from hummingbot.client.settings import ( required_exchanges, @@ -65,6 +66,12 @@ def order_size_prompt() -> str: type_str="decimal", validator=lambda v: validate_decimal(v, 0, 100, inclusive=False), prompt_on_new=True), + "inventory_skew_enabled": + ConfigVar(key="inventory_skew_enabled", + prompt="Would you like to enable inventory skew? (Yes/No) >>> ", + type_str="bool", + default=True, + validator=validate_bool), "target_base_pct": ConfigVar(key="target_base_pct", prompt="For each pair, what is your target base asset percentage? (Enter 20 to indicate 20%) >>> ", diff --git a/hummingbot/strategy/liquidity_mining/start.py b/hummingbot/strategy/liquidity_mining/start.py index d99bb75720..03c7445276 100644 --- a/hummingbot/strategy/liquidity_mining/start.py +++ b/hummingbot/strategy/liquidity_mining/start.py @@ -14,6 +14,7 @@ def start(self): markets = quote_markets if quote_markets else base_markets order_amount = c_map.get("order_amount").value spread = c_map.get("spread").value / Decimal("100") + inventory_skew_enabled = c_map.get("inventory_skew_enabled").value target_base_pct = c_map.get("target_base_pct").value / Decimal("100") order_refresh_time = c_map.get("order_refresh_time").value order_refresh_tolerance_pct = c_map.get("order_refresh_tolerance_pct").value / Decimal("100") @@ -34,6 +35,7 @@ def start(self): token=token, order_amount=order_amount, spread=spread, + inventory_skew_enabled=inventory_skew_enabled, target_base_pct=target_base_pct, order_refresh_time=order_refresh_time, order_refresh_tolerance_pct=order_refresh_tolerance_pct, diff --git a/hummingbot/templates/conf_liquidity_mining_strategy_TEMPLATE.yml b/hummingbot/templates/conf_liquidity_mining_strategy_TEMPLATE.yml index cc841e7d4b..7daab8c8d4 100644 --- a/hummingbot/templates/conf_liquidity_mining_strategy_TEMPLATE.yml +++ b/hummingbot/templates/conf_liquidity_mining_strategy_TEMPLATE.yml @@ -2,7 +2,7 @@ ### Liquidity Mining strategy config ### ######################################################## -template_version: 1 +template_version: 2 strategy: null # The exchange to run this strategy. @@ -21,6 +21,9 @@ order_amount: null # The spread from mid price to place bid and ask orders, enter 1 to indicate 1% spread: null +# Whether to enable Inventory skew feature (true/false). +inventory_skew_enabled: null + # The target base asset percentage for all markets, enter 50 to indicate 50% target target_base_pct: null From e6dc3822030de806118a1705c5843b283f6365b4 Mon Sep 17 00:00:00 2001 From: Daniel Tan Date: Thu, 18 Feb 2021 01:20:11 +0800 Subject: [PATCH 033/131] (add) ProbitExchange[pending initial dev testing] --- .../exchange/probit/probit_exchange.py | 204 +++++++++++------- .../exchange/probit/probit_in_flight_order.py | 4 +- .../connector/exchange/probit/probit_utils.py | 11 + 3 files changed, 144 insertions(+), 75 deletions(-) diff --git a/hummingbot/connector/exchange/probit/probit_exchange.py b/hummingbot/connector/exchange/probit/probit_exchange.py index eeeb3430f5..bf7fc2c977 100644 --- a/hummingbot/connector/exchange/probit/probit_exchange.py +++ b/hummingbot/connector/exchange/probit/probit_exchange.py @@ -603,7 +603,6 @@ async def _update_order_status(self): current_tick = int(self.current_timestamp / self.UPDATE_ORDER_STATUS_MIN_INTERVAL) if current_tick > last_tick and len(self._in_flight_orders) > 0: - # TODO: Refactor _update_order_status tracked_orders = list(self._in_flight_orders.values()) tasks = [] @@ -621,28 +620,66 @@ async def _update_order_status(self): is_auth_required=True) ) self.logger().debug(f"Polling for order status updates of {len(tasks)} orders.") - update_results = await safe_gather(*tasks, return_exceptions=True) - for update_result in update_results: + order_results: List[Dict[str, Any]] = await safe_gather(*tasks, return_exceptions=True) + + # Retrieve start_time and end_time of the earliest and last order. + # Retrieves all trades between this order creations. + min_order_ts: str = "" + + min_ts: float = float("inf") + for order_update in order_results: + order_ts: float = probit_utils.convert_iso_to_epoch(order_update["data"]["time"]) + + if order_ts < min_ts: + min_order_ts = order_update["data"]["time"] + min_ts = order_ts + + trade_history_tasks = [] + for trading_pair in self._trading_pairs: + query_params = { + "start_time": min_order_ts, + "end_time": probit_utils.get_iso_time_now(), + "limit": 1000, + "market_id": trading_pair + } + trade_history_tasks.append(self._api_request( + method="GET", + path_url=CONSTANTS.TRADE_HISTORY_URL + )) + trade_history_results: List[Dict[str, Any]] = await safe_gather(*trade_history_tasks, return_exceptions=True) + + for t_pair_history in trade_history_results: + if isinstance(t_pair_history, Exception): + raise t_pair_history + if "data" not in t_pair_history: + self.logger().info(f"Unexpected response from GET /trade_history. 'data' field not in resp: {t_pair_history}") + continue + + trade_details: List[Dict[str, Any]] = t_pair_history["data"] + for trade in trade_details: + self._process_trade_message(trade) + + for update_result in order_results: if isinstance(update_result, Exception): raise update_result if "data" not in update_result: self.logger().info(f"_update_order_status data not in resp: {update_result}") continue - # TODO: Determine best way to determine that order has been partially/fully executed - for trade_msg in update_result["result"]["trade_list"]: - await self._process_trade_message(trade_msg) - self._process_order_message(update_result["result"]["order_info"]) + order_details: List[Dict[str, Any]] = update_result["data"] + for order in order_details: + self._process_order_message(order_details) def _process_order_message(self, order_msg: Dict[str, Any]): """ - Updates in-flight order and triggers cancellation or failure event if needed. + Updates in-flight order and triggers trade, 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) """ - client_order_id = order_msg["client_oid"] + client_order_id = order_msg["client_order_id"] if client_order_id not in self._in_flight_orders: return tracked_order = self._in_flight_orders[client_order_id] + # Update order execution status tracked_order.last_state = order_msg["status"] if tracked_order.is_cancelled: @@ -655,7 +692,7 @@ def _process_order_message(self, order_msg: Dict[str, Any]): self.stop_tracking_order(client_order_id) elif tracked_order.is_failure: self.logger().info(f"The market order {client_order_id} has failed according to order status API. " - f"Reason: {probit_utils.get_api_reason(order_msg['reason'])}") + f"Order Message: {order_msg}") self.trigger_event(MarketEvent.OrderFailure, MarketOrderFailureEvent( self.current_timestamp, @@ -664,20 +701,27 @@ def _process_order_message(self, order_msg: Dict[str, Any]): )) self.stop_tracking_order(client_order_id) - async def _process_trade_message(self, trade_msg: Dict[str, Any]): + def _process_trade_message(self, order_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. """ - for order in self._in_flight_orders.values(): - await order.get_exchange_order_id() - track_order = [o for o in self._in_flight_orders.values() if trade_msg["order_id"] == o.exchange_order_id] - if not track_order: + ex_order_id = order_msg["order_id"] + + client_order_id = None + for track_order in self.in_flight_orders.values(): + if track_order.exchange_order_id == ex_order_id: + client_order_id = track_order.client_order_id + break + + if client_order_id is None: return - tracked_order = track_order[0] - updated = tracked_order.update_with_trade_update(trade_msg) + + tracked_order = self.in_flight_orders[client_order_id] + updated = tracked_order.update_with_trade_update(order_msg) if not updated: return + self.trigger_event( MarketEvent.OrderFilled, OrderFilledEvent( @@ -686,15 +730,15 @@ async def _process_trade_message(self, trade_msg: Dict[str, Any]): tracked_order.trading_pair, tracked_order.trade_type, tracked_order.order_type, - Decimal(str(trade_msg["traded_price"])), - Decimal(str(trade_msg["traded_quantity"])), - TradeFee(0.0, [(trade_msg["fee_currency"], Decimal(str(trade_msg["fee"])))]), - exchange_trade_id=trade_msg["order_id"] + Decimal(str(order_msg["price"])), + Decimal(str(order_msg["quantity"])), + TradeFee(0.0, [(order_msg["fee_currency_id"], Decimal(str(order_msg["fee_amount"])))]), + exchange_trade_id=order_msg["id"] ) ) if math.isclose(tracked_order.executed_amount_base, tracked_order.amount) or \ tracked_order.executed_amount_base >= tracked_order.amount: - tracked_order.last_state = "FILLED" + 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.") @@ -714,6 +758,41 @@ async def _process_trade_message(self, trade_msg: Dict[str, Any]): tracked_order.order_type)) self.stop_tracking_order(tracked_order.client_order_id) + async def get_open_orders(self) -> List[OpenOrder]: + ret_val = [] + for trading_pair in self._trading_pairs: + query_params = { + "market_id": trading_pair + } + result = await self._api_request( + method="GET", + path_url=CONSTANTS.OPEN_ORDER_URL, + params=query_params, + is_auth_required=True + ) + if "data" not in result: + self.logger().info(f"Unexpected response from GET {CONSTANTS.OPEN_ORDER_URL}. " + f"Params: {query_params} " + f"Response: {result} ") + for order in result["data"]["order_list"]: + if order["type"] != "limit": + raise Exception(f"Unsupported order type {order['type']}") + ret_val.append( + OpenOrder( + client_order_id=order["client_order_id"], + trading_pair=order["market_id"], + price=Decimal(str(order["limit_price"])), + amount=Decimal(str(order["quantity"])), + executed_amount=Decimal(str(order["quantity"])) - Decimal(str(order["filled_quantity"])), + status=order["status"], + order_type=OrderType.LIMIT, + is_buy=True if order["side"].lower() == "buy" else False, + time=int(probit_utils.convert_iso_to_epoch(order["time"])), + exchange_order_id=order["id"] + ) + ) + return ret_val + async def cancel_all(self, timeout_seconds: float): """ Cancels all in-flight orders and waits for cancellation results. @@ -725,13 +804,23 @@ async def cancel_all(self, timeout_seconds: float): raise Exception("cancel_all can only be used when trading_pairs are specified.") cancellation_results = [] try: - for trading_pair in self._trading_pairs: - await self._api_request( - "post", - "private/cancel-all-orders", - {"instrument_name": probit_utils.convert_to_exchange_trading_pair(trading_pair)}, - True - ) + + # ProBit does not have cancel_all_order endpoint + tasks = [] + for tracked_order in self.in_flight_orders.values(): + body_params = { + "market_id": tracked_order.trading_pair, + "order_id": tracked_order.exchange_order_id + } + tasks.append(self._api_request( + method="POST", + path_url=CONSTANTS.CANCEL_ORDER_URL, + data=body_params, + is_auth_required=True + )) + + await safe_gather(*tasks) + open_orders = await self.get_open_orders() for cl_order_id, tracked_order in self._in_flight_orders.items(): open_order = [o for o in open_orders if o.client_order_id == cl_order_id] @@ -801,52 +890,23 @@ async def _user_stream_event_listener(self): """ async for event_message in self._iter_user_event_queue(): try: - if "result" not in event_message or "channel" not in event_message["result"]: + if "channel" not in event_message or event_message["channel"] not in ["open_order", "order_history", "balance", "trade_history"]: continue - channel = event_message["result"]["channel"] - if "user.trade" in channel: - for trade_msg in event_message["result"]["data"]: - await self._process_trade_message(trade_msg) - elif "user.order" in channel: - for order_msg in event_message["result"]["data"]: - self._process_order_message(order_msg) - elif channel == "user.balance": - balances = event_message["result"]["data"] - for balance_entry in balances: - asset_name = balance_entry["currency"] - self._account_balances[asset_name] = Decimal(str(balance_entry["balance"])) - self._account_available_balances[asset_name] = Decimal(str(balance_entry["available"])) + channel = event_message["channel"] + + if channel == "balance": + for asset, balance_details in event_message["data"].items(): + self._account_balances[asset] = Decimal(str(balance_details["total"])) + self._account_available_balances[asset] = Decimal(str(balance_details["available"])) + elif channel in ["open_order", "order_history"]: + for order_update in event_message["data"]: + self._process_order_message(order_update) + elif channel == "trade_history": + for trade_update in event_message["data"]: + self._process_trade_message(trade_update) + except asyncio.CancelledError: raise except Exception: self.logger().error("Unexpected error in user stream listener loop.", exc_info=True) await asyncio.sleep(5.0) - - async def get_open_orders(self) -> List[OpenOrder]: - result = await self._api_request( - "post", - "private/get-open-orders", - {}, - True - ) - ret_val = [] - for order in result["result"]["order_list"]: - if probit_utils.HBOT_BROKER_ID not in order["client_oid"]: - continue - if order["type"] != "LIMIT": - raise Exception(f"Unsupported order type {order['type']}") - ret_val.append( - OpenOrder( - client_order_id=order["client_oid"], - trading_pair=probit_utils.convert_from_exchange_trading_pair(order["instrument_name"]), - price=Decimal(str(order["price"])), - amount=Decimal(str(order["quantity"])), - executed_amount=Decimal(str(order["cumulative_quantity"])), - status=order["status"], - order_type=OrderType.LIMIT, - is_buy=True if order["side"].lower() == "buy" else False, - time=int(order["create_time"]), - exchange_order_id=order["order_id"] - ) - ) - return ret_val diff --git a/hummingbot/connector/exchange/probit/probit_in_flight_order.py b/hummingbot/connector/exchange/probit/probit_in_flight_order.py index 4381ee8549..fbfddc36fc 100644 --- a/hummingbot/connector/exchange/probit/probit_in_flight_order.py +++ b/hummingbot/connector/exchange/probit/probit_in_flight_order.py @@ -77,13 +77,11 @@ def from_json(cls, data: Dict[str, Any]) -> InFlightOrderBase: def update_with_trade_update(self, trade_update: Dict[str, Any]) -> bool: """ - Updates the in flight order with trade update (from private/get-order-detail end point) + Updates the in flight order with trade update (from GET /trade_history end point) return: True if the order gets updated otherwise False """ trade_id = trade_update["id"] - # trade_update["orderId"] is type int if str(trade_update["order_id"]) != self.exchange_order_id or trade_id in self.trade_id_set: - # trade already recorded return False self.trade_id_set.add(trade_id) self.executed_amount_base += Decimal(str(trade_update["quantity"])) diff --git a/hummingbot/connector/exchange/probit/probit_utils.py b/hummingbot/connector/exchange/probit/probit_utils.py index 03a75b1b52..462a162f93 100644 --- a/hummingbot/connector/exchange/probit/probit_utils.py +++ b/hummingbot/connector/exchange/probit/probit_utils.py @@ -1,5 +1,8 @@ #!/usr/bin/env python +import dateutil.parser as dp + +from datetime import datetime from typing import ( Any, Dict, @@ -26,6 +29,14 @@ def get_new_client_order_id(is_buy: bool, trading_pair: str) -> str: return f"{side}-{trading_pair}-{get_tracking_nonce()}" +def convert_iso_to_epoch(ts: str) -> float: + return dp.parse(ts).timestamp() + + +def get_iso_time_now() -> str: + return datetime.utcnow().isoformat()[:-3] + 'Z' + + def convert_snapshot_message_to_order_book_row(message: OrderBookMessage) -> Tuple[List[OrderBookRow], List[OrderBookRow]]: update_id = message.update_id data = [] From 012bb8ca52ac079479377b7acf4c088cbf27bdda Mon Sep 17 00:00:00 2001 From: vic-en Date: Wed, 17 Feb 2021 18:49:30 +0100 Subject: [PATCH 034/131] (feat) add funding event listener in derivative connectors --- .../binance_perpetual_derivative.py | 19 +++--- .../perpetual_finance_derivative.py | 60 +++++++++++++++---- hummingbot/core/event/events.py | 1 + hummingbot/strategy/strategy_base.pxd | 1 + hummingbot/strategy/strategy_base.pyx | 12 ++++ hummingbot/strategy/strategy_py_base.pyx | 6 ++ 6 files changed, 77 insertions(+), 22 deletions(-) diff --git a/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_derivative.py b/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_derivative.py index 6e285a8d99..ecb6d777a3 100644 --- a/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_derivative.py +++ b/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_derivative.py @@ -248,7 +248,7 @@ async def create_order(self, 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, self._leverage, position_action.name) + self.start_tracking_order(order_id, "", trading_pair, trade_type, price, amount, order_type, self._leverage[trading_pair], position_action.name) try: order_result = await self.request(path="/fapi/v1/order", @@ -273,7 +273,7 @@ async def create_order(self, amount, price, order_id, - leverage=self._leverage, + leverage=self._leverage[trading_pair], position=position_action.name)) return order_result except asyncio.CancelledError: @@ -495,7 +495,7 @@ async def _user_stream_event_listener(self): 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")), - leverage=self._leverage, + leverage=self._leverage[convert_from_exchange_trading_pair(order_message.get("s"))], trade_fee=self.get_fee( base_currency=tracked_order.base_asset, quote_currency=tracked_order.quote_asset, @@ -786,7 +786,7 @@ async def _update_order_fills_from_trades(self): Decimal(trade["price"]), Decimal(trade["qty"])), exchange_trade_id=trade["id"], - leverage=self._leverage, + leverage=self._leverage[tracked_order.trading_pair], position=tracked_order.position ) ) @@ -885,8 +885,8 @@ async def _set_leverage(self, trading_pair: str, leverage: int = 1): is_signed=True ) if set_leverage["leverage"] == leverage: - self._leverage = leverage - self.logger().info(f"Leverage Successfully set to {leverage}.") + self._leverage[trading_pair] = leverage + self.logger().info(f"Leverage Successfully set to {leverage} for {trading_pair}.") else: self.logger().error("Unable to set leverage.") return leverage @@ -894,13 +894,12 @@ async def _set_leverage(self, trading_pair: str, leverage: int = 1): def set_leverage(self, trading_pair: str, leverage: int = 1): safe_ensure_future(self._set_leverage(trading_pair, leverage)) - async def _get_funding_info(self, trading_pair): + """async def _get_funding_info(self): prem_index = await self.request("/fapi/v1/premiumIndex", params={"symbol": convert_to_exchange_trading_pair(trading_pair)}) - self._funding_info = Decimal(prem_index.get("lastFundingRate", "0")) + self._funding_info = Decimal(prem_index.get("lastFundingRate", "0"))""" def get_funding_info(self, trading_pair): - safe_ensure_future(self._get_funding_info(trading_pair)) - return self._funding_info + return self._funding_info[trading_pair] async def _set_position_mode(self, position_mode: PositionMode): initial_mode = await self._get_position_mode() diff --git a/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py index 7e86a486d5..e8b8716aac 100644 --- a/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py +++ b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py @@ -23,6 +23,7 @@ BuyOrderCompletedEvent, SellOrderCompletedEvent, MarketOrderFailureEvent, + FundingPaymentCompletedEvent, OrderFilledEvent, OrderType, TradeType, @@ -274,12 +275,12 @@ async def _create_order(self, api_params = {"pair": convert_to_exchange_trading_pair(trading_pair)} if position_action == PositionAction.OPEN: api_params.update({"side": 0 if trade_type == TradeType.BUY else 1, - "margin": str(amount / self._leverage), - "leverage": self._leverage, + "margin": str(amount / self._leverage[trading_pair]), + "leverage": self._leverage[trading_pair], "minBaseAssetAmount": amount}) else: api_params.update({"minimalQuoteAsset": price * amount}) - self.start_tracking_order(order_id, None, trading_pair, trade_type, price, amount, self._leverage, position_action.name) + self.start_tracking_order(order_id, None, trading_pair, trade_type, price, amount, self._leverage[trading_pair], position_action.name) try: order_result = await self._api_request("post", f"perpfi/{position_action.name.lower()}", api_params) hash = order_result.get("txHash") @@ -295,7 +296,8 @@ async def _create_order(self, event_tag = MarketEvent.BuyOrderCreated if trade_type is TradeType.BUY else MarketEvent.SellOrderCreated event_class = BuyOrderCreatedEvent if trade_type is TradeType.BUY else SellOrderCreatedEvent self.trigger_event(event_tag, event_class(self.current_timestamp, OrderType.LIMIT, trading_pair, amount, - price, order_id, hash)) + price, order_id, hash, leverage=self._leverage[trading_pair], + position=position_action.name)) else: self.trigger_event(MarketEvent.OrderFailure, MarketOrderFailureEvent(self.current_timestamp, order_id, OrderType.LIMIT)) @@ -380,7 +382,9 @@ async def _update_order_status(self): Decimal(str(tracked_order.price)), Decimal(str(tracked_order.amount)), fee, - exchange_trade_id=order_id + exchange_trade_id=order_id, + leverage=self._leverage[tracked_order.trading_pair], + position=tracked_order.position ) ) tracked_order.last_state = "FILLED" @@ -518,18 +522,29 @@ async def _update_balances(self): self._in_flight_orders_snapshot_timestamp = self.current_timestamp async def _update_positions(self): - tasks = [] + position_tasks = [] + funding_payment_tasks = [] + funding_info_tasks = [] for pair in self._trading_pairs: - tasks.append(self._api_request("post", - "perpfi/position", - {"pair": convert_to_exchange_trading_pair(pair)})) - positions = await safe_gather(*tasks, return_exceptions=True) - for trading_pair, position in zip(self._trading_pairs, positions.get("position", {})): + position_tasks.append(self._api_request("post", + "perpfi/position", + {"pair": convert_to_exchange_trading_pair(pair)})) + funding_payment_tasks.append(self._api_request("get", + "perpfi/funding_payment", + {"pair": convert_to_exchange_trading_pair(pair)})) + funding_info_tasks.append(self._api_request("get", + "perpfi/funding", + {"pair": convert_to_exchange_trading_pair(pair)})) + positions = await safe_gather(*position_tasks, return_exceptions=True) + funding_payments = await safe_gather(*funding_payment_tasks, return_exceptions=True) + funding_infos = await safe_gather(*funding_info_tasks, return_exceptions=True) + for trading_pair, position in zip(self._trading_pairs, positions): + position = position.get("position", {}) position_side = PositionSide.LONG if position.get("size", 0) > 0 else PositionSide.SHORT unrealized_pnl = Decimal(position.get("pnl")) entry_price = Decimal(position.get("entryPrice")) amount = Decimal(position.get("size")) - leverage = self._leverage + leverage = self._leverage[trading_pair] if amount != 0: self._account_positions[trading_pair + position_side.name] = Position( trading_pair=trading_pair, @@ -543,6 +558,27 @@ async def _update_positions(self): if (trading_pair + position_side.name) in self._account_positions: del self._account_positions[trading_pair + position_side.name] + for trading_pair, funding_payment in zip(self._trading_pairs, funding_payments): + payment = Decimal(str(funding_payment.payment)) + action = "paid" if payment < 0 else "received" + if payment != Decimal("0"): + self.logger().info(f"Funding payment of {payment} {action} on {trading_pair} market.") + self.trigger_event(MarketEvent.FundingPaymentCompleted, + FundingPaymentCompletedEvent(timestamp=funding_payment.timestamp, + market=self.name, + rate=self._funding_info["rate"], + symbol=trading_pair, + amount=payment)) + + for trading_pair, funding_info in zip(self._trading_pairs, funding_infos): + self._funding_info[trading_pair] = funding_info["fr"] + + def get_funding_info(self, trading_pair): + return self._funding_info[trading_pair] + + def set_leverage(self, trading_pair: str, leverage: int = 1): + self._leverage[trading_pair] = leverage + async def _http_client(self) -> aiohttp.ClientSession: """ :returns Shared client session instance diff --git a/hummingbot/core/event/events.py b/hummingbot/core/event/events.py index a085e2ba32..c4a02d1e76 100644 --- a/hummingbot/core/event/events.py +++ b/hummingbot/core/event/events.py @@ -34,6 +34,7 @@ class MarketEvent(Enum): TransactionFailure = 199 BuyOrderCreated = 200 SellOrderCreated = 201 + FundingPaymentCompleted = 202 class NewBlocksWatcherEvent(Enum): diff --git a/hummingbot/strategy/strategy_base.pxd b/hummingbot/strategy/strategy_base.pxd index 2ed151b74e..efb35d8b09 100644 --- a/hummingbot/strategy/strategy_base.pxd +++ b/hummingbot/strategy/strategy_base.pxd @@ -29,6 +29,7 @@ cdef class StrategyBase(TimeIterator): cdef c_did_expire_order(self, object expired_event) cdef c_did_complete_buy_order(self, object order_completed_event) cdef c_did_complete_sell_order(self, object order_completed_event) + cdef c_did_complete_funding_payment(self, object funding_payment_completed_event) cdef c_did_fail_order_tracker(self, object order_failed_event) cdef c_did_cancel_order_tracker(self, object order_cancelled_event) diff --git a/hummingbot/strategy/strategy_base.pyx b/hummingbot/strategy/strategy_base.pyx index 4bc661693c..d214b288e8 100755 --- a/hummingbot/strategy/strategy_base.pyx +++ b/hummingbot/strategy/strategy_base.pyx @@ -46,6 +46,11 @@ cdef class SellOrderCompletedListener(BaseStrategyEventListener): self._owner.c_did_complete_sell_order_tracker(arg) +cdef class FundingPaymentCompletedListener(BaseStrategyEventListener): + cdef c_call(self, object arg): + self._owner.c_did_complete_funding_payment(arg) + + cdef class OrderFilledListener(BaseStrategyEventListener): cdef c_call(self, object arg): self._owner.c_did_fill_order(arg) @@ -83,6 +88,7 @@ cdef class SellOrderCreatedListener(BaseStrategyEventListener): cdef class StrategyBase(TimeIterator): BUY_ORDER_COMPLETED_EVENT_TAG = MarketEvent.BuyOrderCompleted.value SELL_ORDER_COMPLETED_EVENT_TAG = MarketEvent.SellOrderCompleted.value + FUNDING_PAYMENT_COMPLETED_EVENT_TAG = MarketEvent.FundingPaymentCompleted.value ORDER_FILLED_EVENT_TAG = MarketEvent.OrderFilled.value ORDER_CANCELLED_EVENT_TAG = MarketEvent.OrderCancelled.value ORDER_EXPIRED_EVENT_TAG = MarketEvent.OrderExpired.value @@ -105,6 +111,7 @@ cdef class StrategyBase(TimeIterator): self._sb_expire_order_listener = OrderExpiredListener(self) self._sb_complete_buy_order_listener = BuyOrderCompletedListener(self) self._sb_complete_sell_order_listener = SellOrderCompletedListener(self) + self._sb_complete_funding_payment_listener = FundingPaymentCompletedListener(self) self._sb_delegate_lock = False @@ -255,6 +262,7 @@ cdef class StrategyBase(TimeIterator): typed_market.c_add_listener(self.ORDER_EXPIRED_EVENT_TAG, self._sb_expire_order_listener) typed_market.c_add_listener(self.BUY_ORDER_COMPLETED_EVENT_TAG, self._sb_complete_buy_order_listener) typed_market.c_add_listener(self.SELL_ORDER_COMPLETED_EVENT_TAG, self._sb_complete_sell_order_listener) + typed_market.c_add_listener(self.FUNDING_PAYMENT_COMPLETED_EVENT_TAG, self._sb_complete_funding_payment_listener) self._sb_markets.add(typed_market) cdef c_remove_markets(self, list markets): @@ -273,6 +281,7 @@ cdef class StrategyBase(TimeIterator): typed_market.c_remove_listener(self.ORDER_EXPIRED_EVENT_TAG, self._sb_expire_order_listener) typed_market.c_remove_listener(self.BUY_ORDER_COMPLETED_EVENT_TAG, self._sb_complete_buy_order_listener) typed_market.c_remove_listener(self.SELL_ORDER_COMPLETED_EVENT_TAG, self._sb_complete_sell_order_listener) + typed_market.c_remove_listener(self.FUNDING_PAYMENT_COMPLETED_EVENT_TAG, self._sb_complete_funding_payment_listener) self._sb_markets.remove(typed_market) cdef object c_sum_flat_fees(self, str quote_asset, list flat_fees): @@ -317,6 +326,9 @@ cdef class StrategyBase(TimeIterator): cdef c_did_complete_sell_order(self, object order_completed_event): pass + + cdef c_did_complete_funding_payment(self, object funding_payment_completed_event): + pass # ---------------------------------------------------------------------------------------------------------- # diff --git a/hummingbot/strategy/strategy_py_base.pyx b/hummingbot/strategy/strategy_py_base.pyx index 6d846e5eab..9b8546a8cb 100644 --- a/hummingbot/strategy/strategy_py_base.pyx +++ b/hummingbot/strategy/strategy_py_base.pyx @@ -89,3 +89,9 @@ cdef class StrategyPyBase(StrategyBase): def did_complete_sell_order(self, order_completed_event): pass + + cdef c_did_complete_funding_payment(self, object funding_payment_completed_event): + self.did_complete_funding_payment(funding_payment_completed_event) + + def did_complete_funding_payment(self, funding_payment_completed_event): + pass From bf2302a2bc3bf9528bfd76148261283a08d78e9d Mon Sep 17 00:00:00 2001 From: vic-en Date: Wed, 17 Feb 2021 20:30:24 +0100 Subject: [PATCH 035/131] (feat) update binance perp connector --- .../binance_perpetual_derivative.py | 102 ++++++++++++++---- .../perpetual_finance_derivative.py | 2 +- hummingbot/connector/derivative_base.py | 4 +- 3 files changed, 86 insertions(+), 22 deletions(-) diff --git a/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_derivative.py b/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_derivative.py index ecb6d777a3..f2d4359d35 100644 --- a/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_derivative.py +++ b/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_derivative.py @@ -21,6 +21,9 @@ import hmac import time import logging +import ujson +import websockets +from websockets.exceptions import ConnectionClosed from decimal import Decimal from typing import Optional, List, Dict, Any, AsyncIterable from urllib.parse import urlencode @@ -37,6 +40,7 @@ BuyOrderCompletedEvent, BuyOrderCreatedEvent, SellOrderCreatedEvent, + FundingPaymentCompletedEvent, OrderFilledEvent, SellOrderCompletedEvent, PositionSide, PositionMode, PositionAction) from hummingbot.core.utils.async_utils import safe_ensure_future, safe_gather @@ -86,6 +90,7 @@ class BinancePerpetualDerivative(DerivativeBase): MARKET_ORDER_FILLED_EVENT_TAG = MarketEvent.OrderFilled MARKET_BUY_ORDER_CREATED_EVENT_TAG = MarketEvent.BuyOrderCreated MARKET_SELL_ORDER_CREATED_EVENT_TAG = MarketEvent.SellOrderCreated + MARKET_FUNDING_PAYMENT_COMPLETED_EVENT_TAG = MarketEvent.FundingPaymentCompleted API_CALL_TIMEOUT = 10.0 SHORT_POLL_INTERVAL = 5.0 @@ -129,6 +134,7 @@ def __init__(self, self._status_polling_task = None self._user_stream_event_listener_task = None self._trading_rules_polling_task = None + self._funding_info_polling_task = None self._last_poll_timestamp = 0 self._throttler = Throttler((10.0, 1.0)) self._funding_payment_span = [0, 15] @@ -173,6 +179,7 @@ def stop(self, clock: Clock): async def start_network(self): self._order_book_tracker.start() self._trading_rules_polling_task = safe_ensure_future(self._trading_rules_polling_loop()) + self._funding_info_polling_task = safe_ensure_future(self._funding_info_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()) @@ -188,8 +195,10 @@ def _stop_network(self): self._user_stream_event_listener_task.cancel() if self._trading_rules_polling_task is not None: self._trading_rules_polling_task.cancel() + if self._funding_info_polling_task is not None: + self._funding_info_polling_task.cancel() self._status_polling_task = self._user_stream_tracker_task = \ - self._user_stream_event_listener_task = None + self._user_stream_event_listener_task = self._funding_info_polling_task = None async def stop_network(self): self._stop_network() @@ -548,22 +557,26 @@ async def _user_stream_event_listener(self): 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() + event_reason = update_data.get("m", {}) + if event_reason == "FUNDING_FEE": + await self.get_funding_payment(event_message.get("E", int(time.time()))) + else: + # 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 @@ -674,6 +687,37 @@ async def _trading_rules_polling_loop(self): "Check network connection.") await asyncio.sleep(0.5) + async def _funding_info_polling_loop(self): + while True: + try: + ws_subscription_path: str = "/".join([f"{convert_to_exchange_trading_pair(trading_pair).lower()}@markPrice" + for trading_pair in self._trading_pairs]) + stream_url: str = f"{self._stream_url}?streams={ws_subscription_path}" + async with websockets.connect(stream_url) as ws: + ws: websockets.WebSocketClientProtocol = ws + try: + while True: + try: + raw_msg: str = await asyncio.wait_for(ws.recv(), timeout=10.0) + msg = ujson.loads(raw_msg) + trading_pair = msg["s"] + self._funding_info[trading_pair] = {"indexPrice": msg["i"], + "markPrice": msg["p"], + "nextFundingTime": msg["T"], + "rate": msg["r"]} + except asyncio.TimeoutError: + await ws.pong(data=b'') + except ConnectionClosed: + continue + finally: + await ws.close() + except asyncio.CancelledError: + raise + except Exception: + self.logger().error("Unexpected error updating funding info. Retrying after 10 seconds... ", + exc_info=True) + await asyncio.sleep(10.0) + async def _status_polling_loop(self): while True: try: @@ -894,9 +938,27 @@ async def _set_leverage(self, trading_pair: str, leverage: int = 1): def set_leverage(self, trading_pair: str, leverage: int = 1): safe_ensure_future(self._set_leverage(trading_pair, leverage)) - """async def _get_funding_info(self): - prem_index = await self.request("/fapi/v1/premiumIndex", params={"symbol": convert_to_exchange_trading_pair(trading_pair)}) - self._funding_info = Decimal(prem_index.get("lastFundingRate", "0"))""" + async def get_funding_payment(self): + funding_payment_tasks = [] + for pair in self._trading_pairs: + funding_payment_tasks.append(self.request(path="/fapi/v1/income", + params={"symbol": convert_to_exchange_trading_pair(pair), "incomeType": "FUNDING_FEE", "limit": 1}, + method=MethodType.POST, + add_timestamp=True, + is_signed=True)) + funding_payments = await safe_gather(*funding_payment_tasks, return_exceptions=True) + for funding_payment in funding_payments: + payment = Decimal(funding_payment["income"]) + action = "paid" if payment < 0 else "received" + trading_pair = convert_to_exchange_trading_pair(funding_payment["symbol"]) + if payment != Decimal("0"): + self.logger().info(f"Funding payment of {payment} {action} on {trading_pair} market.") + self.trigger_event(self.MARKET_FUNDING_PAYMENT_COMPLETED_EVENT_TAG, + FundingPaymentCompletedEvent(timestamp=funding_payment["time"], + market=self.name, + rate=self._funding_info[trading_pair]["rate"], + symbol=trading_pair, + amount=payment)) def get_funding_info(self, trading_pair): return self._funding_info[trading_pair] diff --git a/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py index e8b8716aac..71bb2ffad7 100644 --- a/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py +++ b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py @@ -566,7 +566,7 @@ async def _update_positions(self): self.trigger_event(MarketEvent.FundingPaymentCompleted, FundingPaymentCompletedEvent(timestamp=funding_payment.timestamp, market=self.name, - rate=self._funding_info["rate"], + rate=self._funding_info[trading_pair]["rate"], symbol=trading_pair, amount=payment)) diff --git a/hummingbot/connector/derivative_base.py b/hummingbot/connector/derivative_base.py index ff4750017a..5bd2667e33 100644 --- a/hummingbot/connector/derivative_base.py +++ b/hummingbot/connector/derivative_base.py @@ -50,10 +50,12 @@ def supported_position_modes(self): def get_funding_info(self, trading_pair): """ - return a dictionary containing: + return a dictionary as follows: + self._trading_info[trading_pair] = { "indexPrice": (i.e "21.169488483519444444") "markPrice": price used for both pnl on most derivatives (i.e "21.210103847902463671") "nextFundingTime": next funding time in unix timestamp (i.e "1612780270") "rate": next funding rate as a decimal and not percentage (i.e 0.00007994084744229488) + } """ raise NotImplementedError From 82c90ee57147c636099a75dbd8f4748ac9c4231a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dennis=20Oca=C3=B1a?= <50150287+dennisocana@users.noreply.github.com> Date: Thu, 18 Feb 2021 14:35:26 +0800 Subject: [PATCH 036/131] (feat) delete exchange connector request template --- .github/ISSUE_TEMPLATE/exchange_request.md | 49 ---------------------- 1 file changed, 49 deletions(-) delete mode 100644 .github/ISSUE_TEMPLATE/exchange_request.md diff --git a/.github/ISSUE_TEMPLATE/exchange_request.md b/.github/ISSUE_TEMPLATE/exchange_request.md deleted file mode 100644 index 51c2cc7308..0000000000 --- a/.github/ISSUE_TEMPLATE/exchange_request.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -name: Exchange connector request -about: Suggest a new exchange connector for hummingbot -title: '' -labels: new exchange -assignees: '' - ---- - -## Exchange Details ✏️ - -- **Name of exchange**: -- **Exchange URL**: -- **Link to API docs**: -- **Type of exchange**: [ ] Centralized [ ] Decentralized -- **Requester details**: Are you affiliated with the exchange? [ ] yes [ ] no - - If yes, what is your role? - -## Rationale / impact ✏️ -(Describe your rationale for building this exchange connector, impact for hummingbot users/community) - -## Additional information ✏️ -(Provide any other useful information that may be helpful to know about this exchange) - ---- - -⚠️ ***Note: do not modify below here*** - -## Developer notes - -This feature request entails building a new exchange connector to allow Hummingbot to connect to an exchange that is currently not supported. - -### Resources -- [Exchange connector developer guide](https://docs.hummingbot.io/developers/connectors/) -- [Discord forum](https://discord.hummingbot.io) - -### Deliverables -1. A complete set of exchange connector files as listed [above](#developer-notes-resources). -2. Unit tests (see [existing unit tests](https://github.com/CoinAlpha/hummingbot/tree/master/test/integration)): - 1. Exchange market test ([example](https://github.com/CoinAlpha/hummingbot/blob/master/test/integration/test_binance_market.py)) - 2. Order book tracker ([example](https://github.com/CoinAlpha/hummingbot/blob/master/test/integration/test_binance_order_book_tracker.py)) - 3. User stream tracker ([example](https://github.com/CoinAlpha/hummingbot/blob/master/test/integration/test_binance_user_stream_tracker.py)) -3. Documentation: - 1. Code commenting (particularly for any code that is materially different from the templates/examples) - 2. Any specific instructions for the use of that exchange connector ([example](https://docs.hummingbot.io/connectors/binance/)) - -### Required skills -- Python -- Previous Cython experience is a plus (optional) \ No newline at end of file From 8e2bbad0191af04b5488148910f39967dad1c2a7 Mon Sep 17 00:00:00 2001 From: Daniel Tan Date: Thu, 18 Feb 2021 14:55:31 +0800 Subject: [PATCH 037/131] (refactor) ProbitAuth module and other Exchange methods --- .../probit_api_order_book_data_source.py | 2 +- .../connector/exchange/probit/probit_auth.py | 56 ++++++------------ .../exchange/probit/probit_constants.py | 1 + .../exchange/probit/probit_exchange.py | 58 ++++++++++++++++--- 4 files changed, 70 insertions(+), 47 deletions(-) diff --git a/hummingbot/connector/exchange/probit/probit_api_order_book_data_source.py b/hummingbot/connector/exchange/probit/probit_api_order_book_data_source.py index 005600c462..1d448bbb71 100644 --- a/hummingbot/connector/exchange/probit/probit_api_order_book_data_source.py +++ b/hummingbot/connector/exchange/probit/probit_api_order_book_data_source.py @@ -61,7 +61,7 @@ async def fetch_trading_pairs() -> List[str]: async with client.get(f"{constants.MARKETS_URL}") as response: if response.status == 200: resp_json: Dict[str, Any] = await response.json() - return [market["market_id"] for market in resp_json["data"]] + return [market["id"] for market in resp_json["data"]] return [] @staticmethod diff --git a/hummingbot/connector/exchange/probit/probit_auth.py b/hummingbot/connector/exchange/probit/probit_auth.py index d3a004f7fa..53ec139f36 100644 --- a/hummingbot/connector/exchange/probit/probit_auth.py +++ b/hummingbot/connector/exchange/probit/probit_auth.py @@ -1,11 +1,7 @@ #!/usr/bin/env python -import aiohttp import base64 import time -import ujson - -import hummingbot.connector.exchange.probit.probit_constants as constants from typing import Dict, Any @@ -20,49 +16,32 @@ def __init__(self, api_key: str, secret_key: str): self.secret_key: str = secret_key self._oauth_token: str = None self._oauth_token_expiration_time: int = -1 - self._http_client: aiohttp.ClientSession = aiohttp.ClientSession() - def _token_has_expired(self): + @property + def oauth_token(self): + return self._oauth_token + + @property + def token_payload(self): + payload = f"{self.api_key}:{self.secret_key}".encode() + return base64.b64encode(payload).decode() + + def token_has_expired(self): now: int = int(time.time()) return now >= self._oauth_token_expiration_time - def _update_expiration_time(self, expiration_time: int): - self._oauth_token_expiration_time = expiration_time + def update_oauth_token(self, new_token: str): + self._oauth_token = new_token - async def _generate_oauth_token(self) -> str: - try: - now: int = int(time.time()) - headers: Dict[str, Any] = self.get_headers() - payload = f"{self.api_key}:{self.secret_key}".encode() - b64_payload = base64.b64encode(payload).decode() - headers.update({ - "Authorization": f"Basic {b64_payload}" - }) - body = ujson.dumps({ - "grant_type": "client_credentials" - }) - resp = await self._http_client.post(url=constants.TOKEN_URL, - headers=headers, - data=body) - token_resp = await resp.json() - - if resp.status != 200: - raise ValueError(f"{__name__}: Error occurred retrieving new OAuth Token. Response: {token_resp}") - - # POST /token endpoint returns both access_token and expires_in - # Updates _oauth_token_expiration_time - - self._update_expiration_time(now + token_resp["expires_in"]) - return token_resp["access_token"] - except Exception as e: - raise e + def update_expiration_time(self, expiration_time: int): + self._oauth_token_expiration_time = expiration_time async def get_oauth_token(self) -> str: if self._oauth_token is None or self._token_has_expired(): - self._oauth_token = await self._generate_oauth_token() + self._oauth_token = await self.generate_oauth_token() return self._oauth_token - async def generate_auth_dict(self): + def generate_auth_dict(self): """ Generates authentication signature and return it in a dictionary along with other inputs :return: a dictionary of request info including the request signature @@ -70,9 +49,8 @@ async def generate_auth_dict(self): headers = self.get_headers() - access_token = await self.get_oauth_token() headers.update({ - "Authorization": f"Bearer {access_token}" + "Authorization": f"Bearer {self._oauth_token}" }) return headers diff --git a/hummingbot/connector/exchange/probit/probit_constants.py b/hummingbot/connector/exchange/probit/probit_constants.py index 95607932a6..6583e26a5b 100644 --- a/hummingbot/connector/exchange/probit/probit_constants.py +++ b/hummingbot/connector/exchange/probit/probit_constants.py @@ -8,6 +8,7 @@ REST_API_VERSON = "v1" # REST API Public Endpoints +TIME_URL = f"{REST_URL+REST_API_VERSON}/time" TICKER_URL = f"{REST_URL+REST_API_VERSON}/ticker" MARKETS_URL = f"{REST_URL+REST_API_VERSON}/market" ORDER_BOOK_URL = f"{REST_URL+REST_API_VERSON}/order_book" diff --git a/hummingbot/connector/exchange/probit/probit_exchange.py b/hummingbot/connector/exchange/probit/probit_exchange.py index bf7fc2c977..11ed57901e 100644 --- a/hummingbot/connector/exchange/probit/probit_exchange.py +++ b/hummingbot/connector/exchange/probit/probit_exchange.py @@ -44,6 +44,7 @@ from hummingbot.core.network_iterator import NetworkStatus from hummingbot.core.utils.async_utils import safe_ensure_future, safe_gather from hummingbot.logger import HummingbotLogger +import ujson probit_logger = None s_decimal_NaN = Decimal("nan") @@ -222,7 +223,12 @@ async def check_network(self) -> NetworkStatus: """ try: # since there is no ping endpoint, the lowest rate call is to get BTC-USDT ticker - await self._api_request("get", "public/get-ticker?instrument_name=BTC_USDT") + resp = await self._api_request( + method="GET", + path_url=CONSTANTS.TIME_URL + ) + if resp.status != 200: + raise except asyncio.CancelledError: raise except Exception: @@ -255,7 +261,10 @@ async def _trading_rules_polling_loop(self): await asyncio.sleep(0.5) async def _update_trading_rules(self): - market_info = await self._api_request("GET", path_url="public/get-instruments") + market_info = await self._api_request( + method="GET", + path_url=CONSTANTS.MARKETS_URL + ) self._trading_rules.clear() self._trading_rules = self._format_trading_rules(market_info) @@ -294,14 +303,44 @@ def _format_trading_rules(self, market_info: Dict[str, Any]) -> Dict[str, Tradin quantity_step = Decimal("1") / Decimal(str(math.pow(10, quantity_decimals))) result[trading_pair] = TradingRule(trading_pair=trading_pair, - min_order_size=Decimal(str(market["min_cost"])), - max_order_size=Decimal(str(market["max_cost"])), + min_order_size=Decimal(str(market["min_quantity"])), + max_order_size=Decimal(str(market["max_quantity"])), + min_order_value=Decimal(str(market["min_cost"])), min_price_increment=Decimal(str(market["price_increment"])), min_base_amount_increment=quantity_step) except Exception: self.logger().error(f"Error parsing the trading pair rule {market}. Skipping.", exc_info=True) return result + async def _get_auth_headers(self, http_client: aiohttp.ClientSession) -> Dict[str, Any]: + if self._probit_auth.token_has_expired: + try: + now: int = int(time.time()) + headers = self._probit_auth.get_headers() + headers.update({ + "Authorization": f"Basic {self._probit_auth.token_payload}" + }) + body = ujson.dumps({ + "grant_type": "client_credentials" + }) + resp = await http_client.post(url=CONSTANTS.TOKEN_URL, + headers=headers, + data=body) + token_resp = await resp.json() + + if resp.status != 200: + raise ValueError(f"Error occurred retrieving new OAuth Token. Response: {token_resp}") + + # POST /token endpoint returns both access_token and expires_in + # Updates _oauth_token_expiration_time + + self._probit_auth.update_expiration_time(now + token_resp["expires_in"]) + self._probit_auth.update_oauth_token(token_resp["access_token"]) + except Exception as e: + raise e + + return self._probit_auth.generate_auth_dict() + async def _api_request(self, method: str, path_url: str, @@ -319,7 +358,7 @@ async def _api_request(self, client = await self._http_client() if is_auth_required: - headers = self._probit_auth.generate_auth_dict() + headers = await self._get_auth_headers(client) else: headers = self._probit_auth.get_headers() @@ -337,8 +376,7 @@ async def _api_request(self, if response.status != 200: raise IOError(f"Error fetching data from {path_url}. HTTP status is {response.status}. " f"Message: {parsed_response}") - if parsed_response["code"] != 0: - raise IOError(f"{path_url} API call failed, response: {parsed_response}") + return parsed_response def get_order_price_quantum(self, trading_pair: str, price: Decimal): @@ -422,10 +460,16 @@ async def _create_order(self, 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"{trade_type.name} order amount {amount} is lower than the minimum order size " f"{trading_rule.min_order_size}.") + order_value: Decimal = amount * price + if order_value < trading_rule.min_order_value: + raise ValueError(f"{trade_type.name} order value {order_value} is lower than the minimum order value " + f"{trading_rule.min_order_value}") + body_params = { "market_id": trading_pair, "type": "limit", # ProBit Order Types ["limit", "market"} From 30add68fe6dce32fe07eb4e480425c6d1dbc7ee4 Mon Sep 17 00:00:00 2001 From: Daniel Tan Date: Thu, 18 Feb 2021 17:29:17 +0800 Subject: [PATCH 038/131] (fix) outstanding issues with ProbitExchange --- .../probit_api_order_book_data_source.py | 20 ++++++----- .../probit_api_user_stream_data_source.py | 4 +-- .../connector/exchange/probit/probit_auth.py | 36 ++++++++++++++++--- .../exchange/probit/probit_exchange.py | 12 ++++--- .../connector/exchange/probit/probit_utils.py | 2 +- 5 files changed, 54 insertions(+), 20 deletions(-) diff --git a/hummingbot/connector/exchange/probit/probit_api_order_book_data_source.py b/hummingbot/connector/exchange/probit/probit_api_order_book_data_source.py index 1d448bbb71..3f04d83710 100644 --- a/hummingbot/connector/exchange/probit/probit_api_order_book_data_source.py +++ b/hummingbot/connector/exchange/probit/probit_api_order_book_data_source.py @@ -51,8 +51,10 @@ async def get_last_traded_prices(cls, trading_pairs: List[str]) -> Dict[str, flo if response.status == 200: resp_json = await response.json() if "data" in resp_json: - for trading_pair in resp_json["data"]: - result[trading_pair["market_id"]] = trading_pair["last"] + for market in resp_json["data"]: + if market["market_id"] in trading_pairs: + result[market["market_id"]] = float(market["last"]) + return result @staticmethod @@ -147,11 +149,11 @@ async def listen_for_trades(self, ev_loop: asyncio.BaseEventLoop, output: asynci if "reset" in msg and msg["reset"] is True: # Ignores first response from "recent_trades" channel. This response details the last 100 trades. continue - for trade_entry in msg["recent_trades"]: trade_msg: OrderBookMessage = ProbitOrderBook.trade_message_from_exchange( msg=trade_entry, - timestamp=msg_timestamp) + timestamp=msg_timestamp, + metadata={"market_id": msg["market_id"]}) output.put_nowait(trade_msg) except asyncio.CancelledError: raise @@ -191,10 +193,12 @@ async def listen_for_order_book_diffs(self, ev_loop: asyncio.BaseEventLoop, outp timestamp=msg_timestamp, ) output.put_nowait(snapshot_msg) - continue - for diff_entry in msg["order_books"]: - diff_msg: OrderBookMessage = ProbitOrderBook.diff_message_from_exchange(diff_entry, - msg_timestamp) + else: + diff_msg: OrderBookMessage = ProbitOrderBook.diff_message_from_exchange( + msg=msg, + timestamp=msg_timestamp, + metadata={"market_id": msg["market_id"]} + ) output.put_nowait(diff_msg) except asyncio.CancelledError: raise diff --git a/hummingbot/connector/exchange/probit/probit_api_user_stream_data_source.py b/hummingbot/connector/exchange/probit/probit_api_user_stream_data_source.py index 9678c8fde2..fcd3f985a6 100644 --- a/hummingbot/connector/exchange/probit/probit_api_user_stream_data_source.py +++ b/hummingbot/connector/exchange/probit/probit_api_user_stream_data_source.py @@ -61,10 +61,10 @@ async def _authenticate(self, ws: websockets.WebSocketClientProtocol): Authenticates user to websocket """ try: - access_token: str = await self._probit_auth.get_oauth_token() + await self._probit_auth.get_auth_headers() auth_payload: Dict[str, Any] = { "type": "authorization", - "token": access_token + "token": self._probit_auth.oauth_token } await ws.send(ujson.dumps(auth_payload)) auth_resp = await ws.recv() diff --git a/hummingbot/connector/exchange/probit/probit_auth.py b/hummingbot/connector/exchange/probit/probit_auth.py index 53ec139f36..51a93882d9 100644 --- a/hummingbot/connector/exchange/probit/probit_auth.py +++ b/hummingbot/connector/exchange/probit/probit_auth.py @@ -1,7 +1,11 @@ #!/usr/bin/env python +import aiohttp import base64 import time +import ujson + +import hummingbot.connector.exchange.probit.probit_constants as CONSTANTS from typing import Dict, Any @@ -36,10 +40,34 @@ def update_oauth_token(self, new_token: str): def update_expiration_time(self, expiration_time: int): self._oauth_token_expiration_time = expiration_time - async def get_oauth_token(self) -> str: - if self._oauth_token is None or self._token_has_expired(): - self._oauth_token = await self.generate_oauth_token() - return self._oauth_token + async def get_auth_headers(self, http_client: aiohttp.ClientSession = aiohttp.ClientSession()) -> Dict[str, Any]: + if self.token_has_expired: + try: + now: int = int(time.time()) + headers = self.get_headers() + headers.update({ + "Authorization": f"Basic {self.token_payload}" + }) + body = ujson.dumps({ + "grant_type": "client_credentials" + }) + resp = await http_client.post(url=CONSTANTS.TOKEN_URL, + headers=headers, + data=body) + token_resp = await resp.json() + + if resp.status != 200: + raise ValueError(f"Error occurred retrieving new OAuth Token. Response: {token_resp}") + + # POST /token endpoint returns both access_token and expires_in + # Updates _oauth_token_expiration_time + + self.update_expiration_time(now + token_resp["expires_in"]) + self.update_oauth_token(token_resp["access_token"]) + except Exception as e: + raise e + + return self.generate_auth_dict() def generate_auth_dict(self): """ diff --git a/hummingbot/connector/exchange/probit/probit_exchange.py b/hummingbot/connector/exchange/probit/probit_exchange.py index 11ed57901e..864f5fd5c8 100644 --- a/hummingbot/connector/exchange/probit/probit_exchange.py +++ b/hummingbot/connector/exchange/probit/probit_exchange.py @@ -92,10 +92,12 @@ def __init__(self, self._in_flight_orders = {} # Dict[client_order_id:str, ProbitInFlightOrder] self._order_not_found_records = {} # Dict[client_order_id:str, count:int] self._trading_rules = {} # Dict[trading_pair:str, TradingRule] + self._last_poll_timestamp = 0 + self._status_polling_task = None + self._user_stream_tracker_task = None self._user_stream_event_listener_task = None self._trading_rules_polling_task = None - self._last_poll_timestamp = 0 @property def name(self) -> str: @@ -192,7 +194,7 @@ async def start_network(self): 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_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): @@ -227,7 +229,7 @@ async def check_network(self) -> NetworkStatus: method="GET", path_url=CONSTANTS.TIME_URL ) - if resp.status != 200: + if "data" not in resp: raise except asyncio.CancelledError: raise @@ -358,7 +360,7 @@ async def _api_request(self, client = await self._http_client() if is_auth_required: - headers = await self._get_auth_headers(client) + headers = await self._probit_auth.get_auth_headers(client) else: headers = self._probit_auth.get_headers() @@ -818,7 +820,7 @@ async def get_open_orders(self) -> List[OpenOrder]: self.logger().info(f"Unexpected response from GET {CONSTANTS.OPEN_ORDER_URL}. " f"Params: {query_params} " f"Response: {result} ") - for order in result["data"]["order_list"]: + for order in result["data"]: if order["type"] != "limit": raise Exception(f"Unsupported order type {order['type']}") ret_val.append( diff --git a/hummingbot/connector/exchange/probit/probit_utils.py b/hummingbot/connector/exchange/probit/probit_utils.py index 462a162f93..5920ef47d7 100644 --- a/hummingbot/connector/exchange/probit/probit_utils.py +++ b/hummingbot/connector/exchange/probit/probit_utils.py @@ -63,7 +63,7 @@ def convert_diff_message_to_order_book_row(message: OrderBookMessage) -> Tuple[L asks = [] for entry in data: - order_row = OrderBookRow(entry["price"], entry["quantity"], update_id) + order_row = OrderBookRow(float(entry["price"]), float(entry["quantity"]), update_id) if entry["side"] == "buy": bids.append(order_row) elif entry["side"] == "sell": From ea7801ffee63a473f374aebd60d4f7a679733301 Mon Sep 17 00:00:00 2001 From: Daniel Tan Date: Thu, 18 Feb 2021 20:24:21 +0800 Subject: [PATCH 039/131] (refactor) refactor ProbitAuth and start _user_stream_tracker --- hummingbot/connector/exchange/probit/probit_auth.py | 1 + hummingbot/connector/exchange/probit/probit_exchange.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/hummingbot/connector/exchange/probit/probit_auth.py b/hummingbot/connector/exchange/probit/probit_auth.py index 51a93882d9..5401f14c94 100644 --- a/hummingbot/connector/exchange/probit/probit_auth.py +++ b/hummingbot/connector/exchange/probit/probit_auth.py @@ -30,6 +30,7 @@ def token_payload(self): payload = f"{self.api_key}:{self.secret_key}".encode() return base64.b64encode(payload).decode() + @property def token_has_expired(self): now: int = int(time.time()) return now >= self._oauth_token_expiration_time diff --git a/hummingbot/connector/exchange/probit/probit_exchange.py b/hummingbot/connector/exchange/probit/probit_exchange.py index 864f5fd5c8..e2a6b4744c 100644 --- a/hummingbot/connector/exchange/probit/probit_exchange.py +++ b/hummingbot/connector/exchange/probit/probit_exchange.py @@ -194,7 +194,7 @@ async def start_network(self): 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_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): From 61c003ada83949156a11df3178a8a827cc156a2a Mon Sep 17 00:00:00 2001 From: Daniel Tan Date: Thu, 18 Feb 2021 20:55:33 +0800 Subject: [PATCH 040/131] (fix) fix market not starting --- .../probit_api_user_stream_data_source.py | 20 ++++++------------- .../exchange/probit/probit_exchange.py | 4 ++-- 2 files changed, 8 insertions(+), 16 deletions(-) diff --git a/hummingbot/connector/exchange/probit/probit_api_user_stream_data_source.py b/hummingbot/connector/exchange/probit/probit_api_user_stream_data_source.py index fcd3f985a6..d88025528b 100644 --- a/hummingbot/connector/exchange/probit/probit_api_user_stream_data_source.py +++ b/hummingbot/connector/exchange/probit/probit_api_user_stream_data_source.py @@ -2,6 +2,7 @@ import asyncio import logging +import time import ujson import websockets @@ -66,9 +67,9 @@ async def _authenticate(self, ws: websockets.WebSocketClientProtocol): "type": "authorization", "token": self._probit_auth.oauth_token } - await ws.send(ujson.dumps(auth_payload)) + await ws.send(ujson.dumps(auth_payload, escape_forward_slashes=False)) auth_resp = await ws.recv() - auth_resp: Dict[str, Any] = ujson.loads(auth_resp, escape_forward_slashes=False) + auth_resp: Dict[str, Any] = ujson.loads(auth_resp) if auth_resp["result"] != "ok": self.logger().error(f"Response: {auth_resp}", @@ -92,21 +93,11 @@ async def _subscribe_to_channels(self, ws: websockets.WebSocketClientProtocol): "channel": channel } await ws.send(ujson.dumps(sub_payload)) - sub_resp = await ws.recv() - sub_resp: Dict[str, Any] = ujson.loads(sub_resp) - - if "reset" in sub_resp and sub_resp["reset"] is True: - continue - else: - self.logger().error(f"Error occured subscribing to {channel}...") - raise except asyncio.CancelledError: raise except Exception: - self.logger().error(f"Error occured subscribing to {CONSTANTS.EXCHANGE_NAME} private channels. " - f"Payload: {sub_payload} " - f"Resp: {sub_resp}", + self.logger().error(f"Error occured subscribing to {CONSTANTS.EXCHANGE_NAME} private channels. ", exc_info=True) async def _inner_messages(self, @@ -114,6 +105,7 @@ async def _inner_messages(self, try: while True: msg: str = await ws.recv() + self._last_recv_time = int(time.time()) yield msg except websockets.exceptions.ConnectionClosed: return @@ -138,7 +130,7 @@ async def listen_for_user_stream(self, ev_loop: asyncio.BaseEventLoop, output: a self.logger().info("Successfully subscribed to all Private channels.") async for msg in self._inner_messages(ws): - output.put_nowait(msg) + output.put_nowait(ujson.loads(msg)) except asyncio.CancelledError: raise except Exception: diff --git a/hummingbot/connector/exchange/probit/probit_exchange.py b/hummingbot/connector/exchange/probit/probit_exchange.py index e2a6b4744c..edbb4b334f 100644 --- a/hummingbot/connector/exchange/probit/probit_exchange.py +++ b/hummingbot/connector/exchange/probit/probit_exchange.py @@ -5,6 +5,7 @@ import logging import math import time +import ujson from decimal import Decimal from typing import ( @@ -44,7 +45,6 @@ from hummingbot.core.network_iterator import NetworkStatus from hummingbot.core.utils.async_utils import safe_ensure_future, safe_gather from hummingbot.logger import HummingbotLogger -import ujson probit_logger = None s_decimal_NaN = Decimal("nan") @@ -936,7 +936,7 @@ async def _user_stream_event_listener(self): """ async for event_message in self._iter_user_event_queue(): try: - if "channel" not in event_message or event_message["channel"] not in ["open_order", "order_history", "balance", "trade_history"]: + if "channel" not in event_message and event_message["channel"] not in CONSTANTS.WS_PRIVATE_CHANNELS: continue channel = event_message["channel"] From c0c54694b152274b21e77d3e2d8a0d121539a68e Mon Sep 17 00:00:00 2001 From: Kirill Stroukov Date: Thu, 18 Feb 2021 14:00:28 +0100 Subject: [PATCH 041/131] feat/Bexy improve auth --- .../connector/exchange/beaxy/beaxy_auth.py | 35 ++++++++++--- .../exchange/beaxy/beaxy_exchange.pyx | 51 ++++++++++--------- .../exchange/beaxy/beaxy_stomp_message.py | 4 +- 3 files changed, 55 insertions(+), 35 deletions(-) diff --git a/hummingbot/connector/exchange/beaxy/beaxy_auth.py b/hummingbot/connector/exchange/beaxy/beaxy_auth.py index 037c411c23..8bfa3cc5d4 100644 --- a/hummingbot/connector/exchange/beaxy/beaxy_auth.py +++ b/hummingbot/connector/exchange/beaxy/beaxy_auth.py @@ -6,6 +6,7 @@ import asyncio from typing import Dict, Any, Optional from time import monotonic +from datetime import datetime import aiohttp from Crypto.PublicKey import RSA @@ -23,6 +24,7 @@ SAFE_TIME_PERIOD_SECONDS = 10 TOKEN_REFRESH_PERIOD_SECONDS = 10 * 60 MIN_TOKEN_LIFE_TIME_SECONDS = 30 +TOKEN_OBTAIN_TIMEOUT = 30 class BeaxyAuth: @@ -36,6 +38,8 @@ def __init__(self, api_key: str, api_secret: str): self.token_obtain = asyncio.Event() self.token_valid_to: float = 0 self.token_next_refresh: float = 0 + self.token_obtain_start_time = 0 + self.token_raw_expires = 0 @classmethod def logger(cls) -> HummingbotLogger: @@ -47,6 +51,9 @@ def logger(cls) -> HummingbotLogger: def is_token_valid(self): return self.token_valid_to > monotonic() + def token_timings_str(self): + return f'auth req start time {self.token_obtain_start_time}, token validness sec {self.token_raw_expires}' + def invalidate_token(self): self.token_valid_to = 0 @@ -61,13 +68,19 @@ async def get_token(self): return self.token # waiting for fresh token - await self.token_obtain.wait() + await asyncio.wait_for(self.token_obtain.wait(), timeout=TOKEN_OBTAIN_TIMEOUT) + + if not self.is_token_valid(): + raise ValueError('Invalid auth token timestamp') return self.token async def _update_token(self): self.token_obtain.clear() + start_time = monotonic() + start_timestamp = datetime.now() + async with aiohttp.ClientSession() as client: async with client.post( f'{BeaxyConstants.TradingApi.BASE_URL}{BeaxyConstants.TradingApi.TOKEN_ENDPOINT}', @@ -85,14 +98,20 @@ async def _update_token(self): raise IOError(f'Error while connecting to login token endpoint. Token lifetime to small {data["expires_in"]}.') self.token = data['access_token'] - current_time = monotonic() + self.token_raw_expires = data['expires_in'] # include safe interval, e.g. time that approx network request can take - self.token_valid_to = current_time + int(data['expires_in']) - SAFE_TIME_PERIOD_SECONDS - self.token_next_refresh = current_time + TOKEN_REFRESH_PERIOD_SECONDS + self.token_obtain_start_time = start_timestamp + self.token_valid_to = start_time + int(data['expires_in']) - SAFE_TIME_PERIOD_SECONDS + self.token_next_refresh = start_time + TOKEN_REFRESH_PERIOD_SECONDS + + if not self.is_token_valid(): + raise ValueError('Invalid auth token timestamp') self.token_obtain.set() + + async def _auth_token_polling_loop(self): """ Separate background process that periodically regenerates auth token @@ -107,12 +126,12 @@ async def _auth_token_polling_loop(self): self.logger().network( 'Unexpected error while fetching auth token.', exc_info=True, - app_warning_msg=f'Could not fetch trading rule updates on Beaxy. ' - f'Check network connection.' + app_warning_msg='Could not fetch trading rule updates on Beaxy. ' + 'Check network connection.' ) await asyncio.sleep(0.5) - async def generate_auth_dict(self, http_method: str, path: str, body: str = "") -> Dict[str, Any]: + async def generate_auth_dict(self, http_method: str, path: str, body: str = '') -> Dict[str, Any]: auth_token = await self.get_token() return {'Authorization': f'Bearer {auth_token}'} @@ -171,7 +190,7 @@ async def __login_attempt(self) -> Dict[str, str]: data: Dict[str, str] = await response.json() return data - def __build_payload(self, http_method: str, path: str, query_params: Dict[str, str], headers: Dict[str, str], body: str = ""): + def __build_payload(self, http_method: str, path: str, query_params: Dict[str, str], headers: Dict[str, str], body: str = ''): query_params_stringified = '&'.join([f'{k}={query_params[k]}' for k in sorted(query_params)]) headers_stringified = '&'.join([f'{k}={headers[k]}' for k in sorted(headers)]) return f'{http_method.upper()}{path.lower()}{query_params_stringified}{headers_stringified}{body}' diff --git a/hummingbot/connector/exchange/beaxy/beaxy_exchange.pyx b/hummingbot/connector/exchange/beaxy/beaxy_exchange.pyx index efc85b7e66..9a4c829970 100644 --- a/hummingbot/connector/exchange/beaxy/beaxy_exchange.pyx +++ b/hummingbot/connector/exchange/beaxy/beaxy_exchange.pyx @@ -232,7 +232,7 @@ cdef class BeaxyExchange(ExchangeBase): self.logger().debug(f'Starting beaxy network. Trading required is {self._trading_required}') self._stop_network() self._order_book_tracker.start() - self.logger().debug(f'OrderBookTracker started, starting polling tasks.') + self.logger().debug('OrderBookTracker started, starting polling tasks.') if self._trading_required: self._auth_polling_task = safe_ensure_future(self._beaxy_auth._auth_token_polling_loop()) self._status_polling_task = safe_ensure_future(self._status_polling_loop()) @@ -248,7 +248,7 @@ cdef class BeaxyExchange(ExchangeBase): except asyncio.CancelledError: raise except Exception: - self.logger().network(f'Error fetching Beaxy network status.', exc_info=True) + self.logger().network('Error fetching Beaxy network status.', exc_info=True) return NetworkStatus.NOT_CONNECTED return NetworkStatus.CONNECTED @@ -289,7 +289,7 @@ cdef class BeaxyExchange(ExchangeBase): Gets a list of the user's active orders via rest API :returns: json response """ - day_ago = (datetime.utcnow() - timedelta(days=1)).strftime("%Y-%m-%dT%H:%M:%SZ") + day_ago = (datetime.utcnow() - timedelta(days=1)).strftime('%Y-%m-%dT%H:%M:%SZ') result = await safe_gather( self._api_request('get', path_url=BeaxyConstants.TradingApi.OPEN_ORDERS_ENDPOINT), @@ -326,7 +326,7 @@ cdef class BeaxyExchange(ExchangeBase): f'The tracked order {client_order_id} does not exist on Beaxy for last day.' f'Removing from tracking.' ) - tracked_order.last_state = "CLOSED" + tracked_order.last_state = 'CLOSED' self.c_trigger_event( self.MARKET_ORDER_CANCELLED_EVENT_TAG, OrderCancelledEvent(self._current_timestamp, client_order_id) @@ -404,7 +404,7 @@ cdef class BeaxyExchange(ExchangeBase): else: self.logger().info(f'The market order {tracked_order.client_order_id} has failed/been cancelled ' f'according to order status API.') - tracked_order.last_state = "cancelled" + tracked_order.last_state = 'cancelled' self.c_trigger_event(self.MARKET_ORDER_CANCELLED_EVENT_TAG, OrderCancelledEvent( self._current_timestamp, @@ -507,15 +507,15 @@ cdef class BeaxyExchange(ExchangeBase): raise except Exception: tracked_order = self._in_flight_orders.get(order_id) - tracked_order.last_state = "FAILURE" + tracked_order.last_state = 'FAILURE' self.c_stop_tracking_order(order_id) order_type_str = order_type.name.lower() self.logger().network( - f"Error submitting buy {order_type_str} order to Beaxy for " - f"{decimal_amount} {trading_pair} " - f"{decimal_price}.", + f'Error submitting buy {order_type_str} order to Beaxy for ' + f'{decimal_amount} {trading_pair} ' + f'{decimal_price}.', exc_info=True, - app_warning_msg=f"Failed to submit buy order to Beaxy. Check API key and network connection." + app_warning_msg='Failed to submit buy order to Beaxy. Check API key and network connection.' ) self.c_trigger_event(self.MARKET_ORDER_FAILURE_EVENT_TAG, MarketOrderFailureEvent( @@ -579,15 +579,15 @@ cdef class BeaxyExchange(ExchangeBase): raise except Exception: tracked_order = self._in_flight_orders.get(order_id) - tracked_order.last_state = "FAILURE" + tracked_order.last_state = 'FAILURE' self.c_stop_tracking_order(order_id) order_type_str = order_type.name.lower() self.logger().network( - f"Error submitting sell {order_type_str} order to Beaxy for " - f"{decimal_amount} {trading_pair} " - f"{decimal_price if order_type is OrderType.LIMIT else ''}.", + f'Error submitting sell {order_type_str} order to Beaxy for ' + f'{decimal_amount} {trading_pair} ' + f'{decimal_price if order_type is OrderType.LIMIT else ""}.', exc_info=True, - app_warning_msg=f"Failed to submit sell order to Beaxy. Check API key and network connection." + app_warning_msg='Failed to submit sell order to Beaxy. Check API key and network connection.' ) self.c_trigger_event(self.MARKET_ORDER_FAILURE_EVENT_TAG, MarketOrderFailureEvent(self._current_timestamp, order_id, order_type)) @@ -670,7 +670,7 @@ cdef class BeaxyExchange(ExchangeBase): successful_cancellations.append(CancellationResult(client_order_id, True)) except Exception as e: self.logger().network( - f'Unexpected error cancelling orders.', + 'Unexpected error cancelling orders.', exc_info=True, app_warning_msg='Failed to cancel order on Coinbase Pro. Check API key and network connection.' ) @@ -699,8 +699,8 @@ cdef class BeaxyExchange(ExchangeBase): raise except Exception: self.logger().network('Error fetching Beaxy trade fees.', exc_info=True, - app_warning_msg=f'Could not fetch Beaxy trading fees. ' - f'Check network connection.') + app_warning_msg='Could not fetch Beaxy trading fees. ' + 'Check network connection.') raise async def _update_balances(self): @@ -747,7 +747,7 @@ cdef class BeaxyExchange(ExchangeBase): self._trading_rules[trading_pair] = trading_rule except Exception: - self.logger().warning(f'Got exception while updating trading rules.', exc_info=True) + self.logger().warning('Got exception while updating trading rules.', exc_info=True) def _format_trading_rules(self, market_dict: Dict[str, Any]) -> List[TradingRule]: """ @@ -919,7 +919,7 @@ cdef class BeaxyExchange(ExchangeBase): url = f'{BeaxyConstants.TradingApi.BASE_URL}{path_url}' if url is None else url - data_str = "" if data is None else json.dumps(data, separators=(',', ':')) + data_str = '' if data is None else json.dumps(data, separators=(',', ':')) if is_auth_required: headers = await self.beaxy_auth.generate_auth_dict(http_method, path_url, data_str) @@ -949,6 +949,7 @@ cdef class BeaxyExchange(ExchangeBase): if response.status not in [200, 204]: if response.status == 401: + self.logger().error(f'Beaxy auth error, token timings: {self._beaxy_auth.token_timings_str()}') self._beaxy_auth.invalidate_token() raise BeaxyIOError( @@ -962,7 +963,7 @@ cdef class BeaxyExchange(ExchangeBase): except BeaxyIOError: raise except Exception: - self.logger().warning(f'Exception while making api request.', exc_info=True) + self.logger().warning('Exception while making api request.', exc_info=True) raise async def _status_polling_loop(self): @@ -987,8 +988,8 @@ cdef class BeaxyExchange(ExchangeBase): self.logger().network( 'Unexpected error while fetching account updates.', exc_info=True, - app_warning_msg=f'Could not fetch account updates on Beaxy.' - f'Check API key and network connection.' + app_warning_msg='Could not fetch account updates on Beaxy.' + 'Check API key and network connection.' ) await asyncio.sleep(0.5) @@ -1007,8 +1008,8 @@ cdef class BeaxyExchange(ExchangeBase): self.logger().network( 'Unexpected error while fetching trading rules.', exc_info=True, - app_warning_msg=f'Could not fetch trading rule updates on Beaxy. ' - f'Check network connection.' + app_warning_msg='Could not fetch trading rule updates on Beaxy. ' + 'Check network connection.' ) await asyncio.sleep(0.5) diff --git a/hummingbot/connector/exchange/beaxy/beaxy_stomp_message.py b/hummingbot/connector/exchange/beaxy/beaxy_stomp_message.py index 981eae75cb..b51f5031ad 100644 --- a/hummingbot/connector/exchange/beaxy/beaxy_stomp_message.py +++ b/hummingbot/connector/exchange/beaxy/beaxy_stomp_message.py @@ -4,7 +4,7 @@ class BeaxyStompMessage: - def __init__(self, command: str = "") -> None: + def __init__(self, command: str = '') -> None: self.command = command self.body: str = "" self.headers: Dict[str, str] = {} @@ -35,7 +35,7 @@ def deserialize(raw_message: str) -> 'BeaxyStompMessage': if line: line_index = raw_message.index(line) retval.body = raw_message[line_index:] - retval.body = "".join(c for c in retval.body if c not in ['\r', '\n', '\0']) + retval.body = ''.join(c for c in retval.body if c not in ['\r', '\n', '\0']) break return retval From 6e2c631aafe9f5cc931986b2484c57b9847be29b Mon Sep 17 00:00:00 2001 From: Daniel Tan Date: Thu, 18 Feb 2021 21:01:22 +0800 Subject: [PATCH 042/131] (fix) fix _api_request not sending body params correctly --- hummingbot/connector/exchange/probit/probit_exchange.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/hummingbot/connector/exchange/probit/probit_exchange.py b/hummingbot/connector/exchange/probit/probit_exchange.py index edbb4b334f..c05d6cf996 100644 --- a/hummingbot/connector/exchange/probit/probit_exchange.py +++ b/hummingbot/connector/exchange/probit/probit_exchange.py @@ -367,7 +367,7 @@ async def _api_request(self, if method == "GET": response = await client.get(path_url, headers=headers, params=params) elif method == "POST": - response = await client.post(path_url, headers=headers, params=params, data=data) + response = await client.post(path_url, headers=headers, data=ujson.dumps(data)) else: raise NotImplementedError(f"{method} HTTP Method not implemented. ") @@ -477,8 +477,8 @@ async def _create_order(self, "type": "limit", # ProBit Order Types ["limit", "market"} "side": trade_type.name.lower(), # ProBit Order Sides ["buy", "sell"] "time_in_force": "gtc", # gtc = Good-Til-Cancelled - "limit_price": price, - "quantity": amount, + "limit_price": str(price), + "quantity": str(amount), "client_order_id": order_id } From e921befe7bc67c39034d7c64101239dcc505b615 Mon Sep 17 00:00:00 2001 From: Daniel Tan Date: Thu, 18 Feb 2021 21:15:38 +0800 Subject: [PATCH 043/131] (add) add probit api key templates --- hummingbot/templates/conf_global_TEMPLATE.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/hummingbot/templates/conf_global_TEMPLATE.yml b/hummingbot/templates/conf_global_TEMPLATE.yml index 8f77ed6df7..0f41f4d3b0 100644 --- a/hummingbot/templates/conf_global_TEMPLATE.yml +++ b/hummingbot/templates/conf_global_TEMPLATE.yml @@ -77,6 +77,9 @@ terra_wallet_seeds: null balancer_max_swaps: 4 +probit_api_key: null +probit_secret_key: null + # Ethereum wallet address: required for trading on a DEX ethereum_wallet: null ethereum_rpc_url: null From 28a3ad81ce912354a59ed7dee76d73bda00244aa Mon Sep 17 00:00:00 2001 From: Nullably <37262506+Nullably@users.noreply.github.com> Date: Thu, 18 Feb 2021 22:24:10 +0800 Subject: [PATCH 044/131] (feat) add parrot connector --- hummingbot/connector/parrot.py | 83 +++++++++++++++++++ .../liquidity_mining/liquidity_mining.py | 37 +++++++-- test/connector/test_parrot.py | 29 +++++++ 3 files changed, 144 insertions(+), 5 deletions(-) create mode 100644 hummingbot/connector/parrot.py create mode 100644 test/connector/test_parrot.py diff --git a/hummingbot/connector/parrot.py b/hummingbot/connector/parrot.py new file mode 100644 index 0000000000..e7ada091ef --- /dev/null +++ b/hummingbot/connector/parrot.py @@ -0,0 +1,83 @@ +import aiohttp +from typing import List, Dict +from dataclasses import dataclass +from decimal import Decimal +from hummingbot.connector.exchange.binance.binance_utils import convert_from_exchange_trading_pair +from hummingbot.core.utils.async_utils import safe_gather + +PARROT_MINER_BASE_URL = "https://papi-development.hummingbot.io/v1/mining_data/" + +s_decimal_0 = Decimal("0") + + +@dataclass +class CampaignSummary: + market_id: int = 0 + trading_pair: str = "" + exchange_name: str = 0 + spread_max: Decimal = s_decimal_0 + payout_asset: str = "" + liquidity: Decimal = s_decimal_0 + active_bots: int = 0 + reward_per_day: Decimal = s_decimal_0 + apy: Decimal = s_decimal_0 + + +async def get_campaign_summary(exchange: str, trading_pairs: List[str] = []) -> Dict[str, CampaignSummary]: + campaigns = await get_active_campaigns(exchange, trading_pairs) + tasks = [get_market_snapshots(m_id) for m_id in campaigns] + results = await safe_gather(*tasks, return_exceptions=True) + for result in results: + if isinstance(result, Exception): + raise result + if result["items"]: + snapshot = result["items"][0] + market_id = int(snapshot["market_id"]) + campaign = campaigns[market_id] + campaign.apy = Decimal(snapshot["annualized_return"]) / Decimal("100") + reward = snapshot["payout_summary"]["open_volume"]["reward"] + if campaign.payout_asset in reward["ask"]: + campaign.reward_per_day = Decimal(str(reward["ask"][campaign.payout_asset])) + if campaign.payout_asset in reward["bid"]: + campaign.reward_per_day += Decimal(str(reward["bid"][campaign.payout_asset])) + oov = snapshot["summary_stats"]["open_volume"] + campaign.liquidity = Decimal(oov["oov_ask"]) + Decimal(oov["oov_bid"]) + campaign.active_bots = int(oov["bots"]) + return {c.trading_pair: c for c in campaigns.values()} + + +async def get_market_snapshots(market_id: int): + async with aiohttp.ClientSession() as client: + url = f"{PARROT_MINER_BASE_URL}market_snapshots/{market_id}?aggregate=1d" + resp = await client.get(url) + resp_json = await resp.json() + return resp_json + + +async def get_active_campaigns(exchange: str, trading_pairs: List[str] = []) -> Dict[int, CampaignSummary]: + campaigns = {} + async with aiohttp.ClientSession() as client: + url = f"{PARROT_MINER_BASE_URL}campaigns" + resp = await client.get(url) + resp_json = await resp.json() + for campaign_retval in resp_json: + for market in campaign_retval["markets"]: + if market["exchange_name"] != exchange: + continue + t_pair = market["trading_pair"] + if exchange == "binance": + t_pair = convert_from_exchange_trading_pair(t_pair) + if trading_pairs and t_pair not in trading_pairs: + continue + campaign = CampaignSummary() + campaign.market_id = int(market["id"]) + campaign.trading_pair = t_pair + campaign.exchange_name = market["exchange_name"] + campaigns[campaign.market_id] = campaign + for bounty_period in campaign_retval["bounty_periods"]: + for payout_parameter in bounty_period["payout_parameters"]: + market_id = int(payout_parameter["market_id"]) + if market_id in campaigns: + campaigns[market_id].spread_max = Decimal(str(payout_parameter["spread_max"])) / Decimal("100") + campaigns[market_id].payout_asset = payout_parameter["payout_asset"] + return campaigns diff --git a/hummingbot/strategy/liquidity_mining/liquidity_mining.py b/hummingbot/strategy/liquidity_mining/liquidity_mining.py index 8045de06b2..fd511f2721 100644 --- a/hummingbot/strategy/liquidity_mining/liquidity_mining.py +++ b/hummingbot/strategy/liquidity_mining/liquidity_mining.py @@ -15,9 +15,11 @@ from hummingbot.core.event.events import OrderType, TradeType from hummingbot.core.data_type.limit_order import LimitOrder from hummingbot.core.utils.estimate_fee import estimate_fee +from hummingbot.core.utils.market_price import usd_value from hummingbot.strategy.pure_market_making.inventory_skew_calculator import ( calculate_bid_ask_ratios_from_base_asset_ratio ) +from hummingbot.connector.parrot import get_campaign_summary NaN = float("nan") s_decimal_zero = Decimal(0) s_decimal_nan = Decimal("NaN") @@ -137,21 +139,42 @@ async def active_orders_df(self) -> pd.DataFrame: def market_status_df(self) -> pd.DataFrame: data = [] - columns = ["Exchange", "Market", "Mid Price", "Volatility", "Base Bal", "Quote Bal", " Base %"] + columns = ["Market", "Mid Price", "Volatility", "Base Bal", "Quote Bal", f"Budget ({self._token})", + "Base %", "Quote %"] for market, market_info in self._market_infos.items(): mid_price = market_info.get_mid_price() base_bal = self._sell_budgets[market] quote_bal = self._buy_budgets[market] total_bal = (base_bal * mid_price) + quote_bal + total_bal_in_token = total_bal + if not self.is_token_a_quote_token(): + total_bal_in_token = base_bal + (quote_bal / mid_price) base_pct = (base_bal * mid_price) / total_bal if total_bal > 0 else s_decimal_zero + quote_pct = quote_bal / total_bal if total_bal > 0 else s_decimal_zero data.append([ - self._exchange.display_name, market, float(mid_price), "" if self._volatility[market].is_nan() else f"{self._volatility[market]:.2%}", float(base_bal), float(quote_bal), - f"{base_pct:.0%}" + float(total_bal_in_token), + f"{base_pct:.0%}", + f"{quote_pct:.0%}" + ]) + return pd.DataFrame(data=data, columns=columns).replace(np.nan, '', regex=True) + + async def miner_status_df(self) -> pd.DataFrame: + data = [] + columns = ["Market", "Reward/day", "Liquidity (bots)", "Yield/day", "Max spread"] + campaigns = await get_campaign_summary(self._exchange.display_name, list(self._market_infos.keys())) + for market, campaign in campaigns.items(): + liquidity_usd = await usd_value(market.split('-')[0], campaign.liquidity) + data.append([ + market, + f"{campaign.reward_per_day:.2f} {campaign.payout_asset}", + f"${liquidity_usd:.0f} ({campaign.active_bots})", + f"{campaign.apy / Decimal(365):.2%}", + f"{campaign.spread_max:.2%}%" ]) return pd.DataFrame(data=data, columns=columns).replace(np.nan, '', regex=True) @@ -162,8 +185,12 @@ async def format_status(self) -> str: warning_lines = [] warning_lines.extend(self.network_warning(list(self._market_infos.values()))) - lines.extend(["", " Markets:"] + [" " + line for line in - self.market_status_df().to_string(index=False).split("\n")]) + market_df = self.market_status_df() + lines.extend(["", " Markets:"] + [" " + line for line in market_df.to_string(index=False).split("\n")]) + + miner_df = await self.miner_status_df() + if not miner_df.empty: + lines.extend(["", " Miners:"] + [" " + line for line in miner_df.to_string(index=False).split("\n")]) # See if there're any open orders. if len(self.active_orders) > 0: diff --git a/test/connector/test_parrot.py b/test/connector/test_parrot.py new file mode 100644 index 0000000000..0c8491b8c8 --- /dev/null +++ b/test/connector/test_parrot.py @@ -0,0 +1,29 @@ +from os.path import join, realpath +import sys; sys.path.insert(0, realpath(join(__file__, "../../../../"))) +import unittest +import asyncio +from hummingbot.connector.parrot import get_active_campaigns, get_campaign_summary + + +class ParrotConnectorUnitTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.ev_loop: asyncio.BaseEventLoop = asyncio.get_event_loop() + + def test_get_active_campaigns(self): + self.ev_loop.run_until_complete(self._test_get_active_campaigns()) + + async def _test_get_active_campaigns(self): + results = await get_active_campaigns("binance") + self.assertGreater(len(results), 0) + for result in results.values(): + print(result) + + def test_get_campaign_summary(self): + self.ev_loop.run_until_complete(self._test_get_campaign_summary()) + + async def _test_get_campaign_summary(self): + results = await get_campaign_summary("binance", ["RLC-BTC", "RLC-ETH"]) + self.assertLessEqual(len(results), 2) + for result in results.values(): + print(result) From d2dc7201af81100970eb6091d17ce8516bc3ae10 Mon Sep 17 00:00:00 2001 From: Kirill Stroukov Date: Thu, 18 Feb 2021 16:38:35 +0100 Subject: [PATCH 045/131] feat/Bexy improve listen for user balance --- .../beaxy_api_user_stream_data_source.py | 33 ++- .../connector/exchange/beaxy/beaxy_auth.py | 2 - .../exchange/beaxy/beaxy_constants.py | 6 + .../exchange/beaxy/beaxy_exchange.pyx | 203 +++++++++--------- 4 files changed, 136 insertions(+), 108 deletions(-) diff --git a/hummingbot/connector/exchange/beaxy/beaxy_api_user_stream_data_source.py b/hummingbot/connector/exchange/beaxy/beaxy_api_user_stream_data_source.py index 053795f5ac..74bedb125b 100644 --- a/hummingbot/connector/exchange/beaxy/beaxy_api_user_stream_data_source.py +++ b/hummingbot/connector/exchange/beaxy/beaxy_api_user_stream_data_source.py @@ -11,6 +11,7 @@ from hummingbot.core.data_type.user_stream_tracker_data_source import UserStreamTrackerDataSource from hummingbot.core.utils.tracking_nonce import get_tracking_nonce +from hummingbot.core.utils.async_utils import safe_gather from hummingbot.connector.exchange.beaxy.beaxy_auth import BeaxyAuth from hummingbot.connector.exchange.beaxy.beaxy_constants import BeaxyConstants @@ -41,13 +42,7 @@ def __init__(self, beaxy_auth: BeaxyAuth, trading_pairs: Optional[List[str]] = [ def last_recv_time(self) -> float: return self._last_recv_time - async def listen_for_user_stream(self, ev_loop: asyncio.BaseEventLoop, output: asyncio.Queue): - """ - *required - Subscribe to user stream via web socket, and keep the connection open for incoming messages - :param ev_loop: ev_loop to execute this function in - :param output: an async queue where the incoming messages are stored - """ + async def __listen_ws(self, dest: str): while True: try: async with websockets.connect(BeaxyConstants.TradingApi.WS_BASE_URL) as ws: @@ -58,7 +53,7 @@ async def listen_for_user_stream(self, ev_loop: asyncio.BaseEventLoop, output: a orders_sub_request = BeaxyStompMessage('SUBSCRIBE') orders_sub_request.headers['id'] = f'sub-humming-{get_tracking_nonce()}' - orders_sub_request.headers['destination'] = '/user/v1/orders' + orders_sub_request.headers['destination'] = dest orders_sub_request.headers['X-Deltix-Nonce'] = str(get_tracking_nonce()) await ws.send(orders_sub_request.serialize()) @@ -68,7 +63,7 @@ async def listen_for_user_stream(self, ev_loop: asyncio.BaseEventLoop, output: a raise Exception(f'Got error from ws. Headers - {stomp_message.headers}') msg = ujson.loads(stomp_message.body) - output.put_nowait(msg) + yield msg except asyncio.CancelledError: raise except Exception: @@ -76,6 +71,26 @@ async def listen_for_user_stream(self, ev_loop: asyncio.BaseEventLoop, output: a 'Retrying after 30 seconds...', exc_info=True) await asyncio.sleep(30.0) + async def _listen_for_balance(self, ev_loop: asyncio.BaseEventLoop, output: asyncio.Queue): + async for msg in self.__listen_ws(BeaxyConstants.TradingApi.WS_BALANCE_ENDPOINT): + output.put_nowait([BeaxyConstants.UserStream.BALANCE_MESSAGE, msg]) + + async def _listen_for_orders(self, ev_loop: asyncio.BaseEventLoop, output: asyncio.Queue): + async for msg in self.__listen_ws(BeaxyConstants.TradingApi.WS_ORDERS_ENDPOINT): + output.put_nowait([BeaxyConstants.UserStream.ORDER_MESSAGE, msg]) + + async def listen_for_user_stream(self, ev_loop: asyncio.BaseEventLoop, output: asyncio.Queue): + """ + *required + Subscribe to user stream via web socket, and keep the connection open for incoming messages + :param ev_loop: ev_loop to execute this function in + :param output: an async queue where the incoming messages are stored + """ + await safe_gather( + self._listen_for_balance(ev_loop, output), + self._listen_for_orders(ev_loop, output), + ) + async def _inner_messages(self, ws: websockets.WebSocketClientProtocol) -> AsyncIterable[str]: """ diff --git a/hummingbot/connector/exchange/beaxy/beaxy_auth.py b/hummingbot/connector/exchange/beaxy/beaxy_auth.py index 8bfa3cc5d4..9a9ffae30a 100644 --- a/hummingbot/connector/exchange/beaxy/beaxy_auth.py +++ b/hummingbot/connector/exchange/beaxy/beaxy_auth.py @@ -110,8 +110,6 @@ async def _update_token(self): self.token_obtain.set() - - async def _auth_token_polling_loop(self): """ Separate background process that periodically regenerates auth token diff --git a/hummingbot/connector/exchange/beaxy/beaxy_constants.py b/hummingbot/connector/exchange/beaxy/beaxy_constants.py index 638315e6cb..ebdd314d5f 100644 --- a/hummingbot/connector/exchange/beaxy/beaxy_constants.py +++ b/hummingbot/connector/exchange/beaxy/beaxy_constants.py @@ -2,6 +2,10 @@ class BeaxyConstants: + class UserStream: + ORDER_MESSAGE = 'ORDER' + BALANCE_MESSAGE = 'BALANCE' + class TradingApi: BASE_URL_V1 = 'https://tradingapi.beaxy.com' BASE_URL = 'https://tradewith.beaxy.com' @@ -18,6 +22,8 @@ class TradingApi: LOGIN_CONFIRM_ENDPOINT = '/api/v1/login/confirm' WS_BASE_URL = 'wss://tradingapi.beaxy.com/websocket/v1' + WS_ORDERS_ENDPOINT = '/user/v1/orders' + WS_BALANCE_ENDPOINT = '/user/v1/balances' class PublicApi: BASE_URL = 'https://services.beaxy.com' diff --git a/hummingbot/connector/exchange/beaxy/beaxy_exchange.pyx b/hummingbot/connector/exchange/beaxy/beaxy_exchange.pyx index 9a4c829970..4a7b1ace19 100644 --- a/hummingbot/connector/exchange/beaxy/beaxy_exchange.pyx +++ b/hummingbot/connector/exchange/beaxy/beaxy_exchange.pyx @@ -787,107 +787,116 @@ cdef class BeaxyExchange(ExchangeBase): await asyncio.sleep(1.0) async def _user_stream_event_listener(self): - async for event_message in self._iter_user_event_queue(): + async for msg_type, event_message in self._iter_user_event_queue(): try: - order = event_message['order'] - exchange_order_id = order['id'] - client_order_id = order['text'] - order_status = order['status'] - - if client_order_id is None: - continue - - tracked_order = self._in_flight_orders.get(client_order_id) - - if tracked_order is None: - self.logger().debug(f'Didn`rt find order with id {client_order_id}') - continue - - execute_price = s_decimal_0 - execute_amount_diff = s_decimal_0 - - if event_message['events']: - order_event = event_message['events'][0] - event_type = order_event['type'] - - if event_type == 'trade': - execute_price = Decimal(order_event.get('trade_price', 0.0)) - execute_amount_diff = Decimal(order_event.get('trade_quantity', 0.0)) - tracked_order.executed_amount_base = Decimal(order['cumulative_quantity']) - tracked_order.executed_amount_quote += execute_amount_diff * execute_price - - if execute_amount_diff > s_decimal_0: - self.logger().info(f'Filled {execute_amount_diff} out of {tracked_order.amount} of the ' - f'{tracked_order.order_type_description} order {tracked_order.client_order_id}') - exchange_order_id = tracked_order.exchange_order_id - - self.c_trigger_event(self.MARKET_ORDER_FILLED_EVENT_TAG, - OrderFilledEvent( - self._current_timestamp, - tracked_order.client_order_id, - tracked_order.trading_pair, - tracked_order.trade_type, - tracked_order.order_type, - execute_price, - execute_amount_diff, - self.c_get_fee( - tracked_order.base_asset, - tracked_order.quote_asset, - tracked_order.order_type, + if msg_type == BeaxyConstants.UserStream.BALANCE_MESSAGE: + for msg in event_message: + asset_name = msg['currency_id'] + available_balance = Decimal(msg['available_for_trading']) + total_balance = Decimal(msg['balance']) + self._account_available_balances[asset_name] = available_balance + self._account_balances[asset_name] = total_balance + + elif msg_type == BeaxyConstants.UserStream.ORDER_MESSAGE: + order = event_message['order'] + exchange_order_id = order['id'] + client_order_id = order['text'] + order_status = order['status'] + + if client_order_id is None: + continue + + tracked_order = self._in_flight_orders.get(client_order_id) + + if tracked_order is None: + self.logger().debug(f'Didn`rt find order with id {client_order_id}') + continue + + execute_price = s_decimal_0 + execute_amount_diff = s_decimal_0 + + if event_message['events']: + order_event = event_message['events'][0] + event_type = order_event['type'] + + if event_type == 'trade': + execute_price = Decimal(order_event.get('trade_price', 0.0)) + execute_amount_diff = Decimal(order_event.get('trade_quantity', 0.0)) + tracked_order.executed_amount_base = Decimal(order['cumulative_quantity']) + tracked_order.executed_amount_quote += execute_amount_diff * execute_price + + if execute_amount_diff > s_decimal_0: + self.logger().info(f'Filled {execute_amount_diff} out of {tracked_order.amount} of the ' + f'{tracked_order.order_type_description} order {tracked_order.client_order_id}') + exchange_order_id = tracked_order.exchange_order_id + + self.c_trigger_event(self.MARKET_ORDER_FILLED_EVENT_TAG, + OrderFilledEvent( + self._current_timestamp, + tracked_order.client_order_id, + tracked_order.trading_pair, tracked_order.trade_type, + tracked_order.order_type, execute_price, execute_amount_diff, - ), - exchange_trade_id=exchange_order_id - )) - - if order_status == 'completely_filled': - if tracked_order.trade_type == TradeType.BUY: - self.logger().info(f'The market buy order {tracked_order.client_order_id} has completed ' - f'according to Beaxy user stream.') - self.c_trigger_event(self.MARKET_BUY_ORDER_COMPLETED_EVENT_TAG, - BuyOrderCompletedEvent(self._current_timestamp, - tracked_order.client_order_id, - tracked_order.base_asset, - tracked_order.quote_asset, - (tracked_order.fee_asset - or tracked_order.base_asset), - tracked_order.executed_amount_base, - tracked_order.executed_amount_quote, - tracked_order.fee_paid, - tracked_order.order_type)) - else: - self.logger().info(f'The market sell order {tracked_order.client_order_id} has completed ' - f'according to Beaxy user stream.') - self.c_trigger_event(self.MARKET_SELL_ORDER_COMPLETED_EVENT_TAG, - SellOrderCompletedEvent(self._current_timestamp, - tracked_order.client_order_id, - tracked_order.base_asset, - tracked_order.quote_asset, - (tracked_order.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)) - - self.c_stop_tracking_order(tracked_order.client_order_id) - - elif order_status == 'canceled': - tracked_order.last_state = 'canceled' - self.c_trigger_event(self.MARKET_ORDER_CANCELLED_EVENT_TAG, - OrderCancelledEvent(self._current_timestamp, tracked_order.client_order_id)) - self.c_stop_tracking_order(tracked_order.client_order_id) - elif order_status in ['rejected', 'replaced', 'suspended']: - tracked_order.last_state = order_status - self.c_trigger_event(self.MARKET_ORDER_FAILURE_EVENT_TAG, - MarketOrderFailureEvent(self._current_timestamp, tracked_order.client_order_id, tracked_order.order_type)) - self.c_stop_tracking_order(tracked_order.client_order_id) - elif order_status == 'expired': - tracked_order.last_state = 'expired' - self.c_trigger_event(self.MARKET_ORDER_EXPIRED_EVENT_TAG, - OrderExpiredEvent(self._current_timestamp, tracked_order.client_order_id)) - self.c_stop_tracking_order(tracked_order.client_order_id) + self.c_get_fee( + tracked_order.base_asset, + tracked_order.quote_asset, + tracked_order.order_type, + tracked_order.trade_type, + execute_price, + execute_amount_diff, + ), + exchange_trade_id=exchange_order_id + )) + + if order_status == 'completely_filled': + if tracked_order.trade_type == TradeType.BUY: + self.logger().info(f'The market buy order {tracked_order.client_order_id} has completed ' + f'according to Beaxy user stream.') + self.c_trigger_event(self.MARKET_BUY_ORDER_COMPLETED_EVENT_TAG, + BuyOrderCompletedEvent(self._current_timestamp, + tracked_order.client_order_id, + tracked_order.base_asset, + tracked_order.quote_asset, + (tracked_order.fee_asset + or tracked_order.base_asset), + tracked_order.executed_amount_base, + tracked_order.executed_amount_quote, + tracked_order.fee_paid, + tracked_order.order_type)) + else: + self.logger().info(f'The market sell order {tracked_order.client_order_id} has completed ' + f'according to Beaxy user stream.') + self.c_trigger_event(self.MARKET_SELL_ORDER_COMPLETED_EVENT_TAG, + SellOrderCompletedEvent(self._current_timestamp, + tracked_order.client_order_id, + tracked_order.base_asset, + tracked_order.quote_asset, + (tracked_order.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)) + + self.c_stop_tracking_order(tracked_order.client_order_id) + + elif order_status == 'canceled': + tracked_order.last_state = 'canceled' + self.c_trigger_event(self.MARKET_ORDER_CANCELLED_EVENT_TAG, + OrderCancelledEvent(self._current_timestamp, tracked_order.client_order_id)) + self.c_stop_tracking_order(tracked_order.client_order_id) + elif order_status in ['rejected', 'replaced', 'suspended']: + tracked_order.last_state = order_status + self.c_trigger_event(self.MARKET_ORDER_FAILURE_EVENT_TAG, + MarketOrderFailureEvent(self._current_timestamp, tracked_order.client_order_id, tracked_order.order_type)) + self.c_stop_tracking_order(tracked_order.client_order_id) + elif order_status == 'expired': + tracked_order.last_state = 'expired' + self.c_trigger_event(self.MARKET_ORDER_EXPIRED_EVENT_TAG, + OrderExpiredEvent(self._current_timestamp, tracked_order.client_order_id)) + self.c_stop_tracking_order(tracked_order.client_order_id) except Exception: self.logger().error('Unexpected error in user stream listener loop.', exc_info=True) From 1c932e86de3324abdf80824496d74e710f61ba83 Mon Sep 17 00:00:00 2001 From: vic-en Date: Thu, 18 Feb 2021 19:54:01 +0100 Subject: [PATCH 046/131] (feat) refactor fetch_trading_pairs to *_api_order_book.py file --- ...tual_finance_api_order_book_data_source.py | 43 +++++++++++++++++++ .../perpetual_finance_derivative.py | 35 +-------------- 2 files changed, 44 insertions(+), 34 deletions(-) create mode 100644 hummingbot/connector/derivative/perpetual_finance/perpetual_finance_api_order_book_data_source.py diff --git a/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_api_order_book_data_source.py b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_api_order_book_data_source.py new file mode 100644 index 0000000000..4abc57b74c --- /dev/null +++ b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_api_order_book_data_source.py @@ -0,0 +1,43 @@ +import aiohttp +from typing import List +import json +import ssl + +from hummingbot.client.config.global_config_map import global_config_map +from hummingbot.connector.derivative.perpetual_finance.perpetual_finance_utils import convert_from_exchange_trading_pair +from hummingbot.client.settings import GATEAWAY_CA_CERT_PATH, GATEAWAY_CLIENT_CERT_PATH, GATEAWAY_CLIENT_KEY_PATH + + +class PerpetualFinanceAPIOrderBookDataSource: + @staticmethod + async def fetch_trading_pairs() -> List[str]: + ssl_ctx = ssl.create_default_context(cafile=GATEAWAY_CA_CERT_PATH) + ssl_ctx.load_cert_chain(GATEAWAY_CLIENT_CERT_PATH, GATEAWAY_CLIENT_KEY_PATH) + conn = aiohttp.TCPConnector(ssl_context=ssl_ctx) + client = aiohttp.ClientSession(connector=conn) + + base_url = f"https://{global_config_map['gateway_api_host'].value}:" \ + f"{global_config_map['gateway_api_port'].value}/perpfi/" + response = await client.get(base_url + "pairs") + parsed_response = json.loads(await response.text()) + if response.status != 200: + err_msg = "" + if "error" in parsed_response: + err_msg = f" Message: {parsed_response['error']}" + raise IOError(f"Error fetching pairs from gateway. HTTP status is {response.status}.{err_msg}") + pairs = parsed_response.get("pairs", []) + if "error" in parsed_response or len(pairs) == 0: + raise Exception(f"Error: {parsed_response['error']}") + else: + status = await client.get(base_url) + status = json.loads(await status.text()) + loadedMetadata = status["loadedMetadata"] + while (not loadedMetadata): + resp = await client.get(base_url + "load-metadata") + resp = json.loads(await resp.text()) + loadedMetadata = resp.get("loadedMetadata", False) + return PerpetualFinanceAPIOrderBookDataSource.fetch_trading_pairs() + trading_pairs = [] + for pair in pairs: + trading_pairs.append(convert_from_exchange_trading_pair(pair)) + return trading_pairs diff --git a/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py index 71bb2ffad7..9323d76967 100644 --- a/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py +++ b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py @@ -32,7 +32,7 @@ ) from hummingbot.connector.derivative_base import DerivativeBase from hummingbot.connector.derivative.perpetual_finance.perpetual_finance_in_flight_order import PerpetualFinanceInFlightOrder -from hummingbot.connector.derivative.perpetual_finance.perpetual_finance_utils import convert_to_exchange_trading_pair, convert_from_exchange_trading_pair +from hummingbot.connector.derivative.perpetual_finance.perpetual_finance_utils import convert_to_exchange_trading_pair from hummingbot.client.settings import GATEAWAY_CA_CERT_PATH, GATEAWAY_CLIENT_CERT_PATH, GATEAWAY_CLIENT_KEY_PATH from hummingbot.client.config.global_config_map import global_config_map from hummingbot.connector.derivative.position import Position @@ -92,39 +92,6 @@ def __init__(self, def name(self): return "perpetual_finance" - @staticmethod - async def fetch_trading_pairs() -> List[str]: - ssl_ctx = ssl.create_default_context(cafile=GATEAWAY_CA_CERT_PATH) - ssl_ctx.load_cert_chain(GATEAWAY_CLIENT_CERT_PATH, GATEAWAY_CLIENT_KEY_PATH) - conn = aiohttp.TCPConnector(ssl_context=ssl_ctx) - client = aiohttp.ClientSession(connector=conn) - - base_url = f"https://{global_config_map['gateway_api_host'].value}:" \ - f"{global_config_map['gateway_api_port'].value}/perpfi/" - response = await client.get(base_url + "pairs") - parsed_response = json.loads(await response.text()) - if response.status != 200: - err_msg = "" - if "error" in parsed_response: - err_msg = f" Message: {parsed_response['error']}" - raise IOError(f"Error fetching pairs from gateway. HTTP status is {response.status}.{err_msg}") - pairs = parsed_response.get("pairs", []) - if "error" in parsed_response or len(pairs) == 0: - raise Exception(f"Error: {parsed_response['error']}") - else: - status = await client.get(base_url) - status = json.loads(await status.text()) - loadedMetadata = status["loadedMetadata"] - while (not loadedMetadata): - resp = await client.get(base_url + "load-metadata") - resp = json.loads(await resp.text()) - loadedMetadata = resp.get("loadedMetadata", False) - return PerpetualFinanceDerivative.fetch_trading_pairs() - trading_pairs = [] - for pair in pairs: - trading_pairs.append(convert_from_exchange_trading_pair(pair)) - return trading_pairs - @property def limit_orders(self) -> List[LimitOrder]: return [ From 57b48217eb37f99b08cb53c02ee936a4e25c9872 Mon Sep 17 00:00:00 2001 From: vic-en Date: Fri, 19 Feb 2021 12:24:41 +0100 Subject: [PATCH 047/131] (feat) refactor autocomplete filter for connector name selection for all strategies --- hummingbot/client/ui/completer.py | 49 ++++++++++++------- .../strategy/amm_arb/amm_arb_config_map.py | 4 +- .../arbitrage/arbitrage_config_map.py | 4 +- .../strategy/celo_arb/celo_arb_config_map.py | 2 +- ...cross_exchange_market_making_config_map.py | 4 +- .../liquidity_mining_config_map.py | 2 +- .../perpetual_market_making_config_map.py | 2 +- .../pure_market_making_config_map.py | 2 +- 8 files changed, 40 insertions(+), 29 deletions(-) diff --git a/hummingbot/client/ui/completer.py b/hummingbot/client/ui/completer.py index 527c442602..ef85af2ccb 100644 --- a/hummingbot/client/ui/completer.py +++ b/hummingbot/client/ui/completer.py @@ -14,7 +14,8 @@ DERIVATIVES, STRATEGIES, CONF_FILE_PATH, - SCRIPTS_PATH + SCRIPTS_PATH, + ConnectorType ) from hummingbot.client.ui.parser import ThrowingArgumentParser from hummingbot.core.utils.wallet_setup import list_wallets @@ -28,15 +29,21 @@ def file_name_list(path, file_extension): return sorted([f for f in listdir(path) if isfile(join(path, f)) and f.endswith(file_extension)]) +SPOT_PROTOCOL_CONNECTOR = {x.name for x in CONNECTOR_SETTINGS.values() if x.type == ConnectorType.Connector} +DERIVATIVE_PROTOCOL_CONNECTOR = {x.name for x in CONNECTOR_SETTINGS.values() if x.type == ConnectorType.Derivative and not x.centralised} + + class HummingbotCompleter(Completer): def __init__(self, hummingbot_application): super(HummingbotCompleter, self).__init__() self.hummingbot_application = hummingbot_application self._path_completer = WordCompleter(file_name_list(CONF_FILE_PATH, "yml")) self._command_completer = WordCompleter(self.parser.commands, ignore_case=True) - self._connector_completer = WordCompleter(CONNECTOR_SETTINGS.keys(), ignore_case=True) - self._exchange_completer = WordCompleter(EXCHANGES, ignore_case=True) + self._exchange_completer = WordCompleter(CONNECTOR_SETTINGS.keys(), ignore_case=True) + self._spot_completer = WordCompleter(EXCHANGES.union(SPOT_PROTOCOL_CONNECTOR), ignore_case=True) + self._spot_exchange_completer = WordCompleter(EXCHANGES, ignore_case=True) self._derivative_completer = WordCompleter(DERIVATIVES, ignore_case=True) + self._derivative_exchange_completer = WordCompleter(DERIVATIVES.difference(DERIVATIVE_PROTOCOL_CONNECTOR), ignore_case=True) self._connect_option_completer = WordCompleter(CONNECT_OPTIONS, ignore_case=True) self._export_completer = WordCompleter(["keys", "trades"], ignore_case=True) self._balance_completer = WordCompleter(["limit", "paper"], ignore_case=True) @@ -96,25 +103,21 @@ def _complete_options(self, document: Document) -> bool: return "(" in self.prompt_text and ")" in self.prompt_text and "/" in self.prompt_text def _complete_exchanges(self, document: Document) -> bool: - text_before_cursor: str = document.text_before_cursor - return "-e" in text_before_cursor or \ - "--exchange" in text_before_cursor or \ - any(x for x in ("exchange name", "name of exchange", "name of the exchange") + return any(x for x in ("exchange name", "name of exchange", "name of the exchange") if x in self.prompt_text.lower()) def _complete_derivatives(self, document: Document) -> bool: text_before_cursor: str = document.text_before_cursor - return "--exchange" in text_before_cursor or \ - "perpetual" in text_before_cursor or \ - any(x for x in ("derivative name", "name of derivative", "name of the derivative") + return "perpetual" in text_before_cursor or \ + any(x for x in ("derivative connector", "derivative name", "name of derivative", "name of the derivative") if x in self.prompt_text.lower()) def _complete_connect_options(self, document: Document) -> bool: text_before_cursor: str = document.text_before_cursor return text_before_cursor.startswith("connect ") - def _complete_connectors(self, document: Document) -> bool: - return "connector" in self.prompt_text + def _complete_spot_connectors(self, document: Document) -> bool: + return "spot" in self.prompt_text def _complete_export_options(self, document: Document) -> bool: text_before_cursor: str = document.text_before_cursor @@ -175,9 +178,13 @@ def get_completions(self, document: Document, complete_event: CompleteEvent): for c in self._wallet_address_completer.get_completions(document, complete_event): yield c - elif self._complete_connectors(document): - for c in self._connector_completer.get_completions(document, complete_event): - yield c + elif self._complete_spot_connectors(document): + if "(Exchange/AMM)" in self.prompt_text: + for c in self._spot_completer.get_completions(document, complete_event): + yield c + else: + for c in self._spot_exchange_completer.get_completions(document, complete_event): + yield c elif self._complete_connect_options(document): for c in self._connect_option_completer.get_completions(document, complete_event): @@ -199,14 +206,18 @@ def get_completions(self, document: Document, complete_event: CompleteEvent): for c in self._history_completer.get_completions(document, complete_event): yield c + elif self._complete_derivatives(document): + if "(Exchange/AMM)" in self.prompt_text: + for c in self._derivative_completer.get_completions(document, complete_event): + yield c + else: + for c in self._derivative_exchange_completer.get_completions(document, complete_event): + yield c + elif self._complete_exchanges(document): for c in self._exchange_completer.get_completions(document, complete_event): yield c - elif self._complete_derivatives(document): - for c in self._derivative_completer.get_completions(document, complete_event): - yield c - elif self._complete_trading_pairs(document): for c in self._trading_pair_completer.get_completions(document, complete_event): yield c diff --git a/hummingbot/strategy/amm_arb/amm_arb_config_map.py b/hummingbot/strategy/amm_arb/amm_arb_config_map.py index 16e15e90c7..d85d87951c 100644 --- a/hummingbot/strategy/amm_arb/amm_arb_config_map.py +++ b/hummingbot/strategy/amm_arb/amm_arb_config_map.py @@ -51,7 +51,7 @@ def order_amount_prompt() -> str: default="amm_arb"), "connector_1": ConfigVar( key="connector_1", - prompt="Enter your first connector (exchange/AMM) >>> ", + prompt="Enter your first spot connector (Exchange/AMM) >>> ", prompt_on_new=True, validator=validate_connector, on_validated=exchange_on_validated), @@ -62,7 +62,7 @@ def order_amount_prompt() -> str: on_validated=market_1_on_validated), "connector_2": ConfigVar( key="connector_2", - prompt="Enter your second connector (exchange/AMM) >>> ", + prompt="Enter your second spot connector (Exchange/AMM) >>> ", prompt_on_new=True, validator=validate_connector, on_validated=exchange_on_validated), diff --git a/hummingbot/strategy/arbitrage/arbitrage_config_map.py b/hummingbot/strategy/arbitrage/arbitrage_config_map.py index 278f71cdde..96313098ae 100644 --- a/hummingbot/strategy/arbitrage/arbitrage_config_map.py +++ b/hummingbot/strategy/arbitrage/arbitrage_config_map.py @@ -47,13 +47,13 @@ def secondary_market_on_validated(value: str): default="arbitrage"), "primary_market": ConfigVar( key="primary_market", - prompt="Enter your primary exchange name >>> ", + prompt="Enter your primary spot connector >>> ", prompt_on_new=True, validator=validate_exchange, on_validated=lambda value: required_exchanges.append(value)), "secondary_market": ConfigVar( key="secondary_market", - prompt="Enter your secondary exchange name >>> ", + prompt="Enter your secondary spot connector >>> ", prompt_on_new=True, validator=validate_exchange, on_validated=secondary_market_on_validated), diff --git a/hummingbot/strategy/celo_arb/celo_arb_config_map.py b/hummingbot/strategy/celo_arb/celo_arb_config_map.py index c92f5b56b0..3e99ca39b3 100644 --- a/hummingbot/strategy/celo_arb/celo_arb_config_map.py +++ b/hummingbot/strategy/celo_arb/celo_arb_config_map.py @@ -35,7 +35,7 @@ def order_amount_prompt() -> str: default="celo_arb"), "secondary_exchange": ConfigVar( key="secondary_exchange", - prompt="Enter your secondary exchange name >>> ", + prompt="Enter your secondary spot connector >>> ", prompt_on_new=True, validator=validate_exchange, on_validated=exchange_on_validated), diff --git a/hummingbot/strategy/cross_exchange_market_making/cross_exchange_market_making_config_map.py b/hummingbot/strategy/cross_exchange_market_making/cross_exchange_market_making_config_map.py index a87eb8f8f6..2d8f2438a9 100644 --- a/hummingbot/strategy/cross_exchange_market_making/cross_exchange_market_making_config_map.py +++ b/hummingbot/strategy/cross_exchange_market_making/cross_exchange_market_making_config_map.py @@ -78,14 +78,14 @@ def taker_market_on_validated(value: str): ), "maker_market": ConfigVar( key="maker_market", - prompt="Enter your maker exchange name >>> ", + prompt="Enter your maker spot connector >>> ", prompt_on_new=True, validator=validate_exchange, on_validated=lambda value: required_exchanges.append(value), ), "taker_market": ConfigVar( key="taker_market", - prompt="Enter your taker exchange name >>> ", + prompt="Enter your taker spot connector >>> ", prompt_on_new=True, validator=validate_exchange, on_validated=taker_market_on_validated, diff --git a/hummingbot/strategy/liquidity_mining/liquidity_mining_config_map.py b/hummingbot/strategy/liquidity_mining/liquidity_mining_config_map.py index 891733234f..ff421ead7b 100644 --- a/hummingbot/strategy/liquidity_mining/liquidity_mining_config_map.py +++ b/hummingbot/strategy/liquidity_mining/liquidity_mining_config_map.py @@ -37,7 +37,7 @@ def order_size_prompt() -> str: default="liquidity_mining"), "exchange": ConfigVar(key="exchange", - prompt="Enter your liquidity mining exchange name >>> ", + prompt="Enter the spot connector to use for liquidity mining >>> ", validator=validate_exchange, on_validated=exchange_on_validated, prompt_on_new=True), diff --git a/hummingbot/strategy/perpetual_market_making/perpetual_market_making_config_map.py b/hummingbot/strategy/perpetual_market_making/perpetual_market_making_config_map.py index b18277dc9d..1040ef9c53 100644 --- a/hummingbot/strategy/perpetual_market_making/perpetual_market_making_config_map.py +++ b/hummingbot/strategy/perpetual_market_making/perpetual_market_making_config_map.py @@ -126,7 +126,7 @@ def derivative_on_validated(value: str): default="perpetual_market_making"), "derivative": ConfigVar(key="derivative", - prompt="Enter your maker derivative name >>> ", + prompt="Enter your maker derivative connector >>> ", validator=validate_derivative, on_validated=derivative_on_validated, prompt_on_new=True), diff --git a/hummingbot/strategy/pure_market_making/pure_market_making_config_map.py b/hummingbot/strategy/pure_market_making/pure_market_making_config_map.py index a8d69eec0b..7f8065b698 100644 --- a/hummingbot/strategy/pure_market_making/pure_market_making_config_map.py +++ b/hummingbot/strategy/pure_market_making/pure_market_making_config_map.py @@ -111,7 +111,7 @@ def exchange_on_validated(value: str): default="pure_market_making"), "exchange": ConfigVar(key="exchange", - prompt="Enter your maker exchange name >>> ", + prompt="Enter your maker spot connector >>> ", validator=validate_exchange, on_validated=exchange_on_validated, prompt_on_new=True), From ecd1ddaead937183d783854514e58078651a842e Mon Sep 17 00:00:00 2001 From: Kirill Stroukov Date: Sat, 20 Feb 2021 00:29:52 +0100 Subject: [PATCH 048/131] bug/Bexy fix hanged orders, fix orderbook inconsitency --- .../beaxy/beaxy_active_order_tracker.pyx | 10 ++----- .../beaxy/beaxy_api_order_book_data_source.py | 29 +++++-------------- .../exchange/beaxy/beaxy_exchange.pxd | 1 + .../exchange/beaxy/beaxy_exchange.pyx | 16 +++++++++- 4 files changed, 25 insertions(+), 31 deletions(-) diff --git a/hummingbot/connector/exchange/beaxy/beaxy_active_order_tracker.pyx b/hummingbot/connector/exchange/beaxy/beaxy_active_order_tracker.pyx index 16444e5836..9066d51b01 100644 --- a/hummingbot/connector/exchange/beaxy/beaxy_active_order_tracker.pyx +++ b/hummingbot/connector/exchange/beaxy/beaxy_active_order_tracker.pyx @@ -91,18 +91,12 @@ cdef class BeaxyActiveOrderTracker: yield [timestamp, float(price), quantity, message.update_id] elif msg_action == ACTION_DELETE: - # in case of DELETE action we need to substract the provided quantity from existing one if price not in active_rows: continue - new_quantity = active_rows[price] - quantity - if new_quantity < 0: - del active_rows[price] - yield [timestamp, float(price), float(0), message.update_id] - else: - active_rows[price] = new_quantity - yield [timestamp, float(price), new_quantity, message.update_id] + del active_rows[price] + yield [timestamp, float(price), float(0), message.update_id] elif msg_action == ACTION_DELETE_THROUGH: # Remove all levels from the specified and below (all the worst prices). diff --git a/hummingbot/connector/exchange/beaxy/beaxy_api_order_book_data_source.py b/hummingbot/connector/exchange/beaxy/beaxy_api_order_book_data_source.py index f577cca0b8..68051aa09c 100644 --- a/hummingbot/connector/exchange/beaxy/beaxy_api_order_book_data_source.py +++ b/hummingbot/connector/exchange/beaxy/beaxy_api_order_book_data_source.py @@ -3,6 +3,7 @@ import logging import aiohttp import asyncio +import json import ujson import cachetools.func from typing import Any, AsyncIterable, Optional, List, Dict @@ -229,6 +230,11 @@ async def _inner_messages( await ws.close() async def listen_for_order_book_diffs(self, ev_loop: asyncio.BaseEventLoop, output: asyncio.Queue): + # As Beaxy orderbook stream go in one ws and it need to be consistent, + # tracking is done only from listen_for_order_book_snapshots + pass + + async def listen_for_order_book_snapshots(self, ev_loop: asyncio.BaseEventLoop, output: asyncio.Queue): while True: try: # at Beaxy all pairs listed without splitter @@ -240,33 +246,12 @@ async def listen_for_order_book_diffs(self, ev_loop: asyncio.BaseEventLoop, outp async with websockets.connect(stream_url) as ws: ws: websockets.WebSocketClientProtocol = ws async for raw_msg in self._inner_messages(ws): - msg = ujson.loads(raw_msg) + msg = json.loads(raw_msg) # ujson may round floats uncorrectly msg_type = msg['type'] if msg_type == ORDERBOOK_MESSAGE_DIFF: order_book_message: OrderBookMessage = BeaxyOrderBook.diff_message_from_exchange( msg, msg['timestamp']) output.put_nowait(order_book_message) - except asyncio.CancelledError: - raise - except Exception: - self.logger().error('Unexpected error with WebSocket connection. Retrying after 30 seconds...', - exc_info=True) - await asyncio.sleep(30.0) - - async def listen_for_order_book_snapshots(self, ev_loop: asyncio.BaseEventLoop, output: asyncio.Queue): - while True: - try: - # at Beaxy all pairs listed without splitter - trading_pairs = [trading_pair_to_symbol(p) for p in self._trading_pairs] - - ws_path: str = '/'.join([f'{trading_pair}@depth20' for trading_pair in trading_pairs]) - stream_url: str = f'{BeaxyConstants.PublicApi.WS_BASE_URL}/book/{ws_path}' - - async with websockets.connect(stream_url) as ws: - ws: websockets.WebSocketClientProtocol = ws - async for raw_msg in self._inner_messages(ws): - msg = ujson.loads(raw_msg) - msg_type = msg['type'] if msg_type == ORDERBOOK_MESSAGE_SNAPSHOT: order_book_message: OrderBookMessage = BeaxyOrderBook.snapshot_message_from_exchange( msg, msg['timestamp']) diff --git a/hummingbot/connector/exchange/beaxy/beaxy_exchange.pxd b/hummingbot/connector/exchange/beaxy/beaxy_exchange.pxd index 30cee0999b..4ad2c6ede4 100644 --- a/hummingbot/connector/exchange/beaxy/beaxy_exchange.pxd +++ b/hummingbot/connector/exchange/beaxy/beaxy_exchange.pxd @@ -17,6 +17,7 @@ cdef class BeaxyExchange(ExchangeBase): object _taker_fee_percentage double _poll_interval dict _in_flight_orders + dict _order_not_found_records TransactionTracker _tx_tracker dict _trading_rules object _coro_queue diff --git a/hummingbot/connector/exchange/beaxy/beaxy_exchange.pyx b/hummingbot/connector/exchange/beaxy/beaxy_exchange.pyx index 4a7b1ace19..f09519005c 100644 --- a/hummingbot/connector/exchange/beaxy/beaxy_exchange.pyx +++ b/hummingbot/connector/exchange/beaxy/beaxy_exchange.pyx @@ -67,6 +67,7 @@ cdef class BeaxyExchange(ExchangeBase): API_CALL_TIMEOUT = 60.0 UPDATE_ORDERS_INTERVAL = 10.0 UPDATE_FEE_PERCENTAGE_INTERVAL = 60.0 + ORDER_NOT_EXIST_CONFIRMATION_COUNT = 3 @classmethod def logger(cls) -> HummingbotLogger: @@ -87,6 +88,7 @@ cdef class BeaxyExchange(ExchangeBase): self._trading_required = trading_required self._beaxy_auth = BeaxyAuth(beaxy_api_key, beaxy_secret_key) self._order_book_tracker = BeaxyOrderBookTracker(trading_pairs=trading_pairs) + self._order_not_found_records = {} self._user_stream_tracker = BeaxyUserStreamTracker(beaxy_auth=self._beaxy_auth) self._ev_loop = asyncio.get_event_loop() self._poll_notifier = asyncio.Event() @@ -321,7 +323,18 @@ cdef class BeaxyExchange(ExchangeBase): client_order_id = tracked_order.client_order_id order_update = closed_order or open_order - if not open_order and not closed_order: + if exchange_order_id or (not open_order and not closed_order): + + # Do nothing, if the order has already been cancelled or has failed + if client_order_id not in self._in_flight_orders: + continue + + 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 for a few times before actually treating + continue + self.logger().info( f'The tracked order {client_order_id} does not exist on Beaxy for last day.' f'Removing from tracking.' @@ -332,6 +345,7 @@ cdef class BeaxyExchange(ExchangeBase): OrderCancelledEvent(self._current_timestamp, client_order_id) ) self.c_stop_tracking_order(client_order_id) + del self._order_not_found_records[client_order_id] continue execute_price = Decimal(order_update['average_price'] if order_update['average_price'] else order_update['limit_price']) From e9ad1612c82ef07655d917825dfb88607ee28309 Mon Sep 17 00:00:00 2001 From: Kirill Stroukov Date: Sat, 20 Feb 2021 10:50:35 +0100 Subject: [PATCH 049/131] bug/Bexy fix empty orderbook diffs --- .../exchange/beaxy/beaxy_active_order_tracker.pyx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/hummingbot/connector/exchange/beaxy/beaxy_active_order_tracker.pyx b/hummingbot/connector/exchange/beaxy/beaxy_active_order_tracker.pyx index 9066d51b01..a4f2d4e8d1 100644 --- a/hummingbot/connector/exchange/beaxy/beaxy_active_order_tracker.pyx +++ b/hummingbot/connector/exchange/beaxy/beaxy_active_order_tracker.pyx @@ -64,6 +64,9 @@ cdef class BeaxyActiveOrderTracker: def get_rates_and_quantities(self, entry) -> tuple: return float(entry['rate']), float(entry['quantity']) + def is_entry_valid(self, entry): + return all([k in entry for k in ['side', 'action', 'price', 'quantity']]) + cdef tuple c_convert_diff_message_to_np_arrays(self, object message): """ Interpret an incoming diff message and apply changes to the order book accordingly @@ -74,6 +77,9 @@ cdef class BeaxyActiveOrderTracker: for entry in message.content['entries']: + if not self.is_entry_valid(entry): + continue + if entry['side'] != side: continue @@ -112,6 +118,9 @@ cdef class BeaxyActiveOrderTracker: del active_rows[key] yield [timestamp, float(price), float(0), message.update_id] + else: + continue + bids = np.array([r for r in diff(SIDE_BID)], dtype='float64', ndmin=2) asks = np.array([r for r in diff(SIDE_ASK)], dtype='float64', ndmin=2) @@ -135,6 +144,10 @@ cdef class BeaxyActiveOrderTracker: self._active_asks.clear() for entry in message.content['entries']: + + if not self.is_entry_valid(entry): + continue + quantity = Decimal(str(entry['quantity'])) price = Decimal(str(entry['price'])) side = entry['side'] @@ -144,7 +157,7 @@ cdef class BeaxyActiveOrderTracker: elif side == SIDE_BID: self.active_bids[price] = quantity else: - raise ValueError(f'Unknown order side for message - "{message}". Aborting.') + continue # Return the sorted snapshot tables. cdef: From 7d9f98f545c03972459c15aacb397172554e8088 Mon Sep 17 00:00:00 2001 From: Kirill Stroukov Date: Sat, 20 Feb 2021 12:29:34 +0100 Subject: [PATCH 050/131] bug/Bexy fix balance update lag --- .../exchange/beaxy/beaxy_exchange.pyx | 27 +++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/hummingbot/connector/exchange/beaxy/beaxy_exchange.pyx b/hummingbot/connector/exchange/beaxy/beaxy_exchange.pyx index f09519005c..ef52fc34eb 100644 --- a/hummingbot/connector/exchange/beaxy/beaxy_exchange.pyx +++ b/hummingbot/connector/exchange/beaxy/beaxy_exchange.pyx @@ -65,7 +65,7 @@ cdef class BeaxyExchange(ExchangeBase): MARKET_SELL_ORDER_CREATED_EVENT_TAG = MarketEvent.SellOrderCreated.value API_CALL_TIMEOUT = 60.0 - UPDATE_ORDERS_INTERVAL = 10.0 + UPDATE_ORDERS_INTERVAL = 15.0 UPDATE_FEE_PERCENTAGE_INTERVAL = 60.0 ORDER_NOT_EXIST_CONFIRMATION_COUNT = 3 @@ -315,19 +315,24 @@ cdef class BeaxyExchange(ExchangeBase): close_order_dict = {entry['order_id']: entry for entry in closed_orders} for tracked_order in tracked_orders: - exchange_order_id = await tracked_order.get_exchange_order_id() + client_order_id = tracked_order.client_order_id + + # Do nothing, if the order has already been cancelled or has failed + if client_order_id not in self._in_flight_orders: + continue + + # get last exchange_order_id with no blocking + exchange_order_id = self._in_flight_orders[client_order_id].exchange_order_id + + if exchange_order_id is None: + continue open_order = open_order_dict.get(exchange_order_id) closed_order = close_order_dict.get(exchange_order_id) - client_order_id = tracked_order.client_order_id order_update = closed_order or open_order - if exchange_order_id or (not open_order and not closed_order): - - # Do nothing, if the order has already been cancelled or has failed - if client_order_id not in self._in_flight_orders: - continue + if not open_order and not closed_order: self._order_not_found_records[client_order_id] = self._order_not_found_records.get(client_order_id, 0) + 1 @@ -336,8 +341,8 @@ cdef class BeaxyExchange(ExchangeBase): continue self.logger().info( - f'The tracked order {client_order_id} does not exist on Beaxy for last day.' - f'Removing from tracking.' + f'The tracked order {client_order_id} does not exist on Beaxy for last day. ' + f'(retried {self._order_not_found_records[client_order_id]}) Removing from tracking.' ) tracked_order.last_state = 'CLOSED' self.c_trigger_event( @@ -1000,7 +1005,7 @@ cdef class BeaxyExchange(ExchangeBase): await self._poll_notifier.wait() await safe_gather( - self._update_balances(), + # self._update_balances(), # due to balance polling inconsistency, we use only ws balance update self._update_trade_fees(), self._update_order_status(), ) From 23d81bab44fd5c1e8dc5b13c5295866f4f7ff77f Mon Sep 17 00:00:00 2001 From: Kirill Stroukov Date: Sat, 20 Feb 2021 14:01:19 +0100 Subject: [PATCH 051/131] bug/Bexy fix exchange_order_id empty --- hummingbot/connector/exchange/beaxy/beaxy_exchange.pyx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/hummingbot/connector/exchange/beaxy/beaxy_exchange.pyx b/hummingbot/connector/exchange/beaxy/beaxy_exchange.pyx index ef52fc34eb..c73ef8e8da 100644 --- a/hummingbot/connector/exchange/beaxy/beaxy_exchange.pyx +++ b/hummingbot/connector/exchange/beaxy/beaxy_exchange.pyx @@ -830,6 +830,9 @@ cdef class BeaxyExchange(ExchangeBase): if tracked_order is None: self.logger().debug(f'Didn`rt find order with id {client_order_id}') continue + + if not tracked_order.exchange_order_id: + tracked_order.exchange_order_id = exchange_order_id execute_price = s_decimal_0 execute_amount_diff = s_decimal_0 @@ -847,7 +850,6 @@ cdef class BeaxyExchange(ExchangeBase): if execute_amount_diff > s_decimal_0: self.logger().info(f'Filled {execute_amount_diff} out of {tracked_order.amount} of the ' f'{tracked_order.order_type_description} order {tracked_order.client_order_id}') - exchange_order_id = tracked_order.exchange_order_id self.c_trigger_event(self.MARKET_ORDER_FILLED_EVENT_TAG, OrderFilledEvent( From 5229dc159cd75a771cdeec3d695b2a5115ca9f42 Mon Sep 17 00:00:00 2001 From: Daniel Tan Date: Sun, 21 Feb 2021 02:25:10 +0800 Subject: [PATCH 052/131] (fix) trades not being process adequetely due to Decimal conversion bug --- hummingbot/connector/exchange/probit/probit_exchange.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/hummingbot/connector/exchange/probit/probit_exchange.py b/hummingbot/connector/exchange/probit/probit_exchange.py index c05d6cf996..30fc2d6038 100644 --- a/hummingbot/connector/exchange/probit/probit_exchange.py +++ b/hummingbot/connector/exchange/probit/probit_exchange.py @@ -674,6 +674,10 @@ async def _update_order_status(self): min_ts: float = float("inf") for order_update in order_results: + if isinstance(order_update, Exception): + raise order_update + + # Order Creation Time order_ts: float = probit_utils.convert_iso_to_epoch(order_update["data"]["time"]) if order_ts < min_ts: @@ -752,6 +756,10 @@ def _process_trade_message(self, order_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. """ + # Only process trade when trade fees have been accounted for; when trade status is "settled". + if order_msg["status"] != "settled": + return + ex_order_id = order_msg["order_id"] client_order_id = None From 3cb6cca36d1f04f0c7aa1b0e5a430675313020dc Mon Sep 17 00:00:00 2001 From: Daniel Tan Date: Sun, 21 Feb 2021 18:41:08 +0800 Subject: [PATCH 053/131] (fix) INVALID_JSON error in _update_order_status --- .../exchange/probit/probit_exchange.py | 31 ++++++++++--------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/hummingbot/connector/exchange/probit/probit_exchange.py b/hummingbot/connector/exchange/probit/probit_exchange.py index 30fc2d6038..a78d3d3f76 100644 --- a/hummingbot/connector/exchange/probit/probit_exchange.py +++ b/hummingbot/connector/exchange/probit/probit_exchange.py @@ -660,7 +660,7 @@ async def _update_order_status(self): "order_id": ex_order_id } - tasks.append(self._api_request(method="POST", + tasks.append(self._api_request(method="GET", path_url=CONSTANTS.ORDER_URL, params=query_params, is_auth_required=True) @@ -678,11 +678,11 @@ async def _update_order_status(self): raise order_update # Order Creation Time - order_ts: float = probit_utils.convert_iso_to_epoch(order_update["data"]["time"]) - - if order_ts < min_ts: - min_order_ts = order_update["data"]["time"] - min_ts = order_ts + for update in order_update["data"]: + order_ts: float = probit_utils.convert_iso_to_epoch(update["time"]) + if order_ts < min_ts: + min_order_ts = update["time"] + min_ts = order_ts trade_history_tasks = [] for trading_pair in self._trading_pairs: @@ -694,7 +694,9 @@ async def _update_order_status(self): } trade_history_tasks.append(self._api_request( method="GET", - path_url=CONSTANTS.TRADE_HISTORY_URL + path_url=CONSTANTS.TRADE_HISTORY_URL, + params=query_params, + is_auth_required=True )) trade_history_results: List[Dict[str, Any]] = await safe_gather(*trade_history_tasks, return_exceptions=True) @@ -709,16 +711,15 @@ async def _update_order_status(self): for trade in trade_details: self._process_trade_message(trade) - for update_result in order_results: - if isinstance(update_result, Exception): - raise update_result - if "data" not in update_result: - self.logger().info(f"_update_order_status data not in resp: {update_result}") + for order_update in order_results: + if isinstance(order_update, Exception): + raise order_update + if "data" not in order_update: + self.logger().info(f"_update_order_status data not in resp: {order_update}") continue - order_details: List[Dict[str, Any]] = update_result["data"] - for order in order_details: - self._process_order_message(order_details) + for order in order_update["data"]: + self._process_order_message(order) def _process_order_message(self, order_msg: Dict[str, Any]): """ From 9f24cffa8c2fcc953a509c9608156300cc02e494 Mon Sep 17 00:00:00 2001 From: Nicolas Baum Date: Mon, 22 Feb 2021 03:26:44 -0300 Subject: [PATCH 054/131] (feat) Added RingBuffer class for strategies --- hummingbot/strategy/utils/__init__.py | 0 hummingbot/strategy/utils/ring_buffer.pxd | 21 +++++ hummingbot/strategy/utils/ring_buffer.pyx | 105 ++++++++++++++++++++++ test/test_ring_buffer.py | 97 ++++++++++++++++++++ 4 files changed, 223 insertions(+) create mode 100644 hummingbot/strategy/utils/__init__.py create mode 100644 hummingbot/strategy/utils/ring_buffer.pxd create mode 100644 hummingbot/strategy/utils/ring_buffer.pyx create mode 100644 test/test_ring_buffer.py diff --git a/hummingbot/strategy/utils/__init__.py b/hummingbot/strategy/utils/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/hummingbot/strategy/utils/ring_buffer.pxd b/hummingbot/strategy/utils/ring_buffer.pxd new file mode 100644 index 0000000000..72c006bdb5 --- /dev/null +++ b/hummingbot/strategy/utils/ring_buffer.pxd @@ -0,0 +1,21 @@ +import numpy as np +from libc.stdint cimport int64_t +cimport numpy as np + +cdef class RingBuffer: + cdef: + np.float64_t[:] _buffer + int64_t _start_index + int64_t _stop_index + int64_t _length + bint _is_full + + cdef void c_add_value(self, float val) + cdef void c_increment_index(self) + cdef double c_get_last_value(self) + cdef bint c_is_full(self) + cdef bint c_is_empty(self) + cdef double c_mean_value(self) + cdef double c_variance(self) + cdef double c_std_dev(self) + cdef np.ndarray[np.double_t, ndim=1] c_get_as_numpy_array(self) diff --git a/hummingbot/strategy/utils/ring_buffer.pyx b/hummingbot/strategy/utils/ring_buffer.pyx new file mode 100644 index 0000000000..1cf98f6350 --- /dev/null +++ b/hummingbot/strategy/utils/ring_buffer.pyx @@ -0,0 +1,105 @@ +import numpy as np +import logging +cimport numpy as np + + +pmm_logger = None + +cdef class RingBuffer: + @classmethod + def logger(cls): + global pmm_logger + if pmm_logger is None: + pmm_logger = logging.getLogger(__name__) + return pmm_logger + + def __cinit__(self, int length): + self._length = length + self._buffer = np.zeros(length, dtype=np.float64) + self._start_index = 0 + self._stop_index = 0 + self._is_full = False + + def __dealloc__(self): + self._buffer = None + + cdef void c_add_value(self, float val): + self._buffer[self._stop_index] = val + self.c_increment_index() + + cdef void c_increment_index(self): + self._stop_index = (self._stop_index + 1) % self._length + if(self._start_index == self._stop_index): + self._is_full = True + self._start_index = (self._start_index + 1) % self._length + + cdef bint c_is_empty(self): + return (not self._is_full) and (self._start_index==self._stop_index) + + cdef double c_get_last_value(self): + if self.c_is_empty(): + return np.nan + return self._buffer[self._stop_index-1] + + cdef bint c_is_full(self): + return self._is_full + + cdef double c_mean_value(self): + result = np.nan + if self._is_full: + result=np.mean(self.c_get_as_numpy_array()) + return result + + cdef double c_variance(self): + result = np.nan + if self._is_full: + result = np.var(self.c_get_as_numpy_array()) + return result + + cdef double c_std_dev(self): + result = np.nan + if self._is_full: + result = np.std(self.c_get_as_numpy_array()) + return result + + cdef np.ndarray[np.double_t, ndim=1] c_get_as_numpy_array(self): + cdef np.ndarray[np.int16_t, ndim=1] indexes + + if not self._is_full: + indexes = np.arange(self._start_index, stop=self._stop_index, dtype=np.int16) + else: + indexes = np.arange(self._start_index, stop=self._start_index + self._length, + dtype=np.int16) % self._length + return np.asarray(self._buffer)[indexes] + + def __init__(self, length): + self._length = length + self._buffer = np.zeros(length, dtype=np.double) + self._start_index = 0 + self._stop_index = 0 + self._is_full = False + + def add_value(self, val): + self.c_add_value(val) + + def get_as_numpy_array(self): + return self.c_get_as_numpy_array() + + def get_last_value(self): + return self.c_get_last_value() + + @property + def is_full(self): + return self.c_is_full() + + @property + def mean_value(self): + return self.c_mean_value() + + @property + def std_dev(self): + return self.c_std_dev() + + @property + def variance(self): + return self.c_variance() diff --git a/test/test_ring_buffer.py b/test/test_ring_buffer.py new file mode 100644 index 0000000000..907296fbcb --- /dev/null +++ b/test/test_ring_buffer.py @@ -0,0 +1,97 @@ +import unittest +from hummingbot.strategy.utils.ring_buffer import RingBuffer +import numpy as np +from decimal import Decimal + + +class RingBufferTest(unittest.TestCase): + BUFFER_LENGTH = 30 + + def setUp(self) -> None: + self.buffer = RingBuffer(self.BUFFER_LENGTH) + + def fill_buffer_with_zeros(self): + for i in range(self.BUFFER_LENGTH): + self.buffer.add_value(0) + + def test_add_value(self): + self.buffer.add_value(1) + self.assertEqual(self.buffer.get_as_numpy_array().size, 1) + + def test_is_full(self): + self.assertFalse(self.buffer.is_full) # Current occupation = 0 + self.buffer.add_value(1) + self.assertFalse(self.buffer.is_full) # Current occupation = 1 + for i in range(self.BUFFER_LENGTH - 2): + self.buffer.add_value(i) + self.assertFalse(self.buffer.is_full) # Current occupation = BUFFER_LENGTH-1 + self.buffer.add_value(1) + self.assertTrue(self.buffer.is_full) # Current occupation = BUFFER_LENGTH + + def test_add_when_full(self): + for i in range(self.BUFFER_LENGTH): + self.buffer.add_value(1) + self.assertTrue(self.buffer.is_full) + # Filled with ones, total sum equals BUFFER_LENGTH + self.assertEqual(np.sum(self.buffer.get_as_numpy_array()), self.BUFFER_LENGTH) + # Add zeros till length/2 check total sum has decreased accordingly + mid_point = self.BUFFER_LENGTH // 2 + for i in range(mid_point): + self.buffer.add_value(0) + self.assertEqual(np.sum(self.buffer.get_as_numpy_array()), self.BUFFER_LENGTH - mid_point) + # Add remaining zeros to complete length, sum should go to zero + for i in range(self.BUFFER_LENGTH - mid_point): + self.buffer.add_value(0) + self.assertEqual(np.sum(self.buffer.get_as_numpy_array()), 0) + + def test_mean(self): + # When not full, mean=nan + self.assertTrue(np.isnan(self.buffer.mean_value)) + for i in range(self.BUFFER_LENGTH // 2): + self.buffer.add_value(1) + # Still not full, mean=nan + self.assertTrue(np.isnan(self.buffer.mean_value)) + for i in range(self.BUFFER_LENGTH - self.BUFFER_LENGTH // 2): + self.buffer.add_value(1) + # Once full, mean != nan + self.assertEqual(self.buffer.mean_value, 1.0) + + def test_mean_with_alternated_samples(self): + for i in range(self.BUFFER_LENGTH * 3): + self.buffer.add_value(2 * ((-1) ** i)) + if self.buffer.is_full: + self.assertEqual(self.buffer.mean_value, 0) + + def test_std_dev_and_variance(self): + # When not full, stddev=var=nan + self.assertTrue(np.isnan(self.buffer.std_dev)) + self.assertTrue(np.isnan(self.buffer.variance)) + for i in range(self.BUFFER_LENGTH // 2): + self.buffer.add_value(1) + # Still not full, stddev=var=nan + self.assertTrue(np.isnan(self.buffer.std_dev)) + self.assertTrue(np.isnan(self.buffer.variance)) + for i in range(self.BUFFER_LENGTH - self.BUFFER_LENGTH // 2): + self.buffer.add_value(1) + # Once full, std_dev = variance = 0 in this case + self.assertEqual(self.buffer.std_dev, 0) + self.assertEqual(self.buffer.variance, 0) + + def test_std_dev_and_variance_with_alternated_samples(self): + for i in range(self.BUFFER_LENGTH * 3): + self.buffer.add_value(2 * ((-1)**i)) + if self.buffer.is_full: + self.assertEqual(self.buffer.std_dev, 2) + self.assertEqual(self.buffer.variance, 4) + + def test_get_last_value(self): + self.assertTrue(np.isnan(self.buffer.get_last_value())) + expected_values = [-2, -1.0, 0, 3, 1e10] + for value in expected_values: + self.buffer.add_value(value) + self.assertEqual(self.buffer.get_last_value(), value) + + # Decimals are casted when added to numpy array as np.float64. No exact match + value = Decimal(3.141592653) + self.buffer.add_value(value) + self.assertAlmostEqual(float(value), self.buffer.get_last_value(), 6) From 46087dea34adcb9bfec69aded116a5656ed23772 Mon Sep 17 00:00:00 2001 From: Nicolas Baum Date: Mon, 22 Feb 2021 03:47:57 -0300 Subject: [PATCH 055/131] Moved utils folder to avoid ambiguation with stragies folders --- hummingbot/strategy/{utils => __utils__}/__init__.py | 0 hummingbot/strategy/{utils => __utils__}/ring_buffer.pxd | 0 hummingbot/strategy/{utils => __utils__}/ring_buffer.pyx | 0 test/test_ring_buffer.py | 2 +- 4 files changed, 1 insertion(+), 1 deletion(-) rename hummingbot/strategy/{utils => __utils__}/__init__.py (100%) rename hummingbot/strategy/{utils => __utils__}/ring_buffer.pxd (100%) rename hummingbot/strategy/{utils => __utils__}/ring_buffer.pyx (100%) diff --git a/hummingbot/strategy/utils/__init__.py b/hummingbot/strategy/__utils__/__init__.py similarity index 100% rename from hummingbot/strategy/utils/__init__.py rename to hummingbot/strategy/__utils__/__init__.py diff --git a/hummingbot/strategy/utils/ring_buffer.pxd b/hummingbot/strategy/__utils__/ring_buffer.pxd similarity index 100% rename from hummingbot/strategy/utils/ring_buffer.pxd rename to hummingbot/strategy/__utils__/ring_buffer.pxd diff --git a/hummingbot/strategy/utils/ring_buffer.pyx b/hummingbot/strategy/__utils__/ring_buffer.pyx similarity index 100% rename from hummingbot/strategy/utils/ring_buffer.pyx rename to hummingbot/strategy/__utils__/ring_buffer.pyx diff --git a/test/test_ring_buffer.py b/test/test_ring_buffer.py index 907296fbcb..6340c9a584 100644 --- a/test/test_ring_buffer.py +++ b/test/test_ring_buffer.py @@ -1,5 +1,5 @@ import unittest -from hummingbot.strategy.utils.ring_buffer import RingBuffer +from hummingbot.strategy.__utils__.ring_buffer import RingBuffer import numpy as np from decimal import Decimal From 27eeac9f852928af7e1f32e218ed4a933f838890 Mon Sep 17 00:00:00 2001 From: Kirill Stroukov Date: Mon, 22 Feb 2021 11:07:49 +0100 Subject: [PATCH 056/131] bug/Bexy fix error message --- hummingbot/connector/exchange/beaxy/beaxy_exchange.pyx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/hummingbot/connector/exchange/beaxy/beaxy_exchange.pyx b/hummingbot/connector/exchange/beaxy/beaxy_exchange.pyx index c73ef8e8da..cb77ddb30f 100644 --- a/hummingbot/connector/exchange/beaxy/beaxy_exchange.pyx +++ b/hummingbot/connector/exchange/beaxy/beaxy_exchange.pyx @@ -691,7 +691,7 @@ cdef class BeaxyExchange(ExchangeBase): self.logger().network( 'Unexpected error cancelling orders.', exc_info=True, - app_warning_msg='Failed to cancel order on Coinbase Pro. Check API key and network connection.' + app_warning_msg='Failed to cancel order on Beaxy exchange. Check API key and network connection.' ) failed_cancellations = [CancellationResult(oid, False) for oid in order_id_set] @@ -830,7 +830,7 @@ cdef class BeaxyExchange(ExchangeBase): if tracked_order is None: self.logger().debug(f'Didn`rt find order with id {client_order_id}') continue - + if not tracked_order.exchange_order_id: tracked_order.exchange_order_id = exchange_order_id From 747df25340b065b032ecb2b64c5c94634de8b4d5 Mon Sep 17 00:00:00 2001 From: vic-en Date: Mon, 22 Feb 2021 13:28:32 +0100 Subject: [PATCH 057/131] (feat) hardcode perpetual finance along other gateway connectors --- hummingbot/client/hummingbot_application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hummingbot/client/hummingbot_application.py b/hummingbot/client/hummingbot_application.py index c64d0b3ccb..3566917232 100644 --- a/hummingbot/client/hummingbot_application.py +++ b/hummingbot/client/hummingbot_application.py @@ -241,7 +241,7 @@ def _initialize_markets(self, market_names: List[Tuple[str, List[str]]]): if conn_setting.use_ethereum_wallet: ethereum_rpc_url = global_config_map.get("ethereum_rpc_url").value # Todo: Hard coded this execption for now until we figure out how to handle all ethereum connectors. - if connector_name in ["balancer", "uniswap"]: + if connector_name in ["balancer", "uniswap", "perpetual_finance"]: private_key = get_eth_wallet_private_key() init_params.update(wallet_private_key=private_key, ethereum_rpc_url=ethereum_rpc_url) else: From c3b895b1b71bf8ed349d1d29722be296c0da1b39 Mon Sep 17 00:00:00 2001 From: vic-en Date: Mon, 22 Feb 2021 13:30:57 +0100 Subject: [PATCH 058/131] (feat) add XDAI to balance command --- hummingbot/client/command/balance_command.py | 20 ++++++++++++++++++-- hummingbot/strategy/strategy_base.pyx | 3 ++- hummingbot/user/user_balances.py | 10 ++++++++++ 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/hummingbot/client/command/balance_command.py b/hummingbot/client/command/balance_command.py index cd4a1c0508..171485ee0a 100644 --- a/hummingbot/client/command/balance_command.py +++ b/hummingbot/client/command/balance_command.py @@ -110,11 +110,17 @@ async def show_balances(self): eth_address = global_config_map["ethereum_wallet"].value if eth_address is not None: - df = await self.ethereum_balances_df() - lines = [" " + line for line in df.to_string(index=False).split("\n")] + eth_df = await self.ethereum_balances_df() + lines = [" " + line for line in eth_df.to_string(index=False).split("\n")] self._notify("\nethereum:") self._notify("\n".join(lines)) + # XDAI balances + xdai_df = await self.xdai_balances_df() + lines = [" " + line for line in xdai_df.to_string(index=False).split("\n")] + self._notify("\nxdai:") + self._notify("\n".join(lines)) + async def exchange_balances_df(self, # type: HummingbotApplication exchange_balances: Dict[str, Decimal], exchange_limits: Dict[str, str]): @@ -177,6 +183,16 @@ async def ethereum_balances_df(self, # type: HummingbotApplication df.sort_values(by=["Asset"], inplace=True) return df + async def xdai_balances_df(self, # type: HummingbotApplication + ): + rows = [] + bals = await UserBalances.xdai_balances() + for token, bal in bals.items(): + rows.append({"Asset": token, "Amount": round(bal, 4)}) + df = pd.DataFrame(data=rows, columns=["Asset", "Amount"]) + df.sort_values(by=["Asset"], inplace=True) + return df + async def asset_limits_df(self, asset_limit_conf: Dict[str, str]): rows = [] diff --git a/hummingbot/strategy/strategy_base.pyx b/hummingbot/strategy/strategy_base.pyx index d214b288e8..610cb5eb80 100755 --- a/hummingbot/strategy/strategy_base.pyx +++ b/hummingbot/strategy/strategy_base.pyx @@ -19,6 +19,7 @@ from hummingbot.core.event.events import ( ) from .order_tracker import OrderTracker +from hummingbot.connector.derivative_base import DerivativeBase NaN = float("nan") s_decimal_nan = Decimal("NaN") @@ -213,7 +214,7 @@ cdef class StrategyBase(TimeIterator): for market_trading_pair_tuple in market_trading_pair_tuples: base_balance = market_trading_pair_tuple.market.get_balance(market_trading_pair_tuple.base_asset) quote_balance = market_trading_pair_tuple.market.get_balance(market_trading_pair_tuple.quote_asset) - if base_balance <= Decimal("0.0001"): + if base_balance <= Decimal("0.0001") and not isinstance(market_trading_pair_tuple.market, DerivativeBase): warning_lines.append(f" {market_trading_pair_tuple.market.name} market " f"{market_trading_pair_tuple.base_asset} balance is too low. Cannot place order.") if quote_balance <= Decimal("0.0001"): diff --git a/hummingbot/user/user_balances.py b/hummingbot/user/user_balances.py index 326a80549e..b9e6e313c2 100644 --- a/hummingbot/user/user_balances.py +++ b/hummingbot/user/user_balances.py @@ -5,6 +5,7 @@ from hummingbot.core.utils.async_utils import safe_gather from hummingbot.client.config.global_config_map import global_config_map from hummingbot.connector.connector.balancer.balancer_connector import BalancerConnector +from hummingbot.connector.derivative.perpetual_finance.perpetual_finance_derivative import PerpetualFinanceDerivative from hummingbot.client.settings import ethereum_required_trading_pairs from typing import Optional, Dict, List from decimal import Decimal @@ -122,6 +123,15 @@ async def eth_n_erc20_balances() -> Dict[str, Decimal]: await connector._update_balances() return connector.get_all_balances() + @staticmethod + async def xdai_balances() -> Dict[str, Decimal]: + connector = PerpetualFinanceDerivative("", + get_eth_wallet_private_key(), + "", + True) + await connector._update_balances() + return connector.get_all_balances() + @staticmethod def validate_ethereum_wallet() -> Optional[str]: if global_config_map.get("ethereum_wallet").value is None: From 26c51ff2d66d208b452679e9aac7a100218e9f8a Mon Sep 17 00:00:00 2001 From: vic-en Date: Mon, 22 Feb 2021 13:32:47 +0100 Subject: [PATCH 059/131] (fix) fix errors related to funding_info in derivative connectors --- .../binance_perpetual_derivative.py | 47 ++++++++----------- .../perpetual_finance_derivative.py | 33 +++++++++---- hummingbot/connector/derivative_base.py | 2 +- hummingbot/model/funding_payment.py | 4 +- 4 files changed, 45 insertions(+), 41 deletions(-) diff --git a/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_derivative.py b/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_derivative.py index f2d4359d35..8a612e7890 100644 --- a/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_derivative.py +++ b/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_derivative.py @@ -129,8 +129,7 @@ def __init__(self, self._order_not_found_records = {} self._last_timestamp = 0 self._trading_rules = {} - # self._trade_fees = {} - # self._last_update_trade_fees_timestamp = 0 + self._trading_pairs = trading_pairs self._status_polling_task = None self._user_stream_event_listener_task = None self._trading_rules_polling_task = None @@ -161,9 +160,7 @@ def status_dict(self): "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 + "funding_info": len(self._funding_info) > 0 } @property @@ -630,8 +627,8 @@ def get_order_book(self, trading_pair: str) -> OrderBook: 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 + last_tick = int(self._last_timestamp / 60.0) + current_tick = int(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) @@ -674,11 +671,8 @@ async def _trading_rules_polling_loop(self): try: await safe_gather( self._update_trading_rules() - - # TODO: Uncomment when implemented - # self._update_trade_fees() ) - await asyncio.sleep(60) + await asyncio.sleep(3600) except asyncio.CancelledError: raise except Exception: @@ -692,25 +686,22 @@ async def _funding_info_polling_loop(self): try: ws_subscription_path: str = "/".join([f"{convert_to_exchange_trading_pair(trading_pair).lower()}@markPrice" for trading_pair in self._trading_pairs]) - stream_url: str = f"{self._stream_url}?streams={ws_subscription_path}" + stream_url: str = f"{self._stream_url}/stream?streams={ws_subscription_path}" async with websockets.connect(stream_url) as ws: ws: websockets.WebSocketClientProtocol = ws - try: - while True: - try: - raw_msg: str = await asyncio.wait_for(ws.recv(), timeout=10.0) - msg = ujson.loads(raw_msg) - trading_pair = msg["s"] - self._funding_info[trading_pair] = {"indexPrice": msg["i"], - "markPrice": msg["p"], - "nextFundingTime": msg["T"], - "rate": msg["r"]} - except asyncio.TimeoutError: - await ws.pong(data=b'') - except ConnectionClosed: - continue - finally: - await ws.close() + while True: + try: + raw_msg: str = await asyncio.wait_for(ws.recv(), timeout=10.0) + msg = ujson.loads(raw_msg) + trading_pair = convert_from_exchange_trading_pair(msg["data"]["s"]) + self._funding_info[trading_pair] = {"indexPrice": msg["data"]["i"], + "markPrice": msg["data"]["p"], + "nextFundingTime": msg["data"]["T"], + "rate": msg["data"]["r"]} + except asyncio.TimeoutError: + await ws.pong(data=b'') + except ConnectionClosed: + raise except asyncio.CancelledError: raise except Exception: diff --git a/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py index 9323d76967..48b86ea17f 100644 --- a/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py +++ b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py @@ -63,13 +63,12 @@ def logger(cls) -> HummingbotLogger: def __init__(self, trading_pairs: List[str], wallet_private_key: str, - ethereum_rpc_url: str, + ethereum_rpc_url: str, # not used, but left in place to be consistent with other gateway connectors trading_required: bool = True ): """ :param trading_pairs: a list of trading pairs :param wallet_private_key: a private key for eth wallet - :param ethereum_rpc_url: this is usually infura RPC URL :param trading_required: Whether actual trading is needed. """ super().__init__() @@ -407,13 +406,15 @@ def has_allowances(self) -> bool: def status_dict(self) -> Dict[str, bool]: return { "account_balance": len(self._account_balances) > 0 if self._trading_required else True, - "allowances": self.has_allowances() if self._trading_required else True + "allowances": self.has_allowances() if self._trading_required else True, + "funding_info": len(self._funding_info) > 0 } async def start_network(self): if self._trading_required: self._status_polling_task = safe_ensure_future(self._status_polling_loop()) self._auto_approve_task = safe_ensure_future(self.auto_approve()) + self._funding_info_polling_task = safe_ensure_future(self._funding_info_polling_loop()) async def stop_network(self): if self._status_polling_task is not None: @@ -422,6 +423,9 @@ async def stop_network(self): if self._auto_approve_task is not None: self._auto_approve_task.cancel() self._auto_approve_task = None + if self._funding_info_polling_task is not None: + self._funding_info_polling_task.cancel() + self._funding_info_polling_task = None async def check_network(self) -> NetworkStatus: try: @@ -491,7 +495,6 @@ async def _update_balances(self): async def _update_positions(self): position_tasks = [] funding_payment_tasks = [] - funding_info_tasks = [] for pair in self._trading_pairs: position_tasks.append(self._api_request("post", "perpfi/position", @@ -499,12 +502,8 @@ async def _update_positions(self): funding_payment_tasks.append(self._api_request("get", "perpfi/funding_payment", {"pair": convert_to_exchange_trading_pair(pair)})) - funding_info_tasks.append(self._api_request("get", - "perpfi/funding", - {"pair": convert_to_exchange_trading_pair(pair)})) positions = await safe_gather(*position_tasks, return_exceptions=True) funding_payments = await safe_gather(*funding_payment_tasks, return_exceptions=True) - funding_infos = await safe_gather(*funding_info_tasks, return_exceptions=True) for trading_pair, position in zip(self._trading_pairs, positions): position = position.get("position", {}) position_side = PositionSide.LONG if position.get("size", 0) > 0 else PositionSide.SHORT @@ -537,8 +536,22 @@ async def _update_positions(self): symbol=trading_pair, amount=payment)) - for trading_pair, funding_info in zip(self._trading_pairs, funding_infos): - self._funding_info[trading_pair] = funding_info["fr"] + async def _funding_info_polling_loop(self): + while True: + try: + funding_info_tasks = [] + for pair in self._trading_pairs: + funding_info_tasks.append(self._api_request("post", + "perpfi/funding", + {"pair": convert_to_exchange_trading_pair(pair)})) + funding_infos = await safe_gather(*funding_info_tasks, return_exceptions=True) + for trading_pair, funding_info in zip(self._trading_pairs, funding_infos): + self._funding_info[trading_pair] = funding_info["fr"] + except Exception: + self.logger().network("Unexpected error while fetching funding info.", exc_info=True, + app_warning_msg="Could not fetch new funding info from Perpetual Finance protocol. " + "Check network connection on gateway.") + await asyncio.sleep(30) def get_funding_info(self, trading_pair): return self._funding_info[trading_pair] diff --git a/hummingbot/connector/derivative_base.py b/hummingbot/connector/derivative_base.py index 5bd2667e33..cd8800f073 100644 --- a/hummingbot/connector/derivative_base.py +++ b/hummingbot/connector/derivative_base.py @@ -18,7 +18,7 @@ def __init__(self): self._funding_info = {} self._account_positions = {} self._position_mode = None - self._leverage = 1 + self._leverage = {} self._funding_payment_span = [0, 0] # time span(in seconds) before and after funding period when exchanges consider active positions eligible for funding payment def set_position_mode(self, position_mode: PositionMode): diff --git a/hummingbot/model/funding_payment.py b/hummingbot/model/funding_payment.py index 99faa771a6..cd11bf1dec 100644 --- a/hummingbot/model/funding_payment.py +++ b/hummingbot/model/funding_payment.py @@ -21,9 +21,9 @@ class FundingPayment(HummingbotBase): __tablename__ = "FundingPayment" - __table_args__ = (Index("tf_config_timestamp_index", + __table_args__ = (Index("fp_config_timestamp_index", "config_file_path", "timestamp"), - Index("tf_market_trading_pair_timestamp_index", + Index("fp_market_trading_pair_timestamp_index", "market", "symbol", "timestamp") ) From 7e7a3e95350e63d8ce8270e1de43790398fd30a6 Mon Sep 17 00:00:00 2001 From: vic-en Date: Mon, 22 Feb 2021 13:52:48 +0100 Subject: [PATCH 060/131] (feat) add spot-perp arbitrage strategy --- .../spot_perpetual_arbitrage/__init__.py | 0 .../spot_perpetual_arbitrage/arb_proposal.py | 125 +++++ .../spot_perpetual_arbitrage/dummy.pxd | 2 + .../spot_perpetual_arbitrage/dummy.pyx | 2 + .../spot_perpetual_arbitrage.py | 497 ++++++++++++++++++ .../spot_perpetual_arbitrage_config_map.py | 125 +++++ .../spot_perpetual_arbitrage/start.py | 37 ++ .../spot_perpetual_arbitrage/utils.py | 44 ++ ..._perpetual_arbitrage_strategy_TEMPLATE.yml | 37 ++ 9 files changed, 869 insertions(+) create mode 100644 hummingbot/strategy/spot_perpetual_arbitrage/__init__.py create mode 100644 hummingbot/strategy/spot_perpetual_arbitrage/arb_proposal.py create mode 100644 hummingbot/strategy/spot_perpetual_arbitrage/dummy.pxd create mode 100644 hummingbot/strategy/spot_perpetual_arbitrage/dummy.pyx create mode 100644 hummingbot/strategy/spot_perpetual_arbitrage/spot_perpetual_arbitrage.py create mode 100644 hummingbot/strategy/spot_perpetual_arbitrage/spot_perpetual_arbitrage_config_map.py create mode 100644 hummingbot/strategy/spot_perpetual_arbitrage/start.py create mode 100644 hummingbot/strategy/spot_perpetual_arbitrage/utils.py create mode 100644 hummingbot/templates/conf_spot_perpetual_arbitrage_strategy_TEMPLATE.yml diff --git a/hummingbot/strategy/spot_perpetual_arbitrage/__init__.py b/hummingbot/strategy/spot_perpetual_arbitrage/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/hummingbot/strategy/spot_perpetual_arbitrage/arb_proposal.py b/hummingbot/strategy/spot_perpetual_arbitrage/arb_proposal.py new file mode 100644 index 0000000000..d6c78cffff --- /dev/null +++ b/hummingbot/strategy/spot_perpetual_arbitrage/arb_proposal.py @@ -0,0 +1,125 @@ +from decimal import Decimal +from hummingbot.core.utils.async_utils import safe_gather +from hummingbot.strategy.market_trading_pair_tuple import MarketTradingPairTuple + +s_decimal_nan = Decimal("NaN") +s_decimal_0 = Decimal("0") + + +class ArbProposalSide: + """ + An arbitrage proposal side which contains info needed for order submission. + """ + def __init__(self, + market_info: MarketTradingPairTuple, + is_buy: bool, + order_price: Decimal, + amount: Decimal + ): + """ + :param market_info: The market where to submit the order + :param is_buy: True if buy order + :param quote_price: The quote price (for an order amount) from the market + :param order_price: The price required for order submission, this could differ from the quote price + :param amount: The order amount + """ + self.market_info: MarketTradingPairTuple = market_info + self.is_buy: bool = is_buy + self.order_price: Decimal = order_price + self.amount: Decimal = amount + + def __repr__(self): + side = "Buy" if self.is_buy else "Sell" + base, quote = self.market_info.trading_pair.split("-") + return f"{self.market_info.market.display_name.capitalize()}: {side} {self.amount} {base}" \ + f" at {self.order_price} {quote}." + + +class ArbProposal: + """ + An arbitrage proposal which contains 2 sides of the proposal - one buy and one sell. + """ + def __init__(self, + spot_market_info: MarketTradingPairTuple, + derivative_market_info: MarketTradingPairTuple, + order_amount: Decimal, + timestamp: float): + self.spot_market_info: MarketTradingPairTuple = spot_market_info + self.derivative_market_info: MarketTradingPairTuple = derivative_market_info + self.spot_side: ArbProposalSide = None + self.derivative_side: ArbProposalSide = None + self.amount: Decimal = order_amount + self.timestamp: float = timestamp + self.spot_buy_sell_prices = [0, 0] + self.deriv_buy_sell_prices = [0, 0] + + async def update_prices(self): + """ + Update the buy and sell prices for both spot and deriv connectors. + """ + tasks = [self.spot_market_info.market.get_order_price(self.spot_market_info.trading_pair, True, self.amount), + self.spot_market_info.market.get_order_price(self.spot_market_info.trading_pair, False, self.amount), + self.derivative_market_info.market.get_order_price(self.derivative_market_info.trading_pair, True, self.amount), + self.derivative_market_info.market.get_order_price(self.derivative_market_info.trading_pair, False, self.amount)] + + prices = await safe_gather(*tasks, return_exceptions=True) + self.spot_buy_sell_prices = [prices[0], prices[1]] + self.deriv_buy_sell_prices = [prices[2], prices[3]] + + def is_funding_payment_time(self): + """ + Check if it's time for funding payment. + Return True if it's time for funding payment else False. + """ + funding_info = self.derivative_market_info.market.get_funding_info(self.derivative_market_info.trading_pair) + funding_payment_span = self.derivative_market_info.market._funding_payment_span + if self.timestamp > (funding_info["nextFundingTime"] - funding_payment_span[0]) and \ + self.timestamp < (funding_info["nextFundingTime"] + funding_payment_span[1]): + return True + else: + return False + + async def proposed_spot_deriv_arb(self): + """ + Determine if the current situation is contango or backwardation and return a pair of buy and sell prices accordingly. + """ + await self.update_prices() + if (sum(self.spot_buy_sell_prices) / 2) > (sum(self.deriv_buy_sell_prices) / 2): # Backwardation + self.spot_side = ArbProposalSide(self.spot_market_info, False, + self.spot_buy_sell_prices[1], + self.amount) + self.derivative_side = ArbProposalSide(self.derivative_market_info, True, + self.deriv_buy_sell_prices[0], + self.amount) + return (self.spot_side, self.derivative_side) + else: # Contango + self.spot_side = ArbProposalSide(self.spot_market_info, True, + self.spot_buy_sell_prices[0], + self.amount) + self.derivative_side = ArbProposalSide(self.derivative_market_info, False, + self.deriv_buy_sell_prices[1], + self.amount) + return (self.spot_side, self.derivative_side) + + def alternate_proposal_sides(self): + """ + Alternate the sides and prices of proposed spot and derivative arb. + """ + if self.spot_side.is_buy: + self.spot_side.is_buy = False + self.spot_side.order_price = self.spot_buy_sell_prices[1] + self.derivative_side.is_buy = True + self.derivative_side.order_price = self.deriv_buy_sell_prices[0] + else: + self.spot_side.is_buy = True + self.spot_side.order_price = self.spot_buy_sell_prices[0] + self.derivative_side.is_buy = False + self.derivative_side.order_price = self.deriv_buy_sell_prices[1] + return (self.spot_side, self.derivative_side) + + def spread(self): + spread = abs(self.spot_side.order_price - self.derivative_side.order_price) / min(self.spot_side.order_price, self.derivative_side.order_price) + return Decimal(str(spread)) + + def __repr__(self): + return f"Spot - {self.spot_market_info.market}\nDerivative - {self.derivative_market_info.market}" diff --git a/hummingbot/strategy/spot_perpetual_arbitrage/dummy.pxd b/hummingbot/strategy/spot_perpetual_arbitrage/dummy.pxd new file mode 100644 index 0000000000..4b098d6f59 --- /dev/null +++ b/hummingbot/strategy/spot_perpetual_arbitrage/dummy.pxd @@ -0,0 +1,2 @@ +cdef class dummy(): + pass diff --git a/hummingbot/strategy/spot_perpetual_arbitrage/dummy.pyx b/hummingbot/strategy/spot_perpetual_arbitrage/dummy.pyx new file mode 100644 index 0000000000..4b098d6f59 --- /dev/null +++ b/hummingbot/strategy/spot_perpetual_arbitrage/dummy.pyx @@ -0,0 +1,2 @@ +cdef class dummy(): + pass diff --git a/hummingbot/strategy/spot_perpetual_arbitrage/spot_perpetual_arbitrage.py b/hummingbot/strategy/spot_perpetual_arbitrage/spot_perpetual_arbitrage.py new file mode 100644 index 0000000000..3ae784fe62 --- /dev/null +++ b/hummingbot/strategy/spot_perpetual_arbitrage/spot_perpetual_arbitrage.py @@ -0,0 +1,497 @@ +from decimal import Decimal +import time +import logging +import asyncio +import pandas as pd +from typing import List, Dict, Tuple +from hummingbot.core.utils.async_utils import safe_ensure_future +from hummingbot.core.clock import Clock +from hummingbot.core.data_type.limit_order import LimitOrder +from hummingbot.core.data_type.market_order import MarketOrder +from hummingbot.logger import HummingbotLogger +from hummingbot.strategy.market_trading_pair_tuple import MarketTradingPairTuple +from hummingbot.strategy.strategy_py_base import StrategyPyBase +from hummingbot.connector.connector_base import ConnectorBase + +from hummingbot.core.event.events import ( + PositionAction, + PositionSide, + PositionMode +) +from hummingbot.connector.derivative.position import Position + +from .arb_proposal import ArbProposalSide, ArbProposal + + +NaN = float("nan") +s_decimal_zero = Decimal(0) +spa_logger = None + + +class SpotPerpetualArbitrageStrategy(StrategyPyBase): + """ + This strategy arbitrages between a spot and a perpetual exchange connector. + For a given order amount, the strategy checks the divergence and convergence in prices that could occur + before and during funding payment on the perpetual exchange. + If presents, the strategy submits taker orders to both market. + """ + + @classmethod + def logger(cls) -> HummingbotLogger: + global spa_logger + if spa_logger is None: + spa_logger = logging.getLogger(__name__) + return spa_logger + + def __init__(self, + spot_market_info: MarketTradingPairTuple, + derivative_market_info: MarketTradingPairTuple, + order_amount: Decimal, + derivative_leverage: int, + min_divergence: Decimal, + min_convergence: Decimal, + spot_market_slippage_buffer: Decimal = Decimal("0"), + derivative_market_slippage_buffer: Decimal = Decimal("0"), + maximize_funding_rate: bool = True, + status_report_interval: float = 10): + """ + :param spot_market_info: The first market + :param derivative_market_info: The second market + :param order_amount: The order amount + :param min_divergence: The minimum spread to start arbitrage (e.g. 0.0003 for 0.3%) + :param min_convergence: The minimum spread to close arbitrage (e.g. 0.0003 for 0.3%) + :param spot_market_slippage_buffer: The buffer for which to adjust order price for higher chance of + the order getting filled. This is quite important for AMM which transaction takes a long time where a slippage + is acceptable rather having the transaction get rejected. The submitted order price will be adjust higher + for buy order and lower for sell order. + :param derivative_market_slippage_buffer: The slipper buffer for market_2 + :param maximize_funding_rate: whether to submit both arbitrage taker orders (buy and sell) simultaneously + If false, the bot will wait for first exchange order filled before submitting the other order. + """ + super().__init__() + self._spot_market_info = spot_market_info + self._derivative_market_info = derivative_market_info + self._min_divergence = min_divergence + self._min_convergence = min_convergence + self._order_amount = order_amount + self._derivative_leverage = derivative_leverage + self._spot_market_slippage_buffer = spot_market_slippage_buffer + self._derivative_market_slippage_buffer = derivative_market_slippage_buffer + self._maximize_funding_rate = maximize_funding_rate + self._all_markets_ready = False + + self._ev_loop = asyncio.get_event_loop() + + self._last_timestamp = 0 + self._status_report_interval = status_report_interval + self.add_markets([spot_market_info.market, derivative_market_info.market]) + + self._current_proposal = None + self._main_task = None + self._spot_done = True + self._deriv_done = True + self._spot_order_ids = [] + self._deriv_order_ids = [] + + @property + def current_proposal(self) -> ArbProposal: + return self._current_proposal + + @current_proposal.setter + def current_proposal(self, value): + self._current_proposal = value + + @property + def min_divergence(self) -> Decimal: + return self._min_divergence + + @property + def min_convergence(self) -> Decimal: + return self._min_convergence + + @property + def order_amount(self) -> Decimal: + return self._order_amount + + @order_amount.setter + def order_amount(self, value): + self._order_amount = value + + @property + def market_info_to_active_orders(self) -> Dict[MarketTradingPairTuple, List[LimitOrder]]: + return self._sb_order_tracker.market_pair_to_active_orders + + @property + def deriv_position(self) -> List[Position]: + return [s for s in self._derivative_market_info.market._account_positions.values() if + s.trading_pair == self._derivative_market_info.trading_pair] + + def tick(self, timestamp: float): + """ + Clock tick entry point, is run every second (on normal tick setting). + :param timestamp: current tick timestamp + """ + if not self._all_markets_ready: + self._all_markets_ready = all([market.ready for market in self.active_markets]) + if not self._all_markets_ready: + # self.logger().warning("Markets are not ready. Please wait...") + return + else: + self.logger().info("Markets are ready. Trading started.") + if len(self.deriv_position) > 0: + self.logger().info("Active position detected, bot assumes first arbitrage was done and would scan for second arbitrage.") + if self.ready_for_new_arb_trades(): + if self._main_task is None or self._main_task.done(): + self.current_proposal = ArbProposal(self._spot_market_info, self._derivative_market_info, self.order_amount, timestamp) + self._main_task = safe_ensure_future(self.main(timestamp)) + + async def main(self, timestamp): + """ + The main procedure for the arbitrage strategy. It first check if it's time for funding payment, decide if to compare with either + min_convergence or min_divergence, applies the slippage buffer, applies budget constraint, then finally execute the + arbitrage. + """ + execute_arb = False + funding_msg = "" + await self.current_proposal.proposed_spot_deriv_arb() + if len(self.deriv_position) > 0 and self.should_alternate_proposal_sides(self.current_proposal, self.deriv_position): + self.current_proposal.alternate_proposal_sides() + + if self.current_proposal.is_funding_payment_time(): + if len(self.deriv_position) > 0: + if self._maximize_funding_rate: + execute_arb = not self.would_receive_funding_payment(self.deriv_position) + if execute_arb: + self.timed_logger(timestamp, "Waiting for funding payment.") + else: + funding_msg = "Time for funding payment, executing second arbitrage to prevent paying funding fee" + else: + funding_msg = "Time for funding payment, executing second arbitrage " \ + "immediately since we don't intend to maximize funding rate" + execute_arb = True + else: + if len(self.deriv_position) > 0: + execute_arb = self.ready_for_execution(self.current_proposal, False) + else: + execute_arb = self.ready_for_execution(self.current_proposal, True) + + if execute_arb: + self.logger().info(self.spread_msg()) + self.apply_slippage_buffers(self.current_proposal) + self.apply_budget_constraint(self.current_proposal) + await self.execute_arb_proposals(self.current_proposal, funding_msg) + else: + self.timed_logger(timestamp, self.spread_msg()) + return + + def timed_logger(self, timestamp, msg): + """ + Displays log at specific intervals. + :param timestamp: current timestamp + :param msg: message to display at next interval + """ + if timestamp - self._last_timestamp > self._status_report_interval: + self.logger().info(msg) + self._last_timestamp = timestamp + + def ready_for_execution(self, proposal: ArbProposal, first: bool): + """ + Check if the spread meets the required spread requirement for the right arbitrage. + :param proposal: current proposal object + :param first: True, if scanning for opportunity for first arbitrage, else, False + :return: True if ready, else, False + """ + spread = self.current_proposal.spread() + if first and spread >= self.min_divergence: + return True + elif not first and spread <= self.min_convergence: + return True + return False + + def should_alternate_proposal_sides(self, proposal: ArbProposal, active_position: List[Position]): + """ + Checks if there's need to alternate the sides of a proposed arbitrage. + :param proposal: current proposal object + :param active_position: information about active position for the derivative connector + :return: True if sides need to be alternated, else, False + """ + deriv_proposal_side = PositionSide.LONG if proposal.derivative_side.is_buy else PositionSide.SHORT + position_side = PositionSide.LONG if active_position[0].amount > 0 else PositionSide.SHORT + if deriv_proposal_side == position_side: + return True + return False + + def would_receive_funding_payment(self, active_position: List[Position]): + """ + Checks if an active position would receive funding payment. + :param active_position: information about active position for the derivative connector + :return: True if funding payment would be received, else, False + """ + funding_info = self._derivative_market_info.market.get_funding_info(self._derivative_market_info.trading_pair) + if (active_position[0].amount > 0 and funding_info["rate"] < 0) or \ + (active_position[0].amount < 0 and funding_info["rate"] > 0): + return True + return False + + def apply_slippage_buffers(self, arb_proposal: ArbProposal): + """ + Updates arb_proposals by adjusting order price for slipper buffer percentage. + E.g. if it is a buy order, for an order price of 100 and 1% slipper buffer, the new order price is 101, + for a sell order, the new order price is 99. + :param arb_proposals: the arbitrage proposal + """ + for arb_side in (arb_proposal.spot_side, arb_proposal.derivative_side): + market = arb_side.market_info.market + arb_side.amount = market.quantize_order_amount(arb_side.market_info.trading_pair, arb_side.amount) + s_buffer = self._spot_market_slippage_buffer if market == self._spot_market_info.market \ + else self._derivative_market_slippage_buffer + if not arb_side.is_buy: + s_buffer *= Decimal("-1") + arb_side.order_price *= Decimal("1") + s_buffer + arb_side.order_price = market.quantize_order_price(arb_side.market_info.trading_pair, + arb_side.order_price) + + def apply_budget_constraint(self, arb_proposal: ArbProposal): + """ + Updates arb_proposals by setting proposal amount to 0 if there is not enough balance to submit order with + required order amount. + :param arb_proposals: the arbitrage proposal + """ + spot_market = self._spot_market_info.market + deriv_market = self._derivative_market_info.market + spot_token = self._spot_market_info.quote_asset if arb_proposal.spot_side.is_buy else self._spot_market_info.base_asset + deriv_token = self._derivative_market_info.quote_asset + spot_token_balance = spot_market.get_available_balance(spot_token) + deriv_token_balance = deriv_market.get_available_balance(deriv_token) + required_spot_balance = arb_proposal.amount * arb_proposal.spot_side.order_price if arb_proposal.spot_side.is_buy else arb_proposal.amount + required_deriv_balance = arb_proposal.amount * arb_proposal.derivative_side.order_price + if spot_token_balance < required_spot_balance: + arb_proposal.amount = s_decimal_zero + self.logger().info(f"Can't arbitrage, {spot_market.display_name} " + f"{spot_token} balance " + f"({spot_token_balance}) is below required order amount ({required_spot_balance}).") + elif deriv_token_balance < required_deriv_balance: + arb_proposal.amount = s_decimal_zero + self.logger().info(f"Can't arbitrage, {deriv_market.display_name} " + f"{deriv_token} balance " + f"({deriv_token_balance}) is below required order amount ({required_deriv_balance}).") + return + + async def execute_arb_proposals(self, arb_proposal: ArbProposal, is_funding_msg: str = ""): + """ + Execute both sides of the arbitrage trades concurrently. + :param arb_proposals: the arbitrage proposal + :param is_funding_msg: message pertaining to funding payment + """ + if arb_proposal.amount == s_decimal_zero: + return + self._spot_done = False + self._deriv_done = False + proposal = self.short_proposal_msg(False) + if is_funding_msg: + opportunity_msg = is_funding_msg + else: + first_arbitage = not bool(len(self.deriv_position)) + opportunity_msg = "Spread wide enough to execute first arbitrage" if first_arbitage else \ + "Spread low enough to execute second arbitrage" + self.logger().info(f"{opportunity_msg}!: \n" + f"{proposal[0]} \n" + f"{proposal[1]} \n") + safe_ensure_future(self.execute_spot_side(arb_proposal.spot_side)) + safe_ensure_future(self.execute_derivative_side(arb_proposal.derivative_side)) + + async def execute_spot_side(self, arb_side: ArbProposalSide): + side = "BUY" if arb_side.is_buy else "SELL" + place_order_fn = self.buy_with_specific_market if arb_side.is_buy else self.sell_with_specific_market + self.log_with_clock(logging.INFO, + f"Placing {side} order for {arb_side.amount} {arb_side.market_info.base_asset} " + f"at {arb_side.market_info.market.display_name} at {arb_side.order_price} price") + order_id = place_order_fn(arb_side.market_info, + arb_side.amount, + arb_side.market_info.market.get_taker_order_type(), + arb_side.order_price, + ) + self._spot_order_ids.append(order_id) + + async def execute_derivative_side(self, arb_side: ArbProposalSide): + side = "BUY" if arb_side.is_buy else "SELL" + place_order_fn = self.buy_with_specific_market if arb_side.is_buy else self.sell_with_specific_market + position_action = PositionAction.OPEN if len(self.deriv_position) == 0 else PositionAction.CLOSE + self.log_with_clock(logging.INFO, + f"Placing {side} order for {arb_side.amount} {arb_side.market_info.base_asset} " + f"at {arb_side.market_info.market.display_name} at {arb_side.order_price} price to {position_action.name} position.") + order_id = place_order_fn(arb_side.market_info, + arb_side.amount, + arb_side.market_info.market.get_taker_order_type(), + arb_side.order_price, + position_action=position_action + ) + self._deriv_order_ids.append(order_id) + + def ready_for_new_arb_trades(self) -> bool: + """ + Returns True if there is no outstanding unfilled order. + """ + for market_info in [self._spot_market_info, self._derivative_market_info]: + if len(self.market_info_to_active_orders.get(market_info, [])) > 0: + return False + if not self._spot_done or not self._deriv_done: + return False + return True + + def short_proposal_msg(self, indented: bool = True) -> List[str]: + """ + Composes a short proposal message. + :param indented: If the message should be indented (by 4 spaces) + :return A list of info on both sides of an arbitrage + """ + lines = [] + proposal = self.current_proposal + lines.append(f"{' ' if indented else ''}{proposal.spot_side}") + lines.append(f"{' ' if indented else ''}{proposal.derivative_side}") + return lines + + def spread_msg(self): + """ + Composes a short spread message. + :return Info about current spread of an arbitrage + """ + spread = self.current_proposal.spread() + first = not bool(len(self.deriv_position)) + target_spread_str = "minimum divergence spread" if first else "minimum convergence spread" + target_spread = self.min_divergence if first else self.min_convergence + msg = f"Current spread: {spread:.2%}, {target_spread_str}: {target_spread:.2%}." + return msg + + def active_positions_df(self) -> pd.DataFrame: + columns = ["Symbol", "Type", "Entry Price", "Amount", "Leverage", "Unrealized PnL"] + data = [] + market, trading_pair = self._derivative_market_info.market, self._derivative_market_info.trading_pair + for idx in self.deriv_position: + is_buy = True if idx.amount > 0 else False + unrealized_profit = ((market.get_price(trading_pair, is_buy) - idx.entry_price) * idx.amount) + data.append([ + idx.trading_pair, + idx.position_side.name, + idx.entry_price, + idx.amount, + idx.leverage, + unrealized_profit + ]) + + return pd.DataFrame(data=data, columns=columns) + + async def format_status(self) -> str: + """ + Returns a status string formatted to display nicely on terminal. The strings composes of 4 parts: markets, + assets, spread and warnings(if any). + """ + columns = ["Exchange", "Market", "Sell Price", "Buy Price", "Mid Price"] + data = [] + for market_info in [self._spot_market_info, self._derivative_market_info]: + market, trading_pair, base_asset, quote_asset = market_info + buy_price = await market.get_quote_price(trading_pair, True, self._order_amount) + sell_price = await market.get_quote_price(trading_pair, False, self._order_amount) + mid_price = (buy_price + sell_price) / 2 + data.append([ + market.display_name, + trading_pair, + float(sell_price), + float(buy_price), + float(mid_price) + ]) + markets_df = pd.DataFrame(data=data, columns=columns) + lines = [] + lines.extend(["", " Markets:"] + [" " + line for line in markets_df.to_string(index=False).split("\n")]) + + # See if there're any active positions. + if len(self.deriv_position) > 0: + df = self.active_positions_df() + lines.extend(["", " Positions:"] + [" " + line for line in df.to_string(index=False).split("\n")]) + else: + lines.extend(["", " No active positions."]) + + assets_df = self.wallet_balance_data_frame([self._spot_market_info, self._derivative_market_info]) + lines.extend(["", " Assets:"] + + [" " + line for line in str(assets_df).split("\n")]) + + lines.extend(["", " Spread details:"] + [" " + self.spread_msg()] + + self.short_proposal_msg()) + + warning_lines = self.network_warning([self._spot_market_info]) + warning_lines.extend(self.network_warning([self._derivative_market_info])) + warning_lines.extend(self.balance_warning([self._spot_market_info])) + warning_lines.extend(self.balance_warning([self._derivative_market_info])) + if len(warning_lines) > 0: + lines.extend(["", "*** WARNINGS ***"] + warning_lines) + + return "\n".join(lines) + + def did_complete_buy_order(self, order_completed_event): + self.update_status(order_completed_event) + + def did_complete_sell_order(self, order_completed_event): + self.update_status(order_completed_event) + + def did_fail_order(self, order_failed_event): + self.retry_order(order_failed_event) + + def did_cancel_order(self, cancelled_event): + self.retry_order(cancelled_event) + + def did_expire_order(self, expired_event): + self.retry_order(expired_event) + + def did_complete_funding_payment(self, funding_payment_completed_event): + # Excute second arbitrage if necessary (even spread hasn't reached min convergence) + if len(self.deriv_position) > 0: + self.apply_slippage_buffers(self.current_proposal) + self.apply_budget_constraint(self.current_proposal) + funding_msg = "Executing second arbitrage after funding payment is received" + safe_ensure_future(self.execute_arb_proposals(self.current_proposal, funding_msg)) + return + + def update_status(self, event): + order_id = event.order_id + if order_id in self._spot_order_ids: + self._spot_done = True + self._spot_order_ids.remove(order_id) + elif order_id in self._deriv_order_ids: + self._deriv_done = True + self._deriv_order_ids.remove(order_id) + + def retry_order(self, event): + order_id = event.order_id + # To-do: Should be updated to do counted retry rather than time base retry. i.e mark as done after retrying 3 times + if event.timestamp > (time.time() - 5): # retry if order failed less than 5 secs ago + if order_id in self._spot_order_ids: + self.logger().info("Retrying failed order on spot exchange.") + safe_ensure_future(self.execute_spot_side(self.current_proposal.spot_side)) + self._spot_order_ids.remove(order_id) + elif order_id in self._deriv_order_ids: + self.logger().info("Retrying failed order on derivative exchange.") + safe_ensure_future(self.execute_derivative_side(self.current_proposal.derivative_side)) + self._deriv_order_ids.remove(order_id) + else: # mark as done + self.update_status(event) + + @property + def tracked_limit_orders(self) -> List[Tuple[ConnectorBase, LimitOrder]]: + return self._sb_order_tracker.tracked_limit_orders + + @property + def tracked_market_orders(self) -> List[Tuple[ConnectorBase, MarketOrder]]: + return self._sb_order_tracker.tracked_market_orders + + def apply_initial_settings(self, trading_pair, leverage): + deriv_market = self._derivative_market_info.market + deriv_market.set_leverage(trading_pair, leverage) + deriv_market.set_position_mode(PositionMode.ONEWAY) + + def start(self, clock: Clock, timestamp: float): + self.apply_initial_settings(self._derivative_market_info.trading_pair, self._derivative_leverage) + + def stop(self, clock: Clock): + if self._main_task is not None: + self._main_task.cancel() + self._main_task = None diff --git a/hummingbot/strategy/spot_perpetual_arbitrage/spot_perpetual_arbitrage_config_map.py b/hummingbot/strategy/spot_perpetual_arbitrage/spot_perpetual_arbitrage_config_map.py new file mode 100644 index 0000000000..8be3cd9653 --- /dev/null +++ b/hummingbot/strategy/spot_perpetual_arbitrage/spot_perpetual_arbitrage_config_map.py @@ -0,0 +1,125 @@ +from hummingbot.client.config.config_var import ConfigVar +from hummingbot.client.config.config_validators import ( + validate_connector, + validate_derivative, + validate_decimal, + validate_bool, + validate_int +) +from hummingbot.client.settings import ( + required_exchanges, + requried_connector_trading_pairs, + EXAMPLE_PAIRS, +) +from decimal import Decimal + + +def exchange_on_validated(value: str) -> None: + required_exchanges.append(value) + + +def spot_market_on_validated(value: str) -> None: + requried_connector_trading_pairs[spot_perpetual_arbitrage_config_map["spot_connector"].value] = [value] + + +def derivative_market_on_validated(value: str) -> None: + requried_connector_trading_pairs[spot_perpetual_arbitrage_config_map["derivative_connector"].value] = [value] + + +def spot_market_prompt() -> str: + connector = spot_perpetual_arbitrage_config_map.get("spot_connector").value + example = EXAMPLE_PAIRS.get(connector) + return "Enter the token trading pair you would like to trade on %s%s >>> " \ + % (connector, f" (e.g. {example})" if example else "") + + +def derivative_market_prompt() -> str: + connector = spot_perpetual_arbitrage_config_map.get("derivative_connector").value + example = EXAMPLE_PAIRS.get(connector) + return "Enter the token trading pair you would like to trade on %s%s >>> " \ + % (connector, f" (e.g. {example})" if example else "") + + +def order_amount_prompt() -> str: + trading_pair = spot_perpetual_arbitrage_config_map["spot_market"].value + base_asset, quote_asset = trading_pair.split("-") + return f"What is the amount of {base_asset} per order? >>> " + + +spot_perpetual_arbitrage_config_map = { + "strategy": ConfigVar( + key="strategy", + prompt="", + default="spot_perpetual_arbitrage"), + "spot_connector": ConfigVar( + key="connector_1", + prompt="Enter a spot connector (Exchange/AMM) >>> ", + prompt_on_new=True, + validator=validate_connector, + on_validated=exchange_on_validated), + "spot_market": ConfigVar( + key="spot_market", + prompt=spot_market_prompt, + prompt_on_new=True, + on_validated=spot_market_on_validated), + "derivative_connector": ConfigVar( + key="derivative_connector", + prompt="Enter a derivative name (Exchange/AMM) >>> ", + prompt_on_new=True, + validator=validate_derivative, + on_validated=exchange_on_validated), + "derivative_market": ConfigVar( + key="derivative_market", + prompt=derivative_market_prompt, + prompt_on_new=True, + on_validated=derivative_market_on_validated), + "order_amount": ConfigVar( + key="order_amount", + prompt=order_amount_prompt, + type_str="decimal", + prompt_on_new=True), + "derivative_leverage": ConfigVar( + key="derivative_leverage", + prompt="How much leverage would you like to use on the derivative exchange? (Enter 1 to indicate 1X) ", + type_str="int", + default=1, + validator= lambda v: validate_int(v), + prompt_on_new=True), + "min_divergence": ConfigVar( + key="min_divergence", + prompt="What is the minimum spread between the spot and derivative market price before starting an arbitrage? (Enter 1 to indicate 1%) >>> ", + prompt_on_new=True, + default=Decimal("1"), + validator=lambda v: validate_decimal(v, 0, 100, inclusive=False), + type_str="decimal"), + "min_convergence": ConfigVar( + key="min_convergence", + prompt="What is the minimum spread between the spot and derivative market price before closing an existing arbitrage? (Enter 1 to indicate 1%) >>> ", + prompt_on_new=True, + default=Decimal("1"), + validator=lambda v: validate_decimal(v, 0, spot_perpetual_arbitrage_config_map["min_divergence"].value), + type_str="decimal"), + "maximize_funding_rate": ConfigVar( + key="maximize_funding_rate", + prompt="Would you like to take advantage of the funding rate on the derivative exchange, even if min convergence is reached during funding time? (True/False) >>> ", + prompt_on_new=True, + default=False, + validator=validate_bool, + type_str="bool"), + "spot_market_slippage_buffer": ConfigVar( + key="spot_market_slippage_buffer", + prompt="How much buffer do you want to add to the price to account for slippage for orders on the spot market " + "(Enter 1 for 1%)? >>> ", + prompt_on_new=True, + default=Decimal("0.05"), + validator=lambda v: validate_decimal(v), + type_str="decimal"), + "derivative_market_slippage_buffer": ConfigVar( + key="derivative_market_slippage_buffer", + prompt="How much buffer do you want to add to the price to account for slippage for orders on the derivative market" + " (Enter 1 for 1%)? >>> ", + prompt_on_new=True, + default=Decimal("0.05"), + validator=lambda v: validate_decimal(v), + type_str="decimal"), +} diff --git a/hummingbot/strategy/spot_perpetual_arbitrage/start.py b/hummingbot/strategy/spot_perpetual_arbitrage/start.py new file mode 100644 index 0000000000..2f145f7dde --- /dev/null +++ b/hummingbot/strategy/spot_perpetual_arbitrage/start.py @@ -0,0 +1,37 @@ +from decimal import Decimal +from hummingbot.strategy.market_trading_pair_tuple import MarketTradingPairTuple +from hummingbot.strategy.spot_perpetual_arbitrage.spot_perpetual_arbitrage import SpotPerpetualArbitrageStrategy +from hummingbot.strategy.spot_perpetual_arbitrage.spot_perpetual_arbitrage_config_map import spot_perpetual_arbitrage_config_map + + +def start(self): + spot_connector = spot_perpetual_arbitrage_config_map.get("spot_connector").value.lower() + spot_market = spot_perpetual_arbitrage_config_map.get("spot_market").value + derivative_connector = spot_perpetual_arbitrage_config_map.get("derivative_connector").value.lower() + derivative_market = spot_perpetual_arbitrage_config_map.get("derivative_market").value + order_amount = spot_perpetual_arbitrage_config_map.get("order_amount").value + derivative_leverage = spot_perpetual_arbitrage_config_map.get("derivative_leverage").value + min_divergence = spot_perpetual_arbitrage_config_map.get("min_divergence").value / Decimal("100") + min_convergence = spot_perpetual_arbitrage_config_map.get("min_convergence").value / Decimal("100") + spot_market_slippage_buffer = spot_perpetual_arbitrage_config_map.get("spot_market_slippage_buffer").value / Decimal("100") + derivative_market_slippage_buffer = spot_perpetual_arbitrage_config_map.get("derivative_market_slippage_buffer").value / Decimal("100") + maximize_funding_rate = spot_perpetual_arbitrage_config_map.get("maximize_funding_rate").value + + self._initialize_markets([(spot_connector, [spot_market]), (derivative_connector, [derivative_market])]) + base_1, quote_1 = spot_market.split("-") + base_2, quote_2 = derivative_market.split("-") + self.assets = set([base_1, quote_1, base_2, quote_2]) + + spot_market_info = MarketTradingPairTuple(self.markets[spot_connector], spot_market, base_1, quote_1) + derivative_market_info = MarketTradingPairTuple(self.markets[derivative_connector], derivative_market, base_2, quote_2) + + self.market_trading_pair_tuples = [spot_market_info, derivative_market_info] + self.strategy = SpotPerpetualArbitrageStrategy(spot_market_info, + derivative_market_info, + order_amount, + derivative_leverage, + min_divergence, + min_convergence, + spot_market_slippage_buffer, + derivative_market_slippage_buffer, + maximize_funding_rate) diff --git a/hummingbot/strategy/spot_perpetual_arbitrage/utils.py b/hummingbot/strategy/spot_perpetual_arbitrage/utils.py new file mode 100644 index 0000000000..f5b2e39936 --- /dev/null +++ b/hummingbot/strategy/spot_perpetual_arbitrage/utils.py @@ -0,0 +1,44 @@ +from decimal import Decimal +from typing import List +from hummingbot.strategy.market_trading_pair_tuple import MarketTradingPairTuple +from .data_types import ArbProposal, ArbProposalSide + +s_decimal_nan = Decimal("NaN") + + +async def create_arb_proposals(market_info_1: MarketTradingPairTuple, + market_info_2: MarketTradingPairTuple, + order_amount: Decimal) -> List[ArbProposal]: + """ + Creates base arbitrage proposals for given markets without any filtering. + :param market_info_1: The first market + :param market_info_2: The second market + :param order_amount: The required order amount. + :return A list of 2 proposal - (market_1 buy, market_2 sell) and (market_1 sell, market_2 buy) + """ + order_amount = Decimal(str(order_amount)) + results = [] + for index in range(0, 2): + is_buy = not bool(index) # bool(0) is False, so start with buy first + m_1_q_price = await market_info_1.market.get_quote_price(market_info_1.trading_pair, is_buy, order_amount) + m_1_o_price = await market_info_1.market.get_order_price(market_info_1.trading_pair, is_buy, order_amount) + m_2_q_price = await market_info_2.market.get_quote_price(market_info_2.trading_pair, not is_buy, order_amount) + m_2_o_price = await market_info_2.market.get_order_price(market_info_2.trading_pair, not is_buy, order_amount) + if any(p is None for p in (m_1_o_price, m_1_q_price, m_2_o_price, m_2_q_price)): + continue + first_side = ArbProposalSide( + market_info_1, + is_buy, + m_1_q_price, + m_1_o_price, + order_amount + ) + second_side = ArbProposalSide( + market_info_2, + not is_buy, + m_2_q_price, + m_2_o_price, + order_amount + ) + results.append(ArbProposal(first_side, second_side)) + return results diff --git a/hummingbot/templates/conf_spot_perpetual_arbitrage_strategy_TEMPLATE.yml b/hummingbot/templates/conf_spot_perpetual_arbitrage_strategy_TEMPLATE.yml new file mode 100644 index 0000000000..7945f553c4 --- /dev/null +++ b/hummingbot/templates/conf_spot_perpetual_arbitrage_strategy_TEMPLATE.yml @@ -0,0 +1,37 @@ +########################################## +### Spot-Perpetual Arbitrage strategy config ### +########################################## + +template_version: 1 +strategy: null + +# The following configuations are only required for the AMM arbitrage trading strategy + +# Connectors and markets parameters +spot_connector: null +spot_market: null +derivative_connector: null +derivative_market: null + +order_amount: null + +derivative_leverage: null + +# Spread required before first arbitrage can take place, expressed in percentage value, e.g. 1 = 1% +min_divergence: null + +# Spread required after first arbitrage before second arbitreage can take place, expressed in percentage value, e.g. 1 = 1% +min_convergence: null + +# A buffer for which to adjust order price for higher chance of the order getting filled. +# This is important for AMM which transaction takes a long time where a slippage is acceptable rather having +# the transaction get rejected. The submitted order price will be adjust higher (by percentage value) for buy order +# and lower for sell order. (Enter 1 for 1%) +spot_market_slippage_buffer: null + +# A buffer to add to the price to account for slippage when buying/selling on second connector market +# (Enter 1 for 1%) +derivative_market_slippage_buffer: null + +# A flag (true/false), if true the bot would only execute second arbitrage once funding payment is received provided second arbitrage wasn't executed before funding period. +maximize_funding_rate: null From de4a236a8268020a4af97d0808883638a4503d78 Mon Sep 17 00:00:00 2001 From: Kirill Stroukov Date: Mon, 22 Feb 2021 17:15:44 +0100 Subject: [PATCH 061/131] feat/Bexy update unnittests to APIv3 --- .../beaxy/beaxy_active_order_tracker.pyx | 4 +- .../beaxy/beaxy_api_order_book_data_source.py | 8 + .../assets/mock_data/fixture_beaxy.py | 279 ++---------------- .../test_beaxy_active_order_tracker.py | 157 ++++++---- test/integration/test_beaxy_market.py | 65 ++-- .../test_beaxy_order_book_tracker.py | 20 -- 6 files changed, 173 insertions(+), 360 deletions(-) diff --git a/hummingbot/connector/exchange/beaxy/beaxy_active_order_tracker.pyx b/hummingbot/connector/exchange/beaxy/beaxy_active_order_tracker.pyx index a4f2d4e8d1..18b8ca2906 100644 --- a/hummingbot/connector/exchange/beaxy/beaxy_active_order_tracker.pyx +++ b/hummingbot/connector/exchange/beaxy/beaxy_active_order_tracker.pyx @@ -106,14 +106,14 @@ cdef class BeaxyActiveOrderTracker: elif msg_action == ACTION_DELETE_THROUGH: # Remove all levels from the specified and below (all the worst prices). - for key in active_rows.keys(): + for key in list(active_rows.keys()): if key < price: del active_rows[key] yield [timestamp, float(price), float(0), message.update_id] elif msg_action == ACTION_DELETE_FROM: # Remove all levels from the specified and above (all the better prices). - for key in active_rows.keys(): + for key in list(active_rows.keys()): if key > price: del active_rows[key] yield [timestamp, float(price), float(0), message.update_id] diff --git a/hummingbot/connector/exchange/beaxy/beaxy_api_order_book_data_source.py b/hummingbot/connector/exchange/beaxy/beaxy_api_order_book_data_source.py index 68051aa09c..f42881c33f 100644 --- a/hummingbot/connector/exchange/beaxy/beaxy_api_order_book_data_source.py +++ b/hummingbot/connector/exchange/beaxy/beaxy_api_order_book_data_source.py @@ -154,6 +154,14 @@ async def get_snapshot(client: aiohttp.ClientSession, trading_pair: str, depth: if response.status != 200: raise IOError(f'Error fetching Beaxy market snapshot for {trading_pair}. ' f'HTTP status is {response.status}.') + + if not await response.text(): # if test is empty it marks that there is no rows + return { + 'timestamp': 1, + 'entries': [], + 'sequenceNumber': 1, + } + data: Dict[str, Any] = await response.json() return data diff --git a/test/integration/assets/mock_data/fixture_beaxy.py b/test/integration/assets/mock_data/fixture_beaxy.py index 03291bdf09..8bb12c7dfe 100644 --- a/test/integration/assets/mock_data/fixture_beaxy.py +++ b/test/integration/assets/mock_data/fixture_beaxy.py @@ -1,6 +1,12 @@ class FixtureBeaxy: - BALANCES = [ + BALANCES = [{'id': '20E33E7E-6940-4521-B3CB-DC75FD9DE0EA', 'currency': 'NEO', 'available_balance': '0', 'total_balance': '0'}, {'id': '64BFCB9A-1E19-4A71-AF2E-FE8344F66B56', 'currency': 'AION', 'available_balance': '0', 'total_balance': '0'}, {'id': '1FB1DD29-406B-4227-AE2A-700D8EDCE001', 'currency': 'BXY', 'available_balance': '5572.63464229', 'total_balance': '5572.63464229'}, {'id': 'F801485E-774A-472C-A4D4-CCEEF4AC4C21', 'currency': 'WAVES', 'available_balance': '0', 'total_balance': '0'}, {'id': 'B916B506-177A-4359-86F2-80D1845B22B5', 'currency': 'BEAM', 'available_balance': '0', 'total_balance': '0'}, {'id': 'D3A33644-A624-4421-BAC6-1F92A93A0CB2', 'currency': 'USD', 'available_balance': '0', 'total_balance': '0'}, {'id': '4C49C9F6-A594-43F1-A351-22C8712596CA', 'currency': 'BTC', 'available_balance': '0.000000029675', 'total_balance': '0.00070592'}, {'id': '2200FC9C-B559-4C70-9605-926E28193605', 'currency': 'BAT', 'available_balance': '0', 'total_balance': '0'}, {'id': '71AF49AC-54A4-4F5A-A3A1-97CFEFE40430', 'currency': 'XRP', 'available_balance': '0', 'total_balance': '0'}, {'id': '53BDA6C6-5C66-4BD7-BE63-D547699525A4', 'currency': 'DRGN', 'available_balance': '0', 'total_balance': '0'}, {'id': '3D0863E2-7AE9-4478-ABED-1FECAF86B639', 'currency': 'ALEPH', 'available_balance': '0', 'total_balance': '0'}, {'id': '91A87666-6590-45A8-B70F-FA28EFD32716', 'currency': 'POLY', 'available_balance': '0', 'total_balance': '0'}, {'id': '4B4E0955-4DA7-404F-98AE-D0AFF654A0E7', 'currency': 'LINK', 'available_balance': '0', 'total_balance': '0'}, {'id': '90030F73-36FE-4651-9A59-5E57464F64E6', 'currency': 'FTM', 'available_balance': '0', 'total_balance': '0'}, {'id': '44ED1E64-BC45-45DF-B186-2444B8279354', 'currency': 'ZEC', 'available_balance': '0', 'total_balance': '0'}, {'id': '3FD59135-B069-453A-B3F8-F610F9DA7D37', 'currency': 'WGR', 'available_balance': '0', 'total_balance': '0'}, {'id': '441649A4-C8A8-445F-8B91-182B5EC9F20D', 'currency': 'EOS', 'available_balance': '0', 'total_balance': '0'}, {'id': 'FD739CEF-3FB7-4885-9A52-41A0A1C570EA', 'currency': 'NRG', 'available_balance': '0', 'total_balance': '0'}, {'id': '6F0C6353-B145-41A1-B695-2DCF4DFD8ADD', 'currency': 'ETH', 'available_balance': '0', 'total_balance': '0'}, {'id': 'C9C2C810-44AB-48FE-A81F-02B72F9FC87D', 'currency': 'ZRX', 'available_balance': '0', 'total_balance': '0'}, {'id': '6D555386-2091-4C9E-B4FD-7F575F0D9753', 'currency': 'ETC', 'available_balance': '0', 'total_balance': '0'}, {'id': 'A7383484-784E-48AB-8F8A-62304FF7C37A', 'currency': 'USDT', 'available_balance': '0', 'total_balance': '0'}, {'id': '54F5AAB9-3608-46F1-BBA8-F0996FFACE0A', 'currency': 'TOMO', 'available_balance': '0', 'total_balance': '0'}, {'id': '9B2ADE73-CE5D-45EE-9788-3587DDCF58F2', 'currency': 'USDC', 'available_balance': '15.52728', 'total_balance': '15.52728'}, {'id': '974AB4C0-6EDE-4EFF-BCA1-1F8FAEE93AA6', 'currency': 'GUNTHY', 'available_balance': '0', 'total_balance': '0'}, {'id': '8FA31F5A-7A94-4CF6-BA95-9990E9B4C76A', 'currency': 'LTC', 'available_balance': '0', 'total_balance': '0'}, {'id': 'D2CF87AB-754E-4FCD-BB42-7FC812931402', 'currency': 'ICX', 'available_balance': '0', 'total_balance': '0'}, {'id': '0B0C8512-A113-4D57-B4C6-D059746B302D', 'currency': 'DASH', 'available_balance': '1', 'total_balance': '1'}, {'id': '35A5F10B-1A9E-47E4-BA27-8918D1A16F8E', 'currency': 'BSV', 'available_balance': '0', 'total_balance': '0'}, {'id': '8A409D82-D049-41C1-A4D2-5F4BA3AF0432', 'currency': 'BCH', 'available_balance': '0', 'total_balance': '0'}, {'id': '0E525961-F6D2-422F-87C1-1A82E325DC8A', 'currency': 'HIVE', 'available_balance': '0', 'total_balance': '0'}, {'id': '03DD4CB5-3BB7-4C74-9FEA-C2CC73123ECE', 'currency': 'XSN', 'available_balance': '0', 'total_balance': '0'}, {'id': 'DA08BE6A-83B7-481A-BD06-5A2BE79E80E6', 'currency': 'GO', 'available_balance': '0', 'total_balance': '0'}, {'id': '81444BA4-EC69-42FE-AD54-09500335788B', 'currency': 'XMR', 'available_balance': '0', 'total_balance': '0'}] + + TRADE_SETTINGS = {'symbols': [{'name': 'HIVEBTC', 'term': 'BTC', 'base': 'HIVE', 'min_size': 1.0, 'max_size': 50000.0, 'maker_fee': 0.15, 'taker_fee': 0.25}, {'name': 'XSNBTC', 'term': 'BTC', 'base': 'XSN', 'min_size': 20.0, 'max_size': 17500.0, 'maker_fee': 0.15, 'taker_fee': 0.25}, {'name': 'ALEPHETH', 'term': 'ETH', 'base': 'ALEPH', 'min_size': 20.0, 'max_size': 50000.0, 'maker_fee': 0.15, 'taker_fee': 0.25}, {'name': 'BSVBTC', 'term': 'BTC', 'base': 'BSV', 'min_size': 0.001, 'max_size': 100.0, 'maker_fee': 0.15, 'taker_fee': 0.25}, {'name': 'XRPBTC', 'term': 'BTC', 'base': 'XRP', 'min_size': 0.1, 'max_size': 81632.65, 'maker_fee': 0.15, 'taker_fee': 0.25}, {'name': 'NEOBTC', 'term': 'BTC', 'base': 'NEO', 'min_size': 0.01, 'max_size': 1722.65, 'maker_fee': 0.15, 'taker_fee': 0.25}, {'name': 'USDTUSDC', 'term': 'USDC', 'base': 'USDT', 'min_size': 10.0, 'max_size': 100000.0, 'maker_fee': 0.15, 'taker_fee': 0.25}, {'name': 'GUNTHYBTC', 'term': 'BTC', 'base': 'GUNTHY', 'min_size': 50.0, 'max_size': 50000.0, 'maker_fee': 0.15, 'taker_fee': 0.25}, {'name': 'ETCBTC', 'term': 'BTC', 'base': 'ETC', 'min_size': 0.01, 'max_size': 2509.41, 'maker_fee': 0.15, 'taker_fee': 0.25}, {'name': 'BTCUSD', 'term': 'USD', 'base': 'BTC', 'min_size': 0.0005, 'max_size': 25.0, 'maker_fee': 0.15, 'taker_fee': 0.25}, {'name': 'BTCUSDT', 'term': 'USDT', 'base': 'BTC', 'min_size': 0.0005, 'max_size': 25.0, 'maker_fee': 0.15, 'taker_fee': 0.25}, {'name': 'ZRXBTC', 'term': 'BTC', 'base': 'ZRX', 'min_size': 0.1, 'max_size': 80000.0, 'maker_fee': 0.15, 'taker_fee': 0.25}, {'name': 'LINKBTC', 'term': 'BTC', 'base': 'LINK', 'min_size': 1.0, 'max_size': 2500.0, 'maker_fee': 0.15, 'taker_fee': 0.25}, {'name': 'BCHBTC', 'term': 'BTC', 'base': 'BCH', 'min_size': 0.001, 'max_size': 60.22, 'maker_fee': 0.15, 'taker_fee': 0.25}, {'name': 'LTCBTC', 'term': 'BTC', 'base': 'LTC', 'min_size': 0.01, 'max_size': 310.8, 'maker_fee': 0.15, 'taker_fee': 0.25}, {'name': 'EOSBTC', 'term': 'BTC', 'base': 'EOS', 'min_size': 0.01, 'max_size': 5434.78, 'maker_fee': 0.15, 'taker_fee': 0.25}, {'name': 'ETHUSDC', 'term': 'USDC', 'base': 'ETH', 'min_size': 0.01, 'max_size': 50.0, 'maker_fee': 0.15, 'taker_fee': 0.25}, {'name': 'ETHUSD', 'term': 'USD', 'base': 'ETH', 'min_size': 0.001, 'max_size': 25.0, 'maker_fee': 0.15, 'taker_fee': 0.25}, {'name': 'ZECBTC', 'term': 'BTC', 'base': 'ZEC', 'min_size': 0.001, 'max_size': 369.21, 'maker_fee': 0.15, 'taker_fee': 0.25}, {'name': 'BSVUSD', 'term': 'USD', 'base': 'BSV', 'min_size': 0.001, 'max_size': 100.0, 'maker_fee': 0.15, 'taker_fee': 0.25}, {'name': 'DASHBTC', 'term': 'BTC', 'base': 'DASH', 'min_size': 0.01, 'max_size': 218.32, 'maker_fee': 0.15, 'taker_fee': 0.25}, {'name': 'XMRBTC', 'term': 'BTC', 'base': 'XMR', 'min_size': 0.001, 'max_size': 277.28, 'maker_fee': 0.15, 'taker_fee': 0.25}, {'name': 'BXYUSDC', 'term': 'USDC', 'base': 'BXY', 'min_size': 2500.0, 'max_size': 250000.0, 'maker_fee': 0.15, 'taker_fee': 0.25}, {'name': 'BXYBTC', 'term': 'BTC', 'base': 'BXY', 'min_size': 2500.0, 'max_size': 250000.0, 'maker_fee': 0.15, 'taker_fee': 0.25}, {'name': 'GOBTC', 'term': 'BTC', 'base': 'GO', 'min_size': 50.0, 'max_size': 2000000.0, 'maker_fee': 0.15, 'taker_fee': 0.25}, {'name': 'AIONBTC', 'term': 'BTC', 'base': 'AION', 'min_size': 0.1, 'max_size': 125000.0, 'maker_fee': 0.15, 'taker_fee': 0.25}, {'name': 'ICXBTC', 'term': 'BTC', 'base': 'ICX', 'min_size': 0.1, 'max_size': 62500.0, 'maker_fee': 0.15, 'taker_fee': 0.25}, {'name': 'WGRBTC', 'term': 'BTC', 'base': 'WGR', 'min_size': 100.0, 'max_size': 150000.0, 'maker_fee': 0.15, 'taker_fee': 0.25}, {'name': 'BTCUSDC', 'term': 'USDC', 'base': 'BTC', 'min_size': 0.0005, 'max_size': 25.0, 'maker_fee': 0.15, 'taker_fee': 0.25}, {'name': 'DRGNBTC', 'term': 'BTC', 'base': 'DRGN', 'min_size': 50.0, 'max_size': 1000000.0, 'maker_fee': 0.15, 'taker_fee': 0.25}, {'name': 'DRGNUSD', 'term': 'USD', 'base': 'DRGN', 'min_size': 50.0, 'max_size': 200000.0, 'maker_fee': 0.15, 'taker_fee': 0.25}, {'name': 'ETHBTC', 'term': 'BTC', 'base': 'ETH', 'min_size': 0.001, 'max_size': 84.65, 'maker_fee': 0.15, 'taker_fee': 0.25}, {'name': 'BEAMBTC', 'term': 'BTC', 'base': 'BEAM', 'min_size': 0.1, 'max_size': 33333.33, 'maker_fee': 0.15, 'taker_fee': 0.25}, {'name': 'WAVESBTC', 'term': 'BTC', 'base': 'WAVES', 'min_size': 0.01, 'max_size': 16528.93, 'maker_fee': 0.15, 'taker_fee': 0.25}, {'name': 'BATBTC', 'term': 'BTC', 'base': 'BAT', 'min_size': 0.1, 'max_size': 90909.09, 'maker_fee': 0.15, 'taker_fee': 0.25}], 'currencies': [{'code': 'LINK', 'name': 'ChainLink', 'precision': 8}, {'code': 'BCH', 'name': 'Bitcoin Cash', 'precision': 8}, {'code': 'EOS', 'name': 'EOS', 'precision': 8}, {'code': 'BSV', 'name': 'BitcoinSV', 'precision': 8}, {'code': 'NRG', 'name': 'Energi', 'precision': 8}, {'code': 'USDT', 'name': 'Tether', 'precision': 8}, {'code': 'ZEC', 'name': 'Zcash', 'precision': 8}, {'code': 'ZRX', 'name': '0x', 'precision': 8}, {'code': 'FTM', 'name': 'Fantom', 'precision': 8}, {'code': 'ICX', 'name': 'ICON', 'precision': 8}, {'code': 'USDC', 'name': 'USD Coin', 'precision': 8}, {'code': 'DASH', 'name': 'Dash', 'precision': 8}, {'code': 'HIVE', 'name': 'Hive', 'precision': 8}, {'code': 'GO', 'name': 'GoChain', 'precision': 8}, {'code': 'BEAM', 'name': 'Beam', 'precision': 8}, {'code': 'AION', 'name': 'Aion', 'precision': 8}, {'code': 'WAVES', 'name': 'Waves', 'precision': 8}, {'code': 'ALEPH', 'name': 'Aleph.im', 'precision': 8}, {'code': 'BTC', 'name': 'Bitcoin', 'precision': 8}, {'code': 'BXY', 'name': 'Beaxy Coin', 'precision': 8}, {'code': 'WGR', 'name': 'Wagerr', 'precision': 8}, {'code': 'USD', 'name': 'United States Dollar', 'precision': 2}, {'code': 'BAT', 'name': 'Basic Attention Token', 'precision': 8}, {'code': 'XSN', 'name': 'Stakenet', 'precision': 8}, {'code': 'XMR', 'name': 'Monero', 'precision': 8}, {'code': 'POLY', 'name': 'Polymath', 'precision': 8}, {'code': 'NEO', 'name': 'Neo', 'precision': 8}, {'code': 'GUNTHY', 'name': 'Gunthy', 'precision': 8}, {'code': 'ETC', 'name': 'Ethereum Classic', 'precision': 8}, {'code': 'VID', 'name': 'VideoCoin', 'precision': 8}, {'code': 'TOMO', 'name': 'TomoChain', 'precision': 8}, {'code': 'DRGN', 'name': 'Dragonchain', 'precision': 8}, {'code': 'LTC', 'name': 'Litecoin', 'precision': 8}, {'code': 'MTL', 'name': 'Metal', 'precision': 8}, {'code': 'XRP', 'name': 'Ripple', 'precision': 8}, {'code': 'ETH', 'name': 'Ethereum', 'precision': 8}]} + + WS_ACCOUNT_BALANCES = [{"account_id": "8FA31F5A-7A94-4CF6-BA95-9990E9B4C76A", "balance": "0", "unrealized_pnl": None, "realized_pnl": None, "measurement_currency_id": None, "available_for_trading": "0", "enter_average_price": None, "current_price": None, "available_for_withdrawal": "0", "currency_id": "LTC", "total_statistics": None, "properties": None}, {"account_id": "64BFCB9A-1E19-4A71-AF2E-FE8344F66B56", "balance": "0", "unrealized_pnl": None, "realized_pnl": None, "measurement_currency_id": None, "available_for_trading": "0", "enter_average_price": None, "current_price": None, "available_for_withdrawal": "0", "currency_id": "AION", "total_statistics": None, "properties": None}, {"account_id": "6F0C6353-B145-41A1-B695-2DCF4DFD8ADD", "balance": "0", "unrealized_pnl": None, "realized_pnl": None, "measurement_currency_id": None, "available_for_trading": "0", "enter_average_price": None, "current_price": None, "available_for_withdrawal": "0", "currency_id": "ETH", "total_statistics": [{"total": "0", "total_this_day": None, "total_this_week": None, "total_this_month": None, "type": "execution"}], "properties": None}, {"account_id": "4B4E0955-4DA7-404F-98AE-D0AFF654A0E7", "balance": "0", "unrealized_pnl": None, "realized_pnl": None, "measurement_currency_id": None, "available_for_trading": "0", "enter_average_price": None, "current_price": None, "available_for_withdrawal": "0", "currency_id": "LINK", "total_statistics": None, "properties": None}, {"account_id": "2200FC9C-B559-4C70-9605-926E28193605", "balance": "0", "unrealized_pnl": None, "realized_pnl": None, "measurement_currency_id": None, "available_for_trading": "0", "enter_average_price": None, "current_price": None, "available_for_withdrawal": "0", "currency_id": "BAT", "total_statistics": [{"total": "0", "total_this_day": None, "total_this_week": None, "total_this_month": None, "type": "execution"}], "properties": None}, {"account_id": "0B0C8512-A113-4D57-B4C6-D059746B302D", "balance": "1.0", "unrealized_pnl": None, "realized_pnl": None, "measurement_currency_id": None, "available_for_trading": "1.0", "enter_average_price": None, "current_price": None, "available_for_withdrawal": "1.0", "currency_id": "DASH", "total_statistics": [{"total": "0", "total_this_day": "0", "total_this_week": "0", "total_this_month": "0", "type": "deposit"}, {"total": "0", "total_this_day": "0", "total_this_week": "0", "total_this_month": "0", "type": "withdrawal"}, {"total": "0.13", "total_this_day": None, "total_this_week": None, "total_this_month": None, "type": "execution"}], "properties": None}, {"account_id": "35A5F10B-1A9E-47E4-BA27-8918D1A16F8E", "balance": "0", "unrealized_pnl": None, "realized_pnl": None, "measurement_currency_id": None, "available_for_trading": "0", "enter_average_price": None, "current_price": None, "available_for_withdrawal": "0", "currency_id": "BSV", "total_statistics": None, "properties": None}, {"account_id": "3D0863E2-7AE9-4478-ABED-1FECAF86B639", "balance": "0", "unrealized_pnl": None, "realized_pnl": None, "measurement_currency_id": None, "available_for_trading": "0", "enter_average_price": None, "current_price": None, "available_for_withdrawal": "0", "currency_id": "ALEPH", "total_statistics": None, "properties": None}, {"account_id": "71AF49AC-54A4-4F5A-A3A1-97CFEFE40430", "balance": "0", "unrealized_pnl": None, "realized_pnl": None, "measurement_currency_id": None, "available_for_trading": "0", "enter_average_price": None, "current_price": None, "available_for_withdrawal": "0", "currency_id": "XRP", "total_statistics": None, "properties": None}, {"account_id": "974AB4C0-6EDE-4EFF-BCA1-1F8FAEE93AA6", "balance": "0", "unrealized_pnl": None, "realized_pnl": None, "measurement_currency_id": None, "available_for_trading": "0", "enter_average_price": None, "current_price": None, "available_for_withdrawal": "0", "currency_id": "GUNTHY", "total_statistics": None, "properties": None}, {"account_id": "81444BA4-EC69-42FE-AD54-09500335788B", "balance": "0", "unrealized_pnl": None, "realized_pnl": None, "measurement_currency_id": None, "available_for_trading": "0", "enter_average_price": None, "current_price": None, "available_for_withdrawal": "0", "currency_id": "XMR", "total_statistics": None, "properties": None}, {"account_id": "C9C2C810-44AB-48FE-A81F-02B72F9FC87D", "balance": "0", "unrealized_pnl": None, "realized_pnl": None, "measurement_currency_id": None, "available_for_trading": "0", "enter_average_price": None, "current_price": None, "available_for_withdrawal": "0", "currency_id": "ZRX", "total_statistics": None, "properties": None}, {"account_id": "8A409D82-D049-41C1-A4D2-5F4BA3AF0432", "balance": "0", "unrealized_pnl": None, "realized_pnl": None, "measurement_currency_id": None, "available_for_trading": "0", "enter_average_price": None, "current_price": None, "available_for_withdrawal": "0", "currency_id": "BCH", "total_statistics": None, "properties": None}, {"account_id": "F801485E-774A-472C-A4D4-CCEEF4AC4C21", "balance": "0", "unrealized_pnl": None, "realized_pnl": None, "measurement_currency_id": None, "available_for_trading": "0", "enter_average_price": None, "current_price": None, "available_for_withdrawal": "0", "currency_id": "WAVES", "total_statistics": None, "properties": None}, {"account_id": "03DD4CB5-3BB7-4C74-9FEA-C2CC73123ECE", "balance": "0", "unrealized_pnl": None, "realized_pnl": None, "measurement_currency_id": None, "available_for_trading": "0", "enter_average_price": None, "current_price": None, "available_for_withdrawal": "0", "currency_id": "XSN", "total_statistics": None, "properties": None}, {"account_id": "91A87666-6590-45A8-B70F-FA28EFD32716", "balance": "0", "unrealized_pnl": None, "realized_pnl": None, "measurement_currency_id": None, "available_for_trading": "0", "enter_average_price": None, "current_price": None, "available_for_withdrawal": "0", "currency_id": "POLY", "total_statistics": None, "properties": None}, {"account_id": "9B2ADE73-CE5D-45EE-9788-3587DDCF58F2", "balance": "28.37140743", "unrealized_pnl": None, "realized_pnl": None, "measurement_currency_id": None, "available_for_trading": "28.37140743", "enter_average_price": None, "current_price": None, "available_for_withdrawal": "28.37140743", "currency_id": "USDC", "total_statistics": [{"total": "0", "total_this_day": "0", "total_this_week": "0", "total_this_month": "0", "type": "deposit"}, {"total": "0", "total_this_day": "0", "total_this_week": "0", "total_this_month": "0", "type": "withdrawal"}, {"total": "80", "total_this_day": None, "total_this_week": None, "total_this_month": None, "type": "credit"}, {"total": "-50.9283871", "total_this_day": None, "total_this_week": None, "total_this_month": None, "type": "execution"}, {"total": "-0.70020547", "total_this_day": None, "total_this_week": None, "total_this_month": None, "type": "trading_commission"}], "properties": None}, {"account_id": "DA08BE6A-83B7-481A-BD06-5A2BE79E80E6", "balance": "0", "unrealized_pnl": None, "realized_pnl": None, "measurement_currency_id": None, "available_for_trading": "0", "enter_average_price": None, "current_price": None, "available_for_withdrawal": "0", "currency_id": "GO", "total_statistics": None, "properties": None}, {"account_id": "53BDA6C6-5C66-4BD7-BE63-D547699525A4", "balance": "0", "unrealized_pnl": None, "realized_pnl": None, "measurement_currency_id": None, "available_for_trading": "0", "enter_average_price": None, "current_price": None, "available_for_withdrawal": "0", "currency_id": "DRGN", "total_statistics": None, "properties": None}, {"account_id": "0E525961-F6D2-422F-87C1-1A82E325DC8A", "balance": "0", "unrealized_pnl": None, "realized_pnl": None, "measurement_currency_id": None, "available_for_trading": "0", "enter_average_price": None, "current_price": None, "available_for_withdrawal": "0", "currency_id": "HIVE", "total_statistics": None, "properties": None}, {"account_id": "44ED1E64-BC45-45DF-B186-2444B8279354", "balance": "0", "unrealized_pnl": None, "realized_pnl": None, "measurement_currency_id": None, "available_for_trading": "0", "enter_average_price": None, "current_price": None, "available_for_withdrawal": "0", "currency_id": "ZEC", "total_statistics": None, "properties": None}, {"account_id": "6D555386-2091-4C9E-B4FD-7F575F0D9753", "balance": "0", "unrealized_pnl": None, "realized_pnl": None, "measurement_currency_id": None, "available_for_trading": "0", "enter_average_price": None, "current_price": None, "available_for_withdrawal": "0", "currency_id": "ETC", "total_statistics": None, "properties": None}, {"account_id": "4C49C9F6-A594-43F1-A351-22C8712596CA", "balance": "0.00060541", "unrealized_pnl": None, "realized_pnl": None, "measurement_currency_id": None, "available_for_trading": "0.00060541", "enter_average_price": None, "current_price": None, "available_for_withdrawal": "0.00060541", "currency_id": "BTC", "total_statistics": [{"total": "0", "total_this_day": "0", "total_this_week": "0", "total_this_month": "0", "type": "deposit"}, {"total": "0", "total_this_day": "0", "total_this_week": "0", "total_this_month": "0", "type": "withdrawal"}, {"total": "0.0007922", "total_this_day": None, "total_this_week": None, "total_this_month": None, "type": "execution"}, {"total": "-0.00018679", "total_this_day": None, "total_this_week": None, "total_this_month": None, "type": "trading_commission"}], "properties": None}, {"account_id": "A7383484-784E-48AB-8F8A-62304FF7C37A", "balance": "0", "unrealized_pnl": None, "realized_pnl": None, "measurement_currency_id": None, "available_for_trading": "0", "enter_average_price": None, "current_price": None, "available_for_withdrawal": "0", "currency_id": "USDT", "total_statistics": None, "properties": None}, {"account_id": "1FB1DD29-406B-4227-AE2A-700D8EDCE001", "balance": "0.0611412", "unrealized_pnl": None, "realized_pnl": None, "measurement_currency_id": None, "available_for_trading": "0.0611412", "enter_average_price": None, "current_price": None, "available_for_withdrawal": "0.0611412", "currency_id": "BXY", "total_statistics": [{"total": "0", "total_this_day": "0", "total_this_week": "0", "total_this_month": "0", "type": "deposit"}, {"total": "0", "total_this_day": "0", "total_this_week": "0", "total_this_month": "0", "type": "withdrawal"}, {"total": "22500", "total_this_day": None, "total_this_week": None, "total_this_month": None, "type": "credit"}, {"total": "-22130", "total_this_day": None, "total_this_week": None, "total_this_month": None, "type": "execution"}, {"total": "-369.9388588", "total_this_day": None, "total_this_week": None, "total_this_month": None, "type": "trading_commission"}], "properties": None}, {"account_id": "FD739CEF-3FB7-4885-9A52-41A0A1C570EA", "balance": "0", "unrealized_pnl": None, "realized_pnl": None, "measurement_currency_id": None, "available_for_trading": "0", "enter_average_price": None, "current_price": None, "available_for_withdrawal": "0", "currency_id": "NRG", "total_statistics": None, "properties": None}, {"account_id": "54F5AAB9-3608-46F1-BBA8-F0996FFACE0A", "balance": "0", "unrealized_pnl": None, "realized_pnl": None, "measurement_currency_id": None, "available_for_trading": "0", "enter_average_price": None, "current_price": None, "available_for_withdrawal": "0", "currency_id": "TOMO", "total_statistics": None, "properties": None}, {"account_id": "20E33E7E-6940-4521-B3CB-DC75FD9DE0EA", "balance": "0", "unrealized_pnl": None, "realized_pnl": None, "measurement_currency_id": None, "available_for_trading": "0", "enter_average_price": None, "current_price": None, "available_for_withdrawal": "0", "currency_id": "NEO", "total_statistics": None, "properties": None}, {"account_id": "3FD59135-B069-453A-B3F8-F610F9DA7D37", "balance": "0", "unrealized_pnl": None, "realized_pnl": None, "measurement_currency_id": None, "available_for_trading": "0", "enter_average_price": None, "current_price": None, "available_for_withdrawal": "0", "currency_id": "WGR", "total_statistics": None, "properties": None}, {"account_id": "90030F73-36FE-4651-9A59-5E57464F64E6", "balance": "0", "unrealized_pnl": None, "realized_pnl": None, "measurement_currency_id": None, "available_for_trading": "0", "enter_average_price": None, "current_price": None, "available_for_withdrawal": "0", "currency_id": "FTM", "total_statistics": None, "properties": None}, {"account_id": "441649A4-C8A8-445F-8B91-182B5EC9F20D", "balance": "0", "unrealized_pnl": None, "realized_pnl": None, "measurement_currency_id": None, "available_for_trading": "0", "enter_average_price": None, "current_price": None, "available_for_withdrawal": "0", "currency_id": "EOS", "total_statistics": [{"total": "0", "total_this_day": None, "total_this_week": None, "total_this_month": None, "type": "execution"}], "properties": None}, {"account_id": "D2CF87AB-754E-4FCD-BB42-7FC812931402", "balance": "0", "unrealized_pnl": None, "realized_pnl": None, "measurement_currency_id": None, "available_for_trading": "0", "enter_average_price": None, "current_price": None, "available_for_withdrawal": "0", "currency_id": "ICX", "total_statistics": None, "properties": None}, {"account_id": "B916B506-177A-4359-86F2-80D1845B22B5", "balance": "0", "unrealized_pnl": None, "realized_pnl": None, "measurement_currency_id": None, "available_for_trading": "0", "enter_average_price": None, "current_price": None, "available_for_withdrawal": "0", "currency_id": "BEAM", "total_statistics": None, "properties": None}, {"account_id": "D3A33644-A624-4421-BAC6-1F92A93A0CB2", "balance": "0", "unrealized_pnl": None, "realized_pnl": None, "measurement_currency_id": None, "available_for_trading": "0", "enter_average_price": None, "current_price": None, "available_for_withdrawal": "0", "currency_id": "USD", "total_statistics": None, "properties": None}] + + SYMBOLS = [ { "symbol": "BXYBTC", "name": "BXYBTC", @@ -883,7 +889,7 @@ class FixtureBeaxy: }, ] - HEALTH = {"logins": 204, "is_alive": True, "users": 10, "timestamp": 1612207143392} + HEALTH = {"trading_server": 200, "historical_data_server": 200} SNAPSHOT_MSG = { "type": "SNAPSHOT_FULL_REFRESH", @@ -1070,277 +1076,42 @@ class FixtureBeaxy: ], } - TEST_LIMIT_BUY_ORDER = { - "average_price": "0", - "receipt_time": 1612351106032, - "close_time": 0, - "reason": None, - "cumulative_quantity": "0", - "remaining_quantity": "0.01", - "status": "pending_new", - "id": "1D37D726-E162-484B-8816-A43B43549CDD", - "timestamp": 1612351106032, - "stop_price": None, - "leverage": None, - "submission_time": None, - "quantity": "0.01", - "limit_price": "0.003266", - "client_order_id": "1D37D726-E162-484B-8816-A43B43549CDD", - "time_in_force": "gtc", - "price": "0.003266", - "expire_time": None, - "text": "HBOT-buy-DASH-BTC-1", - "destination": "MAXI", - "security_id": "DASHBTC", - "side": "buy", - "type": "limit", - "source": "CWUI", - "currency": None, - "properties": None, - } + OPEN_ORDERS = [{'order_id': '40DF064B-D1C9-4A5E-9C14-C27E34593632', 'symbol': 'BXYBTC', 'wallet_id': '4C49C9F6-A594-43F1-A351-22C8712596CA', 'comment': 'HBOT-buy-BXY-BTC-1613578289001047', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'buy', 'order_status': 'new', 'size': '3353', 'limit_price': '0.00000021', 'stop_price': None, 'filled_size': None, 'average_price': None, 'open_time': '2021-02-17T16:11:29.3930000Z', 'close_time': None, 'pay_with_utility_token': False, 'post_only': False}] + + CLOSED_ORDERS = [{'order_id': '47BC38D7-A4B4-48D7-A98E-E8E40DE72891', 'symbol': 'DASHBTC', 'wallet_id': '0B0C8512-A113-4D57-B4C6-D059746B302D', 'comment': 'HBOT-sell-DASH-BTC-1613498528000754', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'sell', 'order_status': 'completely_filled', 'size': '0.01', 'limit_price': '0.005238', 'stop_price': None, 'filled_size': None, 'average_price': '0.005238', 'open_time': '2021-02-16T18:02:08.1980000Z', 'close_time': '2021-02-16T18:02:22.7790000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': '6E70B918-D13D-49A5-B4A3-032A4E2C8120', 'symbol': 'DASHBTC', 'wallet_id': '0B0C8512-A113-4D57-B4C6-D059746B302D', 'comment': 'HBOT-sell-DASH-BTC-1613498601001478', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'sell', 'order_status': 'completely_filled', 'size': '0.01', 'limit_price': '0.005238', 'stop_price': None, 'filled_size': None, 'average_price': '0.005242', 'open_time': '2021-02-16T18:03:21.1600000Z', 'close_time': '2021-02-16T18:03:21.1850000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': '72BBF0DE-4B80-438C-8EB1-C1EF51A10B9D', 'symbol': 'DASHBTC', 'wallet_id': '4C49C9F6-A594-43F1-A351-22C8712596CA', 'comment': 'HBOT-buy-DASH-BTC-1613498601001235', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'buy', 'order_status': 'canceled', 'size': '0.01', 'limit_price': '0.005228', 'stop_price': None, 'filled_size': None, 'average_price': None, 'open_time': '2021-02-16T18:03:21.4150000Z', 'close_time': '2021-02-16T18:03:26.3670000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': '883AAB4C-3A45-4F4F-A265-232D5E49D4AD', 'symbol': 'DASHBTC', 'wallet_id': '4C49C9F6-A594-43F1-A351-22C8712596CA', 'comment': 'HBOT-buy-DASH-BTC-1613498528000608', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'buy', 'order_status': 'completely_filled', 'size': '0.01', 'limit_price': '0.005228', 'stop_price': None, 'filled_size': None, 'average_price': '0.005228', 'open_time': '2021-02-16T18:02:08.2590000Z', 'close_time': '2021-02-16T18:06:57.4830000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': '57B7A379-D77A-4DAA-AA77-074CFAB4339E', 'symbol': 'DASHBTC', 'wallet_id': '4C49C9F6-A594-43F1-A351-22C8712596CA', 'comment': 'HBOT-buy-DASH-BTC-1613498662001030', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'buy', 'order_status': 'completely_filled', 'size': '0.01', 'limit_price': '0.005228', 'stop_price': None, 'filled_size': None, 'average_price': '0.005228', 'open_time': '2021-02-16T18:04:22.1680000Z', 'close_time': '2021-02-16T18:06:57.4850000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': '23057BE1-187F-4288-A517-C50884FF4FAA', 'symbol': 'DASHBTC', 'wallet_id': '4C49C9F6-A594-43F1-A351-22C8712596CA', 'comment': 'HBOT-buy-DASH-BTC-1613498736001136', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'buy', 'order_status': 'completely_filled', 'size': '0.01', 'limit_price': '0.005228', 'stop_price': None, 'filled_size': None, 'average_price': '0.005228', 'open_time': '2021-02-16T18:05:36.0030000Z', 'close_time': '2021-02-16T18:06:57.4880000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': 'D2ACE2E4-4E1D-4B30-9A32-8A34F1EE4613', 'symbol': 'DASHBTC', 'wallet_id': '4C49C9F6-A594-43F1-A351-22C8712596CA', 'comment': 'HBOT-buy-DASH-BTC-1613498816001458', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'buy', 'order_status': 'completely_filled', 'size': '0.01', 'limit_price': '0.005228', 'stop_price': None, 'filled_size': None, 'average_price': '0.005228', 'open_time': '2021-02-16T18:06:56.0310000Z', 'close_time': '2021-02-16T18:06:57.4890000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': '234BF40A-120D-4D4E-962C-402653495201', 'symbol': 'DASHBTC', 'wallet_id': '4C49C9F6-A594-43F1-A351-22C8712596CA', 'comment': 'HBOT-buy-DASH-BTC-1613498878000721', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'buy', 'order_status': 'completely_filled', 'size': '0.01', 'limit_price': '0.005228', 'stop_price': None, 'filled_size': None, 'average_price': '0.005217', 'open_time': '2021-02-16T18:07:58.1400000Z', 'close_time': '2021-02-16T18:07:58.1500000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': 'D0BFFA3F-3E44-47E4-8370-A80BBC593426', 'symbol': 'DASHBTC', 'wallet_id': '0B0C8512-A113-4D57-B4C6-D059746B302D', 'comment': 'HBOT-sell-DASH-BTC-1613498878000899', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'sell', 'order_status': 'canceled', 'size': '0.01', 'limit_price': '0.005238', 'stop_price': None, 'filled_size': None, 'average_price': None, 'open_time': '2021-02-16T18:08:01.5600000Z', 'close_time': '2021-02-16T18:08:03.3850000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': 'E625BAB1-D26F-4CDA-86C6-4809DB76D828', 'symbol': 'DASHBTC', 'wallet_id': '4C49C9F6-A594-43F1-A351-22C8712596CA', 'comment': 'HBOT-buy-DASH-BTC-1613498939001837', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'buy', 'order_status': 'completely_filled', 'size': '0.01', 'limit_price': '0.005228', 'stop_price': None, 'filled_size': None, 'average_price': '0.005208', 'open_time': '2021-02-16T18:08:59.1630000Z', 'close_time': '2021-02-16T18:08:59.1690000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': '7F572842-B33D-43AA-99AA-49A3B2D8DC68', 'symbol': 'DASHBTC', 'wallet_id': '0B0C8512-A113-4D57-B4C6-D059746B302D', 'comment': 'HBOT-sell-DASH-BTC-1613498939001997', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'sell', 'order_status': 'canceled', 'size': '0.01', 'limit_price': '0.005238', 'stop_price': None, 'filled_size': None, 'average_price': None, 'open_time': '2021-02-16T18:08:59.1120000Z', 'close_time': '2021-02-16T18:09:04.2950000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': 'AF928BB6-B84E-4352-8398-14EF2B0E0D2C', 'symbol': 'DASHBTC', 'wallet_id': '4C49C9F6-A594-43F1-A351-22C8712596CA', 'comment': 'HBOT-buy-DASH-BTC-1613499000001648', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'buy', 'order_status': 'completely_filled', 'size': '0.01', 'limit_price': '0.005228', 'stop_price': None, 'filled_size': None, 'average_price': '0.005179', 'open_time': '2021-02-16T18:10:00.1530000Z', 'close_time': '2021-02-16T18:10:00.1720000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': '4C6AF65F-B1E3-4770-A086-A134E0CD21A6', 'symbol': 'DASHBTC', 'wallet_id': '0B0C8512-A113-4D57-B4C6-D059746B302D', 'comment': 'HBOT-sell-DASH-BTC-1613499000001884', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'sell', 'order_status': 'canceled', 'size': '0.01', 'limit_price': '0.005238', 'stop_price': None, 'filled_size': None, 'average_price': None, 'open_time': '2021-02-16T18:10:00.1740000Z', 'close_time': '2021-02-16T18:10:05.2920000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': '9EC5A818-0450-4229-9947-46C83913D45B', 'symbol': 'DASHBTC', 'wallet_id': '0B0C8512-A113-4D57-B4C6-D059746B302D', 'comment': 'HBOT-sell-DASH-BTC-1613499417001856', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'sell', 'order_status': 'completely_filled', 'size': '0.01', 'limit_price': '0.005219', 'stop_price': None, 'filled_size': None, 'average_price': '0.005219', 'open_time': '2021-02-16T18:16:57.1740000Z', 'close_time': '2021-02-16T18:21:19.5160000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': '2B5C1601-59EE-47CA-9478-F895144C40D9', 'symbol': 'DASHBTC', 'wallet_id': '0B0C8512-A113-4D57-B4C6-D059746B302D', 'comment': 'HBOT-sell-DASH-BTC-1613499491001047', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'sell', 'order_status': 'completely_filled', 'size': '0.01', 'limit_price': '0.005219', 'stop_price': None, 'filled_size': None, 'average_price': '0.005219', 'open_time': '2021-02-16T18:18:11.1420000Z', 'close_time': '2021-02-16T18:21:19.5190000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': '1D53E604-CB9A-4B34-A61D-AE5D18B5C87E', 'symbol': 'DASHBTC', 'wallet_id': '0B0C8512-A113-4D57-B4C6-D059746B302D', 'comment': 'HBOT-sell-DASH-BTC-1613499680001068', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'sell', 'order_status': 'completely_filled', 'size': '0.01', 'limit_price': '0.005224', 'stop_price': None, 'filled_size': None, 'average_price': '0.005227', 'open_time': '2021-02-16T18:21:20.1420000Z', 'close_time': '2021-02-16T18:21:20.1730000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': '4FB3D3FA-7143-4193-ABB7-BD54FD63153E', 'symbol': 'DASHBTC', 'wallet_id': '4C49C9F6-A594-43F1-A351-22C8712596CA', 'comment': 'HBOT-buy-DASH-BTC-1613499680000919', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'buy', 'order_status': 'canceled', 'size': '0.01', 'limit_price': '0.005213', 'stop_price': None, 'filled_size': None, 'average_price': None, 'open_time': '2021-02-16T18:21:20.1750000Z', 'close_time': '2021-02-16T18:21:25.3100000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': '0275D285-50E2-4DBE-9A5B-32B91C67639B', 'symbol': 'DASHBTC', 'wallet_id': '0B0C8512-A113-4D57-B4C6-D059746B302D', 'comment': 'HBOT-sell-DASH-BTC-1613498662001316', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'sell', 'order_status': 'completely_filled', 'size': '0.01', 'limit_price': '0.005238', 'stop_price': None, 'filled_size': None, 'average_price': '0.005238', 'open_time': '2021-02-16T18:04:22.1980000Z', 'close_time': '2021-02-16T18:22:33.2570000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': '2F562943-529C-45D3-8804-11F0FB334213', 'symbol': 'DASHBTC', 'wallet_id': '0B0C8512-A113-4D57-B4C6-D059746B302D', 'comment': 'HBOT-sell-DASH-BTC-1613498736001579', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'sell', 'order_status': 'completely_filled', 'size': '0.01', 'limit_price': '0.005238', 'stop_price': None, 'filled_size': None, 'average_price': '0.005238', 'open_time': '2021-02-16T18:05:36.2030000Z', 'close_time': '2021-02-16T18:22:33.2650000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': '2CD1384E-1557-4082-BCFE-AD3D17CE0FD3', 'symbol': 'DASHBTC', 'wallet_id': '0B0C8512-A113-4D57-B4C6-D059746B302D', 'comment': 'HBOT-sell-DASH-BTC-1613500261002133', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'sell', 'order_status': 'canceled', 'size': '0.01', 'limit_price': '0.005244', 'stop_price': None, 'filled_size': None, 'average_price': None, 'open_time': '2021-02-16T18:31:10.6720000Z', 'close_time': '2021-02-16T18:31:29.3330000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': 'B690CAFD-10DF-4B1A-A3D6-29B1E20481BA', 'symbol': 'DASHBTC', 'wallet_id': '4C49C9F6-A594-43F1-A351-22C8712596CA', 'comment': 'HBOT-buy-DASH-BTC-1613500261001971', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'buy', 'order_status': 'canceled', 'size': '0.01', 'limit_price': '0.005234', 'stop_price': None, 'filled_size': None, 'average_price': None, 'open_time': '2021-02-16T18:31:08.8130000Z', 'close_time': '2021-02-16T18:31:31.0880000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': '66763704-7873-4169-A364-59D44F2F5F1F', 'symbol': 'DASHBTC', 'wallet_id': '0B0C8512-A113-4D57-B4C6-D059746B302D', 'comment': 'HBOT-sell-DASH-BTC-1613500186001467', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'sell', 'order_status': 'canceled', 'size': '0.01', 'limit_price': '0.005244', 'stop_price': None, 'filled_size': None, 'average_price': None, 'open_time': '2021-02-16T18:29:46.1660000Z', 'close_time': '2021-02-16T18:31:32.3590000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': '7BAF22E0-C904-419B-8896-59BC34F0F9A0', 'symbol': 'DASHBTC', 'wallet_id': '4C49C9F6-A594-43F1-A351-22C8712596CA', 'comment': 'HBOT-buy-DASH-BTC-1613500186001254', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'buy', 'order_status': 'canceled', 'size': '0.01', 'limit_price': '0.005234', 'stop_price': None, 'filled_size': None, 'average_price': None, 'open_time': '2021-02-16T18:29:46.1570000Z', 'close_time': '2021-02-16T18:31:33.6310000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': 'BA80B9E7-353C-44ED-A40F-82D22668B741', 'symbol': 'DASHBTC', 'wallet_id': '4C49C9F6-A594-43F1-A351-22C8712596CA', 'comment': 'HBOT-buy-DASH-BTC-1613499491000894', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'buy', 'order_status': 'canceled', 'size': '0.01', 'limit_price': '0.005208', 'stop_price': None, 'filled_size': None, 'average_price': None, 'open_time': '2021-02-16T18:18:11.1830000Z', 'close_time': '2021-02-16T18:31:34.8160000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': '310BA54E-8E0C-4D0C-92AD-469EDAC07B6E', 'symbol': 'DASHBTC', 'wallet_id': '4C49C9F6-A594-43F1-A351-22C8712596CA', 'comment': 'HBOT-buy-DASH-BTC-1613499417001641', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'buy', 'order_status': 'canceled', 'size': '0.01', 'limit_price': '0.005208', 'stop_price': None, 'filled_size': None, 'average_price': None, 'open_time': '2021-02-16T18:16:57.1570000Z', 'close_time': '2021-02-16T18:31:36.1130000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': '904C9D8B-4D9A-4A55-A1F6-F64CE1A2F4F0', 'symbol': 'DASHBTC', 'wallet_id': '4C49C9F6-A594-43F1-A351-22C8712596CA', 'comment': 'HBOT-buy-DASH-BTC-1613500336001738', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'buy', 'order_status': 'canceled', 'size': '0.01', 'limit_price': '0.005221', 'stop_price': None, 'filled_size': None, 'average_price': None, 'open_time': '2021-02-16T18:32:16.2100000Z', 'close_time': '2021-02-16T18:32:58.2170000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': 'ED244640-F7AF-44CB-B577-2BCEF01A2DE4', 'symbol': 'DASHBTC', 'wallet_id': '0B0C8512-A113-4D57-B4C6-D059746B302D', 'comment': 'HBOT-sell-DASH-BTC-1613500336002224', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'sell', 'order_status': 'canceled', 'size': '0.01', 'limit_price': '0.005232', 'stop_price': None, 'filled_size': None, 'average_price': None, 'open_time': '2021-02-16T18:32:16.1630000Z', 'close_time': '2021-02-16T18:32:59.5990000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': 'AA44C5C4-00F1-45F5-ACA2-A4DFE5D8EDB2', 'symbol': 'DASHBTC', 'wallet_id': '0B0C8512-A113-4D57-B4C6-D059746B302D', 'comment': 'HBOT-sell-DASH-BTC-1613500436001062', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'sell', 'order_status': 'canceled', 'size': '0.01', 'limit_price': '0.005208', 'stop_price': None, 'filled_size': None, 'average_price': None, 'open_time': '2021-02-16T18:33:56.1730000Z', 'close_time': '2021-02-16T18:34:24.6560000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': '38A25D71-B110-43C6-9060-071430352F0D', 'symbol': 'DASHBTC', 'wallet_id': '4C49C9F6-A594-43F1-A351-22C8712596CA', 'comment': 'HBOT-buy-DASH-BTC-1613500436000888', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'buy', 'order_status': 'canceled', 'size': '0.01', 'limit_price': '0.005198', 'stop_price': None, 'filled_size': None, 'average_price': None, 'open_time': '2021-02-16T18:33:55.7590000Z', 'close_time': '2021-02-16T18:34:25.9860000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': '0F5B9334-0EEC-4F29-8128-2CBAB4E15AC1', 'symbol': 'DASHBTC', 'wallet_id': '0B0C8512-A113-4D57-B4C6-D059746B302D', 'comment': 'HBOT-sell-DASH-BTC-1613500605003800', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'sell', 'order_status': 'completely_filled', 'size': '0.01', 'limit_price': '0.005207', 'stop_price': None, 'filled_size': None, 'average_price': '0.005207', 'open_time': '2021-02-16T18:36:45.2350000Z', 'close_time': '2021-02-16T18:40:46.8300000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': '7FAE0E46-1F55-4411-86B0-B63B4E48268B', 'symbol': 'DASHBTC', 'wallet_id': '0B0C8512-A113-4D57-B4C6-D059746B302D', 'comment': 'HBOT-sell-DASH-BTC-1613500684001365', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'sell', 'order_status': 'completely_filled', 'size': '0.01', 'limit_price': '0.005207', 'stop_price': None, 'filled_size': None, 'average_price': '0.005207', 'open_time': '2021-02-16T18:38:03.8960000Z', 'close_time': '2021-02-16T18:40:46.8320000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': 'CA7498FF-AFF4-4A69-872F-2C8E99DD7122', 'symbol': 'DASHBTC', 'wallet_id': '4C49C9F6-A594-43F1-A351-22C8712596CA', 'comment': 'HBOT-buy-DASH-BTC-1613500684001215', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'buy', 'order_status': 'canceled', 'size': '0.01', 'limit_price': '0.005197', 'stop_price': None, 'filled_size': None, 'average_price': None, 'open_time': '2021-02-16T18:38:04.1700000Z', 'close_time': '2021-02-16T18:40:52.6270000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': 'FA36C109-9C70-44FB-8D52-8C4BF297F9D0', 'symbol': 'DASHBTC', 'wallet_id': '4C49C9F6-A594-43F1-A351-22C8712596CA', 'comment': 'HBOT-buy-DASH-BTC-1613500605003558', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'buy', 'order_status': 'canceled', 'size': '0.01', 'limit_price': '0.005197', 'stop_price': None, 'filled_size': None, 'average_price': None, 'open_time': '2021-02-16T18:36:45.1410000Z', 'close_time': '2021-02-16T18:40:53.9290000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': '80F33E81-43FC-4B34-A386-5BCB91E78A9A', 'symbol': 'DASHBTC', 'wallet_id': '4C49C9F6-A594-43F1-A351-22C8712596CA', 'comment': 'HBOT-buy-DASH-BTC-1613501472000799', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'buy', 'order_status': 'canceled', 'size': '0.01', 'limit_price': '0.005235', 'stop_price': None, 'filled_size': None, 'average_price': None, 'open_time': '2021-02-16T18:51:12.0860000Z', 'close_time': '2021-02-16T18:51:17.6990000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': 'EC3F3D1C-38F0-4A7A-B90C-5E7C706C1C7D', 'symbol': 'DASHBTC', 'wallet_id': '0B0C8512-A113-4D57-B4C6-D059746B302D', 'comment': 'HBOT-sell-DASH-BTC-1613501472000934', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'sell', 'order_status': 'canceled', 'size': '0.01', 'limit_price': '0.005245', 'stop_price': None, 'filled_size': None, 'average_price': None, 'open_time': '2021-02-16T18:51:12.1450000Z', 'close_time': '2021-02-16T18:51:17.7060000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': '2C6261DD-F5CD-4136-86D1-6DF41B32E984', 'symbol': 'BXYBTC', 'wallet_id': '4C49C9F6-A594-43F1-A351-22C8712596CA', 'comment': 'HBOT-buy-BXY-BTC-1613501681001866', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'buy', 'order_status': 'canceled', 'size': '2500', 'limit_price': '0.00000022', 'stop_price': None, 'filled_size': None, 'average_price': None, 'open_time': '2021-02-16T18:54:41.0550000Z', 'close_time': '2021-02-16T18:57:00.4650000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': '053E3A5D-4F2F-42C4-B3F6-297F75017815', 'symbol': 'DASHBTC', 'wallet_id': '4C49C9F6-A594-43F1-A351-22C8712596CA', 'comment': 'HBOT-buy-DASH-BTC-1613501839001647', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'buy', 'order_status': 'canceled', 'size': '0.01', 'limit_price': '0.005258', 'stop_price': None, 'filled_size': None, 'average_price': None, 'open_time': '2021-02-16T18:57:19.0500000Z', 'close_time': '2021-02-16T18:59:16.7420000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': '90C1D9F6-5993-4677-BDB2-8D21E2D7678C', 'symbol': 'DASHBTC', 'wallet_id': '0B0C8512-A113-4D57-B4C6-D059746B302D', 'comment': 'HBOT-sell-DASH-BTC-1613501839001952', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'sell', 'order_status': 'canceled', 'size': '0.01', 'limit_price': '0.00526', 'stop_price': None, 'filled_size': None, 'average_price': None, 'open_time': '2021-02-16T18:57:19.3690000Z', 'close_time': '2021-02-16T18:59:16.7830000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': '60C56397-08A9-438B-B33B-233258D2BA9D', 'symbol': 'DASHBTC', 'wallet_id': '4C49C9F6-A594-43F1-A351-22C8712596CA', 'comment': 'HBOT-buy-DASH-BTC-1613502483001655', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'buy', 'order_status': 'canceled', 'size': '0.01', 'limit_price': '0.005255', 'stop_price': None, 'filled_size': None, 'average_price': None, 'open_time': '2021-02-16T19:08:09.0510000Z', 'close_time': '2021-02-16T19:08:44.7620000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': '9B2F1A76-84D0-40B2-B3E0-115DC928350A', 'symbol': 'DASHBTC', 'wallet_id': '0B0C8512-A113-4D57-B4C6-D059746B302D', 'comment': 'HBOT-sell-DASH-BTC-1613502483001922', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'sell', 'order_status': 'canceled', 'size': '0.01', 'limit_price': '0.005265', 'stop_price': None, 'filled_size': None, 'average_price': None, 'open_time': '2021-02-16T19:08:03.0100000Z', 'close_time': '2021-02-16T19:08:44.7740000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': '55088A1D-BD8B-4657-B07D-FAE8E44AC6B6', 'symbol': 'DASHBTC', 'wallet_id': '0B0C8512-A113-4D57-B4C6-D059746B302D', 'comment': 'HBOT-sell-DASH-BTC-1613503357001360', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'sell', 'order_status': 'canceled', 'size': '0.01', 'limit_price': '0.00524', 'stop_price': None, 'filled_size': None, 'average_price': None, 'open_time': '2021-02-16T19:22:39.1340000Z', 'close_time': '2021-02-16T19:23:12.6180000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': 'A26A7E86-F7FA-4203-A968-1CF477F0B6AC', 'symbol': 'DASHBTC', 'wallet_id': '4C49C9F6-A594-43F1-A351-22C8712596CA', 'comment': 'HBOT-buy-DASH-BTC-1613503393000810', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'buy', 'order_status': 'canceled', 'size': '0.01', 'limit_price': '0.00522', 'stop_price': None, 'filled_size': None, 'average_price': None, 'open_time': '2021-02-16T19:23:43.6570000Z', 'close_time': '2021-02-16T19:24:04.5460000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': '325141A6-906F-4085-9052-E3ADEABEC25F', 'symbol': 'DASHBTC', 'wallet_id': '4C49C9F6-A594-43F1-A351-22C8712596CA', 'comment': 'HBOT-buy-DASH-BTC-1613503445001419', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'buy', 'order_status': 'canceled', 'size': '0.01', 'limit_price': '0.00522', 'stop_price': None, 'filled_size': None, 'average_price': None, 'open_time': '2021-02-16T19:24:06.2390000Z', 'close_time': '2021-02-16T19:25:45.5970000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': 'A68FCAED-42CA-4395-B258-AF249BE5E219', 'symbol': 'DASHBTC', 'wallet_id': '4C49C9F6-A594-43F1-A351-22C8712596CA', 'comment': 'HBOT-buy-DASH-BTC-1613509633002402', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'buy', 'order_status': 'canceled', 'size': '0.01', 'limit_price': '0.005057', 'stop_price': None, 'filled_size': None, 'average_price': None, 'open_time': '2021-02-16T21:07:13.2270000Z', 'close_time': '2021-02-16T21:08:54.3440000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': 'F63638A6-0DCF-4042-8DEF-77C2567D6878', 'symbol': 'DASHBTC', 'wallet_id': '0B0C8512-A113-4D57-B4C6-D059746B302D', 'comment': 'HBOT-sell-DASH-BTC-1613509633002594', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'sell', 'order_status': 'canceled', 'size': '0.01', 'limit_price': '0.005068', 'stop_price': None, 'filled_size': None, 'average_price': None, 'open_time': '2021-02-16T21:07:13.0700000Z', 'close_time': '2021-02-16T21:08:54.5220000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': '205E0588-0CCA-4180-AD43-3D29BC7F9304', 'symbol': 'DASHBTC', 'wallet_id': '0B0C8512-A113-4D57-B4C6-D059746B302D', 'comment': 'HBOT-sell-DASH-BTC-1613509871001581', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'sell', 'order_status': 'canceled', 'size': '0.01', 'limit_price': '0.005055', 'stop_price': None, 'filled_size': None, 'average_price': None, 'open_time': '2021-02-16T21:11:11.0850000Z', 'close_time': '2021-02-16T21:12:10.4420000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': '77ABDD20-2418-4789-8655-FF0036DD092A', 'symbol': 'DASHBTC', 'wallet_id': '4C49C9F6-A594-43F1-A351-22C8712596CA', 'comment': 'HBOT-buy-DASH-BTC-1613509871001281', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'buy', 'order_status': 'canceled', 'size': '0.01', 'limit_price': '0.005044', 'stop_price': None, 'filled_size': None, 'average_price': None, 'open_time': '2021-02-16T21:11:11.1720000Z', 'close_time': '2021-02-16T21:12:10.5370000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': '527BB162-38E0-447C-9222-C21E5BBB6ABF', 'symbol': 'DASHBTC', 'wallet_id': '4C49C9F6-A594-43F1-A351-22C8712596CA', 'comment': 'HBOT-buy-DASH-BTC-1613509990001191', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'buy', 'order_status': 'canceled', 'size': '0.01', 'limit_price': '0.005049', 'stop_price': None, 'filled_size': None, 'average_price': None, 'open_time': '2021-02-16T21:13:10.1610000Z', 'close_time': '2021-02-16T21:14:13.6860000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': 'A07033FD-C6FD-498C-8FFD-85AEE6FA237D', 'symbol': 'DASHBTC', 'wallet_id': '0B0C8512-A113-4D57-B4C6-D059746B302D', 'comment': 'HBOT-sell-DASH-BTC-1613509990001443', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'sell', 'order_status': 'canceled', 'size': '0.01', 'limit_price': '0.00506', 'stop_price': None, 'filled_size': None, 'average_price': None, 'open_time': '2021-02-16T21:13:10.1680000Z', 'close_time': '2021-02-16T21:14:13.6870000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': '82DF808B-7FB0-4929-AE74-E16C56250B39', 'symbol': 'DASHBTC', 'wallet_id': '4C49C9F6-A594-43F1-A351-22C8712596CA', 'comment': 'HBOT-buy-DASH-BTC-1613510101001709', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'buy', 'order_status': 'canceled', 'size': '0.01', 'limit_price': '0.005059', 'stop_price': None, 'filled_size': None, 'average_price': None, 'open_time': '2021-02-16T21:15:00.9990000Z', 'close_time': '2021-02-16T21:15:35.9550000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': '820325F4-F782-4AB2-9F96-4ADDFF659EAE', 'symbol': 'DASHBTC', 'wallet_id': '0B0C8512-A113-4D57-B4C6-D059746B302D', 'comment': 'HBOT-sell-DASH-BTC-1613510101001850', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'sell', 'order_status': 'canceled', 'size': '0.01', 'limit_price': '0.005069', 'stop_price': None, 'filled_size': None, 'average_price': None, 'open_time': '2021-02-16T21:15:01.3150000Z', 'close_time': '2021-02-16T21:15:35.9560000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': '980821A3-7A7D-4D61-A7DB-76B9409460DC', 'symbol': 'DASHBTC', 'wallet_id': '4C49C9F6-A594-43F1-A351-22C8712596CA', 'comment': 'HBOT-buy-DASH-BTC-1613510302002418', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'buy', 'order_status': 'completely_filled', 'size': '0.01', 'limit_price': '0.005063', 'stop_price': None, 'filled_size': None, 'average_price': '0.005063', 'open_time': '2021-02-16T21:18:22.1680000Z', 'close_time': '2021-02-16T21:18:53.3020000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': 'C2136AB3-4DC9-414F-95EE-9ADFA19B28BB', 'symbol': 'DASHBTC', 'wallet_id': '0B0C8512-A113-4D57-B4C6-D059746B302D', 'comment': 'HBOT-sell-DASH-BTC-1613510302002689', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'sell', 'order_status': 'canceled', 'size': '0.01', 'limit_price': '0.005074', 'stop_price': None, 'filled_size': None, 'average_price': None, 'open_time': '2021-02-16T21:18:22.1260000Z', 'close_time': '2021-02-16T21:18:57.3530000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': 'F1D3CF05-80E0-4BB1-958F-3613003855C7', 'symbol': 'DASHBTC', 'wallet_id': '0B0C8512-A113-4D57-B4C6-D059746B302D', 'comment': 'HBOT-sell-DASH-BTC-1613510401000776', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'sell', 'order_status': 'canceled', 'size': '0.01', 'limit_price': '0.005027', 'stop_price': None, 'filled_size': None, 'average_price': None, 'open_time': '2021-02-16T21:20:01.1540000Z', 'close_time': '2021-02-16T21:21:00.5210000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': 'E14BE50F-C4F3-4304-9CE5-7726D9D49E95', 'symbol': 'DASHBTC', 'wallet_id': '4C49C9F6-A594-43F1-A351-22C8712596CA', 'comment': 'HBOT-buy-DASH-BTC-1613510401000615', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'buy', 'order_status': 'canceled', 'size': '0.01', 'limit_price': '0.005017', 'stop_price': None, 'filled_size': None, 'average_price': None, 'open_time': '2021-02-16T21:20:01.1700000Z', 'close_time': '2021-02-16T21:21:00.5430000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': '9A91B273-6F3A-4880-B499-370CF0105145', 'symbol': 'DASHBTC', 'wallet_id': '4C49C9F6-A594-43F1-A351-22C8712596CA', 'comment': 'HBOT-buy-DASH-BTC-1613510491001927', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'buy', 'order_status': 'completely_filled', 'size': '0.01', 'limit_price': '0.005048', 'stop_price': None, 'filled_size': None, 'average_price': '0.005048', 'open_time': '2021-02-16T21:21:31.0900000Z', 'close_time': '2021-02-16T21:21:57.0120000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': '1FD898EB-7C46-4541-8AFC-14D009BE5858', 'symbol': 'DASHBTC', 'wallet_id': '0B0C8512-A113-4D57-B4C6-D059746B302D', 'comment': 'HBOT-sell-DASH-BTC-1613510491002200', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'sell', 'order_status': 'canceled', 'size': '0.01', 'limit_price': '0.005058', 'stop_price': None, 'filled_size': None, 'average_price': None, 'open_time': '2021-02-16T21:21:31.1680000Z', 'close_time': '2021-02-16T21:22:01.2960000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': '56E78ECF-B092-40F0-BC3D-0AAAB2C4C76A', 'symbol': 'DASHBTC', 'wallet_id': '4C49C9F6-A594-43F1-A351-22C8712596CA', 'comment': 'HBOT-buy-DASH-BTC-1613510578001784', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'buy', 'order_status': 'completely_filled', 'size': '0.01', 'limit_price': '0.005048', 'stop_price': None, 'filled_size': None, 'average_price': '0.005048', 'open_time': '2021-02-16T21:22:58.1690000Z', 'close_time': '2021-02-16T21:22:58.1780000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': '3FECC3BC-A2AF-45B6-877B-2FCF23EBC811', 'symbol': 'DASHBTC', 'wallet_id': '0B0C8512-A113-4D57-B4C6-D059746B302D', 'comment': 'HBOT-sell-DASH-BTC-1613510578001934', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'sell', 'order_status': 'canceled', 'size': '0.01', 'limit_price': '0.005058', 'stop_price': None, 'filled_size': None, 'average_price': None, 'open_time': '2021-02-16T21:22:58.1720000Z', 'close_time': '2021-02-16T21:23:03.2950000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': 'D0E08A30-37FC-477D-BD7D-6596F021E93F', 'symbol': 'DASHBTC', 'wallet_id': '4C49C9F6-A594-43F1-A351-22C8712596CA', 'comment': 'HBOT-buy-DASH-BTC-1613510639000795', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'buy', 'order_status': 'completely_filled', 'size': '0.01', 'limit_price': '0.005048', 'stop_price': None, 'filled_size': None, 'average_price': '0.00504', 'open_time': '2021-02-16T21:23:59.0120000Z', 'close_time': '2021-02-16T21:23:59.1740000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': 'FA30C9BB-B1E6-4F48-B695-1F93F5031B9A', 'symbol': 'DASHBTC', 'wallet_id': '0B0C8512-A113-4D57-B4C6-D059746B302D', 'comment': 'HBOT-sell-DASH-BTC-1613510639000972', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'sell', 'order_status': 'canceled', 'size': '0.01', 'limit_price': '0.005058', 'stop_price': None, 'filled_size': None, 'average_price': None, 'open_time': '2021-02-16T21:23:59.1740000Z', 'close_time': '2021-02-16T21:24:04.5200000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': '594FEC28-FBFB-410E-89B4-9B172EE3BBDB', 'symbol': 'DASHBTC', 'wallet_id': '4C49C9F6-A594-43F1-A351-22C8712596CA', 'comment': 'HBOT-buy-DASH-BTC-1613510700001330', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'buy', 'order_status': 'completely_filled', 'size': '0.01', 'limit_price': '0.005048', 'stop_price': None, 'filled_size': None, 'average_price': '0.005045', 'open_time': '2021-02-16T21:25:00.1710000Z', 'close_time': '2021-02-16T21:25:00.1770000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': '31FD95F9-60E9-4198-8E00-763EFE41BECD', 'symbol': 'DASHBTC', 'wallet_id': '0B0C8512-A113-4D57-B4C6-D059746B302D', 'comment': 'HBOT-sell-DASH-BTC-1613510700001526', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'sell', 'order_status': 'canceled', 'size': '0.01', 'limit_price': '0.005058', 'stop_price': None, 'filled_size': None, 'average_price': None, 'open_time': '2021-02-16T21:25:00.1630000Z', 'close_time': '2021-02-16T21:25:05.2610000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': '4716A6CE-2862-4C54-8B0B-B66394D7AD2D', 'symbol': 'DASHBTC', 'wallet_id': '0B0C8512-A113-4D57-B4C6-D059746B302D', 'comment': 'HBOT-sell-DASH-BTC-1613510761001343', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'sell', 'order_status': 'completely_filled', 'size': '0.01', 'limit_price': '0.005058', 'stop_price': None, 'filled_size': None, 'average_price': '0.005058', 'open_time': '2021-02-16T21:26:01.1810000Z', 'close_time': '2021-02-16T21:26:34.0220000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': 'E6ECB484-2BD3-424F-8995-E4AD62381819', 'symbol': 'DASHBTC', 'wallet_id': '4C49C9F6-A594-43F1-A351-22C8712596CA', 'comment': 'HBOT-buy-DASH-BTC-1613510761001203', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'buy', 'order_status': 'canceled', 'size': '0.01', 'limit_price': '0.005048', 'stop_price': None, 'filled_size': None, 'average_price': None, 'open_time': '2021-02-16T21:26:01.0750000Z', 'close_time': '2021-02-16T21:26:36.5330000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': 'A1D9CB4F-0D94-49C8-B284-9C24F6F25C87', 'symbol': 'DASHBTC', 'wallet_id': '0B0C8512-A113-4D57-B4C6-D059746B302D', 'comment': 'HBOT-sell-DASH-BTC-1613510855001470', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'sell', 'order_status': 'completely_filled', 'size': '0.01', 'limit_price': '0.005058', 'stop_price': None, 'filled_size': None, 'average_price': '0.005063', 'open_time': '2021-02-16T21:27:35.1540000Z', 'close_time': '2021-02-16T21:27:35.1670000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': '133E522E-B3E3-4CFE-969E-95C25C437700', 'symbol': 'DASHBTC', 'wallet_id': '4C49C9F6-A594-43F1-A351-22C8712596CA', 'comment': 'HBOT-buy-DASH-BTC-1613510855001314', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'buy', 'order_status': 'canceled', 'size': '0.01', 'limit_price': '0.005048', 'stop_price': None, 'filled_size': None, 'average_price': None, 'open_time': '2021-02-16T21:27:35.1130000Z', 'close_time': '2021-02-16T21:27:40.3070000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': 'CCF02875-BBA1-41FB-84A5-5F4AEAD9FBD6', 'symbol': 'DASHBTC', 'wallet_id': '0B0C8512-A113-4D57-B4C6-D059746B302D', 'comment': 'HBOT-sell-DASH-BTC-1613510916001729', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'sell', 'order_status': 'canceled', 'size': '0.01', 'limit_price': '0.005058', 'stop_price': None, 'filled_size': None, 'average_price': None, 'open_time': '2021-02-16T21:28:36.1940000Z', 'close_time': '2021-02-16T21:29:50.4420000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': 'A64A66DA-8499-4BDE-88B1-EAC6FDF5763C', 'symbol': 'DASHBTC', 'wallet_id': '4C49C9F6-A594-43F1-A351-22C8712596CA', 'comment': 'HBOT-buy-DASH-BTC-1613510916001537', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'buy', 'order_status': 'canceled', 'size': '0.01', 'limit_price': '0.005048', 'stop_price': None, 'filled_size': None, 'average_price': None, 'open_time': '2021-02-16T21:28:36.1220000Z', 'close_time': '2021-02-16T21:29:50.4470000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': 'BEA6F963-1572-4311-A8D1-5F4E536403F1', 'symbol': 'DASHBTC', 'wallet_id': '4C49C9F6-A594-43F1-A351-22C8712596CA', 'comment': 'HBOT-buy-DASH-BTC-1613511036008230', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'buy', 'order_status': 'canceled', 'size': '0.01', 'limit_price': '0.005059', 'stop_price': None, 'filled_size': None, 'average_price': None, 'open_time': '2021-02-16T21:30:38.0410000Z', 'close_time': '2021-02-16T21:31:01.2790000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': '2F9933BF-2EE4-433B-BD89-6F9A3A8F4451', 'symbol': 'DASHBTC', 'wallet_id': '0B0C8512-A113-4D57-B4C6-D059746B302D', 'comment': 'HBOT-sell-DASH-BTC-1613511036008369', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'sell', 'order_status': 'canceled', 'size': '0.01', 'limit_price': '0.005069', 'stop_price': None, 'filled_size': None, 'average_price': None, 'open_time': '2021-02-16T21:30:38.0110000Z', 'close_time': '2021-02-16T21:31:01.3230000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': 'E8E7837F-4C86-496F-BD79-4D4B7C0BF30B', 'symbol': 'DASHBTC', 'wallet_id': '0B0C8512-A113-4D57-B4C6-D059746B302D', 'comment': 'HBOT-sell-DASH-BTC-1613511128001112', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'sell', 'order_status': 'completely_filled', 'size': '0.01', 'limit_price': '0.005071', 'stop_price': None, 'filled_size': None, 'average_price': '0.005071', 'open_time': '2021-02-16T21:32:08.0250000Z', 'close_time': '2021-02-16T21:32:08.1710000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': 'C64497F9-24CF-44C4-85EF-7D3C135D3D34', 'symbol': 'DASHBTC', 'wallet_id': '4C49C9F6-A594-43F1-A351-22C8712596CA', 'comment': 'HBOT-buy-DASH-BTC-1613511128000872', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'buy', 'order_status': 'canceled', 'size': '0.01', 'limit_price': '0.00506', 'stop_price': None, 'filled_size': None, 'average_price': None, 'open_time': '2021-02-16T21:32:08.1710000Z', 'close_time': '2021-02-16T21:32:23.3020000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': 'C46F48AE-6A20-413E-A477-36B03B42960C', 'symbol': 'DASHBTC', 'wallet_id': '0B0C8512-A113-4D57-B4C6-D059746B302D', 'comment': 'HBOT-sell-DASH-BTC-1613511216000942', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'sell', 'order_status': 'completely_filled', 'size': '0.01', 'limit_price': '0.005071', 'stop_price': None, 'filled_size': None, 'average_price': '0.005075', 'open_time': '2021-02-16T21:33:36.0910000Z', 'close_time': '2021-02-16T21:33:36.1710000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': '29ADF01C-23C2-49C3-A002-2EF71587819F', 'symbol': 'DASHBTC', 'wallet_id': '4C49C9F6-A594-43F1-A351-22C8712596CA', 'comment': 'HBOT-buy-DASH-BTC-1613511216000804', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'buy', 'order_status': 'canceled', 'size': '0.01', 'limit_price': '0.00506', 'stop_price': None, 'filled_size': None, 'average_price': None, 'open_time': '2021-02-16T21:33:36.1730000Z', 'close_time': '2021-02-16T21:33:51.2900000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': '9426B1A1-5420-40EC-BC97-12EEFE3835AE', 'symbol': 'DASHBTC', 'wallet_id': '0B0C8512-A113-4D57-B4C6-D059746B302D', 'comment': 'HBOT-sell-DASH-BTC-1613511306001806', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'sell', 'order_status': 'completely_filled', 'size': '0.01', 'limit_price': '0.005071', 'stop_price': None, 'filled_size': None, 'average_price': '0.005088', 'open_time': '2021-02-16T21:35:06.1700000Z', 'close_time': '2021-02-16T21:35:06.1820000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': '2355B1C7-B775-4511-BE30-72F4DAEF0BD5', 'symbol': 'DASHBTC', 'wallet_id': '4C49C9F6-A594-43F1-A351-22C8712596CA', 'comment': 'HBOT-buy-DASH-BTC-1613511306001579', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'buy', 'order_status': 'canceled', 'size': '0.01', 'limit_price': '0.00506', 'stop_price': None, 'filled_size': None, 'average_price': None, 'open_time': '2021-02-16T21:35:06.0350000Z', 'close_time': '2021-02-16T21:35:21.2900000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': '738FE5F1-0B50-4312-8296-096FBC2EB909', 'symbol': 'DASHBTC', 'wallet_id': '0B0C8512-A113-4D57-B4C6-D059746B302D', 'comment': 'HBOT-sell-DASH-BTC-1613511396001639', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'sell', 'order_status': 'completely_filled', 'size': '0.01', 'limit_price': '0.005071', 'stop_price': None, 'filled_size': None, 'average_price': '0.005077', 'open_time': '2021-02-16T21:36:36.1660000Z', 'close_time': '2021-02-16T21:36:36.1730000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': 'BEE6499F-2778-4668-9684-D353ACF1F2AE', 'symbol': 'DASHBTC', 'wallet_id': '4C49C9F6-A594-43F1-A351-22C8712596CA', 'comment': 'HBOT-buy-DASH-BTC-1613511396001481', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'buy', 'order_status': 'canceled', 'size': '0.01', 'limit_price': '0.00506', 'stop_price': None, 'filled_size': None, 'average_price': None, 'open_time': '2021-02-16T21:36:36.1120000Z', 'close_time': '2021-02-16T21:36:51.2360000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': '84EF1C15-249F-4BB4-8728-A2459F30D776', 'symbol': 'DASHBTC', 'wallet_id': '4C49C9F6-A594-43F1-A351-22C8712596CA', 'comment': 'HBOT-buy-DASH-BTC-1613511486001034', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'buy', 'order_status': 'completely_filled', 'size': '0.01', 'limit_price': '0.00506', 'stop_price': None, 'filled_size': None, 'average_price': '0.005044', 'open_time': '2021-02-16T21:38:06.1720000Z', 'close_time': '2021-02-16T21:38:06.2050000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': 'E42E2E8D-0700-4919-916C-44E28CCC61C1', 'symbol': 'DASHBTC', 'wallet_id': '0B0C8512-A113-4D57-B4C6-D059746B302D', 'comment': 'HBOT-sell-DASH-BTC-1613511486001223', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'sell', 'order_status': 'canceled', 'size': '0.01', 'limit_price': '0.005071', 'stop_price': None, 'filled_size': None, 'average_price': None, 'open_time': '2021-02-16T21:38:06.2010000Z', 'close_time': '2021-02-16T21:38:21.4960000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': '084ECA44-CA0E-4730-BDA7-E1F0D79F345F', 'symbol': 'DASHBTC', 'wallet_id': '4C49C9F6-A594-43F1-A351-22C8712596CA', 'comment': 'HBOT-buy-DASH-BTC-1613511561001420', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'buy', 'order_status': 'completely_filled', 'size': '0.01', 'limit_price': '0.00506', 'stop_price': None, 'filled_size': None, 'average_price': '0.005057', 'open_time': '2021-02-16T21:39:21.0640000Z', 'close_time': '2021-02-16T21:39:21.1390000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': 'F463B6EE-318A-4B8D-AA04-483E7E93D83D', 'symbol': 'DASHBTC', 'wallet_id': '0B0C8512-A113-4D57-B4C6-D059746B302D', 'comment': 'HBOT-sell-DASH-BTC-1613511561001568', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'sell', 'order_status': 'canceled', 'size': '0.01', 'limit_price': '0.005071', 'stop_price': None, 'filled_size': None, 'average_price': None, 'open_time': '2021-02-16T21:39:21.1570000Z', 'close_time': '2021-02-16T21:39:36.3680000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': '155CCEA9-0FB9-4759-AA9B-218662E05913', 'symbol': 'DASHBTC', 'wallet_id': '4C49C9F6-A594-43F1-A351-22C8712596CA', 'comment': 'HBOT-buy-DASH-BTC-1613511591001506', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'buy', 'order_status': 'completely_filled', 'size': '0.01', 'limit_price': '0.00506', 'stop_price': None, 'filled_size': None, 'average_price': '0.00506', 'open_time': '2021-02-16T21:39:51.1580000Z', 'close_time': '2021-02-16T21:41:00.9270000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': '86805F06-016B-468F-A97B-D764329B8BA4', 'symbol': 'DASHBTC', 'wallet_id': '0B0C8512-A113-4D57-B4C6-D059746B302D', 'comment': 'HBOT-sell-DASH-BTC-1613511591001722', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'sell', 'order_status': 'canceled', 'size': '0.01', 'limit_price': '0.005071', 'stop_price': None, 'filled_size': None, 'average_price': None, 'open_time': '2021-02-16T21:39:51.1780000Z', 'close_time': '2021-02-16T21:41:06.3700000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': '3240425B-F32B-4EA3-8928-9BC500A1B6AD', 'symbol': 'DASHBTC', 'wallet_id': '4C49C9F6-A594-43F1-A351-22C8712596CA', 'comment': 'HBOT-buy-DASH-BTC-1613511681002094', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'buy', 'order_status': 'completely_filled', 'size': '0.01', 'limit_price': '0.00506', 'stop_price': None, 'filled_size': None, 'average_price': '0.005057', 'open_time': '2021-02-16T21:41:21.1750000Z', 'close_time': '2021-02-16T21:41:21.1820000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': 'E32EC58E-C2CB-4479-AFD1-40CCB0A4EBAD', 'symbol': 'DASHBTC', 'wallet_id': '0B0C8512-A113-4D57-B4C6-D059746B302D', 'comment': 'HBOT-sell-DASH-BTC-1613511681002289', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'sell', 'order_status': 'canceled', 'size': '0.01', 'limit_price': '0.005071', 'stop_price': None, 'filled_size': None, 'average_price': None, 'open_time': '2021-02-16T21:41:21.0930000Z', 'close_time': '2021-02-16T21:41:36.3230000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': '600279F8-6B1D-4E3F-96C7-C1AFCB913850', 'symbol': 'DASHBTC', 'wallet_id': '4C49C9F6-A594-43F1-A351-22C8712596CA', 'comment': 'HBOT-buy-DASH-BTC-1613511711016786', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'buy', 'order_status': 'completely_filled', 'size': '0.01', 'limit_price': '0.00506', 'stop_price': None, 'filled_size': None, 'average_price': '0.005049', 'open_time': '2021-02-16T21:41:51.1790000Z', 'close_time': '2021-02-16T21:41:51.1850000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': '9C2F5264-7B70-42B6-8FDA-CDF2E3B812F9', 'symbol': 'DASHBTC', 'wallet_id': '0B0C8512-A113-4D57-B4C6-D059746B302D', 'comment': 'HBOT-sell-DASH-BTC-1613511711016929', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'sell', 'order_status': 'canceled', 'size': '0.01', 'limit_price': '0.005071', 'stop_price': None, 'filled_size': None, 'average_price': None, 'open_time': '2021-02-16T21:41:51.1590000Z', 'close_time': '2021-02-16T21:42:06.3620000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': '9DE6C354-1C65-4558-9584-F92D51E3E885', 'symbol': 'DASHBTC', 'wallet_id': '4C49C9F6-A594-43F1-A351-22C8712596CA', 'comment': 'HBOT-buy-DASH-BTC-1613511741002308', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'buy', 'order_status': 'completely_filled', 'size': '0.01', 'limit_price': '0.00506', 'stop_price': None, 'filled_size': None, 'average_price': '0.005047', 'open_time': '2021-02-16T21:42:21.0530000Z', 'close_time': '2021-02-16T21:42:21.1770000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': '3D2C9C0A-6555-4D28-B43B-AA6438C9D741', 'symbol': 'DASHBTC', 'wallet_id': '0B0C8512-A113-4D57-B4C6-D059746B302D', 'comment': 'HBOT-sell-DASH-BTC-1613511741002625', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'sell', 'order_status': 'canceled', 'size': '0.01', 'limit_price': '0.005071', 'stop_price': None, 'filled_size': None, 'average_price': None, 'open_time': '2021-02-16T21:42:21.1710000Z', 'close_time': '2021-02-16T21:42:36.2870000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': '58D0F179-534D-43CD-92C7-C2D510F885AA', 'symbol': 'DASHBTC', 'wallet_id': '4C49C9F6-A594-43F1-A351-22C8712596CA', 'comment': 'HBOT-buy-DASH-BTC-1613511771001476', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'buy', 'order_status': 'completely_filled', 'size': '0.01', 'limit_price': '0.00506', 'stop_price': None, 'filled_size': None, 'average_price': '0.005046', 'open_time': '2021-02-16T21:42:51.1670000Z', 'close_time': '2021-02-16T21:42:51.1740000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': '5552BE4F-28AB-4F0D-B6D1-CF09BA038BAD', 'symbol': 'DASHBTC', 'wallet_id': '0B0C8512-A113-4D57-B4C6-D059746B302D', 'comment': 'HBOT-sell-DASH-BTC-1613511771001606', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'sell', 'order_status': 'canceled', 'size': '0.01', 'limit_price': '0.005071', 'stop_price': None, 'filled_size': None, 'average_price': None, 'open_time': '2021-02-16T21:42:51.1530000Z', 'close_time': '2021-02-16T21:43:06.6120000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': 'DDFE5FA6-851E-48E1-A724-AA9B809CC76E', 'symbol': 'DASHBTC', 'wallet_id': '4C49C9F6-A594-43F1-A351-22C8712596CA', 'comment': 'HBOT-buy-DASH-BTC-1613511801001057', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'buy', 'order_status': 'completely_filled', 'size': '0.01', 'limit_price': '0.00506', 'stop_price': None, 'filled_size': None, 'average_price': '0.005048', 'open_time': '2021-02-16T21:43:21.0620000Z', 'close_time': '2021-02-16T21:43:21.1720000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': 'FD99E2DF-3DAF-4AE8-8DE4-F71FB66A951E', 'symbol': 'DASHBTC', 'wallet_id': '0B0C8512-A113-4D57-B4C6-D059746B302D', 'comment': 'HBOT-sell-DASH-BTC-1613511801001199', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'sell', 'order_status': 'canceled', 'size': '0.01', 'limit_price': '0.005071', 'stop_price': None, 'filled_size': None, 'average_price': None, 'open_time': '2021-02-16T21:43:21.1660000Z', 'close_time': '2021-02-16T21:43:36.2890000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': '31B40871-BD7B-4E3D-A1F0-0BF6BF18A998', 'symbol': 'DASHBTC', 'wallet_id': '4C49C9F6-A594-43F1-A351-22C8712596CA', 'comment': 'HBOT-buy-DASH-BTC-1613511831001419', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'buy', 'order_status': 'completely_filled', 'size': '0.01', 'limit_price': '0.00506', 'stop_price': None, 'filled_size': None, 'average_price': '0.00506', 'open_time': '2021-02-16T21:43:51.1840000Z', 'close_time': '2021-02-16T21:43:56.0900000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': '6EE5CCB5-8D55-4E24-A1ED-DE404791EAD4', 'symbol': 'DASHBTC', 'wallet_id': '0B0C8512-A113-4D57-B4C6-D059746B302D', 'comment': 'HBOT-sell-DASH-BTC-1613511831001639', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'sell', 'order_status': 'canceled', 'size': '0.01', 'limit_price': '0.005071', 'stop_price': None, 'filled_size': None, 'average_price': None, 'open_time': '2021-02-16T21:43:51.1440000Z', 'close_time': '2021-02-16T21:44:06.6060000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': '7AF452F3-371D-4282-925C-4BA47B16157F', 'symbol': 'DASHBTC', 'wallet_id': '4C49C9F6-A594-43F1-A351-22C8712596CA', 'comment': 'HBOT-buy-DASH-BTC-1613511861001287', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'buy', 'order_status': 'completely_filled', 'size': '0.01', 'limit_price': '0.00506', 'stop_price': None, 'filled_size': None, 'average_price': '0.00506', 'open_time': '2021-02-16T21:44:21.1360000Z', 'close_time': '2021-02-16T21:44:49.3090000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': '0AB5C307-651D-48E3-B103-CA71ABD352C8', 'symbol': 'DASHBTC', 'wallet_id': '0B0C8512-A113-4D57-B4C6-D059746B302D', 'comment': 'HBOT-sell-DASH-BTC-1613511861001437', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'sell', 'order_status': 'canceled', 'size': '0.01', 'limit_price': '0.005071', 'stop_price': None, 'filled_size': None, 'average_price': None, 'open_time': '2021-02-16T21:44:21.1650000Z', 'close_time': '2021-02-16T21:44:51.2850000Z', 'pay_with_utility_token': False, 'post_only': False}, {'order_id': '2D05D640-5546-448D-BD85-226B51968BBD', 'symbol': 'DASHBTC', 'wallet_id': '4C49C9F6-A594-43F1-A351-22C8712596CA', 'comment': 'HBOT-buy-DASH-BTC-1613511906000617', 'time_in_force': 'good_till_cancel', 'order_type': 'limit', 'side': 'buy', 'order_status': 'completely_filled', 'size': '0.01', 'limit_price': '0.00506', 'stop_price': None, 'filled_size': None, 'average_price': '0.005045', 'open_time': '2021-02-16T21:45:06.0930000Z', 'close_time': '2021-02-16T21:45:06.1710000Z', 'pay_with_utility_token': False, 'post_only': False}] + + ORDERS_OPEN_EMPTY = [] + + ORDERS_CLOSED_EMPTY = [] + + TEST_LIMIT_BUY_ORDER = {'order_id': '435118B0-A7F7-40D2-A409-820E8FC342A2'} TEST_LIMIT_BUY_WS_ORDER_CREATED = """MESSAGE\nstatus:200\ndestination:/v1/orders-user8ea365d0-24a6-83ed-ec03-59bac214fe97\ncontent-type:application/json\nsubscription:sub-humming-1612351104275342\nmessage-id:8ea365d0-24a6-83ed-ec03-59bac214fe97-18\ncontent-length:1397\n\n{"events":[{"trade_quantity":null,"trade_price":null,"commission":null,"original_client_order_id":null,"is_agressor":null,"order_side":"buy","event_id":"13215876","average_price":"0","stop_price":null,"leverage":null,"quantity":"0.01","limit_price":"0.003266","client_order_id":"1D37D726-E162-484B-8816-A43B43549CDD","time_in_force":"gtc","price":"0.003266","expire_time":null,"reason":null,"order_id":"1D37D726-E162-484B-8816-A43B43549CDD","cumulative_quantity":"0","remaining_quantity":"0.01","order_type":"limit","order_status":"new","commission_currency":null,"text":"HBOT-buy-DASH-BTC-1","exchange_id":null,"destination":"MAXI","security_id":"DASHBTC","id":"BTC-8965","type":"submitted","sequence_number":8965,"currency":"DASH","timestamp":1612351106037,"properties":null}],"order":{"average_price":"0","receipt_time":1612351106025,"close_time":0,"reason":null,"cumulative_quantity":"0","remaining_quantity":"0.01","status":"new","id":"1D37D726-E162-484B-8816-A43B43549CDD","timestamp":1612351106037,"stop_price":null,"leverage":null,"submission_time":null,"quantity":"0.01","limit_price":"0.003266","client_order_id":"1D37D726-E162-484B-8816-A43B43549CDD","time_in_force":"gtc","price":"0.003266","expire_time":null,"text":"HBOT-buy-DASH-BTC-1","destination":"MAXI","security_id":"DASHBTC","side":"buy","type":"limit","source":"CWUI","currency":"DASH","properties":null}}""" TEST_LIMIT_BUY_WS_ORDER_COMPLETED = """MESSAGE\nstatus:200\ndestination:/v1/orders-user9d827cdb-52b2-3303-6eba-615683c76ddf\ncontent-type:application/json\nsubscription:sub-humming-1612347898789940\nmessage-id:9d827cdb-52b2-3303-6eba-615683c76ddf-17\ncontent-length:1535\n\n{"events":[{"trade_quantity":"0.01","trade_price":"0.003297","commission":"-0.00000008","original_client_order_id":null,"is_agressor":true,"order_side":"buy","event_id":"0000000000BDBED4","average_price":"0.003297","stop_price":null,"leverage":null,"quantity":"0.01","limit_price":"0.003297","client_order_id":"C715ECA9-D13C-42AE-A978-083F62974B85","time_in_force":"gtc","price":"0.003297","expire_time":null,"reason":null,"order_id":"C715ECA9-D13C-42AE-A978-083F62974B85","cumulative_quantity":"0.01","remaining_quantity":"0","order_type":"limit","order_status":"completely_filled","commission_currency":"BTC","text":"HBOT-buy-DASH-BTC-1","exchange_id":null,"destination":"MAXI","security_id":"DASHBTC","id":"BTC-8938","type":"trade","sequence_number":8938,"currency":"DASH","timestamp":1612347915748,"properties":[{"key":"1115","value":"Order"}]}],"order":{"average_price":"0.003297","receipt_time":1612347915627,"close_time":1612347915748,"reason":null,"cumulative_quantity":"0.01","remaining_quantity":"0","status":"completely_filled","id":"C715ECA9-D13C-42AE-A978-083F62974B85","timestamp":1612347915748,"stop_price":null,"leverage":null,"submission_time":null,"quantity":"0.01","limit_price":"0.003297","client_order_id":"C715ECA9-D13C-42AE-A978-083F62974B85","time_in_force":"gtc","price":"0.003297","expire_time":null,"text":"HBOT-buy-DASH-BTC-1","destination":"MAXI","security_id":"DASHBTC","side":"buy","type":"limit","source":"CWUI","currency":"DASH","properties":[{"key":"7224","value":"-0.00000008"}]}}""" - TEST_LIMIT_SELL_ORDER = { - "average_price": "0", - "receipt_time": 1612367046013, - "close_time": 0, - "reason": None, - "cumulative_quantity": "0", - "remaining_quantity": "0.01", - "status": "pending_new", - "id": "E5BA149A-9DD7-4C2E-A2E8-1C37DF6C5393", - "timestamp": 1612367046013, - "stop_price": None, - "leverage": None, - "submission_time": None, - "quantity": "0.01", - "limit_price": "0.003118", - "client_order_id": "E5BA149A-9DD7-4C2E-A2E8-1C37DF6C5393", - "time_in_force": "gtc", - "price": "0.003118", - "expire_time": None, - "text": "HBOT-sell-DASH-BTC-1", - "destination": "MAXI", - "security_id": "DASHBTC", - "side": "sell", - "type": "limit", - "source": "CWUI", - "currency": None, - "properties": None, - } + TEST_LIMIT_SELL_ORDER = {'order_id': '435118B0-A7F7-40D2-A409-820E8FC342A2'} TEST_LIMIT_SELL_WS_ORDER_CREATED = """MESSAGE\nstatus:200\ndestination:/v1/orders-user9cb6b7b3-b47b-97ac-59dd-76e5b33b5953\ncontent-type:application/json\nsubscription:sub-humming-1612367042528182\nmessage-id:9cb6b7b3-b47b-97ac-59dd-76e5b33b5953-20\ncontent-length:1402\n\n{"events":[{"trade_quantity":null,"trade_price":null,"commission":null,"original_client_order_id":null,"is_agressor":null,"order_side":"sell","event_id":"18102008","average_price":"0","stop_price":null,"leverage":null,"quantity":"0.01","limit_price":"0.003118","client_order_id":"E5BA149A-9DD7-4C2E-A2E8-1C37DF6C5393","time_in_force":"gtc","price":"0.003118","expire_time":null,"reason":null,"order_id":"E5BA149A-9DD7-4C2E-A2E8-1C37DF6C5393","cumulative_quantity":"0","remaining_quantity":"0.01","order_type":"limit","order_status":"new","commission_currency":null,"text":"HBOT-sell-DASH-BTC-1","exchange_id":null,"destination":"MAXI","security_id":"DASHBTC","id":"DASH-8998","type":"submitted","sequence_number":8998,"currency":"DASH","timestamp":1612367046019,"properties":null}],"order":{"average_price":"0","receipt_time":1612367046011,"close_time":0,"reason":null,"cumulative_quantity":"0","remaining_quantity":"0.01","status":"new","id":"E5BA149A-9DD7-4C2E-A2E8-1C37DF6C5393","timestamp":1612367046019,"stop_price":null,"leverage":null,"submission_time":null,"quantity":"0.01","limit_price":"0.003118","client_order_id":"E5BA149A-9DD7-4C2E-A2E8-1C37DF6C5393","time_in_force":"gtc","price":"0.003118","expire_time":null,"text":"HBOT-sell-DASH-BTC-1","destination":"MAXI","security_id":"DASHBTC","side":"sell","type":"limit","source":"CWUI","currency":"DASH","properties":null}}""" TEST_LIMIT_SELL_WS_ORDER_COMPLETED = """MESSAGE\nstatus:200\ndestination:/v1/orders-user9cb6b7b3-b47b-97ac-59dd-76e5b33b5953\ncontent-type:application/json\nsubscription:sub-humming-1612367042528182\nmessage-id:9cb6b7b3-b47b-97ac-59dd-76e5b33b5953-21\ncontent-length:1552\n\n{"events":[{"trade_quantity":"0.01","trade_price":"0.003118","commission":"-0.00000008","original_client_order_id":null,"is_agressor":true,"order_side":"sell","event_id":"0000000001143A1A","average_price":"0.003118","stop_price":null,"leverage":null,"quantity":"0.01","limit_price":"0.003118","client_order_id":"E5BA149A-9DD7-4C2E-A2E8-1C37DF6C5393","time_in_force":"gtc","price":"0.003118","expire_time":null,"reason":null,"order_id":"E5BA149A-9DD7-4C2E-A2E8-1C37DF6C5393","cumulative_quantity":"0.01","remaining_quantity":"0","order_type":"limit","order_status":"completely_filled","commission_currency":"BTC","text":"HBOT-sell-DASH-BTC-1","exchange_id":null,"destination":"MAXI","security_id":"DASHBTC","id":"DASH-9004","type":"trade","sequence_number":9004,"currency":"DASH","timestamp":1612367046020,"properties":[{"key":"1115","value":"Order"}]}],"order":{"average_price":"0.003118","receipt_time":1612367046011,"close_time":1612367046020,"reason":null,"cumulative_quantity":"0.01","remaining_quantity":"0","status":"completely_filled","id":"E5BA149A-9DD7-4C2E-A2E8-1C37DF6C5393","timestamp":1612367046020,"stop_price":null,"leverage":null,"submission_time":null,"quantity":"0.01","limit_price":"0.003118","client_order_id":"E5BA149A-9DD7-4C2E-A2E8-1C37DF6C5393","time_in_force":"gtc","price":"0.003118","expire_time":null,"text":"HBOT-sell-DASH-BTC-1","destination":"MAXI","security_id":"DASHBTC","side":"sell","type":"limit","source":"CWUI","currency":"DASH","properties":[{"key":"7224","value":"-0.00002565747273893521"}]}}""" - TEST_UNFILLED_ORDER1 = { - "average_price": "0", - "receipt_time": 1612368513007, - "close_time": 0, - "reason": None, - "cumulative_quantity": "0", - "remaining_quantity": "0.01", - "status": "pending_new", - "id": "3DE4D10B-6882-4BF8-958A-EA689C145065", - "timestamp": 1612368513007, - "stop_price": None, - "leverage": None, - "submission_time": None, - "quantity": "0.01", - "limit_price": "0.002496", - "client_order_id": "3DE4D10B-6882-4BF8-958A-EA689C145065", - "time_in_force": "gtc", - "price": "0.002496", - "expire_time": None, - "text": "HBOT-buy-DASH-BTC-1612368513006601", - "destination": "MAXI", - "security_id": "DASHBTC", - "side": "buy", - "type": "limit", - "source": "CWUI", - "currency": None, - "properties": None, - } - TEST_UNFILLED_ORDER2 = { - "average_price": "0", - "receipt_time": 1612368513007, - "close_time": 0, - "reason": None, - "cumulative_quantity": "0", - "remaining_quantity": "0.01", - "status": "pending_new", - "id": "3DE4D10B-6882-4BF8-958A-EA689C145065", - "timestamp": 1612368513007, - "stop_price": None, - "leverage": None, - "submission_time": None, - "quantity": "0.01", - "limit_price": "0.002496", - "client_order_id": "3DE4D10B-6882-4BF8-958A-EA689C145065", - "time_in_force": "gtc", - "price": "0.002496", - "expire_time": None, - "text": "HBOT-buy-DASH-BTC-1612368513006601", - "destination": "MAXI", - "security_id": "DASHBTC", - "side": "buy", - "type": "limit", - "source": "CWUI", - "currency": None, - "properties": None, - } + TEST_UNFILLED_ORDER1 = {'order_id': '435118B0-A7F7-40D2-A409-820E8FC342A2'} + TEST_UNFILLED_ORDER2 = {'order_id': '435118B0-A7F7-40D2-A409-820E8FC342A2'} TEST_UNFILLED_ORDER1_WS_ORDER_CREATED = """MESSAGE\nstatus:200\ndestination:/v1/orders-userb0ad4644-7c10-565c-84d0-e1dc576a06f2\ncontent-type:application/json\nsubscription:sub-humming-1612368511313960\nmessage-id:b0ad4644-7c10-565c-84d0-e1dc576a06f2-26\ncontent-length:1397\n\n{"events":[{"trade_quantity":null,"trade_price":null,"commission":null,"original_client_order_id":null,"is_agressor":null,"order_side":"buy","event_id":"18699442","average_price":"0","stop_price":null,"leverage":null,"quantity":"0.01","limit_price":"0.002496","client_order_id":"3DE4D10B-6882-4BF8-958A-EA689C145065","time_in_force":"gtc","price":"0.002496","expire_time":null,"reason":null,"order_id":"3DE4D10B-6882-4BF8-958A-EA689C145065","cumulative_quantity":"0","remaining_quantity":"0.01","order_type":"limit","order_status":"new","commission_currency":null,"text":"HBOT-buy-DASH-BTC-1612368513006601","exchange_id":null,"destination":"MAXI","security_id":"DASHBTC","id":"BTC-9067","type":"submitted","sequence_number":9067,"currency":"DASH","timestamp":1612368513011,"properties":null}],"order":{"average_price":"0","receipt_time":1612368512949,"close_time":0,"reason":null,"cumulative_quantity":"0","remaining_quantity":"0.01","status":"new","id":"3DE4D10B-6882-4BF8-958A-EA689C145065","timestamp":1612368513011,"stop_price":null,"leverage":null,"submission_time":null,"quantity":"0.01","limit_price":"0.002496","client_order_id":"3DE4D10B-6882-4BF8-958A-EA689C145065","time_in_force":"gtc","price":"0.002496","expire_time":null,"text":"HBOT-buy-DASH-BTC-1612368513006601","destination":"MAXI","security_id":"DASHBTC","side":"buy","type":"limit","source":"CWUI","currency":"DASH","properties":null}}""" TEST_UNFILLED_ORDER2_WS_ORDER_CREATED = """MESSAGE\nstatus:200\ndestination:/v1/orders-userb0ad4644-7c10-565c-84d0-e1dc576a06f2\ncontent-type:application/json\nsubscription:sub-humming-1612368511313960\nmessage-id:b0ad4644-7c10-565c-84d0-e1dc576a06f2-27\ncontent-length:1398\n\n{"events":[{"trade_quantity":null,"trade_price":null,"commission":null,"original_client_order_id":null,"is_agressor":null,"order_side":"sell","event_id":"18699800","average_price":"0","stop_price":null,"leverage":null,"quantity":"0.01","limit_price":"0.00311","client_order_id":"9C4B5661-7C66-4AEF-AD5D-F6E813228F2A","time_in_force":"gtc","price":"0.00311","expire_time":null,"reason":null,"order_id":"9C4B5661-7C66-4AEF-AD5D-F6E813228F2A","cumulative_quantity":"0","remaining_quantity":"0.01","order_type":"limit","order_status":"new","commission_currency":null,"text":"HBOT-sell-DASH-BTC-1612368514001761","exchange_id":null,"destination":"MAXI","security_id":"DASHBTC","id":"DASH-9078","type":"submitted","sequence_number":9078,"currency":"DASH","timestamp":1612368514021,"properties":null}],"order":{"average_price":"0","receipt_time":1612368513974,"close_time":0,"reason":null,"cumulative_quantity":"0","remaining_quantity":"0.01","status":"new","id":"9C4B5661-7C66-4AEF-AD5D-F6E813228F2A","timestamp":1612368514021,"stop_price":null,"leverage":null,"submission_time":null,"quantity":"0.01","limit_price":"0.00311","client_order_id":"9C4B5661-7C66-4AEF-AD5D-F6E813228F2A","time_in_force":"gtc","price":"0.00311","expire_time":null,"text":"HBOT-sell-DASH-BTC-1612368514001761","destination":"MAXI","security_id":"DASHBTC","side":"sell","type":"limit","source":"CWUI","currency":"DASH","properties":null}}""" TEST_UNFILLED_ORDER1_WS_ORDER_CANCELED = """MESSAGE\nstatus:200\ndestination:/v1/orders-userb0ad4644-7c10-565c-84d0-e1dc576a06f2\ncontent-type:application/json\nsubscription:sub-humming-1612368511313960\nmessage-id:b0ad4644-7c10-565c-84d0-e1dc576a06f2-28\ncontent-length:1497\n\n{"events":[{"trade_quantity":null,"trade_price":null,"commission":null,"original_client_order_id":null,"is_agressor":null,"order_side":"buy","event_id":"18701450","average_price":"0","stop_price":null,"leverage":null,"quantity":"0.01","limit_price":"0.002496","client_order_id":"3DE4D10B-6882-4BF8-958A-EA689C145065","time_in_force":"gtc","price":"0.002496","expire_time":null,"reason":"Manual cancelation from tradingapi.beaxy.com","order_id":"3DE4D10B-6882-4BF8-958A-EA689C145065","cumulative_quantity":"0","remaining_quantity":"0","order_type":"limit","order_status":"canceled","commission_currency":null,"text":"HBOT-buy-DASH-BTC-1612368513006601","exchange_id":null,"destination":"MAXI","security_id":"DASHBTC","id":"BTC-9089","type":"cancel","sequence_number":9089,"currency":"DASH","timestamp":1612368517010,"properties":null}],"order":{"average_price":"0","receipt_time":1612368512949,"close_time":1612368517010,"reason":"Manual cancelation from tradingapi.beaxy.com","cumulative_quantity":"0","remaining_quantity":"0.01","status":"canceled","id":"3DE4D10B-6882-4BF8-958A-EA689C145065","timestamp":1612368517010,"stop_price":null,"leverage":null,"submission_time":null,"quantity":"0.01","limit_price":"0.002496","client_order_id":"3DE4D10B-6882-4BF8-958A-EA689C145065","time_in_force":"gtc","price":"0.002496","expire_time":null,"text":"HBOT-buy-DASH-BTC-1612368513006601","destination":"MAXI","security_id":"DASHBTC","side":"buy","type":"limit","source":"CWUI","currency":"DASH","properties":null}}""" TEST_UNFILLED_ORDER2_WS_ORDER_CANCELED = """MESSAGE\nstatus:200\ndestination:/v1/orders-userb0ad4644-7c10-565c-84d0-e1dc576a06f2\ncontent-type:application/json\nsubscription:sub-humming-1612368511313960\nmessage-id:b0ad4644-7c10-565c-84d0-e1dc576a06f2-29\ncontent-length:1498\n\n{"events":[{"trade_quantity":null,"trade_price":null,"commission":null,"original_client_order_id":null,"is_agressor":null,"order_side":"sell","event_id":"18701522","average_price":"0","stop_price":null,"leverage":null,"quantity":"0.01","limit_price":"0.00311","client_order_id":"9C4B5661-7C66-4AEF-AD5D-F6E813228F2A","time_in_force":"gtc","price":"0.00311","expire_time":null,"reason":"Manual cancelation from tradingapi.beaxy.com","order_id":"9C4B5661-7C66-4AEF-AD5D-F6E813228F2A","cumulative_quantity":"0","remaining_quantity":"0","order_type":"limit","order_status":"canceled","commission_currency":null,"text":"HBOT-sell-DASH-BTC-1612368514001761","exchange_id":null,"destination":"MAXI","security_id":"DASHBTC","id":"DASH-9100","type":"cancel","sequence_number":9100,"currency":"DASH","timestamp":1612368517226,"properties":null}],"order":{"average_price":"0","receipt_time":1612368513974,"close_time":1612368517226,"reason":"Manual cancelation from tradingapi.beaxy.com","cumulative_quantity":"0","remaining_quantity":"0.01","status":"canceled","id":"9C4B5661-7C66-4AEF-AD5D-F6E813228F2A","timestamp":1612368517226,"stop_price":null,"leverage":null,"submission_time":null,"quantity":"0.01","limit_price":"0.00311","client_order_id":"9C4B5661-7C66-4AEF-AD5D-F6E813228F2A","time_in_force":"gtc","price":"0.00311","expire_time":null,"text":"HBOT-sell-DASH-BTC-1612368514001761","destination":"MAXI","security_id":"DASHBTC","side":"sell","type":"limit","source":"CWUI","currency":"DASH","properties":null}}""" - TEST_MARKET_BUY_ORDER = { - "average_price": "0", - "receipt_time": 1612371213996, - "close_time": 0, - "reason": None, - "cumulative_quantity": "0", - "remaining_quantity": "0.01", - "status": "pending_new", - "id": "E7444777-4AAC-43D9-9AF2-61BE027D42ED", - "timestamp": 1612371213996, - "stop_price": None, - "leverage": None, - "submission_time": None, - "quantity": "0.01", - "limit_price": None, - "client_order_id": "E7444777-4AAC-43D9-9AF2-61BE027D42ED", - "time_in_force": "ioc", - "price": None, - "expire_time": None, - "text": "HBOT-buy-DASH-BTC-1", - "destination": "MAXI", - "security_id": "DASHBTC", - "side": "buy", - "type": "market", - "source": "CWUI", - "currency": None, - "properties": None, - } + TEST_MARKET_BUY_ORDER = {'order_id': '435118B0-A7F7-40D2-A409-820E8FC342A2'} TEST_MARKET_BUY_WS_ORDER_CREATED = """MESSAGE\nstatus:200\ndestination:/v1/orders-userc2959dab-c524-e54c-b973-1aec61426cce\ncontent-type:application/json\nsubscription:sub-humming-1612371211987894\nmessage-id:c2959dab-c524-e54c-b973-1aec61426cce-35\ncontent-length:1375\n\n{"events":[{"trade_quantity":null,"trade_price":null,"commission":null,"original_client_order_id":null,"is_agressor":null,"order_side":"buy","event_id":"19742404","average_price":"0","stop_price":null,"leverage":null,"quantity":"0.01","limit_price":null,"client_order_id":"E7444777-4AAC-43D9-9AF2-61BE027D42ED","time_in_force":"ioc","price":null,"expire_time":null,"reason":null,"order_id":"E7444777-4AAC-43D9-9AF2-61BE027D42ED","cumulative_quantity":"0","remaining_quantity":"0.01","order_type":"market","order_status":"new","commission_currency":null,"text":"HBOT-buy-DASH-BTC-1","exchange_id":null,"destination":"MAXI","security_id":"DASHBTC","id":"BTC-9229","type":"submitted","sequence_number":9229,"currency":"DASH","timestamp":1612371214001,"properties":null}],"order":{"average_price":"0","receipt_time":1612371213978,"close_time":0,"reason":null,"cumulative_quantity":"0","remaining_quantity":"0.01","status":"new","id":"E7444777-4AAC-43D9-9AF2-61BE027D42ED","timestamp":1612371214001,"stop_price":null,"leverage":null,"submission_time":null,"quantity":"0.01","limit_price":null,"client_order_id":"E7444777-4AAC-43D9-9AF2-61BE027D42ED","time_in_force":"ioc","price":null,"expire_time":null,"text":"HBOT-buy-DASH-BTC-1","destination":"MAXI","security_id":"DASHBTC","side":"buy","type":"market","source":"CWUI","currency":"DASH","properties":null}}""" TEST_MARKET_BUY_WS_ORDER_COMPLETED = """MESSAGE\nstatus:200\ndestination:/v1/orders-userc2959dab-c524-e54c-b973-1aec61426cce\ncontent-type:application/json\nsubscription:sub-humming-1612371211987894\nmessage-id:c2959dab-c524-e54c-b973-1aec61426cce-36\ncontent-length:1513\n\n{"events":[{"trade_quantity":"0.01","trade_price":"0.003075","commission":"-0.00000008","original_client_order_id":null,"is_agressor":true,"order_side":"buy","event_id":"00000000012D41E6","average_price":"0.003075","stop_price":null,"leverage":null,"quantity":"0.01","limit_price":null,"client_order_id":"E7444777-4AAC-43D9-9AF2-61BE027D42ED","time_in_force":"ioc","price":null,"expire_time":null,"reason":null,"order_id":"E7444777-4AAC-43D9-9AF2-61BE027D42ED","cumulative_quantity":"0.01","remaining_quantity":"0","order_type":"market","order_status":"completely_filled","commission_currency":"BTC","text":"HBOT-buy-DASH-BTC-1","exchange_id":null,"destination":"MAXI","security_id":"DASHBTC","id":"BTC-9235","type":"trade","sequence_number":9235,"currency":"DASH","timestamp":1612371214001,"properties":[{"key":"1115","value":"Order"}]}],"order":{"average_price":"0.003075","receipt_time":1612371213978,"close_time":1612371214001,"reason":null,"cumulative_quantity":"0.01","remaining_quantity":"0","status":"completely_filled","id":"E7444777-4AAC-43D9-9AF2-61BE027D42ED","timestamp":1612371214001,"stop_price":null,"leverage":null,"submission_time":null,"quantity":"0.01","limit_price":null,"client_order_id":"E7444777-4AAC-43D9-9AF2-61BE027D42ED","time_in_force":"ioc","price":null,"expire_time":null,"text":"HBOT-buy-DASH-BTC-1","destination":"MAXI","security_id":"DASHBTC","side":"buy","type":"market","source":"CWUI","currency":"DASH","properties":[{"key":"7224","value":"-0.00000008"}]}}""" - TEST_MARKET_SELL_ORDER = { - "average_price": "0", - "receipt_time": 1612371349993, - "close_time": 0, - "reason": None, - "cumulative_quantity": "0", - "remaining_quantity": "0.01", - "status": "pending_new", - "id": "770AE260-895C-41DF-A5E9-E67904E7F8C0", - "timestamp": 1612371349993, - "stop_price": None, - "leverage": None, - "submission_time": None, - "quantity": "0.01", - "limit_price": None, - "client_order_id": "770AE260-895C-41DF-A5E9-E67904E7F8C0", - "time_in_force": "ioc", - "price": None, - "expire_time": None, - "text": "HBOT-sell-DASH-BTC-1", - "destination": "MAXI", - "security_id": "DASHBTC", - "side": "sell", - "type": "market", - "source": "CWUI", - "currency": None, - "properties": None, - } + TEST_MARKET_SELL_ORDER = {'order_id': '435118B0-A7F7-40D2-A409-820E8FC342A2'} TEST_MARKET_SELL_WS_ORDER_CREATED = """MESSAGE\nstatus:200\ndestination:/v1/orders-user178e98a8-ccf2-0706-1d57-e30f5e9b91ba\ncontent-type:application/json\nsubscription:sub-humming-1612371349761545\nmessage-id:178e98a8-ccf2-0706-1d57-e30f5e9b91ba-37\ncontent-length:1380\n\n{"events":[{"trade_quantity":null,"trade_price":null,"commission":null,"original_client_order_id":null,"is_agressor":null,"order_side":"sell","event_id":"19803703","average_price":"0","stop_price":null,"leverage":null,"quantity":"0.01","limit_price":null,"client_order_id":"770AE260-895C-41DF-A5E9-E67904E7F8C0","time_in_force":"ioc","price":null,"expire_time":null,"reason":null,"order_id":"770AE260-895C-41DF-A5E9-E67904E7F8C0","cumulative_quantity":"0","remaining_quantity":"0.01","order_type":"market","order_status":"new","commission_currency":null,"text":"HBOT-sell-DASH-BTC-1","exchange_id":null,"destination":"MAXI","security_id":"DASHBTC","id":"DASH-9262","type":"submitted","sequence_number":9262,"currency":"DASH","timestamp":1612371349999,"properties":null}],"order":{"average_price":"0","receipt_time":1612371349955,"close_time":0,"reason":null,"cumulative_quantity":"0","remaining_quantity":"0.01","status":"new","id":"770AE260-895C-41DF-A5E9-E67904E7F8C0","timestamp":1612371349999,"stop_price":null,"leverage":null,"submission_time":null,"quantity":"0.01","limit_price":null,"client_order_id":"770AE260-895C-41DF-A5E9-E67904E7F8C0","time_in_force":"ioc","price":null,"expire_time":null,"text":"HBOT-sell-DASH-BTC-1","destination":"MAXI","security_id":"DASHBTC","side":"sell","type":"market","source":"CWUI","currency":"DASH","properties":null}}""" TEST_MARKET_SELL_WS_ORDER_COMPLETED = """MESSAGE\nstatus:200\ndestination:/v1/orders-user178e98a8-ccf2-0706-1d57-e30f5e9b91ba\ncontent-type:application/json\nsubscription:sub-humming-1612371349761545\nmessage-id:178e98a8-ccf2-0706-1d57-e30f5e9b91ba-38\ncontent-length:1530\n\n{"events":[{"trade_quantity":"0.01","trade_price":"0.003057","commission":"-0.00000008","original_client_order_id":null,"is_agressor":true,"order_side":"sell","event_id":"00000000012E3159","average_price":"0.003057","stop_price":null,"leverage":null,"quantity":"0.01","limit_price":null,"client_order_id":"770AE260-895C-41DF-A5E9-E67904E7F8C0","time_in_force":"ioc","price":null,"expire_time":null,"reason":null,"order_id":"770AE260-895C-41DF-A5E9-E67904E7F8C0","cumulative_quantity":"0.01","remaining_quantity":"0","order_type":"market","order_status":"completely_filled","commission_currency":"BTC","text":"HBOT-sell-DASH-BTC-1","exchange_id":null,"destination":"MAXI","security_id":"DASHBTC","id":"DASH-9268","type":"trade","sequence_number":9268,"currency":"DASH","timestamp":1612371350000,"properties":[{"key":"1115","value":"Order"}]}],"order":{"average_price":"0.003057","receipt_time":1612371349955,"close_time":1612371350000,"reason":null,"cumulative_quantity":"0.01","remaining_quantity":"0","status":"completely_filled","id":"770AE260-895C-41DF-A5E9-E67904E7F8C0","timestamp":1612371350000,"stop_price":null,"leverage":null,"submission_time":null,"quantity":"0.01","limit_price":null,"client_order_id":"770AE260-895C-41DF-A5E9-E67904E7F8C0","time_in_force":"ioc","price":null,"expire_time":null,"text":"HBOT-sell-DASH-BTC-1","destination":"MAXI","security_id":"DASHBTC","side":"sell","type":"market","source":"CWUI","currency":"DASH","properties":[{"key":"7224","value":"-0.00002616944717042852"}]}}""" - TEST_CANCEL_BUY_ORDER = { - "average_price": "0", - "receipt_time": 1612373138029, - "close_time": 0, - "reason": None, - "cumulative_quantity": "0", - "remaining_quantity": "0.01", - "status": "pending_new", - "id": "C24200B6-A16B-46C6-88A2-6D348671B25E", - "timestamp": 1612373138029, - "stop_price": None, - "leverage": None, - "submission_time": None, - "quantity": "0.01", - "limit_price": "0.001544", - "client_order_id": "C24200B6-A16B-46C6-88A2-6D348671B25E", - "time_in_force": "gtc", - "price": "0.001544", - "expire_time": None, - "text": "HBOT-buy-DASH-BTC-1", - "destination": "MAXI", - "security_id": "DASHBTC", - "side": "buy", - "type": "limit", - "source": "CWUI", - "currency": None, - "properties": None, - } + TEST_CANCEL_BUY_ORDER = {'order_id': '435118B0-A7F7-40D2-A409-820E8FC342A2'} TEST_CANCEL_BUY_WS_ORDER_COMPLETED = """MESSAGE\nstatus:200\ndestination:/v1/orders-user1b8f7a06-858e-c0dc-8665-a99fb892c363\ncontent-type:application/json\nsubscription:sub-humming-1612373137250051\nmessage-id:1b8f7a06-858e-c0dc-8665-a99fb892c363-45\ncontent-length:1397\n\n{"events":[{"trade_quantity":null,"trade_price":null,"commission":null,"original_client_order_id":null,"is_agressor":null,"order_side":"buy","event_id":"20424624","average_price":"0","stop_price":null,"leverage":null,"quantity":"0.01","limit_price":"0.001544","client_order_id":"C24200B6-A16B-46C6-88A2-6D348671B25E","time_in_force":"gtc","price":"0.001544","expire_time":null,"reason":null,"order_id":"C24200B6-A16B-46C6-88A2-6D348671B25E","cumulative_quantity":"0","remaining_quantity":"0.01","order_type":"limit","order_status":"new","commission_currency":null,"text":"HBOT-buy-DASH-BTC-1","exchange_id":null,"destination":"MAXI","security_id":"DASHBTC","id":"BTC-9389","type":"submitted","sequence_number":9389,"currency":"DASH","timestamp":1612373138034,"properties":null}],"order":{"average_price":"0","receipt_time":1612373137964,"close_time":0,"reason":null,"cumulative_quantity":"0","remaining_quantity":"0.01","status":"new","id":"C24200B6-A16B-46C6-88A2-6D348671B25E","timestamp":1612373138034,"stop_price":null,"leverage":null,"submission_time":null,"quantity":"0.01","limit_price":"0.001544","client_order_id":"C24200B6-A16B-46C6-88A2-6D348671B25E","time_in_force":"gtc","price":"0.001544","expire_time":null,"text":"HBOT-buy-DASH-BTC-1","destination":"MAXI","security_id":"DASHBTC","side":"buy","type":"limit","source":"CWUI","currency":"DASH","properties":null}}""" TEST_CANCEL_BUY_WS_ORDER_CANCELED = """MESSAGE\nstatus:200\ndestination:/v1/orders-user1b8f7a06-858e-c0dc-8665-a99fb892c363\ncontent-type:application/json\nsubscription:sub-humming-1612373137250051\nmessage-id:1b8f7a06-858e-c0dc-8665-a99fb892c363-46\ncontent-length:1497\n\n{"events":[{"trade_quantity":null,"trade_price":null,"commission":null,"original_client_order_id":null,"is_agressor":null,"order_side":"buy","event_id":"20425042","average_price":"0","stop_price":null,"leverage":null,"quantity":"0.01","limit_price":"0.001544","client_order_id":"C24200B6-A16B-46C6-88A2-6D348671B25E","time_in_force":"gtc","price":"0.001544","expire_time":null,"reason":"Manual cancelation from tradingapi.beaxy.com","order_id":"C24200B6-A16B-46C6-88A2-6D348671B25E","cumulative_quantity":"0","remaining_quantity":"0","order_type":"limit","order_status":"canceled","commission_currency":null,"text":"HBOT-buy-DASH-BTC-1","exchange_id":null,"destination":"MAXI","security_id":"DASHBTC","id":"BTC-9400","type":"cancel","sequence_number":9400,"currency":"DASH","timestamp":1612373139033,"properties":null}],"order":{"average_price":"0","receipt_time":1612373137964,"close_time":1612373139033,"reason":"Manual cancelation from tradingapi.beaxy.com","cumulative_quantity":"0","remaining_quantity":"0.01","status":"canceled","id":"C24200B6-A16B-46C6-88A2-6D348671B25E","timestamp":1612373139033,"stop_price":null,"leverage":null,"submission_time":null,"quantity":"0.01","limit_price":"0.001544","client_order_id":"C24200B6-A16B-46C6-88A2-6D348671B25E","time_in_force":"gtc","price":"0.001544","expire_time":null,"text":"HBOT-buy-DASH-BTC-1","destination":"MAXI","security_id":"DASHBTC","side":"buy","type":"limit","source":"CWUI","currency":"DASH","properties":null}}""" - TEST_CANCEL_ALL_ORDER1 = { - "average_price": "0", - "receipt_time": 1612372497032, - "close_time": 0, - "reason": None, - "cumulative_quantity": "0", - "remaining_quantity": "0.01", - "status": "pending_new", - "id": "3F5C5F5D-A4BE-4E7A-903C-B8FADB708050", - "timestamp": 1612372497032, - "stop_price": None, - "leverage": None, - "submission_time": None, - "quantity": "0.01", - "limit_price": None, - "client_order_id": "3F5C5F5D-A4BE-4E7A-903C-B8FADB708050", - "time_in_force": "ioc", - "price": None, - "expire_time": None, - "text": "HBOT-buy-DASH-BTC-1612372497005401", - "destination": "MAXI", - "security_id": "DASHBTC", - "side": "buy", - "type": "market", - "source": "CWUI", - "currency": None, - "properties": None, - } - TEST_CANCEL_ALL_ORDER2 = { - "average_price": "0", - "receipt_time": 1612372497226, - "close_time": 0, - "reason": None, - "cumulative_quantity": "0", - "remaining_quantity": "0.01", - "status": "pending_new", - "id": "5484A8B3-8C06-4C83-AFC3-B01EB500AD09", - "timestamp": 1612372497226, - "stop_price": None, - "leverage": None, - "submission_time": None, - "quantity": "0.01", - "limit_price": None, - "client_order_id": "5484A8B3-8C06-4C83-AFC3-B01EB500AD09", - "time_in_force": "ioc", - "price": None, - "expire_time": None, - "text": "HBOT-sell-DASH-BTC-1612372497005441", - "destination": "MAXI", - "security_id": "DASHBTC", - "side": "sell", - "type": "market", - "source": "CWUI", - "currency": None, - "properties": None, - } + TEST_CANCEL_ALL_ORDER1 = {'order_id': '435118B0-A7F7-40D2-A409-820E8FC342A2'} + TEST_CANCEL_ALL_ORDER2 = {'order_id': '435118B0-A7F7-40D2-A409-820E8FC342A2'} TEST_CANCEL_BUY_WS_ORDER1_CANCELED = """MESSAGE\nstatus:200\ndestination:/v1/orders-usere53ecdd6-383c-ef5d-1d1f-5471624e0ad9\ncontent-type:application/json\nsubscription:sub-humming-1612372490957013\nmessage-id:e53ecdd6-383c-ef5d-1d1f-5471624e0ad9-43\ncontent-length:1380\n\n{"events":[{"trade_quantity":null,"trade_price":null,"commission":null,"original_client_order_id":null,"is_agressor":null,"order_side":"sell","event_id":"20206349","average_price":"0","stop_price":null,"leverage":null,"quantity":"0.01","limit_price":null,"client_order_id":"5484A8B3-8C06-4C83-AFC3-B01EB500AD09","time_in_force":"ioc","price":null,"expire_time":null,"reason":null,"order_id":"5484A8B3-8C06-4C83-AFC3-B01EB500AD09","cumulative_quantity":"0","remaining_quantity":"0.01","order_type":"market","order_status":"new","commission_currency":null,"text":"HBOT-sell-DASH-BTC-1612372497005441","exchange_id":null,"destination":"MAXI","security_id":"DASHBTC","id":"DASH-9356","type":"submitted","sequence_number":9356,"currency":"DASH","timestamp":1612372497234,"properties":null}],"order":{"average_price":"0","receipt_time":1612372497184,"close_time":0,"reason":null,"cumulative_quantity":"0","remaining_quantity":"0.01","status":"new","id":"5484A8B3-8C06-4C83-AFC3-B01EB500AD09","timestamp":1612372497234,"stop_price":null,"leverage":null,"submission_time":null,"quantity":"0.01","limit_price":null,"client_order_id":"5484A8B3-8C06-4C83-AFC3-B01EB500AD09","time_in_force":"ioc","price":null,"expire_time":null,"text":"HBOT-sell-DASH-BTC-1612372497005441","destination":"MAXI","security_id":"DASHBTC","side":"sell","type":"market","source":"CWUI","currency":"DASH","properties":null}}""" TEST_CANCEL_BUY_WS_ORDER2_CANCELED = """MESSAGE\nstatus:200\ndestination:/v1/orders-usere53ecdd6-383c-ef5d-1d1f-5471624e0ad9\ncontent-type:application/json\nsubscription:sub-humming-1612372490957013\nmessage-id:e53ecdd6-383c-ef5d-1d1f-5471624e0ad9-44\ncontent-length:1530\n\n{"events":[{"trade_quantity":"0.01","trade_price":"0.003068","commission":"-0.00000008","original_client_order_id":null,"is_agressor":true,"order_side":"sell","event_id":"000000000134562F","average_price":"0.003068","stop_price":null,"leverage":null,"quantity":"0.01","limit_price":null,"client_order_id":"5484A8B3-8C06-4C83-AFC3-B01EB500AD09","time_in_force":"ioc","price":null,"expire_time":null,"reason":null,"order_id":"5484A8B3-8C06-4C83-AFC3-B01EB500AD09","cumulative_quantity":"0.01","remaining_quantity":"0","order_type":"market","order_status":"completely_filled","commission_currency":"BTC","text":"HBOT-sell-DASH-BTC-1612372497005441","exchange_id":null,"destination":"MAXI","security_id":"DASHBTC","id":"DASH-9362","type":"trade","sequence_number":9362,"currency":"DASH","timestamp":1612372497236,"properties":[{"key":"1115","value":"Order"}]}],"order":{"average_price":"0.003068","receipt_time":1612372497184,"close_time":1612372497236,"reason":null,"cumulative_quantity":"0.01","remaining_quantity":"0","status":"completely_filled","id":"5484A8B3-8C06-4C83-AFC3-B01EB500AD09","timestamp":1612372497236,"stop_price":null,"leverage":null,"submission_time":null,"quantity":"0.01","limit_price":null,"client_order_id":"5484A8B3-8C06-4C83-AFC3-B01EB500AD09","time_in_force":"ioc","price":null,"expire_time":null,"text":"HBOT-sell-DASH-BTC-1612372497005441","destination":"MAXI","security_id":"DASHBTC","side":"sell","type":"market","source":"CWUI","currency":"DASH","properties":[{"key":"7224","value":"-0.00002607561929595828"}]}}""" diff --git a/test/integration/test_beaxy_active_order_tracker.py b/test/integration/test_beaxy_active_order_tracker.py index 59d9a095f6..3c1637cc30 100644 --- a/test/integration/test_beaxy_active_order_tracker.py +++ b/test/integration/test_beaxy_active_order_tracker.py @@ -27,12 +27,15 @@ def test_insert_update_delete_messages(self): quantity: float = 1 update_id = 123 message_dict: Dict[str, Any] = { - "action": "INSERT", - "quantity": quantity, - "price": price, - "side": side, - "sequenceNumber": update_id, - "sequrity": test_trading_pair + 'timestamp': 1, + 'sequenceNumber': update_id, + 'entries': [{ + "action": "INSERT", + "quantity": quantity, + "price": price, + "side": side, + "sequrity": test_trading_pair + }] } insert_message = BeaxyOrderBook.diff_message_from_exchange(message_dict, float(12345)) insert_ob_row: OrderBookRow = active_tracker.convert_diff_message_to_order_book_row(insert_message) @@ -41,12 +44,15 @@ def test_insert_update_delete_messages(self): # receive UPDATE message updated_quantity: float = 3.2 update_message_dict: Dict[str, Any] = { - "action": "UPDATE", - "quantity": updated_quantity, - "price": price, - "side": side, + 'timestamp': 1, "sequenceNumber": update_id + 1, - "sequrity": test_trading_pair + 'entries': [{ + "action": "UPDATE", + "quantity": updated_quantity, + "price": price, + "side": side, + "sequrity": test_trading_pair + }] } change_message = BeaxyOrderBook.diff_message_from_exchange(update_message_dict, float(12345)) change_ob_row: OrderBookRow = active_tracker.convert_diff_message_to_order_book_row(change_message) @@ -55,45 +61,57 @@ def test_insert_update_delete_messages(self): # receive DELETE message delete_quantity = 1 delete_message_dict: Dict[str, Any] = { - "action": "DELETE", - "quantity": delete_quantity, - "price": price, - "side": side, - "sequenceNumber": update_id + 1 + 1, - "sequrity": test_trading_pair + 'timestamp': 1, + "sequenceNumber": update_id + 2, + 'entries': [{ + "action": "DELETE", + "quantity": delete_quantity, + "price": price, + "side": side, + "sequrity": test_trading_pair + }] } delete_message: BeaxyOrderBookMessage = BeaxyOrderBook.diff_message_from_exchange(delete_message_dict, float(12345)) delete_ob_row: OrderBookRow = active_tracker.convert_diff_message_to_order_book_row(delete_message) - self.assertEqual(delete_ob_row[0], [OrderBookRow(price, float(updated_quantity) - float(delete_quantity), update_id + 1 + 1)]) + self.assertEqual(delete_ob_row[0], [OrderBookRow(price, float(0), update_id + 1 + 1)]) def test_delete_through(self): active_tracker = BeaxyActiveOrderTracker() # receive INSERT message to be added to active orders first_insert: Dict[str, Any] = { - "action": "INSERT", - "quantity": 1, - "price": 133, - "side": "BID", + 'timestamp': 1, "sequenceNumber": 1, - "sequrity": test_trading_pair + 'entries': [{ + "action": "INSERT", + "quantity": 1, + "price": 133, + "side": "BID", + "sequrity": test_trading_pair, + }] } second_insert: Dict[str, Any] = { - "action": "INSERT", - "quantity": 2, - "price": 134, - "side": "BID", + 'timestamp': 1, "sequenceNumber": 2, - "sequrity": test_trading_pair + 'entries': [{ + "action": "INSERT", + "quantity": 2, + "price": 134, + "side": "BID", + "sequrity": test_trading_pair + }] } third_insert: Dict[str, Any] = { - "action": "INSERT", - "quantity": 3, - "price": 135, - "side": "BID", + 'timestamp': 1, "sequenceNumber": 1, - "sequrity": test_trading_pair + 'entries': [{ + "action": "INSERT", + "quantity": 3, + "price": 135, + "side": "BID", + "sequrity": test_trading_pair + }] } inserts = [first_insert, second_insert, third_insert] @@ -102,46 +120,58 @@ def test_delete_through(self): active_tracker.convert_diff_message_to_order_book_row(insert_message) delete_through_dict: Dict[str, Any] = { - "action": "DELETE_THROUGH", - "quantity": 3, - "price": 134, - "side": "BID", + 'timestamp': 1, "sequenceNumber": 1, - "sequrity": test_trading_pair + 'entries': [{ + "action": "DELETE_THROUGH", + "quantity": 3, + "price": 134, + "side": "BID", + "sequrity": test_trading_pair + }] } msg = BeaxyOrderBook.diff_message_from_exchange(delete_through_dict, float(12345)) active_tracker.convert_diff_message_to_order_book_row(msg) - self.assertEqual(len(active_tracker.active_bids), 1) - self.assertEqual(next(iter(active_tracker.active_bids)), 133) + self.assertEqual(len(active_tracker.active_bids), 2) + self.assertEqual(next(iter(active_tracker.active_bids)), 134) def test_delete_from(self): active_tracker = BeaxyActiveOrderTracker() # receive INSERT message to be added to active orders first_insert: Dict[str, Any] = { - "action": "INSERT", - "quantity": 1, - "price": 133, - "side": "ASK", + 'timestamp': 1, "sequenceNumber": 1, - "sequrity": test_trading_pair + 'entries': [{ + "action": "INSERT", + "quantity": 1, + "price": 133, + "side": "ASK", + "sequrity": test_trading_pair + }] } second_insert: Dict[str, Any] = { - "action": "INSERT", - "quantity": 2, - "price": 134, - "side": "ASK", + 'timestamp': 1, "sequenceNumber": 2, - "sequrity": test_trading_pair + 'entries': [{ + "action": "INSERT", + "quantity": 2, + "price": 134, + "side": "ASK", + "sequrity": test_trading_pair + }] } third_insert: Dict[str, Any] = { - "action": "INSERT", - "quantity": 3, - "price": 135, - "side": "ASK", + 'timestamp': 1, "sequenceNumber": 1, - "sequrity": test_trading_pair + 'entries': [{ + "action": "INSERT", + "quantity": 3, + "price": 135, + "side": "ASK", + "sequrity": test_trading_pair + }] } inserts = [first_insert, second_insert, third_insert] @@ -150,18 +180,21 @@ def test_delete_from(self): active_tracker.convert_diff_message_to_order_book_row(insert_message) delete_through_dict: Dict[str, Any] = { - "action": "DELETE_FROM", - "quantity": 3, - "price": 134, - "side": "ASK", + 'timestamp': 1, "sequenceNumber": 1, - "sequrity": test_trading_pair + 'entries': [{ + "action": "DELETE_FROM", + "quantity": 3, + "price": 134, + "side": "ASK", + "sequrity": test_trading_pair + }] } msg = BeaxyOrderBook.diff_message_from_exchange(delete_through_dict, float(12345)) active_tracker.convert_diff_message_to_order_book_row(msg) - self.assertEqual(len(active_tracker.active_asks), 1) - self.assertEqual(next(iter(active_tracker.active_asks)), 135) + self.assertEqual(len(active_tracker.active_asks), 2) + self.assertEqual(next(iter(active_tracker.active_asks)), 133) def test_snapshot(self): active_tracker = BeaxyActiveOrderTracker() diff --git a/test/integration/test_beaxy_market.py b/test/integration/test_beaxy_market.py index fa38fdd343..0e6cfc1799 100644 --- a/test/integration/test_beaxy_market.py +++ b/test/integration/test_beaxy_market.py @@ -53,7 +53,7 @@ def _transform_raw_message_patch(self, msg): PUBLIC_API_BASE_URL = "services.beaxy.com" -PRIVET_API_BASE_URL = "tradingapi.beaxy.com" +PRIVET_API_BASE_URL = "tradewith.beaxy.com" class BeaxyExchangeUnitTest(unittest.TestCase): @@ -88,15 +88,21 @@ def setUpClass(cls): cls._patcher = mock.patch("aiohttp.client.URL") cls._url_mock = cls._patcher.start() cls._url_mock.side_effect = cls.web_app.reroute_local - cls.web_app.update_response("get", PUBLIC_API_BASE_URL, "/api/v2/symbols", FixtureBeaxy.BALANCES) + cls.web_app.update_response("get", PUBLIC_API_BASE_URL, "/api/v2/symbols", FixtureBeaxy.SYMBOLS) cls.web_app.update_response("get", PUBLIC_API_BASE_URL, "/api/v2/symbols/DASHBTC/book", FixtureBeaxy.TRADE_BOOK) cls.web_app.update_response("get", PUBLIC_API_BASE_URL, "/api/v2/symbols/DASHBTC/rate", FixtureBeaxy.EXCHANGE_RATE) - cls.web_app.update_response("get", PRIVET_API_BASE_URL, "/api/v1/accounts", - FixtureBeaxy.ACCOUNTS) - cls.web_app.update_response("get", PRIVET_API_BASE_URL, "/api/v1/trader/health", + cls.web_app.update_response("get", PRIVET_API_BASE_URL, "/api/v2/health", FixtureBeaxy.HEALTH) + cls.web_app.update_response("get", PRIVET_API_BASE_URL, "/api/v2/wallets", + FixtureBeaxy.BALANCES) + cls.web_app.update_response("get", PRIVET_API_BASE_URL, "/api/v2/tradingsettings", + FixtureBeaxy.TRADE_SETTINGS) + cls.web_app.update_response("get", PRIVET_API_BASE_URL, "/api/v2/orders/open", + FixtureBeaxy.ORDERS_OPEN_EMPTY) + cls.web_app.update_response("get", PRIVET_API_BASE_URL, "/api/v2/orders/closed", + FixtureBeaxy.ORDERS_CLOSED_EMPTY) cls._t_nonce_patcher = unittest.mock.patch( "hummingbot.connector.exchange.beaxy.beaxy_exchange.get_tracking_nonce") @@ -117,6 +123,13 @@ def setUpClass(cls): "hummingbot.connector.exchange.beaxy.beaxy_auth.BeaxyAuth._BeaxyAuth__get_session_data") cls._auth_session_mock = cls._auth_session_patcher.start() cls._auth_session_mock.return_value = {"sign_key": 123, "session_id": '123'} + cls._auth_headers_patcher = unittest.mock.patch( + "hummingbot.connector.exchange.beaxy.beaxy_auth.BeaxyAuth.get_token") + cls._auth_headers_mock = cls._auth_headers_patcher.start() + cls._auth_headers_mock.return_value = '123' + cls._auth_poll_patcher = unittest.mock.patch( + "hummingbot.connector.exchange.beaxy.beaxy_auth.BeaxyAuth._auth_token_polling_loop") + cls._auth_poll_mock = cls._auth_poll_patcher.start() cls.clock: Clock = Clock(ClockMode.REALTIME) cls.market: BeaxyExchange = BeaxyExchange( @@ -124,6 +137,14 @@ def setUpClass(cls): trading_pairs=["DASH-BTC"] ) + if API_MOCK_ENABLED: + async def mock_status_polling_task(): + pass + + # disable status polling as it will make orders update inconsistent from mock view + cls.market._status_polling_task = asyncio.ensure_future(mock_status_polling_task()) + cls.ev_loop.run_until_complete(cls.market._update_balances()) + print("Initializing Beaxy market... this will take about a minute.") cls.clock.add_iterator(cls.market) cls.stack: contextlib.ExitStack = contextlib.ExitStack() @@ -213,7 +234,7 @@ def cancel_order(self, trading_pair, order_id, exch_order_id): def test_limit_buy(self): if API_MOCK_ENABLED: - self.web_app.update_response("post", PRIVET_API_BASE_URL, "/api/v1/orders", + self.web_app.update_response("post", PRIVET_API_BASE_URL, "/api/v2/orders", FixtureBeaxy.TEST_LIMIT_BUY_ORDER) amount: Decimal = Decimal("0.01") @@ -226,7 +247,7 @@ def test_limit_buy(self): order_id, _ = self.place_order( True, trading_pair, quantized_amount, OrderType.LIMIT, price, - [(2, FixtureBeaxy.TEST_LIMIT_BUY_WS_ORDER_CREATED), (3, FixtureBeaxy.TEST_LIMIT_BUY_WS_ORDER_COMPLETED)] + [(3, FixtureBeaxy.TEST_LIMIT_BUY_WS_ORDER_CREATED), (5, FixtureBeaxy.TEST_LIMIT_BUY_WS_ORDER_COMPLETED)] ) [order_completed_event] = self.run_parallel(self.market_logger.wait_for(BuyOrderCompletedEvent)) order_completed_event: BuyOrderCompletedEvent = order_completed_event @@ -250,7 +271,7 @@ def test_limit_buy(self): def test_limit_sell(self): if API_MOCK_ENABLED: - self.web_app.update_response("post", PRIVET_API_BASE_URL, "/api/v1/orders", + self.web_app.update_response("post", PRIVET_API_BASE_URL, "/api/v2/orders", FixtureBeaxy.TEST_LIMIT_SELL_ORDER) trading_pair = "DASH-BTC" @@ -262,7 +283,7 @@ def test_limit_sell(self): order_id, _ = self.place_order( False, trading_pair, quantized_amount, OrderType.LIMIT, price, - [(2, FixtureBeaxy.TEST_LIMIT_SELL_WS_ORDER_CREATED), (3, FixtureBeaxy.TEST_LIMIT_SELL_WS_ORDER_COMPLETED)] + [(3, FixtureBeaxy.TEST_LIMIT_SELL_WS_ORDER_CREATED), (5, FixtureBeaxy.TEST_LIMIT_SELL_WS_ORDER_COMPLETED)] ) [order_completed_event] = self.run_parallel(self.market_logger.wait_for(SellOrderCompletedEvent)) order_completed_event: SellOrderCompletedEvent = order_completed_event @@ -310,7 +331,7 @@ def test_limit_maker_rejections(self): def test_limit_makers_unfilled(self): if API_MOCK_ENABLED: - self.web_app.update_response("post", PRIVET_API_BASE_URL, "/api/v1/orders", + self.web_app.update_response("post", PRIVET_API_BASE_URL, "/api/v2/orders", FixtureBeaxy.TEST_UNFILLED_ORDER1) self.assertGreater(self.market.get_balance("BTC"), 0.00005) @@ -328,19 +349,19 @@ def test_limit_makers_unfilled(self): order_id1, exch_order_id_1 = self.place_order( True, trading_pair, quantized_bid_amount, OrderType.LIMIT, quantize_bid_price, - [(2, FixtureBeaxy.TEST_UNFILLED_ORDER1_WS_ORDER_CREATED)] + [(3, FixtureBeaxy.TEST_UNFILLED_ORDER1_WS_ORDER_CREATED)] ) [order_created_event] = self.run_parallel(self.market_logger.wait_for(BuyOrderCreatedEvent)) order_created_event: BuyOrderCreatedEvent = order_created_event self.assertEqual(order_id1, order_created_event.order_id) if API_MOCK_ENABLED: - self.web_app.update_response("post", PRIVET_API_BASE_URL, "/api/v1/orders", + self.web_app.update_response("post", PRIVET_API_BASE_URL, "/api/v2/orders", FixtureBeaxy.TEST_UNFILLED_ORDER2) order_id2, exch_order_id_2 = self.place_order( False, trading_pair, quantized_ask_amount, OrderType.LIMIT, quantize_ask_price, - [(2, FixtureBeaxy.TEST_UNFILLED_ORDER2_WS_ORDER_CREATED)] + [(3, FixtureBeaxy.TEST_UNFILLED_ORDER2_WS_ORDER_CREATED)] ) [order_created_event] = self.run_parallel(self.market_logger.wait_for(SellOrderCreatedEvent)) order_created_event: BuyOrderCreatedEvent = order_created_event @@ -362,7 +383,7 @@ def test_limit_makers_unfilled(self): def test_market_buy(self): if API_MOCK_ENABLED: - self.web_app.update_response("post", PRIVET_API_BASE_URL, "/api/v1/orders", + self.web_app.update_response("post", PRIVET_API_BASE_URL, "/api/v2/orders", FixtureBeaxy.TEST_MARKET_BUY_ORDER) amount: Decimal = Decimal("0.01") @@ -375,7 +396,7 @@ def test_market_buy(self): order_id, _ = self.place_order( True, trading_pair, quantized_amount, OrderType.MARKET, price, - [(2, FixtureBeaxy.TEST_MARKET_BUY_WS_ORDER_CREATED), (3, FixtureBeaxy.TEST_MARKET_BUY_WS_ORDER_COMPLETED)] + [(3, FixtureBeaxy.TEST_MARKET_BUY_WS_ORDER_CREATED), (5, FixtureBeaxy.TEST_MARKET_BUY_WS_ORDER_COMPLETED)] ) [order_completed_event] = self.run_parallel(self.market_logger.wait_for(BuyOrderCompletedEvent)) order_completed_event: BuyOrderCompletedEvent = order_completed_event @@ -399,7 +420,7 @@ def test_market_buy(self): def test_market_sell(self): if API_MOCK_ENABLED: - self.web_app.update_response("post", PRIVET_API_BASE_URL, "/api/v1/orders", + self.web_app.update_response("post", PRIVET_API_BASE_URL, "/api/v2/orders", FixtureBeaxy.TEST_MARKET_SELL_ORDER) trading_pair = "DASH-BTC" @@ -411,7 +432,7 @@ def test_market_sell(self): order_id, _ = self.place_order( False, trading_pair, quantized_amount, OrderType.MARKET, price, - [(2, FixtureBeaxy.TEST_MARKET_SELL_WS_ORDER_CREATED), (3, FixtureBeaxy.TEST_MARKET_SELL_WS_ORDER_COMPLETED)] + [(3, FixtureBeaxy.TEST_MARKET_SELL_WS_ORDER_CREATED), (5, FixtureBeaxy.TEST_MARKET_SELL_WS_ORDER_COMPLETED)] ) [order_completed_event] = self.run_parallel(self.market_logger.wait_for(SellOrderCompletedEvent)) order_completed_event: SellOrderCompletedEvent = order_completed_event @@ -434,10 +455,10 @@ def test_market_sell(self): def test_cancel_order(self): if API_MOCK_ENABLED: - self.web_app.update_response("post", PRIVET_API_BASE_URL, "/api/v1/orders", + self.web_app.update_response("post", PRIVET_API_BASE_URL, "/api/v2/orders", FixtureBeaxy.TEST_CANCEL_BUY_ORDER) - self.web_app.update_response("delete", PRIVET_API_BASE_URL, "/api/v1/orders", '') + self.web_app.update_response("delete", PRIVET_API_BASE_URL, "/api/v2/orders/open/435118B0-A7F7-40D2-A409-820E8FC342A2", '') amount: Decimal = Decimal("0.01") @@ -466,7 +487,7 @@ def test_cancel_order(self): def test_cancel_all(self): if API_MOCK_ENABLED: - self.web_app.update_response("delete", PRIVET_API_BASE_URL, "/api/v1/orders", '') + self.web_app.update_response("delete", PRIVET_API_BASE_URL, "/api/v2/orders/open/435118B0-A7F7-40D2-A409-820E8FC342A2", '') self.assertGreater(self.market.get_balance("BTC"), 0.00005) self.assertGreater(self.market.get_balance("DASH"), 0.01) @@ -485,14 +506,14 @@ def test_cancel_all(self): quantized_ask_amount: Decimal = self.market.quantize_order_amount(trading_pair, ask_amount) if API_MOCK_ENABLED: - self.web_app.update_response("post", PRIVET_API_BASE_URL, "/api/v1/orders", + self.web_app.update_response("post", PRIVET_API_BASE_URL, "/api/v2/orders", FixtureBeaxy.TEST_CANCEL_ALL_ORDER1) _, exch_order_id_1 = self.place_order(True, trading_pair, quantized_bid_amount, OrderType.LIMIT_MAKER, quantize_bid_price) if API_MOCK_ENABLED: - self.web_app.update_response("post", PRIVET_API_BASE_URL, "/api/v1/orders", + self.web_app.update_response("post", PRIVET_API_BASE_URL, "/api/v2/orders", FixtureBeaxy.TEST_CANCEL_ALL_ORDER2) _, exch_order_id_2 = self.place_order(False, trading_pair, quantized_ask_amount, OrderType.LIMIT_MAKER, diff --git a/test/integration/test_beaxy_order_book_tracker.py b/test/integration/test_beaxy_order_book_tracker.py index 8beac70df8..42896d8a94 100644 --- a/test/integration/test_beaxy_order_book_tracker.py +++ b/test/integration/test_beaxy_order_book_tracker.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -import math from os.path import ( join, realpath @@ -7,8 +6,6 @@ import sys; sys.path.insert(0, realpath(join(__file__, "../../../"))) from hummingbot.core.event.event_logger import EventLogger from hummingbot.core.event.events import ( - OrderBookTradeEvent, - TradeType, OrderBookEvent ) import asyncio @@ -90,23 +87,6 @@ def setUp(self): for trading_pair, order_book in self.order_book_tracker.order_books.items(): order_book.add_listener(event_tag, self.event_logger) - def test_order_book_trade_event_emission(self): - """ - Test if order book tracker is able to retrieve order book trade message from exchange and - emit order book trade events after correctly parsing the trade messages - """ - self.run_parallel(self.event_logger.wait_for(OrderBookTradeEvent)) - for ob_trade_event in self.event_logger.event_log: - self.assertTrue(type(ob_trade_event) == OrderBookTradeEvent) - self.assertTrue(ob_trade_event.trading_pair in self.trading_pairs) - self.assertTrue(type(ob_trade_event.timestamp) == float) - self.assertTrue(type(ob_trade_event.amount) == float) - self.assertTrue(type(ob_trade_event.price) == float) - self.assertTrue(type(ob_trade_event.type) == TradeType) - self.assertTrue(math.ceil(math.log10(ob_trade_event.timestamp)) == 10) - self.assertTrue(ob_trade_event.amount > 0) - self.assertTrue(ob_trade_event.price > 0) - def test_tracker_integrity(self): order_books: Dict[str, OrderBook] = self.order_book_tracker.order_books sut_book: OrderBook = order_books[self.trading_pairs[0]] From 229efc9a40268e8fa928d14ed037b4c7ac0a8ea1 Mon Sep 17 00:00:00 2001 From: Jay Bell Date: Mon, 22 Feb 2021 17:00:18 +0000 Subject: [PATCH 062/131] Remove telegram formatting tags --- hummingbot/client/ui/custom_widgets.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/hummingbot/client/ui/custom_widgets.py b/hummingbot/client/ui/custom_widgets.py index ee320a779c..1424d2ba85 100644 --- a/hummingbot/client/ui/custom_widgets.py +++ b/hummingbot/client/ui/custom_widgets.py @@ -178,6 +178,11 @@ def log(self, text: str, save_log: bool = True, silent: bool = False): else: max_width = self.window.render_info.window_width - 2 + # remove simple formatting tags used by telegram + repls = (('', ''), ('', ''), ('
', ''), ('
', '')) + for r in repls: + text = text.replace(*r) + # Split the string into multiple lines if there is a "\n" or if the string exceeds max window width # This operation should not be too expensive because only the newly added lines are processed new_lines_raw: List[str] = str(text).split('\n') From ee58e907fced06946a3d5fcc76205d90a0533b59 Mon Sep 17 00:00:00 2001 From: Nullably <37262506+Nullably@users.noreply.github.com> Date: Tue, 23 Feb 2021 16:14:52 +0800 Subject: [PATCH 063/131] (feat) update status and add max_spread and max_order_age --- .../liquidity_mining/liquidity_mining.py | 76 +++++++++++++------ .../liquidity_mining_config_map.py | 12 +++ hummingbot/strategy/liquidity_mining/start.py | 4 + ...onf_liquidity_mining_strategy_TEMPLATE.yml | 8 +- 4 files changed, 76 insertions(+), 24 deletions(-) diff --git a/hummingbot/strategy/liquidity_mining/liquidity_mining.py b/hummingbot/strategy/liquidity_mining/liquidity_mining.py index fd511f2721..5b8d38583d 100644 --- a/hummingbot/strategy/liquidity_mining/liquidity_mining.py +++ b/hummingbot/strategy/liquidity_mining/liquidity_mining.py @@ -49,6 +49,8 @@ def __init__(self, volatility_interval: int = 60 * 5, avg_volatility_period: int = 10, volatility_to_spread_multiplier: Decimal = Decimal("1"), + max_spread: Decimal = Decimal("-1"), + max_order_age: float = 60. * 60., status_report_interval: float = 900, hb_app_notification: bool = False): super().__init__() @@ -65,6 +67,8 @@ def __init__(self, self._volatility_interval = volatility_interval self._avg_volatility_period = avg_volatility_period self._volatility_to_spread_multiplier = volatility_to_spread_multiplier + self._max_spread = max_spread + self._max_order_age = max_order_age self._ev_loop = asyncio.get_event_loop() self._last_timestamp = 0 self._status_report_interval = status_report_interval @@ -112,6 +116,12 @@ def tick(self, timestamp: float): self._last_timestamp = timestamp + @staticmethod + def order_age(order: LimitOrder) -> float: + if "//" not in order.client_order_id: + return int(time.time()) - int(order.client_order_id[-16:]) / 1e6 + return -1. + async def active_orders_df(self) -> pd.DataFrame: size_q_col = f"Size ({self._token})" if self.is_token_a_quote_token() else "Size (Quote)" columns = ["Market", "Side", "Price", "Spread", "Amount", size_q_col, "Age"] @@ -120,11 +130,9 @@ async def active_orders_df(self) -> pd.DataFrame: mid_price = self._market_infos[order.trading_pair].get_mid_price() spread = 0 if mid_price == 0 else abs(order.price - mid_price) / mid_price size_q = order.quantity * mid_price - age = "n/a" + age = self.order_age(order) # // indicates order is a paper order so 'n/a'. For real orders, calculate age. - if "//" not in order.client_order_id: - age = pd.Timestamp(int(time.time()) - int(order.client_order_id[-16:]) / 1e6, - unit='s').strftime('%H:%M:%S') + age_txt = "n/a" if age <= 0. else pd.Timestamp(age, unit='s').strftime('%H:%M:%S') data.append([ order.trading_pair, "buy" if order.is_buy else "sell", @@ -132,48 +140,64 @@ async def active_orders_df(self) -> pd.DataFrame: f"{spread:.2%}", float(order.quantity), float(size_q), - age + age_txt ]) return pd.DataFrame(data=data, columns=columns) - def market_status_df(self) -> pd.DataFrame: + def budget_status_df(self) -> pd.DataFrame: data = [] - columns = ["Market", "Mid Price", "Volatility", "Base Bal", "Quote Bal", f"Budget ({self._token})", - "Base %", "Quote %"] + columns = ["Market", f"Budget ({self._token})", "Base Bal", "Quote Bal", "Base / Quote"] for market, market_info in self._market_infos.items(): mid_price = market_info.get_mid_price() base_bal = self._sell_budgets[market] quote_bal = self._buy_budgets[market] - total_bal = (base_bal * mid_price) + quote_bal - total_bal_in_token = total_bal + total_bal_in_quote = (base_bal * mid_price) + quote_bal + total_bal_in_token = total_bal_in_quote if not self.is_token_a_quote_token(): total_bal_in_token = base_bal + (quote_bal / mid_price) - base_pct = (base_bal * mid_price) / total_bal if total_bal > 0 else s_decimal_zero - quote_pct = quote_bal / total_bal if total_bal > 0 else s_decimal_zero + base_pct = (base_bal * mid_price) / total_bal_in_quote if total_bal_in_quote > 0 else s_decimal_zero + quote_pct = quote_bal / total_bal_in_quote if total_bal_in_quote > 0 else s_decimal_zero data.append([ market, - float(mid_price), - "" if self._volatility[market].is_nan() else f"{self._volatility[market]:.2%}", + float(total_bal_in_token), float(base_bal), float(quote_bal), - float(total_bal_in_token), - f"{base_pct:.0%}", - f"{quote_pct:.0%}" + f"{base_pct:.0%} / {quote_pct:.0%}" + ]) + return pd.DataFrame(data=data, columns=columns).replace(np.nan, '', regex=True) + + def market_status_df(self) -> pd.DataFrame: + data = [] + columns = ["Market", "Mid Price", "Best Bid %", "Best Ask %", "Volatility"] + for market, market_info in self._market_infos.items(): + mid_price = market_info.get_mid_price() + best_bid = self._exchange.get_price(market, False) + best_ask = self._exchange.get_price(market, True) + best_bid_pct = abs(best_bid - mid_price) / mid_price + best_ask_pct = (best_ask - mid_price) / mid_price + data.append([ + market, + float(mid_price), + f"{best_bid_pct:.2%}", + f"{best_ask_pct:.2%}", + "" if self._volatility[market].is_nan() else f"{self._volatility[market]:.2%}", ]) return pd.DataFrame(data=data, columns=columns).replace(np.nan, '', regex=True) async def miner_status_df(self) -> pd.DataFrame: data = [] - columns = ["Market", "Reward/day", "Liquidity (bots)", "Yield/day", "Max spread"] + columns = ["Market", "Paid in", "Reward/week", "Curr Liquidity", "APY", "Max Spread"] campaigns = await get_campaign_summary(self._exchange.display_name, list(self._market_infos.keys())) for market, campaign in campaigns.items(): + reward_usd = await usd_value(campaign.payout_asset, campaign.reward_per_day * Decimal("7")) liquidity_usd = await usd_value(market.split('-')[0], campaign.liquidity) data.append([ market, - f"{campaign.reward_per_day:.2f} {campaign.payout_asset}", - f"${liquidity_usd:.0f} ({campaign.active_bots})", - f"{campaign.apy / Decimal(365):.2%}", + campaign.payout_asset, + f"${reward_usd:.0f}", + f"${liquidity_usd:.0f}", + f"{campaign.apy:.2%}", f"{campaign.spread_max:.2%}%" ]) return pd.DataFrame(data=data, columns=columns).replace(np.nan, '', regex=True) @@ -185,12 +209,15 @@ async def format_status(self) -> str: warning_lines = [] warning_lines.extend(self.network_warning(list(self._market_infos.values()))) + budget_df = self.budget_status_df() + lines.extend(["", " Budget:"] + [" " + line for line in budget_df.to_string(index=False).split("\n")]) + market_df = self.market_status_df() lines.extend(["", " Markets:"] + [" " + line for line in market_df.to_string(index=False).split("\n")]) miner_df = await self.miner_status_df() if not miner_df.empty: - lines.extend(["", " Miners:"] + [" " + line for line in miner_df.to_string(index=False).split("\n")]) + lines.extend(["", " Miner:"] + [" " + line for line in miner_df.to_string(index=False).split("\n")]) # See if there're any open orders. if len(self.active_orders) > 0: @@ -219,6 +246,8 @@ def create_base_proposals(self): if not self._volatility[market].is_nan(): # volatility applies only when it is higher than the spread setting. spread = max(spread, self._volatility[market] * self._volatility_to_spread_multiplier) + if self._max_spread > s_decimal_zero: + spread = min(spread, self._max_spread) mid_price = market_info.get_mid_price() buy_price = mid_price * (Decimal("1") - spread) buy_price = self._exchange.quantize_order_price(market, buy_price) @@ -301,7 +330,8 @@ def cancel_active_orders(self, proposals: List[Proposal]): if self._refresh_times[proposal.market] > self.current_timestamp: continue cur_orders = [o for o in self.active_orders if o.trading_pair == proposal.market] - if not cur_orders or self.is_within_tolerance(cur_orders, proposal): + if not cur_orders or (all(self.order_age(o) < self._max_order_age for o in cur_orders) + and self.is_within_tolerance(cur_orders, proposal)): continue for order in cur_orders: self.cancel_order(self._market_infos[proposal.market], order.client_order_id) diff --git a/hummingbot/strategy/liquidity_mining/liquidity_mining_config_map.py b/hummingbot/strategy/liquidity_mining/liquidity_mining_config_map.py index ece5a141de..2f70e53cc1 100644 --- a/hummingbot/strategy/liquidity_mining/liquidity_mining_config_map.py +++ b/hummingbot/strategy/liquidity_mining/liquidity_mining_config_map.py @@ -119,4 +119,16 @@ def order_size_prompt() -> str: type_str="decimal", validator=lambda v: validate_decimal(v, min_value=0, inclusive=False), default=Decimal("1")), + "max_spread": + ConfigVar(key="max_spread", + prompt="What is the maximum spread? (Enter 1 to indicate 1% or -1 to ignore this setting) >>> ", + type_str="decimal", + validator=lambda v: validate_decimal(v), + default=Decimal("-1")), + "max_order_age": + ConfigVar(key="max_order_age", + prompt="What is the maximum life time of your orders (in seconds)? >>> ", + type_str="float", + validator=lambda v: validate_decimal(v, min_value=0, inclusive=False), + default=60. * 60.), } diff --git a/hummingbot/strategy/liquidity_mining/start.py b/hummingbot/strategy/liquidity_mining/start.py index 03c7445276..933a838b36 100644 --- a/hummingbot/strategy/liquidity_mining/start.py +++ b/hummingbot/strategy/liquidity_mining/start.py @@ -22,6 +22,8 @@ def start(self): volatility_interval = c_map.get("volatility_interval").value avg_volatility_period = c_map.get("avg_volatility_period").value volatility_to_spread_multiplier = c_map.get("volatility_to_spread_multiplier").value + max_spread = c_map.get("max_spread").value / Decimal("100") + max_order_age = c_map.get("max_order_age").value self._initialize_markets([(exchange, markets)]) exchange = self.markets[exchange] @@ -43,5 +45,7 @@ def start(self): volatility_interval=volatility_interval, avg_volatility_period=avg_volatility_period, volatility_to_spread_multiplier=volatility_to_spread_multiplier, + max_spread=max_spread, + max_order_age=max_order_age, hb_app_notification=True ) diff --git a/hummingbot/templates/conf_liquidity_mining_strategy_TEMPLATE.yml b/hummingbot/templates/conf_liquidity_mining_strategy_TEMPLATE.yml index 7daab8c8d4..f97380ad5c 100644 --- a/hummingbot/templates/conf_liquidity_mining_strategy_TEMPLATE.yml +++ b/hummingbot/templates/conf_liquidity_mining_strategy_TEMPLATE.yml @@ -2,7 +2,7 @@ ### Liquidity Mining strategy config ### ######################################################## -template_version: 2 +template_version: 3 strategy: null # The exchange to run this strategy. @@ -49,5 +49,11 @@ avg_volatility_period: null # The multiplier used to convert average volatility to spread, enter 1 for 1 to 1 conversion volatility_to_spread_multiplier: null +# The maximum value for spread, enter 1 to indicate 1% or -1 to ignore this setting +max_spread: null + +# The maximum life time of your orders in seconds +max_order_age: null + # For more detailed information, see: # https://docs.hummingbot.io/strategies/liquidity-mining/#configuration-parameters From 84ef9f8aba3b9357dd31f12525457bc5a119fc2a Mon Sep 17 00:00:00 2001 From: Nullably <37262506+Nullably@users.noreply.github.com> Date: Tue, 23 Feb 2021 16:23:39 +0800 Subject: [PATCH 064/131] (fix) update to new broker id --- hummingbot/connector/exchange/okex/okex_exchange.pyx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hummingbot/connector/exchange/okex/okex_exchange.pyx b/hummingbot/connector/exchange/okex/okex_exchange.pyx index 3cbdd68a35..f6419d6375 100644 --- a/hummingbot/connector/exchange/okex/okex_exchange.pyx +++ b/hummingbot/connector/exchange/okex/okex_exchange.pyx @@ -775,7 +775,7 @@ cdef class OkexExchange(ExchangeBase): dict kwargs={}): cdef: int64_t tracking_nonce = get_tracking_nonce() - str order_id = f"HUMMINGBOT{tracking_nonce}" # OKEx doesn't permits special characters + str order_id = f"93027a12dac34fBC{tracking_nonce}" # OKEx doesn't permits special characters safe_ensure_future(self.execute_buy(order_id, trading_pair, amount, order_type, price)) return order_id From ffe9924cf7a2892c5f31fab82bb0151b04ce467e Mon Sep 17 00:00:00 2001 From: Nullably <37262506+Nullably@users.noreply.github.com> Date: Tue, 23 Feb 2021 16:28:41 +0800 Subject: [PATCH 065/131] (fix) refactor client id prefix --- hummingbot/connector/exchange/okex/okex_exchange.pyx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/hummingbot/connector/exchange/okex/okex_exchange.pyx b/hummingbot/connector/exchange/okex/okex_exchange.pyx index f6419d6375..32e38c7c9b 100644 --- a/hummingbot/connector/exchange/okex/okex_exchange.pyx +++ b/hummingbot/connector/exchange/okex/okex_exchange.pyx @@ -69,6 +69,7 @@ from hummingbot.connector.exchange.okex.constants import * hm_logger = None s_decimal_0 = Decimal(0) TRADING_PAIR_SPLITTER = "-" +CLIENT_ID_PREFIX = "93027a12dac34fBC" class OKExAPIError(IOError): @@ -775,7 +776,7 @@ cdef class OkexExchange(ExchangeBase): dict kwargs={}): cdef: int64_t tracking_nonce = get_tracking_nonce() - str order_id = f"93027a12dac34fBC{tracking_nonce}" # OKEx doesn't permits special characters + str order_id = f"{CLIENT_ID_PREFIX}{tracking_nonce}" # OKEx doesn't permits special characters safe_ensure_future(self.execute_buy(order_id, trading_pair, amount, order_type, price)) return order_id @@ -846,7 +847,7 @@ cdef class OkexExchange(ExchangeBase): dict kwargs={}): cdef: int64_t tracking_nonce = get_tracking_nonce() - str order_id = f"HUMMINGBOT{tracking_nonce}" # OKEx doesn't permits special characters + str order_id = f"{CLIENT_ID_PREFIX}{tracking_nonce}" # OKEx doesn't permits special characters safe_ensure_future(self.execute_sell(order_id, trading_pair, amount, order_type, price)) return order_id From 985c4842563a1baae9810900bfefc666291dbf17 Mon Sep 17 00:00:00 2001 From: Kirill Stroukov Date: Tue, 23 Feb 2021 13:34:05 +0100 Subject: [PATCH 066/131] feat/Bexy improve closed orders scan window --- .../exchange/beaxy/beaxy_active_order_tracker.pyx | 9 +++++++-- .../connector/exchange/beaxy/beaxy_exchange.pyx | 11 +++++++++-- .../exchange/beaxy/beaxy_in_flight_order.pxd | 3 ++- .../exchange/beaxy/beaxy_in_flight_order.pyx | 14 ++++++++++++-- 4 files changed, 30 insertions(+), 7 deletions(-) diff --git a/hummingbot/connector/exchange/beaxy/beaxy_active_order_tracker.pyx b/hummingbot/connector/exchange/beaxy/beaxy_active_order_tracker.pyx index 18b8ca2906..ce9e95a98f 100644 --- a/hummingbot/connector/exchange/beaxy/beaxy_active_order_tracker.pyx +++ b/hummingbot/connector/exchange/beaxy/beaxy_active_order_tracker.pyx @@ -65,7 +65,7 @@ cdef class BeaxyActiveOrderTracker: return float(entry['rate']), float(entry['quantity']) def is_entry_valid(self, entry): - return all([k in entry for k in ['side', 'action', 'price', 'quantity']]) + return all([k in entry for k in ['side', 'action', 'price']]) cdef tuple c_convert_diff_message_to_np_arrays(self, object message): """ @@ -88,11 +88,16 @@ cdef class BeaxyActiveOrderTracker: timestamp = message.timestamp price = Decimal(str(entry['price'])) - quantity = Decimal(str(entry['quantity'])) active_rows = self._active_bids if order_side == SIDE_BID else self._active_asks if msg_action in (ACTION_UPDATE, ACTION_INSERT): + + if 'quantity' not in entry: + continue + + quantity = Decimal(str(entry['quantity'])) + active_rows[price] = quantity yield [timestamp, float(price), quantity, message.update_id] diff --git a/hummingbot/connector/exchange/beaxy/beaxy_exchange.pyx b/hummingbot/connector/exchange/beaxy/beaxy_exchange.pyx index cb77ddb30f..8ad509b77e 100644 --- a/hummingbot/connector/exchange/beaxy/beaxy_exchange.pyx +++ b/hummingbot/connector/exchange/beaxy/beaxy_exchange.pyx @@ -291,11 +291,17 @@ cdef class BeaxyExchange(ExchangeBase): Gets a list of the user's active orders via rest API :returns: json response """ - day_ago = (datetime.utcnow() - timedelta(days=1)).strftime('%Y-%m-%dT%H:%M:%SZ') + + if self._in_flight_orders: + from_date = min(order.created_at for order in self._in_flight_orders.values()) + else: + from_date = datetime.utcnow() - timedelta(minutes=5) result = await safe_gather( self._api_request('get', path_url=BeaxyConstants.TradingApi.OPEN_ORDERS_ENDPOINT), - self._api_request('get', path_url=BeaxyConstants.TradingApi.CLOSED_ORDERS_ENDPOINT.format(from_date=day_ago)), + self._api_request('get', path_url=BeaxyConstants.TradingApi.CLOSED_ORDERS_ENDPOINT.format( + from_date=from_date.strftime('%Y-%m-%dT%H:%M:%SZ') + )), ) return result @@ -1072,6 +1078,7 @@ cdef class BeaxyExchange(ExchangeBase): trade_type, price, amount, + created_at=datetime.utcnow() ) cdef c_did_timeout_tx(self, str tracking_id): diff --git a/hummingbot/connector/exchange/beaxy/beaxy_in_flight_order.pxd b/hummingbot/connector/exchange/beaxy/beaxy_in_flight_order.pxd index c2b41e4a7c..d199b8bada 100644 --- a/hummingbot/connector/exchange/beaxy/beaxy_in_flight_order.pxd +++ b/hummingbot/connector/exchange/beaxy/beaxy_in_flight_order.pxd @@ -1,4 +1,5 @@ from hummingbot.connector.in_flight_order_base cimport InFlightOrderBase cdef class BeaxyInFlightOrder(InFlightOrderBase): - pass + cdef: + public object created_at diff --git a/hummingbot/connector/exchange/beaxy/beaxy_in_flight_order.pyx b/hummingbot/connector/exchange/beaxy/beaxy_in_flight_order.pyx index b682201de0..b657489997 100644 --- a/hummingbot/connector/exchange/beaxy/beaxy_in_flight_order.pyx +++ b/hummingbot/connector/exchange/beaxy/beaxy_in_flight_order.pyx @@ -2,6 +2,7 @@ from decimal import Decimal from typing import Any, Dict, Optional +from datetime import datetime from hummingbot.core.event.events import OrderType, TradeType from hummingbot.connector.in_flight_order_base import InFlightOrderBase @@ -17,7 +18,8 @@ cdef class BeaxyInFlightOrder(InFlightOrderBase): trade_type: TradeType, price: Decimal, amount: Decimal, - initial_state: str = 'new' + created_at: datetime, + initial_state: str = 'new', ): super().__init__( client_order_id, @@ -29,6 +31,7 @@ cdef class BeaxyInFlightOrder(InFlightOrderBase): amount, initial_state ) + self.created_at = created_at @property def is_done(self) -> bool: @@ -52,6 +55,12 @@ cdef class BeaxyInFlightOrder(InFlightOrderBase): side = 'buy' if self.trade_type == TradeType.BUY else 'sell' return f'{order_type} {side}' + def to_json(self) -> Dict[str, Any]: + return dict( + created_at=self.created_at.isoformat(), + **super(BeaxyInFlightOrder, self).to_json() + ) + @classmethod def from_json(cls, data: Dict[str, Any]) -> InFlightOrderBase: """ @@ -67,7 +76,8 @@ cdef class BeaxyInFlightOrder(InFlightOrderBase): getattr(TradeType, data['trade_type']), Decimal(data['price']), Decimal(data['amount']), - data['last_state'] + datetime.fromisoformat(data['created_at']), + data['last_state'], ) retval.executed_amount_base = Decimal(data['executed_amount_base']) retval.executed_amount_quote = Decimal(data['executed_amount_quote']) From f633fd471fe32b15824b8cc3bd67a5f0213e7586 Mon Sep 17 00:00:00 2001 From: vic-en Date: Tue, 23 Feb 2021 15:03:11 +0100 Subject: [PATCH 067/131] (feat) fix failed jenkins tests --- hummingbot/strategy/strategy_base.pxd | 1 + 1 file changed, 1 insertion(+) diff --git a/hummingbot/strategy/strategy_base.pxd b/hummingbot/strategy/strategy_base.pxd index efb35d8b09..0d5683434e 100644 --- a/hummingbot/strategy/strategy_base.pxd +++ b/hummingbot/strategy/strategy_base.pxd @@ -16,6 +16,7 @@ cdef class StrategyBase(TimeIterator): EventListener _sb_expire_order_listener EventListener _sb_complete_buy_order_listener EventListener _sb_complete_sell_order_listener + EventListener _sb_complete_funding_payment_listener bint _sb_delegate_lock public OrderTracker _sb_order_tracker From c725761219144c69ba84157966af7f7851fb0464 Mon Sep 17 00:00:00 2001 From: Nicolas Baum Date: Tue, 23 Feb 2021 19:21:41 -0300 Subject: [PATCH 068/131] (fix) Fixed mapping of Kraken trading pairs --- .../kraken_api_order_book_data_source.py | 14 +--- .../exchange/kraken/kraken_constants.py | 68 +------------------ .../exchange/kraken/kraken_exchange.pyx | 64 +++++++++++------ .../connector/exchange/kraken/kraken_utils.py | 53 ++++++--------- 4 files changed, 66 insertions(+), 133 deletions(-) diff --git a/hummingbot/connector/exchange/kraken/kraken_api_order_book_data_source.py b/hummingbot/connector/exchange/kraken/kraken_api_order_book_data_source.py index dd421ecd0b..bc5d2c28e9 100755 --- a/hummingbot/connector/exchange/kraken/kraken_api_order_book_data_source.py +++ b/hummingbot/connector/exchange/kraken/kraken_api_order_book_data_source.py @@ -26,7 +26,6 @@ from hummingbot.core.data_type.order_book import OrderBook from hummingbot.logger import HummingbotLogger from hummingbot.connector.exchange.kraken.kraken_order_book import KrakenOrderBook -import hummingbot.connector.exchange.kraken.kraken_constants as constants from hummingbot.connector.exchange.kraken.kraken_utils import ( convert_from_exchange_trading_pair, convert_to_exchange_trading_pair) @@ -199,7 +198,6 @@ async def listen_for_order_book_diffs(self, ev_loop: asyncio.BaseEventLoop, outp await ws.send(ws_message) async for raw_msg in self._inner_messages(ws): msg = ujson.loads(raw_msg) - msg_dict = {"trading_pair": convert_from_exchange_trading_pair(msg[-1]), "asks": msg[1].get("a", []) or msg[1].get("as", []) or [], "bids": msg[1].get("b", []) or msg[1].get("bs", []) or []} @@ -254,8 +252,7 @@ async def get_ws_subscription_message(self, subscription_type: str): # all_markets: pd.DataFrame = await self.get_active_exchange_markets() trading_pairs: List[str] = [] for tp in self._trading_pairs: - base, quote = self.split_to_base_quote(convert_to_exchange_trading_pair(tp)) - trading_pairs.append(f"{base}/{quote}") + trading_pairs.append(convert_to_exchange_trading_pair(tp, '/')) ws_message_dict: Dict[str, Any] = {"event": "subscribe", "pair": trading_pairs, @@ -264,12 +261,3 @@ async def get_ws_subscription_message(self, subscription_type: str): ws_message: str = ujson.dumps(ws_message_dict) return ws_message - - @staticmethod - def split_to_base_quote(exchange_trading_pair: str) -> (Optional[str], Optional[str]): - base, quote = None, None - for quote_asset in constants.QUOTES: - if quote_asset == exchange_trading_pair[-len(quote_asset):]: - base, quote = exchange_trading_pair[:-len(quote_asset)], exchange_trading_pair[-len(quote_asset):] - break - return base, quote diff --git a/hummingbot/connector/exchange/kraken/kraken_constants.py b/hummingbot/connector/exchange/kraken/kraken_constants.py index 580fdac0f9..2e3f4a3a91 100644 --- a/hummingbot/connector/exchange/kraken/kraken_constants.py +++ b/hummingbot/connector/exchange/kraken/kraken_constants.py @@ -1,66 +1,4 @@ -from decimal import Decimal - -CRYPTO_QUOTES = [ - "XBT", - "ETH", - "USDT", - "DAI", - "USDC", -] - -ADDED_CRYPTO_QUOTES = [ - "XXBT", - "XETH", - "BTC", -] - -FIAT_QUOTES = [ - "USD", - "EUR", - "CAD", - "JPY", - "GBP", - "CHF", - "AUD" -] - -FIAT_QUOTES = ["Z" + quote for quote in FIAT_QUOTES] + FIAT_QUOTES - -QUOTES = CRYPTO_QUOTES + ADDED_CRYPTO_QUOTES + FIAT_QUOTES - -BASE_ORDER_MIN = { - "ALGO": Decimal("50"), - "XREP": Decimal("0.3"), - "BAT": Decimal("50"), - "BTC": Decimal("0.002"), - "XBT": Decimal("0.002"), - "BCH": Decimal("0.000002"), - "ADA": Decimal("1"), - "LINK": Decimal("10"), - "ATOM": Decimal("1"), - "DAI": Decimal("10"), - "DASH": Decimal("0.03"), - "XDG": Decimal("3000"), - "EOS": Decimal("3"), - "ETH": Decimal("0.02"), - "ETC": Decimal("0.3"), - "GNO": Decimal("0.02"), - "ICX": Decimal("50"), - "LSK": Decimal("10"), - "LTC": Decimal("0.1"), - "XMR": Decimal("0.1"), - "NANO": Decimal("10"), - "OMG": Decimal("10"), - "PAXG": Decimal("0.01"), - "QTUM": Decimal("0.1"), - "XRP": Decimal("30"), - "SC": Decimal("5000"), - "XLM": Decimal("30"), - "USDT": Decimal("5"), - "XTZ": Decimal("1"), - "USDC": Decimal("5"), - "MLN": Decimal("0.1"), - "WAVES": Decimal("10"), - "ZEC": Decimal("0.03"), - "TRX": Decimal("500") +KRAKEN_TO_HB_MAP = { + "XBT": "BTC", + "XDG": "DOGE", } diff --git a/hummingbot/connector/exchange/kraken/kraken_exchange.pyx b/hummingbot/connector/exchange/kraken/kraken_exchange.pyx index 01bb3ff9a5..4020c30a7d 100755 --- a/hummingbot/connector/exchange/kraken/kraken_exchange.pyx +++ b/hummingbot/connector/exchange/kraken/kraken_exchange.pyx @@ -25,7 +25,6 @@ from hummingbot.core.utils.async_utils import ( ) from hummingbot.connector.exchange.kraken.kraken_api_order_book_data_source import KrakenAPIOrderBookDataSource from hummingbot.connector.exchange.kraken.kraken_auth import KrakenAuth -import hummingbot.connector.exchange.kraken.kraken_constants as constants from hummingbot.connector.exchange.kraken.kraken_utils import ( convert_from_exchange_symbol, convert_from_exchange_trading_pair, @@ -198,7 +197,7 @@ cdef class KrakenExchange(ExchangeBase): asset_pairs_response = await client.get(ASSET_PAIRS_URI) asset_pairs_data: Dict[str, Any] = await asset_pairs_response.json() asset_pairs: Dict[str, Any] = asset_pairs_data["result"] - self._asset_pairs = {pair: details for pair, details in asset_pairs.items() if "." not in pair} + self._asset_pairs = {f"{details['base']}-{details['quote']}": details for _, details in asset_pairs.items()} return self._asset_pairs async def get_active_exchange_markets(self) -> pd.DataFrame: @@ -225,7 +224,7 @@ cdef class KrakenExchange(ExchangeBase): if order.get("status") == "open": details = order.get("descr") if details.get("ordertype") == "limit": - pair = convert_from_exchange_trading_pair(details.get("pair")) + pair = convert_from_exchange_trading_pair(details.get("pair"), tuple((await self.asset_pairs()).keys())) (base, quote) = self.split_trading_pair(pair) vol_locked = Decimal(order.get("vol", 0)) - Decimal(order.get("vol_exec", 0)) if details.get("type") == "sell": @@ -290,24 +289,45 @@ cdef class KrakenExchange(ExchangeBase): """ Example: { - "ADAETH":{ - "altname":"ADAETH", - "wsname":"ADA/ETH", - "aclass_base":"currency", - "base":"ADA", - "aclass_quote":"currency", - "quote":"XETH", - "lot":"unit", - "pair_decimals":7, - "lot_decimals":8, - "lot_multiplier":1, - "leverage_buy":[], - "leverage_sell":[], - "fees":[[0,0.26],[50000,0.24],[100000,0.22],[250000,0.2],[500000,0.18],[1000000,0.16],[2500000,0.14],[5000000,0.12],[10000000,0.1]], - "fees_maker":[[0,0.16],[50000,0.14],[100000,0.12],[250000,0.1],[500000,0.08],[1000000,0.06],[2500000,0.04],[5000000,0.02],[10000000,0]], - "fee_volume_currency":"ZUSD", - "margin_call":80, - "margin_stop":40 + "XBTUSDT": { + "altname": "XBTUSDT", + "wsname": "XBT/USDT", + "aclass_base": "currency", + "base": "XXBT", + "aclass_quote": "currency", + "quote": "USDT", + "lot": "unit", + "pair_decimals": 1, + "lot_decimals": 8, + "lot_multiplier": 1, + "leverage_buy": [2, 3], + "leverage_sell": [2, 3], + "fees": [ + [0, 0.26], + [50000, 0.24], + [100000, 0.22], + [250000, 0.2], + [500000, 0.18], + [1000000, 0.16], + [2500000, 0.14], + [5000000, 0.12], + [10000000, 0.1] + ], + "fees_maker": [ + [0, 0.16], + [50000, 0.14], + [100000, 0.12], + [250000, 0.1], + [500000, 0.08], + [1000000, 0.06], + [2500000, 0.04], + [5000000, 0.02], + [10000000, 0] + ], + "fee_volume_currency": "ZUSD", + "margin_call": 80, + "margin_stop": 40, + "ordermin": "0.0002" } } """ @@ -317,7 +337,7 @@ cdef class KrakenExchange(ExchangeBase): try: base, quote = split_to_base_quote(trading_pair) base = convert_from_exchange_symbol(base) - min_order_size = Decimal(constants.BASE_ORDER_MIN.get(base, 0)) + min_order_size = Decimal(rule.get('ordermin', 0)) min_price_increment = Decimal(f"1e-{rule.get('pair_decimals')}") min_base_amount_increment = Decimal(f"1e-{rule.get('lot_decimals')}") retval.append( diff --git a/hummingbot/connector/exchange/kraken/kraken_utils.py b/hummingbot/connector/exchange/kraken/kraken_utils.py index 8fe40441eb..28bb1eae8b 100644 --- a/hummingbot/connector/exchange/kraken/kraken_utils.py +++ b/hummingbot/connector/exchange/kraken/kraken_utils.py @@ -18,52 +18,40 @@ def split_trading_pair(trading_pair: str) -> Tuple[str, str]: return tuple(convert_from_exchange_trading_pair(trading_pair).split("-")) -def clean_symbol(symbol: str) -> str: - if len(symbol) == 4 and symbol[0] == "X" or symbol[0] == "Z": - symbol = symbol[1:] - if symbol == "XBT": - symbol = "BTC" - return symbol - - def convert_from_exchange_symbol(symbol: str) -> str: - if (len(symbol) == 4 or len(symbol) == 6) and (symbol[0] == "X" or symbol[0] == "Z"): + # Assuming if starts with Z or X and has 4 letters then Z/X is removable + if (symbol[0] == "X" or symbol[0] == "Z") and len(symbol) == 4: symbol = symbol[1:] - if symbol == "XBT": - symbol = "BTC" - return symbol + return constants.KRAKEN_TO_HB_MAP.get(symbol, symbol) def convert_to_exchange_symbol(symbol: str) -> str: - if symbol == "BTC": - symbol = "XBT" - return symbol + inverted_kraken_to_hb_map = {v: k for k, v in constants.KRAKEN_TO_HB_MAP.items()} + return inverted_kraken_to_hb_map.get(symbol, symbol) def split_to_base_quote(exchange_trading_pair: str) -> (Optional[str], Optional[str]): - base, quote = None, None - for quote_asset in constants.QUOTES: - if quote_asset == exchange_trading_pair[-len(quote_asset):]: - if len(exchange_trading_pair[:-len(quote_asset)]) > 2 or exchange_trading_pair[:-len(quote_asset)] == "SC": - base, quote = exchange_trading_pair[:-len(quote_asset)], exchange_trading_pair[-len(quote_asset):] - break - if not base: - quote_asset_d = [quote + ".d" for quote in constants.QUOTES] - for quote_asset in quote_asset_d: - if quote_asset == exchange_trading_pair[-len(quote_asset):]: - base, quote = exchange_trading_pair[:-len(quote_asset)], exchange_trading_pair[-len(quote_asset):] - break + base, quote = exchange_trading_pair.split("-") return base, quote -def convert_from_exchange_trading_pair(exchange_trading_pair: str) -> Optional[str]: +def convert_from_exchange_trading_pair(exchange_trading_pair: str, available_trading_pairs: Optional[Tuple] = None) -> Optional[str]: base, quote = "", "" if "-" in exchange_trading_pair: - return exchange_trading_pair - if "/" in exchange_trading_pair: - base, quote = exchange_trading_pair.split("/") - else: base, quote = split_to_base_quote(exchange_trading_pair) + elif "/" in exchange_trading_pair: + base, quote = exchange_trading_pair.split("/") + elif len(available_trading_pairs) > 0: + # If trading pair has no spaces (i.e. ETHUSDT). Then it will have to match with the existing pairs + # Option 1: Using traditional naming convention + connector_trading_pair = {''.join(convert_from_exchange_trading_pair(tp).split('-')): tp for tp in available_trading_pairs}.get( + exchange_trading_pair) + if not connector_trading_pair: + # Option 2: Using kraken naming convention ( XXBT for Bitcoin, XXDG for Doge, ZUSD for USD, etc) + connector_trading_pair = {''.join(tp.split('-')): tp for tp in available_trading_pairs}.get( + exchange_trading_pair) + return connector_trading_pair + if not base or not quote: return None base = convert_from_exchange_symbol(base) @@ -76,7 +64,6 @@ def convert_to_exchange_trading_pair(hb_trading_pair: str, delimiter: str = "") Note: The result of this method can safely be used to submit/make queries. Result shouldn't be used to parse responses as Kraken add special formating to most pairs. """ - if "-" in hb_trading_pair: base, quote = hb_trading_pair.split("-") elif "/" in hb_trading_pair: From 6d8f617c0af58dcd3fb1f2c721d88c161cca7d3d Mon Sep 17 00:00:00 2001 From: Nullably <37262506+Nullably@users.noreply.github.com> Date: Wed, 24 Feb 2021 12:44:15 +0800 Subject: [PATCH 069/131] (feat) update cancel priority where max_order_age is first --- .../liquidity_mining/liquidity_mining.py | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/hummingbot/strategy/liquidity_mining/liquidity_mining.py b/hummingbot/strategy/liquidity_mining/liquidity_mining.py index 5b8d38583d..c681ce299e 100644 --- a/hummingbot/strategy/liquidity_mining/liquidity_mining.py +++ b/hummingbot/strategy/liquidity_mining/liquidity_mining.py @@ -327,16 +327,20 @@ def is_within_tolerance(self, cur_orders: List[LimitOrder], proposal: Proposal): def cancel_active_orders(self, proposals: List[Proposal]): for proposal in proposals: - if self._refresh_times[proposal.market] > self.current_timestamp: - continue + to_cancel = True cur_orders = [o for o in self.active_orders if o.trading_pair == proposal.market] - if not cur_orders or (all(self.order_age(o) < self._max_order_age for o in cur_orders) - and self.is_within_tolerance(cur_orders, proposal)): - continue - for order in cur_orders: - self.cancel_order(self._market_infos[proposal.market], order.client_order_id) - # To place new order on the next tick - self._refresh_times[order.trading_pair] = self.current_timestamp + 0.1 + if cur_orders and any(self.order_age(o) > self._max_order_age for o in cur_orders): + to_cancel = True + else: + if self._refresh_times[proposal.market] > self.current_timestamp: + to_cancel = False + elif cur_orders and self.is_within_tolerance(cur_orders, proposal): + to_cancel = False + if to_cancel: + for order in cur_orders: + self.cancel_order(self._market_infos[proposal.market], order.client_order_id) + # To place new order on the next tick + self._refresh_times[order.trading_pair] = self.current_timestamp + 0.1 def execute_orders_proposal(self, proposals: List[Proposal]): for proposal in proposals: From 418bffc93ed1ea32205669e220a7e1697ce76a66 Mon Sep 17 00:00:00 2001 From: Nullably <37262506+Nullably@users.noreply.github.com> Date: Wed, 24 Feb 2021 14:22:27 +0800 Subject: [PATCH 070/131] (feat) refactor cancel and sort status output --- .../liquidity_mining/liquidity_mining.py | 27 +++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/hummingbot/strategy/liquidity_mining/liquidity_mining.py b/hummingbot/strategy/liquidity_mining/liquidity_mining.py index c681ce299e..0503dd99d4 100644 --- a/hummingbot/strategy/liquidity_mining/liquidity_mining.py +++ b/hummingbot/strategy/liquidity_mining/liquidity_mining.py @@ -142,8 +142,9 @@ async def active_orders_df(self) -> pd.DataFrame: float(size_q), age_txt ]) - - return pd.DataFrame(data=data, columns=columns) + df = pd.DataFrame(data=data, columns=columns) + df.sort_values(by=["Market", "Side"], inplace=True) + return df def budget_status_df(self) -> pd.DataFrame: data = [] @@ -165,7 +166,9 @@ def budget_status_df(self) -> pd.DataFrame: float(quote_bal), f"{base_pct:.0%} / {quote_pct:.0%}" ]) - return pd.DataFrame(data=data, columns=columns).replace(np.nan, '', regex=True) + df = pd.DataFrame(data=data, columns=columns).replace(np.nan, '', regex=True) + df.sort_values(by=["Market"], inplace=True) + return df def market_status_df(self) -> pd.DataFrame: data = [] @@ -183,7 +186,9 @@ def market_status_df(self) -> pd.DataFrame: f"{best_ask_pct:.2%}", "" if self._volatility[market].is_nan() else f"{self._volatility[market]:.2%}", ]) - return pd.DataFrame(data=data, columns=columns).replace(np.nan, '', regex=True) + df = pd.DataFrame(data=data, columns=columns).replace(np.nan, '', regex=True) + df.sort_values(by=["Market"], inplace=True) + return df async def miner_status_df(self) -> pd.DataFrame: data = [] @@ -200,7 +205,9 @@ async def miner_status_df(self) -> pd.DataFrame: f"{campaign.apy:.2%}", f"{campaign.spread_max:.2%}%" ]) - return pd.DataFrame(data=data, columns=columns).replace(np.nan, '', regex=True) + df = pd.DataFrame(data=data, columns=columns).replace(np.nan, '', regex=True) + df.sort_values(by=["Market"], inplace=True) + return df async def format_status(self) -> str: if not self._ready_to_trade: @@ -327,15 +334,13 @@ def is_within_tolerance(self, cur_orders: List[LimitOrder], proposal: Proposal): def cancel_active_orders(self, proposals: List[Proposal]): for proposal in proposals: - to_cancel = True + to_cancel = False cur_orders = [o for o in self.active_orders if o.trading_pair == proposal.market] if cur_orders and any(self.order_age(o) > self._max_order_age for o in cur_orders): to_cancel = True - else: - if self._refresh_times[proposal.market] > self.current_timestamp: - to_cancel = False - elif cur_orders and self.is_within_tolerance(cur_orders, proposal): - to_cancel = False + elif self._refresh_times[proposal.market] <= self.current_timestamp and \ + cur_orders and not self.is_within_tolerance(cur_orders, proposal): + to_cancel = True if to_cancel: for order in cur_orders: self.cancel_order(self._market_infos[proposal.market], order.client_order_id) From 009dfcab385d13272f61ea6d99cdb836909cb614 Mon Sep 17 00:00:00 2001 From: vic-en Date: Wed, 24 Feb 2021 15:14:20 +0100 Subject: [PATCH 071/131] (fix) factor in leverage when checking balance requirement for derivatives --- .../spot_perpetual_arbitrage/spot_perpetual_arbitrage.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hummingbot/strategy/spot_perpetual_arbitrage/spot_perpetual_arbitrage.py b/hummingbot/strategy/spot_perpetual_arbitrage/spot_perpetual_arbitrage.py index 3ae784fe62..f3413e2505 100644 --- a/hummingbot/strategy/spot_perpetual_arbitrage/spot_perpetual_arbitrage.py +++ b/hummingbot/strategy/spot_perpetual_arbitrage/spot_perpetual_arbitrage.py @@ -264,7 +264,7 @@ def apply_budget_constraint(self, arb_proposal: ArbProposal): spot_token_balance = spot_market.get_available_balance(spot_token) deriv_token_balance = deriv_market.get_available_balance(deriv_token) required_spot_balance = arb_proposal.amount * arb_proposal.spot_side.order_price if arb_proposal.spot_side.is_buy else arb_proposal.amount - required_deriv_balance = arb_proposal.amount * arb_proposal.derivative_side.order_price + required_deriv_balance = (arb_proposal.amount * arb_proposal.derivative_side.order_price) / self._derivative_leverage if spot_token_balance < required_spot_balance: arb_proposal.amount = s_decimal_zero self.logger().info(f"Can't arbitrage, {spot_market.display_name} " From 98e06471ed874a72de58a5bbc764fdc25f858bc6 Mon Sep 17 00:00:00 2001 From: vic-en Date: Wed, 24 Feb 2021 15:42:58 +0100 Subject: [PATCH 072/131] (feat) add trading pair validators --- .../spot_perpetual_arbitrage_config_map.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/hummingbot/strategy/spot_perpetual_arbitrage/spot_perpetual_arbitrage_config_map.py b/hummingbot/strategy/spot_perpetual_arbitrage/spot_perpetual_arbitrage_config_map.py index 8be3cd9653..f20b4403a1 100644 --- a/hummingbot/strategy/spot_perpetual_arbitrage/spot_perpetual_arbitrage_config_map.py +++ b/hummingbot/strategy/spot_perpetual_arbitrage/spot_perpetual_arbitrage_config_map.py @@ -1,5 +1,6 @@ from hummingbot.client.config.config_var import ConfigVar from hummingbot.client.config.config_validators import ( + validate_market_trading_pair, validate_connector, validate_derivative, validate_decimal, @@ -18,10 +19,20 @@ def exchange_on_validated(value: str) -> None: required_exchanges.append(value) +def spot_market_validator(value: str) -> None: + exchange = spot_perpetual_arbitrage_config_map["spot_connector"].value + return validate_market_trading_pair(exchange, value) + + def spot_market_on_validated(value: str) -> None: requried_connector_trading_pairs[spot_perpetual_arbitrage_config_map["spot_connector"].value] = [value] +def derivative_market_validator(value: str) -> None: + exchange = spot_perpetual_arbitrage_config_map["derivative_connector"].value + return validate_market_trading_pair(exchange, value) + + def derivative_market_on_validated(value: str) -> None: requried_connector_trading_pairs[spot_perpetual_arbitrage_config_map["derivative_connector"].value] = [value] @@ -61,6 +72,7 @@ def order_amount_prompt() -> str: key="spot_market", prompt=spot_market_prompt, prompt_on_new=True, + validator=spot_market_validator, on_validated=spot_market_on_validated), "derivative_connector": ConfigVar( key="derivative_connector", @@ -72,6 +84,7 @@ def order_amount_prompt() -> str: key="derivative_market", prompt=derivative_market_prompt, prompt_on_new=True, + validator=derivative_market_validator, on_validated=derivative_market_on_validated), "order_amount": ConfigVar( key="order_amount", From 52273fcee5f6ed480e9b0eb1953cdbadc47927c1 Mon Sep 17 00:00:00 2001 From: Daniel Tan Date: Thu, 25 Feb 2021 02:31:38 +0800 Subject: [PATCH 073/131] (fix) fix issue where orders not meeting trading rules are still being tracked --- .../exchange/probit/probit_exchange.py | 55 ++++++++++--------- 1 file changed, 28 insertions(+), 27 deletions(-) diff --git a/hummingbot/connector/exchange/probit/probit_exchange.py b/hummingbot/connector/exchange/probit/probit_exchange.py index a78d3d3f76..ebeb4105b7 100644 --- a/hummingbot/connector/exchange/probit/probit_exchange.py +++ b/hummingbot/connector/exchange/probit/probit_exchange.py @@ -463,34 +463,35 @@ async def _create_order(self, 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"{trade_type.name} order amount {amount} is lower than the minimum order size " - f"{trading_rule.min_order_size}.") - - order_value: Decimal = amount * price - if order_value < trading_rule.min_order_value: - raise ValueError(f"{trade_type.name} order value {order_value} is lower than the minimum order value " - f"{trading_rule.min_order_value}") - - body_params = { - "market_id": trading_pair, - "type": "limit", # ProBit Order Types ["limit", "market"} - "side": trade_type.name.lower(), # ProBit Order Sides ["buy", "sell"] - "time_in_force": "gtc", # gtc = Good-Til-Cancelled - "limit_price": str(price), - "quantity": str(amount), - "client_order_id": order_id - } - - self.start_tracking_order(order_id, - None, - trading_pair, - trade_type, - price, - amount, - order_type - ) try: + if amount < trading_rule.min_order_size: + raise ValueError(f"{trade_type.name} order amount {amount} is lower than the minimum order size " + f"{trading_rule.min_order_size}.") + + order_value: Decimal = amount * price + if order_value < trading_rule.min_order_value: + raise ValueError(f"{trade_type.name} order value {order_value} is lower than the minimum order value " + f"{trading_rule.min_order_value}") + + body_params = { + "market_id": trading_pair, + "type": "limit", # ProBit Order Types ["limit", "market"} + "side": trade_type.name.lower(), # ProBit Order Sides ["buy", "sell"] + "time_in_force": "gtc", # gtc = Good-Til-Cancelled + "limit_price": str(price), + "quantity": str(amount), + "client_order_id": order_id + } + + self.start_tracking_order(order_id, + None, + trading_pair, + trade_type, + price, + amount, + order_type + ) + order_result = await self._api_request( method="POST", path_url=CONSTANTS.NEW_ORDER_URL, From 7fca6387bc474f2811bb48c7469d9aa7c3fe7344 Mon Sep 17 00:00:00 2001 From: vic-en Date: Wed, 24 Feb 2021 20:10:39 +0100 Subject: [PATCH 074/131] (feat) refactor funding payment --- .../perpetual_finance_derivative.py | 32 +++++++++---------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py index 48b86ea17f..75980610c1 100644 --- a/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py +++ b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py @@ -85,7 +85,8 @@ def __init__(self, self._auto_approve_task = None self._real_time_balance_update = False self._poll_notifier = None - self._funding_payment_span = [1800, 0] + self._funding_payment_span = [120, 120] + self._fundingPayment = {} @property def name(self): @@ -494,16 +495,11 @@ async def _update_balances(self): async def _update_positions(self): position_tasks = [] - funding_payment_tasks = [] for pair in self._trading_pairs: position_tasks.append(self._api_request("post", "perpfi/position", {"pair": convert_to_exchange_trading_pair(pair)})) - funding_payment_tasks.append(self._api_request("get", - "perpfi/funding_payment", - {"pair": convert_to_exchange_trading_pair(pair)})) positions = await safe_gather(*position_tasks, return_exceptions=True) - funding_payments = await safe_gather(*funding_payment_tasks, return_exceptions=True) for trading_pair, position in zip(self._trading_pairs, positions): position = position.get("position", {}) position_side = PositionSide.LONG if position.get("size", 0) > 0 else PositionSide.SHORT @@ -524,17 +520,19 @@ async def _update_positions(self): if (trading_pair + position_side.name) in self._account_positions: del self._account_positions[trading_pair + position_side.name] - for trading_pair, funding_payment in zip(self._trading_pairs, funding_payments): - payment = Decimal(str(funding_payment.payment)) - action = "paid" if payment < 0 else "received" - if payment != Decimal("0"): - self.logger().info(f"Funding payment of {payment} {action} on {trading_pair} market.") - self.trigger_event(MarketEvent.FundingPaymentCompleted, - FundingPaymentCompletedEvent(timestamp=funding_payment.timestamp, - market=self.name, - rate=self._funding_info[trading_pair]["rate"], - symbol=trading_pair, - amount=payment)) + payment = Decimal(str(position.fundingPayment)) + oldPayment = self._fundingPayment.get(trading_pair, 0) + if payment != oldPayment: + self._fundingPayment = oldPayment + action = "paid" if payment < 0 else "received" + if payment != Decimal("0"): + self.logger().info(f"Funding payment of {payment} {action} on {trading_pair} market.") + self.trigger_event(MarketEvent.FundingPaymentCompleted, + FundingPaymentCompletedEvent(timestamp=time.time(), + market=self.name, + rate=self._funding_info[trading_pair]["rate"], + symbol=trading_pair, + amount=payment)) async def _funding_info_polling_loop(self): while True: From e3dd62ccf5b26cdea9c07275b8d9c44555bf6ecd Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Wed, 24 Feb 2021 16:51:10 -0800 Subject: [PATCH 075/131] (feat) adjusted status labels --- hummingbot/strategy/liquidity_mining/liquidity_mining.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/hummingbot/strategy/liquidity_mining/liquidity_mining.py b/hummingbot/strategy/liquidity_mining/liquidity_mining.py index 0503dd99d4..e2f25558cb 100644 --- a/hummingbot/strategy/liquidity_mining/liquidity_mining.py +++ b/hummingbot/strategy/liquidity_mining/liquidity_mining.py @@ -123,7 +123,7 @@ def order_age(order: LimitOrder) -> float: return -1. async def active_orders_df(self) -> pd.DataFrame: - size_q_col = f"Size ({self._token})" if self.is_token_a_quote_token() else "Size (Quote)" + size_q_col = f"Amt({self._token})" if self.is_token_a_quote_token() else "Amt(Quote)" columns = ["Market", "Side", "Price", "Spread", "Amount", size_q_col, "Age"] data = [] for order in self.active_orders: @@ -148,7 +148,7 @@ async def active_orders_df(self) -> pd.DataFrame: def budget_status_df(self) -> pd.DataFrame: data = [] - columns = ["Market", f"Budget ({self._token})", "Base Bal", "Quote Bal", "Base / Quote"] + columns = ["Market", f"Budget({self._token})", "Base bal", "Quote bal", "Base/Quote"] for market, market_info in self._market_infos.items(): mid_price = market_info.get_mid_price() base_bal = self._sell_budgets[market] @@ -172,7 +172,7 @@ def budget_status_df(self) -> pd.DataFrame: def market_status_df(self) -> pd.DataFrame: data = [] - columns = ["Market", "Mid Price", "Best Bid %", "Best Ask %", "Volatility"] + columns = ["Market", "Mid price", "Best bid", "Best ask", "Volatility"] for market, market_info in self._market_infos.items(): mid_price = market_info.get_mid_price() best_bid = self._exchange.get_price(market, False) @@ -192,7 +192,7 @@ def market_status_df(self) -> pd.DataFrame: async def miner_status_df(self) -> pd.DataFrame: data = [] - columns = ["Market", "Paid in", "Reward/week", "Curr Liquidity", "APY", "Max Spread"] + columns = ["Market", "Payout", "Reward/wk", "Liquidity", "Yield/yr", "Max spread"] campaigns = await get_campaign_summary(self._exchange.display_name, list(self._market_infos.keys())) for market, campaign in campaigns.items(): reward_usd = await usd_value(campaign.payout_asset, campaign.reward_per_day * Decimal("7")) From b04b59783fd715916d6dd3784e4835422a144748 Mon Sep 17 00:00:00 2001 From: Kristopher Klosterman Date: Wed, 24 Feb 2021 22:00:54 -0500 Subject: [PATCH 076/131] (fix) Fix for typo in history command #2974, changed curent to current --- hummingbot/client/command/history_command.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hummingbot/client/command/history_command.py b/hummingbot/client/command/history_command.py index bc5346bf1c..9099929e89 100644 --- a/hummingbot/client/command/history_command.py +++ b/hummingbot/client/command/history_command.py @@ -105,7 +105,7 @@ def report_header(self, # type: HummingbotApplication current_time = get_timestamp() lines.extend( [f"\nStart Time: {datetime.fromtimestamp(start_time).strftime('%Y-%m-%d %H:%M:%S')}"] + - [f"Curent Time: {datetime.fromtimestamp(current_time).strftime('%Y-%m-%d %H:%M:%S')}"] + + [f"Current Time: {datetime.fromtimestamp(current_time).strftime('%Y-%m-%d %H:%M:%S')}"] + [f"Duration: {pd.Timedelta(seconds=int(current_time - start_time))}"] ) self._notify("\n".join(lines)) From f80aa356483c97274ff28e02ce34a8620171492f Mon Sep 17 00:00:00 2001 From: vic-en Date: Thu, 25 Feb 2021 13:16:04 +0100 Subject: [PATCH 077/131] (feat) fix incorrect key for spot connector in config map --- .../spot_perpetual_arbitrage_config_map.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hummingbot/strategy/spot_perpetual_arbitrage/spot_perpetual_arbitrage_config_map.py b/hummingbot/strategy/spot_perpetual_arbitrage/spot_perpetual_arbitrage_config_map.py index f20b4403a1..ae5083d55b 100644 --- a/hummingbot/strategy/spot_perpetual_arbitrage/spot_perpetual_arbitrage_config_map.py +++ b/hummingbot/strategy/spot_perpetual_arbitrage/spot_perpetual_arbitrage_config_map.py @@ -63,7 +63,7 @@ def order_amount_prompt() -> str: prompt="", default="spot_perpetual_arbitrage"), "spot_connector": ConfigVar( - key="connector_1", + key="spot_connector", prompt="Enter a spot connector (Exchange/AMM) >>> ", prompt_on_new=True, validator=validate_connector, From 2fbc267c9386a1df0c8bcc92855eff99267a713e Mon Sep 17 00:00:00 2001 From: Daniel Tan Date: Thu, 25 Feb 2021 20:41:52 +0800 Subject: [PATCH 078/131] (fix) fix partially filled orders[WIP] --- hummingbot/connector/exchange/probit/probit_exchange.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/hummingbot/connector/exchange/probit/probit_exchange.py b/hummingbot/connector/exchange/probit/probit_exchange.py index ebeb4105b7..52217a7bc9 100644 --- a/hummingbot/connector/exchange/probit/probit_exchange.py +++ b/hummingbot/connector/exchange/probit/probit_exchange.py @@ -811,7 +811,8 @@ def _process_trade_message(self, order_msg: Dict[str, Any]): tracked_order.executed_amount_base, tracked_order.executed_amount_quote, tracked_order.fee_paid, - tracked_order.order_type)) + tracked_order.order_type, + tracked_order.exchange_order_id)) self.stop_tracking_order(tracked_order.client_order_id) async def get_open_orders(self) -> List[OpenOrder]: From 280da0487b769a8b78ca0e9181df96ed74422c48 Mon Sep 17 00:00:00 2001 From: Daniel Tan Date: Fri, 26 Feb 2021 10:40:38 +0800 Subject: [PATCH 079/131] (fix) fix _update_with_trade_status() and update process_order_message() --- .../exchange/probit/probit_exchange.py | 24 ++++++++++--------- .../exchange/probit/probit_in_flight_order.py | 11 ++++----- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/hummingbot/connector/exchange/probit/probit_exchange.py b/hummingbot/connector/exchange/probit/probit_exchange.py index 52217a7bc9..6ea763ef60 100644 --- a/hummingbot/connector/exchange/probit/probit_exchange.py +++ b/hummingbot/connector/exchange/probit/probit_exchange.py @@ -716,7 +716,7 @@ async def _update_order_status(self): if isinstance(order_update, Exception): raise order_update if "data" not in order_update: - self.logger().info(f"_update_order_status data not in resp: {order_update}") + self.logger().info(f"Unexpected response from GET /order. 'data' field not in resp: {order_update}") continue for order in order_update["data"]: @@ -742,16 +742,18 @@ def _process_order_message(self, order_msg: Dict[str, Any]): client_order_id)) tracked_order.cancelled_event.set() self.stop_tracking_order(client_order_id) - elif tracked_order.is_failure: - self.logger().info(f"The market order {client_order_id} has failed according to order status API. " - f"Order Message: {order_msg}") - self.trigger_event(MarketEvent.OrderFailure, - MarketOrderFailureEvent( - self.current_timestamp, - client_order_id, - tracked_order.order_type - )) - self.stop_tracking_order(client_order_id) + + # ProBit does not have a 'fail' order status + # elif tracked_order.is_failure: + # self.logger().info(f"The market order {client_order_id} has failed according to order status API. " + # f"Order Message: {order_msg}") + # self.trigger_event(MarketEvent.OrderFailure, + # MarketOrderFailureEvent( + # self.current_timestamp, + # client_order_id, + # tracked_order.order_type + # )) + # self.stop_tracking_order(client_order_id) def _process_trade_message(self, order_msg: Dict[str, Any]): """ diff --git a/hummingbot/connector/exchange/probit/probit_in_flight_order.py b/hummingbot/connector/exchange/probit/probit_in_flight_order.py index fbfddc36fc..daafadca3d 100644 --- a/hummingbot/connector/exchange/probit/probit_in_flight_order.py +++ b/hummingbot/connector/exchange/probit/probit_in_flight_order.py @@ -45,8 +45,8 @@ def is_done(self) -> bool: @property def is_failure(self) -> bool: - # TODO: Determine Order Status Definitions for failed orders - return self.last_state in {"REJECTED"} + # TODO: ProBit does not have a 'fail' order status. + return NotImplementedError @property def is_cancelled(self) -> bool: @@ -84,10 +84,9 @@ def update_with_trade_update(self, trade_update: Dict[str, Any]) -> bool: if str(trade_update["order_id"]) != self.exchange_order_id or trade_id in self.trade_id_set: return False self.trade_id_set.add(trade_id) - self.executed_amount_base += Decimal(str(trade_update["quantity"])) - self.fee_paid += Decimal(str(trade_update["fee_amount"])) - self.executed_amount_quote += (Decimal(str(trade_update["price"])) * - Decimal(str(trade_update["quantity"]))) + self.executed_amount_base = Decimal(str(trade_update["quantity"])) + self.fee_paid = Decimal(str(trade_update["fee_amount"])) + self.executed_amount_quote = Decimal(str(trade_update["cost"])) if not self.fee_asset: self.fee_asset = trade_update["fee_currency_id"] return True From b5a8125cde0d2d13b256972d8d73f1d3185d21d4 Mon Sep 17 00:00:00 2001 From: Kirill Stroukov Date: Fri, 26 Feb 2021 10:37:36 +0100 Subject: [PATCH 080/131] feat/Bexy user order stream to APIv3 --- .../beaxy_api_user_stream_data_source.py | 28 ++---- .../connector/exchange/beaxy/beaxy_auth.py | 70 -------------- .../exchange/beaxy/beaxy_constants.py | 9 +- .../exchange/beaxy/beaxy_exchange.pyx | 91 ++++++++++--------- .../exchange/beaxy/beaxy_stomp_message.py | 41 --------- 5 files changed, 59 insertions(+), 180 deletions(-) delete mode 100644 hummingbot/connector/exchange/beaxy/beaxy_stomp_message.py diff --git a/hummingbot/connector/exchange/beaxy/beaxy_api_user_stream_data_source.py b/hummingbot/connector/exchange/beaxy/beaxy_api_user_stream_data_source.py index 74bedb125b..9a4b1cb108 100644 --- a/hummingbot/connector/exchange/beaxy/beaxy_api_user_stream_data_source.py +++ b/hummingbot/connector/exchange/beaxy/beaxy_api_user_stream_data_source.py @@ -3,19 +3,17 @@ import logging import asyncio import time -import ujson +import json import websockets from typing import AsyncIterable, Optional, List from websockets.exceptions import ConnectionClosed from hummingbot.core.data_type.user_stream_tracker_data_source import UserStreamTrackerDataSource -from hummingbot.core.utils.tracking_nonce import get_tracking_nonce from hummingbot.core.utils.async_utils import safe_gather from hummingbot.connector.exchange.beaxy.beaxy_auth import BeaxyAuth from hummingbot.connector.exchange.beaxy.beaxy_constants import BeaxyConstants -from hummingbot.connector.exchange.beaxy.beaxy_stomp_message import BeaxyStompMessage class BeaxyAPIUserStreamDataSource(UserStreamTrackerDataSource): @@ -42,27 +40,15 @@ def __init__(self, beaxy_auth: BeaxyAuth, trading_pairs: Optional[List[str]] = [ def last_recv_time(self) -> float: return self._last_recv_time - async def __listen_ws(self, dest: str): + async def __listen_ws(self, url: str): while True: try: - async with websockets.connect(BeaxyConstants.TradingApi.WS_BASE_URL) as ws: - ws: websockets.WebSocketClientProtocol = ws - connect_request = BeaxyStompMessage('CONNECT') - connect_request.headers = await self._beaxy_auth.generate_ws_auth_dict() - await ws.send(connect_request.serialize()) - - orders_sub_request = BeaxyStompMessage('SUBSCRIBE') - orders_sub_request.headers['id'] = f'sub-humming-{get_tracking_nonce()}' - orders_sub_request.headers['destination'] = dest - orders_sub_request.headers['X-Deltix-Nonce'] = str(get_tracking_nonce()) - await ws.send(orders_sub_request.serialize()) - + token = await self._beaxy_auth.get_token() + async with websockets.connect(url.format(access_token=token)) as ws: async for raw_msg in self._inner_messages(ws): - stomp_message = BeaxyStompMessage.deserialize(raw_msg) - if stomp_message.has_error(): - raise Exception(f'Got error from ws. Headers - {stomp_message.headers}') - - msg = ujson.loads(stomp_message.body) + msg = json.loads(raw_msg) # ujson may round floats uncorrectly + if msg.get('type') == 'keep_alive': + continue yield msg except asyncio.CancelledError: raise diff --git a/hummingbot/connector/exchange/beaxy/beaxy_auth.py b/hummingbot/connector/exchange/beaxy/beaxy_auth.py index 9a9ffae30a..0e800d43c0 100644 --- a/hummingbot/connector/exchange/beaxy/beaxy_auth.py +++ b/hummingbot/connector/exchange/beaxy/beaxy_auth.py @@ -1,20 +1,14 @@ # -*- coding: utf-8 -*- import logging -import base64 -import random import asyncio from typing import Dict, Any, Optional from time import monotonic from datetime import datetime import aiohttp -from Crypto.PublicKey import RSA -from Crypto.Signature import PKCS1_v1_5 -from Crypto.Hash import HMAC, SHA384, SHA256 from hummingbot.core.utils.async_utils import safe_gather -from hummingbot.core.utils.tracking_nonce import get_tracking_nonce from hummingbot.logger import HummingbotLogger from hummingbot.connector.exchange.beaxy.beaxy_constants import BeaxyConstants @@ -132,67 +126,3 @@ async def _auth_token_polling_loop(self): async def generate_auth_dict(self, http_method: str, path: str, body: str = '') -> Dict[str, Any]: auth_token = await self.get_token() return {'Authorization': f'Bearer {auth_token}'} - - async def generate_ws_auth_dict(self) -> Dict[str, Any]: - session_data = await self.__get_session_data() - headers = {'X-Deltix-Nonce': str(get_tracking_nonce()), 'X-Deltix-Session-Id': session_data['session_id']} - payload = self.__build_ws_payload(headers) - hmac = HMAC.new(key= self.__int_to_bytes(session_data['sign_key'], signed=True), msg=bytes(payload, 'utf-8'), digestmod=SHA384) - digestb64 = base64.b64encode(hmac.digest()) - headers['X-Deltix-Signature'] = digestb64.decode('utf-8') - return headers - - async def __get_session_data(self) -> Dict[str, Any]: - if not self._session_data_cache: - dh_number = random.getrandbits(64 * 8) - login_attempt = await self.__login_attempt() - sign_key = await self.__login_confirm(login_attempt, dh_number) - retval = {'sign_key': sign_key, 'session_id': login_attempt['session_id']} - self._session_data_cache = retval - - return self._session_data_cache - - async def __login_confirm(self, login_attempt: Dict[str, str], dh_number: int) -> int: - dh_modulus = int.from_bytes(base64.b64decode(login_attempt['dh_modulus']), 'big', signed= False) - dh_base = int.from_bytes(base64.b64decode(login_attempt['dh_base']), 'big', signed= False) - msg = base64.b64decode(login_attempt['challenge']) - digest = SHA256.new(msg) - pem = f'-----BEGIN PRIVATE KEY-----\n{self.api_secret}\n-----END PRIVATE KEY-----' - privateKey = RSA.importKey(pem) - encryptor = PKCS1_v1_5.new(privateKey) - encrypted_msg = base64.b64encode(encryptor.sign(digest)).decode('utf-8') - dh_key_raw = pow(dh_base, dh_number, dh_modulus) - dh_key_bytes = self.__int_to_bytes(dh_key_raw, signed=True) - dh_key = base64.b64encode(dh_key_bytes).decode('utf-8') - - async with aiohttp.ClientSession() as client: - async with client.post( - f'{BeaxyConstants.TradingApi.BASE_URL_V1}{BeaxyConstants.TradingApi.LOGIN_CONFIRM_ENDPOINT}', json = {'session_id': login_attempt['session_id'], 'signature': encrypted_msg, 'dh_key': dh_key}) as response: - response: aiohttp.ClientResponse = response - if response.status != 200: - raise IOError(f'Error while connecting to login confirm endpoint. HTTP status is {response.status}.') - data: Dict[str, str] = await response.json() - dh_key_result = int.from_bytes(base64.b64decode(data['dh_key']), 'big', signed= False) - return pow(dh_key_result, dh_number, dh_modulus) - - def __int_to_bytes(self, i: int, *, signed: bool = False) -> bytes: - length = ((i + ((i * signed) < 0)).bit_length() + 7 + signed) // 8 - return i.to_bytes(length, byteorder='big', signed=signed) - - async def __login_attempt(self) -> Dict[str, str]: - async with aiohttp.ClientSession() as client: - async with client.post(f'{BeaxyConstants.TradingApi.BASE_URL_V1}{BeaxyConstants.TradingApi.LOGIN_ATTEMT_ENDPOINT}', json = {'api_key_id': self.api_key}) as response: - response: aiohttp.ClientResponse = response - if response.status != 200: - raise IOError(f'Error while connecting to login attempt endpoint. HTTP status is {response.status}.') - data: Dict[str, str] = await response.json() - return data - - def __build_payload(self, http_method: str, path: str, query_params: Dict[str, str], headers: Dict[str, str], body: str = ''): - query_params_stringified = '&'.join([f'{k}={query_params[k]}' for k in sorted(query_params)]) - headers_stringified = '&'.join([f'{k}={headers[k]}' for k in sorted(headers)]) - return f'{http_method.upper()}{path.lower()}{query_params_stringified}{headers_stringified}{body}' - - def __build_ws_payload(self, headers: Dict[str, str]) -> str: - headers_stringified = '&'.join([f'{k}={headers[k]}' for k in sorted(headers)]) - return f'CONNECT/websocket/v1{headers_stringified}' diff --git a/hummingbot/connector/exchange/beaxy/beaxy_constants.py b/hummingbot/connector/exchange/beaxy/beaxy_constants.py index ebdd314d5f..5077903fc9 100644 --- a/hummingbot/connector/exchange/beaxy/beaxy_constants.py +++ b/hummingbot/connector/exchange/beaxy/beaxy_constants.py @@ -18,12 +18,9 @@ class TradingApi: CREATE_ORDER_ENDPOINT = '/api/v2/orders' TRADE_SETTINGS_ENDPOINT = '/api/v2/tradingsettings' - LOGIN_ATTEMT_ENDPOINT = '/api/v1/login/attempt' - LOGIN_CONFIRM_ENDPOINT = '/api/v1/login/confirm' - - WS_BASE_URL = 'wss://tradingapi.beaxy.com/websocket/v1' - WS_ORDERS_ENDPOINT = '/user/v1/orders' - WS_BALANCE_ENDPOINT = '/user/v1/balances' + WS_BASE_URL = 'wss://tradewith.beaxy.com' + WS_ORDERS_ENDPOINT = WS_BASE_URL + '/ws/v2/orders?access_token={access_token}' + WS_BALANCE_ENDPOINT = WS_BASE_URL + '/ws/v2/wallets?access_token={access_token}' class PublicApi: BASE_URL = 'https://services.beaxy.com' diff --git a/hummingbot/connector/exchange/beaxy/beaxy_exchange.pyx b/hummingbot/connector/exchange/beaxy/beaxy_exchange.pyx index 8ad509b77e..0c93817b3e 100644 --- a/hummingbot/connector/exchange/beaxy/beaxy_exchange.pyx +++ b/hummingbot/connector/exchange/beaxy/beaxy_exchange.pyx @@ -359,43 +359,45 @@ cdef class BeaxyExchange(ExchangeBase): del self._order_not_found_records[client_order_id] continue - execute_price = Decimal(order_update['average_price'] if order_update['average_price'] else order_update['limit_price']) + execute_price = Decimal(order_update['limit_price'] if order_update['limit_price'] else order_update['average_price']) if order_update['filled_size']: new_confirmed_amount = Decimal(order_update['filled_size']) execute_amount_diff = new_confirmed_amount - tracked_order.executed_amount_base - order_type_description = tracked_order.order_type_description - # Emit event if executed amount is greater than 0. if execute_amount_diff > s_decimal_0: - order_filled_event = OrderFilledEvent( - self._current_timestamp, - tracked_order.client_order_id, - tracked_order.trading_pair, - tracked_order.trade_type, - tracked_order.order_type, - execute_price, - execute_amount_diff, - self.c_get_fee( - tracked_order.base_asset, - tracked_order.quote_asset, - tracked_order.order_type, + + tracked_order.executed_amount_base = new_confirmed_amount + tracked_order.executed_amount_quote += execute_amount_diff * execute_price + + order_type_description = tracked_order.order_type_description + # Emit event if executed amount is greater than 0. + if execute_amount_diff > s_decimal_0: + order_filled_event = OrderFilledEvent( + self._current_timestamp, + tracked_order.client_order_id, + tracked_order.trading_pair, tracked_order.trade_type, + tracked_order.order_type, execute_price, execute_amount_diff, - ), - exchange_trade_id=exchange_order_id, - ) - self.logger().info(f'Filled {execute_amount_diff} out of {tracked_order.amount} of the ' - f'{order_type_description} order {client_order_id}.') - self.c_trigger_event(self.MARKET_ORDER_FILLED_EVENT_TAG, order_filled_event) - else: - new_confirmed_amount = Decimal(order_update['size']) + self.c_get_fee( + tracked_order.base_asset, + tracked_order.quote_asset, + tracked_order.order_type, + tracked_order.trade_type, + execute_price, + execute_amount_diff, + ), + exchange_trade_id=exchange_order_id, + ) + self.logger().info(f'Filled {execute_amount_diff} out of {tracked_order.amount} of the ' + f'{order_type_description} order {client_order_id}.') + self.c_trigger_event(self.MARKET_ORDER_FILLED_EVENT_TAG, order_filled_event) # Update the tracked order tracked_order.last_state = order_update['order_status'] if not closed_order else 'closed' - tracked_order.executed_amount_base = new_confirmed_amount - tracked_order.executed_amount_quote = new_confirmed_amount * execute_price + if tracked_order.is_done: if not tracked_order.is_failure: if tracked_order.trade_type == TradeType.BUY: @@ -815,18 +817,23 @@ cdef class BeaxyExchange(ExchangeBase): async for msg_type, event_message in self._iter_user_event_queue(): try: if msg_type == BeaxyConstants.UserStream.BALANCE_MESSAGE: - for msg in event_message: - asset_name = msg['currency_id'] - available_balance = Decimal(msg['available_for_trading']) - total_balance = Decimal(msg['balance']) + if event_message['type'] == 'update': + msgs = [event_message['data']] + elif event_message['type'] == 'snapshot': + msgs = event_message['data'] + + for msg in msgs: + asset_name = msg['currency'] + available_balance = Decimal(msg['available_balance']) + total_balance = Decimal(msg['total_balance']) self._account_available_balances[asset_name] = available_balance self._account_balances[asset_name] = total_balance elif msg_type == BeaxyConstants.UserStream.ORDER_MESSAGE: - order = event_message['order'] - exchange_order_id = order['id'] - client_order_id = order['text'] - order_status = order['status'] + order = event_message['data'] + exchange_order_id = order['order_id'] + client_order_id = order['comment'] + order_status = order['order_status'] if client_order_id is None: continue @@ -843,17 +850,17 @@ cdef class BeaxyExchange(ExchangeBase): execute_price = s_decimal_0 execute_amount_diff = s_decimal_0 - if event_message['events']: - order_event = event_message['events'][0] - event_type = order_event['type'] + if order_status == 'partially_filled': + order_filled_size = Decimal(order['trade_size']) + execute_price = Decimal(order['trade_price']) - if event_type == 'trade': - execute_price = Decimal(order_event.get('trade_price', 0.0)) - execute_amount_diff = Decimal(order_event.get('trade_quantity', 0.0)) - tracked_order.executed_amount_base = Decimal(order['cumulative_quantity']) - tracked_order.executed_amount_quote += execute_amount_diff * execute_price + execute_amount_diff = order_filled_size - tracked_order.executed_amount_base if execute_amount_diff > s_decimal_0: + + tracked_order.executed_amount_base = order_filled_size + tracked_order.executed_amount_quote += Decimal(execute_amount_diff * execute_price) + self.logger().info(f'Filled {execute_amount_diff} out of {tracked_order.amount} of the ' f'{tracked_order.order_type_description} order {tracked_order.client_order_id}') @@ -877,7 +884,7 @@ cdef class BeaxyExchange(ExchangeBase): exchange_trade_id=exchange_order_id )) - if order_status == 'completely_filled': + elif order_status == 'completely_filled': if tracked_order.trade_type == TradeType.BUY: self.logger().info(f'The market buy order {tracked_order.client_order_id} has completed ' f'according to Beaxy user stream.') diff --git a/hummingbot/connector/exchange/beaxy/beaxy_stomp_message.py b/hummingbot/connector/exchange/beaxy/beaxy_stomp_message.py deleted file mode 100644 index b51f5031ad..0000000000 --- a/hummingbot/connector/exchange/beaxy/beaxy_stomp_message.py +++ /dev/null @@ -1,41 +0,0 @@ -# -*- coding: utf-8 -*- - -from typing import Dict - - -class BeaxyStompMessage: - def __init__(self, command: str = '') -> None: - self.command = command - self.body: str = "" - self.headers: Dict[str, str] = {} - - def serialize(self) -> str: - result = self.command + '\n' - result += ''.join([f'{k}:{self.headers[k]}\n' for k in self.headers]) - result += '\n' - result += self.body - result += '\0' - return result - - def has_error(self) -> bool: - return self.headers.get('status') != '200' - - @staticmethod - def deserialize(raw_message: str) -> 'BeaxyStompMessage': - lines = raw_message.splitlines() - retval = BeaxyStompMessage() - for index, line in enumerate(lines): - if index == 0: - retval.command = line - else: - split = line.split(':') - if len(split) == 2: - retval.headers[split[0].strip()] = split[1].strip() - else: - if line: - line_index = raw_message.index(line) - retval.body = raw_message[line_index:] - retval.body = ''.join(c for c in retval.body if c not in ['\r', '\n', '\0']) - break - - return retval From 94ebf5186397bbf318eaccd6f80ba5b20ff569fe Mon Sep 17 00:00:00 2001 From: Daniel Tan Date: Fri, 26 Feb 2021 17:53:20 +0800 Subject: [PATCH 081/131] (fix) outstanding issue with handling of partially filled orders --- .../connector/exchange/probit/probit_exchange.py | 12 ++++++++---- .../exchange/probit/probit_in_flight_order.py | 6 +++--- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/hummingbot/connector/exchange/probit/probit_exchange.py b/hummingbot/connector/exchange/probit/probit_exchange.py index 6ea763ef60..f4ad5a303b 100644 --- a/hummingbot/connector/exchange/probit/probit_exchange.py +++ b/hummingbot/connector/exchange/probit/probit_exchange.py @@ -377,7 +377,9 @@ async def _api_request(self, raise IOError(f"Error parsing data from {path_url}. Error: {str(e)}") if response.status != 200: raise IOError(f"Error fetching data from {path_url}. HTTP status is {response.status}. " - f"Message: {parsed_response}") + f"Message: {parsed_response} " + f"Params: {params} " + f"Data: {data}") return parsed_response @@ -734,7 +736,9 @@ def _process_order_message(self, order_msg: Dict[str, Any]): # Update order execution status tracked_order.last_state = order_msg["status"] - if tracked_order.is_cancelled: + + # NOTE: In ProBit partially-filled orders will retain "filled" status when canceled. + if tracked_order.is_cancelled or Decimal(str(order_msg["cancelled_quantity"])) > Decimal("0"): self.logger().info(f"Successfully cancelled order {client_order_id}.") self.trigger_event(MarketEvent.OrderCancelled, OrderCancelledEvent( @@ -743,7 +747,7 @@ def _process_order_message(self, order_msg: Dict[str, Any]): tracked_order.cancelled_event.set() self.stop_tracking_order(client_order_id) - # ProBit does not have a 'fail' order status + # NOTE: ProBit does not have a 'fail' order status # elif tracked_order.is_failure: # self.logger().info(f"The market order {client_order_id} has failed according to order status API. " # f"Order Message: {order_msg}") @@ -957,7 +961,7 @@ async def _user_stream_event_listener(self): for asset, balance_details in event_message["data"].items(): self._account_balances[asset] = Decimal(str(balance_details["total"])) self._account_available_balances[asset] = Decimal(str(balance_details["available"])) - elif channel in ["open_order", "order_history"]: + elif channel in ["open_order"]: for order_update in event_message["data"]: self._process_order_message(order_update) elif channel == "trade_history": diff --git a/hummingbot/connector/exchange/probit/probit_in_flight_order.py b/hummingbot/connector/exchange/probit/probit_in_flight_order.py index daafadca3d..ab6fdcc0c7 100644 --- a/hummingbot/connector/exchange/probit/probit_in_flight_order.py +++ b/hummingbot/connector/exchange/probit/probit_in_flight_order.py @@ -84,9 +84,9 @@ def update_with_trade_update(self, trade_update: Dict[str, Any]) -> bool: if str(trade_update["order_id"]) != self.exchange_order_id or trade_id in self.trade_id_set: return False self.trade_id_set.add(trade_id) - self.executed_amount_base = Decimal(str(trade_update["quantity"])) - self.fee_paid = Decimal(str(trade_update["fee_amount"])) - self.executed_amount_quote = Decimal(str(trade_update["cost"])) + self.executed_amount_base += Decimal(str(trade_update["quantity"])) + self.fee_paid += Decimal(str(trade_update["fee_amount"])) + self.executed_amount_quote += Decimal(str(trade_update["cost"])) if not self.fee_asset: self.fee_asset = trade_update["fee_currency_id"] return True From 9d61893ae064de0b458d079ed9db437a6a7ae645 Mon Sep 17 00:00:00 2001 From: Kirill Stroukov Date: Fri, 26 Feb 2021 14:40:11 +0100 Subject: [PATCH 082/131] feat/Bexy fix order status, fix auth logic --- .../connector/exchange/beaxy/beaxy_auth.py | 86 +++++++++++-------- .../exchange/beaxy/beaxy_in_flight_order.pyx | 6 +- 2 files changed, 53 insertions(+), 39 deletions(-) diff --git a/hummingbot/connector/exchange/beaxy/beaxy_auth.py b/hummingbot/connector/exchange/beaxy/beaxy_auth.py index 0e800d43c0..c9c76d733d 100644 --- a/hummingbot/connector/exchange/beaxy/beaxy_auth.py +++ b/hummingbot/connector/exchange/beaxy/beaxy_auth.py @@ -30,6 +30,7 @@ def __init__(self, api_key: str, api_secret: str): self.token: Optional[str] = None self.token_obtain = asyncio.Event() + self.token_obtain_started = False self.token_valid_to: float = 0 self.token_next_refresh: float = 0 self.token_obtain_start_time = 0 @@ -52,57 +53,70 @@ def invalidate_token(self): self.token_valid_to = 0 async def get_token(self): - if self.is_token_valid(): - return self.token - # token is invalid, waiting for a renew - if not self.token_obtain.is_set(): - # if process of refreshing is not started, start it - await self._update_token() - return self.token + for _ in range(3): + if self.is_token_valid(): + return self.token + + # token is invalid, waiting for a renew + if not self.token_obtain_started: + # if process of refreshing is not started, start it + await self._update_token() + + if not self.is_token_valid(): + continue + return self.token - # waiting for fresh token - await asyncio.wait_for(self.token_obtain.wait(), timeout=TOKEN_OBTAIN_TIMEOUT) + # waiting for fresh token + await asyncio.wait_for(self.token_obtain.wait(), timeout=TOKEN_OBTAIN_TIMEOUT) - if not self.is_token_valid(): - raise ValueError('Invalid auth token timestamp') - return self.token + if not self.is_token_valid(): + continue + return self.token + + raise ValueError('Invalid auth token timestamp') async def _update_token(self): self.token_obtain.clear() + self.token_obtain_started = True - start_time = monotonic() - start_timestamp = datetime.now() + try: - async with aiohttp.ClientSession() as client: - async with client.post( - f'{BeaxyConstants.TradingApi.BASE_URL}{BeaxyConstants.TradingApi.TOKEN_ENDPOINT}', - json={'api_key_id': self.api_key, 'api_secret': self.api_secret} - ) as response: - response: aiohttp.ClientResponse = response - if response.status != 200: - raise IOError(f'Error while connecting to login token endpoint. HTTP status is {response.status}.') - data: Dict[str, str] = await response.json() + start_time = monotonic() + start_timestamp = datetime.now() - if data['type'] != 'Bearer': - raise IOError(f'Error while connecting to login token endpoint. Token type is {data["type"]}.') + async with aiohttp.ClientSession() as client: + async with client.post( + f'{BeaxyConstants.TradingApi.BASE_URL}{BeaxyConstants.TradingApi.TOKEN_ENDPOINT}', + json={'api_key_id': self.api_key, 'api_secret': self.api_secret} + ) as response: + response: aiohttp.ClientResponse = response + if response.status != 200: + raise IOError(f'Error while connecting to login token endpoint. HTTP status is {response.status}.') + data: Dict[str, str] = await response.json() - if int(data['expires_in']) < MIN_TOKEN_LIFE_TIME_SECONDS: - raise IOError(f'Error while connecting to login token endpoint. Token lifetime to small {data["expires_in"]}.') + if data['type'] != 'Bearer': + raise IOError(f'Error while connecting to login token endpoint. Token type is {data["type"]}.') - self.token = data['access_token'] - self.token_raw_expires = data['expires_in'] + if int(data['expires_in']) < MIN_TOKEN_LIFE_TIME_SECONDS: + raise IOError(f'Error while connecting to login token endpoint. Token lifetime to small {data["expires_in"]}.') - # include safe interval, e.g. time that approx network request can take - self.token_obtain_start_time = start_timestamp - self.token_valid_to = start_time + int(data['expires_in']) - SAFE_TIME_PERIOD_SECONDS - self.token_next_refresh = start_time + TOKEN_REFRESH_PERIOD_SECONDS + self.token = data['access_token'] + self.token_raw_expires = data['expires_in'] - if not self.is_token_valid(): - raise ValueError('Invalid auth token timestamp') + # include safe interval, e.g. time that approx network request can take + self.token_obtain_start_time = start_timestamp + self.token_valid_to = start_time + int(data['expires_in']) - SAFE_TIME_PERIOD_SECONDS + self.token_next_refresh = start_time + TOKEN_REFRESH_PERIOD_SECONDS + + if not self.is_token_valid(): + raise ValueError('Invalid auth token timestamp') + + self.token_obtain.set() - self.token_obtain.set() + finally: + self.token_obtain_started = False async def _auth_token_polling_loop(self): """ diff --git a/hummingbot/connector/exchange/beaxy/beaxy_in_flight_order.pyx b/hummingbot/connector/exchange/beaxy/beaxy_in_flight_order.pyx index b657489997..ccbc03cdd0 100644 --- a/hummingbot/connector/exchange/beaxy/beaxy_in_flight_order.pyx +++ b/hummingbot/connector/exchange/beaxy/beaxy_in_flight_order.pyx @@ -35,16 +35,16 @@ cdef class BeaxyInFlightOrder(InFlightOrderBase): @property def is_done(self) -> bool: - return self.last_state in {'closed', 'completely_filled', 'cancelled', 'rejected', 'replaced', 'expired', 'pending_cancel', 'suspended', 'pending_replace'} + return self.last_state in {'closed', 'completely_filled', 'canceled', 'cancelled', 'rejected', 'replaced', 'expired', 'pending_cancel', 'suspended', 'pending_replace'} @property def is_failure(self) -> bool: # This is the only known canceled state - return self.last_state in {'cancelled', 'pending_cancel', 'rejected', 'expired', 'suspended'} + return self.last_state in {'canceled', 'cancelled', 'pending_cancel', 'rejected', 'expired', 'suspended'} @property def is_cancelled(self) -> bool: - return self.last_state == 'cancelled' + return self.last_state in {'cancelled', 'canceled'} @property def order_type_description(self) -> str: From 9188c2729be31223df5a5a7ff13983d310da5ffc Mon Sep 17 00:00:00 2001 From: Daniel Tan Date: Fri, 26 Feb 2021 19:23:46 +0800 Subject: [PATCH 083/131] (refactor) include global and korea domains to ProBit connector --- .../probit_api_order_book_data_source.py | 23 ++++++++++--------- .../probit_api_user_stream_data_source.py | 17 +++++++++++--- .../connector/exchange/probit/probit_auth.py | 6 +++-- .../exchange/probit/probit_constants.py | 6 ++--- .../exchange/probit/probit_exchange.py | 16 +++++++++---- .../probit/probit_order_book_tracker.py | 12 ++++++---- .../probit/probit_user_stream_tracker.py | 15 ++++++++---- .../connector/exchange/probit/probit_utils.py | 23 ++++++++++++++++++- .../templates/conf_fee_overrides_TEMPLATE.yml | 6 +++++ hummingbot/templates/conf_global_TEMPLATE.yml | 3 +++ 10 files changed, 94 insertions(+), 33 deletions(-) diff --git a/hummingbot/connector/exchange/probit/probit_api_order_book_data_source.py b/hummingbot/connector/exchange/probit/probit_api_order_book_data_source.py index 3f04d83710..4e6a8550a4 100644 --- a/hummingbot/connector/exchange/probit/probit_api_order_book_data_source.py +++ b/hummingbot/connector/exchange/probit/probit_api_order_book_data_source.py @@ -7,7 +7,7 @@ import ujson import websockets -import hummingbot.connector.exchange.probit.probit_constants as constants +import hummingbot.connector.exchange.probit.probit_constants as CONSTANTS from typing import ( Any, @@ -38,16 +38,17 @@ def logger(cls) -> HummingbotLogger: cls._logger = logging.getLogger(__name__) return cls._logger - def __init__(self, trading_pairs: List[str] = None): + def __init__(self, trading_pairs: List[str] = None, domain: str = "com"): super().__init__(trading_pairs) + self._domain = domain self._trading_pairs: List[str] = trading_pairs self._snapshot_msg: Dict[str, any] = {} @classmethod - async def get_last_traded_prices(cls, trading_pairs: List[str]) -> Dict[str, float]: + async def get_last_traded_prices(cls, trading_pairs: List[str], domain: str = "com") -> Dict[str, float]: result = {} async with aiohttp.ClientSession() as client: - async with client.get(f"{constants.TICKER_URL}") as response: + async with client.get(f"{CONSTANTS.TICKER_URL.format(domain)}") as response: if response.status == 200: resp_json = await response.json() if "data" in resp_json: @@ -58,25 +59,25 @@ async def get_last_traded_prices(cls, trading_pairs: List[str]) -> Dict[str, flo return result @staticmethod - async def fetch_trading_pairs() -> List[str]: + async def fetch_trading_pairs(domain: str = "com") -> List[str]: async with aiohttp.ClientSession() as client: - async with client.get(f"{constants.MARKETS_URL}") as response: + async with client.get(f"{CONSTANTS.MARKETS_URL.format(domain)}") as response: if response.status == 200: resp_json: Dict[str, Any] = await response.json() return [market["id"] for market in resp_json["data"]] return [] @staticmethod - async def get_order_book_data(trading_pair: str) -> Dict[str, any]: + async def get_order_book_data(trading_pair: str, domain: str = "com") -> Dict[str, any]: """ Get whole orderbook """ async with aiohttp.ClientSession() as client: - async with client.get(url=f"{constants.ORDER_BOOK_URL}", + async with client.get(url=f"{CONSTANTS.ORDER_BOOK_URL.format(domain)}", params={"market_id": trading_pair}) as response: if response.status != 200: raise IOError( - f"Error fetching OrderBook for {trading_pair} at {constants.ORDER_BOOK_PATH_URL}. " + f"Error fetching OrderBook for {trading_pair} at {CONSTANTS.ORDER_BOOK_PATH_URL.format(domain)}. " f"HTTP {response.status}. Response: {await response.json()}" ) return await response.json() @@ -128,7 +129,7 @@ async def listen_for_trades(self, ev_loop: asyncio.BaseEventLoop, output: asynci """ while True: try: - async with websockets.connect(uri=constants.WSS_URL) as ws: + async with websockets.connect(uri=CONSTANTS.WSS_URL.format(self._domain)) as ws: ws: websockets.WebSocketClientProtocol = ws for trading_pair in self._trading_pairs: params: Dict[str, Any] = { @@ -169,7 +170,7 @@ async def listen_for_order_book_diffs(self, ev_loop: asyncio.BaseEventLoop, outp """ while True: try: - async with websockets.connect(uri=constants.WSS_URL) as ws: + async with websockets.connect(uri=CONSTANTS.WSS_URL.format(self._domain)) as ws: ws: websockets.WebSocketClientProtocol = ws for trading_pair in self._trading_pairs: params: Dict[str, Any] = { diff --git a/hummingbot/connector/exchange/probit/probit_api_user_stream_data_source.py b/hummingbot/connector/exchange/probit/probit_api_user_stream_data_source.py index d88025528b..21b980ae2d 100644 --- a/hummingbot/connector/exchange/probit/probit_api_user_stream_data_source.py +++ b/hummingbot/connector/exchange/probit/probit_api_user_stream_data_source.py @@ -34,7 +34,11 @@ def logger(cls) -> HummingbotLogger: cls._logger = logging.getLogger(__name__) return cls._logger - def __init__(self, probit_auth: ProbitAuth, trading_pairs: Optional[List[str]] = []): + def __init__(self, + probit_auth: ProbitAuth, + trading_pairs: Optional[List[str]] = [], + domain: str = "com"): + self._domain: str = domain self._websocket_client: websockets.WebSocketClientProtocol = None self._probit_auth: ProbitAuth = probit_auth self._trading_pairs = trading_pairs @@ -42,6 +46,13 @@ def __init__(self, probit_auth: ProbitAuth, trading_pairs: Optional[List[str]] = self._last_recv_time: float = 0 super().__init__() + @property + def exchange_name(self) -> str: + if self._domain == "com": + return CONSTANTS.EXCHANGE_NAME + else: + return f"{CONSTANTS.EXCHANGE_NAME}_{self._domain}" + @property def last_recv_time(self) -> float: return self._last_recv_time @@ -52,7 +63,7 @@ async def _init_websocket_connection(self) -> websockets.WebSocketClientProtocol """ try: if self._websocket_client is None: - self._websocket_client = await websockets.connect(CONSTANTS.WSS_URL) + self._websocket_client = await websockets.connect(CONSTANTS.WSS_URL.format(self._domain)) return self._websocket_client except Exception: self.logger().network("Unexpected error occured with ProBit WebSocket Connection") @@ -97,7 +108,7 @@ async def _subscribe_to_channels(self, ws: websockets.WebSocketClientProtocol): except asyncio.CancelledError: raise except Exception: - self.logger().error(f"Error occured subscribing to {CONSTANTS.EXCHANGE_NAME} private channels. ", + self.logger().error(f"Error occured subscribing to {self.exchange_name} private channels. ", exc_info=True) async def _inner_messages(self, diff --git a/hummingbot/connector/exchange/probit/probit_auth.py b/hummingbot/connector/exchange/probit/probit_auth.py index 5401f14c94..b609a09c22 100644 --- a/hummingbot/connector/exchange/probit/probit_auth.py +++ b/hummingbot/connector/exchange/probit/probit_auth.py @@ -15,9 +15,11 @@ class ProbitAuth(): Auth class required by ProBit API Learn more at https://docs-en.probit.com/docs/authorization-1 """ - def __init__(self, api_key: str, secret_key: str): + def __init__(self, api_key: str, secret_key: str, domain: str = "com"): self.api_key: str = api_key self.secret_key: str = secret_key + + self._domain = domain self._oauth_token: str = None self._oauth_token_expiration_time: int = -1 @@ -52,7 +54,7 @@ async def get_auth_headers(self, http_client: aiohttp.ClientSession = aiohttp.Cl body = ujson.dumps({ "grant_type": "client_credentials" }) - resp = await http_client.post(url=CONSTANTS.TOKEN_URL, + resp = await http_client.post(url=CONSTANTS.TOKEN_URL.format(self._domain), headers=headers, data=body) token_resp = await resp.json() diff --git a/hummingbot/connector/exchange/probit/probit_constants.py b/hummingbot/connector/exchange/probit/probit_constants.py index 6583e26a5b..d933e7f91f 100644 --- a/hummingbot/connector/exchange/probit/probit_constants.py +++ b/hummingbot/connector/exchange/probit/probit_constants.py @@ -2,8 +2,8 @@ EXCHANGE_NAME = "probit" -REST_URL = "https://api.probit.com/api/exchange/" -WSS_URL = "wss://api.probit.com/api/exchange/v1/ws" +REST_URL = "https://api.probit.{}/api/exchange/" +WSS_URL = "wss://api.probit.{}/api/exchange/v1/ws" REST_API_VERSON = "v1" @@ -12,7 +12,7 @@ TICKER_URL = f"{REST_URL+REST_API_VERSON}/ticker" MARKETS_URL = f"{REST_URL+REST_API_VERSON}/market" ORDER_BOOK_URL = f"{REST_URL+REST_API_VERSON}/order_book" -TOKEN_URL = "https://accounts.probit.com/token" +TOKEN_URL = "https://accounts.probit.{}/token" # REST API Private Endpoints NEW_ORDER_URL = f"{REST_URL+REST_API_VERSON}/new_order" diff --git a/hummingbot/connector/exchange/probit/probit_exchange.py b/hummingbot/connector/exchange/probit/probit_exchange.py index f4ad5a303b..ae3dd1274e 100644 --- a/hummingbot/connector/exchange/probit/probit_exchange.py +++ b/hummingbot/connector/exchange/probit/probit_exchange.py @@ -71,7 +71,8 @@ def __init__(self, probit_api_key: str, probit_secret_key: str, trading_pairs: Optional[List[str]] = None, - trading_required: bool = True + trading_required: bool = True, + domain="com" ): """ :param probit_api_key: The API key to connect to private ProBit APIs. @@ -79,12 +80,13 @@ def __init__(self, :param trading_pairs: The market trading pairs which to track order book data. :param trading_required: Whether actual trading is needed. """ + self._domain = domain super().__init__() self._trading_required = trading_required self._trading_pairs = trading_pairs - self._probit_auth = ProbitAuth(probit_api_key, probit_secret_key) - self._order_book_tracker = ProbitOrderBookTracker(trading_pairs=trading_pairs) - self._user_stream_tracker = ProbitUserStreamTracker(self._probit_auth, trading_pairs) + self._probit_auth = ProbitAuth(probit_api_key, probit_secret_key, domain=domain) + self._order_book_tracker = ProbitOrderBookTracker(trading_pairs=trading_pairs, domain=domain) + self._user_stream_tracker = ProbitUserStreamTracker(self._probit_auth, trading_pairs, domain=domain) self._ev_loop = asyncio.get_event_loop() self._shared_client = None self._poll_notifier = asyncio.Event() @@ -101,7 +103,10 @@ def __init__(self, @property def name(self) -> str: - return CONSTANTS.EXCHANGE_NAME + if self._domain == "com": + return CONSTANTS.EXCHANGE_NAME + else: + return f"{CONSTANTS.EXCHANGE_NAME}_{self._domain}" @property def order_books(self) -> Dict[str, OrderBook]: @@ -357,6 +362,7 @@ async def _api_request(self, signature to the request. :returns A response in json format. """ + path_url = path_url.format(self._domain) client = await self._http_client() if is_auth_required: diff --git a/hummingbot/connector/exchange/probit/probit_order_book_tracker.py b/hummingbot/connector/exchange/probit/probit_order_book_tracker.py index 8c0c1d0fad..e7ac692ba1 100644 --- a/hummingbot/connector/exchange/probit/probit_order_book_tracker.py +++ b/hummingbot/connector/exchange/probit/probit_order_book_tracker.py @@ -2,7 +2,7 @@ import asyncio import bisect import logging -import hummingbot.connector.exchange.probit.probit_constants as constants +import hummingbot.connector.exchange.probit.probit_constants as CONSTANTS import time from collections import defaultdict, deque @@ -25,9 +25,10 @@ def logger(cls) -> HummingbotLogger: cls._logger = logging.getLogger(__name__) return cls._logger - def __init__(self, trading_pairs: Optional[List[str]] = None,): - super().__init__(ProbitAPIOrderBookDataSource(trading_pairs), trading_pairs) + def __init__(self, trading_pairs: Optional[List[str]] = None, domain: str = "com"): + super().__init__(ProbitAPIOrderBookDataSource(trading_pairs, domain), trading_pairs) + self._domain = domain self._ev_loop: asyncio.BaseEventLoop = asyncio.get_event_loop() self._order_book_snapshot_stream: asyncio.Queue = asyncio.Queue() self._order_book_diff_stream: asyncio.Queue = asyncio.Queue() @@ -45,7 +46,10 @@ def exchange_name(self) -> str: """ Name of the current exchange """ - return constants.EXCHANGE_NAME + if self._domain == "com": + return CONSTANTS.EXCHANGE_NAME + else: + return f"{CONSTANTS.EXCHANGE_NAME}_{self._domain}" async def _track_single_book(self, trading_pair: str): """ diff --git a/hummingbot/connector/exchange/probit/probit_user_stream_tracker.py b/hummingbot/connector/exchange/probit/probit_user_stream_tracker.py index d1bcb1cbb8..b057cda3ef 100644 --- a/hummingbot/connector/exchange/probit/probit_user_stream_tracker.py +++ b/hummingbot/connector/exchange/probit/probit_user_stream_tracker.py @@ -3,13 +3,14 @@ import asyncio import logging +import hummingbot.connector.exchange.probit.probit_constants as CONSTANTS + from typing import ( Optional, List, ) from hummingbot.connector.exchange.probit.probit_auth import ProbitAuth -from hummingbot.connector.exchange.probit.probit_constants import EXCHANGE_NAME from hummingbot.connector.exchange.probit.probit_api_user_stream_data_source import \ ProbitAPIUserStreamDataSource from hummingbot.core.data_type.user_stream_tracker_data_source import UserStreamTrackerDataSource @@ -32,8 +33,10 @@ def logger(cls) -> HummingbotLogger: def __init__(self, probit_auth: Optional[ProbitAuth] = None, - trading_pairs: Optional[List[str]] = []): + trading_pairs: Optional[List[str]] = [], + domain: str = "com"): super().__init__() + self._domain: str = domain self._probit_auth: ProbitAuth = probit_auth self._trading_pairs: List[str] = trading_pairs self._ev_loop: asyncio.events.AbstractEventLoop = asyncio.get_event_loop() @@ -50,7 +53,8 @@ def data_source(self) -> UserStreamTrackerDataSource: if not self._data_source: self._data_source = ProbitAPIUserStreamDataSource( probit_auth=self._probit_auth, - trading_pairs=self._trading_pairs + trading_pairs=self._trading_pairs, + domain=self._domain ) return self._data_source @@ -60,7 +64,10 @@ def exchange_name(self) -> str: *required Name of the current exchange """ - return EXCHANGE_NAME + if self._domain == "com": + return CONSTANTS.EXCHANGE_NAME + else: + return f"{CONSTANTS.EXCHANGE_NAME}_{self._domain}" async def start(self): """ diff --git a/hummingbot/connector/exchange/probit/probit_utils.py b/hummingbot/connector/exchange/probit/probit_utils.py index 5920ef47d7..2631e9ec41 100644 --- a/hummingbot/connector/exchange/probit/probit_utils.py +++ b/hummingbot/connector/exchange/probit/probit_utils.py @@ -75,7 +75,7 @@ def convert_diff_message_to_order_book_row(message: OrderBookMessage) -> Tuple[L KEYS = { "probit_api_key": ConfigVar(key="probit_api_key", - prompt="Enter your ProBit API key >>> ", + prompt="Enter your ProBit Client ID >>> ", required_if=using_exchange("probit"), is_secure=True, is_connect_key=True), @@ -86,3 +86,24 @@ def convert_diff_message_to_order_book_row(message: OrderBookMessage) -> Tuple[L is_secure=True, is_connect_key=True), } + +OTHER_DOMAINS = ["probit_kr"] +OTHER_DOMAINS_PARAMETER = {"probit_kr": "kr"} +OTHER_DOMAINS_EXAMPLE_PAIR = {"probit_kr": "BTC-USDT"} +OTHER_DOMAINS_DEFAULT_FEES = {"probit_kr": [0.2, 0.2]} +OTHER_DOMAINS_KEYS = { + "probit_kr": { + "probit_kr_api_key": + ConfigVar(key="probit_kr_api_key", + prompt="Enter your ProBit KR Client ID >>> ", + required_if=using_exchange("probit_kr"), + is_secure=True, + is_connect_key=True), + "probit_kr_secret_key": + ConfigVar(key="probit_kr_secret_key", + prompt="Enter your ProBit KR secret key >>> ", + required_if=using_exchange("probit_kr"), + is_secure=True, + is_connect_key=True), + } +} diff --git a/hummingbot/templates/conf_fee_overrides_TEMPLATE.yml b/hummingbot/templates/conf_fee_overrides_TEMPLATE.yml index 506eb80b09..dd3f6ca402 100644 --- a/hummingbot/templates/conf_fee_overrides_TEMPLATE.yml +++ b/hummingbot/templates/conf_fee_overrides_TEMPLATE.yml @@ -70,3 +70,9 @@ balancer_taker_fee_amount: bitmax_maker_fee: bitmax_taker_fee: + +probit_maker_fee: +probit_taker_fee: + +probit_kr_maker_fee: +probit_kr_taker_fee: diff --git a/hummingbot/templates/conf_global_TEMPLATE.yml b/hummingbot/templates/conf_global_TEMPLATE.yml index 0f41f4d3b0..e7afe14b40 100644 --- a/hummingbot/templates/conf_global_TEMPLATE.yml +++ b/hummingbot/templates/conf_global_TEMPLATE.yml @@ -80,6 +80,9 @@ balancer_max_swaps: 4 probit_api_key: null probit_secret_key: null +probit_kr_api_key: null +probit_kr_secret_key: null + # Ethereum wallet address: required for trading on a DEX ethereum_wallet: null ethereum_rpc_url: null From fe9affb50a17c8ffa71bf1daa9976bd28cf77010 Mon Sep 17 00:00:00 2001 From: Nicolas Baum Date: Fri, 26 Feb 2021 13:47:18 -0300 Subject: [PATCH 084/131] Filtered out dark pools trading pairs from list of assetpairs. Also added a new case when looking for exchange symbol in local list of pairs --- .../connector/exchange/kraken/kraken_exchange.pyx | 6 ++++-- .../connector/exchange/kraken/kraken_utils.py | 15 ++++++++++++++- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/hummingbot/connector/exchange/kraken/kraken_exchange.pyx b/hummingbot/connector/exchange/kraken/kraken_exchange.pyx index 4020c30a7d..4176e5f5e1 100755 --- a/hummingbot/connector/exchange/kraken/kraken_exchange.pyx +++ b/hummingbot/connector/exchange/kraken/kraken_exchange.pyx @@ -29,7 +29,8 @@ from hummingbot.connector.exchange.kraken.kraken_utils import ( convert_from_exchange_symbol, convert_from_exchange_trading_pair, convert_to_exchange_trading_pair, - split_to_base_quote) + split_to_base_quote, + is_dark_pool) from hummingbot.logger import HummingbotLogger from hummingbot.core.event.events import ( MarketEvent, @@ -197,7 +198,8 @@ cdef class KrakenExchange(ExchangeBase): asset_pairs_response = await client.get(ASSET_PAIRS_URI) asset_pairs_data: Dict[str, Any] = await asset_pairs_response.json() asset_pairs: Dict[str, Any] = asset_pairs_data["result"] - self._asset_pairs = {f"{details['base']}-{details['quote']}": details for _, details in asset_pairs.items()} + self._asset_pairs = {f"{details['base']}-{details['quote']}": details + for _, details in asset_pairs.items() if not is_dark_pool(details)} return self._asset_pairs async def get_active_exchange_markets(self) -> pd.DataFrame: diff --git a/hummingbot/connector/exchange/kraken/kraken_utils.py b/hummingbot/connector/exchange/kraken/kraken_utils.py index 28bb1eae8b..0b7cbd34fc 100644 --- a/hummingbot/connector/exchange/kraken/kraken_utils.py +++ b/hummingbot/connector/exchange/kraken/kraken_utils.py @@ -1,7 +1,9 @@ import hummingbot.connector.exchange.kraken.kraken_constants as constants from typing import ( Optional, - Tuple) + Tuple, + Dict, + Any) from hummingbot.client.config.config_var import ConfigVar from hummingbot.client.config.config_methods import using_exchange @@ -50,6 +52,11 @@ def convert_from_exchange_trading_pair(exchange_trading_pair: str, available_tra # Option 2: Using kraken naming convention ( XXBT for Bitcoin, XXDG for Doge, ZUSD for USD, etc) connector_trading_pair = {''.join(tp.split('-')): tp for tp in available_trading_pairs}.get( exchange_trading_pair) + if not connector_trading_pair: + # Option 3: Kraken naming convention but without the initial X and Z + connector_trading_pair = {''.join(convert_to_exchange_symbol(convert_from_exchange_symbol(s)) + for s in tp.split('-')): tp + for tp in available_trading_pairs}.get(exchange_trading_pair) return connector_trading_pair if not base or not quote: @@ -77,6 +84,12 @@ def convert_to_exchange_trading_pair(hb_trading_pair: str, delimiter: str = "") return exchange_trading_pair +def is_dark_pool(trading_pair_details: Dict[str, Any]): + if trading_pair_details.get('altname'): + return trading_pair_details.get('altname').endswith('.d') + return False + + KEYS = { "kraken_api_key": ConfigVar(key="kraken_api_key", From c18251811020f26f25e5a923f04664b2b73e329b Mon Sep 17 00:00:00 2001 From: PtrckM <54516858+PtrckM@users.noreply.github.com> Date: Sat, 27 Feb 2021 17:04:35 +0800 Subject: [PATCH 085/131] (refactor) add VAI token --- hummingbot/connector/exchange/binance/binance_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hummingbot/connector/exchange/binance/binance_utils.py b/hummingbot/connector/exchange/binance/binance_utils.py index 03d9e5c4a3..4c4bf3dbb0 100644 --- a/hummingbot/connector/exchange/binance/binance_utils.py +++ b/hummingbot/connector/exchange/binance/binance_utils.py @@ -12,7 +12,7 @@ DEFAULT_FEES = [0.1, 0.1] RE_4_LETTERS_QUOTE = re.compile(r"^(\w+)(USDT|USDC|USDS|TUSD|BUSD|IDRT|BKRW|BIDR)$") -RE_3_LETTERS_QUOTE = re.compile(r"^(\w+)(BTC|ETH|BNB|DAI|XRP|PAX|TRX|NGN|RUB|TRY|EUR|ZAR|UAH|GBP|USD|BRL|AUD)$") +RE_3_LETTERS_QUOTE = re.compile(r"^(\w+)(BTC|ETH|BNB|DAI|XRP|PAX|TRX|NGN|RUB|TRY|EUR|ZAR|UAH|GBP|USD|BRL|AUD|VAI)$") USD_QUOTES = ["DAI", "USDT", "USDC", "USDS", "TUSD", "PAX", "BUSD", "USD"] From de53344189639f70ca2ec1f5e646d70cf7ff7631 Mon Sep 17 00:00:00 2001 From: TheHolyRoger Date: Fri, 29 Jan 2021 19:20:22 +0000 Subject: [PATCH 086/131] Fix for Arbitrage "Empty DataFrame" format_status output --- hummingbot/strategy/arbitrage/arbitrage.pyx | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/hummingbot/strategy/arbitrage/arbitrage.pyx b/hummingbot/strategy/arbitrage/arbitrage.pyx index fbe75dcf77..fc07f7317b 100755 --- a/hummingbot/strategy/arbitrage/arbitrage.pyx +++ b/hummingbot/strategy/arbitrage/arbitrage.pyx @@ -129,12 +129,18 @@ cdef class ArbitrageStrategy(StrategyBase): f"take bid on {market_pair.second.market.name}: {round(self._current_profitability[1] * 100, 4)} %"]) # See if there're any pending limit orders. - tracked_limit_orders_df = self.tracked_limit_orders_data_frame - tracked_market_orders_df = self.tracked_market_orders_data_frame - - if len(tracked_limit_orders_df) > 0 or len(tracked_market_orders_df) > 0: - df_limit_lines = str(tracked_limit_orders_df).split("\n") - df_market_lines = str(tracked_market_orders_df).split("\n") + tracked_limit_orders = self.tracked_limit_orders + tracked_market_orders = self.tracked_market_orders + + if len(tracked_limit_orders) > 0 or len(tracked_market_orders) > 0: + tracked_limit_orders_df = self.tracked_limit_orders_data_frame + tracked_market_orders_df = self.tracked_market_orders_data_frame + df_limit_lines = (str(tracked_limit_orders_df).split("\n") + if len(tracked_limit_orders) > 0 + else list()) + df_market_lines = (str(tracked_market_orders_df).split("\n") + if len(tracked_market_orders) > 0 + else list()) lines.extend(["", " Pending limit orders:"] + [" " + line for line in df_limit_lines] + [" " + line for line in df_market_lines]) From 5e993fd04119f5c609d6ab2451e9ec7e0770167a Mon Sep 17 00:00:00 2001 From: TheHolyRoger Date: Fri, 5 Feb 2021 06:50:23 +0000 Subject: [PATCH 087/131] Fix / Intermittent 'bid_price' referenced before assignment errors --- hummingbot/strategy/arbitrage/arbitrage.pyx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/hummingbot/strategy/arbitrage/arbitrage.pyx b/hummingbot/strategy/arbitrage/arbitrage.pyx index fc07f7317b..d08a6afc83 100755 --- a/hummingbot/strategy/arbitrage/arbitrage.pyx +++ b/hummingbot/strategy/arbitrage/arbitrage.pyx @@ -409,6 +409,8 @@ cdef class ArbitrageStrategy(StrategyBase): object total_bid_value_adjusted = s_decimal_0 # total revenue adjusted with exchange rate conversion object total_ask_value_adjusted = s_decimal_0 # total cost adjusted with exchange rate conversion object total_previous_step_base_amount = s_decimal_0 + object bid_price = s_decimal_0 # bid price + object ask_price = s_decimal_0 # ask price object profitability object best_profitable_order_amount = s_decimal_0 object best_profitable_order_profitability = s_decimal_0 From 0f127847c60e86a0f39269f36408ab96a2015d38 Mon Sep 17 00:00:00 2001 From: TheHolyRoger Date: Thu, 25 Feb 2021 15:36:42 +0000 Subject: [PATCH 088/131] Fix to stop another paper trade cancel loop when balance too low --- .../exchange/paper_trade/paper_trade_exchange.pyx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/hummingbot/connector/exchange/paper_trade/paper_trade_exchange.pyx b/hummingbot/connector/exchange/paper_trade/paper_trade_exchange.pyx index d38680f396..2b9c586990 100644 --- a/hummingbot/connector/exchange/paper_trade/paper_trade_exchange.pyx +++ b/hummingbot/connector/exchange/paper_trade/paper_trade_exchange.pyx @@ -596,6 +596,10 @@ cdef class PaperTradeExchange(ExchangeBase): f"{quote_asset_balance:.8g} {quote_asset} available.") self.c_delete_limit_order(limit_orders_map_ptr, map_it_ptr, orders_it) + self.c_trigger_event(self.MARKET_ORDER_CANCELLED_EVENT_TAG, + OrderCancelledEvent(self._current_timestamp, + order_id) + ) return # Adjust the market balances according to the trade done. @@ -656,6 +660,10 @@ cdef class PaperTradeExchange(ExchangeBase): f"{base_asset_traded:.8g} {base_asset} needed vs. " f"{base_asset_balance:.8g} {base_asset} available.") self.c_delete_limit_order(limit_orders_map_ptr, map_it_ptr, orders_it) + self.c_trigger_event(self.MARKET_ORDER_CANCELLED_EVENT_TAG, + OrderCancelledEvent(self._current_timestamp, + order_id) + ) return # Adjust the market balances according to the trade done. From c7205ed8cb4c6d3f92089087ef3d5fd45be14782 Mon Sep 17 00:00:00 2001 From: TheHolyRoger Date: Fri, 26 Feb 2021 19:02:50 +0000 Subject: [PATCH 089/131] Add delay to paper trade order created event firings, fixes XMM Fixes: XMM Paper Trade cancel orders loop --- .../paper_trade/paper_trade_exchange.pyx | 42 +++++++++++-------- 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/hummingbot/connector/exchange/paper_trade/paper_trade_exchange.pyx b/hummingbot/connector/exchange/paper_trade/paper_trade_exchange.pyx index d38680f396..4cc051dcd3 100644 --- a/hummingbot/connector/exchange/paper_trade/paper_trade_exchange.pyx +++ b/hummingbot/connector/exchange/paper_trade/paper_trade_exchange.pyx @@ -1,5 +1,6 @@ # distutils: sources=['hummingbot/core/cpp/Utils.cpp', 'hummingbot/core/cpp/LimitOrder.cpp', 'hummingbot/core/cpp/OrderExpirationEntry.cpp'] +import asyncio from collections import ( deque, defaultdict ) @@ -23,6 +24,9 @@ from hummingbot.core.Utils cimport( getIteratorFromReverseIterator, reverse_iterator ) +from hummingbot.core.utils.async_utils import ( + safe_ensure_future, +) from hummingbot.core.clock cimport Clock from hummingbot.core.clock import ( Clock @@ -364,15 +368,13 @@ cdef class PaperTradeExchange(ExchangeBase): quantized_price, quantized_amount )) - self.c_trigger_event(self.MARKET_BUY_ORDER_CREATED_EVENT_TAG, - BuyOrderCreatedEvent( - self._current_timestamp, - order_type, - trading_pair_str, - quantized_amount, - quantized_price, - order_id - )) + safe_ensure_future(self.place_order(self.MARKET_BUY_ORDER_CREATED_EVENT_TAG, + BuyOrderCreatedEvent(self._current_timestamp, + order_type, + trading_pair_str, + quantized_amount, + quantized_price, + order_id))) return order_id cdef str c_sell(self, @@ -419,17 +421,21 @@ cdef class PaperTradeExchange(ExchangeBase): quantized_price, quantized_amount )) - self.c_trigger_event(self.MARKET_SELL_ORDER_CREATED_EVENT_TAG, - SellOrderCreatedEvent( - self._current_timestamp, - order_type, - trading_pair_str, - quantized_amount, - quantized_price, - order_id - )) + safe_ensure_future(self.place_order(self.MARKET_SELL_ORDER_CREATED_EVENT_TAG, + SellOrderCreatedEvent(self._current_timestamp, + order_type, + trading_pair_str, + quantized_amount, + quantized_price, + order_id))) return order_id + async def place_order(self, + event_tag, + order_created_event): + await asyncio.sleep(0.01) + self.c_trigger_event(event_tag, order_created_event) + cdef c_execute_buy(self, str order_id, str trading_pair, object amount): cdef: str quote_asset = self._trading_pairs[trading_pair].quote_asset From a500c1b6282f6343bc610e2ae91bb6ad7650f971 Mon Sep 17 00:00:00 2001 From: TheHolyRoger Date: Sat, 27 Feb 2021 15:33:22 +0000 Subject: [PATCH 090/131] Refactor for generic `trigger_event_async` method --- .../paper_trade/paper_trade_exchange.pyx | 42 ++++++++++--------- 1 file changed, 22 insertions(+), 20 deletions(-) diff --git a/hummingbot/connector/exchange/paper_trade/paper_trade_exchange.pyx b/hummingbot/connector/exchange/paper_trade/paper_trade_exchange.pyx index 4cc051dcd3..8ef620c82d 100644 --- a/hummingbot/connector/exchange/paper_trade/paper_trade_exchange.pyx +++ b/hummingbot/connector/exchange/paper_trade/paper_trade_exchange.pyx @@ -368,13 +368,14 @@ cdef class PaperTradeExchange(ExchangeBase): quantized_price, quantized_amount )) - safe_ensure_future(self.place_order(self.MARKET_BUY_ORDER_CREATED_EVENT_TAG, - BuyOrderCreatedEvent(self._current_timestamp, - order_type, - trading_pair_str, - quantized_amount, - quantized_price, - order_id))) + safe_ensure_future(self.trigger_event_async( + self.MARKET_BUY_ORDER_CREATED_EVENT_TAG, + BuyOrderCreatedEvent(self._current_timestamp, + order_type, + trading_pair_str, + quantized_amount, + quantized_price, + order_id))) return order_id cdef str c_sell(self, @@ -421,21 +422,16 @@ cdef class PaperTradeExchange(ExchangeBase): quantized_price, quantized_amount )) - safe_ensure_future(self.place_order(self.MARKET_SELL_ORDER_CREATED_EVENT_TAG, - SellOrderCreatedEvent(self._current_timestamp, - order_type, - trading_pair_str, - quantized_amount, - quantized_price, - order_id))) + safe_ensure_future(self.trigger_event_async( + self.MARKET_SELL_ORDER_CREATED_EVENT_TAG, + SellOrderCreatedEvent(self._current_timestamp, + order_type, + trading_pair_str, + quantized_amount, + quantized_price, + order_id))) return order_id - async def place_order(self, - event_tag, - order_created_event): - await asyncio.sleep(0.01) - self.c_trigger_event(event_tag, order_created_event) - cdef c_execute_buy(self, str order_id, str trading_pair, object amount): cdef: str quote_asset = self._trading_pairs[trading_pair].quote_asset @@ -1001,3 +997,9 @@ cdef class PaperTradeExchange(ExchangeBase): def get_taker_order_type(self): return OrderType.LIMIT + + async def trigger_event_async(self, + event_tag, + event): + await asyncio.sleep(0.01) + self.c_trigger_event(event_tag, event) From 5dfd53e481bfa928b5c3372a2de6648805408341 Mon Sep 17 00:00:00 2001 From: vic-en Date: Mon, 1 Mar 2021 01:26:32 +0100 Subject: [PATCH 091/131] (fix) made request changes and fixed some bugs related to funding payment completion --- .../binance_perpetual_derivative.py | 4 +-- .../perpetual_finance_derivative.py | 34 +++++++++++-------- .../perpetual_finance_in_flight_order.py | 17 ---------- .../perpetual_finance_utils.py | 4 +-- hummingbot/core/event/events.py | 1 + .../spot_perpetual_arbitrage/arb_proposal.py | 1 - .../spot_perpetual_arbitrage.py | 15 ++++---- 7 files changed, 33 insertions(+), 43 deletions(-) diff --git a/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_derivative.py b/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_derivative.py index 8a612e7890..94a4bbb2e5 100644 --- a/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_derivative.py +++ b/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_derivative.py @@ -947,8 +947,8 @@ async def get_funding_payment(self): self.trigger_event(self.MARKET_FUNDING_PAYMENT_COMPLETED_EVENT_TAG, FundingPaymentCompletedEvent(timestamp=funding_payment["time"], market=self.name, - rate=self._funding_info[trading_pair]["rate"], - symbol=trading_pair, + funding_rate=self._funding_info[trading_pair]["rate"], + trading_pair=trading_pair, amount=payment)) def get_funding_info(self, trading_pair): diff --git a/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py index 75980610c1..17388e23e9 100644 --- a/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py +++ b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py @@ -8,6 +8,7 @@ import ssl import copy from hummingbot.logger.struct_logger import METRICS_LOG_LEVEL +from hummingbot.core.event.events import TradeFee from hummingbot.core.utils import async_ttl_cache from hummingbot.core.network_iterator import NetworkStatus from hummingbot.core.utils.async_utils import safe_ensure_future, safe_gather @@ -180,7 +181,7 @@ async def get_order_price(self, trading_pair: str, is_buy: bool, amount: Decimal """ return await self.get_quote_price(trading_pair, is_buy, amount) - def buy(self, trading_pair: str, amount: Decimal, order_type: OrderType, price: Decimal, position_action: PositionAction) -> str: + def buy(self, trading_pair: str, amount: Decimal, order_type: OrderType, price: Decimal, **kwargs) -> str: """ Buys an amount of base token for a given price (or cheaper). :param trading_pair: The market trading pair @@ -190,9 +191,9 @@ def buy(self, trading_pair: str, amount: Decimal, order_type: OrderType, price: :param position_action: Either OPEN or CLOSE position action. :return: A newly created order id (internal). """ - return self.place_order(True, trading_pair, amount, price, position_action) + return self.place_order(True, trading_pair, amount, price, kwargs["position_action"]) - def sell(self, trading_pair: str, amount: Decimal, order_type: OrderType, price: Decimal, position_action: PositionAction) -> str: + def sell(self, trading_pair: str, amount: Decimal, order_type: OrderType, price: Decimal, **kwargs) -> str: """ Sells an amount of base token for a given price (or at a higher price). :param trading_pair: The market trading pair @@ -202,7 +203,7 @@ def sell(self, trading_pair: str, amount: Decimal, order_type: OrderType, price: :param position_action: Either OPEN or CLOSE position action. :return: A newly created order id (internal). """ - return self.place_order(False, trading_pair, amount, price, position_action) + return self.place_order(False, trading_pair, amount, price, kwargs["position_action"]) def place_order(self, is_buy: bool, trading_pair: str, amount: Decimal, price: Decimal, position_action: PositionAction) -> str: """ @@ -242,9 +243,9 @@ async def _create_order(self, api_params = {"pair": convert_to_exchange_trading_pair(trading_pair)} if position_action == PositionAction.OPEN: api_params.update({"side": 0 if trade_type == TradeType.BUY else 1, - "margin": str(amount / self._leverage[trading_pair]), + "margin": self.quantize_order_amount(trading_pair, (amount / self._leverage[trading_pair])), "leverage": self._leverage[trading_pair], - "minBaseAssetAmount": amount}) + "minBaseAssetAmount": Decimal("0")}) else: api_params.update({"minimalQuoteAsset": price * amount}) self.start_tracking_order(order_id, None, trading_pair, trade_type, price, amount, self._leverage[trading_pair], position_action.name) @@ -337,7 +338,7 @@ async def _update_order_status(self): if update_result["confirmed"] is True: if update_result["receipt"]["status"] == 1: fee = estimate_fee("perpetual_finance", False) - fee.flat_fees = [(tracked_order.fee_asset, Decimal(str(update_result["receipt"]["gasUsed"])))] + fee = TradeFee(fee.percent, [("XDAI", Decimal(str(update_result["receipt"]["gasUsed"])))]) self.trigger_event( MarketEvent.OrderFilled, OrderFilledEvent( @@ -370,7 +371,9 @@ async def _update_order_status(self): tracked_order.fee_asset, tracked_order.executed_amount_base, tracked_order.executed_amount_quote, - float(fee), + float(fee.fee_amount_in_quote(tracked_order.trading_pair, + Decimal(str(tracked_order.price)), + Decimal(str(tracked_order.amount)))), # this ignores the gas fee, which is fine for now tracked_order.order_type)) self.stop_tracking_order(tracked_order.client_order_id) else: @@ -388,10 +391,10 @@ def get_taker_order_type(self): return OrderType.LIMIT def get_order_price_quantum(self, trading_pair: str, price: Decimal) -> Decimal: - return Decimal("1e-15") + return Decimal("1e-6") def get_order_size_quantum(self, trading_pair: str, order_size: Decimal) -> Decimal: - return Decimal("1e-15") + return Decimal("1e-6") @property def ready(self): @@ -454,6 +457,7 @@ async def _status_polling_loop(self): self._poll_notifier = asyncio.Event() await self._poll_notifier.wait() await safe_gather( + self._update_positions(), self._update_balances(), self._update_order_status(), ) @@ -502,7 +506,7 @@ async def _update_positions(self): positions = await safe_gather(*position_tasks, return_exceptions=True) for trading_pair, position in zip(self._trading_pairs, positions): position = position.get("position", {}) - position_side = PositionSide.LONG if position.get("size", 0) > 0 else PositionSide.SHORT + position_side = PositionSide.LONG if Decimal(position.get("size", "0")) > 0 else PositionSide.SHORT unrealized_pnl = Decimal(position.get("pnl")) entry_price = Decimal(position.get("entryPrice")) amount = Decimal(position.get("size")) @@ -520,18 +524,18 @@ async def _update_positions(self): if (trading_pair + position_side.name) in self._account_positions: del self._account_positions[trading_pair + position_side.name] - payment = Decimal(str(position.fundingPayment)) + payment = Decimal(str(position.get("fundingPayment"))) oldPayment = self._fundingPayment.get(trading_pair, 0) if payment != oldPayment: - self._fundingPayment = oldPayment + self._fundingPayment[trading_pair] = oldPayment action = "paid" if payment < 0 else "received" if payment != Decimal("0"): self.logger().info(f"Funding payment of {payment} {action} on {trading_pair} market.") self.trigger_event(MarketEvent.FundingPaymentCompleted, FundingPaymentCompletedEvent(timestamp=time.time(), market=self.name, - rate=self._funding_info[trading_pair]["rate"], - symbol=trading_pair, + funding_rate=self._funding_info[trading_pair]["rate"], + trading_pair=trading_pair, amount=payment)) async def _funding_info_polling_loop(self): diff --git a/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_in_flight_order.py b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_in_flight_order.py index 3dbaad3128..ff2df9b255 100644 --- a/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_in_flight_order.py +++ b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_in_flight_order.py @@ -31,7 +31,6 @@ def __init__(self, amount, initial_state, ) - self.trade_id_set = set() self.leverage = leverage self.position = position @@ -46,19 +45,3 @@ def is_failure(self) -> bool: @property def is_cancelled(self) -> bool: return self.last_state in {"CANCELED", "EXPIRED"} - - @property - def leverage(self) -> Decimal: - return self.leverage - - @leverage.setter - def leverage(self, leverage) -> Decimal: - self.leverage = leverage - - @property - def position(self) -> Decimal: - return self.position - - @position.setter - def position(self, position) -> Decimal: - self.position = position diff --git a/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_utils.py b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_utils.py index 665a0a34e1..d7f1b5f60a 100644 --- a/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_utils.py +++ b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_utils.py @@ -6,8 +6,8 @@ DEFAULT_FEES = [0.1, 0.1] USE_ETHEREUM_WALLET = True -FEE_TYPE = "FlatFee" -FEE_TOKEN = "XDAI" +# FEE_TYPE = "FlatFee" +# FEE_TOKEN = "XDAI" USE_ETH_GAS_LOOKUP = False diff --git a/hummingbot/core/event/events.py b/hummingbot/core/event/events.py index c4a02d1e76..055f7d2cac 100644 --- a/hummingbot/core/event/events.py +++ b/hummingbot/core/event/events.py @@ -207,6 +207,7 @@ class OrderExpiredEvent(NamedTuple): @dataclass class FundingPaymentCompletedEvent: timestamp: float + market: str trading_pair: str amount: Decimal funding_rate: Decimal diff --git a/hummingbot/strategy/spot_perpetual_arbitrage/arb_proposal.py b/hummingbot/strategy/spot_perpetual_arbitrage/arb_proposal.py index d6c78cffff..c24a85eec0 100644 --- a/hummingbot/strategy/spot_perpetual_arbitrage/arb_proposal.py +++ b/hummingbot/strategy/spot_perpetual_arbitrage/arb_proposal.py @@ -19,7 +19,6 @@ def __init__(self, """ :param market_info: The market where to submit the order :param is_buy: True if buy order - :param quote_price: The quote price (for an order amount) from the market :param order_price: The price required for order submission, this could differ from the quote price :param amount: The order amount """ diff --git a/hummingbot/strategy/spot_perpetual_arbitrage/spot_perpetual_arbitrage.py b/hummingbot/strategy/spot_perpetual_arbitrage/spot_perpetual_arbitrage.py index f3413e2505..824280d0f6 100644 --- a/hummingbot/strategy/spot_perpetual_arbitrage/spot_perpetual_arbitrage.py +++ b/hummingbot/strategy/spot_perpetual_arbitrage/spot_perpetual_arbitrage.py @@ -169,6 +169,8 @@ async def main(self, timestamp): funding_msg = "Time for funding payment, executing second arbitrage " \ "immediately since we don't intend to maximize funding rate" execute_arb = True + else: + funding_msg = "Funding payment time, not looking for arbitrage opportunity because prices should be converging now!" else: if len(self.deriv_position) > 0: execute_arb = self.ready_for_execution(self.current_proposal, False) @@ -181,8 +183,10 @@ async def main(self, timestamp): self.apply_budget_constraint(self.current_proposal) await self.execute_arb_proposals(self.current_proposal, funding_msg) else: - self.timed_logger(timestamp, self.spread_msg()) - return + if funding_msg: + self.timed_logger(timestamp, funding_msg) + else: + self.timed_logger(timestamp, self.spread_msg()) def timed_logger(self, timestamp, msg): """ @@ -238,7 +242,7 @@ def apply_slippage_buffers(self, arb_proposal: ArbProposal): Updates arb_proposals by adjusting order price for slipper buffer percentage. E.g. if it is a buy order, for an order price of 100 and 1% slipper buffer, the new order price is 101, for a sell order, the new order price is 99. - :param arb_proposals: the arbitrage proposal + :param arb_proposal: the arbitrage proposal """ for arb_side in (arb_proposal.spot_side, arb_proposal.derivative_side): market = arb_side.market_info.market @@ -255,7 +259,7 @@ def apply_budget_constraint(self, arb_proposal: ArbProposal): """ Updates arb_proposals by setting proposal amount to 0 if there is not enough balance to submit order with required order amount. - :param arb_proposals: the arbitrage proposal + :param arb_proposal: the arbitrage proposal """ spot_market = self._spot_market_info.market deriv_market = self._derivative_market_info.market @@ -275,7 +279,6 @@ def apply_budget_constraint(self, arb_proposal: ArbProposal): self.logger().info(f"Can't arbitrage, {deriv_market.display_name} " f"{deriv_token} balance " f"({deriv_token_balance}) is below required order amount ({required_deriv_balance}).") - return async def execute_arb_proposals(self, arb_proposal: ArbProposal, is_funding_msg: str = ""): """ @@ -444,7 +447,7 @@ def did_expire_order(self, expired_event): def did_complete_funding_payment(self, funding_payment_completed_event): # Excute second arbitrage if necessary (even spread hasn't reached min convergence) - if len(self.deriv_position) > 0: + if len(self.deriv_position) > 0 and self.ready_for_new_arb_trades(): self.apply_slippage_buffers(self.current_proposal) self.apply_budget_constraint(self.current_proposal) funding_msg = "Executing second arbitrage after funding payment is received" From 1a7b80f769e6186d34144fba0862f904bbe2495b Mon Sep 17 00:00:00 2001 From: Daniel Tan Date: Mon, 1 Mar 2021 13:55:49 +0800 Subject: [PATCH 092/131] (fix) fix issue where invalid orders are being tracked by bot --- .../exchange/bitmax/bitmax_exchange.py | 56 +++++++++---------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/hummingbot/connector/exchange/bitmax/bitmax_exchange.py b/hummingbot/connector/exchange/bitmax/bitmax_exchange.py index 43fbe7a05a..4606157dd3 100644 --- a/hummingbot/connector/exchange/bitmax/bitmax_exchange.py +++ b/hummingbot/connector/exchange/bitmax/bitmax_exchange.py @@ -485,36 +485,36 @@ async def _create_order(self, amount = self.quantize_order_amount(trading_pair, amount) price = self.quantize_order_price(trading_pair, price) - # bitmax has a unique way of determening if the order has enough "worth" to be posted - # see https://bitmax-exchange.github.io/bitmax-pro-api/#place-order - notional = Decimal(price * amount) - if notional < bitmax_trading_rule.minNotional or notional > bitmax_trading_rule.maxNotional: - raise ValueError(f"Notional amount {notional} is not withing the range of {bitmax_trading_rule.minNotional}-{bitmax_trading_rule.maxNotional}.") - - # TODO: check balance - [exchange_order_id, timestamp] = bitmax_utils.gen_exchange_order_id(self._account_uid) - - api_params = { - "id": exchange_order_id, - "time": timestamp, - "symbol": bitmax_utils.convert_to_exchange_trading_pair(trading_pair), - "orderPrice": f"{price:f}", - "orderQty": f"{amount:f}", - "orderType": "limit", - "side": trade_type.name - } + try: + # bitmax has a unique way of determening if the order has enough "worth" to be posted + # see https://bitmax-exchange.github.io/bitmax-pro-api/#place-order + notional = Decimal(price * amount) + if notional < bitmax_trading_rule.minNotional or notional > bitmax_trading_rule.maxNotional: + raise ValueError(f"Notional amount {notional} is not withing the range of {bitmax_trading_rule.minNotional}-{bitmax_trading_rule.maxNotional}.") + + # TODO: check balance + [exchange_order_id, timestamp] = bitmax_utils.gen_exchange_order_id(self._account_uid) + + api_params = { + "id": exchange_order_id, + "time": timestamp, + "symbol": bitmax_utils.convert_to_exchange_trading_pair(trading_pair), + "orderPrice": f"{price:f}", + "orderQty": f"{amount:f}", + "orderType": "limit", + "side": trade_type.name + } - self.start_tracking_order( - order_id, - exchange_order_id, - trading_pair, - trade_type, - price, - amount, - order_type - ) + self.start_tracking_order( + order_id, + exchange_order_id, + trading_pair, + trade_type, + price, + amount, + order_type + ) - try: await self._api_request("post", "cash/order", api_params, True, force_auth_path_url="order") tracked_order = self._in_flight_orders.get(order_id) # tracked_order.update_exchange_order_id(exchange_order_id) From a2f4ee5b2b07ed97aeae4ab216e8d6118e41cdd3 Mon Sep 17 00:00:00 2001 From: vic-en Date: Mon, 1 Mar 2021 10:47:23 +0100 Subject: [PATCH 093/131] (fix) fix perp protocol update_position_function --- .../perpetual_finance_derivative.py | 61 ++++++++++--------- 1 file changed, 31 insertions(+), 30 deletions(-) diff --git a/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py index 17388e23e9..b752a5bf35 100644 --- a/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py +++ b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py @@ -506,37 +506,38 @@ async def _update_positions(self): positions = await safe_gather(*position_tasks, return_exceptions=True) for trading_pair, position in zip(self._trading_pairs, positions): position = position.get("position", {}) - position_side = PositionSide.LONG if Decimal(position.get("size", "0")) > 0 else PositionSide.SHORT - unrealized_pnl = Decimal(position.get("pnl")) - entry_price = Decimal(position.get("entryPrice")) amount = Decimal(position.get("size")) - leverage = self._leverage[trading_pair] - if amount != 0: - self._account_positions[trading_pair + position_side.name] = Position( - 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] - - payment = Decimal(str(position.get("fundingPayment"))) - oldPayment = self._fundingPayment.get(trading_pair, 0) - if payment != oldPayment: - self._fundingPayment[trading_pair] = oldPayment - action = "paid" if payment < 0 else "received" - if payment != Decimal("0"): - self.logger().info(f"Funding payment of {payment} {action} on {trading_pair} market.") - self.trigger_event(MarketEvent.FundingPaymentCompleted, - FundingPaymentCompletedEvent(timestamp=time.time(), - market=self.name, - funding_rate=self._funding_info[trading_pair]["rate"], - trading_pair=trading_pair, - amount=payment)) + if amount != Decimal("0"): + position_side = PositionSide.LONG if amount > 0 else PositionSide.SHORT + unrealized_pnl = Decimal(position.get("pnl")) + entry_price = Decimal(position.get("entryPrice")) + leverage = self._leverage[trading_pair] + if amount != 0: + self._account_positions[trading_pair + position_side.name] = Position( + 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] + + payment = Decimal(str(position.get("fundingPayment"))) + oldPayment = self._fundingPayment.get(trading_pair, 0) + if payment != oldPayment: + self._fundingPayment[trading_pair] = oldPayment + action = "paid" if payment < 0 else "received" + if payment != Decimal("0"): + self.logger().info(f"Funding payment of {payment} {action} on {trading_pair} market.") + self.trigger_event(MarketEvent.FundingPaymentCompleted, + FundingPaymentCompletedEvent(timestamp=time.time(), + market=self.name, + funding_rate=self._funding_info[trading_pair]["rate"], + trading_pair=trading_pair, + amount=payment)) async def _funding_info_polling_loop(self): while True: From f9af9a137a4874731e4007ae7aab3822dd2e7c41 Mon Sep 17 00:00:00 2001 From: Daniel Tan Date: Mon, 1 Mar 2021 19:04:36 +0800 Subject: [PATCH 094/131] (refactor) include get_ws_auth_payload() into ProbitAuth --- .../exchange/probit/probit_api_user_stream_data_source.py | 6 +----- hummingbot/connector/exchange/probit/probit_auth.py | 7 +++++++ 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/hummingbot/connector/exchange/probit/probit_api_user_stream_data_source.py b/hummingbot/connector/exchange/probit/probit_api_user_stream_data_source.py index 21b980ae2d..b0e9f185de 100644 --- a/hummingbot/connector/exchange/probit/probit_api_user_stream_data_source.py +++ b/hummingbot/connector/exchange/probit/probit_api_user_stream_data_source.py @@ -73,11 +73,7 @@ async def _authenticate(self, ws: websockets.WebSocketClientProtocol): Authenticates user to websocket """ try: - await self._probit_auth.get_auth_headers() - auth_payload: Dict[str, Any] = { - "type": "authorization", - "token": self._probit_auth.oauth_token - } + auth_payload: Dict[str, Any] = self._probit_auth.get_ws_auth_payload() await ws.send(ujson.dumps(auth_payload, escape_forward_slashes=False)) auth_resp = await ws.recv() auth_resp: Dict[str, Any] = ujson.loads(auth_resp) diff --git a/hummingbot/connector/exchange/probit/probit_auth.py b/hummingbot/connector/exchange/probit/probit_auth.py index b609a09c22..5616c617cd 100644 --- a/hummingbot/connector/exchange/probit/probit_auth.py +++ b/hummingbot/connector/exchange/probit/probit_auth.py @@ -72,6 +72,13 @@ async def get_auth_headers(self, http_client: aiohttp.ClientSession = aiohttp.Cl return self.generate_auth_dict() + async def get_ws_auth_payload(self) -> Dict[str, Any]: + await self.get_auth_headers() + return { + "type": "authorization", + "token": self._oauth_token + } + def generate_auth_dict(self): """ Generates authentication signature and return it in a dictionary along with other inputs From e93b8d511ef0e06b88a5fe206d926161e39f9f3d Mon Sep 17 00:00:00 2001 From: Daniel Tan Date: Mon, 1 Mar 2021 19:06:29 +0800 Subject: [PATCH 095/131] (add) add ProBitAuthUnitTest --- test/connector/exchange/probit/__init__.py | 0 .../exchange/probit/test_probit_auth.py | 62 +++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 test/connector/exchange/probit/__init__.py create mode 100644 test/connector/exchange/probit/test_probit_auth.py diff --git a/test/connector/exchange/probit/__init__.py b/test/connector/exchange/probit/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/connector/exchange/probit/test_probit_auth.py b/test/connector/exchange/probit/test_probit_auth.py new file mode 100644 index 0000000000..4d6ccdab51 --- /dev/null +++ b/test/connector/exchange/probit/test_probit_auth.py @@ -0,0 +1,62 @@ +import aiohttp +import asyncio +import conf +import logging +import sys +import unittest +import ujson +import websockets + +import hummingbot.connector.exchange.probit.probit_constants as CONSTANTS + +from os.path import join, realpath +from typing import ( + Any, + Dict, +) + +from hummingbot.connector.exchange.probit.probit_auth import ProbitAuth +from hummingbot.logger.struct_logger import METRICS_LOG_LEVEL + +sys.path.insert(0, realpath(join(__file__, "../../../../../"))) +logging.basicConfig(level=METRICS_LOG_LEVEL) + + +class ProBitAuthUnitTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls.ev_loop: asyncio.BaseEventLoop = asyncio.get_event_loop() + api_key = conf.probit_api_key + secret_key = conf.crypto_com_secret_key + cls.auth: ProbitAuth = ProbitAuth(api_key, secret_key) + + async def rest_auth(self) -> Dict[str, Any]: + http_client = aiohttp.ClientSession() + resp = await self.auth.get_auth_headers(http_client) + await http_client.close() + return resp + + async def ws_auth(self) -> Dict[Any, Any]: + ws = await websockets.connect(CONSTANTS.WSS_URL.format("com")) + + auth_payload = await self.auth.get_ws_auth_payload() + + await ws.send(ujson.dumps(auth_payload, escape_forward_slashes=False)) + resp = await ws.recv() + await ws.close() + + return ujson.loads(resp) + + def test_rest_auth(self): + result = self.ev_loop.run_until_complete(self.rest_auth()) + assert not isinstance(result, Exception) + + def test_ws_auth(self): + result = self.ev_loop.run_until_complete(self.ws_auth()) + assert result["result"] == "ok" + + +if __name__ == "__main__": + logging.getLogger("hummingbot.core.event.event_reporter").setLevel(logging.WARNING) + unittest.main() From ae7ddc9b06591e2beeaa2848f2ec52bfd8680ba9 Mon Sep 17 00:00:00 2001 From: vic-en Date: Mon, 1 Mar 2021 14:46:16 +0100 Subject: [PATCH 096/131] (fix) alternate logs when waiting for funding payment --- .../spot_perpetual_arbitrage/spot_perpetual_arbitrage.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/hummingbot/strategy/spot_perpetual_arbitrage/spot_perpetual_arbitrage.py b/hummingbot/strategy/spot_perpetual_arbitrage/spot_perpetual_arbitrage.py index 824280d0f6..852502859e 100644 --- a/hummingbot/strategy/spot_perpetual_arbitrage/spot_perpetual_arbitrage.py +++ b/hummingbot/strategy/spot_perpetual_arbitrage/spot_perpetual_arbitrage.py @@ -162,9 +162,9 @@ async def main(self, timestamp): if self._maximize_funding_rate: execute_arb = not self.would_receive_funding_payment(self.deriv_position) if execute_arb: - self.timed_logger(timestamp, "Waiting for funding payment.") - else: funding_msg = "Time for funding payment, executing second arbitrage to prevent paying funding fee" + else: + funding_msg = "Waiting for funding payment." else: funding_msg = "Time for funding payment, executing second arbitrage " \ "immediately since we don't intend to maximize funding rate" From 37997c87d187836698dcdf1efded2081fa4b4c61 Mon Sep 17 00:00:00 2001 From: vic-en Date: Mon, 1 Mar 2021 14:59:10 +0100 Subject: [PATCH 097/131] (fix) add market validators --- hummingbot/strategy/amm_arb/amm_arb_config_map.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/hummingbot/strategy/amm_arb/amm_arb_config_map.py b/hummingbot/strategy/amm_arb/amm_arb_config_map.py index 16e15e90c7..f662855f10 100644 --- a/hummingbot/strategy/amm_arb/amm_arb_config_map.py +++ b/hummingbot/strategy/amm_arb/amm_arb_config_map.py @@ -1,5 +1,6 @@ from hummingbot.client.config.config_var import ConfigVar from hummingbot.client.config.config_validators import ( + validate_market_trading_pair, validate_connector, validate_decimal, validate_bool @@ -16,10 +17,20 @@ def exchange_on_validated(value: str) -> None: required_exchanges.append(value) +def market_1_validator(value: str) -> None: + exchange = amm_arb_config_map["connector_1"].value + return validate_market_trading_pair(exchange, value) + + def market_1_on_validated(value: str) -> None: requried_connector_trading_pairs[amm_arb_config_map["connector_1"].value] = [value] +def market_2_validator(value: str) -> None: + exchange = amm_arb_config_map["connector_2"].value + return validate_market_trading_pair(exchange, value) + + def market_2_on_validated(value: str) -> None: requried_connector_trading_pairs[amm_arb_config_map["connector_2"].value] = [value] @@ -59,6 +70,7 @@ def order_amount_prompt() -> str: key="market_1", prompt=market_1_prompt, prompt_on_new=True, + validator=market_1_on_validated, on_validated=market_1_on_validated), "connector_2": ConfigVar( key="connector_2", @@ -70,6 +82,7 @@ def order_amount_prompt() -> str: key="market_2", prompt=market_2_prompt, prompt_on_new=True, + validator=market_2_validator, on_validated=market_2_on_validated), "order_amount": ConfigVar( key="order_amount", From ee18dc2de73ab0e57f48cd827a9c14de5ec84650 Mon Sep 17 00:00:00 2001 From: Nicolas Baum Date: Mon, 1 Mar 2021 11:38:49 -0300 Subject: [PATCH 098/131] Add Dark Pool description --- hummingbot/connector/exchange/kraken/kraken_utils.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/hummingbot/connector/exchange/kraken/kraken_utils.py b/hummingbot/connector/exchange/kraken/kraken_utils.py index 0b7cbd34fc..972b85a61e 100644 --- a/hummingbot/connector/exchange/kraken/kraken_utils.py +++ b/hummingbot/connector/exchange/kraken/kraken_utils.py @@ -85,6 +85,11 @@ def convert_to_exchange_trading_pair(hb_trading_pair: str, delimiter: str = "") def is_dark_pool(trading_pair_details: Dict[str, Any]): + ''' + Want to filter out dark pool trading pairs from the list of trading pairs + For more info, please check + https://support.kraken.com/hc/en-us/articles/360001391906-Introducing-the-Kraken-Dark-Pool + ''' if trading_pair_details.get('altname'): return trading_pair_details.get('altname').endswith('.d') return False From eaaa03e7b5c9d3290cef3a339315fae53f2335e8 Mon Sep 17 00:00:00 2001 From: Kirill Stroukov Date: Mon, 1 Mar 2021 20:41:43 +0100 Subject: [PATCH 099/131] feat/Bexy fix order skew strategy --- .../connector/exchange/beaxy/beaxy_exchange.pyx | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/hummingbot/connector/exchange/beaxy/beaxy_exchange.pyx b/hummingbot/connector/exchange/beaxy/beaxy_exchange.pyx index 0c93817b3e..7d20066807 100644 --- a/hummingbot/connector/exchange/beaxy/beaxy_exchange.pyx +++ b/hummingbot/connector/exchange/beaxy/beaxy_exchange.pyx @@ -360,10 +360,15 @@ cdef class BeaxyExchange(ExchangeBase): continue execute_price = Decimal(order_update['limit_price'] if order_update['limit_price'] else order_update['average_price']) + new_confirmed_amount = Decimal(order_update['size']) + + # Update the tracked order + tracked_order.executed_amount_base = new_confirmed_amount + tracked_order.executed_amount_quote = new_confirmed_amount * execute_price + tracked_order.last_state = order_update['order_status'] if order_update['filled_size']: - new_confirmed_amount = Decimal(order_update['filled_size']) - execute_amount_diff = new_confirmed_amount - tracked_order.executed_amount_base + execute_amount_diff = Decimal(order_update['filled_size']) - tracked_order.executed_amount_base if execute_amount_diff > s_decimal_0: @@ -395,11 +400,8 @@ cdef class BeaxyExchange(ExchangeBase): f'{order_type_description} order {client_order_id}.') self.c_trigger_event(self.MARKET_ORDER_FILLED_EVENT_TAG, order_filled_event) - # Update the tracked order - tracked_order.last_state = order_update['order_status'] if not closed_order else 'closed' - if tracked_order.is_done: - if not tracked_order.is_failure: + if not tracked_order.is_failure and not tracked_order.is_cancelled: if tracked_order.trade_type == TradeType.BUY: self.logger().info(f'The market buy order {tracked_order.client_order_id} has completed ' f'according to order status API.') From 28f4dd1e8c92548a7e448e138b4edc81f31ccd0e Mon Sep 17 00:00:00 2001 From: vic-en Date: Mon, 1 Mar 2021 22:09:03 +0100 Subject: [PATCH 100/131] (fix) fixed some errors related to perpetual protocol --- ...tual_finance_api_order_book_data_source.py | 10 +++++++ .../perpetual_finance_derivative.py | 28 +++++++++---------- .../spot_perpetual_arbitrage.py | 16 +++++++---- 3 files changed, 34 insertions(+), 20 deletions(-) diff --git a/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_api_order_book_data_source.py b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_api_order_book_data_source.py index 4abc57b74c..2df75a53be 100644 --- a/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_api_order_book_data_source.py +++ b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_api_order_book_data_source.py @@ -2,6 +2,7 @@ from typing import List import json import ssl +from typing import Dict from hummingbot.client.config.global_config_map import global_config_map from hummingbot.connector.derivative.perpetual_finance.perpetual_finance_utils import convert_from_exchange_trading_pair @@ -41,3 +42,12 @@ async def fetch_trading_pairs() -> List[str]: for pair in pairs: trading_pairs.append(convert_from_exchange_trading_pair(pair)) return trading_pairs + + @classmethod + async def get_last_traded_prices(cls, trading_pairs: List[str]) -> Dict[str, float]: + """ + This function doesn't really need to return a value. + It is only currently used for performance calculation which will in turn use the last price of the last trades + if None is returned. + """ + pass diff --git a/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py index b752a5bf35..e8ea75a72a 100644 --- a/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py +++ b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py @@ -243,11 +243,12 @@ async def _create_order(self, api_params = {"pair": convert_to_exchange_trading_pair(trading_pair)} if position_action == PositionAction.OPEN: api_params.update({"side": 0 if trade_type == TradeType.BUY else 1, - "margin": self.quantize_order_amount(trading_pair, (amount / self._leverage[trading_pair])), + "margin": self.quantize_order_amount(trading_pair, (amount / self._leverage[trading_pair] * price)), "leverage": self._leverage[trading_pair], "minBaseAssetAmount": Decimal("0")}) else: - api_params.update({"minimalQuoteAsset": price * amount}) + # api_params.update({"minimalQuoteAsset": price * amount}) + api_params.update({"minimalQuoteAsset": Decimal("0")}) self.start_tracking_order(order_id, None, trading_pair, trade_type, price, amount, self._leverage[trading_pair], position_action.name) try: order_result = await self._api_request("post", f"perpfi/{position_action.name.lower()}", api_params) @@ -512,18 +513,17 @@ async def _update_positions(self): unrealized_pnl = Decimal(position.get("pnl")) entry_price = Decimal(position.get("entryPrice")) leverage = self._leverage[trading_pair] - if amount != 0: - self._account_positions[trading_pair + position_side.name] = Position( - 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] + self._account_positions[trading_pair] = Position( + trading_pair=trading_pair, + position_side=position_side, + unrealized_pnl=unrealized_pnl, + entry_price=entry_price, + amount=amount, + leverage=leverage + ) + else: + if trading_pair in self._account_positions: + del self._account_positions[trading_pair] payment = Decimal(str(position.get("fundingPayment"))) oldPayment = self._fundingPayment.get(trading_pair, 0) diff --git a/hummingbot/strategy/spot_perpetual_arbitrage/spot_perpetual_arbitrage.py b/hummingbot/strategy/spot_perpetual_arbitrage/spot_perpetual_arbitrage.py index 852502859e..6838e5d20e 100644 --- a/hummingbot/strategy/spot_perpetual_arbitrage/spot_perpetual_arbitrage.py +++ b/hummingbot/strategy/spot_perpetual_arbitrage/spot_perpetual_arbitrage.py @@ -369,10 +369,8 @@ def spread_msg(self): def active_positions_df(self) -> pd.DataFrame: columns = ["Symbol", "Type", "Entry Price", "Amount", "Leverage", "Unrealized PnL"] data = [] - market, trading_pair = self._derivative_market_info.market, self._derivative_market_info.trading_pair for idx in self.deriv_position: - is_buy = True if idx.amount > 0 else False - unrealized_profit = ((market.get_price(trading_pair, is_buy) - idx.entry_price) * idx.amount) + unrealized_profit = ((self.current_proposal.derivative_side.order_price - idx.entry_price) * idx.amount) data.append([ idx.trading_pair, idx.position_side.name, @@ -418,8 +416,11 @@ async def format_status(self) -> str: lines.extend(["", " Assets:"] + [" " + line for line in str(assets_df).split("\n")]) - lines.extend(["", " Spread details:"] + [" " + self.spread_msg()] + - self.short_proposal_msg()) + try: + lines.extend(["", " Spread details:"] + [" " + self.spread_msg()] + + self.short_proposal_msg()) + except Exception: + pass warning_lines = self.network_warning([self._spot_market_info]) warning_lines.extend(self.network_warning([self._derivative_market_info])) @@ -447,7 +448,10 @@ def did_expire_order(self, expired_event): def did_complete_funding_payment(self, funding_payment_completed_event): # Excute second arbitrage if necessary (even spread hasn't reached min convergence) - if len(self.deriv_position) > 0 and self.ready_for_new_arb_trades(): + if len(self.deriv_position) > 0 and \ + self._all_markets_ready and \ + self.current_proposal and \ + self.ready_for_new_arb_trades(): self.apply_slippage_buffers(self.current_proposal) self.apply_budget_constraint(self.current_proposal) funding_msg = "Executing second arbitrage after funding payment is received" From 1b6aa7ad734322a28b573e62f88f8dabf3d7d4f0 Mon Sep 17 00:00:00 2001 From: Daniel Tan Date: Tue, 2 Mar 2021 14:55:19 +0800 Subject: [PATCH 101/131] (add) remaining Probit connector unit test cases --- .../probit_api_user_stream_data_source.py | 2 +- .../exchange/probit/connector_test.sqlite | Bin 0 -> 69632 bytes .../exchange/probit/test_probit_exchange.py | 456 ++++++++++++++++++ .../probit/test_probit_order_book_tracker.py | 115 +++++ .../probit/test_probit_user_stream_tracker.py | 41 ++ 5 files changed, 613 insertions(+), 1 deletion(-) create mode 100644 test/connector/exchange/probit/connector_test.sqlite create mode 100644 test/connector/exchange/probit/test_probit_exchange.py create mode 100644 test/connector/exchange/probit/test_probit_order_book_tracker.py create mode 100644 test/connector/exchange/probit/test_probit_user_stream_tracker.py diff --git a/hummingbot/connector/exchange/probit/probit_api_user_stream_data_source.py b/hummingbot/connector/exchange/probit/probit_api_user_stream_data_source.py index b0e9f185de..f8ef3c61c8 100644 --- a/hummingbot/connector/exchange/probit/probit_api_user_stream_data_source.py +++ b/hummingbot/connector/exchange/probit/probit_api_user_stream_data_source.py @@ -73,7 +73,7 @@ async def _authenticate(self, ws: websockets.WebSocketClientProtocol): Authenticates user to websocket """ try: - auth_payload: Dict[str, Any] = self._probit_auth.get_ws_auth_payload() + auth_payload: Dict[str, Any] = await self._probit_auth.get_ws_auth_payload() await ws.send(ujson.dumps(auth_payload, escape_forward_slashes=False)) auth_resp = await ws.recv() auth_resp: Dict[str, Any] = ujson.loads(auth_resp) diff --git a/test/connector/exchange/probit/connector_test.sqlite b/test/connector/exchange/probit/connector_test.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..86198ba15689734b16c2f1eb34c454628d798acf GIT binary patch literal 69632 zcmeI)(Qn#D90zbaiQ__=kSdF;w9RUPrWG}GkT6C!b#0)eq9sWSL~Ar@mJ=?iTY@Q= zwo%px)J@wTv6uZ7`xB;Vf5H0JPU}Ovm%U9|A9g-tFxZf(TBVkJEh3ydAO79v&WCL{ z3O93_LF661wx=5M5;x89Ja<`^IgYM!+zI+;FH>}7%>IWy^N#g?tJB>1OTSG9{^q7f zf8_$#0zXe_$1hKPKJnv(;MaVAh(C5iXA1pc`O;00drFpgA%vO6d^aJf~Gk zRwf0S?gwc!BCAx{X{ySSuSSe?An#gEqOp0d3{X|Itm8m zv-Zm`$nDn;H}3>QDHP%#pEA^~GI11etWLLfT3k-$GQF?r_h}^>PBC*elb6@li}Kn= zF1H~2efI9;V&)c2b>y^nFQ>9sY0<7sT5noUccB~k?CNrUQ@)VW-Xm5v|^=orv?1s=E*5h+Bm~ER~f6ZqwJ}*yTnko)H+erdY!Je zW3%XsHMK-mv~oGn1^-`)vKDBUr!iPNTaY_@>uQOUlcKaqgUj{=XV4I|%C1sXwOZfs zruu|8sBqr^>-&3KdbumOk>jFti3Yc12e++PcC_8T@l5u{k7_pK*#=^@qM%67o{_2`0u}D$5OlL+ba{T z7+S|sJDv88zDH3ni2J~((>6B9?!G(6ir1ZPwXZw9(=pw7jyk(D+Xs!;0X<+GFb^)p z*sl`zOIoG8^Qtv%o5(A<_2urNL*3IG6~iN=OzxAKx=TFADauEq*dau|R-l8XbD$y* zw{NSJT^b|Xh7NSDv8<}sHAB-YEXN#^{*Q5r_leR)iEn1uW-Ij@DOu%@c}F*>oM(LY0O{p+dYrz;(AYM+-5ke{C!FXV+JIu7 zkvX>aqT_?{Vh!#*==d zQ(3JWO24xkmf5J5=t$n_9$xXbha|mqve+JY4jqa7;jl1njw>ANn)Q2SdMT->4MS)D zE36+VG4?dx{A$!XF&t$bWtE*MndhLyu{ze>X&gCr*YP?rJiPZ<=-RnzfQywSsrae$XA1cq8lDGb>8Z zL_ZoCm(uh78o~}jiP>wT^djS!d7&{$|M-lnt#TMZETu}Z2E@z|57sf$%XTaW|}n-#7vByk4B@hXw*z456O1JpmPqaY&MIO zZR3^6Cg!wr>1;D=z9~(qG|F_YWzbBbQ#?!UZQAoiSm=6{RDzElS>OM^!v*fVktW8A zLI45~fB*y_009U<00Izz00fShKy!vakq&)0H$O8gghZcsPT*z-+}?295@DQ!fuzWJMKnYI+=L(@^m_K`Po0ucj%I8?6aR?NSi;*LQ2;5YuQ}R`u_h@ zF7Wh-H3zB-0SG_<0uX=z1Rwwb2tWV=5Ev$b_eayd0k;{f>;D&A;KeXi8g+pH1Rwwb z2tWV=5P$##AOHaf994l?VQyCNSzjOMd}qMA{(sH|o*z{g)Efd2fB*y_009U<00Izz z00bZ~ECO@F+`N75Z%-Yt<^WjN|1Y_~%VDW2>Hz@=KmY;|fB*y_009U<00Iy=(gN=Z zb2E;c0M6@weEF00Izz00bZa0SG_<0uX?} zPzm7rf2f)hm4N^RAOHafKmY;|fB*y_009Vi3YfqD#|M7l=mQG`AOHafKmY;|fB*y_ z009U<00M8jz{kA6rG;~;NUE5QY!t2(Be6s*o=7BP(ZzT&8ILEdAN)@>_Sb7AQcKr} vYLJro`9B|c!qEp72tWV=5P$##AOHafKmY;|fB*#EIe`zx(t_{M+X4Rqh71df literal 0 HcmV?d00001 diff --git a/test/connector/exchange/probit/test_probit_exchange.py b/test/connector/exchange/probit/test_probit_exchange.py new file mode 100644 index 0000000000..ccf5626557 --- /dev/null +++ b/test/connector/exchange/probit/test_probit_exchange.py @@ -0,0 +1,456 @@ +from os.path import join, realpath +import sys; sys.path.insert(0, realpath(join(__file__, "../../../../../"))) + +import asyncio +import conf +import contextlib +import logging +import math +import os +import unittest +import time + +from decimal import Decimal +from typing import List + +from hummingbot.core.clock import Clock, ClockMode +from hummingbot.logger.struct_logger import METRICS_LOG_LEVEL +from hummingbot.core.utils.async_utils import safe_gather, safe_ensure_future +from hummingbot.core.event.event_logger import EventLogger +from hummingbot.core.event.events import ( + BuyOrderCompletedEvent, + BuyOrderCreatedEvent, + MarketEvent, + OrderFilledEvent, + OrderType, + SellOrderCompletedEvent, + SellOrderCreatedEvent, + OrderCancelledEvent +) +from hummingbot.model.sql_connection_manager import ( + SQLConnectionManager, + SQLConnectionType +) +from hummingbot.model.market_state import MarketState +from hummingbot.model.order import Order +from hummingbot.model.trade_fill import TradeFill +from hummingbot.connector.markets_recorder import MarketsRecorder +from hummingbot.connector.exchange.probit.probit_exchange import ProbitExchange + + +logging.basicConfig(level=METRICS_LOG_LEVEL) +API_KEY = conf.probit_api_key +API_SECRET = conf.probit_secret_key +DOMAIN = "com" + + +class ProbitExchangeUnitTest(unittest.TestCase): + events: List[MarketEvent] = [ + MarketEvent.BuyOrderCompleted, + MarketEvent.SellOrderCompleted, + MarketEvent.OrderFilled, + MarketEvent.TransactionFailure, + MarketEvent.BuyOrderCreated, + MarketEvent.SellOrderCreated, + MarketEvent.OrderCancelled, + MarketEvent.OrderFailure + ] + connector: ProbitExchange + event_logger: EventLogger + trading_pair = "BTC-USDT" + base_token, quote_token = trading_pair.split("-") + stack: contextlib.ExitStack + + @classmethod + def setUpClass(cls): + + cls.ev_loop = asyncio.get_event_loop() + + cls.clock: Clock = Clock(ClockMode.REALTIME) + cls.connector: ProbitExchange = ProbitExchange( + probit_api_key=API_KEY, + probit_secret_key=API_SECRET, + trading_pairs=[cls.trading_pair], + trading_required=True, + domain=DOMAIN + ) + print("Initializing Probit market... this will take about a minute.") + cls.clock.add_iterator(cls.connector) + cls.stack: contextlib.ExitStack = contextlib.ExitStack() + cls._clock = cls.stack.enter_context(cls.clock) + cls.ev_loop.run_until_complete(cls.wait_til_ready()) + print("Ready.") + + @classmethod + def tearDownClass(cls) -> None: + cls.stack.close() + + @classmethod + async def wait_til_ready(cls, connector = None): + if connector is None: + connector = cls.connector + while True: + now = time.time() + next_iteration = now // 1.0 + 1 + if connector.ready: + break + else: + await cls._clock.run_til(next_iteration) + await asyncio.sleep(1.0) + + def setUp(self): + self.db_path: str = realpath(join(__file__, "../connector_test.sqlite")) + try: + os.unlink(self.db_path) + except FileNotFoundError: + pass + + self.event_logger = EventLogger() + for event_tag in self.events: + self.connector.add_listener(event_tag, self.event_logger) + + def tearDown(self): + for event_tag in self.events: + self.connector.remove_listener(event_tag, self.event_logger) + self.event_logger = None + + async def run_parallel_async(self, *tasks): + future: asyncio.Future = safe_ensure_future(safe_gather(*tasks)) + while not future.done(): + now = time.time() + next_iteration = now // 1.0 + 1 + await self._clock.run_til(next_iteration) + await asyncio.sleep(1.0) + return future.result() + + def run_parallel(self, *tasks): + return self.ev_loop.run_until_complete(self.run_parallel_async(*tasks)) + + def test_estimate_fee(self): + maker_fee = self.connector.estimate_fee_pct(True) + self.assertAlmostEqual(maker_fee, Decimal("0.001")) + taker_fee = self.connector.estimate_fee_pct(False) + self.assertAlmostEqual(taker_fee, Decimal("0.001")) + + def _cancel_order(self, cl_order_id): + self.connector.cancel(self.trading_pair, cl_order_id) + + def test_limit_buy_and_sell(self): + price = self.connector.get_price(self.trading_pair, True) * Decimal("1.05") + price = self.connector.quantize_order_price(self.trading_pair, price) + amount = self.connector.quantize_order_amount(self.trading_pair, Decimal("0.0001")) + quote_bal = self.connector.get_available_balance(self.quote_token) + base_bal = self.connector.get_available_balance(self.base_token) + + order_id = self.connector.buy(trading_pair=self.trading_pair, + amount=amount, + order_type=OrderType.LIMIT, + price=price, + ) + order_completed_event = self.ev_loop.run_until_complete(self.event_logger.wait_for(BuyOrderCompletedEvent)) + self.ev_loop.run_until_complete(asyncio.sleep(2)) + trade_events = [t for t in self.event_logger.event_log if isinstance(t, OrderFilledEvent)] + base_amount_traded = sum(t.amount for t in trade_events) + quote_amount_traded = sum(t.amount * t.price for t in trade_events) + + self.assertTrue([evt.order_type == OrderType.LIMIT for evt in trade_events]) + self.assertEqual(order_id, order_completed_event.order_id) + self.assertEqual(amount, order_completed_event.base_asset_amount) + self.assertEqual("BTC", order_completed_event.base_asset) + self.assertEqual("USDT", order_completed_event.quote_asset) + self.assertAlmostEqual(base_amount_traded, order_completed_event.base_asset_amount) + self.assertAlmostEqual(quote_amount_traded, order_completed_event.quote_asset_amount) + self.assertGreater(order_completed_event.fee_amount, Decimal(0)) + self.assertTrue(any([isinstance(event, BuyOrderCreatedEvent) and event.order_id == order_id + for event in self.event_logger.event_log])) + + # check available quote balance gets updated, we need to wait a bit for the balance message to arrive + expected_quote_bal = quote_bal - quote_amount_traded + self._mock_ws_bal_update(self.quote_token, expected_quote_bal) + self.ev_loop.run_until_complete(asyncio.sleep(1)) + self.assertAlmostEqual(expected_quote_bal, self.connector.get_available_balance(self.quote_token)) + + # Reset the logs + self.event_logger.clear() + + # Try to sell back the same amount to the exchange, and watch for completion event. + price = self.connector.get_price(self.trading_pair, True) * Decimal("0.95") + price = self.connector.quantize_order_price(self.trading_pair, price) + amount = self.connector.quantize_order_amount(self.trading_pair, Decimal("0.0001")) + order_id = self.connector.sell(trading_pair=self.trading_pair, + amount=amount, + order_type=OrderType.LIMIT, + price=price, + ) + order_completed_event = self.ev_loop.run_until_complete(self.event_logger.wait_for(SellOrderCompletedEvent)) + trade_events = [t for t in self.event_logger.event_log if isinstance(t, OrderFilledEvent)] + base_amount_traded = sum(t.amount for t in trade_events) + quote_amount_traded = sum(t.amount * t.price for t in trade_events) + + self.assertTrue([evt.order_type == OrderType.LIMIT for evt in trade_events]) + self.assertEqual(order_id, order_completed_event.order_id) + self.assertEqual(amount, order_completed_event.base_asset_amount) + self.assertEqual("BTC", order_completed_event.base_asset) + self.assertEqual("USDT", order_completed_event.quote_asset) + self.assertAlmostEqual(base_amount_traded, order_completed_event.base_asset_amount) + self.assertAlmostEqual(quote_amount_traded, order_completed_event.quote_asset_amount) + self.assertGreater(order_completed_event.fee_amount, Decimal(0)) + self.assertTrue(any([isinstance(event, SellOrderCreatedEvent) and event.order_id == order_id + for event in self.event_logger.event_log])) + + # check available base balance gets updated, we need to wait a bit for the balance message to arrive + expected_base_bal = base_bal + self._mock_ws_bal_update(self.base_token, expected_base_bal) + self.ev_loop.run_until_complete(asyncio.sleep(1)) + self.assertAlmostEqual(expected_base_bal, self.connector.get_available_balance(self.base_token), 5) + + def test_limit_makers_unfilled(self): + price = self.connector.get_price(self.trading_pair, True) * Decimal("0.8") + price = self.connector.quantize_order_price(self.trading_pair, price) + amount = self.connector.quantize_order_amount(self.trading_pair, Decimal("0.0001")) + quote_bal = self.connector.get_available_balance(self.quote_token) + + cl_order_id = self.connector.buy(self.trading_pair, amount, OrderType.LIMIT_MAKER, price) + order_created_event = self.ev_loop.run_until_complete(self.event_logger.wait_for(BuyOrderCreatedEvent)) + self.assertEqual(cl_order_id, order_created_event.order_id) + # check available quote balance gets updated, we need to wait a bit for the balance message to arrive + expected_quote_bal = quote_bal - (price * amount) + self._mock_ws_bal_update(self.quote_token, expected_quote_bal) + self.ev_loop.run_until_complete(asyncio.sleep(2)) + self.assertAlmostEqual(expected_quote_bal, self.connector.get_available_balance(self.quote_token)) + self._cancel_order(cl_order_id) + event = self.ev_loop.run_until_complete(self.event_logger.wait_for(OrderCancelledEvent)) + self.assertEqual(cl_order_id, event.order_id) + + price = self.connector.get_price(self.trading_pair, True) * Decimal("1.2") + price = self.connector.quantize_order_price(self.trading_pair, price) + amount = self.connector.quantize_order_amount(self.trading_pair, Decimal("0.0001")) + + cl_order_id = self.connector.sell(self.trading_pair, amount, OrderType.LIMIT_MAKER, price) + order_created_event = self.ev_loop.run_until_complete(self.event_logger.wait_for(SellOrderCreatedEvent)) + self.assertEqual(cl_order_id, order_created_event.order_id) + self._cancel_order(cl_order_id) + event = self.ev_loop.run_until_complete(self.event_logger.wait_for(OrderCancelledEvent)) + self.assertEqual(cl_order_id, event.order_id) + + def _mock_ws_bal_update(self, token, available): + # TODO: Determine best way to test balance via ws + pass + + def test_limit_maker_rejections(self): + price = self.connector.get_price(self.trading_pair, True) * Decimal("1.2") + price = self.connector.quantize_order_price(self.trading_pair, price) + amount = self.connector.quantize_order_amount(self.trading_pair, Decimal("0.0001")) + cl_order_id = self.connector.buy(self.trading_pair, amount, OrderType.LIMIT_MAKER, price) + + event = self.ev_loop.run_until_complete(self.event_logger.wait_for(OrderCancelledEvent)) + self.assertEqual(cl_order_id, event.order_id) + + price = self.connector.get_price(self.trading_pair, False) * Decimal("0.8") + price = self.connector.quantize_order_price(self.trading_pair, price) + amount = self.connector.quantize_order_amount(self.trading_pair, Decimal("0.0001")) + cl_order_id = self.connector.sell(self.trading_pair, amount, OrderType.LIMIT_MAKER, price) + + event = self.ev_loop.run_until_complete(self.event_logger.wait_for(OrderCancelledEvent)) + self.assertEqual(cl_order_id, event.order_id) + + def test_cancel_all(self): + bid_price = self.connector.get_price(self.trading_pair, True) + ask_price = self.connector.get_price(self.trading_pair, False) + bid_price = self.connector.quantize_order_price(self.trading_pair, bid_price * Decimal("0.7")) + ask_price = self.connector.quantize_order_price(self.trading_pair, ask_price * Decimal("1.5")) + amount = self.connector.quantize_order_amount(self.trading_pair, Decimal("0.0001")) + + buy_id = self._place_order(True, amount, OrderType.LIMIT, bid_price, 1) + sell_id = self._place_order(False, amount, OrderType.LIMIT, ask_price, 2) + + self.ev_loop.run_until_complete(asyncio.sleep(1)) + asyncio.ensure_future(self.connector.cancel_all(5)) + self.ev_loop.run_until_complete(asyncio.sleep(5)) + cancel_events = [t for t in self.event_logger.event_log if isinstance(t, OrderCancelledEvent)] + self.assertEqual({buy_id, sell_id}, {o.order_id for o in cancel_events}) + + def test_order_price_precision(self): + bid_price: Decimal = self.connector.get_price(self.trading_pair, True) + ask_price: Decimal = self.connector.get_price(self.trading_pair, False) + mid_price: Decimal = (bid_price + ask_price) / 2 + amount: Decimal = Decimal("0.000123456") + + # Make sure there's enough balance to make the limit orders. + self.assertGreater(self.connector.get_balance("BTC"), Decimal("0.001")) + self.assertGreater(self.connector.get_balance("USDT"), Decimal("10")) + + # Intentionally set some prices with too many decimal places s.t. they + # need to be quantized. Also, place them far away from the mid-price s.t. they won't + # get filled during the test. + bid_price = mid_price * Decimal("0.9333192292111341") + ask_price = mid_price * Decimal("1.0492431474884933") + + cl_order_id_1 = self.connector.buy(trading_pair=self.trading_pair, + amount=amount, + order_type=OrderType.LIMIT, + price=bid_price, + ) + + # Wait for the order created event and examine the order made + self.ev_loop.run_until_complete(self.event_logger.wait_for(BuyOrderCreatedEvent)) + order = self.connector.in_flight_orders[cl_order_id_1] + quantized_bid_price = self.connector.quantize_order_price(self.trading_pair, bid_price) + quantized_bid_size = self.connector.quantize_order_amount(self.trading_pair, amount) + self.assertEqual(quantized_bid_price, order.price) + self.assertEqual(quantized_bid_size, order.amount) + + # Test ask order + cl_order_id_2 = self.connector.sell(trading_pair=self.trading_pair, + amount=amount, + order_type=OrderType.LIMIT, + price=bid_price, + ) + + # Wait for the order created event and examine and order made + self.ev_loop.run_until_complete(self.event_logger.wait_for(SellOrderCreatedEvent)) + order = self.connector.in_flight_orders[cl_order_id_2] + quantized_ask_price = self.connector.quantize_order_price(self.trading_pair, Decimal(ask_price)) + quantized_ask_size = self.connector.quantize_order_amount(self.trading_pair, Decimal(amount)) + self.assertEqual(quantized_ask_price, order.price) + self.assertEqual(quantized_ask_size, order.amount) + + self._cancel_order(cl_order_id_1) + self._cancel_order(cl_order_id_2) + + def test_orders_saving_and_restoration(self): + config_path = "test_config" + strategy_name = "test_strategy" + sql = SQLConnectionManager(SQLConnectionType.TRADE_FILLS, db_path=self.db_path) + order_id = None + recorder = MarketsRecorder(sql, [self.connector], config_path, strategy_name) + recorder.start() + + try: + self.connector._in_flight_orders.clear() + self.assertEqual(0, len(self.connector.tracking_states)) + + # Try to put limit buy order for 0.02 ETH worth of ZRX, and watch for order creation event. + current_bid_price: Decimal = self.connector.get_price(self.trading_pair, True) + price: Decimal = current_bid_price * Decimal("0.8") + price = self.connector.quantize_order_price(self.trading_pair, price) + + amount: Decimal = Decimal("0.0001") + amount = self.connector.quantize_order_amount(self.trading_pair, amount) + + cl_order_id = self.connector.buy(self.trading_pair, amount, OrderType.LIMIT_MAKER, price) + order_created_event = self.ev_loop.run_until_complete(self.event_logger.wait_for(BuyOrderCreatedEvent)) + self.assertEqual(cl_order_id, order_created_event.order_id) + + # Verify tracking states + self.assertEqual(1, len(self.connector.tracking_states)) + self.assertEqual(cl_order_id, list(self.connector.tracking_states.keys())[0]) + + # Verify orders from recorder + recorded_orders: List[Order] = recorder.get_orders_for_config_and_market(config_path, self.connector) + self.assertEqual(1, len(recorded_orders)) + self.assertEqual(cl_order_id, recorded_orders[0].id) + + # Verify saved market states + saved_market_states: MarketState = recorder.get_market_states(config_path, self.connector) + self.assertIsNotNone(saved_market_states) + self.assertIsInstance(saved_market_states.saved_state, dict) + self.assertGreater(len(saved_market_states.saved_state), 0) + + # Close out the current market and start another market. + self.connector.stop(self._clock) + self.ev_loop.run_until_complete(asyncio.sleep(5)) + self.clock.remove_iterator(self.connector) + for event_tag in self.events: + self.connector.remove_listener(event_tag, self.event_logger) + new_connector = ProbitExchange(API_KEY, API_SECRET, [self.trading_pair], True) + for event_tag in self.events: + new_connector.add_listener(event_tag, self.event_logger) + recorder.stop() + recorder = MarketsRecorder(sql, [new_connector], config_path, strategy_name) + recorder.start() + saved_market_states = recorder.get_market_states(config_path, new_connector) + self.clock.add_iterator(new_connector) + self.assertEqual(0, len(new_connector.limit_orders)) + self.assertEqual(0, len(new_connector.tracking_states)) + new_connector.restore_tracking_states(saved_market_states.saved_state) + self.assertEqual(1, len(new_connector.limit_orders)) + self.assertEqual(1, len(new_connector.tracking_states)) + + # Cancel the order and verify that the change is saved. + self._cancel_order(cl_order_id) + self.ev_loop.run_until_complete(self.event_logger.wait_for(OrderCancelledEvent)) + order_id = None + self.assertEqual(0, len(new_connector.limit_orders)) + self.assertEqual(0, len(new_connector.tracking_states)) + saved_market_states = recorder.get_market_states(config_path, new_connector) + self.assertEqual(0, len(saved_market_states.saved_state)) + finally: + if order_id is not None: + self.connector.cancel(self.trading_pair, cl_order_id) + self.run_parallel(self.event_logger.wait_for(OrderCancelledEvent)) + + recorder.stop() + os.unlink(self.db_path) + + def test_update_last_prices(self): + # This is basic test to see if order_book last_trade_price is initiated and updated. + for order_book in self.connector.order_books.values(): + for _ in range(5): + self.ev_loop.run_until_complete(asyncio.sleep(1)) + print(order_book.last_trade_price) + self.assertFalse(math.isnan(order_book.last_trade_price)) + + def test_filled_orders_recorded(self): + config_path: str = "test_config" + strategy_name: str = "test_strategy" + sql = SQLConnectionManager(SQLConnectionType.TRADE_FILLS, db_path=self.db_path) + order_id = None + recorder = MarketsRecorder(sql, [self.connector], config_path, strategy_name) + recorder.start() + + try: + # Try to buy some token from the exchange, and watch for completion event. + price = self.connector.get_price(self.trading_pair, True) * Decimal("1.05") + price = self.connector.quantize_order_price(self.trading_pair, price) + amount = self.connector.quantize_order_amount(self.trading_pair, Decimal("0.0001")) + + order_id = self.connector.buy(trading_pair=self.trading_pair, + amount=amount, + order_type=OrderType.LIMIT, + price=price, + ) + self.ev_loop.run_until_complete(self.event_logger.wait_for(BuyOrderCompletedEvent)) + self.ev_loop.run_until_complete(asyncio.sleep(1)) + + # Reset the logs + self.event_logger.clear() + + # Try to sell back the same amount to the exchange, and watch for completion event. + price = self.connector.get_price(self.trading_pair, True) * Decimal("0.95") + price = self.connector.quantize_order_price(self.trading_pair, price) + amount = self.connector.quantize_order_amount(self.trading_pair, Decimal("0.0001")) + order_id = self.connector.sell(trading_pair=self.trading_pair, + amount=amount, + order_type=OrderType.LIMIT, + price=price, + ) + self.ev_loop.run_until_complete(self.event_logger.wait_for(SellOrderCompletedEvent)) + + # Query the persisted trade logs + trade_fills: List[TradeFill] = recorder.get_trades_for_config(config_path) + self.assertGreaterEqual(len(trade_fills), 2) + buy_fills: List[TradeFill] = [t for t in trade_fills if t.trade_type == "BUY"] + sell_fills: List[TradeFill] = [t for t in trade_fills if t.trade_type == "SELL"] + self.assertGreaterEqual(len(buy_fills), 1) + self.assertGreaterEqual(len(sell_fills), 1) + + order_id = None + + finally: + if order_id is not None: + self.connector.cancel(self.trading_pair, order_id) + self.run_parallel(self.event_logger.wait_for(OrderCancelledEvent)) + + recorder.stop() + os.unlink(self.db_path) diff --git a/test/connector/exchange/probit/test_probit_order_book_tracker.py b/test/connector/exchange/probit/test_probit_order_book_tracker.py new file mode 100644 index 0000000000..22899e0e2f --- /dev/null +++ b/test/connector/exchange/probit/test_probit_order_book_tracker.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python + +from os.path import join, realpath +import sys; sys.path.insert(0, realpath(join(__file__, "../../../../../"))) + +import asyncio +import logging +import math +import time +import unittest + +from typing import ( + Dict, + Optional, + List, +) + +from hummingbot.core.event.event_logger import EventLogger +from hummingbot.core.event.events import OrderBookEvent, OrderBookTradeEvent, TradeType +from hummingbot.connector.exchange.probit.probit_order_book_tracker import ProbitOrderBookTracker +from hummingbot.connector.exchange.probit.probit_api_order_book_data_source import ProbitAPIOrderBookDataSource +from hummingbot.core.data_type.order_book import OrderBook + + +class ProbitOrderBookTrackerUnitTest(unittest.TestCase): + order_book_tracker: Optional[ProbitOrderBookTracker] = None + events: List[OrderBookEvent] = [ + OrderBookEvent.TradeEvent + ] + trading_pairs: List[str] = [ + "BTC-USDT", + "ETH-USDT", + ] + + @classmethod + def setUpClass(cls): + cls.ev_loop: asyncio.BaseEventLoop = asyncio.get_event_loop() + cls.order_book_tracker: ProbitOrderBookTracker = ProbitOrderBookTracker(cls.trading_pairs) + cls.order_book_tracker.start() + cls.ev_loop.run_until_complete(cls.wait_til_tracker_ready()) + + @classmethod + async def wait_til_tracker_ready(cls): + while True: + if len(cls.order_book_tracker.order_books) > 0: + print("Initialized real-time order books.") + return + await asyncio.sleep(1) + + async def run_parallel_async(self, *tasks, timeout=None): + future: asyncio.Future = asyncio.ensure_future(asyncio.gather(*tasks)) + timer = 0 + while not future.done(): + if timeout and timer > timeout: + raise Exception("Timeout running parallel async tasks in tests") + timer += 1 + now = time.time() + _next_iteration = now // 1.0 + 1 # noqa: F841 + await asyncio.sleep(1.0) + return future.result() + + def run_parallel(self, *tasks): + return self.ev_loop.run_until_complete(self.run_parallel_async(*tasks)) + + def setUp(self): + self.event_logger = EventLogger() + for event_tag in self.events: + for trading_pair, order_book in self.order_book_tracker.order_books.items(): + order_book.add_listener(event_tag, self.event_logger) + + def test_order_book_trade_event_emission(self): + """ + Tests if the order book tracker is able to retrieve order book trade message from exchange and emit order book + trade events after correctly parsing the trade messages + """ + self.run_parallel(self.event_logger.wait_for(OrderBookTradeEvent)) + for ob_trade_event in self.event_logger.event_log: + self.assertTrue(type(ob_trade_event) == OrderBookTradeEvent) + self.assertTrue(ob_trade_event.trading_pair in self.trading_pairs) + self.assertTrue(type(ob_trade_event.timestamp) in [float, int]) + self.assertTrue(type(ob_trade_event.amount) == float) + self.assertTrue(type(ob_trade_event.price) == float) + self.assertTrue(type(ob_trade_event.type) == TradeType) + # datetime is in seconds + self.assertTrue(math.ceil(math.log10(ob_trade_event.timestamp)) == 10) + self.assertTrue(ob_trade_event.amount > 0) + self.assertTrue(ob_trade_event.price > 0) + + def test_tracker_integrity(self): + # Wait 10 seconds to process some diffs. + self.ev_loop.run_until_complete(asyncio.sleep(10.0)) + order_books: Dict[str, OrderBook] = self.order_book_tracker.order_books + eth_usdt: OrderBook = order_books["ETH-USDT"] + self.assertIsNot(eth_usdt.last_diff_uid, 0) + self.assertGreaterEqual(eth_usdt.get_price_for_volume(True, 10).result_price, + eth_usdt.get_price(True)) + self.assertLessEqual(eth_usdt.get_price_for_volume(False, 10).result_price, + eth_usdt.get_price(False)) + + def test_api_get_last_traded_prices(self): + prices = self.ev_loop.run_until_complete( + ProbitAPIOrderBookDataSource.get_last_traded_prices(["BTC-USDT", "ETH-USDT"])) + for key, value in prices.items(): + print(f"{key} last_trade_price: {value}") + self.assertGreater(prices["BTC-USDT"], 30000) + self.assertGreater(prices["ETH-USDT"], 1000) + + +def main(): + logging.basicConfig(level=logging.INFO) + unittest.main() + + +if __name__ == "__main__": + main() diff --git a/test/connector/exchange/probit/test_probit_user_stream_tracker.py b/test/connector/exchange/probit/test_probit_user_stream_tracker.py new file mode 100644 index 0000000000..b17dc67934 --- /dev/null +++ b/test/connector/exchange/probit/test_probit_user_stream_tracker.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python +from os.path import join, realpath +import sys; sys.path.insert(0, realpath(join(__file__, "../../../"))) + +import asyncio +import conf +import logging +import unittest + + +from hummingbot.connector.exchange.probit.probit_user_stream_tracker import ProbitUserStreamTracker +from hummingbot.connector.exchange.probit.probit_auth import ProbitAuth +from hummingbot.core.utils.async_utils import safe_ensure_future + + +class ProbitUserStreamTrackerUnitTest(unittest.TestCase): + api_key = conf.probit_api_key + api_secret = conf.probit_secret_key + + @classmethod + def setUpClass(cls): + cls.ev_loop: asyncio.BaseEventLoop = asyncio.get_event_loop() + cls.probit_auth = ProbitAuth(cls.api_key, cls.api_secret) + cls.trading_pairs = ["PROB-USDT"] + cls.user_stream_tracker: ProbitUserStreamTracker = ProbitUserStreamTracker( + probit_auth=cls.probit_auth, trading_pairs=cls.trading_pairs) + cls.user_stream_tracker_task: asyncio.Task = safe_ensure_future(cls.user_stream_tracker.start()) + + def test_user_stream(self): + # Wait process some msgs. + self.ev_loop.run_until_complete(asyncio.sleep(120.0)) + print(self.user_stream_tracker.user_stream) + + +def main(): + logging.basicConfig(level=logging.INFO) + unittest.main() + + +if __name__ == "__main__": + main() From 02bf78836768e989f1e29ef599320dd69d5b1316 Mon Sep 17 00:00:00 2001 From: Nullably <37262506+Nullably@users.noreply.github.com> Date: Tue, 2 Mar 2021 15:07:14 +0800 Subject: [PATCH 102/131] (feat) update source of miner data --- hummingbot/connector/parrot.py | 65 +++++++++++-------- .../liquidity_mining/liquidity_mining.py | 5 +- 2 files changed, 40 insertions(+), 30 deletions(-) diff --git a/hummingbot/connector/parrot.py b/hummingbot/connector/parrot.py index e7ada091ef..cf9979dc2a 100644 --- a/hummingbot/connector/parrot.py +++ b/hummingbot/connector/parrot.py @@ -1,11 +1,13 @@ import aiohttp +import asyncio from typing import List, Dict from dataclasses import dataclass from decimal import Decimal +import logging from hummingbot.connector.exchange.binance.binance_utils import convert_from_exchange_trading_pair from hummingbot.core.utils.async_utils import safe_gather -PARROT_MINER_BASE_URL = "https://papi-development.hummingbot.io/v1/mining_data/" +PARROT_MINER_BASE_URL = "https://papi.hummingbot.io/v1/mining_data/" s_decimal_0 = Decimal("0") @@ -18,37 +20,41 @@ class CampaignSummary: spread_max: Decimal = s_decimal_0 payout_asset: str = "" liquidity: Decimal = s_decimal_0 + liquidity_usd: Decimal = s_decimal_0 active_bots: int = 0 - reward_per_day: Decimal = s_decimal_0 + reward_per_wk: Decimal = s_decimal_0 apy: Decimal = s_decimal_0 async def get_campaign_summary(exchange: str, trading_pairs: List[str] = []) -> Dict[str, CampaignSummary]: - campaigns = await get_active_campaigns(exchange, trading_pairs) - tasks = [get_market_snapshots(m_id) for m_id in campaigns] - results = await safe_gather(*tasks, return_exceptions=True) - for result in results: - if isinstance(result, Exception): - raise result - if result["items"]: - snapshot = result["items"][0] - market_id = int(snapshot["market_id"]) - campaign = campaigns[market_id] - campaign.apy = Decimal(snapshot["annualized_return"]) / Decimal("100") - reward = snapshot["payout_summary"]["open_volume"]["reward"] - if campaign.payout_asset in reward["ask"]: - campaign.reward_per_day = Decimal(str(reward["ask"][campaign.payout_asset])) - if campaign.payout_asset in reward["bid"]: - campaign.reward_per_day += Decimal(str(reward["bid"][campaign.payout_asset])) - oov = snapshot["summary_stats"]["open_volume"] - campaign.liquidity = Decimal(oov["oov_ask"]) + Decimal(oov["oov_bid"]) - campaign.active_bots = int(oov["bots"]) - return {c.trading_pair: c for c in campaigns.values()} + results = {} + try: + campaigns = await get_active_campaigns(exchange, trading_pairs) + tasks = [get_market_snapshots(m_id) for m_id in campaigns] + snapshots = await safe_gather(*tasks, return_exceptions=True) + for snapshot in snapshots: + if isinstance(snapshot, Exception): + raise snapshot + if snapshot["items"]: + snapshot = snapshot["items"][0] + market_id = int(snapshot["market_id"]) + campaign = campaigns[market_id] + campaign.apy = Decimal(snapshot["annualized_return"]) + oov = snapshot["summary_stats"]["open_volume"] + campaign.liquidity = Decimal(oov["oov_eligible_ask"]) + Decimal(oov["oov_eligible_bid"]) + campaign.liquidity_usd = campaign.liquidity * Decimal(oov["base_asset_usd_rate"]) + campaign.active_bots = int(oov["bots"]) + results = {c.trading_pair: c for c in campaigns.values()} + except asyncio.CancelledError: + raise + except Exception: + logging.getLogger(__name__).error("Unexpected error while requesting data from Hummingbot API.", exc_info=True) + return results async def get_market_snapshots(market_id: int): async with aiohttp.ClientSession() as client: - url = f"{PARROT_MINER_BASE_URL}market_snapshots/{market_id}?aggregate=1d" + url = f"{PARROT_MINER_BASE_URL}market_snapshots/{market_id}?aggregate=1m" resp = await client.get(url) resp_json = await resp.json() return resp_json @@ -65,6 +71,9 @@ async def get_active_campaigns(exchange: str, trading_pairs: List[str] = []) -> if market["exchange_name"] != exchange: continue t_pair = market["trading_pair"] + # So far we have only 2 exchanges for mining Binance and Kucoin, Kucoin doesn't require conversion. + # In the future we should create a general approach for this, e.g. move all convert trading pair fn to + # utils.py and import the function dynamically in hummingbot/client/settings.py if exchange == "binance": t_pair = convert_from_exchange_trading_pair(t_pair) if trading_pairs and t_pair not in trading_pairs: @@ -75,9 +84,11 @@ async def get_active_campaigns(exchange: str, trading_pairs: List[str] = []) -> campaign.exchange_name = market["exchange_name"] campaigns[campaign.market_id] = campaign for bounty_period in campaign_retval["bounty_periods"]: - for payout_parameter in bounty_period["payout_parameters"]: - market_id = int(payout_parameter["market_id"]) + for payout in bounty_period["payout_parameters"]: + market_id = int(payout["market_id"]) if market_id in campaigns: - campaigns[market_id].spread_max = Decimal(str(payout_parameter["spread_max"])) / Decimal("100") - campaigns[market_id].payout_asset = payout_parameter["payout_asset"] + campaigns[market_id].reward_per_wk = Decimal(str(payout["bid_budget"])) + \ + Decimal(str(payout["ask_budget"])) + campaigns[market_id].spread_max = Decimal(str(payout["spread_max"])) / Decimal("100") + campaigns[market_id].payout_asset = payout["payout_asset"] return campaigns diff --git a/hummingbot/strategy/liquidity_mining/liquidity_mining.py b/hummingbot/strategy/liquidity_mining/liquidity_mining.py index e2f25558cb..1b262376c2 100644 --- a/hummingbot/strategy/liquidity_mining/liquidity_mining.py +++ b/hummingbot/strategy/liquidity_mining/liquidity_mining.py @@ -195,13 +195,12 @@ async def miner_status_df(self) -> pd.DataFrame: columns = ["Market", "Payout", "Reward/wk", "Liquidity", "Yield/yr", "Max spread"] campaigns = await get_campaign_summary(self._exchange.display_name, list(self._market_infos.keys())) for market, campaign in campaigns.items(): - reward_usd = await usd_value(campaign.payout_asset, campaign.reward_per_day * Decimal("7")) - liquidity_usd = await usd_value(market.split('-')[0], campaign.liquidity) + reward_usd = await usd_value(campaign.payout_asset, campaign.reward_per_wk) data.append([ market, campaign.payout_asset, f"${reward_usd:.0f}", - f"${liquidity_usd:.0f}", + f"${campaign.liquidity_usd:.0f}", f"{campaign.apy:.2%}", f"{campaign.spread_max:.2%}%" ]) From e6fbfc0f542a711a9cd42f33093c89778e335fd2 Mon Sep 17 00:00:00 2001 From: Daniel Tan Date: Tue, 2 Mar 2021 15:24:40 +0800 Subject: [PATCH 103/131] (remove) remove connector_test.sqlite file --- .../exchange/probit/connector_test.sqlite | Bin 69632 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 test/connector/exchange/probit/connector_test.sqlite diff --git a/test/connector/exchange/probit/connector_test.sqlite b/test/connector/exchange/probit/connector_test.sqlite deleted file mode 100644 index 86198ba15689734b16c2f1eb34c454628d798acf..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 69632 zcmeI)(Qn#D90zbaiQ__=kSdF;w9RUPrWG}GkT6C!b#0)eq9sWSL~Ar@mJ=?iTY@Q= zwo%px)J@wTv6uZ7`xB;Vf5H0JPU}Ovm%U9|A9g-tFxZf(TBVkJEh3ydAO79v&WCL{ z3O93_LF661wx=5M5;x89Ja<`^IgYM!+zI+;FH>}7%>IWy^N#g?tJB>1OTSG9{^q7f zf8_$#0zXe_$1hKPKJnv(;MaVAh(C5iXA1pc`O;00drFpgA%vO6d^aJf~Gk zRwf0S?gwc!BCAx{X{ySSuSSe?An#gEqOp0d3{X|Itm8m zv-Zm`$nDn;H}3>QDHP%#pEA^~GI11etWLLfT3k-$GQF?r_h}^>PBC*elb6@li}Kn= zF1H~2efI9;V&)c2b>y^nFQ>9sY0<7sT5noUccB~k?CNrUQ@)VW-Xm5v|^=orv?1s=E*5h+Bm~ER~f6ZqwJ}*yTnko)H+erdY!Je zW3%XsHMK-mv~oGn1^-`)vKDBUr!iPNTaY_@>uQOUlcKaqgUj{=XV4I|%C1sXwOZfs zruu|8sBqr^>-&3KdbumOk>jFti3Yc12e++PcC_8T@l5u{k7_pK*#=^@qM%67o{_2`0u}D$5OlL+ba{T z7+S|sJDv88zDH3ni2J~((>6B9?!G(6ir1ZPwXZw9(=pw7jyk(D+Xs!;0X<+GFb^)p z*sl`zOIoG8^Qtv%o5(A<_2urNL*3IG6~iN=OzxAKx=TFADauEq*dau|R-l8XbD$y* zw{NSJT^b|Xh7NSDv8<}sHAB-YEXN#^{*Q5r_leR)iEn1uW-Ij@DOu%@c}F*>oM(LY0O{p+dYrz;(AYM+-5ke{C!FXV+JIu7 zkvX>aqT_?{Vh!#*==d zQ(3JWO24xkmf5J5=t$n_9$xXbha|mqve+JY4jqa7;jl1njw>ANn)Q2SdMT->4MS)D zE36+VG4?dx{A$!XF&t$bWtE*MndhLyu{ze>X&gCr*YP?rJiPZ<=-RnzfQywSsrae$XA1cq8lDGb>8Z zL_ZoCm(uh78o~}jiP>wT^djS!d7&{$|M-lnt#TMZETu}Z2E@z|57sf$%XTaW|}n-#7vByk4B@hXw*z456O1JpmPqaY&MIO zZR3^6Cg!wr>1;D=z9~(qG|F_YWzbBbQ#?!UZQAoiSm=6{RDzElS>OM^!v*fVktW8A zLI45~fB*y_009U<00Izz00fShKy!vakq&)0H$O8gghZcsPT*z-+}?295@DQ!fuzWJMKnYI+=L(@^m_K`Po0ucj%I8?6aR?NSi;*LQ2;5YuQ}R`u_h@ zF7Wh-H3zB-0SG_<0uX=z1Rwwb2tWV=5Ev$b_eayd0k;{f>;D&A;KeXi8g+pH1Rwwb z2tWV=5P$##AOHaf994l?VQyCNSzjOMd}qMA{(sH|o*z{g)Efd2fB*y_009U<00Izz z00bZ~ECO@F+`N75Z%-Yt<^WjN|1Y_~%VDW2>Hz@=KmY;|fB*y_009U<00Iy=(gN=Z zb2E;c0M6@weEF00Izz00bZa0SG_<0uX?} zPzm7rf2f)hm4N^RAOHafKmY;|fB*y_009Vi3YfqD#|M7l=mQG`AOHafKmY;|fB*y_ z009U<00M8jz{kA6rG;~;NUE5QY!t2(Be6s*o=7BP(ZzT&8ILEdAN)@>_Sb7AQcKr} vYLJro`9B|c!qEp72tWV=5P$##AOHafKmY;|fB*#EIe`zx(t_{M+X4Rqh71df From 7f6be282f15b6b182fde272a1fa823f5ccb72840 Mon Sep 17 00:00:00 2001 From: Nullably <37262506+Nullably@users.noreply.github.com> Date: Tue, 2 Mar 2021 17:25:33 +0800 Subject: [PATCH 104/131] (feat) minor fix --- hummingbot/connector/parrot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hummingbot/connector/parrot.py b/hummingbot/connector/parrot.py index cf9979dc2a..55b3ccad73 100644 --- a/hummingbot/connector/parrot.py +++ b/hummingbot/connector/parrot.py @@ -16,7 +16,7 @@ class CampaignSummary: market_id: int = 0 trading_pair: str = "" - exchange_name: str = 0 + exchange_name: str = "" spread_max: Decimal = s_decimal_0 payout_asset: str = "" liquidity: Decimal = s_decimal_0 From 98919ab47bce625eafb736e509d4d8c58ce16323 Mon Sep 17 00:00:00 2001 From: Daniel Tan Date: Tue, 2 Mar 2021 17:23:51 +0800 Subject: [PATCH 105/131] (fix) fix conf secret key for ProbitAuthUnitTest --- test/connector/exchange/probit/test_probit_auth.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/connector/exchange/probit/test_probit_auth.py b/test/connector/exchange/probit/test_probit_auth.py index 4d6ccdab51..8c8fbef08a 100644 --- a/test/connector/exchange/probit/test_probit_auth.py +++ b/test/connector/exchange/probit/test_probit_auth.py @@ -22,13 +22,13 @@ logging.basicConfig(level=METRICS_LOG_LEVEL) -class ProBitAuthUnitTest(unittest.TestCase): +class ProbitAuthUnitTest(unittest.TestCase): @classmethod def setUpClass(cls): cls.ev_loop: asyncio.BaseEventLoop = asyncio.get_event_loop() api_key = conf.probit_api_key - secret_key = conf.crypto_com_secret_key + secret_key = conf.probit_secret_key cls.auth: ProbitAuth = ProbitAuth(api_key, secret_key) async def rest_auth(self) -> Dict[str, Any]: From 69ce1b8c0b9b6ca508341f8919347ec650d89559 Mon Sep 17 00:00:00 2001 From: Daniel Tan Date: Tue, 2 Mar 2021 17:27:48 +0800 Subject: [PATCH 106/131] (remove) remove redundant websocket client initialization --- .../exchange/probit/probit_api_order_book_data_source.py | 1 - 1 file changed, 1 deletion(-) diff --git a/hummingbot/connector/exchange/probit/probit_api_order_book_data_source.py b/hummingbot/connector/exchange/probit/probit_api_order_book_data_source.py index 4e6a8550a4..93e464cd55 100644 --- a/hummingbot/connector/exchange/probit/probit_api_order_book_data_source.py +++ b/hummingbot/connector/exchange/probit/probit_api_order_book_data_source.py @@ -130,7 +130,6 @@ async def listen_for_trades(self, ev_loop: asyncio.BaseEventLoop, output: asynci while True: try: async with websockets.connect(uri=CONSTANTS.WSS_URL.format(self._domain)) as ws: - ws: websockets.WebSocketClientProtocol = ws for trading_pair in self._trading_pairs: params: Dict[str, Any] = { "channel": "marketdata", From 12bd67a893b9a69f09770aa6ff9bede6e2f5c259 Mon Sep 17 00:00:00 2001 From: vic-en Date: Tue, 2 Mar 2021 10:38:08 +0100 Subject: [PATCH 107/131] (fix) fix market 1 validator --- hummingbot/strategy/amm_arb/amm_arb_config_map.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hummingbot/strategy/amm_arb/amm_arb_config_map.py b/hummingbot/strategy/amm_arb/amm_arb_config_map.py index f662855f10..d695ec7c55 100644 --- a/hummingbot/strategy/amm_arb/amm_arb_config_map.py +++ b/hummingbot/strategy/amm_arb/amm_arb_config_map.py @@ -70,7 +70,7 @@ def order_amount_prompt() -> str: key="market_1", prompt=market_1_prompt, prompt_on_new=True, - validator=market_1_on_validated, + validator=market_1_validator, on_validated=market_1_on_validated), "connector_2": ConfigVar( key="connector_2", From 5396464921a51aec1b23d30864c5b8a9495a3176 Mon Sep 17 00:00:00 2001 From: Daniel Tan Date: Tue, 2 Mar 2021 17:54:47 +0800 Subject: [PATCH 108/131] (add) include better error handling in ProbitExchange --- .../exchange/probit/probit_exchange.py | 54 +++++-------------- 1 file changed, 14 insertions(+), 40 deletions(-) diff --git a/hummingbot/connector/exchange/probit/probit_exchange.py b/hummingbot/connector/exchange/probit/probit_exchange.py index ae3dd1274e..c93b5e76aa 100644 --- a/hummingbot/connector/exchange/probit/probit_exchange.py +++ b/hummingbot/connector/exchange/probit/probit_exchange.py @@ -319,35 +319,6 @@ def _format_trading_rules(self, market_info: Dict[str, Any]) -> Dict[str, Tradin self.logger().error(f"Error parsing the trading pair rule {market}. Skipping.", exc_info=True) return result - async def _get_auth_headers(self, http_client: aiohttp.ClientSession) -> Dict[str, Any]: - if self._probit_auth.token_has_expired: - try: - now: int = int(time.time()) - headers = self._probit_auth.get_headers() - headers.update({ - "Authorization": f"Basic {self._probit_auth.token_payload}" - }) - body = ujson.dumps({ - "grant_type": "client_credentials" - }) - resp = await http_client.post(url=CONSTANTS.TOKEN_URL, - headers=headers, - data=body) - token_resp = await resp.json() - - if resp.status != 200: - raise ValueError(f"Error occurred retrieving new OAuth Token. Response: {token_resp}") - - # POST /token endpoint returns both access_token and expires_in - # Updates _oauth_token_expiration_time - - self._probit_auth.update_expiration_time(now + token_resp["expires_in"]) - self._probit_auth.update_oauth_token(token_resp["access_token"]) - except Exception as e: - raise e - - return self._probit_auth.generate_auth_dict() - async def _api_request(self, method: str, path_url: str, @@ -365,20 +336,23 @@ async def _api_request(self, path_url = path_url.format(self._domain) client = await self._http_client() - if is_auth_required: - headers = await self._probit_auth.get_auth_headers(client) - else: - headers = self._probit_auth.get_headers() + try: + if is_auth_required: + headers = await self._probit_auth.get_auth_headers(client) + else: + headers = self._probit_auth.get_headers() - if method == "GET": - response = await client.get(path_url, headers=headers, params=params) - elif method == "POST": - response = await client.post(path_url, headers=headers, data=ujson.dumps(data)) - else: - raise NotImplementedError(f"{method} HTTP Method not implemented. ") + if method == "GET": + response = await client.get(path_url, headers=headers, params=params) + elif method == "POST": + response = await client.post(path_url, headers=headers, data=ujson.dumps(data)) + else: + raise NotImplementedError(f"{method} HTTP Method not implemented. ") - try: parsed_response = await response.json() + except ValueError as e: + self.logger().error(f"{str(e)}") + raise ValueError(f"Error authenticating request {method} {path_url}. Error: {str(e)}") except Exception as e: raise IOError(f"Error parsing data from {path_url}. Error: {str(e)}") if response.status != 200: From fae9fc057789f7c318c0b2f340f6f8a515282635 Mon Sep 17 00:00:00 2001 From: vic-en Date: Tue, 2 Mar 2021 11:03:23 +0100 Subject: [PATCH 109/131] (fix) add vaidator for amount parameter --- hummingbot/strategy/amm_arb/amm_arb_config_map.py | 1 + 1 file changed, 1 insertion(+) diff --git a/hummingbot/strategy/amm_arb/amm_arb_config_map.py b/hummingbot/strategy/amm_arb/amm_arb_config_map.py index d695ec7c55..2e648e0cb9 100644 --- a/hummingbot/strategy/amm_arb/amm_arb_config_map.py +++ b/hummingbot/strategy/amm_arb/amm_arb_config_map.py @@ -88,6 +88,7 @@ def order_amount_prompt() -> str: key="order_amount", prompt=order_amount_prompt, type_str="decimal", + validator=lambda v: validate_decimal(v, Decimal("0")), prompt_on_new=True), "min_profitability": ConfigVar( key="min_profitability", From c8f10203b7ec3c4649363c3ef95e78d07d19747d Mon Sep 17 00:00:00 2001 From: Kirill Stroukov Date: Tue, 2 Mar 2021 14:00:28 +0100 Subject: [PATCH 110/131] feat/Bexy fix empty history --- .../exchange/beaxy/beaxy_exchange.pyx | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/hummingbot/connector/exchange/beaxy/beaxy_exchange.pyx b/hummingbot/connector/exchange/beaxy/beaxy_exchange.pyx index 7d20066807..bffac62fcc 100644 --- a/hummingbot/connector/exchange/beaxy/beaxy_exchange.pyx +++ b/hummingbot/connector/exchange/beaxy/beaxy_exchange.pyx @@ -887,6 +887,29 @@ cdef class BeaxyExchange(ExchangeBase): )) elif order_status == 'completely_filled': + + self.c_trigger_event( + self.MARKET_ORDER_FILLED_EVENT_TAG, + OrderFilledEvent( + self._current_timestamp, + tracked_order.client_order_id, + tracked_order.trading_pair, + tracked_order.trade_type, + tracked_order.order_type, + execute_price, + execute_amount_diff, + self.c_get_fee( + tracked_order.base_asset, + tracked_order.quote_asset, + tracked_order.order_type, + tracked_order.trade_type, + execute_price, + execute_amount_diff, + ), + exchange_trade_id=exchange_order_id + ) + ) + if tracked_order.trade_type == TradeType.BUY: self.logger().info(f'The market buy order {tracked_order.client_order_id} has completed ' f'according to Beaxy user stream.') From efd6e7d669cba9bc3278483e438e3b6625828f6f Mon Sep 17 00:00:00 2001 From: Nicolas Baum Date: Thu, 4 Mar 2021 00:37:12 -0300 Subject: [PATCH 111/131] (fix) Mapped Kraken tickers to Hummingbot names in the balance update process --- hummingbot/connector/exchange/kraken/kraken_exchange.pyx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/hummingbot/connector/exchange/kraken/kraken_exchange.pyx b/hummingbot/connector/exchange/kraken/kraken_exchange.pyx index 4176e5f5e1..66fedfe487 100755 --- a/hummingbot/connector/exchange/kraken/kraken_exchange.pyx +++ b/hummingbot/connector/exchange/kraken/kraken_exchange.pyx @@ -230,9 +230,9 @@ cdef class KrakenExchange(ExchangeBase): (base, quote) = self.split_trading_pair(pair) vol_locked = Decimal(order.get("vol", 0)) - Decimal(order.get("vol_exec", 0)) if details.get("type") == "sell": - locked[base] += vol_locked + locked[convert_from_exchange_symbol(base)] += vol_locked elif details.get("type") == "buy": - locked[quote] += vol_locked * Decimal(details.get("price")) + locked[convert_from_exchange_symbol(quote)] += vol_locked * Decimal(details.get("price")) for asset_name, balance in balances.items(): cleaned_name = convert_from_exchange_symbol(asset_name).upper() From 8770d2018287cc8a1a714b4409425cd4ff5c6190 Mon Sep 17 00:00:00 2001 From: vic-en Date: Thu, 4 Mar 2021 10:33:19 +0100 Subject: [PATCH 112/131] (feat) ajusted precision of parameters of an active position --- .../perpetual_finance/perpetual_finance_derivative.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py index e8ea75a72a..73eea1569f 100644 --- a/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py +++ b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py @@ -507,11 +507,11 @@ async def _update_positions(self): positions = await safe_gather(*position_tasks, return_exceptions=True) for trading_pair, position in zip(self._trading_pairs, positions): position = position.get("position", {}) - amount = Decimal(position.get("size")) + amount = self.quantize_order_amount(trading_pair, Decimal(position.get("size"))) if amount != Decimal("0"): position_side = PositionSide.LONG if amount > 0 else PositionSide.SHORT - unrealized_pnl = Decimal(position.get("pnl")) - entry_price = Decimal(position.get("entryPrice")) + unrealized_pnl = self.quantize_order_amount(trading_pair, Decimal(position.get("pnl"))) + entry_price = self.quantize_order_price(trading_pair, Decimal(position.get("entryPrice"))) leverage = self._leverage[trading_pair] self._account_positions[trading_pair] = Position( trading_pair=trading_pair, From f0c402497ada0c1feccafc725b9388165fc2e960 Mon Sep 17 00:00:00 2001 From: vic-en Date: Thu, 4 Mar 2021 11:40:15 +0100 Subject: [PATCH 113/131] (feat) add test cases for perpfi connector --- .../connector/perpfi/test_perfi_connector.py | 209 ++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 test/connector/connector/perpfi/test_perfi_connector.py diff --git a/test/connector/connector/perpfi/test_perfi_connector.py b/test/connector/connector/perpfi/test_perfi_connector.py new file mode 100644 index 0000000000..dc0ec26c16 --- /dev/null +++ b/test/connector/connector/perpfi/test_perfi_connector.py @@ -0,0 +1,209 @@ +from os.path import join, realpath +import sys; sys.path.insert(0, realpath(join(__file__, "../../../../../"))) +import unittest +import unittest.mock +import asyncio +import os +from decimal import Decimal +from typing import List +import contextlib +import time +from hummingbot.core.clock import Clock, ClockMode +from hummingbot.core.event.event_logger import EventLogger +from hummingbot.connector.derivative.perpetual_finance.perpetual_finance_derivative import PerpetualFinanceDerivative +from hummingbot.core.event.events import ( + BuyOrderCompletedEvent, + MarketEvent, + OrderType, + SellOrderCompletedEvent, + MarketOrderFailureEvent, + PositionAction, PositionMode +) +from hummingbot.model.sql_connection_manager import ( + SQLConnectionManager, + SQLConnectionType +) +from hummingbot.model.trade_fill import TradeFill +from hummingbot.connector.markets_recorder import MarketsRecorder +from hummingbot.client.config.global_config_map import global_config_map + +global_config_map['gateway_api_host'].value = "localhost" +global_config_map['gateway_api_port'].value = 5000 +global_config_map.get("ethereum_chain_name").value = "mainnet" + +trading_pair = "SNX-USDC" +leverage = 3 +base, quote = trading_pair.split("-") + + +class PerpetualFinanceDerivativeUnitTest(unittest.TestCase): + event_logger: EventLogger + events: List[MarketEvent] = [ + MarketEvent.BuyOrderCompleted, + MarketEvent.SellOrderCompleted, + MarketEvent.OrderFilled, + MarketEvent.TransactionFailure, + MarketEvent.BuyOrderCreated, + MarketEvent.SellOrderCreated, + MarketEvent.OrderCancelled, + MarketEvent.OrderFailure, + MarketEvent.FundingPaymentCompleted + ] + connector: PerpetualFinanceDerivative + stack: contextlib.ExitStack + + @classmethod + def setUpClass(cls): + cls.ev_loop = asyncio.get_event_loop() + cls.clock: Clock = Clock(ClockMode.REALTIME) + cls.connector: PerpetualFinanceDerivative = PerpetualFinanceDerivative( + [trading_pair], + "PRIVATE_KEY_HERE", + "") + print("Initializing PerpetualFinanceDerivative market... this will take about a minute.") + cls.connector.set_leverage(trading_pair, leverage) + cls.connector.set_position_mode(PositionMode.ONEWAY) + cls.clock.add_iterator(cls.connector) + cls.stack: contextlib.ExitStack = contextlib.ExitStack() + cls._clock = cls.stack.enter_context(cls.clock) + cls.ev_loop.run_until_complete(cls.wait_til_ready()) + print("Ready.") + + @classmethod + def tearDownClass(cls) -> None: + cls.stack.close() + + @classmethod + async def wait_til_ready(cls): + while True: + now = time.time() + next_iteration = now // 1.0 + 1 + if cls.connector.ready: + break + else: + await cls._clock.run_til(next_iteration) + await asyncio.sleep(1.0) + + def setUp(self): + self.db_path: str = realpath(join(__file__, "../connector_test.sqlite")) + try: + os.unlink(self.db_path) + except FileNotFoundError: + pass + self.event_logger = EventLogger() + for event_tag in self.events: + self.connector.add_listener(event_tag, self.event_logger) + + def test_update_balances(self): + all_bals = self.connector.get_all_balances() + for token, bal in all_bals.items(): + print(f"{token}: {bal}") + self.assertIn(quote, all_bals) + self.assertTrue(all_bals["XDAI"] > 0) + + def test_allowances(self): + asyncio.get_event_loop().run_until_complete(self._test_allowances()) + + async def _test_allowances(self): + perfi = self.connector + allowances = await perfi.get_allowances() + print(allowances) + + def test_approve(self): + asyncio.get_event_loop().run_until_complete(self._test_approve()) + + async def _test_approve(self): + perfi = self.connector + ret_val = await perfi.approve_perpetual_finance_spender() + print(ret_val) + + def test_get_quote_price(self): + asyncio.get_event_loop().run_until_complete(self._test_get_quote_price()) + + async def _test_get_quote_price(self): + perfi = self.connector + buy_price = await perfi.get_quote_price(trading_pair, True, Decimal("1")) + self.assertTrue(buy_price > 0) + print(f"buy_price: {buy_price}") + sell_price = await perfi.get_quote_price(trading_pair, False, Decimal("1")) + self.assertTrue(sell_price > 0) + print(f"sell_price: {sell_price}") + self.assertTrue(buy_price != sell_price) + + def test_open_and_close_long_position(self): + perfi = self.connector + amount = Decimal("0.1") + price = Decimal("10") + order_id = perfi.buy(trading_pair, amount, OrderType.LIMIT, price, position_action=PositionAction.OPEN) + event = self.ev_loop.run_until_complete(self.event_logger.wait_for(BuyOrderCompletedEvent)) + self.assertTrue(event.order_id is not None) + self.assertEqual(order_id, event.order_id) + self.assertEqual(event.base_asset_amount, amount) + print(event.order_id) + + order_id = perfi.sell(trading_pair, amount, OrderType.LIMIT, price, position_action=PositionAction.CLOSE) + event = self.ev_loop.run_until_complete(self.event_logger.wait_for(SellOrderCompletedEvent)) + self.assertTrue(event.order_id is not None) + self.assertEqual(order_id, event.order_id) + self.assertEqual(event.base_asset_amount, amount) + print(event.order_id) + + def test_open_position_failure(self): + perfi = self.connector + # Since we don't have 1000000 xUSDC, this should trigger order failure + amount = Decimal("1000000") + price = Decimal("10") + order_id = perfi.sell(trading_pair, amount, OrderType.LIMIT, price, position_action=PositionAction.OPEN) + event = self.ev_loop.run_until_complete(self.event_logger.wait_for(MarketOrderFailureEvent)) + self.assertEqual(order_id, event.order_id) + + def test_filled_orders_recorded(self): + config_path = "test_config" + strategy_name = "test_strategy" + sql = SQLConnectionManager(SQLConnectionType.TRADE_FILLS, db_path=self.db_path) + recorder = MarketsRecorder(sql, [self.connector], config_path, strategy_name) + recorder.start() + try: + self.connector._in_flight_orders.clear() + self.assertEqual(0, len(self.connector.tracking_states)) + + price: Decimal = Decimal("10") # quote_price * Decimal("0.8") + price = self.connector.quantize_order_price(trading_pair, price) + + amount: Decimal = Decimal("0.1") + amount = self.connector.quantize_order_amount(trading_pair, amount) + + sell_order_id = self.connector.sell(trading_pair, amount, OrderType.LIMIT, price, position_action=PositionAction.OPEN) + self.ev_loop.run_until_complete(self.event_logger.wait_for(SellOrderCompletedEvent)) + self.ev_loop.run_until_complete(asyncio.sleep(1)) + + price: Decimal = Decimal("10") # quote_price * Decimal("0.8") + price = self.connector.quantize_order_price(trading_pair, price) + + buy_order_id = self.connector.buy(trading_pair, amount, OrderType.LIMIT, price, position_action=PositionAction.CLOSE) + self.ev_loop.run_until_complete(self.event_logger.wait_for(BuyOrderCompletedEvent)) + self.ev_loop.run_until_complete(asyncio.sleep(1)) + + # Query the persisted trade logs + trade_fills: List[TradeFill] = recorder.get_trades_for_config(config_path) + # self.assertGreaterEqual(len(trade_fills), 2) + fills: List[TradeFill] = [t for t in trade_fills if t.trade_type == "SELL"] + self.assertGreaterEqual(len(fills), 1) + self.assertEqual(amount, Decimal(str(fills[0].amount))) + # self.assertEqual(price, Decimal(str(fills[0].price))) + self.assertEqual(base, fills[0].base_asset) + self.assertEqual(quote, fills[0].quote_asset) + self.assertEqual(sell_order_id, fills[0].order_id) + self.assertEqual(trading_pair, fills[0].symbol) + fills: List[TradeFill] = [t for t in trade_fills if t.trade_type == "BUY"] + self.assertGreaterEqual(len(fills), 1) + self.assertEqual(amount, Decimal(str(fills[0].amount))) + # self.assertEqual(price, Decimal(str(fills[0].price))) + self.assertEqual(base, fills[0].base_asset) + self.assertEqual(quote, fills[0].quote_asset) + self.assertEqual(buy_order_id, fills[0].order_id) + self.assertEqual(trading_pair, fills[0].symbol) + + finally: + recorder.stop() + os.unlink(self.db_path) From 0ba1c599a1a2a1d7fb10293d378d7f82b2638dd3 Mon Sep 17 00:00:00 2001 From: Kirill Stroukov Date: Thu, 4 Mar 2021 18:39:58 +0100 Subject: [PATCH 114/131] feat/Bexy fix history stats --- .../exchange/beaxy/beaxy_exchange.pyx | 66 +++++++++++++++---- 1 file changed, 53 insertions(+), 13 deletions(-) diff --git a/hummingbot/connector/exchange/beaxy/beaxy_exchange.pyx b/hummingbot/connector/exchange/beaxy/beaxy_exchange.pyx index bffac62fcc..53f4d09608 100644 --- a/hummingbot/connector/exchange/beaxy/beaxy_exchange.pyx +++ b/hummingbot/connector/exchange/beaxy/beaxy_exchange.pyx @@ -359,25 +359,56 @@ cdef class BeaxyExchange(ExchangeBase): del self._order_not_found_records[client_order_id] continue - execute_price = Decimal(order_update['limit_price'] if order_update['limit_price'] else order_update['average_price']) - new_confirmed_amount = Decimal(order_update['size']) - # Update the tracked order - tracked_order.executed_amount_base = new_confirmed_amount - tracked_order.executed_amount_quote = new_confirmed_amount * execute_price tracked_order.last_state = order_update['order_status'] if order_update['filled_size']: + execute_price = Decimal(order_update['limit_price'] if order_update['limit_price'] else order_update['average_price']) execute_amount_diff = Decimal(order_update['filled_size']) - tracked_order.executed_amount_base + # Emit event if executed amount is greater than 0. if execute_amount_diff > s_decimal_0: - tracked_order.executed_amount_base = new_confirmed_amount + tracked_order.executed_amount_base = execute_amount_diff tracked_order.executed_amount_quote += execute_amount_diff * execute_price order_type_description = tracked_order.order_type_description + order_filled_event = OrderFilledEvent( + self._current_timestamp, + tracked_order.client_order_id, + tracked_order.trading_pair, + tracked_order.trade_type, + tracked_order.order_type, + execute_price, + execute_amount_diff, + self.c_get_fee( + tracked_order.base_asset, + tracked_order.quote_asset, + tracked_order.order_type, + tracked_order.trade_type, + execute_price, + execute_amount_diff, + ), + exchange_trade_id=exchange_order_id, + ) + self.logger().info(f'Filled {execute_amount_diff} out of {tracked_order.amount} of the ' + f'{order_type_description} order {client_order_id}.') + self.c_trigger_event(self.MARKET_ORDER_FILLED_EVENT_TAG, order_filled_event) + + if tracked_order.is_done: + if not tracked_order.is_failure and not tracked_order.is_cancelled: + + new_confirmed_amount = Decimal(order_update['size']) + execute_amount_diff = new_confirmed_amount - tracked_order.executed_amount_base + execute_price = Decimal(order_update['limit_price'] if order_update['limit_price'] else order_update['average_price']) + # Emit event if executed amount is greater than 0. if execute_amount_diff > s_decimal_0: + + tracked_order.executed_amount_base = execute_amount_diff + tracked_order.executed_amount_quote += execute_amount_diff * execute_price + + order_type_description = tracked_order.order_type_description order_filled_event = OrderFilledEvent( self._current_timestamp, tracked_order.client_order_id, @@ -400,8 +431,6 @@ cdef class BeaxyExchange(ExchangeBase): f'{order_type_description} order {client_order_id}.') self.c_trigger_event(self.MARKET_ORDER_FILLED_EVENT_TAG, order_filled_event) - if tracked_order.is_done: - if not tracked_order.is_failure and not tracked_order.is_cancelled: if tracked_order.trade_type == TradeType.BUY: self.logger().info(f'The market buy order {tracked_order.client_order_id} has completed ' f'according to order status API.') @@ -888,9 +917,18 @@ cdef class BeaxyExchange(ExchangeBase): elif order_status == 'completely_filled': - self.c_trigger_event( - self.MARKET_ORDER_FILLED_EVENT_TAG, - OrderFilledEvent( + new_confirmed_amount = Decimal(order['size']) + execute_amount_diff = new_confirmed_amount - tracked_order.executed_amount_base + execute_price = Decimal(order['limit_price'] if order['limit_price'] else order['average_price']) + + # Emit event if executed amount is greater than 0. + if execute_amount_diff > s_decimal_0: + + tracked_order.executed_amount_base = execute_amount_diff + tracked_order.executed_amount_quote += execute_amount_diff * execute_price + + order_type_description = tracked_order.order_type_description + order_filled_event = OrderFilledEvent( self._current_timestamp, tracked_order.client_order_id, tracked_order.trading_pair, @@ -906,9 +944,11 @@ cdef class BeaxyExchange(ExchangeBase): execute_price, execute_amount_diff, ), - exchange_trade_id=exchange_order_id + exchange_trade_id=exchange_order_id, ) - ) + self.logger().info(f'Filled {execute_amount_diff} out of {tracked_order.amount} of the ' + f'{order_type_description} order {client_order_id}.') + self.c_trigger_event(self.MARKET_ORDER_FILLED_EVENT_TAG, order_filled_event) if tracked_order.trade_type == TradeType.BUY: self.logger().info(f'The market buy order {tracked_order.client_order_id} has completed ' From e6aacbb327a54ba87979e7786c3f88cd7325656f Mon Sep 17 00:00:00 2001 From: vic-en Date: Sat, 6 Mar 2021 12:24:46 +0100 Subject: [PATCH 115/131] (fix) remove buggy check for multiple filled orders --- .../exchange/binance/binance_exchange.pyx | 74 ++++++++----------- .../binance/binance_in_flight_order.pyx | 3 +- 2 files changed, 31 insertions(+), 46 deletions(-) diff --git a/hummingbot/connector/exchange/binance/binance_exchange.pyx b/hummingbot/connector/exchange/binance/binance_exchange.pyx index 830aaafa10..ec048f8475 100755 --- a/hummingbot/connector/exchange/binance/binance_exchange.pyx +++ b/hummingbot/connector/exchange/binance/binance_exchange.pyx @@ -531,43 +531,34 @@ cdef class BinanceExchange(ExchangeBase): if tracked_order.is_done: if not tracked_order.is_failure: - exchange_trade_id = next(iter(tracked_order.trade_id_set)) - exchange_order_id = tracked_order.exchange_order_id - if self.is_confirmed_new_order_filled_event(str(exchange_trade_id), - str(exchange_order_id), - tracked_order.trading_pair): - if tracked_order.trade_type is TradeType.BUY: - self.logger().info(f"The market buy order {tracked_order.client_order_id} has completed " - f"according to order status API.") - self.c_trigger_event(self.MARKET_BUY_ORDER_COMPLETED_EVENT_TAG, - BuyOrderCompletedEvent(self._current_timestamp, - client_order_id, - tracked_order.base_asset, - tracked_order.quote_asset, - (tracked_order.fee_asset - or tracked_order.base_asset), - executed_amount_base, - executed_amount_quote, - tracked_order.fee_paid, - order_type)) - else: - self.logger().info(f"The market sell order {client_order_id} has completed " - f"according to order status API.") - self.c_trigger_event(self.MARKET_SELL_ORDER_COMPLETED_EVENT_TAG, - SellOrderCompletedEvent(self._current_timestamp, - client_order_id, - tracked_order.base_asset, - tracked_order.quote_asset, - (tracked_order.fee_asset - or tracked_order.quote_asset), - executed_amount_base, - executed_amount_quote, - tracked_order.fee_paid, - order_type)) + if tracked_order.trade_type is TradeType.BUY: + self.logger().info(f"The market buy order {tracked_order.client_order_id} has completed " + f"according to order status API.") + self.c_trigger_event(self.MARKET_BUY_ORDER_COMPLETED_EVENT_TAG, + BuyOrderCompletedEvent(self._current_timestamp, + client_order_id, + tracked_order.base_asset, + tracked_order.quote_asset, + (tracked_order.fee_asset + or tracked_order.base_asset), + executed_amount_base, + executed_amount_quote, + tracked_order.fee_paid, + order_type)) else: - self.logger().info( - f"The market order {tracked_order.client_order_id} was already filled, or order was not submitted by hummingbot." - f"Ignoring trade filled event in update_order_status.") + self.logger().info(f"The market sell order {client_order_id} has completed " + f"according to order status API.") + self.c_trigger_event(self.MARKET_SELL_ORDER_COMPLETED_EVENT_TAG, + SellOrderCompletedEvent(self._current_timestamp, + client_order_id, + tracked_order.base_asset, + tracked_order.quote_asset, + (tracked_order.fee_asset + or tracked_order.quote_asset), + executed_amount_base, + executed_amount_quote, + tracked_order.fee_paid, + order_type)) else: # check if its a cancelled order # if its a cancelled order, issue cancel and stop tracking order @@ -646,20 +637,13 @@ cdef class BinanceExchange(ExchangeBase): self.logger().debug(f"Event: {event_message}") continue - tracked_order.update_with_execution_report(event_message) + unique_update = tracked_order.update_with_execution_report(event_message) if execution_type == "TRADE": order_filled_event = OrderFilledEvent.order_filled_event_from_binance_execution_report(event_message) order_filled_event = order_filled_event._replace(trading_pair=convert_from_exchange_trading_pair(order_filled_event.trading_pair)) - exchange_trade_id = next(iter(tracked_order.trade_id_set)) - if self.is_confirmed_new_order_filled_event(str(exchange_trade_id), str(tracked_order.exchange_order_id), tracked_order.trading_pair): + if unique_update: self.c_trigger_event(self.MARKET_ORDER_FILLED_EVENT_TAG, order_filled_event) - else: - self.logger().info( - f"The market order {tracked_order.client_order_id} was already filled or order was not submitted by hummingbot." - f"Ignoring trade filled event of exchange trade id {str(exchange_trade_id)} in user stream.") - self.c_stop_tracking_order(tracked_order.client_order_id) - continue if tracked_order.is_done: if not tracked_order.is_failure: diff --git a/hummingbot/connector/exchange/binance/binance_in_flight_order.pyx b/hummingbot/connector/exchange/binance/binance_in_flight_order.pyx index e8a6044dae..085438160d 100644 --- a/hummingbot/connector/exchange/binance/binance_in_flight_order.pyx +++ b/hummingbot/connector/exchange/binance/binance_in_flight_order.pyx @@ -70,7 +70,7 @@ cdef class BinanceInFlightOrder(InFlightOrderBase): trade_id = execution_report["t"] if trade_id in self.trade_id_set: # trade already recorded - return + return False self.trade_id_set.add(trade_id) last_executed_quantity = Decimal(execution_report["l"]) last_commission_amount = Decimal(execution_report["n"]) @@ -84,6 +84,7 @@ cdef class BinanceInFlightOrder(InFlightOrderBase): self.fee_asset = last_commission_asset self.fee_paid += last_commission_amount self.last_state = last_order_state + return True def update_with_trade_update(self, trade_update: Dict[str, Any]): trade_id = trade_update["id"] From 297f46772bf8df6b83ff7a5c5dcb9169e9d8853c Mon Sep 17 00:00:00 2001 From: RC-13 Date: Mon, 8 Mar 2021 11:02:22 +0800 Subject: [PATCH 116/131] (doc) connector status update --- README.md | 37 ++++++++++++++++-------------- assets/beaxy_logo.png | Bin 0 -> 110528 bytes assets/perpetual_finance_logo.png | Bin 0 -> 6639 bytes assets/probit_global_logo.png | Bin 0 -> 7319 bytes assets/probit_korea_logo.png | Bin 0 -> 8238 bytes 5 files changed, 20 insertions(+), 17 deletions(-) create mode 100644 assets/beaxy_logo.png create mode 100644 assets/perpetual_finance_logo.png create mode 100644 assets/probit_global_logo.png create mode 100644 assets/probit_korea_logo.png diff --git a/README.md b/README.md index 99c5c36251..55978739cf 100644 --- a/README.md +++ b/README.md @@ -24,23 +24,25 @@ We created hummingbot to promote **decentralized market-making**: enabling membe | logo | id | name | ver | doc | status | |:---:|:---:|:---:|:---:|:---:|:---:| -| Binance | binance | [Binance](https://www.binance.com/) | 3 | [API](https://github.com/binance/binance-spot-api-docs/blob/master/rest-api.md) | ![GREEN](https://via.placeholder.com/15/008000/?text=+) | -| Binance US | binance_us | [Binance US](https://www.binance.com/) | 3 | [API](https://github.com/binance-us/binance-official-api-docs/blob/master/rest-api.md) | ![YELLOW](https://via.placeholder.com/15/ffff00/?text=+) | -| Binance Perpetual | binance_perpetual | [Binance Futures](https://www.binance.com/) | 1 | [API](https://binance-docs.github.io/apidocs/futures/en/) | ![GREEN](https://via.placeholder.com/15/008000/?text=+) | -|Bittrex Global| bittrex | [Bittrex Global](https://global.bittrex.com/) | 3 | [API](https://bittrex.github.io/api/v3) | ![YELLOW](https://via.placeholder.com/15/ffff00/?text=+) | -| Bitfinex | bitfinex | [Bitfinex](https://www.bitfinex.com/) | 2 | [API](https://docs.bitfinex.com/docs/introduction) | ![YELLOW](https://via.placeholder.com/15/ffff00/?text=+) | -| BitMax | bitmax | [BitMax](https://bitmax.io/en/global-digital-asset-platform) | 1 | [API](https://bitmax-exchange.github.io/bitmax-pro-api/#bitmax-pro-api-documentation) | ![YELLOW](https://via.placeholder.com/15/ffff00/?text=+) | -| Blocktane | blocktane | [Blocktane](https://blocktane.io/) | 2 | [API](https://blocktane.io/api) | ![YELLOW](https://via.placeholder.com/15/ffff00/?text=+) | -| Coinbase Pro | coinbase_pro | [Coinbase Pro](https://pro.coinbase.com/) | * | [API](https://docs.pro.coinbase.com/) | ![GREEN](https://via.placeholder.com/15/008000/?text=+) | -| Crypto.com | crypto_com | [Crypto.com](https://crypto.com/exchange) | 2 | [API](https://exchange-docs.crypto.com/#introduction) | ![YELLOW](https://via.placeholder.com/15/ffff00/?text=+) | -| DyDx | dy/dx | [dy/dx](https://dydx.exchange/) | 1 | [API](https://docs.dydx.exchange/) | ![GREEN](https://via.placeholder.com/15/008000/?text=+) | -| Eterbase | eterbase | [Eterbase](https://www.eterbase.com/) | * | [API](https://developers.eterbase.exchange/?version=latest) | ![RED](https://via.placeholder.com/15/f03c15/?text=+) | -|Huobi Global| huobi | [Huobi Global](https://www.hbg.com) | 1 | [API](https://huobiapi.github.io/docs/spot/v1/en/) | ![GREEN](https://via.placeholder.com/15/008000/?text=+) | -| KuCoin | kucoin | [KuCoin](https://www.kucoin.com/) | 1 | [API](https://docs.kucoin.com/#general) | ![GREEN](https://via.placeholder.com/15/008000/?text=+) | -| Kraken | kraken | [Kraken](https://www.kraken.com/) | 1 | [API](https://www.kraken.com/features/api) | ![YELLOW](https://via.placeholder.com/15/ffff00/?text=+) | -| Liquid | liquid | [Liquid](https://www.liquid.com/) | 2 | [API](https://developers.liquid.com/) | ![GREEN](https://via.placeholder.com/15/008000/?text=+) | -| OKEx | okex | [OKEx](https://www.okex.com/) | 3 | [API](https://www.okex.com/docs/en/) | ![GREEN](https://via.placeholder.com/15/008000/?text=+) | - +| Beaxy | beaxy | [Beaxy](https://beaxy.com/) | 2 | [API](https://beaxyapiv2trading.docs.apiary.io/) |![YELLOW](https://via.placeholder.com/15/ffff00/?text=+) | +| Binance | binance | [Binance](https://www.binance.com/) | 3 | [API](https://github.com/binance/binance-spot-api-docs/blob/master/rest-api.md) |![GREEN](https://via.placeholder.com/15/008000/?text=+) | +| Binance US | binance_us | [Binance US](https://www.binance.com/) | 3 | [API](https://github.com/binance-us/binance-official-api-docs/blob/master/rest-api.md) |![YELLOW](https://via.placeholder.com/15/ffff00/?text=+) | +| Binance Perpetual | binance_perpetual | [Binance Futures](https://www.binance.com/) | 1 | [API](https://binance-docs.github.io/apidocs/futures/en/) |![YELLOW](https://via.placeholder.com/15/ffff00/?text=+) | +|Bittrex Global| bittrex | [Bittrex Global](https://global.bittrex.com/) | 3 | [API](https://bittrex.github.io/api/v3) |![YELLOW](https://via.placeholder.com/15/ffff00/?text=+) | +| Bitfinex | bitfinex | [Bitfinex](https://www.bitfinex.com/) | 2 | [API](https://docs.bitfinex.com/docs/introduction) |![YELLOW](https://via.placeholder.com/15/ffff00/?text=+) | +| BitMax | bitmax | [BitMax](https://bitmax.io/en/global-digital-asset-platform) | 1 | [API](https://bitmax-exchange.github.io/bitmax-pro-api/#bitmax-pro-api-documentation) |![GREEN](https://via.placeholder.com/15/008000/?text=+) | +| Blocktane | blocktane | [Blocktane](https://blocktane.io/) | 2 | [API](https://blocktane.io/api) |![GREEN](https://via.placeholder.com/15/008000/?text=+) | +| Coinbase Pro | coinbase_pro | [Coinbase Pro](https://pro.coinbase.com/) | * | [API](https://docs.pro.coinbase.com/) |![GREEN](https://via.placeholder.com/15/008000/?text=+) | +| Crypto.com | crypto_com | [Crypto.com](https://crypto.com/exchange) | 2 | [API](https://exchange-docs.crypto.com/#introduction) |![YELLOW](https://via.placeholder.com/15/ffff00/?text=+) | +| DyDx | dy/dx | [dy/dx](https://dydx.exchange/) | 1 | [API](https://docs.dydx.exchange/) |![GREEN](https://via.placeholder.com/15/008000/?text=+) | +| Eterbase | eterbase | [Eterbase](https://www.eterbase.com/) | * | [API](https://developers.eterbase.exchange/?version=latest) |![RED](https://via.placeholder.com/15/f03c15/?text=+) | +|Huobi Global| huobi | [Huobi Global](https://www.hbg.com) | 1 | [API](https://huobiapi.github.io/docs/spot/v1/en/) |![GREEN](https://via.placeholder.com/15/008000/?text=+) | +| KuCoin | kucoin | [KuCoin](https://www.kucoin.com/) | 1 | [API](https://docs.kucoin.com/#general) |![GREEN](https://via.placeholder.com/15/008000/?text=+) | +| Kraken | kraken | [Kraken](https://www.kraken.com/) | 1 | [API](https://www.kraken.com/features/api) |![GREEN](https://via.placeholder.com/15/008000/?text=+) | +| Liquid | liquid | [Liquid](https://www.liquid.com/) | 2 | [API](https://developers.liquid.com/) |![GREEN](https://via.placeholder.com/15/008000/?text=+) | +| OKEx | okex | [OKEx](https://www.okex.com/) | 3 | [API](https://www.okex.com/docs/en/) |![GREEN](https://via.placeholder.com/15/008000/?text=+) | +| Probit Global | probit global | [Probit Global](https://www.probit.com/en-us/) | 1 | [API](https://docs-en.probit.com/docs) |![YELLOW](https://via.placeholder.com/15/ffff00/?text=+) | +| Probit Korea | probit korea | [Probit Korea](https://www.probit.kr/en-us/) | 1 | [API](https://docs-en.probit.com/docs) |![YELLOW](https://via.placeholder.com/15/ffff00/?text=+) | ## Supported decentralized exchanges @@ -58,6 +60,7 @@ We created hummingbot to promote **decentralized market-making**: enabling membe |:---:|:---:|:---:|:---:|:---:|:--:| | Celo | celo | [Celo](https://terra.money/) | * | [SDK](https://celo.org/developers) | ![GREEN](https://via.placeholder.com/15/008000/?text=+) | | Balancer | balancer | [Balancer](https://balancer.finance/) | * | [SDK](https://docs.balancer.finance/) | ![GREEN](https://via.placeholder.com/15/008000/?text=+) | +| Perpetual Protocol | perpetual protocol | [Perpetual Protocol](https://perp.fi/) | * | [SDK](https://docs.perp.fi/) | ![YELLOW](https://via.placeholder.com/15/ffff00/?text=+) | | Terra | terra | [Terra](https://terra.money/) | * | [SDK](https://docs.terra.money/) | ![GREEN](https://via.placeholder.com/15/008000/?text=+) | | Uniswap | uniswap | [Uniswap](https://uniswap.org/) | * | [SDK](https://uniswap.org/docs/v2/) | ![GREEN](https://via.placeholder.com/15/008000/?text=+) | diff --git a/assets/beaxy_logo.png b/assets/beaxy_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..921da0cd88ae730f13ab0409d1f4a19f13fe6976 GIT binary patch literal 110528 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

=I1oA5W@3|LaaL={y*Vo$p~7CW&M8V-8fk zXr&|rtZvT$DQN}aFBpD5Zd~@*OUG(G+|gf3xiu2C228gVd0(nI%R`S^Z#zPuTGH@n z0rj>`e*`1HXaX(RW1JJFEF#rsm+Pxo9M35I66^5i!VNLV+#!cdo(j_rQNIVfHDZF% zLUynYG+440;iZK?PM!Eg9pzYVR@jxhjG!P#{LM(*6V^`{U4cZaNyr4g>mlD(Wnfc5 z)un}zx=48OG1d{bKnrt%1b?LvjL}pQuzuvF3(}WinjiAib32RF<13qfby`(rt|#zK z9XcFbl8z2Dbd%F(rq=0#-nA?$iGE~&GC&z1%#dbCb0m2Z`d*|6k|a|NsUdwf9~+JEo{TfIB{o zh|B@GMTJ#}NHN&OcL&&vFBQQoNP(fFIER`-Xz@5wbUP$g>#Sr@z_Rh^jj$dR;!fuM zj0Y0&3uA#po-2(^`%gwt%9AV>MZc6%%(W!B3DWBki$~@DwMg_eq#I95BrXLD8z?|Z zNEGcsH^`>vT88?UCj8OR$B_#?@Bj$HFu;%6UR(zZbMVpap%+W6&%wJ%pGX*ZiO{x* z7PjDo0l32bKg;F+-|Igh@Q-v(J;vd7yM;pl%+TRzPXM4=4VA*pZXpG{nm^kHP3AQR z<3&K+9Hv}K1{tlM(_qH&8(?AmW{33LcWMKmumylo!a=Fy!k<{$=4+PcOC78zt`OMx znT&4it8F7}3Cme092%64=O=z!L5au3UfHm3R$gD5G%9l~c6IlEuk(K|JW&FuEV%l| zY&FNCZaa1IKA?^*ZJ&JcgOOc%U+?^QH@IA@PQU-VT$ZFbf|xo*C47Awsj|k07)@@f zcWFL3v`Y{kVUq|jaAb;m358ihls>&MEO!w!XNu^V`4UNi)Op%UGzp2%{7_SXept@< zA49Zf51;JYjWYacsmnyU7sEfxvS>yE?~E_07%Qy$KM=z>MtDda)UbL>!AE)}ZH|Su zIuey={K%;_xG}JGgo9`Pr#|ddQOqtI|2U~1?pv(eya$W7+ek+?kUH_;uyYG8^ic5K_s@*`uP~s=cuLiP1GwuT3jDwERfVWF%h-HHbIc

23ZMJQDx!rlcPxv3t8eN+(Fv1imK^9JWHH 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 UnbOB;2=Gr@TtTct#PI9?0QS!xcmMzZ literal 0 HcmV?d00001 diff --git a/assets/perpetual_finance_logo.png b/assets/perpetual_finance_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..021f55b5a95a707444891c5eab8f156a8662b198 GIT binary patch literal 6639 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 Ua&P-%!M_fDJp(*i-D2tg4{nb{AOHXW literal 0 HcmV?d00001 diff --git a/assets/probit_korea_logo.png b/assets/probit_korea_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..d5be53c1f513621bf732943589e523a0ea45254e GIT binary patch literal 8238 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 literal 0 HcmV?d00001 From 491fdf4549839862c7fec7603a7fa46160f2ff76 Mon Sep 17 00:00:00 2001 From: Nullably <37262506+Nullably@users.noreply.github.com> Date: Mon, 8 Mar 2021 12:05:41 +0800 Subject: [PATCH 117/131] (fix) update to get_funding_info --- .../perpetual_market_making/perpetual_market_making.pyx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hummingbot/strategy/perpetual_market_making/perpetual_market_making.pyx b/hummingbot/strategy/perpetual_market_making/perpetual_market_making.pyx index 5f7b879d21..c9420143a4 100644 --- a/hummingbot/strategy/perpetual_market_making/perpetual_market_making.pyx +++ b/hummingbot/strategy/perpetual_market_making/perpetual_market_making.pyx @@ -922,7 +922,7 @@ cdef class PerpetualMarketMakingStrategy(StrategyBase): object base_size_total = Decimal("0") quote_balance = market.c_get_available_balance(self.quote_asset) - funding_rate = market.get_funding_rate(self.trading_pair) + funding_rate = Decimal(str(market.get_funding_info(self.trading_pair)["rate"])) trading_fees = market.c_get_fee(self.base_asset, self.quote_asset, OrderType.LIMIT, TradeType.BUY, s_decimal_zero, s_decimal_zero) From a12dd4449b516912936b754a09e0cd2a7d4a2390 Mon Sep 17 00:00:00 2001 From: RC-13 Date: Mon, 8 Mar 2021 12:29:37 +0800 Subject: [PATCH 118/131] (doc) minor edit for binance future --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 55978739cf..77d7cc5c3b 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ We created hummingbot to promote **decentralized market-making**: enabling membe | Beaxy | beaxy | [Beaxy](https://beaxy.com/) | 2 | [API](https://beaxyapiv2trading.docs.apiary.io/) |![YELLOW](https://via.placeholder.com/15/ffff00/?text=+) | | Binance | binance | [Binance](https://www.binance.com/) | 3 | [API](https://github.com/binance/binance-spot-api-docs/blob/master/rest-api.md) |![GREEN](https://via.placeholder.com/15/008000/?text=+) | | Binance US | binance_us | [Binance US](https://www.binance.com/) | 3 | [API](https://github.com/binance-us/binance-official-api-docs/blob/master/rest-api.md) |![YELLOW](https://via.placeholder.com/15/ffff00/?text=+) | -| Binance Perpetual | binance_perpetual | [Binance Futures](https://www.binance.com/) | 1 | [API](https://binance-docs.github.io/apidocs/futures/en/) |![YELLOW](https://via.placeholder.com/15/ffff00/?text=+) | +| Binance Perpetual | binance_perpetual | [Binance Futures](https://www.binance.com/) | 1 | [API](https://binance-docs.github.io/apidocs/futures/en/) |![GREEN](https://via.placeholder.com/15/008000/?text=+) | |Bittrex Global| bittrex | [Bittrex Global](https://global.bittrex.com/) | 3 | [API](https://bittrex.github.io/api/v3) |![YELLOW](https://via.placeholder.com/15/ffff00/?text=+) | | Bitfinex | bitfinex | [Bitfinex](https://www.bitfinex.com/) | 2 | [API](https://docs.bitfinex.com/docs/introduction) |![YELLOW](https://via.placeholder.com/15/ffff00/?text=+) | | BitMax | bitmax | [BitMax](https://bitmax.io/en/global-digital-asset-platform) | 1 | [API](https://bitmax-exchange.github.io/bitmax-pro-api/#bitmax-pro-api-documentation) |![GREEN](https://via.placeholder.com/15/008000/?text=+) | From f55fe23afc73ed192d499e42429469aa8f346089 Mon Sep 17 00:00:00 2001 From: RC-13 Date: Mon, 8 Mar 2021 12:42:08 +0800 Subject: [PATCH 119/131] (refactor) add new connectors and update status --- hummingbot/connector/connector_status.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/hummingbot/connector/connector_status.py b/hummingbot/connector/connector_status.py index 31b83553aa..aa29174b7c 100644 --- a/hummingbot/connector/connector_status.py +++ b/hummingbot/connector/connector_status.py @@ -1,14 +1,16 @@ #!/usr/bin/env python connector_status = { + 'balancer':'green', + 'beaxy': 'yellow', 'binance': 'green', 'binance_perpetual': 'green', 'binance_perpetual_testnet': 'green', 'binance_us': 'yellow', 'bitfinex': 'yellow', - 'bitmax': 'yellow', + 'bitmax': 'green', 'bittrex': 'yellow', - 'blocktane': 'yellow', + 'blocktane': 'green', 'celo': 'green', 'coinbase_pro': 'green', 'crypto_com': 'yellow', @@ -16,13 +18,16 @@ 'eterbase': 'red', 'ethereum': 'red', 'huobi': 'green', - 'kraken': 'yellow', + 'kraken': 'green', 'kucoin': 'green', 'liquid': 'green', 'loopring': 'yellow', - 'probit': 'yellow', 'okex': 'green', - 'terra': 'green' + 'perpetual_finance': 'yellow', + 'probit': 'yellow', + 'probit_kr': 'yellow', + 'terra': 'green', + 'uniswap':'green' } warning_messages = { From 6d585d0a4b0b065ef32e75068e5a56a3e9e646aa Mon Sep 17 00:00:00 2001 From: RC-13 Date: Mon, 8 Mar 2021 12:57:19 +0800 Subject: [PATCH 120/131] (fix) flake8 error --- hummingbot/connector/connector_status.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/hummingbot/connector/connector_status.py b/hummingbot/connector/connector_status.py index aa29174b7c..f220808b1e 100644 --- a/hummingbot/connector/connector_status.py +++ b/hummingbot/connector/connector_status.py @@ -1,7 +1,7 @@ #!/usr/bin/env python connector_status = { - 'balancer':'green', + 'balancer': 'green', 'beaxy': 'yellow', 'binance': 'green', 'binance_perpetual': 'green', @@ -27,7 +27,7 @@ 'probit': 'yellow', 'probit_kr': 'yellow', 'terra': 'green', - 'uniswap':'green' + 'uniswap': 'green' } warning_messages = { From 11f443920411fa4165a99655525e575ac0a221c6 Mon Sep 17 00:00:00 2001 From: sdgoh Date: Mon, 8 Mar 2021 16:03:43 +0800 Subject: [PATCH 121/131] (feat) Update gateway script to include Perp Finance in Ethereum type --- installation/docker-commands/create-gateway.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/installation/docker-commands/create-gateway.sh b/installation/docker-commands/create-gateway.sh index 647b8c7797..c6db0bae8c 100755 --- a/installation/docker-commands/create-gateway.sh +++ b/installation/docker-commands/create-gateway.sh @@ -106,10 +106,10 @@ read_global_config # prompt to setup balancer, uniswap prompt_ethereum_setup () { - read -p " Do you want to setup Balancer or Uniswap? [Y/N] >>> " PROCEED + read -p " Do you want to setup Balancer/Uniswap/Perpetual Finance? [Y/N] >>> " PROCEED if [[ "$PROCEED" == "Y" || "$PROCEED" == "y" ]] then - echo "ℹ️ Retrieving Balancer/Uniswap config from Hummingbot config file ... " + echo "ℹ️ Retrieving config from Hummingbot config file ... " ETHEREUM_SETUP=true echo fi From 72b78917fdbd1947e1a43dc8d724127049100e66 Mon Sep 17 00:00:00 2001 From: Nullably <37262506+Nullably@users.noreply.github.com> Date: Mon, 8 Mar 2021 16:40:00 +0800 Subject: [PATCH 122/131] (fix) check for perpetual finance balances --- hummingbot/client/command/history_command.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/hummingbot/client/command/history_command.py b/hummingbot/client/command/history_command.py index 9099929e89..82062982c8 100644 --- a/hummingbot/client/command/history_command.py +++ b/hummingbot/client/command/history_command.py @@ -90,6 +90,8 @@ async def get_current_balances(self, # type: HummingbotApplication if paper_balances is None: return {} return {token: Decimal(str(bal)) for token, bal in paper_balances.items()} + elif "perpetual_finance" == market: + return await UserBalances.xdai_balances() else: gateway_eth_connectors = [cs.name for cs in CONNECTOR_SETTINGS.values() if cs.use_ethereum_wallet and cs.type == ConnectorType.Connector] From c103564a3d113f7fd63152894618d03b3dfae3f3 Mon Sep 17 00:00:00 2001 From: RC-13 Date: Mon, 8 Mar 2021 17:25:51 +0800 Subject: [PATCH 123/131] (doc) minor edit on some connector ID --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 77d7cc5c3b..10417f0950 100644 --- a/README.md +++ b/README.md @@ -34,15 +34,15 @@ We created hummingbot to promote **decentralized market-making**: enabling membe | Blocktane | blocktane | [Blocktane](https://blocktane.io/) | 2 | [API](https://blocktane.io/api) |![GREEN](https://via.placeholder.com/15/008000/?text=+) | | Coinbase Pro | coinbase_pro | [Coinbase Pro](https://pro.coinbase.com/) | * | [API](https://docs.pro.coinbase.com/) |![GREEN](https://via.placeholder.com/15/008000/?text=+) | | Crypto.com | crypto_com | [Crypto.com](https://crypto.com/exchange) | 2 | [API](https://exchange-docs.crypto.com/#introduction) |![YELLOW](https://via.placeholder.com/15/ffff00/?text=+) | -| DyDx | dy/dx | [dy/dx](https://dydx.exchange/) | 1 | [API](https://docs.dydx.exchange/) |![GREEN](https://via.placeholder.com/15/008000/?text=+) | +| DyDx | dydx | [dy/dx](https://dydx.exchange/) | 1 | [API](https://docs.dydx.exchange/) |![GREEN](https://via.placeholder.com/15/008000/?text=+) | | Eterbase | eterbase | [Eterbase](https://www.eterbase.com/) | * | [API](https://developers.eterbase.exchange/?version=latest) |![RED](https://via.placeholder.com/15/f03c15/?text=+) | |Huobi Global| huobi | [Huobi Global](https://www.hbg.com) | 1 | [API](https://huobiapi.github.io/docs/spot/v1/en/) |![GREEN](https://via.placeholder.com/15/008000/?text=+) | | KuCoin | kucoin | [KuCoin](https://www.kucoin.com/) | 1 | [API](https://docs.kucoin.com/#general) |![GREEN](https://via.placeholder.com/15/008000/?text=+) | | Kraken | kraken | [Kraken](https://www.kraken.com/) | 1 | [API](https://www.kraken.com/features/api) |![GREEN](https://via.placeholder.com/15/008000/?text=+) | | Liquid | liquid | [Liquid](https://www.liquid.com/) | 2 | [API](https://developers.liquid.com/) |![GREEN](https://via.placeholder.com/15/008000/?text=+) | | OKEx | okex | [OKEx](https://www.okex.com/) | 3 | [API](https://www.okex.com/docs/en/) |![GREEN](https://via.placeholder.com/15/008000/?text=+) | -| Probit Global | probit global | [Probit Global](https://www.probit.com/en-us/) | 1 | [API](https://docs-en.probit.com/docs) |![YELLOW](https://via.placeholder.com/15/ffff00/?text=+) | -| Probit Korea | probit korea | [Probit Korea](https://www.probit.kr/en-us/) | 1 | [API](https://docs-en.probit.com/docs) |![YELLOW](https://via.placeholder.com/15/ffff00/?text=+) | +| Probit Global | probit | [Probit Global](https://www.probit.com/en-us/) | 1 | [API](https://docs-en.probit.com/docs) |![YELLOW](https://via.placeholder.com/15/ffff00/?text=+) | +| Probit Korea | probit_kr | [Probit Korea](https://www.probit.kr/en-us/) | 1 | [API](https://docs-en.probit.com/docs) |![YELLOW](https://via.placeholder.com/15/ffff00/?text=+) | ## Supported decentralized exchanges @@ -60,7 +60,7 @@ We created hummingbot to promote **decentralized market-making**: enabling membe |:---:|:---:|:---:|:---:|:---:|:--:| | Celo | celo | [Celo](https://terra.money/) | * | [SDK](https://celo.org/developers) | ![GREEN](https://via.placeholder.com/15/008000/?text=+) | | Balancer | balancer | [Balancer](https://balancer.finance/) | * | [SDK](https://docs.balancer.finance/) | ![GREEN](https://via.placeholder.com/15/008000/?text=+) | -| Perpetual Protocol | perpetual protocol | [Perpetual Protocol](https://perp.fi/) | * | [SDK](https://docs.perp.fi/) | ![YELLOW](https://via.placeholder.com/15/ffff00/?text=+) | +| Perpetual Protocol | perpetual_finance | [Perpetual Protocol](https://perp.fi/) | * | [SDK](https://docs.perp.fi/) | ![YELLOW](https://via.placeholder.com/15/ffff00/?text=+) | | Terra | terra | [Terra](https://terra.money/) | * | [SDK](https://docs.terra.money/) | ![GREEN](https://via.placeholder.com/15/008000/?text=+) | | Uniswap | uniswap | [Uniswap](https://uniswap.org/) | * | [SDK](https://uniswap.org/docs/v2/) | ![GREEN](https://via.placeholder.com/15/008000/?text=+) | From c7e43197daa4d50852d8272190d2eef408031cd5 Mon Sep 17 00:00:00 2001 From: vic-en Date: Mon, 8 Mar 2021 11:10:04 +0100 Subject: [PATCH 124/131] (refactor) remove check that ensures that margin can cover for possible funding fee --- .../perpetual_market_making/perpetual_market_making.pyx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/hummingbot/strategy/perpetual_market_making/perpetual_market_making.pyx b/hummingbot/strategy/perpetual_market_making/perpetual_market_making.pyx index c9420143a4..7a842f6d2e 100644 --- a/hummingbot/strategy/perpetual_market_making/perpetual_market_making.pyx +++ b/hummingbot/strategy/perpetual_market_making/perpetual_market_making.pyx @@ -922,14 +922,12 @@ cdef class PerpetualMarketMakingStrategy(StrategyBase): object base_size_total = Decimal("0") quote_balance = market.c_get_available_balance(self.quote_asset) - funding_rate = Decimal(str(market.get_funding_info(self.trading_pair)["rate"])) trading_fees = market.c_get_fee(self.base_asset, self.quote_asset, OrderType.LIMIT, TradeType.BUY, s_decimal_zero, s_decimal_zero) for buy in proposal.buys: order_size = buy.size * buy.price - funding_amount = order_size * funding_rate if funding_rate > s_decimal_zero else s_decimal_zero - quote_size = (order_size / self._leverage) + (order_size * trading_fees.percent) + funding_amount + quote_size = (order_size / self._leverage) + (order_size * trading_fees.percent) if quote_balance < quote_size_total + quote_size: self.logger().info(f"Insufficient balance: Buy order (price: {buy.price}, size: {buy.size}) is omitted, {self.quote_asset} available balance: {quote_balance - quote_size_total}.") self.logger().warning("You are also at a possible risk of being liquidated if there happens to be an open loss.") @@ -939,8 +937,7 @@ cdef class PerpetualMarketMakingStrategy(StrategyBase): proposal.buys = [o for o in proposal.buys if o.size > 0] for sell in proposal.sells: order_size = sell.size * sell.price - funding_amount = order_size * funding_rate if funding_rate < s_decimal_zero else s_decimal_zero - quote_size = (order_size / self._leverage) + (order_size * trading_fees.percent) + funding_amount + quote_size = (order_size / self._leverage) + (order_size * trading_fees.percent) if quote_balance < quote_size_total + quote_size: self.logger().info(f"Insufficient balance: Sell order (price: {sell.price}, size: {sell.size}) is omitted, {self.quote_asset} available balance: {quote_balance - quote_size_total}.") self.logger().warning("You are also at a possible risk of being liquidated if there happens to be an open loss.") From 36bd10252eb6af3f3bfd65f99ed04907183a262f Mon Sep 17 00:00:00 2001 From: vic-en Date: Mon, 8 Mar 2021 13:50:44 +0100 Subject: [PATCH 125/131] (refactor) change default value of min_convergence to 0.1 --- .../spot_perpetual_arbitrage_config_map.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hummingbot/strategy/spot_perpetual_arbitrage/spot_perpetual_arbitrage_config_map.py b/hummingbot/strategy/spot_perpetual_arbitrage/spot_perpetual_arbitrage_config_map.py index ae5083d55b..8498b4eb2c 100644 --- a/hummingbot/strategy/spot_perpetual_arbitrage/spot_perpetual_arbitrage_config_map.py +++ b/hummingbot/strategy/spot_perpetual_arbitrage/spot_perpetual_arbitrage_config_map.py @@ -109,7 +109,7 @@ def order_amount_prompt() -> str: key="min_convergence", prompt="What is the minimum spread between the spot and derivative market price before closing an existing arbitrage? (Enter 1 to indicate 1%) >>> ", prompt_on_new=True, - default=Decimal("1"), + default=Decimal("0.1"), validator=lambda v: validate_decimal(v, 0, spot_perpetual_arbitrage_config_map["min_divergence"].value), type_str="decimal"), "maximize_funding_rate": ConfigVar( From 137a92774044011bcedc449924b2eb673a14408b Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Mon, 8 Mar 2021 07:18:19 -0800 Subject: [PATCH 126/131] (fix) change default token list to defi.cmc.eth --- hummingbot/client/config/global_config_map.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hummingbot/client/config/global_config_map.py b/hummingbot/client/config/global_config_map.py index d3702700c2..e6c7eaa141 100644 --- a/hummingbot/client/config/global_config_map.py +++ b/hummingbot/client/config/global_config_map.py @@ -155,7 +155,7 @@ def connector_keys(): prompt="Specify token list url of a list available on https://tokenlists.org/ >>> ", type_str="str", required_if=lambda: global_config_map["ethereum_wallet"].value is not None, - default="https://wispy-bird-88a7.uniswap.workers.dev/?url=http://tokens.1inch.eth.link"), + default="https://defi.cmc.eth.link/"), # Whether or not to invoke cancel_all on exit if marketing making on a open order book DEX (e.g. Radar Relay) "on_chain_cancel_on_exit": ConfigVar(key="on_chain_cancel_on_exit", From 2adae92dd2c8d5dc04a027b3bc026966b6bdf6d6 Mon Sep 17 00:00:00 2001 From: Nicolas Baum Date: Mon, 8 Mar 2021 12:43:22 -0300 Subject: [PATCH 127/131] Added wait_for in the trading pair fetcher and timeout for requests library when fetching erc20 tokens --- hummingbot/client/config/config_helpers.py | 2 +- hummingbot/core/utils/trading_pair_fetcher.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/hummingbot/client/config/config_helpers.py b/hummingbot/client/config/config_helpers.py index e99e893667..420123336b 100644 --- a/hummingbot/client/config/config_helpers.py +++ b/hummingbot/client/config/config_helpers.py @@ -169,7 +169,7 @@ def get_erc20_token_addresses() -> Dict[str, List]: address_file_path = TOKEN_ADDRESSES_FILE_PATH token_list = {} - resp = requests.get(token_list_url) + resp = requests.get(token_list_url, timeout=3) decoded_resp = resp.json() for token in decoded_resp["tokens"]: diff --git a/hummingbot/core/utils/trading_pair_fetcher.py b/hummingbot/core/utils/trading_pair_fetcher.py index f267eaaf95..0365cb15c2 100644 --- a/hummingbot/core/utils/trading_pair_fetcher.py +++ b/hummingbot/core/utils/trading_pair_fetcher.py @@ -8,6 +8,7 @@ from hummingbot.logger import HummingbotLogger from hummingbot.client.settings import CONNECTOR_SETTINGS, ConnectorType import logging +import asyncio from .async_utils import safe_ensure_future @@ -47,7 +48,7 @@ async def fetch_all(self): module = getattr(importlib.import_module(module_path), class_name) args = {} args = conn_setting.add_domain_parameter(args) - tasks.append(module.fetch_trading_pairs(**args)) + tasks.append(asyncio.wait_for(module.fetch_trading_pairs(**args), timeout=3)) fetched_connectors.append(conn_setting.name) results = await safe_gather(*tasks, return_exceptions=True) From db25d380888ac7a369305da3cec71984cbf6877d Mon Sep 17 00:00:00 2001 From: vic-en Date: Mon, 8 Mar 2021 17:16:38 +0100 Subject: [PATCH 128/131] (refactor) fetch pair for Perpfi directly from metadata --- ...tual_finance_api_order_book_data_source.py | 41 ++++--------------- 1 file changed, 8 insertions(+), 33 deletions(-) diff --git a/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_api_order_book_data_source.py b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_api_order_book_data_source.py index 2df75a53be..14439628bf 100644 --- a/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_api_order_book_data_source.py +++ b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_api_order_book_data_source.py @@ -1,47 +1,22 @@ import aiohttp from typing import List import json -import ssl from typing import Dict -from hummingbot.client.config.global_config_map import global_config_map from hummingbot.connector.derivative.perpetual_finance.perpetual_finance_utils import convert_from_exchange_trading_pair -from hummingbot.client.settings import GATEAWAY_CA_CERT_PATH, GATEAWAY_CLIENT_CERT_PATH, GATEAWAY_CLIENT_KEY_PATH class PerpetualFinanceAPIOrderBookDataSource: @staticmethod async def fetch_trading_pairs() -> List[str]: - ssl_ctx = ssl.create_default_context(cafile=GATEAWAY_CA_CERT_PATH) - ssl_ctx.load_cert_chain(GATEAWAY_CLIENT_CERT_PATH, GATEAWAY_CLIENT_KEY_PATH) - conn = aiohttp.TCPConnector(ssl_context=ssl_ctx) - client = aiohttp.ClientSession(connector=conn) - - base_url = f"https://{global_config_map['gateway_api_host'].value}:" \ - f"{global_config_map['gateway_api_port'].value}/perpfi/" - response = await client.get(base_url + "pairs") - parsed_response = json.loads(await response.text()) - if response.status != 200: - err_msg = "" - if "error" in parsed_response: - err_msg = f" Message: {parsed_response['error']}" - raise IOError(f"Error fetching pairs from gateway. HTTP status is {response.status}.{err_msg}") - pairs = parsed_response.get("pairs", []) - if "error" in parsed_response or len(pairs) == 0: - raise Exception(f"Error: {parsed_response['error']}") - else: - status = await client.get(base_url) - status = json.loads(await status.text()) - loadedMetadata = status["loadedMetadata"] - while (not loadedMetadata): - resp = await client.get(base_url + "load-metadata") - resp = json.loads(await resp.text()) - loadedMetadata = resp.get("loadedMetadata", False) - return PerpetualFinanceAPIOrderBookDataSource.fetch_trading_pairs() - trading_pairs = [] - for pair in pairs: - trading_pairs.append(convert_from_exchange_trading_pair(pair)) - return trading_pairs + url = "https://metadata.perp.exchange/production.json" + async with aiohttp.ClientSession() as client: + response = await client.get(url) + trading_pairs = [] + parsed_response = json.loads(await response.text()) + contracts = parsed_response["layers"]["layer2"]["contracts"] + trading_pairs = [convert_from_exchange_trading_pair(contract) for contract in contracts.keys() if contracts[contract]["name"] == "Amm"] + return trading_pairs @classmethod async def get_last_traded_prices(cls, trading_pairs: List[str]) -> Dict[str, float]: From 011a6ce3634d1b7f6dde22c38bd4dc26cf15e1fd Mon Sep 17 00:00:00 2001 From: Nicolas Baum Date: Mon, 8 Mar 2021 14:51:50 -0300 Subject: [PATCH 129/131] Adding shielding for tasks to avoid premature cancellation --- hummingbot/core/utils/trading_pair_fetcher.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hummingbot/core/utils/trading_pair_fetcher.py b/hummingbot/core/utils/trading_pair_fetcher.py index 0365cb15c2..81078f8bb2 100644 --- a/hummingbot/core/utils/trading_pair_fetcher.py +++ b/hummingbot/core/utils/trading_pair_fetcher.py @@ -48,7 +48,7 @@ async def fetch_all(self): module = getattr(importlib.import_module(module_path), class_name) args = {} args = conn_setting.add_domain_parameter(args) - tasks.append(asyncio.wait_for(module.fetch_trading_pairs(**args), timeout=3)) + tasks.append(asyncio.wait_for(asyncio.shield(module.fetch_trading_pairs(**args)), timeout=3)) fetched_connectors.append(conn_setting.name) results = await safe_gather(*tasks, return_exceptions=True) From 3af98d30cfed913e5e32eb99bced67a08210bb8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dennis=20Oca=C3=B1a?= <50150287+dennisocana@users.noreply.github.com> Date: Tue, 9 Mar 2021 15:01:55 +0800 Subject: [PATCH 130/131] (release) update to version 0.37.0 --- hummingbot/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hummingbot/VERSION b/hummingbot/VERSION index ebb3ff6b9d..0f1a7dfc7c 100644 --- a/hummingbot/VERSION +++ b/hummingbot/VERSION @@ -1 +1 @@ -dev-0.37.0 +0.37.0 From cbd5590bb9c555c0d3ad788375e36ebc8d3b4f69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dennis=20Oca=C3=B1a?= <50150287+dennisocana@users.noreply.github.com> Date: Tue, 9 Mar 2021 15:03:36 +0800 Subject: [PATCH 131/131] (release) version 0.37.0 release date --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index b3e5dea846..6cd0dd9002 100755 --- a/setup.py +++ b/setup.py @@ -19,7 +19,7 @@ def main(): cpu_count = os.cpu_count() or 8 - version = "20210209" + version = "20210309" packages = [ "hummingbot", "hummingbot.client",