From 0da5ed03afe827c85e37762f2be0a09c750f806c Mon Sep 17 00:00:00 2001 From: Harwinder Sekhon Date: Fri, 10 Jul 2026 20:57:54 -0400 Subject: [PATCH] Add EUR/USD signal gates, trade geometry, and weekend/session filters Re-grafted from feature/eurusd-support onto the evolved aggregator: - 6h EUR gate chain (regime, quality, exhaustion, EMA200, session, score/confidence, volatility, indicator agreement, RSI-extended), active only for EUR/USD with eur_strategy.enabled - _generate_trade_setup: EUR branch reads EurUsdStrategyConfig only (min_sl_pips, min_rr, mature-move + quality-scaled TP, sl_atr_floor, swing-buffer SL) with a pip-denominated spread gate; adds a pip_value<=0 guard; non-EUR branch is arithmetically unchanged (sl_swing_buffer=1.0 extraction only) - EUR weekend/weekday pre-filter in analyze_pair mirroring the XAU block - "EUR STRATEGY" reasons[0] marker so the backtester tags EUR trades --- signal_aggregator.py | 207 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 185 insertions(+), 22 deletions(-) diff --git a/signal_aggregator.py b/signal_aggregator.py index 6f14bb0..b7fc481 100755 --- a/signal_aggregator.py +++ b/signal_aggregator.py @@ -157,6 +157,39 @@ class SignalAggregator: report.action_recommendation = f"NO TRADE — Only trading {allowed}" return report + if pair_info["display"] == "EUR/USD" and self.config.eur_strategy.enabled: + eur_cfg = self.config.eur_strategy + + if eur_cfg.weekend_filter and _is_forex_weekend(now_utc): + report = SignalReport( + symbol=symbol, + display_name=pair_info["display"], + timestamp=str(now_utc), + ) + report.final_signal = SignalStrength.NEUTRAL + report.signal_quality = "F" + report.trade_setup = None + report.warnings.append("WEEKEND FILTER: EUR/USD market closed — no liquidity") + report.action_recommendation = "NO TRADE — Weekend (market closed)" + return report + + if eur_cfg.allowed_weekdays and now_utc.weekday() not in eur_cfg.allowed_weekdays: + day_names = {0: "Mon", 1: "Tue", 2: "Wed", 3: "Thu", 4: "Fri", 5: "Sat", 6: "Sun"} + allowed = "/".join(day_names.get(d, str(d)) for d in eur_cfg.allowed_weekdays) + report = SignalReport( + symbol=symbol, + display_name=pair_info["display"], + timestamp=str(now_utc), + ) + report.final_signal = SignalStrength.NEUTRAL + report.signal_quality = "F" + report.trade_setup = None + report.warnings.append( + f"DAY FILTER: {day_names.get(now_utc.weekday(), '?')} not in allowed days ({allowed})" + ) + report.action_recommendation = f"NO TRADE — Only trading {allowed}" + return report + timeframes = [self.config.primary_timeframe] + list( self.config.confirmation_timeframes ) @@ -746,7 +779,94 @@ class SignalAggregator: report.signal_quality = "D" report.trade_setup = None - # ── 6h. M5 noise gate (gold-tuned thresholds — XAU only) ── + # ── 6h. EUR/USD regime + quality + risk gate ────────────── + if report.display_name == "EUR/USD" and self.config.eur_strategy.enabled: + eur = self.config.eur_strategy + if eur.blocked_regimes and report.market_regime in eur.blocked_regimes: + report.warnings.append( + f"EUR REGIME FILTER: {report.market_regime} blocked" + ) + report.signal_quality = "F" + if (eur.min_quality_grades + and report.signal_quality not in eur.min_quality_grades + and report.signal_quality != "F"): + report.warnings.append( + f"EUR QUALITY FILTER: {report.signal_quality} below minimum" + ) + report.signal_quality = "F" + if (report.signal_quality != "F" + and getattr(report, 'exhaustion_count', 0) > eur.max_exhaustion_tags): + report.warnings.append( + f"EUR EXHAUSTION FILTER: {report.exhaustion_count} tags > max {eur.max_exhaustion_tags}" + ) + report.signal_quality = "F" + if (report.signal_quality != "F" + and eur.block_near_ema200 + and any(w.startswith("NEAR EMA200") for w in report.warnings)): + report.warnings.append("EUR EMA200 FILTER: too close to EMA200") + report.signal_quality = "F" + if eur.session_hours and report.signal_quality != "F": + try: + bar_hour = pd.Timestamp(report.timestamp).hour + if bar_hour not in eur.session_hours: + report.warnings.append( + f"EUR SESSION FILTER: {bar_hour:02}:00 UTC outside allowed hours" + ) + report.signal_quality = "F" + except Exception: + pass + if report.signal_quality != "F": + abs_s = abs(report.final_score) + if abs_s < eur.min_entry_score: + report.warnings.append( + f"EUR SCORE FILTER: |{abs_s:.1f}| < {eur.min_entry_score}" + ) + report.signal_quality = "F" + elif report.final_confidence < eur.min_entry_confidence: + report.warnings.append( + f"EUR CONF FILTER: {report.final_confidence} < {eur.min_entry_confidence}" + ) + report.signal_quality = "F" + if (report.signal_quality != "F" + and eur.blocked_vol_regimes + and report.volatility_regime): + if report.volatility_regime in eur.blocked_vol_regimes: + report.warnings.append( + f"EUR VOL FILTER: {report.volatility_regime} volatility blocked" + ) + report.signal_quality = "F" + if (report.signal_quality != "F" + and eur.min_indicator_agreement > 0 + and report.primary_analysis): + agree_count = sum( + 1 for ind in report.primary_analysis.indicators.values() + if ind.signal.value != 0 + and (ind.signal.value > 0) == (report.final_score > 0) + ) + if agree_count < eur.min_indicator_agreement: + report.warnings.append( + f"EUR AGREE FILTER: {agree_count} < {eur.min_indicator_agreement}" + ) + report.signal_quality = "F" + if (report.signal_quality != "F" + and eur.block_rsi_extended + and report.primary_analysis): + rsi_ind = report.primary_analysis.indicators.get("rsi") + if rsi_ind: + rsi_v = rsi_ind.value + is_buy = report.final_score > 0 + if is_buy and rsi_v > 70: + report.warnings.append( + f"EUR RSI FILTER: RSI {rsi_v:.0f} overbought for BUY" + ) + report.signal_quality = "F" + elif not is_buy and rsi_v < 30: + report.warnings.append( + f"EUR RSI FILTER: RSI {rsi_v:.0f} oversold for SELL" + ) + report.signal_quality = "F" + + # ── 6i. M5 noise gate (gold-tuned thresholds — XAU only) ── if (self.config.primary_timeframe == Timeframe.M5 and report.display_name == "XAU/USD"): self._apply_m5_filters(report) @@ -766,6 +886,17 @@ class SignalAggregator: # ── 8. Reasons ───────────────────────────────────────────── self._collect_reasons(report) + # ── 8b. EUR strategy marker ──────────────────────────────── + # The backtester tags trades from reasons[0]; mark EUR setups so + # they report under the EUR strategy instead of GENERIC. + if (report.display_name == "EUR/USD" and self.config.eur_strategy.enabled + and report.trade_setup is not None): + marker = (f"EUR STRATEGY: {report.market_regime} regime, " + f"score {report.final_score:+.1f}, quality {report.signal_quality}") + target = (report.bullish_reasons if report.trade_setup.direction == "BUY" + else report.bearish_reasons) + target.insert(0, marker) + # ── 9. M5 pullback entry (XAU_PB) ────────────────────────── # Second archetype: when no breakout setup fired, look for a # trend-continuation pullback (retrace to EMA21/KC-mid). Runs @@ -1202,6 +1333,8 @@ class SignalAggregator: atr = report.primary_analysis.key_levels.get("atr", 0) if atr == 0: return None + if pip_value <= 0: + return None # Fix 2: Minimum ATR threshold for XAU/USD if report.display_name == "XAU/USD" and atr < self.config.xau_strategy.min_atr_threshold: @@ -1210,8 +1343,6 @@ class SignalAggregator: ) return None - min_sl_price = self.config.signals.min_sl_pips * pip_value - if report.final_signal in (SignalStrength.BUY, SignalStrength.STRONG_BUY): is_buy = True elif report.final_signal in (SignalStrength.SELL, SignalStrength.STRONG_SELL): @@ -1219,16 +1350,39 @@ class SignalAggregator: else: 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 = tp_floor / tf if tf > 0 else tp_floor - tp_mult = 1.5 - tp_swing_cap = 2.5 + _is_eur = report.display_name == "EUR/USD" and self.config.eur_strategy.enabled - sl_atr_floor = (self.config.xau_strategy.v2_sl_atr_multiplier - if report.display_name == "XAU/USD" else 1.5) + if _is_eur: + # EUR geometry reads only EurUsdStrategyConfig — nothing + # gold-calibrated from SignalConfig applies here. + eur = self.config.eur_strategy + min_sl_price = eur.min_sl_pips * pip_value + min_rr = eur.min_rr_ratio + # No TP-tightening interplay for EUR: the RR floor used for + # target selection IS min_rr (tp_tightening_factor is 1.0; + # revisit if EUR ever tunes it). + pre_tight_min_rr = min_rr + mature_move = abs(report.final_score) > eur.mature_move_threshold + quality_scale = eur.tp_quality_scale.get(report.signal_quality, 1.0) + tp_mult = (eur.tp_atr_multiplier if mature_move + else eur.tp_atr_multiplier * 1.5) * quality_scale + tp_swing_cap = (2.0 if mature_move else 2.5) * quality_scale + sl_atr_floor = eur.sl_atr_floor + sl_swing_buffer = eur.sl_atr_multiplier + else: + min_sl_price = self.config.signals.min_sl_pips * pip_value + 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 = tp_floor / tf if tf > 0 else tp_floor + + tp_mult = 1.5 + tp_swing_cap = 2.5 + + sl_atr_floor = (self.config.xau_strategy.v2_sl_atr_multiplier + if report.display_name == "XAU/USD" else 1.5) + sl_swing_buffer = 1.0 if is_buy: swing_lows = sorted( @@ -1236,9 +1390,9 @@ class SignalAggregator: reverse=True, ) if swing_lows: - sl_price = swing_lows[0] - atr * 1.0 + sl_price = swing_lows[0] - atr * sl_swing_buffer else: - sl_price = price - atr * 2.0 + sl_price = price - atr * (1.0 + sl_swing_buffer) min_sl = max(atr * sl_atr_floor, min_sl_price) if price - sl_price < min_sl: sl_price = price - min_sl @@ -1266,9 +1420,9 @@ class SignalAggregator: v for k, v in levels.items() if k.startswith("swing_high_") and v > price ) if swing_highs: - sl_price = swing_highs[0] + atr * 1.0 + sl_price = swing_highs[0] + atr * sl_swing_buffer else: - sl_price = price + atr * 2.0 + sl_price = price + atr * (1.0 + sl_swing_buffer) min_sl = max(atr * sl_atr_floor, min_sl_price) if sl_price - price < min_sl: sl_price = price + min_sl @@ -1312,13 +1466,22 @@ class SignalAggregator: # 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 + if _is_eur: + min_tp = eur.round_trip_cost * eur.min_tp_cost_multiple + if tp1_distance < min_tp: + report.warnings.append( + f"SPREAD GATE: target {tp1_distance / pip_value:.1f} pips " + f"< {min_tp / pip_value:.1f} pip minimum — costs would eat the edge" + ) + return None + else: + 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