mirror of
https://github.com/freqtrade/freqtrade.git
synced 2026-03-01 07:42:40 +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` 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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <reason> until <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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user