SL-Breite gemessen: Confounder bestaetigt, aber keine Aenderung (Regel greift)

Anlass: analyze_manual_close.py fand +1.229 EUR auf der Verlustseite, mit dem
explizit benannten Confounder, dass simulate() 2,0xATR_M5 ansetzt waehrend live
ATR_M15 gilt. backtest_sl_width.py trennt beides: gegatete Live-Population
(1.224 Entries, Reversal aus, k=0,3), kanonischer Exit, variiert wird
ausschliesslich die SL-Breite.

ATR_M15 / ATR_M5 = 1,80 im Median -> der Live-SL ist rund 3,6xATR_M5 breit,
nicht 2,0. Genau daraus entstanden die 1.229 EUR. Confounder bestaetigt.

  SL-Basis            H1 OR/SR        H2 OR/SR        Worst H1/H2
  2,0xATR_M5        -0,092 / -44    -0,024 / -18     -2,33 / -2,29
  2,0xATR_M15 LIVE  -0,145 / -69    +0,064 / +48     -4,77 / -5,64
  1,5xATR_M5        -0,081 / -38    -0,038 / -28     -1,83 / -1,85
  2,5xATR_M5        -0,121 / -58    -0,013 / -10     -2,83 / -2,79
  3,0xATR_M5        -0,130 / -62    +0,018 / +13     -3,33 / -3,29

KEINE engere Variante schlaegt den Live-SL in BEIDEN Haelften - H2 bevorzugt den
weiten Stop klar. Die vorab fixierte Regel ist nicht erfuellt: keine Aenderung.
Das reproduziert backtest_sl_tf_mismatch.py (23.07., dort auf Squeeze-Entries mit
eigenem Exit-Nachbau) jetzt auf der gegateten Population mit kanonischem Exit -
der alte Schluss war korrekt.

Der echte Unterschied ist das TAIL-RISIKO und damit eine SIZING-Frage:
Worst-Case -5,64xATR (live) gegen -1,85xATR (bei 1,5xATR_M5), Faktor 3. Bei
ATR 0,30 sind das ~1,7 $; unter 95 %-Margin auf ~800 EUR Konto ist ein solcher
Trade ~19 % des Kontos. Der weite Stop kauft die H2-Performance mit genau diesem
Tail. Wer ihn begrenzen will, senkt margin_buffer_pct - nicht die SL-Breite,
die kostet gemessen H2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Axel Hocks
2026-08-04 13:28:49 +02:00
co-authored by Claude Opus 5
parent 3ec4d53022
commit 20137a3dd1
2 changed files with 222 additions and 0 deletions
+26
View File
@@ -814,6 +814,32 @@ dort bereits nachvalidiert, ØR +0,305.) **Eine** Oberfläche:
⚠ Zweite Grenze: der Vergleich gilt **pro Trade**, nicht als Kontostand-
Hochrechnung — ein länger laufender Trade hätte den Positions-Slot belegt und
Folge-Trades verhindert.
✅ **NACHGEMESSEN (`backtest_sl_width.py`, 2026-08-04): der Confounder war die
Erklärung — aber das Geld ist NICHT einfach zu holen.** Gegatete Live-Population
(1.224 Entries, Reversal aus, k=0,3), kanonischer Exit, variiert wird
AUSSCHLIESSLICH die Breite des Initial-SL:
**ATR_M15 / ATR_M5 = 1,80 im Median** → der Live-SL (2,0×ATR_M15) ist rund
**3,6×ATR_M5** breit, nicht 2,0. Genau daraus entstanden die 1.229 €.
| SL-Basis | H1 ØR / ΣR | H2 ØR / ΣR | Worst H1/H2 |
|---|---|---|---|
| 2,0×ATR_M5 | 0,092 / 44 | 0,024 / 18 | 2,33 / 2,29 |
| **2,0×ATR_M15 (LIVE)** | 0,145 / 69 | **+0,064 / +48** | **4,77 / 5,64** |
| 1,5×ATR_M5 | 0,081 / 38 | 0,038 / 28 | **1,83 / 1,85** |
| 2,5×ATR_M5 | 0,121 / 58 | 0,013 / 10 | 2,83 / 2,79 |
| 3,0×ATR_M5 | 0,130 / 62 | +0,018 / +13 | 3,33 / 3,29 |
**KEINE engere Variante schlägt den Live-SL in BEIDEN Hälften** — H2 bevorzugt den
weiten Stop klar (+48 gegen 18). Die vorab fixierte Regel („nur wenn beidhälftig
mehr ΣR UND Worst-Case nicht schlechter") ist damit **nicht erfüllt: keine
Änderung.** Das **reproduziert `backtest_sl_tf_mismatch.py`** (2026-07-23, dort auf
Squeeze-Entries mit eigenem Exit-Nachbau) jetzt auf der gegateten Population mit
dem kanonischen Exit — der alte Schluss war korrekt.
⚠⚠ **Der echte Unterschied ist das TAIL-RISIKO, und das ist eine SIZING-Frage:**
Worst-Case **5,64×ATR** (live) gegen **1,85×ATR** (bei 1,5×ATR_M5) — Faktor 3.
Bei ATR 0,30 sind das ~1,7 $ Bewegung; unter 95 %-Margin-Sizing auf ~800 € Konto
ist ein einzelner solcher Trade rund **19 % des Kontos**. Der weite Stop kauft die
H2-Performance mit genau diesem Tail. Damit landet die Frage dort, wo CLAUDE.md sie
ohnehin verortet: **„Der Hebel ist Sizing, nicht der Exit."** Wer den Tail begrenzen
will, senkt `margin_buffer_pct` — nicht die SL-Breite (die kostet gemessen H2).
- **Mindestgewinn für den S/R-Close = VERWORFEN (gemessen `backtest_srclose_prob.py`,
Mindestgewinn-Sektion):** „nur closen wenn ≥minR im Plus" verliert **monoton in
BEIDEN Hälften** (minR 0,2: 444/213 · 0,5: 1425/854 R vs minR=0). Die vielen
+196
View File
@@ -0,0 +1,196 @@
#!/usr/bin/env python3
"""SL-Breite: ATR_M15 (live) gegen ATR_M5 (kanonisch) — 2026-08-04.
Anlass: `analyze_manual_close.py` zeigte, dass die 69 per Broker-SL geschlossenen
Trades mit dem kanonischen Exit **1.229 € weniger** verloren hätten. Der dortige
Confounder war explizit benannt: `simulate()` setzt den Initial-SL auf
**2,0×ATR_M5**, live kommt er aus **ATR_M15** (`_calc_sl_tp`, SL_TF fix M15) und
ist damit oft deutlich weiter — unter Margin-Sizing schlägt das voll durch.
Diese Messung trennt beides.
⚠ `backtest_sl_tf_mismatch.py` hat die M5-Umstellung 2026-07-23 schon geprüft und
wegen H2 verworfen — aber NUR auf Squeeze-Entries und mit einem eigenen
Exit-Nachbau. Hier: die **gegatete Live-Signal-Population** und der **kanonische**
Exit. Damit ist es keine Wiederholung, sondern die Messung unter den Bedingungen,
unter denen der Bot tatsächlich läuft.
Variiert wird AUSSCHLIESSLICH die Breite des Initial-SL; Trailing, Breakeven,
Time-Stop und TP bleiben identisch — sonst wäre nicht zuzuordnen, was wirkt.
Alle Ergebnisse in **ATR_M5-Einheiten**, damit die Varianten vergleichbar sind.
Aufruf: python backtest_sl_width.py [bars]
"""
import dataclasses
import statistics as stx
import sys
import MetaTrader5 as mt5
from core.analysis import calc_trend_angle
from core.exit_model import LIVE, simulate
from core.wave_rec import (WaveRecommender, _ANGLE_LR, _EMA_FAST, _EMA_SLOW,
_HTF_DEADBAND, _ema_series)
_ATRMIN = 0.06
_COOL = 3
_PIVK = 3
_PIV_LOOK = 300
class _TU:
def snapshot(self):
return {}
def _atr_series(H, L, C, p=14):
t = [0.0]
for i in range(1, len(C)):
t.append(max(H[i] - L[i], abs(H[i] - C[i - 1]), abs(L[i] - C[i - 1])))
return [(sum(t[max(1, i - p + 1):i + 1]) / max(1, len(t[max(1, i - p + 1):i + 1])))
if i else None for i in range(len(C))]
def _atr_m15(H, L, C, N):
"""ATR_M15 je M5-Bar (kausal): aus 3er-Aggregaten, auf M5 zurückgemappt."""
HH = [max(H[i:i + 3]) for i in range(0, N - 2, 3)]
LL = [min(L[i:i + 3]) for i in range(0, N - 2, 3)]
CC = [C[min(i + 2, N - 1)] for i in range(0, N - 2, 3)]
a15 = _atr_series(HH, LL, CC)
out = [None] * N
for j, v in enumerate(a15):
for k in range(j * 3, min(j * 3 + 3, N)):
out[k] = v
return out
def _htf_sign(C, i, n, atr):
lo = max(0, i - n * (_EMA_SLOW + 5))
sub = C[lo:i + 1:n]
if len(sub) < _EMA_SLOW + 2 or not atr:
return 0
d = (_ema_series(sub, _EMA_FAST)[-1] - _ema_series(sub, _EMA_SLOW)[-1]) / atr
return 0 if abs(d) < _HTF_DEADBAND else (1 if d > 0 else -1)
def st(Rs):
if not Rs:
return None
n = len(Rs); w = sum(1 for x in Rs if x > 0); s = sum(Rs)
up = sum(x for x in Rs if x > 0); dn = -sum(x for x in Rs if x < 0)
return dict(n=n, wr=100 * w / n, oR=s / n, pf=(up / dn if dn > 0 else 9.99),
sum=s, worst=min(Rs))
def main():
n_req = int(sys.argv[1]) if len(sys.argv) > 1 else 80000
mt5.initialize()
sym = next((c for c in ("SpotCrude", "USOIL", "WTI", "XTIUSD")
if mt5.symbol_info(c)), None)
bars = mt5.copy_rates_from_pos(sym, mt5.TIMEFRAME_M5, 0, n_req)
point = mt5.symbol_info(sym).point
mt5.shutdown()
H = [float(b["high"]) for b in bars]; L = [float(b["low"]) for b in bars]
C = [float(b["close"]) for b in bars]; SP = [float(b["spread"]) * point for b in bars]
N = len(C); mid = N // 2
A = _atr_series(H, L, C)
A15 = _atr_m15(H, L, C, N)
EF = _ema_series(C, _EMA_FAST); ES = _ema_series(C, _EMA_SLOW)
verh = [A15[i] / A[i] for i in range(200, N)
if A[i] and A15[i] and A[i] >= _ATRMIN]
print(f"ATR_M15 / ATR_M5 — Median {stx.median(verh):.2f} "
f"10./90. Perzentil {sorted(verh)[len(verh)//10]:.2f} / "
f"{sorted(verh)[9*len(verh)//10]:.2f}")
print(f"→ Der Live-SL (2,0×ATR_M15) ist im Median rund "
f"{2.0*stx.median(verh):.1f}×ATR_M5 breit, der kanonische 2,0.\n")
print("Vorberechnung (Winkel, HTF, Pivots) ...", flush=True)
AD = [90.0] * N
for i in range(_ANGLE_LR + 2, N):
AD[i] = calc_trend_angle(C[i - (_ANGLE_LR + 1):i + 1], _ANGLE_LR)
HTF = [0] * N; H1 = [0] * N
for i in range(N):
if A[i]:
HTF[i] = _htf_sign(C, i, 6, A[i]); H1[i] = _htf_sign(C, i, 12, A[i])
PH = [j for j in range(_PIVK, N - _PIVK) if H[j] == max(H[j - _PIVK:j + _PIVK + 1])]
PL = [j for j in range(_PIVK, N - _PIVK) if L[j] == min(L[j - _PIVK:j + _PIVK + 1])]
w = WaveRecommender(_TU(), mt5.TIMEFRAME_M5)
w.set_reversal_enabled(False); w.set_entry_room(0.6)
w.set_dead_hours([]); w.set_breakout_k(0.3)
# Entries EINMAL bestimmen — alle Varianten sehen dieselbe Liste (sonst wäre
# nicht der SL verglichen, sondern eine andere Trade-Folge).
print("Entries bestimmen ...", flush=True)
entries = []
pend = None
i = _PIV_LOOK + _EMA_SLOW + 5
while i < N - LIVE.max_hold - 2:
atr = A[i]
if not atr or atr < _ATRMIN or not A15[i]:
i += 1; continue
w._pb_feats = {"atr": max(atr, 0.12)}
w._pb_levels = {"ph": [C[j] for j in PH if i - _PIV_LOOK <= j <= i - _PIVK],
"pl": [C[j] for j in PL if i - _PIV_LOOK <= j <= i - _PIVK]}
rec, _ = w._build(EF[i], ES[i], C[i], atr, "M5", 5,
htf_trend=HTF[i], h1_trend=H1[i], angle=AD[i])
sig = rec.get("signal")
if sig in ("LONG", "SHORT"):
d = 1 if sig == "LONG" else -1
if pend is None or pend["dir"] != d:
pend = {"dir": d, "level": C[i] + d * 0.3 * atr,
"invalid": C[i] - d * 0.3 * atr, "confirmed": False, "bar": i}
if not pend["confirmed"]:
broke = (C[i] >= pend["level"]) if d > 0 else (C[i] <= pend["level"])
if broke:
pend["confirmed"] = True
else:
if ((C[i] <= pend["invalid"]) if d > 0 else (C[i] >= pend["invalid"])) \
or (i - pend["bar"]) > 12:
pend = {"dir": d, "level": C[i] + d * 0.3 * atr,
"invalid": C[i] - d * 0.3 * atr,
"confirmed": False, "bar": i}
i += 1; continue
rec = w._room_gate(rec, C[i])
if rec.get("signal") in ("LONG", "SHORT"):
entries.append((i, d, C[i], atr, A15[i]))
pend = None
i += LIVE.timestop_bars # fester Vorlauf, für alle Varianten gleich
continue
elif sig == "WARTEN":
pend = None
i += 1
print(f"{len(entries)} Entries (gegatet, Reversal aus, k=0,3)\n")
varianten = [
("A SL 2,0×ATR_M5 (kanonisch)", lambda a5, a15: 2.0),
("B SL 2,0×ATR_M15 (LIVE)", lambda a5, a15: 2.0 * a15 / a5),
("C SL 1,5×ATR_M5", lambda a5, a15: 1.5),
("D SL 2,5×ATR_M5", lambda a5, a15: 2.5),
("E SL 3,0×ATR_M5", lambda a5, a15: 3.0),
]
print(f"{'Variante':<32}{'Hälfte':<10}{'n':>6}{'WR':>6}{'ØR':>9}{'PF':>7}"
f"{'ΣR':>9}{'Worst':>8}")
print("-" * 88)
for lbl, f in varianten:
for hl, lo, hi in (("H1 (alt)", 0, mid), ("H2 (neu)", mid, N)):
Rs = []
for (i, d, px, a5, a15) in entries:
if not (lo <= i < hi):
continue
p = dataclasses.replace(LIVE, sl_atr=max(0.5, f(a5, a15)))
r, _ = simulate(px, d, a5, H, L, C, i + 1, p,
timestop=True, use_tp=True, ret_bar=True)
Rs.append(r - (SP[i] if SP[i] > 0 else 0.0225) / a5)
s = st(Rs)
if s:
print(f"{lbl:<32}{hl:<10}{s['n']:>6}{s['wr']:>5.0f}%{s['oR']:>+9.3f}"
f"{s['pf']:>7.2f}{s['sum']:>+9.0f}{s['worst']:>+8.2f}")
print()
print(" Regel (vorab): eine engere SL-Basis wird nur empfohlen, wenn sie in BEIDEN")
print(" Hälften mehr ΣR liefert UND der Worst-Case nicht schlechter wird.")
print()
if __name__ == "__main__":
main()