diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index 1b6f9b8ae..08c18ee42 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -374,8 +374,7 @@ class FreqtradeBot(LoggingMixin): fo = order.to_ccxt_object() fo['status'] = 'canceled' self.handle_cancel_order( - fo, order.order_id, order.trade, - constants.CANCEL_REASON['TIMEOUT'] + fo, order, order.trade, constants.CANCEL_REASON['TIMEOUT'] ) except ExchangeError as e: @@ -1336,6 +1335,7 @@ class FreqtradeBot(LoggingMixin): :return: None """ for trade in Trade.get_open_trades(): + open_order: Order for open_order in trade.open_orders: try: order = self.exchange.fetch_order(open_order.order_id, trade.pair) @@ -1356,22 +1356,23 @@ class FreqtradeBot(LoggingMixin): ) ): self.handle_cancel_order( - order, open_order.order_id, trade, constants.CANCEL_REASON['TIMEOUT'] + order, open_order, trade, constants.CANCEL_REASON['TIMEOUT'] ) else: self.replace_order(order, open_order, trade) - def handle_cancel_order(self, order: Dict, order_id: str, trade: Trade, reason: str) -> None: + def handle_cancel_order(self, order: Dict, order_obj: Order, trade: Trade, reason: str) -> None: """ Check if current analyzed order timed out and cancel if necessary. :param order: Order dict grabbed with exchange.fetch_order() + :param order_obj: Order object from the database. :param trade: Trade object. :return: None """ if order['side'] == trade.entry_side: - self.handle_cancel_enter(trade, order, order_id, reason) + self.handle_cancel_enter(trade, order, order_obj, reason) else: - canceled = self.handle_cancel_exit(trade, order, order_id, reason) + canceled = self.handle_cancel_exit(trade, order, order_obj, reason) canceled_count = trade.get_canceled_exit_order_count() max_timeouts = self.config.get('unfilledtimeout', {}).get('exit_timeout_count', 0) if (canceled and max_timeouts > 0 and canceled_count >= max_timeouts): @@ -1445,7 +1446,7 @@ class FreqtradeBot(LoggingMixin): cancel_reason = constants.CANCEL_REASON['USER_CANCEL'] if order_obj.price != adjusted_entry_price: # cancel existing order if new price is supplied or None - res = self.handle_cancel_enter(trade, order, order_obj.order_id, cancel_reason, + res = self.handle_cancel_enter(trade, order, order_obj, cancel_reason, replacing=replacing) if not res: self.replace_order_failed( @@ -1486,25 +1487,27 @@ class FreqtradeBot(LoggingMixin): if order['side'] == trade.entry_side: self.handle_cancel_enter( - trade, order, open_order.order_id, constants.CANCEL_REASON['ALL_CANCELLED'] + trade, order, open_order, constants.CANCEL_REASON['ALL_CANCELLED'] ) elif order['side'] == trade.exit_side: self.handle_cancel_exit( - trade, order, open_order.order_id, constants.CANCEL_REASON['ALL_CANCELLED'] + trade, order, open_order, constants.CANCEL_REASON['ALL_CANCELLED'] ) Trade.commit() def handle_cancel_enter( - self, trade: Trade, order: Dict, order_id: str, + self, trade: Trade, order: Dict, order_obj: Order, reason: str, replacing: Optional[bool] = False ) -> bool: """ entry cancel - cancel order + :param order_obj: Order object from the database. :param replacing: Replacing order - prevent trade deletion. :return: True if trade was fully cancelled """ was_trade_fully_canceled = False + order_id = order_obj.order_id side = trade.entry_side.capitalize() if order['status'] not in constants.NON_OPEN_EXCHANGE_STATES: @@ -1518,8 +1521,8 @@ class FreqtradeBot(LoggingMixin): f"Order {order_id} for {trade.pair} not cancelled, " f"as the filled amount of {filled_val} would result in an unexitable trade.") return False - corder = self.exchange.cancel_order_with_result(order_id, trade.pair, - trade.amount) + corder = self.exchange.cancel_order_with_result(order_id, trade.pair, trade.amount) + order_obj.ft_cancel_reason = reason # if replacing, retry fetching the order 3 times if the status is not what we need if replacing: retry_count = 0 @@ -1540,9 +1543,10 @@ class FreqtradeBot(LoggingMixin): else: # Order was cancelled already, so we can reuse the existing dict corder = order - reason = constants.CANCEL_REASON['CANCELLED_ON_EXCHANGE'] + if order_obj.ft_cancel_reason is None: + order_obj.ft_cancel_reason = constants.CANCEL_REASON['CANCELLED_ON_EXCHANGE'] - logger.info(f'{side} order {reason} for {trade}.') + logger.info(f'{side} order {order_obj.ft_cancel_reason} for {trade}.') # Using filled to determine the filled amount filled_amount = safe_value_fallback2(corder, order, 'filled', 'filled') @@ -1555,7 +1559,7 @@ class FreqtradeBot(LoggingMixin): if open_order_count < 1 and trade.nr_of_successful_entries == 0 and not replacing: logger.info(f'{side} order fully cancelled. Removing {trade} from database.') trade.delete() - reason += f", {constants.CANCEL_REASON['FULLY_CANCELLED']}" + order_obj.ft_cancel_reason += f", {constants.CANCEL_REASON['FULLY_CANCELLED']}" else: self.update_trade_state(trade, order_id, corder) logger.info(f'{side} Order timeout for {trade}.') @@ -1565,21 +1569,21 @@ class FreqtradeBot(LoggingMixin): self.update_trade_state(trade, order_id, corder) logger.info(f'Partial {trade.entry_side} order timeout for {trade}.') - reason += f", {constants.CANCEL_REASON['PARTIALLY_FILLED']}" + order_obj.ft_cancel_reason += f", {constants.CANCEL_REASON['PARTIALLY_FILLED']}" self.wallets.update() self._notify_enter_cancel(trade, order_type=self.strategy.order_types['entry'], - reason=reason) + reason=order_obj.ft_cancel_reason) return was_trade_fully_canceled def handle_cancel_exit( - self, trade: Trade, order: Dict, order_id: str, - reason: str + self, trade: Trade, order: Dict, order_obj: Order, reason: str ) -> bool: """ exit order cancel - cancel order and update trade :return: True if exit order was cancelled, false otherwise """ + order_id = order_obj.order_id cancelled = False # Cancelled orders may have the status of 'canceled' or 'closed' if order['status'] not in constants.NON_OPEN_EXCHANGE_STATES: @@ -1604,7 +1608,7 @@ class FreqtradeBot(LoggingMixin): sub_trade=trade.amount != order['amount'] ) return False - + order_obj.ft_cancel_reason = reason try: order = self.exchange.cancel_order_with_result( order['id'], trade.pair, trade.amount) @@ -1623,19 +1627,22 @@ class FreqtradeBot(LoggingMixin): trade.exit_reason = exit_reason_prev cancelled = True else: - reason = constants.CANCEL_REASON['CANCELLED_ON_EXCHANGE'] + if order_obj.ft_cancel_reason is None: + order_obj.ft_cancel_reason = constants.CANCEL_REASON['CANCELLED_ON_EXCHANGE'] trade.exit_reason = None self.update_trade_state(trade, order['id'], order) - logger.info(f'{trade.exit_side.capitalize()} order {reason} for {trade}.') + logger.info( + f'{trade.exit_side.capitalize()} order {order_obj.ft_cancel_reason} for {trade}.') trade.close_rate = None trade.close_rate_requested = None self._notify_exit_cancel( trade, order_type=self.strategy.order_types['exit'], - reason=reason, order_id=order['id'], sub_trade=trade.amount != order['amount'] + reason=order_obj.ft_cancel_reason, order_id=order['id'], + sub_trade=trade.amount != order['amount'] ) return cancelled diff --git a/freqtrade/persistence/migrations.py b/freqtrade/persistence/migrations.py index 69d37530f..717a13f90 100644 --- a/freqtrade/persistence/migrations.py +++ b/freqtrade/persistence/migrations.py @@ -220,6 +220,7 @@ def migrate_orders_table(engine, table_back_name: str, cols_order: List): funding_fee = get_column_def(cols_order, 'funding_fee', '0.0') ft_amount = get_column_def(cols_order, 'ft_amount', 'coalesce(amount, 0.0)') ft_price = get_column_def(cols_order, 'ft_price', 'coalesce(price, 0.0)') + ft_cancel_reason = get_column_def(cols_order, 'ft_cancel_reason', 'null') # sqlite does not support literals for booleans with engine.begin() as connection: @@ -227,13 +228,13 @@ def migrate_orders_table(engine, table_back_name: str, cols_order: List): insert into orders (id, ft_trade_id, ft_order_side, ft_pair, ft_is_open, order_id, status, symbol, order_type, side, price, amount, filled, average, remaining, cost, stop_price, order_date, order_filled_date, order_update_date, ft_fee_base, funding_fee, - ft_amount, ft_price + ft_amount, ft_price, ft_cancel_reason ) select id, ft_trade_id, ft_order_side, ft_pair, ft_is_open, order_id, status, symbol, order_type, side, price, amount, filled, {average} average, remaining, cost, {stop_price} stop_price, order_date, order_filled_date, order_update_date, {ft_fee_base} ft_fee_base, {funding_fee} funding_fee, - {ft_amount} ft_amount, {ft_price} ft_price + {ft_amount} ft_amount, {ft_price} ft_price, {ft_cancel_reason} ft_cancel_reason from {table_back_name} """)) @@ -328,8 +329,8 @@ def check_migrate(engine, decl_base, previous_tables) -> None: # if ('orders' not in previous_tables # or not has_column(cols_orders, 'funding_fee')): migrating = False - # if not has_column(cols_orders, 'ft_price'): - if not has_column(cols_trades, 'is_stop_loss_trailing'): + # if not has_column(cols_trades, 'is_stop_loss_trailing'): + if not has_column(cols_orders, 'ft_cancel_reason'): migrating = True logger.info(f"Running database migration for trades - " f"backup: {table_back_name}, {order_table_bak_name}") diff --git a/freqtrade/persistence/trade_model.py b/freqtrade/persistence/trade_model.py index ea48d5466..3a90271d5 100644 --- a/freqtrade/persistence/trade_model.py +++ b/freqtrade/persistence/trade_model.py @@ -68,6 +68,7 @@ class Order(ModelBase): ft_is_open: Mapped[bool] = mapped_column(nullable=False, default=True, index=True) ft_amount: Mapped[float] = mapped_column(Float(), nullable=False) ft_price: Mapped[float] = mapped_column(Float(), nullable=False) + ft_cancel_reason: Mapped[str] = mapped_column(String(CUSTOM_TAG_MAX_LENGTH), nullable=True) order_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True) status: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) diff --git a/freqtrade/rpc/rpc.py b/freqtrade/rpc/rpc.py index 0abac3975..ef789db52 100644 --- a/freqtrade/rpc/rpc.py +++ b/freqtrade/rpc/rpc.py @@ -795,14 +795,14 @@ class RPC: if order['side'] == trade.entry_side: fully_canceled = self._freqtrade.handle_cancel_enter( - trade, order, oo.order_id, CANCEL_REASON['FORCE_EXIT']) + trade, order, oo, CANCEL_REASON['FORCE_EXIT']) trade_entry_cancelation_res['cancel_state'] = fully_canceled trade_entry_cancelation_registry.append(trade_entry_cancelation_res) if order['side'] == trade.exit_side: # Cancel order - so it is placed anew with a fresh price. self._freqtrade.handle_cancel_exit( - trade, order, oo.order_id, CANCEL_REASON['FORCE_EXIT']) + trade, order, oo, CANCEL_REASON['FORCE_EXIT']) if all(tocr['cancel_state'] is False for tocr in trade_entry_cancelation_registry): if trade.has_open_orders: @@ -955,7 +955,7 @@ class RPC: logger.info(f"Cannot query order for {trade} due to {e}.", exc_info=True) raise RPCException("Order not found.") self._freqtrade.handle_cancel_order( - order, open_order.order_id, trade, CANCEL_REASON['USER_CANCEL']) + order, open_order, trade, CANCEL_REASON['USER_CANCEL']) Trade.commit() def _rpc_delete(self, trade_id: int) -> Dict[str, Union[str, int]]: diff --git a/tests/test_freqtradebot.py b/tests/test_freqtradebot.py index cb6674ea8..67a6668d0 100644 --- a/tests/test_freqtradebot.py +++ b/tests/test_freqtradebot.py @@ -3443,20 +3443,20 @@ def test_handle_cancel_enter(mocker, caplog, default_conf_usdt, limit_order, is_ l_order['filled'] = 0.0 l_order['status'] = 'open' reason = CANCEL_REASON['TIMEOUT'] - assert freqtrade.handle_cancel_enter(trade, l_order, trade.open_orders_ids[0], reason) + assert freqtrade.handle_cancel_enter(trade, l_order, trade.open_orders[0], reason) assert cancel_order_mock.call_count == 1 cancel_order_mock.reset_mock() caplog.clear() l_order['filled'] = 0.01 - assert not freqtrade.handle_cancel_enter(trade, l_order, trade.open_orders_ids[0], reason) + assert not freqtrade.handle_cancel_enter(trade, l_order, trade.open_orders[0], reason) assert cancel_order_mock.call_count == 0 assert log_has_re("Order .* for .* not cancelled, as the filled amount.* unexitable.*", caplog) caplog.clear() cancel_order_mock.reset_mock() l_order['filled'] = 2 - assert not freqtrade.handle_cancel_enter(trade, l_order, trade.open_orders_ids[0], reason) + assert not freqtrade.handle_cancel_enter(trade, l_order, trade.open_orders[0], reason) assert cancel_order_mock.call_count == 1 # Order remained open for some reason (cancel failed) @@ -3464,12 +3464,12 @@ def test_handle_cancel_enter(mocker, caplog, default_conf_usdt, limit_order, is_ cancel_order_mock = MagicMock(return_value=cancel_entry_order) mocker.patch(f'{EXMS}.cancel_order_with_result', cancel_order_mock) - assert not freqtrade.handle_cancel_enter(trade, l_order, trade.open_orders_ids[0], reason) + assert not freqtrade.handle_cancel_enter(trade, l_order, trade.open_orders[0], reason) assert log_has_re(r"Order .* for .* not cancelled.", caplog) # min_pair_stake empty should not crash mocker.patch(f'{EXMS}.get_min_pair_stake_amount', return_value=None) assert not freqtrade.handle_cancel_enter( - trade, limit_order[entry_side(is_short)], trade.open_orders_ids[0], reason + trade, limit_order[entry_side(is_short)], trade.open_orders[0], reason ) # Retry ... @@ -3480,7 +3480,7 @@ def test_handle_cancel_enter(mocker, caplog, default_conf_usdt, limit_order, is_ co_mock = mocker.patch(f'{EXMS}.cancel_order_with_result', return_value=cbo) fo_mock = mocker.patch(f'{EXMS}.fetch_order', return_value=cbo) assert not freqtrade.handle_cancel_enter( - trade, cbo, cbo['id'], reason, replacing=True + trade, cbo, trade.open_orders[0], reason, replacing=True ) assert co_mock.call_count == 1 assert fo_mock.call_count == 3 @@ -3505,7 +3505,7 @@ def test_handle_cancel_enter_exchanges(mocker, caplog, default_conf_usdt, is_sho Trade.session.add(trade) Trade.commit() assert freqtrade.handle_cancel_enter( - trade, limit_buy_order_canceled_empty, trade.open_orders_ids[0], reason + trade, limit_buy_order_canceled_empty, trade.open_orders[0], reason ) assert cancel_order_mock.call_count == 0 assert log_has_re( @@ -3543,7 +3543,7 @@ def test_handle_cancel_enter_corder_empty(mocker, default_conf_usdt, limit_order l_order['filled'] = 0.0 l_order['status'] = 'open' reason = CANCEL_REASON['TIMEOUT'] - assert freqtrade.handle_cancel_enter(trade, l_order, trade.open_orders_ids[0], reason) + assert freqtrade.handle_cancel_enter(trade, l_order, trade.open_orders[0], reason) assert cancel_order_mock.call_count == 1 cancel_order_mock.reset_mock() @@ -3551,7 +3551,7 @@ def test_handle_cancel_enter_corder_empty(mocker, default_conf_usdt, limit_order order = deepcopy(l_order) order['status'] = 'canceled' mocker.patch(f'{EXMS}.fetch_order', return_value=order) - assert not freqtrade.handle_cancel_enter(trade, l_order, trade.open_orders_ids[0], reason) + assert not freqtrade.handle_cancel_enter(trade, l_order, trade.open_orders[0], reason) assert cancel_order_mock.call_count == 1 @@ -3632,8 +3632,9 @@ def test_handle_cancel_exit_limit(mocker, default_conf_usdt, fee, is_short, 'amount': 1, 'status': "open"} reason = CANCEL_REASON['TIMEOUT'] + order_obj = trade.open_orders[-1] send_msg_mock.reset_mock() - assert freqtrade.handle_cancel_exit(trade, order, order['id'], reason) + assert freqtrade.handle_cancel_exit(trade, order, order_obj, reason) assert cancel_order_mock.call_count == 1 assert send_msg_mock.call_count == 1 assert trade.close_rate is None @@ -3645,14 +3646,14 @@ def test_handle_cancel_exit_limit(mocker, default_conf_usdt, fee, is_short, # Partial exit - below exit threshold order['amount'] = amount * leverage order['filled'] = amount * 0.99 * leverage - assert not freqtrade.handle_cancel_exit(trade, order, order['id'], reason) + assert not freqtrade.handle_cancel_exit(trade, order, order_obj, reason) # Assert cancel_order was not called (callcount remains unchanged) assert cancel_order_mock.call_count == 1 assert send_msg_mock.call_count == 1 assert (send_msg_mock.call_args_list[0][0][0]['reason'] == CANCEL_REASON['PARTIALLY_FILLED_KEEP_OPEN']) - assert not freqtrade.handle_cancel_exit(trade, order, order['id'], reason) + assert not freqtrade.handle_cancel_exit(trade, order, order_obj, reason) assert (send_msg_mock.call_args_list[0][0][0]['reason'] == CANCEL_REASON['PARTIALLY_FILLED_KEEP_OPEN']) @@ -3664,7 +3665,7 @@ def test_handle_cancel_exit_limit(mocker, default_conf_usdt, fee, is_short, send_msg_mock.reset_mock() order['filled'] = amount * 0.5 * leverage - assert freqtrade.handle_cancel_exit(trade, order, order['id'], reason) + assert freqtrade.handle_cancel_exit(trade, order, order_obj, reason) assert send_msg_mock.call_count == 1 assert (send_msg_mock.call_args_list[0][0][0]['reason'] == CANCEL_REASON['PARTIALLY_FILLED']) @@ -3680,13 +3681,14 @@ def test_handle_cancel_exit_cancel_exception(mocker, default_conf_usdt) -> None: # TODO: should not be magicmock trade = MagicMock() - order_id = '125' + order_obj = MagicMock() + order_obj.order_id = '125' reason = CANCEL_REASON['TIMEOUT'] order = {'remaining': 1, 'id': '125', 'amount': 1, 'status': "open"} - assert not freqtrade.handle_cancel_exit(trade, order, order_id, reason) + assert not freqtrade.handle_cancel_exit(trade, order, order_obj, reason) # mocker.patch(f'{EXMS}.cancel_order_with_result', return_value=order) # assert not freqtrade.handle_cancel_exit(trade, order, reason)