Repository: freqtrade/freqtrade

Issue:
shutdown should cancel open orders
## Proposal

When stopping the bot (via ctrl+c, `/stop`) open orders are kept open and will be filled by the exchange "whenever" the market is ready to.
This can be dangerous should the bot be stopped due to a sudden market crash

A proper shutdown should allow the following (configurable!):

- [ ] cancel unfilled open orders
- [ ] force-close all open orders

Comments, suggestions, Pullrequests in this regard are welcome :wink: 


Discussion:
I recommend this to be added to the config file as an option that can be toggle on or off according to what people need.

Resolving patch:
diff --git a/freqtrade/constants.py b/freqtrade/constants.py
--- a/freqtrade/constants.py
+++ b/freqtrade/constants.py
@@ -88,6 +88,7 @@
         'fiat_display_currency': {'type': 'string', 'enum': SUPPORTED_FIAT},
         'dry_run': {'type': 'boolean'},
         'dry_run_wallet': {'type': 'number', 'default': DRY_RUN_WALLET},
+        'cancel_open_orders_on_exit': {'type': 'boolean', 'default': False},
         'process_only_new_candles': {'type': 'boolean'},
         'minimal_roi': {
             'type': 'object',
@@ -321,3 +322,10 @@
     'dataformat_ohlcv',
     'dataformat_trades',
 ]
+
+CANCEL_REASON = {
+    "TIMEOUT": "cancelled due to timeout",
+    "PARTIALLY_FILLED": "partially filled - keeping order open",
+    "ALL_CANCELLED": "cancelled (all unfilled and partially filled open orders cancelled)",
+    "CANCELLED_ON_EXCHANGE": "cancelled on exchange",
+}
diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py
--- a/freqtrade/freqtradebot.py
+++ b/freqtrade/freqtradebot.py
@@ -116,6 +116,9 @@ def cleanup(self) -> None:
         """
         logger.info('Cleaning up modules ...')
 
+        if self.config['cancel_open_orders_on_exit']:
+            self.cancel_all_open_orders()
+
         self.rpc.cleanup()
         persistence.cleanup()
 
@@ -165,6 +168,13 @@ def process(self) -> None:
 
         Trade.session.flush()
 
+    def process_stopped(self) -> None:
+        """
+        Close all orders that were left open
+        """
+        if self.config['cancel_open_orders_on_exit']:
+            self.cancel_all_open_orders()
+
     def _refresh_whitelist(self, trades: List[Trade] = []) -> List[str]:
         """
         Refresh whitelist from pairlist or edge and extend it with trades.
@@ -878,11 +888,7 @@ def check_handle_timedout(self) -> None:
                                              default_retval=False)(pair=trade.pair,
                                                                    trade=trade,
                                                                    order=order))):
-
-                self.handle_timedout_limit_buy(trade, order)
-                self.wallets.update()
-                order_type = self.strategy.order_types['buy']
-                self._notify_buy_cancel(trade, order_type)
+                self.handle_cancel_buy(trade, order, constants.CANCEL_REASON['TIMEOUT'])
 
             elif (order['side'] == 'sell' and (
                   trade_state_update
@@ -891,25 +897,43 @@ def check_handle_timedout(self) -> None:
                                            default_retval=False)(pair=trade.pair,
                                                                  trade=trade,
                                                                  order=order))):
-                reason = self.handle_timedout_limit_sell(trade, order)
-                self.wallets.update()
-                order_type = self.strategy.order_types['sell']
-                self._notify_sell_cancel(trade, order_type, reason)
+                self.handle_cancel_sell(trade, order, constants.CANCEL_REASON['TIMEOUT'])
+
+    def cancel_all_open_orders(self) -> None:
+        """
+        Cancel all orders that are currently open
+        :return: None
+        """
+
+        for trade in Trade.get_open_order_trades():
+            try:
+                order = self.exchange.get_order(trade.open_order_id, trade.pair)
+            except (DependencyException, InvalidOrderException):
+                logger.info('Cannot query order for %s due to %s', trade, traceback.format_exc())
+                continue
+
+            if order['side'] == 'buy':
+                self.handle_cancel_buy(trade, order, constants.CANCEL_REASON['ALL_CANCELLED'])
 
-    def handle_timedout_limit_buy(self, trade: Trade, order: Dict) -> bool:
+            elif order['side'] == 'sell':
+                self.handle_cancel_sell(trade, order, constants.CANCEL_REASON['ALL_CANCELLED'])
+
+    def handle_cancel_buy(self, trade: Trade, order: Dict, reason: str) -> bool:
         """
-        Buy timeout - cancel order
+        Buy cancel - cancel order
         :return: True if order was fully cancelled
         """
+        was_trade_fully_canceled = False
+
         # Cancelled orders may have the status of 'canceled' or 'closed'
         if order['status'] not in ('canceled', 'closed'):
-            reason = "cancelled due to timeout"
+            reason = constants.CANCEL_REASON['TIMEOUT']
             corder = self.exchange.cancel_order_with_result(trade.open_order_id, trade.pair,
                                                             trade.amount)
         else:
             # Order was cancelled already, so we can reuse the existing dict
             corder = order
-            reason = "cancelled on exchange"
+            reason = constants.CANCEL_REASON['CANCELLED_ON_EXCHANGE']
 
         logger.info('Buy order %s for %s.', reason, trade)
 
@@ -921,43 +945,45 @@ def handle_timedout_limit_buy(self, trade: Trade, order: Dict) -> bool:
             # if trade is not partially completed, just delete the trade
             Trade.session.delete(trade)
             Trade.session.flush()
-            return True
+            was_trade_fully_canceled = True
+        else:
+            # if trade is partially complete, edit the stake details for the trade
+            # and close the order
+            # cancel_order may not contain the full order dict, so we need to fallback
+            # to the order dict aquired before cancelling.
+            # we need to fall back to the values from order if corder does not contain these keys.
+            trade.amount = filled_amount
+            trade.stake_amount = trade.amount * trade.open_rate
+            self.update_trade_state(trade, corder, trade.amount)
 
-        # if trade is partially complete, edit the stake details for the trade
-        # and close the order
-        # cancel_order may not contain the full order dict, so we need to fallback
-        # to the order dict aquired before cancelling.
-        # we need to fall back to the values from order if corder does not contain these keys.
-        trade.amount = filled_amount
-        trade.stake_amount = trade.amount * trade.open_rate
-        self.update_trade_state(trade, corder, trade.amount)
-
-        trade.open_order_id = None
-        logger.info('Partial buy order timeout for %s.', trade)
-        self.rpc.send_msg({
-            'type': RPCMessageType.STATUS_NOTIFICATION,
-            'status': f'Remaining buy order for {trade.pair} cancelled due to timeout'
-        })
-        return False
+            trade.open_order_id = None
+            logger.info('Partial buy order timeout for %s.', trade)
+            self.rpc.send_msg({
+                'type': RPCMessageType.STATUS_NOTIFICATION,
+                'status': f'Remaining buy order for {trade.pair} cancelled due to timeout'
+            })
+
+        self.wallets.update()
+        self._notify_buy_cancel(trade, order_type=self.strategy.order_types['buy'])
+        return was_trade_fully_canceled
 
-    def handle_timedout_limit_sell(self, trade: Trade, order: Dict) -> str:
+    def handle_cancel_sell(self, trade: Trade, order: Dict, reason: str) -> str:
         """
-        Sell timeout - cancel order and update trade
+        Sell cancel - cancel order and update trade
         :return: Reason for cancel
         """
-        # if trade is not partially completed, just cancel the trade
+        # if trade is not partially completed, just cancel the order
         if order['remaining'] == order['amount'] or order.get('filled') == 0.0:
             if not self.exchange.check_order_canceled_empty(order):
-                reason = "cancelled due to timeout"
                 try:
-                    # if trade is not partially completed, just delete the trade
+                    # if trade is not partially completed, just delete the order
                     self.exchange.cancel_order(trade.open_order_id, trade.pair)
                 except InvalidOrderException:
                     logger.exception(f"Could not cancel sell order {trade.open_order_id}")
                     return 'error cancelling order'
                 logger.info('Sell order %s for %s.', reason, trade)
             else:
-                reason = "cancelled on exchange"
+                reason = constants.CANCEL_REASON['CANCELLED_ON_EXCHANGE']
                 logger.info('Sell order %s for %s.', reason, trade)
 
             trade.close_rate = None
@@ -967,11 +993,17 @@ def handle_timedout_limit_sell(self, trade: Trade, order: Dict) -> str:
             trade.close_date = None
             trade.is_open = True
             trade.open_order_id = None
+        else:
+            # TODO: figure out how to handle partially complete sell orders
+            reason = constants.CANCEL_REASON['PARTIALLY_FILLED']
 
-            return reason
-
-        # TODO: figure out how to handle partially complete sell orders
-        return 'partially filled - keeping order open'
+        self.wallets.update()
+        self._notify_sell_cancel(
+            trade,
+            order_type=self.strategy.order_types['sell'],
+            reason=reason
+        )
+        return reason
 
     def _safe_sell_amount(self, pair: str, amount: float) -> float:
         """
diff --git a/freqtrade/worker.py b/freqtrade/worker.py
--- a/freqtrade/worker.py
+++ b/freqtrade/worker.py
@@ -131,8 +131,7 @@ def _throttle(self, func: Callable[..., Any], throttle_secs: float, *args, **kwa
         return result
 
     def _process_stopped(self) -> None:
-        # Maybe do here something in the future...
-        pass
+        self.freqtrade.process_stopped()
 
     def _process_running(self) -> None:
         try:


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -249,6 +249,7 @@ def default_conf(testdatadir):
         "fiat_display_currency": "USD",
         "ticker_interval": '5m',
         "dry_run": True,
+        "cancel_open_orders_on_exit": False,
         "minimal_roi": {
             "40": 0.0,
             "30": 0.01,
diff --git a/tests/test_freqtradebot.py b/tests/test_freqtradebot.py
--- a/tests/test_freqtradebot.py
+++ b/tests/test_freqtradebot.py
@@ -11,7 +11,7 @@
 import pytest
 import requests
 
-from freqtrade.constants import MATH_CLOSE_PREC, UNLIMITED_STAKE_AMOUNT
+from freqtrade.constants import MATH_CLOSE_PREC, UNLIMITED_STAKE_AMOUNT, CANCEL_REASON
 from freqtrade.exceptions import (DependencyException, InvalidOrderException,
                                   OperationalException, TemporaryError)
 from freqtrade.freqtradebot import FreqtradeBot
@@ -22,7 +22,7 @@
 from freqtrade.worker import Worker
 from tests.conftest import (get_patched_freqtradebot, get_patched_worker,
                             log_has, log_has_re, patch_edge, patch_exchange,
-                            patch_get_signal, patch_wallet, patch_whitelist)
+                            patch_get_signal, patch_wallet, patch_whitelist, create_mock_trades)
 
 
 def patch_RPCManager(mocker) -> MagicMock:
@@ -48,13 +48,31 @@ def test_freqtradebot_state(mocker, default_conf, markets) -> None:
     assert freqtrade.state is State.STOPPED
 
 
-def test_cleanup(mocker, default_conf, caplog) -> None:
-    mock_cleanup = MagicMock()
-    mocker.patch('freqtrade.persistence.cleanup', mock_cleanup)
+def test_process_stopped(mocker, default_conf) -> None:
+
+    freqtrade = get_patched_freqtradebot(mocker, default_conf)
+    coo_mock = mocker.patch('freqtrade.freqtradebot.FreqtradeBot.cancel_all_open_orders')
+    freqtrade.process_stopped()
+    assert coo_mock.call_count == 0
+
+    default_conf['cancel_open_orders_on_exit'] = True
+    freqtrade = get_patched_freqtradebot(mocker, default_conf)
+    freqtrade.process_stopped()
+    assert coo_mock.call_count == 1
+
+
+def test_bot_cleanup(mocker, default_conf, caplog) -> None:
+    mock_cleanup = mocker.patch('freqtrade.persistence.cleanup')
+    coo_mock = mocker.patch('freqtrade.freqtradebot.FreqtradeBot.cancel_all_open_orders')
     freqtrade = get_patched_freqtradebot(mocker, default_conf)
     freqtrade.cleanup()
     assert log_has('Cleaning up modules ...', caplog)
     assert mock_cleanup.call_count == 1
+    assert coo_mock.call_count == 0
+
+    freqtrade.config['cancel_open_orders_on_exit'] = True
+    freqtrade.cleanup()
+    assert coo_mock.call_count == 1
 
 
 def test_order_dict_dry_run(default_conf, mocker, caplog) -> None:
@@ -2284,8 +2302,8 @@ def test_check_handle_timedout_exception(default_conf, ticker, open_trade, mocke
 
     mocker.patch.multiple(
         'freqtrade.freqtradebot.FreqtradeBot',
-        handle_timedout_limit_buy=MagicMock(),
-        handle_timedout_limit_sell=MagicMock(),
+        handle_cancel_buy=MagicMock(),
+        handle_cancel_sell=MagicMock(),
     )
     mocker.patch.multiple(
         'freqtrade.exchange.Exchange',
@@ -2305,50 +2323,54 @@ def test_check_handle_timedout_exception(default_conf, ticker, open_trade, mocke
                       caplog)
 
 
-def test_handle_timedout_limit_buy(mocker, caplog, default_conf, limit_buy_order) -> None:
+def test_handle_cancel_buy(mocker, caplog, default_conf, limit_buy_order) -> None:
     patch_RPCManager(mocker)
     patch_exchange(mocker)
     cancel_order_mock = MagicMock(return_value=limit_buy_order)
     mocker.patch('freqtrade.exchange.Exchange.cancel_order_with_result', cancel_order_mock)
 
     freqtrade = FreqtradeBot(default_conf)
+    freqtrade._notify_buy_cancel = MagicMock()
 
     Trade.session = MagicMock()
     trade = MagicMock()
     trade.pair = 'LTC/ETH'
     limit_buy_order['filled'] = 0.0
     limit_buy_order['status'] = 'open'
-    assert freqtrade.handle_timedout_limit_buy(trade, limit_buy_order)
+    reason = CANCEL_REASON['TIMEOUT']
+    assert freqtrade.handle_cancel_buy(trade, limit_buy_order, reason)
     assert cancel_order_mock.call_count == 1
 
     cancel_order_mock.reset_mock()
     limit_buy_order['filled'] = 2
-    assert not freqtrade.handle_timedout_limit_buy(trade, limit_buy_order)
+    assert not freqtrade.handle_cancel_buy(trade, limit_buy_order, reason)
     assert cancel_order_mock.call_count == 1
 
     limit_buy_order['filled'] = 2
     mocker.patch('freqtrade.exchange.Exchange.cancel_order', side_effect=InvalidOrderException)
-    assert not freqtrade.handle_timedout_limit_buy(trade, limit_buy_order)
+    assert not freqtrade.handle_cancel_buy(trade, limit_buy_order, reason)
 
 
 @pytest.mark.parametrize("limit_buy_order_canceled_empty", ['binance', 'ftx', 'kraken', 'bittrex'],
                          indirect=['limit_buy_order_canceled_empty'])
-def test_handle_timedout_limit_buy_exchanges(mocker, caplog, default_conf,
-                                             limit_buy_order_canceled_empty) -> None:
+def test_handle_cancel_buy_exchanges(mocker, caplog, default_conf,
+                                     limit_buy_order_canceled_empty) -> None:
     patch_RPCManager(mocker)
     patch_exchange(mocker)
     cancel_order_mock = mocker.patch(
         'freqtrade.exchange.Exchange.cancel_order_with_result',
         return_value=limit_buy_order_canceled_empty)
-
+    nofiy_mock = mocker.patch('freqtrade.freqtradebot.FreqtradeBot._notify_buy_cancel')
     freqtrade = FreqtradeBot(default_conf)
 
     Trade.session = MagicMock()
+    reason = CANCEL_REASON['TIMEOUT']
     trade = MagicMock()
     trade.pair = 'LTC/ETH'
-    assert freqtrade.handle_timedout_limit_buy(trade, limit_buy_order_canceled_empty)
+    assert freqtrade.handle_cancel_buy(trade, limit_buy_order_canceled_empty, reason)
     assert cancel_order_mock.call_count == 0
     assert log_has_re(r'Buy order fully cancelled. Removing .* from database\.', caplog)
+    assert nofiy_mock.call_count == 1
 
 
 @pytest.mark.parametrize('cancelorder', [
@@ -2357,8 +2379,8 @@ def test_handle_timedout_limit_buy_exchanges(mocker, caplog, default_conf,
     'String Return value',
     123
 ])
-def test_handle_timedout_limit_buy_corder_empty(mocker, default_conf, limit_buy_order,
-                                                cancelorder) -> None:
+def test_handle_cancel_buy_corder_empty(mocker, default_conf, limit_buy_order,
+                                        cancelorder) -> None:
     patch_RPCManager(mocker)
     patch_exchange(mocker)
     cancel_order_mock = MagicMock(return_value=cancelorder)
@@ -2368,23 +2390,24 @@ def test_handle_timedout_limit_buy_corder_empty(mocker, default_conf, limit_buy_
     )
 
     freqtrade = FreqtradeBot(default_conf)
+    freqtrade._notify_buy_cancel = MagicMock()
 
     Trade.session = MagicMock()
     trade = MagicMock()
     trade.pair = 'LTC/ETH'
     limit_buy_order['filled'] = 0.0
     limit_buy_order['status'] = 'open'
-
-    assert freqtrade.handle_timedout_limit_buy(trade, limit_buy_order)
+    reason = CANCEL_REASON['TIMEOUT']
+    assert freqtrade.handle_cancel_buy(trade, limit_buy_order, reason)
     assert cancel_order_mock.call_count == 1
 
     cancel_order_mock.reset_mock()
     limit_buy_order['filled'] = 1.0
-    assert not freqtrade.handle_timedout_limit_buy(trade, limit_buy_order)
+    assert not freqtrade.handle_cancel_buy(trade, limit_buy_order, reason)
     assert cancel_order_mock.call_count == 1
 
 
-def test_handle_timedout_limit_sell(mocker, default_conf) -> None:
+def test_handle_cancel_sell_limit(mocker, default_conf) -> None:
     patch_RPCManager(mocker)
     patch_exchange(mocker)
     cancel_order_mock = MagicMock()
@@ -2394,21 +2417,23 @@ def test_handle_timedout_limit_sell(mocker, default_conf) -> None:
     )
 
     freqtrade = FreqtradeBot(default_conf)
+    freqtrade._notify_sell_cancel = MagicMock()
 
     trade = MagicMock()
     order = {'remaining': 1,
              'amount': 1,
              'status': "open"}
-    assert freqtrade.handle_timedout_limit_sell(trade, order)
+    reason = CANCEL_REASON['TIMEOUT']
+    assert freqtrade.handle_cancel_sell(trade, order, reason)
     assert cancel_order_mock.call_count == 1
     order['amount'] = 2
-    assert (freqtrade.handle_timedout_limit_sell(trade, order)
-            == 'partially filled - keeping order open')
+    assert (freqtrade.handle_cancel_sell(trade, order, reason)
+            == CANCEL_REASON['PARTIALLY_FILLED'])
     # Assert cancel_order was not called (callcount remains unchanged)
     assert cancel_order_mock.call_count == 1
 
 
-def test_handle_timedout_limit_sell_cancel_exception(mocker, default_conf) -> None:
+def test_handle_cancel_sell_cancel_exception(mocker, default_conf) -> None:
     patch_RPCManager(mocker)
     patch_exchange(mocker)
     mocker.patch(
@@ -2417,10 +2442,11 @@ def test_handle_timedout_limit_sell_cancel_exception(mocker, default_conf) -> No
     freqtrade = FreqtradeBot(default_conf)
 
     trade = MagicMock()
+    reason = CANCEL_REASON['TIMEOUT']
     order = {'remaining': 1,
              'amount': 1,
              'status': "open"}
-    assert freqtrade.handle_timedout_limit_sell(trade, order) == 'error cancelling order'
+    assert freqtrade.handle_cancel_sell(trade, order, reason) == 'error cancelling order'
 
 
 def test_execute_sell_up(default_conf, ticker, fee, ticker_sell_up, mocker) -> None:
@@ -3919,3 +3945,20 @@ def test_sync_wallet_dry_run(mocker, default_conf, ticker, fee, limit_buy_order,
     assert log_has_re(r"Unable to create trade for XRP/BTC: "
                       r"Available balance \(0.0 BTC\) is lower than stake amount \(0.001 BTC\)",
                       caplog)
+
+
+@pytest.mark.usefixtures("init_persistence")
+def test_cancel_all_open_orders(mocker, default_conf, fee, limit_buy_order, limit_sell_order):
+    default_conf['cancel_open_orders_on_exit'] = True
+    mocker.patch('freqtrade.exchange.Exchange.get_order',
+                 side_effect=[DependencyException(), limit_sell_order, limit_buy_order])
+    buy_mock = mocker.patch('freqtrade.freqtradebot.FreqtradeBot.handle_cancel_buy')
+    sell_mock = mocker.patch('freqtrade.freqtradebot.FreqtradeBot.handle_cancel_sell')
+
+    freqtrade = get_patched_freqtradebot(mocker, default_conf)
+    create_mock_trades(fee)
+    trades = Trade.query.all()
+    assert len(trades) == 3
+    freqtrade.cancel_all_open_orders()
+    assert buy_mock.call_count == 1
+    assert sell_mock.call_count == 1