diff --git a/docs/includes/protections.md b/docs/includes/protections.md index c32846165..f2cb32fc0 100644 --- a/docs/includes/protections.md +++ b/docs/includes/protections.md @@ -69,7 +69,16 @@ def protections(self): #### 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. @@ -79,6 +88,7 @@ def protections(self): return [ { "method": "MaxDrawdown", + "calculation_mode": "equity", "lookback_period_candles": 48, "trade_limit": 20, "stop_duration_candles": 12, @@ -163,7 +173,8 @@ class AwesomeStrategy(IStrategy) "lookback_period_candles": 48, "trade_limit": 20, "stop_duration_candles": 4, - "max_allowed_drawdown": 0.2 + "max_allowed_drawdown": 0.2, + "calculation_mode": "equity" }, { "method": "StoplossGuard", diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index b80442ca0..bfb9a1841 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -2421,7 +2421,10 @@ class FreqtradeBot(LoggingMixin): def handle_protections(self, pair: str, side: LongShort) -> None: # Lock pair for one candle to prevent immediate re-entries 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: msg: RPCProtectionMsg = { "type": RPCMessageType.PROTECTION_TRIGGER, @@ -2430,7 +2433,7 @@ class FreqtradeBot(LoggingMixin): } 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: msg = { "type": RPCMessageType.PROTECTION_TRIGGER_GLOBAL, diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index 26fd3f454..4311df21a 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -136,6 +136,7 @@ class Backtesting: "exited": {}, } self.rejected_dict: dict[str, list] = {} + self.starting_balance: float = 0.0 self._exchange_name = self.config["exchange"]["name"] self.__initial_backtest = exchange is None @@ -277,6 +278,7 @@ class Backtesting: self.reset_backtest(False) self.wallets = Wallets(self.config, self.exchange, is_backtest=True) + self.starting_balance = self.wallets.get_starting_balance() self.progress = BTProgress() self.abort = False @@ -1271,8 +1273,8 @@ class Backtesting: def run_protections(self, pair: str, current_time: datetime, side: LongShort): if self.enable_protections: - self.protections.stop_per_pair(pair, current_time, side) - self.protections.global_stop(current_time, side) + self.protections.stop_per_pair(pair, current_time, side, self.starting_balance) + self.protections.global_stop(current_time, side, self.starting_balance) def manage_open_orders(self, trade: LocalTrade, current_time: datetime, row: tuple) -> bool: """ diff --git a/freqtrade/plugins/protectionmanager.py b/freqtrade/plugins/protectionmanager.py index 0eda8422f..e90e5eac2 100644 --- a/freqtrade/plugins/protectionmanager.py +++ b/freqtrade/plugins/protectionmanager.py @@ -47,13 +47,17 @@ class ProtectionManager: """ 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: now = datetime.now(UTC) result = None for protection_handler in self._protection_handlers: 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 not PairLocks.is_global_lock(lock.until, side=lock.lock_side): result = PairLocks.lock_pair( @@ -62,14 +66,20 @@ class ProtectionManager: return result 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: if not now: now = datetime.now(UTC) result = None for protection_handler in self._protection_handlers: 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 not PairLocks.is_pair_locked(pair, lock.until, lock.lock_side): result = PairLocks.lock_pair( diff --git a/freqtrade/plugins/protections/cooldown_period.py b/freqtrade/plugins/protections/cooldown_period.py index 0f6aaa79a..a28bb14b4 100644 --- a/freqtrade/plugins/protections/cooldown_period.py +++ b/freqtrade/plugins/protections/cooldown_period.py @@ -52,7 +52,9 @@ class CooldownPeriod(IProtection): 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 This must evaluate to true for the whole period of the "cooldown period". @@ -63,7 +65,7 @@ class CooldownPeriod(IProtection): return None 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: """ Stops trading (position entering) for this pair diff --git a/freqtrade/plugins/protections/iprotection.py b/freqtrade/plugins/protections/iprotection.py index 8f2f51729..0722e8b6e 100644 --- a/freqtrade/plugins/protections/iprotection.py +++ b/freqtrade/plugins/protections/iprotection.py @@ -102,7 +102,9 @@ class IProtection(LoggingMixin, ABC): """ @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 This must evaluate to true for the whole period of the "cooldown period". @@ -110,7 +112,7 @@ class IProtection(LoggingMixin, ABC): @abstractmethod 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: """ Stops trading (position entering) for this pair diff --git a/freqtrade/plugins/protections/low_profit_pairs.py b/freqtrade/plugins/protections/low_profit_pairs.py index 84934f394..31bc728b8 100644 --- a/freqtrade/plugins/protections/low_profit_pairs.py +++ b/freqtrade/plugins/protections/low_profit_pairs.py @@ -81,7 +81,9 @@ class LowProfitPairs(IProtection): 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 This must evaluate to true for the whole period of the "cooldown period". @@ -91,7 +93,7 @@ class LowProfitPairs(IProtection): return None 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: """ Stops trading (position entering) for this pair diff --git a/freqtrade/plugins/protections/max_drawdown_protection.py b/freqtrade/plugins/protections/max_drawdown_protection.py index e8996d3a8..396791d10 100644 --- a/freqtrade/plugins/protections/max_drawdown_protection.py +++ b/freqtrade/plugins/protections/max_drawdown_protection.py @@ -22,6 +22,7 @@ class MaxDrawdown(IProtection): self._trade_limit = protection_config.get("trade_limit", 1) 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 def short_desc(self) -> str: @@ -42,25 +43,53 @@ class MaxDrawdown(IProtection): 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 ... """ 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) < self._trade_limit: - # Not enough trades in the relevant period + if len(trades_in_window) < self._trade_limit: return None - # Drawdown is always positive try: - # TODO: This should use absolute profit calculation, considering account balance. - drawdown_obj = calculate_max_drawdown(trades_df, value_col="close_profit") - drawdown = drawdown_obj.drawdown_abs + 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") + # In ratios mode, drawdown_abs is the cumulative ratio drop + drawdown = drawdown_obj.drawdown_abs except ValueError: return None @@ -71,7 +100,7 @@ class MaxDrawdown(IProtection): logger.info, ) - until = self.calculate_lock_end(trades) + until = self.calculate_lock_end(trades_in_window) return ProtectionReturn( lock=True, @@ -81,17 +110,19 @@ class MaxDrawdown(IProtection): 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 This must evaluate to true for the whole period of the "cooldown period". :return: Tuple of [bool, until, reason]. If true, all pairs will be locked with until """ - return self._max_drawdown(date_now) + return self._max_drawdown(date_now, starting_balance) 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: """ Stops trading (position entering) for this pair diff --git a/freqtrade/plugins/protections/stoploss_guard.py b/freqtrade/plugins/protections/stoploss_guard.py index a429a2f80..abb084177 100644 --- a/freqtrade/plugins/protections/stoploss_guard.py +++ b/freqtrade/plugins/protections/stoploss_guard.py @@ -86,7 +86,9 @@ class StoplossGuard(IProtection): 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 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) 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: """ Stops trading (position entering) for this pair diff --git a/tests/conftest.py b/tests/conftest.py index 1c0435ff3..abd15a6a1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -604,6 +604,7 @@ def get_default_conf(testdatadir): "cancel_open_orders_on_exit": False, "minimal_roi": {"40": 0.0, "30": 0.01, "20": 0.02, "0": 0.04}, "dry_run_wallet": 1000, + "tradable_balance_ratio": 0.99, "stoploss": -0.10, "unfilledtimeout": {"entry": 10, "exit": 30}, "entry_pricing": { diff --git a/tests/plugins/test_protections.py b/tests/plugins/test_protections.py index 164fdbb08..dc654b7b5 100644 --- a/tests/plugins/test_protections.py +++ b/tests/plugins/test_protections.py @@ -1,5 +1,6 @@ import random from datetime import UTC, datetime, timedelta +from types import SimpleNamespace import pytest @@ -99,9 +100,9 @@ def test_protectionmanager(mocker, default_conf): for handler in freqtrade.protections._protection_handlers: assert handler.name in AVAILABLE_PROTECTIONS 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: - 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( @@ -658,7 +659,7 @@ def test_LowProfitPairs(mocker, default_conf, fee, caplog, only_per_side): @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"] = [ { "method": "MaxDrawdown", @@ -670,9 +671,10 @@ def test_MaxDrawdown(mocker, default_conf, fee, caplog): ] freqtrade = get_patched_freqtradebot(mocker, default_conf) message = r"Trading stopped due to Max.*" + starting_balance = 0.05 - assert not freqtrade.protections.global_stop() - assert not freqtrade.protections.stop_per_pair("XRP/BTC") + assert not freqtrade.protections.global_stop(starting_balance=starting_balance) + assert not freqtrade.protections.stop_per_pair("XRP/BTC", starting_balance=starting_balance) caplog.clear() generate_mock_trade( @@ -704,8 +706,8 @@ def test_MaxDrawdown(mocker, default_conf, fee, caplog): ) Trade.commit() # No losing trade yet ... so max_drawdown will raise exception - assert not freqtrade.protections.global_stop() - assert not freqtrade.protections.stop_per_pair("XRP/BTC") + assert not freqtrade.protections.global_stop(starting_balance=starting_balance) + assert not freqtrade.protections.stop_per_pair("XRP/BTC", starting_balance=starting_balance) generate_mock_trade( "XRP/BTC", @@ -717,8 +719,8 @@ def test_MaxDrawdown(mocker, default_conf, fee, caplog): profit_rate=0.9, ) # Not locked with one trade - assert not freqtrade.protections.global_stop() - assert not freqtrade.protections.stop_per_pair("XRP/BTC") + assert not freqtrade.protections.global_stop(starting_balance=starting_balance) + 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_global_lock() @@ -734,8 +736,8 @@ def test_MaxDrawdown(mocker, default_conf, fee, caplog): Trade.commit() # Not locked with 1 trade (2nd trade is outside of lookback_period) - assert not freqtrade.protections.global_stop() - assert not freqtrade.protections.stop_per_pair("XRP/BTC") + assert not freqtrade.protections.global_stop(starting_balance=starting_balance) + 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_global_lock() assert not log_has_re(message, caplog) @@ -751,7 +753,7 @@ def test_MaxDrawdown(mocker, default_conf, fee, caplog): profit_rate=1.5, ) Trade.commit() - assert not freqtrade.protections.global_stop() + assert not freqtrade.protections.global_stop(starting_balance=starting_balance) assert not PairLocks.is_global_lock() caplog.clear() @@ -764,17 +766,215 @@ def test_MaxDrawdown(mocker, default_conf, fee, caplog): exit_reason=ExitType.ROI.value, min_ago_open=20, min_ago_close=10, - profit_rate=0.8, + profit_rate=0.2, ) 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 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 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( "protectionconf,desc_expected,exception_expected", [