From cac777cb214499b70f0cc187bc9d4888897fe3cc Mon Sep 17 00:00:00 2001 From: Axel-CH Date: Wed, 4 Oct 2023 13:09:44 -0400 Subject: [PATCH 01/48] add property has_open_sl_orders to trade model --- freqtrade/persistence/trade_model.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/freqtrade/persistence/trade_model.py b/freqtrade/persistence/trade_model.py index 631585127..48fed1782 100644 --- a/freqtrade/persistence/trade_model.py +++ b/freqtrade/persistence/trade_model.py @@ -523,6 +523,17 @@ class LocalTrade: ] return len(open_orders_wo_sl) > 0 + @property + def has_open_sl_orders(self) -> int: + """ + True if there are open stoploss orders for this trade + """ + open_sl_orders = [ + o for o in self.orders + if o.ft_order_side in ['stoploss'] and o.ft_is_open + ] + return len(open_sl_orders) > 0 + @property def open_orders_ids(self) -> List[str]: open_orders_ids_wo_sl = [ From 9214af69012a6e73503c174f54ff9534e053447d Mon Sep 17 00:00:00 2001 From: Axel-CH Date: Thu, 5 Oct 2023 22:24:17 -0400 Subject: [PATCH 02/48] update cancel_stoploss_on_exchange to cancel all sl orders of trade --- freqtrade/freqtradebot.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index 02d43432d..ebc146ede 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -904,18 +904,18 @@ class FreqtradeBot(LoggingMixin): def cancel_stoploss_on_exchange(self, trade: Trade) -> Trade: # First cancelling stoploss on exchange ... - if trade.stoploss_order_id: - try: - logger.info(f"Canceling stoploss on exchange for {trade}") - co = self.exchange.cancel_stoploss_order_with_result( - trade.stoploss_order_id, trade.pair, trade.amount) - self.update_trade_state(trade, trade.stoploss_order_id, co, stoploss_order=True) - - # Reset stoploss order id. - trade.stoploss_order_id = None - except InvalidOrderException: - logger.exception(f"Could not cancel stoploss order {trade.stoploss_order_id} " - f"for pair {trade.pair}") + if trade.has_open_sl_orders: + for o in trade.orders: + if o.ft_order_side == 'stoploss' and o.ft_is_open: + try: + logger.info(f"Canceling stoploss on exchange for {trade} " + f"order: {o.order_id}") + co = self.exchange.cancel_stoploss_order_with_result( + o.order_id, trade.pair, trade.amount) + self.update_trade_state(trade, o.order_id, co, stoploss_order=True) + except InvalidOrderException: + logger.exception(f"Could not cancel stoploss order {o.order_id} " + f"for pair {trade.pair}") return trade def get_valid_enter_price_and_stake( From d5a0759051497c977499c38d747ca953aac1c99f Mon Sep 17 00:00:00 2001 From: Axel-CH Date: Fri, 6 Oct 2023 10:29:57 -0400 Subject: [PATCH 03/48] add open_sl_orders helper, use it in cancel_stoploss_on_exchange --- freqtrade/freqtradebot.py | 22 ++++++++++------------ freqtrade/persistence/trade_model.py | 10 ++++++++++ 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index ebc146ede..ff3c36bf8 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -904,18 +904,16 @@ class FreqtradeBot(LoggingMixin): def cancel_stoploss_on_exchange(self, trade: Trade) -> Trade: # First cancelling stoploss on exchange ... - if trade.has_open_sl_orders: - for o in trade.orders: - if o.ft_order_side == 'stoploss' and o.ft_is_open: - try: - logger.info(f"Canceling stoploss on exchange for {trade} " - f"order: {o.order_id}") - co = self.exchange.cancel_stoploss_order_with_result( - o.order_id, trade.pair, trade.amount) - self.update_trade_state(trade, o.order_id, co, stoploss_order=True) - except InvalidOrderException: - logger.exception(f"Could not cancel stoploss order {o.order_id} " - f"for pair {trade.pair}") + for oslo in trade.open_sl_orders: + try: + logger.info(f"Canceling stoploss on exchange for {trade} " + f"order: {oslo.order_id}") + co = self.exchange.cancel_stoploss_order_with_result( + oslo.order_id, trade.pair, trade.amount) + self.update_trade_state(trade, oslo.order_id, co, stoploss_order=True) + except InvalidOrderException: + logger.exception(f"Could not cancel stoploss order {oslo.order_id} " + f"for pair {trade.pair}") return trade def get_valid_enter_price_and_stake( diff --git a/freqtrade/persistence/trade_model.py b/freqtrade/persistence/trade_model.py index 48fed1782..e483dcc24 100644 --- a/freqtrade/persistence/trade_model.py +++ b/freqtrade/persistence/trade_model.py @@ -523,6 +523,16 @@ class LocalTrade: ] return len(open_orders_wo_sl) > 0 + @property + def open_sl_orders(self) -> List[Order]: + """ + All open stoploss orders for this trade + """ + return [ + o for o in self.orders + if o.ft_order_side in ['stoploss'] and o.ft_is_open + ] + @property def has_open_sl_orders(self) -> int: """ From 2565d509a614b0d0f6730b5d1f819d912c3818f5 Mon Sep 17 00:00:00 2001 From: Axel-CH Date: Fri, 6 Oct 2023 10:38:14 -0400 Subject: [PATCH 04/48] remove legacy sl management code from handle_insufficient_funds --- freqtrade/freqtradebot.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index ff3c36bf8..7d78779ff 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -439,10 +439,6 @@ class FreqtradeBot(LoggingMixin): try: fo = self.exchange.fetch_order_or_stoploss_order(order.order_id, order.ft_pair, order.ft_order_side == 'stoploss') - if order.ft_order_side == 'stoploss': - if fo and fo['status'] == 'open': - # Assume this as the open stoploss order - trade.stoploss_order_id = order.order_id if fo: logger.info(f"Found {order} for trade {trade}.") self.update_trade_state(trade, order.order_id, fo, From ea828ccb4a8164f96a0853ae7e9e0b5a06e52adb Mon Sep 17 00:00:00 2001 From: Axel-CH Date: Fri, 6 Oct 2023 10:40:23 -0400 Subject: [PATCH 05/48] remove legacy sl management code from create_stoploss_order --- freqtrade/freqtradebot.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index 7d78779ff..a8f5665c8 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -1189,7 +1189,6 @@ class FreqtradeBot(LoggingMixin): order_obj = Order.parse_from_ccxt_object(stoploss_order, trade.pair, 'stoploss', trade.amount, stop_price) trade.orders.append(order_obj) - trade.stoploss_order_id = str(stoploss_order['id']) trade.stoploss_last_update = datetime.now(timezone.utc) return True except InsufficientFundsError as e: @@ -1198,13 +1197,11 @@ class FreqtradeBot(LoggingMixin): self.handle_insufficient_funds(trade) except InvalidOrderException as e: - trade.stoploss_order_id = None logger.error(f'Unable to place a stoploss order on exchange. {e}') logger.warning('Exiting the trade forcefully') self.emergency_exit(trade, stop_price) except ExchangeError: - trade.stoploss_order_id = None logger.exception('Unable to place a stoploss order on exchange.') return False From 9d82de15d43dcb98e5c6c61912f0709315c874b2 Mon Sep 17 00:00:00 2001 From: Axel-CH Date: Fri, 6 Oct 2023 15:56:08 -0400 Subject: [PATCH 06/48] first updated proposition of handle_stoploss_on_exchange, add sl_orders helper --- freqtrade/freqtradebot.py | 100 ++++++++++++++++----------- freqtrade/persistence/trade_model.py | 10 +++ 2 files changed, 70 insertions(+), 40 deletions(-) diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index a8f5665c8..3338805f7 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -1215,27 +1215,30 @@ class FreqtradeBot(LoggingMixin): """ logger.debug('Handling stoploss on exchange %s ...', trade) - stoploss_order = None - try: - # First we check if there is already a stoploss on exchange - stoploss_order = self.exchange.fetch_stoploss_order( - trade.stoploss_order_id, trade.pair) if trade.stoploss_order_id else None - except InvalidOrderException as exception: - logger.warning('Unable to fetch stoploss order: %s', exception) + stoploss_orders = [] + for slo in trade.sl_orders: + stoploss_order = None + try: + # First we check if there is already a stoploss on exchange + stoploss_order = self.exchange.fetch_stoploss_order( + slo.order_id, trade.pair) if slo.order_id else None + except InvalidOrderException as exception: + logger.warning('Unable to fetch stoploss order: %s', exception) - if stoploss_order: - self.update_trade_state(trade, trade.stoploss_order_id, stoploss_order, - stoploss_order=True) + if stoploss_order: + stoploss_orders.append(stoploss_order) + self.update_trade_state(trade, slo.order_id, stoploss_order, + stoploss_order=True) - # We check if stoploss order is fulfilled - if stoploss_order and stoploss_order['status'] in ('closed', 'triggered'): - trade.exit_reason = ExitType.STOPLOSS_ON_EXCHANGE.value - self.update_trade_state(trade, trade.stoploss_order_id, stoploss_order, - stoploss_order=True) - self._notify_exit(trade, "stoploss", True) - self.handle_protections(trade.pair, trade.trade_direction) - return True + # We check if stoploss order is fulfilled + if stoploss_order and stoploss_order['status'] in ('closed', 'triggered'): + trade.exit_reason = ExitType.STOPLOSS_ON_EXCHANGE.value + self.update_trade_state(trade, slo.order_id, stoploss_order, + stoploss_order=True) + self._notify_exit(trade, "stoploss", True) + self.handle_protections(trade.pair, trade.trade_direction) + return True if trade.has_open_orders or not trade.is_open: # Trade has an open Buy or Sell order, Stoploss-handling can't happen in this case @@ -1244,7 +1247,7 @@ class FreqtradeBot(LoggingMixin): return False # If enter order is fulfilled but there is no stoploss, we add a stoploss on exchange - if not stoploss_order: + if len(stoploss_orders) == 0: stop_price = trade.stoploss_or_liquidation if self.edge: stoploss = self.edge.get_stoploss(pair=trade.pair) @@ -1258,27 +1261,7 @@ class FreqtradeBot(LoggingMixin): # in which case the trade will be closed - which we must check below. return False - # If stoploss order is canceled for some reason we add it again - if (trade.is_open - and stoploss_order - and stoploss_order['status'] in ('canceled', 'cancelled')): - if self.create_stoploss_order(trade=trade, stop_price=trade.stoploss_or_liquidation): - return False - else: - logger.warning('Stoploss order was cancelled, but unable to recreate one.') - - # Finally we check if stoploss on exchange should be moved up because of trailing. - # Triggered Orders are now real orders - so don't replace stoploss anymore - if ( - trade.is_open and stoploss_order - and stoploss_order.get('status_stop') != 'triggered' - and (self.config.get('trailing_stop', False) - or self.config.get('use_custom_stoploss', False)) - ): - # if trailing stoploss is enabled we check if stoploss value has changed - # in which case we cancel stoploss order and put another one with new - # value immediately - self.handle_trailing_stoploss_on_exchange(trade, stoploss_order) + self.manage_trade_stoploss_orders(trade, stoploss_orders) return False @@ -1314,6 +1297,43 @@ class FreqtradeBot(LoggingMixin): logger.warning(f"Could not create trailing stoploss order " f"for pair {trade.pair}.") + def manage_trade_stoploss_orders(self, trade, stoploss_orders): + """ + Check to see if stoploss on exchange should be updated + in case of trailing stoploss on exchange + :param trade: Corresponding Trade + :param stoploss_orders: Current on exchange stoploss orders + :return: None + """ + # If all stoploss orderd are canceled for some reason we add it again + canceled_sl_orders = [o for o in stoploss_orders if o.status in ['canceled', 'cancelled']] + if ( + trade.is_open and + len(stoploss_orders) > 0 and + len(stoploss_orders) == len(canceled_sl_orders) + ): + if self.create_stoploss_order(trade=trade, stop_price=trade.stoploss_or_liquidation): + return False + else: + logger.warning('All Stoploss orders are cancelled, but unable to recreate one.') + + active_sl_orders = [o for o in stoploss_orders if o not in canceled_sl_orders] + if len(active_sl_orders) > 0: + last_active_sl_order = active_sl_orders[-1] + # Finally we check if stoploss on exchange should be moved up because of trailing. + # Triggered Orders are now real orders - so don't replace stoploss anymore + if (trade.is_open and + last_active_sl_order.get('status_stop') != 'triggered' and + (self.config.get('trailing_stop', False) or + self.config.get('use_custom_stoploss', False))): + # if trailing stoploss is enabled we check if stoploss value has changed + # in which case we cancel stoploss order and put another one with new + # value immediately + self.handle_trailing_stoploss_on_exchange(trade, last_active_sl_order) + + # TODO cancel remaining_active_sl_orders active_sl_orders[:-1] + return + def manage_open_orders(self) -> None: """ Management of open orders on exchange. Unfilled orders might be cancelled if timeout diff --git a/freqtrade/persistence/trade_model.py b/freqtrade/persistence/trade_model.py index e483dcc24..ee6531030 100644 --- a/freqtrade/persistence/trade_model.py +++ b/freqtrade/persistence/trade_model.py @@ -544,6 +544,16 @@ class LocalTrade: ] return len(open_sl_orders) > 0 + @property + def sl_orders(self) -> List[Order]: + """ + All stoploss orders for this trade + """ + return [ + o for o in self.orders + if o.ft_order_side in ['stoploss'] + ] + @property def open_orders_ids(self) -> List[str]: open_orders_ids_wo_sl = [ From df8f1b93285b33599d1b4c26aeeeb575a2221afd Mon Sep 17 00:00:00 2001 From: Axel-CH Date: Fri, 6 Oct 2023 15:58:51 -0400 Subject: [PATCH 07/48] update manage_trade_stoploss_orders description --- freqtrade/freqtradebot.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index 3338805f7..6f5ba41ae 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -1299,8 +1299,7 @@ class FreqtradeBot(LoggingMixin): def manage_trade_stoploss_orders(self, trade, stoploss_orders): """ - Check to see if stoploss on exchange should be updated - in case of trailing stoploss on exchange + Perform required actions acording to existing stoploss orders of trade :param trade: Corresponding Trade :param stoploss_orders: Current on exchange stoploss orders :return: None From 2bb68ca53d23634ba6ff5a8a7005bff6866d85c6 Mon Sep 17 00:00:00 2001 From: Axel-CH Date: Fri, 6 Oct 2023 16:08:40 -0400 Subject: [PATCH 08/48] remove stoploss_order_id from LocalTrade class --- freqtrade/persistence/trade_model.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/freqtrade/persistence/trade_model.py b/freqtrade/persistence/trade_model.py index ee6531030..9c671d4b3 100644 --- a/freqtrade/persistence/trade_model.py +++ b/freqtrade/persistence/trade_model.py @@ -366,8 +366,6 @@ class LocalTrade: # percentage value of the initial stop loss initial_stop_loss_pct: Optional[float] = None is_stop_loss_trailing: bool = False - # stoploss order id which is on exchange - stoploss_order_id: Optional[str] = None # last update time of the stoploss order on exchange stoploss_last_update: Optional[datetime] = None # absolute value of the highest reached price From aaa82e1fa9c1f12db70526c463c55b1ac1c2221f Mon Sep 17 00:00:00 2001 From: Axel-CH Date: Fri, 6 Oct 2023 16:34:03 -0400 Subject: [PATCH 09/48] remove all occurence of stoploss_order_id in trade_model, update api schemas, update rpc_delete --- freqtrade/persistence/trade_model.py | 6 ------ freqtrade/rpc/api_server/api_schemas.py | 1 - freqtrade/rpc/rpc.py | 17 +++++++++-------- 3 files changed, 9 insertions(+), 15 deletions(-) diff --git a/freqtrade/persistence/trade_model.py b/freqtrade/persistence/trade_model.py index 9c671d4b3..26834ae48 100644 --- a/freqtrade/persistence/trade_model.py +++ b/freqtrade/persistence/trade_model.py @@ -636,7 +636,6 @@ class LocalTrade: 'stop_loss_abs': self.stop_loss, 'stop_loss_ratio': self.stop_loss_pct if self.stop_loss_pct else None, 'stop_loss_pct': (self.stop_loss_pct * 100) if self.stop_loss_pct else None, - 'stoploss_order_id': self.stoploss_order_id, 'stoploss_last_update': (self.stoploss_last_update.strftime(DATETIME_PRINT_FORMAT) if self.stoploss_last_update else None), 'stoploss_last_update_timestamp': int(self.stoploss_last_update.replace( @@ -787,7 +786,6 @@ class LocalTrade: logger.info(f'{order.order_type.upper()}_{payment} has been fulfilled for {self}.') elif order.ft_order_side == 'stoploss' and order.status not in ('open', ): - self.stoploss_order_id = None self.close_rate_requested = self.stop_loss self.exit_reason = ExitType.STOPLOSS_ON_EXCHANGE.value if self.is_open and order.safe_filled > 0: @@ -1378,9 +1376,6 @@ class Trade(ModelBase, LocalTrade): Float(), nullable=True) # type: ignore is_stop_loss_trailing: Mapped[bool] = mapped_column( nullable=False, default=False) # type: ignore - # stoploss order id which is on exchange - stoploss_order_id: Mapped[Optional[str]] = mapped_column( - String(255), nullable=True, index=True) # type: ignore # last update time of the stoploss order on exchange stoploss_last_update: Mapped[Optional[datetime]] = mapped_column(nullable=True) # type: ignore # absolute value of the highest reached price @@ -1805,7 +1800,6 @@ class Trade(ModelBase, LocalTrade): exit_order_status=data["exit_order_status"], stop_loss=data["stop_loss_abs"], stop_loss_pct=data["stop_loss_ratio"], - stoploss_order_id=data["stoploss_order_id"], stoploss_last_update=( datetime.fromtimestamp(data["stoploss_last_update_timestamp"] // 1000, tz=timezone.utc) diff --git a/freqtrade/rpc/api_server/api_schemas.py b/freqtrade/rpc/api_server/api_schemas.py index 97f6251bc..4f154f3a3 100644 --- a/freqtrade/rpc/api_server/api_schemas.py +++ b/freqtrade/rpc/api_server/api_schemas.py @@ -299,7 +299,6 @@ class TradeSchema(BaseModel): stop_loss_abs: Optional[float] = None stop_loss_ratio: Optional[float] = None stop_loss_pct: Optional[float] = None - stoploss_order_id: Optional[str] = None stoploss_last_update: Optional[str] = None stoploss_last_update_timestamp: Optional[int] = None initial_stop_loss_abs: Optional[float] = None diff --git a/freqtrade/rpc/rpc.py b/freqtrade/rpc/rpc.py index 0abac3975..3ee4bbc91 100644 --- a/freqtrade/rpc/rpc.py +++ b/freqtrade/rpc/rpc.py @@ -978,15 +978,16 @@ class RPC: except (ExchangeError): pass - # cancel stoploss on exchange ... + # cancel stoploss on exchange orders ... if (self._freqtrade.strategy.order_types.get('stoploss_on_exchange') - and trade.stoploss_order_id): - try: - self._freqtrade.exchange.cancel_stoploss_order(trade.stoploss_order_id, - trade.pair) - c_count += 1 - except (ExchangeError): - pass + and trade.has_open_sl_orders): + + for oslo in trade.open_sl_orders: + try: + self._freqtrade.exchange.cancel_stoploss_order(oslo.order_id, trade.pair) + c_count += 1 + except (ExchangeError): + pass trade.delete() self._freqtrade.wallets.update() From e8be011e2bdd6c2960fa223520b31d6d45cbbff8 Mon Sep 17 00:00:00 2001 From: Axel-CH Date: Fri, 6 Oct 2023 17:01:12 -0400 Subject: [PATCH 10/48] update manage_trade_stoploss_orders: remove unrelevant TODO --- freqtrade/freqtradebot.py | 1 - 1 file changed, 1 deletion(-) diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index 6f5ba41ae..834002495 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -1330,7 +1330,6 @@ class FreqtradeBot(LoggingMixin): # value immediately self.handle_trailing_stoploss_on_exchange(trade, last_active_sl_order) - # TODO cancel remaining_active_sl_orders active_sl_orders[:-1] return def manage_open_orders(self) -> None: From c2b32769a19fb9dd04af654570b9ca6e74d4e496 Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 28 Nov 2023 06:48:20 +0100 Subject: [PATCH 11/48] Remove further occurance in bot file --- freqtrade/freqtradebot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index 852e71a6b..69b81a67c 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -1080,7 +1080,7 @@ class FreqtradeBot(LoggingMixin): if ( not trade.has_open_orders - and not trade.stoploss_order_id + and not trade.has_open_sl_orders and not self.wallets.check_exit_amount(trade) ): logger.warning( From 41e40e6214093ab89d2cebcbd27a0f5e2fd5eea3 Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 28 Nov 2023 06:48:27 +0100 Subject: [PATCH 12/48] Update some initial tests --- tests/conftest_trades.py | 1 - tests/conftest_trades_usdt.py | 1 - 2 files changed, 2 deletions(-) diff --git a/tests/conftest_trades.py b/tests/conftest_trades.py index a2276ae16..9ac43d73d 100644 --- a/tests/conftest_trades.py +++ b/tests/conftest_trades.py @@ -266,7 +266,6 @@ def mock_trade_5(fee, is_short: bool): exchange='binance', strategy='SampleStrategy', enter_tag='TEST1', - stoploss_order_id=f'prod_stoploss_{direc(is_short)}_3455', timeframe=5, is_short=is_short, stop_loss_pct=0.10, diff --git a/tests/conftest_trades_usdt.py b/tests/conftest_trades_usdt.py index d73a53605..cf3109090 100644 --- a/tests/conftest_trades_usdt.py +++ b/tests/conftest_trades_usdt.py @@ -282,7 +282,6 @@ def mock_trade_usdt_5(fee, is_short: bool): open_rate=2.0, exchange='binance', strategy='SampleStrategy', - stoploss_order_id=f'prod_stoploss_3455_{direc(is_short)}', timeframe=5, is_short=is_short, ) From 13780d5963ce6ee74268da2076bb78a2788ca678 Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 22 Dec 2023 17:22:33 +0100 Subject: [PATCH 13/48] Remove further usage --- freqtrade/persistence/migrations.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/freqtrade/persistence/migrations.py b/freqtrade/persistence/migrations.py index bb6c04922..a5e3c4640 100644 --- a/freqtrade/persistence/migrations.py +++ b/freqtrade/persistence/migrations.py @@ -91,7 +91,6 @@ def migrate_trades_and_orders_table( is_stop_loss_trailing = get_column_def( cols, 'is_stop_loss_trailing', f'coalesce({stop_loss_pct}, 0.0) <> coalesce({initial_stop_loss_pct}, 0.0)') - stoploss_order_id = get_column_def(cols, 'stoploss_order_id', 'null') stoploss_last_update = get_column_def(cols, 'stoploss_last_update', 'null') max_rate = get_column_def(cols, 'max_rate', '0.0') min_rate = get_column_def(cols, 'min_rate', 'null') @@ -160,7 +159,7 @@ def migrate_trades_and_orders_table( open_rate_requested, close_rate, close_rate_requested, close_profit, stake_amount, amount, amount_requested, open_date, close_date, stop_loss, stop_loss_pct, initial_stop_loss, initial_stop_loss_pct, - is_stop_loss_trailing, stoploss_order_id, stoploss_last_update, + is_stop_loss_trailing, stoploss_last_update, max_rate, min_rate, exit_reason, exit_order_status, strategy, enter_tag, timeframe, open_trade_value, close_profit_abs, trading_mode, leverage, liquidation_price, is_short, @@ -180,7 +179,7 @@ def migrate_trades_and_orders_table( {initial_stop_loss} initial_stop_loss, {initial_stop_loss_pct} initial_stop_loss_pct, {is_stop_loss_trailing} is_stop_loss_trailing, - {stoploss_order_id} stoploss_order_id, {stoploss_last_update} stoploss_last_update, + {stoploss_last_update} stoploss_last_update, {max_rate} max_rate, {min_rate} min_rate, case when {exit_reason} = 'sell_signal' then 'exit_signal' when {exit_reason} = 'custom_sell' then 'custom_exit' From b33a9059abfa3d5e50957096613ae60c16f5bc12 Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 22 Dec 2023 17:35:02 +0100 Subject: [PATCH 14/48] Fix some more tests --- freqtrade/persistence/migrations.py | 24 ++++++++++++------------ tests/persistence/test_persistence.py | 2 -- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/freqtrade/persistence/migrations.py b/freqtrade/persistence/migrations.py index a5e3c4640..fc67448eb 100644 --- a/freqtrade/persistence/migrations.py +++ b/freqtrade/persistence/migrations.py @@ -280,19 +280,19 @@ def fix_old_dry_orders(engine): # - current Trade is closed # - current Order trade_id not equal to current Trade.id # - current Order not stoploss + # TODO: is this still necessary ? how can this be done now ? + # stmt = update(Order).where( + # Order.ft_is_open.is_(True), + # tuple_(Order.ft_trade_id, Order.order_id).not_in( + # select( + # Trade.id, Trade.stoploss_order_id + # ).where(Trade.stoploss_order_id.is_not(None)) + # ), + # Order.ft_order_side == 'stoploss', + # Order.order_id.like('dry%'), - stmt = update(Order).where( - Order.ft_is_open.is_(True), - tuple_(Order.ft_trade_id, Order.order_id).not_in( - select( - Trade.id, Trade.stoploss_order_id - ).where(Trade.stoploss_order_id.is_not(None)) - ), - Order.ft_order_side == 'stoploss', - Order.order_id.like('dry%'), - - ).values(ft_is_open=False) - connection.execute(stmt) + # ).values(ft_is_open=False) + # connection.execute(stmt) # Close dry-run orders for closed trades. stmt = update(Order).where( diff --git a/tests/persistence/test_persistence.py b/tests/persistence/test_persistence.py index 5829f8b71..95db7bc0f 100644 --- a/tests/persistence/test_persistence.py +++ b/tests/persistence/test_persistence.py @@ -1432,7 +1432,6 @@ def test_to_json(fee): 'stop_loss_abs': None, 'stop_loss_ratio': None, 'stop_loss_pct': None, - 'stoploss_order_id': None, 'stoploss_last_update': None, 'stoploss_last_update_timestamp': None, 'initial_stop_loss_abs': None, @@ -1500,7 +1499,6 @@ def test_to_json(fee): 'stop_loss_abs': None, 'stop_loss_pct': None, 'stop_loss_ratio': None, - 'stoploss_order_id': None, 'stoploss_last_update': None, 'stoploss_last_update_timestamp': None, 'initial_stop_loss_abs': None, From 8234879b583f41a151ba214df12a85d6740253e7 Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 22 Dec 2023 17:38:14 +0100 Subject: [PATCH 15/48] stoploss_order_id removal tests --- tests/rpc/test_rpc.py | 1 - tests/rpc/test_rpc_apiserver.py | 2 -- 2 files changed, 3 deletions(-) diff --git a/tests/rpc/test_rpc.py b/tests/rpc/test_rpc.py index 7ea9dae89..ebbc62af6 100644 --- a/tests/rpc/test_rpc.py +++ b/tests/rpc/test_rpc.py @@ -63,7 +63,6 @@ def test_rpc_trade_status(default_conf, ticker, fee, mocker) -> None: 'stop_loss_abs': 9.89e-06, 'stop_loss_pct': -10.0, 'stop_loss_ratio': -0.1, - 'stoploss_order_id': None, 'stoploss_last_update': ANY, 'stoploss_last_update_timestamp': ANY, 'initial_stop_loss_abs': 9.89e-06, diff --git a/tests/rpc/test_rpc_apiserver.py b/tests/rpc/test_rpc_apiserver.py index 17b0399d9..f89b06d39 100644 --- a/tests/rpc/test_rpc_apiserver.py +++ b/tests/rpc/test_rpc_apiserver.py @@ -1175,7 +1175,6 @@ def test_api_status(botclient, mocker, ticker, fee, markets, is_short, 'stop_loss_abs': ANY, 'stop_loss_pct': ANY, 'stop_loss_ratio': ANY, - 'stoploss_order_id': None, 'stoploss_last_update': ANY, 'stoploss_last_update_timestamp': ANY, 'initial_stop_loss_abs': 0.0, @@ -1379,7 +1378,6 @@ def test_api_force_entry(botclient, mocker, fee, endpoint): 'stop_loss_abs': None, 'stop_loss_pct': None, 'stop_loss_ratio': None, - 'stoploss_order_id': None, 'stoploss_last_update': None, 'stoploss_last_update_timestamp': None, 'initial_stop_loss_abs': None, From 15058d3ce675deac6dd3e6ac7e24bed89643c984 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 23 Dec 2023 09:16:13 +0100 Subject: [PATCH 16/48] Add type hints to manage_trade_orders, fix content ... --- freqtrade/freqtradebot.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index 816d35cdc..40cd6cecd 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -1299,7 +1299,7 @@ class FreqtradeBot(LoggingMixin): logger.warning(f"Could not create trailing stoploss order " f"for pair {trade.pair}.") - def manage_trade_stoploss_orders(self, trade, stoploss_orders): + def manage_trade_stoploss_orders(self, trade: Trade, stoploss_orders: Dict): """ Perform required actions acording to existing stoploss orders of trade :param trade: Corresponding Trade @@ -1307,7 +1307,7 @@ class FreqtradeBot(LoggingMixin): :return: None """ # If all stoploss orderd are canceled for some reason we add it again - canceled_sl_orders = [o for o in stoploss_orders if o.status in ['canceled', 'cancelled']] + canceled_sl_orders = [o for o in stoploss_orders if o['status'] in ['canceled', 'cancelled']] if ( trade.is_open and len(stoploss_orders) > 0 and From 28e2bfaf1cbe374b04a9088d07ce715801429b06 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 23 Dec 2023 09:25:35 +0100 Subject: [PATCH 17/48] Fix types of "has" calls --- freqtrade/persistence/trade_model.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/freqtrade/persistence/trade_model.py b/freqtrade/persistence/trade_model.py index 13578ff6a..a4a785c55 100644 --- a/freqtrade/persistence/trade_model.py +++ b/freqtrade/persistence/trade_model.py @@ -512,7 +512,7 @@ class LocalTrade: return [o for o in self.orders if o.ft_is_open and o.ft_order_side != 'stoploss'] @property - def has_open_orders(self) -> int: + def has_open_orders(self) -> bool: """ True if there are open orders for this trade excluding stoploss orders """ @@ -533,7 +533,7 @@ class LocalTrade: ] @property - def has_open_sl_orders(self) -> int: + def has_open_sl_orders(self) -> bool: """ True if there are open stoploss orders for this trade """ From c35b308adabcf7c73a77d23d95df77cdbc79f1a2 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 23 Dec 2023 09:25:39 +0100 Subject: [PATCH 18/48] Fix some tests --- tests/test_freqtradebot.py | 28 +++++++++++++--------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/tests/test_freqtradebot.py b/tests/test_freqtradebot.py index 06d40dfb0..61c63d064 100644 --- a/tests/test_freqtradebot.py +++ b/tests/test_freqtradebot.py @@ -1118,12 +1118,11 @@ def test_add_stoploss_on_exchange(mocker, default_conf_usdt, limit_order, is_sho freqtrade.enter_positions() trade = Trade.session.scalars(select(Trade)).first() trade.is_short = is_short - trade.stoploss_order_id = None trade.is_open = True trades = [trade] freqtrade.exit_positions(trades) - assert trade.stoploss_order_id == '13434334' + assert trade.has_open_sl_orders is True assert stoploss.call_count == 1 assert trade.is_open is True @@ -1535,7 +1534,7 @@ def test_create_stoploss_order_invalid_order( caplog.clear() rpc_mock.reset_mock() freqtrade.create_stoploss_order(trade, 200) - assert trade.stoploss_order_id is None + assert trade.has_open_sl_orders is False assert trade.exit_reason == ExitType.EMERGENCY_EXIT.value assert log_has("Unable to place a stoploss order on exchange. ", caplog) assert log_has("Exiting the trade forcefully", caplog) @@ -1589,14 +1588,13 @@ def test_create_stoploss_order_insufficient_funds( caplog.clear() freqtrade.create_stoploss_order(trade, 200) # stoploss_orderid was empty before - assert trade.stoploss_order_id is None + assert trade.has_open_sl_orders is False assert mock_insuf.call_count == 1 mock_insuf.reset_mock() - trade.stoploss_order_id = 'stoploss_orderid' freqtrade.create_stoploss_order(trade, 200) # No change to stoploss-orderid - assert trade.stoploss_order_id == 'stoploss_orderid' + assert trade.has_open_sl_orders is False assert mock_insuf.call_count == 1 @@ -5679,7 +5677,7 @@ def test_handle_insufficient_funds(mocker, default_conf_usdt, fee, is_short, cap trade = trades[1] reset_open_orders(trade) assert not trade.has_open_orders - assert trade.stoploss_order_id is None + assert trade.has_open_sl_orders is False freqtrade.handle_insufficient_funds(trade) order = trade.orders[0] @@ -5689,7 +5687,7 @@ def test_handle_insufficient_funds(mocker, default_conf_usdt, fee, is_short, cap assert mock_uts.call_count == 0 # No change to orderid - as update_trade_state is mocked assert not trade.has_open_orders - assert trade.stoploss_order_id is None + assert trade.has_open_sl_orders is False caplog.clear() mock_fo.reset_mock() @@ -5700,7 +5698,7 @@ def test_handle_insufficient_funds(mocker, default_conf_usdt, fee, is_short, cap # This part in not relevant anymore # assert not trade.has_open_orders - assert trade.stoploss_order_id is None + assert trade.has_open_sl_orders is False freqtrade.handle_insufficient_funds(trade) order = mock_order_4(is_short=is_short) @@ -5708,8 +5706,8 @@ def test_handle_insufficient_funds(mocker, default_conf_usdt, fee, is_short, cap assert mock_fo.call_count == 1 assert mock_uts.call_count == 1 # Found open buy order - assert trade.has_open_orders - assert trade.stoploss_order_id is None + assert trade.has_open_orders is True + assert trade.has_open_sl_orders is False caplog.clear() mock_fo.reset_mock() @@ -5718,7 +5716,7 @@ def test_handle_insufficient_funds(mocker, default_conf_usdt, fee, is_short, cap trade = trades[4] reset_open_orders(trade) assert not trade.has_open_orders - assert trade.stoploss_order_id is None + assert trade.has_open_sl_orders freqtrade.handle_insufficient_funds(trade) order = mock_order_5_stoploss(is_short=is_short) @@ -5727,7 +5725,7 @@ def test_handle_insufficient_funds(mocker, default_conf_usdt, fee, is_short, cap assert mock_uts.call_count == 2 # stoploss_order_id is "refound" and added to the trade assert not trade.has_open_orders - assert trade.stoploss_order_id is not None + assert trade.has_open_sl_orders is True caplog.clear() mock_fo.reset_mock() @@ -5738,7 +5736,7 @@ def test_handle_insufficient_funds(mocker, default_conf_usdt, fee, is_short, cap reset_open_orders(trade) # This part in not relevant anymore # assert not trade.has_open_orders - assert trade.stoploss_order_id is None + assert trade.has_open_sl_orders is False freqtrade.handle_insufficient_funds(trade) order = mock_order_6_sell(is_short=is_short) @@ -5747,7 +5745,7 @@ def test_handle_insufficient_funds(mocker, default_conf_usdt, fee, is_short, cap assert mock_uts.call_count == 1 # sell-orderid is "refound" and added to the trade assert trade.open_orders_ids[0] == order['id'] - assert trade.stoploss_order_id is None + assert trade.has_open_sl_orders is False caplog.clear() From 6427144934983ee5c1ba25ac7277c0d6440e236e Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 23 Dec 2023 09:27:32 +0100 Subject: [PATCH 19/48] Fix stoploss test --- tests/test_freqtradebot.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_freqtradebot.py b/tests/test_freqtradebot.py index 61c63d064..a2337fa2a 100644 --- a/tests/test_freqtradebot.py +++ b/tests/test_freqtradebot.py @@ -4130,11 +4130,11 @@ def test_may_execute_trade_exit_after_stoploss_on_exchange_hit( freqtrade.manage_open_orders() trade = Trade.session.scalars(select(Trade)).first() trades = [trade] - assert trade.stoploss_order_id is None + assert trade.has_open_sl_orders is False freqtrade.exit_positions(trades) assert trade - assert trade.stoploss_order_id == '123' + assert trade.has_open_sl_orders is True assert not trade.has_open_orders # Assuming stoploss on exchange is hit @@ -4161,7 +4161,7 @@ def test_may_execute_trade_exit_after_stoploss_on_exchange_hit( mocker.patch(f'{EXMS}.fetch_stoploss_order', stoploss_executed) freqtrade.exit_positions(trades) - assert trade.stoploss_order_id is None + assert trade.has_open_sl_orders is False assert trade.is_open is False assert trade.exit_reason == ExitType.STOPLOSS_ON_EXCHANGE.value assert rpc_mock.call_count == 4 From c6ffe82a7a72f1293b901d4685685dd0be429cdc Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 23 Dec 2023 09:29:45 +0100 Subject: [PATCH 20/48] Update more tests --- tests/test_freqtradebot.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_freqtradebot.py b/tests/test_freqtradebot.py index a2337fa2a..ca8b6a4b9 100644 --- a/tests/test_freqtradebot.py +++ b/tests/test_freqtradebot.py @@ -1345,11 +1345,11 @@ def test_handle_stoploss_on_exchange_partial( trade = Trade.session.scalars(select(Trade)).first() trade.is_short = is_short trade.is_open = True - trade.stoploss_order_id = None assert freqtrade.handle_stoploss_on_exchange(trade) is False assert stoploss.call_count == 1 - assert trade.stoploss_order_id == "101" + assert trade.has_open_sl_orders is True + assert trade.open_sl_orders[-1].order_id == "101" assert trade.amount == 30 stop_order_dict.update({'id': "102"}) # Stoploss on exchange is cancelled on exchange, but filled partially. @@ -1369,7 +1369,7 @@ def test_handle_stoploss_on_exchange_partial( # Stoploss filled partially ... assert trade.amount == 15 - assert trade.stoploss_order_id == "102" + assert trade.open_sl_orders[-1].order_id == "102" @pytest.mark.parametrize("is_short", [False, True]) From cbfebd397c1149e9aa8543b9e7995788ba866de0 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 23 Dec 2023 09:47:33 +0100 Subject: [PATCH 21/48] Use a trade for test that actually has an open stop order --- tests/persistence/test_trade_fromjson.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/persistence/test_trade_fromjson.py b/tests/persistence/test_trade_fromjson.py index bb5e77f22..302a81c54 100644 --- a/tests/persistence/test_trade_fromjson.py +++ b/tests/persistence/test_trade_fromjson.py @@ -54,7 +54,6 @@ def test_trade_fromjson(): "stop_loss_abs": 0.1981, "stop_loss_ratio": -0.216, "stop_loss_pct": -21.6, - "stoploss_order_id": null, "stoploss_last_update": "2022-10-18 09:13:42", "stoploss_last_update_timestamp": 1666077222000, "initial_stop_loss_abs": 0.1981, From ae3f62cf9be4ae875de3ca48274c2146e60217e8 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 23 Dec 2023 09:50:42 +0100 Subject: [PATCH 22/48] Fix RPC tests --- tests/rpc/test_rpc.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/rpc/test_rpc.py b/tests/rpc/test_rpc.py index 869150e3d..4d43660e5 100644 --- a/tests/rpc/test_rpc.py +++ b/tests/rpc/test_rpc.py @@ -354,7 +354,6 @@ def test_rpc_delete_trade(mocker, default_conf, fee, markets, caplog, is_short): rpc._rpc_delete('200') trades = Trade.session.scalars(select(Trade)).all() - trades[2].stoploss_order_id = '102' trades[2].orders.append( Order( ft_order_side='stoploss', From 1db4732648de9fb269def2a88402f9da3af2fcab Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 23 Dec 2023 09:57:05 +0100 Subject: [PATCH 23/48] Fix some more tests --- tests/test_freqtradebot.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/tests/test_freqtradebot.py b/tests/test_freqtradebot.py index ca8b6a4b9..3467f037e 100644 --- a/tests/test_freqtradebot.py +++ b/tests/test_freqtradebot.py @@ -1403,11 +1403,11 @@ def test_handle_stoploss_on_exchange_partial_cancel_here( trade = Trade.session.scalars(select(Trade)).first() trade.is_short = is_short trade.is_open = True - trade.stoploss_order_id = None assert freqtrade.handle_stoploss_on_exchange(trade) is False assert stoploss.call_count == 1 - assert trade.stoploss_order_id == "101" + assert trade.has_open_sl_orders is True + assert trade.open_sl_orders[-1].order_id == "101" assert trade.amount == 30 stop_order_dict.update({'id': "102"}) # Stoploss on exchange is open. @@ -1440,7 +1440,8 @@ def test_handle_stoploss_on_exchange_partial_cancel_here( # Canceled Stoploss filled partially ... assert log_has_re('Cancelling current stoploss on exchange.*', caplog) - assert trade.stoploss_order_id == "102" + assert trade.has_open_sl_orders is True + assert trade.open_sl_orders[-1].order_id == "102" assert trade.amount == 15 @@ -4027,7 +4028,17 @@ def test_execute_trade_exit_sloe_cancel_exception( PairLock.session = MagicMock() freqtrade.config['dry_run'] = False - trade.stoploss_order_id = "abcd" + trade.orders.append( + Order( + ft_order_side='stoploss', + ft_pair=trade.pair, + ft_is_open=True, + ft_amount=trade.amount, + ft_price=trade.stop_loss, + order_id='abcd', + status='open', + ) + ) freqtrade.execute_trade_exit(trade=trade, limit=1234, exit_check=ExitCheckTuple(exit_type=ExitType.STOP_LOSS)) From 33bd433c2299cd98a9c7dead01a47c317e555d4b Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 23 Dec 2023 10:31:03 +0100 Subject: [PATCH 24/48] Don't run against all orders, only consider open sl orders. --- freqtrade/freqtradebot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index 40cd6cecd..fec2ed6a4 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -1219,7 +1219,7 @@ class FreqtradeBot(LoggingMixin): logger.debug('Handling stoploss on exchange %s ...', trade) stoploss_orders = [] - for slo in trade.sl_orders: + for slo in trade.open_sl_orders: stoploss_order = None try: # First we check if there is already a stoploss on exchange From 600e311b3ee116555d62fef0d6ab1dd4cfe58e0b Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 23 Dec 2023 10:35:30 +0100 Subject: [PATCH 25/48] Fix test test_handle_stoploss_on_exchange_custom_stop --- tests/test_freqtradebot.py | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/tests/test_freqtradebot.py b/tests/test_freqtradebot.py index 3467f037e..95635edff 100644 --- a/tests/test_freqtradebot.py +++ b/tests/test_freqtradebot.py @@ -1866,6 +1866,7 @@ def test_handle_stoploss_on_exchange_custom_stop( exit_order, ]), get_fee=fee, + is_cancel_order_result_suitable=MagicMock(return_value=True), ) mocker.patch.multiple( EXMS, @@ -1896,7 +1897,6 @@ def test_handle_stoploss_on_exchange_custom_stop( trade = Trade.session.scalars(select(Trade)).first() trade.is_short = is_short trade.is_open = True - trade.stoploss_order_id = '100' trade.stoploss_last_update = dt_now() - timedelta(minutes=601) trade.orders.append( Order( @@ -1908,8 +1908,8 @@ def test_handle_stoploss_on_exchange_custom_stop( order_id='100', ) ) - - stoploss_order_hanging = MagicMock(return_value={ + Trade.commit() + slo = { 'id': '100', 'status': 'open', 'type': 'stop_loss_limit', @@ -1918,9 +1918,17 @@ def test_handle_stoploss_on_exchange_custom_stop( 'info': { 'stopPrice': '2.0805' } - }) + } + slo_canceled = deepcopy(slo) + slo_canceled.update({'status': 'canceled'}) - mocker.patch(f'{EXMS}.fetch_stoploss_order', stoploss_order_hanging) + def fetch_stoploss_order_mock(order_id, *args, **kwargs): + x = deepcopy(slo) + x['id'] = order_id + return x + + mocker.patch(f'{EXMS}.fetch_stoploss_order', MagicMock(fetch_stoploss_order_mock)) + mocker.patch(f'{EXMS}.cancel_stoploss_order', return_value=slo_canceled) assert freqtrade.handle_trade(trade) is False assert freqtrade.handle_stoploss_on_exchange(trade) is False @@ -1939,7 +1947,6 @@ def test_handle_stoploss_on_exchange_custom_stop( stoploss_order_mock = MagicMock(return_value={'id': 'so1', 'status': 'open'}) mocker.patch(f'{EXMS}.cancel_stoploss_order', cancel_order_mock) mocker.patch(f'{EXMS}.create_stoploss', stoploss_order_mock) - trade.stoploss_order_id = '100' # stoploss should not be updated as the interval is 60 seconds assert freqtrade.handle_trade(trade) is False @@ -1953,10 +1960,12 @@ def test_handle_stoploss_on_exchange_custom_stop( # setting stoploss_on_exchange_interval to 0 seconds freqtrade.strategy.order_types['stoploss_on_exchange_interval'] = 0 + cancel_order_mock.assert_not_called() + stoploss_order_mock.assert_not_called() assert freqtrade.handle_stoploss_on_exchange(trade) is False - cancel_order_mock.assert_called_once_with('100', 'ETH/USDT') + cancel_order_mock.assert_called_once_with('13434334', 'ETH/USDT') # Long uses modified ask - offset, short modified bid + offset stoploss_order_mock.assert_called_once_with( amount=pytest.approx(trade.amount), From 68f9402384aad8db16418abd5c32c0e364f5eb26 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 23 Dec 2023 10:44:21 +0100 Subject: [PATCH 26/48] Fix further test --- tests/test_freqtradebot.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/tests/test_freqtradebot.py b/tests/test_freqtradebot.py index 95635edff..a37d88fd6 100644 --- a/tests/test_freqtradebot.py +++ b/tests/test_freqtradebot.py @@ -1759,9 +1759,6 @@ def test_handle_stoploss_on_exchange_trailing_error( {'id': exit_order['id']}, ]), get_fee=fee, - ) - mocker.patch.multiple( - EXMS, create_stoploss=stoploss, stoploss_adjust=MagicMock(return_value=True), ) @@ -1783,10 +1780,8 @@ def test_handle_stoploss_on_exchange_trailing_error( trade = Trade.session.scalars(select(Trade)).first() trade.is_short = is_short trade.is_open = True - trade.stoploss_order_id = "abcd" trade.stop_loss = 0.2 trade.stoploss_last_update = (dt_now() - timedelta(minutes=601)).replace(tzinfo=None) - trade.is_short = is_short stoploss_order_hanging = { 'id': "abcd", @@ -1798,6 +1793,16 @@ def test_handle_stoploss_on_exchange_trailing_error( 'stopPrice': '0.1' } } + trade.orders.append( + Order( + ft_order_side='stoploss', + ft_pair=trade.pair, + ft_is_open=True, + ft_amount=trade.amount, + ft_price=3, + order_id='abcd', + ) + ) mocker.patch(f'{EXMS}.cancel_stoploss_order', side_effect=InvalidOrderException()) mocker.patch(f'{EXMS}.fetch_stoploss_order', @@ -1807,6 +1812,8 @@ def test_handle_stoploss_on_exchange_trailing_error( # Still try to create order assert stoploss.call_count == 1 + # TODO: Is this actually correct ? This will create a new order every time, + assert len(trade.open_sl_orders) == 2 # Fail creating stoploss order trade.stoploss_last_update = dt_now() - timedelta(minutes=601) @@ -1814,7 +1821,7 @@ def test_handle_stoploss_on_exchange_trailing_error( cancel_mock = mocker.patch(f'{EXMS}.cancel_stoploss_order') mocker.patch(f'{EXMS}.create_stoploss', side_effect=ExchangeError()) freqtrade.handle_trailing_stoploss_on_exchange(trade, stoploss_order_hanging) - assert cancel_mock.call_count == 1 + assert cancel_mock.call_count == 2 assert log_has_re(r"Could not create trailing stoploss order for pair ETH/USDT\..*", caplog) From a39b329e3baa84dbfe27658d087ccbefb7d2500e Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 23 Dec 2023 15:30:24 +0100 Subject: [PATCH 27/48] Fix line-length --- freqtrade/freqtradebot.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index fec2ed6a4..e414f9e82 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -1307,12 +1307,13 @@ class FreqtradeBot(LoggingMixin): :return: None """ # If all stoploss orderd are canceled for some reason we add it again - canceled_sl_orders = [o for o in stoploss_orders if o['status'] in ['canceled', 'cancelled']] + canceled_sl_orders = [o for o in stoploss_orders + if o['status'] in ('canceled', 'cancelled')] if ( - trade.is_open and - len(stoploss_orders) > 0 and - len(stoploss_orders) == len(canceled_sl_orders) - ): + trade.is_open and + len(stoploss_orders) > 0 and + len(stoploss_orders) == len(canceled_sl_orders) + ): if self.create_stoploss_order(trade=trade, stop_price=trade.stoploss_or_liquidation): return False else: From f0073078e9a4950f84e78cc02d03b6b124055d79 Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 2 Jan 2024 11:27:42 +0100 Subject: [PATCH 28/48] Fix stop order test --- tests/test_freqtradebot.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/test_freqtradebot.py b/tests/test_freqtradebot.py index a37d88fd6..bafb13a9f 100644 --- a/tests/test_freqtradebot.py +++ b/tests/test_freqtradebot.py @@ -1468,7 +1468,7 @@ def test_handle_sle_cancel_cant_recreate(mocker, default_conf_usdt, fee, caplog, ) mocker.patch.multiple( EXMS, - fetch_stoploss_order=MagicMock(return_value={'status': 'canceled', 'id': 100}), + fetch_stoploss_order=MagicMock(return_value={'status': 'canceled', 'id': '100'}), create_stoploss=MagicMock(side_effect=ExchangeError()), ) freqtrade = FreqtradeBot(default_conf_usdt) @@ -1478,7 +1478,6 @@ def test_handle_sle_cancel_cant_recreate(mocker, default_conf_usdt, fee, caplog, trade = Trade.session.scalars(select(Trade)).first() assert trade.is_short == is_short trade.is_open = True - trade.stoploss_order_id = "100" trade.orders.append( Order( ft_order_side='stoploss', @@ -1493,8 +1492,8 @@ def test_handle_sle_cancel_cant_recreate(mocker, default_conf_usdt, fee, caplog, assert trade assert freqtrade.handle_stoploss_on_exchange(trade) is False - assert log_has_re(r'Stoploss order was cancelled, but unable to recreate one.*', caplog) - assert trade.stoploss_order_id is None + assert log_has_re(r'All Stoploss orders are cancelled, but unable to recreate one\.', caplog) + assert trade.has_open_sl_orders is False assert trade.is_open is True From 501e256c587456d7c19eb822288a08a1969617d7 Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 2 Jan 2024 16:25:42 +0100 Subject: [PATCH 29/48] Fix further stoploss test --- tests/test_freqtradebot.py | 30 +++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/tests/test_freqtradebot.py b/tests/test_freqtradebot.py index 703c06118..763ca8b48 100644 --- a/tests/test_freqtradebot.py +++ b/tests/test_freqtradebot.py @@ -1656,7 +1656,7 @@ def test_handle_stoploss_on_exchange_trailing( trade = Trade.session.scalars(select(Trade)).first() trade.is_short = is_short trade.is_open = True - trade.stoploss_order_id = '100' + assert trade.has_open_sl_orders is False trade.stoploss_last_update = dt_now() - timedelta(minutes=20) trade.orders.append( Order( @@ -1669,24 +1669,31 @@ def test_handle_stoploss_on_exchange_trailing( ) ) - stoploss_order_hanging = MagicMock(return_value={ + stoploss_order_hanging = { 'id': '100', 'status': 'open', 'type': 'stop_loss_limit', 'price': hang_price, 'average': 2, + 'fee': {}, + 'amount': 0, 'info': { 'stopPrice': stop_price[0] } - }) + } + stoploss_order_cancel = deepcopy(stoploss_order_hanging) + stoploss_order_cancel['status'] = 'canceled' - mocker.patch(f'{EXMS}.fetch_stoploss_order', stoploss_order_hanging) + mocker.patch(f'{EXMS}.fetch_stoploss_order', return_value=stoploss_order_hanging) + mocker.patch(f'{EXMS}.cancel_stoploss_order', return_value=stoploss_order_cancel) # stoploss initially at 5% assert freqtrade.handle_trade(trade) is False assert freqtrade.handle_stoploss_on_exchange(trade) is False - assert trade.stoploss_order_id == '13434334' + assert len(trade.open_sl_orders) == 1 + + assert trade.open_sl_orders[-1].order_id == '13434334' # price jumped 2x mocker.patch( @@ -1698,14 +1705,17 @@ def test_handle_stoploss_on_exchange_trailing( }) ) - cancel_order_mock = MagicMock() + cancel_order_mock = MagicMock(return_value={ + 'id': '13434334', 'status': 'canceled', 'fee': {}, 'amount': trade.amount}) stoploss_order_mock = MagicMock(return_value={'id': 'so1', 'status': 'open'}) + mocker.patch(f'{EXMS}.fetch_stoploss_order') mocker.patch(f'{EXMS}.cancel_stoploss_order', cancel_order_mock) mocker.patch(f'{EXMS}.create_stoploss', stoploss_order_mock) # stoploss should not be updated as the interval is 60 seconds assert freqtrade.handle_trade(trade) is False assert freqtrade.handle_stoploss_on_exchange(trade) is False + assert len(trade.open_sl_orders) == 1 cancel_order_mock.assert_not_called() stoploss_order_mock.assert_not_called() @@ -1736,8 +1746,14 @@ def test_handle_stoploss_on_exchange_trailing( 'last': bid[1], }) ) + mocker.patch(f'{EXMS}.cancel_stoploss_order_with_result', + return_value={'id': 'so1', 'status': 'canceled'}) + assert len(trade.open_sl_orders) == 1 + assert trade.open_sl_orders[-1].order_id == 'so1' + assert freqtrade.handle_trade(trade) is True - assert trade.stoploss_order_id is None + assert trade.is_open is False + assert trade.has_open_sl_orders is False @pytest.mark.parametrize("is_short", [False, True]) From 206809d2e7a8e9876bec38c6aeefd9bf02d3d3c8 Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 2 Jan 2024 17:05:21 +0100 Subject: [PATCH 30/48] Update emergency sell test --- tests/test_freqtradebot.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/test_freqtradebot.py b/tests/test_freqtradebot.py index d07c1db23..33b0828a9 100644 --- a/tests/test_freqtradebot.py +++ b/tests/test_freqtradebot.py @@ -1279,7 +1279,7 @@ def test_handle_stoploss_on_exchange_emergency(mocker, default_conf_usdt, fee, i trade = Trade.session.scalars(select(Trade)).first() assert trade.is_short == is_short assert trade.is_open - assert trade.stoploss_order_id is None + assert trade.has_open_sl_orders is False # emergency exit triggered # Trailing stop should not act anymore @@ -1294,7 +1294,6 @@ def test_handle_stoploss_on_exchange_emergency(mocker, default_conf_usdt, fee, i 'remaining': enter_order['amount'], 'info': {'stopPrice': 22}, }]) - trade.stoploss_order_id = "107" trade.stoploss_last_update = dt_now() - timedelta(hours=1) trade.stop_loss = 24 trade.exit_reason = None @@ -1311,14 +1310,14 @@ def test_handle_stoploss_on_exchange_emergency(mocker, default_conf_usdt, fee, i ) freqtrade.config['trailing_stop'] = True stoploss = MagicMock(side_effect=InvalidOrderException()) - + assert trade.has_open_sl_orders is True Trade.commit() mocker.patch(f'{EXMS}.cancel_stoploss_order_with_result', side_effect=InvalidOrderException()) mocker.patch(f'{EXMS}.fetch_stoploss_order', stoploss_order_cancelled) mocker.patch(f'{EXMS}.create_stoploss', stoploss) assert freqtrade.handle_stoploss_on_exchange(trade) is False - assert trade.stoploss_order_id is None + assert trade.has_open_sl_orders is False assert trade.is_open is False assert trade.exit_reason == str(ExitType.EMERGENCY_EXIT) From e199083287f9311d11400699b549620e613af6f2 Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 2 Jan 2024 17:10:33 +0100 Subject: [PATCH 31/48] Fix test ... --- tests/test_freqtradebot.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/tests/test_freqtradebot.py b/tests/test_freqtradebot.py index 33b0828a9..6738fc5ae 100644 --- a/tests/test_freqtradebot.py +++ b/tests/test_freqtradebot.py @@ -1161,11 +1161,11 @@ def test_handle_stoploss_on_exchange(mocker, default_conf_usdt, fee, caplog, is_ trade = Trade.session.scalars(select(Trade)).first() assert trade.is_short == is_short assert trade.is_open - assert trade.stoploss_order_id is None + assert trade.has_open_sl_orders is False assert freqtrade.handle_stoploss_on_exchange(trade) is False assert stoploss.call_count == 1 - assert trade.stoploss_order_id == "13434334" + assert trade.open_sl_orders[-1].order_id == "13434334" # Second case: when stoploss is set but it is not yet hit # should do nothing and return false @@ -1176,7 +1176,8 @@ def test_handle_stoploss_on_exchange(mocker, default_conf_usdt, fee, caplog, is_ assert freqtrade.handle_stoploss_on_exchange(trade) is False hanging_stoploss_order.assert_called_once_with('13434334', trade.pair) - assert trade.stoploss_order_id == "13434334" + assert len(trade.open_sl_orders) == 1 + assert trade.open_sl_orders[-1].order_id == "13434334" # Third case: when stoploss was set but it was canceled for some reason # should set a stoploss immediately and return False @@ -1192,7 +1193,8 @@ def test_handle_stoploss_on_exchange(mocker, default_conf_usdt, fee, caplog, is_ assert freqtrade.handle_stoploss_on_exchange(trade) is False assert stoploss.call_count == 1 - assert trade.stoploss_order_id == "103_1" + assert len(trade.open_sl_orders) == 1 + assert trade.open_sl_orders[-1].order_id == "103_1" assert trade.amount == amount_before # Fourth case: when stoploss is set and it is hit @@ -1218,7 +1220,7 @@ def test_handle_stoploss_on_exchange(mocker, default_conf_usdt, fee, caplog, is_ mocker.patch(f'{EXMS}.fetch_stoploss_order', stoploss_order_hit) assert freqtrade.handle_stoploss_on_exchange(trade) is True assert log_has_re(r'STOP_LOSS_LIMIT is hit for Trade\(id=1, .*\)\.', caplog) - assert trade.stoploss_order_id is None + assert len(trade.open_sl_orders) == 0 assert trade.is_open is False caplog.clear() @@ -1226,26 +1228,27 @@ def test_handle_stoploss_on_exchange(mocker, default_conf_usdt, fee, caplog, is_ trade.is_open = True freqtrade.handle_stoploss_on_exchange(trade) assert log_has('Unable to place a stoploss order on exchange.', caplog) - assert trade.stoploss_order_id is None + assert len(trade.open_sl_orders) == 0 # Fifth case: fetch_order returns InvalidOrder # It should try to add stoploss order stop_order_dict.update({'id': "105"}) - trade.stoploss_order_id = "105" stoploss.reset_mock() mocker.patch(f'{EXMS}.fetch_stoploss_order', side_effect=InvalidOrderException()) mocker.patch(f'{EXMS}.create_stoploss', stoploss) freqtrade.handle_stoploss_on_exchange(trade) + assert len(trade.open_sl_orders) == 1 assert stoploss.call_count == 1 # Sixth case: Closed Trade # Should not create new order - trade.stoploss_order_id = None trade.is_open = False + trade.open_sl_orders[-1].ft_is_open = False stoploss.reset_mock() mocker.patch(f'{EXMS}.fetch_order') mocker.patch(f'{EXMS}.create_stoploss', stoploss) assert freqtrade.handle_stoploss_on_exchange(trade) is False + assert trade.has_open_sl_orders is False assert stoploss.call_count == 0 From 14660f54f8fca7767155746ab3de05bca99ca9fd Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 2 Jan 2024 19:22:16 +0100 Subject: [PATCH 32/48] Remove duplicate call to update_trade_state --- freqtrade/freqtradebot.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index 0b30d89a4..3b01f7756 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -1229,8 +1229,6 @@ class FreqtradeBot(LoggingMixin): # We check if stoploss order is fulfilled if stoploss_order and stoploss_order['status'] in ('closed', 'triggered'): trade.exit_reason = ExitType.STOPLOSS_ON_EXCHANGE.value - self.update_trade_state(trade, slo.order_id, stoploss_order, - stoploss_order=True) self._notify_exit(trade, "stoploss", True) self.handle_protections(trade.pair, trade.trade_direction) return True From dc9c4da95e8f38fb8a249cea6be068b6bf68eb2c Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 2 Jan 2024 19:22:46 +0100 Subject: [PATCH 33/48] Improve integration test stability --- tests/test_integration.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test_integration.py b/tests/test_integration.py index 2e7f38fc8..ffb955f11 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -49,7 +49,7 @@ def test_may_execute_exit_stoploss_on_exchange_multi(default_conf, ticker, fee, stoploss_order_closed['filled'] = stoploss_order_closed['amount'] # Sell first trade based on stoploss, keep 2nd and 3rd trade open - stop_orders = [stoploss_order_closed, stoploss_order_open, stoploss_order_open] + stop_orders = [stoploss_order_closed, stoploss_order_open.copy(), stoploss_order_open.copy()] stoploss_order_mock = MagicMock( side_effect=stop_orders) # Sell 3rd trade (not called for the first trade) @@ -100,9 +100,10 @@ def test_may_execute_exit_stoploss_on_exchange_multi(default_conf, ticker, fee, stop_order = stop_orders[idx] stop_order['id'] = f"stop{idx}" oobj = Order.parse_from_ccxt_object(stop_order, trade.pair, 'stoploss') + oobj.ft_is_open = True trade.orders.append(oobj) - trade.stoploss_order_id = f"stop{idx}" + assert len(trade.open_sl_orders) == 1 n = freqtrade.exit_positions(trades) assert n == 2 @@ -113,6 +114,7 @@ def test_may_execute_exit_stoploss_on_exchange_multi(default_conf, ticker, fee, # Only order for 3rd trade needs to be cancelled assert cancel_order_mock.call_count == 1 + assert stoploss_order_mock.call_count == 3 # Wallets must be updated between stoploss cancellation and selling, and will be updated again # during update_trade_state assert wallets_mock.call_count == 4 From 9f3c6f2dcc2fc4982823a6b581b52c3bc1ae9c38 Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 19 Jan 2024 06:48:32 +0100 Subject: [PATCH 34/48] Fix some tests and comments --- tests/test_freqtradebot.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/tests/test_freqtradebot.py b/tests/test_freqtradebot.py index 6738fc5ae..57cd1a2f5 100644 --- a/tests/test_freqtradebot.py +++ b/tests/test_freqtradebot.py @@ -1198,8 +1198,7 @@ def test_handle_stoploss_on_exchange(mocker, default_conf_usdt, fee, caplog, is_ assert trade.amount == amount_before # Fourth case: when stoploss is set and it is hit - # should unset stoploss_order_id and return true - # as a trade actually happened + # should return true as a trade actually happened caplog.clear() stop_order_dict.update({'id': "103_1"}) @@ -1871,7 +1870,6 @@ def test_stoploss_on_exchange_price_rounding( price_to_precision=price_mock, ) freqtrade = get_patched_freqtradebot(mocker, default_conf_usdt) - open_trade_usdt.stoploss_order_id = '13434334' open_trade_usdt.stop_loss = 222.55 freqtrade.handle_trailing_stoploss_on_exchange(open_trade_usdt, {}) @@ -2078,7 +2076,7 @@ def test_tsl_on_exchange_compatible_with_edge(mocker, edge_conf, fee, limit_orde freqtrade.enter_positions() trade = Trade.session.scalars(select(Trade)).first() trade.is_open = True - trade.stoploss_order_id = '100' + trade.stoploss_last_update = dt_now() trade.orders.append( Order( @@ -4194,8 +4192,7 @@ def test_may_execute_trade_exit_after_stoploss_on_exchange_hit( assert not trade.has_open_orders # Assuming stoploss on exchange is hit - # stoploss_order_id should become None - # and trade should be sold at the price of stoploss + # trade should be sold at the price of stoploss, with exit_reaeon STOPLOSS_ON_EXCHANGE stoploss_executed = MagicMock(return_value={ "id": "123", "timestamp": 1542707426845, @@ -5721,7 +5718,6 @@ def test_handle_insufficient_funds(mocker, default_conf_usdt, fee, is_short, cap def reset_open_orders(trade): - trade.stoploss_order_id = None trade.is_short = is_short create_mock_trades(fee, is_short=is_short) @@ -5779,7 +5775,7 @@ def test_handle_insufficient_funds(mocker, default_conf_usdt, fee, is_short, cap assert log_has_re(r"Trying to refind Order\(.*", caplog) assert mock_fo.call_count == 1 assert mock_uts.call_count == 2 - # stoploss_order_id is "refound" and added to the trade + # stoploss order is "refound" and added to the trade assert not trade.has_open_orders assert trade.has_open_sl_orders is True From 59b34865740cdeb170971034c58cf1f8c7914022 Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 19 Jan 2024 06:49:49 +0100 Subject: [PATCH 35/48] Update migrations --- freqtrade/persistence/migrations.py | 21 +++++++++------------ tests/persistence/test_migrations.py | 6 +++--- 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/freqtrade/persistence/migrations.py b/freqtrade/persistence/migrations.py index fc67448eb..2970da918 100644 --- a/freqtrade/persistence/migrations.py +++ b/freqtrade/persistence/migrations.py @@ -276,23 +276,20 @@ def fix_old_dry_orders(engine): with engine.begin() as connection: # Update current dry-run Orders where + # - stoploss order is Open (will be replaced eventually) + # 2nd query: # - current Order is open # - current Trade is closed # - current Order trade_id not equal to current Trade.id # - current Order not stoploss - # TODO: is this still necessary ? how can this be done now ? - # stmt = update(Order).where( - # Order.ft_is_open.is_(True), - # tuple_(Order.ft_trade_id, Order.order_id).not_in( - # select( - # Trade.id, Trade.stoploss_order_id - # ).where(Trade.stoploss_order_id.is_not(None)) - # ), - # Order.ft_order_side == 'stoploss', - # Order.order_id.like('dry%'), - # ).values(ft_is_open=False) - # connection.execute(stmt) + stmt = update(Order).where( + Order.ft_is_open.is_(True), + Order.ft_order_side == 'stoploss', + Order.order_id.like('dry%'), + + ).values(ft_is_open=False) + connection.execute(stmt) # Close dry-run orders for closed trades. stmt = update(Order).where( diff --git a/tests/persistence/test_migrations.py b/tests/persistence/test_migrations.py index f2bb0b2f1..6ef098cb3 100644 --- a/tests/persistence/test_migrations.py +++ b/tests/persistence/test_migrations.py @@ -74,7 +74,7 @@ def test_init_dryrun_db(default_conf, tmpdir): assert Path(filename).is_file() -def test_migrate_new(mocker, default_conf, fee, caplog): +def test_migrate(mocker, default_conf, fee, caplog): """ Test Database migration (starting with new pairformat) """ @@ -277,7 +277,6 @@ def test_migrate_new(mocker, default_conf, fee, caplog): assert trade.exit_reason is None assert trade.strategy is None assert trade.timeframe == '5m' - assert trade.stoploss_order_id == 'dry_stop_order_id222' assert trade.stoploss_last_update is None assert log_has("trying trades_bak1", caplog) assert log_has("trying trades_bak2", caplog) @@ -294,9 +293,10 @@ def test_migrate_new(mocker, default_conf, fee, caplog): assert orders[0].order_id == 'dry_buy_order' assert orders[0].ft_order_side == 'buy' + # All dry-run stoploss orders will be closed assert orders[-1].order_id == 'dry_stop_order_id222' assert orders[-1].ft_order_side == 'stoploss' - assert orders[-1].ft_is_open is True + assert orders[-1].ft_is_open is False assert orders[1].order_id == 'dry_buy_order22' assert orders[1].ft_order_side == 'buy' From b9a43b8e248a53dd28a26b9eeedcfced61f8bf92 Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 19 Jan 2024 07:12:49 +0100 Subject: [PATCH 36/48] Don't store 'stoploss_last_updated' explicitly it can easily be derived from the very last stoploss order. --- freqtrade/freqtradebot.py | 1 - freqtrade/persistence/migrations.py | 4 +--- freqtrade/persistence/trade_model.py | 20 ++++++-------------- 3 files changed, 7 insertions(+), 18 deletions(-) diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index 3b01f7756..26631eb30 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -1184,7 +1184,6 @@ class FreqtradeBot(LoggingMixin): order_obj = Order.parse_from_ccxt_object(stoploss_order, trade.pair, 'stoploss', trade.amount, stop_price) trade.orders.append(order_obj) - trade.stoploss_last_update = datetime.now(timezone.utc) return True except InsufficientFundsError as e: logger.warning(f"Unable to place stoploss order {e}.") diff --git a/freqtrade/persistence/migrations.py b/freqtrade/persistence/migrations.py index 2970da918..eb55cf455 100644 --- a/freqtrade/persistence/migrations.py +++ b/freqtrade/persistence/migrations.py @@ -91,7 +91,6 @@ def migrate_trades_and_orders_table( is_stop_loss_trailing = get_column_def( cols, 'is_stop_loss_trailing', f'coalesce({stop_loss_pct}, 0.0) <> coalesce({initial_stop_loss_pct}, 0.0)') - stoploss_last_update = get_column_def(cols, 'stoploss_last_update', 'null') max_rate = get_column_def(cols, 'max_rate', '0.0') min_rate = get_column_def(cols, 'min_rate', 'null') exit_reason = get_column_def(cols, 'sell_reason', get_column_def(cols, 'exit_reason', 'null')) @@ -159,7 +158,7 @@ def migrate_trades_and_orders_table( open_rate_requested, close_rate, close_rate_requested, close_profit, stake_amount, amount, amount_requested, open_date, close_date, stop_loss, stop_loss_pct, initial_stop_loss, initial_stop_loss_pct, - is_stop_loss_trailing, stoploss_last_update, + is_stop_loss_trailing, max_rate, min_rate, exit_reason, exit_order_status, strategy, enter_tag, timeframe, open_trade_value, close_profit_abs, trading_mode, leverage, liquidation_price, is_short, @@ -179,7 +178,6 @@ def migrate_trades_and_orders_table( {initial_stop_loss} initial_stop_loss, {initial_stop_loss_pct} initial_stop_loss_pct, {is_stop_loss_trailing} is_stop_loss_trailing, - {stoploss_last_update} stoploss_last_update, {max_rate} max_rate, {min_rate} min_rate, case when {exit_reason} = 'sell_signal' then 'exit_signal' when {exit_reason} = 'custom_sell' then 'custom_exit' diff --git a/freqtrade/persistence/trade_model.py b/freqtrade/persistence/trade_model.py index 7d88294b0..9db13dabc 100644 --- a/freqtrade/persistence/trade_model.py +++ b/freqtrade/persistence/trade_model.py @@ -364,8 +364,6 @@ class LocalTrade: # percentage value of the initial stop loss initial_stop_loss_pct: Optional[float] = None is_stop_loss_trailing: bool = False - # last update time of the stoploss order on exchange - stoploss_last_update: Optional[datetime] = None # absolute value of the highest reached price max_rate: Optional[float] = None # Lowest price reached @@ -455,8 +453,8 @@ class LocalTrade: @property def stoploss_last_update_utc(self): - if self.stoploss_last_update: - return self.stoploss_last_update.replace(tzinfo=timezone.utc) + if self.has_open_sl_orders: + return max(o.order_date_utc for o in self.open_sl_orders) return None @property @@ -638,10 +636,10 @@ class LocalTrade: 'stop_loss_abs': self.stop_loss, 'stop_loss_ratio': self.stop_loss_pct if self.stop_loss_pct else None, 'stop_loss_pct': (self.stop_loss_pct * 100) if self.stop_loss_pct else None, - 'stoploss_last_update': (self.stoploss_last_update.strftime(DATETIME_PRINT_FORMAT) - if self.stoploss_last_update else None), - 'stoploss_last_update_timestamp': int(self.stoploss_last_update.replace( - tzinfo=timezone.utc).timestamp() * 1000) if self.stoploss_last_update else None, + 'stoploss_last_update': (self.stoploss_last_update_utc.strftime(DATETIME_PRINT_FORMAT) + if self.stoploss_last_update_utc else None), + 'stoploss_last_update_timestamp': int(self.stoploss_last_update_utc.timestamp() * 1000 + ) if self.stoploss_last_update_utc else None, 'initial_stop_loss_abs': self.initial_stop_loss, 'initial_stop_loss_ratio': (self.initial_stop_loss_pct if self.initial_stop_loss_pct else None), @@ -1378,10 +1376,6 @@ class LocalTrade: exit_order_status=data["exit_order_status"], stop_loss=data["stop_loss_abs"], stop_loss_pct=data["stop_loss_ratio"], - stoploss_last_update=( - datetime.fromtimestamp(data["stoploss_last_update_timestamp"] // 1000, - tz=timezone.utc) - if data["stoploss_last_update_timestamp"] else None), initial_stop_loss=data["initial_stop_loss_abs"], initial_stop_loss_pct=data["initial_stop_loss_ratio"], min_rate=data["min_rate"], @@ -1487,8 +1481,6 @@ class Trade(ModelBase, LocalTrade): Float(), nullable=True) # type: ignore is_stop_loss_trailing: Mapped[bool] = mapped_column( nullable=False, default=False) # type: ignore - # last update time of the stoploss order on exchange - stoploss_last_update: Mapped[Optional[datetime]] = mapped_column(nullable=True) # type: ignore # absolute value of the highest reached price max_rate: Mapped[Optional[float]] = mapped_column( Float(), nullable=True, default=0.0) # type: ignore From acbea4e26ffe9e6e7ed927ca243a9537a65b8dfe Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 19 Jan 2024 18:15:21 +0100 Subject: [PATCH 37/48] Fix some tests after update_stoploss_date removal --- tests/test_freqtradebot.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/test_freqtradebot.py b/tests/test_freqtradebot.py index 57cd1a2f5..2c14fdca1 100644 --- a/tests/test_freqtradebot.py +++ b/tests/test_freqtradebot.py @@ -1675,6 +1675,7 @@ def test_handle_stoploss_on_exchange_trailing( ft_amount=trade.amount, ft_price=trade.stop_loss, order_id='100', + order_date=dt_now() - timedelta(minutes=20), ) ) @@ -1767,8 +1768,9 @@ def test_handle_stoploss_on_exchange_trailing( @pytest.mark.parametrize("is_short", [False, True]) def test_handle_stoploss_on_exchange_trailing_error( - mocker, default_conf_usdt, fee, caplog, limit_order, is_short + mocker, default_conf_usdt, fee, caplog, limit_order, is_short, time_machine ) -> None: + time_machine.move_to(dt_now() - timedelta(minutes=601)) enter_order = limit_order[entry_side(is_short)] exit_order = limit_order[exit_side(is_short)] # When trailing stoploss is set @@ -1809,7 +1811,6 @@ def test_handle_stoploss_on_exchange_trailing_error( trade.is_short = is_short trade.is_open = True trade.stop_loss = 0.2 - trade.stoploss_last_update = (dt_now() - timedelta(minutes=601)).replace(tzinfo=None) stoploss_order_hanging = { 'id': "abcd", @@ -1829,12 +1830,14 @@ def test_handle_stoploss_on_exchange_trailing_error( ft_amount=trade.amount, ft_price=3, order_id='abcd', + order_date=dt_now(), ) ) mocker.patch(f'{EXMS}.cancel_stoploss_order', side_effect=InvalidOrderException()) mocker.patch(f'{EXMS}.fetch_stoploss_order', return_value=stoploss_order_hanging) + time_machine.shift(timedelta(minutes=50)) freqtrade.handle_trailing_stoploss_on_exchange(trade, stoploss_order_hanging) assert log_has_re(r"Could not cancel stoploss order abcd for pair ETH/USDT.*", caplog) @@ -1844,10 +1847,10 @@ def test_handle_stoploss_on_exchange_trailing_error( assert len(trade.open_sl_orders) == 2 # Fail creating stoploss order - trade.stoploss_last_update = dt_now() - timedelta(minutes=601) caplog.clear() cancel_mock = mocker.patch(f'{EXMS}.cancel_stoploss_order') mocker.patch(f'{EXMS}.create_stoploss', side_effect=ExchangeError()) + time_machine.shift(timedelta(minutes=50)) freqtrade.handle_trailing_stoploss_on_exchange(trade, stoploss_order_hanging) assert cancel_mock.call_count == 2 assert log_has_re(r"Could not create trailing stoploss order for pair ETH/USDT\..*", caplog) From 88ba82d4fd4ee74bf86bdc599d7dceb5b1c58b02 Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 19 Jan 2024 18:17:31 +0100 Subject: [PATCH 38/48] Fix more tests --- tests/test_freqtradebot.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/test_freqtradebot.py b/tests/test_freqtradebot.py index 2c14fdca1..2d962ae1b 100644 --- a/tests/test_freqtradebot.py +++ b/tests/test_freqtradebot.py @@ -1383,8 +1383,9 @@ def test_handle_stoploss_on_exchange_partial( @pytest.mark.parametrize("is_short", [False, True]) def test_handle_stoploss_on_exchange_partial_cancel_here( - mocker, default_conf_usdt, fee, is_short, limit_order, caplog) -> None: + mocker, default_conf_usdt, fee, is_short, limit_order, caplog, time_machine) -> None: stop_order_dict = {'id': "101", "status": "open"} + time_machine.move_to(dt_now()) default_conf_usdt['trailing_stop'] = True stoploss = MagicMock(return_value=stop_order_dict) enter_order = limit_order[entry_side(is_short)] @@ -1443,7 +1444,7 @@ def test_handle_stoploss_on_exchange_partial_cancel_here( }) mocker.patch(f'{EXMS}.fetch_stoploss_order', stoploss_order_hit) mocker.patch(f'{EXMS}.cancel_stoploss_order_with_result', stoploss_order_cancel) - trade.stoploss_last_update = dt_now() - timedelta(minutes=10) + time_machine.shift(timedelta(minutes=15)) assert freqtrade.handle_stoploss_on_exchange(trade) is False # Canceled Stoploss filled partially ... @@ -1934,7 +1935,6 @@ def test_handle_stoploss_on_exchange_custom_stop( trade = Trade.session.scalars(select(Trade)).first() trade.is_short = is_short trade.is_open = True - trade.stoploss_last_update = dt_now() - timedelta(minutes=601) trade.orders.append( Order( ft_order_side='stoploss', @@ -1942,6 +1942,7 @@ def test_handle_stoploss_on_exchange_custom_stop( ft_is_open=True, ft_amount=trade.amount, ft_price=trade.stop_loss, + order_date=dt_now() - timedelta(minutes=601), order_id='100', ) ) From 6eaf42fe33fb1106c0241ca86cd6a1d3092cda4f Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 19 Jan 2024 18:17:53 +0100 Subject: [PATCH 39/48] Default order_date to dt_now if it's not set via ccxt and wasn't previously set. --- freqtrade/persistence/trade_model.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/freqtrade/persistence/trade_model.py b/freqtrade/persistence/trade_model.py index 9db13dabc..1484c006f 100644 --- a/freqtrade/persistence/trade_model.py +++ b/freqtrade/persistence/trade_model.py @@ -170,6 +170,8 @@ class Order(ModelBase): order_date = safe_value_fallback(order, 'timestamp') if order_date: self.order_date = datetime.fromtimestamp(order_date / 1000, tz=timezone.utc) + elif not self.order_date: + self.order_date = dt_now() self.ft_is_open = True if self.status in NON_OPEN_EXCHANGE_STATES: From 58058f0197ee331832ca1c1889eae2aba718b2ab Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 19 Jan 2024 18:20:23 +0100 Subject: [PATCH 40/48] Fix migration test --- tests/persistence/test_migrations.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/persistence/test_migrations.py b/tests/persistence/test_migrations.py index 6ef098cb3..a6a107a5e 100644 --- a/tests/persistence/test_migrations.py +++ b/tests/persistence/test_migrations.py @@ -277,7 +277,6 @@ def test_migrate(mocker, default_conf, fee, caplog): assert trade.exit_reason is None assert trade.strategy is None assert trade.timeframe == '5m' - assert trade.stoploss_last_update is None assert log_has("trying trades_bak1", caplog) assert log_has("trying trades_bak2", caplog) assert log_has("Running database migration for trades - backup: trades_bak2, orders_bak0", From 3ab226a0965fdaf7428a75e5d6b5245bc812272c Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 19 Jan 2024 18:24:03 +0100 Subject: [PATCH 41/48] Remove unused import --- freqtrade/persistence/migrations.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/freqtrade/persistence/migrations.py b/freqtrade/persistence/migrations.py index eb55cf455..cf2e06f71 100644 --- a/freqtrade/persistence/migrations.py +++ b/freqtrade/persistence/migrations.py @@ -1,7 +1,7 @@ import logging from typing import List, Optional -from sqlalchemy import inspect, select, text, tuple_, update +from sqlalchemy import inspect, select, text, update from freqtrade.exceptions import OperationalException from freqtrade.persistence.trade_model import Order, Trade From e76888882dadae9286ed1dcc64830b487c9fd5ca Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 19 Jan 2024 18:59:52 +0100 Subject: [PATCH 42/48] Fix typehint --- freqtrade/freqtradebot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index 26631eb30..73fa9fa68 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -1289,7 +1289,7 @@ class FreqtradeBot(LoggingMixin): logger.warning(f"Could not create trailing stoploss order " f"for pair {trade.pair}.") - def manage_trade_stoploss_orders(self, trade: Trade, stoploss_orders: Dict): + def manage_trade_stoploss_orders(self, trade: Trade, stoploss_orders: List[Dict]): """ Perform required actions acording to existing stoploss orders of trade :param trade: Corresponding Trade From 67c3bad97790de375e696254c842cfe6f4e4ceae Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 2 Feb 2024 06:43:41 +0100 Subject: [PATCH 43/48] Fix misspelled comment --- tests/test_freqtradebot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_freqtradebot.py b/tests/test_freqtradebot.py index b3df537f8..bd0b131af 100644 --- a/tests/test_freqtradebot.py +++ b/tests/test_freqtradebot.py @@ -4209,7 +4209,7 @@ def test_may_execute_trade_exit_after_stoploss_on_exchange_hit( assert not trade.has_open_orders # Assuming stoploss on exchange is hit - # trade should be sold at the price of stoploss, with exit_reaeon STOPLOSS_ON_EXCHANGE + # trade should be sold at the price of stoploss, with exit_reason STOPLOSS_ON_EXCHANGE stoploss_executed = MagicMock(return_value={ "id": "123", "timestamp": 1542707426845, From ad121c19b02d65134df952291ffe9da563d03143 Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 2 Feb 2024 06:57:12 +0100 Subject: [PATCH 44/48] Allow <1m timeframes for utility modes --- freqtrade/exchange/exchange.py | 8 ++++++-- tests/exchange/test_exchange.py | 10 ++++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/freqtrade/exchange/exchange.py b/freqtrade/exchange/exchange.py index 4b2d52a68..85a77fe5e 100644 --- a/freqtrade/exchange/exchange.py +++ b/freqtrade/exchange/exchange.py @@ -23,7 +23,7 @@ from freqtrade.constants import (DEFAULT_AMOUNT_RESERVE_PERCENT, NON_OPEN_EXCHAN BuySell, Config, EntryExit, ExchangeConfig, ListPairsWithTimeframes, MakerTaker, OBLiteral, PairWithTimeframe) from freqtrade.data.converter import clean_ohlcv_dataframe, ohlcv_to_dataframe, trades_dict_to_list -from freqtrade.enums import OPTIMIZE_MODES, CandleType, MarginMode, PriceType, TradingMode +from freqtrade.enums import OPTIMIZE_MODES, CandleType, MarginMode, PriceType, RunMode, TradingMode from freqtrade.exceptions import (DDosProtection, ExchangeError, InsufficientFundsError, InvalidOrderException, OperationalException, PricingError, RetryableOrderError, TemporaryError) @@ -595,7 +595,11 @@ class Exchange: raise OperationalException( f"Invalid timeframe '{timeframe}'. This exchange supports: {self.timeframes}") - if timeframe and timeframe_to_minutes(timeframe) < 1: + if ( + timeframe + and self._config['runmode'] != RunMode.UTIL_EXCHANGE + and timeframe_to_minutes(timeframe) < 1 + ): raise OperationalException("Timeframes < 1m are currently not supported by Freqtrade.") def validate_ordertypes(self, order_types: Dict) -> None: diff --git a/tests/exchange/test_exchange.py b/tests/exchange/test_exchange.py index f686959fc..29e458cdd 100644 --- a/tests/exchange/test_exchange.py +++ b/tests/exchange/test_exchange.py @@ -9,7 +9,7 @@ import ccxt import pytest from pandas import DataFrame -from freqtrade.enums import CandleType, MarginMode, TradingMode +from freqtrade.enums import CandleType, MarginMode, RunMode, TradingMode from freqtrade.exceptions import (DDosProtection, DependencyException, ExchangeError, InsufficientFundsError, InvalidOrderException, OperationalException, PricingError, TemporaryError) @@ -796,7 +796,9 @@ def test_validate_timeframes_failed(default_conf, mocker): mocker.patch(f'{EXMS}._init_ccxt', MagicMock(return_value=api_mock)) mocker.patch(f'{EXMS}._load_markets', MagicMock(return_value={})) - mocker.patch(f'{EXMS}.validate_pairs', MagicMock()) + mocker.patch(f'{EXMS}.validate_pairs') + mocker.patch(f'{EXMS}.validate_stakecurrency') + mocker.patch(f'{EXMS}.validate_pricing') with pytest.raises(OperationalException, match=r"Invalid timeframe '3m'. This exchange supports.*"): Exchange(default_conf) @@ -806,6 +808,10 @@ def test_validate_timeframes_failed(default_conf, mocker): match=r"Timeframes < 1m are currently not supported by Freqtrade."): Exchange(default_conf) + # Will not raise an exception in util mode. + default_conf['runmode'] = RunMode.UTIL_EXCHANGE + Exchange(default_conf) + def test_validate_timeframes_emulated_ohlcv_1(default_conf, mocker): default_conf["timeframe"] = "3m" From 0d14b7a800eaecc38335e27913359f630f3e3a77 Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 2 Feb 2024 07:03:44 +0100 Subject: [PATCH 45/48] perf: only create detail timedelta object once for detail backtests --- freqtrade/optimize/backtesting.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index 21e9c75cc..43aa00a65 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -185,13 +185,14 @@ class Backtesting: # Load detail timeframe if specified self.timeframe_detail = str(self.config.get('timeframe_detail', '')) if self.timeframe_detail: - self.timeframe_detail_min = timeframe_to_minutes(self.timeframe_detail) - if self.timeframe_min <= self.timeframe_detail_min: + timeframe_detail_min = timeframe_to_minutes(self.timeframe_detail) + self.timeframe_detail_td = timedelta(minutes=timeframe_detail_min) + if self.timeframe_min <= timeframe_detail_min: raise OperationalException( "Detail timeframe must be smaller than strategy timeframe.") else: - self.timeframe_detail_min = 0 + self.timeframe_detail_td = timedelta(seconds=0) self.detail_data: Dict[str, DataFrame] = {} self.futures_data: Dict[str, DataFrame] = {} @@ -1268,7 +1269,7 @@ class Backtesting: open_trade_count_start = self.backtest_loop( det_row, pair, current_time_det, end_date, open_trade_count_start, trade_dir, is_first) - current_time_det += timedelta(minutes=self.timeframe_detail_min) + current_time_det += self.timeframe_detail_td is_first = False else: self.dataprovider._set_dataframe_max_date(current_time) From 80f6fbbae9970c05cbbb3f3ce76f3debe62bc9a0 Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 2 Feb 2024 07:07:58 +0100 Subject: [PATCH 46/48] chore: Move bot-related tests to test subdir --- tests/{ => freqtradebot}/test_freqtradebot.py | 0 tests/{ => freqtradebot}/test_integration.py | 0 tests/{ => freqtradebot}/test_worker.py | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename tests/{ => freqtradebot}/test_freqtradebot.py (100%) rename tests/{ => freqtradebot}/test_integration.py (100%) rename tests/{ => freqtradebot}/test_worker.py (100%) diff --git a/tests/test_freqtradebot.py b/tests/freqtradebot/test_freqtradebot.py similarity index 100% rename from tests/test_freqtradebot.py rename to tests/freqtradebot/test_freqtradebot.py diff --git a/tests/test_integration.py b/tests/freqtradebot/test_integration.py similarity index 100% rename from tests/test_integration.py rename to tests/freqtradebot/test_integration.py diff --git a/tests/test_worker.py b/tests/freqtradebot/test_worker.py similarity index 100% rename from tests/test_worker.py rename to tests/freqtradebot/test_worker.py From 57df7d9ecaabd4a724b37b7868308dcd6eb3abd3 Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 2 Feb 2024 07:23:19 +0100 Subject: [PATCH 47/48] chore: convert test dir to package --- tests/freqtradebot/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 tests/freqtradebot/__init__.py diff --git a/tests/freqtradebot/__init__.py b/tests/freqtradebot/__init__.py new file mode 100644 index 000000000..e69de29bb From 8469484998333f1edbf063563c5fddf4c113acf6 Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 2 Feb 2024 07:25:53 +0100 Subject: [PATCH 48/48] chore: Split stoploss tests from freqtradebot stoploss on exchange tests are quiet extensive, and deserve their own test file. --- tests/freqtradebot/test_freqtradebot.py | 1315 ---------------- .../freqtradebot/test_stoploss_on_exchange.py | 1334 +++++++++++++++++ 2 files changed, 1334 insertions(+), 1315 deletions(-) create mode 100644 tests/freqtradebot/test_stoploss_on_exchange.py diff --git a/tests/freqtradebot/test_freqtradebot.py b/tests/freqtradebot/test_freqtradebot.py index bd0b131af..ca6f29078 100644 --- a/tests/freqtradebot/test_freqtradebot.py +++ b/tests/freqtradebot/test_freqtradebot.py @@ -20,7 +20,6 @@ from freqtrade.exceptions import (DependencyException, ExchangeError, Insufficie TemporaryError) from freqtrade.freqtradebot import FreqtradeBot from freqtrade.persistence import Order, PairLocks, Trade -from freqtrade.persistence.models import PairLock from freqtrade.plugins.protections.iprotection import ProtectionReturn from freqtrade.util.datetime_helpers import dt_now, dt_utc from freqtrade.worker import Worker @@ -1090,1070 +1089,6 @@ def test_execute_entry_min_leverage(mocker, default_conf_usdt, fee, limit_order, # assert trade.stake_amount == 2 -@pytest.mark.parametrize("is_short", [False, True]) -def test_add_stoploss_on_exchange(mocker, default_conf_usdt, limit_order, is_short, fee) -> None: - patch_RPCManager(mocker) - patch_exchange(mocker) - mocker.patch.multiple( - EXMS, - fetch_ticker=MagicMock(return_value={ - 'bid': 1.9, - 'ask': 2.2, - 'last': 1.9 - }), - create_order=MagicMock(return_value=limit_order[entry_side(is_short)]), - get_fee=fee, - ) - order = limit_order[entry_side(is_short)] - mocker.patch('freqtrade.freqtradebot.FreqtradeBot.handle_trade', MagicMock(return_value=True)) - mocker.patch(f'{EXMS}.fetch_order', return_value=order) - mocker.patch(f'{EXMS}.get_trades_for_order', return_value=[]) - - stoploss = MagicMock(return_value={'id': 13434334}) - mocker.patch(f'{EXMS}.create_stoploss', stoploss) - - freqtrade = FreqtradeBot(default_conf_usdt) - freqtrade.strategy.order_types['stoploss_on_exchange'] = True - - patch_get_signal(freqtrade, enter_short=is_short, enter_long=not is_short) - - freqtrade.enter_positions() - trade = Trade.session.scalars(select(Trade)).first() - trade.is_short = is_short - trade.is_open = True - trades = [trade] - - freqtrade.exit_positions(trades) - assert trade.has_open_sl_orders is True - assert stoploss.call_count == 1 - assert trade.is_open is True - - -@pytest.mark.parametrize("is_short", [False, True]) -def test_handle_stoploss_on_exchange(mocker, default_conf_usdt, fee, caplog, is_short, - limit_order) -> None: - stop_order_dict = {'id': "13434334"} - stoploss = MagicMock(return_value=stop_order_dict) - enter_order = limit_order[entry_side(is_short)] - exit_order = limit_order[exit_side(is_short)] - patch_RPCManager(mocker) - patch_exchange(mocker) - mocker.patch.multiple( - EXMS, - fetch_ticker=MagicMock(return_value={ - 'bid': 1.9, - 'ask': 2.2, - 'last': 1.9 - }), - create_order=MagicMock(side_effect=[ - enter_order, - exit_order, - ]), - get_fee=fee, - create_stoploss=stoploss - ) - freqtrade = FreqtradeBot(default_conf_usdt) - patch_get_signal(freqtrade, enter_short=is_short, enter_long=not is_short) - - # First case: when stoploss is not yet set but the order is open - # should get the stoploss order id immediately - # and should return false as no trade actually happened - - freqtrade.enter_positions() - trade = Trade.session.scalars(select(Trade)).first() - assert trade.is_short == is_short - assert trade.is_open - assert trade.has_open_sl_orders is False - - assert freqtrade.handle_stoploss_on_exchange(trade) is False - assert stoploss.call_count == 1 - assert trade.open_sl_orders[-1].order_id == "13434334" - - # Second case: when stoploss is set but it is not yet hit - # should do nothing and return false - trade.is_open = True - - hanging_stoploss_order = MagicMock(return_value={'id': '13434334', 'status': 'open'}) - mocker.patch(f'{EXMS}.fetch_stoploss_order', hanging_stoploss_order) - - assert freqtrade.handle_stoploss_on_exchange(trade) is False - hanging_stoploss_order.assert_called_once_with('13434334', trade.pair) - assert len(trade.open_sl_orders) == 1 - assert trade.open_sl_orders[-1].order_id == "13434334" - - # Third case: when stoploss was set but it was canceled for some reason - # should set a stoploss immediately and return False - caplog.clear() - trade.is_open = True - - canceled_stoploss_order = MagicMock(return_value={'id': '13434334', 'status': 'canceled'}) - mocker.patch(f'{EXMS}.fetch_stoploss_order', canceled_stoploss_order) - stoploss.reset_mock() - amount_before = trade.amount - - stop_order_dict.update({'id': "103_1"}) - - assert freqtrade.handle_stoploss_on_exchange(trade) is False - assert stoploss.call_count == 1 - assert len(trade.open_sl_orders) == 1 - assert trade.open_sl_orders[-1].order_id == "103_1" - assert trade.amount == amount_before - - # Fourth case: when stoploss is set and it is hit - # should return true as a trade actually happened - caplog.clear() - stop_order_dict.update({'id': "103_1"}) - - trade = Trade.session.scalars(select(Trade)).first() - trade.is_short = is_short - trade.is_open = True - - stoploss_order_hit = MagicMock(return_value={ - 'id': "103_1", - 'status': 'closed', - 'type': 'stop_loss_limit', - 'price': 3, - 'average': 2, - 'filled': enter_order['amount'], - 'remaining': 0, - 'amount': enter_order['amount'], - }) - mocker.patch(f'{EXMS}.fetch_stoploss_order', stoploss_order_hit) - assert freqtrade.handle_stoploss_on_exchange(trade) is True - assert log_has_re(r'STOP_LOSS_LIMIT is hit for Trade\(id=1, .*\)\.', caplog) - assert len(trade.open_sl_orders) == 0 - assert trade.is_open is False - caplog.clear() - - mocker.patch(f'{EXMS}.create_stoploss', side_effect=ExchangeError()) - trade.is_open = True - freqtrade.handle_stoploss_on_exchange(trade) - assert log_has('Unable to place a stoploss order on exchange.', caplog) - assert len(trade.open_sl_orders) == 0 - - # Fifth case: fetch_order returns InvalidOrder - # It should try to add stoploss order - stop_order_dict.update({'id': "105"}) - stoploss.reset_mock() - mocker.patch(f'{EXMS}.fetch_stoploss_order', side_effect=InvalidOrderException()) - mocker.patch(f'{EXMS}.create_stoploss', stoploss) - freqtrade.handle_stoploss_on_exchange(trade) - assert len(trade.open_sl_orders) == 1 - assert stoploss.call_count == 1 - - # Sixth case: Closed Trade - # Should not create new order - trade.is_open = False - trade.open_sl_orders[-1].ft_is_open = False - stoploss.reset_mock() - mocker.patch(f'{EXMS}.fetch_order') - mocker.patch(f'{EXMS}.create_stoploss', stoploss) - assert freqtrade.handle_stoploss_on_exchange(trade) is False - assert trade.has_open_sl_orders is False - assert stoploss.call_count == 0 - - -@pytest.mark.parametrize("is_short", [False, True]) -def test_handle_stoploss_on_exchange_emergency(mocker, default_conf_usdt, fee, is_short, - limit_order) -> None: - stop_order_dict = {'id': "13434334"} - stoploss = MagicMock(return_value=stop_order_dict) - enter_order = limit_order[entry_side(is_short)] - exit_order = limit_order[exit_side(is_short)] - patch_RPCManager(mocker) - patch_exchange(mocker) - mocker.patch.multiple( - EXMS, - fetch_ticker=MagicMock(return_value={ - 'bid': 1.9, - 'ask': 2.2, - 'last': 1.9 - }), - create_order=MagicMock(side_effect=[ - enter_order, - exit_order, - ]), - get_fee=fee, - create_stoploss=stoploss - ) - freqtrade = FreqtradeBot(default_conf_usdt) - patch_get_signal(freqtrade, enter_short=is_short, enter_long=not is_short) - - freqtrade.enter_positions() - trade = Trade.session.scalars(select(Trade)).first() - assert trade.is_short == is_short - assert trade.is_open - assert trade.has_open_sl_orders is False - - # emergency exit triggered - # Trailing stop should not act anymore - stoploss_order_cancelled = MagicMock(side_effect=[{ - 'id': "107", - 'status': 'canceled', - 'type': 'stop_loss_limit', - 'price': 3, - 'average': 2, - 'amount': enter_order['amount'], - 'filled': 0, - 'remaining': enter_order['amount'], - 'info': {'stopPrice': 22}, - }]) - trade.stoploss_last_update = dt_now() - timedelta(hours=1) - trade.stop_loss = 24 - trade.exit_reason = None - trade.orders.append( - Order( - ft_order_side='stoploss', - ft_pair=trade.pair, - ft_is_open=True, - ft_amount=trade.amount, - ft_price=trade.stop_loss, - order_id='107', - status='open', - ) - ) - freqtrade.config['trailing_stop'] = True - stoploss = MagicMock(side_effect=InvalidOrderException()) - assert trade.has_open_sl_orders is True - Trade.commit() - mocker.patch(f'{EXMS}.cancel_stoploss_order_with_result', - side_effect=InvalidOrderException()) - mocker.patch(f'{EXMS}.fetch_stoploss_order', stoploss_order_cancelled) - mocker.patch(f'{EXMS}.create_stoploss', stoploss) - assert freqtrade.handle_stoploss_on_exchange(trade) is False - assert trade.has_open_sl_orders is False - assert trade.is_open is False - assert trade.exit_reason == str(ExitType.EMERGENCY_EXIT) - - -@pytest.mark.parametrize("is_short", [False, True]) -def test_handle_stoploss_on_exchange_partial( - mocker, default_conf_usdt, fee, is_short, limit_order) -> None: - stop_order_dict = {'id': "101", "status": "open"} - stoploss = MagicMock(return_value=stop_order_dict) - enter_order = limit_order[entry_side(is_short)] - exit_order = limit_order[exit_side(is_short)] - patch_RPCManager(mocker) - patch_exchange(mocker) - mocker.patch.multiple( - EXMS, - fetch_ticker=MagicMock(return_value={ - 'bid': 1.9, - 'ask': 2.2, - 'last': 1.9 - }), - create_order=MagicMock(side_effect=[ - enter_order, - exit_order, - ]), - get_fee=fee, - create_stoploss=stoploss - ) - freqtrade = FreqtradeBot(default_conf_usdt) - patch_get_signal(freqtrade, enter_short=is_short, enter_long=not is_short) - - freqtrade.enter_positions() - trade = Trade.session.scalars(select(Trade)).first() - trade.is_short = is_short - trade.is_open = True - - assert freqtrade.handle_stoploss_on_exchange(trade) is False - assert stoploss.call_count == 1 - assert trade.has_open_sl_orders is True - assert trade.open_sl_orders[-1].order_id == "101" - assert trade.amount == 30 - stop_order_dict.update({'id': "102"}) - # Stoploss on exchange is cancelled on exchange, but filled partially. - # Must update trade amount to guarantee successful exit. - stoploss_order_hit = MagicMock(return_value={ - 'id': "101", - 'status': 'canceled', - 'type': 'stop_loss_limit', - 'price': 3, - 'average': 2, - 'filled': trade.amount / 2, - 'remaining': trade.amount / 2, - 'amount': enter_order['amount'], - }) - mocker.patch(f'{EXMS}.fetch_stoploss_order', stoploss_order_hit) - assert freqtrade.handle_stoploss_on_exchange(trade) is False - # Stoploss filled partially ... - assert trade.amount == 15 - - assert trade.open_sl_orders[-1].order_id == "102" - - -@pytest.mark.parametrize("is_short", [False, True]) -def test_handle_stoploss_on_exchange_partial_cancel_here( - mocker, default_conf_usdt, fee, is_short, limit_order, caplog, time_machine) -> None: - stop_order_dict = {'id': "101", "status": "open"} - time_machine.move_to(dt_now()) - default_conf_usdt['trailing_stop'] = True - stoploss = MagicMock(return_value=stop_order_dict) - enter_order = limit_order[entry_side(is_short)] - exit_order = limit_order[exit_side(is_short)] - patch_RPCManager(mocker) - patch_exchange(mocker) - mocker.patch.multiple( - EXMS, - fetch_ticker=MagicMock(return_value={ - 'bid': 1.9, - 'ask': 2.2, - 'last': 1.9 - }), - create_order=MagicMock(side_effect=[ - enter_order, - exit_order, - ]), - get_fee=fee, - create_stoploss=stoploss - ) - freqtrade = FreqtradeBot(default_conf_usdt) - patch_get_signal(freqtrade, enter_short=is_short, enter_long=not is_short) - - freqtrade.enter_positions() - trade = Trade.session.scalars(select(Trade)).first() - trade.is_short = is_short - trade.is_open = True - - assert freqtrade.handle_stoploss_on_exchange(trade) is False - assert stoploss.call_count == 1 - assert trade.has_open_sl_orders is True - assert trade.open_sl_orders[-1].order_id == "101" - assert trade.amount == 30 - stop_order_dict.update({'id': "102"}) - # Stoploss on exchange is open. - # Freqtrade cancels the stop - but cancel returns a partial filled order. - stoploss_order_hit = MagicMock(return_value={ - 'id': "101", - 'status': 'open', - 'type': 'stop_loss_limit', - 'price': 3, - 'average': 2, - 'filled': 0, - 'remaining': trade.amount, - 'amount': enter_order['amount'], - }) - stoploss_order_cancel = MagicMock(return_value={ - 'id': "101", - 'status': 'canceled', - 'type': 'stop_loss_limit', - 'price': 3, - 'average': 2, - 'filled': trade.amount / 2, - 'remaining': trade.amount / 2, - 'amount': enter_order['amount'], - }) - mocker.patch(f'{EXMS}.fetch_stoploss_order', stoploss_order_hit) - mocker.patch(f'{EXMS}.cancel_stoploss_order_with_result', stoploss_order_cancel) - time_machine.shift(timedelta(minutes=15)) - - assert freqtrade.handle_stoploss_on_exchange(trade) is False - # Canceled Stoploss filled partially ... - assert log_has_re('Cancelling current stoploss on exchange.*', caplog) - - assert trade.has_open_sl_orders is True - assert trade.open_sl_orders[-1].order_id == "102" - assert trade.amount == 15 - - -@pytest.mark.parametrize("is_short", [False, True]) -def test_handle_sle_cancel_cant_recreate(mocker, default_conf_usdt, fee, caplog, is_short, - limit_order) -> None: - # Sixth case: stoploss order was cancelled but couldn't create new one - enter_order = limit_order[entry_side(is_short)] - exit_order = limit_order[exit_side(is_short)] - patch_RPCManager(mocker) - patch_exchange(mocker) - mocker.patch.multiple( - EXMS, - fetch_ticker=MagicMock(return_value={ - 'bid': 1.9, - 'ask': 2.2, - 'last': 1.9 - }), - create_order=MagicMock(side_effect=[ - enter_order, - exit_order, - ]), - get_fee=fee, - ) - mocker.patch.multiple( - EXMS, - fetch_stoploss_order=MagicMock(return_value={'status': 'canceled', 'id': '100'}), - create_stoploss=MagicMock(side_effect=ExchangeError()), - ) - freqtrade = FreqtradeBot(default_conf_usdt) - patch_get_signal(freqtrade, enter_short=is_short, enter_long=not is_short) - - freqtrade.enter_positions() - trade = Trade.session.scalars(select(Trade)).first() - assert trade.is_short == is_short - trade.is_open = True - trade.orders.append( - Order( - ft_order_side='stoploss', - ft_pair=trade.pair, - ft_is_open=True, - ft_amount=trade.amount, - ft_price=trade.stop_loss, - order_id='100', - status='open', - ) - ) - assert trade - - assert freqtrade.handle_stoploss_on_exchange(trade) is False - assert log_has_re(r'All Stoploss orders are cancelled, but unable to recreate one\.', caplog) - assert trade.has_open_sl_orders is False - assert trade.is_open is True - - -@pytest.mark.parametrize("is_short", [False, True]) -def test_create_stoploss_order_invalid_order( - mocker, default_conf_usdt, caplog, fee, is_short, limit_order -): - open_order = limit_order[entry_side(is_short)] - order = limit_order[exit_side(is_short)] - rpc_mock = patch_RPCManager(mocker) - patch_exchange(mocker) - create_order_mock = MagicMock(side_effect=[ - open_order, - order, - ]) - mocker.patch.multiple( - EXMS, - fetch_ticker=MagicMock(return_value={ - 'bid': 1.9, - 'ask': 2.2, - 'last': 1.9 - }), - create_order=create_order_mock, - get_fee=fee, - ) - mocker.patch.multiple( - EXMS, - fetch_order=MagicMock(return_value={'status': 'canceled'}), - create_stoploss=MagicMock(side_effect=InvalidOrderException()), - ) - freqtrade = FreqtradeBot(default_conf_usdt) - patch_get_signal(freqtrade, enter_short=is_short, enter_long=not is_short) - freqtrade.strategy.order_types['stoploss_on_exchange'] = True - - freqtrade.enter_positions() - trade = Trade.session.scalars(select(Trade)).first() - trade.is_short = is_short - caplog.clear() - rpc_mock.reset_mock() - freqtrade.create_stoploss_order(trade, 200) - assert trade.has_open_sl_orders is False - assert trade.exit_reason == ExitType.EMERGENCY_EXIT.value - assert log_has("Unable to place a stoploss order on exchange. ", caplog) - assert log_has("Exiting the trade forcefully", caplog) - - # Should call a market sell - assert create_order_mock.call_count == 2 - assert create_order_mock.call_args[1]['ordertype'] == 'market' - assert create_order_mock.call_args[1]['pair'] == trade.pair - assert create_order_mock.call_args[1]['amount'] == trade.amount - - # Rpc is sending first buy, then sell - assert rpc_mock.call_count == 2 - assert rpc_mock.call_args_list[0][0][0]['exit_reason'] == ExitType.EMERGENCY_EXIT.value - assert rpc_mock.call_args_list[0][0][0]['order_type'] == 'market' - assert rpc_mock.call_args_list[0][0][0]['type'] == 'exit' - assert rpc_mock.call_args_list[1][0][0]['type'] == 'exit_fill' - - -@pytest.mark.parametrize("is_short", [False, True]) -def test_create_stoploss_order_insufficient_funds( - mocker, default_conf_usdt, caplog, fee, limit_order, is_short -): - exit_order = limit_order[exit_side(is_short)]['id'] - freqtrade = get_patched_freqtradebot(mocker, default_conf_usdt) - - mock_insuf = mocker.patch('freqtrade.freqtradebot.FreqtradeBot.handle_insufficient_funds') - mocker.patch.multiple( - EXMS, - fetch_ticker=MagicMock(return_value={ - 'bid': 1.9, - 'ask': 2.2, - 'last': 1.9 - }), - create_order=MagicMock(side_effect=[ - limit_order[entry_side(is_short)], - exit_order, - ]), - get_fee=fee, - fetch_order=MagicMock(return_value={'status': 'canceled'}), - ) - mocker.patch.multiple( - EXMS, - create_stoploss=MagicMock(side_effect=InsufficientFundsError()), - ) - patch_get_signal(freqtrade, enter_short=is_short, enter_long=not is_short) - freqtrade.strategy.order_types['stoploss_on_exchange'] = True - - freqtrade.enter_positions() - trade = Trade.session.scalars(select(Trade)).first() - trade.is_short = is_short - caplog.clear() - freqtrade.create_stoploss_order(trade, 200) - # stoploss_orderid was empty before - assert trade.has_open_sl_orders is False - assert mock_insuf.call_count == 1 - mock_insuf.reset_mock() - - freqtrade.create_stoploss_order(trade, 200) - # No change to stoploss-orderid - assert trade.has_open_sl_orders is False - assert mock_insuf.call_count == 1 - - -@pytest.mark.parametrize("is_short,bid,ask,stop_price,hang_price", [ - (False, [4.38, 4.16], [4.4, 4.17], ['2.0805', 4.4 * 0.95], 3), - (True, [1.09, 1.21], [1.1, 1.22], ['2.321', 1.09 * 1.05], 1.5), -]) -@pytest.mark.usefixtures("init_persistence") -def test_handle_stoploss_on_exchange_trailing( - mocker, default_conf_usdt, fee, is_short, bid, ask, limit_order, stop_price, hang_price, - time_machine, -) -> None: - # When trailing stoploss is set - enter_order = limit_order[entry_side(is_short)] - exit_order = limit_order[exit_side(is_short)] - stoploss = MagicMock(return_value={'id': '13434334', 'status': 'open'}) - start_dt = dt_now() - time_machine.move_to(start_dt, tick=False) - patch_RPCManager(mocker) - mocker.patch.multiple( - EXMS, - fetch_ticker=MagicMock(return_value={ - 'bid': 2.19, - 'ask': 2.2, - 'last': 2.19, - }), - create_order=MagicMock(side_effect=[ - enter_order, - exit_order, - ]), - get_fee=fee, - ) - mocker.patch.multiple( - EXMS, - create_stoploss=stoploss, - stoploss_adjust=MagicMock(return_value=True), - ) - - # enabling TSL - default_conf_usdt['trailing_stop'] = True - - # disabling ROI - default_conf_usdt['minimal_roi']['0'] = 999999999 - - freqtrade = get_patched_freqtradebot(mocker, default_conf_usdt) - - # enabling stoploss on exchange - freqtrade.strategy.order_types['stoploss_on_exchange'] = True - - # setting stoploss - freqtrade.strategy.stoploss = 0.05 if is_short else -0.05 - - # setting stoploss_on_exchange_interval to 60 seconds - freqtrade.strategy.order_types['stoploss_on_exchange_interval'] = 60 - - patch_get_signal(freqtrade, enter_short=is_short, enter_long=not is_short) - - freqtrade.enter_positions() - trade = Trade.session.scalars(select(Trade)).first() - trade.is_short = is_short - trade.is_open = True - assert trade.has_open_sl_orders is False - trade.stoploss_last_update = dt_now() - timedelta(minutes=20) - trade.orders.append( - Order( - ft_order_side='stoploss', - ft_pair=trade.pair, - ft_is_open=True, - ft_amount=trade.amount, - ft_price=trade.stop_loss, - order_id='100', - order_date=dt_now() - timedelta(minutes=20), - ) - ) - - stoploss_order_hanging = { - 'id': '100', - 'status': 'open', - 'type': 'stop_loss_limit', - 'price': hang_price, - 'average': 2, - 'fee': {}, - 'amount': 0, - 'info': { - 'stopPrice': stop_price[0] - } - } - stoploss_order_cancel = deepcopy(stoploss_order_hanging) - stoploss_order_cancel['status'] = 'canceled' - - mocker.patch(f'{EXMS}.fetch_stoploss_order', return_value=stoploss_order_hanging) - mocker.patch(f'{EXMS}.cancel_stoploss_order', return_value=stoploss_order_cancel) - - # stoploss initially at 5% - assert freqtrade.handle_trade(trade) is False - assert freqtrade.handle_stoploss_on_exchange(trade) is False - - assert len(trade.open_sl_orders) == 1 - - assert trade.open_sl_orders[-1].order_id == '13434334' - - # price jumped 2x - mocker.patch( - f'{EXMS}.fetch_ticker', - MagicMock(return_value={ - 'bid': bid[0], - 'ask': ask[0], - 'last': bid[0], - }) - ) - - cancel_order_mock = MagicMock(return_value={ - 'id': '13434334', 'status': 'canceled', 'fee': {}, 'amount': trade.amount}) - stoploss_order_mock = MagicMock(return_value={'id': 'so1', 'status': 'open'}) - mocker.patch(f'{EXMS}.fetch_stoploss_order') - mocker.patch(f'{EXMS}.cancel_stoploss_order', cancel_order_mock) - mocker.patch(f'{EXMS}.create_stoploss', stoploss_order_mock) - - # stoploss should not be updated as the interval is 60 seconds - assert freqtrade.handle_trade(trade) is False - assert freqtrade.handle_stoploss_on_exchange(trade) is False - assert len(trade.open_sl_orders) == 1 - cancel_order_mock.assert_not_called() - stoploss_order_mock.assert_not_called() - - # Move time by 10s ... so stoploss order should be replaced. - time_machine.move_to(start_dt + timedelta(minutes=10), tick=False) - - assert freqtrade.handle_trade(trade) is False - assert trade.stop_loss == stop_price[1] - - assert freqtrade.handle_stoploss_on_exchange(trade) is False - - cancel_order_mock.assert_called_once_with('13434334', 'ETH/USDT') - stoploss_order_mock.assert_called_once_with( - amount=30, - pair='ETH/USDT', - order_types=freqtrade.strategy.order_types, - stop_price=stop_price[1], - side=exit_side(is_short), - leverage=1.0 - ) - - # price fell below stoploss, so dry-run sells trade. - mocker.patch( - f'{EXMS}.fetch_ticker', - MagicMock(return_value={ - 'bid': bid[1], - 'ask': ask[1], - 'last': bid[1], - }) - ) - mocker.patch(f'{EXMS}.cancel_stoploss_order_with_result', - return_value={'id': 'so1', 'status': 'canceled'}) - assert len(trade.open_sl_orders) == 1 - assert trade.open_sl_orders[-1].order_id == 'so1' - - assert freqtrade.handle_trade(trade) is True - assert trade.is_open is False - assert trade.has_open_sl_orders is False - - -@pytest.mark.parametrize("is_short", [False, True]) -def test_handle_stoploss_on_exchange_trailing_error( - mocker, default_conf_usdt, fee, caplog, limit_order, is_short, time_machine -) -> None: - time_machine.move_to(dt_now() - timedelta(minutes=601)) - enter_order = limit_order[entry_side(is_short)] - exit_order = limit_order[exit_side(is_short)] - # When trailing stoploss is set - stoploss = MagicMock(return_value={'id': '13434334', 'status': 'open'}) - patch_exchange(mocker) - - mocker.patch.multiple( - EXMS, - fetch_ticker=MagicMock(return_value={ - 'bid': 1.9, - 'ask': 2.2, - 'last': 1.9 - }), - create_order=MagicMock(side_effect=[ - {'id': enter_order['id']}, - {'id': exit_order['id']}, - ]), - get_fee=fee, - create_stoploss=stoploss, - stoploss_adjust=MagicMock(return_value=True), - ) - - # enabling TSL - default_conf_usdt['trailing_stop'] = True - - freqtrade = get_patched_freqtradebot(mocker, default_conf_usdt) - # enabling stoploss on exchange - freqtrade.strategy.order_types['stoploss_on_exchange'] = True - - # setting stoploss - freqtrade.strategy.stoploss = 0.05 if is_short else -0.05 - - # setting stoploss_on_exchange_interval to 60 seconds - freqtrade.strategy.order_types['stoploss_on_exchange_interval'] = 60 - patch_get_signal(freqtrade, enter_short=is_short, enter_long=not is_short) - freqtrade.enter_positions() - trade = Trade.session.scalars(select(Trade)).first() - trade.is_short = is_short - trade.is_open = True - trade.stop_loss = 0.2 - - stoploss_order_hanging = { - 'id': "abcd", - 'status': 'open', - 'type': 'stop_loss_limit', - 'price': 3, - 'average': 2, - 'info': { - 'stopPrice': '0.1' - } - } - trade.orders.append( - Order( - ft_order_side='stoploss', - ft_pair=trade.pair, - ft_is_open=True, - ft_amount=trade.amount, - ft_price=3, - order_id='abcd', - order_date=dt_now(), - ) - ) - mocker.patch(f'{EXMS}.cancel_stoploss_order', - side_effect=InvalidOrderException()) - mocker.patch(f'{EXMS}.fetch_stoploss_order', - return_value=stoploss_order_hanging) - time_machine.shift(timedelta(minutes=50)) - freqtrade.handle_trailing_stoploss_on_exchange(trade, stoploss_order_hanging) - assert log_has_re(r"Could not cancel stoploss order abcd for pair ETH/USDT.*", caplog) - - # Still try to create order - assert stoploss.call_count == 1 - # TODO: Is this actually correct ? This will create a new order every time, - assert len(trade.open_sl_orders) == 2 - - # Fail creating stoploss order - caplog.clear() - cancel_mock = mocker.patch(f'{EXMS}.cancel_stoploss_order') - mocker.patch(f'{EXMS}.create_stoploss', side_effect=ExchangeError()) - time_machine.shift(timedelta(minutes=50)) - freqtrade.handle_trailing_stoploss_on_exchange(trade, stoploss_order_hanging) - assert cancel_mock.call_count == 2 - assert log_has_re(r"Could not create trailing stoploss order for pair ETH/USDT\..*", caplog) - - -def test_stoploss_on_exchange_price_rounding( - mocker, default_conf_usdt, fee, open_trade_usdt) -> None: - patch_RPCManager(mocker) - mocker.patch.multiple( - EXMS, - get_fee=fee, - ) - price_mock = MagicMock(side_effect=lambda p, s, **kwargs: int(s)) - stoploss_mock = MagicMock(return_value={'id': '13434334'}) - adjust_mock = MagicMock(return_value=False) - mocker.patch.multiple( - EXMS, - create_stoploss=stoploss_mock, - stoploss_adjust=adjust_mock, - price_to_precision=price_mock, - ) - freqtrade = get_patched_freqtradebot(mocker, default_conf_usdt) - open_trade_usdt.stop_loss = 222.55 - - freqtrade.handle_trailing_stoploss_on_exchange(open_trade_usdt, {}) - assert price_mock.call_count == 1 - assert adjust_mock.call_count == 1 - assert adjust_mock.call_args_list[0][0][0] == 222 - - -@pytest.mark.parametrize("is_short", [False, True]) -@pytest.mark.usefixtures("init_persistence") -def test_handle_stoploss_on_exchange_custom_stop( - mocker, default_conf_usdt, fee, is_short, limit_order -) -> None: - enter_order = limit_order[entry_side(is_short)] - exit_order = limit_order[exit_side(is_short)] - # When trailing stoploss is set - stoploss = MagicMock(return_value={'id': 13434334, 'status': 'open'}) - patch_RPCManager(mocker) - mocker.patch.multiple( - EXMS, - fetch_ticker=MagicMock(return_value={ - 'bid': 1.9, - 'ask': 2.2, - 'last': 1.9 - }), - create_order=MagicMock(side_effect=[ - enter_order, - exit_order, - ]), - get_fee=fee, - is_cancel_order_result_suitable=MagicMock(return_value=True), - ) - mocker.patch.multiple( - EXMS, - create_stoploss=stoploss, - stoploss_adjust=MagicMock(return_value=True), - ) - - # enabling TSL - default_conf_usdt['use_custom_stoploss'] = True - - # disabling ROI - default_conf_usdt['minimal_roi']['0'] = 999999999 - - freqtrade = get_patched_freqtradebot(mocker, default_conf_usdt) - - # enabling stoploss on exchange - freqtrade.strategy.order_types['stoploss_on_exchange'] = True - - # setting stoploss - freqtrade.strategy.custom_stoploss = lambda *args, **kwargs: -0.04 - - # setting stoploss_on_exchange_interval to 60 seconds - freqtrade.strategy.order_types['stoploss_on_exchange_interval'] = 60 - - patch_get_signal(freqtrade, enter_short=is_short, enter_long=not is_short) - - freqtrade.enter_positions() - trade = Trade.session.scalars(select(Trade)).first() - trade.is_short = is_short - trade.is_open = True - trade.orders.append( - Order( - ft_order_side='stoploss', - ft_pair=trade.pair, - ft_is_open=True, - ft_amount=trade.amount, - ft_price=trade.stop_loss, - order_date=dt_now() - timedelta(minutes=601), - order_id='100', - ) - ) - Trade.commit() - slo = { - 'id': '100', - 'status': 'open', - 'type': 'stop_loss_limit', - 'price': 3, - 'average': 2, - 'info': { - 'stopPrice': '2.0805' - } - } - slo_canceled = deepcopy(slo) - slo_canceled.update({'status': 'canceled'}) - - def fetch_stoploss_order_mock(order_id, *args, **kwargs): - x = deepcopy(slo) - x['id'] = order_id - return x - - mocker.patch(f'{EXMS}.fetch_stoploss_order', MagicMock(fetch_stoploss_order_mock)) - mocker.patch(f'{EXMS}.cancel_stoploss_order', return_value=slo_canceled) - - assert freqtrade.handle_trade(trade) is False - assert freqtrade.handle_stoploss_on_exchange(trade) is False - - # price jumped 2x - mocker.patch( - f'{EXMS}.fetch_ticker', - MagicMock(return_value={ - 'bid': 4.38 if not is_short else 1.9 / 2, - 'ask': 4.4 if not is_short else 2.2 / 2, - 'last': 4.38 if not is_short else 1.9 / 2, - }) - ) - - cancel_order_mock = MagicMock() - stoploss_order_mock = MagicMock(return_value={'id': 'so1', 'status': 'open'}) - mocker.patch(f'{EXMS}.cancel_stoploss_order', cancel_order_mock) - mocker.patch(f'{EXMS}.create_stoploss', stoploss_order_mock) - - # stoploss should not be updated as the interval is 60 seconds - assert freqtrade.handle_trade(trade) is False - assert freqtrade.handle_stoploss_on_exchange(trade) is False - cancel_order_mock.assert_not_called() - stoploss_order_mock.assert_not_called() - - assert freqtrade.handle_trade(trade) is False - assert trade.stop_loss == 4.4 * 0.96 if not is_short else 1.1 - assert trade.stop_loss_pct == -0.04 if not is_short else 0.04 - - # setting stoploss_on_exchange_interval to 0 seconds - freqtrade.strategy.order_types['stoploss_on_exchange_interval'] = 0 - cancel_order_mock.assert_not_called() - stoploss_order_mock.assert_not_called() - - assert freqtrade.handle_stoploss_on_exchange(trade) is False - - cancel_order_mock.assert_called_once_with('13434334', 'ETH/USDT') - # Long uses modified ask - offset, short modified bid + offset - stoploss_order_mock.assert_called_once_with( - amount=pytest.approx(trade.amount), - pair='ETH/USDT', - order_types=freqtrade.strategy.order_types, - stop_price=4.4 * 0.96 if not is_short else 0.95 * 1.04, - side=exit_side(is_short), - leverage=1.0 - ) - - # price fell below stoploss, so dry-run sells trade. - mocker.patch( - f'{EXMS}.fetch_ticker', - MagicMock(return_value={ - 'bid': 4.17, - 'ask': 4.19, - 'last': 4.17 - }) - ) - assert freqtrade.handle_trade(trade) is True - - -def test_tsl_on_exchange_compatible_with_edge(mocker, edge_conf, fee, limit_order) -> None: - - enter_order = limit_order['buy'] - exit_order = limit_order['sell'] - enter_order['average'] = 2.19 - # When trailing stoploss is set - stoploss = MagicMock(return_value={'id': '13434334', 'status': 'open'}) - patch_RPCManager(mocker) - patch_exchange(mocker) - patch_edge(mocker) - edge_conf['max_open_trades'] = float('inf') - edge_conf['dry_run_wallet'] = 999.9 - edge_conf['exchange']['name'] = 'binance' - mocker.patch.multiple( - EXMS, - fetch_ticker=MagicMock(return_value={ - 'bid': 2.19, - 'ask': 2.2, - 'last': 2.19 - }), - create_order=MagicMock(side_effect=[ - enter_order, - exit_order, - ]), - get_fee=fee, - create_stoploss=stoploss, - ) - - # enabling TSL - edge_conf['trailing_stop'] = True - edge_conf['trailing_stop_positive'] = 0.01 - edge_conf['trailing_stop_positive_offset'] = 0.011 - - # disabling ROI - edge_conf['minimal_roi']['0'] = 999999999 - - freqtrade = FreqtradeBot(edge_conf) - - # enabling stoploss on exchange - freqtrade.strategy.order_types['stoploss_on_exchange'] = True - - # setting stoploss - freqtrade.strategy.stoploss = -0.02 - - # setting stoploss_on_exchange_interval to 0 seconds - freqtrade.strategy.order_types['stoploss_on_exchange_interval'] = 0 - - patch_get_signal(freqtrade) - - freqtrade.active_pair_whitelist = freqtrade.edge.adjust(freqtrade.active_pair_whitelist) - - freqtrade.enter_positions() - trade = Trade.session.scalars(select(Trade)).first() - trade.is_open = True - - trade.stoploss_last_update = dt_now() - trade.orders.append( - Order( - ft_order_side='stoploss', - ft_pair=trade.pair, - ft_is_open=True, - ft_amount=trade.amount, - ft_price=trade.stop_loss, - order_id='100', - ) - ) - - stoploss_order_hanging = MagicMock(return_value={ - 'id': '100', - 'status': 'open', - 'type': 'stop_loss_limit', - 'price': 3, - 'average': 2, - 'stopPrice': '2.178' - }) - - mocker.patch(f'{EXMS}.fetch_stoploss_order', stoploss_order_hanging) - - # stoploss initially at 20% as edge dictated it. - assert freqtrade.handle_trade(trade) is False - assert freqtrade.handle_stoploss_on_exchange(trade) is False - assert pytest.approx(trade.stop_loss) == 1.76 - - cancel_order_mock = MagicMock() - stoploss_order_mock = MagicMock() - mocker.patch(f'{EXMS}.cancel_stoploss_order', cancel_order_mock) - mocker.patch(f'{EXMS}.create_stoploss', stoploss_order_mock) - - # price goes down 5% - mocker.patch(f'{EXMS}.fetch_ticker', MagicMock(return_value={ - 'bid': 2.19 * 0.95, - 'ask': 2.2 * 0.95, - 'last': 2.19 * 0.95 - })) - assert freqtrade.handle_trade(trade) is False - assert freqtrade.handle_stoploss_on_exchange(trade) is False - - # stoploss should remain the same - assert pytest.approx(trade.stop_loss) == 1.76 - - # stoploss on exchange should not be canceled - cancel_order_mock.assert_not_called() - - # price jumped 2x - mocker.patch(f'{EXMS}.fetch_ticker', MagicMock(return_value={ - 'bid': 4.38, - 'ask': 4.4, - 'last': 4.38 - })) - - assert freqtrade.handle_trade(trade) is False - assert freqtrade.handle_stoploss_on_exchange(trade) is False - - # stoploss should be set to 1% as trailing is on - assert trade.stop_loss == 4.4 * 0.99 - cancel_order_mock.assert_called_once_with('100', 'NEO/BTC') - stoploss_order_mock.assert_called_once_with( - amount=30, - pair='NEO/BTC', - order_types=freqtrade.strategy.order_types, - stop_price=4.4 * 0.99, - side='sell', - leverage=1.0 - ) - - @pytest.mark.parametrize('return_value,side_effect,log_message', [ (False, None, 'Found no enter signals for whitelisted currencies. Trying again...'), (None, DependencyException, 'Unable to create trade for ETH/USDT: ') @@ -3988,257 +2923,7 @@ def test_execute_trade_exit_custom_exit_price( } == last_msg -@pytest.mark.parametrize("is_short", [False, True]) -def test_execute_trade_exit_down_stoploss_on_exchange_dry_run( - default_conf_usdt, ticker_usdt, fee, is_short, ticker_usdt_sell_down, - ticker_usdt_sell_up, mocker) -> None: - rpc_mock = patch_RPCManager(mocker) - patch_exchange(mocker) - mocker.patch.multiple( - EXMS, - fetch_ticker=ticker_usdt, - get_fee=fee, - _dry_is_price_crossed=MagicMock(return_value=False), - ) - patch_whitelist(mocker, default_conf_usdt) - freqtrade = FreqtradeBot(default_conf_usdt) - patch_get_signal(freqtrade, enter_short=is_short, enter_long=not is_short) - # Create some test data - freqtrade.enter_positions() - - trade = Trade.session.scalars(select(Trade)).first() - assert trade.is_short == is_short - assert trade - - # Decrease the price and sell it - mocker.patch.multiple( - EXMS, - fetch_ticker=ticker_usdt_sell_up if is_short else ticker_usdt_sell_down - ) - - default_conf_usdt['dry_run'] = True - freqtrade.strategy.order_types['stoploss_on_exchange'] = True - # Setting trade stoploss to 0.01 - - trade.stop_loss = 2.0 * 1.01 if is_short else 2.0 * 0.99 - freqtrade.execute_trade_exit( - trade=trade, limit=trade.stop_loss, - exit_check=ExitCheckTuple(exit_type=ExitType.STOP_LOSS)) - - assert rpc_mock.call_count == 2 - last_msg = rpc_mock.call_args_list[-1][0][0] - - assert { - 'type': RPCMessageType.EXIT, - 'trade_id': 1, - 'exchange': 'Binance', - 'pair': 'ETH/USDT', - 'direction': 'Short' if trade.is_short else 'Long', - 'leverage': 1.0, - 'gain': 'loss', - 'limit': 2.02 if is_short else 1.98, - 'order_rate': 2.02 if is_short else 1.98, - 'amount': pytest.approx(29.70297029 if is_short else 30.0), - 'order_type': 'limit', - 'buy_tag': None, - 'enter_tag': None, - 'open_rate': 2.02 if is_short else 2.0, - 'current_rate': 2.2 if is_short else 2.0, - 'profit_amount': -0.3 if is_short else -0.8985, - 'profit_ratio': -0.00501253 if is_short else -0.01493766, - 'stake_currency': 'USDT', - 'quote_currency': 'USDT', - 'fiat_currency': 'USD', - 'base_currency': 'ETH', - 'exit_reason': ExitType.STOP_LOSS.value, - 'open_date': ANY, - 'close_date': ANY, - 'close_rate': ANY, - 'sub_trade': False, - 'cumulative_profit': 0.0, - 'stake_amount': pytest.approx(60), - 'is_final_exit': False, - 'final_profit_ratio': None, - } == last_msg - - -def test_execute_trade_exit_sloe_cancel_exception( - mocker, default_conf_usdt, ticker_usdt, fee, caplog) -> None: - freqtrade = get_patched_freqtradebot(mocker, default_conf_usdt) - mocker.patch(f'{EXMS}.cancel_stoploss_order', side_effect=InvalidOrderException()) - mocker.patch('freqtrade.wallets.Wallets.get_free', MagicMock(return_value=300)) - create_order_mock = MagicMock(side_effect=[ - {'id': '12345554'}, - {'id': '12345555'}, - ]) - patch_exchange(mocker) - mocker.patch.multiple( - EXMS, - fetch_ticker=ticker_usdt, - get_fee=fee, - create_order=create_order_mock, - ) - - freqtrade.strategy.order_types['stoploss_on_exchange'] = True - patch_get_signal(freqtrade) - freqtrade.enter_positions() - - trade = Trade.session.scalars(select(Trade)).first() - PairLock.session = MagicMock() - - freqtrade.config['dry_run'] = False - trade.orders.append( - Order( - ft_order_side='stoploss', - ft_pair=trade.pair, - ft_is_open=True, - ft_amount=trade.amount, - ft_price=trade.stop_loss, - order_id='abcd', - status='open', - ) - ) - - freqtrade.execute_trade_exit(trade=trade, limit=1234, - exit_check=ExitCheckTuple(exit_type=ExitType.STOP_LOSS)) - assert create_order_mock.call_count == 2 - assert log_has('Could not cancel stoploss order abcd for pair ETH/USDT', caplog) - - -@pytest.mark.parametrize("is_short", [False, True]) -def test_execute_trade_exit_with_stoploss_on_exchange( - default_conf_usdt, ticker_usdt, fee, ticker_usdt_sell_up, is_short, mocker) -> None: - - default_conf_usdt['exchange']['name'] = 'binance' - rpc_mock = patch_RPCManager(mocker) - patch_exchange(mocker) - stoploss = MagicMock(return_value={ - 'id': 123, - 'status': 'open', - 'info': { - 'foo': 'bar' - } - }) - mocker.patch('freqtrade.freqtradebot.FreqtradeBot.handle_order_fee') - - cancel_order = MagicMock(return_value=True) - mocker.patch.multiple( - EXMS, - fetch_ticker=ticker_usdt, - get_fee=fee, - amount_to_precision=lambda s, x, y: y, - price_to_precision=lambda s, x, y: y, - create_stoploss=stoploss, - cancel_stoploss_order=cancel_order, - _dry_is_price_crossed=MagicMock(side_effect=[True, False]), - ) - - freqtrade = FreqtradeBot(default_conf_usdt) - freqtrade.strategy.order_types['stoploss_on_exchange'] = True - patch_get_signal(freqtrade, enter_short=is_short, enter_long=not is_short) - - # Create some test data - freqtrade.enter_positions() - - trade = Trade.session.scalars(select(Trade)).first() - trade.is_short = is_short - assert trade - trades = [trade] - - freqtrade.manage_open_orders() - freqtrade.exit_positions(trades) - - # Increase the price and sell it - mocker.patch.multiple( - EXMS, - fetch_ticker=ticker_usdt_sell_up - ) - - freqtrade.execute_trade_exit( - trade=trade, - limit=ticker_usdt_sell_up()['ask' if is_short else 'bid'], - exit_check=ExitCheckTuple(exit_type=ExitType.STOP_LOSS) - ) - - trade = Trade.session.scalars(select(Trade)).first() - trade.is_short = is_short - assert trade - assert cancel_order.call_count == 1 - assert rpc_mock.call_count == 4 - - -@pytest.mark.parametrize("is_short", [False, True]) -def test_may_execute_trade_exit_after_stoploss_on_exchange_hit( - default_conf_usdt, ticker_usdt, fee, mocker, is_short) -> None: - default_conf_usdt['exchange']['name'] = 'binance' - rpc_mock = patch_RPCManager(mocker) - patch_exchange(mocker) - mocker.patch.multiple( - EXMS, - fetch_ticker=ticker_usdt, - get_fee=fee, - amount_to_precision=lambda s, x, y: y, - price_to_precision=lambda s, x, y: y, - _dry_is_price_crossed=MagicMock(side_effect=[False, True]), - ) - - stoploss = MagicMock(return_value={ - 'id': 123, - 'info': { - 'foo': 'bar' - } - }) - - mocker.patch(f'{EXMS}.create_stoploss', stoploss) - - freqtrade = FreqtradeBot(default_conf_usdt) - freqtrade.strategy.order_types['stoploss_on_exchange'] = True - patch_get_signal(freqtrade, enter_long=not is_short, enter_short=is_short) - - # Create some test data - freqtrade.enter_positions() - freqtrade.manage_open_orders() - trade = Trade.session.scalars(select(Trade)).first() - trades = [trade] - assert trade.has_open_sl_orders is False - - freqtrade.exit_positions(trades) - assert trade - assert trade.has_open_sl_orders is True - assert not trade.has_open_orders - - # Assuming stoploss on exchange is hit - # trade should be sold at the price of stoploss, with exit_reason STOPLOSS_ON_EXCHANGE - stoploss_executed = MagicMock(return_value={ - "id": "123", - "timestamp": 1542707426845, - "datetime": "2018-11-20T09:50:26.845Z", - "lastTradeTimestamp": None, - "symbol": "BTC/USDT", - "type": "stop_loss_limit", - "side": "buy" if is_short else "sell", - "price": 1.08801, - "amount": trade.amount, - "cost": 1.08801 * trade.amount, - "average": 1.08801, - "filled": trade.amount, - "remaining": 0.0, - "status": "closed", - "fee": None, - "trades": None - }) - mocker.patch(f'{EXMS}.fetch_stoploss_order', stoploss_executed) - - freqtrade.exit_positions(trades) - assert trade.has_open_sl_orders is False - assert trade.is_open is False - assert trade.exit_reason == ExitType.STOPLOSS_ON_EXCHANGE.value - assert rpc_mock.call_count == 4 - assert rpc_mock.call_args_list[1][0][0]['type'] == RPCMessageType.ENTRY - assert rpc_mock.call_args_list[1][0][0]['amount'] > 20 - assert rpc_mock.call_args_list[2][0][0]['type'] == RPCMessageType.ENTRY_FILL - assert rpc_mock.call_args_list[3][0][0]['type'] == RPCMessageType.EXIT_FILL @pytest.mark.parametrize( diff --git a/tests/freqtradebot/test_stoploss_on_exchange.py b/tests/freqtradebot/test_stoploss_on_exchange.py new file mode 100644 index 000000000..325fe549f --- /dev/null +++ b/tests/freqtradebot/test_stoploss_on_exchange.py @@ -0,0 +1,1334 @@ +from copy import deepcopy +from datetime import timedelta +from unittest.mock import ANY, MagicMock + +import pytest +from sqlalchemy import select + +from freqtrade.enums import ExitCheckTuple, ExitType, RPCMessageType +from freqtrade.exceptions import ExchangeError, InsufficientFundsError, InvalidOrderException +from freqtrade.freqtradebot import FreqtradeBot +from freqtrade.persistence import Order, Trade +from freqtrade.persistence.models import PairLock +from freqtrade.util.datetime_helpers import dt_now +from tests.conftest import (EXMS, get_patched_freqtradebot, log_has, log_has_re, patch_edge, + patch_exchange, patch_get_signal, patch_whitelist) +from tests.conftest_trades import entry_side, exit_side +from tests.freqtradebot.test_freqtradebot import patch_RPCManager + + +@pytest.mark.parametrize("is_short", [False, True]) +def test_add_stoploss_on_exchange(mocker, default_conf_usdt, limit_order, is_short, fee) -> None: + patch_RPCManager(mocker) + patch_exchange(mocker) + mocker.patch.multiple( + EXMS, + fetch_ticker=MagicMock(return_value={ + 'bid': 1.9, + 'ask': 2.2, + 'last': 1.9 + }), + create_order=MagicMock(return_value=limit_order[entry_side(is_short)]), + get_fee=fee, + ) + order = limit_order[entry_side(is_short)] + mocker.patch('freqtrade.freqtradebot.FreqtradeBot.handle_trade', MagicMock(return_value=True)) + mocker.patch(f'{EXMS}.fetch_order', return_value=order) + mocker.patch(f'{EXMS}.get_trades_for_order', return_value=[]) + + stoploss = MagicMock(return_value={'id': 13434334}) + mocker.patch(f'{EXMS}.create_stoploss', stoploss) + + freqtrade = FreqtradeBot(default_conf_usdt) + freqtrade.strategy.order_types['stoploss_on_exchange'] = True + + patch_get_signal(freqtrade, enter_short=is_short, enter_long=not is_short) + + freqtrade.enter_positions() + trade = Trade.session.scalars(select(Trade)).first() + trade.is_short = is_short + trade.is_open = True + trades = [trade] + + freqtrade.exit_positions(trades) + assert trade.has_open_sl_orders is True + assert stoploss.call_count == 1 + assert trade.is_open is True + + +@pytest.mark.parametrize("is_short", [False, True]) +def test_handle_stoploss_on_exchange(mocker, default_conf_usdt, fee, caplog, is_short, + limit_order) -> None: + stop_order_dict = {'id': "13434334"} + stoploss = MagicMock(return_value=stop_order_dict) + enter_order = limit_order[entry_side(is_short)] + exit_order = limit_order[exit_side(is_short)] + patch_RPCManager(mocker) + patch_exchange(mocker) + mocker.patch.multiple( + EXMS, + fetch_ticker=MagicMock(return_value={ + 'bid': 1.9, + 'ask': 2.2, + 'last': 1.9 + }), + create_order=MagicMock(side_effect=[ + enter_order, + exit_order, + ]), + get_fee=fee, + create_stoploss=stoploss + ) + freqtrade = FreqtradeBot(default_conf_usdt) + patch_get_signal(freqtrade, enter_short=is_short, enter_long=not is_short) + + # First case: when stoploss is not yet set but the order is open + # should get the stoploss order id immediately + # and should return false as no trade actually happened + + freqtrade.enter_positions() + trade = Trade.session.scalars(select(Trade)).first() + assert trade.is_short == is_short + assert trade.is_open + assert trade.has_open_sl_orders is False + + assert freqtrade.handle_stoploss_on_exchange(trade) is False + assert stoploss.call_count == 1 + assert trade.open_sl_orders[-1].order_id == "13434334" + + # Second case: when stoploss is set but it is not yet hit + # should do nothing and return false + trade.is_open = True + + hanging_stoploss_order = MagicMock(return_value={'id': '13434334', 'status': 'open'}) + mocker.patch(f'{EXMS}.fetch_stoploss_order', hanging_stoploss_order) + + assert freqtrade.handle_stoploss_on_exchange(trade) is False + hanging_stoploss_order.assert_called_once_with('13434334', trade.pair) + assert len(trade.open_sl_orders) == 1 + assert trade.open_sl_orders[-1].order_id == "13434334" + + # Third case: when stoploss was set but it was canceled for some reason + # should set a stoploss immediately and return False + caplog.clear() + trade.is_open = True + + canceled_stoploss_order = MagicMock(return_value={'id': '13434334', 'status': 'canceled'}) + mocker.patch(f'{EXMS}.fetch_stoploss_order', canceled_stoploss_order) + stoploss.reset_mock() + amount_before = trade.amount + + stop_order_dict.update({'id': "103_1"}) + + assert freqtrade.handle_stoploss_on_exchange(trade) is False + assert stoploss.call_count == 1 + assert len(trade.open_sl_orders) == 1 + assert trade.open_sl_orders[-1].order_id == "103_1" + assert trade.amount == amount_before + + # Fourth case: when stoploss is set and it is hit + # should return true as a trade actually happened + caplog.clear() + stop_order_dict.update({'id': "103_1"}) + + trade = Trade.session.scalars(select(Trade)).first() + trade.is_short = is_short + trade.is_open = True + + stoploss_order_hit = MagicMock(return_value={ + 'id': "103_1", + 'status': 'closed', + 'type': 'stop_loss_limit', + 'price': 3, + 'average': 2, + 'filled': enter_order['amount'], + 'remaining': 0, + 'amount': enter_order['amount'], + }) + mocker.patch(f'{EXMS}.fetch_stoploss_order', stoploss_order_hit) + assert freqtrade.handle_stoploss_on_exchange(trade) is True + assert log_has_re(r'STOP_LOSS_LIMIT is hit for Trade\(id=1, .*\)\.', caplog) + assert len(trade.open_sl_orders) == 0 + assert trade.is_open is False + caplog.clear() + + mocker.patch(f'{EXMS}.create_stoploss', side_effect=ExchangeError()) + trade.is_open = True + freqtrade.handle_stoploss_on_exchange(trade) + assert log_has('Unable to place a stoploss order on exchange.', caplog) + assert len(trade.open_sl_orders) == 0 + + # Fifth case: fetch_order returns InvalidOrder + # It should try to add stoploss order + stop_order_dict.update({'id': "105"}) + stoploss.reset_mock() + mocker.patch(f'{EXMS}.fetch_stoploss_order', side_effect=InvalidOrderException()) + mocker.patch(f'{EXMS}.create_stoploss', stoploss) + freqtrade.handle_stoploss_on_exchange(trade) + assert len(trade.open_sl_orders) == 1 + assert stoploss.call_count == 1 + + # Sixth case: Closed Trade + # Should not create new order + trade.is_open = False + trade.open_sl_orders[-1].ft_is_open = False + stoploss.reset_mock() + mocker.patch(f'{EXMS}.fetch_order') + mocker.patch(f'{EXMS}.create_stoploss', stoploss) + assert freqtrade.handle_stoploss_on_exchange(trade) is False + assert trade.has_open_sl_orders is False + assert stoploss.call_count == 0 + + +@pytest.mark.parametrize("is_short", [False, True]) +def test_handle_stoploss_on_exchange_emergency(mocker, default_conf_usdt, fee, is_short, + limit_order) -> None: + stop_order_dict = {'id': "13434334"} + stoploss = MagicMock(return_value=stop_order_dict) + enter_order = limit_order[entry_side(is_short)] + exit_order = limit_order[exit_side(is_short)] + patch_RPCManager(mocker) + patch_exchange(mocker) + mocker.patch.multiple( + EXMS, + fetch_ticker=MagicMock(return_value={ + 'bid': 1.9, + 'ask': 2.2, + 'last': 1.9 + }), + create_order=MagicMock(side_effect=[ + enter_order, + exit_order, + ]), + get_fee=fee, + create_stoploss=stoploss + ) + freqtrade = FreqtradeBot(default_conf_usdt) + patch_get_signal(freqtrade, enter_short=is_short, enter_long=not is_short) + + freqtrade.enter_positions() + trade = Trade.session.scalars(select(Trade)).first() + assert trade.is_short == is_short + assert trade.is_open + assert trade.has_open_sl_orders is False + + # emergency exit triggered + # Trailing stop should not act anymore + stoploss_order_cancelled = MagicMock(side_effect=[{ + 'id': "107", + 'status': 'canceled', + 'type': 'stop_loss_limit', + 'price': 3, + 'average': 2, + 'amount': enter_order['amount'], + 'filled': 0, + 'remaining': enter_order['amount'], + 'info': {'stopPrice': 22}, + }]) + trade.stoploss_last_update = dt_now() - timedelta(hours=1) + trade.stop_loss = 24 + trade.exit_reason = None + trade.orders.append( + Order( + ft_order_side='stoploss', + ft_pair=trade.pair, + ft_is_open=True, + ft_amount=trade.amount, + ft_price=trade.stop_loss, + order_id='107', + status='open', + ) + ) + freqtrade.config['trailing_stop'] = True + stoploss = MagicMock(side_effect=InvalidOrderException()) + assert trade.has_open_sl_orders is True + Trade.commit() + mocker.patch(f'{EXMS}.cancel_stoploss_order_with_result', + side_effect=InvalidOrderException()) + mocker.patch(f'{EXMS}.fetch_stoploss_order', stoploss_order_cancelled) + mocker.patch(f'{EXMS}.create_stoploss', stoploss) + assert freqtrade.handle_stoploss_on_exchange(trade) is False + assert trade.has_open_sl_orders is False + assert trade.is_open is False + assert trade.exit_reason == str(ExitType.EMERGENCY_EXIT) + + +@pytest.mark.parametrize("is_short", [False, True]) +def test_handle_stoploss_on_exchange_partial( + mocker, default_conf_usdt, fee, is_short, limit_order) -> None: + stop_order_dict = {'id': "101", "status": "open"} + stoploss = MagicMock(return_value=stop_order_dict) + enter_order = limit_order[entry_side(is_short)] + exit_order = limit_order[exit_side(is_short)] + patch_RPCManager(mocker) + patch_exchange(mocker) + mocker.patch.multiple( + EXMS, + fetch_ticker=MagicMock(return_value={ + 'bid': 1.9, + 'ask': 2.2, + 'last': 1.9 + }), + create_order=MagicMock(side_effect=[ + enter_order, + exit_order, + ]), + get_fee=fee, + create_stoploss=stoploss + ) + freqtrade = FreqtradeBot(default_conf_usdt) + patch_get_signal(freqtrade, enter_short=is_short, enter_long=not is_short) + + freqtrade.enter_positions() + trade = Trade.session.scalars(select(Trade)).first() + trade.is_short = is_short + trade.is_open = True + + assert freqtrade.handle_stoploss_on_exchange(trade) is False + assert stoploss.call_count == 1 + assert trade.has_open_sl_orders is True + assert trade.open_sl_orders[-1].order_id == "101" + assert trade.amount == 30 + stop_order_dict.update({'id': "102"}) + # Stoploss on exchange is cancelled on exchange, but filled partially. + # Must update trade amount to guarantee successful exit. + stoploss_order_hit = MagicMock(return_value={ + 'id': "101", + 'status': 'canceled', + 'type': 'stop_loss_limit', + 'price': 3, + 'average': 2, + 'filled': trade.amount / 2, + 'remaining': trade.amount / 2, + 'amount': enter_order['amount'], + }) + mocker.patch(f'{EXMS}.fetch_stoploss_order', stoploss_order_hit) + assert freqtrade.handle_stoploss_on_exchange(trade) is False + # Stoploss filled partially ... + assert trade.amount == 15 + + assert trade.open_sl_orders[-1].order_id == "102" + + +@pytest.mark.parametrize("is_short", [False, True]) +def test_handle_stoploss_on_exchange_partial_cancel_here( + mocker, default_conf_usdt, fee, is_short, limit_order, caplog, time_machine) -> None: + stop_order_dict = {'id': "101", "status": "open"} + time_machine.move_to(dt_now()) + default_conf_usdt['trailing_stop'] = True + stoploss = MagicMock(return_value=stop_order_dict) + enter_order = limit_order[entry_side(is_short)] + exit_order = limit_order[exit_side(is_short)] + patch_RPCManager(mocker) + patch_exchange(mocker) + mocker.patch.multiple( + EXMS, + fetch_ticker=MagicMock(return_value={ + 'bid': 1.9, + 'ask': 2.2, + 'last': 1.9 + }), + create_order=MagicMock(side_effect=[ + enter_order, + exit_order, + ]), + get_fee=fee, + create_stoploss=stoploss + ) + freqtrade = FreqtradeBot(default_conf_usdt) + patch_get_signal(freqtrade, enter_short=is_short, enter_long=not is_short) + + freqtrade.enter_positions() + trade = Trade.session.scalars(select(Trade)).first() + trade.is_short = is_short + trade.is_open = True + + assert freqtrade.handle_stoploss_on_exchange(trade) is False + assert stoploss.call_count == 1 + assert trade.has_open_sl_orders is True + assert trade.open_sl_orders[-1].order_id == "101" + assert trade.amount == 30 + stop_order_dict.update({'id': "102"}) + # Stoploss on exchange is open. + # Freqtrade cancels the stop - but cancel returns a partial filled order. + stoploss_order_hit = MagicMock(return_value={ + 'id': "101", + 'status': 'open', + 'type': 'stop_loss_limit', + 'price': 3, + 'average': 2, + 'filled': 0, + 'remaining': trade.amount, + 'amount': enter_order['amount'], + }) + stoploss_order_cancel = MagicMock(return_value={ + 'id': "101", + 'status': 'canceled', + 'type': 'stop_loss_limit', + 'price': 3, + 'average': 2, + 'filled': trade.amount / 2, + 'remaining': trade.amount / 2, + 'amount': enter_order['amount'], + }) + mocker.patch(f'{EXMS}.fetch_stoploss_order', stoploss_order_hit) + mocker.patch(f'{EXMS}.cancel_stoploss_order_with_result', stoploss_order_cancel) + time_machine.shift(timedelta(minutes=15)) + + assert freqtrade.handle_stoploss_on_exchange(trade) is False + # Canceled Stoploss filled partially ... + assert log_has_re('Cancelling current stoploss on exchange.*', caplog) + + assert trade.has_open_sl_orders is True + assert trade.open_sl_orders[-1].order_id == "102" + assert trade.amount == 15 + + +@pytest.mark.parametrize("is_short", [False, True]) +def test_handle_sle_cancel_cant_recreate(mocker, default_conf_usdt, fee, caplog, is_short, + limit_order) -> None: + # Sixth case: stoploss order was cancelled but couldn't create new one + enter_order = limit_order[entry_side(is_short)] + exit_order = limit_order[exit_side(is_short)] + patch_RPCManager(mocker) + patch_exchange(mocker) + mocker.patch.multiple( + EXMS, + fetch_ticker=MagicMock(return_value={ + 'bid': 1.9, + 'ask': 2.2, + 'last': 1.9 + }), + create_order=MagicMock(side_effect=[ + enter_order, + exit_order, + ]), + get_fee=fee, + ) + mocker.patch.multiple( + EXMS, + fetch_stoploss_order=MagicMock(return_value={'status': 'canceled', 'id': '100'}), + create_stoploss=MagicMock(side_effect=ExchangeError()), + ) + freqtrade = FreqtradeBot(default_conf_usdt) + patch_get_signal(freqtrade, enter_short=is_short, enter_long=not is_short) + + freqtrade.enter_positions() + trade = Trade.session.scalars(select(Trade)).first() + assert trade.is_short == is_short + trade.is_open = True + trade.orders.append( + Order( + ft_order_side='stoploss', + ft_pair=trade.pair, + ft_is_open=True, + ft_amount=trade.amount, + ft_price=trade.stop_loss, + order_id='100', + status='open', + ) + ) + assert trade + + assert freqtrade.handle_stoploss_on_exchange(trade) is False + assert log_has_re(r'All Stoploss orders are cancelled, but unable to recreate one\.', caplog) + assert trade.has_open_sl_orders is False + assert trade.is_open is True + + +@pytest.mark.parametrize("is_short", [False, True]) +def test_create_stoploss_order_invalid_order( + mocker, default_conf_usdt, caplog, fee, is_short, limit_order +): + open_order = limit_order[entry_side(is_short)] + order = limit_order[exit_side(is_short)] + rpc_mock = patch_RPCManager(mocker) + patch_exchange(mocker) + create_order_mock = MagicMock(side_effect=[ + open_order, + order, + ]) + mocker.patch.multiple( + EXMS, + fetch_ticker=MagicMock(return_value={ + 'bid': 1.9, + 'ask': 2.2, + 'last': 1.9 + }), + create_order=create_order_mock, + get_fee=fee, + ) + mocker.patch.multiple( + EXMS, + fetch_order=MagicMock(return_value={'status': 'canceled'}), + create_stoploss=MagicMock(side_effect=InvalidOrderException()), + ) + freqtrade = FreqtradeBot(default_conf_usdt) + patch_get_signal(freqtrade, enter_short=is_short, enter_long=not is_short) + freqtrade.strategy.order_types['stoploss_on_exchange'] = True + + freqtrade.enter_positions() + trade = Trade.session.scalars(select(Trade)).first() + trade.is_short = is_short + caplog.clear() + rpc_mock.reset_mock() + freqtrade.create_stoploss_order(trade, 200) + assert trade.has_open_sl_orders is False + assert trade.exit_reason == ExitType.EMERGENCY_EXIT.value + assert log_has("Unable to place a stoploss order on exchange. ", caplog) + assert log_has("Exiting the trade forcefully", caplog) + + # Should call a market sell + assert create_order_mock.call_count == 2 + assert create_order_mock.call_args[1]['ordertype'] == 'market' + assert create_order_mock.call_args[1]['pair'] == trade.pair + assert create_order_mock.call_args[1]['amount'] == trade.amount + + # Rpc is sending first buy, then sell + assert rpc_mock.call_count == 2 + assert rpc_mock.call_args_list[0][0][0]['exit_reason'] == ExitType.EMERGENCY_EXIT.value + assert rpc_mock.call_args_list[0][0][0]['order_type'] == 'market' + assert rpc_mock.call_args_list[0][0][0]['type'] == 'exit' + assert rpc_mock.call_args_list[1][0][0]['type'] == 'exit_fill' + + +@pytest.mark.parametrize("is_short", [False, True]) +def test_create_stoploss_order_insufficient_funds( + mocker, default_conf_usdt, caplog, fee, limit_order, is_short +): + exit_order = limit_order[exit_side(is_short)]['id'] + freqtrade = get_patched_freqtradebot(mocker, default_conf_usdt) + + mock_insuf = mocker.patch('freqtrade.freqtradebot.FreqtradeBot.handle_insufficient_funds') + mocker.patch.multiple( + EXMS, + fetch_ticker=MagicMock(return_value={ + 'bid': 1.9, + 'ask': 2.2, + 'last': 1.9 + }), + create_order=MagicMock(side_effect=[ + limit_order[entry_side(is_short)], + exit_order, + ]), + get_fee=fee, + fetch_order=MagicMock(return_value={'status': 'canceled'}), + ) + mocker.patch.multiple( + EXMS, + create_stoploss=MagicMock(side_effect=InsufficientFundsError()), + ) + patch_get_signal(freqtrade, enter_short=is_short, enter_long=not is_short) + freqtrade.strategy.order_types['stoploss_on_exchange'] = True + + freqtrade.enter_positions() + trade = Trade.session.scalars(select(Trade)).first() + trade.is_short = is_short + caplog.clear() + freqtrade.create_stoploss_order(trade, 200) + # stoploss_orderid was empty before + assert trade.has_open_sl_orders is False + assert mock_insuf.call_count == 1 + mock_insuf.reset_mock() + + freqtrade.create_stoploss_order(trade, 200) + # No change to stoploss-orderid + assert trade.has_open_sl_orders is False + assert mock_insuf.call_count == 1 + + +@pytest.mark.parametrize("is_short,bid,ask,stop_price,hang_price", [ + (False, [4.38, 4.16], [4.4, 4.17], ['2.0805', 4.4 * 0.95], 3), + (True, [1.09, 1.21], [1.1, 1.22], ['2.321', 1.09 * 1.05], 1.5), +]) +@pytest.mark.usefixtures("init_persistence") +def test_handle_stoploss_on_exchange_trailing( + mocker, default_conf_usdt, fee, is_short, bid, ask, limit_order, stop_price, hang_price, + time_machine, +) -> None: + # When trailing stoploss is set + enter_order = limit_order[entry_side(is_short)] + exit_order = limit_order[exit_side(is_short)] + stoploss = MagicMock(return_value={'id': '13434334', 'status': 'open'}) + start_dt = dt_now() + time_machine.move_to(start_dt, tick=False) + patch_RPCManager(mocker) + mocker.patch.multiple( + EXMS, + fetch_ticker=MagicMock(return_value={ + 'bid': 2.19, + 'ask': 2.2, + 'last': 2.19, + }), + create_order=MagicMock(side_effect=[ + enter_order, + exit_order, + ]), + get_fee=fee, + ) + mocker.patch.multiple( + EXMS, + create_stoploss=stoploss, + stoploss_adjust=MagicMock(return_value=True), + ) + + # enabling TSL + default_conf_usdt['trailing_stop'] = True + + # disabling ROI + default_conf_usdt['minimal_roi']['0'] = 999999999 + + freqtrade = get_patched_freqtradebot(mocker, default_conf_usdt) + + # enabling stoploss on exchange + freqtrade.strategy.order_types['stoploss_on_exchange'] = True + + # setting stoploss + freqtrade.strategy.stoploss = 0.05 if is_short else -0.05 + + # setting stoploss_on_exchange_interval to 60 seconds + freqtrade.strategy.order_types['stoploss_on_exchange_interval'] = 60 + + patch_get_signal(freqtrade, enter_short=is_short, enter_long=not is_short) + + freqtrade.enter_positions() + trade = Trade.session.scalars(select(Trade)).first() + trade.is_short = is_short + trade.is_open = True + assert trade.has_open_sl_orders is False + trade.stoploss_last_update = dt_now() - timedelta(minutes=20) + trade.orders.append( + Order( + ft_order_side='stoploss', + ft_pair=trade.pair, + ft_is_open=True, + ft_amount=trade.amount, + ft_price=trade.stop_loss, + order_id='100', + order_date=dt_now() - timedelta(minutes=20), + ) + ) + + stoploss_order_hanging = { + 'id': '100', + 'status': 'open', + 'type': 'stop_loss_limit', + 'price': hang_price, + 'average': 2, + 'fee': {}, + 'amount': 0, + 'info': { + 'stopPrice': stop_price[0] + } + } + stoploss_order_cancel = deepcopy(stoploss_order_hanging) + stoploss_order_cancel['status'] = 'canceled' + + mocker.patch(f'{EXMS}.fetch_stoploss_order', return_value=stoploss_order_hanging) + mocker.patch(f'{EXMS}.cancel_stoploss_order', return_value=stoploss_order_cancel) + + # stoploss initially at 5% + assert freqtrade.handle_trade(trade) is False + assert freqtrade.handle_stoploss_on_exchange(trade) is False + + assert len(trade.open_sl_orders) == 1 + + assert trade.open_sl_orders[-1].order_id == '13434334' + + # price jumped 2x + mocker.patch( + f'{EXMS}.fetch_ticker', + MagicMock(return_value={ + 'bid': bid[0], + 'ask': ask[0], + 'last': bid[0], + }) + ) + + cancel_order_mock = MagicMock(return_value={ + 'id': '13434334', 'status': 'canceled', 'fee': {}, 'amount': trade.amount}) + stoploss_order_mock = MagicMock(return_value={'id': 'so1', 'status': 'open'}) + mocker.patch(f'{EXMS}.fetch_stoploss_order') + mocker.patch(f'{EXMS}.cancel_stoploss_order', cancel_order_mock) + mocker.patch(f'{EXMS}.create_stoploss', stoploss_order_mock) + + # stoploss should not be updated as the interval is 60 seconds + assert freqtrade.handle_trade(trade) is False + assert freqtrade.handle_stoploss_on_exchange(trade) is False + assert len(trade.open_sl_orders) == 1 + cancel_order_mock.assert_not_called() + stoploss_order_mock.assert_not_called() + + # Move time by 10s ... so stoploss order should be replaced. + time_machine.move_to(start_dt + timedelta(minutes=10), tick=False) + + assert freqtrade.handle_trade(trade) is False + assert trade.stop_loss == stop_price[1] + + assert freqtrade.handle_stoploss_on_exchange(trade) is False + + cancel_order_mock.assert_called_once_with('13434334', 'ETH/USDT') + stoploss_order_mock.assert_called_once_with( + amount=30, + pair='ETH/USDT', + order_types=freqtrade.strategy.order_types, + stop_price=stop_price[1], + side=exit_side(is_short), + leverage=1.0 + ) + + # price fell below stoploss, so dry-run sells trade. + mocker.patch( + f'{EXMS}.fetch_ticker', + MagicMock(return_value={ + 'bid': bid[1], + 'ask': ask[1], + 'last': bid[1], + }) + ) + mocker.patch(f'{EXMS}.cancel_stoploss_order_with_result', + return_value={'id': 'so1', 'status': 'canceled'}) + assert len(trade.open_sl_orders) == 1 + assert trade.open_sl_orders[-1].order_id == 'so1' + + assert freqtrade.handle_trade(trade) is True + assert trade.is_open is False + assert trade.has_open_sl_orders is False + + +@pytest.mark.parametrize("is_short", [False, True]) +def test_handle_stoploss_on_exchange_trailing_error( + mocker, default_conf_usdt, fee, caplog, limit_order, is_short, time_machine +) -> None: + time_machine.move_to(dt_now() - timedelta(minutes=601)) + enter_order = limit_order[entry_side(is_short)] + exit_order = limit_order[exit_side(is_short)] + # When trailing stoploss is set + stoploss = MagicMock(return_value={'id': '13434334', 'status': 'open'}) + patch_exchange(mocker) + + mocker.patch.multiple( + EXMS, + fetch_ticker=MagicMock(return_value={ + 'bid': 1.9, + 'ask': 2.2, + 'last': 1.9 + }), + create_order=MagicMock(side_effect=[ + {'id': enter_order['id']}, + {'id': exit_order['id']}, + ]), + get_fee=fee, + create_stoploss=stoploss, + stoploss_adjust=MagicMock(return_value=True), + ) + + # enabling TSL + default_conf_usdt['trailing_stop'] = True + + freqtrade = get_patched_freqtradebot(mocker, default_conf_usdt) + # enabling stoploss on exchange + freqtrade.strategy.order_types['stoploss_on_exchange'] = True + + # setting stoploss + freqtrade.strategy.stoploss = 0.05 if is_short else -0.05 + + # setting stoploss_on_exchange_interval to 60 seconds + freqtrade.strategy.order_types['stoploss_on_exchange_interval'] = 60 + patch_get_signal(freqtrade, enter_short=is_short, enter_long=not is_short) + freqtrade.enter_positions() + trade = Trade.session.scalars(select(Trade)).first() + trade.is_short = is_short + trade.is_open = True + trade.stop_loss = 0.2 + + stoploss_order_hanging = { + 'id': "abcd", + 'status': 'open', + 'type': 'stop_loss_limit', + 'price': 3, + 'average': 2, + 'info': { + 'stopPrice': '0.1' + } + } + trade.orders.append( + Order( + ft_order_side='stoploss', + ft_pair=trade.pair, + ft_is_open=True, + ft_amount=trade.amount, + ft_price=3, + order_id='abcd', + order_date=dt_now(), + ) + ) + mocker.patch(f'{EXMS}.cancel_stoploss_order', + side_effect=InvalidOrderException()) + mocker.patch(f'{EXMS}.fetch_stoploss_order', + return_value=stoploss_order_hanging) + time_machine.shift(timedelta(minutes=50)) + freqtrade.handle_trailing_stoploss_on_exchange(trade, stoploss_order_hanging) + assert log_has_re(r"Could not cancel stoploss order abcd for pair ETH/USDT.*", caplog) + + # Still try to create order + assert stoploss.call_count == 1 + # TODO: Is this actually correct ? This will create a new order every time, + assert len(trade.open_sl_orders) == 2 + + # Fail creating stoploss order + caplog.clear() + cancel_mock = mocker.patch(f'{EXMS}.cancel_stoploss_order') + mocker.patch(f'{EXMS}.create_stoploss', side_effect=ExchangeError()) + time_machine.shift(timedelta(minutes=50)) + freqtrade.handle_trailing_stoploss_on_exchange(trade, stoploss_order_hanging) + assert cancel_mock.call_count == 2 + assert log_has_re(r"Could not create trailing stoploss order for pair ETH/USDT\..*", caplog) + + +def test_stoploss_on_exchange_price_rounding( + mocker, default_conf_usdt, fee, open_trade_usdt) -> None: + patch_RPCManager(mocker) + mocker.patch.multiple( + EXMS, + get_fee=fee, + ) + price_mock = MagicMock(side_effect=lambda p, s, **kwargs: int(s)) + stoploss_mock = MagicMock(return_value={'id': '13434334'}) + adjust_mock = MagicMock(return_value=False) + mocker.patch.multiple( + EXMS, + create_stoploss=stoploss_mock, + stoploss_adjust=adjust_mock, + price_to_precision=price_mock, + ) + freqtrade = get_patched_freqtradebot(mocker, default_conf_usdt) + open_trade_usdt.stop_loss = 222.55 + + freqtrade.handle_trailing_stoploss_on_exchange(open_trade_usdt, {}) + assert price_mock.call_count == 1 + assert adjust_mock.call_count == 1 + assert adjust_mock.call_args_list[0][0][0] == 222 + + +@pytest.mark.parametrize("is_short", [False, True]) +@pytest.mark.usefixtures("init_persistence") +def test_handle_stoploss_on_exchange_custom_stop( + mocker, default_conf_usdt, fee, is_short, limit_order +) -> None: + enter_order = limit_order[entry_side(is_short)] + exit_order = limit_order[exit_side(is_short)] + # When trailing stoploss is set + stoploss = MagicMock(return_value={'id': 13434334, 'status': 'open'}) + patch_RPCManager(mocker) + mocker.patch.multiple( + EXMS, + fetch_ticker=MagicMock(return_value={ + 'bid': 1.9, + 'ask': 2.2, + 'last': 1.9 + }), + create_order=MagicMock(side_effect=[ + enter_order, + exit_order, + ]), + get_fee=fee, + is_cancel_order_result_suitable=MagicMock(return_value=True), + ) + mocker.patch.multiple( + EXMS, + create_stoploss=stoploss, + stoploss_adjust=MagicMock(return_value=True), + ) + + # enabling TSL + default_conf_usdt['use_custom_stoploss'] = True + + # disabling ROI + default_conf_usdt['minimal_roi']['0'] = 999999999 + + freqtrade = get_patched_freqtradebot(mocker, default_conf_usdt) + + # enabling stoploss on exchange + freqtrade.strategy.order_types['stoploss_on_exchange'] = True + + # setting stoploss + freqtrade.strategy.custom_stoploss = lambda *args, **kwargs: -0.04 + + # setting stoploss_on_exchange_interval to 60 seconds + freqtrade.strategy.order_types['stoploss_on_exchange_interval'] = 60 + + patch_get_signal(freqtrade, enter_short=is_short, enter_long=not is_short) + + freqtrade.enter_positions() + trade = Trade.session.scalars(select(Trade)).first() + trade.is_short = is_short + trade.is_open = True + trade.orders.append( + Order( + ft_order_side='stoploss', + ft_pair=trade.pair, + ft_is_open=True, + ft_amount=trade.amount, + ft_price=trade.stop_loss, + order_date=dt_now() - timedelta(minutes=601), + order_id='100', + ) + ) + Trade.commit() + slo = { + 'id': '100', + 'status': 'open', + 'type': 'stop_loss_limit', + 'price': 3, + 'average': 2, + 'info': { + 'stopPrice': '2.0805' + } + } + slo_canceled = deepcopy(slo) + slo_canceled.update({'status': 'canceled'}) + + def fetch_stoploss_order_mock(order_id, *args, **kwargs): + x = deepcopy(slo) + x['id'] = order_id + return x + + mocker.patch(f'{EXMS}.fetch_stoploss_order', MagicMock(fetch_stoploss_order_mock)) + mocker.patch(f'{EXMS}.cancel_stoploss_order', return_value=slo_canceled) + + assert freqtrade.handle_trade(trade) is False + assert freqtrade.handle_stoploss_on_exchange(trade) is False + + # price jumped 2x + mocker.patch( + f'{EXMS}.fetch_ticker', + MagicMock(return_value={ + 'bid': 4.38 if not is_short else 1.9 / 2, + 'ask': 4.4 if not is_short else 2.2 / 2, + 'last': 4.38 if not is_short else 1.9 / 2, + }) + ) + + cancel_order_mock = MagicMock() + stoploss_order_mock = MagicMock(return_value={'id': 'so1', 'status': 'open'}) + mocker.patch(f'{EXMS}.cancel_stoploss_order', cancel_order_mock) + mocker.patch(f'{EXMS}.create_stoploss', stoploss_order_mock) + + # stoploss should not be updated as the interval is 60 seconds + assert freqtrade.handle_trade(trade) is False + assert freqtrade.handle_stoploss_on_exchange(trade) is False + cancel_order_mock.assert_not_called() + stoploss_order_mock.assert_not_called() + + assert freqtrade.handle_trade(trade) is False + assert trade.stop_loss == 4.4 * 0.96 if not is_short else 1.1 + assert trade.stop_loss_pct == -0.04 if not is_short else 0.04 + + # setting stoploss_on_exchange_interval to 0 seconds + freqtrade.strategy.order_types['stoploss_on_exchange_interval'] = 0 + cancel_order_mock.assert_not_called() + stoploss_order_mock.assert_not_called() + + assert freqtrade.handle_stoploss_on_exchange(trade) is False + + cancel_order_mock.assert_called_once_with('13434334', 'ETH/USDT') + # Long uses modified ask - offset, short modified bid + offset + stoploss_order_mock.assert_called_once_with( + amount=pytest.approx(trade.amount), + pair='ETH/USDT', + order_types=freqtrade.strategy.order_types, + stop_price=4.4 * 0.96 if not is_short else 0.95 * 1.04, + side=exit_side(is_short), + leverage=1.0 + ) + + # price fell below stoploss, so dry-run sells trade. + mocker.patch( + f'{EXMS}.fetch_ticker', + MagicMock(return_value={ + 'bid': 4.17, + 'ask': 4.19, + 'last': 4.17 + }) + ) + assert freqtrade.handle_trade(trade) is True + + +def test_tsl_on_exchange_compatible_with_edge(mocker, edge_conf, fee, limit_order) -> None: + + enter_order = limit_order['buy'] + exit_order = limit_order['sell'] + enter_order['average'] = 2.19 + # When trailing stoploss is set + stoploss = MagicMock(return_value={'id': '13434334', 'status': 'open'}) + patch_RPCManager(mocker) + patch_exchange(mocker) + patch_edge(mocker) + edge_conf['max_open_trades'] = float('inf') + edge_conf['dry_run_wallet'] = 999.9 + edge_conf['exchange']['name'] = 'binance' + mocker.patch.multiple( + EXMS, + fetch_ticker=MagicMock(return_value={ + 'bid': 2.19, + 'ask': 2.2, + 'last': 2.19 + }), + create_order=MagicMock(side_effect=[ + enter_order, + exit_order, + ]), + get_fee=fee, + create_stoploss=stoploss, + ) + + # enabling TSL + edge_conf['trailing_stop'] = True + edge_conf['trailing_stop_positive'] = 0.01 + edge_conf['trailing_stop_positive_offset'] = 0.011 + + # disabling ROI + edge_conf['minimal_roi']['0'] = 999999999 + + freqtrade = FreqtradeBot(edge_conf) + + # enabling stoploss on exchange + freqtrade.strategy.order_types['stoploss_on_exchange'] = True + + # setting stoploss + freqtrade.strategy.stoploss = -0.02 + + # setting stoploss_on_exchange_interval to 0 seconds + freqtrade.strategy.order_types['stoploss_on_exchange_interval'] = 0 + + patch_get_signal(freqtrade) + + freqtrade.active_pair_whitelist = freqtrade.edge.adjust(freqtrade.active_pair_whitelist) + + freqtrade.enter_positions() + trade = Trade.session.scalars(select(Trade)).first() + trade.is_open = True + + trade.stoploss_last_update = dt_now() + trade.orders.append( + Order( + ft_order_side='stoploss', + ft_pair=trade.pair, + ft_is_open=True, + ft_amount=trade.amount, + ft_price=trade.stop_loss, + order_id='100', + ) + ) + + stoploss_order_hanging = MagicMock(return_value={ + 'id': '100', + 'status': 'open', + 'type': 'stop_loss_limit', + 'price': 3, + 'average': 2, + 'stopPrice': '2.178' + }) + + mocker.patch(f'{EXMS}.fetch_stoploss_order', stoploss_order_hanging) + + # stoploss initially at 20% as edge dictated it. + assert freqtrade.handle_trade(trade) is False + assert freqtrade.handle_stoploss_on_exchange(trade) is False + assert pytest.approx(trade.stop_loss) == 1.76 + + cancel_order_mock = MagicMock() + stoploss_order_mock = MagicMock() + mocker.patch(f'{EXMS}.cancel_stoploss_order', cancel_order_mock) + mocker.patch(f'{EXMS}.create_stoploss', stoploss_order_mock) + + # price goes down 5% + mocker.patch(f'{EXMS}.fetch_ticker', MagicMock(return_value={ + 'bid': 2.19 * 0.95, + 'ask': 2.2 * 0.95, + 'last': 2.19 * 0.95 + })) + assert freqtrade.handle_trade(trade) is False + assert freqtrade.handle_stoploss_on_exchange(trade) is False + + # stoploss should remain the same + assert pytest.approx(trade.stop_loss) == 1.76 + + # stoploss on exchange should not be canceled + cancel_order_mock.assert_not_called() + + # price jumped 2x + mocker.patch(f'{EXMS}.fetch_ticker', MagicMock(return_value={ + 'bid': 4.38, + 'ask': 4.4, + 'last': 4.38 + })) + + assert freqtrade.handle_trade(trade) is False + assert freqtrade.handle_stoploss_on_exchange(trade) is False + + # stoploss should be set to 1% as trailing is on + assert trade.stop_loss == 4.4 * 0.99 + cancel_order_mock.assert_called_once_with('100', 'NEO/BTC') + stoploss_order_mock.assert_called_once_with( + amount=30, + pair='NEO/BTC', + order_types=freqtrade.strategy.order_types, + stop_price=4.4 * 0.99, + side='sell', + leverage=1.0 + ) + + +@pytest.mark.parametrize("is_short", [False, True]) +def test_execute_trade_exit_down_stoploss_on_exchange_dry_run( + default_conf_usdt, ticker_usdt, fee, is_short, ticker_usdt_sell_down, + ticker_usdt_sell_up, mocker) -> None: + rpc_mock = patch_RPCManager(mocker) + patch_exchange(mocker) + mocker.patch.multiple( + EXMS, + fetch_ticker=ticker_usdt, + get_fee=fee, + _dry_is_price_crossed=MagicMock(return_value=False), + ) + patch_whitelist(mocker, default_conf_usdt) + freqtrade = FreqtradeBot(default_conf_usdt) + patch_get_signal(freqtrade, enter_short=is_short, enter_long=not is_short) + + # Create some test data + freqtrade.enter_positions() + + trade = Trade.session.scalars(select(Trade)).first() + assert trade.is_short == is_short + assert trade + + # Decrease the price and sell it + mocker.patch.multiple( + EXMS, + fetch_ticker=ticker_usdt_sell_up if is_short else ticker_usdt_sell_down + ) + + default_conf_usdt['dry_run'] = True + freqtrade.strategy.order_types['stoploss_on_exchange'] = True + # Setting trade stoploss to 0.01 + + trade.stop_loss = 2.0 * 1.01 if is_short else 2.0 * 0.99 + freqtrade.execute_trade_exit( + trade=trade, limit=trade.stop_loss, + exit_check=ExitCheckTuple(exit_type=ExitType.STOP_LOSS)) + + assert rpc_mock.call_count == 2 + last_msg = rpc_mock.call_args_list[-1][0][0] + + assert { + 'type': RPCMessageType.EXIT, + 'trade_id': 1, + 'exchange': 'Binance', + 'pair': 'ETH/USDT', + 'direction': 'Short' if trade.is_short else 'Long', + 'leverage': 1.0, + 'gain': 'loss', + 'limit': 2.02 if is_short else 1.98, + 'order_rate': 2.02 if is_short else 1.98, + 'amount': pytest.approx(29.70297029 if is_short else 30.0), + 'order_type': 'limit', + 'buy_tag': None, + 'enter_tag': None, + 'open_rate': 2.02 if is_short else 2.0, + 'current_rate': 2.2 if is_short else 2.0, + 'profit_amount': -0.3 if is_short else -0.8985, + 'profit_ratio': -0.00501253 if is_short else -0.01493766, + 'stake_currency': 'USDT', + 'quote_currency': 'USDT', + 'fiat_currency': 'USD', + 'base_currency': 'ETH', + 'exit_reason': ExitType.STOP_LOSS.value, + 'open_date': ANY, + 'close_date': ANY, + 'close_rate': ANY, + 'sub_trade': False, + 'cumulative_profit': 0.0, + 'stake_amount': pytest.approx(60), + 'is_final_exit': False, + 'final_profit_ratio': None, + } == last_msg + + +def test_execute_trade_exit_sloe_cancel_exception( + mocker, default_conf_usdt, ticker_usdt, fee, caplog) -> None: + freqtrade = get_patched_freqtradebot(mocker, default_conf_usdt) + mocker.patch(f'{EXMS}.cancel_stoploss_order', side_effect=InvalidOrderException()) + mocker.patch('freqtrade.wallets.Wallets.get_free', MagicMock(return_value=300)) + create_order_mock = MagicMock(side_effect=[ + {'id': '12345554'}, + {'id': '12345555'}, + ]) + patch_exchange(mocker) + mocker.patch.multiple( + EXMS, + fetch_ticker=ticker_usdt, + get_fee=fee, + create_order=create_order_mock, + ) + + freqtrade.strategy.order_types['stoploss_on_exchange'] = True + patch_get_signal(freqtrade) + freqtrade.enter_positions() + + trade = Trade.session.scalars(select(Trade)).first() + PairLock.session = MagicMock() + + freqtrade.config['dry_run'] = False + trade.orders.append( + Order( + ft_order_side='stoploss', + ft_pair=trade.pair, + ft_is_open=True, + ft_amount=trade.amount, + ft_price=trade.stop_loss, + order_id='abcd', + status='open', + ) + ) + + freqtrade.execute_trade_exit(trade=trade, limit=1234, + exit_check=ExitCheckTuple(exit_type=ExitType.STOP_LOSS)) + assert create_order_mock.call_count == 2 + assert log_has('Could not cancel stoploss order abcd for pair ETH/USDT', caplog) + + +@pytest.mark.parametrize("is_short", [False, True]) +def test_execute_trade_exit_with_stoploss_on_exchange( + default_conf_usdt, ticker_usdt, fee, ticker_usdt_sell_up, is_short, mocker) -> None: + + default_conf_usdt['exchange']['name'] = 'binance' + rpc_mock = patch_RPCManager(mocker) + patch_exchange(mocker) + stoploss = MagicMock(return_value={ + 'id': 123, + 'status': 'open', + 'info': { + 'foo': 'bar' + } + }) + mocker.patch('freqtrade.freqtradebot.FreqtradeBot.handle_order_fee') + + cancel_order = MagicMock(return_value=True) + mocker.patch.multiple( + EXMS, + fetch_ticker=ticker_usdt, + get_fee=fee, + amount_to_precision=lambda s, x, y: y, + price_to_precision=lambda s, x, y: y, + create_stoploss=stoploss, + cancel_stoploss_order=cancel_order, + _dry_is_price_crossed=MagicMock(side_effect=[True, False]), + ) + + freqtrade = FreqtradeBot(default_conf_usdt) + freqtrade.strategy.order_types['stoploss_on_exchange'] = True + patch_get_signal(freqtrade, enter_short=is_short, enter_long=not is_short) + + # Create some test data + freqtrade.enter_positions() + + trade = Trade.session.scalars(select(Trade)).first() + trade.is_short = is_short + assert trade + trades = [trade] + + freqtrade.manage_open_orders() + freqtrade.exit_positions(trades) + + # Increase the price and sell it + mocker.patch.multiple( + EXMS, + fetch_ticker=ticker_usdt_sell_up + ) + + freqtrade.execute_trade_exit( + trade=trade, + limit=ticker_usdt_sell_up()['ask' if is_short else 'bid'], + exit_check=ExitCheckTuple(exit_type=ExitType.STOP_LOSS) + ) + + trade = Trade.session.scalars(select(Trade)).first() + trade.is_short = is_short + assert trade + assert cancel_order.call_count == 1 + assert rpc_mock.call_count == 4 + + +@pytest.mark.parametrize("is_short", [False, True]) +def test_may_execute_trade_exit_after_stoploss_on_exchange_hit( + default_conf_usdt, ticker_usdt, fee, mocker, is_short) -> None: + default_conf_usdt['exchange']['name'] = 'binance' + rpc_mock = patch_RPCManager(mocker) + patch_exchange(mocker) + mocker.patch.multiple( + EXMS, + fetch_ticker=ticker_usdt, + get_fee=fee, + amount_to_precision=lambda s, x, y: y, + price_to_precision=lambda s, x, y: y, + _dry_is_price_crossed=MagicMock(side_effect=[False, True]), + ) + + stoploss = MagicMock(return_value={ + 'id': 123, + 'info': { + 'foo': 'bar' + } + }) + + mocker.patch(f'{EXMS}.create_stoploss', stoploss) + + freqtrade = FreqtradeBot(default_conf_usdt) + freqtrade.strategy.order_types['stoploss_on_exchange'] = True + patch_get_signal(freqtrade, enter_long=not is_short, enter_short=is_short) + + # Create some test data + freqtrade.enter_positions() + freqtrade.manage_open_orders() + trade = Trade.session.scalars(select(Trade)).first() + trades = [trade] + assert trade.has_open_sl_orders is False + + freqtrade.exit_positions(trades) + assert trade + assert trade.has_open_sl_orders is True + assert not trade.has_open_orders + + # Assuming stoploss on exchange is hit + # trade should be sold at the price of stoploss, with exit_reason STOPLOSS_ON_EXCHANGE + stoploss_executed = MagicMock(return_value={ + "id": "123", + "timestamp": 1542707426845, + "datetime": "2018-11-20T09:50:26.845Z", + "lastTradeTimestamp": None, + "symbol": "BTC/USDT", + "type": "stop_loss_limit", + "side": "buy" if is_short else "sell", + "price": 1.08801, + "amount": trade.amount, + "cost": 1.08801 * trade.amount, + "average": 1.08801, + "filled": trade.amount, + "remaining": 0.0, + "status": "closed", + "fee": None, + "trades": None + }) + mocker.patch(f'{EXMS}.fetch_stoploss_order', stoploss_executed) + + freqtrade.exit_positions(trades) + assert trade.has_open_sl_orders is False + assert trade.is_open is False + assert trade.exit_reason == ExitType.STOPLOSS_ON_EXCHANGE.value + assert rpc_mock.call_count == 4 + assert rpc_mock.call_args_list[1][0][0]['type'] == RPCMessageType.ENTRY + assert rpc_mock.call_args_list[1][0][0]['amount'] > 20 + assert rpc_mock.call_args_list[2][0][0]['type'] == RPCMessageType.ENTRY_FILL + assert rpc_mock.call_args_list[3][0][0]['type'] == RPCMessageType.EXIT_FILL