# source: https://raw.githubusercontent.com/mlsys-io/PortfolioBench/db17a0a4ca21e82061377a6a03f9b6dd863ef38a/strategy/adaptive_trend/adaptive_trend.py
# --- Do not remove these libs ---
from freqtrade.strategy import IStrategy
from typing import Optional
from pandas import DataFrame
# --------------------------------

from freqtrade.strategy import DecimalParameter, IntParameter
import talib.abstract as ta
import pandas as pd
import numpy as np
from pathlib import Path
from datetime import timedelta


class Github_mlsys_io_PortfolioBench__adaptive_trend__20260408_155837(IStrategy):
    """
    AdaptiveTrend-inspired strategy (Nguyen 2026) implemented for Freqtrade.
    Important to adjust "dry_run_wallet": 1000000 in config for testing to reflect paper’s large capital base and avoid unrealistic leverage.

    Key modules implemented:
      - H4 momentum entry signal
      - ATR-multiplier dynamic trailing stop (custom_stoploss)
      - Rolling Sharpe filter (proxy for monthly SR selection)
      - Market-cap aware filter (top-K long, bottom-K short) via offline CSV
      - 70/30 long-short stake scaling (approximation)

    Breakdown of how the strategy works
    1. Start with broad universe of tradable crypto pairs and collect 4hr OHLCV candles 
    2. At the start of each month, rebuild the tradeable universe
    3. Apply market-cap filter to crypto-pairs
    4. apply performance filter using recent sharpe ratio, coins with strong sharpe are eligible
    5. for each eligible asset, compute momentum based on returns
    6. Compute ATR over k periods. after long, initialize a trailing stop based on S = Pt - b x ATRt
    7. After selecting which assets qualify, allocate capital asymmetrically: 
    roughly 70% to longs and 30% to shorts.
    This reflects the paper’s view that crypto has a long-run positive drift, 
    so the portfolio should not be perfectly market-neutral. 
    8. At the next monthly rebalance, repeat the universe-selection process again: 
    re-check market-cap eligibility, re-check Sharpe, and allow coins to enter or leave the tradable set. 

    """

    INTERFACE_VERSION: int = 3

    # Paper uses 4-hour candles. (H4)
    timeframe = "4h"
    can_short = False
    # Let custom_stoploss manage exits. Keep a hard stop as safety.
    stoploss = -0.99
    minimal_roi = {}

    # Startup candles: need enough for MOM(L), ATR(k), and Sharpe window.
    # Default Sharpe window below is 30 days ≈ 30 * 4 = 120 candles.
    startup_candle_count = 250

    # Optional order type mapping (you can change to market if you want).
    order_types = {
        "entry": "limit",
        "exit": "limit",
        "stoploss": "market",
        "stoploss_on_exchange": False,
    }

    # Hyperoptable parameters
    # Momentum lookback L in candles (paper uses L as a tunable lookback).
    mom_lookback = IntParameter(8, 80, default=40, space="buy")  # 24 * 6h = 6 days

    # Entry threshold θ_entry
    theta_entry = DecimalParameter(0.02, 0.08, decimals=3, default=0.003, space="buy")

    # ATR period k and multiplier α for trailing stop
    atr_period = IntParameter(7, 40, default=14, space="sell")
    atr_mult = DecimalParameter(1.5, 4.5, decimals=2, default=2.50, space="sell")

    # Rolling Sharpe window (proxy for “previous month”)
    sharpe_window_candles = IntParameter(80, 200, default=60, space="buy")  # ~30d

    # Sharpe thresholds γ_L, γ_S (paper uses 1.3 / 1.7)
    gamma_long = DecimalParameter(0.5, 3.0, decimals=2, default=0.30, space="buy")
    gamma_short = DecimalParameter(0.5, 3.5, decimals=2, default=1.70, space="buy")

    # Market-cap filter sizes (paper uses KL=15 and bottom-KS).
    top_k_long = IntParameter(5, 30, default=2, space="buy")
    bottom_k_short = IntParameter(5, 60, default=2, space="buy")

    # Asymmetric allocation λ = 0.7 (long) / 0.3 (short).
    long_alloc = DecimalParameter(0.50, 0.90, decimals=2, default=0.70, space="buy")

    # Market-cap data handling
    _mcap_df: Optional[pd.DataFrame] = None

    def _base_symbol(self, pair: str) -> str:
        # "ETH/USDT" -> "ETH"
        return pair.split("/")[0].strip().upper()

    def load_market_cap_data(self) -> Optional[pd.DataFrame]:
        if self._mcap_df is not None:
            return self._mcap_df

        base_dir = Path(__file__).resolve().parent
        csv_files = list(base_dir.glob("*-usd-max.csv"))

        frames = []

        for path in csv_files:
            df = pd.read_csv(path)

            required_cols = {"snapped_at", "market_cap"}
            if not required_cols.issubset(df.columns):
                continue

            symbol = path.stem.split("-")[0].upper()

            temp = pd.DataFrame({
                "date": pd.to_datetime(df["snapped_at"], utc=True).dt.floor("D"),
                "symbol": symbol,
                "marketCap": pd.to_numeric(df["market_cap"], errors="coerce"),
            })

            temp = temp.dropna(subset=["date", "marketCap"])
            frames.append(temp)

        if not frames:
            self._mcap_df = None
            return None

        df = pd.concat(frames, ignore_index=True)
        df["rank"] = df.groupby("date")["marketCap"].rank(ascending=False, method="min")
        df["total_count"] = df.groupby("date")["symbol"].transform("count")

        self._mcap_df = df
        return df

    def _merge_market_cap(self, dataframe: DataFrame, pair: str) -> DataFrame:
        mcap = self.load_market_cap_data()

        # Reset columns every time so old state cannot leak in
        dataframe["mcap_rank"] = np.nan
        dataframe["mcap_total"] = np.nan
        dataframe["allow_long_mcap"] = 0
        dataframe["allow_short_mcap"] = 0

        if mcap is None or mcap.empty:
            dataframe["allow_long_mcap"] = 1
            dataframe["allow_short_mcap"] = 1
            return dataframe

        sym = self._base_symbol(pair)

        # Ensure candle dates are daily UTC keys
        dataframe["date_day"] = pd.to_datetime(dataframe["date"], utc=True).dt.floor("D")

        # Keep only this symbol
        mcap_sym = mcap.loc[mcap["symbol"] == sym, ["date", "rank", "total_count"]].copy()

        if mcap_sym.empty:
            return dataframe

        # Build lookup maps 
        rank_map = dict(zip(mcap_sym["date"], mcap_sym["rank"]))
        total_map = dict(zip(mcap_sym["date"], mcap_sym["total_count"]))

        dataframe["mcap_rank"] = dataframe["date_day"].map(rank_map)
        dataframe["mcap_total"] = dataframe["date_day"].map(total_map)

        dataframe["allow_long_mcap"] = (
            dataframe["mcap_rank"].notna()
            & (dataframe["mcap_rank"] <= float(self.top_k_long.value))
        ).astype(int)

        dataframe["allow_short_mcap"] = (
            dataframe["mcap_rank"].notna()
            & dataframe["mcap_total"].notna()
            & (dataframe["mcap_rank"] >= (dataframe["mcap_total"] - float(self.bottom_k_short.value) + 1))
        ).astype(int)

        return dataframe

    # Core indicators
    def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
        pair = metadata.get("pair", "")

        # Momentum MOM_t = (P_t - P_{t-L}) / P_{t-L}
        L = int(self.mom_lookback.value)
        dataframe["mom"] = (dataframe["close"] - dataframe["close"].shift(L)) / dataframe["close"].shift(L)

        # ATR over k periods
        k = int(self.atr_period.value)
        dataframe["atr"] = ta.ATR(dataframe, timeperiod=k)

        # Returns (for Sharpe proxy)
        dataframe["ret"] = dataframe["close"].pct_change()

        # Rolling Sharpe (annualized) on H6 frequency: ~1460 bars/year (365*4)
        w = int(self.sharpe_window_candles.value)
        eps = 1e-12
        mean_r = dataframe["ret"].rolling(w).mean()
        std_r = dataframe["ret"].rolling(w).std().replace(0, np.nan)
        ann_factor = np.sqrt(365.0 * 4.0)

        dataframe["sr_long"] = (mean_r / (std_r + eps)) * ann_factor
        # “short Sharpe” = Sharpe of (-ret) (i.e., profit when price falls)
        dataframe["sr_short"] = ((-mean_r) / (std_r + eps)) * ann_factor

        # Market cap filter (top/bottom K)
        dataframe = self._merge_market_cap(dataframe, pair)

        # Clean up early NaNs (don’t invent signals before indicators exist)
        dataframe.replace([np.inf, -np.inf], np.nan, inplace=True)
        print(dataframe.head())
        return dataframe

    # Entries
    def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
        theta = float(self.theta_entry.value)
        gL = float(self.gamma_long.value)
        gS = float(self.gamma_short.value)

        # Long: MOM > θ and SR filter passes and market-cap filter passes
        dataframe.loc[
            (
                (dataframe["volume"] > 0)
                & (dataframe["mom"] > theta)
                & (dataframe["sr_long"] >= gL)
                & (dataframe["allow_long_mcap"] == 1)
            ),
            "enter_long"
        ] = 1

        # Short: MOM < -θ and SR filter passes and market-cap filter passes
        dataframe.loc[
            (
                (dataframe["volume"] > 0)
                & (dataframe["mom"] < -theta)
                & (dataframe["sr_short"] >= gS)
                & (dataframe["allow_short_mcap"] == 1)
            ),
            "enter_short"
        ] = 1
        print(dataframe)
        return dataframe

    # Let trailing stop manage exits (so we don't set exit signals here).
    def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
        dataframe["exit_long"] = 0
        dataframe["exit_short"] = 0
        return dataframe

    # Dynamic trailing stop (ATR-based)
    def custom_stoploss(
        self,
        pair: str,
        trade,
        current_time,
        current_rate,
        current_profit,
        **kwargs
    ) -> float:
        """
        Paper-style dynamic trailing stop:
          For long: S_t = max(S_{t-1}, P_t - α*ATR_t)
          For short: S_t = min(S_{t-1}, P_t + α*ATR_t)

        We store the stop price in trade custom data so it “trails”.
        Returns stoploss as a negative value (relative stop) as required by Freqtrade.
        """
        df = self.dp.get_analyzed_dataframe(pair, self.timeframe)
        if df is None or df.empty:
            return 1  # keep default / do nothing

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

        alpha = float(self.atr_mult.value)

        # Helpers for cross-version compatibility
        def _get(key: str):
            if hasattr(trade, "get_custom_data"):
                return trade.get_custom_data(key)
            if hasattr(trade, "user_data") and isinstance(trade.user_data, dict):
                return trade.user_data.get(key)
            return None

        def _set(key: str, val):
            if hasattr(trade, "set_custom_data"):
                trade.set_custom_data(key, val)
                return
            if hasattr(trade, "user_data"):
                if trade.user_data is None:
                    trade.user_data = {}
                trade.user_data[key] = val

        prev_stop = _get("atr_trail_stop")

        is_short = getattr(trade, "is_short", False)

        if not is_short:
            # Long trailing stop price
            candidate = current_rate - alpha * atr
            if prev_stop is None:
                new_stop = candidate
            else:
                new_stop = max(float(prev_stop), candidate)
        else:
            # Short trailing stop price
            candidate = current_rate + alpha * atr
            if prev_stop is None:
                new_stop = candidate
            else:
                new_stop = min(float(prev_stop), candidate)

        _set("atr_trail_stop", float(new_stop))

        # Convert stop price into a relative stoploss (negative float)
        if not is_short:
            rel = (new_stop / current_rate) - 1.0
        else:
            # For shorts: stop triggers when price rises above stop.
            # Relative "loss" if price moves from current_rate to stop.
            rel = 1.0 - (new_stop / current_rate)

        # Clamp: Freqtrade expects >= -1 and <= 0 for stoploss.
        rel = float(np.clip(rel, -0.99, 0.0))
        return rel

    # Asymmetric 70/30 allocation (approx)
    def custom_stake_amount(
        self,
        pair: str,
        current_time,
        current_rate,
        proposed_stake: float,
        min_stake: float,
        max_stake: float,
        leverage: float,
        entry_tag: Optional[str] = None,
        side: Optional[str] = None,
        **kwargs
    ) -> float:
        """
        Paper uses λ=0.7 long / 0.3 short.
        True equal-weight across each leg requires portfolio-level coordination.
        Here we approximate by scaling stake per trade based on side.
        """
        lam = float(self.long_alloc.value)
        lam = float(np.clip(lam, 0.01, 0.99))
        # Relative short vs long sizing
        short_scale = (1.0 - lam) / lam

        stake = proposed_stake
        if side is not None and side.lower() == "short":
            stake = proposed_stake * short_scale

        return float(np.clip(stake, min_stake, max_stake))

    # Optional: time-based safety exit (paper’s system is stop-driven; keep if you want)
    HOLD_DAYS = 60

    def custom_exit(self, pair, trade, current_time, current_rate, current_profit, **kwargs):
        # prevent zombie positions
        if current_time - trade.open_date_utc >= timedelta(days=self.HOLD_DAYS):
            return "time_exit"
        return None