diff --git a/PULLBACK_ENTRY_PLAN.txt b/PULLBACK_ENTRY_PLAN.txt index be7cfd3..a4e5b03 100644 --- a/PULLBACK_ENTRY_PLAN.txt +++ b/PULLBACK_ENTRY_PLAN.txt @@ -1,6 +1,17 @@ PULLBACK ENTRY PATTERN — IMPLEMENTATION PLAN ============================================= -Drafted 2026-07-09. Status: PLANNED (not implemented). +Drafted 2026-07-09. Status: IMPLEMENTED & VALIDATED (same day). + +OUTCOME: shipped as XAU_PB. 12mo M5 validation: 254 PB trades, ++38.3R, avgR +0.151, WR 58%, halves +20.1/+18.1, all four full +quarters positive. Core breakout trades undisturbed (-3.1R +displacement). Combined book: 549 trades (~10.6/wk), +140.1R, +PF 1.78, DD 11.3R. Calibration lesson: M5 gold trend legs measure +3-4 ATR swing-to-trough — the drafted 0.5-2.5 ATR retrace window +rejected 81% of valid pullbacks (fixed: 0.8-6.0). PB stops are +~$8 vs ~$25 core, so PB got its own trail arm (0.8R vs 0.6R). +Detector lives in signal_aggregator (NOT engine.py) so the GPU +backtest engine exercises the same code path as live. WHY (evidence from 12mo M5 backtests, $0.30 spread, trailing exits) ------------------------------------------------------------------- diff --git a/backtest/gpu_backtest.py b/backtest/gpu_backtest.py index 5e08fb9..732bc78 100644 --- a/backtest/gpu_backtest.py +++ b/backtest/gpu_backtest.py @@ -1094,7 +1094,10 @@ class Trade: final_confidence: float market_regime: str volatility_regime: str = "" - strategy_tag: str = "" # "XAU", "EUR_PB", "GBP_PB", "GENERIC" + strategy_tag: str = "" # "XAU", "XAU_PB", "EUR_PB", "GBP_PB", "GENERIC" + # Per-trade trailing overrides (0 = use the run-level params). + trail_arm_r: float = 0.0 + trail_dist_r: float = 0.0 # Per-instructions: entry reasoning and indicator breakdown ───── entry_reasons: List[str] = field(default_factory=list) @@ -1194,6 +1197,9 @@ def _simulate_trailing( t.exit_reason = "NO_DATA" continue + arm = t.trail_arm_r if t.trail_arm_r > 0 else trail_arm_r + dist = t.trail_dist_r if t.trail_dist_r > 0 else trail_dist_r + cur_sl = t.sl peak = t.entry_price exit_price = None @@ -1217,9 +1223,9 @@ def _simulate_trailing( peak = max(peak, fav) if buy else min(peak, fav) exc = ((peak - t.entry_price) if buy else (t.entry_price - peak)) / risk - if exc >= trail_arm_r: - lvl = (peak - trail_dist_r * risk if buy - else peak + trail_dist_r * risk) + if exc >= arm: + lvl = (peak - dist * risk if buy + else peak + dist * risk) cur_sl = max(cur_sl, lvl) if buy else min(cur_sl, lvl) if exit_price is None: @@ -1617,16 +1623,19 @@ def run_backtest( if report.signal_quality not in bt_cfg.quality_filter: continue - # Strategy tag (from bullish_reasons[0] which the aggregator pre-pends). + # Strategy tag (from reasons[0] which the aggregator pre-pends). + # Pullback SELLs mark bearish_reasons, so check both lists. tag = "GENERIC" - if report.bullish_reasons: - first = report.bullish_reasons[0] - if "XAU STRATEGY" in first: - tag = "XAU" - elif "EUR STRATEGY" in first: - tag = "EUR_PB" - elif "GBP STRATEGY" in first: - tag = "GBP_PB" + first_bull = report.bullish_reasons[0] if report.bullish_reasons else "" + first_bear = report.bearish_reasons[0] if report.bearish_reasons else "" + if "XAU PULLBACK" in first_bull or "XAU PULLBACK" in first_bear: + tag = "XAU_PB" + elif "XAU STRATEGY" in first_bull: + tag = "XAU" + elif "EUR STRATEGY" in first_bull: + tag = "EUR_PB" + elif "GBP STRATEGY" in first_bull: + tag = "GBP_PB" if bt_cfg.strategies_only and tag not in bt_cfg.strategies_only: continue @@ -1684,6 +1693,10 @@ def run_backtest( indicators_opposing=oppose, indicators_neutral=neutral, indicators_total=total, + trail_arm_r=(platform_cfg.signals.pb_trail_arm_r + if tag == "XAU_PB" else 0.0), + trail_dist_r=(platform_cfg.signals.pb_trail_dist_r + if tag == "XAU_PB" else 0.0), )) last_entry_pos = pos diff --git a/config.py b/config.py index 60f7450..599b668 100755 --- a/config.py +++ b/config.py @@ -148,6 +148,11 @@ class SignalConfig: # Set trail_arm_r = 0 to disable and exit at TP1 as before. trail_arm_r: float = 0.6 trail_dist_r: float = 0.75 + # Pullback (XAU_PB) trades run tighter stops (~$8 vs ~$25), so the + # global 0.6R arm fires on noise. Arm later; whole 0.8-arm row of + # the grid is +35-38R vs +32R at global params (balanced halves). + pb_trail_arm_r: float = 0.8 + pb_trail_dist_r: float = 0.75 @dataclass @@ -213,6 +218,28 @@ class XauStrategyConfig: # halves of 6mo and 12mo backtests). m5_sell_min_rsi: float = 25.0 + # ── M5 pullback entry (XAU_PB — see PULLBACK_ENTRY_PLAN.txt) ── + # Second entry archetype: enter on the retrace to EMA21/KC-mid in + # an established trend instead of at Keltner-breakout extension. + # Validated 2026-07: +38.3R/254 trades on 12mo, positive both + # halves and all four full quarters, core trades undisturbed. + m5_pullback_enabled: bool = True + # Composite score is weaker mid-pullback BY DESIGN (price sits + # below the momentum bands) — separate, lower floor than Gate 1. + m5_pullback_min_score: float = 15.0 + m5_pullback_adx_min: float = 20.0 + # RSI must be in the pullback zone, NOT the extended zone the + # breakout entries fire in (those run 65-80). + m5_pullback_rsi_buy: tuple = (35.0, 62.0) + m5_pullback_rsi_sell: tuple = (38.0, 65.0) + # Retrace depth from the pre-pullback swing extreme, in ATRs. + # Shallower = still extended; deeper = trend likely broken. + # M5 gold trend legs measure 3-4 ATR swing-to-trough (median 3.9), + # so the ceiling sits well above that of higher TFs. + m5_pullback_retrace_atr: tuple = (0.8, 6.0) + # Price must have touched EMA21/KC-mid within this many bars. + m5_pullback_touch_bars: int = 3 + @dataclass class FilterConfig: diff --git a/signal_aggregator.py b/signal_aggregator.py index 15ae197..7e6d24f 100755 --- a/signal_aggregator.py +++ b/signal_aggregator.py @@ -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 ────────────────────────────────────────────────── diff --git a/telegram_bot.py b/telegram_bot.py index 696376c..9e6bf58 100755 --- a/telegram_bot.py +++ b/telegram_bot.py @@ -255,18 +255,23 @@ def format_signal_message(report: SignalReport) -> str: lines.append(f"⚖️ RR: 1:{ts.risk_reward:.1f}") # Trailing-stop management plan (matches backtested exit model: - # TP1 is advisory, the trail is the exit). + # TP1 is advisory, the trail is the exit). Pullback setups run + # tighter stops and arm later. sig_cfg = PlatformConfig().signals - if sig_cfg.trail_arm_r > 0: + is_pb = getattr(ts, "strategy", "GENERIC") == "XAU_PB" + arm_r = sig_cfg.pb_trail_arm_r if is_pb else sig_cfg.trail_arm_r + dist_r = sig_cfg.pb_trail_dist_r if is_pb else sig_cfg.trail_dist_r + if arm_r > 0: risk_dist = abs(ts.entry_price - ts.stop_loss) if ts.direction == "BUY": - arm_price = ts.entry_price + sig_cfg.trail_arm_r * risk_dist + arm_price = ts.entry_price + arm_r * risk_dist else: - arm_price = ts.entry_price - sig_cfg.trail_arm_r * risk_dist - trail_dist = sig_cfg.trail_dist_r * risk_dist + arm_price = ts.entry_price - arm_r * risk_dist + trail_dist = dist_r * risk_dist + tag = " (pullback)" if is_pb else "" lines.append( - f"🔄 Trail: once price hits `{_fmt_price(arm_price)}` " - f"(+{sig_cfg.trail_arm_r:.1f}R), trail SL ${trail_dist:.2f} " + f"🔄 Trail{tag}: once price hits `{_fmt_price(arm_price)}` " + f"(+{arm_r:.1f}R), trail SL ${trail_dist:.2f} " f"behind best price — TP1 is a guide, let it run" )