Add M5 pullback entry archetype (XAU_PB): +140R combined on 12mo
Second entry pattern alongside the Keltner-breakout: enter on the retrace to EMA21/KC-mid in an established trend (EMA >=3/4 aligned, ADX>=20, retrace 0.8-6.0 ATR, resumption bar closed back in trend direction, RSI in the pullback zone not the extended zone). Detector lives in the aggregator so live and GPU-backtest paths share one implementation. Own gates (composite floor 15, confirmation-TF agreement, MTF opposition, vol regimes) with a diagnostic funnel. PB stops anchor to the pullback swing (~$8 vs ~$25 breakout), so PB trades get their own trail arm (0.8R vs 0.6R global) via per-trade trail overrides in the backtester and strategy-aware Telegram alerts. 12mo M5 validation: PB alone +38.3R/254 trades, positive both halves and all four full quarters; core breakout trades undisturbed (+101.9R vs +105.0 baseline). Combined book: 549 trades (~10.6/wk), +140.1R, PF 1.78, DD 11.3R vs 311 trades/+105.0R before. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -47,6 +47,8 @@ class TradeSetup:
|
||||
risk_pips: float
|
||||
reward_pips: float
|
||||
pip_value: float
|
||||
# Entry archetype ("GENERIC", "XAU_PB") — selects trail params.
|
||||
strategy: str = "GENERIC"
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -93,6 +95,10 @@ class SignalReport:
|
||||
class SignalAggregator:
|
||||
"""Master aggregator for live signal analysis."""
|
||||
|
||||
# Diagnostic funnel for the M5 pullback route (shared across
|
||||
# instances; read by backtest tooling, harmless in live use).
|
||||
pb_funnel: Dict[str, int] = {}
|
||||
|
||||
def __init__(self, config: PlatformConfig):
|
||||
self.config = config
|
||||
self.data_provider = ForexDataProvider(config)
|
||||
@@ -377,6 +383,183 @@ class SignalAggregator:
|
||||
report.trade_setup = None
|
||||
return
|
||||
|
||||
# ── M5 pullback entry (XAU_PB) ──────────────────────────────────
|
||||
|
||||
def _detect_m5_pullback(
|
||||
self, report: SignalReport, primary_df: pd.DataFrame
|
||||
) -> Optional[Dict]:
|
||||
"""Detect a pullback-continuation candidate on the primary TF.
|
||||
|
||||
Trend intact (EMA alignment >= 3/4, ADX floor) + price retraced
|
||||
to EMA21/KC-mid within the last few bars + resumption bar closed
|
||||
back in trend direction + RSI in the pullback zone (not the
|
||||
extended zone breakout entries fire in).
|
||||
"""
|
||||
cfg = self.config.xau_strategy
|
||||
pa = report.primary_analysis
|
||||
if pa is None or len(primary_df) < 60:
|
||||
return None
|
||||
levels = pa.key_levels
|
||||
atr = levels.get("atr", 0.0)
|
||||
ema_f = levels.get("ema_fast")
|
||||
ema_m = levels.get("ema_medium")
|
||||
ema_s = levels.get("ema_slow")
|
||||
ema_t = levels.get("ema_trend")
|
||||
if not atr or None in (ema_f, ema_m, ema_s, ema_t):
|
||||
return None
|
||||
|
||||
adx_ind = pa.indicators.get("adx")
|
||||
rsi_ind = pa.indicators.get("rsi")
|
||||
if adx_ind is None or rsi_ind is None:
|
||||
return None
|
||||
if adx_ind.value < cfg.m5_pullback_adx_min:
|
||||
return None
|
||||
|
||||
close = float(primary_df["Close"].iloc[-1])
|
||||
open_ = float(primary_df["Open"].iloc[-1])
|
||||
n_touch = cfg.m5_pullback_touch_bars
|
||||
recent_low = float(primary_df["Low"].iloc[-n_touch:].min())
|
||||
recent_high = float(primary_df["High"].iloc[-n_touch:].max())
|
||||
# Pullback swing anchor for SL: extreme of the last 8 bars.
|
||||
pb_low = float(primary_df["Low"].iloc[-8:].min())
|
||||
pb_high = float(primary_df["High"].iloc[-8:].max())
|
||||
kc_mid = levels.get("kc_mid", ema_m)
|
||||
lo_atr, hi_atr = cfg.m5_pullback_retrace_atr
|
||||
rsi = rsi_ind.value
|
||||
|
||||
# BUY: uptrend structure, dip to the band, bullish resumption.
|
||||
up_align = sum([ema_f > ema_m, ema_m > ema_s, ema_s > ema_t,
|
||||
close > ema_t])
|
||||
if up_align >= 3:
|
||||
touched = recent_low <= max(ema_m, kc_mid)
|
||||
swing_high = float(primary_df["High"].iloc[-40:].max())
|
||||
depth = (swing_high - pb_low) / atr
|
||||
resumed = close > open_ and close > ema_f
|
||||
lo_z, hi_z = cfg.m5_pullback_rsi_buy
|
||||
if (touched and resumed and lo_atr <= depth <= hi_atr
|
||||
and lo_z <= rsi <= hi_z):
|
||||
return {"direction": "BUY", "anchor": pb_low,
|
||||
"retrace_atr": depth}
|
||||
|
||||
# SELL: mirrored.
|
||||
dn_align = sum([ema_f < ema_m, ema_m < ema_s, ema_s < ema_t,
|
||||
close < ema_t])
|
||||
if dn_align >= 3:
|
||||
touched = recent_high >= min(ema_m, kc_mid)
|
||||
swing_low = float(primary_df["Low"].iloc[-40:].min())
|
||||
depth = (pb_high - swing_low) / atr
|
||||
resumed = close < open_ and close < ema_f
|
||||
lo_z, hi_z = cfg.m5_pullback_rsi_sell
|
||||
if (touched and resumed and lo_atr <= depth <= hi_atr
|
||||
and lo_z <= rsi <= hi_z):
|
||||
return {"direction": "SELL", "anchor": pb_high,
|
||||
"retrace_atr": depth}
|
||||
return None
|
||||
|
||||
def _try_m5_pullback(
|
||||
self, report: SignalReport, primary_df: pd.DataFrame, pip_value: float
|
||||
) -> None:
|
||||
"""Build an XAU_PB trade setup if a pullback candidate passes gates."""
|
||||
cfg = self.config.xau_strategy
|
||||
# Rejection funnel counters (class-level diagnostic, cheap).
|
||||
funnel = SignalAggregator.pb_funnel
|
||||
|
||||
def _reject(reason: str) -> None:
|
||||
funnel[reason] = funnel.get(reason, 0) + 1
|
||||
|
||||
pb = self._detect_m5_pullback(report, primary_df)
|
||||
if pb is None:
|
||||
return
|
||||
_reject("detected")
|
||||
direction = pb["direction"]
|
||||
is_buy = direction == "BUY"
|
||||
|
||||
# Directional composite floor (lower than Gate 1 by design —
|
||||
# price mid-pullback scores weaker on the momentum indicators).
|
||||
signed = report.final_score if is_buy else -report.final_score
|
||||
if signed < cfg.m5_pullback_min_score:
|
||||
_reject("score")
|
||||
return
|
||||
|
||||
# Confirmation TF must agree (reuse Gate-3 threshold).
|
||||
min_conf = cfg.m5_min_confirmation_score
|
||||
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) or \
|
||||
(not is_buy and tf_score <= -min_conf):
|
||||
confirmed = True
|
||||
break
|
||||
if not confirmed:
|
||||
_reject("confirmation_tf")
|
||||
return
|
||||
|
||||
# Never against multi-TF consensus (mirror of gate 6f).
|
||||
sigs = list(report.mtf_signals.values())
|
||||
bull_n = sum(1 for s in sigs if s in (SignalStrength.BUY, SignalStrength.STRONG_BUY))
|
||||
bear_n = sum(1 for s in sigs if s in (SignalStrength.SELL, SignalStrength.STRONG_SELL))
|
||||
thr = self.config.signals.mtf_agreement_threshold
|
||||
if (is_buy and bear_n >= thr) or (not is_buy and bull_n >= thr):
|
||||
_reject("mtf_opposition")
|
||||
return
|
||||
|
||||
# Volatility gates match the breakout path.
|
||||
if report.volatility_regime in cfg.m5_blocked_vol_regimes:
|
||||
_reject("vol_regime")
|
||||
return
|
||||
if (not is_buy and report.volatility_regime
|
||||
and report.volatility_regime not in cfg.sell_vol_regimes):
|
||||
_reject("sell_vol")
|
||||
return
|
||||
|
||||
atr = report.primary_analysis.key_levels.get("atr", 0.0)
|
||||
entry = report.current_price
|
||||
min_sl_price = self.config.signals.min_sl_pips * pip_value
|
||||
if is_buy:
|
||||
sl_dist = max(entry - (pb["anchor"] - 0.5 * atr), min_sl_price)
|
||||
sl = entry - sl_dist
|
||||
tp1 = entry + 1.5 * sl_dist
|
||||
tp2 = entry + 2.5 * sl_dist
|
||||
else:
|
||||
sl_dist = max((pb["anchor"] + 0.5 * atr) - entry, min_sl_price)
|
||||
sl = entry + sl_dist
|
||||
tp1 = entry - 1.5 * sl_dist
|
||||
tp2 = entry - 2.5 * sl_dist
|
||||
|
||||
# Spread gate — same economics as the breakout generators.
|
||||
min_tp = (self.config.signals.round_trip_cost
|
||||
* self.config.signals.min_tp_cost_multiple)
|
||||
if 1.5 * sl_dist < min_tp:
|
||||
return
|
||||
|
||||
report.trade_setup = TradeSetup(
|
||||
pair=report.symbol,
|
||||
display_name=report.display_name,
|
||||
direction=direction,
|
||||
entry_price=entry,
|
||||
stop_loss=sl,
|
||||
take_profit_1=tp1,
|
||||
take_profit_2=tp2,
|
||||
risk_reward=1.5,
|
||||
risk_pips=sl_dist / pip_value,
|
||||
reward_pips=1.5 * sl_dist / pip_value,
|
||||
pip_value=pip_value,
|
||||
strategy="XAU_PB",
|
||||
)
|
||||
if report.signal_quality not in ("A+", "A", "B"):
|
||||
report.signal_quality = "B"
|
||||
report.final_signal = (SignalStrength.BUY if is_buy
|
||||
else SignalStrength.SELL)
|
||||
report.action_recommendation = (
|
||||
f"{direction} — pullback continuation entry"
|
||||
)
|
||||
marker = (f"XAU PULLBACK STRATEGY: retrace {pb['retrace_atr']:.1f} "
|
||||
f"ATR to EMA21/KC-mid, trend resumed")
|
||||
target = report.bullish_reasons if is_buy else report.bearish_reasons
|
||||
target.insert(0, marker)
|
||||
|
||||
# ── Public: bar-replayable entry point ─────────────────────────
|
||||
|
||||
def analyze_from_dataframes(
|
||||
@@ -577,6 +760,16 @@ class SignalAggregator:
|
||||
# ── 8. Reasons ─────────────────────────────────────────────
|
||||
self._collect_reasons(report)
|
||||
|
||||
# ── 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
|
||||
# after reasons so its marker lands at reasons[0] for tagging.
|
||||
if (self.config.primary_timeframe == Timeframe.M5
|
||||
and self.config.xau_strategy.m5_pullback_enabled
|
||||
and report.trade_setup is None
|
||||
and report.signal_quality != "F"):
|
||||
self._try_m5_pullback(report, primary_df, pip_value)
|
||||
|
||||
return report
|
||||
|
||||
# ── Internals ──────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user