Add M5 gates 5-6 and trailing-stop exits (+48R → +105R on 12mo)

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.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
harwinder
2026-07-09 13:34:57 -04:00
parent db16f209bb
commit 987ec5bbb0
4 changed files with 171 additions and 6 deletions
+109 -6
View File
@@ -1161,16 +1161,108 @@ def _build_indicator_breakdown(
return breakdown, agree, oppose, neutral, total 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( def simulate_trades(
trades: List[Trade], trades: List[Trade],
sim_df: pd.DataFrame, sim_df: pd.DataFrame,
max_bars: int = 200, max_bars: int = 200,
spread: float = 0.0, spread: float = 0.0,
trail_arm_r: float = 0.0,
trail_dist_r: float = 0.75,
) -> None: ) -> 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) Two exit modes:
matrices of post-entry highs/lows and finds the first hit per trade. - 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). SL/TP-same-bar tie: SL assumed first (conservative).
Gap-through fills: a bar *opening* beyond SL/TP fills at the open (worse 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] closes_arr[i, :n] = sim_close[entry_pos:end_pos]
exit_times.append(list(sim_index[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. # 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] 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] 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) warmup_bars: int = 250 # min bars before first signal (EMA200 warmup)
cooldown_bars: int = 5 # min bars between trades on same pair 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 use_cache: bool = True
quality_filter: List[str] = field( quality_filter: List[str] = field(
default_factory=lambda: ["A+", "A", "B", "C"] default_factory=lambda: ["A+", "A", "B", "C"]
@@ -1604,7 +1705,9 @@ def run_backtest(
) )
t_sim = time.perf_counter() t_sim = time.perf_counter()
simulate_trades(trades, sim_df, max_bars=bt_cfg.max_hold_bars, 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( logger.info(
f"Trade simulation took {time.perf_counter() - t_sim:.2f}s " f"Trade simulation took {time.perf_counter() - t_sim:.2f}s "
f"(GPU={'yes' if _GPU_AVAILABLE else 'no'})" 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("--confirm-tfs", default="1h,4h", help="Comma-separated confirmation TFs")
p.add_argument("--warmup-bars", type=int, default=250) p.add_argument("--warmup-bars", type=int, default=250)
p.add_argument("--cooldown-bars", type=int, default=5) 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, p.add_argument("--swing-sr-lookback", type=int, default=0,
help="Override TechnicalConfig.swing_sr_lookback (0 = use config default)") help="Override TechnicalConfig.swing_sr_lookback (0 = use config default)")
p.add_argument("--quality", default="A+,A,B,C", help="Comma-separated allowed grades") p.add_argument("--quality", default="A+,A,B,C", help="Comma-separated allowed grades")
+19
View File
@@ -138,6 +138,17 @@ class SignalConfig:
# TP1 floor as a fraction of SL distance (0.5 = historical RR 0.5). # TP1 floor as a fraction of SL distance (0.5 = historical RR 0.5).
tp_floor_rr: float = 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 @dataclass
class NewsConfig: class NewsConfig:
@@ -193,6 +204,14 @@ class XauStrategyConfig:
# the ATR-based setup generator; B-grade swing setups survive # the ATR-based setup generator; B-grade swing setups survive
# because they catch moderate drops, not extremes. # because they catch moderate drops, not extremes.
m5_sell_max_quality: List[str] = field(default_factory=lambda: ["B", "C"]) 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 @dataclass
+27
View File
@@ -350,6 +350,33 @@ class SignalAggregator:
report.trade_setup = None report.trade_setup = None
return 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 ───────────────────────── # ── Public: bar-replayable entry point ─────────────────────────
def analyze_from_dataframes( def analyze_from_dataframes(
+16
View File
@@ -254,6 +254,22 @@ def format_signal_message(report: SignalReport) -> str:
lines.append(f"🎯 TP2: `{_fmt_price(ts.take_profit_2)}`") lines.append(f"🎯 TP2: `{_fmt_price(ts.take_profit_2)}`")
lines.append(f"⚖️ RR: 1:{ts.risk_reward:.1f}") 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 += ["", "━━━━━━━━━━━━━━━━━━━━", lines += ["", "━━━━━━━━━━━━━━━━━━━━",
"_Educational / research use only._"] "_Educational / research use only._"]