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:
harwinder
2026-07-09 12:48:36 -04:00
parent d95d31a852
commit db16f209bb
4 changed files with 144 additions and 5 deletions
+17
View File
@@ -1383,6 +1383,14 @@ def run_backtest(
primary_timeframe=bt_cfg.primary_tf,
confirmation_timeframes=list(bt_cfg.confirmation_tfs),
)
elif platform_cfg.primary_timeframe != bt_cfg.primary_tf:
# A silent mismatch yields 0 trades: the replayer walks
# bt_cfg.primary_tf bars while the aggregator looks for
# platform_cfg.primary_timeframe in the slices and never finds it.
raise ValueError(
f"primary_tf mismatch: BacktestConfig={bt_cfg.primary_tf.value} "
f"vs PlatformConfig={platform_cfg.primary_timeframe.value}"
)
provider = ForexDataProvider(platform_cfg)
@@ -1464,6 +1472,15 @@ def run_backtest(
if not (bt_filter.session_start_hour <= bar_hour < bt_filter.session_end_hour):
skipped_reasons["SESSION_WINDOW"] = skipped_reasons.get("SESSION_WINDOW", 0) + 1
continue
# Blocked hours (live checks datetime.now().hour ≈ bar close).
# analyze_from_dataframes step 6b checks bar-open timestamp,
# which is one bar behind — misses the last 5min of each hour.
_is_xau = PAIR_INFO[bt_cfg.pair]["display"] == "XAU/USD"
_blocked = (platform_cfg.xau_strategy.blocked_hours_utc
if _is_xau else bt_filter.blocked_hours_utc)
if bar_hour in _blocked:
skipped_reasons["SESSION_WINDOW"] = skipped_reasons.get("SESSION_WINDOW", 0) + 1
continue
# Cooldown.
if pos - last_entry_pos < bt_cfg.cooldown_bars:
+28
View File
@@ -124,6 +124,20 @@ class SignalConfig:
# — smaller wins make the fixed spread a larger share of every TP.
tp_tightening_factor: float = 1.0
# ── Trade geometry ────────────────────────────────────────────
# Round-trip transaction cost estimate in price units (XAU ~$0.30).
round_trip_cost: float = 0.30
# Spread gate: reject setups whose TP1 distance is below
# round_trip_cost × this multiple (caps cost at ~3% of target).
# Validated 2026-07: small-target trades lose in both data halves
# on M5 while TP≥$10 trades earn ~+0.08R/trade.
min_tp_cost_multiple: float = 30.0
# Fixed SL floor in pips (80 pips = $8 on XAU). 0 disables — SL then
# floors at ATR × v2_sl_atr_multiplier only.
min_sl_pips: float = 80.0
# TP1 floor as a fraction of SL distance (0.5 = historical RR 0.5).
tp_floor_rr: float = 0.5
@dataclass
class NewsConfig:
@@ -166,6 +180,20 @@ class XauStrategyConfig:
# shorts lose consistently against gold's macro uptrend).
sell_vol_regimes: List[str] = field(default_factory=lambda: ["high", "extreme"])
# ── M5-specific filters ──────────────────────────────────────
# M5 signals are noisy; require stronger confirmation before
# generating trade setups. Without these, M5 is breakeven
# (+0.01R/trade) with 21% drawdown.
m5_require_mtf: bool = True
m5_min_abs_score: float = 30.0
m5_min_confirmation_score: float = 10.0
# A-grade M5 SELLs enter at the extreme of temporary dips and
# lose (-0.017R/trade on 163 trades) — maximum conviction at
# the worst entry. Cap SELL quality so A-grade can't trigger
# the ATR-based setup generator; B-grade swing setups survive
# because they catch moderate drops, not extremes.
m5_sell_max_quality: List[str] = field(default_factory=lambda: ["B", "C"])
@dataclass
class FilterConfig:
+6 -2
View File
@@ -85,11 +85,12 @@ class TechnicalEngine:
# ── Compute all indicators ─────────────────────────────────
disabled = set(self.config.disabled_indicators or [])
# ATR first: other indicators (EMA ribbon, candlestick) use it
if "atr" not in disabled: self._compute_atr(df, result)
if "ema_trend" not in disabled: self._compute_ema_signals(df, result)
if "rsi" not in disabled: self._compute_rsi(df, result)
if "bollinger" not in disabled: self._compute_bollinger(df, result)
if "adx" not in disabled: self._compute_adx(df, result)
if "atr" not in disabled: self._compute_atr(df, result)
self._compute_macd(df, result)
@@ -817,7 +818,10 @@ class TechnicalEngine:
def _compute_structure_sr(self, df: pd.DataFrame, result: TechnicalAnalysis):
"""Detect swing high/low structure levels for S/R and trade setup."""
lookback = min(self.config.swing_sr_lookback, len(df) - 1)
base_lookback = self.config.swing_sr_lookback
if result.timeframe in ("1min", "5min"):
base_lookback = max(base_lookback, 120)
lookback = min(base_lookback, len(df) - 1)
if lookback < 10:
return
+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