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.
This commit is contained in:
+73
-1
@@ -109,6 +109,10 @@ class SignalAggregator:
|
|||||||
self.config.confirmation_timeframes
|
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] = {}
|
dfs: Dict[Timeframe, pd.DataFrame] = {}
|
||||||
for tf in timeframes:
|
for tf in timeframes:
|
||||||
try:
|
try:
|
||||||
@@ -116,13 +120,81 @@ class SignalAggregator:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Fetch failed for {symbol} {tf.value}: {e}")
|
logger.warning(f"Fetch failed for {symbol} {tf.value}: {e}")
|
||||||
|
|
||||||
return self.analyze_from_dataframes(
|
report = self.analyze_from_dataframes(
|
||||||
symbol=symbol,
|
symbol=symbol,
|
||||||
dfs=dfs,
|
dfs=dfs,
|
||||||
context=context,
|
context=context,
|
||||||
use_news=True,
|
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 ─────────────────────────
|
# ── Public: bar-replayable entry point ─────────────────────────
|
||||||
|
|
||||||
def analyze_from_dataframes(
|
def analyze_from_dataframes(
|
||||||
|
|||||||
Reference in New Issue
Block a user