Timeframe
5m
Direction
Long & Short
Stoploss
-1.5%
Trailing Stop
No
ROI
0m: 10000.0%
Interface Version
3
Startup Candles
50
Indicators
4
freqtrade/freqtrade-strategies
Strategy 003 author@: Gerald Lonlas github@: https://github.com/freqtrade/freqtrade-strategies
"""
OsirisGoldV5 — Pure Mean-Reversion + Volatility Scalper (BTC 5m)
=================================================================
INSIGHT: Traditional indicators have ZERO directional edge on 5m.
V4 proved WR=49% at all score levels = coin flip.
NEW APPROACH: Don't predict direction. Exploit mean-reversion.
When price stretches beyond BB bands + volume spike → it snaps back.
Entry: Mean-reversion (at extremes, bet on reversion)
Exit: Quick scalp to BB middle or small fixed target
PHYSICS:
BTC 5m candle avg range = ~$300 (0.3%)
BB(20,2) captures 95% of moves → touch of band = 2.5% of candles = ~7/day
With volume filter = ~4-5/day
Mean-reversion to BB mid = ~$150-200 profit = 0.15-0.2%
SL = below/above the extreme wick = ~0.3-0.5%
ENTRIES:
LONG: Close <= BB lower (or wick below) + volume spike + green candle
SHORT: Close >= BB upper (or wick above) + volume spike + red candle
NO TREND FILTER (mean-reversion works in ALL regimes)
EXITS:
TP: BB middle (natural mean-reversion target)
SL: Fixed % below entry (wider than noise)
Timeout: 6 candles (30min) — if no reversion, exit
"""
import logging
import numpy as np
from pandas import DataFrame
from freqtrade.persistence import Trade
from freqtrade.strategy import (
IStrategy,
DecimalParameter,
IntParameter,
)
import talib.abstract as ta
logger = logging.getLogger(__name__)
class OsirisGoldV5(IStrategy):
INTERFACE_VERSION = 3
can_short = True
timeframe = "5m"
stoploss = -0.015 # Emergency SL 1.5%
minimal_roi = {"0": 100} # Disabled — custom_exit handles TP
trailing_stop = False
use_custom_stoploss = False
startup_candle_count = 50
process_only_new_candles = True
# ── Mean-reversion params ────────────────────────────────────────────────
bb_period = IntParameter(15, 30, default=20, space="buy", optimize=True)
bb_std = DecimalParameter(1.5, 2.5, default=2.0, decimals=1, space="buy", optimize=True)
vol_mult = DecimalParameter(1.0, 2.5, default=1.3, decimals=1, space="buy", optimize=True)
rsi_extreme_low = IntParameter(20, 40, default=30, space="buy", optimize=True)
rsi_extreme_high = IntParameter(60, 80, default=70, space="buy", optimize=True)
# ── Exit params ──────────────────────────────────────────────────────────
sl_pct = DecimalParameter(0.3, 1.5, default=0.8, decimals=1, space="sell", optimize=True)
tp_to_mid_pct = DecimalParameter(0.3, 1.0, default=0.7, decimals=1, space="sell", optimize=True)
timeout_candles = IntParameter(3, 12, default=6, space="sell", optimize=True)
_last_entry_time: dict = {}
def informative_pairs(self):
return [] # No need for multi-TF — pure price action
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
# Bollinger Bands
for period in [15, 20, 25, 30]:
for std in [1.5, 2.0, 2.5]:
bb = ta.BBANDS(dataframe, timeperiod=period, nbdevup=std, nbdevdn=std)
col_suffix = f"_{period}_{str(std).replace('.','')}"
dataframe[f"bb_upper{col_suffix}"] = bb["upperband"]
dataframe[f"bb_lower{col_suffix}"] = bb["lowerband"]
dataframe[f"bb_mid{col_suffix}"] = bb["middleband"]
# Current active BB (we'll use populate_entry_trend to select)
bb = ta.BBANDS(dataframe, timeperiod=20, nbdevup=2.0, nbdevdn=2.0)
dataframe["bb_upper"] = bb["upperband"]
dataframe["bb_lower"] = bb["lowerband"]
dataframe["bb_mid"] = bb["middleband"]
dataframe["bb_width"] = (bb["upperband"] - bb["lowerband"]) / bb["middleband"]
# RSI for confirmation
dataframe["rsi"] = ta.RSI(dataframe, timeperiod=14)
# Volume spike detection
dataframe["volume_sma"] = ta.SMA(dataframe["volume"], timeperiod=20)
dataframe["rvol"] = dataframe["volume"] / dataframe["volume_sma"].replace(0, np.nan)
# Candle properties
dataframe["is_green"] = (dataframe["close"] > dataframe["open"]).astype(int)
dataframe["is_red"] = (dataframe["close"] < dataframe["open"]).astype(int)
# Distance from BB bands (normalized)
dataframe["dist_lower"] = (dataframe["close"] - dataframe["bb_lower"]) / dataframe["close"] * 100
dataframe["dist_upper"] = (dataframe["bb_upper"] - dataframe["close"]) / dataframe["close"] * 100
# ATR for dynamic SL
dataframe["atr"] = ta.ATR(dataframe, timeperiod=14)
return dataframe
def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
bb_p = int(self.bb_period.value)
bb_s = float(self.bb_std.value)
col = f"_{bb_p}_{str(bb_s).replace('.','')}"
vol_m = float(self.vol_mult.value)
rsi_low = int(self.rsi_extreme_low.value)
rsi_high = int(self.rsi_extreme_high.value)
has_data = dataframe["rsi"].notna() & (dataframe["volume"] > 0)
bb_lower = dataframe[f"bb_lower{col}"]
bb_upper = dataframe[f"bb_upper{col}"]
bb_mid = dataframe[f"bb_mid{col}"]
# ═════════════════════════════════════════════════════════════════════
# LONG: Price at/below lower BB + volume spike + oversold RSI
# Mean-reversion: extreme low → expect bounce to middle
# ═════════════════════════════════════════════════════════════════════
long_signal = (
has_data
& (dataframe["low"] <= bb_lower) # Wick touches lower BB
& (dataframe["close"] > bb_lower) # But closes above (bounce)
& (dataframe["is_green"] == 1) # Green candle (buying pressure)
& (dataframe["rsi"] < rsi_low) # Oversold
& (dataframe["rvol"] > vol_m) # Volume spike (participation)
)
# ═════════════════════════════════════════════════════════════════════
# SHORT: Price at/above upper BB + volume spike + overbought RSI
# Mean-reversion: extreme high → expect drop to middle
# ═════════════════════════════════════════════════════════════════════
short_signal = (
has_data
& (dataframe["high"] >= bb_upper) # Wick touches upper BB
& (dataframe["close"] < bb_upper) # Closes below (rejection)
& (dataframe["is_red"] == 1) # Red candle (selling pressure)
& (dataframe["rsi"] > rsi_high) # Overbought
& (dataframe["rvol"] > vol_m) # Volume spike
)
dataframe.loc[long_signal, "enter_long"] = 1
dataframe.loc[long_signal, "enter_tag"] = "bb_bounce"
dataframe.loc[short_signal, "enter_short"] = 1
dataframe.loc[short_signal, "enter_tag"] = "bb_reject"
return dataframe
def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
return dataframe
def custom_exit(
self, pair, trade, current_time, current_rate, current_profit, **kwargs,
):
# Get BB middle for target
dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
if dataframe.empty:
return None
bb_p = int(self.bb_period.value)
bb_s = float(self.bb_std.value)
col = f"_{bb_p}_{str(bb_s).replace('.','')}"
last = dataframe.iloc[-1]
bb_mid = last.get(f"bb_mid{col}", 0)
is_short = getattr(trade, "is_short", False)
# TP: Price reaches BB middle (mean-reversion target)
if not is_short and current_rate >= bb_mid and current_profit > 0:
return "mr_tp_mid"
if is_short and current_rate <= bb_mid and current_profit > 0:
return "mr_tp_mid"
# SL: Fixed percentage
sl = float(self.sl_pct.value) / 100
if current_profit <= -sl:
return "mr_sl"
# Timeout: Exit after N candles regardless
if trade.open_date_utc:
elapsed = (current_time - trade.open_date_utc).total_seconds() / 300
if elapsed >= self.timeout_candles.value:
return "mr_timeout"
return None
def confirm_trade_entry(
self, pair, order_type, amount, rate, time_in_force,
current_time, entry_tag, side, **kwargs,
) -> bool:
# Cooldown: 2 candles minimum between entries
last = self._last_entry_time.get(pair)
if last is not None and (current_time - last).total_seconds() < 600:
return False
self._last_entry_time[pair] = current_time
return True
def leverage(self, pair, current_time, current_rate, proposed_leverage,
max_leverage, entry_tag, side, **kwargs) -> float:
return 1.0