# source: https://raw.githubusercontent.com/loveolu/crypto-strategy-research/3059293e5edbc1a429c77fec9ff94957d94604a8/user_data/strategies/DonchianBTC1d.py
# directory_url: https://github.com/loveolu/crypto-strategy-research/blob/main/user_data/strategies/
# User: loveolu
# Repository: crypto-strategy-research
# --------------------# Github_loveolu_crypto_strategy_research__DonchianBTC1d__20260911_223401.py
# Daily Donchian Channel Breakout for BTC/USDT futures.
# Migrated from 1h after IS regime analysis revealed timeframe mismatch
# with source paper (Distaso/Mele/Zarattini 2025).
#
# Changes from DonchianBTC1h:
#   1. timeframe = "1d" (matches paper's design horizon)
#   2. Direct period computation (no pre-compute grid)
#   3. ADX upper bound at 55 (filters overextended/blow-off entries)
#   4. EMA proximity cap at +10% (filters stretched late entries)
#   5. Minimum 5-bar hold via custom_exit (prevents premature channel exits)
#
# Bug fix vs user-supplied spec:
#   Donchian channels are shifted ONCE in populate_indicators (rolling.max().shift(1)),
#   then NOT included in the _prev shift loop. Entry/exit uses df["close"] (not
#   close_prev) against the already-lagged channel. This matches the 1h fix.
#   The original spec computed rolling.max() without shift, then shifted again in
#   the lookahead guard — producing close_prev > max(high[...includes high_prev])
#   which is always False.
#
# Freqtrade 2026.4 | OKX Futures USDT-M | isolated margin.

from datetime import datetime, timedelta
from functools import reduce
from typing import Optional

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

from freqtrade.persistence import Trade
from freqtrade.strategy import (
    IStrategy,
    IntParameter,
    DecimalParameter,
)


class Github_loveolu_crypto_strategy_research__DonchianBTC1d__20260911_223401(IStrategy):
    """
    Long-only daily Donchian breakout on BTC/USDT, gated by EMA200 trend,
    ADX regime band (floor + ceiling), and EMA-proximity cap.
    ATR-based trailing stoploss, fractional-risk position sizing.
    """

    INTERFACE_VERSION = 3

    # ------- Core wiring -------
    timeframe = "1d"
    can_short: bool = False
    process_only_new_candles = True
    use_exit_signal = True
    exit_profit_only = False
    ignore_roi_if_entry_signal = False

    startup_candle_count: int = 300  # EMA200 + 100-day channel + buffer

    # Hard backstop; primary stop is custom_stoploss
    stoploss = -0.25
    use_custom_stoploss = True

    # ROI disabled — primary exits are Donchian channel + ATR trailing stop.
    # NOTE: the original spec used keys 30/60/120, which Freqtrade interprets
    # as MINUTES (not days). For a daily strategy those would fire in 30 minutes.
    # Setting to 100x prevents ROI interference with multi-week trend rides.
    minimal_roi = {"0": 100.0}

    trailing_stop = False

    # ------- Hyperopt params (6 max) -------
    donchian_entry_period = IntParameter(20, 100, default=55, space="buy", optimize=True)
    donchian_exit_period = IntParameter(10, 55, default=20, space="sell", optimize=True)
    atr_stop_mult = DecimalParameter(2.0, 5.0, default=3.0, decimals=1, space="sell", optimize=True)
    adx_floor = IntParameter(15, 35, default=22, space="buy", optimize=True)
    # Range raised from 5-15% → 5-50%: on daily BTC a 10% cap blocks the entire
    # 2020-2021 bull run (BTC was 30-60% above EMA200). The 1h insight (10%+ entries
    # fail) does not translate to daily — on hourly it's a blow-off; on daily it's
    # a normal bull market. Hyperopt will find the right cap within 5-50%.
    ema_pct_max = DecimalParameter(5.0, 50.0, default=25.0, decimals=1, space="buy", optimize=True)
    risk_per_trade_pct = DecimalParameter(0.005, 0.015, default=0.01, decimals=3, space="buy", optimize=False)

    # Fixed — empirically derived from IS regime analysis of 1h version
    ADX_CEILING = 55
    MIN_HOLD_BARS = 5  # days; prevents immediate channel-touch exits

    # ------- Protections -------
    @property
    def protections(self):
        return [
            {"method": "CooldownPeriod", "stop_duration_candles": 2},
            {
                "method": "StoplossGuard",
                "lookback_period_candles": 30,
                "trade_limit": 3,
                "stop_duration_candles": 7,
                "only_per_pair": False,
            },
            {
                "method": "MaxDrawdown",
                "lookback_period_candles": 180,  # ~6 months daily
                "trade_limit": 5,
                "stop_duration_candles": 14,
                "max_allowed_drawdown": 0.20,
            },
        ]

    # ------- Indicators -------
    def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
        df = dataframe.copy()

        df["atr"] = ta.ATR(df, timeperiod=14)
        df["ema200"] = ta.EMA(df, timeperiod=200)
        df["adx"] = ta.ADX(df, timeperiod=14)

        # Donchian channels — rolling FIRST, then shift(1).
        # rolling(N).max() at bar t includes high[t], so shift(1) afterwards
        # yields max(high[t-N:t-1]) at bar t — the true prior-N-day high that
        # excludes the current bar. This matches the 1h strategy's correct fix.
        # These columns are NOT in shift_cols (already lagged here).
        entry_p = int(self.donchian_entry_period.value)
        exit_p = int(self.donchian_exit_period.value)
        df["donchian_high_entry"] = df["high"].rolling(entry_p).max().shift(1)
        df["donchian_low_exit"] = df["low"].rolling(exit_p).min().shift(1)

        # EMA proximity — distance above trend filter, as percentage
        df["ema_pct"] = (df["close"] / df["ema200"] - 1.0) * 100.0

        # Realized vol for sizing
        log_ret = np.log(df["close"] / df["close"].shift(1))
        df["realized_vol_30"] = log_ret.rolling(30).std()

        # --- LOOKAHEAD GUARD ---
        # Shift raw indicators by 1 so entry/exit logic reads bar t-1 values.
        # Donchian channels are NOT here — already lagged via .shift(1) above.
        shift_cols = [
            "atr", "ema200", "adx", "ema_pct", "realized_vol_30",
            "close", "high", "low",
        ]
        for col in shift_cols:
            df[f"{col}_prev"] = df[col].shift(1)

        return df

    # ------- Entry -------
    def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
        df = dataframe

        conditions = [
            # Breakout: current close exceeds prior N-day high channel.
            # donchian_high_entry already holds max(high[t-N:t-1]) — no lookahead.
            df["close"] > df["donchian_high_entry"],

            # Trend filter: yesterday's close above yesterday's EMA200.
            df["close_prev"] > df["ema200_prev"],

            # ADX band: enough trend strength, but not blow-off exhaustion.
            df["adx_prev"] > self.adx_floor.value,
            df["adx_prev"] < self.ADX_CEILING,

            # EMA proximity cap: not chasing an already-extended move.
            df["ema_pct_prev"] < self.ema_pct_max.value,
            df["ema_pct_prev"] > 0,  # long side only (redundant with EMA filter; explicit)

            # Sanity
            df["atr_prev"] > 0,
            df["volume"] > 0,
        ]

        df.loc[
            reduce(lambda a, b: a & b, conditions),
            ["enter_long", "enter_tag"]
        ] = (1, "donch_brk_1d")
        return df

    # ------- Exit signal (disabled — exits handled via custom_exit + custom_stoploss) -------
    def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
        dataframe["exit_long"] = 0
        return dataframe

    # ------- Custom exit with minimum hold period -------
    def custom_exit(
        self,
        pair: str,
        trade: Trade,
        current_time: datetime,
        current_rate: float,
        current_profit: float,
        **kwargs,
    ) -> Optional[str]:
        """
        Donchian channel-break exit, gated by a minimum 5-day hold.
        Prevents small winners that exit on first channel touch from
        dragging down the W/L ratio (identified in 1h IS regime analysis).
        """
        bars_held = (current_time - trade.open_date_utc) / timedelta(days=1)
        if bars_held < self.MIN_HOLD_BARS:
            return None

        df, _ = self.dp.get_analyzed_dataframe(pair=pair, timeframe=self.timeframe)
        if df is None or len(df) == 0:
            return None

        last = df.iloc[-1]

        # donchian_low_exit is already shifted: holds min(low[t-N:t-1]) at bar t.
        # Compare current close against that prior-N-day low.
        dc_low = last.get("donchian_low_exit")
        close = last.get("close")

        if dc_low is None or close is None:
            return None
        if np.isnan(float(dc_low)) or np.isnan(float(close)):
            return None

        if float(close) < float(dc_low):
            return "donch_channel_exit"

        return None

    # ------- ATR-based custom stoploss -------
    def custom_stoploss(
        self,
        pair: str,
        trade: Trade,
        current_time: datetime,
        current_rate: float,
        current_profit: float,
        **kwargs,
    ) -> Optional[float]:
        """
        ATR(14) trailing stop: stop = max_price_seen - k * ATR_at_prior_bar.
        On daily bars, ATR(14) ≈ 2-week volatility window — appropriate scale.
        Returns the *relative* distance from current_rate (negative).
        """
        df, _ = self.dp.get_analyzed_dataframe(pair=pair, timeframe=self.timeframe)
        if df is None or len(df) == 0:
            return None

        last = df.iloc[-1]
        atr_prev = last.get("atr_prev")
        if atr_prev is None or np.isnan(float(atr_prev)) or float(atr_prev) <= 0:
            return None

        k = float(self.atr_stop_mult.value)
        peak = trade.max_rate or trade.open_rate
        stop_price = peak - k * float(atr_prev)

        if stop_price <= 0 or current_rate <= 0:
            return None

        rel_stop = (stop_price / current_rate) - 1.0
        # Daily bars: clamp between -2% (noise floor) and -25% (hard backstop).
        rel_stop = max(min(rel_stop, -0.02), -0.25)
        return rel_stop

    # ------- Volatility-targeted position sizing -------
    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:
        """
        Fractional Kelly proxy: risk r% of equity, stop distance = k * ATR(14).
        notional_at_1x = (equity * r) / (k * ATR / price)
        stake (margin)  = notional / leverage
        """
        df, _ = self.dp.get_analyzed_dataframe(pair=pair, timeframe=self.timeframe)
        if df is None or len(df) == 0:
            return proposed_stake

        last = df.iloc[-1]
        atr_prev = last.get("atr_prev")
        if atr_prev is None or np.isnan(float(atr_prev)) or float(atr_prev) <= 0:
            return proposed_stake

        try:
            total_equity = self.wallets.get_total_stake_amount()
        except Exception:
            return proposed_stake

        r = float(self.risk_per_trade_pct.value)
        k = float(self.atr_stop_mult.value)

        stop_dist_pct = (k * float(atr_prev)) / current_rate
        if stop_dist_pct <= 0:
            return proposed_stake

        target_notional = (total_equity * r) / stop_dist_pct
        target_stake = target_notional / max(leverage, 1.0)

        if min_stake is not None:
            target_stake = max(target_stake, min_stake)
        target_stake = min(target_stake, max_stake)

        # Hard cap: never more than 50% of equity as margin on a single trade
        target_stake = min(target_stake, total_equity * 0.5)

        return float(target_stake)

    # ------- Leverage -------
    def leverage(
        self,
        pair: str,
        current_time: datetime,
        current_rate: float,
        proposed_leverage: float,
        max_leverage: float,
        entry_tag: Optional[str],
        side: str,
        **kwargs,
    ) -> float:
        """
        Fixed 2x. Vol sizing targets risk; leverage unlocks the notional.
        2x keeps liquidation comfortably away from ATR stop.
        """
        return min(2.0, max_leverage)
