OSIRIS DAY TRADE v5 — Wide stops (3 ATR), 1:1 R:R, simple trend entries. Key: let the STOPS be wide enough to survive BTC noise. Target ONLY >50% WR, not trying for high R:R.
Timeframe
15m
Direction
Long & Short
Stoploss
-5.0%
Trailing Stop
No
ROI
0m: 10.0%, 240m: 0.3%
Interface Version
3
Startup Candles
200
Indicators
5
freqtrade/freqtrade-strategies
Strategy 003 author@: Gerald Lonlas github@: https://github.com/freqtrade/freqtrade-strategies
"""
OSIRIS DAY TRADE v5 — Wide Stop Trend Follower
================================================================
MANIFESTO:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Ativo: BTC/USDT FUTURES
Tipo: DAY TRADE
Meta: ~10 operações/dia
Timeframe: 15m (primário) + 1h (tendência)
Stop: 3×ATR (~2-4%) — o wick NÃO ALCANÇA
Target: 1:1 R:R (stop = target → WR natural ~50%)
Trail: Breakeven em 0.7R
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
DIAGNÓSTICO v1-v4:
- v1-v3: stops 0.4-1.5% → BTC wick médio de 0.5-1% → stopado
- v4: stops ~2% → WR subiu de 28% para 33%
- Conclusão: stops DEVEM ser > 2×wick_médio para sobreviver
- Com stop=target (1:1), WR teórica = 50% em random walk
- Qualquer edge direcional → WR > 50% → LUCRO
ENTRADAS (SIMPLES — 3 condições simultâneas):
LONG:
1. 1h trend UP (EMA9_1h > EMA21_1h)
2. 15m momentum (RSI > 50 ou close > EMA21)
3. 15m candle verde (confirmação de preço)
SHORT:
1. 1h trend DOWN (EMA9_1h < EMA21_1h)
2. 15m momentum (RSI < 50 ou close < EMA21)
3. 15m candle vermelha
"""
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 OsirisDayTradeV5(IStrategy):
"""
OSIRIS DAY TRADE v5 — Wide stops (3 ATR), 1:1 R:R, simple trend entries.
Key: let the STOPS be wide enough to survive BTC noise.
Target ONLY >50% WR, not trying for high R:R.
"""
INTERFACE_VERSION = 3
can_short = True
timeframe = "15m"
minimal_roi = {"0": 0.10, "240": 0.003}
# Wide safety stoploss
stoploss = -0.05
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 size (ATR multiples)
buy_sl_atr = DecimalParameter(2.0, 4.0, default=3.0, decimals=1, space="buy", optimize=True)
# R:R (1.0 = same as stop)
buy_rr = DecimalParameter(0.8, 1.5, default=1.0, decimals=1, space="buy", optimize=True)
# RSI filter for entry
buy_rsi_long_min = IntParameter(45, 55, default=50, space="buy", optimize=True)
buy_rsi_short_max = IntParameter(45, 55, default=50, space="buy", optimize=True)
# 1h ADX minimum for trend
buy_adx_min = IntParameter(15, 30, default=18, space="buy", optimize=True)
# Max daily trades
buy_max_daily = IntParameter(8, 15, default=10, space="buy", optimize=True)
# Max hold (15m candles)
buy_max_hold = IntParameter(12, 32, default=16, space="buy", optimize=True)
# Breakeven point (R multiple)
buy_be_mult = DecimalParameter(0.4, 0.9, default=0.7, decimals=1, 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["rsi"] = ta.RSI(dataframe, timeperiod=14)
dataframe["atr"] = ta.ATR(dataframe, timeperiod=14)
dataframe["vol_sma"] = ta.SMA(dataframe["volume"], timeperiod=20)
dataframe["vol_ratio"] = dataframe["volume"] / dataframe["vol_sma"].replace(0, 1)
# Candle
dataframe["is_green"] = (dataframe["close"] > dataframe["open"]).astype(int)
dataframe["is_red"] = (dataframe["close"] < dataframe["open"]).astype(int)
# 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)
dataframe = merge_informative_pair(
dataframe, inf_1h, self.timeframe, "1h", ffill=True
)
return dataframe
# ═══════════════════════════════════════════════════════════════════
# ENTRIES — Simple 3-condition trend following
# ═══════════════════════════════════════════════════════════════════
def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
has_1h = "ema9_1h" in dataframe.columns
rsi_long = self.buy_rsi_long_min.value
rsi_short = self.buy_rsi_short_max.value
# 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)
# Common
vol_ok = dataframe["vol_ratio"] > 0.5
has_vol = dataframe["volume"] > 0
atr_ok = dataframe["atr"] > 1
# ── LONG: 1h up + 15m momentum + green candle ──
long_entry = (
h1_up &
(
(dataframe["rsi"] > rsi_long) |
(dataframe["close"] > dataframe["ema21"])
) &
(dataframe["is_green"] == 1) &
vol_ok & has_vol & atr_ok
)
# ── SHORT: 1h down + 15m momentum + red candle ──
short_entry = (
h1_dn &
(
(dataframe["rsi"] < rsi_short) |
(dataframe["close"] < dataframe["ema21"])
) &
(dataframe["is_red"] == 1) &
vol_ok & has_vol & atr_ok
)
dataframe.loc[long_entry, "enter_long"] = 1
dataframe.loc[short_entry, "enter_short"] = 1
return dataframe
# ═══════════════════════════════════════════════════════════════════
# EXIT
# ═══════════════════════════════════════════════════════════════════
def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
# Let custom_exit and stoploss handle exits
# Only exit on extreme RSI as safety
dataframe.loc[dataframe["rsi"] > 85, "exit_long"] = 1
dataframe.loc[dataframe["rsi"] < 15, "exit_short"] = 1
return dataframe
# ═══════════════════════════════════════════════════════════════════
# CUSTOM STOPLOSS — Wide ATR + Breakeven 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.03
last = dataframe.iloc[-1]
atr = last.get("atr", 0)
if atr == 0 or trade.open_rate == 0:
return -0.03
is_short = trade.is_short if hasattr(trade, "is_short") else False
# Wide stop: sl_atr × ATR
stop_pct = self.buy_sl_atr.value * atr / trade.open_rate
stop_pct = max(0.015, min(stop_pct, 0.045))
# Breakeven trail
if current_profit > 0:
r_mult = current_profit / stop_pct
if r_mult >= self.buy_be_mult.value:
return stoploss_from_open(0.001, current_profit, is_short=is_short)
return -stop_pct
# ═══════════════════════════════════════════════════════════════════
# CUSTOM EXIT — 1:1 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
# Target = R:R × stop
risk_pct = max(self.buy_sl_atr.value * atr / trade.open_rate, 0.015)
target_pct = risk_pct * self.buy_rr.value
if current_profit >= target_pct:
return "v5_tp"
# Time exit
minutes = (current_time - trade.open_date_utc).total_seconds() / 60
max_min = self.buy_max_hold.value * 15
if minutes > max_min * 0.7 and current_profit > 0.003:
return "v5_time_profit"
if minutes > max_min:
return "v5_time_force"
return None
# ═══════════════════════════════════════════════════════════════════
# TRADE ENTRY/EXIT MANAGEMENT
# ═══════════════════════════════════════════════════════════════════
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