Timeframe
5m
Direction
Long Only
Stoploss
-8.0%
Trailing Stop
No
ROI
0m: 15.0%, 30m: 8.0%, 60m: 4.0%, 120m: 0.0%
Interface Version
N/A
Startup Candles
300
Indicators
9
freqtrade/freqtrade-strategies
Strategy 003 author@: Gerald Lonlas github@: https://github.com/freqtrade/freqtrade-strategies
#!/usr/bin/env python3
"""
Enhanced Perpetual-Spot Basis Trading Strategy with FreqAI
Features:
- Advanced basis features with volatility and momentum
- Signal filtering and quality scoring
- Dynamic risk management
- Hyperopt-compatible parameters
"""
import pandas as pd
from freqtrade.strategy.interface import IStrategy
from freqtrade.strategy import IntParameter, DecimalParameter, CategoricalParameter
from pandas import DataFrame
import talib.abstract as ta
import numpy as np
import os
from typing import Optional
class PerpSpotBasisStrategy_Enhanced(IStrategy):
timeframe = "5m"
can_short = False
startup_candle_count = 300 # Increased for better indicator stability
# Disable FreqAI exit signals - we'll manage exits ourselves
use_exit_signal = True
# Conservative default ROI and stoploss
minimal_roi = {
"0": 0.15,
"30": 0.08,
"60": 0.04,
"120": 0
}
stoploss = -0.08
# Process only new candles
process_only_new_candles = True
# Hyperopt parameters
freqai_prediction_threshold = DecimalParameter(0.5, 0.9, default=0.75, space='buy', optimize=True, load=True)
signal_quality_threshold = DecimalParameter(0.3, 0.8, default=0.6, space='buy', optimize=True, load=True)
basis_volatility_threshold = DecimalParameter(0.01, 0.1, default=0.05, space='buy', optimize=True, load=True)
volume_confirmation_multiplier = DecimalParameter(1.2, 3.0, default=2.0, space='buy', optimize=True, load=True)
rsi_oversold_threshold = IntParameter(20, 40, default=30, space='buy', optimize=True, load=True)
rsi_overbought_threshold = IntParameter(60, 80, default=70, space='sell', optimize=True, load=True)
# Risk management parameters
max_drawdown_threshold = DecimalParameter(0.05, 0.15, default=0.1, space='buy', optimize=True, load=True)
position_size_multiplier = DecimalParameter(0.5, 1.5, default=1.0, space='buy', optimize=True, load=True)
# FreqAI configuration
freqai_label_period = 12
freqai_min_return = 0.003
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
pair = metadata['pair']
# Load futures data for basis calculations
futures_data = self.load_futures_data(pair)
if futures_data is not None:
dataframe = self.merge_futures_data(dataframe, futures_data)
dataframe = self.calculate_enhanced_basis_features(dataframe)
# Enhanced technical indicators
dataframe = self.calculate_enhanced_technical_indicators(dataframe)
# Market structure features
dataframe = self.calculate_market_structure_features(dataframe)
# Volume analysis
dataframe = self.calculate_enhanced_volume_features(dataframe)
# Signal quality scoring
dataframe = self.calculate_signal_quality_score(dataframe)
# Risk metrics
dataframe = self.calculate_risk_metrics(dataframe)
# FreqAI feature engineering (prefix with %-)
dataframe = self.prepare_freqai_features(dataframe)
# Trigger FreqAI pipeline
dataframe = self.freqai.start(dataframe, metadata, self)
return dataframe
def load_futures_data(self, pair: str) -> pd.DataFrame:
"""Load futures data with multiple fallback paths"""
pair_formatted = pair.replace('/', '_')
possible_paths = [
f"/home/gil/freqtrade/user_data/data/binance/futures/{pair_formatted}-5m-futures.feather",
f"/home/gil/freqtrade/user_data/data/binance/{pair_formatted}-5m-futures.feather",
f"user_data/data/binance/futures/{pair_formatted}-5m-futures.feather",
f"user_data/data/binance/{pair_formatted}-5m-futures.feather",
]
for futures_path in possible_paths:
if os.path.exists(futures_path):
try:
futures_data = pd.read_feather(futures_path)
return futures_data
except Exception as e:
continue
return None
def merge_futures_data(self, spot_df: pd.DataFrame, futures_df: pd.DataFrame) -> pd.DataFrame:
"""Merge spot and futures data on timestamp"""
try:
if not isinstance(spot_df.index, pd.DatetimeIndex):
spot_df.index = pd.to_datetime(spot_df.index)
if not isinstance(futures_df.index, pd.DatetimeIndex):
futures_df.index = pd.to_datetime(futures_df.index)
futures_columns = ['open_perp', 'high_perp', 'low_perp', 'close_perp', 'volume_perp']
futures_df_renamed = futures_df[['open', 'high', 'low', 'close', 'volume']].copy()
futures_df_renamed.columns = futures_columns
merged_df = spot_df.join(futures_df_renamed, how='left')
return merged_df
except Exception as e:
return spot_df
def calculate_enhanced_basis_features(self, dataframe):
"""Enhanced basis features with volatility and momentum"""
if 'close_perp' not in dataframe.columns:
# Create dummy basis features if no futures data
dataframe['basis'] = 0
dataframe['basis_ma_10'] = 0
dataframe['basis_ma_30'] = 0
dataframe['basis_volatility'] = 0
dataframe['basis_momentum'] = 0
dataframe['basis_zscore'] = 0
return dataframe
# Basic basis calculation
dataframe['basis'] = (dataframe['close_perp'] - dataframe['close']) / dataframe['close']
dataframe['basis'] = dataframe['basis'].replace([np.inf, -np.inf], np.nan).ffill().bfill()
# Basis moving averages
dataframe['basis_ma_5'] = ta.SMA(dataframe['basis'], timeperiod=5)
dataframe['basis_ma_10'] = ta.SMA(dataframe['basis'], timeperiod=10)
dataframe['basis_ma_30'] = ta.SMA(dataframe['basis'], timeperiod=30)
dataframe['basis_ma_100'] = ta.SMA(dataframe['basis'], timeperiod=100)
# Basis volatility (rolling standard deviation)
dataframe['basis_volatility'] = dataframe['basis'].rolling(window=20).std()
# Basis momentum and acceleration
dataframe['basis_momentum'] = dataframe['basis'] - dataframe['basis_ma_10']
dataframe['basis_acceleration'] = dataframe['basis_momentum'] - dataframe['basis_momentum'].shift(1)
# Basis z-score with adaptive window
for window in [20, 50, 100]:
basis_mean = dataframe['basis'].rolling(window=window).mean()
basis_std = dataframe['basis'].rolling(window=window).std()
dataframe[f'basis_zscore_{window}'] = (dataframe['basis'] - basis_mean) / basis_std
# Use the most stable z-score
dataframe['basis_zscore'] = dataframe['basis_zscore_50'].fillna(0)
# Basis regime detection (trending vs mean-reverting)
dataframe['basis_regime'] = np.where(
abs(dataframe['basis_zscore']) > 2, 1, # Trending
0 # Mean-reverting
)
# Basis divergence from price momentum
price_momentum = dataframe['close'].pct_change(10)
dataframe['basis_price_divergence'] = dataframe['basis_momentum'] - price_momentum
return dataframe
def calculate_enhanced_technical_indicators(self, dataframe):
"""Enhanced technical indicators with multiple timeframes"""
# Price indicators
for period in [10, 20, 50, 100]:
dataframe[f'ema_{period}'] = ta.EMA(dataframe, timeperiod=period)
dataframe[f'sma_{period}'] = ta.SMA(dataframe, timeperiod=period)
# RSI with multiple periods
for period in [14, 21]:
dataframe[f'rsi_{period}'] = ta.RSI(dataframe, timeperiod=period)
dataframe['rsi'] = dataframe['rsi_14'] # Default
# MACD with signal quality
macd = ta.MACD(dataframe)
dataframe['macd'] = macd['macd']
dataframe['macdsignal'] = macd['macdsignal']
dataframe['macdhist'] = macd['macdhist']
dataframe['macd_strength'] = abs(dataframe['macdhist'])
# Bollinger Bands with squeeze detection
bb = ta.BBANDS(dataframe)
dataframe['bb_upper'] = bb['upperband']
dataframe['bb_middle'] = bb['middleband']
dataframe['bb_lower'] = bb['lowerband']
dataframe['bb_width'] = (dataframe['bb_upper'] - dataframe['bb_lower']) / dataframe['bb_middle']
dataframe['bb_squeeze'] = dataframe['bb_width'] < dataframe['bb_width'].rolling(20).quantile(0.2)
# ADX for trend strength
dataframe['adx'] = ta.ADX(dataframe, timeperiod=14)
dataframe['adx_strong'] = dataframe['adx'] > 25
# Stochastic
stoch = ta.STOCH(dataframe)
dataframe['stoch_k'] = stoch['slowk']
dataframe['stoch_d'] = stoch['slowd']
return dataframe
def calculate_market_structure_features(self, dataframe):
"""Market structure and regime detection"""
# Higher highs and lower lows
dataframe['hh'] = dataframe['high'] > dataframe['high'].shift(1)
dataframe['ll'] = dataframe['low'] < dataframe['low'].shift(1)
# Swing points
dataframe['swing_high'] = (dataframe['high'] > dataframe['high'].shift(1)) & \
(dataframe['high'] > dataframe['high'].shift(-1))
dataframe['swing_low'] = (dataframe['low'] < dataframe['low'].shift(1)) & \
(dataframe['low'] < dataframe['low'].shift(-1))
# Market volatility regime
returns = dataframe['close'].pct_change()
dataframe['volatility_20'] = returns.rolling(20).std()
dataframe['volatility_regime'] = dataframe['volatility_20'] > dataframe['volatility_20'].rolling(100).median()
# Price momentum across multiple timeframes
for period in [5, 10, 20]:
dataframe[f'momentum_{period}'] = dataframe['close'].pct_change(period)
# Trend consistency
dataframe['trend_consistency'] = (
(dataframe['ema_10'] > dataframe['ema_20']).astype(int) +
(dataframe['ema_20'] > dataframe['ema_50']).astype(int) +
(dataframe['momentum_5'] > 0).astype(int) +
(dataframe['momentum_10'] > 0).astype(int)
) / 4.0
return dataframe
def calculate_enhanced_volume_features(self, dataframe):
"""Enhanced volume analysis"""
# Volume moving averages
for period in [10, 20, 50]:
dataframe[f'volume_ma_{period}'] = ta.SMA(dataframe['volume'], timeperiod=period)
# Volume ratios
dataframe['volume_ratio_10'] = dataframe['volume'] / dataframe['volume_ma_10']
dataframe['volume_ratio_20'] = dataframe['volume'] / dataframe['volume_ma_20']
# Volume trend
dataframe['volume_trend'] = dataframe['volume_ma_10'] / dataframe['volume_ma_50']
# On-balance volume and money flow
dataframe['obv'] = ta.OBV(dataframe['close'], dataframe['volume'])
dataframe['obv_ma'] = ta.SMA(dataframe['obv'], timeperiod=20)
dataframe['obv_trend'] = dataframe['obv'] > dataframe['obv_ma']
# Volume-weighted average price
typical_price = (dataframe['high'] + dataframe['low'] + dataframe['close']) / 3
dataframe['vwap'] = (typical_price * dataframe['volume']).rolling(20).sum() / \
dataframe['volume'].rolling(20).sum()
# Volume surge detection
dataframe['volume_surge'] = dataframe['volume'] > dataframe['volume'].rolling(50).quantile(0.8)
return dataframe
def calculate_signal_quality_score(self, dataframe):
"""Calculate a comprehensive signal quality score"""
scores = []
# Trend alignment score
trend_score = dataframe['trend_consistency']
scores.append(trend_score)
# Volume confirmation score
volume_score = np.minimum(dataframe['volume_ratio_20'] / 2.0, 1.0) # Cap at 1.0
scores.append(volume_score)
# Volatility appropriateness (not too high, not too low)
vol_score = 1 - abs(dataframe['volatility_20'] - dataframe['volatility_20'].rolling(100).median()) / \
dataframe['volatility_20'].rolling(100).std()
vol_score = np.clip(vol_score, 0, 1)
scores.append(vol_score)
# MACD strength score
macd_score = np.minimum(dataframe['macd_strength'] / dataframe['macd_strength'].rolling(50).quantile(0.8), 1.0)
scores.append(macd_score)
# ADX trend strength score
adx_score = np.minimum(dataframe['adx'] / 50.0, 1.0)
scores.append(adx_score)
# Combine scores
dataframe['signal_quality'] = pd.DataFrame(scores).T.mean(axis=1)
dataframe['signal_quality'] = dataframe['signal_quality'].fillna(0)
return dataframe
def calculate_risk_metrics(self, dataframe):
"""Calculate risk metrics for position sizing"""
# Recent drawdown
returns = dataframe['close'].pct_change()
cumulative_returns = (1 + returns).cumprod()
rolling_max = cumulative_returns.rolling(50).max()
dataframe['drawdown'] = (cumulative_returns - rolling_max) / rolling_max
# Current risk level
dataframe['risk_level'] = abs(dataframe['drawdown'])
# Volatility-adjusted position sizing
dataframe['position_multiplier'] = 1.0 / (1.0 + dataframe['volatility_20'] * 10)
dataframe['position_multiplier'] = np.clip(dataframe['position_multiplier'], 0.2, 1.5)
return dataframe
def prepare_freqai_features(self, dataframe):
"""Prepare features for FreqAI with proper scaling"""
features = {}
# Basis features
features['%-basis_zscore'] = dataframe.get('basis_zscore', 0).fillna(0).clip(-5, 5)
features['%-basis_volatility'] = dataframe.get('basis_volatility', 0).fillna(0).clip(0, 0.1) * 100
features['%-basis_momentum'] = dataframe.get('basis_momentum', 0).fillna(0).clip(-0.01, 0.01) * 1000
features['%-basis_regime'] = dataframe.get('basis_regime', 0).fillna(0)
# Technical indicators
features['%-rsi'] = dataframe['rsi'].fillna(50) / 100.0
features['%-adx'] = dataframe['adx'].fillna(20) / 100.0
features['%-macd_strength'] = np.log1p(dataframe['macd_strength'].fillna(0))
features['%-bb_width'] = dataframe['bb_width'].fillna(0).clip(0, 0.1) * 100
# Market structure
features['%-trend_consistency'] = dataframe['trend_consistency'].fillna(0)
features['%-volatility_regime'] = dataframe['volatility_regime'].astype(int)
features['%-momentum_5'] = dataframe['momentum_5'].fillna(0).clip(-0.05, 0.05) * 100
features['%-momentum_10'] = dataframe['momentum_10'].fillna(0).clip(-0.1, 0.1) * 50
# Volume features
features['%-volume_ratio'] = np.log1p(dataframe['volume_ratio_20'].fillna(1))
features['%-volume_trend'] = dataframe['volume_trend'].fillna(1).clip(0.5, 2.0)
features['%-obv_trend'] = dataframe['obv_trend'].astype(int)
# Signal quality
features['%-signal_quality'] = dataframe['signal_quality'].fillna(0)
# Add features to dataframe
for key, value in features.items():
dataframe[key] = value
return dataframe
def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
"""Simplified entry logic to generate more trades"""
dataframe.loc[:, 'enter_long'] = 0
# Base technical conditions
rsi_condition = dataframe['rsi'] < 50 # More lenient RSI
volume_condition = dataframe['volume'] > dataframe['volume'].rolling(20).mean() * 1.2 # Basic volume
# Try FreqAI if available (but don't require it)
freqai_condition = True # Default to True if no FreqAI
if '&-enter_long' in dataframe.columns:
# Use FreqAI signals if available
try:
freqai_condition = dataframe['&-enter_long'].astype(str).str.lower() == 'enter'
# Also try probability-based predictions
for col in dataframe.columns:
if isinstance(col, str) and 'prediction' in col.lower():
prob_predictions = dataframe[col].fillna(0)
freqai_condition = freqai_condition | (prob_predictions > 0.5)
break
except:
freqai_condition = True # Fallback if FreqAI fails
# Basic signal quality (if available, otherwise skip)
quality_condition = True
if 'signal_quality' in dataframe.columns:
quality_condition = dataframe['signal_quality'] > 0.4 # Lower threshold
# Combine conditions - only require basic ones
entry_condition = (
rsi_condition &
volume_condition &
(freqai_condition | quality_condition) # Either FreqAI OR basic quality
)
dataframe.loc[entry_condition, 'enter_long'] = 1
return dataframe
def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
"""Enhanced exit logic"""
dataframe.loc[:, 'exit_long'] = 0
# RSI overbought exit
rsi_exit = dataframe['rsi'] > self.rsi_overbought_threshold.value
# Trend reversal exit
trend_exit = dataframe['trend_consistency'] < 0.2
# Basis reversal (if available)
basis_exit = False
if 'basis_zscore' in dataframe.columns:
basis_exit = dataframe['basis_zscore'] > 2
# Volume exhaustion
volume_exit = dataframe['volume_ratio_20'] < 0.5
# Signal quality deterioration
quality_exit = dataframe['signal_quality'] < 0.3
exit_condition = rsi_exit | trend_exit | basis_exit | volume_exit | quality_exit
dataframe.loc[exit_condition, 'exit_long'] = 1
return dataframe
def custom_stake_amount(self, pair: str, current_time, current_rate: float,
proposed_stake: float, min_stake: float, max_stake: float,
entry_tag: str, side: str, **kwargs) -> float:
"""Dynamic position sizing based on risk metrics"""
dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
if dataframe.empty:
return proposed_stake
# Get current position multiplier
current_multiplier = dataframe['position_multiplier'].iloc[-1]
# Apply user-defined multiplier
final_multiplier = current_multiplier * self.position_size_multiplier.value
# Calculate final stake
final_stake = proposed_stake * final_multiplier
# Ensure within bounds
return max(min_stake, min(final_stake, max_stake))
def set_freqai_targets(self, dataframe: DataFrame, metadata: dict, **kwargs) -> DataFrame:
"""Set FreqAI targets with enhanced logic"""
# Calculate future returns
future_return = dataframe["close"].shift(-self.freqai_label_period) / dataframe["close"] - 1
# More sophisticated labeling based on risk-adjusted returns
volatility = dataframe['close'].pct_change().rolling(20).std()
risk_adjusted_threshold = self.freqai_min_return * (1 + volatility * 2)
# Create labels
dataframe["&-enter_long"] = np.where(
future_return > risk_adjusted_threshold,
"enter",
"hold"
)
dataframe["&-enter_long"] = dataframe["&-enter_long"].fillna("hold")
return dataframe
# FreqAI configuration - match config.json identifier
freqai_info = {
"identifier": "MyFreqAIModel_Enhanced",
"features": [
"%-basis_zscore", "%-basis_volatility", "%-basis_momentum", "%-basis_regime",
"%-rsi", "%-adx", "%-macd_strength", "%-bb_width",
"%-trend_consistency", "%-volatility_regime", "%-momentum_5", "%-momentum_10",
"%-volume_ratio", "%-volume_trend", "%-obv_trend",
"%-signal_quality"
],
"label_period_candles": freqai_label_period,
"data_split_parameters": {"test_size": 0.2, "shuffle": False},
"model_training_parameters": {
"n_estimators": 100, # Increased for better performance
"max_depth": 8, # Slightly deeper trees
"min_samples_split": 8,
"min_samples_leaf": 4,
"random_state": 42,
"n_jobs": -1
}
}