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 <noreply@anthropic.com>
This commit is contained in:
Harwinder
2026-05-07 17:40:39 -04:00
parent 7699e6dd1d
commit 53e6e07829
+73 -1
View File
@@ -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(