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:
+185
-22
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user