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). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+93
-3
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user