# source: https://raw.githubusercontent.com/EJMC95/crypto-trading-bot/02ae8421867e1e62555167b55d643f34165cd644/user_data/strategies/SwingDipV1.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__SwingDipV1__20260911_153029.py
#
# WHAT IT IS: a SWING trader. Daily candles, holds days-to-weeks (not minutes).
# It buys oversold DIPS but ONLY while the longer trend is up, and sells into
# strength. The opposite rhythm to your other two bots:
#   v4core  = slow trend rider (holds for months)
#   v5gated = intraday day-trader (holds minutes-to-hours)
#   v6swing = this one: buy-the-dip swing trades (holds ~1-2 weeks)
#
# THE RULE THAT KEEPS IT SANE: only buy dips when the 50-day EMA is above the
# 200-day EMA. Your old V2 "bought dips" in a DOWNtrend and got destroyed
# (catching falling knives). This refuses to buy a dip unless the macro trend
# is still up — dip-buying with the tide, not against it.
#
# EVIDENCE (Binance daily, 2023-07 -> 2026-06, 0.1% fee, un-optimised settings):
#                         3yr return   maxDD     trades  win
#     BTC buy & hold ....  +120.6%     -51.2%      —      —
#     BTC Github_EJMC95_crypto_trading_bot__SwingDipV1__20260911_153029 ....    -5.1%     -21.8%      9     44%
#     ETH buy & hold ....    -8.8%     -67.5%      —      —
#     ETH Github_EJMC95_crypto_trading_bot__SwingDipV1__20260911_153029 ....   +18.1%     -17.9%      7     57%
#
# HONEST LIMITATIONS — READ:
#   - It UNDERPERFORMS buy & hold in a strong bull (it sells into strength and
#     gives up the big trend). Its job is LOW DRAWDOWN + making money on flat/
#     choppy markets (see ETH), NOT beating a raging bull. It is a diversifier.
#   - Small sample: ~7-13 trades over 3 years = low statistical confidence.
#     Treat live use as a measured dry-run experiment.
#   - Spot / long-only. Settings are round numbers chosen to AVOID overfitting,
#     not tuned for max backtest return. Hyperopt them later if you want, but
#     beware the V3 lesson (a great backtest is easy to fake).
#
# [2026-07-01] Switched entry/exit 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; stop-loss/ROI/protections kept as guardrails.
#
# [2026-07-03 ADAPTIVE DUAL-MODE] The 07-01 rewrite silently DROPPED the 50/200
# uptrend gate this bot was validated with — it was buying every 20d-low approach
# in a bear market (the exact V2 knife-catch failure the docstring warns about).
# Restored the gate for normal dip-buys, and added an explicit half-stake
# capitulation-bounce mode (daily RSI<30 at the range bottom, up-close confirmed)
# so the bot still trades the bear regime — deliberately, at reduced size, with
# fast exits — instead of either churning or sitting dead.

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


class Github_EJMC95_crypto_trading_bot__SwingDipV1__20260911_153029(IStrategy):
    INTERFACE_VERSION = 3

    timeframe = "1d"                 # daily candles -> swing, not scalping
    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):
        return [
            {"method": "CooldownPeriod", "stop_duration_candles": 1},
            {"method": "StoplossGuard", "lookback_period_candles": 20,
             "trade_limit": 2, "stop_duration_candles": 5, "only_per_pair": False},
            {"method": "MaxDrawdown", "lookback_period_candles": 40, "trade_limit": 4,
             "stop_duration_candles": 5, "max_allowed_drawdown": 0.2},
        ]

    # Hyperopt-tunable, but defaults are deliberately round (anti-overfit).
    buy_rsi  = IntParameter(20, 55, default=42, space="buy",  optimize=False)
    sell_rsi = IntParameter(55, 80, default=65, space="sell", optimize=False)

    # Take-profit ladder in MINUTES (1 day = 1440). Read in days:
    #   grab +20% any time; after 4 days accept +12%; after 8 days +6%;
    #   after 14 days exit at break-even. -> natural ~2-week max swing.
    minimal_roi = {
        "0": 0.20,
        "5760": 0.12,
        "11520": 0.06,
        "20160": 0.0,
    }

    # Stop: daily swings need room, and the uptrend filter limits downside.
    # [2026-07-03] -0.15 -> -0.10. Backtest 2024-01->2025-12 (15-pair basket):
    # 58% win rate yet -29% net — winners exit at the range top (~+5-8%) while
    # losers rode all the way to -15%, an unbeatable ratio. -10% keeps the
    # asymmetry survivable without clipping ordinary daily noise.
    stoploss = -0.10

    trailing_stop = False
    use_exit_signal = True           # the "sell into strength" exit below
    exit_profit_only = False
    process_only_new_candles = True
    startup_candle_count = 200       # need 200 daily candles for the 200d EMA

    def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
        dataframe["rsi"] = ta.RSI(dataframe, timeperiod=14)
        dataframe["ema50"] = ta.EMA(dataframe, timeperiod=50)
        dataframe["ema200"] = ta.EMA(dataframe, timeperiod=200)
        bb = qtpylib.bollinger_bands(qtpylib.typical_price(dataframe), window=20, stds=2)
        dataframe["bb_lower"] = bb["lower"]
        dataframe["bb_upper"] = bb["upper"]
        # [20-CANDLE RANGE 2026-07-01] Adaptive support/resistance: buy near the
        # rolling 20-candle low, sell near the rolling 20-candle high. shift(1) keeps
        # the current forming bar out of its own band (no look-ahead).
        dataframe["rng_low20"] = dataframe["low"].rolling(20).min().shift(1)
        dataframe["rng_high20"] = dataframe["high"].rolling(20).max().shift(1)
        _rng_band = (dataframe["rng_high20"] - dataframe["rng_low20"]).clip(lower=1e-9)
        # [2026-07-03 ADAPTIVE] Buy zone widened 0.15 -> 0.20 (more dip entries in
        # the friendly regime).
        dataframe["rng_buy_zone"] = dataframe["rng_low20"] + 0.20 * _rng_band
        dataframe["rng_sell_zone"] = dataframe["rng_high20"] - 0.15 * _rng_band
        return dataframe

    def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
        live_vol = dataframe["volume"] > 0

        # UPTREND: the ORIGINAL VALIDATED entry — a genuine oversold dip (RSI<35
        # AND close under the lower Bollinger) inside an uptrend. [2026-07-03]
        # Backtests showed the 07-01 "buy near the 20d range low" replacement lost
        # -29% even in the 2024-25 up-window; the Bollinger dip demands real
        # capitulation, not every drift to the bottom of a drifting range.
        dataframe.loc[
            (
                (dataframe["ema50"] > dataframe["ema200"])
                & (dataframe["rsi"] < self.buy_rsi.value)
                & (dataframe["close"] < dataframe["bb_lower"])
                & live_vol
            ),
            ["enter_long", "enter_tag"],
        ] = (1, "dip_in_uptrend")

        # [2026-07-12 SLEEVE RETIRED] The bear_bounce leg (downtrend half-stake
        # capitulation bounce, shipped 07-03 to all four spot bots) is gone
        # fleet-wide: tagged Binance replay 2022-2026 scored it negative in ALL
        # FOUR carriers (19 entries, -$7.27 aggregate, 26% win) and it never
        # fired once in live paper. In a downtrend this bot stands down — the
        # originally validated behaviour.
        return dataframe

    # [2026-07-04 PULSE] Panic-cluster check (news shocks: hacks/SEC/depegs).
    # Mirrors DayTraderV5Gated: sizing only, fail-safe neutral, 15-min cache.
    _pulse_cache = {"ts": None, "panic": False}

    def _pulse_panic(self, current_time):
        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
            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-04] During an active PANIC news cluster all new stakes halve:
    # dip-buying into a live hack/regulatory shock is the knife-catch case.
    def custom_stake_amount(self, pair, current_time, current_rate, proposed_stake,
                            min_stake, max_stake, leverage, entry_tag, side, **kwargs):
        stake = proposed_stake
        if self._pulse_panic(current_time):
            stake *= 0.5
        # [2026-07-14 L4] Brain's reduce-only per-tag multiplier — 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
        return stake

    def confirm_trade_entry(self, pair, order_type, amount, rate, time_in_force,
                            current_time, entry_tag, side, **kwargs):
        # [2026-07-14 L2] Fleet-risk long-budget veto (26-position-pileup guard,
        # per the 07-07 design's Jul-14 enforcement review). Fail-safe OPEN.
        try:
            import fleet_bus
            if fleet_bus.long_entries_blocked(current_time):
                return False
        except Exception:
            pass
        return True

    def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
        # [2026-07-03] Sell into strength, two ways: the validated RSI>65 exit OR
        # price reaching the top of the 20d range — whichever comes first. More
        # closes, and winners are banked into rallies instead of round-tripping.
        dataframe.loc[
            (
                (
                    (dataframe["rsi"] > self.sell_rsi.value)
                    | (dataframe["close"] >= dataframe["rng_sell_zone"])
                )
                & (dataframe["volume"] > 0)
            ),
            "exit_long",
        ] = 1
        return dataframe
