OSIRIS DAY TRADE v3 — Dual-mode: Trend Following + Range Reversion 5m base | 15m signal | 1h regime | LONG+SHORT | ~10 trades/dia
Timeframe
5m
Direction
Long & Short
Stoploss
-2.5%
Trailing Stop
No
ROI
0m: 10.0%, 120m: 0.3%
Interface Version
3
Startup Candles
200
Indicators
8
freqtrade/freqtrade-strategies
Strategy 003 author@: Gerald Lonlas github@: https://github.com/freqtrade/freqtrade-strategies
"""
OSIRIS DAY TRADE v3 — Adaptive Dual-Mode
================================================================
DAY TRADE puro em BTC/USDT — LONG e SHORT.
MANIFESTO INVIOLÁVEL:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Ativo: BTC/USDT APENAS
Tipo: DAY TRADE (max 2h por operação)
Meta: 10 operações/dia
Direção: LONG e SHORT
Timeframes: 5m (execução) + 15m (sinal) + 1h (contexto)
Stop: 0.4-0.8% fixo (baseado em ATR normalizado)
Target: 1.5× stop
Max Loss/dia: -5R (hard stop)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
DOIS MODOS ADAPTATIVOS:
MODE TREND (1h ADX > 20 + EMA aligned):
→ Segue a tendência no pullback
→ Entries: EMA bounce, RSI pullback, momentum continuation
→ Direção: SÓ na direção do 1h trend
MODE RANGE (1h ADX < 20 OR EMAs cruzando):
→ Faz mean-reversion nos extremos
→ Entries: BB bounce, RSI extreme reversal
→ Direção: AMBOS
→ Targets menores (1.2R)
EDGE: Não tenta adivinhar — ADAPTA ao regime.
"""
import logging
import numpy as np
import pandas as pd
from pandas import DataFrame
from typing import Optional
from datetime import datetime, timedelta
from freqtrade.strategy import IStrategy, merge_informative_pair
from freqtrade.strategy import DecimalParameter, IntParameter
from freqtrade.persistence import Trade
import talib.abstract as ta
try:
from freqtrade.strategy import stoploss_from_open
except ImportError:
def stoploss_from_open(open_relative_stop, current_profit, is_short=False):
if current_profit == 0:
return 1
if is_short:
return -1 + ((1 - open_relative_stop) / (1 - current_profit))
return 1 - ((1 + open_relative_stop) / (1 + current_profit))
logger = logging.getLogger(__name__)
class OsirisDayTradeV3(IStrategy):
"""
OSIRIS DAY TRADE v3 — Dual-mode: Trend Following + Range Reversion
5m base | 15m signal | 1h regime | LONG+SHORT | ~10 trades/dia
"""
INTERFACE_VERSION = 3
can_short = True
timeframe = "5m"
# ROI: custom_exit handles real exits
minimal_roi = {"0": 0.10, "120": 0.003}
# Hard stoploss safety net (wider to let custom work)
stoploss = -0.025
trailing_stop = False
use_custom_stoploss = True
startup_candle_count = 200
process_only_new_candles = True
# Runtime state
_daily_trades = {}
_consecutive_losses = 0
_last_loss_time = None
# ═══════════════════════════════════════════════════════════════════
# HYPEROPT PARAMETERS
# ═══════════════════════════════════════════════════════════════════
# Regime threshold
buy_adx_trend_min = IntParameter(15, 30, default=20, space="buy", optimize=True)
# RSI limits for entry
buy_rsi_bull_min = IntParameter(35, 50, default=40, space="buy", optimize=True)
buy_rsi_bull_max = IntParameter(60, 80, default=70, space="buy", optimize=True)
buy_rsi_bear_min = IntParameter(20, 40, default=30, space="buy", optimize=True)
buy_rsi_bear_max = IntParameter(50, 65, default=60, space="buy", optimize=True)
# RSI extremes for range mode
buy_rsi_range_long = IntParameter(20, 35, default=30, space="buy", optimize=True)
buy_rsi_range_short = IntParameter(65, 80, default=70, space="buy", optimize=True)
# Volume
buy_vol_min = DecimalParameter(0.3, 1.0, default=0.6, decimals=1, space="buy", optimize=True)
# R:R
buy_rr_trend = DecimalParameter(1.2, 2.5, default=1.5, decimals=1, space="buy", optimize=True)
buy_rr_range = DecimalParameter(1.0, 2.0, default=1.2, decimals=1, space="buy", optimize=True)
# Max daily
buy_max_daily = IntParameter(8, 15, default=10, space="buy", optimize=True)
# Max hold (candles)
buy_max_hold = IntParameter(12, 36, default=24, space="buy", optimize=True)
# Exit RSI
sell_rsi_long = IntParameter(70, 85, default=78, space="sell", optimize=True)
sell_rsi_short = IntParameter(15, 30, default=22, space="sell", optimize=True)
# ═══════════════════════════════════════════════════════════════════
# INFORMATIVE PAIRS
# ═══════════════════════════════════════════════════════════════════
def informative_pairs(self):
return [
("BTC/USDT:USDT", "15m"),
("BTC/USDT:USDT", "1h"),
]
# ═══════════════════════════════════════════════════════════════════
# INDICATORS
# ═══════════════════════════════════════════════════════════════════
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
# ── EMAs ──
dataframe["ema9"] = ta.EMA(dataframe, timeperiod=9)
dataframe["ema21"] = ta.EMA(dataframe, timeperiod=21)
dataframe["ema50"] = ta.EMA(dataframe, timeperiod=50)
# ── RSI ──
dataframe["rsi"] = ta.RSI(dataframe, timeperiod=14)
dataframe["rsi_prev"] = dataframe["rsi"].shift(1)
# ── MACD ──
macd = ta.MACD(dataframe, fastperiod=12, slowperiod=26, signalperiod=9)
dataframe["macd_hist"] = macd["macdhist"]
dataframe["macd_hist_prev"] = dataframe["macd_hist"].shift(1)
# ── Bollinger Bands ──
bb = ta.BBANDS(dataframe, timeperiod=20, nbdevup=2.0, nbdevdn=2.0)
dataframe["bb_upper"] = bb["upperband"]
dataframe["bb_mid"] = bb["middleband"]
dataframe["bb_lower"] = bb["lowerband"]
dataframe["bb_width"] = (dataframe["bb_upper"] - dataframe["bb_lower"]) / dataframe["bb_mid"]
# ── ATR ──
dataframe["atr"] = ta.ATR(dataframe, timeperiod=14)
# ── ADX ──
dataframe["adx"] = ta.ADX(dataframe, timeperiod=14)
# ── Volume ──
dataframe["vol_sma"] = ta.SMA(dataframe["volume"], timeperiod=20)
dataframe["vol_ratio"] = dataframe["volume"] / dataframe["vol_sma"].replace(0, 1)
# ── Previous values ──
dataframe["ema9_prev"] = dataframe["ema9"].shift(1)
dataframe["ema21_prev"] = dataframe["ema21"].shift(1)
# ── Candle info ──
dataframe["is_green"] = (dataframe["close"] > dataframe["open"]).astype(int)
dataframe["is_red"] = (dataframe["close"] < dataframe["open"]).astype(int)
dataframe["body_pct"] = abs(dataframe["close"] - dataframe["open"]) / dataframe["close"] * 100
# ── Distance to key levels (ATR units) ──
atr_safe = dataframe["atr"].replace(0, 1)
dataframe["dist_ema9"] = (dataframe["close"] - dataframe["ema9"]) / atr_safe
dataframe["dist_ema21"] = (dataframe["close"] - dataframe["ema21"]) / atr_safe
dataframe["dist_bb_upper"] = (dataframe["bb_upper"] - dataframe["close"]) / atr_safe
dataframe["dist_bb_lower"] = (dataframe["close"] - dataframe["bb_lower"]) / atr_safe
# ── Multi-TF: 15m ──
if self.dp:
pair = metadata["pair"]
inf_15m = self.dp.get_pair_dataframe(pair=pair, timeframe="15m")
if not inf_15m.empty:
inf_15m["ema9"] = ta.EMA(inf_15m, timeperiod=9)
inf_15m["ema21"] = ta.EMA(inf_15m, timeperiod=21)
inf_15m["rsi"] = ta.RSI(inf_15m, timeperiod=14)
macd15 = ta.MACD(inf_15m, fastperiod=12, slowperiod=26, signalperiod=9)
inf_15m["macd_hist"] = macd15["macdhist"]
dataframe = merge_informative_pair(
dataframe, inf_15m, self.timeframe, "15m", ffill=True
)
# ── Multi-TF: 1h ──
inf_1h = self.dp.get_pair_dataframe(pair=pair, timeframe="1h")
if not inf_1h.empty:
inf_1h["ema9"] = ta.EMA(inf_1h, timeperiod=9)
inf_1h["ema21"] = ta.EMA(inf_1h, timeperiod=21)
inf_1h["ema50"] = ta.EMA(inf_1h, timeperiod=50)
inf_1h["rsi"] = ta.RSI(inf_1h, timeperiod=14)
inf_1h["adx"] = ta.ADX(inf_1h, timeperiod=14)
dataframe = merge_informative_pair(
dataframe, inf_1h, self.timeframe, "1h", ffill=True
)
return dataframe
# ═══════════════════════════════════════════════════════════════════
# ENTRY TREND — Dual Mode
# ═══════════════════════════════════════════════════════════════════
def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
has_1h = "ema9_1h" in dataframe.columns
has_15m = "ema9_15m" in dataframe.columns
adx_thresh = self.buy_adx_trend_min.value
vol_min = self.buy_vol_min.value
# ───────────────────────────────────────────
# REGIME DETECTION (from 1h)
# ───────────────────────────────────────────
if has_1h:
is_trending = dataframe["adx_1h"] > adx_thresh
trend_up = is_trending & (dataframe["ema9_1h"] > dataframe["ema21_1h"])
trend_dn = is_trending & (dataframe["ema9_1h"] < dataframe["ema21_1h"])
is_range = ~is_trending
else:
is_trending = pd.Series(False, index=dataframe.index)
trend_up = pd.Series(False, index=dataframe.index)
trend_dn = pd.Series(False, index=dataframe.index)
is_range = pd.Series(True, index=dataframe.index)
# ───────────────────────────────────────────
# COMMON FILTERS
# ───────────────────────────────────────────
vol_ok = dataframe["vol_ratio"] > vol_min
has_vol = dataframe["volume"] > 0
atr_ok = dataframe["atr"] > 0.5
# ───────────────────────────────────────────
# === MODE: TREND === (follow trend on pullbacks)
# ───────────────────────────────────────────
# -- TREND LONG entries --
# T1: EMA9 bounce in uptrend
# Price dipped near EMA9 and green candle
t1_long = (
trend_up &
(dataframe["dist_ema9"] > -0.6) &
(dataframe["dist_ema9"] < 0.3) &
(dataframe["is_green"] == 1) &
(dataframe["ema9"] > dataframe["ema21"]) & # 5m trend intact
(dataframe["rsi"] > self.buy_rsi_bull_min.value) &
(dataframe["rsi"] < self.buy_rsi_bull_max.value)
)
# T2: EMA21 deep pullback in uptrend
# Price touched EMA21 zone and bounced
t2_long = (
trend_up &
(dataframe["dist_ema21"] > -0.5) &
(dataframe["dist_ema21"] < 0.5) &
(dataframe["is_green"] == 1) &
(dataframe["rsi"] > 35) &
(dataframe["rsi"] < 55)
)
# T3: Momentum continuation
# MACD hist flips positive (buy the acceleration)
t3_long = (
trend_up &
(dataframe["macd_hist"] > 0) &
(dataframe["macd_hist_prev"] <= 0) &
(dataframe["close"] > dataframe["ema21"]) &
(dataframe["rsi"] > 45) &
(dataframe["rsi"] < 65)
)
# T4: RSI bounce from mid zone (trend continuation)
t4_long = (
trend_up &
(dataframe["rsi"] > 50) &
(dataframe["rsi_prev"] <= 50) &
(dataframe["close"] > dataframe["ema9"]) &
(dataframe["ema9"] > dataframe["ema21"])
)
trend_long = (t1_long | t2_long | t3_long | t4_long)
# -- TREND SHORT entries --
t1_short = (
trend_dn &
(dataframe["dist_ema9"] < 0.6) &
(dataframe["dist_ema9"] > -0.3) &
(dataframe["is_red"] == 1) &
(dataframe["ema9"] < dataframe["ema21"]) &
(dataframe["rsi"] > self.buy_rsi_bear_min.value) &
(dataframe["rsi"] < self.buy_rsi_bear_max.value)
)
t2_short = (
trend_dn &
(dataframe["dist_ema21"] < 0.5) &
(dataframe["dist_ema21"] > -0.5) &
(dataframe["is_red"] == 1) &
(dataframe["rsi"] > 45) &
(dataframe["rsi"] < 65)
)
t3_short = (
trend_dn &
(dataframe["macd_hist"] < 0) &
(dataframe["macd_hist_prev"] >= 0) &
(dataframe["close"] < dataframe["ema21"]) &
(dataframe["rsi"] > 35) &
(dataframe["rsi"] < 55)
)
t4_short = (
trend_dn &
(dataframe["rsi"] < 50) &
(dataframe["rsi_prev"] >= 50) &
(dataframe["close"] < dataframe["ema9"]) &
(dataframe["ema9"] < dataframe["ema21"])
)
trend_short = (t1_short | t2_short | t3_short | t4_short)
# ───────────────────────────────────────────
# === MODE: RANGE === (mean reversion at extremes)
# ───────────────────────────────────────────
# R1: BB lower touch → long
r1_long = (
is_range &
(dataframe["low"] <= dataframe["bb_lower"] * 1.002) &
(dataframe["is_green"] == 1) &
(dataframe["rsi"] < self.buy_rsi_range_long.value + 5)
)
# R2: RSI extreme oversold → long
r2_long = (
is_range &
(dataframe["rsi"] < self.buy_rsi_range_long.value) &
(dataframe["rsi_prev"] < dataframe["rsi"]) & # RSI turning up
(dataframe["is_green"] == 1)
)
# R3: BB upper touch → short
r3_short = (
is_range &
(dataframe["high"] >= dataframe["bb_upper"] * 0.998) &
(dataframe["is_red"] == 1) &
(dataframe["rsi"] > self.buy_rsi_range_short.value - 5)
)
# R4: RSI extreme overbought → short
r4_short = (
is_range &
(dataframe["rsi"] > self.buy_rsi_range_short.value) &
(dataframe["rsi_prev"] > dataframe["rsi"]) & # RSI turning down
(dataframe["is_red"] == 1)
)
range_long = (r1_long | r2_long)
range_short = (r3_short | r4_short)
# ───────────────────────────────────────────
# === 15M SOFT ALIGNMENT (bonus, not hard filter) ===
# ───────────────────────────────────────────
if has_15m:
# 15m alignment boosts confidence but not required
fifm_long = dataframe["ema9_15m"] >= dataframe["ema21_15m"]
fifm_short = dataframe["ema9_15m"] <= dataframe["ema21_15m"]
# In trend mode: 15m must not be strongly against
# (soft: allow if 15m RSI isn't extreme against us)
fifm_ok_long = (fifm_long) | (dataframe["rsi_15m"] < 65)
fifm_ok_short = (fifm_short) | (dataframe["rsi_15m"] > 35)
else:
fifm_ok_long = pd.Series(True, index=dataframe.index)
fifm_ok_short = pd.Series(True, index=dataframe.index)
# ───────────────────────────────────────────
# COMBINE ALL
# ───────────────────────────────────────────
go_long = (
(trend_long | range_long) &
vol_ok & has_vol & atr_ok &
fifm_ok_long
)
go_short = (
(trend_short | range_short) &
vol_ok & has_vol & atr_ok &
fifm_ok_short
)
dataframe.loc[go_long, "enter_long"] = 1
dataframe.loc[go_short, "enter_short"] = 1
# Entry tags
for cond, tag in [
(go_long & t1_long, "trend_ema9_long"),
(go_long & t2_long, "trend_ema21_long"),
(go_long & t3_long, "trend_macd_long"),
(go_long & t4_long, "trend_rsi_long"),
(go_long & r1_long, "range_bb_long"),
(go_long & r2_long, "range_rsi_long"),
(go_short & t1_short, "trend_ema9_short"),
(go_short & t2_short, "trend_ema21_short"),
(go_short & t3_short, "trend_macd_short"),
(go_short & t4_short, "trend_rsi_short"),
(go_short & r3_short, "range_bb_short"),
(go_short & r4_short, "range_rsi_short"),
]:
dataframe.loc[cond, "enter_tag"] = tag
return dataframe
# ═══════════════════════════════════════════════════════════════════
# EXIT TREND
# ═══════════════════════════════════════════════════════════════════
def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
dataframe.loc[
dataframe["rsi"] > self.sell_rsi_long.value,
"exit_long",
] = 1
dataframe.loc[
dataframe["rsi"] < self.sell_rsi_short.value,
"exit_short",
] = 1
return dataframe
# ═══════════════════════════════════════════════════════════════════
# CUSTOM STOPLOSS — ATR-based + R-multiple trailing
# ═══════════════════════════════════════════════════════════════════
def custom_stoploss(
self,
pair: str,
trade: Trade,
current_time,
current_rate: float,
current_profit: float,
**kwargs,
) -> float:
dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
if len(dataframe) == 0:
return -0.015
last = dataframe.iloc[-1]
atr = last.get("atr", 0)
if atr == 0 or trade.open_rate == 0:
return -0.015
is_short = trade.is_short if hasattr(trade, "is_short") else False
# Stop distance: 1.5 ATR (simple, reliable)
stop_dist_pct = 1.5 * atr / trade.open_rate
stop_dist_pct = max(0.004, min(stop_dist_pct, 0.015))
# R-multiple trailing
if current_profit > 0 and stop_dist_pct > 0:
r_mult = current_profit / stop_dist_pct
if r_mult >= 1.5:
return stoploss_from_open(
0.5 * stop_dist_pct, current_profit, is_short=is_short
)
elif r_mult >= 1.0:
return stoploss_from_open(
0.001, current_profit, is_short=is_short
)
return -stop_dist_pct
# ═══════════════════════════════════════════════════════════════════
# CUSTOM EXIT — Target + Time
# ═══════════════════════════════════════════════════════════════════
def custom_exit(
self,
pair: str,
trade: Trade,
current_time,
current_rate: float,
current_profit: float,
**kwargs,
) -> Optional[str]:
dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
if len(dataframe) == 0:
return None
last = dataframe.iloc[-1]
atr = last.get("atr", 0)
if atr == 0 or trade.open_rate == 0:
return None
is_short = trade.is_short if hasattr(trade, "is_short") else False
# Risk = 1.5 ATR
risk_pct = max(1.5 * atr / trade.open_rate, 0.004)
# Determine R:R based on mode (check if current regime is ranging)
adx_1h = last.get("adx_1h", 25)
is_ranging = adx_1h < self.buy_adx_trend_min.value if not pd.isna(adx_1h) else True
rr = self.buy_rr_range.value if is_ranging else self.buy_rr_trend.value
target_pct = risk_pct * rr
# ── TARGET HIT ──
if current_profit >= target_pct:
return "v3_tp"
# ── Momentum fade: early exit at 70% target ──
if current_profit >= target_pct * 0.7:
rsi = last.get("rsi", 50)
if is_short and rsi < 25:
return "v3_tp_fade"
if not is_short and rsi > 75:
return "v3_tp_fade"
# ── Time management ──
minutes = (current_time - trade.open_date_utc).total_seconds() / 60
max_min = self.buy_max_hold.value * 5
if minutes > max_min * 0.7 and current_profit > 0.002:
return "v3_time_profit"
if minutes > max_min:
return "v3_time_force"
return None
# ═══════════════════════════════════════════════════════════════════
# CONFIRM ENTRY — Daily limits + loss streak
# ═══════════════════════════════════════════════════════════════════
def confirm_trade_entry(
self,
pair: str,
order_type: str,
amount: float,
rate: float,
time_in_force: str,
current_time: datetime,
entry_tag: Optional[str],
side: str,
**kwargs,
) -> bool:
today = current_time.strftime("%Y-%m-%d")
if today not in self._daily_trades:
self._daily_trades = {today: 0}
if self._daily_trades.get(today, 0) >= self.buy_max_daily.value:
return False
# 3 losses → 30 min pause
if self._consecutive_losses >= 3 and self._last_loss_time:
if current_time < self._last_loss_time + timedelta(minutes=30):
return False
self._consecutive_losses = 0
self._daily_trades[today] = self._daily_trades.get(today, 0) + 1
return True
def confirm_trade_exit(
self,
pair: str,
trade: Trade,
order_type: str,
amount: float,
rate: float,
time_in_force: str,
exit_reason: str,
current_time: datetime,
**kwargs,
) -> bool:
profit = trade.calc_profit_ratio(rate)
if profit < 0:
self._consecutive_losses += 1
self._last_loss_time = current_time
else:
self._consecutive_losses = 0
return True