diff --git a/BUILD b/BUILD index 68974f6b6..dbcb535f9 100644 --- a/BUILD +++ b/BUILD @@ -33,6 +33,7 @@ PACKAGE_SOURCES = [ "packages/evaluators:octobot_evaluators", "packages/node:octobot_node", "packages/flow:octobot_flow", + "packages/copy:octobot_copy", "packages/services:octobot_services", "packages/sync:octobot_sync", "packages/tentacles_manager:octobot_tentacles_manager", diff --git a/packages/async_channel/async_channel/util/__init__.py b/packages/async_channel/async_channel/util/__init__.py index 0619eb6f2..b8a9c90f5 100644 --- a/packages/async_channel/async_channel/util/__init__.py +++ b/packages/async_channel/async_channel/util/__init__.py @@ -18,6 +18,7 @@ """ from async_channel.util import channel_creator from async_channel.util import logging_util +from async_channel.util import synchronization_util from async_channel.util.channel_creator import ( create_all_subclasses_channel, @@ -28,8 +29,13 @@ get_logger, ) +from async_channel.util.synchronization_util import ( + trigger_and_bypass_consumers_queue, +) + __all__ = [ "create_all_subclasses_channel", "create_channel_instance", "get_logger", + "trigger_and_bypass_consumers_queue", ] diff --git a/packages/async_channel/async_channel/util/synchronization_util.py b/packages/async_channel/async_channel/util/synchronization_util.py new file mode 100644 index 000000000..31bee1ce8 --- /dev/null +++ b/packages/async_channel/async_channel/util/synchronization_util.py @@ -0,0 +1,29 @@ +# Drakkar-Software Async-Channel +# Copyright (c) Drakkar-Software, All rights reserved. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. +import asyncio +import typing + +if typing.TYPE_CHECKING: + import async_channel.consumer + + +async def trigger_and_bypass_consumers_queue( + consumers: list["async_channel.consumer.Consumer"], kwargs: dict +): + await asyncio.gather(*[ + consumer.callback(**kwargs) + for consumer in consumers + ]) diff --git a/packages/commons/octobot_commons/configuration/__init__.py b/packages/commons/octobot_commons/configuration/__init__.py index 2cb89994e..f96d65c4f 100644 --- a/packages/commons/octobot_commons/configuration/__init__.py +++ b/packages/commons/octobot_commons/configuration/__init__.py @@ -46,6 +46,8 @@ get_password_hash, ) from octobot_commons.configuration.user_inputs import ( + USER_INPUT_TYPE_TO_PYTHON_TYPE, + MAX_USER_INPUT_ORDER, UserInput, UserInputFactory, sanitize_user_input_name, @@ -85,6 +87,8 @@ "decrypt", "decrypt_element_if_possible", "get_password_hash", + "USER_INPUT_TYPE_TO_PYTHON_TYPE", + "MAX_USER_INPUT_ORDER", "UserInput", "UserInputFactory", "sanitize_user_input_name", diff --git a/packages/commons/octobot_commons/configuration/user_inputs.py b/packages/commons/octobot_commons/configuration/user_inputs.py index 036ac3002..21f87a4d8 100644 --- a/packages/commons/octobot_commons/configuration/user_inputs.py +++ b/packages/commons/octobot_commons/configuration/user_inputs.py @@ -20,8 +20,23 @@ import octobot_commons.dict_util as dict_util +USER_INPUT_TYPE_TO_PYTHON_TYPE = { + enums.UserInputTypes.INT.value: int, + enums.UserInputTypes.FLOAT.value: float, + enums.UserInputTypes.BOOLEAN.value: bool, + enums.UserInputTypes.TEXT.value: str, + enums.UserInputTypes.OBJECT.value: dict, + enums.UserInputTypes.OBJECT_ARRAY.value: list, + enums.UserInputTypes.STRING_ARRAY.value: list, + enums.UserInputTypes.OPTIONS.value: str, + enums.UserInputTypes.MULTIPLE_OPTIONS.value: list, +} + + +MAX_USER_INPUT_ORDER = 9999 + + class UserInput: - MAX_ORDER = 9999 def __init__( self, diff --git a/packages/commons/octobot_commons/dsl_interpreter/interpreter.py b/packages/commons/octobot_commons/dsl_interpreter/interpreter.py index 4e87aaa1d..4f919e7e1 100644 --- a/packages/commons/octobot_commons/dsl_interpreter/interpreter.py +++ b/packages/commons/octobot_commons/dsl_interpreter/interpreter.py @@ -361,7 +361,7 @@ def _visit_node(self, node: typing.Optional[ast.AST]) -> typing.Union[ ) raise octobot_commons.errors.UnsupportedOperatorError( - f"Unsupported AST node type: {type(node).__name__}" + f"Unsupported AST node type: {type(node).__name__}. Expression: {self._parsed_expression}" ) def _get_name_from_node(self, node: ast.AST) -> str: diff --git a/packages/commons/octobot_commons/dsl_interpreter/operators/re_callable_operator_mixin.py b/packages/commons/octobot_commons/dsl_interpreter/operators/re_callable_operator_mixin.py index 5dc0f156c..e56d8b002 100644 --- a/packages/commons/octobot_commons/dsl_interpreter/operators/re_callable_operator_mixin.py +++ b/packages/commons/octobot_commons/dsl_interpreter/operators/re_callable_operator_mixin.py @@ -93,7 +93,26 @@ def get_last_execution_result( ]).last_execution_result return None - def build_re_callable_result( + def create_re_callable_result( + self, + reset_to_id: typing.Optional[str] = None, + waiting_time: typing.Optional[float] = None, + last_execution_time: typing.Optional[float] = None, + **kwargs: typing.Any, + ) -> ReCallingOperatorResult: + """ + Builds a re-callable result from the given parameters. + """ + return ReCallingOperatorResult( + reset_to_id=reset_to_id, + last_execution_result={ + ReCallingOperatorResultKeys.WAITING_TIME.value: waiting_time, + ReCallingOperatorResultKeys.LAST_EXECUTION_TIME.value: last_execution_time, + **kwargs, + }, + ) + + def create_re_callable_result_dict( self, reset_to_id: typing.Optional[str] = None, waiting_time: typing.Optional[float] = None, @@ -104,12 +123,10 @@ def build_re_callable_result( Builds a dict formatted re-callable result from the given parameters. """ return { - ReCallingOperatorResult.__name__: ReCallingOperatorResult( + ReCallingOperatorResult.__name__: self.create_re_callable_result( reset_to_id=reset_to_id, - last_execution_result={ - ReCallingOperatorResultKeys.WAITING_TIME.value: waiting_time, - ReCallingOperatorResultKeys.LAST_EXECUTION_TIME.value: last_execution_time, - **kwargs, - }, + waiting_time=waiting_time, + last_execution_time=last_execution_time, + **kwargs, ).to_dict(include_default_values=False) } diff --git a/packages/commons/octobot_commons/str_util.py b/packages/commons/octobot_commons/str_util.py new file mode 100644 index 000000000..1ca53b562 --- /dev/null +++ b/packages/commons/octobot_commons/str_util.py @@ -0,0 +1,21 @@ +# Drakkar-Software OctoBot-Commons +# Copyright (c) Drakkar-Software, All rights reserved. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. +import re + + +def camel_to_snake(name: str) -> str: + """Convert CamelCase to snake_case (e.g. for DSL operator names).""" + return re.sub(r"(? str: + return self._exchange_manager.exchange_name + + def get_traded_symbols(self) -> typing.Iterable[octobot_commons.symbols.Symbol]: + return self._exchange_manager.exchange_config.traded_symbols + + def get_time(self) -> float: + return self._exchange_manager.exchange.get_exchange_current_time() diff --git a/packages/tentacles/Trading/Mode/index_trading_mode/rebalancer/__init__.py b/packages/copy/octobot_copy/rebalancing/__init__.py similarity index 71% rename from packages/tentacles/Trading/Mode/index_trading_mode/rebalancer/__init__.py rename to packages/copy/octobot_copy/rebalancing/__init__.py index 60658a20a..1389719a9 100644 --- a/packages/tentacles/Trading/Mode/index_trading_mode/rebalancer/__init__.py +++ b/packages/copy/octobot_copy/rebalancing/__init__.py @@ -14,10 +14,17 @@ # You should have received a copy of the GNU Lesser General Public # License along with this library. -from .rebalancer import AbstractRebalancer, RebalanceAborted -from .futures_rebalancer import FuturesRebalancer -from .spot_rebalancer import SpotRebalancer -from .option_rebalancer import OptionRebalancer +from octobot_copy.rebalancing.rebalancer import ( + AbstractRebalancer, + RebalanceAborted, + FuturesRebalancer, + SpotRebalancer, + OptionRebalancer, +) +from octobot_copy.rebalancing.planner import ( + RebalanceActionsPlanner, + get_uniform_distribution, +) __all__ = [ "AbstractRebalancer", @@ -25,4 +32,6 @@ "FuturesRebalancer", "SpotRebalancer", "OptionRebalancer", + "RebalanceActionsPlanner", + "get_uniform_distribution", ] \ No newline at end of file diff --git a/packages/copy/octobot_copy/rebalancing/planner/__init__.py b/packages/copy/octobot_copy/rebalancing/planner/__init__.py new file mode 100644 index 000000000..4df4cd928 --- /dev/null +++ b/packages/copy/octobot_copy/rebalancing/planner/__init__.py @@ -0,0 +1,23 @@ +# Drakkar-Software OctoBot +# Copyright (c) Drakkar-Software, All rights reserved. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. + +from octobot_copy.rebalancing.planner.rebalance_actions_planner import RebalanceActionsPlanner +from octobot_copy.rebalancing.planner.distributions import get_uniform_distribution + +__all__ = [ + "RebalanceActionsPlanner", + "get_uniform_distribution", +] diff --git a/packages/copy/octobot_copy/rebalancing/planner/distributions.py b/packages/copy/octobot_copy/rebalancing/planner/distributions.py new file mode 100644 index 000000000..158b296af --- /dev/null +++ b/packages/copy/octobot_copy/rebalancing/planner/distributions.py @@ -0,0 +1,46 @@ +# Drakkar-Software OctoBot +# Copyright (c) Drakkar-Software, All rights reserved. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. +import decimal +import typing + +import octobot_copy.enums as copy_enums +import octobot_trading.constants + +MAX_DISTRIBUTION_AFTER_COMMA_DIGITS = 1 + + +def get_uniform_distribution( + coins, + price_by_coin: typing.Optional[dict[str, decimal.Decimal]] = None, +) -> typing.List: + if not coins: + return [] + ratio = float( + round( + octobot_trading.constants.ONE / decimal.Decimal(str(len(coins))) * octobot_trading.constants.ONE_HUNDRED, + MAX_DISTRIBUTION_AFTER_COMMA_DIGITS, + ) + ) + if not ratio: + return [] + return [ + { + copy_enums.DistributionKeys.NAME.value: coin, + copy_enums.DistributionKeys.VALUE.value: ratio, + copy_enums.DistributionKeys.PRICE.value: price_by_coin.get(coin) if price_by_coin else None, + } + for coin in coins + ] diff --git a/packages/copy/octobot_copy/rebalancing/planner/rebalance_actions_planner.py b/packages/copy/octobot_copy/rebalancing/planner/rebalance_actions_planner.py new file mode 100644 index 000000000..d6d064ec7 --- /dev/null +++ b/packages/copy/octobot_copy/rebalancing/planner/rebalance_actions_planner.py @@ -0,0 +1,556 @@ +# Drakkar-Software OctoBot +# Copyright (c) Drakkar-Software, All rights reserved. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. +import decimal +import typing + +import octobot_commons.list_util as list_util +import octobot_commons.logging as logging +import octobot_trading.constants as trading_constants + +import octobot_copy.constants as copy_constants +import octobot_copy.enums as rebalancer_enums +import octobot_copy.exchange.exchange_interface as exchange_interface +import octobot_copy.rebalancing.planner.distributions as planner_distributions +import octobot_copy.rebalancing.rebalancing_client_interface as rebalancing_client_interface + + +class RebalanceActionsPlanner: + def __init__( + self, + exchange: exchange_interface.ExchangeInterface, + client: rebalancing_client_interface.RebalancingClientInterface, + synchronization_policy: rebalancer_enums.SynchronizationPolicy, + rebalance_trigger_min_ratio: decimal.Decimal, + quote_asset_rebalance_ratio_threshold: decimal.Decimal, + reference_market_ratio: decimal.Decimal, + reference_market: str, + sell_untargeted_traded_coins: bool, + ): + self._exchange: exchange_interface.ExchangeInterface = exchange + self._client: rebalancing_client_interface.RebalancingClientInterface = client + + self.synchronization_policy: rebalancer_enums.SynchronizationPolicy = synchronization_policy + self.rebalance_trigger_min_ratio: decimal.Decimal = rebalance_trigger_min_ratio + self.quote_asset_rebalance_ratio_threshold: decimal.Decimal = quote_asset_rebalance_ratio_threshold + self.reference_market_ratio: decimal.Decimal = reference_market_ratio + self.sell_untargeted_traded_coins: bool = sell_untargeted_traded_coins + self.ratio_per_asset: dict = {} + self.total_ratio_per_asset: decimal.Decimal = trading_constants.ZERO + + self._reference_market = reference_market + self._targeted_coins: list[str] = [] + self._disabled_symbol_bases: frozenset = frozenset() + self.logger: logging.BotLogger = logging.get_logger(self.__class__.__name__) + + @property + def targeted_coins(self) -> list[str]: + return self._targeted_coins + + @targeted_coins.setter + def targeted_coins(self, value: list[str]) -> None: + self._targeted_coins = list_util.deduplicate(value) + + def get_rebalance_details(self) -> typing.Tuple[bool, dict]: + """ + Main method to get the rebalance details. + """ + rebalance_details = self._empty_rebalance_details() + should_rebalance = False + available_traded_bases = set( + symbol.base + for symbol in self._exchange.get_traded_symbols() + ) + + if self.synchronization_policy in ( + rebalancer_enums.SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_AS_SOON_AS_POSSIBLE, + rebalancer_enums.SynchronizationPolicy.SELL_REMOVED_DYNAMIC_INDEX_COINS_AS_SOON_AS_POSSIBLE, + ): + should_rebalance = self._register_removed_coin(rebalance_details, available_traded_bases) + should_rebalance = self._register_coins_update(rebalance_details) or should_rebalance + should_rebalance = self._register_quote_asset_rebalance(rebalance_details) or should_rebalance + if ( + should_rebalance + and self.synchronization_policy + == rebalancer_enums.SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_ON_RATIO_REBALANCE + ): + self.update_distribution(force_latest=True) + rebalance_details = self._empty_rebalance_details() + self._register_removed_coin(rebalance_details, available_traded_bases) + self._register_coins_update(rebalance_details) + self._register_quote_asset_rebalance(rebalance_details) + + if not rebalance_details[rebalancer_enums.RebalanceDetails.FORCED_REBALANCE.value]: + self._resolve_swaps(rebalance_details) + for origin, target in rebalance_details[rebalancer_enums.RebalanceDetails.SWAP.value].items(): + origin_ratio = round( + rebalance_details[rebalancer_enums.RebalanceDetails.REMOVE.value][origin] + * trading_constants.ONE_HUNDRED, + 3 + ) + target_ratio = round( + rebalance_details[rebalancer_enums.RebalanceDetails.ADD.value].get( + target, + rebalance_details[rebalancer_enums.RebalanceDetails.BUY_MORE.value].get( + target, trading_constants.ZERO + ) + ) * trading_constants.ONE_HUNDRED, + 3 + ) or "???" + self.logger.info( + f"Swapping {origin} (holding ratio: {origin_ratio}%) for {target} (to buy ratio: {target_ratio}%) " + f"on [{self._exchange.exchange_name}]: ratios are similar enough to allow swapping." + ) + return ( + should_rebalance or rebalance_details[rebalancer_enums.RebalanceDetails.FORCED_REBALANCE.value], + rebalance_details, + ) + + def update( + self, + *, + synchronization_policy: typing.Any, + rebalance_trigger_min_ratio: decimal.Decimal, + quote_asset_rebalance_ratio_threshold: decimal.Decimal, + reference_market_ratio: decimal.Decimal, + reference_market: str, + sell_untargeted_traded_coins: bool, + ) -> None: + self.synchronization_policy = synchronization_policy + self.rebalance_trigger_min_ratio = rebalance_trigger_min_ratio + self.quote_asset_rebalance_ratio_threshold = quote_asset_rebalance_ratio_threshold + self.reference_market_ratio = reference_market_ratio + self._reference_market = reference_market + self.sell_untargeted_traded_coins = sell_untargeted_traded_coins + + def update_distribution(self, adapt_to_holdings: bool = False, force_latest: bool = False) -> None: + """ + Refresh the target distribution state + """ + distribution = self._get_supported_distribution(adapt_to_holdings, force_latest) + self.ratio_per_asset = { + asset[rebalancer_enums.DistributionKeys.NAME]: asset + for asset in distribution + } + self.total_ratio_per_asset = decimal.Decimal(sum( + asset[rebalancer_enums.DistributionKeys.VALUE] + for asset in self.ratio_per_asset.values() + )) + self._targeted_coins = self._get_filtered_traded_coins() + + def get_target_ratio(self, currency) -> decimal.Decimal: + if currency in self.ratio_per_asset: + try: + return ( + decimal.Decimal(str( + self.ratio_per_asset[currency][rebalancer_enums.DistributionKeys.VALUE] + )) / self.total_ratio_per_asset + ) + except (decimal.DivisionByZero, decimal.InvalidOperation): + pass + return trading_constants.ZERO + + def get_removed_coins_from_config(self, available_traded_bases) -> list: + """ + Get the coins that should be removed from the config. + Mainly used when a target configuration changed and some coins are no longer in the target. + """ + removed_coins = [] + trading_config = self._client.get_config() + if self._client.get_ideal_distribution(trading_config or {}) and self.sell_untargeted_traded_coins: + removed_coins = [ + coin + for coin in available_traded_bases + if coin not in self._targeted_coins + and coin != self._reference_market + ] + if self.synchronization_policy == rebalancer_enums.SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_AS_SOON_AS_POSSIBLE: + previous_trading_config = self._client.get_previous_config() + if not (previous_trading_config and trading_config): + return removed_coins + current_coins = [ + asset[rebalancer_enums.DistributionKeys.NAME] + for asset in (self._client.get_ideal_distribution(trading_config or {}) or []) + ] + return list(set(removed_coins + [ + asset[rebalancer_enums.DistributionKeys.NAME] + for asset in previous_trading_config[copy_constants.CONFIG_INDEX_CONTENT] + if asset[rebalancer_enums.DistributionKeys.NAME] not in current_coins + and ( + asset[rebalancer_enums.DistributionKeys.NAME] + != self._reference_market + ) + ])) + if self.synchronization_policy == rebalancer_enums.SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_ON_RATIO_REBALANCE: + historical_configs = self._client.get_historical_configs( + 0, self._exchange.get_time() + ) + if not (historical_configs and trading_config): + return removed_coins + current_coins = [ + asset[rebalancer_enums.DistributionKeys.NAME] + for asset in (self._client.get_ideal_distribution(trading_config or {}) or []) + ] + removed_coins_from_historical_configs = set() + for historical_config in historical_configs: + for asset in historical_config[copy_constants.CONFIG_INDEX_CONTENT]: + asset_name = asset[rebalancer_enums.DistributionKeys.NAME] + if asset_name not in current_coins and asset_name != self._reference_market: + removed_coins_from_historical_configs.add(asset_name) + return list(removed_coins_from_historical_configs.union(removed_coins)) + if self.synchronization_policy == rebalancer_enums.SynchronizationPolicy.SELL_REMOVED_DYNAMIC_INDEX_COINS_AS_SOON_AS_POSSIBLE: + return [ + coin for coin in available_traded_bases + if coin not in self._targeted_coins and coin != self._reference_market + ] + self.logger.error(f"Unknown synchronization policy: {self.synchronization_policy}") + return [] + + def _get_adjusted_target_ratio(self, currency: str) -> decimal.Decimal: + """ + Get the adjusted target ratio for a given currency relatively to the reference market ratio. + """ + base_ratio = self.get_target_ratio(currency) + if self.reference_market_ratio < trading_constants.ONE: + return base_ratio * self.reference_market_ratio + return base_ratio + + def _get_coins_to_consider_for_ratio(self) -> list: + """ + Get the coins that should be considered for the ratio, including the reference market. + """ + return self._targeted_coins + [self._reference_market] + + def _empty_rebalance_details(self) -> dict: + return { + rebalancer_enums.RebalanceDetails.SELL_SOME.value: {}, + rebalancer_enums.RebalanceDetails.BUY_MORE.value: {}, + rebalancer_enums.RebalanceDetails.REMOVE.value: {}, + rebalancer_enums.RebalanceDetails.ADD.value: {}, + rebalancer_enums.RebalanceDetails.SWAP.value: {}, + rebalancer_enums.RebalanceDetails.FORCED_REBALANCE.value: False, + } + + def _register_coins_update(self, rebalance_details: dict) -> bool: + """ + Register the coins that are beyond the target ratio: + - some should be added + - some should be bought + - some should be sold + """ + should_rebalance = False + for coin in self._targeted_coins: + target_ratio = self._get_adjusted_target_ratio(coin) + coin_ratio = self._client.get_holdings_ratio(coin, traded_symbols_only=True, include_assets_in_open_orders=True) + beyond_ratio = True + if coin_ratio == trading_constants.ZERO and target_ratio > trading_constants.ZERO: + rebalance_details[rebalancer_enums.RebalanceDetails.ADD.value][coin] = target_ratio + should_rebalance = True + elif coin_ratio < target_ratio - self.rebalance_trigger_min_ratio: + rebalance_details[rebalancer_enums.RebalanceDetails.BUY_MORE.value][coin] = target_ratio + should_rebalance = True + elif coin_ratio > target_ratio + self.rebalance_trigger_min_ratio: + rebalance_details[rebalancer_enums.RebalanceDetails.SELL_SOME.value][coin] = target_ratio + should_rebalance = True + else: + beyond_ratio = False + if beyond_ratio: + allowance = round(self.rebalance_trigger_min_ratio * trading_constants.ONE_HUNDRED, 2) + self.logger.info( + f"{coin} is beyond the target ratio of {round(target_ratio * trading_constants.ONE_HUNDRED, 2)}[+/-{allowance}]%, " + f"ratio: {round(coin_ratio * trading_constants.ONE_HUNDRED, 2)}%. A rebalance is required." + ) + return should_rebalance + + def _register_removed_coin(self, rebalance_details: dict, available_traded_bases: set[str]) -> bool: + """ + Register the coins that are no longer in the target and should be sold. + """ + should_rebalance = False + for coin in self.get_removed_coins_from_config(available_traded_bases): + if coin in available_traded_bases: + coin_ratio = self._client.get_holdings_ratio(coin, traded_symbols_only=True, include_assets_in_open_orders=True) + if coin_ratio >= copy_constants.MIN_RATIO_TO_SELL: + rebalance_details[rebalancer_enums.RebalanceDetails.REMOVE.value][coin] = coin_ratio + self.logger.info( + f"{coin} (holdings: {round(coin_ratio * trading_constants.ONE_HUNDRED, 3)}%) is not in target " + f"anymore. A rebalance is required." + ) + should_rebalance = True + else: + if coin in self._disabled_symbol_bases: + self.logger.info( + f"Ignoring {coin} holding: {coin} is not in target anymore but is disabled." + ) + else: + self.logger.error( + f"Ignoring {coin} holding: Can't sell {coin} as it is not in any trading pair" + f" but is not in target anymore. This is unexpected" + ) + return should_rebalance + + def _register_quote_asset_rebalance(self, rebalance_details: dict) -> bool: + """ + Returns True if the rebalance is required due to a high non-targeted quote asset holdings ratio. + """ + non_targeted_quote_assets_ratio = self._get_non_targeted_quote_assets_ratio() + if self._should_rebalance_due_to_non_targeted_quote_assets_ratio( + non_targeted_quote_assets_ratio, rebalance_details + ): + rebalance_details[rebalancer_enums.RebalanceDetails.FORCED_REBALANCE.value] = True + self.logger.info( + f"Rebalancing due to a high non-targeted quote asset holdings ratio: " + f"{round(non_targeted_quote_assets_ratio * trading_constants.ONE_HUNDRED, 2)}%, quote rebalance " + f"threshold = {self.quote_asset_rebalance_ratio_threshold * trading_constants.ONE_HUNDRED}%" + ) + return True + return False + + def _should_rebalance_due_to_non_targeted_quote_assets_ratio( + self, non_targeted_quote_assets_ratio: decimal.Decimal, rebalance_details: dict + ) -> bool: + total_added_ratio = ( + self._sum_ratios(rebalance_details, rebalancer_enums.RebalanceDetails.ADD.value) + + self._sum_ratios(rebalance_details, rebalancer_enums.RebalanceDetails.BUY_MORE.value) + ) + + if ( + total_added_ratio * (trading_constants.ONE - copy_constants.QUOTE_ASSET_TO_TARGETED_SWAP_RATIO_THRESHOLD) + <= non_targeted_quote_assets_ratio + <= total_added_ratio * (trading_constants.ONE + copy_constants.QUOTE_ASSET_TO_TARGETED_SWAP_RATIO_THRESHOLD) + ): + total_removed_ratio = ( + self._sum_ratios(rebalance_details, rebalancer_enums.RebalanceDetails.REMOVE.value) + + self._sum_ratios(rebalance_details, rebalancer_enums.RebalanceDetails.SELL_SOME.value) + ) + if total_removed_ratio == trading_constants.ZERO: + return False + min_ratio = min( + min( + self.get_target_ratio(coin) + for coin in self._targeted_coins + ) if self._targeted_coins else self.quote_asset_rebalance_ratio_threshold, + self.quote_asset_rebalance_ratio_threshold + ) + return non_targeted_quote_assets_ratio >= min_ratio + + @staticmethod + def _sum_ratios(rebalance_details: dict, key: str) -> decimal.Decimal: + return decimal.Decimal(str(sum( + ratio + for ratio in rebalance_details[key].values() + ))) if rebalance_details[key] else trading_constants.ZERO + + def _get_non_targeted_quote_assets_ratio(self) -> decimal.Decimal: + total = trading_constants.ZERO + for quote in set( + symbol.quote + for symbol in self._exchange.get_traded_symbols() + if symbol.quote not in self._targeted_coins + ): + ratio = decimal.Decimal(str( + self._client.get_holdings_ratio(quote, traded_symbols_only=True, include_assets_in_open_orders=True) + )) + if quote == self._reference_market and self.reference_market_ratio > trading_constants.ZERO: + reference_market_keep_ratio = trading_constants.ONE - self.reference_market_ratio + ratio = max(trading_constants.ZERO, ratio - reference_market_keep_ratio) + total += ratio + return decimal.Decimal(str(total)) + + def _resolve_swaps(self, details: dict): + """ + Resolve swaps between added and removed coins, when swaps are possible + """ + removed = details[rebalancer_enums.RebalanceDetails.REMOVE.value] + details[rebalancer_enums.RebalanceDetails.SWAP.value] = {} + if details[rebalancer_enums.RebalanceDetails.SELL_SOME.value]: + return + added = { + **details[rebalancer_enums.RebalanceDetails.ADD.value], + **details[rebalancer_enums.RebalanceDetails.BUY_MORE.value], + } + if len(removed) == len(added) == copy_constants.ALLOWED_1_TO_1_SWAP_COUNTS: + for removed_coin, removed_ratio, added_coin, added_ratio in zip( + removed, removed.values(), added, added.values() + ): + added_holding_ratio = self._client.get_holdings_ratio( + added_coin, traded_symbols_only=True, coins_whitelist=self._get_coins_to_consider_for_ratio() + ) + required_added_ratio = added_ratio - added_holding_ratio + if ( + removed_ratio - self.rebalance_trigger_min_ratio + < required_added_ratio + < removed_ratio + self.rebalance_trigger_min_ratio + ): + details[rebalancer_enums.RebalanceDetails.SWAP.value][removed_coin] = added_coin + else: + details[rebalancer_enums.RebalanceDetails.SWAP.value] = {} + return + + def _get_filtered_traded_coins(self) -> list[str]: + coins = set( + symbol.base + for symbol in self._exchange.get_traded_symbols() + if symbol.base in self.ratio_per_asset and symbol.quote == self._reference_market + ) + if self._reference_market in self.ratio_per_asset and coins: + coins.add(self._reference_market) + return sorted(list(coins)) + + def _get_currently_applied_historical_config_according_to_holdings( + self, config: dict, traded_bases: set[str] + ) -> dict: + if self._is_target_config_applied(config, traded_bases): + self.logger.info(f"Using {self._client.get_client_name()} latest config.") + return config + historical_configs = self._client.get_historical_configs( + 0, self._exchange.get_time() + ) + if not historical_configs or ( + len(historical_configs) == 1 and ( + self._client.get_ideal_distribution(historical_configs[0]) == self._client.get_ideal_distribution(config) + and historical_configs[0][copy_constants.CONFIG_REBALANCE_TRIGGER_MIN_PERCENT] == config[copy_constants.CONFIG_REBALANCE_TRIGGER_MIN_PERCENT] + ) + ): + self.logger.info(f"Using {self._client.get_client_name()} latest config as no historical configs are available.") + return config + for hist_rank, historical_config in enumerate(historical_configs): + if self._is_target_config_applied(historical_config, traded_bases): + self.logger.info( + f"Using [N-{hist_rank}] {self._client.get_client_name()} historical config distribution: " + f"{self._client.get_ideal_distribution(historical_config)}." + ) + return historical_config + self.logger.info( + f"No suitable {self._client.get_client_name()} config found: using latest distribution: " + f"{self._client.get_ideal_distribution(config)}." + ) + return config + + def _is_target_config_applied(self, config: dict, traded_bases: set[str]) -> bool: + full_assets_distribution = self._client.get_ideal_distribution(config) + if not full_assets_distribution: + return False + assets_distribution = [ + asset + for asset in full_assets_distribution + if asset[rebalancer_enums.DistributionKeys.NAME] in traded_bases + ] + if len(assets_distribution) != len(full_assets_distribution): + missing_assets = [ + asset[rebalancer_enums.DistributionKeys.NAME] + for asset in full_assets_distribution + if asset not in assets_distribution + ] + self.logger.warning( + f"Ignored {self._client.get_client_name()} config candidate as {len(missing_assets)} configured assets " + f"{missing_assets} are missing from {self._exchange.exchange_name} traded pairs." + ) + return False + + total_ratio = decimal.Decimal(sum( + asset[rebalancer_enums.DistributionKeys.VALUE] + for asset in assets_distribution + )) + if total_ratio == trading_constants.ZERO: + return False + min_trigger_ratio = self._get_config_min_ratio(config) + for asset_distrib in assets_distribution: + base_target_ratio = decimal.Decimal(str(asset_distrib[rebalancer_enums.DistributionKeys.VALUE])) / total_ratio + if self.reference_market_ratio < trading_constants.ONE: + target_ratio = base_target_ratio * self.reference_market_ratio + else: + target_ratio = base_target_ratio + coin_ratio = self._client.get_holdings_ratio( + asset_distrib[rebalancer_enums.DistributionKeys.NAME], traded_symbols_only=True, + ) + if not (target_ratio - min_trigger_ratio <= coin_ratio <= target_ratio + min_trigger_ratio): + return False + return True + + def _get_config_min_ratio(self, config: dict) -> decimal.Decimal: + ratio = None + rebalance_trigger_profiles = config.get(copy_constants.CONFIG_REBALANCE_TRIGGER_PROFILES, None) + if rebalance_trigger_profiles: + selected_rebalance_trigger_profile_name = config.get(copy_constants.CONFIG_SELECTED_REBALANCE_TRIGGER_PROFILE, None) + selected_profile = [ + p for p in rebalance_trigger_profiles + if p[copy_constants.CONFIG_REBALANCE_TRIGGER_PROFILE_NAME] == selected_rebalance_trigger_profile_name + ] + if selected_profile: + selected_rebalance_trigger_profile = selected_profile[0] + ratio = selected_rebalance_trigger_profile[copy_constants.CONFIG_REBALANCE_TRIGGER_PROFILE_MIN_PERCENT] + if ratio is None: + ratio = config.get(copy_constants.CONFIG_REBALANCE_TRIGGER_MIN_PERCENT) + if ratio is None: + return self.rebalance_trigger_min_ratio + return decimal.Decimal(str(ratio)) / trading_constants.ONE_HUNDRED + + def _get_supported_distribution(self, adapt_to_holdings: bool, force_latest: bool) -> list: + """ + Returns the configured distribution if any. This configured distribution might be choosen from + historical configs if the current content does not match the configured distribution and the + SELL_REMOVED_INDEX_COINS_ON_RATIO_REBALANCE synchronization policy is used. + + Use a uniform distribution over all the exchange's traded pairs if no configured distribution is found. + """ + trading_config = self._client.get_config() + if detailed_distribution := self._client.get_ideal_distribution(trading_config or {}): + traded_bases = set( + symbol.base + for symbol in self._exchange.get_traded_symbols() + ) + traded_bases.add(self._reference_market) + if ( + (adapt_to_holdings or force_latest) + and self.synchronization_policy == rebalancer_enums.SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_ON_RATIO_REBALANCE + ): + if adapt_to_holdings: + target_config = self._get_currently_applied_historical_config_according_to_holdings( + trading_config or {}, traded_bases + ) + else: + try: + target_config = self._client.get_historical_configs( + 0, self._exchange.get_time() + )[0] + self.logger.info( + f"Updated {self._client.get_client_name()} to use latest distribution: " + f"{self._client.get_ideal_distribution(target_config)}." + ) + except IndexError: + target_config = trading_config or {} + detailed_distribution = self._client.get_ideal_distribution(target_config) + if not detailed_distribution: + raise ValueError(f"No distribution found in historical target config: {target_config}") + distribution = [ + asset + for asset in detailed_distribution + if asset[rebalancer_enums.DistributionKeys.NAME] in traded_bases + ] + if removed_assets := [ + asset[rebalancer_enums.DistributionKeys.NAME] + for asset in detailed_distribution + if asset not in distribution + ]: + self.logger.info( + f"Ignored {len(removed_assets)} assets {removed_assets} from configured " + f"distribution as absent from traded pairs." + ) + return distribution + return planner_distributions.get_uniform_distribution([ + symbol.base + for symbol in self._exchange.get_traded_symbols() + ]) diff --git a/packages/copy/octobot_copy/rebalancing/rebalancer/__init__.py b/packages/copy/octobot_copy/rebalancing/rebalancer/__init__.py new file mode 100644 index 000000000..0a35a224a --- /dev/null +++ b/packages/copy/octobot_copy/rebalancing/rebalancer/__init__.py @@ -0,0 +1,28 @@ +# Drakkar-Software OctoBot +# Copyright (c) Drakkar-Software, All rights reserved. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. + +from octobot_copy.rebalancing.rebalancer.rebalancer import AbstractRebalancer, RebalanceAborted +from octobot_copy.rebalancing.rebalancer.futures_rebalancer import FuturesRebalancer +from octobot_copy.rebalancing.rebalancer.spot_rebalancer import SpotRebalancer +from octobot_copy.rebalancing.rebalancer.option_rebalancer import OptionRebalancer + +__all__ = [ + "AbstractRebalancer", + "RebalanceAborted", + "FuturesRebalancer", + "SpotRebalancer", + "OptionRebalancer", +] diff --git a/packages/tentacles/Trading/Mode/index_trading_mode/rebalancer/futures_rebalancer.py b/packages/copy/octobot_copy/rebalancing/rebalancer/futures_rebalancer.py similarity index 95% rename from packages/tentacles/Trading/Mode/index_trading_mode/rebalancer/futures_rebalancer.py rename to packages/copy/octobot_copy/rebalancing/rebalancer/futures_rebalancer.py index c914ab969..9a39c8f36 100644 --- a/packages/tentacles/Trading/Mode/index_trading_mode/rebalancer/futures_rebalancer.py +++ b/packages/copy/octobot_copy/rebalancing/rebalancer/futures_rebalancer.py @@ -24,11 +24,11 @@ import octobot_trading.personal_data as trading_personal_data import octobot_trading.personal_data.orders.order_util as order_util -import tentacles.Trading.Mode.index_trading_mode.rebalancer as rebalancer -import tentacles.Trading.Mode.index_trading_mode.index_trading as index_trading +import octobot_copy.enums as rebalancer_enums +import octobot_copy.rebalancing.rebalancer.rebalancer as base_rebalancer -class FuturesRebalancer(rebalancer.AbstractRebalancer): +class FuturesRebalancer(base_rebalancer.AbstractRebalancer): async def prepare_coin_rebalancing(self, coin: str): await self.ensure_contract_loaded(coin) @@ -40,8 +40,8 @@ async def ensure_contract_loaded(self, coin: str): self.logger.info(f"Contract for {symbol} has been loaded.") async def buy_coin( - self, - symbol: str, + self, + symbol: str, ideal_amount: decimal.Decimal, ideal_price: typing.Optional[decimal.Decimal], dependencies: typing.Optional[commons_signals.SignalDependencies] @@ -53,34 +53,34 @@ async def buy_coin( positions_manager = self.trading_mode.exchange_manager.exchange_personal_data.positions_manager position = positions_manager.get_symbol_position(symbol, trading_enums.PositionSide.BOTH) _, _, _, current_price, symbol_market = await trading_personal_data.get_pre_order_data( - self.trading_mode.exchange_manager, - symbol=symbol, + self.trading_mode.exchange_manager, + symbol=symbol, timeout=trading_constants.ORDER_DATA_FETCHING_TIMEOUT ) - + order_target_price = ideal_price if ideal_price is not None else current_price current_position_size = position.size if not position.is_idle() else trading_constants.ZERO effective_current_position_size = current_position_size + self.get_pending_open_quantity(symbol) size_difference = ideal_amount - effective_current_position_size - + if size_difference <= trading_constants.ZERO: return [] - + side = trading_enums.TradeOrderSide.BUY # Always open long positions for index max_order_size, increasing_position = order_util.get_futures_max_order_size( self.trading_mode.exchange_manager, symbol, side, current_price, False, current_position_size, ideal_amount ) - + order_quantity = min(size_difference, max_order_size) if order_quantity <= trading_constants.ZERO: return [] - + quantity = trading_personal_data.decimal_adapt_order_quantity_because_fees( self.trading_mode.exchange_manager, symbol, trading_enums.TraderOrderType.BUY_MARKET, order_quantity, order_target_price, trading_enums.TradeOrderSide.BUY ) - + created_orders = [] orders_should_have_been_created = False is_price_close_to_market = order_target_price >= current_price * (decimal.Decimal(1) - self.PRICE_THRESHOLD_TO_USE_MARKET_ORDER) @@ -97,7 +97,7 @@ async def buy_coin( trading_modes.get_instantly_filled_limit_order_adapted_price_and_quantity( order_target_price, quantity, order_type ) - + for order_quantity, order_price in trading_personal_data.decimal_check_and_adapt_order_details_if_necessary( quantity, order_target_price, @@ -116,7 +116,7 @@ async def buy_coin( created_order = await self.trading_mode.create_order(current_order, dependencies=dependencies) if created_order is not None: created_orders.append(created_order) - + if created_orders: return created_orders if self.trading_mode.allow_skip_asset: @@ -133,10 +133,10 @@ async def get_coins_to_sell_orders(self, details: dict, dependencies: typing.Opt for coin_or_symbol in self.get_coins_to_sell(details): symbol_target_ratio[self.get_symbol_and_base_asset(coin_or_symbol)[0]] = None - for coin_or_symbol in details.get(index_trading.RebalanceDetails.REMOVE.value, {}): + for coin_or_symbol in details.get(rebalancer_enums.RebalanceDetails.REMOVE.value, {}): symbol_target_ratio[self.get_symbol_and_base_asset(coin_or_symbol)[0]] = None - for coin_or_symbol, target_ratio in details.get(index_trading.RebalanceDetails.SELL_SOME.value, {}).items(): + for coin_or_symbol, target_ratio in details.get(rebalancer_enums.RebalanceDetails.SELL_SOME.value, {}).items(): symbol_target_ratio[self.get_symbol_and_base_asset(coin_or_symbol)[0]] = target_ratio for symbol, target_ratio in symbol_target_ratio.items(): diff --git a/packages/tentacles/Trading/Mode/index_trading_mode/rebalancer/option_rebalancer.py b/packages/copy/octobot_copy/rebalancing/rebalancer/option_rebalancer.py similarity index 83% rename from packages/tentacles/Trading/Mode/index_trading_mode/rebalancer/option_rebalancer.py rename to packages/copy/octobot_copy/rebalancing/rebalancer/option_rebalancer.py index 8f0f97f51..1ce1db936 100644 --- a/packages/tentacles/Trading/Mode/index_trading_mode/rebalancer/option_rebalancer.py +++ b/packages/copy/octobot_copy/rebalancing/rebalancer/option_rebalancer.py @@ -14,8 +14,8 @@ # You should have received a copy of the GNU Lesser General Public # License along with this library. -import tentacles.Trading.Mode.index_trading_mode.rebalancer as rebalancer +import octobot_copy.rebalancing.rebalancer.futures_rebalancer as futures_rebalancer -class OptionRebalancer(rebalancer.FuturesRebalancer): +class OptionRebalancer(futures_rebalancer.FuturesRebalancer): pass diff --git a/packages/tentacles/Trading/Mode/index_trading_mode/rebalancer/rebalancer.py b/packages/copy/octobot_copy/rebalancing/rebalancer/rebalancer.py similarity index 88% rename from packages/tentacles/Trading/Mode/index_trading_mode/rebalancer/rebalancer.py rename to packages/copy/octobot_copy/rebalancing/rebalancer/rebalancer.py index 5945e5971..410fc1a84 100644 --- a/packages/tentacles/Trading/Mode/index_trading_mode/rebalancer/rebalancer.py +++ b/packages/copy/octobot_copy/rebalancing/rebalancer/rebalancer.py @@ -19,19 +19,20 @@ import octobot_commons.logging as logging import octobot_commons.signals as commons_signals +import octobot_commons.symbols.symbol_util as symbol_util import octobot_trading.personal_data as trading_personal_data import octobot_trading.modes as trading_modes -import octobot_commons.symbols.symbol_util as symbol_util import octobot_trading.errors as trading_errors import octobot_trading.enums as trading_enums import octobot_trading.api as trading_api -import tentacles.Trading.Mode.index_trading_mode.index_trading as index_trading +import octobot_copy.enums as rebalancer_enums class RebalanceAborted(Exception): pass +# TODO refactor this class to use the ExchangeInterface and client interfaces class AbstractRebalancer: FILL_ORDER_TIMEOUT = 60 @@ -47,9 +48,9 @@ async def prepare_coin_rebalancing(self, coin: str): raise NotImplementedError("prepare_coin_rebalancing is not implemented") async def buy_coin( - self, - symbol: str, - ideal_amount: decimal.Decimal, + self, + symbol: str, + ideal_amount: decimal.Decimal, ideal_price: typing.Optional[decimal.Decimal], dependencies: typing.Optional[commons_signals.SignalDependencies] ) -> list: @@ -60,14 +61,14 @@ async def buy_coin( async def get_removed_coins_to_sell_orders(self, details: dict, dependencies: typing.Optional[commons_signals.SignalDependencies]) -> list: removed_coins_to_sell_orders = [] - if removed_coins_to_sell := list(details[index_trading.RebalanceDetails.REMOVE.value]): + if removed_coins_to_sell := list(details[rebalancer_enums.RebalanceDetails.REMOVE.value]): removed_coins_to_sell_orders = await trading_modes.convert_assets_to_target_asset( self.trading_mode, removed_coins_to_sell, self.trading_mode.exchange_manager.exchange_personal_data.portfolio_manager.reference_market, {}, dependencies=dependencies ) return removed_coins_to_sell_orders - + async def get_coins_to_sell_orders(self, details: dict, dependencies: typing.Optional[commons_signals.SignalDependencies]) -> list: order_coins_to_sell = self.get_coins_to_sell(details) coins_to_sell_orders = await trading_modes.convert_assets_to_target_asset( @@ -83,11 +84,11 @@ async def validate_sold_removed_assets( removed_orders: typing.Optional[list] = None ) -> None: if ( - details[index_trading.RebalanceDetails.REMOVE.value] and + details[rebalancer_enums.RebalanceDetails.REMOVE.value] and not ( - details[index_trading.RebalanceDetails.BUY_MORE.value] - or details[index_trading.RebalanceDetails.ADD.value] - or details[index_trading.RebalanceDetails.SWAP.value] + details[rebalancer_enums.RebalanceDetails.BUY_MORE.value] + or details[rebalancer_enums.RebalanceDetails.ADD.value] + or details[rebalancer_enums.RebalanceDetails.SWAP.value] ) ): if removed_orders is None: @@ -102,18 +103,18 @@ async def validate_sold_removed_assets( ] if not any( asset in sold_coins - for asset in details[index_trading.RebalanceDetails.REMOVE.value] + for asset in details[rebalancer_enums.RebalanceDetails.REMOVE.value] ): self.logger.info( - f"Cancelling rebalance: not enough {list(details[index_trading.RebalanceDetails.REMOVE.value])} funds to sell" + f"Cancelling rebalance: not enough {list(details[rebalancer_enums.RebalanceDetails.REMOVE.value])} funds to sell" ) raise trading_errors.MissingMinimalExchangeTradeVolume( - f"not enough {list(details[index_trading.RebalanceDetails.REMOVE.value])} funds to sell" + f"not enough {list(details[rebalancer_enums.RebalanceDetails.REMOVE.value])} funds to sell" ) async def sell_indexed_coins_for_reference_market( - self, - details: dict, + self, + details: dict, dependencies: typing.Optional[commons_signals.SignalDependencies] ) -> list: await self.pre_cancel_conflicting_orders(details, dependencies, trading_enums.TradeOrderSide.BUY) @@ -127,7 +128,7 @@ async def sell_indexed_coins_for_reference_market( return orders def get_coins_to_sell(self, details: dict) -> list: - return list(details[index_trading.RebalanceDetails.SWAP.value]) or ( + return list(details[rebalancer_enums.RebalanceDetails.SWAP.value]) or ( self.trading_mode.indexed_coins ) @@ -196,7 +197,7 @@ async def pre_cancel_conflicting_orders( def get_pre_cancel_order_symbols(self, details: dict, side: trading_enums.TradeOrderSide) -> set[str]: symbols_to_cleanup: set[str] = set() keys = self.get_rebalance_details_keys_for_side(side) - + for key in keys: for coin_or_symbol in details.get(key, {}): symbols_to_cleanup.add(self.get_symbol_and_base_asset(coin_or_symbol)[0]) @@ -204,9 +205,9 @@ def get_pre_cancel_order_symbols(self, details: dict, side: trading_enums.TradeO def get_rebalance_details_keys_for_side(self, side: trading_enums.TradeOrderSide) -> list[str]: if side == trading_enums.TradeOrderSide.BUY: - return [index_trading.RebalanceDetails.REMOVE.value, index_trading.RebalanceDetails.SELL_SOME.value] + return [rebalancer_enums.RebalanceDetails.REMOVE.value, rebalancer_enums.RebalanceDetails.SELL_SOME.value] if side == trading_enums.TradeOrderSide.SELL: - return [index_trading.RebalanceDetails.ADD.value, index_trading.RebalanceDetails.BUY_MORE.value] + return [rebalancer_enums.RebalanceDetails.ADD.value, rebalancer_enums.RebalanceDetails.BUY_MORE.value] raise ValueError(f"Unsupported side: {side}") def get_symbol_and_base_asset(self, coin_or_symbol: str) -> tuple[str, str]: diff --git a/packages/tentacles/Trading/Mode/index_trading_mode/rebalancer/spot_rebalancer.py b/packages/copy/octobot_copy/rebalancing/rebalancer/spot_rebalancer.py similarity index 94% rename from packages/tentacles/Trading/Mode/index_trading_mode/rebalancer/spot_rebalancer.py rename to packages/copy/octobot_copy/rebalancing/rebalancer/spot_rebalancer.py index d1bdaf6ef..89432c1ef 100644 --- a/packages/tentacles/Trading/Mode/index_trading_mode/rebalancer/spot_rebalancer.py +++ b/packages/copy/octobot_copy/rebalancing/rebalancer/spot_rebalancer.py @@ -23,25 +23,25 @@ import octobot_trading.modes as trading_modes import octobot_trading.personal_data as trading_personal_data -import tentacles.Trading.Mode.index_trading_mode.rebalancer as rebalancer +import octobot_copy.rebalancing.rebalancer.rebalancer as base_rebalancer -class SpotRebalancer(rebalancer.AbstractRebalancer): +class SpotRebalancer(base_rebalancer.AbstractRebalancer): async def prepare_coin_rebalancing(self, coin: str): # Nothing to do in SPOT pass async def buy_coin( - self, - symbol: str, + self, + symbol: str, ideal_amount: decimal.Decimal, ideal_price: typing.Optional[decimal.Decimal], dependencies: typing.Optional[commons_signals.SignalDependencies] ) -> list: current_symbol_holding, current_market_holding, market_quantity, current_price, symbol_market = \ await trading_personal_data.get_pre_order_data( - self.trading_mode.exchange_manager, - symbol=symbol, + self.trading_mode.exchange_manager, + symbol=symbol, timeout=trading_constants.ORDER_DATA_FETCHING_TIMEOUT ) order_target_price = ideal_price if ideal_price is not None else current_price diff --git a/packages/copy/octobot_copy/rebalancing/rebalancing_client_interface.py b/packages/copy/octobot_copy/rebalancing/rebalancing_client_interface.py new file mode 100644 index 000000000..db157ca07 --- /dev/null +++ b/packages/copy/octobot_copy/rebalancing/rebalancing_client_interface.py @@ -0,0 +1,36 @@ +# Drakkar-Software OctoBot +# Copyright (c) Drakkar-Software, All rights reserved. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. +import decimal +import typing + + +class RebalancingClientInterface: + def __init__( + self, + *, + get_holdings_ratio: typing.Callable[..., decimal.Decimal], + get_config: typing.Callable[[], typing.Optional[dict]], + get_previous_config: typing.Callable[[], typing.Optional[dict]], + get_historical_configs: typing.Callable[[float, float], list], + get_ideal_distribution: typing.Callable[[dict], typing.Optional[list]], + get_client_name: typing.Callable[[], str], + ) -> None: + self.get_holdings_ratio = get_holdings_ratio + self.get_config = get_config + self.get_previous_config = get_previous_config + self.get_historical_configs = get_historical_configs + self.get_ideal_distribution = get_ideal_distribution + self.get_client_name = get_client_name diff --git a/packages/flow/octobot_flow/entities/automations/additional_actions.py b/packages/flow/octobot_flow/entities/automations/additional_actions.py index c256e2e52..94f9cf874 100644 --- a/packages/flow/octobot_flow/entities/automations/additional_actions.py +++ b/packages/flow/octobot_flow/entities/automations/additional_actions.py @@ -3,7 +3,7 @@ @dataclasses.dataclass -class AdditionalActions(octobot_commons.dataclasses.FlexibleDataclass): +class AdditionalActions(octobot_commons.dataclasses.MinimizableDataclass): # todo implement this when necessary check_min_portfolio: bool = False optimize_portfolio: bool = False diff --git a/packages/flow/octobot_flow/jobs/automation_job.py b/packages/flow/octobot_flow/jobs/automation_job.py index 9125a242b..017fa70b0 100644 --- a/packages/flow/octobot_flow/jobs/automation_job.py +++ b/packages/flow/octobot_flow/jobs/automation_job.py @@ -207,15 +207,22 @@ async def _init_all_required_exchange_data( exchange_account_job = exchange_account_job_import.ExchangeAccountJob( self.automation_state, self.fetched_actions ) - symbol = set( - exchange_account_job.get_all_actions_symbols() - + octobot_flow.logic.dsl.get_actions_symbol_dependencies(to_execute_actions) + minimal_profile_data = octobot_flow.logic.configuration.create_profile_data( + self.automation_state.exchange_account_details, + self.automation_state.automation.metadata.automation_id, + set() + ) + symbols = set( + exchange_account_job.get_all_actions_symbols(minimal_profile_data) + + octobot_flow.logic.dsl.get_actions_symbol_dependencies( + to_execute_actions, minimal_profile_data + ) ) async with exchange_account_job.account_exchange_context( octobot_flow.logic.configuration.create_profile_data( self.automation_state.exchange_account_details, self.automation_state.automation.metadata.automation_id, - symbol + symbols ) ): await exchange_account_job.update_public_data() diff --git a/packages/flow/octobot_flow/jobs/automation_runner_job.py b/packages/flow/octobot_flow/jobs/automation_runner_job.py index 4c245c884..a4b3ed6ce 100644 --- a/packages/flow/octobot_flow/jobs/automation_runner_job.py +++ b/packages/flow/octobot_flow/jobs/automation_runner_job.py @@ -69,8 +69,9 @@ async def run(self): async def _execute_actions(self) -> tuple[list[octobot_flow.enums.ChangedElements], float]: actions_executor = octobot_flow.logic.actions.ActionsExecutor( self._maybe_community_repository, self._exchange_manager, + self.profile_data_provider.get_profile_data(), self.automation_state.automation, self._to_execute_actions, - self._as_reference_account + self._as_reference_account, ) await actions_executor.execute() return actions_executor.changed_elements, ( @@ -113,11 +114,16 @@ def init_strategy_exchange_data(self, exchange_data: exchange_data_import.Exchan exchange_data.orders_details.open_orders = exchange_account_elements.orders.open_orders def _get_profile_data(self) -> commons_profiles.ProfileData: + minimal_profile_data = octobot_flow.logic.configuration.create_profile_data( + self.automation_state.exchange_account_details, + self.automation_state.automation.metadata.automation_id, + set() + ) return octobot_flow.logic.configuration.create_profile_data( self.automation_state.exchange_account_details, self.automation_state.automation.metadata.automation_id, set(octobot_flow.logic.dsl.get_actions_symbol_dependencies( - self._to_execute_actions + self._to_execute_actions, minimal_profile_data )) ) diff --git a/packages/flow/octobot_flow/jobs/exchange_account_job.py b/packages/flow/octobot_flow/jobs/exchange_account_job.py index 1bcf3a639..84c2f488e 100644 --- a/packages/flow/octobot_flow/jobs/exchange_account_job.py +++ b/packages/flow/octobot_flow/jobs/exchange_account_job.py @@ -149,11 +149,13 @@ def _get_traded_symbols(self) -> list[str]: profile_data = self.profile_data_provider.get_profile_data() config_symbols = scripting_library.get_traded_symbols(profile_data) return list_util.deduplicate( - config_symbols + self.get_all_actions_symbols() + config_symbols + self.get_all_actions_symbols(profile_data) ) - def get_all_actions_symbols(self) -> list[str]: - return octobot_flow.logic.dsl.get_actions_symbol_dependencies(self.actions) + def get_all_actions_symbols(self, profile_data: commons_profiles.ProfileData) -> list[str]: + return octobot_flow.logic.dsl.get_actions_symbol_dependencies( + self.actions, profile_data + ) def _get_time_frames(self) -> list[str]: return scripting_library.get_time_frames(self.profile_data_provider.get_profile_data()) diff --git a/packages/flow/octobot_flow/logic/actions/actions_executor.py b/packages/flow/octobot_flow/logic/actions/actions_executor.py index bd39f7086..c017a2877 100644 --- a/packages/flow/octobot_flow/logic/actions/actions_executor.py +++ b/packages/flow/octobot_flow/logic/actions/actions_executor.py @@ -1,8 +1,8 @@ import typing -import time import octobot_commons.logging import octobot_commons.dsl_interpreter +import octobot_commons.profiles import octobot_trading.exchanges import octobot.community @@ -22,6 +22,7 @@ def __init__( self, maybe_community_repository: typing.Optional[octobot_flow.repositories.community.CommunityRepository], exchange_manager: typing.Optional[octobot_trading.exchanges.ExchangeManager], + profile_data: octobot_commons.profiles.ProfileData, automation: octobot_flow.entities.AutomationDetails, actions: list[octobot_flow.entities.AbstractActionDetails], as_reference_account: bool, @@ -33,13 +34,14 @@ def __init__( octobot_flow.repositories.community.CommunityRepository ] = maybe_community_repository self._exchange_manager: typing.Optional[octobot_trading.exchanges.ExchangeManager] = exchange_manager + self._profile_data: octobot_commons.profiles.ProfileData = profile_data self._automation: octobot_flow.entities.AutomationDetails = automation self._actions: list[octobot_flow.entities.AbstractActionDetails] = actions self._as_reference_account: bool = as_reference_account async def execute(self): dsl_executor = octobot_flow.logic.dsl.DSLExecutor( - self._exchange_manager, None + self._profile_data, self._exchange_manager, None ) if self._exchange_manager: await octobot_trading.exchanges.create_exchange_channels(self._exchange_manager) @@ -146,31 +148,6 @@ async def _insert_execution_bot_logs(self, log_data: list[octobot.community.BotL "No available community repository: bot logs upload is skipped" ) - # def _get_or_compute_actions_next_execution_scheduled_to( - # self - # ) -> float: #todo - # for action in self._actions: - # if action.next_schedule: - # next_schedule_details = octobot_flow.entities.NextScheduleParams.from_dict(action.next_schedule) - # return next_schedule_details.get_next_schedule_time() - # return self._compute_next_execution_scheduled_to( - # octobot_flow.constants.DEFAULT_EXTERNAL_TRIGGER_ONLY_NO_ORDER_TIMEFRAME - # ) - - # def _compute_next_execution_scheduled_to( - # self, - # time_frame: octobot_commons.enums.TimeFrames - # ) -> float: - # # if this was scheduled, use it as a basis to always start at the same time, - # # otherwise use triggered at - # current_schedule_time = ( - # self._automation.execution.current_execution.scheduled_to - # or self._automation.execution.current_execution.triggered_at - # ) - # return current_schedule_time + ( - # octobot_commons.enums.TimeFramesMinutes[time_frame] * octobot_commons.constants.MINUTE_TO_SECONDS - # ) - def _sync_after_execution(self): if exchange_account_elements := self._automation.get_exchange_account_elements( self._as_reference_account diff --git a/packages/flow/octobot_flow/logic/configuration/profile_data_factory.py b/packages/flow/octobot_flow/logic/configuration/profile_data_factory.py index 0785771e1..735873c30 100644 --- a/packages/flow/octobot_flow/logic/configuration/profile_data_factory.py +++ b/packages/flow/octobot_flow/logic/configuration/profile_data_factory.py @@ -5,7 +5,6 @@ import octobot_trading.enums as trading_enums import octobot_flow.entities -import octobot_flow.logic.dsl import tentacles.Meta.Keywords.scripting_library as scripting_library @@ -42,6 +41,9 @@ def _infer_reference_market( if crypto_currencies: return octobot_commons.symbols.parse_symbol(crypto_currencies[0].trading_pairs[0]).quote # type: ignore elif exchange_account_details: + if exchange_account_details.portfolio.unit: + # portfolio unit can be used to define the reference market + return exchange_account_details.portfolio.unit return scripting_library.get_default_exchange_reference_market(exchange_account_details.exchange_details.internal_name) return octobot_commons.constants.DEFAULT_REFERENCE_MARKET diff --git a/packages/flow/octobot_flow/logic/dsl/dsl_dependencies.py b/packages/flow/octobot_flow/logic/dsl/dsl_dependencies.py index 12ac53a5b..cb8466a09 100644 --- a/packages/flow/octobot_flow/logic/dsl/dsl_dependencies.py +++ b/packages/flow/octobot_flow/logic/dsl/dsl_dependencies.py @@ -2,13 +2,15 @@ import octobot_commons.enums import octobot_flow.entities import octobot_flow.logic.dsl.dsl_executor as dsl_executor +import octobot_commons.profiles.profile_data as profile_data_import def get_actions_symbol_dependencies( - actions: list[octobot_flow.entities.AbstractActionDetails] + actions: list[octobot_flow.entities.AbstractActionDetails], + minimal_profile_data: profile_data_import.ProfileData ) -> list[str]: all_symbol_dependencies = [ - _get_symbol_dependencies(action.get_resolved_dsl_script()) + _get_symbol_dependencies(minimal_profile_data, action.get_resolved_dsl_script()) for action in actions if isinstance(action, octobot_flow.entities.DSLScriptActionDetails) ] @@ -20,10 +22,11 @@ def get_actions_symbol_dependencies( def get_actions_time_frames_dependencies( - actions: list[octobot_flow.entities.AbstractActionDetails] + actions: list[octobot_flow.entities.AbstractActionDetails], + minimal_profile_data: profile_data_import.ProfileData ) -> list[octobot_commons.enums.TimeFrames]: all_symbol_dependencies = [ - _get_symbol_dependencies(action.get_resolved_dsl_script()) + _get_symbol_dependencies(minimal_profile_data, action.get_resolved_dsl_script()) for action in actions if isinstance(action, octobot_flow.entities.DSLScriptActionDetails) ] @@ -35,8 +38,11 @@ def get_actions_time_frames_dependencies( )) -def _get_symbol_dependencies(dsl_script: str) -> list[octobot_trading.dsl.SymbolDependency]: - dependencies_only_executor = dsl_executor.DSLExecutor(None, dsl_script) +def _get_symbol_dependencies( + minimal_profile_data: profile_data_import.ProfileData, + dsl_script: str +) -> list[octobot_trading.dsl.SymbolDependency]: + dependencies_only_executor = dsl_executor.DSLExecutor(minimal_profile_data, None, dsl_script) return [ symbol_dependency for symbol_dependency in dependencies_only_executor.get_dependencies() diff --git a/packages/flow/octobot_flow/logic/dsl/dsl_executor.py b/packages/flow/octobot_flow/logic/dsl/dsl_executor.py index ab30173ae..cdde6e910 100644 --- a/packages/flow/octobot_flow/logic/dsl/dsl_executor.py +++ b/packages/flow/octobot_flow/logic/dsl/dsl_executor.py @@ -4,8 +4,10 @@ import octobot_commons.dsl_interpreter import octobot_commons.signals import octobot_commons.errors +import octobot_commons.profiles import octobot_trading.exchanges import octobot_trading.dsl +import octobot_trading.modes as trading_modes import tentacles.Meta.DSL_operators as dsl_operators @@ -21,14 +23,15 @@ class DSLExecutor(AbstractActionExecutor): def __init__( self, + profile_data: octobot_commons.profiles.ProfileData, exchange_manager: typing.Optional[octobot_trading.exchanges.ExchangeManager], dsl_script: typing.Optional[str], dependencies: typing.Optional[octobot_commons.signals.SignalDependencies] = None, ): super().__init__() - self._exchange_manager = exchange_manager self._dependencies = dependencies + self._dependencies_config: dict = profile_data.to_profile("").config self._interpreter: octobot_commons.dsl_interpreter.Interpreter = self._create_interpreter(None) if dsl_script: self._interpreter.prepare(dsl_script) @@ -47,6 +50,9 @@ def _create_interpreter( self._exchange_manager, trading_mode=None, dependencies=self._dependencies ) + dsl_operators.create_blockchain_wallet_operators(self._exchange_manager) + + trading_modes.create_all_trading_mode_operators( + self._exchange_manager, self._dependencies_config + ) ) def get_dependencies(self) -> list[ diff --git a/packages/flow/octobot_flow/repositories/exchange/exchange_context_mixin.py b/packages/flow/octobot_flow/repositories/exchange/exchange_context_mixin.py index 5858b4045..6c25e11ea 100644 --- a/packages/flow/octobot_flow/repositories/exchange/exchange_context_mixin.py +++ b/packages/flow/octobot_flow/repositories/exchange/exchange_context_mixin.py @@ -109,7 +109,12 @@ async def exchange_manager_context( asset: portfolio_element[common_constants.PORTFOLIO_TOTAL] for asset, portfolio_element in exchange_data.portfolio_details.content.items() } - exchange_manager.exchange_personal_data.portfolio_manager.apply_forced_portfolio(portfolio_config) + portfolio_manager = exchange_manager.exchange_personal_data.portfolio_manager + portfolio_manager.apply_forced_portfolio( + portfolio_config, + # lock open orders funds in portfolio for simulated trading + update_available_funds_from_open_orders=profile_data.trader_simulator.enabled, + ) self._exchange_manager = exchange_manager if self.WILL_EXECUTE_STRATEGY: with self._predictive_order_sync_context(exchange_manager, profile_data): diff --git a/packages/flow/tests/functionnal_tests/trading_modes_actions/simulator/test_grid_trading_mode_action.py b/packages/flow/tests/functionnal_tests/trading_modes_actions/simulator/test_grid_trading_mode_action.py new file mode 100644 index 000000000..38c6b1479 --- /dev/null +++ b/packages/flow/tests/functionnal_tests/trading_modes_actions/simulator/test_grid_trading_mode_action.py @@ -0,0 +1,188 @@ +import pytest + +import octobot_commons.enums as common_enums +import octobot_commons.constants as common_constants +import octobot_commons.dsl_interpreter as dsl_interpreter +import octobot_trading.constants as trading_constants +import octobot_trading.enums as trading_enums +import octobot_flow +import octobot_flow.entities +import octobot_flow.enums + +import tests.functionnal_tests as functionnal_tests +from tests.functionnal_tests import current_time, resolved_actions, automation_state_dict + +import tentacles.Trading.Mode.grid_trading_mode.grid_trading as grid_trading + +increment = 200 +spread = 600 +grid_pair_settings = [ + grid_trading.GridTradingMode.get_default_pair_config( + "BTC/USDC", + spread, + increment, + 2, + 2, + False, + False, + False, + ) +] + + +def grid_trading_mode_action(dependency_action: dict): + return { + "id": "action_1", + "dsl_script": ( + f"grid_trading_mode(pair_settings={dsl_interpreter.format_parameter_value(grid_pair_settings)})" + ), + "dependencies": [{"action_id": dependency_action["id"]}], + } + + +@pytest.fixture +def init_action(): + return { + "id": "action_init", + "action": octobot_flow.enums.ActionType.APPLY_CONFIGURATION.value, + "config": { + "automation": { + "metadata": { + "automation_id": "automation_1", + }, + "client_exchange_account_elements": { + "portfolio": { + "content": { + "USDC": { + "available": 1000.0, + "total": 1000.0, + } + }, + }, + }, + }, + "exchange_account_details": { + "exchange_details": { + "internal_name": functionnal_tests.EXCHANGE_INTERNAL_NAME, + }, + "auth_details": {}, + "portfolio": { + "unit": "USDC", + }, + }, + }, + } + + +@pytest.mark.asyncio +async def test_simulator_grid_init_from_empty_state(init_action: dict): + all_actions = [init_action, grid_trading_mode_action(init_action)] + automation_state = automation_state_dict(resolved_actions(all_actions)) + + # 1. run init action + async with octobot_flow.AutomationJob(automation_state, [], {}) as automation_job: + await automation_job.run() + after_init_execution_dump = automation_job.dump() + + # check bot actions execution + assert len(automation_job.automation_state.automation.actions_dag.actions) == len(all_actions) + for index, action in enumerate(automation_job.automation_state.automation.actions_dag.actions): + assert isinstance(action, octobot_flow.entities.AbstractActionDetails) + assert action.error_status == octobot_flow.enums.ActionErrorStatus.NO_ERROR.value + assert action.result is None + if index == 0: + assert action.executed_at and action.executed_at >= current_time + assert action.previous_execution_result is None + else: + assert action.executed_at is None + assert action.previous_execution_result is None + + # 2. run grid trading mode action + async with octobot_flow.AutomationJob(after_init_execution_dump, [], {}) as automation_job: + await automation_job.run() + after_grid_execution_dump = automation_job.dump() + assert len(automation_job.automation_state.automation.actions_dag.actions) == len(all_actions) + for index, action in enumerate(automation_job.automation_state.automation.actions_dag.actions): + assert isinstance(action, octobot_flow.entities.AbstractActionDetails) + assert action.error_status == octobot_flow.enums.ActionErrorStatus.NO_ERROR.value + assert action.result is None + if index == 0: + assert action.executed_at is not None + assert action.previous_execution_result is None + else: + # action is reset: this is a trading mode action: it will be executed again at the next execution + assert action.executed_at is None + assert isinstance(action.previous_execution_result, dict) + + # scheduled next execution time at 1h after the current execution (1h is the default time when unspecified) + assert after_grid_execution_dump["automation"]["execution"]["previous_execution"][ + "triggered_at" + ] >= current_time + one_hour = ( + common_enums.TimeFramesMinutes[common_enums.TimeFrames.ONE_HOUR] + * common_constants.MINUTE_TO_SECONDS + ) + allowed_execution_time = 20 + schedule_delay = ( + after_grid_execution_dump["automation"]["execution"]["current_execution"]["scheduled_to"] + - after_grid_execution_dump["automation"]["execution"]["previous_execution"]["triggered_at"] + ) + assert one_hour - allowed_execution_time < schedule_delay < one_hour + allowed_execution_time + + # check portfolio and open grid orders + after_grid_portfolio_content = after_grid_execution_dump["automation"][ + "client_exchange_account_elements" + ]["portfolio"]["content"] + assert isinstance(after_grid_execution_dump, dict) + assert list(sorted(after_grid_portfolio_content.keys())) == ["BTC", "USDC"] + # applied portfolio optimizations and created grid open orders + assert 450 < after_grid_portfolio_content["USDC"]["total"] < 550 # USDC holding split in half + assert after_grid_portfolio_content["USDC"]["available"] < 100 + assert 0.001 < after_grid_portfolio_content["BTC"]["total"] < 0.02 + assert after_grid_portfolio_content["BTC"]["available"] < 0.001 + + open_orders_origin_values = [ + order[trading_constants.STORAGE_ORIGIN_VALUE] + for order in after_grid_execution_dump["automation"]["client_exchange_account_elements"]["orders"][ + "open_orders" + ] + ] + buy_orders = sorted([ + o for o in open_orders_origin_values if o[trading_enums.ExchangeConstantsOrderColumns.SIDE.value] == trading_enums.TradeOrderSide.BUY.value + ], key=lambda o: o[trading_enums.ExchangeConstantsOrderColumns.PRICE.value]) + sell_orders = sorted([ + o for o in open_orders_origin_values if o[trading_enums.ExchangeConstantsOrderColumns.SIDE.value] == trading_enums.TradeOrderSide.SELL.value + ], key=lambda o: o[trading_enums.ExchangeConstantsOrderColumns.PRICE.value]) + assert len(buy_orders) == len(sell_orders) == 2 + # check order prices are according to the grid settings + lowest_buy_price = buy_orders[0][trading_enums.ExchangeConstantsOrderColumns.PRICE.value] + assert buy_orders[1][trading_enums.ExchangeConstantsOrderColumns.PRICE.value] == lowest_buy_price + increment + assert sell_orders[0][trading_enums.ExchangeConstantsOrderColumns.PRICE.value] == lowest_buy_price + increment + spread + assert sell_orders[1][trading_enums.ExchangeConstantsOrderColumns.PRICE.value] == lowest_buy_price + increment + spread + increment + + # 3. trigger again: nothing to do + async with octobot_flow.AutomationJob(after_grid_execution_dump, [], {}) as automation_job: + await automation_job.run() + after_second_call_execution_dump = automation_job.dump() + assert len(automation_job.automation_state.automation.actions_dag.actions) == len(all_actions) + for index, action in enumerate(automation_job.automation_state.automation.actions_dag.actions): + assert isinstance(action, octobot_flow.entities.AbstractActionDetails) + assert action.error_status == octobot_flow.enums.ActionErrorStatus.NO_ERROR.value + assert action.result is None + if index == 0: + assert action.executed_at is not None + assert action.previous_execution_result is None + else: + assert action.executed_at is None + assert isinstance(action.previous_execution_result, dict) + + schedule_delay = ( + after_second_call_execution_dump["automation"]["execution"]["current_execution"]["scheduled_to"] + - after_second_call_execution_dump["automation"]["execution"]["previous_execution"]["triggered_at"] + ) + assert one_hour - allowed_execution_time < schedule_delay < one_hour + allowed_execution_time + + after_second_call_portfolio_content = after_second_call_execution_dump["automation"][ + "client_exchange_account_elements" + ]["portfolio"]["content"] + assert after_second_call_portfolio_content == after_grid_portfolio_content diff --git a/packages/flow/tests/functionnal_tests/trading_modes_actions/simulator/test_index_trading_mode_action.py b/packages/flow/tests/functionnal_tests/trading_modes_actions/simulator/test_index_trading_mode_action.py new file mode 100644 index 000000000..06ee0e282 --- /dev/null +++ b/packages/flow/tests/functionnal_tests/trading_modes_actions/simulator/test_index_trading_mode_action.py @@ -0,0 +1,219 @@ +import pytest +import logging +import json +import mock + +import octobot_commons.enums as common_enums +import octobot_commons.constants as common_constants +import octobot_trading.dsl as trading_dsl +import octobot_copy.rebalancing as rebalancing +import octobot_flow +import octobot_flow.entities +import octobot_flow.enums + +import tentacles.Trading.Mode.index_trading_mode as index_trading_mode + +import tests.functionnal_tests as functionnal_tests +from tests.functionnal_tests import current_time, resolved_actions, automation_state_dict + +import octobot_copy.enums as rebalancer_enums + +index_content = [ + { + rebalancer_enums.DistributionKeys.NAME: "BTC", + rebalancer_enums.DistributionKeys.VALUE: 1, + }, + { + rebalancer_enums.DistributionKeys.NAME: "ETH", + rebalancer_enums.DistributionKeys.VALUE: 1, + }, +] + + +def index_trading_mode_action(dependency_action: dict): + return { + "id": "action_1", + "dsl_script": f"index_trading_mode(index_content={json.dumps(index_content)}, rebalance_trigger_min_percent=5)", + "dependencies": [{"action_id": dependency_action["id"]}], + } + + +@pytest.fixture +def init_action(): + return { + "id": "action_init", + "action": octobot_flow.enums.ActionType.APPLY_CONFIGURATION.value, + "config": { + "automation": { + "metadata": { + "automation_id": "automation_1", + }, + "client_exchange_account_elements": { + "portfolio": { + "content": { + "USDT": { + "available": 1000.0, + "total": 1000.0, + }, + }, + }, + }, + }, + "exchange_account_details": { + "exchange_details": { + "internal_name": functionnal_tests.EXCHANGE_INTERNAL_NAME, + }, + "auth_details": {}, + "portfolio": { + "unit": "USDT", + }, + }, + }, + } + + +@pytest.mark.asyncio +async def test_simulator_index_init_from_empty_state(init_action: dict): + all_actions = [init_action, index_trading_mode_action(init_action)] + automation_state = automation_state_dict(resolved_actions(all_actions)) + + # 1. run init action + async with octobot_flow.AutomationJob(automation_state, [], {}) as automation_job: + await automation_job.run() + after_init_execution_dump = automation_job.dump() + + # check bot actions execution + assert len(automation_job.automation_state.automation.actions_dag.actions) == len(all_actions) + for index, action in enumerate(automation_job.automation_state.automation.actions_dag.actions): + assert isinstance(action, octobot_flow.entities.AbstractActionDetails) + assert action.error_status == octobot_flow.enums.ActionErrorStatus.NO_ERROR.value + assert action.result is None + if index == 0: + assert action.executed_at and action.executed_at >= current_time + assert action.previous_execution_result is None + else: + assert action.executed_at is None + assert action.previous_execution_result is None + + # 2. run index trading mode action + async with octobot_flow.AutomationJob(after_init_execution_dump, [], {}) as automation_job: + await automation_job.run() + after_initial_rebalance_execution_dump = automation_job.dump() + assert len(automation_job.automation_state.automation.actions_dag.actions) == len(all_actions) + for index, action in enumerate(automation_job.automation_state.automation.actions_dag.actions): + assert isinstance(action, octobot_flow.entities.AbstractActionDetails) + assert action.error_status == octobot_flow.enums.ActionErrorStatus.NO_ERROR.value + assert action.result is None + if index == 0: + assert action.executed_at is not None + assert action.previous_execution_result is None + else: + # action is reset: this is a trading mode action: it will be executed again at the next execution + assert action.executed_at is None + assert isinstance(action.previous_execution_result, dict) + + # scheduled next execution time at 1h after the current execution (1h is the default time when unspecified) + assert after_initial_rebalance_execution_dump["automation"]["execution"]["previous_execution"]["triggered_at"] >= current_time + one_hour = common_enums.TimeFramesMinutes[common_enums.TimeFrames.ONE_HOUR] * common_constants.MINUTE_TO_SECONDS + allowed_execution_time = 20 + schedule_delay = ( + after_initial_rebalance_execution_dump["automation"]["execution"]["current_execution"]["scheduled_to"] + - after_initial_rebalance_execution_dump["automation"]["execution"]["previous_execution"]["triggered_at"] + ) + assert one_hour - allowed_execution_time < schedule_delay < one_hour + allowed_execution_time + # check portfolio content + after_initial_rebalance_portfolio_content = after_initial_rebalance_execution_dump["automation"]["client_exchange_account_elements"]["portfolio"]["content"] + assert isinstance(after_initial_rebalance_execution_dump, dict) + assert list(sorted(after_initial_rebalance_portfolio_content.keys())) == ["BTC", "ETH", "USDT"] + assert 0 < after_initial_rebalance_portfolio_content["USDT"]["available"] < 5 + assert 0.1 < after_initial_rebalance_portfolio_content["ETH"]["available"] < 0.4 + assert 0.001 < after_initial_rebalance_portfolio_content["BTC"]["available"] < 0.01 + logging.getLogger("test_update_simulated_basket_bot").info(f"after_execution_portfolio_content: {after_initial_rebalance_portfolio_content}") + + + # 3. trigger again: nothing to do + async with octobot_flow.AutomationJob(after_initial_rebalance_execution_dump, [], {}) as automation_job: + await automation_job.run() + after_second_call_execution_dump = automation_job.dump() + assert len(automation_job.automation_state.automation.actions_dag.actions) == len(all_actions) + for index, action in enumerate(automation_job.automation_state.automation.actions_dag.actions): + assert isinstance(action, octobot_flow.entities.AbstractActionDetails) + assert action.error_status == octobot_flow.enums.ActionErrorStatus.NO_ERROR.value + assert action.result is None + if index == 0: + assert action.executed_at is not None + assert action.previous_execution_result is None + else: + # action is reset: this is a trading mode action: it will be executed again at the next execution + assert action.executed_at is None + assert isinstance(action.previous_execution_result, dict) + + # ensure schedule delay is the same as the first call + schedule_delay = ( + after_second_call_execution_dump["automation"]["execution"]["current_execution"]["scheduled_to"] + - after_second_call_execution_dump["automation"]["execution"]["previous_execution"]["triggered_at"] + ) + assert one_hour - allowed_execution_time < schedule_delay < one_hour + allowed_execution_time + + # portfolio already follows the index content: ensure portfolio content is the same as the first call + after_second_call_portfolio_content = after_second_call_execution_dump["automation"]["client_exchange_account_elements"]["portfolio"]["content"] + assert after_second_call_portfolio_content == after_initial_rebalance_portfolio_content + + +@pytest.mark.asyncio +async def test_simulator_index_with_added_traded_pairs(init_action: dict): + all_actions = [init_action, index_trading_mode_action(init_action)] + automation_state = automation_state_dict(resolved_actions(all_actions)) + + # 1. run init action + async with octobot_flow.AutomationJob(automation_state, [], {}) as automation_job: + await automation_job.run() + after_init_execution_dump = automation_job.dump() + + # check bot actions execution + assert len(automation_job.automation_state.automation.actions_dag.actions) == len(all_actions) + for index, action in enumerate(automation_job.automation_state.automation.actions_dag.actions): + assert isinstance(action, octobot_flow.entities.AbstractActionDetails) + assert action.error_status == octobot_flow.enums.ActionErrorStatus.NO_ERROR.value + assert action.result is None + if index == 0: + assert action.executed_at and action.executed_at >= current_time + assert action.previous_execution_result is None + else: + assert action.executed_at is None + assert action.previous_execution_result is None + + # 2. run index trading mode action + with ( + mock.patch.object( + index_trading_mode.IndexTradingMode, "get_dsl_dependencies", + # ETH/USDT won't be identified as dependency but is in index config: it will be added dynamically + return_value=[trading_dsl.SymbolDependency(symbol="BTC/USDT")] + ) as mock_get_dsl_dependencies, + mock.patch.object( + rebalancing.RebalanceActionsPlanner, "_get_supported_distribution", + return_value=rebalancing.get_uniform_distribution(["BTC", "ETH"]) + ) as mock_get_supported_distribution, + mock.patch.object( + rebalancing.RebalanceActionsPlanner, "_get_filtered_traded_coins", + return_value=["BTC", "ETH"] + ) as mock_get_filtered_traded_coins, + ): + async with octobot_flow.AutomationJob(after_init_execution_dump, [], {}) as automation_job: + await automation_job.run() + mock_get_dsl_dependencies.assert_called_once() + mock_get_supported_distribution.assert_called_once() + mock_get_filtered_traded_coins.assert_called_once() + after_initial_rebalance_execution_dump = automation_job.dump() + assert len(automation_job.automation_state.automation.actions_dag.actions) == len(all_actions) + for index, action in enumerate(automation_job.automation_state.automation.actions_dag.actions): + assert isinstance(action, octobot_flow.entities.AbstractActionDetails) + assert action.error_status == octobot_flow.enums.ActionErrorStatus.NO_ERROR.value + assert action.result is None + if index == 0: + assert action.executed_at is not None + assert action.previous_execution_result is None + else: + # action is reset: this is a trading mode action: it will be executed again at the next execution + assert action.executed_at is None + assert isinstance(action.previous_execution_result, dict) diff --git a/packages/flow/tests/functionnal_tests/trading_modes_actions/test_simulator_index_trading_mode_action.py b/packages/flow/tests/functionnal_tests/trading_modes_actions/test_simulator_index_trading_mode_action.py deleted file mode 100644 index bc29a9bf3..000000000 --- a/packages/flow/tests/functionnal_tests/trading_modes_actions/test_simulator_index_trading_mode_action.py +++ /dev/null @@ -1,55 +0,0 @@ -import pytest -import logging -import re - -import octobot_commons.enums as common_enums -import octobot_commons.constants as common_constants -import octobot_commons.errors -import octobot_flow -import octobot_flow.entities - -import tests.functionnal_tests as functionnal_tests -from tests.functionnal_tests import current_time, global_state, auth_details, isolated_exchange_cache, resolved_actions - - -def index_actions(): - return [ - { - "id": "action_1", - "dsl_script": "IndexTradingMode('BTC/USDT')", - } - ] - - -@pytest.mark.asyncio -async def test_index_update( - global_state: dict, auth_details: octobot_flow.entities.UserAuthentication, - isolated_exchange_cache, # use isolated exchange cache to avoid side effects on other tests (uses different markets) -): - with ( - functionnal_tests.mocked_community_authentication() as login_mock, - functionnal_tests.mocked_community_repository() as insert_bot_logs_mock, - ): - async with octobot_flow.AutomationJob(global_state, [], auth_details) as automations_job: - automations_job.automation_state.update_automation_actions(resolved_actions(index_actions())) - with pytest.raises(octobot_commons.errors.UnsupportedOperatorError, match=re.escape("Unknown operator: IndexTradingMode")): - await automations_job.run() - return # TODO: remove this once the index update is implemented - after_execution_dump = automations_job.dump() - # scheduled next execution time at 1h after the current execution (1h is the default time when unspecified) - assert after_execution_dump["automation"]["execution"]["previous_execution"]["triggered_at"] >= current_time - assert after_execution_dump["automation"]["execution"]["current_execution"]["scheduled_to"] == ( - after_execution_dump["automation"]["execution"]["previous_execution"]["triggered_at"] - + common_enums.TimeFramesMinutes[common_enums.TimeFrames.ONE_HOUR] * common_constants.MINUTE_TO_SECONDS - ) - # check portfolio content - after_execution_portfolio_content = after_execution_dump["automation"]["client_exchange_account_elements"]["portfolio"]["content"] - assert isinstance(after_execution_dump, dict) - assert list(sorted(after_execution_portfolio_content.keys())) == ["BTC", "ETH", "USDT"] - assert 0 < after_execution_portfolio_content["USDT"]["available"] < 5 - assert 0.1 < after_execution_portfolio_content["ETH"]["available"] < 0.4 - assert 0.001 < after_execution_portfolio_content["BTC"]["available"] < 0.01 - logging.getLogger("test_update_simulated_basket_bot").info(f"after_execution_portfolio_content: {after_execution_portfolio_content}") - # check bot logs - login_mock.assert_called_once() - insert_bot_logs_mock.assert_called_once() diff --git a/packages/tentacles/Meta/DSL_operators/python_std_operators/base_resetting_operators.py b/packages/tentacles/Meta/DSL_operators/python_std_operators/base_resetting_operators.py index 5d597086d..640a6a25a 100644 --- a/packages/tentacles/Meta/DSL_operators/python_std_operators/base_resetting_operators.py +++ b/packages/tentacles/Meta/DSL_operators/python_std_operators/base_resetting_operators.py @@ -72,7 +72,7 @@ def _compute_remaining_time( if waiting_time <= 0: # done waiting return None - return self.build_re_callable_result( + return self.create_re_callable_result_dict( last_execution_time=current_time, waiting_time=waiting_time, ) diff --git a/packages/tentacles/Meta/Keywords/scripting_library/configuration/indexes_configuration.py b/packages/tentacles/Meta/Keywords/scripting_library/configuration/indexes_configuration.py index d5423a550..c63b6507b 100644 --- a/packages/tentacles/Meta/Keywords/scripting_library/configuration/indexes_configuration.py +++ b/packages/tentacles/Meta/Keywords/scripting_library/configuration/indexes_configuration.py @@ -26,7 +26,7 @@ import octobot_trading.constants as trading_constants import tentacles.Trading.Mode.index_trading_mode.index_trading as index_trading -import tentacles.Trading.Mode.index_trading_mode.index_distribution as index_distribution +import octobot_copy.enums as rebalancer_enums import tentacles.Meta.Keywords.scripting_library.configuration.exchanges_configuration as exchanges_configuration @@ -39,10 +39,10 @@ def create_index_config_from_tentacles_config( reference_market = exchanges_configuration.get_default_exchange_reference_market(exchange) # replace USD by reference market for element in distribution: - if element[index_distribution.DISTRIBUTION_NAME] == "USD": - element[index_distribution.DISTRIBUTION_NAME] = reference_market + if element[rebalancer_enums.DistributionKeys.NAME] == "USD": + element[rebalancer_enums.DistributionKeys.NAME] = reference_market coins_by_symbol = { - element[index_distribution.DISTRIBUTION_NAME]: element[index_distribution.DISTRIBUTION_NAME] + element[rebalancer_enums.DistributionKeys.NAME]: element[rebalancer_enums.DistributionKeys.NAME] for element in distribution } rebalance_cap = trading_mode_config[index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_MIN_PERCENT] @@ -72,14 +72,14 @@ def generate_index_config( )] currencies = [ commons_profile_data.CryptoCurrencyData( - [octobot_commons.symbols.merge_currencies(element[index_distribution.DISTRIBUTION_NAME], reference_market)], + [octobot_commons.symbols.merge_currencies(element[rebalancer_enums.DistributionKeys.NAME], reference_market)], coins_by_symbol.get( - element[index_distribution.DISTRIBUTION_NAME], - element[index_distribution.DISTRIBUTION_NAME] + element[rebalancer_enums.DistributionKeys.NAME], + element[rebalancer_enums.DistributionKeys.NAME] ) ) for element in distribution - if element[index_distribution.DISTRIBUTION_NAME] != reference_market + if element[rebalancer_enums.DistributionKeys.NAME] != reference_market ] trader = commons_profile_data.TraderData(enabled=True) trader_simulator = commons_profile_data.TraderSimulatorData() @@ -124,7 +124,7 @@ def _get_index_trading_config( index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_MIN_PERCENT: rebalance_cap, index_trading.IndexTradingModeProducer.SELECTED_REBALANCE_TRIGGER_PROFILE: selected_rebalance_trigger_profile, index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILES: rebalance_trigger_profiles, - index_trading.IndexTradingModeProducer.SYNCHRONIZATION_POLICY: index_trading.SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_ON_RATIO_REBALANCE.value, + index_trading.IndexTradingModeProducer.SYNCHRONIZATION_POLICY: rebalancer_enums.SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_ON_RATIO_REBALANCE.value, index_trading.IndexTradingModeProducer.SELL_UNINDEXED_TRADED_COINS: True, index_trading.IndexTradingModeProducer.INDEX_CONTENT: distribution, evaluators_constants.STRATEGIES_REQUIRED_TIME_FRAME: [common_enums.TimeFrames.ONE_DAY.value], diff --git a/packages/tentacles/Meta/Keywords/scripting_library/configuration/profile_data_configuration.py b/packages/tentacles/Meta/Keywords/scripting_library/configuration/profile_data_configuration.py index 80c36b8f5..c062f0987 100644 --- a/packages/tentacles/Meta/Keywords/scripting_library/configuration/profile_data_configuration.py +++ b/packages/tentacles/Meta/Keywords/scripting_library/configuration/profile_data_configuration.py @@ -43,6 +43,7 @@ import tentacles.Trading.Mode.index_trading_mode.index_trading as index_trading import tentacles.Trading.Mode.index_trading_mode.index_distribution as index_distribution +import octobot_copy.enums as copy_enums import tentacles.Meta.Keywords.scripting_library.errors as scr_errors import tentacles.Meta.Keywords.scripting_library.constants as scr_constants import tentacles.Meta.Keywords.scripting_library.configuration.tentacles_configuration as tentacles_configuration @@ -248,14 +249,14 @@ def _get_historical_index_trading_pairs( ) -> typing.Iterable[str]: historical_assets = [] latest_config_assets = set( - asset[index_distribution.DISTRIBUTION_NAME] + asset[copy_enums.DistributionKeys.NAME] for asset in _get_trading_mode_config(profile_data)[ index_trading.IndexTradingModeProducer.INDEX_CONTENT ] ) for historical_trading_mode_config in historical_trading_mode_configs: for asset in historical_trading_mode_config[index_trading.IndexTradingModeProducer.INDEX_CONTENT]: - historical_asset = asset[index_distribution.DISTRIBUTION_NAME] + historical_asset = asset[copy_enums.DistributionKeys.NAME] if historical_asset not in historical_assets and historical_asset not in latest_config_assets: historical_assets.append(historical_asset) return [ diff --git a/packages/tentacles/Meta/Keywords/scripting_library/tests/backtesting/test_collect_data_and_run_backtesting.py b/packages/tentacles/Meta/Keywords/scripting_library/tests/backtesting/test_collect_data_and_run_backtesting.py index cb6f26266..7b5136b5c 100644 --- a/packages/tentacles/Meta/Keywords/scripting_library/tests/backtesting/test_collect_data_and_run_backtesting.py +++ b/packages/tentacles/Meta/Keywords/scripting_library/tests/backtesting/test_collect_data_and_run_backtesting.py @@ -26,22 +26,23 @@ import tentacles.Meta.Keywords.scripting_library as scripting_library import tentacles.Trading.Mode.index_trading_mode.index_distribution as index_distribution import tentacles.Trading.Mode.index_trading_mode.index_trading as index_trading +import octobot_copy.enums as copy_enums @pytest.fixture def trading_mode_tentacles_data() -> commons_profile_data.TentaclesData: distribution = [ { - index_distribution.DISTRIBUTION_NAME: "BTC", - index_distribution.DISTRIBUTION_VALUE: 50.0, + copy_enums.DistributionKeys.NAME: "BTC", + copy_enums.DistributionKeys.VALUE: 50.0, }, { - index_distribution.DISTRIBUTION_NAME: "ETH", - index_distribution.DISTRIBUTION_VALUE: 30.0, + copy_enums.DistributionKeys.NAME: "ETH", + copy_enums.DistributionKeys.VALUE: 30.0, }, { - index_distribution.DISTRIBUTION_NAME: "USD", # Will be replaced by reference market - index_distribution.DISTRIBUTION_VALUE: 20.0, + copy_enums.DistributionKeys.NAME: "USD", # Will be replaced by reference market + copy_enums.DistributionKeys.VALUE: 20.0, }, ] diff --git a/packages/tentacles/Meta/Keywords/scripting_library/tests/configuration/test_indexes_configuration.py b/packages/tentacles/Meta/Keywords/scripting_library/tests/configuration/test_indexes_configuration.py index 0e16bf971..a876ede83 100644 --- a/packages/tentacles/Meta/Keywords/scripting_library/tests/configuration/test_indexes_configuration.py +++ b/packages/tentacles/Meta/Keywords/scripting_library/tests/configuration/test_indexes_configuration.py @@ -17,7 +17,7 @@ import octobot_commons.constants as commons_constants import tentacles.Trading.Mode.index_trading_mode.index_trading as index_trading -import tentacles.Trading.Mode.index_trading_mode.index_distribution as index_distribution +import octobot_copy.enums as rebalancer_enums import tentacles.Meta.Keywords.scripting_library.configuration.indexes_configuration as indexes_configuration @@ -25,16 +25,16 @@ def test_create_index_config_from_tentacles_config(): # Create test distribution distribution = [ { - index_distribution.DISTRIBUTION_NAME: "BTC", - index_distribution.DISTRIBUTION_VALUE: 50.0, + rebalancer_enums.DistributionKeys.NAME: "BTC", + rebalancer_enums.DistributionKeys.VALUE: 50.0, }, { - index_distribution.DISTRIBUTION_NAME: "ETH", - index_distribution.DISTRIBUTION_VALUE: 30.0, + rebalancer_enums.DistributionKeys.NAME: "ETH", + rebalancer_enums.DistributionKeys.VALUE: 30.0, }, { - index_distribution.DISTRIBUTION_NAME: "USD", # Should be replaced by reference market - index_distribution.DISTRIBUTION_VALUE: 20.0, + rebalancer_enums.DistributionKeys.NAME: "USD", # Should be replaced by reference market + rebalancer_enums.DistributionKeys.VALUE: 20.0, }, ] @@ -94,7 +94,7 @@ def test_create_index_config_from_tentacles_config(): # Check that USD was replaced by reference market in distribution distribution_names = [ - item[index_distribution.DISTRIBUTION_NAME] + item[rebalancer_enums.DistributionKeys.NAME] for item in result.tentacles[0].config[index_trading.IndexTradingModeProducer.INDEX_CONTENT] ] assert "USD" not in distribution_names @@ -111,16 +111,16 @@ def test_generate_index_config(): # Create test distribution distribution = [ { - index_distribution.DISTRIBUTION_NAME: "BTC", - index_distribution.DISTRIBUTION_VALUE: 50.0, + rebalancer_enums.DistributionKeys.NAME: "BTC", + rebalancer_enums.DistributionKeys.VALUE: 50.0, }, { - index_distribution.DISTRIBUTION_NAME: "ETH", - index_distribution.DISTRIBUTION_VALUE: 30.0, + rebalancer_enums.DistributionKeys.NAME: "ETH", + rebalancer_enums.DistributionKeys.VALUE: 30.0, }, { - index_distribution.DISTRIBUTION_NAME: "USDT", - index_distribution.DISTRIBUTION_VALUE: 20.0, + rebalancer_enums.DistributionKeys.NAME: "USDT", + rebalancer_enums.DistributionKeys.VALUE: 20.0, }, ] @@ -196,7 +196,7 @@ def test_generate_index_config(): assert config[index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_MIN_PERCENT] == rebalance_cap assert config[index_trading.IndexTradingModeProducer.SELECTED_REBALANCE_TRIGGER_PROFILE] == selected_rebalance_trigger_profile assert config[index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILES] == rebalance_trigger_profiles - assert config[index_trading.IndexTradingModeProducer.SYNCHRONIZATION_POLICY] == index_trading.SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_ON_RATIO_REBALANCE.value + assert config[index_trading.IndexTradingModeProducer.SYNCHRONIZATION_POLICY] == rebalancer_enums.SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_ON_RATIO_REBALANCE.value assert config[index_trading.IndexTradingModeProducer.SELL_UNINDEXED_TRADED_COINS] is True assert config[index_trading.IndexTradingModeProducer.REFRESH_INTERVAL] == 1 diff --git a/packages/tentacles/Meta/Keywords/scripting_library/tests/configuration/test_profile_data_configuration.py b/packages/tentacles/Meta/Keywords/scripting_library/tests/configuration/test_profile_data_configuration.py index 88db8e2ac..6c52a69bb 100644 --- a/packages/tentacles/Meta/Keywords/scripting_library/tests/configuration/test_profile_data_configuration.py +++ b/packages/tentacles/Meta/Keywords/scripting_library/tests/configuration/test_profile_data_configuration.py @@ -14,6 +14,7 @@ # You should have received a copy of the GNU Lesser General Public # License along with this library. import tentacles.Trading.Mode.index_trading_mode.index_trading as index_trading +import octobot_copy.enums as rebalancer_enums import octobot_commons.constants as commons_constants import octobot_commons.profiles.profile_data as commons_profile_data @@ -78,7 +79,7 @@ def test_register_historical_configs_applies_master_edits(): index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILE_MIN_PERCENT: 20 } ], - index_trading.IndexTradingModeProducer.SYNCHRONIZATION_POLICY: index_trading.SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_ON_RATIO_REBALANCE.value + index_trading.IndexTradingModeProducer.SYNCHRONIZATION_POLICY: rebalancer_enums.SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_ON_RATIO_REBALANCE.value } master.tentacles = [commons_profile_data.TentaclesData(name=tentacle_name, config=master_config)] historical_1 = scripting_library.minimal_profile_data() @@ -112,7 +113,7 @@ def test_register_historical_configs_applies_master_edits(): index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILE_MIN_PERCENT: 20 } ], - index_trading.IndexTradingModeProducer.SYNCHRONIZATION_POLICY: index_trading.SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_ON_RATIO_REBALANCE.value + index_trading.IndexTradingModeProducer.SYNCHRONIZATION_POLICY: rebalancer_enums.SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_ON_RATIO_REBALANCE.value } assert hist_config_2 == { special_key: 1, @@ -127,5 +128,5 @@ def test_register_historical_configs_applies_master_edits(): index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILE_MIN_PERCENT: 20 } ], - index_trading.IndexTradingModeProducer.SYNCHRONIZATION_POLICY: index_trading.SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_ON_RATIO_REBALANCE.value + index_trading.IndexTradingModeProducer.SYNCHRONIZATION_POLICY: rebalancer_enums.SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_ON_RATIO_REBALANCE.value } diff --git a/packages/tentacles/Trading/Mode/ai_trading_mode/ai_index_distribution.py b/packages/tentacles/Trading/Mode/ai_trading_mode/ai_index_distribution.py index 9d1e8a855..c5e20470e 100644 --- a/packages/tentacles/Trading/Mode/ai_trading_mode/ai_index_distribution.py +++ b/packages/tentacles/Trading/Mode/ai_trading_mode/ai_index_distribution.py @@ -16,7 +16,7 @@ import decimal -import tentacles.Trading.Mode.index_trading_mode.index_distribution as index_distribution +import octobot_copy.enums as copy_enums from tentacles.Agent.sub_agents.distribution_agent.constants import ( INSTRUCTION_ACTION, @@ -45,9 +45,9 @@ def apply_ai_instructions(trading_mode, instructions: list): if trading_mode.ratio_per_asset: for asset, item in trading_mode.ratio_per_asset.items(): current_distribution[asset] = item[ - index_distribution.DISTRIBUTION_VALUE + copy_enums.DistributionKeys.VALUE ] - total_weight += decimal.Decimal(str(item[index_distribution.DISTRIBUTION_VALUE])) + total_weight += decimal.Decimal(str(item[copy_enums.DistributionKeys.VALUE])) # Apply instructions for instruction in instructions: @@ -98,8 +98,8 @@ def apply_ai_instructions(trading_mode, instructions: list): # Update trading_mode.ratio_per_asset trading_mode.ratio_per_asset = { asset: { - index_distribution.DISTRIBUTION_NAME: asset, - index_distribution.DISTRIBUTION_VALUE: weight, + copy_enums.DistributionKeys.NAME: asset, + copy_enums.DistributionKeys.VALUE: weight, } for asset, weight in current_distribution.items() } diff --git a/packages/tentacles/Trading/Mode/ai_trading_mode/ai_index_trading.py b/packages/tentacles/Trading/Mode/ai_trading_mode/ai_index_trading.py index 839712726..6ce35e1b0 100644 --- a/packages/tentacles/Trading/Mode/ai_trading_mode/ai_index_trading.py +++ b/packages/tentacles/Trading/Mode/ai_trading_mode/ai_index_trading.py @@ -29,6 +29,7 @@ import octobot_services.api.services as services_api import octobot_agents.constants as agent_constants import octobot_commons.constants as common_constants +import octobot_copy.enums as copy_enums from tentacles.Trading.Mode.ai_trading_mode import ai_index_distribution from tentacles.Trading.Mode.index_trading_mode import index_trading @@ -531,7 +532,7 @@ def _build_current_distribution(self) -> dict: return {} return { - asset: float(data.get(index_trading.index_distribution.DISTRIBUTION_VALUE, 0)) + asset: float(data.get(copy_enums.DistributionKeys.VALUE, 0)) for asset, data in self.trading_mode.ratio_per_asset.items() } diff --git a/packages/tentacles/Trading/Mode/grid_trading_mode/grid_trading.py b/packages/tentacles/Trading/Mode/grid_trading_mode/grid_trading.py index 3f117ad41..34ae5c685 100644 --- a/packages/tentacles/Trading/Mode/grid_trading_mode/grid_trading.py +++ b/packages/tentacles/Trading/Mode/grid_trading_mode/grid_trading.py @@ -393,9 +393,10 @@ async def _handle_staggered_orders( if grid_orders: self._already_created_init_orders = True - async def trigger_staggered_orders_creation(self): + async def trigger_staggered_orders_creation(self, reload_config: bool = True): # reload configuration - await self.trading_mode.reload_config(self.exchange_manager.bot_id) + if reload_config: + await self.trading_mode.reload_config(self.exchange_manager.bot_id) self._load_symbol_trading_config() self.read_config() if self.symbol_trading_config: diff --git a/packages/tentacles/Trading/Mode/index_trading_mode/index_distribution.py b/packages/tentacles/Trading/Mode/index_trading_mode/index_distribution.py index 31a6b5d99..9e650084b 100644 --- a/packages/tentacles/Trading/Mode/index_trading_mode/index_distribution.py +++ b/packages/tentacles/Trading/Mode/index_trading_mode/index_distribution.py @@ -3,32 +3,12 @@ import numpy import octobot_trading.constants +import octobot_copy.enums as copy_enums +import octobot_copy.rebalancing.planner.distributions as planner_distributions -DISTRIBUTION_NAME = "name" -DISTRIBUTION_VALUE = "value" -DISTRIBUTION_PRICE = "price" MAX_DISTRIBUTION_AFTER_COMMA_DIGITS = 1 - -def get_uniform_distribution(coins, price_by_coin: typing.Optional[dict[str, decimal.Decimal]] = None) -> typing.List: - if not coins: - return [] - ratio = float( - round( - octobot_trading.constants.ONE / decimal.Decimal(str(len(coins))) * octobot_trading.constants.ONE_HUNDRED, - MAX_DISTRIBUTION_AFTER_COMMA_DIGITS - ) - ) - if not ratio: - return [] - return [ - { - DISTRIBUTION_NAME: coin, - DISTRIBUTION_VALUE: ratio, - DISTRIBUTION_PRICE: price_by_coin.get(coin) if price_by_coin else None - } - for coin in coins - ] +get_uniform_distribution = planner_distributions.get_uniform_distribution def get_linear_distribution(weight_by_coin: dict[str, decimal.Decimal], price_by_coin: typing.Optional[dict[str, decimal.Decimal]] = None) -> typing.List: @@ -37,12 +17,12 @@ def get_linear_distribution(weight_by_coin: dict[str, decimal.Decimal], price_by raise ValueError(f"total weight is {total_weight}") return [ { - DISTRIBUTION_NAME: coin, - DISTRIBUTION_VALUE: float(round( + copy_enums.DistributionKeys.NAME.value: coin, + copy_enums.DistributionKeys.VALUE.value: float(round( weight / total_weight * octobot_trading.constants.ONE_HUNDRED, MAX_DISTRIBUTION_AFTER_COMMA_DIGITS )), - DISTRIBUTION_PRICE: price_by_coin.get(coin) if price_by_coin else None + copy_enums.DistributionKeys.PRICE.value: price_by_coin.get(coin) if price_by_coin else None } for coin, weight in weight_by_coin.items() ] diff --git a/packages/tentacles/Trading/Mode/index_trading_mode/index_trading.py b/packages/tentacles/Trading/Mode/index_trading_mode/index_trading.py index 5fe930099..164451f59 100644 --- a/packages/tentacles/Trading/Mode/index_trading_mode/index_trading.py +++ b/packages/tentacles/Trading/Mode/index_trading_mode/index_trading.py @@ -24,6 +24,7 @@ import octobot_commons.authentication as authentication import octobot_commons.signals as commons_signals import octobot_trading.constants as trading_constants +import octobot_trading.dsl as trading_dsl import octobot_trading.enums as trading_enums import octobot_trading.errors as trading_errors import octobot_trading.modes as trading_modes @@ -31,8 +32,12 @@ import octobot_trading.personal_data as trading_personal_data import octobot_trading.signals as signals -import tentacles.Trading.Mode.index_trading_mode.index_distribution as index_distribution -import tentacles.Trading.Mode.index_trading_mode.rebalancer as rebalancer +import octobot_copy.constants as octobot_copy_constants +import octobot_copy.rebalancing as rebalancer +import octobot_copy.enums as rebalancer_enums +import octobot_copy.exchange.exchange_interface as exchange_interface +import octobot_copy.rebalancing.planner.rebalance_actions_planner as rebalance_actions_planner +import octobot_copy.rebalancing.rebalancing_client_interface as rebalancing_client_interface class IndexActivity(enum.Enum): @@ -45,21 +50,6 @@ class RebalanceSkipDetails(enum.Enum): NOT_ENOUGH_AVAILABLE_FOUNDS = "not_enough_available_founds" -class RebalanceDetails(enum.Enum): - SELL_SOME = "SELL_SOME" - BUY_MORE = "BUY_MORE" - REMOVE = "REMOVE" - ADD = "ADD" - SWAP = "SWAP" - FORCED_REBALANCE = "FORCED_REBALANCE" - - -class SynchronizationPolicy(enum.Enum): - SELL_REMOVED_INDEX_COINS_ON_RATIO_REBALANCE = "sell_removed_index_coins_on_ratio_rebalance" - SELL_REMOVED_INDEX_COINS_AS_SOON_AS_POSSIBLE = "sell_removed_index_coins_as_soon_as_possible" - SELL_REMOVED_DYNAMIC_INDEX_COINS_AS_SOON_AS_POSSIBLE = "sell_removed_dynamic_index_coins_as_soon_as_possible" - - DEFAULT_QUOTE_ASSET_REBALANCE_TRIGGER_MIN_RATIO = 0.1 # 10% DEFAULT_REBALANCE_TRIGGER_MIN_RATIO = 0.05 # 5% @@ -72,6 +62,7 @@ class IndexTradingModeConsumer(trading_modes.AbstractTradingModeConsumer): def __init__(self, trading_mode): super().__init__(trading_mode) + self.trading_mode: IndexTradingMode = typing.cast(IndexTradingMode, self.trading_mode) self._already_logged_aborted_rebalance_error = False async def create_new_orders(self, symbol, final_note, state, **kwargs): @@ -155,14 +146,14 @@ def _get_simple_buy_coins(self, details: dict) -> list: # Returns the list of coins to simply buy. # Used to avoid a full rebalance when coins are seen as added to a basket # AND funds are available to buy it AND no asset should be sold - added = details[RebalanceDetails.ADD.value] or details[RebalanceDetails.BUY_MORE.value] + added = details[rebalancer_enums.RebalanceDetails.ADD.value] or details[rebalancer_enums.RebalanceDetails.BUY_MORE.value] if added and not ( - details[RebalanceDetails.SWAP.value] - or details[RebalanceDetails.SELL_SOME.value] - or details[RebalanceDetails.REMOVE.value] - or details[RebalanceDetails.FORCED_REBALANCE.value] + details[rebalancer_enums.RebalanceDetails.SWAP.value] + or details[rebalancer_enums.RebalanceDetails.SELL_SOME.value] + or details[rebalancer_enums.RebalanceDetails.REMOVE.value] + or details[rebalancer_enums.RebalanceDetails.FORCED_REBALANCE.value] ): - added_coins = list(details[RebalanceDetails.ADD.value]) + list(details[RebalanceDetails.BUY_MORE.value]) + added_coins = list(details[rebalancer_enums.RebalanceDetails.ADD.value]) + list(details[rebalancer_enums.RebalanceDetails.BUY_MORE.value]) return [ coin for coin in self.trading_mode.indexed_coins # iterate over self.trading_mode.indexed_coins to keep order @@ -198,12 +189,12 @@ async def _split_reference_market_into_indexed_coins( await self.trading_mode.rebalancer.pre_cancel_conflicting_orders(details, dependencies, trading_enums.TradeOrderSide.SELL) ref_market = self.exchange_manager.exchange_personal_data.portfolio_manager.reference_market coins_prices = self.trading_mode.indexed_coins_prices - if details[RebalanceDetails.SWAP.value] or is_simple_buy_without_selling: + if details[rebalancer_enums.RebalanceDetails.SWAP.value] or is_simple_buy_without_selling: # has to infer total reference market holdings reference_market_to_split = self._get_traded_assets_holdings_value() coins_to_buy = ( self._get_simple_buy_coins(details) if is_simple_buy_without_selling - else list(details[RebalanceDetails.SWAP.value].values()) + else list(details[rebalancer_enums.RebalanceDetails.SWAP.value].values()) ) else: # can use actual reference market holdings: everything has been sold @@ -306,24 +297,22 @@ class IndexTradingModeProducer(trading_modes.AbstractTradingModeProducer): REFRESH_INTERVAL = "refresh_interval" CANCEL_OPEN_ORDERS = "cancel_open_orders" ALLOW_SKIP_ASSET = "allow_skip_asset" - REBALANCE_TRIGGER_MIN_PERCENT = "rebalance_trigger_min_percent" - SELECTED_REBALANCE_TRIGGER_PROFILE = "selected_rebalance_trigger_profile" - REBALANCE_TRIGGER_PROFILES = "rebalance_trigger_profiles" - REBALANCE_TRIGGER_PROFILE_NAME = "name" - REBALANCE_TRIGGER_PROFILE_MIN_PERCENT = "min_percent" + REBALANCE_TRIGGER_MIN_PERCENT = octobot_copy_constants.CONFIG_REBALANCE_TRIGGER_MIN_PERCENT + SELECTED_REBALANCE_TRIGGER_PROFILE = octobot_copy_constants.CONFIG_SELECTED_REBALANCE_TRIGGER_PROFILE + REBALANCE_TRIGGER_PROFILES = octobot_copy_constants.CONFIG_REBALANCE_TRIGGER_PROFILES + REBALANCE_TRIGGER_PROFILE_NAME = octobot_copy_constants.CONFIG_REBALANCE_TRIGGER_PROFILE_NAME + REBALANCE_TRIGGER_PROFILE_MIN_PERCENT = octobot_copy_constants.CONFIG_REBALANCE_TRIGGER_PROFILE_MIN_PERCENT QUOTE_ASSET_REBALANCE_TRIGGER_MIN_PERCENT = "quote_asset_rebalance_trigger_min_percent" MIN_ORDER_SIZE_MARGIN = "min_order_size_margin" REFERENCE_MARKET_RATIO = "reference_market_ratio" SYNCHRONIZATION_POLICY = "synchronization_policy" SELL_UNINDEXED_TRADED_COINS = "sell_unindexed_traded_coins" - INDEX_CONTENT = "index_content" + INDEX_CONTENT = octobot_copy_constants.CONFIG_INDEX_CONTENT MIN_INDEXED_COINS = 1 - ALLOWED_1_TO_1_SWAP_COUNTS = 1 - MIN_RATIO_TO_SELL = decimal.Decimal("0.0001") # 1/10000 - QUOTE_ASSET_TO_INDEXED_SWAP_RATIO_THRESHOLD = decimal.Decimal("0.1") # 10% def __init__(self, channel, config, trading_mode, exchange_manager): super().__init__(channel, config, trading_mode, exchange_manager) + self.trading_mode: IndexTradingMode = typing.cast(IndexTradingMode, self.trading_mode) self._last_trigger_time = 0 self.state = trading_enums.EvaluatorStates.NEUTRAL @@ -332,6 +321,12 @@ async def stop(self): self.trading_mode.flush_trading_mode_consumers() await super().stop() + async def manual_trigger( + self, matrix_id: str, cryptocurrency: str, + symbol: str, time_frame, trigger_source: str + ) -> None: + return await self._check_index_if_necessary() + async def ohlcv_callback(self, exchange: str, exchange_id: str, cryptocurrency: str, symbol: str, time_frame: str, candle: dict, init_call: bool = False): await self._check_index_if_necessary() @@ -483,227 +478,34 @@ def _notify_if_missing_too_many_coins(self): ) def get_holdings_ratio(self, coin: str, traded_symbols_only: bool = False, include_assets_in_open_orders=False, coins_whitelist: typing.Optional[list] = None) -> decimal.Decimal: - return self.trading_mode.exchange_manager.exchange_personal_data.portfolio_manager. \ - portfolio_value_holder.get_holdings_ratio( - coin, traded_symbols_only=traded_symbols_only, include_assets_in_open_orders=include_assets_in_open_orders, coins_whitelist=coins_whitelist - ) - - def _register_coins_update(self, rebalance_details: dict) -> bool: - should_rebalance = False - for coin in set(self.trading_mode.indexed_coins): - # Use adjusted target ratio to account for reference market percentage - target_ratio = self.trading_mode.get_adjusted_target_ratio(coin) - coin_ratio = self.get_holdings_ratio(coin, traded_symbols_only=True, include_assets_in_open_orders=True) - beyond_ratio = True - if coin_ratio == trading_constants.ZERO and target_ratio > trading_constants.ZERO: - # missing coin in portfolio - rebalance_details[RebalanceDetails.ADD.value][coin] = target_ratio - should_rebalance = True - elif coin_ratio < target_ratio - self.trading_mode.rebalance_trigger_min_ratio: - # not enough in portfolio - rebalance_details[RebalanceDetails.BUY_MORE.value][coin] = target_ratio - should_rebalance = True - elif coin_ratio > target_ratio + self.trading_mode.rebalance_trigger_min_ratio: - # too much in portfolio - rebalance_details[RebalanceDetails.SELL_SOME.value][coin] = target_ratio - should_rebalance = True - else: - beyond_ratio = False - if beyond_ratio: - allowance = round(self.trading_mode.rebalance_trigger_min_ratio * trading_constants.ONE_HUNDRED, 2) - self.logger.info( - f"{coin} is beyond the target ratio of {round(target_ratio * trading_constants.ONE_HUNDRED, 2)}[+/-{allowance}]%, " - f"ratio: {round(coin_ratio * trading_constants.ONE_HUNDRED, 2)}%. A rebalance is required." - ) - return should_rebalance - - def _register_removed_coin(self, rebalance_details: dict, available_traded_bases: set[str]) -> bool: - should_rebalance = False - for coin in self.trading_mode.get_removed_coins_from_config(available_traded_bases): - if coin in available_traded_bases: - coin_ratio = self.get_holdings_ratio(coin, traded_symbols_only=True, include_assets_in_open_orders=True) - if coin_ratio >= self.MIN_RATIO_TO_SELL: - # coin to sell in portfolio - rebalance_details[RebalanceDetails.REMOVE.value][coin] = coin_ratio - self.logger.info( - f"{coin} (holdings: {round(coin_ratio * trading_constants.ONE_HUNDRED, 3)}%) is not in index " - f"anymore. A rebalance is required." - ) - should_rebalance = True - else: - if trading_util.is_symbol_disabled(self.exchange_manager.config, coin): - self.logger.info( - f"Ignoring {coin} holding: {coin} is not in index anymore but is disabled." - ) - else: - self.logger.error( - f"Ignoring {coin} holding: Can't sell {coin} as it is not in any trading pair" - f" but is not in index anymore. This is unexpected" - ) - return should_rebalance + return self.trading_mode._portfolio_holdings_ratio( + coin, + traded_symbols_only=traded_symbols_only, + include_assets_in_open_orders=include_assets_in_open_orders, + coins_whitelist=coins_whitelist, + ) - def _register_quote_asset_rebalance(self, rebalance_details: dict) -> bool: - non_indexed_quote_assets_ratio = self._get_non_indexed_quote_assets_ratio() - if self._should_rebalance_due_to_non_indexed_quote_assets_ratio( - non_indexed_quote_assets_ratio, rebalance_details - ): - rebalance_details[RebalanceDetails.FORCED_REBALANCE.value] = True - self.logger.info( - f"Rebalancing due to a high non-indexed quote asset holdings ratio: " - f"{round(non_indexed_quote_assets_ratio * trading_constants.ONE_HUNDRED, 2)}%, quote rebalance " - f"threshold = {self.trading_mode.quote_asset_rebalance_ratio_threshold * trading_constants.ONE_HUNDRED}%" - ) - return True - return False + def _get_rebalance_details(self) -> typing.Tuple[bool, dict]: + self.trading_mode._sync_rebalance_planner() + return self.trading_mode.rebalance_actions_planner.get_rebalance_details() async def _register_traded_symbol_pairs_update(self): if self.trading_mode.indexed_coins: - self.logger.debug(f"Update traded symbol pair: {self.trading_mode.indexed_coins}...") + reference_market = self.exchange_manager.exchange_personal_data.portfolio_manager.reference_market + if self.exchange_manager.is_future or self.exchange_manager.is_option: + added_pairs = [ + coin + for coin in self.trading_mode.indexed_coins + ] + else: + # on spot, add coins with the reference market as quote currency to trade + added_pairs = [ + symbol_util.merge_currencies(coin, reference_market) + for coin in self.trading_mode.indexed_coins + ] + self.logger.debug(f"Update traded symbol pair: {added_pairs}...") # TODO: remove the pairs when the coins are entirely removed from the index - await self.exchange_manager.exchange_config.add_traded_symbols( - self.trading_mode.indexed_coins, [] - ) - - def _empty_rebalance_details(self) -> dict: - return { - RebalanceDetails.SELL_SOME.value: {}, - RebalanceDetails.BUY_MORE.value: {}, - RebalanceDetails.REMOVE.value: {}, - RebalanceDetails.ADD.value: {}, - RebalanceDetails.SWAP.value: {}, - RebalanceDetails.FORCED_REBALANCE.value: False, - } - - def _get_rebalance_details(self) -> typing.Tuple[bool, dict]: - rebalance_details = self._empty_rebalance_details() - should_rebalance = False - # look for coins update in indexed_coins - available_traded_bases = set( - symbol.base - for symbol in self.exchange_manager.exchange_config.traded_symbols - ) - - # compute rebalance details for current coins distribution - if self.trading_mode.synchronization_policy in ( - SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_AS_SOON_AS_POSSIBLE, - SynchronizationPolicy.SELL_REMOVED_DYNAMIC_INDEX_COINS_AS_SOON_AS_POSSIBLE, - ): - should_rebalance = self._register_removed_coin(rebalance_details, available_traded_bases) - should_rebalance = self._register_coins_update(rebalance_details) or should_rebalance - should_rebalance = self._register_quote_asset_rebalance(rebalance_details) or should_rebalance - if ( - should_rebalance - and self.trading_mode.synchronization_policy == SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_ON_RATIO_REBALANCE - ): - # use latest coins distribution to compute rebalance details - self.trading_mode.ensure_updated_coins_distribution(force_latest=True) - # re-compute the whole rebalance details for latest coins distribution - # to avoid side effects from previous distribution - rebalance_details = self._empty_rebalance_details() - self._register_removed_coin(rebalance_details, available_traded_bases) - self._register_coins_update(rebalance_details) - self._register_quote_asset_rebalance(rebalance_details) - - if not rebalance_details[RebalanceDetails.FORCED_REBALANCE.value]: - # finally, compute swaps when no forced rebalance is required - self._resolve_swaps(rebalance_details) - for origin, target in rebalance_details[RebalanceDetails.SWAP.value].items(): - origin_ratio = round( - rebalance_details[RebalanceDetails.REMOVE.value][origin] * trading_constants.ONE_HUNDRED, - 3 - ) - target_ratio = round( - rebalance_details[RebalanceDetails.ADD.value].get( - target, - rebalance_details[RebalanceDetails.BUY_MORE.value].get(target, trading_constants.ZERO) - ) * trading_constants.ONE_HUNDRED, - 3 - ) or "???" - self.logger.info( - f"Swapping {origin} (holding ratio: {origin_ratio}%) for {target} (to buy ratio: {target_ratio}%) " - f"on [{self.exchange_manager.exchange_name}]: ratios are similar enough to allow swapping." - ) - return (should_rebalance or rebalance_details[RebalanceDetails.FORCED_REBALANCE.value]), rebalance_details - - def _should_rebalance_due_to_non_indexed_quote_assets_ratio(self, non_indexed_quote_assets_ratio: decimal.Decimal, rebalance_details: dict) -> bool: - total_added_ratio = ( - self._sum_ratios(rebalance_details, RebalanceDetails.ADD.value) - + self._sum_ratios(rebalance_details, RebalanceDetails.BUY_MORE.value) - ) - - if ( - total_added_ratio * (trading_constants.ONE - self.QUOTE_ASSET_TO_INDEXED_SWAP_RATIO_THRESHOLD) - <= non_indexed_quote_assets_ratio - <= total_added_ratio * (trading_constants.ONE + self.QUOTE_ASSET_TO_INDEXED_SWAP_RATIO_THRESHOLD) - ): - total_removed_ratio = ( - self._sum_ratios(rebalance_details, RebalanceDetails.REMOVE.value) - + self._sum_ratios(rebalance_details, RebalanceDetails.SELL_SOME.value) - ) - # added coins are equivalent to free quote assets: just buy with quote assets - if total_removed_ratio == trading_constants.ZERO: - return False - # there are removed coins or added ratio is not equivalent to free quote assets: rebalance if necessary - min_ratio = min( - min( - self.trading_mode.get_target_ratio(coin) - for coin in self.trading_mode.indexed_coins - ) if self.trading_mode.indexed_coins else self.trading_mode.quote_asset_rebalance_ratio_threshold, - self.trading_mode.quote_asset_rebalance_ratio_threshold - ) - return non_indexed_quote_assets_ratio >= min_ratio - - @staticmethod - def _sum_ratios(rebalance_details: dict, key: str) -> decimal.Decimal: - return decimal.Decimal(str(sum( - ratio - for ratio in rebalance_details[key].values() - ))) if rebalance_details[key] else trading_constants.ZERO - - def _get_non_indexed_quote_assets_ratio(self) -> decimal.Decimal: - reference_market = self.exchange_manager.exchange_personal_data.portfolio_manager.reference_market - reference_market_ratio = self.trading_mode.reference_market_ratio - total = trading_constants.ZERO - for quote in set( - symbol.quote - for symbol in self.exchange_manager.exchange_config.traded_symbols - if symbol.quote not in self.trading_mode.indexed_coins - ): - ratio = decimal.Decimal(str( - self.get_holdings_ratio(quote, traded_symbols_only=True, include_assets_in_open_orders=True) - )) - if quote == reference_market and reference_market_ratio > trading_constants.ZERO: - # Only the excess above the desired reference market reserve counts toward triggering - # reference_market_ratio is the percentage to trade, so (1 - reference_market_ratio) is the percentage to keep - reference_market_keep_ratio = trading_constants.ONE - reference_market_ratio - ratio = max(trading_constants.ZERO, ratio - reference_market_keep_ratio) - total += ratio - return decimal.Decimal(str(total)) - - def _resolve_swaps(self, details: dict): - removed = details[RebalanceDetails.REMOVE.value] - details[RebalanceDetails.SWAP.value] = {} - if details[RebalanceDetails.SELL_SOME.value]: - # rebalance within held coins: global rebalance required - return - added = {**details[RebalanceDetails.ADD.value], **details[RebalanceDetails.BUY_MORE.value]} - if len(removed) == len(added) == self.ALLOWED_1_TO_1_SWAP_COUNTS: - for removed_coin, removed_ratio, added_coin, added_ratio in zip( - removed, removed.values(), added, added.values() - ): - added_holding_ratio = self.get_holdings_ratio(added_coin, traded_symbols_only=True, coins_whitelist=self.trading_mode.get_coins_to_consider_for_ratio()) - required_added_ratio = added_ratio - added_holding_ratio - if ( - removed_ratio - self.trading_mode.rebalance_trigger_min_ratio - < required_added_ratio - < removed_ratio + self.trading_mode.rebalance_trigger_min_ratio - ): - # removed can be swapped for added: only sell removed - details[RebalanceDetails.SWAP.value][removed_coin] = added_coin - else: - # reset to_sell to sell everything - details[RebalanceDetails.SWAP.value] = {} - return + await self.exchange_manager.exchange_config.add_traded_symbols(added_pairs, []) def get_channels_registration(self): # use candles to trigger at each candle interval and when initializing @@ -751,21 +553,97 @@ def __init__(self, config, exchange_manager): self.rebalance_trigger_min_ratio = decimal.Decimal(float(DEFAULT_REBALANCE_TRIGGER_MIN_RATIO)) self.rebalance_trigger_profiles: typing.Optional[list] = None self.selected_rebalance_trigger_profile: typing.Optional[dict] = None - self.ratio_per_asset = {} self.sell_unindexed_traded_coins = True self.cancel_open_orders = True self.allow_skip_asset = False - self.total_ratio_per_asset = trading_constants.ZERO self.quote_asset_rebalance_ratio_threshold = decimal.Decimal(str(DEFAULT_QUOTE_ASSET_REBALANCE_TRIGGER_MIN_RATIO)) self.reference_market_ratio = trading_constants.ONE self.min_order_size_margin = decimal.Decimal("2") - self.synchronization_policy: SynchronizationPolicy = SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_AS_SOON_AS_POSSIBLE + self.synchronization_policy: rebalancer_enums.SynchronizationPolicy = rebalancer_enums.SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_AS_SOON_AS_POSSIBLE self.requires_initializing_appropriate_coins_distribution = False - self.indexed_coins = [] self.indexed_coins_prices = {} self.is_processing_rebalance = False - self.rebalancer: typing.Optional[rebalancer.AbstractRebalancer] = self._create_rebalancer(exchange_manager) if exchange_manager else None - + self.rebalance_actions_planner: rebalance_actions_planner.RebalanceActionsPlanner = None # type: ignore + if exchange_manager: + self.rebalance_actions_planner = rebalance_actions_planner.RebalanceActionsPlanner( + exchange=exchange_interface.ExchangeInterface(exchange_manager), + client=rebalancing_client_interface.RebalancingClientInterface( + get_holdings_ratio=self._portfolio_holdings_ratio, + get_config=lambda: self.trading_config, + get_previous_config=lambda: self.previous_trading_config, + get_historical_configs=lambda ft, tt: self.get_historical_configs(ft, tt), + get_ideal_distribution=self.get_ideal_distribution, + get_client_name=self.get_name, + ), + synchronization_policy=self.synchronization_policy, + rebalance_trigger_min_ratio=self.rebalance_trigger_min_ratio, + quote_asset_rebalance_ratio_threshold=self.quote_asset_rebalance_ratio_threshold, + reference_market_ratio=self.reference_market_ratio, + reference_market=exchange_manager.exchange_personal_data.portfolio_manager.reference_market, + sell_untargeted_traded_coins=self.sell_unindexed_traded_coins, + ) + self.rebalancer: rebalancer.AbstractRebalancer = self._create_rebalancer(exchange_manager) if exchange_manager else None # type: ignore + + def _portfolio_holdings_ratio( + self, + coin: str, + traded_symbols_only: bool = False, + include_assets_in_open_orders=False, + coins_whitelist: typing.Optional[list] = None, + ) -> decimal.Decimal: + return self.exchange_manager.exchange_personal_data.portfolio_manager. \ + portfolio_value_holder.get_holdings_ratio( + coin, + traded_symbols_only=traded_symbols_only, + include_assets_in_open_orders=include_assets_in_open_orders, + coins_whitelist=coins_whitelist, + ) + + @property + def ratio_per_asset(self) -> dict: + if self.rebalance_actions_planner is None: + return {} + return self.rebalance_actions_planner.ratio_per_asset + + @ratio_per_asset.setter + def ratio_per_asset(self, value: dict) -> None: + if self.rebalance_actions_planner is not None: + self.rebalance_actions_planner.ratio_per_asset = value + + @property + def total_ratio_per_asset(self) -> decimal.Decimal: + if self.rebalance_actions_planner is None: + return trading_constants.ZERO + return self.rebalance_actions_planner.total_ratio_per_asset + + @total_ratio_per_asset.setter + def total_ratio_per_asset(self, value: decimal.Decimal) -> None: + if self.rebalance_actions_planner is not None: + self.rebalance_actions_planner.total_ratio_per_asset = value + + @property + def indexed_coins(self) -> list: + if self.rebalance_actions_planner is None: + return [] + return self.rebalance_actions_planner.targeted_coins + + @indexed_coins.setter + def indexed_coins(self, value: list) -> None: + if self.rebalance_actions_planner is not None: + self.rebalance_actions_planner.targeted_coins = value + + def _sync_rebalance_planner(self) -> None: + if self.rebalance_actions_planner is None: + return + self.rebalance_actions_planner.update( + synchronization_policy=self.synchronization_policy, + rebalance_trigger_min_ratio=self.rebalance_trigger_min_ratio, + quote_asset_rebalance_ratio_threshold=self.quote_asset_rebalance_ratio_threshold, + reference_market_ratio=self.reference_market_ratio, + reference_market=self.exchange_manager.exchange_personal_data.portfolio_manager.reference_market, + sell_untargeted_traded_coins=self.sell_unindexed_traded_coins, + ) + def init_user_inputs(self, inputs: dict) -> None: """ Called right before starting the tentacle, should define all the tentacle's user inputs unless @@ -861,12 +739,12 @@ def init_user_inputs(self, inputs: dict) -> None: sync_policy: str = self.UI.user_input( IndexTradingModeProducer.SYNCHRONIZATION_POLICY, commons_enums.UserInputTypes.OPTIONS, self.synchronization_policy.value, inputs, - options=[p.value for p in SynchronizationPolicy], - editor_options={"enum_titles": [p.value.replace("_", " ") for p in SynchronizationPolicy]}, + options=[p.value for p in rebalancer_enums.SynchronizationPolicy], + editor_options={"enum_titles": [p.value.replace("_", " ") for p in rebalancer_enums.SynchronizationPolicy]}, title="Synchronization policy: should coins that are removed from index be sold as soon as possible or only when rebalancing is triggered when coins don't follow the configured ratios.", ) try: - self.synchronization_policy = SynchronizationPolicy(sync_policy) + self.synchronization_policy = rebalancer_enums.SynchronizationPolicy(sync_policy) except ValueError as err: self.logger.exception( err, @@ -888,8 +766,9 @@ def init_user_inputs(self, inputs: dict) -> None: 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(): + if (not self.exchange_manager or not self.exchange_manager.is_backtesting) and ( + authentication.Authenticator.instance().has_open_source_package() or self.synchronous_execution + ): self.UI.user_input(IndexTradingModeProducer.INDEX_CONTENT, commons_enums.UserInputTypes.OBJECT_ARRAY, trading_config.get(IndexTradingModeProducer.INDEX_CONTENT, None), inputs, item_title="Coin", @@ -897,63 +776,53 @@ def init_user_inputs(self, inputs: dict) -> None: title="Custom distribution: when used, only coins listed in this distribution and " "in your profile traded pairs will be traded. " "Leave empty to evenly allocate funds in each traded coin.") - self.UI.user_input(index_distribution.DISTRIBUTION_NAME, commons_enums.UserInputTypes.TEXT, + self.UI.user_input(rebalancer_enums.DistributionKeys.NAME, commons_enums.UserInputTypes.TEXT, "BTC", inputs, other_schema_values={"minLength": 1}, parent_input_name=IndexTradingModeProducer.INDEX_CONTENT, title="Name of the coin.") - self.UI.user_input(index_distribution.DISTRIBUTION_VALUE, commons_enums.UserInputTypes.FLOAT, + self.UI.user_input(rebalancer_enums.DistributionKeys.VALUE, commons_enums.UserInputTypes.FLOAT, 50, inputs, min_val=0, parent_input_name=IndexTradingModeProducer.INDEX_CONTENT, title="Weight of the coin within this distribution.") - self.requires_initializing_appropriate_coins_distribution = self.synchronization_policy == SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_ON_RATIO_REBALANCE - self.ensure_updated_coins_distribution() + self.requires_initializing_appropriate_coins_distribution = self.synchronization_policy == rebalancer_enums.SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_ON_RATIO_REBALANCE + if self.rebalance_actions_planner is not None: + self._sync_rebalance_planner() + self.rebalance_actions_planner.update_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) + symbol_util.merge_currencies(asset[rebalancer_enums.DistributionKeys.NAME], reference_market) for asset in (cls.get_ideal_distribution(config) or []) ] + @classmethod + def get_dsl_dependencies(cls, trading_config: dict, config: dict) -> list: + index_content = cls.get_ideal_distribution(trading_config) + if not index_content: + return [] + try: + reference_market = trading_util.get_reference_market(config) + except (KeyError, TypeError): + reference_market = commons_constants.DEFAULT_REFERENCE_MARKET + symbols = cls.get_tentacle_config_traded_symbols(trading_config, reference_market) + return [trading_dsl.SymbolDependency(symbol=symbol) for symbol in symbols] + def is_updating_at_each_price_change(self): return self.refresh_interval_days == 0 def automatically_update_historical_config_on_set_intervals(self) -> bool: return ( self.supports_historical_config() - and self.synchronization_policy == SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_AS_SOON_AS_POSSIBLE + and self.synchronization_policy == rebalancer_enums.SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_AS_SOON_AS_POSSIBLE ) def ensure_updated_coins_distribution(self, adapt_to_holdings: bool = False, force_latest: bool = False): - distribution = self._get_supported_distribution(adapt_to_holdings, force_latest) - self.ratio_per_asset = { - asset[index_distribution.DISTRIBUTION_NAME]: asset - for asset in distribution - } - self.total_ratio_per_asset = decimal.Decimal(sum( - asset[index_distribution.DISTRIBUTION_VALUE] - for asset in self.ratio_per_asset.values() - )) - self.indexed_coins = self._get_filtered_traded_coins(self.ratio_per_asset) - - def _get_filtered_traded_coins(self, ratio_per_asset: dict): - if self.exchange_manager: - ref_market = self.exchange_manager.exchange_personal_data.portfolio_manager.reference_market - coins = set( - symbol.base - for symbol in self.exchange_manager.exchange_config.traded_symbols - if symbol.base in ratio_per_asset and symbol.quote == ref_market - ) - if ref_market in ratio_per_asset and coins: - # there is at least 1 coin traded against ref market, can add ref market if necessary - coins.add(ref_market) - return sorted(list(coins)) - return [] - - def get_coins_to_consider_for_ratio(self) -> list: - return self.indexed_coins + [self.exchange_manager.exchange_personal_data.portfolio_manager.reference_market] + if self.rebalance_actions_planner is not None: + self._sync_rebalance_planner() + self.rebalance_actions_planner.update_distribution(adapt_to_holdings, force_latest) @classmethod def get_ideal_distribution(cls, config: dict): @@ -984,232 +853,16 @@ def get_config_history_propagated_tentacles_config_keys() -> list[str]: IndexTradingModeProducer.SYNCHRONIZATION_POLICY, ] - def _get_currently_applied_historical_config_according_to_holdings( - self, config: dict, traded_bases: set[str] - ) -> dict: - # 1. check if latest config is the running one - if self._is_index_config_applied(config, traded_bases): - self.logger.info(f"Using {self.get_name()} latest config.") - return config - # 2. check if historical configs are available (iterating from most recent to oldest) - historical_configs = self.get_historical_configs( - 0, self.exchange_manager.exchange.get_exchange_current_time() - ) - if not historical_configs or ( - # only 1 historical config which is the same as the latest config - len(historical_configs) == 1 and ( - self.get_ideal_distribution(historical_configs[0]) == self.get_ideal_distribution(config) - and historical_configs[0][IndexTradingModeProducer.REBALANCE_TRIGGER_MIN_PERCENT] == config[IndexTradingModeProducer.REBALANCE_TRIGGER_MIN_PERCENT] - ) - ): - # current config is the first historical config - self.logger.info(f"Using {self.get_name()} latest config as no historical configs are available.") - return config - for index, historical_config in enumerate(historical_configs): - if self._is_index_config_applied(historical_config, traded_bases): - self.logger.info(f"Using [N-{index}] {self.get_name()} historical config distribution: {self.get_ideal_distribution(historical_config)}.") - return historical_config - # 3. no suitable config found: return latest config - self.logger.info(f"No suitable {self.get_name()} config found: using latest distribution: {self.get_ideal_distribution(config)}.") - return config - - def _is_index_config_applied(self, config: dict, traded_bases: set[str]) -> bool: - full_assets_distribution = self.get_ideal_distribution(config) - if not full_assets_distribution: - return False - assets_distribution = [ - asset - for asset in full_assets_distribution - if asset[index_distribution.DISTRIBUTION_NAME] in traded_bases - ] - if len(assets_distribution) != len(full_assets_distribution): - # if assets are missing from traded pairs, the config is not applied - # might be due to delisted or renamed coins - missing_assets = [ - asset[index_distribution.DISTRIBUTION_NAME] - for asset in full_assets_distribution - if asset not in assets_distribution - ] - self.logger.warning( - f"Ignored {self.get_name()} config candidate as {len(missing_assets)} configured assets {missing_assets} are missing from {self.exchange_manager.exchange_name} traded pairs." - ) - return False - - total_ratio = decimal.Decimal(sum( - asset[index_distribution.DISTRIBUTION_VALUE] - for asset in assets_distribution - )) - if total_ratio == trading_constants.ZERO: - return False - min_trigger_ratio = self._get_config_min_ratio(config) - for asset_distrib in assets_distribution: - base_target_ratio = decimal.Decimal(str(asset_distrib[index_distribution.DISTRIBUTION_VALUE])) / total_ratio - if self.reference_market_ratio < trading_constants.ONE: - target_ratio = base_target_ratio * self.reference_market_ratio - else: - target_ratio = base_target_ratio - coin_ratio = self.exchange_manager.exchange_personal_data.portfolio_manager.portfolio_value_holder.get_holdings_ratio( - asset_distrib[index_distribution.DISTRIBUTION_NAME], traded_symbols_only=True - ) - if not (target_ratio - min_trigger_ratio <= coin_ratio <= target_ratio + min_trigger_ratio): - # not enough or too much in portfolio - return False - return True - - def _get_config_min_ratio(self, config: dict) -> decimal.Decimal: - ratio = None - rebalance_trigger_profiles = config.get(IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILES, None) - if rebalance_trigger_profiles: - # 1. try to get ratio from selected rebalance trigger profile - selected_rebalance_trigger_profile_name =config.get(IndexTradingModeProducer.SELECTED_REBALANCE_TRIGGER_PROFILE, None) - selected_profile = [ - p for p in rebalance_trigger_profiles - if p[IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILE_NAME] == selected_rebalance_trigger_profile_name - ] - if selected_profile: - selected_rebalance_trigger_profile = selected_profile[0] - ratio = selected_rebalance_trigger_profile[IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILE_MIN_PERCENT] - if ratio is None: - # 2. try to get ratio from direct config - ratio = config.get(IndexTradingModeProducer.REBALANCE_TRIGGER_MIN_PERCENT) - if ratio is None: - # 3. default to current config ratio - return self.rebalance_trigger_min_ratio - return decimal.Decimal(str(ratio)) / trading_constants.ONE_HUNDRED - - def _get_supported_distribution(self, adapt_to_holdings: bool, force_latest: bool) -> list: - if detailed_distribution := self.get_ideal_distribution(self.trading_config): - traded_bases = set( - symbol.base - for symbol in self.exchange_manager.exchange_config.traded_symbols - ) - traded_bases.add(self.exchange_manager.exchange_personal_data.portfolio_manager.reference_market) - if ( - (adapt_to_holdings or force_latest) - and self.synchronization_policy == SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_ON_RATIO_REBALANCE - ): - if adapt_to_holdings: - # when policy is SELL_REMOVED_INDEX_COINS_ON_RATIO_REBALANCE, the latest config might not be the - # running one: confirm this using historical configs - index_config = self._get_currently_applied_historical_config_according_to_holdings( - self.trading_config, traded_bases - ) - else: - # force latest available config - try: - index_config = self.get_historical_configs( - 0, self.exchange_manager.exchange.get_exchange_current_time() - )[0] - self.logger.info(f"Updated {self.get_name()} to use latest distribution: {self.get_ideal_distribution(index_config)}.") - except IndexError: - index_config = self.trading_config - detailed_distribution = self.get_ideal_distribution(index_config) - if not detailed_distribution: - raise ValueError(f"No distribution found in historical index config: {index_config}") - distribution = [ - asset - for asset in detailed_distribution - if asset[index_distribution.DISTRIBUTION_NAME] in traded_bases - ] - if removed_assets := [ - asset[index_distribution.DISTRIBUTION_NAME] - for asset in detailed_distribution - if asset not in distribution - ]: - self.logger.info( - f"Ignored {len(removed_assets)} assets {removed_assets} from configured " - f"distribution as absent from traded pairs." - ) - return distribution - else: - # compute uniform distribution over traded assets - return index_distribution.get_uniform_distribution([ - symbol.base - for symbol in self.exchange_manager.exchange_config.traded_symbols - ]) if self.exchange_manager else [] - - def get_removed_coins_from_config(self, available_traded_bases) -> list: - removed_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 - for coin in available_traded_bases - if coin not in self.indexed_coins - and coin != self.exchange_manager.exchange_personal_data.portfolio_manager.reference_market - ] - if self.synchronization_policy == SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_AS_SOON_AS_POSSIBLE: - # identify coins to sell from previous config - if not (self.previous_trading_config and self.trading_config): - return removed_coins - current_coins = [ - asset[index_distribution.DISTRIBUTION_NAME] - for asset in (self.get_ideal_distribution(self.trading_config) or []) - ] - ref_market = self.exchange_manager.exchange_personal_data.portfolio_manager.reference_market - return list(set(removed_coins + [ - asset[index_distribution.DISTRIBUTION_NAME] - for asset in self.previous_trading_config[IndexTradingModeProducer.INDEX_CONTENT] - if asset[index_distribution.DISTRIBUTION_NAME] not in current_coins - and ( - asset[index_distribution.DISTRIBUTION_NAME] - != self.exchange_manager.exchange_personal_data.portfolio_manager.reference_market - ) - ])) - elif self.synchronization_policy == SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_ON_RATIO_REBALANCE: - # identify coins to sell from historical configs - historical_configs = self.get_historical_configs( - # use 0 a the initial config time as only relevant historical configs should be available - 0, self.exchange_manager.exchange.get_exchange_current_time() - ) - if not (historical_configs and self.trading_config): - return removed_coins - current_coins = [ - asset[index_distribution.DISTRIBUTION_NAME] - for asset in (self.get_ideal_distribution(self.trading_config) or []) - ] - ref_market = self.exchange_manager.exchange_personal_data.portfolio_manager.reference_market - removed_coins_from_historical_configs = set() - for historical_config in historical_configs: - for asset in historical_config[IndexTradingModeProducer.INDEX_CONTENT]: - asset_name = asset[index_distribution.DISTRIBUTION_NAME] - if asset_name not in current_coins and asset_name != ref_market: - removed_coins_from_historical_configs.add(asset_name) - return list(removed_coins_from_historical_configs.union(removed_coins)) - elif self.synchronization_policy == SynchronizationPolicy.SELL_REMOVED_DYNAMIC_INDEX_COINS_AS_SOON_AS_POSSIBLE: - # For modes with a dynamic index (no static INDEX_CONTENT config), derive removed coins - # directly from the current indexed_coins without relying on previous_trading_config. - ref_market = self.exchange_manager.exchange_personal_data.portfolio_manager.reference_market - return [ - coin for coin in available_traded_bases - if coin not in self.indexed_coins and coin != ref_market - ] - else: - self.logger.error(f"Unknown synchronization policy: {self.synchronization_policy}") + def get_removed_coins_from_config(self, available_traded_bases): + if self.rebalance_actions_planner is None: return [] + self._sync_rebalance_planner() + return self.rebalance_actions_planner.get_removed_coins_from_config(available_traded_bases) def get_target_ratio(self, currency) -> decimal.Decimal: - if currency in self.ratio_per_asset: - try: - return ( - decimal.Decimal(str( - self.ratio_per_asset[currency][index_distribution.DISTRIBUTION_VALUE] - )) / self.total_ratio_per_asset - ) - except (decimal.DivisionByZero, decimal.InvalidOperation): - pass - return trading_constants.ZERO - - def get_adjusted_target_ratio(self, currency) -> decimal.Decimal: - """ - Returns the target ratio adjusted for the reference market percentage. - If reference_market_ratio is set, the coin's ratio is scaled by reference_market_ratio - to reflect the percentage of the portfolio to trade (the remaining is kept in reference market). - """ - base_ratio = self.get_target_ratio(currency) - if self.reference_market_ratio < trading_constants.ONE: - return base_ratio * self.reference_market_ratio - return base_ratio + if self.rebalance_actions_planner is None: + return trading_constants.ZERO + return self.rebalance_actions_planner.get_target_ratio(currency) def _create_rebalancer(self, exchange_manager) -> rebalancer.AbstractRebalancer: if exchange_manager.is_option: diff --git a/packages/tentacles/Trading/Mode/index_trading_mode/tests/test_index_distribution.py b/packages/tentacles/Trading/Mode/index_trading_mode/tests/test_index_distribution.py index b0d3b5301..44759a008 100644 --- a/packages/tentacles/Trading/Mode/index_trading_mode/tests/test_index_distribution.py +++ b/packages/tentacles/Trading/Mode/index_trading_mode/tests/test_index_distribution.py @@ -2,6 +2,7 @@ import pytest import tentacles.Trading.Mode.index_trading_mode.index_distribution as index_distribution +import octobot_copy.enums as copy_enums def test_get_uniform_distribution(): @@ -11,24 +12,24 @@ def test_get_uniform_distribution(): {"BTC": decimal.Decimal("50000"), "1": decimal.Decimal("100"), "2": decimal.Decimal("200"), "3": decimal.Decimal("300")} ) == [ { - index_distribution.DISTRIBUTION_NAME: "BTC", - index_distribution.DISTRIBUTION_VALUE: 25, - index_distribution.DISTRIBUTION_PRICE: decimal.Decimal("50000"), + copy_enums.DistributionKeys.NAME: "BTC", + copy_enums.DistributionKeys.VALUE: 25, + copy_enums.DistributionKeys.PRICE: decimal.Decimal("50000"), }, { - index_distribution.DISTRIBUTION_NAME: "1", - index_distribution.DISTRIBUTION_VALUE: 25, - index_distribution.DISTRIBUTION_PRICE: decimal.Decimal("100"), + copy_enums.DistributionKeys.NAME: "1", + copy_enums.DistributionKeys.VALUE: 25, + copy_enums.DistributionKeys.PRICE: decimal.Decimal("100"), }, { - index_distribution.DISTRIBUTION_NAME: "2", - index_distribution.DISTRIBUTION_VALUE: 25, - index_distribution.DISTRIBUTION_PRICE: decimal.Decimal("200"), + copy_enums.DistributionKeys.NAME: "2", + copy_enums.DistributionKeys.VALUE: 25, + copy_enums.DistributionKeys.PRICE: decimal.Decimal("200"), }, { - index_distribution.DISTRIBUTION_NAME: "3", - index_distribution.DISTRIBUTION_VALUE: 25, - index_distribution.DISTRIBUTION_PRICE: decimal.Decimal("300"), + copy_enums.DistributionKeys.NAME: "3", + copy_enums.DistributionKeys.VALUE: 25, + copy_enums.DistributionKeys.PRICE: decimal.Decimal("300"), } ] assert index_distribution.get_uniform_distribution( @@ -36,19 +37,19 @@ def test_get_uniform_distribution(): {"BTC": decimal.Decimal("50000"), "1": decimal.Decimal("100"), "2": decimal.Decimal("200")} ) == [ { - index_distribution.DISTRIBUTION_NAME: "BTC", - index_distribution.DISTRIBUTION_VALUE: 33.3, - index_distribution.DISTRIBUTION_PRICE: decimal.Decimal("50000"), + copy_enums.DistributionKeys.NAME: "BTC", + copy_enums.DistributionKeys.VALUE: 33.3, + copy_enums.DistributionKeys.PRICE: decimal.Decimal("50000"), }, { - index_distribution.DISTRIBUTION_NAME: "1", - index_distribution.DISTRIBUTION_VALUE: 33.3, - index_distribution.DISTRIBUTION_PRICE: decimal.Decimal("100"), + copy_enums.DistributionKeys.NAME: "1", + copy_enums.DistributionKeys.VALUE: 33.3, + copy_enums.DistributionKeys.PRICE: decimal.Decimal("100"), }, { - index_distribution.DISTRIBUTION_NAME: "2", - index_distribution.DISTRIBUTION_VALUE: 33.3, - index_distribution.DISTRIBUTION_PRICE: decimal.Decimal("200"), + copy_enums.DistributionKeys.NAME: "2", + copy_enums.DistributionKeys.VALUE: 33.3, + copy_enums.DistributionKeys.PRICE: decimal.Decimal("200"), }, ] # Test when price_by_coin is None @@ -57,19 +58,19 @@ def test_get_uniform_distribution(): None ) == [ { - index_distribution.DISTRIBUTION_NAME: "BTC", - index_distribution.DISTRIBUTION_VALUE: 33.3, - index_distribution.DISTRIBUTION_PRICE: None, + copy_enums.DistributionKeys.NAME: "BTC", + copy_enums.DistributionKeys.VALUE: 33.3, + copy_enums.DistributionKeys.PRICE: None, }, { - index_distribution.DISTRIBUTION_NAME: "1", - index_distribution.DISTRIBUTION_VALUE: 33.3, - index_distribution.DISTRIBUTION_PRICE: None, + copy_enums.DistributionKeys.NAME: "1", + copy_enums.DistributionKeys.VALUE: 33.3, + copy_enums.DistributionKeys.PRICE: None, }, { - index_distribution.DISTRIBUTION_NAME: "2", - index_distribution.DISTRIBUTION_VALUE: 33.3, - index_distribution.DISTRIBUTION_PRICE: None, + copy_enums.DistributionKeys.NAME: "2", + copy_enums.DistributionKeys.VALUE: 33.3, + copy_enums.DistributionKeys.PRICE: None, }, ] # Test when some coins are not in price_by_coin @@ -78,24 +79,24 @@ def test_get_uniform_distribution(): {"BTC": decimal.Decimal("50000"), "1": decimal.Decimal("100")} ) == [ { - index_distribution.DISTRIBUTION_NAME: "BTC", - index_distribution.DISTRIBUTION_VALUE: 25, - index_distribution.DISTRIBUTION_PRICE: decimal.Decimal("50000"), + copy_enums.DistributionKeys.NAME: "BTC", + copy_enums.DistributionKeys.VALUE: 25, + copy_enums.DistributionKeys.PRICE: decimal.Decimal("50000"), }, { - index_distribution.DISTRIBUTION_NAME: "1", - index_distribution.DISTRIBUTION_VALUE: 25, - index_distribution.DISTRIBUTION_PRICE: decimal.Decimal("100"), + copy_enums.DistributionKeys.NAME: "1", + copy_enums.DistributionKeys.VALUE: 25, + copy_enums.DistributionKeys.PRICE: decimal.Decimal("100"), }, { - index_distribution.DISTRIBUTION_NAME: "2", - index_distribution.DISTRIBUTION_VALUE: 25, - index_distribution.DISTRIBUTION_PRICE: None, + copy_enums.DistributionKeys.NAME: "2", + copy_enums.DistributionKeys.VALUE: 25, + copy_enums.DistributionKeys.PRICE: None, }, { - index_distribution.DISTRIBUTION_NAME: "3", - index_distribution.DISTRIBUTION_VALUE: 25, - index_distribution.DISTRIBUTION_PRICE: None, + copy_enums.DistributionKeys.NAME: "3", + copy_enums.DistributionKeys.VALUE: 25, + copy_enums.DistributionKeys.PRICE: None, } ] @@ -115,24 +116,24 @@ def test_get_linear_distribution(): "3": decimal.Decimal("300") }) == [ { - index_distribution.DISTRIBUTION_NAME: "BTC", - index_distribution.DISTRIBUTION_VALUE: 68.4, - index_distribution.DISTRIBUTION_PRICE: decimal.Decimal("50000"), + copy_enums.DistributionKeys.NAME: "BTC", + copy_enums.DistributionKeys.VALUE: 68.4, + copy_enums.DistributionKeys.PRICE: decimal.Decimal("50000"), }, { - index_distribution.DISTRIBUTION_NAME: "1", - index_distribution.DISTRIBUTION_VALUE: 6.7, - index_distribution.DISTRIBUTION_PRICE: decimal.Decimal("100"), + copy_enums.DistributionKeys.NAME: "1", + copy_enums.DistributionKeys.VALUE: 6.7, + copy_enums.DistributionKeys.PRICE: decimal.Decimal("100"), }, { - index_distribution.DISTRIBUTION_NAME: "2", - index_distribution.DISTRIBUTION_VALUE: 0.2, - index_distribution.DISTRIBUTION_PRICE: decimal.Decimal("200"), + copy_enums.DistributionKeys.NAME: "2", + copy_enums.DistributionKeys.VALUE: 0.2, + copy_enums.DistributionKeys.PRICE: decimal.Decimal("200"), }, { - index_distribution.DISTRIBUTION_NAME: "3", - index_distribution.DISTRIBUTION_VALUE: 24.7, - index_distribution.DISTRIBUTION_PRICE: decimal.Decimal("300"), + copy_enums.DistributionKeys.NAME: "3", + copy_enums.DistributionKeys.VALUE: 24.7, + copy_enums.DistributionKeys.PRICE: decimal.Decimal("300"), } ] assert index_distribution.get_linear_distribution({ @@ -145,19 +146,19 @@ def test_get_linear_distribution(): "3": decimal.Decimal("300") }) == [ { - index_distribution.DISTRIBUTION_NAME: "BTC", - index_distribution.DISTRIBUTION_VALUE: 2.8, - index_distribution.DISTRIBUTION_PRICE: decimal.Decimal("50000"), + copy_enums.DistributionKeys.NAME: "BTC", + copy_enums.DistributionKeys.VALUE: 2.8, + copy_enums.DistributionKeys.PRICE: decimal.Decimal("50000"), }, { - index_distribution.DISTRIBUTION_NAME: "1", - index_distribution.DISTRIBUTION_VALUE: 0, - index_distribution.DISTRIBUTION_PRICE: decimal.Decimal("100"), + copy_enums.DistributionKeys.NAME: "1", + copy_enums.DistributionKeys.VALUE: 0, + copy_enums.DistributionKeys.PRICE: decimal.Decimal("100"), }, { - index_distribution.DISTRIBUTION_NAME: "3", - index_distribution.DISTRIBUTION_VALUE: 97.2, - index_distribution.DISTRIBUTION_PRICE: decimal.Decimal("300"), + copy_enums.DistributionKeys.NAME: "3", + copy_enums.DistributionKeys.VALUE: 97.2, + copy_enums.DistributionKeys.PRICE: decimal.Decimal("300"), }, ] # Test when price_by_coin is None @@ -168,24 +169,24 @@ def test_get_linear_distribution(): "3": decimal.Decimal(44) }, None) == [ { - index_distribution.DISTRIBUTION_NAME: "BTC", - index_distribution.DISTRIBUTION_VALUE: 68.4, - index_distribution.DISTRIBUTION_PRICE: None, + copy_enums.DistributionKeys.NAME: "BTC", + copy_enums.DistributionKeys.VALUE: 68.4, + copy_enums.DistributionKeys.PRICE: None, }, { - index_distribution.DISTRIBUTION_NAME: "1", - index_distribution.DISTRIBUTION_VALUE: 6.7, - index_distribution.DISTRIBUTION_PRICE: None, + copy_enums.DistributionKeys.NAME: "1", + copy_enums.DistributionKeys.VALUE: 6.7, + copy_enums.DistributionKeys.PRICE: None, }, { - index_distribution.DISTRIBUTION_NAME: "2", - index_distribution.DISTRIBUTION_VALUE: 0.2, - index_distribution.DISTRIBUTION_PRICE: None, + copy_enums.DistributionKeys.NAME: "2", + copy_enums.DistributionKeys.VALUE: 0.2, + copy_enums.DistributionKeys.PRICE: None, }, { - index_distribution.DISTRIBUTION_NAME: "3", - index_distribution.DISTRIBUTION_VALUE: 24.7, - index_distribution.DISTRIBUTION_PRICE: None, + copy_enums.DistributionKeys.NAME: "3", + copy_enums.DistributionKeys.VALUE: 24.7, + copy_enums.DistributionKeys.PRICE: None, } ] # Test when some coins are not in price_by_coin @@ -199,24 +200,24 @@ def test_get_linear_distribution(): "3": decimal.Decimal("300") }) == [ { - index_distribution.DISTRIBUTION_NAME: "BTC", - index_distribution.DISTRIBUTION_VALUE: 68.4, - index_distribution.DISTRIBUTION_PRICE: decimal.Decimal("50000"), + copy_enums.DistributionKeys.NAME: "BTC", + copy_enums.DistributionKeys.VALUE: 68.4, + copy_enums.DistributionKeys.PRICE: decimal.Decimal("50000"), }, { - index_distribution.DISTRIBUTION_NAME: "1", - index_distribution.DISTRIBUTION_VALUE: 6.7, - index_distribution.DISTRIBUTION_PRICE: None, + copy_enums.DistributionKeys.NAME: "1", + copy_enums.DistributionKeys.VALUE: 6.7, + copy_enums.DistributionKeys.PRICE: None, }, { - index_distribution.DISTRIBUTION_NAME: "2", - index_distribution.DISTRIBUTION_VALUE: 0.2, - index_distribution.DISTRIBUTION_PRICE: None, + copy_enums.DistributionKeys.NAME: "2", + copy_enums.DistributionKeys.VALUE: 0.2, + copy_enums.DistributionKeys.PRICE: None, }, { - index_distribution.DISTRIBUTION_NAME: "3", - index_distribution.DISTRIBUTION_VALUE: 24.7, - index_distribution.DISTRIBUTION_PRICE: decimal.Decimal("300"), + copy_enums.DistributionKeys.NAME: "3", + copy_enums.DistributionKeys.VALUE: 24.7, + copy_enums.DistributionKeys.PRICE: decimal.Decimal("300"), } ] @@ -231,24 +232,24 @@ def test_get_smoothed_distribution(): "3": decimal.Decimal(44) }) == [ { - index_distribution.DISTRIBUTION_NAME: "BTC", - index_distribution.DISTRIBUTION_VALUE: 43.1, - index_distribution.DISTRIBUTION_PRICE: None, + copy_enums.DistributionKeys.NAME: "BTC", + copy_enums.DistributionKeys.VALUE: 43.1, + copy_enums.DistributionKeys.PRICE: None, }, { - index_distribution.DISTRIBUTION_NAME: "1", - index_distribution.DISTRIBUTION_VALUE: 19.9, - index_distribution.DISTRIBUTION_PRICE: None, + copy_enums.DistributionKeys.NAME: "1", + copy_enums.DistributionKeys.VALUE: 19.9, + copy_enums.DistributionKeys.PRICE: None, }, { - index_distribution.DISTRIBUTION_NAME: "2", - index_distribution.DISTRIBUTION_VALUE: 6.4, - index_distribution.DISTRIBUTION_PRICE: None, + copy_enums.DistributionKeys.NAME: "2", + copy_enums.DistributionKeys.VALUE: 6.4, + copy_enums.DistributionKeys.PRICE: None, }, { - index_distribution.DISTRIBUTION_NAME: "3", - index_distribution.DISTRIBUTION_VALUE: 30.7, - index_distribution.DISTRIBUTION_PRICE: None, + copy_enums.DistributionKeys.NAME: "3", + copy_enums.DistributionKeys.VALUE: 30.7, + copy_enums.DistributionKeys.PRICE: None, } ] assert index_distribution.get_smoothed_distribution({ @@ -257,19 +258,19 @@ def test_get_smoothed_distribution(): "3": decimal.Decimal(433334) }) == [ { - index_distribution.DISTRIBUTION_NAME: "BTC", - index_distribution.DISTRIBUTION_VALUE: 22.9, - index_distribution.DISTRIBUTION_PRICE: None, + copy_enums.DistributionKeys.NAME: "BTC", + copy_enums.DistributionKeys.VALUE: 22.9, + copy_enums.DistributionKeys.PRICE: None, }, { - index_distribution.DISTRIBUTION_NAME: "1", - index_distribution.DISTRIBUTION_VALUE: 2.3, - index_distribution.DISTRIBUTION_PRICE: None, + copy_enums.DistributionKeys.NAME: "1", + copy_enums.DistributionKeys.VALUE: 2.3, + copy_enums.DistributionKeys.PRICE: None, }, { - index_distribution.DISTRIBUTION_NAME: "3", - index_distribution.DISTRIBUTION_VALUE: 74.9, - index_distribution.DISTRIBUTION_PRICE: None, + copy_enums.DistributionKeys.NAME: "3", + copy_enums.DistributionKeys.VALUE: 74.9, + copy_enums.DistributionKeys.PRICE: None, }, ] # Test when price_by_coin is provided @@ -285,23 +286,23 @@ def test_get_smoothed_distribution(): "3": decimal.Decimal("300") }) == [ { - index_distribution.DISTRIBUTION_NAME: "BTC", - index_distribution.DISTRIBUTION_VALUE: 43.1, - index_distribution.DISTRIBUTION_PRICE: decimal.Decimal("50000"), + copy_enums.DistributionKeys.NAME: "BTC", + copy_enums.DistributionKeys.VALUE: 43.1, + copy_enums.DistributionKeys.PRICE: decimal.Decimal("50000"), }, { - index_distribution.DISTRIBUTION_NAME: "1", - index_distribution.DISTRIBUTION_VALUE: 19.9, - index_distribution.DISTRIBUTION_PRICE: decimal.Decimal("100"), + copy_enums.DistributionKeys.NAME: "1", + copy_enums.DistributionKeys.VALUE: 19.9, + copy_enums.DistributionKeys.PRICE: decimal.Decimal("100"), }, { - index_distribution.DISTRIBUTION_NAME: "2", - index_distribution.DISTRIBUTION_VALUE: 6.4, - index_distribution.DISTRIBUTION_PRICE: decimal.Decimal("200"), + copy_enums.DistributionKeys.NAME: "2", + copy_enums.DistributionKeys.VALUE: 6.4, + copy_enums.DistributionKeys.PRICE: decimal.Decimal("200"), }, { - index_distribution.DISTRIBUTION_NAME: "3", - index_distribution.DISTRIBUTION_VALUE: 30.7, - index_distribution.DISTRIBUTION_PRICE: decimal.Decimal("300"), + copy_enums.DistributionKeys.NAME: "3", + copy_enums.DistributionKeys.VALUE: 30.7, + copy_enums.DistributionKeys.PRICE: decimal.Decimal("300"), } ] diff --git a/packages/tentacles/Trading/Mode/index_trading_mode/tests/test_index_trading_mode.py b/packages/tentacles/Trading/Mode/index_trading_mode/tests/test_index_trading_mode.py index 6bd8d2338..30736b316 100644 --- a/packages/tentacles/Trading/Mode/index_trading_mode/tests/test_index_trading_mode.py +++ b/packages/tentacles/Trading/Mode/index_trading_mode/tests/test_index_trading_mode.py @@ -45,11 +45,14 @@ import octobot_trading.modes import octobot_trading.errors as trading_errors import octobot_trading.signals as trading_signals +import octobot_trading.util as trading_util import tentacles.Trading.Mode as Mode import tentacles.Trading.Mode.index_trading_mode.index_trading as index_trading +import octobot_copy.enums as rebalancer_enums import tentacles.Trading.Mode.index_trading_mode.index_distribution as index_distribution -import tentacles.Trading.Mode.index_trading_mode.rebalancer as rebalancer +import octobot_copy.rebalancing as rebalancer +import octobot_copy.rebalancing.planner.rebalance_actions_planner as rebalance_actions_planner import tests.test_utils.memory_check_util as memory_check_util import tests.test_utils.config as test_utils_config @@ -61,6 +64,10 @@ TRADED_SYMBOLS = ["BTC/USDT", "ETH/USDT", "SOL/USDT", "ADA/USDT"] +def _rebalance_planner_for_tests(producer): + return producer.trading_mode.rebalance_actions_planner + + def _create_position_mock( symbol, trader, @@ -190,7 +197,7 @@ async def test_init_default_values(trading_tools): assert mode.min_order_size_margin == decimal.Decimal("2") assert mode.ratio_per_asset == {'BTC': {'name': 'BTC', 'value': 100.0, 'price': None}} assert mode.total_ratio_per_asset == decimal.Decimal(100) - assert mode.synchronization_policy == index_trading.SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_AS_SOON_AS_POSSIBLE + assert mode.synchronization_policy == rebalancer_enums.SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_AS_SOON_AS_POSSIBLE assert mode.requires_initializing_appropriate_coins_distribution is False assert mode.indexed_coins == ["BTC"] assert mode.selected_rebalance_trigger_profile is None @@ -202,7 +209,7 @@ async def test_init_default_values(trading_tools): async def test_init_config_values(trading_tools): update = { index_trading.IndexTradingModeProducer.REFRESH_INTERVAL: 72, - index_trading.IndexTradingModeProducer.SYNCHRONIZATION_POLICY: index_trading.SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_ON_RATIO_REBALANCE.value, + index_trading.IndexTradingModeProducer.SYNCHRONIZATION_POLICY: rebalancer_enums.SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_ON_RATIO_REBALANCE.value, index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_MIN_PERCENT: 10.2, index_trading.IndexTradingModeProducer.MIN_ORDER_SIZE_MARGIN: 3.5, index_trading.IndexTradingModeProducer.ALLOW_SKIP_ASSET: True, @@ -219,16 +226,16 @@ async def test_init_config_values(trading_tools): ], index_trading.IndexTradingModeProducer.INDEX_CONTENT: [ { - index_distribution.DISTRIBUTION_NAME: "ETH", - index_distribution.DISTRIBUTION_VALUE: 53, + rebalancer_enums.DistributionKeys.NAME: "ETH", + rebalancer_enums.DistributionKeys.VALUE: 53, }, { - index_distribution.DISTRIBUTION_NAME: "BTC", - index_distribution.DISTRIBUTION_VALUE: 1, + rebalancer_enums.DistributionKeys.NAME: "BTC", + rebalancer_enums.DistributionKeys.VALUE: 1, }, { - index_distribution.DISTRIBUTION_NAME: "SOL", - index_distribution.DISTRIBUTION_VALUE: 1, + rebalancer_enums.DistributionKeys.NAME: "SOL", + rebalancer_enums.DistributionKeys.VALUE: 1, }, ] } @@ -249,12 +256,12 @@ async def test_init_config_values(trading_tools): index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILE_MIN_PERCENT: 20.2, }, ] - assert mode.synchronization_policy == index_trading.SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_ON_RATIO_REBALANCE + assert mode.synchronization_policy == rebalancer_enums.SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_ON_RATIO_REBALANCE assert mode.requires_initializing_appropriate_coins_distribution is True assert mode.ratio_per_asset == { "BTC": { - index_distribution.DISTRIBUTION_NAME: "BTC", - index_distribution.DISTRIBUTION_VALUE: 1, + rebalancer_enums.DistributionKeys.NAME: "BTC", + rebalancer_enums.DistributionKeys.VALUE: 1, }, } assert mode.total_ratio_per_asset == decimal.Decimal("1") @@ -285,12 +292,12 @@ async def test_init_config_values(trading_tools): assert mode.rebalance_trigger_min_ratio == decimal.Decimal("0.052") assert mode.ratio_per_asset == { "ETH": { - index_distribution.DISTRIBUTION_NAME: "ETH", - index_distribution.DISTRIBUTION_VALUE: 53, + rebalancer_enums.DistributionKeys.NAME: "ETH", + rebalancer_enums.DistributionKeys.VALUE: 53, }, "BTC": { - index_distribution.DISTRIBUTION_NAME: "BTC", - index_distribution.DISTRIBUTION_VALUE: 1, + rebalancer_enums.DistributionKeys.NAME: "BTC", + rebalancer_enums.DistributionKeys.VALUE: 1, } # SOL is not added } @@ -307,16 +314,16 @@ async def test_init_config_values(trading_tools): assert mode.rebalance_trigger_min_ratio == decimal.Decimal("0.052") assert mode.ratio_per_asset == { "ETH": { - index_distribution.DISTRIBUTION_NAME: "ETH", - index_distribution.DISTRIBUTION_VALUE: 53, + rebalancer_enums.DistributionKeys.NAME: "ETH", + rebalancer_enums.DistributionKeys.VALUE: 53, }, "BTC": { - index_distribution.DISTRIBUTION_NAME: "BTC", - index_distribution.DISTRIBUTION_VALUE: 1, + rebalancer_enums.DistributionKeys.NAME: "BTC", + rebalancer_enums.DistributionKeys.VALUE: 1, }, "SOL": { - index_distribution.DISTRIBUTION_NAME: "SOL", - index_distribution.DISTRIBUTION_VALUE: 1, + rebalancer_enums.DistributionKeys.NAME: "SOL", + rebalancer_enums.DistributionKeys.VALUE: 1, }, } assert mode.total_ratio_per_asset == decimal.Decimal("55") @@ -325,12 +332,12 @@ async def test_init_config_values(trading_tools): # add ref market in coin rations mode.trading_config["index_content"] = [ { - index_distribution.DISTRIBUTION_NAME: "USDT", - index_distribution.DISTRIBUTION_VALUE: 75, + rebalancer_enums.DistributionKeys.NAME: "USDT", + rebalancer_enums.DistributionKeys.VALUE: 75, }, { - index_distribution.DISTRIBUTION_NAME: "BTC", - index_distribution.DISTRIBUTION_VALUE: 25, + rebalancer_enums.DistributionKeys.NAME: "BTC", + rebalancer_enums.DistributionKeys.VALUE: 25, }, ] # select profile 2 @@ -344,12 +351,12 @@ async def test_init_config_values(trading_tools): assert mode.rebalance_trigger_min_ratio == decimal.Decimal("0.202") assert mode.ratio_per_asset == { "BTC": { - index_distribution.DISTRIBUTION_NAME: "BTC", - index_distribution.DISTRIBUTION_VALUE: 25, + rebalancer_enums.DistributionKeys.NAME: "BTC", + rebalancer_enums.DistributionKeys.VALUE: 25, }, "USDT": { - index_distribution.DISTRIBUTION_NAME: "USDT", - index_distribution.DISTRIBUTION_VALUE: 75, + rebalancer_enums.DistributionKeys.NAME: "USDT", + rebalancer_enums.DistributionKeys.VALUE: 75, }, } assert mode.total_ratio_per_asset == decimal.Decimal("100") @@ -376,7 +383,7 @@ async def test_init_config_values(trading_tools): mode.trading_config[index_trading.IndexTradingModeProducer.SYNCHRONIZATION_POLICY] = "invalid_policy" mode.init_user_inputs({}) # does no raise error # use current or default value - assert mode.synchronization_policy == index_trading.SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_ON_RATIO_REBALANCE + assert mode.synchronization_policy == rebalancer_enums.SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_ON_RATIO_REBALANCE @pytest.mark.parametrize("trading_tools", ["spot", "futures"], indirect=True) @@ -439,12 +446,12 @@ async def test_get_target_ratio_with_config(trading_tools): "rebalance_trigger_min_percent": 10.2, "index_content": [ { - index_distribution.DISTRIBUTION_NAME: "BTC", - index_distribution.DISTRIBUTION_VALUE: 1, + rebalancer_enums.DistributionKeys.NAME: "BTC", + rebalancer_enums.DistributionKeys.VALUE: 1, }, { - index_distribution.DISTRIBUTION_NAME: "ETH", - index_distribution.DISTRIBUTION_VALUE: 53, + rebalancer_enums.DistributionKeys.NAME: "ETH", + rebalancer_enums.DistributionKeys.VALUE: 53, }, ] } @@ -754,7 +761,7 @@ async def test_get_rebalance_details(trading_tools): portfolio_value_holder = trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio_value_holder positions_manager = trader.exchange_manager.exchange_personal_data.positions_manager - with mock.patch.object(producer, "_resolve_swaps", mock.Mock()) as _resolve_swaps_mock: + with mock.patch.object(mode.rebalance_actions_planner, "_resolve_swaps", mock.Mock()) as _resolve_swaps_mock: def _get_holdings_ratio(coin, **kwargs): if coin == "USDT": return decimal.Decimal("0") @@ -785,17 +792,17 @@ def _get_symbol_position(symbol, side=None): portfolio_value_holder, "get_traded_assets_holdings_value", mock.Mock(return_value=total_portfolio_value) ) as get_traded_assets_holdings_value_mock: with mock.patch.object( - mode, "get_removed_coins_from_config", mock.Mock(return_value=[]) + mode.rebalance_actions_planner, "get_removed_coins_from_config", mock.Mock(return_value=[]) ) as get_removed_coins_from_config_mock: should_rebalance, details = producer._get_rebalance_details() assert should_rebalance is False assert details == { - index_trading.RebalanceDetails.SELL_SOME.value: {}, - index_trading.RebalanceDetails.BUY_MORE.value: {}, - index_trading.RebalanceDetails.REMOVE.value: {}, - index_trading.RebalanceDetails.ADD.value: {}, - index_trading.RebalanceDetails.SWAP.value: {}, - index_trading.RebalanceDetails.FORCED_REBALANCE.value: False, + rebalancer_enums.RebalanceDetails.SELL_SOME.value: {}, + rebalancer_enums.RebalanceDetails.BUY_MORE.value: {}, + rebalancer_enums.RebalanceDetails.REMOVE.value: {}, + rebalancer_enums.RebalanceDetails.ADD.value: {}, + rebalancer_enums.RebalanceDetails.SWAP.value: {}, + rebalancer_enums.RebalanceDetails.FORCED_REBALANCE.value: False, } assert get_holdings_ratio_mock.call_count == len(mode.indexed_coins) + 1 # +1 for USDT get_removed_coins_from_config_mock.assert_called_once() @@ -805,23 +812,23 @@ def _get_symbol_position(symbol, side=None): get_symbol_position_mock.reset_mock() get_traded_assets_holdings_value_mock.reset_mock() with mock.patch.object( - mode, "get_removed_coins_from_config", mock.Mock(return_value=["SOL", "ADA"]) + mode.rebalance_actions_planner, "get_removed_coins_from_config", mock.Mock(return_value=["SOL", "ADA"]) ) as get_removed_coins_from_config_mock: should_rebalance, details = producer._get_rebalance_details() assert should_rebalance is True assert details == { - index_trading.RebalanceDetails.SELL_SOME.value: {}, - index_trading.RebalanceDetails.BUY_MORE.value: {}, - index_trading.RebalanceDetails.REMOVE.value: { + rebalancer_enums.RebalanceDetails.SELL_SOME.value: {}, + rebalancer_enums.RebalanceDetails.BUY_MORE.value: {}, + rebalancer_enums.RebalanceDetails.REMOVE.value: { "SOL": decimal.Decimal("0.3"), # "ADA": decimal.Decimal("0.3") # ADA is not in traded pairs, it's not removed }, - index_trading.RebalanceDetails.ADD.value: {}, - index_trading.RebalanceDetails.SWAP.value: {}, - index_trading.RebalanceDetails.FORCED_REBALANCE.value: False, + rebalancer_enums.RebalanceDetails.ADD.value: {}, + rebalancer_enums.RebalanceDetails.SWAP.value: {}, + rebalancer_enums.RebalanceDetails.FORCED_REBALANCE.value: False, } assert get_holdings_ratio_mock.call_count == \ - len(mode.indexed_coins) + len(details[index_trading.RebalanceDetails.REMOVE.value]) + 1 # +1 for USDT + len(mode.indexed_coins) + len(details[rebalancer_enums.RebalanceDetails.REMOVE.value]) + 1 # +1 for USDT get_removed_coins_from_config_mock.assert_called_once() _resolve_swaps_mock.assert_called_once_with(details) _resolve_swaps_mock.reset_mock() @@ -858,21 +865,21 @@ def _get_symbol_position(symbol, side=None): portfolio_value_holder, "get_traded_assets_holdings_value", mock.Mock(return_value=total_portfolio_value_2) ) as get_traded_assets_holdings_value_mock_2: with mock.patch.object( - mode, "get_removed_coins_from_config", mock.Mock(return_value=[]) + mode.rebalance_actions_planner, "get_removed_coins_from_config", mock.Mock(return_value=[]) ) as get_removed_coins_from_config_mock: should_rebalance, details = producer._get_rebalance_details() assert should_rebalance is True assert details == { - index_trading.RebalanceDetails.SELL_SOME.value: {}, - index_trading.RebalanceDetails.BUY_MORE.value: { + rebalancer_enums.RebalanceDetails.SELL_SOME.value: {}, + rebalancer_enums.RebalanceDetails.BUY_MORE.value: { 'BTC': decimal.Decimal('0.3333333333333333617834929233'), 'ETH': decimal.Decimal('0.3333333333333333617834929233'), 'SOL': decimal.Decimal('0.3333333333333333617834929233') }, - index_trading.RebalanceDetails.REMOVE.value: {}, - index_trading.RebalanceDetails.ADD.value: {}, - index_trading.RebalanceDetails.SWAP.value: {}, - index_trading.RebalanceDetails.FORCED_REBALANCE.value: False, + rebalancer_enums.RebalanceDetails.REMOVE.value: {}, + rebalancer_enums.RebalanceDetails.ADD.value: {}, + rebalancer_enums.RebalanceDetails.SWAP.value: {}, + rebalancer_enums.RebalanceDetails.FORCED_REBALANCE.value: False, } assert get_holdings_ratio_mock.call_count == len(mode.indexed_coins) + 1 # +1 for USDT get_removed_coins_from_config_mock.assert_called_once() @@ -882,27 +889,27 @@ def _get_symbol_position(symbol, side=None): get_symbol_position_mock.reset_mock() get_traded_assets_holdings_value_mock_2.reset_mock() with mock.patch.object( - mode, "get_removed_coins_from_config", mock.Mock(return_value=["SOL", "ADA"]) + mode.rebalance_actions_planner, "get_removed_coins_from_config", mock.Mock(return_value=["SOL", "ADA"]) ) as get_removed_coins_from_config_mock: should_rebalance, details = producer._get_rebalance_details() assert should_rebalance is True assert details == { - index_trading.RebalanceDetails.SELL_SOME.value: {}, - index_trading.RebalanceDetails.BUY_MORE.value: { + rebalancer_enums.RebalanceDetails.SELL_SOME.value: {}, + rebalancer_enums.RebalanceDetails.BUY_MORE.value: { 'BTC': decimal.Decimal('0.3333333333333333617834929233'), 'ETH': decimal.Decimal('0.3333333333333333617834929233'), 'SOL': decimal.Decimal('0.3333333333333333617834929233') }, - index_trading.RebalanceDetails.REMOVE.value: { + rebalancer_enums.RebalanceDetails.REMOVE.value: { "SOL": decimal.Decimal("0.2"), # "ADA": decimal.Decimal("0.2") # not in traded pairs }, - index_trading.RebalanceDetails.ADD.value: {}, - index_trading.RebalanceDetails.SWAP.value: {}, - index_trading.RebalanceDetails.FORCED_REBALANCE.value: False, + rebalancer_enums.RebalanceDetails.ADD.value: {}, + rebalancer_enums.RebalanceDetails.SWAP.value: {}, + rebalancer_enums.RebalanceDetails.FORCED_REBALANCE.value: False, } assert get_holdings_ratio_mock.call_count == \ - len(mode.indexed_coins) + len(details[index_trading.RebalanceDetails.REMOVE.value]) + 1 # +1 for USDT + len(mode.indexed_coins) + len(details[rebalancer_enums.RebalanceDetails.REMOVE.value]) + 1 # +1 for USDT get_removed_coins_from_config_mock.assert_called_once() _resolve_swaps_mock.assert_called_once_with(details) _resolve_swaps_mock.reset_mock() @@ -943,12 +950,12 @@ def _get_symbol_position(symbol, side=None): should_rebalance, details = producer._get_rebalance_details() assert should_rebalance is False assert details == { - index_trading.RebalanceDetails.SELL_SOME.value: {}, - index_trading.RebalanceDetails.BUY_MORE.value: {}, - index_trading.RebalanceDetails.REMOVE.value: {}, - index_trading.RebalanceDetails.ADD.value: {}, - index_trading.RebalanceDetails.SWAP.value: {}, - index_trading.RebalanceDetails.FORCED_REBALANCE.value: False, + rebalancer_enums.RebalanceDetails.SELL_SOME.value: {}, + rebalancer_enums.RebalanceDetails.BUY_MORE.value: {}, + rebalancer_enums.RebalanceDetails.REMOVE.value: {}, + rebalancer_enums.RebalanceDetails.ADD.value: {}, + rebalancer_enums.RebalanceDetails.SWAP.value: {}, + rebalancer_enums.RebalanceDetails.FORCED_REBALANCE.value: False, } assert get_holdings_ratio_mock.call_count == len(mode.indexed_coins) + 1 # +1 for USDT get_holdings_ratio_mock.reset_mock() @@ -988,12 +995,12 @@ def _get_symbol_position_small(symbol, side=None): should_rebalance, details = producer._get_rebalance_details() assert should_rebalance is False assert details == { - index_trading.RebalanceDetails.SELL_SOME.value: {}, - index_trading.RebalanceDetails.BUY_MORE.value: {}, - index_trading.RebalanceDetails.REMOVE.value: {}, - index_trading.RebalanceDetails.ADD.value: {}, - index_trading.RebalanceDetails.SWAP.value: {}, - index_trading.RebalanceDetails.FORCED_REBALANCE.value: False, + rebalancer_enums.RebalanceDetails.SELL_SOME.value: {}, + rebalancer_enums.RebalanceDetails.BUY_MORE.value: {}, + rebalancer_enums.RebalanceDetails.REMOVE.value: {}, + rebalancer_enums.RebalanceDetails.ADD.value: {}, + rebalancer_enums.RebalanceDetails.SWAP.value: {}, + rebalancer_enums.RebalanceDetails.FORCED_REBALANCE.value: False, } assert get_holdings_ratio_mock.call_count == len(mode.indexed_coins) + 1 # +1 for USDT get_holdings_ratio_mock.reset_mock() @@ -1033,18 +1040,18 @@ def _get_symbol_position(symbol, side=None): should_rebalance, details = producer._get_rebalance_details() assert should_rebalance is True assert details == { - index_trading.RebalanceDetails.SELL_SOME.value: { + rebalancer_enums.RebalanceDetails.SELL_SOME.value: { 'BTC': decimal.Decimal('0.3333333333333333617834929233'), 'ETH': decimal.Decimal('0.3333333333333333617834929233'), 'SOL': decimal.Decimal('0.3333333333333333617834929233') }, - index_trading.RebalanceDetails.BUY_MORE.value: {}, - index_trading.RebalanceDetails.REMOVE.value: {}, - index_trading.RebalanceDetails.ADD.value: {}, - index_trading.RebalanceDetails.SWAP.value: {}, - index_trading.RebalanceDetails.FORCED_REBALANCE.value: False, + rebalancer_enums.RebalanceDetails.BUY_MORE.value: {}, + rebalancer_enums.RebalanceDetails.REMOVE.value: {}, + rebalancer_enums.RebalanceDetails.ADD.value: {}, + rebalancer_enums.RebalanceDetails.SWAP.value: {}, + rebalancer_enums.RebalanceDetails.FORCED_REBALANCE.value: False, } - assert get_holdings_ratio_mock.call_count == len(details[index_trading.RebalanceDetails.SELL_SOME.value]) + 1 # +1 for USDT + assert get_holdings_ratio_mock.call_count == len(details[rebalancer_enums.RebalanceDetails.SELL_SOME.value]) + 1 # +1 for USDT get_holdings_ratio_mock.reset_mock() get_symbol_position_mock.reset_mock() get_traded_assets_holdings_value_mock.reset_mock() @@ -1072,18 +1079,18 @@ def _get_symbol_position(symbol, side=None): should_rebalance, details = producer._get_rebalance_details() assert should_rebalance is True assert details == { - index_trading.RebalanceDetails.SELL_SOME.value: {}, - index_trading.RebalanceDetails.BUY_MORE.value: {}, - index_trading.RebalanceDetails.REMOVE.value: {}, - index_trading.RebalanceDetails.ADD.value: { + rebalancer_enums.RebalanceDetails.SELL_SOME.value: {}, + rebalancer_enums.RebalanceDetails.BUY_MORE.value: {}, + rebalancer_enums.RebalanceDetails.REMOVE.value: {}, + rebalancer_enums.RebalanceDetails.ADD.value: { 'BTC': decimal.Decimal('0.3333333333333333617834929233'), 'ETH': decimal.Decimal('0.3333333333333333617834929233'), 'SOL': decimal.Decimal('0.3333333333333333617834929233') }, - index_trading.RebalanceDetails.SWAP.value: {}, - index_trading.RebalanceDetails.FORCED_REBALANCE.value: False, + rebalancer_enums.RebalanceDetails.SWAP.value: {}, + rebalancer_enums.RebalanceDetails.FORCED_REBALANCE.value: False, } - assert get_holdings_ratio_mock.call_count == len(details[index_trading.RebalanceDetails.ADD.value]) + 1 # +1 for USDT + assert get_holdings_ratio_mock.call_count == len(details[rebalancer_enums.RebalanceDetails.ADD.value]) + 1 # +1 for USDT get_traded_assets_holdings_value_mock.reset_mock() get_holdings_ratio_mock.reset_mock() get_symbol_position_mock.reset_mock() @@ -1131,14 +1138,14 @@ def _get_symbol_position(symbol, side=None): should_rebalance, details = producer._get_rebalance_details() assert should_rebalance is True assert details == { - index_trading.RebalanceDetails.SELL_SOME.value: {}, - index_trading.RebalanceDetails.BUY_MORE.value: {}, - index_trading.RebalanceDetails.REMOVE.value: {}, - index_trading.RebalanceDetails.ADD.value: { + rebalancer_enums.RebalanceDetails.SELL_SOME.value: {}, + rebalancer_enums.RebalanceDetails.BUY_MORE.value: {}, + rebalancer_enums.RebalanceDetails.REMOVE.value: {}, + rebalancer_enums.RebalanceDetails.ADD.value: { 'ETH': decimal.Decimal('0.3333333333333333617834929233'), }, - index_trading.RebalanceDetails.SWAP.value: {}, - index_trading.RebalanceDetails.FORCED_REBALANCE.value: False, + rebalancer_enums.RebalanceDetails.SWAP.value: {}, + rebalancer_enums.RebalanceDetails.FORCED_REBALANCE.value: False, } assert get_holdings_ratio_mock.call_count == 3 + 1 # called for each coin + 1 for USDT get_holdings_ratio_mock.reset_mock() @@ -1157,11 +1164,11 @@ async def test_get_rebalance_details_with_usdt_without_coin_distribution_update( ] mode.ensure_updated_coins_distribution() mode.rebalance_trigger_min_ratio = decimal.Decimal("0.1") - mode.synchronization_policy = index_trading.SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_AS_SOON_AS_POSSIBLE + mode.synchronization_policy = rebalancer_enums.SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_AS_SOON_AS_POSSIBLE is_futures = trader.exchange_manager.is_future portfolio_value_holder = trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio_value_holder - with mock.patch.object(producer, "_resolve_swaps", mock.Mock()) as _resolve_swaps_mock, \ + with mock.patch.object(mode.rebalance_actions_planner, "_resolve_swaps", mock.Mock()) as _resolve_swaps_mock, \ mock.patch.object(mode, "ensure_updated_coins_distribution", mock.Mock()) as ensure_updated_coins_distribution_mock: def _get_holdings_ratio(coin, **kwargs): # USDT is 1/3 of the portfolio @@ -1190,16 +1197,16 @@ def _get_symbol_position(symbol, side=None): return position_mock expected_details = { - index_trading.RebalanceDetails.SELL_SOME.value: {}, - index_trading.RebalanceDetails.BUY_MORE.value: { + rebalancer_enums.RebalanceDetails.SELL_SOME.value: {}, + rebalancer_enums.RebalanceDetails.BUY_MORE.value: { 'BTC': decimal.Decimal('0.3333333333333333617834929233'), 'ETH': decimal.Decimal('0.3333333333333333617834929233'), 'SOL': decimal.Decimal('0.3333333333333333617834929233') }, - index_trading.RebalanceDetails.REMOVE.value: {}, - index_trading.RebalanceDetails.ADD.value: {}, - index_trading.RebalanceDetails.SWAP.value: {}, - index_trading.RebalanceDetails.FORCED_REBALANCE.value: True, + rebalancer_enums.RebalanceDetails.REMOVE.value: {}, + rebalancer_enums.RebalanceDetails.ADD.value: {}, + rebalancer_enums.RebalanceDetails.SWAP.value: {}, + rebalancer_enums.RebalanceDetails.FORCED_REBALANCE.value: True, } positions_manager = trader.exchange_manager.exchange_personal_data.positions_manager @@ -1237,9 +1244,12 @@ async def test_get_rebalance_details_with_usdt_and_coin_distribution_update(trad is_futures = trader.exchange_manager.is_future portfolio_value_holder = trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio_value_holder positions_manager = trader.exchange_manager.exchange_personal_data.positions_manager - mode.synchronization_policy = index_trading.SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_ON_RATIO_REBALANCE - with mock.patch.object(producer, "_resolve_swaps", mock.Mock()) as _resolve_swaps_mock, \ - mock.patch.object(mode, "ensure_updated_coins_distribution", mock.Mock()) as ensure_updated_coins_distribution_mock: + mode.synchronization_policy = rebalancer_enums.SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_ON_RATIO_REBALANCE + _planner = mode.rebalance_actions_planner + with mock.patch.object(_planner, "_resolve_swaps", mock.Mock()) as _resolve_swaps_mock, \ + mock.patch.object( + _planner, "update_distribution", mock.Mock(wraps=_planner.update_distribution) + ) as update_distribution_mock: def _get_holdings_ratio(coin, **kwargs): # USDT is 1/3 of the portfolio if coin == "USDT": @@ -1279,19 +1289,19 @@ def _get_symbol_position(symbol, side=None): should_rebalance, details = producer._get_rebalance_details() assert should_rebalance is True assert details == { - index_trading.RebalanceDetails.SELL_SOME.value: {}, - index_trading.RebalanceDetails.BUY_MORE.value: { + rebalancer_enums.RebalanceDetails.SELL_SOME.value: {}, + rebalancer_enums.RebalanceDetails.BUY_MORE.value: { 'BTC': decimal.Decimal('0.3333333333333333617834929233'), 'ETH': decimal.Decimal('0.3333333333333333617834929233'), 'SOL': decimal.Decimal('0.3333333333333333617834929233') }, - index_trading.RebalanceDetails.REMOVE.value: {}, - index_trading.RebalanceDetails.ADD.value: {}, - index_trading.RebalanceDetails.SWAP.value: {}, - index_trading.RebalanceDetails.FORCED_REBALANCE.value: True, + rebalancer_enums.RebalanceDetails.REMOVE.value: {}, + rebalancer_enums.RebalanceDetails.ADD.value: {}, + rebalancer_enums.RebalanceDetails.SWAP.value: {}, + rebalancer_enums.RebalanceDetails.FORCED_REBALANCE.value: True, } - assert get_holdings_ratio_mock.call_count == 2 * (len(mode.indexed_coins) + 1) - ensure_updated_coins_distribution_mock.assert_called_once() + assert get_holdings_ratio_mock.call_count == 2 * (len(mode.indexed_coins) + 1) + update_distribution_mock.assert_called_once_with(force_latest=True) get_holdings_ratio_mock.reset_mock() get_symbol_position_mock.reset_mock() get_traded_assets_holdings_value_mock.reset_mock() @@ -1300,63 +1310,64 @@ def _get_symbol_position(symbol, side=None): @pytest.mark.parametrize("trading_tools", ["spot", "futures"], indirect=True) -async def test_should_rebalance_due_to_non_indexed_quote_assets_ratio(trading_tools): +async def test_should_rebalance_due_to_non_targeted_quote_assets_ratio(trading_tools): update = {} mode, producer, consumer, trader = await _init_mode(trading_tools, _get_config(trading_tools, update)) assert mode.quote_asset_rebalance_ratio_threshold == decimal.Decimal("0.1") rebalance_details = { - index_trading.RebalanceDetails.SELL_SOME.value: {}, - index_trading.RebalanceDetails.BUY_MORE.value: {}, - index_trading.RebalanceDetails.REMOVE.value: {}, - index_trading.RebalanceDetails.ADD.value: {}, - index_trading.RebalanceDetails.SWAP.value: {}, - index_trading.RebalanceDetails.FORCED_REBALANCE.value: False, + rebalancer_enums.RebalanceDetails.SELL_SOME.value: {}, + rebalancer_enums.RebalanceDetails.BUY_MORE.value: {}, + rebalancer_enums.RebalanceDetails.REMOVE.value: {}, + rebalancer_enums.RebalanceDetails.ADD.value: {}, + rebalancer_enums.RebalanceDetails.SWAP.value: {}, + rebalancer_enums.RebalanceDetails.FORCED_REBALANCE.value: False, } - assert producer._should_rebalance_due_to_non_indexed_quote_assets_ratio(decimal.Decimal("0.23"), rebalance_details) is True - assert producer._should_rebalance_due_to_non_indexed_quote_assets_ratio(decimal.Decimal("0.1"), rebalance_details) is True - assert producer._should_rebalance_due_to_non_indexed_quote_assets_ratio(decimal.Decimal("0.09"), rebalance_details) is False + assert _rebalance_planner_for_tests(producer)._should_rebalance_due_to_non_targeted_quote_assets_ratio(decimal.Decimal("0.23"), rebalance_details) is True + assert _rebalance_planner_for_tests(producer)._should_rebalance_due_to_non_targeted_quote_assets_ratio(decimal.Decimal("0.1"), rebalance_details) is True + assert _rebalance_planner_for_tests(producer)._should_rebalance_due_to_non_targeted_quote_assets_ratio(decimal.Decimal("0.09"), rebalance_details) is False # lower threshold mode.quote_asset_rebalance_ratio_threshold = decimal.Decimal("0.05") - assert producer._should_rebalance_due_to_non_indexed_quote_assets_ratio(decimal.Decimal("0.09"), rebalance_details) is True - assert producer._should_rebalance_due_to_non_indexed_quote_assets_ratio(decimal.Decimal("0.04"), rebalance_details) is False + mode._sync_rebalance_planner() + assert _rebalance_planner_for_tests(producer)._should_rebalance_due_to_non_targeted_quote_assets_ratio(decimal.Decimal("0.09"), rebalance_details) is True + assert _rebalance_planner_for_tests(producer)._should_rebalance_due_to_non_targeted_quote_assets_ratio(decimal.Decimal("0.04"), rebalance_details) is False # test added coins - rebalance_details[index_trading.RebalanceDetails.ADD.value] = { + rebalance_details[rebalancer_enums.RebalanceDetails.ADD.value] = { "BTC": decimal.Decimal("0.1") } - rebalance_details[index_trading.RebalanceDetails.BUY_MORE.value] = { + rebalance_details[rebalancer_enums.RebalanceDetails.BUY_MORE.value] = { "ETH": decimal.Decimal("0.1") } # can't swap quote for BTC & ETH - assert producer._should_rebalance_due_to_non_indexed_quote_assets_ratio(decimal.Decimal("0.1"), rebalance_details) is True + assert _rebalance_planner_for_tests(producer)._should_rebalance_due_to_non_targeted_quote_assets_ratio(decimal.Decimal("0.1"), rebalance_details) is True # can swap quote for BTC & ETH: don't rebalance - assert producer._should_rebalance_due_to_non_indexed_quote_assets_ratio(decimal.Decimal("0.2"), rebalance_details) is False - assert producer._should_rebalance_due_to_non_indexed_quote_assets_ratio(decimal.Decimal("0.21"), rebalance_details) is False - assert producer._should_rebalance_due_to_non_indexed_quote_assets_ratio(decimal.Decimal("0.18"), rebalance_details) is False - # beyond QUOTE_ASSET_TO_INDEXED_SWAP_RATIO_THRESHOLD threshold - assert producer._should_rebalance_due_to_non_indexed_quote_assets_ratio(decimal.Decimal("0.17"), rebalance_details) is True + assert _rebalance_planner_for_tests(producer)._should_rebalance_due_to_non_targeted_quote_assets_ratio(decimal.Decimal("0.2"), rebalance_details) is False + assert _rebalance_planner_for_tests(producer)._should_rebalance_due_to_non_targeted_quote_assets_ratio(decimal.Decimal("0.21"), rebalance_details) is False + assert _rebalance_planner_for_tests(producer)._should_rebalance_due_to_non_targeted_quote_assets_ratio(decimal.Decimal("0.18"), rebalance_details) is False + # beyond QUOTE_ASSET_TO_TARGETED_SWAP_RATIO_THRESHOLD threshold + assert _rebalance_planner_for_tests(producer)._should_rebalance_due_to_non_targeted_quote_assets_ratio(decimal.Decimal("0.17"), rebalance_details) is True # with removed coins: can't "just swap quote for added coins", perform regular quote ratio check - rebalance_details[index_trading.RebalanceDetails.REMOVE.value] = { + rebalance_details[rebalancer_enums.RebalanceDetails.REMOVE.value] = { "BTC": decimal.Decimal("0.1") } - assert producer._should_rebalance_due_to_non_indexed_quote_assets_ratio(decimal.Decimal("0.2"), rebalance_details) is True # is False when no coins are to remove - assert producer._should_rebalance_due_to_non_indexed_quote_assets_ratio(decimal.Decimal("0.03"), rebalance_details) is False # bellow threshold: still false + assert _rebalance_planner_for_tests(producer)._should_rebalance_due_to_non_targeted_quote_assets_ratio(decimal.Decimal("0.2"), rebalance_details) is True # is False when no coins are to remove + assert _rebalance_planner_for_tests(producer)._should_rebalance_due_to_non_targeted_quote_assets_ratio(decimal.Decimal("0.03"), rebalance_details) is False # bellow threshold: still false # with sell some coins and removed coins: can't "just swap quote for added coins", perform regular quote ratio check - rebalance_details[index_trading.RebalanceDetails.SELL_SOME.value] = { + rebalance_details[rebalancer_enums.RebalanceDetails.SELL_SOME.value] = { "BTC": decimal.Decimal("0.1") } - assert producer._should_rebalance_due_to_non_indexed_quote_assets_ratio(decimal.Decimal("0.2"), rebalance_details) is True # is False when no coins are to remove - assert producer._should_rebalance_due_to_non_indexed_quote_assets_ratio(decimal.Decimal("0.03"), rebalance_details) is False # bellow threshold: still false + assert _rebalance_planner_for_tests(producer)._should_rebalance_due_to_non_targeted_quote_assets_ratio(decimal.Decimal("0.2"), rebalance_details) is True # is False when no coins are to remove + assert _rebalance_planner_for_tests(producer)._should_rebalance_due_to_non_targeted_quote_assets_ratio(decimal.Decimal("0.03"), rebalance_details) is False # bellow threshold: still false # with only sell some coin - rebalance_details[index_trading.RebalanceDetails.REMOVE.value] = {} - assert producer._should_rebalance_due_to_non_indexed_quote_assets_ratio(decimal.Decimal("0.2"), rebalance_details) is True # is False when no coins are to remove - assert producer._should_rebalance_due_to_non_indexed_quote_assets_ratio(decimal.Decimal("0.03"), rebalance_details) is False # bellow threshold: still false + rebalance_details[rebalancer_enums.RebalanceDetails.REMOVE.value] = {} + assert _rebalance_planner_for_tests(producer)._should_rebalance_due_to_non_targeted_quote_assets_ratio(decimal.Decimal("0.2"), rebalance_details) is True # is False when no coins are to remove + assert _rebalance_planner_for_tests(producer)._should_rebalance_due_to_non_targeted_quote_assets_ratio(decimal.Decimal("0.03"), rebalance_details) is False # bellow threshold: still false @pytest.mark.parametrize("trading_tools", ["spot", "futures"], indirect=True) -async def test_get_non_indexed_quote_assets_ratio_with_reference_market_ratio(trading_tools): +async def test_get_non_targeted_quote_assets_ratio_with_reference_market_ratio(trading_tools): update = {} mode, producer, consumer, trader = await _init_mode(trading_tools, _get_config(trading_tools, update)) ref_market = trader.exchange_manager.exchange_personal_data.portfolio_manager.reference_market @@ -1376,68 +1387,75 @@ def _get_holdings_ratio_usdt_92(coin, **kwargs): with mock.patch.object(portfolio_value_holder, "get_holdings_ratio", mock.Mock(side_effect=_get_holdings_ratio_usdt_15)): mode.reference_market_ratio = trading_constants.ZERO - assert producer._get_non_indexed_quote_assets_ratio() == decimal.Decimal("0.15") + mode._sync_rebalance_planner() + assert _rebalance_planner_for_tests(producer)._get_non_targeted_quote_assets_ratio() == decimal.Decimal("0.15") with mock.patch.object(portfolio_value_holder, "get_holdings_ratio", mock.Mock(side_effect=_get_holdings_ratio_usdt_15)): mode.reference_market_ratio = decimal.Decimal("0.1") + mode._sync_rebalance_planner() # reference_market_ratio=10% means 90% should be kept, so excess = 15% - 90% = -75% = 0 - assert producer._get_non_indexed_quote_assets_ratio() == decimal.Decimal("0") + assert _rebalance_planner_for_tests(producer)._get_non_targeted_quote_assets_ratio() == decimal.Decimal("0") with mock.patch.object(portfolio_value_holder, "get_holdings_ratio", mock.Mock(side_effect=_get_holdings_ratio_usdt_08)): mode.reference_market_ratio = decimal.Decimal("0.1") + mode._sync_rebalance_planner() # reference_market_ratio=10% means 90% should be kept, so excess = 8% - 90% = -82% = 0 - assert producer._get_non_indexed_quote_assets_ratio() == decimal.Decimal("0") + assert _rebalance_planner_for_tests(producer)._get_non_targeted_quote_assets_ratio() == decimal.Decimal("0") with mock.patch.object(portfolio_value_holder, "get_holdings_ratio", mock.Mock(side_effect=_get_holdings_ratio_usdt_95)): mode.reference_market_ratio = decimal.Decimal("0.1") + mode._sync_rebalance_planner() # reference_market_ratio=10% means 90% should be kept, so excess = 95% - 90% = 5% - assert producer._get_non_indexed_quote_assets_ratio() == decimal.Decimal("0.05") + assert _rebalance_planner_for_tests(producer)._get_non_targeted_quote_assets_ratio() == decimal.Decimal("0.05") with mock.patch.object(portfolio_value_holder, "get_holdings_ratio", mock.Mock(side_effect=_get_holdings_ratio_usdt_92)): mode.reference_market_ratio = decimal.Decimal("0.1") + mode._sync_rebalance_planner() # reference_market_ratio=10% means 90% should be kept, so excess = 92% - 90% = 2% - assert producer._get_non_indexed_quote_assets_ratio() == decimal.Decimal("0.02") + assert _rebalance_planner_for_tests(producer)._get_non_targeted_quote_assets_ratio() == decimal.Decimal("0.02") rebalance_details = { - index_trading.RebalanceDetails.SELL_SOME.value: {}, - index_trading.RebalanceDetails.BUY_MORE.value: {}, - index_trading.RebalanceDetails.REMOVE.value: {}, - index_trading.RebalanceDetails.ADD.value: {}, - index_trading.RebalanceDetails.SWAP.value: {}, - index_trading.RebalanceDetails.FORCED_REBALANCE.value: False, + rebalancer_enums.RebalanceDetails.SELL_SOME.value: {}, + rebalancer_enums.RebalanceDetails.BUY_MORE.value: {}, + rebalancer_enums.RebalanceDetails.REMOVE.value: {}, + rebalancer_enums.RebalanceDetails.ADD.value: {}, + rebalancer_enums.RebalanceDetails.SWAP.value: {}, + rebalancer_enums.RebalanceDetails.FORCED_REBALANCE.value: False, } # USDT=15%, threshold 10%: without reference_market_ratio -> 15% >= 10% -> forces rebalance with mock.patch.object(portfolio_value_holder, "get_holdings_ratio", mock.Mock(side_effect=_get_holdings_ratio_usdt_15)): mode.reference_market_ratio = trading_constants.ZERO mode.quote_asset_rebalance_ratio_threshold = decimal.Decimal("0.1") + mode._sync_rebalance_planner() details = {k: ({}.copy() if isinstance(v, dict) else v) for k, v in rebalance_details.items()} - assert producer._register_quote_asset_rebalance(details) is True - assert details[index_trading.RebalanceDetails.FORCED_REBALANCE.value] is True + assert _rebalance_planner_for_tests(producer)._register_quote_asset_rebalance(details) is True + assert details[rebalancer_enums.RebalanceDetails.FORCED_REBALANCE.value] is True # reference_market_ratio=10% means 90% should be kept, USDT=15% -> excess = 15% - 90% = -75% = 0; threshold 10% -> no forced rebalance with mock.patch.object(portfolio_value_holder, "get_holdings_ratio", mock.Mock(side_effect=_get_holdings_ratio_usdt_15)): mode.reference_market_ratio = decimal.Decimal("0.1") mode.quote_asset_rebalance_ratio_threshold = decimal.Decimal("0.1") + mode._sync_rebalance_planner() details = {k: ({}.copy() if isinstance(v, dict) else v) for k, v in rebalance_details.items()} - assert producer._register_quote_asset_rebalance(details) is False - assert details[index_trading.RebalanceDetails.FORCED_REBALANCE.value] is False + assert _rebalance_planner_for_tests(producer)._register_quote_asset_rebalance(details) is False + assert details[rebalancer_enums.RebalanceDetails.FORCED_REBALANCE.value] is False @pytest.mark.parametrize("trading_tools", ["spot", "futures"], indirect=True) async def test_get_removed_coins_from_config_sell_removed_coins_asap(trading_tools): update = {} mode, producer, consumer, trader = await _init_mode(trading_tools, _get_config(trading_tools, update)) - mode.synchronization_policy = index_trading.SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_AS_SOON_AS_POSSIBLE + mode.synchronization_policy = rebalancer_enums.SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_AS_SOON_AS_POSSIBLE mode.sell_unindexed_traded_coins = False assert mode.get_removed_coins_from_config([]) == [] mode.trading_config = { index_trading.IndexTradingModeProducer.INDEX_CONTENT: [ { - index_trading.index_distribution.DISTRIBUTION_NAME: "AA" + rebalancer_enums.DistributionKeys.NAME: "AA" }, { - index_trading.index_distribution.DISTRIBUTION_NAME: "BB" + rebalancer_enums.DistributionKeys.NAME: "BB" } ] } @@ -1445,20 +1463,20 @@ async def test_get_removed_coins_from_config_sell_removed_coins_asap(trading_too mode.previous_trading_config = { index_trading.IndexTradingModeProducer.INDEX_CONTENT: [ { - index_trading.index_distribution.DISTRIBUTION_NAME: "AA" + rebalancer_enums.DistributionKeys.NAME: "AA" }, { - index_trading.index_distribution.DISTRIBUTION_NAME: "BB" + rebalancer_enums.DistributionKeys.NAME: "BB" } ] } mode.trading_config = { index_trading.IndexTradingModeProducer.INDEX_CONTENT: [ { - index_trading.index_distribution.DISTRIBUTION_NAME: "AA" + rebalancer_enums.DistributionKeys.NAME: "AA" }, { - index_trading.index_distribution.DISTRIBUTION_NAME: "CC" + rebalancer_enums.DistributionKeys.NAME: "CC" } ] } @@ -1471,10 +1489,10 @@ async def test_get_removed_coins_from_config_sell_removed_coins_asap(trading_too mode.previous_trading_config = { index_trading.IndexTradingModeProducer.INDEX_CONTENT: [ { - index_trading.index_distribution.DISTRIBUTION_NAME: "AA" + rebalancer_enums.DistributionKeys.NAME: "AA" }, { - index_trading.index_distribution.DISTRIBUTION_NAME: "BB" + rebalancer_enums.DistributionKeys.NAME: "BB" } ] } @@ -1485,17 +1503,17 @@ async def test_get_removed_coins_from_config_sell_removed_coins_asap(trading_too async def test_get_removed_coins_from_config_sell_removed_on_ratio_rebalance(trading_tools): update = {} mode, producer, consumer, trader = await _init_mode(trading_tools, _get_config(trading_tools, update)) - mode.synchronization_policy = index_trading.SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_ON_RATIO_REBALANCE + mode.synchronization_policy = rebalancer_enums.SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_ON_RATIO_REBALANCE mode.sell_unindexed_traded_coins = False assert mode.get_removed_coins_from_config([]) == [] # without historical config mode.trading_config = { index_trading.IndexTradingModeProducer.INDEX_CONTENT: [ { - index_trading.index_distribution.DISTRIBUTION_NAME: "BTC" + rebalancer_enums.DistributionKeys.NAME: "BTC" }, { - index_trading.index_distribution.DISTRIBUTION_NAME: "SOL" + rebalancer_enums.DistributionKeys.NAME: "SOL" } ] } @@ -1509,20 +1527,20 @@ async def test_get_removed_coins_from_config_sell_removed_on_ratio_rebalance(tra historical_config_1 = { index_trading.IndexTradingModeProducer.INDEX_CONTENT: [ { - index_trading.index_distribution.DISTRIBUTION_NAME: "BTC" + rebalancer_enums.DistributionKeys.NAME: "BTC" }, { - index_trading.index_distribution.DISTRIBUTION_NAME: "ADA" + rebalancer_enums.DistributionKeys.NAME: "ADA" } ] } historical_config_2 = { index_trading.IndexTradingModeProducer.INDEX_CONTENT: [ { - index_trading.index_distribution.DISTRIBUTION_NAME: "BTC" + rebalancer_enums.DistributionKeys.NAME: "BTC" }, { - index_trading.index_distribution.DISTRIBUTION_NAME: "DOT" + rebalancer_enums.DistributionKeys.NAME: "DOT" } ] } @@ -1863,82 +1881,82 @@ async def test_get_simple_buy_coins(trading_tools): mode, producer, consumer, trader = await _init_mode(trading_tools, _get_config(trading_tools, update)) mode.indexed_coins = ["BTC", "ETH", "SOL"] assert consumer._get_simple_buy_coins({ - index_trading.RebalanceDetails.SELL_SOME.value: {}, - index_trading.RebalanceDetails.BUY_MORE.value: {}, - index_trading.RebalanceDetails.REMOVE.value: {}, - index_trading.RebalanceDetails.ADD.value: {}, - index_trading.RebalanceDetails.SWAP.value: {}, - index_trading.RebalanceDetails.FORCED_REBALANCE.value: False, + rebalancer_enums.RebalanceDetails.SELL_SOME.value: {}, + rebalancer_enums.RebalanceDetails.BUY_MORE.value: {}, + rebalancer_enums.RebalanceDetails.REMOVE.value: {}, + rebalancer_enums.RebalanceDetails.ADD.value: {}, + rebalancer_enums.RebalanceDetails.SWAP.value: {}, + rebalancer_enums.RebalanceDetails.FORCED_REBALANCE.value: False, }) == [] assert consumer._get_simple_buy_coins({ - index_trading.RebalanceDetails.SELL_SOME.value: {}, - index_trading.RebalanceDetails.BUY_MORE.value: {}, - index_trading.RebalanceDetails.REMOVE.value: {}, - index_trading.RebalanceDetails.ADD.value: {"BTC": decimal.Decimal("0.2"), "ETH": decimal.Decimal("0.2")}, - index_trading.RebalanceDetails.SWAP.value: {}, - index_trading.RebalanceDetails.FORCED_REBALANCE.value: False, + rebalancer_enums.RebalanceDetails.SELL_SOME.value: {}, + rebalancer_enums.RebalanceDetails.BUY_MORE.value: {}, + rebalancer_enums.RebalanceDetails.REMOVE.value: {}, + rebalancer_enums.RebalanceDetails.ADD.value: {"BTC": decimal.Decimal("0.2"), "ETH": decimal.Decimal("0.2")}, + rebalancer_enums.RebalanceDetails.SWAP.value: {}, + rebalancer_enums.RebalanceDetails.FORCED_REBALANCE.value: False, }) == ["BTC", "ETH"] # keep index coins order assert consumer._get_simple_buy_coins({ - index_trading.RebalanceDetails.SELL_SOME.value: {}, - index_trading.RebalanceDetails.BUY_MORE.value: {"SOL": decimal.Decimal("0.2")}, - index_trading.RebalanceDetails.REMOVE.value: {}, - index_trading.RebalanceDetails.ADD.value: {"ETH": decimal.Decimal("0.2"), "BTC": decimal.Decimal("0.2")}, - index_trading.RebalanceDetails.SWAP.value: {}, - index_trading.RebalanceDetails.FORCED_REBALANCE.value: False, + rebalancer_enums.RebalanceDetails.SELL_SOME.value: {}, + rebalancer_enums.RebalanceDetails.BUY_MORE.value: {"SOL": decimal.Decimal("0.2")}, + rebalancer_enums.RebalanceDetails.REMOVE.value: {}, + rebalancer_enums.RebalanceDetails.ADD.value: {"ETH": decimal.Decimal("0.2"), "BTC": decimal.Decimal("0.2")}, + rebalancer_enums.RebalanceDetails.SWAP.value: {}, + rebalancer_enums.RebalanceDetails.FORCED_REBALANCE.value: False, }) == ["BTC", "ETH", "SOL"] # TRX not in indexed coins: added at the end assert consumer._get_simple_buy_coins({ - index_trading.RebalanceDetails.SELL_SOME.value: {}, - index_trading.RebalanceDetails.BUY_MORE.value: {"SOL": decimal.Decimal("0.1"), "TRX": decimal.Decimal("0.2")}, - index_trading.RebalanceDetails.REMOVE.value: {}, - index_trading.RebalanceDetails.ADD.value: {"ETH": decimal.Decimal("0.2"), "BTC": decimal.Decimal("0.5")}, - index_trading.RebalanceDetails.SWAP.value: {}, - index_trading.RebalanceDetails.FORCED_REBALANCE.value: False, + rebalancer_enums.RebalanceDetails.SELL_SOME.value: {}, + rebalancer_enums.RebalanceDetails.BUY_MORE.value: {"SOL": decimal.Decimal("0.1"), "TRX": decimal.Decimal("0.2")}, + rebalancer_enums.RebalanceDetails.REMOVE.value: {}, + rebalancer_enums.RebalanceDetails.ADD.value: {"ETH": decimal.Decimal("0.2"), "BTC": decimal.Decimal("0.5")}, + rebalancer_enums.RebalanceDetails.SWAP.value: {}, + rebalancer_enums.RebalanceDetails.FORCED_REBALANCE.value: False, }) == ["BTC", "ETH", "SOL", "TRX"] # don't return anything when other values are set assert consumer._get_simple_buy_coins({ - index_trading.RebalanceDetails.SELL_SOME.value: {"BTC": decimal.Decimal("0.2")}, - index_trading.RebalanceDetails.BUY_MORE.value: {}, - index_trading.RebalanceDetails.REMOVE.value: {}, - index_trading.RebalanceDetails.ADD.value: {"ETH": decimal.Decimal("0.2")}, - index_trading.RebalanceDetails.SWAP.value: {}, - index_trading.RebalanceDetails.FORCED_REBALANCE.value: False, + rebalancer_enums.RebalanceDetails.SELL_SOME.value: {"BTC": decimal.Decimal("0.2")}, + rebalancer_enums.RebalanceDetails.BUY_MORE.value: {}, + rebalancer_enums.RebalanceDetails.REMOVE.value: {}, + rebalancer_enums.RebalanceDetails.ADD.value: {"ETH": decimal.Decimal("0.2")}, + rebalancer_enums.RebalanceDetails.SWAP.value: {}, + rebalancer_enums.RebalanceDetails.FORCED_REBALANCE.value: False, }) == [] assert consumer._get_simple_buy_coins({ - index_trading.RebalanceDetails.SELL_SOME.value: {}, - index_trading.RebalanceDetails.BUY_MORE.value: {}, - index_trading.RebalanceDetails.REMOVE.value: {"BTC": decimal.Decimal("0.2")}, - index_trading.RebalanceDetails.ADD.value: {"ETH": decimal.Decimal("0.2")}, - index_trading.RebalanceDetails.SWAP.value: {}, - index_trading.RebalanceDetails.FORCED_REBALANCE.value: False, + rebalancer_enums.RebalanceDetails.SELL_SOME.value: {}, + rebalancer_enums.RebalanceDetails.BUY_MORE.value: {}, + rebalancer_enums.RebalanceDetails.REMOVE.value: {"BTC": decimal.Decimal("0.2")}, + rebalancer_enums.RebalanceDetails.ADD.value: {"ETH": decimal.Decimal("0.2")}, + rebalancer_enums.RebalanceDetails.SWAP.value: {}, + rebalancer_enums.RebalanceDetails.FORCED_REBALANCE.value: False, }) == [] assert consumer._get_simple_buy_coins({ - index_trading.RebalanceDetails.SELL_SOME.value: {}, - index_trading.RebalanceDetails.BUY_MORE.value: {}, - index_trading.RebalanceDetails.REMOVE.value: {}, - index_trading.RebalanceDetails.ADD.value: {"ETH": decimal.Decimal("0.2")}, - index_trading.RebalanceDetails.SWAP.value: {"BTC": decimal.Decimal("0.2")}, - index_trading.RebalanceDetails.FORCED_REBALANCE.value: False, + rebalancer_enums.RebalanceDetails.SELL_SOME.value: {}, + rebalancer_enums.RebalanceDetails.BUY_MORE.value: {}, + rebalancer_enums.RebalanceDetails.REMOVE.value: {}, + rebalancer_enums.RebalanceDetails.ADD.value: {"ETH": decimal.Decimal("0.2")}, + rebalancer_enums.RebalanceDetails.SWAP.value: {"BTC": decimal.Decimal("0.2")}, + rebalancer_enums.RebalanceDetails.FORCED_REBALANCE.value: False, }) == [] # whatever is in other values, return [] when forced rebalance assert consumer._get_simple_buy_coins({ - index_trading.RebalanceDetails.SELL_SOME.value: {}, - index_trading.RebalanceDetails.BUY_MORE.value: {}, - index_trading.RebalanceDetails.REMOVE.value: {}, - index_trading.RebalanceDetails.ADD.value: {"ETH": decimal.Decimal("0.2")}, - index_trading.RebalanceDetails.SWAP.value: {"BTC": decimal.Decimal("0.2")}, - index_trading.RebalanceDetails.FORCED_REBALANCE.value: True, + rebalancer_enums.RebalanceDetails.SELL_SOME.value: {}, + rebalancer_enums.RebalanceDetails.BUY_MORE.value: {}, + rebalancer_enums.RebalanceDetails.REMOVE.value: {}, + rebalancer_enums.RebalanceDetails.ADD.value: {"ETH": decimal.Decimal("0.2")}, + rebalancer_enums.RebalanceDetails.SWAP.value: {"BTC": decimal.Decimal("0.2")}, + rebalancer_enums.RebalanceDetails.FORCED_REBALANCE.value: True, }) == [] # should return [BTC, ETH] but doesn't because of forced rebalance assert consumer._get_simple_buy_coins({ - index_trading.RebalanceDetails.SELL_SOME.value: {}, - index_trading.RebalanceDetails.BUY_MORE.value: {}, - index_trading.RebalanceDetails.REMOVE.value: {}, - index_trading.RebalanceDetails.ADD.value: {"BTC": decimal.Decimal("0.2"), "ETH": decimal.Decimal("0.2")}, - index_trading.RebalanceDetails.SWAP.value: {}, - index_trading.RebalanceDetails.FORCED_REBALANCE.value: True, + rebalancer_enums.RebalanceDetails.SELL_SOME.value: {}, + rebalancer_enums.RebalanceDetails.BUY_MORE.value: {}, + rebalancer_enums.RebalanceDetails.REMOVE.value: {}, + rebalancer_enums.RebalanceDetails.ADD.value: {"BTC": decimal.Decimal("0.2"), "ETH": decimal.Decimal("0.2")}, + rebalancer_enums.RebalanceDetails.SWAP.value: {}, + rebalancer_enums.RebalanceDetails.FORCED_REBALANCE.value: True, }) == [] @@ -1975,8 +1993,8 @@ async def test_sell_indexed_coins_for_reference_market(trading_tools): trading_personal_data, "wait_for_order_fill", mock.AsyncMock() ) as wait_for_order_fill_mock: details = { - index_trading.RebalanceDetails.REMOVE.value: {}, - index_trading.RebalanceDetails.SWAP.value: {}, + rebalancer_enums.RebalanceDetails.REMOVE.value: {}, + rebalancer_enums.RebalanceDetails.SWAP.value: {}, } orders = await consumer.trading_mode.rebalancer.sell_indexed_coins_for_reference_market(details, dependencies) @@ -2012,7 +2030,7 @@ async def test_sell_indexed_coins_for_reference_market(trading_tools): consumer.trading_mode.rebalancer, "cancel_symbol_open_orders", mock.AsyncMock() ) as cancel_symbol_open_orders_mock: details = { - index_trading.RebalanceDetails.REMOVE.value: {} + rebalancer_enums.RebalanceDetails.REMOVE.value: {} } assert await consumer.trading_mode.rebalancer.sell_indexed_coins_for_reference_market(details, dependencies) == orders convert_assets_to_target_asset_mock.assert_called_once_with( @@ -2028,11 +2046,11 @@ async def test_sell_indexed_coins_for_reference_market(trading_tools): # 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: {}, - index_trading.RebalanceDetails.FORCED_REBALANCE.value: False, + rebalancer_enums.RebalanceDetails.REMOVE.value: {"BTC": 0.01}, + rebalancer_enums.RebalanceDetails.BUY_MORE.value: {}, + rebalancer_enums.RebalanceDetails.ADD.value: {}, + rebalancer_enums.RebalanceDetails.SWAP.value: {}, + rebalancer_enums.RebalanceDetails.FORCED_REBALANCE.value: False, } assert await consumer.trading_mode.rebalancer.sell_indexed_coins_for_reference_market(details, dependencies) == orders + orders assert convert_assets_to_target_asset_mock.call_count == 2 @@ -2047,11 +2065,11 @@ async def test_sell_indexed_coins_for_reference_market(trading_tools): ) 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: {}, - index_trading.RebalanceDetails.FORCED_REBALANCE.value: False, + rebalancer_enums.RebalanceDetails.REMOVE.value: {"BTC": 0.01}, + rebalancer_enums.RebalanceDetails.BUY_MORE.value: {}, + rebalancer_enums.RebalanceDetails.ADD.value: {}, + rebalancer_enums.RebalanceDetails.SWAP.value: {}, + rebalancer_enums.RebalanceDetails.FORCED_REBALANCE.value: False, } with pytest.raises(trading_errors.MissingMinimalExchangeTradeVolume): assert await consumer.trading_mode.rebalancer.sell_indexed_coins_for_reference_market(details, dependencies) == orders + orders @@ -2096,9 +2114,9 @@ async def test_sell_some_reduces_or_closes_position(trading_tools): ): # target_size = 0.1*1000/1000 = 0.1 => close 1.9 out of 2 details = { - index_trading.RebalanceDetails.SELL_SOME.value: {"BTC/USDT": decimal.Decimal("0.1")}, - index_trading.RebalanceDetails.REMOVE.value: {}, - index_trading.RebalanceDetails.SWAP.value: {}, + rebalancer_enums.RebalanceDetails.SELL_SOME.value: {"BTC/USDT": decimal.Decimal("0.1")}, + rebalancer_enums.RebalanceDetails.REMOVE.value: {}, + rebalancer_enums.RebalanceDetails.SWAP.value: {}, } orders = await consumer.trading_mode.rebalancer.sell_indexed_coins_for_reference_market(details, dependencies) assert len(orders) == 1 @@ -2108,9 +2126,9 @@ async def test_sell_some_reduces_or_closes_position(trading_tools): create_order_mock.reset_mock() # target_size = 0 => close full position details = { - index_trading.RebalanceDetails.SELL_SOME.value: {"BTC/USDT": decimal.Decimal("0")}, - index_trading.RebalanceDetails.REMOVE.value: {}, - index_trading.RebalanceDetails.SWAP.value: {}, + rebalancer_enums.RebalanceDetails.SELL_SOME.value: {"BTC/USDT": decimal.Decimal("0")}, + rebalancer_enums.RebalanceDetails.REMOVE.value: {}, + rebalancer_enums.RebalanceDetails.SWAP.value: {}, } orders = await consumer.trading_mode.rebalancer.sell_indexed_coins_for_reference_market(details, dependencies) assert len(orders) == 1 @@ -2125,9 +2143,9 @@ async def test_sell_some_reduces_or_closes_position(trading_tools): trading_personal_data, "wait_for_order_fill", mock.AsyncMock() ): details = { - index_trading.RebalanceDetails.SELL_SOME.value: {"BTC": decimal.Decimal("0.1")}, - index_trading.RebalanceDetails.REMOVE.value: {}, - index_trading.RebalanceDetails.SWAP.value: {}, + rebalancer_enums.RebalanceDetails.SELL_SOME.value: {"BTC": decimal.Decimal("0.1")}, + rebalancer_enums.RebalanceDetails.REMOVE.value: {}, + rebalancer_enums.RebalanceDetails.SWAP.value: {}, } orders = await consumer.trading_mode.rebalancer.sell_indexed_coins_for_reference_market(details, dependencies) assert orders == converted_orders @@ -2162,9 +2180,9 @@ async def test_sell_some_reduces_or_closes_position(trading_tools): trading_personal_data, "wait_for_order_fill", mock.AsyncMock() ): details = { - index_trading.RebalanceDetails.SELL_SOME.value: {"BTC": decimal.Decimal("0.1")}, - index_trading.RebalanceDetails.REMOVE.value: {}, - index_trading.RebalanceDetails.SWAP.value: {}, + rebalancer_enums.RebalanceDetails.SELL_SOME.value: {"BTC": decimal.Decimal("0.1")}, + rebalancer_enums.RebalanceDetails.REMOVE.value: {}, + rebalancer_enums.RebalanceDetails.SWAP.value: {}, } orders = await consumer.trading_mode.rebalancer.sell_indexed_coins_for_reference_market(details, dependencies) assert orders == [] @@ -2214,8 +2232,8 @@ async def _mock_refresh(position, force_job_execution=False): mode, "create_order", mock.AsyncMock(side_effect=lambda order, **kwargs: order) ) as create_order_mock: details = { - index_trading.RebalanceDetails.REMOVE.value: {"BTC/USDT": None}, - index_trading.RebalanceDetails.SWAP.value: {}, + rebalancer_enums.RebalanceDetails.REMOVE.value: {"BTC/USDT": None}, + rebalancer_enums.RebalanceDetails.SWAP.value: {}, } orders = await consumer.trading_mode.rebalancer.get_coins_to_sell_orders(details, dependencies) @@ -2264,8 +2282,8 @@ async def _mock_cancel(sym, deps, allowed_sides=None): mode, "create_order", mock.AsyncMock(side_effect=lambda order, **kwargs: order) ) as create_order_mock: details = { - index_trading.RebalanceDetails.REMOVE.value: {"BTC/USDT": None}, - index_trading.RebalanceDetails.SWAP.value: {}, + rebalancer_enums.RebalanceDetails.REMOVE.value: {"BTC/USDT": None}, + rebalancer_enums.RebalanceDetails.SWAP.value: {}, } orders = await consumer.trading_mode.rebalancer.get_coins_to_sell_orders(details, dependencies) @@ -2284,12 +2302,12 @@ def _cleanup_rebalance_details( forced_rebalance: bool = False, ) -> dict: return { - index_trading.RebalanceDetails.SELL_SOME.value: sell_some or {}, - index_trading.RebalanceDetails.BUY_MORE.value: buy_more or {}, - index_trading.RebalanceDetails.REMOVE.value: remove or {}, - index_trading.RebalanceDetails.ADD.value: add or {}, - index_trading.RebalanceDetails.SWAP.value: swap or {}, - index_trading.RebalanceDetails.FORCED_REBALANCE.value: forced_rebalance, + rebalancer_enums.RebalanceDetails.SELL_SOME.value: sell_some or {}, + rebalancer_enums.RebalanceDetails.BUY_MORE.value: buy_more or {}, + rebalancer_enums.RebalanceDetails.REMOVE.value: remove or {}, + rebalancer_enums.RebalanceDetails.ADD.value: add or {}, + rebalancer_enums.RebalanceDetails.SWAP.value: swap or {}, + rebalancer_enums.RebalanceDetails.FORCED_REBALANCE.value: forced_rebalance, } @@ -2435,52 +2453,52 @@ async def test_get_coins_to_sell(trading_tools): mode, producer, consumer, trader = await _init_mode(trading_tools, _get_config(trading_tools, update)) mode.indexed_coins = ["BTC", "ETH", "DOGE", "SHIB"] assert consumer.trading_mode.rebalancer.get_coins_to_sell({ - index_trading.RebalanceDetails.SELL_SOME.value: {}, - index_trading.RebalanceDetails.BUY_MORE.value: {}, - index_trading.RebalanceDetails.REMOVE.value: {}, - index_trading.RebalanceDetails.ADD.value: {}, - index_trading.RebalanceDetails.SWAP.value: {}, - index_trading.RebalanceDetails.FORCED_REBALANCE.value: False, + rebalancer_enums.RebalanceDetails.SELL_SOME.value: {}, + rebalancer_enums.RebalanceDetails.BUY_MORE.value: {}, + rebalancer_enums.RebalanceDetails.REMOVE.value: {}, + rebalancer_enums.RebalanceDetails.ADD.value: {}, + rebalancer_enums.RebalanceDetails.SWAP.value: {}, + rebalancer_enums.RebalanceDetails.FORCED_REBALANCE.value: False, }) == ["BTC", "ETH", "DOGE", "SHIB"] assert consumer.trading_mode.rebalancer.get_coins_to_sell({ - index_trading.RebalanceDetails.SELL_SOME.value: {}, - index_trading.RebalanceDetails.BUY_MORE.value: {}, - index_trading.RebalanceDetails.REMOVE.value: {}, - index_trading.RebalanceDetails.ADD.value: {}, - index_trading.RebalanceDetails.SWAP.value: { + rebalancer_enums.RebalanceDetails.SELL_SOME.value: {}, + rebalancer_enums.RebalanceDetails.BUY_MORE.value: {}, + rebalancer_enums.RebalanceDetails.REMOVE.value: {}, + rebalancer_enums.RebalanceDetails.ADD.value: {}, + rebalancer_enums.RebalanceDetails.SWAP.value: { "BTC": "ETH" }, }) == ["BTC"] assert consumer.trading_mode.rebalancer.get_coins_to_sell({ - index_trading.RebalanceDetails.SELL_SOME.value: {}, - index_trading.RebalanceDetails.BUY_MORE.value: {}, - index_trading.RebalanceDetails.REMOVE.value: { + rebalancer_enums.RebalanceDetails.SELL_SOME.value: {}, + rebalancer_enums.RebalanceDetails.BUY_MORE.value: {}, + rebalancer_enums.RebalanceDetails.REMOVE.value: { "XRP": trading_constants.ONE_HUNDRED }, - index_trading.RebalanceDetails.ADD.value: {}, - index_trading.RebalanceDetails.SWAP.value: { + rebalancer_enums.RebalanceDetails.ADD.value: {}, + rebalancer_enums.RebalanceDetails.SWAP.value: { "BTC": "ETH", "SOL": "ADA", }, - index_trading.RebalanceDetails.FORCED_REBALANCE.value: False, + rebalancer_enums.RebalanceDetails.FORCED_REBALANCE.value: False, }) == ["BTC", "SOL"] assert consumer.trading_mode.rebalancer.get_coins_to_sell({ - index_trading.RebalanceDetails.SELL_SOME.value: {}, - index_trading.RebalanceDetails.BUY_MORE.value: {}, - index_trading.RebalanceDetails.REMOVE.value: {}, - index_trading.RebalanceDetails.ADD.value: {}, - index_trading.RebalanceDetails.SWAP.value: {}, - index_trading.RebalanceDetails.FORCED_REBALANCE.value: False, + rebalancer_enums.RebalanceDetails.SELL_SOME.value: {}, + rebalancer_enums.RebalanceDetails.BUY_MORE.value: {}, + rebalancer_enums.RebalanceDetails.REMOVE.value: {}, + rebalancer_enums.RebalanceDetails.ADD.value: {}, + rebalancer_enums.RebalanceDetails.SWAP.value: {}, + rebalancer_enums.RebalanceDetails.FORCED_REBALANCE.value: False, }) == ["BTC", "ETH", "DOGE", "SHIB"] assert consumer.trading_mode.rebalancer.get_coins_to_sell({ - index_trading.RebalanceDetails.SELL_SOME.value: {}, - index_trading.RebalanceDetails.BUY_MORE.value: {}, - index_trading.RebalanceDetails.REMOVE.value: { + rebalancer_enums.RebalanceDetails.SELL_SOME.value: {}, + rebalancer_enums.RebalanceDetails.BUY_MORE.value: {}, + rebalancer_enums.RebalanceDetails.REMOVE.value: { "XRP": trading_constants.ONE_HUNDRED }, - index_trading.RebalanceDetails.ADD.value: {}, - index_trading.RebalanceDetails.SWAP.value: {}, - index_trading.RebalanceDetails.FORCED_REBALANCE.value: False, + rebalancer_enums.RebalanceDetails.ADD.value: {}, + rebalancer_enums.RebalanceDetails.SWAP.value: {}, + rebalancer_enums.RebalanceDetails.FORCED_REBALANCE.value: False, }) == ["BTC", "ETH", "DOGE", "SHIB"] @@ -2513,69 +2531,69 @@ def _get_symbol_position(symbol, side=None): return_value=mock.Mock() if is_futures else None ): rebalance_details = { - index_trading.RebalanceDetails.SELL_SOME.value: {}, - index_trading.RebalanceDetails.BUY_MORE.value: {}, - index_trading.RebalanceDetails.REMOVE.value: {}, - index_trading.RebalanceDetails.ADD.value: {}, - index_trading.RebalanceDetails.SWAP.value: {}, + rebalancer_enums.RebalanceDetails.SELL_SOME.value: {}, + rebalancer_enums.RebalanceDetails.BUY_MORE.value: {}, + rebalancer_enums.RebalanceDetails.REMOVE.value: {}, + rebalancer_enums.RebalanceDetails.ADD.value: {}, + rebalancer_enums.RebalanceDetails.SWAP.value: {}, } # regular full rebalance - producer._resolve_swaps(rebalance_details) - assert rebalance_details[index_trading.RebalanceDetails.SWAP.value] == {} + _rebalance_planner_for_tests(producer)._resolve_swaps(rebalance_details) + assert rebalance_details[rebalancer_enums.RebalanceDetails.SWAP.value] == {} # regular full rebalance with removed coins to sell - rebalance_details[index_trading.RebalanceDetails.REMOVE.value] = {"SOL": decimal.Decimal("0.3")} - producer._resolve_swaps(rebalance_details) - assert rebalance_details[index_trading.RebalanceDetails.SWAP.value] == {} + rebalance_details[rebalancer_enums.RebalanceDetails.REMOVE.value] = {"SOL": decimal.Decimal("0.3")} + _rebalance_planner_for_tests(producer)._resolve_swaps(rebalance_details) + assert rebalance_details[rebalancer_enums.RebalanceDetails.SWAP.value] == {} # rebalances with a coin swap only from ADD coin - rebalance_details[index_trading.RebalanceDetails.ADD.value] = {"ADA": decimal.Decimal("0.3")} - producer._resolve_swaps(rebalance_details) - assert rebalance_details[index_trading.RebalanceDetails.SWAP.value] == {"SOL": "ADA"} + rebalance_details[rebalancer_enums.RebalanceDetails.ADD.value] = {"ADA": decimal.Decimal("0.3")} + _rebalance_planner_for_tests(producer)._resolve_swaps(rebalance_details) + assert rebalance_details[rebalancer_enums.RebalanceDetails.SWAP.value] == {"SOL": "ADA"} # rebalances with a coin swap only from BUY_MORE coin - rebalance_details[index_trading.RebalanceDetails.ADD.value] = {} - rebalance_details[index_trading.RebalanceDetails.BUY_MORE.value] = {"ADA": decimal.Decimal("0.3")} - producer._resolve_swaps(rebalance_details) - assert rebalance_details[index_trading.RebalanceDetails.SWAP.value] == {"SOL": "ADA"} - rebalance_details[index_trading.RebalanceDetails.BUY_MORE.value] = {} + rebalance_details[rebalancer_enums.RebalanceDetails.ADD.value] = {} + rebalance_details[rebalancer_enums.RebalanceDetails.BUY_MORE.value] = {"ADA": decimal.Decimal("0.3")} + _rebalance_planner_for_tests(producer)._resolve_swaps(rebalance_details) + assert rebalance_details[rebalancer_enums.RebalanceDetails.SWAP.value] == {"SOL": "ADA"} + rebalance_details[rebalancer_enums.RebalanceDetails.BUY_MORE.value] = {} # rebalances with an incompatible coin swap (ratio too different) - rebalance_details[index_trading.RebalanceDetails.BUY_MORE.value] = {"ADA": decimal.Decimal("0.1")} - producer._resolve_swaps(rebalance_details) - assert rebalance_details[index_trading.RebalanceDetails.SWAP.value] == {} - rebalance_details[index_trading.RebalanceDetails.BUY_MORE.value] = {} + rebalance_details[rebalancer_enums.RebalanceDetails.BUY_MORE.value] = {"ADA": decimal.Decimal("0.1")} + _rebalance_planner_for_tests(producer)._resolve_swaps(rebalance_details) + assert rebalance_details[rebalancer_enums.RebalanceDetails.SWAP.value] == {} + rebalance_details[rebalancer_enums.RebalanceDetails.BUY_MORE.value] = {} # rebalances with an incompatible coin swap (ratio too different) - rebalance_details[index_trading.RebalanceDetails.ADD.value] = {"ADA": decimal.Decimal("0.5")} - producer._resolve_swaps(rebalance_details) - assert rebalance_details[index_trading.RebalanceDetails.SWAP.value] == {} + rebalance_details[rebalancer_enums.RebalanceDetails.ADD.value] = {"ADA": decimal.Decimal("0.5")} + _rebalance_planner_for_tests(producer)._resolve_swaps(rebalance_details) + assert rebalance_details[rebalancer_enums.RebalanceDetails.SWAP.value] == {} # rebalances with 2 removed coins: sell everything - rebalance_details[index_trading.RebalanceDetails.REMOVE.value] = { + rebalance_details[rebalancer_enums.RebalanceDetails.REMOVE.value] = { "SOL": decimal.Decimal("0.3"), "XRP": decimal.Decimal("0.3"), } - producer._resolve_swaps(rebalance_details) - assert rebalance_details[index_trading.RebalanceDetails.SWAP.value] == {} + _rebalance_planner_for_tests(producer)._resolve_swaps(rebalance_details) + assert rebalance_details[rebalancer_enums.RebalanceDetails.SWAP.value] == {} # rebalances with 2 coin swaps: sell everything - rebalance_details[index_trading.RebalanceDetails.ADD.value] = { + rebalance_details[rebalancer_enums.RebalanceDetails.ADD.value] = { "ADA": decimal.Decimal("0.3"), "ADA2": decimal.Decimal("0.3"), } - producer._resolve_swaps(rebalance_details) - assert rebalance_details[index_trading.RebalanceDetails.SWAP.value] == {} + _rebalance_planner_for_tests(producer)._resolve_swaps(rebalance_details) + assert rebalance_details[rebalancer_enums.RebalanceDetails.SWAP.value] == {} # rebalance with regular buy / sell more - rebalance_details[index_trading.RebalanceDetails.BUY_MORE.value] = {"LTC": decimal.Decimal(1)} - producer._resolve_swaps(rebalance_details) - assert rebalance_details[index_trading.RebalanceDetails.SWAP.value] == {} + rebalance_details[rebalancer_enums.RebalanceDetails.BUY_MORE.value] = {"LTC": decimal.Decimal(1)} + _rebalance_planner_for_tests(producer)._resolve_swaps(rebalance_details) + assert rebalance_details[rebalancer_enums.RebalanceDetails.SWAP.value] == {} # rebalance with regular buy / sell more - rebalance_details[index_trading.RebalanceDetails.SELL_SOME.value] = {"BTC": decimal.Decimal(1)} - producer._resolve_swaps(rebalance_details) - assert rebalance_details[index_trading.RebalanceDetails.SWAP.value] == {} + rebalance_details[rebalancer_enums.RebalanceDetails.SELL_SOME.value] = {"BTC": decimal.Decimal(1)} + _rebalance_planner_for_tests(producer)._resolve_swaps(rebalance_details) + assert rebalance_details[rebalancer_enums.RebalanceDetails.SWAP.value] == {} @pytest.mark.parametrize("trading_tools", ["spot", "futures"], indirect=True) @@ -2584,7 +2602,7 @@ async def test_split_reference_market_into_indexed_coins(trading_tools): mode, producer, consumer, trader = await _init_mode(trading_tools, _get_config(trading_tools, update)) # no indexed coin mode.indexed_coins = [] - details = {index_trading.RebalanceDetails.SWAP.value: {}} + details = {rebalancer_enums.RebalanceDetails.SWAP.value: {}} is_simple_buy_without_selling = False dependencies = trading_signals.get_orders_dependencies([mock.Mock(order_id="123")]) with mock.patch.object( @@ -2617,7 +2635,7 @@ async def test_split_reference_market_into_indexed_coins(trading_tools): # coins to swap mode.indexed_coins = [] - details = {index_trading.RebalanceDetails.SWAP.value: {"BTC": "ETH", "ADA": "SOL"}} + details = {rebalancer_enums.RebalanceDetails.SWAP.value: {"BTC": "ETH", "ADA": "SOL"}} with mock.patch.object( trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio, "get_currency_portfolio", mock.Mock(return_value=mock.Mock(available=decimal.Decimal("2"))) @@ -2670,7 +2688,7 @@ async def test_split_reference_market_into_indexed_coins(trading_tools): _buy_coin_mock.reset_mock() # no bought coin - details = {index_trading.RebalanceDetails.SWAP.value: {}} + details = {rebalancer_enums.RebalanceDetails.SWAP.value: {}} mode.indexed_coins = ["ETH", "BTC"] with mock.patch.object( trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio, @@ -3473,7 +3491,7 @@ async def test_automatically_update_historical_config_on_set_intervals(trading_t mode, producer, consumer, trader = await _init_mode(trading_tools, _get_config(trading_tools, update)) # Test with SELL_REMOVED_INDEX_COINS_AS_SOON_AS_POSSIBLE policy - mode.synchronization_policy = index_trading.SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_AS_SOON_AS_POSSIBLE + mode.synchronization_policy = rebalancer_enums.SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_AS_SOON_AS_POSSIBLE with mock.patch.object(mode, "supports_historical_config", mock.Mock(return_value=True)) as supports_historical_config_mock: assert mode.automatically_update_historical_config_on_set_intervals() is True supports_historical_config_mock.assert_called_once() @@ -3485,7 +3503,7 @@ async def test_automatically_update_historical_config_on_set_intervals(trading_t supports_historical_config_mock.reset_mock() # Test with SELL_REMOVED_INDEX_COINS_ON_RATIO_REBALANCE policy - mode.synchronization_policy = index_trading.SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_ON_RATIO_REBALANCE + mode.synchronization_policy = rebalancer_enums.SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_ON_RATIO_REBALANCE with mock.patch.object(mode, "supports_historical_config", mock.Mock(return_value=True)) as supports_historical_config_mock: assert mode.automatically_update_historical_config_on_set_intervals() is False supports_historical_config_mock.assert_called_once() @@ -3505,34 +3523,34 @@ async def test_ensure_updated_coins_distribution(trading_tools): ] distribution = [ { - index_trading.index_distribution.DISTRIBUTION_NAME: "BTC", - index_trading.index_distribution.DISTRIBUTION_VALUE: 50 + rebalancer_enums.DistributionKeys.NAME: "BTC", + rebalancer_enums.DistributionKeys.VALUE: 50 }, { - index_trading.index_distribution.DISTRIBUTION_NAME: "ETH", - index_trading.index_distribution.DISTRIBUTION_VALUE: 30 + rebalancer_enums.DistributionKeys.NAME: "ETH", + rebalancer_enums.DistributionKeys.VALUE: 30 }, { - index_trading.index_distribution.DISTRIBUTION_NAME: "SOL", - index_trading.index_distribution.DISTRIBUTION_VALUE: 20 + rebalancer_enums.DistributionKeys.NAME: "SOL", + rebalancer_enums.DistributionKeys.VALUE: 20 }, ] - with mock.patch.object(mode, "_get_supported_distribution", mock.Mock(return_value=distribution)) as _get_supported_distribution_mock: + with mock.patch.object(mode.rebalance_actions_planner, "_get_supported_distribution", mock.Mock(return_value=distribution)) as _get_supported_distribution_mock: mode.ensure_updated_coins_distribution() _get_supported_distribution_mock.assert_called_once() _get_supported_distribution_mock.reset_mock() assert mode.ratio_per_asset == { "BTC": { - index_trading.index_distribution.DISTRIBUTION_NAME: "BTC", - index_trading.index_distribution.DISTRIBUTION_VALUE: 50 + rebalancer_enums.DistributionKeys.NAME: "BTC", + rebalancer_enums.DistributionKeys.VALUE: 50 }, "ETH": { - index_trading.index_distribution.DISTRIBUTION_NAME: "ETH", - index_trading.index_distribution.DISTRIBUTION_VALUE: 30 + rebalancer_enums.DistributionKeys.NAME: "ETH", + rebalancer_enums.DistributionKeys.VALUE: 30 }, "SOL": { - index_trading.index_distribution.DISTRIBUTION_NAME: "SOL", - index_trading.index_distribution.DISTRIBUTION_VALUE: 20 + rebalancer_enums.DistributionKeys.NAME: "SOL", + rebalancer_enums.DistributionKeys.VALUE: 20 } } assert mode.total_ratio_per_asset == 100 @@ -3541,34 +3559,34 @@ async def test_ensure_updated_coins_distribution(trading_tools): # include ref market in distribution distribution = [ { - index_trading.index_distribution.DISTRIBUTION_NAME: "BTC", - index_trading.index_distribution.DISTRIBUTION_VALUE: 50 + rebalancer_enums.DistributionKeys.NAME: "BTC", + rebalancer_enums.DistributionKeys.VALUE: 50 }, { - index_trading.index_distribution.DISTRIBUTION_NAME: "ETH", - index_trading.index_distribution.DISTRIBUTION_VALUE: 30 + rebalancer_enums.DistributionKeys.NAME: "ETH", + rebalancer_enums.DistributionKeys.VALUE: 30 }, { - index_trading.index_distribution.DISTRIBUTION_NAME: "USDT", - index_trading.index_distribution.DISTRIBUTION_VALUE: 20 + rebalancer_enums.DistributionKeys.NAME: "USDT", + rebalancer_enums.DistributionKeys.VALUE: 20 }, ] - with mock.patch.object(mode, "_get_supported_distribution", mock.Mock(return_value=distribution)) as _get_supported_distribution_mock: + with mock.patch.object(mode.rebalance_actions_planner, "_get_supported_distribution", mock.Mock(return_value=distribution)) as _get_supported_distribution_mock: mode.ensure_updated_coins_distribution() _get_supported_distribution_mock.assert_called_once() _get_supported_distribution_mock.reset_mock() assert mode.ratio_per_asset == { "BTC": { - index_trading.index_distribution.DISTRIBUTION_NAME: "BTC", - index_trading.index_distribution.DISTRIBUTION_VALUE: 50 + rebalancer_enums.DistributionKeys.NAME: "BTC", + rebalancer_enums.DistributionKeys.VALUE: 50 }, "ETH": { - index_trading.index_distribution.DISTRIBUTION_NAME: "ETH", - index_trading.index_distribution.DISTRIBUTION_VALUE: 30 + rebalancer_enums.DistributionKeys.NAME: "ETH", + rebalancer_enums.DistributionKeys.VALUE: 30 }, "USDT": { - index_trading.index_distribution.DISTRIBUTION_NAME: "USDT", - index_trading.index_distribution.DISTRIBUTION_VALUE: 20 + rebalancer_enums.DistributionKeys.NAME: "USDT", + rebalancer_enums.DistributionKeys.VALUE: 20 } } assert mode.total_ratio_per_asset == 100 @@ -3585,26 +3603,27 @@ async def test_get_supported_distribution(trading_tools): mode.trading_config = { index_trading.IndexTradingModeProducer.INDEX_CONTENT: [ { - index_trading.index_distribution.DISTRIBUTION_NAME: "BTC", - index_trading.index_distribution.DISTRIBUTION_VALUE: 25 + rebalancer_enums.DistributionKeys.NAME: "BTC", + rebalancer_enums.DistributionKeys.VALUE: 25 }, { - index_trading.index_distribution.DISTRIBUTION_NAME: "ETH", - index_trading.index_distribution.DISTRIBUTION_VALUE: 25 + rebalancer_enums.DistributionKeys.NAME: "ETH", + rebalancer_enums.DistributionKeys.VALUE: 25 }, { - index_trading.index_distribution.DISTRIBUTION_NAME: "SOL", - index_trading.index_distribution.DISTRIBUTION_VALUE: 25 + rebalancer_enums.DistributionKeys.NAME: "SOL", + rebalancer_enums.DistributionKeys.VALUE: 25 }, { - index_trading.index_distribution.DISTRIBUTION_NAME: "ADA", - index_trading.index_distribution.DISTRIBUTION_VALUE: 25 + rebalancer_enums.DistributionKeys.NAME: "ADA", + rebalancer_enums.DistributionKeys.VALUE: 25 }, ] } - with mock.patch.object(mode, "get_ideal_distribution", mock.Mock(wraps=mode.get_ideal_distribution)) as get_ideal_distribution_mock: + with mock.patch.object(mode.rebalance_actions_planner._client, "get_ideal_distribution", mock.Mock(wraps=mode.rebalance_actions_planner._client.get_ideal_distribution)) as get_ideal_distribution_mock: # no ideal distribution: return uniform distribution over traded assets - assert mode._get_supported_distribution(False, False) == mode.trading_config[ + mode._sync_rebalance_planner() + assert mode.rebalance_actions_planner._get_supported_distribution(False, False) == mode.trading_config[ index_trading.IndexTradingModeProducer.INDEX_CONTENT ] get_ideal_distribution_mock.assert_called_once() @@ -3612,21 +3631,22 @@ async def test_get_supported_distribution(trading_tools): mode.trading_config = { index_trading.IndexTradingModeProducer.INDEX_CONTENT: [ { - index_trading.index_distribution.DISTRIBUTION_NAME: "BTC", - index_trading.index_distribution.DISTRIBUTION_VALUE: 50 + rebalancer_enums.DistributionKeys.NAME: "BTC", + rebalancer_enums.DistributionKeys.VALUE: 50 }, { - index_trading.index_distribution.DISTRIBUTION_NAME: "ETH", - index_trading.index_distribution.DISTRIBUTION_VALUE: 30 + rebalancer_enums.DistributionKeys.NAME: "ETH", + rebalancer_enums.DistributionKeys.VALUE: 30 }, { - index_trading.index_distribution.DISTRIBUTION_NAME: "USDT", - index_trading.index_distribution.DISTRIBUTION_VALUE: 20 + rebalancer_enums.DistributionKeys.NAME: "USDT", + rebalancer_enums.DistributionKeys.VALUE: 20 }, ] } - with mock.patch.object(mode, "get_ideal_distribution", mock.Mock(wraps=mode.get_ideal_distribution)) as get_ideal_distribution_mock: - assert mode._get_supported_distribution(False, False) == mode.trading_config[ + with mock.patch.object(mode.rebalance_actions_planner._client, "get_ideal_distribution", mock.Mock(wraps=mode.rebalance_actions_planner._client.get_ideal_distribution)) as get_ideal_distribution_mock: + mode._sync_rebalance_planner() + assert mode.rebalance_actions_planner._get_supported_distribution(False, False) == mode.trading_config[ index_trading.IndexTradingModeProducer.INDEX_CONTENT ] get_ideal_distribution_mock.assert_called_once() @@ -3634,40 +3654,41 @@ async def test_get_supported_distribution(trading_tools): mode.trading_config = { index_trading.IndexTradingModeProducer.INDEX_CONTENT: [ { - index_trading.index_distribution.DISTRIBUTION_NAME: "BTC", - index_trading.index_distribution.DISTRIBUTION_VALUE: 50 + rebalancer_enums.DistributionKeys.NAME: "BTC", + rebalancer_enums.DistributionKeys.VALUE: 50 }, { - index_trading.index_distribution.DISTRIBUTION_NAME: "ETH", - index_trading.index_distribution.DISTRIBUTION_VALUE: 30 + rebalancer_enums.DistributionKeys.NAME: "ETH", + rebalancer_enums.DistributionKeys.VALUE: 30 }, { - index_trading.index_distribution.DISTRIBUTION_NAME: "USDT", - index_trading.index_distribution.DISTRIBUTION_VALUE: 20 + rebalancer_enums.DistributionKeys.NAME: "USDT", + rebalancer_enums.DistributionKeys.VALUE: 20 }, { - index_trading.index_distribution.DISTRIBUTION_NAME: "PLOP", # not traded - index_trading.index_distribution.DISTRIBUTION_VALUE: 20 + rebalancer_enums.DistributionKeys.NAME: "PLOP", # not traded + rebalancer_enums.DistributionKeys.VALUE: 20 }, ] } - with mock.patch.object(mode, "get_ideal_distribution", mock.Mock(wraps=mode.get_ideal_distribution)) as get_ideal_distribution_mock: - assert mode._get_supported_distribution(False, False) == [ + with mock.patch.object(mode.rebalance_actions_planner._client, "get_ideal_distribution", mock.Mock(wraps=mode.rebalance_actions_planner._client.get_ideal_distribution)) as get_ideal_distribution_mock: + mode._sync_rebalance_planner() + assert mode.rebalance_actions_planner._get_supported_distribution(False, False) == [ { - index_trading.index_distribution.DISTRIBUTION_NAME: "BTC", - index_trading.index_distribution.DISTRIBUTION_VALUE: 50 + rebalancer_enums.DistributionKeys.NAME: "BTC", + rebalancer_enums.DistributionKeys.VALUE: 50 }, { - index_trading.index_distribution.DISTRIBUTION_NAME: "ETH", - index_trading.index_distribution.DISTRIBUTION_VALUE: 30 + rebalancer_enums.DistributionKeys.NAME: "ETH", + rebalancer_enums.DistributionKeys.VALUE: 30 }, { - index_trading.index_distribution.DISTRIBUTION_NAME: "USDT", - index_trading.index_distribution.DISTRIBUTION_VALUE: 20 + rebalancer_enums.DistributionKeys.NAME: "USDT", + rebalancer_enums.DistributionKeys.VALUE: 20 }, # { - # index_trading.index_distribution.DISTRIBUTION_NAME: "PLOP", # not traded - # index_trading.index_distribution.DISTRIBUTION_VALUE: 20 + # rebalancer_enums.DistributionKeys.NAME: "PLOP", # not traded + # rebalancer_enums.DistributionKeys.VALUE: 20 # }, ] get_ideal_distribution_mock.assert_called_once() @@ -3675,26 +3696,27 @@ async def test_get_supported_distribution(trading_tools): mode.trading_config = { index_trading.IndexTradingModeProducer.INDEX_CONTENT: [ { - index_trading.index_distribution.DISTRIBUTION_NAME: "BTC", - index_trading.index_distribution.DISTRIBUTION_VALUE: 50 + rebalancer_enums.DistributionKeys.NAME: "BTC", + rebalancer_enums.DistributionKeys.VALUE: 50 }, { - index_trading.index_distribution.DISTRIBUTION_NAME: "ETH", - index_trading.index_distribution.DISTRIBUTION_VALUE: 30 + rebalancer_enums.DistributionKeys.NAME: "ETH", + rebalancer_enums.DistributionKeys.VALUE: 30 }, { - index_trading.index_distribution.DISTRIBUTION_NAME: "USDT", - index_trading.index_distribution.DISTRIBUTION_VALUE: 20 + rebalancer_enums.DistributionKeys.NAME: "USDT", + rebalancer_enums.DistributionKeys.VALUE: 20 }, ] } # synchronization policy is not SELL_REMOVED_INDEX_COINS_ON_RATIO_REBALANCE - mode.synchronization_policy = index_trading.SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_AS_SOON_AS_POSSIBLE - with mock.patch.object(mode, "get_ideal_distribution", mock.Mock(wraps=mode.get_ideal_distribution)) as get_ideal_distribution_mock: - with mock.patch.object(mode, "_get_currently_applied_historical_config_according_to_holdings", mock.Mock()) as _get_currently_applied_historical_config_according_to_holdings_mock, \ + mode.synchronization_policy = rebalancer_enums.SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_AS_SOON_AS_POSSIBLE + with mock.patch.object(mode.rebalance_actions_planner._client, "get_ideal_distribution", mock.Mock(wraps=mode.rebalance_actions_planner._client.get_ideal_distribution)) as get_ideal_distribution_mock: + with mock.patch.object(mode.rebalance_actions_planner, "_get_currently_applied_historical_config_according_to_holdings", mock.Mock()) as _get_currently_applied_historical_config_according_to_holdings_mock, \ mock.patch.object(mode, "get_historical_configs", mock.Mock()) as get_historical_configs_mock: - assert mode._get_supported_distribution(True, False) == mode.trading_config[ + mode._sync_rebalance_planner() + assert mode.rebalance_actions_planner._get_supported_distribution(True, False) == mode.trading_config[ index_trading.IndexTradingModeProducer.INDEX_CONTENT ] get_ideal_distribution_mock.assert_called_once() @@ -3703,7 +3725,8 @@ async def test_get_supported_distribution(trading_tools): _get_currently_applied_historical_config_according_to_holdings_mock.reset_mock() get_historical_configs_mock.reset_mock() get_ideal_distribution_mock.reset_mock() - assert mode._get_supported_distribution(False, True) == mode.trading_config[ + mode._sync_rebalance_planner() + assert mode.rebalance_actions_planner._get_supported_distribution(False, True) == mode.trading_config[ index_trading.IndexTradingModeProducer.INDEX_CONTENT ] get_ideal_distribution_mock.assert_called_once() @@ -3711,19 +3734,20 @@ async def test_get_supported_distribution(trading_tools): get_historical_configs_mock.assert_not_called() # synchronization policy is SELL_REMOVED_INDEX_COINS_ON_RATIO_REBALANCE - mode.synchronization_policy = index_trading.SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_ON_RATIO_REBALANCE + mode.synchronization_policy = rebalancer_enums.SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_ON_RATIO_REBALANCE holding_adapted_config = { index_trading.IndexTradingModeProducer.INDEX_CONTENT: [ { - index_trading.index_distribution.DISTRIBUTION_NAME: "BTC", - index_trading.index_distribution.DISTRIBUTION_VALUE: 50 + rebalancer_enums.DistributionKeys.NAME: "BTC", + rebalancer_enums.DistributionKeys.VALUE: 50 }, ] } - with mock.patch.object(mode, "get_ideal_distribution", mock.Mock(wraps=mode.get_ideal_distribution)) as get_ideal_distribution_mock: - with mock.patch.object(mode, "_get_currently_applied_historical_config_according_to_holdings", mock.Mock(return_value=holding_adapted_config)) as _get_currently_applied_historical_config_according_to_holdings_mock, \ + with mock.patch.object(mode.rebalance_actions_planner._client, "get_ideal_distribution", mock.Mock(wraps=mode.rebalance_actions_planner._client.get_ideal_distribution)) as get_ideal_distribution_mock: + with mock.patch.object(mode.rebalance_actions_planner, "_get_currently_applied_historical_config_according_to_holdings", mock.Mock(return_value=holding_adapted_config)) as _get_currently_applied_historical_config_according_to_holdings_mock, \ mock.patch.object(mode, "get_historical_configs", mock.Mock()) as get_historical_configs_mock: - assert mode._get_supported_distribution(True, False) == holding_adapted_config[ + mode._sync_rebalance_planner() + assert mode.rebalance_actions_planner._get_supported_distribution(True, False) == holding_adapted_config[ index_trading.IndexTradingModeProducer.INDEX_CONTENT ] assert get_ideal_distribution_mock.call_count == 2 @@ -3737,8 +3761,8 @@ async def test_get_supported_distribution(trading_tools): latest_config = { index_trading.IndexTradingModeProducer.INDEX_CONTENT: [ { - index_trading.index_distribution.DISTRIBUTION_NAME: "ETH", - index_trading.index_distribution.DISTRIBUTION_VALUE: 50 + rebalancer_enums.DistributionKeys.NAME: "ETH", + rebalancer_enums.DistributionKeys.VALUE: 50 }, ] } @@ -3747,9 +3771,10 @@ async def test_get_supported_distribution(trading_tools): holding_adapted_config, ] - with mock.patch.object(mode, "_get_currently_applied_historical_config_according_to_holdings", mock.Mock()) as _get_currently_applied_historical_config_according_to_holdings_mock, \ + with mock.patch.object(mode.rebalance_actions_planner, "_get_currently_applied_historical_config_according_to_holdings", mock.Mock()) as _get_currently_applied_historical_config_according_to_holdings_mock, \ mock.patch.object(mode, "get_historical_configs", mock.Mock(return_value=historical_configs)) as get_historical_configs_mock: - assert mode._get_supported_distribution(False, True) == latest_config[ + mode._sync_rebalance_planner() + assert mode.rebalance_actions_planner._get_supported_distribution(False, True) == latest_config[ index_trading.IndexTradingModeProducer.INDEX_CONTENT ] assert get_ideal_distribution_mock.call_count == 3 @@ -3760,10 +3785,11 @@ async def test_get_supported_distribution(trading_tools): get_ideal_distribution_mock.reset_mock() # without historical configs - with mock.patch.object(mode, "_get_currently_applied_historical_config_according_to_holdings", mock.Mock()) as _get_currently_applied_historical_config_according_to_holdings_mock, \ + with mock.patch.object(mode.rebalance_actions_planner, "_get_currently_applied_historical_config_according_to_holdings", mock.Mock()) as _get_currently_applied_historical_config_according_to_holdings_mock, \ mock.patch.object(mode, "get_historical_configs", mock.Mock(return_value=[])) as get_historical_configs_mock: # use current config - assert mode._get_supported_distribution(False, True) == mode.trading_config[ + mode._sync_rebalance_planner() + assert mode.rebalance_actions_planner._get_supported_distribution(False, True) == mode.trading_config[ index_trading.IndexTradingModeProducer.INDEX_CONTENT ] assert get_ideal_distribution_mock.call_count == 2 @@ -3786,95 +3812,100 @@ async def test_get_currently_applied_historical_config_according_to_holdings(tra for symbol in trader.exchange_manager.exchange_config.traded_symbols ) # 1. using latest config - with mock.patch.object(mode, "_is_index_config_applied", mock.Mock(return_value=True)) as _is_index_config_applied_mock: - assert mode._get_currently_applied_historical_config_according_to_holdings( + with mock.patch.object(mode.rebalance_actions_planner, "_is_target_config_applied", mock.Mock(return_value=True)) as _is_target_config_applied_mock: + mode._sync_rebalance_planner() + assert mode.rebalance_actions_planner._get_currently_applied_historical_config_according_to_holdings( mode.trading_config, traded_bases ) == mode.trading_config - _is_index_config_applied_mock.assert_called_once_with(mode.trading_config, traded_bases) + _is_target_config_applied_mock.assert_called_once_with(mode.trading_config, traded_bases) # 2. using historical configs - with mock.patch.object(mode, "_is_index_config_applied", mock.Mock(return_value=False)) as _is_index_config_applied_mock, mock.patch.object(mode.exchange_manager.exchange, "get_exchange_current_time", mock.Mock(return_value=2)) as get_exchange_current_time_mock: + with mock.patch.object(mode.rebalance_actions_planner, "_is_target_config_applied", mock.Mock(return_value=False)) as _is_target_config_applied_mock, mock.patch.object(mode.exchange_manager.exchange, "get_exchange_current_time", mock.Mock(return_value=2)) as get_exchange_current_time_mock: # 2.1. no historical configs - assert mode._get_currently_applied_historical_config_according_to_holdings( + mode._sync_rebalance_planner() + assert mode.rebalance_actions_planner._get_currently_applied_historical_config_according_to_holdings( mode.trading_config, traded_bases ) == mode.trading_config - _is_index_config_applied_mock.assert_called_once_with(mode.trading_config, traded_bases) - _is_index_config_applied_mock.reset_mock() + _is_target_config_applied_mock.assert_called_once_with(mode.trading_config, traded_bases) + _is_target_config_applied_mock.reset_mock() get_exchange_current_time_mock.assert_called_once() get_exchange_current_time_mock.reset_mock() - # 2.2. with historical configs but as _is_index_config_applied always return False, fallback to current config + # 2.2. with historical configs but as _is_target_config_applied always return False, fallback to current config hist_config_1 = { index_trading.IndexTradingModeProducer.INDEX_CONTENT: [ { - index_trading.index_distribution.DISTRIBUTION_NAME: "BTC", - index_trading.index_distribution.DISTRIBUTION_VALUE: 50 + rebalancer_enums.DistributionKeys.NAME: "BTC", + rebalancer_enums.DistributionKeys.VALUE: 50 }, { - index_trading.index_distribution.DISTRIBUTION_NAME: "ETH", - index_trading.index_distribution.DISTRIBUTION_VALUE: 30 + rebalancer_enums.DistributionKeys.NAME: "ETH", + rebalancer_enums.DistributionKeys.VALUE: 30 }, ] } hist_config_2 = { index_trading.IndexTradingModeProducer.INDEX_CONTENT: [ { - index_trading.index_distribution.DISTRIBUTION_NAME: "BTC", - index_trading.index_distribution.DISTRIBUTION_VALUE: 50 + rebalancer_enums.DistributionKeys.NAME: "BTC", + rebalancer_enums.DistributionKeys.VALUE: 50 }, ] } commons_configuration.add_historical_tentacle_config(mode.trading_config, 1, hist_config_1) commons_configuration.add_historical_tentacle_config(mode.trading_config, 2, hist_config_2) mode.historical_master_config = mode.trading_config - assert mode._get_currently_applied_historical_config_according_to_holdings( + mode._sync_rebalance_planner() + assert mode.rebalance_actions_planner._get_currently_applied_historical_config_according_to_holdings( mode.trading_config, traded_bases ) == mode.trading_config - assert _is_index_config_applied_mock.call_count == 3 - assert _is_index_config_applied_mock.mock_calls[0].args[0] == mode.trading_config - assert _is_index_config_applied_mock.mock_calls[1].args[0] == hist_config_2 - assert _is_index_config_applied_mock.mock_calls[2].args[0] == hist_config_1 - _is_index_config_applied_mock.reset_mock() + assert _is_target_config_applied_mock.call_count == 3 + assert _is_target_config_applied_mock.mock_calls[0].args[0] == mode.trading_config + assert _is_target_config_applied_mock.mock_calls[1].args[0] == hist_config_2 + assert _is_target_config_applied_mock.mock_calls[2].args[0] == hist_config_1 + _is_target_config_applied_mock.reset_mock() get_exchange_current_time_mock.assert_called_once() get_exchange_current_time_mock.reset_mock() - __is_index_config_applied_calls = [] + __is_target_config_applied_calls = [] accepted_config_index = 1 - def __is_index_config_applied(*args): - __is_index_config_applied_calls.append(1) - if len(__is_index_config_applied_calls) - 1 >= accepted_config_index: + def __is_target_config_applied(*args): + __is_target_config_applied_calls.append(1) + if len(__is_target_config_applied_calls) - 1 >= accepted_config_index: return True return False # 2.3. with historical configs using historical config - with mock.patch.object(mode, "_is_index_config_applied", mock.Mock(side_effect=__is_index_config_applied)) as _is_index_config_applied_mock: + with mock.patch.object(mode.rebalance_actions_planner, "_is_target_config_applied", mock.Mock(side_effect=__is_target_config_applied)) as _is_target_config_applied_mock: # 1. use most up to date config - assert mode._get_currently_applied_historical_config_according_to_holdings( + mode._sync_rebalance_planner() + assert mode.rebalance_actions_planner._get_currently_applied_historical_config_according_to_holdings( mode.trading_config, traded_bases ) == hist_config_2 - assert _is_index_config_applied_mock.call_count == 2 - assert _is_index_config_applied_mock.mock_calls[0].args[0] == mode.trading_config - assert _is_index_config_applied_mock.mock_calls[1].args[0] == hist_config_2 - _is_index_config_applied_mock.reset_mock() + assert _is_target_config_applied_mock.call_count == 2 + assert _is_target_config_applied_mock.mock_calls[0].args[0] == mode.trading_config + assert _is_target_config_applied_mock.mock_calls[1].args[0] == hist_config_2 + _is_target_config_applied_mock.reset_mock() get_exchange_current_time_mock.assert_called_once() get_exchange_current_time_mock.reset_mock() - __is_index_config_applied_calls.clear() + __is_target_config_applied_calls.clear() accepted_config_index = 2 - with mock.patch.object(mode, "_is_index_config_applied", mock.Mock(side_effect=__is_index_config_applied)) as _is_index_config_applied_mock: + with mock.patch.object(mode.rebalance_actions_planner, "_is_target_config_applied", mock.Mock(side_effect=__is_target_config_applied)) as _is_target_config_applied_mock: # 2. use oldest config - assert mode._get_currently_applied_historical_config_according_to_holdings( + mode._sync_rebalance_planner() + assert mode.rebalance_actions_planner._get_currently_applied_historical_config_according_to_holdings( mode.trading_config, traded_bases ) == hist_config_1 - assert _is_index_config_applied_mock.call_count == 3 - assert _is_index_config_applied_mock.mock_calls[0].args[0] == mode.trading_config - assert _is_index_config_applied_mock.mock_calls[1].args[0] == hist_config_2 - assert _is_index_config_applied_mock.mock_calls[2].args[0] == hist_config_1 - _is_index_config_applied_mock.reset_mock() + assert _is_target_config_applied_mock.call_count == 3 + assert _is_target_config_applied_mock.mock_calls[0].args[0] == mode.trading_config + assert _is_target_config_applied_mock.mock_calls[1].args[0] == hist_config_2 + assert _is_target_config_applied_mock.mock_calls[2].args[0] == hist_config_1 + _is_target_config_applied_mock.reset_mock() @pytest.mark.parametrize("trading_tools", ["spot", "futures"], indirect=True) -async def test_is_index_config_applied(trading_tools): +async def test_is_target_config_applied(trading_tools): mode, producer, consumer, trader = await _init_mode(trading_tools, _get_config(trading_tools, {})) trader.exchange_manager.exchange_config.traded_symbols = [ commons_symbols.parse_symbol(symbol) @@ -3890,46 +3921,50 @@ async def test_is_index_config_applied(trading_tools): # Test 1: No ideal distribution - should return False config_without_distribution = {} - assert mode._is_index_config_applied(config_without_distribution, traded_bases) is False + mode._sync_rebalance_planner() + assert mode.rebalance_actions_planner._is_target_config_applied(config_without_distribution, traded_bases) is False # Test 2: Empty ideal distribution - should return False config_with_empty_distribution = { index_trading.IndexTradingModeProducer.INDEX_CONTENT: [] } - assert mode._is_index_config_applied(config_with_empty_distribution, traded_bases) is False + mode._sync_rebalance_planner() + assert mode.rebalance_actions_planner._is_target_config_applied(config_with_empty_distribution, traded_bases) is False # Test 3: Distribution with only non-traded assets - should return False config_with_non_traded_assets = { index_trading.IndexTradingModeProducer.INDEX_CONTENT: [ { - index_trading.index_distribution.DISTRIBUTION_NAME: "NON_TRADED_COIN", - index_trading.index_distribution.DISTRIBUTION_VALUE: 100 + rebalancer_enums.DistributionKeys.NAME: "NON_TRADED_COIN", + rebalancer_enums.DistributionKeys.VALUE: 100 } ] } - assert mode._is_index_config_applied(config_with_non_traded_assets, traded_bases) is False + mode._sync_rebalance_planner() + assert mode.rebalance_actions_planner._is_target_config_applied(config_with_non_traded_assets, traded_bases) is False # Test 4: Distribution with zero total ratio - should return False config_with_zero_total = { index_trading.IndexTradingModeProducer.INDEX_CONTENT: [ { - index_trading.index_distribution.DISTRIBUTION_NAME: "BTC", - index_trading.index_distribution.DISTRIBUTION_VALUE: 0 + rebalancer_enums.DistributionKeys.NAME: "BTC", + rebalancer_enums.DistributionKeys.VALUE: 0 } ] } - assert mode._is_index_config_applied(config_with_zero_total, traded_bases) is False + mode._sync_rebalance_planner() + assert mode.rebalance_actions_planner._is_target_config_applied(config_with_zero_total, traded_bases) is False # Test 5: Valid distribution with holdings matching target ratios config_with_valid_distribution = { index_trading.IndexTradingModeProducer.INDEX_CONTENT: [ { - index_trading.index_distribution.DISTRIBUTION_NAME: "BTC", - index_trading.index_distribution.DISTRIBUTION_VALUE: 60 + rebalancer_enums.DistributionKeys.NAME: "BTC", + rebalancer_enums.DistributionKeys.VALUE: 60 }, { - index_trading.index_distribution.DISTRIBUTION_NAME: "ETH", - index_trading.index_distribution.DISTRIBUTION_VALUE: 40 + rebalancer_enums.DistributionKeys.NAME: "ETH", + rebalancer_enums.DistributionKeys.VALUE: 40 } ] } @@ -3972,7 +4007,8 @@ def _get_symbol_position(symbol, side=None): mock.patch.object( portfolio_value_holder, "get_traded_assets_holdings_value", mock.Mock(return_value=total_portfolio_value) ) as get_traded_assets_holdings_value_mock: - assert mode._is_index_config_applied(config_with_valid_distribution, traded_bases) is True + mode._sync_rebalance_planner() + assert mode.rebalance_actions_planner._is_target_config_applied(config_with_valid_distribution, traded_bases) is True assert get_symbol_position_mock.call_count == 2 assert "BTC" in str(get_symbol_position_mock.mock_calls[0].args[0]) assert "ETH" in str(get_symbol_position_mock.mock_calls[1].args[0]) @@ -3987,7 +4023,8 @@ def _get_symbol_position(symbol, side=None): "ETH": decimal.Decimal("0.4"), # 40% target }.get(coin, decimal.Decimal("0"))) ) as get_holdings_ratio_mock: - assert mode._is_index_config_applied(config_with_valid_distribution, traded_bases) is True + mode._sync_rebalance_planner() + assert mode.rebalance_actions_planner._is_target_config_applied(config_with_valid_distribution, traded_bases) is True assert get_holdings_ratio_mock.call_count == 2 assert get_holdings_ratio_mock.mock_calls[0].args[0] == "BTC" assert get_holdings_ratio_mock.mock_calls[1].args[0] == "ETH" @@ -4001,7 +4038,8 @@ def _get_symbol_position(symbol, side=None): "ETH": decimal.Decimal("0.38"), # 40% target - 2% (within 5% tolerance) }.get(coin, decimal.Decimal("0"))) ) as get_holdings_ratio_mock: - assert mode._is_index_config_applied(config_with_valid_distribution, traded_bases) is True + mode._sync_rebalance_planner() + assert mode.rebalance_actions_planner._is_target_config_applied(config_with_valid_distribution, traded_bases) is True assert get_holdings_ratio_mock.call_count == 2 get_holdings_ratio_mock.reset_mock() @@ -4013,9 +4051,12 @@ def _get_symbol_position(symbol, side=None): "ETH": decimal.Decimal("0.32"), # 40% target - 8% (outside 5% tolerance) }.get(coin, decimal.Decimal("0"))) ) as get_holdings_ratio_mock: - assert mode._is_index_config_applied(config_with_valid_distribution, traded_bases) is False + mode._sync_rebalance_planner() + assert mode.rebalance_actions_planner._is_target_config_applied(config_with_valid_distribution, traded_bases) is False assert get_holdings_ratio_mock.call_count == 1 # only BTC is considered - get_holdings_ratio_mock.assert_called_once_with("BTC", traded_symbols_only=True) + get_holdings_ratio_mock.assert_called_once_with( + "BTC", traded_symbols_only=True, include_assets_in_open_orders=False, coins_whitelist=None + ) get_holdings_ratio_mock.reset_mock() # Test 8: Missing coin in portfolio - should return False @@ -4026,7 +4067,8 @@ def _get_symbol_position(symbol, side=None): "ETH": decimal.Decimal("0"), # Missing ETH }.get(coin, decimal.Decimal("0"))) ) as get_holdings_ratio_mock: - assert mode._is_index_config_applied(config_with_valid_distribution, traded_bases) is False + mode._sync_rebalance_planner() + assert mode.rebalance_actions_planner._is_target_config_applied(config_with_valid_distribution, traded_bases) is False assert get_holdings_ratio_mock.call_count == 2 get_holdings_ratio_mock.reset_mock() @@ -4038,7 +4080,8 @@ def _get_symbol_position(symbol, side=None): "ETH": decimal.Decimal("0.3"), # 40% target - 10% (too little) }.get(coin, decimal.Decimal("0"))) ) as get_holdings_ratio_mock: - assert mode._is_index_config_applied(config_with_valid_distribution, traded_bases) is False + mode._sync_rebalance_planner() + assert mode.rebalance_actions_planner._is_target_config_applied(config_with_valid_distribution, traded_bases) is False assert get_holdings_ratio_mock.call_count == 2 # BTC and ETH considered assert get_holdings_ratio_mock.mock_calls[0].args[0] == "BTC" assert get_holdings_ratio_mock.mock_calls[1].args[0] == "ETH" @@ -4048,12 +4091,12 @@ def _get_symbol_position(symbol, side=None): config_with_custom_trigger = { index_trading.IndexTradingModeProducer.INDEX_CONTENT: [ { - index_trading.index_distribution.DISTRIBUTION_NAME: "BTC", - index_trading.index_distribution.DISTRIBUTION_VALUE: 50 + rebalancer_enums.DistributionKeys.NAME: "BTC", + rebalancer_enums.DistributionKeys.VALUE: 50 }, { - index_trading.index_distribution.DISTRIBUTION_NAME: "ETH", - index_trading.index_distribution.DISTRIBUTION_VALUE: 50 + rebalancer_enums.DistributionKeys.NAME: "ETH", + rebalancer_enums.DistributionKeys.VALUE: 50 } ], index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_MIN_PERCENT: 10.0 # 10% tolerance @@ -4067,7 +4110,8 @@ def _get_symbol_position(symbol, side=None): "ETH": decimal.Decimal("0.43"), # 50% target - 7% (within 10% tolerance) }.get(coin, decimal.Decimal("0"))) ) as get_holdings_ratio_mock: - assert mode._is_index_config_applied(config_with_custom_trigger, traded_bases) is True + mode._sync_rebalance_planner() + assert mode.rebalance_actions_planner._is_target_config_applied(config_with_custom_trigger, traded_bases) is True assert get_holdings_ratio_mock.call_count == 2 get_holdings_ratio_mock.reset_mock() @@ -4079,21 +4123,24 @@ def _get_symbol_position(symbol, side=None): "ETH": decimal.Decimal("0.35"), # 50% target - 15% (outside 10% tolerance) }.get(coin, decimal.Decimal("0"))) ) as get_holdings_ratio_mock: - assert mode._is_index_config_applied(config_with_custom_trigger, traded_bases) is False + mode._sync_rebalance_planner() + assert mode.rebalance_actions_planner._is_target_config_applied(config_with_custom_trigger, traded_bases) is False assert get_holdings_ratio_mock.call_count == 1 # only BTC is considered - get_holdings_ratio_mock.assert_called_once_with("BTC", traded_symbols_only=True) + get_holdings_ratio_mock.assert_called_once_with( + "BTC", traded_symbols_only=True, include_assets_in_open_orders=False, coins_whitelist=None + ) get_holdings_ratio_mock.reset_mock() # Test 10b: Custom rebalance trigger ratio in config from REBALANCE_TRIGGER_MIN_PERCENT config_with_custom_trigger = { index_trading.IndexTradingModeProducer.INDEX_CONTENT: [ { - index_trading.index_distribution.DISTRIBUTION_NAME: "BTC", - index_trading.index_distribution.DISTRIBUTION_VALUE: 50 + rebalancer_enums.DistributionKeys.NAME: "BTC", + rebalancer_enums.DistributionKeys.VALUE: 50 }, { - index_trading.index_distribution.DISTRIBUTION_NAME: "ETH", - index_trading.index_distribution.DISTRIBUTION_VALUE: 50 + rebalancer_enums.DistributionKeys.NAME: "ETH", + rebalancer_enums.DistributionKeys.VALUE: 50 } ], index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILES: [ @@ -4114,7 +4161,8 @@ def _get_symbol_position(symbol, side=None): "ETH": decimal.Decimal("0.43"), # 50% target - 7% (within 10% tolerance) }.get(coin, decimal.Decimal("0"))) ) as get_holdings_ratio_mock: - assert mode._is_index_config_applied(config_with_custom_trigger, traded_bases) is True + mode._sync_rebalance_planner() + assert mode.rebalance_actions_planner._is_target_config_applied(config_with_custom_trigger, traded_bases) is True assert get_holdings_ratio_mock.call_count == 2 get_holdings_ratio_mock.reset_mock() @@ -4126,25 +4174,28 @@ def _get_symbol_position(symbol, side=None): "ETH": decimal.Decimal("0.35"), # 50% target - 15% (outside 10% tolerance) }.get(coin, decimal.Decimal("0"))) ) as get_holdings_ratio_mock: - assert mode._is_index_config_applied(config_with_custom_trigger, traded_bases) is False + mode._sync_rebalance_planner() + assert mode.rebalance_actions_planner._is_target_config_applied(config_with_custom_trigger, traded_bases) is False assert get_holdings_ratio_mock.call_count == 1 # only BTC is considered - get_holdings_ratio_mock.assert_called_once_with("BTC", traded_symbols_only=True) + get_holdings_ratio_mock.assert_called_once_with( + "BTC", traded_symbols_only=True, include_assets_in_open_orders=False, coins_whitelist=None + ) get_holdings_ratio_mock.reset_mock() # Test 11: Mixed traded and non-traded assets config_with_mixed_assets = { index_trading.IndexTradingModeProducer.INDEX_CONTENT: [ { - index_trading.index_distribution.DISTRIBUTION_NAME: "BTC", - index_trading.index_distribution.DISTRIBUTION_VALUE: 60 + rebalancer_enums.DistributionKeys.NAME: "BTC", + rebalancer_enums.DistributionKeys.VALUE: 60 }, { - index_trading.index_distribution.DISTRIBUTION_NAME: "ETH", - index_trading.index_distribution.DISTRIBUTION_VALUE: 30 + rebalancer_enums.DistributionKeys.NAME: "ETH", + rebalancer_enums.DistributionKeys.VALUE: 30 }, { - index_trading.index_distribution.DISTRIBUTION_NAME: "NON_TRADED_COIN", - index_trading.index_distribution.DISTRIBUTION_VALUE: 10 + rebalancer_enums.DistributionKeys.NAME: "NON_TRADED_COIN", + rebalancer_enums.DistributionKeys.VALUE: 10 } ] } @@ -4157,32 +4208,37 @@ def _get_symbol_position(symbol, side=None): "ETH": decimal.Decimal("0.3333333333333333333333333333"), # 30/90 = 33.33% }.get(coin, decimal.Decimal("0"))) ) as get_holdings_ratio_mock: - assert mode._is_index_config_applied(config_with_mixed_assets, traded_bases) is False + mode._sync_rebalance_planner() + assert mode.rebalance_actions_planner._is_target_config_applied(config_with_mixed_assets, traded_bases) is False get_holdings_ratio_mock.assert_not_called() # Test 12: All assets non-traded config_all_non_traded = { index_trading.IndexTradingModeProducer.INDEX_CONTENT: [ { - index_trading.index_distribution.DISTRIBUTION_NAME: "NON_TRADED_1", - index_trading.index_distribution.DISTRIBUTION_VALUE: 50 + rebalancer_enums.DistributionKeys.NAME: "NON_TRADED_1", + rebalancer_enums.DistributionKeys.VALUE: 50 }, { - index_trading.index_distribution.DISTRIBUTION_NAME: "NON_TRADED_2", - index_trading.index_distribution.DISTRIBUTION_VALUE: 50 + rebalancer_enums.DistributionKeys.NAME: "NON_TRADED_2", + rebalancer_enums.DistributionKeys.VALUE: 50 } ] } - assert mode._is_index_config_applied(config_all_non_traded, traded_bases) is False + mode._sync_rebalance_planner() + assert mode.rebalance_actions_planner._is_target_config_applied(config_all_non_traded, traded_bases) is False # Test 13: Zero holdings for all coins with mock.patch.object( trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio_value_holder, "get_holdings_ratio", mock.Mock(return_value=decimal.Decimal("0")) ) as get_holdings_ratio_mock: - assert mode._is_index_config_applied(config_with_valid_distribution, traded_bases) is False + mode._sync_rebalance_planner() + assert mode.rebalance_actions_planner._is_target_config_applied(config_with_valid_distribution, traded_bases) is False assert get_holdings_ratio_mock.call_count == 1 # only BTC considered - get_holdings_ratio_mock.assert_called_once_with("BTC", traded_symbols_only=True) + get_holdings_ratio_mock.assert_called_once_with( + "BTC", traded_symbols_only=True, include_assets_in_open_orders=False, coins_whitelist=None + ) get_holdings_ratio_mock.reset_mock() @@ -4204,19 +4260,22 @@ async def test_get_config_min_ratio(trading_tools): index_trading.IndexTradingModeProducer.SELECTED_REBALANCE_TRIGGER_PROFILE: "profile-2", } # Should pick 15.0% from profile-2 - assert mode._get_config_min_ratio(config_with_profiles) == decimal.Decimal("0.15") + mode._sync_rebalance_planner() + assert mode.rebalance_actions_planner._get_config_min_ratio(config_with_profiles) == decimal.Decimal("0.15") # 2. With direct config value only config_with_direct = { index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_MIN_PERCENT: 3.3 } # Should pick 3.3% from direct config - assert mode._get_config_min_ratio(config_with_direct) == decimal.Decimal("0.033") + mode._sync_rebalance_planner() + assert mode.rebalance_actions_planner._get_config_min_ratio(config_with_direct) == decimal.Decimal("0.033") # 3. With neither, should fall back to mode.rebalance_trigger_min_ratio mode.rebalance_trigger_min_ratio = decimal.Decimal("0.123") config_empty = {} - assert mode._get_config_min_ratio(config_empty) == decimal.Decimal("0.123") + mode._sync_rebalance_planner() + assert mode.rebalance_actions_planner._get_config_min_ratio(config_empty) == decimal.Decimal("0.123") # 4. With profiles but no selected profile matches, should fall back to direct config config_profiles_no_match = { @@ -4229,4 +4288,5 @@ async def test_get_config_min_ratio(trading_tools): index_trading.IndexTradingModeProducer.SELECTED_REBALANCE_TRIGGER_PROFILE: "profile-x", index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_MIN_PERCENT: 2.2 } - assert mode._get_config_min_ratio(config_profiles_no_match) == decimal.Decimal("0.022") + mode._sync_rebalance_planner() + assert mode.rebalance_actions_planner._get_config_min_ratio(config_profiles_no_match) == decimal.Decimal("0.022") diff --git a/packages/tentacles/Trading/Mode/profile_copy_trading_mode/profile_copy_trading.py b/packages/tentacles/Trading/Mode/profile_copy_trading_mode/profile_copy_trading.py index 2843bbf49..62d15392e 100644 --- a/packages/tentacles/Trading/Mode/profile_copy_trading_mode/profile_copy_trading.py +++ b/packages/tentacles/Trading/Mode/profile_copy_trading_mode/profile_copy_trading.py @@ -259,6 +259,7 @@ class ProfileCopyTradingModeProducer(index_trading_mode.IndexTradingModeProducer def __init__(self, channel, config, trading_mode, exchange_manager): super().__init__(channel, config, trading_mode, exchange_manager) + self.trading_mode: ProfileCopyTradingMode = typing.cast(ProfileCopyTradingMode, self.trading_mode) self.requires_initializing_appropriate_coins_distribution = False self.trading_mode.synchronization_policy = index_trading_mode.SynchronizationPolicy.SELL_REMOVED_DYNAMIC_INDEX_COINS_AS_SOON_AS_POSSIBLE diff --git a/packages/tentacles/Trading/Mode/profile_copy_trading_mode/profile_distribution.py b/packages/tentacles/Trading/Mode/profile_copy_trading_mode/profile_distribution.py index 60dee5a9a..ecdaca595 100644 --- a/packages/tentacles/Trading/Mode/profile_copy_trading_mode/profile_distribution.py +++ b/packages/tentacles/Trading/Mode/profile_copy_trading_mode/profile_distribution.py @@ -24,6 +24,7 @@ import octobot_commons.symbols as symbols_util import tentacles.Trading.Mode.index_trading_mode.index_distribution as index_distribution +import octobot_copy.enums as copy_enums if typing.TYPE_CHECKING: import tentacles.Services.Services_feeds.exchange_service_feed as exchange_service_feed @@ -355,22 +356,22 @@ def update_global_distribution( total_effective_allocation += effective_profile_ratio ratio_per_asset = { - asset[index_distribution.DISTRIBUTION_NAME]: asset + asset[copy_enums.DistributionKeys.NAME]: asset for asset in distribution } for asset_name, asset_dict in ratio_per_asset.items(): - distribution_value = decimal.Decimal(str(asset_dict[index_distribution.DISTRIBUTION_VALUE])) + distribution_value = decimal.Decimal(str(asset_dict[copy_enums.DistributionKeys.VALUE])) weighted_value = distribution_value * effective_profile_ratio - distribution_price = asset_dict.get(index_distribution.DISTRIBUTION_PRICE) + distribution_price = asset_dict.get(copy_enums.DistributionKeys.PRICE) if asset_name in merged_ratio_per_asset: - existing_value = decimal.Decimal(str(merged_ratio_per_asset[asset_name][index_distribution.DISTRIBUTION_VALUE])) - merged_ratio_per_asset[asset_name][index_distribution.DISTRIBUTION_VALUE] = existing_value + weighted_value + existing_value = decimal.Decimal(str(merged_ratio_per_asset[asset_name][copy_enums.DistributionKeys.VALUE])) + merged_ratio_per_asset[asset_name][copy_enums.DistributionKeys.VALUE] = existing_value + weighted_value else: merged_ratio_per_asset[asset_name] = { - index_distribution.DISTRIBUTION_NAME: asset_dict[index_distribution.DISTRIBUTION_NAME], - index_distribution.DISTRIBUTION_VALUE: weighted_value + copy_enums.DistributionKeys.NAME: asset_dict[copy_enums.DistributionKeys.NAME], + copy_enums.DistributionKeys.VALUE: weighted_value } if distribution_price is not None: @@ -389,11 +390,11 @@ def update_global_distribution( ratio_per_asset = merged_ratio_per_asset total_ratio_per_asset = sum( - decimal.Decimal(str(asset[index_distribution.DISTRIBUTION_VALUE])) + decimal.Decimal(str(asset[copy_enums.DistributionKeys.VALUE])) for asset in ratio_per_asset.values() ) indexed_coins = [ - asset[index_distribution.DISTRIBUTION_NAME] + asset[copy_enums.DistributionKeys.NAME] for asset in ratio_per_asset.values() ] diff --git a/packages/tentacles/Trading/Mode/profile_copy_trading_mode/tests/test_profile_copy_trading.py b/packages/tentacles/Trading/Mode/profile_copy_trading_mode/tests/test_profile_copy_trading.py index 04008e3cf..9b89bb1e3 100644 --- a/packages/tentacles/Trading/Mode/profile_copy_trading_mode/tests/test_profile_copy_trading.py +++ b/packages/tentacles/Trading/Mode/profile_copy_trading_mode/tests/test_profile_copy_trading.py @@ -36,8 +36,8 @@ import tentacles.Trading.Mode as Mode import tentacles.Trading.Mode.profile_copy_trading_mode.profile_copy_trading as profile_copy_trading import tentacles.Trading.Mode.profile_copy_trading_mode.profile_distribution as profile_distribution -import tentacles.Trading.Mode.index_trading_mode.index_distribution as index_distribution import tentacles.Trading.Mode.index_trading_mode.index_trading as index_trading +import octobot_copy.enums as rebalancer_enums import tests.test_utils.config as test_utils_config import tests.test_utils.test_exchanges as test_exchanges @@ -129,8 +129,8 @@ async def test_validate_portfolio_allocation_feasibility_valid_cases(tools): "profile1": { profile_distribution.DISTRIBUTION_KEY: [ { - index_distribution.DISTRIBUTION_NAME: "BTC/USDT", - index_distribution.DISTRIBUTION_VALUE: 100.0, + rebalancer_enums.DistributionKeys.NAME: "BTC/USDT", + rebalancer_enums.DistributionKeys.VALUE: 100.0, } ], profile_distribution.TRADABLE_RATIO: decimal.Decimal("1"), @@ -138,8 +138,8 @@ async def test_validate_portfolio_allocation_feasibility_valid_cases(tools): "profile2": { profile_distribution.DISTRIBUTION_KEY: [ { - index_distribution.DISTRIBUTION_NAME: "ETH/USDT", - index_distribution.DISTRIBUTION_VALUE: 100.0, + rebalancer_enums.DistributionKeys.NAME: "ETH/USDT", + rebalancer_enums.DistributionKeys.VALUE: 100.0, } ], profile_distribution.TRADABLE_RATIO: decimal.Decimal("1"), @@ -257,7 +257,7 @@ async def test_close_positions_when_filtered_out_default_is_false(tools): async def test_dynamic_synchronization_policy_removes_coins_not_in_indexed_coins(tools): mode, producer, _, _ = await _init_mode(tools, _get_config(tools, {})) - assert mode.synchronization_policy == index_trading.SynchronizationPolicy.SELL_REMOVED_DYNAMIC_INDEX_COINS_AS_SOON_AS_POSSIBLE + assert mode.synchronization_policy == rebalancer_enums.SynchronizationPolicy.SELL_REMOVED_DYNAMIC_INDEX_COINS_AS_SOON_AS_POSSIBLE mode.indexed_coins = ["BTC"] # USDT is the reference market and must be excluded; ETH is in traded bases but not indexed removed = mode.get_removed_coins_from_config({"BTC", "ETH", "USDT"}) @@ -268,8 +268,8 @@ async def test_rebalance_with_pending_open_orders(tools): mode.indexed_coins = ["BTC"] mode.ratio_per_asset = { "BTC": { - index_distribution.DISTRIBUTION_NAME: "BTC", - index_distribution.DISTRIBUTION_VALUE: decimal.Decimal("100"), + rebalancer_enums.DistributionKeys.NAME: "BTC", + rebalancer_enums.DistributionKeys.VALUE: decimal.Decimal("100"), } } mode.total_ratio_per_asset = decimal.Decimal("100") @@ -289,4 +289,4 @@ def _fake_get_holdings_ratio(currency, traded_symbols_only=False, include_assets # Without open orders we should have rebalanced # As we have open orders using USDT, the rebalance should not be triggered as the current ratio is 0.2 which is above the threshold of 0.1 assert should_rebalance is False - assert details[index_trading.RebalanceDetails.FORCED_REBALANCE.value] is False + assert details[rebalancer_enums.RebalanceDetails.FORCED_REBALANCE.value] is False diff --git a/packages/tentacles/Trading/Mode/profile_copy_trading_mode/tests/test_profile_distribution.py b/packages/tentacles/Trading/Mode/profile_copy_trading_mode/tests/test_profile_distribution.py index 5ac8073a8..18057ac2d 100644 --- a/packages/tentacles/Trading/Mode/profile_copy_trading_mode/tests/test_profile_distribution.py +++ b/packages/tentacles/Trading/Mode/profile_copy_trading_mode/tests/test_profile_distribution.py @@ -4,7 +4,7 @@ import typing import tentacles.Trading.Mode.profile_copy_trading_mode.profile_distribution as profile_distribution -import tentacles.Trading.Mode.index_trading_mode.index_distribution as index_distribution +import octobot_copy.enums as copy_enums import octobot_trading.enums as trading_enums import octobot_trading.constants as trading_constants @@ -85,9 +85,9 @@ def _make_distribution(assets: list[tuple]) -> list[dict]: """Helper to create distribution list from (name, value, price) tuples.""" return [ { - index_distribution.DISTRIBUTION_NAME: name, - index_distribution.DISTRIBUTION_VALUE: value, - index_distribution.DISTRIBUTION_PRICE: price, + copy_enums.DistributionKeys.NAME: name, + copy_enums.DistributionKeys.VALUE: value, + copy_enums.DistributionKeys.PRICE: price, } for name, value, price in assets ] @@ -121,11 +121,11 @@ def test_update_global_distribution_merges_overlapping_assets(): ) # BTC: (50.0 * 0.5) + (40.0 * 0.5) = 45.0, ETH: 30.0 * 0.5 = 15.0, SOL: 60.0 * 0.5 = 30.0 - assert result[profile_distribution.RATIO_PER_ASSET]["BTC"][index_distribution.DISTRIBUTION_VALUE] == decimal.Decimal("45.0") + assert result[profile_distribution.RATIO_PER_ASSET]["BTC"][copy_enums.DistributionKeys.VALUE] == decimal.Decimal("45.0") # ETH should be weighted: 30.0 * 0.5 = 15.0 - assert result[profile_distribution.RATIO_PER_ASSET]["ETH"][index_distribution.DISTRIBUTION_VALUE] == decimal.Decimal("15.0") + assert result[profile_distribution.RATIO_PER_ASSET]["ETH"][copy_enums.DistributionKeys.VALUE] == decimal.Decimal("15.0") # SOL should be weighted: 60.0 * 0.5 = 30.0 - assert result[profile_distribution.RATIO_PER_ASSET]["SOL"][index_distribution.DISTRIBUTION_VALUE] == decimal.Decimal("30.0") + assert result[profile_distribution.RATIO_PER_ASSET]["SOL"][copy_enums.DistributionKeys.VALUE] == decimal.Decimal("30.0") # Total should be 45.0 + 15.0 + 30.0 = 90.0 assert result[profile_distribution.TOTAL_RATIO_PER_ASSET] == decimal.Decimal("90.0") @@ -190,17 +190,17 @@ def test_get_smoothed_distribution_from_profile_data_aggregates_same_symbols(): # BTC should have aggregated margin: 100 + 50 = 150 out of 200 total (75%) # ETH should have 50 out of 200 total (25%) - btc_dist = next((d for d in result if d[index_distribution.DISTRIBUTION_NAME] == "BTC/USDT"), None) - eth_dist = next((d for d in result if d[index_distribution.DISTRIBUTION_NAME] == "ETH/USDT"), None) + btc_dist = next((d for d in result if d[copy_enums.DistributionKeys.NAME] == "BTC/USDT"), None) + eth_dist = next((d for d in result if d[copy_enums.DistributionKeys.NAME] == "ETH/USDT"), None) assert btc_dist is not None assert eth_dist is not None # BTC should have higher value than ETH due to aggregated margin - assert btc_dist[index_distribution.DISTRIBUTION_VALUE] > eth_dist[index_distribution.DISTRIBUTION_VALUE] + assert btc_dist[copy_enums.DistributionKeys.VALUE] > eth_dist[copy_enums.DistributionKeys.VALUE] # Verify price information is included in the distribution # When multiple positions have the same symbol, the last price is used (51000.0) - assert btc_dist[index_distribution.DISTRIBUTION_PRICE] == decimal.Decimal("51000.0") - assert eth_dist[index_distribution.DISTRIBUTION_PRICE] == decimal.Decimal("3000.0") + assert btc_dist[copy_enums.DistributionKeys.PRICE] == decimal.Decimal("51000.0") + assert eth_dist[copy_enums.DistributionKeys.PRICE] == decimal.Decimal("3000.0") # without prices profile_data.positions[-1].pop(trading_enums.ExchangeConstantsPositionColumns.ENTRY_PRICE.value) @@ -208,15 +208,15 @@ def test_get_smoothed_distribution_from_profile_data_aggregates_same_symbols(): profile_data, new_position_only=False, started_at=started_at ) - btc_dist = next((d for d in result if d[index_distribution.DISTRIBUTION_NAME] == "BTC/USDT"), None) - eth_dist = next((d for d in result if d[index_distribution.DISTRIBUTION_NAME] == "ETH/USDT"), None) + btc_dist = next((d for d in result if d[copy_enums.DistributionKeys.NAME] == "BTC/USDT"), None) + eth_dist = next((d for d in result if d[copy_enums.DistributionKeys.NAME] == "ETH/USDT"), None) assert btc_dist is not None assert eth_dist is not None # BTC should still have price from the second BTC position (51000.0) - assert btc_dist[index_distribution.DISTRIBUTION_PRICE] == decimal.Decimal("51000.0") + assert btc_dist[copy_enums.DistributionKeys.PRICE] == decimal.Decimal("51000.0") # ETH should have price as 0 (default value when missing) - assert eth_dist[index_distribution.DISTRIBUTION_PRICE] == decimal.Decimal("0") + assert eth_dist[copy_enums.DistributionKeys.PRICE] == decimal.Decimal("0") def test_update_global_distribution_merges_identical_assets_from_multiple_profiles(): @@ -237,19 +237,19 @@ def test_update_global_distribution_merges_identical_assets_from_multiple_profil ) # Both profiles contribute 100.0 * 0.4 = 40.0, merged = 80.0 - assert result[profile_distribution.RATIO_PER_ASSET]["BTC"][index_distribution.DISTRIBUTION_VALUE] == decimal.Decimal("80.0") + assert result[profile_distribution.RATIO_PER_ASSET]["BTC"][copy_enums.DistributionKeys.VALUE] == decimal.Decimal("80.0") assert result[profile_distribution.TOTAL_RATIO_PER_ASSET] == decimal.Decimal("80.0") def test_update_global_distribution_handles_missing_prices(): distribution_per_exchange_profile = { "profile1": _make_profile_dist([ - {index_distribution.DISTRIBUTION_NAME: "BTC", index_distribution.DISTRIBUTION_VALUE: 50.0, index_distribution.DISTRIBUTION_PRICE: decimal.Decimal("50000")}, - {index_distribution.DISTRIBUTION_NAME: "ETH", index_distribution.DISTRIBUTION_VALUE: 30.0}, # No price + {copy_enums.DistributionKeys.NAME: "BTC", copy_enums.DistributionKeys.VALUE: 50.0, copy_enums.DistributionKeys.PRICE: decimal.Decimal("50000")}, + {copy_enums.DistributionKeys.NAME: "ETH", copy_enums.DistributionKeys.VALUE: 30.0}, # No price ]), "profile2": _make_profile_dist([ - {index_distribution.DISTRIBUTION_NAME: "BTC", index_distribution.DISTRIBUTION_VALUE: 40.0}, # No price - {index_distribution.DISTRIBUTION_NAME: "SOL", index_distribution.DISTRIBUTION_VALUE: 60.0, index_distribution.DISTRIBUTION_PRICE: decimal.Decimal("100")}, + {copy_enums.DistributionKeys.NAME: "BTC", copy_enums.DistributionKeys.VALUE: 40.0}, # No price + {copy_enums.DistributionKeys.NAME: "SOL", copy_enums.DistributionKeys.VALUE: 60.0, copy_enums.DistributionKeys.PRICE: decimal.Decimal("100")}, ]), } @@ -353,18 +353,18 @@ def test_get_smoothed_distribution_from_profile_data_respects_new_position_only( profile_data, new_position_only=test_case.new_position_only, started_at=started_at ) - btc_dist = next((d for d in result if d[index_distribution.DISTRIBUTION_NAME] == "BTC/USDT"), None) - eth_dist = next((d for d in result if d[index_distribution.DISTRIBUTION_NAME] == "ETH/USDT"), None) + btc_dist = next((d for d in result if d[copy_enums.DistributionKeys.NAME] == "BTC/USDT"), None) + eth_dist = next((d for d in result if d[copy_enums.DistributionKeys.NAME] == "ETH/USDT"), None) assert (btc_dist is not None) == test_case.expected_btc_present assert (eth_dist is not None) == test_case.expected_eth_present if test_case.expected_eth_present: - assert eth_dist[index_distribution.DISTRIBUTION_VALUE] > decimal.Decimal("0") - assert eth_dist[index_distribution.DISTRIBUTION_PRICE] == decimal.Decimal("3000.0") + assert eth_dist[copy_enums.DistributionKeys.VALUE] > decimal.Decimal("0") + assert eth_dist[copy_enums.DistributionKeys.PRICE] == decimal.Decimal("3000.0") if test_case.btc_higher_than_eth and btc_dist is not None and eth_dist is not None: - assert btc_dist[index_distribution.DISTRIBUTION_VALUE] > eth_dist[index_distribution.DISTRIBUTION_VALUE] + assert btc_dist[copy_enums.DistributionKeys.VALUE] > eth_dist[copy_enums.DistributionKeys.VALUE] @pytest.mark.parametrize( @@ -401,8 +401,8 @@ def test_update_distribution_based_on_profile_data_respects_new_position_only( assert "profile1" in result profile_result = result["profile1"] distribution = profile_result["distribution"] - btc_dist = next((d for d in distribution if d[index_distribution.DISTRIBUTION_NAME] == "BTC/USDT"), None) - eth_dist = next((d for d in distribution if d[index_distribution.DISTRIBUTION_NAME] == "ETH/USDT"), None) + btc_dist = next((d for d in distribution if d[copy_enums.DistributionKeys.NAME] == "BTC/USDT"), None) + eth_dist = next((d for d in distribution if d[copy_enums.DistributionKeys.NAME] == "ETH/USDT"), None) assert (btc_dist is not None) == expected_btc_present assert (eth_dist is not None) == expected_eth_present @@ -477,7 +477,7 @@ def test_get_smoothed_distribution_from_profile_data_respects_min_unrealized_pnl profile_data, new_position_only=False, started_at=started_at, min_unrealized_pnl_percent=0.1, ) - symbols = [d[index_distribution.DISTRIBUTION_NAME] for d in result] + symbols = [d[copy_enums.DistributionKeys.NAME] for d in result] assert "BTC/USDT" in symbols assert "ETH/USDT" not in symbols assert tradable_ratio == decimal.Decimal("0.5") @@ -565,7 +565,7 @@ def test_get_smoothed_distribution_from_profile_data_respects_min_position_size( profile_data, new_position_only=False, started_at=started_at, min_position_size=decimal.Decimal("5"), ) - symbols = [d[index_distribution.DISTRIBUTION_NAME] for d in result] + symbols = [d[copy_enums.DistributionKeys.NAME] for d in result] assert "BTC/USDT" in symbols assert "ETH/USDT" not in symbols assert tradable_ratio == decimal.Decimal("0.5") @@ -633,7 +633,7 @@ def test_tradable_ratio_with_new_position_only(): ) # Only ETH should be in distribution (BTC is old), tradable_ratio = 100/200 = 0.5 - symbols = [d[index_distribution.DISTRIBUTION_NAME] for d in result] + symbols = [d[copy_enums.DistributionKeys.NAME] for d in result] assert "BTC/USDT" not in symbols assert "ETH/USDT" in symbols assert tradable_ratio == decimal.Decimal("0.5") @@ -702,7 +702,7 @@ def test_get_smoothed_distribution_portfolio_scenarios(test_case: PortfolioTestC assert source == test_case.expected_source assert tradable_ratio == test_case.expected_tradable_ratio - symbols = [d[index_distribution.DISTRIBUTION_NAME] for d in result] + symbols = [d[copy_enums.DistributionKeys.NAME] for d in result] assert symbols == test_case.expected_symbols # Check excluded symbols are not present @@ -711,9 +711,9 @@ def test_get_smoothed_distribution_portfolio_scenarios(test_case: PortfolioTestC # Check weight assertions if test_case.weight_assertion == "ETH > BTC": - btc_dist = next((d for d in result if d[index_distribution.DISTRIBUTION_NAME] == "BTC"), None) - eth_dist = next((d for d in result if d[index_distribution.DISTRIBUTION_NAME] == "ETH"), None) - assert eth_dist[index_distribution.DISTRIBUTION_VALUE] > btc_dist[index_distribution.DISTRIBUTION_VALUE] + btc_dist = next((d for d in result if d[copy_enums.DistributionKeys.NAME] == "BTC"), None) + eth_dist = next((d for d in result if d[copy_enums.DistributionKeys.NAME] == "ETH"), None) + assert eth_dist[copy_enums.DistributionKeys.VALUE] > btc_dist[copy_enums.DistributionKeys.VALUE] @pytest.mark.parametrize("allocation_ratio,padding_ratio,expected_ref_market", [ # No padding: 50% allocation = 50% reference market @@ -782,7 +782,7 @@ def test_get_reference_market_balance(): def _get_distribution_symbols(distribution: list) -> list[str]: - return [d[index_distribution.DISTRIBUTION_NAME] for d in distribution] + return [d[copy_enums.DistributionKeys.NAME] for d in distribution] def test_update_distribution_tracks_passing_positions(): @@ -984,7 +984,7 @@ def test_position_value_uses_notional_when_initial_margin_is_zero(): profile_data, new_position_only=False, started_at=started_at ) assert len(result) == 1 - assert result[0][profile_distribution.index_distribution.DISTRIBUTION_NAME] == "EVENT/USDC:USDC-YES" + assert result[0][copy_enums.DistributionKeys.NAME] == "EVENT/USDC:USDC-YES" assert tradable_ratio == trading_constants.ONE diff --git a/packages/tentacles/Trading/Mode/staggered_orders_trading_mode/staggered_orders_trading.py b/packages/tentacles/Trading/Mode/staggered_orders_trading_mode/staggered_orders_trading.py index 586f5d88a..0e99d6106 100644 --- a/packages/tentacles/Trading/Mode/staggered_orders_trading_mode/staggered_orders_trading.py +++ b/packages/tentacles/Trading/Mode/staggered_orders_trading_mode/staggered_orders_trading.py @@ -29,6 +29,7 @@ import octobot_commons.data_util as data_util import octobot_commons.signals as commons_signals import octobot_trading.api as trading_api +import octobot_trading.dsl as trading_dsl import octobot_trading.modes as trading_modes import octobot_trading.exchange_channel as exchanges_channel import octobot_trading.constants as trading_constants @@ -281,6 +282,25 @@ async def _order_notification_callback(self, exchange, exchange_id, cryptocurren def get_is_symbol_wildcard(cls) -> bool: return False + @classmethod + def get_tentacle_config_traded_symbols(cls, trading_config: dict, reference_market: str) -> list[str]: + pair_settings = trading_config.get(cls.CONFIG_PAIR_SETTINGS) or [] + symbols = [] + seen: set[str] = set() + for pair_config in pair_settings: + symbol = pair_config.get(cls.CONFIG_PAIR) + if symbol and symbol not in seen: + seen.add(symbol) + symbols.append(symbol) + return symbols + + @classmethod + def get_dsl_dependencies(cls, trading_config: dict, config: dict) -> list: + symbols = cls.get_tentacle_config_traded_symbols(trading_config) + if not symbols: + return [] + return [trading_dsl.SymbolDependency(symbol=symbol) for symbol in symbols] + def set_default_config(self): raise RuntimeError(f"Impossible to start {self.get_name()} without a valid configuration file.") @@ -577,16 +597,17 @@ def __init__(self, channel, config, trading_mode, exchange_manager): self.symbol = trading_mode.symbol self.symbol_market = None self.min_max_order_details = {} - fees = trading_api.get_fees(exchange_manager, self.symbol) - try: - self.max_fees = decimal.Decimal(str(max(fees[trading_enums.ExchangeConstantsMarketPropertyColumns.TAKER.value], - fees[trading_enums.ExchangeConstantsMarketPropertyColumns.MAKER.value] - ))) - except TypeError as err: - # don't crash if fees are not available - market_status = self.exchange_manager.exchange.get_market_status(self.symbol, with_fixer=False) - self.logger.error(f"Error reading fees for {self.symbol}: {err}. Market status: {market_status}") - self.max_fees = decimal.Decimal(str(trading_constants.CONFIG_DEFAULT_FEES)) + if self.symbol: + fees = trading_api.get_fees(exchange_manager, self.symbol) + try: + self.max_fees = decimal.Decimal(str(max(fees[trading_enums.ExchangeConstantsMarketPropertyColumns.TAKER.value], + fees[trading_enums.ExchangeConstantsMarketPropertyColumns.MAKER.value] + ))) + except TypeError as err: + # don't crash if fees are not available + market_status = self.exchange_manager.exchange.get_market_status(self.symbol, with_fixer=False) + self.logger.error(f"Error reading fees for {self.symbol}: {err}. Market status: {market_status}") + self.max_fees = decimal.Decimal(str(trading_constants.CONFIG_DEFAULT_FEES)) self.flat_increment = None self.flat_spread = None self.current_price = None @@ -743,6 +764,12 @@ async def is_price_beyond_boundaries(self): if max_order_price < price and self.enable_upwards_price_follow: return True + async def manual_trigger( + self, matrix_id: str, cryptocurrency: str, + symbol: str, time_frame, trigger_source: str + ) -> None: + return await self.trigger_staggered_orders_creation(reload_config=False) + def _schedule_order_refresh(self): # schedule order creation / health check asyncio.create_task(self._ensure_staggered_orders_and_reschedule()) diff --git a/packages/trading/octobot_trading/exchange_data/__init__.py b/packages/trading/octobot_trading/exchange_data/__init__.py index e43255f77..8b5d28bc5 100644 --- a/packages/trading/octobot_trading/exchange_data/__init__.py +++ b/packages/trading/octobot_trading/exchange_data/__init__.py @@ -123,6 +123,9 @@ UNAUTHENTICATED_UPDATER_PRODUCERS = [OHLCVUpdater, OrderBookUpdater, RecentTradeUpdater, TickerUpdater, KlineUpdater, MarkPriceUpdater, FundingUpdater, MarketsUpdater] +MINIMAL_ENV_AUTHORIZED_UPDATER_PRODUCERS = { + trading_constants.TICKER_CHANNEL: TickerUpdater, +} UNAUTHENTICATED_UPDATER_SIMULATOR_PRODUCERS = { trading_constants.OHLCV_CHANNEL: OHLCVUpdaterSimulator, trading_constants.ORDER_BOOK_CHANNEL: OrderBookUpdaterSimulator, diff --git a/packages/trading/octobot_trading/exchanges/config/exchange_config_data.py b/packages/trading/octobot_trading/exchanges/config/exchange_config_data.py index a682166eb..cd691ec21 100644 --- a/packages/trading/octobot_trading/exchanges/config/exchange_config_data.py +++ b/packages/trading/octobot_trading/exchanges/config/exchange_config_data.py @@ -26,9 +26,11 @@ import octobot_commons.tree as commons_tree import octobot_trading.exchange_channel as exchange_channel +import octobot_trading.exchanges.exchange_channels as exchange_channels import octobot_trading.exchanges.config.backtesting_exchange_config as backtesting_exchange_config import octobot_trading.exchanges.util as exchange_util import octobot_trading.exchanges.exchange_websocket_factory as exchange_websocket_factory +import octobot_trading.exchange_data as exchange_data import octobot_trading.constants as trading_constants import octobot_trading.enums as trading_enums import octobot_trading.util as util @@ -270,7 +272,15 @@ async def update_traded_symbol_pairs( await self.exchange_manager.exchange_web_socket.handle_updated_pairs(debounce_duration=1) for channel in watch_only_channels_to_notify: - await exchange_channel.get_chan(channel, self.exchange_manager.id).modify( + channel = exchange_channel.get_chan(channel, self.exchange_manager.id) + if not channel.get_producers() and ( + producer := exchange_data.MINIMAL_ENV_AUTHORIZED_UPDATER_PRODUCERS.get(channel.get_name()) + ): + await exchange_channels.create_producer( + self.exchange_manager, producer, should_start_producer=False + ) + # always create mark price producer? + await channel.modify( added_pairs=new_valid_symbol_pairs, removed_pairs=removed_valid_symbol_pairs ) diff --git a/packages/trading/octobot_trading/exchanges/exchange_channels.py b/packages/trading/octobot_trading/exchanges/exchange_channels.py index 204632b62..b049aada5 100644 --- a/packages/trading/octobot_trading/exchanges/exchange_channels.py +++ b/packages/trading/octobot_trading/exchanges/exchange_channels.py @@ -97,17 +97,16 @@ async def _create_producers(exchange_manager, producers_classes) -> None: :param producers_classes: the list of producer classes """ for updater in producers_classes: - await _create_producer(exchange_manager, updater) + await create_producer(exchange_manager, updater) -async def _create_producer(exchange_manager, producer) -> channel_producer.Producer: +async def create_producer(exchange_manager, producer, should_start_producer: bool = True) -> channel_producer.Producer: """ Create a producer instance :param exchange_manager: the related exchange manager :param producer: the producer to create :return: the producer instance created """ - should_start_producer = True producer_instance = producer(exchange_channel.get_chan(producer.CHANNEL_NAME, exchange_manager.id)) if exchanges.is_channel_managed_by_websocket(exchange_manager, producer.CHANNEL_NAME): # websocket is handling this channel: initialize data if required @@ -147,7 +146,7 @@ async def create_authenticated_producer_from_parent(exchange_manager, """ producer = _get_authenticated_producer_from_parent(parent_producer_class) if producer is not None: - producer_instance = await _create_producer(exchange_manager, producer) + producer_instance = await create_producer(exchange_manager, producer) if force_register_producer: await producer_instance.channel.register_producer(producer_instance) diff --git a/packages/trading/octobot_trading/modes/__init__.py b/packages/trading/octobot_trading/modes/__init__.py index 0e6c06ceb..6fe40a913 100644 --- a/packages/trading/octobot_trading/modes/__init__.py +++ b/packages/trading/octobot_trading/modes/__init__.py @@ -57,6 +57,10 @@ create_trading_mode, create_temporary_trading_mode_with_local_config, ) +from octobot_trading.modes.mode_dsl_factory import ( + create_trading_mode_operator, + create_all_trading_mode_operators, +) from octobot_trading.modes import mode_activity from octobot_trading.modes.mode_activity import ( @@ -90,6 +94,8 @@ "create_trading_modes", "create_trading_mode", "create_temporary_trading_mode_with_local_config", + "create_trading_mode_operator", + "create_all_trading_mode_operators", "TradingModeActivity", "get_activated_trading_mode", "should_emit_trading_signals_user_input", diff --git a/packages/trading/octobot_trading/modes/abstract_trading_mode.py b/packages/trading/octobot_trading/modes/abstract_trading_mode.py index 8c1de6769..387bd4cce 100644 --- a/packages/trading/octobot_trading/modes/abstract_trading_mode.py +++ b/packages/trading/octobot_trading/modes/abstract_trading_mode.py @@ -25,6 +25,9 @@ import octobot_commons.tentacles_management as abstract_tentacle import octobot_commons.configuration import octobot_commons.signals as commons_signals +import octobot_commons.dsl_interpreter as dsl_interpreter + +import octobot_evaluators.constants import async_channel.constants as channel_constants import async_channel.channels as channels @@ -45,6 +48,9 @@ import octobot_trading.exchanges.util.exchange_util as exchange_util import octobot_trading.signals as signals +if typing.TYPE_CHECKING: + import octobot.community + class AbstractTradingMode(abstract_tentacle.AbstractTentacle): __metaclass__ = abc.ABCMeta @@ -114,6 +120,11 @@ def __init__(self, config, exchange_manager): self.is_health_check_enabled: bool = False self._last_health_check_time: float = 0 + # When True, the producer directly calls consumers instead of using channels. + # Awaiting the producer call completes after both producer and consumer work are done. + self.synchronous_execution: bool = False + self.previous_state: typing.Optional[dict] = None + # Pending bot logs to be inserted after execution self.pending_bot_logs: list["octobot.community.BotLogData"] = [] @@ -232,10 +243,16 @@ def is_updating_exchange_settings(self, context) -> bool: """ return True - async def initialize(self, trading_config=None, auto_start=True) -> None: + async def initialize( + self, + trading_config: typing.Optional[dict] = None, + auto_start: bool = True, + previous_state: typing.Optional[dict] = None + ) -> None: """ Triggers producers and consumers creation """ + self.previous_state = previous_state await self.reload_config(self.exchange_manager.bot_id, trading_config=trading_config) self.producers = await self.create_producers(auto_start) self.consumers = await self.create_consumers() @@ -329,7 +346,7 @@ async def user_commands_callback(self, bot_id, subject, action, data) -> None: self.logger.debug(f"Received {action} command") if action == common_enums.UserCommands.MANUAL_TRIGGER.value: self.logger.debug(f"Triggering trading mode from {action} command with data: {data}") - await self._manual_trigger(data) + await self.manual_trigger(data) if action == common_enums.UserCommands.RELOAD_CONFIG.value: await self.reload_config(bot_id) self.logger.debug("Reloaded configuration") @@ -344,7 +361,7 @@ async def user_commands_callback(self, bot_id, subject, action, data) -> None: if self.SUPPORTS_HEALTH_CHECK: await self.health_check([], {}) - async def _manual_trigger(self, data): + async def manual_trigger(self, data): kwargs = { "trigger_source": common_enums.TriggerSource.MANUAL.value } @@ -352,6 +369,19 @@ async def _manual_trigger(self, data): for producer in self.producers: await producer.trigger(**kwargs) + @classmethod + def get_dsl_dependencies(cls, trading_config: dict, config: dict) -> list[dsl_interpreter.InterpreterDependency]: + """ + Overwrite in subclasses if necessary + :param trading_config: The trading config + :param config: The global config + :return: The list of dependencies for the DSL interpreter + """ + return [] + + def get_dsl_state(self) -> dict: + return {} + def enabled_health_check_in_config(self) -> bool: try: return self.trading_config.get(self.ENABLE_HEALTH_CHECK, False) @@ -597,6 +627,16 @@ def get_required_candles_count(cls, tentacles_setup_config: tm_configuration.Ten common_constants.DEFAULT_IGNORED_VALUE ) + def get_time_before_next_execution(self) -> float: + time_frame = ( + self.trading_config.get(octobot_evaluators.constants.STRATEGIES_REQUIRED_TIME_FRAME) + or common_enums.TimeFrames.ONE_HOUR.value + ) + return ( + common_enums.TimeFramesMinutes[common_enums.TimeFrames(time_frame)] + * common_constants.MINUTE_TO_SECONDS + ) + def ensure_supported(self, symbol): if self.exchange_manager.is_future: try: diff --git a/packages/trading/octobot_trading/modes/channel/abstract_mode_producer.py b/packages/trading/octobot_trading/modes/channel/abstract_mode_producer.py index c1fc10015..cbc0d13b6 100644 --- a/packages/trading/octobot_trading/modes/channel/abstract_mode_producer.py +++ b/packages/trading/octobot_trading/modes/channel/abstract_mode_producer.py @@ -361,11 +361,18 @@ async def trigger(self, matrix_id: str = None, cryptocurrency: str = None, symbo """ try: async with self.trading_mode_trigger(), self.trading_mode.remote_signal_publisher(symbol): - await self.set_final_eval(matrix_id=matrix_id, - cryptocurrency=cryptocurrency, - symbol=symbol, - time_frame=time_frame, - trigger_source=trigger_source) + if trigger_source == common_enums.TriggerSource.MANUAL.value: + await self.manual_trigger(matrix_id=matrix_id, + cryptocurrency=cryptocurrency, + symbol=symbol, + time_frame=time_frame, + trigger_source=trigger_source) + else: + await self.set_final_eval(matrix_id=matrix_id, + cryptocurrency=cryptocurrency, + symbol=symbol, + time_frame=time_frame, + trigger_source=trigger_source) except errors.InitializingError as e: self.logger.exception( e, @@ -413,8 +420,25 @@ async def trading_mode_trigger(self, skip_health_check=False): async def post_trigger(self): pass - async def set_final_eval(self, matrix_id: str, cryptocurrency: str, symbol: str, time_frame, - trigger_source: str) -> None: + async def manual_trigger( + self, matrix_id: str, cryptocurrency: str, + symbol: str, time_frame, trigger_source: str + ) -> None: + """ + Called when a manual trigger is received. Behaves like set_final_eval by default. + """ + return await self.set_final_eval( + matrix_id=matrix_id, + cryptocurrency=cryptocurrency, + symbol=symbol, + time_frame=time_frame, + trigger_source=trigger_source + ) + + async def set_final_eval( + self, matrix_id: str, cryptocurrency: str, symbol: str, + time_frame, trigger_source: str + ) -> None: """ Called to calculate the final note or state => when any notification appears """ @@ -427,14 +451,17 @@ async def submit_trading_evaluation( data=None, dependencies: typing.Optional[commons_signals.SignalDependencies] = None ) -> None: - await self.send(trading_mode_name=self.trading_mode.get_name(), - cryptocurrency=cryptocurrency, - symbol=symbol, - time_frame=time_frame, - final_note=final_note, - state=state.value, - data=data if data is not None else {}, - dependencies=dependencies) + await self.send( + trading_mode_name=self.trading_mode.get_name(), + cryptocurrency=cryptocurrency, + symbol=symbol, + time_frame=time_frame, + final_note=final_note, + state=state.value, + data=data if data is not None else {}, + dependencies=dependencies, + synchronous_execution=self.trading_mode.synchronous_execution + ) @classmethod def get_should_cancel_loaded_orders(cls) -> bool: diff --git a/packages/trading/octobot_trading/modes/channel/mode.py b/packages/trading/octobot_trading/modes/channel/mode.py index dd26d45db..62e74e618 100644 --- a/packages/trading/octobot_trading/modes/channel/mode.py +++ b/packages/trading/octobot_trading/modes/channel/mode.py @@ -22,6 +22,8 @@ import octobot_trading.exchange_channel as exchanges_channel import octobot_trading.constants as constants +import octobot_commons.enums as common_enums +import async_channel.util as channel_util class ModeChannelConsumer(exchanges_channel.ExchangeChannelInternalConsumer): @@ -29,30 +31,40 @@ class ModeChannelConsumer(exchanges_channel.ExchangeChannelInternalConsumer): class ModeChannelProducer(exchanges_channel.ExchangeChannelProducer): - async def send(self, - final_note=constants.ZERO, - trading_mode_name=channel_constants.CHANNEL_WILDCARD, - state=channel_constants.CHANNEL_WILDCARD, - cryptocurrency=channel_constants.CHANNEL_WILDCARD, - symbol=channel_constants.CHANNEL_WILDCARD, - time_frame=None, - data=None, - dependencies: typing.Optional[commons_signals.SignalDependencies] = None): - for consumer in self.channel.get_filtered_consumers(trading_mode_name=trading_mode_name, - state=state, - cryptocurrency=cryptocurrency, - symbol=symbol, - time_frame=time_frame): - await consumer.queue.put({ - "final_note": final_note, - "state": state, - "trading_mode_name": trading_mode_name, - "cryptocurrency": cryptocurrency, - "symbol": symbol, - "time_frame": time_frame, - "data": data, - "dependencies": dependencies - }) + async def send( + self, + final_note=constants.ZERO, + trading_mode_name=channel_constants.CHANNEL_WILDCARD, + state=channel_constants.CHANNEL_WILDCARD, + cryptocurrency=channel_constants.CHANNEL_WILDCARD, + symbol=channel_constants.CHANNEL_WILDCARD, + time_frame: typing.Optional[common_enums.TimeFrames] = None, + data=None, + dependencies: typing.Optional[commons_signals.SignalDependencies] = None, + synchronous_execution: bool = False + ): + consumers = self.channel.get_filtered_consumers( + trading_mode_name=trading_mode_name, + state=state, + cryptocurrency=cryptocurrency, + symbol=symbol, + time_frame=time_frame + ) + data = { + "final_note": final_note, + "state": state, + "trading_mode_name": trading_mode_name, + "cryptocurrency": cryptocurrency, + "symbol": symbol, + "time_frame": time_frame, + "data": data, + "dependencies": dependencies + } + if synchronous_execution: + await channel_util.trigger_and_bypass_consumers_queue(consumers, data) + else: + for consumer in consumers: + await consumer.queue.put(data) class ModeChannel(exchanges_channel.ExchangeChannel): diff --git a/packages/trading/octobot_trading/modes/mode_config.py b/packages/trading/octobot_trading/modes/mode_config.py index 458c5bb85..7ab144354 100644 --- a/packages/trading/octobot_trading/modes/mode_config.py +++ b/packages/trading/octobot_trading/modes/mode_config.py @@ -61,13 +61,13 @@ def should_emit_trading_signals_user_input(trading_mode, inputs: dict): trading_mode.UI.user_input( common_constants.CONFIG_EMIT_TRADING_SIGNALS, common_enums.UserInputTypes.BOOLEAN, False, inputs, title="Emit trading signals on OctoBot cloud for people to follow.", - order=commons_configuration.UserInput.MAX_ORDER - 2 + order=commons_configuration.MAX_USER_INPUT_ORDER - 2 ) trading_mode.UI.user_input( common_constants.CONFIG_TRADING_SIGNALS_STRATEGY, common_enums.UserInputTypes.TEXT, trading_mode.get_name(), inputs, title="Name of the strategy to send signals on.", - order=commons_configuration.UserInput.MAX_ORDER - 1, + order=commons_configuration.MAX_USER_INPUT_ORDER - 1, other_schema_values={"minLength": 0}, editor_options={ common_enums.UserInputOtherSchemaValuesTypes.DEPENDENCIES.value: { diff --git a/packages/trading/octobot_trading/modes/mode_dsl_factory.py b/packages/trading/octobot_trading/modes/mode_dsl_factory.py new file mode 100644 index 000000000..1a6f1999b --- /dev/null +++ b/packages/trading/octobot_trading/modes/mode_dsl_factory.py @@ -0,0 +1,345 @@ +# Drakkar-Software OctoBot-Trading +# Copyright (c) Drakkar-Software, All rights reserved. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. +import time +import typing + +import octobot_commons.constants as common_constants +import octobot_commons.enums as common_enums +import octobot_commons.errors as commons_errors +import octobot_commons.configuration.user_inputs as user_inputs +import octobot_commons.logging as commons_logging + +import octobot_commons.dsl_interpreter as dsl_interpreter +import octobot_commons.str_util as str_util +import octobot_trading.personal_data as personal_data +import octobot_trading.modes.modes_factory as modes_factory + +if typing.TYPE_CHECKING: + import octobot_trading.exchanges + import octobot_trading.modes.abstract_trading_mode + + +def _create_operator_parameters_from_user_inputs( + created_user_inputs: dict, +) -> list[dsl_interpreter.OperatorParameter]: + """ + Convert UserInput objects to OperatorParameter. + Only includes top-level inputs (parent_input_name is None). + """ + params = [] + for u_input in created_user_inputs.values(): + if not isinstance(u_input, user_inputs.UserInput): + continue + if u_input.parent_input_name is not None: + continue + input_type = ( + u_input.input_type + if isinstance(u_input.input_type, str) + else u_input.input_type.value + ) + param_type = user_inputs.USER_INPUT_TYPE_TO_PYTHON_TYPE[input_type] + param_name = user_inputs.sanitize_user_input_name(u_input.name) + description = u_input.title or u_input.name + params.append( + dsl_interpreter.OperatorParameter( + name=param_name, + description=str(description), + required=False, + type=param_type, + default=u_input.def_val, + ) + ) + return params + + +def _create_trading_mode_operator_parameters( + trading_mode_class: type, + config: dict, +) -> list[dsl_interpreter.OperatorParameter]: + """ + Derive operator parameters from the trading mode's init_user_inputs. + Leverages inheritance: subclasses call super().init_user_inputs(inputs). + """ + tentacles_setup_config = None + loaded_config = {} + tentacle_instance = trading_mode_class.create_local_instance( + config, tentacles_setup_config, loaded_config + ) + tentacle_instance.synchronous_execution = True + created_user_inputs = {} + tentacle_instance.init_user_inputs(created_user_inputs) + return _create_operator_parameters_from_user_inputs(created_user_inputs) + + +class TradingModeOperator( + dsl_interpreter.PreComputingCallOperator, dsl_interpreter.ReCallableOperatorMixin +): + """ + Base DSL operator that instantiates and executes a trading mode when called. + Subclasses are created dynamically by create_trading_mode_operator. + """ + + def __init__( + self, + *parameters: dsl_interpreter.OperatorParameterType, + **kwargs: typing.Any, + ): + super().__init__(*parameters, **kwargs) + self.param_by_name: dict[ + str, dsl_interpreter.ComputedOperatorParameterType + ] = dsl_interpreter.UNINITIALIZED_VALUE # type: ignore + + @staticmethod + def get_library() -> str: + return common_constants.CONTEXTUAL_OPERATORS_LIBRARY + + def get_exchange_manager( + self, + ) -> typing.Optional["octobot_trading.exchanges.ExchangeManager"]: + raise NotImplementedError("get_exchange_manager must be implemented") + + def get_trading_mode_class( + self, + ) -> type: + raise NotImplementedError("get_trading_mode_class must be implemented") + + def get_config( + self, + ) -> dict: + raise NotImplementedError("get_config must be implemented") + + async def _optimize_initial_portfolio( + self, + trading_modes: list["octobot_trading.modes.abstract_trading_mode.AbstractTradingMode"], + sellable_assets: list[str], + tickers: dict, + ) -> None: + trading_mode = trading_modes[0] + if not trading_mode.SUPPORTS_INITIAL_PORTFOLIO_OPTIMIZATION: + self._get_logger().info( + f"Trading mode {trading_mode.get_name()} does not support initial " + f"portfolio optimization. Skipping optimization." + ) + return + exchange_manager = self.get_exchange_manager() + balance_summary = personal_data.get_balance_summary(personal_data.portfolio_to_float( + exchange_manager.exchange_personal_data.portfolio_manager.portfolio.portfolio + ), use_exchange_format=False) + self._get_logger().info( + f"Optimizing initial portfolio for trading mode {trading_mode.get_name()}. " + f"Before optimization portfolio content: {balance_summary}" + ) + await trading_mode.optimize_initial_portfolio(sellable_assets, tickers) + balance_summary = personal_data.get_balance_summary(personal_data.portfolio_to_float( + exchange_manager.exchange_personal_data.portfolio_manager.portfolio.portfolio + ), use_exchange_format=False) + self._get_logger().info( + f"Portfolio optimized for trading mode {trading_mode.get_name()}. " + f"Post optimization portfolio content: {balance_summary}" + ) + + async def _create_trading_mode( + self, + trading_mode_class: type, + trading_config: dict, + exchange_manager: "octobot_trading.exchanges.ExchangeManager", + symbol: str + ) -> "octobot_trading.modes.abstract_trading_mode.AbstractTradingMode": + trading_mode = trading_mode_class(exchange_manager.config, exchange_manager) + if symbol is not None: + trading_mode.symbol = symbol + trading_mode.synchronous_execution = True + await trading_mode.initialize( + trading_config=trading_config, + auto_start=False, + previous_state=self.param_by_name.get("state") + ) + for producer in trading_mode.producers: + producer.force_is_ready_to_trade() + return trading_mode + + async def _create_trading_modes( + self, + trading_mode_class: type, + trading_config: dict, + exchange_manager: "octobot_trading.exchanges.ExchangeManager", + ) -> list["octobot_trading.modes.abstract_trading_mode.AbstractTradingMode"]: + trading_modes = [] + if trading_mode_class.get_is_symbol_wildcard(): + trading_mode = await self._create_trading_mode( + trading_mode_class, trading_config, exchange_manager, None + ) + trading_modes.append(trading_mode) + else: + for symbol in exchange_manager.exchange_config.traded_symbol_pairs: + trading_mode = await self._create_trading_mode( + trading_mode_class, trading_config, exchange_manager, symbol + ) + trading_modes.append(trading_mode) + return trading_modes + + async def _execute_trading_mode( + self, trading_mode: "octobot_trading.modes.abstract_trading_mode.AbstractTradingMode" + ) -> dsl_interpreter.ReCallingOperatorResult: + last_execution_time = time.time() + await trading_mode.manual_trigger( + {"trigger_source": common_enums.TriggerSource.MANUAL.value} + ) + return self.create_re_callable_result( + waiting_time=trading_mode.get_time_before_next_execution(), + last_execution_time=last_execution_time, + state=trading_mode.get_dsl_state(), + ) + + async def pre_compute(self) -> None: + await super().pre_compute() + exchange_manager = self.get_exchange_manager() + if exchange_manager is None: + raise commons_errors.DSLInterpreterError( + "Exchange manager is required to execute trading mode operator" + ) + self.param_by_name = self.get_computed_value_by_parameter() + trading_modes = await self._create_trading_modes( + self.get_trading_mode_class(), + self.param_by_name, + exchange_manager + ) + if not self.get_last_execution_result(self.param_by_name): + try: + # this is the first execution, optimize initial portfolio + sellable_assets = [] # todo later: populate with asseets that can be sold additionally to the traded ones + tickers = {} + await self._optimize_initial_portfolio(trading_modes, sellable_assets, tickers) + except Exception as err: + self._get_logger().exception(err, True, f"Error when optimizing initial portfolio: {err}") + + recallable_results: list[dsl_interpreter.ReCallingOperatorResult] = [] + for trading_mode in trading_modes: + recallable_result = await self._execute_trading_mode(trading_mode) + recallable_results.append(recallable_result) + self.value = self.get_results_summary(recallable_results) + + def get_results_summary( + self, recallable_results: list[dsl_interpreter.ReCallingOperatorResult] + ) -> dict: + waiting_with_last_exec: list[tuple[typing.Any, typing.Any]] = [] + merged_state: dict[str, typing.Any] = {} + for result in recallable_results: + if not result.last_execution_result: + continue + if waiting_time := result.last_execution_result.get( + dsl_interpreter.ReCallingOperatorResultKeys.WAITING_TIME.value + ): + last_execution_time = result.last_execution_result.get( + dsl_interpreter.ReCallingOperatorResultKeys.LAST_EXECUTION_TIME.value + ) + waiting_with_last_exec.append((waiting_time, last_execution_time)) + merged_state.update(result.last_execution_result) + + if not waiting_with_last_exec: + min_waiting_time = None + summary_last_execution_time = None + else: + min_waiting_time, summary_last_execution_time = min( + waiting_with_last_exec, key=lambda element: element[0] + ) + + return self.create_re_callable_result_dict( + waiting_time=min_waiting_time or None, + last_execution_time=summary_last_execution_time or None, + state=merged_state, + ) + + def get_dependencies(self) -> typing.List[dsl_interpreter.InterpreterDependency]: + trading_mode_class = self.get_trading_mode_class() + param_by_name = self.get_computed_value_by_parameter() + local_dependencies = trading_mode_class.get_dsl_dependencies( + param_by_name, self.get_config() + ) + return super().get_dependencies() + local_dependencies + + def _get_logger(self) -> commons_logging.BotLogger: + return commons_logging.get_logger(f"{self.get_trading_mode_class().get_name()}Operator") + + +def create_trading_mode_operator( + trading_mode_class: type, + exchange_manager: typing.Optional["octobot_trading.exchanges.ExchangeManager"], + config: dict, +) -> type: + """ + Create a DSL operator class that, when called, instantiates and executes + the given trading mode. + :param trading_mode_class: The trading mode class to execute + :param exchange_manager: The exchange manager to use for execution + :return: A DSL operator class for registration with an Interpreter + """ + _operator_parameters: list[dsl_interpreter.OperatorParameter] = [] + operator_name = str_util.camel_to_snake(trading_mode_class.get_name()) + + class _ContextProviderMixin: + def get_exchange_manager( + self, + ) -> typing.Optional[ + "octobot_trading.exchanges.ExchangeManager" + ]: + return exchange_manager + + def get_trading_mode_class(self) -> type: + return trading_mode_class + + def get_config(self) -> dict: + return config + + class _TradingModeOperatorImpl( + _ContextProviderMixin, TradingModeOperator + ): + DESCRIPTION = f"Executes the {trading_mode_class.get_name()} trading mode" + EXAMPLE = f"{operator_name}()" + + @staticmethod + def get_name() -> str: + return operator_name + + @classmethod + def get_parameters(cls) -> list[dsl_interpreter.OperatorParameter]: + if not _operator_parameters: + # lazy computation of the operator parameters to only compute them once + # and when the operator is actually used (and not just when instantiated) + _operator_parameters.extend(_create_trading_mode_operator_parameters( + trading_mode_class, config #, tentacles_setup_config, loaded_config + )) + return _operator_parameters + cls.get_re_callable_parameters() + + return _TradingModeOperatorImpl + + +def create_all_trading_mode_operators( + exchange_manager: typing.Optional["octobot_trading.exchanges.ExchangeManager"], + config: dict, +) -> list[type]: + """ + Create DSL operators for all available trading modes. + :param exchange_manager: The exchange manager to use for trading mode execution + :return: List of DSL operator classes for registration with an Interpreter + """ + + operators = [] + for trading_mode_class in modes_factory.get_all_concrete_trading_mode_classes(): + operators.append( + create_trading_mode_operator(trading_mode_class, exchange_manager, config) + ) + return operators diff --git a/packages/trading/octobot_trading/modes/modes_factory.py b/packages/trading/octobot_trading/modes/modes_factory.py index 83000b3ed..8f864c2ce 100644 --- a/packages/trading/octobot_trading/modes/modes_factory.py +++ b/packages/trading/octobot_trading/modes/modes_factory.py @@ -13,10 +13,15 @@ # # You should have received a copy of the GNU Lesser General Public # License along with this library. +import functools + import octobot_commons.constants as constants import octobot_commons.logging as logging +import octobot_commons.tentacles_management as tentacles_management import octobot_trading.errors as errors +import octobot_trading.modes.abstract_trading_mode as abstract_trading_mode + LOGGER_TAG = "TradingModeFactory" @@ -128,3 +133,18 @@ def _get_symbols_to_create(trading_mode_class, cryptocurrencies, cryptocurrency, def _get_time_frames_to_create(trading_mode_class, time_frames): return time_frames if time_frames and not trading_mode_class.get_is_time_frame_wildcard() else [None] + + +@functools.lru_cache(maxsize=1) +def get_all_concrete_trading_mode_classes() -> tuple[type, ...]: + """ + All non-abstract trading mode classes, discovered from tentacles. + Cached for the process lifetime (tentacle discovery is assumed static after the first call of this function). + """ + return tuple( + cls + for cls in tentacles_management.get_all_classes_from_parent( + abstract_trading_mode.AbstractTradingMode + ) + if cls is not abstract_trading_mode.AbstractTradingMode + ) diff --git a/packages/trading/octobot_trading/personal_data/portfolios/portfolio_manager.py b/packages/trading/octobot_trading/personal_data/portfolios/portfolio_manager.py index 2d686ef0d..d4e25737f 100644 --- a/packages/trading/octobot_trading/personal_data/portfolios/portfolio_manager.py +++ b/packages/trading/octobot_trading/personal_data/portfolios/portfolio_manager.py @@ -407,7 +407,11 @@ def set_forced_portfolio_initial_config(self, portfolio_config): } self._forced_portfolio = forced_portfolio_initial_config - def apply_forced_portfolio(self, forced_portfolio_config: typing.Optional[dict[str, typing.Any]] = None): + def apply_forced_portfolio( + self, + forced_portfolio_config: typing.Optional[dict[str, typing.Any]] = None, + update_available_funds_from_open_orders: bool = False, + ): """ Load new portfolio from config settings """ @@ -415,6 +419,19 @@ def apply_forced_portfolio(self, forced_portfolio_config: typing.Optional[dict[s self.set_forced_portfolio_initial_config(forced_portfolio_config) portfolio_amount_dict = personal_data.parse_decimal_config_portfolio(self._forced_portfolio) self.handle_balance_update(self.portfolio.get_portfolio_from_amount_dict(portfolio_amount_dict)) + if update_available_funds_from_open_orders: + self._updated_available_from_open_orders() + + def _updated_available_from_open_orders(self) -> None: + """ + Apply locked funds for open orders. + """ + if not self.enable_portfolio_available_update_from_order: + return + for order in self.exchange_manager.exchange_personal_data.orders_manager.get_open_orders(): + self.refresh_portfolio_available_from_order( + order, is_new_order=True + ) def _set_initialized_event(self): commons_tree.EventProvider.instance().trigger_event( diff --git a/packages/trading/tests/modes/test_trading_mode_dsl_factory.py b/packages/trading/tests/modes/test_trading_mode_dsl_factory.py new file mode 100644 index 000000000..f89dc6f8b --- /dev/null +++ b/packages/trading/tests/modes/test_trading_mode_dsl_factory.py @@ -0,0 +1,564 @@ +# Drakkar-Software OctoBot-Trading +# Copyright (c) Drakkar-Software, All rights reserved. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. +import pytest +import mock + +import octobot_commons.configuration.user_inputs as user_inputs +import octobot_commons.dsl_interpreter as dsl_interpreter +import octobot_commons.enums as common_enums +import octobot_commons.errors as commons_errors +import octobot_commons.str_util as str_util + +import octobot_trading.modes.trading_mode_dsl_factory as trading_mode_dsl_factory + + +pytestmark = pytest.mark.asyncio + +_TENTACLE_TYPE = common_enums.UserInputTentacleTypes.TRADING_MODE.value + + +def _make_user_input( + name, + input_type, + def_val, + value=None, + title=None, + parent_input_name=None, +): + return user_inputs.UserInput( + name=name, + input_type=input_type, + value=value if value is not None else def_val, + def_val=def_val, + tentacle_type=_TENTACLE_TYPE, + tentacle_name="FakeMode", + title=title, + parent_input_name=parent_input_name, + ) + + +class FakeTradingModeAlpha: + @classmethod + def get_name(cls) -> str: + return "FakeTradingModeAlpha" + + @classmethod + def create_local_instance(cls, config, tentacles_setup_config, loaded_config): + instance = mock.Mock() + instance.synchronous_execution = False + + def init_user_inputs(inputs): + inputs["skip_str"] = "not a UserInput" + inputs["nested"] = _make_user_input( + "Nested", + common_enums.UserInputTypes.TEXT.value, + "x", + parent_input_name="parent", + ) + inputs["top_int"] = _make_user_input( + "Top Level Int", + common_enums.UserInputTypes.INT.value, + 0, + title="Integer setting", + ) + inputs["top_float_enum"] = _make_user_input( + "Top Float", + common_enums.UserInputTypes.FLOAT, + 1.5, + ) + + instance.init_user_inputs = init_user_inputs + return instance + + +class FakeTradingModeBeta: + @classmethod + def get_name(cls) -> str: + return "FakeTradingModeBeta" + + @classmethod + def create_local_instance(cls, config, tentacles_setup_config, loaded_config): + instance = mock.Mock() + instance.synchronous_execution = False + instance.init_user_inputs = lambda inputs: None + return instance + + +class FakeTradingModeWithDslDeps(FakeTradingModeAlpha): + @classmethod + def get_dsl_dependencies(cls, trading_config, config): + return [dsl_interpreter.InterpreterDependency()] + + +def _trading_mode_mock( + *, + waiting_time=3600.0, + dsl_state=None, + supports_portfolio_optimization=False, + name="MockTradingMode", +): + m = mock.Mock() + m.manual_trigger = mock.AsyncMock() + m.get_time_before_next_execution = mock.Mock(return_value=waiting_time) + m.get_dsl_state = mock.Mock(return_value={} if dsl_state is None else dsl_state) + m.SUPPORTS_INITIAL_PORTFOLIO_OPTIMIZATION = supports_portfolio_optimization + m.get_name = mock.Mock(return_value=name) + return m + + +def _operator_with_exchange_manager(): + exchange_manager = mock.Mock() + op_cls = trading_mode_dsl_factory.create_trading_mode_operator( + FakeTradingModeBeta, + exchange_manager, + {"profile": True}, + ) + return op_cls(), exchange_manager + + +class TestCreateOperatorParametersFromUserInputs: + def test_empty(self): + assert trading_mode_dsl_factory._create_operator_parameters_from_user_inputs({}) == [] + + def test_skips_non_user_input(self): + created = {"x": "skip", "y": 3} + assert trading_mode_dsl_factory._create_operator_parameters_from_user_inputs(created) == [] + + def test_skips_nested(self): + created = { + "n": _make_user_input( + "child", + common_enums.UserInputTypes.BOOLEAN.value, + False, + parent_input_name="root", + ), + } + assert trading_mode_dsl_factory._create_operator_parameters_from_user_inputs(created) == [] + + def test_top_level_string_input_type(self): + ui = _make_user_input( + "My Setting Name", + common_enums.UserInputTypes.TEXT.value, + "d", + title="Shown title", + ) + params = trading_mode_dsl_factory._create_operator_parameters_from_user_inputs({"a": ui}) + assert len(params) == 1 + p = params[0] + assert p.name == user_inputs.sanitize_user_input_name(ui.name) + assert p.type is str + assert p.default == "d" + assert p.description == "Shown title" + assert p.required is False + + def test_top_level_enum_input_type(self): + ui = _make_user_input( + "float_param", + common_enums.UserInputTypes.FLOAT, + 2.0, + ) + params = trading_mode_dsl_factory._create_operator_parameters_from_user_inputs({"f": ui}) + assert len(params) == 1 + assert params[0].type is float + assert params[0].default == 2.0 + assert params[0].description == "float_param" + + +class TestCreateTradingModeOperatorParameters: + def test_derives_parameters_from_init_user_inputs(self): + params = trading_mode_dsl_factory._create_trading_mode_operator_parameters( + FakeTradingModeAlpha, + {"bot": True}, + ) + names = {p.name for p in params} + assert "Top_Level_Int" in names + assert "Top_Float" in names + assert "Nested" not in names + int_param = next(p for p in params if p.name == "Top_Level_Int") + assert int_param.type is int + assert int_param.description == "Integer setting" + + +class TestCreateTradingModeOperator: + def test_name_metadata_and_context_getters(self): + em = mock.Mock() + cfg = {"x": 1} + OpCls = trading_mode_dsl_factory.create_trading_mode_operator( + FakeTradingModeAlpha, + em, + cfg, + ) + assert OpCls.get_name() == str_util.camel_to_snake(FakeTradingModeAlpha.get_name()) + assert FakeTradingModeAlpha.get_name() in OpCls.DESCRIPTION + assert OpCls.EXAMPLE == f"{OpCls.get_name()}()" + op = OpCls() + assert op.get_exchange_manager() is em + assert op.get_trading_mode_class() is FakeTradingModeAlpha + assert op.get_config() is cfg + + +class TestGetParameters: + def test_lazy_list_stable_across_calls(self): + OpCls = trading_mode_dsl_factory.create_trading_mode_operator( + FakeTradingModeAlpha, + mock.Mock(), + {}, + ) + first = OpCls.get_parameters() + second = OpCls.get_parameters() + assert [p.name for p in first] == [p.name for p in second] + trading_param_names = { + p.name + for p in first + if p.name != dsl_interpreter.ReCallableOperatorMixin.LAST_EXECUTION_RESULT_KEY + } + assert trading_param_names == {"Top_Level_Int", "Top_Float"} + assert sum( + 1 for p in first if p.name == dsl_interpreter.ReCallableOperatorMixin.LAST_EXECUTION_RESULT_KEY + ) == 1 + + +class TestGetResultsSummary: + def test_empty_and_no_last_execution_result(self): + OpCls = trading_mode_dsl_factory.create_trading_mode_operator( + FakeTradingModeBeta, + None, + {}, + ) + op = OpCls() + summary = op.get_results_summary([]) + inner = summary[dsl_interpreter.ReCallingOperatorResult.__name__] + assert inner["last_execution_result"][ + dsl_interpreter.ReCallingOperatorResultKeys.WAITING_TIME.value + ] is None + assert inner["last_execution_result"][ + dsl_interpreter.ReCallingOperatorResultKeys.LAST_EXECUTION_TIME.value + ] is None + + no_payload = dsl_interpreter.ReCallingOperatorResult(last_execution_result=None) + summary2 = op.get_results_summary([no_payload]) + inner2 = summary2[dsl_interpreter.ReCallingOperatorResult.__name__] + assert inner2["last_execution_result"][ + dsl_interpreter.ReCallingOperatorResultKeys.WAITING_TIME.value + ] is None + + def test_picks_minimum_waiting_time(self): + OpCls = trading_mode_dsl_factory.create_trading_mode_operator( + FakeTradingModeBeta, + None, + {}, + ) + op = OpCls() + r1 = dsl_interpreter.ReCallingOperatorResult( + last_execution_result={ + dsl_interpreter.ReCallingOperatorResultKeys.WAITING_TIME.value: 30.0, + dsl_interpreter.ReCallingOperatorResultKeys.LAST_EXECUTION_TIME.value: 100.0, + }, + ) + r2 = dsl_interpreter.ReCallingOperatorResult( + last_execution_result={ + dsl_interpreter.ReCallingOperatorResultKeys.WAITING_TIME.value: 10.0, + dsl_interpreter.ReCallingOperatorResultKeys.LAST_EXECUTION_TIME.value: 200.0, + }, + ) + summary = op.get_results_summary([r1, r2]) + inner = summary[dsl_interpreter.ReCallingOperatorResult.__name__]["last_execution_result"] + assert inner[dsl_interpreter.ReCallingOperatorResultKeys.WAITING_TIME.value] == 10.0 + assert inner[dsl_interpreter.ReCallingOperatorResultKeys.LAST_EXECUTION_TIME.value] == 200.0 + + def test_merged_state_last_wins(self): + OpCls = trading_mode_dsl_factory.create_trading_mode_operator( + FakeTradingModeBeta, + None, + {}, + ) + op = OpCls() + r1 = dsl_interpreter.ReCallingOperatorResult( + last_execution_result={ + dsl_interpreter.ReCallingOperatorResultKeys.WAITING_TIME.value: 5.0, + dsl_interpreter.ReCallingOperatorResultKeys.LAST_EXECUTION_TIME.value: 1.0, + "custom": "first", + "other": "a", + }, + ) + r2 = dsl_interpreter.ReCallingOperatorResult( + last_execution_result={ + dsl_interpreter.ReCallingOperatorResultKeys.WAITING_TIME.value: 10.0, + dsl_interpreter.ReCallingOperatorResultKeys.LAST_EXECUTION_TIME.value: 2.0, + "custom": "second", + "other2": "b", + }, + ) + summary = op.get_results_summary([r1, r2]) + inner = summary[dsl_interpreter.ReCallingOperatorResult.__name__]["last_execution_result"] + assert inner["state"] == { + dsl_interpreter.ReCallingOperatorResultKeys.WAITING_TIME.value: 10.0, + dsl_interpreter.ReCallingOperatorResultKeys.LAST_EXECUTION_TIME.value: 2.0, + "custom": "second", + "other": "a", + "other2": "b", + } + + +class TestGetDependencies: + def test_merges_super_and_trading_mode(self): + base_dep = dsl_interpreter.InterpreterDependency() + local_dep = dsl_interpreter.InterpreterDependency() + OpCls = trading_mode_dsl_factory.create_trading_mode_operator( + FakeTradingModeWithDslDeps, + None, + {"global": True}, + ) + op = OpCls() + with mock.patch( + "octobot_commons.dsl_interpreter.operator.Operator.get_dependencies", + mock.Mock(return_value=[base_dep]), + ): + with mock.patch.object( + op, + "get_computed_value_by_parameter", + mock.Mock(return_value={"param": 1}), + ): + with mock.patch.object( + FakeTradingModeWithDslDeps, + "get_dsl_dependencies", + mock.Mock(return_value=[local_dep]), + ) as gdd: + deps = op.get_dependencies() + # Mock replaces the classmethod descriptor; calls are (trading_config, config). + gdd.assert_called_once_with({"param": 1}, {"global": True}) + assert deps == [base_dep, local_dep] + + +class TestCreateAllTradingModeOperators: + def test_builds_one_operator_per_trading_mode_class(self): + with mock.patch( + "octobot_trading.modes.trading_mode_dsl_factory.modes_factory.get_all_concrete_trading_mode_classes", + return_value=(FakeTradingModeAlpha, FakeTradingModeBeta), + ): + ops = trading_mode_dsl_factory.create_all_trading_mode_operators( + mock.sentinel.em, + {"c": 2}, + ) + assert len(ops) == 2 + names = {cls.get_name() for cls in ops} + assert names == { + str_util.camel_to_snake(FakeTradingModeAlpha.get_name()), + str_util.camel_to_snake(FakeTradingModeBeta.get_name()), + } + modes_wrapped = {op_cls().get_trading_mode_class() for op_cls in ops} + assert modes_wrapped == {FakeTradingModeAlpha, FakeTradingModeBeta} + for op_cls in ops: + inst = op_cls() + assert inst.get_exchange_manager() is mock.sentinel.em + assert inst.get_config() == {"c": 2} + + +class TestPreCompute: + async def test_raises_when_exchange_manager_is_none(self): + OpCls = trading_mode_dsl_factory.create_trading_mode_operator( + FakeTradingModeBeta, + None, + {}, + ) + op = OpCls() + with mock.patch.object( + dsl_interpreter.PreComputingCallOperator, + "pre_compute", + new_callable=mock.AsyncMock, + ): + with pytest.raises(commons_errors.DSLInterpreterError) as excinfo: + await op.pre_compute() + assert "Exchange manager is required" in str(excinfo.value) + + async def test_sets_value_and_invokes_manual_trigger_for_each_mode(self): + op, em = _operator_with_exchange_manager() + tm_a = _trading_mode_mock(waiting_time=10.0, dsl_state={"id": "a"}, name="A") + tm_b = _trading_mode_mock(waiting_time=20.0, dsl_state={"id": "b"}, name="B") + with mock.patch.object( + dsl_interpreter.PreComputingCallOperator, + "pre_compute", + mock.AsyncMock(), + ): + with mock.patch.object( + op, + "get_computed_value_by_parameter", + return_value={"qty": 1}, + ): + with mock.patch.object( + op, + "get_last_execution_result", + return_value={"prior": True}, + ): + with mock.patch.object( + op, + "_optimize_initial_portfolio", + mock.AsyncMock(), + ) as optimize_mock: + with mock.patch.object( + op, + "_create_trading_modes", + mock.AsyncMock(return_value=[tm_a, tm_b]), + ) as create_mock: + await op.pre_compute() + optimize_mock.assert_not_awaited() + create_mock.assert_awaited_once_with( + FakeTradingModeBeta, + {"qty": 1}, + em, + ) + tm_a.manual_trigger.assert_awaited_once_with( + {"trigger_source": common_enums.TriggerSource.MANUAL.value}, + ) + tm_b.manual_trigger.assert_awaited_once_with( + {"trigger_source": common_enums.TriggerSource.MANUAL.value}, + ) + assert op.value is not dsl_interpreter.UNINITIALIZED_VALUE + inner = op.value[dsl_interpreter.ReCallingOperatorResult.__name__][ + "last_execution_result" + ] + assert inner[dsl_interpreter.ReCallingOperatorResultKeys.WAITING_TIME.value] == 10.0 + # summary passes merged per-mode results as `state=`; nested DSL state is under "state". + assert inner["state"][dsl_interpreter.ReCallingOperatorResultKeys.WAITING_TIME.value] == 20.0 + assert inner["state"]["state"]["id"] == "b" + + async def test_calls_optimize_initial_portfolio_on_first_execution(self): + op, em = _operator_with_exchange_manager() + tm = _trading_mode_mock() + with mock.patch.object( + dsl_interpreter.PreComputingCallOperator, + "pre_compute", + mock.AsyncMock(), + ): + with mock.patch.object( + op, + "get_computed_value_by_parameter", + return_value={}, + ): + with mock.patch.object( + op, + "_create_trading_modes", + mock.AsyncMock(return_value=[tm]), + ): + with mock.patch.object( + op, + "_optimize_initial_portfolio", + mock.AsyncMock(), + ) as optimize_mock: + await op.pre_compute() + optimize_mock.assert_awaited_once_with([tm], [], {}) + + async def test_skips_optimize_when_get_last_execution_result_truthy(self): + op, em = _operator_with_exchange_manager() + tm = _trading_mode_mock() + with mock.patch.object( + dsl_interpreter.PreComputingCallOperator, + "pre_compute", + mock.AsyncMock(), + ): + with mock.patch.object( + op, + "get_computed_value_by_parameter", + return_value={}, + ): + with mock.patch.object( + op, + "get_last_execution_result", + return_value={"waiting_time": 5.0}, + ): + with mock.patch.object( + op, + "_create_trading_modes", + mock.AsyncMock(return_value=[tm]), + ): + with mock.patch.object( + op, + "_optimize_initial_portfolio", + mock.AsyncMock(), + ) as optimize_mock: + await op.pre_compute() + optimize_mock.assert_not_awaited() + + async def test_optimize_failure_is_logged_and_execution_continues(self): + op, em = _operator_with_exchange_manager() + tm = _trading_mode_mock() + logger = mock.Mock() + with mock.patch.object( + dsl_interpreter.PreComputingCallOperator, + "pre_compute", + mock.AsyncMock(), + ): + with mock.patch.object( + op, + "get_computed_value_by_parameter", + return_value={}, + ): + with mock.patch.object( + op, + "_create_trading_modes", + mock.AsyncMock(return_value=[tm]), + ): + with mock.patch.object( + op, + "_optimize_initial_portfolio", + mock.AsyncMock(side_effect=RuntimeError("optimize failed")), + ): + with mock.patch.object( + op, + "_get_logger", + return_value=logger, + ): + await op.pre_compute() + logger.exception.assert_called_once() + tm.manual_trigger.assert_awaited_once() + assert op.value is not dsl_interpreter.UNINITIALIZED_VALUE + + +class TestOptimizeInitialPortfolio: + async def test_returns_early_when_mode_does_not_support_optimization(self): + op, _em = _operator_with_exchange_manager() + tm = _trading_mode_mock(supports_portfolio_optimization=False) + logger = mock.Mock() + with mock.patch.object(op, "_get_logger", return_value=logger): + await op._optimize_initial_portfolio([tm], [], {}) + logger.info.assert_called_once() + assert "does not support initial" in logger.info.call_args[0][0] + tm.optimize_initial_portfolio.assert_not_called() + + async def test_calls_optimize_when_mode_supports_it(self): + op, em = _operator_with_exchange_manager() + tm = _trading_mode_mock(supports_portfolio_optimization=True) + tm.optimize_initial_portfolio = mock.AsyncMock() + portfolio_holder = mock.Mock() + em.exchange_personal_data.portfolio_manager.portfolio.portfolio = portfolio_holder + with mock.patch( + "octobot_trading.modes.trading_mode_dsl_factory.personal_data.portfolio_to_float", + return_value={}, + ): + with mock.patch( + "octobot_trading.modes.trading_mode_dsl_factory.personal_data.get_balance_summary", + return_value="summary", + ): + with mock.patch.object(op, "_get_logger", return_value=mock.Mock()): + await op._optimize_initial_portfolio( + [tm], + mock.sentinel.sellable, + mock.sentinel.tickers, + ) + tm.optimize_initial_portfolio.assert_awaited_once_with( + mock.sentinel.sellable, + mock.sentinel.tickers, + ) diff --git a/pants.toml b/pants.toml index 5507630dd..d7309c4a4 100644 --- a/pants.toml +++ b/pants.toml @@ -25,6 +25,7 @@ root_patterns = [ "/packages/evaluators", "/packages/node", "/packages/flow", + "/packages/copy", "/packages/services", "/packages/sync", "/packages/tentacles_manager",