# source: https://raw.githubusercontent.com/wangc-joker/ft_userdata/4e4257fd7de6ac8205586b310d51326786101926/user_data/strategies/SystemTrendBreakoutV1.py
from __future__ import annotations

from datetime import datetime
from typing import Any

import numpy as np
import pandas as pd
import talib.abstract as ta
from pandas import DataFrame, Series

from freqtrade.persistence import Trade
from freqtrade.strategy import (
    BooleanParameter,
    DecimalParameter,
    IStrategy,
    IntParameter,
    merge_informative_pair,
    stoploss_from_absolute,
    stoploss_from_open,
    timeframe_to_minutes,
    timeframe_to_prev_date,
)


class Github_wangc_joker_ft_userdata__SystemTrendBreakoutV1__20260421_064037(IStrategy):
    """
    Medium-frequency trend-following system with breakout confirmation and risk-first trade management.

    Design goals:
    - Spot-first, long-only by default.
    - Multi-timeframe market regime filter using 15m / 1h / 4h.
    - Clear vectorized indicators for backtest / hyperopt / lookahead / recursive checks.
    - Minimal heavy work in callbacks to keep live execution efficient.
    """

    INTERFACE_VERSION = 3

    timeframe = "15m"
    informative_timeframe_1h = "1h"
    informative_timeframe_4h = "4h"

    can_short = False
    process_only_new_candles = True
    startup_candle_count = 2400

    use_exit_signal = True
    exit_profit_only = False
    ignore_roi_if_entry_signal = False
    use_custom_stoploss = True

    stoploss = -0.18
    trailing_stop = False

    minimal_roi = {
        "0": 0.10,
        "240": 0.05,
        "720": 0.02,
        "1440": 0.0,
    }

    order_types = {
        "entry": "limit",
        "exit": "limit",
        "emergency_exit": "market",
        "force_entry": "market",
        "force_exit": "market",
        "stoploss": "market",
        "stoploss_on_exchange": False,
        "stoploss_on_exchange_interval": 60,
    }

    order_time_in_force = {
        "entry": "GTC",
        "exit": "GTC",
    }

    max_open_trades = 4

    breakout_lookback = IntParameter(20, 96, default=48, space="buy", optimize=True)
    breakout_buffer = DecimalParameter(0.000, 0.006, default=0.001, decimals=3, space="buy", optimize=True)
    rsi_lower = IntParameter(48, 58, default=52, space="buy", optimize=True)
    rsi_upper = IntParameter(62, 75, default=68, space="buy", optimize=True)
    adx_threshold = IntParameter(18, 28, default=22, space="buy", optimize=True)
    atr_ratio_min = DecimalParameter(0.002, 0.012, default=0.004, decimals=3, space="buy", optimize=True)
    atr_ratio_max = DecimalParameter(0.015, 0.080, default=0.040, decimals=3, space="buy", optimize=True)
    volume_window = IntParameter(12, 48, default=24, space="buy", optimize=False)
    volume_factor = DecimalParameter(1.0, 2.5, default=1.2, decimals=2, space="buy", optimize=True)
    ema_slope_window = IntParameter(6, 48, default=24, space="buy", optimize=True)
    chase_atr_limit = DecimalParameter(0.4, 2.0, default=1.0, decimals=2, space="buy", optimize=True)
    bb_extension_limit = DecimalParameter(0.001, 0.03, default=0.01, decimals=3, space="buy", optimize=True)
    pullback_tolerance = DecimalParameter(0.002, 0.02, default=0.008, decimals=3, space="buy", optimize=True)
    pullback_structure_window = IntParameter(6, 24, default=12, space="buy", optimize=False)
    breakout_memory = IntParameter(4, 24, default=12, space="buy", optimize=False)
    enable_pullback_module = BooleanParameter(default=True, space="buy", optimize=True)

    risk_per_trade = DecimalParameter(0.002, 0.010, default=0.005, decimals=3, space="stake", optimize=False)
    min_position_size_pct = DecimalParameter(0.01, 0.10, default=0.02, decimals=3, space="stake", optimize=False)
    max_position_size_pct = DecimalParameter(0.05, 0.35, default=0.20, decimals=3, space="stake", optimize=False)
    max_pair_exposure_pct = DecimalParameter(0.05, 0.35, default=0.20, decimals=3, space="stake", optimize=False)
    initial_atr_stop_mult = DecimalParameter(1.5, 4.0, default=2.4, decimals=2, space="stoploss", optimize=True)
    breakeven_r_multiple = DecimalParameter(0.8, 1.8, default=1.0, decimals=2, space="stoploss", optimize=True)
    profit_lock_r_multiple = DecimalParameter(1.2, 3.0, default=1.8, decimals=2, space="stoploss", optimize=True)
    atr_trail_mult = DecimalParameter(1.0, 3.5, default=1.8, decimals=2, space="stoploss", optimize=True)
    swing_window = IntParameter(6, 30, default=12, space="stoploss", optimize=False)

    momentum_exit_rsi = IntParameter(38, 55, default=45, space="sell", optimize=True)
    timeout_bars = IntParameter(24, 192, default=96, space="sell", optimize=True)
    timeout_min_profit = DecimalParameter(-0.01, 0.03, default=0.005, decimals=3, space="sell", optimize=True)
    volatility_exit_enabled = BooleanParameter(default=True, space="sell", optimize=True)
    volatility_exit_wick_ratio = DecimalParameter(0.35, 0.75, default=0.50, decimals=2, space="sell", optimize=True)
    volatility_exit_volume_mult = DecimalParameter(1.5, 4.0, default=2.2, decimals=2, space="sell", optimize=True)

    cooldown_candles = IntParameter(2, 16, default=4, space="protection", optimize=True)
    stoploss_guard_lookback = IntParameter(24, 144, default=72, space="protection", optimize=True)
    stoploss_guard_trade_limit = IntParameter(2, 8, default=4, space="protection", optimize=True)
    stoploss_guard_duration = IntParameter(4, 48, default=12, space="protection", optimize=True)
    max_drawdown_lookback = IntParameter(48, 288, default=144, space="protection", optimize=True)
    max_drawdown_trade_limit = IntParameter(6, 30, default=12, space="protection", optimize=True)
    max_drawdown_stop = IntParameter(12, 96, default=24, space="protection", optimize=True)
    max_drawdown_allowed = DecimalParameter(0.08, 0.30, default=0.16, decimals=2, space="protection", optimize=True)
    low_profit_lookback = IntParameter(24, 192, default=72, space="protection", optimize=True)
    low_profit_trade_limit = IntParameter(2, 12, default=4, space="protection", optimize=True)
    low_profit_stop = IntParameter(4, 48, default=12, space="protection", optimize=True)
    low_profit_required = DecimalParameter(-0.02, 0.03, default=0.0, decimals=3, space="protection", optimize=True)

    @property
    def protections(self) -> list[dict[str, Any]]:
        """
        Return strategy-local protections.
        """
        return [
            {
                "method": "CooldownPeriod",
                "stop_duration_candles": int(self.cooldown_candles.value),
            },
            {
                "method": "StoplossGuard",
                "lookback_period_candles": int(self.stoploss_guard_lookback.value),
                "trade_limit": int(self.stoploss_guard_trade_limit.value),
                "stop_duration_candles": int(self.stoploss_guard_duration.value),
                "only_per_pair": False,
            },
            {
                "method": "MaxDrawdown",
                "lookback_period_candles": int(self.max_drawdown_lookback.value),
                "trade_limit": int(self.max_drawdown_trade_limit.value),
                "stop_duration_candles": int(self.max_drawdown_stop.value),
                "max_allowed_drawdown": float(self.max_drawdown_allowed.value),
            },
            {
                "method": "LowProfitPairs",
                "lookback_period_candles": int(self.low_profit_lookback.value),
                "trade_limit": int(self.low_profit_trade_limit.value),
                "stop_duration_candles": int(self.low_profit_stop.value),
                "required_profit": float(self.low_profit_required.value),
            },
        ]

    def informative_pairs(self) -> list[tuple[str, str]]:
        """
        Provide 1h and 4h informative pairs for every pair in the current whitelist.
        """
        if not self.dp:
            return []

        pairs = self.dp.current_whitelist()
        informative: list[tuple[str, str]] = []
        for pair in pairs:
            informative.append((pair, self.informative_timeframe_1h))
            informative.append((pair, self.informative_timeframe_4h))
        return informative

    def _safe_series(self, series: Series, fill_value: float = 0.0) -> Series:
        """
        Normalize NaN / inf values for indicator columns.
        """
        return series.replace([np.inf, -np.inf], np.nan).fillna(fill_value)

    def _add_main_indicators(self, dataframe: DataFrame) -> DataFrame:
        """
        Calculate all 15m indicators used by entry, exit, and risk logic.
        """
        dataframe["ema_20"] = ta.EMA(dataframe, timeperiod=20)
        dataframe["ema_50"] = ta.EMA(dataframe, timeperiod=50)
        dataframe["ema_200"] = ta.EMA(dataframe, timeperiod=200)
        dataframe["rsi"] = ta.RSI(dataframe, timeperiod=14)
        dataframe["adx"] = ta.ADX(dataframe, timeperiod=14)
        dataframe["atr"] = ta.ATR(dataframe, timeperiod=14)
        dataframe["atr_ratio"] = self._safe_series(dataframe["atr"] / dataframe["close"])

        bbands = ta.BBANDS(dataframe, timeperiod=20, nbdevup=2.0, nbdevdn=2.0)
        dataframe["bb_upper"] = bbands["upperband"]
        dataframe["bb_middle"] = bbands["middleband"]
        dataframe["bb_lower"] = bbands["lowerband"]

        volume_window = int(self.volume_window.value)
        dataframe["volume_mean"] = dataframe["volume"].rolling(volume_window, min_periods=volume_window).mean()
        dataframe["volume_ratio"] = self._safe_series(dataframe["volume"] / dataframe["volume_mean"])

        lookback = int(self.breakout_lookback.value)
        dataframe["donchian_high"] = dataframe["high"].shift(1).rolling(lookback, min_periods=lookback).max()
        dataframe["donchian_low"] = dataframe["low"].shift(1).rolling(lookback, min_periods=lookback).min()
        dataframe["donchian_mid"] = (dataframe["donchian_high"] + dataframe["donchian_low"]) / 2.0

        structure_window = int(self.pullback_structure_window.value)
        dataframe["structure_low"] = dataframe["low"].shift(1).rolling(
            structure_window, min_periods=structure_window
        ).min()
        dataframe["swing_low"] = dataframe["low"].shift(1).rolling(
            int(self.swing_window.value), min_periods=int(self.swing_window.value)
        ).min()

        slope_window = int(self.ema_slope_window.value)
        dataframe["ema200_slope_15m"] = dataframe["ema_200"] - dataframe["ema_200"].shift(slope_window)
        dataframe["distance_over_ema20"] = self._safe_series((dataframe["close"] - dataframe["ema_20"]) / dataframe["close"])
        dataframe["distance_to_bb_upper"] = self._safe_series((dataframe["bb_upper"] - dataframe["close"]) / dataframe["close"])

        breakout_buffer = float(self.breakout_buffer.value)
        dataframe["breakout_trigger"] = (
            (dataframe["close"] > dataframe["donchian_high"] * (1.0 + breakout_buffer))
            & (dataframe["close"].shift(1) <= dataframe["donchian_high"].shift(1) * (1.0 + breakout_buffer))
        ).astype(int)
        dataframe["recent_breakout"] = (
            dataframe["breakout_trigger"]
            .shift(1)
            .rolling(int(self.breakout_memory.value), min_periods=1)
            .max()
            .fillna(0)
            .astype(int)
        )

        dataframe["upper_wick_ratio"] = self._safe_series(
            (dataframe["high"] - dataframe[["open", "close"]].max(axis=1))
            / (dataframe["high"] - dataframe["low"]).replace(0, np.nan)
        )
        dataframe["is_green"] = (dataframe["close"] > dataframe["open"]).astype(int)
        dataframe["trend_stack"] = (
            (dataframe["ema_20"] > dataframe["ema_50"]) & (dataframe["ema_50"] > dataframe["ema_200"])
        ).astype(int)

        return dataframe

    def _add_informative_indicators(self, dataframe: DataFrame, pair: str) -> DataFrame:
        """
        Merge and derive indicators from 1h and 4h informative dataframes.
        """
        if not self.dp:
            return dataframe

        informative_1h = self.dp.get_pair_dataframe(pair=pair, timeframe=self.informative_timeframe_1h)
        informative_1h["adx"] = ta.ADX(informative_1h, timeperiod=14)
        informative_1h["ema_50"] = ta.EMA(informative_1h, timeperiod=50)
        informative_1h["ema_200"] = ta.EMA(informative_1h, timeperiod=200)

        informative_4h = self.dp.get_pair_dataframe(pair=pair, timeframe=self.informative_timeframe_4h)
        informative_4h["ema_200"] = ta.EMA(informative_4h, timeperiod=200)
        informative_4h["atr"] = ta.ATR(informative_4h, timeperiod=14)
        informative_4h["atr_ratio"] = self._safe_series(informative_4h["atr"] / informative_4h["close"])
        informative_4h["ema200_slope"] = informative_4h["ema_200"] - informative_4h["ema_200"].shift(
            int(self.ema_slope_window.value)
        )

        dataframe = merge_informative_pair(
            dataframe,
            informative_1h,
            self.timeframe,
            self.informative_timeframe_1h,
            ffill=True,
        )
        dataframe = merge_informative_pair(
            dataframe,
            informative_4h,
            self.timeframe,
            self.informative_timeframe_4h,
            ffill=True,
        )

        return dataframe

    def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
        """
        Build all indicators and debug columns.

        Debug columns are intentionally kept in the dataframe so exported signals
        can be analyzed after backtesting.
        """
        dataframe = self._add_main_indicators(dataframe)
        dataframe = self._add_informative_indicators(dataframe, metadata["pair"])

        slope_window = int(self.ema_slope_window.value)
        dataframe["regime_4h_bull"] = (
            (dataframe["close_4h"] > dataframe["ema_200_4h"])
            & (dataframe["ema_200_4h"] > dataframe["ema_200_4h"].shift(slope_window))
        ).astype(int)
        dataframe["trend_strength_1h"] = (dataframe["adx_1h"] > int(self.adx_threshold.value)).astype(int)
        dataframe["volatility_ok"] = (
            (dataframe["atr_ratio"] >= float(self.atr_ratio_min.value))
            & (dataframe["atr_ratio"] <= float(self.atr_ratio_max.value))
        ).astype(int)
        dataframe["volume_ok"] = (
            dataframe["volume"] > dataframe["volume_mean"] * float(self.volume_factor.value)
        ).astype(int)
        dataframe["market_regime_ok"] = (
            (dataframe["regime_4h_bull"] == 1)
            & (dataframe["trend_strength_1h"] == 1)
            & (dataframe["volatility_ok"] == 1)
            & (dataframe["volume_ok"] == 1)
        ).astype(int)

        return dataframe

    def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
        """
        Create vectorized long entry signals for breakout and pullback modules.
        """
        dataframe["enter_long"] = 0
        dataframe["enter_tag"] = None

        regime_ok = dataframe["market_regime_ok"] == 1
        trend_stack = dataframe["trend_stack"] == 1
        rsi_ok = dataframe["rsi"].between(int(self.rsi_lower.value), int(self.rsi_upper.value))
        not_too_extended = (
            (dataframe["distance_over_ema20"] <= dataframe["atr_ratio"] * float(self.chase_atr_limit.value))
            & (dataframe["distance_to_bb_upper"] >= -float(self.bb_extension_limit.value))
        )

        breakout_entry = (
            regime_ok
            & trend_stack
            & rsi_ok
            & not_too_extended
            & (dataframe["breakout_trigger"] == 1)
        )

        tolerance = float(self.pullback_tolerance.value)
        near_pullback_zone = (
            self._safe_series((dataframe["close"] - dataframe["ema_20"]).abs() / dataframe["close"]) <= tolerance
        ) | (
            self._safe_series((dataframe["close"] - dataframe["donchian_mid"]).abs() / dataframe["close"]) <= tolerance
        )

        pullback_reclaim = (
            (dataframe["close"] > dataframe["ema_20"])
            & (dataframe["close"] > dataframe["open"])
            & (dataframe["volume"] > dataframe["volume_mean"] * float(self.volume_factor.value))
            & (dataframe["close"] > dataframe["close"].shift(1))
        )

        structure_intact = dataframe["low"] > dataframe["structure_low"] * (1.0 - tolerance)

        pullback_entry = (
            regime_ok
            & trend_stack
            & (dataframe["recent_breakout"] == 1)
            & near_pullback_zone
            & structure_intact
            & pullback_reclaim
        )

        if not bool(self.enable_pullback_module.value):
            pullback_entry = pd.Series(False, index=dataframe.index)

        dataframe.loc[breakout_entry, "enter_long"] = 1
        dataframe.loc[breakout_entry, "enter_tag"] = "breakout"

        pullback_only = pullback_entry & ~breakout_entry
        dataframe.loc[pullback_only, "enter_long"] = 1
        dataframe.loc[pullback_only, "enter_tag"] = "pullback"

        return dataframe

    def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
        """
        Keep vectorized exits light and reserve stateful logic for custom_exit.
        """
        dataframe["exit_long"] = 0
        dataframe["exit_tag"] = None

        base_exit = (dataframe["close"] < dataframe["ema_50"]) & (dataframe["rsi"] < int(self.momentum_exit_rsi.value))
        dataframe.loc[base_exit, "exit_long"] = 1
        dataframe.loc[base_exit, "exit_tag"] = "signal_trend_fail"

        return dataframe

    def _get_analyzed_candle(self, pair: str, current_time: datetime) -> Series | None:
        """
        Fetch the latest analyzed candle aligned to the current trade callback time.
        """
        if not self.dp:
            return None

        dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
        if dataframe.empty:
            return None

        candle_time = timeframe_to_prev_date(self.timeframe, current_time)
        candle_df = dataframe.loc[dataframe["date"] == candle_time]
        if candle_df.empty:
            return dataframe.iloc[-1]
        return candle_df.iloc[-1]

    def _get_trade_open_candle(self, pair: str, trade: Trade) -> Series | None:
        """
        Fetch the candle corresponding to the trade open time for stable per-trade references.
        """
        if not self.dp:
            return None

        dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
        if dataframe.empty:
            return None

        trade_date = timeframe_to_prev_date(self.timeframe, trade.open_date_utc)
        candle_df = dataframe.loc[dataframe["date"] == trade_date]
        if candle_df.empty:
            earlier = dataframe.loc[dataframe["date"] <= trade_date]
            if earlier.empty:
                return None
            return earlier.iloc[-1]
        return candle_df.iloc[-1]

    def _stake_balance(self) -> float:
        """
        Return current total stake-currency equity with safe fallbacks.
        """
        stake_currency = self.config.get("stake_currency", "USDT")

        try:
            if self.wallets:
                total = float(self.wallets.get_total(stake_currency))
                if total > 0:
                    return total
        except Exception:
            pass

        dry_run_wallet = self.config.get("dry_run_wallet", 10000)
        if isinstance(dry_run_wallet, dict):
            total = float(dry_run_wallet.get(stake_currency, 10000))
            if total > 0:
                return total
        try:
            return float(dry_run_wallet)
        except Exception:
            return 10000.0

    def custom_stake_amount(
        self,
        pair: str,
        current_time: datetime,
        current_rate: float,
        proposed_stake: float,
        min_stake: float | None,
        max_stake: float,
        leverage: float,
        entry_tag: str | None,
        side: str,
        **kwargs: Any,
    ) -> float:
        """
        Size positions from risk budget and ATR-based initial stop distance.
        """
        if side != "long":
            return 0.0

        candle = self._get_analyzed_candle(pair, current_time)
        if candle is None:
            return proposed_stake

        atr = float(candle.get("atr", 0.0))
        if atr <= 0 or current_rate <= 0:
            return proposed_stake

        equity = self._stake_balance()
        risk_budget = equity * float(self.risk_per_trade.value)
        stop_distance_ratio = (atr * float(self.initial_atr_stop_mult.value)) / current_rate

        if stop_distance_ratio <= 0:
            return proposed_stake

        raw_stake = risk_budget / stop_distance_ratio
        exposure_cap = equity * float(self.max_pair_exposure_pct.value)
        min_cap = equity * float(self.min_position_size_pct.value)
        max_cap = equity * float(self.max_position_size_pct.value)

        stake = min(raw_stake, exposure_cap, max_cap, max_stake)
        stake = max(stake, min_cap)

        if min_stake is not None:
            stake = max(stake, float(min_stake))

        stake = min(stake, max_stake)

        if not np.isfinite(stake) or stake <= 0:
            return proposed_stake

        return float(stake)

    def custom_stoploss(
        self,
        pair: str,
        trade: Trade,
        current_time: datetime,
        current_rate: float,
        current_profit: float,
        after_fill: bool,
        **kwargs: Any,
    ) -> float | None:
        """
        Manage stoploss in layers with ATR and swing-low logic.
        """
        candle = self._get_analyzed_candle(pair, current_time)
        open_candle = self._get_trade_open_candle(pair, trade)
        if candle is None or open_candle is None or current_rate <= 0:
            return None

        entry_atr = float(open_candle.get("atr", 0.0))
        live_atr = float(candle.get("atr", 0.0))
        swing_low = float(candle.get("swing_low", np.nan))
        if entry_atr <= 0:
            return None

        initial_abs_stop = trade.open_rate - entry_atr * float(self.initial_atr_stop_mult.value)
        initial_risk = max(trade.open_rate - initial_abs_stop, trade.open_rate * 0.002)
        one_r_profit = initial_risk / trade.open_rate

        if after_fill:
            return stoploss_from_absolute(
                initial_abs_stop,
                current_rate=current_rate,
                is_short=trade.is_short,
                leverage=trade.leverage,
            )

        if current_profit < float(self.breakeven_r_multiple.value) * one_r_profit:
            return stoploss_from_absolute(
                initial_abs_stop,
                current_rate=current_rate,
                is_short=trade.is_short,
                leverage=trade.leverage,
            )

        breakeven_buffer = max(entry_atr * 0.15, trade.open_rate * 0.001)
        breakeven_stop = trade.open_rate + breakeven_buffer
        desired_stop = breakeven_stop

        if current_profit >= float(self.profit_lock_r_multiple.value) * one_r_profit and live_atr > 0:
            atr_trail_stop = current_rate - live_atr * float(self.atr_trail_mult.value)
            desired_stop = max(desired_stop, atr_trail_stop)

        if np.isfinite(swing_low) and swing_low > 0:
            desired_stop = max(desired_stop, swing_low - live_atr * 0.25)

        desired_stop = min(desired_stop, current_rate * 0.995)

        if desired_stop <= 0:
            return None

        if desired_stop <= trade.open_rate and current_profit > 0:
            return stoploss_from_open(
                0.0,
                current_profit=current_profit,
                is_short=trade.is_short,
                leverage=trade.leverage,
            )

        return stoploss_from_absolute(
            desired_stop,
            current_rate=current_rate,
            is_short=trade.is_short,
            leverage=trade.leverage,
        )

    def custom_exit(
        self,
        pair: str,
        trade: Trade,
        current_time: datetime,
        current_rate: float,
        current_profit: float,
        **kwargs: Any,
    ) -> str | None:
        """
        Use state-aware exits for momentum decay, time stop, and optional volatility climax.
        """
        candle = self._get_analyzed_candle(pair, current_time)
        if candle is None:
            return None

        timeframe_minutes = timeframe_to_minutes(self.timeframe)
        bars_held = max(int((current_time - trade.open_date_utc).total_seconds() // 60 // timeframe_minutes), 0)

        rsi = float(candle.get("rsi", np.nan))
        close_price = float(candle.get("close", current_rate))
        ema20 = float(candle.get("ema_20", np.nan))
        volume_ratio = float(candle.get("volume_ratio", 0.0))
        upper_wick_ratio = float(candle.get("upper_wick_ratio", 0.0))

        if np.isfinite(rsi) and np.isfinite(ema20):
            if rsi < int(self.momentum_exit_rsi.value) and close_price < ema20 and current_profit > -0.01:
                return "momentum_exit"

        if bars_held >= int(self.timeout_bars.value) and current_profit < float(self.timeout_min_profit.value):
            return "timeout_exit"

        if bool(self.volatility_exit_enabled.value):
            if (
                current_profit > 0.01
                and upper_wick_ratio >= float(self.volatility_exit_wick_ratio.value)
                and volume_ratio >= float(self.volatility_exit_volume_mult.value)
                and close_price < float(candle.get("high", current_rate))
            ):
                return "volatility_exit"

        regime_4h_bull = int(candle.get("regime_4h_bull", 1))
        if regime_4h_bull == 0 and current_profit > 0:
            return "regime_exit"

        return None
