Swing trading strategy using EMA, RSI, and ATR indicators with 1h trend filter for spot long-only trading.
Timeframe
15m
Direction
Long Only
Stoploss
-10.0%
Trailing Stop
No
ROI
0m: 1000.0%
Interface Version
3
Startup Candles
N/A
Indicators
4
freqtrade/freqtrade-strategies
Strategy 003 author@: Gerald Lonlas github@: https://github.com/freqtrade/freqtrade-strategies
"""
SwingEmaRsiAtr Strategy
-----------------------
A swing trading strategy for spot long-only on Binance with 15m entries filtered by 1h trend.
Indicators:
- 1h EMA-200 (trend filter)
- 15m EMA-50, EMA-20
- 15m RSI(14)
- 15m ATR(14)
- 15m SMA(volume, 20)
Entry Conditions:
- 1h close > 1h EMA-200 (uptrend)
- 15m close > EMA-50
- RSI(14) crosses above 45
- volume > 0.5 × SMA(volume, 20)
Risk Management:
- Hard stoploss: -10%
- Dynamic ATR-based stoploss: tightens as profit increases
- Custom exit at 2R (2× risk defined by ATR)
Usage:
freqtrade download-data --timeframes 15m 1h --pairs ADA/USDT --days 730
freqtrade backtesting --strategy SwingEmaRsiAtr --timeframe 15m
freqtrade trade --strategy SwingEmaRsiAtr --dry-run
"""
from freqtrade.strategy import IStrategy, merge_informative_pair
from pandas import DataFrame
import pandas as pd
import talib.abstract as ta
from freqtrade.persistence import Trade
from datetime import datetime
from typing import Optional
class SwingEmaRsiAtr(IStrategy):
"""
Swing trading strategy using EMA, RSI, and ATR indicators
with 1h trend filter for spot long-only trading.
"""
# Strategy interface version
INTERFACE_VERSION = 3
# Can this strategy short?
can_short: bool = False
# Timeframe settings
timeframe = '15m'
informative_timeframe = '1h'
# Startup candle count - need at least 200 for EMA-200 on 1h
startup_candle_count: int = 200
# ROI table - disabled as we use custom exit
minimal_roi = {
"0": 10.0 # Effectively disabled (1000% ROI)
}
# Stoploss
stoploss = -0.10 # Hard stop at -10%
# Trailing stop - disabled
trailing_stop = False
# Use custom stoploss
use_custom_stoploss = True
# Order types
order_types = {
'entry': 'limit',
'exit': 'limit',
'stoploss': 'market',
'stoploss_on_exchange': False
}
# Process only new candles
process_only_new_candles = True
# Buy/Sell tags
use_exit_signal = True
exit_profit_only = False
ignore_roi_if_entry_signal = False
def informative_pairs(self):
"""
Define additional (informative) pairs needed for this strategy.
Returns pairs with timeframes needed.
"""
pairs = self.dp.current_whitelist()
informative_pairs = [(pair, self.informative_timeframe) for pair in pairs]
return informative_pairs
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
"""
Add indicators to the dataframe.
"""
# Get informative 1h data
if self.dp:
informative = self.dp.get_pair_dataframe(pair=metadata['pair'], timeframe=self.informative_timeframe)
# Calculate 1h EMA-200
informative['ema_200'] = ta.EMA(informative, timeperiod=200)
# Merge informative data with current timeframe
dataframe = merge_informative_pair(dataframe, informative, self.timeframe, self.informative_timeframe, ffill=True)
# 15m indicators
# EMA indicators
dataframe['ema_50'] = ta.EMA(dataframe, timeperiod=50)
dataframe['ema_20'] = ta.EMA(dataframe, timeperiod=20)
# RSI
dataframe['rsi'] = ta.RSI(dataframe, timeperiod=14)
# ATR for risk management
dataframe['atr'] = ta.ATR(dataframe, timeperiod=14)
# Volume SMA
dataframe['volume_sma'] = ta.SMA(dataframe['volume'], timeperiod=20)
return dataframe
def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
"""
Define entry (buy) conditions.
"""
# Get previous RSI value for cross detection
dataframe['rsi_prev'] = dataframe['rsi'].shift(1)
dataframe.loc[
(
# 1h trend filter: price above 1h EMA-200
(dataframe[f'close_{self.informative_timeframe}'] > dataframe[f'ema_200_{self.informative_timeframe}']) &
# 15m close above EMA-50
(dataframe['close'] > dataframe['ema_50']) &
# RSI crosses above 45
(dataframe['rsi'] > 45) &
(dataframe['rsi_prev'] <= 45) &
# Volume filter
(dataframe['volume'] > 0.5 * dataframe['volume_sma']) &
# Ensure we have valid data
(dataframe['volume'] > 0)
),
'enter_long'] = 1
# Set buy tag
dataframe.loc[dataframe['enter_long'] == 1, 'enter_tag'] = 'RSI45_cross_pullback_in_uptrend'
return dataframe
def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
"""
Define exit (sell) conditions.
Empty as we use custom exit logic.
"""
# No sell signal needed - using custom exit
dataframe['exit_long'] = 0
return dataframe
def custom_stoploss(self, pair: str, trade: Trade, current_time: datetime,
current_rate: float, current_profit: float, **kwargs) -> float:
"""
Custom stoploss logic using ATR-based dynamic stops.
Base: 2.5 × ATR below entry
After > 1R profit: tighten to 2.0 × ATR
After > 2R profit: tighten to 1.5 × ATR
Returns negative fraction for stoploss distance below current_rate.
"""
dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
if dataframe is None or len(dataframe) == 0:
return self.stoploss
last_candle = dataframe.iloc[-1].squeeze()
atr = last_candle['atr']
if pd.isna(atr) or atr <= 0:
return self.stoploss
# Calculate risk unit (R) - initial ATR at entry
# We use current ATR as approximation since we don't store entry ATR
risk_unit = atr * 2.5 # Base multiple
# Calculate profit in terms of risk units (R)
entry_rate = trade.open_rate
profit_distance = current_rate - entry_rate
r_multiple = profit_distance / risk_unit if risk_unit > 0 else 0
# Determine ATR multiple based on profit level
if r_multiple >= 2.0:
atr_multiple = 1.5 # Tighten to 1.5× ATR after 2R profit
elif r_multiple >= 1.0:
atr_multiple = 2.0 # Tighten to 2.0× ATR after 1R profit
else:
atr_multiple = 2.5 # Base stop at 2.5× ATR
# Calculate stoploss distance from current price
stop_distance = atr * atr_multiple
stoploss_value = -stop_distance / current_rate
# Ensure we don't return a stoploss worse than the hard stoploss
return max(stoploss_value, self.stoploss)
def custom_exit(self, pair: str, trade: Trade, current_time: datetime,
current_rate: float, current_profit: float, **kwargs) -> Optional[str]:
"""
Custom exit logic: Take profit at 2R (2× risk).
R = ATR × 2.5 (base multiple)
Exit when profit >= 2R
"""
dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
if dataframe is None or len(dataframe) == 0:
return None
last_candle = dataframe.iloc[-1].squeeze()
atr = last_candle['atr']
if pd.isna(atr) or atr <= 0:
return None
# Calculate risk unit (R)
risk_unit = atr * 2.5 # Base ATR multiple
# Calculate profit in terms of R
entry_rate = trade.open_rate
profit_distance = current_rate - entry_rate
r_multiple = profit_distance / risk_unit if risk_unit > 0 else 0
# Exit at 2R profit target
if r_multiple >= 2.0:
return '2R_profit_target'
return None
# Commands for usage:
# freqtrade download-data --timeframes 15m 1h --pairs ADA/USDT --days 730
# freqtrade backtesting --strategy SwingEmaRsiAtr --timeframe 15m
# freqtrade trade --strategy SwingEmaRsiAtr --dry-run