# source: https://raw.githubusercontent.com/EJMC95/crypto-trading-bot/9a667f79c075ca6f568f582601f88c1e567a0c4c/user_data/strategies/DayTraderV5Gated.py
# directory_url: https://github.com/EJMC95/crypto-trading-bot/blob/main/user_data/strategies/
# User: EJMC95
# Repository: crypto-trading-bot
# --------------------# Github_EJMC95_crypto_trading_bot__DayTraderV5Gated__20260902_112140.py
#
# WHAT THIS IS, IN ONE SENTENCE:
#   It is your live DayTraderV1Aggro, with ONE thing added — a daily 50/200 EMA
#   "macro regime" gate that forbids any buying while the long-term trend is down.
#   Day-trade WITH the tide, never against it.
#
# WHY (the evidence, from a backtest on YOUR OWN data):
#   On Kraken 5m data Dec-2025 -> Jun-2026 (a ~6-month crash, daily trend down the
#   whole time), measured at realistic Kraken taker fees (0.26%/side):
#                                      return      trades   win%   maxDD
#     Buy & hold BTC ................   -23.8%        -       -       -
#     DayTraderV1Aggro (ungated) ....   -99.1%       976     16%   -99.1%   <-- wiped out
#     V5 (this file, gated) .........    +0.0%         0      -      0.0%    <-- stayed in cash
#   The ungated bot took ~1000 small longs into a falling market and bled to zero
#   on fees + losses. The gate simply refused to trade in that regime. Same result
#   for ETH (-99.4% ungated vs 0.0% gated).
#
# HONEST LIMITATIONS — READ BEFORE TRUSTING IT:
#   1. The only 5m data available was that one bear window, where the gate's job
#      was to do NOTHING. This proves the gate AVOIDS disaster; it does NOT yet
#      prove V5 makes money in an UP regime, because we have no 5m bull data to
#      test on. Download 2-3 years of 5m data and re-test before believing upside.
#   2. It is still a long-only 5m strategy underneath. In an up-regime it will
#      churn and pay fees like any day-trader. The gate reduces WHEN it trades,
#      not the per-trade edge. Your own V4 work already showed slow trend-following
#      (daily) is the more reliable earner; treat this as the "active" experiment
#      and keep V4 as the core.
#   3. Needs DAILY (1d) data downloaded for every pair you trade, or the gate has
#      no regime to read. See the download command in the chat / summary doc.
#
# Diff vs DayTraderV1Aggro is intentionally tiny (search "[V5 GATE]"): everything
# else is preserved so any edge you tune on the Aggro carries straight over.
#
# [2026-07-01] Entry/exit switched to a 20-candle RANGE strategy: buy near the
# rolling 20-candle low (bottom 15% of the band), sell near the rolling 20-candle
# high (top 15%). Long-only; custom ATR stop / ROI / protections kept as guardrails.
# The 1h/1d regime indicators are left intact (harmless) though unused by the new entry.
#
# [2026-07-03 ADAPTIVE DUAL-MODE] BTC-daily 50/200 regime switch. Risk-on = range
# pullback buys; risk-off = half-stake RSI<30 exhaustion bounces with fast exits.
# The bot adapts its playbook to the tide instead of either churning against it
# (155 trades, 14% win rate, -$21 live) or switching off entirely.

from datetime import datetime
from typing import Optional

import talib.abstract as ta
import freqtrade.vendor.qtpylib.indicators as qtpylib
from pandas import DataFrame
from freqtrade.strategy import (
    IStrategy,
    merge_informative_pair,
    IntParameter,
    DecimalParameter,
)


class Github_EJMC95_crypto_trading_bot__DayTraderV5Gated__20260902_112140(IStrategy):

    INTERFACE_VERSION = 3

    # [2026-07-03] 15m -> 1h: FEE VIABILITY. A 14-candle range on 15m majors is
    # typically ~0.3-0.8% — structurally below Kraken's ~0.52% round-trip fee, so
    # NO 15m variant could ever be net-green (live: 155 trades -$21 @14% win;
    # backtests: -19% to -25% every variant). A 14-HOUR range runs 1.5-3%+, so
    # the same logic clears fees with room. Still intraday cadence (holds hours).
    timeframe = "1h"
    informative_timeframe = "1h"      # short-term trend filter (unchanged from Aggro)
    # [2026-07-03] Regime timeframe 1d -> 4h. Same 50/200 EMA concept, but: (a) it
    # warms up in ~33 days instead of ~200, so the regime is defined from day one
    # on any exchange/backtest; (b) it flips in days not months, catching tradable
    # risk-on windows INSIDE a bear (like the current +8-12% 7d bounce) — that's
    # the adaptability this bot is for. 4h 50/200 EMA ≈ 8d/33d in wall time.
    regime_timeframe = "4h"

    # ---------------- hyperopt-tunable params (unchanged from Aggro) ---------- #
    buy_rsi = IntParameter(45, 70, default=55, space="buy", optimize=True)
    buy_ema_fast = IntParameter(5, 15, default=9, space="buy", optimize=False)
    buy_ema_slow = IntParameter(18, 50, default=21, space="buy", optimize=False)
    buy_vol_sma = IntParameter(10, 40, default=20, space="buy", optimize=False)
    # [2026-06-30] Kept at 1.5 (NOT widened). Post-exit market analysis (FMP 5m
    # bars, NEAR+TRX, 19 losing trades) showed that after the stop fired price kept
    # FALLING (pooled -0.9% at +1h; only 1/19 recovered) — the stop was protecting
    # capital, not cutting winners short. The losses came from bad ENTRIES on the
    # wrong universe (microcaps/stablecoins), now fixed by the StaticPairList change
    # in config_v5. Widening the stop would have deepened those losses.
    # [2026-07-03] 1.5 -> 2.0. Live evidence since: 155 trades, 21W/134L (14%),
    # 121 killed by this ATR stop — the stop distance (~1.5*ATR15m ≈ 0.3-0.6%)
    # sat BELOW the first ROI target (1.2%), so trades died to ordinary noise
    # before any winner could mature. The 06-30 "keep it tight" call was made
    # when entries were knife-catches; entries are now regime-gated + up-tick
    # confirmed, so the stop's job is disasters, not noise.
    # [2026-07-03b LEARNED 2.0 -> 2.5] Full 157-trade live dissection: every ROI
    # exit won (13/13); 122 stop exits at 6% win = -$17.68 of the -$20.96 total;
    # the ONLY net-positive holds were 90-240min (50% win) while <30min holds
    # went 2%. Winners need room+time to reach the ladder. Backtest confirm on
    # 1h: win rate 30.4% -> 41.3%, better P&L, lower DD.
    atr_stop_mult = DecimalParameter(0.8, 3.0, default=2.5, decimals=1,
                                     space="sell", optimize=True)

    # [V5 GATE] daily regime EMAs. Kept optimize=False on purpose: this is the
    # part we DON'T want to curve-fit — its whole value is being slow and robust.
    regime_ema_fast = IntParameter(30, 80, default=50, space="buy", optimize=False)
    regime_ema_slow = IntParameter(150, 250, default=200, space="buy", optimize=False)

    # [2026-06-30] Lower, faster ROI ladder. Every trade that reached the old 4%
    # first rung won (4/4), but post-exit analysis shows these names reverse fast,
    # so taking a smaller profit quickly banks more of the move before it gives
    # back. The ATR stop is deliberately UNCHANGED (see atr_stop_mult): post-exit
    # data showed losers kept falling after we exited, so the stop was protecting
    # capital — the real fix is entry quality + the liquid-majors universe.
    # [2026-07-03] Ladder raised + slowed (keys are minutes: 3h / 6h / 12h) and
    # re-keyed for 1h candles. Winners breathe to 1.8% early, decay with age.
    # (A "let winners run uncapped via 1h breakouts" variant was also tested and
    # was decisively worse: -33% / 16% win — 1h breakouts churn; the 4h V7 keeps
    # that job. This ladder + range exits is the best variant of five tested.)
    minimal_roi = {
        "0": 0.018,
        "180": 0.012,
        "360": 0.008,
        "720": 0.005,
    }

    stoploss = -0.12
    use_custom_stoploss = True
    trailing_stop = False

    process_only_new_candles = True
    use_exit_signal = True
    exit_profit_only = False
    ignore_roi_if_entry_signal = False
    can_short = False

    # Circuit breakers (research-driven risk guards). Candle counts scale with
    # this strategy's timeframe. Cooldown after each trade; stop-loss guard pauses
    # the bot after a cluster of stops; max-drawdown halts it if it bleeds.
    @property
    def protections(self):
        # [CHURN-FIX 2026-06-25, re-scaled 2026-07-03 for 1h candles] Cooldown 4
        # candles (4h): one pair can't be re-bought into the same chop. Stoploss-
        # Guard 3 stops / 48h pauses 12h after a genuine cluster. MaxDrawdown
        # halts 24h if the book bleeds 15% inside 4 days.
        return [
            {"method": "CooldownPeriod", "stop_duration_candles": 4},
            {"method": "StoplossGuard", "lookback_period_candles": 48,
             "trade_limit": 3, "stop_duration_candles": 12, "only_per_pair": False},
            {"method": "MaxDrawdown", "lookback_period_candles": 96, "trade_limit": 10,
             "stop_duration_candles": 24, "max_allowed_drawdown": 0.15},
        ]

    # [2026-07-01 FIX] Base-timeframe warmup only. Was 200 (for the removed 1d
    # regime), which starved the bot of signals. 110 x 1h is well within
    # Kraken's 720-candle OHLC window, so no starvation risk.
    startup_candle_count = 110

    # [2026-07-03 ADAPTIVE REGIME] BTC daily drives the market-wide risk switch
    # for the whole (BTC-correlated) basket. One always-present series, so it
    # avoids the per-pair daily-data starvation that forced removal of the
    # original 24-pair gate on 07-01. Derived from stake_currency so the same
    # strategy works on Kraken (BTC/USD) and Binance backtests (BTC/USDT).
    @property
    def regime_pair(self) -> str:
        return f"BTC/{self.config.get('stake_currency', 'USD')}"

    def informative_pairs(self):
        return [(self.regime_pair, self.regime_timeframe)]

    def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
        # [2026-07-01 FIX] Base-timeframe indicators ONLY. The old 1h/1d
        # informative merges (regime gate + 1h trend) required ~200 daily candles
        # per pair; on the broadened 24-pair basket that data isn't always present,
        # so populate_indicators raised and the bot produced NO signals at all
        # (heartbeat online, 0 trades). The 20-candle range entry never used those
        # columns, so they are removed. Keep ATR (for the custom stop) + the range.
        dataframe["atr"] = ta.ATR(dataframe, timeperiod=14)
        # [2026-07-03 ADAPTIVE] RSI drives the risk-off exhaustion-bounce mode.
        dataframe["rsi"] = ta.RSI(dataframe, timeperiod=14)

        # [2026-07-02 DIP-CLUSTER FIX] Own-pair 15m EMA50 trend gate. Live losses
        # arrived in clusters: market-wide dips hit all 24 pairs at once, the bot
        # bought several "separate" dips that were the same dip, and every one
        # stopped out together (11 ATR-stop losses, all in two such windows).
        # When the whole market slides, pairs sit BELOW their EMA50 — requiring
        # close > EMA50 blocks exactly those entries while still allowing normal
        # pullback-buys inside an intact uptrend.
        dataframe["ema50"] = ta.EMA(dataframe, timeperiod=50)

        # [2026-07-05 REGULAR-OPERATION FIX] ADX (trend strength) + 20-bar Donchian
        # high, for a momentum-breakout leg that fires on RISING phases — the
        # missing half that left the bot idle (every prior mode needed a pullback).
        dataframe["adx"] = ta.ADX(dataframe, timeperiod=14)
        dataframe["dc_high20"] = dataframe["high"].rolling(20).max()

        # [2026-07-01 INTRADAY RETUNE] The 20-candle / bottom-15% range never fired
        # on 15m (5h window, too tight) — the bot took zero trades. Faster + wider
        # for an intraday scalper: 10-candle (2.5h) window, buy in the bottom third
        # of the range, sell in the top third (34% dead zone in the middle so a
        # fresh buy isn't instantly in the sell zone). shift(1) = no look-ahead.
        # [2026-07-01 FEE-BLEED FIX] The prior 10-candle / 0.33-zone range captured
        # only ~0.34*band between entry and exit — below the ~0.52% round-trip fee,
        # so the bot went 0/11 (all tiny fee losses). Three changes:
        #   1) Wider window (14 candles, ~3.5h) for a steadier, larger band.
        #   2) Buy/sell zones tightened to 0.22 -> capture ~0.56*band per round trip.
        #   3) A FEE GATE (rng_band_pct) so we only take trades where the captured
        #      move can realistically clear fees + leave profit.
        # shift(1) = no look-ahead.
        _N = 14
        dataframe["rng_low20"] = dataframe["low"].rolling(_N).min().shift(1)
        dataframe["rng_high20"] = dataframe["high"].rolling(_N).max().shift(1)
        _rng_band = (dataframe["rng_high20"] - dataframe["rng_low20"]).clip(lower=1e-9)
        # [2026-07-05] Buy zone 0.22 -> 0.37: enter higher up the pullback (bottom
        # 37% of the range, not just 22%) for materially more fills. Sell zone kept
        # at 0.22 so captured move = ~0.41*band (entry 0.37 -> exit 0.78); at the
        # 2.0% band floor that's ~0.82% captured vs ~0.52% fee = ~+0.3% net — still
        # fee-positive, but thinner, so win rate matters more (watch it).
        dataframe["rng_buy_zone"] = dataframe["rng_low20"] + 0.37 * _rng_band
        dataframe["rng_sell_zone"] = dataframe["rng_high20"] - 0.22 * _rng_band
        # band width as a fraction of price — used to skip low-vol chop where the
        # captured move (~0.56*band) would be eaten by the ~0.52% round-trip fee.
        dataframe["rng_band_pct"] = _rng_band / dataframe["close"].clip(lower=1e-9)
        # deeper entry band for the risk-off bounce mode (bottom 15% of the range)
        dataframe["rng_bounce_zone"] = dataframe["rng_low20"] + 0.15 * _rng_band

        # [2026-07-03 ADAPTIVE REGIME] BTC 4h 50/200 EMA as the market-wide
        # regime SWITCH (not a gate): 50>200 -> risk-on pullback buys; 50<200 ->
        # half-stake sweep-reclaim bounces. One always-present series, avoiding
        # the per-pair daily-data starvation that killed the original gate on
        # 07-01. FAIL-SAFE: missing/warming-up data -> 0 -> the CONSERVATIVE
        # half-stake bounce mode, never the aggressive one.
        btc = self.dp.get_pair_dataframe(pair=self.regime_pair, timeframe=self.regime_timeframe)
        regime_col = f"btc_regime_up_{self.regime_timeframe}"  # merge suffixes by tf
        if btc is not None and len(btc) > 0:
            btc = btc.copy()
            btc["btc_regime_up"] = (
                ta.EMA(btc, timeperiod=self.regime_ema_fast.value)
                > ta.EMA(btc, timeperiod=self.regime_ema_slow.value)
            ).astype(int)
            dataframe = merge_informative_pair(
                dataframe, btc[["date", "btc_regime_up"]], self.timeframe,
                self.regime_timeframe, ffill=True
            )
        # Timeframe-agnostic alias (a hardcoded suffix here once silently forced
        # permanent bounce-only mode when the regime tf changed — hence this).
        if regime_col in dataframe.columns:
            dataframe["btc_regime_up"] = dataframe[regime_col].fillna(0).astype(int)
        else:
            dataframe["btc_regime_up"] = 0

        return dataframe

    # Minimum band width (fraction of price) to bother trading — the captured move
    # (~0.56*band) must clear the ~0.52% Kraken round-trip fee with margin.
    # [2026-07-03 ADAPTIVE] Two thresholds: risk-on pullbacks may work slightly
    # thinner bands (0.015 -> captured ~0.84%, ~+0.3% net); risk-off bounces demand
    # wide, washed-out bands (0.022 -> captured ~1.2%, ~+0.7% net) because bear
    # rallies fade fast and entry quality is everything there.
    BAND_PCT_ON = 0.015
    # [2026-07-05] 0.022 -> 0.020 to loosen risk-off participation
    # (bounce_pullback). At 2.0% the captured move (~0.56*band = ~1.12%) still clears
    # the ~0.52% round-trip fee with ~+0.6% net margin, so it stays above the
    # fee-bleed floor the 0/11 scratch run established — just admits more of the
    # current relief-rally pairs that were sitting a hair under the old 2.2% gate.
    BAND_PCT_OFF = 0.020

    def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
        # [2026-07-03 ADAPTIVE] Two regime modes instead of one on/off gate — the
        # bot always has a playbook, it just changes WHICH one with BTC's daily
        # trend. Shared quality gates: up-tick confirmation (no knife-catches,
        # the fix that live data demanded) + live volume.
        uptick = dataframe["close"] > dataframe["close"].shift(1)
        live_vol = dataframe["volume"] > 0

        # RISK-ON: buy pullbacks to the range low inside an intact own-pair
        # uptrend (close > EMA50 blocks market-wide slides).
        dataframe.loc[
            (
                (dataframe["btc_regime_up"] == 1)
                & (dataframe["close"] <= dataframe["rng_buy_zone"])
                & (dataframe["rng_band_pct"] >= self.BAND_PCT_ON)
                & (dataframe["close"] > dataframe["ema50"])
                & uptick & live_vol
            ),
            ["enter_long", "enter_tag"],
        ] = (1, "range_on")

        # [2026-07-12 SLEEVE RETIRED] The bear_bounce leg (risk-off half-stake
        # sweep-and-reclaim bounce, shipped 07-03 to all four spot bots) is gone
        # fleet-wide: tagged Binance replay scored it negative in ALL FOUR
        # carriers (19 entries, -$7.27 aggregate, 26% win; here 10 entries,
        # -$0.81) and it never fired once in live paper. Risk-off participation
        # is now bounce_pullback's job alone (confirmed relief rallies, own-pair
        # rising trend — not capitulation knife-catches).

        # [2026-07-05 BOUNCE PARTICIPATION] Third mode. The two modes above leave a
        # gap: in risk-off the bot ONLY buys the exact capitulation bottom
        # (RSI<30 sweep-reclaim) and never participates in the multi-day RELIEF
        # RALLY that follows — so through a +8-12% bear bounce (exactly now) it
        # sat idle and booked $0. This adds pullback buys DURING a confirmed bounce
        # even while BTC's daily regime is down, but ONLY when the pair's own 1h
        # trend is objectively UP and RISING (close>EMA50 AND EMA50 climbing over
        # ~6h). That own-pair rising-trend requirement is the anti-knife-catch: the
        # -19% failure was buying dips with NO trend confirmation in a waterfall;
        # a rising EMA50 cannot be a waterfall. Half stake (counter-daily-trend) +
        # wide fee-band + fast exits (custom_exit/stop below) bound the downside.
        ema50_rising = dataframe["ema50"] > dataframe["ema50"].shift(6)
        dataframe.loc[
            (
                (dataframe["btc_regime_up"] == 0)
                & (dataframe["close"] <= dataframe["rng_buy_zone"])
                & (dataframe["close"] > dataframe["ema50"])
                & ema50_rising
                & (dataframe["rng_band_pct"] >= self.BAND_PCT_OFF)
                & uptick & live_vol
            ),
            ["enter_long", "enter_tag"],
        ] = (1, "bounce_pullback")

        # [2026-07-05 REGULAR-OPERATION FIX] MOMENTUM-BREAKOUT leg — the missing
        # half. Every mode above needs a PULLBACK (bottom 37% of range) AND an
        # uptrend on the same 1h candle: a rare confluence that left the bot idle
        # for days. This fires on the RISING phase instead: a pair in a confirmed
        # 1h uptrend (close>EMA50) with real trend strength (ADX>=25) that BREAKS
        # its prior 20-bar high. Regime-agnostic (breakouts are valid in either
        # BTC regime), fee-band gated so it stays viable, and the ROI ladder + ATR
        # stop cap the churn 1h breakouts are prone to. Grounded in V7's proven
        # Donchian-breakout edge (PF~1.8), applied to 1h with guardrails. Distinct
        # tag so the brain measures it separately from the pullback modes.
        dataframe.loc[
            (
                (dataframe["adx"] >= 25)
                & (dataframe["close"] > dataframe["ema50"])
                & (dataframe["close"] > dataframe["dc_high20"].shift(1))
                & (dataframe["rng_band_pct"] >= self.BAND_PCT_ON)
                & live_vol
            ),
            ["enter_long", "enter_tag"],
        ] = (1, "trend_breakout")

        # [2026-07-13 SLEEVE RETIRED] range_meanrev (the 07-05 chop leg) is gone —
        # same evidence shape as the fleet-wide bear_bounce retirement (07-12):
        # 7d live, BOTH carriers (this bot 1h + georgia 15m): 52 entries, 16 wins,
        # -$13.94 — every band-width bucket negative (fattest bands LOST MOST, so
        # a higher fee gate can't save it), and a stop-variant counterfactual on
        # the exact live trades (x1.75 / x2.5 / hard-cap-only replayed on real
        # Kraken bars) stays red in every configuration: the entries don't earn
        # enough favourable excursion to clear fees, no exit can fix that. Post-
        # exit replay shows 80%+ of its stop-outs reclaimed entry within 24h —
        # the leg was donating fees+noise to the book. In ADX<20 chop this bot
        # now stands down (scripts in the 13-Jul session scratchpad; summary in
        # reports/WEEKLY_REVIEW_2026-07-13.md).
        return dataframe

    # [2026-07-02 DIP-CLUSTER FIX, rebalanced 2026-07-03] Correlated-entry throttle.
    # The 24 pairs are one beta in a dip: on 2026-07-01/02 the bot opened 4 positions
    # inside a single 15m window and all four stopped out together. Cap NEW positions
    # per candle (2) so one market move can't load up the whole book at once, while
    # still allowing a steady flow of entries.
    MAX_ENTRIES_PER_CANDLE = 2
    _entry_throttle_ts = None
    _entry_throttle_n = 0

    def confirm_trade_entry(self, pair: str, order_type: str, amount: float,
                            rate: float, time_in_force: str, current_time: datetime,
                            entry_tag: Optional[str], side: str, **kwargs) -> bool:
        # [2026-07-14 L2] Fleet-risk long-budget veto — checked BEFORE the
        # throttle so a vetoed entry doesn't burn a per-candle slot. Fail-safe
        # OPEN: stale/missing fleet state never blocks trading (fleet_bus).
        try:
            import fleet_bus
            if fleet_bus.long_entries_blocked(current_time):
                return False
        except Exception:
            pass
        # Bucket entries by 1h candle; reject beyond the per-candle cap.
        bucket = current_time.replace(minute=0, second=0, microsecond=0)
        if self._entry_throttle_ts != bucket:
            self._entry_throttle_ts = bucket
            self._entry_throttle_n = 0
        if self._entry_throttle_n >= self.MAX_ENTRIES_PER_CANDLE:
            return False
        self._entry_throttle_n += 1
        return True

    # [2026-07-03 PULSE] Informed sizing v1: the market_pulse collector (news /
    # social / funding mood, bot_state key 'market-pulse') can HALVE new stakes
    # during a PANIC news cluster (>=3 hack/exploit/regulatory-shock headlines).
    # Entries are deliberately untouched — a signal only earns entry-gate power
    # once the brain (bot_learn) shows a persistent edge. Fail-safe: no DB /
    # import error / stale data -> neutral 1.0x. Cached 15 min per process.
    _pulse_cache = {"ts": None, "panic": False}

    def _pulse_panic(self, current_time: datetime) -> bool:
        c = type(self)._pulse_cache
        try:
            if c["ts"] is not None and (current_time - c["ts"]).total_seconds() < 900:
                return c["panic"]
            import bot_pnl_store as store          # available in the Railway image
            st = store.load_state("market-pulse") or {}
            latest = st.get("latest") or {}
            # [2026-07-17 IMB-04] gate on the payload's OWN updated+ttl_sec —
            # a fossil panic=true from a dead market_pulse must not halve
            # stakes forever. Stale/unstamped/future-dated -> False.
            from datetime import datetime as _dt, timezone as _tz
            _age = (_dt.now(_tz.utc) - _dt.fromisoformat(
                str(st.get("updated")).replace("Z", "+00:00"))).total_seconds()
            c["ts"], c["panic"] = current_time, (
                bool(latest.get("panic"))
                and 0 <= _age <= float(st.get("ttl_sec") or 0))
        except Exception:
            c["ts"], c["panic"] = current_time, False
        return c["panic"]

    # [2026-07-03 ADAPTIVE] Half stake on risk-off bounces: counter-trend scalps
    # only earn conviction sizing in the friendly regime.
    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:
        stake = proposed_stake
        if entry_tag in ("bounce_pullback", "range_meanrev"):
            stake *= 0.5                             # counter-daily-trend scalps size down
        if self._pulse_panic(current_time):
            stake *= 0.5
        # [2026-07-14 L4] Brain's reduce-only per-tag multiplier — the ledger
        # throttles tags it has repeatedly scored negative at sample size.
        # Neutral 1.0 on any doubt (fleet_bus fail-safe contract).
        try:
            import fleet_bus
            stake *= fleet_bus.stake_multiplier(
                self.config.get("bot_name"), entry_tag, current_time)
        except Exception:
            pass
        if stake < proposed_stake and min_stake is not None and stake < min_stake:
            stake = min_stake                       # never breach exchange minimums
        return stake

    # [2026-07-03 ADAPTIVE] Faster exits = more closes, more re-armed slots.
    # Bounces bank +1.2% (sweep-reclaims either work quickly or not at all) or
    # time out after 12h; ANY trade times out after 24h so a stale position
    # can't squat in a slot for days. ROI ladder / ATR stop still apply on top.
    def custom_exit(self, pair: str, trade, current_time: datetime,
                    current_rate: float, current_profit: float, **kwargs):
        age_min = (current_time - trade.open_date_utc).total_seconds() / 60.0
        if trade.enter_tag == "bounce_pullback":
            # bank quick relief-rally profit; relief bounces fade fast, don't overstay
            if current_profit >= 0.012:
                return "bounce_take"
            if age_min >= 720:
                return "bounce_timeout"
        if age_min >= 1440:
            return "max_hold_timeout"
        return None

    def confirm_trade_exit(self, pair: str, trade, order_type: str, amount: float,
                           rate: float, time_in_force: str, exit_reason: str,
                           current_time: datetime, **kwargs) -> bool:
        # [2026-07-05] A trend_breakout enters ABOVE the range top, so the range-top
        # 'exit_signal' from populate_exit_trend would sell it the instant it breaks
        # out — defeating the whole point. Veto that one exit for breakout trades;
        # they still exit via the ROI ladder, the ATR stop, or the 24h max-hold.
        if trade.enter_tag == "trend_breakout" and exit_reason == "exit_signal":
            return False
        return True

    def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
        # Sell into strength at the top of the range.
        dataframe.loc[
            (
                (dataframe["close"] >= dataframe["rng_sell_zone"])
                & (dataframe["volume"] > 0)
            ),
            "exit_long",
        ] = 1
        return dataframe

    def custom_stoploss(self, pair: str, trade, current_time: datetime,
                        current_rate: float, current_profit: float,
                        after_fill: bool, **kwargs) -> Optional[float]:
        # [2026-07-03 ADAPTIVE] Bounce stop 2.0x: tested 1.2x and it was shredded
        # by ordinary 15m noise (7% win rate, avg hold 32min) — reclaims need room
        # to wobble. Half stake keeps the $ risk in line.
        # [2026-07-13] 2.0x -> 3.5x for the counter-trend tags. Live post-exit
        # replay (40 stop-outs, both carriers): 77-89% reclaimed ENTRY within 24h
        # and avg fwd drift after the stop was POSITIVE — the 2.0x stop fired on
        # noise, not danger. Counterfactual on the same trades: x1.75 wider cut
        # the stop bleed -15.57->-9.62 (this bot) and -5.92->-3.77 (georgia).
        # range_meanrev stays in the tuple only to manage positions opened
        # before its 13-Jul entry retirement.
        atr_multiplier = 3.5 if trade.enter_tag in ("bounce_pullback", "range_meanrev") else self.atr_stop_mult.value
        dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
        if dataframe is None or len(dataframe) == 0:
            return None
        last_atr = dataframe["atr"].iat[-1]
        if last_atr is None or last_atr <= 0 or current_rate <= 0:
            return None
        atr_stop_distance = (atr_multiplier * last_atr) / current_rate
        return max(-atr_stop_distance, self.stoploss)
