Merge pull request #12835 from ABSllk/feat/fix-max-drawdown-protection

Change max drawdown protection calculation
This commit is contained in:
Matthias
2026-02-23 20:32:44 +01:00
committed by GitHub
11 changed files with 313 additions and 47 deletions

View File

@@ -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",

View File

@@ -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,

View File

@@ -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:
""" """

View File

@@ -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(

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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,25 +43,53 @@ 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":
drawdown_obj = calculate_max_drawdown(trades_df, value_col="close_profit") # Standard equity-based drawdown
drawdown = drawdown_obj.drawdown_abs # 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")
# In ratios mode, drawdown_abs is the cumulative ratio drop
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

View File

@@ -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

View File

@@ -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": {

View File

@@ -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",
[ [