Timeframe
N/A
Direction
Long Only
Stoploss
N/A
Trailing Stop
No
ROI
N/A
Interface Version
N/A
Startup Candles
N/A
Indicators
4
freqtrade/freqtrade-strategies
Strategy 003 author@: Gerald Lonlas github@: https://github.com/freqtrade/freqtrade-strategies
"""
MeanReversionScalper — Bollinger Band + RSI + Volume Spike Mean-Reversion Scalper
Timeframe: 5m (with 1h informative for trend filter)
Description: Enters when price touches the lower Bollinger Band with oversold RSI
and a volume spike, targeting a 0.3-0.6% snap-back toward the midline.
Entry conditions:
- Close <= BB lower band
- RSI < 30 (momentum exhaustion)
- Volume > 1.5x 20-period average (capitulation spike)
- BB width > 0.5% (enough room for profitable reversion)
- 1h EMA50 slope > -0.3% (avoids buying into waterfall sell-offs)
Risk management:
- Tiered ROI: 0.6% immediately down to 0.1% at 40 minutes
- Trailing stop activating at +0.3%
- Time-based tightening custom stoploss (-0.8% -> -0.3%)
- Signal exit when price hits BB midline
- StoplossGuard after 3 consecutive losses + per-pair cooldown
WARNING: This strategy has NOT been backtested. Run through at least 6 months of
data on 8+ liquid pairs, verify win rate > ~55% with profit factor > 1.3, and
paper trade 2-4 weeks before using real capital. Requires fees <= 0.075% per side.
"""
from datetime import datetime
from freqtrade.persistence import Trade
from freqtrade.strategy import IStrategy, DecimalParameter, IntParameter, merge_informative_pair
from pandas import DataFrame
import pandas_ta as pta
class MeanReversionScalper(IStrategy):
INTERFACE_VERSION: int = 3
timeframe: str = "5m"
# Tiered ROI — 0.6% at open, tapering to 0.1% at 40 min
minimal_roi: dict = {
"0": 0.006,
"10": 0.005,
"20": 0.003,
"30": 0.002,
"40": 0.001,
}
stoploss: float = -0.008 # -0.8% hard stoploss
# Trailing stop — activates at +0.3%
trailing_stop: bool = True
trailing_stop_positive: float = 0.002
trailing_stop_positive_offset: float = 0.003
trailing_only_offset_is_reached: bool = True
# Trade management
max_open_trades: int = 6
use_exit_signal: bool = True
exit_profit_only: bool = False
# Informative timeframe for trend filter
informative_timeframe: str = "1h"
# Process only new candles for performance
process_only_new_candles: bool = True
# Enough candles for 1h EMA50 to warm up (50 * 12 five-min candles per hour)
startup_candle_count: int = 600
# -----------------------------------------------------------------------
# Buy hyperopt parameters
# -----------------------------------------------------------------------
buy_rsi_threshold = IntParameter(20, 40, default=30, space="buy",
help="RSI must be below this to enter")
buy_volume_factor = DecimalParameter(1.2, 2.5, default=1.5, space="buy",
help="Volume must exceed SMA(20) * this factor")
buy_bb_width_min = DecimalParameter(0.003, 0.01, default=0.005, space="buy",
help="Minimum BB width (fraction) for entry")
buy_ema_slope_min = DecimalParameter(-0.005, 0.0, default=-0.003, space="buy",
help="Min 1h EMA50 slope (fraction) — trend filter")
# -----------------------------------------------------------------------
# Sell hyperopt parameters
# -----------------------------------------------------------------------
sell_bb_mid_offset = DecimalParameter(0.995, 1.005, default=1.0, space="sell",
help="Exit at BB mid * this offset")
# -----------------------------------------------------------------------
# Protection — StoplossGuard + CooldownPeriod
# -----------------------------------------------------------------------
@property
def protections(self):
return [
{
"method": "StoplossGuard",
"lookback_period_candles": 12, # 1 hour of 5m candles
"trade_limit": 3,
"stop_duration_candles": 12, # pause for 1 hour
"only_per_pair": False,
},
{
"method": "CooldownPeriod",
"stop_duration_candles": 2, # 10 min cooldown per pair
"only_per_pair": True,
},
]
def informative_pairs(self):
pairs = self.dp.current_whitelist()
return [(pair, self.informative_timeframe) for pair in pairs]
def populate_indicators_1h(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
"""Populate indicators for the 1h informative timeframe."""
dataframe["ema50"] = pta.ema(dataframe["close"], length=50)
dataframe["ema50_slope"] = dataframe["ema50"].pct_change(periods=1)
return dataframe
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
# -- 5m indicators --
# RSI (14)
dataframe["rsi"] = pta.rsi(dataframe["close"], length=14)
# Bollinger Bands (20, 2 std)
bbands = pta.bbands(dataframe["close"], length=20, std=2.0)
dataframe["bb_lower"] = bbands["BBL_20_2.0"]
dataframe["bb_mid"] = bbands["BBM_20_2.0"]
dataframe["bb_upper"] = bbands["BBU_20_2.0"]
# BB width as fraction of midline
dataframe["bb_width"] = (
(dataframe["bb_upper"] - dataframe["bb_lower"]) / dataframe["bb_mid"]
)
# Volume SMA (20)
dataframe["volume_sma_20"] = pta.sma(dataframe["volume"], length=20)
# -- 1h informative: EMA50 slope as trend filter --
if self.dp:
inf_1h = self.dp.get_pair_dataframe(
pair=metadata["pair"], timeframe=self.informative_timeframe
)
inf_1h = self.populate_indicators_1h(inf_1h, metadata)
dataframe = merge_informative_pair(
dataframe, inf_1h, self.timeframe, self.informative_timeframe,
ffill=True,
)
dataframe.rename(
columns={"ema50_slope_1h": "ema50_1h_slope"}, inplace=True
)
else:
dataframe["ema50_1h_slope"] = 0.0
return dataframe
def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
dataframe.loc[
(
# Price at or below lower Bollinger Band
(dataframe["close"] <= dataframe["bb_lower"])
# RSI confirms oversold exhaustion
& (dataframe["rsi"] < self.buy_rsi_threshold.value)
# Volume spike — capitulation, not low-conviction slide
& (dataframe["volume"] > dataframe["volume_sma_20"] * self.buy_volume_factor.value)
# Enough BB width for profitable reversion
& (dataframe["bb_width"] > self.buy_bb_width_min.value)
# 1h trend filter — not a waterfall sell-off
& (dataframe["ema50_1h_slope"] > self.buy_ema_slope_min.value)
# Sanity
& (dataframe["volume"] > 0)
),
"enter_long",
] = 1
return dataframe
def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
dataframe.loc[
(
# Price reverted to BB midline
(dataframe["close"] >= dataframe["bb_mid"] * self.sell_bb_mid_offset.value)
& (dataframe["volume"] > 0)
),
"exit_long",
] = 1
return dataframe
def custom_stoploss(
self,
pair: str,
trade: Trade,
current_time: datetime,
current_rate: float,
current_profit: float,
**kwargs,
) -> float:
"""
Time-based tightening stoploss:
0-10 min : -0.8% (use the static stoploss)
10-20 min: -0.5%
20-30 min: -0.4%
30+ min : -0.3%
"""
elapsed = (current_time - trade.open_date_utc).total_seconds() / 60.0
if elapsed > 30:
return -0.003
elif elapsed > 20:
return -0.004
elif elapsed > 10:
return -0.005
# Return 1 to keep the default stoploss (-0.8%)
return 1