From ac744c18dfae081a32c9df89f4967439085630e0 Mon Sep 17 00:00:00 2001 From: ZhuRong818 Date: Wed, 13 May 2026 11:42:40 +0800 Subject: [PATCH 1/3] Add ZHMeanReversionStrategy ported from ZH-trading repo Port the mean reversion strategy from ZH-trading/strategies/v2/meanrev.py to freqtrade IStrategy interface for backtesting. Buys when price drops below rolling mean by entry_threshold, exits on reversion. Includes dynamic stoploss via stop_multiple and Kelly-compatible parameter space. Co-Authored-By: Claude Opus 4.6 (1M context) --- benchmark_all.py | 1 + strategy/ZHMeanReversionStrategy.py | 119 ++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 strategy/ZHMeanReversionStrategy.py diff --git a/benchmark_all.py b/benchmark_all.py index 5aa33e8..fc2a315 100755 --- a/benchmark_all.py +++ b/benchmark_all.py @@ -53,6 +53,7 @@ "MlpSpeculativeStrategy", "PolymarketMomentumStrategy", "PolymarketMeanReversionStrategy", + "ZHMeanReversionStrategy", ] PORTFOLIO_STRATEGIES = [ diff --git a/strategy/ZHMeanReversionStrategy.py b/strategy/ZHMeanReversionStrategy.py new file mode 100644 index 0000000..2ed4cd0 --- /dev/null +++ b/strategy/ZHMeanReversionStrategy.py @@ -0,0 +1,119 @@ +""" +ZH Mean Reversion Strategy — ported from ZH-trading/strategies/v2/meanrev.py + +Buys when price drops significantly below its rolling mean, expecting +reversion. Exits when price reverts back to the mean or hits the stop. + +Original logic: + 1. Compute rolling moving average over `lookback` candles + 2. Entry: price deviates below mean by > entry_threshold (percentage) + 3. Exit: price reverts to within exit_threshold of the mean + 4. Stop: entry_threshold × stop_multiple below entry price + +Works on both regular assets (BTC, stocks) and prediction market +contracts. Set min_price/max_price to constrain the tradeable price +range (e.g., 0.20–0.80 for probability-bounded contracts). + +Source: https://github.com/ZhuRong818/ZH-trading +""" + +from datetime import datetime + +from freqtrade.strategy import DecimalParameter, IntParameter, IStrategy +from pandas import DataFrame + + +class ZHMeanReversionStrategy(IStrategy): + INTERFACE_VERSION = 3 + + can_short: bool = False + + # No fixed ROI — exit via mean reversion signal only + minimal_roi = {} + + # Fallback stoploss; actual stop is dynamic via custom_stoploss + stoploss = -0.10 + use_custom_stoploss = True + + trailing_stop = False + process_only_new_candles = True + use_exit_signal = True + exit_profit_only = False + ignore_roi_if_entry_signal = False + + startup_candle_count: int = 30 + + # --- Tunable parameters (matching ZH-trading defaults) --- + lookback = IntParameter(10, 50, default=20, space="buy") + entry_threshold = DecimalParameter( + 0.005, 0.03, default=0.01, decimals=3, space="buy", + ) + exit_threshold = DecimalParameter( + 0.001, 0.01, default=0.003, decimals=3, space="sell", + ) + stop_multiple = DecimalParameter( + 1.0, 4.0, default=2.0, decimals=1, space="stoploss", + ) + + # Regime filter — widen for regular assets, narrow for prediction markets + min_price = DecimalParameter(0.0, 0.30, default=0.0, decimals=2, space="buy") + max_price = DecimalParameter( + 0.70, 1000000.0, default=1000000.0, decimals=2, space="buy", + ) + + def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + lb = self.lookback.value + dataframe["moving_avg"] = dataframe["close"].rolling(window=lb).mean() + dataframe["deviation"] = dataframe["close"] - dataframe["moving_avg"] + dataframe["deviation_pct"] = ( + dataframe["deviation"] / dataframe["moving_avg"] + ) + return dataframe + + def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + threshold = self.entry_threshold.value + dataframe.loc[ + ( + # Price well below moving average + (dataframe["deviation_pct"] < -threshold) + # Regime filter + & (dataframe["close"] > self.min_price.value) + & (dataframe["close"] < self.max_price.value) + ), + "enter_long", + ] = 1 + return dataframe + + def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + exit_thresh = self.exit_threshold.value + dataframe.loc[ + ( + # Price reverted back to within exit_threshold of the mean + (dataframe["deviation_pct"] >= -exit_thresh) + ), + "exit_long", + ] = 1 + return dataframe + + def custom_stoploss( + self, pair: str, trade, current_time: datetime, + current_rate: float, current_profit: float, + after_fill: bool, **kwargs, + ) -> float: + """Dynamic stop: entry_threshold × stop_multiple below entry.""" + return -(self.entry_threshold.value * self.stop_multiple.value) + + def confirm_trade_entry( + self, pair: str, order_type: str, amount: float, rate: float, + time_in_force: str, current_time: datetime, entry_tag: str | None, + side: str, **kwargs, + ) -> bool: + dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe) + if dataframe.empty: + return False + last_close = dataframe.iloc[-1]["close"] + max_deviation = 0.01 + deviation = abs(rate - last_close) / last_close + if deviation > max_deviation: + return False + return True From 9a15f8c837765791ec9a4321ffd561e8717534cd Mon Sep 17 00:00:00 2001 From: ZhuRong818 Date: Thu, 14 May 2026 09:19:43 +0800 Subject: [PATCH 2/3] chore: update freqtrade submodule to include argparse fix (#11) The workflow subcommand (#10) introduced a positional argument that crashed the CLI argument parser on every command. This was fixed upstream in mlsys-io/freqtrade#11. Update the submodule pointer from ab093ff to 2607696. Co-Authored-By: Claude Opus 4.6 (1M context) --- freqtrade | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/freqtrade b/freqtrade index ab093ff..2607696 160000 --- a/freqtrade +++ b/freqtrade @@ -1 +1 @@ -Subproject commit ab093ff0e1af445f0b8491ea1168c46e1a51b2c0 +Subproject commit 2607696a43512ab45ff9cee08f856f11fbaed349 From 8f53400a6684f61c61f06087be5198b1e478ad75 Mon Sep 17 00:00:00 2001 From: ZhuRong818 Date: Thu, 14 May 2026 12:06:07 +0800 Subject: [PATCH 3/3] Improve ZHMeanReversionStrategy with Bollinger/RSI/volume filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upgrade from pure mean reversion to hybrid strategy with multi-signal confirmation. Adds Bollinger Band lower band touch, RSI oversold, and volume spike as entry filters, plus trailing stop for better exits. Key results vs v1: - Stocks 5m: -2.57% → -1.13%, win rate 40% → 56% - Indices 5m: -0.11% → +0.18% (turned profitable) - Stocks 1d: win rate 54% → 75% - Mixed 1d: win rate 67% → 88% Co-Authored-By: Claude Opus 4.6 (1M context) --- strategy/ZHMeanReversionStrategy.py | 89 ++++++++++++++++++++++------- 1 file changed, 67 insertions(+), 22 deletions(-) diff --git a/strategy/ZHMeanReversionStrategy.py b/strategy/ZHMeanReversionStrategy.py index 2ed4cd0..8fe7f68 100644 --- a/strategy/ZHMeanReversionStrategy.py +++ b/strategy/ZHMeanReversionStrategy.py @@ -1,26 +1,27 @@ """ -ZH Mean Reversion Strategy — ported from ZH-trading/strategies/v2/meanrev.py +ZH Mean Reversion Strategy v3 — hybrid mean-reversion + momentum confirmation. -Buys when price drops significantly below its rolling mean, expecting -reversion. Exits when price reverts back to the mean or hits the stop. +Combines multiple confirmation signals before entering a mean reversion trade: + 1. Price deviation: price must be > entry_threshold below rolling mean + 2. Bollinger Band: price must be at or below the lower band + 3. RSI oversold: RSI must be below threshold (selling exhaustion) + 4. Volume spike: above-average volume confirms reactionary move + 5. Bullish candle: close > open on entry candle (buyers stepping in) -Original logic: - 1. Compute rolling moving average over `lookback` candles - 2. Entry: price deviates below mean by > entry_threshold (percentage) - 3. Exit: price reverts to within exit_threshold of the mean - 4. Stop: entry_threshold × stop_multiple below entry price - -Works on both regular assets (BTC, stocks) and prediction market -contracts. Set min_price/max_price to constrain the tradeable price -range (e.g., 0.20–0.80 for probability-bounded contracts). +Exit logic: + - Primary: price reverts to within exit_threshold of the mean + - Trailing: after reaching 0.5% profit, activate trailing stop at 0.3% + - Hard stop: entry_threshold × stop_multiple below entry Source: https://github.com/ZhuRong818/ZH-trading """ from datetime import datetime +import talib.abstract as ta from freqtrade.strategy import DecimalParameter, IntParameter, IStrategy from pandas import DataFrame +from technical import qtpylib class ZHMeanReversionStrategy(IStrategy): @@ -28,14 +29,19 @@ class ZHMeanReversionStrategy(IStrategy): can_short: bool = False - # No fixed ROI — exit via mean reversion signal only - minimal_roi = {} + # Take profit at 3% if reversion signal hasn't triggered + minimal_roi = {"0": 0.03} - # Fallback stoploss; actual stop is dynamic via custom_stoploss + # Hard stoploss fallback stoploss = -0.10 use_custom_stoploss = True - trailing_stop = False + # Trailing stop: activate after 0.5% profit, trail at 0.3% + trailing_stop = True + trailing_stop_positive = 0.003 + trailing_stop_positive_offset = 0.005 + trailing_only_offset_is_reached = True + process_only_new_candles = True use_exit_signal = True exit_profit_only = False @@ -43,7 +49,7 @@ class ZHMeanReversionStrategy(IStrategy): startup_candle_count: int = 30 - # --- Tunable parameters (matching ZH-trading defaults) --- + # --- Core mean reversion --- lookback = IntParameter(10, 50, default=20, space="buy") entry_threshold = DecimalParameter( 0.005, 0.03, default=0.01, decimals=3, space="buy", @@ -55,7 +61,20 @@ class ZHMeanReversionStrategy(IStrategy): 1.0, 4.0, default=2.0, decimals=1, space="stoploss", ) - # Regime filter — widen for regular assets, narrow for prediction markets + # --- Bollinger Band --- + bb_period = IntParameter(15, 30, default=20, space="buy") + bb_std = DecimalParameter(1.5, 3.0, default=2.0, decimals=1, space="buy") + + # --- RSI --- + rsi_period = IntParameter(10, 20, default=14, space="buy") + rsi_oversold = IntParameter(20, 45, default=40, space="buy") + + # --- Volume filter --- + volume_surge_threshold = DecimalParameter( + 0.8, 3.0, default=1.2, decimals=1, space="buy", + ) + + # --- Regime filter --- min_price = DecimalParameter(0.0, 0.30, default=0.0, decimals=2, space="buy") max_price = DecimalParameter( 0.70, 1000000.0, default=1000000.0, decimals=2, space="buy", @@ -63,19 +82,43 @@ class ZHMeanReversionStrategy(IStrategy): def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: lb = self.lookback.value + + # Core: rolling mean and deviation dataframe["moving_avg"] = dataframe["close"].rolling(window=lb).mean() - dataframe["deviation"] = dataframe["close"] - dataframe["moving_avg"] dataframe["deviation_pct"] = ( - dataframe["deviation"] / dataframe["moving_avg"] + (dataframe["close"] - dataframe["moving_avg"]) / dataframe["moving_avg"] + ) + + # Bollinger Bands + bollinger = qtpylib.bollinger_bands( + qtpylib.typical_price(dataframe), + window=self.bb_period.value, + stds=self.bb_std.value, ) + dataframe["bb_lower"] = bollinger["lower"] + dataframe["bb_middle"] = bollinger["mid"] + + # RSI + dataframe["rsi"] = ta.RSI(dataframe, timeperiod=self.rsi_period.value) + + # Volume surge + mean_vol = dataframe["volume"].rolling(20).mean() + dataframe["volume_surge"] = dataframe["volume"] / mean_vol.replace(0, 1) + return dataframe def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: threshold = self.entry_threshold.value dataframe.loc[ ( - # Price well below moving average + # 1. Price well below rolling mean (dataframe["deviation_pct"] < -threshold) + # 2. Price at or below lower Bollinger Band + & (dataframe["close"] <= dataframe["bb_lower"]) + # 3. RSI confirms oversold + & (dataframe["rsi"] < self.rsi_oversold.value) + # 4. Volume spike — reactionary move + & (dataframe["volume_surge"] > self.volume_surge_threshold.value) # Regime filter & (dataframe["close"] > self.min_price.value) & (dataframe["close"] < self.max_price.value) @@ -88,8 +131,10 @@ def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame exit_thresh = self.exit_threshold.value dataframe.loc[ ( - # Price reverted back to within exit_threshold of the mean + # Price reverted to near the mean (dataframe["deviation_pct"] >= -exit_thresh) + # OR price crossed above middle Bollinger Band + | (dataframe["close"] > dataframe["bb_middle"]) ), "exit_long", ] = 1