From 0393087168a69a773aa8fddc83d2761194ece06b Mon Sep 17 00:00:00 2001 From: harwinder Date: Thu, 9 Jul 2026 13:34:57 -0400 Subject: [PATCH] =?UTF-8?q?Add=20M5=20gates=205-6=20and=20trailing-stop=20?= =?UTF-8?q?exits=20(+48R=20=E2=86=92=20+105R=20on=2012mo)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gates 5-6 in the M5 filter: block 'high' vol regime entries (chop transition — 61% WR, negative total while low/normal/extreme all positive) and block SELLs with RSI<25 (dip already extended, mean- reverts before target). 365d: 419→311 trades, +41→+48.4R, DD 11.2→8.0R. Replace the fixed RR-0.5 take-profit with a trailing stop: arm at +0.6R favorable excursion, trail 0.75R behind best price, stop levels update at bar close (conservative). The RR-0.5 target was the binding constraint — winners kept running after TP. Whole trail parameter grid (arm 0.4-1.2 × dist 0.5-1.25) lands +90-103R, a plateau not a spike; also +91.7R on the pre-gate 419-trade population. TP1 stays advisory; Telegram alerts include the arm price and trail distance. max_hold 200→300 bars (trailing exits need room; +10R). 12mo M5, same 311 trades: +105.0R total, PF 2.27, DD 7.9R, 61% WR, train/val +54.6/+50.4, every quarter positive. --- backtest/gpu_backtest.py | 115 +++++++++++++++++++++++++++++++++++++-- config.py | 19 +++++++ signal_aggregator.py | 27 +++++++++ telegram_bot.py | 16 ++++++ 4 files changed, 171 insertions(+), 6 deletions(-) diff --git a/backtest/gpu_backtest.py b/backtest/gpu_backtest.py index 13ad53c..5e08fb9 100644 --- a/backtest/gpu_backtest.py +++ b/backtest/gpu_backtest.py @@ -1161,16 +1161,108 @@ def _build_indicator_breakdown( return breakdown, agree, oppose, neutral, total +def _simulate_trailing( + trades: List[Trade], + opens_arr: np.ndarray, + highs_arr: np.ndarray, + lows_arr: np.ndarray, + closes_arr: np.ndarray, + valid_lens: np.ndarray, + exit_times: List[List[pd.Timestamp]], + spread: float, + trail_arm_r: float, + trail_dist_r: float, +) -> None: + """Resolve trades with a trailing stop (per-trade CPU loop). + + Exit reasons: "SL" (original stop, full loss), "TRAIL" (trailed stop + filled at/above entry region), "TIMEOUT". + """ + for i, t in enumerate(trades): + n = int(valid_lens[i]) + if n == 0: + t.exit_reason = "NO_DATA" + continue + + o = opens_arr[i, :n] + h = highs_arr[i, :n] + l = lows_arr[i, :n] + c = closes_arr[i, :n] + buy = t.direction == "BUY" + risk = abs(t.entry_price - t.sl) + if risk <= 0: + t.exit_reason = "NO_DATA" + continue + + cur_sl = t.sl + peak = t.entry_price + exit_price = None + exit_idx = n - 1 + reason = "TIMEOUT" + for j in range(n): + if np.isnan(h[j]): + break + hit = (l[j] <= cur_sl) if buy else (h[j] >= cur_sl) + if hit: + exit_price = cur_sl + # Gap through the stop → filled at the open. + if (buy and o[j] < cur_sl) or (not buy and o[j] > cur_sl): + exit_price = o[j] + reason = "SL" if cur_sl == t.sl else "TRAIL" + exit_idx = j + break + # Bar closed: advance peak, then ratchet the stop for the + # NEXT bar. + fav = h[j] if buy else l[j] + 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) + cur_sl = max(cur_sl, lvl) if buy else min(cur_sl, lvl) + + if exit_price is None: + exit_price = float(c[exit_idx]) + t.exit_reason = reason + t.bars_held = exit_idx + 1 + t.exit_price = float(exit_price) + t.exit_time = exit_times[i][exit_idx] + + pnl = ((t.exit_price - t.entry_price) if buy + else (t.entry_price - t.exit_price)) + t.r_multiple = (pnl - spread) / risk + + h_win = h[:t.bars_held] + l_win = l[:t.bars_held] + if buy: + mfe = (np.nanmax(h_win) - t.entry_price) / risk + mae = (t.entry_price - np.nanmin(l_win)) / risk + else: + mfe = (t.entry_price - np.nanmin(l_win)) / risk + mae = (np.nanmax(h_win) - t.entry_price) / risk + t.mfe_r = float(max(0.0, mfe)) + t.mae_r = float(max(0.0, mae)) + + def simulate_trades( trades: List[Trade], sim_df: pd.DataFrame, max_bars: int = 200, spread: float = 0.0, + trail_arm_r: float = 0.0, + trail_dist_r: float = 0.75, ) -> None: - """Walk forward bar-by-bar after each entry and resolve SL / TP1. + """Walk forward bar-by-bar after each entry and resolve the exit. - Vectorized on GPU when CuPy is available: builds (n_trades × max_bars) - matrices of post-entry highs/lows and finds the first hit per trade. + Two exit modes: + - Static (trail_arm_r <= 0): resolve SL / TP1, vectorized on GPU when + CuPy is available. + - Trailing (trail_arm_r > 0): TP1 is advisory. Once favorable + excursion reaches trail_arm_r × risk, the stop trails + trail_dist_r × risk behind the best price. Stop levels update at + bar CLOSE (intra-bar sequence is unknowable), so a new stop can + only fill from the next bar on — conservative. SL/TP-same-bar tie: SL assumed first (conservative). Gap-through fills: a bar *opening* beyond SL/TP fills at the open (worse @@ -1217,6 +1309,13 @@ def simulate_trades( closes_arr[i, :n] = sim_close[entry_pos:end_pos] exit_times.append(list(sim_index[entry_pos:end_pos])) + if trail_arm_r > 0: + _simulate_trailing( + trades, opens_arr, highs_arr, lows_arr, closes_arr, + valid_lens, exit_times, spread, trail_arm_r, trail_dist_r, + ) + return + # GPU vectorized: for each trade, scan its window for first SL/TP hit. sls = np.array([t.sl for t in trades], dtype=np.float64)[:, None] tps = np.array([t.tp1 for t in trades], dtype=np.float64)[:, None] @@ -1336,7 +1435,9 @@ class BacktestConfig: ) warmup_bars: int = 250 # min bars before first signal (EMA200 warmup) cooldown_bars: int = 5 # min bars between trades on same pair - max_hold_bars: int = 200 # cap simulation length per trade + max_hold_bars: int = 300 # cap simulation length per trade + # (300: trailing exits need room to run; + # 200→300 adds ~10R on 12mo M5) use_cache: bool = True quality_filter: List[str] = field( default_factory=lambda: ["A+", "A", "B", "C"] @@ -1604,7 +1705,9 @@ def run_backtest( ) t_sim = time.perf_counter() simulate_trades(trades, sim_df, max_bars=bt_cfg.max_hold_bars, - spread=bt_cfg.spread) + spread=bt_cfg.spread, + trail_arm_r=platform_cfg.signals.trail_arm_r, + trail_dist_r=platform_cfg.signals.trail_dist_r) logger.info( f"Trade simulation took {time.perf_counter() - t_sim:.2f}s " f"(GPU={'yes' if _GPU_AVAILABLE else 'no'})" @@ -1926,7 +2029,7 @@ def _parse_args(argv: Optional[List[str]] = None) -> argparse.Namespace: p.add_argument("--confirm-tfs", default="1h,4h", help="Comma-separated confirmation TFs") p.add_argument("--warmup-bars", type=int, default=250) p.add_argument("--cooldown-bars", type=int, default=5) - p.add_argument("--max-hold-bars", type=int, default=200) + p.add_argument("--max-hold-bars", type=int, default=300) p.add_argument("--swing-sr-lookback", type=int, default=0, help="Override TechnicalConfig.swing_sr_lookback (0 = use config default)") p.add_argument("--quality", default="A+,A,B,C", help="Comma-separated allowed grades") diff --git a/config.py b/config.py index 333bd3b..60f7450 100755 --- a/config.py +++ b/config.py @@ -138,6 +138,17 @@ class SignalConfig: # TP1 floor as a fraction of SL distance (0.5 = historical RR 0.5). tp_floor_rr: float = 0.5 + # ── Trailing-stop exit (2026-07) ────────────────────────────── + # Once favorable excursion reaches trail_arm_r × risk, the stop + # trails trail_dist_r × risk behind the best price (updated at bar + # close). TP1 becomes advisory — the trail is the exit. Validated + # on 12mo M5: +105R vs +48R with the fixed RR-0.5 target, balanced + # train/val, positive every quarter, PF 2.27. The RR-0.5 target was + # the binding constraint — winners kept running after TP. + # 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 + @dataclass class NewsConfig: @@ -193,6 +204,14 @@ class XauStrategyConfig: # the ATR-based setup generator; B-grade swing setups survive # because they catch moderate drops, not extremes. m5_sell_max_quality: List[str] = field(default_factory=lambda: ["B", "C"]) + # 'high' vol regime on M5 is chop transition — elevated noise + # without directional expansion. 61% WR / negative total across + # 12mo while low/normal/extreme are all positive (validated 2026-07). + m5_blocked_vol_regimes: List[str] = field(default_factory=lambda: ["high"]) + # Block M5 SELLs when RSI is already < this floor: the dip is + # extended and mean-reverts before the target (negative in both + # halves of 6mo and 12mo backtests). + m5_sell_min_rsi: float = 25.0 @dataclass diff --git a/signal_aggregator.py b/signal_aggregator.py index ee783d1..15ae197 100755 --- a/signal_aggregator.py +++ b/signal_aggregator.py @@ -350,6 +350,33 @@ class SignalAggregator: report.trade_setup = None return + # Gate 5: block chop-transition volatility. 'high' vol on M5 is + # elevated noise without the directional expansion of 'extreme' + # — stops get swept before targets (61% WR, negative total in + # every backtest window; low/normal/extreme all positive). + if report.volatility_regime in xau_cfg.m5_blocked_vol_regimes: + report.warnings.append( + f"M5 FILTER: '{report.volatility_regime}' volatility — " + f"chop-transition regime, stops get swept" + ) + report.signal_quality = "D" + report.trade_setup = None + return + + # Gate 6: SELL RSI floor — shorting with RSI already < 25 means + # entering at the extreme of the dip (same pathology as Gate 4, + # visible through RSI instead of quality grade). + if is_sell: + rsi_ind = report.primary_analysis.indicators.get("rsi") + if rsi_ind is not None and rsi_ind.value < xau_cfg.m5_sell_min_rsi: + report.warnings.append( + f"M5 FILTER: SELL blocked — RSI {rsi_ind.value:.0f} < " + f"{xau_cfg.m5_sell_min_rsi:.0f}, dip already extended" + ) + report.signal_quality = "D" + report.trade_setup = None + return + # ── Public: bar-replayable entry point ───────────────────────── def analyze_from_dataframes( diff --git a/telegram_bot.py b/telegram_bot.py index ef268dc..696376c 100755 --- a/telegram_bot.py +++ b/telegram_bot.py @@ -254,6 +254,22 @@ def format_signal_message(report: SignalReport) -> str: lines.append(f"🎯 TP2: `{_fmt_price(ts.take_profit_2)}`") 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). + sig_cfg = PlatformConfig().signals + if sig_cfg.trail_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 + else: + arm_price = ts.entry_price - sig_cfg.trail_arm_r * risk_dist + trail_dist = sig_cfg.trail_dist_r * risk_dist + 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"behind best price — TP1 is a guide, let it run" + ) + lines += ["", "━━━━━━━━━━━━━━━━━━━━", "_Educational / research use only._"]