AI-generated mean-reversion strategy using Bollinger Band Z-score.
Timeframe
4h
Direction
Long Only
Stoploss
-5.0%
Trailing Stop
No
ROI
0m: 15.0%, 720m: 8.0%, 2160m: 3.0%
Interface Version
N/A
Startup Candles
210
Indicators
3
freqtrade/freqtrade-strategies
Strategy 003 author@: Gerald Lonlas github@: https://github.com/freqtrade/freqtrade-strategies
import numpy as np
"""
ZScoreMeanReversionStrategy — Statistical mean-reversion on Bollinger Z-score
==============================================================================
Logic:
Entry : Z-score of close relative to BB drops below -1.5 (oversold)
AND RSI < 35 AND volume spike (>1.5× 20-period average)
AND price > long-term EMA200 (only buy dips in uptrend)
Exit : Z-score returns to 0 (mean) OR RSI > 65
Stop : Fixed 5%
Suitable for: crypto / US stocks, 4h or 1d timeframe
"""
import talib.abstract as ta
from freqtrade.strategy import DecimalParameter, IntParameter, IStrategy
from pandas import DataFrame
class ZScoreMeanReversionStrategy(IStrategy):
"""AI-generated mean-reversion strategy using Bollinger Band Z-score."""
timeframe = "4h"
minimal_roi = {"0": 0.15, "720": 0.08, "2160": 0.03}
stoploss = -0.05
trailing_stop = False
can_short = False
startup_candle_count = 210
# Hyperopt-ready parameters
bb_period = IntParameter(15, 25, default=20, space="buy")
bb_std = DecimalParameter(1.5, 2.5, default=2.0, space="buy")
zscore_entry = DecimalParameter(-2.5, -1.0, default=-1.5, space="buy")
zscore_exit = DecimalParameter(-0.3, 0.5, default=0.0, space="sell")
rsi_entry = IntParameter(25, 45, default=35, space="buy")
rsi_exit = IntParameter(55, 75, default=65, space="sell")
volume_mult = DecimalParameter(1.2, 2.5, default=1.5, space="buy")
trend_ema = IntParameter(150, 250, default=200, space="buy")
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
# Bollinger Bands
bb = ta.BBANDS(
dataframe,
timeperiod=self.bb_period.value,
nbdevup=self.bb_std.value,
nbdevdn=self.bb_std.value,
)
dataframe["bb_upper"] = bb["upperband"]
dataframe["bb_mid"] = bb["middleband"]
dataframe["bb_lower"] = bb["lowerband"]
# Z-score: how many std-devs is close from the BB mid?
bb_std_val = (dataframe["bb_upper"] - dataframe["bb_mid"]) / self.bb_std.value
dataframe["zscore"] = (dataframe["close"] - dataframe["bb_mid"]) / bb_std_val.replace(0, np.nan)
# RSI
dataframe["rsi"] = ta.RSI(dataframe, timeperiod=14)
# Volume filter: rolling 20-bar average
dataframe["vol_ma"] = dataframe["volume"].rolling(20).mean()
dataframe["vol_ratio"] = dataframe["volume"] / dataframe["vol_ma"].replace(0, np.nan)
# Long-term trend EMA
dataframe["ema_trend"] = ta.EMA(dataframe, timeperiod=self.trend_ema.value)
# Bandwidth (squeeze detection — avoid trading in low-vol compression)
dataframe["bb_width"] = (dataframe["bb_upper"] - dataframe["bb_lower"]) / dataframe["bb_mid"]
return dataframe
def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
dataframe.loc[
(
(dataframe["zscore"] < self.zscore_entry.value) # statistically oversold
& (dataframe["rsi"] < self.rsi_entry.value) # momentum confirms weakness
& (dataframe["vol_ratio"] > self.volume_mult.value) # volume spike (capitulation)
& (dataframe["close"] > dataframe["ema_trend"]) # in long-term uptrend
& (dataframe["bb_width"] > 0.02) # not in extreme squeeze
& (dataframe["volume"] > 0)
),
"enter_long",
] = 1
return dataframe
def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
dataframe.loc[
(
(dataframe["zscore"] > self.zscore_exit.value) # mean-reverted
| (dataframe["rsi"] > self.rsi_exit.value) # overbought
| (dataframe["close"] < dataframe["bb_lower"]) # breakdown (stop cascade)
),
"exit_long",
] = 1
return dataframe