Update protection-interface to support per-side locks

This commit is contained in:
Matthias 2022-04-23 19:58:20 +02:00
parent 6ff3b178b0
commit 9e199165b4
9 changed files with 58 additions and 44 deletions

View File

@ -48,6 +48,8 @@ If `trade_limit` or more trades resulted in stoploss, trading will stop for `sto
This applies across all pairs, unless `only_per_pair` is set to true, which will then only look at one pair at a time. This applies across all pairs, unless `only_per_pair` is set to true, which will then only look at one pair at a time.
Similarly, this protection will by default look at all trades (long and short). For futures bots, setting `only_per_side` will make the bot only one side, and will then only lock this one side.
The below example stops trading for all pairs for 4 candles after the last trade if the bot hit stoploss 4 times within the last 24 candles. The below example stops trading for all pairs for 4 candles after the last trade if the bot hit stoploss 4 times within the last 24 candles.
``` python ``` python
@ -59,7 +61,8 @@ def protections(self):
"lookback_period_candles": 24, "lookback_period_candles": 24,
"trade_limit": 4, "trade_limit": 4,
"stop_duration_candles": 4, "stop_duration_candles": 4,
"only_per_pair": False "only_per_pair": False,
"only_per_side": True
} }
] ]
``` ```

View File

@ -1604,21 +1604,21 @@ class FreqtradeBot(LoggingMixin):
if not trade.is_open: if not trade.is_open:
if send_msg and not stoploss_order and not trade.open_order_id: if send_msg and not stoploss_order and not trade.open_order_id:
self._notify_exit(trade, '', True) self._notify_exit(trade, '', True)
self.handle_protections(trade.pair) self.handle_protections(trade.pair, trade.trade_direction)
elif send_msg and not trade.open_order_id: elif send_msg and not trade.open_order_id:
# Enter fill # Enter fill
self._notify_enter(trade, order, fill=True) self._notify_enter(trade, order, fill=True)
return False return False
def handle_protections(self, pair: str) -> None: def handle_protections(self, pair: str, side: str) -> None:
prot_trig = self.protections.stop_per_pair(pair) prot_trig = self.protections.stop_per_pair(pair, side=side)
if prot_trig: if prot_trig:
msg = {'type': RPCMessageType.PROTECTION_TRIGGER, } msg = {'type': RPCMessageType.PROTECTION_TRIGGER, }
msg.update(prot_trig.to_json()) msg.update(prot_trig.to_json())
self.rpc.send_msg(msg) self.rpc.send_msg(msg)
prot_trig_glb = self.protections.global_stop() prot_trig_glb = self.protections.global_stop(side=side)
if prot_trig_glb: if prot_trig_glb:
msg = {'type': RPCMessageType.PROTECTION_TRIGGER_GLOBAL, } msg = {'type': RPCMessageType.PROTECTION_TRIGGER_GLOBAL, }
msg.update(prot_trig_glb.to_json()) msg.update(prot_trig_glb.to_json())

View File

@ -849,10 +849,10 @@ class Backtesting:
return 'short' return 'short'
return None return None
def run_protections(self, enable_protections, pair: str, current_time: datetime): def run_protections(self, enable_protections, pair: str, current_time: datetime, side: str):
if enable_protections: if enable_protections:
self.protections.stop_per_pair(pair, current_time) self.protections.stop_per_pair(pair, current_time, side)
self.protections.global_stop(current_time) self.protections.global_stop(current_time, side)
def check_order_cancel(self, trade: LocalTrade, current_time) -> bool: def check_order_cancel(self, trade: LocalTrade, current_time) -> bool:
""" """
@ -1002,7 +1002,8 @@ class Backtesting:
LocalTrade.close_bt_trade(trade) LocalTrade.close_bt_trade(trade)
trades.append(trade) trades.append(trade)
self.wallets.update() self.wallets.update()
self.run_protections(enable_protections, pair, current_time) self.run_protections(
enable_protections, pair, current_time, trade.trade_direction)
# Move time one configured time_interval ahead. # Move time one configured time_interval ahead.
self.progress.increment() self.progress.increment()

View File

@ -44,13 +44,14 @@ class ProtectionManager():
""" """
return [{p.name: p.short_desc()} for p in self._protection_handlers] return [{p.name: p.short_desc()} for p in self._protection_handlers]
def global_stop(self, now: Optional[datetime] = None) -> Optional[PairLock]: def global_stop(self, now: Optional[datetime] = None, side: str = 'long') -> Optional[PairLock]:
if not now: if not now:
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
result = None result = None
for protection_handler in self._protection_handlers: for protection_handler in self._protection_handlers:
if protection_handler.has_global_stop: if protection_handler.has_global_stop:
lock, until, reason = protection_handler.global_stop(now) lock, until, reason, lock_side = protection_handler.global_stop(
date_now=now, side=side)
# Early stopping - first positive result blocks further trades # Early stopping - first positive result blocks further trades
if lock and until: if lock and until:
@ -58,13 +59,15 @@ class ProtectionManager():
result = PairLocks.lock_pair('*', until, reason, now=now) result = PairLocks.lock_pair('*', until, reason, now=now)
return result return result
def stop_per_pair(self, pair, now: Optional[datetime] = None) -> Optional[PairLock]: def stop_per_pair(
self, pair, now: Optional[datetime] = None, side: str = 'long') -> Optional[PairLock]:
if not now: if not now:
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
result = None result = None
for protection_handler in self._protection_handlers: for protection_handler in self._protection_handlers:
if protection_handler.has_local_stop: if protection_handler.has_local_stop:
lock, until, reason = protection_handler.stop_per_pair(pair, now) lock, until, reason, lock_side = protection_handler.stop_per_pair(
pair=pair, date_now=now, side=side)
if lock and until: if lock and until:
if not PairLocks.is_pair_locked(pair, until): if not PairLocks.is_pair_locked(pair, until):
result = PairLocks.lock_pair(pair, until, reason, now=now) result = PairLocks.lock_pair(pair, until, reason, now=now)

View File

@ -26,7 +26,7 @@ class CooldownPeriod(IProtection):
""" """
return (f"{self.name} - Cooldown period of {self.stop_duration_str}.") return (f"{self.name} - Cooldown period of {self.stop_duration_str}.")
def _cooldown_period(self, pair: str, date_now: datetime, ) -> ProtectionReturn: def _cooldown_period(self, pair: str, date_now: datetime) -> ProtectionReturn:
""" """
Get last trade for this pair Get last trade for this pair
""" """
@ -45,11 +45,11 @@ class CooldownPeriod(IProtection):
self.log_once(f"Cooldown for {pair} for {self.stop_duration_str}.", logger.info) self.log_once(f"Cooldown for {pair} for {self.stop_duration_str}.", logger.info)
until = self.calculate_lock_end([trade], self._stop_duration) until = self.calculate_lock_end([trade], self._stop_duration)
return True, until, self._reason() return True, until, self._reason(), None
return False, None, None return False, None, None, None
def global_stop(self, date_now: datetime) -> ProtectionReturn: def global_stop(self, date_now: datetime, side: str) -> ProtectionReturn:
""" """
Stops trading (position entering) for all pairs Stops trading (position entering) for all pairs
This must evaluate to true for the whole period of the "cooldown period". This must evaluate to true for the whole period of the "cooldown period".
@ -57,9 +57,9 @@ class CooldownPeriod(IProtection):
If true, all pairs will be locked with <reason> until <until> If true, all pairs will be locked with <reason> until <until>
""" """
# Not implemented for cooldown period. # Not implemented for cooldown period.
return False, None, None return False, None, None, None
def stop_per_pair(self, pair: str, date_now: datetime) -> ProtectionReturn: def stop_per_pair(self, pair: str, date_now: datetime, side: str) -> ProtectionReturn:
""" """
Stops trading (position entering) for this pair Stops trading (position entering) for this pair
This must evaluate to true for the whole period of the "cooldown period". This must evaluate to true for the whole period of the "cooldown period".

View File

@ -12,7 +12,8 @@ from freqtrade.persistence import LocalTrade
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
ProtectionReturn = Tuple[bool, Optional[datetime], Optional[str]] # lock, until, reason, lock_side
ProtectionReturn = Tuple[bool, Optional[datetime], Optional[str], Optional[str]]
class IProtection(LoggingMixin, ABC): class IProtection(LoggingMixin, ABC):
@ -80,14 +81,14 @@ class IProtection(LoggingMixin, ABC):
""" """
@abstractmethod @abstractmethod
def global_stop(self, date_now: datetime) -> ProtectionReturn: def global_stop(self, date_now: datetime, side: str) -> ProtectionReturn:
""" """
Stops trading (position entering) for all pairs Stops trading (position entering) for all pairs
This must evaluate to true for the whole period of the "cooldown period". This must evaluate to true for the whole period of the "cooldown period".
""" """
@abstractmethod @abstractmethod
def stop_per_pair(self, pair: str, date_now: datetime) -> ProtectionReturn: def stop_per_pair(self, pair: str, date_now: datetime, side: str) -> ProtectionReturn:
""" """
Stops trading (position entering) for this pair Stops trading (position entering) for this pair
This must evaluate to true for the whole period of the "cooldown period". This must evaluate to true for the whole period of the "cooldown period".

View File

@ -51,7 +51,7 @@ class LowProfitPairs(IProtection):
# trades = Trade.get_trades(filters).all() # trades = Trade.get_trades(filters).all()
if len(trades) < self._trade_limit: if len(trades) < self._trade_limit:
# Not enough trades in the relevant period # Not enough trades in the relevant period
return False, None, None return False, None, None, None
profit = sum(trade.close_profit for trade in trades if trade.close_profit) profit = sum(trade.close_profit for trade in trades if trade.close_profit)
if profit < self._required_profit: if profit < self._required_profit:
@ -60,20 +60,20 @@ class LowProfitPairs(IProtection):
f"within {self._lookback_period} minutes.", logger.info) f"within {self._lookback_period} minutes.", logger.info)
until = self.calculate_lock_end(trades, self._stop_duration) until = self.calculate_lock_end(trades, self._stop_duration)
return True, until, self._reason(profit) return True, until, self._reason(profit), None
return False, None, None return False, None, None, None
def global_stop(self, date_now: datetime) -> ProtectionReturn: def global_stop(self, date_now: datetime, side: str) -> ProtectionReturn:
""" """
Stops trading (position entering) for all pairs Stops trading (position entering) for all pairs
This must evaluate to true for the whole period of the "cooldown period". This must evaluate to true for the whole period of the "cooldown period".
:return: Tuple of [bool, until, reason]. :return: Tuple of [bool, until, reason].
If true, all pairs will be locked with <reason> until <until> If true, all pairs will be locked with <reason> until <until>
""" """
return False, None, None return False, None, None, None
def stop_per_pair(self, pair: str, date_now: datetime) -> ProtectionReturn: def stop_per_pair(self, pair: str, date_now: datetime, side: str) -> ProtectionReturn:
""" """
Stops trading (position entering) for this pair Stops trading (position entering) for this pair
This must evaluate to true for the whole period of the "cooldown period". This must evaluate to true for the whole period of the "cooldown period".

View File

@ -51,14 +51,14 @@ class MaxDrawdown(IProtection):
if len(trades) < self._trade_limit: if len(trades) < self._trade_limit:
# Not enough trades in the relevant period # Not enough trades in the relevant period
return False, None, None return False, None, None, None
# Drawdown is always positive # Drawdown is always positive
try: try:
# TODO: This should use absolute profit calculation, considering account balance. # TODO: This should use absolute profit calculation, considering account balance.
drawdown, _, _, _, _, _ = calculate_max_drawdown(trades_df, value_col='close_profit') drawdown, _, _, _, _, _ = calculate_max_drawdown(trades_df, value_col='close_profit')
except ValueError: except ValueError:
return False, None, None return False, None, None, None
if drawdown > self._max_allowed_drawdown: if drawdown > self._max_allowed_drawdown:
self.log_once( self.log_once(
@ -66,11 +66,11 @@ class MaxDrawdown(IProtection):
f" within {self.lookback_period_str}.", logger.info) f" within {self.lookback_period_str}.", logger.info)
until = self.calculate_lock_end(trades, self._stop_duration) until = self.calculate_lock_end(trades, self._stop_duration)
return True, until, self._reason(drawdown) return True, until, self._reason(drawdown), None
return False, None, None return False, None, None, None
def global_stop(self, date_now: datetime) -> ProtectionReturn: def global_stop(self, date_now: datetime, side: str) -> ProtectionReturn:
""" """
Stops trading (position entering) for all pairs Stops trading (position entering) for all pairs
This must evaluate to true for the whole period of the "cooldown period". This must evaluate to true for the whole period of the "cooldown period".
@ -79,11 +79,11 @@ class MaxDrawdown(IProtection):
""" """
return self._max_drawdown(date_now) return self._max_drawdown(date_now)
def stop_per_pair(self, pair: str, date_now: datetime) -> ProtectionReturn: def stop_per_pair(self, pair: str, date_now: datetime, side: str) -> ProtectionReturn:
""" """
Stops trading (position entering) for this pair Stops trading (position entering) for this pair
This must evaluate to true for the whole period of the "cooldown period". This must evaluate to true for the whole period of the "cooldown period".
:return: Tuple of [bool, until, reason]. :return: Tuple of [bool, until, reason].
If true, this pair will be locked with <reason> until <until> If true, this pair will be locked with <reason> until <until>
""" """
return False, None, None return False, None, None, None

View File

@ -1,7 +1,7 @@
import logging import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Any, Dict from typing import Any, Dict, Optional
from freqtrade.enums import ExitType from freqtrade.enums import ExitType
from freqtrade.persistence import Trade from freqtrade.persistence import Trade
@ -21,6 +21,7 @@ class StoplossGuard(IProtection):
self._trade_limit = protection_config.get('trade_limit', 10) self._trade_limit = protection_config.get('trade_limit', 10)
self._disable_global_stop = protection_config.get('only_per_pair', False) self._disable_global_stop = protection_config.get('only_per_pair', False)
self._only_per_side = protection_config.get('only_per_side', False)
def short_desc(self) -> str: def short_desc(self) -> str:
""" """
@ -36,7 +37,8 @@ class StoplossGuard(IProtection):
return (f'{self._trade_limit} stoplosses in {self._lookback_period} min, ' return (f'{self._trade_limit} stoplosses in {self._lookback_period} min, '
f'locking for {self._stop_duration} min.') f'locking for {self._stop_duration} min.')
def _stoploss_guard(self, date_now: datetime, pair: str = None) -> ProtectionReturn: def _stoploss_guard(
self, date_now: datetime, pair: Optional[str], side: str) -> ProtectionReturn:
""" """
Evaluate recent trades Evaluate recent trades
""" """
@ -48,15 +50,19 @@ class StoplossGuard(IProtection):
ExitType.STOPLOSS_ON_EXCHANGE.value) ExitType.STOPLOSS_ON_EXCHANGE.value)
and trade.close_profit and trade.close_profit < 0)] and trade.close_profit and trade.close_profit < 0)]
if self._only_per_side and side:
# Long or short trades only
trades = [trade for trade in trades if trade.trade_direction == side]
if len(trades) < self._trade_limit: if len(trades) < self._trade_limit:
return False, None, None return False, None, None, None
self.log_once(f"Trading stopped due to {self._trade_limit} " self.log_once(f"Trading stopped due to {self._trade_limit} "
f"stoplosses within {self._lookback_period} minutes.", logger.info) f"stoplosses within {self._lookback_period} minutes.", logger.info)
until = self.calculate_lock_end(trades, self._stop_duration) until = self.calculate_lock_end(trades, self._stop_duration)
return True, until, self._reason() return True, until, self._reason(), (side if self._only_per_side else None)
def global_stop(self, date_now: datetime) -> ProtectionReturn: def global_stop(self, date_now: datetime, side: str) -> ProtectionReturn:
""" """
Stops trading (position entering) for all pairs Stops trading (position entering) for all pairs
This must evaluate to true for the whole period of the "cooldown period". This must evaluate to true for the whole period of the "cooldown period".
@ -64,14 +70,14 @@ class StoplossGuard(IProtection):
If true, all pairs will be locked with <reason> until <until> If true, all pairs will be locked with <reason> until <until>
""" """
if self._disable_global_stop: if self._disable_global_stop:
return False, None, None return False, None, None, None
return self._stoploss_guard(date_now, None) return self._stoploss_guard(date_now, None, side)
def stop_per_pair(self, pair: str, date_now: datetime) -> ProtectionReturn: def stop_per_pair(self, pair: str, date_now: datetime, side: str) -> ProtectionReturn:
""" """
Stops trading (position entering) for this pair Stops trading (position entering) for this pair
This must evaluate to true for the whole period of the "cooldown period". This must evaluate to true for the whole period of the "cooldown period".
:return: Tuple of [bool, until, reason]. :return: Tuple of [bool, until, reason].
If true, this pair will be locked with <reason> until <until> If true, this pair will be locked with <reason> until <until>
""" """
return self._stoploss_guard(date_now, pair) return self._stoploss_guard(date_now, pair, side)