OSIRIS v6 — Trailing-only pullback strategy. No fixed target. Trailing stop lets winners run. Pullback entry gives better prices than momentum chase.
Timeframe
15m
Direction
Long & Short
Stoploss
-6.0%
Trailing Stop
No
ROI
0m: 50.0%
Interface Version
3
Startup Candles
200
Indicators
6
freqtrade/freqtrade-strategies
Strategy 003 author@: Gerald Lonlas github@: https://github.com/freqtrade/freqtrade-strategies
"""
OSIRIS DAY TRADE v6 — Trailing Pullback + Funding Rate
================================================================
MANIFESTO:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Ativo: BTC/USDT FUTURES
Tipo: DAY TRADE
Meta: 5-10 operações/dia
Timeframe: 15m (primário) + 1h (tendência)
Stop: 4×ATR inicial (~3-5%)
Target: NENHUM FIXO — trailing puro (deja winners rodarem)
Trail: Progressivo: 3.5ATR → 3ATR → 2ATR conforme lucro
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
INSIGHT CHAVE (v1-v5):
Stops < 2× wick_médio → stopado por ruído (28-43% WR)
Target fixo 1:1 → limita winners, não compensa losers
SOLUÇÃO: trailing PURO — winners rodam até o trail pegar
ENTRADA: PULLBACK EM TENDÊNCIA
LONG:
1. 1h trend UP (EMA9 > EMA21 + ADX > 18)
2. 15m pullback (2+ candles vermelhas OU RSI < 45)
3. 15m reversão (candle verde com volume)
SHORT: espelho
FUNDING RATE (contrarian filter):
- Funding > +0.02% → evita long (crowded)
- Funding < -0.01% → evita short (crowded)
TRAILING PROGRESSIVO:
profit < 1R: stop a -4 ATR (deixa respirar)
profit >= 1R: stop a -3 ATR (protege)
profit >= 2R: stop a -2 ATR (lock hard)
profit >= 3R: stop a -1.5 ATR (squeeze)
"""
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 OsirisDayTradeV6(IStrategy):
"""
OSIRIS v6 — Trailing-only pullback strategy.
No fixed target. Trailing stop lets winners run.
Pullback entry gives better prices than momentum chase.
"""
INTERFACE_VERSION = 3
can_short = True
timeframe = "15m"
# Wide ROI — trailing does the work
minimal_roi = {"0": 0.50} # basically disabled
# Very wide safety stoploss
stoploss = -0.06
trailing_stop = False
use_custom_stoploss = True
startup_candle_count = 200
process_only_new_candles = True
_daily_trades = {}
_consecutive_losses = 0
_last_loss_time = None
# ═══════════════════════════════════════════════════════════════════
# PARAMETERS
# ═══════════════════════════════════════════════════════════════════
# Stop sizes (ATR multiples)
buy_sl_initial = DecimalParameter(3.0, 5.0, default=4.0, decimals=1, space="buy", optimize=True)
buy_sl_trail_1 = DecimalParameter(2.5, 4.0, default=3.0, decimals=1, space="buy", optimize=True)
buy_sl_trail_2 = DecimalParameter(1.5, 3.0, default=2.0, decimals=1, space="buy", optimize=True)
buy_sl_trail_3 = DecimalParameter(1.0, 2.0, default=1.5, decimals=1, space="buy", optimize=True)
# 1h ADX minimum
buy_adx_min = IntParameter(12, 25, default=18, space="buy", optimize=True)
# Pullback detection
buy_pullback_candles = IntParameter(1, 4, default=2, space="buy", optimize=True)
buy_rsi_pullback_long = IntParameter(30, 50, default=45, space="buy", optimize=True)
buy_rsi_pullback_short = IntParameter(50, 70, default=55, space="buy", optimize=True)
# Volume
buy_vol_min = DecimalParameter(0.4, 1.2, default=0.7, decimals=1, space="buy", optimize=True)
# Max daily
buy_max_daily = IntParameter(6, 15, default=10, space="buy", optimize=True)
# Max hold (15m candles)
buy_max_hold = IntParameter(16, 48, default=32, space="buy", optimize=True)
# ═══════════════════════════════════════════════════════════════════
# INFORMATIVE
# ═══════════════════════════════════════════════════════════════════
def informative_pairs(self):
return [
("BTC/USDT:USDT", "1h"),
]
# ═══════════════════════════════════════════════════════════════════
# INDICATORS
# ═══════════════════════════════════════════════════════════════════
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
# 15m indicators
dataframe["ema9"] = ta.EMA(dataframe, timeperiod=9)
dataframe["ema21"] = ta.EMA(dataframe, timeperiod=21)
dataframe["ema50"] = ta.EMA(dataframe, timeperiod=50)
dataframe["rsi"] = ta.RSI(dataframe, timeperiod=14)
dataframe["atr"] = ta.ATR(dataframe, timeperiod=14)
macd = ta.MACD(dataframe, fastperiod=12, slowperiod=26, signalperiod=9)
dataframe["macd_hist"] = macd["macdhist"]
dataframe["vol_sma"] = ta.SMA(dataframe["volume"], timeperiod=20)
dataframe["vol_ratio"] = dataframe["volume"] / dataframe["vol_sma"].replace(0, 1)
# Candle direction
dataframe["is_green"] = (dataframe["close"] > dataframe["open"]).astype(int)
dataframe["is_red"] = (dataframe["close"] < dataframe["open"]).astype(int)
# Count consecutive red/green candles (for pullback detection)
dataframe["consec_red"] = 0
dataframe["consec_green"] = 0
red = dataframe["is_red"].values
green = dataframe["is_green"].values
n = len(dataframe)
c_red = np.zeros(n, dtype=int)
c_green = np.zeros(n, dtype=int)
for i in range(1, n):
if red[i - 1]:
c_red[i] = c_red[i - 1] + 1
else:
c_red[i] = 0
if green[i - 1]:
c_green[i] = c_green[i - 1] + 1
else:
c_green[i] = 0
dataframe["consec_red"] = c_red
dataframe["consec_green"] = c_green
# 1h merge
if self.dp:
pair = metadata["pair"]
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["adx"] = ta.ADX(inf_1h, timeperiod=14)
inf_1h["rsi"] = ta.RSI(inf_1h, timeperiod=14)
dataframe = merge_informative_pair(
dataframe, inf_1h, self.timeframe, "1h", ffill=True
)
return dataframe
# ═══════════════════════════════════════════════════════════════════
# ENTRIES — Pullback in trend
# ═══════════════════════════════════════════════════════════════════
def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
has_1h = "ema9_1h" in dataframe.columns
pb_candles = self.buy_pullback_candles.value
rsi_pb_long = self.buy_rsi_pullback_long.value
rsi_pb_short = self.buy_rsi_pullback_short.value
vol_min = self.buy_vol_min.value
# Common
vol_ok = dataframe["vol_ratio"] > vol_min
has_vol = dataframe["volume"] > 0
atr_ok = dataframe["atr"] > 1
# ── 1H TREND ──
if has_1h:
h1_up = (dataframe["ema9_1h"] > dataframe["ema21_1h"]) & (dataframe["adx_1h"] > self.buy_adx_min.value)
h1_dn = (dataframe["ema9_1h"] < dataframe["ema21_1h"]) & (dataframe["adx_1h"] > self.buy_adx_min.value)
else:
h1_up = pd.Series(True, index=dataframe.index)
h1_dn = pd.Series(True, index=dataframe.index)
# ── PULLBACK DETECTION ──
# Long pullback: N consecutive red candles OR RSI dipped below threshold
pullback_long = (
(dataframe["consec_red"] >= pb_candles) |
(dataframe["rsi"] < rsi_pb_long)
)
# Short pullback: N consecutive green candles OR RSI spiked above threshold
pullback_short = (
(dataframe["consec_green"] >= pb_candles) |
(dataframe["rsi"] > rsi_pb_short)
)
# ── REVERSAL CANDLE (entry trigger) ──
reversal_long = dataframe["is_green"] == 1
reversal_short = dataframe["is_red"] == 1
# ── TREND ALIGNMENT ON 15m ──
# For longs: 15m still in uptrend (EMA9 > EMA21) despite pullback
ema_trend_up = dataframe["ema9"] > dataframe["ema21"]
ema_trend_dn = dataframe["ema9"] < dataframe["ema21"]
# ── COMBINE ──
go_long = (
h1_up &
pullback_long &
reversal_long &
ema_trend_up & # pullback within uptrend, not reversal
vol_ok & has_vol & atr_ok
)
go_short = (
h1_dn &
pullback_short &
reversal_short &
ema_trend_dn &
vol_ok & has_vol & atr_ok
)
dataframe.loc[go_long, "enter_long"] = 1
dataframe.loc[go_short, "enter_short"] = 1
# Tags
dataframe.loc[go_long & (dataframe["consec_red"] >= pb_candles), "enter_tag"] = "pullback_candles_long"
dataframe.loc[go_long & (dataframe["rsi"] < rsi_pb_long), "enter_tag"] = "pullback_rsi_long"
dataframe.loc[go_short & (dataframe["consec_green"] >= pb_candles), "enter_tag"] = "pullback_candles_short"
dataframe.loc[go_short & (dataframe["rsi"] > rsi_pb_short), "enter_tag"] = "pullback_rsi_short"
return dataframe
# ═══════════════════════════════════════════════════════════════════
# EXIT — Minimal: let trailing do the work
# ═══════════════════════════════════════════════════════════════════
def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
# Only exit on extreme conditions
dataframe.loc[dataframe["rsi"] > 88, "exit_long"] = 1
dataframe.loc[dataframe["rsi"] < 12, "exit_short"] = 1
return dataframe
# ═══════════════════════════════════════════════════════════════════
# CUSTOM STOPLOSS — Progressive ATR 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.04
last = dataframe.iloc[-1]
atr = last.get("atr", 0)
if atr == 0 or trade.open_rate == 0:
return -0.04
# R = initial risk (4 ATR from entry)
r_pct = self.buy_sl_initial.value * atr / trade.open_rate
r_pct = max(0.02, min(r_pct, 0.05))
if current_profit <= 0:
# No profit: use initial wide stop
return -r_pct
# R-multiple achieved
r_mult = current_profit / r_pct
# Progressive trailing: tighter as profit grows
if r_mult >= 3.0:
trail = self.buy_sl_trail_3.value * atr / trade.open_rate
elif r_mult >= 2.0:
trail = self.buy_sl_trail_2.value * atr / trade.open_rate
elif r_mult >= 1.0:
trail = self.buy_sl_trail_1.value * atr / trade.open_rate
else:
# < 1R profit: still use initial stop
return -r_pct
trail = max(0.008, min(trail, 0.04))
return -trail
# ═══════════════════════════════════════════════════════════════════
# CUSTOM EXIT — Time management only
# ═══════════════════════════════════════════════════════════════════
def custom_exit(
self,
pair: str,
trade: Trade,
current_time,
current_rate: float,
current_profit: float,
**kwargs,
) -> Optional[str]:
minutes = (current_time - trade.open_date_utc).total_seconds() / 60
max_min = self.buy_max_hold.value * 15
# If profitable and running long, take it
if minutes > max_min * 0.7 and current_profit > 0.005:
return "v6_time_profit"
# Hard max hold
if minutes > max_min:
return "v6_time_force"
# Exit if 1h trend flips against position
dataframe, _ = self.dp.get_analyzed_dataframe(trade.pair, self.timeframe)
if len(dataframe) > 0:
last = dataframe.iloc[-1]
is_short = trade.is_short if hasattr(trade, "is_short") else False
if "ema9_1h" in last.index and "ema21_1h" in last.index:
h1_up = last.get("ema9_1h", 0) > last.get("ema21_1h", 0)
h1_dn = last.get("ema9_1h", 0) < last.get("ema21_1h", 0)
# Trend flipped against us AND we're in small profit/loss
if not is_short and h1_dn and current_profit > -0.005:
return "v6_trend_flip"
if is_short and h1_up and current_profit > -0.005:
return "v6_trend_flip"
return None
# ═══════════════════════════════════════════════════════════════════
# CONFIRM
# ═══════════════════════════════════════════════════════════════════
def confirm_trade_entry(self, pair, order_type, amount, rate, time_in_force,
current_time, entry_tag, side, **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
if self._consecutive_losses >= 3 and self._last_loss_time:
if current_time < self._last_loss_time + timedelta(minutes=45):
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, trade, order_type, amount, rate,
time_in_force, exit_reason, current_time, **kwargs) -> bool:
if trade.calc_profit_ratio(rate) < 0:
self._consecutive_losses += 1
self._last_loss_time = current_time
else:
self._consecutive_losses = 0
return True