diff --git a/README.md b/README.md index 10417f0950..6e06549ef2 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 | |:---:|:---:|:---:|:---:|:---:|:---:| -| Beaxy | beaxy | [Beaxy](https://beaxy.com/) | 2 | [API](https://beaxyapiv2trading.docs.apiary.io/) |![YELLOW](https://via.placeholder.com/15/ffff00/?text=+) | +| AscendEx | ascend_ex | [AscendEx](https://ascendex.com/en/global-digital-asset-platform) | 1 | [API](https://ascendex.github.io/ascendex-pro-api/#ascendex-pro-api-documentation) |![YELLOW](https://via.placeholder.com/15/ffff00/?text=+) | +| Beaxy | beaxy | [Beaxy](https://beaxy.com/) | 2 | [API](https://beaxyapiv2trading.docs.apiary.io/) |![GREEN](https://via.placeholder.com/15/008000/?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/) |![GREEN](https://via.placeholder.com/15/008000/?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=+) | +| Coinbase Pro | coinbase_pro | [Coinbase Pro](https://pro.coinbase.com/) | * | [API](https://docs.pro.coinbase.com/) |![YELLOW](https://via.placeholder.com/15/ffff00/?text=+) | +| CoinZoom | coinzoom | [CoinZoom](https://trade.coinzoom.com/landing) | * | [API](https://api-docs.coinzoom.com/) |![YELLOW](https://via.placeholder.com/15/ffff00/?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=+) | +| Digifinex | digifinex | [Digifinex](https://www.digifinex.com/en-ww) | 3 | [API](https://docs.digifinex.com/en-ww/v3/#introduction) |![YELLOW](https://via.placeholder.com/15/ffff00/?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=+) | +| HitBTC | hitbtc | [HitBTC](https://hitbtc.com/) | 2 | [API](https://api.hitbtc.com/) | ![YELLOW](https://via.placeholder.com/15/ffff00/?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=+) | +| Liquid | liquid | [Liquid](https://www.liquid.com/) | 2 | [API](https://developers.liquid.com/) |![YELLOW](https://via.placeholder.com/15/ffff00/?text=+) | +| OKEx | okex | [OKEx](https://www.okex.com/) | 3 | [API](https://www.okex.com/docs/en/) |![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=+) | diff --git a/assets/ascend_ex_logo.png b/assets/ascend_ex_logo.png new file mode 100644 index 0000000000..c30f757cf4 Binary files /dev/null and b/assets/ascend_ex_logo.png differ diff --git a/assets/bitmax_logo.png b/assets/bitmax_logo.png deleted file mode 100644 index 362daeca21..0000000000 Binary files a/assets/bitmax_logo.png and /dev/null differ diff --git a/assets/coinzoom_logo.svg b/assets/coinzoom_logo.svg new file mode 100644 index 0000000000..8184f907e7 --- /dev/null +++ b/assets/coinzoom_logo.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/assets/digifinex_logo.svg b/assets/digifinex_logo.svg new file mode 100644 index 0000000000..095449cb45 --- /dev/null +++ b/assets/digifinex_logo.svg @@ -0,0 +1,32 @@ + + + + DigiFinex-c-01 + Created with Sketch. + + + + + + + + + + \ No newline at end of file diff --git a/assets/hitbtc_logo.png b/assets/hitbtc_logo.png new file mode 100644 index 0000000000..efdac10517 Binary files /dev/null and b/assets/hitbtc_logo.png differ diff --git a/bin/hummingbot.py b/bin/hummingbot.py index 07c0d7c886..2ae42f4a66 100755 --- a/bin/hummingbot.py +++ b/bin/hummingbot.py @@ -45,7 +45,7 @@ async def main(): # This init_logging() call is important, to skip over the missing config warnings. init_logging("hummingbot_logs.yml") - read_system_configs_from_yml() + await read_system_configs_from_yml() hb = HummingbotApplication.main_application() diff --git a/bin/hummingbot_quickstart.py b/bin/hummingbot_quickstart.py index 5f940c7758..91b75c25a4 100755 --- a/bin/hummingbot_quickstart.py +++ b/bin/hummingbot_quickstart.py @@ -78,14 +78,14 @@ async def quick_start(args): await Security.wait_til_decryption_done() await create_yml_files() init_logging("hummingbot_logs.yml") - read_system_configs_from_yml() + await read_system_configs_from_yml() hb = HummingbotApplication.main_application() # Todo: validate strategy and config_file_name before assinging if config_file_name is not None: hb.strategy_file_name = config_file_name - hb.strategy_name = update_strategy_config_map_from_file(os.path.join(CONF_FILE_PATH, config_file_name)) + hb.strategy_name = await update_strategy_config_map_from_file(os.path.join(CONF_FILE_PATH, config_file_name)) # To ensure quickstart runs with the default value of False for kill_switch_enabled if not present if not global_config_map.get("kill_switch_enabled"): diff --git a/conf/__init__.py b/conf/__init__.py index 65ba5a8347..69e87838fa 100644 --- a/conf/__init__.py +++ b/conf/__init__.py @@ -104,6 +104,15 @@ crypto_com_api_key = os.getenv("CRYPTO_COM_API_KEY") crypto_com_secret_key = os.getenv("CRYPTO_COM_SECRET_KEY") +# HitBTC Tests +hitbtc_api_key = os.getenv("HITBTC_API_KEY") +hitbtc_secret_key = os.getenv("HITBTC_SECRET_KEY") + +# CoinZoom Test +coinzoom_api_key = os.getenv("COINZOOM_API_KEY") +coinzoom_secret_key = os.getenv("COINZOOM_SECRET_KEY") +coinzoom_username = os.getenv("COINZOOM_USERNAME") + # Wallet Tests test_erc20_token_address = os.getenv("TEST_ERC20_TOKEN_ADDRESS") web3_test_private_key_a = os.getenv("TEST_WALLET_PRIVATE_KEY_A") diff --git a/hummingbot/VERSION b/hummingbot/VERSION index 0f1a7dfc7c..ca75280b09 100644 --- a/hummingbot/VERSION +++ b/hummingbot/VERSION @@ -1 +1 @@ -0.37.0 +0.38.0 diff --git a/hummingbot/client/command/__init__.py b/hummingbot/client/command/__init__.py index 738e615143..a869e0a0fd 100644 --- a/hummingbot/client/command/__init__.py +++ b/hummingbot/client/command/__init__.py @@ -19,6 +19,7 @@ from .open_orders_command import OpenOrdersCommand from .trades_command import TradesCommand from .pnl_command import PnlCommand +from .rate_command import RateCommand __all__ = [ @@ -42,5 +43,6 @@ GenerateCertsCommand, OpenOrdersCommand, TradesCommand, - PnlCommand + PnlCommand, + RateCommand, ] diff --git a/hummingbot/client/command/balance_command.py b/hummingbot/client/command/balance_command.py index 386fa731bf..459ac95eab 100644 --- a/hummingbot/client/command/balance_command.py +++ b/hummingbot/client/command/balance_command.py @@ -10,7 +10,8 @@ ) from hummingbot.client.config.config_validators import validate_decimal, validate_exchange from hummingbot.market.celo.celo_cli import CeloCLI -from hummingbot.core.utils.market_price import usd_value +from hummingbot.client.performance import smart_round +from hummingbot.core.rate_oracle.rate_oracle import RateOracle import pandas as pd from decimal import Decimal from typing import TYPE_CHECKING, Dict, Optional, List @@ -76,6 +77,7 @@ def balance(self, save_to_yml(file_path, config_map) async def show_balances(self): + total_col_name = f'Total ({RateOracle.global_token_symbol})' self._notify("Updating balances, please wait...") all_ex_bals = await UserBalances.instance().all_balances_all_exchanges() all_ex_avai_bals = UserBalances.instance().all_avai_balances_all_exchanges() @@ -88,18 +90,17 @@ async def show_balances(self): 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, {})) - df, allocated_total = await self.exchange_balances_usd_df(bals, all_ex_avai_bals.get(exchange, {})) + df, allocated_total = await self.exchange_balances_extra_df(bals, all_ex_avai_bals.get(exchange, {})) if df.empty: self._notify("You have no balance on this exchange.") else: lines = [" " + line for line in df.to_string(index=False).split("\n")] 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 Total: {RateOracle.global_token_symbol} {smart_round(df[total_col_name].sum())} " + f"Allocated: {allocated_total / df[total_col_name].sum():.2%}") + exchanges_total += df[total_col_name].sum() - self._notify(f"\n\nExchanges Total: $ {exchanges_total:.0f} ") + self._notify(f"\n\nExchanges Total: {RateOracle.global_token_symbol} {exchanges_total:.0f} ") celo_address = global_config_map["celo_address"].value if celo_address is not None: @@ -126,25 +127,10 @@ async def show_balances(self): 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]): - rows = [] - for token, bal in exchange_balances.items(): - limit = Decimal(exchange_limits.get(token.upper(), 0)) if exchange_limits is not None else Decimal(0) - if bal == Decimal(0) and limit == Decimal(0): - continue - token = token.upper() - rows.append({"Asset": token.upper(), - "Amount": round(bal, 4), - "Limit": round(limit, 4) if limit > Decimal(0) else "-"}) - df = pd.DataFrame(data=rows, columns=["Asset", "Amount", "Limit"]) - df.sort_values(by=["Asset"], inplace=True) - return df - - async def exchange_balances_usd_df(self, # type: HummingbotApplication - ex_balances: Dict[str, Decimal], - ex_avai_balances: Dict[str, Decimal]): + async def exchange_balances_extra_df(self, # type: HummingbotApplication + ex_balances: Dict[str, Decimal], + ex_avai_balances: Dict[str, Decimal]): + total_col_name = f"Total ({RateOracle.global_token_symbol})" allocated_total = Decimal("0") rows = [] for token, bal in ex_balances.items(): @@ -152,15 +138,16 @@ async def exchange_balances_usd_df(self, # type: HummingbotApplication continue avai = Decimal(ex_avai_balances.get(token.upper(), 0)) if ex_avai_balances is not None else Decimal(0) allocated = f"{(bal - avai) / bal:.0%}" - usd = await usd_value(token, bal) - usd = 0 if usd is None else usd - allocated_total += await usd_value(token, (bal - avai)) + rate = await RateOracle.global_rate(token) + rate = Decimal("0") if rate is None else rate + global_value = rate * bal + allocated_total += rate * (bal - avai) rows.append({"Asset": token.upper(), "Total": round(bal, 4), - "Total ($)": round(usd), + total_col_name: smart_round(global_value), "Allocated": allocated, }) - df = pd.DataFrame(data=rows, columns=["Asset", "Total", "Total ($)", "Allocated"]) + df = pd.DataFrame(data=rows, columns=["Asset", "Total", total_col_name, "Allocated"]) df.sort_values(by=["Asset"], inplace=True) return df, allocated_total diff --git a/hummingbot/client/command/config_command.py b/hummingbot/client/command/config_command.py index 444609ce50..1c72502953 100644 --- a/hummingbot/client/command/config_command.py +++ b/hummingbot/client/command/config_command.py @@ -36,6 +36,7 @@ no_restart_pmm_keys_in_percentage = ["bid_spread", "ask_spread", "order_level_spread", "inventory_target_base_pct"] no_restart_pmm_keys = ["order_amount", "order_levels", "filled_order_delay", "inventory_skew_enabled", "inventory_range_multiplier"] global_configs_to_display = ["0x_active_cancels", + "autofill_import", "kill_switch_enabled", "kill_switch_rate", "telegram_enabled", @@ -44,17 +45,15 @@ "send_error_logs", "script_enabled", "script_file_path", - "manual_gas_price", "ethereum_chain_name", - "ethgasstation_gas_enabled", - "ethgasstation_api_key", - "ethgasstation_gas_level", - "ethgasstation_refresh_time", "gateway_enabled", "gateway_cert_passphrase", "gateway_api_host", "gateway_api_port", - "balancer_max_swaps"] + "balancer_max_swaps", + "rate_oracle_source", + "global_token", + "global_token_symbol"] class ConfigCommand: @@ -82,7 +81,7 @@ def list_configs(self, # type: HummingbotApplication self._notify("\n".join(lines)) if self.strategy_name is not None: - data = [[cv.key, cv.value] for cv in self.strategy_config_map.values() if not cv.is_secure] + data = [[cv.printable_key or cv.key, cv.value] for cv in self.strategy_config_map.values() if not cv.is_secure] df = pd.DataFrame(data=data, columns=columns) self._notify("\nStrategy Configurations:") lines = [" " + line for line in df.to_string(index=False, max_colwidth=50).split("\n")] @@ -199,7 +198,7 @@ async def asset_ratio_maintenance_prompt(self, # type: HummingbotApplication balances = await UserBalances.instance().balances(exchange, base, quote) if balances is None: return - base_ratio = UserBalances.base_amount_ratio(exchange, market, balances) + base_ratio = await UserBalances.base_amount_ratio(exchange, market, balances) if base_ratio is None: return base_ratio = round(base_ratio, 3) @@ -236,9 +235,12 @@ async def inventory_price_prompt( exchange = config_map["exchange"].value market = config_map["market"].value base_asset, quote_asset = market.split("-") - balances = await UserBalances.instance().balances( - exchange, base_asset, quote_asset - ) + if global_config_map["paper_trade_enabled"].value: + balances = global_config_map["paper_trade_account_balance"].value + else: + balances = await UserBalances.instance().balances( + exchange, base_asset, quote_asset + ) if balances.get(base_asset) is None: return diff --git a/hummingbot/client/command/create_command.py b/hummingbot/client/command/create_command.py index 21881542b7..4aad717851 100644 --- a/hummingbot/client/command/create_command.py +++ b/hummingbot/client/command/create_command.py @@ -97,16 +97,22 @@ async def prompt_a_config(self, # type: HummingbotApplication config: ConfigVar, input_value=None, assign_default=True): + if config.key == "inventory_price": + await self.inventory_price_prompt(self.strategy_config_map, input_value) + return if input_value is None: if assign_default: self.app.set_text(parse_config_default_to_text(config)) - input_value = await self.app.prompt(prompt=config.prompt, is_password=config.is_secure) + prompt = await config.get_prompt() + input_value = await self.app.prompt(prompt=prompt, is_password=config.is_secure) if self.app.to_stop_config: return - err_msg = config.validate(input_value) + config.value = parse_cvar_value(config, input_value) + err_msg = await config.validate(input_value) if err_msg is not None: self._notify(err_msg) + config.value = None await self.prompt_a_config(config) else: config.value = parse_cvar_value(config, input_value) diff --git a/hummingbot/client/command/import_command.py b/hummingbot/client/command/import_command.py index 8c02162393..5799f3a2f4 100644 --- a/hummingbot/client/command/import_command.py +++ b/hummingbot/client/command/import_command.py @@ -1,6 +1,7 @@ import os from hummingbot.core.utils.async_utils import safe_ensure_future +from hummingbot.client.config.global_config_map import global_config_map from hummingbot.client.config.config_helpers import ( update_strategy_config_map_from_file, short_strategy_name, @@ -34,7 +35,7 @@ async def import_config_file(self, # type: HummingbotApplication self.app.to_stop_config = False return strategy_path = os.path.join(CONF_FILE_PATH, file_name) - strategy = update_strategy_config_map_from_file(strategy_path) + strategy = await update_strategy_config_map_from_file(strategy_path) self.strategy_file_name = file_name self.strategy_name = strategy self._notify(f"Configuration from {self.strategy_file_name} file is imported.") @@ -43,6 +44,9 @@ async def import_config_file(self, # type: HummingbotApplication self.app.change_prompt(prompt=">>> ") if await self.status_check_all(): self._notify("\nEnter \"start\" to start market making.") + autofill_import = global_config_map.get("autofill_import").value + if autofill_import is not None: + self.app.set_text(autofill_import) async def prompt_a_file_name(self # type: HummingbotApplication ): diff --git a/hummingbot/client/command/open_orders_command.py b/hummingbot/client/command/open_orders_command.py index ea5a2dde2e..6e4fd36966 100644 --- a/hummingbot/client/command/open_orders_command.py +++ b/hummingbot/client/command/open_orders_command.py @@ -9,7 +9,8 @@ from datetime import timezone from hummingbot.core.utils.async_utils import safe_ensure_future from hummingbot.core.data_type.common import OpenOrder -from hummingbot.core.utils.market_price import usd_value, get_binance_mid_price +from hummingbot.core.utils.market_price import get_binance_mid_price +from hummingbot.core.rate_oracle.rate_oracle import RateOracle s_float_0 = float(0) s_decimal_0 = Decimal("0") @@ -30,6 +31,7 @@ async def open_orders_report(self, # type: HummingbotApplication full_report: bool): exchange = "binance" connector = await self.get_binance_connector() + g_sym = RateOracle.global_token_symbol if connector is None: self._notify("This command supports only binance (for now), please first connect to binance.") return @@ -39,31 +41,31 @@ async def open_orders_report(self, # type: HummingbotApplication return orders = sorted(orders, key=lambda x: (x.trading_pair, x.is_buy)) data = [] - columns = ["Market", " Side", " Spread", " Size ($)", " Age"] + columns = ["Market", " Side", " Spread", f" Size ({g_sym})", " Age"] if full_report: columns.extend([" Allocation", " Per Total"]) cur_balances = await self.get_current_balances(exchange) total_value = 0 for o in orders: - total_value += await usd_value(o.trading_pair.split("-")[0], o.amount) + total_value += await RateOracle.global_value(o.trading_pair.split("-")[0], o.amount) for order in orders: base, quote = order.trading_pair.split("-") side = "buy" if order.is_buy else "sell" mid_price = await get_binance_mid_price(order.trading_pair) spread = abs(order.price - mid_price) / mid_price - size_usd = await usd_value(order.trading_pair.split("-")[0], order.amount) + size_global = await RateOracle.global_value(order.trading_pair.split("-")[0], order.amount) age = pd.Timestamp((datetime.utcnow().replace(tzinfo=timezone.utc).timestamp() * 1e3 - order.time) / 1e3, unit='s').strftime('%H:%M:%S') - data_row = [order.trading_pair, side, f"{spread:.2%}", round(size_usd), age] + data_row = [order.trading_pair, side, f"{spread:.2%}", round(size_global), age] if full_report: token = quote if order.is_buy else base token_value = order.amount * order.price if order.is_buy else order.amount per_bal = token_value / cur_balances[token] token_txt = f"({token})" - data_row.extend([f"{per_bal:.0%} {token_txt:>6}", f"{size_usd / total_value:.0%}"]) + data_row.extend([f"{per_bal:.0%} {token_txt:>6}", f"{size_global / total_value:.0%}"]) data.append(data_row) lines = [] orders_df: pd.DataFrame = pd.DataFrame(data=data, columns=columns) lines.extend([" " + line for line in orders_df.to_string(index=False).split("\n")]) self._notify("\n" + "\n".join(lines)) - self._notify(f"\n Total: $ {total_value:.0f}") + self._notify(f"\n Total: {g_sym} {total_value:.0f}") diff --git a/hummingbot/client/command/pnl_command.py b/hummingbot/client/command/pnl_command.py index f02c07358c..75a1ebf720 100644 --- a/hummingbot/client/command/pnl_command.py +++ b/hummingbot/client/command/pnl_command.py @@ -9,12 +9,12 @@ from hummingbot.core.utils.async_utils import safe_ensure_future from hummingbot.client.config.security import Security from hummingbot.user.user_balances import UserBalances -from hummingbot.core.utils.market_price import usd_value from hummingbot.core.data_type.trade import Trade from hummingbot.core.data_type.common import OpenOrder from hummingbot.client.performance import calculate_performance_metrics from hummingbot.client.command.history_command import get_timestamp from hummingbot.client.config.global_config_map import global_config_map +from hummingbot.core.rate_oracle.rate_oracle import RateOracle s_float_0 = float(0) s_decimal_0 = Decimal("0") @@ -73,16 +73,17 @@ async def pnl_report(self, # type: HummingbotApplication markets = set(global_config_map["binance_markets"].value.split(",")) markets = sorted(markets) data = [] - columns = ["Market", " Traded ($)", " Fee ($)", " PnL ($)", " Return %"] + g_sym = RateOracle.global_token_symbol + columns = ["Market", f" Traded ({g_sym})", f" Fee ({g_sym})", f" PnL ({g_sym})", " Return %"] for market in markets: base, quote = market.split("-") trades: List[Trade] = await connector.get_my_trades(market, days) if not trades: continue perf = await calculate_performance_metrics(exchange, market, trades, cur_balances) - volume = await usd_value(quote, abs(perf.b_vol_quote) + abs(perf.s_vol_quote)) - fee = await usd_value(quote, perf.fee_in_quote) - pnl = await usd_value(quote, perf.total_pnl) + volume = await RateOracle.global_value(quote, abs(perf.b_vol_quote) + abs(perf.s_vol_quote)) + fee = await RateOracle.global_value(quote, perf.fee_in_quote) + pnl = await RateOracle.global_value(quote, perf.total_pnl) data.append([market, round(volume, 2), round(fee, 2), round(pnl, 2), f"{perf.return_pct:.2%}"]) if not data: self._notify(f"No trades during the last {days} day(s).") @@ -91,4 +92,5 @@ async def pnl_report(self, # type: HummingbotApplication df: pd.DataFrame = pd.DataFrame(data=data, columns=columns) lines.extend([" " + line for line in df.to_string(index=False).split("\n")]) self._notify("\n" + "\n".join(lines)) - self._notify(f"\n Total PnL: $ {df[' PnL ($)'].sum():.2f} Total fee: $ {df[' Fee ($)'].sum():.2f}") + self._notify(f"\n Total PnL: {g_sym} {df[f' PnL ({g_sym})'].sum():.2f} " + f"Total fee: {g_sym} {df[f' Fee ({g_sym})'].sum():.2f}") diff --git a/hummingbot/client/command/rate_command.py b/hummingbot/client/command/rate_command.py new file mode 100644 index 0000000000..b784e3c0c3 --- /dev/null +++ b/hummingbot/client/command/rate_command.py @@ -0,0 +1,58 @@ +from decimal import Decimal +import threading +from typing import ( + TYPE_CHECKING, +) +from hummingbot.core.utils.async_utils import safe_ensure_future +from hummingbot.core.rate_oracle.rate_oracle import RateOracle +from hummingbot.client.errors import OracleRateUnavailable + +s_float_0 = float(0) +s_decimal_0 = Decimal("0") + +if TYPE_CHECKING: + from hummingbot.client.hummingbot_application import HummingbotApplication + + +class RateCommand: + def rate(self, # type: HummingbotApplication + pair: str, + token: str + ): + if threading.current_thread() != threading.main_thread(): + self.ev_loop.call_soon_threadsafe(self.trades) + return + if pair: + safe_ensure_future(self.show_rate(pair)) + elif token: + safe_ensure_future(self.show_token_value(token)) + + async def show_rate(self, # type: HummingbotApplication + pair: str, + ): + try: + msg = await RateCommand.oracle_rate_msg(pair) + except OracleRateUnavailable: + msg = "Rate is not available." + self._notify(msg) + + @staticmethod + async def oracle_rate_msg(pair: str, + ): + pair = pair.upper() + rate = await RateOracle.rate_async(pair) + if rate is None: + raise OracleRateUnavailable + base, quote = pair.split("-") + return f"Source: {RateOracle.source.name}\n1 {base} = {rate} {quote}" + + async def show_token_value(self, # type: HummingbotApplication + token: str + ): + token = token.upper() + self._notify(f"Source: {RateOracle.source.name}") + rate = await RateOracle.global_rate(token) + if rate is None: + self._notify("Rate is not available.") + return + self._notify(f"1 {token} = {RateOracle.global_token_symbol} {rate} {RateOracle.global_token}") diff --git a/hummingbot/client/command/start_command.py b/hummingbot/client/command/start_command.py index e8c2e589a3..a8b516d9ec 100644 --- a/hummingbot/client/command/start_command.py +++ b/hummingbot/client/command/start_command.py @@ -17,19 +17,18 @@ from hummingbot.client.config.config_helpers import ( get_strategy_starter_file, ) -from hummingbot.client.settings import ( - STRATEGIES, - SCRIPTS_PATH, - ethereum_gas_station_required, - required_exchanges, -) +import hummingbot.client.settings as settings from hummingbot.core.utils.async_utils import safe_ensure_future from hummingbot.core.utils.kill_switch import KillSwitch from typing import TYPE_CHECKING from hummingbot.client.config.global_config_map import global_config_map -from hummingbot.core.utils.eth_gas_station_lookup import EthGasStationLookup from hummingbot.script.script_iterator import ScriptIterator from hummingbot.connector.connector_status import get_connector_status, warning_messages +from hummingbot.client.config.config_var import ConfigVar +from hummingbot.client.command.rate_command import RateCommand +from hummingbot.client.config.config_validators import validate_bool +from hummingbot.client.errors import OracleRateUnavailable +from hummingbot.core.rate_oracle.rate_oracle import RateOracle if TYPE_CHECKING: from hummingbot.client.hummingbot_application import HummingbotApplication @@ -59,15 +58,19 @@ def start(self, # type: HummingbotApplication async def start_check(self, # type: HummingbotApplication log_level: Optional[str] = None, restore: Optional[bool] = False): - if self.strategy_task is not None and not self.strategy_task.done(): self._notify('The bot is already running - please run "stop" first') return + if settings.required_rate_oracle: + if not (await self.confirm_oracle_conversion_rate()): + self._notify("The strategy failed to start.") + return + else: + RateOracle.get_instance().start() is_valid = await self.status_check_all(notify_success=False) if not is_valid: return - if self._last_started_strategy_file != self.strategy_file_name: init_logging("hummingbot_logs.yml", override_log_level=log_level.upper() if log_level else None, @@ -85,7 +88,7 @@ async def start_check(self, # type: HummingbotApplication if global_config_map.get("paper_trade_enabled").value: self._notify("\nPaper Trading ON: All orders are simulated, and no real orders are placed.") - for exchange in required_exchanges: + for exchange in settings.required_exchanges: connector = str(exchange) status = get_connector_status(connector) @@ -106,7 +109,7 @@ async def start_market_making(self, # type: HummingbotApplication strategy_name: str, restore: Optional[bool] = False): start_strategy: Callable = get_strategy_starter_file(strategy_name) - if strategy_name in STRATEGIES: + if strategy_name in settings.STRATEGIES: start_strategy(self) else: raise NotImplementedError @@ -133,7 +136,7 @@ async def start_market_making(self, # type: HummingbotApplication script_file = global_config_map["script_file_path"].value folder = dirname(script_file) if folder == "": - script_file = join(SCRIPTS_PATH, script_file) + script_file = join(settings.SCRIPTS_PATH, script_file) if self.strategy_name != "pure_market_making": self._notify("Error: script feature is only available for pure_market_making strategy (for now).") else: @@ -142,9 +145,6 @@ async def start_market_making(self, # type: HummingbotApplication self.clock.add_iterator(self._script_iterator) self._notify(f"Script ({script_file}) started.") - if global_config_map["ethgasstation_gas_enabled"].value and ethereum_gas_station_required(): - EthGasStationLookup.get_instance().start() - self.strategy_task: asyncio.Task = safe_ensure_future(self._run_clock(), loop=self.ev_loop) self._notify(f"\n'{strategy_name}' strategy started.\n" f"Run `status` command to query the progress.") @@ -155,3 +155,30 @@ async def start_market_making(self, # type: HummingbotApplication await self.wait_till_ready(self.kill_switch.start) except Exception as e: self.logger().error(str(e), exc_info=True) + + async def confirm_oracle_conversion_rate(self, # type: HummingbotApplication + ) -> bool: + try: + result = False + self.app.clear_input() + self.placeholder_mode = True + self.app.hide_input = True + for pair in settings.rate_oracle_pairs: + msg = await RateCommand.oracle_rate_msg(pair) + self._notify("\nRate Oracle:\n" + msg) + config = ConfigVar(key="confirm_oracle_use", + type_str="bool", + prompt="Please confirm to proceed if the above oracle source and rates are correct for " + "this strategy (Yes/No) >>> ", + required_if=lambda: True, + validator=lambda v: validate_bool(v)) + await self.prompt_a_config(config) + if config.value: + result = True + except OracleRateUnavailable: + self._notify("Oracle rate is not available.") + finally: + self.placeholder_mode = False + self.app.hide_input = False + self.app.change_prompt(prompt=">>> ") + return result diff --git a/hummingbot/client/command/status_command.py b/hummingbot/client/command/status_command.py index 3cf92785ad..1fb5e69344 100644 --- a/hummingbot/client/command/status_command.py +++ b/hummingbot/client/command/status_command.py @@ -18,7 +18,7 @@ ) from hummingbot.client.config.security import Security from hummingbot.user.user_balances import UserBalances -from hummingbot.client.settings import required_exchanges, ethereum_wallet_required, ethereum_gas_station_required +from hummingbot.client.settings import required_exchanges, ethereum_wallet_required from hummingbot.core.utils.async_utils import safe_ensure_future from typing import TYPE_CHECKING @@ -186,10 +186,6 @@ async def status_check_all(self, # type: HummingbotApplication else: self._notify(" - ETH wallet check: ETH wallet is not connected.") - if ethereum_gas_station_required() and not global_config_map["ethgasstation_gas_enabled"].value: - self._notify(f' - ETH gas station check: Manual gas price is fixed at ' - f'{global_config_map["manual_gas_price"].value}.') - loading_markets: List[ConnectorBase] = [] for market in self.markets.values(): if not market.ready: diff --git a/hummingbot/client/command/stop_command.py b/hummingbot/client/command/stop_command.py index 3848aadea4..3413f2d90f 100644 --- a/hummingbot/client/command/stop_command.py +++ b/hummingbot/client/command/stop_command.py @@ -3,7 +3,7 @@ import threading from typing import TYPE_CHECKING from hummingbot.core.utils.async_utils import safe_ensure_future -from hummingbot.core.utils.eth_gas_station_lookup import EthGasStationLookup +from hummingbot.core.rate_oracle.rate_oracle import RateOracle if TYPE_CHECKING: from hummingbot.client.hummingbot_application import HummingbotApplication @@ -45,8 +45,8 @@ async def stop_loop(self, # type: HummingbotApplication if self.strategy_task is not None and not self.strategy_task.cancelled(): self.strategy_task.cancel() - if EthGasStationLookup.get_instance().started: - EthGasStationLookup.get_instance().stop() + if RateOracle.get_instance().started: + RateOracle.get_instance().stop() if self.markets_recorder is not None: self.markets_recorder.stop() diff --git a/hummingbot/client/command/trades_command.py b/hummingbot/client/command/trades_command.py index d319aee011..bc1b502a1e 100644 --- a/hummingbot/client/command/trades_command.py +++ b/hummingbot/client/command/trades_command.py @@ -7,12 +7,12 @@ ) from datetime import datetime from hummingbot.core.utils.async_utils import safe_ensure_future -from hummingbot.core.utils.market_price import usd_value from hummingbot.core.data_type.trade import Trade, TradeType from hummingbot.core.data_type.common import OpenOrder from hummingbot.client.performance import smart_round from hummingbot.client.command.history_command import get_timestamp from hummingbot.client.config.global_config_map import global_config_map +from hummingbot.core.rate_oracle.rate_oracle import RateOracle s_float_0 = float(0) s_decimal_0 = Decimal("0") @@ -58,11 +58,13 @@ async def market_trades_report(self, # type: HummingbotApplication days: float, market: str): trades: List[Trade] = await connector.get_my_trades(market, days) + g_sym = RateOracle.global_token_symbol if not trades: self._notify(f"There is no trade on {market}.") return data = [] - columns = ["Time", " Side", " Price", "Amount", " Amount ($)"] + amount_g_col_name = f" Amount ({g_sym})" + columns = ["Time", " Side", " Price", "Amount", amount_g_col_name] trades = sorted(trades, key=lambda x: (x.trading_pair, x.timestamp)) fees = {} # a dict of token and total fee amount fee_usd = 0 @@ -70,14 +72,14 @@ async def market_trades_report(self, # type: HummingbotApplication for trade in trades: time = f"{datetime.fromtimestamp(trade.timestamp / 1e3).strftime('%Y-%m-%d %H:%M:%S')} " side = "buy" if trade.side is TradeType.BUY else "sell" - usd = await usd_value(trade.trading_pair.split("-")[0], trade.amount) + usd = await RateOracle.global_value(trade.trading_pair.split("-")[0], trade.amount) data.append([time, side, smart_round(trade.price), smart_round(trade.amount), round(usd)]) for fee in trade.trade_fee.flat_fees: if fee[0] not in fees: fees[fee[0]] = fee[1] else: fees[fee[0]] += fee[1] - fee_usd += await usd_value(fee[0], fee[1]) + fee_usd += await RateOracle.global_value(fee[0], fee[1]) lines = [] df: pd.DataFrame = pd.DataFrame(data=data, columns=columns) @@ -85,4 +87,5 @@ async def market_trades_report(self, # type: HummingbotApplication lines.extend([" " + line for line in df.to_string(index=False).split("\n")]) self._notify("\n" + "\n".join(lines)) fee_text = ",".join(k + ": " + f"{v:.4f}" for k, v in fees.items()) - self._notify(f"\n Total traded: $ {df[' Amount ($)'].sum():.0f} Fees: {fee_text} ($ {fee_usd:.2f})") + self._notify(f"\n Total traded: {g_sym} {df[amount_g_col_name].sum():.0f} " + f"Fees: {fee_text} ({g_sym} {fee_usd:.2f})") diff --git a/hummingbot/client/config/config_helpers.py b/hummingbot/client/config/config_helpers.py index 420123336b..2f8b491c75 100644 --- a/hummingbot/client/config/config_helpers.py +++ b/hummingbot/client/config/config_helpers.py @@ -1,5 +1,6 @@ import logging from decimal import Decimal +from functools import lru_cache import ruamel.yaml from os import ( unlink @@ -35,7 +36,7 @@ CONNECTOR_SETTINGS ) from hummingbot.client.config.security import Security -from hummingbot.core.utils.market_price import get_mid_price +from hummingbot.core.utils.market_price import get_last_price from hummingbot import get_strategy_list from eth_account import Account @@ -164,6 +165,7 @@ def get_eth_wallet_private_key() -> Optional[str]: return account.privateKey.hex() +@lru_cache(None) def get_erc20_token_addresses() -> Dict[str, List]: token_list_url = global_config_map.get("ethereum_token_list_url").value address_file_path = TOKEN_ADDRESSES_FILE_PATH @@ -261,15 +263,15 @@ def validate_strategy_file(file_path: str) -> Optional[str]: return None -def update_strategy_config_map_from_file(yml_path: str) -> str: +async def update_strategy_config_map_from_file(yml_path: str) -> str: strategy = strategy_name_from_file(yml_path) config_map = get_strategy_config_map(strategy) template_path = get_strategy_template_path(strategy) - load_yml_into_cm(yml_path, template_path, config_map) + await load_yml_into_cm(yml_path, template_path, config_map) return strategy -def load_yml_into_cm(yml_path: str, template_file_path: str, cm: Dict[str, ConfigVar]): +async def load_yml_into_cm(yml_path: str, template_file_path: str, cm: Dict[str, ConfigVar]): try: with open(yml_path) as stream: data = yaml_parser.load(stream) or {} @@ -301,7 +303,7 @@ def load_yml_into_cm(yml_path: str, template_file_path: str, cm: Dict[str, Confi # Todo: the proper process should be first validate the value then assign it cvar.value = parse_cvar_value(cvar, val_in_file) if cvar.value is not None: - err_msg = cvar.validate(str(cvar.value)) + err_msg = await cvar.validate(str(cvar.value)) if err_msg is not None: # Instead of raising an exception, simply skip over this variable and wait till the user is prompted logging.getLogger().error("Invalid value %s for config variable %s" % (val_in_file, cvar.key)) @@ -320,14 +322,14 @@ def load_yml_into_cm(yml_path: str, template_file_path: str, cm: Dict[str, Confi exc_info=True) -def read_system_configs_from_yml(): +async def read_system_configs_from_yml(): """ Read global config and selected strategy yml files and save the values to corresponding config map If a yml file is outdated, it gets reformatted with the new template """ - load_yml_into_cm(GLOBAL_CONFIG_PATH, join(TEMPLATE_PATH, "conf_global_TEMPLATE.yml"), global_config_map) - load_yml_into_cm(TRADE_FEES_CONFIG_PATH, join(TEMPLATE_PATH, "conf_fee_overrides_TEMPLATE.yml"), - fee_overrides_config_map) + await load_yml_into_cm(GLOBAL_CONFIG_PATH, join(TEMPLATE_PATH, "conf_global_TEMPLATE.yml"), global_config_map) + await load_yml_into_cm(TRADE_FEES_CONFIG_PATH, join(TEMPLATE_PATH, "conf_fee_overrides_TEMPLATE.yml"), + fee_overrides_config_map) # In case config maps get updated (due to default values) save_system_configs_to_yml() @@ -403,12 +405,12 @@ def default_min_quote(quote_asset: str) -> (str, Decimal): return result_quote, result_amount -def minimum_order_amount(exchange: str, trading_pair: str) -> Decimal: +async def minimum_order_amount(exchange: str, trading_pair: str) -> Decimal: base_asset, quote_asset = trading_pair.split("-") default_quote_asset, default_amount = default_min_quote(quote_asset) quote_amount = Decimal("0") if default_quote_asset == quote_asset: - mid_price = get_mid_price(exchange, trading_pair) + mid_price = await get_last_price(exchange, trading_pair) if mid_price is not None: quote_amount = default_amount / mid_price return round(quote_amount, 4) diff --git a/hummingbot/client/config/config_var.py b/hummingbot/client/config/config_var.py index b901fbdaff..6a346653b9 100644 --- a/hummingbot/client/config/config_var.py +++ b/hummingbot/client/config/config_var.py @@ -1,17 +1,19 @@ from typing import ( Optional, - Callable, + Callable ) +import inspect RequiredIf = Callable[[str], Optional[bool]] Validator = Callable[[str], Optional[str]] +Prompt = Callable[[str], Optional[str]] OnValidated = Callable class ConfigVar: def __init__(self, key: str, - prompt: Optional[any], + prompt: Prompt, is_secure: bool = False, default: any = None, type_str: str = "str", @@ -22,8 +24,9 @@ def __init__(self, # Whether to prompt a user for value when new strategy config file is created prompt_on_new: bool = False, # Whether this is a config var used in connect command - is_connect_key: bool = False): - self._prompt = prompt + is_connect_key: bool = False, + printable_key: str = None): + self.prompt = prompt self.key = key self.value = None self.is_secure = is_secure @@ -34,25 +37,34 @@ def __init__(self, self._on_validated = on_validated self.prompt_on_new = prompt_on_new self.is_connect_key = is_connect_key + self.printable_key = printable_key - @property - def prompt(self): - if callable(self._prompt): - return self._prompt() + async def get_prompt(self): + if inspect.iscoroutinefunction(self.prompt): + return await self.prompt() + elif inspect.isfunction(self.prompt): + return self.prompt() else: - return self._prompt + return self.prompt @property def required(self) -> bool: assert callable(self._required_if) return self._required_if() - def validate(self, value: str) -> Optional[str]: + async def validate(self, value: str) -> Optional[str]: assert callable(self._validator) assert callable(self._on_validated) if self.required and (value is None or value == ""): return "Value is required." - err_msg = self._validator(value) + err_msg = None + if inspect.iscoroutinefunction(self._validator): + err_msg = await self._validator(value) + elif inspect.isfunction(self._validator): + err_msg = self._validator(value) if err_msg is None and self._validator is not None: - self._on_validated(value) + if inspect.iscoroutinefunction(self._on_validated): + await self._on_validated(value) + elif inspect.isfunction(self._on_validated): + self._on_validated(value) return err_msg diff --git a/hummingbot/client/config/global_config_map.py b/hummingbot/client/config/global_config_map.py index e6c7eaa141..7a6c96fa23 100644 --- a/hummingbot/client/config/global_config_map.py +++ b/hummingbot/client/config/global_config_map.py @@ -10,6 +10,7 @@ validate_int, validate_decimal ) +from hummingbot.core.rate_oracle.rate_oracle import RateOracleSource, RateOracle def generate_client_id() -> str: @@ -45,6 +46,23 @@ def connector_keys(): return all_keys +def validate_rate_oracle_source(value: str) -> Optional[str]: + if value not in (r.name for r in RateOracleSource): + return f"Invalid source, please choose value from {','.join(r.name for r in RateOracleSource)}" + + +def rate_oracle_source_on_validated(value: str): + RateOracle.source = RateOracleSource[value] + + +def global_token_on_validated(value: str): + RateOracle.global_token = value.upper() + + +def global_token_symbol_on_validated(value: str): + RateOracle.global_token_symbol = value + + # Main global config store key_config_map = connector_keys() @@ -178,6 +196,14 @@ def connector_keys(): default=-100, validator=lambda v: validate_decimal(v, Decimal(-100), Decimal(100)), required_if=lambda: global_config_map["kill_switch_enabled"].value), + "autofill_import": + ConfigVar(key="autofill_import", + prompt="What to auto-fill in the prompt after each import command? (start/config) >>> ", + type_str="str", + default=None, + validator=lambda s: None if s in {"start", + "config"} else "Invalid auto-fill prompt.", + required_if=lambda: False), "telegram_enabled": ConfigVar(key="telegram_enabled", prompt="Would you like to enable telegram? >>> ", @@ -272,32 +298,6 @@ def connector_keys(): type_str="decimal", validator=lambda v: validate_decimal(v, Decimal(0), inclusive=False), default=50), - "ethgasstation_gas_enabled": - ConfigVar(key="ethgasstation_gas_enabled", - prompt="Do you want to enable Ethereum gas station price lookup? >>> ", - required_if=lambda: False, - type_str="bool", - validator=validate_bool, - default=False), - "ethgasstation_api_key": - ConfigVar(key="ethgasstation_api_key", - prompt="Enter API key for defipulse.com gas station API >>> ", - required_if=lambda: global_config_map["ethgasstation_gas_enabled"].value, - type_str="str"), - "ethgasstation_gas_level": - ConfigVar(key="ethgasstation_gas_level", - prompt="Enter gas level you want to use for Ethereum transactions (fast, fastest, safeLow, average) " - ">>> ", - required_if=lambda: global_config_map["ethgasstation_gas_enabled"].value, - type_str="str", - validator=lambda s: None if s in {"fast", "fastest", "safeLow", "average"} - else "Invalid gas level."), - "ethgasstation_refresh_time": - ConfigVar(key="ethgasstation_refresh_time", - prompt="Enter refresh time for Ethereum gas price lookup (in seconds) >>> ", - required_if=lambda: global_config_map["ethgasstation_gas_enabled"].value, - type_str="int", - default=120), "gateway_api_host": ConfigVar(key="gateway_api_host", prompt=None, @@ -332,6 +332,29 @@ def connector_keys(): required_if=lambda: False, default="HARD-USDT,HARD-BTC,XEM-ETH,XEM-BTC,ALGO-USDT,ALGO-BTC,COTI-BNB,COTI-USDT,COTI-BTC,MFT-BNB," "MFT-ETH,MFT-USDT,RLC-ETH,RLC-BTC,RLC-USDT"), + "rate_oracle_source": + ConfigVar(key="rate_oracle_source", + prompt=f"What source do you want rate oracle to pull data from? " + f"({','.join(r.name for r in RateOracleSource)}) >>> ", + type_str="str", + required_if=lambda: False, + validator=validate_rate_oracle_source, + on_validated=rate_oracle_source_on_validated, + default=RateOracleSource.binance.name), + "global_token": + ConfigVar(key="global_token", + prompt="What is your default display token? (e.g. USD,EUR,BTC) >>> ", + type_str="str", + required_if=lambda: False, + on_validated=global_token_on_validated, + default="USD"), + "global_token_symbol": + ConfigVar(key="global_token_symbol", + prompt="What is your default display token symbol? (e.g. $,€) >>> ", + type_str="str", + required_if=lambda: False, + on_validated=global_token_symbol_on_validated, + default="$"), } global_config_map = {**key_config_map, **main_config_map} diff --git a/hummingbot/client/errors.py b/hummingbot/client/errors.py index 54b19a407c..22e25d2d1f 100644 --- a/hummingbot/client/errors.py +++ b/hummingbot/client/errors.py @@ -7,3 +7,7 @@ class InvalidCommandError(Exception): class ArgumentParserError(Exception): pass + + +class OracleRateUnavailable(Exception): + pass diff --git a/hummingbot/client/hummingbot_application.py b/hummingbot/client/hummingbot_application.py index 3566917232..f777132f21 100644 --- a/hummingbot/client/hummingbot_application.py +++ b/hummingbot/client/hummingbot_application.py @@ -21,7 +21,6 @@ from hummingbot.client.errors import InvalidCommandError, ArgumentParserError from hummingbot.client.config.global_config_map import global_config_map, using_wallet from hummingbot.client.config.config_helpers import ( - get_erc20_token_addresses, get_strategy_config_map, get_connector_class, get_eth_wallet_private_key, @@ -29,6 +28,7 @@ from hummingbot.strategy.strategy_base import StrategyBase from hummingbot.strategy.cross_exchange_market_making import CrossExchangeMarketPair from hummingbot.core.utils.kill_switch import KillSwitch +from hummingbot.core.utils.trading_pair_fetcher import TradingPairFetcher from hummingbot.data_feed.data_feed_base import DataFeedBase from hummingbot.notifier.notifier_base import NotifierBase from hummingbot.notifier.telegram_notifier import TelegramNotifier @@ -36,7 +36,6 @@ from hummingbot.connector.markets_recorder import MarketsRecorder from hummingbot.client.config.security import Security from hummingbot.connector.exchange_base import ExchangeBase -from hummingbot.core.utils.trading_pair_fetcher import TradingPairFetcher from hummingbot.client.settings import CONNECTOR_SETTINGS, ConnectorType s_logger = None @@ -62,6 +61,8 @@ def main_application(cls) -> "HummingbotApplication": return cls._main_app def __init__(self): + # This is to start fetching trading pairs for auto-complete + TradingPairFetcher.get_instance() self.ev_loop: asyncio.BaseEventLoop = asyncio.get_event_loop() self.parser: ThrowingArgumentParser = load_parser(self) self.app = HummingbotCLI( @@ -96,9 +97,6 @@ def __init__(self): self.trade_fill_db: Optional[SQLConnectionManager] = None self.markets_recorder: Optional[MarketsRecorder] = None self._script_iterator = None - # This is to start fetching trading pairs for auto-complete - TradingPairFetcher.get_instance() - self._binance_connector = None @property @@ -195,10 +193,14 @@ def _initialize_market_assets(market_name: str, trading_pairs: List[str]) -> Lis return market_trading_pairs def _initialize_wallet(self, token_trading_pairs: List[str]): + # Todo: This function should be removed as it's currently not used by current working connectors + if not using_wallet(): return - if not self.token_list: - self.token_list = get_erc20_token_addresses() + # Commented this out for now since get_erc20_token_addresses uses blocking call + + # if not self.token_list: + # self.token_list = get_erc20_token_addresses() ethereum_wallet = global_config_map.get("ethereum_wallet").value private_key = Security._private_keys[ethereum_wallet] diff --git a/hummingbot/client/settings.py b/hummingbot/client/settings.py index ae6392fde7..19b12c058d 100644 --- a/hummingbot/client/settings.py +++ b/hummingbot/client/settings.py @@ -15,6 +15,9 @@ # Global variables required_exchanges: List[str] = [] requried_connector_trading_pairs: Dict[str, List[str]] = {} +# Set these two variables if a strategy uses oracle for rate conversion +required_rate_oracle: bool = False +rate_oracle_pairs: List[str] = [] # Global static values KEYFILE_PREFIX = "key_file_" @@ -95,7 +98,7 @@ def base_name(self) -> str: def _create_connector_settings() -> Dict[str, ConnectorSetting]: - connector_exceptions = ["paper_trade"] + connector_exceptions = ["paper_trade", "eterbase"] connector_settings = {} package_dir = Path(__file__).resolve().parent.parent.parent type_dirs = [f for f in scandir(f'{str(package_dir)}/hummingbot/connector') if f.is_dir()] diff --git a/hummingbot/client/ui/completer.py b/hummingbot/client/ui/completer.py index ef85af2ccb..bdb7ae66fa 100644 --- a/hummingbot/client/ui/completer.py +++ b/hummingbot/client/ui/completer.py @@ -21,6 +21,7 @@ from hummingbot.core.utils.wallet_setup import list_wallets from hummingbot.core.utils.trading_pair_fetcher import TradingPairFetcher from hummingbot.client.command.connect_command import OPTIONS as CONNECT_OPTIONS +from hummingbot.core.rate_oracle.rate_oracle import RateOracleSource def file_name_list(path, file_extension): @@ -39,9 +40,9 @@ def __init__(self, hummingbot_application): 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._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._exchange_completer = WordCompleter(sorted(CONNECTOR_SETTINGS.keys()), ignore_case=True) + self._spot_completer = WordCompleter(sorted(EXCHANGES.union(SPOT_PROTOCOL_CONNECTOR)), ignore_case=True) + self._spot_exchange_completer = WordCompleter(sorted(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) @@ -50,6 +51,7 @@ def __init__(self, hummingbot_application): self._history_completer = WordCompleter(["--days", "--verbose", "--precision"], ignore_case=True) self._strategy_completer = WordCompleter(STRATEGIES, ignore_case=True) self._py_file_completer = WordCompleter(file_name_list(SCRIPTS_PATH, "py")) + self._rate_oracle_completer = WordCompleter([r.name for r in RateOracleSource], ignore_case=True) @property def prompt_text(self) -> str: @@ -66,11 +68,12 @@ def get_subcommand_completer(self, first_word: str) -> Completer: @property def _trading_pair_completer(self) -> Completer: trading_pair_fetcher = TradingPairFetcher.get_instance() + market = "" for exchange in sorted(list(CONNECTOR_SETTINGS.keys()), key=len, reverse=True): if exchange in self.prompt_text: market = exchange break - trading_pairs = trading_pair_fetcher.trading_pairs.get(market, []) if trading_pair_fetcher.ready else [] + trading_pairs = trading_pair_fetcher.trading_pairs.get(market, []) if trading_pair_fetcher.ready and market else [] return WordCompleter(trading_pairs, ignore_case=True, sentence=True) @property @@ -156,6 +159,9 @@ def _complete_balance_limit_exchanges(self, document: Document): command_args = text_before_cursor.split(" ") return len(command_args) == 3 and command_args[0] == "balance" and command_args[1] == "limit" + def _complete_rate_oracle_source(self, document: Document): + return all(x in self.prompt_text for x in ("source", "rate oracle")) + def get_completions(self, document: Document, complete_event: CompleteEvent): """ Get completions for the current scope. This is the defining function for the completer @@ -234,6 +240,10 @@ def get_completions(self, document: Document, complete_event: CompleteEvent): for c in self._option_completer.get_completions(document, complete_event): yield c + elif self._complete_rate_oracle_source(document): + for c in self._rate_oracle_completer.get_completions(document, complete_event): + yield c + else: text_before_cursor: str = document.text_before_cursor try: diff --git a/hummingbot/client/ui/parser.py b/hummingbot/client/ui/parser.py index 06fb6a20da..f1fff6fa83 100644 --- a/hummingbot/client/ui/parser.py +++ b/hummingbot/client/ui/parser.py @@ -139,4 +139,11 @@ def load_parser(hummingbot) -> ThrowingArgumentParser: ticker_parser.add_argument("--market", type=str, dest="market", help="The market (trading pair) of the order book") ticker_parser.set_defaults(func=hummingbot.ticker) + rate_parser = subparsers.add_parser('rate', help="Show rate of a given trading pair") + rate_parser.add_argument("-p", "--pair", default=None, + dest="pair", help="The market trading pair you want to see rate.") + rate_parser.add_argument("-t", "--token", default=None, + dest="token", help="The token you want to see its value.") + rate_parser.set_defaults(func=hummingbot.rate) + return parser diff --git a/hummingbot/connector/connector/balancer/balancer_connector.py b/hummingbot/connector/connector/balancer/balancer_connector.py index e1bc1e131d..179c6b2e8f 100644 --- a/hummingbot/connector/connector/balancer/balancer_connector.py +++ b/hummingbot/connector/connector/balancer/balancer_connector.py @@ -7,7 +7,6 @@ import time import ssl import copy -import itertools as it from hummingbot.logger.struct_logger import METRICS_LOG_LEVEL from hummingbot.core.utils import async_ttl_cache from hummingbot.core.network_iterator import NetworkStatus @@ -31,9 +30,9 @@ from hummingbot.connector.connector_base import ConnectorBase from hummingbot.connector.connector.balancer.balancer_in_flight_order import BalancerInFlightOrder 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.client.config.config_helpers import get_erc20_token_addresses +from hummingbot.core.utils.ethereum import check_transaction_exceptions, fetch_trading_pairs +from hummingbot.client.config.fee_overrides_config_map import fee_overrides_config_map s_logger = None s_decimal_0 = Decimal("0") @@ -71,12 +70,9 @@ def __init__(self, """ super().__init__() self._trading_pairs = trading_pairs - tokens = set() + self._tokens = set() for trading_pair in trading_pairs: - tokens.update(set(trading_pair.split("-"))) - self._erc_20_token_list = self.token_list() - self._token_addresses = {t: l[0] for t, l in self._erc_20_token_list.items() if t in tokens} - self._token_decimals = {t: l[1] for t, l in self._erc_20_token_list.items() if t in tokens} + self._tokens.update(set(trading_pair.split("-"))) self._wallet_private_key = wallet_private_key self._ethereum_rpc_url = ethereum_rpc_url self._trading_required = trading_required @@ -84,10 +80,13 @@ def __init__(self, self._shared_client = None self._last_poll_timestamp = 0.0 self._last_balance_poll_timestamp = time.time() + self._last_est_gas_cost_reported = 0 self._in_flight_orders = {} self._allowances = {} self._status_polling_task = None self._auto_approve_task = None + self._initiate_pool_task = None + self._initiate_pool_status = None self._real_time_balance_update = False self._max_swaps = global_config_map['balancer_max_swaps'].value self._poll_notifier = None @@ -96,17 +95,9 @@ def __init__(self, def name(self): return "balancer" - @staticmethod - def token_list(): - return get_erc20_token_addresses() - @staticmethod async def fetch_trading_pairs() -> List[str]: - token_list = BalancerConnector.token_list() - trading_pairs = [] - for base, quote in it.permutations(token_list.keys(), 2): - trading_pairs.append(f"{base}-{quote}") - return trading_pairs + return await fetch_trading_pairs() @property def limit_orders(self) -> List[LimitOrder]: @@ -115,6 +106,26 @@ def limit_orders(self) -> List[LimitOrder]: for in_flight_order in self._in_flight_orders.values() ] + async def initiate_pool(self) -> str: + """ + Initiate connector and cache pools + """ + try: + self.logger().info(f"Initializing Balancer connector and caching pools for {self._trading_pairs}.") + resp = await self._api_request("get", "eth/balancer/start", + {"pairs": json.dumps(self._trading_pairs)}) + status = bool(str(resp["success"])) + if bool(str(resp["success"])): + self._initiate_pool_status = status + except asyncio.CancelledError: + raise + except Exception as e: + self.logger().network( + f"Error initializing {self._trading_pairs} swap pools", + exc_info=True, + app_warning_msg=str(e) + ) + async def auto_approve(self): """ Automatically approves Balancer contract as a spender for token in trading pairs. @@ -138,9 +149,7 @@ async def approve_balancer_spender(self, token_symbol: str) -> Decimal: """ resp = await self._api_request("post", "eth/approve", - {"tokenAddress": self._token_addresses[token_symbol], - "gasPrice": str(get_gas_price()), - "decimals": self._token_decimals[token_symbol], # if not supplied, gateway would treat it eth-like with 18 decimals + {"token": token_symbol, "connector": self.name}) amount_approved = Decimal(str(resp["amount"])) if amount_approved > 0: @@ -155,12 +164,11 @@ async def get_allowances(self) -> Dict[str, Decimal]: :return: A dictionary of token and its allowance (how much Balancer can spend). """ ret_val = {} - resp = await self._api_request("post", "eth/allowances-2", - {"tokenAddressList": ("".join([tok + "," for tok in self._token_addresses.values()])).rstrip(","), - "tokenDecimalList": ("".join([str(dec) + "," for dec in self._token_decimals.values()])).rstrip(","), + resp = await self._api_request("post", "eth/allowances", + {"tokenList": "[" + (",".join(['"' + t + '"' for t in self._tokens])) + "]", "connector": self.name}) - for address, amount in resp["approvals"].items(): - ret_val[self.get_token(address)] = Decimal(str(amount)) + for token, amount in resp["approvals"].items(): + ret_val[token] = Decimal(str(amount)) return ret_val @async_ttl_cache(ttl=5, maxsize=10) @@ -177,15 +185,44 @@ async def get_quote_price(self, trading_pair: str, is_buy: bool, amount: Decimal base, quote = trading_pair.split("-") side = "buy" if is_buy else "sell" resp = await self._api_request("post", - f"balancer/{side}-price", - {"base": self._token_addresses[base], - "quote": self._token_addresses[quote], + "eth/balancer/price", + {"base": base, + "quote": quote, "amount": amount, - "base_decimals": self._token_decimals[base], - "quote_decimals": self._token_decimals[quote], - "maxSwaps": self._max_swaps}) - if resp["price"] is not None: - return Decimal(str(resp["price"])) + "side": side.upper()}) + required_items = ["price", "gasLimit", "gasPrice", "gasCost"] + if any(item not in resp.keys() for item in required_items): + if "info" in resp.keys(): + self.logger().info(f"Unable to get price. {resp['info']}") + else: + self.logger().info(f"Missing data from price result. Incomplete return result for ({resp.keys()})") + else: + gas_limit = resp["gasLimit"] + gas_price = resp["gasPrice"] + gas_cost = resp["gasCost"] + price = resp["price"] + account_standing = { + "allowances": self._allowances, + "balances": self._account_balances, + "base": base, + "quote": quote, + "amount": amount, + "side": side, + "gas_limit": gas_limit, + "gas_price": gas_price, + "gas_cost": gas_cost, + "price": price, + "swaps": len(resp["swaps"]) + } + exceptions = check_transaction_exceptions(account_standing) + for index in range(len(exceptions)): + self.logger().info(f"Warning! [{index+1}/{len(exceptions)}] {side} order - {exceptions[index]}") + + if price is not None and len(exceptions) == 0: + # TODO standardize quote price object to include price, fee, token, is fee part of quote. + fee_overrides_config_map["balancer_maker_fee_amount"].value = Decimal(str(gas_cost)) + fee_overrides_config_map["balancer_taker_fee_amount"].value = Decimal(str(gas_cost)) + return Decimal(str(price)) except asyncio.CancelledError: raise except Exception as e: @@ -255,24 +292,28 @@ 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 = {"base": self._token_addresses[base], - "quote": self._token_addresses[quote], + api_params = {"base": base, + "quote": quote, + "side": trade_type.name.upper(), "amount": str(amount), - "maxPrice": str(price), - "maxSwaps": str(self._max_swaps), - "gasPrice": str(gas_price), - "base_decimals": self._token_decimals[base], - "quote_decimals": self._token_decimals[quote], + "limitPrice": str(price), } - self.start_tracking_order(order_id, None, trading_pair, trade_type, price, amount, gas_price) try: - order_result = await self._api_request("post", f"balancer/{trade_type.name.lower()}", api_params) + order_result = await self._api_request("post", "eth/balancer/trade", api_params) hash = order_result.get("txHash") + gas_price = order_result.get("gasPrice") + gas_limit = order_result.get("gasLimit") + gas_cost = order_result.get("gasCost") + self.start_tracking_order(order_id, None, trading_pair, trade_type, price, amount, gas_price) tracked_order = self._in_flight_orders.get(order_id) + + # update onchain balance + await self._update_balances() + if tracked_order is not None: self.logger().info(f"Created {trade_type.name} order {order_id} txHash: {hash} " - f"for {amount} {trading_pair}.") + f"for {amount} {trading_pair}. Estimated Gas Cost: {gas_cost} ETH " + f" (gas limit: {gas_limit}, gas price: {gas_price})") tracked_order.update_exchange_order_id(hash) tracked_order.gas_price = gas_price if hash is not None: @@ -340,7 +381,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", + "eth/poll", {"txHash": order_id})) update_results = await safe_gather(*tasks, return_exceptions=True) for update_result in update_results: @@ -416,7 +457,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 \ + return len(self._allowances.values()) == len(self._tokens) and \ all(amount > s_decimal_0 for amount in self._allowances.values()) @property @@ -429,6 +470,7 @@ def status_dict(self) -> Dict[str, bool]: async def start_network(self): if self._trading_required: self._status_polling_task = safe_ensure_future(self._status_polling_loop()) + self._initiate_pool_task = safe_ensure_future(self.initiate_pool()) self._auto_approve_task = safe_ensure_future(self.auto_approve()) async def stop_network(self): @@ -438,6 +480,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._initiate_pool_task is not None: + self._initiate_pool_task.cancel() + self._initiate_pool_task = None async def check_network(self) -> NetworkStatus: try: @@ -478,9 +523,6 @@ async def _status_polling_loop(self): app_warning_msg="Could not fetch balances from Gateway API.") await asyncio.sleep(0.5) - def get_token(self, token_address: str) -> str: - return [k for k, v in self._token_addresses.items() if v == token_address][0] - async def _update_balances(self, on_interval = False): """ Calls Eth API to update total and available balances. @@ -492,12 +534,10 @@ async def _update_balances(self, on_interval = False): local_asset_names = set(self._account_balances.keys()) remote_asset_names = set() resp_json = await self._api_request("post", - "eth/balances-2", - {"tokenAddressList": ("".join([tok + "," for tok in self._token_addresses.values()])).rstrip(","), - "tokenDecimalList": ("".join([str(dec) + "," for dec in self._token_decimals.values()])).rstrip(",")}) + "eth/balances", + {"tokenList": "[" + (",".join(['"' + t + '"' for t in self._tokens])) + "]"}) + 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) @@ -554,7 +594,7 @@ async def _api_request(self, 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']}") + raise Exception(f"Error: {parsed_response['error']} {parsed_response['message']}") return parsed_response diff --git a/hummingbot/connector/connector/terra/terra_connector.py b/hummingbot/connector/connector/terra/terra_connector.py index 1836750621..0f27b3e0ed 100644 --- a/hummingbot/connector/connector/terra/terra_connector.py +++ b/hummingbot/connector/connector/terra/terra_connector.py @@ -107,7 +107,7 @@ async def get_quote_price(self, trading_pair: str, is_buy: bool, amount: Decimal base, quote = trading_pair.split("-") side = "buy" if is_buy else "sell" - resp = await self._api_request("post", "terra/price", {"base": base, "quote": quote, "trade_type": side, + resp = await self._api_request("post", "terra/price", {"base": base, "quote": quote, "side": side, "amount": str(amount)}) txFee = resp["txFee"] / float(amount) price_with_txfee = resp["price"] + txFee if is_buy else resp["price"] - txFee @@ -185,9 +185,9 @@ async def _create_order(self, base, quote = trading_pair.split("-") api_params = {"base": base, "quote": quote, - "trade_type": "buy" if trade_type is TradeType.BUY else "sell", + "side": "buy" if trade_type is TradeType.BUY else "sell", "amount": str(amount), - "secret": self._terra_wallet_seeds, + "privateKey": self._terra_wallet_seeds, # "maxPrice": str(price), } self.start_tracking_order(order_id, None, trading_pair, trade_type, price, amount) diff --git a/hummingbot/connector/connector/uniswap/uniswap_connector.py b/hummingbot/connector/connector/uniswap/uniswap_connector.py index 2fef254945..c8277b65b1 100644 --- a/hummingbot/connector/connector/uniswap/uniswap_connector.py +++ b/hummingbot/connector/connector/uniswap/uniswap_connector.py @@ -7,7 +7,6 @@ import time import ssl import copy -import itertools as it from hummingbot.logger.struct_logger import METRICS_LOG_LEVEL from hummingbot.core.utils import async_ttl_cache from hummingbot.core.network_iterator import NetworkStatus @@ -31,9 +30,9 @@ from hummingbot.connector.connector_base import ConnectorBase from hummingbot.connector.connector.uniswap.uniswap_in_flight_order import UniswapInFlightOrder 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.client.config.config_helpers import get_erc20_token_addresses +from hummingbot.core.utils.ethereum import check_transaction_exceptions, fetch_trading_pairs +from hummingbot.client.config.fee_overrides_config_map import fee_overrides_config_map s_logger = None s_decimal_0 = Decimal("0") @@ -71,12 +70,9 @@ def __init__(self, """ super().__init__() self._trading_pairs = trading_pairs - tokens = set() + self._tokens = set() for trading_pair in trading_pairs: - tokens.update(set(trading_pair.split("-"))) - self._erc_20_token_list = self.token_list() - self._token_addresses = {t: l[0] for t, l in self._erc_20_token_list.items() if t in tokens} - self._token_decimals = {t: l[1] for t, l in self._erc_20_token_list.items() if t in tokens} + self._tokens.update(set(trading_pair.split("-"))) self._wallet_private_key = wallet_private_key self._ethereum_rpc_url = ethereum_rpc_url self._trading_required = trading_required @@ -84,10 +80,13 @@ def __init__(self, self._shared_client = None self._last_poll_timestamp = 0.0 self._last_balance_poll_timestamp = time.time() + self._last_est_gas_cost_reported = 0 self._in_flight_orders = {} self._allowances = {} self._status_polling_task = None self._auto_approve_task = None + self._initiate_pool_task = None + self._initiate_pool_status = None self._real_time_balance_update = False self._poll_notifier = None @@ -95,17 +94,9 @@ def __init__(self, def name(self): return "uniswap" - @staticmethod - def token_list(): - return get_erc20_token_addresses() - @staticmethod async def fetch_trading_pairs() -> List[str]: - token_list = UniswapConnector.token_list() - trading_pairs = [] - for base, quote in it.permutations(token_list.keys(), 2): - trading_pairs.append(f"{base}-{quote}") - return trading_pairs + return await fetch_trading_pairs() @property def limit_orders(self) -> List[LimitOrder]: @@ -114,6 +105,26 @@ def limit_orders(self) -> List[LimitOrder]: for in_flight_order in self._in_flight_orders.values() ] + async def initiate_pool(self) -> str: + """ + Initiate connector and start caching paths for trading_pairs + """ + try: + self.logger().info(f"Initializing Uniswap connector and paths for {self._trading_pairs} pairs.") + resp = await self._api_request("get", "eth/uniswap/start", + {"pairs": json.dumps(self._trading_pairs)}) + status = bool(str(resp["success"])) + if bool(str(resp["success"])): + self._initiate_pool_status = status + except asyncio.CancelledError: + raise + except Exception as e: + self.logger().network( + f"Error initializing {self._trading_pairs} ", + exc_info=True, + app_warning_msg=str(e) + ) + async def auto_approve(self): """ Automatically approves Uniswap contract as a spender for token in trading pairs. @@ -137,9 +148,7 @@ async def approve_uniswap_spender(self, token_symbol: str) -> Decimal: """ resp = await self._api_request("post", "eth/approve", - {"tokenAddress": self._token_addresses[token_symbol], - "gasPrice": str(get_gas_price()), - "decimals": self._token_decimals[token_symbol], # if not supplied, gateway would treat it eth-like with 18 decimals + {"token": token_symbol, "connector": self.name}) amount_approved = Decimal(str(resp["amount"])) if amount_approved > 0: @@ -154,12 +163,11 @@ async def get_allowances(self) -> Dict[str, Decimal]: :return: A dictionary of token and its allowance (how much Uniswap can spend). """ ret_val = {} - resp = await self._api_request("post", "eth/allowances-2", - {"tokenAddressList": ("".join([tok + "," for tok in self._token_addresses.values()])).rstrip(","), - "tokenDecimalList": ("".join([str(dec) + "," for dec in self._token_decimals.values()])).rstrip(","), + resp = await self._api_request("post", "eth/allowances", + {"tokenList": "[" + (",".join(['"' + t + '"' for t in self._tokens])) + "]", "connector": self.name}) - for address, amount in resp["approvals"].items(): - ret_val[self.get_token(address)] = Decimal(str(amount)) + for token, amount in resp["approvals"].items(): + ret_val[token] = Decimal(str(amount)) return ret_val @async_ttl_cache(ttl=5, maxsize=10) @@ -176,12 +184,43 @@ async def get_quote_price(self, trading_pair: str, is_buy: bool, amount: Decimal base, quote = trading_pair.split("-") side = "buy" if is_buy else "sell" resp = await self._api_request("post", - f"uniswap/{side}-price", - {"base": self._token_addresses[base], - "quote": self._token_addresses[quote], + "eth/uniswap/price", + {"base": base, + "quote": quote, + "side": side.upper(), "amount": amount}) - if resp["price"] is not None: - return Decimal(str(resp["price"])) + required_items = ["price", "gasLimit", "gasPrice", "gasCost"] + if any(item not in resp.keys() for item in required_items): + if "info" in resp.keys(): + self.logger().info(f"Unable to get price. {resp['info']}") + else: + self.logger().info(f"Missing data from price result. Incomplete return result for ({resp.keys()})") + else: + gas_limit = resp["gasLimit"] + gas_price = resp["gasPrice"] + gas_cost = resp["gasCost"] + price = resp["price"] + account_standing = { + "allowances": self._allowances, + "balances": self._account_balances, + "base": base, + "quote": quote, + "amount": amount, + "side": side, + "gas_limit": gas_limit, + "gas_price": gas_price, + "gas_cost": gas_cost, + "price": price + } + exceptions = check_transaction_exceptions(account_standing) + for index in range(len(exceptions)): + self.logger().info(f"Warning! [{index+1}/{len(exceptions)}] {side} order - {exceptions[index]}") + + if price is not None and len(exceptions) == 0: + # TODO standardize quote price object to include price, fee, token, is fee part of quote. + fee_overrides_config_map["uniswap_maker_fee_amount"].value = Decimal(str(gas_cost)) + fee_overrides_config_map["uniswap_taker_fee_amount"].value = Decimal(str(gas_cost)) + return Decimal(str(price)) except asyncio.CancelledError: raise except Exception as e: @@ -251,21 +290,24 @@ 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 = {"base": self._token_addresses[base], - "quote": self._token_addresses[quote], + api_params = {"base": base, + "quote": quote, + "side": trade_type.name.upper(), "amount": str(amount), - "maxPrice": str(price), - "gasPrice": str(gas_price), + "limitPrice": str(price), } - self.start_tracking_order(order_id, None, trading_pair, trade_type, price, amount, gas_price) try: - order_result = await self._api_request("post", f"uniswap/{trade_type.name.lower()}", api_params) + order_result = await self._api_request("post", "eth/uniswap/trade", api_params) hash = order_result.get("txHash") + gas_price = order_result.get("gasPrice") + gas_limit = order_result.get("gasLimit") + gas_cost = order_result.get("gasCost") + self.start_tracking_order(order_id, None, trading_pair, trade_type, price, amount, gas_price) 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}.") + f"for {amount} {trading_pair}. Estimated Gas Cost: {gas_cost} ETH " + f" (gas limit: {gas_limit}, gas price: {gas_price})") tracked_order.update_exchange_order_id(hash) tracked_order.gas_price = gas_price if hash is not None: @@ -333,7 +375,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", + "eth/poll", {"txHash": order_id})) update_results = await safe_gather(*tasks, return_exceptions=True) for update_result in update_results: @@ -409,7 +451,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 \ + return len(self._allowances.values()) == len(self._tokens) and \ all(amount > s_decimal_0 for amount in self._allowances.values()) @property @@ -422,6 +464,7 @@ def status_dict(self) -> Dict[str, bool]: async def start_network(self): if self._trading_required: self._status_polling_task = safe_ensure_future(self._status_polling_loop()) + self._initiate_pool_task = safe_ensure_future(self.initiate_pool()) self._auto_approve_task = safe_ensure_future(self.auto_approve()) async def stop_network(self): @@ -431,6 +474,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._initiate_pool_task is not None: + self._initiate_pool_task.cancel() + self._initiate_pool_task = None async def check_network(self) -> NetworkStatus: try: @@ -458,7 +504,7 @@ async def _status_polling_loop(self): self._poll_notifier = asyncio.Event() await self._poll_notifier.wait() await safe_gather( - self._update_balances(), + self._update_balances(on_interval=True), self._update_order_status(), ) self._last_poll_timestamp = self.current_timestamp @@ -471,37 +517,32 @@ async def _status_polling_loop(self): app_warning_msg="Could not fetch balances from Gateway API.") await asyncio.sleep(0.5) - def get_token(self, token_address: str) -> str: - return [k for k, v in self._token_addresses.items() if v == token_address][0] - - async def _update_balances(self): + async def _update_balances(self, on_interval = False): """ 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: + if not on_interval or (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", - "eth/balances-2", - {"tokenAddressList": ("".join([tok + "," for tok in self._token_addresses.values()])).rstrip(","), - "tokenDecimalList": ("".join([str(dec) + "," for dec in self._token_decimals.values()])).rstrip(",")}) - 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 + local_asset_names = set(self._account_balances.keys()) + remote_asset_names = set() + resp_json = await self._api_request("post", + "eth/balances", + {"tokenList": "[" + (",".join(['"' + t + '"' for t in self._tokens])) + "]"}) + + for token, bal in resp_json["balances"].items(): + 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: """ @@ -547,7 +588,7 @@ async def _api_request(self, 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']}") + raise Exception(f"Error: {parsed_response['error']} {parsed_response['message']}") return parsed_response diff --git a/hummingbot/connector/connector_status.py b/hummingbot/connector/connector_status.py index f220808b1e..314494bc23 100644 --- a/hummingbot/connector/connector_status.py +++ b/hummingbot/connector/connector_status.py @@ -1,28 +1,30 @@ #!/usr/bin/env python connector_status = { + 'ascend_ex': 'yellow', 'balancer': 'green', - 'beaxy': 'yellow', + 'beaxy': 'green', 'binance': 'green', - 'binance_perpetual': 'green', - 'binance_perpetual_testnet': 'green', + 'binance_perpetual': 'yellow', + 'binance_perpetual_testnet': 'yellow', 'binance_us': 'yellow', 'bitfinex': 'yellow', - 'bitmax': 'green', 'bittrex': 'yellow', 'blocktane': 'green', 'celo': 'green', - 'coinbase_pro': 'green', + 'coinbase_pro': 'yellow', + 'coinzoom': 'yellow', 'crypto_com': 'yellow', + 'digifinex': "yellow", 'dydx': 'green', - 'eterbase': 'red', 'ethereum': 'red', + 'hitbtc': 'yellow', 'huobi': 'green', 'kraken': 'green', 'kucoin': 'green', - 'liquid': 'green', + 'liquid': 'yellow', 'loopring': 'yellow', - 'okex': 'green', + 'okex': 'yellow', 'perpetual_finance': 'yellow', 'probit': 'yellow', 'probit_kr': 'yellow', diff --git a/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_api_order_book_data_source.py b/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_api_order_book_data_source.py index b6e54ed720..9dc900251f 100644 --- a/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_api_order_book_data_source.py +++ b/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_api_order_book_data_source.py @@ -2,13 +2,10 @@ import logging import time from typing import Dict, List, Optional, Any, AsyncIterable -from decimal import Decimal import aiohttp import pandas as pd import ujson -import requests -import cachetools.func import websockets from websockets.exceptions import ConnectionClosed @@ -83,17 +80,6 @@ async def get_trading_pairs(self) -> List[str]: return self._trading_pairs """ - @staticmethod - @cachetools.func.ttl_cache(ttl=10) - def get_mid_price(trading_pair: str, domain=None) -> Optional[Decimal]: - from hummingbot.connector.derivative.binance_perpetual.binance_perpetual_utils import convert_to_exchange_trading_pair - - BASE_URL = TESTNET_BASE_URL if domain == "binance_perpetual_testnet" else PERPETUAL_BASE_URL - resp = requests.get(url=f"{TICKER_PRICE_URL.format(BASE_URL)}?symbol={convert_to_exchange_trading_pair(trading_pair)}") - record = resp.json() - result = (Decimal(record["bidPrice"]) + Decimal(record["askPrice"])) / Decimal("2") - return result - @staticmethod async def fetch_trading_pairs(domain=None) -> List[str]: try: diff --git a/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_derivative.py b/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_derivative.py index 94a4bbb2e5..252895e083 100644 --- a/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_derivative.py +++ b/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_derivative.py @@ -556,7 +556,7 @@ async def _user_stream_event_listener(self): update_data = event_message.get("a", {}) event_reason = update_data.get("m", {}) if event_reason == "FUNDING_FEE": - await self.get_funding_payment(event_message.get("E", int(time.time()))) + await self.get_funding_payment() else: # update balances for asset in update_data.get("B", []): @@ -933,7 +933,7 @@ 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}, + params={"symbol": convert_to_exchange_trading_pair(pair), "incomeType": "FUNDING_FEE", "limit": len(self._account_positions)}, method=MethodType.POST, add_timestamp=True, is_signed=True)) diff --git a/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_order_book_tracker.py b/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_order_book_tracker.py index e4df92f7b9..c25ba5444b 100644 --- a/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_order_book_tracker.py +++ b/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_order_book_tracker.py @@ -80,10 +80,8 @@ async def _order_book_diff_router(self): now: float = time.time() if int(now / 60.0) > int(last_message_timestamp / 60.0): - self.logger().debug("Diff messages process: %d, rejected: %d, queued: %d", - messages_accepted, - messages_rejected, - messages_queued) + self.logger().debug(f"Diff messages processed: {messages_accepted}, " + f"rejected: {messages_rejected}, queued: {messages_queued}") messages_accepted = 0 messages_rejected = 0 messages_queued = 0 @@ -128,14 +126,13 @@ async def _track_single_book(self, trading_pair: str): 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) + self.logger().debug(f"Processed {diff_messages_accepted} order book diffs for {trading_pair}.") diff_messages_accepted = 0 last_message_timestamp = now elif message.type is OrderBookMessageType.SNAPSHOT: past_diffs: List[OrderBookMessage] = list(past_diffs_window) order_book.restore_from_snapshot_and_diffs(message, past_diffs) - self.logger().debug("Processed order book snapshot for %s.", trading_pair) + self.logger().debug(f"Processed order book snapshot for {trading_pair}.") except asyncio.CancelledError: raise except Exception: diff --git a/hummingbot/connector/exchange/bitmax/__init__.py b/hummingbot/connector/exchange/ascend_ex/__init__.py similarity index 100% rename from hummingbot/connector/exchange/bitmax/__init__.py rename to hummingbot/connector/exchange/ascend_ex/__init__.py diff --git a/hummingbot/connector/exchange/bitmax/bitmax_active_order_tracker.pxd b/hummingbot/connector/exchange/ascend_ex/ascend_ex_active_order_tracker.pxd similarity index 90% rename from hummingbot/connector/exchange/bitmax/bitmax_active_order_tracker.pxd rename to hummingbot/connector/exchange/ascend_ex/ascend_ex_active_order_tracker.pxd index fbc1eb3080..833d9864a0 100644 --- a/hummingbot/connector/exchange/bitmax/bitmax_active_order_tracker.pxd +++ b/hummingbot/connector/exchange/ascend_ex/ascend_ex_active_order_tracker.pxd @@ -1,7 +1,7 @@ # distutils: language=c++ cimport numpy as np -cdef class BitmaxActiveOrderTracker: +cdef class AscendExActiveOrderTracker: cdef dict _active_bids cdef dict _active_asks diff --git a/hummingbot/connector/exchange/bitmax/bitmax_active_order_tracker.pyx b/hummingbot/connector/exchange/ascend_ex/ascend_ex_active_order_tracker.pyx similarity index 93% rename from hummingbot/connector/exchange/bitmax/bitmax_active_order_tracker.pyx rename to hummingbot/connector/exchange/ascend_ex/ascend_ex_active_order_tracker.pyx index 092a97c45c..b80930b8b2 100644 --- a/hummingbot/connector/exchange/bitmax/bitmax_active_order_tracker.pyx +++ b/hummingbot/connector/exchange/ascend_ex/ascend_ex_active_order_tracker.pyx @@ -12,12 +12,12 @@ from hummingbot.core.data_type.order_book_row import OrderBookRow _logger = None s_empty_diff = np.ndarray(shape=(0, 4), dtype="float64") -BitmaxOrderBookTrackingDictionary = Dict[Decimal, Dict[str, Dict[str, any]]] +AscendExOrderBookTrackingDictionary = Dict[Decimal, Dict[str, Dict[str, any]]] -cdef class BitmaxActiveOrderTracker: +cdef class AscendExActiveOrderTracker: def __init__(self, - active_asks: BitmaxOrderBookTrackingDictionary = None, - active_bids: BitmaxOrderBookTrackingDictionary = None): + active_asks: AscendExOrderBookTrackingDictionary = None, + active_bids: AscendExOrderBookTrackingDictionary = None): super().__init__() self._active_asks = active_asks or {} self._active_bids = active_bids or {} @@ -30,11 +30,11 @@ cdef class BitmaxActiveOrderTracker: return _logger @property - def active_asks(self) -> BitmaxOrderBookTrackingDictionary: + def active_asks(self) -> AscendExOrderBookTrackingDictionary: return self._active_asks @property - def active_bids(self) -> BitmaxOrderBookTrackingDictionary: + def active_bids(self) -> AscendExOrderBookTrackingDictionary: return self._active_bids # TODO: research this more diff --git a/hummingbot/connector/exchange/bitmax/bitmax_api_order_book_data_source.py b/hummingbot/connector/exchange/ascend_ex/ascend_ex_api_order_book_data_source.py similarity index 91% rename from hummingbot/connector/exchange/bitmax/bitmax_api_order_book_data_source.py rename to hummingbot/connector/exchange/ascend_ex/ascend_ex_api_order_book_data_source.py index 306b0631c4..0c64a9c1c4 100644 --- a/hummingbot/connector/exchange/bitmax/bitmax_api_order_book_data_source.py +++ b/hummingbot/connector/exchange/ascend_ex/ascend_ex_api_order_book_data_source.py @@ -13,13 +13,13 @@ from hummingbot.core.data_type.order_book_tracker_data_source import OrderBookTrackerDataSource from hummingbot.core.utils.async_utils import safe_gather from hummingbot.logger import HummingbotLogger -from hummingbot.connector.exchange.bitmax.bitmax_active_order_tracker import BitmaxActiveOrderTracker -from hummingbot.connector.exchange.bitmax.bitmax_order_book import BitmaxOrderBook -from hummingbot.connector.exchange.bitmax.bitmax_utils import convert_from_exchange_trading_pair, convert_to_exchange_trading_pair -from hummingbot.connector.exchange.bitmax.bitmax_constants import EXCHANGE_NAME, REST_URL, WS_URL, PONG_PAYLOAD +from hummingbot.connector.exchange.ascend_ex.ascend_ex_active_order_tracker import AscendExActiveOrderTracker +from hummingbot.connector.exchange.ascend_ex.ascend_ex_order_book import AscendExOrderBook +from hummingbot.connector.exchange.ascend_ex.ascend_ex_utils import convert_from_exchange_trading_pair, convert_to_exchange_trading_pair +from hummingbot.connector.exchange.ascend_ex.ascend_ex_constants import EXCHANGE_NAME, REST_URL, WS_URL, PONG_PAYLOAD -class BitmaxAPIOrderBookDataSource(OrderBookTrackerDataSource): +class AscendExAPIOrderBookDataSource(OrderBookTrackerDataSource): MAX_RETRIES = 20 MESSAGE_TIMEOUT = 30.0 SNAPSHOT_TIMEOUT = 10.0 @@ -105,13 +105,13 @@ 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 = snapshot.get("data").get("ts") - snapshot_msg: OrderBookMessage = BitmaxOrderBook.snapshot_message_from_exchange( + snapshot_msg: OrderBookMessage = AscendExOrderBook.snapshot_message_from_exchange( snapshot.get("data"), snapshot_timestamp, metadata={"trading_pair": trading_pair} ) order_book = self.order_book_create_function() - active_order_tracker: BitmaxActiveOrderTracker = BitmaxActiveOrderTracker() + active_order_tracker: AscendExActiveOrderTracker = AscendExActiveOrderTracker() 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 @@ -141,7 +141,7 @@ async def listen_for_trades(self, ev_loop: asyncio.BaseEventLoop, output: asynci for trade in msg.get("data"): trade_timestamp: int = trade.get("ts") - trade_msg: OrderBookMessage = BitmaxOrderBook.trade_message_from_exchange( + trade_msg: OrderBookMessage = AscendExOrderBook.trade_message_from_exchange( trade, trade_timestamp, metadata={"trading_pair": trading_pair} @@ -181,7 +181,7 @@ async def listen_for_order_book_diffs(self, ev_loop: asyncio.BaseEventLoop, outp msg_timestamp: int = msg.get("data").get("ts") trading_pair: str = convert_from_exchange_trading_pair(msg.get("symbol")) - order_book_message: OrderBookMessage = BitmaxOrderBook.diff_message_from_exchange( + order_book_message: OrderBookMessage = AscendExOrderBook.diff_message_from_exchange( msg.get("data"), msg_timestamp, metadata={"trading_pair": trading_pair} @@ -207,7 +207,7 @@ async def listen_for_order_book_snapshots(self, ev_loop: asyncio.BaseEventLoop, try: snapshot: Dict[str, any] = await self.get_order_book_data(trading_pair) snapshot_timestamp: float = snapshot.get("data").get("ts") - snapshot_msg: OrderBookMessage = BitmaxOrderBook.snapshot_message_from_exchange( + snapshot_msg: OrderBookMessage = AscendExOrderBook.snapshot_message_from_exchange( snapshot.get("data"), snapshot_timestamp, metadata={"trading_pair": trading_pair} diff --git a/hummingbot/connector/exchange/bitmax/bitmax_api_user_stream_data_source.py b/hummingbot/connector/exchange/ascend_ex/ascend_ex_api_user_stream_data_source.py similarity index 79% rename from hummingbot/connector/exchange/bitmax/bitmax_api_user_stream_data_source.py rename to hummingbot/connector/exchange/ascend_ex/ascend_ex_api_user_stream_data_source.py index 10bbac6763..19571d90c8 100755 --- a/hummingbot/connector/exchange/bitmax/bitmax_api_user_stream_data_source.py +++ b/hummingbot/connector/exchange/ascend_ex/ascend_ex_api_user_stream_data_source.py @@ -9,11 +9,12 @@ from typing import Optional, List, AsyncIterable, Any from hummingbot.core.data_type.user_stream_tracker_data_source import UserStreamTrackerDataSource from hummingbot.logger import HummingbotLogger -from hummingbot.connector.exchange.bitmax.bitmax_auth import BitmaxAuth -from hummingbot.connector.exchange.bitmax.bitmax_constants import REST_URL, getWsUrlPriv, PONG_PAYLOAD +from hummingbot.connector.exchange.ascend_ex.ascend_ex_auth import AscendExAuth +from hummingbot.connector.exchange.ascend_ex.ascend_ex_constants import REST_URL, PONG_PAYLOAD +from hummingbot.connector.exchange.ascend_ex.ascend_ex_utils import get_ws_url_private -class BitmaxAPIUserStreamDataSource(UserStreamTrackerDataSource): +class AscendExAPIUserStreamDataSource(UserStreamTrackerDataSource): MAX_RETRIES = 20 MESSAGE_TIMEOUT = 10.0 PING_TIMEOUT = 5.0 @@ -26,8 +27,8 @@ def logger(cls) -> HummingbotLogger: cls._logger = logging.getLogger(__name__) return cls._logger - def __init__(self, bitmax_auth: BitmaxAuth, trading_pairs: Optional[List[str]] = []): - self._bitmax_auth: BitmaxAuth = bitmax_auth + def __init__(self, ascend_ex_auth: AscendExAuth, trading_pairs: Optional[List[str]] = []): + self._ascend_ex_auth: AscendExAuth = ascend_ex_auth self._trading_pairs = trading_pairs self._current_listen_key = None self._listen_for_user_stream_task = None @@ -50,18 +51,18 @@ async def listen_for_user_stream(self, ev_loop: asyncio.BaseEventLoop, output: a while True: try: response = await aiohttp.ClientSession().get(f"{REST_URL}/info", headers={ - **self._bitmax_auth.get_headers(), - **self._bitmax_auth.get_auth_headers("info"), + **self._ascend_ex_auth.get_headers(), + **self._ascend_ex_auth.get_auth_headers("info"), }) info = await response.json() accountGroup = info.get("data").get("accountGroup") - headers = self._bitmax_auth.get_auth_headers("stream") + headers = self._ascend_ex_auth.get_auth_headers("stream") payload = { "op": "sub", "ch": "order:cash" } - async with websockets.connect(f"{getWsUrlPriv(accountGroup)}/stream", extra_headers=headers) as ws: + async with websockets.connect(f"{get_ws_url_private(accountGroup)}/stream", extra_headers=headers) as ws: try: ws: websockets.WebSocketClientProtocol = ws await ws.send(ujson.dumps(payload)) @@ -75,12 +76,12 @@ async def listen_for_user_stream(self, ev_loop: asyncio.BaseEventLoop, output: a output.put_nowait(msg) except Exception: self.logger().error( - "Unexpected error when parsing Bitmax message. ", exc_info=True + "Unexpected error when parsing AscendEx message. ", exc_info=True ) raise except Exception: self.logger().error( - "Unexpected error while listening to Bitmax messages. ", exc_info=True + "Unexpected error while listening to AscendEx messages. ", exc_info=True ) raise finally: @@ -89,7 +90,7 @@ async def listen_for_user_stream(self, ev_loop: asyncio.BaseEventLoop, output: a raise except Exception: self.logger().error( - "Unexpected error with Bitmax WebSocket connection. " "Retrying after 30 seconds...", exc_info=True + "Unexpected error with AscendEx WebSocket connection. " "Retrying after 30 seconds...", exc_info=True ) await asyncio.sleep(30.0) diff --git a/hummingbot/connector/exchange/bitmax/bitmax_auth.py b/hummingbot/connector/exchange/ascend_ex/ascend_ex_auth.py similarity index 79% rename from hummingbot/connector/exchange/bitmax/bitmax_auth.py rename to hummingbot/connector/exchange/ascend_ex/ascend_ex_auth.py index 646fb13a8a..23b2c78f67 100755 --- a/hummingbot/connector/exchange/bitmax/bitmax_auth.py +++ b/hummingbot/connector/exchange/ascend_ex/ascend_ex_auth.py @@ -1,13 +1,13 @@ import hmac import hashlib from typing import Dict, Any -from hummingbot.connector.exchange.bitmax.bitmax_utils import get_ms_timestamp +from hummingbot.connector.exchange.ascend_ex.ascend_ex_utils import get_ms_timestamp -class BitmaxAuth(): +class AscendExAuth(): """ - Auth class required by bitmax API - Learn more at https://bitmax-exchange.github.io/bitmax-pro-api/#authenticate-a-restful-request + Auth class required by AscendEx API + Learn more at https://ascendex.github.io/ascendex-pro-api/#authenticate-a-restful-request """ def __init__(self, api_key: str, secret_key: str): self.api_key = api_key @@ -39,7 +39,7 @@ def get_auth_headers( def get_headers(self) -> Dict[str, Any]: """ - Generates generic headers required by bitmax + Generates generic headers required by AscendEx :return: a dictionary of headers """ diff --git a/hummingbot/connector/exchange/ascend_ex/ascend_ex_constants.py b/hummingbot/connector/exchange/ascend_ex/ascend_ex_constants.py new file mode 100644 index 0000000000..27165b3257 --- /dev/null +++ b/hummingbot/connector/exchange/ascend_ex/ascend_ex_constants.py @@ -0,0 +1,7 @@ +# A single source of truth for constant variables related to the exchange + + +EXCHANGE_NAME = "ascend_ex" +REST_URL = "https://ascendex.com/api/pro/v1" +WS_URL = "wss://ascendex.com/0/api/pro/v1/stream" +PONG_PAYLOAD = {"op": "pong"} diff --git a/hummingbot/connector/exchange/bitmax/bitmax_exchange.py b/hummingbot/connector/exchange/ascend_ex/ascend_ex_exchange.py similarity index 88% rename from hummingbot/connector/exchange/bitmax/bitmax_exchange.py rename to hummingbot/connector/exchange/ascend_ex/ascend_ex_exchange.py index 4606157dd3..a8aac4e618 100644 --- a/hummingbot/connector/exchange/bitmax/bitmax_exchange.py +++ b/hummingbot/connector/exchange/ascend_ex/ascend_ex_exchange.py @@ -36,25 +36,25 @@ TradeFee ) from hummingbot.connector.exchange_base import ExchangeBase -from hummingbot.connector.exchange.bitmax.bitmax_order_book_tracker import BitmaxOrderBookTracker -from hummingbot.connector.exchange.bitmax.bitmax_user_stream_tracker import BitmaxUserStreamTracker -from hummingbot.connector.exchange.bitmax.bitmax_auth import BitmaxAuth -from hummingbot.connector.exchange.bitmax.bitmax_in_flight_order import BitmaxInFlightOrder -from hummingbot.connector.exchange.bitmax import bitmax_utils -from hummingbot.connector.exchange.bitmax.bitmax_constants import EXCHANGE_NAME, REST_URL, getRestUrlPriv +from hummingbot.connector.exchange.ascend_ex.ascend_ex_order_book_tracker import AscendExOrderBookTracker +from hummingbot.connector.exchange.ascend_ex.ascend_ex_user_stream_tracker import AscendExUserStreamTracker +from hummingbot.connector.exchange.ascend_ex.ascend_ex_auth import AscendExAuth +from hummingbot.connector.exchange.ascend_ex.ascend_ex_in_flight_order import AscendExInFlightOrder +from hummingbot.connector.exchange.ascend_ex import ascend_ex_utils +from hummingbot.connector.exchange.ascend_ex.ascend_ex_constants import EXCHANGE_NAME, REST_URL from hummingbot.core.data_type.common import OpenOrder ctce_logger = None s_decimal_NaN = Decimal("nan") -BitmaxTradingRule = namedtuple("BitmaxTradingRule", "minNotional maxNotional") -BitmaxOrder = namedtuple("BitmaxOrder", "symbol price orderQty orderType avgPx cumFee cumFilledQty errorCode feeAsset lastExecTime orderId seqNum side status stopPrice execInst") -BitmaxBalance = namedtuple("BitmaxBalance", "asset availableBalance totalBalance") +AscendExTradingRule = namedtuple("AscendExTradingRule", "minNotional maxNotional") +AscendExOrder = namedtuple("AscendExOrder", "symbol price orderQty orderType avgPx cumFee cumFilledQty errorCode feeAsset lastExecTime orderId seqNum side status stopPrice execInst") +AscendExBalance = namedtuple("AscendExBalance", "asset availableBalance totalBalance") -class BitmaxExchange(ExchangeBase): +class AscendExExchange(ExchangeBase): """ - BitmaxExchange connects with Bitmax exchange and provides order book pricing, user account tracking and + AscendExExchange connects with AscendEx exchange and provides order book pricing, user account tracking and trading functionality. """ API_CALL_TIMEOUT = 10.0 @@ -70,31 +70,31 @@ def logger(cls) -> HummingbotLogger: return ctce_logger def __init__(self, - bitmax_api_key: str, - bitmax_secret_key: str, + ascend_ex_api_key: str, + ascend_ex_secret_key: str, trading_pairs: Optional[List[str]] = None, trading_required: bool = True ): """ - :param bitmax_api_key: The API key to connect to private Bitmax APIs. - :param bitmax_secret_key: The API secret. + :param ascend_ex_api_key: The API key to connect to private AscendEx APIs. + :param ascend_ex_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._bitmax_auth = BitmaxAuth(bitmax_api_key, bitmax_secret_key) - self._order_book_tracker = BitmaxOrderBookTracker(trading_pairs=trading_pairs) - self._user_stream_tracker = BitmaxUserStreamTracker(self._bitmax_auth, trading_pairs) + self._ascend_ex_auth = AscendExAuth(ascend_ex_api_key, ascend_ex_secret_key) + self._order_book_tracker = AscendExOrderBookTracker(trading_pairs=trading_pairs) + self._user_stream_tracker = AscendExUserStreamTracker(self._ascend_ex_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, BitmaxInFlightOrder] + self._in_flight_orders = {} # Dict[client_order_id:str, AscendExInFlightOrder] self._order_not_found_records = {} # Dict[client_order_id:str, count:int] self._trading_rules = {} # Dict[trading_pair:str, TradingRule] - self._bitmax_trading_rules = {} # Dict[trading_pair:str, BitmaxTradingRule] + self._ascend_ex_trading_rules = {} # Dict[trading_pair:str, AscendExTradingRule] self._status_polling_task = None self._user_stream_event_listener_task = None self._trading_rules_polling_task = None @@ -115,7 +115,7 @@ def trading_rules(self) -> Dict[str, TradingRule]: return self._trading_rules @property - def in_flight_orders(self) -> Dict[str, BitmaxInFlightOrder]: + def in_flight_orders(self) -> Dict[str, AscendExInFlightOrder]: return self._in_flight_orders @property @@ -126,7 +126,7 @@ def status_dict(self) -> Dict[str, bool]: return { "order_books_initialized": self._order_book_tracker.ready, "account_balance": len(self._account_balances) > 0 if self._trading_required else True, - "trading_rule_initialized": len(self._trading_rules) > 0 and len(self._bitmax_trading_rules) > 0, + "trading_rule_initialized": len(self._trading_rules) > 0 and len(self._ascend_ex_trading_rules) > 0, "user_stream_initialized": self._user_stream_tracker.data_source.last_recv_time > 0 if self._trading_required else True, "account_data": self._account_group is not None and self._account_uid is not None @@ -165,7 +165,7 @@ def restore_tracking_states(self, saved_states: Dict[str, any]): :param saved_states: The saved tracking_states. """ self._in_flight_orders.update({ - key: BitmaxInFlightOrder.from_json(value) + key: AscendExInFlightOrder.from_json(value) for key, value in saved_states.items() }) @@ -258,22 +258,22 @@ async def _trading_rules_polling_loop(self): 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 Bitmax. " + app_warning_msg="Could not fetch new trading rules from AscendEx. " "Check network connection.") await asyncio.sleep(0.5) async def _update_trading_rules(self): instruments_info = await self._api_request("get", path_url="products") - [trading_rules, bitmax_trading_rules] = self._format_trading_rules(instruments_info) + [trading_rules, ascend_ex_trading_rules] = self._format_trading_rules(instruments_info) self._trading_rules.clear() self._trading_rules = trading_rules - self._bitmax_trading_rules.clear() - self._bitmax_trading_rules = bitmax_trading_rules + self._ascend_ex_trading_rules.clear() + self._ascend_ex_trading_rules = ascend_ex_trading_rules def _format_trading_rules( self, instruments_info: Dict[str, Any] - ) -> [Dict[str, TradingRule], Dict[str, Dict[str, BitmaxTradingRule]]]: + ) -> [Dict[str, TradingRule], Dict[str, Dict[str, AscendExTradingRule]]]: """ Converts json API response into a dictionary of trading rules. :param instruments_info: The json API response @@ -299,27 +299,27 @@ def _format_trading_rules( } """ trading_rules = {} - bitmax_trading_rules = {} + ascend_ex_trading_rules = {} for rule in instruments_info["data"]: try: - trading_pair = bitmax_utils.convert_from_exchange_trading_pair(rule["symbol"]) + trading_pair = ascend_ex_utils.convert_from_exchange_trading_pair(rule["symbol"]) trading_rules[trading_pair] = TradingRule( trading_pair, min_price_increment=Decimal(rule["tickSize"]), min_base_amount_increment=Decimal(rule["lotSize"]) ) - bitmax_trading_rules[trading_pair] = BitmaxTradingRule( + ascend_ex_trading_rules[trading_pair] = AscendExTradingRule( minNotional=Decimal(rule["minNotional"]), maxNotional=Decimal(rule["maxNotional"]) ) except Exception: self.logger().error(f"Error parsing the trading pair rule {rule}. Skipping.", exc_info=True) - return [trading_rules, bitmax_trading_rules] + return [trading_rules, ascend_ex_trading_rules] async def _update_account_data(self): headers = { - **self._bitmax_auth.get_headers(), - **self._bitmax_auth.get_auth_headers("info"), + **self._ascend_ex_auth.get_headers(), + **self._ascend_ex_auth.get_auth_headers("info"), } url = f"{REST_URL}/info" response = await aiohttp.ClientSession().get(url, headers=headers) @@ -359,16 +359,16 @@ async def _api_request(self, if (self._account_group) is None: await self._update_account_data() - url = f"{getRestUrlPriv(self._account_group)}/{path_url}" + url = f"{ascend_ex_utils.get_rest_url_private(self._account_group)}/{path_url}" headers = { - **self._bitmax_auth.get_headers(), - **self._bitmax_auth.get_auth_headers( + **self._ascend_ex_auth.get_headers(), + **self._ascend_ex_auth.get_auth_headers( path_url if force_auth_path_url is None else force_auth_path_url ), } else: url = f"{REST_URL}/{path_url}" - headers = self._bitmax_auth.get_headers() + headers = self._ascend_ex_auth.get_headers() client = await self._http_client() if method == "get": @@ -433,7 +433,7 @@ def buy(self, trading_pair: str, amount: Decimal, order_type=OrderType.MARKET, :param price: The price (note: this is no longer optional) :returns A new internal order id """ - client_order_id = bitmax_utils.gen_client_order_id(True, trading_pair) + client_order_id = ascend_ex_utils.gen_client_order_id(True, trading_pair) safe_ensure_future(self._create_order(TradeType.BUY, client_order_id, trading_pair, amount, order_type, price)) return client_order_id @@ -448,7 +448,7 @@ def sell(self, trading_pair: str, amount: Decimal, order_type=OrderType.MARKET, :param price: The price (note: this is no longer optional) :returns A new internal order id """ - client_order_id = bitmax_utils.gen_client_order_id(False, trading_pair) + client_order_id = ascend_ex_utils.gen_client_order_id(False, trading_pair) safe_ensure_future(self._create_order(TradeType.SELL, client_order_id, trading_pair, amount, order_type, price)) return client_order_id @@ -480,25 +480,25 @@ async def _create_order(self, """ if not order_type.is_limit_type(): raise Exception(f"Unsupported order type: {order_type}") - bitmax_trading_rule = self._bitmax_trading_rules[trading_pair] + ascend_ex_trading_rule = self._ascend_ex_trading_rules[trading_pair] amount = self.quantize_order_amount(trading_pair, amount) price = self.quantize_order_price(trading_pair, price) 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 + # ascend_ex has a unique way of determening if the order has enough "worth" to be posted + # see https://ascendex.github.io/ascendex-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}.") + if notional < ascend_ex_trading_rule.minNotional or notional > ascend_ex_trading_rule.maxNotional: + raise ValueError(f"Notional amount {notional} is not withing the range of {ascend_ex_trading_rule.minNotional}-{ascend_ex_trading_rule.maxNotional}.") # TODO: check balance - [exchange_order_id, timestamp] = bitmax_utils.gen_exchange_order_id(self._account_uid) + [exchange_order_id, timestamp] = ascend_ex_utils.gen_exchange_order_id(self._account_uid, order_id) api_params = { "id": exchange_order_id, "time": timestamp, - "symbol": bitmax_utils.convert_to_exchange_trading_pair(trading_pair), + "symbol": ascend_ex_utils.convert_to_exchange_trading_pair(trading_pair), "orderPrice": f"{price:f}", "orderQty": f"{amount:f}", "orderType": "limit", @@ -517,7 +517,6 @@ async def _create_order(self, 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) if tracked_order is not None: self.logger().info(f"Created {order_type.name} {trade_type.name} order {order_id} for " @@ -540,7 +539,7 @@ async def _create_order(self, except Exception as e: self.stop_tracking_order(order_id) self.logger().network( - f"Error submitting {trade_type.name} {order_type.name} order to Bitmax for " + f"Error submitting {trade_type.name} {order_type.name} order to AscendEx for " f"{amount} {trading_pair} " f"{price}.", exc_info=True, @@ -560,7 +559,7 @@ def start_tracking_order(self, """ Starts tracking an order by simply adding it into _in_flight_orders dictionary. """ - self._in_flight_orders[order_id] = BitmaxInFlightOrder( + self._in_flight_orders[order_id] = AscendExInFlightOrder( client_order_id=order_id, exchange_order_id=exchange_order_id, trading_pair=trading_pair, @@ -596,9 +595,9 @@ async def _execute_cancel(self, trading_pair: str, order_id: str) -> str: "delete", "cash/order", { - "symbol": bitmax_utils.convert_to_exchange_trading_pair(trading_pair), + "symbol": ascend_ex_utils.convert_to_exchange_trading_pair(trading_pair), "orderId": ex_order_id, - "time": bitmax_utils.get_ms_timestamp() + "time": ascend_ex_utils.get_ms_timestamp() }, True, force_auth_path_url="order" @@ -615,7 +614,7 @@ async def _execute_cancel(self, trading_pair: str, order_id: str) -> str: 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 Bitmax. " + app_warning_msg=f"Failed to cancel the order {order_id} on AscendEx. " f"Check API key and network connection." ) @@ -639,7 +638,7 @@ async def _status_polling_loop(self): 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 Bitmax. " + app_warning_msg="Could not fetch account updates from AscendEx. " "Check API key and network connection.") await asyncio.sleep(0.5) @@ -649,7 +648,7 @@ async def _update_balances(self): """ response = await self._api_request("get", "cash/balance", {}, True, force_auth_path_url="balance") balances = list(map( - lambda balance: BitmaxBalance( + lambda balance: AscendExBalance( balance["asset"], balance["availableBalance"], balance["totalBalance"] @@ -687,7 +686,7 @@ async def _update_order_status(self): continue order_data = response.get("data") - self._process_order_message(BitmaxOrder( + self._process_order_message(AscendExOrder( order_data["symbol"], order_data["price"], order_data["orderQty"], @@ -738,7 +737,7 @@ async def cancel_all(self, timeout_seconds: float): self.logger().network( "Failed to cancel all orders.", exc_info=True, - app_warning_msg="Failed to cancel all orders on Bitmax. Check API key and network connection." + app_warning_msg="Failed to cancel all orders on AscendEx. Check API key and network connection." ) return cancellation_results @@ -783,14 +782,14 @@ async def _iter_user_event_queue(self) -> AsyncIterable[Dict[str, any]]: self.logger().network( "Unknown error. Retrying after 1 seconds.", exc_info=True, - app_warning_msg="Could not fetch user events from Bitmax. Check API key and network connection." + app_warning_msg="Could not fetch user events from AscendEx. 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 - BitmaxAPIUserStreamDataSource. + AscendExAPIUserStreamDataSource. """ async for event_message in self._iter_user_event_queue(): try: @@ -798,7 +797,7 @@ async def _user_stream_event_listener(self): order_data = event_message.get("data") trading_pair = order_data["s"] base_asset, quote_asset = tuple(asset for asset in trading_pair.split("/")) - self._process_order_message(BitmaxOrder( + self._process_order_message(AscendExOrder( trading_pair, order_data["p"], order_data["q"], @@ -817,12 +816,12 @@ async def _user_stream_event_listener(self): order_data["ei"] )) # Handles balance updates from orders. - base_asset_balance = BitmaxBalance( + base_asset_balance = AscendExBalance( base_asset, order_data["bab"], order_data["btb"] ) - quote_asset_balance = BitmaxBalance( + quote_asset_balance = AscendExBalance( quote_asset, order_data["qab"], order_data["qtb"] @@ -831,7 +830,7 @@ async def _user_stream_event_listener(self): elif event_message.get("m") == "balance": # Handles balance updates from Deposits/Withdrawals, Transfers between Cash and Margin Accounts balance_data = event_message.get("data") - balance = BitmaxBalance( + balance = AscendExBalance( balance_data["a"], balance_data["ab"], balance_data["tb"] @@ -869,7 +868,7 @@ async def get_open_orders(self) -> List[OpenOrder]: ret_val.append( OpenOrder( client_order_id=client_order_id, - trading_pair=bitmax_utils.convert_from_exchange_trading_pair(order["symbol"]), + trading_pair=ascend_ex_utils.convert_from_exchange_trading_pair(order["symbol"]), price=Decimal(str(order["price"])), amount=Decimal(str(order["orderQty"])), executed_amount=Decimal(str(order["cumFilledQty"])), @@ -882,7 +881,7 @@ async def get_open_orders(self) -> List[OpenOrder]: ) return ret_val - def _process_order_message(self, order_msg: BitmaxOrder): + def _process_order_message(self, order_msg: AscendExOrder): """ 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) @@ -917,7 +916,7 @@ def _process_order_message(self, order_msg: BitmaxOrder): 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: {bitmax_utils.get_api_reason(order_msg.errorCode)}") + f"Reason: {ascend_ex_utils.get_api_reason(order_msg.errorCode)}") self.trigger_event(MarketEvent.OrderFailure, MarketOrderFailureEvent( self.current_timestamp, @@ -965,7 +964,7 @@ def _process_order_message(self, order_msg: BitmaxOrder): ) self.stop_tracking_order(client_order_id) - def _process_balances(self, balances: List[BitmaxBalance]): + def _process_balances(self, balances: List[AscendExBalance]): local_asset_names = set(self._account_balances.keys()) remote_asset_names = set() diff --git a/hummingbot/connector/exchange/bitmax/bitmax_in_flight_order.py b/hummingbot/connector/exchange/ascend_ex/ascend_ex_in_flight_order.py similarity index 95% rename from hummingbot/connector/exchange/bitmax/bitmax_in_flight_order.py rename to hummingbot/connector/exchange/ascend_ex/ascend_ex_in_flight_order.py index b455433ba0..0654f2cba9 100644 --- a/hummingbot/connector/exchange/bitmax/bitmax_in_flight_order.py +++ b/hummingbot/connector/exchange/ascend_ex/ascend_ex_in_flight_order.py @@ -12,7 +12,7 @@ from hummingbot.connector.in_flight_order_base import InFlightOrderBase -class BitmaxInFlightOrder(InFlightOrderBase): +class AscendExInFlightOrder(InFlightOrderBase): def __init__(self, client_order_id: str, exchange_order_id: Optional[str], @@ -53,7 +53,7 @@ def from_json(cls, data: Dict[str, Any]) -> InFlightOrderBase: :param data: json data from API :return: formatted InFlightOrder """ - retval = BitmaxInFlightOrder( + retval = AscendExInFlightOrder( data["client_order_id"], data["exchange_order_id"], data["trading_pair"], diff --git a/hummingbot/connector/exchange/bitmax/bitmax_order_book.py b/hummingbot/connector/exchange/ascend_ex/ascend_ex_order_book.py similarity index 83% rename from hummingbot/connector/exchange/bitmax/bitmax_order_book.py rename to hummingbot/connector/exchange/ascend_ex/ascend_ex_order_book.py index be6702853d..88ebe8b0d1 100644 --- a/hummingbot/connector/exchange/bitmax/bitmax_order_book.py +++ b/hummingbot/connector/exchange/ascend_ex/ascend_ex_order_book.py @@ -1,24 +1,25 @@ #!/usr/bin/env python import logging -import hummingbot.connector.exchange.bitmax.bitmax_constants as constants +import hummingbot.connector.exchange.ascend_ex.ascend_ex_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.bitmax.bitmax_order_book_message import BitmaxOrderBookMessage +from hummingbot.connector.exchange.ascend_ex.ascend_ex_order_book_message import AscendExOrderBookMessage +from hummingbot.logger import HummingbotLogger _logger = None -class BitmaxOrderBook(OrderBook): +class AscendExOrderBook(OrderBook): @classmethod def logger(cls) -> HummingbotLogger: global _logger @@ -35,13 +36,13 @@ def snapshot_message_from_exchange(cls, 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: BitmaxOrderBookMessage + :return: AscendExOrderBookMessage """ if metadata: msg.update(metadata) - return BitmaxOrderBookMessage( + return AscendExOrderBookMessage( message_type=OrderBookMessageType.SNAPSHOT, content=msg, timestamp=timestamp @@ -53,9 +54,9 @@ def snapshot_message_from_db(cls, record: RowProxy, metadata: Optional[Dict] = N *used for backtesting Convert a row of snapshot data into standard OrderBookMessage format :param record: a row of snapshot data from the database - :return: BitmaxOrderBookMessage + :return: AscendExOrderBookMessage """ - return BitmaxOrderBookMessage( + return AscendExOrderBookMessage( message_type=OrderBookMessageType.SNAPSHOT, content=record.json, timestamp=record.timestamp @@ -70,13 +71,13 @@ def diff_message_from_exchange(cls, 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: BitmaxOrderBookMessage + :return: AscendExOrderBookMessage """ if metadata: msg.update(metadata) - return BitmaxOrderBookMessage( + return AscendExOrderBookMessage( message_type=OrderBookMessageType.DIFF, content=msg, timestamp=timestamp @@ -88,9 +89,9 @@ 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: BitmaxOrderBookMessage + :return: AscendExOrderBookMessage """ - return BitmaxOrderBookMessage( + return AscendExOrderBookMessage( message_type=OrderBookMessageType.DIFF, content=record.json, timestamp=record.timestamp @@ -104,7 +105,7 @@ def trade_message_from_exchange(cls, """ Convert a trade data into standard OrderBookMessage format :param record: a trade data from the database - :return: BitmaxOrderBookMessage + :return: AscendExOrderBookMessage """ if metadata: @@ -117,7 +118,7 @@ def trade_message_from_exchange(cls, "amount": msg.get("q"), }) - return BitmaxOrderBookMessage( + return AscendExOrderBookMessage( message_type=OrderBookMessageType.TRADE, content=msg, timestamp=timestamp @@ -129,9 +130,9 @@ 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: BitmaxOrderBookMessage + :return: AscendExOrderBookMessage """ - return BitmaxOrderBookMessage( + return AscendExOrderBookMessage( message_type=OrderBookMessageType.TRADE, content=record.json, timestamp=record.timestamp @@ -142,5 +143,5 @@ 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(self, snapshot: OrderBookMessage, diffs: List[OrderBookMessage]): + 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/bitmax/bitmax_order_book_message.py b/hummingbot/connector/exchange/ascend_ex/ascend_ex_order_book_message.py similarity index 95% rename from hummingbot/connector/exchange/bitmax/bitmax_order_book_message.py rename to hummingbot/connector/exchange/ascend_ex/ascend_ex_order_book_message.py index a00550b66b..c5b0ef2e13 100644 --- a/hummingbot/connector/exchange/bitmax/bitmax_order_book_message.py +++ b/hummingbot/connector/exchange/ascend_ex/ascend_ex_order_book_message.py @@ -13,7 +13,7 @@ ) -class BitmaxOrderBookMessage(OrderBookMessage): +class AscendExOrderBookMessage(OrderBookMessage): def __new__( cls, message_type: OrderBookMessageType, @@ -27,7 +27,7 @@ def __new__( raise ValueError("timestamp must not be None when initializing snapshot messages.") timestamp = content["timestamp"] - return super(BitmaxOrderBookMessage, cls).__new__( + return super(AscendExOrderBookMessage, cls).__new__( cls, message_type, content, timestamp=timestamp, *args, **kwargs ) diff --git a/hummingbot/connector/exchange/ascend_ex/ascend_ex_order_book_tracker.py b/hummingbot/connector/exchange/ascend_ex/ascend_ex_order_book_tracker.py new file mode 100644 index 0000000000..e982f5c85b --- /dev/null +++ b/hummingbot/connector/exchange/ascend_ex/ascend_ex_order_book_tracker.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python +import asyncio +import bisect +import logging +import time + +import hummingbot.connector.exchange.ascend_ex.ascend_ex_constants as constants + +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.ascend_ex.ascend_ex_order_book_message import AscendExOrderBookMessage +from hummingbot.connector.exchange.ascend_ex.ascend_ex_active_order_tracker import AscendExActiveOrderTracker +from hummingbot.connector.exchange.ascend_ex.ascend_ex_api_order_book_data_source import AscendExAPIOrderBookDataSource +from hummingbot.connector.exchange.ascend_ex.ascend_ex_order_book import AscendExOrderBook +from hummingbot.logger import HummingbotLogger + + +class AscendExOrderBookTracker(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__(AscendExAPIOrderBookDataSource(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, AscendExOrderBook] = {} + self._saved_message_queues: Dict[str, Deque[AscendExOrderBookMessage]] = \ + defaultdict(lambda: deque(maxlen=1000)) + self._active_order_trackers: Dict[str, AscendExActiveOrderTracker] = defaultdict(AscendExActiveOrderTracker) + 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[AscendExOrderBookMessage] = deque() + self._past_diffs_windows[trading_pair] = past_diffs_window + + message_queue: asyncio.Queue = self._tracking_message_queues[trading_pair] + order_book: AscendExOrderBook = self._order_books[trading_pair] + active_order_tracker: AscendExActiveOrderTracker = self._active_order_trackers[trading_pair] + + last_message_timestamp: float = time.time() + diff_messages_accepted: int = 0 + + while True: + try: + message: AscendExOrderBookMessage = None + saved_messages: Deque[AscendExOrderBookMessage] = 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(f"Processed {diff_messages_accepted} order book diffs for {trading_pair}.") + diff_messages_accepted = 0 + last_message_timestamp = now + elif message.type is OrderBookMessageType.SNAPSHOT: + past_diffs: List[AscendExOrderBookMessage] = 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(f"Processed order book snapshot for {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/ascend_ex/ascend_ex_order_book_tracker_entry.py b/hummingbot/connector/exchange/ascend_ex/ascend_ex_order_book_tracker_entry.py new file mode 100644 index 0000000000..ebefecfebb --- /dev/null +++ b/hummingbot/connector/exchange/ascend_ex/ascend_ex_order_book_tracker_entry.py @@ -0,0 +1,21 @@ +from hummingbot.core.data_type.order_book import OrderBook +from hummingbot.core.data_type.order_book_tracker_entry import OrderBookTrackerEntry +from hummingbot.connector.exchange.ascend_ex.ascend_ex_active_order_tracker import AscendExActiveOrderTracker + + +class AscendExOrderBookTrackerEntry(OrderBookTrackerEntry): + def __init__( + self, trading_pair: str, timestamp: float, order_book: OrderBook, active_order_tracker: AscendExActiveOrderTracker + ): + self._active_order_tracker = active_order_tracker + super(AscendExOrderBookTrackerEntry, self).__init__(trading_pair, timestamp, order_book) + + def __repr__(self) -> str: + return ( + f"AscendExOrderBookTrackerEntry(trading_pair='{self._trading_pair}', timestamp='{self._timestamp}', " + f"order_book='{self._order_book}')" + ) + + @property + def active_order_tracker(self) -> AscendExActiveOrderTracker: + return self._active_order_tracker diff --git a/hummingbot/connector/exchange/ascend_ex/ascend_ex_user_stream_tracker.py b/hummingbot/connector/exchange/ascend_ex/ascend_ex_user_stream_tracker.py new file mode 100644 index 0000000000..8557ca44db --- /dev/null +++ b/hummingbot/connector/exchange/ascend_ex/ascend_ex_user_stream_tracker.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python + +import asyncio +import logging + +from typing import ( + Optional, + List, +) +from hummingbot.connector.exchange.ascend_ex.ascend_ex_api_user_stream_data_source import \ + AscendExAPIUserStreamDataSource +from hummingbot.connector.exchange.ascend_ex.ascend_ex_auth import AscendExAuth +from hummingbot.connector.exchange.ascend_ex.ascend_ex_constants import EXCHANGE_NAME +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 AscendExUserStreamTracker(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, + ascend_ex_auth: Optional[AscendExAuth] = None, + trading_pairs: Optional[List[str]] = []): + super().__init__() + self._ascend_ex_auth: AscendExAuth = ascend_ex_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 = AscendExAPIUserStreamDataSource( + ascend_ex_auth=self._ascend_ex_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) diff --git a/hummingbot/connector/exchange/bitmax/bitmax_utils.py b/hummingbot/connector/exchange/ascend_ex/ascend_ex_utils.py similarity index 57% rename from hummingbot/connector/exchange/bitmax/bitmax_utils.py rename to hummingbot/connector/exchange/ascend_ex/ascend_ex_utils.py index 45f943688d..a8ac64400f 100644 --- a/hummingbot/connector/exchange/bitmax/bitmax_utils.py +++ b/hummingbot/connector/exchange/ascend_ex/ascend_ex_utils.py @@ -15,7 +15,15 @@ DEFAULT_FEES = [0.1, 0.1] -HBOT_BROKER_ID = "hbot-" +HBOT_BROKER_ID = "HMBot" + + +def get_rest_url_private(account_id: int) -> str: + return f"https://ascendex.com/{account_id}/api/pro/v1" + + +def get_ws_url_private(account_id: int) -> str: + return f"wss://ascendex.com/{account_id}/api/pro/v1" def convert_from_exchange_trading_pair(exchange_trading_pair: str) -> str: @@ -35,29 +43,29 @@ def uuid32(): return ''.join(random.choice(string.ascii_uppercase + string.ascii_lowercase + string.digits) for _ in range(32)) -def derive_order_id(user_uid: str, cl_order_id: str, ts: int, order_src='a') -> str: +def derive_order_id(user_uid: str, cl_order_id: str, ts: int) -> str: """ Server order generator based on user info and input. :param user_uid: user uid :param cl_order_id: user random digital and number id :param ts: order timestamp in milliseconds - :param order_src: 'a' for rest api order, 's' for websocket order. :return: order id of length 32 """ - return (order_src + format(ts, 'x')[-11:] + user_uid[-11:] + cl_order_id[-9:])[:32] + return (HBOT_BROKER_ID + format(ts, 'x')[-11:] + user_uid[-11:] + cl_order_id[-5:])[:32] -def gen_exchange_order_id(userUid: str) -> Tuple[str, int]: +def gen_exchange_order_id(userUid: str, client_order_id: str) -> Tuple[str, int]: """ - Generate an order id - :param user_uid: user uid + Generates the exchange order id based on user uid and client order id. + :param user_uid: user uid, + :param client_order_id: client order id used for local order tracking :return: order id of length 32 """ time = get_ms_timestamp() return [ derive_order_id( userUid, - uuid32(), + client_order_id, time ), time @@ -66,20 +74,20 @@ def gen_exchange_order_id(userUid: str) -> Tuple[str, int]: def gen_client_order_id(is_buy: bool, trading_pair: str) -> str: side = "B" if is_buy else "S" - return f"{HBOT_BROKER_ID}{side}-{trading_pair}-{get_tracking_nonce()}" + return f"{HBOT_BROKER_ID}-{side}-{trading_pair}-{get_tracking_nonce()}" KEYS = { - "bitmax_api_key": - ConfigVar(key="bitmax_api_key", - prompt="Enter your Bitmax API key >>> ", - required_if=using_exchange("bitmax"), + "ascend_ex_api_key": + ConfigVar(key="ascend_ex_api_key", + prompt="Enter your AscendEx API key >>> ", + required_if=using_exchange("ascend_ex"), is_secure=True, is_connect_key=True), - "bitmax_secret_key": - ConfigVar(key="bitmax_secret_key", - prompt="Enter your Bitmax secret key >>> ", - required_if=using_exchange("bitmax"), + "ascend_ex_secret_key": + ConfigVar(key="ascend_ex_secret_key", + prompt="Enter your AscendEx secret key >>> ", + required_if=using_exchange("ascend_ex"), is_secure=True, is_connect_key=True), } diff --git a/hummingbot/connector/exchange/bamboo_relay/bamboo_relay_order_book_tracker.py b/hummingbot/connector/exchange/bamboo_relay/bamboo_relay_order_book_tracker.py index ef08e8c989..f29546a2b2 100644 --- a/hummingbot/connector/exchange/bamboo_relay/bamboo_relay_order_book_tracker.py +++ b/hummingbot/connector/exchange/bamboo_relay/bamboo_relay_order_book_tracker.py @@ -130,10 +130,8 @@ async def _order_book_diff_router(self): # Log some statistics. now: float = time.time() if int(now / 60.0) > int(last_message_timestamp / 60.0): - self.logger().debug("Diff messages processed: %d, rejected: %d, queued: %d", - messages_accepted, - messages_rejected, - messages_queued) + self.logger().debug(f"Diff messages processed: {messages_accepted}, " + f"rejected: {messages_rejected}, queued: {messages_queued}") messages_accepted = 0 messages_rejected = 0 messages_queued = 0 @@ -175,7 +173,7 @@ async def _track_single_book(self, trading_pair: str): 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) - self.logger().debug("Processed order book snapshot for %s.", trading_pair) + self.logger().debug(f"Processed order book snapshot for {trading_pair}.") except asyncio.CancelledError: raise except Exception: 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 f42881c33f..5be1e9257a 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 @@ -5,13 +5,10 @@ import asyncio import json 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 @@ -112,16 +109,6 @@ async def fetch_trading_pairs() -> Optional[List[str]]: 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]: diff --git a/hummingbot/connector/exchange/beaxy/beaxy_exchange.pyx b/hummingbot/connector/exchange/beaxy/beaxy_exchange.pyx index 53f4d09608..9fe3817fbf 100644 --- a/hummingbot/connector/exchange/beaxy/beaxy_exchange.pyx +++ b/hummingbot/connector/exchange/beaxy/beaxy_exchange.pyx @@ -363,8 +363,8 @@ cdef class BeaxyExchange(ExchangeBase): 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 + execute_price = Decimal(str(order_update['limit_price'] if order_update['limit_price'] else order_update['average_price'])) + execute_amount_diff = Decimal(str(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: @@ -398,9 +398,9 @@ cdef class BeaxyExchange(ExchangeBase): if tracked_order.is_done: if not tracked_order.is_failure and not tracked_order.is_cancelled: - new_confirmed_amount = Decimal(order_update['size']) + new_confirmed_amount = Decimal(str(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']) + execute_price = Decimal(str(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: @@ -748,8 +748,8 @@ cdef class BeaxyExchange(ExchangeBase): 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._maker_fee_percentage[symbol] = Decimal(str(symbol_data['maker_fee'])) + self._taker_fee_percentage[symbol] = Decimal(str(symbol_data['taker_fee'])) self._last_fee_percentage_update_timestamp = current_timestamp except asyncio.CancelledError: @@ -774,8 +774,8 @@ cdef class BeaxyExchange(ExchangeBase): for balance_entry in account_balances: asset_name = balance_entry['currency'] - available_balance = Decimal(balance_entry['available_balance']) - total_balance = Decimal(balance_entry['total_balance']) + available_balance = Decimal(str(balance_entry['available_balance'])) + total_balance = Decimal(str(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) @@ -855,8 +855,8 @@ cdef class BeaxyExchange(ExchangeBase): for msg in msgs: asset_name = msg['currency'] - available_balance = Decimal(msg['available_balance']) - total_balance = Decimal(msg['total_balance']) + available_balance = Decimal(str(msg['available_balance'])) + total_balance = Decimal(str(msg['total_balance'])) self._account_available_balances[asset_name] = available_balance self._account_balances[asset_name] = total_balance @@ -882,8 +882,8 @@ cdef class BeaxyExchange(ExchangeBase): execute_amount_diff = s_decimal_0 if order_status == 'partially_filled': - order_filled_size = Decimal(order['trade_size']) - execute_price = Decimal(order['trade_price']) + order_filled_size = Decimal(str(order['trade_size'])) + execute_price = Decimal(str(order['trade_price'])) execute_amount_diff = order_filled_size - tracked_order.executed_amount_base @@ -917,9 +917,9 @@ cdef class BeaxyExchange(ExchangeBase): elif order_status == 'completely_filled': - new_confirmed_amount = Decimal(order['size']) + new_confirmed_amount = Decimal(str(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']) + execute_price = Decimal(str(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: diff --git a/hummingbot/connector/exchange/beaxy/beaxy_order_book_tracker.py b/hummingbot/connector/exchange/beaxy/beaxy_order_book_tracker.py index d730dc97c7..3ba8c17580 100644 --- a/hummingbot/connector/exchange/beaxy/beaxy_order_book_tracker.py +++ b/hummingbot/connector/exchange/beaxy/beaxy_order_book_tracker.py @@ -82,10 +82,8 @@ async def _order_book_diff_router(self): # 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) + self.logger().debug(f"Messages processed: {messages_accepted}, " + f"rejected: {messages_rejected}, queued: {messages_queued}") messages_accepted = 0 messages_rejected = 0 messages_queued = 0 @@ -163,8 +161,7 @@ async def _track_single_book(self, trading_pair: str): # 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) + self.logger().debug(f"Processed {diff_messages_accepted} order book diffs for {trading_pair}.") diff_messages_accepted = 0 last_message_timestamp = now elif message.type is OrderBookMessageType.SNAPSHOT: @@ -178,7 +175,7 @@ async def _track_single_book(self, trading_pair: str): 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) + self.logger().debug(f"Processed order book snapshot for {trading_pair}.") except asyncio.CancelledError: raise except Exception: diff --git a/hummingbot/connector/exchange/binance/binance_api_order_book_data_source.py b/hummingbot/connector/exchange/binance/binance_api_order_book_data_source.py index 253f5eb4b3..7215fc29d6 100755 --- a/hummingbot/connector/exchange/binance/binance_api_order_book_data_source.py +++ b/hummingbot/connector/exchange/binance/binance_api_order_book_data_source.py @@ -13,8 +13,6 @@ ) from decimal import Decimal import re -import requests -import cachetools.func import time import ujson import websockets @@ -68,16 +66,6 @@ async def get_last_traded_price(cls, trading_pair: str, domain: str = "com") -> resp_json = await resp.json() return float(resp_json["lastPrice"]) - @staticmethod - @cachetools.func.ttl_cache(ttl=10, maxsize=1000) - def get_mid_price(trading_pair: str, domain="com") -> Optional[Decimal]: - from hummingbot.connector.exchange.binance.binance_utils import convert_to_exchange_trading_pair - url = TICKER_PRICE_CHANGE_URL.format(domain) - resp = requests.get(url=f"{url}?symbol={convert_to_exchange_trading_pair(trading_pair)}") - record = resp.json() - result = (Decimal(record.get("bidPrice", "0")) + Decimal(record.get("askPrice", "0"))) / Decimal("2") - return result if result else None - @staticmethod @async_ttl_cache(ttl=2, maxsize=1) async def get_all_mid_prices(domain="com") -> Optional[Decimal]: diff --git a/hummingbot/connector/exchange/binance/binance_exchange.pyx b/hummingbot/connector/exchange/binance/binance_exchange.pyx index ec048f8475..8601b0731d 100755 --- a/hummingbot/connector/exchange/binance/binance_exchange.pyx +++ b/hummingbot/connector/exchange/binance/binance_exchange.pyx @@ -402,7 +402,7 @@ cdef class BinanceExchange(ExchangeBase): trading_pairs = list(trading_pairs_to_order_map.keys()) tasks = [self.query_api(self._binance_client.get_my_trades, symbol=convert_to_exchange_trading_pair(trading_pair)) for trading_pair in trading_pairs] - self.logger().debug("Polling for order fills of %d trading pairs.", len(tasks)) + self.logger().debug(f"Polling for order fills of {len(tasks)} trading pairs.") results = await safe_gather(*tasks, return_exceptions=True) for trades, trading_pair in zip(results, trading_pairs): order_map = trading_pairs_to_order_map[trading_pair] @@ -448,7 +448,7 @@ cdef class BinanceExchange(ExchangeBase): trading_pairs = self._order_book_tracker._trading_pairs tasks = [self.query_api(self._binance_client.get_my_trades, symbol=convert_to_exchange_trading_pair(trading_pair)) for trading_pair in trading_pairs] - self.logger().debug("Polling for order fills of %d trading pairs.", len(tasks)) + self.logger().debug(f"Polling for order fills of {len(tasks)} trading pairs.") exchange_history = await safe_gather(*tasks, return_exceptions=True) for trades, trading_pair in zip(exchange_history, trading_pairs): if isinstance(trades, Exception): @@ -494,7 +494,7 @@ cdef class BinanceExchange(ExchangeBase): tasks = [self.query_api(self._binance_client.get_order, symbol=convert_to_exchange_trading_pair(o.trading_pair), origClientOrderId=o.client_order_id) for o in tracked_orders] - self.logger().debug("Polling for order status updates of %d orders.", len(tasks)) + self.logger().debug(f"Polling for order status updates of {len(tasks)} orders.") results = await safe_gather(*tasks, return_exceptions=True) for order_update, tracked_order in zip(results, tracked_orders): client_order_id = tracked_order.client_order_id diff --git a/hummingbot/connector/exchange/binance/binance_order_book_tracker.py b/hummingbot/connector/exchange/binance/binance_order_book_tracker.py index 40edaad388..55481ae47a 100644 --- a/hummingbot/connector/exchange/binance/binance_order_book_tracker.py +++ b/hummingbot/connector/exchange/binance/binance_order_book_tracker.py @@ -80,10 +80,8 @@ async def _order_book_diff_router(self): # Log some statistics. now: float = time.time() if int(now / 60.0) > int(last_message_timestamp / 60.0): - self.logger().debug("Diff messages processed: %d, rejected: %d, queued: %d", - messages_accepted, - messages_rejected, - messages_queued) + self.logger().debug(f"Diff messages processed: {messages_accepted}, " + f"rejected: {messages_rejected}, queued: {messages_queued}") messages_accepted = 0 messages_rejected = 0 messages_queued = 0 @@ -129,14 +127,13 @@ async def _track_single_book(self, trading_pair: str): # 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) + self.logger().debug(f"Processed {diff_messages_accepted} order book diffs for {trading_pair}.") diff_messages_accepted = 0 last_message_timestamp = now elif message.type is OrderBookMessageType.SNAPSHOT: past_diffs: List[OrderBookMessage] = list(past_diffs_window) order_book.restore_from_snapshot_and_diffs(message, past_diffs) - self.logger().debug("Processed order book snapshot for %s.", trading_pair) + self.logger().debug(f"Processed order book snapshot for {trading_pair}.") except asyncio.CancelledError: raise except Exception: diff --git a/hummingbot/connector/exchange/binance/binance_utils.py b/hummingbot/connector/exchange/binance/binance_utils.py index 4c4bf3dbb0..3957fbc97e 100644 --- a/hummingbot/connector/exchange/binance/binance_utils.py +++ b/hummingbot/connector/exchange/binance/binance_utils.py @@ -11,7 +11,7 @@ EXAMPLE_PAIR = "ZRX-ETH" DEFAULT_FEES = [0.1, 0.1] -RE_4_LETTERS_QUOTE = re.compile(r"^(\w+)(USDT|USDC|USDS|TUSD|BUSD|IDRT|BKRW|BIDR)$") +RE_4_LETTERS_QUOTE = re.compile(r"^(\w+)(USDT|USDC|USDS|TUSD|BUSD|IDRT|BKRW|BIDR|BVND)$") 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"] diff --git a/hummingbot/connector/exchange/bitfinex/bitfinex_api_order_book_data_source.py b/hummingbot/connector/exchange/bitfinex/bitfinex_api_order_book_data_source.py index 75754b942a..aee07068de 100644 --- a/hummingbot/connector/exchange/bitfinex/bitfinex_api_order_book_data_source.py +++ b/hummingbot/connector/exchange/bitfinex/bitfinex_api_order_book_data_source.py @@ -6,9 +6,6 @@ import asyncio import ujson import pandas as pd -from decimal import Decimal -import requests -import cachetools.func from typing import ( Any, AsyncIterable, @@ -91,15 +88,6 @@ def __init__(self, trading_pairs: Optional[List[str]] = None): # way it is stored in Hummingbot order book, usually timestamp) self._tracked_book_entries: Dict[int, OrderBookRow] = {} - @staticmethod - @cachetools.func.ttl_cache(ttl=10) - def get_mid_price(trading_pair: str) -> Optional[Decimal]: - exchange_trading_pair = convert_to_exchange_trading_pair(trading_pair) - resp = requests.get(url=f"https://api-pub.bitfinex.com/v2/ticker/{exchange_trading_pair}") - record = resp.json() - result = (Decimal(record[0]) + Decimal(record[2])) / Decimal("2") - return result - @staticmethod async def fetch_trading_pairs() -> List[str]: try: diff --git a/hummingbot/connector/exchange/bitfinex/bitfinex_order_book_tracker.py b/hummingbot/connector/exchange/bitfinex/bitfinex_order_book_tracker.py index 9efdbb568a..68be932262 100644 --- a/hummingbot/connector/exchange/bitfinex/bitfinex_order_book_tracker.py +++ b/hummingbot/connector/exchange/bitfinex/bitfinex_order_book_tracker.py @@ -118,10 +118,8 @@ async def _order_book_diff_router(self): # Log some statistics. now: float = time.time() if int(now / CALC_STAT_MINUTE) > int(last_message_timestamp / CALC_STAT_MINUTE): - self.logger().debug("Diff messages processed: %d, rejected: %d, queued: %d", - messages_accepted, - messages_rejected, - messages_queued) + self.logger().debug(f"Diff messages processed: {messages_accepted}, " + f"rejected: {messages_rejected}, queued: {messages_queued}") messages_accepted = 0 messages_rejected = 0 messages_queued = 0 @@ -171,8 +169,7 @@ async def _track_single_book(self, trading_pair: str): # Output some statistics periodically. now: float = time.time() if int(now / CALC_STAT_MINUTE) > int(last_message_timestamp / CALC_STAT_MINUTE): - self.logger().debug( - "Processed %d order book diffs for %s.", diff_messages_accepted, trading_pair) + self.logger().debug(f"Processed {diff_messages_accepted} order book diffs for {trading_pair}.") diff_messages_accepted = 0 last_message_timestamp = now @@ -193,7 +190,7 @@ async def _track_single_book(self, trading_pair: str): ) order_book.apply_diffs(d_bids, d_asks, diff_message.update_id) - self.logger().debug("Processed order book snapshot for %s.", trading_pair) + self.logger().debug(f"Processed order book snapshot for {trading_pair}.") except asyncio.CancelledError: raise except Exception as err: diff --git a/hummingbot/connector/exchange/bitmax/bitmax_constants.py b/hummingbot/connector/exchange/bitmax/bitmax_constants.py deleted file mode 100644 index 51ec061543..0000000000 --- a/hummingbot/connector/exchange/bitmax/bitmax_constants.py +++ /dev/null @@ -1,15 +0,0 @@ -# A single source of truth for constant variables related to the exchange - - -EXCHANGE_NAME = "bitmax" -REST_URL = "https://bitmax.io/api/pro/v1" -WS_URL = "wss://bitmax.io/1/api/pro/v1/stream" -PONG_PAYLOAD = {"op": "pong"} - - -def getRestUrlPriv(accountId: int) -> str: - return f"https://bitmax.io/{accountId}/api/pro/v1" - - -def getWsUrlPriv(accountId: int) -> str: - return f"wss://bitmax.io/{accountId}/api/pro/v1" diff --git a/hummingbot/connector/exchange/bittrex/bittrex_api_order_book_data_source.py b/hummingbot/connector/exchange/bittrex/bittrex_api_order_book_data_source.py index 1d125ea85e..b379106836 100644 --- a/hummingbot/connector/exchange/bittrex/bittrex_api_order_book_data_source.py +++ b/hummingbot/connector/exchange/bittrex/bittrex_api_order_book_data_source.py @@ -14,10 +14,6 @@ from signalr_aio.hubs import Hub from async_timeout import timeout -import requests -import cachetools.func -from decimal import Decimal - 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 @@ -101,20 +97,6 @@ async def websocket_connection(self) -> (signalr_aio.Connection, signalr_aio.hub return self._websocket_connection, self._websocket_hub - @staticmethod - @cachetools.func.ttl_cache(ttl=10) - def get_mid_price(trading_pair: str) -> Optional[Decimal]: - resp = requests.get(url="https://api.bittrex.com/api/v1.1/public/getmarketsummaries") - records = resp.json() - result = None - for record in records["result"]: - symbols = record["MarketName"].split("-") - pair = f"{symbols[1]}-{symbols[0]}" - if trading_pair == pair and record["Bid"] is not None and record["Ask"] is not None: - result = (Decimal(record["Bid"]) + Decimal(record["Ask"])) / Decimal("2") - break - return result - @staticmethod async def fetch_trading_pairs() -> List[str]: try: diff --git a/hummingbot/connector/exchange/bittrex/bittrex_order_book_tracker.py b/hummingbot/connector/exchange/bittrex/bittrex_order_book_tracker.py index 4b779eba98..1a103368d3 100644 --- a/hummingbot/connector/exchange/bittrex/bittrex_order_book_tracker.py +++ b/hummingbot/connector/exchange/bittrex/bittrex_order_book_tracker.py @@ -184,7 +184,7 @@ async def _track_single_book(self, trading_pair: str): 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) + self.logger().debug(f"Processed order book snapshot for {trading_pair}.") except asyncio.CancelledError: raise except Exception: diff --git a/hummingbot/connector/exchange/blocktane/blocktane_api_order_book_data_source.py b/hummingbot/connector/exchange/blocktane/blocktane_api_order_book_data_source.py index 71198bb609..cdf4d55c32 100644 --- a/hummingbot/connector/exchange/blocktane/blocktane_api_order_book_data_source.py +++ b/hummingbot/connector/exchange/blocktane/blocktane_api_order_book_data_source.py @@ -2,9 +2,7 @@ import asyncio import aiohttp -import cachetools.func from collections import namedtuple -from decimal import Decimal import logging import pandas as pd from typing import ( @@ -15,7 +13,6 @@ Optional ) import re -import requests import time import ujson import websockets @@ -68,20 +65,6 @@ async def get_last_traded_prices(cls, trading_pairs: List[str]) -> Dict[str, flo def trading_pairs(self) -> List[str]: return self._trading_pairs - @staticmethod - @cachetools.func.ttl_cache(ttl=10) - def get_mid_price(trading_pair: str) -> Optional[Decimal]: - exchange_trading_pair: str = convert_to_exchange_trading_pair(trading_pair) - - try: - resp = requests.get(url=f"{SINGLE_MARKET_DEPTH_URL.format(exchange_trading_pair)}?limit=1").json() - best_bid: Decimal = Decimal(resp["bids"][0][0]) - best_ask: Decimal = Decimal(resp["ask"][0][0]) - - return (best_ask + best_bid) / 2 - except Exception: - return None - @staticmethod async def fetch_trading_pairs() -> List[str]: try: diff --git a/hummingbot/connector/exchange/blocktane/blocktane_order_book_tracker.py b/hummingbot/connector/exchange/blocktane/blocktane_order_book_tracker.py index beb3d3d66d..feec224339 100644 --- a/hummingbot/connector/exchange/blocktane/blocktane_order_book_tracker.py +++ b/hummingbot/connector/exchange/blocktane/blocktane_order_book_tracker.py @@ -77,10 +77,8 @@ async def _order_book_diff_router(self): # Log some statistics. now: float = time.time() if int(now / 60.0) > int(last_message_timestamp / 60.0): - # self.logger().debug("Diff messages processed: %d, rejected: %d, queued: %d", - # messages_accepted, - # messages_rejected, - # messages_queued) + # self.logger().debug(f"Diff messages processed: {messages_accepted}, " + # f"rejected: {messages_rejected}, queued: {messages_queued}") messages_accepted = 0 messages_rejected = 0 messages_queued = 0 @@ -127,14 +125,13 @@ async def _track_single_book(self, trading_pair: str): # 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) + # self.logger().debug(f"Processed {diff_messages_accepted} order book diffs for {trading_pair}.") diff_messages_accepted = 0 last_message_timestamp = now elif message.type is OrderBookMessageType.SNAPSHOT: past_diffs: List[OrderBookMessage] = list(past_diffs_window) order_book.restore_from_snapshot_and_diffs(message, past_diffs) - # self.logger().debug("Processed order book snapshot for %s.", trading_pair) + # self.logger().debug(f"Processed order book snapshot for {trading_pair}.") except asyncio.CancelledError: raise except Exception: diff --git a/hummingbot/connector/exchange/coinbase_pro/coinbase_pro_api_order_book_data_source.py b/hummingbot/connector/exchange/coinbase_pro/coinbase_pro_api_order_book_data_source.py index 95bca8b062..0b97fd45ce 100755 --- a/hummingbot/connector/exchange/coinbase_pro/coinbase_pro_api_order_book_data_source.py +++ b/hummingbot/connector/exchange/coinbase_pro/coinbase_pro_api_order_book_data_source.py @@ -11,15 +11,10 @@ List, Optional, ) -from decimal import Decimal import time import ujson import websockets from websockets.exceptions import ConnectionClosed - -import requests -import cachetools.func - from hummingbot.core.data_type.order_book import OrderBook from hummingbot.connector.exchange.coinbase_pro.coinbase_pro_order_book import CoinbaseProOrderBook from hummingbot.core.data_type.order_book_tracker_data_source import OrderBookTrackerDataSource @@ -66,16 +61,6 @@ async def get_last_traded_price(cls, trading_pair: str) -> float: resp_json = await resp.json() return float(resp_json["price"]) - @staticmethod - @cachetools.func.ttl_cache(ttl=10) - def get_mid_price(trading_pair: str) -> Optional[Decimal]: - COINBASE_PRO_PRICE_URL = "https://api.pro.coinbase.com/products/TO_BE_REPLACED/ticker" - resp = requests.get(url=COINBASE_PRO_PRICE_URL.replace("TO_BE_REPLACED", trading_pair)) - record = resp.json() - if "bid" in record and "ask" in record: - result = (Decimal(record["bid"]) + Decimal(record["ask"])) / Decimal("2") - return result - @staticmethod async def fetch_trading_pairs() -> List[str]: try: diff --git a/hummingbot/connector/exchange/coinbase_pro/coinbase_pro_order_book_tracker.py b/hummingbot/connector/exchange/coinbase_pro/coinbase_pro_order_book_tracker.py index ea8f5f4b6a..ebacd90075 100644 --- a/hummingbot/connector/exchange/coinbase_pro/coinbase_pro_order_book_tracker.py +++ b/hummingbot/connector/exchange/coinbase_pro/coinbase_pro_order_book_tracker.py @@ -99,10 +99,8 @@ async def _order_book_diff_router(self): # Log some statistics. now: float = time.time() if int(now / 60.0) > int(last_message_timestamp / 60.0): - self.logger().debug("Diff messages processed: %d, rejected: %d, queued: %d", - messages_accepted, - messages_rejected, - messages_queued) + self.logger().debug(f"Diff messages processed: {messages_accepted}, " + f"rejected: {messages_rejected}, queued: {messages_queued}") messages_accepted = 0 messages_rejected = 0 messages_queued = 0 @@ -153,8 +151,7 @@ async def _track_single_book(self, trading_pair: str): # 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) + self.logger().debug(f"Processed {diff_messages_accepted} order book diffs for {trading_pair}.") diff_messages_accepted = 0 last_message_timestamp = now elif message.type is OrderBookMessageType.SNAPSHOT: @@ -168,7 +165,7 @@ async def _track_single_book(self, trading_pair: str): 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) + self.logger().debug(f"Processed order book snapshot for {trading_pair}.") except asyncio.CancelledError: raise except Exception: diff --git a/test/connector/exchange/bitmax/__init__.py b/hummingbot/connector/exchange/coinzoom/__init__.py similarity index 100% rename from test/connector/exchange/bitmax/__init__.py rename to hummingbot/connector/exchange/coinzoom/__init__.py diff --git a/hummingbot/connector/exchange/coinzoom/coinzoom_active_order_tracker.pxd b/hummingbot/connector/exchange/coinzoom/coinzoom_active_order_tracker.pxd new file mode 100644 index 0000000000..881d7862df --- /dev/null +++ b/hummingbot/connector/exchange/coinzoom/coinzoom_active_order_tracker.pxd @@ -0,0 +1,13 @@ +# distutils: language=c++ +cimport numpy as np + +cdef class CoinzoomActiveOrderTracker: + cdef dict _active_bids + cdef dict _active_asks + cdef dict _active_asks_ids + cdef dict _active_bids_ids + + cdef tuple c_convert_diff_message_to_np_arrays(self, object message) + cdef tuple c_convert_snapshot_message_to_np_arrays(self, object message) + # This method doesn't seem to be used anywhere at all + # cdef np.ndarray[np.float64_t, ndim=1] c_convert_trade_message_to_np_array(self, object message) diff --git a/hummingbot/connector/exchange/coinzoom/coinzoom_active_order_tracker.pyx b/hummingbot/connector/exchange/coinzoom/coinzoom_active_order_tracker.pyx new file mode 100644 index 0000000000..a7e4fcb815 --- /dev/null +++ b/hummingbot/connector/exchange/coinzoom/coinzoom_active_order_tracker.pyx @@ -0,0 +1,157 @@ +# 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 + +_logger = None +s_empty_diff = np.ndarray(shape=(0, 4), dtype="float64") +CoinzoomOrderBookTrackingDictionary = Dict[Decimal, Dict[str, Dict[str, any]]] + +cdef class CoinzoomActiveOrderTracker: + def __init__(self, + active_asks: CoinzoomOrderBookTrackingDictionary = None, + active_bids: CoinzoomOrderBookTrackingDictionary = None): + super().__init__() + self._active_asks = active_asks or {} + self._active_bids = active_bids or {} + self._active_asks_ids = {} + self._active_bids_ids = {} + + @classmethod + def logger(cls) -> HummingbotLogger: + global _logger + if _logger is None: + _logger = logging.getLogger(__name__) + return _logger + + @property + def active_asks(self) -> CoinzoomOrderBookTrackingDictionary: + return self._active_asks + + @property + def active_bids(self) -> CoinzoomOrderBookTrackingDictionary: + return self._active_bids + + # TODO: research this more + def volume_for_ask_price(self, price) -> float: + return NotImplementedError + + # TODO: research this more + def volume_for_bid_price(self, price) -> float: + return NotImplementedError + + def get_rates_and_quantities(self, entry) -> tuple: + # price, quantity + return float(entry[0]), float(entry[1]) + + def get_rates_and_amts_with_ids(self, entry, id_list) -> tuple: + if len(entry) > 1: + price = float(entry[1]) + amount = float(entry[2]) + id_list[str(entry[0])] = price + else: + price = id_list.get(str(entry[0])) + amount = 0.0 + return price, amount + + cdef tuple c_convert_diff_message_to_np_arrays(self, object message): + cdef: + dict content = message.content + list content_keys = list(content.keys()) + list bid_entries = [] + list ask_entries = [] + str order_id + str order_side + str price_raw + object price + dict order_dict + double timestamp = message.timestamp + double amount = 0 + dict nps = {'bids': s_empty_diff, 'asks': s_empty_diff} + + if "b" in content_keys: + bid_entries = content["b"] + if "s" in content_keys: + ask_entries = content["s"] + + for entries, diff_key, id_list in [ + (bid_entries, 'bids', self._active_bids_ids), + (ask_entries, 'asks', self._active_asks_ids) + ]: + if len(entries) > 0: + nps[diff_key] = np.array( + [[timestamp, price, amount, message.update_id] + for price, amount in [self.get_rates_and_amts_with_ids(entry, id_list) for entry in entries] + if price is not None], + dtype="float64", ndmin=2 + ) + return nps['bids'], nps['asks'] + + cdef tuple c_convert_snapshot_message_to_np_arrays(self, object message): + cdef: + float price + float amount + str order_id + dict order_dict + + # Refresh all order tracking. + self._active_bids.clear() + self._active_asks.clear() + timestamp = message.timestamp + content = message.content + content_keys = list(content.keys()) + + if "bids" in content_keys: + for snapshot_orders, active_orders in [(content["bids"], self._active_bids), (content["asks"], self._active_asks)]: + for entry in snapshot_orders: + price, amount = self.get_rates_and_quantities(entry) + active_orders[price] = amount + else: + for snapshot_orders, active_orders, active_order_ids in [ + (content["b"], self._active_bids, self._active_bids_ids), + (content["s"], self._active_asks, self._active_asks_ids) + ]: + for entry in snapshot_orders: + price, amount = self.get_rates_and_amts_with_ids(entry, active_order_ids) + active_orders[price] = amount + + # Return the sorted snapshot tables. + cdef: + np.ndarray[np.float64_t, ndim=2] bids = np.array( + [[message.timestamp, float(price), float(self._active_bids[price]), message.update_id] + for price in sorted(self._active_bids.keys())], dtype='float64', ndmin=2) + np.ndarray[np.float64_t, ndim=2] asks = np.array( + [[message.timestamp, float(price), float(self._active_asks[price]), message.update_id] + for price in sorted(self._active_asks.keys(), reverse=True)], dtype='float64', ndmin=2) + + if bids.shape[1] != 4: + bids = bids.reshape((0, 4)) + if asks.shape[1] != 4: + asks = asks.reshape((0, 4)) + + return bids, asks + + # This method doesn't seem to be used anywhere at all + # cdef np.ndarray[np.float64_t, ndim=1] c_convert_trade_message_to_np_array(self, object message): + # cdef: + # double trade_type_value = 1.0 if message.content[4] == "BUY" else 2.0 + # list content = message.content + + # return np.array([message.timestamp, trade_type_value, float(content[1]), float(content[2])], + # dtype="float64") + + def convert_diff_message_to_order_book_row(self, message): + 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): + 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/coinzoom/coinzoom_api_order_book_data_source.py b/hummingbot/connector/exchange/coinzoom/coinzoom_api_order_book_data_source.py new file mode 100644 index 0000000000..49032d6f7f --- /dev/null +++ b/hummingbot/connector/exchange/coinzoom/coinzoom_api_order_book_data_source.py @@ -0,0 +1,214 @@ +#!/usr/bin/env python +import asyncio +import logging +import time +import pandas as pd +from decimal import Decimal +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 .coinzoom_constants import Constants +from .coinzoom_active_order_tracker import CoinzoomActiveOrderTracker +from .coinzoom_order_book import CoinzoomOrderBook +from .coinzoom_websocket import CoinzoomWebsocket +from .coinzoom_utils import ( + convert_to_exchange_trading_pair, + convert_from_exchange_trading_pair, + api_call_with_retries, + CoinzoomAPIError, +) + + +class CoinzoomAPIOrderBookDataSource(OrderBookTrackerDataSource): + _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, Decimal]: + results = {} + tickers: List[Dict[Any]] = await api_call_with_retries("GET", Constants.ENDPOINT["TICKER"]) + for trading_pair in trading_pairs: + ex_pair: str = convert_to_exchange_trading_pair(trading_pair, True) + ticker: Dict[Any] = list([tic for symbol, tic in tickers.items() if symbol == ex_pair])[0] + results[trading_pair]: Decimal = Decimal(str(ticker["last_price"])) + return results + + @staticmethod + async def fetch_trading_pairs() -> List[str]: + try: + symbols: List[Dict[str, Any]] = await api_call_with_retries("GET", Constants.ENDPOINT["SYMBOL"]) + trading_pairs: List[str] = list([convert_from_exchange_trading_pair(sym["symbol"]) for sym in symbols]) + # Filter out unmatched pairs so nothing breaks + return [sym for sym in trading_pairs if sym is not None] + except Exception: + # Do nothing if the request fails -- there will be no autocomplete for CoinZoom trading pairs + pass + return [] + + @staticmethod + async def get_order_book_data(trading_pair: str) -> Dict[str, any]: + """ + Get whole orderbook + """ + try: + ex_pair = convert_to_exchange_trading_pair(trading_pair, True) + ob_endpoint = Constants.ENDPOINT["ORDER_BOOK"].format(trading_pair=ex_pair) + orderbook_response: Dict[Any] = await api_call_with_retries("GET", ob_endpoint) + return orderbook_response + except CoinzoomAPIError as e: + err = e.error_payload.get('error', e.error_payload) + raise IOError( + f"Error fetching OrderBook for {trading_pair} at {Constants.EXCHANGE_NAME}. " + f"HTTP status is {e.error_payload['status']}. Error is {err.get('message', str(err))}.") + + 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 = float(snapshot['timestamp']) + snapshot_msg: OrderBookMessage = CoinzoomOrderBook.snapshot_message_from_exchange( + snapshot, + snapshot_timestamp, + metadata={"trading_pair": trading_pair}) + order_book = self.order_book_create_function() + active_order_tracker: CoinzoomActiveOrderTracker = CoinzoomActiveOrderTracker() + 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 listen_for_trades(self, ev_loop: asyncio.BaseEventLoop, output: asyncio.Queue): + """ + Listen for trades using websocket trade channel + """ + while True: + try: + ws = CoinzoomWebsocket() + await ws.connect() + + for pair in self._trading_pairs: + await ws.subscribe({Constants.WS_SUB["TRADES"]: {'symbol': convert_to_exchange_trading_pair(pair)}}) + + async for response in ws.on_message(): + msg_keys = list(response.keys()) if response is not None else [] + + if not Constants.WS_METHODS["TRADES_UPDATE"] in msg_keys: + continue + + trade: List[Any] = response[Constants.WS_METHODS["TRADES_UPDATE"]] + trade_msg: OrderBookMessage = CoinzoomOrderBook.trade_message_from_exchange(trade) + output.put_nowait(trade_msg) + + except asyncio.CancelledError: + raise + except Exception: + self.logger().error("Unexpected error.", exc_info=True) + raise + 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 = CoinzoomWebsocket() + await ws.connect() + + order_book_methods = [ + Constants.WS_METHODS['ORDERS_SNAPSHOT'], + Constants.WS_METHODS['ORDERS_UPDATE'], + ] + + for pair in self._trading_pairs: + ex_pair = convert_to_exchange_trading_pair(pair) + ws_stream = { + Constants.WS_SUB["ORDERS"]: { + 'requestId': ex_pair, + 'symbol': ex_pair, + 'aggregate': False, + 'depth': 0, + } + } + await ws.subscribe(ws_stream) + + async for response in ws.on_message(): + msg_keys = list(response.keys()) if response is not None else [] + + method_key = [key for key in msg_keys if key in order_book_methods] + + if len(method_key) != 1: + continue + + method: str = method_key[0] + order_book_data: dict = response + timestamp: int = int(time.time() * 1e3) + pair: str = convert_from_exchange_trading_pair(response[method]) + + order_book_msg_cls = (CoinzoomOrderBook.diff_message_from_exchange + if method == Constants.WS_METHODS['ORDERS_UPDATE'] else + CoinzoomOrderBook.snapshot_message_from_exchange) + + orderbook_msg: OrderBookMessage = order_book_msg_cls( + order_book_data, + timestamp, + metadata={"trading_pair": pair}) + 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_msg: OrderBookMessage = CoinzoomOrderBook.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/coinzoom/coinzoom_api_user_stream_data_source.py b/hummingbot/connector/exchange/coinzoom/coinzoom_api_user_stream_data_source.py new file mode 100755 index 0000000000..7aede77e89 --- /dev/null +++ b/hummingbot/connector/exchange/coinzoom/coinzoom_api_user_stream_data_source.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python +import time +import asyncio +import logging +from typing import ( + Any, + AsyncIterable, + List, + Optional, +) +from hummingbot.core.data_type.user_stream_tracker_data_source import UserStreamTrackerDataSource +from hummingbot.logger import HummingbotLogger +from .coinzoom_constants import Constants +from .coinzoom_auth import CoinzoomAuth +from .coinzoom_utils import CoinzoomAPIError +from .coinzoom_websocket import CoinzoomWebsocket + + +class CoinzoomAPIUserStreamDataSource(UserStreamTrackerDataSource): + + _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, coinzoom_auth: CoinzoomAuth, trading_pairs: Optional[List[str]] = []): + self._coinzoom_auth: CoinzoomAuth = coinzoom_auth + self._ws: CoinzoomWebsocket = None + 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 _ws_request_balances(self): + return await self._ws.request(Constants.WS_METHODS["USER_BALANCE"]) + + async def _listen_to_orders_trades_balances(self) -> AsyncIterable[Any]: + """ + Subscribe to active orders via web socket + """ + + try: + self._ws = CoinzoomWebsocket(self._coinzoom_auth) + + await self._ws.connect() + + await self._ws.subscribe({Constants.WS_SUB["USER_ORDERS_TRADES"]: {}}) + + event_methods = [ + Constants.WS_METHODS["USER_ORDERS"], + # We don't need to know about pending cancels + # Constants.WS_METHODS["USER_ORDERS_CANCEL"], + ] + + async for msg in self._ws.on_message(): + self._last_recv_time = time.time() + + msg_keys = list(msg.keys()) if msg is not None else [] + + if not any(ws_method in msg_keys for ws_method in event_methods): + continue + yield msg + except Exception as e: + raise e + finally: + await self._ws.disconnect() + await asyncio.sleep(5) + + 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: + async for msg in self._listen_to_orders_trades_balances(): + output.put_nowait(msg) + except asyncio.CancelledError: + raise + except CoinzoomAPIError as e: + self.logger().error(e.error_payload.get('error'), exc_info=True) + raise + except Exception: + self.logger().error( + f"Unexpected error with {Constants.EXCHANGE_NAME} WebSocket connection. " + "Retrying after 30 seconds...", exc_info=True) + await asyncio.sleep(30.0) diff --git a/hummingbot/connector/exchange/coinzoom/coinzoom_auth.py b/hummingbot/connector/exchange/coinzoom/coinzoom_auth.py new file mode 100755 index 0000000000..9379f3716b --- /dev/null +++ b/hummingbot/connector/exchange/coinzoom/coinzoom_auth.py @@ -0,0 +1,31 @@ +from typing import Dict, Any + + +class CoinzoomAuth(): + """ + Auth class required by CoinZoom API + Learn more at https://exchange-docs.crypto.com/#digital-signature + """ + def __init__(self, api_key: str, secret_key: str, username: str): + self.api_key = api_key + self.secret_key = secret_key + self.username = username + + def get_ws_params(self) -> Dict[str, str]: + return { + "apiKey": str(self.api_key), + "secretKey": str(self.secret_key), + } + + def get_headers(self) -> Dict[str, Any]: + """ + Generates authentication headers required by CoinZoom + :return: a dictionary of auth headers + """ + headers = { + "Content-Type": "application/json", + "Coinzoom-Api-Key": str(self.api_key), + "Coinzoom-Api-Secret": str(self.secret_key), + "User-Agent": f"hummingbot ZoomMe: {self.username}" + } + return headers diff --git a/hummingbot/connector/exchange/coinzoom/coinzoom_constants.py b/hummingbot/connector/exchange/coinzoom/coinzoom_constants.py new file mode 100644 index 0000000000..0cad1cb049 --- /dev/null +++ b/hummingbot/connector/exchange/coinzoom/coinzoom_constants.py @@ -0,0 +1,60 @@ +# A single source of truth for constant variables related to the exchange +class Constants: + """ + API Documentation Links: + https://api-docs.coinzoom.com/ + https://api-markets.coinzoom.com/ + """ + EXCHANGE_NAME = "coinzoom" + REST_URL = "https://api.coinzoom.com/api/v1/public" + # REST_URL = "https://api.stage.coinzoom.com/api/v1/public" + WS_PRIVATE_URL = "wss://api.coinzoom.com/api/v1/public/market/data/stream" + # WS_PRIVATE_URL = "wss://api.stage.coinzoom.com/api/v1/public/market/data/stream" + WS_PUBLIC_URL = "wss://api.coinzoom.com/api/v1/public/market/data/stream" + # WS_PUBLIC_URL = "wss://api.stage.coinzoom.com/api/v1/public/market/data/stream" + + HBOT_BROKER_ID = "CZ_API_HBOT" + + ENDPOINT = { + # Public Endpoints + "TICKER": "marketwatch/ticker", + "SYMBOL": "instruments", + "ORDER_BOOK": "marketwatch/orderbook/{trading_pair}/150/2", + "ORDER_CREATE": "orders/new", + "ORDER_DELETE": "orders/cancel", + "ORDER_STATUS": "orders/list", + "USER_ORDERS": "orders/list", + "USER_BALANCES": "ledger/list", + } + + WS_SUB = { + "TRADES": "TradeSummaryRequest", + "ORDERS": "OrderBookRequest", + "USER_ORDERS_TRADES": "OrderUpdateRequest", + + } + + WS_METHODS = { + "ORDERS_SNAPSHOT": "ob", + "ORDERS_UPDATE": "oi", + "TRADES_UPDATE": "ts", + "USER_BALANCE": "getTradingBalance", + "USER_ORDERS": "OrderResponse", + "USER_ORDERS_CANCEL": "OrderCancelResponse", + } + + # Timeouts + MESSAGE_TIMEOUT = 30.0 + PING_TIMEOUT = 10.0 + API_CALL_TIMEOUT = 10.0 + API_MAX_RETRIES = 4 + + # Intervals + # Only used when nothing is received from WS + SHORT_POLL_INTERVAL = 5.0 + # One minute should be fine since we request balance updates on order updates + LONG_POLL_INTERVAL = 60.0 + # One minute should be fine for order status since we get these via WS + UPDATE_ORDER_STATUS_INTERVAL = 60.0 + # 10 minute interval to update trading rules, these would likely never change whilst running. + INTERVAL_TRADING_RULES = 600 diff --git a/hummingbot/connector/exchange/coinzoom/coinzoom_exchange.py b/hummingbot/connector/exchange/coinzoom/coinzoom_exchange.py new file mode 100644 index 0000000000..65108d7475 --- /dev/null +++ b/hummingbot/connector/exchange/coinzoom/coinzoom_exchange.py @@ -0,0 +1,919 @@ +import logging +from typing import ( + Dict, + List, + Optional, + Any, + AsyncIterable, +) +from decimal import Decimal +import asyncio +import aiohttp +import math +import time +import ujson +from async_timeout import timeout + +from hummingbot.core.network_iterator import NetworkStatus +from hummingbot.logger import HummingbotLogger +from hummingbot.core.clock import Clock +from hummingbot.core.utils.asyncio_throttle import Throttler +from hummingbot.core.utils.async_utils import safe_ensure_future, safe_gather +from hummingbot.connector.trading_rule import TradingRule +from hummingbot.core.data_type.cancellation_result import CancellationResult +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.connector.exchange_base import ExchangeBase +from hummingbot.connector.exchange.coinzoom.coinzoom_order_book_tracker import CoinzoomOrderBookTracker +from hummingbot.connector.exchange.coinzoom.coinzoom_user_stream_tracker import CoinzoomUserStreamTracker +from hummingbot.connector.exchange.coinzoom.coinzoom_auth import CoinzoomAuth +from hummingbot.connector.exchange.coinzoom.coinzoom_in_flight_order import CoinzoomInFlightOrder +from hummingbot.connector.exchange.coinzoom.coinzoom_utils import ( + convert_from_exchange_trading_pair, + convert_to_exchange_trading_pair, + get_new_client_order_id, + aiohttp_response_with_errors, + retry_sleep_time, + str_date_to_ts, + CoinzoomAPIError, +) +from hummingbot.connector.exchange.coinzoom.coinzoom_constants import Constants +from hummingbot.core.data_type.common import OpenOrder +ctce_logger = None +s_decimal_NaN = Decimal("nan") + + +class CoinzoomExchange(ExchangeBase): + """ + CoinzoomExchange connects with CoinZoom exchange and provides order book pricing, user account tracking and + trading functionality. + """ + ORDER_NOT_EXIST_CONFIRMATION_COUNT = 3 + ORDER_NOT_EXIST_CANCEL_COUNT = 2 + + @classmethod + def logger(cls) -> HummingbotLogger: + global ctce_logger + if ctce_logger is None: + ctce_logger = logging.getLogger(__name__) + return ctce_logger + + def __init__(self, + coinzoom_api_key: str, + coinzoom_secret_key: str, + coinzoom_username: str, + trading_pairs: Optional[List[str]] = None, + trading_required: bool = True + ): + """ + :param coinzoom_api_key: The API key to connect to private CoinZoom APIs. + :param coinzoom_secret_key: The API secret. + :param coinzoom_username: The ZoomMe Username. + :param trading_pairs: The market trading pairs which to track order book data. + :param trading_required: Whether actual trading is needed. + """ + super().__init__() + self._trading_required = trading_required + self._trading_pairs = trading_pairs + self._coinzoom_auth = CoinzoomAuth(coinzoom_api_key, coinzoom_secret_key, coinzoom_username) + self._order_book_tracker = CoinzoomOrderBookTracker(trading_pairs=trading_pairs) + self._user_stream_tracker = CoinzoomUserStreamTracker(self._coinzoom_auth, trading_pairs) + self._ev_loop = asyncio.get_event_loop() + self._shared_client = None + self._poll_notifier = asyncio.Event() + self._last_timestamp = 0 + self._in_flight_orders = {} # Dict[client_order_id:str, CoinzoomInFlightOrder] + self._order_not_found_records = {} # Dict[client_order_id:str, count:int] + self._trading_rules = {} # Dict[trading_pair:str, TradingRule] + self._status_polling_task = None + self._user_stream_event_listener_task = None + self._trading_rules_polling_task = None + self._last_poll_timestamp = 0 + self._throttler = Throttler(rate_limit = (8.0, 6)) + + @property + def name(self) -> str: + return "coinzoom" + + @property + def order_books(self) -> Dict[str, OrderBook]: + return self._order_book_tracker.order_books + + @property + def trading_rules(self) -> Dict[str, TradingRule]: + return self._trading_rules + + @property + def in_flight_orders(self) -> Dict[str, CoinzoomInFlightOrder]: + return self._in_flight_orders + + @property + def status_dict(self) -> Dict[str, bool]: + """ + A dictionary of statuses of various connector's components. + """ + return { + "order_books_initialized": self._order_book_tracker.ready, + "account_balance": len(self._account_balances) > 0 if self._trading_required else True, + "trading_rule_initialized": len(self._trading_rules) > 0, + "user_stream_initialized": + self._user_stream_tracker.data_source.last_recv_time > 0 if self._trading_required else True, + } + + @property + def ready(self) -> bool: + """ + :return True when all statuses pass, this might take 5-10 seconds for all the connector's components and + services to be ready. + """ + return all(self.status_dict.values()) + + @property + def limit_orders(self) -> List[LimitOrder]: + return [ + in_flight_order.to_limit_order() + for in_flight_order in self._in_flight_orders.values() + ] + + @property + def tracking_states(self) -> Dict[str, any]: + """ + :return active in-flight orders in json format, is used to save in sqlite db. + """ + return { + key: value.to_json() + for key, value in self._in_flight_orders.items() + if not value.is_done + } + + def restore_tracking_states(self, saved_states: Dict[str, any]): + """ + Restore in-flight orders from saved tracking states, this is st the connector can pick up on where it left off + when it disconnects. + :param saved_states: The saved tracking_states. + """ + self._in_flight_orders.update({ + key: CoinzoomInFlightOrder.from_json(value) + for key, value in saved_states.items() + }) + + def supported_order_types(self) -> List[OrderType]: + """ + :return a list of OrderType supported by this connector. + Note that Market order type is no longer required and will not be used. + """ + return [OrderType.LIMIT, OrderType.LIMIT_MAKER] + + def start(self, clock: Clock, timestamp: float): + """ + This function is called automatically by the clock. + """ + super().start(clock, timestamp) + + def stop(self, clock: Clock): + """ + This function is called automatically by the clock. + """ + super().stop(clock) + + async def start_network(self): + """ + This function is required by NetworkIterator base class and is called automatically. + It starts tracking order book, polling trading rules, + updating statuses and tracking user data. + """ + self._order_book_tracker.start() + self._trading_rules_polling_task = safe_ensure_future(self._trading_rules_polling_loop()) + if self._trading_required: + self._status_polling_task = safe_ensure_future(self._status_polling_loop()) + self._user_stream_tracker_task = safe_ensure_future(self._user_stream_tracker.start()) + self._user_stream_event_listener_task = safe_ensure_future(self._user_stream_event_listener()) + + async def stop_network(self): + """ + This function is required by NetworkIterator base class and is called automatically. + """ + self._order_book_tracker.stop() + if self._status_polling_task is not None: + self._status_polling_task.cancel() + self._status_polling_task = None + if self._trading_rules_polling_task is not None: + self._trading_rules_polling_task.cancel() + self._trading_rules_polling_task = None + if self._status_polling_task is not None: + self._status_polling_task.cancel() + self._status_polling_task = None + if self._user_stream_tracker_task is not None: + self._user_stream_tracker_task.cancel() + self._user_stream_tracker_task = None + if self._user_stream_event_listener_task is not None: + self._user_stream_event_listener_task.cancel() + self._user_stream_event_listener_task = None + + async def check_network(self) -> NetworkStatus: + """ + This function is required by NetworkIterator base class and is called periodically to check + the network connection. Simply ping the network (or call any light weight public API). + """ + try: + # since there is no ping endpoint, the lowest rate call is to get BTC-USD symbol + await self._api_request("GET", Constants.ENDPOINT['SYMBOL']) + except asyncio.CancelledError: + raise + except Exception: + return NetworkStatus.NOT_CONNECTED + return NetworkStatus.CONNECTED + + async def _http_client(self) -> aiohttp.ClientSession: + """ + :returns Shared client session instance + """ + if self._shared_client is None: + self._shared_client = aiohttp.ClientSession() + return self._shared_client + + async def _trading_rules_polling_loop(self): + """ + Periodically update trading rule. + """ + while True: + try: + await self._update_trading_rules() + await asyncio.sleep(Constants.INTERVAL_TRADING_RULES) + except asyncio.CancelledError: + raise + except Exception as e: + self.logger().network(f"Unexpected error while fetching trading rules. Error: {str(e)}", + exc_info=True, + app_warning_msg=("Could not fetch new trading rules from " + f"{Constants.EXCHANGE_NAME}. Check network connection.")) + await asyncio.sleep(0.5) + + async def _update_trading_rules(self): + symbols_info = await self._api_request("GET", endpoint=Constants.ENDPOINT['SYMBOL']) + self._trading_rules.clear() + self._trading_rules = self._format_trading_rules(symbols_info) + + def _format_trading_rules(self, symbols_info: Dict[str, Any]) -> Dict[str, TradingRule]: + """ + Converts json API response into a dictionary of trading rules. + :param symbols_info: The json API response + :return A dictionary of trading rules. + Response Example: + [ + { + "symbol" : "BTC/USD", + "baseCurrencyCode" : "BTC", + "termCurrencyCode" : "USD", + "minTradeAmt" : 0.0001, + "maxTradeAmt" : 10, + "maxPricePrecision" : 2, + "maxQuantityPrecision" : 6, + "issueOnly" : false + } + ] + """ + result = {} + for rule in symbols_info: + try: + trading_pair = convert_from_exchange_trading_pair(rule["symbol"]) + min_amount = Decimal(str(rule["minTradeAmt"])) + min_price = Decimal(f"1e-{rule['maxPricePrecision']}") + result[trading_pair] = TradingRule(trading_pair, + min_order_size=min_amount, + max_order_size=Decimal(str(rule["maxTradeAmt"])), + min_price_increment=min_price, + min_base_amount_increment=min_amount, + min_notional_size=min(min_price * min_amount, Decimal("0.00000001")), + max_price_significant_digits=Decimal(str(rule["maxPricePrecision"])), + ) + except Exception: + self.logger().error(f"Error parsing the trading pair rule {rule}. Skipping.", exc_info=True) + return result + + async def _api_request(self, + method: str, + endpoint: str, + params: Optional[Dict[str, Any]] = None, + is_auth_required: bool = False, + try_count: int = 0) -> Dict[str, Any]: + """ + Sends an aiohttp request and waits for a response. + :param method: The HTTP method, e.g. get or post + :param endpoint: The path url or the API end point + :param params: Additional get/post parameters + :param is_auth_required: Whether an authentication is required, when True the function will add encrypted + signature to the request. + :returns A response in json format. + """ + async with self._throttler.weighted_task(request_weight=1): + url = f"{Constants.REST_URL}/{endpoint}" + shared_client = await self._http_client() + # Turn `params` into either GET params or POST body data + qs_params: dict = params if method.upper() == "GET" else None + req_params = ujson.dumps(params) if method.upper() == "POST" and params is not None else None + # Generate auth headers if needed. + headers: dict = {"Content-Type": "application/json", "User-Agent": "hummingbot"} + if is_auth_required: + headers: dict = self._coinzoom_auth.get_headers() + # Build request coro + response_coro = shared_client.request(method=method.upper(), url=url, headers=headers, + params=qs_params, data=req_params, + timeout=Constants.API_CALL_TIMEOUT) + http_status, parsed_response, request_errors = await aiohttp_response_with_errors(response_coro) + if request_errors or parsed_response is None: + if try_count < Constants.API_MAX_RETRIES: + try_count += 1 + time_sleep = retry_sleep_time(try_count) + self.logger().info(f"Error fetching data from {url}. HTTP status is {http_status}. " + f"Retrying in {time_sleep:.0f}s.") + await asyncio.sleep(time_sleep) + return await self._api_request(method=method, endpoint=endpoint, params=params, + is_auth_required=is_auth_required, try_count=try_count) + else: + raise CoinzoomAPIError({"error": parsed_response, "status": http_status}) + if "error" in parsed_response: + raise CoinzoomAPIError(parsed_response) + return parsed_response + + def get_order_price_quantum(self, trading_pair: str, price: Decimal): + """ + Returns a price step, a minimum price increment for a given trading pair. + """ + trading_rule = self._trading_rules[trading_pair] + return trading_rule.min_price_increment + + def get_order_size_quantum(self, trading_pair: str, order_size: Decimal): + """ + Returns an order amount step, a minimum amount increment for a given trading pair. + """ + trading_rule = self._trading_rules[trading_pair] + return Decimal(trading_rule.min_base_amount_increment) + + def get_order_book(self, trading_pair: str) -> OrderBook: + if trading_pair not in self._order_book_tracker.order_books: + raise ValueError(f"No order book exists for '{trading_pair}'.") + return self._order_book_tracker.order_books[trading_pair] + + def buy(self, trading_pair: str, amount: Decimal, order_type=OrderType.MARKET, + price: Decimal = s_decimal_NaN, **kwargs) -> str: + """ + Buys an amount of base asset (of the given trading pair). This function returns immediately. + To see an actual order, you'll have to wait for BuyOrderCreatedEvent. + :param trading_pair: The market (e.g. BTC-USDT) to buy from + :param amount: The amount in base token value + :param order_type: The order type + :param price: The price (note: this is no longer optional) + :returns A new internal order id + """ + order_id: str = get_new_client_order_id(True, trading_pair) + safe_ensure_future(self._create_order(TradeType.BUY, order_id, trading_pair, amount, order_type, price)) + return order_id + + def sell(self, trading_pair: str, amount: Decimal, order_type=OrderType.MARKET, + price: Decimal = s_decimal_NaN, **kwargs) -> str: + """ + Sells an amount of base asset (of the given trading pair). This function returns immediately. + To see an actual order, you'll have to wait for SellOrderCreatedEvent. + :param trading_pair: The market (e.g. BTC-USDT) to sell from + :param amount: The amount in base token value + :param order_type: The order type + :param price: The price (note: this is no longer optional) + :returns A new internal order id + """ + order_id: str = get_new_client_order_id(False, trading_pair) + safe_ensure_future(self._create_order(TradeType.SELL, order_id, trading_pair, amount, order_type, price)) + return order_id + + def cancel(self, trading_pair: str, order_id: str): + """ + Cancel an order. This function returns immediately. + To get the cancellation result, you'll have to wait for OrderCancelledEvent. + :param trading_pair: The market (e.g. BTC-USDT) of the order. + :param order_id: The internal order id (also called client_order_id) + """ + safe_ensure_future(self._execute_cancel(trading_pair, order_id)) + return order_id + + async def _create_order(self, + trade_type: TradeType, + order_id: str, + trading_pair: str, + amount: Decimal, + order_type: OrderType, + price: Decimal): + """ + Calls create-order API end point to place an order, starts tracking the order and triggers order created event. + :param trade_type: BUY or SELL + :param order_id: Internal order id (also called client_order_id) + :param trading_pair: The market to place order + :param amount: The order amount (in base token value) + :param order_type: The order type + :param price: The order price + """ + if not order_type.is_limit_type(): + raise Exception(f"Unsupported order type: {order_type}") + trading_rule = self._trading_rules[trading_pair] + + amount = self.quantize_order_amount(trading_pair, amount) + price = self.quantize_order_price(trading_pair, price) + if amount < trading_rule.min_order_size: + raise ValueError(f"Buy order amount {amount} is lower than the minimum order size " + f"{trading_rule.min_order_size}.") + order_type_str = order_type.name.upper().split("_")[0] + api_params = {"symbol": convert_to_exchange_trading_pair(trading_pair), + "orderType": order_type_str, + "orderSide": trade_type.name.upper(), + "quantity": f"{amount:f}", + "price": f"{price:f}", + "originType": Constants.HBOT_BROKER_ID, + # CoinZoom doesn't support client order id yet + # "clientOrderId": order_id, + "payFeesWithZoomToken": "true", + } + self.start_tracking_order(order_id, None, trading_pair, trade_type, price, amount, order_type) + try: + order_result = await self._api_request("POST", Constants.ENDPOINT["ORDER_CREATE"], api_params, True) + exchange_order_id = str(order_result) + tracked_order = self._in_flight_orders.get(order_id) + if tracked_order is not None: + self.logger().info(f"Created {order_type.name} {trade_type.name} order {order_id} for " + f"{amount} {trading_pair}.") + tracked_order.update_exchange_order_id(exchange_order_id) + if trade_type is TradeType.BUY: + event_tag = MarketEvent.BuyOrderCreated + event_cls = BuyOrderCreatedEvent + else: + event_tag = MarketEvent.SellOrderCreated + event_cls = SellOrderCreatedEvent + self.trigger_event(event_tag, + event_cls(self.current_timestamp, order_type, trading_pair, amount, price, order_id)) + except asyncio.CancelledError: + raise + except CoinzoomAPIError as e: + error_reason = e.error_payload.get('error', {}).get('message') + self.stop_tracking_order(order_id) + self.logger().network( + f"Error submitting {trade_type.name} {order_type.name} order to {Constants.EXCHANGE_NAME} for " + f"{amount} {trading_pair} {price} - {error_reason}.", + exc_info=True, + app_warning_msg=(f"Error submitting order to {Constants.EXCHANGE_NAME} - {error_reason}.") + ) + self.trigger_event(MarketEvent.OrderFailure, + MarketOrderFailureEvent(self.current_timestamp, order_id, order_type)) + + def start_tracking_order(self, + order_id: str, + exchange_order_id: str, + trading_pair: str, + trade_type: TradeType, + price: Decimal, + amount: Decimal, + order_type: OrderType): + """ + Starts tracking an order by simply adding it into _in_flight_orders dictionary. + """ + self._in_flight_orders[order_id] = CoinzoomInFlightOrder( + client_order_id=order_id, + exchange_order_id=exchange_order_id, + trading_pair=trading_pair, + order_type=order_type, + trade_type=trade_type, + price=price, + amount=amount + ) + + def stop_tracking_order(self, order_id: str): + """ + Stops tracking an order by simply removing it from _in_flight_orders dictionary. + """ + if order_id in self._in_flight_orders: + del self._in_flight_orders[order_id] + if order_id in self._order_not_found_records: + del self._order_not_found_records[order_id] + + async def _execute_cancel(self, trading_pair: str, order_id: str) -> str: + """ + Executes order cancellation process by first calling cancel-order API. The API result doesn't confirm whether + the cancellation is successful, it simply states it receives the request. + :param trading_pair: The market trading pair (Unused during cancel on CoinZoom) + :param order_id: The internal order id + order.last_state to change to CANCELED + """ + order_was_cancelled = False + try: + tracked_order = self._in_flight_orders.get(order_id) + if tracked_order is None: + raise ValueError(f"Failed to cancel order - {order_id}. Order not found.") + if tracked_order.exchange_order_id is None: + await tracked_order.get_exchange_order_id() + ex_order_id = tracked_order.exchange_order_id + api_params = { + "orderId": ex_order_id, + "symbol": convert_to_exchange_trading_pair(trading_pair) + } + await self._api_request("POST", + Constants.ENDPOINT["ORDER_DELETE"], + api_params, + is_auth_required=True) + order_was_cancelled = True + except asyncio.CancelledError: + raise + except CoinzoomAPIError as e: + err = e.error_payload.get('error', e.error_payload) + self.logger().error(f"Order Cancel API Error: {err}") + # CoinZoom doesn't report any error if the order wasn't found so we can only handle API failures here. + self._order_not_found_records[order_id] = self._order_not_found_records.get(order_id, 0) + 1 + if self._order_not_found_records[order_id] >= self.ORDER_NOT_EXIST_CANCEL_COUNT: + order_was_cancelled = True + if order_was_cancelled: + self.logger().info(f"Successfully cancelled order {order_id} on {Constants.EXCHANGE_NAME}.") + self.stop_tracking_order(order_id) + self.trigger_event(MarketEvent.OrderCancelled, + OrderCancelledEvent(self.current_timestamp, order_id)) + tracked_order.cancelled_event.set() + return CancellationResult(order_id, True) + else: + self.logger().network( + f"Failed to cancel order {order_id}: {err.get('message', str(err))}", + exc_info=True, + app_warning_msg=f"Failed to cancel the order {order_id} on {Constants.EXCHANGE_NAME}. " + f"Check API key and network connection." + ) + return CancellationResult(order_id, False) + + async def _status_polling_loop(self): + """ + Periodically update user balances and order status via REST API. This serves as a fallback measure for web + socket API updates. + """ + while True: + try: + self._poll_notifier = asyncio.Event() + await self._poll_notifier.wait() + await safe_gather( + self._update_balances(), + self._update_order_status(), + ) + self._last_poll_timestamp = self.current_timestamp + except asyncio.CancelledError: + raise + except Exception as e: + self.logger().error(str(e), exc_info=True) + warn_msg = (f"Could not fetch account updates from {Constants.EXCHANGE_NAME}. " + "Check API key and network connection.") + self.logger().network("Unexpected error while fetching account updates.", exc_info=True, + app_warning_msg=warn_msg) + await asyncio.sleep(0.5) + + async def _update_balances(self): + """ + Calls REST API to update total and available balances. + """ + account_info = await self._api_request("GET", Constants.ENDPOINT["USER_BALANCES"], is_auth_required=True) + self._process_balance_message(account_info) + + async def _update_order_status(self): + """ + Calls REST API to get status update for each in-flight order. + """ + last_tick = int(self._last_poll_timestamp / Constants.UPDATE_ORDER_STATUS_INTERVAL) + current_tick = int(self.current_timestamp / Constants.UPDATE_ORDER_STATUS_INTERVAL) + + if current_tick > last_tick and len(self._in_flight_orders) > 0: + tracked_orders = list(self._in_flight_orders.values()) + api_params = { + 'symbol': None, + 'orderSide': None, + 'orderStatuses': ["NEW", "PARTIALLY_FILLED"], + 'size': 500, + 'bookmarkOrderId': None + } + self.logger().debug(f"Polling for order status updates of {len(tracked_orders)} orders.") + open_orders = await self._api_request("POST", + Constants.ENDPOINT["ORDER_STATUS"], + api_params, + is_auth_required=True) + + open_orders_dict = {o['id']: o for o in open_orders} + found_ex_order_ids = list(open_orders_dict.keys()) + + for tracked_order in tracked_orders: + client_order_id = tracked_order.client_order_id + ex_order_id = tracked_order.exchange_order_id + if ex_order_id not in found_ex_order_ids: + self._order_not_found_records[client_order_id] = \ + self._order_not_found_records.get(client_order_id, 0) + 1 + if self._order_not_found_records[client_order_id] < self.ORDER_NOT_EXIST_CONFIRMATION_COUNT: + # Wait until the order is not found a few times before actually treating it as failed. + continue + self.trigger_event(MarketEvent.OrderFailure, + MarketOrderFailureEvent( + self.current_timestamp, client_order_id, tracked_order.order_type)) + self.stop_tracking_order(client_order_id) + else: + self._process_order_message(open_orders_dict[ex_order_id]) + + def _process_order_message(self, order_msg: Dict[str, Any]): + """ + Updates in-flight order and triggers cancellation or failure event if needed. + :param order_msg: The order response from either REST or web socket API (they are of the same format) + Example Orders: + REST request + { + "id" : "977f82aa-23dc-4c8b-982c-2ee7d2002882", + "clientOrderId" : null, + "symbol" : "BTC/USD", + "orderType" : "LIMIT", + "orderSide" : "BUY", + "quantity" : 0.1, + "price" : 54570, + "payFeesWithZoomToken" : false, + "orderStatus" : "PARTIALLY_FILLED", + "timestamp" : "2021-03-24T04:07:26.260253Z", + "executions" : + [ + { + "id" : "38761582-2b37-4e27-a561-434981d21a96", + "executionType" : "PARTIAL_FILL", + "orderStatus" : "PARTIALLY_FILLED", + "lastPrice" : 54570, + "averagePrice" : 54570, + "lastQuantity" : 0.01, + "leavesQuantity" : 0.09, + "cumulativeQuantity" : 0.01, + "rejectReason" : null, + "timestamp" : "2021-03-24T04:07:44.503222Z" + } + ] + } + WS request + { + 'orderId': '962a2a54-fbcf-4d89-8f37-a8854020a823', + 'symbol': 'BTC/USD', 'orderType': 'LIMIT', + 'orderSide': 'BUY', + 'price': 5000, + 'quantity': 0.001, + 'executionType': 'CANCEL', + 'orderStatus': 'CANCELLED', + 'lastQuantity': 0, + 'leavesQuantity': 0, + 'cumulativeQuantity': 0, + 'transactTime': '2021-03-23T19:06:51.155520Z' + + ... Optional fields + + 'id': '4eb3f26c-91bd-4bd2-bacb-15b2f432c452', + "orderType": "LIMIT", + "lastPrice": 56518.7, + "averagePrice": 56518.7, + } + """ + # Looks like CoinZoom might support clientOrderId eventually so leaving this here for now. + # if order_msg.get('clientOrderId') is not None: + # client_order_id = order_msg["clientOrderId"] + # if client_order_id not in self._in_flight_orders: + # return + # tracked_order = self._in_flight_orders[client_order_id] + # else: + if "orderId" not in order_msg: + exchange_order_id = str(order_msg["id"]) + else: + exchange_order_id = str(order_msg["orderId"]) + tracked_orders = list(self._in_flight_orders.values()) + track_order = [o for o in tracked_orders if exchange_order_id == o.exchange_order_id] + if not track_order: + return + tracked_order = track_order[0] + + # Estimate fee + order_msg["trade_fee"] = self.estimate_fee_pct(tracked_order.order_type is OrderType.LIMIT_MAKER) + updated = tracked_order.update_with_order_update(order_msg) + # Call Update balances on every message to catch order create, fill and cancel. + safe_ensure_future(self._update_balances()) + + if updated: + safe_ensure_future(self._trigger_order_fill(tracked_order, order_msg)) + elif tracked_order.is_cancelled: + self.logger().info(f"Successfully cancelled order {tracked_order.client_order_id}.") + self.stop_tracking_order(tracked_order.client_order_id) + self.trigger_event(MarketEvent.OrderCancelled, + OrderCancelledEvent(self.current_timestamp, tracked_order.client_order_id)) + tracked_order.cancelled_event.set() + elif tracked_order.is_failure: + self.logger().info(f"The order {tracked_order.client_order_id} has failed according to order status API. ") + self.trigger_event(MarketEvent.OrderFailure, + MarketOrderFailureEvent( + self.current_timestamp, tracked_order.client_order_id, tracked_order.order_type)) + self.stop_tracking_order(tracked_order.client_order_id) + + async def _trigger_order_fill(self, + tracked_order: CoinzoomInFlightOrder, + update_msg: Dict[str, Any]): + self.trigger_event( + MarketEvent.OrderFilled, + OrderFilledEvent( + self.current_timestamp, + tracked_order.client_order_id, + tracked_order.trading_pair, + tracked_order.trade_type, + tracked_order.order_type, + Decimal(str(update_msg.get("averagePrice", update_msg.get("price", "0")))), + tracked_order.executed_amount_base, + TradeFee(percent=update_msg["trade_fee"]), + update_msg.get("exchange_trade_id", update_msg.get("id", update_msg.get("orderId"))) + ) + ) + if math.isclose(tracked_order.executed_amount_base, tracked_order.amount) or \ + tracked_order.executed_amount_base >= tracked_order.amount or \ + tracked_order.is_done: + tracked_order.last_state = "FILLED" + self.logger().info(f"The {tracked_order.trade_type.name} order " + f"{tracked_order.client_order_id} has completed " + f"according to order status API.") + event_tag = MarketEvent.BuyOrderCompleted if tracked_order.trade_type is TradeType.BUY \ + else MarketEvent.SellOrderCompleted + event_class = BuyOrderCompletedEvent if tracked_order.trade_type is TradeType.BUY \ + else SellOrderCompletedEvent + await asyncio.sleep(0.1) + self.trigger_event(event_tag, + event_class(self.current_timestamp, + tracked_order.client_order_id, + tracked_order.base_asset, + tracked_order.quote_asset, + tracked_order.fee_asset, + tracked_order.executed_amount_base, + tracked_order.executed_amount_quote, + tracked_order.fee_paid, + tracked_order.order_type)) + self.stop_tracking_order(tracked_order.client_order_id) + + def _process_balance_message(self, balance_update): + local_asset_names = set(self._account_balances.keys()) + remote_asset_names = set() + for account in balance_update: + asset_name = account["currency"] + total_bal = Decimal(str(account["totalBalance"])) + self._account_available_balances[asset_name] = total_bal + Decimal(str(account["reservedBalance"])) + self._account_balances[asset_name] = total_bal + remote_asset_names.add(asset_name) + + asset_names_to_remove = local_asset_names.difference(remote_asset_names) + for asset_name in asset_names_to_remove: + del self._account_available_balances[asset_name] + del self._account_balances[asset_name] + + async def cancel_all(self, timeout_seconds: float) -> List[CancellationResult]: + """ + Cancels all in-flight orders and waits for cancellation results. + Used by bot's top level stop and exit commands (cancelling outstanding orders on exit) + :param timeout_seconds: The timeout at which the operation will be canceled. + :returns List of CancellationResult which indicates whether each order is successfully cancelled. + """ + # if self._trading_pairs is None: + # raise Exception("cancel_all can only be used when trading_pairs are specified.") + open_orders = [o for o in self._in_flight_orders.values() if not o.is_done] + if len(open_orders) == 0: + return [] + tasks = [self._execute_cancel(o.trading_pair, o.client_order_id) for o in open_orders] + cancellation_results = [] + try: + async with timeout(timeout_seconds): + cancellation_results = await safe_gather(*tasks, return_exceptions=False) + except Exception: + self.logger().network( + "Unexpected error cancelling orders.", exc_info=True, + app_warning_msg=(f"Failed to cancel all orders on {Constants.EXCHANGE_NAME}. " + "Check API key and network connection.") + ) + return cancellation_results + + def tick(self, timestamp: float): + """ + Is called automatically by the clock for each clock's tick (1 second by default). + It checks if status polling task is due for execution. + """ + now = time.time() + poll_interval = (Constants.SHORT_POLL_INTERVAL + if now - self._user_stream_tracker.last_recv_time > 60.0 + else Constants.LONG_POLL_INTERVAL) + last_tick = int(self._last_timestamp / poll_interval) + current_tick = int(timestamp / poll_interval) + if current_tick > last_tick: + if not self._poll_notifier.is_set(): + self._poll_notifier.set() + self._last_timestamp = timestamp + + def get_fee(self, + base_currency: str, + quote_currency: str, + order_type: OrderType, + order_side: TradeType, + amount: Decimal, + price: Decimal = s_decimal_NaN) -> TradeFee: + """ + To get trading fee, this function is simplified by using fee override configuration. Most parameters to this + function are ignore except order_type. Use OrderType.LIMIT_MAKER to specify you want trading fee for + maker order. + """ + is_maker = order_type is OrderType.LIMIT_MAKER + return TradeFee(percent=self.estimate_fee_pct(is_maker)) + + async def _iter_user_event_queue(self) -> AsyncIterable[Dict[str, any]]: + while True: + try: + yield await self._user_stream_tracker.user_stream.get() + except asyncio.CancelledError: + raise + except Exception: + self.logger().network( + "Unknown error. Retrying after 1 seconds.", exc_info=True, + app_warning_msg=(f"Could not fetch user events from {Constants.EXCHANGE_NAME}. " + "Check API key and network connection.")) + await asyncio.sleep(1.0) + + async def _user_stream_event_listener(self): + """ + Listens to message in _user_stream_tracker.user_stream queue. The messages are put in by + CoinzoomAPIUserStreamDataSource. + """ + async for event_message in self._iter_user_event_queue(): + try: + event_methods = [ + Constants.WS_METHODS["USER_ORDERS"], + Constants.WS_METHODS["USER_ORDERS_CANCEL"], + ] + + msg_keys = list(event_message.keys()) if event_message is not None else [] + + method_key = [key for key in msg_keys if key in event_methods] + + if len(method_key) != 1: + continue + + method: str = method_key[0] + + if method == Constants.WS_METHODS["USER_ORDERS"]: + self._process_order_message(event_message[method]) + elif method == Constants.WS_METHODS["USER_ORDERS_CANCEL"]: + self._process_order_message(event_message[method]) + except asyncio.CancelledError: + raise + except Exception: + self.logger().error("Unexpected error in user stream listener loop.", exc_info=True) + await asyncio.sleep(5.0) + + # This is currently unused, but looks like a future addition. + async def get_open_orders(self) -> List[OpenOrder]: + tracked_orders = list(self._in_flight_orders.values()) + api_params = { + 'symbol': None, + 'orderSide': None, + 'orderStatuses': ["NEW", "PARTIALLY_FILLED"], + 'size': 500, + 'bookmarkOrderId': None + } + result = await self._api_request("POST", Constants.ENDPOINT["USER_ORDERS"], api_params, is_auth_required=True) + ret_val = [] + for order in result: + exchange_order_id = str(order["id"]) + # CoinZoom doesn't support client order ids yet so we must find it from the tracked orders. + track_order = [o for o in tracked_orders if exchange_order_id == o.exchange_order_id] + if not track_order or len(track_order) < 1: + # Skip untracked orders + continue + client_order_id = track_order[0].client_order_id + # if Constants.HBOT_BROKER_ID not in order["clientOrderId"]: + # continue + if order["orderType"] != OrderType.LIMIT.name.upper(): + self.logger().info(f"Unsupported order type found: {order['type']}") + # Skip and report non-limit orders + continue + ret_val.append( + OpenOrder( + client_order_id=client_order_id, + trading_pair=convert_from_exchange_trading_pair(order["symbol"]), + price=Decimal(str(order["price"])), + amount=Decimal(str(order["quantity"])), + executed_amount=Decimal(str(order["cumQuantity"])), + status=order["orderStatus"], + order_type=OrderType.LIMIT, + is_buy=True if order["orderSide"].lower() == TradeType.BUY.name.lower() else False, + time=str_date_to_ts(order["timestamp"]), + exchange_order_id=order["id"] + ) + ) + return ret_val diff --git a/hummingbot/connector/exchange/coinzoom/coinzoom_in_flight_order.py b/hummingbot/connector/exchange/coinzoom/coinzoom_in_flight_order.py new file mode 100644 index 0000000000..61da8fdb0b --- /dev/null +++ b/hummingbot/connector/exchange/coinzoom/coinzoom_in_flight_order.py @@ -0,0 +1,163 @@ +from decimal import Decimal +from typing import ( + Any, + Dict, + Optional, +) +import asyncio +from hummingbot.core.event.events import ( + OrderType, + TradeType +) +from hummingbot.connector.in_flight_order_base import InFlightOrderBase + +s_decimal_0 = Decimal(0) + + +class CoinzoomInFlightOrder(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, + ) + self.trade_id_set = set() + self.cancelled_event = asyncio.Event() + + @property + def is_done(self) -> bool: + return self.last_state in {"FILLED", "CANCELLED", "REJECTED"} + + @property + def is_failure(self) -> bool: + 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 = CoinzoomInFlightOrder( + 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_order_update(self, order_update: Dict[str, Any]) -> bool: + """ + Updates the in flight order with order update (from private/get-order-detail end point) + return: True if the order gets updated otherwise False + Example Orders: + REST request + { + "id" : "977f82aa-23dc-4c8b-982c-2ee7d2002882", + "clientOrderId" : null, + "symbol" : "BTC/USD", + "orderType" : "LIMIT", + "orderSide" : "BUY", + "quantity" : 0.1, + "price" : 54570, + "payFeesWithZoomToken" : false, + "orderStatus" : "PARTIALLY_FILLED", + "timestamp" : "2021-03-24T04:07:26.260253Z", + "executions" : + [ + { + "id" : "38761582-2b37-4e27-a561-434981d21a96", + "executionType" : "PARTIAL_FILL", + "orderStatus" : "PARTIALLY_FILLED", + "lastPrice" : 54570, + "averagePrice" : 54570, + "lastQuantity" : 0.01, + "leavesQuantity" : 0.09, + "cumulativeQuantity" : 0.01, + "rejectReason" : null, + "timestamp" : "2021-03-24T04:07:44.503222Z" + } + ] + } + WS request + { + 'id': '4eb3f26c-91bd-4bd2-bacb-15b2f432c452', + 'orderId': '962a2a54-fbcf-4d89-8f37-a8854020a823', + 'symbol': 'BTC/USD', 'orderType': 'LIMIT', + 'orderSide': 'BUY', + 'price': 5000, + 'quantity': 0.001, + 'executionType': 'CANCEL', + 'orderStatus': 'CANCELLED', + 'lastQuantity': 0, + 'leavesQuantity': 0, + 'cumulativeQuantity': 0, + 'transactTime': '2021-03-23T19:06:51.155520Z' + } + """ + # Update order execution status + self.last_state = order_update["orderStatus"] + + if 'cumulativeQuantity' not in order_update and 'executions' not in order_update: + return False + + trades = order_update.get('executions') + if trades is not None: + new_trades = False + for trade in trades: + trade_id = str(trade["timestamp"]) + if trade_id not in self.trade_id_set: + self.trade_id_set.add(trade_id) + order_update["exchange_trade_id"] = trade.get("id") + # Add executed amounts + executed_price = Decimal(str(trade.get("lastPrice", "0"))) + self.executed_amount_base += Decimal(str(trade["lastQuantity"])) + self.executed_amount_quote += executed_price * self.executed_amount_base + # Set new trades flag + new_trades = True + if not new_trades: + # trades already recorded + return False + else: + trade_id = str(order_update["transactTime"]) + if trade_id in self.trade_id_set: + # trade already recorded + return False + self.trade_id_set.add(trade_id) + # Set executed amounts + executed_price = Decimal(str(order_update.get("averagePrice", order_update.get("price", "0")))) + self.executed_amount_base = Decimal(str(order_update["cumulativeQuantity"])) + self.executed_amount_quote = executed_price * self.executed_amount_base + if self.executed_amount_base <= s_decimal_0: + # No trades executed yet. + return False + self.fee_paid += order_update.get("trade_fee") * self.executed_amount_base + if not self.fee_asset: + self.fee_asset = self.quote_asset + return True diff --git a/hummingbot/connector/exchange/coinzoom/coinzoom_order_book.py b/hummingbot/connector/exchange/coinzoom/coinzoom_order_book.py new file mode 100644 index 0000000000..e771d48cf3 --- /dev/null +++ b/hummingbot/connector/exchange/coinzoom/coinzoom_order_book.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python + +import logging +from hummingbot.connector.exchange.coinzoom.coinzoom_constants import 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.coinzoom.coinzoom_order_book_message import CoinzoomOrderBookMessage +from .coinzoom_utils import ( + convert_from_exchange_trading_pair, + str_date_to_ts, +) + +_logger = None + + +class CoinzoomOrderBook(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: CoinzoomOrderBookMessage + """ + + if metadata: + msg.update(metadata) + + return CoinzoomOrderBookMessage( + 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: CoinzoomOrderBookMessage + """ + return CoinzoomOrderBookMessage( + 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: CoinzoomOrderBookMessage + """ + + if metadata: + msg.update(metadata) + + return CoinzoomOrderBookMessage( + 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: CoinzoomOrderBookMessage + """ + return CoinzoomOrderBookMessage( + 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: CoinzoomOrderBookMessage + """ + + trade_msg = { + "trade_type": msg[4], + "price": msg[1], + "amount": msg[2], + "trading_pair": convert_from_exchange_trading_pair(msg[0]) + } + trade_timestamp = str_date_to_ts(msg[3]) + + return CoinzoomOrderBookMessage( + message_type=OrderBookMessageType.TRADE, + content=trade_msg, + timestamp=trade_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: CoinzoomOrderBookMessage + """ + return CoinzoomOrderBookMessage( + 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(self, snapshot: OrderBookMessage, diffs: List[OrderBookMessage]): + raise NotImplementedError(Constants.EXCHANGE_NAME + " order book needs to retain individual order data.") diff --git a/hummingbot/connector/exchange/coinzoom/coinzoom_order_book_message.py b/hummingbot/connector/exchange/coinzoom/coinzoom_order_book_message.py new file mode 100644 index 0000000000..d6bc00541d --- /dev/null +++ b/hummingbot/connector/exchange/coinzoom/coinzoom_order_book_message.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python + +from typing import ( + Dict, + Optional, +) + +from hummingbot.core.data_type.order_book_message import ( + OrderBookMessage, + OrderBookMessageType, +) +from hummingbot.connector.exchange.coinzoom.coinzoom_constants import Constants + + +class CoinzoomOrderBookMessage(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(CoinzoomOrderBookMessage, 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 self.timestamp + else: + return -1 + + @property + def trade_id(self) -> int: + if self.type is OrderBookMessageType.TRADE: + return self.timestamp + return -1 + + @property + def trading_pair(self) -> str: + return self.content["trading_pair"] + + # The `asks` and `bids` properties are only used in the methods below. + # They are all replaced or unused in this connector: + # OrderBook.restore_from_snapshot_and_diffs + # OrderBookTracker._track_single_book + # MockAPIOrderBookDataSource.get_tracking_pairs + @property + def asks(self): + raise NotImplementedError(Constants.EXCHANGE_NAME + " order book uses active_order_tracker.") + + @property + def bids(self): + raise NotImplementedError(Constants.EXCHANGE_NAME + " order book uses active_order_tracker.") + + 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/coinzoom/coinzoom_order_book_tracker.py b/hummingbot/connector/exchange/coinzoom/coinzoom_order_book_tracker.py new file mode 100644 index 0000000000..c81ca9a7bd --- /dev/null +++ b/hummingbot/connector/exchange/coinzoom/coinzoom_order_book_tracker.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python +import asyncio +import bisect +import logging +from hummingbot.connector.exchange.coinzoom.coinzoom_constants import 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.logger import HummingbotLogger +from hummingbot.core.data_type.order_book_tracker import OrderBookTracker +from hummingbot.connector.exchange.coinzoom.coinzoom_order_book_message import CoinzoomOrderBookMessage +from hummingbot.connector.exchange.coinzoom.coinzoom_active_order_tracker import CoinzoomActiveOrderTracker +from hummingbot.connector.exchange.coinzoom.coinzoom_api_order_book_data_source import CoinzoomAPIOrderBookDataSource +from hummingbot.connector.exchange.coinzoom.coinzoom_order_book import CoinzoomOrderBook + + +class CoinzoomOrderBookTracker(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__(CoinzoomAPIOrderBookDataSource(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, CoinzoomOrderBook] = {} + self._saved_message_queues: Dict[str, Deque[CoinzoomOrderBookMessage]] = \ + defaultdict(lambda: deque(maxlen=1000)) + self._active_order_trackers: Dict[str, CoinzoomActiveOrderTracker] = defaultdict(CoinzoomActiveOrderTracker) + 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[CoinzoomOrderBookMessage] = deque() + self._past_diffs_windows[trading_pair] = past_diffs_window + + message_queue: asyncio.Queue = self._tracking_message_queues[trading_pair] + order_book: CoinzoomOrderBook = self._order_books[trading_pair] + active_order_tracker: CoinzoomActiveOrderTracker = self._active_order_trackers[trading_pair] + + last_message_timestamp: float = time.time() + diff_messages_accepted: int = 0 + + while True: + try: + message: CoinzoomOrderBookMessage = None + saved_messages: Deque[CoinzoomOrderBookMessage] = 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(f"Processed {diff_messages_accepted} order book diffs for {trading_pair}.") + diff_messages_accepted = 0 + last_message_timestamp = now + elif message.type is OrderBookMessageType.SNAPSHOT: + past_diffs: List[CoinzoomOrderBookMessage] = 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(f"Processed order book snapshot for {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/coinzoom/coinzoom_order_book_tracker_entry.py b/hummingbot/connector/exchange/coinzoom/coinzoom_order_book_tracker_entry.py new file mode 100644 index 0000000000..94feda5275 --- /dev/null +++ b/hummingbot/connector/exchange/coinzoom/coinzoom_order_book_tracker_entry.py @@ -0,0 +1,21 @@ +from hummingbot.core.data_type.order_book import OrderBook +from hummingbot.core.data_type.order_book_tracker_entry import OrderBookTrackerEntry +from hummingbot.connector.exchange.coinzoom.coinzoom_active_order_tracker import CoinzoomActiveOrderTracker + + +class CoinzoomOrderBookTrackerEntry(OrderBookTrackerEntry): + def __init__( + self, trading_pair: str, timestamp: float, order_book: OrderBook, active_order_tracker: CoinzoomActiveOrderTracker + ): + self._active_order_tracker = active_order_tracker + super(CoinzoomOrderBookTrackerEntry, self).__init__(trading_pair, timestamp, order_book) + + def __repr__(self) -> str: + return ( + f"CoinzoomOrderBookTrackerEntry(trading_pair='{self._trading_pair}', timestamp='{self._timestamp}', " + f"order_book='{self._order_book}')" + ) + + @property + def active_order_tracker(self) -> CoinzoomActiveOrderTracker: + return self._active_order_tracker diff --git a/hummingbot/connector/exchange/coinzoom/coinzoom_user_stream_tracker.py b/hummingbot/connector/exchange/coinzoom/coinzoom_user_stream_tracker.py new file mode 100644 index 0000000000..79c7584d70 --- /dev/null +++ b/hummingbot/connector/exchange/coinzoom/coinzoom_user_stream_tracker.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python + +import asyncio +import logging +from typing import ( + Optional, + List, +) +from hummingbot.core.data_type.user_stream_tracker_data_source import UserStreamTrackerDataSource +from hummingbot.logger import HummingbotLogger +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.coinzoom.coinzoom_api_user_stream_data_source import \ + CoinzoomAPIUserStreamDataSource +from hummingbot.connector.exchange.coinzoom.coinzoom_auth import CoinzoomAuth +from hummingbot.connector.exchange.coinzoom.coinzoom_constants import Constants + + +class CoinzoomUserStreamTracker(UserStreamTracker): + _cbpust_logger: Optional[HummingbotLogger] = None + + @classmethod + def logger(cls) -> HummingbotLogger: + if cls._bust_logger is None: + cls._bust_logger = logging.getLogger(__name__) + return cls._bust_logger + + def __init__(self, + coinzoom_auth: Optional[CoinzoomAuth] = None, + trading_pairs: Optional[List[str]] = []): + super().__init__() + self._coinzoom_auth: CoinzoomAuth = coinzoom_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 = CoinzoomAPIUserStreamDataSource( + coinzoom_auth=self._coinzoom_auth, + trading_pairs=self._trading_pairs + ) + return self._data_source + + @property + def exchange_name(self) -> str: + """ + *required + Name of the current exchange + """ + return Constants.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) diff --git a/hummingbot/connector/exchange/coinzoom/coinzoom_utils.py b/hummingbot/connector/exchange/coinzoom/coinzoom_utils.py new file mode 100644 index 0000000000..498ed56541 --- /dev/null +++ b/hummingbot/connector/exchange/coinzoom/coinzoom_utils.py @@ -0,0 +1,149 @@ +import aiohttp +import asyncio +import random +from dateutil.parser import parse as dateparse +from typing import ( + Any, + Dict, + Optional, +) + +from hummingbot.core.utils.tracking_nonce import get_tracking_nonce +from hummingbot.client.config.config_var import ConfigVar +from hummingbot.client.config.config_methods import using_exchange +from .coinzoom_constants import Constants + + +CENTRALIZED = True + +EXAMPLE_PAIR = "BTC-USD" + +DEFAULT_FEES = [0.2, 0.26] + + +class CoinzoomAPIError(IOError): + def __init__(self, error_payload: Dict[str, Any]): + super().__init__(str(error_payload)) + self.error_payload = error_payload + + +# convert date string to timestamp +def str_date_to_ts(date: str) -> int: + return int(dateparse(date).timestamp() * 1e3) + + +# Request ID class +class RequestId: + """ + Generate request ids + """ + _request_id: int = 0 + + @classmethod + def generate_request_id(cls) -> int: + return get_tracking_nonce() + + +def convert_from_exchange_trading_pair(ex_trading_pair: str) -> Optional[str]: + # CoinZoom uses uppercase (BTC/USDT) + return ex_trading_pair.replace("/", "-") + + +def convert_to_exchange_trading_pair(hb_trading_pair: str, alternative: bool = False) -> str: + # CoinZoom uses uppercase (BTCUSDT) + if alternative: + return hb_trading_pair.replace("-", "_").upper() + else: + return hb_trading_pair.replace("-", "/").upper() + + +def get_new_client_order_id(is_buy: bool, trading_pair: str) -> str: + side = "B" if is_buy else "S" + symbols = trading_pair.split("-") + base = symbols[0].upper() + quote = symbols[1].upper() + base_str = f"{base[0]}{base[-1]}" + quote_str = f"{quote[0]}{quote[-1]}" + return f"{Constants.HBOT_BROKER_ID}{side}{base_str}{quote_str}{get_tracking_nonce()}" + + +def retry_sleep_time(try_count: int) -> float: + random.seed() + randSleep = 1 + float(random.randint(1, 10) / 100) + return float(2 + float(randSleep * (1 + (try_count ** try_count)))) + + +async def aiohttp_response_with_errors(request_coroutine): + http_status, parsed_response, request_errors = None, None, False + try: + async with request_coroutine as response: + http_status = response.status + try: + parsed_response = await response.json() + except Exception: + if response.status not in [204]: + request_errors = True + try: + parsed_response = str(await response.read()) + if len(parsed_response) > 100: + parsed_response = f"{parsed_response[:100]} ... (truncated)" + except Exception: + pass + TempFailure = (parsed_response is None or + (response.status not in [200, 201, 204] and "error" not in parsed_response)) + if TempFailure: + parsed_response = response.reason if parsed_response is None else parsed_response + request_errors = True + except Exception: + request_errors = True + return http_status, parsed_response, request_errors + + +async def api_call_with_retries(method, + endpoint, + params: Optional[Dict[str, Any]] = None, + shared_client=None, + try_count: int = 0) -> Dict[str, Any]: + url = f"{Constants.REST_URL}/{endpoint}" + headers = {"Content-Type": "application/json", "User-Agent": "hummingbot"} + http_client = shared_client if shared_client is not None else aiohttp.ClientSession() + # Build request coro + response_coro = http_client.request(method=method.upper(), url=url, headers=headers, + params=params, timeout=Constants.API_CALL_TIMEOUT) + http_status, parsed_response, request_errors = await aiohttp_response_with_errors(response_coro) + if shared_client is None: + await http_client.close() + if request_errors or parsed_response is None: + if try_count < Constants.API_MAX_RETRIES: + try_count += 1 + time_sleep = retry_sleep_time(try_count) + print(f"Error fetching data from {url}. HTTP status is {http_status}. " + f"Retrying in {time_sleep:.0f}s.") + await asyncio.sleep(time_sleep) + return await api_call_with_retries(method=method, endpoint=endpoint, params=params, + shared_client=shared_client, try_count=try_count) + else: + raise CoinzoomAPIError({"error": parsed_response, "status": http_status}) + return parsed_response + + +KEYS = { + "coinzoom_api_key": + ConfigVar(key="coinzoom_api_key", + prompt=f"Enter your {Constants.EXCHANGE_NAME} API key >>> ", + required_if=using_exchange("coinzoom"), + is_secure=True, + is_connect_key=True), + "coinzoom_secret_key": + ConfigVar(key="coinzoom_secret_key", + prompt=f"Enter your {Constants.EXCHANGE_NAME} secret key >>> ", + required_if=using_exchange("coinzoom"), + is_secure=True, + is_connect_key=True), + "coinzoom_username": + ConfigVar(key="coinzoom_username", + prompt=f"Enter your {Constants.EXCHANGE_NAME} ZoomMe username >>> ", + required_if=using_exchange("coinzoom"), + is_secure=True, + is_connect_key=True), +} diff --git a/hummingbot/connector/exchange/coinzoom/coinzoom_websocket.py b/hummingbot/connector/exchange/coinzoom/coinzoom_websocket.py new file mode 100644 index 0000000000..7da31a20e4 --- /dev/null +++ b/hummingbot/connector/exchange/coinzoom/coinzoom_websocket.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python +import asyncio +import logging +import websockets +import json +from hummingbot.connector.exchange.coinzoom.coinzoom_constants import Constants + + +from typing import ( + Any, + AsyncIterable, + Dict, + List, + Optional, +) +from websockets.exceptions import ConnectionClosed +from hummingbot.logger import HummingbotLogger +from hummingbot.connector.exchange.coinzoom.coinzoom_auth import CoinzoomAuth + +# reusable websocket class +# ToDo: We should eventually remove this class, and instantiate web socket connection normally (see Binance for example) + + +class CoinzoomWebsocket(): + _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, + auth: Optional[CoinzoomAuth] = None): + self._auth: Optional[CoinzoomAuth] = auth + self._isPrivate = True if self._auth is not None else False + self._WS_URL = Constants.WS_PRIVATE_URL if self._isPrivate else Constants.WS_PUBLIC_URL + self._client: Optional[websockets.WebSocketClientProtocol] = None + self._is_subscribed = False + + @property + def is_subscribed(self): + return self._is_subscribed + + # connect to exchange + async def connect(self): + # if auth class was passed into websocket class + # we need to emit authenticated requests + extra_headers = self._auth.get_headers() if self._isPrivate else {"User-Agent": "hummingbot"} + self._client = await websockets.connect(self._WS_URL, extra_headers=extra_headers) + + return self._client + + # disconnect from exchange + async def disconnect(self): + if self._client is None: + return + + await self._client.close() + + # receive & parse messages + async def _messages(self) -> AsyncIterable[Any]: + try: + while True: + try: + raw_msg_str: str = await asyncio.wait_for(self._client.recv(), timeout=Constants.MESSAGE_TIMEOUT) + try: + msg = json.loads(raw_msg_str) + + # CoinZoom doesn't support ping or heartbeat messages. + # Can handle them here if that changes - use `safe_ensure_future`. + + # Check response for a subscribed/unsubscribed message; + result: List[str] = list([d['result'] + for k, d in msg.items() + if (isinstance(d, dict) and d.get('result') is not None)]) + + if len(result): + if result[0] == 'subscribed': + self._is_subscribed = True + elif result[0] == 'unsubscribed': + self._is_subscribed = False + yield None + else: + yield msg + except ValueError: + continue + except asyncio.TimeoutError: + await asyncio.wait_for(self._client.ping(), timeout=Constants.PING_TIMEOUT) + except asyncio.TimeoutError: + self.logger().warning("WebSocket ping timed out. Going to reconnect...") + return + except ConnectionClosed: + return + finally: + await self.disconnect() + + # emit messages + async def _emit(self, method: str, action: str, data: Optional[Dict[str, Any]] = {}) -> int: + payload = { + method: { + "action": action, + **data + } + } + return await self._client.send(json.dumps(payload)) + + # request via websocket + async def request(self, method: str, action: str, data: Optional[Dict[str, Any]] = {}) -> int: + return await self._emit(method, action, data) + + # subscribe to a method + async def subscribe(self, + streams: Optional[Dict[str, Any]] = {}) -> int: + for stream, stream_dict in streams.items(): + if self._isPrivate: + stream_dict = {**stream_dict, **self._auth.get_ws_params()} + await self.request(stream, "subscribe", stream_dict) + return True + + # unsubscribe to a method + async def unsubscribe(self, + streams: Optional[Dict[str, Any]] = {}) -> int: + for stream, stream_dict in streams.items(): + if self._isPrivate: + stream_dict = {**stream_dict, **self._auth.get_ws_params()} + await self.request(stream, "unsubscribe", stream_dict) + return True + + # listen to messages by method + async def on_message(self) -> AsyncIterable[Any]: + async for msg in self._messages(): + yield msg diff --git a/hummingbot/connector/exchange/crypto_com/crypto_com_order_book_tracker.py b/hummingbot/connector/exchange/crypto_com/crypto_com_order_book_tracker.py index 8cf9223b46..cc25e14fc7 100644 --- a/hummingbot/connector/exchange/crypto_com/crypto_com_order_book_tracker.py +++ b/hummingbot/connector/exchange/crypto_com/crypto_com_order_book_tracker.py @@ -83,8 +83,7 @@ async def _track_single_book(self, trading_pair: str): # 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) + self.logger().debug(f"Processed {diff_messages_accepted} order book diffs for {trading_pair}.") diff_messages_accepted = 0 last_message_timestamp = now elif message.type is OrderBookMessageType.SNAPSHOT: @@ -98,7 +97,7 @@ async def _track_single_book(self, trading_pair: str): 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) + self.logger().debug(f"Processed order book snapshot for {trading_pair}.") except asyncio.CancelledError: raise except Exception: diff --git a/hummingbot/connector/exchange/digifinex/__init__.py b/hummingbot/connector/exchange/digifinex/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/hummingbot/connector/exchange/digifinex/digifinex_active_order_tracker.pxd b/hummingbot/connector/exchange/digifinex/digifinex_active_order_tracker.pxd new file mode 100644 index 0000000000..f9fdfcd81e --- /dev/null +++ b/hummingbot/connector/exchange/digifinex/digifinex_active_order_tracker.pxd @@ -0,0 +1,10 @@ +# distutils: language=c++ +cimport numpy as np + +cdef class DigifinexActiveOrderTracker: + 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) + cdef np.ndarray[np.float64_t, ndim=1] c_convert_trade_message_to_np_array(self, object message) diff --git a/hummingbot/connector/exchange/digifinex/digifinex_active_order_tracker.pyx b/hummingbot/connector/exchange/digifinex/digifinex_active_order_tracker.pyx new file mode 100644 index 0000000000..19ff274da7 --- /dev/null +++ b/hummingbot/connector/exchange/digifinex/digifinex_active_order_tracker.pyx @@ -0,0 +1,169 @@ +# 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 + +_logger = None +s_empty_diff = np.ndarray(shape=(0, 4), dtype="float64") +DigifinexOrderBookTrackingDictionary = Dict[Decimal, Dict[str, Dict[str, any]]] + +cdef class DigifinexActiveOrderTracker: + def __init__(self, + active_asks: DigifinexOrderBookTrackingDictionary = None, + active_bids: DigifinexOrderBookTrackingDictionary = None): + super().__init__() + self._active_asks = active_asks or {} + self._active_bids = active_bids or {} + + @classmethod + def logger(cls) -> HummingbotLogger: + global _logger + if _logger is None: + _logger = logging.getLogger(__name__) + return _logger + + @property + def active_asks(self) -> DigifinexOrderBookTrackingDictionary: + return self._active_asks + + @property + def active_bids(self) -> DigifinexOrderBookTrackingDictionary: + return self._active_bids + + # TODO: research this more + def volume_for_ask_price(self, price) -> float: + return NotImplementedError + + # TODO: research this more + def volume_for_bid_price(self, price) -> float: + return NotImplementedError + + def get_rates_and_quantities(self, entry) -> tuple: + # price, quantity + return float(entry[0]), float(entry[1]) + + cdef tuple c_convert_diff_message_to_np_arrays(self, object message): + cdef: + dict content = message.content + list bid_entries = [] + list ask_entries = [] + str order_id + str order_side + str price_raw + object price + dict order_dict + double timestamp = message.timestamp + double amount = 0 + + bid_entries = content["bids"] + ask_entries = content["asks"] + + bids = s_empty_diff + asks = s_empty_diff + + if len(bid_entries) > 0: + bids = np.array( + [[timestamp, + float(price), + float(amount), + message.update_id] + for price, amount in [self.get_rates_and_quantities(entry) for entry in bid_entries]], + dtype="float64", + ndmin=2 + ) + + if len(ask_entries) > 0: + asks = np.array( + [[timestamp, + float(price), + float(amount), + message.update_id] + for price, amount in [self.get_rates_and_quantities(entry) for entry in ask_entries]], + dtype="float64", + ndmin=2 + ) + + return bids, asks + + cdef tuple c_convert_snapshot_message_to_np_arrays(self, object message): + cdef: + float price + float amount + str order_id + dict order_dict + + # Refresh all order tracking. + self._active_bids.clear() + self._active_asks.clear() + timestamp = message.timestamp + content = message.content + + for snapshot_orders, active_orders in [(content["bids"], self._active_bids), (content["asks"], self.active_asks)]: + for order in snapshot_orders: + price, amount = self.get_rates_and_quantities(order) + + order_dict = { + "order_id": timestamp, + "amount": amount + } + + if price in active_orders: + active_orders[price][timestamp] = order_dict + else: + active_orders[price] = { + timestamp: order_dict + } + + cdef: + np.ndarray[np.float64_t, ndim=2] bids = np.array( + [[message.timestamp, + price, + sum([order_dict["amount"] + for order_dict in self._active_bids[price].values()]), + 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, + price, + sum([order_dict["amount"] + for order_dict in self.active_asks[price].values()]), + message.update_id] + for price in sorted(self.active_asks.keys(), reverse=True)], dtype="float64", ndmin=2 + ) + + if bids.shape[1] != 4: + bids = bids.reshape((0, 4)) + if asks.shape[1] != 4: + asks = asks.reshape((0, 4)) + + return bids, asks + + cdef np.ndarray[np.float64_t, ndim=1] c_convert_trade_message_to_np_array(self, object message): + cdef: + double trade_type_value = 2.0 + + timestamp = message.timestamp + content = message.content + + return np.array( + [timestamp, trade_type_value, float(content["price"]), float(content["size"])], + dtype="float64" + ) + + def convert_diff_message_to_order_book_row(self, message): + 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): + 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/digifinex/digifinex_api_order_book_data_source.py b/hummingbot/connector/exchange/digifinex/digifinex_api_order_book_data_source.py new file mode 100644 index 0000000000..b4162b365b --- /dev/null +++ b/hummingbot/connector/exchange/digifinex/digifinex_api_order_book_data_source.py @@ -0,0 +1,226 @@ +#!/usr/bin/env python +import asyncio +import logging +import time +import aiohttp +import traceback +import pandas as pd +import hummingbot.connector.exchange.digifinex.digifinex_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.core.utils.async_utils import safe_gather +from hummingbot.logger import HummingbotLogger +from . import digifinex_utils +from .digifinex_active_order_tracker import DigifinexActiveOrderTracker +from .digifinex_order_book import DigifinexOrderBook +from .digifinex_websocket import DigifinexWebsocket +# from .digifinex_utils import ms_timestamp_to_s + + +class DigifinexAPIOrderBookDataSource(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: + resp = await client.get(f"{constants.REST_URL}/ticker") + resp_json = await resp.json() + for t_pair in trading_pairs: + last_trade = [o["last"] for o in resp_json["ticker"] if o["symbol"] == + digifinex_utils.convert_to_exchange_trading_pair(t_pair)] + if last_trade and last_trade[0] is not None: + result[t_pair] = last_trade[0] + return result + + @staticmethod + async def fetch_trading_pairs() -> List[str]: + async with aiohttp.ClientSession() as client: + async with client.get(f"{constants.REST_URL}/ticker", timeout=10) as response: + if response.status == 200: + from hummingbot.connector.exchange.digifinex.digifinex_utils import \ + convert_from_exchange_trading_pair + try: + data: Dict[str, Any] = await response.json() + return [convert_from_exchange_trading_pair(item["symbol"]) for item in data["ticker"]] + except Exception: + pass + # Do nothing if the request fails -- there will be no autocomplete for kucoin trading pairs + return [] + + @staticmethod + async def get_order_book_data(trading_pair: str) -> Dict[str, any]: + """ + Get whole orderbook + """ + async with aiohttp.ClientSession() as client: + orderbook_response = await client.get( + f"{constants.REST_URL}/order_book?limit=150&symbol=" + f"{digifinex_utils.convert_to_exchange_trading_pair(trading_pair)}" + ) + + if orderbook_response.status != 200: + raise IOError( + f"Error fetching OrderBook for {trading_pair} at {constants.EXCHANGE_NAME}. " + f"HTTP status is {orderbook_response.status}." + ) + + orderbook_data: List[Dict[str, Any]] = await safe_gather(orderbook_response.json()) + orderbook_data = orderbook_data[0] + return orderbook_data + + 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 = DigifinexOrderBook.snapshot_message_from_exchange( + snapshot, + snapshot_timestamp, + metadata={"trading_pair": trading_pair} + ) + order_book = self.order_book_create_function() + active_order_tracker: DigifinexActiveOrderTracker = DigifinexActiveOrderTracker() + 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 listen_for_trades(self, ev_loop: asyncio.BaseEventLoop, output: asyncio.Queue): + """ + Listen for trades using websocket trade channel + """ + while True: + try: + ws = DigifinexWebsocket() + await ws.connect() + + await ws.subscribe("trades", list(map( + lambda pair: f"{digifinex_utils.convert_to_ws_trading_pair(pair)}", + self._trading_pairs + ))) + + async for response in ws.on_message(): + params = response["params"] + symbol = params[2] + for trade in params[1]: + trade_timestamp: int = trade["time"] + trade_msg: OrderBookMessage = DigifinexOrderBook.trade_message_from_exchange( + trade, + trade_timestamp, + metadata={"trading_pair": digifinex_utils.convert_from_ws_trading_pair(symbol)} + ) + 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 = DigifinexWebsocket() + await ws.connect() + + await ws.subscribe("depth", list(map( + lambda pair: f"{digifinex_utils.convert_to_ws_trading_pair(pair)}", + self._trading_pairs + ))) + + async for response in ws.on_message(): + if response is None or 'params' not in response: + continue + + params = response["params"] + symbol = params[2] + order_book_data = params[1] + timestamp: int = int(time.time()) + + if params[0] is True: + orderbook_msg: OrderBookMessage = DigifinexOrderBook.snapshot_message_from_exchange( + order_book_data, + timestamp, + metadata={"trading_pair": digifinex_utils.convert_from_ws_trading_pair(symbol)} + ) + else: + orderbook_msg: OrderBookMessage = DigifinexOrderBook.diff_message_from_exchange( + order_book_data, + timestamp, + metadata={"trading_pair": digifinex_utils.convert_from_ws_trading_pair(symbol)} + ) + 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 = snapshot["date"] + snapshot_msg: OrderBookMessage = DigifinexOrderBook.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 as e: + self.logger().network( + f"Unexpected error with WebSocket connection: {e}", + exc_info=True, + app_warning_msg="Unexpected error with WebSocket connection. Retrying in 5 seconds. " + "Check network connection.\n" + + traceback.format_exc() + ) + 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/digifinex/digifinex_api_user_stream_data_source.py b/hummingbot/connector/exchange/digifinex/digifinex_api_user_stream_data_source.py new file mode 100644 index 0000000000..8059190bc5 --- /dev/null +++ b/hummingbot/connector/exchange/digifinex/digifinex_api_user_stream_data_source.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python + +import time +import asyncio +import logging +from typing import Optional, List, AsyncIterable, Any +from hummingbot.connector.exchange.digifinex.digifinex_global import DigifinexGlobal +from hummingbot.core.data_type.user_stream_tracker_data_source import UserStreamTrackerDataSource +from hummingbot.logger import HummingbotLogger +# from .digifinex_auth import DigifinexAuth +from hummingbot.connector.exchange.digifinex.digifinex_websocket import DigifinexWebsocket +from hummingbot.connector.exchange.digifinex import digifinex_utils + + +class DigifinexAPIUserStreamDataSource(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, _global: DigifinexGlobal, trading_pairs: Optional[List[str]] = []): + self._global: DigifinexGlobal = _global + 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_to_orders_trades_balances(self) -> AsyncIterable[Any]: + """ + Subscribe to active orders via web socket + """ + + try: + ws = DigifinexWebsocket(self._global.auth) + await ws.connect() + await ws.subscribe("order", list(map( + lambda pair: f"{digifinex_utils.convert_to_ws_trading_pair(pair)}", + self._trading_pairs + ))) + + currencies = set() + for trade_pair in self._trading_pairs: + trade_pair_currencies = trade_pair.split('-') + currencies.update(trade_pair_currencies) + await ws.subscribe("balance", currencies) + + balance_snapshot = await self._global.rest_api.get_balance() + # { + # "code": 0, + # "list": [ + # { + # "currency": "BTC", + # "free": 4723846.89208129, + # "total": 0 + # } + # ] + # } + yield {'method': 'balance.update', 'params': balance_snapshot['list']} + self._last_recv_time = time.time() + + # await ws.subscribe(["user.order", "user.trade", "user.balance"]) + async for msg in ws.on_message(): + # { + # "method": "balance.update", + # "params": [{ + # "currency": "USDT", + # "free": "99944652.8478545303601106", + # "total": "99944652.8478545303601106", + # "used": "0.0000000000" + # }], + # "id": null + # } + yield msg + self._last_recv_time = time.time() + if (msg.get("result") is None): + continue + except Exception as e: + self.logger().exception(e) + raise e + finally: + await ws.disconnect() + await asyncio.sleep(5) + + 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: + async for msg in self._listen_to_orders_trades_balances(): + output.put_nowait(msg) + except asyncio.CancelledError: + raise + except Exception: + self.logger().error( + "Unexpected error with Digifinex WebSocket connection. " "Retrying after 30 seconds...", exc_info=True + ) + await asyncio.sleep(30.0) diff --git a/hummingbot/connector/exchange/digifinex/digifinex_auth.py b/hummingbot/connector/exchange/digifinex/digifinex_auth.py new file mode 100644 index 0000000000..d90e2da161 --- /dev/null +++ b/hummingbot/connector/exchange/digifinex/digifinex_auth.py @@ -0,0 +1,77 @@ +import hmac +import hashlib +import base64 +import urllib +import aiohttp +from typing import List, Dict, Any +# from hummingbot.connector.exchange.digifinex.digifinex_utils import get_ms_timestamp +from hummingbot.connector.exchange.digifinex import digifinex_constants as Constants +from hummingbot.connector.exchange.digifinex.time_patcher import TimePatcher +# import time + +_time_patcher: TimePatcher = None + + +def time_patcher() -> TimePatcher: + global _time_patcher + if _time_patcher is None: + _time_patcher = TimePatcher('Digifinex', DigifinexAuth.query_time_func) + _time_patcher.start() + return _time_patcher + + +class DigifinexAuth(): + """ + Auth class required by digifinex API + Learn more at https://docs.digifinex.io/en-ww/v3/#signature-authentication-amp-verification + """ + def __init__(self, api_key: str, secret_key: str): + self.api_key = api_key + self.secret_key = secret_key + self.time_patcher = time_patcher() + # self.time_patcher = time + + @classmethod + async def query_time_func() -> float: + async with aiohttp.ClientSession() as session: + async with session.get(Constants.REST_URL + '/time') as resp: + resp_data: Dict[str, float] = await resp.json() + return float(resp_data["server_time"]) + + def get_private_headers( + self, + path_url: str, + request_id: int, + data: Dict[str, Any] = None + ): + + data = data or {} + payload = urllib.parse.urlencode(data) + sig = hmac.new( + self.secret_key.encode('utf-8'), + payload.encode('utf-8'), + hashlib.sha256 + ).hexdigest() + nonce = int(self.time_patcher.time()) + + header = { + 'ACCESS-KEY': self.api_key, + 'ACCESS-TIMESTAMP': str(nonce), + 'ACCESS-SIGN': sig, + } + + return header + + def generate_ws_signature(self) -> List[Any]: + data = [None] * 3 + data[0] = self.api_key + nonce = int(self.time_patcher.time() * 1000) + data[1] = str(nonce) + + data[2] = base64.b64encode(hmac.new( + self.secret_key.encode('latin-1'), + f"{nonce}".encode('latin-1'), + hashlib.sha256 + ).digest()) + + return data diff --git a/hummingbot/connector/exchange/digifinex/digifinex_constants.py b/hummingbot/connector/exchange/digifinex/digifinex_constants.py new file mode 100644 index 0000000000..0caa476ba7 --- /dev/null +++ b/hummingbot/connector/exchange/digifinex/digifinex_constants.py @@ -0,0 +1,39 @@ +# A single source of truth for constant variables related to the exchange +import os + +if os.environ.get('digifinex_test') == '1': + host = 'openapi.digifinex.vip' +else: + host = 'openapi.digifinex.com' + +EXCHANGE_NAME = "digifinex" +REST_URL = f"https://{host}/v3" +WSS_PRIVATE_URL = f"wss://{host}/ws/v1/" +WSS_PUBLIC_URL = f"wss://{host}/ws/v1/" + +API_REASONS = { + 0: "Success", + 10001: "Malformed request, (E.g. not using application/json for REST)", + 10002: "Not authenticated, or key/signature incorrect", + 10003: "IP address not whitelisted", + 10004: "Missing required fields", + 10005: "Disallowed based on user tier", + 10006: "Requests have exceeded rate limits", + 10007: "Nonce value differs by more than 30 seconds from server", + 10008: "Invalid method specified", + 10009: "Invalid date range", + 20001: "Duplicated record", + 20002: "Insufficient balance", + 30003: "Invalid instrument_name specified", + 30004: "Invalid side specified", + 30005: "Invalid type specified", + 30006: "Price is lower than the minimum", + 30007: "Price is higher than the maximum", + 30008: "Quantity is lower than the minimum", + 30009: "Quantity is higher than the maximum", + 30010: "Required argument is blank or missing", + 30013: "Too many decimal places for Price", + 30014: "Too many decimal places for Quantity", + 30016: "The notional amount is less than the minimum", + 30017: "The notional amount exceeds the maximum", +} diff --git a/hummingbot/connector/exchange/digifinex/digifinex_exchange.py b/hummingbot/connector/exchange/digifinex/digifinex_exchange.py new file mode 100644 index 0000000000..db01913616 --- /dev/null +++ b/hummingbot/connector/exchange/digifinex/digifinex_exchange.py @@ -0,0 +1,829 @@ +import logging +from typing import ( + Dict, + List, + Optional, + Any, + AsyncIterable, +) +from decimal import Decimal +import asyncio +# import json +# import aiohttp +import math +import time + +from hummingbot.core.network_iterator import NetworkStatus +from hummingbot.logger import HummingbotLogger +from hummingbot.core.clock import Clock +from hummingbot.core.utils import estimate_fee +from hummingbot.core.utils.async_utils import safe_ensure_future, safe_gather +from hummingbot.connector.trading_rule import TradingRule +from hummingbot.core.data_type.cancellation_result import CancellationResult +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.connector.exchange_base import ExchangeBase +from hummingbot.connector.exchange.digifinex.digifinex_global import DigifinexGlobal +from hummingbot.connector.exchange.digifinex.digifinex_order_book_tracker import DigifinexOrderBookTracker +from hummingbot.connector.exchange.digifinex.digifinex_user_stream_tracker import DigifinexUserStreamTracker +# from hummingbot.connector.exchange.digifinex.digifinex_auth import DigifinexAuth +from hummingbot.connector.exchange.digifinex.digifinex_in_flight_order import DigifinexInFlightOrder +from hummingbot.connector.exchange.digifinex import digifinex_utils +# from hummingbot.connector.exchange.digifinex import digifinex_constants as Constants +from hummingbot.core.data_type.common import OpenOrder +ctce_logger = None +s_decimal_NaN = Decimal("nan") + + +class DigifinexExchange(ExchangeBase): + """ + DigifinexExchange connects with digifinex.com 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 ctce_logger + if ctce_logger is None: + ctce_logger = logging.getLogger(__name__) + return ctce_logger + + def __init__(self, + digifinex_api_key: str, + digifinex_secret_key: str, + trading_pairs: Optional[List[str]] = None, + trading_required: bool = True + ): + """ + :param key: The API key to connect to private digifinex.com APIs. + :param secret: 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._global = DigifinexGlobal(digifinex_api_key, digifinex_secret_key) + # self._rest_api = DigifinexRestApi(self._digifinex_auth, self._http_client) + self._order_book_tracker = DigifinexOrderBookTracker(trading_pairs=trading_pairs) + self._user_stream_tracker = DigifinexUserStreamTracker(self._global, trading_pairs) + self._ev_loop = asyncio.get_event_loop() + self._poll_notifier = asyncio.Event() + self._last_timestamp = 0 + self._in_flight_orders: Dict[str, DigifinexInFlightOrder] = {} # Dict[client_order_id:str, DigifinexInFlightOrder] + 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 "digifinex" + + @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, DigifinexInFlightOrder]: + 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: DigifinexInFlightOrder.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: + await self._global.rest_api.request("get", "ping") + except asyncio.CancelledError: + raise + except Exception as e: + _ = e + self.logger().exception('check_network', stack_info=True) + return NetworkStatus.NOT_CONNECTED + return NetworkStatus.CONNECTED + + 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 digifinex.com. " + "Check network connection.") + await asyncio.sleep(0.5) + + async def _update_trading_rules(self): + instruments_info = await self._global.rest_api.request("get", path_url="markets") + self._trading_rules.clear() + self._trading_rules = self._format_trading_rules(instruments_info) + + def _format_trading_rules(self, instruments_info: Dict[str, Any]) -> Dict[str, TradingRule]: + """ + Converts json API response into a dictionary of trading rules. + :param instruments_info: The json API response + :return A dictionary of trading rules. + Response Example: + { + "data": [{ + "volume_precision": 4, + "price_precision": 2, + "market": "btc_usdt", + "min_amount": 2, + "min_volume": 0.0001 + }], + "date": 1589873858, + "code": 0 + } + """ + result = {} + for rule in instruments_info["data"]: + try: + trading_pair = digifinex_utils.convert_from_exchange_trading_pair(rule["market"]) + price_decimals = Decimal(str(rule["price_precision"])) + quantity_decimals = Decimal(str(rule["volume_precision"])) + # E.g. a price decimal of 2 means 0.01 incremental. + price_step = Decimal("1") / Decimal(str(math.pow(10, price_decimals))) + quantity_step = Decimal("1") / Decimal(str(math.pow(10, quantity_decimals))) + result[trading_pair] = TradingRule(trading_pair, + min_price_increment=price_step, + min_base_amount_increment=quantity_step) + except Exception: + self.logger().error(f"Error parsing the trading pair rule {rule}. Skipping.", exc_info=True) + return result + + 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 = digifinex_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 = digifinex_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) + """ + 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: + self.ev_loop.run_until_complete(tracked_order.get_exchange_order_id()) + safe_ensure_future(self._execute_cancel(tracked_order)) + return order_id + + async def _create_order(self, + trade_type: TradeType, + order_id: str, + trading_pair: str, + amount: Decimal, + order_type: OrderType, + price: Decimal): + """ + Calls create-order API end point to place an order, starts tracking the order and triggers order created event. + :param trade_type: BUY or SELL + :param order_id: Internal order id (also called client_order_id) + :param trading_pair: The market to place order + :param amount: The order amount (in base token value) + :param order_type: The order type + :param price: The order price + """ + if not order_type.is_limit_type(): + raise Exception(f"Unsupported order type: {order_type}") + trading_rule = self._trading_rules[trading_pair] + + amount = self.quantize_order_amount(trading_pair, amount) + price = self.quantize_order_price(trading_pair, price) + if amount < trading_rule.min_order_size: + raise ValueError(f"Buy order amount {amount} is lower than the minimum order size " + f"{trading_rule.min_order_size}.") + symbol = digifinex_utils.convert_to_exchange_trading_pair(trading_pair) + api_params = {"symbol": symbol, + "type": trade_type.name.lower(), + "price": f"{price:f}", + "amount": f"{amount:f}", + # "client_oid": order_id + } + if order_type is OrderType.LIMIT_MAKER: + api_params["post_only"] = 1 + self.start_tracking_order(order_id, + None, + trading_pair, + trade_type, + price, + amount, + order_type + ) + try: + order_result = await self._global.rest_api.request("post", "spot/order/new", api_params, True) + exchange_order_id = str(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.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 Digifinex 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] = DigifinexInFlightOrder( + 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, o: DigifinexInFlightOrder) -> 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: + await self._global.rest_api.request( + "post", + "spot/order/cancel", + {"order_id": o.exchange_order_id}, + True + ) + if o.client_order_id in self._in_flight_orders: + self.trigger_event(MarketEvent.OrderCancelled, + OrderCancelledEvent(self.current_timestamp, o.client_order_id)) + del self._in_flight_orders[o.client_order_id] + return o.exchange_order_id + except asyncio.CancelledError: + raise + except Exception as e: + self.logger().network( + f"Failed to cancel order {o.exchange_order_id}: {str(e)}", + exc_info=True, + app_warning_msg=f"Failed to cancel the order {o.exchange_order_id} on Digifinex. " + 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 Digifinex. " + "Check API key and network connection.") + await asyncio.sleep(0.5) + + async def _update_balances(self): + local_asset_names = set(self._account_balances.keys()) + remote_asset_names = set() + account_info = await self._global.rest_api.get_balance() + for account in account_info["list"]: + asset_name = account["currency"] + self._account_available_balances[asset_name] = Decimal(str(account["free"])) + self._account_balances[asset_name] = Decimal(str(account["total"])) + remote_asset_names.add(asset_name) + + try: + 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] + except Exception as e: + self.logger().error(e) + + 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: + 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._global.rest_api.request("get", + "spot/order/detail", + {"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 "data" not in update_result: + self.logger().info(f"_update_order_status result not in resp: {update_result}") + continue + order_data = update_result["data"] + self._process_rest_trade_details(order_data) + self._process_order_status(order_data.get('order_id'), order_data.get('status')) + + def _process_order_status(self, exchange_order_id: str, status: int): + """ + Updates in-flight order and triggers cancellation or failure event if needed. + """ + tracked_order = self.find_exchange_order(exchange_order_id) + if tracked_order is None: + return + client_order_id = tracked_order.client_order_id + # Update order execution status + tracked_order.last_state = str(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: {digifinex_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) + + def _process_rest_trade_details(self, order_detail_msg: Any): + for trade_msg in order_detail_msg['detail']: + """ + 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() + tracked_order = self.find_exchange_order(trade_msg['order_id']) + if tracked_order is None: + return + + updated = tracked_order.update_with_rest_order_detail(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["executed_price"])), + Decimal(str(trade_msg["executed_amount"])), + estimate_fee.estimate_fee(self.name, tracked_order.order_type in [OrderType.LIMIT, OrderType.LIMIT_MAKER]), + # TradeFee(0.0, [(trade_msg["fee_currency"], Decimal(str(trade_msg["fee"])))]), + exchange_trade_id=trade_msg["tid"] + ) + ) + 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) + + def find_exchange_order(self, exchange_order_id: str): + for o in self._in_flight_orders.values(): + if o.exchange_order_id == exchange_order_id: + return o + + def _process_order_message_traded(self, order_msg): + tracked_order: DigifinexInFlightOrder = self.find_exchange_order(order_msg['id']) + if tracked_order is None: + return + + (delta_trade_amount, delta_trade_price) = tracked_order.update_with_order_update(order_msg) + if not delta_trade_amount: + 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, + delta_trade_price, + delta_trade_amount, + estimate_fee.estimate_fee(self.name, tracked_order.order_type in [OrderType.LIMIT, OrderType.LIMIT_MAKER]), + # TradeFee(0.0, [(trade_msg["fee_currency"], Decimal(str(trade_msg["fee"])))]), + exchange_trade_id='N/A' + ) + ) + if math.isclose(tracked_order.executed_amount_base, tracked_order.amount) or \ + tracked_order.executed_amount_base >= tracked_order.amount: + tracked_order.last_state = "2" + 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._global.rest_api.request( + # "post", + # "private/cancel-all-orders", + # {"instrument_name": digifinex_utils.convert_to_exchange_trading_pair(trading_pair)}, + # True + # ) + + open_orders = list(self._in_flight_orders.values()) + for o in open_orders: + await self._execute_cancel(o) + + for cl_order_id, tracked_order in self._in_flight_orders.items(): + open_order = [o for o in open_orders if o.exchange_order_id == tracked_order.exchange_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 Digifinex. 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 Digifinex. 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 + DigifinexAPIUserStreamDataSource. + """ + async for event_message in self._iter_user_event_queue(): + try: + if "method" not in event_message: + continue + channel = event_message["method"] + # if "user.trade" in channel: + # for trade_msg in event_message["result"]["data"]: + # await self._process_trade_message(trade_msg) + if "order.update" in channel: + for order_msg in event_message["params"]: + self._process_order_status(order_msg['id'], order_msg['status']) + self._process_order_message_traded(order_msg) + elif channel == "balance.update": + balances = event_message["params"] + for balance_entry in balances: + asset_name = balance_entry["currency"] + self._account_balances[asset_name] = Decimal(str(balance_entry["total"])) + self._account_available_balances[asset_name] = Decimal(str(balance_entry["free"])) + 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._global.rest_api.request( + "get", + "spot/order/current", + {}, + True + ) + ret_val = [] + for order in result["data"]: + # if digifinex_utils.HBOT_BROKER_ID not in order["client_oid"]: + # continue + if order["type"] not in ["buy", "sell"]: + raise Exception(f"Unsupported order type {order['type']}") + ret_val.append( + OpenOrder( + client_order_id=None, + trading_pair=digifinex_utils.convert_from_exchange_trading_pair(order["symbol"]), + price=Decimal(str(order["price"])), + amount=Decimal(str(order["amount"])), + executed_amount=Decimal(str(order["executed_amount"])), + status=order["status"], + order_type=OrderType.LIMIT, + is_buy=True if order["type"] == "buy" else False, + time=int(order["created_date"]), + exchange_order_id=order["order_id"] + ) + ) + return ret_val diff --git a/hummingbot/connector/exchange/digifinex/digifinex_global.py b/hummingbot/connector/exchange/digifinex/digifinex_global.py new file mode 100644 index 0000000000..13b8350d96 --- /dev/null +++ b/hummingbot/connector/exchange/digifinex/digifinex_global.py @@ -0,0 +1,19 @@ +import aiohttp +from hummingbot.connector.exchange.digifinex.digifinex_auth import DigifinexAuth +from hummingbot.connector.exchange.digifinex.digifinex_rest_api import DigifinexRestApi + + +class DigifinexGlobal: + + def __init__(self, key: str, secret: str): + self.auth = DigifinexAuth(key, secret) + self.rest_api = DigifinexRestApi(self.auth, self.http_client) + self._shared_client: aiohttp.ClientSession = None + + 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 diff --git a/hummingbot/connector/exchange/digifinex/digifinex_in_flight_order.py b/hummingbot/connector/exchange/digifinex/digifinex_in_flight_order.py new file mode 100644 index 0000000000..4c7382acee --- /dev/null +++ b/hummingbot/connector/exchange/digifinex/digifinex_in_flight_order.py @@ -0,0 +1,123 @@ +from decimal import Decimal +from typing import ( + Any, + Dict, + Optional, + Tuple, +) +import asyncio +from hummingbot.core.event.events import ( + OrderType, + TradeType +) +from hummingbot.connector.in_flight_order_base import InFlightOrderBase + + +class DigifinexInFlightOrder(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 {"2", "3", "4"} + + @property + def is_failure(self) -> bool: + return False + # return self.last_state in {"REJECTED"} + + @property + def is_cancelled(self) -> bool: + return self.last_state in {"3", "4"} + + # @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 + """ + retval = DigifinexInFlightOrder( + 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_rest_order_detail(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["tid"] + # trade_update["orderId"] is type int + if 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["executed_amount"])) + # self.fee_paid += Decimal(str(trade_update["fee"])) + self.executed_amount_quote += (Decimal(str(trade_update["executed_price"])) * + Decimal(str(trade_update["executed_amount"]))) + # if not self.fee_asset: + # self.fee_asset = trade_update["fee_currency"] + return True + + def update_with_order_update(self, order_update) -> Tuple[Decimal, Decimal]: + """ + Updates the in flight order with trade update (from order message) + return: (delta_trade_amount, delta_trade_price) + """ + + # todo: order_msg contains no trade_id. may be re-processed + if order_update['filled'] == '0': + return (0, 0) + + self.trade_id_set.add("N/A") + executed_amount_base = Decimal(order_update['filled']) + if executed_amount_base == self.executed_amount_base: + return (0, 0) + delta_trade_amount = executed_amount_base - self.executed_amount_base + self.executed_amount_base = executed_amount_base + + executed_amount_quote = executed_amount_base * Decimal(order_update['price_avg'] or order_update['price']) + delta_trade_price = (executed_amount_quote - self.executed_amount_quote) / delta_trade_amount + self.executed_amount_quote = executed_amount_base * Decimal(order_update['price_avg'] or order_update['price']) + return (delta_trade_amount, delta_trade_price) diff --git a/hummingbot/connector/exchange/digifinex/digifinex_order_book.py b/hummingbot/connector/exchange/digifinex/digifinex_order_book.py new file mode 100644 index 0000000000..29ecae9727 --- /dev/null +++ b/hummingbot/connector/exchange/digifinex/digifinex_order_book.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python + +import logging +import hummingbot.connector.exchange.digifinex.digifinex_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.digifinex.digifinex_order_book_message import DigifinexOrderBookMessage + +_logger = None + + +class DigifinexOrderBook(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: DigifinexOrderBookMessage + """ + + if metadata: + msg.update(metadata) + + return DigifinexOrderBookMessage( + 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: DigifinexOrderBookMessage + """ + return DigifinexOrderBookMessage( + 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: DigifinexOrderBookMessage + """ + + if metadata: + msg.update(metadata) + + return DigifinexOrderBookMessage( + 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: DigifinexOrderBookMessage + """ + return DigifinexOrderBookMessage( + 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: DigifinexOrderBookMessage + """ + + if metadata: + msg.update(metadata) + + msg.update({ + "exchange_order_id": msg.get("id"), + "trade_type": msg.get("type"), + "price": msg.get("price"), + "amount": msg.get("amount"), + }) + + return DigifinexOrderBookMessage( + 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: DigifinexOrderBookMessage + """ + return DigifinexOrderBookMessage( + 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(self, snapshot: OrderBookMessage, diffs: List[OrderBookMessage]): + raise NotImplementedError(constants.EXCHANGE_NAME + " order book needs to retain individual order data.") diff --git a/hummingbot/connector/exchange/digifinex/digifinex_order_book_message.py b/hummingbot/connector/exchange/digifinex/digifinex_order_book_message.py new file mode 100644 index 0000000000..883ea99da7 --- /dev/null +++ b/hummingbot/connector/exchange/digifinex/digifinex_order_book_message.py @@ -0,0 +1,80 @@ +#!/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 DigifinexOrderBookMessage(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(DigifinexOrderBookMessage, 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 * 1e3) + else: + return -1 + + @property + def trade_id(self) -> int: + if self.type is OrderBookMessageType.TRADE: + return int(self.timestamp * 1e3) + return -1 + + @property + def trading_pair(self) -> str: + if "trading_pair" in self.content: + return self.content["trading_pair"] + elif "instrument_name" in self.content: + return self.content["instrument_name"] + + @property + def asks(self) -> List[OrderBookRow]: + asks = map(self.content["asks"], lambda ask: {"price": ask[0], "amount": ask[1]}) + + return [ + OrderBookRow(float(price), float(amount), self.update_id) for price, amount in asks + ] + + @property + def bids(self) -> List[OrderBookRow]: + bids = map(self.content["bids"], lambda bid: {"price": bid[0], "amount": bid[1]}) + + return [ + OrderBookRow(float(price), float(amount), self.update_id) for price, amount in bids + ] + + 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/bitmax/bitmax_order_book_tracker.py b/hummingbot/connector/exchange/digifinex/digifinex_order_book_tracker.py similarity index 75% rename from hummingbot/connector/exchange/bitmax/bitmax_order_book_tracker.py rename to hummingbot/connector/exchange/digifinex/digifinex_order_book_tracker.py index 9fa26cc508..a90d5fb035 100644 --- a/hummingbot/connector/exchange/bitmax/bitmax_order_book_tracker.py +++ b/hummingbot/connector/exchange/digifinex/digifinex_order_book_tracker.py @@ -2,20 +2,21 @@ import asyncio import bisect import logging -import hummingbot.connector.exchange.bitmax.bitmax_constants as constants +import hummingbot.connector.exchange.digifinex.digifinex_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.logger import HummingbotLogger from hummingbot.core.data_type.order_book_tracker import OrderBookTracker -from hummingbot.connector.exchange.bitmax.bitmax_order_book_message import BitmaxOrderBookMessage -from hummingbot.connector.exchange.bitmax.bitmax_active_order_tracker import BitmaxActiveOrderTracker -from hummingbot.connector.exchange.bitmax.bitmax_api_order_book_data_source import BitmaxAPIOrderBookDataSource -from hummingbot.connector.exchange.bitmax.bitmax_order_book import BitmaxOrderBook +from hummingbot.connector.exchange.digifinex.digifinex_order_book_message import DigifinexOrderBookMessage +from hummingbot.connector.exchange.digifinex.digifinex_active_order_tracker import DigifinexActiveOrderTracker +from hummingbot.connector.exchange.digifinex.digifinex_api_order_book_data_source import DigifinexAPIOrderBookDataSource +from hummingbot.connector.exchange.digifinex.digifinex_order_book import DigifinexOrderBook -class BitmaxOrderBookTracker(OrderBookTracker): +class DigifinexOrderBookTracker(OrderBookTracker): _logger: Optional[HummingbotLogger] = None @classmethod @@ -25,7 +26,7 @@ def logger(cls) -> HummingbotLogger: return cls._logger def __init__(self, trading_pairs: Optional[List[str]] = None,): - super().__init__(BitmaxAPIOrderBookDataSource(trading_pairs), trading_pairs) + super().__init__(DigifinexAPIOrderBookDataSource(trading_pairs), trading_pairs) self._ev_loop: asyncio.BaseEventLoop = asyncio.get_event_loop() self._order_book_snapshot_stream: asyncio.Queue = asyncio.Queue() @@ -33,10 +34,10 @@ def __init__(self, trading_pairs: Optional[List[str]] = None,): 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, BitmaxOrderBook] = {} - self._saved_message_queues: Dict[str, Deque[BitmaxOrderBookMessage]] = \ + self._order_books: Dict[str, DigifinexOrderBook] = {} + self._saved_message_queues: Dict[str, Deque[DigifinexOrderBookMessage]] = \ defaultdict(lambda: deque(maxlen=1000)) - self._active_order_trackers: Dict[str, BitmaxActiveOrderTracker] = defaultdict(BitmaxActiveOrderTracker) + self._active_order_trackers: Dict[str, DigifinexActiveOrderTracker] = defaultdict(DigifinexActiveOrderTracker) self._order_book_stream_listener_task: Optional[asyncio.Task] = None self._order_book_trade_listener_task: Optional[asyncio.Task] = None @@ -51,20 +52,20 @@ 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[BitmaxOrderBookMessage] = deque() + past_diffs_window: Deque[DigifinexOrderBookMessage] = deque() self._past_diffs_windows[trading_pair] = past_diffs_window message_queue: asyncio.Queue = self._tracking_message_queues[trading_pair] - order_book: BitmaxOrderBook = self._order_books[trading_pair] - active_order_tracker: BitmaxActiveOrderTracker = self._active_order_trackers[trading_pair] + order_book: DigifinexOrderBook = self._order_books[trading_pair] + active_order_tracker: DigifinexActiveOrderTracker = self._active_order_trackers[trading_pair] last_message_timestamp: float = time.time() diff_messages_accepted: int = 0 while True: try: - message: BitmaxOrderBookMessage = None - saved_messages: Deque[BitmaxOrderBookMessage] = self._saved_message_queues[trading_pair] + message: DigifinexOrderBookMessage = None + saved_messages: Deque[DigifinexOrderBookMessage] = self._saved_message_queues[trading_pair] # Process saved messages first if there are any if len(saved_messages) > 0: message = saved_messages.popleft() @@ -87,7 +88,7 @@ async def _track_single_book(self, trading_pair: str): diff_messages_accepted = 0 last_message_timestamp = now elif message.type is OrderBookMessageType.SNAPSHOT: - past_diffs: List[BitmaxOrderBookMessage] = list(past_diffs_window) + past_diffs: List[DigifinexOrderBookMessage] = 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:] diff --git a/hummingbot/connector/exchange/digifinex/digifinex_order_book_tracker_entry.py b/hummingbot/connector/exchange/digifinex/digifinex_order_book_tracker_entry.py new file mode 100644 index 0000000000..afc487dc70 --- /dev/null +++ b/hummingbot/connector/exchange/digifinex/digifinex_order_book_tracker_entry.py @@ -0,0 +1,21 @@ +from hummingbot.core.data_type.order_book import OrderBook +from hummingbot.core.data_type.order_book_tracker_entry import OrderBookTrackerEntry +from hummingbot.connector.exchange.digifinex.digifinex_active_order_tracker import DigifinexActiveOrderTracker + + +class DigifinexOrderBookTrackerEntry(OrderBookTrackerEntry): + def __init__( + self, trading_pair: str, timestamp: float, order_book: OrderBook, active_order_tracker: DigifinexActiveOrderTracker + ): + self._active_order_tracker = active_order_tracker + super(DigifinexOrderBookTrackerEntry, self).__init__(trading_pair, timestamp, order_book) + + def __repr__(self) -> str: + return ( + f"DigifinexOrderBookTrackerEntry(trading_pair='{self._trading_pair}', timestamp='{self._timestamp}', " + f"order_book='{self._order_book}')" + ) + + @property + def active_order_tracker(self) -> DigifinexActiveOrderTracker: + return self._active_order_tracker diff --git a/hummingbot/connector/exchange/digifinex/digifinex_rest_api.py b/hummingbot/connector/exchange/digifinex/digifinex_rest_api.py new file mode 100644 index 0000000000..34ca77afb5 --- /dev/null +++ b/hummingbot/connector/exchange/digifinex/digifinex_rest_api.py @@ -0,0 +1,112 @@ +from typing import Callable, Dict, Any +import aiohttp +import json +import urllib +from hummingbot.connector.exchange.digifinex.digifinex_auth import DigifinexAuth +from hummingbot.connector.exchange.digifinex import digifinex_constants as Constants +from hummingbot.connector.exchange.digifinex import digifinex_utils + + +class DigifinexRestApi: + + def __init__(self, auth: DigifinexAuth, http_client_getter: Callable[[], aiohttp.ClientSession]): + self._auth = auth + self._http_client = http_client_getter + + async def request(self, + method: str, + path_url: str, + params: Dict[str, Any] = {}, + 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. + """ + url = f"{Constants.REST_URL}/{path_url}" + client = await self._http_client() + if is_auth_required: + request_id = digifinex_utils.RequestId.generate_request_id() + headers = self._auth.get_private_headers(path_url, request_id, params) + else: + headers = {} + headers['User-Agent'] = 'hummingbot' + + if method == "get": + url = f'{url}?{urllib.parse.urlencode(params)}' + response = await client.get(url, headers=headers) + elif method == "post": + response = await client.post(url, data=params, headers=headers) + else: + raise NotImplementedError + + try: + parsed_response = json.loads(await response.text()) + except Exception as e: + raise IOError(f"Error parsing data from {url}. Error: {str(e)}") + if response.status != 200: + raise IOError(f"Error fetching data from {url}. HTTP status is {response.status}. " + f"Message: {parsed_response}") + code = parsed_response["code"] + if code != 0: + msgs = { + 10001: "Wrong request method, please check it's a GET or POST request", + 10002: "Invalid ApiKey", + 10003: "Sign doesn't match", + 10004: "Illegal request parameters", + 10005: "Request frequency exceeds the limit", + 10006: "Unauthorized to execute this request", + 10007: "IP address Unauthorized", + 10008: "Timestamp for this request is invalid", + 10009: "Unexist endpoint or misses ACCESS-KEY, please check endpoint URL", + 10011: "ApiKey expired. Please go to client side to re-create an ApiKey.", + 20002: "Trade of this trading pair is suspended", + 20007: "Price precision error", + 20008: "Amount precision error", + 20009: "Amount is less than the minimum requirement", + 20010: "Cash Amount is less than the minimum requirement", + 20011: "Insufficient balance", + 20012: "Invalid trade type (valid value: buy/sell)", + 20013: "No order info found", + 20014: "Invalid date (Valid format: 2018-07-25)", + 20015: "Date exceeds the limit", + 20018: "Your have been banned for API trading by the system", + 20019: 'Wrong trading pair symbol, correct format:"base_quote", e.g. "btc_usdt"', + 20020: "You have violated the API trading rules and temporarily banned for trading. At present, we have certain restrictions on the user's transaction rate and withdrawal rate.", + 20021: "Invalid currency", + 20022: "The ending timestamp must be larger than the starting timestamp", + 20023: "Invalid transfer type", + 20024: "Invalid amount", + 20025: "This currency is not transferable at the moment", + 20026: "Transfer amount exceed your balance", + 20027: "Abnormal account status", + 20028: "Blacklist for transfer", + 20029: "Transfer amount exceed your daily limit", + 20030: "You have no position on this trading pair", + 20032: "Withdrawal limited", + 20033: "Wrong Withdrawal ID", + 20034: "Withdrawal service of this crypto has been closed", + 20035: "Withdrawal limit", + 20036: "Withdrawal cancellation failed", + 20037: "The withdrawal address, Tag or chain type is not included in the withdrawal management list", + 20038: "The withdrawal address is not on the white list", + 20039: "Can't be canceled in current status", + 20040: "Withdraw too frequently; limitation: 3 times a minute, 100 times a day", + 20041: "Beyond the daily withdrawal limit", + 20042: "Current trading pair does not support API trading", + 50000: "Exception error", + } + raise IOError(f"{url} API call failed, response: {parsed_response} ({msgs[code]})") + # print(f"REQUEST: {method} {path_url} {params}") + # print(f"RESPONSE: {parsed_response}") + return parsed_response + + async def get_balance(self) -> Dict[str, Any]: + """ + Calls REST API to update total and available balances. + """ + account_info = await self.request("get", "spot/assets", {}, True) + return account_info diff --git a/hummingbot/connector/exchange/bitmax/bitmax_user_stream_tracker.py b/hummingbot/connector/exchange/digifinex/digifinex_user_stream_tracker.py similarity index 78% rename from hummingbot/connector/exchange/bitmax/bitmax_user_stream_tracker.py rename to hummingbot/connector/exchange/digifinex/digifinex_user_stream_tracker.py index 8255abdb94..fe0125a6a3 100644 --- a/hummingbot/connector/exchange/bitmax/bitmax_user_stream_tracker.py +++ b/hummingbot/connector/exchange/digifinex/digifinex_user_stream_tracker.py @@ -15,13 +15,13 @@ safe_ensure_future, safe_gather, ) -from hummingbot.connector.exchange.bitmax.bitmax_api_user_stream_data_source import \ - BitmaxAPIUserStreamDataSource -from hummingbot.connector.exchange.bitmax.bitmax_auth import BitmaxAuth -from hummingbot.connector.exchange.bitmax.bitmax_constants import EXCHANGE_NAME +from hummingbot.connector.exchange.digifinex.digifinex_api_user_stream_data_source import \ + DigifinexAPIUserStreamDataSource +from hummingbot.connector.exchange.digifinex.digifinex_global import DigifinexGlobal +from hummingbot.connector.exchange.digifinex.digifinex_constants import EXCHANGE_NAME -class BitmaxUserStreamTracker(UserStreamTracker): +class DigifinexUserStreamTracker(UserStreamTracker): _cbpust_logger: Optional[HummingbotLogger] = None @classmethod @@ -31,10 +31,10 @@ def logger(cls) -> HummingbotLogger: return cls._bust_logger def __init__(self, - bitmax_auth: Optional[BitmaxAuth] = None, + _global: DigifinexGlobal, trading_pairs: Optional[List[str]] = []): super().__init__() - self._bitmax_auth: BitmaxAuth = bitmax_auth + self._global: DigifinexGlobal = _global self._trading_pairs: List[str] = trading_pairs self._ev_loop: asyncio.events.AbstractEventLoop = asyncio.get_event_loop() self._data_source: Optional[UserStreamTrackerDataSource] = None @@ -48,8 +48,8 @@ def data_source(self) -> UserStreamTrackerDataSource: :return: OrderBookTrackerDataSource """ if not self._data_source: - self._data_source = BitmaxAPIUserStreamDataSource( - bitmax_auth=self._bitmax_auth, + self._data_source = DigifinexAPIUserStreamDataSource( + self._global, trading_pairs=self._trading_pairs ) return self._data_source diff --git a/hummingbot/connector/exchange/digifinex/digifinex_utils.py b/hummingbot/connector/exchange/digifinex/digifinex_utils.py new file mode 100644 index 0000000000..a48ac5d3b1 --- /dev/null +++ b/hummingbot/connector/exchange/digifinex/digifinex_utils.py @@ -0,0 +1,98 @@ +import math +from typing import Dict, List + +from hummingbot.core.utils.tracking_nonce import get_tracking_nonce, get_tracking_nonce_low_res +from . import digifinex_constants as Constants + +from hummingbot.client.config.config_var import ConfigVar +from hummingbot.client.config.config_methods import using_exchange + + +CENTRALIZED = True + +EXAMPLE_PAIR = "ETH-USDT" + +DEFAULT_FEES = [0.1, 0.1] + +HBOT_BROKER_ID = "HBOT-" + + +# deeply merge two dictionaries +def merge_dicts(source: Dict, destination: Dict) -> Dict: + for key, value in source.items(): + if isinstance(value, dict): + # get node or create one + node = destination.setdefault(key, {}) + merge_dicts(value, node) + else: + destination[key] = value + + return destination + + +# join paths +def join_paths(*paths: List[str]) -> str: + return "/".join(paths) + + +# get timestamp in milliseconds +def get_ms_timestamp() -> int: + return get_tracking_nonce_low_res() + + +# convert milliseconds timestamp to seconds +def ms_timestamp_to_s(ms: int) -> int: + return math.floor(ms / 1e3) + + +# Request ID class +class RequestId: + """ + Generate request ids + """ + _request_id: int = 0 + + @classmethod + def generate_request_id(cls) -> int: + return get_tracking_nonce() + + +def convert_from_exchange_trading_pair(exchange_trading_pair: str) -> str: + return exchange_trading_pair.replace("_", "-").upper() + + +def convert_from_ws_trading_pair(exchange_trading_pair: str) -> str: + return exchange_trading_pair.replace("_", "-") + + +def convert_to_exchange_trading_pair(hb_trading_pair: str) -> str: + return hb_trading_pair.replace("-", "_").lower() + + +def convert_to_ws_trading_pair(hb_trading_pair: str) -> str: + return hb_trading_pair.replace("-", "_") + + +def get_new_client_order_id(is_buy: bool, trading_pair: str) -> str: + side = "B" if is_buy else "S" + return f"{HBOT_BROKER_ID}{side}-{trading_pair}-{get_tracking_nonce()}" + + +def get_api_reason(code: str) -> str: + return Constants.API_REASONS.get(int(code), code) + + +KEYS = { + "digifinex_api_key": + ConfigVar(key="digifinex_api_key", + prompt="Enter your Digifinex API key >>> ", + required_if=using_exchange("digifinex"), + is_secure=True, + is_connect_key=True), + "digifinex_secret_key": + ConfigVar(key="digifinex_secret_key", + prompt="Enter your Digifinex secret key >>> ", + required_if=using_exchange("digifinex"), + is_secure=True, + is_connect_key=True), +} diff --git a/hummingbot/connector/exchange/digifinex/digifinex_websocket.py b/hummingbot/connector/exchange/digifinex/digifinex_websocket.py new file mode 100644 index 0000000000..c2ca969778 --- /dev/null +++ b/hummingbot/connector/exchange/digifinex/digifinex_websocket.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python +import asyncio +import copy +import logging +import websockets +import zlib +import ujson +from asyncio import InvalidStateError +import hummingbot.connector.exchange.digifinex.digifinex_constants as constants +# from hummingbot.core.utils.async_utils import safe_ensure_future + + +from typing import Optional, AsyncIterable, Any, List +from websockets.exceptions import ConnectionClosed +from hummingbot.logger import HummingbotLogger +from hummingbot.connector.exchange.digifinex.digifinex_auth import DigifinexAuth +from hummingbot.connector.exchange.digifinex.digifinex_utils import RequestId + +# reusable websocket class +# ToDo: We should eventually remove this class, and instantiate web socket connection normally (see Binance for example) + + +class DigifinexWebsocket(RequestId): + MESSAGE_TIMEOUT = 30.0 + PING_TIMEOUT = 10.0 + _logger: Optional[HummingbotLogger] = None + disconnect_future: asyncio.Future = None + tasks: [asyncio.Task] = [] + login_msg_id: int = 0 + + @classmethod + def logger(cls) -> HummingbotLogger: + if cls._logger is None: + cls._logger = logging.getLogger(__name__) + return cls._logger + + def __init__(self, auth: Optional[DigifinexAuth] = None): + self._auth: Optional[DigifinexAuth] = auth + self._isPrivate = True if self._auth is not None else False + self._WS_URL = constants.WSS_PRIVATE_URL if self._isPrivate else constants.WSS_PUBLIC_URL + self._client: Optional[websockets.WebSocketClientProtocol] = None + + # connect to exchange + async def connect(self): + if self.disconnect_future is not None: + raise InvalidStateError('already connected') + self.disconnect_future = asyncio.Future() + + try: + self._client = await websockets.connect(self._WS_URL) + + # if auth class was passed into websocket class + # we need to emit authenticated requests + if self._isPrivate: + await self.login() + self.tasks.append(asyncio.create_task(self._ping_loop())) + + return self._client + except Exception as e: + self.logger().error(f"Websocket error: '{str(e)}'", exc_info=True) + + async def login(self): + self.login_msg_id = await self._emit("server.auth", self._auth.generate_ws_signature()) + msg = await self._messages() + if msg is None: + raise ConnectionError('websocket auth failed: connection closed unexpectedly') + if msg.get('error') is not None: + raise ConnectionError(f'websocket auth failed: {msg}') + + # disconnect from exchange + async def disconnect(self): + if self._client is None: + return + + await self._client.close() + if not self.disconnect_future.done: + self.disconnect_future.result(True) + if len(self.tasks) > 0: + await asyncio.wait(self.tasks) + + async def _ping_loop(self): + while True: + try: + disconnected = await asyncio.wait_for(self.disconnect_future, 30) + _ = disconnected + break + except asyncio.TimeoutError: + await self._emit('server.ping', []) + # msg = await self._messages() # concurrent read not allowed + + # receive & parse messages + async def _messages(self) -> Any: + try: + success = False + while True: + try: + raw_msg_bytes: bytes = await asyncio.wait_for(self._client.recv(), timeout=self.MESSAGE_TIMEOUT) + inflated_msg: bytes = zlib.decompress(raw_msg_bytes) + raw_msg = ujson.loads(inflated_msg) + # if "method" in raw_msg and raw_msg["method"] == "server.ping": + # payload = {"id": raw_msg["id"], "method": "public/respond-heartbeat"} + # safe_ensure_future(self._client.send(ujson.dumps(payload))) + # self.logger().debug(inflated_msg) + # method = raw_msg.get('method') + # if method not in ['depth.update', 'trades.update']: + # self.logger().network(inflated_msg) + + err = raw_msg.get('error') + if err is not None: + raise ConnectionError(raw_msg) + elif raw_msg.get('result') == 'pong': + continue # ignore ping response + + success = True + return raw_msg + except asyncio.TimeoutError: + await asyncio.wait_for(self._client.ping(), timeout=self.PING_TIMEOUT) + except asyncio.TimeoutError: + self.logger().warning("WebSocket ping timed out. Going to reconnect...") + return + except ConnectionClosed: + return + except Exception as e: + _ = e + self.logger().exception('digifinex.websocket._messages', stack_info=True) + raise + finally: + if not success: + await self.disconnect() + + # emit messages + async def _emit(self, method: str, data: Optional[Any] = {}) -> int: + id = self.generate_request_id() + + payload = { + "id": id, + "method": method, + "params": copy.deepcopy(data), + } + + req = ujson.dumps(payload) + self.logger().network(req) # todo remove log + await self._client.send(req) + + return id + + # request via websocket + async def request(self, method: str, data: Optional[Any] = {}) -> int: + return await self._emit(method, data) + + # subscribe to a method + async def subscribe(self, category: str, channels: List[str]) -> int: + id = await self.request(category + ".subscribe", channels) + msg = await self._messages() + if msg.get('error') is not None: + raise ConnectionError(f'subscribe {category} {channels} failed: {msg}') + return id + + # unsubscribe to a method + async def unsubscribe(self, channels: List[str]) -> int: + return await self.request("unsubscribe", { + "channels": channels + }) + + # listen to messages by method + async def on_message(self) -> AsyncIterable[Any]: + while True: + msg = await self._messages() + if msg is None: + return + if 'pong' in str(msg): + _ = int(0) + yield msg diff --git a/hummingbot/connector/exchange/digifinex/time_patcher.py b/hummingbot/connector/exchange/digifinex/time_patcher.py new file mode 100644 index 0000000000..3c6d195f30 --- /dev/null +++ b/hummingbot/connector/exchange/digifinex/time_patcher.py @@ -0,0 +1,112 @@ +import asyncio +from collections import deque +import logging +import statistics +import time +from typing import Deque, Optional, Callable, Awaitable + +from hummingbot.logger import HummingbotLogger +from hummingbot.core.utils.async_utils import safe_ensure_future + + +class TimePatcher: + # BINANCE_TIME_API = "https://api.binance.com/api/v1/time" + NaN = float("nan") + _bt_logger = None + _bt_shared_instance = None + + @classmethod + def logger(cls) -> HummingbotLogger: + if cls._bt_logger is None: + cls._bt_logger = logging.getLogger(__name__) + return cls._bt_logger + + # query_time_func returns the server time in seconds + def __init__(self, exchange_name: str, query_time_func: Callable[[], Awaitable[float]], check_interval: float = 60.0): + self._exchange_name = exchange_name + self._query_time_func = query_time_func + self._time_offset_ms: Deque[float] = deque([]) + self._set_server_time_offset_task: Optional[asyncio.Task] = None + self._started: bool = False + self._server_time_offset_check_interval = check_interval + self._median_window = 5 + self._last_update_local_time: float = self.NaN + self._scheduled_update_task: Optional[asyncio.Task] = None + + @property + def started(self) -> bool: + return self._started + + @property + def time_offset_ms(self) -> float: + if not self._time_offset_ms: + return (time.time() - time.perf_counter()) * 1e3 + return statistics.median(self._time_offset_ms) + + def add_time_offset_ms_sample(self, offset: float): + self._time_offset_ms.append(offset) + while len(self._time_offset_ms) > self._median_window: + self._time_offset_ms.popleft() + + def clear_time_offset_ms_samples(self): + self._time_offset_ms.clear() + + def time(self) -> float: + return time.perf_counter() + self.time_offset_ms * 1e-3 + + def start(self): + if self._set_server_time_offset_task is None: + self._set_server_time_offset_task = safe_ensure_future(self.update_server_time_offset_loop()) + self._started = True + + def stop(self): + if self._set_server_time_offset_task: + self._set_server_time_offset_task.cancel() + self._set_server_time_offset_task = None + self._time_offset_ms.clear() + self._started = False + + def schedule_update_server_time_offset(self) -> asyncio.Task: + # If an update task is already scheduled, don't do anything. + if self._scheduled_update_task is not None and not self._scheduled_update_task.done(): + return self._scheduled_update_task + + current_local_time: float = time.perf_counter() + if not (current_local_time - self._last_update_local_time < 5): + # If there was no recent update, schedule the server time offset update immediately. + self._scheduled_update_task = safe_ensure_future(self.update_server_time_offset()) + else: + # If there was a recent update, schedule the server time offset update after 5 seconds. + async def update_later(): + await asyncio.sleep(5.0) + await self.update_server_time_offset() + self._scheduled_update_task = safe_ensure_future(update_later()) + + return self._scheduled_update_task + + async def update_server_time_offset_loop(self): + while True: + await self.update_server_time_offset() + await asyncio.sleep(self._server_time_offset_check_interval) + + async def update_server_time_offset(self): + try: + local_before_ms: float = time.perf_counter() * 1e3 + query_time_func = self._query_time_func.__func__ + server_time = await query_time_func() + # async with aiohttp.ClientSession() as session: + # async with session.get(self.BINANCE_TIME_API) as resp: + # resp_data: Dict[str, float] = await resp.json() + # binance_server_time_ms: float = float(resp_data["serverTime"]) + # local_after_ms: float = time.perf_counter() * 1e3 + local_after_ms: float = time.perf_counter() * 1e3 + local_server_time_pre_image_ms: float = (local_before_ms + local_after_ms) / 2.0 + time_offset_ms: float = server_time * 1000 - local_server_time_pre_image_ms + self.add_time_offset_ms_sample(time_offset_ms) + self._last_update_local_time = time.perf_counter() + except asyncio.CancelledError: + raise + except Exception: + self.logger().network(f"Error getting {self._exchange_name} server time.", exc_info=True, + app_warning_msg=f"Could not refresh {self._exchange_name} server time. " + "Check network connection.") diff --git a/hummingbot/connector/exchange/dolomite/dolomite_order_book_tracker.py b/hummingbot/connector/exchange/dolomite/dolomite_order_book_tracker.py index 5c2e50b388..d460d67804 100644 --- a/hummingbot/connector/exchange/dolomite/dolomite_order_book_tracker.py +++ b/hummingbot/connector/exchange/dolomite/dolomite_order_book_tracker.py @@ -107,7 +107,7 @@ async def _track_single_book(self, trading_pair: str): elif message.type is OrderBookMessageType.SNAPSHOT: 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) - self.logger().debug("Processed order book snapshot for %s.", trading_pair) + self.logger().debug(f"Processed order book snapshot for {trading_pair}.") except asyncio.CancelledError: raise diff --git a/hummingbot/connector/exchange/dydx/dydx_api_order_book_data_source.py b/hummingbot/connector/exchange/dydx/dydx_api_order_book_data_source.py index 8eab204355..447f3863af 100644 --- a/hummingbot/connector/exchange/dydx/dydx_api_order_book_data_source.py +++ b/hummingbot/connector/exchange/dydx/dydx_api_order_book_data_source.py @@ -1,19 +1,10 @@ #!/usr/bin/env python import asyncio -from decimal import Decimal from datetime import datetime - import aiohttp import logging -# import pandas as pd -# import math - -import requests -import cachetools.func - from typing import AsyncIterable, Dict, List, Optional, Any - import time import ujson import websockets @@ -22,7 +13,7 @@ from hummingbot.connector.exchange.dydx.dydx_order_book import DydxOrderBook from hummingbot.connector.exchange.dydx.dydx_active_order_tracker import DydxActiveOrderTracker from hummingbot.connector.exchange.dydx.dydx_api_token_configuration_data_source import DydxAPITokenConfigurationDataSource -from hummingbot.connector.exchange.dydx.dydx_utils import convert_from_exchange_trading_pair, convert_to_exchange_trading_pair, convert_v2_pair_to_v1 +from hummingbot.connector.exchange.dydx.dydx_utils import convert_from_exchange_trading_pair, convert_v2_pair_to_v1 from hummingbot.core.data_type.order_book_tracker_data_source import OrderBookTrackerDataSource from hummingbot.logger import HummingbotLogger from hummingbot.core.data_type.order_book import OrderBook @@ -139,22 +130,6 @@ async def _inner_messages(self, ws: websockets.WebSocketClientProtocol) -> Async finally: await ws.close() - @staticmethod - @cachetools.func.ttl_cache(ttl=10) - def get_mid_price(trading_pair: str) -> Optional[Decimal]: - exchange_pair: str = convert_to_exchange_trading_pair(trading_pair) - market_info_response = requests.get(url=DYDX_MARKET_INFO_URL.format(exchange_pair)) - market_info = market_info_response.json() - base_decimals = market_info['market']['baseCurrency']['decimals'] - quote_decimals = market_info['market']['quoteCurrency']['decimals'] - - resp = requests.get(url=DYDX_ORDERBOOK_URL.format(exchange_pair)) - record = resp.json() - conversion_factor = Decimal(f"1e{base_decimals - quote_decimals}") - best_bid = Decimal(record["bids"][0]["price"]) * conversion_factor - best_ask = Decimal(record["asks"][0]["price"]) * conversion_factor - return (best_bid + best_ask) / 2 - @staticmethod async def fetch_trading_pairs() -> List[str]: try: diff --git a/hummingbot/connector/exchange/dydx/dydx_order_book_tracker.py b/hummingbot/connector/exchange/dydx/dydx_order_book_tracker.py index 1450c57156..67763d305e 100644 --- a/hummingbot/connector/exchange/dydx/dydx_order_book_tracker.py +++ b/hummingbot/connector/exchange/dydx/dydx_order_book_tracker.py @@ -86,7 +86,7 @@ async def _track_single_book(self, trading_pair: str): elif message.type is OrderBookMessageType.SNAPSHOT: s_bids, s_asks = active_order_tracker.convert_snapshot_message_to_order_book_row(message) order_book.apply_snapshot(s_bids, s_asks, int(message.timestamp)) - self.logger().debug("Processed order book snapshot for %s.", trading_pair) + self.logger().debug(f"Processed order book snapshot for {trading_pair}.") except asyncio.CancelledError: raise diff --git a/hummingbot/connector/exchange/hitbtc/__init__.py b/hummingbot/connector/exchange/hitbtc/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/hummingbot/connector/exchange/hitbtc/hitbtc_active_order_tracker.pxd b/hummingbot/connector/exchange/hitbtc/hitbtc_active_order_tracker.pxd new file mode 100644 index 0000000000..5babac5332 --- /dev/null +++ b/hummingbot/connector/exchange/hitbtc/hitbtc_active_order_tracker.pxd @@ -0,0 +1,11 @@ +# distutils: language=c++ +cimport numpy as np + +cdef class HitbtcActiveOrderTracker: + 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) + # This method doesn't seem to be used anywhere at all + # cdef np.ndarray[np.float64_t, ndim=1] c_convert_trade_message_to_np_array(self, object message) diff --git a/hummingbot/connector/exchange/hitbtc/hitbtc_active_order_tracker.pyx b/hummingbot/connector/exchange/hitbtc/hitbtc_active_order_tracker.pyx new file mode 100644 index 0000000000..5e248bb3d5 --- /dev/null +++ b/hummingbot/connector/exchange/hitbtc/hitbtc_active_order_tracker.pyx @@ -0,0 +1,155 @@ +# 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 + +_logger = None +s_empty_diff = np.ndarray(shape=(0, 4), dtype="float64") +HitbtcOrderBookTrackingDictionary = Dict[Decimal, Dict[str, Dict[str, any]]] + +cdef class HitbtcActiveOrderTracker: + def __init__(self, + active_asks: HitbtcOrderBookTrackingDictionary = None, + active_bids: HitbtcOrderBookTrackingDictionary = None): + super().__init__() + self._active_asks = active_asks or {} + self._active_bids = active_bids or {} + + @classmethod + def logger(cls) -> HummingbotLogger: + global _logger + if _logger is None: + _logger = logging.getLogger(__name__) + return _logger + + @property + def active_asks(self) -> HitbtcOrderBookTrackingDictionary: + return self._active_asks + + @property + def active_bids(self) -> HitbtcOrderBookTrackingDictionary: + return self._active_bids + + # TODO: research this more + def volume_for_ask_price(self, price) -> float: + return NotImplementedError + + # TODO: research this more + def volume_for_bid_price(self, price) -> float: + return NotImplementedError + + def get_rates_and_quantities(self, entry) -> tuple: + # price, quantity + return float(entry["price"]), float(entry["size"]) + + cdef tuple c_convert_diff_message_to_np_arrays(self, object message): + cdef: + dict content = message.content + list content_keys = list(content.keys()) + list bid_entries = [] + list ask_entries = [] + str order_id + str order_side + str price_raw + object price + dict order_dict + double timestamp = message.timestamp + double amount = 0 + + if "bid" in content_keys: + bid_entries = content["bid"] + if "ask" in content_keys: + ask_entries = content["ask"] + + bids = s_empty_diff + asks = s_empty_diff + + if len(bid_entries) > 0: + bids = np.array( + [[timestamp, + price, + amount, + message.update_id] + for price, amount in [self.get_rates_and_quantities(entry) for entry in bid_entries]], + dtype="float64", + ndmin=2 + ) + + if len(ask_entries) > 0: + asks = np.array( + [[timestamp, + price, + amount, + message.update_id] + for price, amount in [self.get_rates_and_quantities(entry) for entry in ask_entries]], + dtype="float64", + ndmin=2 + ) + + return bids, asks + + cdef tuple c_convert_snapshot_message_to_np_arrays(self, object message): + cdef: + float price + float amount + str order_id + dict order_dict + + # Refresh all order tracking. + self._active_bids.clear() + self._active_asks.clear() + timestamp = message.timestamp + content = message.content + + for snapshot_orders, active_orders in [(content["bid"], self._active_bids), (content["ask"], self._active_asks)]: + for entry in snapshot_orders: + price, amount = self.get_rates_and_quantities(entry) + active_orders[price] = amount + + # Return the sorted snapshot tables. + cdef: + np.ndarray[np.float64_t, ndim=2] bids = np.array( + [[message.timestamp, + float(price), + float(self._active_bids[price]), + message.update_id] + for price in sorted(self._active_bids.keys())], dtype='float64', ndmin=2) + np.ndarray[np.float64_t, ndim=2] asks = np.array( + [[message.timestamp, + float(price), + float(self._active_asks[price]), + message.update_id] + for price in sorted(self._active_asks.keys(), reverse=True)], dtype='float64', ndmin=2) + + if bids.shape[1] != 4: + bids = bids.reshape((0, 4)) + if asks.shape[1] != 4: + asks = asks.reshape((0, 4)) + + return bids, asks + + # This method doesn't seem to be used anywhere at all + # cdef np.ndarray[np.float64_t, ndim=1] c_convert_trade_message_to_np_array(self, object message): + # cdef: + # double trade_type_value = 1.0 if message.content["side"] == "buy" else 2.0 + # list content = message.content + # return np.array( + # [message.timestamp, trade_type_value, float(content["price"]), float(content["quantity"])], + # dtype="float64" + # ) + + def convert_diff_message_to_order_book_row(self, message): + 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): + 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/hitbtc/hitbtc_api_order_book_data_source.py b/hummingbot/connector/exchange/hitbtc/hitbtc_api_order_book_data_source.py new file mode 100644 index 0000000000..40d83516da --- /dev/null +++ b/hummingbot/connector/exchange/hitbtc/hitbtc_api_order_book_data_source.py @@ -0,0 +1,216 @@ +#!/usr/bin/env python +import asyncio +import logging +import time +import pandas as pd +from decimal import Decimal +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 .hitbtc_constants import Constants +from .hitbtc_active_order_tracker import HitbtcActiveOrderTracker +from .hitbtc_order_book import HitbtcOrderBook +from .hitbtc_websocket import HitbtcWebsocket +from .hitbtc_utils import ( + str_date_to_ts, + convert_to_exchange_trading_pair, + convert_from_exchange_trading_pair, + api_call_with_retries, + HitbtcAPIError, +) + + +class HitbtcAPIOrderBookDataSource(OrderBookTrackerDataSource): + _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, Decimal]: + results = {} + if len(trading_pairs) > 1: + tickers: List[Dict[Any]] = await api_call_with_retries("GET", Constants.ENDPOINT["TICKER"]) + for trading_pair in trading_pairs: + ex_pair: str = convert_to_exchange_trading_pair(trading_pair) + if len(trading_pairs) > 1: + ticker: Dict[Any] = list([tic for tic in tickers if tic['symbol'] == ex_pair])[0] + else: + url_endpoint = Constants.ENDPOINT["TICKER_SINGLE"].format(trading_pair=ex_pair) + ticker: Dict[Any] = await api_call_with_retries("GET", url_endpoint) + results[trading_pair]: Decimal = Decimal(str(ticker["last"])) + return results + + @staticmethod + async def fetch_trading_pairs() -> List[str]: + try: + symbols: List[Dict[str, Any]] = await api_call_with_retries("GET", Constants.ENDPOINT["SYMBOL"]) + trading_pairs: List[str] = list([convert_from_exchange_trading_pair(sym["id"]) for sym in symbols]) + # Filter out unmatched pairs so nothing breaks + return [sym for sym in trading_pairs if sym is not None] + except Exception: + # Do nothing if the request fails -- there will be no autocomplete for HitBTC trading pairs + pass + return [] + + @staticmethod + async def get_order_book_data(trading_pair: str) -> Dict[str, any]: + """ + Get whole orderbook + """ + try: + ex_pair = convert_to_exchange_trading_pair(trading_pair) + orderbook_response: Dict[Any] = await api_call_with_retries("GET", Constants.ENDPOINT["ORDER_BOOK"], + params={"limit": 150, "symbols": ex_pair}) + return orderbook_response[ex_pair] + except HitbtcAPIError as e: + err = e.error_payload.get('error', e.error_payload) + raise IOError( + f"Error fetching OrderBook for {trading_pair} at {Constants.EXCHANGE_NAME}. " + f"HTTP status is {e.error_payload['status']}. Error is {err.get('message', str(err))}.") + + 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 = HitbtcOrderBook.snapshot_message_from_exchange( + snapshot, + snapshot_timestamp, + metadata={"trading_pair": trading_pair}) + order_book = self.order_book_create_function() + active_order_tracker: HitbtcActiveOrderTracker = HitbtcActiveOrderTracker() + 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 listen_for_trades(self, ev_loop: asyncio.BaseEventLoop, output: asyncio.Queue): + """ + Listen for trades using websocket trade channel + """ + while True: + try: + ws = HitbtcWebsocket() + await ws.connect() + + for pair in self._trading_pairs: + await ws.subscribe(Constants.WS_SUB["TRADES"], convert_to_exchange_trading_pair(pair)) + + async for response in ws.on_message(): + method: str = response.get("method", None) + trades_data: str = response.get("params", None) + + if trades_data is None or method != Constants.WS_METHODS['TRADES_UPDATE']: + continue + + pair: str = convert_from_exchange_trading_pair(response["params"]["symbol"]) + + for trade in trades_data["data"]: + trade: Dict[Any] = trade + trade_timestamp: int = str_date_to_ts(trade["timestamp"]) + trade_msg: OrderBookMessage = HitbtcOrderBook.trade_message_from_exchange( + trade, + trade_timestamp, + metadata={"trading_pair": pair}) + 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 = HitbtcWebsocket() + await ws.connect() + + order_book_methods = [ + Constants.WS_METHODS['ORDERS_SNAPSHOT'], + Constants.WS_METHODS['ORDERS_UPDATE'], + ] + + for pair in self._trading_pairs: + await ws.subscribe(Constants.WS_SUB["ORDERS"], convert_to_exchange_trading_pair(pair)) + + async for response in ws.on_message(): + method: str = response.get("method", None) + order_book_data: str = response.get("params", None) + + if order_book_data is None or method not in order_book_methods: + continue + + timestamp: int = str_date_to_ts(order_book_data["timestamp"]) + pair: str = convert_from_exchange_trading_pair(order_book_data["symbol"]) + + order_book_msg_cls = (HitbtcOrderBook.diff_message_from_exchange + if method == Constants.WS_METHODS['ORDERS_UPDATE'] else + HitbtcOrderBook.snapshot_message_from_exchange) + + orderbook_msg: OrderBookMessage = order_book_msg_cls( + order_book_data, + timestamp, + metadata={"trading_pair": pair}) + 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 = str_date_to_ts(snapshot["timestamp"]) + snapshot_msg: OrderBookMessage = HitbtcOrderBook.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/hitbtc/hitbtc_api_user_stream_data_source.py b/hummingbot/connector/exchange/hitbtc/hitbtc_api_user_stream_data_source.py new file mode 100755 index 0000000000..954ab9c344 --- /dev/null +++ b/hummingbot/connector/exchange/hitbtc/hitbtc_api_user_stream_data_source.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python +import time +import asyncio +import logging +from typing import ( + Any, + AsyncIterable, + List, + Optional, +) +from hummingbot.core.data_type.user_stream_tracker_data_source import UserStreamTrackerDataSource +from hummingbot.logger import HummingbotLogger +from .hitbtc_constants import Constants +from .hitbtc_auth import HitbtcAuth +from .hitbtc_utils import HitbtcAPIError +from .hitbtc_websocket import HitbtcWebsocket + + +class HitbtcAPIUserStreamDataSource(UserStreamTrackerDataSource): + + _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, hitbtc_auth: HitbtcAuth, trading_pairs: Optional[List[str]] = []): + self._hitbtc_auth: HitbtcAuth = hitbtc_auth + self._ws: HitbtcWebsocket = None + 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 _ws_request_balances(self): + return await self._ws.request(Constants.WS_METHODS["USER_BALANCE"]) + + async def _listen_to_orders_trades_balances(self) -> AsyncIterable[Any]: + """ + Subscribe to active orders via web socket + """ + + try: + self._ws = HitbtcWebsocket(self._hitbtc_auth) + + await self._ws.connect() + + await self._ws.subscribe(Constants.WS_SUB["USER_ORDERS_TRADES"], None, {}) + + event_methods = [ + Constants.WS_METHODS["USER_ORDERS"], + Constants.WS_METHODS["USER_TRADES"], + ] + + async for msg in self._ws.on_message(): + self._last_recv_time = time.time() + + if msg.get("params", msg.get("result", None)) is None: + continue + elif msg.get("method", None) in event_methods: + await self._ws_request_balances() + yield msg + except Exception as e: + raise e + finally: + await self._ws.disconnect() + await asyncio.sleep(5) + + 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: + async for msg in self._listen_to_orders_trades_balances(): + output.put_nowait(msg) + except asyncio.CancelledError: + raise + except HitbtcAPIError as e: + self.logger().error(e.error_payload.get('error'), exc_info=True) + raise + except Exception: + self.logger().error( + f"Unexpected error with {Constants.EXCHANGE_NAME} WebSocket connection. " + "Retrying after 30 seconds...", exc_info=True) + await asyncio.sleep(30.0) diff --git a/hummingbot/connector/exchange/hitbtc/hitbtc_auth.py b/hummingbot/connector/exchange/hitbtc/hitbtc_auth.py new file mode 100755 index 0000000000..be37f2e149 --- /dev/null +++ b/hummingbot/connector/exchange/hitbtc/hitbtc_auth.py @@ -0,0 +1,72 @@ +import hmac +import hashlib +import time +from base64 import b64encode +from typing import Dict, Any + + +class HitbtcAuth(): + """ + Auth class required by HitBTC API + Learn more at https://exchange-docs.crypto.com/#digital-signature + """ + def __init__(self, api_key: str, secret_key: str): + self.api_key = api_key + self.secret_key = secret_key + + def generate_payload( + self, + method: str, + url: str, + params: Dict[str, Any] = None, + ): + """ + Generates authentication payload and returns it. + :return: A base64 encoded payload for the authentication header. + """ + # Nonce is standard EPOCH timestamp only accurate to 1s + nonce = str(int(time.time())) + body = "" + # Need to build the full URL with query string for HS256 sig + if params is not None and len(params) > 0: + query_string = "&".join([f"{k}={v}" for k, v in params.items()]) + if method == "GET": + url = f"{url}?{query_string}" + else: + body = query_string + # Concat payload + payload = f"{method}{nonce}{url}{body}" + # Create HS256 sig + sig = hmac.new(self.secret_key.encode(), payload.encode(), hashlib.sha256).hexdigest() + # Base64 encode it with public key and nonce + return b64encode(f"{self.api_key}:{nonce}:{sig}".encode()).decode().strip() + + def generate_auth_dict_ws(self, + nonce: int): + """ + Generates an authentication params for HitBTC websockets login + :return: a dictionary of auth params + """ + return { + "algo": "HS256", + "pKey": str(self.api_key), + "nonce": str(nonce), + "signature": hmac.new(self.secret_key.encode('utf-8'), + str(nonce).encode('utf-8'), + hashlib.sha256).hexdigest() + } + + def get_headers(self, + method, + url, + params) -> Dict[str, Any]: + """ + Generates authentication headers required by HitBTC + :return: a dictionary of auth headers + """ + payload = self.generate_payload(method, url, params) + headers = { + "Authorization": f"HS256 {payload}", + "Content-Type": "application/x-www-form-urlencoded", + } + return headers diff --git a/hummingbot/connector/exchange/hitbtc/hitbtc_constants.py b/hummingbot/connector/exchange/hitbtc/hitbtc_constants.py new file mode 100644 index 0000000000..538e0b21f2 --- /dev/null +++ b/hummingbot/connector/exchange/hitbtc/hitbtc_constants.py @@ -0,0 +1,57 @@ +# A single source of truth for constant variables related to the exchange +class Constants: + EXCHANGE_NAME = "hitbtc" + REST_URL = "https://api.hitbtc.com/api/2" + REST_URL_AUTH = "/api/2" + WS_PRIVATE_URL = "wss://api.hitbtc.com/api/2/ws/trading" + WS_PUBLIC_URL = "wss://api.hitbtc.com/api/2/ws/public" + + HBOT_BROKER_ID = "refzzz48" + + ENDPOINT = { + # Public Endpoints + "TICKER": "public/ticker", + "TICKER_SINGLE": "public/ticker/{trading_pair}", + "SYMBOL": "public/symbol", + "ORDER_BOOK": "public/orderbook", + "ORDER_CREATE": "order", + "ORDER_DELETE": "order/{id}", + "ORDER_STATUS": "order/{id}", + "USER_ORDERS": "order", + "USER_BALANCES": "trading/balance", + } + + WS_SUB = { + "TRADES": "Trades", + "ORDERS": "Orderbook", + "USER_ORDERS_TRADES": "Reports", + + } + + WS_METHODS = { + "ORDERS_SNAPSHOT": "snapshotOrderbook", + "ORDERS_UPDATE": "updateOrderbook", + "TRADES_SNAPSHOT": "snapshotTrades", + "TRADES_UPDATE": "updateTrades", + "USER_BALANCE": "getTradingBalance", + "USER_ORDERS": "activeOrders", + "USER_TRADES": "report", + } + + # Timeouts + MESSAGE_TIMEOUT = 30.0 + PING_TIMEOUT = 10.0 + API_CALL_TIMEOUT = 10.0 + API_MAX_RETRIES = 4 + + # Intervals + # Only used when nothing is received from WS + SHORT_POLL_INTERVAL = 5.0 + # One minute should be fine since we get trades, orders and balances via WS + LONG_POLL_INTERVAL = 60.0 + UPDATE_ORDER_STATUS_INTERVAL = 60.0 + # 10 minute interval to update trading rules, these would likely never change whilst running. + INTERVAL_TRADING_RULES = 600 + + # Trading pair splitter regex + TRADING_PAIR_SPLITTER = r"^(\w+)(BTC|BCH|DAI|DDRST|EOSDT|EOS|ETH|EURS|IDRT|PAX|BUSD|GUSD|TUSD|USDC|USDT|USD)$" diff --git a/hummingbot/connector/exchange/hitbtc/hitbtc_exchange.py b/hummingbot/connector/exchange/hitbtc/hitbtc_exchange.py new file mode 100644 index 0000000000..ba0ecadcb0 --- /dev/null +++ b/hummingbot/connector/exchange/hitbtc/hitbtc_exchange.py @@ -0,0 +1,878 @@ +import logging +from typing import ( + Dict, + List, + Optional, + Any, + AsyncIterable, +) +from decimal import Decimal +import asyncio +import aiohttp +import math +import time +from async_timeout import timeout + +from hummingbot.core.network_iterator import NetworkStatus +from hummingbot.logger import HummingbotLogger +from hummingbot.core.clock import Clock +from hummingbot.core.utils.async_utils import safe_ensure_future, safe_gather +from hummingbot.connector.trading_rule import TradingRule +from hummingbot.core.data_type.cancellation_result import CancellationResult +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.connector.exchange_base import ExchangeBase +from hummingbot.connector.exchange.hitbtc.hitbtc_order_book_tracker import HitbtcOrderBookTracker +from hummingbot.connector.exchange.hitbtc.hitbtc_user_stream_tracker import HitbtcUserStreamTracker +from hummingbot.connector.exchange.hitbtc.hitbtc_auth import HitbtcAuth +from hummingbot.connector.exchange.hitbtc.hitbtc_in_flight_order import HitbtcInFlightOrder +from hummingbot.connector.exchange.hitbtc.hitbtc_utils import ( + convert_from_exchange_trading_pair, + convert_to_exchange_trading_pair, + translate_asset, + get_new_client_order_id, + aiohttp_response_with_errors, + retry_sleep_time, + str_date_to_ts, + HitbtcAPIError, +) +from hummingbot.connector.exchange.hitbtc.hitbtc_constants import Constants +from hummingbot.core.data_type.common import OpenOrder +ctce_logger = None +s_decimal_NaN = Decimal("nan") + + +class HitbtcExchange(ExchangeBase): + """ + HitbtcExchange connects with HitBTC exchange and provides order book pricing, user account tracking and + trading functionality. + """ + ORDER_NOT_EXIST_CONFIRMATION_COUNT = 3 + ORDER_NOT_EXIST_CANCEL_COUNT = 2 + + @classmethod + def logger(cls) -> HummingbotLogger: + global ctce_logger + if ctce_logger is None: + ctce_logger = logging.getLogger(__name__) + return ctce_logger + + def __init__(self, + hitbtc_api_key: str, + hitbtc_secret_key: str, + trading_pairs: Optional[List[str]] = None, + trading_required: bool = True + ): + """ + :param hitbtc_api_key: The API key to connect to private HitBTC APIs. + :param hitbtc_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._hitbtc_auth = HitbtcAuth(hitbtc_api_key, hitbtc_secret_key) + self._order_book_tracker = HitbtcOrderBookTracker(trading_pairs=trading_pairs) + self._user_stream_tracker = HitbtcUserStreamTracker(self._hitbtc_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, HitbtcInFlightOrder] + 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 "hitbtc" + + @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, HitbtcInFlightOrder]: + 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: HitbtcInFlightOrder.from_json(value) + for key, value in saved_states.items() + }) + + def supported_order_types(self) -> List[OrderType]: + """ + :return a list of OrderType supported by this connector. + Note that Market order type is no longer required and will not be used. + """ + return [OrderType.LIMIT, OrderType.LIMIT_MAKER] + + def start(self, clock: Clock, timestamp: float): + """ + This function is called automatically by the clock. + """ + super().start(clock, timestamp) + + def stop(self, clock: Clock): + """ + This function is called automatically by the clock. + """ + super().stop(clock) + + async def start_network(self): + """ + This function is required by NetworkIterator base class and is called automatically. + It starts tracking order book, polling trading rules, + updating statuses and tracking user data. + """ + self._order_book_tracker.start() + self._trading_rules_polling_task = safe_ensure_future(self._trading_rules_polling_loop()) + if self._trading_required: + self._status_polling_task = safe_ensure_future(self._status_polling_loop()) + self._user_stream_tracker_task = safe_ensure_future(self._user_stream_tracker.start()) + self._user_stream_event_listener_task = safe_ensure_future(self._user_stream_event_listener()) + + async def stop_network(self): + """ + This function is required by NetworkIterator base class and is called automatically. + """ + self._order_book_tracker.stop() + if self._status_polling_task is not None: + self._status_polling_task.cancel() + self._status_polling_task = None + if self._trading_rules_polling_task is not None: + self._trading_rules_polling_task.cancel() + self._trading_rules_polling_task = None + if self._status_polling_task is not None: + self._status_polling_task.cancel() + self._status_polling_task = None + if self._user_stream_tracker_task is not None: + self._user_stream_tracker_task.cancel() + self._user_stream_tracker_task = None + if self._user_stream_event_listener_task is not None: + self._user_stream_event_listener_task.cancel() + self._user_stream_event_listener_task = None + + async def check_network(self) -> NetworkStatus: + """ + This function is required by NetworkIterator base class and is called periodically to check + the network connection. Simply ping the network (or call any light weight public API). + """ + try: + # since there is no ping endpoint, the lowest rate call is to get BTC-USD symbol + await self._api_request("GET", + Constants.ENDPOINT['SYMBOL'], + params={'symbols': 'BTCUSD'}) + except asyncio.CancelledError: + raise + except Exception: + return NetworkStatus.NOT_CONNECTED + return NetworkStatus.CONNECTED + + async def _http_client(self) -> aiohttp.ClientSession: + """ + :returns Shared client session instance + """ + if self._shared_client is None: + self._shared_client = aiohttp.ClientSession() + return self._shared_client + + async def _trading_rules_polling_loop(self): + """ + Periodically update trading rule. + """ + while True: + try: + await self._update_trading_rules() + await asyncio.sleep(Constants.INTERVAL_TRADING_RULES) + except asyncio.CancelledError: + raise + except Exception as e: + self.logger().network(f"Unexpected error while fetching trading rules. Error: {str(e)}", + exc_info=True, + app_warning_msg=("Could not fetch new trading rules from " + f"{Constants.EXCHANGE_NAME}. Check network connection.")) + await asyncio.sleep(0.5) + + async def _update_trading_rules(self): + symbols_info = await self._api_request("GET", endpoint=Constants.ENDPOINT['SYMBOL']) + self._trading_rules.clear() + self._trading_rules = self._format_trading_rules(symbols_info) + + def _format_trading_rules(self, symbols_info: Dict[str, Any]) -> Dict[str, TradingRule]: + """ + Converts json API response into a dictionary of trading rules. + :param symbols_info: The json API response + :return A dictionary of trading rules. + Response Example: + [ + { + id: "BTCUSD", + baseCurrency: "BTC", + quoteCurrency: "USD", + quantityIncrement: "0.00001", + tickSize: "0.01", + takeLiquidityRate: "0.0025", + provideLiquidityRate: "0.001", + feeCurrency: "USD", + marginTrading: true, + maxInitialLeverage: "12.00" + } + ] + """ + result = {} + for rule in symbols_info: + try: + trading_pair = convert_from_exchange_trading_pair(rule["id"]) + price_step = Decimal(str(rule["tickSize"])) + size_step = Decimal(str(rule["quantityIncrement"])) + result[trading_pair] = TradingRule(trading_pair, + min_order_size=size_step, + min_base_amount_increment=size_step, + min_price_increment=price_step) + except Exception: + self.logger().error(f"Error parsing the trading pair rule {rule}. Skipping.", exc_info=True) + return result + + async def _api_request(self, + method: str, + endpoint: str, + params: Optional[Dict[str, Any]] = None, + is_auth_required: bool = False, + try_count: int = 0) -> Dict[str, Any]: + """ + Sends an aiohttp request and waits for a response. + :param method: The HTTP method, e.g. get or post + :param endpoint: The path url or the API end point + :param params: Additional get/post parameters + :param is_auth_required: Whether an authentication is required, when True the function will add encrypted + signature to the request. + :returns A response in json format. + """ + url = f"{Constants.REST_URL}/{endpoint}" + shared_client = await self._http_client() + # Turn `params` into either GET params or POST body data + qs_params: dict = params if method.upper() == "GET" else None + req_form = aiohttp.FormData(params) if method.upper() == "POST" and params is not None else None + # Generate auth headers if needed. + headers: dict = {"Content-Type": "application/x-www-form-urlencoded"} + if is_auth_required: + headers: dict = self._hitbtc_auth.get_headers(method, f"{Constants.REST_URL_AUTH}/{endpoint}", + params) + # Build request coro + response_coro = shared_client.request(method=method.upper(), url=url, headers=headers, + params=qs_params, data=req_form, + timeout=Constants.API_CALL_TIMEOUT) + http_status, parsed_response, request_errors = await aiohttp_response_with_errors(response_coro) + if request_errors or parsed_response is None: + if try_count < Constants.API_MAX_RETRIES: + try_count += 1 + time_sleep = retry_sleep_time(try_count) + self.logger().info(f"Error fetching data from {url}. HTTP status is {http_status}. " + f"Retrying in {time_sleep:.0f}s.") + await asyncio.sleep(time_sleep) + return await self._api_request(method=method, endpoint=endpoint, params=params, + is_auth_required=is_auth_required, try_count=try_count) + else: + raise HitbtcAPIError({"error": parsed_response, "status": http_status}) + if "error" in parsed_response: + raise HitbtcAPIError(parsed_response) + return parsed_response + + def get_order_price_quantum(self, trading_pair: str, price: Decimal): + """ + Returns a price step, a minimum price increment for a given trading pair. + """ + trading_rule = self._trading_rules[trading_pair] + return trading_rule.min_price_increment + + def get_order_size_quantum(self, trading_pair: str, order_size: Decimal): + """ + Returns an order amount step, a minimum amount increment for a given trading pair. + """ + trading_rule = self._trading_rules[trading_pair] + return Decimal(trading_rule.min_base_amount_increment) + + def get_order_book(self, trading_pair: str) -> OrderBook: + if trading_pair not in self._order_book_tracker.order_books: + raise ValueError(f"No order book exists for '{trading_pair}'.") + return self._order_book_tracker.order_books[trading_pair] + + def buy(self, trading_pair: str, amount: Decimal, order_type=OrderType.MARKET, + price: Decimal = s_decimal_NaN, **kwargs) -> str: + """ + Buys an amount of base asset (of the given trading pair). This function returns immediately. + To see an actual order, you'll have to wait for BuyOrderCreatedEvent. + :param trading_pair: The market (e.g. BTC-USDT) to buy from + :param amount: The amount in base token value + :param order_type: The order type + :param price: The price (note: this is no longer optional) + :returns A new internal order id + """ + order_id: str = get_new_client_order_id(True, trading_pair) + safe_ensure_future(self._create_order(TradeType.BUY, order_id, trading_pair, amount, order_type, price)) + return order_id + + def sell(self, trading_pair: str, amount: Decimal, order_type=OrderType.MARKET, + price: Decimal = s_decimal_NaN, **kwargs) -> str: + """ + Sells an amount of base asset (of the given trading pair). This function returns immediately. + To see an actual order, you'll have to wait for SellOrderCreatedEvent. + :param trading_pair: The market (e.g. BTC-USDT) to sell from + :param amount: The amount in base token value + :param order_type: The order type + :param price: The price (note: this is no longer optional) + :returns A new internal order id + """ + order_id: str = get_new_client_order_id(False, trading_pair) + safe_ensure_future(self._create_order(TradeType.SELL, order_id, trading_pair, amount, order_type, price)) + return order_id + + def cancel(self, trading_pair: str, order_id: str): + """ + Cancel an order. This function returns immediately. + To get the cancellation result, you'll have to wait for OrderCancelledEvent. + :param trading_pair: The market (e.g. BTC-USDT) of the order. + :param order_id: The internal order id (also called client_order_id) + """ + safe_ensure_future(self._execute_cancel(trading_pair, order_id)) + return order_id + + async def _create_order(self, + trade_type: TradeType, + order_id: str, + trading_pair: str, + amount: Decimal, + order_type: OrderType, + price: Decimal): + """ + Calls create-order API end point to place an order, starts tracking the order and triggers order created event. + :param trade_type: BUY or SELL + :param order_id: Internal order id (also called client_order_id) + :param trading_pair: The market to place order + :param amount: The order amount (in base token value) + :param order_type: The order type + :param price: The order price + """ + if not order_type.is_limit_type(): + raise Exception(f"Unsupported order type: {order_type}") + trading_rule = self._trading_rules[trading_pair] + + amount = self.quantize_order_amount(trading_pair, amount) + price = self.quantize_order_price(trading_pair, price) + if amount < trading_rule.min_order_size: + raise ValueError(f"Buy order amount {amount} is lower than the minimum order size " + f"{trading_rule.min_order_size}.") + order_type_str = order_type.name.lower().split("_")[0] + api_params = {"symbol": convert_to_exchange_trading_pair(trading_pair), + "side": trade_type.name.lower(), + "type": order_type_str, + "price": f"{price:f}", + "quantity": f"{amount:f}", + "clientOrderId": order_id, + # Without strict validate, HitBTC might adjust order prices/sizes. + "strictValidate": "true", + } + if order_type is OrderType.LIMIT_MAKER: + api_params["postOnly"] = "true" + self.start_tracking_order(order_id, None, trading_pair, trade_type, price, amount, order_type) + try: + order_result = await self._api_request("POST", Constants.ENDPOINT["ORDER_CREATE"], api_params, True) + exchange_order_id = str(order_result["id"]) + tracked_order = self._in_flight_orders.get(order_id) + if tracked_order is not None: + self.logger().info(f"Created {order_type.name} {trade_type.name} order {order_id} for " + f"{amount} {trading_pair}.") + tracked_order.update_exchange_order_id(exchange_order_id) + if trade_type is TradeType.BUY: + event_tag = MarketEvent.BuyOrderCreated + event_cls = BuyOrderCreatedEvent + else: + event_tag = MarketEvent.SellOrderCreated + event_cls = SellOrderCreatedEvent + self.trigger_event(event_tag, + event_cls(self.current_timestamp, order_type, trading_pair, amount, price, order_id)) + except asyncio.CancelledError: + raise + except HitbtcAPIError as e: + error_reason = e.error_payload.get('error', {}).get('message') + self.stop_tracking_order(order_id) + self.logger().network( + f"Error submitting {trade_type.name} {order_type.name} order to {Constants.EXCHANGE_NAME} for " + f"{amount} {trading_pair} {price} - {error_reason}.", + exc_info=True, + app_warning_msg=(f"Error submitting order to {Constants.EXCHANGE_NAME} - {error_reason}.") + ) + self.trigger_event(MarketEvent.OrderFailure, + MarketOrderFailureEvent(self.current_timestamp, order_id, order_type)) + + def start_tracking_order(self, + order_id: str, + exchange_order_id: str, + trading_pair: str, + trade_type: TradeType, + price: Decimal, + amount: Decimal, + order_type: OrderType): + """ + Starts tracking an order by simply adding it into _in_flight_orders dictionary. + """ + self._in_flight_orders[order_id] = HitbtcInFlightOrder( + client_order_id=order_id, + exchange_order_id=exchange_order_id, + trading_pair=trading_pair, + order_type=order_type, + trade_type=trade_type, + price=price, + amount=amount + ) + + def stop_tracking_order(self, order_id: str): + """ + Stops tracking an order by simply removing it from _in_flight_orders dictionary. + """ + if order_id in self._in_flight_orders: + del self._in_flight_orders[order_id] + if order_id in self._order_not_found_records: + del self._order_not_found_records[order_id] + + async def _execute_cancel(self, trading_pair: str, order_id: str) -> str: + """ + Executes order cancellation process by first calling cancel-order API. The API result doesn't confirm whether + the cancellation is successful, it simply states it receives the request. + :param trading_pair: The market trading pair (Unused during cancel on HitBTC) + :param order_id: The internal order id + order.last_state to change to CANCELED + """ + order_was_cancelled = False + try: + tracked_order = self._in_flight_orders.get(order_id) + if tracked_order is None: + raise ValueError(f"Failed to cancel order - {order_id}. Order not found.") + if tracked_order.exchange_order_id is None: + await tracked_order.get_exchange_order_id() + # ex_order_id = tracked_order.exchange_order_id + await self._api_request("DELETE", + Constants.ENDPOINT["ORDER_DELETE"].format(id=order_id), + is_auth_required=True) + order_was_cancelled = True + except asyncio.CancelledError: + raise + except HitbtcAPIError as e: + err = e.error_payload.get('error', e.error_payload) + self._order_not_found_records[order_id] = self._order_not_found_records.get(order_id, 0) + 1 + if err.get('code') == 20002 and \ + self._order_not_found_records[order_id] >= self.ORDER_NOT_EXIST_CANCEL_COUNT: + order_was_cancelled = True + if order_was_cancelled: + self.logger().info(f"Successfully cancelled order {order_id} on {Constants.EXCHANGE_NAME}.") + self.stop_tracking_order(order_id) + self.trigger_event(MarketEvent.OrderCancelled, + OrderCancelledEvent(self.current_timestamp, order_id)) + tracked_order.cancelled_event.set() + return CancellationResult(order_id, True) + else: + self.logger().network( + f"Failed to cancel order {order_id}: {err.get('message', str(err))}", + exc_info=True, + app_warning_msg=f"Failed to cancel the order {order_id} on {Constants.EXCHANGE_NAME}. " + f"Check API key and network connection." + ) + return CancellationResult(order_id, False) + + async def _status_polling_loop(self): + """ + Periodically update user balances and order status via REST API. This serves as a fallback measure for web + socket API updates. + """ + while True: + try: + self._poll_notifier = asyncio.Event() + await self._poll_notifier.wait() + await safe_gather( + self._update_balances(), + self._update_order_status(), + ) + self._last_poll_timestamp = self.current_timestamp + except asyncio.CancelledError: + raise + except Exception as e: + self.logger().error(str(e), exc_info=True) + warn_msg = (f"Could not fetch account updates from {Constants.EXCHANGE_NAME}. " + "Check API key and network connection.") + self.logger().network("Unexpected error while fetching account updates.", exc_info=True, + app_warning_msg=warn_msg) + await asyncio.sleep(0.5) + + async def _update_balances(self): + """ + Calls REST API to update total and available balances. + """ + account_info = await self._api_request("GET", Constants.ENDPOINT["USER_BALANCES"], is_auth_required=True) + self._process_balance_message(account_info) + + async def _update_order_status(self): + """ + Calls REST API to get status update for each in-flight order. + """ + last_tick = int(self._last_poll_timestamp / Constants.UPDATE_ORDER_STATUS_INTERVAL) + current_tick = int(self.current_timestamp / Constants.UPDATE_ORDER_STATUS_INTERVAL) + + if current_tick > last_tick and len(self._in_flight_orders) > 0: + tracked_orders = list(self._in_flight_orders.values()) + tasks = [] + for tracked_order in tracked_orders: + # exchange_order_id = await tracked_order.get_exchange_order_id() + order_id = tracked_order.client_order_id + tasks.append(self._api_request("GET", + Constants.ENDPOINT["ORDER_STATUS"].format(id=order_id), + is_auth_required=True)) + self.logger().debug(f"Polling for order status updates of {len(tasks)} orders.") + responses = await safe_gather(*tasks, return_exceptions=True) + for response, tracked_order in zip(responses, tracked_orders): + client_order_id = tracked_order.client_order_id + if isinstance(response, HitbtcAPIError): + err = response.error_payload.get('error', response.error_payload) + if err.get('code') == 20002: + self._order_not_found_records[client_order_id] = \ + self._order_not_found_records.get(client_order_id, 0) + 1 + if self._order_not_found_records[client_order_id] < self.ORDER_NOT_EXIST_CONFIRMATION_COUNT: + # Wait until the order not found error have repeated a few times before actually treating + # it as failed. See: https://github.com/CoinAlpha/hummingbot/issues/601 + continue + self.trigger_event(MarketEvent.OrderFailure, + MarketOrderFailureEvent( + self.current_timestamp, client_order_id, tracked_order.order_type)) + self.stop_tracking_order(client_order_id) + else: + continue + elif "clientOrderId" not in response: + self.logger().info(f"_update_order_status clientOrderId not in resp: {response}") + continue + else: + self._process_order_message(response) + + def _process_order_message(self, order_msg: Dict[str, Any]): + """ + Updates in-flight order and triggers cancellation or failure event if needed. + :param order_msg: The order response from either REST or web socket API (they are of the same format) + Example Order: + { + "id": "4345613661", + "clientOrderId": "57d5525562c945448e3cbd559bd068c3", + "symbol": "BCCBTC", + "side": "sell", + "status": "new", + "type": "limit", + "timeInForce": "GTC", + "quantity": "0.013", + "price": "0.100000", + "cumQuantity": "0.000", + "postOnly": false, + "createdAt": "2017-10-20T12:17:12.245Z", + "updatedAt": "2017-10-20T12:17:12.245Z", + "reportType": "status" + } + """ + client_order_id = order_msg["clientOrderId"] + if client_order_id not in self._in_flight_orders: + return + tracked_order = self._in_flight_orders[client_order_id] + # Update order execution status + tracked_order.last_state = order_msg["status"] + # update order + tracked_order.executed_amount_base = Decimal(order_msg["cumQuantity"]) + tracked_order.executed_amount_quote = Decimal(order_msg["price"]) * Decimal(order_msg["cumQuantity"]) + + if tracked_order.is_cancelled: + self.logger().info(f"Successfully cancelled order {client_order_id}.") + self.stop_tracking_order(client_order_id) + self.trigger_event(MarketEvent.OrderCancelled, + OrderCancelledEvent(self.current_timestamp, client_order_id)) + tracked_order.cancelled_event.set() + elif tracked_order.is_failure: + self.logger().info(f"The market order {client_order_id} has failed according to order status API. ") + 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. + Example Trade: + { + "id": "4345697765", + "clientOrderId": "53b7cf917963464a811a4af426102c19", + "symbol": "ETHBTC", + "side": "sell", + "status": "filled", + "type": "limit", + "timeInForce": "GTC", + "quantity": "0.001", + "price": "0.053868", + "cumQuantity": "0.001", + "postOnly": false, + "createdAt": "2017-10-20T12:20:05.952Z", + "updatedAt": "2017-10-20T12:20:38.708Z", + "reportType": "trade", + "tradeQuantity": "0.001", + "tradePrice": "0.053868", + "tradeId": 55051694, + "tradeFee": "-0.000000005" + } + """ + tracked_orders = list(self._in_flight_orders.values()) + for order in tracked_orders: + await order.get_exchange_order_id() + track_order = [o for o in tracked_orders if trade_msg["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.get("tradePrice", "0"))), + Decimal(str(trade_msg.get("tradeQuantity", "0"))), + TradeFee(0.0, [(tracked_order.quote_asset, Decimal(str(trade_msg.get("tradeFee", "0"))))]), + exchange_trade_id=trade_msg["id"] + ) + ) + if math.isclose(tracked_order.executed_amount_base, tracked_order.amount) or \ + tracked_order.executed_amount_base >= tracked_order.amount or \ + tracked_order.is_done: + tracked_order.last_state = "FILLED" + self.logger().info(f"The {tracked_order.trade_type.name} order " + f"{tracked_order.client_order_id} has completed " + f"according to order status API.") + event_tag = MarketEvent.BuyOrderCompleted if tracked_order.trade_type is TradeType.BUY \ + else MarketEvent.SellOrderCompleted + event_class = BuyOrderCompletedEvent if tracked_order.trade_type is TradeType.BUY \ + else SellOrderCompletedEvent + await asyncio.sleep(0.1) + self.trigger_event(event_tag, + event_class(self.current_timestamp, + tracked_order.client_order_id, + tracked_order.base_asset, + tracked_order.quote_asset, + tracked_order.fee_asset, + tracked_order.executed_amount_base, + tracked_order.executed_amount_quote, + tracked_order.fee_paid, + tracked_order.order_type)) + self.stop_tracking_order(tracked_order.client_order_id) + + def _process_balance_message(self, balance_update): + local_asset_names = set(self._account_balances.keys()) + remote_asset_names = set() + for account in balance_update: + asset_name = translate_asset(account["currency"]) + self._account_available_balances[asset_name] = Decimal(str(account["available"])) + self._account_balances[asset_name] = Decimal(str(account["reserved"])) + Decimal(str(account["available"])) + remote_asset_names.add(asset_name) + + asset_names_to_remove = local_asset_names.difference(remote_asset_names) + for asset_name in asset_names_to_remove: + del self._account_available_balances[asset_name] + del self._account_balances[asset_name] + + async def cancel_all(self, timeout_seconds: float) -> List[CancellationResult]: + """ + Cancels all in-flight orders and waits for cancellation results. + Used by bot's top level stop and exit commands (cancelling outstanding orders on exit) + :param timeout_seconds: The timeout at which the operation will be canceled. + :returns List of CancellationResult which indicates whether each order is successfully cancelled. + """ + if self._trading_pairs is None: + raise Exception("cancel_all can only be used when trading_pairs are specified.") + open_orders = [o for o in self._in_flight_orders.values() if not o.is_done] + if len(open_orders) == 0: + return [] + tasks = [self._execute_cancel(o.trading_pair, o.client_order_id) for o in open_orders] + cancellation_results = [] + try: + async with timeout(timeout_seconds): + cancellation_results = await safe_gather(*tasks, return_exceptions=False) + except Exception: + self.logger().network( + "Unexpected error cancelling orders.", exc_info=True, + app_warning_msg=(f"Failed to cancel all orders on {Constants.EXCHANGE_NAME}. " + "Check API key and network connection.") + ) + return cancellation_results + + def tick(self, timestamp: float): + """ + Is called automatically by the clock for each clock's tick (1 second by default). + It checks if status polling task is due for execution. + """ + now = time.time() + poll_interval = (Constants.SHORT_POLL_INTERVAL + if now - self._user_stream_tracker.last_recv_time > 60.0 + else Constants.LONG_POLL_INTERVAL) + last_tick = int(self._last_timestamp / poll_interval) + current_tick = int(timestamp / poll_interval) + if current_tick > last_tick: + if not self._poll_notifier.is_set(): + self._poll_notifier.set() + self._last_timestamp = timestamp + + def get_fee(self, + base_currency: str, + quote_currency: str, + order_type: OrderType, + order_side: TradeType, + amount: Decimal, + price: Decimal = s_decimal_NaN) -> TradeFee: + """ + To get trading fee, this function is simplified by using fee override configuration. Most parameters to this + function are ignore except order_type. Use OrderType.LIMIT_MAKER to specify you want trading fee for + maker order. + """ + is_maker = order_type is OrderType.LIMIT_MAKER + return TradeFee(percent=self.estimate_fee_pct(is_maker)) + + async def _iter_user_event_queue(self) -> AsyncIterable[Dict[str, any]]: + while True: + try: + yield await self._user_stream_tracker.user_stream.get() + except asyncio.CancelledError: + raise + except Exception: + self.logger().network( + "Unknown error. Retrying after 1 seconds.", exc_info=True, + app_warning_msg=(f"Could not fetch user events from {Constants.EXCHANGE_NAME}. " + "Check API key and network connection.")) + await asyncio.sleep(1.0) + + async def _user_stream_event_listener(self): + """ + Listens to message in _user_stream_tracker.user_stream queue. The messages are put in by + HitbtcAPIUserStreamDataSource. + """ + async for event_message in self._iter_user_event_queue(): + try: + event_methods = [ + Constants.WS_METHODS["USER_ORDERS"], + Constants.WS_METHODS["USER_TRADES"], + ] + method: str = event_message.get("method", None) + params: str = event_message.get("params", None) + account_balances: list = event_message.get("result", None) + + if method not in event_methods and account_balances is None: + self.logger().error(f"Unexpected message in user stream: {event_message}.", exc_info=True) + continue + if method == Constants.WS_METHODS["USER_TRADES"]: + await self._process_trade_message(params) + elif method == Constants.WS_METHODS["USER_ORDERS"]: + for order_msg in params: + self._process_order_message(order_msg) + elif isinstance(account_balances, list) and "currency" in account_balances[0]: + self._process_balance_message(account_balances) + except asyncio.CancelledError: + raise + except Exception: + self.logger().error("Unexpected error in user stream listener loop.", exc_info=True) + await asyncio.sleep(5.0) + + # This is currently unused, but looks like a future addition. + async def get_open_orders(self) -> List[OpenOrder]: + result = await self._api_request("GET", Constants.ENDPOINT["USER_ORDERS"], is_auth_required=True) + ret_val = [] + for order in result: + if Constants.HBOT_BROKER_ID not in order["clientOrderId"]: + continue + if order["type"] != OrderType.LIMIT.name.lower(): + self.logger().info(f"Unsupported order type found: {order['type']}") + continue + ret_val.append( + OpenOrder( + client_order_id=order["clientOrderId"], + trading_pair=convert_from_exchange_trading_pair(order["symbol"]), + price=Decimal(str(order["price"])), + amount=Decimal(str(order["quantity"])), + executed_amount=Decimal(str(order["cumQuantity"])), + status=order["status"], + order_type=OrderType.LIMIT, + is_buy=True if order["side"].lower() == TradeType.BUY.name.lower() else False, + time=str_date_to_ts(order["createdAt"]), + exchange_order_id=order["id"] + ) + ) + return ret_val diff --git a/hummingbot/connector/exchange/hitbtc/hitbtc_in_flight_order.py b/hummingbot/connector/exchange/hitbtc/hitbtc_in_flight_order.py new file mode 100644 index 0000000000..54766be2f1 --- /dev/null +++ b/hummingbot/connector/exchange/hitbtc/hitbtc_in_flight_order.py @@ -0,0 +1,118 @@ +from decimal import Decimal +from typing import ( + Any, + Dict, + Optional, +) +import asyncio +from hummingbot.core.event.events import ( + OrderType, + TradeType +) +from hummingbot.connector.in_flight_order_base import InFlightOrderBase + +s_decimal_0 = Decimal(0) + + +class HitbtcInFlightOrder(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, + ) + self.trade_id_set = set() + self.cancelled_event = asyncio.Event() + + @property + def is_done(self) -> bool: + return self.last_state in {"filled", "canceled", "expired"} + + @property + def is_failure(self) -> bool: + return self.last_state in {"suspended"} + + @property + def is_cancelled(self) -> bool: + return self.last_state in {"canceled", "expired"} + + @classmethod + def from_json(cls, data: Dict[str, Any]) -> InFlightOrderBase: + """ + :param data: json data from API + :return: formatted InFlightOrder + """ + retval = HitbtcInFlightOrder( + 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 + Example Trade: + { + "id": "4345697765", + "clientOrderId": "53b7cf917963464a811a4af426102c19", + "symbol": "ETHBTC", + "side": "sell", + "status": "filled", + "type": "limit", + "timeInForce": "GTC", + "quantity": "0.001", + "price": "0.053868", + "cumQuantity": "0.001", + "postOnly": false, + "createdAt": "2017-10-20T12:20:05.952Z", + "updatedAt": "2017-10-20T12:20:38.708Z", + "reportType": "trade", + } + ... Trade variables are only included after fills. + { + "tradeQuantity": "0.001", + "tradePrice": "0.053868", + "tradeId": 55051694, + "tradeFee": "-0.000000005" + } + """ + self.executed_amount_base = Decimal(str(trade_update["cumQuantity"])) + if self.executed_amount_base <= s_decimal_0: + # No trades executed yet. + return False + trade_id = trade_update["updatedAt"] + if trade_id in self.trade_id_set: + # trade already recorded + return False + self.trade_id_set.add(trade_id) + self.fee_paid += Decimal(str(trade_update.get("tradeFee", "0"))) + self.executed_amount_quote += (Decimal(str(trade_update.get("tradePrice", "0"))) * + Decimal(str(trade_update.get("tradeQuantity", "0")))) + if not self.fee_asset: + self.fee_asset = self.quote_asset + return True diff --git a/hummingbot/connector/exchange/hitbtc/hitbtc_order_book.py b/hummingbot/connector/exchange/hitbtc/hitbtc_order_book.py new file mode 100644 index 0000000000..1a3c91a121 --- /dev/null +++ b/hummingbot/connector/exchange/hitbtc/hitbtc_order_book.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python + +import logging +from hummingbot.connector.exchange.hitbtc.hitbtc_constants import 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.hitbtc.hitbtc_order_book_message import HitbtcOrderBookMessage + +_logger = None + + +class HitbtcOrderBook(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: HitbtcOrderBookMessage + """ + + if metadata: + msg.update(metadata) + + return HitbtcOrderBookMessage( + 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: HitbtcOrderBookMessage + """ + return HitbtcOrderBookMessage( + 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: HitbtcOrderBookMessage + """ + + if metadata: + msg.update(metadata) + + return HitbtcOrderBookMessage( + 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: HitbtcOrderBookMessage + """ + return HitbtcOrderBookMessage( + 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: HitbtcOrderBookMessage + """ + + if metadata: + msg.update(metadata) + + msg.update({ + "exchange_order_id": msg.get("id"), + "trade_type": msg.get("side"), + "price": msg.get("price"), + "amount": msg.get("quantity"), + }) + + return HitbtcOrderBookMessage( + 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: HitbtcOrderBookMessage + """ + return HitbtcOrderBookMessage( + 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(self, snapshot: OrderBookMessage, diffs: List[OrderBookMessage]): + raise NotImplementedError(Constants.EXCHANGE_NAME + " order book needs to retain individual order data.") diff --git a/hummingbot/connector/exchange/hitbtc/hitbtc_order_book_message.py b/hummingbot/connector/exchange/hitbtc/hitbtc_order_book_message.py new file mode 100644 index 0000000000..fdc207d64d --- /dev/null +++ b/hummingbot/connector/exchange/hitbtc/hitbtc_order_book_message.py @@ -0,0 +1,81 @@ +#!/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, +) +from .hitbtc_constants import Constants +from .hitbtc_utils import ( + convert_from_exchange_trading_pair, +) + + +class HitbtcOrderBookMessage(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(HitbtcOrderBookMessage, 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 * 1e3) + else: + return -1 + + @property + def trade_id(self) -> int: + if self.type is OrderBookMessageType.TRADE: + return int(self.timestamp * 1e3) + return -1 + + @property + def trading_pair(self) -> str: + if "trading_pair" in self.content: + return self.content["trading_pair"] + elif "symbol" in self.content: + return convert_from_exchange_trading_pair(self.content["symbol"]) + + # The `asks` and `bids` properties are only used in the methods below. + # They are all replaced or unused in this connector: + # OrderBook.restore_from_snapshot_and_diffs + # OrderBookTracker._track_single_book + # MockAPIOrderBookDataSource.get_tracking_pairs + @property + def asks(self) -> List[OrderBookRow]: + raise NotImplementedError(Constants.EXCHANGE_NAME + " order book uses active_order_tracker.") + + @property + def bids(self) -> List[OrderBookRow]: + raise NotImplementedError(Constants.EXCHANGE_NAME + " order book uses active_order_tracker.") + + 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/hitbtc/hitbtc_order_book_tracker.py b/hummingbot/connector/exchange/hitbtc/hitbtc_order_book_tracker.py new file mode 100644 index 0000000000..d3161de17e --- /dev/null +++ b/hummingbot/connector/exchange/hitbtc/hitbtc_order_book_tracker.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python +import asyncio +import bisect +import logging +from hummingbot.connector.exchange.hitbtc.hitbtc_constants import 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.logger import HummingbotLogger +from hummingbot.core.data_type.order_book_tracker import OrderBookTracker +from hummingbot.connector.exchange.hitbtc.hitbtc_order_book_message import HitbtcOrderBookMessage +from hummingbot.connector.exchange.hitbtc.hitbtc_active_order_tracker import HitbtcActiveOrderTracker +from hummingbot.connector.exchange.hitbtc.hitbtc_api_order_book_data_source import HitbtcAPIOrderBookDataSource +from hummingbot.connector.exchange.hitbtc.hitbtc_order_book import HitbtcOrderBook + + +class HitbtcOrderBookTracker(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__(HitbtcAPIOrderBookDataSource(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, HitbtcOrderBook] = {} + self._saved_message_queues: Dict[str, Deque[HitbtcOrderBookMessage]] = \ + defaultdict(lambda: deque(maxlen=1000)) + self._active_order_trackers: Dict[str, HitbtcActiveOrderTracker] = defaultdict(HitbtcActiveOrderTracker) + 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[HitbtcOrderBookMessage] = deque() + self._past_diffs_windows[trading_pair] = past_diffs_window + + message_queue: asyncio.Queue = self._tracking_message_queues[trading_pair] + order_book: HitbtcOrderBook = self._order_books[trading_pair] + active_order_tracker: HitbtcActiveOrderTracker = self._active_order_trackers[trading_pair] + + last_message_timestamp: float = time.time() + diff_messages_accepted: int = 0 + + while True: + try: + message: HitbtcOrderBookMessage = None + saved_messages: Deque[HitbtcOrderBookMessage] = 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(f"Processed {diff_messages_accepted} order book diffs for {trading_pair}.") + diff_messages_accepted = 0 + last_message_timestamp = now + elif message.type is OrderBookMessageType.SNAPSHOT: + past_diffs: List[HitbtcOrderBookMessage] = 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(f"Processed order book snapshot for {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/bitmax/bitmax_order_book_tracker_entry.py b/hummingbot/connector/exchange/hitbtc/hitbtc_order_book_tracker_entry.py similarity index 56% rename from hummingbot/connector/exchange/bitmax/bitmax_order_book_tracker_entry.py rename to hummingbot/connector/exchange/hitbtc/hitbtc_order_book_tracker_entry.py index a97a33088a..5edfbadec0 100644 --- a/hummingbot/connector/exchange/bitmax/bitmax_order_book_tracker_entry.py +++ b/hummingbot/connector/exchange/hitbtc/hitbtc_order_book_tracker_entry.py @@ -1,21 +1,21 @@ from hummingbot.core.data_type.order_book import OrderBook from hummingbot.core.data_type.order_book_tracker_entry import OrderBookTrackerEntry -from hummingbot.connector.exchange.bitmax.bitmax_active_order_tracker import BitmaxActiveOrderTracker +from hummingbot.connector.exchange.hitbtc.hitbtc_active_order_tracker import HitbtcActiveOrderTracker -class BitmaxOrderBookTrackerEntry(OrderBookTrackerEntry): +class HitbtcOrderBookTrackerEntry(OrderBookTrackerEntry): def __init__( - self, trading_pair: str, timestamp: float, order_book: OrderBook, active_order_tracker: BitmaxActiveOrderTracker + self, trading_pair: str, timestamp: float, order_book: OrderBook, active_order_tracker: HitbtcActiveOrderTracker ): self._active_order_tracker = active_order_tracker - super(BitmaxOrderBookTrackerEntry, self).__init__(trading_pair, timestamp, order_book) + super(HitbtcOrderBookTrackerEntry, self).__init__(trading_pair, timestamp, order_book) def __repr__(self) -> str: return ( - f"BitmaxOrderBookTrackerEntry(trading_pair='{self._trading_pair}', timestamp='{self._timestamp}', " + f"HitbtcOrderBookTrackerEntry(trading_pair='{self._trading_pair}', timestamp='{self._timestamp}', " f"order_book='{self._order_book}')" ) @property - def active_order_tracker(self) -> BitmaxActiveOrderTracker: + def active_order_tracker(self) -> HitbtcActiveOrderTracker: return self._active_order_tracker diff --git a/hummingbot/connector/exchange/hitbtc/hitbtc_user_stream_tracker.py b/hummingbot/connector/exchange/hitbtc/hitbtc_user_stream_tracker.py new file mode 100644 index 0000000000..7b04002ccd --- /dev/null +++ b/hummingbot/connector/exchange/hitbtc/hitbtc_user_stream_tracker.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python + +import asyncio +import logging +from typing import ( + Optional, + List, +) +from hummingbot.core.data_type.user_stream_tracker_data_source import UserStreamTrackerDataSource +from hummingbot.logger import HummingbotLogger +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.hitbtc.hitbtc_api_user_stream_data_source import \ + HitbtcAPIUserStreamDataSource +from hummingbot.connector.exchange.hitbtc.hitbtc_auth import HitbtcAuth +from hummingbot.connector.exchange.hitbtc.hitbtc_constants import Constants + + +class HitbtcUserStreamTracker(UserStreamTracker): + _cbpust_logger: Optional[HummingbotLogger] = None + + @classmethod + def logger(cls) -> HummingbotLogger: + if cls._bust_logger is None: + cls._bust_logger = logging.getLogger(__name__) + return cls._bust_logger + + def __init__(self, + hitbtc_auth: Optional[HitbtcAuth] = None, + trading_pairs: Optional[List[str]] = []): + super().__init__() + self._hitbtc_auth: HitbtcAuth = hitbtc_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 = HitbtcAPIUserStreamDataSource( + hitbtc_auth=self._hitbtc_auth, + trading_pairs=self._trading_pairs + ) + return self._data_source + + @property + def exchange_name(self) -> str: + """ + *required + Name of the current exchange + """ + return Constants.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) diff --git a/hummingbot/connector/exchange/hitbtc/hitbtc_utils.py b/hummingbot/connector/exchange/hitbtc/hitbtc_utils.py new file mode 100644 index 0000000000..3f430227b0 --- /dev/null +++ b/hummingbot/connector/exchange/hitbtc/hitbtc_utils.py @@ -0,0 +1,174 @@ +import aiohttp +import asyncio +import random +import re +from dateutil.parser import parse as dateparse +from typing import ( + Any, + Dict, + Optional, + Tuple, +) + +from hummingbot.core.utils.tracking_nonce import get_tracking_nonce +from hummingbot.client.config.config_var import ConfigVar +from hummingbot.client.config.config_methods import using_exchange +from .hitbtc_constants import Constants + + +TRADING_PAIR_SPLITTER = re.compile(Constants.TRADING_PAIR_SPLITTER) + +CENTRALIZED = True + +EXAMPLE_PAIR = "BTC-USD" + +DEFAULT_FEES = [0.1, 0.25] + + +class HitbtcAPIError(IOError): + def __init__(self, error_payload: Dict[str, Any]): + super().__init__(str(error_payload)) + self.error_payload = error_payload + + +# convert date string to timestamp +def str_date_to_ts(date: str) -> int: + return int(dateparse(date).timestamp()) + + +# Request ID class +class RequestId: + """ + Generate request ids + """ + _request_id: int = 0 + + @classmethod + def generate_request_id(cls) -> int: + return get_tracking_nonce() + + +def split_trading_pair(trading_pair: str) -> Optional[Tuple[str, str]]: + try: + m = TRADING_PAIR_SPLITTER.match(trading_pair) + return m.group(1), m.group(2) + # Exceptions are now logged as warnings in trading pair fetcher + except Exception: + return None + + +def translate_asset(asset_name: str) -> str: + asset_replacements = [ + ("USD", "USDT"), + ] + for asset_replacement in asset_replacements: + for inv in [0, 1]: + if asset_name == asset_replacement[inv]: + return asset_replacement[(0 if inv else 1)] + return asset_name + + +def translate_assets(hb_trading_pair: str) -> str: + assets = hb_trading_pair.split('-') + for x in range(len(assets)): + assets[x] = translate_asset(assets[x]) + return '-'.join(assets) + + +def convert_from_exchange_trading_pair(ex_trading_pair: str) -> Optional[str]: + regex_match = split_trading_pair(ex_trading_pair) + if regex_match is None: + return None + # HitBTC uses uppercase (BTCUSDT) + base_asset, quote_asset = split_trading_pair(ex_trading_pair) + return translate_assets(f"{base_asset.upper()}-{quote_asset.upper()}") + + +def convert_to_exchange_trading_pair(hb_trading_pair: str) -> str: + # HitBTC uses uppercase (BTCUSDT) + return translate_assets(hb_trading_pair).replace("-", "").upper() + + +def get_new_client_order_id(is_buy: bool, trading_pair: str) -> str: + side = "B" if is_buy else "S" + symbols = trading_pair.split("-") + base = symbols[0].upper() + quote = symbols[1].upper() + base_str = f"{base[0]}{base[-1]}" + quote_str = f"{quote[0]}{quote[-1]}" + return f"{Constants.HBOT_BROKER_ID}-{side}-{base_str}{quote_str}-{get_tracking_nonce()}" + + +def retry_sleep_time(try_count: int) -> float: + random.seed() + randSleep = 1 + float(random.randint(1, 10) / 100) + return float(2 + float(randSleep * (1 + (try_count ** try_count)))) + + +async def aiohttp_response_with_errors(request_coroutine): + http_status, parsed_response, request_errors = None, None, False + try: + async with request_coroutine as response: + http_status = response.status + try: + parsed_response = await response.json() + except Exception: + request_errors = True + try: + parsed_response = str(await response.read()) + if len(parsed_response) > 100: + parsed_response = f"{parsed_response[:100]} ... (truncated)" + except Exception: + pass + TempFailure = (parsed_response is None or + (response.status not in [200, 201] and "error" not in parsed_response)) + if TempFailure: + parsed_response = response.reason if parsed_response is None else parsed_response + request_errors = True + except Exception: + request_errors = True + return http_status, parsed_response, request_errors + + +async def api_call_with_retries(method, + endpoint, + params: Optional[Dict[str, Any]] = None, + shared_client=None, + try_count: int = 0) -> Dict[str, Any]: + url = f"{Constants.REST_URL}/{endpoint}" + headers = {"Content-Type": "application/json"} + http_client = shared_client if shared_client is not None else aiohttp.ClientSession() + # Build request coro + response_coro = http_client.request(method=method.upper(), url=url, headers=headers, + params=params, timeout=Constants.API_CALL_TIMEOUT) + http_status, parsed_response, request_errors = await aiohttp_response_with_errors(response_coro) + if shared_client is None: + await http_client.close() + if request_errors or parsed_response is None: + if try_count < Constants.API_MAX_RETRIES: + try_count += 1 + time_sleep = retry_sleep_time(try_count) + print(f"Error fetching data from {url}. HTTP status is {http_status}. " + f"Retrying in {time_sleep:.0f}s.") + await asyncio.sleep(time_sleep) + return await api_call_with_retries(method=method, endpoint=endpoint, params=params, + shared_client=shared_client, try_count=try_count) + else: + raise HitbtcAPIError({"error": parsed_response, "status": http_status}) + return parsed_response + + +KEYS = { + "hitbtc_api_key": + ConfigVar(key="hitbtc_api_key", + prompt=f"Enter your {Constants.EXCHANGE_NAME} API key >>> ", + required_if=using_exchange("hitbtc"), + is_secure=True, + is_connect_key=True), + "hitbtc_secret_key": + ConfigVar(key="hitbtc_secret_key", + prompt=f"Enter your {Constants.EXCHANGE_NAME} secret key >>> ", + required_if=using_exchange("hitbtc"), + is_secure=True, + is_connect_key=True), +} diff --git a/hummingbot/connector/exchange/hitbtc/hitbtc_websocket.py b/hummingbot/connector/exchange/hitbtc/hitbtc_websocket.py new file mode 100644 index 0000000000..da65b869a2 --- /dev/null +++ b/hummingbot/connector/exchange/hitbtc/hitbtc_websocket.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python +import asyncio +import copy +import logging +import websockets +import json +from hummingbot.connector.exchange.hitbtc.hitbtc_constants import Constants + + +from typing import ( + Any, + AsyncIterable, + Dict, + Optional, +) +from websockets.exceptions import ConnectionClosed +from hummingbot.logger import HummingbotLogger +from hummingbot.connector.exchange.hitbtc.hitbtc_auth import HitbtcAuth +from hummingbot.connector.exchange.hitbtc.hitbtc_utils import ( + RequestId, + HitbtcAPIError, +) + +# reusable websocket class +# ToDo: We should eventually remove this class, and instantiate web socket connection normally (see Binance for example) + + +class HitbtcWebsocket(RequestId): + _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, + auth: Optional[HitbtcAuth] = None): + self._auth: Optional[HitbtcAuth] = auth + self._isPrivate = True if self._auth is not None else False + self._WS_URL = Constants.WS_PRIVATE_URL if self._isPrivate else Constants.WS_PUBLIC_URL + self._client: Optional[websockets.WebSocketClientProtocol] = None + + # connect to exchange + async def connect(self): + self._client = await websockets.connect(self._WS_URL) + + # if auth class was passed into websocket class + # we need to emit authenticated requests + if self._isPrivate: + auth_params = self._auth.generate_auth_dict_ws(self.generate_request_id()) + await self._emit("login", auth_params, no_id=True) + raw_msg_str: str = await asyncio.wait_for(self._client.recv(), timeout=Constants.MESSAGE_TIMEOUT) + json_msg = json.loads(raw_msg_str) + if json_msg.get("result") is not True: + err_msg = json_msg.get('error', {}).get('message') + raise HitbtcAPIError({"error": f"Failed to authenticate to websocket - {err_msg}."}) + + return self._client + + # disconnect from exchange + async def disconnect(self): + if self._client is None: + return + + await self._client.close() + + # receive & parse messages + async def _messages(self) -> AsyncIterable[Any]: + try: + while True: + try: + raw_msg_str: str = await asyncio.wait_for(self._client.recv(), timeout=Constants.MESSAGE_TIMEOUT) + try: + msg = json.loads(raw_msg_str) + # HitBTC doesn't support ping or heartbeat messages. + # Can handle them here if that changes - use `safe_ensure_future`. + yield msg + except ValueError: + continue + except asyncio.TimeoutError: + await asyncio.wait_for(self._client.ping(), timeout=Constants.PING_TIMEOUT) + except asyncio.TimeoutError: + self.logger().warning("WebSocket ping timed out. Going to reconnect...") + return + except ConnectionClosed: + return + finally: + await self.disconnect() + + # emit messages + async def _emit(self, method: str, data: Optional[Dict[str, Any]] = {}, no_id: bool = False) -> int: + id = self.generate_request_id() + + payload = { + "id": id, + "method": method, + "params": copy.deepcopy(data), + } + + await self._client.send(json.dumps(payload)) + + return id + + # request via websocket + async def request(self, method: str, data: Optional[Dict[str, Any]] = {}) -> int: + return await self._emit(method, data) + + # subscribe to a method + async def subscribe(self, + channel: str, + trading_pair: Optional[str] = None, + params: Optional[Dict[str, Any]] = {}) -> int: + if trading_pair is not None: + params['symbol'] = trading_pair + return await self.request(f"subscribe{channel}", params) + + # unsubscribe to a method + async def unsubscribe(self, + channel: str, + trading_pair: Optional[str] = None, + params: Optional[Dict[str, Any]] = {}) -> int: + if trading_pair is not None: + params['symbol'] = trading_pair + return await self.request(f"unsubscribe{channel}", params) + + # listen to messages by method + async def on_message(self) -> AsyncIterable[Any]: + async for msg in self._messages(): + yield msg diff --git a/hummingbot/connector/exchange/huobi/huobi_order_book_tracker.py b/hummingbot/connector/exchange/huobi/huobi_order_book_tracker.py index 39c83f429b..f66d0e0053 100644 --- a/hummingbot/connector/exchange/huobi/huobi_order_book_tracker.py +++ b/hummingbot/connector/exchange/huobi/huobi_order_book_tracker.py @@ -78,10 +78,8 @@ async def _order_book_diff_router(self): # Log some statistics. now: float = time.time() if int(now / 60.0) > int(last_message_timestamp / 60.0): - self.logger().debug("Diff messages processed: %d, rejected: %d, queued: %d", - messages_accepted, - messages_rejected, - messages_queued) + self.logger().debug(f"Diff messages processed: {messages_accepted}, " + f"rejected: {messages_rejected}, queued: {messages_queued}") messages_accepted = 0 messages_rejected = 0 messages_queued = 0 @@ -114,13 +112,12 @@ async def _track_single_book(self, trading_pair: str): # 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) + self.logger().debug(f"Processed {diff_messages_accepted} order book diffs for {trading_pair}.") diff_messages_accepted = 0 last_message_timestamp = now elif message.type is OrderBookMessageType.SNAPSHOT: order_book.apply_snapshot(message.bids, message.asks, message.update_id) - self.logger().debug("Processed order book snapshot for %s.", trading_pair) + self.logger().debug(f"Processed order book snapshot for {trading_pair}.") except asyncio.CancelledError: raise except Exception: 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 bc5d2c28e9..b3ee16a409 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 @@ -11,15 +11,11 @@ List, Optional ) -from decimal import Decimal import time import ujson import websockets from websockets.exceptions import ConnectionClosed -import requests -import cachetools.func - from hummingbot.core.utils.async_utils import safe_gather from hummingbot.core.data_type.order_book_tracker_data_source import OrderBookTrackerDataSource from hummingbot.core.data_type.order_book_message import OrderBookMessage @@ -130,21 +126,6 @@ async def _inner_messages(self, finally: await ws.close() - @staticmethod - @cachetools.func.ttl_cache(ttl=10) - def get_mid_price(trading_pair: str) -> Optional[Decimal]: - from hummingbot.connector.exchange.kraken.kraken_utils import convert_to_exchange_trading_pair - - KRAKEN_PRICE_URL = "https://api.kraken.com/0/public/Ticker?pair=" - k_pair = convert_to_exchange_trading_pair(trading_pair) - resp = requests.get(url=KRAKEN_PRICE_URL + k_pair) - resp_json = resp.json() - if len(resp_json["error"]) == 0: - for record in resp_json["result"]: # assume only one pair is received - record = resp_json["result"][record] - result = (Decimal(record["a"][0]) + Decimal(record["b"][0])) / Decimal("2") - return result - @staticmethod async def fetch_trading_pairs() -> List[str]: try: diff --git a/hummingbot/connector/exchange/kraken/kraken_order_book_tracker.py b/hummingbot/connector/exchange/kraken/kraken_order_book_tracker.py index 8ac727f7d5..cce7530271 100644 --- a/hummingbot/connector/exchange/kraken/kraken_order_book_tracker.py +++ b/hummingbot/connector/exchange/kraken/kraken_order_book_tracker.py @@ -78,9 +78,7 @@ async def _order_book_diff_router(self): # Log some statistics. now: float = time.time() if int(now / 60.0) > int(last_message_timestamp / 60.0): - self.logger().debug("Diff messages processed: %d, rejected: %d", - messages_accepted, - messages_rejected) + self.logger().debug(f"Diff messages processed: {messages_accepted}, rejected: {messages_rejected}") messages_accepted = 0 messages_rejected = 0 diff --git a/hummingbot/connector/exchange/kucoin/kucoin_api_order_book_data_source.py b/hummingbot/connector/exchange/kucoin/kucoin_api_order_book_data_source.py index cb49ea881f..f46aa80358 100644 --- a/hummingbot/connector/exchange/kucoin/kucoin_api_order_book_data_source.py +++ b/hummingbot/connector/exchange/kucoin/kucoin_api_order_book_data_source.py @@ -4,14 +4,11 @@ import aiohttp import asyncio from async_timeout import timeout -import cachetools.func from collections import defaultdict -from decimal import Decimal from enum import Enum import json import logging import pandas as pd -import requests import time from typing import ( Any, @@ -319,18 +316,6 @@ async def get_trading_pairs(self) -> List[str]: ) return self._trading_pairs - @staticmethod - @cachetools.func.ttl_cache(ttl=10) - def get_mid_price(trading_pair: str) -> Optional[Decimal]: - resp = requests.get(url=TICKER_PRICE_CHANGE_URL) - records = resp.json() - result = None - for record in records["data"]["ticker"]: - if trading_pair == record["symbolName"] and record["buy"] is not None and record["sell"] is not None: - result = (Decimal(record["buy"]) + Decimal(record["sell"])) / Decimal("2") - break - return result - @staticmethod async def fetch_trading_pairs() -> List[str]: async with aiohttp.ClientSession() as client: diff --git a/hummingbot/connector/exchange/kucoin/kucoin_order_book_tracker.py b/hummingbot/connector/exchange/kucoin/kucoin_order_book_tracker.py index 8b42c7db4f..10764f2b08 100644 --- a/hummingbot/connector/exchange/kucoin/kucoin_order_book_tracker.py +++ b/hummingbot/connector/exchange/kucoin/kucoin_order_book_tracker.py @@ -75,10 +75,8 @@ async def _order_book_diff_router(self): # Log some statistics. now: float = time.time() if int(now / 60.0) > int(last_message_timestamp / 60.0): - self.logger().debug("Diff messages processed: %d, rejected: %d, queued: %d", - messages_accepted, - messages_rejected, - messages_queued) + self.logger().debug(f"Diff messages processed: {messages_accepted}, " + f"rejected: {messages_rejected}, queued: {messages_queued}") messages_accepted = 0 messages_rejected = 0 messages_queued = 0 @@ -127,8 +125,7 @@ async def _track_single_book(self, trading_pair: str): # 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) + self.logger().debug(f"Processed {diff_messages_accepted} order book diffs for {trading_pair}.") diff_messages_accepted = 0 last_message_timestamp = now elif message.type is OrderBookMessageType.SNAPSHOT: @@ -142,7 +139,7 @@ async def _track_single_book(self, trading_pair: str): 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) + self.logger().debug(f"Processed order book snapshot for {trading_pair}.") except asyncio.CancelledError: raise except Exception: diff --git a/hummingbot/connector/exchange/liquid/liquid_api_order_book_data_source.py b/hummingbot/connector/exchange/liquid/liquid_api_order_book_data_source.py index 454686452c..3e01510d95 100644 --- a/hummingbot/connector/exchange/liquid/liquid_api_order_book_data_source.py +++ b/hummingbot/connector/exchange/liquid/liquid_api_order_book_data_source.py @@ -4,10 +4,7 @@ import pandas as pd import time from typing import Any, AsyncIterable, Dict, List, Optional -from decimal import Decimal import ujson -import requests -import cachetools.func import websockets from websockets.exceptions import ConnectionClosed @@ -162,19 +159,6 @@ def filter_market_data(cls, exchange_markets_data) -> (List[dict]): if item['disabled'] is False ] - @staticmethod - @cachetools.func.ttl_cache(ttl=10) - def get_mid_price(trading_pair: str) -> Optional[Decimal]: - resp = requests.get(url=Constants.GET_EXCHANGE_MARKETS_URL) - records = resp.json() - result = None - for record in records: - pair = f"{record['base_currency']}-{record['quoted_currency']}" - if trading_pair == pair and record["market_ask"] is not None and record["market_bid"] is not None: - result = (Decimal(record["market_ask"]) + Decimal(record["market_bid"])) / Decimal("2") - break - return result - @staticmethod async def fetch_trading_pairs() -> List[str]: try: diff --git a/hummingbot/connector/exchange/liquid/liquid_order_book_tracker.py b/hummingbot/connector/exchange/liquid/liquid_order_book_tracker.py index 75b2d50f68..d9c11e0278 100644 --- a/hummingbot/connector/exchange/liquid/liquid_order_book_tracker.py +++ b/hummingbot/connector/exchange/liquid/liquid_order_book_tracker.py @@ -80,10 +80,8 @@ async def _order_book_diff_router(self): # Log some statistics. now: float = time.time() if int(now / 60.0) > int(last_message_timestamp / 60.0): - self.logger().debug("Diff messages processed: %d, rejected %d, queued: %d", - messages_accepted, - messages_rejected, - messages_queued) + self.logger().debug(f"Diff messages processed: {messages_accepted}, " + f"rejected: {messages_rejected}, queued: {messages_queued}") messages_accepted = 0 messages_rejected = 0 messages_queued = 0 @@ -146,15 +144,14 @@ async def _track_single_book(self, trading_pair: str): # 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) + self.logger().debug(f"Processed {diff_messages_accepted} order book diffs for {trading_pair}.") diff_messages_accepted = 0 last_message_timestamp = now elif message.type is OrderBookMessageType.SNAPSHOT: past_diffs: List[OrderBookMessage] = list(past_diffs_window) past_diffs_window.append(message) order_book.restore_from_snapshot_and_diffs(message, past_diffs) - self.logger().debug("Processed order book snapshot for %s.", trading_pair) + self.logger().debug(f"Processed order book snapshot for {trading_pair}.") except asyncio.CancelledError: raise except Exception: diff --git a/hummingbot/connector/exchange/loopring/loopring_api_order_book_data_source.py b/hummingbot/connector/exchange/loopring/loopring_api_order_book_data_source.py index c227739459..8773fb2bbf 100644 --- a/hummingbot/connector/exchange/loopring/loopring_api_order_book_data_source.py +++ b/hummingbot/connector/exchange/loopring/loopring_api_order_book_data_source.py @@ -1,18 +1,9 @@ #!/usr/bin/env python import asyncio -from decimal import Decimal - import aiohttp import logging -# import pandas as pd -# import math - -import requests -import cachetools.func - from typing import AsyncIterable, Dict, List, Optional, Any - import time import ujson import websockets @@ -123,17 +114,6 @@ async def _inner_messages(self, ws: websockets.WebSocketClientProtocol) -> Async finally: await ws.close() - @staticmethod - @cachetools.func.ttl_cache(ttl=10) - def get_mid_price(trading_pair: str) -> Optional[Decimal]: - resp = requests.get(url=LOOPRING_PRICE_URL, params={"market": trading_pair}) - record = resp.json() - if record["resultInfo"]["code"] == 0: - data = record["data"] - mid_price = (Decimal(data[9]) + Decimal(data[10])) / 2 - - return mid_price - @staticmethod async def fetch_trading_pairs() -> List[str]: try: diff --git a/hummingbot/connector/exchange/loopring/loopring_order_book_tracker.py b/hummingbot/connector/exchange/loopring/loopring_order_book_tracker.py index 1f43d627d4..0a9decf1da 100644 --- a/hummingbot/connector/exchange/loopring/loopring_order_book_tracker.py +++ b/hummingbot/connector/exchange/loopring/loopring_order_book_tracker.py @@ -90,7 +90,7 @@ async def _track_single_book(self, trading_pair: str): elif message.type is OrderBookMessageType.SNAPSHOT: s_bids, s_asks = active_order_tracker.convert_snapshot_message_to_order_book_row(message) order_book.apply_snapshot(s_bids, s_asks, message.timestamp) - self.logger().debug("Processed order book snapshot for %s.", trading_pair) + self.logger().debug(f"Processed order book snapshot for {trading_pair}.") except asyncio.CancelledError: raise diff --git a/hummingbot/connector/exchange/okex/okex_api_order_book_data_source.py b/hummingbot/connector/exchange/okex/okex_api_order_book_data_source.py index 105982bb79..4aab780846 100644 --- a/hummingbot/connector/exchange/okex/okex_api_order_book_data_source.py +++ b/hummingbot/connector/exchange/okex/okex_api_order_book_data_source.py @@ -14,9 +14,6 @@ List, Optional, ) -from decimal import Decimal -import requests -import cachetools.func import websockets from websockets.exceptions import ConnectionClosed @@ -28,7 +25,6 @@ from hummingbot.connector.exchange.okex.okex_order_book import OkexOrderBook from hummingbot.connector.exchange.okex.constants import ( OKEX_SYMBOLS_URL, - OKEX_PRICE_URL, OKEX_DEPTH_URL, OKEX_WS_URI, ) @@ -80,17 +76,8 @@ async def get_active_exchange_markets(cls) -> pd.DataFrame: all_markets.rename({"quote_volume_24h": "volume", "last": "price"}, axis="columns", inplace=True) - return all_markets - @staticmethod - @cachetools.func.ttl_cache(ttl=10) - def get_mid_price(trading_pair: str) -> Optional[Decimal]: - resp = requests.get(url=OKEX_PRICE_URL.format(trading_pair=trading_pair)) - record = resp.json() - if 'best_ask' in record and 'best_bid' in record: - return (Decimal(record["best_ask"]) + Decimal(record["best_bid"])) / Decimal("2") - @staticmethod async def fetch_trading_pairs() -> List[str]: # Returns a List of str, representing each active trading pair on the exchange. diff --git a/hummingbot/connector/exchange/okex/okex_order_book_tracker.py b/hummingbot/connector/exchange/okex/okex_order_book_tracker.py index e29a82c743..a09a5731fe 100644 --- a/hummingbot/connector/exchange/okex/okex_order_book_tracker.py +++ b/hummingbot/connector/exchange/okex/okex_order_book_tracker.py @@ -69,10 +69,8 @@ async def _order_book_diff_router(self): # Log some statistics. now: float = time.time() if int(now / 60.0) > int(last_message_timestamp / 60.0): - self.logger().debug("Diff messages processed: %d, rejected: %d, queued: %d", - messages_accepted, - messages_rejected, - messages_queued) + self.logger().debug(f"Diff messages processed: {messages_accepted}, " + f"rejected: {messages_rejected}, queued: {messages_queued}") messages_accepted = 0 messages_rejected = 0 messages_queued = 0 @@ -107,13 +105,12 @@ async def _track_single_book(self, trading_pair: str): # 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) + self.logger().debug(f"Processed {diff_messages_accepted} order book diffs for {trading_pair}.") diff_messages_accepted = 0 last_message_timestamp = now elif message.type is OrderBookMessageType.SNAPSHOT: order_book.apply_snapshot(message.bids, message.asks, message.update_id) - self.logger().debug("Processed order book snapshot for %s.", trading_pair) + self.logger().debug(f"Processed order book snapshot for {trading_pair}.") except asyncio.CancelledError: raise except Exception: diff --git a/hummingbot/connector/exchange/probit/probit_order_book_tracker.py b/hummingbot/connector/exchange/probit/probit_order_book_tracker.py index e7ac692ba1..cb5f4f9ab4 100644 --- a/hummingbot/connector/exchange/probit/probit_order_book_tracker.py +++ b/hummingbot/connector/exchange/probit/probit_order_book_tracker.py @@ -85,8 +85,7 @@ async def _track_single_book(self, trading_pair: str): # 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) + self.logger().debug(f"Processed {diff_messages_accepted} order book diffs for {trading_pair}.") diff_messages_accepted = 0 last_message_timestamp = now elif message.type is OrderBookMessageType.SNAPSHOT: @@ -100,7 +99,7 @@ async def _track_single_book(self, trading_pair: str): 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) + self.logger().debug(f"Processed order book snapshot for {trading_pair}.") except asyncio.CancelledError: raise except Exception: diff --git a/hummingbot/connector/exchange/radar_relay/radar_relay_order_book_tracker.py b/hummingbot/connector/exchange/radar_relay/radar_relay_order_book_tracker.py index 65c6da593f..34c35917e2 100644 --- a/hummingbot/connector/exchange/radar_relay/radar_relay_order_book_tracker.py +++ b/hummingbot/connector/exchange/radar_relay/radar_relay_order_book_tracker.py @@ -128,10 +128,8 @@ async def _order_book_diff_router(self): # Log some statistics. now: float = time.time() if int(now / 60.0) > int(last_message_timestamp / 60.0): - self.logger().debug("Diff messages processed: %d, rejected: %d, queued: %d", - messages_accepted, - messages_rejected, - messages_queued) + self.logger().debug(f"Diff messages processed: {messages_accepted}, " + f"rejected: {messages_rejected}, queued: {messages_queued}") messages_accepted = 0 messages_rejected = 0 messages_queued = 0 @@ -179,8 +177,7 @@ async def _track_single_book(self, trading_pair: str): # 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) + self.logger().debug(f"Processed {diff_messages_accepted} order book diffs for {trading_pair}.") diff_messages_accepted = 0 last_message_timestamp = now elif message.type is OrderBookMessageType.SNAPSHOT: @@ -194,7 +191,7 @@ async def _track_single_book(self, trading_pair: str): 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) + self.logger().debug(f"Processed order book snapshot for {trading_pair}.") except asyncio.CancelledError: raise except Exception: diff --git a/hummingbot/core/data_type/order_book_tracker.py b/hummingbot/core/data_type/order_book_tracker.py index 36ca95179c..b5a92b2d24 100644 --- a/hummingbot/core/data_type/order_book_tracker.py +++ b/hummingbot/core/data_type/order_book_tracker.py @@ -264,14 +264,13 @@ async def _track_single_book(self, trading_pair: str): # 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) + self.logger().debug(f"Processed {diff_messages_accepted} order book diffs for {trading_pair}.") diff_messages_accepted = 0 last_message_timestamp = now elif message.type is OrderBookMessageType.SNAPSHOT: past_diffs: List[OrderBookMessage] = list(past_diffs_window) order_book.restore_from_snapshot_and_diffs(message, past_diffs) - self.logger().debug("Processed order book snapshot for %s.", trading_pair) + self.logger().debug(f"Processed order book snapshot for {trading_pair}.") except asyncio.CancelledError: raise except Exception: @@ -307,9 +306,7 @@ async def _emit_trade_event_loop(self): # Log some statistics. now: float = time.time() if int(now / 60.0) > int(last_message_timestamp / 60.0): - self.logger().debug("Trade messages processed: %d, rejected: %d", - messages_accepted, - messages_rejected) + self.logger().debug(f"Trade messages processed: {messages_accepted}, rejected: {messages_rejected}") messages_accepted = 0 messages_rejected = 0 diff --git a/hummingbot/core/rate_oracle/__init__.py b/hummingbot/core/rate_oracle/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/hummingbot/core/rate_oracle/rate_oracle.py b/hummingbot/core/rate_oracle/rate_oracle.py new file mode 100644 index 0000000000..5fd847350c --- /dev/null +++ b/hummingbot/core/rate_oracle/rate_oracle.py @@ -0,0 +1,286 @@ +import asyncio +import logging +from typing import ( + Dict, + Optional, + List +) +from decimal import Decimal +import aiohttp +from enum import Enum +from hummingbot.logger import HummingbotLogger +from hummingbot.core.network_base import NetworkBase, NetworkStatus +from hummingbot.core.utils.async_utils import safe_ensure_future +from hummingbot.connector.exchange.binance.binance_utils import convert_from_exchange_trading_pair as \ + binance_convert_from_exchange_pair +from hummingbot.core.rate_oracle.utils import find_rate +from hummingbot.core.utils.async_utils import safe_gather +from hummingbot.core.utils import async_ttl_cache + + +class RateOracleSource(Enum): + """ + Supported sources for RateOracle + """ + binance = 0 + coingecko = 1 + + +class RateOracle(NetworkBase): + """ + RateOracle provides conversion rates for any given pair token symbols in both async and sync fashions. + It achieves this by query URL on a given source for prices and store them, either in cache or as an object member. + The find_rate is then used on these prices to find a rate on a given pair. + """ + # Set these below class members before query for rates + source: RateOracleSource = RateOracleSource.binance + global_token: str = "USDT" + global_token_symbol: str = "$" + + _logger: Optional[HummingbotLogger] = None + _shared_instance: "RateOracle" = None + _shared_client: Optional[aiohttp.ClientSession] = None + _cgecko_supported_vs_tokens: List[str] = [] + + binance_price_url = "https://api.binance.com/api/v3/ticker/bookTicker" + binance_us_price_url = "https://api.binance.us/api/v3/ticker/bookTicker" + coingecko_usd_price_url = "https://api.coingecko.com/api/v3/coins/markets?vs_currency={}&order=market_cap_desc" \ + "&per_page=250&page={}&sparkline=false" + coingecko_supported_vs_tokens_url = "https://api.coingecko.com/api/v3/simple/supported_vs_currencies" + + @classmethod + def get_instance(cls) -> "RateOracle": + if cls._shared_instance is None: + cls._shared_instance = RateOracle() + return cls._shared_instance + + @classmethod + def logger(cls) -> HummingbotLogger: + if cls._logger is None: + cls._logger = logging.getLogger(__name__) + return cls._logger + + def __init__(self): + super().__init__() + self._check_network_interval = 30.0 + self._ev_loop = asyncio.get_event_loop() + self._prices: Dict[str, Decimal] = {} + self._fetch_price_task: Optional[asyncio.Task] = None + self._ready_event = asyncio.Event() + + @classmethod + async def _http_client(cls) -> aiohttp.ClientSession: + if cls._shared_client is None: + cls._shared_client = aiohttp.ClientSession() + return cls._shared_client + + async def get_ready(self): + """ + The network is ready when it first successfully get prices for a given source. + """ + try: + if not self._ready_event.is_set(): + await self._ready_event.wait() + except asyncio.CancelledError: + raise + except Exception: + self.logger().error("Unexpected error while waiting for data feed to get ready.", + exc_info=True) + + @property + def name(self) -> str: + return "rate_oracle" + + @property + def prices(self) -> Dict[str, Decimal]: + """ + Actual prices retrieved from URL + """ + return self._prices.copy() + + def rate(self, pair: str) -> Decimal: + """ + Finds a conversion rate for a given symbol, this can be direct or indirect prices as long as it can find a route + to achieve this. + :param pair: A trading pair, e.g. BTC-USDT + :return A conversion rate + """ + return find_rate(self._prices, pair) + + @classmethod + async def rate_async(cls, pair: str) -> Decimal: + """ + Finds a conversion rate in an async operation, it is a class method which can be used directly without having to + start the RateOracle network. + :param pair: A trading pair, e.g. BTC-USDT + :return A conversion rate + """ + prices = await cls.get_prices() + return find_rate(prices, pair) + + @classmethod + async def global_rate(cls, token: str) -> Decimal: + """ + Finds a conversion rate of a given token to a global token + :param token: A token symbol, e.g. BTC + :return A conversion rate + """ + prices = await cls.get_prices() + pair = token + "-" + cls.global_token + return find_rate(prices, pair) + + @classmethod + async def global_value(cls, token: str, amount: Decimal) -> Decimal: + """ + Finds a value of a given token amount in a global token unit + :param token: A token symbol, e.g. BTC + :param amount: An amount of token to be converted to value + :return A value of the token in global token unit + """ + rate = await cls.global_rate(token) + rate = Decimal("0") if rate is None else rate + return amount * rate + + async def fetch_price_loop(self): + while True: + try: + self._prices = await self.get_prices() + if self._prices: + self._ready_event.set() + except asyncio.CancelledError: + raise + except Exception: + self.logger().network(f"Error fetching new prices from {self.source.name}.", exc_info=True, + app_warning_msg=f"Couldn't fetch newest prices from {self.source.name}.") + await asyncio.sleep(1) + + @classmethod + async def get_prices(cls) -> Dict[str, Decimal]: + """ + Fetches prices of a specified source + :return A dictionary of trading pairs and prices + """ + if cls.source == RateOracleSource.binance: + return await cls.get_binance_prices() + elif cls.source == RateOracleSource.coingecko: + return await cls.get_coingecko_prices(cls.global_token) + else: + raise NotImplementedError + + @classmethod + @async_ttl_cache(ttl=1, maxsize=1) + async def get_binance_prices(cls) -> Dict[str, Decimal]: + """ + Fetches Binance prices from binance.com and binance.us where only USD pairs from binance.us prices are added + to the prices dictionary. + :return A dictionary of trading pairs and prices + """ + results = {} + tasks = [cls.get_binance_prices_by_domain(cls.binance_price_url), + cls.get_binance_prices_by_domain(cls.binance_us_price_url, "USD")] + task_results = await safe_gather(*tasks, return_exceptions=True) + for task_result in task_results: + if isinstance(task_result, Exception): + cls.logger().error("Unexpected error while retrieving rates from Coingecko. " + "Check the log file for more info.") + break + else: + results.update(task_result) + return results + + @classmethod + async def get_binance_prices_by_domain(cls, url: str, quote_symbol: str = None) -> Dict[str, Decimal]: + """ + Fetches binance prices + :param url: A URL end point + :param quote_symbol: A quote symbol, if specified only pairs with the quote symbol are included for prices + :return A dictionary of trading pairs and prices + """ + results = {} + client = await cls._http_client() + async with client.request("GET", url) as resp: + records = await resp.json() + for record in records: + trading_pair = binance_convert_from_exchange_pair(record["symbol"]) + if quote_symbol is not None: + base, quote = trading_pair.split("-") + if quote != quote_symbol: + continue + if trading_pair and record["bidPrice"] is not None and record["askPrice"] is not None: + results[trading_pair] = (Decimal(record["bidPrice"]) + Decimal(record["askPrice"])) / Decimal( + "2") + return results + + @classmethod + @async_ttl_cache(ttl=30, maxsize=1) + async def get_coingecko_prices(cls, vs_currency: str) -> Dict[str, Decimal]: + """ + Fetches CoinGecko prices for the top 1000 token (order by market cap), each API query returns 250 results, + hence it queries 4 times concurrently. + :param vs_currency: A currency (crypto or fiat) to get prices of tokens in, see + https://api.coingecko.com/api/v3/simple/supported_vs_currencies for the current supported list + :return A dictionary of trading pairs and prices + """ + results = {} + if not cls._cgecko_supported_vs_tokens: + client = await cls._http_client() + async with client.request("GET", cls.coingecko_supported_vs_tokens_url) as resp: + records = await resp.json() + cls._cgecko_supported_vs_tokens = records + if vs_currency.lower() not in cls._cgecko_supported_vs_tokens: + vs_currency = "usd" + tasks = [cls.get_coingecko_prices_by_page(vs_currency, i) for i in range(1, 5)] + task_results = await safe_gather(*tasks, return_exceptions=True) + for task_result in task_results: + if isinstance(task_result, Exception): + cls.logger().error("Unexpected error while retrieving rates from Coingecko. " + "Check the log file for more info.") + break + else: + results.update(task_result) + return results + + @classmethod + async def get_coingecko_prices_by_page(cls, vs_currency: str, page_no: int) -> Dict[str, Decimal]: + """ + Fetches CoinGecko prices by page number. + :param vs_currency: A currency (crypto or fiat) to get prices of tokens in, see + https://api.coingecko.com/api/v3/simple/supported_vs_currencies for the current supported list + :param page_no: The page number + :return A dictionary of trading pairs and prices (250 results max) + """ + results = {} + client = await cls._http_client() + async with client.request("GET", cls.coingecko_usd_price_url.format(vs_currency, page_no)) as resp: + records = await resp.json() + for record in records: + pair = f'{record["symbol"].upper()}-{vs_currency.upper()}' + if record["current_price"]: + results[pair] = Decimal(str(record["current_price"])) + return results + + async def start_network(self): + await self.stop_network() + self._fetch_price_task = safe_ensure_future(self.fetch_price_loop()) + + async def stop_network(self): + if self._fetch_price_task is not None: + self._fetch_price_task.cancel() + self._fetch_price_task = None + + async def check_network(self) -> NetworkStatus: + try: + prices = await self.get_prices() + if not prices: + raise Exception(f"Error fetching new prices from {self.source.name}.") + except asyncio.CancelledError: + raise + except Exception: + return NetworkStatus.NOT_CONNECTED + return NetworkStatus.CONNECTED + + def start(self): + NetworkBase.start(self) + + def stop(self): + NetworkBase.stop(self) diff --git a/hummingbot/core/rate_oracle/utils.py b/hummingbot/core/rate_oracle/utils.py new file mode 100644 index 0000000000..261c6dae79 --- /dev/null +++ b/hummingbot/core/rate_oracle/utils.py @@ -0,0 +1,32 @@ +from typing import Dict +from decimal import Decimal + + +def find_rate(prices: Dict[str, Decimal], pair: str) -> Decimal: + ''' + Finds exchange rate for a given trading pair from a dictionary of prices + For example, given prices of {"HBOT-USDT": Decimal("100"), "AAVE-USDT": Decimal("50"), "USDT-GBP": Decimal("0.75")} + A rate for USDT-HBOT will be 1 / 100 + A rate for HBOT-AAVE will be 100 / 50 + A rate for AAVE-HBOT will be 50 / 100 + A rate for HBOT-GBP will be 100 * 0.75 + :param prices: The dictionary of trading pairs and their prices + :param pair: The trading pair + ''' + if pair in prices: + return prices[pair] + base, quote = pair.split("-") + if base == quote: + return Decimal("1") + reverse_pair = f"{quote}-{base}" + if reverse_pair in prices: + return Decimal("1") / prices[reverse_pair] + base_prices = {k: v for k, v in prices.items() if k.startswith(f"{base}-")} + for base_pair, proxy_price in base_prices.items(): + link_quote = base_pair.split("-")[1] + link_pair = f"{link_quote}-{quote}" + if link_pair in prices: + return proxy_price * prices[link_pair] + common_denom_pair = f"{quote}-{link_quote}" + if common_denom_pair in prices: + return proxy_price / prices[common_denom_pair] diff --git a/hummingbot/core/utils/estimate_fee.py b/hummingbot/core/utils/estimate_fee.py index 78823e425e..5a52568861 100644 --- a/hummingbot/core/utils/estimate_fee.py +++ b/hummingbot/core/utils/estimate_fee.py @@ -2,16 +2,11 @@ from hummingbot.core.event.events import TradeFee, TradeFeeType from hummingbot.client.config.fee_overrides_config_map import fee_overrides_config_map from hummingbot.client.settings import CONNECTOR_SETTINGS -from hummingbot.core.utils.eth_gas_station_lookup import get_gas_price, get_gas_limit def estimate_fee(exchange: str, is_maker: bool) -> TradeFee: if exchange not in CONNECTOR_SETTINGS: raise Exception(f"Invalid connector. {exchange} does not exist in CONNECTOR_SETTINGS") - use_gas = CONNECTOR_SETTINGS[exchange].use_eth_gas_lookup - if use_gas: - gas_amount = get_gas_price(in_gwei=False) * get_gas_limit(exchange) - return TradeFee(percent=0, flat_fees=[("ETH", gas_amount)]) fee_type = CONNECTOR_SETTINGS[exchange].fee_type fee_token = CONNECTOR_SETTINGS[exchange].fee_token default_fees = CONNECTOR_SETTINGS[exchange].default_fees diff --git a/hummingbot/core/utils/eth_gas_station_lookup.py b/hummingbot/core/utils/eth_gas_station_lookup.py deleted file mode 100644 index ee56502631..0000000000 --- a/hummingbot/core/utils/eth_gas_station_lookup.py +++ /dev/null @@ -1,178 +0,0 @@ -import asyncio -import requests -import logging -from typing import ( - Optional, - Dict, - Any -) -import aiohttp -from enum import Enum -from decimal import Decimal -from hummingbot.core.network_base import ( - NetworkBase, - NetworkStatus -) -from hummingbot.core.utils.async_utils import safe_ensure_future -from hummingbot.logger import HummingbotLogger -from hummingbot.client.config.global_config_map import global_config_map -from hummingbot.client.settings import CONNECTOR_SETTINGS -from hummingbot.client.settings import GATEAWAY_CA_CERT_PATH, GATEAWAY_CLIENT_CERT_PATH, GATEAWAY_CLIENT_KEY_PATH - -ETH_GASSTATION_API_URL = "https://data-api.defipulse.com/api/v1/egs/api/ethgasAPI.json?api-key={}" - - -def get_gas_price(in_gwei: bool = True) -> Decimal: - if not global_config_map["ethgasstation_gas_enabled"].value: - gas_price = global_config_map["manual_gas_price"].value - else: - gas_price = EthGasStationLookup.get_instance().gas_price - return gas_price if in_gwei else gas_price / Decimal("1e9") - - -def get_gas_limit(connector_name: str) -> int: - gas_limit = request_gas_limit(connector_name) - return gas_limit - - -def request_gas_limit(connector_name: str) -> int: - host = global_config_map["gateway_api_host"].value - port = global_config_map["gateway_api_port"].value - balancer_max_swaps = global_config_map["balancer_max_swaps"].value - - base_url = ':'.join(['https://' + host, port]) - url = f"{base_url}/{connector_name}/gas-limit" - - ca_certs = GATEAWAY_CA_CERT_PATH - client_certs = (GATEAWAY_CLIENT_CERT_PATH, GATEAWAY_CLIENT_KEY_PATH) - params = {"maxSwaps": balancer_max_swaps} if connector_name == "balancer" else {} - response = requests.post(url, data=params, verify=ca_certs, cert=client_certs) - parsed_response = response.json() - if response.status_code != 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['gasLimit'] - - -class GasLevel(Enum): - fast = "fast" - fastest = "fastest" - safeLow = "safeLow" - average = "average" - - -class EthGasStationLookup(NetworkBase): - _egsl_logger: Optional[HummingbotLogger] = None - _shared_instance: "EthGasStationLookup" = None - - @classmethod - def get_instance(cls) -> "EthGasStationLookup": - if cls._shared_instance is None: - cls._shared_instance = EthGasStationLookup() - return cls._shared_instance - - @classmethod - def logger(cls) -> HummingbotLogger: - if cls._egsl_logger is None: - cls._egsl_logger = logging.getLogger(__name__) - return cls._egsl_logger - - def __init__(self): - super().__init__() - self._gas_prices: Dict[str, Decimal] = {} - self._gas_limits: Dict[str, Decimal] = {} - self._balancer_max_swaps: int = global_config_map["balancer_max_swaps"].value - self._async_task = None - - @property - def api_key(self): - return global_config_map["ethgasstation_api_key"].value - - @property - def gas_level(self) -> GasLevel: - return GasLevel[global_config_map["ethgasstation_gas_level"].value] - - @property - def refresh_time(self): - return global_config_map["ethgasstation_refresh_time"].value - - @property - def gas_price(self): - return self._gas_prices[self.gas_level] - - @property - def gas_limits(self): - return self._gas_limits - - @gas_limits.setter - def gas_limits(self, gas_limits: Dict[str, int]): - for key, value in gas_limits.items(): - self._gas_limits[key] = value - - @property - def balancer_max_swaps(self): - return self._balancer_max_swaps - - @balancer_max_swaps.setter - def balancer_max_swaps(self, max_swaps: int): - self._balancer_max_swaps = max_swaps - - async def gas_price_update_loop(self): - while True: - try: - url = ETH_GASSTATION_API_URL.format(self.api_key) - async with aiohttp.ClientSession() as client: - response = await client.get(url=url) - if response.status != 200: - raise IOError(f"Error fetching current gas prices. " - f"HTTP status is {response.status}.") - resp_data: Dict[str, Any] = await response.json() - for key, value in resp_data.items(): - if key in GasLevel.__members__: - self._gas_prices[GasLevel[key]] = Decimal(str(value)) / Decimal("10") - prices_str = ', '.join([k.name + ': ' + str(v) for k, v in self._gas_prices.items()]) - self.logger().info(f"Gas levels: [{prices_str}]") - for name, con_setting in CONNECTOR_SETTINGS.items(): - if con_setting.use_eth_gas_lookup: - self._gas_limits[name] = get_gas_limit(name) - self.logger().info(f"{name} Gas estimate:" - f" limit = {self._gas_limits[name]:.0f}," - f" price = {self.gas_level.name}," - f" estimated cost = {get_gas_price(False) * self._gas_limits[name]:.5f} ETH") - await asyncio.sleep(self.refresh_time) - except asyncio.CancelledError: - raise - except Exception: - self.logger().network("Unexpected error running logging task.", exc_info=True) - await asyncio.sleep(self.refresh_time) - - async def start_network(self): - self._async_task = safe_ensure_future(self.gas_price_update_loop()) - - async def stop_network(self): - if self._async_task is not None: - self._async_task.cancel() - self._async_task = None - - async def check_network(self) -> NetworkStatus: - try: - url = ETH_GASSTATION_API_URL.format(self.api_key) - async with aiohttp.ClientSession() as client: - response = await client.get(url=url) - if response.status != 200: - raise Exception(f"Error connecting to {url}. HTTP status is {response.status}.") - except asyncio.CancelledError: - raise - except Exception: - return NetworkStatus.NOT_CONNECTED - return NetworkStatus.CONNECTED - - def start(self): - NetworkBase.start(self) - - def stop(self): - NetworkBase.stop(self) diff --git a/hummingbot/core/utils/ethereum.py b/hummingbot/core/utils/ethereum.py index ff667d3a0a..e30c2b3478 100644 --- a/hummingbot/core/utils/ethereum.py +++ b/hummingbot/core/utils/ethereum.py @@ -3,7 +3,11 @@ from hexbytes import HexBytes from web3 import Web3 from web3.datastructures import AttributeDict -from typing import Dict +from typing import Dict, List +import aiohttp +from hummingbot.client.config.global_config_map import global_config_map +import itertools as it +from hummingbot.core.utils import async_ttl_cache def check_web3(ethereum_rpc_url: str) -> bool: @@ -32,3 +36,55 @@ def block_values_to_hex(block: AttributeDict) -> AttributeDict: except binascii.Error: formatted_block[key] = value return AttributeDict(formatted_block) + + +def check_transaction_exceptions(trade_data: dict) -> dict: + + exception_list = [] + + gas_limit = trade_data["gas_limit"] + # gas_price = trade_data["gas_price"] + gas_cost = trade_data["gas_cost"] + amount = trade_data["amount"] + side = trade_data["side"] + base = trade_data["base"] + quote = trade_data["quote"] + balances = trade_data["balances"] + allowances = trade_data["allowances"] + swaps_message = f"Total swaps: {trade_data['swaps']}" if "swaps" in trade_data.keys() else '' + + eth_balance = balances["ETH"] + + # check for sufficient gas + if eth_balance < gas_cost: + exception_list.append(f"Insufficient ETH balance to cover gas:" + f" Balance: {eth_balance}. Est. gas cost: {gas_cost}. {swaps_message}") + + trade_token = base if side == "side" else quote + trade_allowance = allowances[trade_token] + + # check for gas limit set to low + gas_limit_threshold = 21000 + if gas_limit < gas_limit_threshold: + exception_list.append(f"Gas limit {gas_limit} below recommended {gas_limit_threshold} threshold.") + + # check for insufficient token allowance + if allowances[trade_token] < amount: + exception_list.append(f"Insufficient {trade_token} allowance {trade_allowance}. Amount to trade: {amount}") + + return exception_list + + +@async_ttl_cache(ttl=30) +async def fetch_trading_pairs() -> List[str]: + token_list_url = global_config_map.get("ethereum_token_list_url").value + tokens = set() + async with aiohttp.ClientSession() as client: + resp = await client.get(token_list_url) + resp_json = await resp.json() + for token in resp_json["tokens"]: + tokens.add(token["symbol"]) + trading_pairs = [] + for base, quote in it.permutations(tokens, 2): + trading_pairs.append(f"{base}-{quote}") + return trading_pairs diff --git a/hummingbot/core/utils/market_price.py b/hummingbot/core/utils/market_price.py index 1fa0c66ab7..385329dc5b 100644 --- a/hummingbot/core/utils/market_price.py +++ b/hummingbot/core/utils/market_price.py @@ -1,17 +1,8 @@ from typing import Optional, Dict from decimal import Decimal import importlib -from hummingbot.core.utils import async_ttl_cache -from hummingbot.client.settings import ALL_CONNECTORS, CONNECTOR_SETTINGS, ConnectorType +from hummingbot.client.settings import CONNECTOR_SETTINGS, ConnectorType from hummingbot.connector.exchange.binance.binance_api_order_book_data_source import BinanceAPIOrderBookDataSource -from hummingbot.connector.exchange.binance.binance_utils import USD_QUOTES - - -async def usd_value(token: str, amount: Decimal) -> Optional[Decimal]: - if token in USD_QUOTES: - return amount - usd_values = await token_usd_values() - return usd_values.get(token, 0) * amount async def get_binance_mid_price(trading_pair: str) -> Dict[str, Decimal]: @@ -20,51 +11,6 @@ async def get_binance_mid_price(trading_pair: str) -> Dict[str, Decimal]: return prices.get(trading_pair, None) -@async_ttl_cache(ttl=5, maxsize=100) -async def token_usd_values() -> Dict[str, Decimal]: - prices = await BinanceAPIOrderBookDataSource.get_all_mid_prices() - prices = {k: v for k, v in prices.items() if k is not None} - tokens = {t.split("-")[0] for t in prices} - ret_val = {} - for token in tokens: - token_usd_pairs = [t for t in prices if t.split("-")[0] == token and t.split("-")[1] in USD_QUOTES] - if token_usd_pairs: - ret_val[token] = max([prices[usd_pair] for usd_pair in token_usd_pairs]) - else: - token_any_pairs = [t for t, price in prices.items() if t.split("-")[0] == token and price > 0] - if not token_any_pairs: - continue - quote = token_any_pairs[0].split("-")[1] - quote_usds = [t for t in prices if t.split("-")[0] == quote and t.split("-")[1] in USD_QUOTES] - if quote_usds: - price = prices[token_any_pairs[0]] * prices[quote_usds[0]] - ret_val[token] = price - return ret_val - - -def get_mid_price(exchange: str, trading_pair: str) -> Optional[Decimal]: - - mid_price = None - for connector_type, connectors in ALL_CONNECTORS.items(): - if exchange in connectors: - try: - module_name = f"{exchange}_api_order_book_data_source" - class_name = "".join([o.capitalize() for o in exchange.split("_")]) + "APIOrderBookDataSource" - module_path = f"hummingbot.connector.{connector_type}.{exchange}.{module_name}" - module = getattr(importlib.import_module(module_path), class_name) - mid_price = module.get_mid_price(trading_pair) - except Exception: - pass - if mid_price is None: - module_name = "binance_api_order_book_data_source" - class_name = "BinanceAPIOrderBookDataSource" - module_path = f"hummingbot.connector.exchange.binance.{module_name}" - module = getattr(importlib.import_module(module_path), class_name) - mid_price = module.get_mid_price(trading_pair) - - return mid_price - - async def get_last_price(exchange: str, trading_pair: str) -> Optional[Decimal]: if exchange in CONNECTOR_SETTINGS: conn_setting = CONNECTOR_SETTINGS[exchange] diff --git a/hummingbot/strategy/__utils__/trailing_indicators/average_volatility.py b/hummingbot/strategy/__utils__/trailing_indicators/average_volatility.py new file mode 100644 index 0000000000..cf57615e80 --- /dev/null +++ b/hummingbot/strategy/__utils__/trailing_indicators/average_volatility.py @@ -0,0 +1,14 @@ +from .base_trailing_indicator import BaseTrailingIndicator +import numpy as np + + +class AverageVolatilityIndicator(BaseTrailingIndicator): + def __init__(self, sampling_length: int = 30, processing_length: int = 15): + super().__init__(sampling_length, processing_length) + + def _indicator_calculation(self) -> float: + return np.var(self._sampling_buffer.get_as_numpy_array()) + + def _processing_calculation(self) -> float: + processing_array = self._processing_buffer.get_as_numpy_array() + return np.sqrt(np.mean(processing_array)) diff --git a/hummingbot/strategy/__utils__/trailing_indicators/base_trailing_indicator.py b/hummingbot/strategy/__utils__/trailing_indicators/base_trailing_indicator.py new file mode 100644 index 0000000000..8df46b58b0 --- /dev/null +++ b/hummingbot/strategy/__utils__/trailing_indicators/base_trailing_indicator.py @@ -0,0 +1,49 @@ +from abc import ABC, abstractmethod +import numpy as np +import logging +from ..ring_buffer import RingBuffer + +pmm_logger = None + + +class BaseTrailingIndicator(ABC): + @classmethod + def logger(cls): + global pmm_logger + if pmm_logger is None: + pmm_logger = logging.getLogger(__name__) + return pmm_logger + + def __init__(self, sampling_length: int = 30, processing_length: int = 15): + self._sampling_length = sampling_length + self._sampling_buffer = RingBuffer(sampling_length) + self._processing_length = processing_length + self._processing_buffer = RingBuffer(processing_length) + + def add_sample(self, value: float): + self._sampling_buffer.add_value(value) + indicator_value = self._indicator_calculation() + self._processing_buffer.add_value(indicator_value) + + @abstractmethod + def _indicator_calculation(self) -> float: + raise NotImplementedError + + def _processing_calculation(self) -> float: + """ + Processing of the processing buffer to return final value. + Default behavior is buffer average + """ + return np.mean(self._processing_buffer.get_as_numpy_array()) + + @property + def current_value(self) -> float: + return self._processing_calculation() + + @property + def is_sampling_buffer_full(self) -> bool: + return self._sampling_buffer.is_full + + @property + def is_processing_buffer_full(self) -> bool: + return self._processing_buffer.is_full diff --git a/hummingbot/strategy/__utils__/trailing_indicators/exponential_moving_average.py b/hummingbot/strategy/__utils__/trailing_indicators/exponential_moving_average.py new file mode 100644 index 0000000000..ed380fc99a --- /dev/null +++ b/hummingbot/strategy/__utils__/trailing_indicators/exponential_moving_average.py @@ -0,0 +1,17 @@ +from base_trailing_indicator import BaseTrailingIndicator +import pandas as pd + + +class ExponentialMovingAverageIndicator(BaseTrailingIndicator): + def __init__(self, sampling_length: int = 30, processing_length: int = 1): + if processing_length != 1: + raise Exception("Exponential moving average processing_length should be 1") + super().__init__(sampling_length, processing_length) + + def _indicator_calculation(self) -> float: + ema = pd.Series(self._sampling_buffer.get_as_numpy_array())\ + .ewm(span=self._sampling_length, adjust=True).mean() + return ema[-1] + + def _processing_calculation(self) -> float: + return self._processing_buffer.get_last_value() diff --git a/hummingbot/strategy/amm_arb/amm_arb.py b/hummingbot/strategy/amm_arb/amm_arb.py index b96af808fc..ac3e620419 100644 --- a/hummingbot/strategy/amm_arb/amm_arb.py +++ b/hummingbot/strategy/amm_arb/amm_arb.py @@ -12,6 +12,7 @@ from hummingbot.strategy.strategy_py_base import StrategyPyBase from hummingbot.connector.connector_base import ConnectorBase from hummingbot.client.settings import ETH_WALLET_CONNECTORS +from hummingbot.client.performance import smart_round from hummingbot.connector.connector.uniswap.uniswap_connector import UniswapConnector from .utils import create_arb_proposals, ArbProposal @@ -253,13 +254,18 @@ async def format_status(self) -> str: 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 + + # check for unavailable price data + buy_price = smart_round(Decimal(str(buy_price)), 8) if buy_price is not None else '-' + sell_price = smart_round(Decimal(str(sell_price)), 8) if sell_price is not None else '-' + mid_price = smart_round(((buy_price + sell_price) / 2), 8) if '-' not in [buy_price, sell_price] else '-' + data.append([ market.display_name, trading_pair, - float(sell_price), - float(buy_price), - float(mid_price) + sell_price, + buy_price, + mid_price ]) markets_df = pd.DataFrame(data=data, columns=columns) lines = [] @@ -335,7 +341,7 @@ async def quote_in_eth_rate_fetch_loop(self): self._market_2_quote_eth_rate = await self.request_rate_in_eth(self._market_info_2.quote_asset) self.logger().warning(f"Estimate conversion rate - " f"{self._market_info_2.quote_asset}:ETH = {self._market_2_quote_eth_rate} ") - await asyncio.sleep(60 * 5) + await asyncio.sleep(60 * 1) except asyncio.CancelledError: raise except Exception as e: @@ -348,4 +354,5 @@ async def quote_in_eth_rate_fetch_loop(self): async def request_rate_in_eth(self, quote: str) -> int: if self._uniswap is None: self._uniswap = UniswapConnector([f"{quote}-WETH"], "", None) + await self._uniswap.initiate_pool() # initiate to cache swap pool return await self._uniswap.get_quote_price(f"{quote}-WETH", True, 1) diff --git a/hummingbot/strategy/arbitrage/arbitrage.pxd b/hummingbot/strategy/arbitrage/arbitrage.pxd index 316fd6ede6..9d999eea9d 100755 --- a/hummingbot/strategy/arbitrage/arbitrage.pxd +++ b/hummingbot/strategy/arbitrage/arbitrage.pxd @@ -23,10 +23,12 @@ cdef class ArbitrageStrategy(StrategyBase): object _exchange_rate_conversion int _failed_order_tolerance bint _cool_off_logged + bint _use_oracle_conversion_rate object _secondary_to_primary_base_conversion_rate object _secondary_to_primary_quote_conversion_rate bint _hb_app_notification tuple _current_profitability + double _last_conv_rates_logged cdef tuple c_calculate_arbitrage_top_order_profitability(self, object market_pair) cdef c_process_market_pair(self, object market_pair) diff --git a/hummingbot/strategy/arbitrage/arbitrage.pyx b/hummingbot/strategy/arbitrage/arbitrage.pyx index d08a6afc83..b406d9a742 100755 --- a/hummingbot/strategy/arbitrage/arbitrage.pyx +++ b/hummingbot/strategy/arbitrage/arbitrage.pyx @@ -20,6 +20,8 @@ from hummingbot.core.network_iterator import NetworkStatus from hummingbot.strategy.strategy_base import StrategyBase from hummingbot.strategy.market_trading_pair_tuple import MarketTradingPairTuple from hummingbot.strategy.arbitrage.arbitrage_market_pair import ArbitrageMarketPair +from hummingbot.core.rate_oracle.rate_oracle import RateOracle +from hummingbot.client.performance import smart_round NaN = float("nan") s_decimal_0 = Decimal(0) @@ -49,6 +51,7 @@ cdef class ArbitrageStrategy(StrategyBase): status_report_interval: float = 60.0, next_trade_delay_interval: float = 15.0, failed_order_tolerance: int = 1, + use_oracle_conversion_rate: bool = False, secondary_to_primary_base_conversion_rate: Decimal = Decimal("1"), secondary_to_primary_quote_conversion_rate: Decimal = Decimal("1"), hb_app_notification: bool = False): @@ -75,9 +78,10 @@ cdef class ArbitrageStrategy(StrategyBase): self._failed_order_tolerance = failed_order_tolerance self._cool_off_logged = False self._current_profitability = () - + self._use_oracle_conversion_rate = use_oracle_conversion_rate self._secondary_to_primary_base_conversion_rate = secondary_to_primary_base_conversion_rate self._secondary_to_primary_quote_conversion_rate = secondary_to_primary_quote_conversion_rate + self._last_conv_rates_logged = 0 self._hb_app_notification = hb_app_notification @@ -106,6 +110,55 @@ cdef class ArbitrageStrategy(StrategyBase): def tracked_market_orders_data_frame(self) -> List[pd.DataFrame]: return self._sb_order_tracker.tracked_market_orders_data_frame + def get_second_to_first_conversion_rate(self) -> Tuple[str, Decimal, str, Decimal]: + """ + Find conversion rates from secondary market to primary market + :return: A tuple of quote pair symbol, quote conversion rate source, quote conversion rate, + base pair symbol, base conversion rate source, base conversion rate + """ + quote_rate = Decimal("1") + quote_pair = f"{self._market_pairs[0].second.quote_asset}-{self._market_pairs[0].first.quote_asset}" + quote_rate_source = "fixed" + if self._use_oracle_conversion_rate: + if self._market_pairs[0].second.quote_asset != self._market_pairs[0].first.quote_asset: + quote_rate_source = RateOracle.source.name + quote_rate = RateOracle.get_instance().rate(quote_pair) + else: + quote_rate = self._secondary_to_primary_quote_conversion_rate + base_rate = Decimal("1") + base_pair = f"{self._market_pairs[0].second.base_asset}-{self._market_pairs[0].first.base_asset}" + base_rate_source = "fixed" + if self._use_oracle_conversion_rate: + if self._market_pairs[0].second.base_asset != self._market_pairs[0].first.base_asset: + base_rate_source = RateOracle.source.name + base_rate = RateOracle.get_instance().rate(base_pair) + else: + base_rate = self._secondary_to_primary_base_conversion_rate + return quote_pair, quote_rate_source, quote_rate, base_pair, base_rate_source, base_rate + + def log_conversion_rates(self): + quote_pair, quote_rate_source, quote_rate, base_pair, base_rate_source, base_rate = \ + self.get_second_to_first_conversion_rate() + if quote_pair.split("-")[0] != quote_pair.split("-")[1]: + self.logger().info(f"{quote_pair} ({quote_rate_source}) conversion rate: {smart_round(quote_rate)}") + if base_pair.split("-")[0] != base_pair.split("-")[1]: + self.logger().info(f"{base_pair} ({base_rate_source}) conversion rate: {smart_round(base_rate)}") + + def oracle_status_df(self): + columns = ["Source", "Pair", "Rate"] + data = [] + quote_pair, quote_rate_source, quote_rate, base_pair, base_rate_source, base_rate = \ + self.get_second_to_first_conversion_rate() + if quote_pair.split("-")[0] != quote_pair.split("-")[1]: + data.extend([ + [quote_rate_source, quote_pair, smart_round(quote_rate)], + ]) + if base_pair.split("-")[0] != base_pair.split("-")[1]: + data.extend([ + [base_rate_source, base_pair, smart_round(base_rate)], + ]) + return pd.DataFrame(data=data, columns=columns) + def format_status(self) -> str: cdef: list lines = [] @@ -117,6 +170,11 @@ cdef class ArbitrageStrategy(StrategyBase): lines.extend(["", " Markets:"] + [" " + line for line in str(markets_df).split("\n")]) + oracle_df = self.oracle_status_df() + if not oracle_df.empty: + lines.extend(["", " Rate conversion:"] + + [" " + line for line in str(oracle_df).split("\n")]) + assets_df = self.wallet_balance_data_frame([market_pair.first, market_pair.second]) lines.extend(["", " Assets:"] + [" " + line for line in str(assets_df).split("\n")]) @@ -194,6 +252,10 @@ cdef class ArbitrageStrategy(StrategyBase): for market_pair in self._market_pairs: self.c_process_market_pair(market_pair) + # log conversion rates every 5 minutes + if self._last_conv_rates_logged + (60. * 5) < self._current_timestamp: + self.log_conversion_rates() + self._last_conv_rates_logged = self._current_timestamp finally: self._last_timestamp = timestamp @@ -390,7 +452,20 @@ cdef class ArbitrageStrategy(StrategyBase): if market_info == self._market_pairs[0].first: return Decimal("1") elif market_info == self._market_pairs[0].second: - return self._secondary_to_primary_quote_conversion_rate / self._secondary_to_primary_base_conversion_rate + _, _, quote_rate, _, _, base_rate = self.get_second_to_first_conversion_rate() + return quote_rate / base_rate + # if not self._use_oracle_conversion_rate: + # return self._secondary_to_primary_quote_conversion_rate / self._secondary_to_primary_base_conversion_rate + # else: + # quote_rate = Decimal("1") + # if self._market_pairs[0].second.quote_asset != self._market_pairs[0].first.quote_asset: + # quote_pair = f"{self._market_pairs[0].second.quote_asset}-{self._market_pairs[0].first.quote_asset}" + # quote_rate = RateOracle.get_instance().rate(quote_pair) + # base_rate = Decimal("1") + # if self._market_pairs[0].second.base_asset != self._market_pairs[0].first.base_asset: + # base_pair = f"{self._market_pairs[0].second.base_asset}-{self._market_pairs[0].first.base_asset}" + # base_rate = RateOracle.get_instance().rate(base_pair) + # return quote_rate / base_rate cdef tuple c_find_best_profitable_amount(self, object buy_market_trading_pair_tuple, object sell_market_trading_pair_tuple): """ diff --git a/hummingbot/strategy/arbitrage/arbitrage_config_map.py b/hummingbot/strategy/arbitrage/arbitrage_config_map.py index 96313098ae..781396f642 100644 --- a/hummingbot/strategy/arbitrage/arbitrage_config_map.py +++ b/hummingbot/strategy/arbitrage/arbitrage_config_map.py @@ -2,12 +2,11 @@ from hummingbot.client.config.config_validators import ( validate_exchange, validate_market_trading_pair, - validate_decimal -) -from hummingbot.client.settings import ( - required_exchanges, - EXAMPLE_PAIRS, + validate_decimal, + validate_bool ) +from hummingbot.client.config.config_helpers import parse_cvar_value +import hummingbot.client.settings as settings from decimal import Decimal from typing import Optional @@ -24,70 +23,109 @@ def validate_secondary_market_trading_pair(value: str) -> Optional[str]: def primary_trading_pair_prompt(): primary_market = arbitrage_config_map.get("primary_market").value - example = EXAMPLE_PAIRS.get(primary_market) + example = settings.EXAMPLE_PAIRS.get(primary_market) return "Enter the token trading pair you would like to trade on %s%s >>> " \ % (primary_market, f" (e.g. {example})" if example else "") def secondary_trading_pair_prompt(): secondary_market = arbitrage_config_map.get("secondary_market").value - example = EXAMPLE_PAIRS.get(secondary_market) + example = settings.EXAMPLE_PAIRS.get(secondary_market) return "Enter the token trading pair you would like to trade on %s%s >>> " \ % (secondary_market, f" (e.g. {example})" if example else "") def secondary_market_on_validated(value: str): - required_exchanges.append(value) + settings.required_exchanges.append(value) + + +def update_oracle_settings(value: str): + c_map = arbitrage_config_map + if not (c_map["use_oracle_conversion_rate"].value is not None and + c_map["primary_market_trading_pair"].value is not None and + c_map["secondary_market_trading_pair"].value is not None): + return + use_oracle = parse_cvar_value(c_map["use_oracle_conversion_rate"], c_map["use_oracle_conversion_rate"].value) + first_base, first_quote = c_map["primary_market_trading_pair"].value.split("-") + second_base, second_quote = c_map["secondary_market_trading_pair"].value.split("-") + if use_oracle and (first_base != second_base or first_quote != second_quote): + settings.required_rate_oracle = True + settings.rate_oracle_pairs = [] + if first_base != second_base: + settings.rate_oracle_pairs.append(f"{second_base}-{first_base}") + if first_quote != second_quote: + settings.rate_oracle_pairs.append(f"{second_quote}-{first_quote}") + else: + settings.required_rate_oracle = False + settings.rate_oracle_pairs = [] arbitrage_config_map = { - "strategy": - ConfigVar(key="strategy", - prompt="", - default="arbitrage"), + "strategy": ConfigVar( + key="strategy", + prompt="", + default="arbitrage" + ), "primary_market": ConfigVar( key="primary_market", prompt="Enter your primary spot connector >>> ", prompt_on_new=True, validator=validate_exchange, - on_validated=lambda value: required_exchanges.append(value)), + on_validated=lambda value: settings.required_exchanges.append(value), + ), "secondary_market": ConfigVar( key="secondary_market", prompt="Enter your secondary spot connector >>> ", prompt_on_new=True, validator=validate_exchange, - on_validated=secondary_market_on_validated), + on_validated=secondary_market_on_validated, + ), "primary_market_trading_pair": ConfigVar( key="primary_market_trading_pair", prompt=primary_trading_pair_prompt, prompt_on_new=True, - validator=validate_primary_market_trading_pair), + validator=validate_primary_market_trading_pair, + on_validated=update_oracle_settings, + ), "secondary_market_trading_pair": ConfigVar( key="secondary_market_trading_pair", prompt=secondary_trading_pair_prompt, prompt_on_new=True, - validator=validate_secondary_market_trading_pair), + validator=validate_secondary_market_trading_pair, + on_validated=update_oracle_settings, + ), "min_profitability": ConfigVar( key="min_profitability", prompt="What is the minimum profitability for you to make a trade? (Enter 1 to indicate 1%) >>> ", prompt_on_new=True, default=Decimal("0.3"), validator=lambda v: validate_decimal(v, Decimal(-100), Decimal("100"), inclusive=True), - type_str="decimal"), + type_str="decimal", + ), + "use_oracle_conversion_rate": ConfigVar( + key="use_oracle_conversion_rate", + type_str="bool", + prompt="Do you want to use rate oracle on unmatched trading pairs? (Yes/No) >>> ", + prompt_on_new=True, + validator=lambda v: validate_bool(v), + on_validated=update_oracle_settings, + ), "secondary_to_primary_base_conversion_rate": ConfigVar( key="secondary_to_primary_base_conversion_rate", prompt="Enter conversion rate for secondary base asset value to primary base asset value, e.g. " - "if primary base asset is USD, secondary is DAI and 1 USD is worth 1.25 DAI, " - "the conversion rate is 0.8 (1 / 1.25) >>> ", + "if primary base asset is USD and the secondary is DAI, 1 DAI is valued at 1.25 USD, " + "the conversion rate is 1.25 >>> ", default=Decimal("1"), - validator=lambda v: validate_decimal(v, Decimal(0), Decimal("100"), inclusive=False), - type_str="decimal"), + validator=lambda v: validate_decimal(v, Decimal(0), inclusive=False), + type_str="decimal", + ), "secondary_to_primary_quote_conversion_rate": ConfigVar( key="secondary_to_primary_quote_conversion_rate", prompt="Enter conversion rate for secondary quote asset value to primary quote asset value, e.g. " - "if primary quote asset is USD, secondary is DAI and 1 USD is worth 1.25 DAI, " - "the conversion rate is 0.8 (1 / 1.25) >>> ", + "if primary quote asset is USD and the secondary is DAI and 1 DAI is valued at 1.25 USD, " + "the conversion rate is 1.25 >>> ", default=Decimal("1"), - validator=lambda v: validate_decimal(v, Decimal(0), Decimal("100"), inclusive=False), - type_str="decimal"), + validator=lambda v: validate_decimal(v, Decimal(0), inclusive=False), + type_str="decimal", + ), } diff --git a/hummingbot/strategy/arbitrage/start.py b/hummingbot/strategy/arbitrage/start.py index e9afc4f3ba..b429e8cb89 100644 --- a/hummingbot/strategy/arbitrage/start.py +++ b/hummingbot/strategy/arbitrage/start.py @@ -15,6 +15,7 @@ def start(self): raw_primary_trading_pair = arbitrage_config_map.get("primary_market_trading_pair").value raw_secondary_trading_pair = arbitrage_config_map.get("secondary_market_trading_pair").value min_profitability = arbitrage_config_map.get("min_profitability").value / Decimal("100") + use_oracle_conversion_rate = arbitrage_config_map.get("use_oracle_conversion_rate").value secondary_to_primary_base_conversion_rate = arbitrage_config_map["secondary_to_primary_base_conversion_rate"].value secondary_to_primary_quote_conversion_rate = arbitrage_config_map["secondary_to_primary_quote_conversion_rate"].value @@ -41,6 +42,7 @@ def start(self): self.strategy = ArbitrageStrategy(market_pairs=[self.market_pair], min_profitability=min_profitability, logging_options=ArbitrageStrategy.OPTION_LOG_ALL, + use_oracle_conversion_rate=use_oracle_conversion_rate, secondary_to_primary_base_conversion_rate=secondary_to_primary_base_conversion_rate, secondary_to_primary_quote_conversion_rate=secondary_to_primary_quote_conversion_rate, hb_app_notification=True) diff --git a/hummingbot/strategy/avellaneda_market_making/__init__.py b/hummingbot/strategy/avellaneda_market_making/__init__.py new file mode 100644 index 0000000000..d29aaf1e02 --- /dev/null +++ b/hummingbot/strategy/avellaneda_market_making/__init__.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python + +from .avellaneda_market_making import AvellanedaMarketMakingStrategy +__all__ = [ + AvellanedaMarketMakingStrategy, +] diff --git a/hummingbot/strategy/avellaneda_market_making/avellaneda_market_making.pxd b/hummingbot/strategy/avellaneda_market_making/avellaneda_market_making.pxd new file mode 100644 index 0000000000..79df1f715f --- /dev/null +++ b/hummingbot/strategy/avellaneda_market_making/avellaneda_market_making.pxd @@ -0,0 +1,73 @@ +# distutils: language=c++ + +from libc.stdint cimport int64_t +from hummingbot.strategy.strategy_base cimport StrategyBase + + +cdef class AvellanedaMarketMakingStrategy(StrategyBase): + cdef: + object _market_info + object _minimum_spread + object _order_amount + double _order_refresh_time + double _max_order_age + object _order_refresh_tolerance_pct + double _filled_order_delay + object _inventory_target_base_pct + bint _order_optimization_enabled + bint _add_transaction_costs_to_orders + bint _hb_app_notification + bint _is_debug + + double _cancel_timestamp + double _create_timestamp + object _limit_order_type + bint _all_markets_ready + int _filled_buys_balance + int _filled_sells_balance + double _last_timestamp + double _status_report_interval + int64_t _logging_options + object _last_own_trade_price + int _volatility_sampling_period + double _last_sampling_timestamp + bint _parameters_based_on_spread + int _ticks_to_be_ready + object _min_spread + object _max_spread + object _vol_to_spread_multiplier + object _inventory_risk_aversion + object _kappa + object _gamma + object _eta + object _closing_time + object _time_left + object _q_adjustment_factor + object _reserved_price + object _optimal_spread + object _optimal_bid + object _optimal_ask + object _latest_parameter_calculation_vol + str _debug_csv_path + object _avg_vol + + cdef object c_get_mid_price(self) + cdef object c_create_base_proposal(self) + cdef tuple c_get_adjusted_available_balance(self, list orders) + cdef c_apply_order_price_modifiers(self, object proposal) + cdef c_apply_order_amount_eta_transformation(self, object proposal) + cdef c_apply_budget_constraint(self, object proposal) + cdef c_apply_order_optimization(self, object proposal) + cdef c_apply_add_transaction_costs(self, object proposal) + cdef bint c_is_within_tolerance(self, list current_prices, list proposal_prices) + cdef c_cancel_active_orders(self, object proposal) + cdef c_aged_order_refresh(self) + cdef bint c_to_create_orders(self, object proposal) + cdef c_execute_orders_proposal(self, object proposal) + cdef set_timers(self) + cdef double c_get_spread(self) + cdef c_collect_market_variables(self, double timestamp) + cdef bint c_is_algorithm_ready(self) + cdef c_calculate_reserved_price_and_optimal_spread(self) + cdef object c_calculate_target_inventory(self) + cdef c_recalculate_parameters(self) diff --git a/hummingbot/strategy/avellaneda_market_making/avellaneda_market_making.pyx b/hummingbot/strategy/avellaneda_market_making/avellaneda_market_making.pyx new file mode 100644 index 0000000000..9704ec2032 --- /dev/null +++ b/hummingbot/strategy/avellaneda_market_making/avellaneda_market_making.pyx @@ -0,0 +1,1022 @@ +from decimal import Decimal +import logging +import pandas as pd +import numpy as np +from typing import ( + List, + Dict, +) +from math import ( + floor, + ceil +) +import time +import datetime +import os +from hummingbot.core.clock cimport Clock +from hummingbot.core.event.events import TradeType +from hummingbot.core.data_type.limit_order cimport LimitOrder +from hummingbot.core.data_type.limit_order import LimitOrder +from hummingbot.core.network_iterator import NetworkStatus +from hummingbot.connector.exchange_base import ExchangeBase +from hummingbot.connector.exchange_base cimport ExchangeBase +from hummingbot.core.event.events import OrderType + +from hummingbot.strategy.market_trading_pair_tuple import MarketTradingPairTuple +from hummingbot.strategy.strategy_base import StrategyBase +from hummingbot.client.config.global_config_map import global_config_map + +from .data_types import ( + Proposal, + PriceSize +) +from ..order_tracker cimport OrderTracker +from ..__utils__.trailing_indicators.average_volatility import AverageVolatilityIndicator + + +NaN = float("nan") +s_decimal_zero = Decimal(0) +s_decimal_neg_one = Decimal(-1) +s_decimal_one = Decimal(1) +pmm_logger = None + + +cdef class AvellanedaMarketMakingStrategy(StrategyBase): + OPTION_LOG_CREATE_ORDER = 1 << 3 + OPTION_LOG_MAKER_ORDER_FILLED = 1 << 4 + OPTION_LOG_STATUS_REPORT = 1 << 5 + OPTION_LOG_ALL = 0x7fffffffffffffff + + # These are exchanges where you're expected to expire orders instead of actively cancelling them. + RADAR_RELAY_TYPE_EXCHANGES = {"radar_relay", "bamboo_relay"} + + @classmethod + def logger(cls): + global pmm_logger + if pmm_logger is None: + pmm_logger = logging.getLogger(__name__) + return pmm_logger + + def __init__(self, + market_info: MarketTradingPairTuple, + order_amount: Decimal, + order_refresh_time: float = 30.0, + max_order_age = 1800.0, + order_refresh_tolerance_pct: Decimal = s_decimal_neg_one, + order_optimization_enabled = True, + filled_order_delay: float = 60.0, + inventory_target_base_pct: Decimal = s_decimal_zero, + add_transaction_costs_to_orders: bool = True, + logging_options: int = OPTION_LOG_ALL, + status_report_interval: float = 900, + hb_app_notification: bool = False, + parameters_based_on_spread: bool = True, + min_spread: Decimal = Decimal("0.15"), + max_spread: Decimal = Decimal("2"), + vol_to_spread_multiplier: Decimal = Decimal("1.3"), + inventory_risk_aversion: Decimal = Decimal("0.5"), + order_book_depth_factor: Decimal = Decimal("0.1"), + risk_factor: Decimal = Decimal("0.5"), + order_amount_shape_factor: Decimal = Decimal("0.005"), + closing_time: Decimal = Decimal("1"), + debug_csv_path: str = '', + volatility_buffer_size: int = 30, + is_debug: bool = True, + ): + super().__init__() + self._sb_order_tracker = OrderTracker() + self._market_info = market_info + self._order_amount = order_amount + self._order_optimization_enabled = order_optimization_enabled + self._order_refresh_time = order_refresh_time + self._max_order_age = max_order_age + self._order_refresh_tolerance_pct = order_refresh_tolerance_pct + self._filled_order_delay = filled_order_delay + self._inventory_target_base_pct = inventory_target_base_pct + self._add_transaction_costs_to_orders = add_transaction_costs_to_orders + self._hb_app_notification = hb_app_notification + + self._cancel_timestamp = 0 + self._create_timestamp = 0 + self._limit_order_type = self._market_info.market.get_maker_order_type() + self._all_markets_ready = False + self._filled_buys_balance = 0 + self._filled_sells_balance = 0 + self._logging_options = logging_options + self._last_timestamp = 0 + self._status_report_interval = status_report_interval + self._last_own_trade_price = Decimal('nan') + + self.c_add_markets([market_info.market]) + self._ticks_to_be_ready = volatility_buffer_size + self._parameters_based_on_spread = parameters_based_on_spread + self._min_spread = min_spread + self._max_spread = max_spread + self._vol_to_spread_multiplier = vol_to_spread_multiplier + self._inventory_risk_aversion = inventory_risk_aversion + self._avg_vol = AverageVolatilityIndicator(volatility_buffer_size, 1) + self._last_sampling_timestamp = 0 + self._kappa = order_book_depth_factor + self._gamma = risk_factor + self._eta = order_amount_shape_factor + self._time_left = closing_time + self._closing_time = closing_time + self._latest_parameter_calculation_vol = s_decimal_zero + self._reserved_price = s_decimal_zero + self._optimal_spread = s_decimal_zero + self._optimal_ask = s_decimal_zero + self._optimal_bid = s_decimal_zero + self._debug_csv_path = debug_csv_path + self._is_debug = is_debug + try: + if self._is_debug: + os.unlink(self._debug_csv_path) + except FileNotFoundError: + pass + + def all_markets_ready(self): + return all([market.ready for market in self._sb_markets]) + + @property + def market_info(self) -> MarketTradingPairTuple: + return self._market_info + + @property + def order_refresh_tolerance_pct(self) -> Decimal: + return self._order_refresh_tolerance_pct + + @order_refresh_tolerance_pct.setter + def order_refresh_tolerance_pct(self, value: Decimal): + self._order_refresh_tolerance_pct = value + + @property + def order_amount(self) -> Decimal: + return self._order_amount + + @order_amount.setter + def order_amount(self, value: Decimal): + self._order_amount = value + + @property + def inventory_target_base_pct(self) -> Decimal: + return self._inventory_target_base_pct + + @inventory_target_base_pct.setter + def inventory_target_base_pct(self, value: Decimal): + self._inventory_target_base_pct = value + + @property + def order_optimization_enabled(self) -> bool: + return self._order_optimization_enabled + + @order_optimization_enabled.setter + def order_optimization_enabled(self, value: bool): + self._order_optimization_enabled = value + + @property + def order_refresh_time(self) -> float: + return self._order_refresh_time + + @order_refresh_time.setter + def order_refresh_time(self, value: float): + self._order_refresh_time = value + + @property + def filled_order_delay(self) -> float: + return self._filled_order_delay + + @filled_order_delay.setter + def filled_order_delay(self, value: float): + self._filled_order_delay = value + + @property + def filled_order_delay(self) -> float: + return self._filled_order_delay + + @filled_order_delay.setter + def filled_order_delay(self, value: float): + self._filled_order_delay = value + + @property + def add_transaction_costs_to_orders(self) -> bool: + return self._add_transaction_costs_to_orders + + @add_transaction_costs_to_orders.setter + def add_transaction_costs_to_orders(self, value: bool): + self._add_transaction_costs_to_orders = value + + @property + def base_asset(self): + return self._market_info.base_asset + + @property + def quote_asset(self): + return self._market_info.quote_asset + + @property + def trading_pair(self): + return self._market_info.trading_pair + + def get_price(self) -> float: + return self.get_mid_price() + + def get_last_price(self) -> float: + return self._market_info.get_last_price() + + def get_mid_price(self) -> float: + return self.c_get_mid_price() + + cdef object c_get_mid_price(self): + return self._market_info.get_mid_price() + + @property + def market_info_to_active_orders(self) -> Dict[MarketTradingPairTuple, List[LimitOrder]]: + return self._sb_order_tracker.market_pair_to_active_orders + + @property + def active_orders(self) -> List[LimitOrder]: + if self._market_info not in self.market_info_to_active_orders: + return [] + return self.market_info_to_active_orders[self._market_info] + + @property + def active_buys(self) -> List[LimitOrder]: + return [o for o in self.active_orders if o.is_buy] + + @property + def active_sells(self) -> List[LimitOrder]: + return [o for o in self.active_orders if not o.is_buy] + + @property + def logging_options(self) -> int: + return self._logging_options + + @logging_options.setter + def logging_options(self, int64_t logging_options): + self._logging_options = logging_options + + @property + def order_tracker(self): + return self._sb_order_tracker + + def pure_mm_assets_df(self, to_show_current_pct: bool) -> pd.DataFrame: + market, trading_pair, base_asset, quote_asset = self._market_info + price = self._market_info.get_mid_price() + base_balance = float(market.get_balance(base_asset)) + quote_balance = float(market.get_balance(quote_asset)) + available_base_balance = float(market.get_available_balance(base_asset)) + available_quote_balance = float(market.get_available_balance(quote_asset)) + base_value = base_balance * float(price) + total_in_quote = base_value + quote_balance + base_ratio = base_value / total_in_quote if total_in_quote > 0 else 0 + quote_ratio = quote_balance / total_in_quote if total_in_quote > 0 else 0 + data=[ + ["", base_asset, quote_asset], + ["Total Balance", round(base_balance, 4), round(quote_balance, 4)], + ["Available Balance", round(available_base_balance, 4), round(available_quote_balance, 4)], + [f"Current Value ({quote_asset})", round(base_value, 4), round(quote_balance, 4)] + ] + if to_show_current_pct: + data.append(["Current %", f"{base_ratio:.1%}", f"{quote_ratio:.1%}"]) + df = pd.DataFrame(data=data) + return df + + def active_orders_df(self) -> pd.DataFrame: + market, trading_pair, base_asset, quote_asset = self._market_info + price = self.get_price() + active_orders = self.active_orders + no_sells = len([o for o in active_orders if not o.is_buy and o.client_order_id]) + active_orders.sort(key=lambda x: x.price, reverse=True) + columns = ["Level", "Type", "Price", "Spread", "Amount (Orig)", "Amount (Adj)", "Age"] + data = [] + lvl_buy, lvl_sell = 0, 0 + for idx in range(0, len(active_orders)): + order = active_orders[idx] + spread = 0 if price == 0 else abs(order.price - price)/price + age = "n/a" + # // 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') + amount_orig = self._order_amount + data.append([ + "", + "buy" if order.is_buy else "sell", + float(order.price), + f"{spread:.2%}", + amount_orig, + float(order.quantity), + age + ]) + + return pd.DataFrame(data=data, columns=columns) + + def market_status_data_frame(self, market_trading_pair_tuples: List[MarketTradingPairTuple]) -> pd.DataFrame: + markets_data = [] + markets_columns = ["Exchange", "Market", "Best Bid", "Best Ask", f"MidPrice"] + markets_columns.append('Reserved Price') + market_books = [(self._market_info.market, self._market_info.trading_pair)] + for market, trading_pair in market_books: + bid_price = market.get_price(trading_pair, False) + ask_price = market.get_price(trading_pair, True) + ref_price = self.get_price() + markets_data.append([ + market.display_name, + trading_pair, + float(bid_price), + float(ask_price), + float(ref_price), + round(self._reserved_price, 5), + ]) + return pd.DataFrame(data=markets_data, columns=markets_columns).replace(np.nan, '', regex=True) + + def format_status(self) -> str: + if not self._all_markets_ready: + return "Market connectors are not ready." + cdef: + list lines = [] + list warning_lines = [] + warning_lines.extend(self.network_warning([self._market_info])) + + markets_df = self.market_status_data_frame([self._market_info]) + lines.extend(["", " Markets:"] + [" " + line for line in markets_df.to_string(index=False).split("\n")]) + + assets_df = self.pure_mm_assets_df(True) + first_col_length = max(*assets_df[0].apply(len)) + df_lines = assets_df.to_string(index=False, header=False, + formatters={0: ("{:<" + str(first_col_length) + "}").format}).split("\n") + lines.extend(["", " Assets:"] + [" " + line for line in df_lines]) + + # See if there are any open orders. + if len(self.active_orders) > 0: + df = self.active_orders_df() + lines.extend(["", " Orders:"] + [" " + line for line in df.to_string(index=False).split("\n")]) + else: + lines.extend(["", " No active maker orders."]) + + volatility_pct = self._avg_vol.current_value / float(self.get_price()) * 100.0 + if all((self._gamma, self._kappa, volatility_pct)): + lines.extend(["", f" Strategy parameters:", + f" risk_factor(\u03B3)= {self._gamma:.5E}", + f" order_book_depth_factor(\u03BA)= {self._kappa:.5E}", + f" volatility= {volatility_pct:.3f}%", + f" time until end of trading cycle= {str(datetime.timedelta(seconds=float(self._time_left)//1e3))}"]) + + warning_lines.extend(self.balance_warning([self._market_info])) + + if len(warning_lines) > 0: + lines.extend(["", "*** WARNINGS ***"] + warning_lines) + + return "\n".join(lines) + + # The following exposed Python functions are meant for unit tests + # --------------------------------------------------------------- + def execute_orders_proposal(self, proposal: Proposal): + return self.c_execute_orders_proposal(proposal) + + def cancel_order(self, order_id: str): + return self.c_cancel_order(self._market_info, order_id) + + # --------------------------------------------------------------- + + cdef c_start(self, Clock clock, double timestamp): + StrategyBase.c_start(self, clock, timestamp) + self._last_timestamp = timestamp + # start tracking any restored limit order + restored_order_ids = self.c_track_restored_orders(self.market_info) + self._time_left = self._closing_time + + cdef c_tick(self, double timestamp): + StrategyBase.c_tick(self, timestamp) + cdef: + int64_t current_tick = (timestamp // self._status_report_interval) + int64_t last_tick = (self._last_timestamp // self._status_report_interval) + bint should_report_warnings = ((current_tick > last_tick) and + (self._logging_options & self.OPTION_LOG_STATUS_REPORT)) + cdef object proposal + try: + if not self._all_markets_ready: + self._all_markets_ready = all([mkt.ready for mkt in self._sb_markets]) + if not self._all_markets_ready: + # Markets not ready yet. Don't do anything. + if should_report_warnings: + self.logger().warning(f"Markets are not ready. No market making trades are permitted.") + return + + if should_report_warnings: + if not all([mkt.network_status is NetworkStatus.CONNECTED for mkt in self._sb_markets]): + self.logger().warning(f"WARNING: Some markets are not connected or are down at the moment. Market " + f"making may be dangerous when markets or networks are unstable.") + + self.c_collect_market_variables(timestamp) + if self.c_is_algorithm_ready(): + # If gamma or kappa are -1 then it's the first time they are calculated. + # Also, if volatility goes beyond the threshold specified, we consider volatility regime has changed + # so parameters need to be recalculated. + if (self._gamma is None) or (self._kappa is None) or \ + (self._parameters_based_on_spread and + self.volatility_diff_from_last_parameter_calculation(self.get_volatility()) > (self._vol_to_spread_multiplier - 1)): + self.c_recalculate_parameters() + self.c_calculate_reserved_price_and_optimal_spread() + + proposal = None + if self._create_timestamp <= self._current_timestamp: + # 1. Create base order proposals + proposal = self.c_create_base_proposal() + # 2. Apply functions that modify orders amount + self.c_apply_order_amount_eta_transformation(proposal) + # 3. Apply functions that modify orders price + self.c_apply_order_price_modifiers(proposal) + # 4. Apply budget constraint, i.e. can't buy/sell more than what you have. + self.c_apply_budget_constraint(proposal) + + self.c_cancel_active_orders(proposal) + if self._is_debug: + self.dump_debug_variables() + refresh_proposal = self.c_aged_order_refresh() + # Firstly restore cancelled aged order + if refresh_proposal is not None: + self.c_execute_orders_proposal(refresh_proposal) + if self.c_to_create_orders(proposal): + self.c_execute_orders_proposal(proposal) + else: + self._ticks_to_be_ready-=1 + if self._ticks_to_be_ready % 5 == 0: + self.logger().info(f"Calculating volatility... {self._ticks_to_be_ready} seconds to start trading") + finally: + self._last_timestamp = timestamp + + cdef c_collect_market_variables(self, double timestamp): + market, trading_pair, base_asset, quote_asset = self._market_info + self._last_sampling_timestamp = timestamp + self._time_left = max(self._time_left - Decimal(timestamp - self._last_timestamp) * 1000, 0) + price = self.get_price() + self._avg_vol.add_sample(price) + # Calculate adjustment factor to have 0.01% of inventory resolution + base_balance = market.get_balance(base_asset) + quote_balance = market.get_balance(quote_asset) + inventory_in_base = quote_balance / price + base_balance + self._q_adjustment_factor = Decimal( + "1e5") / inventory_in_base + if self._time_left == 0: + # Re-cycle algorithm + self._time_left = self._closing_time + if self._parameters_based_on_spread: + self.c_recalculate_parameters() + self.logger().info("Recycling algorithm time left and parameters if needed.") + + def volatility_diff_from_last_parameter_calculation(self, current_vol): + if self._latest_parameter_calculation_vol == 0: + return s_decimal_zero + return abs(self._latest_parameter_calculation_vol - Decimal(str(current_vol))) / self._latest_parameter_calculation_vol + + cdef double c_get_spread(self): + cdef: + ExchangeBase market = self._market_info.market + str trading_pair = self._market_info.trading_pair + + return market.c_get_price(trading_pair, True) - market.c_get_price(trading_pair, False) + + def get_volatility(self): + vol = Decimal(str(self._avg_vol.current_value)) + if vol == s_decimal_zero: + if self._latest_parameter_calculation_vol != s_decimal_zero: + vol = Decimal(str(self._latest_parameter_calculation_vol)) + else: + # Default value at start time if price has no activity + vol = Decimal(str(self.c_get_spread()/2)) + return vol + + cdef c_calculate_reserved_price_and_optimal_spread(self): + cdef: + ExchangeBase market = self._market_info.market + + time_left_fraction = Decimal(str(self._time_left / self._closing_time)) + + price = self.get_price() + q = (market.get_balance(self.base_asset) - Decimal(str(self.c_calculate_target_inventory()))) * self._q_adjustment_factor + vol = self.get_volatility() + mid_price_variance = vol ** 2 + + self._reserved_price = price - (q * self._gamma * mid_price_variance * time_left_fraction) + self._optimal_spread = self._gamma * mid_price_variance * time_left_fraction + 2 * Decimal( + 1 + self._gamma / self._kappa).ln() / self._gamma + + if self._parameters_based_on_spread: + min_limit_bid = min(price * (1 - self._max_spread), price - self._vol_to_spread_multiplier * vol) + max_limit_bid = price * (1 - self._min_spread) + min_limit_ask = price * (1 + self._min_spread) + max_limit_ask = max(price * (1 + self._max_spread), price + self._vol_to_spread_multiplier * vol) + else: + min_limit_bid = s_decimal_zero + max_limit_bid = min_limit_ask = price + max_limit_ask = Decimal("Inf") + + self._optimal_ask = min(max(self._reserved_price + self._optimal_spread / 2, + min_limit_ask), + max_limit_ask) + self._optimal_bid = min(max(self._reserved_price - self._optimal_spread / 2, + min_limit_bid), + max_limit_bid) + # This is not what the algorithm will use as proposed bid and ask. This is just the raw output. + # Optimal bid and optimal ask prices will be used + if self._is_debug: + self.logger().info(f"bid={(price-(self._reserved_price - self._optimal_spread / 2)) / price * 100:.4f}% | " + f"ask={((self._reserved_price + self._optimal_spread / 2) - price) / price * 100:.4f}% | " + f"q={q/self._q_adjustment_factor:.4f} | " + f"vol={vol:.4f}") + + cdef object c_calculate_target_inventory(self): + cdef: + ExchangeBase market = self._market_info.market + str trading_pair = self._market_info.trading_pair + str base_asset = self._market_info.base_asset + str quote_asset = self._market_info.quote_asset + object mid_price + object base_value + object inventory_value + object target_inventory_value + + price = self.get_price() + base_asset_amount = market.get_balance(base_asset) + quote_asset_amount = market.get_balance(quote_asset) + base_value = base_asset_amount * price + inventory_value = base_value + quote_asset_amount + target_inventory_value = inventory_value * self._inventory_target_base_pct + return market.c_quantize_order_amount(trading_pair, Decimal(str(target_inventory_value / price))) + + cdef c_recalculate_parameters(self): + cdef: + ExchangeBase market = self._market_info.market + + q = (market.get_balance(self.base_asset) - self.c_calculate_target_inventory()) * self._q_adjustment_factor + vol = self.get_volatility() + price=self.get_price() + + if q != 0: + min_spread = self._min_spread * price + max_spread = self._max_spread * price + + # GAMMA + # If q or vol are close to 0, gamma will -> Inf. Is this desirable? + max_possible_gamma = min( + (max_spread - min_spread) / (2 * abs(q) * (vol ** 2)), + (max_spread * (2-self._inventory_risk_aversion) / + self._inventory_risk_aversion + min_spread) / (vol ** 2)) + self._gamma = self._inventory_risk_aversion * max_possible_gamma + + # KAPPA + # Want the maximum possible spread but with restrictions to avoid negative kappa or division by 0 + max_spread_around_reserved_price = max_spread * (2-self._inventory_risk_aversion) + min_spread * self._inventory_risk_aversion + if max_spread_around_reserved_price <= self._gamma * (vol ** 2): + self._kappa = Decimal('1e100') # Cap to kappa -> Infinity + else: + self._kappa = self._gamma / (Decimal.exp((max_spread_around_reserved_price * self._gamma - (vol * self._gamma) **2) / 2) - 1) + + # ETA + + q_where_to_decay_order_amount = self.c_calculate_target_inventory() * (1 - self._inventory_risk_aversion) + self._eta = s_decimal_one + if q_where_to_decay_order_amount != s_decimal_zero: + self._eta = self._eta / q_where_to_decay_order_amount + + self._latest_parameter_calculation_vol = vol + + cdef bint c_is_algorithm_ready(self): + return self._avg_vol.is_sampling_buffer_full + + cdef object c_create_base_proposal(self): + cdef: + ExchangeBase market = self._market_info.market + list buys = [] + list sells = [] + + price = market.c_quantize_order_price(self.trading_pair, Decimal(str(self._optimal_bid))) + size = market.c_quantize_order_amount(self.trading_pair, self._order_amount) + if size>0: + buys.append(PriceSize(price, size)) + + price = market.c_quantize_order_price(self.trading_pair, Decimal(str(self._optimal_ask))) + size = market.c_quantize_order_amount(self.trading_pair, self._order_amount) + if size>0: + sells.append(PriceSize(price, size)) + + return Proposal(buys, sells) + + cdef tuple c_get_adjusted_available_balance(self, list orders): + """ + Calculates the available balance, plus the amount attributed to orders. + :return: (base amount, quote amount) in Decimal + """ + cdef: + ExchangeBase market = self._market_info.market + object base_balance = market.c_get_available_balance(self.base_asset) + object quote_balance = market.c_get_available_balance(self.quote_asset) + + for order in orders: + if order.is_buy: + quote_balance += order.quantity * order.price + else: + base_balance += order.quantity + + return base_balance, quote_balance + + cdef c_apply_order_price_modifiers(self, object proposal): + if self._order_optimization_enabled: + self.c_apply_order_optimization(proposal) + + if self._add_transaction_costs_to_orders: + self.c_apply_add_transaction_costs(proposal) + + cdef c_apply_budget_constraint(self, object proposal): + cdef: + ExchangeBase market = self._market_info.market + object quote_size + object base_size + object adjusted_amount + + base_balance, quote_balance = self.c_get_adjusted_available_balance(self.active_orders) + + for buy in proposal.buys: + buy_fee = market.c_get_fee(self.base_asset, self.quote_asset, OrderType.LIMIT, TradeType.BUY, + buy.size, buy.price) + quote_size = buy.size * buy.price * (Decimal(1) + buy_fee.percent) + + # Adjust buy order size to use remaining balance if less than the order amount + if quote_balance < quote_size: + adjusted_amount = quote_balance / (buy.price * (Decimal("1") + buy_fee.percent)) + adjusted_amount = market.c_quantize_order_amount(self.trading_pair, adjusted_amount) + buy.size = adjusted_amount + quote_balance = s_decimal_zero + elif quote_balance == s_decimal_zero: + buy.size = s_decimal_zero + else: + quote_balance -= quote_size + + proposal.buys = [o for o in proposal.buys if o.size > 0] + + for sell in proposal.sells: + base_size = sell.size + + # Adjust sell order size to use remaining balance if less than the order amount + if base_balance < base_size: + adjusted_amount = market.c_quantize_order_amount(self.trading_pair, base_balance) + sell.size = adjusted_amount + base_balance = s_decimal_zero + elif base_balance == s_decimal_zero: + sell.size = s_decimal_zero + else: + base_balance -= base_size + + proposal.sells = [o for o in proposal.sells if o.size > 0] + + # Compare the market price with the top bid and top ask price + cdef c_apply_order_optimization(self, object proposal): + cdef: + ExchangeBase market = self._market_info.market + object own_buy_size = s_decimal_zero + object own_sell_size = s_decimal_zero + object best_order_spread + + for order in self.active_orders: + if order.is_buy: + own_buy_size = order.quantity + else: + own_sell_size = order.quantity + + if len(proposal.buys) > 0: + # Get the top bid price in the market using order_optimization_depth and your buy order volume + top_bid_price = self._market_info.get_price_for_volume( + False, own_buy_size).result_price + price_quantum = market.c_get_order_price_quantum( + self.trading_pair, + top_bid_price + ) + # Get the price above the top bid + price_above_bid = (ceil(top_bid_price / price_quantum) + 1) * price_quantum + + # If the price_above_bid is lower than the price suggested by the top pricing proposal, + # lower the price and from there apply the best_order_spread to each order in the next levels + proposal.buys = sorted(proposal.buys, key = lambda p: p.price, reverse = True) + lower_buy_price = min(proposal.buys[0].price, price_above_bid) + for i, proposed in enumerate(proposal.buys): + proposal.buys[i].price = market.c_quantize_order_price(self.trading_pair, lower_buy_price) + + if len(proposal.sells) > 0: + # Get the top ask price in the market using order_optimization_depth and your sell order volume + top_ask_price = self._market_info.get_price_for_volume( + True, own_sell_size).result_price + price_quantum = market.c_get_order_price_quantum( + self.trading_pair, + top_ask_price + ) + # Get the price below the top ask + price_below_ask = (floor(top_ask_price / price_quantum) - 1) * price_quantum + + # If the price_below_ask is higher than the price suggested by the pricing proposal, + # increase your price and from there apply the best_order_spread to each order in the next levels + proposal.sells = sorted(proposal.sells, key = lambda p: p.price) + higher_sell_price = max(proposal.sells[0].price, price_below_ask) + for i, proposed in enumerate(proposal.sells): + proposal.sells[i].price = market.c_quantize_order_price(self.trading_pair, higher_sell_price) + + cdef c_apply_order_amount_eta_transformation(self, object proposal): + cdef: + ExchangeBase market = self._market_info.market + str trading_pair = self._market_info.trading_pair + + # eta parameter is described in the paper as the shape parameter for having exponentially decreasing order amount + # for orders that go against inventory target (i.e. Want to buy when excess inventory or sell when deficit inventory) + q = market.get_balance(self.base_asset) - self.c_calculate_target_inventory() + if len(proposal.buys) > 0: + if q > 0: + for i, proposed in enumerate(proposal.buys): + + proposal.buys[i].size = market.c_quantize_order_amount(trading_pair, proposal.buys[i].size * Decimal.exp(-self._eta * q)) + proposal.buys = [o for o in proposal.buys if o.size > 0] + + if len(proposal.sells) > 0: + if q < 0: + for i, proposed in enumerate(proposal.sells): + proposal.sells[i].size = market.c_quantize_order_amount(trading_pair, proposal.sells[i].size * Decimal.exp(self._eta * q)) + proposal.sells = [o for o in proposal.sells if o.size > 0] + + cdef object c_apply_add_transaction_costs(self, object proposal): + cdef: + ExchangeBase market = self._market_info.market + for buy in proposal.buys: + fee = market.c_get_fee(self.base_asset, self.quote_asset, + self._limit_order_type, TradeType.BUY, buy.size, buy.price) + price = buy.price * (Decimal(1) - fee.percent) + buy.price = market.c_quantize_order_price(self.trading_pair, price) + for sell in proposal.sells: + fee = market.c_get_fee(self.base_asset, self.quote_asset, + self._limit_order_type, TradeType.SELL, sell.size, sell.price) + price = sell.price * (Decimal(1) + fee.percent) + sell.price = market.c_quantize_order_price(self.trading_pair, price) + + cdef c_did_fill_order(self, object order_filled_event): + cdef: + str order_id = order_filled_event.order_id + object market_info = self._sb_order_tracker.c_get_shadow_market_pair_from_order_id(order_id) + tuple order_fill_record + + if market_info is not None: + limit_order_record = self._sb_order_tracker.c_get_shadow_limit_order(order_id) + order_fill_record = (limit_order_record, order_filled_event) + + if order_filled_event.trade_type is TradeType.BUY: + if self._logging_options & self.OPTION_LOG_MAKER_ORDER_FILLED: + self.log_with_clock( + logging.INFO, + f"({market_info.trading_pair}) Maker buy order of " + f"{order_filled_event.amount} {market_info.base_asset} filled." + ) + else: + if self._logging_options & self.OPTION_LOG_MAKER_ORDER_FILLED: + self.log_with_clock( + logging.INFO, + f"({market_info.trading_pair}) Maker sell order of " + f"{order_filled_event.amount} {market_info.base_asset} filled." + ) + + cdef c_did_complete_buy_order(self, object order_completed_event): + cdef: + str order_id = order_completed_event.order_id + limit_order_record = self._sb_order_tracker.c_get_limit_order(self._market_info, order_id) + if limit_order_record is None: + return + active_sell_ids = [x.client_order_id for x in self.active_orders if not x.is_buy] + + # delay order creation by filled_order_delay (in seconds) + self._create_timestamp = self._current_timestamp + self._filled_order_delay + self._cancel_timestamp = min(self._cancel_timestamp, self._create_timestamp) + + self._filled_buys_balance += 1 + self._last_own_trade_price = limit_order_record.price + + self.log_with_clock( + logging.INFO, + f"({self.trading_pair}) Maker buy order {order_id} " + f"({limit_order_record.quantity} {limit_order_record.base_currency} @ " + f"{limit_order_record.price} {limit_order_record.quote_currency}) has been completely filled." + ) + self.notify_hb_app( + f"Maker BUY order {limit_order_record.quantity} {limit_order_record.base_currency} @ " + f"{limit_order_record.price} {limit_order_record.quote_currency} is filled." + ) + + cdef c_did_complete_sell_order(self, object order_completed_event): + cdef: + str order_id = order_completed_event.order_id + LimitOrder limit_order_record = self._sb_order_tracker.c_get_limit_order(self._market_info, order_id) + if limit_order_record is None: + return + active_buy_ids = [x.client_order_id for x in self.active_orders if x.is_buy] + + # delay order creation by filled_order_delay (in seconds) + self._create_timestamp = self._current_timestamp + self._filled_order_delay + self._cancel_timestamp = min(self._cancel_timestamp, self._create_timestamp) + + self._filled_sells_balance += 1 + self._last_own_trade_price = limit_order_record.price + + self.log_with_clock( + logging.INFO, + f"({self.trading_pair}) Maker sell order {order_id} " + f"({limit_order_record.quantity} {limit_order_record.base_currency} @ " + f"{limit_order_record.price} {limit_order_record.quote_currency}) has been completely filled." + ) + self.notify_hb_app( + f"Maker SELL order {limit_order_record.quantity} {limit_order_record.base_currency} @ " + f"{limit_order_record.price} {limit_order_record.quote_currency} is filled." + ) + + cdef bint c_is_within_tolerance(self, list current_prices, list proposal_prices): + if len(current_prices) != len(proposal_prices): + return False + current_prices = sorted(current_prices) + proposal_prices = sorted(proposal_prices) + for current, proposal in zip(current_prices, proposal_prices): + # if spread diff is more than the tolerance or order quantities are different, return false. + if abs(proposal - current)/current > self._order_refresh_tolerance_pct: + return False + return True + + # Cancel active orders + # Return value: whether order cancellation is deferred. + cdef c_cancel_active_orders(self, object proposal): + if self._cancel_timestamp > self._current_timestamp: + return + if not global_config_map.get("0x_active_cancels").value: + if ((self._market_info.market.name in self.RADAR_RELAY_TYPE_EXCHANGES) or + (self._market_info.market.name == "bamboo_relay" and not self._market_info.market.use_coordinator)): + return + + cdef: + list active_orders = self.active_orders + list active_buy_prices = [] + list active_sells = [] + bint to_defer_canceling = False + if len(active_orders) == 0: + return + if proposal is not None: + active_buy_prices = [Decimal(str(o.price)) for o in active_orders if o.is_buy] + active_sell_prices = [Decimal(str(o.price)) for o in active_orders if not o.is_buy] + proposal_buys = [buy.price for buy in proposal.buys] + proposal_sells = [sell.price for sell in proposal.sells] + if self.c_is_within_tolerance(active_buy_prices, proposal_buys) and \ + self.c_is_within_tolerance(active_sell_prices, proposal_sells): + to_defer_canceling = True + + if not to_defer_canceling: + for order in active_orders: + self.c_cancel_order(self._market_info, order.client_order_id) + else: + self.set_timers() + + # Refresh all active order that are older that the _max_order_age + cdef c_aged_order_refresh(self): + cdef: + list active_orders = self.active_orders + list buys = [] + list sells = [] + + for order in active_orders: + age = 0 if "//" in order.client_order_id else \ + int(int(time.time()) - int(order.client_order_id[-16:])/1e6) + + # To prevent duplicating orders due to delay in receiving cancel response + refresh_check = [o for o in active_orders if o.price == order.price + and o.quantity == order.quantity] + if len(refresh_check) > 1: + continue + + if age >= self._max_order_age: + if order.is_buy: + buys.append(PriceSize(order.price, order.quantity)) + else: + sells.append(PriceSize(order.price, order.quantity)) + self.logger().info(f"Refreshing {'Buy' if order.is_buy else 'Sell'} order with ID - " + f"{order.client_order_id} because it reached maximum order age of " + f"{self._max_order_age} seconds.") + self.c_cancel_order(self._market_info, order.client_order_id) + return Proposal(buys, sells) + + cdef bint c_to_create_orders(self, object proposal): + return self._create_timestamp < self._current_timestamp and \ + proposal is not None + + cdef c_execute_orders_proposal(self, object proposal): + cdef: + double expiration_seconds = (self._order_refresh_time + if ((self._market_info.market.name in self.RADAR_RELAY_TYPE_EXCHANGES) or + (self._market_info.market.name == "bamboo_relay" and + not self._market_info.market.use_coordinator)) + else NaN) + str bid_order_id, ask_order_id + bint orders_created = False + + if len(proposal.buys) > 0: + if self._logging_options & self.OPTION_LOG_CREATE_ORDER: + price_quote_str = [f"{buy.size.normalize()} {self.base_asset}, " + f"{buy.price.normalize()} {self.quote_asset}" + for buy in proposal.buys] + self.logger().info( + f"({self.trading_pair}) Creating {len(proposal.buys)} bid orders " + f"at (Size, Price): {price_quote_str}" + ) + for buy in proposal.buys: + bid_order_id = self.c_buy_with_specific_market( + self._market_info, + buy.size, + order_type=self._limit_order_type, + price=buy.price, + expiration_seconds=expiration_seconds + ) + orders_created = True + if len(proposal.sells) > 0: + if self._logging_options & self.OPTION_LOG_CREATE_ORDER: + price_quote_str = [f"{sell.size.normalize()} {self.base_asset}, " + f"{sell.price.normalize()} {self.quote_asset}" + for sell in proposal.sells] + self.logger().info( + f"({self.trading_pair}) Creating {len(proposal.sells)} ask " + f"orders at (Size, Price): {price_quote_str}" + ) + for sell in proposal.sells: + ask_order_id = self.c_sell_with_specific_market( + self._market_info, + sell.size, + order_type=self._limit_order_type, + price=sell.price, + expiration_seconds=expiration_seconds + ) + orders_created = True + if orders_created: + self.set_timers() + + cdef set_timers(self): + cdef double next_cycle = self._current_timestamp + self._order_refresh_time + if self._create_timestamp <= self._current_timestamp: + self._create_timestamp = next_cycle + if self._cancel_timestamp <= self._current_timestamp: + self._cancel_timestamp = min(self._create_timestamp, next_cycle) + + def notify_hb_app(self, msg: str): + if self._hb_app_notification: + from hummingbot.client.hummingbot_application import HummingbotApplication + HummingbotApplication.main_application()._notify(msg) + + def dump_debug_variables(self): + market = self._market_info.market + mid_price = self.get_price() + spread = Decimal(str(self.c_get_spread())) + + best_ask = mid_price + spread / 2 + new_ask = self._reserved_price + self._optimal_spread / 2 + best_bid = mid_price - spread / 2 + new_bid = self._reserved_price - self._optimal_spread / 2 + if not os.path.exists(self._debug_csv_path): + df_header = pd.DataFrame([('mid_price', + 'spread', + 'reserved_price', + 'optimal_spread', + 'optimal_bid', + 'optimal_ask', + 'optimal_bid_to_mid_%', + 'optimal_ask_to_mid_%', + 'current_inv', + 'target_inv', + 'time_left_fraction', + 'mid_price std_dev', + 'gamma', + 'kappa', + 'eta', + 'current_vol_to_calculation_vol', + 'inventory_target_pct', + 'min_spread', + 'max_spread', + 'vol_to_spread_multiplier')]) + df_header.to_csv(self._debug_csv_path, mode='a', header=False, index=False) + df = pd.DataFrame([(mid_price, + spread, + self._reserved_price, + self._optimal_spread, + self._optimal_bid, + self._optimal_ask, + (mid_price - (self._reserved_price - self._optimal_spread / 2)) / mid_price, + ((self._reserved_price + self._optimal_spread / 2) - mid_price) / mid_price, + market.get_balance(self.base_asset), + self.c_calculate_target_inventory(), + self._time_left / self._closing_time, + self._avg_vol.current_value, + self._gamma, + self._kappa, + self._eta, + self.volatility_diff_from_last_parameter_calculation(self.get_volatility()), + self.inventory_target_base_pct, + self._min_spread, + self._max_spread, + self._vol_to_spread_multiplier)]) + df.to_csv(self._debug_csv_path, mode='a', header=False, index=False) diff --git a/hummingbot/strategy/avellaneda_market_making/avellaneda_market_making_config_map.py b/hummingbot/strategy/avellaneda_market_making/avellaneda_market_making_config_map.py new file mode 100644 index 0000000000..34a6617163 --- /dev/null +++ b/hummingbot/strategy/avellaneda_market_making/avellaneda_market_making_config_map.py @@ -0,0 +1,243 @@ +from decimal import Decimal + +from hummingbot.client.config.config_var import ConfigVar +from hummingbot.client.config.config_validators import ( + validate_exchange, + validate_market_trading_pair, + validate_bool, + validate_decimal, +) +from hummingbot.client.settings import ( + required_exchanges, + EXAMPLE_PAIRS, +) +from hummingbot.client.config.global_config_map import ( + using_bamboo_coordinator_mode, + using_exchange +) +from hummingbot.client.config.config_helpers import ( + minimum_order_amount, +) +from typing import Optional + + +def maker_trading_pair_prompt(): + exchange = avellaneda_market_making_config_map.get("exchange").value + example = EXAMPLE_PAIRS.get(exchange) + return "Enter the token trading pair you would like to trade on %s%s >>> " \ + % (exchange, f" (e.g. {example})" if example else "") + + +# strategy specific validators +def validate_exchange_trading_pair(value: str) -> Optional[str]: + exchange = avellaneda_market_making_config_map.get("exchange").value + return validate_market_trading_pair(exchange, value) + + +def validate_max_spread(value: str) -> Optional[str]: + validate_decimal(value, 0, 100, inclusive=False) + if avellaneda_market_making_config_map["min_spread"].value is not None: + min_spread = Decimal(avellaneda_market_making_config_map["min_spread"].value) + max_spread = Decimal(value) + if min_spread >= max_spread: + return f"Max spread cannot be lesser or equal to min spread {max_spread}%<={min_spread}%" + + +def onvalidated_min_spread(value: str): + # If entered valid min_spread, max_spread is invalidated so user sets it up again + avellaneda_market_making_config_map["max_spread"].value = None + + +async def order_amount_prompt() -> str: + exchange = avellaneda_market_making_config_map["exchange"].value + trading_pair = avellaneda_market_making_config_map["market"].value + base_asset, quote_asset = trading_pair.split("-") + min_amount = await minimum_order_amount(exchange, trading_pair) + return f"What is the amount of {base_asset} per order? (minimum {min_amount}) >>> " + + +async def validate_order_amount(value: str) -> Optional[str]: + try: + exchange = avellaneda_market_making_config_map["exchange"].value + trading_pair = avellaneda_market_making_config_map["market"].value + min_amount = await minimum_order_amount(exchange, trading_pair) + if Decimal(value) < min_amount: + return f"Order amount must be at least {min_amount}." + except Exception: + return "Invalid order amount." + + +def on_validated_price_source_exchange(value: str): + if value is None: + avellaneda_market_making_config_map["price_source_market"].value = None + + +def exchange_on_validated(value: str): + required_exchanges.append(value) + + +def on_validated_parameters_based_on_spread(value: str): + if value == 'True': + avellaneda_market_making_config_map.get("risk_factor").value = None + avellaneda_market_making_config_map.get("order_book_depth_factor").value = None + avellaneda_market_making_config_map.get("order_amount_shape_factor").value = None + else: + avellaneda_market_making_config_map.get("max_spread").value = None + avellaneda_market_making_config_map.get("min_spread").value = None + avellaneda_market_making_config_map.get("vol_to_spread_multiplier").value = None + avellaneda_market_making_config_map.get("inventory_risk_aversion").value = None + + +avellaneda_market_making_config_map = { + "strategy": + ConfigVar(key="strategy", + prompt=None, + default="avellaneda_market_making"), + "exchange": + ConfigVar(key="exchange", + prompt="Enter your maker spot connector >>> ", + validator=validate_exchange, + on_validated=exchange_on_validated, + prompt_on_new=True), + "market": + ConfigVar(key="market", + prompt=maker_trading_pair_prompt, + validator=validate_exchange_trading_pair, + prompt_on_new=True), + "order_amount": + ConfigVar(key="order_amount", + prompt=order_amount_prompt, + type_str="decimal", + validator=validate_order_amount, + prompt_on_new=True), + "order_optimization_enabled": + ConfigVar(key="order_optimization_enabled", + prompt="Do you want to enable best bid ask jumping? (Yes/No) >>> ", + type_str="bool", + default=True, + validator=validate_bool), + "parameters_based_on_spread": + ConfigVar(key="parameters_based_on_spread", + prompt="Do you want to automate Avellaneda-Stoikov parameters based on min/max spread? >>> ", + type_str="bool", + validator=validate_bool, + on_validated=on_validated_parameters_based_on_spread, + default=True, + prompt_on_new=True), + "min_spread": + ConfigVar(key="min_spread", + prompt="Enter the minimum spread allowed from mid-price in percentage " + "(Enter 1 to indicate 1%) >>> ", + type_str="decimal", + required_if=lambda: avellaneda_market_making_config_map.get("parameters_based_on_spread").value, + validator=lambda v: validate_decimal(v, 0, 100, inclusive=False), + prompt_on_new=True, + on_validated=onvalidated_min_spread), + "max_spread": + ConfigVar(key="max_spread", + prompt="Enter the maximum spread allowed from mid-price in percentage " + "(Enter 1 to indicate 1%) >>> ", + type_str="decimal", + required_if=lambda: avellaneda_market_making_config_map.get("parameters_based_on_spread").value, + validator=lambda v: validate_max_spread(v), + prompt_on_new=True), + "vol_to_spread_multiplier": + ConfigVar(key="vol_to_spread_multiplier", + prompt="Enter the Volatility threshold multiplier (Should be greater than 1.0): " + "(If market volatility multiplied by this value is above the maximum spread, it will increase the maximum spread value) >>>", + type_str="decimal", + required_if=lambda: avellaneda_market_making_config_map.get("parameters_based_on_spread").value, + validator=lambda v: validate_decimal(v, 1, 10, inclusive=False), + prompt_on_new=True), + "inventory_risk_aversion": + ConfigVar(key="inventory_risk_aversion", + prompt="Enter Inventory risk aversion between 0 and 1: (For values close to 0.999 spreads will be more " + "skewed to meet the inventory target, while close to 0.001 spreads will be close to symmetrical, " + "increasing profitability but also increasing inventory risk)>>>", + type_str="decimal", + required_if=lambda: avellaneda_market_making_config_map.get("parameters_based_on_spread").value, + validator=lambda v: validate_decimal(v, 0, 1, inclusive=False), + prompt_on_new=True), + "order_book_depth_factor": + ConfigVar(key="order_book_depth_factor", + printable_key="order_book_depth_factor(\u03BA)", + prompt="Enter order book depth factor (\u03BA) >>> ", + type_str="decimal", + required_if=lambda: not avellaneda_market_making_config_map.get("parameters_based_on_spread").value, + validator=lambda v: validate_decimal(v, 0, 1e10, inclusive=False), + prompt_on_new=True), + "risk_factor": + ConfigVar(key="risk_factor", + printable_key="risk_factor(\u03B3)", + prompt="Enter risk factor (\u03B3) >>> ", + type_str="decimal", + required_if=lambda: not avellaneda_market_making_config_map.get("parameters_based_on_spread").value, + validator=lambda v: validate_decimal(v, 0, 1e10, inclusive=False), + prompt_on_new=True), + "order_amount_shape_factor": + ConfigVar(key="order_amount_shape_factor", + printable_key="order_amount_shape_factor(\u03B7)", + prompt="Enter order amount shape factor (\u03B7) >>> ", + type_str="decimal", + required_if=lambda: not avellaneda_market_making_config_map.get("parameters_based_on_spread").value, + validator=lambda v: validate_decimal(v, 0, 1, inclusive=True), + prompt_on_new=True), + "closing_time": + ConfigVar(key="closing_time", + prompt="Enter operational closing time (T). (How long will each trading cycle last " + "in days or fractions of day) >>> ", + type_str="decimal", + validator=lambda v: validate_decimal(v, 0, 10, inclusive=False), + default=Decimal("0.041666667")), + "order_refresh_time": + ConfigVar(key="order_refresh_time", + prompt="How often do you want to cancel and replace bids and asks " + "(in seconds)? >>> ", + required_if=lambda: not (using_exchange("radar_relay")() or + (using_exchange("bamboo_relay")() and not using_bamboo_coordinator_mode())), + type_str="float", + validator=lambda v: validate_decimal(v, 0, inclusive=False), + prompt_on_new=True), + "max_order_age": + ConfigVar(key="max_order_age", + prompt="How long do you want to cancel and replace bids and asks " + "with the same price (in seconds)? >>> ", + required_if=lambda: not (using_exchange("radar_relay")() or + (using_exchange("bamboo_relay")() and not using_bamboo_coordinator_mode())), + type_str="float", + default=Decimal("1800"), + validator=lambda v: validate_decimal(v, 0, inclusive=False)), + "order_refresh_tolerance_pct": + ConfigVar(key="order_refresh_tolerance_pct", + prompt="Enter the percent change in price needed to refresh orders at each cycle " + "(Enter 1 to indicate 1%) >>> ", + type_str="decimal", + default=Decimal("0"), + validator=lambda v: validate_decimal(v, -10, 10, inclusive=True)), + "filled_order_delay": + ConfigVar(key="filled_order_delay", + prompt="How long do you want to wait before placing the next order " + "if your order gets filled (in seconds)? >>> ", + type_str="float", + validator=lambda v: validate_decimal(v, min_value=0, inclusive=False), + default=60), + "inventory_target_base_pct": + ConfigVar(key="inventory_target_base_pct", + prompt="What is the inventory target for the base asset? Enter 50 for 50% >>> ", + type_str="decimal", + validator=lambda v: validate_decimal(v, 0, 100), + prompt_on_new=True, + default=Decimal("50")), + "add_transaction_costs": + ConfigVar(key="add_transaction_costs", + prompt="Do you want to add transaction costs automatically to order prices? (Yes/No) >>> ", + type_str="bool", + default=False, + validator=validate_bool), + "volatility_buffer_size": + ConfigVar(key="volatility_buffer_size", + prompt="Enter amount of ticks that will be stored to calculate volatility>>> ", + type_str="int", + validator=lambda v: validate_decimal(v, 5, 600), + default=60), +} diff --git a/hummingbot/strategy/avellaneda_market_making/data_types.py b/hummingbot/strategy/avellaneda_market_making/data_types.py new file mode 100644 index 0000000000..ce86cd6091 --- /dev/null +++ b/hummingbot/strategy/avellaneda_market_making/data_types.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python +from typing import ( + NamedTuple, + List +) +from decimal import Decimal +from hummingbot.core.event.events import OrderType + +ORDER_PROPOSAL_ACTION_CREATE_ORDERS = 1 +ORDER_PROPOSAL_ACTION_CANCEL_ORDERS = 1 << 1 + + +class OrdersProposal(NamedTuple): + actions: int + buy_order_type: OrderType + buy_order_prices: List[Decimal] + buy_order_sizes: List[Decimal] + sell_order_type: OrderType + sell_order_prices: List[Decimal] + sell_order_sizes: List[Decimal] + cancel_order_ids: List[str] + + +class PricingProposal(NamedTuple): + buy_order_prices: List[Decimal] + sell_order_prices: List[Decimal] + + +class SizingProposal(NamedTuple): + buy_order_sizes: List[Decimal] + sell_order_sizes: List[Decimal] + + +class PriceSize: + def __init__(self, price: Decimal, size: Decimal): + self.price: Decimal = price + self.size: Decimal = size + + def __repr__(self): + return f"[ p: {self.price} s: {self.size} ]" + + +class Proposal: + def __init__(self, buys: List[PriceSize], sells: List[PriceSize]): + self.buys: List[PriceSize] = buys + self.sells: List[PriceSize] = sells + + def __repr__(self): + return f"{len(self.buys)} buys: {', '.join([str(o) for o in self.buys])} " \ + f"{len(self.sells)} sells: {', '.join([str(o) for o in self.sells])}" diff --git a/hummingbot/strategy/avellaneda_market_making/start.py b/hummingbot/strategy/avellaneda_market_making/start.py new file mode 100644 index 0000000000..2d12fe8a39 --- /dev/null +++ b/hummingbot/strategy/avellaneda_market_making/start.py @@ -0,0 +1,85 @@ +from typing import ( + List, + Tuple, +) + +from hummingbot import data_path +import os.path +from hummingbot.client.hummingbot_application import HummingbotApplication +from hummingbot.strategy.market_trading_pair_tuple import MarketTradingPairTuple +from hummingbot.strategy.avellaneda_market_making import ( + AvellanedaMarketMakingStrategy, +) +from hummingbot.strategy.avellaneda_market_making.avellaneda_market_making_config_map import avellaneda_market_making_config_map as c_map +from decimal import Decimal +import pandas as pd + + +def start(self): + try: + order_amount = c_map.get("order_amount").value + order_optimization_enabled = c_map.get("order_optimization_enabled").value + order_refresh_time = c_map.get("order_refresh_time").value + exchange = c_map.get("exchange").value.lower() + raw_trading_pair = c_map.get("market").value + inventory_target_base_pct = 0 if c_map.get("inventory_target_base_pct").value is None else \ + c_map.get("inventory_target_base_pct").value / Decimal('100') + filled_order_delay = c_map.get("filled_order_delay").value + order_refresh_tolerance_pct = c_map.get("order_refresh_tolerance_pct").value / Decimal('100') + add_transaction_costs_to_orders = c_map.get("add_transaction_costs").value + + trading_pair: str = raw_trading_pair + maker_assets: Tuple[str, str] = self._initialize_market_assets(exchange, [trading_pair])[0] + market_names: List[Tuple[str, List[str]]] = [(exchange, [trading_pair])] + self._initialize_wallet(token_trading_pairs=list(set(maker_assets))) + self._initialize_markets(market_names) + self.assets = set(maker_assets) + maker_data = [self.markets[exchange], trading_pair] + list(maker_assets) + self.market_trading_pair_tuples = [MarketTradingPairTuple(*maker_data)] + + strategy_logging_options = AvellanedaMarketMakingStrategy.OPTION_LOG_ALL + parameters_based_on_spread = c_map.get("parameters_based_on_spread").value + if parameters_based_on_spread: + risk_factor = order_book_depth_factor = order_amount_shape_factor = None + min_spread = c_map.get("min_spread").value / Decimal(100) + max_spread = c_map.get("max_spread").value / Decimal(100) + vol_to_spread_multiplier = c_map.get("vol_to_spread_multiplier").value + inventory_risk_aversion = c_map.get("inventory_risk_aversion").value + else: + min_spread = max_spread = vol_to_spread_multiplier = inventory_risk_aversion = None + order_book_depth_factor = c_map.get("order_book_depth_factor").value + risk_factor = c_map.get("risk_factor").value + order_amount_shape_factor = c_map.get("order_amount_shape_factor").value + closing_time = c_map.get("closing_time").value * Decimal(3600 * 24 * 1e3) + volatility_buffer_size = c_map.get("volatility_buffer_size").value + debug_csv_path = os.path.join(data_path(), + HummingbotApplication.main_application().strategy_file_name.rsplit('.', 1)[0] + + f"_{pd.Timestamp.now().strftime('%Y-%m-%d_%H-%M-%S')}.csv") + + self.strategy = AvellanedaMarketMakingStrategy( + market_info=MarketTradingPairTuple(*maker_data), + order_amount=order_amount, + order_optimization_enabled=order_optimization_enabled, + inventory_target_base_pct=inventory_target_base_pct, + order_refresh_time=order_refresh_time, + order_refresh_tolerance_pct=order_refresh_tolerance_pct, + filled_order_delay=filled_order_delay, + add_transaction_costs_to_orders=add_transaction_costs_to_orders, + logging_options=strategy_logging_options, + hb_app_notification=True, + parameters_based_on_spread=parameters_based_on_spread, + min_spread=min_spread, + max_spread=max_spread, + vol_to_spread_multiplier=vol_to_spread_multiplier, + inventory_risk_aversion=inventory_risk_aversion, + order_book_depth_factor=order_book_depth_factor, + risk_factor=risk_factor, + order_amount_shape_factor=order_amount_shape_factor, + closing_time=closing_time, + debug_csv_path=debug_csv_path, + volatility_buffer_size=volatility_buffer_size, + is_debug=False + ) + except Exception as e: + self._notify(str(e)) + self.logger().error("Unknown error during initialization.", exc_info=True) diff --git a/hummingbot/strategy/cross_exchange_market_making/cross_exchange_market_making.pxd b/hummingbot/strategy/cross_exchange_market_making/cross_exchange_market_making.pxd index af6556e069..ac99e62386 100755 --- a/hummingbot/strategy/cross_exchange_market_making/cross_exchange_market_making.pxd +++ b/hummingbot/strategy/cross_exchange_market_making/cross_exchange_market_making.pxd @@ -31,10 +31,12 @@ cdef class CrossExchangeMarketMakingStrategy(StrategyBase): dict _market_pairs int64_t _logging_options OrderIDMarketPairTracker _market_pair_tracker + bint _use_oracle_conversion_rate object _taker_to_maker_base_conversion_rate object _taker_to_maker_quote_conversion_rate bint _hb_app_notification list _maker_order_ids + double _last_conv_rates_logged cdef c_process_market_pair(self, object market_pair, diff --git a/hummingbot/strategy/cross_exchange_market_making/cross_exchange_market_making.pyx b/hummingbot/strategy/cross_exchange_market_making/cross_exchange_market_making.pyx index 6bc8d1e66c..7f56c692ec 100755 --- a/hummingbot/strategy/cross_exchange_market_making/cross_exchange_market_making.pyx +++ b/hummingbot/strategy/cross_exchange_market_making/cross_exchange_market_making.pyx @@ -9,6 +9,7 @@ from math import ( ceil ) from numpy import isnan +import pandas as pd from typing import ( List, Tuple, @@ -28,6 +29,8 @@ from hummingbot.strategy.strategy_base cimport StrategyBase from hummingbot.strategy.strategy_base import StrategyBase from .cross_exchange_market_pair import CrossExchangeMarketPair from .order_id_market_pair_tracker import OrderIDMarketPairTracker +from hummingbot.core.rate_oracle.rate_oracle import RateOracle +from hummingbot.client.performance import smart_round NaN = float("nan") s_decimal_zero = Decimal(0) @@ -73,6 +76,7 @@ cdef class CrossExchangeMarketMakingStrategy(StrategyBase): top_depth_tolerance: Decimal = Decimal(0), logging_options: int = OPTION_LOG_ALL, status_report_interval: float = 900, + use_oracle_conversion_rate: bool = False, taker_to_maker_base_conversion_rate: Decimal = Decimal("1"), taker_to_maker_quote_conversion_rate: Decimal = Decimal("1"), hb_app_notification: bool = False @@ -132,8 +136,10 @@ cdef class CrossExchangeMarketMakingStrategy(StrategyBase): self._status_report_interval = status_report_interval self._market_pair_tracker = OrderIDMarketPairTracker() self._adjust_orders_enabled = adjust_order_enabled + self._use_oracle_conversion_rate = use_oracle_conversion_rate self._taker_to_maker_base_conversion_rate = taker_to_maker_base_conversion_rate self._taker_to_maker_quote_conversion_rate = taker_to_maker_quote_conversion_rate + self._last_conv_rates_logged = 0 self._hb_app_notification = hb_app_notification self._maker_order_ids = [] @@ -167,6 +173,56 @@ cdef class CrossExchangeMarketMakingStrategy(StrategyBase): def logging_options(self, int64_t logging_options): self._logging_options = logging_options + def get_taker_to_maker_conversion_rate(self) -> Tuple[str, Decimal, str, Decimal]: + """ + Find conversion rates from taker market to maker market + :return: A tuple of quote pair symbol, quote conversion rate source, quote conversion rate, + base pair symbol, base conversion rate source, base conversion rate + """ + quote_rate = Decimal("1") + market_pairs = list(self._market_pairs.values())[0] + quote_pair = f"{market_pairs.taker.quote_asset}-{market_pairs.maker.quote_asset}" + quote_rate_source = "fixed" + if self._use_oracle_conversion_rate: + if market_pairs.taker.quote_asset != market_pairs.maker.quote_asset: + quote_rate_source = RateOracle.source.name + quote_rate = RateOracle.get_instance().rate(quote_pair) + else: + quote_rate = self._taker_to_maker_quote_conversion_rate + base_rate = Decimal("1") + base_pair = f"{market_pairs.taker.base_asset}-{market_pairs.maker.base_asset}" + base_rate_source = "fixed" + if self._use_oracle_conversion_rate: + if market_pairs.taker.base_asset != market_pairs.maker.base_asset: + base_rate_source = RateOracle.source.name + base_rate = RateOracle.get_instance().rate(base_pair) + else: + base_rate = self._taker_to_maker_base_conversion_rate + return quote_pair, quote_rate_source, quote_rate, base_pair, base_rate_source, base_rate + + def log_conversion_rates(self): + quote_pair, quote_rate_source, quote_rate, base_pair, base_rate_source, base_rate = \ + self.get_taker_to_maker_conversion_rate() + if quote_pair.split("-")[0] != quote_pair.split("-")[1]: + self.logger().info(f"{quote_pair} ({quote_rate_source}) conversion rate: {smart_round(quote_rate)}") + if base_pair.split("-")[0] != base_pair.split("-")[1]: + self.logger().info(f"{base_pair} ({base_rate_source}) conversion rate: {smart_round(base_rate)}") + + def oracle_status_df(self): + columns = ["Source", "Pair", "Rate"] + data = [] + quote_pair, quote_rate_source, quote_rate, base_pair, base_rate_source, base_rate = \ + self.get_taker_to_maker_conversion_rate() + if quote_pair.split("-")[0] != quote_pair.split("-")[1]: + data.extend([ + [quote_rate_source, quote_pair, smart_round(quote_rate)], + ]) + if base_pair.split("-")[0] != base_pair.split("-")[1]: + data.extend([ + [base_rate_source, base_pair, smart_round(base_rate)], + ]) + return pd.DataFrame(data=data, columns=columns) + def format_status(self) -> str: cdef: list lines = [] @@ -190,6 +246,11 @@ cdef class CrossExchangeMarketMakingStrategy(StrategyBase): lines.extend(["", " Markets:"] + [" " + line for line in str(markets_df).split("\n")]) + oracle_df = self.oracle_status_df() + if not oracle_df.empty: + lines.extend(["", " Rate conversion:"] + + [" " + line for line in str(oracle_df).split("\n")]) + assets_df = self.wallet_balance_data_frame([market_pair.maker, market_pair.taker]) lines.extend(["", " Assets:"] + [" " + line for line in str(assets_df).split("\n")]) @@ -305,6 +366,10 @@ cdef class CrossExchangeMarketMakingStrategy(StrategyBase): # Process each market pair independently. for market_pair in self._market_pairs.values(): self.c_process_market_pair(market_pair, market_pair_to_active_orders[market_pair]) + # log conversion rates every 5 minutes + if self._last_conv_rates_logged + (60. * 5) < self._current_timestamp: + self.log_conversion_rates() + self._last_conv_rates_logged = self._current_timestamp finally: self._last_timestamp = timestamp @@ -1070,13 +1135,16 @@ cdef class CrossExchangeMarketMakingStrategy(StrategyBase): object order_price = active_order.price ExchangeBase maker_market = market_pair.maker.market ExchangeBase taker_market = market_pair.taker.market - - object quote_asset_amount = maker_market.c_get_balance(market_pair.maker.quote_asset) if is_buy else \ - taker_market.c_get_balance(market_pair.taker.quote_asset) - object base_asset_amount = taker_market.c_get_balance(market_pair.taker.base_asset) if is_buy else \ - maker_market.c_get_balance(market_pair.maker.base_asset) object order_size_limit + quote_pair, quote_rate_source, quote_rate, base_pair, base_rate_source, base_rate = \ + self.get_taker_to_maker_conversion_rate() + + quote_asset_amount = maker_market.c_get_balance(market_pair.maker.quote_asset) if is_buy else \ + taker_market.c_get_balance(market_pair.taker.quote_asset) * quote_rate + base_asset_amount = taker_market.c_get_balance(market_pair.taker.base_asset) * base_rate if is_buy else \ + maker_market.c_get_balance(market_pair.maker.base_asset) + order_size_limit = min(base_asset_amount, quote_asset_amount / order_price) quantized_size_limit = maker_market.c_quantize_order_amount(active_order.trading_pair, order_size_limit) @@ -1096,7 +1164,15 @@ cdef class CrossExchangeMarketMakingStrategy(StrategyBase): """ Return price conversion rate for a taker market (to convert it into maker base asset value) """ - return self._taker_to_maker_quote_conversion_rate / self._taker_to_maker_base_conversion_rate + _, _, quote_rate, _, _, base_rate = self.get_taker_to_maker_conversion_rate() + return quote_rate / base_rate + # else: + # market_pairs = list(self._market_pairs.values())[0] + # quote_pair = f"{market_pairs.taker.quote_asset}-{market_pairs.maker.quote_asset}" + # base_pair = f"{market_pairs.taker.base_asset}-{market_pairs.maker.base_asset}" + # quote_rate = RateOracle.get_instance().rate(quote_pair) + # base_rate = RateOracle.get_instance().rate(base_pair) + # return quote_rate / base_rate cdef c_check_and_create_new_orders(self, object market_pair, bint has_active_bid, bint has_active_ask): """ @@ -1126,15 +1202,15 @@ cdef class CrossExchangeMarketMakingStrategy(StrategyBase): True, bid_size ) - effective_hedging_price_adjusted = effective_hedging_price * self.market_conversion_rate() + effective_hedging_price_adjusted = effective_hedging_price / self.market_conversion_rate() if self._logging_options & self.OPTION_LOG_CREATE_ORDER: self.log_with_clock( logging.INFO, f"({market_pair.maker.trading_pair}) Creating limit bid order for " f"{bid_size} {market_pair.maker.base_asset} at " f"{bid_price} {market_pair.maker.quote_asset}. " - f"Current hedging price: {effective_hedging_price} {market_pair.taker.quote_asset} " - f"(Rate adjusted: {effective_hedging_price_adjusted:.2f} {market_pair.taker.quote_asset})." + f"Current hedging price: {effective_hedging_price:.8f} {market_pair.maker.quote_asset} " + f"(Rate adjusted: {effective_hedging_price_adjusted:.8f} {market_pair.taker.quote_asset})." ) order_id = self.c_place_order(market_pair, True, True, bid_size, bid_price) else: @@ -1165,15 +1241,15 @@ cdef class CrossExchangeMarketMakingStrategy(StrategyBase): False, ask_size ) - effective_hedging_price_adjusted = effective_hedging_price + effective_hedging_price_adjusted = effective_hedging_price / self.market_conversion_rate() if self._logging_options & self.OPTION_LOG_CREATE_ORDER: self.log_with_clock( logging.INFO, f"({market_pair.maker.trading_pair}) Creating limit ask order for " f"{ask_size} {market_pair.maker.base_asset} at " f"{ask_price} {market_pair.maker.quote_asset}. " - f"Current hedging price: {effective_hedging_price} {market_pair.maker.quote_asset} " - f"(Rate adjusted: {effective_hedging_price_adjusted:.2f} {market_pair.maker.quote_asset})." + f"Current hedging price: {effective_hedging_price:.8f} {market_pair.maker.quote_asset} " + f"(Rate adjusted: {effective_hedging_price_adjusted:.8f} {market_pair.taker.quote_asset})." ) order_id = self.c_place_order(market_pair, False, True, ask_size, ask_price) else: 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 2d8f2438a9..b621b0b554 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 @@ -5,7 +5,8 @@ validate_decimal, validate_bool ) -from hummingbot.client.settings import required_exchanges, EXAMPLE_PAIRS +from hummingbot.client.config.config_helpers import parse_cvar_value +import hummingbot.client.settings as settings from decimal import Decimal from hummingbot.client.config.config_helpers import ( minimum_order_amount @@ -15,7 +16,7 @@ def maker_trading_pair_prompt(): maker_market = cross_exchange_market_making_config_map.get("maker_market").value - example = EXAMPLE_PAIRS.get(maker_market) + example = settings.EXAMPLE_PAIRS.get(maker_market) return "Enter the token trading pair you would like to trade on maker market: %s%s >>> " % ( maker_market, f" (e.g. {example})" if example else "", @@ -24,7 +25,7 @@ def maker_trading_pair_prompt(): def taker_trading_pair_prompt(): taker_market = cross_exchange_market_making_config_map.get("taker_market").value - example = EXAMPLE_PAIRS.get(taker_market) + example = settings.EXAMPLE_PAIRS.get(taker_market) return "Enter the token trading pair you would like to trade on taker market: %s%s >>> " % ( taker_market, f" (e.g. {example})" if example else "", @@ -48,19 +49,19 @@ def validate_taker_market_trading_pair(value: str) -> Optional[str]: return validate_market_trading_pair(taker_market, value) -def order_amount_prompt() -> str: +async def order_amount_prompt() -> str: maker_exchange = cross_exchange_market_making_config_map["maker_market"].value trading_pair = cross_exchange_market_making_config_map["maker_market_trading_pair"].value base_asset, quote_asset = trading_pair.split("-") - min_amount = minimum_order_amount(maker_exchange, trading_pair) + min_amount = await minimum_order_amount(maker_exchange, trading_pair) return f"What is the amount of {base_asset} per order? (minimum {min_amount}) >>> " -def validate_order_amount(value: str) -> Optional[str]: +async def validate_order_amount(value: str) -> Optional[str]: try: maker_exchange = cross_exchange_market_making_config_map.get("maker_market").value trading_pair = cross_exchange_market_making_config_map["maker_market_trading_pair"].value - min_amount = minimum_order_amount(maker_exchange, trading_pair) + min_amount = await minimum_order_amount(maker_exchange, trading_pair) if Decimal(value) < min_amount: return f"Order amount must be at least {min_amount}." except Exception: @@ -68,7 +69,28 @@ def validate_order_amount(value: str) -> Optional[str]: def taker_market_on_validated(value: str): - required_exchanges.append(value) + settings.required_exchanges.append(value) + + +def update_oracle_settings(value: str): + c_map = cross_exchange_market_making_config_map + if not (c_map["use_oracle_conversion_rate"].value is not None and + c_map["maker_market_trading_pair"].value is not None and + c_map["taker_market_trading_pair"].value is not None): + return + use_oracle = parse_cvar_value(c_map["use_oracle_conversion_rate"], c_map["use_oracle_conversion_rate"].value) + first_base, first_quote = c_map["maker_market_trading_pair"].value.split("-") + second_base, second_quote = c_map["taker_market_trading_pair"].value.split("-") + if use_oracle and (first_base != second_base or first_quote != second_quote): + settings.required_rate_oracle = True + settings.rate_oracle_pairs = [] + if first_base != second_base: + settings.rate_oracle_pairs.append(f"{second_base}-{first_base}") + if first_quote != second_quote: + settings.rate_oracle_pairs.append(f"{second_quote}-{first_quote}") + else: + settings.required_rate_oracle = False + settings.rate_oracle_pairs = [] cross_exchange_market_making_config_map = { @@ -81,7 +103,7 @@ def taker_market_on_validated(value: str): prompt="Enter your maker spot connector >>> ", prompt_on_new=True, validator=validate_exchange, - on_validated=lambda value: required_exchanges.append(value), + on_validated=lambda value: settings.required_exchanges.append(value), ), "taker_market": ConfigVar( key="taker_market", @@ -94,13 +116,15 @@ def taker_market_on_validated(value: str): key="maker_market_trading_pair", prompt=maker_trading_pair_prompt, prompt_on_new=True, - validator=validate_maker_market_trading_pair + validator=validate_maker_market_trading_pair, + on_validated=update_oracle_settings ), "taker_market_trading_pair": ConfigVar( key="taker_market_trading_pair", prompt=taker_trading_pair_prompt, prompt_on_new=True, - validator=validate_taker_market_trading_pair + validator=validate_taker_market_trading_pair, + on_validated=update_oracle_settings ), "min_profitability": ConfigVar( key="min_profitability", @@ -193,22 +217,29 @@ def taker_market_on_validated(value: str): required_if=lambda: False, validator=lambda v: validate_decimal(v, Decimal(0), Decimal(100), inclusive=False) ), + "use_oracle_conversion_rate": ConfigVar( + key="use_oracle_conversion_rate", + type_str="bool", + prompt="Do you want to use rate oracle on unmatched trading pairs? (Yes/No) >>> ", + prompt_on_new=True, + validator=lambda v: validate_bool(v), + on_validated=update_oracle_settings), "taker_to_maker_base_conversion_rate": ConfigVar( key="taker_to_maker_base_conversion_rate", prompt="Enter conversion rate for taker base asset value to maker base asset value, e.g. " - "if maker base asset is USD, taker is DAI and 1 USD is worth 1.25 DAI, " - "the conversion rate is 0.8 (1 / 1.25) >>> ", + "if maker base asset is USD and the taker is DAI, 1 DAI is valued at 1.25 USD, " + "the conversion rate is 1.25 >>> ", default=Decimal("1"), - validator=lambda v: validate_decimal(v, Decimal(0), Decimal("100"), inclusive=False), + validator=lambda v: validate_decimal(v, Decimal(0), inclusive=False), type_str="decimal" ), "taker_to_maker_quote_conversion_rate": ConfigVar( key="taker_to_maker_quote_conversion_rate", prompt="Enter conversion rate for taker quote asset value to maker quote asset value, e.g. " - "if taker quote asset is USD, maker is DAI and 1 USD is worth 1.25 DAI, " - "the conversion rate is 0.8 (1 / 1.25) >>> ", + "if maker quote asset is USD and the taker is DAI, 1 DAI is valued at 1.25 USD, " + "the conversion rate is 1.25 >>> ", default=Decimal("1"), - validator=lambda v: validate_decimal(v, Decimal(0), Decimal("100"), inclusive=False), + validator=lambda v: validate_decimal(v, Decimal(0), inclusive=False), type_str="decimal" ), } diff --git a/hummingbot/strategy/cross_exchange_market_making/start.py b/hummingbot/strategy/cross_exchange_market_making/start.py index c3ffe6ef98..a735d69863 100644 --- a/hummingbot/strategy/cross_exchange_market_making/start.py +++ b/hummingbot/strategy/cross_exchange_market_making/start.py @@ -28,6 +28,7 @@ def start(self): order_size_taker_balance_factor = xemm_map.get("order_size_taker_balance_factor").value / Decimal("100") order_size_portfolio_ratio_limit = xemm_map.get("order_size_portfolio_ratio_limit").value / Decimal("100") anti_hysteresis_duration = xemm_map.get("anti_hysteresis_duration").value + use_oracle_conversion_rate = xemm_map.get("use_oracle_conversion_rate").value taker_to_maker_base_conversion_rate = xemm_map.get("taker_to_maker_base_conversion_rate").value taker_to_maker_quote_conversion_rate = xemm_map.get("taker_to_maker_quote_conversion_rate").value @@ -83,6 +84,7 @@ def start(self): order_size_taker_balance_factor=order_size_taker_balance_factor, order_size_portfolio_ratio_limit=order_size_portfolio_ratio_limit, anti_hysteresis_duration=anti_hysteresis_duration, + use_oracle_conversion_rate=use_oracle_conversion_rate, taker_to_maker_base_conversion_rate=taker_to_maker_base_conversion_rate, taker_to_maker_quote_conversion_rate=taker_to_maker_quote_conversion_rate, hb_app_notification=True, diff --git a/hummingbot/strategy/liquidity_mining/liquidity_mining.py b/hummingbot/strategy/liquidity_mining/liquidity_mining.py index 1b262376c2..b0de1f8cf7 100644 --- a/hummingbot/strategy/liquidity_mining/liquidity_mining.py +++ b/hummingbot/strategy/liquidity_mining/liquidity_mining.py @@ -15,11 +15,12 @@ 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 +from hummingbot.core.rate_oracle.rate_oracle import RateOracle + NaN = float("nan") s_decimal_zero = Decimal(0) s_decimal_nan = Decimal("NaN") @@ -192,15 +193,16 @@ def market_status_df(self) -> pd.DataFrame: async def miner_status_df(self) -> pd.DataFrame: data = [] + g_sym = RateOracle.global_token_symbol 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_wk) + reward = await RateOracle.global_value(campaign.payout_asset, campaign.reward_per_wk) data.append([ market, campaign.payout_asset, - f"${reward_usd:.0f}", - f"${campaign.liquidity_usd:.0f}", + f"{g_sym}{reward:.0f}", + f"{g_sym}{campaign.liquidity_usd:.0f}", f"{campaign.apy:.2%}", f"{campaign.spread_max:.2%}%" ]) diff --git a/hummingbot/strategy/perpetual_market_making/perpetual_market_making.pyx b/hummingbot/strategy/perpetual_market_making/perpetual_market_making.pyx index 7a842f6d2e..b5a635b37c 100644 --- a/hummingbot/strategy/perpetual_market_making/perpetual_market_making.pyx +++ b/hummingbot/strategy/perpetual_market_making/perpetual_market_making.pyx @@ -661,7 +661,7 @@ cdef class PerpetualMarketMakingStrategy(StrategyBase): for position in active_positions: if (ask_price > position.entry_price and position.amount > 0) or (bid_price < position.entry_price and position.amount < 0): # check if there is an active order to take profit, and create if none exists - profit_spread = self._long_profit_taking_spread if position.amount < 0 else self._short_profit_taking_spread + profit_spread = self._long_profit_taking_spread if position.amount > 0 else self._short_profit_taking_spread take_profit_price = position.entry_price * (Decimal("1") + profit_spread) if position.amount > 0 \ else position.entry_price * (Decimal("1") - profit_spread) price = market.c_quantize_order_price(self.trading_pair, take_profit_price) @@ -675,10 +675,10 @@ cdef class PerpetualMarketMakingStrategy(StrategyBase): size = market.c_quantize_order_amount(self.trading_pair, abs(position.amount)) if size > 0 and price > 0: if position.amount < 0: - self.logger().info(f"Creating profit taking buy order to lock profit on long position.") + self.logger().info(f"Creating profit taking buy order to lock profit on short position.") buys.append(PriceSize(price, size)) else: - self.logger().info(f"Creating profit taking sell order to lock profit on short position.") + self.logger().info(f"Creating profit taking sell order to lock profit on long position.") sells.append(PriceSize(price, size)) return Proposal(buys, sells) 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 1040ef9c53..978b93091e 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 @@ -39,19 +39,19 @@ def validate_derivative_position_mode(value: str) -> Optional[str]: return "Position mode can either be One-way or Hedge mode" -def order_amount_prompt() -> str: +async def order_amount_prompt() -> str: derivative = perpetual_market_making_config_map["derivative"].value trading_pair = perpetual_market_making_config_map["market"].value base_asset, quote_asset = trading_pair.split("-") - min_amount = minimum_order_amount(derivative, trading_pair) + min_amount = await minimum_order_amount(derivative, trading_pair) return f"What is the amount of {base_asset} per order? (minimum {min_amount}) >>> " -def validate_order_amount(value: str) -> Optional[str]: +async def validate_order_amount(value: str) -> Optional[str]: try: derivative = perpetual_market_making_config_map["derivative"].value trading_pair = perpetual_market_making_config_map["market"].value - min_amount = minimum_order_amount(derivative, trading_pair) + min_amount = await minimum_order_amount(derivative, trading_pair) if Decimal(value) < min_amount: return f"Order amount must be at least {min_amount}." except Exception: 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 7f8065b698..e488ec0f17 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 @@ -35,19 +35,19 @@ def validate_exchange_trading_pair(value: str) -> Optional[str]: return validate_market_trading_pair(exchange, value) -def order_amount_prompt() -> str: +async def order_amount_prompt() -> str: exchange = pure_market_making_config_map["exchange"].value trading_pair = pure_market_making_config_map["market"].value base_asset, quote_asset = trading_pair.split("-") - min_amount = minimum_order_amount(exchange, trading_pair) + min_amount = await minimum_order_amount(exchange, trading_pair) return f"What is the amount of {base_asset} per order? (minimum {min_amount}) >>> " -def validate_order_amount(value: str) -> Optional[str]: +async def validate_order_amount(value: str) -> Optional[str]: try: exchange = pure_market_making_config_map["exchange"].value trading_pair = pure_market_making_config_map["market"].value - min_amount = minimum_order_amount(exchange, trading_pair) + min_amount = await minimum_order_amount(exchange, trading_pair) if Decimal(value) < min_amount: return f"Order amount must be at least {min_amount}." except Exception: @@ -100,6 +100,11 @@ def validate_price_floor_ceiling(value: str) -> Optional[str]: return "Value must be more than 0 or -1 to disable this feature." +def on_validated_price_type(value: str): + if value == 'inventory_cost': + pure_market_making_config_map["inventory_price"].value = None + + def exchange_on_validated(value: str): required_exchanges.append(value) @@ -241,6 +246,7 @@ def exchange_on_validated(value: str): prompt="What is the price of your base asset inventory? ", type_str="decimal", validator=lambda v: validate_decimal(v, min_value=Decimal("0"), inclusive=True), + required_if=lambda: pure_market_making_config_map.get("price_type").value == "inventory_cost", default=Decimal("1"), ), "filled_order_delay": @@ -308,6 +314,7 @@ def exchange_on_validated(value: str): type_str="str", required_if=lambda: pure_market_making_config_map.get("price_source").value != "custom_api", default="mid_price", + on_validated=on_validated_price_type, validator=lambda s: None if s in {"mid_price", "last_price", "last_own_trade_price", diff --git a/hummingbot/templates/conf_arbitrage_strategy_TEMPLATE.yml b/hummingbot/templates/conf_arbitrage_strategy_TEMPLATE.yml index c383731e90..7459d5f5b6 100644 --- a/hummingbot/templates/conf_arbitrage_strategy_TEMPLATE.yml +++ b/hummingbot/templates/conf_arbitrage_strategy_TEMPLATE.yml @@ -2,7 +2,7 @@ ### Arbitrage strategy config ### ##################################### -template_version: 4 +template_version: 5 strategy: null # The following configuations are only required for the @@ -18,6 +18,10 @@ secondary_market_trading_pair: null # Expressed in percentage value, e.g. 1 = 1% target profit min_profitability: null +# Whether to use rate oracle on unmatched trading pairs +# Set this to either True or False +use_oracle_conversion_rate: null + # The conversion rate for secondary base asset value to primary base asset value. # e.g. if primary base asset is USD, secondary is DAI and 1 USD is worth 1.25 DAI, " # the conversion rate is 0.8 (1 / 1.25) diff --git a/hummingbot/templates/conf_avellaneda_market_making_strategy_TEMPLATE.yml b/hummingbot/templates/conf_avellaneda_market_making_strategy_TEMPLATE.yml new file mode 100644 index 0000000000..c4205052b4 --- /dev/null +++ b/hummingbot/templates/conf_avellaneda_market_making_strategy_TEMPLATE.yml @@ -0,0 +1,52 @@ +######################################################## +### Avellaneda market making strategy config ### +######################################################## + +template_version: 1 +strategy: null + +# Exchange and token parameters. +exchange: null + +# Token trading pair for the exchange, e.g. BTC-USDT +market: null + +# Time in seconds before cancelling and placing new orders. +# If the value is 60, the bot cancels active orders and placing new ones after a minute. +order_refresh_time: null + +# Whether to enable order optimization mode (true/false). +order_optimization_enabled: true + +# Time in seconds before replacing existing order with new orders at thesame price. +max_order_age: null + +# The spread (from mid price) to defer order refresh process to the next cycle. +# (Enter 1 to indicate 1%), value below 0, e.g. -1, is to disable this feature - not recommended. +order_refresh_tolerance_pct: null + +# Size of your bid and ask order. +order_amount: null + +# How long to wait before placing the next order in case your order gets filled. +filled_order_delay: null + +# Target base asset inventory percentage target to be maintained (for Inventory skew feature). +inventory_target_base_pct: null + +# Whether to enable adding transaction costs to order price calculation (true/false). +add_transaction_costs: null + +# Avellaneda - Stoikov algorithm parameters +parameters_based_on_spread: null +min_spread: null +max_spread: null +vol_to_spread_multiplier: null +inventory_risk_aversion: null +order_book_depth_factor: null +risk_factor: null +order_amount_shape_factor: null +closing_time: null + +# Buffer size used to store historic samples and calculate volatility +volatility_buffer_size: 60 \ No newline at end of file diff --git a/hummingbot/templates/conf_cross_exchange_market_making_strategy_TEMPLATE.yml b/hummingbot/templates/conf_cross_exchange_market_making_strategy_TEMPLATE.yml index dff79727cf..f435dbdbaa 100644 --- a/hummingbot/templates/conf_cross_exchange_market_making_strategy_TEMPLATE.yml +++ b/hummingbot/templates/conf_cross_exchange_market_making_strategy_TEMPLATE.yml @@ -2,7 +2,7 @@ ### Cross exchange market making strategy config ### ######################################################## -template_version: 4 +template_version: 5 strategy: null # The following configuations are only required for the @@ -60,6 +60,10 @@ order_size_taker_balance_factor: null # in terms of ratio of total portfolio value on both maker and taker markets order_size_portfolio_ratio_limit: null +# Whether to use rate oracle on unmatched trading pairs +# Set this to either True or False +use_oracle_conversion_rate: null + # The conversion rate for taker base asset value to maker base asset value. # e.g. if maker base asset is USD, taker is DAI and 1 USD is worth 1.25 DAI, " # the conversion rate is 0.8 (1 / 1.25) diff --git a/hummingbot/templates/conf_fee_overrides_TEMPLATE.yml b/hummingbot/templates/conf_fee_overrides_TEMPLATE.yml index c4f1e53d48..b8e79b7993 100644 --- a/hummingbot/templates/conf_fee_overrides_TEMPLATE.yml +++ b/hummingbot/templates/conf_fee_overrides_TEMPLATE.yml @@ -17,6 +17,9 @@ beaxy_taker_fee: coinbase_pro_maker_fee: coinbase_pro_taker_fee: +coinzoom_maker_fee: +coinzoom_taker_fee: + dydx_maker_fee: dydx_taker_fee: @@ -44,8 +47,9 @@ kraken_taker_fee: dolomite_maker_fee_amount: dolomite_taker_fee_amount: -eterbase_maker_fee: -eterbase_taker_fee: + +hitbtc_maker_fee: +hitbtc_taker_fee: loopring_maker_fee: loopring_taker_fee: @@ -71,8 +75,11 @@ okex_taker_fee: balancer_maker_fee_amount: balancer_taker_fee_amount: -bitmax_maker_fee: -bitmax_taker_fee: +uniswap_maker_fee_amount: +uniswap_taker_fee_amount: + +ascend_ex_maker_fee: +ascend_ex_taker_fee: probit_maker_fee: probit_taker_fee: diff --git a/hummingbot/templates/conf_global_TEMPLATE.yml b/hummingbot/templates/conf_global_TEMPLATE.yml index ebbb32196e..ad833aa312 100644 --- a/hummingbot/templates/conf_global_TEMPLATE.yml +++ b/hummingbot/templates/conf_global_TEMPLATE.yml @@ -3,7 +3,7 @@ ################################# # For more detailed information: https://docs.hummingbot.io -template_version: 18 +template_version: 20 # Exchange configs bamboo_relay_use_coordinator: false @@ -34,6 +34,10 @@ coinbase_pro_api_key: null coinbase_pro_secret_key: null coinbase_pro_passphrase: null +coinzoom_api_key: null +coinzoom_secret_key: null +coinzoom_username: null + dydx_eth_private_key: null dydx_node_address: null @@ -58,9 +62,9 @@ kraken_secret_key: null crypto_com_api_key: null crypto_com_secret_key: null -eterbase_api_key: null -eterbase_secret_key: null -eterbase_account: null + +hitbtc_api_key: null +hitbtc_secret_key: null bitfinex_api_key: null bitfinex_secret_key: null @@ -69,8 +73,8 @@ okex_api_key: null okex_secret_key: null okex_passphrase: null -bitmax_api_key: null -bitmax_secret_key: null +ascend_ex_api_key: null +ascend_ex_secret_key: null celo_address: null celo_password: null @@ -78,6 +82,9 @@ celo_password: null terra_wallet_address: null terra_wallet_seeds: null +digifinex_api_key: null +digifinex_secret_key: null + balancer_max_swaps: 4 probit_api_key: null @@ -91,13 +98,16 @@ ethereum_wallet: null ethereum_rpc_url: null ethereum_rpc_ws_url: null ethereum_chain_name: MAIN_NET -ethereum_token_list_url: null +ethereum_token_list_url: https://wispy-bird-88a7.uniswap.workers.dev/?url=http://tokens.1inch.eth.link # Kill switch kill_switch_enabled: null # The rate of performance at which you would want the bot to stop trading (-20 = 20%) kill_switch_rate: null +# What to auto-fill in the prompt after each import command (start/config) +autofill_import: null + # DEX active order cancellation 0x_active_cancels: false @@ -169,14 +179,7 @@ balance_asset_limit: # Fixed gas price (in Gwei) for Ethereum transactions manual_gas_price: -# To enable gas price lookup (true/false) -ethgasstation_gas_enabled: -# API key for defipulse.com gas station API -ethgasstation_api_key: -# Gas level you want to use for Ethereum transactions (fast, fastest, safeLow, average) -ethgasstation_gas_level: -# Refresh time for Ethereum gas price lookups (in seconds) -ethgasstation_refresh_time: + # Gateway API Configurations # default host to only use localhost # Port need to match the final installation port for Gateway @@ -189,3 +192,12 @@ heartbeat_enabled: heartbeat_interval_min: # a list of binance markets (for trades/pnl reporting) separated by ',' e.g. RLC-USDT,RLC-BTC binance_markets: + +# A source for rate oracle, currently binance or coingecko +rate_oracle_source: + +# A universal token which to display tokens values in, e.g. USD,EUR,BTC +global_token: + +# A symbol for the global token, e.g. $, € +global_token_symbol: \ No newline at end of file diff --git a/hummingbot/user/user_balances.py b/hummingbot/user/user_balances.py index b9e6e313c2..445c00b963 100644 --- a/hummingbot/user/user_balances.py +++ b/hummingbot/user/user_balances.py @@ -1,4 +1,4 @@ -from hummingbot.core.utils.market_price import get_mid_price +from hummingbot.core.utils.market_price import get_last_price from hummingbot.client.settings import CONNECTOR_SETTINGS from hummingbot.client.config.security import Security from hummingbot.client.config.config_helpers import get_connector_class, get_eth_wallet_private_key @@ -149,12 +149,12 @@ def validate_ethereum_wallet() -> Optional[str]: return None @staticmethod - def base_amount_ratio(exchange, trading_pair, balances) -> Optional[Decimal]: + async def base_amount_ratio(exchange, trading_pair, balances) -> Optional[Decimal]: try: base, quote = trading_pair.split("-") base_amount = balances.get(base, 0) quote_amount = balances.get(quote, 0) - price = get_mid_price(exchange, trading_pair) + price = await get_last_price(exchange, trading_pair) total_value = base_amount + (quote_amount / price) return None if total_value <= 0 else base_amount / total_value except Exception: diff --git a/installation/docker-commands/create-gateway.sh b/installation/docker-commands/create-gateway.sh index c6db0bae8c..49fc98579c 100755 --- a/installation/docker-commands/create-gateway.sh +++ b/installation/docker-commands/create-gateway.sh @@ -40,7 +40,7 @@ else else echo "‼️ hummingbot_conf & hummingbot_certs directory missing from path $FOLDER" prompt_hummingbot_data_path - fi + fi if [[ -f "$FOLDER/hummingbot_certs/server_cert.pem" && -f "$FOLDER/hummingbot_certs/server_key.pem" && -f "$FOLDER/hummingbot_certs/ca_cert.pem" ]]; then echo @@ -79,70 +79,208 @@ do then HUMMINGBOT_INSTANCE_ID="$(echo -e "${value}" | tr -d '[:space:]')" fi - # chain - if [ "$key" == "ethereum_chain_name" ] + # +done < "$GLOBAL_CONFIG" +} +read_global_config + +# prompt to setup balancer, uniswap +prompt_ethereum_setup () { + read -p " Do you want to setup Balancer or Uniswap? [Y/N] (default \"Y\") >>> " PROCEED + if [[ "$PROCEED" == "Y" || "$PROCEED" == "y" || "$PROCEED" == "" ]] then - ETHEREUM_CHAIN="$(echo -e "${value}" | tr -d '[:space:]')" - # subgraph url - if [[ "$ETHEREUM_CHAIN" == "MAIN_NET" || "$ETHEREUM_CHAIN" == "main_net" || "$ETHEREUM_CHAIN" == "MAINNET" || "$ETHEREUM_CHAIN" == "mainnet" ]] + ETHEREUM_SETUP=true + echo + read -p " Enter Ethereum chain you want to use [mainnet/kovan] (default = \"mainnet\") >>> " ETHEREUM_CHAIN + # chain selection + if [ "$ETHEREUM_CHAIN" == "" ] + then + ETHEREUM_CHAIN="mainnet" + fi + if [[ "$ETHEREUM_CHAIN" != "mainnet" && "$ETHEREUM_CHAIN" != "kovan" ]] + then + echo "‼️ ERROR. Unsupported chains (mainnet/kovan). " + prompt_ethereum_setup + fi + # set subgraph url, exchange_proxy + if [[ "$ETHEREUM_CHAIN" == "mainnet" ]] then ETHEREUM_CHAIN="mainnet" REACT_APP_SUBGRAPH_URL="https://api.thegraph.com/subgraphs/name/balancer-labs/balancer" EXCHANGE_PROXY="0x3E66B66Fd1d0b02fDa6C811Da9E0547970DB2f21" else - ETHEREUM_CHAIN="kovan" - REACT_APP_SUBGRAPH_URL="https://api.thegraph.com/subgraphs/name/balancer-labs/balancer-kovan" - EXCHANGE_PROXY="0x4e67bf5bD28Dd4b570FBAFe11D0633eCbA2754Ec" + if [[ "$ETHEREUM_CHAIN" == "kovan" ]] + then + REACT_APP_SUBGRAPH_URL="https://api.thegraph.com/subgraphs/name/balancer-labs/balancer-kovan" + EXCHANGE_PROXY="0x4e67bf5bD28Dd4b570FBAFe11D0633eCbA2754Ec" + fi fi fi - # ethereum rpc url - if [ "$key" == "ethereum_rpc_url" ] +} +prompt_ethereum_setup + +# prompt to ethereum rpc +prompt_ethereum_rpc_setup () { + if [ "$ETHEREUM_RPC_URL" == "" ] then - ETHEREUM_RPC_URL="$(echo -e "${value}" | tr -d '[:space:]')" + read -p " Enter the Ethereum RPC node URL to connect to >>> " ETHEREUM_RPC_URL + if [ "$ETHEREUM_RPC_URL" == "" ] + then + prompt_ethereum_rpc_setup + fi + else + read -p " Use the this Ethereum RPC node ($ETHEREUM_RPC_URL) setup in Hummingbot client? [Y/N] (default = \"Y\") >>> " PROCEED + if [[ "$PROCEED" == "Y" || "$PROCEED" == "y" || "$PROCEED" == "" ]] + then + echo + else + ETHEREUM_RPC_URL="" + prompt_ethereum_rpc_setup + fi fi -done < "$GLOBAL_CONFIG" } -read_global_config +prompt_ethereum_rpc_setup -# prompt to setup balancer, uniswap -prompt_ethereum_setup () { - read -p " Do you want to setup Balancer/Uniswap/Perpetual Finance? [Y/N] >>> " PROCEED - if [[ "$PROCEED" == "Y" || "$PROCEED" == "y" ]] +# prompt to setup ethereum token list +prompt_token_list_source () { + echo + echo " Enter the token list url available at https://tokenlists.org/" + read -p " (default = \"https://wispy-bird-88a7.uniswap.workers.dev/?url=http://tokens.1inch.eth.link\") >>> " ETHEREUM_TOKEN_LIST_URL + if [ "$ETHEREUM_TOKEN_LIST_URL" == "" ] then + echo echo "ℹ️ Retrieving config from Hummingbot config file ... " ETHEREUM_SETUP=true + ETHEREUM_TOKEN_LIST_URL=https://wispy-bird-88a7.uniswap.workers.dev/?url=http://tokens.1inch.eth.link + fi +} +prompt_token_list_source + +# prompt to setup eth gas level +prompt_eth_gasstation_gas_level () { + echo + read -p " Enter gas level you want to use for Ethereum transactions (fast, fastest, safeLow, average) (default = \"fast\") >>> " ETH_GAS_STATION_GAS_LEVEL + if [ "$ETH_GAS_STATION_GAS_LEVEL" == "" ] + then + ETH_GAS_STATION_GAS_LEVEL=fast + else + if [[ "$ETH_GAS_STATION_GAS_LEVEL" != "fast" && "$ETH_GAS_STATION_GAS_LEVEL" != "fastest" && "$ETH_GAS_STATION_GAS_LEVEL" != "safeLow" && "$ETH_GAS_STATION_GAS_LEVEL" != "safelow" && "$ETH_GAS_STATION_GAS_LEVEL" != "average" ]] + then + prompt_eth_gasstation_gas_level + fi + fi +} + +# prompt to setup eth gas station +prompt_eth_gasstation_setup () { + echo + read -p " Enable dynamic Ethereum gas price lookup? [Y/N] (default = \"Y\") >>> " PROCEED + if [[ "$PROCEED" == "Y" || "$PROCEED" == "y" || "$PROCEED" == "" ]] + then + ENABLE_ETH_GAS_STATION=true + read -p " Enter API key for Eth Gas Station (https://ethgasstation.info/) >>> " ETH_GAS_STATION_API_KEY + if [ "$ETH_GAS_STATION_API_KEY" == "" ] + then + prompt_eth_gasstation_setup + else + # set gas level + prompt_eth_gasstation_gas_level + + # set refresh interval + read -p " Enter refresh time for Ethereum gas price lookup (in seconds) (default = \"120\") >>> " ETH_GAS_STATION_REFRESH_TIME + if [ "$ETH_GAS_STATION_REFRESH_TIME" == "" ] + then + ETH_GAS_STATION_REFRESH_TIME=120 + fi + fi + else + if [[ "$PROCEED" == "N" || "$PROCEED" == "n" ]] + then + ENABLE_ETH_GAS_STATION=false + # set manual gas price + read -p " Enter fixed gas price (in Gwei) you want to use for Ethereum transactions (default = \"100\") >>> " MANUAL_GAS_PRICE + if [ "$MANUAL_GAS_PRICE" == "" ] + then + MANUAL_GAS_PRICE=100 + fi + else + prompt_eth_gasstation_setup + fi + fi + echo +} +prompt_eth_gasstation_setup + +prompt_balancer_setup () { + # Ask the user for the Balancer specific settings + echo "ℹ️ Balancer setting " + read -p " Enter the maximum Balancer swap pool (default = \"4\") >>> " BALANCER_MAX_SWAPS + if [ "$BALANCER_MAX_SWAPS" == "" ] + then + BALANCER_MAX_SWAPS="4" echo fi } -prompt_ethereum_setup -# Ask the user for ethereum network -prompt_terra_network () { -read -p " Enter Terra chain you want to use [mainnet/testnet] (default = \"mainnet\") >>> " TERRA -# chain selection -if [ "$TERRA" == "" ] -then - TERRA="mainnet" -fi -if [[ "$TERRA" != "mainnet" && "$TERRA" != "testnet" ]] -then - echo "‼️ ERROR. Unsupported chains (mainnet/testnet). " - prompt_terra_network -fi -# setup chain params -if [[ "$TERRA" == "mainnet" ]] -then - TERRA_LCD_URL="https://lcd.terra.dev" - TERRA_CHAIN="columbus-4" -elif [ "$TERRA" == "testnet" ] +prompt_uniswap_setup () { + # Ask the user for the Uniswap specific settings + echo "ℹ️ Uniswap setting " + read -p " Enter the allowed slippage for swap transactions (default = \"1.5\") >>> " UNISWAP_SLIPPAGE + if [ "$UNISWAP_SLIPPAGE" == "" ] + then + UNISWAP_SLIPPAGE="1.5" + echo + fi +} + +if [[ "$ETHEREUM_SETUP" == true ]] then - TERRA_LCD_URL="https://tequila-lcd.terra.dev" - TERRA_CHAIN="tequila-0004" + prompt_balancer_setup + prompt_uniswap_setup fi + +prompt_xdai_setup () { + # Ask the user for the Uniswap specific settings + echo "ℹ️ XDAI setting " + read -p " Enter preferred XDAI rpc provider (default = \"https://rpc.xdaichain.com\") >>> " XDAI_PROVIDER + if [ "$XDAI_PROVIDER" == "" ] + then + XDAI_PROVIDER="https://rpc.xdaichain.com" + echo + fi } +prompt_xdai_setup + +# Ask the user for ethereum network +prompt_terra_network () { + echo + read -p " Enter Terra chain you want to use [mainnet/testnet] (default = \"mainnet\") >>> " TERRA + # chain selection + if [ "$TERRA" == "" ] + then + TERRA="mainnet" + fi + if [[ "$TERRA" != "mainnet" && "$TERRA" != "testnet" ]] + then + echo "‼️ ERROR. Unsupported chains (mainnet/testnet). " + prompt_terra_network + fi + # setup chain params + if [[ "$TERRA" == "mainnet" ]] + then + TERRA_LCD_URL="https://lcd.terra.dev" + TERRA_CHAIN="columbus-4" + elif [ "$TERRA" == "testnet" ] + then + TERRA_LCD_URL="https://tequila-lcd.terra.dev" + TERRA_CHAIN="tequila-0004" + fi +} + prompt_terra_setup () { - read -p " Do you want to setup Terra? [Y/N] >>> " PROCEED - if [[ "$PROCEED" == "Y" || "$PROCEED" == "y" ]] + echo + read -p " Do you want to setup Terra? [Y/N] (default \"Y\") >>> " PROCEED + if [[ "$PROCEED" == "Y" || "$PROCEED" == "y" || "$PROCEED" == "" ]] then TERRA_SETUP=true prompt_terra_network @@ -202,9 +340,17 @@ echo printf "%30s %5s\n" "Hummingbot Instance ID:" "$HUMMINGBOT_INSTANCE_ID" printf "%30s %5s\n" "Ethereum Chain:" "$ETHEREUM_CHAIN" printf "%30s %5s\n" "Ethereum RPC URL:" "$ETHEREUM_RPC_URL" +printf "%30s %5s\n" "Ethereum Token List URL:" "$ETHEREUM_TOKEN_LIST_URL" +printf "%30s %5s\n" "Manual Gas Price:" "$MANUAL_GAS_PRICE" +printf "%30s %5s\n" "Enable Eth Gas Station:" "$ENABLE_ETH_GAS_STATION" +printf "%30s %5s\n" "Eth Gas Station API:" "$ETH_GAS_STATION_API_KEY" +printf "%30s %5s\n" "Eth Gas Station Level:" "$ETH_GAS_STATION_GAS_LEVEL" +printf "%30s %5s\n" "Eth Gas Station Refresh Interval:" "$ETH_GAS_STATION_REFRESH_TIME" printf "%30s %5s\n" "Balancer Subgraph:" "$REACT_APP_SUBGRAPH_URL" printf "%30s %5s\n" "Balancer Exchange Proxy:" "$EXCHANGE_PROXY" +printf "%30s %5s\n" "Balancer Max Swaps:" "$BALANCER_MAX_SWAPS" printf "%30s %5s\n" "Uniswap Router:" "$UNISWAP_ROUTER" +printf "%30s %5s\n" "Uniswap Allowed Slippage:" "$UNISWAP_SLIPPAGE" printf "%30s %5s\n" "Terra Chain:" "$TERRA" printf "%30s %5s\n" "Gateway Log Path:" "$LOG_PATH" printf "%30s %5s\n" "Gateway Cert Path:" "$CERT_PATH" @@ -220,13 +366,46 @@ echo "NODE_ENV=prod" >> $ENV_FILE echo "PORT=$PORT" >> $ENV_FILE echo "" >> $ENV_FILE echo "HUMMINGBOT_INSTANCE_ID=$HUMMINGBOT_INSTANCE_ID" >> $ENV_FILE + +# ethereum config +echo "" >> $ENV_FILE +echo "# Ethereum Settings" >> $ENV_FILE echo "ETHEREUM_CHAIN=$ETHEREUM_CHAIN" >> $ENV_FILE echo "ETHEREUM_RPC_URL=$ETHEREUM_RPC_URL" >> $ENV_FILE +echo "ETHEREUM_TOKEN_LIST_URL=$ETHEREUM_TOKEN_LIST_URL" >> $ENV_FILE +echo "" >> $ENV_FILE +echo "ENABLE_ETH_GAS_STATION=$ENABLE_ETH_GAS_STATION" >> $ENV_FILE +echo "ETH_GAS_STATION_API_KEY=$ETH_GAS_STATION_API_KEY" >> $ENV_FILE +echo "ETH_GAS_STATION_GAS_LEVEL=$ETH_GAS_STATION_GAS_LEVEL" >> $ENV_FILE +echo "ETH_GAS_STATION_REFRESH_TIME=$ETH_GAS_STATION_REFRESH_TIME" >> $ENV_FILE +echo "MANUAL_GAS_PRICE=$MANUAL_GAS_PRICE" >> $ENV_FILE + +# balancer config +echo "" >> $ENV_FILE +echo "# Balancer Settings" >> $ENV_FILE echo "REACT_APP_SUBGRAPH_URL=$REACT_APP_SUBGRAPH_URL" >> $ENV_FILE # must used "REACT_APP_SUBGRAPH_URL" for balancer-sor echo "EXCHANGE_PROXY=$EXCHANGE_PROXY" >> $ENV_FILE +echo "BALANCER_MAX_SWAPS=$BALANCER_MAX_SWAPS" >> $ENV_FILE + +# uniswap config +echo "" >> $ENV_FILE +echo "# Uniswap Settings" >> $ENV_FILE echo "UNISWAP_ROUTER=$UNISWAP_ROUTER" >> $ENV_FILE +echo "UNISWAP_ALLOWED_SLIPPAGE=$UNISWAP_SLIPPAGE" >> $ENV_FILE +echo "UNISWAP_NO_RESERVE_CHECK_INTERVAL=300000" >> $ENV_FILE +echo "UNISWAP_PAIRS_CACHE_TIME=1000" >> $ENV_FILE + +# terra config +echo "" >> $ENV_FILE +echo "# Terra Settings" >> $ENV_FILE echo "TERRA_LCD_URL=$TERRA_LCD_URL" >> $ENV_FILE echo "TERRA_CHAIN=$TERRA_CHAIN" >> $ENV_FILE + +# perpeptual finance config +echo "" >> $ENV_FILE +echo "# Perpeptual Settings" >> $ENV_FILE +echo "XDAI_PROVIDER=$XDAI_PROVIDER" >> $ENV_FILE + echo "" >> $ENV_FILE prompt_proceed () { @@ -234,7 +413,12 @@ prompt_proceed () { read -p " Do you want to proceed with installation? [Y/N] >>> " PROCEED if [ "$PROCEED" == "" ] then - PROCEED="Y" + prompt_proceed + else + if [[ "$PROCEED" != "Y" && "$PROCEED" != "y" ]] + then + PROCEED="N" + fi fi } diff --git a/setup.py b/setup.py index 6cd0dd9002..a1d52d191e 100755 --- a/setup.py +++ b/setup.py @@ -1,6 +1,7 @@ #!/usr/bin/env python from setuptools import setup +from setuptools.command.build_ext import build_ext from Cython.Build import cythonize import numpy as np import os @@ -17,9 +18,19 @@ os.environ["CFLAGS"] = "-std=c++11" +# Avoid a gcc warning below: +# cc1plus: warning: command line option ‘-Wstrict-prototypes’ is valid +# for C/ObjC but not for C++ +class BuildExt(build_ext): + def build_extensions(self): + if os.name != "nt" and '-Wstrict-prototypes' in self.compiler.compiler_so: + self.compiler.compiler_so.remove('-Wstrict-prototypes') + super().build_extensions() + + def main(): cpu_count = os.cpu_count() or 8 - version = "20210309" + version = "20210406" packages = [ "hummingbot", "hummingbot.client", @@ -39,11 +50,13 @@ def main(): "hummingbot.connector.connector.balancer", "hummingbot.connector.connector.terra", "hummingbot.connector.exchange", + "hummingbot.connector.exchange.ascend_ex", "hummingbot.connector.exchange.binance", "hummingbot.connector.exchange.bitfinex", "hummingbot.connector.exchange.bittrex", "hummingbot.connector.exchange.bamboo_relay", "hummingbot.connector.exchange.coinbase_pro", + "hummingbot.connector.exchange.coinzoom", "hummingbot.connector.exchange.dydx", "hummingbot.connector.exchange.huobi", "hummingbot.connector.exchange.radar_relay", @@ -56,7 +69,7 @@ def main(): "hummingbot.connector.exchange.dolomite", "hummingbot.connector.exchange.eterbase", "hummingbot.connector.exchange.beaxy", - "hummingbot.connector.exchange.bitmax", + "hummingbot.connector.exchange.hitbtc", "hummingbot.connector.derivative", "hummingbot.connector.derivative.binance_perpetual", "hummingbot.script", @@ -164,6 +177,7 @@ def main(): "bin/hummingbot.py", "bin/hummingbot_quickstart.py" ], + cmdclass={'build_ext': BuildExt}, ) diff --git a/setup/requirements-arm.txt b/setup/requirements-arm.txt index 93ed82682e..d50a0073af 100644 --- a/setup/requirements-arm.txt +++ b/setup/requirements-arm.txt @@ -22,7 +22,7 @@ mypy-extensions==0.4.3 python-telegram-bot==12.8 python-binance==0.7.5 pandas==1.1.0 -aiohttp==3.6.2 +aiohttp==3.7.4 bitstring==3.1.7 pyblake2==1.1.2 pysha3==1.0.2 diff --git a/test/connector/connector/balancer/test_balancer_connector.py b/test/connector/connector/balancer/test_balancer_connector.py index 151d0e403e..66ad53df97 100644 --- a/test/connector/connector/balancer/test_balancer_connector.py +++ b/test/connector/connector/balancer/test_balancer_connector.py @@ -28,8 +28,7 @@ global_config_map['gateway_api_host'].value = "localhost" global_config_map['gateway_api_port'].value = 5000 -global_config_map['ethgasstation_gas_enabled'].value = False -global_config_map['manual_gas_price'].value = 50 +global_config_map['ethereum_token_list_url'].value = "https://defi.cmc.eth.link" global_config_map.get("ethereum_chain_name").value = "kovan" trading_pair = "WETH-DAI" @@ -96,6 +95,14 @@ def setUp(self): for event_tag in self.events: self.connector.add_listener(event_tag, self.event_logger) + def test_fetch_trading_pairs(self): + asyncio.get_event_loop().run_until_complete(self._test_fetch_trading_pairs()) + + async def _test_fetch_trading_pairs(self): + pairs = await BalancerConnector.fetch_trading_pairs() + print(pairs) + self.assertGreater(len(pairs), 0) + def test_update_balances(self): all_bals = self.connector.get_all_balances() for token, bal in all_bals.items(): diff --git a/test/connector/exchange/bitmax/.gitignore b/test/connector/exchange/ascend_ex/.gitignore similarity index 100% rename from test/connector/exchange/bitmax/.gitignore rename to test/connector/exchange/ascend_ex/.gitignore diff --git a/test/connector/exchange/ascend_ex/__init__.py b/test/connector/exchange/ascend_ex/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/connector/exchange/bitmax/test_bitmax_auth.py b/test/connector/exchange/ascend_ex/test_ascend_ex_auth.py similarity index 72% rename from test/connector/exchange/bitmax/test_bitmax_auth.py rename to test/connector/exchange/ascend_ex/test_ascend_ex_auth.py index 46a68bfae7..59d866ad87 100644 --- a/test/connector/exchange/bitmax/test_bitmax_auth.py +++ b/test/connector/exchange/ascend_ex/test_ascend_ex_auth.py @@ -9,21 +9,22 @@ import logging from os.path import join, realpath from typing import Dict, Any -from hummingbot.connector.exchange.bitmax.bitmax_auth import BitmaxAuth +from hummingbot.connector.exchange.ascend_ex.ascend_ex_auth import AscendExAuth from hummingbot.logger.struct_logger import METRICS_LOG_LEVEL -from hummingbot.connector.exchange.bitmax.bitmax_constants import REST_URL, getWsUrlPriv +from hummingbot.connector.exchange.ascend_ex.ascend_ex_constants import REST_URL +from hummingbot.connector.exchange.ascend_ex.ascend_ex_util import get_rest_url_private sys.path.insert(0, realpath(join(__file__, "../../../../../"))) logging.basicConfig(level=METRICS_LOG_LEVEL) -class TestAuth(unittest.TestCase): +class TestAscendExAuth(unittest.TestCase): @classmethod def setUpClass(cls): cls.ev_loop: asyncio.BaseEventLoop = asyncio.get_event_loop() - api_key = conf.bitmax_api_key - secret_key = conf.bitmax_secret_key - cls.auth = BitmaxAuth(api_key, secret_key) + api_key = conf.ascend_ex_api_key + secret_key = conf.ascend_ex_secret_key + cls.auth = AscendExAuth(api_key, secret_key) async def rest_auth(self) -> Dict[Any, Any]: headers = { @@ -37,7 +38,7 @@ async def ws_auth(self) -> Dict[Any, Any]: info = await self.rest_auth() accountGroup = info.get("data").get("accountGroup") headers = self.auth.get_auth_headers("stream") - ws = await websockets.connect(f"{getWsUrlPriv(accountGroup)}/stream", extra_headers=headers) + ws = await websockets.connect(f"{get_rest_url_private(accountGroup)}/stream", extra_headers=headers) raw_msg = await asyncio.wait_for(ws.recv(), 5000) msg = ujson.loads(raw_msg) diff --git a/test/connector/exchange/bitmax/test_bitmax_exchange.py b/test/connector/exchange/ascend_ex/test_ascend_ex_exchange.py similarity index 97% rename from test/connector/exchange/bitmax/test_bitmax_exchange.py rename to test/connector/exchange/ascend_ex/test_ascend_ex_exchange.py index ca642fe385..e3c928520d 100644 --- a/test/connector/exchange/bitmax/test_bitmax_exchange.py +++ b/test/connector/exchange/ascend_ex/test_ascend_ex_exchange.py @@ -1,15 +1,17 @@ from os.path import join, realpath import sys; sys.path.insert(0, realpath(join(__file__, "../../../../../"))) + import asyncio -import logging -from decimal import Decimal -import unittest +import conf import contextlib -import time +import logging +import math import os +import time +import unittest + +from decimal import Decimal from typing import List -import conf -import math from hummingbot.core.clock import Clock, ClockMode from hummingbot.logger.struct_logger import METRICS_LOG_LEVEL @@ -33,15 +35,15 @@ from hummingbot.model.order import Order from hummingbot.model.trade_fill import TradeFill from hummingbot.connector.markets_recorder import MarketsRecorder -from hummingbot.connector.exchange.bitmax.bitmax_exchange import BitmaxExchange +from hummingbot.connector.exchange.ascend_ex.ascend_ex_exchange import AscendExExchange logging.basicConfig(level=METRICS_LOG_LEVEL) -API_KEY = conf.bitmax_api_key -API_SECRET = conf.bitmax_secret_key +API_KEY = conf.ascend_ex_api_key +API_SECRET = conf.ascend_ex_secret_key -class BitmaxExchangeUnitTest(unittest.TestCase): +class AscendExExchangeUnitTest(unittest.TestCase): events: List[MarketEvent] = [ MarketEvent.BuyOrderCompleted, MarketEvent.SellOrderCompleted, @@ -52,7 +54,7 @@ class BitmaxExchangeUnitTest(unittest.TestCase): MarketEvent.OrderCancelled, MarketEvent.OrderFailure ] - connector: BitmaxExchange + connector: AscendExExchange event_logger: EventLogger trading_pair = "BTC-USDT" base_token, quote_token = trading_pair.split("-") @@ -65,13 +67,13 @@ def setUpClass(cls): cls.ev_loop = asyncio.get_event_loop() cls.clock: Clock = Clock(ClockMode.REALTIME) - cls.connector: BitmaxExchange = BitmaxExchange( - bitmax_api_key=API_KEY, - bitmax_secret_key=API_SECRET, + cls.connector: AscendExExchange = AscendExExchange( + ascend_ex_api_key=API_KEY, + ascend_ex_secret_key=API_SECRET, trading_pairs=[cls.trading_pair], trading_required=True ) - print("Initializing Bitmax market... this will take about a minute.") + print("Initializing AscendEx exchange... 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) @@ -336,7 +338,7 @@ def test_orders_saving_and_restoration(self): self.clock.remove_iterator(self.connector) for event_tag in self.events: self.connector.remove_listener(event_tag, self.event_logger) - new_connector = BitmaxExchange(API_KEY, API_SECRET, [self.trading_pair], True) + new_connector = AscendExExchange(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() diff --git a/test/connector/exchange/bitmax/test_bitmax_order_book_tracker.py b/test/connector/exchange/ascend_ex/test_ascend_ex_order_book_tracker.py similarity index 88% rename from test/connector/exchange/bitmax/test_bitmax_order_book_tracker.py rename to test/connector/exchange/ascend_ex/test_ascend_ex_order_book_tracker.py index 331860fbba..ed19775c0d 100755 --- a/test/connector/exchange/bitmax/test_bitmax_order_book_tracker.py +++ b/test/connector/exchange/ascend_ex/test_ascend_ex_order_book_tracker.py @@ -9,8 +9,8 @@ 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.bitmax.bitmax_order_book_tracker import BitmaxOrderBookTracker -from hummingbot.connector.exchange.bitmax.bitmax_api_order_book_data_source import BitmaxAPIOrderBookDataSource +from hummingbot.connector.exchange.ascend_ex.ascend_ex_order_book_tracker import AscendExOrderBookTracker +from hummingbot.connector.exchange.ascend_ex.ascend_ex_api_order_book_data_source import AscendExAPIOrderBookDataSource from hummingbot.core.data_type.order_book import OrderBook from hummingbot.logger.struct_logger import METRICS_LOG_LEVEL @@ -19,8 +19,8 @@ logging.basicConfig(level=METRICS_LOG_LEVEL) -class BitmaxOrderBookTrackerUnitTest(unittest.TestCase): - order_book_tracker: Optional[BitmaxOrderBookTracker] = None +class AscendExOrderBookTrackerUnitTest(unittest.TestCase): + order_book_tracker: Optional[AscendExOrderBookTracker] = None events: List[OrderBookEvent] = [ OrderBookEvent.TradeEvent ] @@ -32,7 +32,7 @@ class BitmaxOrderBookTrackerUnitTest(unittest.TestCase): @classmethod def setUpClass(cls): cls.ev_loop: asyncio.BaseEventLoop = asyncio.get_event_loop() - cls.order_book_tracker: BitmaxOrderBookTracker = BitmaxOrderBookTracker(cls.trading_pairs) + cls.order_book_tracker: AscendExOrderBookTracker = AscendExOrderBookTracker(cls.trading_pairs) cls.order_book_tracker.start() cls.ev_loop.run_until_complete(cls.wait_til_tracker_ready()) @@ -96,7 +96,7 @@ def test_tracker_integrity(self): def test_api_get_last_traded_prices(self): prices = self.ev_loop.run_until_complete( - BitmaxAPIOrderBookDataSource.get_last_traded_prices(["BTC-USDT", "LTC-BTC"])) + AscendExAPIOrderBookDataSource.get_last_traded_prices(["BTC-USDT", "LTC-BTC"])) for key, value in prices.items(): print(f"{key} last_trade_price: {value}") self.assertGreater(prices["BTC-USDT"], 1000) diff --git a/test/connector/exchange/bitmax/test_bitmax_user_stream_tracker.py b/test/connector/exchange/ascend_ex/test_ascend_ex_user_stream_tracker.py similarity index 58% rename from test/connector/exchange/bitmax/test_bitmax_user_stream_tracker.py rename to test/connector/exchange/ascend_ex/test_ascend_ex_user_stream_tracker.py index 1b4cb5c84b..ce107d09f2 100644 --- a/test/connector/exchange/bitmax/test_bitmax_user_stream_tracker.py +++ b/test/connector/exchange/ascend_ex/test_ascend_ex_user_stream_tracker.py @@ -6,8 +6,8 @@ import conf from os.path import join, realpath -from hummingbot.connector.exchange.bitmax.bitmax_user_stream_tracker import BitmaxUserStreamTracker -from hummingbot.connector.exchange.bitmax.bitmax_auth import BitmaxAuth +from hummingbot.connector.exchange.ascend_ex.ascend_ex_user_stream_tracker import AscendExUserStreamTracker +from hummingbot.connector.exchange.ascend_ex.ascend_ex_auth import AscendExAuth from hummingbot.core.utils.async_utils import safe_ensure_future from hummingbot.logger.struct_logger import METRICS_LOG_LEVEL @@ -16,17 +16,17 @@ logging.basicConfig(level=METRICS_LOG_LEVEL) -class BitmaxUserStreamTrackerUnitTest(unittest.TestCase): - api_key = conf.bitmax_api_key - api_secret = conf.bitmax_secret_key +class AscendExUserStreamTrackerUnitTest(unittest.TestCase): + api_key = conf.ascend_ex_api_key + api_secret = conf.ascend_ex_secret_key @classmethod def setUpClass(cls): cls.ev_loop: asyncio.BaseEventLoop = asyncio.get_event_loop() - cls.bitmax_auth = BitmaxAuth(cls.api_key, cls.api_secret) + cls.ascend_ex_auth = AscendExAuth(cls.api_key, cls.api_secret) cls.trading_pairs = ["BTC-USDT"] - cls.user_stream_tracker: BitmaxUserStreamTracker = BitmaxUserStreamTracker( - bitmax_auth=cls.bitmax_auth, trading_pairs=cls.trading_pairs) + cls.user_stream_tracker: AscendExUserStreamTracker = AscendExUserStreamTracker( + ascend_ex_auth=cls.ascend_ex_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): diff --git a/test/connector/exchange/coinzoom/.gitignore b/test/connector/exchange/coinzoom/.gitignore new file mode 100644 index 0000000000..23d9952b8c --- /dev/null +++ b/test/connector/exchange/coinzoom/.gitignore @@ -0,0 +1 @@ +backups \ No newline at end of file diff --git a/test/connector/exchange/coinzoom/__init__.py b/test/connector/exchange/coinzoom/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/connector/exchange/coinzoom/test_coinzoom_auth.py b/test/connector/exchange/coinzoom/test_coinzoom_auth.py new file mode 100644 index 0000000000..f2573e4460 --- /dev/null +++ b/test/connector/exchange/coinzoom/test_coinzoom_auth.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python +import sys +import asyncio +import unittest +import aiohttp +import conf +import logging +from async_timeout import timeout +from os.path import join, realpath +from typing import Dict, Any +from hummingbot.connector.exchange.coinzoom.coinzoom_auth import CoinzoomAuth +from hummingbot.connector.exchange.coinzoom.coinzoom_websocket import CoinzoomWebsocket +from hummingbot.logger.struct_logger import METRICS_LOG_LEVEL +from hummingbot.connector.exchange.coinzoom.coinzoom_constants import Constants + +sys.path.insert(0, realpath(join(__file__, "../../../../../"))) +logging.basicConfig(level=METRICS_LOG_LEVEL) + + +class TestAuth(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.ev_loop: asyncio.BaseEventLoop = asyncio.get_event_loop() + api_key = conf.coinzoom_api_key + secret_key = conf.coinzoom_secret_key + api_username = conf.coinzoom_username + cls.auth = CoinzoomAuth(api_key, secret_key, api_username) + + async def rest_auth(self) -> Dict[Any, Any]: + endpoint = Constants.ENDPOINT['USER_BALANCES'] + headers = self.auth.get_headers() + response = await aiohttp.ClientSession().get(f"{Constants.REST_URL}/{endpoint}", headers=headers) + return await response.json() + + async def ws_auth(self) -> Dict[Any, Any]: + ws = CoinzoomWebsocket(self.auth) + await ws.connect() + user_ws_streams = {Constants.WS_SUB["USER_ORDERS_TRADES"]: {}} + async with timeout(30): + await ws.subscribe(user_ws_streams) + async for response in ws.on_message(): + if ws.is_subscribed: + return True + return False + + def test_rest_auth(self): + result = self.ev_loop.run_until_complete(self.rest_auth()) + if len(result) == 0 or "currency" not in result[0].keys(): + print(f"Unexpected response for API call: {result}") + assert "currency" in result[0].keys() + + def test_ws_auth(self): + subscribed = self.ev_loop.run_until_complete(self.ws_auth()) + assert subscribed is True diff --git a/test/connector/exchange/coinzoom/test_coinzoom_exchange.py b/test/connector/exchange/coinzoom/test_coinzoom_exchange.py new file mode 100644 index 0000000000..979b4651b6 --- /dev/null +++ b/test/connector/exchange/coinzoom/test_coinzoom_exchange.py @@ -0,0 +1,442 @@ +from os.path import join, realpath +import sys; sys.path.insert(0, realpath(join(__file__, "../../../../../"))) +import asyncio +import logging +from decimal import Decimal +import unittest +import contextlib +import time +import os +from typing import List +import conf +import math +from async_timeout import timeout + +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.coinzoom.coinzoom_exchange import CoinzoomExchange + +logging.basicConfig(level=METRICS_LOG_LEVEL) + +API_KEY = conf.coinzoom_api_key +API_SECRET = conf.coinzoom_secret_key +API_USERNAME = conf.coinzoom_username + + +class CoinzoomExchangeUnitTest(unittest.TestCase): + events: List[MarketEvent] = [ + MarketEvent.BuyOrderCompleted, + MarketEvent.SellOrderCompleted, + MarketEvent.OrderFilled, + MarketEvent.TransactionFailure, + MarketEvent.BuyOrderCreated, + MarketEvent.SellOrderCreated, + MarketEvent.OrderCancelled, + MarketEvent.OrderFailure + ] + connector: CoinzoomExchange + event_logger: EventLogger + trading_pair = "BTC-USD" + base_token, quote_token = trading_pair.split("-") + stack: contextlib.ExitStack + + @classmethod + def setUpClass(cls): + global MAINNET_RPC_URL + + cls.ev_loop = asyncio.get_event_loop() + + cls.clock: Clock = Clock(ClockMode.REALTIME) + cls.connector: CoinzoomExchange = CoinzoomExchange( + coinzoom_api_key=API_KEY, + coinzoom_secret_key=API_SECRET, + coinzoom_username=API_USERNAME, + trading_pairs=[cls.trading_pair], + trading_required=True + ) + print("Initializing Coinzoom 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 + async with timeout(90): + 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 _place_order(self, is_buy, amount, order_type, price, ex_order_id) -> str: + 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) + return cl_order_id + + def _cancel_order(self, cl_order_id, connector=None): + if connector is None: + connector = self.connector + return connector.cancel(self.trading_pair, cl_order_id) + + def test_estimate_fee(self): + maker_fee = self.connector.estimate_fee_pct(True) + self.assertAlmostEqual(maker_fee, Decimal("0.002")) + taker_fee = self.connector.estimate_fee_pct(False) + self.assertAlmostEqual(taker_fee, Decimal("0.0026")) + + def test_buy_and_sell(self): + price = self.connector.get_price(self.trading_pair, True) * Decimal("1.02") + 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._place_order(True, amount, OrderType.LIMIT, price, 1) + order_completed_event = self.ev_loop.run_until_complete(self.event_logger.wait_for(BuyOrderCompletedEvent)) + self.ev_loop.run_until_complete(asyncio.sleep(5)) + 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("USD", 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 str(event.order_id) == str(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.ev_loop.run_until_complete(asyncio.sleep(1)) + self.ev_loop.run_until_complete(self.connector._update_balances()) + self.assertAlmostEqual(expected_quote_bal, self.connector.get_available_balance(self.quote_token), 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.98") + 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._place_order(False, amount, OrderType.LIMIT, price, 2) + 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("USD", 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.ev_loop.run_until_complete(asyncio.sleep(1)) + self.ev_loop.run_until_complete(self.connector._update_balances()) + self.ev_loop.run_until_complete(asyncio.sleep(5)) + 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) + price_quantum = self.connector.get_order_price_quantum(self.trading_pair, price) + amount = self.connector.quantize_order_amount(self.trading_pair, Decimal("0.0001")) + self.ev_loop.run_until_complete(asyncio.sleep(1)) + self.ev_loop.run_until_complete(self.connector._update_balances()) + self.ev_loop.run_until_complete(asyncio.sleep(2)) + quote_bal = self.connector.get_available_balance(self.quote_token) + + cl_order_id = self._place_order(True, amount, OrderType.LIMIT_MAKER, price, 1) + 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 + taker_fee = self.connector.estimate_fee_pct(False) + quote_amount = (math.ceil(((price * amount) * (Decimal("1") + taker_fee)) / price_quantum) * price_quantum) + expected_quote_bal = quote_bal - quote_amount + self.ev_loop.run_until_complete(asyncio.sleep(1)) + self.ev_loop.run_until_complete(self.connector._update_balances()) + self.ev_loop.run_until_complete(asyncio.sleep(2)) + + self.assertAlmostEqual(expected_quote_bal, self.connector.get_available_balance(self.quote_token), 5) + 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._place_order(False, amount, OrderType.LIMIT_MAKER, price, 2) + 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) + + # # @TODO: find a way to create "rejected" + # 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.000001")) + # cl_order_id = self._place_order(True, amount, OrderType.LIMIT_MAKER, price, 1) + # 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.000001")) + # cl_order_id = self._place_order(False, amount, OrderType.LIMIT_MAKER, price, 2) + # 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.9")) + ask_price = self.connector.quantize_order_price(self.trading_pair, ask_price * Decimal("1.1")) + 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(15)) + self.ev_loop.run_until_complete(self.event_logger.wait_for(OrderCancelledEvent)) + self.ev_loop.run_until_complete(asyncio.sleep(1)) + 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_quantized_values(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 + + # Make sure there's enough balance to make the limit orders. + self.assertGreater(self.connector.get_balance("BTC"), Decimal("0.0005")) + self.assertGreater(self.connector.get_balance("USD"), 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 = self.connector.quantize_order_price(self.trading_pair, mid_price * Decimal("0.9333192292111341")) + ask_price = self.connector.quantize_order_price(self.trading_pair, mid_price * Decimal("1.1492431474884933")) + amount = self.connector.quantize_order_amount(self.trading_pair, Decimal("0.000123456")) + + # Test bid order + cl_order_id_1 = self._place_order(True, amount, OrderType.LIMIT, bid_price, 1) + # Wait for the order created event and examine the order made + self.ev_loop.run_until_complete(self.event_logger.wait_for(BuyOrderCreatedEvent)) + + # Test ask order + cl_order_id_2 = self._place_order(False, amount, OrderType.LIMIT, ask_price, 1) + # Wait for the order created event and examine and order made + self.ev_loop.run_until_complete(self.event_logger.wait_for(SellOrderCreatedEvent)) + + self._cancel_order(cl_order_id_1) + self.ev_loop.run_until_complete(self.event_logger.wait_for(OrderCancelledEvent)) + self._cancel_order(cl_order_id_2) + self.ev_loop.run_until_complete(self.event_logger.wait_for(OrderCancelledEvent)) + + 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._place_order(True, amount, OrderType.LIMIT_MAKER, price, 1) + 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) + # Clear the event loop + self.event_logger.clear() + new_connector = CoinzoomExchange(API_KEY, API_SECRET, API_USERNAME, [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.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) + 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, new_connector) + self.ev_loop.run_until_complete(self.event_logger.wait_for(OrderCancelledEvent)) + recorder.save_market_states(config_path, new_connector) + 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)) + 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._place_order(True, amount, OrderType.LIMIT, price, 1) + 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._place_order(False, amount, OrderType.LIMIT, price, 2) + self.ev_loop.run_until_complete(self.event_logger.wait_for(SellOrderCompletedEvent)) + 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) + 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/coinzoom/test_coinzoom_order_book_tracker.py b/test/connector/exchange/coinzoom/test_coinzoom_order_book_tracker.py new file mode 100755 index 0000000000..62a1a1b6d9 --- /dev/null +++ b/test/connector/exchange/coinzoom/test_coinzoom_order_book_tracker.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python +import sys +import math +import time +import asyncio +import logging +import unittest +from async_timeout import timeout +from os.path import join, realpath +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.coinzoom.coinzoom_order_book_tracker import CoinzoomOrderBookTracker +from hummingbot.connector.exchange.coinzoom.coinzoom_api_order_book_data_source import CoinzoomAPIOrderBookDataSource +from hummingbot.core.data_type.order_book import OrderBook +from hummingbot.logger.struct_logger import METRICS_LOG_LEVEL + + +sys.path.insert(0, realpath(join(__file__, "../../../../../"))) +logging.basicConfig(level=METRICS_LOG_LEVEL) + + +class CoinzoomOrderBookTrackerUnitTest(unittest.TestCase): + order_book_tracker: Optional[CoinzoomOrderBookTracker] = None + events: List[OrderBookEvent] = [ + OrderBookEvent.TradeEvent + ] + trading_pairs: List[str] = [ + "BTC-USD", + "ETH-USD", + ] + + @classmethod + def setUpClass(cls): + cls.ev_loop: asyncio.BaseEventLoop = asyncio.get_event_loop() + cls.order_book_tracker: CoinzoomOrderBookTracker = CoinzoomOrderBookTracker(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): + async with timeout(20): + 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, timeout=60)) + + 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 milliseconds + self.assertTrue(math.ceil(math.log10(ob_trade_event.timestamp)) == 13) + self.assertTrue(ob_trade_event.amount > 0) + self.assertTrue(ob_trade_event.price > 0) + + def test_tracker_integrity(self): + # Wait 5 seconds to process some diffs. + self.ev_loop.run_until_complete(asyncio.sleep(5.0)) + order_books: Dict[str, OrderBook] = self.order_book_tracker.order_books + eth_usd: OrderBook = order_books["ETH-USD"] + self.assertIsNot(eth_usd.last_diff_uid, 0) + self.assertGreaterEqual(eth_usd.get_price_for_volume(True, 10).result_price, + eth_usd.get_price(True)) + self.assertLessEqual(eth_usd.get_price_for_volume(False, 10).result_price, + eth_usd.get_price(False)) + + def test_api_get_last_traded_prices(self): + prices = self.ev_loop.run_until_complete( + CoinzoomAPIOrderBookDataSource.get_last_traded_prices(["BTC-USD", "LTC-BTC"])) + for key, value in prices.items(): + print(f"{key} last_trade_price: {value}") + self.assertGreater(prices["BTC-USD"], 1000) + self.assertLess(prices["LTC-BTC"], 1) diff --git a/test/connector/exchange/coinzoom/test_coinzoom_user_stream_tracker.py b/test/connector/exchange/coinzoom/test_coinzoom_user_stream_tracker.py new file mode 100644 index 0000000000..f9f85f335d --- /dev/null +++ b/test/connector/exchange/coinzoom/test_coinzoom_user_stream_tracker.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python + +import sys +import asyncio +import logging +import unittest +import conf + +from os.path import join, realpath +from hummingbot.connector.exchange.coinzoom.coinzoom_user_stream_tracker import CoinzoomUserStreamTracker +from hummingbot.connector.exchange.coinzoom.coinzoom_auth import CoinzoomAuth +from hummingbot.core.utils.async_utils import safe_ensure_future +from hummingbot.logger.struct_logger import METRICS_LOG_LEVEL + + +sys.path.insert(0, realpath(join(__file__, "../../../../../"))) +logging.basicConfig(level=METRICS_LOG_LEVEL) + + +class CoinzoomUserStreamTrackerUnitTest(unittest.TestCase): + api_key = conf.coinzoom_api_key + api_secret = conf.coinzoom_secret_key + api_username = conf.coinzoom_username + + @classmethod + def setUpClass(cls): + cls.ev_loop: asyncio.BaseEventLoop = asyncio.get_event_loop() + cls.trading_pairs = ["BTC-USD"] + cls.user_stream_tracker: CoinzoomUserStreamTracker = CoinzoomUserStreamTracker( + coinzoom_auth=CoinzoomAuth(cls.api_key, cls.api_secret, cls.api_username), + 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. + print("Sleeping for 30s to gather some user stream messages.") + self.ev_loop.run_until_complete(asyncio.sleep(30.0)) + print(self.user_stream_tracker.user_stream) diff --git a/test/connector/exchange/digifinex/__init__.py b/test/connector/exchange/digifinex/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/connector/exchange/digifinex/fixture.py b/test/connector/exchange/digifinex/fixture.py new file mode 100644 index 0000000000..85390bb01f --- /dev/null +++ b/test/connector/exchange/digifinex/fixture.py @@ -0,0 +1,127 @@ +BALANCES = { + 'id': 815129178419638016, 'method': 'private/get-account-summary', 'code': 0, + 'result': { + 'accounts': [{'balance': 50, 'available': 50, 'order': 0.0, 'stake': 0, 'currency': 'USDT'}, + {'balance': 0.002, 'available': 0.002, 'order': 0, 'stake': 0, 'currency': 'BTC'}] + } +} + +INSTRUMENTS = {'id': -1, 'method': 'public/get-instruments', 'code': 0, 'result': {'instruments': [ + {'instrument_name': 'BTC_USDT', 'quote_currency': 'USDT', 'base_currency': 'BTC', 'price_decimals': 2, + 'quantity_decimals': 6}, + {'instrument_name': 'ETH_USDT', 'quote_currency': 'USDT', 'base_currency': 'ETH', 'price_decimals': 2, + 'quantity_decimals': 5}, +]}} + +TICKERS = {'code': 0, 'method': 'public/get-ticker', 'result': { + 'instrument_name': 'BTC_USDT', + 'data': [{'i': 'BTC_USDT', 'b': 11490.0, 'k': 11492.05, + 'a': 11490.0, 't': 1598674849297, + 'v': 754.531926, 'h': 11546.11, 'l': 11366.62, + 'c': 104.19}]}} + +GET_BOOK = { + "code": 0, "method": "public/get-book", "result": { + "instrument_name": "BTC_USDT", "depth": 5, "data": + [{"bids": [[11490.00, 0.010676, 1], [11488.34, 0.055374, 1], [11487.47, 0.003000, 1], + [11486.50, 0.031032, 1], + [11485.97, 0.087074, 1]], + "asks": [[11492.05, 0.232044, 1], [11492.06, 0.497900, 1], [11493.12, 2.005693, 1], + [11494.12, 7.000000, 1], + [11494.41, 0.032853, 1]], "t": 1598676097390}]}} + +PLACE_ORDER = {'id': 632194937848317440, 'method': 'private/create-order', 'code': 0, + 'result': {'order_id': '1', 'client_oid': 'buy-BTC-USDT-1598607082008742'}} + +CANCEL = {'id': 31484728768575776, 'method': 'private/cancel-order', 'code': 0} + +UNFILLED_ORDER = { + 'id': 798015906490506624, + 'method': 'private/get-order-detail', + 'code': 0, + 'result': { + 'trade_list': [], + 'order_info': { + 'status': 'ACTIVE', + 'side': 'BUY', + 'price': 9164.82, + 'quantity': 0.0001, + 'order_id': '1', + 'client_oid': 'buy-BTC-USDT-1598607082008742', + 'create_time': 1598607082329, + 'update_time': 1598607082332, + 'type': 'LIMIT', + 'instrument_name': 'BTC_USDT', + 'avg_price': 0.0, + 'cumulative_quantity': 0.0, + 'cumulative_value': 0.0, + 'fee_currency': 'BTC', + 'exec_inst': 'POST_ONLY', + 'time_in_force': 'GOOD_TILL_CANCEL'} + } +} + +WS_INITIATED = {'id': 317343764453238848, 'method': 'public/auth', 'code': 0} +WS_SUBSCRIBE = {'id': 802984382214439040, 'method': 'subscribe', 'code': 0} +WS_HEARTBEAT = {'id': 1598755526207, 'method': 'public/heartbeat'} + +WS_ORDER_FILLED = { + 'id': -1, 'method': 'subscribe', 'code': 0, + 'result': { + 'instrument_name': 'BTC_USDT', + 'subscription': 'user.order.BTC_USDT', + 'channel': 'user.order', + 'data': [ + {'status': 'FILLED', + 'side': 'BUY', + 'price': 12080.9, + 'quantity': 0.0001, + 'order_id': '1', + 'client_oid': 'buy-BTC-USDT-1598681216010994', + 'create_time': 1598681216332, + 'update_time': 1598681216334, + 'type': 'LIMIT', + 'instrument_name': 'BTC_USDT', + 'avg_price': 11505.62, + 'cumulative_quantity': 0.0001, + 'cumulative_value': 11.50562, + 'fee_currency': 'BTC', + 'exec_inst': '', + 'time_in_force': 'GOOD_TILL_CANCEL'}]}} + +WS_TRADE = { + 'id': -1, 'method': 'subscribe', 'code': 0, + 'result': { + 'instrument_name': 'BTC_USDT', + 'subscription': 'user.trade.BTC_USDT', + 'channel': 'user.trade', + 'data': [ + {'side': 'BUY', + 'fee': 1.6e-06, + 'trade_id': '699422550491763776', + 'instrument_name': 'BTC_USDT', + 'create_time': 1598681216334, + 'traded_price': 11505.62, + 'traded_quantity': 0.0001, + 'fee_currency': 'BTC', + 'order_id': '1'}]}} + +WS_BALANCE = { + 'id': -1, 'method': 'subscribe', 'code': 0, + 'result': { + 'subscription': 'user.balance', 'channel': 'user.balance', + 'data': [{'balance': 47, 'available': 46, + 'order': 1, 'stake': 0, + 'currency': 'USDT'}]}} + +WS_ORDER_CANCELLED = { + 'id': -1, 'method': 'subscribe', 'code': 0, + 'result': { + 'instrument_name': 'BTC_USDT', 'subscription': 'user.order.BTC_USDT', + 'channel': 'user.order', 'data': [ + {'status': 'CANCELED', 'side': 'BUY', 'price': 13918.12, 'quantity': 0.0001, + 'order_id': '1', 'client_oid': 'buy-BTC-USDT-1598757896008300', + 'create_time': 1598757896312, 'update_time': 1598757896312, 'type': 'LIMIT', + 'instrument_name': 'BTC_USDT', 'avg_price': 0.0, 'cumulative_quantity': 0.0, + 'cumulative_value': 0.0, 'fee_currency': 'BTC', 'exec_inst': 'POST_ONLY', + 'time_in_force': 'GOOD_TILL_CANCEL'}]}} diff --git a/test/connector/exchange/digifinex/test_digifinex_auth.py b/test/connector/exchange/digifinex/test_digifinex_auth.py new file mode 100644 index 0000000000..4fcbc59c13 --- /dev/null +++ b/test/connector/exchange/digifinex/test_digifinex_auth.py @@ -0,0 +1,32 @@ +from os.path import join, realpath +import sys; sys.path.insert(0, realpath(join(__file__, "../../../../../"))) + +import asyncio +import unittest + +import conf +from hummingbot.connector.exchange.digifinex.digifinex_auth import DigifinexAuth +from hummingbot.connector.exchange.digifinex.digifinex_websocket import DigifinexWebsocket + + +class TestAuth(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.ev_loop: asyncio.BaseEventLoop = asyncio.get_event_loop() + api_key = conf.digifinex_api_key + secret_key = conf.digifinex_secret_key + cls.auth = DigifinexAuth(api_key, secret_key) + cls.ws = DigifinexWebsocket(cls.auth) + + async def ws_auth(self): + await self.ws.connect() + await self.ws.subscribe("balance", ["USDT", "BTC", "ETH"]) + + # no msg will arrive until balance changed after subscription + # async for response in self.ws.on_message(): + # if (response.get("method") == "subscribe"): + # return response + + def test_ws_auth(self): + self.ev_loop.run_until_complete(self.ws_auth()) + # assert result["code"] == 0 diff --git a/test/connector/exchange/digifinex/test_digifinex_exchange.py b/test/connector/exchange/digifinex/test_digifinex_exchange.py new file mode 100644 index 0000000000..54f9be5c57 --- /dev/null +++ b/test/connector/exchange/digifinex/test_digifinex_exchange.py @@ -0,0 +1,539 @@ +# print('__file__={0:<35} | __name__={1:<20} | __package__={2:<20}'.format(__file__,__name__,str(__package__))) +import os +from os.path import join, realpath +import sys; sys.path.insert(0, realpath(join(__file__, "../../../../../"))) +import asyncio +import logging +from decimal import Decimal +import unittest +import contextlib +import time +from typing import List +# from unittest import mock +import conf +import math + +from test.connector.exchange.digifinex import fixture +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.digifinex.digifinex_exchange import DigifinexExchange +# from hummingbot.connector.exchange.digifinex.digifinex_constants import WSS_PUBLIC_URL, WSS_PRIVATE_URL +# from test.integration.humming_web_app import HummingWebApp +# from test.integration.humming_ws_server import HummingWsServerFactory + +# API_MOCK_ENABLED = conf.mock_api_enabled is not None and conf.mock_api_enabled.lower() in ['true', 'yes', '1'] +API_MOCK_ENABLED = False + +logging.basicConfig(level=METRICS_LOG_LEVEL) +# logging.basicConfig(level=logging.NETWORK) +# logging.basicConfig(level=logging.DEBUG) +API_KEY = conf.digifinex_api_key +API_SECRET = conf.digifinex_secret_key +# BASE_API_URL = "openapi.digifinex.com" + + +class DigifinexExchangeUnitTest(unittest.TestCase): + events: List[MarketEvent] = [ + MarketEvent.BuyOrderCompleted, + MarketEvent.SellOrderCompleted, + MarketEvent.OrderFilled, + MarketEvent.TransactionFailure, + MarketEvent.BuyOrderCreated, + MarketEvent.SellOrderCreated, + MarketEvent.OrderCancelled, + MarketEvent.OrderFailure + ] + connector: DigifinexExchange + event_logger: EventLogger + trading_pair = "BTC-USDT" + base_token, quote_token = trading_pair.split("-") + stack: contextlib.ExitStack + sql: SQLConnectionManager + + @classmethod + def setUpClass(cls): + global MAINNET_RPC_URL + + cls.ev_loop = asyncio.get_event_loop() + + if API_MOCK_ENABLED: + raise NotImplementedError() + # 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: DigifinexExchange = DigifinexExchange( + digifinex_api_key=API_KEY, + digifinex_secret_key=API_SECRET, + trading_pairs=[cls.trading_pair], + trading_required=True + ) + print("Initializing Digifinex 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) + # 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): + 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: + # on windows cannot unlink the sqlite db file before closing the db + if os.name != 'nt': + 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 + # self.sql._engine.dispose() + + 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 _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): + self.ev_loop.run_until_complete(self.connector.cancel_all(0)) + + 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._place_order(True, amount, OrderType.LIMIT, price, 1, None, + fixture.WS_TRADE) + 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) + # todo: get fee + # 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), delta=0.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._place_order(False, amount, OrderType.LIMIT, price, 2, None, + fixture.WS_TRADE) + 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) + # todo: get fee + # 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.00005")) + quote_bal = self.connector.get_available_balance(self.quote_token) + + # order_id = self.connector.buy(self.trading_pair, amount, OrderType.LIMIT_MAKER, price) + cl_order_id = self._place_order(True, amount, OrderType.LIMIT_MAKER, price, 1, fixture.UNFILLED_ORDER) + 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), 1) + 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._place_order(False, amount, OrderType.LIMIT_MAKER, price, 2, fixture.UNFILLED_ORDER) + 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): + # 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) + + 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._place_order(True, amount, OrderType.LIMIT_MAKER, price, 1, None, None, + fixture.WS_ORDER_CANCELLED) + 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._place_order(False, amount, OrderType.LIMIT_MAKER, price, 2, None, None, + fixture.WS_ORDER_CANCELLED) + 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(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}) + + 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._place_order(True, amount, OrderType.LIMIT, bid_price, 1, fixture.UNFILLED_ORDER) + + # 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._place_order(False, amount, OrderType.LIMIT, ask_price, 1, fixture.UNFILLED_ORDER) + + # 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._place_order(True, amount, OrderType.LIMIT_MAKER, price, 1, fixture.UNFILLED_ORDER) + 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 = DigifinexExchange(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) + 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) + 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() + # sql._engine.dispose() + # on windows cannot unlink the sqlite db file before closing the db + if os.name != 'nt': + 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._place_order(True, amount, OrderType.LIMIT, price, 1, None, + fixture.WS_TRADE) + 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._place_order(False, amount, OrderType.LIMIT, price, 2, None, + fixture.WS_TRADE) + 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() + # sql._engine.dispose() + # on windows cannot unlink the sqlite db file before closing the db + if os.name != 'nt': + os.unlink(self.db_path) + + +# unittest.main() diff --git a/test/connector/exchange/digifinex/test_digifinex_order_book_tracker.py b/test/connector/exchange/digifinex/test_digifinex_order_book_tracker.py new file mode 100644 index 0000000000..ebbe50fc0e --- /dev/null +++ b/test/connector/exchange/digifinex/test_digifinex_order_book_tracker.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python +from os.path import join, realpath +import sys; sys.path.insert(0, realpath(join(__file__, "../../../../../"))) +import math +import time +import asyncio +import logging +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.digifinex.digifinex_order_book_tracker import DigifinexOrderBookTracker +from hummingbot.connector.exchange.digifinex.digifinex_api_order_book_data_source import DigifinexAPIOrderBookDataSource +from hummingbot.core.data_type.order_book import OrderBook + + +class DigifinexOrderBookTrackerUnitTest(unittest.TestCase): + order_book_tracker: Optional[DigifinexOrderBookTracker] = 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: DigifinexOrderBookTracker = DigifinexOrderBookTracker(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): + logmsg = [f"{x.__qualname__}({x.cr_frame.f_locals if x.cr_frame is not None else None})" for x in tasks] + 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) + if future._exception is not None: + logging.exception(logmsg) + 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 5 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( + DigifinexAPIOrderBookDataSource.get_last_traded_prices(["BTC-USDT", "LTC-USDT"])) + for key, value in prices.items(): + print(f"{key} last_trade_price: {value}") + self.assertGreater(prices["BTC-USDT"], 1000) + self.assertLess(prices["LTC-USDT"], 10000) diff --git a/test/connector/exchange/digifinex/test_digifinex_user_stream_tracker.py b/test/connector/exchange/digifinex/test_digifinex_user_stream_tracker.py new file mode 100644 index 0000000000..f529ee22f6 --- /dev/null +++ b/test/connector/exchange/digifinex/test_digifinex_user_stream_tracker.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python + +from os.path import join, realpath +import sys; sys.path.insert(0, realpath(join(__file__, "../../../../../"))) + +import asyncio +import logging +import unittest +import conf + +from os.path import join, realpath +from hummingbot.connector.exchange.digifinex.digifinex_user_stream_tracker import DigifinexUserStreamTracker +from hummingbot.connector.exchange.digifinex.digifinex_global import DigifinexGlobal +from hummingbot.core.utils.async_utils import safe_ensure_future + +sys.path.insert(0, realpath(join(__file__, "../../../"))) + + +class DigifinexUserStreamTrackerUnitTest(unittest.TestCase): + api_key = conf.digifinex_api_key + api_secret = conf.digifinex_secret_key + + @classmethod + def setUpClass(cls): + cls.ev_loop: asyncio.BaseEventLoop = asyncio.get_event_loop() + cls._global = DigifinexGlobal(cls.api_key, cls.api_secret) + cls.trading_pairs = ["BTC-USDT"] + cls.user_stream_tracker: DigifinexUserStreamTracker = DigifinexUserStreamTracker( + cls._global, 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() diff --git a/test/connector/exchange/hitbtc/.gitignore b/test/connector/exchange/hitbtc/.gitignore new file mode 100644 index 0000000000..23d9952b8c --- /dev/null +++ b/test/connector/exchange/hitbtc/.gitignore @@ -0,0 +1 @@ +backups \ No newline at end of file diff --git a/test/connector/exchange/hitbtc/__init__.py b/test/connector/exchange/hitbtc/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/connector/exchange/hitbtc/test_hitbtc_auth.py b/test/connector/exchange/hitbtc/test_hitbtc_auth.py new file mode 100644 index 0000000000..1943412ea3 --- /dev/null +++ b/test/connector/exchange/hitbtc/test_hitbtc_auth.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python +import sys +import asyncio +import unittest +import aiohttp +import conf +import logging +from os.path import join, realpath +from typing import Dict, Any +from hummingbot.connector.exchange.hitbtc.hitbtc_auth import HitbtcAuth +from hummingbot.connector.exchange.hitbtc.hitbtc_websocket import HitbtcWebsocket +from hummingbot.logger.struct_logger import METRICS_LOG_LEVEL +from hummingbot.connector.exchange.hitbtc.hitbtc_constants import Constants + +sys.path.insert(0, realpath(join(__file__, "../../../../../"))) +logging.basicConfig(level=METRICS_LOG_LEVEL) + + +class TestAuth(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.ev_loop: asyncio.BaseEventLoop = asyncio.get_event_loop() + api_key = conf.hitbtc_api_key + secret_key = conf.hitbtc_secret_key + cls.auth = HitbtcAuth(api_key, secret_key) + + async def rest_auth(self) -> Dict[Any, Any]: + endpoint = Constants.ENDPOINT['USER_BALANCES'] + headers = self.auth.get_headers("GET", f"{Constants.REST_URL_AUTH}/{endpoint}", None) + http_client = aiohttp.ClientSession() + response = await http_client.get(f"{Constants.REST_URL}/{endpoint}", headers=headers) + await http_client.close() + return await response.json() + + async def ws_auth(self) -> Dict[Any, Any]: + ws = HitbtcWebsocket(self.auth) + await ws.connect() + await ws.subscribe(Constants.WS_SUB["USER_ORDERS_TRADES"], None, {}) + async for response in ws.on_message(): + return response + + def test_rest_auth(self): + result = self.ev_loop.run_until_complete(self.rest_auth()) + if len(result) == 0 or "currency" not in result[0].keys(): + print(f"Unexpected response for API call: {result}") + assert "currency" in result[0].keys() + + def test_ws_auth(self): + response = self.ev_loop.run_until_complete(self.ws_auth()) + if 'result' not in response: + print(f"Unexpected response for API call: {response}") + assert response['result'] is True diff --git a/test/connector/exchange/hitbtc/test_hitbtc_exchange.py b/test/connector/exchange/hitbtc/test_hitbtc_exchange.py new file mode 100644 index 0000000000..f63d4829d3 --- /dev/null +++ b/test/connector/exchange/hitbtc/test_hitbtc_exchange.py @@ -0,0 +1,438 @@ +from os.path import join, realpath +import sys; sys.path.insert(0, realpath(join(__file__, "../../../../../"))) +import asyncio +import logging +from decimal import Decimal +import unittest +import contextlib +import time +import os +from typing import List +import conf +import math + +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.hitbtc.hitbtc_exchange import HitbtcExchange + +logging.basicConfig(level=METRICS_LOG_LEVEL) + +API_KEY = conf.hitbtc_api_key +API_SECRET = conf.hitbtc_secret_key + + +class HitbtcExchangeUnitTest(unittest.TestCase): + events: List[MarketEvent] = [ + MarketEvent.BuyOrderCompleted, + MarketEvent.SellOrderCompleted, + MarketEvent.OrderFilled, + MarketEvent.TransactionFailure, + MarketEvent.BuyOrderCreated, + MarketEvent.SellOrderCreated, + MarketEvent.OrderCancelled, + MarketEvent.OrderFailure + ] + connector: HitbtcExchange + event_logger: EventLogger + trading_pair = "BTC-USDT" + base_token, quote_token = trading_pair.split("-") + stack: contextlib.ExitStack + + @classmethod + def setUpClass(cls): + global MAINNET_RPC_URL + + cls.ev_loop = asyncio.get_event_loop() + + cls.clock: Clock = Clock(ClockMode.REALTIME) + cls.connector: HitbtcExchange = HitbtcExchange( + hitbtc_api_key=API_KEY, + hitbtc_secret_key=API_SECRET, + trading_pairs=[cls.trading_pair], + trading_required=True + ) + print("Initializing Hitbtc 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 _place_order(self, is_buy, amount, order_type, price, ex_order_id) -> str: + 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) + return cl_order_id + + def _cancel_order(self, cl_order_id, connector=None): + if connector is None: + connector = self.connector + return connector.cancel(self.trading_pair, cl_order_id) + + 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.0025")) + + def test_buy_and_sell(self): + price = self.connector.get_price(self.trading_pair, True) * Decimal("1.02") + price = self.connector.quantize_order_price(self.trading_pair, price) + amount = self.connector.quantize_order_amount(self.trading_pair, Decimal("0.0002")) + quote_bal = self.connector.get_available_balance(self.quote_token) + base_bal = self.connector.get_available_balance(self.base_token) + + order_id = self._place_order(True, amount, OrderType.LIMIT, price, 1) + order_completed_event = self.ev_loop.run_until_complete(self.event_logger.wait_for(BuyOrderCompletedEvent)) + self.ev_loop.run_until_complete(asyncio.sleep(5)) + 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 str(event.order_id) == str(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.ev_loop.run_until_complete(asyncio.sleep(1)) + self.ev_loop.run_until_complete(self.connector._update_balances()) + self.assertAlmostEqual(expected_quote_bal, self.connector.get_available_balance(self.quote_token), 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.98") + price = self.connector.quantize_order_price(self.trading_pair, price) + amount = self.connector.quantize_order_amount(self.trading_pair, Decimal("0.0002")) + order_id = self._place_order(False, amount, OrderType.LIMIT, price, 2) + 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.ev_loop.run_until_complete(asyncio.sleep(1)) + self.ev_loop.run_until_complete(self.connector._update_balances()) + self.ev_loop.run_until_complete(asyncio.sleep(5)) + 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.0002")) + self.ev_loop.run_until_complete(asyncio.sleep(1)) + self.ev_loop.run_until_complete(self.connector._update_balances()) + self.ev_loop.run_until_complete(asyncio.sleep(2)) + quote_bal = self.connector.get_available_balance(self.quote_token) + + cl_order_id = self._place_order(True, amount, OrderType.LIMIT_MAKER, price, 1) + 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 + taker_fee = self.connector.estimate_fee_pct(False) + quote_amount = ((price * amount)) + quote_amount = ((price * amount) * (Decimal("1") + taker_fee)) + expected_quote_bal = quote_bal - quote_amount + self.ev_loop.run_until_complete(asyncio.sleep(1)) + self.ev_loop.run_until_complete(self.connector._update_balances()) + self.ev_loop.run_until_complete(asyncio.sleep(2)) + + self.assertAlmostEqual(expected_quote_bal, self.connector.get_available_balance(self.quote_token), 5) + 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.0002")) + + cl_order_id = self._place_order(False, amount, OrderType.LIMIT_MAKER, price, 2) + 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) + + # # @TODO: find a way to create "rejected" + # 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.000001")) + # cl_order_id = self._place_order(True, amount, OrderType.LIMIT_MAKER, price, 1) + # 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.000001")) + # cl_order_id = self._place_order(False, amount, OrderType.LIMIT_MAKER, price, 2) + # 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.9")) + ask_price = self.connector.quantize_order_price(self.trading_pair, ask_price * Decimal("1.1")) + amount = self.connector.quantize_order_amount(self.trading_pair, Decimal("0.0002")) + + 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(self.event_logger.wait_for(OrderCancelledEvent)) + self.ev_loop.run_until_complete(asyncio.sleep(1)) + 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_quantized_values(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 + + # Make sure there's enough balance to make the limit orders. + self.assertGreater(self.connector.get_balance("BTC"), Decimal("0.0005")) + 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 = self.connector.quantize_order_price(self.trading_pair, mid_price * Decimal("0.9333192292111341")) + ask_price = self.connector.quantize_order_price(self.trading_pair, mid_price * Decimal("1.1492431474884933")) + amount = self.connector.quantize_order_amount(self.trading_pair, Decimal("0.000223456")) + + # Test bid order + cl_order_id_1 = self._place_order(True, amount, OrderType.LIMIT, bid_price, 1) + # Wait for the order created event and examine the order made + self.ev_loop.run_until_complete(self.event_logger.wait_for(BuyOrderCreatedEvent)) + + # Test ask order + cl_order_id_2 = self._place_order(False, amount, OrderType.LIMIT, ask_price, 1) + # Wait for the order created event and examine and order made + self.ev_loop.run_until_complete(self.event_logger.wait_for(SellOrderCreatedEvent)) + + self._cancel_order(cl_order_id_1) + self.ev_loop.run_until_complete(self.event_logger.wait_for(OrderCancelledEvent)) + self._cancel_order(cl_order_id_2) + self.ev_loop.run_until_complete(self.event_logger.wait_for(OrderCancelledEvent)) + + 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.0002") + amount = self.connector.quantize_order_amount(self.trading_pair, amount) + + cl_order_id = self._place_order(True, amount, OrderType.LIMIT_MAKER, price, 1) + 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) + # Clear the event loop + self.event_logger.clear() + new_connector = HitbtcExchange(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.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) + 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, new_connector) + self.ev_loop.run_until_complete(self.event_logger.wait_for(OrderCancelledEvent)) + recorder.save_market_states(config_path, new_connector) + 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)) + 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.0002")) + + order_id = self._place_order(True, amount, OrderType.LIMIT, price, 1) + 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.0002")) + order_id = self._place_order(False, amount, OrderType.LIMIT, price, 2) + self.ev_loop.run_until_complete(self.event_logger.wait_for(SellOrderCompletedEvent)) + 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) + 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/hitbtc/test_hitbtc_order_book_tracker.py b/test/connector/exchange/hitbtc/test_hitbtc_order_book_tracker.py new file mode 100755 index 0000000000..bb929c3474 --- /dev/null +++ b/test/connector/exchange/hitbtc/test_hitbtc_order_book_tracker.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python +import sys +import math +import time +import asyncio +import logging +import unittest +from os.path import join, realpath +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.hitbtc.hitbtc_order_book_tracker import HitbtcOrderBookTracker +from hummingbot.connector.exchange.hitbtc.hitbtc_api_order_book_data_source import HitbtcAPIOrderBookDataSource +from hummingbot.core.data_type.order_book import OrderBook +from hummingbot.logger.struct_logger import METRICS_LOG_LEVEL + + +sys.path.insert(0, realpath(join(__file__, "../../../../../"))) +logging.basicConfig(level=METRICS_LOG_LEVEL) + + +class HitbtcOrderBookTrackerUnitTest(unittest.TestCase): + order_book_tracker: Optional[HitbtcOrderBookTracker] = 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: HitbtcOrderBookTracker = HitbtcOrderBookTracker(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 5 seconds to process some diffs. + self.ev_loop.run_until_complete(asyncio.sleep(5.0)) + order_books: Dict[str, OrderBook] = self.order_book_tracker.order_books + eth_usd: OrderBook = order_books["ETH-USDT"] + self.assertIsNot(eth_usd.last_diff_uid, 0) + self.assertGreaterEqual(eth_usd.get_price_for_volume(True, 10).result_price, + eth_usd.get_price(True)) + self.assertLessEqual(eth_usd.get_price_for_volume(False, 10).result_price, + eth_usd.get_price(False)) + + def test_api_get_last_traded_prices(self): + prices = self.ev_loop.run_until_complete( + HitbtcAPIOrderBookDataSource.get_last_traded_prices(["BTC-USDT", "LTC-BTC"])) + for key, value in prices.items(): + print(f"{key} last_trade_price: {value}") + self.assertGreater(prices["BTC-USDT"], 1000) + self.assertLess(prices["LTC-BTC"], 1) diff --git a/test/connector/exchange/hitbtc/test_hitbtc_user_stream_tracker.py b/test/connector/exchange/hitbtc/test_hitbtc_user_stream_tracker.py new file mode 100644 index 0000000000..c53dcff7bc --- /dev/null +++ b/test/connector/exchange/hitbtc/test_hitbtc_user_stream_tracker.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python + +import sys +import asyncio +import logging +import unittest +import conf + +from os.path import join, realpath +from hummingbot.connector.exchange.hitbtc.hitbtc_user_stream_tracker import HitbtcUserStreamTracker +from hummingbot.connector.exchange.hitbtc.hitbtc_auth import HitbtcAuth +from hummingbot.core.utils.async_utils import safe_ensure_future +from hummingbot.logger.struct_logger import METRICS_LOG_LEVEL + + +sys.path.insert(0, realpath(join(__file__, "../../../../../"))) +logging.basicConfig(level=METRICS_LOG_LEVEL) + + +class HitbtcUserStreamTrackerUnitTest(unittest.TestCase): + api_key = conf.hitbtc_api_key + api_secret = conf.hitbtc_secret_key + + @classmethod + def setUpClass(cls): + cls.ev_loop: asyncio.BaseEventLoop = asyncio.get_event_loop() + cls.trading_pairs = ["BTC-USDT"] + cls.user_stream_tracker: HitbtcUserStreamTracker = HitbtcUserStreamTracker( + hitbtc_auth=HitbtcAuth(cls.api_key, cls.api_secret), + 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. + print("Sleeping for 30s to gather some user stream messages.") + self.ev_loop.run_until_complete(asyncio.sleep(30.0)) + print(self.user_stream_tracker.user_stream) diff --git a/test/test_rate_oracle.py b/test/test_rate_oracle.py new file mode 100644 index 0000000000..cbe1fe8d32 --- /dev/null +++ b/test/test_rate_oracle.py @@ -0,0 +1,78 @@ +import unittest +from decimal import Decimal +import asyncio +from hummingbot.core.rate_oracle.utils import find_rate +from hummingbot.core.rate_oracle.rate_oracle import RateOracle + + +class RateOracleTest(unittest.TestCase): + + def test_find_rate_from_source(self): + asyncio.get_event_loop().run_until_complete(self._test_find_rate_from_source()) + + async def _test_find_rate_from_source(self): + rate = await RateOracle.rate_async("BTC-USDT") + print(rate) + self.assertGreater(rate, 100) + + def test_get_rate_coingecko(self): + asyncio.get_event_loop().run_until_complete(self._test_get_rate_coingecko()) + + async def _test_get_rate_coingecko(self): + rates = await RateOracle.get_coingecko_prices_by_page("USD", 1) + print(rates) + self.assertGreater(len(rates), 100) + rates = await RateOracle.get_coingecko_prices("USD") + print(rates) + self.assertGreater(len(rates), 700) + + def test_rate_oracle_network(self): + oracle = RateOracle.get_instance() + oracle.start() + asyncio.get_event_loop().run_until_complete(oracle.get_ready()) + print(oracle.prices) + self.assertGreater(len(oracle.prices), 0) + rate = oracle.rate("SCRT-USDT") + print(f"rate SCRT-USDT: {rate}") + self.assertGreater(rate, 0) + rate1 = oracle.rate("BTC-USDT") + print(f"rate BTC-USDT: {rate1}") + self.assertGreater(rate1, 100) + # wait for 5 s to check rate again + asyncio.get_event_loop().run_until_complete(asyncio.sleep(5)) + rate2 = oracle.rate("BTC-USDT") + print(f"rate BTC-USDT: {rate2}") + self.assertNotEqual(0, rate2) + oracle.stop() + + def test_find_rate(self): + prices = {"HBOT-USDT": Decimal("100"), "AAVE-USDT": Decimal("50"), "USDT-GBP": Decimal("0.75")} + rate = find_rate(prices, "HBOT-USDT") + self.assertEqual(rate, Decimal("100")) + rate = find_rate(prices, "ZBOT-USDT") + self.assertEqual(rate, None) + rate = find_rate(prices, "USDT-HBOT") + self.assertEqual(rate, Decimal("0.01")) + rate = find_rate(prices, "HBOT-AAVE") + self.assertEqual(rate, Decimal("2")) + rate = find_rate(prices, "AAVE-HBOT") + self.assertEqual(rate, Decimal("0.5")) + rate = find_rate(prices, "HBOT-GBP") + self.assertEqual(rate, Decimal("75")) + + def test_get_binance_prices(self): + asyncio.get_event_loop().run_until_complete(self._test_get_binance_prices()) + + async def _test_get_binance_prices(self): + com_prices = await RateOracle.get_binance_prices_by_domain(RateOracle.binance_price_url) + print(com_prices) + self.assertGreater(len(com_prices), 1) + us_prices = await RateOracle.get_binance_prices_by_domain(RateOracle.binance_us_price_url, "USD") + print(us_prices) + self.assertGreater(len(us_prices), 1) + quotes = {p.split("-")[1] for p in us_prices} + self.assertEqual(len(quotes), 1) + self.assertEqual(list(quotes)[0], "USD") + combined_prices = await RateOracle.get_binance_prices() + self.assertGreater(len(combined_prices), 1) + self.assertGreater(len(combined_prices), len(com_prices))