Timeframe
1h
Direction
Long Only
Stoploss
-5.0%
Trailing Stop
Yes
ROI
0m: 5.0%
Interface Version
3
Startup Candles
N/A
Indicators
2
freqtrade/freqtrade-strategies
freqtrade/freqtrade-strategies
this is an example class, implementing a PSAR based trailing stop loss you are supposed to take the `custom_stoploss()` and `populate_indicators()` parts and adapt it to your own strategy
freqtrade/freqtrade-strategies
Strategy 003 author@: Gerald Lonlas github@: https://github.com/freqtrade/freqtrade-strategies
from freqtrade.strategy import IStrategy
try:
# freqtrade.freqai may vary between versions; import safely
from freqtrade.freqai.prediction_models import TensorflowRegressor
from freqtrade.freqai.data_kitchen import FreqaiDataKitchen
except Exception:
TensorflowRegressor = None
FreqaiDataKitchen = None
from datetime import datetime
import numpy as np
import pandas as pd
import talib.abstract as ta
from pandas import DataFrame
import requests
import logging
logger = logging.getLogger(__name__)
class MLGridStrategy(IStrategy):
INTERFACE_VERSION = 3
minimal_roi = {"0": 0.05}
stoploss = -0.05
trailing_stop = True
trailing_stop_positive = 0.02
timeframe = '1h'
# Rename numeric default to avoid colliding with the leverage() method
default_leverage = 2
grid_levels = 5
grid_spacing = 0.01
startup_candle_count: int = 50
# Default to False to avoid requiring heavy ML dependencies for quick runs.
# Enable via config (or set to True after installing freqai/TensorFlow).
freqai_enabled = False
# Use imported model if available, otherwise disable freqai features at runtime
freqai_model = TensorflowRegressor if TensorflowRegressor is not None else None
freqai_corr_pairlist = ['BTC/USDT:USDT']
freqai_feature_parameters = {
'window_size': 20,
'corr_window_size': 20,
}
freqai_model_parameters = {
'epochs': 100,
'batch_size': 32,
'optimizer': 'adam',
'loss': 'mse',
'metrics': ['mae'],
'layers': [
{'type': 'MultiHeadAttention', 'num_heads': 4, 'key_dim': 50},
{'type': 'Dense', 'units': 64, 'activation': 'relu'},
{'type': 'Dense', 'units': 1},
]
}
freqai_retrain_period = 12
freqai_identifier = 'mlgrid_transformer'
def __init__(self, config: dict) -> None:
# IStrategy implementations may be constructed with a config dict.
# Safely load freqai configuration overrides from the strategy/config file
# while keeping reasonable defaults when freqai isn't available.
super().__init__(config)
try:
self._load_freqai_config()
except Exception:
# Do not fail strategy construction if freqai or config keys are missing
logger.debug("Failed to load freqai config overrides; using defaults.")
def _load_freqai_config(self) -> None:
"""Merge freqai-related config from self.config with strategy defaults.
This allows users to set `freqai_enabled` and `freqai_model_parameters`
in `config.json` (top-level or under strategy-specific keys) without
requiring code changes.
"""
cfg = getattr(self, 'config', None) or {}
# Top-level config option: freqai_enabled
if isinstance(cfg, dict):
top_enabled = cfg.get('freqai_enabled')
if isinstance(top_enabled, bool):
self.freqai_enabled = top_enabled
# Strategy-specific overrides: config['strategy'] or config['strategy_config']
strat_cfg = cfg.get('strategy') if isinstance(cfg.get('strategy'), dict) else {}
if not strat_cfg:
strat_cfg = cfg.get('strategy_config') if isinstance(cfg.get('strategy_config'), dict) else {}
# Some users may put strategy options under strategy_name -> options
# e.g., config['strategy']['MLGridStrategy'] = {...}
strat_name_cfg = {}
if isinstance(strat_cfg, dict):
strat_name_cfg = strat_cfg.get(self.__class__.__name__, {}) if isinstance(strat_cfg.get(self.__class__.__name__), dict) else {}
# Merge order: defaults <- top-level strategy dict <- strategy-name dict
merged = {}
merged.update(getattr(self, 'freqai_model_parameters', {}) or {})
# top-level freqai_model_parameters
top_params = cfg.get('freqai_model_parameters')
if isinstance(top_params, dict):
merged.update(top_params)
# strategy-level params
strat_params = strat_cfg.get('freqai_model_parameters') if isinstance(strat_cfg.get('freqai_model_parameters'), dict) else None
if isinstance(strat_params, dict):
merged.update(strat_params)
# named strategy override
named_params = strat_name_cfg.get('freqai_model_parameters') if isinstance(strat_name_cfg.get('freqai_model_parameters'), dict) else None
if isinstance(named_params, dict):
merged.update(named_params)
# Apply merged model params back to the strategy
self.freqai_model_parameters = merged
# Also allow enabling via strategy-specific flags
strat_enabled = strat_name_cfg.get('freqai_enabled') if isinstance(strat_name_cfg.get('freqai_enabled'), bool) else None
if strat_enabled is not None:
self.freqai_enabled = strat_enabled
# Log resolved settings for easier debugging (non-fatal)
try:
logger.info(f"freqai_enabled={self.freqai_enabled}")
logger.info(f"freqai_model_parameters={self.freqai_model_parameters}")
except Exception:
# Best-effort logging; do not raise
pass
def informative_pairs(self):
return [("BTC/USDT:USDT", "1h")]
def fetch_sentiment(self) -> float:
try:
url = "https://api.coingecko.com/api/v3/coins/bitcoin?localization=false&tickers=false&market_data=false&community_data=true"
response = requests.get(url, timeout=5)
data = response.json()
sentiment = data.get('sentiment_votes_up_percentage', 50) / 100
logger.info(f"Fetched sentiment: {sentiment}")
return sentiment
except Exception as e:
logger.warning(f"Sentiment fetch failed: {e}")
return 0.5
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
dataframe['rsi'] = ta.RSI(dataframe, timeperiod=14)
macd, macdsignal, macdhist = ta.MACD(dataframe)
dataframe['macd'] = macd
dataframe['macdsignal'] = macdsignal
dataframe['macdhist'] = macdhist
dataframe['sentiment'] = self.fetch_sentiment()
# Debug: log the most recent calculated indicators for visibility during backtests
try:
last = dataframe.tail(1).iloc[0]
logger.debug(f"Indicators (last row) - rsi={last.get('rsi')}, macdhist={last.get('macdhist')}, sentiment={last.get('sentiment')}")
except Exception:
pass
if self.freqai_enabled and FreqaiDataKitchen is not None:
dk = FreqaiDataKitchen(self.config)
dataframe = dk.make_features(dataframe, self, metadata, self.freqai_feature_parameters)
return dataframe
def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
dataframe['enter_long'] = 0
dataframe['enter_short'] = 0
# Obtain predicted_delta series: prefer freqai predictions, otherwise use a simple momentum fallback
use_fallback = True
predicted_delta = None
sentiment = dataframe['sentiment'] if 'sentiment' in dataframe.columns else 0.5
if self.freqai_enabled and hasattr(self, 'freqai') and self.freqai is not None:
try:
pred_df = self.freqai.get_predictions(dataframe, metadata)
if pred_df is not None and 'prediction' in pred_df:
predicted_delta = pred_df['prediction']
use_fallback = False
else:
logger.warning("Missing or malformed prediction data; falling back to momentum predictor.")
except Exception:
logger.exception("Error obtaining freqai predictions; falling back to momentum predictor.")
if use_fallback:
# Momentum fallback: short-window percent change as a cheap proxy for predicted delta
try:
# 3-period return (can be tuned); clip to avoid extreme values
predicted_delta = dataframe['close'].pct_change(periods=3).fillna(0).clip(-0.1, 0.1)
except Exception:
# Final safeguard: zero series if even that fails
predicted_delta = pd.Series([0.0] * len(dataframe), index=dataframe.index)
# Choose thresholds depending on whether we're using the ML predictor or fallback
if use_fallback:
pred_threshold_long = 0.001
pred_threshold_short = -0.001
# Relaxed fallback thresholds for easier signal generation during testing
pred_threshold_long = 0.0005
pred_threshold_short = -0.0005
# For fallback, do not gate on sentiment (useful for offline testing)
sentiment_high = 0.0
sentiment_low = 1.0
# Use a small MACD magnitude threshold: values with abs(macdhist) in the top 75%
try:
macd_abs_thresh = dataframe['macdhist'].abs().quantile(0.25)
except Exception:
macd_abs_thresh = 0.0
else:
pred_threshold_long = 0.005
pred_threshold_short = -0.005
sentiment_high = 0.6
sentiment_low = 0.4
# Now compute conditions; predicted_delta may be a Series or array-like
try:
pd_pred = pd.Series(predicted_delta, index=dataframe.index)
except Exception:
pd_pred = predicted_delta
# When using fallback, use relaxed RSI bounds and require a meaningful macdhist magnitude
if use_fallback:
conditions_long = (
(dataframe['rsi'] < 40) &
(dataframe['macdhist'] > macd_abs_thresh) &
(pd_pred > pred_threshold_long)
)
else:
conditions_long = (
(dataframe['rsi'] < 30) &
(dataframe['macdhist'] > 0) &
(pd_pred > pred_threshold_long) &
(sentiment > sentiment_high)
)
dataframe.loc[conditions_long, 'enter_long'] = 1
# Debug: log how many long conditions matched and a sample index
try:
n_long = int(conditions_long.sum())
logger.debug(f"populate_entry_trend: {n_long} long conditions matched")
if n_long > 0:
sample_idxs = dataframe.loc[conditions_long].iloc[-3:].index.tolist()
logger.debug(f"populate_entry_trend: sample long indices: {sample_idxs}")
except Exception:
pass
if use_fallback:
conditions_short = (
(dataframe['rsi'] > 60) &
(dataframe['macdhist'] < -macd_abs_thresh) &
(pd_pred < pred_threshold_short)
)
else:
conditions_short = (
(dataframe['rsi'] > 70) &
(dataframe['macdhist'] < 0) &
(pd_pred < pred_threshold_short) &
(sentiment < sentiment_low)
)
dataframe.loc[conditions_short, 'enter_short'] = 1
# Debug: log how many short conditions matched
try:
n_short = int(conditions_short.sum())
logger.debug(f"populate_entry_trend: {n_short} short conditions matched")
if n_short > 0:
sample_idxs = dataframe.loc[conditions_short].iloc[-3:].index.tolist()
logger.debug(f"populate_entry_trend: sample short indices: {sample_idxs}")
except Exception:
pass
return dataframe
def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
dataframe['exit_long'] = 0
dataframe['exit_short'] = 0
exit_long_mask = (dataframe['rsi'] > 70) | (dataframe['sentiment'] < 0.4)
exit_short_mask = (dataframe['rsi'] < 30) | (dataframe['sentiment'] > 0.6)
dataframe.loc[exit_long_mask, 'exit_long'] = 1
dataframe.loc[exit_short_mask, 'exit_short'] = 1
try:
n_exit_long = int(exit_long_mask.sum())
n_exit_short = int(exit_short_mask.sum())
logger.debug(f"populate_exit_trend: exit_long={n_exit_long}, exit_short={n_exit_short}")
except Exception:
pass
return dataframe
def leverage(self, pair: str, current_time: datetime, current_rate: float,
proposed_leverage: float, max_leverage: float, entry_tag: str, side: str,
**kwargs) -> float:
"""
Compute dynamic leverage. Returns a numeric leverage value.
Note: use `default_leverage` as the numeric default to avoid recursion.
"""
if self.freqai_enabled and hasattr(self, 'freqai') and self.freqai is not None:
pred_df = self.freqai.get_predictions(pd.DataFrame(), {'pair': pair})
if pred_df is None or 'prediction' not in pred_df:
return self.default_leverage
vol_pred = np.std(pred_df['prediction'][-10:])
sentiment = self.fetch_sentiment()
leverage_adj = self.default_leverage * (1 - vol_pred * 5) * (1 + (sentiment - 0.5))
# Ensure leverage is at least 1 and does not exceed the strategy default
leverage_final = min(max(leverage_adj, 1), self.default_leverage)
logger.info(f"Adjusted leverage: {leverage_final}")
return leverage_final
return self.default_leverage
from typing import Any
def custom_stoploss(self, pair: str, trade: Any, current_time: datetime, current_rate: float,
current_profit: float, **kwargs) -> float:
"""
Custom stoploss: once trade profit reaches 1% (0.01), start trailing the stoploss
so that it remains 1% behind the current profit. Return a stoploss as a
negative fraction (e.g., -0.02 means 2% stoploss). Return 1 to keep existing
stoploss behavior.
"""
try:
# current_profit is a fraction (e.g., 0.02 for +2%) provided by freqtrade
profit = float(current_profit)
except Exception:
return 1
# If profit hasn't reached the trailing threshold, use normal strategy stoploss
trailing_threshold = 0.01 # 1%
trailing_distance = 0.01 # keep stoploss 1% behind current profit
if profit >= trailing_threshold:
# Desired stoploss level: current_profit - trailing_distance
new_stop = profit - trailing_distance
# Convert to negative stoploss fraction (freqtrade expects negative)
stoploss_frac = -abs(new_stop)
# Ensure we don't set a stoploss that's higher (less protective) than strategy stoploss
strategy_stop = getattr(self, 'stoploss', -0.05)
# Use the tighter (closer to zero) of the two (i.e., max since both negative)
final_stop = max(stoploss_frac, strategy_stop)
logger.debug(f"custom_stoploss: profit={profit:.4f} new_stop={final_stop:.4f}")
return float(final_stop)
return 1
def custom_entry(self, pair: str, current_time: datetime, current_rate: float,
current_profit: float, **kwargs):
"""
Implements grid order placement logic based on prediction and sentiment.
"""
# If freqai is disabled or not available, do not attempt custom grid entry
if not self.freqai_enabled or not hasattr(self, 'freqai') or self.freqai is None:
return None
pred_df = self.freqai.get_predictions(pd.DataFrame(), {'pair': pair})
if pred_df is None or 'prediction' not in pred_df:
logger.warning("No prediction data for custom_entry.")
return None
sentiment = self.fetch_sentiment()
predicted_delta = pred_df['prediction'].iloc[-1]
vol_pred = np.std(pred_df['prediction'][-10:])
dynamic_spacing = self.grid_spacing * (1 + vol_pred + (sentiment - 0.5))
grid_orders = []
direction = 'buy' if predicted_delta > 0 else 'sell'
base_price = current_rate
for i in range(1, self.grid_levels + 1):
offset = base_price * dynamic_spacing * i
price = base_price - offset if direction == 'buy' else base_price + offset
grid_orders.append({
'price': round(price, 2),
'amount': 1 / self.grid_levels,
'side': direction,
'leverage': self.leverage(pair, current_time, current_rate, self.default_leverage, self.default_leverage, 'grid', direction)
})
logger.info(f"Placing {direction} grid orders: {grid_orders}")
return grid_orders