From a6c43183e2ea2be36f291e3bd4cc29a8b1eccd5b Mon Sep 17 00:00:00 2001 From: Axel Hocks Date: Wed, 5 Aug 2026 17:34:23 +0200 Subject: [PATCH] SL-Basis M5 vs M15 auf Level-Entries gemessen: Tail-Frage, keine Ertragsfrage Strukturfund vorweg: exit_model.simulate nimmt EINEN atr fuer SL, TP, Trail und Breakeven - und alle Backtests uebergeben ATR_M5. Live kommt der SL aus ATR_M15 (Median 1,80x groesser). Praktisch jede Projekt-Messung modelliert damit 2,0xATR_M5, waehrend live ~4,0xATR_M5 gilt. Einzige bisherige Ausnahme war backtest_sl_width.py. Gemessen auf der NEUEN Level-Entry-Population (80k, 2 Halbjahre, nur die SL-Basis variiert): SQUEEZE: Ertrag ist ein Wash - alle Varianten innerhalb eines Standardfehlers, und die einzige beidhaelftig bessere Zelle (2,5) hat schlechtere Nachbarn. WELLE: der weite Live-SL ist in BEIDEN Haelften der beste, jede Verengung kostet. Der einzige robuste Unterschied ist das Tail-Risiko: Worst-Case -8,83 xATR (live, Squeeze H2) gegen -2,94 bei 2,5xATR_M5 = Faktor 3. Bei 3,5 Lots sind das ~-890 EUR gegen ~-300 EUR im schlechtesten Einzeltrade. Antwort auf die Frage: vertretbar ja, aber als Risiko- und nicht als Ertragsentscheidung. Saubere Umsetzung waere quellen-abhaengig (M5-ATR nur bei auto_squeeze) - strukturell begruendet, nicht data-mined. Nicht gebaut. Reproduziert NICHT backtest_sl_tf_mismatch.py (23.07.): dort Einstieg am Bar-Close, hier am Level - der Einstiegspreis hat den Befund gedreht. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 39 +++++++++- backtest_sl_basis.py | 172 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 210 insertions(+), 1 deletion(-) create mode 100644 backtest_sl_basis.py diff --git a/CLAUDE.md b/CLAUDE.md index 9a0bc39..617cd12 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1178,7 +1178,44 @@ dort bereits nachvalidiert, ØR +0,305.) **Eine** Oberfläche: ist der Unterschied zwischen „Wash" und „Edge zerstört"** — ein ungegateter Level-TP ist etwas grundlegend anderes. Damit ist die User-Idee in allen DREI Teilen durchgemessen; der Live-Exit-Stack bleibt unverändert. - - **S/R-verankerter SL = KEIN Edge (gemessen, `backtest_sl_method.py`):** + - **⚠⚠ SL-BASIS M5 vs. M15 auf der NEUEN Level-Entry-Population (gemessen + `backtest_sl_basis.py`, 2026-08-05, User-Frage „wäre M5-ATR vertretbar?") — + DIFFERENZIERT, und dabei eine Mess-Betriebs-Lücke gefunden.** + ⚠ **Zuerst der strukturelle Fund:** `exit_model.simulate()` nimmt **EINEN** `atr` + für SL, TP, Trail und Breakeven — und **alle Backtests übergeben ATR_M5**. Live + kommt der SL dagegen aus **ATR_M15** (`mt5_utils.atr_value` mit `SL_TF`), im + Median **1,80×** so groß. Praktisch jede Projekt-Messung modelliert damit einen + SL von 2,0×ATR_M5, während live **~4,0×ATR_M5** gilt. Einzige bisherige Ausnahme: + `backtest_sl_width.py` (04.08.), das genau das explizit verglichen hat. + **Ergebnis auf Level-Entries (80k, 2 Halbjahre, nur die SL-Basis variiert):** + | Population | Variante | H1 ØR / Worst | H2 ØR / Worst | + |---|---|---|---| + | **SQUEEZE** | LIVE (≈4,1×ATR_M5) | +0,455 / **−5,87** | +0,614 / **−8,83** | + | | 2,5×ATR_M5 | +0,459 / −2,82 | +0,620 / −2,94 | + | | 2,0×ATR_M5 | +0,452 / −2,32 | +0,610 / −2,44 | + | **WELLE** | LIVE (≈3,9×ATR_M5) | **+0,371** / −4,97 | **+0,356** / −6,00 | + | | 2,5×ATR_M5 | +0,362 / −2,79 | +0,343 / −2,72 | + | | 3,0×ATR_M5 | +0,358 / −3,29 | +0,352 / −3,22 | + ✅ **Ertragsseitig ist es ein WASH auf der Squeeze-Population** — alle Varianten + liegen innerhalb eines Standardfehlers (≈0,035 bei n≈800), und die einzige Zelle, + die den Live-Stand in ØR beidhälftig schlägt (2,5), hat **schlechtere Nachbarn** + → nach der Nachbar-Regel Rauschen. **Auf der Wellen-Population ist der weite + Live-SL dagegen in BEIDEN Hälften der beste** — jede Verengung kostet dort. + ⚠⚠ **Der EINZIGE robuste Unterschied ist das TAIL-RISIKO — und der ist groß und + monoton:** Worst-Case **−8,83×ATR** (live, Squeeze H2) gegen **−2,94** bei + 2,5×ATR_M5, also **Faktor 3**. Bei 3,5 Lots und ATR_M5 0,33 sind das ~**−890 €** + gegen ~−300 € im einzelnen schlechtesten Trade. + ✅ **Antwort: „vertretbar" ja — aber es ist keine Ertrags-, sondern eine + Risiko-Entscheidung.** Für den Squeeze kostet die Verengung gemessen nichts und + drittelt den Tail; für Wellen-/Signal-Trades kostet sie Ertrag in beiden Hälften. + Eine saubere Umsetzung wäre **quellen-abhängig** (`_calc_sl_tp` nutzt M5-ATR nur + bei `source="auto_squeeze"`) — strukturell begründet (Breakout- gegen + Pullback-Einstieg haben verschiedene Nachlauf-Dynamik), nicht data-mined. + **Nicht gebaut** — die Entscheidung über Tail-Risiko gehört dem User. + ⚠ Das reproduziert NICHT `backtest_sl_tf_mismatch.py` (23.07., dort war M5 in H2 + schlechter): dort wurde am Bar-Close eingestiegen, hier am Level. Der + Einstiegspreis hat den Befund gedreht. +- **S/R-verankerter SL = KEIN Edge (gemessen, `backtest_sl_method.py`):** Pivot+Band vs. „S/R ∓ ATR/0,10-Puffer" vs. fest 2,0×ATR — **gedeckelt alle identisch** (Ø-R 0,196–0,201, PF 1,38–1,39, Worst −2,2×ATR): der **Band-Cap [1,8–2,2]×ATR dominiert**, die Verankerung ist Rauschen. Nur **ungedeckelt** diff --git a/backtest_sl_basis.py b/backtest_sl_basis.py new file mode 100644 index 0000000..51d9f3a --- /dev/null +++ b/backtest_sl_basis.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +"""SL-Basis M5 gegen M15 — auf der NEUEN Level-Entry-Population (2026-08-05). + +FRAGE (User): „Wäre SL-Breite aus M5-ATR vertretbar?" + +⚠⚠ BEIM PRÜFEN AUFGEFALLEN — eine Lücke zwischen Messung und Betrieb: +`exit_model.simulate()` nimmt EINEN `atr` für SL, TP, Trail und Breakeven. **Alle +Backtests übergeben ATR_M5.** Live kommt der SL aber aus **ATR_M15** +(`mt5_utils.atr_value(sym)` mit `SL_TF`), im Median **1,80× so groß**. Damit +modellieren praktisch alle Messungen des Projekts einen SL von 2,0×ATR_M5 — +während live 1,8–2,2×ATR_M15 ≈ **3,2–4,0×ATR_M5** gilt. +Einzige Ausnahme: `backtest_sl_width.py` (04.08.) hat genau das explizit gemessen +und die Verengung verworfen (H2 bevorzugte den weiten Stop klar: +48 gegen −18). + +WARUM ES SICH TROTZDEM ZU WIEDERHOLEN LOHNT: jene Messung lief auf Entries **am +Bar-Close**. Seit dem 05.08. steigen beide Auto-Pfade über ruhende Stop-Orders +**am Level** ein — eine andere Einstiegs-Population mit anderem Verlauf danach +(ØR kippte dadurch von −0,09 auf +0,35). Ob der weite Stop auch dort noch besser +ist, ist ungemessen. + +MESSUNG: dieselben Level-Entries wie in `backtest_trail_be.py`, Trail/BE/TP +unverändert auf M5-ATR (wie in allen Backtests), variiert wird AUSSCHLIESSLICH die +SL-Basis. Der Live-Fall wird pro Trade exakt nachgebildet: `sl_atr = 2.2 × +(ATR_M15 / ATR_M5)` an genau diesem Bar. + +VORAB FIXIERTE ENTSCHEIDUNGSREGEL: + Umstellen auf M5 nur, wenn es den Live-Stand in ΣR **und** ØR in BEIDEN + Halbjahren und auf BEIDEN Populationen schlägt. Der Worst-Case darf sich dabei + nicht verschlechtern (er wird bei engerem SL ohnehin besser). + ⚠ Wird die Regel verfehlt, ist „vertretbar" trotzdem eine eigene Frage: ein + engerer Stop kauft Tail-Sicherheit gegen Ertrag. Diese Abwägung ist NICHT + gemessen entscheidbar — die Zahlen liefern nur den Preis. + +Aufruf: python backtest_sl_basis.py [bars] +""" +import sys + +import MetaTrader5 as mt5 + +from backtest_cost_gate import _atr_series, kandidaten +from backtest_trail_be import _ema_series, _htf_sign, wellen_entries +from core.analysis import calc_trend_angle +from core.exit_model import LIVE, simulate +from core.wave_rec import (WaveRecommender, _EMA_FAST, _EMA_SLOW, _N_BARS, + _ANGLE_LR, _ANGLE_DEAD) + +_PIV_K, _PIV_LOOK = 3, 300 +_SL_LIVE = 2.2 # `INIT_SL_MAX_ATR` — der live wirksame Deckel + + +def atr_m15_auf_m5(H, L, C): + """ATR(14) auf M15 (3 M5-Bars aggregiert), zurückgemappt auf den M5-Index.""" + oh, ol, oc, idx = [], [], [], [] + for j in range(0, len(C) - 2, 3): + oh.append(max(H[j:j + 3])); ol.append(min(L[j:j + 3])) + oc.append(C[j + 2]); idx.append(j + 2) + tr = [0.0] + [max(oh[i] - ol[i], abs(oh[i] - oc[i - 1]), abs(ol[i] - oc[i - 1])) + for i in range(1, len(oc))] + a15 = [None] + [sum(tr[max(1, i - 13):i + 1]) / len(tr[max(1, i - 13):i + 1]) + for i in range(1, len(oc))] + out = [None] * len(C) + for k, j in enumerate(idx): + for x in range(j, min(j + 3, len(C))): + out[x] = a15[k] + # Vorlauf auffuellen + letzte = None + for i in range(len(out)): + if out[i] is None: + out[i] = letzte + else: + letzte = out[i] + return out + + +def lauf(K, H, L, C, A15, lo, hi, basis): + """basis: float = feste SL-Weite in ×ATR_M5 · 'live' = 2,2×ATR_M15 je Trade.""" + rs = [] + frei = lo + weiten = [] + for k in K: + if not (lo <= k["i"] < hi) or k["i"] < frei: + continue + if basis == "live": + a15 = A15[k["i"]] or k["atr"] + sl = _SL_LIVE * (a15 / k["atr"]) # in ×ATR_M5 ausgedrueckt + else: + sl = float(basis) + weiten.append(sl) + p = LIVE.with_(sl_atr=sl) + r, xb = simulate(k["lvl"], k["d"], k["atr"], H, L, C, k["i"] + 1, p, + timestop=True, use_tp=True, ret_bar=True) + rs.append(r - k["kosten"]) + frei = xb + 1 + return rs, (sum(weiten) / len(weiten) if weiten else 0.0) + + +def kenn(rs): + if not rs: + return None + n = len(rs); s = sum(rs) + g = sum(x for x in rs if x > 0); v = -sum(x for x in rs if x < 0) + return {"n": n, "wr": 100 * sum(1 for x in rs if x > 0) / n, "oer": s / n, + "sr": s, "pf": (g / v) if v > 0 else float("inf"), "worst": min(rs)} + + +def main(): + n_bars = 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_bars) + 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]; T = [int(b["time"]) for b in bars] + SP = [float(b["spread"]) * point for b in bars] + A = _atr_series(H, L, C) + A15 = atr_m15_auf_m5(H, L, C) + EF = _ema_series(C, _EMA_FAST); ES = _ema_series(C, _EMA_SLOW) + AD = [0.0] * len(C) + for i in range(_N_BARS, len(C)): + AD[i] = calc_trend_angle(C[i - _N_BARS:i + 1], _ANGLE_LR) - 90.0 + if abs(AD[i]) < _ANGLE_DEAD: + AD[i] = 0.0 + HTF = [_htf_sign(C, i, 6, A[i] or 0.1) for i in range(len(C))] + H1S = [_htf_sign(C, i, 12, A[i] or 0.1) for i in range(len(C))] + PH = [j for j in range(_PIV_K, len(C) - _PIV_K) + if H[j] == max(H[j - _PIV_K:j + _PIV_K + 1])] + PL = [j for j in range(_PIV_K, len(C) - _PIV_K) + if L[j] == min(L[j - _PIV_K:j + _PIV_K + 1])] + w = WaveRecommender(lambda: None) + w.set_reversal_enabled(False); w.set_entry_room(0.6); w.set_dead_hours("") + mid = len(C) // 2 + + v = [A15[i] / A[i] for i in range(len(C)) if A[i] and A15[i] and A[i] > 0.05] + v.sort() + print("=" * 100) + print(f" SL-BASIS M5 gegen M15 — Level-Entries · {sym} M5, {len(C)} Bars") + print(f" Verhaeltnis ATR_M15/ATR_M5: Median {v[len(v)//2]:.2f} · " + f"Ø {sum(v)/len(v):.2f} → live-SL entspricht ~{_SL_LIVE*v[len(v)//2]:.1f}xATR_M5") + print(" Trail/BE/TP bleiben auf M5-ATR (wie in allen Backtests) — variiert wird NUR der SL.") + print("=" * 100) + + pops = [("SQUEEZE", kandidaten(H, L, C, T, SP, A)), + ("WELLE (gegated)", + wellen_entries(w, H, L, C, A, SP, EF, ES, AD, HTF, H1S, PH, PL))] + for name, K in pops: + print(f"\n {name} — {len(K)} Entries") + print(f" {'SL-Basis':<22}{'Ø Weite':>9}" + f"{'H1 OeR':>9}{'H1 SigmaR':>11}{'H1 Worst':>10}" + f"{'H2 OeR':>9}{'H2 SigmaR':>11}{'H2 Worst':>10}") + for basis, lbl in (("live", "LIVE (2,2xATR_M15)"), (2.0, "2,0xATR_M5"), + (2.5, "2,5xATR_M5"), (3.0, "3,0xATR_M5"), + (3.6, "3,6xATR_M5 (~live)"), (1.5, "1,5xATR_M5")): + r1, w1 = lauf(K, H, L, C, A15, 0, mid, basis) + r2, w2 = lauf(K, H, L, C, A15, mid, len(C), basis) + k1, k2 = kenn(r1), kenn(r2) + if not (k1 and k2): + continue + print(f" {lbl:<22}{(w1+w2)/2:>9.2f}" + f"{k1['oer']:>+9.3f}{k1['sr']:>+11.1f}{k1['worst']:>+10.2f}" + f"{k2['oer']:>+9.3f}{k2['sr']:>+11.1f}{k2['worst']:>+10.2f}") + print() + print(" LESART: 'Ø Weite' = tatsaechliche SL-Distanz in xATR_M5. Die LIVE-Zeile") + print(" zeigt, was der Bot heute macht; alle anderen Zeilen sind Alternativen.") + print(" ⚠ Enger SL verbessert den Worst-Case IMMER (er deckelt frueher) — die") + print(" Frage ist, was er an Ertrag kostet.") + print() + + +if __name__ == "__main__": + main()