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:
+109
-6
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user