Merge pull request #6870 from freqtrade/should_exit_list

Should exit list
This commit is contained in:
Matthias 2022-05-24 06:57:50 +02:00 committed by GitHub
commit 23e089061b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 152 additions and 44 deletions

View File

@ -530,8 +530,9 @@ Since backtesting lacks some detailed information about what happens within a ca
- Exit-reason does not explain if a trade was positive or negative, just what triggered the exit (this can look odd if negative ROI values are used) - Exit-reason does not explain if a trade was positive or negative, just what triggered the exit (this can look odd if negative ROI values are used)
- Evaluation sequence (if multiple signals happen on the same candle) - Evaluation sequence (if multiple signals happen on the same candle)
- Exit-signal - Exit-signal
- ROI (if not stoploss)
- Stoploss - Stoploss
- ROI
- Trailing stoploss
Taking these assumptions, backtesting tries to mirror real trading as closely as possible. However, backtesting will **never** replace running a strategy in dry-run mode. Taking these assumptions, backtesting tries to mirror real trading as closely as possible. However, backtesting will **never** replace running a strategy in dry-run mode.
Also, keep in mind that past results don't guarantee future success. Also, keep in mind that past results don't guarantee future success.

View File

@ -563,6 +563,14 @@ class AwesomeStrategy(IStrategy):
`confirm_trade_exit()` can be used to abort a trade exit (sell) at the latest second (maybe because the price is not what we expect). `confirm_trade_exit()` can be used to abort a trade exit (sell) at the latest second (maybe because the price is not what we expect).
`confirm_trade_exit()` may be called multiple times within one iteration for the same trade if different exit-reasons apply.
The exit-reasons (if applicable) will be in the following sequence:
* `exit_signal` / `custom_exit`
* `stop_loss`
* `roi`
* `trailing_stop_loss`
``` python ``` python
from freqtrade.persistence import Trade from freqtrade.persistence import Trade
@ -605,6 +613,9 @@ class AwesomeStrategy(IStrategy):
``` ```
!!! Warning
`confirm_trade_exit()` can prevent stoploss exits, causing significant losses as this would ignore stoploss exits.
## Adjust trade position ## Adjust trade position
The `position_adjustment_enable` strategy property enables the usage of `adjust_trade_position()` callback in the strategy. The `position_adjustment_enable` strategy property enables the usage of `adjust_trade_position()` callback in the strategy.

View File

@ -15,3 +15,9 @@ class ExitCheckTuple:
@property @property
def exit_flag(self): def exit_flag(self):
return self.exit_type != ExitType.NONE return self.exit_type != ExitType.NONE
def __eq__(self, other):
return self.exit_type == other.exit_type and self.exit_reason == other.exit_reason
def __repr__(self):
return f"ExitCheckTuple({self.exit_type}, {self.exit_reason})"

View File

@ -1106,7 +1106,7 @@ class FreqtradeBot(LoggingMixin):
""" """
Check and execute trade exit Check and execute trade exit
""" """
should_exit: ExitCheckTuple = self.strategy.should_exit( exits: List[ExitCheckTuple] = self.strategy.should_exit(
trade, trade,
exit_rate, exit_rate,
datetime.now(timezone.utc), datetime.now(timezone.utc),
@ -1114,12 +1114,13 @@ class FreqtradeBot(LoggingMixin):
exit_=exit_, exit_=exit_,
force_stoploss=self.edge.stoploss(trade.pair) if self.edge else 0 force_stoploss=self.edge.stoploss(trade.pair) if self.edge else 0
) )
for should_exit in exits:
if should_exit.exit_flag: if should_exit.exit_flag:
logger.info(f'Exit for {trade.pair} detected. Reason: {should_exit.exit_type}' logger.info(f'Exit for {trade.pair} detected. Reason: {should_exit.exit_type}'
f'Tag: {exit_tag if exit_tag is not None else "None"}') f'{f" Tag: {exit_tag}" if exit_tag is not None else ""}')
self.execute_trade_exit(trade, exit_rate, should_exit, exit_tag=exit_tag) exited = self.execute_trade_exit(trade, exit_rate, should_exit, exit_tag=exit_tag)
return True if exited:
return True
return False return False
def manage_open_orders(self) -> None: def manage_open_orders(self) -> None:
@ -1406,7 +1407,7 @@ class FreqtradeBot(LoggingMixin):
:param trade: Trade instance :param trade: Trade instance
:param limit: limit rate for the sell order :param limit: limit rate for the sell order
:param exit_check: CheckTuple with signal and reason :param exit_check: CheckTuple with signal and reason
:return: True if it succeeds (supported) False (not supported) :return: True if it succeeds False
""" """
trade.funding_fees = self.exchange.get_funding_fees( trade.funding_fees = self.exchange.get_funding_fees(
pair=trade.pair, pair=trade.pair,
@ -1453,7 +1454,7 @@ class FreqtradeBot(LoggingMixin):
time_in_force=time_in_force, exit_reason=exit_reason, time_in_force=time_in_force, exit_reason=exit_reason,
sell_reason=exit_reason, # sellreason -> compatibility sell_reason=exit_reason, # sellreason -> compatibility
current_time=datetime.now(timezone.utc)): current_time=datetime.now(timezone.utc)):
logger.info(f"User requested abortion of exiting {trade.pair}") logger.info(f"User requested abortion of {trade.pair} exit.")
return False return False
try: try:

View File

@ -531,15 +531,23 @@ class Backtesting:
if check_adjust_entry: if check_adjust_entry:
trade = self._get_adjust_trade_entry_for_candle(trade, row) trade = self._get_adjust_trade_entry_for_candle(trade, row)
exit_candle_time: datetime = row[DATE_IDX].to_pydatetime()
enter = row[SHORT_IDX] if trade.is_short else row[LONG_IDX] enter = row[SHORT_IDX] if trade.is_short else row[LONG_IDX]
exit_sig = row[ESHORT_IDX] if trade.is_short else row[ELONG_IDX] exit_sig = row[ESHORT_IDX] if trade.is_short else row[ELONG_IDX]
exit_ = self.strategy.should_exit( exits = self.strategy.should_exit(
trade, row[OPEN_IDX], exit_candle_time, # type: ignore trade, row[OPEN_IDX], row[DATE_IDX].to_pydatetime(), # type: ignore
enter=enter, exit_=exit_sig, enter=enter, exit_=exit_sig,
low=row[LOW_IDX], high=row[HIGH_IDX] low=row[LOW_IDX], high=row[HIGH_IDX]
) )
for exit_ in exits:
t = self._get_exit_for_signal(trade, row, exit_)
if t:
return t
return None
def _get_exit_for_signal(self, trade: LocalTrade, row: Tuple,
exit_: ExitCheckTuple) -> Optional[LocalTrade]:
exit_candle_time: datetime = row[DATE_IDX].to_pydatetime()
if exit_.exit_flag: if exit_.exit_flag:
trade.close_date = exit_candle_time trade.close_date = exit_candle_time
exit_reason = exit_.exit_reason exit_reason = exit_.exit_reason

View File

@ -878,16 +878,16 @@ class IStrategy(ABC, HyperStrategyMixin):
def should_exit(self, trade: Trade, rate: float, current_time: datetime, *, def should_exit(self, trade: Trade, rate: float, current_time: datetime, *,
enter: bool, exit_: bool, enter: bool, exit_: bool,
low: float = None, high: float = None, low: float = None, high: float = None,
force_stoploss: float = 0) -> ExitCheckTuple: force_stoploss: float = 0) -> List[ExitCheckTuple]:
""" """
This function evaluates if one of the conditions required to trigger an exit order This function evaluates if one of the conditions required to trigger an exit order
has been reached, which can either be a stop-loss, ROI or exit-signal. has been reached, which can either be a stop-loss, ROI or exit-signal.
:param low: Only used during backtesting to simulate (long)stoploss/(short)ROI :param low: Only used during backtesting to simulate (long)stoploss/(short)ROI
:param high: Only used during backtesting, to simulate (short)stoploss/(long)ROI :param high: Only used during backtesting, to simulate (short)stoploss/(long)ROI
:param force_stoploss: Externally provided stoploss :param force_stoploss: Externally provided stoploss
:return: True if trade should be exited, False otherwise :return: List of exit reasons - or empty list.
""" """
exits: List[ExitCheckTuple] = []
current_rate = rate current_rate = rate
current_profit = trade.calc_profit_ratio(current_rate) current_profit = trade.calc_profit_ratio(current_rate)
@ -938,24 +938,29 @@ class IStrategy(ABC, HyperStrategyMixin):
logger.debug(f"{trade.pair} - Sell signal received. " logger.debug(f"{trade.pair} - Sell signal received. "
f"exit_type=ExitType.{exit_signal.name}" + f"exit_type=ExitType.{exit_signal.name}" +
(f", custom_reason={custom_reason}" if custom_reason else "")) (f", custom_reason={custom_reason}" if custom_reason else ""))
return ExitCheckTuple(exit_type=exit_signal, exit_reason=custom_reason) exits.append(ExitCheckTuple(exit_type=exit_signal, exit_reason=custom_reason))
# Sequence: # Sequence:
# Exit-signal # Exit-signal
# ROI (if not stoploss)
# Stoploss # Stoploss
if roi_reached and stoplossflag.exit_type != ExitType.STOP_LOSS: # ROI
logger.debug(f"{trade.pair} - Required profit reached. exit_type=ExitType.ROI") # Trailing stoploss
return ExitCheckTuple(exit_type=ExitType.ROI)
if stoplossflag.exit_flag: if stoplossflag.exit_type == ExitType.STOP_LOSS:
logger.debug(f"{trade.pair} - Stoploss hit. exit_type={stoplossflag.exit_type}") logger.debug(f"{trade.pair} - Stoploss hit. exit_type={stoplossflag.exit_type}")
return stoplossflag exits.append(stoplossflag)
# This one is noisy, commented out... if roi_reached:
# logger.debug(f"{trade.pair} - No exit signal.") logger.debug(f"{trade.pair} - Required profit reached. exit_type=ExitType.ROI")
return ExitCheckTuple(exit_type=ExitType.NONE) exits.append(ExitCheckTuple(exit_type=ExitType.ROI))
if stoplossflag.exit_type == ExitType.TRAILING_STOP_LOSS:
logger.debug(f"{trade.pair} - Trailing stoploss hit.")
exits.append(stoplossflag)
return exits
def stop_loss_reached(self, current_rate: float, trade: Trade, def stop_loss_reached(self, current_rate: float, trade: Trade,
current_time: datetime, current_profit: float, current_time: datetime, current_profit: float,

View File

@ -495,37 +495,113 @@ def test_custom_exit(default_conf, fee, caplog) -> None:
enter=False, exit_=False, enter=False, exit_=False,
low=None, high=None) low=None, high=None)
assert res.exit_flag is False assert res == []
assert res.exit_type == ExitType.NONE
strategy.custom_exit = MagicMock(return_value=True) strategy.custom_exit = MagicMock(return_value=True)
res = strategy.should_exit(trade, 1, now, res = strategy.should_exit(trade, 1, now,
enter=False, exit_=False, enter=False, exit_=False,
low=None, high=None) low=None, high=None)
assert res.exit_flag is True assert res[0].exit_flag is True
assert res.exit_type == ExitType.CUSTOM_EXIT assert res[0].exit_type == ExitType.CUSTOM_EXIT
assert res.exit_reason == 'custom_exit' assert res[0].exit_reason == 'custom_exit'
strategy.custom_exit = MagicMock(return_value='hello world') strategy.custom_exit = MagicMock(return_value='hello world')
res = strategy.should_exit(trade, 1, now, res = strategy.should_exit(trade, 1, now,
enter=False, exit_=False, enter=False, exit_=False,
low=None, high=None) low=None, high=None)
assert res.exit_type == ExitType.CUSTOM_EXIT assert res[0].exit_type == ExitType.CUSTOM_EXIT
assert res.exit_flag is True assert res[0].exit_flag is True
assert res.exit_reason == 'hello world' assert res[0].exit_reason == 'hello world'
caplog.clear() caplog.clear()
strategy.custom_exit = MagicMock(return_value='h' * 100) strategy.custom_exit = MagicMock(return_value='h' * 100)
res = strategy.should_exit(trade, 1, now, res = strategy.should_exit(trade, 1, now,
enter=False, exit_=False, enter=False, exit_=False,
low=None, high=None) low=None, high=None)
assert res.exit_type == ExitType.CUSTOM_EXIT assert res[0].exit_type == ExitType.CUSTOM_EXIT
assert res.exit_flag is True assert res[0].exit_flag is True
assert res.exit_reason == 'h' * 64 assert res[0].exit_reason == 'h' * 64
assert log_has_re('Custom exit reason returned from custom_exit is too long.*', caplog) assert log_has_re('Custom exit reason returned from custom_exit is too long.*', caplog)
def test_should_sell(default_conf, fee) -> None:
strategy = StrategyResolver.load_strategy(default_conf)
trade = Trade(
pair='ETH/BTC',
stake_amount=0.01,
amount=1,
open_date=arrow.utcnow().shift(hours=-1).datetime,
fee_open=fee.return_value,
fee_close=fee.return_value,
exchange='binance',
open_rate=1,
)
now = arrow.utcnow().datetime
res = strategy.should_exit(trade, 1, now,
enter=False, exit_=False,
low=None, high=None)
assert res == []
strategy.min_roi_reached = MagicMock(return_value=True)
res = strategy.should_exit(trade, 1, now,
enter=False, exit_=False,
low=None, high=None)
assert len(res) == 1
assert res == [ExitCheckTuple(exit_type=ExitType.ROI)]
strategy.min_roi_reached = MagicMock(return_value=True)
strategy.stop_loss_reached = MagicMock(
return_value=ExitCheckTuple(exit_type=ExitType.STOP_LOSS))
res = strategy.should_exit(trade, 1, now,
enter=False, exit_=False,
low=None, high=None)
assert len(res) == 2
assert res == [
ExitCheckTuple(exit_type=ExitType.STOP_LOSS),
ExitCheckTuple(exit_type=ExitType.ROI),
]
strategy.custom_exit = MagicMock(return_value='hello world')
# custom-exit and exit-signal is first
res = strategy.should_exit(trade, 1, now,
enter=False, exit_=False,
low=None, high=None)
assert len(res) == 3
assert res == [
ExitCheckTuple(exit_type=ExitType.CUSTOM_EXIT, exit_reason='hello world'),
ExitCheckTuple(exit_type=ExitType.STOP_LOSS),
ExitCheckTuple(exit_type=ExitType.ROI),
]
strategy.stop_loss_reached = MagicMock(
return_value=ExitCheckTuple(exit_type=ExitType.TRAILING_STOP_LOSS))
# Regular exit signal
res = strategy.should_exit(trade, 1, now,
enter=False, exit_=True,
low=None, high=None)
assert len(res) == 3
assert res == [
ExitCheckTuple(exit_type=ExitType.EXIT_SIGNAL),
ExitCheckTuple(exit_type=ExitType.ROI),
ExitCheckTuple(exit_type=ExitType.TRAILING_STOP_LOSS),
]
# Regular exit signal, no ROI
strategy.min_roi_reached = MagicMock(return_value=False)
res = strategy.should_exit(trade, 1, now,
enter=False, exit_=True,
low=None, high=None)
assert len(res) == 2
assert res == [
ExitCheckTuple(exit_type=ExitType.EXIT_SIGNAL),
ExitCheckTuple(exit_type=ExitType.TRAILING_STOP_LOSS),
]
@pytest.mark.parametrize('side', TRADE_SIDES) @pytest.mark.parametrize('side', TRADE_SIDES)
def test_leverage_callback(default_conf, side) -> None: def test_leverage_callback(default_conf, side) -> None:
default_conf['strategy'] = 'StrategyTestV2' default_conf['strategy'] = 'StrategyTestV2'

View File

@ -52,8 +52,8 @@ def test_may_execute_exit_stoploss_on_exchange_multi(default_conf, ticker, fee,
side_effect=[stoploss_order_closed, stoploss_order_open, stoploss_order_open]) side_effect=[stoploss_order_closed, stoploss_order_open, stoploss_order_open])
# Sell 3rd trade (not called for the first trade) # Sell 3rd trade (not called for the first trade)
should_sell_mock = MagicMock(side_effect=[ should_sell_mock = MagicMock(side_effect=[
ExitCheckTuple(exit_type=ExitType.NONE), [],
ExitCheckTuple(exit_type=ExitType.EXIT_SIGNAL)] [ExitCheckTuple(exit_type=ExitType.EXIT_SIGNAL)]]
) )
cancel_order_mock = MagicMock() cancel_order_mock = MagicMock()
mocker.patch('freqtrade.exchange.Binance.stoploss', stoploss) mocker.patch('freqtrade.exchange.Binance.stoploss', stoploss)
@ -160,11 +160,11 @@ def test_forcebuy_last_unlimited(default_conf, ticker, fee, mocker, balance_rati
_notify_exit=MagicMock(), _notify_exit=MagicMock(),
) )
should_sell_mock = MagicMock(side_effect=[ should_sell_mock = MagicMock(side_effect=[
ExitCheckTuple(exit_type=ExitType.NONE), [],
ExitCheckTuple(exit_type=ExitType.EXIT_SIGNAL), [ExitCheckTuple(exit_type=ExitType.EXIT_SIGNAL)],
ExitCheckTuple(exit_type=ExitType.NONE), [],
ExitCheckTuple(exit_type=ExitType.NONE), [],
ExitCheckTuple(exit_type=ExitType.NONE)] []]
) )
mocker.patch("freqtrade.strategy.interface.IStrategy.should_exit", should_sell_mock) mocker.patch("freqtrade.strategy.interface.IStrategy.should_exit", should_sell_mock)