"""BTC/USDT multi-timeframe trend-pullback research strategy, V4.1. Market: Binance BTC/USDT spot, long only Execution timeframe: completed 5-minute candles Macro context: completed native 1-hour candles V4.1 removes V4's squeeze/breakout entry and instead enters a confirmed bounce from 5m EMA support during a healthy bullish 1h trend. Its initial ATR stop is wider, and its activated stop ratchets from breakeven behind the highest completed 5m close without ever loosening. """ from datetime import datetime from math import isfinite import pandas as pd import talib.abstract as ta from pandas import DataFrame from freqtrade.persistence import Trade from freqtrade.strategy import IStrategy, merge_informative_pair, stoploss_from_absolute class BTCMomentumV4_1_TrendPullback(IStrategy): INTERFACE_VERSION = 3 timeframe = "5m" informative_timeframes = ("1h",) can_short = False minimal_roi = {} use_exit_signal = True exit_profit_only = False ignore_roi_if_entry_signal = False # The custom ATR stop is the normal risk control. This fixed value is a # wide emergency floor in case analyzed candle data is unavailable. stoploss = -0.10 use_custom_stoploss = True trailing_stop = False process_only_new_candles = True order_types = { "entry": "market", "exit": "market", "stoploss": "market", "stoploss_on_exchange": False, } # Applied to each requested timeframe; sufficient for the 1h EMA(200) # and all 5m pullback, volume, and exit references. startup_candle_count: int = 250 # Native 5m pullback and bounce parameters. EMA_5M_FAST_PERIOD = 20 EMA_5M_TREND_PERIOD = 50 PULLBACK_LOOKBACK = 3 MIN_BODY_RATIO = 0.50 VOLUME_SMA_PERIOD = 20 ATR_5M_PERIOD = 14 # Completed native 1h macro-trend parameters. EMA_1H_FAST_PERIOD = 50 EMA_1H_SLOW_PERIOD = 200 ADX_1H_PERIOD = 14 ADX_ENTRY_THRESHOLD = 18.0 RSI_1H_PERIOD = 14 RSI_ENTRY_MIN = 48.0 RSI_ENTRY_MAX = 72.0 # Initial risk, activated breakeven step, and trailing ratchet. INITIAL_ATR_MULTIPLIER = 2.25 RATCHET_ACTIVATION_PROFIT = 0.0075 BREAKEVEN_LOCK_PROFIT = 0.0005 TRAILING_ATR_MULTIPLIER = 1.75 EMA_EXIT_PROFIT_GATE = 0.0100 _ENTRY_ATR_KEY = "btc_momentum_v4_1_entry_atr" _HIGHEST_CLOSE_KEY = "btc_momentum_v4_1_highest_close" _EFFECTIVE_STOP_KEY = "btc_momentum_v4_1_effective_stop" _RATCHET_ACTIVE_KEY = "btc_momentum_v4_1_ratchet_active" def informative_pairs(self): """Load native 1h candles for every configured spot pair.""" return [ (pair, timeframe) for pair in self.dp.current_whitelist() for timeframe in self.informative_timeframes ] def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: # -------------------------- native 5m --------------------------- dataframe["atr_14"] = ta.ATR( dataframe, timeperiod=self.ATR_5M_PERIOD, ) dataframe["ema_20"] = ta.EMA( dataframe, timeperiod=self.EMA_5M_FAST_PERIOD, ) dataframe["ema_50"] = ta.EMA( dataframe, timeperiod=self.EMA_5M_TREND_PERIOD, ) dataframe["volume_sma_20"] = dataframe["volume"].rolling( self.VOLUME_SMA_PERIOD, min_periods=self.VOLUME_SMA_PERIOD, ).mean() # Includes the current completed candle and the prior two candles. dataframe["pullback_touched_ema20"] = ( (dataframe["low"] <= dataframe["ema_20"]) .rolling( self.PULLBACK_LOOKBACK, min_periods=self.PULLBACK_LOOKBACK, ) .max() .eq(1.0) ) dataframe["previous_high"] = dataframe["high"].shift(1) candle_range = dataframe["high"] - dataframe["low"] dataframe["bullish_body_ratio"] = ( (dataframe["close"] - dataframe["open"]) / candle_range.where(candle_range > 0) ) # -------------------------- native 1h --------------------------- informative_1h = self.dp.get_pair_dataframe( pair=metadata["pair"], timeframe="1h", ).copy() informative_1h["ema_50"] = ta.EMA( informative_1h, timeperiod=self.EMA_1H_FAST_PERIOD, ) informative_1h["ema_200"] = ta.EMA( informative_1h, timeperiod=self.EMA_1H_SLOW_PERIOD, ) informative_1h["adx_14"] = ta.ADX( informative_1h, timeperiod=self.ADX_1H_PERIOD, ) informative_1h["rsi_14"] = ta.RSI( informative_1h, timeperiod=self.RSI_1H_PERIOD, ) dataframe = merge_informative_pair( dataframe, informative_1h, self.timeframe, "1h", ffill=True, ) # merge_informative_pair delays each 1h row until that native candle # has closed, so unfinished macro values are never visible to 5m. return dataframe def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: macro_trend = ( (dataframe["close_1h"] > dataframe["ema_50_1h"]) & (dataframe["ema_50_1h"] > dataframe["ema_200_1h"]) & (dataframe["adx_14_1h"] > self.ADX_ENTRY_THRESHOLD) & (dataframe["rsi_14_1h"] > self.RSI_ENTRY_MIN) & (dataframe["rsi_14_1h"] < self.RSI_ENTRY_MAX) ) local_trend = dataframe["close"] > dataframe["ema_50"] bullish_confirmation = ( (dataframe["close"] > dataframe["open"]) & (dataframe["close"] > dataframe["previous_high"]) & (dataframe["bullish_body_ratio"] >= self.MIN_BODY_RATIO) & (dataframe["volume"] > dataframe["volume_sma_20"]) ) enter_long = ( macro_trend & local_trend & dataframe["pullback_touched_ema20"] & bullish_confirmation & (dataframe["volume"] > 0) ) dataframe.loc[enter_long, ["enter_long", "enter_tag"]] = ( 1, "mtf_trend_pullback_bounce", ) return dataframe def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: # The profit-gated EMA exhaustion is evaluated in custom_exit, which # can inspect each trade's achieved gross profit. dataframe["exit_long"] = 0 return dataframe @classmethod def _next_stop_price( cls, entry_price: float, entry_atr: float, previous_effective_stop: float | None, highest_completed_close: float | None, current_atr: float, ratchet_active: bool, ) -> float: """Return the monotonic absolute stop price for a long trade.""" initial_stop = entry_price - cls.INITIAL_ATR_MULTIPLIER * entry_atr candidates = [initial_stop] if previous_effective_stop is not None and isfinite(previous_effective_stop): candidates.append(previous_effective_stop) if ratchet_active: candidates.append( entry_price * (1.0 + cls.BREAKEVEN_LOCK_PROFIT) ) if ( highest_completed_close is not None and isfinite(highest_completed_close) and isfinite(current_atr) and current_atr > 0 ): candidates.append( highest_completed_close - cls.TRAILING_ATR_MULTIPLIER * current_atr ) return max(candidates) def _completed_candles( self, pair: str, current_time: datetime, ) -> DataFrame: """Return only 5m candles completed by the callback timestamp.""" dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe) if dataframe.empty: return dataframe candle_cutoff = pd.Timestamp(current_time) - pd.Timedelta(minutes=5) return dataframe.loc[dataframe["date"] <= candle_cutoff] def custom_stoploss( self, pair: str, trade: Trade, current_time: datetime, current_rate: float, current_profit: float, after_fill: bool, **kwargs, ) -> float | None: """Maintain the initial ATR stop and activated monotonic ratchet.""" completed = self._completed_candles(pair, current_time) if completed.empty or "atr_14" not in completed.columns: return None current_atr = float(completed.iloc[-1]["atr_14"]) if not isfinite(current_atr) or current_atr <= 0: return None entry_atr = trade.get_custom_data(self._ENTRY_ATR_KEY) if entry_atr is None: # At next-candle market entry this is the signal candle's ATR, # which is the latest completed 5m ATR available at entry time. entry_atr = current_atr trade.set_custom_data(self._ENTRY_ATR_KEY, float(entry_atr)) entry_atr = float(entry_atr) post_entry = completed.loc[ completed["date"] >= pd.Timestamp(trade.open_date_utc) ] stored_highest = trade.get_custom_data(self._HIGHEST_CLOSE_KEY) highest_completed_close = ( float(stored_highest) if stored_highest is not None else None ) if not post_entry.empty: observed_highest = float(post_entry["close"].max()) if isfinite(observed_highest): highest_completed_close = ( observed_highest if highest_completed_close is None else max(highest_completed_close, observed_highest) ) trade.set_custom_data( self._HIGHEST_CLOSE_KEY, highest_completed_close, ) ratchet_active = bool( trade.get_custom_data(self._RATCHET_ACTIVE_KEY, False) ) observed_peak_rate = max( float(trade.max_rate or trade.open_rate), float(current_rate), ) if observed_peak_rate >= float(trade.open_rate) * ( 1.0 + self.RATCHET_ACTIVATION_PROFIT ): ratchet_active = True trade.set_custom_data(self._RATCHET_ACTIVE_KEY, True) stored_stop = trade.get_custom_data(self._EFFECTIVE_STOP_KEY) previous_effective_stop = ( float(stored_stop) if stored_stop is not None else None ) effective_stop = self._next_stop_price( entry_price=float(trade.open_rate), entry_atr=entry_atr, previous_effective_stop=previous_effective_stop, highest_completed_close=highest_completed_close, current_atr=current_atr, ratchet_active=ratchet_active, ) trade.set_custom_data(self._EFFECTIVE_STOP_KEY, float(effective_stop)) distance = stoploss_from_absolute( stop_rate=effective_stop, current_rate=current_rate, is_short=trade.is_short, leverage=trade.leverage or 1.0, ) return distance if distance > 0 else None def custom_exit( self, pair: str, trade: Trade, current_time: datetime, current_rate: float, current_profit: float, **kwargs, ) -> str | None: """Exit after a +1% peak when a completed 5m close is below EMA(50).""" completed = self._completed_candles(pair, current_time) if completed.empty: return None current = completed.iloc[-1] peak_rate = max( float(trade.max_rate or trade.open_rate), float(current_rate), ) achieved_profit = peak_rate / float(trade.open_rate) - 1.0 if ( achieved_profit >= self.EMA_EXIT_PROFIT_GATE and pd.notna(current.get("ema_50")) and float(current["close"]) < float(current["ema_50"]) ): return "ema50_exhaustion_after_1pct" return None