From c8c3e32926021224218a08128d16e5a05eb96f5e Mon Sep 17 00:00:00 2001 From: Axel Hocks Date: Wed, 5 Aug 2026 16:58:50 +0200 Subject: [PATCH] Trail x Breakeven gemeinsam geprueft: keine Aenderung - und ein Aufloesungs- Artefakt, das einen alten Schluss entwertet Anlass: Frage zur Trail-Kalibrierung "jetzt bei 3 Lot". Zur Praemisse: die Positionsgroesse aendert das Optimum NICHT (R ist ATR-normiert). Gemessen wurde die echte offene Frage - be=1,3 stammt aus der Zeit mit Trail 1,5, mult wurde am 31.07. auf 1,0 gesenkt ohne das Breakeven neu zu pruefen. (1) Breakeven ist praktisch wirkungslos: bei mult <= 0,6 sind alle be-Zeilen identisch (der enge Trail erreicht den Einstand frueher als der BE-Boden). Bei mult 1,0 ist be=0,6 um +0,019 besser - unter einem Standardfehler. Keine Aenderung. (2) Der Trail ist monoton besser bis hinunter zu 0,05 xATR (~3 Cent) - das waere vom Spread allein ausgeloest. Ein Optimum, das bis an die physikalische Unmoeglichkeit monoton bleibt, misst die Methode, nicht den Markt. Aufloesungs-Artefakt nachgewiesen (analyze_trail_resolution.py): mit in DOLLAR fixiertem Trail auf M5 gegen M1, 597 gemeinsame Einstiege, kippt das Vorzeichen der Differenz bei ~0,3 xATR - die groebere Aufloesung ueberschaetzt enge Trails (-0,027 bei 0,10) und unterschaetzt weite (+0,036 bei 1,0, +0,066 bei 1,5). Damit ist der Befund vom 31.07. ("enger ist monoton besser") teilweise entwertet. Erster Testentwurf war falsch konstruiert: der Trail wurde mit dem ATR der jeweiligen Zeitebene skaliert, damit bleibt das Verhaeltnis Trail/Bar-Spanne konstant und die Aufloesung wird gar nicht variiert. Nebenbefund gegen die eigene Slippage-Vermutung: die Trade-Zahl ist ueber alle Multiplikatoren fast konstant (2108 bei 0,3 gegen 2083 bei 1,0), Worst-Case ueberall -2,44. Das Slippage-Argument traegt kaum, das Aufloesungs-Argument traegt. Ergebnis: Trail 1,0 / Breakeven 1,3 bleiben. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 49 +++++++- analyze_trail_resolution.py | 52 ++++++++ backtest_trail_be.py | 242 ++++++++++++++++++++++++++++++++++++ 3 files changed, 342 insertions(+), 1 deletion(-) create mode 100644 analyze_trail_resolution.py create mode 100644 backtest_trail_be.py diff --git a/CLAUDE.md b/CLAUDE.md index 2cfeb3e..27e1f35 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -748,7 +748,54 @@ dort bereits nachvalidiert, ØR +0,305.) **Eine** Oberfläche: Trades ab, die sich erholt hätten; NOSIG-H2 war ohne Eingriff sogar leicht profitabel (ΣR +80, PF 1,15) und wird durch jede Adverse-Schwelle verschlechtert. Hypothese „schwächere Population profitiert eher" widerlegt. Keine Änderung. - - **Trailing-Optimierung geprüft = KEINE Änderung (gemessen, `backtest_trailing.py`, + - **⚠⚠ TRAIL × BREAKEVEN GEMEINSAM GEPRÜFT = KEINE ÄNDERUNG — und ein + METHODEN-BEFUND, der einen alten Schluss entwertet (2026-08-05, + `backtest_trail_be.py` + `analyze_trail_resolution.py`).** Anlass: User-Frage zur + Kalibrierung „jetzt bei 3 Lot". + ⚠ **Zur Prämisse: die Positionsgröße ändert das Trail-Optimum NICHT.** R ist + ATR-normiert; Lots skalieren nur das EUR-Ergebnis. Gemessen wurde deshalb die + echte offene Frage: **`be=1,3` stammt aus `backtest_exit.py`, als der Trail noch + 1,5 war; `mult` wurde am 31.07. auf 1,0 gesenkt, OHNE das Breakeven neu zu + prüfen.** 2D-Sweep über beide Populationen (2.365 Squeeze- / 1.566 Wellen-Entries), + feste Entry-Listen, kanonischer Exit, Echtkosten, 2 Halbjahre. + **(1) Das Breakeven ist praktisch wirkungslos.** Bei mult ≤ 0,6 sind ALLE + be-Zeilen **identisch** — der enge Trail erreicht den Einstand ohnehin früher als + der Breakeven-Boden. Erst ab mult 0,8 wirkt er überhaupt, und dort ist be=0,6 + marginal besser (+0,019 bzw. +0,018 ØR). ⚠ Das liegt **unter einem Standardfehler** + (≈0,026 bei n≈1.500) → **kein Handlungsbedarf**, be bleibt 1,3. + **(2) Der Trail ist monoton besser, je enger — bis 0,05×ATR.** Und genau das ist + das Problem: ein Trail von 0,05×ATR (≈3 Cent) wäre vom Spread allein ausgelöst. + Ein Optimum, das bis an die physikalische Unmöglichkeit monoton bleibt, misst + nicht den Markt, sondern die Methode. + ⚠⚠ **AUFLÖSUNGS-ARTEFAKT NACHGEWIESEN.** `simulate()` prüft den Stop gegen + Bar-Hoch/-Tief. Ein Trail, der ENGER ist als die typische Intrabar-Spanne, ist + nicht auflösbar — das Modell nimmt den Bar-Hochpunkt als Gewinn mit und steigt + erst im NÄCHSTEN Bar aus. Test mit **in DOLLAR fixiertem** Trail auf M5 gegen M1 + (597 gemeinsame Einstiege): + | Trail | M5 ØR | M1 ØR | Differenz | + |---|---|---|---| + | 0,10×ATR | +0,093 | +0,066 | **−0,027** | + | 0,20×ATR | +0,049 | +0,039 | −0,010 | + | 0,30×ATR | +0,013 | +0,016 | +0,003 | + | 1,00×ATR | −0,046 | −0,010 | **+0,036** | + | 1,50×ATR | −0,038 | +0,028 | **+0,066** | + **Das Vorzeichen kippt bei ~0,3×ATR:** die gröbere Auflösung **überschätzt enge + Trails und unterschätzt weite**. Eine Tick-Simulation würde denselben Weg noch + weiter gehen. ⚠ **Damit ist der Befund vom 31.07. („enger ist monoton besser", + `backtest_trailmult.py`) teilweise entwertet** — er ruht auf derselben + M5-Auflösung. Die damalige Entscheidung, NICHT auf 0,5 zu gehen, war trotzdem + richtig, nur aus dem falschen Grund (genannt wurde „Randwert + Slippage"; + der eigentliche Grund ist die Auflösungsgrenze). + ⚠ Nebenbefund gegen meine eigene Slippage-Vermutung: die Trade-ZAHL ist über alle + Multiplikatoren fast konstant (2.108 bei 0,3 gegen 2.083 bei 1,0), nur die + Haltedauer sinkt (12 → 18 min) und der Worst-Case ist überall identisch (−2,44, + der SL deckelt). Die Slippage-Exposition steigt also NICHT dramatisch — das + Auflösungs-Argument trägt, das Slippage-Argument kaum. + ✅ **Ergebnis: Trail 1,0 / Breakeven 1,3 bleiben.** 1,0×ATR entspricht rund einer + M5-Bar-Spanne (die ATR IST die mittlere Bar-Spanne) und ist damit ungefähr die + feinste Einstellung, die diese Simulation ehrlich auflösen kann. Wer enger will, + braucht Tick-Daten — nicht mehr Bars. +- **Trailing-Optimierung geprüft = KEINE Änderung (gemessen, `backtest_trailing.py`, 2 Halbjahre, echte Phasen-Sim):** Die Live-Config (`_TRAIL_START_ATR=0,3` · mult 1,5 M5 · Breakeven 1,3) hat die **beste Verlustseite** (bestes H1, Worst nur −1,5×ATR). Trailing trägt klar: ohne Trailing ΣR −1313/Worst −2,0 vs. Live +1777/ diff --git a/analyze_trail_resolution.py b/analyze_trail_resolution.py new file mode 100644 index 0000000..d9e1dea --- /dev/null +++ b/analyze_trail_resolution.py @@ -0,0 +1,52 @@ +"""Korrigierter Aufloesungs-Test. + +FEHLER im ersten Versuch: der Trail wurde mit dem ATR der JEWEILIGEN Zeitebene +skaliert. Damit bleibt das Verhaeltnis Trail/Bar-Spanne konstant — die Aufloesung +wird gar nicht variiert. Richtig: Trail in DOLLAR fixieren, nur die Bar-Groesse +aendern. Dieselben Einstiegszeitpunkte auf beiden Zeitebenen. +""" +import sys +from datetime import datetime, timedelta, timezone +sys.path.insert(0, r"c:\Users\ah\Downloads\tradingrobot") +import MetaTrader5 as mt5 +from core.exit_model import LIVE, simulate + +ATR_FIX = 0.30 # $ — fuer BEIDE Zeitebenen identisch +mt5.initialize() +sym = next(c for c in ("SpotCrude","USOIL","WTI") if mt5.symbol_info(c)) +d = {} +for lbl, tf, n in (("M5", mt5.TIMEFRAME_M5, 40000), ("M1", mt5.TIMEFRAME_M1, 60000)): + b = mt5.copy_rates_from_pos(sym, tf, 0, n) + d[lbl] = {"T":[int(x["time"]) for x in b], + "H":[float(x["high"]) for x in b], "L":[float(x["low"]) for x in b], + "C":[float(x["close"]) for x in b]} +mt5.shutdown() +print(f"M5 {len(d['M5']['T'])} Bars · M1 {len(d['M1']['T'])} Bars") +# gemeinsamer Zeitraum +t_von = max(d["M5"]["T"][0], d["M1"]["T"][0]); t_bis = min(d["M5"]["T"][-1], d["M1"]["T"][-1]) +import bisect +m1T = d["M1"]["T"] +paare = [] +for i,t in enumerate(d["M5"]["T"]): + if not (t_von <= t <= t_bis) or i % 20: continue + j = bisect.bisect_left(m1T, t) + if 0 < j < len(m1T)-300: paare.append((i,j)) +print(f"gemeinsame Einstiege: {len(paare)}") +print() +print(f"{'Trail $':>9}{'= xATR':>8}{'M5 OeR':>10}{'M1 OeR':>10}{'Differenz':>11}") +print("-"*48) +for mult in (0.1,0.2,0.3,0.5,0.8,1.0,1.5): + p = LIVE.with_(mult=mult) + out = {} + for lbl, idx in (("M5",0), ("M1",1)): + X = d[lbl]; rs=[] + for pr in paare: + k = pr[idx] + rs.append(simulate(X["C"][k], 1, ATR_FIX, X["H"], X["L"], X["C"], k+1, + p, timestop=False, use_tp=True)) + out[lbl] = sum(rs)/len(rs) + print(f"{mult*ATR_FIX:>9.3f}{mult:>8.2f}{out['M5']:>+10.3f}{out['M1']:>+10.3f}" + f"{out['M1']-out['M5']:>+11.3f}") +print() +print("Wird die Differenz bei engem Trail deutlich NEGATIV, ist der M5-Befund") +print("'enger ist immer besser' ein Aufloesungs-Artefakt.") diff --git a/backtest_trail_be.py b/backtest_trail_be.py new file mode 100644 index 0000000..5365161 --- /dev/null +++ b/backtest_trail_be.py @@ -0,0 +1,242 @@ +#!/usr/bin/env python3 +"""Trailing × Breakeven — sind die beiden noch GEMEINSAM kalibriert? (2026-08-05) + +ANLASS: Frage des Users nach der Trailing-Kalibrierung, ausgelöst vom Trade +T=49408993 (05.08.): er lief +0,50 $ ins Plus (0,85×ATR), wurde vom Trail +ausgestoppt und endete bei −15,33 €. + +⚠ ZUR PRÄMISSE „mein Einsatz ist jetzt 3 Lot": **die Positionsgröße ändert das +Trailing-Optimum NICHT.** R ist ATR-normiert; Lots skalieren nur das EUR-Ergebnis, +nicht den optimalen Multiplikator. Gemessen wird deshalb etwas anderes — und +Wichtigeres: + +**TRAIL UND BREAKEVEN WURDEN NIE GEMEINSAM OPTIMIERT.** + * `be = 1.3` stammt aus `backtest_exit.py` — gemessen, als der Trail noch **1,5** war. + * `mult` wurde am 31.07. auf **1,0** gesenkt (`backtest_trailmult.py`), **ohne** + das Breakeven neu zu prüfen. +Die beiden hängen aber zusammen: solange der Gewinn unter `be` liegt, steht der +Stop noch auf der Verlustseite; ist der Trail eng, wird dieses Band schneller +durchlaufen. Genau in diesem Band ist der Trade von heute gestorben — Gewinn +0,85×ATR, Breakeven-Schwelle 1,3×ATR, also **0,45×ATR zu wenig**, und der Trail +(1,0×ATR) lag zwangsläufig noch über dem Einstieg. + +MESSUNG: 2D-Sweep mult × be, kanonischer Exit (`exit_model.simulate`), Echtkosten, +80k M5, 2 Halbjahre, auf ZWEI Populationen (Squeeze-Ausbrüche und gegatete +Wellensignale) über eine JE POPULATION FESTE Entry-Liste — nur die Exit-Parameter +variieren, sonst wäre nicht vergleichbar, was verglichen wird. + +VORAB FIXIERTE ENTSCHEIDUNGSREGEL: + Umstellen nur, wenn ein Paar (mult, be) den Ist-Stand (1,0 / 1,3) + (a) in ΣR **und** ØR in BEIDEN Halbjahren schlägt, + (b) auf BEIDEN Populationen, + (c) und die direkten Nachbarwerte mithalten (kein Parameter-Zufall), + (d) ohne den Worst-Case zu verschlechtern. + Sonst bleibt es, wie es ist. + +Aufruf: python backtest_trail_be.py [bars] +""" +import sys + +import MetaTrader5 as mt5 + +from backtest_cost_gate import _atr_series, kandidaten +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, _HTF_DEADBAND) + +_ATRMIN = 0.06 +_TIMEOUT_BARS = 12 +_PIV_K, _PIV_LOOK = 3, 300 +_ATR_FLOOR_PB = 0.12 +_K = 0.3 +# ⚠ Bereich bewusst NACH UNTEN erweitert: der Vorlauf zeigte das Optimum bei 0,6, +# also am RAND — dann misst man moeglicherweise nur die Grenze des Suchraums. +# Genau diese Warnung steht seit 31.07. in CLAUDE.md (backtest_trailmult.py). +_MULT = (0.3, 0.4, 0.5, 0.6, 0.8, 1.0, 1.5) +# Bei engem Trail ist das Breakeven wirkungslos (der Trail erreicht den Einstand +# ohnehin frueher) — deshalb ein grobes Raster, das reicht fuer die Interaktion. +_BE = (0.6, 1.0, 1.3, 2.0) + + +def _ema_series(v, p): + k = 2 / (p + 1); out = [v[0]] + for x in v[1:]: + out.append(out[-1] + k * (x - out[-1])) + return out + + +def _htf_sign(C, i, n, atr): + if i < n * _EMA_SLOW: + return 0 + seg = C[i - n * _EMA_SLOW:i + 1:n] + if len(seg) < _EMA_SLOW + 2: + return 0 + ef = _ema_series(seg, _EMA_FAST)[-1]; es = _ema_series(seg, _EMA_SLOW)[-1] + dead = _HTF_DEADBAND * atr * (n ** 0.5) + return 1 if ef - es > dead else (-1 if es - ef > dead else 0) + + +def wellen_entries(w, H, L, C, A, SP, EF, ES, AD, HTF, H1, PH, PL): + """Gegatete Wellensignale — volle Live-Kette, EINE feste Liste.""" + out = [] + pend = None + for i in range(_PIV_LOOK + _EMA_SLOW + 5, len(C) - 2): + atr = A[i] + if not atr or atr < _ATRMIN: + continue + w._pb_feats = {"atr": max(atr, _ATR_FLOOR_PB)} + w._pb_levels = {"ph": [C[j] for j in PH if i - _PIV_LOOK <= j <= i - _PIV_K], + "pl": [C[j] for j in PL if i - _PIV_LOOK <= j <= i - _PIV_K]} + 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") + frisch = False + 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 * _K * atr, + "invalid": C[i] - d * _K * atr, "confirmed": False, "bar": i} + if not pend["confirmed"]: + broke = (C[i] >= pend["level"]) if d > 0 else (C[i] <= pend["level"]) + against = (C[i] <= pend["invalid"]) if d > 0 else (C[i] >= pend["invalid"]) + if broke: + pend["confirmed"] = True; frisch = True + else: + if against or (i - pend["bar"]) > _TIMEOUT_BARS: + pend = {"dir": d, "level": C[i] + d * _K * atr, + "invalid": C[i] - d * _K * atr, + "confirmed": False, "bar": i} + continue + else: + pend = None + continue + if not frisch: + continue + rec = w._room_gate(rec, C[i]) + if rec.get("signal") not in ("LONG", "SHORT"): + continue + out.append({"i": i, "d": 1 if rec["signal"] == "LONG" else -1, + "lvl": pend["level"], "atr": max(atr, _ATRMIN), + "kosten": (SP[i] if SP[i] > 0 else 0.0225) / max(atr, _ATRMIN)}) + pend = None + return out + + +def sweep(K, H, L, C, lo, hi, mult, be): + """Sequentiell, EIN Slot — nur die Exit-Parameter variieren.""" + p = LIVE.with_(mult=mult, be=be) + rs = [] + frei = lo + for k in K: + if not (lo <= k["i"] < hi) or k["i"] < frei: + continue + 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 + + +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, "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) + 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) # ⚠ ohne diese Zeile ist das Gate ein No-op + w.set_dead_hours("") + mid = len(C) // 2 + + print("=" * 96) + print(f" TRAILING x BREAKEVEN — gemeinsam kalibriert? · {sym} M5, {len(C)} Bars") + print(f" Ist-Stand: mult {LIVE.mult} · be {LIVE.be} · 2 Halbjahre · Echtkosten") + print(" Hinweis: die Positionsgroesse aendert das Optimum NICHT (R ist ATR-normiert).") + print("=" * 96) + + 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))] + + ist = {} + for name, K in pops: + print(f"\n {name} — {len(K)} Entries") + print(f" {'be \\ mult':<11}" + "".join(f"{m:>15.1f}" for m in _MULT)) + for be in _BE: + zeile = f" {be:<11.1f}" + for m in _MULT: + k1 = kenn(sweep(K, H, L, C, 0, mid, m, be)) + k2 = kenn(sweep(K, H, L, C, mid, len(C), m, be)) + if m == LIVE.mult and be == LIVE.be: + ist[name] = (k1, k2) + zeile += f"{(k1['oer'] if k1 else 0):>+7.3f}/{(k2['oer'] if k2 else 0):>+7.3f}" + print(zeile) + print(f" (Zellen = OeR H1 / OeR H2; Ist-Stand {LIVE.mult}/{LIVE.be} ist die Referenz)") + + # ── Urteil nach der VORAB fixierten Regel ───────────────────────────── + print("\n" + "=" * 96) + print(" URTEIL (Regel VOR dem Lauf fixiert: schlaegt Ist-Stand in OeR UND SigmaR,") + print(" in BEIDEN Haelften, auf BEIDEN Populationen, Nachbarn halten mit,") + print(" Worst-Case nicht schlechter)") + print("=" * 96) + treffer = [] + for m in _MULT: + for be in _BE: + if m == LIVE.mult and be == LIVE.be: + continue + ok = True + for name, K in pops: + i1, i2 = ist[name] + k1 = kenn(sweep(K, H, L, C, 0, mid, m, be)) + k2 = kenn(sweep(K, H, L, C, mid, len(C), m, be)) + if not (k1 and k2 and i1 and i2): + ok = False; break + if not (k1["oer"] > i1["oer"] and k2["oer"] > i2["oer"] + and k1["sr"] > i1["sr"] and k2["sr"] > i2["sr"] + and k1["worst"] >= i1["worst"] - 1e-9 + and k2["worst"] >= i2["worst"] - 1e-9): + ok = False; break + if ok: + treffer.append((m, be)) + if treffer: + print(f" Kandidaten (vor Nachbar-Pruefung): {treffer}") + print(" ⚠ Nachbarn von Hand gegenpruefen — eine einzelne bessere Zelle") + print(" zwischen schlechteren ist Rauschen.") + else: + print(" => KEIN Paar schlaegt den Ist-Stand auf beiden Populationen.") + print(" Trailing 1,0 / Breakeven 1,3 bleibt.") + print() + + +if __name__ == "__main__": + main()