mirror of
https://github.com/freqtrade/freqtrade.git
synced 2025-11-29 08:33:07 +00:00
Merge pull request #9276 from freqtrade/cancel_behavior
Improve Cancel order messaging behavior
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]]:
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user