diff --git a/CLAUDE.md b/CLAUDE.md index 7d54eed..ec6f9d4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/backtest_sl_width.py b/backtest_sl_width.py new file mode 100644 index 0000000..8020f73 --- /dev/null +++ b/backtest_sl_width.py @@ -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()