From 2b456cbdeb8ac2250de382613d7488ceae1707cf Mon Sep 17 00:00:00 2001 From: simwai <16225108+simwai@users.noreply.github.com> Date: Thu, 4 Jul 2024 10:29:13 +0200 Subject: [PATCH] Added unlock_at field for protection config --- freqtrade/configuration/config_validation.py | 27 +++++- freqtrade/constants.py | 1 + .../plugins/protections/cooldown_period.py | 5 +- freqtrade/plugins/protections/iprotection.py | 38 +++++++- .../plugins/protections/low_profit_pairs.py | 5 +- .../protections/max_drawdown_protection.py | 5 +- .../plugins/protections/stoploss_guard.py | 5 +- tests/plugins/test_protections.py | 86 ++++++++++++++++--- 8 files changed, 151 insertions(+), 21 deletions(-) diff --git a/freqtrade/configuration/config_validation.py b/freqtrade/configuration/config_validation.py index 3f8e5c9ef..597752614 100644 --- a/freqtrade/configuration/config_validation.py +++ b/freqtrade/configuration/config_validation.py @@ -1,6 +1,7 @@ import logging from collections import Counter from copy import deepcopy +from datetime import datetime from typing import Any, Dict from jsonschema import Draft4Validator, validators @@ -192,18 +193,40 @@ def _validate_protections(conf: Dict[str, Any]) -> None: """ for prot in conf.get("protections", []): + parsed_unlock_at = _validate_unlock_at(prot) + if "stop_duration" in prot and "stop_duration_candles" in prot: raise ConfigurationError( "Protections must specify either `stop_duration` or `stop_duration_candles`.\n" - f"Please fix the protection {prot.get('method')}" + f"Please fix the protection {prot.get('method')}." ) if "lookback_period" in prot and "lookback_period_candles" in prot: raise ConfigurationError( "Protections must specify either `lookback_period` or `lookback_period_candles`.\n" - f"Please fix the protection {prot.get('method')}" + f"Please fix the protection {prot.get('method')}." ) + if parsed_unlock_at is not None and "stop_duration" in prot: + raise ConfigurationError( + "Protections must specify either `unlock_at` or `stop_duration`.\n" + f"Please fix the protection {prot.get('method')}." + ) + + if parsed_unlock_at is not None and "stop_duration_candles" in prot: + raise ConfigurationError( + "Protections must specify either `unlock_at` or `stop_duration_candles`.\n" + f"Please fix the protection {prot.get('method')}." + ) + + +def _validate_unlock_at(config_unlock_at: str) -> datetime: + if config_unlock_at is not None and isinstance(config_unlock_at, str): + try: + return datetime.strptime(config_unlock_at, "%H:%M") + except ValueError: + raise ConfigurationError(f"Invalid date format for unlock_at: {config_unlock_at}.") + def _validate_ask_orderbook(conf: Dict[str, Any]) -> None: ask_strategy = conf.get("exit_pricing", {}) diff --git a/freqtrade/constants.py b/freqtrade/constants.py index f8f1ac7ee..88031d65b 100644 --- a/freqtrade/constants.py +++ b/freqtrade/constants.py @@ -356,6 +356,7 @@ CONF_SCHEMA = { "properties": { "method": {"type": "string", "enum": AVAILABLE_PROTECTIONS}, "stop_duration": {"type": "number", "minimum": 0.0}, + "unlock_at": {"type": "string"}, "stop_duration_candles": {"type": "number", "minimum": 0}, "trade_limit": {"type": "number", "minimum": 1}, "lookback_period": {"type": "number", "minimum": 1}, diff --git a/freqtrade/plugins/protections/cooldown_period.py b/freqtrade/plugins/protections/cooldown_period.py index 2948d17d0..9b75cb50d 100644 --- a/freqtrade/plugins/protections/cooldown_period.py +++ b/freqtrade/plugins/protections/cooldown_period.py @@ -18,7 +18,10 @@ class CooldownPeriod(IProtection): """ LockReason to use """ - return f"Cooldown period for {self.stop_duration_str}." + reason = f"Cooldown period for {self.stop_duration_str}." + if self.unlock_at_str is not None: + reason += f" Unlocking trading at {self.unlock_at_str}." + return reason def short_desc(self) -> str: """ diff --git a/freqtrade/plugins/protections/iprotection.py b/freqtrade/plugins/protections/iprotection.py index 204a8b827..a3ddcfe33 100644 --- a/freqtrade/plugins/protections/iprotection.py +++ b/freqtrade/plugins/protections/iprotection.py @@ -2,7 +2,7 @@ import logging from abc import ABC, abstractmethod from dataclasses import dataclass from datetime import datetime, timedelta, timezone -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Union from freqtrade.constants import Config, LongShort from freqtrade.exchange import timeframe_to_minutes @@ -33,21 +33,33 @@ class IProtection(LoggingMixin, ABC): self._protection_config = protection_config self._stop_duration_candles: Optional[int] = None self._lookback_period_candles: Optional[int] = None + self.unlock_at: Optional[datetime] = None tf_in_min = timeframe_to_minutes(config["timeframe"]) if "stop_duration_candles" in protection_config: self._stop_duration_candles = int(protection_config.get("stop_duration_candles", 1)) self._stop_duration = tf_in_min * self._stop_duration_candles else: - self._stop_duration_candles = None self._stop_duration = int(protection_config.get("stop_duration", 60)) if "lookback_period_candles" in protection_config: self._lookback_period_candles = int(protection_config.get("lookback_period_candles", 1)) self._lookback_period = tf_in_min * self._lookback_period_candles else: - self._lookback_period_candles = None self._lookback_period = int(protection_config.get("lookback_period", 60)) + if "unlock_at" in protection_config: + now_time = datetime.now(timezone.utc) + unlock_at = datetime.strptime(protection_config["unlock_at"], "%H:%M").replace( + day=now_time.day, year=now_time.year, month=now_time.month + ) + + if unlock_at.time() < now_time.time(): + unlock_at = unlock_at.replace(day=now_time.day + 1) + + unlock_at = unlock_at.replace(tzinfo=timezone.utc) + self._stop_duration = self.calculate_timespan(now_time, unlock_at) + self.unlock_at = unlock_at + LoggingMixin.__init__(self, logger) @property @@ -80,6 +92,15 @@ class IProtection(LoggingMixin, ABC): else: return f"{self._lookback_period} {plural(self._lookback_period, 'minute', 'minutes')}" + @property + def unlock_at_str(self) -> Union[str, None]: + """ + Output configured unlock time + """ + if self.unlock_at: + return self.unlock_at.strftime("%H:%M") + return None + @abstractmethod def short_desc(self) -> str: """ @@ -118,3 +139,14 @@ class IProtection(LoggingMixin, ABC): until = max_date + timedelta(minutes=stop_minutes) return until + + @staticmethod + def calculate_timespan(start_time: datetime, end_time: datetime) -> int: + """ + Calculate the timespan between two datetime objects in minutes. + + :param start_time: The start datetime. + :param end_time: The end datetime. + :return: The difference between the two datetimes in minutes. + """ + return int((end_time - start_time).total_seconds() / 60) diff --git a/freqtrade/plugins/protections/low_profit_pairs.py b/freqtrade/plugins/protections/low_profit_pairs.py index 360f6721c..5904ca276 100644 --- a/freqtrade/plugins/protections/low_profit_pairs.py +++ b/freqtrade/plugins/protections/low_profit_pairs.py @@ -34,10 +34,13 @@ class LowProfitPairs(IProtection): """ LockReason to use """ - return ( + reason = ( f"{profit} < {self._required_profit} in {self.lookback_period_str}, " f"locking for {self.stop_duration_str}." ) + if self.unlock_at_str is not None: + reason += f" Unlocking trading at {self.unlock_at_str}." + return reason def _low_profit( self, date_now: datetime, pair: str, side: LongShort diff --git a/freqtrade/plugins/protections/max_drawdown_protection.py b/freqtrade/plugins/protections/max_drawdown_protection.py index a1ba166fa..fcecdc3d0 100644 --- a/freqtrade/plugins/protections/max_drawdown_protection.py +++ b/freqtrade/plugins/protections/max_drawdown_protection.py @@ -37,10 +37,13 @@ class MaxDrawdown(IProtection): """ LockReason to use """ - return ( + reason = ( f"{drawdown} passed {self._max_allowed_drawdown} in {self.lookback_period_str}, " f"locking for {self.stop_duration_str}." ) + if self.unlock_at_str is not None: + reason += f" Unlocking trading at {self.unlock_at_str}." + return reason def _max_drawdown(self, date_now: datetime) -> Optional[ProtectionReturn]: """ diff --git a/freqtrade/plugins/protections/stoploss_guard.py b/freqtrade/plugins/protections/stoploss_guard.py index a9aca20b4..42b04fba7 100644 --- a/freqtrade/plugins/protections/stoploss_guard.py +++ b/freqtrade/plugins/protections/stoploss_guard.py @@ -36,10 +36,13 @@ class StoplossGuard(IProtection): """ LockReason to use """ - return ( + reason = ( f"{self._trade_limit} stoplosses in {self._lookback_period} min, " f"locking for {self._stop_duration} min." ) + if self.unlock_at_str is not None: + reason += f" Unlocking trading at {self.unlock_at_str}." + return reason def _stoploss_guard( self, date_now: datetime, pair: Optional[str], side: LongShort diff --git a/tests/plugins/test_protections.py b/tests/plugins/test_protections.py index c8a8fdf20..94bfc8d1f 100644 --- a/tests/plugins/test_protections.py +++ b/tests/plugins/test_protections.py @@ -102,56 +102,94 @@ def test_protectionmanager(mocker, default_conf): @pytest.mark.parametrize( - "timeframe,expected,protconf", + "timeframe,expected_lookback,expected_stop,protconf", [ ( "1m", - [20, 10], + 20, + 10, [{"method": "StoplossGuard", "lookback_period_candles": 20, "stop_duration": 10}], ), ( "5m", - [100, 15], + 100, + 15, [{"method": "StoplossGuard", "lookback_period_candles": 20, "stop_duration": 15}], ), ( "1h", - [1200, 40], + 1200, + 40, [{"method": "StoplossGuard", "lookback_period_candles": 20, "stop_duration": 40}], ), ( "1d", - [1440, 5], + 1440, + 5, [{"method": "StoplossGuard", "lookback_period_candles": 1, "stop_duration": 5}], ), ( "1m", - [20, 5], + 20, + 5, [{"method": "StoplossGuard", "lookback_period": 20, "stop_duration_candles": 5}], ), ( "5m", - [15, 25], + 15, + 25, [{"method": "StoplossGuard", "lookback_period": 15, "stop_duration_candles": 5}], ), ( "1h", - [50, 600], + 50, + 600, [{"method": "StoplossGuard", "lookback_period": 50, "stop_duration_candles": 10}], ), ( "1h", - [60, 540], + 60, + 540, [{"method": "StoplossGuard", "lookback_period_candles": 1, "stop_duration_candles": 9}], ), + ( + "1m", + 20, + "01:00", + [{"method": "StoplossGuard", "lookback_period_candles": 20, "unlock_at": "01:00"}], + ), + ( + "5m", + 100, + "02:00", + [{"method": "StoplossGuard", "lookback_period_candles": 20, "unlock_at": "02:00"}], + ), + ( + "1h", + 1200, + "03:00", + [{"method": "StoplossGuard", "lookback_period_candles": 20, "unlock_at": "03:00"}], + ), + ( + "1d", + 1440, + "04:00", + [{"method": "StoplossGuard", "lookback_period_candles": 1, "unlock_at": "04:00"}], + ), ], ) -def test_protections_init(default_conf, timeframe, expected, protconf): +def test_protections_init(default_conf, timeframe, expected_lookback, expected_stop, protconf): + """ + Test the initialization of protections with different configurations, including unlock_at. + """ default_conf["timeframe"] = timeframe man = ProtectionManager(default_conf, protconf) assert len(man._protection_handlers) == len(protconf) - assert man._protection_handlers[0]._lookback_period == expected[0] - assert man._protection_handlers[0]._stop_duration == expected[1] + assert man._protection_handlers[0]._lookback_period == expected_lookback + if isinstance(expected_stop, int): + assert man._protection_handlers[0]._stop_duration == expected_stop + else: + assert man._protection_handlers[0].unlock_at.strftime("%H:%M") == expected_stop @pytest.mark.parametrize("is_short", [False, True]) @@ -654,6 +692,30 @@ def test_MaxDrawdown(mocker, default_conf, fee, caplog): "if drawdown is > 0.0 within 20 candles.'}]", None, ), + ( + { + "method": "StoplossGuard", + "lookback_period_candles": 12, + "trade_limit": 2, + "required_profit": -0.05, + "unlock_at": "01:00", + }, + "[{'StoplossGuard': 'StoplossGuard - Frequent Stoploss Guard, " + "2 stoplosses with profit < -5.00% within 12 candles. Unlocking trading at 01:00.'}]", + None, + ), + ( + {"method": "LowProfitPairs", "lookback_period_candles": 11, "unlock_at": "03:00"}, + "[{'LowProfitPairs': 'LowProfitPairs - Low Profit Protection, locks pairs with " + "profit < 0.0 within 11 candles. Unlocking trading at 03:00.'}]", + None, + ), + ( + {"method": "MaxDrawdown", "lookback_period_candles": 20, "unlock_at": "04:00"}, + "[{'MaxDrawdown': 'MaxDrawdown - Max drawdown protection, stop trading " + "if drawdown is > 0.0 within 20 candles. Unlocking trading at 04:00.'}]", + None, + ), ], ) def test_protection_manager_desc(