mirror of
https://github.com/freqtrade/freqtrade.git
synced 2026-03-04 21:03:31 +00:00
Merge pull request #12835 from ABSllk/feat/fix-max-drawdown-protection
Change max drawdown protection calculation
This commit is contained in:
@@ -69,7 +69,16 @@ def protections(self):
|
|||||||
|
|
||||||
#### MaxDrawdown
|
#### MaxDrawdown
|
||||||
|
|
||||||
`MaxDrawdown` uses all trades within `lookback_period` in minutes (or in candles when using `lookback_period_candles`) to determine the maximum drawdown. If the drawdown is below `max_allowed_drawdown`, trading will stop for `stop_duration` in minutes (or in candles when using `stop_duration_candles`) after the last trade - assuming that the bot needs some time to let markets recover.
|
The `MaxDrawdown` protection evaluates trades that closed within the current `lookback_period` (or `lookback_period_candles`). It supports 2 calculation modes:
|
||||||
|
|
||||||
|
- `calculation_mode: "ratios"` (default): Legacy approximation based on cumulative profit ratios.
|
||||||
|
- `calculation_mode: "equity"`: Standard peak-to-trough drawdown on the account equity curve, using starting balance and cumulative absolute profit.
|
||||||
|
|
||||||
|
With `calculation_mode: "ratios"`, drawdown is derived from cumulative trade profit ratios, not from the account equity curve. This is kept for backward compatibility and can differ from account-level drawdown when position sizing changes over time.
|
||||||
|
|
||||||
|
For new setups, `calculation_mode: "equity"` is recommended. Prefer `calculation_mode: "ratios"` only when you intentionally rely on legacy behavior, especially with fixed stake amount configurations where ratio-based behavior is easier to reason about.
|
||||||
|
|
||||||
|
If the observed drawdown exceeds `max_allowed_drawdown`, trading will stop for `stop_duration` after the last trade - assuming that the bot needs some time to let markets recover.
|
||||||
|
|
||||||
The below sample stops trading for 12 candles if max-drawdown is > 20% considering all pairs - with a minimum of `trade_limit` trades - within the last 48 candles. If desired, `lookback_period` and/or `stop_duration` can be used.
|
The below sample stops trading for 12 candles if max-drawdown is > 20% considering all pairs - with a minimum of `trade_limit` trades - within the last 48 candles. If desired, `lookback_period` and/or `stop_duration` can be used.
|
||||||
|
|
||||||
@@ -79,6 +88,7 @@ def protections(self):
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"method": "MaxDrawdown",
|
"method": "MaxDrawdown",
|
||||||
|
"calculation_mode": "equity",
|
||||||
"lookback_period_candles": 48,
|
"lookback_period_candles": 48,
|
||||||
"trade_limit": 20,
|
"trade_limit": 20,
|
||||||
"stop_duration_candles": 12,
|
"stop_duration_candles": 12,
|
||||||
@@ -163,7 +173,8 @@ class AwesomeStrategy(IStrategy)
|
|||||||
"lookback_period_candles": 48,
|
"lookback_period_candles": 48,
|
||||||
"trade_limit": 20,
|
"trade_limit": 20,
|
||||||
"stop_duration_candles": 4,
|
"stop_duration_candles": 4,
|
||||||
"max_allowed_drawdown": 0.2
|
"max_allowed_drawdown": 0.2,
|
||||||
|
"calculation_mode": "equity"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"method": "StoplossGuard",
|
"method": "StoplossGuard",
|
||||||
|
|||||||
@@ -2421,7 +2421,10 @@ class FreqtradeBot(LoggingMixin):
|
|||||||
def handle_protections(self, pair: str, side: LongShort) -> None:
|
def handle_protections(self, pair: str, side: LongShort) -> None:
|
||||||
# Lock pair for one candle to prevent immediate re-entries
|
# Lock pair for one candle to prevent immediate re-entries
|
||||||
self.strategy.lock_pair(pair, datetime.now(UTC), reason="Auto lock", side=side)
|
self.strategy.lock_pair(pair, datetime.now(UTC), reason="Auto lock", side=side)
|
||||||
prot_trig = self.protections.stop_per_pair(pair, side=side)
|
starting_balance = self.wallets.get_starting_balance()
|
||||||
|
prot_trig = self.protections.stop_per_pair(
|
||||||
|
pair, side=side, starting_balance=starting_balance
|
||||||
|
)
|
||||||
if prot_trig:
|
if prot_trig:
|
||||||
msg: RPCProtectionMsg = {
|
msg: RPCProtectionMsg = {
|
||||||
"type": RPCMessageType.PROTECTION_TRIGGER,
|
"type": RPCMessageType.PROTECTION_TRIGGER,
|
||||||
@@ -2430,7 +2433,7 @@ class FreqtradeBot(LoggingMixin):
|
|||||||
}
|
}
|
||||||
self.rpc.send_msg(msg)
|
self.rpc.send_msg(msg)
|
||||||
|
|
||||||
prot_trig_glb = self.protections.global_stop(side=side)
|
prot_trig_glb = self.protections.global_stop(side=side, starting_balance=starting_balance)
|
||||||
if prot_trig_glb:
|
if prot_trig_glb:
|
||||||
msg = {
|
msg = {
|
||||||
"type": RPCMessageType.PROTECTION_TRIGGER_GLOBAL,
|
"type": RPCMessageType.PROTECTION_TRIGGER_GLOBAL,
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ class Backtesting:
|
|||||||
"exited": {},
|
"exited": {},
|
||||||
}
|
}
|
||||||
self.rejected_dict: dict[str, list] = {}
|
self.rejected_dict: dict[str, list] = {}
|
||||||
|
self.starting_balance: float = 0.0
|
||||||
|
|
||||||
self._exchange_name = self.config["exchange"]["name"]
|
self._exchange_name = self.config["exchange"]["name"]
|
||||||
self.__initial_backtest = exchange is None
|
self.__initial_backtest = exchange is None
|
||||||
@@ -277,6 +278,7 @@ class Backtesting:
|
|||||||
self.reset_backtest(False)
|
self.reset_backtest(False)
|
||||||
|
|
||||||
self.wallets = Wallets(self.config, self.exchange, is_backtest=True)
|
self.wallets = Wallets(self.config, self.exchange, is_backtest=True)
|
||||||
|
self.starting_balance = self.wallets.get_starting_balance()
|
||||||
|
|
||||||
self.progress = BTProgress()
|
self.progress = BTProgress()
|
||||||
self.abort = False
|
self.abort = False
|
||||||
@@ -1271,8 +1273,8 @@ class Backtesting:
|
|||||||
|
|
||||||
def run_protections(self, pair: str, current_time: datetime, side: LongShort):
|
def run_protections(self, pair: str, current_time: datetime, side: LongShort):
|
||||||
if self.enable_protections:
|
if self.enable_protections:
|
||||||
self.protections.stop_per_pair(pair, current_time, side)
|
self.protections.stop_per_pair(pair, current_time, side, self.starting_balance)
|
||||||
self.protections.global_stop(current_time, side)
|
self.protections.global_stop(current_time, side, self.starting_balance)
|
||||||
|
|
||||||
def manage_open_orders(self, trade: LocalTrade, current_time: datetime, row: tuple) -> bool:
|
def manage_open_orders(self, trade: LocalTrade, current_time: datetime, row: tuple) -> bool:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -47,13 +47,17 @@ class ProtectionManager:
|
|||||||
"""
|
"""
|
||||||
return [{p.name: p.short_desc()} for p in self._protection_handlers]
|
return [{p.name: p.short_desc()} for p in self._protection_handlers]
|
||||||
|
|
||||||
def global_stop(self, now: datetime | None = None, side: LongShort = "long") -> PairLock | None:
|
def global_stop(
|
||||||
|
self, now: datetime | None = None, side: LongShort = "long", starting_balance: float = 0.0
|
||||||
|
) -> PairLock | None:
|
||||||
if not now:
|
if not now:
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
result = None
|
result = None
|
||||||
for protection_handler in self._protection_handlers:
|
for protection_handler in self._protection_handlers:
|
||||||
if protection_handler.has_global_stop:
|
if protection_handler.has_global_stop:
|
||||||
lock = protection_handler.global_stop(date_now=now, side=side)
|
lock = protection_handler.global_stop(
|
||||||
|
date_now=now, side=side, starting_balance=starting_balance
|
||||||
|
)
|
||||||
if lock and lock.until:
|
if lock and lock.until:
|
||||||
if not PairLocks.is_global_lock(lock.until, side=lock.lock_side):
|
if not PairLocks.is_global_lock(lock.until, side=lock.lock_side):
|
||||||
result = PairLocks.lock_pair(
|
result = PairLocks.lock_pair(
|
||||||
@@ -62,14 +66,20 @@ class ProtectionManager:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
def stop_per_pair(
|
def stop_per_pair(
|
||||||
self, pair, now: datetime | None = None, side: LongShort = "long"
|
self,
|
||||||
|
pair,
|
||||||
|
now: datetime | None = None,
|
||||||
|
side: LongShort = "long",
|
||||||
|
starting_balance: float = 0.0,
|
||||||
) -> PairLock | None:
|
) -> PairLock | None:
|
||||||
if not now:
|
if not now:
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
result = None
|
result = None
|
||||||
for protection_handler in self._protection_handlers:
|
for protection_handler in self._protection_handlers:
|
||||||
if protection_handler.has_local_stop:
|
if protection_handler.has_local_stop:
|
||||||
lock = protection_handler.stop_per_pair(pair=pair, date_now=now, side=side)
|
lock = protection_handler.stop_per_pair(
|
||||||
|
pair=pair, date_now=now, side=side, starting_balance=starting_balance
|
||||||
|
)
|
||||||
if lock and lock.until:
|
if lock and lock.until:
|
||||||
if not PairLocks.is_pair_locked(pair, lock.until, lock.lock_side):
|
if not PairLocks.is_pair_locked(pair, lock.until, lock.lock_side):
|
||||||
result = PairLocks.lock_pair(
|
result = PairLocks.lock_pair(
|
||||||
|
|||||||
@@ -52,7 +52,9 @@ class CooldownPeriod(IProtection):
|
|||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def global_stop(self, date_now: datetime, side: LongShort) -> ProtectionReturn | None:
|
def global_stop(
|
||||||
|
self, date_now: datetime, side: LongShort, starting_balance: float
|
||||||
|
) -> ProtectionReturn | None:
|
||||||
"""
|
"""
|
||||||
Stops trading (position entering) for all pairs
|
Stops trading (position entering) for all pairs
|
||||||
This must evaluate to true for the whole period of the "cooldown period".
|
This must evaluate to true for the whole period of the "cooldown period".
|
||||||
@@ -63,7 +65,7 @@ class CooldownPeriod(IProtection):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def stop_per_pair(
|
def stop_per_pair(
|
||||||
self, pair: str, date_now: datetime, side: LongShort
|
self, pair: str, date_now: datetime, side: LongShort, starting_balance: float
|
||||||
) -> ProtectionReturn | None:
|
) -> ProtectionReturn | None:
|
||||||
"""
|
"""
|
||||||
Stops trading (position entering) for this pair
|
Stops trading (position entering) for this pair
|
||||||
|
|||||||
@@ -102,7 +102,9 @@ class IProtection(LoggingMixin, ABC):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def global_stop(self, date_now: datetime, side: LongShort) -> ProtectionReturn | None:
|
def global_stop(
|
||||||
|
self, date_now: datetime, side: LongShort, starting_balance: float
|
||||||
|
) -> ProtectionReturn | None:
|
||||||
"""
|
"""
|
||||||
Stops trading (position entering) for all pairs
|
Stops trading (position entering) for all pairs
|
||||||
This must evaluate to true for the whole period of the "cooldown period".
|
This must evaluate to true for the whole period of the "cooldown period".
|
||||||
@@ -110,7 +112,7 @@ class IProtection(LoggingMixin, ABC):
|
|||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def stop_per_pair(
|
def stop_per_pair(
|
||||||
self, pair: str, date_now: datetime, side: LongShort
|
self, pair: str, date_now: datetime, side: LongShort, starting_balance: float
|
||||||
) -> ProtectionReturn | None:
|
) -> ProtectionReturn | None:
|
||||||
"""
|
"""
|
||||||
Stops trading (position entering) for this pair
|
Stops trading (position entering) for this pair
|
||||||
|
|||||||
@@ -81,7 +81,9 @@ class LowProfitPairs(IProtection):
|
|||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def global_stop(self, date_now: datetime, side: LongShort) -> ProtectionReturn | None:
|
def global_stop(
|
||||||
|
self, date_now: datetime, side: LongShort, starting_balance: float
|
||||||
|
) -> ProtectionReturn | None:
|
||||||
"""
|
"""
|
||||||
Stops trading (position entering) for all pairs
|
Stops trading (position entering) for all pairs
|
||||||
This must evaluate to true for the whole period of the "cooldown period".
|
This must evaluate to true for the whole period of the "cooldown period".
|
||||||
@@ -91,7 +93,7 @@ class LowProfitPairs(IProtection):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def stop_per_pair(
|
def stop_per_pair(
|
||||||
self, pair: str, date_now: datetime, side: LongShort
|
self, pair: str, date_now: datetime, side: LongShort, starting_balance: float
|
||||||
) -> ProtectionReturn | None:
|
) -> ProtectionReturn | None:
|
||||||
"""
|
"""
|
||||||
Stops trading (position entering) for this pair
|
Stops trading (position entering) for this pair
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ class MaxDrawdown(IProtection):
|
|||||||
|
|
||||||
self._trade_limit = protection_config.get("trade_limit", 1)
|
self._trade_limit = protection_config.get("trade_limit", 1)
|
||||||
self._max_allowed_drawdown = protection_config.get("max_allowed_drawdown", 0.0)
|
self._max_allowed_drawdown = protection_config.get("max_allowed_drawdown", 0.0)
|
||||||
|
self._calculation_mode = protection_config.get("calculation_mode", "ratios")
|
||||||
# TODO: Implement checks to limit max_drawdown to sensible values
|
# TODO: Implement checks to limit max_drawdown to sensible values
|
||||||
|
|
||||||
def short_desc(self) -> str:
|
def short_desc(self) -> str:
|
||||||
@@ -42,24 +43,52 @@ class MaxDrawdown(IProtection):
|
|||||||
f"locking {self.unlock_reason_time_element}."
|
f"locking {self.unlock_reason_time_element}."
|
||||||
)
|
)
|
||||||
|
|
||||||
def _max_drawdown(self, date_now: datetime) -> ProtectionReturn | None:
|
def _max_drawdown(self, date_now: datetime, starting_balance: float) -> ProtectionReturn | None:
|
||||||
"""
|
"""
|
||||||
Evaluate recent trades for drawdown ...
|
Evaluate recent trades for drawdown ...
|
||||||
"""
|
"""
|
||||||
look_back_until = date_now - timedelta(minutes=self._lookback_period)
|
look_back_until = date_now - timedelta(minutes=self._lookback_period)
|
||||||
|
|
||||||
trades = Trade.get_trades_proxy(is_open=False, close_date=look_back_until)
|
trades_in_window = Trade.get_trades_proxy(is_open=False, close_date=look_back_until)
|
||||||
|
|
||||||
trades_df = pd.DataFrame([trade.to_json() for trade in trades])
|
if len(trades_in_window) < self._trade_limit:
|
||||||
|
|
||||||
if len(trades) < self._trade_limit:
|
|
||||||
# Not enough trades in the relevant period
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Drawdown is always positive
|
|
||||||
try:
|
try:
|
||||||
# TODO: This should use absolute profit calculation, considering account balance.
|
if self._calculation_mode == "equity":
|
||||||
|
# Standard equity-based drawdown
|
||||||
|
# Get all trades to calculate cumulative profit before the window
|
||||||
|
all_closed_trades = Trade.get_trades_proxy(is_open=False)
|
||||||
|
profit_before_window = sum(
|
||||||
|
trade.close_profit_abs or 0.0
|
||||||
|
for trade in all_closed_trades
|
||||||
|
if trade.close_date_utc <= look_back_until
|
||||||
|
)
|
||||||
|
|
||||||
|
trades_df = pd.DataFrame(
|
||||||
|
[
|
||||||
|
{"close_date": t.close_date_utc, "profit_abs": t.close_profit_abs}
|
||||||
|
for t in trades_in_window
|
||||||
|
]
|
||||||
|
)
|
||||||
|
actual_starting_balance = starting_balance + profit_before_window
|
||||||
|
drawdown_obj = calculate_max_drawdown(
|
||||||
|
trades_df,
|
||||||
|
value_col="profit_abs",
|
||||||
|
starting_balance=actual_starting_balance,
|
||||||
|
relative=True,
|
||||||
|
)
|
||||||
|
drawdown = drawdown_obj.relative_account_drawdown
|
||||||
|
else:
|
||||||
|
# Legacy ratios-based calculation (default)
|
||||||
|
trades_df = pd.DataFrame(
|
||||||
|
[
|
||||||
|
{"close_date": t.close_date_utc, "close_profit": t.close_profit}
|
||||||
|
for t in trades_in_window
|
||||||
|
]
|
||||||
|
)
|
||||||
drawdown_obj = calculate_max_drawdown(trades_df, value_col="close_profit")
|
drawdown_obj = calculate_max_drawdown(trades_df, value_col="close_profit")
|
||||||
|
# In ratios mode, drawdown_abs is the cumulative ratio drop
|
||||||
drawdown = drawdown_obj.drawdown_abs
|
drawdown = drawdown_obj.drawdown_abs
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return None
|
return None
|
||||||
@@ -71,7 +100,7 @@ class MaxDrawdown(IProtection):
|
|||||||
logger.info,
|
logger.info,
|
||||||
)
|
)
|
||||||
|
|
||||||
until = self.calculate_lock_end(trades)
|
until = self.calculate_lock_end(trades_in_window)
|
||||||
|
|
||||||
return ProtectionReturn(
|
return ProtectionReturn(
|
||||||
lock=True,
|
lock=True,
|
||||||
@@ -81,17 +110,19 @@ class MaxDrawdown(IProtection):
|
|||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def global_stop(self, date_now: datetime, side: LongShort) -> ProtectionReturn | None:
|
def global_stop(
|
||||||
|
self, date_now: datetime, side: LongShort, starting_balance: float
|
||||||
|
) -> ProtectionReturn | None:
|
||||||
"""
|
"""
|
||||||
Stops trading (position entering) for all pairs
|
Stops trading (position entering) for all pairs
|
||||||
This must evaluate to true for the whole period of the "cooldown period".
|
This must evaluate to true for the whole period of the "cooldown period".
|
||||||
:return: Tuple of [bool, until, reason].
|
:return: Tuple of [bool, until, reason].
|
||||||
If true, all pairs will be locked with <reason> until <until>
|
If true, all pairs will be locked with <reason> until <until>
|
||||||
"""
|
"""
|
||||||
return self._max_drawdown(date_now)
|
return self._max_drawdown(date_now, starting_balance)
|
||||||
|
|
||||||
def stop_per_pair(
|
def stop_per_pair(
|
||||||
self, pair: str, date_now: datetime, side: LongShort
|
self, pair: str, date_now: datetime, side: LongShort, starting_balance: float
|
||||||
) -> ProtectionReturn | None:
|
) -> ProtectionReturn | None:
|
||||||
"""
|
"""
|
||||||
Stops trading (position entering) for this pair
|
Stops trading (position entering) for this pair
|
||||||
|
|||||||
@@ -86,7 +86,9 @@ class StoplossGuard(IProtection):
|
|||||||
lock_side=(side if self._only_per_side else "*"),
|
lock_side=(side if self._only_per_side else "*"),
|
||||||
)
|
)
|
||||||
|
|
||||||
def global_stop(self, date_now: datetime, side: LongShort) -> ProtectionReturn | None:
|
def global_stop(
|
||||||
|
self, date_now: datetime, side: LongShort, starting_balance: float
|
||||||
|
) -> ProtectionReturn | None:
|
||||||
"""
|
"""
|
||||||
Stops trading (position entering) for all pairs
|
Stops trading (position entering) for all pairs
|
||||||
This must evaluate to true for the whole period of the "cooldown period".
|
This must evaluate to true for the whole period of the "cooldown period".
|
||||||
@@ -98,7 +100,7 @@ class StoplossGuard(IProtection):
|
|||||||
return self._stoploss_guard(date_now, None, side)
|
return self._stoploss_guard(date_now, None, side)
|
||||||
|
|
||||||
def stop_per_pair(
|
def stop_per_pair(
|
||||||
self, pair: str, date_now: datetime, side: LongShort
|
self, pair: str, date_now: datetime, side: LongShort, starting_balance: float
|
||||||
) -> ProtectionReturn | None:
|
) -> ProtectionReturn | None:
|
||||||
"""
|
"""
|
||||||
Stops trading (position entering) for this pair
|
Stops trading (position entering) for this pair
|
||||||
|
|||||||
@@ -604,6 +604,7 @@ def get_default_conf(testdatadir):
|
|||||||
"cancel_open_orders_on_exit": False,
|
"cancel_open_orders_on_exit": False,
|
||||||
"minimal_roi": {"40": 0.0, "30": 0.01, "20": 0.02, "0": 0.04},
|
"minimal_roi": {"40": 0.0, "30": 0.01, "20": 0.02, "0": 0.04},
|
||||||
"dry_run_wallet": 1000,
|
"dry_run_wallet": 1000,
|
||||||
|
"tradable_balance_ratio": 0.99,
|
||||||
"stoploss": -0.10,
|
"stoploss": -0.10,
|
||||||
"unfilledtimeout": {"entry": 10, "exit": 30},
|
"unfilledtimeout": {"entry": 10, "exit": 30},
|
||||||
"entry_pricing": {
|
"entry_pricing": {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import random
|
import random
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -99,9 +100,9 @@ def test_protectionmanager(mocker, default_conf):
|
|||||||
for handler in freqtrade.protections._protection_handlers:
|
for handler in freqtrade.protections._protection_handlers:
|
||||||
assert handler.name in AVAILABLE_PROTECTIONS
|
assert handler.name in AVAILABLE_PROTECTIONS
|
||||||
if not handler.has_global_stop:
|
if not handler.has_global_stop:
|
||||||
assert handler.global_stop(datetime.now(UTC), "*") is None
|
assert handler.global_stop(datetime.now(UTC), "*", 1000.0) is None
|
||||||
if not handler.has_local_stop:
|
if not handler.has_local_stop:
|
||||||
assert handler.stop_per_pair("XRP/BTC", datetime.now(UTC), "*") is None
|
assert handler.stop_per_pair("XRP/BTC", datetime.now(UTC), "*", 1000.0) is None
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
@@ -658,7 +659,7 @@ def test_LowProfitPairs(mocker, default_conf, fee, caplog, only_per_side):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.usefixtures("init_persistence")
|
@pytest.mark.usefixtures("init_persistence")
|
||||||
def test_MaxDrawdown(mocker, default_conf, fee, caplog):
|
def test_MaxDrawdown_ratio_mode(mocker, default_conf, fee, caplog):
|
||||||
default_conf["_strategy_protections"] = [
|
default_conf["_strategy_protections"] = [
|
||||||
{
|
{
|
||||||
"method": "MaxDrawdown",
|
"method": "MaxDrawdown",
|
||||||
@@ -670,9 +671,10 @@ def test_MaxDrawdown(mocker, default_conf, fee, caplog):
|
|||||||
]
|
]
|
||||||
freqtrade = get_patched_freqtradebot(mocker, default_conf)
|
freqtrade = get_patched_freqtradebot(mocker, default_conf)
|
||||||
message = r"Trading stopped due to Max.*"
|
message = r"Trading stopped due to Max.*"
|
||||||
|
starting_balance = 0.05
|
||||||
|
|
||||||
assert not freqtrade.protections.global_stop()
|
assert not freqtrade.protections.global_stop(starting_balance=starting_balance)
|
||||||
assert not freqtrade.protections.stop_per_pair("XRP/BTC")
|
assert not freqtrade.protections.stop_per_pair("XRP/BTC", starting_balance=starting_balance)
|
||||||
caplog.clear()
|
caplog.clear()
|
||||||
|
|
||||||
generate_mock_trade(
|
generate_mock_trade(
|
||||||
@@ -704,8 +706,8 @@ def test_MaxDrawdown(mocker, default_conf, fee, caplog):
|
|||||||
)
|
)
|
||||||
Trade.commit()
|
Trade.commit()
|
||||||
# No losing trade yet ... so max_drawdown will raise exception
|
# No losing trade yet ... so max_drawdown will raise exception
|
||||||
assert not freqtrade.protections.global_stop()
|
assert not freqtrade.protections.global_stop(starting_balance=starting_balance)
|
||||||
assert not freqtrade.protections.stop_per_pair("XRP/BTC")
|
assert not freqtrade.protections.stop_per_pair("XRP/BTC", starting_balance=starting_balance)
|
||||||
|
|
||||||
generate_mock_trade(
|
generate_mock_trade(
|
||||||
"XRP/BTC",
|
"XRP/BTC",
|
||||||
@@ -717,8 +719,8 @@ def test_MaxDrawdown(mocker, default_conf, fee, caplog):
|
|||||||
profit_rate=0.9,
|
profit_rate=0.9,
|
||||||
)
|
)
|
||||||
# Not locked with one trade
|
# Not locked with one trade
|
||||||
assert not freqtrade.protections.global_stop()
|
assert not freqtrade.protections.global_stop(starting_balance=starting_balance)
|
||||||
assert not freqtrade.protections.stop_per_pair("XRP/BTC")
|
assert not freqtrade.protections.stop_per_pair("XRP/BTC", starting_balance=starting_balance)
|
||||||
assert not PairLocks.is_pair_locked("XRP/BTC")
|
assert not PairLocks.is_pair_locked("XRP/BTC")
|
||||||
assert not PairLocks.is_global_lock()
|
assert not PairLocks.is_global_lock()
|
||||||
|
|
||||||
@@ -734,8 +736,8 @@ def test_MaxDrawdown(mocker, default_conf, fee, caplog):
|
|||||||
Trade.commit()
|
Trade.commit()
|
||||||
|
|
||||||
# Not locked with 1 trade (2nd trade is outside of lookback_period)
|
# Not locked with 1 trade (2nd trade is outside of lookback_period)
|
||||||
assert not freqtrade.protections.global_stop()
|
assert not freqtrade.protections.global_stop(starting_balance=starting_balance)
|
||||||
assert not freqtrade.protections.stop_per_pair("XRP/BTC")
|
assert not freqtrade.protections.stop_per_pair("XRP/BTC", starting_balance=starting_balance)
|
||||||
assert not PairLocks.is_pair_locked("XRP/BTC")
|
assert not PairLocks.is_pair_locked("XRP/BTC")
|
||||||
assert not PairLocks.is_global_lock()
|
assert not PairLocks.is_global_lock()
|
||||||
assert not log_has_re(message, caplog)
|
assert not log_has_re(message, caplog)
|
||||||
@@ -751,7 +753,7 @@ def test_MaxDrawdown(mocker, default_conf, fee, caplog):
|
|||||||
profit_rate=1.5,
|
profit_rate=1.5,
|
||||||
)
|
)
|
||||||
Trade.commit()
|
Trade.commit()
|
||||||
assert not freqtrade.protections.global_stop()
|
assert not freqtrade.protections.global_stop(starting_balance=starting_balance)
|
||||||
assert not PairLocks.is_global_lock()
|
assert not PairLocks.is_global_lock()
|
||||||
|
|
||||||
caplog.clear()
|
caplog.clear()
|
||||||
@@ -764,17 +766,215 @@ def test_MaxDrawdown(mocker, default_conf, fee, caplog):
|
|||||||
exit_reason=ExitType.ROI.value,
|
exit_reason=ExitType.ROI.value,
|
||||||
min_ago_open=20,
|
min_ago_open=20,
|
||||||
min_ago_close=10,
|
min_ago_close=10,
|
||||||
profit_rate=0.8,
|
profit_rate=0.2,
|
||||||
)
|
)
|
||||||
Trade.commit()
|
Trade.commit()
|
||||||
assert not freqtrade.protections.stop_per_pair("XRP/BTC")
|
assert not freqtrade.protections.stop_per_pair("XRP/BTC", starting_balance=starting_balance)
|
||||||
# local lock not supported
|
# local lock not supported
|
||||||
assert not PairLocks.is_pair_locked("XRP/BTC")
|
assert not PairLocks.is_pair_locked("XRP/BTC")
|
||||||
assert freqtrade.protections.global_stop()
|
assert freqtrade.protections.global_stop(starting_balance=starting_balance)
|
||||||
assert PairLocks.is_global_lock()
|
assert PairLocks.is_global_lock()
|
||||||
assert log_has_re(message, caplog)
|
assert log_has_re(message, caplog)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("init_persistence")
|
||||||
|
def test_MaxDrawdown_equity_mode(mocker, default_conf, fee):
|
||||||
|
default_conf["_strategy_protections"] = [
|
||||||
|
{
|
||||||
|
"method": "MaxDrawdown",
|
||||||
|
"lookback_period": 1000,
|
||||||
|
"stop_duration": 60,
|
||||||
|
"trade_limit": 1,
|
||||||
|
"max_allowed_drawdown": 0.01,
|
||||||
|
"calculation_mode": "equity",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
freqtrade = get_patched_freqtradebot(mocker, default_conf)
|
||||||
|
starting_balance = 0.01
|
||||||
|
|
||||||
|
assert not freqtrade.protections.global_stop(starting_balance=starting_balance)
|
||||||
|
|
||||||
|
generate_mock_trade(
|
||||||
|
"XRP/BTC",
|
||||||
|
fee.return_value,
|
||||||
|
False,
|
||||||
|
exit_reason=ExitType.STOP_LOSS.value,
|
||||||
|
min_ago_open=30,
|
||||||
|
min_ago_close=10,
|
||||||
|
profit_rate=0.5,
|
||||||
|
)
|
||||||
|
Trade.commit()
|
||||||
|
|
||||||
|
assert freqtrade.protections.global_stop(starting_balance=starting_balance)
|
||||||
|
assert PairLocks.is_global_lock()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"calculation_mode,expected_locked",
|
||||||
|
[("ratios", True), ("equity", False)],
|
||||||
|
)
|
||||||
|
@pytest.mark.usefixtures("init_persistence")
|
||||||
|
def test_MaxDrawdown_mode_comparison(mocker, default_conf, fee, calculation_mode, expected_locked):
|
||||||
|
default_conf["_strategy_protections"] = [
|
||||||
|
{
|
||||||
|
"method": "MaxDrawdown",
|
||||||
|
"lookback_period": 1000,
|
||||||
|
"stop_duration": 60,
|
||||||
|
"trade_limit": 3,
|
||||||
|
"max_allowed_drawdown": 0.15,
|
||||||
|
"calculation_mode": calculation_mode,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
freqtrade = get_patched_freqtradebot(mocker, default_conf)
|
||||||
|
starting_balance = 1000.0
|
||||||
|
|
||||||
|
# Same trade sequence for both modes: ratios mode lock expected,equity mode no lock expected.
|
||||||
|
generate_mock_trade(
|
||||||
|
"XRP/BTC",
|
||||||
|
fee.return_value,
|
||||||
|
False,
|
||||||
|
exit_reason=ExitType.ROI.value,
|
||||||
|
min_ago_open=120,
|
||||||
|
min_ago_close=50,
|
||||||
|
profit_rate=1.2,
|
||||||
|
)
|
||||||
|
generate_mock_trade(
|
||||||
|
"ETH/BTC",
|
||||||
|
fee.return_value,
|
||||||
|
False,
|
||||||
|
exit_reason=ExitType.STOP_LOSS.value,
|
||||||
|
min_ago_open=80,
|
||||||
|
min_ago_close=20,
|
||||||
|
profit_rate=0.9,
|
||||||
|
)
|
||||||
|
generate_mock_trade(
|
||||||
|
"NEO/BTC",
|
||||||
|
fee.return_value,
|
||||||
|
False,
|
||||||
|
exit_reason=ExitType.STOP_LOSS.value,
|
||||||
|
min_ago_open=40,
|
||||||
|
min_ago_close=10,
|
||||||
|
profit_rate=0.9,
|
||||||
|
)
|
||||||
|
Trade.commit()
|
||||||
|
|
||||||
|
lock = freqtrade.protections.global_stop(starting_balance=starting_balance)
|
||||||
|
assert bool(lock) is expected_locked
|
||||||
|
assert PairLocks.is_global_lock(side="long") is expected_locked
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("calculation_mode", ["ratios", "equity"])
|
||||||
|
@pytest.mark.usefixtures("init_persistence")
|
||||||
|
def test_MaxDrawdown_threshold_boundary(mocker, default_conf, calculation_mode):
|
||||||
|
threshold = 0.15
|
||||||
|
default_conf["_strategy_protections"] = [
|
||||||
|
{
|
||||||
|
"method": "MaxDrawdown",
|
||||||
|
"lookback_period": 1000,
|
||||||
|
"stop_duration": 60,
|
||||||
|
"trade_limit": 1,
|
||||||
|
"max_allowed_drawdown": threshold,
|
||||||
|
"calculation_mode": calculation_mode,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
freqtrade = get_patched_freqtradebot(mocker, default_conf)
|
||||||
|
handler = next(p for p in freqtrade.protections._protection_handlers if p.name == "MaxDrawdown")
|
||||||
|
md_globals = handler._max_drawdown.__globals__
|
||||||
|
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
trades_in_window = [
|
||||||
|
SimpleNamespace(
|
||||||
|
close_date_utc=now - timedelta(minutes=10), close_profit_abs=-1.0, close_profit=-0.1
|
||||||
|
)
|
||||||
|
]
|
||||||
|
all_closed_trades = [
|
||||||
|
SimpleNamespace(
|
||||||
|
close_date_utc=now - timedelta(minutes=1500), close_profit_abs=5.0, close_profit=0.2
|
||||||
|
),
|
||||||
|
trades_in_window[0],
|
||||||
|
]
|
||||||
|
|
||||||
|
proxy_side_effect = [trades_in_window]
|
||||||
|
if calculation_mode == "equity":
|
||||||
|
proxy_side_effect.append(all_closed_trades)
|
||||||
|
|
||||||
|
mocker.patch.object(
|
||||||
|
md_globals["Trade"],
|
||||||
|
"get_trades_proxy",
|
||||||
|
side_effect=proxy_side_effect,
|
||||||
|
)
|
||||||
|
calc_mock = mocker.Mock(
|
||||||
|
return_value=SimpleNamespace(relative_account_drawdown=threshold, drawdown_abs=threshold)
|
||||||
|
)
|
||||||
|
mocker.patch.dict(md_globals, {"calculate_max_drawdown": calc_mock})
|
||||||
|
|
||||||
|
assert not handler.global_stop(datetime.now(UTC), "long", starting_balance=1000.0)
|
||||||
|
assert not PairLocks.is_global_lock()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"calculation_mode,expected_value_col,expected_proxy_calls",
|
||||||
|
[("ratios", "close_profit", 1), ("equity", "profit_abs", 2)],
|
||||||
|
)
|
||||||
|
@pytest.mark.usefixtures("init_persistence")
|
||||||
|
def test_MaxDrawdown_calculation_mode_dispatch(
|
||||||
|
mocker, default_conf, calculation_mode, expected_value_col, expected_proxy_calls
|
||||||
|
):
|
||||||
|
default_conf["_strategy_protections"] = [
|
||||||
|
{
|
||||||
|
"method": "MaxDrawdown",
|
||||||
|
"lookback_period": 1000,
|
||||||
|
"stop_duration": 60,
|
||||||
|
"trade_limit": 1,
|
||||||
|
"max_allowed_drawdown": 0.15,
|
||||||
|
"calculation_mode": calculation_mode,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
freqtrade = get_patched_freqtradebot(mocker, default_conf)
|
||||||
|
handler = next(p for p in freqtrade.protections._protection_handlers if p.name == "MaxDrawdown")
|
||||||
|
md_globals = handler._max_drawdown.__globals__
|
||||||
|
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
trades_in_window = [
|
||||||
|
SimpleNamespace(
|
||||||
|
close_date_utc=now - timedelta(minutes=10), close_profit_abs=-1.0, close_profit=-0.1
|
||||||
|
)
|
||||||
|
]
|
||||||
|
all_closed_trades = [
|
||||||
|
SimpleNamespace(
|
||||||
|
close_date_utc=now - timedelta(minutes=1500), close_profit_abs=5.0, close_profit=0.2
|
||||||
|
),
|
||||||
|
trades_in_window[0],
|
||||||
|
]
|
||||||
|
|
||||||
|
proxy_side_effect = [trades_in_window]
|
||||||
|
if calculation_mode == "equity":
|
||||||
|
proxy_side_effect.append(all_closed_trades)
|
||||||
|
|
||||||
|
proxy_mock = mocker.patch.object(
|
||||||
|
md_globals["Trade"],
|
||||||
|
"get_trades_proxy",
|
||||||
|
side_effect=proxy_side_effect,
|
||||||
|
)
|
||||||
|
calc_mock = mocker.Mock(
|
||||||
|
return_value=SimpleNamespace(relative_account_drawdown=0.0, drawdown_abs=0.0)
|
||||||
|
)
|
||||||
|
mocker.patch.dict(md_globals, {"calculate_max_drawdown": calc_mock})
|
||||||
|
|
||||||
|
assert not handler.global_stop(datetime.now(UTC), "long", starting_balance=1000.0)
|
||||||
|
|
||||||
|
assert proxy_mock.call_count == expected_proxy_calls
|
||||||
|
kwargs = calc_mock.call_args.kwargs
|
||||||
|
assert kwargs["value_col"] == expected_value_col
|
||||||
|
|
||||||
|
if calculation_mode == "equity":
|
||||||
|
assert kwargs["starting_balance"] == 1005.0
|
||||||
|
assert kwargs["relative"] is True
|
||||||
|
else:
|
||||||
|
assert "starting_balance" not in kwargs
|
||||||
|
assert "relative" not in kwargs
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"protectionconf,desc_expected,exception_expected",
|
"protectionconf,desc_expected,exception_expected",
|
||||||
[
|
[
|
||||||
|
|||||||
Reference in New Issue
Block a user