Timeframe
1h
Direction
Long Only
Stoploss
-15.0%
Trailing Stop
No
ROI
0m: 22.2%, 2219m: 16.7%, 5267m: 9.0%, 9945m: 0.0%
Interface Version
3
Startup Candles
200
Indicators
3
freqtrade/freqtrade-strategies
Strategy 003 author@: Gerald Lonlas github@: https://github.com/freqtrade/freqtrade-strategies
"""
BBBreakoutStrategy — Phase 1.7
Bollinger Band breakout with EMA200 trend filter and volume confirmation.
Designed to be backtested per-timeframe (1h, 2h, 4h, 6h, 8h, 12h) to find
which pairs trend cleanly on which timeframe for this setup.
Entry (all four must be true on the same candle):
1. Close > BB upper band (period=20, StdDev=1) — momentum breakout
2. Close > EMA200 — bull structure only, skip bear markets
3. EMA200 > EMA200[10 candles ago] — EMA200 must be rising (blocks spikes above falling EMA)
4. Volume > Volume MA(20) — confirms the breakout has real participation
Exit:
- Signal: close < BB middle (SMA20) — momentum exhausted, back to mean
- Stop: ATR(14) * atr_multiplier from entry price (custom_stoploss)
- ROI table: lock in profit over time if mean-close exit doesn't fire
StdDev=1 (not the usual 2): tighter bands = earlier signals, but requires
the EMA200 and volume filters to reduce false breakouts.
Not financial advice. Do not use with real funds without independent review.
"""
import talib.abstract as ta
from pandas import DataFrame
from freqtrade.strategy import DecimalParameter, IStrategy
class BBBreakoutStrategy(IStrategy):
INTERFACE_VERSION = 3
can_short = False
timeframe = "1h"
startup_candle_count = 200 # EMA200 needs 200 candles to warm up
# Hyperopt best (epoch 255/500, 6h timeframe, 500 epochs):
# atr_multiplier=3.4, buy_volume_factor=1.0
# ROI times are in minutes (timeframe-independent).
minimal_roi = {
"0": 0.222, # 22.2% anytime
"2219": 0.167, # 16.7% after ~37 h
"5267": 0.09, # 9% after ~87 h (~3.6 days)
"9945": 0, # breakeven after ~166 h (~6.9 days)
}
# Hard cap — safety net if ATR-based stop fails.
# In practice the 2xATR custom stop fires first.
stoploss = -0.15
use_custom_stoploss = True
trailing_stop = False
process_only_new_candles = True
# ------------------------------------------------------------------ #
# Hyperopt parameters #
# ------------------------------------------------------------------ #
atr_multiplier = DecimalParameter(1.0, 3.5, default=3.4, decimals=1, space="sell", optimize=True)
buy_volume_factor = DecimalParameter(1.0, 2.0, default=1.0, decimals=1, space="buy", optimize=True)
# ------------------------------------------------------------------ #
# Indicators #
# ------------------------------------------------------------------ #
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
bb = ta.BBANDS(dataframe, timeperiod=20, nbdevup=1.0, nbdevdn=1.0)
dataframe["bb_upper"] = bb["upperband"]
dataframe["bb_middle"] = bb["middleband"]
dataframe["ema200"] = ta.EMA(dataframe, timeperiod=200)
dataframe["volume_ma20"] = dataframe["volume"].rolling(window=20).mean()
dataframe["atr"] = ta.ATR(dataframe, timeperiod=14)
return dataframe
# ------------------------------------------------------------------ #
# Entry #
# ------------------------------------------------------------------ #
def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
dataframe.loc[
(
(dataframe["close"] > dataframe["bb_upper"])
& (dataframe["close"] > dataframe["ema200"])
# EMA200 must be rising over the last 10 candles (40h on 4h tf).
# Blocks entries where price spikes above a still-falling 200 EMA —
# the most common false breakout pattern in a bear market.
& (dataframe["ema200"] > dataframe["ema200"].shift(10))
& (dataframe["volume"] > dataframe["volume_ma20"] * self.buy_volume_factor.value)
& (dataframe["volume"] > 0)
),
["enter_long", "enter_tag"],
] = [1, "bb_breakout"]
return dataframe
# ------------------------------------------------------------------ #
# Exit #
# ------------------------------------------------------------------ #
def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
# Require 2 consecutive closes below the BB middle before exiting.
# A single touch of the midline is noise on 4h; two consecutive closes
# indicate genuine momentum exhaustion, not a transient pullback.
dataframe.loc[
(
(dataframe["close"] < dataframe["bb_middle"])
& (dataframe["close"].shift(1) < dataframe["bb_middle"].shift(1))
& (dataframe["volume"] > 0)
),
["exit_long", "exit_tag"],
] = [1, "bb_mean_close"]
return dataframe
# ------------------------------------------------------------------ #
# Dynamic 2x ATR stop #
# ------------------------------------------------------------------ #
def custom_stoploss(self, pair: str, trade, current_time,
current_rate: float, current_profit: float, **kwargs) -> float:
dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
if len(dataframe) == 0:
return self.stoploss
atr = dataframe.iloc[-1]["atr"]
if not (atr > 0):
return self.stoploss
stop_price = trade.open_rate - (self.atr_multiplier.value * atr)
stop_ratio = (stop_price / current_rate) - 1.0
# Floor at -25% in case ATR is unusually large on illiquid candles
return max(stop_ratio, -0.25)