OsirisFlowDT — Mean reversion using RSI extremes + Order Flow exhaustion.
Timeframe
5m
Direction
Long & Short
Stoploss
-1.2%
Trailing Stop
Yes
ROI
0m: 3.0%, 12m: 1.8%, 24m: 1.0%
Interface Version
N/A
Startup Candles
200
Indicators
7
freqtrade/freqtrade-strategies
Strategy 003 author@: Gerald Lonlas github@: https://github.com/freqtrade/freqtrade-strategies
"""
OsirisFlowDT — Mean Reversion Day Trade Strategy
==================================================
THESIS: Order flow data has ZERO predictive power for CONTINUATION on BTC 5m.
BUT it has genuine edge for MEAN REVERSION (exhaustion → bounce).
PROVEN EDGE (validated on 129K candles, 15 months):
- RSI < 25 + delta_zscore < -2.0 → LONG → 57.8-61.8% WR at 6-candle horizon
- RSI < 20 + delta_zscore < -2.0 → LONG → 61.8% WR (n=131, 0.3/day)
- dz > 3.0 exhaustion (any RSI) → SHORT → 56.0% WR at 3-candle horizon
- SHORT side is weaker, asymmetric (longs more reliable)
PHILOSOPHY: Few, high-conviction mean reversion trades.
- Primary: RSI extreme + OF exhaustion = entry trigger
- Confirmation: Absorption/sweep patterns, BB touch
- Exit: Quick TP (ATR-based), tight SL, max 2h hold
TARGET: 0.5-2 trades/day, >55% WR, R:R >= 1.5
"""
import logging
from pathlib import Path
import numpy as np
import pandas as pd
from pandas import DataFrame
from freqtrade.strategy import IStrategy, merge_informative_pair
from freqtrade.strategy import CategoricalParameter, DecimalParameter, IntParameter
from freqtrade.persistence import Trade
import talib.abstract as ta
logger = logging.getLogger(__name__)
OF_PATH = Path(__file__).resolve().parent.parent / "data" / "orderflow" / "BTCUSDT-orderflow-5m.feather"
class OsirisFlowDT(IStrategy):
"""
OsirisFlowDT — Mean reversion using RSI extremes + Order Flow exhaustion.
"""
timeframe = "5m"
can_short = True
# ROI: wide targets, exit mostly via trailing or signal
minimal_roi = {"0": 0.03, "12": 0.018, "24": 0.01}
stoploss = -0.012
trailing_stop = True
trailing_stop_positive = 0.005
trailing_stop_positive_offset = 0.015 # Only trail after +1.5%
trailing_only_offset_is_reached = True
use_custom_stoploss = False
startup_candle_count = 200
process_only_new_candles = True
# ═══════════════════════════════════════════════════════════════════
# HYPEROPT PARAMETERS — ENTRY
# ═══════════════════════════════════════════════════════════════════
# RSI oversold/overbought thresholds
buy_rsi_max = IntParameter(15, 35, default=25, space="buy", optimize=True)
sell_rsi_min = IntParameter(65, 85, default=75, space="buy", optimize=True)
# OF delta z-score exhaustion threshold
buy_dz_min = DecimalParameter(1.0, 3.0, default=2.0, decimals=1, space="buy", optimize=True)
sell_dz_min = DecimalParameter(1.0, 3.0, default=2.0, decimals=1, space="buy", optimize=True)
# Extra filters (confirmations)
use_bb_touch = CategoricalParameter([True, False], default=True, space="buy", optimize=True)
use_absorption = CategoricalParameter([True, False], default=True, space="buy", optimize=True)
use_trend_filter = CategoricalParameter([True, False], default=False, space="buy", optimize=True)
# Minimum score (confirmation points needed beyond RSI+OF)
confirm_min = IntParameter(0, 3, default=0, space="buy", optimize=True)
# ═══════════════════════════════════════════════════════════════════
# HYPEROPT PARAMETERS — EXIT
# ═══════════════════════════════════════════════════════════════════
exit_rsi_long = IntParameter(45, 70, default=55, space="sell", optimize=True)
exit_rsi_short = IntParameter(30, 55, default=45, space="sell", optimize=True)
exit_dz_reversal = DecimalParameter(0.0, 1.5, default=0.5, decimals=1, space="sell", optimize=True)
# ── Internal state ──────────────────────────────────────────────────
_of_data: pd.DataFrame = None
_of_loaded = False
# ═══════════════════════════════════════════════════════════════════
# ORDER FLOW DATA LOADER
# ═══════════════════════════════════════════════════════════════════
def _load_orderflow(self):
if self._of_loaded:
return
self._of_loaded = True
if OF_PATH.exists():
try:
of = pd.read_feather(OF_PATH)
of["date"] = pd.to_datetime(of["date"], utc=True)
of = of.set_index("date").sort_index()
keep = ["delta", "buy_ratio", "delta_zscore", "absorption",
"sweep_up", "sweep_down", "big_imbalance", "trade_burst",
"aggression", "delta_flips"]
available = [c for c in keep if c in of.columns]
self._of_data = of[available]
logger.info(f"OsirisFlowDT: Loaded {len(of)} OF rows")
except Exception as e:
logger.warning(f"OsirisFlowDT: OF load failed: {e}")
self._of_data = None
else:
logger.warning(f"OsirisFlowDT: OF not found at {OF_PATH}")
self._of_data = None
def _merge_orderflow(self, df: DataFrame) -> DataFrame:
self._load_orderflow()
if self._of_data is None:
for c in ["of_delta_zscore", "of_buy_ratio", "of_absorption",
"of_sweep_up", "of_sweep_down", "of_aggression"]:
df[c] = 0.0
return df
of = self._of_data.copy()
of = of.rename(columns={
"delta_zscore": "of_delta_zscore",
"buy_ratio": "of_buy_ratio",
"absorption": "of_absorption",
"sweep_up": "of_sweep_up",
"sweep_down": "of_sweep_down",
"aggression": "of_aggression",
})
merge_cols = [c for c in of.columns if c.startswith("of_")]
of_merge = of[merge_cols].reset_index()
df_date = df["date"].copy()
if df_date.dt.tz is None:
df_date = df_date.dt.tz_localize("UTC")
else:
df_date = df_date.dt.tz_convert("UTC")
of_merge["date"] = pd.to_datetime(of_merge["date"], utc=True)
df["_merge_date"] = df_date
of_merge = of_merge.rename(columns={"date": "_merge_date"})
merged = pd.merge(df, of_merge, on="_merge_date", how="left")
merged = merged.drop(columns=["_merge_date"])
for c in merge_cols:
if c in merged.columns:
merged[c] = merged[c].fillna(0)
return merged
# ═══════════════════════════════════════════════════════════════════
# INDICATORS
# ═══════════════════════════════════════════════════════════════════
def informative_pairs(self):
pairs = self.dp.current_whitelist()
return [(pair, "15m") for pair in pairs]
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
# RSI (primary signal)
dataframe["rsi"] = ta.RSI(dataframe, timeperiod=14)
# Bollinger Bands (confirmation)
bb = ta.BBANDS(dataframe, timeperiod=20, nbdevup=2.0, nbdevdn=2.0)
dataframe["bb_lower"] = bb["lowerband"]
dataframe["bb_upper"] = bb["upperband"]
dataframe["bb_mid"] = bb["middleband"]
# ATR (for dynamic SL/TP)
dataframe["atr"] = ta.ATR(dataframe, timeperiod=14)
dataframe["atr_pct"] = dataframe["atr"] / dataframe["close"] * 100
# EMAs for trend filter
dataframe["ema_50"] = ta.EMA(dataframe, timeperiod=50)
dataframe["ema_200"] = ta.EMA(dataframe, timeperiod=200)
# StochRSI for overbought/oversold depth
stoch = ta.STOCHRSI(dataframe, timeperiod=14, fastk_period=3, fastd_period=3)
dataframe["stochrsi_k"] = stoch["fastk"]
# CCI for mean reversion confirmation
dataframe["cci"] = ta.CCI(dataframe, timeperiod=14)
# Volume
hl = (dataframe["high"] - dataframe["low"]).replace(0, np.nan)
bv = (dataframe["volume"] * (dataframe["close"] - dataframe["low"]) / hl).fillna(dataframe["volume"] * 0.5)
sv = (dataframe["volume"] * (dataframe["high"] - dataframe["close"]) / hl).fillna(dataframe["volume"] * 0.5)
dataframe["pressure_ratio"] = (bv / (bv + sv).replace(0, 1)).fillna(0.5)
dataframe["rvol"] = dataframe["volume"] / dataframe["volume"].rolling(20).mean().replace(0, 1)
# Order Flow
dataframe = self._merge_orderflow(dataframe)
# 15m MTF (trend context)
if self.dp:
inf_15m = self.dp.get_pair_dataframe(pair=metadata["pair"], timeframe="15m")
if not inf_15m.empty:
inf_15m["rsi"] = ta.RSI(inf_15m, timeperiod=14)
inf_15m["ema_50"] = ta.EMA(inf_15m, timeperiod=50)
dataframe = merge_informative_pair(dataframe, inf_15m, self.timeframe, "15m", ffill=True)
return dataframe
# ═══════════════════════════════════════════════════════════════════
# ENTRY — MEAN REVERSION
# ═══════════════════════════════════════════════════════════════════
def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
# ── LONG: RSI oversold + OF selling exhaustion → bounce ─────
long_base = (
(dataframe["rsi"] < self.buy_rsi_max.value)
& (dataframe["of_delta_zscore"] < -self.buy_dz_min.value)
& (dataframe["volume"] > 0)
)
# Confirmation points
confirm = np.zeros(len(dataframe))
if self.use_bb_touch.value:
confirm += (dataframe["close"] <= dataframe["bb_lower"]).astype(int)
if self.use_absorption.value:
confirm += (dataframe["of_absorption"] > 0.5).astype(int)
if self.use_trend_filter.value:
confirm += (dataframe["close"] > dataframe["ema_200"]).astype(int)
# Additional confirmations: StochRSI extreme, CCI extreme, high volume
confirm += (dataframe["stochrsi_k"] < 15).astype(int)
confirm += (dataframe["cci"] < -150).astype(int)
confirm += (dataframe["rvol"] > 1.5).astype(int)
dataframe.loc[long_base & (confirm >= self.confirm_min.value), "enter_long"] = 1
# ── SHORT: RSI overbought + OF buying exhaustion → drop ─────
short_base = (
(dataframe["rsi"] > self.sell_rsi_min.value)
& (dataframe["of_delta_zscore"] > self.sell_dz_min.value)
& (dataframe["volume"] > 0)
)
confirm_s = np.zeros(len(dataframe))
if self.use_bb_touch.value:
confirm_s += (dataframe["close"] >= dataframe["bb_upper"]).astype(int)
if self.use_absorption.value:
confirm_s += (dataframe["of_absorption"] > 0.5).astype(int)
if self.use_trend_filter.value:
confirm_s += (dataframe["close"] < dataframe["ema_200"]).astype(int)
confirm_s += (dataframe["stochrsi_k"] > 85).astype(int)
confirm_s += (dataframe["cci"] > 150).astype(int)
confirm_s += (dataframe["rvol"] > 1.5).astype(int)
dataframe.loc[short_base & (confirm_s >= self.confirm_min.value), "enter_short"] = 1
return dataframe
# ═══════════════════════════════════════════════════════════════════
# EXIT — Quick out when reversion happens
# ═══════════════════════════════════════════════════════════════════
def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
# Exit long: RSI recovered to neutral + OF delta flipped
dataframe.loc[
(dataframe["rsi"] > self.exit_rsi_long.value)
& (dataframe["of_delta_zscore"] > self.exit_dz_reversal.value)
& (dataframe["volume"] > 0),
"exit_long"
] = 1
# Exit short: RSI fell to neutral + OF delta flipped negative
dataframe.loc[
(dataframe["rsi"] < self.exit_rsi_short.value)
& (dataframe["of_delta_zscore"] < -self.exit_dz_reversal.value)
& (dataframe["volume"] > 0),
"exit_short"
] = 1
return dataframe
# ═══════════════════════════════════════════════════════════════════
# CUSTOM STOPLOSS — ATR-based, tight for mean reversion
# ═══════════════════════════════════════════════════════════════════
def custom_stoploss(self, pair, trade: Trade, current_time,
current_rate, current_profit, after_fill, **kwargs) -> float:
return -1 # Disabled — using static stoploss
# ═══════════════════════════════════════════════════════════════════
# CUSTOM EXIT — max hold 2h for mean reversion
# ═══════════════════════════════════════════════════════════════════
def custom_exit(self, pair, trade, current_time, current_rate, current_profit, **kwargs):
if not trade.open_date_utc:
return None
hours = (current_time - trade.open_date_utc).total_seconds() / 3600
# Max hold: 4 hours — mean reversion should resolve by then
if hours >= 4:
if current_profit > 0:
return "mr_profit_4h"
return "mr_timeout_4h"
return None
def leverage(self, pair, current_time, current_rate, proposed_leverage,
max_leverage, entry_tag, side, **kwargs) -> float:
return 1.0