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.
This commit is contained in:
+12
-1
@@ -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)
|
||||
-------------------------------------------------------------------
|
||||
|
||||
+26
-13
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────
|
||||
|
||||
+12
-7
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user