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,
|
primary_timeframe=bt_cfg.primary_tf,
|
||||||
confirmation_timeframes=list(bt_cfg.confirmation_tfs),
|
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)
|
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):
|
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
|
skipped_reasons["SESSION_WINDOW"] = skipped_reasons.get("SESSION_WINDOW", 0) + 1
|
||||||
continue
|
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.
|
# Cooldown.
|
||||||
if pos - last_entry_pos < bt_cfg.cooldown_bars:
|
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.
|
# — smaller wins make the fixed spread a larger share of every TP.
|
||||||
tp_tightening_factor: float = 1.0
|
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
|
@dataclass
|
||||||
class NewsConfig:
|
class NewsConfig:
|
||||||
@@ -166,6 +180,20 @@ class XauStrategyConfig:
|
|||||||
# shorts lose consistently against gold's macro uptrend).
|
# shorts lose consistently against gold's macro uptrend).
|
||||||
sell_vol_regimes: List[str] = field(default_factory=lambda: ["high", "extreme"])
|
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
|
@dataclass
|
||||||
class FilterConfig:
|
class FilterConfig:
|
||||||
|
|||||||
@@ -85,11 +85,12 @@ class TechnicalEngine:
|
|||||||
# ── Compute all indicators ─────────────────────────────────
|
# ── Compute all indicators ─────────────────────────────────
|
||||||
disabled = set(self.config.disabled_indicators or [])
|
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 "ema_trend" not in disabled: self._compute_ema_signals(df, result)
|
||||||
if "rsi" not in disabled: self._compute_rsi(df, result)
|
if "rsi" not in disabled: self._compute_rsi(df, result)
|
||||||
if "bollinger" not in disabled: self._compute_bollinger(df, result)
|
if "bollinger" not in disabled: self._compute_bollinger(df, result)
|
||||||
if "adx" not in disabled: self._compute_adx(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)
|
self._compute_macd(df, result)
|
||||||
|
|
||||||
@@ -817,7 +818,10 @@ class TechnicalEngine:
|
|||||||
|
|
||||||
def _compute_structure_sr(self, df: pd.DataFrame, result: TechnicalAnalysis):
|
def _compute_structure_sr(self, df: pd.DataFrame, result: TechnicalAnalysis):
|
||||||
"""Detect swing high/low structure levels for S/R and trade setup."""
|
"""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:
|
if lookback < 10:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
+93
-3
@@ -283,6 +283,73 @@ class SignalAggregator:
|
|||||||
return None
|
return None
|
||||||
return float(adx.iloc[-1])
|
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 ─────────────────────────
|
# ── Public: bar-replayable entry point ─────────────────────────
|
||||||
|
|
||||||
def analyze_from_dataframes(
|
def analyze_from_dataframes(
|
||||||
@@ -467,6 +534,10 @@ class SignalAggregator:
|
|||||||
report.signal_quality = "D"
|
report.signal_quality = "D"
|
||||||
report.trade_setup = None
|
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 ──────────────────
|
# ── 7. Trade setup for actionable signals ──────────────────
|
||||||
if report.trade_setup is None and report.signal_quality in ("A+", "A"):
|
if report.trade_setup is None and report.signal_quality in ("A+", "A"):
|
||||||
atr = (report.primary_analysis.key_levels.get("atr", 0)
|
atr = (report.primary_analysis.key_levels.get("atr", 0)
|
||||||
@@ -634,6 +705,15 @@ class SignalAggregator:
|
|||||||
if rr < min_rr - 1e-9:
|
if rr < min_rr - 1e-9:
|
||||||
return None
|
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(
|
return TradeSetup(
|
||||||
pair=report.symbol,
|
pair=report.symbol,
|
||||||
display_name=report.display_name,
|
display_name=report.display_name,
|
||||||
@@ -903,8 +983,7 @@ class SignalAggregator:
|
|||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
min_sl_pips = 8.0 if pip_value < 0.01 else 80.0
|
min_sl_price = self.config.signals.min_sl_pips * pip_value
|
||||||
min_sl_price = min_sl_pips * pip_value
|
|
||||||
|
|
||||||
if report.final_signal in (SignalStrength.BUY, SignalStrength.STRONG_BUY):
|
if report.final_signal in (SignalStrength.BUY, SignalStrength.STRONG_BUY):
|
||||||
is_buy = True
|
is_buy = True
|
||||||
@@ -914,8 +993,9 @@ class SignalAggregator:
|
|||||||
is_buy = report.final_score >= 0
|
is_buy = report.final_score >= 0
|
||||||
levels = report.primary_analysis.key_levels
|
levels = report.primary_analysis.key_levels
|
||||||
min_rr = self.config.signals.min_rr_ratio
|
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
|
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_mult = 1.5
|
||||||
tp_swing_cap = 2.5
|
tp_swing_cap = 2.5
|
||||||
@@ -1003,6 +1083,16 @@ class SignalAggregator:
|
|||||||
if rr < min_rr - 1e-9:
|
if rr < min_rr - 1e-9:
|
||||||
return None
|
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
|
risk_pips = sl_distance / pip_value
|
||||||
reward_pips = tp1_distance / pip_value
|
reward_pips = tp1_distance / pip_value
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user