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.
This commit is contained in:
harwinder
2026-05-15 10:51:51 -04:00
parent bb3c475f22
commit 04328eaeab
15 changed files with 852 additions and 2108 deletions
+161 -569
View File
@@ -176,21 +176,10 @@ class SignalAggregator:
if bt.session_filter:
xau_active = (
report.display_name == "XAU/USD"
and self.config.xau_strategy.enabled
self.config.xau_strategy.enabled
and any("XAU STRATEGY" in r for r in report.bullish_reasons)
)
eur_active = (
report.display_name == "EUR/USD"
and self.config.eur_strategy.enabled
and any("EUR STRATEGY" in r for r in report.bullish_reasons)
)
gbp_active = (
report.display_name == "GBP/USD"
and self.config.gbp_strategy.enabled
and any("GBP STRATEGY" in r for r in report.bullish_reasons)
)
if not xau_active and not eur_active and not gbp_active:
if not xau_active:
self._apply_session_filter_live(report)
return report
@@ -398,182 +387,38 @@ class SignalAggregator:
except Exception:
pass
# ── 6c. XAU/USD oversold bounce strategy (DISABLED — replaced by v2 confluence) ──
# if report.display_name == "XAU/USD" and self.config.xau_strategy.enabled:
# if self._check_xau_oversold_bounce(report, dfs):
# report.final_signal = SignalStrength.STRONG_BUY
# report.final_score = 50.0
# report.final_confidence = 85.0
# report.signal_quality = "A+"
# report.action_recommendation = (
# "STRONG BUY — XAU oversold bounce "
# "(RSI<30 + M15/H1/H4 all above EMA200)"
# )
# report.bullish_reasons.insert(
# 0,
# "XAU STRATEGY: M15 RSI oversold + triple-TF bullish trend confirmation",
# )
# report.trade_setup = self._generate_xau_trade_setup(report, dfs)
# ── 6d. EUR/USD pullback-buy strategy ─────────────────────
if report.display_name == "EUR/USD" and self.config.eur_strategy.enabled:
if self._check_eur_pullback_buy(report, dfs):
report.final_signal = SignalStrength.BUY
report.final_score = 40.0
report.final_confidence = 75.0
# ── 6c. Transitioning regime quality boost ──────────────────
if report.market_regime == "transitioning" and report.signal_quality in ("B", "C", "D"):
abs_score = abs(report.final_score)
if abs_score >= 30 and report.final_confidence >= 50 and report.mtf_agreement:
report.signal_quality = "A"
report.action_recommendation = (
"BUY — EUR pullback entry "
"(RSI pullback + not at resistance + M15/H1 above EMA200)"
)
report.bullish_reasons.insert(
0,
"EUR STRATEGY: M15 RSI pullback + BB mid-band + dual-TF EMA200 confirmation",
)
report.trade_setup = self._generate_eur_trade_setup(report, dfs)
report.action_recommendation = f"{report.final_signal.name.replace('_', ' ')} — Trend emerging, early entry"
elif abs_score >= 25 and report.final_confidence >= 45:
report.signal_quality = "B"
report.action_recommendation = f"{report.final_signal.name.replace('_', ' ')} — Trend forming, watch closely"
# ── 6e. GBP/USD pullback-buy strategy ─────────────────────
if report.display_name == "GBP/USD" and self.config.gbp_strategy.enabled:
if self._check_gbp_pullback_buy(report, dfs):
report.final_signal = SignalStrength.BUY
report.final_score = 40.0
report.final_confidence = 75.0
report.signal_quality = "A"
report.action_recommendation = (
"BUY — GBP pullback entry "
"(RSI pullback + not at resistance + M15/H1 above EMA200)"
)
report.bullish_reasons.insert(
0,
"GBP STRATEGY: M15 RSI pullback + BB mid-band + dual-TF EMA200 confirmation",
)
report.trade_setup = self._generate_gbp_trade_setup(report, dfs)
# ── 6d. XAU/USD BUY threshold boost (Fix 5) ──────────────
if (report.display_name == "XAU/USD"
and self.config.xau_strategy.buy_threshold_boost > 0
and report.final_signal in (SignalStrength.BUY, SignalStrength.STRONG_BUY)
and report.signal_quality in ("B", "C")):
report.warnings.append(
f"XAU BUY FILTER: BUY requires higher confidence (quality {report.signal_quality} insufficient)"
)
report.signal_quality = "D"
report.trade_setup = None
# ── 6f. XAU/USD H4 RSI + H1 MACD directional gates ──────
if (report.display_name == "XAU/USD"
and self.config.xau_strategy.enabled
and report.signal_quality in ("A+", "A", "B", "C")
and report.final_signal != SignalStrength.NEUTRAL):
xau_cfg = self.config.xau_strategy
is_buy = report.final_signal in (SignalStrength.BUY, SignalStrength.STRONG_BUY)
if xau_cfg.h4_rsi_gate and Timeframe.H4 in dfs:
h4_close = dfs[Timeframe.H4]["Close"]
delta = h4_close.diff()
gain = delta.clip(lower=0).ewm(alpha=1/14, min_periods=14, adjust=False).mean()
loss = (-delta.clip(upper=0)).ewm(alpha=1/14, min_periods=14, adjust=False).mean()
h4_rsi = 100 - (100 / (1 + gain / loss))
if len(h4_rsi) > 0:
h4_rsi_val = float(h4_rsi.iloc[-1])
if is_buy and h4_rsi_val <= 55:
report.warnings.append(
f"H4 RSI GATE: RSI {h4_rsi_val:.0f} ≤55 — no BUY confirmation"
)
report.signal_quality = "D"
report.trade_setup = None
elif not is_buy and h4_rsi_val >= 45:
report.warnings.append(
f"H4 RSI GATE: RSI {h4_rsi_val:.0f} ≥45 — no SELL confirmation"
)
report.signal_quality = "D"
report.trade_setup = None
if (xau_cfg.h1_macd_gate
and Timeframe.H1 in dfs
and report.signal_quality in ("A+", "A", "B", "C")):
h1_df = dfs[Timeframe.H1]
h1_close = h1_df["Close"]
h1_ema_fast = h1_close.ewm(span=12, adjust=False).mean()
h1_ema_slow = h1_close.ewm(span=26, adjust=False).mean()
h1_macd_line = h1_ema_fast - h1_ema_slow
h1_signal_line = h1_macd_line.ewm(span=9, adjust=False).mean()
h1_macd_hist = h1_macd_line - h1_signal_line
if len(h1_macd_hist) > 0:
h1_hist_val = float(h1_macd_hist.iloc[-1])
if is_buy and h1_hist_val <= 0:
report.warnings.append(
f"H1 MACD GATE: histogram {h1_hist_val:.2f} ≤0 — no BUY confirmation"
)
report.signal_quality = "D"
report.trade_setup = None
elif not is_buy and h1_hist_val >= 0:
report.warnings.append(
f"H1 MACD GATE: histogram {h1_hist_val:.2f} ≥0 — no SELL confirmation"
)
report.signal_quality = "D"
report.trade_setup = None
# ── RSI zone gate ──────────────────────────────────────
if (xau_cfg.rsi_zone
and report.signal_quality in ("A+", "A", "B", "C")):
m15_analysis = report.primary_analysis
if m15_analysis and hasattr(m15_analysis, '_rsi_series'):
rsi_val = float(m15_analysis._rsi_series.iloc[-1])
lo, hi = xau_cfg.rsi_zone
if not (lo <= rsi_val <= hi):
report.warnings.append(
f"RSI ZONE GATE: RSI {rsi_val:.0f} outside [{lo:.0f}-{hi:.0f}]"
)
report.signal_quality = "D"
report.trade_setup = None
# ── 6g. XAU/USD SELL quality gate ─────────────────────────
# ── 6d. SELL quality gate ─────────────────────────────────
xau_is_sell_dir = (
report.final_signal in (SignalStrength.SELL, SignalStrength.STRONG_SELL)
or (report.final_signal == SignalStrength.NEUTRAL and report.final_score < 0)
)
if (report.display_name == "XAU/USD"
and self.config.xau_strategy.enabled
if (self.config.xau_strategy.enabled
and xau_is_sell_dir
and report.signal_quality not in self.config.xau_strategy.sell_min_quality):
report.warnings.append(
f"XAU SELL FILTER: quality {report.signal_quality} below minimum for SELL"
f"SELL FILTER: quality {report.signal_quality} below minimum for SELL"
)
report.signal_quality = "D"
report.trade_setup = None
# ── 7. Trade setup for actionable signals ──────────────────
# Pair-specific generic-trade suppression: when a pair has a dedicated
# strategy and that strategy didn't fire, falling through to a generic
# swing-based setup tends to chase momentum at unfavorable spots.
# 90-day EUR M15 backtest: allow=True → +4.7R, allow=False → +9.5R.
suppress_generic = (
(report.display_name == "XAU/USD"
and not self.config.xau_strategy.allow_generic_trades)
or (report.display_name == "EUR/USD"
and self.config.eur_strategy.enabled
and not self.config.eur_strategy.allow_generic_trades)
or (report.display_name == "GBP/USD"
and self.config.gbp_strategy.enabled
and not self.config.gbp_strategy.allow_generic_trades)
)
if report.trade_setup is None and suppress_generic:
pass # generic trade setup suppressed for this pair
elif report.trade_setup is None and report.signal_quality in ("A+", "A", "B", "C"):
is_sell_direction = (
report.final_signal in (SignalStrength.SELL, SignalStrength.STRONG_SELL)
or (report.final_signal == SignalStrength.NEUTRAL and report.final_score < 0)
)
if is_sell_direction and self.config.backtest.sell_filter:
if report.display_name in self.config.backtest.sell_allowed_pairs:
report.trade_setup = self._generate_trade_setup(report, pip_value)
elif self._forex_sell_qualifies(report):
report.trade_setup = self._generate_trade_setup(report, pip_value)
else:
report.trade_setup = None
else:
report.trade_setup = self._generate_trade_setup(report, pip_value)
if report.trade_setup is None and report.signal_quality in ("A+", "A"):
atr = (report.primary_analysis.key_levels.get("atr", 0)
if report.primary_analysis else 0)
if atr > 0:
report.trade_setup = self._generate_xau_atr_trade_setup(report, atr)
if report.trade_setup is None and report.signal_quality in ("A+", "A", "B", "C"):
report.trade_setup = self._generate_trade_setup(report, pip_value)
# ── 8. Reasons ─────────────────────────────────────────────
self._collect_reasons(report)
@@ -582,21 +427,10 @@ class SignalAggregator:
# ── Internals ──────────────────────────────────────────────────
def _forex_sell_qualifies(self, report: SignalReport) -> bool:
"""Always returns False — forex SELL is structurally unprofitable.
0/4 trades won even under best conditions (trending_down + MTF + ADX>=25 + RSI<=50)."""
return False
def _check_xau_oversold_bounce(
self, report: SignalReport, dfs: Dict[Timeframe, pd.DataFrame]
) -> bool:
"""Return True when XAU/USD oversold-bounce conditions are met.
Conditions:
1. M15 RSI(14) < rsi_entry_threshold
2. Price > EMA(200) on at least min_ema200_tfs of {M15, H1, H4}
3. Current bar hour not in blocked list
"""
"""Return True when XAU/USD oversold-bounce conditions are met."""
cfg = self.config.xau_strategy
try:
@@ -671,9 +505,11 @@ class SignalAggregator:
price = report.current_price
pip_value = 0.10
tp1 = price + cfg.tp_atr_multiplier * atr
quality_scale = self._TP_QUALITY_SCALE.get(report.signal_quality, 0.75)
tp_mult = cfg.tp_atr_multiplier * quality_scale
tp1 = price + tp_mult * atr
sl = price - cfg.sl_atr_multiplier * atr
tp2 = price + cfg.tp_atr_multiplier * 2.0 * atr
tp2 = price + tp_mult * 1.5 * atr
sl_distance = price - sl
tp_distance = tp1 - price
@@ -693,10 +529,12 @@ class SignalAggregator:
pip_value=pip_value,
)
_TP_QUALITY_SCALE = {"A+": 2.0, "A": 1.5, "B": 1.0, "C": 1.0}
def _generate_xau_atr_trade_setup(
self, report: SignalReport, atr: float
) -> Optional[TradeSetup]:
"""XAU-aware exits for generic signals: wide SL, tight TP via ATR."""
"""XAU ATR-based trade setup with quality-tiered TP scaling."""
cfg = self.config.xau_strategy
price = report.current_price
pip_value = 0.10
@@ -708,21 +546,28 @@ class SignalAggregator:
else:
is_buy = report.final_score >= 0
quality_scale = self._TP_QUALITY_SCALE.get(report.signal_quality, 0.75)
tp_mult = cfg.tp_atr_multiplier * quality_scale
if is_buy:
sl = price - cfg.sl_atr_multiplier * atr
tp1 = price + cfg.tp_atr_multiplier * atr
tp2 = price + cfg.tp_atr_multiplier * 2.0 * atr
tp1 = price + tp_mult * atr
tp2 = price + tp_mult * 1.5 * atr
direction = "BUY"
else:
sl = price + cfg.sl_atr_multiplier * atr
tp1 = price - cfg.tp_atr_multiplier * atr
tp2 = price - cfg.tp_atr_multiplier * 2.0 * atr
tp1 = price - tp_mult * atr
tp2 = price - tp_mult * 1.5 * atr
direction = "SELL"
sl_distance = abs(price - sl)
tp_distance = abs(tp1 - price)
rr = tp_distance / sl_distance if sl_distance > 0 else 0
min_rr = self.config.signals.min_rr_ratio
if rr < min_rr - 1e-9:
return None
return TradeSetup(
pair=report.symbol,
display_name=report.display_name,
@@ -737,277 +582,6 @@ class SignalAggregator:
pip_value=pip_value,
)
def _check_eur_pullback_buy(
self, report: SignalReport, dfs: Dict[Timeframe, pd.DataFrame]
) -> bool:
"""Return True when EUR/USD pullback-buy conditions are met.
Conditions (all must be true):
0. Bar's weekday is in the universal allowed_weekdays list
1. Current bar hour not in blocked list
2. M15 RSI(14) in 30–55 (pullback, not overbought)
3. M15 BB position < 0.75 (not at resistance)
4. M15 price > EMA200 (uptrend)
5. H1 price > EMA200 (higher-TF confirmation)
6. Price within 1.5×ATR of EMA21 (not chasing)
"""
cfg = self.config.eur_strategy
try:
bar_ts = pd.Timestamp(report.timestamp)
bar_hour = bar_ts.hour
bar_wd = bar_ts.weekday()
if bar_hour in cfg.blocked_hours_utc:
return False
# Universal day filter — must be respected here because the
# strategy hook overwrites the section-6b filter's quality reset.
allowed_wd = self.config.backtest.allowed_weekdays
if allowed_wd and bar_wd not in allowed_wd:
return False
except Exception:
return False
m15_analysis = None
h1_analysis = None
if report.primary_analysis:
tf = report.primary_analysis.timeframe
if tf == Timeframe.M15.value:
m15_analysis = report.primary_analysis
elif tf == Timeframe.H1.value:
h1_analysis = report.primary_analysis
for ca in report.confirmation_analyses:
if ca.timeframe == Timeframe.M15.value:
m15_analysis = ca
elif ca.timeframe == Timeframe.H1.value:
h1_analysis = ca
if not m15_analysis or not h1_analysis:
return False
m15_rsi = m15_analysis.indicators.get("rsi")
if not m15_rsi or m15_rsi.value < cfg.rsi_lower or m15_rsi.value > cfg.rsi_upper:
return False
m15_bb = m15_analysis.indicators.get("bollinger")
if m15_bb and m15_bb.value > cfg.bb_max_position:
return False
m15_ema200 = m15_analysis.key_levels.get("ema_trend")
m15_df = dfs.get(Timeframe.M15)
if m15_df is None or m15_ema200 is None:
return False
m15_price = float(m15_df["Close"].iloc[-1])
if m15_price <= m15_ema200:
return False
h1_ema200 = h1_analysis.key_levels.get("ema_trend")
h1_df = dfs.get(Timeframe.H1)
if h1_ema200 is None:
return False
h1_price = float(h1_df["Close"].iloc[-1]) if h1_df is not None else report.current_price
if h1_price <= h1_ema200:
return False
m15_atr = m15_analysis.key_levels.get("atr", 0)
m15_ema21 = m15_analysis.key_levels.get("ema_medium")
if m15_atr > 0 and m15_ema21:
dist = abs(m15_price - m15_ema21) / m15_atr
if dist > cfg.chase_max_atr_from_ema21:
return False
# Minimum indicator confluence — rejects gate-passes with weak
# technical agreement. The pullback gate alone fires Quality=A
# even when M15 indicators are mostly neutral; this enforces
# actual confluence.
if cfg.min_indicators_agree > 0:
agreeing = sum(
1
for name in ("ema_trend", "rsi", "bollinger", "adx", "candlestick")
if (ind := m15_analysis.indicators.get(name)) is not None
and ind.signal in (SignalStrength.BUY, SignalStrength.STRONG_BUY)
)
if agreeing < cfg.min_indicators_agree:
return False
return True
def _generate_eur_trade_setup(
self, report: SignalReport, dfs: Dict[Timeframe, pd.DataFrame]
) -> Optional[TradeSetup]:
"""Generate EUR/USD-specific trade setup with tight TP / wide SL."""
cfg = self.config.eur_strategy
m15_analysis = None
if report.primary_analysis and report.primary_analysis.timeframe == Timeframe.M15.value:
m15_analysis = report.primary_analysis
else:
for ca in report.confirmation_analyses:
if ca.timeframe == Timeframe.M15.value:
m15_analysis = ca
break
if not m15_analysis:
return None
atr = m15_analysis.key_levels.get("atr", 0)
if atr <= 0:
return None
price = report.current_price
pip_value = 0.0001
tp1 = price + cfg.tp_atr_multiplier * atr
sl = price - cfg.sl_atr_multiplier * atr
tp2 = price + cfg.tp_atr_multiplier * 2.0 * atr
sl_distance = price - sl
tp_distance = tp1 - price
rr = tp_distance / sl_distance if sl_distance > 0 else 0
return TradeSetup(
pair=report.symbol,
display_name=report.display_name,
direction="BUY",
entry_price=price,
stop_loss=round(sl, 5),
take_profit_1=round(tp1, 5),
take_profit_2=round(tp2 , 5),
risk_reward=round(rr, 2),
risk_pips=round(sl_distance / pip_value, 1),
reward_pips=round(tp_distance / pip_value, 1),
pip_value=pip_value,
)
def _check_gbp_pullback_buy(
self, report: SignalReport, dfs: Dict[Timeframe, pd.DataFrame]
) -> bool:
"""GBP/USD pullback-buy — same logic as EUR, different config."""
cfg = self.config.gbp_strategy
try:
bar_ts = pd.Timestamp(report.timestamp)
bar_hour = bar_ts.hour
bar_wd = bar_ts.weekday()
if bar_hour in cfg.blocked_hours_utc:
return False
allowed_wd = self.config.backtest.allowed_weekdays
if allowed_wd and bar_wd not in allowed_wd:
return False
except Exception:
return False
m15_analysis = None
h1_analysis = None
if report.primary_analysis:
tf = report.primary_analysis.timeframe
if tf == Timeframe.M15.value:
m15_analysis = report.primary_analysis
elif tf == Timeframe.H1.value:
h1_analysis = report.primary_analysis
for ca in report.confirmation_analyses:
if ca.timeframe == Timeframe.M15.value:
m15_analysis = ca
elif ca.timeframe == Timeframe.H1.value:
h1_analysis = ca
if not m15_analysis or not h1_analysis:
return False
m15_rsi = m15_analysis.indicators.get("rsi")
if not m15_rsi or m15_rsi.value < cfg.rsi_lower or m15_rsi.value > cfg.rsi_upper:
return False
m15_bb = m15_analysis.indicators.get("bollinger")
if m15_bb and (m15_bb.value < cfg.bb_min_position or m15_bb.value > cfg.bb_max_position):
return False
m15_ema200 = m15_analysis.key_levels.get("ema_trend")
m15_df = dfs.get(Timeframe.M15)
if m15_df is None or m15_ema200 is None:
return False
m15_price = float(m15_df["Close"].iloc[-1])
if m15_price <= m15_ema200:
return False
h1_ema200 = h1_analysis.key_levels.get("ema_trend")
h1_df = dfs.get(Timeframe.H1)
if h1_ema200 is None:
return False
h1_price = float(h1_df["Close"].iloc[-1]) if h1_df is not None else report.current_price
if h1_price <= h1_ema200:
return False
m15_atr = m15_analysis.key_levels.get("atr", 0)
m15_ema21 = m15_analysis.key_levels.get("ema_medium")
if m15_atr > 0 and m15_ema21:
dist = abs(m15_price - m15_ema21) / m15_atr
if dist > cfg.chase_max_atr_from_ema21:
return False
# See EUR equivalent — enforce minimum indicator confluence.
if cfg.min_indicators_agree > 0:
agreeing = sum(
1
for name in ("ema_trend", "rsi", "bollinger", "adx", "candlestick")
if (ind := m15_analysis.indicators.get(name)) is not None
and ind.signal in (SignalStrength.BUY, SignalStrength.STRONG_BUY)
)
if agreeing < cfg.min_indicators_agree:
return False
return True
def _generate_gbp_trade_setup(
self, report: SignalReport, dfs: Dict[Timeframe, pd.DataFrame]
) -> Optional[TradeSetup]:
"""Generate GBP/USD trade setup with tight TP / balanced SL."""
cfg = self.config.gbp_strategy
m15_analysis = None
if report.primary_analysis and report.primary_analysis.timeframe == Timeframe.M15.value:
m15_analysis = report.primary_analysis
else:
for ca in report.confirmation_analyses:
if ca.timeframe == Timeframe.M15.value:
m15_analysis = ca
break
if not m15_analysis:
return None
atr = m15_analysis.key_levels.get("atr", 0)
if atr <= 0:
return None
price = report.current_price
pip_value = 0.0001
tp1 = price + cfg.tp_atr_multiplier * atr
sl = price - cfg.sl_atr_multiplier * atr
tp2 = price + cfg.tp_atr_multiplier * 2.0 * atr
sl_distance = price - sl
tp_distance = tp1 - price
rr = tp_distance / sl_distance if sl_distance > 0 else 0
return TradeSetup(
pair=report.symbol,
display_name=report.display_name,
direction="BUY",
entry_price=price,
stop_loss=round(sl, 5),
take_profit_1=round(tp1, 5),
take_profit_2=round(tp2, 5),
risk_reward=round(rr, 2),
risk_pips=round(sl_distance / pip_value, 1),
reward_pips=round(tp_distance / pip_value, 1),
pip_value=pip_value,
)
def _check_mtf_agreement(self, report: SignalReport) -> bool:
signals = list(report.mtf_signals.values())
if len(signals) < 2:
@@ -1023,154 +597,184 @@ class SignalAggregator:
score = report.primary_score
confidence = report.primary_confidence
# ── Bonuses (additive, applied first) ─────────────────────
if report.mtf_agreement:
confidence += 10
all_scores = list(report.mtf_scores.values())
if all_scores:
mtf_avg = sum(all_scores) / len(all_scores)
score = score * 0.6 + mtf_avg * 0.4
else:
confidence -= 15
if len(report.mtf_scores) >= 2:
scores = list(report.mtf_scores.values())
has_bull = any(s > 10 for s in scores)
has_bear = any(s < -10 for s in scores)
if has_bull and has_bear:
report.warnings.append("Timeframes CONFLICT — mixed signals across timeframes")
if abs(report.news_sentiment) > 0.2:
if (report.news_sentiment > 0 and score > 0) or \
(report.news_sentiment < 0 and score < 0):
confidence += 5
elif (report.news_sentiment > 0 and score < -10) or \
(report.news_sentiment < 0 and score > 10):
report.warnings.append("News sentiment CONFLICTS with technical signal")
confidence -= 10
primary = report.primary_analysis
if primary and report.market_regime.startswith("trending"):
rsi_ind = primary.indicators.get("rsi")
bb_ind = primary.indicators.get("bollinger")
if rsi_ind:
rsi_val = rsi_ind.value
if report.market_regime in ("trending_up", "trending") and score > 0 and 38 <= rsi_val <= 55:
confidence += 5
elif report.market_regime == "trending_down" and score < 0 and 45 <= rsi_val <= 62:
confidence += 5
if bb_ind:
bb_pos = bb_ind.value
if report.market_regime in ("trending_up", "trending") and score > 0 and bb_pos < 0.4:
confidence += 5
elif report.market_regime == "trending_down" and score < 0 and bb_pos > 0.6:
confidence += 5
# ── Event risk (hard cap, separate from penalty system) ───
if report.event_risk == "danger":
report.warnings.append(f"EVENT RISK: {report.event_description}")
confidence = min(confidence, 30)
report.action_recommendation = f"WAIT — {report.event_description}"
elif report.event_risk == "caution":
# ── Penalties (diminishing cap system) ────────────────────
# Collect all applicable penalties, then apply with exponential
# decay so stacking never guts a signal beyond 30%.
penalties = []
if not report.mtf_agreement:
penalties.append(("mtf_disagreement", 15))
if len(report.mtf_scores) >= 2:
scores_list = list(report.mtf_scores.values())
if any(s > 10 for s in scores_list) and any(s < -10 for s in scores_list):
report.warnings.append("Timeframes CONFLICT — mixed signals across timeframes")
if abs(report.news_sentiment) > 0.2:
if (report.news_sentiment > 0 and score < -10) or \
(report.news_sentiment < 0 and score > 10):
report.warnings.append("News sentiment CONFLICTS with technical signal")
penalties.append(("news_conflict", 10))
if report.event_risk == "caution":
report.warnings.append(f"Upcoming event: {report.event_description}")
confidence -= 15
penalties.append(("event_caution", 15))
is_counter_trend = (
(report.market_regime == "trending_up" and score < 0) or
(report.market_regime == "trending_down" and score > 0)
)
if is_counter_trend:
confidence -= 15
report.warnings.append("Counter-trend signal — reduced confidence")
penalties.append(("counter_trend", 15))
if report.volatility_regime == "extreme":
report.warnings.append("EXTREME volatility — wider stops needed, reduced position size")
confidence -= 10
report.warnings.append("EXTREME volatility — wider stops needed")
penalties.append(("extreme_vol", 10))
elif report.volatility_regime == "low":
report.warnings.append("Low volatility — potential for breakout, tight ranges")
report.warnings.append("Low volatility — potential for breakout")
is_xau = report.display_name == "XAU/USD"
primary = report.primary_analysis
if primary is not None:
ema200 = primary.key_levels.get("ema_trend")
if ema200 and ema200 > 0 and report.current_price > 0:
ema_dist_pct = abs(report.current_price - ema200) / ema200 * 100.0
_lo = float(os.environ.get("FB2_EMA_LO", "0.5"))
_hi = float(os.environ.get("FB2_EMA_HI", "1.5"))
if ema_dist_pct < _lo:
report.warnings.append(
f"NEAR EMA200 ({ema_dist_pct:.2f}%) — chop risk"
)
confidence -= 3 if is_xau else 10
elif ema_dist_pct > (2.5 if is_xau else _hi):
report.warnings.append(
f"FAR FROM EMA200 ({ema_dist_pct:.2f}%) — over-extended"
)
confidence -= 5 if is_xau else 10
report.warnings.append(f"NEAR EMA200 ({ema_dist_pct:.2f}%) — chop risk")
penalties.append(("ema200_near", 5))
elif ema_dist_pct > 2.5:
report.warnings.append(f"FAR FROM EMA200 ({ema_dist_pct:.2f}%) — over-extended")
penalties.append(("ema200_far", 5))
atr = primary.key_levels.get("atr", 0)
if atr > 0 and score != 0:
is_buy_signal = score > 0
if is_buy_signal:
if score > 0:
swing_lows = sorted(
v for k, v in primary.key_levels.items()
if k.startswith("swing_low_")
v for k, v in primary.key_levels.items() if k.startswith("swing_low_")
)
_exh_mult = 3.5 if is_xau else 2.5
if swing_lows and report.current_price - swing_lows[0] > _exh_mult * atr:
confidence -= 5 if is_xau else 15
report.warnings.append(f"TREND EXHAUSTION — price extended >{_exh_mult} ATR past swing low")
if swing_lows and report.current_price - swing_lows[0] > 3.5 * atr:
report.warnings.append("TREND EXHAUSTION — price extended >3.5 ATR past swing low")
penalties.append(("exhaustion", 8))
else:
swing_highs = sorted(
(v for k, v in primary.key_levels.items()
if k.startswith("swing_high_")),
(v for k, v in primary.key_levels.items() if k.startswith("swing_high_")),
reverse=True,
)
_exh_mult_s = 3.5 if is_xau else 2.5
if swing_highs and swing_highs[0] - report.current_price > _exh_mult_s * atr:
confidence -= 5 if is_xau else 15
report.warnings.append(f"TREND EXHAUSTION — price extended >{_exh_mult_s} ATR past swing high")
if swing_highs and swing_highs[0] - report.current_price > 3.5 * atr:
report.warnings.append("TREND EXHAUSTION — price extended >3.5 ATR past swing high")
penalties.append(("exhaustion", 8))
ema_medium = primary.key_levels.get("ema_medium")
if atr > 0 and ema_medium and report.market_regime.startswith("trending"):
pullback_dist = abs(report.current_price - ema_medium) / atr
_pb_severe = 2.5 if is_xau else 2.0
_pb_mild = 2.0 if is_xau else 1.5
if pullback_dist > _pb_severe:
confidence -= 8 if is_xau else 20
report.warnings.append(
f"NO PULLBACK — price {pullback_dist:.1f} ATR from EMA21, chasing risk"
)
elif pullback_dist > _pb_mild:
confidence -= 5 if is_xau else 10
report.warnings.append(
f"Weak pullback — price {pullback_dist:.1f} ATR from EMA21"
)
if pullback_dist > 2.5:
report.warnings.append(f"NO PULLBACK — price {pullback_dist:.1f} ATR from EMA21")
penalties.append(("no_pullback", 8))
elif pullback_dist > 2.0:
report.warnings.append(f"Weak pullback — price {pullback_dist:.1f} ATR from EMA21")
penalties.append(("weak_pullback", 5))
if report.market_regime.startswith("trending"):
rsi_ind = primary.indicators.get("rsi")
bb_ind = primary.indicators.get("bollinger")
if rsi_ind:
rsi_val = rsi_ind.value
_rsi_pen = 5 if is_xau else 15
if report.market_regime in ("trending_up", "trending") and score > 0:
if rsi_val > 65:
confidence -= _rsi_pen
report.warnings.append(
f"Extended entry — RSI {rsi_val:.0f} in uptrend (prefer pullback to 40-55)"
)
elif 38 <= rsi_val <= 55:
confidence += 5
elif report.market_regime == "trending_down" and score < 0:
if rsi_val < 35:
confidence -= _rsi_pen
report.warnings.append(
f"Extended entry — RSI {rsi_val:.0f} in downtrend (prefer bounce to 45-62)"
)
elif 45 <= rsi_val <= 62:
confidence += 5
if report.market_regime in ("trending_up", "trending") and score > 0 and rsi_val > 65:
report.warnings.append(f"Extended — RSI {rsi_val:.0f} in uptrend")
penalties.append(("rsi_extended", 5))
elif report.market_regime == "trending_down" and score < 0 and rsi_val < 35:
report.warnings.append(f"Extended — RSI {rsi_val:.0f} in downtrend")
penalties.append(("rsi_extended", 5))
if bb_ind:
bb_pos = bb_ind.value
if report.market_regime in ("trending_up", "trending") and score > 0:
if bb_pos > 0.85:
confidence -= 10
elif bb_pos < 0.4:
confidence += 5
elif report.market_regime == "trending_down" and score < 0:
if bb_pos < 0.15:
confidence -= 10
elif bb_pos > 0.6:
confidence += 5
if report.market_regime in ("trending_up", "trending") and score > 0 and bb_pos > 0.85:
penalties.append(("bb_overextended", 8))
elif report.market_regime == "trending_down" and score < 0 and bb_pos < 0.15:
penalties.append(("bb_overextended", 8))
# Hard block: static exhaustion + fading momentum → kill the trade
# Static signals alone are normal in gold trends. Only block when
# price is extended AND momentum indicators confirm deceleration.
_EXHAUSTION_TAGS = {"exhaustion", "no_pullback", "rsi_extended", "bb_overextended"}
exhaustion_count = sum(1 for name, _ in penalties if name in _EXHAUSTION_TAGS)
momentum_fading = False
if primary is not None and exhaustion_count >= 2:
roc_ind = primary.indicators.get("roc")
macd_ind = primary.indicators.get("macd")
roc_fading = False
macd_fading = False
if roc_ind:
if score > 0 and roc_ind.value < 0:
roc_fading = True
elif score < 0 and roc_ind.value > 0:
roc_fading = True
if macd_ind:
if score > 0 and macd_ind.signal in (SignalStrength.SELL, SignalStrength.STRONG_SELL):
macd_fading = True
elif score < 0 and macd_ind.signal in (SignalStrength.BUY, SignalStrength.STRONG_BUY):
macd_fading = True
momentum_fading = roc_fading or macd_fading
exhaustion_blocked = exhaustion_count >= 2 and momentum_fading
# Apply penalties with diminishing returns (cap at 30%)
MAX_PENALTY = 30.0
penalties.sort(key=lambda x: x[1], reverse=True)
total_penalty = 0.0
decay = 1.0
for _name, amount in penalties:
applied = amount * decay
if total_penalty + applied > MAX_PENALTY:
applied = MAX_PENALTY - total_penalty
total_penalty += applied
decay *= 0.5
if total_penalty >= MAX_PENALTY:
break
confidence -= total_penalty
confidence = max(0.0, min(95.0, confidence))
if exhaustion_blocked:
report.warnings.append(
f"EXHAUSTION BLOCK — {exhaustion_count} exhaustion signals fired, trade rejected"
)
confidence = 0.0
# Final signal
if not is_xau and score > 42:
confidence -= 10
report.warnings.append("EXHAUSTION: score > 42 — trend overextended")
if score >= 35 and confidence >= 55:
report.final_signal = SignalStrength.STRONG_BUY
elif score >= 25 and confidence >= 50:
@@ -1185,21 +789,9 @@ class SignalAggregator:
report.final_score = score
report.final_confidence = confidence
# SELL suppression (pair-aware)
sell_suppressed = False
if self.config.backtest.sell_filter and report.final_signal in (
SignalStrength.SELL, SignalStrength.STRONG_SELL
):
if report.display_name not in self.config.backtest.sell_allowed_pairs:
report.final_signal = SignalStrength.NEUTRAL
report.warnings.append(f"SELL SUPPRESSED for {report.display_name}")
sell_suppressed = True
# Quality grade
abs_score = abs(score)
if sell_suppressed:
report.signal_quality = "F"
elif abs_score >= 40 and confidence >= 65 and report.mtf_agreement:
if abs_score >= 40 and confidence >= 65 and report.mtf_agreement:
report.signal_quality = "A+"
elif abs_score >= 33 and confidence >= 55 and report.mtf_agreement:
report.signal_quality = "A"
@@ -1252,8 +844,8 @@ class SignalAggregator:
min_rr = self.config.signals.min_rr_ratio
mature_move = abs(report.final_score) > 30
tp_mult = 0.9 if mature_move else 1.2
tp_swing_cap = 1.5 if mature_move else 2.0
tp_mult = 1.0 if mature_move else 1.5
tp_swing_cap = 2.0 if mature_move else 2.5
# Fix 3: Wider SL multiplier for XAU/USD (1.8x vs default 1.5x)
sl_atr_floor = (self.config.xau_strategy.v2_sl_atr_multiplier