Cancel all open orders after receiving /stop or ctrl+c

This commit is contained in:
jpribyl 2020-04-24 16:16:52 -06:00
parent 0f50449196
commit bd51cd332b
16 changed files with 124 additions and 52 deletions

View File

@ -6,6 +6,7 @@
"fiat_display_currency": "USD", "fiat_display_currency": "USD",
"ticker_interval": "5m", "ticker_interval": "5m",
"dry_run": false, "dry_run": false,
"cancel_open_orders_on_exit": false,
"trailing_stop": false, "trailing_stop": false,
"unfilledtimeout": { "unfilledtimeout": {
"buy": 10, "buy": 10,

View File

@ -6,6 +6,7 @@
"fiat_display_currency": "USD", "fiat_display_currency": "USD",
"ticker_interval": "5m", "ticker_interval": "5m",
"dry_run": true, "dry_run": true,
"cancel_open_orders_on_exit": false,
"trailing_stop": false, "trailing_stop": false,
"unfilledtimeout": { "unfilledtimeout": {
"buy": 10, "buy": 10,

View File

@ -8,6 +8,7 @@
"amend_last_stake_amount": false, "amend_last_stake_amount": false,
"last_stake_amount_min_ratio": 0.5, "last_stake_amount_min_ratio": 0.5,
"dry_run": false, "dry_run": false,
"cancel_open_orders_on_exit": false,
"ticker_interval": "5m", "ticker_interval": "5m",
"trailing_stop": false, "trailing_stop": false,
"trailing_stop_positive": 0.005, "trailing_stop_positive": 0.005,

View File

@ -6,6 +6,7 @@
"fiat_display_currency": "EUR", "fiat_display_currency": "EUR",
"ticker_interval": "5m", "ticker_interval": "5m",
"dry_run": true, "dry_run": true,
"cancel_open_orders_on_exit": false,
"trailing_stop": false, "trailing_stop": false,
"unfilledtimeout": { "unfilledtimeout": {
"buy": 10, "buy": 10,

View File

@ -51,6 +51,7 @@ Mandatory parameters are marked as **Required**, which means that they are requi
| `fiat_display_currency` | Fiat currency used to show your profits. [More information below](#what-values-can-be-used-for-fiat_display_currency). <br> **Datatype:** String | `fiat_display_currency` | Fiat currency used to show your profits. [More information below](#what-values-can-be-used-for-fiat_display_currency). <br> **Datatype:** String
| `dry_run` | **Required.** Define if the bot must be in Dry Run or production mode. <br>*Defaults to `true`.* <br> **Datatype:** Boolean | `dry_run` | **Required.** Define if the bot must be in Dry Run or production mode. <br>*Defaults to `true`.* <br> **Datatype:** Boolean
| `dry_run_wallet` | Define the starting amount in stake currency for the simulated wallet used by the bot running in the Dry Run mode.<br>*Defaults to `1000`.* <br> **Datatype:** Float | `dry_run_wallet` | Define the starting amount in stake currency for the simulated wallet used by the bot running in the Dry Run mode.<br>*Defaults to `1000`.* <br> **Datatype:** Float
| `cancel_open_orders_on_exit` | Cancel orders when `/stop` is issued or `ctrl+c` is pressed. Including this will allow you to use `/stop` to cancel unfilled orders in the event of a market crash. This will not impact open positions. <br>*Defaults to `False`.* <br> **Datatype:** Boolean
| `process_only_new_candles` | Enable processing of indicators only when new candles arrive. If false each loop populates the indicators, this will mean the same candle is processed many times creating system load but can be useful of your strategy depends on tick data not only candle. [Strategy Override](#parameters-in-the-strategy). <br>*Defaults to `false`.* <br> **Datatype:** Boolean | `process_only_new_candles` | Enable processing of indicators only when new candles arrive. If false each loop populates the indicators, this will mean the same candle is processed many times creating system load but can be useful of your strategy depends on tick data not only candle. [Strategy Override](#parameters-in-the-strategy). <br>*Defaults to `false`.* <br> **Datatype:** Boolean
| `minimal_roi` | **Required.** Set the threshold in percent the bot will use to sell a trade. [More information below](#understand-minimal_roi). [Strategy Override](#parameters-in-the-strategy). <br> **Datatype:** Dict | `minimal_roi` | **Required.** Set the threshold in percent the bot will use to sell a trade. [More information below](#understand-minimal_roi). [Strategy Override](#parameters-in-the-strategy). <br> **Datatype:** Dict
| `stoploss` | **Required.** Value of the stoploss in percent used by the bot. More details in the [stoploss documentation](stoploss.md). [Strategy Override](#parameters-in-the-strategy). <br> **Datatype:** Float (as ratio) | `stoploss` | **Required.** Value of the stoploss in percent used by the bot. More details in the [stoploss documentation](stoploss.md). [Strategy Override](#parameters-in-the-strategy). <br> **Datatype:** Float (as ratio)

View File

@ -13,7 +13,7 @@ ARGS_COMMON = ["verbosity", "logfile", "version", "config", "datadir", "user_dat
ARGS_STRATEGY = ["strategy", "strategy_path"] ARGS_STRATEGY = ["strategy", "strategy_path"]
ARGS_TRADE = ["db_url", "sd_notify", "dry_run"] ARGS_TRADE = ["db_url", "sd_notify", "dry_run", "cancel_open_orders_on_exit"]
ARGS_COMMON_OPTIMIZE = ["ticker_interval", "timerange", ARGS_COMMON_OPTIMIZE = ["ticker_interval", "timerange",
"max_open_trades", "stake_amount", "fee"] "max_open_trades", "stake_amount", "fee"]

View File

@ -109,6 +109,11 @@ AVAILABLE_CLI_OPTIONS = {
help='Enforce dry-run for trading (removes Exchange secrets and simulates trades).', help='Enforce dry-run for trading (removes Exchange secrets and simulates trades).',
action='store_true', action='store_true',
), ),
"cancel_open_orders_on_exit": Arg(
'--cancel-open-orders-on-exit',
help='Close unfilled open orders when the bot stops / exits',
action='store_true',
),
# Optimize common # Optimize common
"ticker_interval": Arg( "ticker_interval": Arg(
'-i', '--ticker-interval', '-i', '--ticker-interval',

View File

@ -134,6 +134,11 @@ class Configuration:
if config['runmode'] not in TRADING_MODES: if config['runmode'] not in TRADING_MODES:
return return
if self.args.get('cancel_open_orders_on_exit', False):
config.update({
'cancel_open_orders_on_exit': self.args.get('cancel_open_orders_on_exit')
})
if config.get('dry_run', False): if config.get('dry_run', False):
logger.info('Dry run is enabled') logger.info('Dry run is enabled')
if config.get('db_url') in [None, constants.DEFAULT_DB_PROD_URL]: if config.get('db_url') in [None, constants.DEFAULT_DB_PROD_URL]:

View File

@ -85,6 +85,7 @@ CONF_SCHEMA = {
'fiat_display_currency': {'type': 'string', 'enum': SUPPORTED_FIAT}, 'fiat_display_currency': {'type': 'string', 'enum': SUPPORTED_FIAT},
'dry_run': {'type': 'boolean'}, 'dry_run': {'type': 'boolean'},
'dry_run_wallet': {'type': 'number', 'default': DRY_RUN_WALLET}, 'dry_run_wallet': {'type': 'number', 'default': DRY_RUN_WALLET},
'cancel_open_orders_on_exit': {'type': 'boolean', 'default': False},
'process_only_new_candles': {'type': 'boolean'}, 'process_only_new_candles': {'type': 'boolean'},
'minimal_roi': { 'minimal_roi': {
'type': 'object', 'type': 'object',
@ -318,3 +319,10 @@ SCHEMA_MINIMAL_REQUIRED = [
'dataformat_ohlcv', 'dataformat_ohlcv',
'dataformat_trades', 'dataformat_trades',
] ]
CANCEL_REASON = {
"TIMEOUT": "cancelled due to timeout",
"PARTIALLY_FILLED": "partially filled - keeping order open",
"ALL_CANCELLED": "cancelled (all unfilled orders cancelled)",
"CANCELLED_ON_EXCHANGE": "cancelled on exchange",
}

View File

@ -113,6 +113,9 @@ class FreqtradeBot:
""" """
logger.info('Cleaning up modules ...') logger.info('Cleaning up modules ...')
if self.config['cancel_open_orders_on_exit']:
self.cancel_all_open_orders()
self.rpc.cleanup() self.rpc.cleanup()
persistence.cleanup() persistence.cleanup()
@ -162,6 +165,13 @@ class FreqtradeBot:
Trade.session.flush() Trade.session.flush()
def process_stopped(self) -> None:
"""
Close all trades 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]: def _refresh_whitelist(self, trades: List[Trade] = []) -> List[str]:
""" """
Refresh whitelist from pairlist or edge and extend it with trades. Refresh whitelist from pairlist or edge and extend it with trades.
@ -875,11 +885,7 @@ class FreqtradeBot:
default_retval=False)(pair=trade.pair, default_retval=False)(pair=trade.pair,
trade=trade, trade=trade,
order=order))): order=order))):
self.handle_cancel_buy(trade, order, constants.CANCEL_REASON['TIMEOUT'])
self.handle_timedout_limit_buy(trade, order)
self.wallets.update()
order_type = self.strategy.order_types['buy']
self._notify_buy_cancel(trade, order_type)
elif (order['side'] == 'sell' and ( elif (order['side'] == 'sell' and (
trade_state_update trade_state_update
@ -888,24 +894,42 @@ class FreqtradeBot:
default_retval=False)(pair=trade.pair, default_retval=False)(pair=trade.pair,
trade=trade, trade=trade,
order=order))): order=order))):
reason = self.handle_timedout_limit_sell(trade, order) self.handle_cancel_sell(trade, order, constants.CANCEL_REASON['TIMEOUT'])
self.wallets.update()
order_type = self.strategy.order_types['sell']
self._notify_sell_cancel(trade, order_type, reason)
def handle_timedout_limit_buy(self, trade: Trade, order: Dict) -> bool: def cancel_all_open_orders(self) -> None:
""" """
Buy timeout - cancel order 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'])
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 cancel - cancel order
:return: True if order was fully cancelled :return: True if order was fully cancelled
""" """
was_trade_fully_canceled = False
if order['status'] != 'canceled': if order['status'] != 'canceled':
reason = "cancelled due to timeout" reason = constants.CANCEL_REASON['TIMEOUT']
corder = self.exchange.cancel_order_with_result(trade.open_order_id, trade.pair, corder = self.exchange.cancel_order_with_result(trade.open_order_id, trade.pair,
trade.amount) trade.amount)
else: else:
# Order was cancelled already, so we can reuse the existing dict # Order was cancelled already, so we can reuse the existing dict
corder = order corder = order
reason = "cancelled on exchange" reason = constants.CANCEL_REASON['CANCELLED_ON_EXCHANGE']
logger.info('Buy order %s for %s.', reason, trade) logger.info('Buy order %s for %s.', reason, trade)
@ -914,40 +938,42 @@ class FreqtradeBot:
# if trade is not partially completed, just delete the trade # if trade is not partially completed, just delete the trade
Trade.session.delete(trade) Trade.session.delete(trade)
Trade.session.flush() 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 = order['amount'] - safe_value_fallback(corder, order,
'remaining', 'remaining')
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 trade.open_order_id = None
# and close the order logger.info('Partial buy order timeout for %s.', trade)
# cancel_order may not contain the full order dict, so we need to fallback self.rpc.send_msg({
# to the order dict aquired before cancelling. 'type': RPCMessageType.STATUS_NOTIFICATION,
# we need to fall back to the values from order if corder does not contain these keys. 'status': f'Remaining buy order for {trade.pair} cancelled due to timeout'
trade.amount = order['amount'] - safe_value_fallback(corder, order, })
'remaining', 'remaining')
trade.stake_amount = trade.amount * trade.open_rate
self.update_trade_state(trade, corder, trade.amount)
trade.open_order_id = None self.wallets.update()
logger.info('Partial buy order timeout for %s.', trade) self._notify_buy_cancel(trade, order_type=self.strategy.order_types['buy'])
self.rpc.send_msg({ return was_trade_fully_canceled
'type': RPCMessageType.STATUS_NOTIFICATION,
'status': f'Remaining buy order for {trade.pair} cancelled due to timeout'
})
return False
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 :return: Reason for cancel
""" """
# if trade is not partially completed, just cancel the trade # if trade is not partially completed, just cancel the trade
if order['remaining'] == order['amount'] or order.get('filled') == 0.0: if order['remaining'] == order['amount'] or order.get('filled') == 0.0:
if not self.exchange.check_order_canceled_empty(order): if not self.exchange.check_order_canceled_empty(order):
reason = "cancelled due to timeout"
# if trade is not partially completed, just delete the trade # if trade is not partially completed, just delete the trade
self.exchange.cancel_order(trade.open_order_id, trade.pair) self.exchange.cancel_order(trade.open_order_id, trade.pair)
logger.info('Sell order %s for %s.', reason, trade) logger.info('Sell order %s for %s.', reason, trade)
else: else:
reason = "cancelled on exchange" reason = constants.CANCEL_REASON['CANCELLED_ON_EXCHANGE']
logger.info('Sell order %s for %s.', reason, trade) logger.info('Sell order %s for %s.', reason, trade)
trade.close_rate = None trade.close_rate = None
@ -957,11 +983,17 @@ class FreqtradeBot:
trade.close_date = None trade.close_date = None
trade.is_open = True trade.is_open = True
trade.open_order_id = None trade.open_order_id = None
else:
# TODO: figure out how to handle partially complete sell orders
reason = constants.CANCEL_REASON['PARTIALLY_FILLED']
return reason self.wallets.update()
self._notify_sell_cancel(
# TODO: figure out how to handle partially complete sell orders trade,
return 'partially filled - keeping order open' order_type=self.strategy.order_types['sell'],
reason=reason
)
return reason
def _safe_sell_amount(self, pair: str, amount: float) -> float: def _safe_sell_amount(self, pair: str, amount: float) -> float:
""" """

View File

@ -6,6 +6,7 @@
"fiat_display_currency": "{{ fiat_display_currency }}", "fiat_display_currency": "{{ fiat_display_currency }}",
"ticker_interval": "{{ ticker_interval }}", "ticker_interval": "{{ ticker_interval }}",
"dry_run": {{ dry_run | lower }}, "dry_run": {{ dry_run | lower }},
"cancel_open_orders_on_exit": false,
"unfilledtimeout": { "unfilledtimeout": {
"buy": 10, "buy": 10,
"sell": 30 "sell": 30

View File

@ -131,8 +131,7 @@ class Worker:
return result return result
def _process_stopped(self) -> None: def _process_stopped(self) -> None:
# Maybe do here something in the future... self.freqtrade.process_stopped()
pass
def _process_running(self) -> None: def _process_running(self) -> None:
try: try:

View File

@ -249,6 +249,7 @@ def default_conf(testdatadir):
"fiat_display_currency": "USD", "fiat_display_currency": "USD",
"ticker_interval": '5m', "ticker_interval": '5m',
"dry_run": True, "dry_run": True,
"cancel_open_orders_on_exit": False,
"minimal_roi": { "minimal_roi": {
"40": 0.0, "40": 0.0,
"30": 0.01, "30": 0.01,

View File

@ -64,6 +64,14 @@ def test_parse_args_db_url() -> None:
assert args["db_url"] == 'sqlite:///test.sqlite' assert args["db_url"] == 'sqlite:///test.sqlite'
def test_parse_args_cancel_open_orders_on_exit() -> None:
args = Arguments(['trade']).get_parsed_arg()
assert args["cancel_open_orders_on_exit"] is False
args = Arguments(['trade', '--cancel-open-orders-on-exit']).get_parsed_arg()
assert args["cancel_open_orders_on_exit"] is True
def test_parse_args_verbose() -> None: def test_parse_args_verbose() -> None:
args = Arguments(['trade', '-v']).get_parsed_arg() args = Arguments(['trade', '-v']).get_parsed_arg()
assert args["verbosity"] == 1 assert args["verbosity"] == 1

View File

@ -250,6 +250,7 @@ def test_load_config_with_params(default_conf, mocker) -> None:
'--strategy', 'TestStrategy', '--strategy', 'TestStrategy',
'--strategy-path', '/some/path', '--strategy-path', '/some/path',
'--db-url', 'sqlite:///someurl', '--db-url', 'sqlite:///someurl',
'--cancel-open-orders-on-exit',
] ]
args = Arguments(arglist).get_parsed_arg() args = Arguments(arglist).get_parsed_arg()
configuration = Configuration(args) configuration = Configuration(args)
@ -258,6 +259,7 @@ def test_load_config_with_params(default_conf, mocker) -> None:
assert validated_conf.get('strategy') == 'TestStrategy' assert validated_conf.get('strategy') == 'TestStrategy'
assert validated_conf.get('strategy_path') == '/some/path' assert validated_conf.get('strategy_path') == '/some/path'
assert validated_conf.get('db_url') == 'sqlite:///someurl' assert validated_conf.get('db_url') == 'sqlite:///someurl'
assert validated_conf.get('cancel_open_orders_on_exit') is True
# Test conf provided db_url prod # Test conf provided db_url prod
conf = default_conf.copy() conf = default_conf.copy()

View File

@ -11,7 +11,7 @@ import arrow
import pytest import pytest
import requests 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, from freqtrade.exceptions import (DependencyException, InvalidOrderException,
OperationalException, TemporaryError) OperationalException, TemporaryError)
from freqtrade.freqtradebot import FreqtradeBot from freqtrade.freqtradebot import FreqtradeBot
@ -2281,8 +2281,8 @@ def test_check_handle_timedout_exception(default_conf, ticker, open_trade, mocke
mocker.patch.multiple( mocker.patch.multiple(
'freqtrade.freqtradebot.FreqtradeBot', 'freqtrade.freqtradebot.FreqtradeBot',
handle_timedout_limit_buy=MagicMock(), handle_cancel_buy=MagicMock(),
handle_timedout_limit_sell=MagicMock(), handle_cancel_sell=MagicMock(),
) )
mocker.patch.multiple( mocker.patch.multiple(
'freqtrade.exchange.Exchange', 'freqtrade.exchange.Exchange',
@ -2309,21 +2309,23 @@ def test_handle_timedout_limit_buy(mocker, caplog, default_conf, limit_buy_order
mocker.patch('freqtrade.exchange.Exchange.cancel_order_with_result', cancel_order_mock) mocker.patch('freqtrade.exchange.Exchange.cancel_order_with_result', cancel_order_mock)
freqtrade = FreqtradeBot(default_conf) freqtrade = FreqtradeBot(default_conf)
freqtrade._notify_buy_cancel = MagicMock()
Trade.session = MagicMock() Trade.session = MagicMock()
trade = MagicMock() trade = MagicMock()
trade.pair = 'LTC/ETH' trade.pair = 'LTC/ETH'
limit_buy_order['remaining'] = limit_buy_order['amount'] limit_buy_order['remaining'] = limit_buy_order['amount']
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 assert cancel_order_mock.call_count == 1
cancel_order_mock.reset_mock() cancel_order_mock.reset_mock()
limit_buy_order['amount'] = 2 limit_buy_order['amount'] = 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 assert cancel_order_mock.call_count == 1
mocker.patch('freqtrade.exchange.Exchange.cancel_order', side_effect=InvalidOrderException) 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('cancelorder', [ @pytest.mark.parametrize('cancelorder', [
@ -2343,17 +2345,19 @@ def test_handle_timedout_limit_buy_corder_empty(mocker, default_conf, limit_buy_
) )
freqtrade = FreqtradeBot(default_conf) freqtrade = FreqtradeBot(default_conf)
freqtrade._notify_buy_cancel = MagicMock()
Trade.session = MagicMock() Trade.session = MagicMock()
trade = MagicMock() trade = MagicMock()
trade.pair = 'LTC/ETH' trade.pair = 'LTC/ETH'
limit_buy_order['remaining'] = limit_buy_order['amount'] limit_buy_order['remaining'] = limit_buy_order['amount']
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 assert cancel_order_mock.call_count == 1
cancel_order_mock.reset_mock() cancel_order_mock.reset_mock()
limit_buy_order['amount'] = 2 limit_buy_order['amount'] = 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 assert cancel_order_mock.call_count == 1
@ -2367,16 +2371,18 @@ def test_handle_timedout_limit_sell(mocker, default_conf) -> None:
) )
freqtrade = FreqtradeBot(default_conf) freqtrade = FreqtradeBot(default_conf)
freqtrade._notify_sell_cancel = MagicMock()
trade = MagicMock() trade = MagicMock()
order = {'remaining': 1, order = {'remaining': 1,
'amount': 1, 'amount': 1,
'status': "open"} '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 assert cancel_order_mock.call_count == 1
order['amount'] = 2 order['amount'] = 2
assert (freqtrade.handle_timedout_limit_sell(trade, order) assert (freqtrade.handle_cancel_sell(trade, order, reason)
== 'partially filled - keeping order open') == CANCEL_REASON['PARTIALLY_FILLED'])
# Assert cancel_order was not called (callcount remains unchanged) # Assert cancel_order was not called (callcount remains unchanged)
assert cancel_order_mock.call_count == 1 assert cancel_order_mock.call_count == 1