from __future__ import annotations from datetime import timedelta 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, informative, stoploss_from_absolute class Top9RegimeStructureStrategy(IStrategy): """ Clean V1 redesign of the old Top9 regime/reversal family. V1 intentionally excludes reversal entries. The goal is to validate a shorter, more transparent 1h/1d regime-structure strategy before adding reversal and live-trading capital caps in later versions. """ INTERFACE_VERSION = 3 can_short = True timeframe = "1h" process_only_new_candles = True startup_candle_count = 240 use_exit_signal = True exit_profit_only = False ignore_roi_if_entry_signal = True use_custom_stoploss = True minimal_roi = {"0": 0.10} stoploss = -0.03 allowed_pairs = { "BTC/USDT:USDT", "ETH/USDT:USDT", "BNB/USDT:USDT", "SOL/USDT:USDT", "TRX/USDT:USDT", "ADA/USDT:USDT", "ZEC/USDT:USDT", "XRP/USDT:USDT", "DOGE/USDT:USDT", } pair_multipliers = { "BTC/USDT:USDT": 1.00, "ETH/USDT:USDT": 1.00, "SOL/USDT:USDT": 1.00, "BNB/USDT:USDT": 0.90, "XRP/USDT:USDT": 0.80, "ADA/USDT:USDT": 0.75, "DOGE/USDT:USDT": 0.60, "TRX/USDT:USDT": 0.70, "ZEC/USDT:USDT": 0.80, } signal_multipliers = { "long_trend_breakout": 1.00, "long_pullback_restart": 0.90, "short_trend_breakdown": 0.80, "short_pullback_fail": 0.60, } enable_long_trend_breakout = True enable_long_pullback_restart = True enable_short_trend_breakdown = True enable_short_pullback_fail = True allow_range_short_trend = True require_4h_short_trend = False require_4h_short_breakdown = False require_major_4h_short_breakdown = False major_pairs = {"BTC/USDT:USDT", "ETH/USDT:USDT"} use_signal_quality_stake_scale = False require_4h_long_trend = False require_strong_bull_long = False use_v2_long_breakout = False @property def protections(self): return [ {"method": "CooldownPeriod", "stop_duration_candles": 3}, { "method": "StoplossGuard", "lookback_period_candles": 60, "trade_limit": 2, "stop_duration_candles": 14, "only_per_pair": False, }, { "method": "MaxDrawdown", "lookback_period_candles": 96, "trade_limit": 10, "stop_duration_candles": 24, "max_allowed_drawdown": 0.10, }, ] @staticmethod def _safe_div(a, b): return a / b.replace(0, np.nan) @staticmethod def _crossed_above(series: pd.Series, level: pd.Series) -> pd.Series: return (series > level) & (series.shift(1) <= level.shift(1)) @staticmethod def _crossed_below(series: pd.Series, level: pd.Series) -> pd.Series: return (series < level) & (series.shift(1) >= level.shift(1)) @staticmethod def _trend_indicators(dataframe: DataFrame) -> DataFrame: dataframe["ema_fast"] = ta.EMA(dataframe, timeperiod=20) dataframe["ema_slow"] = ta.EMA(dataframe, timeperiod=50) dataframe["rsi"] = ta.RSI(dataframe, timeperiod=14) dataframe["atr"] = ta.ATR(dataframe, timeperiod=14) dataframe["atr_pct"] = dataframe["atr"] / dataframe["close"] dataframe["volume_mean"] = dataframe["volume"].shift(1).rolling(20).mean() dataframe["recent_high"] = dataframe["high"].shift(1).rolling(24).max() dataframe["recent_low"] = dataframe["low"].shift(1).rolling(24).min() dataframe["range_high"] = dataframe["high"].shift(1).rolling(12).max() dataframe["range_low"] = dataframe["low"].shift(1).rolling(12).min() dataframe["range_width"] = (dataframe["range_high"] - dataframe["range_low"]) / dataframe["close"] dataframe["range_tight"] = dataframe["range_width"] < 0.045 dataframe["body_pct"] = (dataframe["close"] - dataframe["open"]).abs() / dataframe["close"] dataframe["ema_slow_slope"] = dataframe["ema_slow"] - dataframe["ema_slow"].shift(6) dataframe["ema_slow_slope_up"] = dataframe["ema_slow_slope"] > 0 dataframe["ema_slow_slope_down"] = dataframe["ema_slow_slope"] < 0 dataframe["center"] = (dataframe["high"] + dataframe["low"] + dataframe["close"]) / 3.0 dataframe["center_ma"] = dataframe["center"].rolling(5).mean() dataframe["center_up"] = dataframe["center_ma"] > dataframe["center_ma"].shift(3) dataframe["center_down"] = dataframe["center_ma"] < dataframe["center_ma"].shift(3) dataframe["structure_stop_long"] = dataframe["low"].shift(1).rolling(8).min() dataframe["structure_stop_short"] = dataframe["high"].shift(1).rolling(8).max() return dataframe @informative("1d") def populate_indicators_1d(self, dataframe: DataFrame, metadata: dict) -> DataFrame: dataframe = self._trend_indicators(dataframe) bull_strong = ( (dataframe["close"] > dataframe["ema_fast"]) & (dataframe["ema_fast"] > dataframe["ema_slow"]) & (dataframe["rsi"] > 58) & dataframe["ema_slow_slope_up"] ) bull_weak = ( ~bull_strong & (dataframe["close"] > dataframe["ema_slow"]) & (dataframe["rsi"] > 48) ) bear = ( (dataframe["close"] < dataframe["ema_fast"]) & (dataframe["ema_fast"] < dataframe["ema_slow"]) & (dataframe["rsi"] < 45) & dataframe["ema_slow_slope_down"] ) dataframe["regime_id"] = 0 dataframe.loc[bull_strong, "regime_id"] = 3 dataframe.loc[bull_weak, "regime_id"] = 2 dataframe.loc[bear, "regime_id"] = -1 dataframe["regime_long_multiplier"] = np.select( [dataframe["regime_id"].eq(3), dataframe["regime_id"].eq(2), dataframe["regime_id"].eq(-1)], [1.10, 0.90, 0.40], default=0.60, ) dataframe["regime_short_multiplier"] = np.select( [dataframe["regime_id"].eq(3), dataframe["regime_id"].eq(2), dataframe["regime_id"].eq(-1)], [0.20, 0.50, 1.00], default=0.60, ) return dataframe @informative("4h") def populate_indicators_4h(self, dataframe: DataFrame, metadata: dict) -> DataFrame: dataframe = self._trend_indicators(dataframe) dataframe["trend_short_ok"] = ( (dataframe["close"] < dataframe["ema_fast"]) & (dataframe["ema_fast"] < dataframe["ema_slow"]) & dataframe["ema_slow_slope_down"] & (dataframe["rsi"] < 48) ) dataframe["breakdown_short_ok"] = ( dataframe["trend_short_ok"] & (dataframe["close"] < dataframe["recent_low"] * 0.997) & (dataframe["body_pct"] > 0.006) ) dataframe["trend_long_ok"] = ( (dataframe["close"] > dataframe["ema_fast"]) & (dataframe["ema_fast"] > dataframe["ema_slow"]) & dataframe["ema_slow_slope_up"] & (dataframe["rsi"] > 52) ) return dataframe def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: dataframe = self._trend_indicators(dataframe) dataframe["vol_ok"] = dataframe["volume"] > dataframe["volume_mean"] * 1.20 dataframe["breakout_vol_ok"] = dataframe["volume"] > dataframe["volume_mean"] * 1.70 dataframe["volatility_ok"] = dataframe["atr_pct"] < 0.060 dataframe["pullback_seen_long"] = ( (dataframe["low"].shift(1).rolling(8).min() < dataframe["ema_fast"].shift(1) * 1.006) | (dataframe["low"].shift(1).rolling(8).min() < dataframe["ema_slow"].shift(1) * 1.010) ) dataframe["pullback_seen_short"] = ( (dataframe["high"].shift(1).rolling(8).max() > dataframe["ema_fast"].shift(1) * 0.994) | (dataframe["high"].shift(1).rolling(8).max() > dataframe["ema_slow"].shift(1) * 0.990) ) return dataframe def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: if metadata["pair"] not in self.allowed_pairs: return dataframe regime = dataframe.get("regime_id_1d", pd.Series(0, index=dataframe.index)).fillna(0) bull_strong = regime.eq(3) bull_weak = regime.eq(2) bear = regime.eq(-1) range_regime = regime.eq(0) base_filter = dataframe["vol_ok"] & dataframe["volatility_ok"] & (dataframe["volume"] > 0) trend_4h_short_ok = dataframe.get("trend_short_ok_4h", pd.Series(True, index=dataframe.index)).fillna(False) breakdown_4h_short_ok = dataframe.get("breakdown_short_ok_4h", pd.Series(True, index=dataframe.index)).fillna(False) trend_4h_long_ok = dataframe.get("trend_long_ok_4h", pd.Series(True, index=dataframe.index)).fillna(False) long_4h_trend_filter = trend_4h_long_ok if self.require_4h_long_trend else True short_4h_trend_filter = trend_4h_short_ok if self.require_4h_short_trend else True pair_needs_major_breakdown = ( self.require_major_4h_short_breakdown and metadata["pair"] in self.major_pairs ) short_4h_breakdown_filter = ( breakdown_4h_short_ok if self.require_4h_short_breakdown or pair_needs_major_breakdown else True ) if self.use_v2_long_breakout: long_regime = bull_strong if self.require_strong_bull_long else (bull_strong | bull_weak) long_trend_breakout = ( long_regime & base_filter & long_4h_trend_filter & dataframe["breakout_vol_ok"] & dataframe["range_tight"] & dataframe["ema_slow_slope_up"] & (dataframe["rsi"] > 55) & (dataframe["rsi"] < 72) & (dataframe["close"] > dataframe["recent_high"] * 1.012) & (dataframe["body_pct"] > 0.010) ) else: long_trend_breakout = ( (bull_strong | bull_weak) & base_filter & dataframe["breakout_vol_ok"] & dataframe["range_tight"] & dataframe["ema_slow_slope_up"] & (dataframe["rsi"] > 54) & (dataframe["close"] > dataframe["recent_high"] * 1.010) & (dataframe["body_pct"] > 0.010) ) long_pullback_restart = ( (bull_strong | bull_weak | range_regime) & base_filter & ~bear & dataframe["pullback_seen_long"] & self._crossed_above(dataframe["close"], dataframe["ema_fast"]) & dataframe["center_up"] & (dataframe["rsi"] > 52) & (dataframe["rsi"] < 68) ) short_trend_regime = bear | range_regime if self.allow_range_short_trend else bear short_trend_breakdown = ( short_trend_regime & base_filter & short_4h_trend_filter & short_4h_breakdown_filter & dataframe["breakout_vol_ok"] & dataframe["ema_slow_slope_down"] & (dataframe["rsi"] < 46) & (dataframe["close"] < dataframe["recent_low"] * 0.990) & (dataframe["body_pct"] > 0.010) ) short_pullback_fail = ( bear & base_filter & dataframe["pullback_seen_short"] & self._crossed_below(dataframe["close"], dataframe["ema_fast"]) & dataframe["center_down"] & (dataframe["rsi"] < 48) ) if self.enable_long_trend_breakout: dataframe.loc[long_trend_breakout, ["enter_long", "enter_tag"]] = (1, "long_trend_breakout") if self.enable_long_pullback_restart: dataframe.loc[long_pullback_restart, ["enter_long", "enter_tag"]] = (1, "long_pullback_restart") if self.enable_short_trend_breakdown: dataframe.loc[short_trend_breakdown, ["enter_short", "enter_tag"]] = (1, "short_trend_breakdown") if self.enable_short_pullback_fail: dataframe.loc[short_pullback_fail, ["enter_short", "enter_tag"]] = (1, "short_pullback_fail") return dataframe def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: return dataframe def _current_candle(self, pair: str): if not self.dp: return None dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe) if dataframe.empty: return None return dataframe.iloc[-1] def _signal_quality_multiplier( self, pair: str, candle, entry_tag: Optional[str], side: str, ) -> float: if not self.use_signal_quality_stake_scale or candle is None: return 1.0 if side == "long" and entry_tag == "long_trend_breakout": quality = 0.78 if bool(candle.get("trend_long_ok_4h", False)): quality *= 1.08 else: quality *= 0.70 regime = int(candle.get("regime_id_1d", 0) or 0) if regime == 3: quality *= 1.10 elif regime == 2: quality *= 0.86 else: quality *= 0.60 rsi = float(candle.get("rsi", 50.0) or 50.0) body_pct = float(candle.get("body_pct", 0.0) or 0.0) volume_mean = float(candle.get("volume_mean", 0.0) or 0.0) volume = float(candle.get("volume", 0.0) or 0.0) if 58 <= rsi <= 68: quality *= 1.08 elif rsi > 72: quality *= 0.76 if body_pct > 0.018: quality *= 1.06 elif body_pct < 0.012: quality *= 0.92 if volume_mean > 0: volume_ratio = volume / volume_mean if volume_ratio > 2.4: quality *= 1.06 elif volume_ratio < 1.8: quality *= 0.90 return max(0.40, min(quality, 1.05)) if side != "short" or entry_tag != "short_trend_breakdown": return 1.0 quality = 1.0 if bool(candle.get("breakdown_short_ok_4h", False)): quality *= 1.08 elif bool(candle.get("trend_short_ok_4h", False)): quality *= 0.92 else: quality *= 0.75 if pair in self.major_pairs: quality *= 0.82 rsi = float(candle.get("rsi", 50.0) or 50.0) body_pct = float(candle.get("body_pct", 0.0) or 0.0) volume_mean = float(candle.get("volume_mean", 0.0) or 0.0) volume = float(candle.get("volume", 0.0) or 0.0) if rsi < 38: quality *= 1.08 elif rsi > 44: quality *= 0.88 if body_pct > 0.018: quality *= 1.06 elif body_pct < 0.012: quality *= 0.92 if volume_mean > 0: volume_ratio = volume / volume_mean if volume_ratio > 2.4: quality *= 1.06 elif volume_ratio < 1.8: quality *= 0.92 return max(0.55, min(quality, 1.18)) def custom_stake_amount( self, pair: str, current_time, current_rate: float, proposed_stake: float, min_stake: Optional[float], max_stake: float, leverage: float, entry_tag: Optional[str], side: str, **kwargs, ) -> float: candle = self._current_candle(pair) signal_mult = self.signal_multipliers.get(entry_tag or "", 0.70) pair_mult = self.pair_multipliers.get(pair, 0.70) if candle is None: regime_mult = 1.0 elif side == "short": regime_mult = float(candle.get("regime_short_multiplier_1d", 0.6) or 0.6) else: regime_mult = float(candle.get("regime_long_multiplier_1d", 0.6) or 0.6) quality_mult = self._signal_quality_multiplier(pair, candle, entry_tag, side) final_mult = max(0.35, min(signal_mult * regime_mult * pair_mult * quality_mult, 1.3)) stake = proposed_stake * final_mult if min_stake is not None and stake < min_stake: return 0.0 return min(stake, max_stake) def custom_stoploss( self, pair: str, trade: Trade, current_time, current_rate: float, current_profit: float, after_fill: bool, **kwargs, ) -> Optional[float]: candle = self._current_candle(pair) if candle is None: return self.stoploss if trade.is_short: structure_stop = candle.get("structure_stop_short") capped_stop = trade.open_rate * 1.03 stop_price = capped_stop if pd.isna(structure_stop) else min(float(structure_stop), capped_stop) else: structure_stop = candle.get("structure_stop_long") capped_stop = trade.open_rate * 0.97 stop_price = capped_stop if pd.isna(structure_stop) else max(float(structure_stop), capped_stop) return stoploss_from_absolute( stop_price, current_rate, is_short=trade.is_short, leverage=trade.leverage, ) def custom_exit( self, pair: str, trade: Trade, current_time, current_rate: float, current_profit: float, **kwargs, ) -> Optional[str]: candle = self._current_candle(pair) if candle is None: return None age = current_time - trade.open_date_utc if age > timedelta(hours=72) and current_profit < 0: return "stale_loss_72h" if age > timedelta(hours=120) and current_profit < 0.01: return "stale_flat_120h" if age > timedelta(hours=240) and current_profit < 0.03: return "stale_low_profit_240h" regime = int(candle.get("regime_id_1d", 0) or 0) if trade.is_short: if regime in (2, 3) and bool(candle.get("center_up", False)) and current_profit > -0.01: return "trend_flip_short" if current_profit > 0.005 and bool(candle.get("center_up", False)) and candle["close"] > candle.get("ema_fast", candle["close"]): return "structure_exit_short" stop_short = candle.get("structure_stop_short") if pd.notna(stop_short) and candle["close"] > float(stop_short): return "swing_exit_short" return None if regime == -1 and bool(candle.get("center_down", False)) and current_profit > -0.01: return "trend_flip_long" if current_profit > 0.005 and bool(candle.get("center_down", False)) and candle["close"] < candle.get("ema_fast", candle["close"]): return "structure_exit_long" stop_long = candle.get("structure_stop_long") if pd.notna(stop_long) and candle["close"] < float(stop_long): return "swing_exit_long" return None class Top9RegimeStructureNoLongPullbackStrategy(Top9RegimeStructureStrategy): """V1 experiment: remove the currently weakest long pullback entry.""" enable_long_pullback_restart = False class Top9RegimeStructureTrendOnlyStrategy(Top9RegimeStructureStrategy): """V1 experiment: keep only trend breakout/breakdown entries.""" enable_long_pullback_restart = False enable_short_pullback_fail = False class Top9RegimeStructureCorePairsStrategy(Top9RegimeStructureStrategy): """V1 experiment: remove DOGE/BNB/TRX while keeping the same signal set.""" allowed_pairs = { "BTC/USDT:USDT", "ETH/USDT:USDT", "SOL/USDT:USDT", "ADA/USDT:USDT", "ZEC/USDT:USDT", "XRP/USDT:USDT", } class Top9RegimeStructureCoreTrendOnlyStrategy(Top9RegimeStructureCorePairsStrategy): """V1 experiment: core pairs plus only trend breakout/breakdown entries.""" enable_long_pullback_restart = False enable_short_pullback_fail = False class Top9RegimeStructureShortOnlyStrategy(Top9RegimeStructureStrategy): """V1 experiment: disable longs and keep both short entries.""" enable_long_trend_breakout = False enable_long_pullback_restart = False class Top9RegimeStructureShortTrendOnlyStrategy(Top9RegimeStructureStrategy): """V1 experiment: keep only the strongest short trend breakdown entry.""" enable_long_trend_breakout = False enable_long_pullback_restart = False enable_short_pullback_fail = False class Top9RegimeStructureCoreShortOnlyStrategy(Top9RegimeStructureCorePairsStrategy): """V1 experiment: core pairs, short entries only.""" enable_long_trend_breakout = False enable_long_pullback_restart = False class Top9RegimeStructureCoreShortOnlyTightStopStrategy(Top9RegimeStructureCoreShortOnlyStrategy): """V1 experiment: core short-only with a tighter 2% structural stop cap.""" stoploss = -0.02 def custom_stoploss( self, pair: str, trade: Trade, current_time, current_rate: float, current_profit: float, after_fill: bool, **kwargs, ) -> Optional[float]: candle = self._current_candle(pair) if candle is None: return self.stoploss if trade.is_short: structure_stop = candle.get("structure_stop_short") capped_stop = trade.open_rate * 1.02 stop_price = capped_stop if pd.isna(structure_stop) else min(float(structure_stop), capped_stop) else: structure_stop = candle.get("structure_stop_long") capped_stop = trade.open_rate * 0.98 stop_price = capped_stop if pd.isna(structure_stop) else max(float(structure_stop), capped_stop) return stoploss_from_absolute( stop_price, current_rate, is_short=trade.is_short, leverage=trade.leverage, ) class Top9RegimeStructureCoreShortTrendTightStopStrategy( Top9RegimeStructureCoreShortOnlyTightStopStrategy ): """V1 experiment: core pairs, tight stop, only short trend breakdown.""" enable_short_pullback_fail = False class Top9RegimeStructureCoreShortPullbackTightStopStrategy( Top9RegimeStructureCoreShortOnlyTightStopStrategy ): """V1 experiment: core pairs, tight stop, only short pullback fail.""" enable_short_trend_breakdown = False class Top9RegimeStructureAltShortOnlyTightStopStrategy( Top9RegimeStructureCoreShortOnlyTightStopStrategy ): """V1 experiment: remove currently weak BTC/ETH from the short-only basket.""" allowed_pairs = { "SOL/USDT:USDT", "ADA/USDT:USDT", "ZEC/USDT:USDT", "XRP/USDT:USDT", } class Top9RegimeStructureAltShortTrendTightStopStrategy( Top9RegimeStructureAltShortOnlyTightStopStrategy ): """V1 experiment: alt basket, tight stop, only short trend breakdown.""" enable_short_pullback_fail = False class Top9RegimeStructureBearOnlyShortTrendTightStopStrategy( Top9RegimeStructureCoreShortTrendTightStopStrategy ): """V1 experiment: core trend shorts, but reject range-regime breakdowns.""" allow_range_short_trend = False class Top9RegimeStructureAltBearOnlyShortTrendTightStopStrategy( Top9RegimeStructureAltShortTrendTightStopStrategy ): """V1 experiment: alt trend shorts, but reject range-regime breakdowns.""" allow_range_short_trend = False class Top9RegimeStructureBear4hShortTrendTightStopStrategy( Top9RegimeStructureBearOnlyShortTrendTightStopStrategy ): """V1 experiment: 1d bear trend shorts need 4h bearish confirmation.""" require_4h_short_trend = True class Top9RegimeStructureAltBear4hShortTrendTightStopStrategy( Top9RegimeStructureAltBearOnlyShortTrendTightStopStrategy ): """V1 experiment: alt bear trend shorts need 4h bearish confirmation.""" require_4h_short_trend = True class Top9RegimeStructureBear4hBreakdownShortTrendTightStopStrategy( Top9RegimeStructureBearOnlyShortTrendTightStopStrategy ): """V1 experiment: 1d bear trend shorts need a 4h structural breakdown.""" require_4h_short_trend = True require_4h_short_breakdown = True class Top9RegimeStructureMajorStrict4hShortTrendTightStopStrategy( Top9RegimeStructureBear4hShortTrendTightStopStrategy ): """V1 experiment: 4h trend for all shorts, stricter 4h breakdown for BTC/ETH.""" require_major_4h_short_breakdown = True class Top9RegimeStructureQualityStake4hShortTrendTightStopStrategy( Top9RegimeStructureBear4hShortTrendTightStopStrategy ): """V1 experiment: keep V1.16 entries, but scale stake by signal quality.""" use_signal_quality_stake_scale = True class Top9RegimeStructureV2LongBreakoutQualityStrategy( Top9RegimeStructureQualityStake4hShortTrendTightStopStrategy ): """V2.1/V2.2: add conservative bull-regime long breakouts to V1.20.""" enable_long_trend_breakout = True enable_long_pullback_restart = False require_4h_long_trend = True require_strong_bull_long = True use_v2_long_breakout = True signal_multipliers = { **Top9RegimeStructureQualityStake4hShortTrendTightStopStrategy.signal_multipliers, "long_trend_breakout": 0.72, }