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:
+73
-1
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user