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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Harwinder Sekhon
2026-07-10 20:57:54 -04:00
parent 77604960f5
commit 3b5c0b27ed
+185 -22
View File
@@ -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