import talib.abstract as ta from freqtrade.strategy import IStrategy, IntParameter, DecimalParameter from freqtrade.enums import RunMode from pandas import DataFrame from datetime import datetime class Horizon(IStrategy): """ Horizon Strategy A quantitative trend-following strategy designed for trading on 4-hour timeframe. It combines Linear Regression Slope for momentum detection with Exponential Moving Average (EMA) as a primary trend direction filter and Volume EMA for confirmation. """ # Default ROI table fallback (overridden by Horizon.json in production/backtest) minimal_roi = { "0": 0.20, "240": 0.12, "720": 0.08, "1440": 0.04 } # Default stoploss fallback (-3.4%) stoploss = -0.034 # Primary timeframe timeframe = "4h" # Strategy capabilities can_short = True startup_candle_count = 200 def leverage( self, pair: str, current_time: datetime, current_rate: float, proposed_leverage: float, max_leverage: float, entry_tag: str, side: str, **kwargs, ) -> float: """ Custom leverage calculation callback. Reads leverage configuration dynamically from strategy_settings in config file. """ cfg_lev = ( self.config.get("strategy_settings", {}) .get("Horizon", {}) .get("leverage", 2.0) ) try: lev = float(cfg_lev) except Exception: lev = 2.0 if max_leverage and lev > max_leverage: return float(max_leverage) return lev def confirm_trade_entry( self, pair: str, order_type: str, amount: float, rate: float, time_in_force: str, current_time: datetime, entry_tag: str, side: str, **kwargs, ) -> bool: """ Confirm trade entry callback. Optional account value cap check via strategy_settings.Horizon.max_account_value. """ cfg = self.config.get("strategy_settings", {}).get("Horizon", {}) max_val = cfg.get("max_account_value") if max_val is not None: try: tot = self.wallets.get_total_stake_amount() if tot >= float(max_val): if self.config.get("runmode") != RunMode.HYPEROPT: print(f"[Horizon] Skip entry {pair}: account value {tot} >= max {max_val}") return False except Exception: pass return True # Strategy Parameters (Hyperopt-able boundaries) slope_period = IntParameter(10, 50, default=24, space="buy") ema_trend_period = IntParameter(20, 100, default=50, space="buy") buy_slope_min = DecimalParameter(0.0001, 0.002, default=0.0005, space="buy") short_slope_max = DecimalParameter(-0.002, -0.0001, default=-0.0005, space="sell") exit_long_slope = DecimalParameter(-0.001, 0.001, default=-0.0005, space="sell") exit_short_slope = DecimalParameter(-0.001, 0.001, default=0.0005, space="buy") def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: """ Populate Technical Analysis indicators for the given dataframe. """ # Linear Regression Slope for momentum strength dataframe["slope"] = ta.LINEARREG_SLOPE( dataframe["close"], timeperiod=self.slope_period.value ) # Exponential Moving Average for main trend filter dataframe["ema_trend"] = ta.EMA( dataframe["close"], timeperiod=self.ema_trend_period.value ) # 20-period Volume EMA for volume confirmation dataframe["vol_ema"] = ta.EMA(dataframe["volume"], timeperiod=20) return dataframe def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: """ Populate entry (buy/short) signals. Uses shifted candles (.shift(1)) to prevent lookahead bias / repaint. """ # Long Entry Conditions: # 1. Linear slope is strongly positive (> buy_slope_min) # 2. Close price is above the primary EMA trend line (Uptrend) # 3. Volume is above 20-period Volume EMA (Volume confirmation) dataframe.loc[ ( (dataframe["slope"].shift(1) > float(self.buy_slope_min.value)) & (dataframe["close"].shift(1) > dataframe["ema_trend"].shift(1)) & (dataframe["volume"].shift(1) > dataframe["vol_ema"].shift(1)) & (dataframe["volume"].shift(1) > 0) ), "enter_long", ] = 1 # Short Entry Conditions: # 1. Linear slope is strongly negative (< short_slope_max) # 2. Close price is below the primary EMA trend line (Downtrend) # 3. Volume is above 20-period Volume EMA (Volume confirmation) dataframe.loc[ ( (dataframe["slope"].shift(1) < float(self.short_slope_max.value)) & (dataframe["close"].shift(1) < dataframe["ema_trend"].shift(1)) & (dataframe["volume"].shift(1) > dataframe["vol_ema"].shift(1)) & (dataframe["volume"].shift(1) > 0) ), "enter_short", ] = 1 return dataframe def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: """ Populate exit (sell) signals based on slope trend reversal. """ # Exit Long: Slope reverses downwards below threshold dataframe.loc[ ((dataframe["slope"].shift(1) < float(self.exit_long_slope.value))), "exit_long", ] = 1 # Exit Short: Slope reverses upwards above threshold dataframe.loc[ ((dataframe["slope"].shift(1) > float(self.exit_short_slope.value))), "exit_short", ] = 1 return dataframe