From 6b8b0b07aa72039744b917ef2bc26b2a98c2b7a2 Mon Sep 17 00:00:00 2001 From: Guillaume De Saint Martin Date: Sat, 23 Nov 2024 22:29:51 +0100 Subject: [PATCH 1/3] [IndexTrading] support historical config and fix chain rebalance --- .../Mode/index_trading_mode/index_trading.py | 47 ++++++++++++-- .../tests/test_index_trading_mode.py | 64 +++++++++++++++++-- 2 files changed, 98 insertions(+), 13 deletions(-) diff --git a/Trading/Mode/index_trading_mode/index_trading.py b/Trading/Mode/index_trading_mode/index_trading.py index 82c06d78c..f75524bbb 100644 --- a/Trading/Mode/index_trading_mode/index_trading.py +++ b/Trading/Mode/index_trading_mode/index_trading.py @@ -89,10 +89,43 @@ async def _rebalance_portfolio(self, details: dict): return orders async def _sell_indexed_coins_for_reference_market(self, details: dict) -> list: + removed_coins_to_sell_orders = [] + if removed_coins_to_sell := list(details[RebalanceDetails.REMOVE.value]): + removed_coins_to_sell_orders = await trading_modes.convert_assets_to_target_asset( + self.trading_mode, removed_coins_to_sell, + self.exchange_manager.exchange_personal_data.portfolio_manager.reference_market, {} + ) + if ( + details[RebalanceDetails.REMOVE.value] and + not ( + details[RebalanceDetails.BUY_MORE.value] + or details[RebalanceDetails.ADD.value] + or details[RebalanceDetails.SWAP.value] + ) + ): + # if rebalance is triggered by removed assets, make sure that the asset can actually be sold + # otherwise the whole rebalance is useless + sold_coins = [ + symbol_util.parse_symbol(order.symbol).base + if order.side is trading_enums.TradeOrderSide.SELL + else symbol_util.parse_symbol(order.symbol).quote + for order in removed_coins_to_sell_orders + ] + if not any( + asset in sold_coins + for asset in details[RebalanceDetails.REMOVE.value] + ): + self.logger.info( + f"Cancelling rebalance: not enough {list(details[RebalanceDetails.REMOVE.value])} funds to sell" + ) + raise trading_errors.MissingMinimalExchangeTradeVolume( + f"not enough {list(details[RebalanceDetails.REMOVE.value])} funds to sell" + ) + order_coins_to_sell = self._get_coins_to_sell(details) orders = await trading_modes.convert_assets_to_target_asset( - self.trading_mode, self._get_coins_to_sell(details), + self.trading_mode, order_coins_to_sell, self.exchange_manager.exchange_personal_data.portfolio_manager.reference_market, {} - ) + ) + removed_coins_to_sell_orders if orders: # ensure orders are filled await asyncio.gather( @@ -106,7 +139,7 @@ async def _sell_indexed_coins_for_reference_market(self, details: dict) -> list: def _get_coins_to_sell(self, details: dict) -> list: return list(details[RebalanceDetails.SWAP.value]) or ( - self.trading_mode.indexed_coins + list(details[RebalanceDetails.REMOVE.value]) + self.trading_mode.indexed_coins ) async def _ensure_enough_funds_to_buy_after_selling(self): @@ -268,6 +301,7 @@ async def _check_index_if_necessary(self): if ( current_time - self._last_trigger_time ) >= self.trading_mode.refresh_interval_days * commons_constants.DAYS_TO_SECONDS: + self.trading_mode.update_config_and_user_inputs_if_necessary() if len(self.trading_mode.indexed_coins) < self.MIN_INDEXED_COINS: self.logger.error( f"At least {self.MIN_INDEXED_COINS} coin is required to maintain an index. Please " @@ -489,6 +523,7 @@ def init_user_inputs(self, inputs: dict) -> None: Called right before starting the tentacle, should define all the tentacle's user inputs unless those are defined somewhere else. """ + trading_config = self.trading_config self.refresh_interval_days = float(self.UI.user_input( IndexTradingModeProducer.REFRESH_INTERVAL, commons_enums.UserInputTypes.FLOAT, self.refresh_interval_days, inputs, @@ -503,14 +538,14 @@ def init_user_inputs(self, inputs: dict) -> None: title="Rebalance cap: maximum allowed percent holding of a coin beyond initial ratios before " "triggering a rebalance.", ))) / trading_constants.ONE_HUNDRED - self.sell_unindexed_traded_coins = self.trading_config.get( + self.sell_unindexed_traded_coins = trading_config.get( IndexTradingModeProducer.SELL_UNINDEXED_TRADED_COINS, self.sell_unindexed_traded_coins ) if (not self.exchange_manager or not self.exchange_manager.is_backtesting) and \ authentication.Authenticator.instance().has_open_source_package(): self.UI.user_input(IndexTradingModeProducer.INDEX_CONTENT, commons_enums.UserInputTypes.OBJECT_ARRAY, - self.trading_config.get(IndexTradingModeProducer.INDEX_CONTENT, None), inputs, + trading_config.get(IndexTradingModeProducer.INDEX_CONTENT, None), inputs, item_title="Coin", other_schema_values={"minItems": 0, "uniqueItems": True}, title="Custom distribution: when used, only coins listed in this distribution and " @@ -607,7 +642,7 @@ def get_removed_coins_from_config(self, available_traded_bases) -> list: return removed_coins current_coins = [ asset[index_distribution.DISTRIBUTION_NAME] - for asset in self.get_ideal_distribution() + for asset in (self.get_ideal_distribution() or []) ] return list(set(removed_coins + [ asset[index_distribution.DISTRIBUTION_NAME] diff --git a/Trading/Mode/index_trading_mode/tests/test_index_trading_mode.py b/Trading/Mode/index_trading_mode/tests/test_index_trading_mode.py index 5385738ba..3290fa9f6 100644 --- a/Trading/Mode/index_trading_mode/tests/test_index_trading_mode.py +++ b/Trading/Mode/index_trading_mode/tests/test_index_trading_mode.py @@ -351,7 +351,7 @@ async def test_ohlcv_callback(tools): await producer.ohlcv_callback("binance", "123", "BTC", "BTC/USDT", None, None) ensure_index_mock.assert_not_called() _notify_if_missing_too_many_coins_mock.assert_not_called() - get_exchange_current_time_mock.assert_called_once() + assert get_exchange_current_time_mock.call_count == 2 get_exchange_current_time_mock.reset_mock() assert producer._last_trigger_time == current_time @@ -361,7 +361,7 @@ async def test_ohlcv_callback(tools): await producer.ohlcv_callback("binance", "123", "BTC", "BTC/USDT", None, None) ensure_index_mock.assert_not_called() _notify_if_missing_too_many_coins_mock.assert_not_called() - get_exchange_current_time_mock.assert_called_once() + assert get_exchange_current_time_mock.call_count == 1 assert producer._last_trigger_time == current_time with mock.patch.object( @@ -371,7 +371,7 @@ async def test_ohlcv_callback(tools): await producer.ohlcv_callback("binance", "123", "BTC", "BTC/USDT", None, None) ensure_index_mock.assert_called_once() _notify_if_missing_too_many_coins_mock.assert_called_once() - get_exchange_current_time_mock.assert_called_once() + assert get_exchange_current_time_mock.call_count == 2 assert producer._last_trigger_time == current_time * 2 @@ -801,20 +801,70 @@ async def test_ensure_enough_funds_to_buy_after_selling(tools): async def test_sell_indexed_coins_for_reference_market(tools): update = {} mode, producer, consumer, trader = await _init_mode(tools, _get_config(tools, update)) + orders = [ + mock.Mock( + symbol="BTC/USDT", + side=trading_enums.TradeOrderSide.SELL + ), + mock.Mock( + symbol="ETH/USDT", + side=trading_enums.TradeOrderSide.SELL + ) + ] with mock.patch.object( - octobot_trading.modes, "convert_assets_to_target_asset", mock.AsyncMock(return_value=["1", "2"]) + octobot_trading.modes, "convert_assets_to_target_asset", mock.AsyncMock(return_value=orders) ) as convert_assets_to_target_asset_mock, mock.patch.object( trading_personal_data, "wait_for_order_fill", mock.AsyncMock() ) as wait_for_order_fill_mock, mock.patch.object( consumer, "_get_coins_to_sell", mock.Mock(return_value=[1, 2, 3]) ) as _get_coins_to_sell_mock: - assert await consumer._sell_indexed_coins_for_reference_market("details") == ["1", "2"] + details = { + index_trading.RebalanceDetails.REMOVE.value: {} + } + assert await consumer._sell_indexed_coins_for_reference_market(details) == orders convert_assets_to_target_asset_mock.assert_called_once_with( mode, [1, 2, 3], consumer.exchange_manager.exchange_personal_data.portfolio_manager.reference_market, {} ) assert wait_for_order_fill_mock.call_count == 2 - _get_coins_to_sell_mock.assert_called_once_with("details") + _get_coins_to_sell_mock.assert_called_once_with(details) + convert_assets_to_target_asset_mock.reset_mock() + wait_for_order_fill_mock.reset_mock() + _get_coins_to_sell_mock.reset_mock() + + # with valid remove coins + details = { + index_trading.RebalanceDetails.REMOVE.value: {"BTC": 0.01}, + index_trading.RebalanceDetails.BUY_MORE.value: {}, + index_trading.RebalanceDetails.ADD.value: {}, + index_trading.RebalanceDetails.SWAP.value: {}, + } + assert await consumer._sell_indexed_coins_for_reference_market(details) == orders + orders + assert convert_assets_to_target_asset_mock.call_count == 2 + assert wait_for_order_fill_mock.call_count == 4 + _get_coins_to_sell_mock.assert_called_once_with(details) + convert_assets_to_target_asset_mock.reset_mock() + wait_for_order_fill_mock.reset_mock() + _get_coins_to_sell_mock.reset_mock() + + with mock.patch.object( + octobot_trading.modes, "convert_assets_to_target_asset", mock.AsyncMock(return_value=[]) + ) as convert_assets_to_target_asset_mock_2: + # with remove coins that can't be sold + details = { + index_trading.RebalanceDetails.REMOVE.value: {"BTC": 0.01}, + index_trading.RebalanceDetails.BUY_MORE.value: {}, + index_trading.RebalanceDetails.ADD.value: {}, + index_trading.RebalanceDetails.SWAP.value: {}, + } + with pytest.raises(trading_errors.MissingMinimalExchangeTradeVolume): + assert await consumer._sell_indexed_coins_for_reference_market(details) == orders + orders + convert_assets_to_target_asset_mock_2.assert_called_once_with( + mode, ["BTC"], + consumer.exchange_manager.exchange_personal_data.portfolio_manager.reference_market, {} + ) + wait_for_order_fill_mock.assert_not_called() + _get_coins_to_sell_mock.assert_not_called() async def test_get_coins_to_sell(tools): @@ -864,7 +914,7 @@ async def test_get_coins_to_sell(tools): }, index_trading.RebalanceDetails.ADD.value: {}, index_trading.RebalanceDetails.SWAP.value: {}, - }) == ["BTC", "ETH", "DOGE", "SHIB", "XRP"] + }) == ["BTC", "ETH", "DOGE", "SHIB"] async def test_resolve_swaps(tools): From 25ba1ef86abd8394d7c184d48e5e97d388941810 Mon Sep 17 00:00:00 2001 From: Guillaume De Saint Martin Date: Sun, 24 Nov 2024 13:23:43 +0100 Subject: [PATCH 2/3] [IndexTrading] implement get_tentacle_config_traded_symbols --- .../Mode/index_trading_mode/index_trading.py | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/Trading/Mode/index_trading_mode/index_trading.py b/Trading/Mode/index_trading_mode/index_trading.py index f75524bbb..52f72f7f0 100644 --- a/Trading/Mode/index_trading_mode/index_trading.py +++ b/Trading/Mode/index_trading_mode/index_trading.py @@ -368,7 +368,7 @@ async def _send_alert_notification(self): self.logger.exception(e, True, f"Impossible to send notification: {e}") def _notify_if_missing_too_many_coins(self): - if ideal_distribution := self.trading_mode.get_ideal_distribution(): + if ideal_distribution := self.trading_mode.get_ideal_distribution(self.trading_mode.trading_config): if len(self.trading_mode.indexed_coins) < len(ideal_distribution) / 2: self.logger.error( f"Less than half of configured coins can be traded on {self.exchange_manager.exchange_name}. " @@ -563,6 +563,13 @@ def init_user_inputs(self, inputs: dict) -> None: title="Weight of the coin within this distribution.") self._update_coins_distribution() + @classmethod + def get_tentacle_config_traded_symbols(cls, config: dict, reference_market: str) -> list: + return [ + symbol_util.merge_currencies(asset[index_distribution.DISTRIBUTION_NAME], reference_market) + for asset in (cls.get_ideal_distribution(config) or []) + ] + def is_updating_at_each_price_change(self): return self.refresh_interval_days == 0 @@ -596,11 +603,12 @@ def _get_filtered_traded_coins(self): def get_coins_to_consider_for_ratio(self) -> list: return self.indexed_coins + [self.exchange_manager.exchange_personal_data.portfolio_manager.reference_market] - def get_ideal_distribution(self): - return self.trading_config.get(IndexTradingModeProducer.INDEX_CONTENT, None) + @classmethod + def get_ideal_distribution(cls, config: dict): + return config.get(IndexTradingModeProducer.INDEX_CONTENT, None) def _get_supported_distribution(self) -> list: - if full_distribution := self.get_ideal_distribution(): + if full_distribution := self.get_ideal_distribution(self.trading_config): traded_bases = set( symbol.base for symbol in self.exchange_manager.exchange_config.traded_symbols @@ -630,7 +638,7 @@ def _get_supported_distribution(self) -> list: def get_removed_coins_from_config(self, available_traded_bases) -> list: removed_coins = [] - if self.get_ideal_distribution() and self.sell_unindexed_traded_coins: + if self.get_ideal_distribution(self.trading_config) and self.sell_unindexed_traded_coins: # only remove non indexed coins if an ideal distribution is set removed_coins = [ coin @@ -642,7 +650,7 @@ def get_removed_coins_from_config(self, available_traded_bases) -> list: return removed_coins current_coins = [ asset[index_distribution.DISTRIBUTION_NAME] - for asset in (self.get_ideal_distribution() or []) + for asset in (self.get_ideal_distribution(self.trading_config) or []) ] return list(set(removed_coins + [ asset[index_distribution.DISTRIBUTION_NAME] From 35426f938aaed46436506b70da26b4b60ae9628b Mon Sep 17 00:00:00 2001 From: Guillaume De Saint Martin Date: Mon, 25 Nov 2024 09:20:14 +0100 Subject: [PATCH 3/3] [DCA] update _wait_for_bot_init call --- Trading/Mode/dca_trading_mode/dca_trading.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Trading/Mode/dca_trading_mode/dca_trading.py b/Trading/Mode/dca_trading_mode/dca_trading.py index d3f01cc18..523dd7440 100644 --- a/Trading/Mode/dca_trading_mode/dca_trading.py +++ b/Trading/Mode/dca_trading_mode/dca_trading.py @@ -592,7 +592,7 @@ def get_channels_registration(self): async def delayed_start(self): await self._wait_for_bot_init( - self.CONFIG_INIT_TIMEOUT, extra_topics=[commons_enums.InitializationEventExchangeTopics.PRICE.value] + self.CONFIG_INIT_TIMEOUT, extra_symbol_topics=[commons_enums.InitializationEventExchangeTopics.PRICE.value] ) await self.dca_task()