# pragma pylint: disable=missing-docstring, invalid-name, pointless-string-statement # flake8: noqa: F401 # isort: skip_file # --- Do not remove these imports --- import numpy as np import pandas as pd from datetime import datetime, timedelta, timezone from pandas import DataFrame from typing import Optional, Union from freqtrade.strategy import ( IStrategy, Trade, Order, PairLocks, informative, BooleanParameter, CategoricalParameter, DecimalParameter, IntParameter, RealParameter, timeframe_to_minutes, timeframe_to_next_date, timeframe_to_prev_date, merge_informative_pair, stoploss_from_absolute, stoploss_from_open, ) # -------------------------------- import talib.abstract as ta from technical import qtpylib class BreakoutBBSqueeze_5m_v1(IStrategy): """ Bollinger Band Squeeze Breakout Strategy. Detects BB squeezes using Keltner Channel overlap, then enters on breakout with volume confirmation. No signal-based exits — relies entirely on ROI and trailing stop. Family: Breakout Timeframe: 5m with 1h informative Direction: Long + Short """ INTERFACE_VERSION = 3 can_short: bool = True # --- Minimal ROI (wider for breakouts) --- minimal_roi = { "60": 0.01, "30": 0.02, "0": 0.04, } # --- Stoploss --- stoploss = -0.025 # --- Trailing Stop --- trailing_stop = True trailing_stop_positive = 0.008 trailing_stop_positive_offset = 0.015 trailing_only_offset_is_reached = True # --- Timeframe --- timeframe = "5m" # --- NO exit signal --- use_exit_signal = False process_only_new_candles = True exit_profit_only = False ignore_roi_if_entry_signal = False # --- Hyperoptable Parameters --- bb_period = IntParameter(15, 25, default=20, space="buy", optimize=True, load=True) bb_std = DecimalParameter(1.5, 2.5, default=2.0, decimals=1, space="buy", optimize=True, load=True) kc_period = IntParameter(15, 25, default=20, space="buy", optimize=True, load=True) kc_atr_mult = DecimalParameter(1.0, 2.0, default=1.5, decimals=1, space="buy", optimize=True, load=True) squeeze_lookback = IntParameter(3, 8, default=5, space="buy", optimize=True, load=True) rsi_long_min = IntParameter(45, 55, default=50, space="buy", optimize=True, load=True) rsi_short_max = IntParameter(45, 55, default=50, space="sell", optimize=True, load=True) volume_factor = DecimalParameter(1.0, 2.0, default=1.2, decimals=1, space="buy", optimize=True, load=True) ema_1h_lookback = IntParameter(1, 5, default=3, space="buy", optimize=True, load=True) # Startup: EMA50 on 1h needs 50 candles = 600 on 5m + BB/KC buffer startup_candle_count: int = 650 order_types = { "entry": "limit", "exit": "limit", "stoploss": "market", "stoploss_on_exchange": False, } order_time_in_force = {"entry": "GTC", "exit": "GTC"} plot_config = { "main_plot": { "bb_upper": {"color": "blue"}, "bb_lower": {"color": "blue"}, "bb_mid": {"color": "grey"}, "kc_upper": {"color": "orange"}, "kc_lower": {"color": "orange"}, }, "subplots": { "RSI": {"rsi": {"color": "red"}}, "BB Width": {"bb_width": {"color": "green"}}, "Squeeze": {"squeeze_on": {"color": "yellow", "type": "bar"}}, }, } # --- 1h Informative --- @informative("1h") def populate_indicators_1h(self, dataframe: DataFrame, metadata: dict) -> DataFrame: dataframe["ema_50"] = ta.EMA(dataframe, timeperiod=50) return dataframe def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: # --- Bollinger Bands --- bollinger = qtpylib.bollinger_bands( qtpylib.typical_price(dataframe), window=self.bb_period.value, stds=self.bb_std.value, ) dataframe["bb_upper"] = bollinger["upper"] dataframe["bb_lower"] = bollinger["lower"] dataframe["bb_mid"] = bollinger["mid"] dataframe["bb_width"] = (dataframe["bb_upper"] - dataframe["bb_lower"]) / dataframe["bb_mid"] # --- Keltner Channel --- kc_mid = ta.EMA(dataframe, timeperiod=self.kc_period.value) kc_atr = ta.ATR(dataframe, timeperiod=self.kc_period.value) dataframe["kc_upper"] = kc_mid + (kc_atr * self.kc_atr_mult.value) dataframe["kc_lower"] = kc_mid - (kc_atr * self.kc_atr_mult.value) # --- Squeeze Detection --- # Squeeze is ON when BB is INSIDE Keltner Channel dataframe["squeeze_on"] = ( (dataframe["bb_upper"] < dataframe["kc_upper"]) & (dataframe["bb_lower"] > dataframe["kc_lower"]) ).astype(int) # Was squeeze active in the last N candles? dataframe["squeeze_recent"] = dataframe["squeeze_on"].rolling( window=self.squeeze_lookback.value ).max() # --- RSI --- dataframe["rsi"] = ta.RSI(dataframe, timeperiod=14) # --- Volume SMA --- dataframe["volume_sma"] = ta.SMA(dataframe["volume"], timeperiod=20) return dataframe def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: # --- Long: Squeeze release + breakout above upper BB --- dataframe.loc[ ( (dataframe["squeeze_recent"] == 1) & (dataframe["close"] > dataframe["bb_upper"]) & (dataframe["rsi"] > self.rsi_long_min.value) & (dataframe["ema_50_1h"] > dataframe["ema_50_1h"].shift(self.ema_1h_lookback.value)) & (dataframe["volume"] > (dataframe["volume_sma"] * self.volume_factor.value)) ), "enter_long", ] = 1 # --- Short: Squeeze release + breakout below lower BB --- dataframe.loc[ ( (dataframe["squeeze_recent"] == 1) & (dataframe["close"] < dataframe["bb_lower"]) & (dataframe["rsi"] < self.rsi_short_max.value) & (dataframe["ema_50_1h"] < dataframe["ema_50_1h"].shift(self.ema_1h_lookback.value)) & (dataframe["volume"] > (dataframe["volume_sma"] * self.volume_factor.value)) ), "enter_short", ] = 1 return dataframe def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: # NO exit signals — rely on ROI and trailing stop only # (Evolution insight: signal-based exits consistently lose money) return dataframe