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:
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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