""" Plan-compliant BTC/USDT momentum baseline, version 1. Market: Binance BTC/USDT spot Signal timeframe: 1 minute Informative timeframe: 5 minutes (completed candles only) Direction: long only The informative indicators are calculated on the native 5m dataframe before ``merge_informative_pair`` makes them available to the 1m strategy. The helper delays each informative row until its 5m candle has closed, preventing unfinished higher-timeframe data from leaking into a 1m decision. V1.1 adds exactly one trading-logic change to BTCMomentumV1: long entries also require the completed 1m RSI(14) to be above 55. """ from datetime import datetime from math import isfinite import pandas as pd import talib.abstract as ta from pandas import DataFrame, Series from freqtrade.persistence import Trade from freqtrade.strategy import IStrategy, merge_informative_pair, stoploss_from_absolute class BTCMomentumV1_1_RSI(IStrategy): INTERFACE_VERSION = 3 timeframe = "1m" informative_timeframe = "5m" can_short = False # An empty ROI table disables fixed take-profit exits. The strategy exits # only when the confirmed 5m regime ends or its custom ATR stop is reached. minimal_roi = {} use_exit_signal = True exit_profit_only = False ignore_roi_if_entry_signal = False # Wide emergency floor. Normal risk control is entirely handled by the # ATR-based custom stop below. 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, } # 1,000 1m candles provide 200 5m candles. This covers BB(20), the # 100-candle bandwidth quantile, the six-candle consolidation lookback, # ATR(14), the previous-50 ATR mean, the previous-20 breakout level, # EMA(20), 1m ATR(14), and 1m RSI(14), with additional warmup margin. startup_candle_count: int = 1000 # Frozen 5m consolidation/breakout parameters. ATR_5M_PERIOD = 14 BB_PERIOD = 20 BB_STDDEV = 2.0 BANDWIDTH_QUANTILE_PERIOD = 100 BANDWIDTH_QUANTILE = 0.25 CONSOLIDATION_LOOKBACK = 6 CONSOLIDATION_MIN_COUNT = 4 BREAKOUT_LOOKBACK = 20 ATR_REFERENCE_PERIOD = 50 ATR_EXPANSION_MULTIPLIER = 1.20 REGIME_MAX_CANDLES = 6 EMA_5M_PERIOD = 20 # Frozen 1m risk parameters. ATR_1M_PERIOD = 14 INITIAL_ATR_MULTIPLIER = 1.25 TRAILING_ATR_MULTIPLIER = 1.50 _ENTRY_ATR_KEY = "btc_momentum_v1_entry_atr" _HIGHEST_CLOSE_KEY = "btc_momentum_v1_highest_close" _EFFECTIVE_STOP_KEY = "btc_momentum_v1_effective_stop" def informative_pairs(self): """Load 5m candles for every configured (BTC/USDT) spot pair.""" return [ (pair, self.informative_timeframe) for pair in self.dp.current_whitelist() ] @classmethod def _calculate_bullish_regime( cls, breakout: Series, close: Series, ema: Series, ) -> Series: """ Build a causal regime state. A breakout starts (or refreshes) a six-completed-candle window, including the breakout candle. An EMA breach ends that regime immediately and it cannot reactivate without another breakout. """ remaining = 0 active: list[bool] = [] for breakout_now, close_now, ema_now in zip(breakout, close, ema): if bool(breakout_now): remaining = cls.REGIME_MAX_CANDLES above_ema = ( pd.notna(close_now) and pd.notna(ema_now) and float(close_now) > float(ema_now) ) regime_now = remaining > 0 and above_ema active.append(regime_now) if regime_now: remaining -= 1 else: # Once price loses EMA(20), the old breakout cannot reactivate # the regime even if price recovers inside the original window. remaining = 0 return Series(active, index=breakout.index, dtype=bool) def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: informative = self.dp.get_pair_dataframe( pair=metadata["pair"], timeframe=self.informative_timeframe, ).copy() # ------------------------- 5m indicators ------------------------- informative["atr"] = ta.ATR( informative, timeperiod=self.ATR_5M_PERIOD, ) bb_upper, bb_middle, bb_lower = ta.BBANDS( informative["close"], timeperiod=self.BB_PERIOD, nbdevup=self.BB_STDDEV, nbdevdn=self.BB_STDDEV, matype=0, ) informative["bb_upper"] = bb_upper informative["bb_middle"] = bb_middle informative["bb_lower"] = bb_lower informative["bb_bandwidth"] = ( (informative["bb_upper"] - informative["bb_lower"]) / informative["bb_middle"] ) informative["bandwidth_q25"] = informative["bb_bandwidth"].rolling( self.BANDWIDTH_QUANTILE_PERIOD, min_periods=self.BANDWIDTH_QUANTILE_PERIOD, ).quantile(self.BANDWIDTH_QUANTILE) informative["consolidation"] = ( informative["bb_bandwidth"] < informative["bandwidth_q25"] ).fillna(False) # The breakout candle itself is deliberately excluded from all three # "previous" references below. previous_consolidations = ( informative["consolidation"] .astype(int) .shift(1) .rolling( self.CONSOLIDATION_LOOKBACK, min_periods=self.CONSOLIDATION_LOOKBACK, ) .sum() ) informative["recent_consolidation"] = ( previous_consolidations >= self.CONSOLIDATION_MIN_COUNT ).fillna(False) informative["previous_breakout_level"] = ( informative["high"] .shift(1) .rolling(self.BREAKOUT_LOOKBACK, min_periods=self.BREAKOUT_LOOKBACK) .max() ) informative["previous_atr_reference"] = ( informative["atr"] .shift(1) .rolling( self.ATR_REFERENCE_PERIOD, min_periods=self.ATR_REFERENCE_PERIOD, ) .mean() ) informative["breakout"] = ( informative["recent_consolidation"] & ( informative["close"] > informative["previous_breakout_level"] ) & ( informative["atr"] > self.ATR_EXPANSION_MULTIPLIER * informative["previous_atr_reference"] ) ).fillna(False) informative["ema_20"] = ta.EMA( informative, timeperiod=self.EMA_5M_PERIOD, ) informative["bullish_regime"] = self._calculate_bullish_regime( informative["breakout"], informative["close"], informative["ema_20"], ) # merge_informative_pair shifts each 5m row to the first compatible # 1m row whose close is at or after that 5m candle's close. dataframe = merge_informative_pair( dataframe, informative, self.timeframe, self.informative_timeframe, ffill=True, ) # -------------------------- 1m indicators ------------------------- dataframe["atr_1m"] = ta.ATR( dataframe, timeperiod=self.ATR_1M_PERIOD, ) # V1.1 FILTER (the only trading-logic addition): 1m RSI(14). dataframe["rsi_14_1m"] = ta.RSI(dataframe, timeperiod=14) return dataframe def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: raw_entry = ( dataframe["bullish_regime_5m"].fillna(False).astype(bool) & (dataframe["close"] > dataframe["high"].shift(1)) & (dataframe["volume"] > 0) & (dataframe["rsi_14_1m"] > 55.0) ) dataframe["raw_entry"] = raw_entry.astype(int) dataframe.loc[raw_entry, ["enter_long", "enter_tag"]] = ( 1, "bullish_breakout_regime", ) return dataframe def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: regime = dataframe["bullish_regime_5m"].fillna(False).astype(bool) regime_ended = regime.shift(1).fillna(False) & ~regime dataframe.loc[regime_ended, ["exit_long", "exit_tag"]] = ( 1, "bullish_regime_ended", ) 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, ) -> 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 ( 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 custom_stoploss( self, pair: str, trade: Trade, current_time: datetime, current_rate: float, current_profit: float, after_fill: bool, **kwargs, ) -> float | None: """ Maintain the ATR stop using only candles completed by ``current_time``. Freqtrade also enforces monotonic stop movement internally. Persisting the absolute effective stop here makes the same invariant explicit and keeps it intact across bot loops and restarts. """ dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe) if dataframe.empty or "atr_1m" not in dataframe.columns: return None candle_cutoff = pd.Timestamp(current_time) - pd.Timedelta(minutes=1) completed = dataframe.loc[dataframe["date"] <= candle_cutoff] if completed.empty: return None current_atr = float(completed.iloc[-1]["atr_1m"]) 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 entry time, this is the newest ATR known without peeking into # the just-opened candle. entry_atr = current_atr trade.set_custom_data(self._ENTRY_ATR_KEY, float(entry_atr)) entry_atr = float(entry_atr) # A candle counts "since entry" only after the 1m candle whose open # timestamp is at or later than the fill timestamp has completed. post_entry = completed.loc[ dataframe.loc[completed.index, "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, ) 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, ) 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, ) # A zero distance means price is already beyond the requested stop. # Keeping the engine's previous stop is safer than asking it to refresh # the stop after a gap. return distance if distance > 0 else None