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