# source: https://raw.githubusercontent.com/tamilsanmia/ProfitPath/402fac763b204322d20e2b56dc5bb13ab0e2597d/backend/app/strategy_templates/ProfitPath.py
import logging
import warnings
from datetime import datetime

warnings.filterwarnings('ignore')

logger = logging.getLogger(__name__)
import talib.abstract as ta

import numpy as np
from pandas import DataFrame
from typing import Optional, Dict, Any
from freqtrade.persistence import Trade
from freqtrade.strategy import (
    IStrategy,
    informative,  # @informative decorator
    # Hyperopt Parameters
    BooleanParameter,
    DecimalParameter,
    IntParameter,
    stoploss_from_open,
)
from scipy.signal import argrelextrema
import pandas as pd

warnings.simplefilter(action="ignore", category=pd.errors.PerformanceWarning)

class Github_tamilsanmia_ProfitPath__ProfitPath__20260319_063616(IStrategy):
    # Core trade behavior
    exit_profit_only = True
    trailing_stop = False
    position_adjustment_enable = True
    ignore_roi_if_entry_signal = True
    max_entry_position_adjustment = 1
    max_dca_multiplier = 1

    # DCA re-entry window (profit ratio): allow DCA only between these bounds
    dca_reentry_min_profit = -0.05
    dca_reentry_max_drawdown = -0.3

    # 24h change filter bounds per entry timeframe
    chg_5m_min = -10.0
    chg_5m_max = 10.0
    chg_15m_min = -10.0
    chg_15m_max = 10.0
    chg_30m_min = -10.0
    chg_30m_max = 10.0
    chg_1h_min = -10.0
    chg_1h_max = 10.0
    chg_4h_min = -10.0
    chg_4h_max = 10.0

    # 24h change filter bounds per timeframe for DCA orders (separate from entry bounds)
    dca_chg_5m_min = -5.0
    dca_chg_5m_max = 5.0
    dca_chg_15m_min = -10.0
    dca_chg_15m_max = 10.0
    dca_chg_30m_min = -10.0
    dca_chg_30m_max = 10.0
    dca_chg_1h_min = -10.0
    dca_chg_1h_max = 10.0
    dca_chg_4h_min = -10.0
    dca_chg_4h_max = 10.0

    # Extra distance beyond min/max to force custom exit (example: -5 with buffer 2 => exit at -7)
    chg_5m_exit_buffer = 2.0
    chg_15m_exit_buffer = 2.0
    chg_30m_exit_buffer = 2.0
    chg_1h_exit_buffer = 2.0
    chg_4h_exit_buffer = 2.0

    # Enable/disable change filter globally and per timeframe
    use_chg_filter = True
    chg_5m_enabled = True
    chg_15m_enabled = True
    chg_30m_enabled = True
    chg_1h_enabled = True
    chg_4h_enabled = True

    # Telegram alerts when 24h change crosses configured thresholds
    telegram_chg_alert_enabled = True
    telegram_chg_min = -10.0
    telegram_chg_max = 10.0
    telegram_chg_alert_state: Dict[str, Dict[str, Any]] = {}

    # Strategy/runtime settings
    process_only_new_candles = True
    can_short = True
    use_exit_signal = True
    startup_candle_count: int = 200
    stoploss = -0.5
    dca_stoploss = -0.2
    leverage_value = 5

    # Stake allocation ratios (portion of proposed stake)
    initial_entry_stake_ratio = 0.5
    dca_entry_stake_ratio = 0.5

    # Enable/disable entries per timeframe
    entry_5m_enabled = True
    entry_15m_enabled = False
    entry_30m_enabled = False
    entry_1h_enabled = False
    entry_4h_enabled = False

    # Base strategy timeframes
    use_custom_stoploss = True
    timeframe = "5m"
    informative_timeframe = "4h"

    # DCA 
    initial_safety_order_trigger = DecimalParameter(
        low=-0.02, high=-0.01, default=-0.018, decimals=3, space="entry", optimize=True, load=True
    )
    max_safety_orders = IntParameter(1, 6, default=3, space="entry", optimize=True)
    safety_order_step_scale = DecimalParameter(
        low=1.05, high=1.5, default=1.25, decimals=2, space="entry", optimize=True, load=True
    )
    safety_order_volume_scale = DecimalParameter(
        low=1.1, high=2, default=1.4, decimals=1, space="entry", optimize=True, load=True
    )

    # Custom Functions
    increment = DecimalParameter(
        low=1.0005, high=1.002, default=1.001, decimals=4, space="entry", optimize=True, load=True
    )
    last_entry_price = None
    

    # Protections
    cooldown_lookback = IntParameter(2, 48, default=1, space="protection", optimize=True)
    stop_duration = IntParameter(12, 200, default=4, space="protection", optimize=True)
    use_stop_protection = BooleanParameter(default=True, space="protection", optimize=True)

    minimal_roi = {
        "0": 0.5,
        "60": 0.45,
        "120": 0.4,
        "240": 0.3,
        "360": 0.25,
        "720": 0.2,
        "1440": 0.15,
        "2880": 0.1,
        "3600": 0.05,
        "7200": 0.02,
    }

    plot_config = {
        "main_plot": {
        },
        "subplots": {
            "RSI": {
                "rsi": {"color": "#1f77b4", "type": "line"},
                "rsi_15m": {"color": "#9467bd", "type": "line", "secondary_y": False},
                "rsi_30m": {"color": "#ff520e", "type": "line", "secondary_y": False},
                "rsi_1h": {"color": "#ff7f0e", "type": "line", "secondary_y": False},
                "rsi_4h": {"color": "#2ca02c", "type": "line", "secondary_y": False},
            },
            "24H CHG %": {
                "chg_pct": {"color": "#d62728", "type": "line"},
            },
        },
    }

    @property
    def protections(self):
        prot = []
        prot.append(
            {"method": "CooldownPeriod", "stop_duration_candles": self.cooldown_lookback.value}
        )
        if self.use_stop_protection.value:
            prot.append(
                {
                    "method": "StoplossGuard",
                    "lookback_period_candles": 24 * 3,
                    "trade_limit": 2,
                    "stop_duration_candles": self.stop_duration.value,
                    "only_per_pair": False,
                }
            )
        return prot

    def custom_stake_amount(
            self,
            pair: str,
            current_time: datetime,
            current_rate: float,
            proposed_stake: float,
            min_stake: Optional[float],
            max_stake: float,
            leverage: float,
            entry_tag: Optional[str],
            side: str,
            **kwargs,
    ) -> float:
        # Split proposed stake using configurable ratios for initial entry vs DCA.
        trade = kwargs.get('trade')
        if trade is not None and hasattr(trade, 'nr_of_successful_entries'):
            if trade.nr_of_successful_entries == 0:
                return proposed_stake * self.initial_entry_stake_ratio
            else:
                return proposed_stake * self.dca_entry_stake_ratio
        else:
            return proposed_stake * self.initial_entry_stake_ratio

    def custom_entry_price(
            self,
            pair: str,
            trade: Optional["Trade"],
            current_time: datetime,
            proposed_rate: float,
            entry_tag: Optional[str],
            side: str,
            **kwargs,
    ) -> float:
        dataframe, _ = self.dp.get_analyzed_dataframe(
            pair=pair, timeframe=self.timeframe
        )
        entry_price = (dataframe["close"].iloc[-1] + dataframe["open"].iloc[-1] + proposed_rate) / 3
        if proposed_rate < entry_price:
            entry_price = proposed_rate

        logger.info(
            f"{pair} Using Entry Price: {entry_price} | close: {dataframe['close'].iloc[-1]} open: {dataframe['open'].iloc[-1]} proposed_rate: {proposed_rate}"
        )

        if self.last_entry_price is not None and abs(entry_price - self.last_entry_price) < 0.0005:
            entry_price *= self.increment.value
            logger.info(
                f"{pair} Incremented entry price: {entry_price} based on previous entry price : {self.last_entry_price}."
            )

        self.last_entry_price = entry_price

        return entry_price

    def confirm_trade_exit(
            self,
            pair: str,
            trade: Trade,
            order_type: str,
            amount: float,
            rate: float,
            time_in_force: str,
            exit_reason: str,
            current_time: datetime,
            **kwargs,
    ) -> bool:
        if exit_reason == "partial_exit" and trade.calc_profit_ratio(rate) < 0:
            logger.info(f"{trade.pair} partial exit is below 0")
            self.dp.send_msg(f"{trade.pair} partial exit is below 0")
            return False
        if exit_reason == "trailing_stop_loss" and trade.calc_profit_ratio(rate) < 0:
            logger.info(f"{trade.pair} trailing stop price is below 0")
            self.dp.send_msg(f"{trade.pair} trailing stop price is below 0")
            return False
        return True

    def adjust_trade_position(
            self,
            trade: Trade,
            current_time: datetime,
            current_rate: float,
            current_profit: float,
            min_stake: Optional[float],
            max_stake: float,
            current_entry_rate: float,
            current_exit_rate: float,
            current_entry_profit: float,
            current_exit_profit: float,
            **kwargs,
    ) -> Optional[float]:
        dataframe, _ = self.dp.get_analyzed_dataframe(trade.pair, self.timeframe)
        filled_entries = trade.select_filled_orders(trade.entry_side)
        count_of_entries = trade.nr_of_successful_entries

        # Partial take-profit: scale out in two steps at +25% and +40%.
        if current_profit > 0.25 and trade.nr_of_successful_exits == 0:
            return -(trade.stake_amount / 4)
        if current_profit > 0.40 and trade.nr_of_successful_exits == 1:
            return -(trade.stake_amount / 3)

        # Allow exactly one re-entry: only when the trade has one filled entry.
        if count_of_entries != 1:
            return None

        # Re-entry is allowed only between configured min/max DCA profit bounds.
        if (
            current_profit > self.dca_reentry_min_profit
            or current_profit < self.dca_reentry_max_drawdown
        ):
            return None

        if not filled_entries or dataframe.empty:
            return None

        last_candle = dataframe.iloc[-1]
        shifted_candle = dataframe.iloc[-6] if len(dataframe) >= 6 else None
        is_short_trade = bool(getattr(trade, "is_short", False))
        entry_tag = (getattr(trade, "enter_tag", "") or "").strip()

        def chg_ok(column_name: str, min_value: float, max_value: float, enabled: bool = True) -> bool:
            if not self.use_chg_filter or not enabled:
                return True
            value = last_candle.get(column_name, 0)
            return min_value <= value <= max_value

        if is_short_trade:
            signal_by_tag = {
                "5M - Short": self.entry_5m_enabled and (
                    last_candle.get("maxima_check", 1) == 0
                    and last_candle.get("volume", 0) > 0
                    and last_candle.get("rsi", 0) > 70
                    and chg_ok("chg_pct", self.dca_chg_5m_min, self.dca_chg_5m_max, self.chg_5m_enabled)
                ),
                "5M - Shift Short": self.entry_5m_enabled and (
                    last_candle.get("DI_catch", 0) == 1
                    and last_candle.get("maxima_check", 1) == 0
                    and shifted_candle is not None
                    and shifted_candle.get("maxima_check", 0) == 1
                    and last_candle.get("volume", 0) > 0
                    and last_candle.get("rsi", 0) > 70
                    and chg_ok("chg_pct", self.dca_chg_5m_min, self.dca_chg_5m_max, self.chg_5m_enabled)
                ),
                "15M - Short": self.entry_15m_enabled and (
                    last_candle.get("maxima_check_15m", 1) == 0
                    and last_candle.get("volume_15m", 0) > 0
                    and last_candle.get("rsi_15m", 0) > 70
                    and chg_ok("chg_pct", self.dca_chg_15m_min, self.dca_chg_15m_max, self.chg_15m_enabled)
                    and last_candle.get("maxima_check", 0) == 1
                    and last_candle.get("volume", 0) > 0
                    and last_candle.get("rsi", 0) > 70
                ),
                "15M - Shift Short": self.entry_15m_enabled and (
                    last_candle.get("DI_catch_15m", 0) == 1
                    and last_candle.get("maxima_check_15m", 1) == 0
                    and shifted_candle is not None
                    and shifted_candle.get("maxima_check", 0) == 1
                    and last_candle.get("volume_15m", 0) > 0
                    and last_candle.get("rsi_15m", 0) > 70
                    and chg_ok("chg_pct", self.dca_chg_15m_min, self.dca_chg_15m_max, self.chg_15m_enabled)
                    and last_candle.get("maxima_check", 1) == 0
                    and last_candle.get("volume", 0) > 0
                    and last_candle.get("rsi", 0) > 70
                ),
                "30M - Short": self.entry_30m_enabled and (
                    last_candle.get("maxima_check_30m", 1) == 0
                    and last_candle.get("volume_30m", 0) > 0
                    and last_candle.get("rsi_30m", 0) > 70
                    and chg_ok("chg_pct", self.dca_chg_30m_min, self.dca_chg_30m_max, self.chg_30m_enabled)
                    and last_candle.get("maxima_check", 0) == 1
                    and last_candle.get("volume", 0) > 0
                    and last_candle.get("rsi", 0) > 70
                ),
                "30M - Shift Short": self.entry_30m_enabled and (
                    last_candle.get("DI_catch_30m", 0) == 1
                    and last_candle.get("maxima_check_30m", 1) == 0
                    and shifted_candle is not None
                    and shifted_candle.get("maxima_check", 0) == 1
                    and last_candle.get("volume_30m", 0) > 0
                    and last_candle.get("rsi_30m", 0) > 70
                    and chg_ok("chg_pct", self.dca_chg_30m_min, self.dca_chg_30m_max, self.chg_30m_enabled)
                    and last_candle.get("maxima_check", 1) == 0
                    and last_candle.get("volume", 0) > 0
                    and last_candle.get("rsi", 0) > 70
                ),
                "1H - Short": self.entry_1h_enabled and (
                    last_candle.get("maxima_check_1h", 1) == 0
                    and last_candle.get("volume_1h", 0) > 0
                    and last_candle.get("rsi_1h", 0) > 70
                    and chg_ok("chg_pct", self.dca_chg_1h_min, self.dca_chg_1h_max, self.chg_1h_enabled)
                    and last_candle.get("maxima_check", 0) == 1
                    and last_candle.get("volume", 0) > 0
                    and last_candle.get("rsi", 0) > 70
                ),
                "1H - Shift Short": self.entry_1h_enabled and (
                    last_candle.get("DI_catch_1h", 0) == 1
                    and last_candle.get("maxima_check_1h", 1) == 0
                    and shifted_candle is not None
                    and shifted_candle.get("maxima_check", 0) == 1
                    and last_candle.get("volume_1h", 0) > 0
                    and last_candle.get("rsi_1h", 0) > 70
                    and chg_ok("chg_pct", self.dca_chg_1h_min, self.dca_chg_1h_max, self.chg_1h_enabled)
                    and last_candle.get("maxima_check", 1) == 0
                    and last_candle.get("volume", 0) > 0
                    and last_candle.get("rsi", 0) > 70
                ),
                "4H - Short": self.entry_4h_enabled and (
                    last_candle.get("maxima_check_4h", 1) == 0
                    and last_candle.get("volume_4h", 0) > 0
                    and last_candle.get("rsi_4h", 0) > 70
                    and chg_ok("chg_pct", self.dca_chg_4h_min, self.dca_chg_4h_max, self.chg_4h_enabled)
                    and last_candle.get("maxima_check", 0) == 1
                    and last_candle.get("volume", 0) > 0
                    and last_candle.get("rsi", 0) > 70
                ),
                "4H - Shift Short": self.entry_4h_enabled and (
                    last_candle.get("DI_catch_4h", 0) == 1
                    and last_candle.get("maxima_check_4h", 1) == 0
                    and shifted_candle is not None
                    and shifted_candle.get("maxima_check", 0) == 1
                    and last_candle.get("volume_4h", 0) > 0
                    and last_candle.get("rsi_4h", 0) > 70
                    and chg_ok("chg_pct", self.dca_chg_4h_min, self.dca_chg_4h_max, self.chg_4h_enabled)
                    and last_candle.get("maxima_check", 1) == 0
                    and last_candle.get("volume", 0) > 0
                    and last_candle.get("rsi", 0) > 70
                ),
            }
        else:
            signal_by_tag = {
                "5M - Long": self.entry_5m_enabled and (
                    last_candle.get("minima_check", 1) == 0
                    and last_candle.get("volume", 0) > 0
                    and last_candle.get("rsi", 100) < 30
                    and chg_ok("chg_pct", self.dca_chg_5m_min, self.dca_chg_5m_max, self.chg_5m_enabled)
                ),
                "5M - Shift Long": self.entry_5m_enabled and (
                    last_candle.get("DI_catch", 0) == 1
                    and last_candle.get("minima_check", 1) == 0
                    and shifted_candle is not None
                    and shifted_candle.get("minima_check", 0) == 1
                    and last_candle.get("volume", 0) > 0
                    and last_candle.get("rsi", 100) < 30
                    and chg_ok("chg_pct", self.dca_chg_5m_min, self.dca_chg_5m_max, self.chg_5m_enabled)
                ),
                "15M - Long": self.entry_15m_enabled and (
                    last_candle.get("minima_check_15m", 1) == 0
                    and last_candle.get("volume_15m", 0) > 0
                    and last_candle.get("rsi_15m", 100) < 30
                    and chg_ok("chg_pct", self.dca_chg_15m_min, self.dca_chg_15m_max, self.chg_15m_enabled)
                    and last_candle.get("minima_check", 1) == 0
                    and last_candle.get("volume", 0) > 0
                    and last_candle.get("rsi", 100) < 30
                ),
                "15M - Shift Long": self.entry_15m_enabled and (
                    last_candle.get("DI_catch_15m", 0) == 1
                    and last_candle.get("minima_check_15m", 1) == 0
                    and shifted_candle is not None
                    and shifted_candle.get("minima_check", 0) == 1
                    and last_candle.get("volume_15m", 0) > 0
                    and last_candle.get("rsi_15m", 100) < 30
                    and chg_ok("chg_pct", self.dca_chg_15m_min, self.dca_chg_15m_max, self.chg_15m_enabled)
                    and last_candle.get("minima_check", 1) == 0
                    and last_candle.get("volume", 0) > 0
                    and last_candle.get("rsi", 100) < 30
                ),
                "30M - Long": self.entry_30m_enabled and (
                    last_candle.get("minima_check_30m", 1) == 0
                    and last_candle.get("volume_30m", 0) > 0
                    and last_candle.get("rsi_30m", 100) < 30
                    and chg_ok("chg_pct", self.dca_chg_30m_min, self.dca_chg_30m_max, self.chg_30m_enabled)
                    and last_candle.get("minima_check", 1) == 0
                    and last_candle.get("volume", 0) > 0
                    and last_candle.get("rsi", 100) < 30
                ),
                "30M - Shift Long": self.entry_30m_enabled and (
                    last_candle.get("DI_catch_30m", 0) == 1
                    and last_candle.get("minima_check_30m", 1) == 0
                    and shifted_candle is not None
                    and shifted_candle.get("minima_check", 0) == 1
                    and last_candle.get("volume_30m", 0) > 0
                    and last_candle.get("rsi_30m", 100) < 30
                    and chg_ok("chg_pct", self.dca_chg_30m_min, self.dca_chg_30m_max, self.chg_30m_enabled)
                    and last_candle.get("minima_check", 1) == 0
                    and last_candle.get("volume", 0) > 0
                    and last_candle.get("rsi", 100) < 30
                ),
                "1H - Long": self.entry_1h_enabled and (
                    last_candle.get("minima_check_1h", 1) == 0
                    and last_candle.get("volume_1h", 0) > 0
                    and last_candle.get("rsi_1h", 100) < 30
                    and chg_ok("chg_pct", self.dca_chg_1h_min, self.dca_chg_1h_max, self.chg_1h_enabled)
                    and last_candle.get("minima_check", 1) == 0
                    and last_candle.get("volume", 0) > 0
                    and last_candle.get("rsi", 100) < 30
                ),
                "1H - Shift Long": self.entry_1h_enabled and (
                    last_candle.get("DI_catch_1h", 0) == 1
                    and last_candle.get("minima_check_1h", 1) == 0
                    and shifted_candle is not None
                    and shifted_candle.get("minima_check", 0) == 1
                    and last_candle.get("volume_1h", 0) > 0
                    and last_candle.get("rsi_1h", 100) < 30
                    and chg_ok("chg_pct", self.dca_chg_1h_min, self.dca_chg_1h_max, self.chg_1h_enabled)
                    and last_candle.get("minima_check", 1) == 0
                    and last_candle.get("volume", 0) > 0
                    and last_candle.get("rsi", 100) < 30
                ),
                "4H - Long": self.entry_4h_enabled and (
                    last_candle.get("minima_check_4h", 1) == 0
                    and last_candle.get("volume_4h", 0) > 0
                    and last_candle.get("rsi_4h", 100) < 30
                    and chg_ok("chg_pct", self.dca_chg_4h_min, self.dca_chg_4h_max, self.chg_4h_enabled)
                    and last_candle.get("minima_check", 1) == 0
                    and last_candle.get("volume", 0) > 0
                    and last_candle.get("rsi", 100) < 30
                ),
                "4H - Shift Long": self.entry_4h_enabled and (
                    last_candle.get("DI_catch_4h", 0) == 1
                    and last_candle.get("minima_check_4h", 1) == 0
                    and shifted_candle is not None
                    and shifted_candle.get("minima_check", 0) == 1
                    and last_candle.get("volume_4h", 0) > 0
                    and last_candle.get("rsi_4h", 100) < 30
                    and chg_ok("chg_pct", self.dca_chg_4h_min, self.dca_chg_4h_max, self.chg_4h_enabled)
                    and last_candle.get("minima_check", 1) == 0
                    and last_candle.get("volume", 0) > 0
                    and last_candle.get("rsi", 100) < 30
                ),
            }

        matching_signal_tags = [tag for tag, is_active in signal_by_tag.items() if is_active]
        if not matching_signal_tags:
            return None

        # DCA is allowed only when an active signal is from the same timeframe as the original entry.
        if entry_tag:
            entry_timeframe = entry_tag.split(" - ", 1)[0]
            active_timeframes = {tag.split(" - ", 1)[0] for tag in matching_signal_tags}
            if entry_timeframe not in active_timeframes:
                return None

        try:
            stake_amount = filled_entries[0].cost
            dca_scale = float(self.safety_order_volume_scale.value) ** (count_of_entries - 1)
            stake_amount = stake_amount * dca_scale

            if min_stake is not None and stake_amount < min_stake:
                return None

            return min(stake_amount, max_stake)
        except Exception as exception:
            logger.debug("DCA position adjust failed for %s: %s", trade.pair, exception)
            return None
        return None

    def leverage(
            self,
            pair: str,
            current_time: "datetime",
            current_rate: float,
            proposed_leverage: float,
            max_leverage: float,
            side: str,
            **kwargs,
    ) -> float:
        # Leverage is configurable and capped by exchange/pair max leverage.
        return max(min(self.leverage_value, max_leverage), 1.0)

    def custom_stoploss(
            self,
            pair: str,
            trade: Trade,
            current_time: datetime,
            current_rate: float,
            current_profit: float,
            **kwargs,
    ) -> float:
        if trade.nr_of_successful_entries > 1:
            return stoploss_from_open(self.dca_stoploss, current_profit)
        return self.stoploss

    def custom_exit(
            self,
            pair: str,
            trade: Trade,
            current_time: datetime,
            current_rate: float,
            current_profit: float,
            **kwargs,
    ) -> Optional[str]:
        dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
        if dataframe.empty:
            return None

        chg_value = dataframe.iloc[-1].get("chg_pct", np.nan)
        if pd.isna(chg_value):
            return None

        timeframe = (getattr(trade, "enter_tag", "") or "").split(" - ", 1)[0]
        bounds_by_tf = {
            "5M": (self.chg_5m_min, self.chg_5m_max),
            "15M": (self.chg_15m_min, self.chg_15m_max),
            "30M": (self.chg_30m_min, self.chg_30m_max),
            "1H": (self.chg_1h_min, self.chg_1h_max),
            "4H": (self.chg_4h_min, self.chg_4h_max),
        }
        buffers_by_tf = {
            "5M": self.chg_5m_exit_buffer,
            "15M": self.chg_15m_exit_buffer,
            "30M": self.chg_30m_exit_buffer,
            "1H": self.chg_1h_exit_buffer,
            "4H": self.chg_4h_exit_buffer,
        }
        bounds = bounds_by_tf.get(timeframe)
        if not bounds:
            return None
        exit_buffer = buffers_by_tf.get(timeframe, 2.0)

        chg_min, chg_max = bounds
        lower_exit = chg_min - exit_buffer
        upper_exit = chg_max + exit_buffer
        chg_value = float(chg_value)
        is_short_trade = bool(getattr(trade, "is_short", False))

        # Long: use the downside breach (min - buffer). Short: use the upside breach (max + buffer).
        if not is_short_trade and chg_value <= lower_exit:
            return f"chg_below_{timeframe.lower()}_{lower_exit:.1f}"
        if is_short_trade and chg_value >= upper_exit:
            return f"chg_above_{timeframe.lower()}_{upper_exit:.1f}"

        return None

    def informative_pairs(self):
        pairs = self.dp.current_whitelist()
        informative_pairs = [(pair, '15m') for pair in pairs]
        informative_pairs += [(pair, '30m') for pair in pairs]
        informative_pairs += [(pair, '1h') for pair in pairs]
        informative_pairs += [(pair, self.informative_timeframe) for pair in pairs]
        return informative_pairs

    @informative('15m')
    def populate_indicators_15m(self, dataframe: DataFrame, metadata: dict) -> DataFrame:

        # Basic Indicators
        dataframe["rsi"] = ta.RSI(dataframe)

        # DI logic for "DI_catch"
        di_values = ta.PLUS_DI(dataframe) - ta.MINUS_DI(dataframe)
        dataframe["DI_catch"] = np.where(di_values > 0, 0, 1)

        # Local Extrema (Order 5)
        maxima = np.zeros(len(dataframe))
        minima = np.zeros(len(dataframe))

        maxima[argrelextrema(dataframe["close"].values, np.greater, order=5)] = 1
        minima[argrelextrema(dataframe["close"].values, np.less, order=5)] = 1

        dataframe["maxima"] = maxima
        dataframe["minima"] = minima

        # Check if a minima/maxima occurred within the last 4 periods
        # Note: Logic remains '0' if a peak exists, '1' if no peak exists per your original code
        dataframe["maxima_check"] = (
            dataframe["maxima"].rolling(4).apply(lambda x: int((x != 1).all()), raw=True).fillna(0)
        )
        dataframe["minima_check"] = (
            dataframe["minima"].rolling(4).apply(lambda x: int((x != 1).all()), raw=True).fillna(0)
        )

        return dataframe

    @informative('30m')
    def populate_indicators_30m(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
        
        # Basic Indicators  
        dataframe["rsi"] = ta.RSI(dataframe)

        # DI logic for "DI_catch"
        di_values = ta.PLUS_DI(dataframe) - ta.MINUS_DI(dataframe)
        dataframe["DI_catch"] = np.where(di_values > 0, 0, 1)

        # Local Extrema (Order 5)
        maxima = np.zeros(len(dataframe))
        minima = np.zeros(len(dataframe))

        maxima[argrelextrema(dataframe["close"].values, np.greater, order=5)] = 1
        minima[argrelextrema(dataframe["close"].values, np.less, order=5)] = 1

        dataframe["maxima"] = maxima
        dataframe["minima"] = minima

        # Check if a minima/maxima occurred within the last 4 periods
        # Note: Logic remains '0' if a peak exists, '1' if no peak exists per your original code
        dataframe["maxima_check"] = (
            dataframe["maxima"].rolling(4).apply(lambda x: int((x != 1).all()), raw=True).fillna(0)
        )
        dataframe["minima_check"] = (
            dataframe["minima"].rolling(4).apply(lambda x: int((x != 1).all()), raw=True).fillna(0)
        )

        return dataframe
    
    @informative('1h')
    def populate_indicators_1h(self, dataframe: DataFrame, metadata: dict) -> DataFrame:

        # Basic Indicators  
        dataframe["rsi"] = ta.RSI(dataframe)

        # DI logic for "DI_catch"
        di_values = ta.PLUS_DI(dataframe) - ta.MINUS_DI(dataframe)
        dataframe["DI_catch"] = np.where(di_values > 0, 0, 1)

        # Local Extrema (Order 5)
        maxima = np.zeros(len(dataframe))
        minima = np.zeros(len(dataframe))

        maxima[argrelextrema(dataframe["close"].values, np.greater, order=5)] = 1
        minima[argrelextrema(dataframe["close"].values, np.less, order=5)] = 1

        dataframe["maxima"] = maxima
        dataframe["minima"] = minima

        # Check if a minima/maxima occurred within the last 4 periods
        # Note: Logic remains '0' if a peak exists, '1' if no peak exists per your original code
        dataframe["maxima_check"] = (
            dataframe["maxima"].rolling(4).apply(lambda x: int((x != 1).all()), raw=True).fillna(0)
        )
        dataframe["minima_check"] = (
            dataframe["minima"].rolling(4).apply(lambda x: int((x != 1).all()), raw=True).fillna(0)
        )

        return dataframe

    @informative('4h')
    def populate_indicators_4h(self, dataframe: DataFrame, metadata: dict) -> DataFrame:

        # Basic Indicators  
        dataframe["rsi"] = ta.RSI(dataframe)

        # DI logic for "DI_catch"
        di_values = ta.PLUS_DI(dataframe) - ta.MINUS_DI(dataframe)
        dataframe["DI_catch"] = np.where(di_values > 0, 0, 1)

        # Local Extrema (Order 5)
        maxima = np.zeros(len(dataframe))
        minima = np.zeros(len(dataframe))

        maxima[argrelextrema(dataframe["close"].values, np.greater, order=5)] = 1
        minima[argrelextrema(dataframe["close"].values, np.less, order=5)] = 1

        dataframe["maxima"] = maxima
        dataframe["minima"] = minima

        # Check if a minima/maxima occurred within the last 4 periods
        # Note: Logic remains '0' if a peak exists, '1' if no peak exists per your original code
        dataframe["maxima_check"] = (
            dataframe["maxima"].rolling(4).apply(lambda x: int((x != 1).all()), raw=True).fillna(0)
        )
        dataframe["minima_check"] = (
            dataframe["minima"].rolling(4).apply(lambda x: int((x != 1).all()), raw=True).fillna(0)
        )

        return dataframe

    def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:

        # Basic Indicators  
        dataframe["rsi"] = ta.RSI(dataframe)
        # 24h change on base timeframe candles (5m -> 288 candles per day).
        dataframe["chg_pct"] = np.where(
            dataframe["close"].shift(288) > 0,
            ((dataframe["close"] - dataframe["close"].shift(288)) / dataframe["close"].shift(288)) * 100,
            np.nan,
        )

        # DI logic for "DI_catch"
        di_values = ta.PLUS_DI(dataframe) - ta.MINUS_DI(dataframe)
        dataframe["DI_catch"] = np.where(di_values > 0, 0, 1)

        # Local Extrema (Order 5)
        maxima = np.zeros(len(dataframe))
        minima = np.zeros(len(dataframe))

        maxima[argrelextrema(dataframe["close"].values, np.greater, order=5)] = 1
        minima[argrelextrema(dataframe["close"].values, np.less, order=5)] = 1

        dataframe["maxima"] = maxima
        dataframe["minima"] = minima

        # Check if a minima/maxima occurred within the last 4 periods
        # Note: Logic remains '0' if a peak exists, '1' if no peak exists per your original code
        dataframe["maxima_check"] = (
            dataframe["maxima"].rolling(4).apply(lambda x: int((x != 1).all()), raw=True).fillna(0)
        )
        dataframe["minima_check"] = (
            dataframe["minima"].rolling(4).apply(lambda x: int((x != 1).all()), raw=True).fillna(0)
        )
                
        return dataframe

    def _notify_chg_in_range(self, df: DataFrame, metadata: dict) -> None:
        if not self.telegram_chg_alert_enabled or df.empty:
            return
        if not getattr(self, "dp", None):
            return

        pair = metadata.get("pair", "")
        if not pair:
            return

        last_candle = df.iloc[-1]
        chg_value = last_candle.get("chg_pct", np.nan)
        if pd.isna(chg_value):
            return

        chg_value = float(chg_value)
        if chg_value <= self.telegram_chg_min:
            zone = "below"
        elif chg_value >= self.telegram_chg_max:
            zone = "above"
        else:
            zone = "within"

        state = self.telegram_chg_alert_state.get(pair, {"zone": "within"})
        prev_zone = state.get("zone", "within")

        if zone != prev_zone and zone in {"below", "above"}:
            if zone == "below":
                msg = (
                    f"{pair} 24h chg% dropped below {self.telegram_chg_min:.2f}% -> {chg_value:.2f}%"
                )
            else:
                msg = (
                    f"{pair} 24h chg% rose above {self.telegram_chg_max:.2f}% -> {chg_value:.2f}%"
                )
            try:
                self.dp.send_msg(msg)
            except Exception as exception:
                logger.debug("Telegram chg alert failed for %s: %s", pair, exception)

        state["zone"] = zone
        self.telegram_chg_alert_state[pair] = state

    def populate_entry_trend(self, df: DataFrame, metadata: dict) -> DataFrame:

        self._notify_chg_in_range(df, metadata)

        chg_5m_ok = True
        chg_15m_ok = True
        chg_30m_ok = True
        chg_1h_ok = True
        chg_4h_ok = True
        if self.use_chg_filter:
            chg_5m_ok = (
                (df["chg_pct"] >= self.chg_5m_min) & (df["chg_pct"] <= self.chg_5m_max)
            ) if self.chg_5m_enabled else True
            chg_15m_ok = (
                (df["chg_pct"] >= self.chg_15m_min) & (df["chg_pct"] <= self.chg_15m_max)
            ) if self.chg_15m_enabled else True
            chg_30m_ok = (
                (df["chg_pct"] >= self.chg_30m_min) & (df["chg_pct"] <= self.chg_30m_max)
            ) if self.chg_30m_enabled else True
            chg_1h_ok = (
                (df["chg_pct"] >= self.chg_1h_min) & (df["chg_pct"] <= self.chg_1h_max)
            ) if self.chg_1h_enabled else True
            chg_4h_ok = (
                (df["chg_pct"] >= self.chg_4h_min) & (df["chg_pct"] <= self.chg_4h_max)
            ) if self.chg_4h_enabled else True

        # ===== LONG ENTRIES - 5m ONLY =====

        df.loc[
            (
                self.entry_5m_enabled
                & (df["minima_check"] == 0)
                & (df["volume"] > 0)
                & (df["rsi"] < 30)
                & chg_5m_ok
            ),
            ["enter_long", "enter_tag"],
        ] = (1, "5M - Long")

        df.loc[
            (
                self.entry_5m_enabled
                & (df["DI_catch"] == 1)
                & (df["minima_check"] == 0)
                & (df["minima_check"].shift(5) == 1)
                & (df["volume"] > 0)
                & (df["rsi"] < 30)
                & chg_5m_ok
            ),
            ["enter_long", "enter_tag"],
        ] = (1, "5M - Shift Long")

        # ===== LONG ENTRIES - 15m ONLY =====

        df.loc[
            (
                self.entry_15m_enabled
                & (df["minima_check_15m"] == 0)
                & (df["volume_15m"] > 0)
                & (df["rsi_15m"] < 30)
                & chg_15m_ok
                & (df["minima_check"] == 0)
                & (df["volume"] > 0)
                & (df["rsi"] < 30)
            ),
            ["enter_long", "enter_tag"],
        ] = (1, "15M - Long")

        df.loc[
            (
                self.entry_15m_enabled
                & (df["DI_catch_15m"] == 1)
                & (df["minima_check_15m"] == 0)
                & (df["minima_check"].shift(5) == 1)
                & (df["volume_15m"] > 0)
                & (df["rsi_15m"] < 30)
                & chg_15m_ok
                & (df["minima_check"] == 0)
                & (df["volume"] > 0)
                & (df["rsi"] < 30)
            ),
            ["enter_long", "enter_tag"],
        ] = (1, "15M - Shift Long")

        # ===== LONG ENTRIES - 30M ONLY =====

        df.loc[
            (
                self.entry_30m_enabled
                &(df["minima_check_30m"] == 0)
                & (df["volume_30m"] > 0)
                & (df["rsi_30m"] < 30)
                & chg_30m_ok
                & (df["minima_check"] == 0)
                & (df["volume"] > 0)
                & (df["rsi"] < 30)
            ),
            ["enter_long", "enter_tag"],
        ] = (1, "30M - Long")

        df.loc[
            (
                self.entry_30m_enabled
                &(df["DI_catch_30m"] == 1) 
                & (df["minima_check_30m"] == 0)
                & (df["minima_check"].shift(5) == 1) 
                & (df["volume_30m"] > 0)
                & (df["rsi_30m"] < 30)
                & chg_30m_ok
                & (df["minima_check"] == 0)
                & (df["volume"] > 0)
                & (df["rsi"] < 30)
            ),
            ["enter_long", "enter_tag"],
        ] = (1, "30M - Shift Long")        

        # ===== LONG ENTRIES - 1h ONLY =====

        df.loc[
            (
                self.entry_1h_enabled
                &(df["minima_check_1h"] == 0)
                & (df["volume_1h"] > 0)
                & (df["rsi_1h"] < 30)
                & chg_1h_ok
                & (df["minima_check"] == 0)
                & (df["volume"] > 0)
                & (df["rsi"] < 30)
            ),
            ["enter_long", "enter_tag"],
        ] = (1, "1H - Long")

        df.loc[
            (
                self.entry_1h_enabled
                &(df["DI_catch_1h"] == 1) 
                & (df["minima_check_1h"] == 0)
                & (df["minima_check"].shift(5) == 1) 
                & (df["volume_1h"] > 0)
                & (df["rsi_1h"] < 30)
                & chg_1h_ok
                & (df["minima_check"] == 0)
                & (df["volume"] > 0)
                & (df["rsi"] < 30)
            ),
            ["enter_long", "enter_tag"],
        ] = (1, "1H - Shift Long")

        # ===== LONG ENTRIES - 4h ONLY =====

        df.loc[
            (
                self.entry_4h_enabled
                &(df["minima_check_4h"] == 0)
                & (df["volume_4h"] > 0)
                & (df["rsi_4h"] < 30)
                & chg_4h_ok
                & (df["minima_check"] == 0)
                & (df["volume"] > 0)
                & (df["rsi"] < 30)
            ),
            ["enter_long", "enter_tag"],
        ] = (1, "4H - Long")

        df.loc[
            (
                self.entry_4h_enabled
                &(df["DI_catch_4h"] == 1) 
                & (df["minima_check_4h"] == 0)
                & (df["minima_check"].shift(5) == 1) 
                & (df["volume_4h"] > 0)
                & (df["rsi_4h"] < 30)
                & chg_4h_ok
                & (df["minima_check"] == 0)
                & (df["volume"] > 0)
                & (df["rsi"] < 30)
            ),
            ["enter_long", "enter_tag"],
        ] = (1, "4H - Shift Long")

        # ===== SHORT ENTRIES - 5m ONLY =====

        df.loc[
            (
                self.entry_5m_enabled
                & (df["maxima_check"] == 0)
                & (df["volume"] > 0)
                & (df["rsi"] > 70)
                & chg_5m_ok
            ),
            ["enter_short", "enter_tag"],
        ] = (1, "5M - Short")

        df.loc[
            (
                self.entry_5m_enabled
                & (df["DI_catch"] == 1)
                & (df["maxima_check"] == 0)
                & (df["maxima_check"].shift(5) == 1)
                & (df["volume"] > 0)
                & (df["rsi"] > 70)
                & chg_5m_ok
            ),
            ["enter_short", "enter_tag"],
        ] = (1, "5M - Shift Short")

        # ===== SHORT ENTRIES - 15m ONLY =====

        df.loc[
            (
                self.entry_15m_enabled
                & (df["maxima_check_15m"] == 0)
                & (df["volume_15m"] > 0)
                & (df["rsi_15m"] > 70)
                & chg_15m_ok
                & (df["maxima_check"] == 1)
                & (df["volume"] > 0)
                & (df["rsi"] > 70)
            ),
            ["enter_short", "enter_tag"],
        ] = (1, "15M - Short")

        df.loc[
            (
                self.entry_15m_enabled
                & (df["DI_catch_15m"] == 1)
                & (df["maxima_check_15m"] == 0)
                & (df["maxima_check"].shift(5) == 1)
                & (df["volume_15m"] > 0)
                & (df["rsi_15m"] > 70)
                & chg_15m_ok
                & (df["maxima_check"] == 0)
                & (df["volume"] > 0)
                & (df["rsi"] > 70)
            ),
            ["enter_short", "enter_tag"],
        ] = (1, "15M - Shift Short")

        # ===== SHORT ENTRIES - 30m ONLY =====

        df.loc[
            (
                self.entry_30m_enabled
                &(df["maxima_check_30m"] == 0)
                & (df["volume_30m"] > 0)
                & (df["rsi_30m"] > 70)
                & chg_30m_ok
                & (df["maxima_check"] == 1)
                & (df["volume"] > 0)
                & (df["rsi"] > 70)
            ),
            ["enter_short", "enter_tag"],
        ] = (1, "30M - Short")

        df.loc[
            (
                self.entry_30m_enabled
                &(df["DI_catch_30m"] == 1) 
                & (df["maxima_check_30m"] == 0)
                & (df["maxima_check"].shift(5) == 1) 
                & (df["volume_30m"] > 0)
                & (df["rsi_30m"] > 70)
                & chg_30m_ok
                & (df["maxima_check"] == 0)
                & (df["volume"] > 0)
                & (df["rsi"] > 70)
            ),
            ["enter_short", "enter_tag"],
        ] = (1, "30M - Shift Short")

        # ===== SHORT ENTRIES - 1h ONLY =====

        df.loc[
            (
                self.entry_1h_enabled
                &
                (df["maxima_check_1h"] == 0)
                & (df["volume_1h"] > 0)
                & (df["rsi_1h"] > 70)
                & chg_1h_ok
                & (df["maxima_check"] == 1)
                & (df["volume"] > 0)
                & (df["rsi"] > 70)
            ),
            ["enter_short", "enter_tag"],
        ] = (1, "1H - Short")

        df.loc[
            (
                self.entry_1h_enabled
                &(df["DI_catch_1h"] == 1) 
                & (df["maxima_check_1h"] == 0)
                & (df["maxima_check"].shift(5) == 1) 
                & (df["volume_1h"] > 0)
                & (df["rsi_1h"] > 70)
                & chg_1h_ok
                & (df["maxima_check"] == 0)
                & (df["volume"] > 0)
                & (df["rsi"] > 70)
            ),
            ["enter_short", "enter_tag"],
        ] = (1, "1H - Shift Short")

        # ===== SHORT ENTRIES - 4h ONLY =====

        df.loc[
            (
                self.entry_4h_enabled
                &(df["maxima_check_4h"] == 0)
                & (df["volume_4h"] > 0)
                & (df["rsi_4h"] > 70)
                & chg_4h_ok
                & (df["maxima_check"] == 1)
                & (df["volume"] > 0)
                & (df["rsi"] > 70)
            ),
            ["enter_short", "enter_tag"],
        ] = (1, "4H - Short")

        df.loc[
            (
                self.entry_4h_enabled
                &(df["DI_catch_4h"] == 1)
                &(df["maxima_check_4h"] == 0)
                & (df["maxima_check"].shift(5) == 1)
                & (df["volume_4h"] > 0)
                & (df["rsi_4h"] > 70)
                & chg_4h_ok
                & (df["maxima_check"] == 0)
                & (df["volume"] > 0)
                & (df["rsi"] > 70)
            ),
            ["enter_short", "enter_tag"],
        ] = (1, "4H - Shift Short")

        return df

    def populate_exit_trend(self, df: DataFrame, metadata: dict) -> DataFrame:

        df.loc[((df["maxima_check"] == 0) & (df["volume"] > 0)), ["exit_long", "exit_tag"]] = (
            1,
            "Long Exit",
        )
        
        df.loc[((df["minima_check"] == 0) & (df["volume"] > 0)), ["exit_short", "exit_tag"]] = (
            1,
            "Short Exit",
        )

        return df