diff --git a/config_examples/config_full.example.json b/config_examples/config_full.example.json index 83b8a27d0..228a08a02 100644 --- a/config_examples/config_full.example.json +++ b/config_examples/config_full.example.json @@ -28,6 +28,7 @@ "unfilledtimeout": { "buy": 10, "sell": 30, + "exit_timeout_count": 0, "unit": "minutes" }, "bid_strategy": { diff --git a/docs/configuration.md b/docs/configuration.md index 24198b44c..f2958f725 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -102,6 +102,7 @@ Mandatory parameters are marked as **Required**, which means that they are requi | `unfilledtimeout.buy` | **Required.** How long (in minutes or seconds) the bot will wait for an unfilled buy order to complete, after which the order will be cancelled and repeated at current (new) price, as long as there is a signal. [Strategy Override](#parameters-in-the-strategy).
**Datatype:** Integer | `unfilledtimeout.sell` | **Required.** How long (in minutes or seconds) the bot will wait for an unfilled sell order to complete, after which the order will be cancelled and repeated at current (new) price, as long as there is a signal. [Strategy Override](#parameters-in-the-strategy).
**Datatype:** Integer | `unfilledtimeout.unit` | Unit to use in unfilledtimeout setting. Note: If you set unfilledtimeout.unit to "seconds", "internals.process_throttle_secs" must be inferior or equal to timeout [Strategy Override](#parameters-in-the-strategy).
*Defaults to `minutes`.*
**Datatype:** String +| `unfilledtimeout.exit_timeout_count` | How many times can exit orders time out. Once this number of timeouts is reached, an emergency sell is triggered. 0 to disable and allow unlimited order cancels. [Strategy Override](#parameters-in-the-strategy).
*Defaults to `0`.*
**Datatype:** Integer | `bid_strategy.price_side` | Select the side of the spread the bot should look at to get the buy rate. [More information below](#buy-price-side).
*Defaults to `bid`.*
**Datatype:** String (either `ask` or `bid`). | `bid_strategy.ask_last_balance` | **Required.** Interpolate the bidding price. More information [below](#buy-price-without-orderbook-enabled). | `bid_strategy.use_order_book` | Enable buying using the rates in [Order Book Bids](#buy-price-with-orderbook-enabled).
**Datatype:** Boolean diff --git a/freqtrade/constants.py b/freqtrade/constants.py index 656893999..73830af09 100644 --- a/freqtrade/constants.py +++ b/freqtrade/constants.py @@ -156,6 +156,7 @@ CONF_SCHEMA = { 'properties': { 'buy': {'type': 'number', 'minimum': 1}, 'sell': {'type': 'number', 'minimum': 1}, + 'exit_timeout_count': {'type': 'number', 'minimum': 0, 'default': 0}, 'unit': {'type': 'string', 'enum': TIMEOUT_UNITS, 'default': 'minutes'} } }, diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index a1e8cad4a..a797b2e70 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -920,6 +920,13 @@ class FreqtradeBot(LoggingMixin): trade=trade, order=order))): self.handle_cancel_exit(trade, order, constants.CANCEL_REASON['TIMEOUT']) + canceled_count = trade.get_exit_order_count() + max_timeouts = self.config.get('unfilledtimeout', {}).get('exit_timeout_count', 0) + if max_timeouts > 0 and canceled_count >= max_timeouts: + logger.warning(f'Emergencyselling trade {trade}, as the sell order ' + f'timed out {max_timeouts} times.') + self.execute_trade_exit(trade, order.get('price'), sell_reason=SellCheckTuple( + sell_type=SellType.EMERGENCY_SELL)) def cancel_all_open_orders(self) -> None: """ @@ -1283,7 +1290,7 @@ class FreqtradeBot(LoggingMixin): if self.exchange.check_order_canceled_empty(order): # Trade has been cancelled on exchange - # Handling of this will happen in check_handle_timeout. + # Handling of this will happen in check_handle_timedout. return True # Try update amount (binance-fix) diff --git a/freqtrade/persistence/models.py b/freqtrade/persistence/models.py index b3518c228..61e59b7c3 100644 --- a/freqtrade/persistence/models.py +++ b/freqtrade/persistence/models.py @@ -491,6 +491,13 @@ class LocalTrade(): def update_order(self, order: Dict) -> None: Order.update_orders(self.orders, order) + def get_exit_order_count(self) -> int: + """ + Get amount of failed exiting orders + assumes full exits. + """ + return len([o for o in self.orders if o.ft_order_side == 'sell']) + def _calc_open_trade_value(self) -> float: """ Calculate the open_rate including open_fee. @@ -775,7 +782,7 @@ class Trade(_DECL_BASE, LocalTrade): return Trade.query @staticmethod - def get_open_order_trades(): + def get_open_order_trades() -> List['Trade']: """ Returns all open trades NOTE: Not supported in Backtesting. diff --git a/tests/test_freqtradebot.py b/tests/test_freqtradebot.py index 5acefd515..47b76af61 100644 --- a/tests/test_freqtradebot.py +++ b/tests/test_freqtradebot.py @@ -2134,11 +2134,12 @@ def test_check_handle_timedout_buy_exception(default_conf_usdt, ticker_usdt, def test_check_handle_timedout_sell_usercustom(default_conf_usdt, ticker_usdt, limit_sell_order_old, - mocker, open_trade) -> None: - default_conf_usdt["unfilledtimeout"] = {"buy": 1440, "sell": 1440} + mocker, open_trade, caplog) -> None: + default_conf_usdt["unfilledtimeout"] = {"buy": 1440, "sell": 1440, "exit_timeout_count": 1} rpc_mock = patch_RPCManager(mocker) cancel_order_mock = MagicMock() patch_exchange(mocker) + et_mock = mocker.patch('freqtrade.freqtradebot.FreqtradeBot.execute_trade_exit') mocker.patch.multiple( 'freqtrade.exchange.Exchange', fetch_ticker=ticker_usdt, @@ -2181,6 +2182,14 @@ def test_check_handle_timedout_sell_usercustom(default_conf_usdt, ticker_usdt, l assert open_trade.is_open is True assert freqtrade.strategy.check_sell_timeout.call_count == 1 + # 2nd canceled trade ... + caplog.clear() + open_trade.open_order_id = 'order_id_2' + mocker.patch('freqtrade.persistence.Trade.get_exit_order_count', return_value=1) + freqtrade.check_handle_timedout() + assert log_has_re('Emergencyselling trade.*', caplog) + assert et_mock.call_count == 1 + def test_check_handle_timedout_sell(default_conf_usdt, ticker_usdt, limit_sell_order_old, mocker, open_trade) -> None: diff --git a/tests/test_persistence.py b/tests/test_persistence.py index 719dc8263..d1d3ce382 100644 --- a/tests/test_persistence.py +++ b/tests/test_persistence.py @@ -13,7 +13,7 @@ from sqlalchemy import create_engine, inspect, text from freqtrade import constants from freqtrade.exceptions import DependencyException, OperationalException from freqtrade.persistence import LocalTrade, Order, Trade, clean_dry_run_db, init_db -from tests.conftest import create_mock_trades, log_has, log_has_re +from tests.conftest import create_mock_trades, create_mock_trades_usdt, log_has, log_has_re def test_init_create_session(default_conf): @@ -1190,6 +1190,14 @@ def test_get_best_pair(fee): assert res[1] == 0.01 +@pytest.mark.usefixtures("init_persistence") +def test_get_exit_order_count(fee): + + create_mock_trades_usdt(fee) + trade = Trade.get_trades([Trade.pair == 'ETC/USDT']).first() + assert trade.get_exit_order_count() == 1 + + @pytest.mark.usefixtures("init_persistence") def test_update_order_from_ccxt(caplog): # Most basic order return (only has orderid)