From 53e6e078296a53686d32bf7b279097a376c7940a Mon Sep 17 00:00:00 2001 From: Harwinder Date: Thu, 7 May 2026 17:40:39 -0400 Subject: [PATCH] Wire regime filter into live mode --regime-filter previously affected only backtests. Live mode now also checks higher-TF ADX after analysis: signals are downgraded to NEUTRAL with a warning when ADX < threshold, and confirming when above. This brings live behavior into parity with what the backtester measures. Co-Authored-By: Claude Opus 4.7 --- signal_aggregator.py | 74 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 73 insertions(+), 1 deletion(-) diff --git a/signal_aggregator.py b/signal_aggregator.py index f5179c7..a7e53cf 100644 --- a/signal_aggregator.py +++ b/signal_aggregator.py @@ -109,6 +109,10 @@ class SignalAggregator: self.config.confirmation_timeframes ) + bt = self.config.backtest + if bt.regime_filter and bt.regime_filter_timeframe not in timeframes: + timeframes.append(bt.regime_filter_timeframe) + dfs: Dict[Timeframe, pd.DataFrame] = {} for tf in timeframes: try: @@ -116,13 +120,81 @@ class SignalAggregator: except Exception as e: logger.warning(f"Fetch failed for {symbol} {tf.value}: {e}") - return self.analyze_from_dataframes( + report = self.analyze_from_dataframes( symbol=symbol, dfs=dfs, context=context, use_news=True, ) + if bt.regime_filter: + self._apply_regime_filter_live(report, dfs) + + return report + + def _apply_regime_filter_live( + self, report: SignalReport, dfs: Dict[Timeframe, pd.DataFrame] + ) -> None: + """If higher-TF ADX is below threshold, downgrade signal to NEUTRAL.""" + bt = self.config.backtest + regime_tf = bt.regime_filter_timeframe + df = dfs.get(regime_tf) + if df is None or df.empty: + report.warnings.append( + f"Regime filter requested but {regime_tf.value} data unavailable" + ) + return + + adx_value = self._latest_adx(df, self.config.technical.adx_period) + if adx_value is None: + report.warnings.append( + f"Regime filter: ADX could not be computed on {regime_tf.value}" + ) + return + + if adx_value < bt.regime_filter_min_adx: + report.warnings.append( + f"REGIME FILTER: {regime_tf.value} ADX={adx_value:.1f} < " + f"{bt.regime_filter_min_adx:.0f} — market choppy, skipping signal" + ) + report.final_signal = SignalStrength.NEUTRAL + report.signal_quality = "F" + report.action_recommendation = ( + f"NO TRADE — Regime filter (ADX {adx_value:.1f} on {regime_tf.value})" + ) + report.trade_setup = None + else: + report.bullish_reasons.append( + f"Regime: {regime_tf.value} ADX={adx_value:.1f} confirms trending market" + ) if report.final_score > 0 else report.bearish_reasons.append( + f"Regime: {regime_tf.value} ADX={adx_value:.1f} confirms trending market" + ) + + @staticmethod + def _latest_adx(df: pd.DataFrame, period: int = 14) -> Optional[float]: + """Wilder ADX on a fresh df; returns the last finite value or None.""" + if df is None or len(df) < period * 3: + return None + high, low, close = df["High"], df["Low"], df["Close"] + tr = pd.concat([ + high - low, + (high - close.shift()).abs(), + (low - close.shift()).abs(), + ], axis=1).max(axis=1) + plus_dm = high.diff() + minus_dm = -low.diff() + plus_dm = plus_dm.where((plus_dm > minus_dm) & (plus_dm > 0), 0.0) + minus_dm = minus_dm.where((minus_dm > plus_dm) & (minus_dm > 0), 0.0) + atr_s = tr.ewm(alpha=1/period, min_periods=period).mean() + plus_di = 100 * (plus_dm.ewm(alpha=1/period, min_periods=period).mean() / atr_s) + minus_di = 100 * (minus_dm.ewm(alpha=1/period, min_periods=period).mean() / atr_s) + denom = (plus_di + minus_di).replace(0, float("nan")) + dx = 100 * (plus_di - minus_di).abs() / denom + adx = dx.ewm(alpha=1/period, min_periods=period).mean().dropna() + if adx.empty: + return None + return float(adx.iloc[-1]) + # ── Public: bar-replayable entry point ───────────────────────── def analyze_from_dataframes(