Added unlock_at field for protection config

This commit is contained in:
simwai 2024-07-04 10:29:13 +02:00
parent 1d3ca5743b
commit 2b456cbdeb
8 changed files with 151 additions and 21 deletions

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
@ -192,18 +193,40 @@ def _validate_protections(conf: Dict[str, Any]) -> None:
""" """
for prot in conf.get("protections", []): for prot in conf.get("protections", []):
parsed_unlock_at = _validate_unlock_at(prot)
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:
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: def _validate_ask_orderbook(conf: Dict[str, Any]) -> None:
ask_strategy = conf.get("exit_pricing", {}) ask_strategy = conf.get("exit_pricing", {})

View File

@ -356,6 +356,7 @@ CONF_SCHEMA = {
"properties": { "properties": {
"method": {"type": "string", "enum": AVAILABLE_PROTECTIONS}, "method": {"type": "string", "enum": AVAILABLE_PROTECTIONS},
"stop_duration": {"type": "number", "minimum": 0.0}, "stop_duration": {"type": "number", "minimum": 0.0},
"unlock_at": {"type": "string"},
"stop_duration_candles": {"type": "number", "minimum": 0}, "stop_duration_candles": {"type": "number", "minimum": 0},
"trade_limit": {"type": "number", "minimum": 1}, "trade_limit": {"type": "number", "minimum": 1},
"lookback_period": {"type": "number", "minimum": 1}, "lookback_period": {"type": "number", "minimum": 1},

View File

@ -18,7 +18,10 @@ class CooldownPeriod(IProtection):
""" """
LockReason to use 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: def short_desc(self) -> str:
""" """

View File

@ -2,7 +2,7 @@ import logging
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timedelta, timezone 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.constants import Config, LongShort
from freqtrade.exchange import timeframe_to_minutes from freqtrade.exchange import timeframe_to_minutes
@ -33,21 +33,33 @@ class IProtection(LoggingMixin, ABC):
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._lookback_period_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"]) 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
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
else: else:
self._lookback_period_candles = None
self._lookback_period = int(protection_config.get("lookback_period", 60)) 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) LoggingMixin.__init__(self, logger)
@property @property
@ -80,6 +92,15 @@ 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_at_str(self) -> Union[str, None]:
"""
Output configured unlock time
"""
if self.unlock_at:
return self.unlock_at.strftime("%H:%M")
return None
@abstractmethod @abstractmethod
def short_desc(self) -> str: def short_desc(self) -> str:
""" """
@ -118,3 +139,14 @@ class IProtection(LoggingMixin, ABC):
until = max_date + timedelta(minutes=stop_minutes) until = max_date + timedelta(minutes=stop_minutes)
return until 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)

View File

@ -34,10 +34,13 @@ class LowProfitPairs(IProtection):
""" """
LockReason to use LockReason to use
""" """
return ( reason = (
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 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( def _low_profit(
self, date_now: datetime, pair: str, side: LongShort self, date_now: datetime, pair: str, side: LongShort

View File

@ -37,10 +37,13 @@ class MaxDrawdown(IProtection):
""" """
LockReason to use LockReason to use
""" """
return ( reason = (
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 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]: def _max_drawdown(self, date_now: datetime) -> Optional[ProtectionReturn]:
""" """

View File

@ -36,10 +36,13 @@ class StoplossGuard(IProtection):
""" """
LockReason to use LockReason to use
""" """
return ( reason = (
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 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( def _stoploss_guard(
self, date_now: datetime, pair: Optional[str], side: LongShort self, date_now: datetime, pair: Optional[str], side: LongShort

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.strftime("%H:%M") == expected_stop
@pytest.mark.parametrize("is_short", [False, True]) @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.'}]", "if drawdown is > 0.0 within 20 candles.'}]",
None, 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( def test_protection_manager_desc(