# source: https://raw.githubusercontent.com/AlexEisie/GeniusTraderTaffy/782b201c5a48e7b21748de9d631ae9f0ef369154/strategies/TaffyMomentum.py
# directory_url: https://github.com/AlexEisie/GeniusTraderTaffy/blob/main/strategies/
# User: AlexEisie
# Repository: GeniusTraderTaffy
# --------------------"""
Github_AlexEisie_GeniusTraderTaffy__TaffyMomentum__20260819_144029 — 截面相对动量轮动（现货 / 只做多 / 1h / OKX）
====================================================================
设计依据（2026-02~08 OKX 1h 数据实证，见 analysis/edge_study2.py）：

  * 7 日截面动量 top20% 币种的 48h 前瞻收益 +0.50%（top10% 达 +0.76%），
    显著高于 ~0.20% 的往返成本；中/尾部分组均为负。
  * 4 日尺度方差比 ~0.78：市场整体均值回归，时序突破无边际，
    但"相对强弱"横截面动量依然显著 —— 这是本策略唯一的 alpha 来源。
  * 2026-08 市场处于"选择性轮动"阶段（BTC 横盘、强势山寨轮涨），
    与该边际的结构吻合。

核心规则：
  入场：7d 动量截面排名 >= rank_enter(0.85) 且自身价格站上 EMA200，
        且非抛物线拉升（24h 涨幅 < 20%）、市场广度 >= 0.25。
  出场：排名跌破 rank_exit(0.55)（动量衰减）或 跌破 EMA200 且排名走弱；
        辅以 ATR 自适应移动止损让利润奔跑。
  风控：protections 处理连续止损与组合回撤；广度闸门挡住普跌崩盘。

排名的实现：populate_indicators 中通过 dataprovider 拉取整个白名单的
1h 收盘价，逐时间戳做横截面百分位排名。排名在 t 时刻只使用 <=t 的收盘，
无未来函数；类级缓存避免 O(n^2) 重复计算。
====================================================================
"""

from datetime import datetime

import talib.abstract as ta
from pandas import DataFrame, Series, isna
import pandas as pd

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


class Github_AlexEisie_GeniusTraderTaffy__TaffyMomentum__20260819_144029(IStrategy):
    INTERFACE_VERSION = 3

    timeframe = "1h"
    can_short = False

    # 出场交给信号 + 移动止损，ROI 仅留极端兜底
    minimal_roi = {"0": 10}

    stoploss = -0.10
    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

    # 168h 动量 + EMA200，留缓冲
    startup_candle_count = 400

    # ---------------- 可调参数 ----------------
    rank_enter = DecimalParameter(0.75, 0.95, default=0.85, decimals=2, space="buy", optimize=True)
    rank_exit = DecimalParameter(0.40, 0.70, default=0.55, decimals=2, space="sell", optimize=True)
    breadth_min = DecimalParameter(0.10, 0.40, default=0.25, decimals=2, space="buy", optimize=True)
    max_ret24 = DecimalParameter(0.10, 0.30, default=0.20, decimals=2, space="buy", optimize=True)
    atr_stop_mult = DecimalParameter(1.5, 3.5, default=2.5, decimals=1, space="sell", optimize=True)
    atr_trail_mult = DecimalParameter(1.0, 3.0, default=2.0, decimals=1, space="sell", optimize=True)

    # ---------------- 保护 ----------------
    @property
    def protections(self):
        return [
            {"method": "CooldownPeriod", "stop_duration_candles": 6},
            {
                "method": "MaxDrawdown",
                "lookback_period_candles": 168,
                "trade_limit": 8,
                "stop_duration_candles": 24,
                "max_allowed_drawdown": 0.12,
            },
            {
                "method": "StoplossGuard",
                "lookback_period_candles": 48,
                "trade_limit": 4,
                "stop_duration_candles": 24,
                "only_per_pair": False,
            },
        ]

    # ---------------- 截面表缓存 ----------------
    # 值: (cache_key, DataFrame[date x pair] rank, Series[date] breadth)
    _xs_cache = None

    def informative_pairs(self):
        pairs = self.dp.current_whitelist()
        return [(p, self.timeframe) for p in pairs]

    def _xs_tables(self):
        """跨币种 7d 动量百分位排名 + 市场广度。逐时间戳计算，无未来数据。"""
        pairs = tuple(sorted(self.dp.current_whitelist()))
        closes = {}
        last_dt = None
        for p in pairs:
            df = self.dp.get_pair_dataframe(p, self.timeframe)
            if df is None or df.empty:
                continue
            s = df.set_index("date")["close"]
            closes[p] = s
            if last_dt is None or s.index[-1] > last_dt:
                last_dt = s.index[-1]

        key = (pairs, last_dt)
        if self._xs_cache is not None and self._xs_cache[0] == key:
            return self._xs_cache[1], self._xs_cache[2]

        cm = pd.DataFrame(closes)
        mom = cm.pct_change(168, fill_method=None)
        rank = mom.rank(axis=1, pct=True)
        ema200 = cm.ewm(span=200, min_periods=100).mean()
        above = (cm > ema200) & cm.notna()
        breadth = above.sum(axis=1) / cm.notna().sum(axis=1).clip(lower=1)
        type(self)._xs_cache = (key, rank, breadth)
        return rank, breadth

    # ---------------- 指标 ----------------
    def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
        dataframe["ema200"] = ta.EMA(dataframe, timeperiod=200)
        dataframe["atr"] = ta.ATR(dataframe, timeperiod=14)
        dataframe["atr_pct"] = dataframe["atr"] / dataframe["close"]
        dataframe["ret24"] = dataframe["close"].pct_change(24)

        rank, breadth = self._xs_tables()
        pair = metadata["pair"]
        if pair in rank.columns:
            dataframe["xs_rank"] = dataframe["date"].map(rank[pair])
        else:
            dataframe["xs_rank"] = float("nan")
        dataframe["breadth"] = dataframe["date"].map(breadth)
        return dataframe

    # ---------------- 入场 ----------------
    def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
        dataframe.loc[
            (dataframe["xs_rank"] >= self.rank_enter.value)
            & (dataframe["close"] > dataframe["ema200"])
            & (dataframe["ret24"] < self.max_ret24.value)
            & (dataframe["breadth"] >= self.breadth_min.value)
            & (dataframe["volume"] > 0),
            ["enter_long", "enter_tag"],
        ] = (1, "xs_mom")
        return dataframe

    # ---------------- 出场 ----------------
    def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
        dataframe.loc[
            (dataframe["xs_rank"] < self.rank_exit.value) & (dataframe["volume"] > 0),
            ["exit_long", "exit_tag"],
        ] = (1, "mom_decay")

        dataframe.loc[
            (dataframe["close"] < dataframe["ema200"])
            & (dataframe["xs_rank"] < 0.75)
            & (dataframe["volume"] > 0),
            ["exit_long", "exit_tag"],
        ] = (1, "trend_break")
        return dataframe

    # ---------------- ATR 自适应止损 ----------------
    def custom_stoploss(
        self,
        pair: str,
        trade: Trade,
        current_time: datetime,
        current_rate: float,
        current_profit: float,
        after_fill: bool,
        **kwargs,
    ) -> float | None:
        dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
        if dataframe is None or len(dataframe) == 0:
            return None
        atr_pct = dataframe["atr_pct"].iloc[-1]
        if isna(atr_pct) or atr_pct <= 0:
            return None

        if current_profit < 0.05:
            # 初始阶段：相对开仓价的 ATR 止损，夹在 4%~10%
            initial = min(max(atr_pct * self.atr_stop_mult.value, 0.04), 0.10)
            return stoploss_from_open(
                -initial, current_profit, is_short=trade.is_short, leverage=trade.leverage
            )
        # 盈利 5% 之后：相对现价的 ATR 移动止损，夹在 2.5%~6%
        trail = min(max(atr_pct * self.atr_trail_mult.value, 0.025), 0.06)
        return -trail

    plot_config = {
        "main_plot": {"ema200": {"color": "red"}},
        "subplots": {
            "rank": {"xs_rank": {"color": "blue"}},
            "breadth": {"breadth": {"color": "green"}},
        },
    }
