OSIRIS DAY TRADE v2 — Multi-TF Trend Following com Pullback 5m main | 15m signal | 1h trend | LONG + SHORT | 10 trades/dia
Timeframe
5m
Direction
Long & Short
Stoploss
-2.0%
Trailing Stop
No
ROI
0m: 10.0%, 60m: 3.0%, 120m: 1.0%, 180m: 0.3%
Interface Version
3
Startup Candles
200
Indicators
7
freqtrade/freqtrade-strategies
Strategy 003 author@: Gerald Lonlas github@: https://github.com/freqtrade/freqtrade-strategies
"""
OSIRIS DAY TRADE v2 — Multi-Timeframe Trend Pullback
================================================================
DAY TRADE puro em BTC/USDT — LONG e SHORT.
MANIFESTO INVIOLÁVEL:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Ativo: BTC/USDT APENAS
Tipo: DAY TRADE (max 3h por operação)
Meta: 10 operações/dia
Direção: LONG e SHORT
Timeframes: 5m (execução) + 15m (sinal) + 1h (tendência)
Risco/Alvo: 1.5:1 mínimo, média 1.8:1
Max Loss/dia: -5R (hard stop)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
FILOSOFIA:
Não tenta adivinhar topos e fundos.
SEGUE A TENDÊNCIA e entra no PULLBACK.
1H diz PRA ONDE. 15m diz QUANDO. 5m diz O PREÇO EXATO.
ENTRADAS (4 tipos, cada um ~2-3/dia = 10 total):
A) EMA Pullback: Preço retorna à EMA21, candle de reversão
B) BB Bounce: Preço toca BB oposta ao trend, reverte
C) Momentum Cross: EMA9 cruza EMA21 na direção do 1H
D) RSI Divergence: RSI volta de zona extrema na direção do trend
STOPS:
SL = abaixo/acima do swing recente (5 bars) + 0.1×ATR buffer
Mínimo 0.3%, máximo 1.5%
Trail: breakeven em 1R, lock 0.5R em 1.5R
100% proprietário. OSIRIS v2 — Trend Following, Not Guessing.
"""
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 OsirisDayTradeV2(IStrategy):
"""
OSIRIS DAY TRADE v2 — Multi-TF Trend Following com Pullback
5m main | 15m signal | 1h trend | LONG + SHORT | 10 trades/dia
"""
INTERFACE_VERSION = 3
can_short = True
timeframe = "5m"
# ROI: wide — custom_exit handles the real exits
minimal_roi = {"0": 0.10, "60": 0.03, "120": 0.01, "180": 0.003}
# Safety net stoploss
stoploss = -0.02
# Custom stoploss handles R-based trailing
trailing_stop = False
use_custom_stoploss = True
startup_candle_count = 200
process_only_new_candles = True
# Runtime state
_daily_trades = {}
_daily_losses_r = {}
_consecutive_losses = 0
_last_loss_time = None
# ═══════════════════════════════════════════════════════════════════
# HYPEROPT PARAMETERS
# ═══════════════════════════════════════════════════════════════════
# Trend alignment strictness
buy_trend_strict = IntParameter(0, 1, default=0, space="buy", optimize=True)
# Pullback depth (how close to EMA for pullback entry)
buy_pullback_atr_mult = DecimalParameter(0.2, 1.0, default=0.5, decimals=1, space="buy", optimize=True)
# RSI thresholds
buy_rsi_ob = IntParameter(65, 80, default=70, space="buy", optimize=True)
buy_rsi_os = IntParameter(20, 35, default=30, space="buy", optimize=True)
# Volume filter
buy_vol_min = DecimalParameter(0.5, 1.5, default=0.7, decimals=1, space="buy", optimize=True)
# R:R target
buy_rr_target = DecimalParameter(1.2, 2.5, default=1.5, decimals=1, space="buy", optimize=True)
# Max trades per day
buy_max_daily = IntParameter(8, 15, default=10, space="buy", optimize=True)
# Cooldown (candles between entries)
buy_cooldown = IntParameter(2, 8, default=3, space="buy", optimize=True)
# Max hold (candles of 5m = max 36 = 3 hours)
buy_max_hold = IntParameter(18, 48, default=36, space="buy", optimize=True)
# Stop buffer
buy_sl_buffer_atr = DecimalParameter(0.05, 0.3, default=0.1, decimals=2, space="buy", optimize=True)
# Exit
sell_rsi_exit_long = IntParameter(72, 85, default=78, space="sell", optimize=True)
sell_rsi_exit_short = IntParameter(15, 28, default=22, space="sell", optimize=True)
# ═══════════════════════════════════════════════════════════════════
# INFORMATIVE PAIRS
# ═══════════════════════════════════════════════════════════════════
def informative_pairs(self):
return [
("BTC/USDT:USDT", "15m"),
("BTC/USDT:USDT", "1h"),
]
# ═══════════════════════════════════════════════════════════════════
# INDICATORS — 5m (main)
# ═══════════════════════════════════════════════════════════════════
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)
# === MACD ===
macd = ta.MACD(dataframe, fastperiod=12, slowperiod=26, signalperiod=9)
dataframe["macd"] = macd["macd"]
dataframe["macd_signal"] = macd["macdsignal"]
dataframe["macd_hist"] = macd["macdhist"]
# === 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"]
# === ATR ===
dataframe["atr"] = ta.ATR(dataframe, timeperiod=14)
# === ADX ===
dataframe["adx"] = ta.ADX(dataframe, timeperiod=14)
dataframe["plus_di"] = ta.PLUS_DI(dataframe, timeperiod=14)
dataframe["minus_di"] = ta.MINUS_DI(dataframe, timeperiod=14)
# === Volume ===
dataframe["vol_sma"] = ta.SMA(dataframe["volume"], timeperiod=20)
dataframe["vol_ratio"] = dataframe["volume"] / dataframe["vol_sma"].replace(0, 1)
# === Swing levels (5 bar) ===
dataframe["swing_high_5"] = dataframe["high"].rolling(5, center=False).max().shift(1)
dataframe["swing_low_5"] = dataframe["low"].rolling(5, center=False).min().shift(1)
dataframe["swing_high_10"] = dataframe["high"].rolling(10, center=False).max().shift(1)
dataframe["swing_low_10"] = dataframe["low"].rolling(10, center=False).min().shift(1)
# === EMA previous values (for cross detection) ===
dataframe["ema9_prev"] = dataframe["ema9"].shift(1)
dataframe["ema21_prev"] = dataframe["ema21"].shift(1)
dataframe["rsi_prev"] = dataframe["rsi"].shift(1)
dataframe["close_prev"] = dataframe["close"].shift(1)
# === Candle patterns ===
dataframe["is_bullish"] = (dataframe["close"] > dataframe["open"]).astype(int)
dataframe["is_bearish"] = (dataframe["close"] < dataframe["open"]).astype(int)
dataframe["body"] = abs(dataframe["close"] - dataframe["open"])
dataframe["upper_wick"] = dataframe["high"] - dataframe[["close", "open"]].max(axis=1)
dataframe["lower_wick"] = dataframe[["close", "open"]].min(axis=1) - dataframe["low"]
dataframe["candle_range"] = dataframe["high"] - dataframe["low"]
# === Distance to EMAs (in ATR units) ===
dataframe["dist_ema21"] = (dataframe["close"] - dataframe["ema21"]) / dataframe["atr"].replace(0, 1)
dataframe["dist_ema50"] = (dataframe["close"] - dataframe["ema50"]) / dataframe["atr"].replace(0, 1)
# === 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["ema50"] = ta.EMA(inf_15m, timeperiod=50)
inf_15m["rsi"] = ta.RSI(inf_15m, timeperiod=14)
inf_15m["atr"] = ta.ATR(inf_15m, timeperiod=14)
macd15 = ta.MACD(inf_15m, fastperiod=12, slowperiod=26, signalperiod=9)
inf_15m["macd_hist"] = macd15["macdhist"]
bb15 = ta.BBANDS(inf_15m, timeperiod=20, nbdevup=2.0, nbdevdn=2.0)
inf_15m["bb_upper"] = bb15["upperband"]
inf_15m["bb_lower"] = bb15["lowerband"]
inf_15m["adx"] = ta.ADX(inf_15m, timeperiod=14)
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)
macd1h = ta.MACD(inf_1h, fastperiod=12, slowperiod=26, signalperiod=9)
inf_1h["macd_hist"] = macd1h["macdhist"]
dataframe = merge_informative_pair(
dataframe, inf_1h, self.timeframe, "1h", ffill=True
)
return dataframe
# ═══════════════════════════════════════════════════════════════════
# ENTRY TREND
# ═══════════════════════════════════════════════════════════════════
def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
pb_mult = self.buy_pullback_atr_mult.value
vol_min = self.buy_vol_min.value
rsi_ob = self.buy_rsi_ob.value
rsi_os = self.buy_rsi_os.value
# ───────────────────────────────────────────
# 1H TREND BIAS
# ───────────────────────────────────────────
# Check if 1h columns exist
has_1h = "ema9_1h" in dataframe.columns
has_15m = "ema9_15m" in dataframe.columns
if has_1h:
trend_up_1h = dataframe["ema9_1h"] > dataframe["ema21_1h"]
trend_dn_1h = dataframe["ema9_1h"] < dataframe["ema21_1h"]
above_ema50_1h = dataframe["close"] > dataframe["ema50_1h"]
below_ema50_1h = dataframe["close"] < dataframe["ema50_1h"]
else:
trend_up_1h = pd.Series(True, index=dataframe.index)
trend_dn_1h = pd.Series(True, index=dataframe.index)
above_ema50_1h = pd.Series(True, index=dataframe.index)
below_ema50_1h = pd.Series(True, index=dataframe.index)
# Strict mode: require close above/below 1h EMA50 too
if self.buy_trend_strict.value == 1 and has_1h:
bias_long = trend_up_1h & above_ema50_1h
bias_short = trend_dn_1h & below_ema50_1h
else:
bias_long = trend_up_1h
bias_short = trend_dn_1h
# ───────────────────────────────────────────
# 15M MOMENTUM ALIGNMENT
# ───────────────────────────────────────────
if has_15m:
mom_long_15m = dataframe["ema9_15m"] > dataframe["ema21_15m"]
mom_short_15m = dataframe["ema9_15m"] < dataframe["ema21_15m"]
else:
mom_long_15m = pd.Series(True, index=dataframe.index)
mom_short_15m = pd.Series(True, index=dataframe.index)
# ───────────────────────────────────────────
# 5M COMMON FILTERS
# ───────────────────────────────────────────
vol_ok = dataframe["vol_ratio"] > vol_min
has_volume = dataframe["volume"] > 0
atr_valid = dataframe["atr"] > 0.5
# Risk: stop distance must be between 0.3% and 1.5%
stop_long = dataframe["swing_low_5"]
stop_short = dataframe["swing_high_5"]
risk_long_pct = (dataframe["close"] - stop_long) / dataframe["close"]
risk_short_pct = (stop_short - dataframe["close"]) / dataframe["close"]
risk_ok_long = (risk_long_pct > 0.003) & (risk_long_pct < 0.015)
risk_ok_short = (risk_short_pct > 0.003) & (risk_short_pct < 0.015)
# ───────────────────────────────────────────
# ENTRY TYPE A: EMA PULLBACK
# Price touched EMA21 zone and bounced
# ───────────────────────────────────────────
near_ema21_long = (
(dataframe["dist_ema21"] > -pb_mult) &
(dataframe["dist_ema21"] < pb_mult * 0.5) &
(dataframe["is_bullish"] == 1)
)
near_ema21_short = (
(dataframe["dist_ema21"] < pb_mult) &
(dataframe["dist_ema21"] > -pb_mult * 0.5) &
(dataframe["is_bearish"] == 1)
)
# EMA9 still above EMA21 (trend intact)
ema_trend_up = dataframe["ema9"] > dataframe["ema21"]
ema_trend_dn = dataframe["ema9"] < dataframe["ema21"]
entry_a_long = near_ema21_long & ema_trend_up
entry_a_short = near_ema21_short & ema_trend_dn
# ───────────────────────────────────────────
# ENTRY TYPE B: BB BOUNCE
# Price touched opposite BB and reverses
# ───────────────────────────────────────────
touch_bb_lower = dataframe["low"] <= dataframe["bb_lower"] * 1.001
touch_bb_upper = dataframe["high"] >= dataframe["bb_upper"] * 0.999
entry_b_long = touch_bb_lower & (dataframe["is_bullish"] == 1) & (dataframe["rsi"] < 40)
entry_b_short = touch_bb_upper & (dataframe["is_bearish"] == 1) & (dataframe["rsi"] > 60)
# ───────────────────────────────────────────
# ENTRY TYPE C: EMA CROSS (momentum start)
# EMA9 crosses EMA21 in direction of 1h trend
# ───────────────────────────────────────────
ema_cross_up = (dataframe["ema9"] > dataframe["ema21"]) & (dataframe["ema9_prev"] <= dataframe["ema21_prev"])
ema_cross_dn = (dataframe["ema9"] < dataframe["ema21"]) & (dataframe["ema9_prev"] >= dataframe["ema21_prev"])
entry_c_long = ema_cross_up & (dataframe["adx"] > 18)
entry_c_short = ema_cross_dn & (dataframe["adx"] > 18)
# ───────────────────────────────────────────
# ENTRY TYPE D: RSI REVERSAL FROM EXTREME
# RSI crosses back from OS/OB zone in trend direction
# ───────────────────────────────────────────
rsi_bounce_long = (dataframe["rsi"] > rsi_os) & (dataframe["rsi_prev"] <= rsi_os)
rsi_bounce_short = (dataframe["rsi"] < rsi_ob) & (dataframe["rsi_prev"] >= rsi_ob)
entry_d_long = rsi_bounce_long & (dataframe["close"] > dataframe["ema50"])
entry_d_short = rsi_bounce_short & (dataframe["close"] < dataframe["ema50"])
# ───────────────────────────────────────────
# COMBINE: bias + momentum + entry + filters
# ───────────────────────────────────────────
any_long_entry = entry_a_long | entry_b_long | entry_c_long | entry_d_long
any_short_entry = entry_a_short | entry_b_short | entry_c_short | entry_d_short
go_long = (
bias_long &
mom_long_15m &
any_long_entry &
vol_ok &
has_volume &
atr_valid &
risk_ok_long
)
go_short = (
bias_short &
mom_short_15m &
any_short_entry &
vol_ok &
has_volume &
atr_valid &
risk_ok_short
)
# Tag entries for analysis
dataframe.loc[go_long, "enter_long"] = 1
dataframe.loc[go_short, "enter_short"] = 1
# Entry tags
conditions_long = [
(go_long & entry_a_long, "ema_pullback_long"),
(go_long & entry_b_long, "bb_bounce_long"),
(go_long & entry_c_long, "ema_cross_long"),
(go_long & entry_d_long, "rsi_reversal_long"),
]
conditions_short = [
(go_short & entry_a_short, "ema_pullback_short"),
(go_short & entry_b_short, "bb_bounce_short"),
(go_short & entry_c_short, "ema_cross_short"),
(go_short & entry_d_short, "rsi_reversal_short"),
]
for cond, tag in conditions_long + conditions_short:
dataframe.loc[cond, "enter_tag"] = tag
return dataframe
# ═══════════════════════════════════════════════════════════════════
# EXIT TREND
# ═══════════════════════════════════════════════════════════════════
def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
# Long exit: RSI extreme overbought
dataframe.loc[
dataframe["rsi"] > self.sell_rsi_exit_long.value,
"exit_long",
] = 1
# Short exit: RSI extreme oversold
dataframe.loc[
dataframe["rsi"] < self.sell_rsi_exit_short.value,
"exit_short",
] = 1
return dataframe
# ═══════════════════════════════════════════════════════════════════
# CUSTOM STOPLOSS — Structural + 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
# Calculate initial risk from structure
if is_short:
swing_ref = last.get("swing_high_5", 0)
if swing_ref > 0:
buf = self.buy_sl_buffer_atr.value * atr
stop_price = swing_ref + buf
stop_dist_pct = (stop_price - trade.open_rate) / trade.open_rate
else:
stop_dist_pct = 1.0 * atr / trade.open_rate
else:
swing_ref = last.get("swing_low_5", 0)
if swing_ref > 0:
buf = self.buy_sl_buffer_atr.value * atr
stop_price = swing_ref - buf
stop_dist_pct = (trade.open_rate - stop_price) / trade.open_rate
else:
stop_dist_pct = 1.0 * atr / trade.open_rate
# Clamp stop distance
stop_dist_pct = max(0.003, 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 >= 2.0:
# Near/past target: lock 1R profit
return stoploss_from_open(
1.0 * stop_dist_pct, current_profit, is_short=is_short
)
elif r_mult >= 1.5:
# Lock 0.5R
return stoploss_from_open(
0.5 * stop_dist_pct, current_profit, is_short=is_short
)
elif r_mult >= 1.0:
# Breakeven
return stoploss_from_open(
0.001, current_profit, is_short=is_short
)
return -stop_dist_pct
# ═══════════════════════════════════════════════════════════════════
# CUSTOM EXIT — R:R Target + Time + Momentum Fade
# ═══════════════════════════════════════════════════════════════════
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
# Estimate risk distance
if is_short:
swing = last.get("swing_high_5", 0)
risk = (swing - trade.open_rate) if swing > 0 else 1.0 * atr
else:
swing = last.get("swing_low_5", 0)
risk = (trade.open_rate - swing) if swing > 0 else 1.0 * atr
risk_pct = max(risk / trade.open_rate, 0.003)
target_pct = risk_pct * self.buy_rr_target.value
# ── R:R TARGET HIT ──
if current_profit >= target_pct:
return "v2_tp_rr"
# ── MOMENTUM FADE → early exit at 60% of target ──
if current_profit >= target_pct * 0.6:
rsi = last.get("rsi", 50)
macd_h = last.get("macd_hist", 0)
if is_short:
# Short: momentum fades when RSI very low or MACD turns positive
if rsi < 25 or macd_h > 0:
return "v2_tp_fade"
else:
# Long: momentum fades when RSI very high or MACD turns negative
if rsi > 75 or macd_h < 0:
return "v2_tp_fade"
# ── TIME EXIT ──
minutes = (current_time - trade.open_date_utc).total_seconds() / 60
max_minutes = self.buy_max_hold.value * 5
# Small profit over time → take it
if minutes > max_minutes * 0.7 and current_profit > 0.002:
return "v2_time_profit"
# Hard time limit
if minutes > max_minutes:
return "v2_time_force"
return None
# ═══════════════════════════════════════════════════════════════════
# CONFIRM ENTRY — Daily limits + cooldown + loss management
# ═══════════════════════════════════════════════════════════════════
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")
# Reset daily counters
if today not in self._daily_trades:
self._daily_trades = {today: 0}
self._daily_losses_r = {today: 0.0}
# Daily trade limit
if self._daily_trades.get(today, 0) >= self.buy_max_daily.value:
return False
# Daily loss limit: stop after -5R
if self._daily_losses_r.get(today, 0) <= -5.0:
return False
# 3 consecutive losses → 30 min pause
if self._consecutive_losses >= 3 and self._last_loss_time:
pause_until = self._last_loss_time + timedelta(minutes=30)
if current_time < pause_until:
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)
today = current_time.strftime("%Y-%m-%d")
if profit < 0:
self._consecutive_losses += 1
self._last_loss_time = current_time
# Track daily losses in R
self._daily_losses_r[today] = self._daily_losses_r.get(today, 0) - 1
else:
self._consecutive_losses = 0
self._daily_losses_r[today] = self._daily_losses_r.get(today, 0) + profit * 100
return True