diff --git a/.gitignore b/.gitignore index c818981ee..d371c9dd9 100644 --- a/.gitignore +++ b/.gitignore @@ -114,3 +114,5 @@ target/ !config_examples/config_full.example.json !config_examples/config_kraken.example.json !config_examples/config_freqai.example.json + +docker-compose-*.yml diff --git a/build_helpers/schema.json b/build_helpers/schema.json index 89a7f1efb..8438dc3a0 100644 --- a/build_helpers/schema.json +++ b/build_helpers/schema.json @@ -605,6 +605,10 @@ "type": "number", "minimum": 0 }, + "unlock_at": { + "description": "Time when trading will be unlocked regularly. Format: HH:MM", + "type": "string" + }, "trade_limit": { "description": "Minimum number of trades required during lookback period.", "type": "number", diff --git a/docs/includes/protections.md b/docs/includes/protections.md index 12af081c0..a4cb9d3cc 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 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/configuration/config_schema.py b/freqtrade/configuration/config_schema.py index 86b081570..cd349daed 100644 --- a/freqtrade/configuration/config_schema.py +++ b/freqtrade/configuration/config_schema.py @@ -476,6 +476,12 @@ CONF_SCHEMA = { "type": "number", "minimum": 0, }, + "unlock_at": { + "description": ( + "Time when trading will be unlocked regularly. Format: HH:MM" + ), + "type": "string", + }, "trade_limit": { "description": "Minimum number of trades required during lookback period.", "type": "number", diff --git a/freqtrade/configuration/config_validation.py b/freqtrade/configuration/config_validation.py index f7f021b9c..6a14841ff 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 @@ -201,16 +202,32 @@ def _validate_protections(conf: Dict[str, Any]) -> None: """ for prot in conf.get("protections", []): + 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( "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 or "stop_duration_candles" in prot + ): + raise ConfigurationError( + "Protections must specify either `unlock_at`, `stop_duration` or " + "`stop_duration_candles`.\n" + f"Please fix the protection {prot.get('method')}." ) diff --git a/freqtrade/plugins/protections/cooldown_period.py b/freqtrade/plugins/protections/cooldown_period.py index 2948d17d0..d30bd87e5 100644 --- a/freqtrade/plugins/protections/cooldown_period.py +++ b/freqtrade/plugins/protections/cooldown_period.py @@ -18,19 +18,19 @@ class CooldownPeriod(IProtection): """ LockReason to use """ - return f"Cooldown period for {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 + Short method description - used for startup messages """ - return f"{self.name} - Cooldown period 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]: """ 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, @@ -42,8 +42,8 @@ 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) - until = self.calculate_lock_end([trade], self._stop_duration) + self.log_once(f"Cooldown for {pair} {self.unlock_reason_time_element}.", logger.info) + 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 204a8b827..c4b039161 100644 --- a/freqtrade/plugins/protections/iprotection.py +++ b/freqtrade/plugins/protections/iprotection.py @@ -32,15 +32,19 @@ 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[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 = protection_config.get("unlock_at") 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 @@ -80,6 +84,16 @@ class IProtection(LoggingMixin, ABC): else: return f"{self._lookback_period} {plural(self._lookback_period, 'minute', 'minutes')}" + @property + def unlock_reason_time_element(self) -> str: + """ + Output configured unlock time or stop duration + """ + if self._unlock_at is not None: + return f"until {self._unlock_at}" + else: + return f"for {self.stop_duration_str}" + @abstractmethod def short_desc(self) -> str: """ @@ -105,16 +119,23 @@ 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 + 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 360f6721c..f0023646a 100644 --- a/freqtrade/plugins/protections/low_profit_pairs.py +++ b/freqtrade/plugins/protections/low_profit_pairs.py @@ -36,7 +36,7 @@ class LowProfitPairs(IProtection): """ return ( f"{profit} < {self._required_profit} in {self.lookback_period_str}, " - f"locking for {self.stop_duration_str}." + f"locking {self.unlock_reason_time_element}." ) def _low_profit( @@ -70,7 +70,7 @@ class LowProfitPairs(IProtection): f"within {self._lookback_period} minutes.", logger.info, ) - 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 a1ba166fa..5939ee9f0 100644 --- a/freqtrade/plugins/protections/max_drawdown_protection.py +++ b/freqtrade/plugins/protections/max_drawdown_protection.py @@ -39,7 +39,7 @@ class MaxDrawdown(IProtection): """ return ( f"{drawdown} passed {self._max_allowed_drawdown} in {self.lookback_period_str}, " - f"locking for {self.stop_duration_str}." + f"locking {self.unlock_reason_time_element}." ) def _max_drawdown(self, date_now: datetime) -> Optional[ProtectionReturn]: @@ -70,7 +70,8 @@ class MaxDrawdown(IProtection): f" within {self.lookback_period_str}.", logger.info, ) - 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 a9aca20b4..da7437178 100644 --- a/freqtrade/plugins/protections/stoploss_guard.py +++ b/freqtrade/plugins/protections/stoploss_guard.py @@ -38,7 +38,7 @@ class StoplossGuard(IProtection): """ return ( f"{self._trade_limit} stoplosses in {self._lookback_period} min, " - f"locking for {self._stop_duration} min." + f"locking {self.unlock_reason_time_element}." ) def _stoploss_guard( @@ -78,7 +78,7 @@ class StoplossGuard(IProtection): f"stoplosses within {self._lookback_period} minutes.", logger.info, ) - until = self.calculate_lock_end(trades, self._stop_duration) + until = self.calculate_lock_end(trades) return ProtectionReturn( lock=True, until=until, diff --git a/tests/plugins/test_protections.py b/tests/plugins/test_protections.py index c8a8fdf20..3fb27ce3d 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 == expected_stop @pytest.mark.parametrize("is_short", [False, True]) @@ -385,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): @@ -610,7 +731,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, ), ( @@ -639,7 +760,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, ), ( @@ -654,6 +775,38 @@ 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", + "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.'}]", + 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.'}]", + 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.'}]", + None, + ), ], ) def test_protection_manager_desc( diff --git a/tests/test_configuration.py b/tests/test_configuration.py index e160130c7..9f9081ab0 100644 --- a/tests/test_configuration.py +++ b/tests/test_configuration.py @@ -840,6 +840,25 @@ 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, + ), + ( + [{"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):