diff --git a/CHANGELOG.md b/CHANGELOG.md index 57be470b1..1f8bc6bb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.4.237] - 2024-12-12 +### Added +[Trades] add get_real_or_estimated_trade_fee +[TradingMode] add is_first_trading_mode_on_this_matrix +[TradingMode] add get_is_using_trading_mode_on_exchange +[Websockets] add additional config support to ws exchanges +### Updated +[Orders] make apply_inactive_orders more flexible +### Fixed +[Orders] fix partially fill channel notification + ## [2.4.236] - 2024-12-08 ### Tools [Tools] use gather_waiting_for_all_before_raising diff --git a/README.md b/README.md index 881b3a530..a9947fd68 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# OctoBot-Trading [2.4.236](https://github.com/Drakkar-Software/OctoBot-Trading/blob/master/CHANGELOG.md) +# OctoBot-Trading [2.4.237](https://github.com/Drakkar-Software/OctoBot-Trading/blob/master/CHANGELOG.md) [![Codacy Badge](https://api.codacy.com/project/badge/Grade/903b6b22bceb4661b608a86fea655f69)](https://app.codacy.com/gh/Drakkar-Software/OctoBot-Trading?utm_source=github.com&utm_medium=referral&utm_content=Drakkar-Software/OctoBot-Trading&utm_campaign=Badge_Grade_Dashboard) [![PyPI](https://img.shields.io/pypi/v/OctoBot-Trading.svg)](https://pypi.python.org/pypi/OctoBot-Trading/) [![Coverage Status](https://coveralls.io/repos/github/Drakkar-Software/OctoBot-Trading/badge.svg?branch=master)](https://coveralls.io/github/Drakkar-Software/OctoBot-Trading?branch=master) diff --git a/octobot_trading/__init__.py b/octobot_trading/__init__.py index 59908f655..03e5a58d7 100644 --- a/octobot_trading/__init__.py +++ b/octobot_trading/__init__.py @@ -15,4 +15,4 @@ # License along with this library. PROJECT_NAME = "OctoBot-Trading" -VERSION = "2.4.236" # major.minor.revision +VERSION = "2.4.237" # major.minor.revision diff --git a/octobot_trading/exchanges/connectors/ccxt/ccxt_connector.py b/octobot_trading/exchanges/connectors/ccxt/ccxt_connector.py index a13d71bdf..9fcbd532b 100644 --- a/octobot_trading/exchanges/connectors/ccxt/ccxt_connector.py +++ b/octobot_trading/exchanges/connectors/ccxt/ccxt_connector.py @@ -949,7 +949,7 @@ def get_ccxt_order_type(self, order_type: enums.TraderOrderType): return enums.TradeOrderType.MARKET.value raise RuntimeError(f"Unknown order type: {order_type}") - def get_trade_fee(self, symbol: str, order_type: enums.TraderOrderType, quantity, price, taker_or_maker): + def get_trade_fee(self, symbol: str, order_type: enums.TraderOrderType, quantity, price, taker_or_maker) -> dict: fees = self.calculate_fees(symbol, order_type, quantity, price, taker_or_maker) fees[enums.FeePropertyColumns.IS_FROM_EXCHANGE.value] = False fees[enums.FeePropertyColumns.COST.value] = decimal.Decimal( diff --git a/octobot_trading/exchanges/exchange_builder.py b/octobot_trading/exchanges/exchange_builder.py index a31b7d50a..fbacd6de0 100644 --- a/octobot_trading/exchanges/exchange_builder.py +++ b/octobot_trading/exchanges/exchange_builder.py @@ -79,7 +79,9 @@ async def _build_exchange_manager(self): await self._build_trader() # create trading modes - await self._build_trading_modes_if_required(trading_mode_class) + await self._build_trading_modes_if_required( + trading_mode_class, self.exchange_manager.tentacles_setup_config + ) # add to global exchanges self.exchange_manager.update_debug_info() @@ -126,17 +128,24 @@ async def _register_trading_modes_requirements(self, trading_mode_class, tentacl not trading_mode_class.is_ignoring_cancelled_orders_trades() ) - async def _build_trading_modes_if_required(self, trading_mode_class): + async def _build_trading_modes_if_required(self, trading_mode_class, tentacles_setup_config): if self._is_using_trading_modes: # self.exchange_manager.trader can be None if neither simulator or real trader has be set - if self.exchange_manager.is_trading: + if self.exchange_manager.is_trading and ( + self.exchange_manager.trader is None or ( + trading_mode_class.get_is_using_trading_mode_on_exchange( + self.exchange_name, tentacles_setup_config + ) + ) + ): if self.exchange_manager.trader is None: self.logger.warning(f"There wont be any order created on {self.exchange_name}: neither " f"simulated nor real trader has been activated.") else: self.exchange_manager.trading_modes = await self.build_trading_modes(trading_mode_class) else: - self.logger.info(f"{self.exchange_name} exchange is online and won't be trading") + no_action = "trading without using direct trading mode" if self.exchange_manager.is_trading else "trading" + self.logger.info(f"{self.exchange_name} exchange is online and won't be {no_action}") async def build_trading_modes(self, trading_mode_class): try: diff --git a/octobot_trading/exchanges/types/rest_exchange.py b/octobot_trading/exchanges/types/rest_exchange.py index 48a62e20f..b953d3281 100644 --- a/octobot_trading/exchanges/types/rest_exchange.py +++ b/octobot_trading/exchanges/types/rest_exchange.py @@ -808,7 +808,7 @@ async def cancel_order( ) -> enums.OrderStatus: return await self.connector.cancel_order(exchange_order_id, symbol, order_type, **kwargs) - def get_trade_fee(self, symbol: str, order_type: enums.TraderOrderType, quantity, price, taker_or_maker): + def get_trade_fee(self, symbol: str, order_type: enums.TraderOrderType, quantity, price, taker_or_maker) -> dict: return self.connector.get_trade_fee(symbol, order_type, quantity, price, taker_or_maker) def get_fees(self, symbol): diff --git a/octobot_trading/modes/abstract_trading_mode.py b/octobot_trading/modes/abstract_trading_mode.py index 6526876ad..998dbd160 100644 --- a/octobot_trading/modes/abstract_trading_mode.py +++ b/octobot_trading/modes/abstract_trading_mode.py @@ -183,14 +183,25 @@ def is_following_trading_signals(self) -> bool: return False @classmethod - def get_is_trading_on_exchange(cls, exchange_name, - tentacles_setup_config: tm_configuration.TentaclesSetupConfiguration) -> bool: + def get_is_trading_on_exchange( + cls, exchange_name, tentacles_setup_config: tm_configuration.TentaclesSetupConfiguration + ) -> bool: """ :return: When returning false, the associated exchange_manager.is_trading will be set to false, which will prevent the initialization of trade related elements. Default is True """ return True + @classmethod + def get_is_using_trading_mode_on_exchange( + cls, exchange_name, tentacles_setup_config: tm_configuration.TentaclesSetupConfiguration + ) -> bool: + """ + :return: When returning false, no trading mode will be created on this exchange, even if + get_is_trading_on_exchange() returns True. Default is falling back to get_is_trading_on_exchange() + """ + return cls.get_is_trading_on_exchange(exchange_name, tentacles_setup_config) + @classmethod def is_ignoring_cancelled_orders_trades(cls) -> bool: """ @@ -672,3 +683,7 @@ def get_trading_mode_consumers(self): for consumer in self.consumers if isinstance(consumer, abstract_mode_consumer.AbstractTradingModeConsumer) ] + + def is_first_trading_mode_on_this_matrix(self) -> bool: + all_trading_modes = modes_util.get_trading_modes_of_this_type_on_this_matrix(self) + return bool(all_trading_modes and all_trading_modes[0] is self) diff --git a/octobot_trading/personal_data/__init__.py b/octobot_trading/personal_data/__init__.py index f42a7391d..95023fbe7 100644 --- a/octobot_trading/personal_data/__init__.py +++ b/octobot_trading/personal_data/__init__.py @@ -223,6 +223,7 @@ TradePnl, compute_win_rate, aggregate_trades_by_exchange_order_id, + get_real_or_estimated_trade_fee, ) from octobot_trading.personal_data import transactions from octobot_trading.personal_data.transactions import ( @@ -458,6 +459,7 @@ "TradePnl", "compute_win_rate", "aggregate_trades_by_exchange_order_id", + "get_real_or_estimated_trade_fee", "ExchangePersonalData", "get_asset_price_from_converter_or_tickers", "resolve_sub_portfolios", diff --git a/octobot_trading/personal_data/orders/active_order_swap_strategies/active_order_swap_strategy.py b/octobot_trading/personal_data/orders/active_order_swap_strategies/active_order_swap_strategy.py index 8ff2a597e..3df0a9a3c 100644 --- a/octobot_trading/personal_data/orders/active_order_swap_strategies/active_order_swap_strategy.py +++ b/octobot_trading/personal_data/orders/active_order_swap_strategies/active_order_swap_strategy.py @@ -35,17 +35,23 @@ class ActiveOrderSwapStrategy: def is_priority_order(self, order) -> bool: raise NotImplementedError("is_priority_order is not implemented") - async def apply_inactive_orders(self, orders: list): + async def apply_inactive_orders( + self, orders: list, + trigger_above_by_order_id: typing.Optional[dict[str, bool]] = None + ): for order in orders: + trigger_above = trigger_above_by_order_id.get( + order.order_id, order.trigger_above + ) if trigger_above_by_order_id else order.trigger_above trigger_price = self._get_trigger_price(order) if self.is_priority_order(order): # still register active trigger in case this order becomes inactive order.update( - active_trigger=order_util.create_order_price_trigger(order, trigger_price, order.trigger_above) + active_trigger=order_util.create_order_price_trigger(order, trigger_price, trigger_above) ) else: await order.set_as_inactive( - order_util.create_order_price_trigger(order, trigger_price, order.trigger_above) + order_util.create_order_price_trigger(order, trigger_price, trigger_above) ) def on_order_update(self, order, update_time): diff --git a/octobot_trading/personal_data/orders/order.py b/octobot_trading/personal_data/orders/order.py index ed481ee49..97c6ec9a6 100644 --- a/octobot_trading/personal_data/orders/order.py +++ b/octobot_trading/personal_data/orders/order.py @@ -931,8 +931,9 @@ def update_from_raw(self, raw_order): ) filled_price = decimal.Decimal(str(price)) # set average price with real average price if available, use filled_price otherwise - average_price = decimal.Decimal(str(raw_order.get(enums.ExchangeConstantsOrderColumns.AVERAGE.value, 0.0) - or filled_price)) + average_price = decimal.Decimal(str( + raw_order.get(enums.ExchangeConstantsOrderColumns.AVERAGE.value, 0.0) or filled_price + )) return self.update( symbol=str(raw_order.get(enums.ExchangeConstantsOrderColumns.SYMBOL.value, None)), @@ -1187,12 +1188,13 @@ def to_string(self): ) trailing_profile = f"Trailing profile : {self.trailing_profile} | " if self.trailing_profile else "" cancel_policy = f"Cancel policy : {self.cancel_policy} | " if self.cancel_policy else "" + filled_quantity = f" ({self.filled_quantity} Filled)" if self.filled_quantity else "" return ( f"{inactive}{self.symbol} | " f"{chained_order}" f"{self.order_type.name if self.order_type is not None else 'Unknown'} | " f"Price : {str(self.origin_price)} | " - f"Quantity : {str(self.origin_quantity)}{' (Reduce only)' if self.reduce_only else ''} | " + f"Quantity : {str(self.origin_quantity)}{filled_quantity}{' (Reduce only)' if self.reduce_only else ''} | " f"State : {self.state.state.value if self.state is not None else 'Unknown'} | " f"{trailing_profile}" f"{cancel_policy}" diff --git a/octobot_trading/personal_data/orders/order_state.py b/octobot_trading/personal_data/orders/order_state.py index 49a78578d..11d7e6456 100644 --- a/octobot_trading/personal_data/orders/order_state.py +++ b/octobot_trading/personal_data/orders/order_state.py @@ -76,7 +76,7 @@ def get_logger(self): """ :return: the order logger """ - return logging.get_logger(self.order.get_logger_name() if self.order is not None else + return logging.get_logger(f"[{self.__class__.__name__}] {self.order.get_logger_name()}" if self.order is not None else f"{self.__class__.__name__}_without_order") def log_event_message(self, state_message, error=None): diff --git a/octobot_trading/personal_data/orders/states/fill_order_state.py b/octobot_trading/personal_data/orders/states/fill_order_state.py index 3c97438e1..03f46a5e1 100644 --- a/octobot_trading/personal_data/orders/states/fill_order_state.py +++ b/octobot_trading/personal_data/orders/states/fill_order_state.py @@ -74,9 +74,13 @@ async def on_refresh_successful(self): can also be still pending or be fully filled """ - if self.order.status is enums.OrderStatus.PARTIALLY_FILLED: - # TODO manage partially filled - await self.update() + self.get_logger().info(f"on_refresh_successful [{self.order.status}] for {self.order}") + if self.order.is_partially_filled(): + self.get_logger().info(f"Partially filled order: {str(self.order)}") + # notify order partially filled + await self.order.exchange_manager.exchange_personal_data.handle_order_update_notification( + self.order, enums.OrderUpdateType.STATE_CHANGE + ) elif self.order.status in constants.FILL_ORDER_STATUS_SCOPE: self.state = enums.OrderStates.FILLED await self.update() diff --git a/octobot_trading/personal_data/orders/states/open_order_state.py b/octobot_trading/personal_data/orders/states/open_order_state.py index 357e08c7f..b4dcd1b98 100644 --- a/octobot_trading/personal_data/orders/states/open_order_state.py +++ b/octobot_trading/personal_data/orders/states/open_order_state.py @@ -18,6 +18,7 @@ import octobot_trading.enums as enums import octobot_trading.personal_data.orders.order_state as order_state import octobot_trading.personal_data.orders.states.order_state_factory as order_state_factory +import octobot_trading.personal_data.portfolios.portfolio_util as portfolio_util class OpenOrderState(order_state.OrderState): @@ -55,11 +56,16 @@ async def initialize_impl(self, forced=False) -> None: # update the availability of the currency in the portfolio if order is not # from exchange initialization (otherwise it's already taken into account in portfolio) portfolio_manager = self.order.exchange_manager.exchange_personal_data.portfolio_manager - before_order_details = str(portfolio_manager.portfolio) + before_order_details = portfolio_util.filter_empty_values( + portfolio_util.portfolio_to_float(portfolio_manager.portfolio.portfolio) + ) portfolio_manager.refresh_portfolio_available_from_order(self.order, True) + after_order_details = portfolio_util.filter_empty_values( + portfolio_util.portfolio_to_float(portfolio_manager.portfolio.portfolio) + ) self.get_logger().debug( - f"Updated portfolio available after new open order. " - f"Before order: {before_order_details}. After order: {portfolio_manager.portfolio}" + f"Updated [{self.order.exchange_manager.exchange_name}] portfolio available after new open order. " + f"Before order: {before_order_details}. After order: {after_order_details}" ) return await super().initialize_impl() diff --git a/octobot_trading/personal_data/portfolios/history/historical_asset_value.py b/octobot_trading/personal_data/portfolios/history/historical_asset_value.py index 9d9648638..168a02960 100644 --- a/octobot_trading/personal_data/portfolios/history/historical_asset_value.py +++ b/octobot_trading/personal_data/portfolios/history/historical_asset_value.py @@ -33,7 +33,7 @@ def __contains__(self, item): def __repr__(self): return f"{self.__class__.__name__} " \ - f"[timestamp: {HistoricalAssetValue}, _value_by_currency{self._value_by_currency}]" + f"[timestamp: {self._timestamp}, _value_by_currency{self._value_by_currency}]" def get(self, currency): return self._value_by_currency[currency] diff --git a/octobot_trading/personal_data/trades/__init__.py b/octobot_trading/personal_data/trades/__init__.py index dd27549d7..6359cca16 100644 --- a/octobot_trading/personal_data/trades/__init__.py +++ b/octobot_trading/personal_data/trades/__init__.py @@ -42,6 +42,7 @@ from octobot_trading.personal_data.trades.trades_util import ( compute_win_rate, aggregate_trades_by_exchange_order_id, + get_real_or_estimated_trade_fee, ) __all__ = [ @@ -57,4 +58,5 @@ "TradePnl", "compute_win_rate", "aggregate_trades_by_exchange_order_id", + "get_real_or_estimated_trade_fee", ] diff --git a/octobot_trading/personal_data/trades/trade.py b/octobot_trading/personal_data/trades/trade.py index 919cb77f3..797351623 100644 --- a/octobot_trading/personal_data/trades/trade.py +++ b/octobot_trading/personal_data/trades/trade.py @@ -176,6 +176,9 @@ def from_dict(cls, trader, trade_dict): trade.creation_time = trade_dict.get(enums.TradeExtraConstants.CREATION_TIME.value) return trade + def duplicate(self): + return self.__class__.from_dict(self.trader, self.to_dict()) + def clear(self): self.trader = None # type: ignore self.exchange_manager = None # type: ignore diff --git a/octobot_trading/personal_data/trades/trade_factory.py b/octobot_trading/personal_data/trades/trade_factory.py index 0a08b7236..96a85923d 100644 --- a/octobot_trading/personal_data/trades/trade_factory.py +++ b/octobot_trading/personal_data/trades/trade_factory.py @@ -13,20 +13,23 @@ # # You should have received a copy of the GNU Lesser General Public # License along with this library. +import typing + import octobot_commons.logging as logging import octobot_trading.personal_data.trades.trade as trade_class +import octobot_trading.personal_data.orders.order as order_class # pylint: disable=unused-import import octobot_trading.personal_data.orders.order_factory as order_factory import octobot_trading.enums as enums import octobot_trading.constants as constants -def create_trade_instance_from_raw(trader, raw_trade): +def create_trade_instance_from_raw(trader, raw_trade: dict[str, typing.Any]) -> "trade_class.Trade": order = create_closed_order_instance_from_raw_trade(trader, raw_trade) exchange_trade_id = raw_trade.get(enums.ExchangeConstantsOrderColumns.EXCHANGE_TRADE_ID.value) return create_trade_from_order(order, exchange_trade_id=exchange_trade_id) -def create_closed_order_instance_from_raw_trade(trader, raw_trade): +def create_closed_order_instance_from_raw_trade(trader, raw_trade: dict[str, typing.Any]) -> "order_class.Order": order = order_factory.create_order_from_raw(trader, raw_trade) order.update_from_raw(raw_trade) if order.status is enums.OrderStatus.CANCELED: @@ -38,12 +41,12 @@ def create_closed_order_instance_from_raw_trade(trader, raw_trade): return order -def create_trade_from_order(order, - close_status=None, - creation_time=0, - canceled_time=0, - executed_time=0, - exchange_trade_id=None): +def create_trade_from_order(order: "order_class.Order", + close_status: typing.Optional[enums.OrderStatus] = None, + creation_time: int = 0, + canceled_time: int = 0, + executed_time: int = 0, + exchange_trade_id: typing.Optional[str] = None) -> "trade_class.Trade": if close_status is not None: order.status = close_status trade = trade_class.Trade(order.trader) @@ -58,5 +61,5 @@ def create_trade_from_order(order, return trade -def create_trade_from_dict(trader, trade_dict): +def create_trade_from_dict(trader, trade_dict: dict[str, typing.Any]) -> "trade_class.Trade": return trade_class.Trade.from_dict(trader, trade_dict) diff --git a/octobot_trading/personal_data/trades/trades_util.py b/octobot_trading/personal_data/trades/trades_util.py index dc8e33e7c..cf95c0394 100644 --- a/octobot_trading/personal_data/trades/trades_util.py +++ b/octobot_trading/personal_data/trades/trades_util.py @@ -15,6 +15,7 @@ # License along with this library. import decimal +import octobot_commons.symbols as symbols_util import octobot_trading.enums as trading_enums import octobot_trading.constants as constants import octobot_trading.personal_data as personal_data @@ -93,3 +94,67 @@ def aggregate_trades_by_exchange_order_id(trades: list) -> dict: += trade.fee[trading_enums.FeePropertyColumns.COST.value] to_update.executed_time = max(to_update.executed_time, trade.executed_time) return aggregated_trades_by_exchange_order_id + + +def get_real_or_estimated_trade_fee(trade: "personal_data.Trade") -> tuple[dict, bool]: + order_type = trading_enums.TraderOrderType.BUY_LIMIT if trade.side is trading_enums.TradeOrderSide.BUY else trading_enums.TraderOrderType.SELL_LIMIT + trading_order_fee_currency = None + trading_order_fee_rate = None + is_estimated_trading_fee = False + if trade.fee and all( + key.value in trade.fee for key in [ + trading_enums.FeePropertyColumns.CURRENCY, trading_enums.FeePropertyColumns.RATE + ] + ): + trading_order_fee_currency = trade.fee[trading_enums.FeePropertyColumns.CURRENCY.value] + trading_order_fee_rate = trade.fee[trading_enums.FeePropertyColumns.RATE.value] + else: + # try to get fees from somewhere else + try: + # check if order has fees + order = trade.exchange_manager.exchange_personal_data.orders_manager.get_order( + None, exchange_order_id=trade.exchange_order_id + ) + if not order.fee or not all( + key.value in order.fee for key in [ + trading_enums.FeePropertyColumns.CURRENCY, trading_enums.FeePropertyColumns.RATE + ] + ): + raise KeyError("no fee") + trading_order_fee_currency = order.fee[trading_enums.FeePropertyColumns.CURRENCY.value] + trading_order_fee_rate = order.fee[trading_enums.FeePropertyColumns.RATE.value] + except KeyError: + # try with other trades from the same order + trades = trade.exchange_manager.exchange_personal_data.trades_manager.get_trades( + None, exchange_order_id=trade.exchange_order_id + ) + for other_trade in trades: + if not other_trade.fee or not all( + key.value in other_trade.fee for key in [ + trading_enums.FeePropertyColumns.CURRENCY, trading_enums.FeePropertyColumns.RATE + ] + ): + continue + trading_order_fee_currency = other_trade.fee[trading_enums.FeePropertyColumns.CURRENCY.value] + trading_order_fee_rate = other_trade.fee[trading_enums.FeePropertyColumns.RATE.value] + + if trading_order_fee_currency and trading_order_fee_rate: + base, quote = symbols_util.parse_symbol(trade.symbol).base_and_quote() + if trading_order_fee_currency == base: + trading_order_fee_cost = trading_order_fee_rate * trade.executed_quantity + elif trading_order_fee_currency == quote: + trading_order_fee_cost = trading_order_fee_rate * trade.executed_quantity * trade.executed_price + else: + # fee currency is not the base or quote of the symbol + trading_order_fee_cost = constants.ZERO + trading_fee = { + trading_enums.FeePropertyColumns.CURRENCY.value: trading_order_fee_currency, + trading_enums.FeePropertyColumns.COST.value: trading_order_fee_cost, + } + else: + # estimate fees + trading_fee = trade.exchange_manager.exchange.get_trade_fee( + trade.symbol, order_type, trade.executed_quantity, trade.executed_price, trade.taker_or_maker + ) + is_estimated_trading_fee = True + return trading_fee, is_estimated_trading_fee diff --git a/tests/exchanges/test_exchange_builder.py b/tests/exchanges/test_exchange_builder.py index 802431c07..8463d1cda 100644 --- a/tests/exchanges/test_exchange_builder.py +++ b/tests/exchanges/test_exchange_builder.py @@ -30,10 +30,11 @@ async def test_build_trading_modes_if_required(exchange_manager): builder = exchanges.ExchangeBuilder({}, "binanceus") builder.exchange_manager = exchange_manager + tentacles_setup_config = exchange_manager.tentacles_setup_config # no set trader: no trading mode creation attempt assert builder.exchange_manager.trader is None - await builder._build_trading_modes_if_required(None) + await builder._build_trading_modes_if_required(None, tentacles_setup_config) assert builder.exchange_manager.trader is None # with trader simulator: will attempt to create a trading mode and fail (because of the None arg) @@ -41,12 +42,39 @@ async def test_build_trading_modes_if_required(exchange_manager): with mock.patch.object( commons_authentication.Authenticator, "has_open_source_package", mock.Mock(return_value=False) ) as has_open_source_package_mock: + # get_is_using_trading_mode_on_exchange returns True + trading_mode_class = mock.Mock( + get_is_using_trading_mode_on_exchange=mock.Mock(return_value=True), + get_supported_exchange_types=mock.Mock(return_value=[]), + return_value=mock.Mock( + initialize=mock.AsyncMock(), + ), + ) with pytest.raises(AttributeError): - await builder._build_trading_modes_if_required(None) + await builder._build_trading_modes_if_required(None, tentacles_setup_config) + has_open_source_package_mock.assert_not_called() + await builder._build_trading_modes_if_required(trading_mode_class, tentacles_setup_config) + trading_mode_class.get_is_using_trading_mode_on_exchange.assert_called_once_with(builder.exchange_name, tentacles_setup_config) + trading_mode_class.get_supported_exchange_types.assert_called_once() + trading_mode_class.return_value.initialize.assert_awaited_once() has_open_source_package_mock.assert_called_once() + trading_mode_class.reset_mock() + trading_mode_class.get_is_using_trading_mode_on_exchange.reset_mock() + trading_mode_class.get_supported_exchange_types.reset_mock() + trading_mode_class.return_value.initialize.reset_mock() + + # get_is_using_trading_mode_on_exchange returns False + trading_mode_class.get_is_using_trading_mode_on_exchange.return_value = False + await builder._build_trading_modes_if_required(trading_mode_class, tentacles_setup_config) + trading_mode_class.get_is_using_trading_mode_on_exchange.assert_called_once_with(builder.exchange_name, tentacles_setup_config) + trading_mode_class.get_supported_exchange_types.assert_not_called() + trading_mode_class.return_value.initialize.assert_not_called() + has_open_source_package_mock.assert_called_once() + # raised by default has_open_source_package (which should be overriden) + trading_mode_class.get_is_using_trading_mode_on_exchange.return_value = True with pytest.raises(NotImplementedError): - await builder._build_trading_modes_if_required(None) + await builder._build_trading_modes_if_required(trading_mode_class, tentacles_setup_config) async def test_build_collector_exchange(exchange_manager): diff --git a/tests/personal_data/orders/active_order_swap_strategies/test_active_order_swap_strategy.py b/tests/personal_data/orders/active_order_swap_strategies/test_active_order_swap_strategy.py index 996699c5d..c5fb6fe15 100644 --- a/tests/personal_data/orders/active_order_swap_strategies/test_active_order_swap_strategy.py +++ b/tests/personal_data/orders/active_order_swap_strategies/test_active_order_swap_strategy.py @@ -68,6 +68,80 @@ async def test_apply_inactive_orders(swap_strategy, simulated_trader): assert swap_strategy.is_priority_order.call_count == 3 assert set_as_inactive_mock.call_count == 2 + # Test with trigger_above_by_order_id parameter + swap_strategy.is_priority_order = mock.Mock(side_effect=lambda o: o.order_type is enums.TraderOrderType.STOP_LOSS) + with mock.patch.object(personal_data.Order, "set_as_inactive", mock.AsyncMock()) as set_as_inactive_mock, \ + mock.patch.object(personal_data.Order, "update", mock.Mock()) as update_mock: + stop_loss = created_order(personal_data.StopLossLimitOrder, enums.TraderOrderType.STOP_LOSS, + trader_instance, side=enums.TradeOrderSide.SELL) + stop_loss.trigger_above = False + stop_loss.order_id = "stop_loss_id" + stop_loss.get_filling_price = mock.Mock(return_value=decimal.Decimal("100")) + + sell_limit = created_order(personal_data.SellLimitOrder, enums.TraderOrderType.SELL_LIMIT, + trader_instance, side=enums.TradeOrderSide.SELL) + sell_limit.trigger_above = True + sell_limit.order_id = "sell_limit_id" + sell_limit.get_filling_price = mock.Mock(return_value=decimal.Decimal("200")) + + buy_limit = created_order(personal_data.SellLimitOrder, enums.TraderOrderType.BUY_LIMIT, + trader_instance, side=enums.TradeOrderSide.SELL) + buy_limit.trigger_above = False + buy_limit.order_id = "buy_limit_id" + buy_limit.get_filling_price = mock.Mock(return_value=decimal.Decimal("300")) + + # Test: trigger_above_by_order_id overrides order's trigger_above + trigger_above_by_order_id = { + "stop_loss_id": True, # Override False to True + "sell_limit_id": False, # Override True to False + # buy_limit_id not in dict, should use its own trigger_above (False) + } + await swap_strategy.apply_inactive_orders([stop_loss, sell_limit, buy_limit], + trigger_above_by_order_id=trigger_above_by_order_id) + + # Verify priority order (stop_loss) gets active_trigger with overridden trigger_above=True + assert update_mock.call_count == 1 + update_call_kwargs = update_mock.call_args[1] + assert "active_trigger" in update_call_kwargs + active_trigger = update_call_kwargs["active_trigger"] + assert active_trigger.trigger_above is True # Overridden from False to True + + # Verify non-priority orders (sell_limit, buy_limit) get set_as_inactive with correct trigger_above + assert set_as_inactive_mock.call_count == 2 + assert set_as_inactive_mock.call_args_list[0][0][0].trigger_above is False # sell_limit order's trigger_above is overridden from True to False + assert set_as_inactive_mock.call_args_list[0][0][0].trigger_price == decimal.Decimal("200") # sell_limit order's trigger_price + assert set_as_inactive_mock.call_args_list[1][0][0].trigger_above is False # buy limit order's trigger_above's origin value + assert set_as_inactive_mock.call_args_list[1][0][0].trigger_price == decimal.Decimal("300") # buy_limit order's trigger_price + + # Test: trigger_above_by_order_id is None, should use order's trigger_above + swap_strategy.is_priority_order = mock.Mock(side_effect=lambda o: o.order_type is enums.TraderOrderType.STOP_LOSS) + with mock.patch.object(personal_data.Order, "set_as_inactive", mock.AsyncMock()) as set_as_inactive_mock, \ + mock.patch.object(personal_data.Order, "update", mock.Mock()) as update_mock: + stop_loss = created_order(personal_data.StopLossLimitOrder, enums.TraderOrderType.STOP_LOSS, + trader_instance, side=enums.TradeOrderSide.SELL) + stop_loss.trigger_above = True + stop_loss.order_id = "stop_loss_id" + stop_loss.get_filling_price = mock.Mock(return_value=decimal.Decimal("100")) + + sell_limit = created_order(personal_data.SellLimitOrder, enums.TraderOrderType.SELL_LIMIT, + trader_instance, side=enums.TradeOrderSide.SELL) + sell_limit.trigger_above = False + sell_limit.order_id = "sell_limit_id" + sell_limit.get_filling_price = mock.Mock(return_value=decimal.Decimal("200")) + + await swap_strategy.apply_inactive_orders([stop_loss, sell_limit], + trigger_above_by_order_id=None) + + # Verify priority order uses its own trigger_above=True + assert update_mock.call_count == 1 + update_call_kwargs = update_mock.call_args[1] + active_trigger = update_call_kwargs["active_trigger"] + assert active_trigger.trigger_above is True # Uses order's own value + + # Verify non-priority order uses its own trigger_above=False + assert set_as_inactive_mock.call_count == 1 + assert sell_limit.trigger_above is False # Uses order's own value + async def test_execute_no_reverse(swap_strategy, simulated_trader): diff --git a/tests/personal_data/trades/test_trade_util.py b/tests/personal_data/trades/test_trade_util.py index b6ee52440..a19008dbe 100644 --- a/tests/personal_data/trades/test_trade_util.py +++ b/tests/personal_data/trades/test_trade_util.py @@ -14,6 +14,8 @@ # You should have received a copy of the GNU Lesser General Public # License along with this library. import decimal +import time +import mock from tests import event_loop import octobot_trading.personal_data as personal_data @@ -81,3 +83,371 @@ def _get_fees(currency, value): enums.FeePropertyColumns.CURRENCY.value: currency, enums.FeePropertyColumns.COST.value: decimal.Decimal(str(value)) } + + +class TestGetTradingOrderFee: + """Test class for get_trading_order_fee method.""" + + def create_trade( + self, trader, exchange_order_id: str, + side, executed_quantity, executed_price, symbol, fee + ) -> personal_data.Trade: + return personal_data.create_trade_from_dict( + trader, + { + enums.ExchangeConstantsOrderColumns.EXCHANGE_ID.value: exchange_order_id, + enums.ExchangeConstantsOrderColumns.SYMBOL.value: symbol, + enums.ExchangeConstantsOrderColumns.SIDE.value: side, + enums.ExchangeConstantsOrderColumns.AMOUNT.value: executed_quantity, + enums.ExchangeConstantsOrderColumns.PRICE.value: executed_price, + enums.ExchangeConstantsOrderColumns.TYPE.value: enums.TradeOrderType.LIMIT.value, + enums.ExchangeConstantsOrderColumns.COST.value: executed_quantity * executed_price, + enums.ExchangeConstantsOrderColumns.TIMESTAMP.value: time.time(), + enums.ExchangeConstantsOrderColumns.FEE.value: fee, + } + ) + + def test_gets_fee_from_trade_with_base_currency(self, simulated_trader): + _, _, trader = simulated_trader + # Create trade with fee already set (base currency) + trade = self.create_trade( + trader, "order_123", enums.TradeOrderSide.BUY, decimal.Decimal("0.1"), decimal.Decimal("1000"), SYMBOL, + { + enums.FeePropertyColumns.CURRENCY.value: "BTC", + enums.FeePropertyColumns.RATE.value: decimal.Decimal("0.001"), # 0.1% + } + ) + + # Get trading order fee + # Should return calculated fee from trade.fee directly and not be estimated + assert personal_data.get_real_or_estimated_trade_fee( + trade + ) == ( + _get_fees("BTC", 0.0001), + False + ) + + def test_gets_fee_from_trade_with_quote_currency(self, simulated_trader): + _, _, trader = simulated_trader + # Create trade with fee already set (quote currency) + trade = self.create_trade( + trader, "order_123", enums.TradeOrderSide.BUY, decimal.Decimal("0.1"), decimal.Decimal("1000"), SYMBOL, + { + enums.FeePropertyColumns.CURRENCY.value: "USDT", + enums.FeePropertyColumns.RATE.value: decimal.Decimal("0.001"), # 0.1% + } + ) + + # Get trading order fee + # Should return calculated fee from trade.fee directly and not be estimated + assert personal_data.get_real_or_estimated_trade_fee( + trade + ) == ( + _get_fees("USDT", 0.1), + False + ) + + def test_gets_fee_from_order_with_base_currency(self, simulated_trader): + _, _, trader = simulated_trader + trade = self.create_trade( + trader, "order_123", enums.TradeOrderSide.BUY, decimal.Decimal("0.1"), decimal.Decimal("1000"), SYMBOL, None + ) + # Create mock order with fee in base currency + mock_order = mock.Mock() + mock_order.fee = { + enums.FeePropertyColumns.CURRENCY.value: "BTC", + enums.FeePropertyColumns.RATE.value: decimal.Decimal("0.001"), # 0.1% + } + + # Mock orders_manager.get_order + trader.exchange_manager.exchange_personal_data.orders_manager.get_order = mock.Mock( + return_value=mock_order + ) + + # Get trading order fee + # Should return calculated fee and not be estimated + assert personal_data.get_real_or_estimated_trade_fee( + trade + ) == ( + _get_fees("BTC", 0.0001), + False + ) + + def test_gets_fee_from_order_with_quote_currency(self, simulated_trader): + _, _, trader = simulated_trader + trade = self.create_trade( + trader, "order_123", enums.TradeOrderSide.BUY, decimal.Decimal("0.1"), decimal.Decimal("1000"), SYMBOL, None + ) + # Create mock order with fee in quote currency + mock_order = mock.Mock() + mock_order.fee = { + enums.FeePropertyColumns.CURRENCY.value: "USDT", + enums.FeePropertyColumns.RATE.value: decimal.Decimal("0.001"), # 0.1% + } + + # Mock orders_manager.get_order + trader.exchange_manager.exchange_personal_data.orders_manager.get_order = mock.Mock( + return_value=mock_order + ) + + # Get trading order fee + # Should return calculated fee and not be estimated + assert personal_data.get_real_or_estimated_trade_fee( + trade + ) == ( + _get_fees("USDT", 0.1), + False + ) + + def test_gets_fee_from_trades_when_order_has_no_fee(self, simulated_trader): + _, _, trader = simulated_trader + trade = self.create_trade( + trader, "order_123", enums.TradeOrderSide.BUY, decimal.Decimal("0.1"), decimal.Decimal("1000"), SYMBOL, None + ) + + # Create mock order without fee + mock_order = mock.Mock() + mock_order.fee = None + + # Create mock trade with fee + mock_trade = mock.Mock() + mock_trade.fee = { + enums.FeePropertyColumns.CURRENCY.value: "USDT", + enums.FeePropertyColumns.RATE.value: decimal.Decimal("0.001"), + } + + # Mock orders_manager.get_order to return order without fee + trader.exchange_manager.exchange_personal_data.orders_manager.get_order = mock.Mock( + return_value=mock_order + ) + + # Mock trades_manager.get_trades to return trade with fee + trader.exchange_manager.exchange_personal_data.trades_manager.get_trades = mock.Mock( + return_value=[mock_trade] + ) + + # Get trading order fee + # Should return calculated fee from trade and not be estimated + assert personal_data.get_real_or_estimated_trade_fee( + trade + ) == ( + # Fee cost = rate * amount * price = 0.001 * 0.1 * 1000 = 0.1 USDT + _get_fees("USDT", 0.1), + False + ) + + def test_gets_fee_from_trades_when_order_fee_missing_fields(self, simulated_trader): + _, _, trader = simulated_trader + trade = self.create_trade( + trader, "order_123", enums.TradeOrderSide.BUY, decimal.Decimal("0.1"), decimal.Decimal("1000"), SYMBOL, None + ) + + # Create mock order with incomplete fee (missing RATE) + mock_order = mock.Mock() + mock_order.fee = { + enums.FeePropertyColumns.CURRENCY.value: "USDT", + # Missing RATE + } + + # Create mock trade with fee + mock_trade = mock.Mock() + mock_trade.fee = { + enums.FeePropertyColumns.CURRENCY.value: "BTC", + enums.FeePropertyColumns.RATE.value: decimal.Decimal("0.001"), + } + + # Mock orders_manager.get_order + trader.exchange_manager.exchange_personal_data.orders_manager.get_order = mock.Mock( + return_value=mock_order + ) + + # Mock trades_manager.get_trades + trader.exchange_manager.exchange_personal_data.trades_manager.get_trades = mock.Mock( + return_value=[mock_trade] + ) + + # Get trading order fee + # Should return calculated fee from trade and not be estimated + assert personal_data.get_real_or_estimated_trade_fee( + trade + ) == ( + # Fee cost = rate * amount = 0.001 * 0.1 = 0.0001 BTC + _get_fees("BTC", 0.0001), + False + ) + + def test_skips_trades_without_fee(self, simulated_trader): + _, _, trader = simulated_trader + trade = self.create_trade( + trader, "order_123", enums.TradeOrderSide.BUY, decimal.Decimal("0.1"), decimal.Decimal("1000"), SYMBOL, None + ) + + # Create mock order without fee + mock_order = mock.Mock() + mock_order.fee = None + + # Create mock trades: one without fee, one with fee + mock_trade_no_fee = mock.Mock() + mock_trade_no_fee.fee = None + + mock_trade_with_fee = mock.Mock() + mock_trade_with_fee.fee = { + enums.FeePropertyColumns.CURRENCY.value: "USDT", + enums.FeePropertyColumns.RATE.value: decimal.Decimal("0.001"), + } + + # Mock orders_manager.get_order + trader.exchange_manager.exchange_personal_data.orders_manager.get_order = mock.Mock( + return_value=mock_order + ) + + # Mock trades_manager.get_trades to return both trades + trader.exchange_manager.exchange_personal_data.trades_manager.get_trades = mock.Mock( + return_value=[mock_trade_no_fee, mock_trade_with_fee] + ) + + # Get trading order fee + assert personal_data.get_real_or_estimated_trade_fee( + trade + ) == ( + # Should return calculated fee from the trade with fee + _get_fees("USDT", 0.1), + False + ) + + def test_estimates_fee_when_no_order_or_trade_fee(self, simulated_trader): + _, _, trader = simulated_trader + trade = self.create_trade( + trader, "order_123", enums.TradeOrderSide.BUY, decimal.Decimal("0.1"), decimal.Decimal("1000"), SYMBOL, None + ) + + # Create mock order without fee + mock_order = mock.Mock() + mock_order.fee = None + + # Create mock trades without fee + mock_trade = mock.Mock() + mock_trade.fee = None + + # Mock orders_manager.get_order + trader.exchange_manager.exchange_personal_data.orders_manager.get_order = mock.Mock( + return_value=mock_order + ) + + # Mock trades_manager.get_trades + trader.exchange_manager.exchange_personal_data.trades_manager.get_trades = mock.Mock( + return_value=[mock_trade] + ) + + # Mock get_trade_fee + mock_fee = { + enums.FeePropertyColumns.CURRENCY.value: "USDT", + enums.FeePropertyColumns.COST.value: decimal.Decimal("0.1"), + } + with mock.patch.object( + trader.exchange_manager.exchange, + "get_trade_fee", + return_value=mock_fee + ) as mock_get_trade_fee: + # Get trading order fee + # Should estimate fee and return True for is_estimated + assert personal_data.get_real_or_estimated_trade_fee( + trade + ) == ( + mock_fee, + True + ) + + # Verify get_trade_fee was called with correct parameters + mock_get_trade_fee.assert_called_once_with( + trade.symbol, + enums.TraderOrderType.BUY_LIMIT, # BUY trade -> BUY_LIMIT + trade.executed_quantity, + trade.executed_price, + trade.taker_or_maker + ) + + def test_handles_order_not_found_exception(self, simulated_trader): + _, _, trader = simulated_trader + trade = self.create_trade( + trader, "order_123", enums.TradeOrderSide.BUY, decimal.Decimal("0.1"), decimal.Decimal("1000"), SYMBOL, None + ) + + # Mock orders_manager.get_order to raise KeyError (order not found) + trader.exchange_manager.exchange_personal_data.orders_manager.get_order = mock.Mock( + side_effect=KeyError("Order not found") + ) + + # Create mock trade with fee + mock_trade = mock.Mock() + mock_trade.fee = { + enums.FeePropertyColumns.CURRENCY.value: "USDT", + enums.FeePropertyColumns.RATE.value: decimal.Decimal("0.001"), + } + + # Mock trades_manager.get_trades + trader.exchange_manager.exchange_personal_data.trades_manager.get_trades = mock.Mock( + return_value=[mock_trade] + ) + + # Get trading order fee + # Should fall back to trade fee + assert personal_data.get_real_or_estimated_trade_fee( + trade + ) == ( + _get_fees("USDT", 0.1), + False + ) + + def test_calculates_base_fee_correctly(self, simulated_trader): + _, _, trader = simulated_trader + trade = self.create_trade( + trader, "order_123", enums.TradeOrderSide.BUY, decimal.Decimal("0.5"), decimal.Decimal("1000"), SYMBOL, None + ) + + # Create mock order with fee in base currency + mock_order = mock.Mock() + mock_order.fee = { + enums.FeePropertyColumns.CURRENCY.value: "BTC", + enums.FeePropertyColumns.RATE.value: decimal.Decimal("0.002"), # 0.2% + } + + # Mock orders_manager.get_order + trader.exchange_manager.exchange_personal_data.orders_manager.get_order = mock.Mock( + return_value=mock_order + ) + + # Get trading order fee + assert personal_data.get_real_or_estimated_trade_fee( + trade + ) == ( + _get_fees("BTC", 0.001), + False + ) + + def test_calculates_quote_fee_correctly(self, simulated_trader): + _, _, trader = simulated_trader + trade = self.create_trade( + trader, "order_123", enums.TradeOrderSide.SELL, decimal.Decimal("0.2"), decimal.Decimal("5000"), SYMBOL, None + ) + + # Create mock order with fee in quote currency + mock_order = mock.Mock() + mock_order.fee = { + enums.FeePropertyColumns.CURRENCY.value: "USDT", + enums.FeePropertyColumns.RATE.value: decimal.Decimal("0.001"), # 0.1% + } + + # Mock orders_manager.get_order + trader.exchange_manager.exchange_personal_data.orders_manager.get_order = mock.Mock( + return_value=mock_order + ) + + # Get trading order fee + assert personal_data.get_real_or_estimated_trade_fee( + trade + ) == ( + # Fee cost = rate * amount * price = 0.001 * 0.2 * 5000 = 1.0 USDT + _get_fees("USDT", 1.0), + False + )