Fix M5 signal quality: ATR ordering bug, noise gates, blocked-hour leak (+41R)

ATR was computed after EMAs so ribbon compression detection was dead code
on all timeframes — moved ATR first. Extended M5 swing S/R lookback from
50 to 120 bars. Added four M5-specific filters: min score, MTF agreement,
confirmation TF conviction, and SELL quality cap (A-grade M5 shorts enter
at dip extremes and lose). Fixed backtester blocked-hour check to use bar
close time matching the live bot. Added trade geometry config (spread gate,
SL floor, TP floor RR) and primary_tf mismatch guard in backtester.

Backtest: 1133 trades/+11.5R → 419 trades/+41.0R, PF 1.03→1.37, DD 21→11R.
Train/val split positive in both halves (+19.8R/+21.3R).
This commit is contained in:
harwinder
2026-07-09 12:48:36 -04:00
parent 2291fc3936
commit 8687f98b39
4 changed files with 144 additions and 5 deletions
+93 -3
View File
@@ -283,6 +283,73 @@ class SignalAggregator:
return None
return float(adx.iloc[-1])
def _apply_m5_filters(self, report: SignalReport) -> None:
"""Extra selectivity for M5 — noisy TF needs stronger confirmation."""
if report.signal_quality not in ("A+", "A", "B", "C"):
return
xau_cfg = self.config.xau_strategy
abs_score = abs(report.final_score)
# Gate 1: minimum absolute score (M5 composite scores near the
# threshold are noise — require a wider margin).
if abs_score < xau_cfg.m5_min_abs_score:
report.warnings.append(
f"M5 FILTER: |score| {abs_score:.1f} < {xau_cfg.m5_min_abs_score:.0f} minimum"
)
report.signal_quality = "D"
report.trade_setup = None
return
# Gate 2: require MTF agreement (M5 alone is too noisy).
if xau_cfg.m5_require_mtf and not report.mtf_agreement:
report.warnings.append(
"M5 FILTER: no MTF agreement — M5 signals require confirmation"
)
report.signal_quality = "D"
report.trade_setup = None
return
# Gate 3: at least one confirmation TF must have a meaningful
# score in the same direction — NEUTRAL confirmations don't
# count for M5 since they indicate the move isn't visible on
# higher timeframes yet.
min_conf_score = xau_cfg.m5_min_confirmation_score
is_buy = report.final_score > 0
confirmed = False
primary_tf = self.config.primary_timeframe.value
for tf_val, tf_score in report.mtf_scores.items():
if tf_val == primary_tf:
continue
if is_buy and tf_score >= min_conf_score:
confirmed = True
break
if not is_buy and tf_score <= -min_conf_score:
confirmed = True
break
if not confirmed:
report.warnings.append(
"M5 FILTER: no confirmation TF shows directional conviction"
)
report.signal_quality = "D"
report.trade_setup = None
return
# Gate 4: cap SELL quality on M5 — A-grade SELLs enter at the
# extreme of temporary dips in gold's macro uptrend and lose
# money. B-grade SELLs (weaker conviction, shallower entries)
# are marginally profitable.
is_sell = report.final_score < 0
if is_sell and report.signal_quality not in xau_cfg.m5_sell_max_quality:
report.warnings.append(
f"M5 FILTER: SELL quality {report.signal_quality} capped — "
f"high-conviction M5 shorts enter at extremes"
)
report.signal_quality = "D"
report.trade_setup = None
return
# ── Public: bar-replayable entry point ─────────────────────────
def analyze_from_dataframes(
@@ -467,6 +534,10 @@ class SignalAggregator:
report.signal_quality = "D"
report.trade_setup = None
# ── 6h. M5 noise gate ────────────────────────────────────
if self.config.primary_timeframe == Timeframe.M5:
self._apply_m5_filters(report)
# ── 7. Trade setup for actionable signals ──────────────────
if report.trade_setup is None and report.signal_quality in ("A+", "A"):
atr = (report.primary_analysis.key_levels.get("atr", 0)
@@ -634,6 +705,15 @@ class SignalAggregator:
if rr < min_rr - 1e-9:
return None
# Spread gate (same rule as _generate_trade_setup).
min_tp = self.config.signals.round_trip_cost * self.config.signals.min_tp_cost_multiple
if tp_distance < min_tp:
report.warnings.append(
f"SPREAD GATE: target ${tp_distance:.2f} < ${min_tp:.2f} minimum — "
f"costs would eat the edge"
)
return None
return TradeSetup(
pair=report.symbol,
display_name=report.display_name,
@@ -903,8 +983,7 @@ class SignalAggregator:
)
return None
min_sl_pips = 8.0 if pip_value < 0.01 else 80.0
min_sl_price = min_sl_pips * pip_value
min_sl_price = self.config.signals.min_sl_pips * pip_value
if report.final_signal in (SignalStrength.BUY, SignalStrength.STRONG_BUY):
is_buy = True
@@ -914,8 +993,9 @@ class SignalAggregator:
is_buy = report.final_score >= 0
levels = report.primary_analysis.key_levels
min_rr = self.config.signals.min_rr_ratio
tp_floor = max(min_rr, self.config.signals.tp_floor_rr)
tf = self.config.signals.tp_tightening_factor
pre_tight_min_rr = min_rr / tf if tf > 0 else min_rr
pre_tight_min_rr = tp_floor / tf if tf > 0 else tp_floor
tp_mult = 1.5
tp_swing_cap = 2.5
@@ -1003,6 +1083,16 @@ class SignalAggregator:
if rr < min_rr - 1e-9:
return None
# Spread gate: a target too close to entry hands most of the win
# to transaction costs (validated on M5: sub-$10 targets lose).
min_tp = self.config.signals.round_trip_cost * self.config.signals.min_tp_cost_multiple
if tp1_distance < min_tp:
report.warnings.append(
f"SPREAD GATE: target ${tp1_distance:.2f} < ${min_tp:.2f} minimum — "
f"costs would eat the edge"
)
return None
risk_pips = sl_distance / pip_value
reward_pips = tp1_distance / pip_value