From b137967a68c30d59460045ae2511b10f7cb2d415 Mon Sep 17 00:00:00 2001 From: Axel Hocks Date: Fri, 31 Jul 2026 08:45:53 +0200 Subject: [PATCH] Ursache der P(break)-Live-Degradation gefunden: Merkmals-Diskrepanz backtest_pbreak_touches.py (M30-Level wie live, Touch-Zahl als 5. Merkmal) plus Rekonstruktion der Live-Merkmale aus candles_m1. Beweiskette: 1) Der Backtest reproduziert die 0,71 (Fit H1 -> AUC oos H2 0,715). Modell und Pipeline sind in Ordnung. 2) Populations-Hypothese widerlegt: live ist ausgerechnet die 1. Beruehrung (die trainierte Situation) mit AUC 0,445 die SCHLECHTESTE Gruppe, die Chop-Faelle (5.+) mit 0,577 die beste. Der Versatz "Modell 23-25 %, real 40-42 %" ist in JEDER Teilmenge gleich gross. 3) Merkmale rekonstruiert (n=2275): mom3 live 0,149 vs Training 1,4523 (-1,303), mom6 0,181 vs 1,6565 (-1,476). Daraus z-Versatz -1,05 -> aus 39,4 % Basisrate werden ~19 %, beobachtet 23 %. Erklaert praktisch die gesamte Fehlkalibrierung. MECHANISMUS: Training fixiert das Level beim Entry (>=0,3xATR entfernt) und wartet auf den ersten Touch -> mom3 ~1,45xATR = ein Schub. Live waehlt _draw_levels das naechstgelegene Level jede Sekunde NEU -> der Kurs steht oft neben einem Level, das gerade erst zum naechsten wurde, ohne Anlauf -> mom3 ~0,15. Gleicher Code, andere Situation. Folge: das Modell liegt live immer unter der Schwelle 0,55 -> das Gate war seit Inbetriebnahme faktisch nie aktiv (91 % aller Beruehrungen geschlossen = der pauschale S/R-Close, der 2x verworfen wurde). Zwei Reparaturwege (beide ungemessen): (a) Ziel-Level beim Oeffnen fixieren statt laufend neu waehlen, oder (b) Modell auf live-spiegelnder Stichprobe neu trainieren. TOUCH-ZAHL (User-Idee): im Backtest AUC 0,715 -> 0,725, Gewicht +0,176 fuer die 2.-4. Beruehrung (Vorzeichen bestaetigt), aber R-Ertrag nicht robust (H1 +131, H2 -43). Im Trainings-Datensatz 76 % Erst-Beruehrungen und KEINE 5+-Faelle - das live beobachtete umgekehrte U ist dort nicht abbildbar. Kleine Verbesserung, nicht der Hebel. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 38 +++++ backtest_pbreak_touches.py | 284 +++++++++++++++++++++++++++++++++++++ 2 files changed, 322 insertions(+) create mode 100644 backtest_pbreak_touches.py diff --git a/CLAUDE.md b/CLAUDE.md index 10b24dc..d96b314 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1449,6 +1449,44 @@ dort bereits nachvalidiert, ØR +0,305.) **Eine** Oberfläche: beiden Hälften schlechter = Gewinner-Kappen). **Das erklärt die kontrafaktische Live-Messung vom Vortag** (`analyze_srclose_live.py`: −8 €/Trade, 43 % klar zu früh geschlossen, nur 26 % klar richtig) — Symptom und Ursache passen zusammen. + ⚠⚠ **URSACHE GEFUNDEN (2026-07-31, `backtest_pbreak_touches.py` + Rekonstruktion aus + `candles_m1`): das Modell ist NICHT verfallen — es bekommt live eine ANDERE + Merkmalsverteilung, als es trainiert wurde.** Beweiskette in drei Schritten: + (1) **Der Backtest reproduziert die 0,71** (M30-Level, 4 Merkmale, Fit H1 → **AUC + out-of-sample H2 0,715**) → Modell und Pipeline sind in Ordnung. + (2) **Die Populations-Hypothese ist widerlegt:** live nach Berührungszahl + aufgeschlüsselt ist ausgerechnet die 1. Berührung (= die trainierte Situation) mit + **AUC 0,445 die SCHLECHTESTE** Gruppe, die Chop-Fälle (5.+) mit 0,577 die beste. + Der Versatz „Modell sagt 23–25 %, real 40–42 %" ist in JEDER Teilmenge gleich groß + (~17 Pp) — Signatur eines Merkmals-Problems, nicht eines Edge-Verfalls. + (3) **Merkmale aus `candles_m1` rekonstruiert (n=2275) — der Versatz ist messbar:** + | Merkmal | Live-Mittel | Training-µ | Differenz | + |---|---|---|---| + | mom3 | 0,149 | 1,4523 | **−1,303** | + | mom6 | 0,181 | 1,6565 | **−1,476** | + Daraus rechnet sich der z-Versatz exakt: mom3 → 1,433×(−1,303)/2,418 = **−0,772**, + mom6 → **−0,275**, zusammen **−1,05**. Aus der Trainings-Basisrate 39,4 % (z=−0,429) + wird damit ~19 % — beobachtet werden 23 %. **Erklärt praktisch die gesamte + Fehlkalibrierung.** + ⚠ **DER MECHANISMUS:** Im Training ist der Touch-Bar `jt` der Moment, in dem der Kurs + nach einem ECHTEN ANLAUF (Level bei Entry ≥0,3×ATR entfernt fixiert) zum ersten Mal + ans Level stößt → mom3 ≈ 1,45×ATR = ein Schub. **Live wählt `_draw_levels` das + nächstgelegene Level JEDE SEKUNDE NEU** (mit Hysterese) — der Kurs steht also oft + neben einem Level, das gerade erst zum nächstgelegenen WURDE, ohne je darauf + zugelaufen zu sein → mom3 ≈ 0,15. Gleicher Code, gleiche Formeln, aber **Training = + Level bei Entry fixiert, Live = Level wandert mit.** Konsequenz: das Modell antwortet + live konsistent „kein Schub → kein Durchbruch → 23 %", liegt damit IMMER unter der + Schwelle 0,55 → **das Gate war seit Inbetriebnahme faktisch nie aktiv.** + **Zwei Reparaturwege (beide ungemessen, erst prüfen):** (a) Ziel-Level beim Öffnen + der Position FIXIEREN statt laufend neu zu wählen (macht Live = Training), oder + (b) das Modell auf einer live-spiegelnden Stichprobe NEU trainieren (bei jedem Bar + im 0,15×ATR-Band um das dynamisch nächste Level sampeln, ohne Anlauf-Bedingung). + **TOUCH-ZAHL im Backtest (2 Halbjahre, `backtest_pbreak_touches.py`):** hebt die AUC + nur von **0,715 auf 0,725**, Gewicht +0,176 für die 2.–4. Berührung (User-Vorzeichen + bestätigt), aber der **R-Ertrag ist nicht robust** (H1 +131, H2 −43). ⚠ Im Trainings- + Datensatz sind **76 % Erst-Berührungen und es gibt KEINE 5+-Fälle** (live sind 63 % + fünfte oder später) — das live beobachtete umgekehrte U lässt sich dort nicht einmal + abbilden. Kleine Verbesserung, nicht der Hebel. ✅ **Pipeline gegen das Training geprüft, KEIN Bug gefunden:** `dist` ist in beiden entry-basiert (`abs(level−entry)/atr`, `backtest_srclose_prob.py:86`), `mom6`/`mom3` in beiden am Touch-Bar `jt`, `confirm`/`reject` dieselbe ±0,5×ATR-Definition, Timeout diff --git a/backtest_pbreak_touches.py b/backtest_pbreak_touches.py new file mode 100644 index 0000000..f3f9ccf --- /dev/null +++ b/backtest_pbreak_touches.py @@ -0,0 +1,284 @@ +#!/usr/bin/env python3 +"""P(break) mit TOUCH-ZAHL als 5. Merkmal — trägt sie über 2 echte Halbjahre? + +User-Idee 2026-07-31: „berücksichtige, ob der Kurs das erste, zweite oder dritte Mal +in einer Zeitspanne den S/R trifft — je öfter, desto wahrscheinlicher der Durchbruch." + +AUF LIVE-DATEN BEREITS BESTÄTIGT (`analyze_pbreak_live.py`, 2275 Vorhersagen, beide +Hälften): Bruchrate nach n-ter Berührung im 30-min-Fenster + 1. 38,3 / 41,7 % 3. 43,2 / 45,1 % + 2. 40,8 / 45,6 % 4. 45,1 / 47,4 % ← Maximum, +6/+4 Pp über Basis + 5.+ 37,8 / 41,8 % ← FÄLLT ZURÜCK unter die Basis (größte Gruppe, n≈1400) +Also ein **umgekehrtes U**: 2–4 Berührungen machen das Level mürbe, ab 5 in 30 min +ist es eine Range-Begrenzung und HÄLT. Das aktuelle Modell sieht davon nichts (sagt +über alle Berührungszahlen konstant ~23 % voraus). + +⚠ Die Live-Bestätigung ist schwach (beide „Hälften" nur 4 Tage auseinander, Buckets +n=82–139) — DESHALB dieser Test über 80k M5-Bars / 2 echte Halbjahre. +⚠ `backtest_srbreak.py` fand die Touch-Zahl früher „nicht robust" — allerdings ohne +Fenster-Definition und ohne die 5+-Umkehr. + +WAS HIER ANDERS IST ALS IN `backtest_srclose_prob.py`: + * **M30-Level** (wie live seit 2026-07-30), nicht die alten M5-Pivots. + * Direkter Vergleich **4 Merkmale (Live-Modell) vs. 5 Merkmale (+Touch-Zahl)** — + ohne diesen Kontrast wäre nicht unterscheidbar, ob eine bessere AUC vom neuen + Merkmal kommt oder von der Neuanpassung. + * Urteil: das 5-Merkmal-Modell muss in BEIDEN Hälften besser sein (AUC UND R-Ertrag). + +Die Touch-Zahl wird als **umgekehrtes U** kodiert (zwei Dummies statt eines linearen +Terms): `t_mid` = 1 bei 2–4 Berührungen, `t_many` = 1 ab 5. Ein linearer Term würde +die gemessene Umkehr systematisch verfehlen. +""" +import sys +from collections import deque + +import numpy as np +import MetaTrader5 as mt5 + +from core.analysis import calc_trend_angle +from core.wave_rec import (_EMA_FAST, _EMA_SLOW, _N_BARS, _ANGLE_LR, _ANGLE_DEAD, + _REVERSAL_STRETCH, _STRETCH_MAX) + +_MAXH = 200; _ATRMIN = 0.12; _SL_ATR = 2.0; _TRAILON = 0.3; _MULT = 1.5; _BE = 1.3 +_LOCK_START = 3.5; _LOCK_SCALE = 0.6; _LOCK_MIN = 1.2; _TP_INIT = 3.5 +_PIV_K = 3; _BRK_W = 12; _BRK_ATR = 0.5 +_M30 = 6; _PIV_LOOKBACK = 50 # M30-Level wie live +_ZONE = 0.15 # „am Level" = |Kurs−Level| <= X×ATR (wie live) +_TWIN = 6 # Touch-Fenster in M5-Bars (6 = 30 min, wie live) + + +def _ema_series(v, p): + k = 2.0 / (p + 1); o = []; e = v[0] + for i, x in enumerate(v): + e = x if i == 0 else x * k + e * (1 - k) + o.append(e) + return o + + +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]))) + out = [] + for i in range(len(C)): + if not i: + out.append(None); continue + w = t[max(1, i - p + 1):i + 1] + out.append(sum(w) / max(1, len(w))) + return out + + +def build_levels(H, L): + """M30-Pivots k=3, kausal (Pivot erst 3 M30-Bars später bekannt) — wie live.""" + nm = len(H) // _M30 + mh = [max(H[i * _M30:(i + 1) * _M30]) for i in range(nm)] + ml = [min(L[i * _M30:(i + 1) * _M30]) for i in range(nm)] + born = [[] for _ in range(nm)] + for p in range(_PIV_K, nm - _PIV_K): + if mh[p] == max(mh[p - _PIV_K:p + _PIV_K + 1]) and p + _PIV_K < nm: + born[p + _PIV_K].append(mh[p]) + if ml[p] == min(ml[p - _PIV_K:p + _PIV_K + 1]) and p + _PIV_K < nm: + born[p + _PIV_K].append(ml[p]) + lv = []; win = deque(); cur = [] + for m in range(nm): + win.append(born[m]); cur.extend(born[m]) + while len(win) > _PIV_LOOKBACK: + for x in win.popleft(): + try: cur.remove(x) + except ValueError: pass + lv.append(sorted(cur)) + return lv + + +def touch_count(H, L, level, atr, jt, win): + """Wievielte Berührung dieses Levels innerhalb der letzten `win` Bars? + + Gezählt werden EPISODEN (Eintritte in die Zone |Kurs−Level| <= _ZONE×ATR nach + einem Bar außerhalb) — nicht Bars, sonst zählt ein langes Verweilen als viele + Berührungen. Die aktuelle Berührung bei jt zählt mit, Ergebnis also >= 1. + """ + z = _ZONE * atr + n = 0; inside_prev = False + for j in range(max(0, jt - win), jt + 1): + inside = (L[j] <= level + z) and (H[j] >= level - z) + if inside and not inside_prev: + n += 1 + inside_prev = inside + return max(1, n) + + +def sim_run(entry, d, atr, H, L, C, j0): + sl = entry - d * _SL_ATR * atr; tp = entry + d * _TP_INIT * atr + hw = entry; rank = 0 + end = min(j0 + _MAXH, len(C) - 1) + for j in range(j0, end + 1): + hi, lo = H[j], L[j] + if (lo <= sl) if d > 0 else (hi >= sl): return (sl - entry) * d / atr + if (hi >= tp) if d > 0 else (lo <= tp): return (tp - entry) * d / atr + hw = max(hw, hi) if d > 0 else min(hw, lo) + profit = (hw - entry) * d + ph = 0 if profit < _TRAILON * atr else (1 if profit < _LOCK_START * atr else 2) + if ph < rank: ph = rank + rank = ph + if ph == 1: + cand = hw - d * _MULT * atr + if profit >= _BE * atr: cand = max(cand, entry) if d > 0 else min(cand, entry) + sl = max(sl, cand) if d > 0 else min(sl, cand) + elif ph == 2: + tm = max(_LOCK_MIN, _MULT * _LOCK_SCALE); cand = hw - d * tm * atr + cand = max(cand, entry) if d > 0 else min(cand, entry) + sl = max(sl, cand) if d > 0 else min(sl, cand) + return (C[end] - entry) * d / atr + + +def collect(a, b, H, L, C, EF, ES, AT, SP, LV): + TH = _REVERSAL_STRETCH; out = [] + for i in range(max(a, _N_BARS, 320), min(b, len(C) - _MAXH - 1)): + atr = AT[i] + if not atr or atr <= 0: continue + atr = max(atr, _ATRMIN); es = ES[i]; ef = EF[i] + stretch = (C[i] - es) / atr + ang = calc_trend_angle(C[i - _ANGLE_LR - 2:i], _ANGLE_LR); ad = ang - 90.0 + d = 0 + if stretch <= -TH and ad >= _ANGLE_DEAD: d = 1 + elif stretch >= TH and ad <= -_ANGLE_DEAD: d = -1 + elif abs(stretch) < _STRETCH_MAX: d = 1 if ef > es else -1 if ef < es else 0 + if not d: continue + entry = C[i]; cost = (SP[i] if SP[i] > 0 else 0.0225) / atr + R_run = sim_run(entry, d, atr, H, L, C, i + 1) - cost + rec = {"R_run": R_run, "touched": False} + lv = LV[min(i // _M30, len(LV) - 1)] + level = None + if lv: + if d > 0: + cands = [p for p in lv if p > entry + 0.3 * atr] + level = min(cands) if cands else None + else: + cands = [p for p in lv if p < entry - 0.3 * atr] + level = max(cands) if cands else None + if level is not None: + jt = None + for j in range(i + 1, min(i + _MAXH, len(C) - _BRK_W)): + if ((H[j] >= level) if d > 0 else (L[j] <= level)): jt = j; break + if ((entry - L[j]) if d > 0 else (H[j] - entry)) >= 2.0 * atr: break + if jt is not None: + up = level + d * _BRK_ATR * atr; dn = level - d * _BRK_ATR * atr + brk = False + for j in range(jt, min(jt + _BRK_W, len(H))): + if (H[j] >= up) if d > 0 else (L[j] <= up): brk = True; break + if (L[j] <= dn) if d > 0 else (H[j] >= dn): brk = False; break + tc = touch_count(H, L, level, atr, jt, _TWIN) + base4 = [(C[jt] - C[max(0, jt - 6)]) * d / atr, + (C[jt] - C[max(0, jt - 3)]) * d / atr, + 1.0 if (EF[jt] - ES[jt]) * d > 0 else 0.0, + abs(level - entry) / atr] + rec.update(touched=True, brk=1.0 if brk else 0.0, tc=tc, + R_close=(level - entry) * d / atr - cost, + feat4=base4, + # umgekehrtes U: 2–4 Berührungen vs. 5+ + feat5=base4 + [1.0 if 2 <= tc <= 4 else 0.0, + 1.0 if tc >= 5 else 0.0]) + out.append(rec) + return out + + +def fit_logreg(X, y, iters=3000, lr=0.3): + n, m = X.shape + Xb = np.hstack([np.ones((n, 1)), X]); w = np.zeros(m + 1) + for _ in range(iters): + p = 1 / (1 + np.exp(-(Xb @ w))) + w -= lr * (Xb.T @ (p - y)) / n + return w + + +def auc(ps, ys): + order = np.argsort(ps); ys = np.asarray(ys)[order] + pos = ys.sum(); neg = len(ys) - pos + if not pos or not neg: return 0.5 + ranks = np.arange(1, len(ys) + 1) + return float((ranks[ys == 1].sum() - pos * (pos + 1) / 2) / (pos * neg)) + + +def main(): + global _TWIN + n = int(sys.argv[1]) if len(sys.argv) > 1 else 80000 + if len(sys.argv) > 2: + _TWIN = int(sys.argv[2]) + mt5.initialize(); sym = None + for c in ("SpotCrude", "USOIL", "WTI", "XTIUSD"): + if mt5.symbol_info(c): sym = c; break + si = mt5.symbol_info(sym); point = si.point + bars = mt5.copy_rates_from_pos(sym, mt5.TIMEFRAME_M5, 0, n) + 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] + EF = _ema_series(C, _EMA_FAST); ES = _ema_series(C, _EMA_SLOW); AT = _atr_series(H, L, C) + LV = build_levels(H, L) + mid = len(C) // 2 + ev1 = collect(_N_BARS, mid, H, L, C, EF, ES, AT, SP, LV) + ev2 = collect(mid, len(C), H, L, C, EF, ES, AT, SP, LV) + t1 = [e for e in ev1 if e["touched"]]; t2 = [e for e in ev2 if e["touched"]] + + print("=" * 98) + print(f" P(break) + TOUCH-ZAHL — {sym} M5 ({len(C)} Bars), M30-Level wie live") + print(f" Touch-Fenster {_TWIN} Bars = {_TWIN*5} min · Zone {_ZONE}×ATR") + print(f" Fit auf H1 ({len(t1)} Touches), geprüft auf H2 ({len(t2)} Touches)") + print("=" * 98) + + y1 = np.array([e["brk"] for e in t1]); y2 = np.array([e["brk"] for e in t2]) + print(f"\n Basisrate Durchbruch: H1 {100*y1.mean():.1f} % H2 {100*y2.mean():.1f} %") + + # ── Rohbefund: Bruchrate je Berührungszahl, BEIDE Hälften + print(f"\n Rohbefund — Bruchrate je Berührungszahl (User-Hypothese):") + print(f" {'Berührung':<12}{'H1 n':>7}{'H1 Bruch':>11}{'H2 n':>7}{'H2 Bruch':>11}") + for b in (1, 2, 3, 4, 5): + lab = f"{b}." if b < 5 else "5.+" + a1 = [e["brk"] for e in t1 if (e["tc"] == b if b < 5 else e["tc"] >= 5)] + a2 = [e["brk"] for e in t2 if (e["tc"] == b if b < 5 else e["tc"] >= 5)] + if len(a1) < 20 or len(a2) < 20: + print(f" {lab:<12}{len(a1):>7}{'-':>11}{len(a2):>7}{'-':>11}"); continue + print(f" {lab:<12}{len(a1):>7}{100*np.mean(a1):>10.1f}%" + f"{len(a2):>7}{100*np.mean(a2):>10.1f}%") + + # ── Modellvergleich 4 vs 5 Merkmale + results = {} + for tag, key in (("4 Merkmale (Live-Modell)", "feat4"), + ("5 Merkmale (+Touch-Zahl)", "feat5")): + X1 = np.array([e[key] for e in t1]); X2 = np.array([e[key] for e in t2]) + mu = X1.mean(0); sd = X1.std(0) + 1e-9 + w = fit_logreg((X1 - mu) / sd, y1) + p2 = 1 / (1 + np.exp(-(np.hstack([np.ones((len(X2), 1)), (X2 - mu) / sd]) @ w))) + p1 = 1 / (1 + np.exp(-(np.hstack([np.ones((len(X1), 1)), (X1 - mu) / sd]) @ w))) + results[key] = (mu, sd, w, p1, p2) + print(f"\n {tag}") + print(f" AUC in-sample H1 {auc(p1, y1):.3f} out-of-sample H2 {auc(p2, y2):.3f}") + if key == "feat5": + print(f" Gewichte Touch: 2–4 Ber. {w[5]:+.3f} · 5+ Ber. {w[6]:+.3f}") + print(f" Kalibrierung H2: {'Bucket':<12}{'n':>6}{'vorherg.':>10}{'real':>8}") + for lo, hi in ((0, .2), (.2, .35), (.35, .5), (.5, .65), (.65, 1.01)): + sel = [(p, y) for p, y in zip(p2, y2) if lo <= p < hi] + if len(sel) < 40: continue + print(f" {lo:.2f}–{hi:<7.2f}{len(sel):>6}" + f"{100*np.mean([p for p, _ in sel]):>9.1f}%" + f"{100*np.mean([y for _, y in sel]):>7.1f}%") + + # ── R-Ertrag der Close-Regel, beide Modelle, beide Hälften + print(f"\n{'='*98}\n R-ERTRAG der Close-Regel (Echtkosten, Baseline = immer laufen lassen)\n{'='*98}") + for lbl, ev, ts, idx in (("H1", ev1, t1, 3), ("H2", ev2, t2, 4)): + base = sum(e["R_run"] for e in ev) + untouched = sum(e["R_run"] for e in ev if not e["touched"]) + print(f"\n {lbl}: {len(ev)} Wellen · {len(ts)} Touches · Baseline ΣR={base:+.0f}") + print(f" {'Schwelle':<10}{'4 Merkmale':>14}{'5 Merkmale':>14}{'Differenz':>12}") + for X in (0.35, 0.45, 0.55, 0.60): + row = [] + for key in ("feat4", "feat5"): + mu, sd, w, p1, p2 = results[key] + ps = p1 if lbl == "H1" else p2 + tot = sum((e["R_close"] if p < X else e["R_run"]) + for e, p in zip(ts, ps)) + untouched + row.append(tot) + print(f" P≥{X:.2f} {row[0]:>+14.0f}{row[1]:>+14.0f}{row[1]-row[0]:>+12.0f}") + + +if __name__ == "__main__": + main()