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