OSIRIS DAY TRADE v4 — 15m Primary, structural stops, momentum entries. Key insight: wider stops survive BTC volatility → higher WR → profit.
Timeframe
15m
Direction
Long & Short
Stoploss
-3.5%
Trailing Stop
No
ROI
0m: 10.0%, 180m: 0.5%
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 v4 — 15m Primary, Structural Momentum
================================================================
MANIFESTO INVIOLÁVEL:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Ativo: BTC/USDT FUTURES
Tipo: DAY TRADE (max 3h por operação)
Meta: 5-10 operações/dia
Timeframe: 15m (primário) + 1h (tendência)
Stops: LARGOS (2×ATR, ~1-2.5%) — sobrevive ao ruído
Target: 1.5R (realista, alta fill rate)
Direção: LONG e SHORT
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
DIAGNÓSTICO DOS v1-v3:
- 5m: stops de 0.4-1.5% são MENORES que o wick médio do BTC
- Resultado: 28% WR porque TODA trade é stopada por ruído
- Não é problema de sinal — é problema de TIMEFRAME + STOP
FIX:
- 15m base: sinais mais limpos, ATR ~400-800 USD
- Stop 2×ATR = 1-2.5% tipicamente → sobrevive wicks normais
- Target 3×ATR = 1.5-3.75% → alcançável em 1-3 horas
- Menos trades (5-8/dia) mas com EDGE REAL
ENTRIES (baseados em 15m, filtrados pelo 1h):
1. RSI Momentum Cross: RSI cruza 50 no 15m (momentum shift)
2. MACD Hist Flip: MACD hist muda de sinal (acceleration)
3. BB Reversion: Toca BB extrema + vela de reversão
4. EMA Pullback: Retorno à EMA21 no 15m em tendência ativa
5. Breakout: Novo high/low de 20 bars com volume alto
1H FILTER:
- TREND (ADX>20 + EMA aligned): só trade na direção
- RANGE (ADX<20): BB bounce em ambas direções
"""
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 OsirisDayTradeV4(IStrategy):
"""
OSIRIS DAY TRADE v4 — 15m Primary, structural stops, momentum entries.
Key insight: wider stops survive BTC volatility → higher WR → profit.
"""
INTERFACE_VERSION = 3
can_short = True
timeframe = "15m"
# Wide ROI — let custom_exit handle properly
minimal_roi = {"0": 0.10, "180": 0.005}
# Safety stoploss: wider than custom to avoid interference
stoploss = -0.035
trailing_stop = False
use_custom_stoploss = True
startup_candle_count = 200
process_only_new_candles = True
# Runtime
_daily_trades = {}
_consecutive_losses = 0
_last_loss_time = None
# ═══════════════════════════════════════════════════════════════════
# HYPEROPT PARAMETERS
# ═══════════════════════════════════════════════════════════════════
# Regime
buy_adx_trend = IntParameter(15, 30, default=20, space="buy", optimize=True)
# Stop multiplier (ATR units)
buy_sl_atr_mult = DecimalParameter(1.5, 3.0, default=2.0, decimals=1, space="buy", optimize=True)
# R:R target
buy_rr = DecimalParameter(1.2, 2.5, default=1.5, decimals=1, space="buy", optimize=True)
# RSI thresholds
buy_rsi_cross = IntParameter(45, 55, default=50, space="buy", optimize=True)
# Volume filter
buy_vol_min = DecimalParameter(0.3, 1.0, default=0.5, decimals=1, space="buy", optimize=True)
# Max daily trades
buy_max_daily = IntParameter(6, 15, default=10, space="buy", optimize=True)
# Max hold (15m candles: 12 = 3h, 16 = 4h)
buy_max_hold = IntParameter(8, 24, default=12, space="buy", optimize=True)
# Breakout lookback
buy_breakout_bars = IntParameter(10, 30, default=20, space="buy", optimize=True)
# Exit RSI
sell_rsi_long = IntParameter(72, 88, default=80, space="sell", optimize=True)
sell_rsi_short = IntParameter(12, 28, default=20, space="sell", optimize=True)
# ═══════════════════════════════════════════════════════════════════
# INFORMATIVE PAIRS
# ═══════════════════════════════════════════════════════════════════
def informative_pairs(self):
return [
("BTC/USDT:USDT", "1h"),
]
# ═══════════════════════════════════════════════════════════════════
# INDICATORS (all on 15m)
# ═══════════════════════════════════════════════════════════════════
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"]
# ── ATR (the KEY metric for stops) ──
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)
# ── Donchian channel for breakouts ──
bk = self.buy_breakout_bars.value
dataframe["dc_high"] = dataframe["high"].rolling(bk).max().shift(1)
dataframe["dc_low"] = dataframe["low"].rolling(bk).min().shift(1)
# ── Previous values for cross detection ──
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)
# ── Distance to EMA21 in ATR units ──
atr_safe = dataframe["atr"].replace(0, 1)
dataframe["dist_ema21"] = (dataframe["close"] - dataframe["ema21"]) / atr_safe
# ── 1H informative ──
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["ema50"] = ta.EMA(inf_1h, timeperiod=50)
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
# ═══════════════════════════════════════════════════════════════════
# ENTRY TREND
# ═══════════════════════════════════════════════════════════════════
def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
has_1h = "ema9_1h" in dataframe.columns
vol_min = self.buy_vol_min.value
rsi_cross = self.buy_rsi_cross.value
# ── 1H REGIME ──
if has_1h:
h1_trending = dataframe["adx_1h"] > self.buy_adx_trend.value
h1_up = h1_trending & (dataframe["ema9_1h"] > dataframe["ema21_1h"])
h1_dn = h1_trending & (dataframe["ema9_1h"] < dataframe["ema21_1h"])
h1_range = ~h1_trending
else:
h1_up = pd.Series(False, index=dataframe.index)
h1_dn = pd.Series(False, index=dataframe.index)
h1_range = pd.Series(True, index=dataframe.index)
# ── COMMON ──
vol_ok = dataframe["vol_ratio"] > vol_min
has_vol = dataframe["volume"] > 0
atr_ok = dataframe["atr"] > 1 # at least $1 ATR on 15m
# ═══════════════════════════════════════════════════════════════
# ENTRY 1: RSI MOMENTUM CROSS (back-and-forth around centerline)
# ═══════════════════════════════════════════════════════════════
rsi_cross_long = (
(dataframe["rsi"] > rsi_cross) &
(dataframe["rsi_prev"] <= rsi_cross) &
(dataframe["close"] > dataframe["ema21"])
)
rsi_cross_short = (
(dataframe["rsi"] < (100 - rsi_cross)) &
(dataframe["rsi_prev"] >= (100 - rsi_cross)) &
(dataframe["close"] < dataframe["ema21"])
)
# ═══════════════════════════════════════════════════════════════
# ENTRY 2: MACD HISTOGRAM FLIP (acceleration change)
# ═══════════════════════════════════════════════════════════════
macd_flip_long = (
(dataframe["macd_hist"] > 0) &
(dataframe["macd_hist_prev"] <= 0) &
(dataframe["close"] > dataframe["ema21"])
)
macd_flip_short = (
(dataframe["macd_hist"] < 0) &
(dataframe["macd_hist_prev"] >= 0) &
(dataframe["close"] < dataframe["ema21"])
)
# ═══════════════════════════════════════════════════════════════
# ENTRY 3: BOLLINGER BAND REVERSION (range mode)
# ═══════════════════════════════════════════════════════════════
bb_long = (
(dataframe["low"] <= dataframe["bb_lower"] * 1.003) &
(dataframe["is_green"] == 1) &
(dataframe["rsi"] < 35)
)
bb_short = (
(dataframe["high"] >= dataframe["bb_upper"] * 0.997) &
(dataframe["is_red"] == 1) &
(dataframe["rsi"] > 65)
)
# ═══════════════════════════════════════════════════════════════
# ENTRY 4: EMA21 PULLBACK (trend continuation)
# Price returns to EMA21 zone in established trend
# ═══════════════════════════════════════════════════════════════
pullback_long = (
(dataframe["dist_ema21"] > -0.5) &
(dataframe["dist_ema21"] < 0.5) &
(dataframe["is_green"] == 1) &
(dataframe["ema9"] > dataframe["ema21"]) &
(dataframe["rsi"] > 40) &
(dataframe["rsi"] < 60)
)
pullback_short = (
(dataframe["dist_ema21"] < 0.5) &
(dataframe["dist_ema21"] > -0.5) &
(dataframe["is_red"] == 1) &
(dataframe["ema9"] < dataframe["ema21"]) &
(dataframe["rsi"] > 40) &
(dataframe["rsi"] < 60)
)
# ═══════════════════════════════════════════════════════════════
# ENTRY 5: DONCHIAN BREAKOUT (new high/low with volume)
# ═══════════════════════════════════════════════════════════════
breakout_long = (
(dataframe["close"] > dataframe["dc_high"]) &
(dataframe["vol_ratio"] > 1.3)
)
breakout_short = (
(dataframe["close"] < dataframe["dc_low"]) &
(dataframe["vol_ratio"] > 1.3)
)
# ═══════════════════════════════════════════════════════════════
# COMBINE WITH 1H FILTER
# ═══════════════════════════════════════════════════════════════
# Trend entries: MUST align with 1h direction
trend_entries_long = (rsi_cross_long | macd_flip_long | pullback_long | breakout_long)
trend_entries_short = (rsi_cross_short | macd_flip_short | pullback_short | breakout_short)
# Range entries: allowed in both directions when 1h is ranging
range_entries_long = bb_long
range_entries_short = bb_short
go_long = (
(
(h1_up & trend_entries_long) | # trend mode: with 1h
(h1_range & range_entries_long) | # range mode: BB bounce
(h1_range & (rsi_cross_long | macd_flip_long)) # range: momentum in range
) &
vol_ok & has_vol & atr_ok
)
go_short = (
(
(h1_dn & trend_entries_short) |
(h1_range & range_entries_short) |
(h1_range & (rsi_cross_short | macd_flip_short))
) &
vol_ok & has_vol & atr_ok
)
dataframe.loc[go_long, "enter_long"] = 1
dataframe.loc[go_short, "enter_short"] = 1
# Tags
for cond, tag in [
(go_long & rsi_cross_long, "rsi_cross_long"),
(go_long & macd_flip_long, "macd_flip_long"),
(go_long & bb_long, "bb_bounce_long"),
(go_long & pullback_long, "pullback_long"),
(go_long & breakout_long, "breakout_long"),
(go_short & rsi_cross_short, "rsi_cross_short"),
(go_short & macd_flip_short, "macd_flip_short"),
(go_short & bb_short, "bb_bounce_short"),
(go_short & pullback_short, "pullback_short"),
(go_short & breakout_short, "breakout_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 — Wide 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.02
last = dataframe.iloc[-1]
atr = last.get("atr", 0)
if atr == 0 or trade.open_rate == 0:
return -0.02
is_short = trade.is_short if hasattr(trade, "is_short") else False
# Stop: 2 ATR (on 15m this is typically 1-2.5%)
sl_mult = self.buy_sl_atr_mult.value
stop_dist_pct = sl_mult * atr / trade.open_rate
stop_dist_pct = max(0.008, min(stop_dist_pct, 0.03))
# R-multiple trailing
if current_profit > 0 and stop_dist_pct > 0:
r_mult = current_profit / stop_dist_pct
if r_mult >= 2.0:
# Lock 1R
return stoploss_from_open(
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 management
# ═══════════════════════════════════════════════════════════════════
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 = sl_mult × ATR
risk_pct = max(self.buy_sl_atr_mult.value * atr / trade.open_rate, 0.008)
target_pct = risk_pct * self.buy_rr.value
# ── TARGET HIT ──
if current_profit >= target_pct:
return "v4_tp"
# ── MOMENTUM FADE at 70% target ──
if current_profit >= target_pct * 0.7:
rsi = last.get("rsi", 50)
if is_short and rsi < 22:
return "v4_fade"
if not is_short and rsi > 78:
return "v4_fade"
# ── TIME EXIT ──
minutes = (current_time - trade.open_date_utc).total_seconds() / 60
max_min = self.buy_max_hold.value * 15 # 15m candles
if minutes > max_min * 0.7 and current_profit > 0.003:
return "v4_time_profit"
if minutes > max_min:
return "v4_time_force"
return None
# ═══════════════════════════════════════════════════════════════════
# CONFIRM ENTRY
# ═══════════════════════════════════════════════════════════════════
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
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: str,
trade: Trade,
order_type: str,
amount: float,
rate: float,
time_in_force: str,
exit_reason: str,
current_time: datetime,
**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