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.
This commit is contained in:
harwinder
2026-07-09 13:34:57 -04:00
parent 8687f98b39
commit 0393087168
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
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")