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 01/19] 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( From 57118691d813c5b98e38dbeaf9eff1ee0019e65f Mon Sep 17 00:00:00 2001 From: simwai <16225108+simwai@users.noreply.github.com> Date: Thu, 4 Jul 2024 10:53:14 +0200 Subject: [PATCH 02/19] Removed entry in gitignore --- .gitignore | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 1e96fd5da..d371c9dd9 100644 --- a/.gitignore +++ b/.gitignore @@ -115,5 +115,4 @@ target/ !config_examples/config_kraken.example.json !config_examples/config_freqai.example.json -config_examples/nfi_configs/*.json -docker-compose-*.yml \ No newline at end of file +docker-compose-*.yml From 77b4689ac8ffea0ea734d76bdfd9e77d9cfd24db Mon Sep 17 00:00:00 2001 From: simwai <16225108+simwai@users.noreply.github.com> Date: Fri, 5 Jul 2024 22:14:35 +0200 Subject: [PATCH 03/19] Fixed implementation of unlock_at and updated unit tests --- .../plugins/protections/cooldown_period.py | 3 ++ freqtrade/plugins/protections/iprotection.py | 43 +++++++++++++------ .../plugins/protections/low_profit_pairs.py | 2 + .../protections/max_drawdown_protection.py | 2 + .../plugins/protections/stoploss_guard.py | 3 ++ tests/plugins/test_protections.py | 8 ++-- 6 files changed, 45 insertions(+), 16 deletions(-) diff --git a/freqtrade/plugins/protections/cooldown_period.py b/freqtrade/plugins/protections/cooldown_period.py index 9b75cb50d..56c790d55 100644 --- a/freqtrade/plugins/protections/cooldown_period.py +++ b/freqtrade/plugins/protections/cooldown_period.py @@ -46,6 +46,9 @@ class CooldownPeriod(IProtection): # Ignore type error as we know we only get closed trades. trade = sorted(trades, key=lambda t: t.close_date)[-1] # type: ignore self.log_once(f"Cooldown for {pair} for {self.stop_duration_str}.", logger.info) + + self.set_unlock_at_as_stop_duration() + until = self.calculate_lock_end([trade], self._stop_duration) return ProtectionReturn( diff --git a/freqtrade/plugins/protections/iprotection.py b/freqtrade/plugins/protections/iprotection.py index a3ddcfe33..c6cdd3665 100644 --- a/freqtrade/plugins/protections/iprotection.py +++ b/freqtrade/plugins/protections/iprotection.py @@ -47,18 +47,7 @@ class IProtection(LoggingMixin, ABC): else: 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 + self.set_unlock_at_as_stop_duration() LoggingMixin.__init__(self, logger) @@ -101,6 +90,36 @@ class IProtection(LoggingMixin, ABC): return self.unlock_at.strftime("%H:%M") return None + def set_unlock_at_as_stop_duration(self) -> None: + """ + Calculates the stop_duration based on the unlock_at protection config value and sets it. + """ + if "unlock_at" in self._protection_config: + self._stop_duration = self.calculate_unlock_at() + return None + + logger.warning( + "Couldn't update the stop duration, because unlock_at is not set in the " + "protection config." + ) + + def calculate_unlock_at(self) -> int: + """ + Calculate and update the stop duration based on the unlock at config. + """ + + now_time = datetime.now(timezone.utc) + unlock_at = datetime.strptime( + str(self._protection_config.get("unlock_at_config")), "%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) + + self.unlock_at = unlock_at.replace(tzinfo=timezone.utc) + result = IProtection.calculate_timespan(now_time, self.unlock_at) + return result + @abstractmethod def short_desc(self) -> str: """ diff --git a/freqtrade/plugins/protections/low_profit_pairs.py b/freqtrade/plugins/protections/low_profit_pairs.py index 5904ca276..6024fe894 100644 --- a/freqtrade/plugins/protections/low_profit_pairs.py +++ b/freqtrade/plugins/protections/low_profit_pairs.py @@ -73,6 +73,8 @@ class LowProfitPairs(IProtection): f"within {self._lookback_period} minutes.", logger.info, ) + + self.set_unlock_at_as_stop_duration() until = self.calculate_lock_end(trades, self._stop_duration) return ProtectionReturn( diff --git a/freqtrade/plugins/protections/max_drawdown_protection.py b/freqtrade/plugins/protections/max_drawdown_protection.py index fcecdc3d0..3f97d418e 100644 --- a/freqtrade/plugins/protections/max_drawdown_protection.py +++ b/freqtrade/plugins/protections/max_drawdown_protection.py @@ -73,6 +73,8 @@ class MaxDrawdown(IProtection): f" within {self.lookback_period_str}.", logger.info, ) + + self.set_unlock_at_as_stop_duration() until = self.calculate_lock_end(trades, self._stop_duration) return ProtectionReturn( diff --git a/freqtrade/plugins/protections/stoploss_guard.py b/freqtrade/plugins/protections/stoploss_guard.py index 42b04fba7..329b6b772 100644 --- a/freqtrade/plugins/protections/stoploss_guard.py +++ b/freqtrade/plugins/protections/stoploss_guard.py @@ -81,7 +81,10 @@ class StoplossGuard(IProtection): f"stoplosses within {self._lookback_period} minutes.", logger.info, ) + + self.set_unlock_at_as_stop_duration() until = self.calculate_lock_end(trades, self._stop_duration) + return ProtectionReturn( lock=True, until=until, diff --git a/tests/plugins/test_protections.py b/tests/plugins/test_protections.py index 94bfc8d1f..4c76693c8 100644 --- a/tests/plugins/test_protections.py +++ b/tests/plugins/test_protections.py @@ -189,7 +189,7 @@ def test_protections_init(default_conf, timeframe, expected_lookback, expected_s 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 + assert man._protection_handlers[0].unlock_at_str == expected_stop @pytest.mark.parametrize("is_short", [False, True]) @@ -701,19 +701,19 @@ def test_MaxDrawdown(mocker, default_conf, fee, caplog): "unlock_at": "01:00", }, "[{'StoplossGuard': 'StoplossGuard - Frequent Stoploss Guard, " - "2 stoplosses with profit < -5.00% within 12 candles. Unlocking trading at 01:00.'}]", + "2 stoplosses with profit < -5.00% within 12 candles.'}]", 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.'}]", + "profit < 0.0 within 11 candles.'}]", 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.'}]", + "if drawdown is > 0.0 within 20 candles.'}]", None, ), ], From af505b346c00f496d546e3b9f6a80be55a4d816d Mon Sep 17 00:00:00 2001 From: simwai <16225108+simwai@users.noreply.github.com> Date: Fri, 5 Jul 2024 22:17:40 +0200 Subject: [PATCH 04/19] Fixed an access on the config by a wrong config key --- freqtrade/plugins/protections/iprotection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/freqtrade/plugins/protections/iprotection.py b/freqtrade/plugins/protections/iprotection.py index c6cdd3665..da0fc7a78 100644 --- a/freqtrade/plugins/protections/iprotection.py +++ b/freqtrade/plugins/protections/iprotection.py @@ -110,7 +110,7 @@ class IProtection(LoggingMixin, ABC): now_time = datetime.now(timezone.utc) unlock_at = datetime.strptime( - str(self._protection_config.get("unlock_at_config")), "%H:%M" + str(self._protection_config.get("unlock_at")), "%H:%M" ).replace(day=now_time.day, year=now_time.year, month=now_time.month) if unlock_at.time() < now_time.time(): From be894664ef59f1adbca7156e6db4600aa3ddc82b Mon Sep 17 00:00:00 2001 From: simwai <16225108+simwai@users.noreply.github.com> Date: Sun, 14 Jul 2024 21:46:22 +0200 Subject: [PATCH 05/19] Fixed building of wrong reason texts Removed unnecessary method set_unlock_at_as_stop_duration() --- .../plugins/protections/cooldown_period.py | 24 ++++++++---- freqtrade/plugins/protections/iprotection.py | 37 ++++++------------- .../plugins/protections/low_profit_pairs.py | 15 ++++---- .../protections/max_drawdown_protection.py | 12 ++++-- .../plugins/protections/stoploss_guard.py | 15 ++++---- 5 files changed, 51 insertions(+), 52 deletions(-) diff --git a/freqtrade/plugins/protections/cooldown_period.py b/freqtrade/plugins/protections/cooldown_period.py index 56c790d55..30a611189 100644 --- a/freqtrade/plugins/protections/cooldown_period.py +++ b/freqtrade/plugins/protections/cooldown_period.py @@ -18,16 +18,23 @@ class CooldownPeriod(IProtection): """ LockReason to use """ - reason = f"Cooldown period for {self.stop_duration_str}." + reason = "Cooldown period" + if self.unlock_at_str is not None: - reason += f" Unlocking trading at {self.unlock_at_str}." - return reason + return f"{reason} until {self.unlock_at_str}." + else: + return f"{reason}of {self.stop_duration_str}." def short_desc(self) -> str: """ - Short method description - used for startup-messages + Short method description - used for startup messages """ - return f"{self.name} - Cooldown period of {self.stop_duration_str}." + result = f"{self.name} - Cooldown period " + + if self.unlock_at_str is not None: + return f"{result} until {self.unlock_at_str}." + else: + return f"{result}of {self.stop_duration_str}." def _cooldown_period(self, pair: str, date_now: datetime) -> Optional[ProtectionReturn]: """ @@ -47,9 +54,10 @@ class CooldownPeriod(IProtection): trade = sorted(trades, key=lambda t: t.close_date)[-1] # type: ignore self.log_once(f"Cooldown for {pair} for {self.stop_duration_str}.", logger.info) - self.set_unlock_at_as_stop_duration() - - until = self.calculate_lock_end([trade], self._stop_duration) + if self.unlock_at is not None: + until = self.calculate_unlock_at() + else: + until = self.calculate_lock_end([trade], self._stop_duration) return ProtectionReturn( lock=True, diff --git a/freqtrade/plugins/protections/iprotection.py b/freqtrade/plugins/protections/iprotection.py index da0fc7a78..bf86caefe 100644 --- a/freqtrade/plugins/protections/iprotection.py +++ b/freqtrade/plugins/protections/iprotection.py @@ -36,19 +36,21 @@ class IProtection(LoggingMixin, ABC): 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 + if "unlock_at" in protection_config: + self.unlock_at = self.calculate_unlock_at() else: - self._stop_duration = int(protection_config.get("stop_duration", 60)) + 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 = 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 = int(protection_config.get("lookback_period", 60)) - self.set_unlock_at_as_stop_duration() - LoggingMixin.__init__(self, logger) @property @@ -90,24 +92,10 @@ class IProtection(LoggingMixin, ABC): return self.unlock_at.strftime("%H:%M") return None - def set_unlock_at_as_stop_duration(self) -> None: + def calculate_unlock_at(self) -> datetime: """ - Calculates the stop_duration based on the unlock_at protection config value and sets it. + Calculate and update the unlock time based on the unlock at config. """ - if "unlock_at" in self._protection_config: - self._stop_duration = self.calculate_unlock_at() - return None - - logger.warning( - "Couldn't update the stop duration, because unlock_at is not set in the " - "protection config." - ) - - def calculate_unlock_at(self) -> int: - """ - Calculate and update the stop duration based on the unlock at config. - """ - now_time = datetime.now(timezone.utc) unlock_at = datetime.strptime( str(self._protection_config.get("unlock_at")), "%H:%M" @@ -116,9 +104,7 @@ class IProtection(LoggingMixin, ABC): if unlock_at.time() < now_time.time(): unlock_at = unlock_at.replace(day=now_time.day + 1) - self.unlock_at = unlock_at.replace(tzinfo=timezone.utc) - result = IProtection.calculate_timespan(now_time, self.unlock_at) - return result + return unlock_at.replace(tzinfo=timezone.utc) @abstractmethod def short_desc(self) -> str: @@ -156,7 +142,6 @@ class IProtection(LoggingMixin, ABC): max_date = max_date.replace(tzinfo=timezone.utc) until = max_date + timedelta(minutes=stop_minutes) - return until @staticmethod diff --git a/freqtrade/plugins/protections/low_profit_pairs.py b/freqtrade/plugins/protections/low_profit_pairs.py index 6024fe894..b997254ab 100644 --- a/freqtrade/plugins/protections/low_profit_pairs.py +++ b/freqtrade/plugins/protections/low_profit_pairs.py @@ -34,12 +34,11 @@ class LowProfitPairs(IProtection): """ LockReason to use """ - reason = ( - f"{profit} < {self._required_profit} in {self.lookback_period_str}, " - f"locking for {self.stop_duration_str}." - ) + reason = f"{profit} < {self._required_profit} in {self.lookback_period_str}, locking" if self.unlock_at_str is not None: - reason += f" Unlocking trading at {self.unlock_at_str}." + reason += f" until {self.unlock_at_str}." + else: + reason += f" for {self.stop_duration_str}." return reason def _low_profit( @@ -74,8 +73,10 @@ class LowProfitPairs(IProtection): logger.info, ) - self.set_unlock_at_as_stop_duration() - until = self.calculate_lock_end(trades, self._stop_duration) + if self.unlock_at is not None: + until = self.calculate_unlock_at() + else: + until = self.calculate_lock_end(trades, self._stop_duration) return ProtectionReturn( lock=True, diff --git a/freqtrade/plugins/protections/max_drawdown_protection.py b/freqtrade/plugins/protections/max_drawdown_protection.py index 3f97d418e..264ad57d0 100644 --- a/freqtrade/plugins/protections/max_drawdown_protection.py +++ b/freqtrade/plugins/protections/max_drawdown_protection.py @@ -39,10 +39,12 @@ class MaxDrawdown(IProtection): """ reason = ( f"{drawdown} passed {self._max_allowed_drawdown} in {self.lookback_period_str}, " - f"locking for {self.stop_duration_str}." + f"locking " ) if self.unlock_at_str is not None: - reason += f" Unlocking trading at {self.unlock_at_str}." + reason += f" until {self.unlock_at_str}." + else: + reason += f" for {self.stop_duration_str}." return reason def _max_drawdown(self, date_now: datetime) -> Optional[ProtectionReturn]: @@ -74,8 +76,10 @@ class MaxDrawdown(IProtection): logger.info, ) - self.set_unlock_at_as_stop_duration() - until = self.calculate_lock_end(trades, self._stop_duration) + if self.unlock_at is not None: + until = self.calculate_unlock_at() + else: + until = self.calculate_lock_end(trades, self._stop_duration) return ProtectionReturn( lock=True, diff --git a/freqtrade/plugins/protections/stoploss_guard.py b/freqtrade/plugins/protections/stoploss_guard.py index 329b6b772..f36a2f157 100644 --- a/freqtrade/plugins/protections/stoploss_guard.py +++ b/freqtrade/plugins/protections/stoploss_guard.py @@ -36,12 +36,11 @@ class StoplossGuard(IProtection): """ LockReason to use """ - reason = ( - f"{self._trade_limit} stoplosses in {self._lookback_period} min, " - f"locking for {self._stop_duration} min." - ) + reason = f"{self._trade_limit} stoplosses in {self._lookback_period} min, " f"locking " if self.unlock_at_str is not None: - reason += f" Unlocking trading at {self.unlock_at_str}." + reason += f" until {self.unlock_at_str}." + else: + reason += f" for {self._stop_duration} min." return reason def _stoploss_guard( @@ -82,8 +81,10 @@ class StoplossGuard(IProtection): logger.info, ) - self.set_unlock_at_as_stop_duration() - until = self.calculate_lock_end(trades, self._stop_duration) + if self.unlock_at is not None: + until = self.calculate_unlock_at() + else: + until = self.calculate_lock_end(trades, self._stop_duration) return ProtectionReturn( lock=True, From 16dd86e732c1471c2e6b15f551e1a15bfc08581b Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 16 Jul 2024 06:28:02 +0200 Subject: [PATCH 06/19] _unlock_at should be private --- .../plugins/protections/cooldown_period.py | 2 +- freqtrade/plugins/protections/iprotection.py | 20 +++++++++---------- .../plugins/protections/low_profit_pairs.py | 2 +- .../protections/max_drawdown_protection.py | 2 +- .../plugins/protections/stoploss_guard.py | 2 +- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/freqtrade/plugins/protections/cooldown_period.py b/freqtrade/plugins/protections/cooldown_period.py index 30a611189..d9fb5b9a6 100644 --- a/freqtrade/plugins/protections/cooldown_period.py +++ b/freqtrade/plugins/protections/cooldown_period.py @@ -54,7 +54,7 @@ class CooldownPeriod(IProtection): trade = sorted(trades, key=lambda t: t.close_date)[-1] # type: ignore self.log_once(f"Cooldown for {pair} for {self.stop_duration_str}.", logger.info) - if self.unlock_at is not None: + if self._unlock_at is not None: until = self.calculate_unlock_at() else: until = self.calculate_lock_end([trade], self._stop_duration) diff --git a/freqtrade/plugins/protections/iprotection.py b/freqtrade/plugins/protections/iprotection.py index bf86caefe..709d03354 100644 --- a/freqtrade/plugins/protections/iprotection.py +++ b/freqtrade/plugins/protections/iprotection.py @@ -33,22 +33,22 @@ 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 + self._unlock_at: Optional[datetime] = None tf_in_min = timeframe_to_minutes(config["timeframe"]) - if "unlock_at" in protection_config: - self.unlock_at = self.calculate_unlock_at() + 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 + elif "unlock_at" in protection_config: + self._unlock_at = self.calculate_unlock_at() else: - 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 = int(protection_config.get("stop_duration", 60)) + 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)) LoggingMixin.__init__(self, logger) @@ -88,8 +88,8 @@ class IProtection(LoggingMixin, ABC): """ Output configured unlock time """ - if self.unlock_at: - return self.unlock_at.strftime("%H:%M") + if self._unlock_at: + return self._unlock_at.strftime("%H:%M") return None def calculate_unlock_at(self) -> datetime: diff --git a/freqtrade/plugins/protections/low_profit_pairs.py b/freqtrade/plugins/protections/low_profit_pairs.py index b997254ab..88554d2be 100644 --- a/freqtrade/plugins/protections/low_profit_pairs.py +++ b/freqtrade/plugins/protections/low_profit_pairs.py @@ -73,7 +73,7 @@ class LowProfitPairs(IProtection): logger.info, ) - if self.unlock_at is not None: + if self._unlock_at is not None: until = self.calculate_unlock_at() else: until = self.calculate_lock_end(trades, self._stop_duration) diff --git a/freqtrade/plugins/protections/max_drawdown_protection.py b/freqtrade/plugins/protections/max_drawdown_protection.py index 264ad57d0..1816df303 100644 --- a/freqtrade/plugins/protections/max_drawdown_protection.py +++ b/freqtrade/plugins/protections/max_drawdown_protection.py @@ -76,7 +76,7 @@ class MaxDrawdown(IProtection): logger.info, ) - if self.unlock_at is not None: + if self._unlock_at is not None: until = self.calculate_unlock_at() else: until = self.calculate_lock_end(trades, self._stop_duration) diff --git a/freqtrade/plugins/protections/stoploss_guard.py b/freqtrade/plugins/protections/stoploss_guard.py index f36a2f157..0ef2254e8 100644 --- a/freqtrade/plugins/protections/stoploss_guard.py +++ b/freqtrade/plugins/protections/stoploss_guard.py @@ -81,7 +81,7 @@ class StoplossGuard(IProtection): logger.info, ) - if self.unlock_at is not None: + if self._unlock_at is not None: until = self.calculate_unlock_at() else: until = self.calculate_lock_end(trades, self._stop_duration) From 1e36bc98b94b30a46940a13124e5419baeafc6bb Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 16 Jul 2024 06:35:32 +0200 Subject: [PATCH 07/19] chore: Remove unused method --- freqtrade/plugins/protections/iprotection.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/freqtrade/plugins/protections/iprotection.py b/freqtrade/plugins/protections/iprotection.py index 709d03354..1d57b3e4d 100644 --- a/freqtrade/plugins/protections/iprotection.py +++ b/freqtrade/plugins/protections/iprotection.py @@ -143,14 +143,3 @@ 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) From d13f47ec0b80877e88f5344f0dcd500b607e702c Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 16 Jul 2024 06:48:30 +0200 Subject: [PATCH 08/19] align wording to simplify "locking for" element --- freqtrade/plugins/protections/cooldown_period.py | 14 ++------------ freqtrade/plugins/protections/iprotection.py | 11 +++++++++++ freqtrade/plugins/protections/low_profit_pairs.py | 10 ++++------ .../plugins/protections/max_drawdown_protection.py | 9 ++------- freqtrade/plugins/protections/stoploss_guard.py | 10 ++++------ tests/plugins/test_protections.py | 4 ++-- 6 files changed, 25 insertions(+), 33 deletions(-) diff --git a/freqtrade/plugins/protections/cooldown_period.py b/freqtrade/plugins/protections/cooldown_period.py index d9fb5b9a6..3391d175a 100644 --- a/freqtrade/plugins/protections/cooldown_period.py +++ b/freqtrade/plugins/protections/cooldown_period.py @@ -18,23 +18,13 @@ class CooldownPeriod(IProtection): """ LockReason to use """ - reason = "Cooldown period" - - if self.unlock_at_str is not None: - return f"{reason} until {self.unlock_at_str}." - else: - return f"{reason}of {self.stop_duration_str}." + return f"Cooldown period for {self.unlock_reason_time_element}." def short_desc(self) -> str: """ Short method description - used for startup messages """ - result = f"{self.name} - Cooldown period " - - if self.unlock_at_str is not None: - return f"{result} until {self.unlock_at_str}." - else: - return f"{result}of {self.stop_duration_str}." + return f"{self.name} - Cooldown period {self.unlock_reason_time_element}." def _cooldown_period(self, pair: str, date_now: datetime) -> Optional[ProtectionReturn]: """ diff --git a/freqtrade/plugins/protections/iprotection.py b/freqtrade/plugins/protections/iprotection.py index 1d57b3e4d..c02fedfb2 100644 --- a/freqtrade/plugins/protections/iprotection.py +++ b/freqtrade/plugins/protections/iprotection.py @@ -32,6 +32,7 @@ class IProtection(LoggingMixin, ABC): self._config = config self._protection_config = protection_config self._stop_duration_candles: Optional[int] = None + self._stop_duration: int = 0 self._lookback_period_candles: Optional[int] = None self._unlock_at: Optional[datetime] = None @@ -92,6 +93,16 @@ class IProtection(LoggingMixin, ABC): return self._unlock_at.strftime("%H:%M") return None + @property + def unlock_reason_time_element(self) -> str: + """ + Output configured unlock time or stop duration + """ + if self.unlock_at_str is not None: + return f"until {self.unlock_at_str}" + else: + return f"for {self.stop_duration_str}" + def calculate_unlock_at(self) -> datetime: """ Calculate and update the unlock time based on the unlock at config. diff --git a/freqtrade/plugins/protections/low_profit_pairs.py b/freqtrade/plugins/protections/low_profit_pairs.py index 88554d2be..518a20c0f 100644 --- a/freqtrade/plugins/protections/low_profit_pairs.py +++ b/freqtrade/plugins/protections/low_profit_pairs.py @@ -34,12 +34,10 @@ class LowProfitPairs(IProtection): """ LockReason to use """ - reason = f"{profit} < {self._required_profit} in {self.lookback_period_str}, locking" - if self.unlock_at_str is not None: - reason += f" until {self.unlock_at_str}." - else: - reason += f" for {self.stop_duration_str}." - return reason + return ( + f"{profit} < {self._required_profit} in {self.lookback_period_str}, " + f"locking {self.unlock_reason_time_element}." + ) 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 1816df303..6f1c1ebf8 100644 --- a/freqtrade/plugins/protections/max_drawdown_protection.py +++ b/freqtrade/plugins/protections/max_drawdown_protection.py @@ -37,15 +37,10 @@ class MaxDrawdown(IProtection): """ LockReason to use """ - reason = ( + return ( f"{drawdown} passed {self._max_allowed_drawdown} in {self.lookback_period_str}, " - f"locking " + f"locking {self.unlock_reason_time_element}." ) - if self.unlock_at_str is not None: - reason += f" until {self.unlock_at_str}." - else: - reason += f" for {self.stop_duration_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 0ef2254e8..21e883bbd 100644 --- a/freqtrade/plugins/protections/stoploss_guard.py +++ b/freqtrade/plugins/protections/stoploss_guard.py @@ -36,12 +36,10 @@ class StoplossGuard(IProtection): """ LockReason to use """ - reason = f"{self._trade_limit} stoplosses in {self._lookback_period} min, " f"locking " - if self.unlock_at_str is not None: - reason += f" until {self.unlock_at_str}." - else: - reason += f" for {self._stop_duration} min." - return reason + return ( + f"{self._trade_limit} stoplosses in {self._lookback_period} min, " + f"locking {self.unlock_reason_time_element}." + ) 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 4c76693c8..02252fb64 100644 --- a/tests/plugins/test_protections.py +++ b/tests/plugins/test_protections.py @@ -648,7 +648,7 @@ def test_MaxDrawdown(mocker, default_conf, fee, caplog): ), ( {"method": "CooldownPeriod", "stop_duration": 60}, - "[{'CooldownPeriod': 'CooldownPeriod - Cooldown period of 60 minutes.'}]", + "[{'CooldownPeriod': 'CooldownPeriod - Cooldown period for 60 minutes.'}]", None, ), ( @@ -677,7 +677,7 @@ def test_MaxDrawdown(mocker, default_conf, fee, caplog): ), ( {"method": "CooldownPeriod", "stop_duration_candles": 5}, - "[{'CooldownPeriod': 'CooldownPeriod - Cooldown period of 5 candles.'}]", + "[{'CooldownPeriod': 'CooldownPeriod - Cooldown period for 5 candles.'}]", None, ), ( From 65972d9c0cabc802842dd27c9ee6bda30261d28b Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 16 Jul 2024 06:51:31 +0200 Subject: [PATCH 09/19] Add cooldown with timeperiod test --- tests/plugins/test_protections.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/plugins/test_protections.py b/tests/plugins/test_protections.py index 02252fb64..9d34d18fe 100644 --- a/tests/plugins/test_protections.py +++ b/tests/plugins/test_protections.py @@ -692,6 +692,14 @@ def test_MaxDrawdown(mocker, default_conf, fee, caplog): "if drawdown is > 0.0 within 20 candles.'}]", None, ), + ( + { + "method": "CooldownPeriod", + "unlock_at": "01:00", + }, + "[{'CooldownPeriod': 'CooldownPeriod - Cooldown period until 01:00.'}]", + None, + ), ( { "method": "StoplossGuard", From 26aa336450219f0937018711aa2688af9b76284f Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 16 Jul 2024 07:05:42 +0200 Subject: [PATCH 10/19] Combine "until" logic into calculate_lock_end --- .../plugins/protections/cooldown_period.py | 6 +--- freqtrade/plugins/protections/iprotection.py | 29 ++++++++----------- .../plugins/protections/low_profit_pairs.py | 6 +--- .../protections/max_drawdown_protection.py | 5 +--- .../plugins/protections/stoploss_guard.py | 7 +---- 5 files changed, 16 insertions(+), 37 deletions(-) diff --git a/freqtrade/plugins/protections/cooldown_period.py b/freqtrade/plugins/protections/cooldown_period.py index 3391d175a..9608a51cc 100644 --- a/freqtrade/plugins/protections/cooldown_period.py +++ b/freqtrade/plugins/protections/cooldown_period.py @@ -43,11 +43,7 @@ class CooldownPeriod(IProtection): # Ignore type error as we know we only get closed trades. trade = sorted(trades, key=lambda t: t.close_date)[-1] # type: ignore self.log_once(f"Cooldown for {pair} for {self.stop_duration_str}.", logger.info) - - if self._unlock_at is not None: - until = self.calculate_unlock_at() - else: - until = self.calculate_lock_end([trade], self._stop_duration) + until = self.calculate_lock_end([trade]) return ProtectionReturn( lock=True, diff --git a/freqtrade/plugins/protections/iprotection.py b/freqtrade/plugins/protections/iprotection.py index c02fedfb2..f4278be77 100644 --- a/freqtrade/plugins/protections/iprotection.py +++ b/freqtrade/plugins/protections/iprotection.py @@ -103,20 +103,6 @@ class IProtection(LoggingMixin, ABC): else: return f"for {self.stop_duration_str}" - def calculate_unlock_at(self) -> datetime: - """ - Calculate and update the unlock time based on the unlock at config. - """ - now_time = datetime.now(timezone.utc) - unlock_at = datetime.strptime( - str(self._protection_config.get("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) - - return unlock_at.replace(tzinfo=timezone.utc) - @abstractmethod def short_desc(self) -> str: """ @@ -142,15 +128,24 @@ class IProtection(LoggingMixin, ABC): If true, this pair will be locked with until """ - @staticmethod - def calculate_lock_end(trades: List[LocalTrade], stop_minutes: int) -> datetime: + def calculate_lock_end(self, trades: List[LocalTrade]) -> datetime: """ Get lock end time + Implicitly uses `self._stop_duration` or `self._unlock_at` depending on the configuration. """ max_date: datetime = max([trade.close_date for trade in trades if trade.close_date]) # coming from Database, tzinfo is not set. if max_date.tzinfo is None: max_date = max_date.replace(tzinfo=timezone.utc) - until = max_date + timedelta(minutes=stop_minutes) + if self._unlock_at is not None: + # unlock_at case with fixed hour of the day + until = self._unlock_at + hour, minutes = self._unlock_at.split(":") + unlock_at = max_date.replace(hour=int(hour), minute=int(minutes)) + if unlock_at < max_date: + unlock_at += timedelta(days=1) + return unlock_at + + until = max_date + timedelta(minutes=self._stop_duration) return until diff --git a/freqtrade/plugins/protections/low_profit_pairs.py b/freqtrade/plugins/protections/low_profit_pairs.py index 518a20c0f..f0023646a 100644 --- a/freqtrade/plugins/protections/low_profit_pairs.py +++ b/freqtrade/plugins/protections/low_profit_pairs.py @@ -70,11 +70,7 @@ class LowProfitPairs(IProtection): f"within {self._lookback_period} minutes.", logger.info, ) - - if self._unlock_at is not None: - until = self.calculate_unlock_at() - else: - until = self.calculate_lock_end(trades, self._stop_duration) + until = self.calculate_lock_end(trades) return ProtectionReturn( lock=True, diff --git a/freqtrade/plugins/protections/max_drawdown_protection.py b/freqtrade/plugins/protections/max_drawdown_protection.py index 6f1c1ebf8..5939ee9f0 100644 --- a/freqtrade/plugins/protections/max_drawdown_protection.py +++ b/freqtrade/plugins/protections/max_drawdown_protection.py @@ -71,10 +71,7 @@ class MaxDrawdown(IProtection): logger.info, ) - if self._unlock_at is not None: - until = self.calculate_unlock_at() - else: - until = self.calculate_lock_end(trades, self._stop_duration) + until = self.calculate_lock_end(trades) return ProtectionReturn( lock=True, diff --git a/freqtrade/plugins/protections/stoploss_guard.py b/freqtrade/plugins/protections/stoploss_guard.py index 21e883bbd..da7437178 100644 --- a/freqtrade/plugins/protections/stoploss_guard.py +++ b/freqtrade/plugins/protections/stoploss_guard.py @@ -78,12 +78,7 @@ class StoplossGuard(IProtection): f"stoplosses within {self._lookback_period} minutes.", logger.info, ) - - if self._unlock_at is not None: - until = self.calculate_unlock_at() - else: - until = self.calculate_lock_end(trades, self._stop_duration) - + until = self.calculate_lock_end(trades) return ProtectionReturn( lock=True, until=until, From be3fcd90e28ba9e2ee571727a696dcdffa29e60c Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 16 Jul 2024 07:14:33 +0200 Subject: [PATCH 11/19] Remove unneeded property --- freqtrade/plugins/protections/iprotection.py | 19 +++++-------------- tests/plugins/test_protections.py | 2 +- 2 files changed, 6 insertions(+), 15 deletions(-) diff --git a/freqtrade/plugins/protections/iprotection.py b/freqtrade/plugins/protections/iprotection.py index f4278be77..670a803c4 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, Union +from typing import Any, Dict, List, Optional from freqtrade.constants import Config, LongShort from freqtrade.exchange import timeframe_to_minutes @@ -34,14 +34,14 @@ class IProtection(LoggingMixin, ABC): self._stop_duration_candles: Optional[int] = None self._stop_duration: int = 0 self._lookback_period_candles: Optional[int] = None - self._unlock_at: Optional[datetime] = None + self._unlock_at: Optional[str] = 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 elif "unlock_at" in protection_config: - self._unlock_at = self.calculate_unlock_at() + self._unlock_at = protection_config.get("unlock_at") else: self._stop_duration = int(protection_config.get("stop_duration", 60)) @@ -84,22 +84,13 @@ 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 - @property def unlock_reason_time_element(self) -> str: """ Output configured unlock time or stop duration """ - if self.unlock_at_str is not None: - return f"until {self.unlock_at_str}" + if self._unlock_at is not None: + return f"until {self._unlock_at}" else: return f"for {self.stop_duration_str}" diff --git a/tests/plugins/test_protections.py b/tests/plugins/test_protections.py index 9d34d18fe..c537eb035 100644 --- a/tests/plugins/test_protections.py +++ b/tests/plugins/test_protections.py @@ -189,7 +189,7 @@ def test_protections_init(default_conf, timeframe, expected_lookback, expected_s if isinstance(expected_stop, int): assert man._protection_handlers[0]._stop_duration == expected_stop else: - assert man._protection_handlers[0].unlock_at_str == expected_stop + assert man._protection_handlers[0]._unlock_at == expected_stop @pytest.mark.parametrize("is_short", [False, True]) From a3c52445ee740b66bb85bba3997ae9d658e79688 Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 16 Jul 2024 07:14:46 +0200 Subject: [PATCH 12/19] Simplify validation --- freqtrade/configuration/config_validation.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/freqtrade/configuration/config_validation.py b/freqtrade/configuration/config_validation.py index 597752614..4bb260b52 100644 --- a/freqtrade/configuration/config_validation.py +++ b/freqtrade/configuration/config_validation.py @@ -207,15 +207,12 @@ def _validate_protections(conf: Dict[str, Any]) -> None: f"Please fix the protection {prot.get('method')}." ) - if parsed_unlock_at is not None and "stop_duration" in prot: + if parsed_unlock_at is not None and ( + "stop_duration" in prot or "stop_duration_candles" 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" + "Protections must specify either `unlock_at`, `stop_duration` or " + "`stop_duration_candles`.\n" f"Please fix the protection {prot.get('method')}." ) From d590ab003f34f91742d013dd1e99a2ec319cb327 Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 16 Jul 2024 07:26:41 +0200 Subject: [PATCH 13/19] Add unlock_at config test, simplify validation --- freqtrade/configuration/config_validation.py | 15 ++++++--------- tests/test_configuration.py | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/freqtrade/configuration/config_validation.py b/freqtrade/configuration/config_validation.py index 4bb260b52..aa9a1757d 100644 --- a/freqtrade/configuration/config_validation.py +++ b/freqtrade/configuration/config_validation.py @@ -193,7 +193,12 @@ def _validate_protections(conf: Dict[str, Any]) -> None: """ for prot in conf.get("protections", []): - parsed_unlock_at = _validate_unlock_at(prot) + parsed_unlock_at = None + if (config_unlock_at := prot.get("unlock_at")) is not None: + try: + parsed_unlock_at = datetime.strptime(config_unlock_at, "%H:%M") + except ValueError: + raise ConfigurationError(f"Invalid date format for unlock_at: {config_unlock_at}.") if "stop_duration" in prot and "stop_duration_candles" in prot: raise ConfigurationError( @@ -217,14 +222,6 @@ def _validate_protections(conf: Dict[str, Any]) -> None: ) -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", {}) ob_min = ask_strategy.get("order_book_min") diff --git a/tests/test_configuration.py b/tests/test_configuration.py index f9368246a..af482a965 100644 --- a/tests/test_configuration.py +++ b/tests/test_configuration.py @@ -840,6 +840,21 @@ def test_validate_whitelist(default_conf): ], r"Protections must specify either `stop_duration`.*", ), + ( + [ + { + "method": "StoplossGuard", + "lookback_period": 20, + "stop_duration": 10, + "unlock_at": "20:02", + } + ], + r"Protections must specify either `unlock_at`, `stop_duration` or.*", + ), + ( + [{"method": "StoplossGuard", "lookback_period_candles": 20, "unlock_at": "20:02"}], + None, + ), ], ) def test_validate_protections(default_conf, protconf, expected): From dcc9d20ccae24116fac6cb7b66d5de1a6f3fd855 Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 16 Jul 2024 07:31:11 +0200 Subject: [PATCH 14/19] Remove unnecessary statement --- freqtrade/plugins/protections/iprotection.py | 1 - 1 file changed, 1 deletion(-) diff --git a/freqtrade/plugins/protections/iprotection.py b/freqtrade/plugins/protections/iprotection.py index 670a803c4..c4b039161 100644 --- a/freqtrade/plugins/protections/iprotection.py +++ b/freqtrade/plugins/protections/iprotection.py @@ -131,7 +131,6 @@ class IProtection(LoggingMixin, ABC): if self._unlock_at is not None: # unlock_at case with fixed hour of the day - until = self._unlock_at hour, minutes = self._unlock_at.split(":") unlock_at = max_date.replace(hour=int(hour), minute=int(minutes)) if unlock_at < max_date: From f714d1ab2849a3566a28739882f8d4eeb57e0175 Mon Sep 17 00:00:00 2001 From: simwai <16225108+simwai@users.noreply.github.com> Date: Thu, 18 Jul 2024 15:08:12 +0200 Subject: [PATCH 15/19] Added unlock_at field to protections document --- docs/includes/protections.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/includes/protections.md b/docs/includes/protections.md index 12af081c0..e64ca0328 100644 --- a/docs/includes/protections.md +++ b/docs/includes/protections.md @@ -36,6 +36,7 @@ All protection end times are rounded up to the next candle to avoid sudden, unex | `lookback_period_candles` | Only trades that completed within the last `lookback_period_candles` candles will be considered. This setting may be ignored by some Protections.
**Datatype:** Positive integer (in candles). | `lookback_period` | Only trades that completed after `current_time - lookback_period` will be considered.
Cannot be used together with `lookback_period_candles`.
This setting may be ignored by some Protections.
**Datatype:** Float (in minutes) | `trade_limit` | Number of trades required at minimum (not used by all Protections).
**Datatype:** Positive integer +| `unlock_at` | Time when trading will be unlocked regularly (not used by all Protections).
**Datatype:** string
**Input Format:** "HH:MM" (24-hours) !!! Note "Durations" Durations (`stop_duration*` and `lookback_period*` can be defined in either minutes or candles). @@ -44,7 +45,7 @@ All protection end times are rounded up to the next candle to avoid sudden, unex #### Stoploss Guard `StoplossGuard` selects all trades within `lookback_period` in minutes (or in candles when using `lookback_period_candles`). -If `trade_limit` or more trades resulted in stoploss, trading will stop for `stop_duration` in minutes (or in candles when using `stop_duration_candles`). +If `trade_limit` or more trades resulted in stoploss, trading will stop for `stop_duration` in minutes (or in candles when using `stop_duration_candles`, or until the set time when using `unlock_at`). This applies across all pairs, unless `only_per_pair` is set to true, which will then only look at one pair at a time. @@ -97,7 +98,7 @@ def protections(self): #### Low Profit Pairs `LowProfitPairs` uses all trades for a pair within `lookback_period` in minutes (or in candles when using `lookback_period_candles`) to determine the overall profit ratio. -If that ratio is below `required_profit`, that pair will be locked for `stop_duration` in minutes (or in candles when using `stop_duration_candles`). +If that ratio is below `required_profit`, that pair will be locked for `stop_duration` in minutes (or in candles when using `stop_duration_candles`, or until the set time when using `unlock_at`). For futures bots, setting `only_per_side` will make the bot only consider one side, and will then only lock this one side, allowing for example shorts to continue after a series of long losses. @@ -120,7 +121,7 @@ def protections(self): #### Cooldown Period -`CooldownPeriod` locks a pair for `stop_duration` in minutes (or in candles when using `stop_duration_candles`) after selling, avoiding a re-entry for this pair for `stop_duration` minutes. +`CooldownPeriod` locks a pair for `stop_duration` in minutes (or in candles when using `stop_duration_candles`, or until the set time when using `unlock_at`) after selling, avoiding a re-entry for this pair for `stop_duration` minutes. The below example will stop trading a pair for 2 candles after closing a trade, allowing this pair to "cool down". From 1760624954938c119905d1dd49c3ad1134460ec4 Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 2 Aug 2024 19:48:43 +0200 Subject: [PATCH 16/19] test: Test "invalid date format" --- tests/test_configuration.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_configuration.py b/tests/test_configuration.py index af482a965..6943549d9 100644 --- a/tests/test_configuration.py +++ b/tests/test_configuration.py @@ -855,6 +855,10 @@ def test_validate_whitelist(default_conf): [{"method": "StoplossGuard", "lookback_period_candles": 20, "unlock_at": "20:02"}], None, ), + ( + [{"method": "StoplossGuard", "lookback_period_candles": 20, "unlock_at": "55:102"}], + "Invalid date format for unlock_at: 55:102.", + ), ], ) def test_validate_protections(default_conf, protconf, expected): From 57139295b5b24a2fb7488f358e0c7b575ab270de Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 2 Aug 2024 20:12:44 +0200 Subject: [PATCH 17/19] tests: Add unlock_at test --- tests/plugins/test_protections.py | 83 +++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/tests/plugins/test_protections.py b/tests/plugins/test_protections.py index c537eb035..3fb27ce3d 100644 --- a/tests/plugins/test_protections.py +++ b/tests/plugins/test_protections.py @@ -423,6 +423,89 @@ def test_CooldownPeriod(mocker, default_conf, fee, caplog): assert not PairLocks.is_global_lock() +@pytest.mark.usefixtures("init_persistence") +def test_CooldownPeriod_unlock_at(mocker, default_conf, fee, caplog, time_machine): + default_conf["protections"] = [ + { + "method": "CooldownPeriod", + "unlock_at": "05:00", + } + ] + freqtrade = get_patched_freqtradebot(mocker, default_conf) + message = r"Trading stopped due to .*" + assert not freqtrade.protections.global_stop() + assert not freqtrade.protections.stop_per_pair("XRP/BTC") + + assert not log_has_re(message, caplog) + caplog.clear() + + start_dt = datetime(2024, 5, 2, 0, 30, 0, tzinfo=timezone.utc) + time_machine.move_to(start_dt, tick=False) + + generate_mock_trade( + "XRP/BTC", + fee.return_value, + False, + exit_reason=ExitType.STOP_LOSS.value, + min_ago_open=20, + min_ago_close=10, + ) + + assert not freqtrade.protections.global_stop() + assert freqtrade.protections.stop_per_pair("XRP/BTC") + assert PairLocks.is_pair_locked("XRP/BTC") + assert not PairLocks.is_global_lock() + + # Move time to "4:30" + time_machine.move_to(start_dt + timedelta(hours=4), tick=False) + assert PairLocks.is_pair_locked("XRP/BTC") + assert not PairLocks.is_global_lock() + + # Move time to "past 5:00" + time_machine.move_to(start_dt + timedelta(hours=5), tick=False) + assert not PairLocks.is_pair_locked("XRP/BTC") + assert not PairLocks.is_global_lock() + + # Force rollover to the next day. + start_dt = datetime(2024, 5, 2, 22, 00, 0, tzinfo=timezone.utc) + time_machine.move_to(start_dt, tick=False) + generate_mock_trade( + "ETH/BTC", + fee.return_value, + False, + exit_reason=ExitType.ROI.value, + min_ago_open=20, + min_ago_close=10, + ) + + assert not freqtrade.protections.global_stop() + assert not PairLocks.is_pair_locked("ETH/BTC") + assert freqtrade.protections.stop_per_pair("ETH/BTC") + assert PairLocks.is_pair_locked("ETH/BTC") + assert not PairLocks.is_global_lock() + # Move to 23:00 + time_machine.move_to(start_dt + timedelta(hours=1), tick=False) + assert PairLocks.is_pair_locked("ETH/BTC") + assert not PairLocks.is_global_lock() + + # Move to 04:59 (should still be locked) + time_machine.move_to(start_dt + timedelta(hours=6, minutes=59), tick=False) + assert PairLocks.is_pair_locked("ETH/BTC") + assert not PairLocks.is_global_lock() + + # Move to 05:01 (should still be locked - it unlocks once the 05:00 candle stops at 05:05) + time_machine.move_to(start_dt + timedelta(hours=7, minutes=1), tick=False) + + assert PairLocks.is_pair_locked("ETH/BTC") + assert not PairLocks.is_global_lock() + + # Move to 05:01 (unlocked). + time_machine.move_to(start_dt + timedelta(hours=7, minutes=5), tick=False) + + assert not PairLocks.is_pair_locked("ETH/BTC") + assert not PairLocks.is_global_lock() + + @pytest.mark.parametrize("only_per_side", [False, True]) @pytest.mark.usefixtures("init_persistence") def test_LowProfitPairs(mocker, default_conf, fee, caplog, only_per_side): From 98c8521057b1a6e536600950b05326e49577769a Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 2 Aug 2024 20:13:59 +0200 Subject: [PATCH 18/19] chore: fix minor gotcha --- docs/includes/protections.md | 2 +- freqtrade/plugins/protections/cooldown_period.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/includes/protections.md b/docs/includes/protections.md index e64ca0328..a4cb9d3cc 100644 --- a/docs/includes/protections.md +++ b/docs/includes/protections.md @@ -121,7 +121,7 @@ def protections(self): #### Cooldown Period -`CooldownPeriod` locks a pair for `stop_duration` in minutes (or in candles when using `stop_duration_candles`, or until the set time when using `unlock_at`) after selling, avoiding a re-entry for this pair for `stop_duration` minutes. +`CooldownPeriod` locks a pair for `stop_duration` in minutes (or in candles when using `stop_duration_candles`, or until the set time when using `unlock_at`) after exiting, avoiding a re-entry for this pair for `stop_duration` minutes. The below example will stop trading a pair for 2 candles after closing a trade, allowing this pair to "cool down". diff --git a/freqtrade/plugins/protections/cooldown_period.py b/freqtrade/plugins/protections/cooldown_period.py index 9608a51cc..09d506b91 100644 --- a/freqtrade/plugins/protections/cooldown_period.py +++ b/freqtrade/plugins/protections/cooldown_period.py @@ -30,7 +30,7 @@ class CooldownPeriod(IProtection): """ Get last trade for this pair """ - look_back_until = date_now - timedelta(minutes=self._stop_duration) + look_back_until = date_now - timedelta(minutes=self._lookback_period) # filters = [ # Trade.is_open.is_(False), # Trade.close_date > look_back_until, From f63910d355cbdcacaad0f1e460df25a688e0ee6d Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 2 Aug 2024 20:15:46 +0200 Subject: [PATCH 19/19] chore: improve wording for cooldown_period --- freqtrade/plugins/protections/cooldown_period.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/freqtrade/plugins/protections/cooldown_period.py b/freqtrade/plugins/protections/cooldown_period.py index 09d506b91..d30bd87e5 100644 --- a/freqtrade/plugins/protections/cooldown_period.py +++ b/freqtrade/plugins/protections/cooldown_period.py @@ -42,7 +42,7 @@ class CooldownPeriod(IProtection): # Get latest trade # Ignore type error as we know we only get closed trades. trade = sorted(trades, key=lambda t: t.close_date)[-1] # type: ignore - self.log_once(f"Cooldown for {pair} for {self.stop_duration_str}.", logger.info) + self.log_once(f"Cooldown for {pair} {self.unlock_reason_time_element}.", logger.info) until = self.calculate_lock_end([trade]) return ProtectionReturn(