From 057dd41099cc3a603ee9e0804613c8f5e50b8fd7 Mon Sep 17 00:00:00 2001 From: Vladimir Kamarzin Date: Mon, 26 Oct 2020 11:04:10 +0500 Subject: [PATCH 001/126] (feat) Add inventory cost price type Closes: #2314 --- hummingbot/client/command/config_command.py | 45 +++++++- hummingbot/core/event/events.py | 1 + hummingbot/exceptions.py | 6 + hummingbot/model/inventory_cost.py | 66 +++++++++++ .../strategy/pure_market_making/__init__.py | 4 +- .../inventory_cost_price_delegate.py | 68 ++++++++++++ .../pure_market_making/pure_market_making.pxd | 1 + .../pure_market_making/pure_market_making.pyx | 64 +++++++++-- .../pure_market_making_config_map.py | 13 ++- .../strategy/pure_market_making/start.py | 9 +- ...f_pure_market_making_strategy_TEMPLATE.yml | 9 +- test/test_inventory_cost_price_delegate.py | 105 ++++++++++++++++++ test/test_pmm.py | 28 +++++ 13 files changed, 399 insertions(+), 20 deletions(-) create mode 100644 hummingbot/exceptions.py create mode 100644 hummingbot/model/inventory_cost.py create mode 100644 hummingbot/strategy/pure_market_making/inventory_cost_price_delegate.py create mode 100644 test/test_inventory_cost_price_delegate.py diff --git a/hummingbot/client/command/config_command.py b/hummingbot/client/command/config_command.py index c399c966f8..76a404d84c 100644 --- a/hummingbot/client/command/config_command.py +++ b/hummingbot/client/command/config_command.py @@ -6,12 +6,13 @@ from decimal import Decimal import pandas as pd from os.path import join +from sqlalchemy.orm import Session from hummingbot.client.settings import ( GLOBAL_CONFIG_PATH, CONF_FILE_PATH, ) from hummingbot.client.config.global_config_map import global_config_map -from hummingbot.client.config.config_validators import validate_bool +from hummingbot.client.config.config_validators import validate_bool, validate_decimal from hummingbot.client.config.config_helpers import ( missing_required_configs, save_to_yml @@ -19,6 +20,7 @@ from hummingbot.client.config.security import Security from hummingbot.client.config.config_var import ConfigVar from hummingbot.core.utils.async_utils import safe_ensure_future +from hummingbot.model.inventory_cost import InventoryCost from hummingbot.strategy.pure_market_making import ( PureMarketMakingStrategy ) @@ -127,6 +129,8 @@ async def _config_single_key(self, # type: HummingbotApplication self._notify("Please follow the prompt to complete configurations: ") if config_var.key == "inventory_target_base_pct": await self.asset_ratio_maintenance_prompt(config_map, input_value) + elif config_var.key == "inventory_price": + await self.inventory_price_prompt(config_map, input_value) else: await self.prompt_a_config(config_var, input_value=input_value, assign_default=False) if self.app.to_stop_config: @@ -204,3 +208,42 @@ async def asset_ratio_maintenance_prompt(self, # type: HummingbotApplication self.app.to_stop_config = False return await self.prompt_a_config(config_map["inventory_target_base_pct"]) + + async def inventory_price_prompt( + self, # type: HummingbotApplication + config_map, + input_value=None, + ): + key = "inventory_price" + if input_value: + config_map[key].value = Decimal(input_value) + else: + 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 balances.get(base_asset) is None: + return + + cvar = ConfigVar( + key="temp_config", + prompt=f"On {exchange}, you have {balances[base_asset]:.4f} {base_asset}. " + f"What was the price for this amount in {quote_asset}? >>> ", + required_if=lambda: True, + type_str="decimal", + validator=lambda v: validate_decimal( + v, min_value=Decimal("0"), inclusive=True + ), + ) + await self.prompt_a_config(cvar) + config_map[key].value = cvar.value + session: Session = self.trade_fill_db.get_shared_session() + InventoryCost.update_or_create( + session, + base_asset=base_asset, + quote_asset=quote_asset, + base_volume=balances[base_asset], + quote_volume=balances[base_asset] * cvar.value, + ) diff --git a/hummingbot/core/event/events.py b/hummingbot/core/event/events.py index 85f55e54b4..595bf69877 100644 --- a/hummingbot/core/event/events.py +++ b/hummingbot/core/event/events.py @@ -77,6 +77,7 @@ class PriceType(Enum): BestAsk = 3 LastTrade = 4 LastOwnTrade = 5 + InventoryCost = 6 class MarketTransactionFailureEvent(NamedTuple): diff --git a/hummingbot/exceptions.py b/hummingbot/exceptions.py new file mode 100644 index 0000000000..7b15f850a7 --- /dev/null +++ b/hummingbot/exceptions.py @@ -0,0 +1,6 @@ +class HummingbotBaseException(Exception): + pass + + +class NoPrice(HummingbotBaseException): + pass diff --git a/hummingbot/model/inventory_cost.py b/hummingbot/model/inventory_cost.py new file mode 100644 index 0000000000..21e2a9f3e8 --- /dev/null +++ b/hummingbot/model/inventory_cost.py @@ -0,0 +1,66 @@ +from decimal import Decimal +from typing import Optional + +from sqlalchemy import ( + Column, + String, + Numeric, + Integer, + UniqueConstraint, +) +from sqlalchemy.orm import Session + +from . import HummingbotBase + + +class InventoryCost(HummingbotBase): + __tablename__ = "InventoryCost" + __table_args__ = ( + UniqueConstraint("base_asset", "quote_asset"), + ) + + id = Column(Integer, primary_key=True, nullable=False) + base_asset = Column(String(45), nullable=False) + quote_asset = Column(String(45), nullable=False) + base_volume = Column(Numeric(48, 18), nullable=False) + quote_volume = Column(Numeric(48, 18), nullable=False) + + @classmethod + def get_record( + cls, sql_session: Session, base_asset: str, quote_asset: str + ) -> Optional["InventoryCost"]: + return ( + sql_session.query(cls) + .filter(cls.base_asset == base_asset, cls.quote_asset == quote_asset) + .first() + ) + + @classmethod + def update_or_create( + cls, + sql_session: Session, + base_asset: str, + quote_asset: str, + base_volume: Decimal, + quote_volume: Decimal, + ) -> None: + rows_updated: int = sql_session.query(cls).filter( + cls.base_asset == base_asset, cls.quote_asset == quote_asset + ).update( + { + "base_volume": cls.base_volume + base_volume, + "quote_volume": cls.quote_volume + quote_volume, + } + ) + if not rows_updated: + record = InventoryCost( + base_asset=base_asset, + quote_asset=quote_asset, + base_volume=float(base_volume), + quote_volume=float(quote_volume), + ) + sql_session.add(record) + try: + sql_session.commit() + except Exception: + sql_session.rollback() diff --git a/hummingbot/strategy/pure_market_making/__init__.py b/hummingbot/strategy/pure_market_making/__init__.py index 02d5e41c19..3e98fb1a8b 100644 --- a/hummingbot/strategy/pure_market_making/__init__.py +++ b/hummingbot/strategy/pure_market_making/__init__.py @@ -4,9 +4,11 @@ from .asset_price_delegate import AssetPriceDelegate from .order_book_asset_price_delegate import OrderBookAssetPriceDelegate from .api_asset_price_delegate import APIAssetPriceDelegate +from .inventory_cost_price_delegate import InventoryCostPriceDelegate __all__ = [ PureMarketMakingStrategy, AssetPriceDelegate, OrderBookAssetPriceDelegate, - APIAssetPriceDelegate + APIAssetPriceDelegate, + InventoryCostPriceDelegate, ] diff --git a/hummingbot/strategy/pure_market_making/inventory_cost_price_delegate.py b/hummingbot/strategy/pure_market_making/inventory_cost_price_delegate.py new file mode 100644 index 0000000000..652a7745fd --- /dev/null +++ b/hummingbot/strategy/pure_market_making/inventory_cost_price_delegate.py @@ -0,0 +1,68 @@ +from decimal import Decimal + +from hummingbot.core.event.events import OrderFilledEvent, TradeType +from hummingbot.exceptions import NoPrice +from hummingbot.model.inventory_cost import InventoryCost +from hummingbot.model.sql_connection_manager import SQLConnectionManager + + +class InventoryCostPriceDelegate: + def __init__(self, sql: SQLConnectionManager, trading_pair: str) -> None: + self.base_asset, self.quote_asset = trading_pair.split("-") + self._session = sql.get_shared_session() + + @property + def ready(self) -> bool: + return True + + def get_price(self) -> Decimal: + record = InventoryCost.get_record( + self._session, self.base_asset, self.quote_asset + ) + try: + price = record.quote_volume / record.base_volume + except (ZeroDivisionError, AttributeError): + raise NoPrice("Inventory cost delegate does not have price cost data yet") + + return Decimal(price) + + def process_order_fill_event(self, fill_event: OrderFilledEvent) -> None: + base_asset, quote_asset = fill_event.trading_pair.split("-") + quote_volume = fill_event.amount * fill_event.price + base_volume = fill_event.amount + + for fee_asset, fee_amount in fill_event.trade_fee.flat_fees: + if fill_event.trade_type == TradeType.BUY: + if fee_asset == base_asset: + base_volume -= fee_amount + elif fee_asset == quote_asset: + quote_volume += fee_amount + else: + # Ok, some other asset used (like BNB), assume that we paid in base asset for simplicity + base_volume /= 1 + fill_event.trade_fee.percent + else: + if fee_asset == base_asset: + base_volume += fee_amount + elif fee_asset == quote_asset: + quote_volume -= fee_amount + else: + # Ok, some other asset used (like BNB), assume that we paid in quote asset for simplicity + quote_volume /= 1 + fill_event.trade_fee.percent + + if fill_event.trade_type == TradeType.SELL: + base_volume = -base_volume + quote_volume = -quote_volume + + # Make sure we're not going to create negative inventory cost here. If user didn't properly set cost, + # just assume 0 cost, so consequent buys will set cost correctly + record = InventoryCost.get_record(self._session, base_asset, quote_asset) + if record: + base_volume = max(-record.base_volume, base_volume) + quote_volume = max(-record.quote_volume, quote_volume) + else: + base_volume = Decimal("0") + quote_volume = Decimal("0") + + InventoryCost.update_or_create( + self._session, base_asset, quote_asset, base_volume, quote_volume + ) diff --git a/hummingbot/strategy/pure_market_making/pure_market_making.pxd b/hummingbot/strategy/pure_market_making/pure_market_making.pxd index f4d5897bf5..b1aca23ae9 100644 --- a/hummingbot/strategy/pure_market_making/pure_market_making.pxd +++ b/hummingbot/strategy/pure_market_making/pure_market_making.pxd @@ -30,6 +30,7 @@ cdef class PureMarketMakingStrategy(StrategyBase): object _bid_order_optimization_depth bint _add_transaction_costs_to_orders object _asset_price_delegate + object _inventory_cost_price_delegate object _price_type bint _take_if_crossed object _price_ceiling diff --git a/hummingbot/strategy/pure_market_making/pure_market_making.pyx b/hummingbot/strategy/pure_market_making/pure_market_making.pyx index f959cf12b0..947b73fa36 100644 --- a/hummingbot/strategy/pure_market_making/pure_market_making.pyx +++ b/hummingbot/strategy/pure_market_making/pure_market_making.pyx @@ -20,6 +20,7 @@ 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.exceptions import NoPrice from hummingbot.strategy.market_trading_pair_tuple import MarketTradingPairTuple from hummingbot.strategy.strategy_base import StrategyBase @@ -36,6 +37,7 @@ from .asset_price_delegate import AssetPriceDelegate from .inventory_skew_calculator cimport c_calculate_bid_ask_ratios_from_base_asset_ratio from .inventory_skew_calculator import calculate_total_order_size from .order_book_asset_price_delegate cimport OrderBookAssetPriceDelegate +from .inventory_cost_price_delegate import InventoryCostPriceDelegate NaN = float("nan") @@ -81,6 +83,7 @@ cdef class PureMarketMakingStrategy(StrategyBase): bid_order_optimization_depth: Decimal = s_decimal_zero, add_transaction_costs_to_orders: bool = False, asset_price_delegate: AssetPriceDelegate = None, + inventory_cost_price_delegate: InventoryCostPriceDelegate = None, price_type: str = "mid_price", take_if_crossed: bool = False, price_ceiling: Decimal = s_decimal_neg_one, @@ -121,6 +124,7 @@ cdef class PureMarketMakingStrategy(StrategyBase): self._bid_order_optimization_depth = bid_order_optimization_depth self._add_transaction_costs_to_orders = add_transaction_costs_to_orders self._asset_price_delegate = asset_price_delegate + self._inventory_cost_price_delegate = inventory_cost_price_delegate self._price_type = self.get_price_type(price_type) self._take_if_crossed = take_if_crossed self._price_ceiling = price_ceiling @@ -331,17 +335,19 @@ cdef class PureMarketMakingStrategy(StrategyBase): def trading_pair(self): return self._market_info.trading_pair - def get_price(self) -> float: - if self._asset_price_delegate is not None: - price_provider = self._asset_price_delegate - else: - price_provider = self._market_info + def get_price(self) -> Decimal: + price_provider = self._asset_price_delegate or self._market_info + if self._price_type is PriceType.LastOwnTrade: price = self._last_own_trade_price + elif self._price_type is PriceType.InventoryCost: + price = price_provider.get_price_by_type(PriceType.MidPrice) else: price = price_provider.get_price_by_type(self._price_type) + if price.is_nan(): price = price_provider.get_price_by_type(PriceType.MidPrice) + return price def get_last_price(self) -> float: @@ -403,6 +409,14 @@ cdef class PureMarketMakingStrategy(StrategyBase): def asset_price_delegate(self, value): self._asset_price_delegate = value + @property + def inventory_cost_price_delegate(self) -> AssetPriceDelegate: + return self._inventory_cost_price_delegate + + @inventory_cost_price_delegate.setter + def inventory_cost_price_delegate(self, value): + self._inventory_cost_price_delegate = value + @property def order_tracker(self): return self._sb_order_tracker @@ -535,9 +549,19 @@ cdef class PureMarketMakingStrategy(StrategyBase): bid_price = market.get_price(trading_pair, False) ask_price = market.get_price(trading_pair, True) ref_price = float("nan") - if market == self._market_info.market and self._asset_price_delegate is None: + if market == self._market_info.market and self._inventory_cost_price_delegate is not None: + # We're using inventory_cost, show it's price + try: + ref_price = self._inventory_cost_price_delegate.get_price() + except NoPrice: + ref_price = self.get_price() + elif market == self._market_info.market and self._asset_price_delegate is None: ref_price = self.get_price() - elif market == self._asset_price_delegate.market and self._price_type is not PriceType.LastOwnTrade: + elif ( + self._asset_price_delegate is not None + and market == self._asset_price_delegate.market + and self._price_type is not PriceType.LastOwnTrade + ): ref_price = self._asset_price_delegate.get_price_by_type(self._price_type) markets_data.append([ market.display_name, @@ -628,7 +652,7 @@ cdef class PureMarketMakingStrategy(StrategyBase): # asset_mid_price = self.c_set_mid_price(market_info) if self._create_timestamp <= self._current_timestamp: # 1. Create base order proposals - proposal =self.c_create_base_proposal() + proposal = self.c_create_base_proposal() # 2. Apply functions that limit numbers of buys and sells proposal self.c_apply_order_levels_modifiers(proposal) # 3. Apply functions that modify orders price @@ -654,6 +678,17 @@ cdef class PureMarketMakingStrategy(StrategyBase): list buys = [] list sells = [] + buy_reference_price = sell_reference_price = self.get_price() + + if self._inventory_cost_price_delegate is not None: + try: + inventory_cost_price = self._inventory_cost_price_delegate.get_price() + except NoPrice: + pass + else: + buy_reference_price = min(inventory_cost_price, buy_reference_price) + sell_reference_price = max(inventory_cost_price, sell_reference_price) + # First to check if a customized order override is configured, otherwise the proposal will be created according # to order spread, amount, and levels setting. order_override = self._order_override @@ -661,14 +696,14 @@ cdef class PureMarketMakingStrategy(StrategyBase): for key, value in order_override.items(): if str(value[0]) in ["buy", "sell"]: if str(value[0]) == "buy": - price = self.get_price() * (Decimal("1") - Decimal(str(value[1])) / Decimal("100")) + price = buy_reference_price * (Decimal("1") - Decimal(str(value[1])) / Decimal("100")) price = market.c_quantize_order_price(self.trading_pair, price) size = Decimal(str(value[2])) size = market.c_quantize_order_amount(self.trading_pair, size) if size > 0 and price > 0: buys.append(PriceSize(price, size)) elif str(value[0]) == "sell": - price = self.get_price() * (Decimal("1") + Decimal(str(value[1])) / Decimal("100")) + price = sell_reference_price * (Decimal("1") + Decimal(str(value[1])) / Decimal("100")) price = market.c_quantize_order_price(self.trading_pair, price) size = Decimal(str(value[2])) size = market.c_quantize_order_amount(self.trading_pair, size) @@ -676,14 +711,14 @@ cdef class PureMarketMakingStrategy(StrategyBase): sells.append(PriceSize(price, size)) else: for level in range(0, self._buy_levels): - price = self.get_price() * (Decimal("1") - self._bid_spread - (level * self._order_level_spread)) + price = buy_reference_price * (Decimal("1") - self._bid_spread - (level * self._order_level_spread)) price = market.c_quantize_order_price(self.trading_pair, price) size = self._order_amount + (self._order_level_amount * level) size = market.c_quantize_order_amount(self.trading_pair, size) if size > 0: buys.append(PriceSize(price, size)) for level in range(0, self._sell_levels): - price = self.get_price() * (Decimal("1") + self._ask_spread + (level * self._order_level_spread)) + price = sell_reference_price * (Decimal("1") + self._ask_spread + (level * self._order_level_spread)) price = market.c_quantize_order_price(self.trading_pair, price) size = self._order_amount + (self._order_level_amount * level) size = market.c_quantize_order_amount(self.trading_pair, size) @@ -923,6 +958,9 @@ cdef class PureMarketMakingStrategy(StrategyBase): f"{order_filled_event.amount} {market_info.base_asset} filled." ) + if self._inventory_cost_price_delegate is not None: + self._inventory_cost_price_delegate.process_order_fill_event(order_filled_event) + cdef c_did_complete_buy_order(self, object order_completed_event): cdef: str order_id = order_completed_event.order_id @@ -1169,5 +1207,7 @@ cdef class PureMarketMakingStrategy(StrategyBase): return PriceType.LastTrade elif price_type_str == 'last_own_trade_price': return PriceType.LastOwnTrade + elif price_type_str == 'inventory_cost': + return PriceType.InventoryCost else: raise ValueError(f"Unrecognized price type string {price_type_str}.") 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 3c7dc83ff4..4b73335f51 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 @@ -238,6 +238,12 @@ def exchange_on_validated(value: str): type_str="decimal", validator=lambda v: validate_decimal(v, min_value=0, inclusive=False), default=Decimal("1")), + "inventory_price": + ConfigVar(key="inventory_price", + 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), + ), "filled_order_delay": ConfigVar(key="filled_order_delay", prompt="How long do you want to wait before placing the next order " @@ -298,7 +304,8 @@ def exchange_on_validated(value: str): on_validated=on_validate_price_source), "price_type": ConfigVar(key="price_type", - prompt="Which price type to use? (mid_price/last_price/last_own_trade_price/best_bid/best_ask) >>> ", + prompt="Which price type to use? (" + "mid_price/last_price/last_own_trade_price/best_bid/best_ask/inventory_cost) >>> ", type_str="str", required_if=lambda: pure_market_making_config_map.get("price_source").value != "custom_api", default="mid_price", @@ -306,7 +313,9 @@ def exchange_on_validated(value: str): "last_price", "last_own_trade_price", "best_bid", - "best_ask"} else + "best_ask", + "inventory_cost", + } else "Invalid price type."), "price_source_exchange": ConfigVar(key="price_source_exchange", diff --git a/hummingbot/strategy/pure_market_making/start.py b/hummingbot/strategy/pure_market_making/start.py index 6b9994286f..64e95392cf 100644 --- a/hummingbot/strategy/pure_market_making/start.py +++ b/hummingbot/strategy/pure_market_making/start.py @@ -3,11 +3,13 @@ Tuple, ) +from hummingbot.client.hummingbot_application import HummingbotApplication from hummingbot.strategy.market_trading_pair_tuple import MarketTradingPairTuple from hummingbot.strategy.pure_market_making import ( PureMarketMakingStrategy, OrderBookAssetPriceDelegate, - APIAssetPriceDelegate + APIAssetPriceDelegate, + InventoryCostPriceDelegate, ) from hummingbot.strategy.pure_market_making.pure_market_making_config_map import pure_market_making_config_map as c_map from hummingbot.connector.exchange.paper_trade import create_paper_trade_market @@ -65,6 +67,10 @@ def start(self): asset_price_delegate = OrderBookAssetPriceDelegate(ext_market, asset_trading_pair) elif price_source == "custom_api": asset_price_delegate = APIAssetPriceDelegate(price_source_custom_api) + inventory_cost_price_delegate = None + if price_type == "inventory_cost": + db = HummingbotApplication.main_application().trade_fill_db + inventory_cost_price_delegate = InventoryCostPriceDelegate(db, trading_pair) take_if_crossed = c_map.get("take_if_crossed").value strategy_logging_options = PureMarketMakingStrategy.OPTION_LOG_ALL @@ -89,6 +95,7 @@ def start(self): add_transaction_costs_to_orders=add_transaction_costs_to_orders, logging_options=strategy_logging_options, asset_price_delegate=asset_price_delegate, + inventory_cost_price_delegate=inventory_cost_price_delegate, price_type=price_type, take_if_crossed=take_if_crossed, price_ceiling=price_ceiling, diff --git a/hummingbot/templates/conf_pure_market_making_strategy_TEMPLATE.yml b/hummingbot/templates/conf_pure_market_making_strategy_TEMPLATE.yml index d50f3380fa..864192594e 100644 --- a/hummingbot/templates/conf_pure_market_making_strategy_TEMPLATE.yml +++ b/hummingbot/templates/conf_pure_market_making_strategy_TEMPLATE.yml @@ -2,7 +2,7 @@ ### Pure market making strategy config ### ######################################################## -template_version: 19 +template_version: 20 strategy: null # Exchange and token parameters. @@ -57,6 +57,9 @@ inventory_target_base_pct: null # inventory skew feature). inventory_range_multiplier: null +# Initial price of the base asset +inventory_price: null + # Number of levels of orders to place on each side of the order book. order_levels: null @@ -91,7 +94,7 @@ add_transaction_costs: null # The price source (current_market/external_market/custom_api). price_source: null -# The price type (mid_price/last_price/last_own_trade_price/best_bid/best_ask). +# The price type (mid_price/last_price/last_own_trade_price/best_bid/best_ask/inventory_cost). price_type: null # An external exchange name (for external exchange pricing source). @@ -117,4 +120,4 @@ take_if_crossed: null order_override: null # For more detailed information, see: -# https://docs.hummingbot.io/strategies/pure-market-making/#configuration-parameters \ No newline at end of file +# https://docs.hummingbot.io/strategies/pure-market-making/#configuration-parameters diff --git a/test/test_inventory_cost_price_delegate.py b/test/test_inventory_cost_price_delegate.py new file mode 100644 index 0000000000..7feaa73087 --- /dev/null +++ b/test/test_inventory_cost_price_delegate.py @@ -0,0 +1,105 @@ +import unittest +from decimal import Decimal + +from hummingbot.core.event.events import OrderFilledEvent, TradeType +from hummingbot.core.event.events import TradeFee, OrderType +from hummingbot.exceptions import NoPrice +from hummingbot.model.inventory_cost import InventoryCost +from hummingbot.model.sql_connection_manager import ( + SQLConnectionManager, + SQLConnectionType, +) +from hummingbot.strategy.pure_market_making.inventory_cost_price_delegate import ( + InventoryCostPriceDelegate, +) + + +class TestInventoryCostPriceDelegate(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.trade_fill_sql = SQLConnectionManager( + SQLConnectionType.TRADE_FILLS, db_path="" + ) + cls.trading_pair = "BTC-USDT" + cls.base_asset, cls.quote_asset = cls.trading_pair.split("-") + cls._session = cls.trade_fill_sql.get_shared_session() + + def setUp(self): + for table in [InventoryCost.__table__]: + self.trade_fill_sql.get_shared_session().execute(table.delete()) + self.delegate = InventoryCostPriceDelegate( + self.trade_fill_sql, self.trading_pair + ) + + def test_process_order_fill_event_buy(self): + amount = Decimal("1") + price = Decimal("9000") + event = OrderFilledEvent( + timestamp=1, + order_id="order1", + trading_pair=self.trading_pair, + trade_type=TradeType.BUY, + order_type=OrderType.LIMIT, + price=price, + amount=amount, + trade_fee=TradeFee(percent=Decimal("0"), flat_fees=[]), + ) + # first event creates DB record + self.delegate.process_order_fill_event(event) + count = self._session.query(InventoryCost).count() + self.assertEqual(count, 1) + + # second event causes update to existing record + self.delegate.process_order_fill_event(event) + record = InventoryCost.get_record( + self._session, self.base_asset, self.quote_asset + ) + self.assertEqual(record.base_volume, amount * 2) + self.assertEqual(record.quote_volume, price * 2) + + def test_process_order_fill_event_sell_no_initial_cost_set(self): + amount = Decimal("1") + price = Decimal("9000") + event = OrderFilledEvent( + timestamp=1, + order_id="order1", + trading_pair=self.trading_pair, + trade_type=TradeType.SELL, + order_type=OrderType.LIMIT, + price=price, + amount=amount, + trade_fee=TradeFee(percent=Decimal("0"), flat_fees=[]), + ) + # First event is a sell when there was no InventoryCost created. Assume wrong input from user + self.delegate.process_order_fill_event(event) + record = InventoryCost.get_record( + self._session, self.base_asset, self.quote_asset + ) + self.assertEqual(record.base_volume, Decimal("0")) + self.assertEqual(record.quote_volume, Decimal("0")) + + # Second event - InventoryCost created, amounts are 0, test that record updated correctly + self.delegate.process_order_fill_event(event) + record = InventoryCost.get_record( + self._session, self.base_asset, self.quote_asset + ) + self.assertEqual(record.base_volume, Decimal("0")) + self.assertEqual(record.quote_volume, Decimal("0")) + + def test_get_price_by_type(self): + amount = Decimal("1") + price = Decimal("9000") + + # Test when no records + self.assertRaises(NoPrice, self.delegate.get_price) + + record = InventoryCost( + base_asset=self.base_asset, + quote_asset=self.quote_asset, + base_volume=amount, + quote_volume=amount * price, + ) + self._session.add(record) + self._session.commit() + delegate_price = self.delegate.get_price() + self.assertEqual(delegate_price, price) diff --git a/test/test_pmm.py b/test/test_pmm.py index d9964ac865..d6bef7d665 100644 --- a/test/test_pmm.py +++ b/test/test_pmm.py @@ -21,8 +21,13 @@ TradeType, PriceType, ) +from hummingbot.model.sql_connection_manager import ( + SQLConnectionManager, + SQLConnectionType, +) from hummingbot.strategy.pure_market_making.pure_market_making import PureMarketMakingStrategy from hummingbot.strategy.pure_market_making.order_book_asset_price_delegate import OrderBookAssetPriceDelegate +from hummingbot.strategy.pure_market_making.inventory_cost_price_delegate import InventoryCostPriceDelegate from hummingbot.core.data_type.order_book import OrderBook from hummingbot.core.data_type.order_book_row import OrderBookRow from hummingbot.client.command.config_command import ConfigCommand @@ -135,6 +140,10 @@ def setUp(self): volume_step_size=10) self.ext_market.add_data(self.ext_data) self.order_book_asset_del = OrderBookAssetPriceDelegate(self.ext_market, self.trading_pair) + trade_fill_sql = SQLConnectionManager( + SQLConnectionType.TRADE_FILLS, db_path="" + ) + self.inventory_cost_price_del = InventoryCostPriceDelegate(trade_fill_sql, self.trading_pair) def simulate_maker_market_trade( self, is_buy: bool, quantity: Decimal, price: Decimal, market: Optional[BacktestMarket] = None, @@ -773,6 +782,25 @@ def test_inventory_skew_multiple_orders_status(self): self.assertEqual("50.0%", status_df.iloc[4, 1]) self.assertEqual("150.0%", status_df.iloc[4, 2]) + def test_inventory_cost_price_del(self): + strategy = self.one_level_strategy + strategy.inventory_cost_price_delegate = self.inventory_cost_price_del + self.clock.add_iterator(strategy) + self.clock.backtest_til(self.start_timestamp + 1) + + # Expecting to have orders set according to mid_price as there is no inventory cost data yet + first_bid_order = strategy.active_buys[0] + first_ask_order = strategy.active_sells[0] + self.assertEqual(Decimal("99"), first_bid_order.price) + self.assertEqual(Decimal("101"), first_ask_order.price) + + self.simulate_maker_market_trade( + is_buy=False, quantity=Decimal("10"), price=Decimal("98.9"), + ) + self.clock.backtest_til(self.start_timestamp + 7) + first_bid_order = strategy.active_buys[0] + self.assertEqual(Decimal("98.01"), first_bid_order.price) + def test_order_book_asset_del(self): strategy = self.one_level_strategy strategy.asset_price_delegate = self.order_book_asset_del From 3d2371aed41993c4d5078e1bc0918e3f894041cd Mon Sep 17 00:00:00 2001 From: Vladimir Kamarzin Date: Mon, 26 Oct 2020 14:49:25 +0500 Subject: [PATCH 002/126] (fix) Fix inventory price setting from command --- hummingbot/client/command/config_command.py | 3 +- hummingbot/model/inventory_cost.py | 29 ++++++++++++------- .../inventory_cost_price_delegate.py | 2 +- 3 files changed, 22 insertions(+), 12 deletions(-) diff --git a/hummingbot/client/command/config_command.py b/hummingbot/client/command/config_command.py index 76a404d84c..f2c00dc2ae 100644 --- a/hummingbot/client/command/config_command.py +++ b/hummingbot/client/command/config_command.py @@ -240,10 +240,11 @@ async def inventory_price_prompt( await self.prompt_a_config(cvar) config_map[key].value = cvar.value session: Session = self.trade_fill_db.get_shared_session() - InventoryCost.update_or_create( + InventoryCost.add_volume( session, base_asset=base_asset, quote_asset=quote_asset, base_volume=balances[base_asset], quote_volume=balances[base_asset] * cvar.value, + overwrite=True, ) diff --git a/hummingbot/model/inventory_cost.py b/hummingbot/model/inventory_cost.py index 21e2a9f3e8..7844ff5d2c 100644 --- a/hummingbot/model/inventory_cost.py +++ b/hummingbot/model/inventory_cost.py @@ -36,22 +36,30 @@ def get_record( ) @classmethod - def update_or_create( + def add_volume( cls, sql_session: Session, base_asset: str, quote_asset: str, base_volume: Decimal, quote_volume: Decimal, + overwrite: bool = False, ) -> None: - rows_updated: int = sql_session.query(cls).filter( - cls.base_asset == base_asset, cls.quote_asset == quote_asset - ).update( - { + if overwrite: + update = { + "base_volume": base_volume, + "quote_volume": quote_volume, + } + else: + update = { "base_volume": cls.base_volume + base_volume, "quote_volume": cls.quote_volume + quote_volume, } - ) + + rows_updated: int = sql_session.query(cls).filter( + cls.base_asset == base_asset, cls.quote_asset == quote_asset + ).update(update) + if not rows_updated: record = InventoryCost( base_asset=base_asset, @@ -60,7 +68,8 @@ def update_or_create( quote_volume=float(quote_volume), ) sql_session.add(record) - try: - sql_session.commit() - except Exception: - sql_session.rollback() + + try: + sql_session.commit() + except Exception: + sql_session.rollback() diff --git a/hummingbot/strategy/pure_market_making/inventory_cost_price_delegate.py b/hummingbot/strategy/pure_market_making/inventory_cost_price_delegate.py index 652a7745fd..926a98ecb0 100644 --- a/hummingbot/strategy/pure_market_making/inventory_cost_price_delegate.py +++ b/hummingbot/strategy/pure_market_making/inventory_cost_price_delegate.py @@ -63,6 +63,6 @@ def process_order_fill_event(self, fill_event: OrderFilledEvent) -> None: base_volume = Decimal("0") quote_volume = Decimal("0") - InventoryCost.update_or_create( + InventoryCost.add_volume( self._session, base_asset, quote_asset, base_volume, quote_volume ) From 81fdd9a7ce125309175cf7ada25e8f075564727a Mon Sep 17 00:00:00 2001 From: Vladimir Kamarzin Date: Sat, 7 Nov 2020 18:19:55 +0500 Subject: [PATCH 003/126] (feat) Add new param for cost-based inventory ref price New param allows to place bids at prices higher than inventory cost. May be useful in uptrends. See https://github.com/CoinAlpha/hummingbot/issues/2314#issuecomment-716409652 --- .../pure_market_making/pure_market_making.pxd | 1 + .../pure_market_making/pure_market_making.pyx | 13 ++++++++- .../pure_market_making_config_map.py | 8 ++++++ .../strategy/pure_market_making/start.py | 2 ++ ...f_pure_market_making_strategy_TEMPLATE.yml | 3 +++ test/test_pmm.py | 27 +++++++++++++++++++ 6 files changed, 53 insertions(+), 1 deletion(-) diff --git a/hummingbot/strategy/pure_market_making/pure_market_making.pxd b/hummingbot/strategy/pure_market_making/pure_market_making.pxd index b1aca23ae9..6d38dc80c0 100644 --- a/hummingbot/strategy/pure_market_making/pure_market_making.pxd +++ b/hummingbot/strategy/pure_market_making/pure_market_making.pxd @@ -31,6 +31,7 @@ cdef class PureMarketMakingStrategy(StrategyBase): bint _add_transaction_costs_to_orders object _asset_price_delegate object _inventory_cost_price_delegate + bint _inventory_cost_allow_higher_bids object _price_type bint _take_if_crossed object _price_ceiling diff --git a/hummingbot/strategy/pure_market_making/pure_market_making.pyx b/hummingbot/strategy/pure_market_making/pure_market_making.pyx index 07fa72233a..2a8e753e0e 100644 --- a/hummingbot/strategy/pure_market_making/pure_market_making.pyx +++ b/hummingbot/strategy/pure_market_making/pure_market_making.pyx @@ -84,6 +84,7 @@ cdef class PureMarketMakingStrategy(StrategyBase): add_transaction_costs_to_orders: bool = False, asset_price_delegate: AssetPriceDelegate = None, inventory_cost_price_delegate: InventoryCostPriceDelegate = None, + inventory_cost_allow_higher_bids: bool = False, price_type: str = "mid_price", take_if_crossed: bool = False, price_ceiling: Decimal = s_decimal_neg_one, @@ -125,6 +126,7 @@ cdef class PureMarketMakingStrategy(StrategyBase): self._add_transaction_costs_to_orders = add_transaction_costs_to_orders self._asset_price_delegate = asset_price_delegate self._inventory_cost_price_delegate = inventory_cost_price_delegate + self._inventory_cost_allow_higher_bids = inventory_cost_allow_higher_bids self._price_type = self.get_price_type(price_type) self._take_if_crossed = take_if_crossed self._price_ceiling = price_ceiling @@ -428,6 +430,14 @@ cdef class PureMarketMakingStrategy(StrategyBase): def inventory_cost_price_delegate(self, value): self._inventory_cost_price_delegate = value + @property + def inventory_cost_allow_higher_bids(self): + return self._inventory_cost_allow_higher_bids + + @inventory_cost_allow_higher_bids.setter + def inventory_cost_allow_higher_bids(self, value): + self._inventory_cost_allow_higher_bids = value + @property def order_tracker(self): return self._sb_order_tracker @@ -697,7 +707,8 @@ cdef class PureMarketMakingStrategy(StrategyBase): except NoPrice: pass else: - buy_reference_price = min(inventory_cost_price, buy_reference_price) + if not self._inventory_cost_allow_higher_bids: + buy_reference_price = min(inventory_cost_price, buy_reference_price) sell_reference_price = max(inventory_cost_price, sell_reference_price) # First to check if a customized order override is configured, otherwise the proposal will be created according 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 4b73335f51..511618720b 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 @@ -342,6 +342,14 @@ def exchange_on_validated(value: str): prompt="Enter pricing API URL >>> ", required_if=lambda: pure_market_making_config_map.get("price_source").value == "custom_api", type_str="str"), + "inventory_cost_allow_higher_bids": + ConfigVar(key="inventory_cost_allow_higher_bids", + prompt="Do you want to allow placing bid orders higher than inventory price? (" + "Yes/No) >>> ", + required_if=lambda: pure_market_making_config_map.get( + "price_type").value == "inventory_cost", + type_str="bool", + validator=validate_bool), "order_override": ConfigVar(key="order_override", prompt=None, diff --git a/hummingbot/strategy/pure_market_making/start.py b/hummingbot/strategy/pure_market_making/start.py index 0ba5e2987c..e104617d43 100644 --- a/hummingbot/strategy/pure_market_making/start.py +++ b/hummingbot/strategy/pure_market_making/start.py @@ -71,6 +71,7 @@ def start(self): if price_type == "inventory_cost": db = HummingbotApplication.main_application().trade_fill_db inventory_cost_price_delegate = InventoryCostPriceDelegate(db, trading_pair) + inventory_cost_allow_higher_bids = c_map.get("inventory_cost_allow_higher_bids").value take_if_crossed = c_map.get("take_if_crossed").value strategy_logging_options = PureMarketMakingStrategy.OPTION_LOG_ALL @@ -96,6 +97,7 @@ def start(self): logging_options=strategy_logging_options, asset_price_delegate=asset_price_delegate, inventory_cost_price_delegate=inventory_cost_price_delegate, + inventory_cost_allow_higher_bids=inventory_cost_allow_higher_bids, price_type=price_type, take_if_crossed=take_if_crossed, price_ceiling=price_ceiling, diff --git a/hummingbot/templates/conf_pure_market_making_strategy_TEMPLATE.yml b/hummingbot/templates/conf_pure_market_making_strategy_TEMPLATE.yml index 864192594e..52b49a8845 100644 --- a/hummingbot/templates/conf_pure_market_making_strategy_TEMPLATE.yml +++ b/hummingbot/templates/conf_pure_market_making_strategy_TEMPLATE.yml @@ -60,6 +60,9 @@ inventory_range_multiplier: null # Initial price of the base asset inventory_price: null +# Allow to place bids higher than inventory price? +inventory_cost_allow_higher_bids: null + # Number of levels of orders to place on each side of the order book. order_levels: null diff --git a/test/test_pmm.py b/test/test_pmm.py index d6bef7d665..a7c7550767 100644 --- a/test/test_pmm.py +++ b/test/test_pmm.py @@ -801,6 +801,33 @@ def test_inventory_cost_price_del(self): first_bid_order = strategy.active_buys[0] self.assertEqual(Decimal("98.01"), first_bid_order.price) + def test_inventory_cost_price_del_allow_higher_bids(self): + strategy = self.one_level_strategy + strategy.inventory_cost_price_delegate = self.inventory_cost_price_del + strategy.inventory_cost_allow_higher_bids = True + self.clock.add_iterator(strategy) + self.clock.backtest_til(self.start_timestamp + 1) + + # Expecting to have orders set according to mid_price as there is no inventory cost data yet + first_bid_order = strategy.active_buys[0] + first_ask_order = strategy.active_sells[0] + self.assertEqual(Decimal("99"), first_bid_order.price) + self.assertEqual(Decimal("101"), first_ask_order.price) + + # Filling buy order + self.simulate_maker_market_trade( + is_buy=False, quantity=Decimal("1"), price=Decimal("98.9"), + ) + # And shifting market up + simulate_order_book_widening(self.book_data.order_book, 101, 110) + self.clock.backtest_til(self.start_timestamp + 7) + + # Expect inventory cost price to be equal to buy price + self.assertEqual(strategy.inventory_cost_price_delegate.get_price(), Decimal("99")) + first_bid_order = strategy.active_buys[0] + # Expect bid price to be higher that inventory cost price + self.assertGreater(first_bid_order.price, Decimal("101")) + def test_order_book_asset_del(self): strategy = self.one_level_strategy strategy.asset_price_delegate = self.order_book_asset_del From 684f2553c3d2af09c377fcb67a101710f2f78d55 Mon Sep 17 00:00:00 2001 From: Vladimir Kamarzin Date: Mon, 9 Nov 2020 17:16:55 +0500 Subject: [PATCH 004/126] (fix) Handle decimal zero division --- .../inventory_cost_price_delegate.py | 4 ++-- test/test_inventory_cost_price_delegate.py | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/hummingbot/strategy/pure_market_making/inventory_cost_price_delegate.py b/hummingbot/strategy/pure_market_making/inventory_cost_price_delegate.py index 926a98ecb0..3fc4fa99f4 100644 --- a/hummingbot/strategy/pure_market_making/inventory_cost_price_delegate.py +++ b/hummingbot/strategy/pure_market_making/inventory_cost_price_delegate.py @@ -1,4 +1,4 @@ -from decimal import Decimal +from decimal import Decimal, InvalidOperation from hummingbot.core.event.events import OrderFilledEvent, TradeType from hummingbot.exceptions import NoPrice @@ -21,7 +21,7 @@ def get_price(self) -> Decimal: ) try: price = record.quote_volume / record.base_volume - except (ZeroDivisionError, AttributeError): + except (ZeroDivisionError, InvalidOperation, AttributeError): raise NoPrice("Inventory cost delegate does not have price cost data yet") return Decimal(price) diff --git a/test/test_inventory_cost_price_delegate.py b/test/test_inventory_cost_price_delegate.py index 7feaa73087..f56fb3e814 100644 --- a/test/test_inventory_cost_price_delegate.py +++ b/test/test_inventory_cost_price_delegate.py @@ -103,3 +103,17 @@ def test_get_price_by_type(self): self._session.commit() delegate_price = self.delegate.get_price() self.assertEqual(delegate_price, price) + + def test_get_price_by_type_zero_division(self): + amount = Decimal("0") + price = Decimal("9000") + + record = InventoryCost( + base_asset=self.base_asset, + quote_asset=self.quote_asset, + base_volume=amount, + quote_volume=amount * price, + ) + self._session.add(record) + self._session.commit() + self.assertRaises(NoPrice, self.delegate.get_price) From 741e8e10a6e058df56718e99b0aaf54d5f1f4384 Mon Sep 17 00:00:00 2001 From: Nullably <37262506+Nullably@users.noreply.github.com> Date: Tue, 22 Dec 2020 16:57:18 +0800 Subject: [PATCH 005/126] (feat) add base files --- .../strategy/liquidity_mining/__init__.py | 0 .../liquidity_mining/liquidity_mining.py | 150 ++++++++++++++++++ .../liquidity_mining_config_map.py | 54 +++++++ ...onf_liquidity_mining_strategy_TEMPLATE.yml | 29 ++++ 4 files changed, 233 insertions(+) create mode 100644 hummingbot/strategy/liquidity_mining/__init__.py create mode 100644 hummingbot/strategy/liquidity_mining/liquidity_mining.py create mode 100644 hummingbot/strategy/liquidity_mining/liquidity_mining_config_map.py create mode 100644 hummingbot/templates/conf_liquidity_mining_strategy_TEMPLATE.yml diff --git a/hummingbot/strategy/liquidity_mining/__init__.py b/hummingbot/strategy/liquidity_mining/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/hummingbot/strategy/liquidity_mining/liquidity_mining.py b/hummingbot/strategy/liquidity_mining/liquidity_mining.py new file mode 100644 index 0000000000..0eb2de738b --- /dev/null +++ b/hummingbot/strategy/liquidity_mining/liquidity_mining.py @@ -0,0 +1,150 @@ +from decimal import Decimal +import logging +import asyncio +import pandas as pd +from typing import List +from hummingbot.core.clock import Clock +from hummingbot.logger import HummingbotLogger +from hummingbot.strategy.strategy_py_base import StrategyPyBase +from hummingbot.connector.exchange_base import ExchangeBase +from hummingbot.client.settings import ETH_WALLET_CONNECTORS +from hummingbot.connector.connector.uniswap.uniswap_connector import UniswapConnector + + +NaN = float("nan") +s_decimal_zero = Decimal(0) +amm_logger = None + + +class LiquidityMiningStrategy(StrategyPyBase): + """ + This is a basic arbitrage strategy which can be used for most types of connectors (CEX, DEX or AMM). + For a given order amount, the strategy checks both sides of the trade (market_1 and market_2) for arb opportunity. + If presents, the strategy submits taker orders to both market. + """ + + @classmethod + def logger(cls) -> HummingbotLogger: + global amm_logger + if amm_logger is None: + amm_logger = logging.getLogger(__name__) + return amm_logger + + def __init__(self, + exchange: ExchangeBase, + markets: List[str], + initial_spread: Decimal, + order_refresh_time: float, + order_refresh_tolerance_pct: Decimal, + status_report_interval: float = 900): + super().__init__() + self._exchange = exchange + self._markets = markets + self._initial_spread = initial_spread + self._order_refresh_time = order_refresh_time + self._order_refresh_tolerance_pct = order_refresh_tolerance_pct + self._ev_loop = asyncio.get_event_loop() + self._last_timestamp = 0 + self._status_report_interval = status_report_interval + + def tick(self, timestamp: float): + """ + Clock tick entry point, is run every second (on normal tick setting). + :param timestamp: current tick timestamp + """ + if not self._all_markets_ready: + self._all_markets_ready = all([market.ready for market in self.active_markets]) + if not self._all_markets_ready: + self.logger().warning("Markets are not ready. Please wait...") + return + else: + self.logger().info("Markets are ready. Trading started.") + if self._create_timestamp <= self._current_timestamp: + # 1. Create base order proposals + proposal = self.create_base_proposal() + # 2. Apply functions that limit numbers of buys and sells proposal + # self.c_apply_order_levels_modifiers(proposal) + # 3. Apply functions that modify orders price + # self.c_apply_order_price_modifiers(proposal) + # 4. Apply functions that modify orders size + # self.c_apply_order_size_modifiers(proposal) + # 5. Apply budget constraint, i.e. can't buy/sell more than what you have. + # self.c_apply_budget_constraint(proposal) + + self.cancel_active_orders(proposal) + if self.c_to_create_orders(proposal): + self.c_execute_orders_proposal(proposal) + + self._last_timestamp = timestamp + + async def format_status(self) -> str: + """ + Returns a status string formatted to display nicely on terminal. The strings composes of 4 parts: markets, + assets, profitability and warnings(if any). + """ + + if self._arb_proposals is None: + return " The strategy is not ready, please try again later." + # active_orders = self.market_info_to_active_orders.get(self._market_info, []) + columns = ["Exchange", "Market", "Sell Price", "Buy Price", "Mid Price"] + data = [] + for market_info in [self._market_info_1, self._market_info_2]: + market, trading_pair, base_asset, quote_asset = market_info + buy_price = await market.get_quote_price(trading_pair, True, self._order_amount) + sell_price = await market.get_quote_price(trading_pair, False, self._order_amount) + mid_price = (buy_price + sell_price) / 2 + data.append([ + market.display_name, + trading_pair, + float(sell_price), + float(buy_price), + float(mid_price) + ]) + markets_df = pd.DataFrame(data=data, columns=columns) + lines = [] + lines.extend(["", " Markets:"] + [" " + line for line in markets_df.to_string(index=False).split("\n")]) + + assets_df = self.wallet_balance_data_frame([self._market_info_1, self._market_info_2]) + lines.extend(["", " Assets:"] + + [" " + line for line in str(assets_df).split("\n")]) + + lines.extend(["", " Profitability:"] + self.short_proposal_msg(self._arb_proposals)) + + warning_lines = self.network_warning([self._market_info_1]) + warning_lines.extend(self.network_warning([self._market_info_2])) + warning_lines.extend(self.balance_warning([self._market_info_1])) + warning_lines.extend(self.balance_warning([self._market_info_2])) + if len(warning_lines) > 0: + lines.extend(["", "*** WARNINGS ***"] + warning_lines) + + return "\n".join(lines) + + def start(self, clock: Clock, timestamp: float): + pass + + def stop(self, clock: Clock): + pass + + async def quote_in_eth_rate_fetch_loop(self): + while True: + try: + if self._market_info_1.market.name in ETH_WALLET_CONNECTORS and \ + "WETH" not in self._market_info_1.trading_pair.split("-"): + self._market_1_quote_eth_rate = await self.request_rate_in_eth(self._market_info_1.quote_asset) + if self._market_info_2.market.name in ETH_WALLET_CONNECTORS and \ + "WETH" not in self._market_info_2.trading_pair.split("-"): + self._market_2_quote_eth_rate = await self.request_rate_in_eth(self._market_info_2.quote_asset) + await asyncio.sleep(60 * 5) + except asyncio.CancelledError: + raise + except Exception as e: + self.logger().error(str(e), exc_info=True) + self.logger().network("Unexpected error while fetching account updates.", + exc_info=True, + app_warning_msg="Could not fetch balances from Gateway API.") + await asyncio.sleep(0.5) + + async def request_rate_in_eth(self, quote: str) -> int: + if self._uniswap is None: + self._uniswap = UniswapConnector([f"{quote}-WETH"], "", None) + return await self._uniswap.get_quote_price(f"{quote}-WETH", True, 1) diff --git a/hummingbot/strategy/liquidity_mining/liquidity_mining_config_map.py b/hummingbot/strategy/liquidity_mining/liquidity_mining_config_map.py new file mode 100644 index 0000000000..b3a2f6346b --- /dev/null +++ b/hummingbot/strategy/liquidity_mining/liquidity_mining_config_map.py @@ -0,0 +1,54 @@ +from decimal import Decimal + +from hummingbot.client.config.config_var import ConfigVar +from hummingbot.client.config.config_validators import ( + validate_exchange, + validate_decimal, +) +from hummingbot.client.settings import ( + required_exchanges, +) + + +def exchange_on_validated(value: str) -> None: + required_exchanges.append(value) + + +liquidity_mining_config_map = { + "strategy": ConfigVar( + key="strategy", + prompt="", + default="liquidity_mining"), + "exchange": + ConfigVar(key="exchange", + prompt="Enter your liquidity mining exchange name >>> ", + validator=validate_exchange, + on_validated=exchange_on_validated, + prompt_on_new=True), + "markets": + ConfigVar(key="markets", + prompt="Enter a list of markets", + # validator=validate_exchange_trading_pair, + prompt_on_new=True), + "initial_spread": + ConfigVar(key="initial_spread", + prompt="How far away from the mid price do you want to place bid order and ask order? " + "(Enter 1 to indicate 1%) >>> ", + type_str="decimal", + validator=lambda v: validate_decimal(v, 0, 100, inclusive=False), + prompt_on_new=True), + "order_refresh_time": + ConfigVar(key="order_refresh_time", + prompt="How often do you want to cancel and replace bids and asks " + "(in seconds)? >>> ", + type_str="float", + validator=lambda v: validate_decimal(v, 0, inclusive=False), + default=5.), + "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.2"), + validator=lambda v: validate_decimal(v, -10, 10, inclusive=True)), +} diff --git a/hummingbot/templates/conf_liquidity_mining_strategy_TEMPLATE.yml b/hummingbot/templates/conf_liquidity_mining_strategy_TEMPLATE.yml new file mode 100644 index 0000000000..3c8109f3ed --- /dev/null +++ b/hummingbot/templates/conf_liquidity_mining_strategy_TEMPLATE.yml @@ -0,0 +1,29 @@ +######################################################## +### Liquidity Mining strategy config ### +######################################################## + +template_version: 1 +strategy: null + +# Exchange and token parameters. +exchange: null + +# Token trading pair for the exchange, e.g. BTC-USDT +markets: null + +# How far away from mid price to place the bid and ask order. +# Spread of 1 = 1% away from mid price at that time. +# Example if mid price is 100 and initial_spread is 1. +# Your bid is placed at 99 and ask is at 101. +initial_spread: 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 + +# 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 + +# For more detailed information, see: +# https://docs.hummingbot.io/strategies/pure-market-making/#configuration-parameters From 7baa2e19a2926e566f5bc9a32e2d6b57552c090b Mon Sep 17 00:00:00 2001 From: Vladimir Kamarzin Date: Sat, 26 Dec 2020 22:00:09 +0500 Subject: [PATCH 006/126] (fix) Handle TypeError when exiting from inventory_price prompt --- hummingbot/client/command/config_command.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/hummingbot/client/command/config_command.py b/hummingbot/client/command/config_command.py index cb07b33b8a..05d56d54d5 100644 --- a/hummingbot/client/command/config_command.py +++ b/hummingbot/client/command/config_command.py @@ -254,12 +254,20 @@ async def inventory_price_prompt( ) await self.prompt_a_config(cvar) config_map[key].value = cvar.value + + try: + quote_volume = balances[base_asset] * cvar.value + except TypeError: + # TypeError: unsupported operand type(s) for *: 'decimal.Decimal' and 'NoneType' - bad input / no input + self._notify("Inventory price not updated due to bad input") + return + session: Session = self.trade_fill_db.get_shared_session() InventoryCost.add_volume( session, base_asset=base_asset, quote_asset=quote_asset, base_volume=balances[base_asset], - quote_volume=balances[base_asset] * cvar.value, + quote_volume=quote_volume, overwrite=True, ) From 3956cf3f584c4c7b708ed5e558382885113d4df6 Mon Sep 17 00:00:00 2001 From: Vladimir Kamarzin Date: Sat, 26 Dec 2020 22:06:49 +0500 Subject: [PATCH 007/126] (fix) Add default value for inventory_price It's ok to set default because it doesn't set the default in database. So, it doesn't affect anything --- .../pure_market_making/pure_market_making_config_map.py | 1 + .../templates/conf_pure_market_making_strategy_TEMPLATE.yml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) 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 398c9a9983..78a4d5dff8 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 @@ -241,6 +241,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), + default=Decimal("1"), ), "filled_order_delay": ConfigVar(key="filled_order_delay", diff --git a/hummingbot/templates/conf_pure_market_making_strategy_TEMPLATE.yml b/hummingbot/templates/conf_pure_market_making_strategy_TEMPLATE.yml index 61ff054c2a..7976befa4e 100644 --- a/hummingbot/templates/conf_pure_market_making_strategy_TEMPLATE.yml +++ b/hummingbot/templates/conf_pure_market_making_strategy_TEMPLATE.yml @@ -60,7 +60,7 @@ inventory_target_base_pct: null # inventory skew feature). inventory_range_multiplier: null -# Initial price of the base asset +# Initial price of the base asset. Note: this setting is not affects anything, the price is kept in the database. inventory_price: null # Allow to place bids higher than inventory price? From f59021ccf3770a5836978799c57bd1ef6d3a1457 Mon Sep 17 00:00:00 2001 From: vic-en Date: Wed, 30 Dec 2020 02:17:49 +0100 Subject: [PATCH 008/126] (fix) remove extra take profit orders when using multiple order levels --- .../perpetual_market_making/perpetual_market_making.pyx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/hummingbot/strategy/perpetual_market_making/perpetual_market_making.pyx b/hummingbot/strategy/perpetual_market_making/perpetual_market_making.pyx index a0f0faad28..e474b84de7 100644 --- a/hummingbot/strategy/perpetual_market_making/perpetual_market_making.pyx +++ b/hummingbot/strategy/perpetual_market_making/perpetual_market_making.pyx @@ -627,6 +627,7 @@ cdef class PerpetualMarketMakingStrategy(StrategyBase): cdef: ExchangeBase market = self._market_info.market list active_orders = self.active_orders + list unwanted_exit_orders = [o for o in active_orders if o.client_order_id not in self._ts_exit_orders] list buys = [] list sells = [] @@ -636,7 +637,6 @@ cdef class PerpetualMarketMakingStrategy(StrategyBase): self.logger().error(f"Kindly ensure you do not interract with the exchange through other platforms and restart this strategy.") else: # Cancel open order that could potentially close position before reaching take_profit_limit - unwanted_exit_orders = [o for o in active_orders if o.client_order_id not in self._ts_exit_orders] for order in unwanted_exit_orders: if active_positions[0].amount < 0 and order.is_buy: self.c_cancel_order(self._market_info, order.client_order_id) @@ -651,6 +651,11 @@ cdef class PerpetualMarketMakingStrategy(StrategyBase): 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) + old_exit_orders = [o for o in active_orders if (o.price > price and position.amount < 0 and o.client_order_id in self._ts_exit_orders) + or (o.price < price and position.amount > 0 and o.client_order_id in self._ts_exit_orders)] + for old_order in old_exit_orders: + self.c_cancel_order(self._market_info, old_order.client_order_id) + self.logger().info(f"Cancelled previous take profit order {old_order.client_order_id} in favour of new take profit order.") exit_order_exists = [o for o in active_orders if o.price == price] if len(exit_order_exists) == 0: size = market.c_quantize_order_amount(self.trading_pair, abs(position.amount)) From 587f83eee195297c35ddaa5c76784011fe46dc6c Mon Sep 17 00:00:00 2001 From: Nullably <37262506+Nullably@users.noreply.github.com> Date: Thu, 31 Dec 2020 10:56:29 +0800 Subject: [PATCH 009/126] (feat) add data types and start --- .../strategy/liquidity_mining/data_types.py | 21 +++ .../liquidity_mining/liquidity_mining.py | 156 +++++++----------- .../liquidity_mining_config_map.py | 2 +- hummingbot/strategy/liquidity_mining/start.py | 25 +++ 4 files changed, 109 insertions(+), 95 deletions(-) create mode 100644 hummingbot/strategy/liquidity_mining/data_types.py create mode 100644 hummingbot/strategy/liquidity_mining/start.py diff --git a/hummingbot/strategy/liquidity_mining/data_types.py b/hummingbot/strategy/liquidity_mining/data_types.py new file mode 100644 index 0000000000..10f2ef19ca --- /dev/null +++ b/hummingbot/strategy/liquidity_mining/data_types.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python +from decimal import 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, market: str, buy: PriceSize, sell: PriceSize): + self.market: str = market + self.buy: PriceSize = buy + self.sell: PriceSize = sell + + def __repr__(self): + return f"{self.market} buy: {self.buy} sell: {self.sell}" diff --git a/hummingbot/strategy/liquidity_mining/liquidity_mining.py b/hummingbot/strategy/liquidity_mining/liquidity_mining.py index 0eb2de738b..8fa681a4d1 100644 --- a/hummingbot/strategy/liquidity_mining/liquidity_mining.py +++ b/hummingbot/strategy/liquidity_mining/liquidity_mining.py @@ -1,19 +1,18 @@ from decimal import Decimal import logging import asyncio -import pandas as pd -from typing import List +from typing import Dict from hummingbot.core.clock import Clock from hummingbot.logger import HummingbotLogger from hummingbot.strategy.strategy_py_base import StrategyPyBase from hummingbot.connector.exchange_base import ExchangeBase -from hummingbot.client.settings import ETH_WALLET_CONNECTORS -from hummingbot.connector.connector.uniswap.uniswap_connector import UniswapConnector - +from hummingbot.strategy.market_trading_pair_tuple import MarketTradingPairTuple +from .data_types import Proposal, PriceSize +from ...core.event.events import OrderType NaN = float("nan") s_decimal_zero = Decimal(0) -amm_logger = None +lms_logger = None class LiquidityMiningStrategy(StrategyPyBase): @@ -25,99 +24,54 @@ class LiquidityMiningStrategy(StrategyPyBase): @classmethod def logger(cls) -> HummingbotLogger: - global amm_logger - if amm_logger is None: - amm_logger = logging.getLogger(__name__) - return amm_logger + global lms_logger + if lms_logger is None: + lms_logger = logging.getLogger(__name__) + return lms_logger def __init__(self, exchange: ExchangeBase, - markets: List[str], + market_infos: Dict[str, MarketTradingPairTuple], initial_spread: Decimal, order_refresh_time: float, order_refresh_tolerance_pct: Decimal, status_report_interval: float = 900): super().__init__() self._exchange = exchange - self._markets = markets + self._market_infos = market_infos self._initial_spread = initial_spread self._order_refresh_time = order_refresh_time self._order_refresh_tolerance_pct = order_refresh_tolerance_pct self._ev_loop = asyncio.get_event_loop() self._last_timestamp = 0 self._status_report_interval = status_report_interval + self._ready_to_trade = False + self.add_markets([exchange]) def tick(self, timestamp: float): """ Clock tick entry point, is run every second (on normal tick setting). :param timestamp: current tick timestamp """ - if not self._all_markets_ready: - self._all_markets_ready = all([market.ready for market in self.active_markets]) - if not self._all_markets_ready: - self.logger().warning("Markets are not ready. Please wait...") + if not self._ready_to_trade: + self._ready_to_trade = self._exchange.ready + if not self._exchange.ready: + self.logger().warning(f"{self._exchange.name} is not ready. Please wait...") return else: - self.logger().info("Markets are ready. Trading started.") - if self._create_timestamp <= self._current_timestamp: - # 1. Create base order proposals - proposal = self.create_base_proposal() - # 2. Apply functions that limit numbers of buys and sells proposal - # self.c_apply_order_levels_modifiers(proposal) - # 3. Apply functions that modify orders price - # self.c_apply_order_price_modifiers(proposal) - # 4. Apply functions that modify orders size - # self.c_apply_order_size_modifiers(proposal) - # 5. Apply budget constraint, i.e. can't buy/sell more than what you have. - # self.c_apply_budget_constraint(proposal) + self.logger().info(f"{self._exchange.name} is ready. Trading started.") - self.cancel_active_orders(proposal) - if self.c_to_create_orders(proposal): - self.c_execute_orders_proposal(proposal) + proposals = self.create_base_proposals() + self.apply_volatility_adjustment(proposals) + self.execute_orders_proposal(proposals) + # self.cancel_active_orders(proposal) + # if self.c_to_create_orders(proposal): + # self.c_execute_orders_proposal(proposal) self._last_timestamp = timestamp async def format_status(self) -> str: - """ - Returns a status string formatted to display nicely on terminal. The strings composes of 4 parts: markets, - assets, profitability and warnings(if any). - """ - - if self._arb_proposals is None: - return " The strategy is not ready, please try again later." - # active_orders = self.market_info_to_active_orders.get(self._market_info, []) - columns = ["Exchange", "Market", "Sell Price", "Buy Price", "Mid Price"] - data = [] - for market_info in [self._market_info_1, self._market_info_2]: - market, trading_pair, base_asset, quote_asset = market_info - buy_price = await market.get_quote_price(trading_pair, True, self._order_amount) - sell_price = await market.get_quote_price(trading_pair, False, self._order_amount) - mid_price = (buy_price + sell_price) / 2 - data.append([ - market.display_name, - trading_pair, - float(sell_price), - float(buy_price), - float(mid_price) - ]) - markets_df = pd.DataFrame(data=data, columns=columns) - lines = [] - lines.extend(["", " Markets:"] + [" " + line for line in markets_df.to_string(index=False).split("\n")]) - - assets_df = self.wallet_balance_data_frame([self._market_info_1, self._market_info_2]) - lines.extend(["", " Assets:"] + - [" " + line for line in str(assets_df).split("\n")]) - - lines.extend(["", " Profitability:"] + self.short_proposal_msg(self._arb_proposals)) - - warning_lines = self.network_warning([self._market_info_1]) - warning_lines.extend(self.network_warning([self._market_info_2])) - warning_lines.extend(self.balance_warning([self._market_info_1])) - warning_lines.extend(self.balance_warning([self._market_info_2])) - if len(warning_lines) > 0: - lines.extend(["", "*** WARNINGS ***"] + warning_lines) - - return "\n".join(lines) + return "Not implemented" def start(self, clock: Clock, timestamp: float): pass @@ -125,26 +79,40 @@ def start(self, clock: Clock, timestamp: float): def stop(self, clock: Clock): pass - async def quote_in_eth_rate_fetch_loop(self): - while True: - try: - if self._market_info_1.market.name in ETH_WALLET_CONNECTORS and \ - "WETH" not in self._market_info_1.trading_pair.split("-"): - self._market_1_quote_eth_rate = await self.request_rate_in_eth(self._market_info_1.quote_asset) - if self._market_info_2.market.name in ETH_WALLET_CONNECTORS and \ - "WETH" not in self._market_info_2.trading_pair.split("-"): - self._market_2_quote_eth_rate = await self.request_rate_in_eth(self._market_info_2.quote_asset) - await asyncio.sleep(60 * 5) - except asyncio.CancelledError: - raise - except Exception as e: - self.logger().error(str(e), exc_info=True) - self.logger().network("Unexpected error while fetching account updates.", - exc_info=True, - app_warning_msg="Could not fetch balances from Gateway API.") - await asyncio.sleep(0.5) - - async def request_rate_in_eth(self, quote: str) -> int: - if self._uniswap is None: - self._uniswap = UniswapConnector([f"{quote}-WETH"], "", None) - return await self._uniswap.get_quote_price(f"{quote}-WETH", True, 1) + def create_base_proposals(self): + proposals = [] + for market, market_info in self._market_infos.items(): + mid_price = market_info.get_mid_price() + buy_price = mid_price * (Decimal("1") - self._initial_spread) + buy_price = self._exchange.quantize_order_price(market, buy_price) + buy_size = Decimal("0.2") + buy_size = self._exchange.quantize_order_amount(market, buy_size) + + sell_price = mid_price * (Decimal("1") + self._initial_spread) + sell_price = self._exchange.quantize_order_price(market, sell_price) + sell_size = Decimal("0.2") + sell_size = self._exchange.quantize_order_amount(market, sell_size) + proposals.append(Proposal(market, PriceSize(buy_price, buy_size), PriceSize(sell_price, sell_size))) + return proposals + + def apply_volatility_adjustment(self, proposals): + return + + def execute_orders_proposal(self, proposals): + for proposal in proposals: + if proposal.buy.size > 0: + self.logger().info(f"({proposal.market}) Creating a bid order {proposal.buy}") + self.buy_with_specific_market( + self._market_infos[proposal.market], + proposal.buy.size, + order_type=OrderType.LIMIT_MAKER, + price=proposal.buy.price + ) + if proposal.sell.size > 0: + self.logger().info(f"({proposal.market}) Creating an ask order at {proposal.sell}") + self.sell_with_specific_market( + self._market_infos[proposal.market], + proposal.sell.size, + order_type=OrderType.LIMIT_MAKER, + price=proposal.sell.price + ) diff --git a/hummingbot/strategy/liquidity_mining/liquidity_mining_config_map.py b/hummingbot/strategy/liquidity_mining/liquidity_mining_config_map.py index b3a2f6346b..3a698293d2 100644 --- a/hummingbot/strategy/liquidity_mining/liquidity_mining_config_map.py +++ b/hummingbot/strategy/liquidity_mining/liquidity_mining_config_map.py @@ -27,7 +27,7 @@ def exchange_on_validated(value: str) -> None: prompt_on_new=True), "markets": ConfigVar(key="markets", - prompt="Enter a list of markets", + prompt="Enter a list of markets >>> ", # validator=validate_exchange_trading_pair, prompt_on_new=True), "initial_spread": diff --git a/hummingbot/strategy/liquidity_mining/start.py b/hummingbot/strategy/liquidity_mining/start.py new file mode 100644 index 0000000000..f0728e3372 --- /dev/null +++ b/hummingbot/strategy/liquidity_mining/start.py @@ -0,0 +1,25 @@ +from decimal import Decimal +from hummingbot.strategy.market_trading_pair_tuple import MarketTradingPairTuple +from hummingbot.strategy.liquidity_mining.liquidity_mining import LiquidityMiningStrategy +from hummingbot.strategy.liquidity_mining.liquidity_mining_config_map import liquidity_mining_config_map as c_map + + +def start(self): + exchange = c_map.get("exchange").value.lower() + markets_text = c_map.get("markets").value + initial_spread = c_map.get("initial_spread").value / Decimal("100") + order_refresh_time = c_map.get("order_refresh_time").value + order_refresh_tolerance_pct = c_map.get("order_refresh_tolerance_pct").value + markets = list(markets_text.split(",")) + + self._initialize_markets([(exchange, markets)]) + exchange = self.markets[exchange] + market_infos = {} + for market in markets: + base, quote = market.split("-") + market_infos[market] = MarketTradingPairTuple(exchange, market, base, quote) + self.strategy = LiquidityMiningStrategy(exchange=exchange, + market_infos=market_infos, + initial_spread=initial_spread, + order_refresh_time=order_refresh_time, + order_refresh_tolerance_pct=order_refresh_tolerance_pct) From 1a82726e1a2d08838bc5c4d8695133a424d4042a Mon Sep 17 00:00:00 2001 From: Vladimir Kamarzin Date: Thu, 31 Dec 2020 11:38:47 +0500 Subject: [PATCH 010/126] Revert "(feat) Add new param for cost-based inventory ref price" This reverts commit 81fdd9a7ce125309175cf7ada25e8f075564727a. Remove this functionality according to Jack comments. https://github.com/CoinAlpha/hummingbot/pull/2513#discussion_r549401722 --- .../pure_market_making/pure_market_making.pxd | 1 - .../pure_market_making/pure_market_making.pyx | 13 +-------- .../pure_market_making_config_map.py | 8 ------ .../strategy/pure_market_making/start.py | 2 -- ...f_pure_market_making_strategy_TEMPLATE.yml | 3 --- test/test_pmm.py | 27 ------------------- 6 files changed, 1 insertion(+), 53 deletions(-) diff --git a/hummingbot/strategy/pure_market_making/pure_market_making.pxd b/hummingbot/strategy/pure_market_making/pure_market_making.pxd index 44f0a0eb0d..dcc5316ba6 100644 --- a/hummingbot/strategy/pure_market_making/pure_market_making.pxd +++ b/hummingbot/strategy/pure_market_making/pure_market_making.pxd @@ -32,7 +32,6 @@ cdef class PureMarketMakingStrategy(StrategyBase): bint _add_transaction_costs_to_orders object _asset_price_delegate object _inventory_cost_price_delegate - bint _inventory_cost_allow_higher_bids object _price_type bint _take_if_crossed object _price_ceiling diff --git a/hummingbot/strategy/pure_market_making/pure_market_making.pyx b/hummingbot/strategy/pure_market_making/pure_market_making.pyx index 4b49e3abaf..f49a762d93 100644 --- a/hummingbot/strategy/pure_market_making/pure_market_making.pyx +++ b/hummingbot/strategy/pure_market_making/pure_market_making.pyx @@ -85,7 +85,6 @@ cdef class PureMarketMakingStrategy(StrategyBase): add_transaction_costs_to_orders: bool = False, asset_price_delegate: AssetPriceDelegate = None, inventory_cost_price_delegate: InventoryCostPriceDelegate = None, - inventory_cost_allow_higher_bids: bool = False, price_type: str = "mid_price", take_if_crossed: bool = False, price_ceiling: Decimal = s_decimal_neg_one, @@ -128,7 +127,6 @@ cdef class PureMarketMakingStrategy(StrategyBase): self._add_transaction_costs_to_orders = add_transaction_costs_to_orders self._asset_price_delegate = asset_price_delegate self._inventory_cost_price_delegate = inventory_cost_price_delegate - self._inventory_cost_allow_higher_bids = inventory_cost_allow_higher_bids self._price_type = self.get_price_type(price_type) self._take_if_crossed = take_if_crossed self._price_ceiling = price_ceiling @@ -433,14 +431,6 @@ cdef class PureMarketMakingStrategy(StrategyBase): def inventory_cost_price_delegate(self, value): self._inventory_cost_price_delegate = value - @property - def inventory_cost_allow_higher_bids(self): - return self._inventory_cost_allow_higher_bids - - @inventory_cost_allow_higher_bids.setter - def inventory_cost_allow_higher_bids(self, value): - self._inventory_cost_allow_higher_bids = value - @property def order_tracker(self): return self._sb_order_tracker @@ -719,8 +709,7 @@ cdef class PureMarketMakingStrategy(StrategyBase): except NoPrice: pass else: - if not self._inventory_cost_allow_higher_bids: - buy_reference_price = min(inventory_cost_price, buy_reference_price) + buy_reference_price = min(inventory_cost_price, buy_reference_price) sell_reference_price = max(inventory_cost_price, sell_reference_price) # First to check if a customized order override is configured, otherwise the proposal will be created according 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 78a4d5dff8..a8d69eec0b 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 @@ -341,14 +341,6 @@ def exchange_on_validated(value: str): prompt="Enter pricing API URL >>> ", required_if=lambda: pure_market_making_config_map.get("price_source").value == "custom_api", type_str="str"), - "inventory_cost_allow_higher_bids": - ConfigVar(key="inventory_cost_allow_higher_bids", - prompt="Do you want to allow placing bid orders higher than inventory price? (" - "Yes/No) >>> ", - required_if=lambda: pure_market_making_config_map.get( - "price_type").value == "inventory_cost", - type_str="bool", - validator=validate_bool), "order_override": ConfigVar(key="order_override", prompt=None, diff --git a/hummingbot/strategy/pure_market_making/start.py b/hummingbot/strategy/pure_market_making/start.py index 6041e94e78..6c900e0320 100644 --- a/hummingbot/strategy/pure_market_making/start.py +++ b/hummingbot/strategy/pure_market_making/start.py @@ -72,7 +72,6 @@ def start(self): if price_type == "inventory_cost": db = HummingbotApplication.main_application().trade_fill_db inventory_cost_price_delegate = InventoryCostPriceDelegate(db, trading_pair) - inventory_cost_allow_higher_bids = c_map.get("inventory_cost_allow_higher_bids").value take_if_crossed = c_map.get("take_if_crossed").value strategy_logging_options = PureMarketMakingStrategy.OPTION_LOG_ALL @@ -99,7 +98,6 @@ def start(self): logging_options=strategy_logging_options, asset_price_delegate=asset_price_delegate, inventory_cost_price_delegate=inventory_cost_price_delegate, - inventory_cost_allow_higher_bids=inventory_cost_allow_higher_bids, price_type=price_type, take_if_crossed=take_if_crossed, price_ceiling=price_ceiling, diff --git a/hummingbot/templates/conf_pure_market_making_strategy_TEMPLATE.yml b/hummingbot/templates/conf_pure_market_making_strategy_TEMPLATE.yml index 7976befa4e..1a1a83f20e 100644 --- a/hummingbot/templates/conf_pure_market_making_strategy_TEMPLATE.yml +++ b/hummingbot/templates/conf_pure_market_making_strategy_TEMPLATE.yml @@ -63,9 +63,6 @@ inventory_range_multiplier: null # Initial price of the base asset. Note: this setting is not affects anything, the price is kept in the database. inventory_price: null -# Allow to place bids higher than inventory price? -inventory_cost_allow_higher_bids: null - # Number of levels of orders to place on each side of the order book. order_levels: null diff --git a/test/test_pmm.py b/test/test_pmm.py index 66b27384c5..b89e58ced0 100644 --- a/test/test_pmm.py +++ b/test/test_pmm.py @@ -818,33 +818,6 @@ def test_inventory_cost_price_del(self): first_bid_order = strategy.active_buys[0] self.assertEqual(Decimal("98.01"), first_bid_order.price) - def test_inventory_cost_price_del_allow_higher_bids(self): - strategy = self.one_level_strategy - strategy.inventory_cost_price_delegate = self.inventory_cost_price_del - strategy.inventory_cost_allow_higher_bids = True - self.clock.add_iterator(strategy) - self.clock.backtest_til(self.start_timestamp + 1) - - # Expecting to have orders set according to mid_price as there is no inventory cost data yet - first_bid_order = strategy.active_buys[0] - first_ask_order = strategy.active_sells[0] - self.assertEqual(Decimal("99"), first_bid_order.price) - self.assertEqual(Decimal("101"), first_ask_order.price) - - # Filling buy order - self.simulate_maker_market_trade( - is_buy=False, quantity=Decimal("1"), price=Decimal("98.9"), - ) - # And shifting market up - simulate_order_book_widening(self.book_data.order_book, 101, 110) - self.clock.backtest_til(self.start_timestamp + 7) - - # Expect inventory cost price to be equal to buy price - self.assertEqual(strategy.inventory_cost_price_delegate.get_price(), Decimal("99")) - first_bid_order = strategy.active_buys[0] - # Expect bid price to be higher that inventory cost price - self.assertGreater(first_bid_order.price, Decimal("101")) - def test_order_book_asset_del(self): strategy = self.one_level_strategy strategy.asset_price_delegate = self.order_book_asset_del From 4c080aa04b7d2061b351618578336ed7ff8c0e8c Mon Sep 17 00:00:00 2001 From: Nullably <37262506+Nullably@users.noreply.github.com> Date: Fri, 1 Jan 2021 13:23:26 +0800 Subject: [PATCH 011/126] (feat) add cancel and tolerance check fn --- .../liquidity_mining/liquidity_mining.py | 39 +++++++++++++++++-- hummingbot/strategy/liquidity_mining/start.py | 2 +- hummingbot/strategy/strategy_py_base.pyx | 4 ++ 3 files changed, 41 insertions(+), 4 deletions(-) diff --git a/hummingbot/strategy/liquidity_mining/liquidity_mining.py b/hummingbot/strategy/liquidity_mining/liquidity_mining.py index 8fa681a4d1..46d87e6a3d 100644 --- a/hummingbot/strategy/liquidity_mining/liquidity_mining.py +++ b/hummingbot/strategy/liquidity_mining/liquidity_mining.py @@ -1,14 +1,15 @@ from decimal import Decimal import logging import asyncio -from typing import Dict +from typing import Dict, List from hummingbot.core.clock import Clock from hummingbot.logger import HummingbotLogger from hummingbot.strategy.strategy_py_base import StrategyPyBase from hummingbot.connector.exchange_base import ExchangeBase from hummingbot.strategy.market_trading_pair_tuple import MarketTradingPairTuple from .data_types import Proposal, PriceSize -from ...core.event.events import OrderType +from hummingbot.core.event.events import OrderType +from hummingbot.core.data_type.limit_order import LimitOrder NaN = float("nan") s_decimal_zero = Decimal(0) @@ -46,8 +47,14 @@ def __init__(self, self._last_timestamp = 0 self._status_report_interval = status_report_interval self._ready_to_trade = False + self._refresh_times = {market: 0 for market in market_infos} self.add_markets([exchange]) + @property + def active_orders(self): + limit_orders = self.order_tracker.active_limit_orders + return [o[1] for o in limit_orders] + def tick(self, timestamp: float): """ Clock tick entry point, is run every second (on normal tick setting). @@ -64,7 +71,7 @@ def tick(self, timestamp: float): proposals = self.create_base_proposals() self.apply_volatility_adjustment(proposals) self.execute_orders_proposal(proposals) - # self.cancel_active_orders(proposal) + self.cancel_active_orders(proposals) # if self.c_to_create_orders(proposal): # self.c_execute_orders_proposal(proposal) @@ -98,8 +105,32 @@ def create_base_proposals(self): def apply_volatility_adjustment(self, proposals): return + def is_within_tolerance(self, cur_orders: List[LimitOrder], proposal: Proposal): + cur_buy = [o for o in cur_orders if o.is_buy] + cur_sell = [o for o in cur_orders if not o.is_buy] + if (cur_buy and proposal.buy.size == 0) or (cur_sell and proposal.sell.size == 0): + return False + if abs(proposal.buy.price - cur_buy[0].price) / cur_buy[0].price > self._order_refresh_tolerance_pct: + return False + if abs(proposal.sell.price - cur_sell[0].price) / cur_sell[0].price > self._order_refresh_tolerance_pct: + return False + return True + + def cancel_active_orders(self, proposals): + for proposal in proposals: + if self._refresh_times[proposal.market] > self.current_timestamp: + continue + cur_orders = [o for o in self.active_orders if o.trading_pair == proposal.market] + if not cur_orders or self.is_within_tolerance(cur_orders, proposal): + continue + for order in cur_orders: + self.cancel_order(self._market_infos[proposal.market], order.client_order_id) + def execute_orders_proposal(self, proposals): for proposal in proposals: + cur_orders = [o for o in self.active_orders if o.trading_pair == proposal.market] + if cur_orders: + continue if proposal.buy.size > 0: self.logger().info(f"({proposal.market}) Creating a bid order {proposal.buy}") self.buy_with_specific_market( @@ -116,3 +147,5 @@ def execute_orders_proposal(self, proposals): order_type=OrderType.LIMIT_MAKER, price=proposal.sell.price ) + if proposal.buy.size > 0 or proposal.sell.size > 0: + self._refresh_times[proposal.market] = self.current_timestamp + self._order_refresh_time diff --git a/hummingbot/strategy/liquidity_mining/start.py b/hummingbot/strategy/liquidity_mining/start.py index f0728e3372..f990ced8f7 100644 --- a/hummingbot/strategy/liquidity_mining/start.py +++ b/hummingbot/strategy/liquidity_mining/start.py @@ -9,7 +9,7 @@ def start(self): markets_text = c_map.get("markets").value initial_spread = c_map.get("initial_spread").value / Decimal("100") order_refresh_time = c_map.get("order_refresh_time").value - order_refresh_tolerance_pct = c_map.get("order_refresh_tolerance_pct").value + order_refresh_tolerance_pct = c_map.get("order_refresh_tolerance_pct").value / Decimal("100") markets = list(markets_text.split(",")) self._initialize_markets([(exchange, markets)]) diff --git a/hummingbot/strategy/strategy_py_base.pyx b/hummingbot/strategy/strategy_py_base.pyx index e206fde11b..6d846e5eab 100644 --- a/hummingbot/strategy/strategy_py_base.pyx +++ b/hummingbot/strategy/strategy_py_base.pyx @@ -3,6 +3,7 @@ from hummingbot.strategy.strategy_base cimport StrategyBase from hummingbot.connector.connector_base import ConnectorBase from hummingbot.core.clock import Clock from hummingbot.core.clock cimport Clock +from hummingbot.strategy.market_trading_pair_tuple import MarketTradingPairTuple from .order_tracker import OrderTracker @@ -38,6 +39,9 @@ cdef class StrategyPyBase(StrategyBase): def tick(self, timestamp: float): raise NotImplementedError + def cancel_order(self, market_trading_pair_tuple: MarketTradingPairTuple, order_id: str): + self.c_cancel_order(market_trading_pair_tuple, order_id) + cdef c_did_create_buy_order(self, object order_created_event): self.did_create_buy_order(order_created_event) From b4b4a0c57028bb5783092e02715208e7baff44ec Mon Sep 17 00:00:00 2001 From: Nullably <37262506+Nullably@users.noreply.github.com> Date: Mon, 4 Jan 2021 11:33:04 +0800 Subject: [PATCH 012/126] (feat) add reserved balances config --- .../strategy/liquidity_mining/data_types.py | 6 ++ .../liquidity_mining/liquidity_mining.py | 69 ++++++++++++++++++- .../liquidity_mining_config_map.py | 6 ++ hummingbot/strategy/liquidity_mining/start.py | 15 ++-- ...onf_liquidity_mining_strategy_TEMPLATE.yml | 2 + 5 files changed, 90 insertions(+), 8 deletions(-) diff --git a/hummingbot/strategy/liquidity_mining/data_types.py b/hummingbot/strategy/liquidity_mining/data_types.py index 10f2ef19ca..a974cdfa01 100644 --- a/hummingbot/strategy/liquidity_mining/data_types.py +++ b/hummingbot/strategy/liquidity_mining/data_types.py @@ -19,3 +19,9 @@ def __init__(self, market: str, buy: PriceSize, sell: PriceSize): def __repr__(self): return f"{self.market} buy: {self.buy} sell: {self.sell}" + + def base(self): + return self.market.split("-")[0] + + def quote(self): + return self.market.split("-")[1] diff --git a/hummingbot/strategy/liquidity_mining/liquidity_mining.py b/hummingbot/strategy/liquidity_mining/liquidity_mining.py index 46d87e6a3d..48b2f99b62 100644 --- a/hummingbot/strategy/liquidity_mining/liquidity_mining.py +++ b/hummingbot/strategy/liquidity_mining/liquidity_mining.py @@ -1,7 +1,7 @@ from decimal import Decimal import logging import asyncio -from typing import Dict, List +from typing import Dict, List, Set from hummingbot.core.clock import Clock from hummingbot.logger import HummingbotLogger from hummingbot.strategy.strategy_py_base import StrategyPyBase @@ -10,6 +10,7 @@ from .data_types import Proposal, PriceSize from hummingbot.core.event.events import OrderType from hummingbot.core.data_type.limit_order import LimitOrder +from hummingbot.core.utils.estimate_fee import estimate_fee NaN = float("nan") s_decimal_zero = Decimal(0) @@ -36,6 +37,7 @@ def __init__(self, initial_spread: Decimal, order_refresh_time: float, order_refresh_tolerance_pct: Decimal, + reserved_balances: Dict[str, Decimal], status_report_interval: float = 900): super().__init__() self._exchange = exchange @@ -43,11 +45,13 @@ def __init__(self, self._initial_spread = initial_spread self._order_refresh_time = order_refresh_time self._order_refresh_tolerance_pct = order_refresh_tolerance_pct + self._reserved_balances = reserved_balances self._ev_loop = asyncio.get_event_loop() self._last_timestamp = 0 self._status_report_interval = status_report_interval self._ready_to_trade = False self._refresh_times = {market: 0 for market in market_infos} + self._token_balances = {} self.add_markets([exchange]) @property @@ -69,9 +73,12 @@ def tick(self, timestamp: float): self.logger().info(f"{self._exchange.name} is ready. Trading started.") proposals = self.create_base_proposals() + self._token_balances = self.adjusted_available_balances() self.apply_volatility_adjustment(proposals) - self.execute_orders_proposal(proposals) + self.allocate_capital(proposals) self.cancel_active_orders(proposals) + self.execute_orders_proposal(proposals) + # if self.c_to_create_orders(proposal): # self.c_execute_orders_proposal(proposal) @@ -105,10 +112,29 @@ def create_base_proposals(self): def apply_volatility_adjustment(self, proposals): return + def allocate_capital(self, proposals: List[Proposal]): + # First let's assign all the sell order size based on the base token balance available + base_tokens = self.all_base_tokens() + for base in base_tokens: + base_proposals = [p for p in proposals if p.base() == base] + sell_size = self._token_balances[base] / len(base_proposals) + for proposal in base_proposals: + proposal.sell.size = self._exchange.quantize_order_amount(proposal.market, sell_size) + + # Then assign all the buy order size based on the quote token balance available + quote_tokens = self.all_quote_tokens() + for quote in quote_tokens: + quote_proposals = [p for p in proposals if p.quote() == quote] + quote_size = self._token_balances[quote] / len(quote_proposals) + for proposal in quote_proposals: + buy_fee = estimate_fee(self._exchange.name, True) + buy_amount = quote_size / (proposal.buy.price * (Decimal("1") + buy_fee.percent)) + proposal.buy.size = self._exchange.quantize_order_amount(proposal.market, buy_amount) + def is_within_tolerance(self, cur_orders: List[LimitOrder], proposal: Proposal): cur_buy = [o for o in cur_orders if o.is_buy] cur_sell = [o for o in cur_orders if not o.is_buy] - if (cur_buy and proposal.buy.size == 0) or (cur_sell and proposal.sell.size == 0): + if (cur_buy and proposal.buy.size <= 0) or (cur_sell and proposal.sell.size <= 0): return False if abs(proposal.buy.price - cur_buy[0].price) / cur_buy[0].price > self._order_refresh_tolerance_pct: return False @@ -149,3 +175,40 @@ def execute_orders_proposal(self, proposals): ) if proposal.buy.size > 0 or proposal.sell.size > 0: self._refresh_times[proposal.market] = self.current_timestamp + self._order_refresh_time + + def all_base_tokens(self) -> Set[str]: + tokens = set() + for market in self._market_infos: + tokens.add(market.split("-")[0]) + return tokens + + def all_quote_tokens(self) -> Set[str]: + tokens = set() + for market in self._market_infos: + tokens.add(market.split("-")[1]) + return tokens + + def all_tokens(self) -> Set[str]: + tokens = set() + for market in self._market_infos: + tokens.update(market.split("-")) + return tokens + + def adjusted_available_balances(self) -> Dict[str, Decimal]: + """ + Calculates all available balances, account for amount attributed to orders and reserved balance. + :return: a dictionary of token and its available balance + """ + tokens = self.all_tokens() + token_bals = {t: s_decimal_zero for t in tokens} + for token in tokens: + bal = self._exchange.get_available_balance(token) + reserved = self._reserved_balances.get(token, s_decimal_zero) + token_bals[token] = bal - reserved + for order in self.active_orders: + base, quote = order.trading_pair.split("-") + if order.is_buy: + token_bals[quote] += order.quantity * order.price + else: + token_bals[base] += order.quantity + return token_bals diff --git a/hummingbot/strategy/liquidity_mining/liquidity_mining_config_map.py b/hummingbot/strategy/liquidity_mining/liquidity_mining_config_map.py index 3a698293d2..3617e19244 100644 --- a/hummingbot/strategy/liquidity_mining/liquidity_mining_config_map.py +++ b/hummingbot/strategy/liquidity_mining/liquidity_mining_config_map.py @@ -51,4 +51,10 @@ def exchange_on_validated(value: str) -> None: type_str="decimal", default=Decimal("0.2"), validator=lambda v: validate_decimal(v, -10, 10, inclusive=True)), + "reserved_balances": + ConfigVar(key="reserved_balances", + prompt="Enter a list of token and its reserved balance (to not be used) e.g. BTC-0.1, BNB-1 >>> ", + required_if=lambda: False, + default={"BNB": 1}, + type_str="json"), } diff --git a/hummingbot/strategy/liquidity_mining/start.py b/hummingbot/strategy/liquidity_mining/start.py index f990ced8f7..dcf331419e 100644 --- a/hummingbot/strategy/liquidity_mining/start.py +++ b/hummingbot/strategy/liquidity_mining/start.py @@ -10,6 +10,8 @@ def start(self): initial_spread = c_map.get("initial_spread").value / Decimal("100") order_refresh_time = c_map.get("order_refresh_time").value order_refresh_tolerance_pct = c_map.get("order_refresh_tolerance_pct").value / Decimal("100") + reserved_balances = c_map.get("reserved_balances").value or {} + reserved_balances = {k: Decimal(str(v)) for k, v in reserved_balances.items()} markets = list(markets_text.split(",")) self._initialize_markets([(exchange, markets)]) @@ -18,8 +20,11 @@ def start(self): for market in markets: base, quote = market.split("-") market_infos[market] = MarketTradingPairTuple(exchange, market, base, quote) - self.strategy = LiquidityMiningStrategy(exchange=exchange, - market_infos=market_infos, - initial_spread=initial_spread, - order_refresh_time=order_refresh_time, - order_refresh_tolerance_pct=order_refresh_tolerance_pct) + self.strategy = LiquidityMiningStrategy( + exchange=exchange, + market_infos=market_infos, + initial_spread=initial_spread, + order_refresh_time=order_refresh_time, + reserved_balances=reserved_balances, + order_refresh_tolerance_pct=order_refresh_tolerance_pct + ) diff --git a/hummingbot/templates/conf_liquidity_mining_strategy_TEMPLATE.yml b/hummingbot/templates/conf_liquidity_mining_strategy_TEMPLATE.yml index 3c8109f3ed..10b4fc5448 100644 --- a/hummingbot/templates/conf_liquidity_mining_strategy_TEMPLATE.yml +++ b/hummingbot/templates/conf_liquidity_mining_strategy_TEMPLATE.yml @@ -25,5 +25,7 @@ order_refresh_time: null # (Enter 1 to indicate 1%), value below 0, e.g. -1, is to disable this feature - not recommended. order_refresh_tolerance_pct: null +reserved_balances: + # For more detailed information, see: # https://docs.hummingbot.io/strategies/pure-market-making/#configuration-parameters From bdfec21d827fc09ad9027c2c374b1bca52e8de4a Mon Sep 17 00:00:00 2001 From: Nicolas Baum Date: Mon, 4 Jan 2021 06:35:13 -0300 Subject: [PATCH 013/126] (feat) Added local history reconciliation with exchange history for Binance --- hummingbot/connector/connector_base.pxd | 1 + hummingbot/connector/connector_base.pyx | 17 ++ .../exchange/binance/binance_exchange.pyx | 222 +++++++++++------- hummingbot/connector/markets_recorder.py | 3 + 4 files changed, 154 insertions(+), 89 deletions(-) diff --git a/hummingbot/connector/connector_base.pxd b/hummingbot/connector/connector_base.pxd index 3f1e1c4874..cddf9ccb30 100644 --- a/hummingbot/connector/connector_base.pxd +++ b/hummingbot/connector/connector_base.pxd @@ -12,6 +12,7 @@ cdef class ConnectorBase(NetworkIterator): public bint _real_time_balance_update public dict _in_flight_orders_snapshot public double _in_flight_orders_snapshot_timestamp + public list _current_trade_fills cdef str c_buy(self, str trading_pair, object amount, object order_type=*, object price=*, dict kwargs=*) cdef str c_sell(self, str trading_pair, object amount, object order_type=*, object price=*, dict kwargs=*) diff --git a/hummingbot/connector/connector_base.pyx b/hummingbot/connector/connector_base.pyx index 04edb16994..319269dc4d 100644 --- a/hummingbot/connector/connector_base.pyx +++ b/hummingbot/connector/connector_base.pyx @@ -16,6 +16,7 @@ from hummingbot.connector.in_flight_order_base import InFlightOrderBase from hummingbot.core.event.events import OrderFilledEvent from hummingbot.client.config.global_config_map import global_config_map from hummingbot.core.utils.estimate_fee import estimate_fee +from hummingbot.model.trade_fill import TradeFill NaN = float("nan") s_decimal_NaN = Decimal("nan") @@ -54,6 +55,7 @@ cdef class ConnectorBase(NetworkIterator): # for _in_flight_orders_snapshot and _in_flight_orders_snapshot_timestamp when the update user balances. self._in_flight_orders_snapshot = {} # Dict[order_id:str, InFlightOrderBase] self._in_flight_orders_snapshot_timestamp = 0.0 + self._current_trade_fills = [] @property def real_time_balance_update(self) -> bool: @@ -412,3 +414,18 @@ cdef class ConnectorBase(NetworkIterator): @property def available_balances(self) -> Dict[str, Decimal]: return self._account_available_balances + + def add_trade_fills_from_market_recorder(self, current_trade_fills: List[TradeFill]): + """ + Gets updates from new records in TradeFill table. This is used in method is_confirmed_new_order_filled_event + """ + self._current_trade_fills.extend(current_trade_fills) + + def is_confirmed_new_order_filled_event(self, exchange_trade_id: int, trading_pair: str): + """ + Returns True if order to be filled is not already present in TradeFill entries. + This is intended to avoid duplicated order fills in local DB. + """ + return not any(tf for tf in self._current_trade_fills if (tf.market == self.display_name) and + (int(tf.exchange_trade_id) == exchange_trade_id) and + (tf.symbol == trading_pair)) diff --git a/hummingbot/connector/exchange/binance/binance_exchange.pyx b/hummingbot/connector/exchange/binance/binance_exchange.pyx index 15761b554f..5f3b3ed2d4 100755 --- a/hummingbot/connector/exchange/binance/binance_exchange.pyx +++ b/hummingbot/connector/exchange/binance/binance_exchange.pyx @@ -1,3 +1,4 @@ +from traceback import format_exc from collections import defaultdict from libc.stdint cimport int64_t import aiohttp @@ -392,18 +393,55 @@ cdef class BinanceExchange(ExchangeBase): int64_t last_tick = (self._last_poll_timestamp / self.UPDATE_ORDER_STATUS_MIN_INTERVAL) int64_t current_tick = (self._current_timestamp / self.UPDATE_ORDER_STATUS_MIN_INTERVAL) - if current_tick > last_tick and len(self._in_flight_orders) > 0: - trading_pairs_to_order_map = defaultdict(lambda: {}) - for o in self._in_flight_orders.values(): - trading_pairs_to_order_map[o.trading_pair][o.exchange_order_id] = o - - 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)) - 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] + if current_tick > last_tick: + results = None + if len(self._in_flight_orders) > 0: + trading_pairs_to_order_map = defaultdict(lambda: {}) + for o in self._in_flight_orders.values(): + trading_pairs_to_order_map[o.trading_pair][o.exchange_order_id] = o + + 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)) + 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] + if isinstance(trades, Exception): + self.logger().network( + f"Error fetching trades update for the order {trading_pair}: {trades}.", + app_warning_msg=f"Failed to fetch trade update for {trading_pair}." + ) + continue + for trade in trades: + order_id = str(trade["orderId"]) + if order_id in order_map: + tracked_order = order_map[order_id] + order_type = tracked_order.order_type + applied_trade = order_map[order_id].update_with_trade_update(trade) + if applied_trade: + self.c_trigger_event(self.MARKET_ORDER_FILLED_EVENT_TAG, + OrderFilledEvent( + self._current_timestamp, + tracked_order.client_order_id, + tracked_order.trading_pair, + tracked_order.trade_type, + order_type, + Decimal(trade["price"]), + Decimal(trade["qty"]), + TradeFee( + percent=Decimal(0.0), + flat_fees=[(trade["commissionAsset"], + Decimal(trade["commission"]))] + ), + exchange_trade_id=trade["id"] + )) + if not results: + tasks=[self.query_api(self._binance_client.get_my_trades, symbol=convert_to_exchange_trading_pair(trading_pair)) + for trading_pair in self._order_book_tracker._trading_pairs] + self.logger().debug("Polling for order fills of %d trading pairs.", len(tasks)) + results = await safe_gather(*tasks, return_exceptions=True) + for trades, trading_pair in zip(results, self._order_book_tracker._trading_pairs): if isinstance(trades, Exception): self.logger().network( f"Error fetching trades update for the order {trading_pair}: {trades}.", @@ -411,28 +449,27 @@ cdef class BinanceExchange(ExchangeBase): ) continue for trade in trades: - order_id = str(trade["orderId"]) - if order_id in order_map: - tracked_order = order_map[order_id] - order_type = tracked_order.order_type - applied_trade = order_map[order_id].update_with_trade_update(trade) - if applied_trade: - self.c_trigger_event(self.MARKET_ORDER_FILLED_EVENT_TAG, - OrderFilledEvent( - self._current_timestamp, - tracked_order.client_order_id, - tracked_order.trading_pair, - tracked_order.trade_type, - order_type, - Decimal(trade["price"]), - Decimal(trade["qty"]), - TradeFee( - percent=Decimal(0.0), - flat_fees=[(trade["commissionAsset"], - Decimal(trade["commission"]))] - ), - exchange_trade_id=trade["id"] - )) + if self.is_confirmed_new_order_filled_event(trade["id"], trading_pair): + event_tag = self.MARKET_BUY_ORDER_CREATED_EVENT_TAG if trade["isBuyer"] \ + else self.MARKET_SELL_ORDER_CREATED_EVENT_TAG + client_order_id=get_client_order_id("buy" if trade["isBuyer"] else "sell", trading_pair) + self.c_trigger_event(self.MARKET_ORDER_FILLED_EVENT_TAG, + OrderFilledEvent( + trade["time"], + client_order_id, + trading_pair, + TradeType.BUY if trade["isBuyer"] else TradeType.SELL, + OrderType.LIMIT_MAKER, # defaulting to this value since trade info lacks field + Decimal(trade["price"]), + Decimal(trade["qty"]), + TradeFee( + percent=Decimal(0.0), + flat_fees=[(trade["commissionAsset"], + Decimal(trade["commission"]))] + ), + exchange_trade_id=trade["id"] + )) + self.logger().info(f"Recreating missing trade in TradeFill: {trade}") async def _update_order_status(self): cdef: @@ -484,34 +521,36 @@ cdef class BinanceExchange(ExchangeBase): if tracked_order.is_done: if not tracked_order.is_failure: - if tracked_order.trade_type is TradeType.BUY: - self.logger().info(f"The market buy order {tracked_order.client_order_id} has completed " - f"according to order status API.") - self.c_trigger_event(self.MARKET_BUY_ORDER_COMPLETED_EVENT_TAG, - BuyOrderCompletedEvent(self._current_timestamp, - client_order_id, - tracked_order.base_asset, - tracked_order.quote_asset, - (tracked_order.fee_asset - or tracked_order.base_asset), - executed_amount_base, - executed_amount_quote, - tracked_order.fee_paid, - order_type)) - else: - self.logger().info(f"The market sell order {client_order_id} has completed " - f"according to order status API.") - self.c_trigger_event(self.MARKET_SELL_ORDER_COMPLETED_EVENT_TAG, - SellOrderCompletedEvent(self._current_timestamp, - client_order_id, - tracked_order.base_asset, - tracked_order.quote_asset, - (tracked_order.fee_asset - or tracked_order.quote_asset), - executed_amount_base, - executed_amount_quote, - tracked_order.fee_paid, - order_type)) + if self.is_confirmed_new_order_filled_event(tracked_order.exchange_order_id, + tracked_order.trading_pair): + if tracked_order.trade_type is TradeType.BUY: + self.logger().info(f"The market buy order {tracked_order.client_order_id} has completed " + f"according to order status API.") + self.c_trigger_event(self.MARKET_BUY_ORDER_COMPLETED_EVENT_TAG, + BuyOrderCompletedEvent(self._current_timestamp, + client_order_id, + tracked_order.base_asset, + tracked_order.quote_asset, + (tracked_order.fee_asset + or tracked_order.base_asset), + executed_amount_base, + executed_amount_quote, + tracked_order.fee_paid, + order_type)) + else: + self.logger().info(f"The market sell order {client_order_id} has completed " + f"according to order status API.") + self.c_trigger_event(self.MARKET_SELL_ORDER_COMPLETED_EVENT_TAG, + SellOrderCompletedEvent(self._current_timestamp, + client_order_id, + tracked_order.base_asset, + tracked_order.quote_asset, + (tracked_order.fee_asset + or tracked_order.quote_asset), + executed_amount_base, + executed_amount_quote, + tracked_order.fee_paid, + order_type)) else: # check if its a cancelled order # if its a cancelled order, issue cancel and stop tracking order @@ -599,34 +638,39 @@ cdef class BinanceExchange(ExchangeBase): if tracked_order.is_done: if not tracked_order.is_failure: - if tracked_order.trade_type is TradeType.BUY: - self.logger().info(f"The market buy order {tracked_order.client_order_id} has completed " - f"according to user stream.") - self.c_trigger_event(self.MARKET_BUY_ORDER_COMPLETED_EVENT_TAG, - BuyOrderCompletedEvent(self._current_timestamp, - tracked_order.client_order_id, - tracked_order.base_asset, - tracked_order.quote_asset, - (tracked_order.fee_asset - or tracked_order.base_asset), - tracked_order.executed_amount_base, - tracked_order.executed_amount_quote, - tracked_order.fee_paid, - tracked_order.order_type)) + if self.is_confirmed_new_order_filled_event(tracked_order.exchange_order_id, tracked_order.trading_pair): + if tracked_order.trade_type is TradeType.BUY: + self.logger().info(f"The market buy order {tracked_order.client_order_id} has completed " + f"according to user stream.") + self.c_trigger_event(self.MARKET_BUY_ORDER_COMPLETED_EVENT_TAG, + BuyOrderCompletedEvent(self._current_timestamp, + tracked_order.client_order_id, + tracked_order.base_asset, + tracked_order.quote_asset, + (tracked_order.fee_asset + or tracked_order.base_asset), + tracked_order.executed_amount_base, + tracked_order.executed_amount_quote, + tracked_order.fee_paid, + tracked_order.order_type)) + else: + self.logger().info(f"The market sell order {tracked_order.client_order_id} has completed " + f"according to user stream.") + self.c_trigger_event(self.MARKET_SELL_ORDER_COMPLETED_EVENT_TAG, + SellOrderCompletedEvent(self._current_timestamp, + tracked_order.client_order_id, + tracked_order.base_asset, + tracked_order.quote_asset, + (tracked_order.fee_asset + or tracked_order.quote_asset), + tracked_order.executed_amount_base, + tracked_order.executed_amount_quote, + tracked_order.fee_paid, + tracked_order.order_type)) else: - self.logger().info(f"The market sell order {tracked_order.client_order_id} has completed " - f"according to user stream.") - self.c_trigger_event(self.MARKET_SELL_ORDER_COMPLETED_EVENT_TAG, - SellOrderCompletedEvent(self._current_timestamp, - tracked_order.client_order_id, - tracked_order.base_asset, - tracked_order.quote_asset, - (tracked_order.fee_asset - or tracked_order.quote_asset), - tracked_order.executed_amount_base, - tracked_order.executed_amount_quote, - tracked_order.fee_paid, - tracked_order.order_type)) + self.logger().info( + f"The market order {tracked_order.client_order_id} was already filled. " + f"Ignoring trade filled event.") else: # check if its a cancelled order # if its a cancelled order, check in flight orders diff --git a/hummingbot/connector/markets_recorder.py b/hummingbot/connector/markets_recorder.py index 76a59cbcea..9b5964375d 100644 --- a/hummingbot/connector/markets_recorder.py +++ b/hummingbot/connector/markets_recorder.py @@ -58,6 +58,8 @@ def __init__(self, self._markets: List[ConnectorBase] = markets self._config_file_path: str = config_file_path self._strategy_name: str = strategy_name + for market in self._markets: + market.add_trade_fills_from_market_recorder(self.get_trades_for_config(self._config_file_path, 2000)) self._create_order_forwarder: SourceInfoEventForwarder = SourceInfoEventForwarder(self._did_create_order) self._fill_order_forwarder: SourceInfoEventForwarder = SourceInfoEventForwarder(self._did_fill_order) @@ -237,6 +239,7 @@ def _did_fill_order(self, self.save_market_states(self._config_file_path, market, no_commit=True) session.commit() self.append_to_csv(trade_fill_record) + market.add_trade_fills_from_market_recorder([trade_fill_record]) def append_to_csv(self, trade: TradeFill): csv_file = "trades_" + trade.config_file_path[:-4] + ".csv" From 3dfaa5fec8afaed6b69167535981a32bd433345a Mon Sep 17 00:00:00 2001 From: Nullably <37262506+Nullably@users.noreply.github.com> Date: Mon, 4 Jan 2021 21:51:58 +0800 Subject: [PATCH 014/126] (feat) add a gap after cancelling and placing new order --- .../liquidity_mining/liquidity_mining.py | 31 ++++++++++++------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/hummingbot/strategy/liquidity_mining/liquidity_mining.py b/hummingbot/strategy/liquidity_mining/liquidity_mining.py index 48b2f99b62..7dc5a59d5d 100644 --- a/hummingbot/strategy/liquidity_mining/liquidity_mining.py +++ b/hummingbot/strategy/liquidity_mining/liquidity_mining.py @@ -136,9 +136,11 @@ def is_within_tolerance(self, cur_orders: List[LimitOrder], proposal: Proposal): cur_sell = [o for o in cur_orders if not o.is_buy] if (cur_buy and proposal.buy.size <= 0) or (cur_sell and proposal.sell.size <= 0): return False - if abs(proposal.buy.price - cur_buy[0].price) / cur_buy[0].price > self._order_refresh_tolerance_pct: + if cur_buy and \ + abs(proposal.buy.price - cur_buy[0].price) / cur_buy[0].price > self._order_refresh_tolerance_pct: return False - if abs(proposal.sell.price - cur_sell[0].price) / cur_sell[0].price > self._order_refresh_tolerance_pct: + if cur_sell and \ + abs(proposal.sell.price - cur_sell[0].price) / cur_sell[0].price > self._order_refresh_tolerance_pct: return False return True @@ -151,14 +153,17 @@ def cancel_active_orders(self, proposals): continue for order in cur_orders: self.cancel_order(self._market_infos[proposal.market], order.client_order_id) + # To place new order on the next tick + self._refresh_times[order.trading_pair] = self.current_timestamp + 0.1 def execute_orders_proposal(self, proposals): for proposal in proposals: cur_orders = [o for o in self.active_orders if o.trading_pair == proposal.market] - if cur_orders: + if cur_orders or self._refresh_times[proposal.market] > self.current_timestamp: continue if proposal.buy.size > 0: - self.logger().info(f"({proposal.market}) Creating a bid order {proposal.buy}") + self.logger().info(f"({proposal.market}) Creating a bid order {proposal.buy} value: " + f"{proposal.buy.size * proposal.buy.price:.2f} {proposal.quote()}") self.buy_with_specific_market( self._market_infos[proposal.market], proposal.buy.size, @@ -200,15 +205,19 @@ def adjusted_available_balances(self) -> Dict[str, Decimal]: :return: a dictionary of token and its available balance """ tokens = self.all_tokens() - token_bals = {t: s_decimal_zero for t in tokens} + adjusted_bals = {t: s_decimal_zero for t in tokens} + total_bals = self._exchange.get_all_balances() for token in tokens: - bal = self._exchange.get_available_balance(token) - reserved = self._reserved_balances.get(token, s_decimal_zero) - token_bals[token] = bal - reserved + adjusted_bals[token] = self._exchange.get_available_balance(token) for order in self.active_orders: base, quote = order.trading_pair.split("-") if order.is_buy: - token_bals[quote] += order.quantity * order.price + adjusted_bals[quote] += order.quantity * order.price else: - token_bals[base] += order.quantity - return token_bals + adjusted_bals[base] += order.quantity + for token in tokens: + adjusted_bals[token] = min(adjusted_bals[token], total_bals[token]) + reserved = self._reserved_balances.get(token, s_decimal_zero) + adjusted_bals[token] -= reserved + # self.logger().info(f"token balances: {adjusted_bals}") + return adjusted_bals From 77afdb4fa69e8f540e33a90b3d21a17fe22f9ba0 Mon Sep 17 00:00:00 2001 From: Nicolas Baum Date: Tue, 5 Jan 2021 17:06:57 -0300 Subject: [PATCH 015/126] (feat) Moved history reconciliation code to a separate function. Added comments --- hummingbot/connector/connector_base.pyx | 1 + .../exchange/binance/binance_exchange.pyx | 76 ++++++++++--------- hummingbot/connector/markets_recorder.py | 1 + 3 files changed, 44 insertions(+), 34 deletions(-) diff --git a/hummingbot/connector/connector_base.pyx b/hummingbot/connector/connector_base.pyx index 319269dc4d..f38584163f 100644 --- a/hummingbot/connector/connector_base.pyx +++ b/hummingbot/connector/connector_base.pyx @@ -426,6 +426,7 @@ cdef class ConnectorBase(NetworkIterator): Returns True if order to be filled is not already present in TradeFill entries. This is intended to avoid duplicated order fills in local DB. """ + # Assume (exchange_trade_id, trading_pair) are unique return not any(tf for tf in self._current_trade_fills if (tf.market == self.display_name) and (int(tf.exchange_trade_id) == exchange_trade_id) and (tf.symbol == trading_pair)) diff --git a/hummingbot/connector/exchange/binance/binance_exchange.pyx b/hummingbot/connector/exchange/binance/binance_exchange.pyx index 5f3b3ed2d4..20cacb1331 100755 --- a/hummingbot/connector/exchange/binance/binance_exchange.pyx +++ b/hummingbot/connector/exchange/binance/binance_exchange.pyx @@ -436,40 +436,48 @@ cdef class BinanceExchange(ExchangeBase): ), exchange_trade_id=trade["id"] )) - if not results: - tasks=[self.query_api(self._binance_client.get_my_trades, symbol=convert_to_exchange_trading_pair(trading_pair)) - for trading_pair in self._order_book_tracker._trading_pairs] - self.logger().debug("Polling for order fills of %d trading pairs.", len(tasks)) - results = await safe_gather(*tasks, return_exceptions=True) - for trades, trading_pair in zip(results, self._order_book_tracker._trading_pairs): - if isinstance(trades, Exception): - self.logger().network( - f"Error fetching trades update for the order {trading_pair}: {trades}.", - app_warning_msg=f"Failed to fetch trade update for {trading_pair}." - ) - continue - for trade in trades: - if self.is_confirmed_new_order_filled_event(trade["id"], trading_pair): - event_tag = self.MARKET_BUY_ORDER_CREATED_EVENT_TAG if trade["isBuyer"] \ - else self.MARKET_SELL_ORDER_CREATED_EVENT_TAG - client_order_id=get_client_order_id("buy" if trade["isBuyer"] else "sell", trading_pair) - self.c_trigger_event(self.MARKET_ORDER_FILLED_EVENT_TAG, - OrderFilledEvent( - trade["time"], - client_order_id, - trading_pair, - TradeType.BUY if trade["isBuyer"] else TradeType.SELL, - OrderType.LIMIT_MAKER, # defaulting to this value since trade info lacks field - Decimal(trade["price"]), - Decimal(trade["qty"]), - TradeFee( - percent=Decimal(0.0), - flat_fees=[(trade["commissionAsset"], - Decimal(trade["commission"]))] - ), - exchange_trade_id=trade["id"] - )) - self.logger().info(f"Recreating missing trade in TradeFill: {trade}") + await self._history_reconciliation(results) + + async def _history_reconciliation(self, exchange_history): + """ + Method looks in the exchange history to check for any missing trade in local history. + If found, it will trigger an order_filled event to record it in local DB. + """ + if not exchange_history: + tasks = [self.query_api(self._binance_client.get_my_trades, + symbol=convert_to_exchange_trading_pair(trading_pair)) + for trading_pair in self._order_book_tracker._trading_pairs] + self.logger().debug("Polling for order fills of %d trading pairs.", len(tasks)) + exchange_history = await safe_gather(*tasks, return_exceptions=True) + for trades, trading_pair in zip(exchange_history, self._order_book_tracker._trading_pairs): + if isinstance(trades, Exception): + self.logger().network( + f"Error fetching trades update for the order {trading_pair}: {trades}.", + app_warning_msg=f"Failed to fetch trade update for {trading_pair}." + ) + continue + for trade in trades: + if self.is_confirmed_new_order_filled_event(trade["id"], trading_pair): + event_tag = self.MARKET_BUY_ORDER_CREATED_EVENT_TAG if trade["isBuyer"] \ + else self.MARKET_SELL_ORDER_CREATED_EVENT_TAG + client_order_id = get_client_order_id("buy" if trade["isBuyer"] else "sell", trading_pair) + self.c_trigger_event(self.MARKET_ORDER_FILLED_EVENT_TAG, + OrderFilledEvent( + trade["time"], + client_order_id, + trading_pair, + TradeType.BUY if trade["isBuyer"] else TradeType.SELL, + OrderType.LIMIT_MAKER, # defaulting to this value since trade info lacks field + Decimal(trade["price"]), + Decimal(trade["qty"]), + TradeFee( + percent=Decimal(0.0), + flat_fees=[(trade["commissionAsset"], + Decimal(trade["commission"]))] + ), + exchange_trade_id=trade["id"] + )) + self.logger().info(f"Recreating missing trade in TradeFill: {trade}") async def _update_order_status(self): cdef: diff --git a/hummingbot/connector/markets_recorder.py b/hummingbot/connector/markets_recorder.py index 9b5964375d..485af73dfb 100644 --- a/hummingbot/connector/markets_recorder.py +++ b/hummingbot/connector/markets_recorder.py @@ -58,6 +58,7 @@ def __init__(self, self._markets: List[ConnectorBase] = markets self._config_file_path: str = config_file_path self._strategy_name: str = strategy_name + # Internal collection of trade fills in connector will be used for remote/local history reconciliation for market in self._markets: market.add_trade_fills_from_market_recorder(self.get_trades_for_config(self._config_file_path, 2000)) From 48aea1716dc02c010af230f80d50ef273086ba6d Mon Sep 17 00:00:00 2001 From: Nullably <37262506+Nullably@users.noreply.github.com> Date: Wed, 6 Jan 2021 14:01:02 +0800 Subject: [PATCH 016/126] (feat) display open orders on status --- hummingbot/strategy/liquidity_mining/liquidity_mining.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/hummingbot/strategy/liquidity_mining/liquidity_mining.py b/hummingbot/strategy/liquidity_mining/liquidity_mining.py index 7dc5a59d5d..b474cc90e8 100644 --- a/hummingbot/strategy/liquidity_mining/liquidity_mining.py +++ b/hummingbot/strategy/liquidity_mining/liquidity_mining.py @@ -85,7 +85,8 @@ def tick(self, timestamp: float): self._last_timestamp = timestamp async def format_status(self) -> str: - return "Not implemented" + from hummingbot.client.hummingbot_application import HummingbotApplication + HummingbotApplication.main_application().open_orders(full_report=True) def start(self, clock: Clock, timestamp: float): pass From 5fabb8db28f5f75c566c966e1873c3ef95e0aac0 Mon Sep 17 00:00:00 2001 From: Vladimir Kamarzin Date: Wed, 6 Jan 2021 14:35:54 +0500 Subject: [PATCH 017/126] (refactor) Return None when price data not available As requested in code review, return None instead of raising an exception. --- hummingbot/exceptions.py | 4 ---- .../inventory_cost_price_delegate.py | 16 +++++++++++----- .../pure_market_making/pure_market_making.pyx | 13 ++++--------- test/test_inventory_cost_price_delegate.py | 9 ++++----- 4 files changed, 19 insertions(+), 23 deletions(-) diff --git a/hummingbot/exceptions.py b/hummingbot/exceptions.py index 7b15f850a7..84bd25861b 100644 --- a/hummingbot/exceptions.py +++ b/hummingbot/exceptions.py @@ -1,6 +1,2 @@ class HummingbotBaseException(Exception): pass - - -class NoPrice(HummingbotBaseException): - pass diff --git a/hummingbot/strategy/pure_market_making/inventory_cost_price_delegate.py b/hummingbot/strategy/pure_market_making/inventory_cost_price_delegate.py index 3fc4fa99f4..18e771d5ff 100644 --- a/hummingbot/strategy/pure_market_making/inventory_cost_price_delegate.py +++ b/hummingbot/strategy/pure_market_making/inventory_cost_price_delegate.py @@ -1,10 +1,12 @@ from decimal import Decimal, InvalidOperation +from typing import Optional from hummingbot.core.event.events import OrderFilledEvent, TradeType -from hummingbot.exceptions import NoPrice from hummingbot.model.inventory_cost import InventoryCost from hummingbot.model.sql_connection_manager import SQLConnectionManager +s_decimal_0 = Decimal("0") + class InventoryCostPriceDelegate: def __init__(self, sql: SQLConnectionManager, trading_pair: str) -> None: @@ -15,15 +17,19 @@ def __init__(self, sql: SQLConnectionManager, trading_pair: str) -> None: def ready(self) -> bool: return True - def get_price(self) -> Decimal: + def get_price(self) -> Optional[Decimal]: record = InventoryCost.get_record( self._session, self.base_asset, self.quote_asset ) + + if record is None or record.base_volume is None or record.base_volume is None: + return None + try: price = record.quote_volume / record.base_volume - except (ZeroDivisionError, InvalidOperation, AttributeError): - raise NoPrice("Inventory cost delegate does not have price cost data yet") - + except InvalidOperation: + # decimal.InvalidOperation: [] - both volumes are 0 + return None return Decimal(price) def process_order_fill_event(self, fill_event: OrderFilledEvent) -> None: diff --git a/hummingbot/strategy/pure_market_making/pure_market_making.pyx b/hummingbot/strategy/pure_market_making/pure_market_making.pyx index f49a762d93..1696083726 100644 --- a/hummingbot/strategy/pure_market_making/pure_market_making.pyx +++ b/hummingbot/strategy/pure_market_making/pure_market_making.pyx @@ -20,7 +20,6 @@ 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.exceptions import NoPrice from hummingbot.strategy.market_trading_pair_tuple import MarketTradingPairTuple from hummingbot.strategy.strategy_base import StrategyBase @@ -565,9 +564,8 @@ cdef class PureMarketMakingStrategy(StrategyBase): ref_price = float("nan") if market == self._market_info.market and self._inventory_cost_price_delegate is not None: # We're using inventory_cost, show it's price - try: - ref_price = self._inventory_cost_price_delegate.get_price() - except NoPrice: + ref_price = self._inventory_cost_price_delegate.get_price() + if ref_price is None: ref_price = self.get_price() elif market == self._market_info.market and self._asset_price_delegate is None: ref_price = self.get_price() @@ -704,11 +702,8 @@ cdef class PureMarketMakingStrategy(StrategyBase): buy_reference_price = sell_reference_price = self.get_price() if self._inventory_cost_price_delegate is not None: - try: - inventory_cost_price = self._inventory_cost_price_delegate.get_price() - except NoPrice: - pass - else: + inventory_cost_price = self._inventory_cost_price_delegate.get_price() + if inventory_cost_price is not None: buy_reference_price = min(inventory_cost_price, buy_reference_price) sell_reference_price = max(inventory_cost_price, sell_reference_price) diff --git a/test/test_inventory_cost_price_delegate.py b/test/test_inventory_cost_price_delegate.py index f56fb3e814..ff90fc2d29 100644 --- a/test/test_inventory_cost_price_delegate.py +++ b/test/test_inventory_cost_price_delegate.py @@ -3,7 +3,6 @@ from hummingbot.core.event.events import OrderFilledEvent, TradeType from hummingbot.core.event.events import TradeFee, OrderType -from hummingbot.exceptions import NoPrice from hummingbot.model.inventory_cost import InventoryCost from hummingbot.model.sql_connection_manager import ( SQLConnectionManager, @@ -91,7 +90,7 @@ def test_get_price_by_type(self): price = Decimal("9000") # Test when no records - self.assertRaises(NoPrice, self.delegate.get_price) + self.assertIsNone(self.delegate.get_price()) record = InventoryCost( base_asset=self.base_asset, @@ -105,15 +104,15 @@ def test_get_price_by_type(self): self.assertEqual(delegate_price, price) def test_get_price_by_type_zero_division(self): + # Test for situation when position was fully closed with profit amount = Decimal("0") - price = Decimal("9000") record = InventoryCost( base_asset=self.base_asset, quote_asset=self.quote_asset, base_volume=amount, - quote_volume=amount * price, + quote_volume=amount, ) self._session.add(record) self._session.commit() - self.assertRaises(NoPrice, self.delegate.get_price) + self.assertIsNone(self.delegate.get_price()) From fe0b6dd0369a35c1ce13b25867acabee696b23f0 Mon Sep 17 00:00:00 2001 From: michael chuang Date: Thu, 7 Jan 2021 16:40:16 +0800 Subject: [PATCH 018/126] fix / kucoin trade id should use timestamp to align with diff messages --- .../connector/exchange/kucoin/kucoin_order_book.pyx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/hummingbot/connector/exchange/kucoin/kucoin_order_book.pyx b/hummingbot/connector/exchange/kucoin/kucoin_order_book.pyx index d4231637a7..cbecaad119 100644 --- a/hummingbot/connector/exchange/kucoin/kucoin_order_book.pyx +++ b/hummingbot/connector/exchange/kucoin/kucoin_order_book.pyx @@ -60,20 +60,20 @@ cdef class KucoinOrderBook(OrderBook): @classmethod def snapshot_message_from_db(cls, record: RowProxy, metadata: Optional[Dict] = None) -> OrderBookMessage: - ts = record["timestamp"] + ts = int(record["timestamp"]) msg = record["json"] if type(record["json"]) == dict else ujson.loads(record["json"]) if metadata: msg.update(metadata) return OrderBookMessage(OrderBookMessageType.SNAPSHOT, { "trading_pair": msg["s"], - "update_id": int(ts), + "update_id": ts, "bids": msg["bids"], "asks": msg["asks"] }, timestamp=record["timestamp"] * 1e-3) @classmethod def diff_message_from_db(cls, record: RowProxy, metadata: Optional[Dict] = None) -> OrderBookMessage: - ts = record["timestamp"] + ts = int(record["timestamp"]) msg = ujson.loads(record["json"]) # Kucoin json in DB is TEXT if metadata: msg.update(metadata) @@ -113,17 +113,18 @@ cdef class KucoinOrderBook(OrderBook): @classmethod def trade_message_from_db(cls, record: RowProxy, metadata: Optional[Dict] = None): + ts = int(record["timestamp"]) msg = record["json"] if metadata: msg.update(metadata) return OrderBookMessage(OrderBookMessageType.TRADE, { "trading_pair": msg["s"], "trade_type": msg["trade_type"], - "trade_id": msg["trade_id"], + "trade_id": ts, "update_id": msg["update_id"], "price": msg["price"], "amount": msg["amount"] - }, timestamp=record["timestamp"] * 1e-9) + }, timestamp=ts * 1e-3) @classmethod def trade_message_from_exchange(cls, msg: Dict[str, any], metadata: Optional[Dict] = None): From 23de869a6d5f58cd5da2061c36cb987d164ee3c5 Mon Sep 17 00:00:00 2001 From: nionis Date: Sat, 9 Jan 2021 22:09:20 +0200 Subject: [PATCH 019/126] (feat) bitmax connector: stage 1 & 2 --- .../connector/exchange/bitmax/.gitignore | 1 + .../connector/exchange/bitmax/__init__.py | 0 .../bitmax/bitmax_active_order_tracker.pxd | 10 + .../bitmax/bitmax_active_order_tracker.pyx | 170 ++++++++++++ .../bitmax_api_order_book_data_source.py | 260 ++++++++++++++++++ .../bitmax_api_user_stream_data_source.py | 111 ++++++++ .../connector/exchange/bitmax/bitmax_auth.py | 49 ++++ .../exchange/bitmax/bitmax_constants.py | 14 + .../exchange/bitmax/bitmax_order_book.py | 151 ++++++++++ .../bitmax/bitmax_order_book_message.py | 79 ++++++ .../bitmax/bitmax_order_book_tracker.py | 109 ++++++++ .../bitmax/bitmax_order_book_tracker_entry.py | 21 ++ .../bitmax/bitmax_user_stream_tracker.py | 73 +++++ .../connector/exchange/bitmax/bitmax_utils.py | 67 +++++ .../templates/conf_fee_overrides_TEMPLATE.yml | 5 +- hummingbot/templates/conf_global_TEMPLATE.yml | 10 +- setup.py | 1 + test/connector/exchange/bitmax/.gitignore | 1 + test/connector/exchange/bitmax/__init__.py | 0 .../exchange/bitmax/test_bitmax_auth.py | 54 ++++ .../bitmax/test_bitmax_order_book_tracker.py | 103 +++++++ .../bitmax/test_bitmax_user_stream_tracker.py | 35 +++ 22 files changed, 1319 insertions(+), 5 deletions(-) create mode 100644 hummingbot/connector/exchange/bitmax/.gitignore create mode 100644 hummingbot/connector/exchange/bitmax/__init__.py create mode 100644 hummingbot/connector/exchange/bitmax/bitmax_active_order_tracker.pxd create mode 100644 hummingbot/connector/exchange/bitmax/bitmax_active_order_tracker.pyx create mode 100644 hummingbot/connector/exchange/bitmax/bitmax_api_order_book_data_source.py create mode 100755 hummingbot/connector/exchange/bitmax/bitmax_api_user_stream_data_source.py create mode 100755 hummingbot/connector/exchange/bitmax/bitmax_auth.py create mode 100644 hummingbot/connector/exchange/bitmax/bitmax_constants.py create mode 100644 hummingbot/connector/exchange/bitmax/bitmax_order_book.py create mode 100644 hummingbot/connector/exchange/bitmax/bitmax_order_book_message.py create mode 100644 hummingbot/connector/exchange/bitmax/bitmax_order_book_tracker.py create mode 100644 hummingbot/connector/exchange/bitmax/bitmax_order_book_tracker_entry.py create mode 100644 hummingbot/connector/exchange/bitmax/bitmax_user_stream_tracker.py create mode 100644 hummingbot/connector/exchange/bitmax/bitmax_utils.py create mode 100644 test/connector/exchange/bitmax/.gitignore create mode 100644 test/connector/exchange/bitmax/__init__.py create mode 100644 test/connector/exchange/bitmax/test_bitmax_auth.py create mode 100755 test/connector/exchange/bitmax/test_bitmax_order_book_tracker.py create mode 100644 test/connector/exchange/bitmax/test_bitmax_user_stream_tracker.py diff --git a/hummingbot/connector/exchange/bitmax/.gitignore b/hummingbot/connector/exchange/bitmax/.gitignore new file mode 100644 index 0000000000..23d9952b8c --- /dev/null +++ b/hummingbot/connector/exchange/bitmax/.gitignore @@ -0,0 +1 @@ +backups \ No newline at end of file diff --git a/hummingbot/connector/exchange/bitmax/__init__.py b/hummingbot/connector/exchange/bitmax/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/hummingbot/connector/exchange/bitmax/bitmax_active_order_tracker.pxd b/hummingbot/connector/exchange/bitmax/bitmax_active_order_tracker.pxd new file mode 100644 index 0000000000..fbc1eb3080 --- /dev/null +++ b/hummingbot/connector/exchange/bitmax/bitmax_active_order_tracker.pxd @@ -0,0 +1,10 @@ +# distutils: language=c++ +cimport numpy as np + +cdef class BitmaxActiveOrderTracker: + 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/bitmax/bitmax_active_order_tracker.pyx b/hummingbot/connector/exchange/bitmax/bitmax_active_order_tracker.pyx new file mode 100644 index 0000000000..092a97c45c --- /dev/null +++ b/hummingbot/connector/exchange/bitmax/bitmax_active_order_tracker.pyx @@ -0,0 +1,170 @@ +# distutils: language=c++ +# distutils: sources=hummingbot/core/cpp/OrderBookEntry.cpp + +import logging +import numpy as np +import random + +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") +BitmaxOrderBookTrackingDictionary = Dict[Decimal, Dict[str, Dict[str, any]]] + +cdef class BitmaxActiveOrderTracker: + def __init__(self, + active_asks: BitmaxOrderBookTrackingDictionary = None, + active_bids: BitmaxOrderBookTrackingDictionary = 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) -> BitmaxOrderBookTrackingDictionary: + return self._active_asks + + @property + def active_bids(self) -> BitmaxOrderBookTrackingDictionary: + 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/bitmax/bitmax_api_order_book_data_source.py b/hummingbot/connector/exchange/bitmax/bitmax_api_order_book_data_source.py new file mode 100644 index 0000000000..ed61767326 --- /dev/null +++ b/hummingbot/connector/exchange/bitmax/bitmax_api_order_book_data_source.py @@ -0,0 +1,260 @@ +#!/usr/bin/env python +import asyncio +import logging +import aiohttp +import websockets +import ujson + +from typing import Optional, List, Dict, Any, AsyncIterable +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 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, ms_timestamp_to_s +from hummingbot.connector.exchange.bitmax.bitmax_constants import EXCHANGE_NAME, REST_URL, WS_URL + + +class BitmaxAPIOrderBookDataSource(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"{REST_URL}/ticker") + if resp.status != 200: + raise IOError( + f"Error fetching last traded prices at {EXCHANGE_NAME}. " + f"HTTP status is {resp.status}." + ) + + resp_json = await resp.json() + if resp_json.get("code") != 0: + raise IOError( + f"Error fetching last traded prices at {EXCHANGE_NAME}. " + f"Error is {resp_json.message}." + ) + + for item in resp_json.get("data"): + trading_pair: str = convert_from_exchange_trading_pair(item.get("symbol")) + if trading_pair not in trading_pairs: + continue + + # we had to calculate mid price + ask = float(item.get("ask")[0]) + bid = float(item.get("bid")[0]) + result[trading_pair] = (ask + bid) / 2 + + return result + + @staticmethod + async def fetch_trading_pairs() -> List[str]: + async with aiohttp.ClientSession() as client: + resp = await client.get(f"{REST_URL}/ticker") + + if resp.status != 200: + # Do nothing if the request fails -- there will be no autocomplete for kucoin trading pairs + return [] + + data: Dict[str, Dict[str, Any]] = await resp.json() + return [convert_from_exchange_trading_pair(item["symbol"]) for item in data["data"]] + + @staticmethod + async def get_order_book_data(trading_pair: str) -> Dict[str, any]: + """ + Get whole orderbook + """ + async with aiohttp.ClientSession() as client: + resp = await client.get(f"{REST_URL}/depth?symbol={convert_to_exchange_trading_pair(trading_pair)}") + if resp.status != 200: + raise IOError( + f"Error fetching OrderBook for {trading_pair} at {EXCHANGE_NAME}. " + f"HTTP status is {resp.status}." + ) + + data: List[Dict[str, Any]] = await safe_gather(resp.json()) + item = data[0] + if item.get("code") != 0: + raise IOError( + f"Error fetching OrderBook for {trading_pair} at {EXCHANGE_NAME}. " + f"Error is {item.message}." + ) + + return item["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 = snapshot.get("data").get("ts") + snapshot_msg: OrderBookMessage = BitmaxOrderBook.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() + 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): + while True: + try: + trading_pairs = ",".join(list( + map(lambda trading_pair: convert_to_exchange_trading_pair(trading_pair), self._trading_pairs) + )) + payload = { + "op": "sub", + "ch": f"trades:{trading_pairs}" + } + + async with websockets.connect(WS_URL) as ws: + ws: websockets.WebSocketClientProtocol = ws + await ws.send(ujson.dumps(payload)) + + async for raw_msg in self._inner_messages(ws): + try: + msg = ujson.loads(raw_msg) + if (msg is None or msg.get("m") != "trades"): + continue + + trading_pair: str = convert_from_exchange_trading_pair(msg.get("symbol")) + + for trade in msg.get("data"): + trade_timestamp: int = ms_timestamp_to_s(trade.get("ts")) + trade_msg: OrderBookMessage = BitmaxOrderBook.trade_message_from_exchange( + trade, + trade_timestamp, + metadata={"trading_pair": trading_pair} + ) + output.put_nowait(trade_msg) + except Exception: + raise + except asyncio.CancelledError: + raise + except Exception as e: + self.logger().debug(str(e)) + self.logger().error("Unexpected error with WebSocket connection. Retrying after 30 seconds...", + exc_info=True) + await asyncio.sleep(30.0) + + async def listen_for_order_book_diffs(self, ev_loop: asyncio.BaseEventLoop, output: asyncio.Queue): + while True: + try: + trading_pairs = ",".join(list( + map(lambda trading_pair: convert_to_exchange_trading_pair(trading_pair), self._trading_pairs) + )) + ch = f"bbo:{trading_pairs}" + payload = { + "op": "sub", + "ch": ch + } + + async with websockets.connect(WS_URL) as ws: + ws: websockets.WebSocketClientProtocol = ws + await ws.send(ujson.dumps(payload)) + + async for raw_msg in self._inner_messages(ws): + try: + msg = ujson.loads(raw_msg) + if (msg is None or msg.get("m") != "bbo"): + continue + + msg_timestamp: int = ms_timestamp_to_s(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( + msg.get("data"), + msg_timestamp, + metadata={"trading_pair": trading_pair} + ) + output.put_nowait(order_book_message) + except Exception: + raise + except asyncio.CancelledError: + raise + except Exception as e: + self.logger().debug(str(e)) + self.logger().error("Unexpected error with WebSocket connection. Retrying after 30 seconds...", + exc_info=True) + await asyncio.sleep(30.0) + + async def listen_for_order_book_snapshots(self, ev_loop: asyncio.BaseEventLoop, output: asyncio.Queue): + while True: + try: + trading_pairs = ",".join(list( + map(lambda trading_pair: convert_to_exchange_trading_pair(trading_pair), self._trading_pairs) + )) + ch = f"depth:{trading_pairs}" + payload = { + "op": "sub", + "ch": ch + } + + async with websockets.connect(WS_URL) as ws: + ws: websockets.WebSocketClientProtocol = ws + await ws.send(ujson.dumps(payload)) + + async for raw_msg in self._inner_messages(ws): + try: + msg = ujson.loads(raw_msg) + if (msg is None or msg.get("m") != "depth"): + continue + + msg_timestamp: int = ms_timestamp_to_s(msg.get("data").get("ts")) + trading_pair: str = convert_from_exchange_trading_pair(msg.get("symbol")) + order_book_message: OrderBookMessage = BitmaxOrderBook.snapshot_message_from_exchange( + msg.get("data"), + msg_timestamp, + metadata={"trading_pair": trading_pair} + ) + output.put_nowait(order_book_message) + except Exception: + raise + except asyncio.CancelledError: + raise + except Exception: + self.logger().error("Unexpected error with WebSocket connection. Retrying after 30 seconds...", + exc_info=True) + await asyncio.sleep(30.0) + + async def _inner_messages( + self, + ws: websockets.WebSocketClientProtocol + ) -> AsyncIterable[str]: + # Terminate the recv() loop as soon as the next message timed out, so the outer loop can reconnect. + try: + while True: + try: + raw_msg: str = await asyncio.wait_for(ws.recv(), timeout=self.MESSAGE_TIMEOUT) + yield raw_msg + except asyncio.TimeoutError: + try: + pong_waiter = await ws.ping() + await asyncio.wait_for(pong_waiter, timeout=self.PING_TIMEOUT) + except asyncio.TimeoutError: + raise + except asyncio.TimeoutError: + self.logger().warning("WebSocket ping timed out. Going to reconnect...") + return + except websockets.ConnectionClosed: + return + finally: + await ws.close() diff --git a/hummingbot/connector/exchange/bitmax/bitmax_api_user_stream_data_source.py b/hummingbot/connector/exchange/bitmax/bitmax_api_user_stream_data_source.py new file mode 100755 index 0000000000..f81914adc8 --- /dev/null +++ b/hummingbot/connector/exchange/bitmax/bitmax_api_user_stream_data_source.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python +import time +import asyncio +import logging +import websockets +import aiohttp +import ujson + +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 + + +class BitmaxAPIUserStreamDataSource(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, bitmax_auth: BitmaxAuth, trading_pairs: Optional[List[str]] = []): + self._bitmax_auth: BitmaxAuth = bitmax_auth + self._trading_pairs = trading_pairs + self._current_listen_key = None + self._listen_for_user_stream_task = None + self._last_recv_time: float = 0 + super().__init__() + + @property + def last_recv_time(self) -> float: + return self._last_recv_time + + async def listen_for_user_stream(self, ev_loop: asyncio.BaseEventLoop, output: asyncio.Queue) -> 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: + response = await aiohttp.ClientSession().get(f"{REST_URL}/info", headers={ + **self._bitmax_auth.get_headers(), + **self._bitmax_auth.get_auth_headers("info"), + }) + info = await response.json() + accountGroup = info.get("data").get("accountGroup") + headers = self._bitmax_auth.get_auth_headers("stream") + payload = { + "op": "sub", + "ch": "order:cash" + } + + async with websockets.connect(f"{getWsUrlPriv(accountGroup)}/stream", extra_headers=headers) as ws: + try: + ws: websockets.WebSocketClientProtocol = ws + await ws.send(ujson.dumps(payload)) + + async for raw_msg in self._inner_messages(ws): + try: + msg = ujson.loads(raw_msg) + if (msg is None or (msg.get("m") != "order" and msg.get("m") != "cash")): + continue + + output.put_nowait(msg) + except Exception: + raise + except Exception: + raise + except asyncio.CancelledError: + raise + except Exception as e: + print(str(e)) + self.logger().error( + "Unexpected error with Bitmax WebSocket connection. " "Retrying after 30 seconds...", exc_info=True + ) + await asyncio.sleep(30.0) + + async def _inner_messages( + self, + ws: websockets.WebSocketClientProtocol + ) -> AsyncIterable[str]: + # Terminate the recv() loop as soon as the next message timed out, so the outer loop can reconnect. + try: + while True: + try: + raw_msg: str = await asyncio.wait_for(ws.recv(), timeout=self.MESSAGE_TIMEOUT) + self._last_recv_time = time.time() + yield raw_msg + except asyncio.TimeoutError: + try: + pong_waiter = await ws.ping() + await asyncio.wait_for(pong_waiter, timeout=self.PING_TIMEOUT) + self._last_recv_time = time.time() + except asyncio.TimeoutError: + raise + except asyncio.TimeoutError: + self.logger().warning("WebSocket ping timed out. Going to reconnect...") + return + except websockets.ConnectionClosed: + return + finally: + await ws.close() diff --git a/hummingbot/connector/exchange/bitmax/bitmax_auth.py b/hummingbot/connector/exchange/bitmax/bitmax_auth.py new file mode 100755 index 0000000000..646fb13a8a --- /dev/null +++ b/hummingbot/connector/exchange/bitmax/bitmax_auth.py @@ -0,0 +1,49 @@ +import hmac +import hashlib +from typing import Dict, Any +from hummingbot.connector.exchange.bitmax.bitmax_utils import get_ms_timestamp + + +class BitmaxAuth(): + """ + Auth class required by bitmax API + Learn more at https://bitmax-exchange.github.io/bitmax-pro-api/#authenticate-a-restful-request + """ + def __init__(self, api_key: str, secret_key: str): + self.api_key = api_key + self.secret_key = secret_key + + def get_auth_headers( + self, + path_url: str, + data: Dict[str, Any] = None + ): + """ + Generates authentication signature and return it in a dictionary along with other inputs + :return: a dictionary of request info including the request signature + """ + + timestamp = str(get_ms_timestamp()) + message = timestamp + path_url + signature = hmac.new( + self.secret_key.encode('utf-8'), + message.encode('utf-8'), + hashlib.sha256 + ).hexdigest() + + return { + "x-auth-key": self.api_key, + "x-auth-signature": signature, + "x-auth-timestamp": timestamp, + } + + def get_headers(self) -> Dict[str, Any]: + """ + Generates generic headers required by bitmax + :return: a dictionary of headers + """ + + return { + "Accept": "application/json", + "Content-Type": 'application/json', + } diff --git a/hummingbot/connector/exchange/bitmax/bitmax_constants.py b/hummingbot/connector/exchange/bitmax/bitmax_constants.py new file mode 100644 index 0000000000..5d825d5c93 --- /dev/null +++ b/hummingbot/connector/exchange/bitmax/bitmax_constants.py @@ -0,0 +1,14 @@ +# 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" + + +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/bitmax/bitmax_order_book.py b/hummingbot/connector/exchange/bitmax/bitmax_order_book.py new file mode 100644 index 0000000000..7477332280 --- /dev/null +++ b/hummingbot/connector/exchange/bitmax/bitmax_order_book.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python + +import logging +import hummingbot.connector.exchange.bitmax.bitmax_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 + +_logger = None + + +class BitmaxOrderBook(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: BitmaxOrderBookMessage + """ + + if metadata: + msg.update(metadata) + + return BitmaxOrderBookMessage( + 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: BitmaxOrderBookMessage + """ + return BitmaxOrderBookMessage( + 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: BitmaxOrderBookMessage + """ + + if metadata: + msg.update(metadata) + + msg.update({ + "bids": [msg.get("bid")], + "asks": [msg.get("ask")], + }) + + return BitmaxOrderBookMessage( + 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: BitmaxOrderBookMessage + """ + return BitmaxOrderBookMessage( + 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: BitmaxOrderBookMessage + """ + + if metadata: + msg.update(metadata) + + msg.update({ + "exchange_order_id": msg.get("seqnum"), + "trade_type": "buy" if msg.get("bm") else "sell", + "price": msg.get("p"), + "amount": msg.get("q"), + }) + + return BitmaxOrderBookMessage( + 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: BitmaxOrderBookMessage + """ + return BitmaxOrderBookMessage( + 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/bitmax/bitmax_order_book_message.py b/hummingbot/connector/exchange/bitmax/bitmax_order_book_message.py new file mode 100644 index 0000000000..7cfaea496d --- /dev/null +++ b/hummingbot/connector/exchange/bitmax/bitmax_order_book_message.py @@ -0,0 +1,79 @@ +#!/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 BitmaxOrderBookMessage(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(BitmaxOrderBookMessage, 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.content["seqnum"]) + return int(self.timestamp) + else: + return -1 + + @property + def trade_id(self) -> int: + if self.type is OrderBookMessageType.TRADE: + # return int(self.content["seqnum"]) + return int(self.timestamp) + return -1 + + @property + def trading_pair(self) -> str: + return self.content["trading_pair"] + + @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/bitmax/bitmax_order_book_tracker.py new file mode 100644 index 0000000000..9fa26cc508 --- /dev/null +++ b/hummingbot/connector/exchange/bitmax/bitmax_order_book_tracker.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python +import asyncio +import bisect +import logging +import hummingbot.connector.exchange.bitmax.bitmax_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 + + +class BitmaxOrderBookTracker(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__(BitmaxAPIOrderBookDataSource(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, BitmaxOrderBook] = {} + self._saved_message_queues: Dict[str, Deque[BitmaxOrderBookMessage]] = \ + defaultdict(lambda: deque(maxlen=1000)) + self._active_order_trackers: Dict[str, BitmaxActiveOrderTracker] = defaultdict(BitmaxActiveOrderTracker) + 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[BitmaxOrderBookMessage] = 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] + + 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] + # Process saved messages first if there are any + if len(saved_messages) > 0: + message = saved_messages.popleft() + else: + message = await message_queue.get() + + if message.type is OrderBookMessageType.DIFF: + bids, asks = active_order_tracker.convert_diff_message_to_order_book_row(message) + order_book.apply_diffs(bids, asks, message.update_id) + past_diffs_window.append(message) + while len(past_diffs_window) > self.PAST_DIFF_WINDOW_SIZE: + past_diffs_window.popleft() + diff_messages_accepted += 1 + + # Output some statistics periodically. + now: float = time.time() + if int(now / 60.0) > int(last_message_timestamp / 60.0): + self.logger().debug("Processed %d order book diffs for %s.", + diff_messages_accepted, trading_pair) + diff_messages_accepted = 0 + last_message_timestamp = now + elif message.type is OrderBookMessageType.SNAPSHOT: + past_diffs: List[BitmaxOrderBookMessage] = list(past_diffs_window) + # only replay diffs later than snapshot, first update active order with snapshot then replay diffs + replay_position = bisect.bisect_right(past_diffs, message) + replay_diffs = past_diffs[replay_position:] + s_bids, s_asks = active_order_tracker.convert_snapshot_message_to_order_book_row(message) + order_book.apply_snapshot(s_bids, s_asks, message.update_id) + for diff_message in replay_diffs: + d_bids, d_asks = active_order_tracker.convert_diff_message_to_order_book_row(diff_message) + order_book.apply_diffs(d_bids, d_asks, diff_message.update_id) + + self.logger().debug("Processed order book snapshot for %s.", trading_pair) + except asyncio.CancelledError: + raise + except Exception: + self.logger().network( + f"Unexpected error processing order book messages for {trading_pair}.", + exc_info=True, + app_warning_msg="Unexpected error processing order book messages. Retrying after 5 seconds." + ) + await asyncio.sleep(5.0) diff --git a/hummingbot/connector/exchange/bitmax/bitmax_order_book_tracker_entry.py b/hummingbot/connector/exchange/bitmax/bitmax_order_book_tracker_entry.py new file mode 100644 index 0000000000..a97a33088a --- /dev/null +++ b/hummingbot/connector/exchange/bitmax/bitmax_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.bitmax.bitmax_active_order_tracker import BitmaxActiveOrderTracker + + +class BitmaxOrderBookTrackerEntry(OrderBookTrackerEntry): + def __init__( + self, trading_pair: str, timestamp: float, order_book: OrderBook, active_order_tracker: BitmaxActiveOrderTracker + ): + self._active_order_tracker = active_order_tracker + super(BitmaxOrderBookTrackerEntry, self).__init__(trading_pair, timestamp, order_book) + + def __repr__(self) -> str: + return ( + f"BitmaxOrderBookTrackerEntry(trading_pair='{self._trading_pair}', timestamp='{self._timestamp}', " + f"order_book='{self._order_book}')" + ) + + @property + def active_order_tracker(self) -> BitmaxActiveOrderTracker: + return self._active_order_tracker diff --git a/hummingbot/connector/exchange/bitmax/bitmax_user_stream_tracker.py b/hummingbot/connector/exchange/bitmax/bitmax_user_stream_tracker.py new file mode 100644 index 0000000000..8255abdb94 --- /dev/null +++ b/hummingbot/connector/exchange/bitmax/bitmax_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.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 + + +class BitmaxUserStreamTracker(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, + bitmax_auth: Optional[BitmaxAuth] = None, + trading_pairs: Optional[List[str]] = []): + super().__init__() + self._bitmax_auth: BitmaxAuth = bitmax_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 = BitmaxAPIUserStreamDataSource( + bitmax_auth=self._bitmax_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/bitmax/bitmax_utils.py new file mode 100644 index 0000000000..f974acf8c8 --- /dev/null +++ b/hummingbot/connector/exchange/bitmax/bitmax_utils.py @@ -0,0 +1,67 @@ +import math +# from typing import Dict, List +from hummingbot.core.utils.tracking_nonce import get_tracking_nonce_low_res +# from . import crypto_com_constants as Constants + +from hummingbot.client.config.config_var import ConfigVar +from hummingbot.client.config.config_methods import using_exchange + + +CENTRALIZED = True + +EXAMPLE_PAIR = "BTC-USDT" + +DEFAULT_FEES = [0.1, 0.1] + +# HBOT_BROKER_ID = "HBOT-" + + +def convert_from_exchange_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("-", "/") + +# # 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) + + +KEYS = { + "bitmax_api_key": + ConfigVar(key="bitmax_api_key", + prompt="Enter your Bitmax API key >>> ", + required_if=using_exchange("bitmax"), + 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"), + is_secure=True, + is_connect_key=True), +} diff --git a/hummingbot/templates/conf_fee_overrides_TEMPLATE.yml b/hummingbot/templates/conf_fee_overrides_TEMPLATE.yml index 5228d83d3c..c957300f12 100644 --- a/hummingbot/templates/conf_fee_overrides_TEMPLATE.yml +++ b/hummingbot/templates/conf_fee_overrides_TEMPLATE.yml @@ -66,4 +66,7 @@ okex_maker_fee: okex_taker_fee: balancer_maker_fee_amount: -balancer_taker_fee_amount: \ No newline at end of file +balancer_taker_fee_amount: + +bitmax_maker_fee_amount: +bitmax_taker_fee_amount: diff --git a/hummingbot/templates/conf_global_TEMPLATE.yml b/hummingbot/templates/conf_global_TEMPLATE.yml index c4c9b4cdfe..0b3e550552 100644 --- a/hummingbot/templates/conf_global_TEMPLATE.yml +++ b/hummingbot/templates/conf_global_TEMPLATE.yml @@ -63,6 +63,9 @@ okex_api_key: null okex_secret_key: null okex_passphrase: null +bitmax_api_key: null +bitmax_secret_key: null + celo_address: null celo_password: null @@ -112,9 +115,9 @@ log_level: INFO debug_console: false strategy_report_interval: 900.0 logger_override_whitelist: -- hummingbot.strategy.arbitrage -- hummingbot.strategy.cross_exchange_market_making -- conf + - hummingbot.strategy.arbitrage + - hummingbot.strategy.cross_exchange_market_making + - conf key_file_path: conf/ log_file_path: logs/ on_chain_cancel_on_exit: false @@ -131,7 +134,6 @@ min_quote_order_amount: TUSD: 11 TRY: 11 - # Advanced database options, currently supports SQLAlchemy's included dialects # Reference: https://docs.sqlalchemy.org/en/13/dialects/ db_engine: sqlite diff --git a/setup.py b/setup.py index 2403010250..7cc8795132 100755 --- a/setup.py +++ b/setup.py @@ -55,6 +55,7 @@ def main(): "hummingbot.connector.exchange.liquid", "hummingbot.connector.exchange.dolomite", "hummingbot.connector.exchange.eterbase", + "hummingbot.connector.exchange.bitmax", "hummingbot.connector.derivative", "hummingbot.connector.derivative.binance_perpetual", "hummingbot.script", diff --git a/test/connector/exchange/bitmax/.gitignore b/test/connector/exchange/bitmax/.gitignore new file mode 100644 index 0000000000..23d9952b8c --- /dev/null +++ b/test/connector/exchange/bitmax/.gitignore @@ -0,0 +1 @@ +backups \ No newline at end of file diff --git a/test/connector/exchange/bitmax/__init__.py b/test/connector/exchange/bitmax/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/connector/exchange/bitmax/test_bitmax_auth.py b/test/connector/exchange/bitmax/test_bitmax_auth.py new file mode 100644 index 0000000000..46a68bfae7 --- /dev/null +++ b/test/connector/exchange/bitmax/test_bitmax_auth.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python +import sys +import asyncio +import unittest +import aiohttp +import ujson +import websockets +import conf +import logging +from os.path import join, realpath +from typing import Dict, Any +from hummingbot.connector.exchange.bitmax.bitmax_auth import BitmaxAuth +from hummingbot.logger.struct_logger import METRICS_LOG_LEVEL +from hummingbot.connector.exchange.bitmax.bitmax_constants import REST_URL, getWsUrlPriv + +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.bitmax_api_key + secret_key = conf.bitmax_secret_key + cls.auth = BitmaxAuth(api_key, secret_key) + + async def rest_auth(self) -> Dict[Any, Any]: + headers = { + **self.auth.get_headers(), + **self.auth.get_auth_headers("info"), + } + response = await aiohttp.ClientSession().get(f"{REST_URL}/info", headers=headers) + return await response.json() + + 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) + + raw_msg = await asyncio.wait_for(ws.recv(), 5000) + msg = ujson.loads(raw_msg) + + return msg + + def test_rest_auth(self): + result = self.ev_loop.run_until_complete(self.rest_auth()) + assert result["code"] == 0 + + def test_ws_auth(self): + result = self.ev_loop.run_until_complete(self.ws_auth()) + assert result["m"] == "connected" + assert result["type"] == "auth" diff --git a/test/connector/exchange/bitmax/test_bitmax_order_book_tracker.py b/test/connector/exchange/bitmax/test_bitmax_order_book_tracker.py new file mode 100755 index 0000000000..a1fe109e5f --- /dev/null +++ b/test/connector/exchange/bitmax/test_bitmax_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.bitmax.bitmax_order_book_tracker import BitmaxOrderBookTracker +from hummingbot.connector.exchange.bitmax.bitmax_api_order_book_data_source import BitmaxAPIOrderBookDataSource +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 BitmaxOrderBookTrackerUnitTest(unittest.TestCase): + order_book_tracker: Optional[BitmaxOrderBookTracker] = 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: BitmaxOrderBookTracker = BitmaxOrderBookTracker(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_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( + BitmaxAPIOrderBookDataSource.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/bitmax/test_bitmax_user_stream_tracker.py b/test/connector/exchange/bitmax/test_bitmax_user_stream_tracker.py new file mode 100644 index 0000000000..1b4cb5c84b --- /dev/null +++ b/test/connector/exchange/bitmax/test_bitmax_user_stream_tracker.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python +import sys +import asyncio +import logging +import unittest +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.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 BitmaxUserStreamTrackerUnitTest(unittest.TestCase): + api_key = conf.bitmax_api_key + api_secret = conf.bitmax_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.trading_pairs = ["BTC-USDT"] + cls.user_stream_tracker: BitmaxUserStreamTracker = BitmaxUserStreamTracker( + bitmax_auth=cls.bitmax_auth, trading_pairs=cls.trading_pairs) + cls.user_stream_tracker_task: asyncio.Task = safe_ensure_future(cls.user_stream_tracker.start()) + + def test_user_stream(self): + # Wait process some msgs. + self.ev_loop.run_until_complete(asyncio.sleep(120.0)) + print(self.user_stream_tracker.user_stream) From 639efae811c8e5794190fa13aeb4312a0a6bf4e1 Mon Sep 17 00:00:00 2001 From: vic-en Date: Sun, 10 Jan 2021 23:46:59 +0100 Subject: [PATCH 020/126] (fix) fix uninitialized user stream in huobi --- .../huobi_api_user_stream_data_source.py | 6 ++---- .../exchange/huobi/huobi_exchange.pyx | 20 ++++++++++++++----- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/hummingbot/connector/exchange/huobi/huobi_api_user_stream_data_source.py b/hummingbot/connector/exchange/huobi/huobi_api_user_stream_data_source.py index e0bf3314f0..7970237658 100644 --- a/hummingbot/connector/exchange/huobi/huobi_api_user_stream_data_source.py +++ b/hummingbot/connector/exchange/huobi/huobi_api_user_stream_data_source.py @@ -74,7 +74,7 @@ async def _authenticate_client(self): await self._websocket_connection.send_json(auth_request) resp: aiohttp.WSMessage = await self._websocket_connection.receive() msg = resp.json() - if msg["code"] != 200: + if msg.get("code", 0) != 200: self.logger().error(f"Error occurred authenticating to websocket API server. {msg}") self.logger().info("Successfully authenticated") @@ -86,7 +86,7 @@ async def _subscribe_topic(self, topic: str): await self._websocket_connection.send_json(subscribe_request) resp = await self._websocket_connection.receive() msg = resp.json() - if msg["code"] != 200: + if msg.get("code", 0) != 200: self.logger().error(f"Error occurred subscribing to topic. {topic}. {msg}") self.logger().info(f"Successfully subscribed to {topic}") @@ -118,8 +118,6 @@ async def _socket_user_stream(self) -> AsyncIterable[str]: await self._websocket_connection.send_json(pong_response) continue - import sys - print(f"MSG: {message}", file=sys.stdout) yield message async def listen_for_user_stream(self, ev_loop: asyncio.BaseEventLoop, output: asyncio.Queue): diff --git a/hummingbot/connector/exchange/huobi/huobi_exchange.pyx b/hummingbot/connector/exchange/huobi/huobi_exchange.pyx index 90595eab8c..3916a5b376 100644 --- a/hummingbot/connector/exchange/huobi/huobi_exchange.pyx +++ b/hummingbot/connector/exchange/huobi/huobi_exchange.pyx @@ -199,6 +199,7 @@ cdef class HuobiExchange(ExchangeBase): self._user_stream_event_listener_task = safe_ensure_future(self._user_stream_event_listener()) if self._trading_required: await self._update_account_id() + self._user_stream_tracker_task = safe_ensure_future(self._user_stream_tracker.start()) self._status_polling_task = safe_ensure_future(self._status_polling_loop()) def _stop_network(self): @@ -212,6 +213,9 @@ cdef class HuobiExchange(ExchangeBase): if self._user_stream_event_listener_task is not None: self._user_stream_event_listener_task.cancel() self._user_stream_event_listener_task = None + if self._user_stream_tracker_task is not None: + self._user_stream_tracker_task.cancel() + self._user_stream_tracker_task = None async def stop_network(self): self._stop_network() @@ -577,8 +581,10 @@ cdef class HuobiExchange(ExchangeBase): data = stream_message["data"] if channel == HUOBI_ACCOUNT_UPDATE_TOPIC: asset_name = data["currency"].upper() - balance = data["balance"] - available_balance = data["available"] + # Huobi balance update can contain either balance or available_balance, or both. + # Hence, the reason for the setting existing value(i.e assume no change) if get fails. + balance = data.get("balance", self._account_balances.get(asset_name, "0")) + available_balance = data.get("available", self._account_available_balances.get(asset_name, "0")) self._account_balances.update({asset_name: Decimal(balance)}) self._account_available_balances.update({asset_name: Decimal(available_balance)}) @@ -599,8 +605,10 @@ cdef class HuobiExchange(ExchangeBase): continue execute_amount_diff = s_decimal_0 - execute_price = Decimal(data["tradePrice"]) - remaining_amount = Decimal(data["remainAmt"]) + # tradePrice is documented by Huobi but not sent. + # However, orderprice isn't applicable to all order_types, so we assume tradePrice will be sent for such orders + execute_price = Decimal(data.get("orderPrice", data.get("tradePrice", "0"))) + remaining_amount = Decimal(data.get("remainAmt", data["orderSize"])) order_type = data["type"] new_confirmed_amount = Decimal(tracked_order.amount - remaining_amount) @@ -669,6 +677,8 @@ cdef class HuobiExchange(ExchangeBase): if order_status == "canceled": tracked_order.last_state = order_status + self.logger().info(f"The order {tracked_order.client_order_id} has been cancelled " + f"according to order delta websocket API.") self.c_trigger_event(self.MARKET_ORDER_CANCELLED_EVENT_TAG, OrderCancelledEvent(self._current_timestamp, tracked_order.client_order_id)) @@ -680,7 +690,7 @@ cdef class HuobiExchange(ExchangeBase): except asyncio.CancelledError: raise except Exception as e: - self.logger().error(f"Unexpected error in user stream listener lopp. {e}", exc_info=True) + self.logger().error(f"Unexpected error in user stream listener loop. {e}", exc_info=True) await asyncio.sleep(5.0) @property From f6efb5aeda6f52b9b96be9426c09dcd05faf7c63 Mon Sep 17 00:00:00 2001 From: Nicolas Baum Date: Mon, 11 Jan 2021 09:54:42 -0300 Subject: [PATCH 021/126] (feat) Added tests both for duplicate prevention and for history reconciliation. Added unique nonce to orders. Also fixed test orders_saving_and_restoration which broke market object. --- .../exchange/binance/binance_exchange.pyx | 76 ++++--- hummingbot/connector/markets_recorder.py | 3 +- test/integration/test_binance_market.py | 202 ++++++++++++++---- 3 files changed, 209 insertions(+), 72 deletions(-) diff --git a/hummingbot/connector/exchange/binance/binance_exchange.pyx b/hummingbot/connector/exchange/binance/binance_exchange.pyx index 20cacb1331..fdff72ae6c 100755 --- a/hummingbot/connector/exchange/binance/binance_exchange.pyx +++ b/hummingbot/connector/exchange/binance/binance_exchange.pyx @@ -529,7 +529,8 @@ cdef class BinanceExchange(ExchangeBase): if tracked_order.is_done: if not tracked_order.is_failure: - if self.is_confirmed_new_order_filled_event(tracked_order.exchange_order_id, + exchange_order_id = next(iter(tracked_order.trade_id_set)) + if self.is_confirmed_new_order_filled_event(exchange_order_id, tracked_order.trading_pair): if tracked_order.trade_type is TradeType.BUY: self.logger().info(f"The market buy order {tracked_order.client_order_id} has completed " @@ -559,6 +560,10 @@ cdef class BinanceExchange(ExchangeBase): executed_amount_quote, tracked_order.fee_paid, order_type)) + else: + self.logger().info( + f"The market order {tracked_order.client_order_id} was already filled. " + f"Ignoring trade filled event in update_order_status.") else: # check if its a cancelled order # if its a cancelled order, issue cancel and stop tracking order @@ -642,43 +647,46 @@ cdef class BinanceExchange(ExchangeBase): if execution_type == "TRADE": order_filled_event = OrderFilledEvent.order_filled_event_from_binance_execution_report(event_message) order_filled_event = order_filled_event._replace(trading_pair=convert_from_exchange_trading_pair(order_filled_event.trading_pair)) - self.c_trigger_event(self.MARKET_ORDER_FILLED_EVENT_TAG, order_filled_event) + exchange_order_id = next(iter(tracked_order.trade_id_set)) + if self.is_confirmed_new_order_filled_event(exchange_order_id, tracked_order.trading_pair): + self.c_trigger_event(self.MARKET_ORDER_FILLED_EVENT_TAG, order_filled_event) + else: + self.logger().info( + f"The market order {tracked_order.client_order_id} was already filled." + f"Ignoring trade filled event in user stream.") + self.c_stop_tracking_order(tracked_order.client_order_id) + continue if tracked_order.is_done: if not tracked_order.is_failure: - if self.is_confirmed_new_order_filled_event(tracked_order.exchange_order_id, tracked_order.trading_pair): - if tracked_order.trade_type is TradeType.BUY: - self.logger().info(f"The market buy order {tracked_order.client_order_id} has completed " - f"according to user stream.") - self.c_trigger_event(self.MARKET_BUY_ORDER_COMPLETED_EVENT_TAG, - BuyOrderCompletedEvent(self._current_timestamp, - tracked_order.client_order_id, - tracked_order.base_asset, - tracked_order.quote_asset, - (tracked_order.fee_asset - or tracked_order.base_asset), - tracked_order.executed_amount_base, - tracked_order.executed_amount_quote, - tracked_order.fee_paid, - tracked_order.order_type)) - else: - self.logger().info(f"The market sell order {tracked_order.client_order_id} has completed " - f"according to user stream.") - self.c_trigger_event(self.MARKET_SELL_ORDER_COMPLETED_EVENT_TAG, - SellOrderCompletedEvent(self._current_timestamp, - tracked_order.client_order_id, - tracked_order.base_asset, - tracked_order.quote_asset, - (tracked_order.fee_asset - or tracked_order.quote_asset), - tracked_order.executed_amount_base, - tracked_order.executed_amount_quote, - tracked_order.fee_paid, - tracked_order.order_type)) + if tracked_order.trade_type is TradeType.BUY: + self.logger().info(f"The market buy order {tracked_order.client_order_id} has completed " + f"according to user stream.") + self.c_trigger_event(self.MARKET_BUY_ORDER_COMPLETED_EVENT_TAG, + BuyOrderCompletedEvent(self._current_timestamp, + tracked_order.client_order_id, + tracked_order.base_asset, + tracked_order.quote_asset, + (tracked_order.fee_asset + or tracked_order.base_asset), + tracked_order.executed_amount_base, + tracked_order.executed_amount_quote, + tracked_order.fee_paid, + tracked_order.order_type)) else: - self.logger().info( - f"The market order {tracked_order.client_order_id} was already filled. " - f"Ignoring trade filled event.") + self.logger().info(f"The market sell order {tracked_order.client_order_id} has completed " + f"according to user stream.") + self.c_trigger_event(self.MARKET_SELL_ORDER_COMPLETED_EVENT_TAG, + SellOrderCompletedEvent(self._current_timestamp, + tracked_order.client_order_id, + tracked_order.base_asset, + tracked_order.quote_asset, + (tracked_order.fee_asset + or tracked_order.quote_asset), + tracked_order.executed_amount_base, + tracked_order.executed_amount_quote, + tracked_order.fee_paid, + tracked_order.order_type)) else: # check if its a cancelled order # if its a cancelled order, check in flight orders diff --git a/hummingbot/connector/markets_recorder.py b/hummingbot/connector/markets_recorder.py index 485af73dfb..e6141ea55d 100644 --- a/hummingbot/connector/markets_recorder.py +++ b/hummingbot/connector/markets_recorder.py @@ -1,5 +1,4 @@ #!/usr/bin/env python - import os.path import pandas as pd import asyncio @@ -239,8 +238,8 @@ def _did_fill_order(self, session.add(trade_fill_record) self.save_market_states(self._config_file_path, market, no_commit=True) session.commit() - self.append_to_csv(trade_fill_record) market.add_trade_fills_from_market_recorder([trade_fill_record]) + self.append_to_csv(trade_fill_record) def append_to_csv(self, trade: TradeFill): csv_file = "trades_" + trade.config_file_path[:-4] + ".csv" diff --git a/test/integration/test_binance_market.py b/test/integration/test_binance_market.py index 10d8451161..d27421dc51 100644 --- a/test/integration/test_binance_market.py +++ b/test/integration/test_binance_market.py @@ -1,6 +1,5 @@ from os.path import join, realpath -import sys; sys.path.insert(0, realpath(join(__file__, "../../bin"))) -# import sys; sys.path.insert(0, realpath(join(__file__, "../../../"))) +import sys import asyncio import conf import contextlib @@ -60,7 +59,7 @@ from unittest import mock import requests from test.integration.humming_ws_server import HummingWsServerFactory - +sys.path.insert(0, realpath(join(__file__, "../../bin"))) MAINNET_RPC_URL = "http://mainnet-rpc.mainnet:8545" logging.basicConfig(level=METRICS_LOG_LEVEL) @@ -118,6 +117,10 @@ def setUpClass(cls): FixtureBinance.LINKETH_SNAP, params={'symbol': 'LINKETH'}) cls.web_app.update_response("get", cls.base_api_url, "/api/v1/depth", FixtureBinance.ZRXETH_SNAP, params={'symbol': 'ZRXETH'}) + cls.web_app.update_response("get", cls.base_api_url, "/api/v3/myTrades", + {}, params={'symbol': 'ZRXETH'}) + cls.web_app.update_response("get", cls.base_api_url, "/api/v3/myTrades", + {}, params={'symbol': 'LINKETH'}) ws_base_url = "wss://stream.binance.com:9443/ws" cls._ws_user_url = f"{ws_base_url}/{FixtureBinance.LISTEN_KEY['listenKey']}" HummingWsServerFactory.start_new_server(cls._ws_user_url) @@ -129,8 +132,9 @@ def setUpClass(cls): cls._t_nonce_patcher = unittest.mock.patch( "hummingbot.connector.exchange.binance.binance_exchange.get_tracking_nonce") cls._t_nonce_mock = cls._t_nonce_patcher.start() + cls.current_nonce = 1000000000000000 cls.clock: Clock = Clock(ClockMode.REALTIME) - cls.market: BinanceExchange = BinanceExchange(API_KEY, API_SECRET, ["LINK-ETH", "ZRX-ETH"], True) + cls.market: BinanceExchange = BinanceExchange(API_KEY, API_SECRET, ["LINK-ETH", "ZRX-ETH"], -True) print("Initializing Binance market... this will take about a minute.") cls.ev_loop: asyncio.BaseEventLoop = asyncio.get_event_loop() cls.clock.add_iterator(cls.market) @@ -168,6 +172,8 @@ def setUp(self): pass self.market_logger = EventLogger() + self.market._current_trade_fills = [] + self.ev_loop.run_until_complete(self.wait_til_ready()) for event_tag in self.events: self.market.add_listener(event_tag, self.market_logger) @@ -188,6 +194,11 @@ async def run_parallel_async(self, *tasks): def run_parallel(self, *tasks): return self.ev_loop.run_until_complete(self.run_parallel_async(*tasks)) + @classmethod + def get_current_nonce(cls): + cls.current_nonce += 1 + return cls.current_nonce + def test_get_fee(self): maker_buy_trade_fee: TradeFee = self.market.get_fee("BTC", "USDT", OrderType.LIMIT_MAKER, TradeType.BUY, Decimal(1), Decimal(4000)) self.assertGreater(maker_buy_trade_fee.percent, 0) @@ -227,7 +238,7 @@ def test_buy_and_sell(self): amount: Decimal = 1 quantized_amount: Decimal = self.market.quantize_order_amount("LINK-ETH", amount) - order_id = self.place_order(True, "LINK-ETH", amount, OrderType.LIMIT, bid_price, 10001, FixtureBinance.BUY_MARKET_ORDER, + order_id = self.place_order(True, "LINK-ETH", amount, OrderType.LIMIT, bid_price, self.get_current_nonce(), FixtureBinance.BUY_MARKET_ORDER, FixtureBinance.WS_AFTER_BUY_1, FixtureBinance.WS_AFTER_BUY_2) [order_completed_event] = self.run_parallel(self.market_logger.wait_for(BuyOrderCompletedEvent)) order_completed_event: BuyOrderCompletedEvent = order_completed_event @@ -283,7 +294,7 @@ def test_limit_maker_rejections(self): amount = self.market.quantize_order_amount("LINK-ETH", 1) order_id = self.place_order(True, "LINK-ETH", amount, OrderType.LIMIT_MAKER, - price, 10001, + price, self.get_current_nonce(), FixtureBinance.LIMIT_MAKER_ERROR) [order_failure_event] = self.run_parallel(self.market_logger.wait_for(MarketOrderFailureEvent)) self.assertEqual(order_id, order_failure_event.order_id) @@ -296,7 +307,7 @@ def test_limit_maker_rejections(self): amount = self.market.quantize_order_amount("LINK-ETH", 1) order_id = self.place_order(False, "LINK-ETH", amount, OrderType.LIMIT_MAKER, - price, 10002, + price, self.get_current_nonce(), FixtureBinance.LIMIT_MAKER_ERROR) [order_failure_event] = self.run_parallel(self.market_logger.wait_for(MarketOrderFailureEvent)) self.assertEqual(order_id, order_failure_event.order_id) @@ -306,23 +317,31 @@ def test_limit_makers_unfilled(self): price = self.market.quantize_order_price("LINK-ETH", price) amount = self.market.quantize_order_amount("LINK-ETH", 1) - order_id = self.place_order(True, "LINK-ETH", amount, OrderType.LIMIT_MAKER, - price, 10001, - FixtureBinance.OPEN_BUY_ORDER) - [order_created_event] = self.run_parallel(self.market_logger.wait_for(BuyOrderCreatedEvent)) - order_created_event: BuyOrderCreatedEvent = order_created_event - self.assertEqual(order_id, order_created_event.order_id) + buy_id = self.place_order(True, "LINK-ETH", amount, OrderType.LIMIT_MAKER, + price, self.get_current_nonce(), + FixtureBinance.OPEN_BUY_ORDER) + [buy_order_created_event] = self.run_parallel(self.market_logger.wait_for(BuyOrderCreatedEvent)) + buy_order_created_event: BuyOrderCreatedEvent = buy_order_created_event + self.assertEqual(buy_id, buy_order_created_event.order_id) price = self.market.get_price("LINK-ETH", True) * Decimal("1.2") price = self.market.quantize_order_price("LINK-ETH", price) amount = self.market.quantize_order_amount("LINK-ETH", 1) - order_id = self.place_order(False, "LINK-ETH", amount, OrderType.LIMIT_MAKER, - price, 10002, - FixtureBinance.OPEN_SELL_ORDER) - [order_created_event] = self.run_parallel(self.market_logger.wait_for(SellOrderCreatedEvent)) - order_created_event: BuyOrderCreatedEvent = order_created_event - self.assertEqual(order_id, order_created_event.order_id) + sell_id = self.place_order(False, "LINK-ETH", amount, OrderType.LIMIT_MAKER, + price, self.get_current_nonce(), + FixtureBinance.OPEN_SELL_ORDER) + [sell_order_created_event] = self.run_parallel(self.market_logger.wait_for(SellOrderCreatedEvent)) + sell_order_created_event: BuyOrderCreatedEvent = sell_order_created_event + self.assertEqual(sell_id, sell_order_created_event.order_id) + + if API_MOCK_ENABLED: + resp = self.fixture(FixtureBinance.CANCEL_ORDER, origClientOrderId=buy_id, side="BUY") + self.web_app.update_response("delete", self.base_api_url, "/api/v3/order", resp, + params={'origClientOrderId': buy_id}) + resp = self.fixture(FixtureBinance.CANCEL_ORDER, origClientOrderId=sell_id, side="SELL") + self.web_app.update_response("delete", self.base_api_url, "/api/v3/order", resp, + params={'origClientOrderId': sell_id}) [cancellation_results] = self.run_parallel(self.market.cancel_all(5)) for cr in cancellation_results: @@ -371,11 +390,11 @@ def test_cancel_all(self): quantize_bid_price: Decimal = self.market.quantize_order_price(trading_pair, bid_price * Decimal("0.7")) quantize_ask_price: Decimal = self.market.quantize_order_price(trading_pair, ask_price * Decimal("1.5")) - buy_id = self.place_order(True, "LINK-ETH", quantized_amount, OrderType.LIMIT, quantize_bid_price, 10001, + buy_id = self.place_order(True, "LINK-ETH", quantized_amount, OrderType.LIMIT, quantize_bid_price, self.get_current_nonce(), FixtureBinance.OPEN_BUY_ORDER, FixtureBinance.WS_AFTER_BUY_1, FixtureBinance.WS_AFTER_BUY_2) - sell_id = self.place_order(False, "LINK-ETH", quantized_amount, OrderType.LIMIT, quantize_ask_price, 10002, + sell_id = self.place_order(False, "LINK-ETH", quantized_amount, OrderType.LIMIT, quantize_ask_price, self.get_current_nonce(), FixtureBinance.OPEN_SELL_ORDER, FixtureBinance.WS_AFTER_SELL_1, FixtureBinance.WS_AFTER_SELL_2) @@ -415,7 +434,7 @@ def test_order_price_precision(self): bid_amount: Decimal = Decimal("1.23123216") if API_MOCK_ENABLED: - resp = self.order_response(FixtureBinance.ORDER_BUY_PRECISION, 1000001, "buy", "LINK-ETH") + resp = self.order_response(FixtureBinance.ORDER_BUY_PRECISION, self.get_current_nonce(), "buy", "LINK-ETH") self.web_app.update_response("post", self.base_api_url, "/api/v3/order", resp) # Test bid order bid_order_id: str = self.market.buy( @@ -444,7 +463,7 @@ def test_order_price_precision(self): # Test ask order if API_MOCK_ENABLED: - resp = self.order_response(FixtureBinance.ORDER_SELL_PRECISION, 1000002, "sell", "LINK-ETH") + resp = self.order_response(FixtureBinance.ORDER_SELL_PRECISION, self.get_current_nonce(), "sell", "LINK-ETH") self.web_app.update_response("post", self.base_api_url, "/api/v3/order", resp) ask_order_id: str = self.market.sell( trading_pair, @@ -526,7 +545,7 @@ def test_orders_saving_and_restoration(self): quantized_amount: Decimal = self.market.quantize_order_amount("LINK-ETH", amount) if API_MOCK_ENABLED: - resp = self.order_response(FixtureBinance.OPEN_BUY_ORDER, 1000001, "buy", "LINK-ETH") + resp = self.order_response(FixtureBinance.OPEN_BUY_ORDER, self.get_current_nonce(), "buy", "LINK-ETH") self.web_app.update_response("post", self.base_api_url, "/api/v3/order", resp) order_id = self.market.buy("LINK-ETH", quantized_amount, OrderType.LIMIT, quantize_bid_price) [order_created_event] = self.run_parallel(self.market_logger.wait_for(BuyOrderCreatedEvent)) @@ -552,7 +571,7 @@ def test_orders_saving_and_restoration(self): self.clock.remove_iterator(self.market) for event_tag in self.events: self.market.remove_listener(event_tag, self.market_logger) - self.market: BinanceExchange = BinanceExchange(API_KEY, API_SECRET, ["LINK-ETH", "ZRX-ETH"], True) + self.__class__.market: BinanceExchange = BinanceExchange(API_KEY, API_SECRET, ["LINK-ETH", "ZRX-ETH"], True) for event_tag in self.events: self.market.add_listener(event_tag, self.market_logger) recorder.stop() @@ -560,6 +579,8 @@ def test_orders_saving_and_restoration(self): recorder.start() saved_market_states = recorder.get_market_states(config_path, self.market) self.clock.add_iterator(self.market) + self.ev_loop.run_until_complete(self.wait_til_ready()) + self.assertEqual(0, len(self.market.limit_orders)) self.assertEqual(0, len(self.market.tracking_states)) self.market.restore_tracking_states(saved_market_states.saved_state) @@ -598,7 +619,8 @@ def test_order_fill_record(self): config_path: str = "test_config" strategy_name: str = "test_strategy" sql: SQLConnectionManager = SQLConnectionManager(SQLConnectionType.TRADE_FILLS, db_path=self.db_path) - order_id: Optional[str] = None + buy_id: Optional[str] = None + sell_id: Optional[str] = None recorder: MarketsRecorder = MarketsRecorder(sql, [self.market], config_path, strategy_name) recorder.start() @@ -606,9 +628,9 @@ def test_order_fill_record(self): # Try to buy 1 LINK from the exchange, and watch for completion event. bid_price: Decimal = self.market.get_price("LINK-ETH", True) amount: Decimal = 1 - order_id = self.place_order(True, "LINK-ETH", amount, OrderType.LIMIT, bid_price, 10001, - FixtureBinance.BUY_LIMIT_ORDER, FixtureBinance.WS_AFTER_BUY_1, - FixtureBinance.WS_AFTER_BUY_2) + buy_id = self.place_order(True, "LINK-ETH", amount, OrderType.LIMIT, bid_price, self.get_current_nonce(), + FixtureBinance.BUY_LIMIT_ORDER, FixtureBinance.WS_AFTER_BUY_1, + FixtureBinance.WS_AFTER_BUY_2) [buy_order_completed_event] = self.run_parallel(self.market_logger.wait_for(BuyOrderCompletedEvent)) # Reset the logs @@ -617,9 +639,9 @@ def test_order_fill_record(self): # Try to sell back the same amount of LINK to the exchange, and watch for completion event. ask_price: Decimal = self.market.get_price("LINK-ETH", False) amount = buy_order_completed_event.base_asset_amount - order_id = self.place_order(False, "LINK-ETH", amount, OrderType.LIMIT, ask_price, 10002, - FixtureBinance.SELL_LIMIT_ORDER, FixtureBinance.WS_AFTER_SELL_1, - FixtureBinance.WS_AFTER_SELL_2) + sell_id = self.place_order(False, "LINK-ETH", amount, OrderType.LIMIT, ask_price, self.get_current_nonce(), + FixtureBinance.SELL_LIMIT_ORDER, FixtureBinance.WS_AFTER_SELL_1, + FixtureBinance.WS_AFTER_SELL_2) [sell_order_completed_event] = self.run_parallel(self.market_logger.wait_for(SellOrderCompletedEvent)) # Query the persisted trade logs @@ -630,17 +652,125 @@ def test_order_fill_record(self): self.assertGreaterEqual(len(buy_fills), 1) self.assertGreaterEqual(len(sell_fills), 1) - order_id = None + buy_id = sell_id = None finally: - if order_id is not None: - self.market.cancel("LINK-ETH", order_id) + if buy_id is not None: + self.market.cancel("LINK-ETH", buy_id) + self.run_parallel(self.market_logger.wait_for(OrderCancelledEvent)) + if sell_id is not None: + self.market.cancel("LINK-ETH", sell_id) + self.run_parallel(self.market_logger.wait_for(OrderCancelledEvent)) + + recorder.stop() + os.unlink(self.db_path) + + def test_prevent_duplicated_orders(self): + config_path: str = "test_config" + strategy_name: str = "test_strategy" + sql: SQLConnectionManager = SQLConnectionManager(SQLConnectionType.TRADE_FILLS, db_path=self.db_path) + buy_id: Optional[str] = None + recorder: MarketsRecorder = MarketsRecorder(sql, [self.market], config_path, strategy_name) + recorder.start() + + try: + # Perform the same order twice which should produce the same exchange_order_id + # Try to buy 1 LINK from the exchange, and watch for completion event. + bid_price: Decimal = self.market.get_price("LINK-ETH", True) + amount: Decimal = 1 + buy_id = self.place_order(True, "LINK-ETH", amount, OrderType.LIMIT, bid_price, self.get_current_nonce(), + FixtureBinance.BUY_LIMIT_ORDER, FixtureBinance.WS_AFTER_BUY_1, + FixtureBinance.WS_AFTER_BUY_2) + [buy_order_completed_event] = self.run_parallel(self.market_logger.wait_for(BuyOrderCompletedEvent)) + + self.market_logger.clear() + + # Simulate that order is still in in_flight_orders + order_json = {"client_order_id": buy_id, + "exchange_order_id": str(FixtureBinance.WS_AFTER_BUY_2['t']), + "trading_pair": "LINK-ETH", + "order_type": "MARKET", + "trade_type": "BUY", + "price": bid_price, + "amount": amount, + "last_state": "NEW", + "executed_amount_base": "0", + "executed_amount_quote": "0", + "fee_asset": "LINK", + "fee_paid": "0.0"} + self.market.restore_tracking_states({buy_id: order_json}) + self.market.in_flight_orders.get(buy_id).trade_id_set.add(str(FixtureBinance.WS_AFTER_BUY_2['t'])) + # Simulate incoming responses as if buy_id is executed again + data = self.fixture(FixtureBinance.WS_AFTER_BUY_2, c=buy_id) + HummingWsServerFactory.send_json_threadsafe(self._ws_user_url, data, delay=0.11) + # Will wait, but no order filled event should be triggered because order is ignored + self.run_parallel(asyncio.sleep(1)) + # Query the persisted trade logs + trade_fills: List[TradeFill] = recorder.get_trades_for_config(config_path) + buy_fills: List[TradeFill] = [t for t in trade_fills if t.trade_type == "BUY"] + exchange_trade_id = FixtureBinance.WS_AFTER_BUY_2['t'] + self.assertEqual(len([bf for bf in buy_fills if int(bf.exchange_trade_id) == exchange_trade_id]), 1) + + buy_id = None + + finally: + if buy_id is not None: + self.market.cancel("LINK-ETH", buy_id) + self.run_parallel(self.market_logger.wait_for(OrderCancelledEvent)) + + recorder.stop() + os.unlink(self.db_path) + + def test_history_reconciliation(self): + config_path: str = "test_config" + strategy_name: str = "test_strategy" + sql: SQLConnectionManager = SQLConnectionManager(SQLConnectionType.TRADE_FILLS, db_path=self.db_path) + recorder: MarketsRecorder = MarketsRecorder(sql, [self.market], config_path, strategy_name) + recorder.start() + try: + bid_price: Decimal = self.market.get_price("LINK-ETH", True) + # Will temporarily change binance history request to return trades + buy_id = "1580204166011219" + order_id = 123456 + self._t_nonce_mock.return_value = 1234567890123456 + binance_trades = [{ + 'symbol': "LINKETH", + 'id': buy_id, + 'orderid': order_id, + 'orderListId': -1, + 'price': float(bid_price), + 'qty': 1, + 'quoteQty': float(bid_price), + 'commission': 0, + 'commissionAsset': "ETH", + 'time': 1580093596074, + 'isBuyer': True, + 'isMaker': True, + 'isBestMatch': True, + }] + self.web_app.update_response("get", self.base_api_url, "/api/v3/myTrades", + binance_trades, params={'symbol': 'LINKETH'}) + [market_order_completed] = self.run_parallel(self.market_logger.wait_for(OrderFilledEvent)) + + trade_fills: List[TradeFill] = recorder.get_trades_for_config(config_path) + buy_fills: List[TradeFill] = [t for t in trade_fills if t.trade_type == "BUY"] + self.assertEqual(len([bf for bf in buy_fills if bf.exchange_trade_id == buy_id]), 1) + + buy_id = None + + finally: + if buy_id is not None: + self.market.cancel("LINK-ETH", buy_id) self.run_parallel(self.market_logger.wait_for(OrderCancelledEvent)) + # Undo change to binance history request + self.web_app.update_response("get", self.base_api_url, "/api/v3/myTrades", + {}, params={'symbol': 'LINKETH'}) + recorder.stop() os.unlink(self.db_path) - def test_pair_convesion(self): + def test_pair_conversion(self): if API_MOCK_ENABLED: return for pair in self.market.trading_rules: From 8d95a6415ded99d2aa0a528c09f3e76f41c3a8ae Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Mon, 11 Jan 2021 16:53:17 -0800 Subject: [PATCH 022/126] (release) changed version to dev-0.36.0 --- hummingbot/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hummingbot/VERSION b/hummingbot/VERSION index 7b52f5e517..499011d53c 100644 --- a/hummingbot/VERSION +++ b/hummingbot/VERSION @@ -1 +1 @@ -0.35.0 +dev-0.36.0 From 0d4408ac38638ce26717bd67125ff23adf9b48fa Mon Sep 17 00:00:00 2001 From: Nicolas Baum Date: Thu, 14 Jan 2021 22:43:53 -0300 Subject: [PATCH 023/126] (feat) Added exchange_order_id column to Order table enabling matching of missing trades with local client order ids --- hummingbot/connector/connector_base.pxd | 3 +- hummingbot/connector/connector_base.pyx | 27 ++++++++------ .../exchange/binance/binance_exchange.pyx | 11 +++--- hummingbot/connector/markets_recorder.py | 35 ++++++++++++++----- hummingbot/model/order.py | 4 ++- test/integration/test_binance_market.py | 5 ++- 6 files changed, 58 insertions(+), 27 deletions(-) diff --git a/hummingbot/connector/connector_base.pxd b/hummingbot/connector/connector_base.pxd index cddf9ccb30..bc62163d08 100644 --- a/hummingbot/connector/connector_base.pxd +++ b/hummingbot/connector/connector_base.pxd @@ -12,7 +12,8 @@ cdef class ConnectorBase(NetworkIterator): public bint _real_time_balance_update public dict _in_flight_orders_snapshot public double _in_flight_orders_snapshot_timestamp - public list _current_trade_fills + public set _current_trade_fills + public set _exchange_order_ids cdef str c_buy(self, str trading_pair, object amount, object order_type=*, object price=*, dict kwargs=*) cdef str c_sell(self, str trading_pair, object amount, object order_type=*, object price=*, dict kwargs=*) diff --git a/hummingbot/connector/connector_base.pyx b/hummingbot/connector/connector_base.pyx index f38584163f..3fb7288201 100644 --- a/hummingbot/connector/connector_base.pyx +++ b/hummingbot/connector/connector_base.pyx @@ -2,7 +2,8 @@ from decimal import Decimal from typing import ( Dict, List, - Tuple + Tuple, + Set, ) from hummingbot.core.data_type.cancellation_result import CancellationResult from hummingbot.core.event.events import ( @@ -13,10 +14,10 @@ from hummingbot.core.event.events import ( from hummingbot.core.event.event_logger import EventLogger from hummingbot.core.network_iterator import NetworkIterator from hummingbot.connector.in_flight_order_base import InFlightOrderBase +from hummingbot.connector.markets_recorder import TradeFill_order_details from hummingbot.core.event.events import OrderFilledEvent from hummingbot.client.config.global_config_map import global_config_map from hummingbot.core.utils.estimate_fee import estimate_fee -from hummingbot.model.trade_fill import TradeFill NaN = float("nan") s_decimal_NaN = Decimal("nan") @@ -55,7 +56,8 @@ cdef class ConnectorBase(NetworkIterator): # for _in_flight_orders_snapshot and _in_flight_orders_snapshot_timestamp when the update user balances. self._in_flight_orders_snapshot = {} # Dict[order_id:str, InFlightOrderBase] self._in_flight_orders_snapshot_timestamp = 0.0 - self._current_trade_fills = [] + self._current_trade_fills = set() + self._exchange_order_ids = set() @property def real_time_balance_update(self) -> bool: @@ -415,18 +417,23 @@ cdef class ConnectorBase(NetworkIterator): def available_balances(self) -> Dict[str, Decimal]: return self._account_available_balances - def add_trade_fills_from_market_recorder(self, current_trade_fills: List[TradeFill]): + def add_trade_fills_from_market_recorder(self, current_trade_fills: Set[TradeFill_order_details]): """ Gets updates from new records in TradeFill table. This is used in method is_confirmed_new_order_filled_event """ - self._current_trade_fills.extend(current_trade_fills) + self._current_trade_fills.update(current_trade_fills) - def is_confirmed_new_order_filled_event(self, exchange_trade_id: int, trading_pair: str): + def add_exchange_order_ids_from_market_recorder(self, current_exchange_order_ids: Set[str]): + """ + Gets updates from new orders in Order table. This is used in method connector _history_reconciliation + """ + self._exchange_order_ids.update(current_exchange_order_ids) + + def is_confirmed_new_order_filled_event(self, exchange_trade_id: str, trading_pair: str): """ Returns True if order to be filled is not already present in TradeFill entries. This is intended to avoid duplicated order fills in local DB. """ - # Assume (exchange_trade_id, trading_pair) are unique - return not any(tf for tf in self._current_trade_fills if (tf.market == self.display_name) and - (int(tf.exchange_trade_id) == exchange_trade_id) and - (tf.symbol == trading_pair)) + # Assume (market, exchange_trade_id, trading_pair) are unique. Also order has to be recorded in Order table + return (not TradeFill_order_details(self.display_name, exchange_trade_id, trading_pair) in self._current_trade_fills) and \ + (exchange_trade_id in self._exchange_order_ids) diff --git a/hummingbot/connector/exchange/binance/binance_exchange.pyx b/hummingbot/connector/exchange/binance/binance_exchange.pyx index fdff72ae6c..123fe7fec9 100755 --- a/hummingbot/connector/exchange/binance/binance_exchange.pyx +++ b/hummingbot/connector/exchange/binance/binance_exchange.pyx @@ -457,9 +457,7 @@ cdef class BinanceExchange(ExchangeBase): ) continue for trade in trades: - if self.is_confirmed_new_order_filled_event(trade["id"], trading_pair): - event_tag = self.MARKET_BUY_ORDER_CREATED_EVENT_TAG if trade["isBuyer"] \ - else self.MARKET_SELL_ORDER_CREATED_EVENT_TAG + if self.is_confirmed_new_order_filled_event(str(trade["id"]), trading_pair): client_order_id = get_client_order_id("buy" if trade["isBuyer"] else "sell", trading_pair) self.c_trigger_event(self.MARKET_ORDER_FILLED_EVENT_TAG, OrderFilledEvent( @@ -530,7 +528,7 @@ cdef class BinanceExchange(ExchangeBase): if tracked_order.is_done: if not tracked_order.is_failure: exchange_order_id = next(iter(tracked_order.trade_id_set)) - if self.is_confirmed_new_order_filled_event(exchange_order_id, + if self.is_confirmed_new_order_filled_event(str(exchange_order_id), tracked_order.trading_pair): if tracked_order.trade_type is TradeType.BUY: self.logger().info(f"The market buy order {tracked_order.client_order_id} has completed " @@ -648,7 +646,7 @@ cdef class BinanceExchange(ExchangeBase): order_filled_event = OrderFilledEvent.order_filled_event_from_binance_execution_report(event_message) order_filled_event = order_filled_event._replace(trading_pair=convert_from_exchange_trading_pair(order_filled_event.trading_pair)) exchange_order_id = next(iter(tracked_order.trade_id_set)) - if self.is_confirmed_new_order_filled_event(exchange_order_id, tracked_order.trading_pair): + if self.is_confirmed_new_order_filled_event(str(exchange_order_id), tracked_order.trading_pair): self.c_trigger_event(self.MARKET_ORDER_FILLED_EVENT_TAG, order_filled_event) else: self.logger().info( @@ -913,7 +911,8 @@ cdef class BinanceExchange(ExchangeBase): trading_pair, amount, price, - order_id + order_id, + exchange_order_id )) except asyncio.CancelledError: raise diff --git a/hummingbot/connector/markets_recorder.py b/hummingbot/connector/markets_recorder.py index e6141ea55d..16f013367f 100644 --- a/hummingbot/connector/markets_recorder.py +++ b/hummingbot/connector/markets_recorder.py @@ -8,12 +8,13 @@ ) import time import threading +from collections import namedtuple from typing import ( Dict, List, Optional, Tuple, - Union + Union, ) from hummingbot import data_path @@ -37,6 +38,8 @@ from hummingbot.model.sql_connection_manager import SQLConnectionManager from hummingbot.model.trade_fill import TradeFill +TradeFill_order_details = namedtuple("TradeFill_order_details", "market exchange_trade_id symbol") + class MarketsRecorder: market_event_tag_map: Dict[int, MarketEvent] = { @@ -59,7 +62,13 @@ def __init__(self, self._strategy_name: str = strategy_name # Internal collection of trade fills in connector will be used for remote/local history reconciliation for market in self._markets: - market.add_trade_fills_from_market_recorder(self.get_trades_for_config(self._config_file_path, 2000)) + trade_fills = self.get_trades_for_config(self._config_file_path, 2000) + market.add_trade_fills_from_market_recorder({TradeFill_order_details(tf.market, + tf.exchange_trade_id, + tf.symbol) for tf in trade_fills}) + + exchange_order_ids = self.get_orders_for_config_and_market(self._config_file_path, market, True, 2000) + market.add_exchange_order_ids_from_market_recorder({o.exchange_trade_id for o in exchange_order_ids}) self._create_order_forwarder: SourceInfoEventForwarder = SourceInfoEventForwarder(self._did_create_order) self._fill_order_forwarder: SourceInfoEventForwarder = SourceInfoEventForwarder(self._did_fill_order) @@ -109,14 +118,22 @@ def stop(self): for event_pair in self._event_pairs: market.remove_listener(event_pair[0], event_pair[1]) - def get_orders_for_config_and_market(self, config_file_path: str, market: ConnectorBase) -> List[Order]: + def get_orders_for_config_and_market(self, config_file_path: str, market: ConnectorBase, + with_exchange_trade_id_present: Optional[bool] = False, + number_of_rows: Optional[int] = None) -> List[Order]: session: Session = self.session + filters = [Order.config_file_path == config_file_path, + Order.market == market.display_name] + if with_exchange_trade_id_present: + filters.append(Order.exchange_trade_id.isnot(None)) query: Query = (session .query(Order) - .filter(Order.config_file_path == config_file_path, - Order.market == market.display_name) + .filter(*filters) .order_by(Order.creation_timestamp)) - return query.all() + if number_of_rows is None: + return query.all() + else: + return query.limit(number_of_rows).all() def get_trades_for_config(self, config_file_path: str, number_of_rows: Optional[int] = None) -> List[TradeFill]: session: Session = self.session @@ -186,13 +203,15 @@ def _did_create_order(self, amount=float(evt.amount), price=float(evt.price) if evt.price == evt.price else 0, last_status=event_type.name, - last_update_timestamp=timestamp) + last_update_timestamp=timestamp, + exchange_trade_id=evt.exchange_order_id) order_status: OrderStatus = OrderStatus(order=order_record, timestamp=timestamp, status=event_type.name) session.add(order_record) session.add(order_status) self.save_market_states(self._config_file_path, market, no_commit=True) + market.add_exchange_order_ids_from_market_recorder({evt.exchange_order_id}) session.commit() def _did_fill_order(self, @@ -238,7 +257,7 @@ def _did_fill_order(self, session.add(trade_fill_record) self.save_market_states(self._config_file_path, market, no_commit=True) session.commit() - market.add_trade_fills_from_market_recorder([trade_fill_record]) + market.add_trade_fills_from_market_recorder({TradeFill_order_details(trade_fill_record.market, trade_fill_record.exchange_trade_id, trade_fill_record.symbol)}) self.append_to_csv(trade_fill_record) def append_to_csv(self, trade: TradeFill): diff --git a/hummingbot/model/order.py b/hummingbot/model/order.py index 736a6b5055..c91a4859d8 100644 --- a/hummingbot/model/order.py +++ b/hummingbot/model/order.py @@ -40,6 +40,7 @@ class Order(HummingbotBase): price = Column(Float, nullable=False) last_status = Column(Text, nullable=False) last_update_timestamp = Column(BigInteger, nullable=False) + exchange_trade_id = Column(Text, nullable=True) status = relationship("OrderStatus", back_populates="order") trade_fills = relationship("TradeFill", back_populates="order") @@ -49,7 +50,8 @@ def __repr__(self) -> str: f"quote_asset='{self.quote_asset}', creation_timestamp={self.creation_timestamp}, " \ f"order_type='{self.order_type}', amount={self.amount}, " \ f"price={self.price}, last_status='{self.last_status}', " \ - f"last_update_timestamp={self.last_update_timestamp})" + f"last_update_timestamp={self.last_update_timestamp}), " \ + f"exchange_trade_id={self.exchange_trade_id}" @staticmethod def to_bounty_api_json(order: "Order") -> Dict[str, Any]: diff --git a/test/integration/test_binance_market.py b/test/integration/test_binance_market.py index d27421dc51..a09c47ab8e 100644 --- a/test/integration/test_binance_market.py +++ b/test/integration/test_binance_market.py @@ -172,7 +172,8 @@ def setUp(self): pass self.market_logger = EventLogger() - self.market._current_trade_fills = [] + self.market._current_trade_fills = set() + self.market._exchange_order_ids = set() self.ev_loop.run_until_complete(self.wait_til_ready()) for event_tag in self.events: self.market.add_listener(event_tag, self.market_logger) @@ -373,6 +374,7 @@ def place_order(self, is_buy, trading_pair, amount, order_type, price, nonce, fi else: order_id = self.market.sell(trading_pair, amount, order_type, price) if API_MOCK_ENABLED and fixture_ws_1 is not None and fixture_ws_2 is not None: + self.market.add_exchange_order_ids_from_market_recorder({str(fixture_ws_2['t'])}) data = self.fixture(fixture_ws_1, c=order_id) HummingWsServerFactory.send_json_threadsafe(self._ws_user_url, data, delay=0.1) data = self.fixture(fixture_ws_2, c=order_id) @@ -748,6 +750,7 @@ def test_history_reconciliation(self): 'isMaker': True, 'isBestMatch': True, }] + self.market.add_exchange_order_ids_from_market_recorder({buy_id}) self.web_app.update_response("get", self.base_api_url, "/api/v3/myTrades", binance_trades, params={'symbol': 'LINKETH'}) [market_order_completed] = self.run_parallel(self.market_logger.wait_for(OrderFilledEvent)) From 3bfd67f83a73c4a346dc8f4de80df58bf0ead8b8 Mon Sep 17 00:00:00 2001 From: Michael Borraccia Date: Fri, 15 Jan 2021 14:04:25 -0500 Subject: [PATCH 024/126] Add blocktane.io exchange connector --- .../connector/exchange/blocktane/__init__.py | 0 .../blocktane_active_order_tracker.pxd | 8 + .../blocktane_active_order_tracker.pyx | 84 ++ .../blocktane_api_order_book_data_source.py | 249 ++++ .../blocktane_api_user_stream_data_source.py | 106 ++ .../exchange/blocktane/blocktane_auth.py | 35 + .../exchange/blocktane/blocktane_exchange.pxd | 39 + .../exchange/blocktane/blocktane_exchange.pyx | 1042 +++++++++++++++++ .../blocktane/blocktane_in_flight_order.pxd | 5 + .../blocktane/blocktane_in_flight_order.pyx | 77 ++ .../blocktane/blocktane_order_book.pxd | 4 + .../blocktane/blocktane_order_book.pyx | 162 +++ .../blocktane/blocktane_order_book_tracker.py | 165 +++ .../blocktane_user_stream_tracker.py | 57 + .../exchange/blocktane/blocktane_utils.py | 55 + 15 files changed, 2088 insertions(+) create mode 100644 hummingbot/connector/exchange/blocktane/__init__.py create mode 100644 hummingbot/connector/exchange/blocktane/blocktane_active_order_tracker.pxd create mode 100644 hummingbot/connector/exchange/blocktane/blocktane_active_order_tracker.pyx create mode 100644 hummingbot/connector/exchange/blocktane/blocktane_api_order_book_data_source.py create mode 100644 hummingbot/connector/exchange/blocktane/blocktane_api_user_stream_data_source.py create mode 100644 hummingbot/connector/exchange/blocktane/blocktane_auth.py create mode 100644 hummingbot/connector/exchange/blocktane/blocktane_exchange.pxd create mode 100644 hummingbot/connector/exchange/blocktane/blocktane_exchange.pyx create mode 100644 hummingbot/connector/exchange/blocktane/blocktane_in_flight_order.pxd create mode 100644 hummingbot/connector/exchange/blocktane/blocktane_in_flight_order.pyx create mode 100644 hummingbot/connector/exchange/blocktane/blocktane_order_book.pxd create mode 100644 hummingbot/connector/exchange/blocktane/blocktane_order_book.pyx create mode 100644 hummingbot/connector/exchange/blocktane/blocktane_order_book_tracker.py create mode 100644 hummingbot/connector/exchange/blocktane/blocktane_user_stream_tracker.py create mode 100644 hummingbot/connector/exchange/blocktane/blocktane_utils.py diff --git a/hummingbot/connector/exchange/blocktane/__init__.py b/hummingbot/connector/exchange/blocktane/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/hummingbot/connector/exchange/blocktane/blocktane_active_order_tracker.pxd b/hummingbot/connector/exchange/blocktane/blocktane_active_order_tracker.pxd new file mode 100644 index 0000000000..26dc1aca02 --- /dev/null +++ b/hummingbot/connector/exchange/blocktane/blocktane_active_order_tracker.pxd @@ -0,0 +1,8 @@ +# distutils: language=c++ +cimport numpy as np + +cdef class BlocktaneActiveOrderTracker: + cdef dict _active_bids + cdef dict _active_asks + + cdef tuple c_convert_diff_message_to_np_arrays(self, object message) diff --git a/hummingbot/connector/exchange/blocktane/blocktane_active_order_tracker.pyx b/hummingbot/connector/exchange/blocktane/blocktane_active_order_tracker.pyx new file mode 100644 index 0000000000..2816aee93d --- /dev/null +++ b/hummingbot/connector/exchange/blocktane/blocktane_active_order_tracker.pyx @@ -0,0 +1,84 @@ +# 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") +BlocktaneOrderBookTrackingDictionary = Dict[Decimal, Dict[str, Dict[str, any]]] + +cdef class BlocktaneActiveOrderTracker: + def __init__(self, + active_asks: BlocktaneOrderBookTrackingDictionary = None, + active_bids: BlocktaneOrderBookTrackingDictionary = 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) -> BlocktaneOrderBookTrackingDictionary: + return self._active_asks + + @property + def active_bids(self) -> BlocktaneOrderBookTrackingDictionary: + return self._active_bids + + def get_rates_and_quantities(self, entry) -> tuple: + amount = 0 + if len(entry[1]) > 0: + amount = entry[1] + return float(entry[0]), float(amount) + + cdef tuple c_convert_diff_message_to_np_arrays(self, object message): + cdef: + dict content = message.content + list bid_entries = content["bids"] + list ask_entries = content["asks"] + str order_id + str order_side + str price_raw + object price + dict order_dict + double timestamp = message.timestamp + double quantity = 0 + + bids = s_empty_diff + asks = s_empty_diff + + if len(bid_entries) > 0: + bids = np.array( + [[timestamp, x[0], x[1], message.update_id] for x 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, x[0], x[1], message.update_id] for x in [self.get_rates_and_quantities(entry) for entry in ask_entries]], + dtype="float64", + ndmin=2 + ) + + return bids, asks + + 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): + pass 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 new file mode 100644 index 0000000000..fb038916be --- /dev/null +++ b/hummingbot/connector/exchange/blocktane/blocktane_api_order_book_data_source.py @@ -0,0 +1,249 @@ +#!/usr/bin/env python + +import asyncio +import aiohttp +import cachetools.func +from collections import namedtuple +from decimal import Decimal +import logging +import pandas as pd +from typing import ( + Any, + AsyncIterable, + Dict, + List, + Optional +) +import re +import requests +import time +import ujson +import websockets + +from hummingbot.core.data_type.order_book_tracker_data_source import OrderBookTrackerDataSource +from hummingbot.core.data_type.order_book_message import OrderBookMessage +from hummingbot.core.data_type.order_book import OrderBook +from hummingbot.logger import HummingbotLogger +from hummingbot.connector.exchange.blocktane.blocktane_order_book import BlocktaneOrderBook +from hummingbot.connector.exchange.blocktane.blocktane_utils import convert_to_exchange_trading_pair, convert_from_exchange_trading_pair + +BLOCKTANE_REST_URL = "https://trade.blocktane.io/api/v2/xt/public" +DIFF_STREAM_URL = "wss://trade.blocktane.io/api/v2/ws/public" +TICKER_PRICE_CHANGE_URL = "https://trade.blocktane.io/api/v2/xt/public/markets/tickers" +SINGLE_MARKET_DEPTH_URL = "https://trade.blocktane.io/api/v2/xt/public/markets/{}/depth" +EXCHANGE_INFO_URL = "https://trade.blocktane.io/api/v2/xt/public/markets" + +OrderBookRow = namedtuple("Book", ["price", "amount"]) + +API_CALL_TIMEOUT = 5 + + +class BlocktaneAPIOrderBookDataSource(OrderBookTrackerDataSource): + + MESSAGE_TIMEOUT = 30.0 + PING_TIMEOUT = 10.0 + + _baobds_logger: Optional[HummingbotLogger] = None + + @classmethod + def logger(cls) -> HummingbotLogger: + if cls._baobds_logger is None: + cls._baobds_logger = logging.getLogger(__name__) + return cls._baobds_logger + + def __init__(self, trading_pairs: Optional[List[str]] = None): + super().__init__(trading_pairs) + self._order_book_create_function = lambda: OrderBook() + + @classmethod + async def get_last_traded_prices(cls, trading_pairs: List[str]) -> Dict[str, float]: + async with aiohttp.ClientSession() as client: + resp = await client.get(TICKER_PRICE_CHANGE_URL) + resp_json = await resp.json() + + return {convert_from_exchange_trading_pair(market): float(data["ticker"]["last"]) for market, data in resp_json.items() + if convert_from_exchange_trading_pair(market) in trading_pairs} + + @property + 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: + async with aiohttp.ClientSession() as client: + async with client.get(EXCHANGE_INFO_URL, timeout=API_CALL_TIMEOUT) as response: + if response.status == 200: + data = await response.json() + raw_trading_pairs = [d["id"] for d in data if d["state"] == "enabled"] + trading_pair_list: List[str] = [] + for raw_trading_pair in raw_trading_pairs: + converted_trading_pair: Optional[str] = convert_from_exchange_trading_pair(raw_trading_pair) + if converted_trading_pair is not None: + trading_pair_list.append(converted_trading_pair) + return trading_pair_list + + except Exception: + # Do nothing if the request fails -- there will be no autocomplete for blocktane trading pairs + pass + + return [] + + @staticmethod + async def get_snapshot(client: aiohttp.ClientSession, trading_pair: str, limit: int = 1000) -> Dict[str, Any]: + request_url: str = f"{BLOCKTANE_REST_URL}/markets/{convert_to_exchange_trading_pair(trading_pair)}/depth" + + async with client.get(request_url) as response: + response: aiohttp.ClientResponse = response + if response.status != 200: + raise IOError(f"Error fetching blocktane market snapshot for {trading_pair}. " + f"HTTP status is {response.status}.") + + data: Dict[str, Any] = await response.json() + + # Need to add the symbol into the snapshot message for the Kafka message queue. + # Because otherwise, there'd be no way for the receiver to know which market the + # snapshot belongs to. + + return _prepare_snapshot(trading_pair, data["bids"], data["asks"]) + + async def get_new_order_book(self, trading_pair: str) -> OrderBook: + async with aiohttp.ClientSession() as client: + snapshot: Dict[str, Any] = await self.get_snapshot(client, trading_pair, 1000) + snapshot_timestamp: float = time.time() + snapshot_msg: OrderBookMessage = BlocktaneOrderBook.snapshot_message_from_exchange( + snapshot, + snapshot_timestamp, + metadata={"trading_pair": trading_pair} + ) + order_book: OrderBook = self.order_book_create_function() + order_book.apply_snapshot(snapshot_msg.bids, snapshot_msg.asks, snapshot_msg.update_id) + return order_book + + async def _inner_messages(self, + ws: websockets.WebSocketClientProtocol) -> AsyncIterable[str]: + # Terminate the recv() loop as soon as the next message timed out, so the outer loop can reconnect. + try: + while True: + try: + msg: str = await asyncio.wait_for(ws.recv(), timeout=self.MESSAGE_TIMEOUT) + yield msg + except asyncio.TimeoutError: + pong_waiter = await ws.ping() + await asyncio.wait_for(pong_waiter, timeout=self.PING_TIMEOUT) + except asyncio.TimeoutError: + self.logger().warning("WebSocket ping timed out. Going to reconnect...") + raise + except websockets.exceptions.ConnectionClosed: + raise + finally: + await ws.close() + + async def listen_for_trades(self, ev_loop: asyncio.BaseEventLoop, output: asyncio.Queue): + while True: + try: + ws_path: str = "&stream=".join([f"{convert_to_exchange_trading_pair(trading_pair)}.trades" for trading_pair in self._trading_pairs]) + stream_url: str = f"{DIFF_STREAM_URL}/?stream={ws_path}" + + async with websockets.connect(stream_url) as ws: + ws: websockets.WebSocketClientProtocol = ws + async for raw_msg in self._inner_messages(ws): + msg = ujson.loads(raw_msg) + if (list(msg.keys())[0].endswith("trades")): + trade_msg: OrderBookMessage = BlocktaneOrderBook.trade_message_from_exchange(msg) + output.put_nowait(trade_msg) + except asyncio.CancelledError: + raise + except Exception: + self.logger().error("Unexpected error with WebSocket connection. Retrying after 30 seconds...", + exc_info=True) + await asyncio.sleep(30.0) + + async def listen_for_order_book_diffs(self, ev_loop: asyncio.BaseEventLoop, output: asyncio.Queue): + while True: + try: + ws_path: str = "&stream=".join([f"{convert_to_exchange_trading_pair(trading_pair)}.ob-inc" for trading_pair in self._trading_pairs]) + stream_url: str = f"{DIFF_STREAM_URL}/?stream={ws_path}" + + async with websockets.connect(stream_url) as ws: + ws: websockets.WebSocketClientProtocol = ws + async for raw_msg in self._inner_messages(ws): + msg = ujson.loads(raw_msg) + key = list(msg.keys())[0] + if ('ob-inc' in key): + pair = re.sub(r'\.ob-inc', '', key) + parsed_msg = {"pair": convert_from_exchange_trading_pair(pair), + "bids": msg[key]["bids"] if "bids" in msg[key] else [], + "asks": msg[key]["asks"] if "asks" in msg[key] else []} + order_book_message: OrderBookMessage = BlocktaneOrderBook.diff_message_from_exchange(parsed_msg, time.time()) + output.put_nowait(order_book_message) + except asyncio.CancelledError: + raise + except Exception: + self.logger().error("Unexpected error with WebSocket connection. Retrying after 30 seconds...", + exc_info=True) + await asyncio.sleep(30.0) + + async def listen_for_order_book_snapshots(self, ev_loop: asyncio.BaseEventLoop, output: asyncio.Queue): + while True: + try: + async with aiohttp.ClientSession() as client: + for trading_pair in self._trading_pairs: + try: + snapshot: Dict[str, Any] = await self.get_snapshot(client, trading_pair) + snapshot_timestamp: float = time.time() + snapshot_msg: OrderBookMessage = BlocktaneOrderBook.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 blocktane's API rate limits. + await asyncio.sleep(5.0) + except asyncio.CancelledError: + raise + except Exception: + self.logger().error("Unexpected error.", exc_info=True) + await asyncio.sleep(5.0) + this_hour: pd.Timestamp = pd.Timestamp.utcnow().replace(minute=0, second=0, microsecond=0) + next_hour: pd.Timestamp = this_hour + pd.Timedelta(hours=1) + delta: float = next_hour.timestamp() - time.time() + await asyncio.sleep(delta) + except asyncio.CancelledError: + raise + except Exception: + self.logger().error("Unexpected error.", exc_info=True) + await asyncio.sleep(5.0) + + +def _prepare_snapshot(pair: str, bids: List, asks: List) -> Dict[str, Any]: + """ + Return structure of three elements: + symbol: traded pair symbol + bids: List of OrderBookRow for bids + asks: List of OrderBookRow for asks + """ + + format_bids = [OrderBookRow(i[0], i[1]) for i in bids] + format_asks = [OrderBookRow(i[0], i[1]) for i in asks] + + return { + "symbol": pair, + "bids": format_bids, + "asks": format_asks, + } diff --git a/hummingbot/connector/exchange/blocktane/blocktane_api_user_stream_data_source.py b/hummingbot/connector/exchange/blocktane/blocktane_api_user_stream_data_source.py new file mode 100644 index 0000000000..25e2b60673 --- /dev/null +++ b/hummingbot/connector/exchange/blocktane/blocktane_api_user_stream_data_source.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python + +import asyncio +import logging +import time +import ujson +import websockets +from typing import ( + AsyncIterable, + Dict, + Optional, + List +) + +from hummingbot.core.utils.async_utils import safe_ensure_future +from hummingbot.connector.exchange.blocktane.blocktane_auth import BlocktaneAuth +from hummingbot.core.data_type.user_stream_tracker_data_source import UserStreamTrackerDataSource +from hummingbot.logger import HummingbotLogger + +WS_BASE_URL = "wss://trade.blocktane.io/api/v2/ws/private/?stream=order&stream=trade&stream=balance" + + +class BlocktaneAPIUserStreamDataSource(UserStreamTrackerDataSource): + + MESSAGE_TIMEOUT = 30.0 + PING_TIMEOUT = 10.0 + + _bausds_logger: Optional[HummingbotLogger] = None + + @classmethod + def logger(cls) -> HummingbotLogger: + if cls._bausds_logger is None: + cls._bausds_logger = logging.getLogger(__name__) + return cls._bausds_logger + + def __init__(self, blocktane_auth: BlocktaneAuth, trading_pairs: Optional[List[str]] = None): + self._blocktane_auth: BlocktaneAuth = blocktane_auth + 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 + + @staticmethod + async def wait_til_next_tick(seconds: float = 1.0): + now: float = time.time() + current_tick: int = int(now // seconds) + delay_til_next_tick: float = (current_tick + 1) * seconds - now + await asyncio.sleep(delay_til_next_tick) + + async def listen_for_user_stream(self, ev_loop: asyncio.BaseEventLoop, output: asyncio.Queue): + while True: + try: + if self._listen_for_user_stream_task is not None: + self._listen_for_user_stream_task.cancel() + self._listen_for_user_stream_task = safe_ensure_future(self.log_user_stream(output)) + await self.wait_til_next_tick(seconds=60.0) + except asyncio.CancelledError: + raise + except Exception: + self.logger().error("Unexpected error while maintaining the user event listen key. Retrying after " + "5 seconds...", exc_info=True) + await asyncio.sleep(5) + + async def _inner_messages(self, ws) -> AsyncIterable[str]: + # Terminate the recv() loop as soon as the next message timed out, so the outer loop can reconnect. + try: + while True: + try: + msg: str = await asyncio.wait_for(ws.recv(), timeout=self.MESSAGE_TIMEOUT) + self._last_recv_time = time.time() + yield msg + except asyncio.TimeoutError: + pong_waiter = await ws.ping() + await asyncio.wait_for(pong_waiter, timeout=self.PING_TIMEOUT) + self._last_recv_time = time.time() + except asyncio.TimeoutError: + self.logger().warning("WebSocket ping timed out. Going to reconnect...") + raise + except websockets.exceptions.ConnectionClosed: + raise + finally: + await ws.close() + + async def messages(self) -> AsyncIterable[str]: + ws = await self.get_ws_connection() + while(True): + async for msg in self._inner_messages(ws): + yield msg + + def get_ws_connection(self): + ws = websockets.connect(WS_BASE_URL, extra_headers=self._blocktane_auth.generate_auth_dict()) + + return ws + + async def log_user_stream(self, output: asyncio.Queue): + while True: + try: + async for message in self.messages(): + decoded: Dict[str, any] = ujson.loads(message) + output.put_nowait(decoded) + except Exception: + self.logger().error("Unexpected error. Retrying after 5 seconds...", exc_info=True) + await asyncio.sleep(5.0) diff --git a/hummingbot/connector/exchange/blocktane/blocktane_auth.py b/hummingbot/connector/exchange/blocktane/blocktane_auth.py new file mode 100644 index 0000000000..38b2d44ac0 --- /dev/null +++ b/hummingbot/connector/exchange/blocktane/blocktane_auth.py @@ -0,0 +1,35 @@ +import hmac +import time +import hashlib + + +class BlocktaneAuth: + def __init__(self, api_key: str, secret_key: str): + self.api_key = api_key + self.secret_key = secret_key + + def generate_auth_dict(self) -> dict: + """ + Returns headers for authenticated api request. + """ + nonce = self.make_nonce() + signature = self.auth_sig(nonce) + + payload = { + "X-Auth-Apikey": self.api_key, + "X-Auth-Nonce": nonce, + "X-Auth-Signature": signature, + "Content-Type": "application/json" + } + return payload + + def make_nonce(self) -> str: + return str(round(time.time() * 1000)) + + def auth_sig(self, nonce: str) -> str: + sig = hmac.new( + self.secret_key.encode('utf8'), + '{}{}'.format(nonce, self.api_key).encode('utf8'), + hashlib.sha256 + ).hexdigest() + return sig diff --git a/hummingbot/connector/exchange/blocktane/blocktane_exchange.pxd b/hummingbot/connector/exchange/blocktane/blocktane_exchange.pxd new file mode 100644 index 0000000000..535e3a204b --- /dev/null +++ b/hummingbot/connector/exchange/blocktane/blocktane_exchange.pxd @@ -0,0 +1,39 @@ +from libc.stdint cimport int64_t + +from hummingbot.connector.exchange_base cimport ExchangeBase +from hummingbot.core.data_type.transaction_tracker cimport TransactionTracker + + +cdef class BlocktaneExchange(ExchangeBase): + cdef: + str _account_id + object _blocktane_auth + object _coro_queue + object _data_source_type + object _ev_loop + dict _in_flight_orders + double _last_timestamp + double _last_poll_timestamp + dict _order_not_found_records + object _user_stream_tracker + object _poll_notifier + double _poll_interval + dict _trading_rules + public object _coro_scheduler_task + public object _order_tracker_task + public object _shared_client + public object _status_polling_task + public object _trading_rules_polling_task + public object _user_stream_event_listener_task + public object _user_stream_tracker_task + TransactionTracker _tx_tracker + + cdef c_start_tracking_order(self, + str order_id, + str exchange_order_id, + str trading_pair, + object trade_type, + object order_type, + object price, + object amount) + cdef c_did_timeout_tx(self, str tracking_id) diff --git a/hummingbot/connector/exchange/blocktane/blocktane_exchange.pyx b/hummingbot/connector/exchange/blocktane/blocktane_exchange.pyx new file mode 100644 index 0000000000..8d1d9258cd --- /dev/null +++ b/hummingbot/connector/exchange/blocktane/blocktane_exchange.pyx @@ -0,0 +1,1042 @@ +import re +import time +import asyncio +import aiohttp +import copy +import logging +import pandas as pd +import simplejson +import traceback +from decimal import Decimal +from libc.stdint cimport int64_t +from threading import Lock +from async_timeout import timeout +from typing import Optional, List, Dict, Any, AsyncIterable, Tuple + + +from hummingbot.core.clock cimport Clock +from hummingbot.connector.exchange_base cimport ExchangeBase +from hummingbot.connector.exchange_base import NaN +from hummingbot.logger import HummingbotLogger +from hummingbot.connector.trading_rule cimport TradingRule +from hummingbot.core.network_iterator import NetworkStatus +from hummingbot.core.data_type.order_book cimport OrderBook +from hummingbot.core.data_type.limit_order import LimitOrder +from hummingbot.core.utils.tracking_nonce import get_tracking_nonce +from hummingbot.connector.exchange.blocktane.blocktane_auth import BlocktaneAuth +from hummingbot.core.data_type.transaction_tracker import TransactionTracker +from hummingbot.core.data_type.cancellation_result import CancellationResult +from hummingbot.core.utils.async_utils import safe_ensure_future, safe_gather +from hummingbot.core.event.events import ( + MarketEvent, + TradeFee, + OrderType, + OrderFilledEvent, + TradeType, + BuyOrderCompletedEvent, + SellOrderCompletedEvent, OrderCancelledEvent, MarketTransactionFailureEvent, + MarketOrderFailureEvent, SellOrderCreatedEvent, BuyOrderCreatedEvent +) +from hummingbot.client.config.fee_overrides_config_map import fee_overrides_config_map +from hummingbot.core.data_type.order_book_tracker import OrderBookTrackerDataSourceType +from hummingbot.connector.exchange.blocktane.blocktane_in_flight_order import BlocktaneInFlightOrder +from hummingbot.connector.exchange.blocktane.blocktane_order_book_tracker import BlocktaneOrderBookTracker +from hummingbot.connector.exchange.blocktane.blocktane_user_stream_tracker import BlocktaneUserStreamTracker +from hummingbot.connector.exchange.blocktane.blocktane_api_order_book_data_source import BlocktaneAPIOrderBookDataSource +from hummingbot.connector.exchange.blocktane.blocktane_utils import convert_from_exchange_trading_pair, convert_to_exchange_trading_pair, split_trading_pair + +bm_logger = None +s_decimal_0 = Decimal(0) + + +class BlocktaneAPIException(IOError): + def __init__(self, message, status_code = 0, malformed=False, body = None): + super().__init__(message) + self.status_code = status_code + self.malformed = malformed + self.body = body + + +cdef class BlocktaneExchangeTransactionTracker(TransactionTracker): + cdef: + BlocktaneExchange _owner + + def __init__(self, owner: BlocktaneExchange): + super().__init__() + self._owner = owner + + cdef c_did_timeout_tx(self, str tx_id): + TransactionTracker.c_did_timeout_tx(self, tx_id) + self._owner.c_did_timeout_tx(tx_id) + +cdef class BlocktaneExchange(ExchangeBase): + MARKET_RECEIVED_ASSET_EVENT_TAG = MarketEvent.ReceivedAsset.value + MARKET_BUY_ORDER_COMPLETED_EVENT_TAG = MarketEvent.BuyOrderCompleted.value + MARKET_SELL_ORDER_COMPLETED_EVENT_TAG = MarketEvent.SellOrderCompleted.value + MARKET_WITHDRAW_ASSET_EVENT_TAG = MarketEvent.WithdrawAsset.value + MARKET_ORDER_CANCELLED_EVENT_TAG = MarketEvent.OrderCancelled.value + MARKET_TRANSACTION_FAILURE_EVENT_TAG = MarketEvent.TransactionFailure.value + MARKET_ORDER_FAILURE_EVENT_TAG = MarketEvent.OrderFailure.value + MARKET_ORDER_FILLED_EVENT_TAG = MarketEvent.OrderFilled.value + MARKET_BUY_ORDER_CREATED_EVENT_TAG = MarketEvent.BuyOrderCreated.value + MARKET_SELL_ORDER_CREATED_EVENT_TAG = MarketEvent.SellOrderCreated.value + + API_CALL_TIMEOUT = 10.0 + UPDATE_ORDERS_INTERVAL = 10.0 + ORDER_NOT_EXIST_WAIT_TIME = 10.0 + + BLOCKTANE_API_ENDPOINT = "https://trade.blocktane.io/api/v2/xt" + + @classmethod + def logger(cls) -> HummingbotLogger: + global bm_logger + if bm_logger is None: + bm_logger = logging.getLogger(__name__) + return bm_logger + + def __init__(self, + blocktane_api_key: str, + blocktane_api_secret: str, + poll_interval: float = 5.0, + trading_pairs: Optional[List[str]] = None, + trading_required: bool = True): + super().__init__() + self._real_time_balance_update = True + self._account_id = "" + self._account_available_balances = {} + self._account_balances = {} + self._blocktane_auth = BlocktaneAuth(blocktane_api_key, blocktane_api_secret) + self._ev_loop = asyncio.get_event_loop() + self._in_flight_orders = {} + self._last_poll_timestamp = 0 + self._last_timestamp = 0 + self._order_book_tracker = BlocktaneOrderBookTracker(trading_pairs=trading_pairs) + self._order_not_found_records = {} + self._order_tracker_task = None + self._poll_notifier = asyncio.Event() + self._poll_interval = poll_interval + self._shared_client = None + self._status_polling_task = None + self._trading_required = trading_required + self._trading_rules = {} + self._trading_rules_polling_task = None + self._tx_tracker = BlocktaneExchangeTransactionTracker(self) + self._user_stream_event_listener_task = None + self._user_stream_tracker = BlocktaneUserStreamTracker(blocktane_auth=self._blocktane_auth, trading_pairs=trading_pairs) + self._user_stream_tracker_task = None + self._check_network_interval = 60.0 + + @staticmethod + def split_trading_pair(trading_pair: str) -> Optional[Tuple[str, str]]: + return split_trading_pair(trading_pair) + + @staticmethod + def convert_from_exchange_trading_pair(exchange_trading_pair: str) -> Optional[str]: + return convert_from_exchange_trading_pair(exchange_trading_pair) + + @staticmethod + def convert_to_exchange_trading_pair(hb_trading_pair: str) -> str: + return convert_to_exchange_trading_pair(hb_trading_pair) + + @property + def name(self) -> str: + return "blocktane" + + @property + def order_books(self) -> Dict[str, OrderBook]: + return self._order_book_tracker.order_books + + @property + def blocktane_auth(self) -> BlocktaneAuth: + return self._blocktane_auth + + @property + def status_dict(self) -> Dict[str, bool]: + return { + "order_book_initialized": self._order_book_tracker.ready, + "account_balance": len(self._account_balances) > 0 if self._trading_required else True, + "trading_rule_initialized": len(self._trading_rules) > 0 if self._trading_required else True + } + + @property + def ready(self) -> bool: + 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 { + key: value.to_json() + for key, value in self._in_flight_orders.items() + } + + def restore_tracking_states(self, saved_states: Dict[str, any]): + self._in_flight_orders.update({ + key: BlocktaneInFlightOrder.from_json(value) + for key, value in saved_states.items() + }) + + async def get_active_exchange_markets(self) -> pd.DataFrame: + return await BlocktaneAPIOrderBookDataSource.get_active_exchange_markets() + + cdef c_start(self, Clock clock, double timestamp): + self._tx_tracker.c_start(clock, timestamp) + ExchangeBase.c_start(self, clock, timestamp) + + cdef c_tick(self, double timestamp): + cdef: + int64_t last_tick = (self._last_timestamp / self._poll_interval) + int64_t current_tick = (timestamp / self._poll_interval) + + ExchangeBase.c_tick(self, timestamp) + self._tx_tracker.c_tick(timestamp) + if current_tick > last_tick: + if not self._poll_notifier.is_set(): + self._poll_notifier.set() + self._last_timestamp = timestamp + + cdef object c_get_fee(self, + str base_currency, + str quote_currency, + object order_type, + object order_side, + object amount, + object price): + # Fee info from https://trade.bolsacripto.com/api/v2/xt/public/trading_fees + cdef: + object maker_fee = Decimal(0.002) + object taker_fee = Decimal(0.002) + if order_type is OrderType.LIMIT and fee_overrides_config_map["blocktane_maker_fee"].value is not None: + return TradeFee(percent=fee_overrides_config_map["blocktane_maker_fee"].value) + if order_type is OrderType.MARKET and fee_overrides_config_map["blocktane_taker_fee"].value is not None: + return TradeFee(percent=fee_overrides_config_map["blocktane_taker_fee"].value) + + return TradeFee(percent=maker_fee if order_type is OrderType.LIMIT else taker_fee) + + async def _update_balances(self): + cdef: + dict account_info + list balances + str asset_name + set local_asset_names = set(self._account_balances.keys()) + set remote_asset_names = set() + set asset_names_to_remove + + path_url = "/account/balances" + account_balances = await self._api_request("GET", path_url=path_url) + + for balance_entry in account_balances: + asset_name = balance_entry["currency"].upper() + available_balance = Decimal(balance_entry["balance"]) + total_balance = available_balance + Decimal(balance_entry["locked"]) + self._account_available_balances[asset_name] = available_balance + self._account_balances[asset_name] = total_balance + remote_asset_names.add(asset_name) + + asset_names_to_remove = local_asset_names.difference(remote_asset_names) + for asset_name in asset_names_to_remove: + del self._account_available_balances[asset_name] + del self._account_balances[asset_name] + + def _format_trading_rules(self, market_dict: Dict[str, Dict[str, Any]]) -> List[TradingRule]: + cdef: + list trading_rules = [] + + for pair, info in market_dict.items(): + try: + trading_rules.append( + TradingRule(trading_pair=convert_from_exchange_trading_pair(info["id"]), + min_order_size=Decimal(info["min_amount"]), + min_price_increment=Decimal(f"1e-{info['price_precision']}"), + min_quote_amount_increment=Decimal(f"1e-{info['amount_precision']}"), + min_base_amount_increment=Decimal(f"1e-{info['amount_precision']}")) + ) + except Exception: + self.logger().error(f"Error parsing the trading pair rule {info}. Skipping.", exc_info=True) + return trading_rules + + async def _update_trading_rules(self): + cdef: + # The poll interval for withdraw rules is 60 seconds. + int64_t last_tick = (self._last_timestamp / 60.0) + int64_t current_tick = (self._current_timestamp / 60.0) + if current_tick > last_tick or len(self._trading_rules) <= 0: + market_path_url = "/public/markets" + ticker_path_url = "/public/markets/tickers" + + market_list = await self._api_request("GET", path_url=market_path_url) + + ticker_list = await self._api_request("GET", path_url=ticker_path_url) + ticker_data = {symbol: item['ticker'] for symbol, item in ticker_list.items()} + + result_list = { + market["id"]: {**market, **ticker_data[market["id"]]} + for market in market_list + if market["id"] in ticker_data + } + + trading_rules_list = self._format_trading_rules(result_list) + self._trading_rules.clear() + for trading_rule in trading_rules_list: + self._trading_rules[trading_rule.trading_pair] = trading_rule + + @property + def in_flight_orders(self) -> Dict[str, BlocktaneInFlightOrder]: + return self._in_flight_orders + + def supported_order_types(self): + return [OrderType.LIMIT, OrderType.MARKET] + + async def get_order(self, client_order_id: str) -> Dict[str, Any]: + # Used to retrieve a single order by client_order_id + path_url = f"/market/orders/{client_order_id}?client_id=true" + + return await self._api_request("GET", path_url=path_url) + + def issue_creation_event(self, exchange_order_id, tracked_order): + if tracked_order.exchange_order_id is not None: + # We've already issued this creation event + return + tracked_order.update_exchange_order_id(str(exchange_order_id)) + tracked_order.last_state = "PENDING" + if tracked_order.trade_type is TradeType.SELL: + cls = SellOrderCreatedEvent + tag = self.MARKET_SELL_ORDER_CREATED_EVENT_TAG + else: + cls = BuyOrderCreatedEvent + tag = self.MARKET_BUY_ORDER_CREATED_EVENT_TAG + self.c_trigger_event(tag, cls( + self._current_timestamp, + tracked_order.order_type, + tracked_order.trading_pair, + tracked_order.amount, + tracked_order.price, + tracked_order.client_order_id)) + self.logger().info(f"Created {tracked_order.order_type} {tracked_order.trade_type} {tracked_order.client_order_id} for " + f"{tracked_order.amount} {tracked_order.trading_pair}.") + + async def _update_order_status(self): + cdef: + # This is intended to be a backup measure to close straggler orders, in case Blocktane's user stream events + # are not capturing the updates as intended. Also handles filled events that are not captured by + # _user_stream_event_listener + # The poll interval for order status is 10 seconds. + int64_t last_tick = (self._last_poll_timestamp / self.UPDATE_ORDERS_INTERVAL) + int64_t current_tick = (self._current_timestamp / self.UPDATE_ORDERS_INTERVAL) + + try: + if current_tick > last_tick and len(self._in_flight_orders) > 0: + tracked_orders = list(self._in_flight_orders.values()) + for tracked_order in tracked_orders: + client_order_id = tracked_order.client_order_id + if tracked_order.last_state == "NEW" and tracked_order.created_at >= (int(time.time()) - self.ORDER_NOT_EXIST_WAIT_TIME): + continue # Don't query for orders that are waiting for a response from the API unless they are older then ORDER_NOT_EXIST_WAIT_TIME + try: + order = await self.get_order(client_order_id) + except BlocktaneAPIException as e: + if e.status_code == 404: + if (not e.malformed and e.body == 'record.not_found' and + tracked_order.created_at < (int(time.time()) - self.ORDER_NOT_EXIST_WAIT_TIME)): + # This was an indeterminate order that may or may not have been live on the exchange + # The exchange has informed us that this never became live on the exchange + self.c_trigger_event( + self.MARKET_ORDER_FAILURE_EVENT_TAG, + MarketOrderFailureEvent(self._current_timestamp, + client_order_id, + tracked_order.order_type) + ) + self.logger().warning( + f"Error fetching status update for the order {client_order_id}: " + f"{tracked_order}. Marking as failed current_timestamp={self._current_timestamp} created_at:{tracked_order.created_at}" + ) + self.c_stop_tracking_order(client_order_id) + continue + else: + self.logger().warning( + f"Error fetching status update for the order {client_order_id}:" + f" HTTP status: {e.status_code} {'malformed: ' + str(e.malformed) if e.malformed else e.body}. Will try again." + ) + continue + + if tracked_order.exchange_order_id is None: + # This was an indeterminate order that has not yet had a creation event issued + self.issue_creation_event(order["id"], tracked_order) + + order_state = order["state"] + order_type = "LIMIT" if tracked_order.order_type is OrderType.LIMIT else "MARKET" + trade_type = "BUY" if tracked_order.trade_type is TradeType.BUY else "SELL" + + order_type_description = tracked_order.order_type + executed_amount_diff = s_decimal_0 + avg_price = Decimal(order["avg_price"]) + new_confirmed_amount = Decimal(order["executed_volume"]) + executed_amount_base_diff = new_confirmed_amount - tracked_order.executed_amount_base + if executed_amount_base_diff > s_decimal_0: + self.logger().info(f"Updated order status with fill from polling _update_order_status: {simplejson.dumps(order)}") + new_confirmed_quote_amount = new_confirmed_amount * avg_price + executed_amount_quote_diff = new_confirmed_quote_amount - tracked_order.executed_amount_quote + executed_price = executed_amount_quote_diff / executed_amount_base_diff + + tracked_order.executed_amount_base = new_confirmed_amount + tracked_order.executed_amount_quote = new_confirmed_quote_amount + self.logger().info(f"Filled {executed_amount_base_diff} out of {tracked_order.amount} of the " + f"{order_type} order {tracked_order.client_order_id}.") + self.c_trigger_event(self.MARKET_ORDER_FILLED_EVENT_TAG, + OrderFilledEvent( + self._current_timestamp, + tracked_order.client_order_id, + tracked_order.trading_pair, + tracked_order.trade_type, + tracked_order.order_type, + executed_price, + executed_amount_base_diff, + self.c_get_fee( + tracked_order.base_asset, + tracked_order.quote_asset, + tracked_order.order_type, + tracked_order.trade_type, + executed_price, + executed_amount_base_diff + ) + )) + + if order_state == "done": + tracked_order.last_state = "done" + self.logger().info(f"The {order_type}-{trade_type} " + f"{client_order_id} has completed according to Blocktane order status API.") + + if tracked_order.trade_type is TradeType.BUY: + self.c_trigger_event(self.MARKET_BUY_ORDER_COMPLETED_EVENT_TAG, + BuyOrderCompletedEvent( + self._current_timestamp, + tracked_order.client_order_id, + tracked_order.base_asset, + tracked_order.quote_asset, + tracked_order.fee_asset or tracked_order.base_asset, + tracked_order.executed_amount_base, + tracked_order.executed_amount_quote, + tracked_order.fee_paid, + tracked_order.order_type)) + elif tracked_order.trade_type is TradeType.SELL: + self.c_trigger_event(self.MARKET_SELL_ORDER_COMPLETED_EVENT_TAG, + SellOrderCompletedEvent( + self._current_timestamp, + tracked_order.client_order_id, + tracked_order.base_asset, + tracked_order.quote_asset, + tracked_order.fee_asset or tracked_order.base_asset, + tracked_order.executed_amount_base, + tracked_order.executed_amount_quote, + tracked_order.fee_paid, + tracked_order.order_type)) + else: + raise ValueError("Invalid trade_type for {client_order_id}: {tracked_order.trade_type}") + self.c_stop_tracking_order(client_order_id) + + if order_state == "cancel": + tracked_order.last_state = "cancel" + self.logger().info(f"The {tracked_order.order_type}-{tracked_order.trade_type} " + f"{client_order_id} has been cancelled according to Blocktane order status API.") + self.c_trigger_event(self.MARKET_ORDER_CANCELLED_EVENT_TAG, + OrderCancelledEvent( + self._current_timestamp, + client_order_id + )) + self.c_stop_tracking_order(client_order_id) + + if order_state == 'reject': + tracked_order.last_state = order_state + self.c_trigger_event( + self.MARKET_ORDER_FAILURE_EVENT_TAG, + MarketOrderFailureEvent(self._current_timestamp, + client_order_id, + tracked_order.order_type) + ) + self.logger().info(f"The {tracked_order.order_type}-{tracked_order.trade_type} " + f"{client_order_id} has been rejected according to Blocktane order status API.") + self.c_stop_tracking_order(client_order_id) + + except Exception as e: + self.logger().error("Update Order Status Error: " + str(e) + " " + str(e.__cause__)) + + async def _iter_user_stream_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().error("Unknown error. Retrying after 1 second.", exc_info=True) + await asyncio.sleep(1.0) + + async def _user_stream_event_listener(self): + async for stream_message in self._iter_user_stream_queue(): + try: + if 'balance' in stream_message: # Updates balances + balance_updates = stream_message['balance'] + for update in balance_updates: + available_balance: Decimal = Decimal(update['balance']) + total_balance: Decimal = available_balance + Decimal(update['locked']) + asset_name = update["currency"].upper() + self._account_available_balances[asset_name] = available_balance + self._account_balances[asset_name] = total_balance + + elif 'order' in stream_message: # Updates tracked orders + order = stream_message.get('order') + order_status = order["state"] + + order_id = order["client_id"] + + in_flight_orders = self._in_flight_orders.copy() + tracked_order = in_flight_orders.get(order_id) + + if tracked_order is None: + self.logger().debug(f"Unrecognized order ID from user stream: {order_id}.") + continue + + if tracked_order.exchange_order_id is None: + # This was an indeterminate order that has not yet had a creation event issued + self.issue_creation_event(order["id"], tracked_order) + + order_type = tracked_order.order_type + executed_amount_diff = s_decimal_0 + avg_price = Decimal(order["avg_price"]) + new_confirmed_amount = Decimal(order["executed_volume"]) + executed_amount_base_diff = new_confirmed_amount - tracked_order.executed_amount_base + if executed_amount_base_diff > s_decimal_0: + new_confirmed_quote_amount = new_confirmed_amount * avg_price + executed_amount_quote_diff = new_confirmed_quote_amount - tracked_order.executed_amount_quote + executed_price = executed_amount_quote_diff / executed_amount_base_diff + + tracked_order.executed_amount_base = new_confirmed_amount + tracked_order.executed_amount_quote = new_confirmed_quote_amount + tracked_order.last_state = order_status + self.logger().info(f"Filled {executed_amount_base_diff} out of {tracked_order.amount} of the " + f"{order_type} order {tracked_order.client_order_id}.") + self.c_trigger_event(self.MARKET_ORDER_FILLED_EVENT_TAG, + OrderFilledEvent( + self._current_timestamp, + tracked_order.client_order_id, + tracked_order.trading_pair, + tracked_order.trade_type, + tracked_order.order_type, + executed_price, + executed_amount_base_diff, + self.c_get_fee( + tracked_order.base_asset, + tracked_order.quote_asset, + tracked_order.order_type, + tracked_order.trade_type, + executed_price, + executed_amount_base_diff + ) + )) + + if order_status == "done": # FILL(COMPLETE) + self.logger().info(f"The order " + f"{tracked_order.client_order_id} has completed according to Blocktane User stream.") + tracked_order.last_state = "done" + if tracked_order.trade_type is TradeType.BUY: + self.logger().info(f"The LIMIT_BUY order {tracked_order.client_order_id} has completed " + f"according to Blocktane websocket API.") + self.c_trigger_event(self.MARKET_BUY_ORDER_COMPLETED_EVENT_TAG, + BuyOrderCompletedEvent( + self._current_timestamp, + tracked_order.client_order_id, + tracked_order.base_asset, + tracked_order.quote_asset, + tracked_order.fee_asset or tracked_order.quote_asset, + tracked_order.executed_amount_base, + tracked_order.executed_amount_quote, + tracked_order.fee_paid, + tracked_order.order_type + )) + elif tracked_order.trade_type is TradeType.SELL: + self.logger().info(f"The LIMIT_SELL order {tracked_order.client_order_id} has completed " + f"according to Blocktane WebSocket API.") + self.c_trigger_event(self.MARKET_SELL_ORDER_COMPLETED_EVENT_TAG, + SellOrderCompletedEvent( + self._current_timestamp, + tracked_order.client_order_id, + tracked_order.base_asset, + tracked_order.quote_asset, + tracked_order.fee_asset or tracked_order.quote_asset, + tracked_order.executed_amount_base, + tracked_order.executed_amount_quote, + tracked_order.fee_paid, + tracked_order.order_type + )) + else: + raise ValueError("Invalid trade_type for {tracked_order.client_order_id}: {tracked_order.trade_type}") + self.c_stop_tracking_order(tracked_order.client_order_id) + continue + + if order_status == "cancel": # CANCEL + + self.logger().info(f"The order {tracked_order.client_order_id} has been cancelled " + f"according to Blocktane WebSocket API.") + tracked_order.last_state = "cancel" + self.c_trigger_event(self.MARKET_ORDER_CANCELLED_EVENT_TAG, + OrderCancelledEvent(self._current_timestamp, + tracked_order.client_order_id)) + self.c_stop_tracking_order(tracked_order.client_order_id) + + if order_status == 'reject': + tracked_order.last_state = order_status + self.c_trigger_event( + self.MARKET_ORDER_FAILURE_EVENT_TAG, + MarketOrderFailureEvent(self._current_timestamp, + tracked_order.client_order_id, + tracked_order.order_type) + ) + self.logger().info(f"The order {tracked_order.client_order_id} has been rejected " + f"according to Blocktane WebSocket API.") + self.c_stop_tracking_order(tracked_order.client_order_id) + + else: + # Ignores all other user stream message types + continue + + except asyncio.CancelledError: + raise + except Exception as e: + self.logger().error("Unexpected error in user stream listener loop. {e}", exc_info=True) + await asyncio.sleep(5.0) + + async def _status_polling_loop(self): + while True: + try: + self._poll_notifier = asyncio.Event() + await self._poll_notifier.wait() + + await safe_gather( + self._update_balances(), + self._update_order_status(), + ) + self._last_poll_timestamp = self._current_timestamp + except asyncio.CancelledError: + raise + except Exception as e: + self.logger().warning(f"Unexpected error while polling updates. {e}", + exc_info=True) + await asyncio.sleep(5.0) + + async def _trading_rules_polling_loop(self): + while True: + try: + await self._update_trading_rules() + await asyncio.sleep(60 * 5) + except asyncio.CancelledError: + raise + except Exception as e: + self.logger().warning(f"Unexpected error while fetching trading rule updates. {e}", + exc_info=True) + await asyncio.sleep(0.5) + + cdef OrderBook c_get_order_book(self, str trading_pair): + cdef: + dict order_books = self._order_book_tracker.order_books + + if trading_pair not in order_books: + raise ValueError(f"No order book exists for '{trading_pair}'.") + return order_books[trading_pair] + + cdef c_start_tracking_order(self, + str order_id, + str exchange_order_id, + str trading_pair, + object order_type, + object trade_type, + object price, + object amount): + self._in_flight_orders[order_id] = BlocktaneInFlightOrder( + order_id, + exchange_order_id, + trading_pair, + order_type, + trade_type, + price, + amount, + int(time.time()) + ) + + cdef c_stop_tracking_order(self, str order_id): + if order_id in self._in_flight_orders: + del self._in_flight_orders[order_id] + + cdef c_did_timeout_tx(self, str tracking_id): + self.c_trigger_event(self.MARKET_TRANSACTION_FAILURE_EVENT_TAG, + MarketTransactionFailureEvent(self._current_timestamp, tracking_id)) + + cdef object c_get_order_price_quantum(self, str trading_pair, object price): + cdef: + TradingRule trading_rule = self._trading_rules[trading_pair] + return Decimal(trading_rule.min_price_increment) + + cdef object c_get_order_size_quantum(self, str trading_pair, object order_size): + cdef: + TradingRule trading_rule = self._trading_rules[trading_pair] + return Decimal(trading_rule.min_base_amount_increment) + + cdef object c_quantize_order_amount(self, str trading_pair, object amount, object price=0.0): + cdef: + TradingRule trading_rule = self._trading_rules[trading_pair] + object quantized_amount = ExchangeBase.c_quantize_order_amount(self, trading_pair, amount) + + global s_decimal_0 + if quantized_amount < trading_rule.min_order_size: + return s_decimal_0 + + if quantized_amount < trading_rule.min_order_value: + return s_decimal_0 + + return quantized_amount + + async def place_order(self, + order_id: str, + trading_pair: str, + amount: Decimal, + is_buy: bool, + order_type: OrderType, + price: Decimal) -> Dict[str, Any]: + path_url = "/market/orders" + + params = {} + if order_type is OrderType.LIMIT: # Blocktane supports CEILING_LIMIT + params = { + "market": convert_to_exchange_trading_pair(trading_pair), + "side": "buy" if is_buy else "sell", + "volume": f"{amount:f}", + "ord_type": "limit", + "price": f"{price:f}", + "client_id": str(order_id) + } + elif order_type is OrderType.MARKET: + params = { + "market": convert_to_exchange_trading_pair(trading_pair), + "side": "buy" if is_buy else "sell", + "volume": str(amount), + "ord_type": "market", + "client_id": str(order_id) + } + + self.logger().info(f"Requesting order placement for {order_id} at {self._current_timestamp}") + api_response = await self._api_request("POST", path_url=path_url, params=params) + return api_response + + async def execute_buy(self, + order_id: str, + trading_pair: str, + amount: Decimal, + order_type: OrderType, + price: Optional[Decimal] = s_decimal_0): + cdef: + TradingRule trading_rule = self._trading_rules[trading_pair] + double quote_amount + object decimal_amount + object decimal_price + str exchange_order_id + object tracked_order + + decimal_amount = self.c_quantize_order_amount(trading_pair, amount) + decimal_price = (self.c_quantize_order_price(trading_pair, price) + if order_type is OrderType.LIMIT + else s_decimal_0) + + if decimal_amount < trading_rule.min_order_size: + raise ValueError(f"Buy order amount {decimal_amount} is lower than the minimum order size " + f"{trading_rule.min_order_size}.") + + try: + order_result = None + self.c_start_tracking_order( + order_id, + None, + trading_pair, + order_type, + TradeType.BUY, + decimal_price, + decimal_amount, + ) + + if order_type is OrderType.LIMIT: + order_result = await self.place_order(order_id, + trading_pair, + decimal_amount, + True, + order_type, + decimal_price) + elif order_type is OrderType.MARKET: + decimal_price = self.c_get_price(trading_pair, True) + order_result = await self.place_order(order_id, + trading_pair, + decimal_amount, + True, + order_type, + decimal_price) + + else: + raise ValueError(f"Invalid OrderType {order_type}. Aborting.") + + exchange_order_id = str(order_result.get("id")) + tracked_order = self._in_flight_orders.get(order_id) + if tracked_order is not None and exchange_order_id is not None: + self.issue_creation_event(exchange_order_id, tracked_order) + else: + self.logger().error(f"Unable to issue creation event for {order_id}: {tracked_order} {exchange_order_id}") + except asyncio.TimeoutError: + self.logger().error(f"Network timout while submitting order {order_id} to Blocktane. Order will be recovered.") + except Exception: + self.logger().error( + f"Error submitting {order_id}: buy {order_type} order to Blocktane for " + f"{decimal_amount} {trading_pair} " + f"{decimal_price if order_type.is_limit_type() else ''}.", + exc_info=True + ) + tracked_order = self._in_flight_orders.get(order_id) + tracked_order.last_state = "FAILURE" + tracked_order.update_exchange_order_id("0") # prevents deadlock on get_exchange_order_id() + self.c_stop_tracking_order(order_id) + self.c_trigger_event(self.MARKET_ORDER_FAILURE_EVENT_TAG, MarketOrderFailureEvent(self._current_timestamp, order_id, order_type)) + + cdef str c_buy(self, + str trading_pair, + object amount, + object order_type=OrderType.LIMIT, + object price=NaN, + dict kwargs={}): + cdef: + int64_t tracking_nonce = get_tracking_nonce() + str order_id = str(f"buy-{trading_pair}-{tracking_nonce}") + safe_ensure_future(self.execute_buy(order_id, trading_pair, amount, order_type, price)) + return order_id + + async def execute_sell(self, + order_id: str, + trading_pair: str, + amount: Decimal, + order_type: OrderType = OrderType.LIMIT, + price: Optional[Decimal] = NaN): + cdef: + TradingRule trading_rule = self._trading_rules[trading_pair] + double quote_amount + object decimal_amount + object decimal_price + str exchange_order_id + object tracked_order + + decimal_amount = self.c_quantize_order_amount(trading_pair, amount) + decimal_price = (self.c_quantize_order_price(trading_pair, price) + if order_type is OrderType.LIMIT + else s_decimal_0) + + if decimal_amount < trading_rule.min_order_size: + raise ValueError(f"Sell order amount {decimal_amount} is lower than the minimum order size " + f"{trading_rule.min_order_size}") + + try: + order_result = None + + self.c_start_tracking_order( + order_id, + None, + trading_pair, + order_type, + TradeType.SELL, + decimal_price, + decimal_amount, + ) + + if order_type is OrderType.LIMIT: + order_result = await self.place_order(order_id, + trading_pair, + decimal_amount, + False, + order_type, + decimal_price) + elif order_type is OrderType.MARKET: + decimal_price = self.c_get_price(trading_pair, False) + order_result = await self.place_order(order_id, + trading_pair, + decimal_amount, + False, + order_type, + decimal_price) + else: + raise ValueError(f"Invalid OrderType {order_type}. Aborting.") + + exchange_order_id = str(order_result.get("id")) + tracked_order = self._in_flight_orders.get(order_id) + if tracked_order is not None and exchange_order_id is not None: + self.issue_creation_event(exchange_order_id, tracked_order) + else: + self.logger().error(f"Unable to issue creation event for {order_id}: {tracked_order} {exchange_order_id}") + except asyncio.TimeoutError: + self.logger().error(f"Network timout while submitting order {order_id} to Blocktane. Order will be recovered.") + except Exception: + self.logger().error( + f"Error submitting {order_id}: sell {order_type} order to Blocktane for " + f"{decimal_amount} {trading_pair} " + f"{decimal_price if order_type.is_limit_type() else ''}.", + exc_info=True, + ) + tracked_order = self._in_flight_orders.get(order_id) + tracked_order.last_state = "FAILURE" + tracked_order.update_exchange_order_id("0") # prevents deadlock on get_exchange_order_id() + self.c_stop_tracking_order(order_id) + self.c_trigger_event(self.MARKET_ORDER_FAILURE_EVENT_TAG, MarketOrderFailureEvent(self._current_timestamp, order_id, order_type)) + + cdef str c_sell(self, + str trading_pair, + object amount, + object order_type=OrderType.MARKET, + object price=0.0, + dict kwargs={}): + cdef: + int64_t tracking_nonce = get_tracking_nonce() + str order_id = str(f"sell-{trading_pair}-{tracking_nonce}") + + safe_ensure_future(self.execute_sell(order_id, trading_pair, amount, order_type, price)) + return order_id + + async def execute_cancel(self, trading_pair: str, order_id: str): + try: + tracked_order = self._in_flight_orders.get(order_id) + if tracked_order is None: + self.logger().error(f"The order {order_id} is not tracked. ") + raise ValueError + path_url = f"/market/orders/{order_id}/cancel?client_id=true" + + cancel_result = await self._api_request("POST", path_url=path_url) + self.logger().info(f"Requested cancel of order {order_id}") + + # TODO: this cancel result looks like: + # {"id":4699083,"uuid":"2421ceb6-a8b7-445b-9ca9-0f7f1a05e285","side":"buy","ord_type":"limit","price":"0.02","avg_price":"0.0","state":"cancel","market":"ethbtc","created_at":"2020-09-09T18:53:56+02:00","updated_at":"2020-09-09T18:56:41+02:00","origin_volume":"1.0","remaining_volume":"1.0","executed_volume":"0.0","trades_count":0} + # and should be used as an order status update + + return order_id + except asyncio.CancelledError: + raise + except Exception as err: + if ("record.not_found" in str(err) and tracked_order is not None and + tracked_order.created_at < (int(time.time()) - self.ORDER_NOT_EXIST_WAIT_TIME)): + # The order doesn't exist + self.logger().info(f"The order {order_id} does not exist on Blocktane. Marking as cancelled.") + self.c_stop_tracking_order(order_id) + self.c_trigger_event(self.MARKET_ORDER_CANCELLED_EVENT_TAG, + OrderCancelledEvent(self._current_timestamp, order_id)) + return order_id + + self.logger().error( + f"Failed to cancel order {order_id}: {str(err)}.", + exc_info=True + ) + return None + + cdef c_cancel(self, str trading_pair, str order_id): + safe_ensure_future(self.execute_cancel(trading_pair, order_id)) + return order_id + + async def cancel_all(self, timeout_seconds: float) -> List[CancellationResult]: + incomplete_orders = [order for order in self._in_flight_orders.values() if not order.is_done] + tasks = [self.execute_cancel(o.trading_pair, o.client_order_id) for o in incomplete_orders] + order_id_set = set([o.client_order_id for o in incomplete_orders]) + successful_cancellation = [] + + try: + async with timeout(timeout_seconds): + api_responses = await safe_gather(*tasks, return_exceptions=True) + for res in api_responses: + order_id_set.remove(res) + successful_cancellation.append(CancellationResult(res, True)) + except Exception as e: + self.logger().error( + f"Unexpected error cancelling orders. {e}" + ) + + failed_cancellation = [CancellationResult(oid, False) for oid in order_id_set] + return successful_cancellation + failed_cancellation + + async def _http_client(self) -> aiohttp.ClientSession: + if self._shared_client is None: + self._shared_client = aiohttp.ClientSession() + return self._shared_client + + async def _api_request(self, + http_method: str, + path_url: str = None, + params: Dict[str, any] = None, + body: Dict[str, any] = None, + subaccount_id: str = '') -> Dict[str, Any]: + assert path_url is not None + url = f"{self.BLOCKTANE_API_ENDPOINT}{path_url}" + + content_type = "application/json" if http_method == "post" else "application/x-www-form-urlencoded" + headers = {"Content-Type": content_type} + headers = self.blocktane_auth.generate_auth_dict() + + client = await self._http_client() + async with client.request(http_method, + url=url, + headers=headers, + params=params, + data=body, + timeout=self.API_CALL_TIMEOUT) as response: + + try: + data = await response.json(content_type=None) + except Exception as e: + raise BlocktaneAPIException(f"Malformed response. Expected JSON got:{await response.text()}", + status_code=response.status, + malformed=True) + + if response.status not in [200, 201]: # HTTP Response code of 20X generally means it is successful + try: + error_msg = data['errors'][0] + except Exception: + error_msg = await response.text() + raise BlocktaneAPIException(f"Error fetching response from {http_method}-{url}. HTTP Status Code {response.status}: " + f"{error_msg}", status_code=response.status, body=error_msg) + + return data + + async def check_network(self) -> NetworkStatus: + try: + await self._api_request("GET", path_url="/public/health/alive") + except asyncio.CancelledError: + raise + except Exception: + return NetworkStatus.NOT_CONNECTED + return NetworkStatus.CONNECTED + + def _stop_network(self): + if self._order_tracker_task is not None: + self._order_tracker_task.cancel() + if self._status_polling_task is not None: + self._status_polling_task.cancel() + if self._user_stream_tracker_task is not None: + self._user_stream_tracker_task.cancel() + if self._user_stream_event_listener_task is not None: + self._user_stream_event_listener_task.cancel() + self._order_tracker_task = self._status_polling_task = self._user_stream_tracker_task = \ + self._user_stream_event_listener_task = None + + async def stop_network(self): + self._stop_network() + + async def start_network(self): + if self._order_tracker_task is not None: + self._stop_network() + + self._order_tracker_task = safe_ensure_future(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()) diff --git a/hummingbot/connector/exchange/blocktane/blocktane_in_flight_order.pxd b/hummingbot/connector/exchange/blocktane/blocktane_in_flight_order.pxd new file mode 100644 index 0000000000..083d624a59 --- /dev/null +++ b/hummingbot/connector/exchange/blocktane/blocktane_in_flight_order.pxd @@ -0,0 +1,5 @@ +from hummingbot.connector.in_flight_order_base cimport InFlightOrderBase + +cdef class BlocktaneInFlightOrder(InFlightOrderBase): + cdef: + public long long created_at diff --git a/hummingbot/connector/exchange/blocktane/blocktane_in_flight_order.pyx b/hummingbot/connector/exchange/blocktane/blocktane_in_flight_order.pyx new file mode 100644 index 0000000000..4b85b7c3e4 --- /dev/null +++ b/hummingbot/connector/exchange/blocktane/blocktane_in_flight_order.pyx @@ -0,0 +1,77 @@ +import logging +from decimal import Decimal +from typing import ( + Any, + Dict, + Optional +) + +from hummingbot.core.event.events import ( + OrderType, + TradeType +) +from hummingbot.connector.exchange.blocktane.blocktane_exchange import BlocktaneExchange +from hummingbot.connector.in_flight_order_base import InFlightOrderBase + +s_decimal_0 = Decimal(0) + + +cdef class BlocktaneInFlightOrder(InFlightOrderBase): + def __init__(self, + client_order_id: str, + exchange_order_id: str, + trading_pair: str, + order_type: OrderType, + trade_type: TradeType, + price: Decimal, + amount: Decimal, + created_at: int, + initial_state: str = "NEW"): + super().__init__( + client_order_id, + exchange_order_id, + trading_pair, + order_type, + trade_type, + price, + amount, + initial_state + ) + self.created_at = created_at + + def to_json(self) -> Dict[str, Any]: + response = super().to_json() + response["created_at"] = self.created_at + return response + + @property + def is_done(self) -> bool: + return self.last_state in {"FILLED", "CANCELED", "PENDING_CANCEL", "REJECTED", "EXPIRED"} + + @property + def is_failure(self) -> bool: + return self.last_state in {"CANCELED", "PENDING_CANCEL", "REJECTED", "EXPIRED"} + + @property + def is_cancelled(self) -> bool: + return self.last_state in {"CANCELED"} + + @classmethod + def from_json(cls, data: Dict[str, Any]) -> InFlightOrderBase: + cdef: + BlocktaneInFlightOrder retval = BlocktaneInFlightOrder( + client_order_id=data["client_order_id"], + exchange_order_id=data["exchange_order_id"], + trading_pair=data["trading_pair"], + order_type=getattr(OrderType, data["order_type"]), + trade_type=getattr(TradeType, data["trade_type"]), + price=Decimal(data["price"]), + amount=Decimal(data["amount"]), + created_at=data["created_at"], + initial_state=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"]) + return retval diff --git a/hummingbot/connector/exchange/blocktane/blocktane_order_book.pxd b/hummingbot/connector/exchange/blocktane/blocktane_order_book.pxd new file mode 100644 index 0000000000..ec9ce9ca4b --- /dev/null +++ b/hummingbot/connector/exchange/blocktane/blocktane_order_book.pxd @@ -0,0 +1,4 @@ +from hummingbot.core.data_type.order_book cimport OrderBook + +cdef class BlocktaneOrderBook(OrderBook): + pass diff --git a/hummingbot/connector/exchange/blocktane/blocktane_order_book.pyx b/hummingbot/connector/exchange/blocktane/blocktane_order_book.pyx new file mode 100644 index 0000000000..2752bcf4a4 --- /dev/null +++ b/hummingbot/connector/exchange/blocktane/blocktane_order_book.pyx @@ -0,0 +1,162 @@ +#!/usr/bin/env python +import logging +from typing import ( + Dict, + Optional +) +import ujson + +from aiokafka import ConsumerRecord +from sqlalchemy.engine import RowProxy + +from hummingbot.logger import HummingbotLogger +from hummingbot.core.event.events import TradeType +from hummingbot.core.data_type.order_book cimport OrderBook +from hummingbot.core.data_type.order_book_message import ( + OrderBookMessage, + OrderBookMessageType +) +from hummingbot.connector.exchange.blocktane.blocktane_utils import convert_to_exchange_trading_pair, convert_from_exchange_trading_pair + +_bob_logger = None + +cdef class BlocktaneOrderBook(OrderBook): + @classmethod + def logger(cls) -> HummingbotLogger: + global _bob_logger + if _bob_logger is None: + _bob_logger = logging.getLogger(__name__) + return _bob_logger + + @classmethod + def snapshot_message_from_exchange(cls, + msg: Dict[str, any], + timestamp: float, + metadata: Optional[Dict] = None) -> OrderBookMessage: + if metadata: + msg.update(metadata) + return OrderBookMessage(OrderBookMessageType.SNAPSHOT, { + "trading_pair": msg["trading_pair"], + "update_id": timestamp, # not sure whether this is correct + "bids": msg["bids"], + "asks": msg["asks"] + }, timestamp=timestamp) + + @classmethod + def diff_message_from_exchange(cls, + msg: Dict[str, any], + timestamp: Optional[float] = None, + metadata: Optional[Dict] = None) -> OrderBookMessage: + def adjust_empty_amounts(levels): + result = [] + for level in levels: + if len(level) == 0: + continue + if len(level) < 2 or level[1] == '': + result.append([level[0], "0"]) + else: + result.append(level) + return result + + if metadata: + msg.update(metadata) + return OrderBookMessage(OrderBookMessageType.DIFF, { + "trading_pair": msg["pair"], + "update_id": timestamp, + "bids": adjust_empty_amounts([msg["bids"]]), + "asks": adjust_empty_amounts([msg["asks"]]) + }, timestamp=timestamp) + + @classmethod + def snapshot_message_from_db(cls, record: RowProxy, metadata: Optional[Dict] = None) -> OrderBookMessage: + msg = record["json"] if type(record["json"])==dict else ujson.loads(record["json"]) + if metadata: + msg.update(metadata) + return OrderBookMessage(OrderBookMessageType.SNAPSHOT, { + "trading_pair": msg["pair"], + "update_id": record.timestamp, + "bids": msg["bids"], + "asks": msg["asks"] + }, timestamp=record["timestamp"] * 1e-3) + + @classmethod + def diff_message_from_db(cls, record: RowProxy, metadata: Optional[Dict] = None) -> OrderBookMessage: + msg = ujson.loads(record["json"]) + if metadata: + msg.update(metadata) + return OrderBookMessage(OrderBookMessageType.DIFF, { + "trading_pair": msg["pair"], + "update_id": record.timestamp, + "bids": msg["bids"], + "asks": msg["asks"] + }, timestamp=record["timestamp"] * 1e-3) + + @classmethod + def snapshot_message_from_kafka(cls, record: ConsumerRecord, metadata: Optional[Dict] = None) -> OrderBookMessage: + msg = ujson.loads(record.value.decode("utf-8")) + if metadata: + msg.update(metadata) + return OrderBookMessage(OrderBookMessageType.SNAPSHOT, { + "trading_pair": msg["pair"], + "update_id": record.timestamp, + "bids": msg["bids"], + "asks": msg["asks"] + }, timestamp=record.timestamp * 1e-3) + + @classmethod + def diff_message_from_kafka(cls, record: ConsumerRecord, metadata: Optional[Dict] = None) -> OrderBookMessage: + msg = ujson.loads(record.value.decode("utf-8")) + if metadata: + msg.update(metadata) + return OrderBookMessage(OrderBookMessageType.DIFF, { + "trading_pair": msg["pair"], + "update_id": record.timestamp, + "bids": msg["bids"], + "asks": msg["asks"], + + }, timestamp=record.timestamp * 1e-3) + + @classmethod + def trade_message_from_db(cls, record: RowProxy, metadata: Optional[Dict] = None): + msg = record["json"] + if metadata: + msg.update(metadata) + ts = record.timestamp + return OrderBookMessage(OrderBookMessageType.TRADE, { + "trading_pair": msg["market"], + "trade_type": msg["taker_type"], + "trade_id": msg["id"], + "update_id": ts, + "price": msg["price"], + "amount": msg["amount"] + }, timestamp=ts * 1e-3) # not sure about this + + @classmethod + def trade_message_from_exchange(cls, msg: Dict[str, any], metadata: Optional[Dict] = None): + # {'fthusd.trades': {'trades': [{'tid': 9, 'taker_type': 'sell', 'date': 1586958619, 'price': '51.0', 'amount': '0.02'}]}} + if metadata: + msg.update(metadata) + + # cls.logger().error("Message from exchange: " + str(msg)) + + msg = msg.popitem() + # cls.logger().error(str(msg)) + + market = convert_from_exchange_trading_pair(msg[0].split('.')[0]) + trade = msg[1]["trades"][0] + ts = trade['date'] + # cls.logger().error(str(market) + " " + str(trade) + " " + str(ts)) + return OrderBookMessage(OrderBookMessageType.TRADE, { + "trading_pair": market, + "trade_type": trade['taker_type'], + "trade_id": trade["tid"], + "update_id": ts, + "price": trade["price"], + "amount": trade["amount"] + }, timestamp=ts) + + @classmethod + def from_snapshot(cls, msg: OrderBookMessage) -> "OrderBook": + retval = BlocktaneOrderBook() + retval.apply_snapshot(msg.bids, msg.asks, msg.update_id) + return retval diff --git a/hummingbot/connector/exchange/blocktane/blocktane_order_book_tracker.py b/hummingbot/connector/exchange/blocktane/blocktane_order_book_tracker.py new file mode 100644 index 0000000000..982db9d4f8 --- /dev/null +++ b/hummingbot/connector/exchange/blocktane/blocktane_order_book_tracker.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python + +import asyncio +from collections import deque, defaultdict +import logging +import time +from typing import ( + Deque, + Dict, + List, + Optional +) + +from hummingbot.logger import HummingbotLogger +from hummingbot.core.data_type.order_book_tracker import OrderBookTracker +from hummingbot.core.utils.async_utils import safe_ensure_future +from hummingbot.connector.exchange.blocktane.blocktane_api_order_book_data_source import BlocktaneAPIOrderBookDataSource +from hummingbot.connector.exchange.blocktane.blocktane_active_order_tracker import BlocktaneActiveOrderTracker +from hummingbot.core.data_type.order_book import OrderBook +from hummingbot.core.data_type.order_book_message import OrderBookMessage, OrderBookMessageType + + +class BlocktaneOrderBookTracker(OrderBookTracker): + _bobt_logger: Optional[HummingbotLogger] = None + + @classmethod + def logger(cls) -> HummingbotLogger: + if cls._bobt_logger is None: + cls._bobt_logger = logging.getLogger(__name__) + return cls._bobt_logger + + def __init__(self, + trading_pairs: Optional[List[str]] = None): + super().__init__( + data_source=BlocktaneAPIOrderBookDataSource(trading_pairs=trading_pairs), + trading_pairs=trading_pairs) + + self._order_book_diff_stream: asyncio.Queue = asyncio.Queue() + self._order_book_snapshot_stream: asyncio.Queue = asyncio.Queue() + + self._ev_loop: asyncio.BaseEventLoop = asyncio.get_event_loop() + self._saved_message_queues: Dict[str, Deque[OrderBookMessage]] = defaultdict(lambda: deque(maxlen=1000)) + self._active_order_tracker = BlocktaneActiveOrderTracker() + + @property + def exchange_name(self) -> str: + return "blocktane" + + async def start(self): + super().start() + self._order_book_trade_listener_task = safe_ensure_future( + self.data_source.listen_for_trades(self._ev_loop, self._order_book_trade_stream) + ) + self._order_book_diff_listener_task = safe_ensure_future( + self.data_source.listen_for_order_book_diffs(self._ev_loop, self._order_book_diff_stream) + ) + self._order_book_snapshot_listener_task = safe_ensure_future( + self.data_source.listen_for_order_book_snapshots(self._ev_loop, self._order_book_snapshot_stream) + ) + self._order_book_diff_router_task = safe_ensure_future( + self._order_book_diff_router() + ) + self._order_book_snapshot_router_task = safe_ensure_future( + self._order_book_snapshot_router() + ) + + async def _order_book_diff_router(self): + """ + Route the real-time order book diff messages to the correct order book. + """ + last_message_timestamp: float = time.time() + messages_queued: int = 0 + messages_accepted: int = 0 + messages_rejected: int = 0 + + while True: + try: + ob_message: OrderBookMessage = await self._order_book_diff_stream.get() + trading_pair: str = ob_message.trading_pair + + if trading_pair not in self._tracking_message_queues: + messages_queued += 1 + # Save diff messages received before snapshots are ready + self._saved_message_queues[trading_pair].append(ob_message) + continue + message_queue: asyncio.Queue = self._tracking_message_queues[trading_pair] + # Check the order book's initial update ID. If it's larger, don't bother. + order_book: OrderBook = self._order_books[trading_pair] + + if order_book.snapshot_uid > ob_message.update_id: + messages_rejected += 1 + continue + await message_queue.put(ob_message) + messages_accepted += 1 + + # Log some statistics. + now: float = time.time() + if int(now / 60.0) > int(last_message_timestamp / 60.0): + # self.logger().debug("Diff messages processed: %d, rejected: %d, queued: %d", + # messages_accepted, + # messages_rejected, + # messages_queued) + messages_accepted = 0 + messages_rejected = 0 + messages_queued = 0 + + last_message_timestamp = now + except asyncio.CancelledError: + raise + except Exception: + self.logger().network( + "Unexpected error routing order book messages.", + exc_info=True, + app_warning_msg="Unexpected error routing order book messages. Retrying after 5 seconds." + ) + await asyncio.sleep(5.0) + + async def _track_single_book(self, trading_pair: str): + past_diffs_window: Deque[OrderBookMessage] = deque() + self._past_diffs_windows[trading_pair] = past_diffs_window + + message_queue: asyncio.Queue = self._tracking_message_queues[trading_pair] + order_book: OrderBook = self._order_books[trading_pair] + last_message_timestamp: float = time.time() + diff_messages_accepted: int = 0 + + while True: + try: + message: OrderBookMessage = None + saved_messages: Deque[OrderBookMessage] = 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 = self._active_order_tracker.convert_diff_message_to_order_book_row(message) + order_book.apply_diffs(bids, asks, message.update_id) + past_diffs_window.append(message) + while len(past_diffs_window) > self.PAST_DIFF_WINDOW_SIZE: + past_diffs_window.popleft() + diff_messages_accepted += 1 + + # Output some statistics periodically. + now: float = time.time() + if int(now / 60.0) > int(last_message_timestamp / 60.0): + # self.logger().debug("Processed %d order book diffs for %s.", + # diff_messages_accepted, trading_pair) + diff_messages_accepted = 0 + last_message_timestamp = now + elif message.type is OrderBookMessageType.SNAPSHOT: + past_diffs: List[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) + except asyncio.CancelledError: + raise + except Exception: + self.logger().network( + f"Unexpected error tracking order book for {trading_pair}.", + exc_info=True, + app_warning_msg="Unexpected error tracking order book. Retrying after 5 seconds." + ) + await asyncio.sleep(5.0) diff --git a/hummingbot/connector/exchange/blocktane/blocktane_user_stream_tracker.py b/hummingbot/connector/exchange/blocktane/blocktane_user_stream_tracker.py new file mode 100644 index 0000000000..144198d3ca --- /dev/null +++ b/hummingbot/connector/exchange/blocktane/blocktane_user_stream_tracker.py @@ -0,0 +1,57 @@ +#!/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.blocktane.blocktane_api_user_stream_data_source import BlocktaneAPIUserStreamDataSource +from hummingbot.connector.exchange.blocktane.blocktane_auth import BlocktaneAuth + + +class BlocktaneUserStreamTracker(UserStreamTracker): + _bust_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, + blocktane_auth: Optional[BlocktaneAuth] = None, + trading_pairs=None): + super().__init__() + self._blocktane_auth: BlocktaneAuth = blocktane_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: + if not self._data_source: + self._data_source = BlocktaneAPIUserStreamDataSource( + blocktane_auth=self._blocktane_auth, trading_pairs=self._trading_pairs + ) + return self._data_source + + @property + def exchange_name(self) -> str: + return "blocktane" + + async def start(self): + self._user_stream_tracking_task = safe_ensure_future( + self.data_source.listen_for_user_stream(self._ev_loop, self._user_stream) + ) + await safe_gather(self._user_stream_tracking_task) diff --git a/hummingbot/connector/exchange/blocktane/blocktane_utils.py b/hummingbot/connector/exchange/blocktane/blocktane_utils.py new file mode 100644 index 0000000000..857c0d30aa --- /dev/null +++ b/hummingbot/connector/exchange/blocktane/blocktane_utils.py @@ -0,0 +1,55 @@ +import re + +from typing import Optional, Tuple + +from hummingbot.client.config.config_var import ConfigVar +from hummingbot.client.config.config_methods import using_exchange + +CENTRALIZED = True + +EXAMPLE_PAIR = "BRL-BTC" + +# DEFAULT_FEES = [0.35, 0.45] # The actual fees +DEFAULT_FEES = [0.01, 0.01] # Our special liquidity provider rates + +KEYS = { + "blocktane_api_key": + ConfigVar(key="blocktane_api_key", + prompt="Enter your Blocktane API key >>> ", + required_if=using_exchange("blocktane"), + is_secure=True, + is_connect_key=True), + "blocktane_api_secret": + ConfigVar(key="blocktane_api_secret", + prompt="Enter your Blocktane API secret >>> ", + required_if=using_exchange("blocktane"), + is_secure=True, + is_connect_key=True) +} + +TRADING_PAIR_SPLITTER = re.compile(r"^(\w+)(BTC|btc|ETH|eth|BRL|brl|PAX|pax)$") + + +def split_trading_pair(trading_pair: str) -> Optional[Tuple[str, str]]: + try: + if ('/' in trading_pair): + m = trading_pair.split('/') + return m[0], m[1] + else: + 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 convert_from_exchange_trading_pair(exchange_trading_pair: str) -> Optional[str]: + if split_trading_pair(exchange_trading_pair) is None: + return None + # Blocktane does not split BASEQUOTE (fthusd) + base_asset, quote_asset = split_trading_pair(exchange_trading_pair) + return f"{base_asset}-{quote_asset}".upper() + + +def convert_to_exchange_trading_pair(hb_trading_pair: str) -> str: + return hb_trading_pair.lower().replace("-", "") From 451c61e25ddb0ec1655699b912e918654772a70c Mon Sep 17 00:00:00 2001 From: Michael Borraccia Date: Fri, 15 Jan 2021 15:15:36 -0500 Subject: [PATCH 025/126] Minor fixes --- .../connector/exchange/blocktane/blocktane_exchange.pyx | 5 +++-- hummingbot/connector/exchange/blocktane/blocktane_utils.py | 5 ++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/hummingbot/connector/exchange/blocktane/blocktane_exchange.pyx b/hummingbot/connector/exchange/blocktane/blocktane_exchange.pyx index 8d1d9258cd..2a673634d1 100644 --- a/hummingbot/connector/exchange/blocktane/blocktane_exchange.pyx +++ b/hummingbot/connector/exchange/blocktane/blocktane_exchange.pyx @@ -3,9 +3,9 @@ import time import asyncio import aiohttp import copy +import json import logging import pandas as pd -import simplejson import traceback from decimal import Decimal from libc.stdint cimport int64_t @@ -69,6 +69,7 @@ cdef class BlocktaneExchangeTransactionTracker(TransactionTracker): TransactionTracker.c_did_timeout_tx(self, tx_id) self._owner.c_did_timeout_tx(tx_id) + cdef class BlocktaneExchange(ExchangeBase): MARKET_RECEIVED_ASSET_EVENT_TAG = MarketEvent.ReceivedAsset.value MARKET_BUY_ORDER_COMPLETED_EVENT_TAG = MarketEvent.BuyOrderCompleted.value @@ -378,7 +379,7 @@ cdef class BlocktaneExchange(ExchangeBase): new_confirmed_amount = Decimal(order["executed_volume"]) executed_amount_base_diff = new_confirmed_amount - tracked_order.executed_amount_base if executed_amount_base_diff > s_decimal_0: - self.logger().info(f"Updated order status with fill from polling _update_order_status: {simplejson.dumps(order)}") + self.logger().info(f"Updated order status with fill from polling _update_order_status: {json.dumps(order)}") new_confirmed_quote_amount = new_confirmed_amount * avg_price executed_amount_quote_diff = new_confirmed_quote_amount - tracked_order.executed_amount_quote executed_price = executed_amount_quote_diff / executed_amount_base_diff diff --git a/hummingbot/connector/exchange/blocktane/blocktane_utils.py b/hummingbot/connector/exchange/blocktane/blocktane_utils.py index 857c0d30aa..146d1befc7 100644 --- a/hummingbot/connector/exchange/blocktane/blocktane_utils.py +++ b/hummingbot/connector/exchange/blocktane/blocktane_utils.py @@ -7,10 +7,9 @@ CENTRALIZED = True -EXAMPLE_PAIR = "BRL-BTC" +EXAMPLE_PAIR = "BTC-BRL" -# DEFAULT_FEES = [0.35, 0.45] # The actual fees -DEFAULT_FEES = [0.01, 0.01] # Our special liquidity provider rates +DEFAULT_FEES = [0.35, 0.45] # The actual fees KEYS = { "blocktane_api_key": From 2a24b5d4a2d93593e726c9d933cc681996ed99c5 Mon Sep 17 00:00:00 2001 From: Michael Borraccia Date: Fri, 15 Jan 2021 18:11:54 -0500 Subject: [PATCH 026/126] Add these missing functions to the blocktane exchange class --- .../exchange/blocktane/blocktane_exchange.pyx | 32 +++++++++++++++++-- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/hummingbot/connector/exchange/blocktane/blocktane_exchange.pyx b/hummingbot/connector/exchange/blocktane/blocktane_exchange.pyx index 2a673634d1..e8fe3ca41c 100644 --- a/hummingbot/connector/exchange/blocktane/blocktane_exchange.pyx +++ b/hummingbot/connector/exchange/blocktane/blocktane_exchange.pyx @@ -16,7 +16,7 @@ from typing import Optional, List, Dict, Any, AsyncIterable, Tuple from hummingbot.core.clock cimport Clock from hummingbot.connector.exchange_base cimport ExchangeBase -from hummingbot.connector.exchange_base import NaN +from hummingbot.connector.exchange_base import s_decimal_NaN from hummingbot.logger import HummingbotLogger from hummingbot.connector.trading_rule cimport TradingRule from hummingbot.core.network_iterator import NetworkStatus @@ -810,7 +810,7 @@ cdef class BlocktaneExchange(ExchangeBase): str trading_pair, object amount, object order_type=OrderType.LIMIT, - object price=NaN, + object price=s_decimal_NaN, dict kwargs={}): cdef: int64_t tracking_nonce = get_tracking_nonce() @@ -823,7 +823,7 @@ cdef class BlocktaneExchange(ExchangeBase): trading_pair: str, amount: Decimal, order_type: OrderType = OrderType.LIMIT, - price: Optional[Decimal] = NaN): + price: Optional[Decimal] = s_decimal_NaN): cdef: TradingRule trading_rule = self._trading_rules[trading_pair] double quote_amount @@ -1041,3 +1041,29 @@ cdef class BlocktaneExchange(ExchangeBase): self._status_polling_task = safe_ensure_future(self._status_polling_loop()) self._user_stream_tracker_task = safe_ensure_future(self._user_stream_tracker.start()) self._user_stream_event_listener_task = safe_ensure_future(self._user_stream_event_listener()) + + def get_order_book(self, trading_pair: str) -> OrderBook: + return self.c_get_order_book(trading_pair) + + def get_price(self, trading_pair: str, is_buy: bool) -> Decimal: + return self.c_get_price(trading_pair, is_buy) + + def buy(self, trading_pair: str, amount: Decimal, order_type=OrderType.MARKET, + price: Decimal = s_decimal_NaN, **kwargs) -> str: + return self.c_buy(trading_pair, amount, order_type, price, kwargs) + + def sell(self, trading_pair: str, amount: Decimal, order_type=OrderType.MARKET, + price: Decimal = s_decimal_NaN, **kwargs) -> str: + return self.c_sell(trading_pair, amount, order_type, price, kwargs) + + def cancel(self, trading_pair: str, client_order_id: str): + return self.c_cancel(trading_pair, client_order_id) + + def get_fee(self, + base_currency: str, + quote_currency: str, + order_type: OrderType, + order_side: TradeType, + amount: Decimal, + price: Decimal = s_decimal_NaN) -> TradeFee: + return self.c_get_fee(base_currency, quote_currency, order_type, order_side, amount, price) From 10fcab2ed4104990f57463a19e6a9d296cd0381f Mon Sep 17 00:00:00 2001 From: Nicolas Baum Date: Sun, 17 Jan 2021 02:19:38 -0300 Subject: [PATCH 027/126] (feat) Fixed tests and renamed exchange order id column --- hummingbot/connector/connector_base.pyx | 4 +-- .../exchange/binance/binance_exchange.pyx | 30 ++++++++++++------- hummingbot/connector/markets_recorder.py | 6 ++-- hummingbot/model/order.py | 4 +-- test/integration/test_binance_market.py | 14 +++++---- 5 files changed, 34 insertions(+), 24 deletions(-) diff --git a/hummingbot/connector/connector_base.pyx b/hummingbot/connector/connector_base.pyx index 3fb7288201..191c105a9c 100644 --- a/hummingbot/connector/connector_base.pyx +++ b/hummingbot/connector/connector_base.pyx @@ -429,11 +429,11 @@ cdef class ConnectorBase(NetworkIterator): """ self._exchange_order_ids.update(current_exchange_order_ids) - def is_confirmed_new_order_filled_event(self, exchange_trade_id: str, trading_pair: str): + def is_confirmed_new_order_filled_event(self, exchange_trade_id: str, exchange_order_id: str, trading_pair: str): """ Returns True if order to be filled is not already present in TradeFill entries. This is intended to avoid duplicated order fills in local DB. """ # Assume (market, exchange_trade_id, trading_pair) are unique. Also order has to be recorded in Order table return (not TradeFill_order_details(self.display_name, exchange_trade_id, trading_pair) in self._current_trade_fills) and \ - (exchange_trade_id in self._exchange_order_ids) + (exchange_order_id in self._exchange_order_ids) diff --git a/hummingbot/connector/exchange/binance/binance_exchange.pyx b/hummingbot/connector/exchange/binance/binance_exchange.pyx index 123fe7fec9..9c7c334cf1 100755 --- a/hummingbot/connector/exchange/binance/binance_exchange.pyx +++ b/hummingbot/connector/exchange/binance/binance_exchange.pyx @@ -146,6 +146,7 @@ cdef class BinanceExchange(ExchangeBase): self._trading_rules = {} # Dict[trading_pair:str, TradingRule] self._trade_fees = {} # Dict[trading_pair:str, (maker_fee_percent:Decimal, taken_fee_percent:Decimal)] self._last_update_trade_fees_timestamp = 0 + self._last_update_history_reconciliation_timestamp = defaultdict(lambda: 0) self._status_polling_task = None self._user_stream_event_listener_task = None self._trading_rules_polling_task = None @@ -444,9 +445,13 @@ cdef class BinanceExchange(ExchangeBase): If found, it will trigger an order_filled event to record it in local DB. """ if not exchange_history: - tasks = [self.query_api(self._binance_client.get_my_trades, - symbol=convert_to_exchange_trading_pair(trading_pair)) - for trading_pair in self._order_book_tracker._trading_pairs] + tasks=[] + for trading_pair in self._order_book_tracker._trading_pairs: + my_trades_query_args = {'symbol': convert_to_exchange_trading_pair(trading_pair)} + if self._last_update_history_reconciliation_timestamp[trading_pair]>0: + my_trades_query_args.update({'startTime': self._last_update_history_reconciliation_timestamp[trading_pair]+1}) + tasks.append(self.query_api(self._binance_client.get_my_trades, + **my_trades_query_args)) self.logger().debug("Polling for order fills of %d trading pairs.", len(tasks)) exchange_history = await safe_gather(*tasks, return_exceptions=True) for trades, trading_pair in zip(exchange_history, self._order_book_tracker._trading_pairs): @@ -456,8 +461,9 @@ cdef class BinanceExchange(ExchangeBase): app_warning_msg=f"Failed to fetch trade update for {trading_pair}." ) continue + self._last_update_history_reconciliation_timestamp[trading_pair] = max(trade["time"] for trade in trades) for trade in trades: - if self.is_confirmed_new_order_filled_event(str(trade["id"]), trading_pair): + if self.is_confirmed_new_order_filled_event(str(trade["id"]), str(trade["orderId"]), trading_pair): client_order_id = get_client_order_id("buy" if trade["isBuyer"] else "sell", trading_pair) self.c_trigger_event(self.MARKET_ORDER_FILLED_EVENT_TAG, OrderFilledEvent( @@ -527,8 +533,10 @@ cdef class BinanceExchange(ExchangeBase): if tracked_order.is_done: if not tracked_order.is_failure: - exchange_order_id = next(iter(tracked_order.trade_id_set)) - if self.is_confirmed_new_order_filled_event(str(exchange_order_id), + exchange_trade_id = next(iter(tracked_order.trade_id_set)) + exchange_order_id = tracked_order.exchange_order_id + if self.is_confirmed_new_order_filled_event(str(exchange_trade_id), + str(exchange_order_id), tracked_order.trading_pair): if tracked_order.trade_type is TradeType.BUY: self.logger().info(f"The market buy order {tracked_order.client_order_id} has completed " @@ -560,7 +568,7 @@ cdef class BinanceExchange(ExchangeBase): order_type)) else: self.logger().info( - f"The market order {tracked_order.client_order_id} was already filled. " + f"The market order {tracked_order.client_order_id} was already filled, or order was not submitted by hummingbot." f"Ignoring trade filled event in update_order_status.") else: # check if its a cancelled order @@ -645,13 +653,13 @@ cdef class BinanceExchange(ExchangeBase): if execution_type == "TRADE": order_filled_event = OrderFilledEvent.order_filled_event_from_binance_execution_report(event_message) order_filled_event = order_filled_event._replace(trading_pair=convert_from_exchange_trading_pair(order_filled_event.trading_pair)) - exchange_order_id = next(iter(tracked_order.trade_id_set)) - if self.is_confirmed_new_order_filled_event(str(exchange_order_id), tracked_order.trading_pair): + exchange_trade_id = next(iter(tracked_order.trade_id_set)) + if self.is_confirmed_new_order_filled_event(str(exchange_trade_id), str(tracked_order.exchange_order_id), tracked_order.trading_pair): self.c_trigger_event(self.MARKET_ORDER_FILLED_EVENT_TAG, order_filled_event) else: self.logger().info( - f"The market order {tracked_order.client_order_id} was already filled." - f"Ignoring trade filled event in user stream.") + f"The market order {tracked_order.client_order_id} was already filled or order was not submitted by hummingbot." + f"Ignoring trade filled event of exchange trade id {str(exchange_trade_id)} in user stream.") self.c_stop_tracking_order(tracked_order.client_order_id) continue diff --git a/hummingbot/connector/markets_recorder.py b/hummingbot/connector/markets_recorder.py index 16f013367f..e1b71519d0 100644 --- a/hummingbot/connector/markets_recorder.py +++ b/hummingbot/connector/markets_recorder.py @@ -125,7 +125,7 @@ def get_orders_for_config_and_market(self, config_file_path: str, market: Connec filters = [Order.config_file_path == config_file_path, Order.market == market.display_name] if with_exchange_trade_id_present: - filters.append(Order.exchange_trade_id.isnot(None)) + filters.append(Order.exchange_order_id.isnot(None)) query: Query = (session .query(Order) .filter(*filters) @@ -204,14 +204,14 @@ def _did_create_order(self, price=float(evt.price) if evt.price == evt.price else 0, last_status=event_type.name, last_update_timestamp=timestamp, - exchange_trade_id=evt.exchange_order_id) + exchange_order_id=evt.exchange_order_id) order_status: OrderStatus = OrderStatus(order=order_record, timestamp=timestamp, status=event_type.name) session.add(order_record) session.add(order_status) - self.save_market_states(self._config_file_path, market, no_commit=True) market.add_exchange_order_ids_from_market_recorder({evt.exchange_order_id}) + self.save_market_states(self._config_file_path, market, no_commit=True) session.commit() def _did_fill_order(self, diff --git a/hummingbot/model/order.py b/hummingbot/model/order.py index c91a4859d8..7ef408400c 100644 --- a/hummingbot/model/order.py +++ b/hummingbot/model/order.py @@ -40,7 +40,7 @@ class Order(HummingbotBase): price = Column(Float, nullable=False) last_status = Column(Text, nullable=False) last_update_timestamp = Column(BigInteger, nullable=False) - exchange_trade_id = Column(Text, nullable=True) + exchange_order_id = Column(Text, nullable=True) status = relationship("OrderStatus", back_populates="order") trade_fills = relationship("TradeFill", back_populates="order") @@ -51,7 +51,7 @@ def __repr__(self) -> str: f"order_type='{self.order_type}', amount={self.amount}, " \ f"price={self.price}, last_status='{self.last_status}', " \ f"last_update_timestamp={self.last_update_timestamp}), " \ - f"exchange_trade_id={self.exchange_trade_id}" + f"exchange_order_id={self.exchange_order_id}" @staticmethod def to_bounty_api_json(order: "Order") -> Dict[str, Any]: diff --git a/test/integration/test_binance_market.py b/test/integration/test_binance_market.py index a09c47ab8e..376d2ee637 100644 --- a/test/integration/test_binance_market.py +++ b/test/integration/test_binance_market.py @@ -241,6 +241,7 @@ def test_buy_and_sell(self): order_id = self.place_order(True, "LINK-ETH", amount, OrderType.LIMIT, bid_price, self.get_current_nonce(), FixtureBinance.BUY_MARKET_ORDER, FixtureBinance.WS_AFTER_BUY_1, FixtureBinance.WS_AFTER_BUY_2) + self.market.add_exchange_order_ids_from_market_recorder({str(FixtureBinance.BUY_MARKET_ORDER['orderId'])}) [order_completed_event] = self.run_parallel(self.market_logger.wait_for(BuyOrderCompletedEvent)) order_completed_event: BuyOrderCompletedEvent = order_completed_event trade_events: List[OrderFilledEvent] = [t for t in self.market_logger.event_log @@ -268,6 +269,7 @@ def test_buy_and_sell(self): quantized_amount = order_completed_event.base_asset_amount order_id = self.place_order(False, "LINK-ETH", amount, OrderType.LIMIT, ask_price, 10002, FixtureBinance.SELL_MARKET_ORDER, FixtureBinance.WS_AFTER_SELL_1, FixtureBinance.WS_AFTER_SELL_2) + self.market.add_exchange_order_ids_from_market_recorder({str(FixtureBinance.SELL_MARKET_ORDER['orderId'])}) [order_completed_event] = self.run_parallel(self.market_logger.wait_for(SellOrderCompletedEvent)) order_completed_event: SellOrderCompletedEvent = order_completed_event trade_events = [t for t in self.market_logger.event_log @@ -374,10 +376,10 @@ def place_order(self, is_buy, trading_pair, amount, order_type, price, nonce, fi else: order_id = self.market.sell(trading_pair, amount, order_type, price) if API_MOCK_ENABLED and fixture_ws_1 is not None and fixture_ws_2 is not None: - self.market.add_exchange_order_ids_from_market_recorder({str(fixture_ws_2['t'])}) - data = self.fixture(fixture_ws_1, c=order_id) + exchange_order_id = str(resp['orderId']) + data = self.fixture(fixture_ws_1, c=order_id, i=exchange_order_id) HummingWsServerFactory.send_json_threadsafe(self._ws_user_url, data, delay=0.1) - data = self.fixture(fixture_ws_2, c=order_id) + data = self.fixture(fixture_ws_2, c=order_id, i=exchange_order_id) HummingWsServerFactory.send_json_threadsafe(self._ws_user_url, data, delay=0.11) return order_id @@ -733,12 +735,12 @@ def test_history_reconciliation(self): bid_price: Decimal = self.market.get_price("LINK-ETH", True) # Will temporarily change binance history request to return trades buy_id = "1580204166011219" - order_id = 123456 + order_id = "123456" self._t_nonce_mock.return_value = 1234567890123456 binance_trades = [{ 'symbol': "LINKETH", 'id': buy_id, - 'orderid': order_id, + 'orderId': order_id, 'orderListId': -1, 'price': float(bid_price), 'qty': 1, @@ -750,7 +752,7 @@ def test_history_reconciliation(self): 'isMaker': True, 'isBestMatch': True, }] - self.market.add_exchange_order_ids_from_market_recorder({buy_id}) + self.market.add_exchange_order_ids_from_market_recorder({order_id}) self.web_app.update_response("get", self.base_api_url, "/api/v3/myTrades", binance_trades, params={'symbol': 'LINKETH'}) [market_order_completed] = self.run_parallel(self.market_logger.wait_for(OrderFilledEvent)) From 8b9093ab55797f589482a8f21e5ef0d4b713c82a Mon Sep 17 00:00:00 2001 From: Nicolas Baum Date: Sun, 17 Jan 2021 03:25:37 -0300 Subject: [PATCH 028/126] (feat) Added automated schema migration feature. Also added column exchange_trade_id to table Order (feat) fixed minor bug (feat) Removed check_and_upgrade_trade_fills_db method (feat) Renamed exchange_trade_id column to exchange_order_id (feat) Renamed transformation (feat) fix minor bugs --- hummingbot/model/db_migration/__init__.py | 0 .../model/db_migration/base_transformation.py | 55 +++++++++++++++++ hummingbot/model/db_migration/migrator.py | 60 +++++++++++++++++++ .../model/db_migration/transformations.py | 28 +++++++++ hummingbot/model/order.py | 4 +- hummingbot/model/sql_connection_manager.py | 49 +++++++-------- 6 files changed, 171 insertions(+), 25 deletions(-) create mode 100644 hummingbot/model/db_migration/__init__.py create mode 100644 hummingbot/model/db_migration/base_transformation.py create mode 100644 hummingbot/model/db_migration/migrator.py create mode 100644 hummingbot/model/db_migration/transformations.py diff --git a/hummingbot/model/db_migration/__init__.py b/hummingbot/model/db_migration/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/hummingbot/model/db_migration/base_transformation.py b/hummingbot/model/db_migration/base_transformation.py new file mode 100644 index 0000000000..bd3e062a9a --- /dev/null +++ b/hummingbot/model/db_migration/base_transformation.py @@ -0,0 +1,55 @@ +import logging +from abc import ABC, abstractmethod +import functools +from sqlalchemy import ( + Column, +) + + +@functools.total_ordering +class DatabaseTransformation(ABC): + def __init__(self, migrator): + self.migrator = migrator + + @abstractmethod + def apply(self, db_handle): + pass + + @property + @abstractmethod + def name(self): + return "" + + @property + @abstractmethod + def from_version(self): + return None + + @property + @abstractmethod + def to_version(self): + return None + + def does_apply_to_version(self, version: int) -> bool: + if self.to_version is not None and self.from_version is not None: + return version <= self.to_version + return False + + def __eq__(self, other): + return (self.to_version == other.to_version) and (self.from_version == other.to_version) + + def __lt__(self, other): + if self.to_version == other.to_version: + return self.from_version < other.from_version + else: + return self.to_version < other.to_version + + def add_column(self, engine, table_name, column: Column, dry_run=True): + column_name = column.compile(dialect=engine.dialect) + column_type = column.type.compile(engine.dialect) + column_nullable = "NULL" if column.nullable else "NOT NULL" + query_to_execute = f'ALTER TABLE \"{table_name}\" ADD COLUMN {column_name} {column_type} {column_nullable}' + if dry_run: + logging.getLogger().info(f"Query to execute in DB: {query_to_execute}") + else: + engine.execute(query_to_execute) diff --git a/hummingbot/model/db_migration/migrator.py b/hummingbot/model/db_migration/migrator.py new file mode 100644 index 0000000000..50e426e11e --- /dev/null +++ b/hummingbot/model/db_migration/migrator.py @@ -0,0 +1,60 @@ +import logging +from shutil import copyfile, move +from inspect import isabstract, isclass, getmembers +from pathlib import Path +import pandas as pd +from sqlalchemy.exc import SQLAlchemyError +from hummingbot.model.db_migration.base_transformation import DatabaseTransformation +from hummingbot.model.sql_connection_manager import SQLConnectionType, SQLConnectionManager + + +class Migrator: + @classmethod + def _get_transformations(cls): + import hummingbot.model.db_migration.transformations as transformations + return [o for _, o in getmembers(transformations, + predicate=lambda c: isclass(c) and + issubclass(c, DatabaseTransformation) and + not isabstract(c))] + + def __init__(self): + self.transformations = [t(self) for t in self._get_transformations()] + + def migrate_db_to_version(self, db_handle, to_version): + original_db_path = db_handle.db_path + original_db_name = Path(original_db_path).stem + backup_db_path = original_db_path + '.backup_' + pd.Timestamp.utcnow().strftime("%Y%m%d-%H%M%S") + new_db_path = original_db_path + '.new' + copyfile(original_db_path, new_db_path) + copyfile(original_db_path, backup_db_path) + + db_handle.get_shared_session().close() + db_handle.engine.dispose() + new_db_handle = SQLConnectionManager(SQLConnectionType.TRADE_FILLS, new_db_path, original_db_name, True) + + relevant_transformations = [t for t in self.transformations if t.does_apply_to_version(to_version)] + if relevant_transformations: + logging.getLogger().info( + f"Will run DB migration from {db_handle.get_local_db_version().value} to {to_version}") + + migration_succesful = False + try: + for transformation in sorted(relevant_transformations): + logging.getLogger().info(f"Applying {transformation.name} to DB...") + new_db_handle = transformation.apply(new_db_handle) + logging.getLogger().info(f"DONE with {transformation.name}") + migration_succesful = True + except SQLAlchemyError: + logging.getLogger().error("Unexpected error while checking and upgrading the local database.", + exc_info=True) + finally: + try: + new_db_handle.get_shared_session().close() + new_db_handle.engine.dispose() + if migration_succesful: + move(new_db_path, original_db_path) + db_handle.__init__(SQLConnectionType.TRADE_FILLS, original_db_path, original_db_name, True) + except Exception as e: + logging.getLogger().error(f"Fatal error migrating DB {original_db_path}") + raise e + return db_handle diff --git a/hummingbot/model/db_migration/transformations.py b/hummingbot/model/db_migration/transformations.py new file mode 100644 index 0000000000..855c0f9839 --- /dev/null +++ b/hummingbot/model/db_migration/transformations.py @@ -0,0 +1,28 @@ +from hummingbot.model.db_migration.base_transformation import DatabaseTransformation +from hummingbot.model.sql_connection_manager import SQLConnectionManager +from sqlalchemy import ( + Column, + Text, +) + + +class AddExchangeOrderIdColumnToOrders(DatabaseTransformation): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def apply(self, db_handle: SQLConnectionManager) -> SQLConnectionManager: + exchange_order_id_column = Column("exchange_order_id", Text, nullable=True) + self.add_column(db_handle.engine, "Order", exchange_order_id_column, dry_run=False) + return db_handle + + @property + def name(self): + return "AddExchangeOrderIdColumnToOrders" + + @property + def from_version(self): + return 20190614 + + @property + def to_version(self): + return 20210114 diff --git a/hummingbot/model/order.py b/hummingbot/model/order.py index 736a6b5055..da65e8e370 100644 --- a/hummingbot/model/order.py +++ b/hummingbot/model/order.py @@ -40,6 +40,7 @@ class Order(HummingbotBase): price = Column(Float, nullable=False) last_status = Column(Text, nullable=False) last_update_timestamp = Column(BigInteger, nullable=False) + exchange_order_id = Column(Text, nullable=True) status = relationship("OrderStatus", back_populates="order") trade_fills = relationship("TradeFill", back_populates="order") @@ -49,7 +50,8 @@ def __repr__(self) -> str: f"quote_asset='{self.quote_asset}', creation_timestamp={self.creation_timestamp}, " \ f"order_type='{self.order_type}', amount={self.amount}, " \ f"price={self.price}, last_status='{self.last_status}', " \ - f"last_update_timestamp={self.last_update_timestamp})" + f"last_update_timestamp={self.last_update_timestamp}), " \ + f"exchange_order_id°={self.exchange_order_id}" @staticmethod def to_bounty_api_json(order: "Order") -> Dict[str, Any]: diff --git a/hummingbot/model/sql_connection_manager.py b/hummingbot/model/sql_connection_manager.py index 7ad289b1a3..23f53331f8 100644 --- a/hummingbot/model/sql_connection_manager.py +++ b/hummingbot/model/sql_connection_manager.py @@ -9,7 +9,6 @@ MetaData, ) from sqlalchemy.engine.base import Engine -from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import ( sessionmaker, Session, @@ -47,7 +46,7 @@ class SQLConnectionManager: _scm_trade_fills_instance: Optional["SQLConnectionManager"] = None LOCAL_DB_VERSION_KEY = "local_db_version" - LOCAL_DB_VERSION_VALUE = "20190614" + LOCAL_DB_VERSION_VALUE = "20210114" @classmethod def logger(cls) -> HummingbotLogger: @@ -101,7 +100,8 @@ def get_db_engine(cls, def __init__(self, connection_type: SQLConnectionType, db_path: Optional[str] = None, - db_name: Optional[str] = None): + db_name: Optional[str] = None, + called_from_migrator = False): db_path = self.create_db_path(db_path, db_name) self.db_path = db_path @@ -140,8 +140,8 @@ def __init__(self, self._session_cls = sessionmaker(bind=self._engine) self._shared_session: Session = self._session_cls() - if connection_type is SQLConnectionType.TRADE_FILLS: - self.check_and_upgrade_trade_fills_db() + if connection_type is SQLConnectionType.TRADE_FILLS and (not called_from_migrator): + self.check_and_migrate_db() @property def engine(self) -> Engine: @@ -150,26 +150,27 @@ def engine(self) -> Engine: def get_shared_session(self) -> Session: return self._shared_session - def check_and_upgrade_trade_fills_db(self): - try: - query: Query = (self._shared_session.query(LocalMetadata) - .filter(LocalMetadata.key == self.LOCAL_DB_VERSION_KEY)) - result: Optional[LocalMetadata] = query.one_or_none() - - if result is None: - version_info: LocalMetadata = LocalMetadata(key=self.LOCAL_DB_VERSION_KEY, - value=self.LOCAL_DB_VERSION_VALUE) - self._shared_session.add(version_info) + def get_local_db_version(self): + query: Query = (self._shared_session.query(LocalMetadata) + .filter(LocalMetadata.key == self.LOCAL_DB_VERSION_KEY)) + result: Optional[LocalMetadata] = query.one_or_none() + return result + + def check_and_migrate_db(self): + from hummingbot.model.db_migration.migrator import Migrator + local_db_version = self.get_local_db_version() + if local_db_version is None: + version_info: LocalMetadata = LocalMetadata(key=self.LOCAL_DB_VERSION_KEY, + value=self.LOCAL_DB_VERSION_VALUE) + self._shared_session.add(version_info) + self._shared_session.commit() + else: + # There's no past db version to upgrade from at this moment. So we'll just update the version value + # if needed. + if local_db_version.value < self.LOCAL_DB_VERSION_VALUE: + Migrator().migrate_db_to_version(self, int(self.LOCAL_DB_VERSION_VALUE)) + local_db_version.value = self.LOCAL_DB_VERSION_VALUE self._shared_session.commit() - else: - # There's no past db version to upgrade from at this moment. So we'll just update the version value - # if needed. - if result.value < self.LOCAL_DB_VERSION_VALUE: - result.value = self.LOCAL_DB_VERSION_VALUE - self._shared_session.commit() - except SQLAlchemyError: - self.logger().error("Unexpected error while checking and upgrading the local database.", - exc_info=True) def commit(self): self._shared_session.commit() From b3a2858bcebed9c787657e325c37817d3603a456 Mon Sep 17 00:00:00 2001 From: Nicolas Baum Date: Sun, 17 Jan 2021 18:04:30 -0300 Subject: [PATCH 029/126] (feat) Minor bug fixes --- hummingbot/connector/exchange/binance/binance_exchange.pxd | 1 + hummingbot/connector/exchange/binance/binance_exchange.pyx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/hummingbot/connector/exchange/binance/binance_exchange.pxd b/hummingbot/connector/exchange/binance/binance_exchange.pxd index 853033a50b..abd79cfc92 100755 --- a/hummingbot/connector/exchange/binance/binance_exchange.pxd +++ b/hummingbot/connector/exchange/binance/binance_exchange.pxd @@ -22,6 +22,7 @@ cdef class BinanceExchange(ExchangeBase): public object _user_stream_event_listener_task public object _user_stream_tracker_task public object _trading_rules_polling_task + public object _last_update_history_reconciliation_timestamp object _async_scheduler object _set_server_time_offset_task object _throttler diff --git a/hummingbot/connector/exchange/binance/binance_exchange.pyx b/hummingbot/connector/exchange/binance/binance_exchange.pyx index 9c7c334cf1..fb97b821e3 100755 --- a/hummingbot/connector/exchange/binance/binance_exchange.pyx +++ b/hummingbot/connector/exchange/binance/binance_exchange.pyx @@ -461,7 +461,7 @@ cdef class BinanceExchange(ExchangeBase): app_warning_msg=f"Failed to fetch trade update for {trading_pair}." ) continue - self._last_update_history_reconciliation_timestamp[trading_pair] = max(trade["time"] for trade in trades) + self._last_update_history_reconciliation_timestamp[trading_pair] = max(trade["time"] for trade in trades) if trades else 0 for trade in trades: if self.is_confirmed_new_order_filled_event(str(trade["id"]), str(trade["orderId"]), trading_pair): client_order_id = get_client_order_id("buy" if trade["isBuyer"] else "sell", trading_pair) From 4b8a766a26a870a8919faacea59762b767c05162 Mon Sep 17 00:00:00 2001 From: Nicolas Baum Date: Sun, 17 Jan 2021 18:36:24 -0300 Subject: [PATCH 030/126] (feat) bug fixing --- hummingbot/connector/markets_recorder.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/hummingbot/connector/markets_recorder.py b/hummingbot/connector/markets_recorder.py index e1b71519d0..a98072bc64 100644 --- a/hummingbot/connector/markets_recorder.py +++ b/hummingbot/connector/markets_recorder.py @@ -68,7 +68,7 @@ def __init__(self, tf.symbol) for tf in trade_fills}) exchange_order_ids = self.get_orders_for_config_and_market(self._config_file_path, market, True, 2000) - market.add_exchange_order_ids_from_market_recorder({o.exchange_trade_id for o in exchange_order_ids}) + market.add_exchange_order_ids_from_market_recorder({o.exchange_order_id for o in exchange_order_ids}) self._create_order_forwarder: SourceInfoEventForwarder = SourceInfoEventForwarder(self._did_create_order) self._fill_order_forwarder: SourceInfoEventForwarder = SourceInfoEventForwarder(self._did_fill_order) @@ -119,12 +119,12 @@ def stop(self): market.remove_listener(event_pair[0], event_pair[1]) def get_orders_for_config_and_market(self, config_file_path: str, market: ConnectorBase, - with_exchange_trade_id_present: Optional[bool] = False, + with_exchange_order_id_present: Optional[bool] = False, number_of_rows: Optional[int] = None) -> List[Order]: session: Session = self.session filters = [Order.config_file_path == config_file_path, Order.market == market.display_name] - if with_exchange_trade_id_present: + if with_exchange_order_id_present: filters.append(Order.exchange_order_id.isnot(None)) query: Query = (session .query(Order) From 56b7227ef7cb68d3b1b587281764b02fe9ee4834 Mon Sep 17 00:00:00 2001 From: Nicolas Baum Date: Sun, 17 Jan 2021 19:09:09 -0300 Subject: [PATCH 031/126] (feat) removed _last_timestamp_history_reconciliation to avoid overlooking trades --- hummingbot/connector/exchange/binance/binance_exchange.pxd | 1 - hummingbot/connector/exchange/binance/binance_exchange.pyx | 4 ---- 2 files changed, 5 deletions(-) diff --git a/hummingbot/connector/exchange/binance/binance_exchange.pxd b/hummingbot/connector/exchange/binance/binance_exchange.pxd index abd79cfc92..853033a50b 100755 --- a/hummingbot/connector/exchange/binance/binance_exchange.pxd +++ b/hummingbot/connector/exchange/binance/binance_exchange.pxd @@ -22,7 +22,6 @@ cdef class BinanceExchange(ExchangeBase): public object _user_stream_event_listener_task public object _user_stream_tracker_task public object _trading_rules_polling_task - public object _last_update_history_reconciliation_timestamp object _async_scheduler object _set_server_time_offset_task object _throttler diff --git a/hummingbot/connector/exchange/binance/binance_exchange.pyx b/hummingbot/connector/exchange/binance/binance_exchange.pyx index fb97b821e3..100dd06491 100755 --- a/hummingbot/connector/exchange/binance/binance_exchange.pyx +++ b/hummingbot/connector/exchange/binance/binance_exchange.pyx @@ -146,7 +146,6 @@ cdef class BinanceExchange(ExchangeBase): self._trading_rules = {} # Dict[trading_pair:str, TradingRule] self._trade_fees = {} # Dict[trading_pair:str, (maker_fee_percent:Decimal, taken_fee_percent:Decimal)] self._last_update_trade_fees_timestamp = 0 - self._last_update_history_reconciliation_timestamp = defaultdict(lambda: 0) self._status_polling_task = None self._user_stream_event_listener_task = None self._trading_rules_polling_task = None @@ -448,8 +447,6 @@ cdef class BinanceExchange(ExchangeBase): tasks=[] for trading_pair in self._order_book_tracker._trading_pairs: my_trades_query_args = {'symbol': convert_to_exchange_trading_pair(trading_pair)} - if self._last_update_history_reconciliation_timestamp[trading_pair]>0: - my_trades_query_args.update({'startTime': self._last_update_history_reconciliation_timestamp[trading_pair]+1}) tasks.append(self.query_api(self._binance_client.get_my_trades, **my_trades_query_args)) self.logger().debug("Polling for order fills of %d trading pairs.", len(tasks)) @@ -461,7 +458,6 @@ cdef class BinanceExchange(ExchangeBase): app_warning_msg=f"Failed to fetch trade update for {trading_pair}." ) continue - self._last_update_history_reconciliation_timestamp[trading_pair] = max(trade["time"] for trade in trades) if trades else 0 for trade in trades: if self.is_confirmed_new_order_filled_event(str(trade["id"]), str(trade["orderId"]), trading_pair): client_order_id = get_client_order_id("buy" if trade["isBuyer"] else "sell", trading_pair) From 800c8672af80864cde92d50fe62e2a222968e187 Mon Sep 17 00:00:00 2001 From: Nullably <37262506+Nullably@users.noreply.github.com> Date: Mon, 18 Jan 2021 10:30:28 +0800 Subject: [PATCH 032/126] (feat) add comment --- hummingbot/strategy/liquidity_mining/liquidity_mining.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/hummingbot/strategy/liquidity_mining/liquidity_mining.py b/hummingbot/strategy/liquidity_mining/liquidity_mining.py index b474cc90e8..47798d1f3c 100644 --- a/hummingbot/strategy/liquidity_mining/liquidity_mining.py +++ b/hummingbot/strategy/liquidity_mining/liquidity_mining.py @@ -111,6 +111,9 @@ def create_base_proposals(self): return proposals def apply_volatility_adjustment(self, proposals): + # Todo: apply volatility spread adjustment, the volatility can be calculated from OHLC + # (maybe ATR or hourly candle?) or from the mid price movement (see + # scripts/spreads_adjusted_on_volatility_script.py). return def allocate_capital(self, proposals: List[Proposal]): From 0b8c6520a33416b30c56085a533f53fb0420fd2e Mon Sep 17 00:00:00 2001 From: Nicolas Baum Date: Mon, 18 Jan 2021 01:14:54 -0300 Subject: [PATCH 033/126] (feat) Fixed bug in migration succesful check and local db version change commit --- hummingbot/model/db_migration/migrator.py | 2 +- hummingbot/model/sql_connection_manager.py | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/hummingbot/model/db_migration/migrator.py b/hummingbot/model/db_migration/migrator.py index 50e426e11e..26ad4a8c58 100644 --- a/hummingbot/model/db_migration/migrator.py +++ b/hummingbot/model/db_migration/migrator.py @@ -57,4 +57,4 @@ def migrate_db_to_version(self, db_handle, to_version): except Exception as e: logging.getLogger().error(f"Fatal error migrating DB {original_db_path}") raise e - return db_handle + return migration_succesful diff --git a/hummingbot/model/sql_connection_manager.py b/hummingbot/model/sql_connection_manager.py index 23f53331f8..cc09440d8e 100644 --- a/hummingbot/model/sql_connection_manager.py +++ b/hummingbot/model/sql_connection_manager.py @@ -168,9 +168,11 @@ def check_and_migrate_db(self): # There's no past db version to upgrade from at this moment. So we'll just update the version value # if needed. if local_db_version.value < self.LOCAL_DB_VERSION_VALUE: - Migrator().migrate_db_to_version(self, int(self.LOCAL_DB_VERSION_VALUE)) - local_db_version.value = self.LOCAL_DB_VERSION_VALUE - self._shared_session.commit() + was_migration_succesful = Migrator().migrate_db_to_version(self, int(self.LOCAL_DB_VERSION_VALUE)) + if was_migration_succesful: + # Cannot use variable local_db_version because reference is not valid since Migrator changed it + self.get_local_db_version().value = self.LOCAL_DB_VERSION_VALUE + self._shared_session.commit() def commit(self): self._shared_session.commit() From e9f8ab0b4220a1fc6cc7eb433bb8179e059a0028 Mon Sep 17 00:00:00 2001 From: Nicolas Baum Date: Mon, 18 Jan 2021 02:22:01 -0300 Subject: [PATCH 034/126] (feat) Fixed history recon of partially filled in_flight orders --- .../exchange/binance/binance_exchange.pyx | 37 ++++++++++--------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/hummingbot/connector/exchange/binance/binance_exchange.pyx b/hummingbot/connector/exchange/binance/binance_exchange.pyx index 100dd06491..4390c4b4f2 100755 --- a/hummingbot/connector/exchange/binance/binance_exchange.pyx +++ b/hummingbot/connector/exchange/binance/binance_exchange.pyx @@ -461,23 +461,26 @@ cdef class BinanceExchange(ExchangeBase): for trade in trades: if self.is_confirmed_new_order_filled_event(str(trade["id"]), str(trade["orderId"]), trading_pair): client_order_id = get_client_order_id("buy" if trade["isBuyer"] else "sell", trading_pair) - self.c_trigger_event(self.MARKET_ORDER_FILLED_EVENT_TAG, - OrderFilledEvent( - trade["time"], - client_order_id, - trading_pair, - TradeType.BUY if trade["isBuyer"] else TradeType.SELL, - OrderType.LIMIT_MAKER, # defaulting to this value since trade info lacks field - Decimal(trade["price"]), - Decimal(trade["qty"]), - TradeFee( - percent=Decimal(0.0), - flat_fees=[(trade["commissionAsset"], - Decimal(trade["commission"]))] - ), - exchange_trade_id=trade["id"] - )) - self.logger().info(f"Recreating missing trade in TradeFill: {trade}") + # Should check if this is a partial filling of a in_flight order. + # In that case, user_stream or _update_order_fills_from_trades will take care when fully filled. + if client_order_id not in self.in_flight_orders: + self.c_trigger_event(self.MARKET_ORDER_FILLED_EVENT_TAG, + OrderFilledEvent( + trade["time"], + client_order_id, + trading_pair, + TradeType.BUY if trade["isBuyer"] else TradeType.SELL, + OrderType.LIMIT_MAKER, # defaulting to this value since trade info lacks field + Decimal(trade["price"]), + Decimal(trade["qty"]), + TradeFee( + percent=Decimal(0.0), + flat_fees=[(trade["commissionAsset"], + Decimal(trade["commission"]))] + ), + exchange_trade_id=trade["id"] + )) + self.logger().info(f"Recreating missing trade in TradeFill: {trade}") async def _update_order_status(self): cdef: From 99be82329c2efa6ba7a5dd59018e585e3e94b176 Mon Sep 17 00:00:00 2001 From: Nullably <37262506+Nullably@users.noreply.github.com> Date: Mon, 18 Jan 2021 21:27:48 +0800 Subject: [PATCH 035/126] (feat) add format_status --- .../liquidity_mining/liquidity_mining.py | 49 +++++++++++++++++-- 1 file changed, 46 insertions(+), 3 deletions(-) diff --git a/hummingbot/strategy/liquidity_mining/liquidity_mining.py b/hummingbot/strategy/liquidity_mining/liquidity_mining.py index 47798d1f3c..f27be0c60f 100644 --- a/hummingbot/strategy/liquidity_mining/liquidity_mining.py +++ b/hummingbot/strategy/liquidity_mining/liquidity_mining.py @@ -2,6 +2,8 @@ import logging import asyncio from typing import Dict, List, Set +import pandas as pd +import time from hummingbot.core.clock import Clock from hummingbot.logger import HummingbotLogger from hummingbot.strategy.strategy_py_base import StrategyPyBase @@ -11,6 +13,7 @@ from hummingbot.core.event.events import OrderType 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 NaN = float("nan") s_decimal_zero = Decimal(0) @@ -84,9 +87,48 @@ def tick(self, timestamp: float): self._last_timestamp = timestamp + async def active_orders_df(self) -> pd.DataFrame: + columns = ["Market", "Side", "Price", "Spread", "Size", "Size ($)", "Age"] + data = [] + for order in self.active_orders: + mid_price = self._market_infos[order.trading_pair].get_mid_price() + spread = 0 if mid_price == 0 else abs(order.price - mid_price) / mid_price + size_usd = await usd_value(order.trading_pair.split("-")[0], order.quantity) + 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') + data.append([ + order.trading_pair, + "buy" if order.is_buy else "sell", + float(order.price), + f"{spread:.2%}", + float(order.quantity), + round(size_usd), + age + ]) + + return pd.DataFrame(data=data, columns=columns) + async def format_status(self) -> str: - from hummingbot.client.hummingbot_application import HummingbotApplication - HummingbotApplication.main_application().open_orders(full_report=True) + if not self._ready_to_trade: + return "Market connectors are not ready." + lines = [] + warning_lines = [] + warning_lines.extend(self.network_warning(list(self._market_infos.values()))) + + # See if there're any open orders. + if len(self.active_orders) > 0: + df = await 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."]) + + warning_lines.extend(self.balance_warning(list(self._market_infos.values()))) + if len(warning_lines) > 0: + lines.extend(["", "*** WARNINGS ***"] + warning_lines) + return "\n".join(lines) def start(self, clock: Clock, timestamp: float): pass @@ -210,7 +252,8 @@ def adjusted_available_balances(self) -> Dict[str, Decimal]: """ tokens = self.all_tokens() adjusted_bals = {t: s_decimal_zero for t in tokens} - total_bals = self._exchange.get_all_balances() + total_bals = {t: s_decimal_zero for t in tokens} + total_bals.update(self._exchange.get_all_balances()) for token in tokens: adjusted_bals[token] = self._exchange.get_available_balance(token) for order in self.active_orders: From fa4e925218c91698e52288323f8f8b6b9f00f878 Mon Sep 17 00:00:00 2001 From: vic-en Date: Mon, 18 Jan 2021 15:24:24 +0100 Subject: [PATCH 036/126] (feat) add leverage to db and binance perp connector --- .../binance_perpetual/binance_perpetual_derivative.py | 11 +++++++++-- hummingbot/connector/markets_recorder.py | 6 ++++-- hummingbot/core/event/events.py | 5 +++++ hummingbot/model/order.py | 4 +++- hummingbot/model/sql_connection_manager.py | 7 ++++--- hummingbot/model/trade_fill.py | 7 +++++-- 6 files changed, 30 insertions(+), 10 deletions(-) diff --git a/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_derivative.py b/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_derivative.py index 620dc56491..6000146cde 100644 --- a/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_derivative.py +++ b/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_derivative.py @@ -134,6 +134,7 @@ def __init__(self, self._funding_rate = 0 self._account_positions = {} self._position_mode = None + self._leverage = 1 @property def name(self) -> str: @@ -274,7 +275,8 @@ async def create_order(self, trading_pair, amount, price, - order_id)) + order_id, + leverage=self._leverage)) return order_result except asyncio.CancelledError: raise @@ -496,6 +498,7 @@ async def _user_stream_event_listener(self): order_type=OrderType.LIMIT if order_message.get("o") == "LIMIT" else OrderType.MARKET, price=Decimal(order_message.get("L")), amount=Decimal(order_message.get("l")), + leverage=self._leverage, trade_fee=self.get_fee( base_currency=tracked_order.base_asset, quote_currency=tracked_order.quote_asset, @@ -771,6 +774,8 @@ async def _update_order_fills_from_trades(self): order_type = tracked_order.order_type applied_trade = tracked_order.update_with_trade_updates(trade) if applied_trade: + self.logger().info(f"The {order_type.name.lower()} {tracked_order.trade_type.name.lower()} order {order_id} has " + f"been filled according to order status API.") self.trigger_event( self.MARKET_ORDER_FILLED_EVENT_TAG, OrderFilledEvent( @@ -788,7 +793,8 @@ async def _update_order_fills_from_trades(self): tracked_order.trade_type, Decimal(trade["price"]), Decimal(trade["qty"])), - exchange_trade_id=trade["id"] + exchange_trade_id=trade["id"], + leverage=self._leverage ) ) @@ -886,6 +892,7 @@ async def _set_margin(self, trading_pair: str, leverage: int = 1): is_signed=True ) if set_leverage["leverage"] == leverage: + self._leverage = leverage self.logger().info(f"Leverage Successfully set to {leverage}.") else: self.logger().error("Unable to set leverage.") diff --git a/hummingbot/connector/markets_recorder.py b/hummingbot/connector/markets_recorder.py index 76a59cbcea..43cab6caf3 100644 --- a/hummingbot/connector/markets_recorder.py +++ b/hummingbot/connector/markets_recorder.py @@ -182,6 +182,7 @@ def _did_create_order(self, creation_timestamp=timestamp, order_type=evt.type.name, amount=float(evt.amount), + leverage=evt.leverage if evt.leverage else 1, price=float(evt.price) if evt.price == evt.price else 0, last_status=event_type.name, last_update_timestamp=timestamp) @@ -230,6 +231,7 @@ def _did_fill_order(self, order_type=evt.order_type.name, price=float(evt.price) if evt.price == evt.price else 0, amount=float(evt.amount), + leverage=evt.leverage if evt.leverage else 1, trade_fee=TradeFee.to_json(evt.trade_fee), exchange_trade_id=evt.exchange_trade_id) session.add(order_status) @@ -247,10 +249,10 @@ def append_to_csv(self, trade: TradeFill): age = pd.Timestamp(int(trade.timestamp / 1e3 - int(trade.order_id[-16:]) / 1e6), unit='s').strftime('%H:%M:%S') if not os.path.exists(csv_path): df_header = pd.DataFrame([["Config File", "Strategy", "Exchange", "Timestamp", "Market", "Base", "Quote", - "Trade", "Type", "Price", "Amount", "Fee", "Age", "Order ID", "Exchange Trade ID"]]) + "Trade", "Type", "Price", "Amount", "Leverage", "Fee", "Age", "Order ID", "Exchange Trade ID"]]) df_header.to_csv(csv_path, mode='a', header=False, index=False) df = pd.DataFrame([[trade.config_file_path, trade.strategy, trade.market, trade.timestamp, trade.symbol, trade.base_asset, trade.quote_asset, - trade.trade_type, trade.order_type, trade.price, trade.amount, trade.trade_fee, age, trade.order_id, trade.exchange_trade_id]]) + trade.trade_type, trade.order_type, trade.price, trade.amount, trade.leverage, trade.trade_fee, age, trade.order_id, trade.exchange_trade_id]]) df.to_csv(csv_path, mode='a', header=False, index=False) def _update_order_status(self, diff --git a/hummingbot/core/event/events.py b/hummingbot/core/event/events.py index a59c4fa86c..603c5fff3a 100644 --- a/hummingbot/core/event/events.py +++ b/hummingbot/core/event/events.py @@ -174,6 +174,7 @@ class BuyOrderCompletedEvent: fee_amount: Decimal order_type: OrderType exchange_order_id: Optional[str] = None + leverage: Optional[int] = 1 @dataclass @@ -188,6 +189,7 @@ class SellOrderCompletedEvent: fee_amount: Decimal order_type: OrderType exchange_order_id: Optional[str] = None + leverage: Optional[int] = 1 @dataclass @@ -294,6 +296,7 @@ class OrderFilledEvent(NamedTuple): amount: Decimal trade_fee: TradeFee exchange_trade_id: str = "" + leverage: Optional[int] = 1 @classmethod def order_filled_events_from_order_book_rows(cls, @@ -338,6 +341,7 @@ class BuyOrderCreatedEvent: price: Decimal order_id: str exchange_order_id: Optional[str] = None + leverage: Optional[int] = 1 @dataclass @@ -349,3 +353,4 @@ class SellOrderCreatedEvent: price: Decimal order_id: str exchange_order_id: Optional[str] = None + leverage: Optional[int] = 1 diff --git a/hummingbot/model/order.py b/hummingbot/model/order.py index 736a6b5055..4670266576 100644 --- a/hummingbot/model/order.py +++ b/hummingbot/model/order.py @@ -9,6 +9,7 @@ Text, Index, BigInteger, + Integer, Float ) from sqlalchemy.orm import relationship @@ -37,6 +38,7 @@ class Order(HummingbotBase): creation_timestamp = Column(BigInteger, nullable=False) order_type = Column(Text, nullable=False) amount = Column(Float, nullable=False) + leverage = Column(Integer, nullable=False, default=1) price = Column(Float, nullable=False) last_status = Column(Text, nullable=False) last_update_timestamp = Column(BigInteger, nullable=False) @@ -47,7 +49,7 @@ def __repr__(self) -> str: return f"Order(id={self.id}, config_file_path='{self.config_file_path}', strategy='{self.strategy}', " \ f"market='{self.market}', symbol='{self.symbol}', base_asset='{self.base_asset}', " \ f"quote_asset='{self.quote_asset}', creation_timestamp={self.creation_timestamp}, " \ - f"order_type='{self.order_type}', amount={self.amount}, " \ + f"order_type='{self.order_type}', amount={self.amount}, leverage={self.leverage}, " \ f"price={self.price}, last_status='{self.last_status}', " \ f"last_update_timestamp={self.last_update_timestamp})" diff --git a/hummingbot/model/sql_connection_manager.py b/hummingbot/model/sql_connection_manager.py index 7ad289b1a3..5ba7fca6a3 100644 --- a/hummingbot/model/sql_connection_manager.py +++ b/hummingbot/model/sql_connection_manager.py @@ -47,7 +47,8 @@ class SQLConnectionManager: _scm_trade_fills_instance: Optional["SQLConnectionManager"] = None LOCAL_DB_VERSION_KEY = "local_db_version" - LOCAL_DB_VERSION_VALUE = "20190614" + # LOCAL_DB_VERSION_VALUE = "20190614" + LOCAL_DB_VERSION_VALUE = "20210114" @classmethod def logger(cls) -> HummingbotLogger: @@ -162,11 +163,11 @@ def check_and_upgrade_trade_fills_db(self): self._shared_session.add(version_info) self._shared_session.commit() else: - # There's no past db version to upgrade from at this moment. So we'll just update the version value - # if needed. if result.value < self.LOCAL_DB_VERSION_VALUE: result.value = self.LOCAL_DB_VERSION_VALUE self._shared_session.commit() + self._shared_session.execute("ALTER TABLE `TradeFill` ADD COLUMN leverage INTEGER AFTER amount DEFAULT 1;") + self._shared_session.execute("ALTER TABLE `Order` ADD COLUMN leverage INTEGER AFTER amount DEFAULT 1;") except SQLAlchemyError: self.logger().error("Unexpected error while checking and upgrading the local database.", exc_info=True) diff --git a/hummingbot/model/trade_fill.py b/hummingbot/model/trade_fill.py index 5476982416..f3d41f0fb6 100644 --- a/hummingbot/model/trade_fill.py +++ b/hummingbot/model/trade_fill.py @@ -51,6 +51,7 @@ class TradeFill(HummingbotBase): order_type = Column(Text, nullable=False) price = Column(Float, nullable=False) amount = Column(Float, nullable=False) + leverage = Column(Integer, nullable=False, default=1) trade_fee = Column(JSON, nullable=False) exchange_trade_id = Column(Text, nullable=False) order = relationship("Order", back_populates="trade_fills") @@ -59,8 +60,8 @@ def __repr__(self) -> str: return f"TradeFill(id={self.id}, config_file_path='{self.config_file_path}', strategy='{self.strategy}', " \ f"market='{self.market}', symbol='{self.symbol}', base_asset='{self.base_asset}', " \ f"quote_asset='{self.quote_asset}', timestamp={self.timestamp}, order_id='{self.order_id}', " \ - f"trade_type='{self.trade_type}', order_type='{self.order_type}', price={self.price}, " \ - f"amount={self.amount}, trade_fee={self.trade_fee}, exchange_trade_id={self.exchange_trade_id})" + f"trade_type='{self.trade_type}', order_type='{self.order_type}', price={self.price}, amount={self.amount}, " \ + f"leverage={self.leverage}, trade_fee={self.trade_fee}, exchange_trade_id={self.exchange_trade_id})" @staticmethod def get_trades(sql_session: Session, @@ -111,6 +112,7 @@ def to_pandas(cls, trades: List): "Side", "Price", "Amount", + "Leverage", "Age"] data = [] index = 0 @@ -139,6 +141,7 @@ def to_pandas(cls, trades: List): trade.trade_type.lower(), trade.price, trade.amount, + trade.leverage, age, ]) df = pd.DataFrame(data=data, columns=columns) From e93098a4b5a6a989ea6a9ee2fbcbd67ff4c9e855 Mon Sep 17 00:00:00 2001 From: Nicolas Baum Date: Mon, 18 Jan 2021 14:02:14 -0300 Subject: [PATCH 037/126] (feat) Changed way from_version property is used in transformations --- hummingbot/model/db_migration/base_transformation.py | 12 +++++++----- hummingbot/model/db_migration/migrator.py | 4 ++-- hummingbot/model/db_migration/transformations.py | 6 +----- hummingbot/model/sql_connection_manager.py | 4 ++-- 4 files changed, 12 insertions(+), 14 deletions(-) diff --git a/hummingbot/model/db_migration/base_transformation.py b/hummingbot/model/db_migration/base_transformation.py index bd3e062a9a..6580e84b62 100644 --- a/hummingbot/model/db_migration/base_transformation.py +++ b/hummingbot/model/db_migration/base_transformation.py @@ -21,18 +21,20 @@ def name(self): return "" @property - @abstractmethod def from_version(self): - return None + return 0 @property @abstractmethod def to_version(self): return None - def does_apply_to_version(self, version: int) -> bool: - if self.to_version is not None and self.from_version is not None: - return version <= self.to_version + def does_apply_to_version(self, original_version: int, target_version: int) -> bool: + if self.to_version is not None: + # from_version > 0 means from_version property was overridden by transformation class + if self.from_version > 0: + return (self.from_version >= original_version) and (self.to_version <= target_version) + return self.to_version <= target_version return False def __eq__(self, other): diff --git a/hummingbot/model/db_migration/migrator.py b/hummingbot/model/db_migration/migrator.py index 26ad4a8c58..d44cb71dd6 100644 --- a/hummingbot/model/db_migration/migrator.py +++ b/hummingbot/model/db_migration/migrator.py @@ -20,7 +20,7 @@ def _get_transformations(cls): def __init__(self): self.transformations = [t(self) for t in self._get_transformations()] - def migrate_db_to_version(self, db_handle, to_version): + def migrate_db_to_version(self, db_handle, from_version, to_version): original_db_path = db_handle.db_path original_db_name = Path(original_db_path).stem backup_db_path = original_db_path + '.backup_' + pd.Timestamp.utcnow().strftime("%Y%m%d-%H%M%S") @@ -32,7 +32,7 @@ def migrate_db_to_version(self, db_handle, to_version): db_handle.engine.dispose() new_db_handle = SQLConnectionManager(SQLConnectionType.TRADE_FILLS, new_db_path, original_db_name, True) - relevant_transformations = [t for t in self.transformations if t.does_apply_to_version(to_version)] + relevant_transformations = [t for t in self.transformations if t.does_apply_to_version(from_version, to_version)] if relevant_transformations: logging.getLogger().info( f"Will run DB migration from {db_handle.get_local_db_version().value} to {to_version}") diff --git a/hummingbot/model/db_migration/transformations.py b/hummingbot/model/db_migration/transformations.py index 855c0f9839..1b53ec9332 100644 --- a/hummingbot/model/db_migration/transformations.py +++ b/hummingbot/model/db_migration/transformations.py @@ -19,10 +19,6 @@ def apply(self, db_handle: SQLConnectionManager) -> SQLConnectionManager: def name(self): return "AddExchangeOrderIdColumnToOrders" - @property - def from_version(self): - return 20190614 - @property def to_version(self): - return 20210114 + return 20210118 diff --git a/hummingbot/model/sql_connection_manager.py b/hummingbot/model/sql_connection_manager.py index cc09440d8e..68465e489d 100644 --- a/hummingbot/model/sql_connection_manager.py +++ b/hummingbot/model/sql_connection_manager.py @@ -46,7 +46,7 @@ class SQLConnectionManager: _scm_trade_fills_instance: Optional["SQLConnectionManager"] = None LOCAL_DB_VERSION_KEY = "local_db_version" - LOCAL_DB_VERSION_VALUE = "20210114" + LOCAL_DB_VERSION_VALUE = "20210118" @classmethod def logger(cls) -> HummingbotLogger: @@ -168,7 +168,7 @@ def check_and_migrate_db(self): # There's no past db version to upgrade from at this moment. So we'll just update the version value # if needed. if local_db_version.value < self.LOCAL_DB_VERSION_VALUE: - was_migration_succesful = Migrator().migrate_db_to_version(self, int(self.LOCAL_DB_VERSION_VALUE)) + was_migration_succesful = Migrator().migrate_db_to_version(self, int(local_db_version.value), int(self.LOCAL_DB_VERSION_VALUE)) if was_migration_succesful: # Cannot use variable local_db_version because reference is not valid since Migrator changed it self.get_local_db_version().value = self.LOCAL_DB_VERSION_VALUE From 47d6dcdac7768cc3b6e40a8bd2fed00e0134d4d6 Mon Sep 17 00:00:00 2001 From: Michael Borraccia Date: Mon, 18 Jan 2021 13:40:06 -0500 Subject: [PATCH 038/126] Upgrade to the newer version of this connector --- .../blocktane_api_order_book_data_source.py | 85 ++++++++++--------- .../blocktane_api_user_stream_data_source.py | 64 ++++---------- .../exchange/blocktane/blocktane_exchange.pxd | 1 - .../exchange/blocktane/blocktane_exchange.pyx | 15 ++-- .../blocktane/blocktane_order_book_tracker.py | 18 ---- 5 files changed, 67 insertions(+), 116 deletions(-) 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 fb038916be..71198bb609 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 @@ -134,24 +134,19 @@ async def get_new_order_book(self, trading_pair: str) -> OrderBook: order_book.apply_snapshot(snapshot_msg.bids, snapshot_msg.asks, snapshot_msg.update_id) return order_book - async def _inner_messages(self, - ws: websockets.WebSocketClientProtocol) -> AsyncIterable[str]: + def get_ws_connection(self, stream_url): + ws = websockets.connect(stream_url) + return ws + + async def _inner_messages(self, ws: websockets.WebSocketClientProtocol) -> AsyncIterable[str]: # Terminate the recv() loop as soon as the next message timed out, so the outer loop can reconnect. - try: - while True: - try: - msg: str = await asyncio.wait_for(ws.recv(), timeout=self.MESSAGE_TIMEOUT) - yield msg - except asyncio.TimeoutError: - pong_waiter = await ws.ping() - await asyncio.wait_for(pong_waiter, timeout=self.PING_TIMEOUT) - except asyncio.TimeoutError: - self.logger().warning("WebSocket ping timed out. Going to reconnect...") - raise - except websockets.exceptions.ConnectionClosed: - raise - finally: - await ws.close() + while True: + try: + msg: str = await asyncio.wait_for(ws.recv(), timeout=self.MESSAGE_TIMEOUT) + yield msg + except asyncio.TimeoutError: + pong_waiter = await ws.ping() + await asyncio.wait_for(pong_waiter, timeout=self.PING_TIMEOUT) async def listen_for_trades(self, ev_loop: asyncio.BaseEventLoop, output: asyncio.Queue): while True: @@ -159,19 +154,22 @@ async def listen_for_trades(self, ev_loop: asyncio.BaseEventLoop, output: asynci ws_path: str = "&stream=".join([f"{convert_to_exchange_trading_pair(trading_pair)}.trades" for trading_pair in self._trading_pairs]) stream_url: str = f"{DIFF_STREAM_URL}/?stream={ws_path}" - async with websockets.connect(stream_url) as ws: - ws: websockets.WebSocketClientProtocol = ws - async for raw_msg in self._inner_messages(ws): - msg = ujson.loads(raw_msg) - if (list(msg.keys())[0].endswith("trades")): - trade_msg: OrderBookMessage = BlocktaneOrderBook.trade_message_from_exchange(msg) - output.put_nowait(trade_msg) + ws: websockets.WebSocketClientProtocol = await self.get_ws_connection(stream_url) + async for raw_msg in self._inner_messages(ws): + msg = ujson.loads(raw_msg) + if (list(msg.keys())[0].endswith("trades")): + trade_msg: OrderBookMessage = BlocktaneOrderBook.trade_message_from_exchange(msg) + output.put_nowait(trade_msg) except asyncio.CancelledError: raise + except asyncio.TimeoutError: + self.logger().warning("WebSocket ping timed out. Reconnecting after 30 seconds...") except Exception: - self.logger().error("Unexpected error with WebSocket connection. Retrying after 30 seconds...", - exc_info=True) - await asyncio.sleep(30.0) + self.logger().error("Unexpected error while maintaining the user event listen key. Retrying after " + "30 seconds...", exc_info=True) + finally: + await ws.close() + await asyncio.sleep(30) async def listen_for_order_book_diffs(self, ev_loop: asyncio.BaseEventLoop, output: asyncio.Queue): while True: @@ -179,24 +177,27 @@ async def listen_for_order_book_diffs(self, ev_loop: asyncio.BaseEventLoop, outp ws_path: str = "&stream=".join([f"{convert_to_exchange_trading_pair(trading_pair)}.ob-inc" for trading_pair in self._trading_pairs]) stream_url: str = f"{DIFF_STREAM_URL}/?stream={ws_path}" - async with websockets.connect(stream_url) as ws: - ws: websockets.WebSocketClientProtocol = ws - async for raw_msg in self._inner_messages(ws): - msg = ujson.loads(raw_msg) - key = list(msg.keys())[0] - if ('ob-inc' in key): - pair = re.sub(r'\.ob-inc', '', key) - parsed_msg = {"pair": convert_from_exchange_trading_pair(pair), - "bids": msg[key]["bids"] if "bids" in msg[key] else [], - "asks": msg[key]["asks"] if "asks" in msg[key] else []} - order_book_message: OrderBookMessage = BlocktaneOrderBook.diff_message_from_exchange(parsed_msg, time.time()) - output.put_nowait(order_book_message) + ws: websockets.WebSocketClientProtocol = await self.get_ws_connection(stream_url) + async for raw_msg in self._inner_messages(ws): + msg = ujson.loads(raw_msg) + key = list(msg.keys())[0] + if ('ob-inc' in key): + pair = re.sub(r'\.ob-inc', '', key) + parsed_msg = {"pair": convert_from_exchange_trading_pair(pair), + "bids": msg[key]["bids"] if "bids" in msg[key] else [], + "asks": msg[key]["asks"] if "asks" in msg[key] else []} + order_book_message: OrderBookMessage = BlocktaneOrderBook.diff_message_from_exchange(parsed_msg, time.time()) + output.put_nowait(order_book_message) except asyncio.CancelledError: raise + except asyncio.TimeoutError: + self.logger().warning("WebSocket ping timed out. Reconnecting after 30 seconds...") except Exception: - self.logger().error("Unexpected error with WebSocket connection. Retrying after 30 seconds...", - exc_info=True) - await asyncio.sleep(30.0) + self.logger().error("Unexpected error while maintaining the user event listen key. Retrying after " + "30 seconds...", exc_info=True) + finally: + await ws.close() + await asyncio.sleep(30) async def listen_for_order_book_snapshots(self, ev_loop: asyncio.BaseEventLoop, output: asyncio.Queue): while True: diff --git a/hummingbot/connector/exchange/blocktane/blocktane_api_user_stream_data_source.py b/hummingbot/connector/exchange/blocktane/blocktane_api_user_stream_data_source.py index 25e2b60673..fb907fdc74 100644 --- a/hummingbot/connector/exchange/blocktane/blocktane_api_user_stream_data_source.py +++ b/hummingbot/connector/exchange/blocktane/blocktane_api_user_stream_data_source.py @@ -12,7 +12,6 @@ List ) -from hummingbot.core.utils.async_utils import safe_ensure_future from hummingbot.connector.exchange.blocktane.blocktane_auth import BlocktaneAuth from hummingbot.core.data_type.user_stream_tracker_data_source import UserStreamTrackerDataSource from hummingbot.logger import HummingbotLogger @@ -34,73 +33,44 @@ def logger(cls) -> HummingbotLogger: return cls._bausds_logger def __init__(self, blocktane_auth: BlocktaneAuth, trading_pairs: Optional[List[str]] = None): + super().__init__() self._blocktane_auth: BlocktaneAuth = blocktane_auth - 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 - @staticmethod - async def wait_til_next_tick(seconds: float = 1.0): - now: float = time.time() - current_tick: int = int(now // seconds) - delay_til_next_tick: float = (current_tick + 1) * seconds - now - await asyncio.sleep(delay_til_next_tick) - async def listen_for_user_stream(self, ev_loop: asyncio.BaseEventLoop, output: asyncio.Queue): while True: try: - if self._listen_for_user_stream_task is not None: - self._listen_for_user_stream_task.cancel() - self._listen_for_user_stream_task = safe_ensure_future(self.log_user_stream(output)) - await self.wait_til_next_tick(seconds=60.0) + ws = await self.get_ws_connection() + async for message in self._inner_messages(ws): + decoded: Dict[str, any] = ujson.loads(message) + output.put_nowait(decoded) except asyncio.CancelledError: raise + except asyncio.TimeoutError: + self.logger().warning("WebSocket ping timed out. Reconnecting after 5 seconds...") except Exception: self.logger().error("Unexpected error while maintaining the user event listen key. Retrying after " "5 seconds...", exc_info=True) + finally: + await ws.close() await asyncio.sleep(5) async def _inner_messages(self, ws) -> AsyncIterable[str]: # Terminate the recv() loop as soon as the next message timed out, so the outer loop can reconnect. - try: - while True: - try: - msg: str = await asyncio.wait_for(ws.recv(), timeout=self.MESSAGE_TIMEOUT) - self._last_recv_time = time.time() - yield msg - except asyncio.TimeoutError: - pong_waiter = await ws.ping() - await asyncio.wait_for(pong_waiter, timeout=self.PING_TIMEOUT) - self._last_recv_time = time.time() - except asyncio.TimeoutError: - self.logger().warning("WebSocket ping timed out. Going to reconnect...") - raise - except websockets.exceptions.ConnectionClosed: - raise - finally: - await ws.close() - - async def messages(self) -> AsyncIterable[str]: - ws = await self.get_ws_connection() - while(True): - async for msg in self._inner_messages(ws): + while True: + try: + msg: str = await asyncio.wait_for(ws.recv(), timeout=self.MESSAGE_TIMEOUT) + self._last_recv_time = time.time() yield msg + except asyncio.TimeoutError: + pong_waiter = await ws.ping() + await asyncio.wait_for(pong_waiter, timeout=self.PING_TIMEOUT) + self._last_recv_time = time.time() def get_ws_connection(self): ws = websockets.connect(WS_BASE_URL, extra_headers=self._blocktane_auth.generate_auth_dict()) - return ws - - async def log_user_stream(self, output: asyncio.Queue): - while True: - try: - async for message in self.messages(): - decoded: Dict[str, any] = ujson.loads(message) - output.put_nowait(decoded) - except Exception: - self.logger().error("Unexpected error. Retrying after 5 seconds...", exc_info=True) - await asyncio.sleep(5.0) diff --git a/hummingbot/connector/exchange/blocktane/blocktane_exchange.pxd b/hummingbot/connector/exchange/blocktane/blocktane_exchange.pxd index 535e3a204b..c30dd88e35 100644 --- a/hummingbot/connector/exchange/blocktane/blocktane_exchange.pxd +++ b/hummingbot/connector/exchange/blocktane/blocktane_exchange.pxd @@ -20,7 +20,6 @@ cdef class BlocktaneExchange(ExchangeBase): double _poll_interval dict _trading_rules public object _coro_scheduler_task - public object _order_tracker_task public object _shared_client public object _status_polling_task public object _trading_rules_polling_task diff --git a/hummingbot/connector/exchange/blocktane/blocktane_exchange.pyx b/hummingbot/connector/exchange/blocktane/blocktane_exchange.pyx index e8fe3ca41c..2f96920e32 100644 --- a/hummingbot/connector/exchange/blocktane/blocktane_exchange.pyx +++ b/hummingbot/connector/exchange/blocktane/blocktane_exchange.pyx @@ -113,7 +113,6 @@ cdef class BlocktaneExchange(ExchangeBase): self._last_timestamp = 0 self._order_book_tracker = BlocktaneOrderBookTracker(trading_pairs=trading_pairs) self._order_not_found_records = {} - self._order_tracker_task = None self._poll_notifier = asyncio.Event() self._poll_interval = poll_interval self._shared_client = None @@ -944,6 +943,9 @@ cdef class BlocktaneExchange(ExchangeBase): safe_ensure_future(self.execute_cancel(trading_pair, order_id)) return order_id + def cancel(self, trading_pair: str, client_order_id: str): + return self.c_cancel(trading_pair, client_order_id) + async def cancel_all(self, timeout_seconds: float) -> List[CancellationResult]: incomplete_orders = [order for order in self._in_flight_orders.values() if not order.is_done] tasks = [self.execute_cancel(o.trading_pair, o.client_order_id) for o in incomplete_orders] @@ -1017,25 +1019,22 @@ cdef class BlocktaneExchange(ExchangeBase): return NetworkStatus.CONNECTED def _stop_network(self): - if self._order_tracker_task is not None: - self._order_tracker_task.cancel() + self._order_book_tracker.stop() if self._status_polling_task is not None: self._status_polling_task.cancel() if self._user_stream_tracker_task is not None: self._user_stream_tracker_task.cancel() if self._user_stream_event_listener_task is not None: self._user_stream_event_listener_task.cancel() - self._order_tracker_task = self._status_polling_task = self._user_stream_tracker_task = \ + self._status_polling_task = self._user_stream_tracker_task = \ self._user_stream_event_listener_task = None async def stop_network(self): self._stop_network() async def start_network(self): - if self._order_tracker_task is not None: - self._stop_network() - - self._order_tracker_task = safe_ensure_future(self._order_book_tracker.start()) + self._stop_network() + self._order_book_tracker.start() self._trading_rules_polling_task = safe_ensure_future(self._trading_rules_polling_loop()) if self._trading_required: self._status_polling_task = safe_ensure_future(self._status_polling_loop()) diff --git a/hummingbot/connector/exchange/blocktane/blocktane_order_book_tracker.py b/hummingbot/connector/exchange/blocktane/blocktane_order_book_tracker.py index 982db9d4f8..0a856e09d0 100644 --- a/hummingbot/connector/exchange/blocktane/blocktane_order_book_tracker.py +++ b/hummingbot/connector/exchange/blocktane/blocktane_order_book_tracker.py @@ -46,24 +46,6 @@ def __init__(self, def exchange_name(self) -> str: return "blocktane" - async def start(self): - super().start() - self._order_book_trade_listener_task = safe_ensure_future( - self.data_source.listen_for_trades(self._ev_loop, self._order_book_trade_stream) - ) - self._order_book_diff_listener_task = safe_ensure_future( - self.data_source.listen_for_order_book_diffs(self._ev_loop, self._order_book_diff_stream) - ) - self._order_book_snapshot_listener_task = safe_ensure_future( - self.data_source.listen_for_order_book_snapshots(self._ev_loop, self._order_book_snapshot_stream) - ) - self._order_book_diff_router_task = safe_ensure_future( - self._order_book_diff_router() - ) - self._order_book_snapshot_router_task = safe_ensure_future( - self._order_book_snapshot_router() - ) - async def _order_book_diff_router(self): """ Route the real-time order book diff messages to the correct order book. From 73ef33f138b9adcc34c9bde95712fec2f32d6f45 Mon Sep 17 00:00:00 2001 From: Nicolas Baum Date: Tue, 19 Jan 2021 09:13:12 -0300 Subject: [PATCH 039/126] (feat) fixed typo --- test/integration/test_binance_market.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/integration/test_binance_market.py b/test/integration/test_binance_market.py index 376d2ee637..41c756bbef 100644 --- a/test/integration/test_binance_market.py +++ b/test/integration/test_binance_market.py @@ -134,7 +134,7 @@ def setUpClass(cls): cls._t_nonce_mock = cls._t_nonce_patcher.start() cls.current_nonce = 1000000000000000 cls.clock: Clock = Clock(ClockMode.REALTIME) - cls.market: BinanceExchange = BinanceExchange(API_KEY, API_SECRET, ["LINK-ETH", "ZRX-ETH"], -True) + cls.market: BinanceExchange = BinanceExchange(API_KEY, API_SECRET, ["LINK-ETH", "ZRX-ETH"], True) print("Initializing Binance market... this will take about a minute.") cls.ev_loop: asyncio.BaseEventLoop = asyncio.get_event_loop() cls.clock.add_iterator(cls.market) From 86e87f1806f4fd30ada58c8a67ca06dfd03bd722 Mon Sep 17 00:00:00 2001 From: Nullably <37262506+Nullably@users.noreply.github.com> Date: Wed, 20 Jan 2021 16:15:29 +0800 Subject: [PATCH 040/126] (feat) add buy and sell budgets --- .../liquidity_mining/liquidity_mining.py | 74 ++++++++++++++----- 1 file changed, 54 insertions(+), 20 deletions(-) diff --git a/hummingbot/strategy/liquidity_mining/liquidity_mining.py b/hummingbot/strategy/liquidity_mining/liquidity_mining.py index f27be0c60f..1574525e28 100644 --- a/hummingbot/strategy/liquidity_mining/liquidity_mining.py +++ b/hummingbot/strategy/liquidity_mining/liquidity_mining.py @@ -55,6 +55,9 @@ def __init__(self, self._ready_to_trade = False self._refresh_times = {market: 0 for market in market_infos} self._token_balances = {} + self._sell_budgets = {} + self._buy_budgets = {} + self.add_markets([exchange]) @property @@ -74,11 +77,12 @@ def tick(self, timestamp: float): return else: self.logger().info(f"{self._exchange.name} is ready. Trading started.") + self.assign_balanced_budgets() proposals = self.create_base_proposals() self._token_balances = self.adjusted_available_balances() self.apply_volatility_adjustment(proposals) - self.allocate_capital(proposals) + self.allocate_order_size(proposals) self.cancel_active_orders(proposals) self.execute_orders_proposal(proposals) @@ -142,14 +146,10 @@ def create_base_proposals(self): mid_price = market_info.get_mid_price() buy_price = mid_price * (Decimal("1") - self._initial_spread) buy_price = self._exchange.quantize_order_price(market, buy_price) - buy_size = Decimal("0.2") - buy_size = self._exchange.quantize_order_amount(market, buy_size) - sell_price = mid_price * (Decimal("1") + self._initial_spread) sell_price = self._exchange.quantize_order_price(market, sell_price) - sell_size = Decimal("0.2") - sell_size = self._exchange.quantize_order_amount(market, sell_size) - proposals.append(Proposal(market, PriceSize(buy_price, buy_size), PriceSize(sell_price, sell_size))) + proposals.append(Proposal(market, PriceSize(buy_price, s_decimal_zero), + PriceSize(sell_price, s_decimal_zero))) return proposals def apply_volatility_adjustment(self, proposals): @@ -158,24 +158,58 @@ def apply_volatility_adjustment(self, proposals): # scripts/spreads_adjusted_on_volatility_script.py). return - def allocate_capital(self, proposals: List[Proposal]): - # First let's assign all the sell order size based on the base token balance available + def assign_balanced_budgets(self): + # Equally assign buy and sell budgets to all markets base_tokens = self.all_base_tokens() + self._sell_budgets = {m: s_decimal_zero for m in self._market_infos} for base in base_tokens: - base_proposals = [p for p in proposals if p.base() == base] - sell_size = self._token_balances[base] / len(base_proposals) - for proposal in base_proposals: - proposal.sell.size = self._exchange.quantize_order_amount(proposal.market, sell_size) - + base_markets = [m for m in self._market_infos if m.split("-")[0] == base] + sell_size = self._exchange.get_available_balance(base) / len(base_markets) + for market in base_markets: + self._sell_budgets[market] = sell_size # Then assign all the buy order size based on the quote token balance available quote_tokens = self.all_quote_tokens() + self._buy_budgets = {m: s_decimal_zero for m in self._market_infos} for quote in quote_tokens: - quote_proposals = [p for p in proposals if p.quote() == quote] - quote_size = self._token_balances[quote] / len(quote_proposals) - for proposal in quote_proposals: - buy_fee = estimate_fee(self._exchange.name, True) - buy_amount = quote_size / (proposal.buy.price * (Decimal("1") + buy_fee.percent)) - proposal.buy.size = self._exchange.quantize_order_amount(proposal.market, buy_amount) + quote_markets = [m for m in self._market_infos if m.split("-")[1] == quote] + buy_size = self._exchange.get_available_balance(quote) / len(quote_markets) + for market in quote_markets: + self._buy_budgets[market] = buy_size + + def allocate_order_size(self, proposals: List[Proposal]): + balances = self._token_balances.copy() + for proposal in proposals: + sell_size = balances[proposal.base()] if balances[proposal.base()] < self._sell_budgets[proposal.market] \ + else self._sell_budgets[proposal.market] + sell_size = self._exchange.quantize_order_amount(proposal.market, sell_size) + if sell_size > s_decimal_zero: + proposal.sell.size = sell_size + balances[proposal.base()] -= sell_size + + quote_size = balances[proposal.quote()] if balances[proposal.quote()] < self._buy_budgets[proposal.market] \ + else self._buy_budgets[proposal.market] + buy_fee = estimate_fee(self._exchange.name, True) + buy_size = quote_size / (proposal.buy.price * (Decimal("1") + buy_fee.percent)) + if buy_size > s_decimal_zero: + proposal.buy.size = self._exchange.quantize_order_amount(proposal.market, buy_size) + balances[proposal.quote()] -= quote_size + + # base_tokens = self.all_base_tokens() + # for base in base_tokens: + # base_proposals = [p for p in proposals if p.base() == base] + # sell_size = self._token_balances[base] / len(base_proposals) + # for proposal in base_proposals: + # proposal.sell.size = self._exchange.quantize_order_amount(proposal.market, sell_size) + # + # # Then assign all the buy order size based on the quote token balance available + # quote_tokens = self.all_quote_tokens() + # for quote in quote_tokens: + # quote_proposals = [p for p in proposals if p.quote() == quote] + # quote_size = self._token_balances[quote] / len(quote_proposals) + # for proposal in quote_proposals: + # buy_fee = estimate_fee(self._exchange.name, True) + # buy_amount = quote_size / (proposal.buy.price * (Decimal("1") + buy_fee.percent)) + # proposal.buy.size = self._exchange.quantize_order_amount(proposal.market, buy_amount) def is_within_tolerance(self, cur_orders: List[LimitOrder], proposal: Proposal): cur_buy = [o for o in cur_orders if o.is_buy] From ec3cf5b249f7aac6fa48ebb6aa3e388f3bb0d640 Mon Sep 17 00:00:00 2001 From: Nullably <37262506+Nullably@users.noreply.github.com> Date: Wed, 20 Jan 2021 16:55:28 +0800 Subject: [PATCH 041/126] (fix) check if strategy file name changed before init logging --- hummingbot/client/command/start_command.py | 8 +++++--- hummingbot/client/hummingbot_application.py | 1 + 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/hummingbot/client/command/start_command.py b/hummingbot/client/command/start_command.py index c16cb9edb7..e8c2e589a3 100644 --- a/hummingbot/client/command/start_command.py +++ b/hummingbot/client/command/start_command.py @@ -68,9 +68,11 @@ async def start_check(self, # type: HummingbotApplication if not is_valid: return - init_logging("hummingbot_logs.yml", - override_log_level=log_level.upper() if log_level else None, - strategy_file_path=self.strategy_file_name) + 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, + strategy_file_path=self.strategy_file_name) + self._last_started_strategy_file = self.strategy_file_name # If macOS, disable App Nap. if platform.system() == "Darwin": diff --git a/hummingbot/client/hummingbot_application.py b/hummingbot/client/hummingbot_application.py index 241175fe6a..c64d0b3ccb 100644 --- a/hummingbot/client/hummingbot_application.py +++ b/hummingbot/client/hummingbot_application.py @@ -91,6 +91,7 @@ def __init__(self): self.kill_switch: Optional[KillSwitch] = None self._app_warnings: Deque[ApplicationWarning] = deque() self._trading_required: bool = True + self._last_started_strategy_file: Optional[str] = None self.trade_fill_db: Optional[SQLConnectionManager] = None self.markets_recorder: Optional[MarketsRecorder] = None From 19a5184de9d4387d1c4fa0355ffb8d700c00a592 Mon Sep 17 00:00:00 2001 From: Nullably <37262506+Nullably@users.noreply.github.com> Date: Wed, 20 Jan 2021 21:10:34 +0800 Subject: [PATCH 042/126] (feat) add more config variables --- .../liquidity_mining/liquidity_mining.py | 8 +-- .../liquidity_mining_config_map.py | 58 ++++++++++++++++--- hummingbot/strategy/liquidity_mining/start.py | 14 +++-- ...onf_liquidity_mining_strategy_TEMPLATE.yml | 20 ++++--- 4 files changed, 75 insertions(+), 25 deletions(-) diff --git a/hummingbot/strategy/liquidity_mining/liquidity_mining.py b/hummingbot/strategy/liquidity_mining/liquidity_mining.py index 1574525e28..81774a581e 100644 --- a/hummingbot/strategy/liquidity_mining/liquidity_mining.py +++ b/hummingbot/strategy/liquidity_mining/liquidity_mining.py @@ -37,7 +37,7 @@ def logger(cls) -> HummingbotLogger: def __init__(self, exchange: ExchangeBase, market_infos: Dict[str, MarketTradingPairTuple], - initial_spread: Decimal, + custom_spread_pct: Decimal, order_refresh_time: float, order_refresh_tolerance_pct: Decimal, reserved_balances: Dict[str, Decimal], @@ -45,7 +45,7 @@ def __init__(self, super().__init__() self._exchange = exchange self._market_infos = market_infos - self._initial_spread = initial_spread + self._custom_spread_pct = custom_spread_pct self._order_refresh_time = order_refresh_time self._order_refresh_tolerance_pct = order_refresh_tolerance_pct self._reserved_balances = reserved_balances @@ -144,9 +144,9 @@ def create_base_proposals(self): proposals = [] for market, market_info in self._market_infos.items(): mid_price = market_info.get_mid_price() - buy_price = mid_price * (Decimal("1") - self._initial_spread) + buy_price = mid_price * (Decimal("1") - self._custom_spread_pct) buy_price = self._exchange.quantize_order_price(market, buy_price) - sell_price = mid_price * (Decimal("1") + self._initial_spread) + sell_price = mid_price * (Decimal("1") + self._custom_spread_pct) sell_price = self._exchange.quantize_order_price(market, sell_price) proposals.append(Proposal(market, PriceSize(buy_price, s_decimal_zero), PriceSize(sell_price, s_decimal_zero))) diff --git a/hummingbot/strategy/liquidity_mining/liquidity_mining_config_map.py b/hummingbot/strategy/liquidity_mining/liquidity_mining_config_map.py index 3617e19244..5f39dd0059 100644 --- a/hummingbot/strategy/liquidity_mining/liquidity_mining_config_map.py +++ b/hummingbot/strategy/liquidity_mining/liquidity_mining_config_map.py @@ -4,6 +4,7 @@ from hummingbot.client.config.config_validators import ( validate_exchange, validate_decimal, + validate_bool ) from hummingbot.client.settings import ( required_exchanges, @@ -14,6 +15,11 @@ def exchange_on_validated(value: str) -> None: required_exchanges.append(value) +def auto_campaigns_participation_on_validated(value: bool) -> None: + if value: + liquidity_mining_config_map["markets"].value = "HARD-USDT,RLC-USDT,AVAX-USDT,ALGO-USDT,XEM-USDT,MFT-USDT,ETH-USDT" + + liquidity_mining_config_map = { "strategy": ConfigVar( key="strategy", @@ -25,16 +31,55 @@ def exchange_on_validated(value: str) -> None: validator=validate_exchange, on_validated=exchange_on_validated, prompt_on_new=True), + "auto_campaigns_participation": + ConfigVar(key="auto_campaigns_participation", + prompt="Do you want the bot to automatically participate in as many mining campaign as possible " + "(Yes/No) >>> ", + type_str="bool", + validator=validate_bool, + on_validated=auto_campaigns_participation_on_validated, + prompt_on_new=True), "markets": ConfigVar(key="markets", prompt="Enter a list of markets >>> ", - # validator=validate_exchange_trading_pair, + required_if=lambda: not liquidity_mining_config_map.get("auto_campaigns_participation").value, + prompt_on_new=True), + "reserved_balances": + ConfigVar(key="reserved_balances", + prompt="Enter a list of tokens and their reserved balance (to not be used by the bot), " + "This can be used for an asset amount you want to set a side to pay for fee (e.g. BNB for " + "Binance), to limit exposure or in anticipation of withdrawal. The format is TOKEN-amount " + "e.g. BTC-0.1, BNB-1 >>> ", + type_str="str", + prompt_on_new=True), + "auto_assign_campaign_budgets": + ConfigVar(key="auto_assign_campaign_budgets", + prompt="Do you want the bot to automatically assign budgets (equal weight) for all campaign markets? " + "The assignment assumes full allocation of your assets (after accounting for reserved balance)" + " (Yes/No) >>> ", + type_str="bool", + validator=validate_bool, prompt_on_new=True), - "initial_spread": - ConfigVar(key="initial_spread", + "campaign_budgets": + ConfigVar(key="campaign_budgets", + prompt="Enter a list of campaigns and their buy and sell budgets. For example " + "XEM-ETH:500-2, XEM-USDT:300-250 (this means on XEM-ETH campaign sell budget is 500 XEM, " + "and buy budget is 2 ETH) >>> ", + required_if=lambda: not liquidity_mining_config_map.get("auto_assign_campaign_budgets").value, + type_str="json"), + "spread_level": + ConfigVar(key="spread_level", + prompt="Enter spread level (tight/medium/wide/custom) >>> ", + type_str="str", + default="medium", + validator=lambda s: None if s in {"tight", "medium", "wide", "custom"} else "Invalid spread level.", + prompt_on_new=True), + "custom_spread_pct": + ConfigVar(key="custom_spread_pct", prompt="How far away from the mid price do you want to place bid order and ask order? " "(Enter 1 to indicate 1%) >>> ", type_str="decimal", + required_if=lambda: liquidity_mining_config_map.get("spread_level").value == "custom", validator=lambda v: validate_decimal(v, 0, 100, inclusive=False), prompt_on_new=True), "order_refresh_time": @@ -51,10 +96,5 @@ def exchange_on_validated(value: str) -> None: type_str="decimal", default=Decimal("0.2"), validator=lambda v: validate_decimal(v, -10, 10, inclusive=True)), - "reserved_balances": - ConfigVar(key="reserved_balances", - prompt="Enter a list of token and its reserved balance (to not be used) e.g. BTC-0.1, BNB-1 >>> ", - required_if=lambda: False, - default={"BNB": 1}, - type_str="json"), + } diff --git a/hummingbot/strategy/liquidity_mining/start.py b/hummingbot/strategy/liquidity_mining/start.py index dcf331419e..5795906639 100644 --- a/hummingbot/strategy/liquidity_mining/start.py +++ b/hummingbot/strategy/liquidity_mining/start.py @@ -7,11 +7,15 @@ def start(self): exchange = c_map.get("exchange").value.lower() markets_text = c_map.get("markets").value - initial_spread = c_map.get("initial_spread").value / Decimal("100") + if c_map.get("auto_campaigns_participation").value: + markets_text = "HARD-USDT,RLC-USDT,AVAX-USDT,ALGO-USDT,XEM-USDT,MFT-USDT,AVAX-BNB,MFT-BNB" + reserved_bals_text = c_map.get("reserved_balances").value or "" + reserved_bals = reserved_bals_text.split(",") + reserved_bals = {r.split(":")[0]: Decimal(r.split(":")[1]) for r in reserved_bals} + custom_spread_pct = c_map.get("custom_spread_pct").value / Decimal("100") order_refresh_time = c_map.get("order_refresh_time").value order_refresh_tolerance_pct = c_map.get("order_refresh_tolerance_pct").value / Decimal("100") - reserved_balances = c_map.get("reserved_balances").value or {} - reserved_balances = {k: Decimal(str(v)) for k, v in reserved_balances.items()} + markets = list(markets_text.split(",")) self._initialize_markets([(exchange, markets)]) @@ -23,8 +27,8 @@ def start(self): self.strategy = LiquidityMiningStrategy( exchange=exchange, market_infos=market_infos, - initial_spread=initial_spread, + custom_spread_pct=custom_spread_pct, order_refresh_time=order_refresh_time, - reserved_balances=reserved_balances, + reserved_balances=reserved_bals, order_refresh_tolerance_pct=order_refresh_tolerance_pct ) diff --git a/hummingbot/templates/conf_liquidity_mining_strategy_TEMPLATE.yml b/hummingbot/templates/conf_liquidity_mining_strategy_TEMPLATE.yml index 10b4fc5448..e0d5bb2d88 100644 --- a/hummingbot/templates/conf_liquidity_mining_strategy_TEMPLATE.yml +++ b/hummingbot/templates/conf_liquidity_mining_strategy_TEMPLATE.yml @@ -8,14 +8,20 @@ strategy: null # Exchange and token parameters. exchange: null +auto_campaigns_participation: null + # Token trading pair for the exchange, e.g. BTC-USDT markets: null -# How far away from mid price to place the bid and ask order. -# Spread of 1 = 1% away from mid price at that time. -# Example if mid price is 100 and initial_spread is 1. -# Your bid is placed at 99 and ask is at 101. -initial_spread: null +reserved_balances: null + +auto_assign_campaign_budgets: null + +campaign_budgets: null + +spread_level: null + +custom_spread_pct: 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. @@ -25,7 +31,7 @@ order_refresh_time: null # (Enter 1 to indicate 1%), value below 0, e.g. -1, is to disable this feature - not recommended. order_refresh_tolerance_pct: null -reserved_balances: + # For more detailed information, see: -# https://docs.hummingbot.io/strategies/pure-market-making/#configuration-parameters +# https://docs.hummingbot.io/strategies/liquidity-mining/#configuration-parameters From 4e19e6b919b708cc177059eef7e5dbb1f4be1cd9 Mon Sep 17 00:00:00 2001 From: vic-en Date: Wed, 20 Jan 2021 19:33:11 +0100 Subject: [PATCH 043/126] (feat) add columns to DB --- hummingbot/core/event/events.py | 5 ++-- .../model/db_migration/transformations.py | 23 +++++++++++++++++++ hummingbot/model/order.py | 3 ++- hummingbot/model/sql_connection_manager.py | 2 +- hummingbot/model/trade_fill.py | 5 +++- 5 files changed, 33 insertions(+), 5 deletions(-) diff --git a/hummingbot/core/event/events.py b/hummingbot/core/event/events.py index 603c5fff3a..fa968c3216 100644 --- a/hummingbot/core/event/events.py +++ b/hummingbot/core/event/events.py @@ -174,7 +174,6 @@ class BuyOrderCompletedEvent: fee_amount: Decimal order_type: OrderType exchange_order_id: Optional[str] = None - leverage: Optional[int] = 1 @dataclass @@ -189,7 +188,6 @@ class SellOrderCompletedEvent: fee_amount: Decimal order_type: OrderType exchange_order_id: Optional[str] = None - leverage: Optional[int] = 1 @dataclass @@ -297,6 +295,7 @@ class OrderFilledEvent(NamedTuple): trade_fee: TradeFee exchange_trade_id: str = "" leverage: Optional[int] = 1 + position: Optional[str] = "NILL" @classmethod def order_filled_events_from_order_book_rows(cls, @@ -342,6 +341,7 @@ class BuyOrderCreatedEvent: order_id: str exchange_order_id: Optional[str] = None leverage: Optional[int] = 1 + position: Optional[str] = "NILL" @dataclass @@ -354,3 +354,4 @@ class SellOrderCreatedEvent: order_id: str exchange_order_id: Optional[str] = None leverage: Optional[int] = 1 + position: Optional[str] = "NILL" diff --git a/hummingbot/model/db_migration/transformations.py b/hummingbot/model/db_migration/transformations.py index 1b53ec9332..f2a5817760 100644 --- a/hummingbot/model/db_migration/transformations.py +++ b/hummingbot/model/db_migration/transformations.py @@ -3,6 +3,7 @@ from sqlalchemy import ( Column, Text, + Integer ) @@ -22,3 +23,25 @@ def name(self): @property def to_version(self): return 20210118 + + +class AddLeverageAndPositionColumns(DatabaseTransformation): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def apply(self, db_handle: SQLConnectionManager) -> SQLConnectionManager: + leverage_column = Column("leverage", Integer, nullable=True) + position_column = Column("position", Text, nullable=True) + self.add_column(db_handle.engine, "Order", leverage_column, dry_run=False) + self.add_column(db_handle.engine, "Order", position_column, dry_run=False) + self.add_column(db_handle.engine, "TradeFill", leverage_column, dry_run=False) + self.add_column(db_handle.engine, "TradeFill", position_column, dry_run=False) + return db_handle + + @property + def name(self): + return "AddLeverageAndPositionColumns" + + @property + def to_version(self): + return 20210119 diff --git a/hummingbot/model/order.py b/hummingbot/model/order.py index 2a77c112cc..84582085ac 100644 --- a/hummingbot/model/order.py +++ b/hummingbot/model/order.py @@ -43,6 +43,7 @@ class Order(HummingbotBase): last_status = Column(Text, nullable=False) last_update_timestamp = Column(BigInteger, nullable=False) exchange_order_id = Column(Text, nullable=True) + position = Column(Text, nullable=True) status = relationship("OrderStatus", back_populates="order") trade_fills = relationship("TradeFill", back_populates="order") @@ -53,7 +54,7 @@ def __repr__(self) -> str: f"order_type='{self.order_type}', amount={self.amount}, leverage={self.leverage}, " \ f"price={self.price}, last_status='{self.last_status}', " \ f"last_update_timestamp={self.last_update_timestamp}), " \ - f"exchange_order_id°={self.exchange_order_id}" + f"exchange_order_id°={self.exchange_order_id}, position={self.position}" @staticmethod def to_bounty_api_json(order: "Order") -> Dict[str, Any]: diff --git a/hummingbot/model/sql_connection_manager.py b/hummingbot/model/sql_connection_manager.py index 68465e489d..3bc6f9b093 100644 --- a/hummingbot/model/sql_connection_manager.py +++ b/hummingbot/model/sql_connection_manager.py @@ -46,7 +46,7 @@ class SQLConnectionManager: _scm_trade_fills_instance: Optional["SQLConnectionManager"] = None LOCAL_DB_VERSION_KEY = "local_db_version" - LOCAL_DB_VERSION_VALUE = "20210118" + LOCAL_DB_VERSION_VALUE = "20210119" @classmethod def logger(cls) -> HummingbotLogger: diff --git a/hummingbot/model/trade_fill.py b/hummingbot/model/trade_fill.py index f3d41f0fb6..ea00b6b348 100644 --- a/hummingbot/model/trade_fill.py +++ b/hummingbot/model/trade_fill.py @@ -54,6 +54,7 @@ class TradeFill(HummingbotBase): leverage = Column(Integer, nullable=False, default=1) trade_fee = Column(JSON, nullable=False) exchange_trade_id = Column(Text, nullable=False) + position = Column(Text, nullable=True) order = relationship("Order", back_populates="trade_fills") def __repr__(self) -> str: @@ -61,7 +62,7 @@ def __repr__(self) -> str: f"market='{self.market}', symbol='{self.symbol}', base_asset='{self.base_asset}', " \ f"quote_asset='{self.quote_asset}', timestamp={self.timestamp}, order_id='{self.order_id}', " \ f"trade_type='{self.trade_type}', order_type='{self.order_type}', price={self.price}, amount={self.amount}, " \ - f"leverage={self.leverage}, trade_fee={self.trade_fee}, exchange_trade_id={self.exchange_trade_id})" + f"leverage={self.leverage}, trade_fee={self.trade_fee}, exchange_trade_id={self.exchange_trade_id}, position={self.position})" @staticmethod def get_trades(sql_session: Session, @@ -113,6 +114,7 @@ def to_pandas(cls, trades: List): "Price", "Amount", "Leverage", + "Position", "Age"] data = [] index = 0 @@ -142,6 +144,7 @@ def to_pandas(cls, trades: List): trade.price, trade.amount, trade.leverage, + trade.position, age, ]) df = pd.DataFrame(data=data, columns=columns) From ee98bb82effbd2cac11a9b588584dabe498be658 Mon Sep 17 00:00:00 2001 From: vic-en Date: Wed, 20 Jan 2021 19:34:06 +0100 Subject: [PATCH 044/126] (feat) add leverage and position to event parameter and fix user_stream last_recv_time --- .../binance_perpetual_derivative.py | 38 ++++++++++--------- .../binance_perpetual_in_flight_order.py | 4 ++ ...nance_perpetual_user_stream_data_source.py | 7 +++- hummingbot/connector/markets_recorder.py | 10 +++-- 4 files changed, 36 insertions(+), 23 deletions(-) diff --git a/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_derivative.py b/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_derivative.py index 6000146cde..910f88b93d 100644 --- a/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_derivative.py +++ b/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_derivative.py @@ -251,7 +251,7 @@ async def create_order(self, else: api_params["positionSide"] = "SHORT" if trade_type is TradeType.BUY else "LONG" - self.start_tracking_order(order_id, "", trading_pair, trade_type, price, amount, order_type) + self.start_tracking_order(order_id, "", trading_pair, trade_type, price, amount, order_type, self._leverage, position_action.name) try: order_result = await self.request(path="/fapi/v1/order", @@ -276,7 +276,8 @@ async def create_order(self, amount, price, order_id, - leverage=self._leverage)) + leverage=self._leverage, + position=position_action.name)) return order_result except asyncio.CancelledError: raise @@ -441,7 +442,7 @@ def get_order_size_quantum(self, trading_pair: str, order_size: object): # ORDER TRACKING --- def start_tracking_order(self, order_id: str, exchange_order_id: str, trading_pair: str, trading_type: object, - price: object, amount: object, order_type: object): + price: object, amount: object, order_type: object, leverage: int, position: str): self._in_flight_orders[order_id] = BinancePerpetualsInFlightOrder( client_order_id=order_id, exchange_order_id=exchange_order_id, @@ -449,7 +450,10 @@ def start_tracking_order(self, order_id: str, exchange_order_id: str, trading_pa order_type=order_type, trade_type=trading_type, price=price, - amount=amount + amount=amount, + leverage=leverage, + position=position + ) def stop_tracking_order(self, order_id: str): @@ -507,7 +511,8 @@ async def _user_stream_event_listener(self): amount=Decimal(order_message.get("q")), price=Decimal(order_message.get("p")) ), - exchange_trade_id=order_message.get("t") + exchange_trade_id=order_message.get("t"), + position=tracked_order.position ) self.trigger_event(self.MARKET_ORDER_FILLED_EVENT_TAG, order_filled_event) @@ -599,8 +604,8 @@ def tick(self, timestamp: float): poll_interval = (self.SHORT_POLL_INTERVAL if now - self._user_stream_tracker.last_recv_time > 60.0 else self.LONG_POLL_INTERVAL) - last_tick = self._last_timestamp / poll_interval - current_tick = timestamp / poll_interval + 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() @@ -683,10 +688,10 @@ async def _status_polling_loop(self): await self._poll_notifier.wait() await safe_gather( self._update_balances(), - self._update_positions(), - self._update_order_fills_from_trades(), - self._update_order_status() + self._update_positions() ) + await self._update_order_fills_from_trades(), + await self._update_order_status() self._last_poll_timestamp = self.current_timestamp except asyncio.CancelledError: raise @@ -741,8 +746,8 @@ async def _update_positions(self): del self._account_positions[trading_pair + position_side.name] async def _update_order_fills_from_trades(self): - last_tick = self._last_poll_timestamp / self.UPDATE_ORDER_STATUS_MIN_INTERVAL - current_tick = self.current_timestamp / self.UPDATE_ORDER_STATUS_MIN_INTERVAL + 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: trading_pairs_to_order_map = defaultdict(lambda: {}) for order in self._in_flight_orders.values(): @@ -774,8 +779,6 @@ async def _update_order_fills_from_trades(self): order_type = tracked_order.order_type applied_trade = tracked_order.update_with_trade_updates(trade) if applied_trade: - self.logger().info(f"The {order_type.name.lower()} {tracked_order.trade_type.name.lower()} order {order_id} has " - f"been filled according to order status API.") self.trigger_event( self.MARKET_ORDER_FILLED_EVENT_TAG, OrderFilledEvent( @@ -794,13 +797,14 @@ async def _update_order_fills_from_trades(self): Decimal(trade["price"]), Decimal(trade["qty"])), exchange_trade_id=trade["id"], - leverage=self._leverage + leverage=self._leverage, + position=tracked_order.position ) ) async def _update_order_status(self): - last_tick = self._last_poll_timestamp / self.UPDATE_ORDER_STATUS_MIN_INTERVAL - current_tick = self.current_timestamp / self.UPDATE_ORDER_STATUS_MIN_INTERVAL + 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 = [self.request(path="/fapi/v1/order", diff --git a/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_in_flight_order.py b/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_in_flight_order.py index 5a8254fc7c..9669c0e050 100644 --- a/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_in_flight_order.py +++ b/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_in_flight_order.py @@ -14,6 +14,8 @@ def __init__(self, trade_type: TradeType, price: Decimal, amount: Decimal, + leverage: int, + position: str, initial_state: str = "NEW"): super().__init__( client_order_id, @@ -26,6 +28,8 @@ def __init__(self, initial_state ) self.trade_id_set = set() + self.leverage = leverage + self.position = position @property def is_done(self): diff --git a/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_user_stream_data_source.py b/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_user_stream_data_source.py index 9b792e8a6a..56051850fb 100644 --- a/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_user_stream_data_source.py +++ b/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_user_stream_data_source.py @@ -1,5 +1,6 @@ import asyncio import logging +import time from typing import Optional, Dict, AsyncIterable import aiohttp @@ -62,12 +63,14 @@ async def ws_messages(self, client: websockets.WebSocketClientProtocol) -> Async try: while True: try: - raw_msg: str = await asyncio.wait_for(client.recv(), timeout=60.0) + raw_msg: str = await asyncio.wait_for(client.recv(), timeout=50.0) + self._last_recv_time = time.time() yield raw_msg except asyncio.TimeoutError: try: + self._last_recv_time = time.time() pong_waiter = await client.ping() - await asyncio.wait_for(pong_waiter, timeout=60.0) + await asyncio.wait_for(pong_waiter, timeout=50.0) except asyncio.TimeoutError: raise except asyncio.TimeoutError: diff --git a/hummingbot/connector/markets_recorder.py b/hummingbot/connector/markets_recorder.py index 43cab6caf3..3dff80230b 100644 --- a/hummingbot/connector/markets_recorder.py +++ b/hummingbot/connector/markets_recorder.py @@ -184,6 +184,7 @@ def _did_create_order(self, amount=float(evt.amount), leverage=evt.leverage if evt.leverage else 1, price=float(evt.price) if evt.price == evt.price else 0, + position=evt.position if evt.position else "NILL", last_status=event_type.name, last_update_timestamp=timestamp) order_status: OrderStatus = OrderStatus(order=order_record, @@ -233,7 +234,8 @@ def _did_fill_order(self, amount=float(evt.amount), leverage=evt.leverage if evt.leverage else 1, trade_fee=TradeFee.to_json(evt.trade_fee), - exchange_trade_id=evt.exchange_trade_id) + exchange_trade_id=evt.exchange_trade_id, + position=evt.position if evt.position else "NILL",) session.add(order_status) session.add(trade_fill_record) self.save_market_states(self._config_file_path, market, no_commit=True) @@ -249,10 +251,10 @@ def append_to_csv(self, trade: TradeFill): age = pd.Timestamp(int(trade.timestamp / 1e3 - int(trade.order_id[-16:]) / 1e6), unit='s').strftime('%H:%M:%S') if not os.path.exists(csv_path): df_header = pd.DataFrame([["Config File", "Strategy", "Exchange", "Timestamp", "Market", "Base", "Quote", - "Trade", "Type", "Price", "Amount", "Leverage", "Fee", "Age", "Order ID", "Exchange Trade ID"]]) + "Trade", "Type", "Price", "Amount", "Leverage", "Fee", "Age", "Order ID", "Exchange Trade ID", "Position"]]) df_header.to_csv(csv_path, mode='a', header=False, index=False) - df = pd.DataFrame([[trade.config_file_path, trade.strategy, trade.market, trade.timestamp, trade.symbol, trade.base_asset, trade.quote_asset, - trade.trade_type, trade.order_type, trade.price, trade.amount, trade.leverage, trade.trade_fee, age, trade.order_id, trade.exchange_trade_id]]) + df = pd.DataFrame([[trade.config_file_path, trade.strategy, trade.market, trade.timestamp, trade.symbol, trade.base_asset, trade.quote_asset, trade.trade_type, + trade.order_type, trade.price, trade.amount, trade.leverage, trade.trade_fee, age, trade.order_id, trade.exchange_trade_id, trade.position]]) df.to_csv(csv_path, mode='a', header=False, index=False) def _update_order_status(self, From 8855d4a8ad560e6f0dcf9feac2cc000c4199c31d Mon Sep 17 00:00:00 2001 From: vic-en Date: Wed, 20 Jan 2021 19:39:18 +0100 Subject: [PATCH 045/126] (feat) modify performance to calculate pnl differently for derivative --- hummingbot/client/command/history_command.py | 5 ++- hummingbot/client/performance.py | 42 ++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/hummingbot/client/command/history_command.py b/hummingbot/client/command/history_command.py index 53224e1b19..bc5346bf1c 100644 --- a/hummingbot/client/command/history_command.py +++ b/hummingbot/client/command/history_command.py @@ -14,7 +14,8 @@ from hummingbot.client.settings import ( MAXIMUM_TRADE_FILLS_DISPLAY_OUTPUT, CONNECTOR_SETTINGS, - ConnectorType + ConnectorType, + DERIVATIVES ) from hummingbot.model.trade_fill import TradeFill from hummingbot.user.user_balances import UserBalances @@ -141,6 +142,7 @@ def report_performance_by_market(self, # type: HummingbotApplication assets_columns = ["", "start", "current", "change"] assets_data = [ + [f"{base:<17}", "-", "-", "-"] if market in DERIVATIVES else # No base asset for derivatives because they are margined [f"{base:<17}", smart_round(perf.start_base_bal, precision), smart_round(perf.cur_base_bal, precision), @@ -153,6 +155,7 @@ def report_performance_by_market(self, # type: HummingbotApplication smart_round(perf.start_price), smart_round(perf.cur_price), smart_round(perf.cur_price - perf.start_price)], + [f"{'Base asset %':<17}", "-", "-", "-"] if market in DERIVATIVES else # No base asset for derivatives because they are margined [f"{'Base asset %':<17}", f"{perf.start_base_ratio_pct:.2%}", f"{perf.cur_base_ratio_pct:.2%}", diff --git a/hummingbot/client/performance.py b/hummingbot/client/performance.py index 7ae51f80bf..8469688c13 100644 --- a/hummingbot/client/performance.py +++ b/hummingbot/client/performance.py @@ -53,6 +53,27 @@ def __init__(self): self.fees: Dict[str, Decimal] = {} +def position_order(open: list, close: list): + # pair open position order with close position OrderStatus + for o in open: + for c in close: + if o.position == "OPEN" and c.position == "CLOSE": + open.remove(o) + close.remove(c) + return (o, c) + return None + + +def derivative_pnl(long: list, short: list): + # It is assumed that the amount and leverage for both open and close orders are thesame. + pnls = [] + for lg in long: + pnls.append((lg[1].price - lg[0].price) * (lg[1].amount * lg[1].leverage)) + for st in short: + pnls.append((st[0].price - st[1].price) * (st[1].amount * st[1].leverage)) + return pnls + + async def calculate_performance_metrics(exchange: str, trading_pair: str, trades: List[Any], @@ -77,6 +98,7 @@ def divide(value, divisor): perf = PerformanceMetrics() buys = [t for t in trades if t.trade_type.upper() == "BUY"] sells = [t for t in trades if t.trade_type.upper() == "SELL"] + derivative = True if "NILL" not in [t.position for t in trades] else False perf.num_buys = len(buys) perf.num_sells = len(sells) perf.num_trades = perf.num_buys + perf.num_sells @@ -114,6 +136,26 @@ def divide(value, divisor): perf.cur_value = (perf.cur_base_bal * perf.cur_price) + perf.cur_quote_bal perf.trade_pnl = perf.cur_value - perf.hold_value + # Handle trade_pnl differently for derivatives + if derivative: + buys_copy = buys.copy() + sells_copy = sells.copy() + long = [] + short = [] + + while True: + lng = position_order(buys_copy, sells_copy) + if lng is not None: + long.append(lng) + + sht = position_order(sells_copy, buys_copy) + if sht is not None: + short.append(sht) + if lng is None and sht is None: + break + + perf.trade_pnl = Decimal(str(sum(derivative_pnl(long, short)))) + for trade in trades: if type(trade) is TradeFill: if trade.trade_fee.get("percent") is not None and trade.trade_fee["percent"] > 0: From ff89504d83cc5e820499387f3e6976a734586011 Mon Sep 17 00:00:00 2001 From: vic-en Date: Wed, 20 Jan 2021 20:46:19 +0100 Subject: [PATCH 046/126] (fix) remove leverage factor in pnl calculation --- hummingbot/client/performance.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/hummingbot/client/performance.py b/hummingbot/client/performance.py index 8469688c13..8e507f206c 100644 --- a/hummingbot/client/performance.py +++ b/hummingbot/client/performance.py @@ -68,9 +68,9 @@ def derivative_pnl(long: list, short: list): # It is assumed that the amount and leverage for both open and close orders are thesame. pnls = [] for lg in long: - pnls.append((lg[1].price - lg[0].price) * (lg[1].amount * lg[1].leverage)) + pnls.append((lg[1].price - lg[0].price) * lg[1].amount) for st in short: - pnls.append((st[0].price - st[1].price) * (st[1].amount * st[1].leverage)) + pnls.append((st[0].price - st[1].price) * st[1].amount) return pnls From e17736a6f2c17095ab2291fbbe6c8eb3ed483cee Mon Sep 17 00:00:00 2001 From: vic-en Date: Wed, 20 Jan 2021 21:44:54 +0100 Subject: [PATCH 047/126] (feat) aggregate trade fills for orders with multiple fills --- hummingbot/client/performance.py | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/hummingbot/client/performance.py b/hummingbot/client/performance.py index 8e507f206c..21c9b4424d 100644 --- a/hummingbot/client/performance.py +++ b/hummingbot/client/performance.py @@ -64,6 +64,28 @@ def position_order(open: list, close: list): return None +def aggregate_position_order(buys: list, sells: list): + aggregated_buys = [] + aggregated_sells = [] + for buy in buys: + if buy.order_id not in [ag.order_id for ag in aggregated_buys]: + aggregate_price = [b.price for b in buys if b.order_id == buy.order_id] + aggregate_amount = [b.amount for b in buys if b.order_id == buy.order_id] + buy.price = sum(aggregate_price) / len(aggregate_price) + buy.amount = sum(aggregate_amount) + aggregated_buys.append(buy) + + for sell in sells: + if sell.order_id not in [ag.order_id for ag in aggregated_sells]: + aggregate_price = [s.price for s in sells if s.order_id == sell.order_id] + aggregate_amount = [s.amount for s in sells if s.order_id == sell.order_id] + sell.price = sum(aggregate_price) / len(aggregate_price) + sell.amount = sum(aggregate_amount) + aggregated_sells.append(sell) + + return aggregated_buys, aggregated_sells + + def derivative_pnl(long: list, short: list): # It is assumed that the amount and leverage for both open and close orders are thesame. pnls = [] @@ -138,8 +160,7 @@ def divide(value, divisor): # Handle trade_pnl differently for derivatives if derivative: - buys_copy = buys.copy() - sells_copy = sells.copy() + buys_copy, sells_copy = aggregate_position_order(buys.copy(), sells.copy()) long = [] short = [] From 4c15771dc542c0930f125586076226a8634e1c62 Mon Sep 17 00:00:00 2001 From: Nicolas Baum Date: Thu, 21 Jan 2021 00:12:52 -0300 Subject: [PATCH 048/126] (fix) Modified Throttler parameters according to Kraken API counter for rate limit. Also added request retry to handle Cloudflare web server exceptions --- .../exchange/kraken/kraken_exchange.pyx | 105 +++++++++++++----- 1 file changed, 78 insertions(+), 27 deletions(-) diff --git a/hummingbot/connector/exchange/kraken/kraken_exchange.pyx b/hummingbot/connector/exchange/kraken/kraken_exchange.pyx index 029fa552e5..42fff7f051 100755 --- a/hummingbot/connector/exchange/kraken/kraken_exchange.pyx +++ b/hummingbot/connector/exchange/kraken/kraken_exchange.pyx @@ -6,6 +6,7 @@ from decimal import Decimal import logging import pandas as pd from collections import defaultdict +import re from typing import ( Any, Dict, @@ -100,6 +101,14 @@ cdef class KrakenExchange(ExchangeBase): ORDER_NOT_EXIST_CONFIRMATION_COUNT = 3 + API_MAX_COUNTER = 20 + API_COUNTER_DECREASE_RATE_PER_SEC = 0.33 + API_COUNTER_POINTS = {ADD_ORDER_URI: 0, + CANCEL_ORDER_URI: 0, + BALANCE_URI: 1, + OPEN_ORDERS_URI: 1, + QUERY_ORDERS_URI: 1} + @classmethod def logger(cls) -> HummingbotLogger: global s_logger @@ -134,7 +143,8 @@ cdef class KrakenExchange(ExchangeBase): self._user_stream_event_listener_task = None self._trading_rules_polling_task = None self._async_scheduler = AsyncCallScheduler(call_interval=0.5) - self._throttler = Throttler(rate_limit = (10.0, 1.0)) + self._throttler = Throttler(rate_limit=(self.API_MAX_COUNTER, self.API_MAX_COUNTER/self.API_COUNTER_DECREASE_RATE_PER_SEC), + retry_interval=1.0/self.API_COUNTER_DECREASE_RATE_PER_SEC) self._last_pull_timestamp = 0 self._shared_client = None self._asset_pairs = {} @@ -206,13 +216,8 @@ cdef class KrakenExchange(ExchangeBase): set remote_asset_names = set() set asset_names_to_remove - balances = await self._api_request("POST", - BALANCE_URI, - is_auth_required=True) - - open_orders = await self._api_request("POST", - OPEN_ORDERS_URI, - is_auth_required=True) + balances = await self._api_request_with_retry("POST", BALANCE_URI, is_auth_required=True) + open_orders = await self._api_request_with_retry("POST", OPEN_ORDERS_URI, is_auth_required=True) locked = defaultdict(Decimal) @@ -337,10 +342,10 @@ cdef class KrakenExchange(ExchangeBase): if len(self._in_flight_orders) > 0: tracked_orders = list(self._in_flight_orders.values()) - tasks = [self._api_request("POST", - QUERY_ORDERS_URI, - data={"txid": o.exchange_order_id}, - is_auth_required=True) + tasks = [self._api_request_with_retry("POST", + QUERY_ORDERS_URI, + data={"txid": o.exchange_order_id}, + is_auth_required=True) for o in tracked_orders] results = await safe_gather(*tasks, return_exceptions=True) @@ -663,6 +668,53 @@ cdef class KrakenExchange(ExchangeBase): self._shared_client = aiohttp.ClientSession() return self._shared_client + @staticmethod + def is_cloudflare_exception(exception: Exception): + """ + Error status 5xx or 10xx are related to Cloudflare. + https://support.kraken.com/hc/en-us/articles/360001491786-API-error-messages#6 + """ + return bool(re.search(r"HTTP status is (5|10)\d\d\.", str(exception))) + + async def get_open_orders_with_userref(self, userref: int): + data = {'userref': userref} + return await self._api_request("POST", + OPEN_ORDERS_URI, + is_auth_required=True, + data = data) + + async def _api_request_with_retry(self, + method: str, + path_url: str, + params: Optional[Dict[str, Any]] = None, + data: Optional[Dict[str, Any]] = None, + is_auth_required: bool = False, + request_weight: int = 1, + retry_count = 5) -> Dict[str, Any]: + if retry_count == 0: + return await self._api_request(method, path_url, params, data, is_auth_required, request_weight) + + result = None + for retry_attempt in range(retry_count): + try: + result= await self._api_request(method, path_url, params, data, is_auth_required, request_weight) + break + except IOError as e: + if self.is_cloudflare_exception(e): + if path_url == ADD_ORDER_URI: + self.logger().info(f"Retrying {path_url}") + # Order placement could have been successful despite the IOError, so we check for the open order. + response = self.get_open_orders_with_userref(data.get('userref')) + if any(response.get("open").values()): + return response + self.logger().warning(f"Cloudflare error. Attempt {retry_attempt+1}/{retry_count} API command {method}: {path_url}") + continue + else: + raise e + if result is None: + raise IOError(f"Error fetching data from {KRAKEN_ROOT_API + path_url}.") + return result + async def _api_request(self, method: str, path_url: str, @@ -670,11 +722,10 @@ cdef class KrakenExchange(ExchangeBase): data: Optional[Dict[str, Any]] = None, is_auth_required: bool = False, request_weight: int = 1) -> Dict[str, Any]: - async with self._throttler.weighted_task(request_weight=request_weight): + current_api_cost = self.API_COUNTER_POINTS.get(path_url, 0) + async with self._throttler.weighted_task(request_weight=current_api_cost): url = KRAKEN_ROOT_API + path_url - client = await self._http_client() - headers = {} data_dict = data if data is not None else {} @@ -722,10 +773,10 @@ cdef class KrakenExchange(ExchangeBase): async def get_order(self, client_order_id: str) -> Dict[str, Any]: o = self._in_flight_orders.get(client_order_id) - result = await self._api_request("POST", - QUERY_ORDERS_URI, - data={"txid": o.exchange_order_id}, - is_auth_required=True) + result = await self._api_request_with_retry("POST", + QUERY_ORDERS_URI, + data={"txid": o.exchange_order_id}, + is_auth_required=True) return result def supported_order_types(self): @@ -750,10 +801,10 @@ cdef class KrakenExchange(ExchangeBase): } if order_type is OrderType.LIMIT_MAKER: data["oflags"] = "post" - return await self._api_request("post", - ADD_ORDER_URI, - data=data, - is_auth_required=True) + return await self._api_request_with_retry("post", + ADD_ORDER_URI, + data=data, + is_auth_required=True) async def execute_buy(self, order_id: str, @@ -926,10 +977,10 @@ cdef class KrakenExchange(ExchangeBase): if tracked_order is None: raise ValueError(f"Failed to cancel order – {order_id}. Order not found.") data: Dict[str, str] = {"txid": tracked_order.exchange_order_id} - cancel_result = await self._api_request("POST", - CANCEL_ORDER_URI, - data=data, - is_auth_required=True) + cancel_result = await self._api_request_with_retry("POST", + CANCEL_ORDER_URI, + data=data, + is_auth_required=True) if isinstance(cancel_result, dict) and (cancel_result.get("count") == 1 or cancel_result.get("error") is not None): self.logger().info(f"Successfully cancelled order {order_id}.") From 2f5fc15fa8134b4160776d102523b7f3cef21403 Mon Sep 17 00:00:00 2001 From: Nullably <37262506+Nullably@users.noreply.github.com> Date: Thu, 21 Jan 2021 16:08:32 +0800 Subject: [PATCH 049/126] (feat) minor edit --- .../strategy/liquidity_mining/liquidity_mining_config_map.py | 5 +++-- hummingbot/strategy/liquidity_mining/start.py | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/hummingbot/strategy/liquidity_mining/liquidity_mining_config_map.py b/hummingbot/strategy/liquidity_mining/liquidity_mining_config_map.py index 5f39dd0059..d419c27922 100644 --- a/hummingbot/strategy/liquidity_mining/liquidity_mining_config_map.py +++ b/hummingbot/strategy/liquidity_mining/liquidity_mining_config_map.py @@ -48,9 +48,10 @@ def auto_campaigns_participation_on_validated(value: bool) -> None: ConfigVar(key="reserved_balances", prompt="Enter a list of tokens and their reserved balance (to not be used by the bot), " "This can be used for an asset amount you want to set a side to pay for fee (e.g. BNB for " - "Binance), to limit exposure or in anticipation of withdrawal. The format is TOKEN-amount " - "e.g. BTC-0.1, BNB-1 >>> ", + "Binance), to limit exposure or in anticipation of withdrawal, e.g. BTC:0.1,BNB:1 >>> ", type_str="str", + default="", + validator=lambda s: None, prompt_on_new=True), "auto_assign_campaign_budgets": ConfigVar(key="auto_assign_campaign_budgets", diff --git a/hummingbot/strategy/liquidity_mining/start.py b/hummingbot/strategy/liquidity_mining/start.py index 5795906639..abc0a072c3 100644 --- a/hummingbot/strategy/liquidity_mining/start.py +++ b/hummingbot/strategy/liquidity_mining/start.py @@ -8,7 +8,7 @@ def start(self): exchange = c_map.get("exchange").value.lower() markets_text = c_map.get("markets").value if c_map.get("auto_campaigns_participation").value: - markets_text = "HARD-USDT,RLC-USDT,AVAX-USDT,ALGO-USDT,XEM-USDT,MFT-USDT,AVAX-BNB,MFT-BNB" + markets_text = "HARD-USDT,RLC-USDT,AVAX-USDT,EOS-USDT,EOS-BTC,MFT-USDT,AVAX-BNB,MFT-BNB" reserved_bals_text = c_map.get("reserved_balances").value or "" reserved_bals = reserved_bals_text.split(",") reserved_bals = {r.split(":")[0]: Decimal(r.split(":")[1]) for r in reserved_bals} From d61a7aa479a1869784f603a1727448a8091ced62 Mon Sep 17 00:00:00 2001 From: Nicolas Baum Date: Thu, 21 Jan 2021 23:25:39 -0300 Subject: [PATCH 050/126] (feat) Dynamic trades csv file fields --- hummingbot/connector/markets_recorder.py | 43 +++++++++++++++++++----- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/hummingbot/connector/markets_recorder.py b/hummingbot/connector/markets_recorder.py index a98072bc64..d4a40f65e9 100644 --- a/hummingbot/connector/markets_recorder.py +++ b/hummingbot/connector/markets_recorder.py @@ -1,6 +1,7 @@ #!/usr/bin/env python import os.path import pandas as pd +from shutil import move import asyncio from sqlalchemy.orm import ( Session, @@ -260,19 +261,43 @@ def _did_fill_order(self, market.add_trade_fills_from_market_recorder({TradeFill_order_details(trade_fill_record.market, trade_fill_record.exchange_trade_id, trade_fill_record.symbol)}) self.append_to_csv(trade_fill_record) + @staticmethod + def _is_primitive_type(obj: object) -> bool: + return not hasattr(obj, '__dict__') + + @staticmethod + def _is_protected_method(method_name: str) -> bool: + return method_name.startswith('_') + + @staticmethod + def _csv_matches_header(file_path: str, header: tuple) -> bool: + df = pd.read_csv(file_path, header=None) + return tuple(df.iloc[0].values) == header + def append_to_csv(self, trade: TradeFill): - csv_file = "trades_" + trade.config_file_path[:-4] + ".csv" - csv_path = os.path.join(data_path(), csv_file) + csv_filename = "trades_" + trade.config_file_path[:-4] + ".csv" + csv_path = os.path.join(data_path(), csv_filename) + + field_names = ("id",) # id field should be first + field_names += tuple(attr for attr in dir(trade) if (not self._is_protected_method(attr) and + self._is_primitive_type(getattr(trade, attr)) and + (not attr in field_names))) + field_data = tuple(getattr(trade, attr) for attr in field_names) + + # adding extra field "age" # // indicates order is a paper order so 'n/a'. For real orders, calculate age. - age = "n/a" - if "//" not in trade.order_id: - age = pd.Timestamp(int(trade.timestamp / 1e3 - int(trade.order_id[-16:]) / 1e6), unit='s').strftime('%H:%M:%S') + age = pd.Timestamp(int(trade.timestamp / 1e3 - int(trade.order_id[-16:]) / 1e6), unit='s').strftime( + '%H:%M:%S') if "//" not in trade.order_id else "n/a" + field_names += ("age",) + field_data += (age,) + + if(not self._csv_matches_header(csv_path, field_names)): + move(csv_path, csv_path[:-4]+'_old_' + pd.Timestamp.utcnow().strftime("%Y%m%d-%H%M%S")+".csv") + if not os.path.exists(csv_path): - df_header = pd.DataFrame([["Config File", "Strategy", "Exchange", "Timestamp", "Market", "Base", "Quote", - "Trade", "Type", "Price", "Amount", "Fee", "Age", "Order ID", "Exchange Trade ID"]]) + df_header = pd.DataFrame([field_names]) df_header.to_csv(csv_path, mode='a', header=False, index=False) - df = pd.DataFrame([[trade.config_file_path, trade.strategy, trade.market, trade.timestamp, trade.symbol, trade.base_asset, trade.quote_asset, - trade.trade_type, trade.order_type, trade.price, trade.amount, trade.trade_fee, age, trade.order_id, trade.exchange_trade_id]]) + df = pd.DataFrame([field_data]) df.to_csv(csv_path, mode='a', header=False, index=False) def _update_order_status(self, From 1e17daf874dd611127c5cb9264fe798edabcd5d1 Mon Sep 17 00:00:00 2001 From: Nicolas Baum Date: Thu, 21 Jan 2021 23:30:54 -0300 Subject: [PATCH 051/126] (feat) Thank you flake8 --- hummingbot/connector/markets_recorder.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/hummingbot/connector/markets_recorder.py b/hummingbot/connector/markets_recorder.py index d4a40f65e9..d3af1a4f15 100644 --- a/hummingbot/connector/markets_recorder.py +++ b/hummingbot/connector/markets_recorder.py @@ -280,8 +280,8 @@ def append_to_csv(self, trade: TradeFill): field_names = ("id",) # id field should be first field_names += tuple(attr for attr in dir(trade) if (not self._is_protected_method(attr) and - self._is_primitive_type(getattr(trade, attr)) and - (not attr in field_names))) + self._is_primitive_type(getattr(trade, attr)) and + (attr not in field_names))) field_data = tuple(getattr(trade, attr) for attr in field_names) # adding extra field "age" @@ -292,7 +292,7 @@ def append_to_csv(self, trade: TradeFill): field_data += (age,) if(not self._csv_matches_header(csv_path, field_names)): - move(csv_path, csv_path[:-4]+'_old_' + pd.Timestamp.utcnow().strftime("%Y%m%d-%H%M%S")+".csv") + move(csv_path, csv_path[:-4] + '_old_' + pd.Timestamp.utcnow().strftime("%Y%m%d-%H%M%S") + ".csv") if not os.path.exists(csv_path): df_header = pd.DataFrame([field_names]) From 20d7ecbb7b0687c55b408758323d3463f5b6494a Mon Sep 17 00:00:00 2001 From: Nicolas Baum Date: Fri, 22 Jan 2021 00:05:00 -0300 Subject: [PATCH 052/126] (feat) Corrected missing case where csv file originally does not exist --- hummingbot/connector/markets_recorder.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hummingbot/connector/markets_recorder.py b/hummingbot/connector/markets_recorder.py index d3af1a4f15..7a80759905 100644 --- a/hummingbot/connector/markets_recorder.py +++ b/hummingbot/connector/markets_recorder.py @@ -291,7 +291,7 @@ def append_to_csv(self, trade: TradeFill): field_names += ("age",) field_data += (age,) - if(not self._csv_matches_header(csv_path, field_names)): + if(os.path.exists(csv_path) and (not self._csv_matches_header(csv_path, field_names))): move(csv_path, csv_path[:-4] + '_old_' + pd.Timestamp.utcnow().strftime("%Y%m%d-%H%M%S") + ".csv") if not os.path.exists(csv_path): From 55b86b87f496fac443b1baf2ca85c986d5fe8ee4 Mon Sep 17 00:00:00 2001 From: Peng Wang Date: Thu, 21 Jan 2021 20:34:05 -0800 Subject: [PATCH 053/126] add check so that it won't throw exception if key is not in dict --- hummingbot/wallet/ethereum/erc20_token.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hummingbot/wallet/ethereum/erc20_token.py b/hummingbot/wallet/ethereum/erc20_token.py index 1b5358a97d..55b847a118 100644 --- a/hummingbot/wallet/ethereum/erc20_token.py +++ b/hummingbot/wallet/ethereum/erc20_token.py @@ -83,7 +83,7 @@ def __init__(self, # By default token_overrides will be assigned an empty dictionary # This helps prevent breaking of market unit tests - token_overrides: Dict[str, str] = global_config_map["ethereum_token_overrides"].value or {} + token_overrides: Dict[str, str] = global_config_map["ethereum_token_overrides"].value if "ethereum_token_overrides" in global_config_map else {} override_addr_to_token_name: Dict[str, str] = {value: key for key, value in token_overrides.items()} override_token_name: Optional[str] = override_addr_to_token_name.get(address) if override_token_name == "WETH": From 2cd0d891eb525322d5eda1e0423df78c7009e509 Mon Sep 17 00:00:00 2001 From: Nicolas Baum Date: Fri, 22 Jan 2021 19:22:50 -0300 Subject: [PATCH 054/126] (fix) Added sleep time between failure retries when cloudflare error is raised. Also added retry interval option to api requests --- .../exchange/kraken/kraken_exchange.pyx | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/hummingbot/connector/exchange/kraken/kraken_exchange.pyx b/hummingbot/connector/exchange/kraken/kraken_exchange.pyx index 42fff7f051..ca6e0bfecb 100755 --- a/hummingbot/connector/exchange/kraken/kraken_exchange.pyx +++ b/hummingbot/connector/exchange/kraken/kraken_exchange.pyx @@ -678,10 +678,10 @@ cdef class KrakenExchange(ExchangeBase): async def get_open_orders_with_userref(self, userref: int): data = {'userref': userref} - return await self._api_request("POST", - OPEN_ORDERS_URI, - is_auth_required=True, - data = data) + return await self._api_request_with_retry("POST", + OPEN_ORDERS_URI, + is_auth_required=True, + data=data) async def _api_request_with_retry(self, method: str, @@ -690,7 +690,9 @@ cdef class KrakenExchange(ExchangeBase): data: Optional[Dict[str, Any]] = None, is_auth_required: bool = False, request_weight: int = 1, - retry_count = 5) -> Dict[str, Any]: + retry_count = 5, + retry_interval = 1.0) -> Dict[str, Any]: + request_weight = self.API_COUNTER_POINTS.get(path_url, 0) if retry_count == 0: return await self._api_request(method, path_url, params, data, is_auth_required, request_weight) @@ -703,11 +705,12 @@ cdef class KrakenExchange(ExchangeBase): if self.is_cloudflare_exception(e): if path_url == ADD_ORDER_URI: self.logger().info(f"Retrying {path_url}") - # Order placement could have been successful despite the IOError, so we check for the open order. + # Order placement could have been successful despite the IOError, so check for the open order. response = self.get_open_orders_with_userref(data.get('userref')) if any(response.get("open").values()): return response self.logger().warning(f"Cloudflare error. Attempt {retry_attempt+1}/{retry_count} API command {method}: {path_url}") + await asyncio.sleep(retry_interval) continue else: raise e @@ -722,8 +725,7 @@ cdef class KrakenExchange(ExchangeBase): data: Optional[Dict[str, Any]] = None, is_auth_required: bool = False, request_weight: int = 1) -> Dict[str, Any]: - current_api_cost = self.API_COUNTER_POINTS.get(path_url, 0) - async with self._throttler.weighted_task(request_weight=current_api_cost): + async with self._throttler.weighted_task(request_weight=request_weight): url = KRAKEN_ROOT_API + path_url client = await self._http_client() headers = {} From 0d3276bad24e27629beb7c82e2bc25a26d1c616c Mon Sep 17 00:00:00 2001 From: nionis Date: Sat, 23 Jan 2021 00:39:48 +0200 Subject: [PATCH 055/126] (feat) bitmax connector: stage 3 --- .../exchange/bitmax/bitmax_exchange.py | 946 ++++++++++++++++++ .../exchange/bitmax/bitmax_in_flight_order.py | 71 ++ .../connector/exchange/bitmax/bitmax_utils.py | 57 +- .../exchange/bitmax/test_bitmax_exchange.py | 421 ++++++++ 4 files changed, 1474 insertions(+), 21 deletions(-) create mode 100644 hummingbot/connector/exchange/bitmax/bitmax_exchange.py create mode 100644 hummingbot/connector/exchange/bitmax/bitmax_in_flight_order.py create mode 100644 test/connector/exchange/bitmax/test_bitmax_exchange.py diff --git a/hummingbot/connector/exchange/bitmax/bitmax_exchange.py b/hummingbot/connector/exchange/bitmax/bitmax_exchange.py new file mode 100644 index 0000000000..f55ec4a1a3 --- /dev/null +++ b/hummingbot/connector/exchange/bitmax/bitmax_exchange.py @@ -0,0 +1,946 @@ +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.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.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.core.data_type.common import OpenOrder +ctce_logger = None +s_decimal_NaN = Decimal("nan") + + +class BitmaxExchange(ExchangeBase): + """ + BitmaxExchange connects with Bitmax 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, + bitmax_api_key: str, + bitmax_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 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._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._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._account_group = None # required in order to make post requests + self._account_uid = None # required in order to produce deterministic order ids + + @property + def name(self) -> str: + return EXCHANGE_NAME + + @property + def order_books(self) -> Dict[str, OrderBook]: + return self._order_book_tracker.order_books + + @property + def trading_rules(self) -> Dict[str, TradingRule]: + return self._trading_rules + + @property + def in_flight_orders(self) -> Dict[str, BitmaxInFlightOrder]: + 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, + "account_data": self._account_group is not None and self._account_uid is not None + } + + @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: BitmaxInFlightOrder.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() + await self._update_account_data() + self._trading_rules_polling_task = safe_ensure_future(self._trading_rules_polling_loop()) + if self._trading_required: + self._status_polling_task = safe_ensure_future(self._status_polling_loop()) + self._user_stream_tracker_task = safe_ensure_future(self._user_stream_tracker.start()) + self._user_stream_event_listener_task = safe_ensure_future(self._user_stream_event_listener()) + + async def stop_network(self): + """ + This function is required by NetworkIterator base class and is called automatically. + """ + self._order_book_tracker.stop() + if self._status_polling_task is not None: + self._status_polling_task.cancel() + self._status_polling_task = None + if self._trading_rules_polling_task is not None: + self._trading_rules_polling_task.cancel() + self._trading_rules_polling_task = None + if self._status_polling_task is not None: + self._status_polling_task.cancel() + self._status_polling_task = None + if self._user_stream_tracker_task is not None: + self._user_stream_tracker_task.cancel() + self._user_stream_tracker_task = None + if self._user_stream_event_listener_task is not None: + self._user_stream_event_listener_task.cancel() + self._user_stream_event_listener_task = None + + async def check_network(self) -> NetworkStatus: + """ + This function is required by NetworkIterator base class and is called periodically to check + the network connection. Simply ping the network (or call any light weight public API). + """ + try: + # since there is no ping endpoint, the lowest rate call is to get BTC-USDT ticker + await self._api_request("get", "ticker") + except asyncio.CancelledError: + raise + except Exception: + return NetworkStatus.NOT_CONNECTED + return NetworkStatus.CONNECTED + + async def _http_client(self) -> aiohttp.ClientSession: + """ + :returns Shared client session instance + """ + if self._shared_client is None: + self._shared_client = aiohttp.ClientSession() + return self._shared_client + + async def _trading_rules_polling_loop(self): + """ + Periodically update trading rule. + """ + while True: + try: + await self._update_trading_rules() + await asyncio.sleep(60) + except asyncio.CancelledError: + raise + except Exception as e: + self.logger().network(f"Unexpected error while fetching trading rules. Error: {str(e)}", + exc_info=True, + app_warning_msg="Could not fetch new trading rules from Bitmax. " + "Check network connection.") + await asyncio.sleep(0.5) + + async def _update_trading_rules(self): + instruments_info = await self._api_request("get", path_url="products") + 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: + { + "code": 0, + "data": [ + { + "symbol": "BTMX/USDT", + "baseAsset": "BTMX", + "quoteAsset": "USDT", + "status": "Normal", + "minNotional": "5", + "maxNotional": "100000", + "marginTradable": true, + "commissionType": "Quote", + "commissionReserveRate": "0.001", + "tickSize": "0.000001", + "lotSize": "0.001" + } + ] + } + """ + result = {} + for rule in instruments_info["data"]: + try: + trading_pair = bitmax_utils.convert_from_exchange_trading_pair(rule["symbol"]) + # TODO: investigate https://bitmax-exchange.github.io/bitmax-pro-api/#place-order + result[trading_pair] = TradingRule( + trading_pair, + # min_order_size=Decimal(rule["minNotional"]), + # max_order_size=Decimal(rule["maxNotional"]), + min_price_increment=Decimal(rule["tickSize"]), + min_base_amount_increment=Decimal(rule["lotSize"]) + ) + except Exception: + self.logger().error(f"Error parsing the trading pair rule {rule}. Skipping.", exc_info=True) + return result + + async def _update_account_data(self): + headers = { + **self._bitmax_auth.get_headers(), + **self._bitmax_auth.get_auth_headers("info"), + } + url = f"{REST_URL}/info" + response = await aiohttp.ClientSession().get(url, headers=headers) + + 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}") + if parsed_response["code"] != 0: + raise IOError(f"{url} API call failed, response: {parsed_response}") + + self._account_group = parsed_response["data"]["accountGroup"] + self._account_uid = parsed_response["data"]["userUID"] + + async def _api_request(self, + method: str, + path_url: str, + params: Dict[str, Any] = {}, + is_auth_required: bool = False, + force_auth_path_url: Optional[str] = None + ) -> 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 = None + headers = None + + if is_auth_required: + url = f"{getRestUrlPriv(self._account_group)}/{path_url}" + headers = { + **self._bitmax_auth.get_headers(), + **self._bitmax_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() + + client = await self._http_client() + if method == "get": + response = await client.get( + url, + headers=headers + ) + elif method == "post": + response = await client.post( + url, + headers=headers, + data=json.dumps(params) + ) + elif method == "delete": + response = await client.delete( + url, + headers=headers, + data=json.dumps(params) + ) + else: + raise NotImplementedError + + if response.status != 200: + print(url, path_url, force_auth_path_url, response) + + 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}") + if parsed_response["code"] != 0: + raise IOError(f"{url} API call failed, response: {parsed_response}") + + return parsed_response + + def get_order_price_quantum(self, trading_pair: str, price: Decimal): + """ + Returns a price step, a minimum price increment for a given trading pair. + """ + trading_rule = self._trading_rules[trading_pair] + return trading_rule.min_price_increment + + def get_order_size_quantum(self, trading_pair: str, order_size: Decimal): + """ + Returns an order amount step, a minimum amount increment for a given trading pair. + """ + trading_rule = self._trading_rules[trading_pair] + return Decimal(trading_rule.min_base_amount_increment) + + def get_order_book(self, trading_pair: str) -> OrderBook: + if trading_pair not in self._order_book_tracker.order_books: + raise ValueError(f"No order book exists for '{trading_pair}'.") + return self._order_book_tracker.order_books[trading_pair] + + def buy(self, trading_pair: str, amount: Decimal, order_type=OrderType.MARKET, + price: Decimal = s_decimal_NaN, **kwargs) -> str: + """ + Buys an amount of base asset (of the given trading pair). This function returns immediately. + To see an actual order, you'll have to wait for BuyOrderCreatedEvent. + :param trading_pair: The market (e.g. BTC-USDT) to buy from + :param amount: The amount in base token value + :param order_type: The order type + :param price: The price (note: this is no longer optional) + :returns A new internal order id + """ + [order_id, timestamp] = bitmax_utils.gen_order_id(self._account_uid) + safe_ensure_future(self._create_order(TradeType.BUY, order_id, timestamp, 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, timestamp] = bitmax_utils.gen_order_id(self._account_uid) + safe_ensure_future(self._create_order(TradeType.SELL, order_id, timestamp, 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, + timestamp: int, + 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}.") + + # TODO: check balance + + api_params = { + "id": order_id, + "time": timestamp, + "symbol": bitmax_utils.convert_to_exchange_trading_pair(trading_pair), + "orderPrice": f"{price:f}", + "orderQty": f"{amount:f}", + "orderType": "limit", + "side": trade_type.name + } + + self.start_tracking_order( + order_id, + order_id, + trading_pair, + trade_type, + price, + amount, + order_type + ) + + try: + await self._api_request("post", "cash/order", api_params, True, force_auth_path_url="order") + # exchange_order_id = 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 Bitmax 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] = BitmaxInFlightOrder( + client_order_id=order_id, + exchange_order_id=exchange_order_id, + trading_pair=trading_pair, + order_type=order_type, + trade_type=trade_type, + price=price, + amount=amount + ) + + def stop_tracking_order(self, order_id: str): + """ + Stops tracking an order by simply removing it from _in_flight_orders dictionary. + """ + if order_id in self._in_flight_orders: + del self._in_flight_orders[order_id] + + async def _execute_cancel(self, trading_pair: str, order_id: str) -> str: + """ + Executes order cancellation process by first calling cancel-order API. The API result doesn't confirm whether + the cancellation is successful, it simply states it receives the request. + :param trading_pair: The market trading pair + :param order_id: The internal order id + order.last_state to change to CANCELED + """ + try: + tracked_order = self._in_flight_orders.get(order_id) + if tracked_order is None: + raise ValueError(f"Failed to cancel order - {order_id}. Order not found.") + if tracked_order.exchange_order_id is None: + await tracked_order.get_exchange_order_id() + ex_order_id = tracked_order.exchange_order_id + await self._api_request( + "delete", + "cash/order", + { + "symbol": bitmax_utils.convert_to_exchange_trading_pair(trading_pair), + "orderId": ex_order_id, + "time": bitmax_utils.get_ms_timestamp() + }, + True, + force_auth_path_url="order" + ) + return order_id + except asyncio.CancelledError: + raise + except Exception as e: + self.logger().network( + f"Failed to cancel order {order_id}: {str(e)}", + exc_info=True, + app_warning_msg=f"Failed to cancel the order {order_id} on Bitmax. " + 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 Bitmax. " + "Check API key and network connection.") + await asyncio.sleep(0.5) + + async def _update_balances(self): + """ + Calls REST API to update total and available balances. + """ + print("-----> _update_balances") + local_asset_names = set(self._account_balances.keys()) + remote_asset_names = set() + response = await self._api_request("get", "cash/balance", {}, True, force_auth_path_url="balance") + for account in response["data"]: + asset_name = account["asset"] + self._account_available_balances[asset_name] = Decimal(str(account["availableBalance"])) + self._account_balances[asset_name] = Decimal(str(account["totalBalance"])) + remote_asset_names.add(asset_name) + + asset_names_to_remove = local_asset_names.difference(remote_asset_names) + for asset_name in asset_names_to_remove: + del self._account_available_balances[asset_name] + del self._account_balances[asset_name] + + async def _update_order_status(self): + """ + Calls REST API to get status update for each in-flight order. + """ + last_tick = int(self._last_poll_timestamp / self.UPDATE_ORDER_STATUS_MIN_INTERVAL) + current_tick = int(self.current_timestamp / self.UPDATE_ORDER_STATUS_MIN_INTERVAL) + + print("_update_order_status") + + 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._api_request( + "get", + f"cash/order/status?orderId={order_id}", + {}, + True, + force_auth_path_url="order/status") + ) + 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: + print("update_result", update_result) + + 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 + # for trade_msg in update_result["data"]["trade_list"]: + # await self._process_trade_message(trade_msg) + for order_data in update_result["data"]: + print("update_result order_data", order_data) + self._process_order_message(order_data) + + def _process_order_message(self, order_msg: Dict[str, Any]): + """ + Updates in-flight order and triggers cancellation or failure event if needed. + :param order_msg: The order response from either REST or web socket API (they are of the same format) + """ + client_order_id = order_msg["orderId"] + + print("_process_order_message", order_msg) + print("client_order_id", client_order_id) + print("in flight", self._in_flight_orders) + + if self._in_flight_orders.get(client_order_id, None) is None: + print("NOT IN FLIGHT ORDERS") + return + + tracked_order = self._in_flight_orders[client_order_id] + # Update order execution status + tracked_order.last_state = order_msg["st"] + + 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: {bitmax_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) + elif tracked_order.is_done: + print("----> order is done", ) + + price = Decimal(order_msg["ap"]) + tracked_order.executed_amount_base = Decimal(order_msg["cfq"]) + tracked_order.executed_amount_quote = price * tracked_order.executed_amount_base + tracked_order.fee_paid = Decimal(order_msg["cf"]) + tracked_order.fee_asset = order_msg["fa"] + + 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, + price, + tracked_order.executed_amount_base, + TradeFee(0.0, [(tracked_order.fee_asset, tracked_order.fee_paid)]), + exchange_trade_id=order_msg["orderId"] + ) + ) + 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(client_order_id) + + async def _process_trade_message(self, trade_msg: Dict[str, Any]): + """ + Updates in-flight order and trigger order filled event for trade message received. Triggers order completed + event if the total executed amount equals to the specified order amount. + """ + for order in self._in_flight_orders.values(): + await order.get_exchange_order_id() + track_order = [o for o in self._in_flight_orders.values() if trade_msg["order_id"] == o.exchange_order_id] + if not track_order: + return + tracked_order = track_order[0] + updated = tracked_order.update_with_trade_update(trade_msg) + if not updated: + return + self.trigger_event( + MarketEvent.OrderFilled, + OrderFilledEvent( + self.current_timestamp, + tracked_order.client_order_id, + tracked_order.trading_pair, + tracked_order.trade_type, + tracked_order.order_type, + Decimal(str(trade_msg["traded_price"])), + Decimal(str(trade_msg["traded_quantity"])), + TradeFee(0.0, [(trade_msg["fee_currency"], Decimal(str(trade_msg["fee"])))]), + exchange_trade_id=trade_msg["order_id"] + ) + ) + if math.isclose(tracked_order.executed_amount_base, tracked_order.amount) or \ + tracked_order.executed_amount_base >= tracked_order.amount: + tracked_order.last_state = "FILLED" + self.logger().info(f"The {tracked_order.trade_type.name} order " + f"{tracked_order.client_order_id} has completed " + f"according to order status API.") + event_tag = MarketEvent.BuyOrderCompleted if tracked_order.trade_type is TradeType.BUY \ + else MarketEvent.SellOrderCompleted + event_class = BuyOrderCompletedEvent if tracked_order.trade_type is TradeType.BUY \ + else SellOrderCompletedEvent + self.trigger_event(event_tag, + event_class(self.current_timestamp, + tracked_order.client_order_id, + tracked_order.base_asset, + tracked_order.quote_asset, + tracked_order.fee_asset, + tracked_order.executed_amount_base, + tracked_order.executed_amount_quote, + tracked_order.fee_paid, + tracked_order.order_type)) + self.stop_tracking_order(tracked_order.client_order_id) + + async def cancel_all(self, timeout_seconds: float): + """ + Cancels all in-flight orders and waits for cancellation results. + Used by bot's top level stop and exit commands (cancelling outstanding orders on exit) + :param timeout_seconds: The timeout at which the operation will be canceled. + :returns List of CancellationResult which indicates whether each order is successfully cancelled. + """ + if self._trading_pairs is None: + raise Exception("cancel_all can only be used when trading_pairs are specified.") + cancellation_results = [] + try: + for trading_pair in self._trading_pairs: + await self._api_request( + "delete", + "cash/order/all", + {"symbol": bitmax_utils.convert_to_exchange_trading_pair(trading_pair)}, + True, + force_auth_path_url="order/all" + ) + open_orders = await self.get_open_orders() + for cl_order_id, tracked_order in self._in_flight_orders.items(): + open_order = [o for o in open_orders if o.client_order_id == cl_order_id] + if not open_order: + cancellation_results.append(CancellationResult(cl_order_id, True)) + self.trigger_event(MarketEvent.OrderCancelled, + OrderCancelledEvent(self.current_timestamp, cl_order_id)) + else: + cancellation_results.append(CancellationResult(cl_order_id, False)) + except Exception: + self.logger().network( + "Failed to cancel all orders.", + exc_info=True, + app_warning_msg="Failed to cancel all orders on Bitmax. 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 Bitmax. 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. + """ + async for event_message in self._iter_user_event_queue(): + try: + print("event_message", event_message) + if event_message.get("m") == "order": + print("in order") + self._process_order_message(event_message.get("data")) + + # if "result" not in event_message or "channel" not in event_message["result"]: + # continue + # channel = event_message["result"]["channel"] + # if "user.trade" in channel: + # for trade_msg in event_message["result"]["data"]: + # await self._process_trade_message(trade_msg) + # elif "user.order" in channel: + # for order_msg in event_message["result"]["data"]: + # self._process_order_message(order_msg) + # elif channel == "user.balance": + # balances = event_message["result"]["data"] + # for balance_entry in balances: + # asset_name = balance_entry["currency"] + # self._account_balances[asset_name] = Decimal(str(balance_entry["balance"])) + # self._account_available_balances[asset_name] = Decimal(str(balance_entry["available"])) + except asyncio.CancelledError: + raise + except Exception: + self.logger().error("Unexpected error in user stream listener loop.", exc_info=True) + await asyncio.sleep(5.0) + + async def get_open_orders(self) -> List[OpenOrder]: + result = await self._api_request( + "get", + "cash/order/open", + {}, + True, + force_auth_path_url="order/open" + ) + ret_val = [] + for order in result["data"]: + if order["type"] != "LIMIT": + raise Exception(f"Unsupported order type {order['type']}") + ret_val.append( + OpenOrder( + client_order_id=order["orderId"], + trading_pair=bitmax_utils.convert_from_exchange_trading_pair(order["symbol"]), + price=Decimal(str(order["price"])), + amount=Decimal(str(order["orderQty"])), + executed_amount=Decimal(str(order["cumFilledQty"])), + status=order["status"], + order_type=OrderType.LIMIT, + is_buy=True if order["side"].lower() == "buy" else False, + time=int(order["lastExecTime"]), + exchange_order_id=order["orderId"] + ) + ) + return ret_val diff --git a/hummingbot/connector/exchange/bitmax/bitmax_in_flight_order.py b/hummingbot/connector/exchange/bitmax/bitmax_in_flight_order.py new file mode 100644 index 0000000000..b455433ba0 --- /dev/null +++ b/hummingbot/connector/exchange/bitmax/bitmax_in_flight_order.py @@ -0,0 +1,71 @@ +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 + + +class BitmaxInFlightOrder(InFlightOrderBase): + def __init__(self, + client_order_id: str, + exchange_order_id: Optional[str], + trading_pair: str, + order_type: OrderType, + trade_type: TradeType, + price: Decimal, + amount: Decimal, + initial_state: str = "OPEN"): + super().__init__( + client_order_id, + exchange_order_id, + trading_pair, + order_type, + trade_type, + price, + amount, + initial_state, + ) + self.trade_id_set = set() + self.cancelled_event = asyncio.Event() + + @property + def is_done(self) -> bool: + return self.last_state in {"Filled", "Canceled", "Reject"} + + @property + def is_failure(self) -> bool: + return self.last_state in {"Reject"} + + @property + def is_cancelled(self) -> bool: + return self.last_state in {"Canceled"} + + @classmethod + def from_json(cls, data: Dict[str, Any]) -> InFlightOrderBase: + """ + :param data: json data from API + :return: formatted InFlightOrder + """ + retval = BitmaxInFlightOrder( + data["client_order_id"], + data["exchange_order_id"], + data["trading_pair"], + getattr(OrderType, data["order_type"]), + getattr(TradeType, data["trade_type"]), + Decimal(data["price"]), + Decimal(data["amount"]), + data["last_state"] + ) + retval.executed_amount_base = Decimal(data["executed_amount_base"]) + retval.executed_amount_quote = Decimal(data["executed_amount_quote"]) + retval.fee_asset = data["fee_asset"] + retval.fee_paid = Decimal(data["fee_paid"]) + retval.last_state = data["last_state"] + return retval diff --git a/hummingbot/connector/exchange/bitmax/bitmax_utils.py b/hummingbot/connector/exchange/bitmax/bitmax_utils.py index f974acf8c8..e0eebcf12b 100644 --- a/hummingbot/connector/exchange/bitmax/bitmax_utils.py +++ b/hummingbot/connector/exchange/bitmax/bitmax_utils.py @@ -1,7 +1,8 @@ +import random +import string import math -# from typing import Dict, List +from typing import Tuple from hummingbot.core.utils.tracking_nonce import get_tracking_nonce_low_res -# from . import crypto_com_constants as Constants from hummingbot.client.config.config_var import ConfigVar from hummingbot.client.config.config_methods import using_exchange @@ -13,8 +14,6 @@ DEFAULT_FEES = [0.1, 0.1] -# HBOT_BROKER_ID = "HBOT-" - def convert_from_exchange_trading_pair(exchange_trading_pair: str) -> str: return exchange_trading_pair.replace("/", "-") @@ -23,23 +22,6 @@ def convert_from_exchange_trading_pair(exchange_trading_pair: str) -> str: def convert_to_exchange_trading_pair(hb_trading_pair: str) -> str: return hb_trading_pair.replace("-", "/") -# # 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: @@ -51,6 +33,39 @@ def ms_timestamp_to_s(ms: int) -> int: return math.floor(ms / 1e3) +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: + """ + 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] + + +def gen_order_id(userUid: str) -> Tuple[str, int]: + """ + Generate an order id + :param user_uid: user uid + :return: order id of length 32 + """ + time = get_ms_timestamp() + return [ + derive_order_id( + userUid, + uuid32(), + time + ), + time + ] + + KEYS = { "bitmax_api_key": ConfigVar(key="bitmax_api_key", diff --git a/test/connector/exchange/bitmax/test_bitmax_exchange.py b/test/connector/exchange/bitmax/test_bitmax_exchange.py new file mode 100644 index 0000000000..f821eb8865 --- /dev/null +++ b/test/connector/exchange/bitmax/test_bitmax_exchange.py @@ -0,0 +1,421 @@ +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.bitmax.bitmax_exchange import BitmaxExchange + +logging.basicConfig(level=METRICS_LOG_LEVEL) + +API_KEY = conf.bitmax_api_key +API_SECRET = conf.bitmax_secret_key + + +class BitmaxExchangeUnitTest(unittest.TestCase): + events: List[MarketEvent] = [ + MarketEvent.BuyOrderCompleted, + MarketEvent.SellOrderCompleted, + MarketEvent.OrderFilled, + MarketEvent.TransactionFailure, + MarketEvent.BuyOrderCreated, + MarketEvent.SellOrderCreated, + MarketEvent.OrderCancelled, + MarketEvent.OrderFailure + ] + connector: BitmaxExchange + 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: BitmaxExchange = BitmaxExchange( + bitmax_api_key=API_KEY, + bitmax_secret_key=API_SECRET, + trading_pairs=[cls.trading_pair], + trading_required=True + ) + print("Initializing Bitmax 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): + self.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.001")) + + def test_buy_and_sell(self): + price = self.connector.get_price(self.trading_pair, True) * Decimal("1.05") + price = self.connector.quantize_order_price(self.trading_pair, price) + amount = self.connector.quantize_order_amount(self.trading_pair, Decimal("0.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(2)) + trade_events = [t for t in self.event_logger.event_log if isinstance(t, OrderFilledEvent)] + base_amount_traded = sum(t.amount for t in trade_events) + quote_amount_traded = sum(t.amount * t.price for t in trade_events) + + self.assertTrue([evt.order_type == OrderType.LIMIT for evt in trade_events]) + self.assertEqual(order_id, order_completed_event.order_id) + self.assertEqual(amount, order_completed_event.base_asset_amount) + self.assertEqual("BTC", order_completed_event.base_asset) + self.assertEqual("USDT", order_completed_event.quote_asset) + self.assertAlmostEqual(base_amount_traded, order_completed_event.base_asset_amount) + self.assertAlmostEqual(quote_amount_traded, order_completed_event.quote_asset_amount) + self.assertGreater(order_completed_event.fee_amount, Decimal(0)) + self.assertTrue(any([isinstance(event, BuyOrderCreatedEvent) and event.order_id == order_id + for event in self.event_logger.event_log])) + + # check available quote balance gets updated, we need to wait a bit for the balance message to arrive + expected_quote_bal = quote_bal - quote_amount_traded + # self.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.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) + 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.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")) + 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 + expected_quote_bal = quote_bal - (price * amount) + 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), 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.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(3)) + 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_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.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 = 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._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.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) + new_connector = BitmaxExchange(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) + self.ev_loop.run_until_complete(self.event_logger.wait_for(OrderCancelledEvent)) + order_id = None + self.assertEqual(0, len(new_connector.limit_orders)) + self.assertEqual(0, len(new_connector.tracking_states)) + saved_market_states = recorder.get_market_states(config_path, new_connector) + self.assertEqual(0, len(saved_market_states.saved_state)) + finally: + if order_id is not None: + self.connector.cancel(self.trading_pair, cl_order_id) + self.run_parallel(self.event_logger.wait_for(OrderCancelledEvent)) + + recorder.stop() + os.unlink(self.db_path) + + def test_update_last_prices(self): + # This is basic test to see if order_book last_trade_price is initiated and updated. + for order_book in self.connector.order_books.values(): + for _ in range(5): + self.ev_loop.run_until_complete(asyncio.sleep(1)) + 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)) + + # 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) From 701f0926c4593c710874c6afc1b0e22e33ffebf9 Mon Sep 17 00:00:00 2001 From: nionis Date: Sat, 23 Jan 2021 00:43:05 +0200 Subject: [PATCH 056/126] (clean) remove bitmax gitignore file --- hummingbot/connector/exchange/bitmax/.gitignore | 1 - 1 file changed, 1 deletion(-) delete mode 100644 hummingbot/connector/exchange/bitmax/.gitignore diff --git a/hummingbot/connector/exchange/bitmax/.gitignore b/hummingbot/connector/exchange/bitmax/.gitignore deleted file mode 100644 index 23d9952b8c..0000000000 --- a/hummingbot/connector/exchange/bitmax/.gitignore +++ /dev/null @@ -1 +0,0 @@ -backups \ No newline at end of file From 11c3af53cbb02a404aa96f1c3ac9cc3a5116f812 Mon Sep 17 00:00:00 2001 From: nionis Date: Sat, 23 Jan 2021 18:21:37 +0200 Subject: [PATCH 057/126] (fix) bitmax: various improvements --- hummingbot/connector/connector_base.pyx | 2 +- .../bitmax_api_user_stream_data_source.py | 2 +- .../exchange/bitmax/bitmax_exchange.py | 173 ++++++++++-------- .../connector/exchange/bitmax/bitmax_utils.py | 11 +- hummingbot/connector/markets_recorder.py | 4 +- hummingbot/connector/utils.py | 4 + .../exchange/bitmax/test_bitmax_exchange.py | 30 +-- 7 files changed, 126 insertions(+), 100 deletions(-) diff --git a/hummingbot/connector/connector_base.pyx b/hummingbot/connector/connector_base.pyx index 191c105a9c..59e7a2686b 100644 --- a/hummingbot/connector/connector_base.pyx +++ b/hummingbot/connector/connector_base.pyx @@ -14,7 +14,7 @@ from hummingbot.core.event.events import ( from hummingbot.core.event.event_logger import EventLogger from hummingbot.core.network_iterator import NetworkIterator from hummingbot.connector.in_flight_order_base import InFlightOrderBase -from hummingbot.connector.markets_recorder import TradeFill_order_details +from hummingbot.connector.utils import TradeFill_order_details from hummingbot.core.event.events import OrderFilledEvent from hummingbot.client.config.global_config_map import global_config_map from hummingbot.core.utils.estimate_fee import estimate_fee diff --git a/hummingbot/connector/exchange/bitmax/bitmax_api_user_stream_data_source.py b/hummingbot/connector/exchange/bitmax/bitmax_api_user_stream_data_source.py index f81914adc8..0eeae1cd43 100755 --- a/hummingbot/connector/exchange/bitmax/bitmax_api_user_stream_data_source.py +++ b/hummingbot/connector/exchange/bitmax/bitmax_api_user_stream_data_source.py @@ -67,7 +67,7 @@ async def listen_for_user_stream(self, ev_loop: asyncio.BaseEventLoop, output: a async for raw_msg in self._inner_messages(ws): try: msg = ujson.loads(raw_msg) - if (msg is None or (msg.get("m") != "order" and msg.get("m") != "cash")): + if msg is None: continue output.put_nowait(msg) diff --git a/hummingbot/connector/exchange/bitmax/bitmax_exchange.py b/hummingbot/connector/exchange/bitmax/bitmax_exchange.py index f55ec4a1a3..361eb899bb 100644 --- a/hummingbot/connector/exchange/bitmax/bitmax_exchange.py +++ b/hummingbot/connector/exchange/bitmax/bitmax_exchange.py @@ -12,6 +12,7 @@ import aiohttp import math import time +from collections import namedtuple from hummingbot.core.network_iterator import NetworkStatus from hummingbot.logger import HummingbotLogger @@ -21,6 +22,7 @@ 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, @@ -46,6 +48,9 @@ s_decimal_NaN = Decimal("nan") +BitmaxTradingRule = namedtuple("BitmaxTradingRule", "minNotional maxNotional") + + class BitmaxExchange(ExchangeBase): """ BitmaxExchange connects with Bitmax exchange and provides order book pricing, user account tracking and @@ -88,6 +93,7 @@ def __init__(self, self._in_flight_orders = {} # Dict[client_order_id:str, BitmaxInFlightOrder] 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._status_polling_task = None self._user_stream_event_listener_task = None self._trading_rules_polling_task = None @@ -119,7 +125,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, + "trading_rule_initialized": len(self._trading_rules) > 0 and len(self._bitmax_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 @@ -257,10 +263,16 @@ async def _trading_rules_polling_loop(self): 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) self._trading_rules.clear() - self._trading_rules = self._format_trading_rules(instruments_info) + self._trading_rules = trading_rules + self._bitmax_trading_rules.clear() + self._bitmax_trading_rules = bitmax_trading_rules - def _format_trading_rules(self, instruments_info: Dict[str, Any]) -> Dict[str, TradingRule]: + def _format_trading_rules( + self, + instruments_info: Dict[str, Any] + ) -> [Dict[str, TradingRule], Dict[str, Dict[str, BitmaxTradingRule]]]: """ Converts json API response into a dictionary of trading rules. :param instruments_info: The json API response @@ -285,21 +297,23 @@ def _format_trading_rules(self, instruments_info: Dict[str, Any]) -> Dict[str, T ] } """ - result = {} + trading_rules = {} + bitmax_trading_rules = {} for rule in instruments_info["data"]: try: trading_pair = bitmax_utils.convert_from_exchange_trading_pair(rule["symbol"]) - # TODO: investigate https://bitmax-exchange.github.io/bitmax-pro-api/#place-order - result[trading_pair] = TradingRule( + trading_rules[trading_pair] = TradingRule( trading_pair, - # min_order_size=Decimal(rule["minNotional"]), - # max_order_size=Decimal(rule["maxNotional"]), min_price_increment=Decimal(rule["tickSize"]), min_base_amount_increment=Decimal(rule["lotSize"]) ) + bitmax_trading_rules[trading_pair] = BitmaxTradingRule( + 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 result + return [trading_rules, bitmax_trading_rules] async def _update_account_data(self): headers = { @@ -341,6 +355,9 @@ async def _api_request(self, headers = None if is_auth_required: + if (self._account_group) is None: + await self._update_account_data() + url = f"{getRestUrlPriv(self._account_group)}/{path_url}" headers = { **self._bitmax_auth.get_headers(), @@ -373,9 +390,6 @@ async def _api_request(self, else: raise NotImplementedError - if response.status != 200: - print(url, path_url, force_auth_path_url, response) - try: parsed_response = json.loads(await response.text()) except Exception as e: @@ -418,9 +432,9 @@ 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 """ - [order_id, timestamp] = bitmax_utils.gen_order_id(self._account_uid) - safe_ensure_future(self._create_order(TradeType.BUY, order_id, timestamp, trading_pair, amount, order_type, price)) - return order_id + client_order_id = bitmax_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 def sell(self, trading_pair: str, amount: Decimal, order_type=OrderType.MARKET, price: Decimal = s_decimal_NaN, **kwargs) -> str: @@ -433,9 +447,9 @@ 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 """ - [order_id, timestamp] = bitmax_utils.gen_order_id(self._account_uid) - safe_ensure_future(self._create_order(TradeType.SELL, order_id, timestamp, trading_pair, amount, order_type, price)) - return order_id + client_order_id = bitmax_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 def cancel(self, trading_pair: str, order_id: str): """ @@ -450,7 +464,6 @@ def cancel(self, trading_pair: str, order_id: str): async def _create_order(self, trade_type: TradeType, order_id: str, - timestamp: int, trading_pair: str, amount: Decimal, order_type: OrderType, @@ -458,7 +471,7 @@ async def _create_order(self, """ 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 order_id: Internal order id (aka 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 @@ -466,18 +479,22 @@ async def _create_order(self, """ if not order_type.is_limit_type(): raise Exception(f"Unsupported order type: {order_type}") - trading_rule = self._trading_rules[trading_pair] + bitmax_trading_rule = self._bitmax_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}.") + + # bitmax has a unique way of determening if the order has enough "worth" to be posted + # see https://bitmax-exchange.github.io/bitmax-pro-api/#place-order + notional = Decimal(price * amount) + if notional < bitmax_trading_rule.minNotional or notional > bitmax_trading_rule.maxNotional: + raise ValueError(f"Notional amount {notional} is not withing the range of {bitmax_trading_rule.minNotional}-{bitmax_trading_rule.maxNotional}.") # TODO: check balance + [exchange_order_id, timestamp] = bitmax_utils.gen_exchange_order_id(self._account_uid) api_params = { - "id": order_id, + "id": exchange_order_id, "time": timestamp, "symbol": bitmax_utils.convert_to_exchange_trading_pair(trading_pair), "orderPrice": f"{price:f}", @@ -488,7 +505,7 @@ async def _create_order(self, self.start_tracking_order( order_id, - order_id, + exchange_order_id, trading_pair, trade_type, price, @@ -498,12 +515,12 @@ async def _create_order(self, try: await self._api_request("post", "cash/order", api_params, True, force_auth_path_url="order") - # exchange_order_id = order_id 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 " 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 @@ -514,7 +531,8 @@ async def _create_order(self, trading_pair, amount, price, - order_id + order_id, + exchange_order_id=exchange_order_id )) except asyncio.CancelledError: raise @@ -541,6 +559,7 @@ def start_tracking_order(self, """ Starts tracking an order by simply adding it into _in_flight_orders dictionary. """ + print("start_tracking_order", order_id, exchange_order_id) self._in_flight_orders[order_id] = BitmaxInFlightOrder( client_order_id=order_id, exchange_order_id=exchange_order_id, @@ -623,14 +642,13 @@ async def _update_balances(self): """ Calls REST API to update total and available balances. """ - print("-----> _update_balances") local_asset_names = set(self._account_balances.keys()) remote_asset_names = set() response = await self._api_request("get", "cash/balance", {}, True, force_auth_path_url="balance") for account in response["data"]: asset_name = account["asset"] - self._account_available_balances[asset_name] = Decimal(str(account["availableBalance"])) - self._account_balances[asset_name] = Decimal(str(account["totalBalance"])) + self._account_available_balances[asset_name] = Decimal(account["availableBalance"]) + self._account_balances[asset_name] = Decimal(account["totalBalance"]) remote_asset_names.add(asset_name) asset_names_to_remove = local_asset_names.difference(remote_asset_names) @@ -645,8 +663,6 @@ async def _update_order_status(self): 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) - print("_update_order_status") - if current_tick > last_tick and len(self._in_flight_orders) > 0: tracked_orders = list(self._in_flight_orders.values()) tasks = [] @@ -662,8 +678,6 @@ async def _update_order_status(self): 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: - print("update_result", update_result) - if isinstance(update_result, Exception): raise update_result if "data" not in update_result: @@ -672,7 +686,6 @@ async def _update_order_status(self): # for trade_msg in update_result["data"]["trade_list"]: # await self._process_trade_message(trade_msg) for order_data in update_result["data"]: - print("update_result order_data", order_data) self._process_order_message(order_data) def _process_order_message(self, order_msg: Dict[str, Any]): @@ -680,14 +693,14 @@ def _process_order_message(self, order_msg: Dict[str, Any]): Updates in-flight order and triggers cancellation or failure event if needed. :param order_msg: The order response from either REST or web socket API (they are of the same format) """ - client_order_id = order_msg["orderId"] + exchange_order_id = order_msg["orderId"] + client_order_id = None - print("_process_order_message", order_msg) - print("client_order_id", client_order_id) - print("in flight", self._in_flight_orders) + for in_flight_order in self._in_flight_orders.values(): + if in_flight_order.exchange_order_id == exchange_order_id: + client_order_id = in_flight_order.client_order_id - if self._in_flight_orders.get(client_order_id, None) is None: - print("NOT IN FLIGHT ORDERS") + if client_order_id is None: return tracked_order = self._in_flight_orders[client_order_id] @@ -699,7 +712,8 @@ def _process_order_message(self, order_msg: Dict[str, Any]): self.trigger_event(MarketEvent.OrderCancelled, OrderCancelledEvent( self.current_timestamp, - client_order_id)) + client_order_id, + exchange_order_id)) tracked_order.cancelled_event.set() self.stop_tracking_order(client_order_id) elif tracked_order.is_failure: @@ -713,8 +727,6 @@ def _process_order_message(self, order_msg: Dict[str, Any]): )) self.stop_tracking_order(client_order_id) elif tracked_order.is_done: - print("----> order is done", ) - price = Decimal(order_msg["ap"]) tracked_order.executed_amount_base = Decimal(order_msg["cfq"]) tracked_order.executed_amount_quote = price * tracked_order.executed_amount_base @@ -725,14 +737,14 @@ def _process_order_message(self, order_msg: Dict[str, Any]): MarketEvent.OrderFilled, OrderFilledEvent( self.current_timestamp, - tracked_order.client_order_id, + client_order_id, tracked_order.trading_pair, tracked_order.trade_type, tracked_order.order_type, price, tracked_order.executed_amount_base, TradeFee(0.0, [(tracked_order.fee_asset, tracked_order.fee_paid)]), - exchange_trade_id=order_msg["orderId"] + exchange_order_id ) ) event_tag = MarketEvent.BuyOrderCompleted if tracked_order.trade_type is TradeType.BUY else MarketEvent.SellOrderCompleted @@ -741,14 +753,15 @@ def _process_order_message(self, order_msg: Dict[str, Any]): event_tag, event_class( self.current_timestamp, - tracked_order.client_order_id, + 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 + tracked_order.order_type, + exchange_order_id ) ) self.stop_tracking_order(client_order_id) @@ -778,7 +791,7 @@ async def _process_trade_message(self, trade_msg: Dict[str, Any]): Decimal(str(trade_msg["traded_price"])), Decimal(str(trade_msg["traded_quantity"])), TradeFee(0.0, [(trade_msg["fee_currency"], Decimal(str(trade_msg["fee"])))]), - exchange_trade_id=trade_msg["order_id"] + trade_msg["order_id"] ) ) if math.isclose(tracked_order.executed_amount_base, tracked_order.amount) or \ @@ -814,17 +827,20 @@ async def cancel_all(self, timeout_seconds: float): raise Exception("cancel_all can only be used when trading_pairs are specified.") cancellation_results = [] try: - for trading_pair in self._trading_pairs: - await self._api_request( - "delete", - "cash/order/all", - {"symbol": bitmax_utils.convert_to_exchange_trading_pair(trading_pair)}, - True, - force_auth_path_url="order/all" - ) + await self._api_request( + "delete", + "cash/order/all", + {}, + True, + force_auth_path_url="order/all" + ) + open_orders = await self.get_open_orders() + for cl_order_id, tracked_order in self._in_flight_orders.items(): open_order = [o for o in open_orders if o.client_order_id == cl_order_id] + print("cancel all tracked_order", tracked_order) + print("cancel all", cl_order_id) if not open_order: cancellation_results.append(CancellationResult(cl_order_id, True)) self.trigger_event(MarketEvent.OrderCancelled, @@ -891,26 +907,13 @@ async def _user_stream_event_listener(self): """ async for event_message in self._iter_user_event_queue(): try: - print("event_message", event_message) if event_message.get("m") == "order": - print("in order") self._process_order_message(event_message.get("data")) - - # if "result" not in event_message or "channel" not in event_message["result"]: - # continue - # channel = event_message["result"]["channel"] - # if "user.trade" in channel: - # for trade_msg in event_message["result"]["data"]: - # await self._process_trade_message(trade_msg) - # elif "user.order" in channel: - # for order_msg in event_message["result"]["data"]: - # self._process_order_message(order_msg) - # elif channel == "user.balance": - # balances = event_message["result"]["data"] - # for balance_entry in balances: - # asset_name = balance_entry["currency"] - # self._account_balances[asset_name] = Decimal(str(balance_entry["balance"])) - # self._account_available_balances[asset_name] = Decimal(str(balance_entry["available"])) + elif event_message.get("m") == "balance": + data = event_message.get("data") + asset_name = data["a"] + self._account_available_balances[asset_name] = Decimal(data["ab"]) + self._data_balances[asset_name] = Decimal(data["tb"]) except asyncio.CancelledError: raise except Exception: @@ -929,9 +932,21 @@ async def get_open_orders(self) -> List[OpenOrder]: for order in result["data"]: if order["type"] != "LIMIT": raise Exception(f"Unsupported order type {order['type']}") + + exchange_order_id = order["orderId"] + client_order_id = None + for in_flight_order in self._in_flight_orders.values(): + if in_flight_order.exchange_order_id == exchange_order_id: + client_order_id = in_flight_order.client_order_id + + if client_order_id is None: + raise Exception(f"Client order id for {exchange_order_id} not found.") + + print("open order", client_order_id) + ret_val.append( OpenOrder( - client_order_id=order["orderId"], + client_order_id=client_order_id, trading_pair=bitmax_utils.convert_from_exchange_trading_pair(order["symbol"]), price=Decimal(str(order["price"])), amount=Decimal(str(order["orderQty"])), @@ -940,7 +955,7 @@ async def get_open_orders(self) -> List[OpenOrder]: order_type=OrderType.LIMIT, is_buy=True if order["side"].lower() == "buy" else False, time=int(order["lastExecTime"]), - exchange_order_id=order["orderId"] + exchange_order_id=exchange_order_id ) ) return ret_val diff --git a/hummingbot/connector/exchange/bitmax/bitmax_utils.py b/hummingbot/connector/exchange/bitmax/bitmax_utils.py index e0eebcf12b..71d8d57c88 100644 --- a/hummingbot/connector/exchange/bitmax/bitmax_utils.py +++ b/hummingbot/connector/exchange/bitmax/bitmax_utils.py @@ -6,6 +6,7 @@ from hummingbot.client.config.config_var import ConfigVar from hummingbot.client.config.config_methods import using_exchange +from hummingbot.core.utils.tracking_nonce import get_tracking_nonce CENTRALIZED = True @@ -15,6 +16,9 @@ DEFAULT_FEES = [0.1, 0.1] +HBOT_BROKER_ID = "hbot-" + + def convert_from_exchange_trading_pair(exchange_trading_pair: str) -> str: return exchange_trading_pair.replace("/", "-") @@ -49,7 +53,7 @@ def derive_order_id(user_uid: str, cl_order_id: str, ts: int, order_src='a') -> return (order_src + format(ts, 'x')[-11:] + user_uid[-11:] + cl_order_id[-9:])[:32] -def gen_order_id(userUid: str) -> Tuple[str, int]: +def gen_exchange_order_id(userUid: str) -> Tuple[str, int]: """ Generate an order id :param user_uid: user uid @@ -66,6 +70,11 @@ def gen_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()}" + + KEYS = { "bitmax_api_key": ConfigVar(key="bitmax_api_key", diff --git a/hummingbot/connector/markets_recorder.py b/hummingbot/connector/markets_recorder.py index a98072bc64..4fbae0491f 100644 --- a/hummingbot/connector/markets_recorder.py +++ b/hummingbot/connector/markets_recorder.py @@ -8,7 +8,6 @@ ) import time import threading -from collections import namedtuple from typing import ( Dict, List, @@ -37,8 +36,7 @@ from hummingbot.model.order_status import OrderStatus from hummingbot.model.sql_connection_manager import SQLConnectionManager from hummingbot.model.trade_fill import TradeFill - -TradeFill_order_details = namedtuple("TradeFill_order_details", "market exchange_trade_id symbol") +from hummingbot.connector.utils import TradeFill_order_details class MarketsRecorder: diff --git a/hummingbot/connector/utils.py b/hummingbot/connector/utils.py index 22872e032e..59f936af3a 100644 --- a/hummingbot/connector/utils.py +++ b/hummingbot/connector/utils.py @@ -6,6 +6,10 @@ Optional ) from zero_ex.order_utils import Order as ZeroExOrder +from collections import namedtuple + + +TradeFill_order_details = namedtuple("TradeFill_order_details", "market exchange_trade_id symbol") def zrx_order_to_json(order: Optional[ZeroExOrder]) -> Optional[Dict[str, any]]: diff --git a/test/connector/exchange/bitmax/test_bitmax_exchange.py b/test/connector/exchange/bitmax/test_bitmax_exchange.py index f821eb8865..ca642fe385 100644 --- a/test/connector/exchange/bitmax/test_bitmax_exchange.py +++ b/test/connector/exchange/bitmax/test_bitmax_exchange.py @@ -230,21 +230,21 @@ def test_limit_makers_unfilled(self): 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) + # # @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) From 8eea0d960ab7c9bfa50e717e5dfe8a82a06efcf7 Mon Sep 17 00:00:00 2001 From: Vladimir Kamarzin Date: Sun, 24 Jan 2021 17:52:26 +0500 Subject: [PATCH 058/126] (refactor) Raise an error when initial price is not set See disussion here https://github.com/CoinAlpha/hummingbot/pull/2513#discussion_r549292627 --- hummingbot/strategy/pure_market_making/pure_market_making.pyx | 4 ++++ test/test_pmm.py | 3 +-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/hummingbot/strategy/pure_market_making/pure_market_making.pyx b/hummingbot/strategy/pure_market_making/pure_market_making.pyx index 1696083726..35a2624e1f 100644 --- a/hummingbot/strategy/pure_market_making/pure_market_making.pyx +++ b/hummingbot/strategy/pure_market_making/pure_market_making.pyx @@ -706,6 +706,10 @@ cdef class PureMarketMakingStrategy(StrategyBase): if inventory_cost_price is not None: buy_reference_price = min(inventory_cost_price, buy_reference_price) sell_reference_price = max(inventory_cost_price, sell_reference_price) + else: + base_balance = float(market.get_balance(self._market_info.base_asset)) + if base_balance > 0: + raise RuntimeError("Initial inventory price is not set while inventory_cost feature is active.") # First to check if a customized order override is configured, otherwise the proposal will be created according # to order spread, amount, and levels setting. diff --git a/test/test_pmm.py b/test/test_pmm.py index b89e58ced0..545e9daf38 100644 --- a/test/test_pmm.py +++ b/test/test_pmm.py @@ -803,13 +803,12 @@ def test_inventory_cost_price_del(self): strategy = self.one_level_strategy strategy.inventory_cost_price_delegate = self.inventory_cost_price_del self.clock.add_iterator(strategy) + self.market.set_balance("HBOT", 0) self.clock.backtest_til(self.start_timestamp + 1) # Expecting to have orders set according to mid_price as there is no inventory cost data yet first_bid_order = strategy.active_buys[0] - first_ask_order = strategy.active_sells[0] self.assertEqual(Decimal("99"), first_bid_order.price) - self.assertEqual(Decimal("101"), first_ask_order.price) self.simulate_maker_market_trade( is_buy=False, quantity=Decimal("10"), price=Decimal("98.9"), From f34f651e6d87e0fdbfc0ab95fc994ed3167fa6c4 Mon Sep 17 00:00:00 2001 From: Vladimir Kamarzin Date: Sun, 24 Jan 2021 18:50:56 +0500 Subject: [PATCH 059/126] (feat) Sells aren't influencing inventory price Reflecting @phbrgnomo comments, sell orders should not alter buy price. And also, buy orders should not be limited by inventory price, only sells. --- .../inventory_cost_price_delegate.py | 22 ++++----- .../pure_market_making/pure_market_making.pyx | 2 +- test/test_inventory_cost_price_delegate.py | 49 ++++++++++++++----- test/test_pmm.py | 14 +++++- 4 files changed, 60 insertions(+), 27 deletions(-) diff --git a/hummingbot/strategy/pure_market_making/inventory_cost_price_delegate.py b/hummingbot/strategy/pure_market_making/inventory_cost_price_delegate.py index 18e771d5ff..5c411c506c 100644 --- a/hummingbot/strategy/pure_market_making/inventory_cost_price_delegate.py +++ b/hummingbot/strategy/pure_market_making/inventory_cost_price_delegate.py @@ -50,24 +50,20 @@ def process_order_fill_event(self, fill_event: OrderFilledEvent) -> None: if fee_asset == base_asset: base_volume += fee_amount elif fee_asset == quote_asset: + # TODO: with new logic, this quote volume adjustment does not impacts anything quote_volume -= fee_amount else: - # Ok, some other asset used (like BNB), assume that we paid in quote asset for simplicity - quote_volume /= 1 + fill_event.trade_fee.percent + # Ok, some other asset used (like BNB), assume that we paid in base asset for simplicity + base_volume /= 1 + fill_event.trade_fee.percent if fill_event.trade_type == TradeType.SELL: - base_volume = -base_volume - quote_volume = -quote_volume - - # Make sure we're not going to create negative inventory cost here. If user didn't properly set cost, - # just assume 0 cost, so consequent buys will set cost correctly record = InventoryCost.get_record(self._session, base_asset, quote_asset) - if record: - base_volume = max(-record.base_volume, base_volume) - quote_volume = max(-record.quote_volume, quote_volume) - else: - base_volume = Decimal("0") - quote_volume = Decimal("0") + if not record: + raise RuntimeError("Sold asset without having inventory price set. This should not happen.") + + # We're keeping initial buy price intact. Profits are not changing inventory price intentionally. + quote_volume = -(Decimal(record.quote_volume / record.base_volume) * base_volume) + base_volume = -base_volume InventoryCost.add_volume( self._session, base_asset, quote_asset, base_volume, quote_volume diff --git a/hummingbot/strategy/pure_market_making/pure_market_making.pyx b/hummingbot/strategy/pure_market_making/pure_market_making.pyx index 35a2624e1f..8ff73da3ad 100644 --- a/hummingbot/strategy/pure_market_making/pure_market_making.pyx +++ b/hummingbot/strategy/pure_market_making/pure_market_making.pyx @@ -704,7 +704,7 @@ cdef class PureMarketMakingStrategy(StrategyBase): if self._inventory_cost_price_delegate is not None: inventory_cost_price = self._inventory_cost_price_delegate.get_price() if inventory_cost_price is not None: - buy_reference_price = min(inventory_cost_price, buy_reference_price) + # Only limit sell price. Buy are always allowed. sell_reference_price = max(inventory_cost_price, sell_reference_price) else: base_balance = float(market.get_balance(self._market_info.base_asset)) diff --git a/test/test_inventory_cost_price_delegate.py b/test/test_inventory_cost_price_delegate.py index ff90fc2d29..205dae2bdc 100644 --- a/test/test_inventory_cost_price_delegate.py +++ b/test/test_inventory_cost_price_delegate.py @@ -56,34 +56,59 @@ def test_process_order_fill_event_buy(self): self.assertEqual(record.base_volume, amount * 2) self.assertEqual(record.quote_volume, price * 2) - def test_process_order_fill_event_sell_no_initial_cost_set(self): + def test_process_order_fill_event_sell(self): amount = Decimal("1") price = Decimal("9000") + + # Test when no records + self.assertIsNone(self.delegate.get_price()) + + record = InventoryCost( + base_asset=self.base_asset, + quote_asset=self.quote_asset, + base_volume=amount, + quote_volume=amount * price, + ) + self._session.add(record) + self._session.commit() + + amount_sell = Decimal("0.5") + price_sell = Decimal("10000") event = OrderFilledEvent( timestamp=1, order_id="order1", trading_pair=self.trading_pair, trade_type=TradeType.SELL, order_type=OrderType.LIMIT, - price=price, - amount=amount, + price=price_sell, + amount=amount_sell, trade_fee=TradeFee(percent=Decimal("0"), flat_fees=[]), ) - # First event is a sell when there was no InventoryCost created. Assume wrong input from user + self.delegate.process_order_fill_event(event) record = InventoryCost.get_record( self._session, self.base_asset, self.quote_asset ) - self.assertEqual(record.base_volume, Decimal("0")) - self.assertEqual(record.quote_volume, Decimal("0")) + # Remaining base volume reduced by sold amount + self.assertEqual(record.base_volume, amount - amount_sell) + # Remaining quote volume has been reduced using original price + self.assertEqual(record.quote_volume, amount_sell * price) - # Second event - InventoryCost created, amounts are 0, test that record updated correctly - self.delegate.process_order_fill_event(event) - record = InventoryCost.get_record( - self._session, self.base_asset, self.quote_asset + def test_process_order_fill_event_sell_no_initial_cost_set(self): + amount = Decimal("1") + price = Decimal("9000") + event = OrderFilledEvent( + timestamp=1, + order_id="order1", + trading_pair=self.trading_pair, + trade_type=TradeType.SELL, + order_type=OrderType.LIMIT, + price=price, + amount=amount, + trade_fee=TradeFee(percent=Decimal("0"), flat_fees=[]), ) - self.assertEqual(record.base_volume, Decimal("0")) - self.assertEqual(record.quote_volume, Decimal("0")) + with self.assertRaises(RuntimeError): + self.delegate.process_order_fill_event(event) def test_get_price_by_type(self): amount = Decimal("1") diff --git a/test/test_pmm.py b/test/test_pmm.py index 545e9daf38..c92ef00d2e 100644 --- a/test/test_pmm.py +++ b/test/test_pmm.py @@ -813,9 +813,21 @@ def test_inventory_cost_price_del(self): self.simulate_maker_market_trade( is_buy=False, quantity=Decimal("10"), price=Decimal("98.9"), ) + new_mid_price = Decimal("96") + self.book_data.set_balanced_order_book( + mid_price=new_mid_price, + min_price=1, + max_price=200, + price_step_size=1, + volume_step_size=10, + ) self.clock.backtest_til(self.start_timestamp + 7) first_bid_order = strategy.active_buys[0] - self.assertEqual(Decimal("98.01"), first_bid_order.price) + first_ask_order = strategy.active_sells[0] + expected_bid_price = new_mid_price / Decimal(1 + self.bid_spread) + self.assertAlmostEqual(expected_bid_price, first_bid_order.price, places=1) + expected_ask_price = Decimal("99") * Decimal(1 + self.ask_spread) + self.assertAlmostEqual(expected_ask_price, first_ask_order.price) def test_order_book_asset_del(self): strategy = self.one_level_strategy From 0d16abee43e54437df3d9d068338f9cde48f4d00 Mon Sep 17 00:00:00 2001 From: Nullably <37262506+Nullably@users.noreply.github.com> Date: Mon, 25 Jan 2021 10:20:12 +0800 Subject: [PATCH 060/126] (feat) update parameters according to Mike's --- .../liquidity_mining/liquidity_mining.py | 8 +- .../liquidity_mining_config_map.py | 88 +++++++++---------- hummingbot/strategy/liquidity_mining/start.py | 20 ++--- ...onf_liquidity_mining_strategy_TEMPLATE.yml | 13 +-- 4 files changed, 58 insertions(+), 71 deletions(-) diff --git a/hummingbot/strategy/liquidity_mining/liquidity_mining.py b/hummingbot/strategy/liquidity_mining/liquidity_mining.py index 81774a581e..48a2269160 100644 --- a/hummingbot/strategy/liquidity_mining/liquidity_mining.py +++ b/hummingbot/strategy/liquidity_mining/liquidity_mining.py @@ -37,18 +37,18 @@ def logger(cls) -> HummingbotLogger: def __init__(self, exchange: ExchangeBase, market_infos: Dict[str, MarketTradingPairTuple], - custom_spread_pct: Decimal, + spread: Decimal, + target_base_pcts: Dict[str, Decimal], order_refresh_time: float, order_refresh_tolerance_pct: Decimal, - reserved_balances: Dict[str, Decimal], status_report_interval: float = 900): super().__init__() self._exchange = exchange self._market_infos = market_infos - self._custom_spread_pct = custom_spread_pct + self._spread = spread self._order_refresh_time = order_refresh_time self._order_refresh_tolerance_pct = order_refresh_tolerance_pct - self._reserved_balances = reserved_balances + self._target_base_pcts = target_base_pcts self._ev_loop = asyncio.get_event_loop() self._last_timestamp = 0 self._status_report_interval = status_report_interval diff --git a/hummingbot/strategy/liquidity_mining/liquidity_mining_config_map.py b/hummingbot/strategy/liquidity_mining/liquidity_mining_config_map.py index d419c27922..fbc4d09ce6 100644 --- a/hummingbot/strategy/liquidity_mining/liquidity_mining_config_map.py +++ b/hummingbot/strategy/liquidity_mining/liquidity_mining_config_map.py @@ -1,10 +1,9 @@ from decimal import Decimal - +from typing import Optional from hummingbot.client.config.config_var import ConfigVar from hummingbot.client.config.config_validators import ( validate_exchange, - validate_decimal, - validate_bool + validate_decimal ) from hummingbot.client.settings import ( required_exchanges, @@ -15,9 +14,27 @@ def exchange_on_validated(value: str) -> None: required_exchanges.append(value) -def auto_campaigns_participation_on_validated(value: bool) -> None: - if value: - liquidity_mining_config_map["markets"].value = "HARD-USDT,RLC-USDT,AVAX-USDT,ALGO-USDT,XEM-USDT,MFT-USDT,ETH-USDT" +def token_validate(value: str) -> Optional[str]: + markets = list(liquidity_mining_config_map["markets"].value.split(",")) + tokens = set() + for market in markets: + tokens.update(set(market.split("-"))) + if value not in tokens: + return f"Invalid token. {value} is not one of {','.join(tokens)}" + + +def order_size_prompt() -> str: + token = liquidity_mining_config_map["token"].value + return f"What is the size of each order (in {token} amount)? >>> " + + +def target_base_pct_prompt() -> str: + markets = list(liquidity_mining_config_map["markets"].value.split(",")) + token = liquidity_mining_config_map["token"].value + markets = [m for m in markets if token in m.split("-")] + example = ",".join([f"{m}:10%" for m in markets[:2]]) + return f"For each of {token} markets, what is the target base asset pct? (Enter a list of markets and" \ + f" their target e.g. {example}) >>> " liquidity_mining_config_map = { @@ -31,58 +48,35 @@ def auto_campaigns_participation_on_validated(value: bool) -> None: validator=validate_exchange, on_validated=exchange_on_validated, prompt_on_new=True), - "auto_campaigns_participation": - ConfigVar(key="auto_campaigns_participation", - prompt="Do you want the bot to automatically participate in as many mining campaign as possible " - "(Yes/No) >>> ", - type_str="bool", - validator=validate_bool, - on_validated=auto_campaigns_participation_on_validated, - prompt_on_new=True), "markets": ConfigVar(key="markets", - prompt="Enter a list of markets >>> ", - required_if=lambda: not liquidity_mining_config_map.get("auto_campaigns_participation").value, - prompt_on_new=True), - "reserved_balances": - ConfigVar(key="reserved_balances", - prompt="Enter a list of tokens and their reserved balance (to not be used by the bot), " - "This can be used for an asset amount you want to set a side to pay for fee (e.g. BNB for " - "Binance), to limit exposure or in anticipation of withdrawal, e.g. BTC:0.1,BNB:1 >>> ", + prompt="Enter a list of markets (comma separated, e.g. LTC-USDT,ETH-USDT) >>> ", type_str="str", - default="", - validator=lambda s: None, prompt_on_new=True), - "auto_assign_campaign_budgets": - ConfigVar(key="auto_assign_campaign_budgets", - prompt="Do you want the bot to automatically assign budgets (equal weight) for all campaign markets? " - "The assignment assumes full allocation of your assets (after accounting for reserved balance)" - " (Yes/No) >>> ", - type_str="bool", - validator=validate_bool, - prompt_on_new=True), - "campaign_budgets": - ConfigVar(key="campaign_budgets", - prompt="Enter a list of campaigns and their buy and sell budgets. For example " - "XEM-ETH:500-2, XEM-USDT:300-250 (this means on XEM-ETH campaign sell budget is 500 XEM, " - "and buy budget is 2 ETH) >>> ", - required_if=lambda: not liquidity_mining_config_map.get("auto_assign_campaign_budgets").value, - type_str="json"), - "spread_level": - ConfigVar(key="spread_level", - prompt="Enter spread level (tight/medium/wide/custom) >>> ", + "token": + ConfigVar(key="token", + prompt="What asset (base or quote) do you want to use to provide liquidity? >>> ", type_str="str", - default="medium", - validator=lambda s: None if s in {"tight", "medium", "wide", "custom"} else "Invalid spread level.", + validator=token_validate, + prompt_on_new=True), + "order_size": + ConfigVar(key="order_size", + prompt=order_size_prompt, + type_str="decimal", + validator=lambda v: validate_decimal(v, 0, inclusive=False), prompt_on_new=True), - "custom_spread_pct": - ConfigVar(key="custom_spread_pct", + "spread": + ConfigVar(key="spread", prompt="How far away from the mid price do you want to place bid order and ask order? " "(Enter 1 to indicate 1%) >>> ", type_str="decimal", - required_if=lambda: liquidity_mining_config_map.get("spread_level").value == "custom", validator=lambda v: validate_decimal(v, 0, 100, inclusive=False), prompt_on_new=True), + "target_base_pct": + ConfigVar(key="spread", + prompt=target_base_pct_prompt, + type_str="str", + prompt_on_new=True), "order_refresh_time": ConfigVar(key="order_refresh_time", prompt="How often do you want to cancel and replace bids and asks " diff --git a/hummingbot/strategy/liquidity_mining/start.py b/hummingbot/strategy/liquidity_mining/start.py index abc0a072c3..4facc5b932 100644 --- a/hummingbot/strategy/liquidity_mining/start.py +++ b/hummingbot/strategy/liquidity_mining/start.py @@ -6,18 +6,16 @@ def start(self): exchange = c_map.get("exchange").value.lower() - markets_text = c_map.get("markets").value - if c_map.get("auto_campaigns_participation").value: - markets_text = "HARD-USDT,RLC-USDT,AVAX-USDT,EOS-USDT,EOS-BTC,MFT-USDT,AVAX-BNB,MFT-BNB" - reserved_bals_text = c_map.get("reserved_balances").value or "" - reserved_bals = reserved_bals_text.split(",") - reserved_bals = {r.split(":")[0]: Decimal(r.split(":")[1]) for r in reserved_bals} - custom_spread_pct = c_map.get("custom_spread_pct").value / Decimal("100") + base_targets = {} + for target_txt in c_map.get("target_base_pct").value.split(","): + market, pct_txt = target_txt.split(":") + pct_txt = pct_txt.replace("%", "") + base_targets[market] = Decimal(pct_txt) + spread = c_map.get("spread").value / Decimal("100") order_refresh_time = c_map.get("order_refresh_time").value order_refresh_tolerance_pct = c_map.get("order_refresh_tolerance_pct").value / Decimal("100") - markets = list(markets_text.split(",")) - + markets = list(base_targets.keys()) self._initialize_markets([(exchange, markets)]) exchange = self.markets[exchange] market_infos = {} @@ -27,8 +25,8 @@ def start(self): self.strategy = LiquidityMiningStrategy( exchange=exchange, market_infos=market_infos, - custom_spread_pct=custom_spread_pct, + spread=spread, + target_base_pcts=base_targets, order_refresh_time=order_refresh_time, - reserved_balances=reserved_bals, order_refresh_tolerance_pct=order_refresh_tolerance_pct ) diff --git a/hummingbot/templates/conf_liquidity_mining_strategy_TEMPLATE.yml b/hummingbot/templates/conf_liquidity_mining_strategy_TEMPLATE.yml index e0d5bb2d88..c6bdc16e44 100644 --- a/hummingbot/templates/conf_liquidity_mining_strategy_TEMPLATE.yml +++ b/hummingbot/templates/conf_liquidity_mining_strategy_TEMPLATE.yml @@ -8,20 +8,15 @@ strategy: null # Exchange and token parameters. exchange: null -auto_campaigns_participation: null - -# Token trading pair for the exchange, e.g. BTC-USDT markets: null -reserved_balances: null - -auto_assign_campaign_budgets: null +token: null -campaign_budgets: null +order_size: null -spread_level: null +spread: null -custom_spread_pct: null +target_base_pct: 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. From 5157b1287fda55ea5c8f403357a69633f4bb441f Mon Sep 17 00:00:00 2001 From: Nullably <37262506+Nullably@users.noreply.github.com> Date: Mon, 25 Jan 2021 14:57:39 +0800 Subject: [PATCH 061/126] (feat) update target_base_pct to ask each question --- hummingbot/client/command/config_command.py | 31 +++++++++- hummingbot/client/command/create_command.py | 3 + .../liquidity_mining/liquidity_mining.py | 57 ++++++++++--------- .../liquidity_mining_config_map.py | 23 ++++++-- hummingbot/strategy/liquidity_mining/start.py | 13 +++-- ...onf_liquidity_mining_strategy_TEMPLATE.yml | 7 ++- 6 files changed, 91 insertions(+), 43 deletions(-) diff --git a/hummingbot/client/command/config_command.py b/hummingbot/client/command/config_command.py index b5da7a80bc..4ae14a46e4 100644 --- a/hummingbot/client/command/config_command.py +++ b/hummingbot/client/command/config_command.py @@ -3,6 +3,7 @@ List, Any, ) +from collections import OrderedDict from decimal import Decimal import pandas as pd from os.path import join @@ -25,6 +26,7 @@ from hummingbot.strategy.perpetual_market_making import ( PerpetualMarketMakingStrategy ) +from hummingbot.strategy.liquidity_mining.liquidity_mining_config_map import liquidity_mining_config_map from hummingbot.user.user_balances import UserBalances from typing import TYPE_CHECKING if TYPE_CHECKING: @@ -76,14 +78,14 @@ def list_configs(self, # type: HummingbotApplication if cv.key in global_configs_to_display and not cv.is_secure] df = pd.DataFrame(data=data, columns=columns) self._notify("\nGlobal Configurations:") - lines = [" " + line for line in df.to_string(index=False).split("\n")] + lines = [" " + line for line in df.to_string(index=False, max_colwidth=50).split("\n")] 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] df = pd.DataFrame(data=data, columns=columns) self._notify("\nStrategy Configurations:") - lines = [" " + line for line in df.to_string(index=False).split("\n")] + lines = [" " + line for line in df.to_string(index=False, max_colwidth=50).split("\n")] self._notify("\n".join(lines)) def config_able_keys(self # type: HummingbotApplication @@ -141,6 +143,8 @@ async def _config_single_key(self, # type: HummingbotApplication self._notify("Please follow the prompt to complete configurations: ") if config_var.key == "inventory_target_base_pct": await self.asset_ratio_maintenance_prompt(config_map, input_value) + elif config_var.key == "target_base_pct": + await self.lm_target_base_pct_prompt(config_map, input_value) else: await self.prompt_a_config(config_var, input_value=input_value, assign_default=False) if self.app.to_stop_config: @@ -219,3 +223,26 @@ async def asset_ratio_maintenance_prompt(self, # type: HummingbotApplication self.app.to_stop_config = False return await self.prompt_a_config(config_map["inventory_target_base_pct"]) + + async def lm_target_base_pct_prompt(self, # type: HummingbotApplication + input_value=None): + config_map = liquidity_mining_config_map + if input_value: + config_map['target_base_pct'].value = input_value + else: + markets = config_map["markets"].value + markets = list(markets.split(",")) + target_base_pcts = OrderedDict() + for market in markets: + cvar = ConfigVar(key="temp_config", + prompt=f"For {market} market, what is the target base asset pct? >>> ", + required_if=lambda: True, + type_str="float") + await self.prompt_a_config(cvar) + if cvar.value: + target_base_pcts[market] = cvar.value + else: + if self.app.to_stop_config: + self.app.to_stop_config = False + return + config_map['target_base_pct'].value = target_base_pcts diff --git a/hummingbot/client/command/create_command.py b/hummingbot/client/command/create_command.py index 21881542b7..76e250cfa0 100644 --- a/hummingbot/client/command/create_command.py +++ b/hummingbot/client/command/create_command.py @@ -97,6 +97,9 @@ async def prompt_a_config(self, # type: HummingbotApplication config: ConfigVar, input_value=None, assign_default=True): + if config.key == "target_base_pct": + await self.lm_target_base_pct_prompt(input_value) + return if input_value is None: if assign_default: self.app.set_text(parse_config_default_to_text(config)) diff --git a/hummingbot/strategy/liquidity_mining/liquidity_mining.py b/hummingbot/strategy/liquidity_mining/liquidity_mining.py index 48a2269160..1959cdb8a2 100644 --- a/hummingbot/strategy/liquidity_mining/liquidity_mining.py +++ b/hummingbot/strategy/liquidity_mining/liquidity_mining.py @@ -37,6 +37,8 @@ def logger(cls) -> HummingbotLogger: def __init__(self, exchange: ExchangeBase, market_infos: Dict[str, MarketTradingPairTuple], + token: str, + order_size: Decimal, spread: Decimal, target_base_pcts: Dict[str, Decimal], order_refresh_time: float, @@ -45,6 +47,8 @@ def __init__(self, super().__init__() self._exchange = exchange self._market_infos = market_infos + self._token = token + self._order_size = order_size self._spread = spread self._order_refresh_time = order_refresh_time self._order_refresh_tolerance_pct = order_refresh_tolerance_pct @@ -144,9 +148,9 @@ def create_base_proposals(self): proposals = [] for market, market_info in self._market_infos.items(): mid_price = market_info.get_mid_price() - buy_price = mid_price * (Decimal("1") - self._custom_spread_pct) + buy_price = mid_price * (Decimal("1") - self._spread) buy_price = self._exchange.quantize_order_price(market, buy_price) - sell_price = mid_price * (Decimal("1") + self._custom_spread_pct) + sell_price = mid_price * (Decimal("1") + self._spread) sell_price = self._exchange.quantize_order_price(market, sell_price) proposals.append(Proposal(market, PriceSize(buy_price, s_decimal_zero), PriceSize(sell_price, s_decimal_zero))) @@ -179,37 +183,36 @@ def assign_balanced_budgets(self): def allocate_order_size(self, proposals: List[Proposal]): balances = self._token_balances.copy() for proposal in proposals: - sell_size = balances[proposal.base()] if balances[proposal.base()] < self._sell_budgets[proposal.market] \ - else self._sell_budgets[proposal.market] - sell_size = self._exchange.quantize_order_amount(proposal.market, sell_size) + base_size = self._order_size if self._token == proposal.base() else self._order_size / proposal.sell.price + base_size = balances[proposal.base()] if balances[proposal.base()] < base_size else base_size + sell_size = self._exchange.quantize_order_amount(proposal.market, base_size) if sell_size > s_decimal_zero: proposal.sell.size = sell_size balances[proposal.base()] -= sell_size - quote_size = balances[proposal.quote()] if balances[proposal.quote()] < self._buy_budgets[proposal.market] \ - else self._buy_budgets[proposal.market] + quote_size = self._order_size if self._token == proposal.quote() else self._order_size * proposal.buy.price + quote_size = balances[proposal.quote()] if balances[proposal.quote()] < quote_size else quote_size buy_fee = estimate_fee(self._exchange.name, True) buy_size = quote_size / (proposal.buy.price * (Decimal("1") + buy_fee.percent)) if buy_size > s_decimal_zero: proposal.buy.size = self._exchange.quantize_order_amount(proposal.market, buy_size) balances[proposal.quote()] -= quote_size - - # base_tokens = self.all_base_tokens() - # for base in base_tokens: - # base_proposals = [p for p in proposals if p.base() == base] - # sell_size = self._token_balances[base] / len(base_proposals) - # for proposal in base_proposals: - # proposal.sell.size = self._exchange.quantize_order_amount(proposal.market, sell_size) + # balances = self._token_balances.copy() + # for proposal in proposals: + # sell_size = balances[proposal.base()] if balances[proposal.base()] < self._sell_budgets[proposal.market] \ + # else self._sell_budgets[proposal.market] + # sell_size = self._exchange.quantize_order_amount(proposal.market, sell_size) + # if sell_size > s_decimal_zero: + # proposal.sell.size = sell_size + # balances[proposal.base()] -= sell_size # - # # Then assign all the buy order size based on the quote token balance available - # quote_tokens = self.all_quote_tokens() - # for quote in quote_tokens: - # quote_proposals = [p for p in proposals if p.quote() == quote] - # quote_size = self._token_balances[quote] / len(quote_proposals) - # for proposal in quote_proposals: - # buy_fee = estimate_fee(self._exchange.name, True) - # buy_amount = quote_size / (proposal.buy.price * (Decimal("1") + buy_fee.percent)) - # proposal.buy.size = self._exchange.quantize_order_amount(proposal.market, buy_amount) + # quote_size = balances[proposal.quote()] if balances[proposal.quote()] < self._buy_budgets[proposal.market] \ + # else self._buy_budgets[proposal.market] + # buy_fee = estimate_fee(self._exchange.name, True) + # buy_size = quote_size / (proposal.buy.price * (Decimal("1") + buy_fee.percent)) + # if buy_size > s_decimal_zero: + # proposal.buy.size = self._exchange.quantize_order_amount(proposal.market, buy_size) + # balances[proposal.quote()] -= quote_size def is_within_tolerance(self, cur_orders: List[LimitOrder], proposal: Proposal): cur_buy = [o for o in cur_orders if o.is_buy] @@ -296,9 +299,9 @@ def adjusted_available_balances(self) -> Dict[str, Decimal]: adjusted_bals[quote] += order.quantity * order.price else: adjusted_bals[base] += order.quantity - for token in tokens: - adjusted_bals[token] = min(adjusted_bals[token], total_bals[token]) - reserved = self._reserved_balances.get(token, s_decimal_zero) - adjusted_bals[token] -= reserved + # for token in tokens: + # adjusted_bals[token] = min(adjusted_bals[token], total_bals[token]) + # reserved = self._reserved_balances.get(token, s_decimal_zero) + # adjusted_bals[token] -= reserved # self.logger().info(f"token balances: {adjusted_bals}") return adjusted_bals diff --git a/hummingbot/strategy/liquidity_mining/liquidity_mining_config_map.py b/hummingbot/strategy/liquidity_mining/liquidity_mining_config_map.py index fbc4d09ce6..37d0eb616a 100644 --- a/hummingbot/strategy/liquidity_mining/liquidity_mining_config_map.py +++ b/hummingbot/strategy/liquidity_mining/liquidity_mining_config_map.py @@ -15,7 +15,7 @@ def exchange_on_validated(value: str) -> None: def token_validate(value: str) -> Optional[str]: - markets = list(liquidity_mining_config_map["markets"].value.split(",")) + markets = list(liquidity_mining_config_map["eligible_markets"].value.split(",")) tokens = set() for market in markets: tokens.update(set(market.split("-"))) @@ -23,6 +23,12 @@ def token_validate(value: str) -> Optional[str]: return f"Invalid token. {value} is not one of {','.join(tokens)}" +def token_on_validated(value: str) -> None: + el_markets = list(liquidity_mining_config_map["eligible_markets"].value.split(",")) + markets = [m for m in el_markets if value in m.split("-")] + liquidity_mining_config_map["markets"].value = ",".join(markets) + + def order_size_prompt() -> str: token = liquidity_mining_config_map["token"].value return f"What is the size of each order (in {token} amount)? >>> " @@ -48,16 +54,21 @@ def target_base_pct_prompt() -> str: validator=validate_exchange, on_validated=exchange_on_validated, prompt_on_new=True), - "markets": - ConfigVar(key="markets", + "eligible_markets": + ConfigVar(key="eligible_markets", prompt="Enter a list of markets (comma separated, e.g. LTC-USDT,ETH-USDT) >>> ", type_str="str", prompt_on_new=True), + "markets": + ConfigVar(key="markets", + prompt=None, + type_str="str"), "token": ConfigVar(key="token", prompt="What asset (base or quote) do you want to use to provide liquidity? >>> ", type_str="str", validator=token_validate, + on_validated=token_on_validated, prompt_on_new=True), "order_size": ConfigVar(key="order_size", @@ -73,9 +84,9 @@ def target_base_pct_prompt() -> str: validator=lambda v: validate_decimal(v, 0, 100, inclusive=False), prompt_on_new=True), "target_base_pct": - ConfigVar(key="spread", - prompt=target_base_pct_prompt, - type_str="str", + ConfigVar(key="target_base_pct", + prompt=None, + type_str="json", prompt_on_new=True), "order_refresh_time": ConfigVar(key="order_refresh_time", diff --git a/hummingbot/strategy/liquidity_mining/start.py b/hummingbot/strategy/liquidity_mining/start.py index 4facc5b932..99620bdd0f 100644 --- a/hummingbot/strategy/liquidity_mining/start.py +++ b/hummingbot/strategy/liquidity_mining/start.py @@ -6,16 +6,15 @@ def start(self): exchange = c_map.get("exchange").value.lower() - base_targets = {} - for target_txt in c_map.get("target_base_pct").value.split(","): - market, pct_txt = target_txt.split(":") - pct_txt = pct_txt.replace("%", "") - base_targets[market] = Decimal(pct_txt) + markets = list(c_map.get("markets").value.split(",")) + token = c_map.get("token").value + order_size = c_map.get("order_size").value spread = c_map.get("spread").value / Decimal("100") + base_targets = c_map.get("target_base_pct").value + base_targets = {k: Decimal(str(v)) / Decimal("100") for k, v in base_targets.items()} order_refresh_time = c_map.get("order_refresh_time").value order_refresh_tolerance_pct = c_map.get("order_refresh_tolerance_pct").value / Decimal("100") - markets = list(base_targets.keys()) self._initialize_markets([(exchange, markets)]) exchange = self.markets[exchange] market_infos = {} @@ -25,6 +24,8 @@ def start(self): self.strategy = LiquidityMiningStrategy( exchange=exchange, market_infos=market_infos, + token=token, + order_size=order_size, spread=spread, target_base_pcts=base_targets, order_refresh_time=order_refresh_time, diff --git a/hummingbot/templates/conf_liquidity_mining_strategy_TEMPLATE.yml b/hummingbot/templates/conf_liquidity_mining_strategy_TEMPLATE.yml index c6bdc16e44..be1b89ab26 100644 --- a/hummingbot/templates/conf_liquidity_mining_strategy_TEMPLATE.yml +++ b/hummingbot/templates/conf_liquidity_mining_strategy_TEMPLATE.yml @@ -8,6 +8,8 @@ strategy: null # Exchange and token parameters. exchange: null +eligible_markets: null + markets: null token: null @@ -16,7 +18,9 @@ order_size: null spread: null -target_base_pct: null +# For each pair, what is the target base asset pct? +target_base_pct: + BTC-USDT: 10.0 # 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. @@ -27,6 +31,5 @@ order_refresh_time: null order_refresh_tolerance_pct: null - # For more detailed information, see: # https://docs.hummingbot.io/strategies/liquidity-mining/#configuration-parameters From a419f9871b4c4eaa4f30162376581064194ee187 Mon Sep 17 00:00:00 2001 From: vic-en <31972210+vic-en@users.noreply.github.com> Date: Mon, 25 Jan 2021 13:46:56 +0100 Subject: [PATCH 062/126] Update signature hints for new funtions --- hummingbot/client/performance.py | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/hummingbot/client/performance.py b/hummingbot/client/performance.py index 21c9b4424d..d653582b05 100644 --- a/hummingbot/client/performance.py +++ b/hummingbot/client/performance.py @@ -53,8 +53,13 @@ def __init__(self): self.fees: Dict[str, Decimal] = {} -def position_order(open: list, close: list): - # pair open position order with close position OrderStatus +def position_order(open: list, close: list): + """ + Pair open position order with close position orders + :param open: a list of orders that may have an open position order + :param close: a list of orders that may have an close position order + :return: A tuple containing a pair of an open order with a close position order + """ for o in open: for c in close: if o.position == "OPEN" and c.position == "CLOSE": @@ -65,6 +70,12 @@ def position_order(open: list, close: list): def aggregate_position_order(buys: list, sells: list): + """ + Aggregate the amount field for orders with multiple fills + :param buys: a list of buy orders + :param sells: a list of sell orders + :return: 2 lists containing aggregated amounts for buy and sell orders. + """ aggregated_buys = [] aggregated_sells = [] for buy in buys: @@ -88,6 +99,12 @@ def aggregate_position_order(buys: list, sells: list): def derivative_pnl(long: list, short: list): # It is assumed that the amount and leverage for both open and close orders are thesame. + """ + Calculates PnL for a close position + :param long: a list containing pairs of open and closed long position orders + :param short: a list containing pairs of open and closed short position orders + :return: A list containing PnL for each closed positions + """ pnls = [] for lg in long: pnls.append((lg[1].price - lg[0].price) * lg[1].amount) From 377dc154a87d5c5c1c7ca695baf6a300cc3a6f54 Mon Sep 17 00:00:00 2001 From: vic-en <31972210+vic-en@users.noreply.github.com> Date: Mon, 25 Jan 2021 13:50:15 +0100 Subject: [PATCH 063/126] Removed trailing space character --- hummingbot/client/performance.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hummingbot/client/performance.py b/hummingbot/client/performance.py index d653582b05..eb67245700 100644 --- a/hummingbot/client/performance.py +++ b/hummingbot/client/performance.py @@ -53,7 +53,7 @@ def __init__(self): self.fees: Dict[str, Decimal] = {} -def position_order(open: list, close: list): +def position_order(open: list, close: list): """ Pair open position order with close position orders :param open: a list of orders that may have an open position order From 48b2ab0e6bba60d83a698f766a5479425240b75b Mon Sep 17 00:00:00 2001 From: Nullably <37262506+Nullably@users.noreply.github.com> Date: Tue, 26 Jan 2021 15:57:36 +0800 Subject: [PATCH 064/126] (feat) update target_base_pct to a single number applies to all --- hummingbot/client/command/config_command.py | 2 -- hummingbot/client/command/create_command.py | 3 --- .../strategy/liquidity_mining/liquidity_mining.py | 4 ++-- .../liquidity_mining_config_map.py | 14 +++----------- hummingbot/strategy/liquidity_mining/start.py | 5 ++--- .../conf_liquidity_mining_strategy_TEMPLATE.yml | 3 +-- 6 files changed, 8 insertions(+), 23 deletions(-) diff --git a/hummingbot/client/command/config_command.py b/hummingbot/client/command/config_command.py index 4ae14a46e4..4486c170b6 100644 --- a/hummingbot/client/command/config_command.py +++ b/hummingbot/client/command/config_command.py @@ -143,8 +143,6 @@ async def _config_single_key(self, # type: HummingbotApplication self._notify("Please follow the prompt to complete configurations: ") if config_var.key == "inventory_target_base_pct": await self.asset_ratio_maintenance_prompt(config_map, input_value) - elif config_var.key == "target_base_pct": - await self.lm_target_base_pct_prompt(config_map, input_value) else: await self.prompt_a_config(config_var, input_value=input_value, assign_default=False) if self.app.to_stop_config: diff --git a/hummingbot/client/command/create_command.py b/hummingbot/client/command/create_command.py index 76e250cfa0..21881542b7 100644 --- a/hummingbot/client/command/create_command.py +++ b/hummingbot/client/command/create_command.py @@ -97,9 +97,6 @@ async def prompt_a_config(self, # type: HummingbotApplication config: ConfigVar, input_value=None, assign_default=True): - if config.key == "target_base_pct": - await self.lm_target_base_pct_prompt(input_value) - return if input_value is None: if assign_default: self.app.set_text(parse_config_default_to_text(config)) diff --git a/hummingbot/strategy/liquidity_mining/liquidity_mining.py b/hummingbot/strategy/liquidity_mining/liquidity_mining.py index 1959cdb8a2..ee1e2e0472 100644 --- a/hummingbot/strategy/liquidity_mining/liquidity_mining.py +++ b/hummingbot/strategy/liquidity_mining/liquidity_mining.py @@ -40,7 +40,7 @@ def __init__(self, token: str, order_size: Decimal, spread: Decimal, - target_base_pcts: Dict[str, Decimal], + target_base_pct: Decimal, order_refresh_time: float, order_refresh_tolerance_pct: Decimal, status_report_interval: float = 900): @@ -52,7 +52,7 @@ def __init__(self, self._spread = spread self._order_refresh_time = order_refresh_time self._order_refresh_tolerance_pct = order_refresh_tolerance_pct - self._target_base_pcts = target_base_pcts + self._target_base_pct = target_base_pct self._ev_loop = asyncio.get_event_loop() self._last_timestamp = 0 self._status_report_interval = status_report_interval diff --git a/hummingbot/strategy/liquidity_mining/liquidity_mining_config_map.py b/hummingbot/strategy/liquidity_mining/liquidity_mining_config_map.py index 37d0eb616a..c2cd90682f 100644 --- a/hummingbot/strategy/liquidity_mining/liquidity_mining_config_map.py +++ b/hummingbot/strategy/liquidity_mining/liquidity_mining_config_map.py @@ -34,15 +34,6 @@ def order_size_prompt() -> str: return f"What is the size of each order (in {token} amount)? >>> " -def target_base_pct_prompt() -> str: - markets = list(liquidity_mining_config_map["markets"].value.split(",")) - token = liquidity_mining_config_map["token"].value - markets = [m for m in markets if token in m.split("-")] - example = ",".join([f"{m}:10%" for m in markets[:2]]) - return f"For each of {token} markets, what is the target base asset pct? (Enter a list of markets and" \ - f" their target e.g. {example}) >>> " - - liquidity_mining_config_map = { "strategy": ConfigVar( key="strategy", @@ -85,8 +76,9 @@ def target_base_pct_prompt() -> str: prompt_on_new=True), "target_base_pct": ConfigVar(key="target_base_pct", - prompt=None, - type_str="json", + prompt=" For each pair, what is your target base asset percentage? (Enter 1 to indicate 1%) >>> ", + type_str="decimal", + validator=lambda v: validate_decimal(v, 0, 100, inclusive=False), prompt_on_new=True), "order_refresh_time": ConfigVar(key="order_refresh_time", diff --git a/hummingbot/strategy/liquidity_mining/start.py b/hummingbot/strategy/liquidity_mining/start.py index 99620bdd0f..e02a0d3673 100644 --- a/hummingbot/strategy/liquidity_mining/start.py +++ b/hummingbot/strategy/liquidity_mining/start.py @@ -10,8 +10,7 @@ def start(self): token = c_map.get("token").value order_size = c_map.get("order_size").value spread = c_map.get("spread").value / Decimal("100") - base_targets = c_map.get("target_base_pct").value - base_targets = {k: Decimal(str(v)) / Decimal("100") for k, v in base_targets.items()} + target_base_pct = c_map.get("target_base_pct").value / Decimal("100") order_refresh_time = c_map.get("order_refresh_time").value order_refresh_tolerance_pct = c_map.get("order_refresh_tolerance_pct").value / Decimal("100") @@ -27,7 +26,7 @@ def start(self): token=token, order_size=order_size, spread=spread, - target_base_pcts=base_targets, + target_base_pct=target_base_pct, order_refresh_time=order_refresh_time, order_refresh_tolerance_pct=order_refresh_tolerance_pct ) diff --git a/hummingbot/templates/conf_liquidity_mining_strategy_TEMPLATE.yml b/hummingbot/templates/conf_liquidity_mining_strategy_TEMPLATE.yml index be1b89ab26..1db3888874 100644 --- a/hummingbot/templates/conf_liquidity_mining_strategy_TEMPLATE.yml +++ b/hummingbot/templates/conf_liquidity_mining_strategy_TEMPLATE.yml @@ -19,8 +19,7 @@ order_size: null spread: null # For each pair, what is the target base asset pct? -target_base_pct: - BTC-USDT: 10.0 +target_base_pct: 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. From 6b8e1a2d730883a066424d085d68a173ae6530c3 Mon Sep 17 00:00:00 2001 From: nionis Date: Tue, 26 Jan 2021 20:56:51 +0200 Subject: [PATCH 065/126] (clean) remove log --- .../exchange/bitmax/bitmax_api_user_stream_data_source.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/hummingbot/connector/exchange/bitmax/bitmax_api_user_stream_data_source.py b/hummingbot/connector/exchange/bitmax/bitmax_api_user_stream_data_source.py index 0eeae1cd43..e3ebf24e9b 100755 --- a/hummingbot/connector/exchange/bitmax/bitmax_api_user_stream_data_source.py +++ b/hummingbot/connector/exchange/bitmax/bitmax_api_user_stream_data_source.py @@ -77,8 +77,7 @@ async def listen_for_user_stream(self, ev_loop: asyncio.BaseEventLoop, output: a raise except asyncio.CancelledError: raise - except Exception as e: - print(str(e)) + except Exception: self.logger().error( "Unexpected error with Bitmax WebSocket connection. " "Retrying after 30 seconds...", exc_info=True ) From 6a34e454859858a202b13a6935b6015707eb5ef9 Mon Sep 17 00:00:00 2001 From: nionis Date: Tue, 26 Jan 2021 21:06:29 +0200 Subject: [PATCH 066/126] (clean) remove trades functions --- .../exchange/bitmax/bitmax_exchange.py | 54 +------------------ 1 file changed, 1 insertion(+), 53 deletions(-) diff --git a/hummingbot/connector/exchange/bitmax/bitmax_exchange.py b/hummingbot/connector/exchange/bitmax/bitmax_exchange.py index 361eb899bb..730268a052 100644 --- a/hummingbot/connector/exchange/bitmax/bitmax_exchange.py +++ b/hummingbot/connector/exchange/bitmax/bitmax_exchange.py @@ -10,7 +10,6 @@ import asyncio import json import aiohttp -import math import time from collections import namedtuple @@ -683,8 +682,7 @@ async def _update_order_status(self): if "data" not in update_result: self.logger().info(f"_update_order_status result not in resp: {update_result}") continue - # for trade_msg in update_result["data"]["trade_list"]: - # await self._process_trade_message(trade_msg) + for order_data in update_result["data"]: self._process_order_message(order_data) @@ -766,56 +764,6 @@ def _process_order_message(self, order_msg: Dict[str, Any]): ) self.stop_tracking_order(client_order_id) - async def _process_trade_message(self, trade_msg: Dict[str, Any]): - """ - Updates in-flight order and trigger order filled event for trade message received. Triggers order completed - event if the total executed amount equals to the specified order amount. - """ - for order in self._in_flight_orders.values(): - await order.get_exchange_order_id() - track_order = [o for o in self._in_flight_orders.values() if trade_msg["order_id"] == o.exchange_order_id] - if not track_order: - return - tracked_order = track_order[0] - updated = tracked_order.update_with_trade_update(trade_msg) - if not updated: - return - self.trigger_event( - MarketEvent.OrderFilled, - OrderFilledEvent( - self.current_timestamp, - tracked_order.client_order_id, - tracked_order.trading_pair, - tracked_order.trade_type, - tracked_order.order_type, - Decimal(str(trade_msg["traded_price"])), - Decimal(str(trade_msg["traded_quantity"])), - TradeFee(0.0, [(trade_msg["fee_currency"], Decimal(str(trade_msg["fee"])))]), - trade_msg["order_id"] - ) - ) - if math.isclose(tracked_order.executed_amount_base, tracked_order.amount) or \ - tracked_order.executed_amount_base >= tracked_order.amount: - tracked_order.last_state = "FILLED" - self.logger().info(f"The {tracked_order.trade_type.name} order " - f"{tracked_order.client_order_id} has completed " - f"according to order status API.") - event_tag = MarketEvent.BuyOrderCompleted if tracked_order.trade_type is TradeType.BUY \ - else MarketEvent.SellOrderCompleted - event_class = BuyOrderCompletedEvent if tracked_order.trade_type is TradeType.BUY \ - else SellOrderCompletedEvent - self.trigger_event(event_tag, - event_class(self.current_timestamp, - tracked_order.client_order_id, - tracked_order.base_asset, - tracked_order.quote_asset, - tracked_order.fee_asset, - tracked_order.executed_amount_base, - tracked_order.executed_amount_quote, - tracked_order.fee_paid, - tracked_order.order_type)) - self.stop_tracking_order(tracked_order.client_order_id) - async def cancel_all(self, timeout_seconds: float): """ Cancels all in-flight orders and waits for cancellation results. From 8fa4dbdcafcb14b3c5eea0c5d5dff82a701427ec Mon Sep 17 00:00:00 2001 From: nionis Date: Tue, 26 Jan 2021 21:10:18 +0200 Subject: [PATCH 067/126] (fix) bitmax: improve listen_for_user_stream --- .../exchange/bitmax/bitmax_api_user_stream_data_source.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/hummingbot/connector/exchange/bitmax/bitmax_api_user_stream_data_source.py b/hummingbot/connector/exchange/bitmax/bitmax_api_user_stream_data_source.py index e3ebf24e9b..1f723a88b5 100755 --- a/hummingbot/connector/exchange/bitmax/bitmax_api_user_stream_data_source.py +++ b/hummingbot/connector/exchange/bitmax/bitmax_api_user_stream_data_source.py @@ -72,9 +72,17 @@ 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 + ) raise except Exception: + self.logger().error( + "Unexpected error while listening to Bitmax messages. ", exc_info=True + ) raise + finally: + ws.close() except asyncio.CancelledError: raise except Exception: From a065b791499f615251fe162ff8a8fee1d9ef928c Mon Sep 17 00:00:00 2001 From: nionis Date: Tue, 26 Jan 2021 21:18:14 +0200 Subject: [PATCH 068/126] (fix) bitmax: use milliseconds --- .../exchange/bitmax/bitmax_api_order_book_data_source.py | 8 ++++---- hummingbot/connector/exchange/bitmax/bitmax_utils.py | 6 ------ .../exchange/bitmax/test_bitmax_order_book_tracker.py | 4 ++-- 3 files changed, 6 insertions(+), 12 deletions(-) diff --git a/hummingbot/connector/exchange/bitmax/bitmax_api_order_book_data_source.py b/hummingbot/connector/exchange/bitmax/bitmax_api_order_book_data_source.py index ed61767326..8509c83375 100644 --- a/hummingbot/connector/exchange/bitmax/bitmax_api_order_book_data_source.py +++ b/hummingbot/connector/exchange/bitmax/bitmax_api_order_book_data_source.py @@ -13,7 +13,7 @@ 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, ms_timestamp_to_s +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 @@ -139,7 +139,7 @@ async def listen_for_trades(self, ev_loop: asyncio.BaseEventLoop, output: asynci trading_pair: str = convert_from_exchange_trading_pair(msg.get("symbol")) for trade in msg.get("data"): - trade_timestamp: int = ms_timestamp_to_s(trade.get("ts")) + trade_timestamp: int = trade.get("ts") trade_msg: OrderBookMessage = BitmaxOrderBook.trade_message_from_exchange( trade, trade_timestamp, @@ -178,7 +178,7 @@ async def listen_for_order_book_diffs(self, ev_loop: asyncio.BaseEventLoop, outp if (msg is None or msg.get("m") != "bbo"): continue - msg_timestamp: int = ms_timestamp_to_s(msg.get("data").get("ts")) + 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( msg.get("data"), @@ -218,7 +218,7 @@ async def listen_for_order_book_snapshots(self, ev_loop: asyncio.BaseEventLoop, if (msg is None or msg.get("m") != "depth"): continue - msg_timestamp: int = ms_timestamp_to_s(msg.get("data").get("ts")) + msg_timestamp: int = msg.get("data").get("ts") trading_pair: str = convert_from_exchange_trading_pair(msg.get("symbol")) order_book_message: OrderBookMessage = BitmaxOrderBook.snapshot_message_from_exchange( msg.get("data"), diff --git a/hummingbot/connector/exchange/bitmax/bitmax_utils.py b/hummingbot/connector/exchange/bitmax/bitmax_utils.py index 71d8d57c88..45f943688d 100644 --- a/hummingbot/connector/exchange/bitmax/bitmax_utils.py +++ b/hummingbot/connector/exchange/bitmax/bitmax_utils.py @@ -1,6 +1,5 @@ import random import string -import math from typing import Tuple from hummingbot.core.utils.tracking_nonce import get_tracking_nonce_low_res @@ -32,11 +31,6 @@ 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) - - def uuid32(): return ''.join(random.choice(string.ascii_uppercase + string.ascii_lowercase + string.digits) for _ in range(32)) diff --git a/test/connector/exchange/bitmax/test_bitmax_order_book_tracker.py b/test/connector/exchange/bitmax/test_bitmax_order_book_tracker.py index a1fe109e5f..331860fbba 100755 --- a/test/connector/exchange/bitmax/test_bitmax_order_book_tracker.py +++ b/test/connector/exchange/bitmax/test_bitmax_order_book_tracker.py @@ -78,8 +78,8 @@ def test_order_book_trade_event_emission(self): 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) + # 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) From c83796c37ad2169e2d7a390b848ae74dc94ce972 Mon Sep 17 00:00:00 2001 From: nionis Date: Tue, 26 Jan 2021 21:20:34 +0200 Subject: [PATCH 069/126] (fix) bitmax: await ws.close --- .../exchange/bitmax/bitmax_api_user_stream_data_source.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hummingbot/connector/exchange/bitmax/bitmax_api_user_stream_data_source.py b/hummingbot/connector/exchange/bitmax/bitmax_api_user_stream_data_source.py index 1f723a88b5..55d01a8f0c 100755 --- a/hummingbot/connector/exchange/bitmax/bitmax_api_user_stream_data_source.py +++ b/hummingbot/connector/exchange/bitmax/bitmax_api_user_stream_data_source.py @@ -82,7 +82,7 @@ async def listen_for_user_stream(self, ev_loop: asyncio.BaseEventLoop, output: a ) raise finally: - ws.close() + await ws.close() except asyncio.CancelledError: raise except Exception: From 93512e1855c35fb18169b101ab2d116f32a1b213 Mon Sep 17 00:00:00 2001 From: nionis Date: Tue, 26 Jan 2021 21:37:11 +0200 Subject: [PATCH 070/126] (fix) revert TradeFill_order_details changes --- hummingbot/connector/connector_base.pyx | 2 +- hummingbot/connector/exchange/bitmax/bitmax_exchange.py | 4 ---- hummingbot/connector/markets_recorder.py | 5 ++++- hummingbot/connector/utils.py | 4 ---- 4 files changed, 5 insertions(+), 10 deletions(-) diff --git a/hummingbot/connector/connector_base.pyx b/hummingbot/connector/connector_base.pyx index 59e7a2686b..191c105a9c 100644 --- a/hummingbot/connector/connector_base.pyx +++ b/hummingbot/connector/connector_base.pyx @@ -14,7 +14,7 @@ from hummingbot.core.event.events import ( from hummingbot.core.event.event_logger import EventLogger from hummingbot.core.network_iterator import NetworkIterator from hummingbot.connector.in_flight_order_base import InFlightOrderBase -from hummingbot.connector.utils import TradeFill_order_details +from hummingbot.connector.markets_recorder import TradeFill_order_details from hummingbot.core.event.events import OrderFilledEvent from hummingbot.client.config.global_config_map import global_config_map from hummingbot.core.utils.estimate_fee import estimate_fee diff --git a/hummingbot/connector/exchange/bitmax/bitmax_exchange.py b/hummingbot/connector/exchange/bitmax/bitmax_exchange.py index 730268a052..d4181c0d26 100644 --- a/hummingbot/connector/exchange/bitmax/bitmax_exchange.py +++ b/hummingbot/connector/exchange/bitmax/bitmax_exchange.py @@ -771,8 +771,6 @@ async def cancel_all(self, timeout_seconds: float): :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: await self._api_request( @@ -787,8 +785,6 @@ async def cancel_all(self, timeout_seconds: float): for cl_order_id, tracked_order in self._in_flight_orders.items(): open_order = [o for o in open_orders if o.client_order_id == cl_order_id] - print("cancel all tracked_order", tracked_order) - print("cancel all", cl_order_id) if not open_order: cancellation_results.append(CancellationResult(cl_order_id, True)) self.trigger_event(MarketEvent.OrderCancelled, diff --git a/hummingbot/connector/markets_recorder.py b/hummingbot/connector/markets_recorder.py index 4fbae0491f..6f14352356 100644 --- a/hummingbot/connector/markets_recorder.py +++ b/hummingbot/connector/markets_recorder.py @@ -8,6 +8,7 @@ ) import time import threading +from collections import namedtuple from typing import ( Dict, List, @@ -36,7 +37,9 @@ from hummingbot.model.order_status import OrderStatus from hummingbot.model.sql_connection_manager import SQLConnectionManager from hummingbot.model.trade_fill import TradeFill -from hummingbot.connector.utils import TradeFill_order_details + + +TradeFill_order_details = namedtuple("TradeFill_order_details", "market exchange_trade_id symbol") class MarketsRecorder: diff --git a/hummingbot/connector/utils.py b/hummingbot/connector/utils.py index 59f936af3a..22872e032e 100644 --- a/hummingbot/connector/utils.py +++ b/hummingbot/connector/utils.py @@ -6,10 +6,6 @@ Optional ) from zero_ex.order_utils import Order as ZeroExOrder -from collections import namedtuple - - -TradeFill_order_details = namedtuple("TradeFill_order_details", "market exchange_trade_id symbol") def zrx_order_to_json(order: Optional[ZeroExOrder]) -> Optional[Dict[str, any]]: From b4a736c8ab1b9ea131490c8a84e6e732a7329384 Mon Sep 17 00:00:00 2001 From: nionis Date: Tue, 26 Jan 2021 22:01:18 +0200 Subject: [PATCH 071/126] (fix) bitmax: get_last_traded_prices --- .../bitmax_api_order_book_data_source.py | 44 +++++++++---------- 1 file changed, 21 insertions(+), 23 deletions(-) diff --git a/hummingbot/connector/exchange/bitmax/bitmax_api_order_book_data_source.py b/hummingbot/connector/exchange/bitmax/bitmax_api_order_book_data_source.py index 8509c83375..21d0d0081d 100644 --- a/hummingbot/connector/exchange/bitmax/bitmax_api_order_book_data_source.py +++ b/hummingbot/connector/exchange/bitmax/bitmax_api_order_book_data_source.py @@ -39,32 +39,30 @@ def __init__(self, trading_pairs: List[str] = None): async def get_last_traded_prices(cls, trading_pairs: List[str]) -> Dict[str, float]: result = {} - async with aiohttp.ClientSession() as client: - resp = await client.get(f"{REST_URL}/ticker") - if resp.status != 200: - raise IOError( - f"Error fetching last traded prices at {EXCHANGE_NAME}. " - f"HTTP status is {resp.status}." - ) - - resp_json = await resp.json() - if resp_json.get("code") != 0: - raise IOError( - f"Error fetching last traded prices at {EXCHANGE_NAME}. " - f"Error is {resp_json.message}." - ) - - for item in resp_json.get("data"): - trading_pair: str = convert_from_exchange_trading_pair(item.get("symbol")) - if trading_pair not in trading_pairs: + for trading_pair in trading_pairs: + async with aiohttp.ClientSession() as client: + resp = await client.get(f"{REST_URL}/trades?symbol={convert_to_exchange_trading_pair(trading_pair)}") + if resp.status != 200: + raise IOError( + f"Error fetching last traded prices at {EXCHANGE_NAME}. " + f"HTTP status is {resp.status}." + ) + + resp_json = await resp.json() + if resp_json.get("code") != 0: + raise IOError( + f"Error fetching last traded prices at {EXCHANGE_NAME}. " + f"Error is {resp_json.message}." + ) + + trades = resp_json.get("data").get("data") + if (len(trades) == 0): continue - # we had to calculate mid price - ask = float(item.get("ask")[0]) - bid = float(item.get("bid")[0]) - result[trading_pair] = (ask + bid) / 2 + # last trade is the most recent trade + result[trading_pair] = float(trades[-1].get("p")) - return result + return result @staticmethod async def fetch_trading_pairs() -> List[str]: From 50427ed03092eed7e3cd90aaaef01d3e7f613515 Mon Sep 17 00:00:00 2001 From: vic-en Date: Tue, 26 Jan 2021 21:29:12 +0100 Subject: [PATCH 072/126] (fix) add fix to huobi user stream socket --- .../huobi_api_user_stream_data_source.py | 54 ++++++++++--------- .../exchange/huobi/huobi_exchange.pxd | 1 - .../exchange/huobi/huobi_exchange.pyx | 24 ++++++--- 3 files changed, 44 insertions(+), 35 deletions(-) diff --git a/hummingbot/connector/exchange/huobi/huobi_api_user_stream_data_source.py b/hummingbot/connector/exchange/huobi/huobi_api_user_stream_data_source.py index 7970237658..03d6494664 100644 --- a/hummingbot/connector/exchange/huobi/huobi_api_user_stream_data_source.py +++ b/hummingbot/connector/exchange/huobi/huobi_api_user_stream_data_source.py @@ -1,6 +1,7 @@ #!/usr/bin/env python import aiohttp import asyncio +import time import logging @@ -18,7 +19,7 @@ HUOBI_API_ENDPOINT = "https://api.huobi.pro" HUOBI_WS_ENDPOINT = "wss://api.huobi.pro/ws/v2" -HUOBI_ACCOUNT_UPDATE_TOPIC = "accounts.update#1" +HUOBI_ACCOUNT_UPDATE_TOPIC = "accounts.update#2" HUOBI_ORDER_UPDATE_TOPIC = "orders#*" HUOBI_SUBSCRIBE_TOPICS = { @@ -74,9 +75,8 @@ async def _authenticate_client(self): await self._websocket_connection.send_json(auth_request) resp: aiohttp.WSMessage = await self._websocket_connection.receive() msg = resp.json() - if msg.get("code", 0) != 200: - self.logger().error(f"Error occurred authenticating to websocket API server. {msg}") - self.logger().info("Successfully authenticated") + if msg.get("code", 0) == 200: + self.logger().info("Successfully authenticated") async def _subscribe_topic(self, topic: str): subscribe_request = { @@ -84,11 +84,7 @@ async def _subscribe_topic(self, topic: str): "ch": topic } await self._websocket_connection.send_json(subscribe_request) - resp = await self._websocket_connection.receive() - msg = resp.json() - if msg.get("code", 0) != 200: - self.logger().error(f"Error occurred subscribing to topic. {topic}. {msg}") - self.logger().info(f"Successfully subscribed to {topic}") + self._last_recv_time = time.time() async def get_ws_connection(self) -> aiohttp.client._WSRequestContextManager: if self._client_session is None: @@ -102,23 +98,29 @@ async def _socket_user_stream(self) -> AsyncIterable[str]: Main iterator that manages the websocket connection. """ while True: - raw_msg = await self._websocket_connection.receive() - - if raw_msg.type != aiohttp.WSMsgType.TEXT: - continue - - message = raw_msg.json() - - # Handle ping messages - if message["action"] == "ping": - pong_response = { - "action": "pong", - "data": message["data"] - } - await self._websocket_connection.send_json(pong_response) - continue - - yield message + try: + raw_msg = await asyncio.wait_for(self._websocket_connection.receive(), timeout=30) + self._last_recv_time = time.time() + + if raw_msg.type != aiohttp.WSMsgType.TEXT: + # since all ws messages from huobi are TEXT, any other type should cause ws to reconnect + return + + message = raw_msg.json() + + # Handle ping messages + if message["action"] == "ping": + pong_response = { + "action": "pong", + "data": message["data"] + } + await self._websocket_connection.send_json(pong_response) + continue + + yield message + except asyncio.TimeoutError: + self.logger().error("Userstream websocket timeout, going to reconnect...") + return async def listen_for_user_stream(self, ev_loop: asyncio.BaseEventLoop, output: asyncio.Queue): while True: diff --git a/hummingbot/connector/exchange/huobi/huobi_exchange.pxd b/hummingbot/connector/exchange/huobi/huobi_exchange.pxd index a0c2a0977f..68bbd17eec 100644 --- a/hummingbot/connector/exchange/huobi/huobi_exchange.pxd +++ b/hummingbot/connector/exchange/huobi/huobi_exchange.pxd @@ -14,7 +14,6 @@ cdef class HuobiExchange(ExchangeBase): double _last_poll_timestamp double _last_timestamp object _poll_notifier - double _poll_interval object _shared_client public object _status_polling_task dict _trading_rules diff --git a/hummingbot/connector/exchange/huobi/huobi_exchange.pyx b/hummingbot/connector/exchange/huobi/huobi_exchange.pyx index 3916a5b376..5d63a8452d 100644 --- a/hummingbot/connector/exchange/huobi/huobi_exchange.pyx +++ b/hummingbot/connector/exchange/huobi/huobi_exchange.pyx @@ -4,6 +4,7 @@ import asyncio from decimal import Decimal from libc.stdint cimport int64_t import logging +import time import pandas as pd from typing import ( Any, @@ -94,6 +95,8 @@ cdef class HuobiExchange(ExchangeBase): MARKET_SELL_ORDER_CREATED_EVENT_TAG = MarketEvent.SellOrderCreated.value API_CALL_TIMEOUT = 10.0 UPDATE_ORDERS_INTERVAL = 10.0 + SHORT_POLL_INTERVAL = 5.0 + LONG_POLL_INTERVAL = 120.0 @classmethod def logger(cls) -> HummingbotLogger: @@ -105,7 +108,6 @@ cdef class HuobiExchange(ExchangeBase): def __init__(self, huobi_api_key: str, huobi_secret_key: str, - poll_interval: float = 5.0, trading_pairs: Optional[List[str]] = None, trading_required: bool = True): @@ -121,7 +123,6 @@ cdef class HuobiExchange(ExchangeBase): trading_pairs=trading_pairs ) self._poll_notifier = asyncio.Event() - self._poll_interval = poll_interval self._shared_client = None self._status_polling_task = None self._trading_required = trading_required @@ -231,8 +232,12 @@ cdef class HuobiExchange(ExchangeBase): cdef c_tick(self, double timestamp): cdef: - int64_t last_tick = (self._last_timestamp / self._poll_interval) - int64_t current_tick = (timestamp / self._poll_interval) + double now = time.time() + double poll_interval = (self.SHORT_POLL_INTERVAL + if now - self._user_stream_tracker.last_recv_time > 60.0 + else self.LONG_POLL_INTERVAL) + int64_t last_tick = (self._last_timestamp / poll_interval) + int64_t current_tick = (timestamp / poll_interval) ExchangeBase.c_tick(self, timestamp) self._tx_tracker.c_tick(timestamp) if current_tick > last_tick: @@ -579,12 +584,15 @@ cdef class HuobiExchange(ExchangeBase): continue data = stream_message["data"] + if len(data) == 0 and stream_message["code"] == 200: + # This is a subcribtion confirmation. + self.logger().info(f"Successfully subscribed to {channel}") + continue + if channel == HUOBI_ACCOUNT_UPDATE_TOPIC: asset_name = data["currency"].upper() - # Huobi balance update can contain either balance or available_balance, or both. - # Hence, the reason for the setting existing value(i.e assume no change) if get fails. - balance = data.get("balance", self._account_balances.get(asset_name, "0")) - available_balance = data.get("available", self._account_available_balances.get(asset_name, "0")) + balance = data["balance"] + available_balance = data["available"] self._account_balances.update({asset_name: Decimal(balance)}) self._account_available_balances.update({asset_name: Decimal(available_balance)}) From b41bc4c4384415578b2f91d6a3c3d6e722da3912 Mon Sep 17 00:00:00 2001 From: vic-en Date: Wed, 27 Jan 2021 16:37:21 +0100 Subject: [PATCH 073/126] (feat) add stop_loss parameter --- .../perpetual_market_making/perpetual_market_making.pxd | 1 + .../perpetual_market_making/perpetual_market_making.pyx | 2 ++ .../perpetual_market_making_config_map.py | 7 +++++++ hummingbot/strategy/perpetual_market_making/start.py | 2 ++ .../conf_perpetual_market_making_strategy_TEMPLATE.yml | 5 ++++- 5 files changed, 16 insertions(+), 1 deletion(-) diff --git a/hummingbot/strategy/perpetual_market_making/perpetual_market_making.pxd b/hummingbot/strategy/perpetual_market_making/perpetual_market_making.pxd index 50cbbc2088..00e9acecf7 100644 --- a/hummingbot/strategy/perpetual_market_making/perpetual_market_making.pxd +++ b/hummingbot/strategy/perpetual_market_making/perpetual_market_making.pxd @@ -18,6 +18,7 @@ cdef class PerpetualMarketMakingStrategy(StrategyBase): object _short_profit_taking_spread object _ts_activation_spread object _ts_callback_rate + object _stop_loss_spread object _close_position_order_type int _order_levels int _buy_levels diff --git a/hummingbot/strategy/perpetual_market_making/perpetual_market_making.pyx b/hummingbot/strategy/perpetual_market_making/perpetual_market_making.pyx index e474b84de7..97345a799c 100644 --- a/hummingbot/strategy/perpetual_market_making/perpetual_market_making.pyx +++ b/hummingbot/strategy/perpetual_market_making/perpetual_market_making.pyx @@ -76,6 +76,7 @@ cdef class PerpetualMarketMakingStrategy(StrategyBase): short_profit_taking_spread: Decimal, ts_activation_spread: Decimal, ts_callback_rate: Decimal, + stop_loss_spread: Decimal, close_position_order_type: str, order_levels: int = 1, order_level_spread: Decimal = s_decimal_zero, @@ -119,6 +120,7 @@ cdef class PerpetualMarketMakingStrategy(StrategyBase): self._short_profit_taking_spread = short_profit_taking_spread self._ts_activation_spread = ts_activation_spread self._ts_callback_rate = ts_callback_rate + self._stop_loss_spread = stop_loss_spread self._close_position_order_type = OrderType.MARKET if close_position_order_type == "MARKET" else OrderType.LIMIT self._order_levels = order_levels self._buy_levels = order_levels 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 48aa10c1bf..b9b31c0009 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 @@ -230,6 +230,13 @@ def derivative_on_validated(value: str): default=Decimal("0"), validator=lambda v: validate_decimal(v, 0, 100, True), prompt_on_new=True), + "stop_loss_spread": + ConfigVar(key="stop_loss_spread", + prompt="At what spread from position entry price do you want to stop_loss? (Enter 1 for 1%) >>> ", + type_str="decimal", + default=Decimal("0"), + validator=lambda v: validate_decimal(v, 0, 101, False), + prompt_on_new=True), "close_position_order_type": ConfigVar(key="close_position_order_type", prompt="What order type do you want to use for closing positions? (LIMIT/MARKET) >>> ", diff --git a/hummingbot/strategy/perpetual_market_making/start.py b/hummingbot/strategy/perpetual_market_making/start.py index e4fa06e202..bfb7fadbbb 100644 --- a/hummingbot/strategy/perpetual_market_making/start.py +++ b/hummingbot/strategy/perpetual_market_making/start.py @@ -28,6 +28,7 @@ def start(self): short_profit_taking_spread = c_map.get("short_profit_taking_spread").value / Decimal('100') ts_activation_spread = c_map.get("ts_activation_spread").value / Decimal('100') ts_callback_rate = c_map.get("ts_callback_rate").value / Decimal('100') + stop_loss_spread = c_map.get("stop_loss_spread").value / Decimal('100') close_position_order_type = c_map.get("close_position_order_type").value minimum_spread = c_map.get("minimum_spread").value / Decimal('100') price_ceiling = c_map.get("price_ceiling").value @@ -86,6 +87,7 @@ def start(self): short_profit_taking_spread = short_profit_taking_spread, ts_activation_spread = ts_activation_spread, ts_callback_rate = ts_callback_rate, + stop_loss_spread = stop_loss_spread, close_position_order_type = close_position_order_type, order_level_spread=order_level_spread, order_level_amount=order_level_amount, diff --git a/hummingbot/templates/conf_perpetual_market_making_strategy_TEMPLATE.yml b/hummingbot/templates/conf_perpetual_market_making_strategy_TEMPLATE.yml index 7eaaf28517..47b8743d29 100644 --- a/hummingbot/templates/conf_perpetual_market_making_strategy_TEMPLATE.yml +++ b/hummingbot/templates/conf_perpetual_market_making_strategy_TEMPLATE.yml @@ -2,7 +2,7 @@ ### Perpetual market making strategy config ### ######################################################## -template_version: 3 +template_version: 4 strategy: null # derivative and token parameters. @@ -59,6 +59,9 @@ ts_activation_spread: null # Callback rate for trailing stop ts_callback_rate: null +# Spread from position entry price to place a stop-loss order to close position +stop_loss_spread: null + # OrderType to use for closing positions when using Trailing stop feature close_position_order_type: null From 19680e8b8a1a36d55dfdeb42503a67bdbf3e5f1c Mon Sep 17 00:00:00 2001 From: vic-en Date: Wed, 27 Jan 2021 16:38:07 +0100 Subject: [PATCH 074/126] (refactor) refactor _ts_exit_orders to _exit_orders --- .../perpetual_market_making.pxd | 2 +- .../perpetual_market_making.pyx | 30 +++++++++---------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/hummingbot/strategy/perpetual_market_making/perpetual_market_making.pxd b/hummingbot/strategy/perpetual_market_making/perpetual_market_making.pxd index 00e9acecf7..39ca019370 100644 --- a/hummingbot/strategy/perpetual_market_making/perpetual_market_making.pxd +++ b/hummingbot/strategy/perpetual_market_making/perpetual_market_making.pxd @@ -58,7 +58,7 @@ cdef class PerpetualMarketMakingStrategy(StrategyBase): object _last_own_trade_price object _ts_peak_bid_price object _ts_peak_ask_price - list _ts_exit_orders + list _exit_orders cdef c_manage_positions(self, list session_positions) cdef c_profit_taking_feature(self, object mode, list active_positions) cdef c_trailing_stop_feature(self, object mode, list active_positions) diff --git a/hummingbot/strategy/perpetual_market_making/perpetual_market_making.pyx b/hummingbot/strategy/perpetual_market_making/perpetual_market_making.pyx index 97345a799c..76e0849bd7 100644 --- a/hummingbot/strategy/perpetual_market_making/perpetual_market_making.pyx +++ b/hummingbot/strategy/perpetual_market_making/perpetual_market_making.pyx @@ -159,7 +159,7 @@ cdef class PerpetualMarketMakingStrategy(StrategyBase): self._last_own_trade_price = Decimal('nan') self._ts_peak_bid_price = Decimal('0') self._ts_peak_ask_price = Decimal('0') - self._ts_exit_orders = [] + self._exit_orders = [] self.c_add_markets([market_info.market]) @@ -629,7 +629,7 @@ cdef class PerpetualMarketMakingStrategy(StrategyBase): cdef: ExchangeBase market = self._market_info.market list active_orders = self.active_orders - list unwanted_exit_orders = [o for o in active_orders if o.client_order_id not in self._ts_exit_orders] + list unwanted_exit_orders = [o for o in active_orders if o.client_order_id not in self._exit_orders] list buys = [] list sells = [] @@ -653,8 +653,8 @@ cdef class PerpetualMarketMakingStrategy(StrategyBase): 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) - old_exit_orders = [o for o in active_orders if (o.price > price and position.amount < 0 and o.client_order_id in self._ts_exit_orders) - or (o.price < price and position.amount > 0 and o.client_order_id in self._ts_exit_orders)] + old_exit_orders = [o for o in active_orders if (o.price > price and position.amount < 0 and o.client_order_id in self._exit_orders) + or (o.price < price and position.amount > 0 and o.client_order_id in self._exit_orders)] for old_order in old_exit_orders: self.c_cancel_order(self._market_info, old_order.client_order_id) self.logger().info(f"Cancelled previous take profit order {old_order.client_order_id} in favour of new take profit order.") @@ -687,7 +687,7 @@ cdef class PerpetualMarketMakingStrategy(StrategyBase): self.logger().info(f"Kindly ensure you do not interract with the exchange through other platforms and restart this strategy.") else: # Cancel open order that could potentially close position and affect trailing stop functionality - unwanted_exit_orders = [o for o in active_orders if o.client_order_id not in self._ts_exit_orders] + unwanted_exit_orders = [o for o in active_orders if o.client_order_id not in self._exit_orders] for order in unwanted_exit_orders: if active_positions[0].amount < 0 and order.is_buy: self.c_cancel_order(self._market_info, order.client_order_id) @@ -707,9 +707,9 @@ cdef class PerpetualMarketMakingStrategy(StrategyBase): price = market.c_quantize_order_price(self.trading_pair, exit_price) # Do some checks to prevent duplicating orders to close positions - exit_order_exists = [o for o in active_orders if o.client_order_id in self._ts_exit_orders] + exit_order_exists = [o for o in active_orders if o.client_order_id in self._exit_orders] create_order = True - self._ts_exit_orders = [] if len(exit_order_exists) == 0 else self._ts_exit_orders + self._exit_orders = [] if len(exit_order_exists) == 0 else self._exit_orders for order in exit_order_exists: if not order.is_buy: create_order = False @@ -725,9 +725,9 @@ cdef class PerpetualMarketMakingStrategy(StrategyBase): price = market.c_quantize_order_price(self.trading_pair, exit_price) # Do some checks to prevent duplicating orders to close positions - exit_order_exists = [o for o in active_orders if o.client_order_id in self._ts_exit_orders] + exit_order_exists = [o for o in active_orders if o.client_order_id in self._exit_orders] create_order = True - self._ts_exit_orders = [] if len(exit_order_exists) == 0 else self._ts_exit_orders + self._exit_orders = [] if len(exit_order_exists) == 0 else self._exit_orders for order in exit_order_exists: if not order.is_buy: create_order = False @@ -743,9 +743,9 @@ cdef class PerpetualMarketMakingStrategy(StrategyBase): price = market.c_quantize_order_price(self.trading_pair, exit_price) # Do some checks to prevent duplicating orders to close positions - exit_order_exists = [o for o in active_orders if o.client_order_id in self._ts_exit_orders] + exit_order_exists = [o for o in active_orders if o.client_order_id in self._exit_orders] create_order = True - self._ts_exit_orders = [] if len(exit_order_exists) == 0 else self._ts_exit_orders + self._exit_orders = [] if len(exit_order_exists) == 0 else self._exit_orders for order in exit_order_exists: if order.is_buy: create_order = False @@ -761,9 +761,9 @@ cdef class PerpetualMarketMakingStrategy(StrategyBase): price = market.c_quantize_order_price(self.trading_pair, exit_price) # Do some checks to prevent duplicating orders to close positions - exit_order_exists = [o for o in active_orders if o.client_order_id in self._ts_exit_orders] + exit_order_exists = [o for o in active_orders if o.client_order_id in self._exit_orders] create_order = True - self._ts_exit_orders = [] if len(exit_order_exists) == 0 else self._ts_exit_orders + self._exit_orders = [] if len(exit_order_exists) == 0 else self._exit_orders for order in exit_order_exists: if order.is_buy: create_order = False @@ -1195,7 +1195,7 @@ cdef class PerpetualMarketMakingStrategy(StrategyBase): position_action=position_action ) if position_action == PositionAction.CLOSE: - self._ts_exit_orders.append(bid_order_id) + self._exit_orders.append(bid_order_id) orders_created = True if len(proposal.sells) > 0: if self._logging_options & self.OPTION_LOG_CREATE_ORDER: @@ -1216,7 +1216,7 @@ cdef class PerpetualMarketMakingStrategy(StrategyBase): position_action=position_action ) if position_action == PositionAction.CLOSE: - self._ts_exit_orders.append(ask_order_id) + self._exit_orders.append(ask_order_id) orders_created = True if orders_created: self.set_timers() From a75c2ef59afca3fe5de43cd21e25f331bfe4adee Mon Sep 17 00:00:00 2001 From: Daniel Tan Date: Tue, 26 Jan 2021 13:10:49 +0800 Subject: [PATCH 075/126] (fix) balance command not reporting certain Assets correctly --- hummingbot/core/utils/market_price.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hummingbot/core/utils/market_price.py b/hummingbot/core/utils/market_price.py index 687620d246..1fa0c66ab7 100644 --- a/hummingbot/core/utils/market_price.py +++ b/hummingbot/core/utils/market_price.py @@ -29,7 +29,7 @@ async def token_usd_values() -> Dict[str, Decimal]: 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] = prices[token_usd_pairs[0]] + 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: From 61ac9edc2f8c65dd7d36d4351ce612233fc394a6 Mon Sep 17 00:00:00 2001 From: Daniel Tan Date: Thu, 28 Jan 2021 01:07:13 +0800 Subject: [PATCH 076/126] (fix) bitfinex not reflecting right asset tokens --- .../exchange/bitfinex/bitfinex_exchange.pyx | 4 ++- .../exchange/bitfinex/bitfinex_utils.py | 32 +++++++++++++++++-- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/hummingbot/connector/exchange/bitfinex/bitfinex_exchange.pyx b/hummingbot/connector/exchange/bitfinex/bitfinex_exchange.pyx index d8c7ef6732..8f1b86666f 100644 --- a/hummingbot/connector/exchange/bitfinex/bitfinex_exchange.pyx +++ b/hummingbot/connector/exchange/bitfinex/bitfinex_exchange.pyx @@ -62,6 +62,7 @@ from hummingbot.connector.exchange.bitfinex.bitfinex_utils import ( split_trading_pair, convert_from_exchange_trading_pair, convert_to_exchange_trading_pair, + convert_from_exchange_token, ) s_logger = None @@ -283,6 +284,7 @@ cdef class BitfinexExchange(ExchangeBase): continue asset_name = balance_entry.currency + asset_name = convert_from_exchange_token(asset_name) # None or 0 self._account_balances[asset_name] = Decimal(balance_entry.balance or 0) self._account_available_balances[asset_name] = Decimal(balance_entry.balance) - Decimal(balance_entry.unsettled_interest) @@ -984,7 +986,7 @@ cdef class BitfinexExchange(ExchangeBase): if (wallet_type != "exchange"): continue - asset_name = wallet[1] + asset_name = convert_from_exchange_token(wallet[1]) balance = wallet[2] balance_available = wallet[4] diff --git a/hummingbot/connector/exchange/bitfinex/bitfinex_utils.py b/hummingbot/connector/exchange/bitfinex/bitfinex_utils.py index a5833b8637..dee0706b6f 100644 --- a/hummingbot/connector/exchange/bitfinex/bitfinex_utils.py +++ b/hummingbot/connector/exchange/bitfinex/bitfinex_utils.py @@ -13,6 +13,9 @@ EXAMPLE_PAIR = "ETH-USD" +# BitFinex list Tether(USDT) as 'UST' +EXCHANGE_TO_HB_CONVERSION = {"UST": "USDT"} +HB_TO_EXCHANGE_CONVERSION = {v: k for k, v in EXCHANGE_TO_HB_CONVERSION.items()} DEFAULT_FEES = [0.1, 0.2] @@ -87,15 +90,38 @@ def valid_exchange_trading_pair(trading_pair: str) -> bool: return False +def convert_from_exchange_token(token: str) -> str: + if token in EXCHANGE_TO_HB_CONVERSION: + token = EXCHANGE_TO_HB_CONVERSION[token] + return token + + +def convert_to_exchange_token(token: str) -> str: + if token in HB_TO_EXCHANGE_CONVERSION: + token = HB_TO_EXCHANGE_CONVERSION[token] + return token + + def convert_from_exchange_trading_pair(exchange_trading_pair: str) -> Optional[str]: try: - # exchange does not split BASEQUOTE (BTCUSDT) base_asset, quote_asset = split_trading_pair_from_exchange(exchange_trading_pair) + + base_asset = convert_from_exchange_token(base_asset) + quote_asset = convert_from_exchange_token(quote_asset) + return f"{base_asset}-{quote_asset}" except Exception as e: raise e def convert_to_exchange_trading_pair(hb_trading_pair: str) -> str: - # exchange does not split BASEQUOTE (BTCUSDT) - return f't{hb_trading_pair.replace("-", "")}' + base_asset, quote_asset = hb_trading_pair.split("-") + + base_asset = convert_to_exchange_token(base_asset) + quote_asset = convert_to_exchange_token(quote_asset) + + if len(base_asset) > 3: # Adds ':' delimiter if base asset > 3 characters + trading_pair = f"t{base_asset}:{quote_asset}" + else: + trading_pair = f"t{base_asset}{quote_asset}" + return trading_pair From dc54d9068103dbe78ca6015e31a7002ab3f1e318 Mon Sep 17 00:00:00 2001 From: vic-en Date: Wed, 27 Jan 2021 20:50:36 +0100 Subject: [PATCH 077/126] (feat) add stop loss funtionality --- .../perpetual_market_making.pxd | 2 + .../perpetual_market_making.pyx | 78 +++++++++++-------- .../perpetual_market_making_config_map.py | 5 +- 3 files changed, 48 insertions(+), 37 deletions(-) diff --git a/hummingbot/strategy/perpetual_market_making/perpetual_market_making.pxd b/hummingbot/strategy/perpetual_market_making/perpetual_market_making.pxd index 39ca019370..5d74b78967 100644 --- a/hummingbot/strategy/perpetual_market_making/perpetual_market_making.pxd +++ b/hummingbot/strategy/perpetual_market_making/perpetual_market_making.pxd @@ -20,6 +20,7 @@ cdef class PerpetualMarketMakingStrategy(StrategyBase): object _ts_callback_rate object _stop_loss_spread object _close_position_order_type + object _close_order_type int _order_levels int _buy_levels int _sell_levels @@ -62,6 +63,7 @@ cdef class PerpetualMarketMakingStrategy(StrategyBase): cdef c_manage_positions(self, list session_positions) cdef c_profit_taking_feature(self, object mode, list active_positions) cdef c_trailing_stop_feature(self, object mode, list active_positions) + cdef c_stop_loss_feature(self, object mode, list active_positions) cdef c_apply_initial_settings(self, str trading_pair, object position, int64_t leverage) cdef object c_get_mid_price(self) cdef object c_create_base_proposal(self) diff --git a/hummingbot/strategy/perpetual_market_making/perpetual_market_making.pyx b/hummingbot/strategy/perpetual_market_making/perpetual_market_making.pyx index 76e0849bd7..3d874964e9 100644 --- a/hummingbot/strategy/perpetual_market_making/perpetual_market_making.pyx +++ b/hummingbot/strategy/perpetual_market_making/perpetual_market_making.pyx @@ -605,6 +605,7 @@ cdef class PerpetualMarketMakingStrategy(StrategyBase): self.c_cancel_hanging_orders() self.c_cancel_orders_below_min_spread() if self.c_to_create_orders(proposal): + self._close_order_type = OrderType.LIMIT self.c_execute_orders_proposal(proposal, PositionAction.OPEN) # Reset peak ask and bid prices self._ts_peak_ask_price = market.get_price(self.trading_pair, False) @@ -619,12 +620,20 @@ cdef class PerpetualMarketMakingStrategy(StrategyBase): object mode = self._position_mode if self._position_management == "Profit_taking": + self._close_order_type = OrderType.LIMIT proposals = self.c_profit_taking_feature(mode, session_positions) else: + self._close_order_type = self._close_position_order_type proposals = self.c_trailing_stop_feature(mode, session_positions) if proposals is not None: self.c_execute_orders_proposal(proposals, PositionAction.CLOSE) + # check if stop loss needs to be placed + proposals = self.c_stop_loss_feature(mode, session_positions) + if proposals is not None: + self._close_order_type = self._close_position_order_type + self.c_execute_orders_proposal(proposals, PositionAction.CLOSE) + cdef c_profit_taking_feature(self, object mode, list active_positions): cdef: ExchangeBase market = self._market_info.market @@ -701,22 +710,7 @@ cdef class PerpetualMarketMakingStrategy(StrategyBase): continue if position.amount > 0: # this is a long position top_ask = market.get_price(self.trading_pair, False) - if top_ask < position.entry_price: - exit_price = market.get_price_for_volume(self.trading_pair, False, - abs(position.amount)).result_price - price = market.c_quantize_order_price(self.trading_pair, exit_price) - - # Do some checks to prevent duplicating orders to close positions - exit_order_exists = [o for o in active_orders if o.client_order_id in self._exit_orders] - create_order = True - self._exit_orders = [] if len(exit_order_exists) == 0 else self._exit_orders - for order in exit_order_exists: - if not order.is_buy: - create_order = False - if create_order is True: - self.logger().info(f"Closing long position immediately to stop-loss.") - sells.append(PriceSize(price, abs(position.amount))) - elif max(top_ask, self._ts_peak_ask_price) >= (position.entry_price * (Decimal("1") + self._ts_activation_spread)): + if max(top_ask, self._ts_peak_ask_price) >= (position.entry_price * (Decimal("1") + self._ts_activation_spread)): if top_ask > self._ts_peak_ask_price or self._ts_peak_ask_price == Decimal("0"): self._ts_peak_ask_price = top_ask elif top_ask <= (self._ts_peak_ask_price * (Decimal("1") - self._ts_callback_rate)): @@ -737,22 +731,7 @@ cdef class PerpetualMarketMakingStrategy(StrategyBase): f"from {self._ts_peak_ask_price} {self.quote_asset} trailing maximum price to secure profit.") else: top_bid = market.get_price(self.trading_pair, True) - if top_bid > position.entry_price: - exit_price = market.get_price_for_volume(self.trading_pair, True, - abs(position.amount)).result_price - price = market.c_quantize_order_price(self.trading_pair, exit_price) - - # Do some checks to prevent duplicating orders to close positions - exit_order_exists = [o for o in active_orders if o.client_order_id in self._exit_orders] - create_order = True - self._exit_orders = [] if len(exit_order_exists) == 0 else self._exit_orders - for order in exit_order_exists: - if order.is_buy: - create_order = False - if create_order is True: - buys.append(PriceSize(price, abs(position.amount))) - self.logger().info(f"Closing short position immediately to stop-loss.") - elif min(top_bid, self._ts_peak_bid_price) <= (position.entry_price * (Decimal("1") - self._ts_activation_spread)): + if min(top_bid, self._ts_peak_bid_price) <= (position.entry_price * (Decimal("1") - self._ts_activation_spread)): if top_bid < self._ts_peak_bid_price or self._ts_peak_ask_price == Decimal("0"): self._ts_peak_bid_price = top_bid elif top_bid >= (self._ts_peak_bid_price * (Decimal("1") + self._ts_callback_rate)): @@ -773,6 +752,38 @@ cdef class PerpetualMarketMakingStrategy(StrategyBase): f"from {self._ts_peak_bid_price}{self.quote_asset} trailing minimum price to secure profit.") return Proposal(buys, sells) + cdef c_stop_loss_feature(self, object mode, list active_positions): + cdef: + ExchangeBase market = self._market_info.market + list active_orders = self.active_orders + list all_exit_orders = [o for o in active_orders if o.client_order_id not in self._exit_orders] + top_ask = market.get_price(self.trading_pair, False) + top_bid = market.get_price(self.trading_pair, True) + list buys = [] + list sells = [] + + for position in active_positions: + # check if stop loss order needs to be placed + stop_loss_price = position.entry_price * (Decimal("1") + self._stop_loss_spread) if position.amount < 0 \ + else position.entry_price * (Decimal("1") - self._stop_loss_spread) + if (top_ask < stop_loss_price and position.amount > 0) or (top_bid > stop_loss_price and position.amount < 0): + price = market.c_quantize_order_price(self.trading_pair, stop_loss_price) + take_profit_orders = [o for o in active_orders if (o.price < position.entry_price and position.amount < 0 and o.client_order_id in self._exit_orders) + or (o.price > position.entry_price and position.amount > 0 and o.client_order_id in self._exit_orders)] + # cancel take profit orders if they exist + for old_order in take_profit_orders: + self.c_cancel_order(self._market_info, old_order.client_order_id) + self.logger().info(f"Cancelled previous take profit order {old_order.client_order_id} in favour of new stop loss order.") + exit_order_exists = [o for o in active_orders if o.price == price] + if len(exit_order_exists) == 0: + size = market.c_quantize_order_amount(self.trading_pair, abs(position.amount)) + if size > 0 and price > 0: + if position.amount < 0: + buys.append(PriceSize(price, size)) + else: + sells.append(PriceSize(price, size)) + return Proposal(buys, sells) + cdef object c_create_base_proposal(self): cdef: ExchangeBase market = self._market_info.market @@ -1173,8 +1184,7 @@ cdef class PerpetualMarketMakingStrategy(StrategyBase): double expiration_seconds = NaN str bid_order_id, ask_order_id bint orders_created = False - object order_type = self._close_position_order_type if position_action == PositionAction.CLOSE and \ - self._position_management == "Trailing_stop" else OrderType.LIMIT + object order_type = self._close_order_type if len(proposal.buys) > 0: if self._logging_options & self.OPTION_LOG_CREATE_ORDER: 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 b9b31c0009..b18277dc9d 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 @@ -232,15 +232,14 @@ def derivative_on_validated(value: str): prompt_on_new=True), "stop_loss_spread": ConfigVar(key="stop_loss_spread", - prompt="At what spread from position entry price do you want to stop_loss? (Enter 1 for 1%) >>> ", + prompt="At what spread from position entry price do you want to place stop_loss order? (Enter 1 for 1%) >>> ", type_str="decimal", default=Decimal("0"), validator=lambda v: validate_decimal(v, 0, 101, False), prompt_on_new=True), "close_position_order_type": ConfigVar(key="close_position_order_type", - prompt="What order type do you want to use for closing positions? (LIMIT/MARKET) >>> ", - required_if=lambda: perpetual_market_making_config_map.get("position_management").value == "Trailing_stop", + prompt="What order type do you want trailing stop and/or stop loss features to use for closing positions? (LIMIT/MARKET) >>> ", type_str="str", default="LIMIT", validator=lambda s: None if s in {"LIMIT", "MARKET"} else From fbcb8b2840d152fbbe91a9f8cdf7c09a4005ef98 Mon Sep 17 00:00:00 2001 From: Nullably <37262506+Nullably@users.noreply.github.com> Date: Thu, 28 Jan 2021 15:08:10 +0800 Subject: [PATCH 078/126] (feat) add budget allocation --- .../liquidity_mining/liquidity_mining.py | 122 ++++++++++++------ .../liquidity_mining_config_map.py | 3 + hummingbot/strategy/order_tracker.pyx | 6 + hummingbot/strategy/strategy_py_base.pyx | 1 + 4 files changed, 96 insertions(+), 36 deletions(-) diff --git a/hummingbot/strategy/liquidity_mining/liquidity_mining.py b/hummingbot/strategy/liquidity_mining/liquidity_mining.py index ee1e2e0472..7c2b35a3c3 100644 --- a/hummingbot/strategy/liquidity_mining/liquidity_mining.py +++ b/hummingbot/strategy/liquidity_mining/liquidity_mining.py @@ -10,14 +10,17 @@ from hummingbot.connector.exchange_base import ExchangeBase from hummingbot.strategy.market_trading_pair_tuple import MarketTradingPairTuple from .data_types import Proposal, PriceSize -from hummingbot.core.event.events import OrderType +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 +) NaN = float("nan") s_decimal_zero = Decimal(0) lms_logger = None +INVENTORY_RANGE_MULTIPLIER = 1 class LiquidityMiningStrategy(StrategyPyBase): @@ -81,12 +84,13 @@ def tick(self, timestamp: float): return else: self.logger().info(f"{self._exchange.name} is ready. Trading started.") - self.assign_balanced_budgets() + self.create_budget_allocation() proposals = self.create_base_proposals() self._token_balances = self.adjusted_available_balances() self.apply_volatility_adjustment(proposals) - self.allocate_order_size(proposals) + self.apply_inventory_skew(proposals) + self.apply_budget_constraint(proposals) self.cancel_active_orders(proposals) self.execute_orders_proposal(proposals) @@ -150,10 +154,11 @@ def create_base_proposals(self): mid_price = market_info.get_mid_price() buy_price = mid_price * (Decimal("1") - self._spread) buy_price = self._exchange.quantize_order_price(market, buy_price) + buy_size = self.base_order_size(market, buy_price) sell_price = mid_price * (Decimal("1") + self._spread) sell_price = self._exchange.quantize_order_price(market, sell_price) - proposals.append(Proposal(market, PriceSize(buy_price, s_decimal_zero), - PriceSize(sell_price, s_decimal_zero))) + sell_size = self.base_order_size(market, sell_price) + proposals.append(Proposal(market, PriceSize(buy_price, buy_size), PriceSize(sell_price, sell_size))) return proposals def apply_volatility_adjustment(self, proposals): @@ -162,6 +167,23 @@ def apply_volatility_adjustment(self, proposals): # scripts/spreads_adjusted_on_volatility_script.py). return + def create_budget_allocation(self): + # Equally assign buy and sell budgets to all markets + self._sell_budgets = {m: s_decimal_zero for m in self._market_infos} + self._buy_budgets = {m: s_decimal_zero for m in self._market_infos} + if self._token == list(self._market_infos.keys())[0].split("-")[0]: + base_markets = [m for m in self._market_infos if m.split("-")[0] == self._token] + sell_size = self._exchange.get_available_balance(self._token) / len(base_markets) + for market in base_markets: + self._sell_budgets[market] = sell_size + self._buy_budgets[market] = self._exchange.get_available_balance(market.split("-")[1]) + else: + quote_markets = [m for m in self._market_infos if m.split("-")[1] == self._token] + buy_size = self._exchange.get_available_balance(self._token) / len(quote_markets) + for market in quote_markets: + self._buy_budgets[market] = buy_size + self._sell_budgets[market] = self._exchange.get_available_balance(market.split("-")[0]) + def assign_balanced_budgets(self): # Equally assign buy and sell budgets to all markets base_tokens = self.all_base_tokens() @@ -180,39 +202,28 @@ def assign_balanced_budgets(self): for market in quote_markets: self._buy_budgets[market] = buy_size - def allocate_order_size(self, proposals: List[Proposal]): + def base_order_size(self, trading_pair: str, price: Decimal = s_decimal_zero): + base, quote = trading_pair.split("-") + if self._token == base: + return self._order_size + if price == s_decimal_zero: + price = self._market_infos[trading_pair].get_mid_price() + return self._order_size / price + + def apply_budget_constraint(self, proposals: List[Proposal]): balances = self._token_balances.copy() for proposal in proposals: - base_size = self._order_size if self._token == proposal.base() else self._order_size / proposal.sell.price - base_size = balances[proposal.base()] if balances[proposal.base()] < base_size else base_size - sell_size = self._exchange.quantize_order_amount(proposal.market, base_size) - if sell_size > s_decimal_zero: - proposal.sell.size = sell_size - balances[proposal.base()] -= sell_size - - quote_size = self._order_size if self._token == proposal.quote() else self._order_size * proposal.buy.price + if balances[proposal.base()] < proposal.sell.size: + proposal.sell.size = balances[proposal.base()] + proposal.sell.size = self._exchange.quantize_order_amount(proposal.market, proposal.sell.size) + balances[proposal.base()] -= proposal.sell.size + + quote_size = proposal.buy.size * proposal.buy.price quote_size = balances[proposal.quote()] if balances[proposal.quote()] < quote_size else quote_size buy_fee = estimate_fee(self._exchange.name, True) buy_size = quote_size / (proposal.buy.price * (Decimal("1") + buy_fee.percent)) - if buy_size > s_decimal_zero: - proposal.buy.size = self._exchange.quantize_order_amount(proposal.market, buy_size) - balances[proposal.quote()] -= quote_size - # balances = self._token_balances.copy() - # for proposal in proposals: - # sell_size = balances[proposal.base()] if balances[proposal.base()] < self._sell_budgets[proposal.market] \ - # else self._sell_budgets[proposal.market] - # sell_size = self._exchange.quantize_order_amount(proposal.market, sell_size) - # if sell_size > s_decimal_zero: - # proposal.sell.size = sell_size - # balances[proposal.base()] -= sell_size - # - # quote_size = balances[proposal.quote()] if balances[proposal.quote()] < self._buy_budgets[proposal.market] \ - # else self._buy_budgets[proposal.market] - # buy_fee = estimate_fee(self._exchange.name, True) - # buy_size = quote_size / (proposal.buy.price * (Decimal("1") + buy_fee.percent)) - # if buy_size > s_decimal_zero: - # proposal.buy.size = self._exchange.quantize_order_amount(proposal.market, buy_size) - # balances[proposal.quote()] -= quote_size + proposal.buy.size = self._exchange.quantize_order_amount(proposal.market, buy_size) + balances[proposal.quote()] -= quote_size def is_within_tolerance(self, cur_orders: List[LimitOrder], proposal: Proposal): cur_buy = [o for o in cur_orders if o.is_buy] @@ -227,7 +238,7 @@ def is_within_tolerance(self, cur_orders: List[LimitOrder], proposal: Proposal): return False return True - def cancel_active_orders(self, proposals): + def cancel_active_orders(self, proposals: List[Proposal]): for proposal in proposals: if self._refresh_times[proposal.market] > self.current_timestamp: continue @@ -239,7 +250,7 @@ def cancel_active_orders(self, proposals): # To place new order on the next tick self._refresh_times[order.trading_pair] = self.current_timestamp + 0.1 - def execute_orders_proposal(self, proposals): + def execute_orders_proposal(self, proposals: List[Proposal]): for proposal in proposals: cur_orders = [o for o in self.active_orders if o.trading_pair == proposal.market] if cur_orders or self._refresh_times[proposal.market] > self.current_timestamp: @@ -305,3 +316,42 @@ def adjusted_available_balances(self) -> Dict[str, Decimal]: # adjusted_bals[token] -= reserved # self.logger().info(f"token balances: {adjusted_bals}") return adjusted_bals + + def apply_inventory_skew(self, proposals: List[Proposal]): + balances = self.adjusted_available_balances() + for proposal in proposals: + mid_price = self._market_infos[proposal.market].get_mid_price() + total_order_size = proposal.sell.size + proposal.buy.size + bid_ask_ratios = calculate_bid_ask_ratios_from_base_asset_ratio( + float(balances[proposal.base()]), + float(balances[proposal.quote()]), + float(mid_price), + float(self._target_base_pct), + float(total_order_size * INVENTORY_RANGE_MULTIPLIER) + ) + proposal.buy.size *= Decimal(bid_ask_ratios.bid_ratio) + # proposal.buy.size = self._exchange.quantize_order_amount(proposal.market, proposal.buy.size) + + proposal.sell.size *= Decimal(bid_ask_ratios.ask_ratio) + # proposal.sell.size = self._exchange.quantize_order_amount(proposal.market, proposal.sell.size) + + def did_fill_order(self, order_filled_event): + order_id = order_filled_event.order_id + market_info = self.order_tracker.get_shadow_market_pair_from_order_id(order_id) + if market_info is not None: + if order_filled_event.trade_type is TradeType.BUY: + 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." + ) + self._buy_budgets[market_info.trading_pair] -= (order_filled_event.amount * order_filled_event.price) + self._sell_budgets[market_info.trading_pair] += (order_filled_event.amount) + else: + 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." + ) + self._sell_budgets[market_info.trading_pair] -= (order_filled_event.amount) + self._buy_budgets[market_info.trading_pair] += (order_filled_event.amount * order_filled_event.price) diff --git a/hummingbot/strategy/liquidity_mining/liquidity_mining_config_map.py b/hummingbot/strategy/liquidity_mining/liquidity_mining_config_map.py index c2cd90682f..98d75db281 100644 --- a/hummingbot/strategy/liquidity_mining/liquidity_mining_config_map.py +++ b/hummingbot/strategy/liquidity_mining/liquidity_mining_config_map.py @@ -15,6 +15,7 @@ def exchange_on_validated(value: str) -> None: def token_validate(value: str) -> Optional[str]: + value = value.upper() markets = list(liquidity_mining_config_map["eligible_markets"].value.split(",")) tokens = set() for market in markets: @@ -24,6 +25,8 @@ def token_validate(value: str) -> Optional[str]: def token_on_validated(value: str) -> None: + value = value.upper() + liquidity_mining_config_map["token"].value = value el_markets = list(liquidity_mining_config_map["eligible_markets"].value.split(",")) markets = [m for m in el_markets if value in m.split("-")] liquidity_mining_config_map["markets"].value = ",".join(markets) diff --git a/hummingbot/strategy/order_tracker.pyx b/hummingbot/strategy/order_tracker.pyx index feadf82e23..c3398f30cc 100644 --- a/hummingbot/strategy/order_tracker.pyx +++ b/hummingbot/strategy/order_tracker.pyx @@ -171,6 +171,9 @@ cdef class OrderTracker(TimeIterator): cdef object c_get_shadow_market_pair_from_order_id(self, str order_id): return self._shadow_order_id_to_market_pair.get(order_id) + def get_shadow_market_pair_from_order_id(self, order_id: str) -> MarketTradingPairTuple: + return self.c_get_shadow_market_pair_from_order_id(order_id) + cdef LimitOrder c_get_limit_order(self, object market_pair, str order_id): return self._tracked_limit_orders.get(market_pair, {}).get(order_id) @@ -183,6 +186,9 @@ cdef class OrderTracker(TimeIterator): return self._shadow_tracked_limit_orders.get(market_pair, {}).get(order_id) + def get_shadow_limit_order(self, order_id: str) -> LimitOrder: + return self.c_get_shadow_limit_order(order_id) + cdef c_start_tracking_limit_order(self, object market_pair, str order_id, bint is_buy, object price, object quantity): if market_pair not in self._tracked_limit_orders: diff --git a/hummingbot/strategy/strategy_py_base.pyx b/hummingbot/strategy/strategy_py_base.pyx index 6d846e5eab..e738588f2c 100644 --- a/hummingbot/strategy/strategy_py_base.pyx +++ b/hummingbot/strategy/strategy_py_base.pyx @@ -55,6 +55,7 @@ cdef class StrategyPyBase(StrategyBase): pass cdef c_did_fill_order(self, object order_filled_event): + print(f"c_did_fill_order {order_filled_event}") self.did_fill_order(order_filled_event) def did_fill_order(self, order_filled_event): From 6c14f048dda3f47051a04afd57ae665d482985f4 Mon Sep 17 00:00:00 2001 From: Keith O'Neill Date: Thu, 28 Jan 2021 16:32:17 -0500 Subject: [PATCH 079/126] updates to loopringconnector for v1 -> v2 --- .../loopring_api_order_book_data_source.py | 21 +-- ...ing_api_token_configuration_data_source.py | 39 +++-- .../loopring_api_user_stream_data_source.py | 6 +- .../exchange/loopring/loopring_exchange.pxd | 2 +- .../exchange/loopring/loopring_exchange.pyx | 159 +++++++++--------- .../loopring/loopring_in_flight_order.pyx | 2 +- .../loopring/loopring_order_book_message.py | 2 +- .../loopring/loopring_order_book_tracker.py | 5 +- .../loopring/loopring_user_stream_tracker.py | 2 +- .../exchange/loopring/loopring_utils.py | 6 +- 10 files changed, 132 insertions(+), 112 deletions(-) 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 2f71555ed1..77f4e69ebf 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 @@ -33,12 +33,12 @@ from hummingbot.core.data_type.order_book_message import OrderBookMessage -MARKETS_URL = "/api/v2/exchange/markets" -TICKER_URL = "/api/v2/ticker?market=:markets" -SNAPSHOT_URL = "/api/v2/depth?market=:trading_pair" -TOKEN_INFO_URL = "/api/v2/exchange/tokens" -WS_URL = "wss://ws.loopring.io/v2/ws" -LOOPRING_PRICE_URL = "https://api.loopring.io/api/v2/ticker" +MARKETS_URL = "/api/v3/exchange/markets" +TICKER_URL = "/api/v3/ticker?market=:markets" +SNAPSHOT_URL = "/api/v3/depth?market=:trading_pair" +TOKEN_INFO_URL = "/api/v3/exchange/tokens" +WS_URL = "wss://ws.api3.loopring.io/v3/ws" +LOOPRING_PRICE_URL = "https://api3.loopring.io/api/v3/ticker" class LoopringAPIOrderBookDataSource(OrderBookTrackerDataSource): @@ -65,7 +65,7 @@ def __init__(self, trading_pairs: List[str] = None, rest_api_url="", websocket_u @classmethod async def get_last_traded_prices(cls, trading_pairs: List[str]) -> Dict[str, float]: async with aiohttp.ClientSession() as client: - resp = await client.get(f"https://api.loopring.io{TICKER_URL}".replace(":markets", ",".join(trading_pairs))) + resp = await client.get(f"https://api3.loopring.io{TICKER_URL}".replace(":markets", ",".join(trading_pairs))) resp_json = await resp.json() return {x[0]: float(x[7]) for x in resp_json.get("data", [])} @@ -78,7 +78,7 @@ def trading_pairs(self) -> List[str]: return self._trading_pairs async def get_snapshot(self, client: aiohttp.ClientSession, trading_pair: str, level: int = 0) -> Dict[str, any]: - async with client.get(f"https://api.loopring.io{SNAPSHOT_URL}&level={level}".replace(":trading_pair", trading_pair)) as response: + async with client.get(f"https://api3.loopring.io{SNAPSHOT_URL}&level={level}".replace(":trading_pair", trading_pair)) as response: response: aiohttp.ClientResponse = response if response.status != 200: raise IOError( @@ -91,6 +91,7 @@ async def get_snapshot(self, client: aiohttp.ClientSession, trading_pair: str, l async def get_new_order_book(self, trading_pair: str) -> OrderBook: async with aiohttp.ClientSession() as client: snapshot: Dict[str, Any] = await self.get_snapshot(client, trading_pair, 1000) + snapshot["data"] = {"bids": snapshot["bids"], "asks": snapshot["asks"]} snapshot_timestamp: float = time.time() snapshot_msg: OrderBookMessage = LoopringOrderBook.snapshot_message_from_exchange( snapshot, @@ -137,7 +138,7 @@ def get_mid_price(trading_pair: str) -> Optional[Decimal]: async def fetch_trading_pairs() -> List[str]: try: async with aiohttp.ClientSession() as client: - async with client.get(f"https://api.loopring.io{MARKETS_URL}", timeout=5) as response: + async with client.get(f"https://api3.loopring.io{MARKETS_URL}", timeout=5) as response: if response.status == 200: all_trading_pairs: Dict[str, Any] = await response.json() valid_trading_pairs: list = [] @@ -234,4 +235,4 @@ async def listen_for_order_book_snapshots(self, ev_loop: asyncio.BaseEventLoop, except Exception: self.logger().error("Unexpected error with WebSocket connection. Retrying after 30 seconds...", exc_info=True) - await asyncio.sleep(30.0) + await asyncio.sleep(30.0) \ No newline at end of file diff --git a/hummingbot/connector/exchange/loopring/loopring_api_token_configuration_data_source.py b/hummingbot/connector/exchange/loopring/loopring_api_token_configuration_data_source.py index 831e7e1067..275f0afb40 100644 --- a/hummingbot/connector/exchange/loopring/loopring_api_token_configuration_data_source.py +++ b/hummingbot/connector/exchange/loopring/loopring_api_token_configuration_data_source.py @@ -13,7 +13,7 @@ from hummingbot.core.event.events import TradeType from hummingbot.core.utils.async_utils import safe_ensure_future -TOKEN_CONFIGURATIONS_URL = '/api/v2/exchange/tokens' +TOKEN_CONFIGURATIONS_URL = '/api/v3/exchange/tokens' class LoopringAPITokenConfigurationDataSource(): @@ -38,14 +38,15 @@ def create(cls): async def _configure(self): async with aiohttp.ClientSession() as client: response: aiohttp.ClientResponse = await client.get( - f"https://api.loopring.io{TOKEN_CONFIGURATIONS_URL}" + f"https://api3.loopring.io{TOKEN_CONFIGURATIONS_URL}" ) if response.status >= 300: raise IOError(f"Error fetching active loopring token configurations. HTTP status is {response.status}.") response_dict: Dict[str, Any] = await response.json() - for config in response_dict['data']: + + for config in response_dict: self._token_configurations[config['tokenId']] = config self._tokenid_lookup[config['symbol']] = config['tokenId'] self._symbol_lookup[config['tokenId']] = config['symbol'] @@ -86,21 +87,33 @@ def sell_buy_amounts(self, baseid, quoteid, amount, price, side) -> Tuple[int]: """ Returns the buying and selling amounts for unidirectional orders, based on the order side, price and amount and returns the padded values. """ + quote_amount = amount * price - padded_amount = self.pad(amount, baseid) - padded_quote_amount = self.pad(quote_amount, quoteid) + padded_amount = int(self.pad(amount, baseid)) + padded_quote_amount = int(self.pad(quote_amount, quoteid)) if side is TradeType.SELL: return { - "tokenSId": baseid, - "tokenBId": quoteid, - "amountS": padded_amount, - "amountB": padded_quote_amount, + "sellToken": { + "tokenId": str(baseid), + "volume": str(padded_amount) + }, + "buyToken": { + "tokenId": str(quoteid), + "volume": str(padded_quote_amount) + }, + "fillAmountBOrS": False } else: return { - "tokenSId": quoteid, - "tokenBId": baseid, - "amountS": padded_quote_amount, - "amountB": padded_amount, + "sellToken": { + "tokenId": str(quoteid), + "volume": str(padded_quote_amount) + }, + "buyToken": { + "tokenId": str(baseid), + "volume": str(padded_amount) + }, + "fillAmountBOrS": True } + \ No newline at end of file diff --git a/hummingbot/connector/exchange/loopring/loopring_api_user_stream_data_source.py b/hummingbot/connector/exchange/loopring/loopring_api_user_stream_data_source.py index e985c9cd9a..11f8b41d36 100644 --- a/hummingbot/connector/exchange/loopring/loopring_api_user_stream_data_source.py +++ b/hummingbot/connector/exchange/loopring/loopring_api_user_stream_data_source.py @@ -19,9 +19,9 @@ from hummingbot.connector.exchange.loopring.loopring_order_book import LoopringOrderBook from hummingbot.connector.exchange.loopring.loopring_utils import get_ws_api_key -LOOPRING_WS_URL = "wss://ws.loopring.io/v2/ws" +LOOPRING_WS_URL = "wss://ws.api3.loopring.io/v3/ws" -LOOPRING_ROOT_API = "https://api.loopring.io" +LOOPRING_ROOT_API = "https://api3.loopring.io" class LoopringAPIUserStreamDataSource(UserStreamTrackerDataSource): @@ -106,4 +106,4 @@ async def _inner_messages(self, async def stop(self): if self._shared_client is not None and not self._shared_client.closed: - await self._shared_client.close() + await self._shared_client.close() \ No newline at end of file diff --git a/hummingbot/connector/exchange/loopring/loopring_exchange.pxd b/hummingbot/connector/exchange/loopring/loopring_exchange.pxd index ad9b2cdbed..5b8138e23c 100644 --- a/hummingbot/connector/exchange/loopring/loopring_exchange.pxd +++ b/hummingbot/connector/exchange/loopring/loopring_exchange.pxd @@ -12,7 +12,7 @@ cdef class LoopringExchange(ExchangeBase): object _shared_client object _loopring_auth int _loopring_accountid - int _loopring_exchangeid + str _loopring_exchangeid str _loopring_private_key object _order_sign_param diff --git a/hummingbot/connector/exchange/loopring/loopring_exchange.pyx b/hummingbot/connector/exchange/loopring/loopring_exchange.pyx index 46ac952b08..6a51359c2a 100644 --- a/hummingbot/connector/exchange/loopring/loopring_exchange.pyx +++ b/hummingbot/connector/exchange/loopring/loopring_exchange.pyx @@ -81,17 +81,17 @@ API_CALL_TIMEOUT = 10.0 # ========================================================== -GET_ORDER_ROUTE = "/api/v2/order" -MAINNET_API_REST_ENDPOINT = "https://api.loopring.io/" -MAINNET_WS_ENDPOINT = "wss://ws.loopring.io/v2/ws" -EXCHANGE_INFO_ROUTE = "api/v2/timestamp" -BALANCES_INFO_ROUTE = "api/v2/user/balances" -ACCOUNT_INFO_ROUTE = "api/v2/account" -MARKETS_INFO_ROUTE = "api/v2/exchange/markets" -TOKENS_INFO_ROUTE = "api/v2/exchange/tokens" -NEXT_ORDER_ID = "api/v2/orderId" +GET_ORDER_ROUTE = "/api/v3/order" +MAINNET_API_REST_ENDPOINT = "https://api3.loopring.io/" +MAINNET_WS_ENDPOINT = "wss://ws.api3.loopring.io/v2/ws" +EXCHANGE_INFO_ROUTE = "api/v3/timestamp" +BALANCES_INFO_ROUTE = "api/v3/user/balances" +ACCOUNT_INFO_ROUTE = "api/v3/account" +MARKETS_INFO_ROUTE = "api/v3/exchange/markets" +TOKENS_INFO_ROUTE = "api/v3/exchange/tokens" +NEXT_ORDER_ID = "api/v3/storageId" ORDER_ROUTE = "api/v3/order" -ORDER_CANCEL_ROUTE = "api/v2/orders" +ORDER_CANCEL_ROUTE = "api/v3/order" MAXIMUM_FILL_COUNT = 16 UNRECOGNIZED_ORDER_DEBOUCE = 20 # seconds @@ -145,7 +145,7 @@ cdef class LoopringExchange(ExchangeBase): def __init__(self, loopring_accountid: int, - loopring_exchangeid: int, + loopring_exchangeid: str, loopring_private_key: str, loopring_api_key: str, poll_interval: float = 10.0, @@ -182,7 +182,7 @@ cdef class LoopringExchange(ExchangeBase): self._polling_update_task = None self._loopring_accountid = int(loopring_accountid) - self._loopring_exchangeid = int(loopring_exchangeid) + self._loopring_exchangeid = loopring_exchangeid self._loopring_private_key = loopring_private_key # State @@ -192,7 +192,7 @@ cdef class LoopringExchange(ExchangeBase): self._in_flight_orders = {} self._next_order_id = {} self._trading_pairs = trading_pairs - self._order_sign_param = poseidon_params(SNARK_SCALAR_FIELD, 14, 6, 53, b'poseidon', 5, security_target=128) + self._order_sign_param = poseidon_params(SNARK_SCALAR_FIELD, 12, 6, 53, b'poseidon', 5, security_target=128) self._order_id_lock = asyncio.Lock() @@ -266,33 +266,31 @@ cdef class LoopringExchange(ExchangeBase): next_id = self._next_order_id if force_sync or self._next_order_id.get(token) is None: try: - response = await self.api_request("GET", NEXT_ORDER_ID, params={"accountId": self._loopring_accountid, "tokenSId": token}) - next_id = response["data"] + response = await self.api_request("GET", NEXT_ORDER_ID, params={"accountId": self._loopring_accountid, "sellTokenId": token}) + next_id = response["orderId"] self._next_order_id[token] = next_id except Exception as e: self.logger().info(str(e)) self.logger().info("Error getting the next order id from Loopring") else: next_id = self._next_order_id[token] - self._next_order_id[token] = next_id + 1 + self._next_order_id[token] = (next_id + 2) % 4294967294 return next_id async def _serialize_order(self, order): return [ - int(order["exchangeId"]), - int(order["orderId"]), + int(order["exchange"], 16), + int(order["storageId"]), int(order["accountId"]), - int(order["tokenSId"]), - int(order["tokenBId"]), - int(order["amountS"]), - int(order["amountB"]), - int(order["allOrNone"] == 'true'), - int(order["validSince"]), + int(order["sellToken"]['tokenId']), + int(order["buyToken"]['tokenId']), + int(order["sellToken"]['volume']), + int(order["buyToken"]['volume']), int(order["validUntil"]), int(order["maxFeeBips"]), - int(order["buy"] == 'true'), - int(order["label"]) + int(order["fillAmountBOrS"]), + int(order.get("taker", "0x0"), 16) ] def supported_order_types(self): @@ -311,38 +309,39 @@ cdef class LoopringExchange(ExchangeBase): validSince = int(time.time()) - 3600 order_details = self._token_configuration.sell_buy_amounts(baseid, quoteid, amount, price, order_side) - token_s_id = int(order_details["tokenSId"]) - order_id = await self._get_next_order_id(token_s_id) + token_s_id = order_details["sellToken"]["tokenId"] + order_id = await self._get_next_order_id(int(token_s_id)) order = { - "exchangeId": self._loopring_exchangeid, - "orderId": order_id, + "exchange": str(self._loopring_exchangeid), + "storageId": order_id, "accountId": self._loopring_accountid, "allOrNone": "false", "validSince": validSince, "validUntil": validSince + (604800 * 5), # Until week later - "maxFeeBips": 63, - "label": 20, - "buy": "true" if order_side is TradeType.BUY else "false", + "maxFeeBips": 50, "clientOrderId": client_order_id, **order_details } if order_type is OrderType.LIMIT_MAKER: order["orderType"] = "MAKER_ONLY" - serialized_message = await self._serialize_order(order) msgHash = poseidon(serialized_message, self._order_sign_param) - fq_obj = FQ(int(self._loopring_private_key)) + fq_obj = FQ(int(self._loopring_private_key, 16)) signed_message = PoseidonEdDSA.sign(msgHash, fq_obj) - # Update with signature + + eddsa = "0x" + "".join([ + hex(int(signed_message.sig.R.x))[2:].zfill(64), + hex(int(signed_message.sig.R.y))[2:].zfill(64), + hex(int(signed_message.sig.s))[2:].zfill(64) + ]) + order.update({ "hash": str(msgHash), - "signatureRx": str(signed_message.sig.R.x), - "signatureRy": str(signed_message.sig.R.y), - "signatureS": str(signed_message.sig.s) + "eddsaSignature": eddsa }) - return await self.api_request("POST", ORDER_ROUTE, params=order, data=order) + return await self.api_request("POST", ORDER_ROUTE, data=order) async def execute_order(self, order_side, client_order_id, trading_pair, amount, order_type, price): """ @@ -350,6 +349,7 @@ cdef class LoopringExchange(ExchangeBase): validates the order against the trading rules before placing this order. """ # Quantize order + amount = self.c_quantize_order_amount(trading_pair, amount) price = self.c_quantize_order_price(trading_pair, price) @@ -374,21 +374,22 @@ cdef class LoopringExchange(ExchangeBase): try: creation_response = await self.place_order(client_order_id, trading_pair, amount, order_side is TradeType.BUY, order_type, price) - except asyncio.exceptions.TimeoutError: + except asyncio.TimeoutError: # We timed out while placing this order. We may have successfully submitted the order, or we may have had connection # issues that prevented the submission from taking place. We'll assume that the order is live and let our order status # updates mark this as cancelled if it doesn't actually exist. + self.logger().warning(f"Order {client_order_id} has timed out and putatively failed. Order will be tracked until reconciled.") return True # Verify the response from the exchange - if "data" not in creation_response.keys(): - raise Exception(creation_response['resultInfo']['message']) + if "status" not in creation_response.keys(): + raise Exception(creation_response) - status = creation_response["data"]["status"] - if status != 'NEW_ACTIVED': + status = creation_response["status"] + if status != 'processing': raise Exception(status) - loopring_order_hash = creation_response["data"]["orderHash"] + loopring_order_hash = creation_response["hash"] in_flight_order.update_exchange_order_id(loopring_order_hash) # Begin tracking order @@ -467,14 +468,15 @@ cdef class LoopringExchange(ExchangeBase): } res = await self.api_request("DELETE", ORDER_CANCEL_ROUTE, params=cancellation_payload, secure=True) - code = res['resultInfo']['code'] - message = res['resultInfo']['message'] - if code == 102117 and in_flight_order.created_at < (int(time.time()) - UNRECOGNIZED_ORDER_DEBOUCE): - # Order doesn't exist and enough time has passed so we are safe to mark this as canceled - self.c_trigger_event(ORDER_CANCELLED_EVENT, cancellation_event) - self.c_stop_tracking_order(client_order_id) - elif code != 0 and (code != 100001 or message != "order in status CANCELLED can't be cancelled"): - raise Exception(f"Cancel order returned code {res['resultInfo']['code']} ({res['resultInfo']['message']})") + + if 'resultInfo' in res: + code = res['resultInfo']['code'] + if code == 102117 and in_flight_order.created_at < (int(time.time()) - UNRECOGNIZED_ORDER_DEBOUCE): + # Order doesn't exist and enough time has passed so we are safe to mark this as canceled + self.c_trigger_event(ORDER_CANCELLED_EVENT, cancellation_event) + self.c_stop_tracking_order(client_order_id) + elif code != None and code != 0 and (code != 100001 or message != "order in status CANCELLED can't be cancelled"): + raise Exception(f"Cancel order returned code {res['resultInfo']['code']} ({res['resultInfo']['message']})") return True @@ -681,14 +683,13 @@ cdef class LoopringExchange(ExchangeBase): if len(tokens) == 0: await self.token_configuration._configure() tokens = set(self.token_configuration.get_tokens()) - async with self._lock: completed_tokens = set() for data in updates: - padded_total_amount: str = data['totalAmount'] + padded_total_amount: str = data['total'] token_id: int = data['tokenId'] completed_tokens.add(token_id) - padded_amount_locked: string = data['amountLocked'] + padded_amount_locked: string = data['locked'] token_symbol: str = self._token_configuration.get_symbol(token_id) total_amount: Decimal = self._token_configuration.unpad(padded_total_amount, token_id) @@ -731,6 +732,8 @@ cdef class LoopringExchange(ExchangeBase): topic: str = event['topic']['topic'] data: Dict[str, Any] = event['data'] if topic == 'account': + data['total'] = data['totalAmount'] + data['locked'] = data['amountLocked'] await self._set_balances([data], is_snapshot=False) elif topic == 'order': client_order_id: str = data['clientOrderId'] @@ -777,37 +780,31 @@ cdef class LoopringExchange(ExchangeBase): self.logger().info(e) async def _update_balances(self): - balances_response = await self.api_request("GET", BALANCES_INFO_ROUTE, - params = { - "accountId": self._loopring_accountid - }) - await self._set_balances(balances_response["data"]) + balances_response = await self.api_request("GET", f"{BALANCES_INFO_ROUTE}?accountId={self._loopring_accountid}") + await self._set_balances(balances_response) async def _update_trading_rules(self): markets_info, tokens_info = await asyncio.gather( self.api_request("GET", MARKETS_INFO_ROUTE), self.api_request("GET", TOKENS_INFO_ROUTE) ) - # Loopring fees not available from api - markets_info = markets_info["data"] - tokens_info = tokens_info["data"] tokens_info = {t['tokenId']: t for t in tokens_info} - for market in markets_info: + for market in markets_info['markets']: if market['enabled'] is True: baseid, quoteid = market['baseTokenId'], market['quoteTokenId'] try: self._trading_rules[market["market"]] = TradingRule( trading_pair=market["market"], - min_order_size = self.token_configuration.unpad(tokens_info[baseid]['minOrderAmount'], baseid), - max_order_size = self.token_configuration.unpad(tokens_info[baseid]['maxOrderAmount'], baseid), + min_order_size = self.token_configuration.unpad(tokens_info[baseid]['orderAmounts']['minimum'], baseid), + max_order_size = self.token_configuration.unpad(tokens_info[baseid]['orderAmounts']['maximum'], baseid), min_price_increment=Decimal(f"1e-{market['precisionForPrice']}"), min_base_amount_increment=Decimal(f"1e-{tokens_info[baseid]['precision']}"), min_quote_amount_increment=Decimal(f"1e-{tokens_info[quoteid]['precision']}"), - min_notional_size = self.token_configuration.unpad(tokens_info[quoteid]['minOrderAmount'], quoteid), + min_notional_size = self.token_configuration.unpad(tokens_info[quoteid]['orderAmounts']['minimum'], quoteid), supports_limit_orders = True, supports_market_orders = False ) @@ -838,13 +835,14 @@ cdef class LoopringExchange(ExchangeBase): "accountId": self._loopring_accountid, "orderHash": tracked_order.exchange_order_id }) - data = loopring_order_request["data"] + data = loopring_order_request except Exception: self.logger().warning(f"Failed to fetch tracked Loopring order " - f"{client_order_id }({tracked_order.exchange_order_id}) from api (code: {loopring_order_request['resultInfo']['code']})") + f"{client_order_id }({tracked_order.exchange_order_id}) from api (code: {loopring_order_request})") # check if this error is because the api cliams to be unaware of this order. If so, and this order # is reasonably old, mark the order as cancelled + print(loopring_order_request) if loopring_order_request['resultInfo']['code'] == 107003: if tracked_order.created_at < (int(time.time()) - UNRECOGNIZED_ORDER_DEBOUCE): self.logger().warning(f"marking {client_order_id} as cancelled") @@ -854,6 +852,9 @@ cdef class LoopringExchange(ExchangeBase): continue try: + data["filledSize"] = data["volumes"]["baseFilled"] + data["filledVolume"] = data["volumes"]["quoteFilled"] + data["filledFee"] = data["volumes"]["fee"] self._update_inflight_order(tracked_order, data) except Exception as e: self.logger().error(f"Failed to update Loopring order {tracked_order.exchange_order_id}") @@ -918,23 +919,29 @@ cdef class LoopringExchange(ExchangeBase): headers.update(self._loopring_auth.generate_auth_dict()) full_url = f"{self.API_REST_ENDPOINT}{url}" - + # Signs requests for secure requests if secure: ordered_data = self._encode_request(full_url, http_method, params) hasher = hashlib.sha256() hasher.update(ordered_data.encode('utf-8')) msgHash = int(hasher.hexdigest(), 16) % SNARK_SCALAR_FIELD - signed = PoseidonEdDSA.sign(msgHash, FQ(int(self._loopring_private_key))) - signature = ','.join(str(_) for _ in [signed.sig.R.x, signed.sig.R.y, signed.sig.s]) + signed = PoseidonEdDSA.sign(msgHash, FQ(int(self._loopring_private_key, 16))) + signature = "0x" + "".join([ + hex(int(signed.sig.R.x))[2:].zfill(64), + hex(int(signed.sig.R.y))[2:].zfill(64), + hex(int(signed.sig.s))[2:].zfill(64) + ]) headers.update({"X-API-SIG": signature}) - async with self._shared_client.request(http_method, url=full_url, timeout=API_CALL_TIMEOUT, data=data, params=params, headers=headers) as response: if response.status != 200: self.logger().info(f"Issue with Loopring API {http_method} to {url}, response: ") self.logger().info(await response.text()) + data = await response.json() + if 'resultInfo' in data: + return data raise IOError(f"Error fetching data from {full_url}. HTTP status is {response.status}.") data = await response.json() return data @@ -963,4 +970,4 @@ cdef class LoopringExchange(ExchangeBase): order_side: TradeType, amount: Decimal, price: Decimal = s_decimal_NaN) -> TradeFee: - return self.c_get_fee(base_currency, quote_currency, order_type, order_side, amount, price) + return self.c_get_fee(base_currency, quote_currency, order_type, order_side, amount, price) \ No newline at end of file diff --git a/hummingbot/connector/exchange/loopring/loopring_in_flight_order.pyx b/hummingbot/connector/exchange/loopring/loopring_in_flight_order.pyx index aea55314f2..d592a32c66 100644 --- a/hummingbot/connector/exchange/loopring/loopring_in_flight_order.pyx +++ b/hummingbot/connector/exchange/loopring/loopring_in_flight_order.pyx @@ -167,4 +167,4 @@ cdef class LoopringInFlightOrder(InFlightOrderBase): if self.exchange_order_id is None: self.update_exchange_order_id(data.get('hash', None)) - return events + return events \ No newline at end of file diff --git a/hummingbot/connector/exchange/loopring/loopring_order_book_message.py b/hummingbot/connector/exchange/loopring/loopring_order_book_message.py index 44297fddce..54d39fec2d 100644 --- a/hummingbot/connector/exchange/loopring/loopring_order_book_message.py +++ b/hummingbot/connector/exchange/loopring/loopring_order_book_message.py @@ -27,7 +27,7 @@ def __new__(cls, message_type: OrderBookMessageType, content: Dict[str, any], ti @property def update_id(self) -> int: if self.type == OrderBookMessageType.SNAPSHOT: - return self.content["data"]["version"] + return self.content["version"] elif self.type == OrderBookMessageType.DIFF: return self.content["endVersion"] diff --git a/hummingbot/connector/exchange/loopring/loopring_order_book_tracker.py b/hummingbot/connector/exchange/loopring/loopring_order_book_tracker.py index 29427c0694..1f43d627d4 100644 --- a/hummingbot/connector/exchange/loopring/loopring_order_book_tracker.py +++ b/hummingbot/connector/exchange/loopring/loopring_order_book_tracker.py @@ -36,8 +36,8 @@ def logger(cls) -> HummingbotLogger: def __init__( self, trading_pairs: Optional[List[str]] = None, - rest_api_url: str = "https://api.loopring.io", - websocket_url: str = "wss://ws.loopring.io/v2/ws", + rest_api_url: str = "https://api3.loopring.io", + websocket_url: str = "wss://ws.api3.loopring.io/v2/ws", token_configuration: LoopringAPITokenConfigurationDataSource = None, loopring_auth: str = "" ): @@ -83,7 +83,6 @@ async def _track_single_book(self, trading_pair: str): 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.content["startVersion"]) diff --git a/hummingbot/connector/exchange/loopring/loopring_user_stream_tracker.py b/hummingbot/connector/exchange/loopring/loopring_user_stream_tracker.py index cadd2449ea..a51f0f6696 100644 --- a/hummingbot/connector/exchange/loopring/loopring_user_stream_tracker.py +++ b/hummingbot/connector/exchange/loopring/loopring_user_stream_tracker.py @@ -51,4 +51,4 @@ async def start(self): self._user_stream_tracking_task = safe_ensure_future( self.data_source.listen_for_user_stream(self._ev_loop, self._user_stream) ) - await safe_gather(self._user_stream_tracking_task) + await safe_gather(self._user_stream_tracking_task) \ No newline at end of file diff --git a/hummingbot/connector/exchange/loopring/loopring_utils.py b/hummingbot/connector/exchange/loopring/loopring_utils.py index 17896e82cb..087b22d71a 100644 --- a/hummingbot/connector/exchange/loopring/loopring_utils.py +++ b/hummingbot/connector/exchange/loopring/loopring_utils.py @@ -6,11 +6,11 @@ CENTRALIZED = True -EXAMPLE_PAIR = "LRC-ETH" +EXAMPLE_PAIR = "LRC-USDT" DEFAULT_FEES = [0.0, 0.2] -LOOPRING_ROOT_API = "https://api.loopring.io" +LOOPRING_ROOT_API = "https://api3.loopring.io" LOOPRING_WS_KEY_PATH = "/v2/ws/key" KEYS = { @@ -60,4 +60,4 @@ async def get_ws_api_key(): raise IOError(f"Error getting WS key. Server responded with status: {response.status}.") response_dict: Dict[str, Any] = await response.json() - return response_dict['data'] + return response_dict['data'] \ No newline at end of file From be1be1beeb5b4fb0ed238bcd5720fc3942df6577 Mon Sep 17 00:00:00 2001 From: Michael Borraccia Date: Thu, 28 Jan 2021 17:00:43 -0500 Subject: [PATCH 080/126] Fixes for flak8 --- .../loopring_api_order_book_data_source.py | 2 +- ...ing_api_token_configuration_data_source.py | 39 +++++++++---------- .../loopring_api_user_stream_data_source.py | 2 +- .../exchange/loopring/loopring_exchange.pyx | 26 ++++++------- .../loopring/loopring_in_flight_order.pyx | 2 +- .../loopring/loopring_user_stream_tracker.py | 2 +- .../exchange/loopring/loopring_utils.py | 2 +- 7 files changed, 35 insertions(+), 40 deletions(-) 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 77f4e69ebf..f53ad4f491 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 @@ -235,4 +235,4 @@ async def listen_for_order_book_snapshots(self, ev_loop: asyncio.BaseEventLoop, except Exception: self.logger().error("Unexpected error with WebSocket connection. Retrying after 30 seconds...", exc_info=True) - await asyncio.sleep(30.0) \ No newline at end of file + await asyncio.sleep(30.0) diff --git a/hummingbot/connector/exchange/loopring/loopring_api_token_configuration_data_source.py b/hummingbot/connector/exchange/loopring/loopring_api_token_configuration_data_source.py index 275f0afb40..c83e353adb 100644 --- a/hummingbot/connector/exchange/loopring/loopring_api_token_configuration_data_source.py +++ b/hummingbot/connector/exchange/loopring/loopring_api_token_configuration_data_source.py @@ -87,33 +87,32 @@ def sell_buy_amounts(self, baseid, quoteid, amount, price, side) -> Tuple[int]: """ Returns the buying and selling amounts for unidirectional orders, based on the order side, price and amount and returns the padded values. """ - + quote_amount = amount * price padded_amount = int(self.pad(amount, baseid)) padded_quote_amount = int(self.pad(quote_amount, quoteid)) if side is TradeType.SELL: return { - "sellToken": { - "tokenId": str(baseid), - "volume": str(padded_amount) - }, - "buyToken": { - "tokenId": str(quoteid), - "volume": str(padded_quote_amount) - }, - "fillAmountBOrS": False + "sellToken": { + "tokenId": str(baseid), + "volume": str(padded_amount) + }, + "buyToken": { + "tokenId": str(quoteid), + "volume": str(padded_quote_amount) + }, + "fillAmountBOrS": False } else: return { - "sellToken": { - "tokenId": str(quoteid), - "volume": str(padded_quote_amount) - }, - "buyToken": { - "tokenId": str(baseid), - "volume": str(padded_amount) - }, - "fillAmountBOrS": True + "sellToken": { + "tokenId": str(quoteid), + "volume": str(padded_quote_amount) + }, + "buyToken": { + "tokenId": str(baseid), + "volume": str(padded_amount) + }, + "fillAmountBOrS": True } - \ No newline at end of file diff --git a/hummingbot/connector/exchange/loopring/loopring_api_user_stream_data_source.py b/hummingbot/connector/exchange/loopring/loopring_api_user_stream_data_source.py index 11f8b41d36..d33dac0876 100644 --- a/hummingbot/connector/exchange/loopring/loopring_api_user_stream_data_source.py +++ b/hummingbot/connector/exchange/loopring/loopring_api_user_stream_data_source.py @@ -106,4 +106,4 @@ async def _inner_messages(self, async def stop(self): if self._shared_client is not None and not self._shared_client.closed: - await self._shared_client.close() \ No newline at end of file + await self._shared_client.close() diff --git a/hummingbot/connector/exchange/loopring/loopring_exchange.pyx b/hummingbot/connector/exchange/loopring/loopring_exchange.pyx index 6a51359c2a..db4e7279bf 100644 --- a/hummingbot/connector/exchange/loopring/loopring_exchange.pyx +++ b/hummingbot/connector/exchange/loopring/loopring_exchange.pyx @@ -330,12 +330,10 @@ cdef class LoopringExchange(ExchangeBase): signed_message = PoseidonEdDSA.sign(msgHash, fq_obj) # Update with signature - eddsa = "0x" + "".join([ - hex(int(signed_message.sig.R.x))[2:].zfill(64), - hex(int(signed_message.sig.R.y))[2:].zfill(64), - hex(int(signed_message.sig.s))[2:].zfill(64) - ]) - + eddsa = "0x" + "".join([hex(int(signed_message.sig.R.x))[2:].zfill(64), + hex(int(signed_message.sig.R.y))[2:].zfill(64), + hex(int(signed_message.sig.s))[2:].zfill(64)]) + order.update({ "hash": str(msgHash), "eddsaSignature": eddsa @@ -468,14 +466,14 @@ cdef class LoopringExchange(ExchangeBase): } res = await self.api_request("DELETE", ORDER_CANCEL_ROUTE, params=cancellation_payload, secure=True) - + if 'resultInfo' in res: code = res['resultInfo']['code'] if code == 102117 and in_flight_order.created_at < (int(time.time()) - UNRECOGNIZED_ORDER_DEBOUCE): # Order doesn't exist and enough time has passed so we are safe to mark this as canceled self.c_trigger_event(ORDER_CANCELLED_EVENT, cancellation_event) self.c_stop_tracking_order(client_order_id) - elif code != None and code != 0 and (code != 100001 or message != "order in status CANCELLED can't be cancelled"): + elif code is not None and code != 0 and (code != 100001 or message != "order in status CANCELLED can't be cancelled"): raise Exception(f"Cancel order returned code {res['resultInfo']['code']} ({res['resultInfo']['message']})") return True @@ -919,7 +917,7 @@ cdef class LoopringExchange(ExchangeBase): headers.update(self._loopring_auth.generate_auth_dict()) full_url = f"{self.API_REST_ENDPOINT}{url}" - + # Signs requests for secure requests if secure: ordered_data = self._encode_request(full_url, http_method, params) @@ -927,11 +925,9 @@ cdef class LoopringExchange(ExchangeBase): hasher.update(ordered_data.encode('utf-8')) msgHash = int(hasher.hexdigest(), 16) % SNARK_SCALAR_FIELD signed = PoseidonEdDSA.sign(msgHash, FQ(int(self._loopring_private_key, 16))) - signature = "0x" + "".join([ - hex(int(signed.sig.R.x))[2:].zfill(64), - hex(int(signed.sig.R.y))[2:].zfill(64), - hex(int(signed.sig.s))[2:].zfill(64) - ]) + signature = "0x" + "".join([hex(int(signed.sig.R.x))[2:].zfill(64), + hex(int(signed.sig.R.y))[2:].zfill(64), + hex(int(signed.sig.s))[2:].zfill(64)]) headers.update({"X-API-SIG": signature}) async with self._shared_client.request(http_method, url=full_url, timeout=API_CALL_TIMEOUT, @@ -970,4 +966,4 @@ cdef class LoopringExchange(ExchangeBase): order_side: TradeType, amount: Decimal, price: Decimal = s_decimal_NaN) -> TradeFee: - return self.c_get_fee(base_currency, quote_currency, order_type, order_side, amount, price) \ No newline at end of file + return self.c_get_fee(base_currency, quote_currency, order_type, order_side, amount, price) diff --git a/hummingbot/connector/exchange/loopring/loopring_in_flight_order.pyx b/hummingbot/connector/exchange/loopring/loopring_in_flight_order.pyx index d592a32c66..aea55314f2 100644 --- a/hummingbot/connector/exchange/loopring/loopring_in_flight_order.pyx +++ b/hummingbot/connector/exchange/loopring/loopring_in_flight_order.pyx @@ -167,4 +167,4 @@ cdef class LoopringInFlightOrder(InFlightOrderBase): if self.exchange_order_id is None: self.update_exchange_order_id(data.get('hash', None)) - return events \ No newline at end of file + return events diff --git a/hummingbot/connector/exchange/loopring/loopring_user_stream_tracker.py b/hummingbot/connector/exchange/loopring/loopring_user_stream_tracker.py index a51f0f6696..cadd2449ea 100644 --- a/hummingbot/connector/exchange/loopring/loopring_user_stream_tracker.py +++ b/hummingbot/connector/exchange/loopring/loopring_user_stream_tracker.py @@ -51,4 +51,4 @@ async def start(self): self._user_stream_tracking_task = safe_ensure_future( self.data_source.listen_for_user_stream(self._ev_loop, self._user_stream) ) - await safe_gather(self._user_stream_tracking_task) \ No newline at end of file + await safe_gather(self._user_stream_tracking_task) diff --git a/hummingbot/connector/exchange/loopring/loopring_utils.py b/hummingbot/connector/exchange/loopring/loopring_utils.py index 087b22d71a..3b34eece32 100644 --- a/hummingbot/connector/exchange/loopring/loopring_utils.py +++ b/hummingbot/connector/exchange/loopring/loopring_utils.py @@ -60,4 +60,4 @@ async def get_ws_api_key(): raise IOError(f"Error getting WS key. Server responded with status: {response.status}.") response_dict: Dict[str, Any] = await response.json() - return response_dict['data'] \ No newline at end of file + return response_dict['data'] From 163cb8e53fa46c55923bbb605ab2026cb4b38499 Mon Sep 17 00:00:00 2001 From: Michael Borraccia Date: Thu, 28 Jan 2021 17:07:51 -0500 Subject: [PATCH 081/126] Fix for unused import --- .../connector/exchange/blocktane/blocktane_order_book_tracker.py | 1 - 1 file changed, 1 deletion(-) diff --git a/hummingbot/connector/exchange/blocktane/blocktane_order_book_tracker.py b/hummingbot/connector/exchange/blocktane/blocktane_order_book_tracker.py index 0a856e09d0..beb3d3d66d 100644 --- a/hummingbot/connector/exchange/blocktane/blocktane_order_book_tracker.py +++ b/hummingbot/connector/exchange/blocktane/blocktane_order_book_tracker.py @@ -13,7 +13,6 @@ from hummingbot.logger import HummingbotLogger from hummingbot.core.data_type.order_book_tracker import OrderBookTracker -from hummingbot.core.utils.async_utils import safe_ensure_future from hummingbot.connector.exchange.blocktane.blocktane_api_order_book_data_source import BlocktaneAPIOrderBookDataSource from hummingbot.connector.exchange.blocktane.blocktane_active_order_tracker import BlocktaneActiveOrderTracker from hummingbot.core.data_type.order_book import OrderBook From 22570fb77e4988436b86269d983411115e86defb Mon Sep 17 00:00:00 2001 From: Michael Borraccia Date: Thu, 28 Jan 2021 17:23:29 -0500 Subject: [PATCH 082/126] Add blocktane keys to global config template --- hummingbot/templates/conf_global_TEMPLATE.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/hummingbot/templates/conf_global_TEMPLATE.yml b/hummingbot/templates/conf_global_TEMPLATE.yml index c4c9b4cdfe..ff754bb8df 100644 --- a/hummingbot/templates/conf_global_TEMPLATE.yml +++ b/hummingbot/templates/conf_global_TEMPLATE.yml @@ -24,6 +24,9 @@ binance_perpetual_testnet_api_secret: null bittrex_api_key: null bittrex_secret_key: null +blocktane_api_key: null +blocktane_api_secret: null + coinbase_pro_api_key: null coinbase_pro_secret_key: null coinbase_pro_passphrase: null From 8b265d29563473b9007559cc908c6788115c530a Mon Sep 17 00:00:00 2001 From: Nicolas Baum Date: Thu, 28 Jan 2021 20:05:07 -0300 Subject: [PATCH 083/126] (fix) Fixed bug when applying transformation to already migrated db --- hummingbot/model/db_migration/base_transformation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hummingbot/model/db_migration/base_transformation.py b/hummingbot/model/db_migration/base_transformation.py index 6580e84b62..182e4b4882 100644 --- a/hummingbot/model/db_migration/base_transformation.py +++ b/hummingbot/model/db_migration/base_transformation.py @@ -34,7 +34,7 @@ def does_apply_to_version(self, original_version: int, target_version: int) -> b # from_version > 0 means from_version property was overridden by transformation class if self.from_version > 0: return (self.from_version >= original_version) and (self.to_version <= target_version) - return self.to_version <= target_version + return (self.to_version > original_version) and (self.to_version <= target_version) return False def __eq__(self, other): From abad4a5133c4f06e0a6c035fbdb0a905f00431dd Mon Sep 17 00:00:00 2001 From: Nullably <37262506+Nullably@users.noreply.github.com> Date: Fri, 29 Jan 2021 14:41:26 +0800 Subject: [PATCH 084/126] (feat) add Markets section to status --- .../liquidity_mining/liquidity_mining.py | 38 ++++++++++++++++--- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/hummingbot/strategy/liquidity_mining/liquidity_mining.py b/hummingbot/strategy/liquidity_mining/liquidity_mining.py index 7c2b35a3c3..d78337c84e 100644 --- a/hummingbot/strategy/liquidity_mining/liquidity_mining.py +++ b/hummingbot/strategy/liquidity_mining/liquidity_mining.py @@ -3,6 +3,7 @@ import asyncio from typing import Dict, List, Set import pandas as pd +import numpy as np import time from hummingbot.core.clock import Clock from hummingbot.logger import HummingbotLogger @@ -19,6 +20,7 @@ ) NaN = float("nan") s_decimal_zero = Decimal(0) +s_decimal_nan = Decimal("NaN") lms_logger = None INVENTORY_RANGE_MULTIPLIER = 1 @@ -123,6 +125,27 @@ async def active_orders_df(self) -> pd.DataFrame: return pd.DataFrame(data=data, columns=columns) + def market_status_df(self) -> pd.DataFrame: + data = [] + columns = ["Exchange", "Market", "Mid Price", "Base Balance", "Quote Balance", " Base / Quote %"] + balances = self.adjusted_available_balances() + for market, market_info in self._market_infos.items(): + base, quote = market.split("-") + mid_price = market_info.get_mid_price() + adj_base_bal = balances[base] * mid_price + total_bal = adj_base_bal + balances[quote] + base_pct = adj_base_bal / total_bal if total_bal > 0 else s_decimal_zero + quote_pct = balances[quote] / total_bal if total_bal > 0 else s_decimal_zero + data.append([ + self._exchange.display_name, + market, + float(mid_price), + float(balances[base]), + float(balances[quote]), + f"{f'{base_pct:.0%}':>5s} /{f'{quote_pct:.0%}':>5s}" + ]) + return pd.DataFrame(data=data, columns=columns).replace(np.nan, '', regex=True) + async def format_status(self) -> str: if not self._ready_to_trade: return "Market connectors are not ready." @@ -130,6 +153,9 @@ async def format_status(self) -> str: warning_lines = [] warning_lines.extend(self.network_warning(list(self._market_infos.values()))) + lines.extend(["", " Markets:"] + [" " + line for line in + self.market_status_df().to_string(index=False).split("\n")]) + # See if there're any open orders. if len(self.active_orders) > 0: df = await self.active_orders_df() @@ -318,13 +344,15 @@ def adjusted_available_balances(self) -> Dict[str, Decimal]: return adjusted_bals def apply_inventory_skew(self, proposals: List[Proposal]): - balances = self.adjusted_available_balances() + # balances = self.adjusted_available_balances() for proposal in proposals: + buy_budget = self._buy_budgets[proposal.market] + sell_budget = self._sell_budgets[proposal.market] mid_price = self._market_infos[proposal.market].get_mid_price() total_order_size = proposal.sell.size + proposal.buy.size bid_ask_ratios = calculate_bid_ask_ratios_from_base_asset_ratio( - float(balances[proposal.base()]), - float(balances[proposal.quote()]), + float(sell_budget), + float(buy_budget), float(mid_price), float(self._target_base_pct), float(total_order_size * INVENTORY_RANGE_MULTIPLIER) @@ -346,12 +374,12 @@ def did_fill_order(self, order_filled_event): f"{order_filled_event.amount} {market_info.base_asset} filled." ) self._buy_budgets[market_info.trading_pair] -= (order_filled_event.amount * order_filled_event.price) - self._sell_budgets[market_info.trading_pair] += (order_filled_event.amount) + self._sell_budgets[market_info.trading_pair] += order_filled_event.amount else: 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." ) - self._sell_budgets[market_info.trading_pair] -= (order_filled_event.amount) + self._sell_budgets[market_info.trading_pair] -= order_filled_event.amount self._buy_budgets[market_info.trading_pair] += (order_filled_event.amount * order_filled_event.price) From aa7804f7d9801cd980791e6d05a22acd47d1e1a9 Mon Sep 17 00:00:00 2001 From: Keith O'Neill Date: Fri, 29 Jan 2021 13:16:34 -0500 Subject: [PATCH 085/126] loopring autocomplete for market pair working --- .../exchange/loopring/loopring_api_order_book_data_source.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 77f4e69ebf..0667f8db57 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 @@ -142,7 +142,7 @@ async def fetch_trading_pairs() -> List[str]: if response.status == 200: all_trading_pairs: Dict[str, Any] = await response.json() valid_trading_pairs: list = [] - for item in all_trading_pairs["data"]: + for item in all_trading_pairs["markets"]: valid_trading_pairs.append(item["market"]) trading_pair_list: List[str] = [] for raw_trading_pair in valid_trading_pairs: From 42d7d6060860195175bea2c81eef9be9e7531ef4 Mon Sep 17 00:00:00 2001 From: vic-en Date: Fri, 29 Jan 2021 21:19:25 +0100 Subject: [PATCH 086/126] (feat) general optimization to position management features --- .../perpetual_market_making.pxd | 2 +- .../perpetual_market_making.pyx | 130 +++++++++++------- 2 files changed, 84 insertions(+), 48 deletions(-) diff --git a/hummingbot/strategy/perpetual_market_making/perpetual_market_making.pxd b/hummingbot/strategy/perpetual_market_making/perpetual_market_making.pxd index 5d74b78967..6c7753faef 100644 --- a/hummingbot/strategy/perpetual_market_making/perpetual_market_making.pxd +++ b/hummingbot/strategy/perpetual_market_making/perpetual_market_making.pxd @@ -21,6 +21,7 @@ cdef class PerpetualMarketMakingStrategy(StrategyBase): object _stop_loss_spread object _close_position_order_type object _close_order_type + double _market_position_close_timestamp int _order_levels int _buy_levels int _sell_levels @@ -52,7 +53,6 @@ cdef class PerpetualMarketMakingStrategy(StrategyBase): int _filled_buys_balance int _filled_sells_balance list _hanging_order_ids - list _exit_order_ids double _last_timestamp double _status_report_interval int64_t _logging_options diff --git a/hummingbot/strategy/perpetual_market_making/perpetual_market_making.pyx b/hummingbot/strategy/perpetual_market_making/perpetual_market_making.pyx index 3d874964e9..80be41a9da 100644 --- a/hummingbot/strategy/perpetual_market_making/perpetual_market_making.pyx +++ b/hummingbot/strategy/perpetual_market_making/perpetual_market_making.pyx @@ -148,11 +148,11 @@ cdef class PerpetualMarketMakingStrategy(StrategyBase): self._cancel_timestamp = 0 self._create_timestamp = 0 + self._market_position_close_timestamp = 0 self._all_markets_ready = False self._filled_buys_balance = 0 self._filled_sells_balance = 0 self._hanging_order_ids = [] - self._exit_order_ids = [] self._logging_options = logging_options self._last_timestamp = 0 self._status_report_interval = status_report_interval @@ -357,10 +357,6 @@ cdef class PerpetualMarketMakingStrategy(StrategyBase): def hanging_order_ids(self) -> List[str]: return self._hanging_order_ids - @property - def exit_order_ids(self) -> List[str]: - return self._exit_order_ids - @property def market_info_to_active_orders(self) -> Dict[MarketTradingPairTuple, List[LimitOrder]]: return self._sb_order_tracker.market_pair_to_active_orders @@ -461,14 +457,18 @@ cdef class PerpetualMarketMakingStrategy(StrategyBase): def active_positions_df(self) -> pd.DataFrame: columns = ["Symbol", "Type", "Entry Price", "Amount", "Leverage", "Unrealized PnL"] data = [] + market, trading_pair = self._market_info.market, self._market_info.trading_pair for idx in self.active_positions.values(): + is_buy = True if idx.amount > 0 else False + unrealized_profit = ((market.get_price(trading_pair, is_buy) - idx.entry_price) * idx.amount) if is_buy \ + else ((market.get_price(trading_pair, is_buy) - idx.entry_price) * idx.amount) data.append([ idx.trading_pair, idx.position_side.name, idx.entry_price, idx.amount, idx.leverage, - idx.unrealized_pnl + unrealized_profit ]) return pd.DataFrame(data=data, columns=columns) @@ -586,6 +586,7 @@ cdef class PerpetualMarketMakingStrategy(StrategyBase): f"making may be dangerous when markets or networks are unstable.") if len(session_positions) == 0: + self._exit_orders = [] # Empty list of exit order at this point to reduce size proposal = None asset_mid_price = Decimal("0") # asset_mid_price = self.c_set_mid_price(market_info) @@ -639,6 +640,8 @@ cdef class PerpetualMarketMakingStrategy(StrategyBase): ExchangeBase market = self._market_info.market list active_orders = self.active_orders list unwanted_exit_orders = [o for o in active_orders if o.client_order_id not in self._exit_orders] + ask_price = market.get_price(self.trading_pair, False) + bid_price = market.get_price(self.trading_pair, True) list buys = [] list sells = [] @@ -651,30 +654,33 @@ cdef class PerpetualMarketMakingStrategy(StrategyBase): for order in unwanted_exit_orders: if active_positions[0].amount < 0 and order.is_buy: self.c_cancel_order(self._market_info, order.client_order_id) - self.logger().info(f"Cancelled buy order {order.client_order_id} in favour of take profit order.") + self.logger().info(f"Initiated cancellation of buy order {order.client_order_id} in favour of take profit order.") elif active_positions[0].amount > 0 and not order.is_buy: self.c_cancel_order(self._market_info, order.client_order_id) - self.logger().info(f"Cancelled sell order {order.client_order_id} in favour of take profit order.") + self.logger().info(f"Initiated cancellation of sell order {order.client_order_id} in favour of take profit order.") for position in active_positions: - # 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 - 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) - old_exit_orders = [o for o in active_orders if (o.price > price and position.amount < 0 and o.client_order_id in self._exit_orders) - or (o.price < price and position.amount > 0 and o.client_order_id in self._exit_orders)] - for old_order in old_exit_orders: - self.c_cancel_order(self._market_info, old_order.client_order_id) - self.logger().info(f"Cancelled previous take profit order {old_order.client_order_id} in favour of new take profit order.") - exit_order_exists = [o for o in active_orders if o.price == price] - if len(exit_order_exists) == 0: - size = market.c_quantize_order_amount(self.trading_pair, abs(position.amount)) - if size > 0 and price > 0: - if position.amount < 0: - buys.append(PriceSize(price, size)) - else: - sells.append(PriceSize(price, size)) + 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 + 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) + old_exit_orders = [o for o in active_orders if (o.price != price and position.amount < 0 and o.client_order_id in self._exit_orders and o.is_buy) + or (o.price != price and position.amount > 0 and o.client_order_id in self._exit_orders and not o.is_buy)] + for old_order in old_exit_orders: + self.c_cancel_order(self._market_info, old_order.client_order_id) + self.logger().info(f"Initiated cancellation of previous take profit order {old_order.client_order_id} in favour of new take profit order.") + exit_order_exists = [o for o in active_orders if o.price == price] + if len(exit_order_exists) == 0: + 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.") + buys.append(PriceSize(price, size)) + else: + self.logger().info(f"Creating profit taking sell order to lock profit on short position.") + sells.append(PriceSize(price, size)) return Proposal(buys, sells) cdef c_trailing_stop_feature(self, object mode, list active_positions): @@ -700,10 +706,10 @@ cdef class PerpetualMarketMakingStrategy(StrategyBase): for order in unwanted_exit_orders: if active_positions[0].amount < 0 and order.is_buy: self.c_cancel_order(self._market_info, order.client_order_id) - self.logger().info(f"Cancelled buy order {order.client_order_id} in favour of trailing stop.") + self.logger().info(f"Initiated cancellation of buy order {order.client_order_id} in favour of trailing stop.") elif active_positions[0].amount > 0 and not order.is_buy: self.c_cancel_order(self._market_info, order.client_order_id) - self.logger().info(f"Cancelled sell order {order.client_order_id} in favour of trailing stop.") + self.logger().info(f"Initiated cancellation of sell order {order.client_order_id} in favour of trailing stop.") for position in active_positions: if position.amount == Decimal("0"): @@ -712,6 +718,10 @@ cdef class PerpetualMarketMakingStrategy(StrategyBase): top_ask = market.get_price(self.trading_pair, False) if max(top_ask, self._ts_peak_ask_price) >= (position.entry_price * (Decimal("1") + self._ts_activation_spread)): if top_ask > self._ts_peak_ask_price or self._ts_peak_ask_price == Decimal("0"): + estimated_exit = (top_ask * (Decimal("1") - self._ts_callback_rate)) + estimated_exit = "Nill" if estimated_exit <= position.entry_price else estimated_exit + self.logger().info(f"New {top_ask} {self.quote_asset} peak price on sell order book, estimated exit price" + f" to lock profit is {estimated_exit} {self.quote_asset}.") self._ts_peak_ask_price = top_ask elif top_ask <= (self._ts_peak_ask_price * (Decimal("1") - self._ts_callback_rate)): exit_price = market.get_price_for_volume(self.trading_pair, False, @@ -721,18 +731,25 @@ cdef class PerpetualMarketMakingStrategy(StrategyBase): # Do some checks to prevent duplicating orders to close positions exit_order_exists = [o for o in active_orders if o.client_order_id in self._exit_orders] create_order = True - self._exit_orders = [] if len(exit_order_exists) == 0 else self._exit_orders + # self._exit_orders = [] if len(exit_order_exists) == 0 else self._exit_orders for order in exit_order_exists: if not order.is_buy: create_order = False - if create_order is True: + if create_order is True and price > position.entry_price: + if self._close_position_order_type == OrderType.MARKET and self._current_timestamp <= self._market_position_close_timestamp: + continue + self._market_position_close_timestamp = self._current_timestamp + 10 # 10 seconds delay before attempting to close position with market order sells.append(PriceSize(price, abs(position.amount))) - self.logger().info(f"Trailing stop will Close long position immediately at {price}{self.quote_asset} due deviation " - f"from {self._ts_peak_ask_price} {self.quote_asset} trailing maximum price to secure profit.") + self.logger().info(f"Trailing stop will Close long position immediately at {price}{self.quote_asset} due to {self._ts_callback_rate}%" + f" deviation from {self._ts_peak_ask_price} {self.quote_asset} trailing maximum price to secure profit.") else: top_bid = market.get_price(self.trading_pair, True) if min(top_bid, self._ts_peak_bid_price) <= (position.entry_price * (Decimal("1") - self._ts_activation_spread)): if top_bid < self._ts_peak_bid_price or self._ts_peak_ask_price == Decimal("0"): + estimated_exit = (top_bid * (Decimal("1") + self._ts_callback_rate)) + estimated_exit = "Nill" if estimated_exit >= position.entry_price else estimated_exit + self.logger().info(f"New {top_bid} {self.quote_asset} peak price on buy order book, estimated exit price" + f" to lock profit is {estimated_exit} {self.quote_asset}.") self._ts_peak_bid_price = top_bid elif top_bid >= (self._ts_peak_bid_price * (Decimal("1") + self._ts_callback_rate)): exit_price = market.get_price_for_volume(self.trading_pair, True, @@ -742,14 +759,17 @@ cdef class PerpetualMarketMakingStrategy(StrategyBase): # Do some checks to prevent duplicating orders to close positions exit_order_exists = [o for o in active_orders if o.client_order_id in self._exit_orders] create_order = True - self._exit_orders = [] if len(exit_order_exists) == 0 else self._exit_orders + # self._exit_orders = [] if len(exit_order_exists) == 0 else self._exit_orders for order in exit_order_exists: if order.is_buy: create_order = False - if create_order is True: + if create_order is True and price < position.entry_price: + if self._close_position_order_type == OrderType.MARKET and self._current_timestamp <= self._market_position_close_timestamp: + continue + self._market_position_close_timestamp = self._current_timestamp + 10 # 10 seconds delay before attempting to close position with market order buys.append(PriceSize(price, abs(position.amount))) - self.logger().info(f"Trailing stop will close short position immediately at {price}{self.quote_asset} due deviation " - f"from {self._ts_peak_bid_price}{self.quote_asset} trailing minimum price to secure profit.") + self.logger().info(f"Trailing stop will close short position immediately at {price}{self.quote_asset} due to {self._ts_callback_rate}%" + f" deviation from {self._ts_peak_bid_price}{self.quote_asset} trailing minimum price to secure profit.") return Proposal(buys, sells) cdef c_stop_loss_feature(self, object mode, list active_positions): @@ -766,22 +786,38 @@ cdef class PerpetualMarketMakingStrategy(StrategyBase): # check if stop loss order needs to be placed stop_loss_price = position.entry_price * (Decimal("1") + self._stop_loss_spread) if position.amount < 0 \ else position.entry_price * (Decimal("1") - self._stop_loss_spread) - if (top_ask < stop_loss_price and position.amount > 0) or (top_bid > stop_loss_price and position.amount < 0): + if (top_ask <= stop_loss_price and position.amount > 0): price = market.c_quantize_order_price(self.trading_pair, stop_loss_price) - take_profit_orders = [o for o in active_orders if (o.price < position.entry_price and position.amount < 0 and o.client_order_id in self._exit_orders) - or (o.price > position.entry_price and position.amount > 0 and o.client_order_id in self._exit_orders)] + take_profit_orders = [o for o in active_orders if (o.is_buy and position.amount < 0 and o.client_order_id in self._exit_orders)] # cancel take profit orders if they exist for old_order in take_profit_orders: self.c_cancel_order(self._market_info, old_order.client_order_id) - self.logger().info(f"Cancelled previous take profit order {old_order.client_order_id} in favour of new stop loss order.") - exit_order_exists = [o for o in active_orders if o.price == price] + self.logger().info(f"Initiated cancellation of existing take profit order {old_order.client_order_id} in favour of new stop loss order.") + exit_order_exists = [o for o in active_orders if o.price == price and not o.is_buy] if len(exit_order_exists) == 0: size = market.c_quantize_order_amount(self.trading_pair, abs(position.amount)) if size > 0 and price > 0: - if position.amount < 0: - buys.append(PriceSize(price, size)) - else: - sells.append(PriceSize(price, size)) + if self._close_position_order_type == OrderType.MARKET and self._current_timestamp <= self._market_position_close_timestamp: + continue + self._market_position_close_timestamp = self._current_timestamp + 10 # 10 seconds delay before attempting to close position with market order + self.logger().info(f"Creating stop loss sell order to close long position.") + sells.append(PriceSize(price, size)) + elif (top_bid >= stop_loss_price and position.amount < 0): + price = market.c_quantize_order_price(self.trading_pair, stop_loss_price) + take_profit_orders = [o for o in active_orders if (not o.is_buy and position.amount > 0 and o.client_order_id in self._exit_orders)] + # cancel take profit orders if they exist + for old_order in take_profit_orders: + self.c_cancel_order(self._market_info, old_order.client_order_id) + self.logger().info(f"Initiated cancellation existing take profit order {old_order.client_order_id} in favour of stop loss order.") + exit_order_exists = [o for o in active_orders if o.price == price and o.is_buy] + if len(exit_order_exists) == 0: + size = market.c_quantize_order_amount(self.trading_pair, abs(position.amount)) + if size > 0 and price > 0: + if self._close_position_order_type == OrderType.MARKET and self._current_timestamp <= self._market_position_close_timestamp: + continue + self._market_position_close_timestamp = self._current_timestamp + 10 # 10 seconds delay before attempting to close position with market order + self.logger().info(f"Creating stop loss buy order to close short position.") + buys.append(PriceSize(price, size)) return Proposal(buys, sells) cdef object c_create_base_proposal(self): @@ -1192,7 +1228,7 @@ cdef class PerpetualMarketMakingStrategy(StrategyBase): 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"({self.trading_pair}) Creating {len(proposal.buys)} {self._close_order_type.name} bid orders " f"at (Size, Price): {price_quote_str} to {position_action.name} position." ) for buy in proposal.buys: @@ -1213,7 +1249,7 @@ cdef class PerpetualMarketMakingStrategy(StrategyBase): 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"({self.trading_pair}) Creating {len(proposal.sells)} {self._close_order_type.name} ask " f"orders at (Size, Price): {price_quote_str} to {position_action.name} position." ) for sell in proposal.sells: From 4df7a9effecd9268911a12b9fd6286c00450707e Mon Sep 17 00:00:00 2001 From: vic-en Date: Fri, 29 Jan 2021 21:24:19 +0100 Subject: [PATCH 087/126] (fix) remove redundant conditiony --- .../perpetual_market_making/perpetual_market_making.pyx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/hummingbot/strategy/perpetual_market_making/perpetual_market_making.pyx b/hummingbot/strategy/perpetual_market_making/perpetual_market_making.pyx index 80be41a9da..10ae516d0a 100644 --- a/hummingbot/strategy/perpetual_market_making/perpetual_market_making.pyx +++ b/hummingbot/strategy/perpetual_market_making/perpetual_market_making.pyx @@ -460,8 +460,7 @@ cdef class PerpetualMarketMakingStrategy(StrategyBase): market, trading_pair = self._market_info.market, self._market_info.trading_pair for idx in self.active_positions.values(): is_buy = True if idx.amount > 0 else False - unrealized_profit = ((market.get_price(trading_pair, is_buy) - idx.entry_price) * idx.amount) if is_buy \ - else ((market.get_price(trading_pair, is_buy) - idx.entry_price) * idx.amount) + unrealized_profit = ((market.get_price(trading_pair, is_buy) - idx.entry_price) * idx.amount) data.append([ idx.trading_pair, idx.position_side.name, From 4bdcc38ff3a0f3b0b058361e555c86fbaac5cee4 Mon Sep 17 00:00:00 2001 From: vic-en Date: Sat, 30 Jan 2021 12:38:21 +0100 Subject: [PATCH 088/126] (fix) fix condition to cancel existing tp orders --- .../perpetual_market_making/perpetual_market_making.pyx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/hummingbot/strategy/perpetual_market_making/perpetual_market_making.pyx b/hummingbot/strategy/perpetual_market_making/perpetual_market_making.pyx index 10ae516d0a..18c065d8b2 100644 --- a/hummingbot/strategy/perpetual_market_making/perpetual_market_making.pyx +++ b/hummingbot/strategy/perpetual_market_making/perpetual_market_making.pyx @@ -787,7 +787,7 @@ cdef class PerpetualMarketMakingStrategy(StrategyBase): else position.entry_price * (Decimal("1") - self._stop_loss_spread) if (top_ask <= stop_loss_price and position.amount > 0): price = market.c_quantize_order_price(self.trading_pair, stop_loss_price) - take_profit_orders = [o for o in active_orders if (o.is_buy and position.amount < 0 and o.client_order_id in self._exit_orders)] + take_profit_orders = [o for o in active_orders if (not o.is_buy and o.price > stop_loss_price and o.client_order_id in self._exit_orders)] # cancel take profit orders if they exist for old_order in take_profit_orders: self.c_cancel_order(self._market_info, old_order.client_order_id) @@ -803,7 +803,7 @@ cdef class PerpetualMarketMakingStrategy(StrategyBase): sells.append(PriceSize(price, size)) elif (top_bid >= stop_loss_price and position.amount < 0): price = market.c_quantize_order_price(self.trading_pair, stop_loss_price) - take_profit_orders = [o for o in active_orders if (not o.is_buy and position.amount > 0 and o.client_order_id in self._exit_orders)] + take_profit_orders = [o for o in active_orders if (o.is_buy and o.price < stop_loss_price and o.client_order_id in self._exit_orders)] # cancel take profit orders if they exist for old_order in take_profit_orders: self.c_cancel_order(self._market_info, old_order.client_order_id) From b51690add9ddcde82e0eb106847056d24093bfb9 Mon Sep 17 00:00:00 2001 From: Keith O'Neill Date: Sat, 30 Jan 2021 10:45:30 -0500 Subject: [PATCH 089/126] changed connect key exchange id to exchange address --- .../connector/exchange/loopring/loopring_exchange.pyx | 4 ++-- hummingbot/connector/exchange/loopring/loopring_utils.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/hummingbot/connector/exchange/loopring/loopring_exchange.pyx b/hummingbot/connector/exchange/loopring/loopring_exchange.pyx index db4e7279bf..cde93edc46 100644 --- a/hummingbot/connector/exchange/loopring/loopring_exchange.pyx +++ b/hummingbot/connector/exchange/loopring/loopring_exchange.pyx @@ -145,7 +145,7 @@ cdef class LoopringExchange(ExchangeBase): def __init__(self, loopring_accountid: int, - loopring_exchangeid: str, + loopring_exchangeaddress: str, loopring_private_key: str, loopring_api_key: str, poll_interval: float = 10.0, @@ -182,7 +182,7 @@ cdef class LoopringExchange(ExchangeBase): self._polling_update_task = None self._loopring_accountid = int(loopring_accountid) - self._loopring_exchangeid = loopring_exchangeid + self._loopring_exchangeid = loopring_exchangeaddress self._loopring_private_key = loopring_private_key # State diff --git a/hummingbot/connector/exchange/loopring/loopring_utils.py b/hummingbot/connector/exchange/loopring/loopring_utils.py index 3b34eece32..2a8ec79d40 100644 --- a/hummingbot/connector/exchange/loopring/loopring_utils.py +++ b/hummingbot/connector/exchange/loopring/loopring_utils.py @@ -20,9 +20,9 @@ required_if=using_exchange("loopring"), is_secure=True, is_connect_key=True), - "loopring_exchangeid": - ConfigVar(key="loopring_exchangeid", - prompt="Enter the Loopring exchange id >>> ", + "loopring_exchangeaddress": + ConfigVar(key="loopring_exchangeaddress", + prompt="Enter the Loopring exchange address >>> ", required_if=using_exchange("loopring"), is_secure=True, is_connect_key=True), From e533678992559fd0668497cdfb7f236f1d9e430b Mon Sep 17 00:00:00 2001 From: Nullably <37262506+Nullably@users.noreply.github.com> Date: Mon, 1 Feb 2021 13:23:46 +0800 Subject: [PATCH 090/126] (feat) add spread adjustment on volatility --- .../liquidity_mining/liquidity_mining.py | 70 +++++++++++++------ 1 file changed, 49 insertions(+), 21 deletions(-) diff --git a/hummingbot/strategy/liquidity_mining/liquidity_mining.py b/hummingbot/strategy/liquidity_mining/liquidity_mining.py index d78337c84e..85e04804bc 100644 --- a/hummingbot/strategy/liquidity_mining/liquidity_mining.py +++ b/hummingbot/strategy/liquidity_mining/liquidity_mining.py @@ -1,9 +1,10 @@ from decimal import Decimal import logging import asyncio -from typing import Dict, List, Set +from typing import Dict, List, Set, Optional import pandas as pd import numpy as np +from statistics import mean import time from hummingbot.core.clock import Clock from hummingbot.logger import HummingbotLogger @@ -23,6 +24,8 @@ s_decimal_nan = Decimal("NaN") lms_logger = None INVENTORY_RANGE_MULTIPLIER = 1 +VOLATILITY_INTERVAL = 5 * 60 # 5 minutes interval +VOLATILITY_AVG_PERIOD = 10 class LiquidityMiningStrategy(StrategyPyBase): @@ -66,6 +69,8 @@ def __init__(self, self._token_balances = {} self._sell_budgets = {} self._buy_budgets = {} + self._mid_prices = {market: [] for market in market_infos} + self._last_vol_reported = 0. self.add_markets([exchange]) @@ -88,9 +93,9 @@ def tick(self, timestamp: float): self.logger().info(f"{self._exchange.name} is ready. Trading started.") self.create_budget_allocation() + self.update_mid_prices() proposals = self.create_base_proposals() self._token_balances = self.adjusted_available_balances() - self.apply_volatility_adjustment(proposals) self.apply_inventory_skew(proposals) self.apply_budget_constraint(proposals) self.cancel_active_orders(proposals) @@ -176,23 +181,21 @@ def stop(self, clock: Clock): def create_base_proposals(self): proposals = [] + volatility = self.calculate_volatility() for market, market_info in self._market_infos.items(): + spread = self._spread + if volatility.get(market) is not None: + spread = max(spread, volatility[market]) mid_price = market_info.get_mid_price() - buy_price = mid_price * (Decimal("1") - self._spread) + buy_price = mid_price * (Decimal("1") - spread) buy_price = self._exchange.quantize_order_price(market, buy_price) buy_size = self.base_order_size(market, buy_price) - sell_price = mid_price * (Decimal("1") + self._spread) + sell_price = mid_price * (Decimal("1") + spread) sell_price = self._exchange.quantize_order_price(market, sell_price) sell_size = self.base_order_size(market, sell_price) proposals.append(Proposal(market, PriceSize(buy_price, buy_size), PriceSize(sell_price, sell_size))) return proposals - def apply_volatility_adjustment(self, proposals): - # Todo: apply volatility spread adjustment, the volatility can be calculated from OHLC - # (maybe ATR or hourly candle?) or from the mid price movement (see - # scripts/spreads_adjusted_on_volatility_script.py). - return - def create_budget_allocation(self): # Equally assign buy and sell budgets to all markets self._sell_budgets = {m: s_decimal_zero for m in self._market_infos} @@ -281,9 +284,12 @@ def execute_orders_proposal(self, proposals: List[Proposal]): cur_orders = [o for o in self.active_orders if o.trading_pair == proposal.market] if cur_orders or self._refresh_times[proposal.market] > self.current_timestamp: continue + mid_price = self._market_infos[proposal.market].get_mid_price() if proposal.buy.size > 0: + spread = abs(proposal.buy.price - mid_price) / mid_price self.logger().info(f"({proposal.market}) Creating a bid order {proposal.buy} value: " - f"{proposal.buy.size * proposal.buy.price:.2f} {proposal.quote()}") + f"{proposal.buy.size * proposal.buy.price:.2f} {proposal.quote()} spread: " + f"{spread:.2%}") self.buy_with_specific_market( self._market_infos[proposal.market], proposal.buy.size, @@ -291,7 +297,10 @@ def execute_orders_proposal(self, proposals: List[Proposal]): price=proposal.buy.price ) if proposal.sell.size > 0: - self.logger().info(f"({proposal.market}) Creating an ask order at {proposal.sell}") + spread = abs(proposal.sell.price - mid_price) / mid_price + self.logger().info(f"({proposal.market}) Creating an ask order at {proposal.sell} value: " + f"{proposal.sell.size * proposal.sell.price:.2f} {proposal.quote()} spread: " + f"{spread:.2%}") self.sell_with_specific_market( self._market_infos[proposal.market], proposal.sell.size, @@ -336,15 +345,9 @@ def adjusted_available_balances(self) -> Dict[str, Decimal]: adjusted_bals[quote] += order.quantity * order.price else: adjusted_bals[base] += order.quantity - # for token in tokens: - # adjusted_bals[token] = min(adjusted_bals[token], total_bals[token]) - # reserved = self._reserved_balances.get(token, s_decimal_zero) - # adjusted_bals[token] -= reserved - # self.logger().info(f"token balances: {adjusted_bals}") return adjusted_bals def apply_inventory_skew(self, proposals: List[Proposal]): - # balances = self.adjusted_available_balances() for proposal in proposals: buy_budget = self._buy_budgets[proposal.market] sell_budget = self._sell_budgets[proposal.market] @@ -358,10 +361,7 @@ def apply_inventory_skew(self, proposals: List[Proposal]): float(total_order_size * INVENTORY_RANGE_MULTIPLIER) ) proposal.buy.size *= Decimal(bid_ask_ratios.bid_ratio) - # proposal.buy.size = self._exchange.quantize_order_amount(proposal.market, proposal.buy.size) - proposal.sell.size *= Decimal(bid_ask_ratios.ask_ratio) - # proposal.sell.size = self._exchange.quantize_order_amount(proposal.market, proposal.sell.size) def did_fill_order(self, order_filled_event): order_id = order_filled_event.order_id @@ -383,3 +383,31 @@ def did_fill_order(self, order_filled_event): ) self._sell_budgets[market_info.trading_pair] -= order_filled_event.amount self._buy_budgets[market_info.trading_pair] += (order_filled_event.amount * order_filled_event.price) + + def update_mid_prices(self): + for market in self._market_infos: + mid_price = self._market_infos[market].get_mid_price() + self._mid_prices[market].append(mid_price) + # To avoid memory leak, we store only the last part of the list needed for volatility calculation + self._mid_prices[market] = self._mid_prices[market][-1 * VOLATILITY_INTERVAL * VOLATILITY_AVG_PERIOD:] + + def calculate_volatility(self) -> Dict[str, Optional[Decimal]]: + volatility = {market: None for market in self._market_infos} + for market, mid_prices in self._mid_prices.items(): + last_index = len(mid_prices) - 1 + atr = [] + first_index = last_index - (VOLATILITY_INTERVAL * VOLATILITY_AVG_PERIOD) + first_index = max(first_index, 0) + for i in range(last_index, first_index, VOLATILITY_INTERVAL * -1): + prices = mid_prices[i - VOLATILITY_INTERVAL + 1: i + 1] + if not prices: + break + atr.append((max(prices) - min(prices)) / min(prices)) + if atr: + volatility[market] = mean(atr) + if self._last_vol_reported < self.current_timestamp - 60.: + for market, vol in volatility.items(): + if vol is not None: + self.logger().info(f"{market} volatility: {vol:.2%}") + self._last_vol_reported = self.current_timestamp + return volatility From a99f15f64be1f4f00af1f58e0fe74be740aadc8e Mon Sep 17 00:00:00 2001 From: Nullably <37262506+Nullably@users.noreply.github.com> Date: Mon, 1 Feb 2021 15:13:22 +0800 Subject: [PATCH 091/126] (feat) add default configs --- hummingbot/client/config/config_validators.py | 14 +++---- .../liquidity_mining/liquidity_mining.py | 24 ++++++++---- .../liquidity_mining_config_map.py | 37 ++++++++++++++++--- hummingbot/strategy/liquidity_mining/start.py | 10 ++++- ...onf_liquidity_mining_strategy_TEMPLATE.yml | 22 ++++++++++- 5 files changed, 84 insertions(+), 23 deletions(-) diff --git a/hummingbot/client/config/config_validators.py b/hummingbot/client/config/config_validators.py index f8c98b7328..f71e010012 100644 --- a/hummingbot/client/config/config_validators.py +++ b/hummingbot/client/config/config_validators.py @@ -69,24 +69,24 @@ def validate_bool(value: str) -> Optional[str]: return f"Invalid value, please choose value from {valid_values}" -def validate_int(value: str, min_value: Decimal = None, max_value: Decimal = None, inclusive=True) -> Optional[str]: +def validate_int(value: str, min_value: int = None, max_value: int = None, inclusive=True) -> Optional[str]: try: int_value = int(value) except Exception: return f"{value} is not in integer format." if inclusive: if min_value is not None and max_value is not None: - if not (int(str(min_value)) <= int_value <= int(str(max_value))): + if not (min_value <= int_value <= max_value): return f"Value must be between {min_value} and {max_value}." - elif min_value is not None and not int_value >= int(str(min_value)): + elif min_value is not None and not int_value >= min_value: return f"Value cannot be less than {min_value}." - elif max_value is not None and not int_value <= int(str(max_value)): + elif max_value is not None and not int_value <= max_value: return f"Value cannot be more than {max_value}." else: if min_value is not None and max_value is not None: - if not (int(str(min_value)) < int_value < int(str(max_value))): + if not (min_value < int_value < max_value): return f"Value must be between {min_value} and {max_value} (exclusive)." - elif min_value is not None and not int_value > int(str(min_value)): + elif min_value is not None and not int_value > min_value: return f"Value must be more than {min_value}." - elif max_value is not None and not int_value < int(str(max_value)): + elif max_value is not None and not int_value < max_value: return f"Value must be less than {max_value}." diff --git a/hummingbot/strategy/liquidity_mining/liquidity_mining.py b/hummingbot/strategy/liquidity_mining/liquidity_mining.py index 85e04804bc..e80a0cb326 100644 --- a/hummingbot/strategy/liquidity_mining/liquidity_mining.py +++ b/hummingbot/strategy/liquidity_mining/liquidity_mining.py @@ -23,8 +23,6 @@ s_decimal_zero = Decimal(0) s_decimal_nan = Decimal("NaN") lms_logger = None -INVENTORY_RANGE_MULTIPLIER = 1 -VOLATILITY_INTERVAL = 5 * 60 # 5 minutes interval VOLATILITY_AVG_PERIOD = 10 @@ -51,6 +49,10 @@ def __init__(self, target_base_pct: Decimal, order_refresh_time: float, order_refresh_tolerance_pct: Decimal, + inventory_range_multiplier: Decimal = Decimal("1"), + volatility_interval: int = 60 * 5, + avg_volatility_period: int = 10, + volatility_to_spread_multiplier: Decimal = Decimal("1"), status_report_interval: float = 900): super().__init__() self._exchange = exchange @@ -61,6 +63,10 @@ def __init__(self, self._order_refresh_time = order_refresh_time self._order_refresh_tolerance_pct = order_refresh_tolerance_pct self._target_base_pct = target_base_pct + self._inventory_range_multiplier = inventory_range_multiplier + self._volatility_interval = volatility_interval + self._avg_volatility_period = avg_volatility_period + self._volatility_to_spread_multiplier = volatility_to_spread_multiplier self._ev_loop = asyncio.get_event_loop() self._last_timestamp = 0 self._status_report_interval = status_report_interval @@ -185,7 +191,8 @@ def create_base_proposals(self): for market, market_info in self._market_infos.items(): spread = self._spread if volatility.get(market) is not None: - spread = max(spread, volatility[market]) + # volatility applies only when it is higher than the spread setting. + spread = max(spread, volatility[market] * self._volatility_to_spread_multiplier) mid_price = market_info.get_mid_price() buy_price = mid_price * (Decimal("1") - spread) buy_price = self._exchange.quantize_order_price(market, buy_price) @@ -358,7 +365,7 @@ def apply_inventory_skew(self, proposals: List[Proposal]): float(buy_budget), float(mid_price), float(self._target_base_pct), - float(total_order_size * INVENTORY_RANGE_MULTIPLIER) + float(total_order_size * self._inventory_range_multiplier) ) proposal.buy.size *= Decimal(bid_ask_ratios.bid_ratio) proposal.sell.size *= Decimal(bid_ask_ratios.ask_ratio) @@ -389,17 +396,18 @@ def update_mid_prices(self): mid_price = self._market_infos[market].get_mid_price() self._mid_prices[market].append(mid_price) # To avoid memory leak, we store only the last part of the list needed for volatility calculation - self._mid_prices[market] = self._mid_prices[market][-1 * VOLATILITY_INTERVAL * VOLATILITY_AVG_PERIOD:] + max_len = self._volatility_interval * self._avg_volatility_period + self._mid_prices[market] = self._mid_prices[market][-1 * max_len:] def calculate_volatility(self) -> Dict[str, Optional[Decimal]]: volatility = {market: None for market in self._market_infos} for market, mid_prices in self._mid_prices.items(): last_index = len(mid_prices) - 1 atr = [] - first_index = last_index - (VOLATILITY_INTERVAL * VOLATILITY_AVG_PERIOD) + first_index = last_index - (self._volatility_interval * self._avg_volatility_period) first_index = max(first_index, 0) - for i in range(last_index, first_index, VOLATILITY_INTERVAL * -1): - prices = mid_prices[i - VOLATILITY_INTERVAL + 1: i + 1] + for i in range(last_index, first_index, self._volatility_interval * -1): + prices = mid_prices[i - self._volatility_interval + 1: i + 1] if not prices: break atr.append((max(prices) - min(prices)) / min(prices)) diff --git a/hummingbot/strategy/liquidity_mining/liquidity_mining_config_map.py b/hummingbot/strategy/liquidity_mining/liquidity_mining_config_map.py index 98d75db281..726c78675a 100644 --- a/hummingbot/strategy/liquidity_mining/liquidity_mining_config_map.py +++ b/hummingbot/strategy/liquidity_mining/liquidity_mining_config_map.py @@ -3,7 +3,8 @@ from hummingbot.client.config.config_var import ConfigVar from hummingbot.client.config.config_validators import ( validate_exchange, - validate_decimal + validate_decimal, + validate_int ) from hummingbot.client.settings import ( required_exchanges, @@ -72,14 +73,14 @@ def order_size_prompt() -> str: prompt_on_new=True), "spread": ConfigVar(key="spread", - prompt="How far away from the mid price do you want to place bid order and ask order? " + prompt="How far away from the mid price do you want to place bid and ask orders? " "(Enter 1 to indicate 1%) >>> ", type_str="decimal", validator=lambda v: validate_decimal(v, 0, 100, inclusive=False), prompt_on_new=True), "target_base_pct": ConfigVar(key="target_base_pct", - prompt=" For each pair, what is your target base asset percentage? (Enter 1 to indicate 1%) >>> ", + prompt="For each pair, what is your target base asset percentage? (Enter 20 to indicate 20%) >>> ", type_str="decimal", validator=lambda v: validate_decimal(v, 0, 100, inclusive=False), prompt_on_new=True), @@ -89,7 +90,7 @@ def order_size_prompt() -> str: "(in seconds)? >>> ", type_str="float", validator=lambda v: validate_decimal(v, 0, inclusive=False), - default=5.), + default=10.), "order_refresh_tolerance_pct": ConfigVar(key="order_refresh_tolerance_pct", prompt="Enter the percent change in price needed to refresh orders at each cycle " @@ -97,5 +98,31 @@ def order_size_prompt() -> str: type_str="decimal", default=Decimal("0.2"), validator=lambda v: validate_decimal(v, -10, 10, inclusive=True)), - + "inventory_range_multiplier": + ConfigVar(key="inventory_range_multiplier", + prompt="What is your tolerable range of inventory around the target, " + "expressed in multiples of your total order size? ", + type_str="decimal", + validator=lambda v: validate_decimal(v, min_value=0, inclusive=False), + default=Decimal("1")), + "volatility_interval": + ConfigVar(key="volatility_interval", + prompt="What is an interval, in second, in which to pick historical mid price data from to calculate " + "market volatility? >>> ", + type_str="int", + validator=lambda v: validate_int(v, min_value=1, inclusive=False), + default=60 * 5), + "avg_volatility_period": + ConfigVar(key="avg_volatility_period", + prompt="How many interval does it take to calculate average market volatility? >>> ", + type_str="int", + validator=lambda v: validate_int(v, min_value=1, inclusive=False), + default=10), + "volatility_to_spread_multiplier": + ConfigVar(key="volatility_to_spread_multiplier", + prompt="Enter a multiplier used to convert average volatility to spread " + "(enter 1 for 1 to 1 conversion) >>> ", + type_str="decimal", + validator=lambda v: validate_decimal(v, min_value=0, inclusive=False), + default=Decimal("1")), } diff --git a/hummingbot/strategy/liquidity_mining/start.py b/hummingbot/strategy/liquidity_mining/start.py index e02a0d3673..085b54cf38 100644 --- a/hummingbot/strategy/liquidity_mining/start.py +++ b/hummingbot/strategy/liquidity_mining/start.py @@ -13,6 +13,10 @@ def start(self): target_base_pct = c_map.get("target_base_pct").value / Decimal("100") order_refresh_time = c_map.get("order_refresh_time").value order_refresh_tolerance_pct = c_map.get("order_refresh_tolerance_pct").value / Decimal("100") + inventory_range_multiplier = c_map.get("inventory_range_multiplier").value + volatility_interval = c_map.get("volatility_interval").value + avg_volatility_period = c_map.get("avg_volatility_period").value + volatility_to_spread_multiplier = c_map.get("volatility_to_spread_multiplier").value self._initialize_markets([(exchange, markets)]) exchange = self.markets[exchange] @@ -28,5 +32,9 @@ def start(self): spread=spread, target_base_pct=target_base_pct, order_refresh_time=order_refresh_time, - order_refresh_tolerance_pct=order_refresh_tolerance_pct + order_refresh_tolerance_pct=order_refresh_tolerance_pct, + inventory_range_multiplier=inventory_range_multiplier, + volatility_interval=volatility_interval, + avg_volatility_period=avg_volatility_period, + volatility_to_spread_multiplier=volatility_to_spread_multiplier ) diff --git a/hummingbot/templates/conf_liquidity_mining_strategy_TEMPLATE.yml b/hummingbot/templates/conf_liquidity_mining_strategy_TEMPLATE.yml index 1db3888874..15282712da 100644 --- a/hummingbot/templates/conf_liquidity_mining_strategy_TEMPLATE.yml +++ b/hummingbot/templates/conf_liquidity_mining_strategy_TEMPLATE.yml @@ -5,20 +5,25 @@ template_version: 1 strategy: null -# Exchange and token parameters. +# The exchange to run this strategy. exchange: null +# The list of eligible markets, comma separated, e.g. LTC-USDT,ETH-USDT eligible_markets: null +# The actual markets to run liquidity mining, this is auto populated based on eligible_markets and token markets: null +# The asset (base or quote) to use to provide liquidity token: null +# The size of each order in specified token amount order_size: null +# The spread from mid price to place bid and ask orders, enter 1 to indicate 1% spread: null -# For each pair, what is the target base asset pct? +# The target base asset percentage for all markets, enter 50 to indicate 50% target target_base_pct: null # Time in seconds before cancelling and placing new orders. @@ -29,6 +34,19 @@ order_refresh_time: null # (Enter 1 to indicate 1%), value below 0, e.g. -1, is to disable this feature - not recommended. order_refresh_tolerance_pct: null +# The range around the inventory target base percent to maintain, expressed in multiples of total order size (for +# inventory skew feature). +inventory_range_multiplier: null + +# The interval, in second, in which to pick historical mid price data from to calculate market volatility +# E.g 300 for 5 minutes interval +volatility_interval: null + +# The number of interval to calculate average market volatility. +avg_volatility_period: null + +# The multiplier used to convert average volatility to spread, enter 1 for 1 to 1 conversion +volatility_to_spread_multiplier: null # For more detailed information, see: # https://docs.hummingbot.io/strategies/liquidity-mining/#configuration-parameters From f73df35eede5d6e45e4f52da384d9bbf446f09c8 Mon Sep 17 00:00:00 2001 From: Nullably <37262506+Nullably@users.noreply.github.com> Date: Mon, 1 Feb 2021 15:26:55 +0800 Subject: [PATCH 092/126] (feat) minor formatting --- .../liquidity_mining/liquidity_mining.py | 26 ------------------- 1 file changed, 26 deletions(-) diff --git a/hummingbot/strategy/liquidity_mining/liquidity_mining.py b/hummingbot/strategy/liquidity_mining/liquidity_mining.py index e80a0cb326..1f4dfd577e 100644 --- a/hummingbot/strategy/liquidity_mining/liquidity_mining.py +++ b/hummingbot/strategy/liquidity_mining/liquidity_mining.py @@ -27,11 +27,6 @@ class LiquidityMiningStrategy(StrategyPyBase): - """ - This is a basic arbitrage strategy which can be used for most types of connectors (CEX, DEX or AMM). - For a given order amount, the strategy checks both sides of the trade (market_1 and market_2) for arb opportunity. - If presents, the strategy submits taker orders to both market. - """ @classmethod def logger(cls) -> HummingbotLogger: @@ -107,9 +102,6 @@ def tick(self, timestamp: float): self.cancel_active_orders(proposals) self.execute_orders_proposal(proposals) - # if self.c_to_create_orders(proposal): - # self.c_execute_orders_proposal(proposal) - self._last_timestamp = timestamp async def active_orders_df(self) -> pd.DataFrame: @@ -220,24 +212,6 @@ def create_budget_allocation(self): self._buy_budgets[market] = buy_size self._sell_budgets[market] = self._exchange.get_available_balance(market.split("-")[0]) - def assign_balanced_budgets(self): - # Equally assign buy and sell budgets to all markets - base_tokens = self.all_base_tokens() - self._sell_budgets = {m: s_decimal_zero for m in self._market_infos} - for base in base_tokens: - base_markets = [m for m in self._market_infos if m.split("-")[0] == base] - sell_size = self._exchange.get_available_balance(base) / len(base_markets) - for market in base_markets: - self._sell_budgets[market] = sell_size - # Then assign all the buy order size based on the quote token balance available - quote_tokens = self.all_quote_tokens() - self._buy_budgets = {m: s_decimal_zero for m in self._market_infos} - for quote in quote_tokens: - quote_markets = [m for m in self._market_infos if m.split("-")[1] == quote] - buy_size = self._exchange.get_available_balance(quote) / len(quote_markets) - for market in quote_markets: - self._buy_budgets[market] = buy_size - def base_order_size(self, trading_pair: str, price: Decimal = s_decimal_zero): base, quote = trading_pair.split("-") if self._token == base: From 8616fe74641b879c14e4427a0a004423a761cf2e Mon Sep 17 00:00:00 2001 From: Nullably <37262506+Nullably@users.noreply.github.com> Date: Mon, 1 Feb 2021 15:33:56 +0800 Subject: [PATCH 093/126] (feat) minor refactoring --- hummingbot/client/command/config_command.py | 25 --------------------- hummingbot/strategy/strategy_py_base.pyx | 1 - 2 files changed, 26 deletions(-) diff --git a/hummingbot/client/command/config_command.py b/hummingbot/client/command/config_command.py index 4486c170b6..6736fc4458 100644 --- a/hummingbot/client/command/config_command.py +++ b/hummingbot/client/command/config_command.py @@ -3,7 +3,6 @@ List, Any, ) -from collections import OrderedDict from decimal import Decimal import pandas as pd from os.path import join @@ -26,7 +25,6 @@ from hummingbot.strategy.perpetual_market_making import ( PerpetualMarketMakingStrategy ) -from hummingbot.strategy.liquidity_mining.liquidity_mining_config_map import liquidity_mining_config_map from hummingbot.user.user_balances import UserBalances from typing import TYPE_CHECKING if TYPE_CHECKING: @@ -221,26 +219,3 @@ async def asset_ratio_maintenance_prompt(self, # type: HummingbotApplication self.app.to_stop_config = False return await self.prompt_a_config(config_map["inventory_target_base_pct"]) - - async def lm_target_base_pct_prompt(self, # type: HummingbotApplication - input_value=None): - config_map = liquidity_mining_config_map - if input_value: - config_map['target_base_pct'].value = input_value - else: - markets = config_map["markets"].value - markets = list(markets.split(",")) - target_base_pcts = OrderedDict() - for market in markets: - cvar = ConfigVar(key="temp_config", - prompt=f"For {market} market, what is the target base asset pct? >>> ", - required_if=lambda: True, - type_str="float") - await self.prompt_a_config(cvar) - if cvar.value: - target_base_pcts[market] = cvar.value - else: - if self.app.to_stop_config: - self.app.to_stop_config = False - return - config_map['target_base_pct'].value = target_base_pcts diff --git a/hummingbot/strategy/strategy_py_base.pyx b/hummingbot/strategy/strategy_py_base.pyx index e738588f2c..6d846e5eab 100644 --- a/hummingbot/strategy/strategy_py_base.pyx +++ b/hummingbot/strategy/strategy_py_base.pyx @@ -55,7 +55,6 @@ cdef class StrategyPyBase(StrategyBase): pass cdef c_did_fill_order(self, object order_filled_event): - print(f"c_did_fill_order {order_filled_event}") self.did_fill_order(order_filled_event) def did_fill_order(self, order_filled_event): From 0001dd6fb0bf8678fb927ef9aea142e553cf3249 Mon Sep 17 00:00:00 2001 From: Nullably <37262506+Nullably@users.noreply.github.com> Date: Mon, 1 Feb 2021 15:36:33 +0800 Subject: [PATCH 094/126] (feat) add dummy cython files --- hummingbot/strategy/liquidity_mining/dummy.pxd | 2 ++ hummingbot/strategy/liquidity_mining/dummy.pyx | 2 ++ 2 files changed, 4 insertions(+) create mode 100644 hummingbot/strategy/liquidity_mining/dummy.pxd create mode 100644 hummingbot/strategy/liquidity_mining/dummy.pyx diff --git a/hummingbot/strategy/liquidity_mining/dummy.pxd b/hummingbot/strategy/liquidity_mining/dummy.pxd new file mode 100644 index 0000000000..4b098d6f59 --- /dev/null +++ b/hummingbot/strategy/liquidity_mining/dummy.pxd @@ -0,0 +1,2 @@ +cdef class dummy(): + pass diff --git a/hummingbot/strategy/liquidity_mining/dummy.pyx b/hummingbot/strategy/liquidity_mining/dummy.pyx new file mode 100644 index 0000000000..4b098d6f59 --- /dev/null +++ b/hummingbot/strategy/liquidity_mining/dummy.pyx @@ -0,0 +1,2 @@ +cdef class dummy(): + pass From 916021f74ece6fa52ae983e9bd4b80851ea53e77 Mon Sep 17 00:00:00 2001 From: vic-en Date: Mon, 1 Feb 2021 10:19:26 +0100 Subject: [PATCH 095/126] (fix) used quantized price to determine orders to be cancelled by SL feature --- .../perpetual_market_making/perpetual_market_making.pyx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/hummingbot/strategy/perpetual_market_making/perpetual_market_making.pyx b/hummingbot/strategy/perpetual_market_making/perpetual_market_making.pyx index 18c065d8b2..5eea14483d 100644 --- a/hummingbot/strategy/perpetual_market_making/perpetual_market_making.pyx +++ b/hummingbot/strategy/perpetual_market_making/perpetual_market_making.pyx @@ -787,7 +787,7 @@ cdef class PerpetualMarketMakingStrategy(StrategyBase): else position.entry_price * (Decimal("1") - self._stop_loss_spread) if (top_ask <= stop_loss_price and position.amount > 0): price = market.c_quantize_order_price(self.trading_pair, stop_loss_price) - take_profit_orders = [o for o in active_orders if (not o.is_buy and o.price > stop_loss_price and o.client_order_id in self._exit_orders)] + take_profit_orders = [o for o in active_orders if (not o.is_buy and o.price > price and o.client_order_id in self._exit_orders)] # cancel take profit orders if they exist for old_order in take_profit_orders: self.c_cancel_order(self._market_info, old_order.client_order_id) @@ -803,7 +803,7 @@ cdef class PerpetualMarketMakingStrategy(StrategyBase): sells.append(PriceSize(price, size)) elif (top_bid >= stop_loss_price and position.amount < 0): price = market.c_quantize_order_price(self.trading_pair, stop_loss_price) - take_profit_orders = [o for o in active_orders if (o.is_buy and o.price < stop_loss_price and o.client_order_id in self._exit_orders)] + take_profit_orders = [o for o in active_orders if (o.is_buy and o.price < price and o.client_order_id in self._exit_orders)] # cancel take profit orders if they exist for old_order in take_profit_orders: self.c_cancel_order(self._market_info, old_order.client_order_id) From 39a0e695e8a6478e385f5c9c5252720cd45f6aea Mon Sep 17 00:00:00 2001 From: Daniel Tan Date: Mon, 1 Feb 2021 19:42:33 +0800 Subject: [PATCH 096/126] (fix) missing trading pairs in Bitfinex --- .../bitfinex/bitfinex_api_order_book_data_source.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) 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 a94d92bb9d..75754b942a 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 @@ -107,20 +107,16 @@ async def fetch_trading_pairs() -> List[str]: async with client.get("https://api-pub.bitfinex.com/v2/conf/pub:list:pair:exchange", timeout=10) as response: if response.status == 200: data = await response.json() - raw_trading_pairs: List[Dict[str, any]] = list((filter( - lambda trading_pair: True if ":" not in trading_pair else False, - data[0] - ))) trading_pair_list: List[str] = [] - for raw_trading_pair in raw_trading_pairs: + for trading_pair in data[0]: # change the following line accordingly converted_trading_pair: Optional[str] = \ - convert_from_exchange_trading_pair(raw_trading_pair) + convert_from_exchange_trading_pair(trading_pair) if converted_trading_pair is not None: trading_pair_list.append(converted_trading_pair) else: logging.getLogger(__name__).info(f"Could not parse the trading pair " - f"{raw_trading_pair}, skipping it...") + f"{trading_pair}, skipping it...") return trading_pair_list except Exception: # Do nothing if the request fails -- there will be no autocomplete available From 5d50c72a9cbdfb1276cbb2464b59c7a78dc8d371 Mon Sep 17 00:00:00 2001 From: Daniel Tan Date: Mon, 1 Feb 2021 21:48:54 +0800 Subject: [PATCH 097/126] (refactor) rename and move TradeFill_order_details into utils.py --- hummingbot/connector/connector_base.pyx | 6 +++--- hummingbot/connector/markets_recorder.py | 12 +++++------- hummingbot/connector/utils.py | 4 ++++ 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/hummingbot/connector/connector_base.pyx b/hummingbot/connector/connector_base.pyx index 191c105a9c..fee2bfba9d 100644 --- a/hummingbot/connector/connector_base.pyx +++ b/hummingbot/connector/connector_base.pyx @@ -14,7 +14,7 @@ from hummingbot.core.event.events import ( from hummingbot.core.event.event_logger import EventLogger from hummingbot.core.network_iterator import NetworkIterator from hummingbot.connector.in_flight_order_base import InFlightOrderBase -from hummingbot.connector.markets_recorder import TradeFill_order_details +from hummingbot.connector.utils import TradeFillOrderDetails from hummingbot.core.event.events import OrderFilledEvent from hummingbot.client.config.global_config_map import global_config_map from hummingbot.core.utils.estimate_fee import estimate_fee @@ -417,7 +417,7 @@ cdef class ConnectorBase(NetworkIterator): def available_balances(self) -> Dict[str, Decimal]: return self._account_available_balances - def add_trade_fills_from_market_recorder(self, current_trade_fills: Set[TradeFill_order_details]): + def add_trade_fills_from_market_recorder(self, current_trade_fills: Set[TradeFillOrderDetails]): """ Gets updates from new records in TradeFill table. This is used in method is_confirmed_new_order_filled_event """ @@ -435,5 +435,5 @@ cdef class ConnectorBase(NetworkIterator): This is intended to avoid duplicated order fills in local DB. """ # Assume (market, exchange_trade_id, trading_pair) are unique. Also order has to be recorded in Order table - return (not TradeFill_order_details(self.display_name, exchange_trade_id, trading_pair) in self._current_trade_fills) and \ + return (not TradeFillOrderDetails(self.display_name, exchange_trade_id, trading_pair) in self._current_trade_fills) and \ (exchange_order_id in self._exchange_order_ids) diff --git a/hummingbot/connector/markets_recorder.py b/hummingbot/connector/markets_recorder.py index d6dd5c4711..8c0e027341 100644 --- a/hummingbot/connector/markets_recorder.py +++ b/hummingbot/connector/markets_recorder.py @@ -9,7 +9,6 @@ ) import time import threading -from collections import namedtuple from typing import ( Dict, List, @@ -33,14 +32,13 @@ ) from hummingbot.core.event.event_forwarder import SourceInfoEventForwarder from hummingbot.connector.connector_base import ConnectorBase +from hummingbot.connector.utils import TradeFillOrderDetails from hummingbot.model.market_state import MarketState from hummingbot.model.order import Order from hummingbot.model.order_status import OrderStatus from hummingbot.model.sql_connection_manager import SQLConnectionManager from hummingbot.model.trade_fill import TradeFill -TradeFill_order_details = namedtuple("TradeFill_order_details", "market exchange_trade_id symbol") - class MarketsRecorder: market_event_tag_map: Dict[int, MarketEvent] = { @@ -64,9 +62,9 @@ def __init__(self, # Internal collection of trade fills in connector will be used for remote/local history reconciliation for market in self._markets: trade_fills = self.get_trades_for_config(self._config_file_path, 2000) - market.add_trade_fills_from_market_recorder({TradeFill_order_details(tf.market, - tf.exchange_trade_id, - tf.symbol) for tf in trade_fills}) + market.add_trade_fills_from_market_recorder({TradeFillOrderDetails(tf.market, + tf.exchange_trade_id, + tf.symbol) for tf in trade_fills}) exchange_order_ids = self.get_orders_for_config_and_market(self._config_file_path, market, True, 2000) market.add_exchange_order_ids_from_market_recorder({o.exchange_order_id for o in exchange_order_ids}) @@ -262,7 +260,7 @@ def _did_fill_order(self, session.add(trade_fill_record) self.save_market_states(self._config_file_path, market, no_commit=True) session.commit() - market.add_trade_fills_from_market_recorder({TradeFill_order_details(trade_fill_record.market, trade_fill_record.exchange_trade_id, trade_fill_record.symbol)}) + market.add_trade_fills_from_market_recorder({TradeFillOrderDetails(trade_fill_record.market, trade_fill_record.exchange_trade_id, trade_fill_record.symbol)}) self.append_to_csv(trade_fill_record) @staticmethod diff --git a/hummingbot/connector/utils.py b/hummingbot/connector/utils.py index 22872e032e..265e4807d1 100644 --- a/hummingbot/connector/utils.py +++ b/hummingbot/connector/utils.py @@ -1,6 +1,7 @@ #!/usr/bin/env python import base64 +from collections import namedtuple from typing import ( Dict, Optional @@ -8,6 +9,9 @@ from zero_ex.order_utils import Order as ZeroExOrder +TradeFillOrderDetails = namedtuple("TradeFillOrderDetails", "market exchange_trade_id symbol") + + def zrx_order_to_json(order: Optional[ZeroExOrder]) -> Optional[Dict[str, any]]: if order is None: return None From 3dbd384afd548933f286b97689be1bed8c839a81 Mon Sep 17 00:00:00 2001 From: Keith O'Neill Date: Mon, 1 Feb 2021 12:18:01 -0500 Subject: [PATCH 098/126] changed loopring config var in global config template --- hummingbot/templates/conf_global_TEMPLATE.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hummingbot/templates/conf_global_TEMPLATE.yml b/hummingbot/templates/conf_global_TEMPLATE.yml index c4c9b4cdfe..6b63ae8701 100644 --- a/hummingbot/templates/conf_global_TEMPLATE.yml +++ b/hummingbot/templates/conf_global_TEMPLATE.yml @@ -38,7 +38,7 @@ liquid_api_key: null liquid_secret_key: null loopring_accountid: null -loopring_exchangeid: null +loopring_exchangeaddress: null loopring_api_key: null loopring_private_key: null From c9df13eeee57222b28c6c8b70186d62eb537974f Mon Sep 17 00:00:00 2001 From: Nicolas Baum Date: Tue, 2 Feb 2021 11:13:31 -0300 Subject: [PATCH 099/126] (fix) Changed way order is searched in in_flight_orders when history reconciliating. Also fixed bug when retrieving trade history --- .../connector/exchange/binance/binance_exchange.pyx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/hummingbot/connector/exchange/binance/binance_exchange.pyx b/hummingbot/connector/exchange/binance/binance_exchange.pyx index 4390c4b4f2..edaa293c12 100755 --- a/hummingbot/connector/exchange/binance/binance_exchange.pyx +++ b/hummingbot/connector/exchange/binance/binance_exchange.pyx @@ -447,8 +447,8 @@ cdef class BinanceExchange(ExchangeBase): tasks=[] for trading_pair in self._order_book_tracker._trading_pairs: my_trades_query_args = {'symbol': convert_to_exchange_trading_pair(trading_pair)} - tasks.append(self.query_api(self._binance_client.get_my_trades, - **my_trades_query_args)) + tasks.append(self.query_api(self._binance_client.get_my_trades, + **my_trades_query_args)) self.logger().debug("Polling for order fills of %d trading pairs.", len(tasks)) exchange_history = await safe_gather(*tasks, return_exceptions=True) for trades, trading_pair in zip(exchange_history, self._order_book_tracker._trading_pairs): @@ -460,14 +460,13 @@ cdef class BinanceExchange(ExchangeBase): continue for trade in trades: if self.is_confirmed_new_order_filled_event(str(trade["id"]), str(trade["orderId"]), trading_pair): - client_order_id = get_client_order_id("buy" if trade["isBuyer"] else "sell", trading_pair) # Should check if this is a partial filling of a in_flight order. # In that case, user_stream or _update_order_fills_from_trades will take care when fully filled. - if client_order_id not in self.in_flight_orders: + if not any(trade["id"] in in_flight_order.trade_id_set for in_flight_order in self._in_flight_orders.values()): self.c_trigger_event(self.MARKET_ORDER_FILLED_EVENT_TAG, OrderFilledEvent( trade["time"], - client_order_id, + get_client_order_id("buy" if trade["isBuyer"] else "sell", trading_pair), trading_pair, TradeType.BUY if trade["isBuyer"] else TradeType.SELL, OrderType.LIMIT_MAKER, # defaulting to this value since trade info lacks field From 5f07bebf33dce2c2f56b8dcc857b17d015b97f86 Mon Sep 17 00:00:00 2001 From: Keith O'Neill Date: Tue, 2 Feb 2021 15:26:15 -0500 Subject: [PATCH 100/126] fixed get last traded prices --- .../exchange/loopring/loopring_api_order_book_data_source.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 c0820cf736..c227739459 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 @@ -67,7 +67,7 @@ async def get_last_traded_prices(cls, trading_pairs: List[str]) -> Dict[str, flo async with aiohttp.ClientSession() as client: resp = await client.get(f"https://api3.loopring.io{TICKER_URL}".replace(":markets", ",".join(trading_pairs))) resp_json = await resp.json() - return {x[0]: float(x[7]) for x in resp_json.get("data", [])} + return {x[0]: float(x[7]) for x in resp_json.get("tickers", [])} @property def order_book_class(self) -> LoopringOrderBook: From d76e2f4ba08a13fd66e4611be800c3dc0319fcc2 Mon Sep 17 00:00:00 2001 From: Nicolas Baum Date: Wed, 3 Feb 2021 00:04:32 -0300 Subject: [PATCH 101/126] (fix) Changed exchange_order_ids set to dict with pair exchange_order_id : client_order_id. In this way it's available when recreating orders using the original client order id --- hummingbot/connector/connector_base.pxd | 2 +- hummingbot/connector/connector_base.pyx | 6 +++--- .../connector/exchange/binance/binance_exchange.pyx | 3 ++- hummingbot/connector/markets_recorder.py | 4 ++-- test/integration/test_binance_market.py | 8 ++++---- 5 files changed, 12 insertions(+), 11 deletions(-) diff --git a/hummingbot/connector/connector_base.pxd b/hummingbot/connector/connector_base.pxd index bc62163d08..5b46805337 100644 --- a/hummingbot/connector/connector_base.pxd +++ b/hummingbot/connector/connector_base.pxd @@ -13,7 +13,7 @@ cdef class ConnectorBase(NetworkIterator): public dict _in_flight_orders_snapshot public double _in_flight_orders_snapshot_timestamp public set _current_trade_fills - public set _exchange_order_ids + public dict _exchange_order_ids cdef str c_buy(self, str trading_pair, object amount, object order_type=*, object price=*, dict kwargs=*) cdef str c_sell(self, str trading_pair, object amount, object order_type=*, object price=*, dict kwargs=*) diff --git a/hummingbot/connector/connector_base.pyx b/hummingbot/connector/connector_base.pyx index fee2bfba9d..ffde243b4a 100644 --- a/hummingbot/connector/connector_base.pyx +++ b/hummingbot/connector/connector_base.pyx @@ -57,7 +57,7 @@ cdef class ConnectorBase(NetworkIterator): self._in_flight_orders_snapshot = {} # Dict[order_id:str, InFlightOrderBase] self._in_flight_orders_snapshot_timestamp = 0.0 self._current_trade_fills = set() - self._exchange_order_ids = set() + self._exchange_order_ids = dict() @property def real_time_balance_update(self) -> bool: @@ -423,7 +423,7 @@ cdef class ConnectorBase(NetworkIterator): """ self._current_trade_fills.update(current_trade_fills) - def add_exchange_order_ids_from_market_recorder(self, current_exchange_order_ids: Set[str]): + def add_exchange_order_ids_from_market_recorder(self, current_exchange_order_ids: Dict[str, str]): """ Gets updates from new orders in Order table. This is used in method connector _history_reconciliation """ @@ -436,4 +436,4 @@ cdef class ConnectorBase(NetworkIterator): """ # Assume (market, exchange_trade_id, trading_pair) are unique. Also order has to be recorded in Order table return (not TradeFillOrderDetails(self.display_name, exchange_trade_id, trading_pair) in self._current_trade_fills) and \ - (exchange_order_id in self._exchange_order_ids) + (exchange_order_id in set(self._exchange_order_ids.keys())) diff --git a/hummingbot/connector/exchange/binance/binance_exchange.pyx b/hummingbot/connector/exchange/binance/binance_exchange.pyx index edaa293c12..e12e6f321c 100755 --- a/hummingbot/connector/exchange/binance/binance_exchange.pyx +++ b/hummingbot/connector/exchange/binance/binance_exchange.pyx @@ -466,7 +466,8 @@ cdef class BinanceExchange(ExchangeBase): self.c_trigger_event(self.MARKET_ORDER_FILLED_EVENT_TAG, OrderFilledEvent( trade["time"], - get_client_order_id("buy" if trade["isBuyer"] else "sell", trading_pair), + self._exchange_order_ids.get(str(trade["orderId"]), + get_client_order_id("buy" if trade["isBuyer"] else "sell", trading_pair)), trading_pair, TradeType.BUY if trade["isBuyer"] else TradeType.SELL, OrderType.LIMIT_MAKER, # defaulting to this value since trade info lacks field diff --git a/hummingbot/connector/markets_recorder.py b/hummingbot/connector/markets_recorder.py index 8c0e027341..1b35fe333b 100644 --- a/hummingbot/connector/markets_recorder.py +++ b/hummingbot/connector/markets_recorder.py @@ -67,7 +67,7 @@ def __init__(self, tf.symbol) for tf in trade_fills}) exchange_order_ids = self.get_orders_for_config_and_market(self._config_file_path, market, True, 2000) - market.add_exchange_order_ids_from_market_recorder({o.exchange_order_id for o in exchange_order_ids}) + market.add_exchange_order_ids_from_market_recorder({o.exchange_order_id: o.id for o in exchange_order_ids}) self._create_order_forwarder: SourceInfoEventForwarder = SourceInfoEventForwarder(self._did_create_order) self._fill_order_forwarder: SourceInfoEventForwarder = SourceInfoEventForwarder(self._did_fill_order) @@ -211,7 +211,7 @@ def _did_create_order(self, status=event_type.name) session.add(order_record) session.add(order_status) - market.add_exchange_order_ids_from_market_recorder({evt.exchange_order_id}) + market.add_exchange_order_ids_from_market_recorder({evt.exchange_order_id: evt.order_id}) self.save_market_states(self._config_file_path, market, no_commit=True) session.commit() diff --git a/test/integration/test_binance_market.py b/test/integration/test_binance_market.py index 41c756bbef..8faab86184 100644 --- a/test/integration/test_binance_market.py +++ b/test/integration/test_binance_market.py @@ -173,7 +173,7 @@ def setUp(self): self.market_logger = EventLogger() self.market._current_trade_fills = set() - self.market._exchange_order_ids = set() + self.market._exchange_order_ids = dict() self.ev_loop.run_until_complete(self.wait_til_ready()) for event_tag in self.events: self.market.add_listener(event_tag, self.market_logger) @@ -241,7 +241,7 @@ def test_buy_and_sell(self): order_id = self.place_order(True, "LINK-ETH", amount, OrderType.LIMIT, bid_price, self.get_current_nonce(), FixtureBinance.BUY_MARKET_ORDER, FixtureBinance.WS_AFTER_BUY_1, FixtureBinance.WS_AFTER_BUY_2) - self.market.add_exchange_order_ids_from_market_recorder({str(FixtureBinance.BUY_MARKET_ORDER['orderId'])}) + self.market.add_exchange_order_ids_from_market_recorder({str(FixtureBinance.BUY_MARKET_ORDER['orderId']): "buy-LINKETH-1580093594011279"}) [order_completed_event] = self.run_parallel(self.market_logger.wait_for(BuyOrderCompletedEvent)) order_completed_event: BuyOrderCompletedEvent = order_completed_event trade_events: List[OrderFilledEvent] = [t for t in self.market_logger.event_log @@ -269,7 +269,7 @@ def test_buy_and_sell(self): quantized_amount = order_completed_event.base_asset_amount order_id = self.place_order(False, "LINK-ETH", amount, OrderType.LIMIT, ask_price, 10002, FixtureBinance.SELL_MARKET_ORDER, FixtureBinance.WS_AFTER_SELL_1, FixtureBinance.WS_AFTER_SELL_2) - self.market.add_exchange_order_ids_from_market_recorder({str(FixtureBinance.SELL_MARKET_ORDER['orderId'])}) + self.market.add_exchange_order_ids_from_market_recorder({str(FixtureBinance.SELL_MARKET_ORDER['orderId']): "sell-LINKETH-1580194659898896"}) [order_completed_event] = self.run_parallel(self.market_logger.wait_for(SellOrderCompletedEvent)) order_completed_event: SellOrderCompletedEvent = order_completed_event trade_events = [t for t in self.market_logger.event_log @@ -752,7 +752,7 @@ def test_history_reconciliation(self): 'isMaker': True, 'isBestMatch': True, }] - self.market.add_exchange_order_ids_from_market_recorder({order_id}) + self.market.add_exchange_order_ids_from_market_recorder({order_id: "buy-LINKETH-1580093594011279"}) self.web_app.update_response("get", self.base_api_url, "/api/v3/myTrades", binance_trades, params={'symbol': 'LINKETH'}) [market_order_completed] = self.run_parallel(self.market_logger.wait_for(OrderFilledEvent)) From 21efb087a8f4f9a2c1006719ec6db7812a6a4955 Mon Sep 17 00:00:00 2001 From: Nullably <37262506+Nullably@users.noreply.github.com> Date: Wed, 3 Feb 2021 14:15:11 +0800 Subject: [PATCH 102/126] (feat) add volatility to status --- .../liquidity_mining/liquidity_mining.py | 103 +++++++++++------- hummingbot/strategy/liquidity_mining/start.py | 3 +- 2 files changed, 63 insertions(+), 43 deletions(-) diff --git a/hummingbot/strategy/liquidity_mining/liquidity_mining.py b/hummingbot/strategy/liquidity_mining/liquidity_mining.py index 1f4dfd577e..beb95c802a 100644 --- a/hummingbot/strategy/liquidity_mining/liquidity_mining.py +++ b/hummingbot/strategy/liquidity_mining/liquidity_mining.py @@ -1,7 +1,7 @@ from decimal import Decimal import logging import asyncio -from typing import Dict, List, Set, Optional +from typing import Dict, List, Set import pandas as pd import numpy as np from statistics import mean @@ -15,7 +15,6 @@ 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 ) @@ -23,10 +22,10 @@ s_decimal_zero = Decimal(0) s_decimal_nan = Decimal("NaN") lms_logger = None -VOLATILITY_AVG_PERIOD = 10 class LiquidityMiningStrategy(StrategyPyBase): + VOLATILITY_REPORT_INTERVAL = 60. @classmethod def logger(cls) -> HummingbotLogger: @@ -48,7 +47,8 @@ def __init__(self, volatility_interval: int = 60 * 5, avg_volatility_period: int = 10, volatility_to_spread_multiplier: Decimal = Decimal("1"), - status_report_interval: float = 900): + status_report_interval: float = 900, + hb_app_notification: bool = False): super().__init__() self._exchange = exchange self._market_infos = market_infos @@ -71,7 +71,9 @@ def __init__(self, self._sell_budgets = {} self._buy_budgets = {} self._mid_prices = {market: [] for market in market_infos} + self._volatility = {market: s_decimal_nan for market in self._market_infos} self._last_vol_reported = 0. + self._hb_app_notification = hb_app_notification self.add_markets([exchange]) @@ -95,6 +97,7 @@ def tick(self, timestamp: float): self.create_budget_allocation() self.update_mid_prices() + self.update_volatility() proposals = self.create_base_proposals() self._token_balances = self.adjusted_available_balances() self.apply_inventory_skew(proposals) @@ -105,12 +108,13 @@ def tick(self, timestamp: float): self._last_timestamp = timestamp async def active_orders_df(self) -> pd.DataFrame: - columns = ["Market", "Side", "Price", "Spread", "Size", "Size ($)", "Age"] + size_q_col = f"Size ({self._token})" if self.is_token_a_quote_token() else "Size (Quote)" + columns = ["Market", "Side", "Price", "Spread", "Amount", size_q_col, "Age"] data = [] for order in self.active_orders: mid_price = self._market_infos[order.trading_pair].get_mid_price() spread = 0 if mid_price == 0 else abs(order.price - mid_price) / mid_price - size_usd = await usd_value(order.trading_pair.split("-")[0], order.quantity) + size_q = order.quantity * mid_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: @@ -122,7 +126,7 @@ async def active_orders_df(self) -> pd.DataFrame: float(order.price), f"{spread:.2%}", float(order.quantity), - round(size_usd), + float(size_q), age ]) @@ -130,22 +134,23 @@ async def active_orders_df(self) -> pd.DataFrame: def market_status_df(self) -> pd.DataFrame: data = [] - columns = ["Exchange", "Market", "Mid Price", "Base Balance", "Quote Balance", " Base / Quote %"] + columns = ["Exchange", "Market", "Mid Price", "Volatility", "Base Bal", "Quote Bal", " Base %"] balances = self.adjusted_available_balances() for market, market_info in self._market_infos.items(): base, quote = market.split("-") mid_price = market_info.get_mid_price() - adj_base_bal = balances[base] * mid_price - total_bal = adj_base_bal + balances[quote] - base_pct = adj_base_bal / total_bal if total_bal > 0 else s_decimal_zero - quote_pct = balances[quote] / total_bal if total_bal > 0 else s_decimal_zero + base_bal = self._sell_budgets[market] + quote_bal = self._buy_budgets[market] + total_bal = (base_bal * mid_price) + balances[quote] + base_pct = (base_bal * mid_price) / total_bal if total_bal > 0 else s_decimal_zero data.append([ self._exchange.display_name, market, float(mid_price), - float(balances[base]), - float(balances[quote]), - f"{f'{base_pct:.0%}':>5s} /{f'{quote_pct:.0%}':>5s}" + "" if self._volatility[market].is_nan() else f"{self._volatility[market]:.2%}", + float(base_bal), + float(quote_bal), + f"{base_pct:.0%}" ]) return pd.DataFrame(data=data, columns=columns).replace(np.nan, '', regex=True) @@ -179,12 +184,11 @@ def stop(self, clock: Clock): def create_base_proposals(self): proposals = [] - volatility = self.calculate_volatility() for market, market_info in self._market_infos.items(): spread = self._spread - if volatility.get(market) is not None: + if not self._volatility[market].is_nan(): # volatility applies only when it is higher than the spread setting. - spread = max(spread, volatility[market] * self._volatility_to_spread_multiplier) + spread = max(spread, self._volatility[market] * self._volatility_to_spread_multiplier) mid_price = market_info.get_mid_price() buy_price = mid_price * (Decimal("1") - spread) buy_price = self._exchange.quantize_order_price(market, buy_price) @@ -266,6 +270,7 @@ def execute_orders_proposal(self, proposals: List[Proposal]): if cur_orders or self._refresh_times[proposal.market] > self.current_timestamp: continue mid_price = self._market_infos[proposal.market].get_mid_price() + spread = s_decimal_zero if proposal.buy.size > 0: spread = abs(proposal.buy.price - mid_price) / mid_price self.logger().info(f"({proposal.market}) Creating a bid order {proposal.buy} value: " @@ -289,8 +294,20 @@ def execute_orders_proposal(self, proposals: List[Proposal]): price=proposal.sell.price ) if proposal.buy.size > 0 or proposal.sell.size > 0: + if not self._volatility[proposal.market].is_nan() and spread > self._spread: + adjusted_vol = self._volatility[proposal.market] * self._volatility_to_spread_multiplier + if adjusted_vol > self._spread: + self.logger().info(f"({proposal.market}) Spread is widened to {spread:.2%} due to high " + f"market volatility") + self._refresh_times[proposal.market] = self.current_timestamp + self._order_refresh_time + def is_token_a_quote_token(self): + quotes = self.all_quote_tokens() + if len(quotes) == 1 and self._token in quotes: + return True + return False + def all_base_tokens(self) -> Set[str]: tokens = set() for market in self._market_infos: @@ -344,26 +361,24 @@ def apply_inventory_skew(self, proposals: List[Proposal]): proposal.buy.size *= Decimal(bid_ask_ratios.bid_ratio) proposal.sell.size *= Decimal(bid_ask_ratios.ask_ratio) - def did_fill_order(self, order_filled_event): - order_id = order_filled_event.order_id + def did_fill_order(self, event): + order_id = event.order_id market_info = self.order_tracker.get_shadow_market_pair_from_order_id(order_id) if market_info is not None: - if order_filled_event.trade_type is TradeType.BUY: - 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." - ) - self._buy_budgets[market_info.trading_pair] -= (order_filled_event.amount * order_filled_event.price) - self._sell_budgets[market_info.trading_pair] += order_filled_event.amount + if event.trade_type is TradeType.BUY: + msg = f"({market_info.trading_pair}) Maker BUY order (price: {event.price}) of {event.amount} " \ + f"{market_info.base_asset} is filled." + self.log_with_clock(logging.INFO, msg) + self.notify_hb_app(msg) + self._buy_budgets[market_info.trading_pair] -= (event.amount * event.price) + self._sell_budgets[market_info.trading_pair] += event.amount else: - 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." - ) - self._sell_budgets[market_info.trading_pair] -= order_filled_event.amount - self._buy_budgets[market_info.trading_pair] += (order_filled_event.amount * order_filled_event.price) + msg = f"({market_info.trading_pair}) Maker SELL order (price: {event.price}) of {event.amount} " \ + f"{market_info.base_asset} is filled." + self.log_with_clock(logging.INFO, msg) + self.notify_hb_app(msg) + self._sell_budgets[market_info.trading_pair] -= event.amount + self._buy_budgets[market_info.trading_pair] += (event.amount * event.price) def update_mid_prices(self): for market in self._market_infos: @@ -373,8 +388,8 @@ def update_mid_prices(self): max_len = self._volatility_interval * self._avg_volatility_period self._mid_prices[market] = self._mid_prices[market][-1 * max_len:] - def calculate_volatility(self) -> Dict[str, Optional[Decimal]]: - volatility = {market: None for market in self._market_infos} + def update_volatility(self): + self._volatility = {market: s_decimal_nan for market in self._market_infos} for market, mid_prices in self._mid_prices.items(): last_index = len(mid_prices) - 1 atr = [] @@ -386,10 +401,14 @@ def calculate_volatility(self) -> Dict[str, Optional[Decimal]]: break atr.append((max(prices) - min(prices)) / min(prices)) if atr: - volatility[market] = mean(atr) - if self._last_vol_reported < self.current_timestamp - 60.: - for market, vol in volatility.items(): - if vol is not None: + self._volatility[market] = mean(atr) + if self._last_vol_reported < self.current_timestamp - self.VOLATILITY_REPORT_INTERVAL: + for market, vol in self._volatility.items(): + if not vol.is_nan(): self.logger().info(f"{market} volatility: {vol:.2%}") self._last_vol_reported = self.current_timestamp - return volatility + + def notify_hb_app(self, msg: str): + if self._hb_app_notification: + from hummingbot.client.hummingbot_application import HummingbotApplication + HummingbotApplication.main_application()._notify(msg) diff --git a/hummingbot/strategy/liquidity_mining/start.py b/hummingbot/strategy/liquidity_mining/start.py index 085b54cf38..21d3b8ce16 100644 --- a/hummingbot/strategy/liquidity_mining/start.py +++ b/hummingbot/strategy/liquidity_mining/start.py @@ -36,5 +36,6 @@ def start(self): inventory_range_multiplier=inventory_range_multiplier, volatility_interval=volatility_interval, avg_volatility_period=avg_volatility_period, - volatility_to_spread_multiplier=volatility_to_spread_multiplier + volatility_to_spread_multiplier=volatility_to_spread_multiplier, + hb_app_notification=True ) From 60e62fd2ddb2a7a1c7d3fee9aad16b014806fcea Mon Sep 17 00:00:00 2001 From: nionis Date: Wed, 3 Feb 2021 13:20:36 +0200 Subject: [PATCH 103/126] (fix) cannot find corresponding config to key bitmax_maker_fee_amount in template --- hummingbot/templates/conf_fee_overrides_TEMPLATE.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/hummingbot/templates/conf_fee_overrides_TEMPLATE.yml b/hummingbot/templates/conf_fee_overrides_TEMPLATE.yml index c957300f12..506eb80b09 100644 --- a/hummingbot/templates/conf_fee_overrides_TEMPLATE.yml +++ b/hummingbot/templates/conf_fee_overrides_TEMPLATE.yml @@ -68,5 +68,5 @@ okex_taker_fee: balancer_maker_fee_amount: balancer_taker_fee_amount: -bitmax_maker_fee_amount: -bitmax_taker_fee_amount: +bitmax_maker_fee: +bitmax_taker_fee: From d5092ff3fc1137fa78dad0bc21643c23ff7c3d03 Mon Sep 17 00:00:00 2001 From: Nullably <37262506+Nullably@users.noreply.github.com> Date: Wed, 3 Feb 2021 20:58:43 +0800 Subject: [PATCH 104/126] (feat) remove eligible_markets and update order_size to order_amount --- .../liquidity_mining/liquidity_mining.py | 11 ++++----- .../liquidity_mining_config_map.py | 23 ++++--------------- hummingbot/strategy/liquidity_mining/start.py | 12 ++++++---- ...onf_liquidity_mining_strategy_TEMPLATE.yml | 8 +++---- 4 files changed, 21 insertions(+), 33 deletions(-) diff --git a/hummingbot/strategy/liquidity_mining/liquidity_mining.py b/hummingbot/strategy/liquidity_mining/liquidity_mining.py index beb95c802a..d42e751009 100644 --- a/hummingbot/strategy/liquidity_mining/liquidity_mining.py +++ b/hummingbot/strategy/liquidity_mining/liquidity_mining.py @@ -25,7 +25,6 @@ class LiquidityMiningStrategy(StrategyPyBase): - VOLATILITY_REPORT_INTERVAL = 60. @classmethod def logger(cls) -> HummingbotLogger: @@ -38,7 +37,7 @@ def __init__(self, exchange: ExchangeBase, market_infos: Dict[str, MarketTradingPairTuple], token: str, - order_size: Decimal, + order_amount: Decimal, spread: Decimal, target_base_pct: Decimal, order_refresh_time: float, @@ -53,7 +52,7 @@ def __init__(self, self._exchange = exchange self._market_infos = market_infos self._token = token - self._order_size = order_size + self._order_amount = order_amount self._spread = spread self._order_refresh_time = order_refresh_time self._order_refresh_tolerance_pct = order_refresh_tolerance_pct @@ -219,10 +218,10 @@ def create_budget_allocation(self): def base_order_size(self, trading_pair: str, price: Decimal = s_decimal_zero): base, quote = trading_pair.split("-") if self._token == base: - return self._order_size + return self._order_amount if price == s_decimal_zero: price = self._market_infos[trading_pair].get_mid_price() - return self._order_size / price + return self._order_amount / price def apply_budget_constraint(self, proposals: List[Proposal]): balances = self._token_balances.copy() @@ -402,7 +401,7 @@ def update_volatility(self): atr.append((max(prices) - min(prices)) / min(prices)) if atr: self._volatility[market] = mean(atr) - if self._last_vol_reported < self.current_timestamp - self.VOLATILITY_REPORT_INTERVAL: + if self._last_vol_reported < self.current_timestamp - self._volatility_interval: for market, vol in self._volatility.items(): if not vol.is_nan(): self.logger().info(f"{market} volatility: {vol:.2%}") diff --git a/hummingbot/strategy/liquidity_mining/liquidity_mining_config_map.py b/hummingbot/strategy/liquidity_mining/liquidity_mining_config_map.py index 726c78675a..891733234f 100644 --- a/hummingbot/strategy/liquidity_mining/liquidity_mining_config_map.py +++ b/hummingbot/strategy/liquidity_mining/liquidity_mining_config_map.py @@ -17,7 +17,7 @@ def exchange_on_validated(value: str) -> None: def token_validate(value: str) -> Optional[str]: value = value.upper() - markets = list(liquidity_mining_config_map["eligible_markets"].value.split(",")) + markets = list(liquidity_mining_config_map["markets"].value.split(",")) tokens = set() for market in markets: tokens.update(set(market.split("-"))) @@ -25,14 +25,6 @@ def token_validate(value: str) -> Optional[str]: return f"Invalid token. {value} is not one of {','.join(tokens)}" -def token_on_validated(value: str) -> None: - value = value.upper() - liquidity_mining_config_map["token"].value = value - el_markets = list(liquidity_mining_config_map["eligible_markets"].value.split(",")) - markets = [m for m in el_markets if value in m.split("-")] - liquidity_mining_config_map["markets"].value = ",".join(markets) - - def order_size_prompt() -> str: token = liquidity_mining_config_map["token"].value return f"What is the size of each order (in {token} amount)? >>> " @@ -49,24 +41,19 @@ def order_size_prompt() -> str: validator=validate_exchange, on_validated=exchange_on_validated, prompt_on_new=True), - "eligible_markets": - ConfigVar(key="eligible_markets", + "markets": + ConfigVar(key="markets", prompt="Enter a list of markets (comma separated, e.g. LTC-USDT,ETH-USDT) >>> ", type_str="str", prompt_on_new=True), - "markets": - ConfigVar(key="markets", - prompt=None, - type_str="str"), "token": ConfigVar(key="token", prompt="What asset (base or quote) do you want to use to provide liquidity? >>> ", type_str="str", validator=token_validate, - on_validated=token_on_validated, prompt_on_new=True), - "order_size": - ConfigVar(key="order_size", + "order_amount": + ConfigVar(key="order_amount", prompt=order_size_prompt, type_str="decimal", validator=lambda v: validate_decimal(v, 0, inclusive=False), diff --git a/hummingbot/strategy/liquidity_mining/start.py b/hummingbot/strategy/liquidity_mining/start.py index 21d3b8ce16..d99bb75720 100644 --- a/hummingbot/strategy/liquidity_mining/start.py +++ b/hummingbot/strategy/liquidity_mining/start.py @@ -6,9 +6,13 @@ def start(self): exchange = c_map.get("exchange").value.lower() - markets = list(c_map.get("markets").value.split(",")) - token = c_map.get("token").value - order_size = c_map.get("order_size").value + el_markets = list(c_map.get("markets").value.split(",")) + token = c_map.get("token").value.upper() + el_markets = [m.upper() for m in el_markets] + quote_markets = [m for m in el_markets if m.split("-")[1] == token] + base_markets = [m for m in el_markets if m.split("-")[0] == token] + markets = quote_markets if quote_markets else base_markets + order_amount = c_map.get("order_amount").value spread = c_map.get("spread").value / Decimal("100") target_base_pct = c_map.get("target_base_pct").value / Decimal("100") order_refresh_time = c_map.get("order_refresh_time").value @@ -28,7 +32,7 @@ def start(self): exchange=exchange, market_infos=market_infos, token=token, - order_size=order_size, + order_amount=order_amount, spread=spread, target_base_pct=target_base_pct, order_refresh_time=order_refresh_time, diff --git a/hummingbot/templates/conf_liquidity_mining_strategy_TEMPLATE.yml b/hummingbot/templates/conf_liquidity_mining_strategy_TEMPLATE.yml index 15282712da..cc841e7d4b 100644 --- a/hummingbot/templates/conf_liquidity_mining_strategy_TEMPLATE.yml +++ b/hummingbot/templates/conf_liquidity_mining_strategy_TEMPLATE.yml @@ -8,17 +8,15 @@ strategy: null # The exchange to run this strategy. exchange: null -# The list of eligible markets, comma separated, e.g. LTC-USDT,ETH-USDT -eligible_markets: null - -# The actual markets to run liquidity mining, this is auto populated based on eligible_markets and token +# The list of markets, comma separated, e.g. LTC-USDT,ETH-USDT +# Note: The actual markets will be determined by token below, only markets with the token specified below. markets: null # The asset (base or quote) to use to provide liquidity token: null # The size of each order in specified token amount -order_size: null +order_amount: null # The spread from mid price to place bid and ask orders, enter 1 to indicate 1% spread: null From 533372b406c74d15155b238313e147d7a35c3e83 Mon Sep 17 00:00:00 2001 From: nionis Date: Wed, 3 Feb 2021 20:46:56 +0200 Subject: [PATCH 105/126] various improvements --- .../exchange/bitmax/bitmax_exchange.py | 270 +++++++++++------- 1 file changed, 164 insertions(+), 106 deletions(-) diff --git a/hummingbot/connector/exchange/bitmax/bitmax_exchange.py b/hummingbot/connector/exchange/bitmax/bitmax_exchange.py index d4181c0d26..f73be707eb 100644 --- a/hummingbot/connector/exchange/bitmax/bitmax_exchange.py +++ b/hummingbot/connector/exchange/bitmax/bitmax_exchange.py @@ -48,6 +48,8 @@ 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 totalBalance availableBalance") class BitmaxExchange(ExchangeBase): @@ -558,7 +560,6 @@ def start_tracking_order(self, """ Starts tracking an order by simply adding it into _in_flight_orders dictionary. """ - print("start_tracking_order", order_id, exchange_order_id) self._in_flight_orders[order_id] = BitmaxInFlightOrder( client_order_id=order_id, exchange_order_id=exchange_order_id, @@ -606,6 +607,9 @@ async def _execute_cancel(self, trading_pair: str, order_id: str) -> str: except asyncio.CancelledError: raise except Exception as e: + if str(e).find("Order not found") != -1: + return + self.logger().network( f"Failed to cancel order {order_id}: {str(e)}", exc_info=True, @@ -641,19 +645,16 @@ async def _update_balances(self): """ Calls REST API to update total and available balances. """ - local_asset_names = set(self._account_balances.keys()) - remote_asset_names = set() response = await self._api_request("get", "cash/balance", {}, True, force_auth_path_url="balance") - for account in response["data"]: - asset_name = account["asset"] - self._account_available_balances[asset_name] = Decimal(account["availableBalance"]) - self._account_balances[asset_name] = Decimal(account["totalBalance"]) - 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] + balances = list(map( + lambda balance: BitmaxBalance( + balance["asset"], + balance["availableBalance"], + balance["totalBalance"] + ), + response.get("data", list()) + )) + self._process_balances(balances) async def _update_order_status(self): """ @@ -675,94 +676,33 @@ async def _update_order_status(self): force_auth_path_url="order/status") ) 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}") + responses = await safe_gather(*tasks, return_exceptions=True) + for response in responses: + if isinstance(response, Exception): + raise response + if "data" not in response: + self.logger().info(f"_update_order_status result not in resp: {response}") continue - for order_data in update_result["data"]: - self._process_order_message(order_data) - - 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) - """ - exchange_order_id = order_msg["orderId"] - client_order_id = None - - for in_flight_order in self._in_flight_orders.values(): - if in_flight_order.exchange_order_id == exchange_order_id: - client_order_id = in_flight_order.client_order_id - - if client_order_id is None: - return - - tracked_order = self._in_flight_orders[client_order_id] - # Update order execution status - tracked_order.last_state = order_msg["st"] - - 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, - exchange_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: {bitmax_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) - elif tracked_order.is_done: - price = Decimal(order_msg["ap"]) - tracked_order.executed_amount_base = Decimal(order_msg["cfq"]) - tracked_order.executed_amount_quote = price * tracked_order.executed_amount_base - tracked_order.fee_paid = Decimal(order_msg["cf"]) - tracked_order.fee_asset = order_msg["fa"] - - self.trigger_event( - MarketEvent.OrderFilled, - OrderFilledEvent( - self.current_timestamp, - client_order_id, - tracked_order.trading_pair, - tracked_order.trade_type, - tracked_order.order_type, - price, - tracked_order.executed_amount_base, - TradeFee(0.0, [(tracked_order.fee_asset, tracked_order.fee_paid)]), - exchange_order_id - ) - ) - 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, - 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, - exchange_order_id - ) - ) - self.stop_tracking_order(client_order_id) + order_data = response.get("data") + self._process_order_message(BitmaxOrder( + order_data["symbol"], + order_data["price"], + order_data["orderQty"], + order_data["orderType"], + order_data["avgPx"], + order_data["cumFee"], + order_data["cumFilledQty"], + order_data["errorCode"], + order_data["feeAsset"], + order_data["lastExecTime"], + order_data["orderId"], + order_data["seqNum"], + order_data["side"], + order_data["status"], + order_data["stopPrice"], + order_data["execInst"] + )) async def cancel_all(self, timeout_seconds: float): """ @@ -852,12 +792,34 @@ async def _user_stream_event_listener(self): async for event_message in self._iter_user_event_queue(): try: if event_message.get("m") == "order": - self._process_order_message(event_message.get("data")) + order_data = event_message.get("data") + self._process_order_message(BitmaxOrder( + order_data["s"], + order_data["p"], + order_data["q"], + order_data["ot"], + order_data["ap"], + order_data["cf"], + order_data["cfq"], + order_data["err"], + order_data["fa"], + order_data["t"], + order_data["orderId"], + order_data["sn"], + order_data["sd"], + order_data["st"], + order_data["sp"], + order_data["ei"] + )) elif event_message.get("m") == "balance": - data = event_message.get("data") - asset_name = data["a"] - self._account_available_balances[asset_name] = Decimal(data["ab"]) - self._data_balances[asset_name] = Decimal(data["tb"]) + balance_data = event_message.get("data") + balance = BitmaxBalance( + balance_data["a"], + balance_data["ab"], + balance_data["tb"] + ) + self._process_balances(list(balance)) + except asyncio.CancelledError: raise except Exception: @@ -886,8 +848,6 @@ async def get_open_orders(self) -> List[OpenOrder]: if client_order_id is None: raise Exception(f"Client order id for {exchange_order_id} not found.") - print("open order", client_order_id) - ret_val.append( OpenOrder( client_order_id=client_order_id, @@ -903,3 +863,101 @@ async def get_open_orders(self) -> List[OpenOrder]: ) ) return ret_val + + def _process_order_message(self, order_msg: BitmaxOrder): + """ + 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) + """ + exchange_order_id = order_msg.orderId + client_order_id = None + + for in_flight_order in self._in_flight_orders.values(): + if in_flight_order.exchange_order_id == exchange_order_id: + client_order_id = in_flight_order.client_order_id + + if client_order_id is None: + return + + tracked_order = self._in_flight_orders[client_order_id] + + # update order + tracked_order.last_state = order_msg.status + tracked_order.fee_paid = Decimal(order_msg.cumFee) + tracked_order.fee_asset = order_msg.feeAsset + tracked_order.executed_amount_base = Decimal(order_msg.cumFilledQty) + tracked_order.executed_amount_quote = Decimal(order_msg.price) * Decimal(order_msg.cumFilledQty) + + 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, + exchange_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: {bitmax_utils.get_api_reason(order_msg.errorCode)}") + self.trigger_event(MarketEvent.OrderFailure, + MarketOrderFailureEvent( + self.current_timestamp, + client_order_id, + tracked_order.order_type + )) + self.stop_tracking_order(client_order_id) + elif tracked_order.is_done: + price = Decimal(order_msg.price) + tracked_order.executed_amount_base = Decimal(order_msg.cumFilledQty) + tracked_order.executed_amount_quote = price * tracked_order.executed_amount_base + tracked_order.fee_paid = Decimal(order_msg.cumFee) + tracked_order.fee_asset = order_msg.feeAsset + + self.trigger_event( + MarketEvent.OrderFilled, + OrderFilledEvent( + self.current_timestamp, + client_order_id, + tracked_order.trading_pair, + tracked_order.trade_type, + tracked_order.order_type, + price, + tracked_order.executed_amount_base, + TradeFee(0.0, [(tracked_order.fee_asset, tracked_order.fee_paid)]), + exchange_order_id + ) + ) + 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, + 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, + exchange_order_id + ) + ) + self.stop_tracking_order(client_order_id) + + def _process_balances(self, balances: List[BitmaxBalance]): + local_asset_names = set(self._account_balances.keys()) + remote_asset_names = set() + + for balance in balances: + asset_name = balance.asset + self._account_available_balances[asset_name] = Decimal(balance.availableBalance) + self._account_balances[asset_name] = Decimal(balance.totalBalance) + 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] From b3912b0c4ef2b70428a0fc723822f091a6b4c0fc Mon Sep 17 00:00:00 2001 From: Keith O'Neill Date: Wed, 3 Feb 2021 16:10:30 -0500 Subject: [PATCH 106/126] loopring order amount quantization handles no price parameter --- .../connector/exchange/loopring/loopring_exchange.pyx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/hummingbot/connector/exchange/loopring/loopring_exchange.pyx b/hummingbot/connector/exchange/loopring/loopring_exchange.pyx index cde93edc46..033b3307ac 100644 --- a/hummingbot/connector/exchange/loopring/loopring_exchange.pyx +++ b/hummingbot/connector/exchange/loopring/loopring_exchange.pyx @@ -871,14 +871,19 @@ cdef class LoopringExchange(ExchangeBase): cdef object c_quantize_order_price(self, str trading_pair, object price): return price.quantize(self.c_get_order_price_quantum(trading_pair, price), rounding=ROUND_DOWN) - cdef object c_quantize_order_amount(self, str trading_pair, object amount, object price = 0.0): + cdef object c_quantize_order_amount(self, str trading_pair, object amount, object price = s_decimal_0): + cdef: + object current_price = self.c_get_price(trading_pair, False) quantized_amount = amount.quantize(self.c_get_order_size_quantum(trading_pair, amount), rounding=ROUND_DOWN) rules = self._trading_rules[trading_pair] - if quantized_amount < rules.min_order_size: return s_decimal_0 - if price > 0 and price * quantized_amount < rules.min_notional_size: + if price == s_decimal_0: + notional_size = current_price * quantized_amount + if notional_size < rules.min_notional_size: + return s_decimal_0 + elif price > 0 and price * quantized_amount < rules.min_notional_size: return s_decimal_0 return quantized_amount From 1c00f8c1b4e319eefb9f95f0eb718db74607e8fc Mon Sep 17 00:00:00 2001 From: Nicolas Baum Date: Wed, 3 Feb 2021 20:45:00 -0300 Subject: [PATCH 107/126] (fix) Added exponential waiting interval between retries --- hummingbot/connector/exchange/kraken/kraken_exchange.pyx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/hummingbot/connector/exchange/kraken/kraken_exchange.pyx b/hummingbot/connector/exchange/kraken/kraken_exchange.pyx index ca6e0bfecb..5a636b6c74 100755 --- a/hummingbot/connector/exchange/kraken/kraken_exchange.pyx +++ b/hummingbot/connector/exchange/kraken/kraken_exchange.pyx @@ -691,7 +691,7 @@ cdef class KrakenExchange(ExchangeBase): is_auth_required: bool = False, request_weight: int = 1, retry_count = 5, - retry_interval = 1.0) -> Dict[str, Any]: + retry_interval = 2.0) -> Dict[str, Any]: request_weight = self.API_COUNTER_POINTS.get(path_url, 0) if retry_count == 0: return await self._api_request(method, path_url, params, data, is_auth_required, request_weight) @@ -710,7 +710,7 @@ cdef class KrakenExchange(ExchangeBase): if any(response.get("open").values()): return response self.logger().warning(f"Cloudflare error. Attempt {retry_attempt+1}/{retry_count} API command {method}: {path_url}") - await asyncio.sleep(retry_interval) + await asyncio.sleep(retry_interval ** retry_interval) continue else: raise e From a6e64566940d596eb5a429518844189e83572a2c Mon Sep 17 00:00:00 2001 From: Nicolas Baum Date: Wed, 3 Feb 2021 20:52:38 -0300 Subject: [PATCH 108/126] Correcting wrong variable --- hummingbot/connector/exchange/kraken/kraken_exchange.pyx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hummingbot/connector/exchange/kraken/kraken_exchange.pyx b/hummingbot/connector/exchange/kraken/kraken_exchange.pyx index 5a636b6c74..01bb3ff9a5 100755 --- a/hummingbot/connector/exchange/kraken/kraken_exchange.pyx +++ b/hummingbot/connector/exchange/kraken/kraken_exchange.pyx @@ -710,7 +710,7 @@ cdef class KrakenExchange(ExchangeBase): if any(response.get("open").values()): return response self.logger().warning(f"Cloudflare error. Attempt {retry_attempt+1}/{retry_count} API command {method}: {path_url}") - await asyncio.sleep(retry_interval ** retry_interval) + await asyncio.sleep(retry_interval ** retry_attempt) continue else: raise e From c87c4b26aba8fdbceb26c1c5eb47039d273456b5 Mon Sep 17 00:00:00 2001 From: vic-en Date: Thu, 4 Feb 2021 12:49:57 +0100 Subject: [PATCH 109/126] (fix) fix race condition in binance status polling loop --- hummingbot/connector/exchange/binance/binance_exchange.pyx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/hummingbot/connector/exchange/binance/binance_exchange.pyx b/hummingbot/connector/exchange/binance/binance_exchange.pyx index e12e6f321c..310d7b96a0 100755 --- a/hummingbot/connector/exchange/binance/binance_exchange.pyx +++ b/hummingbot/connector/exchange/binance/binance_exchange.pyx @@ -736,9 +736,9 @@ cdef class BinanceExchange(ExchangeBase): await self._poll_notifier.wait() await safe_gather( self._update_balances(), - self._update_order_fills_from_trades(), - self._update_order_status(), + self._update_order_fills_from_trades() ) + await self._update_order_status() self._last_poll_timestamp = self._current_timestamp except asyncio.CancelledError: raise From 485ed98e6ca4704247ffc6042eb7652860079768 Mon Sep 17 00:00:00 2001 From: nionis Date: Thu, 4 Feb 2021 19:06:12 +0200 Subject: [PATCH 110/126] various improvements --- .../bitmax_api_order_book_data_source.py | 70 ++++++++++--------- .../exchange/bitmax/bitmax_exchange.py | 5 ++ .../exchange/bitmax/bitmax_order_book.py | 5 -- .../bitmax/bitmax_order_book_message.py | 5 +- 4 files changed, 42 insertions(+), 43 deletions(-) diff --git a/hummingbot/connector/exchange/bitmax/bitmax_api_order_book_data_source.py b/hummingbot/connector/exchange/bitmax/bitmax_api_order_book_data_source.py index 21d0d0081d..a62b4fc050 100644 --- a/hummingbot/connector/exchange/bitmax/bitmax_api_order_book_data_source.py +++ b/hummingbot/connector/exchange/bitmax/bitmax_api_order_book_data_source.py @@ -4,6 +4,8 @@ import aiohttp import websockets import ujson +import time +import pandas as pd from typing import Optional, List, Dict, Any, AsyncIterable from hummingbot.core.data_type.order_book import OrderBook @@ -160,7 +162,7 @@ async def listen_for_order_book_diffs(self, ev_loop: asyncio.BaseEventLoop, outp trading_pairs = ",".join(list( map(lambda trading_pair: convert_to_exchange_trading_pair(trading_pair), self._trading_pairs) )) - ch = f"bbo:{trading_pairs}" + ch = f"depth:{trading_pairs}" payload = { "op": "sub", "ch": ch @@ -173,7 +175,7 @@ async def listen_for_order_book_diffs(self, ev_loop: asyncio.BaseEventLoop, outp async for raw_msg in self._inner_messages(ws): try: msg = ujson.loads(raw_msg) - if (msg is None or msg.get("m") != "bbo"): + if (msg is None or msg.get("m") != "depth"): continue msg_timestamp: int = msg.get("data").get("ts") @@ -195,43 +197,43 @@ async def listen_for_order_book_diffs(self, ev_loop: asyncio.BaseEventLoop, outp await asyncio.sleep(30.0) 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: - trading_pairs = ",".join(list( - map(lambda trading_pair: convert_to_exchange_trading_pair(trading_pair), self._trading_pairs) - )) - ch = f"depth:{trading_pairs}" - payload = { - "op": "sub", - "ch": ch - } - - async with websockets.connect(WS_URL) as ws: - ws: websockets.WebSocketClientProtocol = ws - await ws.send(ujson.dumps(payload)) - - async for raw_msg in self._inner_messages(ws): - try: - msg = ujson.loads(raw_msg) - if (msg is None or msg.get("m") != "depth"): - continue - - msg_timestamp: int = msg.get("data").get("ts") - trading_pair: str = convert_from_exchange_trading_pair(msg.get("symbol")) - order_book_message: OrderBookMessage = BitmaxOrderBook.snapshot_message_from_exchange( - msg.get("data"), - msg_timestamp, - metadata={"trading_pair": trading_pair} - ) - output.put_nowait(order_book_message) - except Exception: - raise + for trading_pair in self._trading_pairs: + 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.get("data"), + 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 with WebSocket connection. Retrying after 30 seconds...", - exc_info=True) - await asyncio.sleep(30.0) + self.logger().error("Unexpected error.", exc_info=True) + await asyncio.sleep(5.0) async def _inner_messages( self, diff --git a/hummingbot/connector/exchange/bitmax/bitmax_exchange.py b/hummingbot/connector/exchange/bitmax/bitmax_exchange.py index f73be707eb..e4d2c088a8 100644 --- a/hummingbot/connector/exchange/bitmax/bitmax_exchange.py +++ b/hummingbot/connector/exchange/bitmax/bitmax_exchange.py @@ -603,11 +603,15 @@ async def _execute_cancel(self, trading_pair: str, order_id: str) -> str: True, force_auth_path_url="order" ) + + self.stop_tracking_order(order_id) + return order_id except asyncio.CancelledError: raise except Exception as e: if str(e).find("Order not found") != -1: + self.stop_tracking_order(order_id) return self.logger().network( @@ -729,6 +733,7 @@ async def cancel_all(self, timeout_seconds: float): cancellation_results.append(CancellationResult(cl_order_id, True)) self.trigger_event(MarketEvent.OrderCancelled, OrderCancelledEvent(self.current_timestamp, cl_order_id)) + self.stop_tracking_order(cl_order_id) else: cancellation_results.append(CancellationResult(cl_order_id, False)) except Exception: diff --git a/hummingbot/connector/exchange/bitmax/bitmax_order_book.py b/hummingbot/connector/exchange/bitmax/bitmax_order_book.py index 7477332280..be6702853d 100644 --- a/hummingbot/connector/exchange/bitmax/bitmax_order_book.py +++ b/hummingbot/connector/exchange/bitmax/bitmax_order_book.py @@ -76,11 +76,6 @@ def diff_message_from_exchange(cls, if metadata: msg.update(metadata) - msg.update({ - "bids": [msg.get("bid")], - "asks": [msg.get("ask")], - }) - return BitmaxOrderBookMessage( message_type=OrderBookMessageType.DIFF, content=msg, diff --git a/hummingbot/connector/exchange/bitmax/bitmax_order_book_message.py b/hummingbot/connector/exchange/bitmax/bitmax_order_book_message.py index 7cfaea496d..a00550b66b 100644 --- a/hummingbot/connector/exchange/bitmax/bitmax_order_book_message.py +++ b/hummingbot/connector/exchange/bitmax/bitmax_order_book_message.py @@ -34,15 +34,12 @@ def __new__( @property def update_id(self) -> int: if self.type in [OrderBookMessageType.DIFF, OrderBookMessageType.SNAPSHOT]: - # return int(self.content["seqnum"]) return int(self.timestamp) - else: - return -1 + return -1 @property def trade_id(self) -> int: if self.type is OrderBookMessageType.TRADE: - # return int(self.content["seqnum"]) return int(self.timestamp) return -1 From 0227cb2e8e4200ebac08f827423f33b5ef56dda6 Mon Sep 17 00:00:00 2001 From: Nicolas Baum Date: Thu, 4 Feb 2021 20:19:11 -0300 Subject: [PATCH 111/126] (fix) hist recon now performs its own request to get_my_trades instead of reusing result from _update_order_fills_from_trades --- .../exchange/binance/binance_exchange.pyx | 20 ++++++++----------- 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/hummingbot/connector/exchange/binance/binance_exchange.pyx b/hummingbot/connector/exchange/binance/binance_exchange.pyx index 310d7b96a0..aa2bdcb350 100755 --- a/hummingbot/connector/exchange/binance/binance_exchange.pyx +++ b/hummingbot/connector/exchange/binance/binance_exchange.pyx @@ -394,7 +394,6 @@ cdef class BinanceExchange(ExchangeBase): int64_t current_tick = (self._current_timestamp / self.UPDATE_ORDER_STATUS_MIN_INTERVAL) if current_tick > last_tick: - results = None if len(self._in_flight_orders) > 0: trading_pairs_to_order_map = defaultdict(lambda: {}) for o in self._in_flight_orders.values(): @@ -436,22 +435,18 @@ cdef class BinanceExchange(ExchangeBase): ), exchange_trade_id=trade["id"] )) - await self._history_reconciliation(results) - async def _history_reconciliation(self, exchange_history): + async def _history_reconciliation(self): """ Method looks in the exchange history to check for any missing trade in local history. If found, it will trigger an order_filled event to record it in local DB. """ - if not exchange_history: - tasks=[] - for trading_pair in self._order_book_tracker._trading_pairs: - my_trades_query_args = {'symbol': convert_to_exchange_trading_pair(trading_pair)} - tasks.append(self.query_api(self._binance_client.get_my_trades, - **my_trades_query_args)) - self.logger().debug("Polling for order fills of %d trading pairs.", len(tasks)) - exchange_history = await safe_gather(*tasks, return_exceptions=True) - for trades, trading_pair in zip(exchange_history, self._order_book_tracker._trading_pairs): + 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)) + exchange_history = await safe_gather(*tasks, return_exceptions=True) + for trades, trading_pair in zip(exchange_history, trading_pairs): if isinstance(trades, Exception): self.logger().network( f"Error fetching trades update for the order {trading_pair}: {trades}.", @@ -738,6 +733,7 @@ cdef class BinanceExchange(ExchangeBase): self._update_balances(), self._update_order_fills_from_trades() ) + await self._history_reconciliation() await self._update_order_status() self._last_poll_timestamp = self._current_timestamp except asyncio.CancelledError: From d70501730503407963b311e15c449bd2d2422e10 Mon Sep 17 00:00:00 2001 From: Nicolas Baum Date: Thu, 4 Feb 2021 20:25:58 -0300 Subject: [PATCH 112/126] Adding frequency of hist recon equal to _update_order_fills_from_trades --- .../exchange/binance/binance_exchange.pyx | 76 ++++++++++--------- 1 file changed, 41 insertions(+), 35 deletions(-) diff --git a/hummingbot/connector/exchange/binance/binance_exchange.pyx b/hummingbot/connector/exchange/binance/binance_exchange.pyx index aa2bdcb350..fef15810d5 100755 --- a/hummingbot/connector/exchange/binance/binance_exchange.pyx +++ b/hummingbot/connector/exchange/binance/binance_exchange.pyx @@ -441,41 +441,47 @@ cdef class BinanceExchange(ExchangeBase): Method looks in the exchange history to check for any missing trade in local history. If found, it will trigger an order_filled event to record it in local DB. """ - 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)) - exchange_history = await safe_gather(*tasks, return_exceptions=True) - for trades, trading_pair in zip(exchange_history, trading_pairs): - if isinstance(trades, Exception): - self.logger().network( - f"Error fetching trades update for the order {trading_pair}: {trades}.", - app_warning_msg=f"Failed to fetch trade update for {trading_pair}." - ) - continue - for trade in trades: - if self.is_confirmed_new_order_filled_event(str(trade["id"]), str(trade["orderId"]), trading_pair): - # Should check if this is a partial filling of a in_flight order. - # In that case, user_stream or _update_order_fills_from_trades will take care when fully filled. - if not any(trade["id"] in in_flight_order.trade_id_set for in_flight_order in self._in_flight_orders.values()): - self.c_trigger_event(self.MARKET_ORDER_FILLED_EVENT_TAG, - OrderFilledEvent( - trade["time"], - self._exchange_order_ids.get(str(trade["orderId"]), - get_client_order_id("buy" if trade["isBuyer"] else "sell", trading_pair)), - trading_pair, - TradeType.BUY if trade["isBuyer"] else TradeType.SELL, - OrderType.LIMIT_MAKER, # defaulting to this value since trade info lacks field - Decimal(trade["price"]), - Decimal(trade["qty"]), - TradeFee( - percent=Decimal(0.0), - flat_fees=[(trade["commissionAsset"], - Decimal(trade["commission"]))] - ), - exchange_trade_id=trade["id"] - )) - self.logger().info(f"Recreating missing trade in TradeFill: {trade}") + cdef: + # The minimum poll interval for order status is 10 seconds. + int64_t last_tick = (self._last_poll_timestamp / self.UPDATE_ORDER_STATUS_MIN_INTERVAL) + int64_t current_tick = (self._current_timestamp / self.UPDATE_ORDER_STATUS_MIN_INTERVAL) + + if current_tick > last_tick: + 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)) + exchange_history = await safe_gather(*tasks, return_exceptions=True) + for trades, trading_pair in zip(exchange_history, trading_pairs): + if isinstance(trades, Exception): + self.logger().network( + f"Error fetching trades update for the order {trading_pair}: {trades}.", + app_warning_msg=f"Failed to fetch trade update for {trading_pair}." + ) + continue + for trade in trades: + if self.is_confirmed_new_order_filled_event(str(trade["id"]), str(trade["orderId"]), trading_pair): + # Should check if this is a partial filling of a in_flight order. + # In that case, user_stream or _update_order_fills_from_trades will take care when fully filled. + if not any(trade["id"] in in_flight_order.trade_id_set for in_flight_order in self._in_flight_orders.values()): + self.c_trigger_event(self.MARKET_ORDER_FILLED_EVENT_TAG, + OrderFilledEvent( + trade["time"], + self._exchange_order_ids.get(str(trade["orderId"]), + get_client_order_id("buy" if trade["isBuyer"] else "sell", trading_pair)), + trading_pair, + TradeType.BUY if trade["isBuyer"] else TradeType.SELL, + OrderType.LIMIT_MAKER, # defaulting to this value since trade info lacks field + Decimal(trade["price"]), + Decimal(trade["qty"]), + TradeFee( + percent=Decimal(0.0), + flat_fees=[(trade["commissionAsset"], + Decimal(trade["commission"]))] + ), + exchange_trade_id=trade["id"] + )) + self.logger().info(f"Recreating missing trade in TradeFill: {trade}") async def _update_order_status(self): cdef: From fb62be09693178345a43a2e9f86a85e97ecefaf7 Mon Sep 17 00:00:00 2001 From: Nicolas Baum Date: Thu, 4 Feb 2021 20:41:21 -0300 Subject: [PATCH 113/126] Changed frequency of history recon to 120 sec --- .../connector/exchange/binance/binance_exchange.pyx | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/hummingbot/connector/exchange/binance/binance_exchange.pyx b/hummingbot/connector/exchange/binance/binance_exchange.pyx index fef15810d5..830aaafa10 100755 --- a/hummingbot/connector/exchange/binance/binance_exchange.pyx +++ b/hummingbot/connector/exchange/binance/binance_exchange.pyx @@ -437,14 +437,12 @@ cdef class BinanceExchange(ExchangeBase): )) async def _history_reconciliation(self): - """ - Method looks in the exchange history to check for any missing trade in local history. - If found, it will trigger an order_filled event to record it in local DB. - """ cdef: - # The minimum poll interval for order status is 10 seconds. - int64_t last_tick = (self._last_poll_timestamp / self.UPDATE_ORDER_STATUS_MIN_INTERVAL) - int64_t current_tick = (self._current_timestamp / self.UPDATE_ORDER_STATUS_MIN_INTERVAL) + # Method looks in the exchange history to check for any missing trade in local history. + # If found, it will trigger an order_filled event to record it in local DB. + # The minimum poll interval for order status is 120 seconds. + int64_t last_tick = (self._last_poll_timestamp / self.LONG_POLL_INTERVAL) + int64_t current_tick = (self._current_timestamp / self.LONG_POLL_INTERVAL) if current_tick > last_tick: trading_pairs = self._order_book_tracker._trading_pairs From 7605a08f3adfd46a253150524bcbad63c0bd0d09 Mon Sep 17 00:00:00 2001 From: Daniel Tan Date: Fri, 5 Feb 2021 09:31:47 +0800 Subject: [PATCH 114/126] (add) include bitmax connector status --- hummingbot/connector/connector_status.py | 1 + 1 file changed, 1 insertion(+) diff --git a/hummingbot/connector/connector_status.py b/hummingbot/connector/connector_status.py index 9cffc0c671..302a10430e 100644 --- a/hummingbot/connector/connector_status.py +++ b/hummingbot/connector/connector_status.py @@ -6,6 +6,7 @@ 'binance_perpetual_testnet': 'yellow', 'binance_us': 'yellow', 'bitfinex': 'green', + 'bitmax': 'yellow', 'bittrex': 'yellow', 'celo': 'green', 'coinbase_pro': 'green', From f437704a0f443a260a74d175d1219fa42a7682ba Mon Sep 17 00:00:00 2001 From: Daniel Tan Date: Fri, 5 Feb 2021 12:15:26 +0800 Subject: [PATCH 115/126] (fix) fix successfully cancelled orders not reflected in bot --- hummingbot/connector/exchange/bitmax/bitmax_exchange.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hummingbot/connector/exchange/bitmax/bitmax_exchange.py b/hummingbot/connector/exchange/bitmax/bitmax_exchange.py index e4d2c088a8..a05e26307b 100644 --- a/hummingbot/connector/exchange/bitmax/bitmax_exchange.py +++ b/hummingbot/connector/exchange/bitmax/bitmax_exchange.py @@ -727,7 +727,7 @@ async def cancel_all(self, timeout_seconds: float): open_orders = await self.get_open_orders() - for cl_order_id, tracked_order in self._in_flight_orders.items(): + for cl_order_id, tracked_order in self._in_flight_orders.copy().items(): open_order = [o for o in open_orders if o.client_order_id == cl_order_id] if not open_order: cancellation_results.append(CancellationResult(cl_order_id, True)) From bb093b19720ffee644e0fa227b4aa52262a2bf95 Mon Sep 17 00:00:00 2001 From: Daniel Tan Date: Fri, 5 Feb 2021 14:47:02 +0800 Subject: [PATCH 116/126] (fix) fix websocket disconnects, incorrect balance updates and cancellation issue --- .../bitmax_api_order_book_data_source.py | 6 ++++-- .../bitmax_api_user_stream_data_source.py | 8 ++++--- .../exchange/bitmax/bitmax_constants.py | 1 + .../exchange/bitmax/bitmax_exchange.py | 21 +++++++++++++++---- 4 files changed, 27 insertions(+), 9 deletions(-) diff --git a/hummingbot/connector/exchange/bitmax/bitmax_api_order_book_data_source.py b/hummingbot/connector/exchange/bitmax/bitmax_api_order_book_data_source.py index a62b4fc050..306b0631c4 100644 --- a/hummingbot/connector/exchange/bitmax/bitmax_api_order_book_data_source.py +++ b/hummingbot/connector/exchange/bitmax/bitmax_api_order_book_data_source.py @@ -16,13 +16,14 @@ 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 +from hummingbot.connector.exchange.bitmax.bitmax_constants import EXCHANGE_NAME, REST_URL, WS_URL, PONG_PAYLOAD class BitmaxAPIOrderBookDataSource(OrderBookTrackerDataSource): MAX_RETRIES = 20 MESSAGE_TIMEOUT = 30.0 SNAPSHOT_TIMEOUT = 10.0 + PING_TIMEOUT = 15.0 _logger: Optional[HummingbotLogger] = None @@ -247,8 +248,9 @@ async def _inner_messages( yield raw_msg except asyncio.TimeoutError: try: - pong_waiter = await ws.ping() + pong_waiter = ws.send(ujson.dumps(PONG_PAYLOAD)) await asyncio.wait_for(pong_waiter, timeout=self.PING_TIMEOUT) + self._last_recv_time = time.time() except asyncio.TimeoutError: raise except asyncio.TimeoutError: diff --git a/hummingbot/connector/exchange/bitmax/bitmax_api_user_stream_data_source.py b/hummingbot/connector/exchange/bitmax/bitmax_api_user_stream_data_source.py index 55d01a8f0c..10bbac6763 100755 --- a/hummingbot/connector/exchange/bitmax/bitmax_api_user_stream_data_source.py +++ b/hummingbot/connector/exchange/bitmax/bitmax_api_user_stream_data_source.py @@ -10,12 +10,13 @@ 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 +from hummingbot.connector.exchange.bitmax.bitmax_constants import REST_URL, getWsUrlPriv, PONG_PAYLOAD class BitmaxAPIUserStreamDataSource(UserStreamTrackerDataSource): MAX_RETRIES = 20 - MESSAGE_TIMEOUT = 30.0 + MESSAGE_TIMEOUT = 10.0 + PING_TIMEOUT = 5.0 _logger: Optional[HummingbotLogger] = None @@ -31,6 +32,7 @@ def __init__(self, bitmax_auth: BitmaxAuth, trading_pairs: Optional[List[str]] = self._current_listen_key = None self._listen_for_user_stream_task = None self._last_recv_time: float = 0 + self._ws_client: websockets.WebSocketClientProtocol = None super().__init__() @property @@ -104,7 +106,7 @@ async def _inner_messages( yield raw_msg except asyncio.TimeoutError: try: - pong_waiter = await ws.ping() + pong_waiter = ws.send(ujson.dumps(PONG_PAYLOAD)) await asyncio.wait_for(pong_waiter, timeout=self.PING_TIMEOUT) self._last_recv_time = time.time() except asyncio.TimeoutError: diff --git a/hummingbot/connector/exchange/bitmax/bitmax_constants.py b/hummingbot/connector/exchange/bitmax/bitmax_constants.py index 5d825d5c93..51ec061543 100644 --- a/hummingbot/connector/exchange/bitmax/bitmax_constants.py +++ b/hummingbot/connector/exchange/bitmax/bitmax_constants.py @@ -4,6 +4,7 @@ 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: diff --git a/hummingbot/connector/exchange/bitmax/bitmax_exchange.py b/hummingbot/connector/exchange/bitmax/bitmax_exchange.py index a05e26307b..43fbe7a05a 100644 --- a/hummingbot/connector/exchange/bitmax/bitmax_exchange.py +++ b/hummingbot/connector/exchange/bitmax/bitmax_exchange.py @@ -49,7 +49,7 @@ 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 totalBalance availableBalance") +BitmaxBalance = namedtuple("BitmaxBalance", "asset availableBalance totalBalance") class BitmaxExchange(ExchangeBase): @@ -604,8 +604,6 @@ async def _execute_cancel(self, trading_pair: str, order_id: str) -> str: force_auth_path_url="order" ) - self.stop_tracking_order(order_id) - return order_id except asyncio.CancelledError: raise @@ -798,8 +796,10 @@ async def _user_stream_event_listener(self): try: if event_message.get("m") == "order": 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( - order_data["s"], + trading_pair, order_data["p"], order_data["q"], order_data["ot"], @@ -816,7 +816,20 @@ async def _user_stream_event_listener(self): order_data["sp"], order_data["ei"] )) + # Handles balance updates from orders. + base_asset_balance = BitmaxBalance( + base_asset, + order_data["bab"], + order_data["btb"] + ) + quote_asset_balance = BitmaxBalance( + quote_asset, + order_data["qab"], + order_data["qtb"] + ) + self._process_balances([base_asset_balance, quote_asset_balance]) 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_data["a"], From 210539921bdffd28979ce038d10ac8ac80905228 Mon Sep 17 00:00:00 2001 From: RC-13 Date: Fri, 5 Feb 2021 18:22:42 +0800 Subject: [PATCH 117/126] (doc) update connector status on ReadMe --- README.md | 36 +++++++++++++++++------------- assets/binance_perpetual_logo.png | Bin 0 -> 14187 bytes assets/binanceus_logo.png | Bin 0 -> 5063 bytes assets/bitmax_logo.png | Bin 0 -> 202800 bytes assets/blocktane_logo.svg | 1 + 5 files changed, 21 insertions(+), 16 deletions(-) create mode 100644 assets/binance_perpetual_logo.png create mode 100644 assets/binanceus_logo.png create mode 100644 assets/bitmax_logo.png create mode 100644 assets/blocktane_logo.svg diff --git a/README.md b/README.md index 4f38ec619c..a68b288879 100644 --- a/README.md +++ b/README.md @@ -19,24 +19,28 @@ We created hummingbot to promote **decentralized market-making**: enabling membe ![RED](https://via.placeholder.com/15/f03c15/?text=+) RED - Connector is broken and unusable -OKEx is reportedly being investigated by Chinese authorities and has stopped withdrawals. ## Supported centralized exchanges | logo | id | name | ver | doc | status | |:---:|:---:|:---:|:---:|:---:|:---:| -| Binance | binance | [Binance](https://www.binance.com/) | 3 | [API](https://github.com/binance-exchange/binance-official-api-docs/blob/master/rest-api.md) | ![GREEN](https://via.placeholder.com/15/008000/?text=+) | Connector is working properly +| Binance | binance | [Binance](https://www.binance.com/) | 3 | [API](https://github.com/binance/binance-spot-api-docs/blob/master/rest-api.md) | ![GREEN](https://via.placeholder.com/15/008000/?text=+) | +| Binance US | binance_us | [Binance US](https://www.binance.com/) | 3 | [API](https://github.com/binance-us/binance-official-api-docs/blob/master/rest-api.md) | ![YELLOW](https://via.placeholder.com/15/ffff00/?text=+) | +| Binance Perpetual | binance_perpetual | [Binance Futures](https://www.binance.com/) | 1 | [API](https://binance-docs.github.io/apidocs/futures/en/) | ![YELLOW](https://via.placeholder.com/15/ffff00/?text=+) | +|Bittrex Global| bittrex | [Bittrex Global](https://global.bittrex.com/) | 3 | [API](https://bittrex.github.io/api/v3) | ![YELLOW](https://via.placeholder.com/15/ffff00/?text=+) | +| Bitfinex | bitfinex | [Bitfinex](https://www.bitfinex.com/) | 2 | [API](https://docs.bitfinex.com/docs/introduction) | ![YELLOW](https://via.placeholder.com/15/ffff00/?text=+) | +| BitMax | bitmax | [BitMax](https://bitmax.io/en/global-digital-asset-platform) | 1 | [API](https://bitmax-exchange.github.io/bitmax-pro-api/#bitmax-pro-api-documentation) | ![YELLOW](https://via.placeholder.com/15/ffff00/?text=+) | +| Blocktane | blocktane | [Blocktane](https://blocktane.io/) | 2 | [API](https://blocktane.io/api) | ![YELLOW](https://via.placeholder.com/15/ffff00/?text=+) | | Coinbase Pro | coinbase_pro | [Coinbase Pro](https://pro.coinbase.com/) | * | [API](https://docs.pro.coinbase.com/) | ![GREEN](https://via.placeholder.com/15/008000/?text=+) | +| Crypto.com | crypto_com | [Crypto.com](https://crypto.com/exchange) | 2 | [API](https://exchange-docs.crypto.com/#introduction) | ![YELLOW](https://via.placeholder.com/15/ffff00/?text=+) | +| DyDx | dy/dx | [dy/dx](https://dydx.exchange/) | 1 | [API](https://docs.dydx.exchange/) | ![GREEN](https://via.placeholder.com/15/008000/?text=+) | +| Eterbase | eterbase | [Eterbase](https://www.eterbase.com/) | * | [API](https://developers.eterbase.exchange/?version=latest) | ![RED](https://via.placeholder.com/15/f03c15/?text=+) | |Huobi Global| huobi | [Huobi Global](https://www.hbg.com) | 1 | [API](https://huobiapi.github.io/docs/spot/v1/en/) | ![GREEN](https://via.placeholder.com/15/008000/?text=+) | -|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=+) | -| Liquid | liquid | [Liquid](https://www.liquid.com/) | 2 | [API](https://developers.liquid.com/) | ![GREEN](https://via.placeholder.com/15/008000/?text=+) | | KuCoin | kucoin | [KuCoin](https://www.kucoin.com/) | 1 | [API](https://docs.kucoin.com/#general) | ![GREEN](https://via.placeholder.com/15/008000/?text=+) | | Kraken | kraken | [Kraken](https://www.kraken.com/) | 1 | [API](https://www.kraken.com/features/api) | ![YELLOW](https://via.placeholder.com/15/ffff00/?text=+) | -| Eterbase | eterbase | [Eterbase](https://www.eterbase.com/) | * | [API](https://developers.eterbase.exchange/?version=latest) | ![RED](https://via.placeholder.com/15/f03c15/?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=+) | -| Bitfinex | bitfinex | [Bitfinex](https://www.bitfinex.com/) | 2 | [API](https://docs.bitfinex.com/docs/introduction) | ![GREEN](https://via.placeholder.com/15/008000/?text=+) | -| OKEx | okex | [OKEx](https://www.okex.com/) | 2 | [API](https://www.okex.com/docs/en/) | ![RED](https://via.placeholder.com/15/f03c15/?text=+) | -| DyDx | dy/dx | [dy/dx](https://dydx.exchange/) | 1 | [API](https://docs.dydx.exchange/) | ![YELLOW](https://via.placeholder.com/15/ffff00/?text=+) | +| Liquid | liquid | [Liquid](https://www.liquid.com/) | 2 | [API](https://developers.liquid.com/) | ![GREEN](https://via.placeholder.com/15/008000/?text=+) | +| OKEx | okex | [OKEx](https://www.okex.com/) | 2 | [API](https://www.okex.com/docs/en/) | ![GREEN](https://via.placeholder.com/15/008000/?text=+) | + ## Supported decentralized exchanges @@ -46,16 +50,16 @@ OKEx is reportedly being investigated by Chinese authorities and has stopped wit | Bamboo Relay | bamboo_relay | [Bamboo Relay](https://bamboorelay.com/) | 3 | [API](https://sra.bamboorelay.com/) | [dex@bamboorelay.com](mailto:dex@bamboorelay.com) | ![RED](https://via.placeholder.com/15/f03c15/?text=+) | |Dolomite| dolomite | [Dolomite](https://dolomite.io/) | 1 | [API](https://docs.dolomite.io/) | [corey@dolomite.io](mailto:corey@dolomite.io) | ![RED](https://via.placeholder.com/15/f03c15/?text=+) | | Radar Relay | radar_relay | [Radar Relay](https://radarrelay.com/) | 2 | [API](https://developers.radarrelay.com/api/trade-api) | | ![unavailable](https://via.placeholder.com/15/f03c15/?text=+) | -| Loopring | loopring | [Loopring](https://loopring.io/) | * | [API](https://docs.loopring.io/en/) | | ![YELLOW](https://via.placeholder.com/15/ffff00/?text=+) | +| Loopring | loopring | [Loopring](https://loopring.io/) | 3 | [API](https://docs3.loopring.io/en/) | | ![YELLOW](https://via.placeholder.com/15/ffff00/?text=+) | ## Supported protocol exchanges | logo | id | name | ver | doc| status | |:---:|:---:|:---:|:---:|:---:|:--:| -| Celo | celo | [Celo](https://celo.org/) | * | [SDK](https://celo.org/developers) | ![GREEN](https://via.placeholder.com/15/008000/?text=+) | -| Balancer | balancer | [Balancer](https://balancer.finance/) | * | [SDK](https://docs.balancer.finance/) | ![YELLOW](https://via.placeholder.com/15/ffff00/?text=+) | -| Terra | terra | [Terra](https://terra.money/) | * | [SDK](https://docs.terra.money/) | ![YELLOW](https://via.placeholder.com/15/ffff00/?text=+) | -| Uniswap | uniswap | [Uniswap](https://uniswap.org/) | * | [SDK](https://uniswap.org/docs/v2/) | ![YELLOW](https://via.placeholder.com/15/ffff00/?text=+) | +| Celo | celo | [Celo](https://terra.money/) | * | [SDK](https://celo.org/developers) | ![GREEN](https://via.placeholder.com/15/008000/?text=+) | +| Balancer | balancer | [Balancer](https://balancer.finance/) | * | [SDK](https://docs.balancer.finance/) | ![GREEN](https://via.placeholder.com/15/008000/?text=+) | +| Terra | terra | [Terra](https://terra.money/) | * | [SDK](https://docs.terra.money/) | ![GREEN](https://via.placeholder.com/15/008000/?text=+) | +| Uniswap | uniswap | [Uniswap](https://uniswap.org/) | * | [SDK](https://uniswap.org/docs/v2/) | ![GREEN](https://via.placeholder.com/15/008000/?text=+) | @@ -65,7 +69,7 @@ OKEx is reportedly being investigated by Chinese authorities and has stopped wit - [Website](https://hummingbot.io) - [Documentation](https://docs.hummingbot.io) -- [FAQs](https://docs.hummingbot.io/resources/faq/) +- [FAQs](https://hummingbot.zendesk.com/hc/en-us/categories/900001272063-Hummingbot-FAQs) ### Install Hummingbot @@ -98,4 +102,4 @@ Hummingbot was created and is maintained by CoinAlpha, Inc. We are [a global tea ## Legal - **License**: Hummingbot is licensed under [Apache 2.0](./LICENSE). -- **Data collection**: read important information regarding [Hummingbot Data Collection](DATA_COLLECTION.md). +- **Data collection**: read important information regarding [Hummingbot Data Collection](DATA_COLLECTION.md). \ No newline at end of file diff --git a/assets/binance_perpetual_logo.png b/assets/binance_perpetual_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..be83e28a431e2d93a31bf028938f27fe24e9e794 GIT binary patch literal 14187 zcmZ{LV{j&2wDl9)wrxyoV`AI3ojkEMu|2WvWHPZav2B|-Z`JqbtGc%ur~91RU43>{ zuYLB~t0NWVB@tk8VF3UDg0z&F@^{0Pv;+0DvI?!0UGx@E8DaV+H_D4Zm&E0RSw=><%Tq?*T|-SxGU#*MD1ncWKgh z50tC4oH*102qp{@_QYl8A^^bQAT1`W>bZL1=i#X~`1r`%nd9%QEblD>1dw18y9S}3 zsDTd#5u6ulh%3>f3n!AgqadR*!b)MCs0n$e=%W%BKRK`3vuzPk1p*3|d)_*qM&J0} z0<#;RCNeWTYPA~8tGR^`tgi*ZZxzsAO~06#C1xmRqZjBAQTll>9oS5c0<*tdeQd4; z5kIdOuMfgS3QoYe#e?`qYM!R)4#y}0Qy#5P#3h`VSzYwLPB@mf1F+Y(w3b7RNVd5R z$Bi1T41JcuKU8$@e46ej_)-`bWyUdX9*au8P97Jx1MmZ~!u;J6(C{U%C#MVEmugLW zXE|o?ypvT920$M=5b0#s{ny*HcC}ti_{IK;Yt_G%T?uYH;=Uv8I*{DYgNf#=1Wg}> z-`;J?60OkozsV=)u`xqRks=coFU7i@8Vyg&W(9$j;Sr-c5fqD>RSSPI0cD0g80KgGC&AUWfvxCe_lc0h z8$v12?sx9a-#hkNV!|1K+Cq~lO5R`AG|&566dEZ%-U!(E1Q4a&{Fw2mvB4T(Dd;yb zgRo13V788o?&kv)Rr6I;ID_nPvB7S~#OT)vfg?f~M3am?6%#2c^it#KvsFEK5=u~D zjyx)Vii*w(y&6IBxzc^~vMw+212M81>OE3ns>8gQ0{m|!LBKWB8D_TZ)NP!RDIG(Dc_wo5K?r#Mxhv z);U=`syzv^GNa$6y7xgb*@}EvLOdZjg2AZSFC)Bn4TQL8f&ONM6UqX|kzG6l15DAA zbEQ#`{{0;c)KjzncT@5H`}PX86*zG8bDKMCNq{c+S_AsdG=Rz#*0+^3JCFDAy^R;2iag`Aet< zJ}L=!+z7gbyYqjFZOUu@dv($IBlLk|SDg>#~XQ0?XCbRhFmo z*NruT7@nAd@si$f1s+|Mim@=LTa&#%M)t*cY{hAxLf+m@w+#vb@b z8C0CCzFk}wNtzhHgxHHhcQuggfn(&<%3b5k(YBlwGvkF1R<<3JTU-||7i^w}#GZ+} z%zkjwX58OK3ZScxP%g+Yu+$3j(HIR;qN`n>_4Q-V zSE9c%9@6B#u^*ea8(9ki|2De)F!PVL{_rs#5KufZfQ6ZMN9Psy0(kcl5#*nNk%Q{d zK^dAR@cC>`me{P9@3c4OL=FW>01sO=I-U2^_Np^3^%83jr_10g#wJ^EJm-6UO6rg+ z#G)WvtQ8Mp9V1pp*{|@53{)ma?%zw1K8>O&^4Dq~em6_N(8~FxtJyRZpka(J-X3oA zuPcya!*~G%*>vBfm#`Cv;I#7-3fSgIWST-wv83{w8_YE~u#ap$8)6R<$!i;88W;7Q z@A9G%URM2OZMD5UN{#nLc=FU{ZxX|dHs%Y1+=B|>;AVF_g0KHhFQv^&`IQo)X@)HM z&bA$jU+^|UCF_Alk-ACDfql26S6hN+b8=0itE_Bim#I|92Uv(JTrueNy8bNYIgR7Z zOPnqC0{0g@Uh1;TI;M89`S^8eS>#DL<0e$}4quP<==}Fgbp&iv$9{ z+YWkf8_gcy(=$Vv@%Dp4Wh)b49LDtX8qYW9v_D$1C{I~9{mj)d<% z$4M_lBnn(>BEXi>eRay2o8*fZR5;#=5AE9nGiO9N`1<$^U0S8w%$`sqxq)vU_*d%F zbgg}8-_%oMIYP+Y25$1t@m%or>DQ_$fIRRIISV%V*H7V!3u;(^c@~beI-4A-7rTD_ z_pwmqw$7Adx99^uMw6aaC255^N?XOc9Pf^4+sD}P`K1kB2p;{gwF(?QX0uY zQ#R-I#8YP#$)uN;;Trwh>sPH=IM`&rly1jinXf}45;U7POD!%DM4Y>jCQoTbjRLJT zf=3uzY51gS=gU&pd(DDg2iFlEt=|TSL6cd{S(&Yq-_J_1O9^M&T5d<@E>hn-2-tG3 zGKzPef-#V=L4v{4;o z3G&WJ4!D;2{W@X>vkm_}$J%()D9AqQKpcc~=aqfTVoHE%3QJLOaP4uu;CYMB!47g6 zZhuo=_WSz{R(L77_g1eS>s!{r(gK2Ua{q_6&*3?*U0L^6708u8=CehWz&RQy2fL`G`1lJGx;Em-zwz@lAK@jbHl8<-raI@UgKG> z3BWkpS|l2BY(F-9GO(02#&hqcZ~RuTCr$6=O=4sO>*igI^Hjnb34B#^```%2kVRRU zSA^jH@Mkz#DYRvpub)8!ty@8%Kpfzq=pW_<-guJ#rb#14G8+6jiu*q>wY>5Qo~x?^ zvB>CwS-7z6I62?EEfgxGmMj_#nzRZO^t8JX8dS^3=-`T^@xj%f$>8Z$oPY<6>Q}@^ z8gR4REQ15WxTg_Nz6Jx%FA{{0F+(b92!TAJ38rUaKhH|Y&H8@#4bP%L;^J3fV?Ye5 zXeGD-XRPtzFM`xk?w~sd1X>8mKlNruf+i|w$=fsB^A0#K=WxNETCAk7x_8B^w@I_| z{2?rT#)Z&G#(GkXygmM98o7@eX9wK*_Qi9x9W(QE97H){pfJfE3gtN_SJlJ*)tN2&NZ zGSQ*#PvPN~`IWxQegLcktx9CpKzO5ZJJF`#leup(hg3yEB+IsPu3~yB1ATis*#$i{ z#yU#BfJ-r&-Y!{+Kw`hPqkj1|uj3HA`TImjb+C7d@iMTI0K5b7T!4f?AdTje^g6|1 z*6oq>W<*zByJnvv1mt={pg@w35L6CIAm<4EFkY5uv!TahVsc~xTTyqa-~2c7^Jy<9 z9*#1qpPnb;R?V`RSJaWC|KEI6X3V(i&QE`3Hc09U$MZJnM_!7c|Q z`EZL6Vbg7B;@i5x!4zpi2o0214k}XJx0u0^nWmce_oIQksV?@C_pJ2eWkJ=US3UKr zw|D%*nx;(x+_EK5z!-3WzJPkMoEIK-545^Nl}Q3`-9c<23d$$7qVM8jj1>}m)wRaV zZ|i#WE7QAJ4b09g{AiqW%LN?NJ= z+Uvlwo2ojF2$Xj1W+kD3K=T=KE^vHYyN zDzDO4X>darT5%V(?g||#PI02oE)$=O5DCNtT;&J~PS7twpi3>t8f(u+Ps;U(W%j*-zVL2>w+7j#!PZr#zn+Is5+iBYnfSLVf^HcK92IC}uR%g?u zu*Ny_ZY)z&y-@(*)&1u`9V+iu-{!f^fJ5u?=i#^gN>HQ;ZDrSifD0<9fKhiv2=3nRU)6)>kp~i z(oW7BictFd+{DW@;e%WcR8aA+_y1DmBsWxlf^Lg28d!4lA){33fP6pKZlS*ZDA=l( zD{S2(kg4+{22-XckiDc(#Urq^#sX80D&!c;6w@!dxa1hAb&;U#5- z8Q7w*)c?UB%}Rg?o5M1prQ|qJ58IIV_r(Q&8pgMIlDM5h*S{T0u#!iKRHLOC}2I;~^RtVmo4LaCUfwMWa**q*b{=OaO%gE={!m2yAu zs4?o?OjPsiJM+t6L^=$RK023?ku?9z$J*V(q?6sCRg~8O#OOikBolPYe?Y0lM3L1A8(x-sVUBwt$sqE4v>M`fPa6d^24=zY0&R z#}MrMLv?qqZ=zdbG0D1r9_wV{7mA?}P_{dS+q1Dz_oI41bKJnBTX-S zqe>LDmOYB4&ae}c^*m6PB;Ji~Fk#aypnwCo2PERiJS3E7d*4=p?xQ_F@qDRw*yQ<2 zmT;&!2Lq2vMd-qIS8y6%R%})&X`!V*n!4a;`0V-wYj|I;e5Yhz;8L^zEF}b9S8!s3oyAGj1*1$$PNFOigB0|lDkV#HOv%!vW zJxG{umGH5ctSQ|!I=olzmp)b(88#|bEoVnBPR8$TCc@$_Dl6a%P$7)c94@JqdclDB z()Neq`-?|rO+G(q4w!LHZ$&yI)ncZ=3=STMDAkV#q;z<-Dm*M*LKfI5gh`4 z#E>t}5yB$PUBtXZy3lA$G~d(E)rL5A}24F(>@ z4%kx?YTfsAnw}{QtXZwh(eyDHSmtBSaLm3Hj1T{)ut&!xn{k_xg}zZmq#6b>Cu123 zW97QY6 zqkC+fbnHVnBT+lI;6-7)+Ryg;C|r(KFTkRaS}fFRgm!7B+mD({Vz$u_yvK_It9(V| z^|LX|LVF>pG#u)Lzdb6qBI2PhGWyT0fjgc-89|Lj76SZ^J4QDTR{>HHBv z=YF5(HTWHJvj*wXT8P-XF$@dftV6AgVnFKU89H1Vs2Z%ZrPZ43Fv2(<%*dCrl$aQG zFYJU^GbnJBmC2VxOn5LDaFwD^;fvY>(N&3~kc11a_V;dHBT-R^02aTI%BCCZ# zXf}_KG0+XCjiO*5kNz<#HnzVkKTea=hZ%P7DMV|k+L)Nbu=^Y+HRY<*8nOPwFr1C| z6g+V;+jxy0SU|cWN&2Sai64-<%Anxpn=IkbwOoz%)cEQ_`SY4{`O=DNkE)qGAOq*! zoY$#b?DL$(`zqP*&*H=L<&)zkJ8_eKtgZf{E`@CrBSGB`?9{z2Ldzz)6fjCxR2>>k ziJhQDW^;eH$6aLY@9fc=w*+5dr`Ju2MtSnA^Y0YbZRCeq2}K>6gcHw!CP)<4e%+i8 zvtYe@Y38ZcO-IRYBc?a#TzdBPElwpcUaEhIfG|fJ%(QcSD2sVQ*$v1r9#E}0Ei65qxmd-Egb|gQt;*#grd__Os0`T`HmW@PDdjuLI5d_7XN>ZZs*X`g8-tleulT(lm??+Sk+iPX%+Xe^Bm~W0qC;p;h z%FF-}0*F~(Lyo%BPRN%2m(ltwl7cz3J1A&UVm~#=^aGtr_)lm5rn8WnqAt{2^ZjU? zwfM|DpWB>Lr3xkr~z~?X!yOv^j~K2H!sVI3{>Pm;|qT%nK24_ZIy^T0bJ(i@YXwv>`_z` z4kDJ_BrKVk9|ufr_t-#Vpd&1FDM0LI_)in4YEF8EgbR+?B)BMz#g9`B{!feV1eEZM zC^aYDdx@!26hzH?_tg(3{GE8=Y!3W6iz2B9wFVf<214GjI^RrX&^+s?O}1(eUO zrv@nGR>?;qpgX)V+get!E~kdmEkIc;st+m~Gv<2fq`k>r#K8JB$nd%HOf)kMX!q4x zC}w0PX=0OEO4;D674jxUYEudMmS>}T?96n3P76h5Io!cmVi9k4K$ zbglA01F`{&SDd;Y)oT_)@+`uorkv2!pAU)&y&Hh*AvBVF^k-M4{OkGUb58AryEw`i zY48aazY;&OdC!t;lrPsODUGz-7vr~Ll5V|F{GB*KwgOj|&fiK>F)G)i@h_Upn=vjK z#=Cl>n+|A&|2Vh@y~mL7?53-7(%Wm6bd)@=Iu$4MQ-cqZMh5ddbo6Cnl-~44NVbMT zt+)~C26wB4XK?B)Khln$`je1aWgG*n*=CWWLlOpcs+k-}r9SM%1PHV4hdt0?K;VC= z9}QgD#@q1FYm7j01bi=nwD`Ro51Qm~0wpowh^eeXYd!$`bUE)-Mr>!;ACBc6M!i<9 z32HVoZ8dW{kc>3Ukn8{chVMFGP8}mRTa~2#oJMnQfO}B@oA74oTuq$xg+)O?&9%d> zBnJ`F%*S13UVPdfC|dVP%Gxv}P*n1B;)V(qaEE;nWq}#8+?+NkAt*^2)KnZNotwAlXa2fbnvlaok^9G)(eegdWvQpS8>}!# zJGlzw8vof1>gO~a&uCYRZyW# zT)YS|V>tJ0f2S|EmwUL2#H?zgV;AN)+scqlq_@s~o)3*?*mU;dL{eSC3_OjyDDgms z6J8wh-v5I|!&i?gSqm1T>7SE(O3ZLT^6T*8zxr#Kbx8~^Rl)G2j&7{gGk?1f#IWch z7&kHuMigqYcA1s%n%V!&itc+lr2W+{hU-QJ0}}xhjTj==F6Y0PWv3=iS8EA4k?}kJ zdB?K!g)m^E9(r_`;Brbdci=JgpSJ!DcP3f+Po5JK?0V!F+Uegyl_aR$L?3p(CAbA^ zvDs74L58O-AyTlX_?a@KW-Pp{yXIgS@&#IW${$n1i1@izKF$&DW|v>tO4W*Lug4cw zC5AqMW?2YUZ}NB&>9O~vZH~SCdIY4wZZUp`T|I9h>?8{XOA0YbKI)G~VhNadrc06( zf00*8M;3^c`i+Z?4^q3o^H^eui>zF#Pi)J1EY+hHn|Z}{I&S^$%2Q1}3LcPe+OyE@ zP0AzC0tz5>J<=DS1^U;E%vpkb>8=OM@*hG9f}3Cb;P#?pp+<+dgY>qVZ6J z@x%+nwIr8*pGGa}1fI=MC_YQQOGDlSs6jmt1bHvnWbMbFn0CDL>8jW3xmES);X<7? zXxsIiSgN7d@%CN~F7_P5mqTX%)hw4hhrE44qC-V6S}>Du{^RboCk{RR;f`^9hNwk* zs(ZN=e$9O=d-^k4FLyH*l}zH~Fig}4Cw^-_W~+MLOiQ2c=Rgbv#PUqnD?%!spQG2^ zL1vbOuZg5NN_aF@V~gKSnBPxes6Q&NVg`(@| z8NzC?WXbcKi_&N`jIJJ&Kzz1H#kU%`1~YR z+q12?jP}`tmy}0|=a*z{nINzuz%ndI0<%*!P8*=ES^v6d;QJXc9uq;#c(g=zXz_!< zfHOkvb$Rj=OAZwqp2NH>!n}(Xo32&rnot_8_w|FXmt#c#gWa*Ro)laWJpmA1qvo8; z>#ff!??dr4x&K@_s!Jo3ANwm=qQ@flM30B25!ib2kA7t7ym?zUb-Br<;pr^|yTr|H z-qJM!Kk3(TX1!x{`%}HjiQ$qfIY^yvbm-S*;Wreb$PD z;5cn?Ao`TE{FPz%`=+%@ZT8wo6hS+9bJd%zwj;3#uBGeSM1#n@IsC z-EX_fNkWMmN$X%+h0Bs>I3I=MFopH6_>3?m7@X4^SF#Vc?g++X&8ppM^G6v9#bu>O zng2#suwJQ{ZzJ`WuXSo$$MWF7-d>*z@lN3V>@Z~*l->x&_B`~>NVeh%k^V|=vMReE ziMs8muxDLZDP)-8?Jn>{avJY2wS#+P%}Z0Tb>FFe1j zaxyoFiiM;`mcI~CTl2=R1yR!FO#aW)aZ+GBT9k4y2EL&n1aR#QQY#X%v{idegWq*) zrwdD!(&iaFe3WJYjkGWTO7AKJ48^U;(#8hE$CSwiX|l*qk>BCgwdfD$VyEUo4FbFW z{mZeWeyK-J*qvd>81)r(&Q_eqi{kpB|EA+I=Y=yH5OmLH{NHQx_=BiqGGSO@DIa{M$^ph zgO}&40F=H@lkSG|U*?{PfznkuXb>UB!#l|WS$%TA5$j+Mh1oh6AelA4F1yOBLP2Zg z6@kJ)SeV^%51h3p7$M?k$0t0oYXRT)j&x=cTM*bX<6a%o2d ze_4YUky1{LN@mX^*2*m6iVv+!7_QsVt3!j(IH7EX>XH=A4Uon7S3U1CbyhR2Ot`9V zNO1>}E$zFoY@2o@cYdUwJm5q_WuGmWKe$2Eu3HU_x>{xYZKTP`e3b}+IDKy}h!qIk ze)cjNtylg0P|)Wts$D_xRl~7r=b_|NI@PWpzm{+WKD+I$TA$gpj>3R29>9$~79Ap` zeVFU$2xJQW^Jx5)~;0&>+FTO6PZj`HDj=Ok0_m(}RpP<0V9O4&{g zFZHeLcTHuRE6kZGvjx$we)UF zgxYw}1^imP(>9mkC2iL8N2eFrW!J!yepxUsA8h}X)!c3gFa5x&I88eUW|Z1n?2HN?YnFF)3&(_`?yd=-?Kjh`3H$j4|PnpZgV!)Bt_-{xlpbZ zWPPOh>#}{q5S_6}G}w1pp;4y_^}9j^>hy6f>M*9K8!YhlM8q(kwn>GkeU<=FJ02ly z>OH4wS_;E$?f~eQOgoK;HA-$!K=2^ABff{q4tq|}9yZ6t{rFs^C|BV*EBa9I39S$p zwj3zv1iH#0hQ5j*5QINlvt=rbzmd`S?JBG#C+T1XGI6gJpQHDB_pSsF&0C^KK0WH+ zn)A&#I+`-uAa5qs2%B1pV2^F5oo6t1i7Gl!^yq^|-bH7NpJScjsXLG5m$tk!^LS z<6-sS)@l0}g=CIV=#r&qyOQKl63p9**ni0Ho^9X_B6!9A1C?9|4gg%PtZjD{gd~IRV3NUN$Qnku@MSbgeUpgc36B&k((1k_ku2E9RA$aMx6^QQVg51i?Z#e*}{LO`mbJ+`l!0FqY@3e!^P1y{Wne&-*iU~S_N4spFdiv zeeXB=4;*w5s?Y!*61R$wJ^AQ&z7^LWxoFD1N9H||9B9q>d^XSQ<#7!yPjsK&kZy1T zg$S{uF&^&m-^lwoLccxDux5?h??nA#CCZoa`FNv&_qVJYx+ZFf?AJ&aWMGN@7+QG9uV`}qeJ5& z&fVNapjOuy4jv*=}{?L^*aF*OD#3WpBDUbfJB@t`rl|Hv$nA0M1p6z zqNol=?;2h8JOJ>>L&>-5drxs|zZ%B(s?jUFO=JVEVuKkExY`_xO{}jP*aWO~ z&qMGy$ANv!3{5d2Fggl`xl zDNWbGL>1zM12!sE&FO5HuN+|IF^0&F=->XM4#`qJcz+M-+D4?# zHN{;qf;m)>-R^3NSi)@Y1YdZkedjtg;PC!72Qv2PBtl{dkG=_dI&I#qFU+2EY*+ z@WXHwrQg>+#NzPJ@nxDmKewihF@tDY4wVmw6EYKEwSiatGZ`G?!HqOVW;=ud1@sWL z5_`l0|Bo`XkzW@rI|zj-bE}DcJE}18@eCjUzC}*t5R>#f7Z!hP3-1=Wpj#y=hzz>% zNcdT>D{E4PkLTv3upO169{c$QF(MW+=uSqW{x&`!WZ=KfcFPhjpA+e z$M3)?p=g=*3;!b!6ZkY2oXXgAbD;iByS%565?5v>g};IB&;@9|kEwAyY=MFpczM6% zT(L@WVjg**(omn?#Ev^H?v=v3`KR|gZj!1-qJAP>84I#R&3>gaXjqJIJ$(`IYs&n$ zcnnB`3_c><1u|a6k6Nq}flXRrZlCF_TqJ$}J}nXsc0DM>Qe5{}Ut0#r8pMQonl?6^ zu$=1F@@HRf5_4ZuANKN=RgKbXLLQ3ANk()J95J!QIK|9Rh|_NITp4?&(mGg!8wNJ@ z$2^ps8>fekR!2W9qH^}5+WMo9z!&t`U0+d=R36@I)(I` zFD=PHP$IM)<$Beo-wo1(Mn{=R#!j2==f%C_RRWC>BBc$+%>@tH2qUToBH=NvWOU8c z!J=!QJ;blv7;*DBJzcKPcRh8q!AMscx8*bvvMulYsx~uGqK>nQ-(AAds=&@)j|c_q`xtIpxhPaZs{-dR+rah^KZ9i zrEhn&YyEEP8?a&NQ+u11jMN{BuXNs7h1Tr+d}y%8YURTZ1q?D0ma^5jcFPCOXF&2T zG~?ebW-?hQe338{|L*;wc)4#zcC|X(^^7}#2N>M2dJygWee`<)U`q(V9N)*QEBpBv zi~R6EtRUXUV8fS2b3D{lU(H>aLxf~^TXya7Q|9K*vkR;-$VkQpiq(s>gRAAgygc*? zo9v-()aWzAz_JZPl0T|CySXykbmGbd)Od@?x)o$x28>9qrE~J zeuC*~vnN2QNI+^}geilDW8`sI{ytw07!xGlXZm&1p^{UPM9^vg)7ig|W;iPvzM;bE zbb*-_ZU?Eu|Bxe8Dr$#?Rr8OQ9z&OkDGH8hNp)e#sZpdAv{e~*wE1L z;rEJ=#YZq$@+Eiz-aB`%f`mM2Fh!1oNm4Y9=Y=e#`N`P_+yU9R!@^56#p|U0Xvn3J z#wgz`QJLYXkEiOujRTUjPL8JR1tDs_ha=?i_pH5Dl?mF7fNEbMflS|N6aD24)73>z zc9X3N2jMZyy>~OAx&nWv#~sNyGUSB4Bbss+Lb7bdg^gEbRZNVpK)}-|Eb9|Y;D8Lv zg#$4gASQh$MB~uE34293&Ljln+?{>S4N86kJ;aiygi-5m^Hg4ozBRt#k@1f^30rErbnrS9`ir0d>^`7y`%IJ05g5c!|b zw=`B9AFkxRxM3;H2bH!oZ5RbZjtZ4Wl`lsVhB2c?e6&VU) z1;HGeWb)z4G&m~oZ;TzEHiN^1Hp(TYvs)(Cwz^c~x|q=#SfCni|NYg#iAI7*P(vHe zb&~w8veCVNPH#im9{(_JlE>zf5b`SV&moIZ-fr}w=wk^wiHFh?iahPDGzW(I!(B`j(;0t<<1YojD#6q4E|1CFNNRW=au8qhQQt zcLJM+6VgOWt6!fTD@M+so4~$<@CB%yw=85I-QM%8%8AUhJ4Pvs-jwT3qo5Z0`|j@? z9PTSYEX6JAIgDnqrVvboeJZMM{A<~8iQK#L6?`CFRunvtoOx@ONdDz9m;L#zK;#BT zEUXLlTMUoBr(Y+m^c-d+tSu;h&?`HRdE4t--j}9BU(C;8KB3WR9(RgoJKrHo>ei=A ziB0bQ76J)`uxXtA5t_dy`%v$*Sl-=B*vMy%{D9Le@-H=DTa^wVq>jP*bhRs%AG({> zYsqF1ZHD*d@EMamSZ1>8@o)U_6gBJs&qY?<&sZPpa3+)LFLm{QIK@y2%roQMQ`SyR z_~jgaWo7}q9AP?WP_TfegImg|E6)VxWoeP5!E#q^rRqHHagC3C*P|<+sSo1%^~Nkx zJ4VQZQ}lSelx%0)Gma2SMWd9{JV_?JU>dg(Vbp zkCjpmjq%^fru2N1d+qLQ8xW9wo6AaiIfy0STyEQUc6%tF=Hu%4d2?65c{w4H7YA<_ zMrpi(HH_rIJArgD6EazW@w>H4&%i;QB(HTZ-D!EAr~~Vva~wtzFqn-Iz?AV-3skMT zvuY*IgM#Ln0wa=lQ%(d;P z=)Do0ob@#y6=|6MMxN@u-Ger=+`1jp@=zxmu!#pBIN0k!aI>_-TT=4oti9@%{U?Pt zmR_o4$3H{OTC*@PPBmX59Ut#@>7PGVz#9Ur9P!-&=_3Xx#31%%W6Z*F`g_$l?X8ZD zyw0u;Oh391v^f;N&8J;?s7DP(^L^_v9RlR2K5FcG<@<9cw*{?ch5RnIR}BA=CH)+W zpkmauBf=425_>1vdYgP+{B2i?N{sVD5 zv2&XcB(gI!Yi__m-4}ngwhceS#zDx+ucSNGjgC^h!4Me%L54Y|88|MBm@|!0ir`;6 z6*YS_hl1z+5ZTKE!7g+EZhRS30qbODe~D@WexaksBAaSAGCYwO$c($6%@AHp)y3*X zLK=a{F4M#z837T3;pw-(Jt{kgG_SrIfcvRgf2jg|HwFO)c69BZm*|o-E)3>?f5Ua6 zFfL_J@)XAfX8@yG}2#>t)CY+>Y83n;P4N8?3&%RE^R% z6rGkN9dd_tbJh83>9*ab|0^y{kA>cWR#Y52!b)}0dCbEoZLuW&+T5eSU_!YvV513N zw;MRmJ2QD(qN;x>Yd32(U8;g)ah{8n%F!a;zu>aQew+Y*DzvAO2VZle*L`|gnfSuG z5qdHN3ceYKt+qQ+ivH~tU>v@Cf(+xSB98+|NXa6Dvk_g&)M7z~MtMN}mKdqy#YujImqv6cs(fNVq22esJbp z2}~3)B^o;ODiBRq7;qOdo%hOXtIh7l{&cwOr zy|`P|)SqWI#fJ=Jj)n&K>hj!C9~STo9Y0*A?KRK5l{FGx1}!MtusrPd#59QqgPOlO zGuu&vJD`FA&<)4L0KSH;?uU=lUpfMBmRHEh9Wd76m38S{j^Nj{@DvB|`8=l9f4crH zNfO5S^T0#c^Om(DhbSg{Ll=U%+49@<^9Bo(E4oFoVgV`! zin5q3N_ul2?FMGQ;#i9@akm9E;(*xI3H-3XnMMcUp14U+h`K5Ofp->z)I4Bj`0RQi z8)duZi~PX>VFf(QE+xJ3gqJ%2FyVZHGCXnOTOn^kMO_KvTlo3Z&qb?!q>a_?A2h;PP)n zBuCemtz-1X7Iyr8E#>)B037%HX4CpO_DrJ+HRMwzi3`G~tfN92G~lA%wh#eLAKw1E z^_t{#c=_RS6<6Td=75l4b8Qgz)+q@%4(Z;?oS~-~g8wPLl+4WM}!1VFYW%VBCfL|NWBZT~G(vr_ny9{`)^Q0EMf#rmLxu zs~L}pv)Ok8U}a`yVPIxqU}aTh=H_AN_}=N5nR%F*ML;3Q{yzgd2U9C^um9VC&7eE) P+W;UfE-zLiVi@#4TO>!C literal 0 HcmV?d00001 diff --git a/assets/binanceus_logo.png b/assets/binanceus_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..9f3842b4b6a0e851c3fc1a4378a8b4d2d5e368c7 GIT binary patch literal 5063 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 literal 0 HcmV?d00001 diff --git a/assets/bitmax_logo.png b/assets/bitmax_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..d1f4566fc6c961740c18512b00aff6dd46f12e1b GIT binary patch literal 202800 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 j)mzWrgRhoqU@mi1qQV8$Q1xHQfep-DIlNF&moR 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 F{2%O9?^FN) literal 0 HcmV?d00001 diff --git a/assets/blocktane_logo.svg b/assets/blocktane_logo.svg new file mode 100644 index 0000000000..cf096a3e0f --- /dev/null +++ b/assets/blocktane_logo.svg @@ -0,0 +1 @@ + \ No newline at end of file From 64aa18a983796ad96059fbd9769084b2682eda9c Mon Sep 17 00:00:00 2001 From: RC-13 Date: Fri, 5 Feb 2021 18:40:01 +0800 Subject: [PATCH 118/126] (doc) update logo --- README.md | 2 +- assets/bitmax_logo.png | Bin 202800 -> 28370 bytes assets/blocktane_logo.png | Bin 0 -> 19872 bytes assets/blocktane_logo.svg | 1 - 4 files changed, 1 insertion(+), 2 deletions(-) create mode 100644 assets/blocktane_logo.png delete mode 100644 assets/blocktane_logo.svg diff --git a/README.md b/README.md index a68b288879..f5b5a5f5e7 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ We created hummingbot to promote **decentralized market-making**: enabling membe |Bittrex Global| bittrex | [Bittrex Global](https://global.bittrex.com/) | 3 | [API](https://bittrex.github.io/api/v3) | ![YELLOW](https://via.placeholder.com/15/ffff00/?text=+) | | Bitfinex | bitfinex | [Bitfinex](https://www.bitfinex.com/) | 2 | [API](https://docs.bitfinex.com/docs/introduction) | ![YELLOW](https://via.placeholder.com/15/ffff00/?text=+) | | BitMax | bitmax | [BitMax](https://bitmax.io/en/global-digital-asset-platform) | 1 | [API](https://bitmax-exchange.github.io/bitmax-pro-api/#bitmax-pro-api-documentation) | ![YELLOW](https://via.placeholder.com/15/ffff00/?text=+) | -| Blocktane | blocktane | [Blocktane](https://blocktane.io/) | 2 | [API](https://blocktane.io/api) | ![YELLOW](https://via.placeholder.com/15/ffff00/?text=+) | +| Blocktane | blocktane | [Blocktane](https://blocktane.io/) | 2 | [API](https://blocktane.io/api) | ![YELLOW](https://via.placeholder.com/15/ffff00/?text=+) | | Coinbase Pro | coinbase_pro | [Coinbase Pro](https://pro.coinbase.com/) | * | [API](https://docs.pro.coinbase.com/) | ![GREEN](https://via.placeholder.com/15/008000/?text=+) | | Crypto.com | crypto_com | [Crypto.com](https://crypto.com/exchange) | 2 | [API](https://exchange-docs.crypto.com/#introduction) | ![YELLOW](https://via.placeholder.com/15/ffff00/?text=+) | | DyDx | dy/dx | [dy/dx](https://dydx.exchange/) | 1 | [API](https://docs.dydx.exchange/) | ![GREEN](https://via.placeholder.com/15/008000/?text=+) | diff --git a/assets/bitmax_logo.png b/assets/bitmax_logo.png index d1f4566fc6c961740c18512b00aff6dd46f12e1b..362daeca21783d84f7e93ff899b5d5302a9eccdd 100644 GIT binary patch literal 28370 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 bIJXrY`DW-;c?tej9YRY@PqkXvI_&=el0^x> literal 202800 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 j)mzWrgRhoqU@mi1qQV8$Q1xHQfep-DIlNF&moR znt0InW8jG4DbQUqM>iUuOUv%)R{F5rys2w%-g}oGA}U)RyC-{H%s-z$GW#`rBHw#hoTCj5 zU#vdRbvw${VZr>{@5v2e@Ow$IanrJBH)zH_dH4}7?OueC8}zKINiN@sL3xxv^q{LE zFBoo~{6GiwVth(?B7STaKx+V)*Ks7fRw$KXy7+_ePu7K4A+$rB-xC544_Wu#R`xQy zeZ0=CG#W2&wWsrMpLX8y?v-%J}v6q z4DF^@<~bQnuk#)54jDm#;q7Jq1JyG`T zGeH^o4piKR_on<>V?Mi#FLuykvZVD?`1}m=*UZYWXrwE?F-fR|SAyrGDRvL*Yn=)R zF9l29&b`h?b^5I~3ArkGz3iY^0<#NxIJ7iLAUkm88hJML5uA0Ghva3hiY)dtMa89c z1UPdp?X!|otnm~ZI>h;&&)IRVfXY}M;Jo);Ol>!3 zZbI_p$7=oHRkMa+lO05)eT~Bq(%ownwS&kTR#+W2mi}Jjf+$)>6ril+j7fYn*Xp zs?;!BXkl`ZDUoUo9KwYp%$0$d6yDKW#Xmz)LKcAm+rv7#hv)JlCYM^whfPBCaDv1s z;mM%s3DeurT)EhP>WWM#dj!iL2CG;O5(vZRrtcxEMOpYHl;dtk2G6h~N;Q!~vY0qE z!HsDqr4cq2-9f!*X7BY#Q)Kkxy^?f`roQ2cF$Z*B$hueKljzW&7DOpn&YaD+ief2QnSid zahq!F=9Swlb#{G7PK6UtQlxUbbp~RP_%M%OCwg#ZdX@&|=0R25ca* zygYs1j|Dq;krfp&MZ5z)r_4|U)z}@@!QQvD{trvYfr7`Kv3{GnN6~XFeTV;Wci88a z4YzhK6NwHbH8#=3ylk`EUui}aJ1enyuat7z<=5#u7dxGS&P}zG-gL8Z(QQ2)Y{$I~ z*PIKC2X9B3kWBeDHSEBNLL42wBByWreJcB4*7=BZILA zFepl~i|Dx9-kUr3dq0+H2?}96?-}wXdRDOVHg}xxt3R^O$6d8>H?Wqr&vm)tR+Xi+ zrQ^%m+6*(HZf%KThbdc0*oy~~tR82(;%sbwRy|bs70c`!_tR=M=Nq5>uh+>zi0$nf zJ;J+EX-fJdJG-y<_ziE)=t1`A*?+QJ!OjQ3Kpef6tpms>Yoofjj(f)>g z%9CtJc_~ZNz(}AT`R2#@T?2bia6qc>e`C8;b%ZB^(fJ<`S#w9LAp49wC;wd<$51Gx zcQVVQ{5d-ENZXv*1Fv12k9y6MEvdZppNURy^$gLM?N(;J%ewBZ7NsvfJQt~3aeDwd zb4d-82cGnn=+m6$pK%ZNMpcg{5ji?fthsALrJV|n|8riHZ{ch7#ofPrV)|NYOGn$_ z#QV|Tvl#loGCEFlRX=8amcg1*++}y9Y6&O2#X%;3n0cl9DsHd^U6vFR(0r(=Y!dDO zsyKVQRY|r?N}|q6)m0L>Kf_nmq}^#gej+5-Q*(=|x`#Irizj#+&yl+z)~qCAUlWX6 zTW_p~`=f|@yaN(QN81)?a(hzl%v5gD9asx z+afsnoemEWf85k0?ID@=>+sz&=cy)5DS-xWO$D~&r~_!h-T5McQQUozo2tO)egjR4 z)82q;(xGx;{0MvdqewBMx3XXi=A``oj1Yg@uY={5Fq^OTO=apIDPRGLpLI zUw7@D%ZXw3?P|-GG5q2_6%;o<^Xy&s)c4J|Hs}n5#5aDjjTN8KH2VaufxL58g}#S* zVXKa@TLH2~I!{=JbnbpH@;=s^YtOEgph~s)+sGTV8`Q4AuTM37^*_;dHn1J$n}@cSdJ*JhQ}jP7EE`3X@hv-}}nYlp(O`6q2cu zg0HC&L5YOeORYB^m);@_yC4=99t#1F4E~Uj^Ydb)yn=e8|IG`n-|S5>1#A(l*U!1{ zo8w9vj$|mIV)efTzOu(m`9&UqIL)w`j5U(JEABp}b_;8pAHCJ61SdFV;si+ln+s@oPR0{6a(NV2l7 z5v?mT16YICR9ydRmv1Cxo1442-m9(-ZW(;%)w8pqueo*x`uWW>Y8B#TpIvQo7`GF- zQLZ$VW6#pR`wAi+Py^ZqyJ}*Xlh20HPE4{hqeA`C@EM9J!?ATfV0zSj|xKc+9(nkFnG>q?Yfa$aw6xBq5j}7wbKg^xoy#~boLzAVerb!Y=*N+swd2^rjBB(=QS7m zQ-gSYLpFlnS^bh}d1CGvVM=C4Zo8xv>I#34RPQ51B-V``=vuL}F58o?Ezph^5uJM( zV-PzQf_W4;jnN;s$Cf30H1X7fR6$9d%1QLQ5xLM>n-;yiW_JOPiyu!!UMN!gd9CK%Z_9sd$zfiA8DEnv z=t%8)k(gL&K6g%j7{+FasV&6No~7A$$hTRVF7tNYE8&%6XcJbPr8Jn|J~GZ^MP9WW zA&uV5sC0PkdR6D$<^^?DSC2b|x8x1wa9EgFMpC8*zUD2b(&PMY5Zq2^Y6mJf5@%m;I55-_R_vT%07uV1Od9rTPxwTM*|^NG1?Hr`#?I^w>GzQjFo3nvo}7v zD=nj=yJ!$U9$$pl?JyP+?Yf_ot3A?2KUY_OA5&`2A`@x-W$iqzCrNC&zG`N_I{0}3yjh>T^R-$ zOjg#WD z#i;V*l&PXSM^Ynx-X+MnffEeA%d`=gP&n|bOQ$W&o?xao1^By7^jxib!W@-8Xq7P^ z@G@ekjp=l1&mW^QtG>~~yt4}fb}O)Yt>pIX1ZOTLw;-Z*tRj0;HkNzwWno7hd7E#* z+H2}T*uamjdw$HVy{RSk&xk+1Rd^`~!aYX(|Kp;ne5SNV+Ep$QAEv~}#Y-qN{w@2w zY;>|ic%n-n@={{P{pXJTE6;@UG3z{>S?0=t-;479R95-zm{H z5#e+uaYeUs%&}Yk+YFyUP;AkH`>;ER}2s@`&n9ILTt3Q?d9 zj}88TR=t=Fgz{&E*v@K0_D*>J=jtcA0q_V(*(7WU8KuYaYU(m8DF8kQl9yTw2PB{t zMG|U~SUL599Y$KPg0$4(gr-ce49ZHA1Z|Gvz`Ba)B}`j_U1&UvrWC{1-4Y&L&Qhqn zDokE5T`HW^f5}Mx51mBPXq!b`y?L%{+j@Hu@8fSBqt{qV!0_ahW}ScCHL1f-IC(>b zZB}j7Mh^Qfe{ugLBC!}TJMl-^CoFBXoQBU-{Tn&mcA?rnyE2wGYBQX!y<_I{wm9Z# z_VK@FmQimNTMucC%nfNQbvyo`rwpmnqutciUDf$aw=RA} zEKNc#8H|BKfDTO;jxD=Cu8Ni{!k!Uqk?_O{v?Q3jsuqA=po#jd0UPxZZb9-f&cD0#iA)mKticSs*YrOI*BzxjEZa zilg@5$+f@7DB40)=u9|hCD+}W)sc1b%Qicu?qENRrgXY}T@HHBChfS zj?wa}M5WQsNMz7tpVf2uMxP9`0-Jnn?9iA(MQC>M)?6L(ZiD@Q~()OluSxy-wLf| zD71&(QOW{jlIJE^_+E&+NXd!lIdI31?jDn=Qdl&kJDDj6d;yP5-7USZ#{z7L*Cq2` z{WOcs>M7Dx#?5C~-`j&0-bKAd`7sjIhX3*I+_C3<(UZ-!#nHh>cQvk^p|?gSUKx1& z4wVv0<3dyvg`-_~&<{NubhznuQBFwAL zq}L9G|>N*c$>M;4z5^ z>lN|L0Ya#|UU7%e+7))jVBUzW3V~QA_BK-cwWE}Q=HA~ER`HDEv=(DQ-*zK=X zD@qQ1KNOX>e%3~Dc365rt>boBNJ_;^ORP=%q^NmEjKUaGf+MnAef2iw0sq;th-LMg zH(L%LN5=U8F3F?D9^;R@zo$1>z6a#hYBx#foZTe{9WRw?*2?DjlqH(!3h)bmD@wY}rcWT-<3#Yy2xuVHw+G}0={Yk7X z!j>*V0)V2XsBd*N{6-W?J~!w4&w_uG)U}!u&E;I%uo~yTSnm*;)4B6R4-*vc5b)%% z)~bHz;nB^vE}ZS0naXR4USj|W$q_zND=V10&aXn+qV?3&oWx6e zoLoYs$oFS4a;s5}8lAcZ6z2K3ojrNJO+vOScmXDJw20FEh~IfiPV~DDn9BYQ3Df(i zv3DbUi|)Y7nT$r`PmAc0OGH495TlE#iNnG@eQ#M{1D>PsJQlnRLbWhEn^Kt&oRkxc zjJxFkt2%Or>F~hsCH=Oi#z2!L3v|BLp5t`F^0 z?+hMn_+(+n-?2&kO2E3(#{QyxmH9!$%nEGDGity@qp-L^|C5o9W^-Y~gvme5dL7RM z_3qt(1gJ`6iyV`{FI@BE%&t(cFEekvqgFq!oSDj47gE1f88u!_nyMBu=jQKiy^y~+ zS)`-&0U&=uVds_uUQvj3Mwci{-cgJ3&Q5>J|60iiFPm`_p2$anFBBS}C+2;>BkY}> z``Pi+0aQJ4VU-qS9H>%S#Ls6;p%a?Q7}4*76s>b5D}I^)sMLTZqB)zS`j}mB91nR& zK1)9`qyFWm*&k4KzliY`!RZ$4#fYA)qe}JN1n8^S@U1M#)M7%PU=vM0KO>l$ty>$M!o{4jRVGhkhD#j2hd`RbXV=~yZNX2f!L;J{QbRVtE!wi;#0ND z-S>{s&!g_M5+-o)EJ-o%Z0hT^(J_*LJa!~g;m(YRm@#hN9Q9u}LdV+`qdZJ%i&py=^ zyBJE;z`GGCU2hQmfy(=0J?99;`1?=KTlUQphN;;Mwm(M=OET7|x79l@Z=W(+HcXh} zRJFSY@vgonhooL@Md!;cyqof-7fz(nMnp%-lYM?vtGOmMgn8jq^@AtAudm;{+@o2RObqvCK3xvxdN%k$-=U&HDKj zh-X6<)=%L=q?lW45g{+Z&x^gW=C2>oLN${iY4WA?5jIR1!Ap2@Z{zJngsbSZnA}D- z)TB)y6s&p(GL=JP;PU`-df`+-NCcNP`?v6d7Z>J?KGa#~+LuY9Zxse)`j>g$zgSl( zm{yJtquFa*qm0&zT`%bv_vv_@FKKzCx}EC}>zskX+o(+F(b2-=nUwRP!BRrM>K3k9si@*0ll!UxcK8)y%>#3QruP zrakn_M^p)NAFPL-hE(qpou(q<4*a+x1H>r)wP`;CL+#a)3V8X~YE;d1C7QDC3!kph z^iE5vZv@FKIt?Tf{h|qXaiYtwc*!$d@Trd6E`0A+VGQ2we_Kf2iVPn-w2I$_`0@5y zwvPheYfaR*U|}B@@LeV<_~{d9y+oZghiJX{77|Z!Ugv;7fQZDs7{MPQ27oo!_w^XP7mA9QFqFJ;cl)S%COhwN*eJ z+xD;(#l{2I$xb2Ou|7^>xpG}#@-{`!c+PWuB40v=F@8W|Jt{miDnimE#DTrtWaO7! zJ}F#yjA8`UvnSh?8QanHLY^O0{dDT;9VBlj?gXhdae6P>RBVuqQcd)5Fr{Qe=Jdr@ zhM%)~^gJK;QVs!D%d!bC7eSPo-{SG*^v`t8#38+)y|=rBXP}g_fRci(0${TozRxGKKlc{Ob7&^6A=)Q%D!hNLIMFv8o*F?9R1)-H4GP1((`fVAmqs)TTd3+RuC zFxW0Hus@IWh*&Nndhl2+O&U(WhKU8KJX&a-)Bx7o4W?6q}%bARnrimvyEneKB< z8kO|!aY+{{=jr>;+j6jVPX{+h(>KB9m`yrdKV|mD-P)|j-!DI`#EPsfA)N83)_=Tk zRc$~Odthywd)tbh@fBWaTNL<#RSkyiS0N5q#GBla0fwmQpHB!zhW`+eTp$~OQ1i`h zD;};zLr1`Lrcf8(p=-t7zo~(R*huC&8EDTKj2|!fcW+Ghy)r23{`eD3Z;){MfRHRy zk0B(Bo?bb|OISK>!~JP1v_mluO%)JB?WnEZ zClamJuz%e3TTm6-j;=73)1Z}Mw!K?^lV`$h%jQ%w(*2n7Y8wM%hy$I%Zu@n$lwT~d zd7u5Nqp$N;vH82~tQ_?}+zsLmwD~6N8yyvQH`QZ(%+PqKb$}znF4vTJ@KWtJ0c9ZV zFwO@y3H7naj&ck!A^MP|zwLbp6Uo|d$?&3>vD9i)w(8V-HlHMUiR(`y%P|!3ryu&2 z_U$|3RS!xQ!0Ndgk#vA=QTg2Sg z%r1HEj(yjC?#O9D)SNaJ7f@mLUNL&l5bK)r-UE%%wiHJux!N8>-jFg{5na>#_qT|2 zjnWG?`8zH$B~LC5`O=%Uq>95fnT%?pg#fS>c&?leh*9d%xmlcUv z3^lfd7l>DmpWcOzP4w&BOd{VPRGhvDDoeVlj_!`vd5b@MM<{0{O6hS4R6jB`Pf(=K zC(_7NwzQ{YoOXw85q34zl*HrUl^;&)qhsU!t|4-DYI-G_j0YDsR2lIzl)ceP-VHrx z3ugOqVrS|*x(#A7fi*I=l%iTo{5VbfY+rW!%bt<32*5AmoJDW<6ppxG$hVxGE3&H8 z49L(YwsE`8Npx3R68}7ZwdB@SRjOle3-7}7{wgk{Q&VY>of>ZS8r9v@(t}CLV|!9P zxpmDWzRmYH*|X%=-OiZRRU4Azgam(A`;7wPZX(o>BUH5TL?&Vv5dP6^5_uOR1`5{W zwCACfZ`tG%nx+&j=|0@``UV$n03Jwk^y^i#yXlkiXov0r44gJY%Bg}G2#^O`$>b{- z^6;(wS`w(xvlNb6icr#<_Ulgx{f+g-B)z?ibA$a3(oSwe*K8gzN73bJ1m}kOZ<#BW zphVLYckS#A75|kG)4HEX>@vbKm+t{>dKIkO(FZBb^IA?YU*SIy-hd>}dnb28=>Qr)zxXEp1 zKacqO#s@_6xzgp(B>4qfKQ3CcJoE%bnd)%HE@+_((meN4B@&DT${y_3#mAhPuwRBs z_^c>PgX+o2W`?kj56F_X@Ps*4C2{)on6>IAp&}kecp_%8WMp-I5kyT$U^+LNHy2~H zWzv;8?v3=#C4X`FtGpp#RAHN)KUvMol;7U1Bhec>Z=mZt14kE%(y$lZb_ry%xR4sQ=ND$(vKFkA#1H=&!4gKjg)u@TI! zTZrJG3L=(F)N^iXzg9QKi_Woad!SJi;d^GlPR%W5&vLubu$2C19Y&iClLvWO{T5~~ zpBSb(N1lM_c~v7+UNufT=hXW2V=w2?`kyWDc{OfsZ?i#k99Y}fyOW2vb#+6!=07r~ z9!K@Rd(B_tw^%tCg&B{7DL)hUO?MB1aR6y(LYfLr`XpP)DoD>#SHg}zr$AlEDxc<$ z;=^9YH*_^h6{ICjuN&;&Fc>0-UcEc!P($?j%}ksIl7GG!_zR6o(Rq8b{Fhn(^QY@- zRAhvA0&cfY>zMWZ7jr-U=MXpj^^J>1xDj%mo4vG0^iUas~v9_?VtZ{Mgj}lndvI&zaYOZ)JT*=9n|4+ zMn^>d5KLwhBzzwbikBmUmqO5si1{G(1x`g_DzegidTxvqBjd3?A73^X{d!~T= z_hLw^Z^b&*6~YtxR93=t0iw6D1w1GjIxayh+ZIc`CRYp<{cEu=8AY$?dFyJkonz%! zN?OKggkr6~bSXen4X00PqBmo!(w?u00%qS^sCKp2H*YO3J9t<^=ZY`Bnp2x?OHc6_ zhqqL*Kn#-js z6*@gcJdZm^PtKT;tIOP2p$Wi$HEhDw6;u4jZ9gyU-!oFon#&tfYTjx@W-)D>t)npY zj?6}lgWhhH<6QeO)&qngu@tf+v$(^b1MD7iHzHer-zW7oLvs8mQrx86S0EWC2BF6( z>QMew1-b}C6rvLs&=^7S=Fx+CV@)8zoNW6L`gPymmB%ToAkcO_UC_J`fGiB9YPjUR zVxITQElBZ zmS6P7u{#vLcs6wh@(%a2i6h0qmA&~|OD{j*fI&FTO1FV*u~SO983`pkKnHI|3cX&Y zx)9SKsKsR5$AMwWI%aMUju9&Ci%9P*A{SzwdsNvLMmf6|utgmLqV`FTs5M}vcay6j z_6=*+!Q`<-|EbWF*o^rXIif}~#;|<8`}oDYk-upcZ}rDsbqq}q4Eg;z#j1f#tA*}i z!d|IbA%UC`Idy)(J9#Jhy%{@{VGbzUz%&Q`TQ^2r?7&AQc@P4pIs25|N_<&17 zg*#iK;rQ;rs@4UYv*8=413xcZiEi{+bUY+k{}tY{E1C*ihM|Wpr`=pG4Z2wFgapOKiwq`=Jm|2_tTI?I^QW zA+e*Z+TJTi*)1@oo+Mb~!o!qZ`3GNIt2z`bSN-(d$I*A)Nf}esB`q_9XSlcicrx4r zBFociEpgrbTL#6Ev@zV}+B1T*R9~MU2|5W^7a(Utbrx_tVn>K$3KLqQZX_ zVQa2ifAYaw?9JakQZ=?YxLYEJ1gdp4UZ&15K0SvYQ@;AkOgCqXWsk<`mx-@C^Zjpv zU9JSXe2@I%`RC7i=WD1Rc0?D;&&SqY(0&%e>{3a$&j?+SK5_hDtKo2JoevP5^n7-9 zr)mFZb6(!ZxvVF%z&O|R%&DU}cOw=6mInmjxWPPB+Pi#VsmEk!g8Imddrs`=ik(u` zPzZxs*T+U8Vlg}aB5_1;FN!Yb#ZAux13#klEF}#=N4jh%;-0J&WN_TtrR$3uhc_g}cpRwK8)?0ucbvn$9 zT=mL5E7RSDTr+q*uLadq)=0Hb_V9Pza+VF?D90upJtbm8(g@{a4x2VF7oxRFg5Evkhpo$e~IjD{_1y)d%kFiB5g8mcZYT zUir#k<&fwp{L~K*bP4c=&du~8@axK*kj_X5Dy#>5Ezzd%9b~QH!SyczU4$p$U7m^m zz2YLWOxflBxbvbuSqk5jQlnbbn)}DsJsEs7GZQUdK@5n=b|3A?;oo!>cB3CLtPMKo zip0wUlEQAvQx2rhxx;gZVv2m!kGIe^h?;E|8~rguE7t1x$bX}6*{n7_*TR*!n%_Te z^t%EmC$OHkXV7RNu$}@pLKG7gqw=-T^{%bU9goGFowxyG4wUe6#&h(pV@-v=P!B~p zDn>@oNDeZL^<6}G`IAvh&`4L%%q*5k2ta{H>d^@1SOau}O|8Ye@ra+Ui%~_B;!;%c z1`}%Q;P70_ol~l?F1Bc^Z|RXr8r%z?aql6kvCf!Ymeb#i`O=)0RiVXYOY7a#eFwP5 zuf71Y!`cgMcc+^-Y2T76{Kwk6tAcX5Mzg80%3pRQW-kPNSz^;ZY*w$-n@w`(^tm*# z%*a;|>8<1z5bhWPsZ<}b%q&Z&`z(9-Ipjq!FmT&nc(#Ktw@Ie@#dVkTtObL3P!ivxS z95U22X0Mlx_8v(tznt?)Be6j87h5&S$#=@3JOfAdshf4C|Em^L)qb?sXH|uB)Ixm| zNLD^OWU@nXmO8w<3wKy(HamWPT^s^8Mh)!6OPD1D!XHo62|Zi!$lJ&TLEGTBZ4V|A zSOThlfiTHiiJ<~u>lYn0DGTria|Um{&~hibnD?ZS-UCQkDs@8%a}V(_k{n_}pYlLliS z1g?6I_rqx#fD|6@A0Ti%JgO>EPKi(=5Lk76m3Y6;P|-7&jEpdat1LOYrGUbB4^o!{ zfE{2LJy?_Ul1f~pMoLl~=rrXfFNxT8<(09ML0|aJ_V)9NBby9AxcwEWm;3I5{ouPs z>22<1fd~Jvs;W)~awvU1k@Z(@4lyDiRB!xUzQHFOI;bPzN&|4xm3?YMz;0eDSI8atyZ~W8Zq3FNVnUNy?`{->l ziR1!1iF$BPN+0gk@^dk4X2+f_u^%0yua{oks~jDY@^oV*2KNiVEq9)-?)P~86W22e zO9}BGwoP+&Mear2obMmng)0VzP_ysD$}lD;8#&LJ$L2^7)l|&Hk4p-BBP21D#}#k< zG!ITDhuZ8x2TCLIW~d=U1KaHMIAPGuyv|-5%4qRiNv<=r|!mNAJW|Fb$4_ zY3NLC(3k9nN!C^7-j&`x#K*wqbRf)Eu&pe|x9Pa4cXX!nPG9u$P3T?Tvu{;}JIt4= zuw+Fx_M^$l|EYHfqTZ?{{9_ay>AnL<>Jgs1R|?uy{=bJbY&-{89=xZ!UlBnI0*B1G zOaX~I$Iu{Ij?s^Rpc@`cXZ?Sx>5ZH}V}0xBkC9TZY7f2i%YRAQt?Av%9@~7aF}k?? z-ONk^QjT#V%k$NOcLh0nx+2x`J%GzJ{@o*7$G7W`LRLspJT}v9Ij~2l zeQ4rxifhw8t`KS4LH|STsk=?Z)_*Os)BR$lrvK@C_mH&S?C`FI=N+$KZc+H^nLOI4 zc0NbX4~sczSjDaz3;TFvsz;`^aVG2P?%0;kK;vG_?O!GeG-!AR$u|@QVZoF%azBkS zrUBCS+YWdm1kzrBeZao`V=oQ95bOX@sDMO<8Wk3%ak6v$k5A@3Sm z2v}q1I6VTcam^p|w+ZW@n%vZ542NWfyo=&H9+MPlxC6cnWAS5ls z5K`IKQdG9=Ta4-yQDLlETPCuTjNRz8T1O-67|s!eFmrR_j$dZKj8N~ zKCjntitxGb`&!=D`&w>0(Z%fXO?T&*P}J&aMtQ!7i~q{=-cj& z_fWplWoCKx+aRjxbxX}-|qX)YFZEeeqMnaE>U{nWCWO=v#&@{WBFfWu#_MQ*#tMRR$t?L8|lkViPQ0 zb@9kX5`AY|;)MDtbHD7EGRc;y&Rt!VOb!3-Y=_fiSwJIL5robijsL2updtUwFImHpQFD(q)0P8nK)p8SD!?CRB62=MWT{##!-<-mscDp`px zZ}|-R+wNm&h1wVL+8KXrs|sCR+eVv~8I!H_-jV7gKzW+_+XQ3IxZOhyDNu{$NvY*h z0+ZZycprVLP1H;CYRIn3+B2a8ayR2#?#FIc=gqgS%C?R+zutI;|Hd!Dd6b3u>1tXfr~^_plMx(u z=L7!Qq@50I{G#?miuY5n$DvFY(B}uH4YqlswN8YC7O4*YLF;BgXluWL*oDl_nn1pe zfiE+jdnSiylHG3gpUaQ8JQL74AFCg4#BDt$l~?WPnNqakANz;NzTbl(((Q-=cjH3v zuSa6_%Z>-18W&WTSKh07tbA~0g5Kx&C?8#gJuz-+eU7_&2KZ?ZZ5zA zi@2aR$$Xe#`B6jygsK-3`mmmBQhMD$F@z|p{dWePXly>;7(boTM8^uqr|Nx}gcgrLC&Bim!2O^QiSpcPuNlKh@bKmiwnYvj&-Dx2{mI@b z@7hv;N3md%>t`^p4-Xu2Fp9U#^s%?@==rwIJ#;9yW2joY$-dHt{@Sb~Mr72d28!($ zULV6m&LgVdi^ChG_*Y5zT95HqfqycM?}VH=A!zRUSI8eaI)7{8Uv*Wjx-)?iGI!f3 zxpLLAD?+O(2efs9Ltg*>@80LNLcd=rAKi8732L|V?!O8R4(T0wqEei~?a`>HU|Y7e zWmXVr3^(}#$7_VR;cT~{-k^(nV-An)*=f(7=t11|2M6|fG<67!%;(1q3ss+!^~^{K zbt%>kl)}|Y&kd-jwwiNEI1%_q_NCe+PtOg@&bGuz(i@N@9n8;&TYxPNizN1kui+Zo z$ToXKY~z!U?)hr{VqfROEw_SZdK9vaU(m`A?!vf9VrHy3>*`c{=S;_plpQQCt6hBS zI@snLr0FXgH@?0s_($x!m)SLarY$-3rbQE1e~}n5d=leH;RVt=7mX$*3<`w!Y{MD} z`e!)wXy@jIHFz?3F;g#lPNQl!kH$vSEGt=8ejaY){5bp_LxJfDG6y?fg@$!UFNuaa}U+2x7tA8d_wAU@hH z_&8sTBav28)a#jC+#Y9w6l)jgPkCpJ1!>ZQxXez%_hFdY5FbZ;e&`m7Ds;tLV$+`b zj2}kHWT)$?HZv_HtyBDkQ9^?5uW=gn>H;4bBG3 zVh%k}kN40nfZCNTgwE6p2%(Qr`Gtjfz(E{mZA6{%utE|#sF23zGk>DCG%|(J#3nF1 zXFr=tQa<@mS=`cr9V<_mYeLs>S2?v-v#OR^QXY!h+r$jV@!}A){{?H%v=A8<|Fu9p z^^EZ5-r-c$lFyHzMrmQHz2&GLW1ia*V zUXyv{vadFeyDMbM>iWlJp31$pU%WW4c!4Ll_0s@OS_SqVm|-)dFz@CD#_}3c5U||p z5e+(+el<{$r2u~wf;@2(n1n6$sp9=ikT~SA>+PyJPl^HEL~Vx~=R?WC(e#sjTPv<6 zEnoIsL@?efA9vT2c&CW{hR&kGIVzC}QB#e`IdXT2i^eQk%4Lf&C@H*M8mp@n-_(>; zXK`VmEAxCxS9ouCc=L|C1#ZOX#tW*M(Q7`EwVoa<{0uHLojSMsI@wj^Nk?MN^P=yB z*L`Jce6)jVA_^Y3bea5B;pB4r7g$a|8+^kUPAub6I*zKVTCemH?(MPL+XaO_<5-Jw z$7Kq!P37Rfh~(azEDX&bsiBa{MtPNz}K`&;PXLCy1rz2QG zVcHT-7n|GGUQBfiJB1X989_U~V~Hy9=7bfiy`q z^~Ui)d&MGG24_5g*b_T(n74Qw&wI7QL?Sg6IHc1ma{LJY#F<;lzlXR7 zCgH&3{DwL+C0#-gyVS6;W4-6PkUt)Tk(^HTKiF_sOR9~&A&ZrWr+jhS4TCrKE(}Y!}N^wD7<%DiQ)}{}|d!GAA zo3lRwNP#f4edPZ37Z$aavbA}_117d@95(ZU+s z;*E8E61eIi#ioQu*1Rkwb&Qy)>WJ!rTkGA-9CJhu1w8qhSt7|Er%dEk7oK*Rr0(g5&w|XNt{nQ?*AZ2FGhEDqV_Hr{g{&k?q+Vk`6eNiK z8`vM}hcefEe;N|#tR0DA#V$O=raXjbp}`ZP1yw0=*R;ijE>^iJC_!J{@PV(7`^zkU z=1rsz=n!9aKf-g0Zx>h3R~~T zUXc}?q@k_eH5vZDsuW`)6Z#zb=vYoJL(IAYE{zFv}@0pd7 z)q4?CRSSTX%oNbB{^FxY>iDGbXGn?W`*o73G2qSKD?ybE`0oSGWAR=$z+@VS>IPh0 zFp;0{Ex5N+owrW*967bYnP;6@a;UrDoZGsRoQjB&3*^*xU+phmTLoW!W3UG)dXeXh z9(I|xlA2ChRb8+2I`-YaX$3O;;qpKqUaDHb+-YUswcW1X%{}aCja}w-w#Sv1Phv4u z|1W=g>knsBcu7rCU7sX~*GLY8+(U?aVGth2q5_0WQlg3?_5kL9Or1ri10q9R1WtOn zvu)9Bbve-v1=M}!F*E_B^X&Tw7dLQg3VPvUe|ug+mT4Haikqs4e7BwSBK}?_z#MsY zzUM@?^gliTM*4LNjQ;96hkFtqZfU~SV9?W#73ZU>)T~$v6;1&uR_9i4{&4?pJ|Jw7 z-Mn67S_MZ}KKJ(nV~4ZXi7;Hf2=|HQyk&K|+=+7Uc?{Zfk-29cjMA55p(j5h%}5!I?7g%efL-=Z+@x1Xs@pKGndOfv)>MDHGC zQI*D&83L{wX95q*G*I`x7MuCRu3ab2UssPN)A9$hB0Yu^_HhwM1doa49~!zas0632 z*Do-GDyHQNx>qiM-@-->4; zFhvWG5vQpDD!a&uhX7%L@VdUxcdoz^bw9adv!Ilwj!~?mZ~OUjANS|h7PPlo@o~Wn zk_y_#O+T_y^=cpGHrCTy>1S2VC%|57qIkn_YmA99^%a!w3wLKw*osBPV+m<7kofFH z4$nLh%#ajsVgQ^M44Vzba!(=Bm;g8!74o2%BaN1ChA8~@f6}ORg)NT8?9YmWqtc_T z!#>7y?jB_L7uEM=thV#uo+lN7N7KCZmU$03ryi9Xyr-fvktmLxzQIoVkf?b8ZdEX*hpOev4_AwCC6U<_v1(H4#J-C3*Qlq?+Z` zmLsbjbF|pcdb?9Ryn@mru*RGf<+So{(+X`$ha#eSQn32!`L24YCkP^Of)?3UNlF`& z?0n%0XkL6BGzJEOE%>TB@8ZA{>?gx)nGUd!g%|U&Qwn6ml4`eq`_}Jn@$kI z%1Usi18K`Qc?K|+g_xV(avO}1$Qw#qaltbR?uUH;wp;+QwC+oBt*P2NjKAGz8+ zoG(|8S91#J@wfeAIg)fRXGK4){L++ANRHJ&*Tumu(*g6k7pvyUlVKrc;eSk6vX?i= zb>@wB_y&38<8PKucnnsq3@#(Dq&#QxVu4|Eo4UL#J+BgqB|xAN?uE(^ z!m;siZ3d-@+3L&_=O1J7-%KGaHxpM4u;kq9|G>&2nDgb1J)Q>Iw}x7bU0=${kXDCV zf9+0*_s>EHqPoD|AuDnpa*&43V^~C*YcEnxkdpWO{m7J`fL5R`R5LZgn5(=C64KcF zqeFLoyuPu_x3Y&+E#7!#rvu@}Nk2`mj^>o2p_NX0tXJenM46f`RdSQ` z#3N4604Q~FrL`-!6#q^!lrg7n`K&!trTtG;dZJND*#|GaMaRQ)UfKy%Zs zi5NohP&4MKB)v!NT8khn&&F1EU}upZ!JU_3YJ2+Tg^>GHMT!A;VtR1}k$YeTwH{`1 zQ}VFu&{~gkDa)*wp4apR-`}skqNkJ}z9v`QAV$E4nzO=?B zGe1FhN3MZ?|^#%F%XX@dIfqYJ0Au14?jvTpVY3yYZ+0>=Rq|uulc+nuweaNbf;FlE;dJ z?%@c0UMK_G4AIa3V`(2U+8_8hXESf;W>2|RRAzj#Pkv$1_#{Qok=E3a{M@yFIM~E( zXNH+F1;!H0*8-Tn;RQ|jnoJOVxGsT#oKnGR#qjgp&DqV|I?tq`)sjx*pWpN;$+qRu z>4xb~VrlF5cj`Ol2O6)rl?nye3zVUTEw_Ce8-gF1S5A`E=Scgpt0a6EC*?MW}1nRrW8^WhPSOoF-aqvTWwUM}9EUGW~YqHwQo&5R&5 zHe0Z;&tW4dRH?vD>;S_;_V;<;SD)=g@9n+ko~0sp%nO^oppAQRtkiupFRQH`8~<(?&lf}EcCG`Ija zuU=;Fht3p;*Nah!*8?ThZAsP6n7u5874=tdAAAsIsT<%881tZQG>|H%Q4zJwWoSd} z*Lw9e8N(%l?#YYm{bO;xB#vCz_B{Ml)Ef!Ub6+xW=gqHL#G9LXZ5_^gx7pvsu*}bn zMgN|c9qZrTlz#lMgqEh;BZGtfYpa0SO9%7HD#NOozwpTSvy$-C9DlkGrWe2NFx&pc zlFZh0Gu}*me0xPzP`c-gJ)LaJ$fM=nJ>J3nAN6*`=yZ%bGMl+JQi79*Q|k_XK;Ky| z0JhLf0?$H20i9<#W|bVT%h~5q!&w2lmK@o zwz_CfUPax%#E#$_-HhRlXh$S6h9?C5G!x_sBAIlPDLc3D?U|BEl*>(5jE%ywwpgW@ z%l79gxnu-pNIuzVG^bx0QsvpxRbSoq;jrq6!iRJZx4b|E2Sj1Wx>7eSEuLGlvW0)> zGV;BJs@ga>o2{6jt8QAnl}(ALKDp!eiuUX6SUH`kIg@Mb@NmlAJWU8&x#pV~6qF z7h%+hh#-{Po8BO#vR#=~n1*Oy>>chMBc8{Tk4$pRi5pg*E;t+VYO znjgCFv-+AsoD6N*;;U`#f&k<#mV1~bgL)m9fUE2Zj-X$wQe952l(3EuT#pQ(e2Z6aS{xy8x&VtIVZ)UTV+`6kvE@;-6?Djt$BLzu3H^|uLc`%cR&$g+ln<}62 zZp?^uAK+{o%5w$v9f1P2?J1>q=C=1l&N(&faVJ&|h|EwGkQ4o_Op|m$a_0>;^yk&_ z9p|Cf5OjQ#;P?(1gW!;HW5J-3a2}#elrYgcBE`q8byHV|(||ZsRu;b5@c&hD3o8uaQruL&lyV5eBo4~D7-r->|Vjvh1+DeIPgwXi& zs4*!Gaj>Fdf?(?A71e!#&``jL8(2)zuRQACr7LyO^|jaQcA@YbE1bD(_gYD(A|r|I z6-OD&TW3HurOi8xe}~ir(TMBB)MZe)2kvEib9`G&k(?%v-*|(2|fW7#}e+Ate z=B?-jg^yzysF3m3JMo4wUiZ(;=KH~v=jB+&C1@V=rHVShc!lL7td%e?LH+l=Lqd2A z=%wA4E&v)42!el&Q`UO_$YTS4xwS_ZF}zw%j{Et{1H~8fcMeXBx^Bn-e16cOq>#+W z^MM;}->Rp!nF9RVxAFn$#2}S5?PD^ePkc#)#1U`-__*d>bry{z^N|RqvdL$1I+@bu zYTIc(GISsnlJv7Djnn#mwdN9Sg25tol~|IXfy;(OchMj+An<0=~|@;Ei&tk6TH^Dc21$sDyg0CvBpQ zZzQT1v)LKF#EdoBZl+Y()C(9lWb#45%%1KQEN`u!2JHq!=WgZX_?C>pH}9G$Ef{iQ z@tCK#n`}^Y`1b63)%44~%^(m97TED1a>5V)K&QvTz}3=X&NZ@F5vBf^2rTXXl|W;< zEwNgTB~$z70c))=8GmFwT04HyBGY_}E*1 zskiTYaFArB;#=Jn<#Fv+FdV|O+IsZMjMabOf|rX>2Z;y!VAchCtdR4L&mh~`JBM~- zPR!_{3!>ggPwLb@C#N9wmhiOLvquEo#n8O{-141?rUDYxMx-^)ZVl>AyF#}r{W$FbJj$=djUv%#;xaNapPq{!yUdo1B_o(om|PGKMfW>FJcWCz#ruaA zd<{w+Ux0=AC6EPPQTzG03@r_xj<*|RV&6!zpbj-G3@%_zLeBB9GvEGZIikB^g=gE; z+qf0;prLfpkv%c^(HH+0}0+m7w8IZR8lW5C7=}-O0CO%I_b~ zj>m1UX6J@;ELtpOJ6-nw*g50*tE2k@5>P8>X~aMRDO9&2k4`A$V4TOA_&S*Jp6UBl zuAFxQfY1v3e8g7!*Z9s2sG)fJqy)uqZljK!2?r)A>&&{4#Tt(_;DE1NOU=SQhOlgJ zh9qq(9Cdsu-B)MU5AmDIQByq4kxe8X{YcR@44$!~aA={%oKE&<143zY%Is!a0i# z%(2pRF9>aA3W?Gy8Inqx3ld@DV!B$Uo3?_lpOrYJ?^ADtTgES zwX_=HisTXgl}(kxT!nce8s)d=#Cbmvba1xTSEBkX47%w1!fbWHd{n|Hz}4s|tOQw> z?5H!l39aTqTrQIcEg25;3c2@lQv#~ixi~15PKce_g04iqUX+LQqdStR-M#67Pp=nM zTas9ig={Rt#J(RZmCsklrX@qz>w;J7^9>)IAcd9LFQDZuLW}tCCc*c6O_8+gft-tf ziazt%?N?OsJ$p1aJH>Nc`{=P#1rEj)+Owl4R}8gLE$b{MhYxHi7==n{VjvJG4B*h( z;ye}a-tVzKoys9?uRqh&g>#1%oIoe~G~`eR1sCsf3RJ}eG;hI=O&v>!amZ(ta|ESjMv=X80RuSAd}d;!}fM^nKDzhgSN6by*& zi;;yyrGv3%%05~oLa7HlqKiS~a-yz5GDr$So9tv=Rry7{yI34<6=srhMF?XKd3!rG zb7Me)FRtR6;^T(&DgpT|XXR`TIDjn~nmzYA+aurMxX8;?kk03j7a`Rt`8_6;Z_^Jd z+g@F>KYW9%_~tM9BzmsB|M7W}y}h_^WkJCLP$D=Zg*<1x9^g830WRb@^KR3jUh)WE zq7x6}3!E~DT|DV<>upH;PW#a=gFsK!W22QyisK*_a(ld zLH(Qq7#&6N{*TyZptJW5&lh%t93n$}D+!`r@oi5(5Z8$AqU<&=8mQCn>ryW_`55^) z8e`t#FS_REDp{J?#*MW8`QsV0;CL;B+#RM7$5L}kCSxPCAT{UTjG-LAeib9%`jmPf zt2py{Wmd338%H^ynvUdfS&)eJPfpVY>U{w)%zMvkg{Mh@66z;OJDtTG*hc^{RAc>> z;8hw`K(OJym}}_12K5bvs6`eYwU)>XMy4F&pqofkz)Vw3vf7G%nN95iD)i}AcXyzt zw@&|tCZzeVJlnp;*%py!jx)xJ1PTHrfG(OnkUinAc5TE$8mv)mIMrR-B|LC#)zc#L z;<;c6r@ywXsml3~48;1H?531;D~4VOrvc8HOu>-DaRvun&qpPvd1Q)V^3pe*OKpG< zmCedJUg!~I&sZ5`D_mI#RoiXUXu`4e^F${y>rN5J1PDKeFa{k41vB8~4 zM@_bB>7z4{;G zuA65>B&+49u6Tkr%7|cBc2t(O3*RxNM)X=J|G1A#0i*8YG2@svM#oCY5n|D~MpFaY zzUid${-Nfi=C4~1U=>w~=(L39bIOrW{}Oqn<(S-K0ig*K4c|pzSf^2mi1z9Z-Ptf4 z6|HJOl64ldXOFpH+L<@nX|T+~BhF*5Ii^^HSTBDNZ-4JX+>815J{y)Szbs_tDm#Rp zkj1&Xix3qkYG)9nNBSl~sbl6K$NSX$3VStDG_tHQJgg8m`IW2gBa`pgbvz2T;%-;( ze%cdYg1>UmnkiWL4D#GxwBbW4Ugx_Ho72bgh266E{XKfbID>lXhKRi|l=i&RDYkfH zE<0*KeEBlzZus#%=H%i^Ag7GVaN|^Ua<@NfhPsE@6;)IFL07MGk;B{wx(V1w~^CbDMJqjY`$l8(Xzj7?c?fBD>dcJmAE-o2JmDpCL!p{63E%h)H3@xLa24StJgpmp^wetJgPbX(S&!oPh^NWWa$uTOpy=7A0^NX@ z-Mmr${n@pR3$+UhttQN2ztaBM=*{*_<-ZE@^7j>I9ckTmE66_D5!32${nMpzwVdVA zwgc&tb9t-iX0%k--prGfr;_=(ao_66MsLVxNPgR|e{-P(i&pi$T2YBOFQ|Lk0HI*I z7q`8IpbP$ca_|sZM5sVsZ}v4k)n6M??{*?nS-gK4R#b{6gmsPcm#`Dv-eICltaid`*zjr1H*RTk)13! z4t!@b!b*+{j?0lDgFgQQ?=D8c*K_iY*2x9P2uxj661wW6svt1!>Qn#DzGXUl>YCD& z!Ik zMgi_Ha?;i|GPGzcBQ|j3*+-Jq(&CIufrd!^96Yazr5Ea5)=tcY>EHMuwm;mU{#-%I zA=#|a*mK>3*!uq2=IXny1-b?3v7Kcn`_{cU{`3~-Gf@hX-@L}5K9gHyO--s$-8TC1 zp3Bz7ngqYT4~8{`vScmi4QXn!Re5AW_C(b=;BFsF7zT3RvVAd6p-o{S%aG0i4%*HJ zt|1XZQ}t9&lg6>@i-M%ck%d9=^vLtB;2J`-9NGKHhOu6WI82lJ28C~r>lX@8w`h>Z zurRKJh5%g(122v2H7Y9~1 z8$w1bgQ48U{TC=;cp9Om41O0pwYzvT29%D+wv4pzn+-Cq#$gAZ*O8*FM5YH5Y2!~> zIcKVC#81&{#+&^ZsR(^z$W>|RTTM#}qO(=)O_W`I$rWss62rZbx$j5WTPgy-5^P=n zr5`&mK$?sxxbQmk;;m{@f#vkFFYNClN;iM?#*}|fhpTQ;4j)|6&=T?h=eBob5O1j> zfP{Yz59n@s0h*dU+9Vhox(}p1_a_vD2p7mZq#+|<00M{6DOek@4!Wv&eiWIpi?y8` z9THET)`XBhiCR|zCBN8L_;NL)SF{~_8 zl5Jd9(s1=O`rq7ud&NQPDpMWTnyZp+KaX4{_TDrJu(HXSEE$Vna5(oWw*QZGC-FRJ zL{yzIwx+;m+^*zv8ZR6yEl09CoGtXmNbC8I`t30?-^ z7M)Y*>1WtD$}>C}9?e%4J!`Q>1D62fm9M2e_3$ z0%ON=(Uv2IJhoAa9xGxUKhQDc1__*kP%oiA+hJ$P%~Ff7WQOACv{P2IX`8a!W~#Wq z#!BjLjQ>L1-eGIVJq7@&wt^lt6*J&n9}|V4&%UwSH}kU^)}h%f=SlHN>x19VBvpR4waNKpJdmC@M`jAYd@UGW zlJrBnr!=7FVieieZG2Q`=)sk=3{Dbb47pcpQ2_KoD_R^gap~DsHhnA>o^>S|!RIGn z9#7oVm$0%-WWO6GQ&+uR_dg;mU*Bjn1}`w0nZ%tI-%~ z+7YM?^WA>}`^J(KD48lx+k-bfOoVo`qATM48_@-P^_ax-6v115{N!^$JWYbufL&j& zz9D017Tf!$;HOU4L8Q1G9-v&2|JCNMPCfcP?}oes=8~^}9wm5rQzF#k4`uagT#KI; zPK3yyI0a*w*`4os(&Ae3j!3Jr?YpHqeCs{;P&|KVw;VHU5`D?p#t@tFpI+=uK!O5v zabg(&Bb42oGdSUnEDll8OjH{kkd5o0F1X*t6|b%6TjBnqjglv@ZhzvAl8seGwUtG6 zo-TRU+&YG*JoZv=q}v*s?yFfjkoV9)<<#1w4oPwF>k0 zw}qdX=ve|5%9{I}c`{>1ul?AH)~1SHM&}KQ+C9;Hzx*rLSJTXStcX*Nyl5g|)W2|Q z1ERlQm?nk!rPIZR7Tn26AxZuLUGeNI{rE?o87GqQ;D(@st@~B;3y}`?k&f@yySQhi zW2o9YN22(@R=94gDsTFueqMWw8m6y>TK(^xaicX2?1Iu$yEk0>z3)4yH_V0M&6+r2 za*mSEMOg+BR3FG{z3J#KPg?EA4xfFY7D%7oGu+s|m7w~ouq!=sPOx_yG28@UBMP+ zZq)QoV_e3F$L0|a^3xO!j?jNs3jO}d$FK}3&W5HcmBa&mfcZ7_n;EYs(}|0sp7P~{!)Lh zU5y5%C*W9JTo1lon_qrNmbv4-d0!Oc-sk)N-y8uGLcFl8NuUBdT=sy7naRF^eogAp zl@~*lr=8dD|G^AleWLgo0NZBIJ>aWrX?pC(NA8{OFKHK>RED{DAwiEX*VJzO>(&~q z@BXX&*m=mUq{`Uw{LO{U`U+L;S=Rm23TT*M833>Muv;F2sN*kvo^djrZ4-PY_^%i8 zC?agTo~jZ3HCrRr>u%Mi-7gxxa&JEtib-B;nCH0l=mn)l7 zW53Xfyo`)hUVSDRS+#WSQprr}H;8jmWE;k=XAjXt3rYwLfw&}OJo{uKhTPjso<2Fd z#>+ZIug{movH6l0b5Q*zBZJcrm>+t;X{KyUU`>?m{IN9N0_k&xq1^l@KAE15h6#$$ zo27@A%Ch#sRN=M{rmQn}iJS6ArysBp>FSMHT+(JPO_t3Q4%HbW&yTX; zef*KU{^R(mP%K;yY1aEdVI^5I=)6`|HC=BJR3dNPh?`cN*Jy=hpR6_#s@UD_#42f} zm+G-?{S7w+ucl{+Hm|GcQ#KrAe(WVyocVFFbrO*9Rw8C*{dID9d+#;PZ*uSH9UE`a z38|H*{#kte{s-bb4{BtKNvi+GdEywn=t|A^@<9Jo|IMy++_`G|xF^~+ZfZrz8am+` zJ84mu8}VU3y+Pr{vY8w1R~fh8Zn=_zek}q`M%c>D?9N_`^fY;rifocvdkXD58U;&mvy*GxUtNyby8b)ty=|BIYOON zGszVj-Jx(xeDmwvbB+eS#H(L^oO~4cUw`hWqQ3jl1bNJ7%W3BY^a@ou;b9|s6`{C2 zZE{r_=YdvQ->lN{XO}eX@^tuUsm|>c}ptuAgnB+TZ^TQR)s4*qP(hePs?Ud zU_L7nt?ACb#>sc}Mtkq)zd7>3?)D<5@Q1e5=8j2a3sbH4P1>?3vGQ|O78`p_2g-ex z{e0CCS*ZXNQ2HSB7{Bp({EYcjn)+%*xqgRoaQ=le^NesoIzpKEabfe(3s*3q6kG(| z_n=wl3tC3g^XY-~+JRgEiq;-TmKdp{KyhYrP3NshrpMJ8_hP>R!I z=wO#E(HoUV+a!=he@G#H?hDVucTld(|MLm#`U=fYqSrn&y?i7R?7IQ$c6#SUIh#Pt z^wD~!z7?mf(nXc|vlEW+Zs>KFXIk%ef_TU`dHeut^O#nV`}AzHHH|WunpCjQmQL!% zAfcZzK_SRC^!_p9d7*+!fUOO=8D23&LOUQ`{Mz3mAg<`#%0G%hvGL}N#T#2L!nN;t~MnlL?lQ^i?26fvuYX7d&fe(ZA$AD z91M2TM6mffHX5;0F6e~L8u4$=3%>_+?)+jE{U+wluuaNBpY&J!%YimAmFel5OrgJ& z8Ma@3l%3I^T$^-nZu!t2gzl*6z(%l=S34hY3N-L_q!Ejl!9kwJcOzdsRaVJAPV_15 z#3XWld5=;4EK>N53TjB%ZCt7_XME_bG|Xv3NZwUys`lT4Vzg z3g?|6|C?dP?T{Y-Pl;85yvbqT9pjcNX%t(WIpc$s7kGfO35;)LiIXiSFo>-`=k+Q+f%ecnzizTD zdOtcDhd#gINjjA^7??T`9~U{QCtVPPN949v<9h`os%BHA!|=!UfTDxcIUm~w*U^}P zY8|!4y%!9XYSddXR+v6r>A|9o1xUnS@zRH-9a!3dr5#w>fu$W-+JU7VSlWT59a!3d zr5#w>fu$W-+JXPOcc5nU?QQ-FqIgbwUS%&mOFOW%14}!wv;#{!@V|Co>+E4@b`k!B z);xLfe|=%;FH1YHv;#{!u(Sh9JFv6^OFOW%1OMOHfj0sI{3@e6b9o|Lp3N5={ohS> zEWQ2G4lM1!(he-`z|syZ?ZDCwEbYM34lM1!{~vcCWK*=fN$jWoZI#IC(OL$Y`KRpe F{2%O9?^FN) diff --git a/assets/blocktane_logo.png b/assets/blocktane_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..87cdc1cd35e04cedfb3014e15b803fed9db26acd GIT binary patch literal 19872 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 literal 0 HcmV?d00001 diff --git a/assets/blocktane_logo.svg b/assets/blocktane_logo.svg deleted file mode 100644 index cf096a3e0f..0000000000 --- a/assets/blocktane_logo.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file From 0d148aa5e07a7e535aedbcd4d5bc80ecfa1b43b6 Mon Sep 17 00:00:00 2001 From: RC-13 Date: Fri, 5 Feb 2021 19:10:01 +0800 Subject: [PATCH 119/126] (refactor) update connector status --- hummingbot/connector/connector_status.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/hummingbot/connector/connector_status.py b/hummingbot/connector/connector_status.py index 302a10430e..805a703d6a 100644 --- a/hummingbot/connector/connector_status.py +++ b/hummingbot/connector/connector_status.py @@ -5,13 +5,14 @@ 'binance_perpetual': 'yellow', 'binance_perpetual_testnet': 'yellow', 'binance_us': 'yellow', - 'bitfinex': 'green', + 'bitfinex': 'yellow', 'bitmax': 'yellow', 'bittrex': 'yellow', + 'blocktane': 'yellow', 'celo': 'green', 'coinbase_pro': 'green', 'crypto_com': 'yellow', - 'dydx': 'yellow', + 'dydx': 'green', 'eterbase': 'red', 'ethereum': 'red', 'huobi': 'green', @@ -19,13 +20,12 @@ 'kucoin': 'green', 'liquid': 'green', 'loopring': 'yellow', - 'okex': 'red', - 'terra': 'yellow' + 'okex': 'green', + 'terra': 'green' } warning_messages = { - 'eterbase': 'Hack investigation and security audit is ongoing for Eterbase. Trading is currently disabled.', - 'okex': 'OKEx is reportedly being investigated by Chinese authorities and has stopped withdrawals.' + 'eterbase': 'Hack investigation and security audit is ongoing for Eterbase. Trading is currently disabled.' } From c62440cbc62dc228c0cff3d21b994d8c3733d278 Mon Sep 17 00:00:00 2001 From: TheHolyRoger Date: Tue, 2 Feb 2021 22:51:28 +0000 Subject: [PATCH 120/126] Fix / Paper Trade Cancel Orders Loop - change comparison operator --- hummingbot/core/cpp/LimitOrder.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/hummingbot/core/cpp/LimitOrder.cpp b/hummingbot/core/cpp/LimitOrder.cpp index 470e5d66d1..63df16f324 100644 --- a/hummingbot/core/cpp/LimitOrder.cpp +++ b/hummingbot/core/cpp/LimitOrder.cpp @@ -62,7 +62,12 @@ LimitOrder &LimitOrder::operator=(const LimitOrder &other) { } bool operator<(LimitOrder const &a, LimitOrder const &b) { - return (bool)(PyObject_RichCompareBool(a.price, b.price, Py_LT)); + if ((bool)(PyObject_RichCompareBool(a.price, b.price, Py_EQ))) { + // return (bool)(PyObject_RichCompareBool(a.quantity, b.quantity, Py_LT)); + return (bool)(a.clientOrderID < b.clientOrderID); + } else { + return (bool)(PyObject_RichCompareBool(a.price, b.price, Py_LT)); + } } std::string LimitOrder::getClientOrderID() const { From 0f445ae7da4c9d3261a5fda33d1761731791c87e Mon Sep 17 00:00:00 2001 From: RC-13 Date: Sat, 6 Feb 2021 20:08:54 +0800 Subject: [PATCH 121/126] (doc) update okex api version --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f5b5a5f5e7..9549671ac4 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ We created hummingbot to promote **decentralized market-making**: enabling membe | KuCoin | kucoin | [KuCoin](https://www.kucoin.com/) | 1 | [API](https://docs.kucoin.com/#general) | ![GREEN](https://via.placeholder.com/15/008000/?text=+) | | Kraken | kraken | [Kraken](https://www.kraken.com/) | 1 | [API](https://www.kraken.com/features/api) | ![YELLOW](https://via.placeholder.com/15/ffff00/?text=+) | | Liquid | liquid | [Liquid](https://www.liquid.com/) | 2 | [API](https://developers.liquid.com/) | ![GREEN](https://via.placeholder.com/15/008000/?text=+) | -| OKEx | okex | [OKEx](https://www.okex.com/) | 2 | [API](https://www.okex.com/docs/en/) | ![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=+) | From 82f4dfeb91e36ab693d43560d57c6e6093a042d8 Mon Sep 17 00:00:00 2001 From: Nullably <37262506+Nullably@users.noreply.github.com> Date: Sun, 7 Feb 2021 21:36:52 +0800 Subject: [PATCH 122/126] (fix) check for derivative only on TradeFill objects --- hummingbot/client/command/pnl_command.py | 5 ++++- hummingbot/client/performance.py | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/hummingbot/client/command/pnl_command.py b/hummingbot/client/command/pnl_command.py index db7b32126e..f02c07358c 100644 --- a/hummingbot/client/command/pnl_command.py +++ b/hummingbot/client/command/pnl_command.py @@ -67,7 +67,10 @@ async def pnl_report(self, # type: HummingbotApplication orders: List[OpenOrder] = await connector.get_open_orders() markets = {o.trading_pair for o in orders} else: - markets = set(global_config_map["binance_markets"].value.split(",")) + if self.strategy_config_map is not None and "markets" in self.strategy_config_map: + markets = set(self.strategy_config_map["markets"].value.split(",")) + else: + markets = set(global_config_map["binance_markets"].value.split(",")) markets = sorted(markets) data = [] columns = ["Market", " Traded ($)", " Fee ($)", " PnL ($)", " Return %"] diff --git a/hummingbot/client/performance.py b/hummingbot/client/performance.py index eb67245700..a033c3e90b 100644 --- a/hummingbot/client/performance.py +++ b/hummingbot/client/performance.py @@ -137,7 +137,9 @@ def divide(value, divisor): perf = PerformanceMetrics() buys = [t for t in trades if t.trade_type.upper() == "BUY"] sells = [t for t in trades if t.trade_type.upper() == "SELL"] - derivative = True if "NILL" not in [t.position for t in trades] else False + derivative = False + if trades and type(trades[0]) == TradeFill and "NILL" not in [t.position for t in trades]: + derivative = True perf.num_buys = len(buys) perf.num_sells = len(sells) perf.num_trades = perf.num_buys + perf.num_sells From 518dd21cf2b12bc4319e22e0c53375b4aea46829 Mon Sep 17 00:00:00 2001 From: RC-13 Date: Mon, 8 Feb 2021 20:54:08 +0800 Subject: [PATCH 123/126] (doc) change status for Binance perpetual futures --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 9549671ac4..99c5c36251 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ We created hummingbot to promote **decentralized market-making**: enabling membe |:---:|:---:|:---:|:---:|:---:|:---:| | Binance | binance | [Binance](https://www.binance.com/) | 3 | [API](https://github.com/binance/binance-spot-api-docs/blob/master/rest-api.md) | ![GREEN](https://via.placeholder.com/15/008000/?text=+) | | Binance US | binance_us | [Binance US](https://www.binance.com/) | 3 | [API](https://github.com/binance-us/binance-official-api-docs/blob/master/rest-api.md) | ![YELLOW](https://via.placeholder.com/15/ffff00/?text=+) | -| Binance Perpetual | binance_perpetual | [Binance Futures](https://www.binance.com/) | 1 | [API](https://binance-docs.github.io/apidocs/futures/en/) | ![YELLOW](https://via.placeholder.com/15/ffff00/?text=+) | +| Binance Perpetual | binance_perpetual | [Binance Futures](https://www.binance.com/) | 1 | [API](https://binance-docs.github.io/apidocs/futures/en/) | ![GREEN](https://via.placeholder.com/15/008000/?text=+) | |Bittrex Global| bittrex | [Bittrex Global](https://global.bittrex.com/) | 3 | [API](https://bittrex.github.io/api/v3) | ![YELLOW](https://via.placeholder.com/15/ffff00/?text=+) | | Bitfinex | bitfinex | [Bitfinex](https://www.bitfinex.com/) | 2 | [API](https://docs.bitfinex.com/docs/introduction) | ![YELLOW](https://via.placeholder.com/15/ffff00/?text=+) | | BitMax | bitmax | [BitMax](https://bitmax.io/en/global-digital-asset-platform) | 1 | [API](https://bitmax-exchange.github.io/bitmax-pro-api/#bitmax-pro-api-documentation) | ![YELLOW](https://via.placeholder.com/15/ffff00/?text=+) | From 694a9c7c6680b66dd57c95f0133177f3ecaefc80 Mon Sep 17 00:00:00 2001 From: RC-13 Date: Mon, 8 Feb 2021 21:01:29 +0800 Subject: [PATCH 124/126] (refactor) update binance perpetual status --- hummingbot/connector/connector_status.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/hummingbot/connector/connector_status.py b/hummingbot/connector/connector_status.py index 805a703d6a..ccf1d44cfc 100644 --- a/hummingbot/connector/connector_status.py +++ b/hummingbot/connector/connector_status.py @@ -2,8 +2,8 @@ connector_status = { 'binance': 'green', - 'binance_perpetual': 'yellow', - 'binance_perpetual_testnet': 'yellow', + 'binance_perpetual': 'green', + 'binance_perpetual_testnet': 'green', 'binance_us': 'yellow', 'bitfinex': 'yellow', 'bitmax': 'yellow', From a8407df8673a8a41439cbf63627573c4cddca825 Mon Sep 17 00:00:00 2001 From: Dennis Ocana Date: Tue, 9 Feb 2021 08:54:25 +0800 Subject: [PATCH 125/126] (release) update to version 0.36.0 --- hummingbot/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hummingbot/VERSION b/hummingbot/VERSION index 499011d53c..93d4c1ef06 100644 --- a/hummingbot/VERSION +++ b/hummingbot/VERSION @@ -1 +1 @@ -dev-0.36.0 +0.36.0 From 8d1d7aaf7009855d9079a87a5c280f5d9411b459 Mon Sep 17 00:00:00 2001 From: Dennis Ocana Date: Tue, 9 Feb 2021 08:55:40 +0800 Subject: [PATCH 126/126] (release) version 0.36.0 release date --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 538f026963..d1f046b7f3 100755 --- a/setup.py +++ b/setup.py @@ -19,7 +19,7 @@ def main(): cpu_count = os.cpu_count() or 8 - version = "20210111" + version = "20210209" packages = [ "hummingbot", "hummingbot.client",