# source: https://raw.githubusercontent.com/VanceChu/Crypto-Hackers-NUTS/2e3976789ad8bc2aa3b63acb221cc444f2472e47/final_strategies/sources/FeeOptMomentum5m.py
"""
================================================================================
Github_VanceChu_Crypto_Hackers_NUTS__FeeOptMomentum5m__20260327_180934 — Fee-Optimized Momentum + Minute-Level Execution Strategy
================================================================================
Target   : Net positive alpha after 0.10–0.20% round-trip fees per trade
Platform : Roostoo / Freqtrade IStrategy v3
Timeframe: 5m execution  |  15m & 30m signal layers  |  BTC 30m regime

--------------------------------------------------------------------------------
ARCHITECTURE — THREE LAYERS
--------------------------------------------------------------------------------

  Layer 1 · BTC Regime Filter (30m)
    ─ Only enter when BTC is in Risk-On or weak Risk-On.
    ─ On Risk-Off: suppress all new entries.
    ─ On Hard Bear (BTC drops >4% in 3h): trigger emergency exit.
    Levels: STRONG_BULL=2, WEAK_BULL=1, NEUTRAL=0, BEAR=-1

  Layer 2 · Momentum Pre-Screen (15m / 30m proxy)
    ─ Coin must show positive 6h return > 0.5%
    ─ Volume must expand vs 10h average (≥1.5×)
    ─ Price must be above 20-bar rolling average
    ─ Breakout from recent 3h high (15m proxy)
    ─ RSI between 45–78 (momentum, not overbought)
    Output: momentum_score composite; only trade coins above threshold.

  Layer 3 · 5m Execution (Fee-Aware Entry)
    ─ Mode A  Micro-breakout: price breaks 30m high + volume surge
    ─ Mode B  Pullback continuation: already broken out on 15m, pulls back
              to EMA5/VWAP, then resumes up
    ─ Anti-chase: ignore bars with |Δprice| > 3%
    ─ Minimum edge gate: momentum_score > threshold (no noise trades)

--------------------------------------------------------------------------------
FEE MODEL & MINIMUM EDGE
--------------------------------------------------------------------------------
  maker 0.05%  +  maker 0.05%  =  0.10%  round-trip (normal)
  taker 0.10%  +  taker 0.10%  =  0.20%  round-trip (emergency)
  Minimum target per trade: ≥ 0.50% (5× maker round-trip buffer)
  First ROI tier: 0.6% — smallest fee-safe take-profit level

--------------------------------------------------------------------------------
ENTRY LOGIC
--------------------------------------------------------------------------------
  All of the following must be true simultaneously:
  A. btc_regime ≥ 0                        (Layer 1: not in bear)
  B. momentum_15m > 0.5%                   (Layer 2: 6h uptrend)
  C. vol_15m_ratio > 1.5                   (Layer 2: volume expanding)
  D. close > ema20_5m                      (Layer 2: trend aligned)
  E. RSI(7) in [45, 78]                    (Layer 2: not overbought)
  F. micro_breakout OR pullback_cont       (Layer 3: execution signal)
  G. bar_return < 3%                       (anti-chase filter)
  H. momentum_score > 2.0                  (minimum edge gate)

  Scale-in via adjust_trade_position:
    Initial entry: 40% of trade budget
    Add-on: +60% when floating profit > 0.3% (breakout confirmed)

--------------------------------------------------------------------------------
EXIT LOGIC (priority order)
--------------------------------------------------------------------------------
  1. Hard stop    : -0.6% (stoploss parameter)
  2. Regime fail  : BTC turns BEAR → taker exit (emergency)
  3. Momentum decay: MACD histogram flips negative + volume fades
  4. EMA cross-down: ema5 crosses below ema20 on 5m
  5. Trailing stop: activated after +0.6%, trails -0.8% from peak
  6. Time stop    : held >60m with <0.3% profit → cut at -0.3%
  7. ROI tiers    : +0.6% @10h / +0.9% @5h / +1.2% @2.5h / +1.5% instant

--------------------------------------------------------------------------------
BACKTEST RESULTS  (standalone engine, Binance 5m data, fee=0.1%, slip=0.05%)
--------------------------------------------------------------------------------
  See Github_VanceChu_Crypto_Hackers_NUTS__FeeOptMomentum5m__20260327_180934_Report.md for full results and parameter analysis.

--------------------------------------------------------------------------------
KEY PARAMETERS (most sensitive, tune these first)
--------------------------------------------------------------------------------
  MIN_EDGE          0.005    minimum momentum_score gate
  MOM_THRESHOLD     0.005    minimum 6h coin return to qualify (0.5%)
  VOL_SURGE_15M     1.5      minimum 15m volume expansion ratio
  EXEC_BREAKOUT     6        5m bars for micro-breakout channel (30m)
  TRAILING_STOP_PCT 0.008    trailing stop distance after breakeven
  TIME_STOP_BARS    12       bars without progress → forced exit (60m)
================================================================================
"""

from __future__ import annotations

import numpy as np
import pandas as pd
from datetime import datetime
from pandas import DataFrame

from freqtrade.strategy import IStrategy

try:
    import talib.abstract as ta
    HAS_TALIB = True
except ImportError:
    HAS_TALIB = False


class Github_VanceChu_Crypto_Hackers_NUTS__FeeOptMomentum5m__20260327_180934(IStrategy):
    """
    Fee-Optimized Momentum Strategy with 5-minute Precision Execution.

    Three-layer signal architecture:
      Layer 1  BTC regime filter (30m)
      Layer 2  Momentum pre-screen (15m / 30m proxy via rolling windows)
      Layer 3  5m execution (micro-breakout or pullback continuation)

    Fee-first design: minimum 0.5% edge gate, maker-preferred exits,
    tight -0.6% stop to maintain favorable loss/win asymmetry.
    """

    INTERFACE_VERSION: int = 3
    can_short: bool = False
    timeframe = "5m"

    # ── ROI table ──────────────────────────────────────────────────────────────
    # All levels are net-positive after 0.10% maker round-trip fees.
    # +0.6% is the minimum fee-safe profit floor (6× maker round-trip).
    minimal_roi = {
        "0":   0.015,   # +1.5%  — take immediately on strong breakout
        "30":  0.012,   # +1.2%  — after 30 bars (150m ≈ 2.5h)
        "60":  0.009,   # +0.9%  — after 60 bars (~5h)
        "120": 0.006,   # +0.6%  — after 120 bars (~10h)  fee breakeven floor
    }

    stoploss = -0.006            # Hard stop -0.6% (covers maker round-trip × 6)
    use_custom_stoploss = True   # Trailing + time-based stop via custom_stoploss
    trailing_stop = False        # Managed manually in custom_stoploss

    startup_candle_count: int = 300   # warm-up for 48h momentum proxies
    process_only_new_candles = True
    use_exit_signal = True
    exit_profit_only = False
    ignore_roi_if_entry_signal = False

    # ═══════════════════════════════════════════════════════════════════════════
    # STRATEGY CONSTANTS  (adjust these for parameter search)
    # ═══════════════════════════════════════════════════════════════════════════

    # ── Fee model ──────────────────────────────────────────────────────────────
    MAKER_FEE   = 0.0005   # 0.05% per leg
    TAKER_FEE   = 0.001    # 0.10% per leg
    MIN_EDGE    = 0.005    # 0.50% net minimum per trade

    # ── Layer 1: BTC regime (30m) ──────────────────────────────────────────────
    BTC_EMA_FAST    = 20    # × 30m = 10h fast EMA
    BTC_EMA_SLOW    = 50    # × 30m = 25h slow EMA
    BTC_RETURN_BARS = 6     # × 30m = 3h return window for BTC momentum check

    # ── Layer 2: Momentum pre-screen (15m & 30m proxy on 5m bars) ─────────────
    # 15m approximation: rolling windows 3× the 15m period counts
    MOM_WINDOW_15M  = 24    # 24 × 15m = 6h momentum → 72 × 5m bars
    BREAKOUT_15M    = 12    # 12 × 15m = 3h breakout → 36 × 5m bars
    VOL_AVG_15M     = 20    # 20 × 15m baseline → 60 × 5m bars
    VOL_SURGE_15M   = 1.5   # 15m volume must be ≥1.5× 10h average
    MOM_THRESHOLD   = 0.005 # minimum 6h return to qualify (0.5%)
    RSI_SCREEN_MIN  = 45    # Layer 2 RSI lower bound
    RSI_SCREEN_MAX  = 78    # Layer 2 RSI upper bound (not overbought)

    # 30m approximation: rolling windows 6× the 30m period counts
    MOM_WINDOW_30M  = 48    # 48 × 30m = 24h → 288 × 5m bars

    # ── Layer 3: 5m execution ─────────────────────────────────────────────────
    EMA_FAST_5M     = 5     # 5 × 5m = 25m fast EMA
    EMA_SLOW_5M     = 20    # 20 × 5m = 100m slow EMA
    EXEC_BREAKOUT   = 6     # 6 × 5m = 30m micro-breakout channel
    EXEC_VOL_SURGE  = 1.3   # 5m volume must be ≥1.3× baseline at entry
    RSI_EXEC_MIN    = 40    # execution RSI lower bound
    RSI_EXEC_MAX    = 75    # execution RSI upper bound
    ANTI_CHASE_MAX  = 0.03  # reject entry if last bar moved >3%
    MIN_SCORE_GATE  = 2.0   # momentum_score gate for minimum edge enforcement

    # ── Exit / stop parameters ────────────────────────────────────────────────
    TRAILING_STOP_PCT = 0.008   # 0.8% trail distance (activated after breakeven)
    BREAKEVEN_PCT     = 0.006   # activate trailing after floating profit > 0.6%
    TIME_STOP_MIN     = 60      # minutes without progress before time-stop
    TIME_STOP_PROFIT  = 0.003   # profit threshold for time-stop (0.3%)
    TIME_STOP_CUT     = 0.003   # stop at -0.3% if time-stop fires

    # ── Position sizing constants ─────────────────────────────────────────────
    # Initial entry = 40% of stake; add-on = 60% of stake on confirmation
    INITIAL_STAKE_RATIO = 0.4   # fraction for first entry
    ADDON_PROFIT_MIN    = 0.003 # floating profit needed before scaling in (+0.3%)

    # ═══════════════════════════════════════════════════════════════════════════
    # PLOT CONFIG
    # ═══════════════════════════════════════════════════════════════════════════
    plot_config = {
        "main_plot": {
            "ema5_5m":        {"color": "#1E90FF"},
            "ema20_5m":       {"color": "#FF8C00"},
            "exec_high":      {"color": "#32CD32", "type": "line"},
            "vwap_approx":    {"color": "#FFD700", "type": "line"},
        },
        "subplots": {
            "RSI":        {"rsi7_5m":        {"color": "#9370DB"}},
            "Momentum":   {
                "momentum_15m": {"color": "#FF4500"},
                "momentum_30m": {"color": "#228B22"},
            },
            "Score":      {"momentum_score": {"color": "#DC143C", "type": "bar"}},
            "Regime":     {"btc_regime":     {"color": "#4682B4", "type": "bar"}},
            "Vol Ratio":  {"vol_ratio_5m":   {"color": "#708090", "type": "bar"}},
        },
    }

    # ═══════════════════════════════════════════════════════════════════════════
    # INFORMATIVE PAIRS
    # ═══════════════════════════════════════════════════════════════════════════
    def informative_pairs(self):
        return [("BTC/USD", "30m")]

    # ═══════════════════════════════════════════════════════════════════════════
    # PRIVATE HELPERS
    # ═══════════════════════════════════════════════════════════════════════════

    @staticmethod
    def _ema(series: pd.Series, span: int) -> pd.Series:
        return series.ewm(span=span, adjust=False).mean()

    @staticmethod
    def _rsi(series: pd.Series, period: int = 14) -> pd.Series:
        delta = series.diff()
        gain = delta.clip(lower=0)
        loss = -delta.clip(upper=0)
        alpha = 1.0 / period
        avg_gain = gain.ewm(alpha=alpha, min_periods=period, adjust=False).mean()
        avg_loss = loss.ewm(alpha=alpha, min_periods=period, adjust=False).mean()
        rs = avg_gain / avg_loss.replace(0, np.nan)
        return (100 - (100 / (1 + rs))).fillna(50)

    @staticmethod
    def _macd(series: pd.Series, fast=12, slow=26, signal=9):
        ema_f = series.ewm(span=fast, adjust=False).mean()
        ema_s = series.ewm(span=slow, adjust=False).mean()
        macd_line = ema_f - ema_s
        sig_line  = macd_line.ewm(span=signal, adjust=False).mean()
        return macd_line, sig_line, macd_line - sig_line

    # ═══════════════════════════════════════════════════════════════════════════
    # POPULATE INDICATORS
    # ═══════════════════════════════════════════════════════════════════════════
    def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:  # noqa: C901

        # ── LAYER 3: 5m execution indicators ──────────────────────────────────

        dataframe["ema5_5m"]  = self._ema(dataframe["close"], self.EMA_FAST_5M)
        dataframe["ema20_5m"] = self._ema(dataframe["close"], self.EMA_SLOW_5M)
        dataframe["rsi7_5m"]  = self._rsi(dataframe["close"], 7)

        # Micro-breakout channel: highest high / lowest low over last 30m
        dataframe["exec_high"] = (
            dataframe["high"].rolling(self.EXEC_BREAKOUT).max().shift(1)
        )
        dataframe["exec_low"] = (
            dataframe["low"].rolling(self.EXEC_BREAKOUT).min().shift(1)
        )

        # Volume ratio (current 5m bar vs 2h rolling average)
        dataframe["vol_avg_5m"]   = dataframe["volume"].rolling(24).mean()
        dataframe["vol_ratio_5m"] = (
            dataframe["volume"] / dataframe["vol_avg_5m"].replace(0, np.nan)
        ).fillna(1.0)

        # VWAP approximation (rolling 20-bar typical price weighted by volume)
        typical = (dataframe["high"] + dataframe["low"] + dataframe["close"]) / 3
        roll_vol = dataframe["volume"].rolling(20).sum().replace(0, np.nan)
        dataframe["vwap_approx"] = (
            (typical * dataframe["volume"]).rolling(20).sum() / roll_vol
        )

        # MACD for momentum decay detection
        macd_line, sig_line, histogram = self._macd(dataframe["close"])
        dataframe["macd_5m"]      = macd_line
        dataframe["macd_sig_5m"]  = sig_line
        dataframe["macd_hist_5m"] = histogram

        # Anti-chase: magnitude of last bar's price move
        dataframe["bar_return_5m"] = dataframe["close"].pct_change().abs()

        # ── LAYER 2: Momentum pre-screen (15m & 30m approximation) ────────────
        # We approximate 15m signals using 3× rolling windows on 5m data.
        # This avoids needing extra informative pair declarations while keeping
        # the same economic meaning as true 15m candles.

        m15_bars     = self.MOM_WINDOW_15M * 3     # 72 × 5m  ≈ 6h
        brk15_bars   = self.BREAKOUT_15M * 3       # 36 × 5m  ≈ 3h
        volavg15_bars = self.VOL_AVG_15M * 3       # 60 × 5m  ≈ 5h

        # 6h momentum (15m proxy)
        dataframe["momentum_15m"] = (
            dataframe["close"] / dataframe["close"].shift(m15_bars) - 1
        )

        # 3h breakout high (15m proxy)
        dataframe["breakout_15m_high"] = (
            dataframe["high"].rolling(brk15_bars).max().shift(1)
        )

        # Volume expansion over last 3 bars vs 5h average (15m proxy)
        vol_avg_15m = dataframe["volume"].rolling(volavg15_bars).mean()
        dataframe["vol_15m_ratio"] = (
            dataframe["volume"].rolling(3).sum()
            / (vol_avg_15m * 3).replace(0, np.nan)
        ).fillna(1.0)

        # 24h momentum (30m proxy)
        m30_bars = self.MOM_WINDOW_30M * 6        # 288 × 5m ≈ 24h
        dataframe["momentum_30m"] = (
            dataframe["close"] / dataframe["close"].shift(m30_bars) - 1
        )

        # Layer 2 qualification flags (all must be True to pass pre-screen)
        dataframe["l2_momentum_ok"] = dataframe["momentum_15m"] > self.MOM_THRESHOLD
        dataframe["l2_vol_ok"]      = dataframe["vol_15m_ratio"] > self.VOL_SURGE_15M
        dataframe["l2_trend_ok"]    = dataframe["close"] > dataframe["ema20_5m"]
        dataframe["l2_breakout_ok"] = dataframe["close"] > dataframe["breakout_15m_high"]
        dataframe["l2_rsi_ok"]      = (
            (dataframe["rsi7_5m"] > self.RSI_SCREEN_MIN) &
            (dataframe["rsi7_5m"] < self.RSI_SCREEN_MAX)
        )

        # Composite momentum score (0–10 range approximately)
        # Weights: 40% short-return, 30% 24h return, 15% vol expansion, 15% breakout
        dataframe["momentum_score"] = (
            0.40 * dataframe["momentum_15m"].clip(-0.20,  0.20) * 100
            + 0.30 * dataframe["momentum_30m"].clip(-0.30, 0.30) * 100
            + 0.15 * (dataframe["vol_15m_ratio"] - 1).clip(0, 5)
            + 0.15 * (
                (dataframe["close"] / dataframe["breakout_15m_high"].replace(0, np.nan) - 1)
                .clip(0, 0.10) * 100
            ).fillna(0)
        )

        # ── LAYER 1: BTC regime (30m informative) ─────────────────────────────
        btc = self.dp.get_pair_dataframe(pair="BTC/USD", timeframe="30m")

        if not btc.empty:
            btc = btc.copy()
            btc["btc_ema_fast"]   = self._ema(btc["close"], self.BTC_EMA_FAST)
            btc["btc_ema_slow"]   = self._ema(btc["close"], self.BTC_EMA_SLOW)
            btc["btc_return_3h"]  = btc["close"].pct_change(self.BTC_RETURN_BARS)

            # Regime scoring:
            #   2 = STRONG_BULL: above both EMAs, 3h return positive
            #   1 = WEAK_BULL  : above slow EMA, 3h return > -1%
            #   0 = NEUTRAL    : close to slow EMA (+/- 2%), 3h return > -2%
            #  -1 = BEAR       : below slow EMA or accelerating down
            conditions = [
                (
                    (btc["close"] > btc["btc_ema_fast"]) &
                    (btc["close"] > btc["btc_ema_slow"]) &
                    (btc["btc_return_3h"] > 0.005)
                ),
                (
                    (btc["close"] > btc["btc_ema_slow"]) &
                    (btc["btc_return_3h"] > -0.010)
                ),
                (
                    (btc["close"] > btc["btc_ema_slow"] * 0.98) &
                    (btc["btc_return_3h"] > -0.020)
                ),
            ]
            btc["btc_regime"] = np.select(conditions, [2, 1, 0], default=-1)

            # Flag emergency: BTC 3h drop > 4% → force all exits
            btc["btc_emergency"] = btc["btc_return_3h"] < -0.04

            btc_merge = btc[["date", "btc_regime", "btc_emergency"]].copy()
            dataframe = pd.merge_asof(
                dataframe,
                btc_merge,
                on="date",
                direction="backward",
            )
        else:
            # BTC data unavailable: assume neutral (do not block all trading)
            dataframe["btc_regime"]    = 0
            dataframe["btc_emergency"] = False

        dataframe["btc_regime"]    = dataframe["btc_regime"].fillna(0).astype(int)
        dataframe["btc_emergency"] = dataframe["btc_emergency"].fillna(False)

        return dataframe

    # ═══════════════════════════════════════════════════════════════════════════
    # ENTRY TREND
    # ═══════════════════════════════════════════════════════════════════════════
    def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:

        # ── Layer 1 gate ───────────────────────────────────────────────────────
        regime_ok = (dataframe["btc_regime"] >= 0) & (~dataframe["btc_emergency"])

        # ── Layer 2 gate ───────────────────────────────────────────────────────
        l2_ok = (
            dataframe["l2_momentum_ok"] &
            dataframe["l2_vol_ok"] &
            dataframe["l2_trend_ok"] &
            dataframe["l2_rsi_ok"]
        )

        # ── Layer 3a: Micro-breakout ───────────────────────────────────────────
        # Price breaks above last 30m high with volume surge.
        micro_breakout = (
            (dataframe["close"] > dataframe["exec_high"]) &
            (dataframe["vol_ratio_5m"] > self.EXEC_VOL_SURGE) &
            (dataframe["close"] > dataframe["ema5_5m"]) &
            (dataframe["rsi7_5m"] > self.RSI_EXEC_MIN) &
            (dataframe["rsi7_5m"] < self.RSI_EXEC_MAX) &
            (dataframe["bar_return_5m"] < self.ANTI_CHASE_MAX)
        )

        # ── Layer 3b: Pullback continuation ───────────────────────────────────
        # Coin already broke out on 15m; pulled back to EMA5/VWAP; now resumes.
        pullback_cont = (
            dataframe["l2_breakout_ok"] &                                  # 15m breakout confirmed
            (dataframe["close"] > dataframe["ema5_5m"]) &                  # recovered above EMA5
            (dataframe["close"].shift(2) <= dataframe["ema5_5m"].shift(2)) & # was at/below EMA5
            (dataframe["close"] > dataframe["close"].shift(1)) &           # bar is up
            (dataframe["rsi7_5m"] > self.RSI_EXEC_MIN) &
            (dataframe["rsi7_5m"] < self.RSI_EXEC_MAX) &
            (dataframe["bar_return_5m"] < self.ANTI_CHASE_MAX)
        )

        # ── Fee gate: minimum edge ─────────────────────────────────────────────
        fee_ok = dataframe["momentum_score"] > self.MIN_SCORE_GATE

        # ── Combined entry ─────────────────────────────────────────────────────
        entry = regime_ok & l2_ok & (micro_breakout | pullback_cont) & fee_ok

        dataframe.loc[entry, "enter_long"] = 1

        # Tag entry type for performance attribution
        dataframe.loc[entry & micro_breakout,  "enter_tag"] = "micro_breakout"
        dataframe.loc[entry & pullback_cont,   "enter_tag"] = "pullback_cont"
        # If both fire, micro_breakout tag overrides (last write wins reversed)
        dataframe.loc[entry & micro_breakout,  "enter_tag"] = "micro_breakout"

        return dataframe

    # ═══════════════════════════════════════════════════════════════════════════
    # EXIT TREND
    # ═══════════════════════════════════════════════════════════════════════════
    def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:

        # ── Exit A: Regime emergency ───────────────────────────────────────────
        regime_fail = dataframe["btc_emergency"] | (dataframe["btc_regime"] < 0)

        # ── Exit B: MACD momentum decay ────────────────────────────────────────
        # Histogram crosses from positive to negative + volume fading
        macd_decay = (
            (dataframe["macd_hist_5m"] < 0) &
            (dataframe["macd_hist_5m"].shift(1) >= 0) &
            (dataframe["vol_ratio_5m"] < 0.80)
        )

        # ── Exit C: EMA cross-down ─────────────────────────────────────────────
        ema_crossdown = (
            (dataframe["ema5_5m"] < dataframe["ema20_5m"]) &
            (dataframe["ema5_5m"].shift(1) >= dataframe["ema20_5m"].shift(1))
        )

        # ── Exit D: Price below VWAP + below EMA5 + volume fading ─────────────
        vwap_breakdown = (
            (dataframe["close"] < dataframe["vwap_approx"]) &
            (dataframe["close"] < dataframe["ema5_5m"]) &
            (dataframe["vol_ratio_5m"] < 0.70)
        )

        exit_signal = regime_fail | macd_decay | ema_crossdown

        dataframe.loc[exit_signal, "exit_long"] = 1
        return dataframe

    # ═══════════════════════════════════════════════════════════════════════════
    # CUSTOM STOPLOSS  (trailing + time-based)
    # ═══════════════════════════════════════════════════════════════════════════
    def custom_stoploss(
        self,
        pair: str,
        trade,
        current_time: datetime,
        current_rate: float,
        current_profit: float,
        after_fill: bool,
        **kwargs,
    ) -> float:
        """
        Dynamic stop-loss logic:

          1. Hard floor:  -0.6% (covers round-trip fees with buffer)
          2. Breakeven:   once profit > +0.6%, move stop to near 0
          3. Trailing:    once profit > +0.6%, trail -0.8% from peak
          4. Time stop:   if held >60m with <0.3% profit, cut at -0.3%
        """
        # Activate trailing stop after crossing breakeven
        if current_profit > self.BREAKEVEN_PCT:
            # Trail 0.8% below current profit peak
            return -self.TRAILING_STOP_PCT

        # Time stop: penalize stagnant trades to free capital
        trade_duration = (
            current_time - trade.open_date_utc
        ).total_seconds() / 60.0
        if trade_duration > self.TIME_STOP_MIN and current_profit < self.TIME_STOP_PROFIT:
            return -self.TIME_STOP_CUT  # tighten to -0.3%

        return self.stoploss  # default hard floor -0.6%

    # ═══════════════════════════════════════════════════════════════════════════
    # CUSTOM EXIT  (tiered take-profit)
    # ═══════════════════════════════════════════════════════════════════════════
    def custom_exit(
        self,
        pair: str,
        trade,
        current_time: datetime,
        current_rate: float,
        current_profit: float,
        **kwargs,
    ):
        """
        Tiered take-profit with time-decay:

          +1.5%  instant                      — strong breakout, lock in
          +0.9%  after 45m hold               — solid profit, reduce exposure
          +0.6%  after 2h hold                — fee-safe minimum, free capital

        Normal exits use maker (limit) orders.
        Emergency regime exits use taker (market) orders via exit_trend signal.
        """
        trade_duration = (
            current_time - trade.open_date_utc
        ).total_seconds() / 60.0

        if current_profit >= 0.015:
            return "tp3_strong_1.5pct"          # instant TP on big move

        if current_profit >= 0.009 and trade_duration >= 45:
            return "tp2_time_0.9pct"            # take +0.9% after 45m

        if current_profit >= 0.006 and trade_duration >= 120:
            return "tp1_time_0.6pct"            # take +0.6% after 2h (minimum)

        return None

    # ═══════════════════════════════════════════════════════════════════════════
    # POSITION SIZING  (scale-in 40% → +60% on confirmation)
    # ═══════════════════════════════════════════════════════════════════════════
    def custom_stake_amount(
        self,
        current_time: datetime,
        current_rate: float,
        proposed_stake: float,
        min_stake: float | None,
        max_stake: float,
        leverage: float,
        entry_tag: str | None,
        side: str,
    ) -> float:
        """
        Initial entry is 40% of the proposed stake.
        The remaining 60% is added via adjust_trade_position when the breakout
        is confirmed (floating profit > ADDON_PROFIT_MIN).
        """
        return max(proposed_stake * self.INITIAL_STAKE_RATIO, min_stake or 0.0)

    def adjust_trade_position(
        self,
        trade,
        current_time: datetime,
        current_rate: float,
        current_profit: float,
        min_stake: float | None,
        max_stake: float,
        current_entry_rate: float,
        current_exit_rate: float,
        current_entry_profit: float,
        current_exit_profit: float,
        **kwargs,
    ) -> float | None:
        """
        Add the remaining 60% stake when breakout is confirmed:
          - Only once (trade has exactly 1 entry fill so far)
          - Floating profit > +0.3% (direction confirmed)
          - Profit did not stall (we are not chasing a fading move)
        """
        count_of_entries = trade.nr_of_successful_buys

        # Only add once
        if count_of_entries != 1:
            return None

        # Require confirmed positive momentum
        if current_profit < self.ADDON_PROFIT_MIN:
            return None

        # Add-on size = 1.5× initial so that total ≈ 40% + 60% of full budget
        addon = trade.stake_amount * 1.5
        if min_stake and addon < min_stake:
            return None
        if addon > max_stake:
            return min(addon, max_stake)

        return addon
