Merge pull request #10400 from simwai/feature/stoploss-start-at

Added unlock_at field for protection config
This commit is contained in:
Matthias 2024-08-10 16:40:09 +02:00 committed by GitHub
commit 6f33115187
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 259 additions and 35 deletions

2
.gitignore vendored
View File

@ -114,3 +114,5 @@ target/
!config_examples/config_full.example.json !config_examples/config_full.example.json
!config_examples/config_kraken.example.json !config_examples/config_kraken.example.json
!config_examples/config_freqai.example.json !config_examples/config_freqai.example.json
docker-compose-*.yml

View File

@ -605,6 +605,10 @@
"type": "number", "type": "number",
"minimum": 0 "minimum": 0
}, },
"unlock_at": {
"description": "Time when trading will be unlocked regularly. Format: HH:MM",
"type": "string"
},
"trade_limit": { "trade_limit": {
"description": "Minimum number of trades required during lookback period.", "description": "Minimum number of trades required during lookback period.",
"type": "number", "type": "number",

View File

@ -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. <br> **Datatype:** Positive integer (in candles). | `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. <br> **Datatype:** Positive integer (in candles).
| `lookback_period` | Only trades that completed after `current_time - lookback_period` will be considered. <br>Cannot be used together with `lookback_period_candles`. <br>This setting may be ignored by some Protections. <br> **Datatype:** Float (in minutes) | `lookback_period` | Only trades that completed after `current_time - lookback_period` will be considered. <br>Cannot be used together with `lookback_period_candles`. <br>This setting may be ignored by some Protections. <br> **Datatype:** Float (in minutes)
| `trade_limit` | Number of trades required at minimum (not used by all Protections). <br> **Datatype:** Positive integer | `trade_limit` | Number of trades required at minimum (not used by all Protections). <br> **Datatype:** Positive integer
| `unlock_at` | Time when trading will be unlocked regularly (not used by all Protections). <br> **Datatype:** string <br>**Input Format:** "HH:MM" (24-hours)
!!! Note "Durations" !!! Note "Durations"
Durations (`stop_duration*` and `lookback_period*` can be defined in either minutes or candles). 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 #### Stoploss Guard
`StoplossGuard` selects all trades within `lookback_period` in minutes (or in candles when using `lookback_period_candles`). `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. 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 #### 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. `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. 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 #### 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". The below example will stop trading a pair for 2 candles after closing a trade, allowing this pair to "cool down".

View File

@ -476,6 +476,12 @@ CONF_SCHEMA = {
"type": "number", "type": "number",
"minimum": 0, "minimum": 0,
}, },
"unlock_at": {
"description": (
"Time when trading will be unlocked regularly. Format: HH:MM"
),
"type": "string",
},
"trade_limit": { "trade_limit": {
"description": "Minimum number of trades required during lookback period.", "description": "Minimum number of trades required during lookback period.",
"type": "number", "type": "number",

View File

@ -1,6 +1,7 @@
import logging import logging
from collections import Counter from collections import Counter
from copy import deepcopy from copy import deepcopy
from datetime import datetime
from typing import Any, Dict from typing import Any, Dict
from jsonschema import Draft4Validator, validators from jsonschema import Draft4Validator, validators
@ -201,16 +202,32 @@ def _validate_protections(conf: Dict[str, Any]) -> None:
""" """
for prot in conf.get("protections", []): 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: if "stop_duration" in prot and "stop_duration_candles" in prot:
raise ConfigurationError( raise ConfigurationError(
"Protections must specify either `stop_duration` or `stop_duration_candles`.\n" "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: if "lookback_period" in prot and "lookback_period_candles" in prot:
raise ConfigurationError( raise ConfigurationError(
"Protections must specify either `lookback_period` or `lookback_period_candles`.\n" "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')}."
) )

View File

@ -18,19 +18,19 @@ class CooldownPeriod(IProtection):
""" """
LockReason to use 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: 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]: def _cooldown_period(self, pair: str, date_now: datetime) -> Optional[ProtectionReturn]:
""" """
Get last trade for this pair 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 = [ # filters = [
# Trade.is_open.is_(False), # Trade.is_open.is_(False),
# Trade.close_date > look_back_until, # Trade.close_date > look_back_until,
@ -42,8 +42,8 @@ class CooldownPeriod(IProtection):
# Get latest trade # Get latest trade
# Ignore type error as we know we only get closed trades. # Ignore type error as we know we only get closed trades.
trade = sorted(trades, key=lambda t: t.close_date)[-1] # type: ignore 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], self._stop_duration) until = self.calculate_lock_end([trade])
return ProtectionReturn( return ProtectionReturn(
lock=True, lock=True,

View File

@ -32,15 +32,19 @@ class IProtection(LoggingMixin, ABC):
self._config = config self._config = config
self._protection_config = protection_config self._protection_config = protection_config
self._stop_duration_candles: Optional[int] = None self._stop_duration_candles: Optional[int] = None
self._stop_duration: int = 0
self._lookback_period_candles: Optional[int] = None self._lookback_period_candles: Optional[int] = None
self._unlock_at: Optional[str] = None
tf_in_min = timeframe_to_minutes(config["timeframe"]) tf_in_min = timeframe_to_minutes(config["timeframe"])
if "stop_duration_candles" in protection_config: if "stop_duration_candles" in protection_config:
self._stop_duration_candles = int(protection_config.get("stop_duration_candles", 1)) self._stop_duration_candles = int(protection_config.get("stop_duration_candles", 1))
self._stop_duration = tf_in_min * self._stop_duration_candles 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: else:
self._stop_duration_candles = None
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: if "lookback_period_candles" in protection_config:
self._lookback_period_candles = int(protection_config.get("lookback_period_candles", 1)) self._lookback_period_candles = int(protection_config.get("lookback_period_candles", 1))
self._lookback_period = tf_in_min * self._lookback_period_candles self._lookback_period = tf_in_min * self._lookback_period_candles
@ -80,6 +84,16 @@ class IProtection(LoggingMixin, ABC):
else: else:
return f"{self._lookback_period} {plural(self._lookback_period, 'minute', 'minutes')}" 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 @abstractmethod
def short_desc(self) -> str: def short_desc(self) -> str:
""" """
@ -105,16 +119,23 @@ class IProtection(LoggingMixin, ABC):
If true, this pair will be locked with <reason> until <until> If true, this pair will be locked with <reason> until <until>
""" """
@staticmethod def calculate_lock_end(self, trades: List[LocalTrade]) -> datetime:
def calculate_lock_end(trades: List[LocalTrade], stop_minutes: int) -> datetime:
""" """
Get lock end time 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]) max_date: datetime = max([trade.close_date for trade in trades if trade.close_date])
# coming from Database, tzinfo is not set. # coming from Database, tzinfo is not set.
if max_date.tzinfo is None: if max_date.tzinfo is None:
max_date = max_date.replace(tzinfo=timezone.utc) 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 return until

View File

@ -36,7 +36,7 @@ class LowProfitPairs(IProtection):
""" """
return ( return (
f"{profit} < {self._required_profit} in {self.lookback_period_str}, " 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( def _low_profit(
@ -70,7 +70,7 @@ class LowProfitPairs(IProtection):
f"within {self._lookback_period} minutes.", f"within {self._lookback_period} minutes.",
logger.info, logger.info,
) )
until = self.calculate_lock_end(trades, self._stop_duration) until = self.calculate_lock_end(trades)
return ProtectionReturn( return ProtectionReturn(
lock=True, lock=True,

View File

@ -39,7 +39,7 @@ class MaxDrawdown(IProtection):
""" """
return ( return (
f"{drawdown} passed {self._max_allowed_drawdown} in {self.lookback_period_str}, " 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]: def _max_drawdown(self, date_now: datetime) -> Optional[ProtectionReturn]:
@ -70,7 +70,8 @@ class MaxDrawdown(IProtection):
f" within {self.lookback_period_str}.", f" within {self.lookback_period_str}.",
logger.info, logger.info,
) )
until = self.calculate_lock_end(trades, self._stop_duration)
until = self.calculate_lock_end(trades)
return ProtectionReturn( return ProtectionReturn(
lock=True, lock=True,

View File

@ -38,7 +38,7 @@ class StoplossGuard(IProtection):
""" """
return ( return (
f"{self._trade_limit} stoplosses in {self._lookback_period} min, " 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( def _stoploss_guard(
@ -78,7 +78,7 @@ class StoplossGuard(IProtection):
f"stoplosses within {self._lookback_period} minutes.", f"stoplosses within {self._lookback_period} minutes.",
logger.info, logger.info,
) )
until = self.calculate_lock_end(trades, self._stop_duration) until = self.calculate_lock_end(trades)
return ProtectionReturn( return ProtectionReturn(
lock=True, lock=True,
until=until, until=until,

View File

@ -102,56 +102,94 @@ def test_protectionmanager(mocker, default_conf):
@pytest.mark.parametrize( @pytest.mark.parametrize(
"timeframe,expected,protconf", "timeframe,expected_lookback,expected_stop,protconf",
[ [
( (
"1m", "1m",
[20, 10], 20,
10,
[{"method": "StoplossGuard", "lookback_period_candles": 20, "stop_duration": 10}], [{"method": "StoplossGuard", "lookback_period_candles": 20, "stop_duration": 10}],
), ),
( (
"5m", "5m",
[100, 15], 100,
15,
[{"method": "StoplossGuard", "lookback_period_candles": 20, "stop_duration": 15}], [{"method": "StoplossGuard", "lookback_period_candles": 20, "stop_duration": 15}],
), ),
( (
"1h", "1h",
[1200, 40], 1200,
40,
[{"method": "StoplossGuard", "lookback_period_candles": 20, "stop_duration": 40}], [{"method": "StoplossGuard", "lookback_period_candles": 20, "stop_duration": 40}],
), ),
( (
"1d", "1d",
[1440, 5], 1440,
5,
[{"method": "StoplossGuard", "lookback_period_candles": 1, "stop_duration": 5}], [{"method": "StoplossGuard", "lookback_period_candles": 1, "stop_duration": 5}],
), ),
( (
"1m", "1m",
[20, 5], 20,
5,
[{"method": "StoplossGuard", "lookback_period": 20, "stop_duration_candles": 5}], [{"method": "StoplossGuard", "lookback_period": 20, "stop_duration_candles": 5}],
), ),
( (
"5m", "5m",
[15, 25], 15,
25,
[{"method": "StoplossGuard", "lookback_period": 15, "stop_duration_candles": 5}], [{"method": "StoplossGuard", "lookback_period": 15, "stop_duration_candles": 5}],
), ),
( (
"1h", "1h",
[50, 600], 50,
600,
[{"method": "StoplossGuard", "lookback_period": 50, "stop_duration_candles": 10}], [{"method": "StoplossGuard", "lookback_period": 50, "stop_duration_candles": 10}],
), ),
( (
"1h", "1h",
[60, 540], 60,
540,
[{"method": "StoplossGuard", "lookback_period_candles": 1, "stop_duration_candles": 9}], [{"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 default_conf["timeframe"] = timeframe
man = ProtectionManager(default_conf, protconf) man = ProtectionManager(default_conf, protconf)
assert len(man._protection_handlers) == len(protconf) assert len(man._protection_handlers) == len(protconf)
assert man._protection_handlers[0]._lookback_period == expected[0] assert man._protection_handlers[0]._lookback_period == expected_lookback
assert man._protection_handlers[0]._stop_duration == expected[1] 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]) @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() 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.parametrize("only_per_side", [False, True])
@pytest.mark.usefixtures("init_persistence") @pytest.mark.usefixtures("init_persistence")
def test_LowProfitPairs(mocker, default_conf, fee, caplog, only_per_side): 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}, {"method": "CooldownPeriod", "stop_duration": 60},
"[{'CooldownPeriod': 'CooldownPeriod - Cooldown period of 60 minutes.'}]", "[{'CooldownPeriod': 'CooldownPeriod - Cooldown period for 60 minutes.'}]",
None, None,
), ),
( (
@ -639,7 +760,7 @@ def test_MaxDrawdown(mocker, default_conf, fee, caplog):
), ),
( (
{"method": "CooldownPeriod", "stop_duration_candles": 5}, {"method": "CooldownPeriod", "stop_duration_candles": 5},
"[{'CooldownPeriod': 'CooldownPeriod - Cooldown period of 5 candles.'}]", "[{'CooldownPeriod': 'CooldownPeriod - Cooldown period for 5 candles.'}]",
None, None,
), ),
( (
@ -654,6 +775,38 @@ def test_MaxDrawdown(mocker, default_conf, fee, caplog):
"if drawdown is > 0.0 within 20 candles.'}]", "if drawdown is > 0.0 within 20 candles.'}]",
None, 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( def test_protection_manager_desc(

View File

@ -840,6 +840,25 @@ def test_validate_whitelist(default_conf):
], ],
r"Protections must specify either `stop_duration`.*", 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): def test_validate_protections(default_conf, protconf, expected):