ETH/USDT scalping strategy with regime filtering and trend continuation focus.
Timeframe
5m
Direction
Long & Short
Stoploss
-2.5%
Trailing Stop
No
ROI
0m: 10.0%, 30m: 5.0%, 60m: 2.0%, 120m: 1.0%
Interface Version
3
Startup Candles
N/A
Indicators
6
freqtrade/freqtrade-strategies
Strategy 003 author@: Gerald Lonlas github@: https://github.com/freqtrade/freqtrade-strategies
"""
ETH/USDT Perpetual Futures Scalping Strategy for Freqtrade.
Tuned for medium volatility trend continuation.
"""
from typing import Optional
import pandas as pd
from pandas import DataFrame
from freqtrade.strategy import IStrategy, informative
import talib.abstract as ta
from bot.strategy.signal_filters import SignalFilters
from bot.strategy.risk_engine import RiskEngine
from bot.config.default_config import config as app_config
class ETHScalpStrategy(IStrategy):
"""
ETH/USDT scalping strategy with regime filtering and trend continuation focus.
Strategy Logic:
1. Regime Filter (HTF): EMA crossover + ADX for trend direction/strength
2. Entry Signals (LTF): Trend continuation entries with momentum confirmation
3. Filters: Volatility (ATR) and volume spike detection
4. Risk Management: Dynamic stops, partial TPs, breakeven logic
Differences from BTC strategy:
- Lower ADX threshold (22 vs 25) - ETH can trend with less strong momentum
- Higher ATR threshold (0.001 vs 0.0005) - ETH typically more volatile
- Stronger volume confirmation (1.3x vs 1.2x)
- Slightly wider stops (2.2 ATR vs 2.0) to handle volatility
"""
# Strategy metadata
INTERFACE_VERSION = 3
# Enable shorts (futures)
can_short = True
# Minimal ROI - handled by custom exit logic
minimal_roi = {
"0": 0.10,
"30": 0.05,
"60": 0.02,
"120": 0.01
}
# Stoploss
stoploss = -0.025 # Slightly wider hard stop for ETH volatility
# Trailing stop
trailing_stop = False
# Optimal timeframe
timeframe = "5m"
# Run "populate_indicators()" only for new candle
process_only_new_candles = True
# Use exit signals
use_exit_signal = True
exit_profit_only = False
exit_profit_offset = 0.0
# Number of candles the strategy requires before producing valid signals
startup_candle_count: int = 250
# Strategy parameters (can be optimized)
# Regime Filter - TODO: calibrate via hyperopt/backtest
regime_htf_timeframe = "15m"
regime_ema_fast = 50
regime_ema_slow = 200
regime_adx_threshold = 22 # Lower for ETH
regime_adx_period = 14
# Entry Signals - TODO: calibrate via hyperopt/backtest
entry_ema_period = 21
entry_rsi_period = 14
entry_rsi_long_threshold = 42 # Slightly higher
entry_rsi_short_threshold = 58 # Slightly lower
# Filters - TODO: calibrate via hyperopt/backtest
filter_atr_period = 14
filter_atr_min_threshold = 0.001 # Higher for ETH
filter_volume_period = 20
entry_volume_multiplier = 1.3 # Stronger volume requirement
# Position sizing
position_adjustment_enable = True
max_entry_position_adjustment = 0 # No DCA/averaging
def __init__(self, config: dict, *args, **kwargs):
super().__init__(config)
# Initialize signal filters and risk engine
self.signal_filters = SignalFilters(
atr_period=self.filter_atr_period,
atr_min_threshold=self.filter_atr_min_threshold,
volume_period=self.filter_volume_period,
volume_multiplier=self.entry_volume_multiplier
)
self.risk_engine = RiskEngine(
max_risk_per_trade=app_config.MAX_RISK_PER_TRADE,
max_daily_drawdown=app_config.MAX_DAILY_DRAWDOWN,
max_consecutive_losses=app_config.MAX_CONSECUTIVE_LOSSES,
cooldown_minutes=app_config.COOLDOWN_MINUTES,
stop_loss_atr_multiplier=2.2, # Wider for ETH
stop_loss_max_percent=0.025,
take_profit_r_levels=app_config.TAKE_PROFIT_R_LEVELS,
breakeven_after_first_tp=app_config.BREAKEVEN_AFTER_FIRST_TP
)
@informative('15m')
def populate_indicators_15m(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
"""
Populate indicators for higher timeframe (regime detection).
"""
# EMA for regime
dataframe['ema_fast'] = ta.EMA(dataframe, timeperiod=self.regime_ema_fast)
dataframe['ema_slow'] = ta.EMA(dataframe, timeperiod=self.regime_ema_slow)
# ADX for trend strength
dataframe['adx'] = ta.ADX(dataframe, timeperiod=self.regime_adx_period)
# Regime determination
dataframe['regime_bullish'] = (
(dataframe['ema_fast'] > dataframe['ema_slow']) &
(dataframe['adx'] > self.regime_adx_threshold)
)
dataframe['regime_bearish'] = (
(dataframe['ema_fast'] < dataframe['ema_slow']) &
(dataframe['adx'] > self.regime_adx_threshold)
)
dataframe['regime_neutral'] = ~(
dataframe['regime_bullish'] | dataframe['regime_bearish']
)
return dataframe
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
"""
Populate indicators for execution timeframe.
"""
# Entry EMA
dataframe['ema'] = ta.EMA(dataframe, timeperiod=self.entry_ema_period)
# VWAP (approximation using cumulative)
dataframe['vwap'] = (
(dataframe['close'] * dataframe['volume']).cumsum() /
dataframe['volume'].cumsum()
)
# RSI for entry triggers
dataframe['rsi'] = ta.RSI(dataframe, timeperiod=self.entry_rsi_period)
# MACD for momentum confirmation (ETH-specific)
macd = ta.MACD(dataframe)
dataframe['macd'] = macd['macd']
dataframe['macd_signal'] = macd['macdsignal']
dataframe['macd_hist'] = macd['macdhist']
# Apply signal filters (ATR, volume)
dataframe = self.signal_filters.apply_all_filters(
dataframe,
enable_mean_reversion=True
)
return dataframe
def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
"""
Populate buy/sell signals based on regime and entry conditions.
"""
# Long Entry Conditions
long_conditions = [
# HTF regime is bullish
(dataframe['regime_bullish_15m'] == True),
# Price near EMA support
(dataframe['close'] <= dataframe['ema'] * 1.015), # Within 1.5% of EMA
# RSI recovery (slightly different thresholds for ETH)
(dataframe['rsi'] > self.entry_rsi_long_threshold),
(dataframe['rsi'].shift(1) <= self.entry_rsi_long_threshold),
# MACD momentum confirmation
(dataframe['macd'] > dataframe['macd_signal']),
# Filters passed
(dataframe['filters_passed'] == True),
# Volume condition
(dataframe['volume'] > 0)
]
# Short Entry Conditions
short_conditions = [
# HTF regime is bearish
(dataframe['regime_bearish_15m'] == True),
# Price near EMA resistance
(dataframe['close'] >= dataframe['ema'] * 0.985), # Within 1.5% of EMA
# RSI rollover
(dataframe['rsi'] < self.entry_rsi_short_threshold),
(dataframe['rsi'].shift(1) >= self.entry_rsi_short_threshold),
# MACD momentum confirmation
(dataframe['macd'] < dataframe['macd_signal']),
# Filters passed
(dataframe['filters_passed'] == True),
# Volume condition
(dataframe['volume'] > 0)
]
# Combine conditions
dataframe['enter_long'] = 0
dataframe['enter_short'] = 0
if long_conditions:
dataframe.loc[
pd.concat(long_conditions, axis=1).all(axis=1),
'enter_long'
] = 1
if short_conditions:
dataframe.loc[
pd.concat(short_conditions, axis=1).all(axis=1),
'enter_short'
] = 1
return dataframe
def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
"""
Populate exit signals (optional - mainly using custom exit logic).
"""
dataframe['exit_long'] = 0
dataframe['exit_short'] = 0
# Exit long when regime flips bearish
dataframe.loc[
(dataframe['regime_bearish_15m'] == True),
'exit_long'
] = 1
# Exit short when regime flips bullish
dataframe.loc[
(dataframe['regime_bullish_15m'] == True),
'exit_short'
] = 1
return dataframe
def custom_stoploss(
self,
pair: str,
trade: 'Trade',
current_time: 'datetime',
current_rate: float,
current_profit: float,
**kwargs
) -> Optional[float]:
"""
Custom stoploss logic using ATR-based dynamic stops.
"""
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()
# Use risk engine to calculate dynamic stop
stop_distance = self.risk_engine.calculate_stop_loss(
entry_price=trade.open_rate,
current_price=current_rate,
atr=last_candle.get('atr', 0),
trade_side='long' if trade.is_short is False else 'short'
)
if stop_distance:
return stop_distance
return None
def custom_exit(
self,
pair: str,
trade: 'Trade',
current_time: 'datetime',
current_rate: float,
current_profit: float,
**kwargs
) -> Optional[str]:
"""
Custom exit logic for partial profits and breakeven.
"""
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()
# Check if we should exit based on risk engine rules
should_exit, reason = self.risk_engine.check_exit_conditions(
entry_price=trade.open_rate,
current_price=current_rate,
trade_duration_minutes=(current_time - trade.open_date_utc).total_seconds() / 60,
current_profit_ratio=current_profit,
atr=last_candle.get('atr', 0)
)
if should_exit:
return reason
return None