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)
- Evaluation sequence (if multiple signals happen on the same candle)
- Exit-signal
- ROI (if not 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.
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()` 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
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
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
def exit_flag(self):
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
"""
should_exit: ExitCheckTuple = self.strategy.should_exit(
exits: List[ExitCheckTuple] = self.strategy.should_exit(
trade,
exit_rate,
datetime.now(timezone.utc),
@ -1114,12 +1114,13 @@ class FreqtradeBot(LoggingMixin):
exit_=exit_,
force_stoploss=self.edge.stoploss(trade.pair) if self.edge else 0
)
if should_exit.exit_flag:
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"}')
self.execute_trade_exit(trade, exit_rate, should_exit, exit_tag=exit_tag)
return True
for should_exit in exits:
if should_exit.exit_flag:
logger.info(f'Exit for {trade.pair} detected. Reason: {should_exit.exit_type}'
f'{f" Tag: {exit_tag}" if exit_tag is not None else ""}')
exited = self.execute_trade_exit(trade, exit_rate, should_exit, exit_tag=exit_tag)
if exited:
return True
return False
def manage_open_orders(self) -> None:
@ -1406,7 +1407,7 @@ class FreqtradeBot(LoggingMixin):
:param trade: Trade instance
:param limit: limit rate for the sell order
: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(
pair=trade.pair,
@ -1453,7 +1454,7 @@ class FreqtradeBot(LoggingMixin):
time_in_force=time_in_force, exit_reason=exit_reason,
sell_reason=exit_reason, # sellreason -> compatibility
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
try:

View File

@ -531,15 +531,23 @@ class Backtesting:
if check_adjust_entry:
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]
exit_sig = row[ESHORT_IDX] if trade.is_short else row[ELONG_IDX]
exit_ = self.strategy.should_exit(
trade, row[OPEN_IDX], exit_candle_time, # type: ignore
exits = self.strategy.should_exit(
trade, row[OPEN_IDX], row[DATE_IDX].to_pydatetime(), # type: ignore
enter=enter, exit_=exit_sig,
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:
trade.close_date = exit_candle_time
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, *,
enter: bool, exit_: bool,
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
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 high: Only used during backtesting, to simulate (short)stoploss/(long)ROI
: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_profit = trade.calc_profit_ratio(current_rate)
@ -938,24 +938,29 @@ class IStrategy(ABC, HyperStrategyMixin):
logger.debug(f"{trade.pair} - Sell signal received. "
f"exit_type=ExitType.{exit_signal.name}" +
(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:
# Exit-signal
# ROI (if not stoploss)
# Stoploss
if roi_reached and stoplossflag.exit_type != ExitType.STOP_LOSS:
logger.debug(f"{trade.pair} - Required profit reached. exit_type=ExitType.ROI")
return ExitCheckTuple(exit_type=ExitType.ROI)
# ROI
# Trailing stoploss
if stoplossflag.exit_flag:
if stoplossflag.exit_type == ExitType.STOP_LOSS:
logger.debug(f"{trade.pair} - Stoploss hit. exit_type={stoplossflag.exit_type}")
return stoplossflag
exits.append(stoplossflag)
# This one is noisy, commented out...
# logger.debug(f"{trade.pair} - No exit signal.")
return ExitCheckTuple(exit_type=ExitType.NONE)
if roi_reached:
logger.debug(f"{trade.pair} - Required profit reached. exit_type=ExitType.ROI")
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,
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,
low=None, high=None)
assert res.exit_flag is False
assert res.exit_type == ExitType.NONE
assert res == []
strategy.custom_exit = MagicMock(return_value=True)
res = strategy.should_exit(trade, 1, now,
enter=False, exit_=False,
low=None, high=None)
assert res.exit_flag is True
assert res.exit_type == ExitType.CUSTOM_EXIT
assert res.exit_reason == 'custom_exit'
assert res[0].exit_flag is True
assert res[0].exit_type == ExitType.CUSTOM_EXIT
assert res[0].exit_reason == 'custom_exit'
strategy.custom_exit = MagicMock(return_value='hello world')
res = strategy.should_exit(trade, 1, now,
enter=False, exit_=False,
low=None, high=None)
assert res.exit_type == ExitType.CUSTOM_EXIT
assert res.exit_flag is True
assert res.exit_reason == 'hello world'
assert res[0].exit_type == ExitType.CUSTOM_EXIT
assert res[0].exit_flag is True
assert res[0].exit_reason == 'hello world'
caplog.clear()
strategy.custom_exit = MagicMock(return_value='h' * 100)
res = strategy.should_exit(trade, 1, now,
enter=False, exit_=False,
low=None, high=None)
assert res.exit_type == ExitType.CUSTOM_EXIT
assert res.exit_flag is True
assert res.exit_reason == 'h' * 64
assert res[0].exit_type == ExitType.CUSTOM_EXIT
assert res[0].exit_flag is True
assert res[0].exit_reason == 'h' * 64
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)
def test_leverage_callback(default_conf, side) -> None:
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])
# Sell 3rd trade (not called for the first trade)
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()
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(),
)
should_sell_mock = MagicMock(side_effect=[
ExitCheckTuple(exit_type=ExitType.NONE),
ExitCheckTuple(exit_type=ExitType.EXIT_SIGNAL),
ExitCheckTuple(exit_type=ExitType.NONE),
ExitCheckTuple(exit_type=ExitType.NONE),
ExitCheckTuple(exit_type=ExitType.NONE)]
[],
[ExitCheckTuple(exit_type=ExitType.EXIT_SIGNAL)],
[],
[],
[]]
)
mocker.patch("freqtrade.strategy.interface.IStrategy.should_exit", should_sell_mock)