Refocus on XAU/USD: add momentum indicators, tune TP, exhaustion block

Strip EUR/USD, GBP/USD, USD/JPY pairs and strategies from entire codebase.
Add 3 new scored indicators (ROC, Keltner/TTM Squeeze, ADX slope) and
enable MACD-as-pillar + divergence detection for early trend entry.
Introduce "transitioning" regime (ADX < 25, slope > 3.0) to catch trend
births before ADX confirms. Gold-specific composite weights per regime.

Tune TP multipliers: quality-tiered scaling (A+/A get 1.5-2.0x ATR via
XAU ATR method, B/C use swing-level targeting). Backtest: +24R total,
65% WR, PF 1.12 over 365 days.

Add momentum-confirmed exhaustion block: hard-reject trades when 2+
static exhaustion signals fire AND ROC/MACD confirm fading momentum.
Prevents late trend entries without killing strong trend trades.

Telegram: simplify buttons to timeframe-only labels, add "Scan All TFs"
multi-timeframe summary, add /scan command, fix BB/KC level breach keys,
fix price formatting (.2f for gold), fix deprecated asyncio call, fix
"1 pairs" grammar and 60s/30s dashboard refresh mismatch.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
harwinder
2026-05-15 10:51:51 -04:00
parent 98daf71a52
commit 8ed6454ea5
15 changed files with 852 additions and 2108 deletions
+208 -12
View File
@@ -93,6 +93,9 @@ class TechnicalEngine:
self._compute_macd(df, result)
if "roc" not in disabled: self._compute_roc(df, result)
if "keltner" not in disabled: self._compute_keltner(df, result)
if "divergence" not in disabled: self._detect_divergences(df, result)
if "structure" not in disabled: self._compute_structure_sr(df, result)
if "candlestick" not in disabled: self._detect_candlestick_patterns(df, result)
@@ -247,7 +250,22 @@ class TechnicalEngine:
crossover_desc = " | Death cross (fast/medium)"
conf = min(conf + 10, 95)
desc = f"EMA alignment: {alignment_score}/{max_checks}{crossover_desc}"
# EMA ribbon compression/expansion detection
ribbon_desc = ""
atr_val = result.key_levels.get("atr", 0)
ema_values = [f, m, s]
if atr_val > 0:
ribbon_width = (max(ema_values) - min(ema_values)) / atr_val
if ribbon_width < 1.0:
ribbon_desc = " | Ribbon COMPRESSED (trend change imminent)"
if len(ema_fast) >= 6:
prev_vals = [ema_fast.iloc[-5], ema_medium.iloc[-5], ema_slow.iloc[-5]]
prev_width = (max(prev_vals) - min(prev_vals)) / atr_val if atr_val > 0 else 0
if prev_width < 1.0 and ribbon_width >= 1.0:
ribbon_desc = " | Ribbon EXPANDING (new trend starting)"
conf = min(conf + 10, 95)
desc = f"EMA alignment: {alignment_score}/{max_checks}{crossover_desc}{ribbon_desc}"
result.indicators["ema_trend"] = IndicatorSignal(
name="EMA Trend", value=normalized,
signal=signal, confidence=conf,
@@ -499,12 +517,33 @@ class TechnicalEngine:
signal = SignalStrength.SELL if adx_val >= c.adx_strong_trend else SignalStrength.NEUTRAL
direction = "bearish"
desc = f"ADX {adx_val:.1f} ({trend_strength} {direction}) | +DI={plus_di_val:.1f} -DI={minus_di_val:.1f}"
# ADX slope — trend acceleration detection
slope_lookback = self.config.adx_slope_lookback
adx_slope = 0.0
if len(adx) > slope_lookback:
prev_adx = adx.iloc[-slope_lookback - 1]
if not pd.isna(prev_adx):
adx_slope = adx_val - prev_adx
slope_desc = ""
emerging_threshold = self.config.adx_slope_emerging_threshold
if adx_val < self.config.adx_strong_trend and adx_slope > emerging_threshold:
slope_desc = " | TREND EMERGING (ADX rising fast)"
conf = min(conf + 15, 85)
elif adx_val >= self.config.adx_strong_trend and adx_slope > 0:
slope_desc = f" | Trend strengthening (slope +{adx_slope:.1f})"
conf = min(conf + 5, 85)
elif adx_val >= self.config.adx_strong_trend and adx_slope < -emerging_threshold:
slope_desc = f" | Trend weakening (slope {adx_slope:.1f})"
conf = max(conf - 10, 30)
desc = f"ADX {adx_val:.1f} ({trend_strength} {direction}) | +DI={plus_di_val:.1f} -DI={minus_di_val:.1f}{slope_desc}"
result.indicators["adx"] = IndicatorSignal(
name="ADX", value=adx_val, signal=signal,
confidence=conf, description=desc, weight=0.5,
)
result._adx_slope = adx_slope # type: ignore[attr-defined]
def _compute_atr(self, df: pd.DataFrame, result: TechnicalAnalysis):
"""ATR volatility measurement and regime classification."""
@@ -550,6 +589,153 @@ class TechnicalEngine:
weight=0.0, # ATR is context, not a directional signal
)
def _compute_roc(self, df: pd.DataFrame, result: TechnicalAnalysis):
"""Rate of Change — momentum acceleration indicator."""
c = self.config
close = df["Close"]
n = c.roc_period
if len(close) < n + 5:
return
roc_val = ((close.iloc[-1] - close.iloc[-n - 1]) / close.iloc[-n - 1]) * 100
roc_prev = ((close.iloc[-2] - close.iloc[-n - 2]) / close.iloc[-n - 2]) * 100
if pd.isna(roc_val) or pd.isna(roc_prev):
return
roc_rising = roc_val > roc_prev
roc_abs = abs(roc_val)
threshold = c.roc_threshold
if roc_val > 0 and roc_rising:
if roc_abs > threshold * 2:
signal = SignalStrength.STRONG_BUY
conf = min(80, 60 + roc_abs * 5)
desc = f"ROC +{roc_val:.2f}% — Strong momentum acceleration"
else:
signal = SignalStrength.BUY
conf = min(70, 50 + roc_abs * 10)
desc = f"ROC +{roc_val:.2f}% — Rising momentum"
elif roc_val < 0 and not roc_rising:
if roc_abs > threshold * 2:
signal = SignalStrength.STRONG_SELL
conf = min(80, 60 + roc_abs * 5)
desc = f"ROC {roc_val:.2f}% — Strong bearish momentum"
else:
signal = SignalStrength.SELL
conf = min(70, 50 + roc_abs * 10)
desc = f"ROC {roc_val:.2f}% — Falling momentum"
elif roc_prev <= 0 < roc_val:
signal = SignalStrength.BUY
conf = 65
desc = f"ROC crossed above zero (+{roc_val:.2f}%)"
elif roc_prev >= 0 > roc_val:
signal = SignalStrength.SELL
conf = 65
desc = f"ROC crossed below zero ({roc_val:.2f}%)"
else:
signal = SignalStrength.NEUTRAL
conf = 30
desc = f"ROC {roc_val:+.2f}% — No clear momentum"
result.indicators["roc"] = IndicatorSignal(
name="ROC", value=roc_val, signal=signal,
confidence=conf, description=desc, weight=1.5,
)
def _compute_keltner(self, df: pd.DataFrame, result: TechnicalAnalysis):
"""Keltner Channels + Bollinger-Keltner squeeze detection (TTM Squeeze)."""
c = self.config
close = df["Close"]
high, low = df["High"], df["Low"]
if len(close) < max(c.kc_period, c.kc_atr_period) + 5:
return
kc_mid = close.ewm(span=c.kc_period, adjust=False).mean()
tr1 = high - low
tr2 = (high - close.shift()).abs()
tr3 = (low - close.shift()).abs()
tr = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1)
kc_atr = tr.ewm(alpha=1/c.kc_atr_period, min_periods=c.kc_atr_period, adjust=False).mean()
kc_upper = kc_mid + c.kc_multiplier * kc_atr
kc_lower = kc_mid - c.kc_multiplier * kc_atr
latest = close.iloc[-1]
kc_upper_val = kc_upper.iloc[-1]
kc_lower_val = kc_lower.iloc[-1]
kc_mid_val = kc_mid.iloc[-1]
if pd.isna(kc_upper_val) or pd.isna(kc_lower_val):
return
bb_upper = result.key_levels.get("bb_upper")
bb_lower = result.key_levels.get("bb_lower")
is_squeeze = False
squeeze_released = False
if bb_upper is not None and bb_lower is not None:
is_squeeze = bb_lower > kc_lower_val and bb_upper < kc_upper_val
if len(close) >= 3:
bb_sma = close.rolling(c.bb_period).mean()
bb_std = close.rolling(c.bb_period).std(ddof=0)
if len(bb_sma) >= 2 and not pd.isna(bb_sma.iloc[-2]):
prev_bb_upper = bb_sma.iloc[-2] + c.bb_std * bb_std.iloc[-2]
prev_bb_lower = bb_sma.iloc[-2] - c.bb_std * bb_std.iloc[-2]
prev_kc_upper = kc_upper.iloc[-2]
prev_kc_lower = kc_lower.iloc[-2]
was_squeeze = prev_bb_lower > prev_kc_lower and prev_bb_upper < prev_kc_upper
squeeze_released = was_squeeze and not is_squeeze
macd_hist = getattr(result, "_macd_histogram", None)
macd_direction = 0
if macd_hist is not None and len(macd_hist) >= 1 and not pd.isna(macd_hist.iloc[-1]):
macd_direction = 1 if macd_hist.iloc[-1] > 0 else -1
if squeeze_released:
if macd_direction > 0:
signal = SignalStrength.STRONG_BUY
desc = "Keltner SQUEEZE RELEASED — bullish breakout"
elif macd_direction < 0:
signal = SignalStrength.STRONG_SELL
desc = "Keltner SQUEEZE RELEASED — bearish breakout"
else:
signal = SignalStrength.NEUTRAL
desc = "Keltner squeeze released — direction unclear"
conf = 80
elif is_squeeze:
signal = SignalStrength.NEUTRAL
conf = 60
desc = "Keltner SQUEEZE active — breakout imminent"
elif latest > kc_upper_val:
signal = SignalStrength.BUY
conf = 65
desc = f"Price above Keltner upper — strong uptrend continuation"
elif latest < kc_lower_val:
signal = SignalStrength.SELL
conf = 65
desc = f"Price below Keltner lower — strong downtrend continuation"
else:
signal = SignalStrength.NEUTRAL
conf = 30
desc = "Price within Keltner Channels"
result.indicators["keltner"] = IndicatorSignal(
name="Keltner", value=float(latest - kc_mid_val),
signal=signal, confidence=conf,
description=desc, weight=1.5,
)
result.key_levels["kc_upper"] = float(kc_upper_val)
result.key_levels["kc_lower"] = float(kc_lower_val)
result.key_levels["kc_mid"] = float(kc_mid_val)
result._kc_squeeze = is_squeeze # type: ignore[attr-defined]
result._kc_squeeze_released = squeeze_released # type: ignore[attr-defined]
def _detect_divergences(self, df: pd.DataFrame, result: TechnicalAnalysis):
"""
Detect RSI and MACD divergences (powerful reversal signals).
@@ -818,9 +1004,10 @@ class TechnicalEngine:
)
def _classify_regime(self, df: pd.DataFrame, result: TechnicalAnalysis):
"""Classify the market regime (trending vs ranging)."""
"""Classify the market regime (trending vs ranging vs transitioning)."""
adx_sig = result.indicators.get("adx")
ema_sig = result.indicators.get("ema_trend")
adx_slope = getattr(result, "_adx_slope", 0.0)
if adx_sig and adx_sig.value >= self.config.adx_strong_trend:
if ema_sig and ema_sig.signal in (SignalStrength.BUY, SignalStrength.STRONG_BUY):
@@ -829,6 +1016,8 @@ class TechnicalEngine:
result.market_regime = "trending_down"
else:
result.market_regime = "trending"
elif adx_sig and adx_slope > self.config.adx_slope_emerging_threshold:
result.market_regime = "transitioning"
else:
result.market_regime = "ranging"
@@ -844,21 +1033,28 @@ class TechnicalEngine:
total_weight = 0.0
total_confidence = 0.0
# Use override weights if provided, else defaults
# Use override weights if provided, else gold-optimized defaults
if self.config.regime_weights_override:
regime_weights = self.config.regime_weights_override
else:
_trending = {
"ema_trend": 2.0, "rsi": 1.5, "bollinger": 0.8,
"candlestick": 1.0, "divergence": 1.5,
"ema_trend": 2.0, "rsi": 1.0, "macd": 2.0,
"bollinger": 0.5, "keltner": 1.5, "roc": 1.5,
"candlestick": 0.8, "divergence": 2.0, "adx": 0.8,
}
regime_weights = {
"trending_up": _trending,
"trending_down": _trending,
"trending": _trending,
"ranging": {
"ema_trend": 0.5, "rsi": 2.0, "bollinger": 2.0,
"candlestick": 1.0, "divergence": 2.0,
"ema_trend": 0.3, "rsi": 1.5, "macd": 1.5,
"bollinger": 1.5, "keltner": 2.0, "roc": 0.5,
"candlestick": 1.0, "divergence": 2.5, "adx": 0.5,
},
"transitioning": {
"ema_trend": 1.5, "rsi": 1.0, "macd": 2.5,
"bollinger": 1.0, "keltner": 2.5, "roc": 2.0,
"candlestick": 1.0, "divergence": 2.0, "adx": 1.0,
},
}
@@ -888,13 +1084,13 @@ class TechnicalEngine:
normalized_score = max(-100, min(100, normalized_score))
if normalized_score >= 45:
if normalized_score >= 40:
result.composite_signal = SignalStrength.STRONG_BUY
elif normalized_score >= 20:
elif normalized_score >= 15:
result.composite_signal = SignalStrength.BUY
elif normalized_score <= -45:
elif normalized_score <= -40:
result.composite_signal = SignalStrength.STRONG_SELL
elif normalized_score <= -20:
elif normalized_score <= -15:
result.composite_signal = SignalStrength.SELL
else:
result.composite_signal = SignalStrength.NEUTRAL