From 010007f7a3c823fedcea49ae091223045a1d7c1e Mon Sep 17 00:00:00 2001 From: Axel Hocks Date: Sat, 8 Aug 2026 15:01:44 +0200 Subject: [PATCH] Exit-Abstand aus M15-ATR gemessen = faellt durch, Exit bleibt am M5-ATR Zweiter Teil der M15-Idee: M5-Einstieg behalten, nur den Exit groeber fuehren. Schliesst zugleich den seit 31.07. in core/trailing.py offenen Punkt ("bisher misst er nur M5"). M5-ATR (heute) H1 +0,219 / PF 1,41 / Worst -2,17 H2 +0,117 / PF 1,25 / Worst -2,23 M15-ATR H1 +0,096 / PF 1,14 / Worst -4,26 H2 +0,019 / PF 1,02 / Worst -6,73 Schlechter in BEIDEN Haelften und das Tail-Risiko verdreifacht sich. Dasselbe Muster wie bei backtest_sl_basis.py. Zwei Methoden-Fallen, die die Messung fast entwertet haetten: 1) simulate() normiert R mit DEMSELBEN ATR, den es fuer SL/Trail/TP nutzt. Mit dem 1,86x groesseren M15-ATR waere dieselbe Dollar-Bewegung ein kleineres R - die M15-Variante haette mechanisch schlechter ausgesehen. Beide werden deshalb in M5-Risikoeinheiten zurueckgerechnet. 2) Der erste Lauf sammelte die Entry-Liste ueber exit_fn - das wird von scan() bei JEDEM Box-Ausbruch gerufen, nicht nur bei den komprimierten: 519 statt 132 Einstiege, also die Kontrollgruppe statt des Squeeze. Behoben ueber den additiven Rueckruf on_entry(j, d, lvl, atr, komprimiert) in core/squeeze_scan.py; Bitgleichheit weiter durch tests/test_squeeze_scan.py belegt. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 27 ++++++++ backtest_exit_atr_tf.py | 137 ++++++++++++++++++++++++++++++++++++++++ core/squeeze_scan.py | 12 +++- 3 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 backtest_exit_atr_tf.py diff --git a/CLAUDE.md b/CLAUDE.md index 04933b0..a8ef40b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4942,6 +4942,33 @@ Modell schliesst keine Trades. „die Rangfolge ist kaputt" ist **nicht** belegt; belegt ist nur die Basisraten-/Kalibrierungslücke. +## ⚠⚠ EXIT-ABSTAND AUS M15-ATR = FÄLLT DURCH (2026-08-08) + +Zweiter Teil der M15-Idee: M5-Einstieg behalten, nur den EXIT gröber führen. +Gemessen (`backtest_exit_atr_tf.py`, 80k M5, feste geteilte Entry-Liste, +kanonischer Exit, Echtkosten). Schliesst zugleich den seit dem 31.07. in +`core/trailing.py` offenen Punkt („bisher misst er nur M5"). +| | H1 ØR / PF / Worst | H2 ØR / PF / Worst | +|---|---|---| +| **M5-ATR (heute)** | **+0,219 / 1,41 / −2,17** | **+0,117 / 1,25 / −2,23** | +| M15-ATR (1,86× breiter) | +0,096 / 1,14 / **−4,26** | +0,019 / 1,02 / **−6,73** | +**Schlechter in BEIDEN Hälften — und das Tail-Risiko verdreifacht sich.** +Dasselbe Muster wie bei `backtest_sl_basis.py`: ein breiterer Exit kauft nichts +und kostet Tail. **Der Exit bleibt am M5-ATR.** + +⚠⚠ **METHODEN-FALLE, die die Messung fast entwertet hätte** — zweimal: +**(1)** `simulate()` normiert R mit DEMSELBEN ATR, den es für SL/Trail/TP nutzt. +Mit dem 1,86× grösseren M15-ATR wäre dieselbe Dollar-Bewegung ein kleineres R — +die M15-Variante hätte mechanisch schlechter ausgesehen. Beide werden deshalb in +**M5-Risikoeinheiten** zurückgerechnet (`R × ATR_verwendet / ATR_M5`). +**(2)** Der erste Lauf sammelte die Entry-Liste über `exit_fn` — das wird von +`scan()` bei **JEDEM** Box-Ausbruch gerufen, nicht nur bei den komprimierten: +**519 statt 132 Einstiege**, also die Kontrollgruppe statt des Squeeze. Behoben +über den additiven Rückruf **`on_entry(j, d, lvl, atr, komprimiert)`** in +`core/squeeze_scan.py` (Bitgleichheit weiter durch `tests/test_squeeze_scan.py` +belegt). ⚠ **Ein Haken, der mehr liefert als gedacht, ist eine stille +Populations-Änderung.** + ## ⚠⚠ SQUEEZE AUF M15 = FÄLLT DURCH — kein M15-Modul gebaut (2026-08-08) Anlass: User „baue mir das M15-Empfehlungsmodul". Der Squeeze ist der einzige diff --git a/backtest_exit_atr_tf.py b/backtest_exit_atr_tf.py new file mode 100644 index 0000000..be1d4c2 --- /dev/null +++ b/backtest_exit_atr_tf.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +"""Squeeze-Einstieg auf M5, EXIT-Abstände aus dem M15-ATR — trägt das? + +ANLASS (2026-08-08). Der M15-EINSTIEG ist gemessen durchgefallen +(`backtest_squeeze_m15.py`: n=33 in H1, Nachbarn kippen). Offen bleibt die +andere Hälfte der Idee: den validierten M5-Einstieg behalten und nur den EXIT +gröber führen. Dafür spricht die Kostenseite (Median Spread/ATR: M15 **0,104** +gegen M5 0,193) und der dokumentierte Befund, dass der Verlust-Hebel im Exit und +im Sizing liegt, nicht im Signal. + +⚠ IM CODE STEHT DIESE FRAGE SEIT DEM 31.07. OFFEN — `core/trailing.py`: +„Falls die Staffelung je zurück soll: erst `backtest_trailing.py` auf +M15-/M30-BASIS-Signalen laufen lassen — **bisher misst er nur M5**." +Live nimmt das Trailing den ATR der WELLEN-Zeitebene als Abstandsmaß; die +Heuristik landet häufig auf M15/M30 (31.07.: 19 von 33 Phasen). Welcher ATR das +sein SOLLTE, ist nie gemessen worden. + +⚠⚠ DIE FALLE, DIE DIESE MESSUNG SONST WERTLOS MACHT: `simulate()` normiert das +Ergebnis mit DEMSELBEN ATR, den es für SL/Trail/TP benutzt (R = Gewinn/ATR). Mit +dem ~1,8× größeren M15-ATR würde dieselbe Dollar-Bewegung zu einem kleineren R — +die Varianten wären **nicht vergleichbar**, und die M15-Variante sähe +mechanisch schlechter aus. Beide werden deshalb in **M5-Risikoeinheiten** +zurückgerechnet: R_m5 = R_ergebnis × ATR_verwendet / ATR_M5. + +⚠ NICHT dasselbe wie `backtest_sl_basis.py` (05.08.): dort wurde nur der +INITIAL-SL variiert (Ergebnis: Wash beim Squeeze, Tail-Risiko ×3, daraufhin +`_calc_sl_tp(atr_tf=M5)` für Squeeze-Trades gebaut). Hier hängt der GESAMTE Exit +am ATR — SL, Trail-Abstand, Breakeven, Lock-Schwellen und TP. + +AUFBAU: feste, geteilte Entry-Liste (Squeeze auf M5, dokumentierte Parameter +Box 10 / k 0,1 / Floor 0,12), Touch-Fill, Echtkosten, kanonischer Exit-Kern, +2 Halbjahre. M15-ATR kausal aus den M5-Bars aggregiert. + +⚠⚠ ENTSCHEIDUNGSREGEL, VORAB: die M15-Variante muss in BEIDEN Hälften mehr ΣR +(in M5-Einheiten) liefern UND darf den Worst-Case nicht verschlechtern. Ein +breiterer Exit kauft Ertrag sonst mit Tail-Risiko — genau die Abwägung, die bei +`backtest_sl_basis.py` gegen die Umstellung sprach. + +Aufruf: python backtest_exit_atr_tf.py [n_M5_bars] (Default 80000) +""" +from __future__ import annotations + +import sys + +import MetaTrader5 as mt5 + +from core.exit_model import LIVE, simulate +from core.squeeze_scan import atr_series, scan + +_FLOOR = 0.12 # dokumentierter M5-Floor (backtest_squeeze_touchfill.py) +_M15 = 3 # 3 M5-Bars = 1 M15-Bar + + +def atr_m15_auf_m5(H, L, C): + """ATR aus M15-Bars, auf den M5-Index abgebildet. + + ⚠ LOOK-AHEAD-frei: an M5-Index i zählt nur der letzte ABGESCHLOSSENE + M15-Bar. Dieselbe Konstruktion wie `_htf_signs` in `backtest_auto_signal`.""" + hh, hl, hc = [], [], [] + for s in range(0, len(C) - _M15 + 1, _M15): + hh.append(max(H[s:s + _M15])); hl.append(min(L[s:s + _M15])) + hc.append(C[s + _M15 - 1]) + a15 = atr_series(hh, hl, hc) + out = [None] * len(C) + for i in range(len(C)): + j = i // _M15 - 1 + out[i] = a15[j] if 0 <= j < len(a15) else None + return out + + +def kz(Rs, label): + if not Rs: + return f" {label:<26} n=0" + n = len(Rs); w = sum(1 for x in Rs if x > 0); s = sum(Rs) + g = sum(x for x in Rs if x > 0); v = -sum(x for x in Rs if x < 0) + return (f" {label:<26} n={n:>4} Treffer={100*w/n:>3.0f}% ØR={s/n:+.3f} " + f"PF={(g/v if v > 0 else 9.99):>4.2f} ΣR={s:>+6.0f} Worst={min(Rs):+.2f}") + + +def main(): + n = 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) + 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] + A5 = atr_series(H, L, C) + A15 = atr_m15_auf_m5(H, L, C) + mid = len(C) // 2 + + gueltig = [(a15 / a5) for a5, a15 in zip(A5, A15) if a5 and a15 and a5 > 0] + gueltig.sort() + print("=" * 98) + print(f" EXIT-ABSTAND aus M5-ATR gegen M15-ATR — {sym}, {len(C)} M5-Bars") + print(f" Verhältnis ATR_M15/ATR_M5: Median {gueltig[len(gueltig)//2]:.2f}×") + print(" ⚠ Beide Varianten in M5-RISIKOEINHEITEN gerechnet, sonst unvergleichbar") + print("=" * 98) + + for nm, lo, hi in (("H1", 40, mid), ("H2", mid, len(C) - LIVE.max_hold - 2)): + # ── EINE feste Entry-Liste (der Einstieg ist in beiden Varianten gleich) + eintraege = [] + + # ⚠ NUR komprimierte Ausbrueche = die Squeeze-Population. `exit_fn` + # allein taugt dafuer NICHT: es wird bei JEDEM Box-Ausbruch gerufen und + # lieferte still die Kontrollgruppe (519 statt 132 Einstiege). + def sammeln(j, d, lvl, atr, komprimiert, _l=eintraege): + if komprimiert: + _l.append((lvl, d, j + 1)) + + scan(H, L, C, A5, SP, 1.0, lo, hi, 2.5, box_n=10, k=0.1, + atr_min=_FLOOR, max_hold=LIVE.max_hold, on_entry=sammeln) + + print(f"\n ── {nm} ── {len(eintraege)} feste Squeeze-Einstiege, " + f"beide Varianten identisch") + for label, quelle in (("M5-ATR (heute)", A5), ("M15-ATR", A15)): + Rs = [] + for entry, d, j0 in eintraege: + i = j0 - 1 + a5 = A5[i] + a = quelle[i] + if not a or not a5 or a5 <= 0: + continue + a = max(a, _FLOOR) + R = simulate(entry, d, a, H, L, C, j0, LIVE) + # ⚠ zurueck in M5-Einheiten: R × verwendeter ATR ÷ ATR_M5 + r_m5 = R * a / max(a5, _FLOOR) + kosten = (SP[i] if SP[i] > 0 else 0.0225) / max(a5, _FLOOR) + Rs.append(r_m5 - kosten) + print(kz(Rs, label)) + + +if __name__ == "__main__": + main() diff --git a/core/squeeze_scan.py b/core/squeeze_scan.py index eda9503..185affb 100644 --- a/core/squeeze_scan.py +++ b/core/squeeze_scan.py @@ -76,7 +76,8 @@ def scan(H, L, C, A, SP, point, lo_i, hi_i, squeeze_mult, *, box_n: int = BOX_N_STD, k: float = K_STD, atr_min: float = ATRMIN_STD, fenster: int = FENSTER_STD, cooldown: int = COOLDOWN_STD, max_hold: int = MAXH_STD, - exit_fn: Callable | None = None) -> tuple[list, list]: + exit_fn: Callable | None = None, + on_entry: Callable | None = None) -> tuple[list, list]: """Ein Durchlauf: sammelt R (netto) je Ausbruch. Rueckgabe `(Rs_sq, Rs_any)` — `Rs_any` = ALLE Box-Ausbrueche (die Kontrolle), @@ -86,6 +87,13 @@ def scan(H, L, C, A, SP, point, lo_i, hi_i, squeeze_mult, *, ⚠ `point` wird nicht mehr benutzt (SP kommt bereits in Preis), bleibt aber in der Signatur, damit bestehende Aufrufe unveraendert funktionieren. + + ⚠ `on_entry(j, d, lvl, atr, komprimiert)` ist ADDITIV (2026-08-08): ein + Rueckruf je Ausbruch, der auch sagt, ob er aus einer KOMPRESSION kam. Ohne + ihn ist `exit_fn` der einzige Haken — und der wird bei JEDEM Box-Ausbruch + gerufen, nicht nur bei den komprimierten. Wer darueber eine Entry-Liste + sammelt, bekommt still die Kontrollgruppe statt des Squeeze (real passiert: + 519 statt 132 Einstiege). Verhalten ohne `on_entry` unveraendert. """ ex = exit_fn or (lambda e, d, a, h, l, c, j: exit_legacy( e, d, a, h, l, c, j, max_hold=max_hold)) @@ -118,6 +126,8 @@ def scan(H, L, C, A, SP, point, lo_i, hi_i, squeeze_mult, *, i += 1 continue j, d, lvl = treffer + if on_entry is not None: + on_entry(j, d, lvl, atr, komprimiert) R = ex(lvl, d, atr, H, L, C, j + 1) - kosten(j, atr) Rs_any.append(R) if komprimiert: