Bollinger Band Mean Reversion — 4h SPOT
Timeframe
4h
Direction
Long Only
Stoploss
-4.0%
Trailing Stop
No
ROI
0m: 3.0%, 30m: 2.0%, 60m: 1.5%
Interface Version
3
Startup Candles
50
Indicators
4
freqtrade/freqtrade-strategies
Strategy 003 author@: Gerald Lonlas github@: https://github.com/freqtrade/freqtrade-strategies
# pragma pylint: disable=missing-docstring, invalid-name, pointless-string-statement
# flake8: noqa: F401
"""
Strategy 09: Bollinger Band Mean Reversion
==========================================
Type : Mean Reversion (SPOT ONLY — Halal, no shorting, no margin, no futures)
Timeframe : 4h
Author : Crypto Halal Algo Trading Suite
⚠️ WARNING
-----------
MEAN REVERSION IS EXTREMELY DANGEROUS IN CRYPTO TRENDING MARKETS.
Crypto assets can trend strongly for weeks or months. Buying every
"oversold" dip in a strong downtrend is a guaranteed path to ruin.
The ADX < 20 filter is MANDATORY. Under no circumstances should you
remove or relax it. If ADX is above 20, the market is trending and
this strategy should sit on the sidelines.
DESCRIPTION
-----------
Classic Bollinger Band mean-reversion strategy adapted for crypto SPOT trading.
When price closes below the lower Bollinger Band and confirms re-entry back
inside the bands on the next candle, we buy with the expectation that price
reverts to the 20-period moving average (the middle band).
The "wait for re-entry" confirmation is critical: closing below the band is
NOT the entry signal. The entry is when price re-enters the band from below,
confirming that selling pressure has exhausted itself.
CORE LOGIC
----------
1. ADX Filter : ADX(14) must be < 20. This is the MOST IMPORTANT filter.
It ensures we only trade in ranging/sideways markets where
BB mean-reversion has historical edge.
2. RSI Confirmation: RSI(14) must be < 30 on the candle that closes below
the lower BB. This adds momentum confirmation.
3. Entry Trigger : Price CLOSED below the lower BB on the previous candle
(breach confirmed) AND price NOW closes ABOVE the lower BB
(re-entry confirmed). This two-candle pattern reduces false
signals from brief wicks below the band.
4. Exit Target : Price returns to the middle Bollinger Band (20 SMA).
This is the mean we are reverting to.
5. Stoploss : Hard 4% stop below entry. In a genuine trend reversal,
price will continue falling; the stoploss caps the loss.
PARAMETERS
----------
bb_period : 20
bb_std : 2.0
rsi_period : 14
adx_period : 14
adx_max : 20 ← MANDATORY upper limit
rsi_oversold : 30
RISK TABLE
----------
Scenario | Outcome
---------------------|------------------------------------
ADX < 20 (ranging) | Strategy has edge, BB reverts
ADX 20-25 (weak trend)| Edge erodes, use caution
ADX > 25 (trending) | DO NOT TRADE — strategy fails here
ADX > 40 (strong trend)| Catastrophic losses expected
HALAL COMPLIANCE
----------------
SPOT trading only. No leverage, no margin, no shorting, no futures/options.
All profits come from price appreciation in owned assets.
"""
from datetime import datetime
from typing import Optional
import numpy as np
import pandas as pd
import pandas_ta as ta
from freqtrade.strategy import IStrategy
from pandas import DataFrame
# ---------------------------------------------------------------------------
# Utility helpers
# ---------------------------------------------------------------------------
def crossed_above(s1: pd.Series, s2: pd.Series) -> pd.Series:
"""Returns True on the candle where s1 crosses above s2."""
return (s1 > s2) & (s1.shift(1) <= s2.shift(1))
def crossed_below(s1: pd.Series, s2: pd.Series) -> pd.Series:
"""Returns True on the candle where s1 crosses below s2."""
return (s1 < s2) & (s1.shift(1) >= s2.shift(1))
# ---------------------------------------------------------------------------
# Strategy class
# ---------------------------------------------------------------------------
class Strategy09_BB_MeanReversion(IStrategy):
"""
Bollinger Band Mean Reversion — 4h SPOT
⚠️ ADX < 20 filter is MANDATORY. See module docstring for full risk warnings.
Entry : Price was below lower BB last candle, now re-enters above it,
with ADX < 20 (ranging) and RSI < 30 (oversold).
Exit : Price closes at or above the middle BB (20 SMA).
Stop : -4% hard stop.
"""
INTERFACE_VERSION = 3
# ------------------------------------------------------------------
# Metadata
# ------------------------------------------------------------------
timeframe = "4h"
can_short = False # HALAL: long-only, SPOT only
# ------------------------------------------------------------------
# ROI — profit targets
# ------------------------------------------------------------------
minimal_roi = {
"0": 0.03, # 3% — take quick profit if available
"30": 0.02, # 2% after 30 min
"60": 0.015, # 1.5% after 60 min
}
# ------------------------------------------------------------------
# Stoploss
# ------------------------------------------------------------------
stoploss = -0.04 # 4% — slightly wider than S08 for BB strategies
trailing_stop = False # No trailing; mean reversion has clear exit target
# ------------------------------------------------------------------
# Order settings
# ------------------------------------------------------------------
process_only_new_candles = True
startup_candle_count = 50 # BB(20) + ADX(14) + warmup
# ------------------------------------------------------------------
# Strategy parameters
# ------------------------------------------------------------------
bb_period = 20
bb_std = 2.0
rsi_period = 14
adx_period = 14
adx_max = 20.0 # ⚠️ MANDATORY — do not increase
rsi_oversold = 30.0
# ------------------------------------------------------------------
# Indicators
# ------------------------------------------------------------------
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
"""
Compute Bollinger Bands, RSI, and ADX.
Key columns added
-----------------
bb_lower : Lower Bollinger Band (20, 2σ)
bb_mid : Middle Bollinger Band (20 SMA) — the mean target
bb_upper : Upper Bollinger Band (20, 2σ)
rsi : RSI(14)
adx : ADX(14) — must be < 20 for entries
prev_below_lower : Boolean — was close below bb_lower on previous candle?
"""
# --- Bollinger Bands ---
bb = ta.bbands(dataframe["close"], length=self.bb_period, std=self.bb_std)
if bb is not None and not bb.empty:
lower_col = [c for c in bb.columns if c.startswith("BBL_")]
mid_col = [c for c in bb.columns if c.startswith("BBM_")]
upper_col = [c for c in bb.columns if c.startswith("BBU_")]
dataframe["bb_lower"] = bb[lower_col[0]] if lower_col else np.nan
dataframe["bb_mid"] = bb[mid_col[0]] if mid_col else np.nan
dataframe["bb_upper"] = bb[upper_col[0]] if upper_col else np.nan
else:
dataframe["bb_lower"] = np.nan
dataframe["bb_mid"] = np.nan
dataframe["bb_upper"] = np.nan
# --- RSI ---
dataframe["rsi"] = ta.rsi(dataframe["close"], length=self.rsi_period)
# --- ADX ---
adx_df = ta.adx(
dataframe["high"], dataframe["low"], dataframe["close"],
length=self.adx_period
)
adx_col = [c for c in adx_df.columns if c.startswith("ADX_")]
dataframe["adx"] = adx_df[adx_col[0]] if adx_col else np.nan
# --- "Was below lower BB last candle?" flag ---
# This is the pre-condition for the two-candle re-entry pattern.
dataframe["prev_below_lower"] = dataframe["close"].shift(1) < dataframe["bb_lower"].shift(1)
# --- RSI was oversold on the breach candle (previous candle) ---
dataframe["prev_rsi_oversold"] = dataframe["rsi"].shift(1) < self.rsi_oversold
# --- NaN Safety: convert any None to NaN so pandas comparisons work ---
for col in dataframe.columns:
if col not in ['open', 'high', 'low', 'close', 'volume', 'date']:
try:
dataframe[col] = pd.to_numeric(dataframe[col], errors='coerce')
except (ValueError, TypeError):
pass
return dataframe
# ------------------------------------------------------------------
# Entry logic
# ------------------------------------------------------------------
def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
"""
Entry conditions — ALL must be true:
1. ADX < 20 : Market is ranging (MANDATORY)
2. prev_below_lower : Close was below lower BB on the previous candle
(breach event occurred)
3. prev_rsi_oversold : RSI was < 30 on the breach candle
4. close > bb_lower : Price has now re-entered above the lower BB
(re-entry confirmation — the actual entry signal)
5. volume > 0 : Liquid candle
The two-candle pattern (breach then re-entry) is the core differentiator
versus naive "buy when price touches lower BB" approaches that are often
stopped out before the mean reversion occurs.
"""
conditions = (
(dataframe["adx"] < self.adx_max) & # 1. Ranging — MANDATORY
dataframe["prev_below_lower"] & # 2. Breach confirmed
dataframe["prev_rsi_oversold"] & # 3. RSI oversold at breach
(dataframe["close"] > dataframe["bb_lower"]) & # 4. Re-entry confirmed
(dataframe["volume"] > 0) # 5. Liquid
)
dataframe.loc[conditions, "enter_long"] = 1
return dataframe
# ------------------------------------------------------------------
# Exit logic
# ------------------------------------------------------------------
def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
"""
Exit when price closes at or above the middle Bollinger Band (20 SMA).
This is the "mean" we are reverting to — once reached, take profit.
Additional exits are handled by:
- minimal_roi table (time-based profit targets)
- stoploss (-4%) for failed mean reversions
"""
conditions = dataframe["close"] >= dataframe["bb_mid"]
dataframe.loc[conditions, "exit_long"] = 1
return dataframe
# ------------------------------------------------------------------
# Optional: custom stoploss hook (informational — hard SL handles it)
# ------------------------------------------------------------------
def custom_exit(
self,
pair: str,
trade,
current_time: datetime,
current_rate: float,
current_profit: float,
**kwargs,
) -> Optional[str]:
"""
Safety valve: if a trade has been open for more than 10 candles (40 hours)
and is still below -1%, exit. In a genuine mean reversion the price should
have recovered well before this point.
"""
max_candles = 10
candle_hours = 4
max_hours = max_candles * candle_hours # 40 hours
trade_hours = (current_time - trade.open_date_utc).total_seconds() / 3600
if trade_hours >= max_hours and current_profit < -0.01:
return f"mean_reversion_failed_{max_candles}c"
return None