From 0944b1c13aae80a8af82284d158dd07f214ef84b Mon Sep 17 00:00:00 2001 From: Axel Hocks Date: Fri, 31 Jul 2026 08:57:16 +0200 Subject: [PATCH] P(break) neu trainiert auf live-spiegelnder Stichprobe (Weg b) - eingebaut Behebt die am selben Tag gefundene Merkmals-Diskrepanz: das alte Modell war auf "Anlauf zu einem beim Entry FIXIERTEN Level" trainiert, live waehlt _draw_levels das Level jede Sekunde neu -> mom3 live 0,15 statt 1,45 -> Ausgabe immer ~23 % -> Gate seit Inbetriebnahme faktisch nie aktiv. backtest_pbreak_retrain.py: Stichprobe spiegelt den Live-Pfad (Level dynamisch mit Hysterese, gesampelt an JEDEM Bar im 0,15xATR-Band, keine Anlauf-Bedingung). Kontrolle: Merkmalsmittel der Stichprobe (mom6 0,203 / mom3 0,202) treffen die live rekonstruierten Werte (0,181 / 0,149). AUC auf dieser Stichprobe (H2, out-of-sample): ALT-Modell 0,368 (schlechter als Zufall, 19,2 % vs real 39,4 %) NEU-Modell 0,654 kalibriert backtest_pbreak_rvalue.py (R-Ertrag, Echtkosten, Baseline = nur Trailing): NEU schlaegt ALT in BEIDEN Haelften bei JEDER Schwelle. Gegen die Baseline gewinnt es bei 0,35/0,45/0,55 in beiden (bei 0,60 kippt H1). Bestwert 0,35: H1 -149 vs -506 (+357), H2 +1308 vs -110 (+1418). Nebenbefund: das ALTE Modell war in H1 schlechter als gar kein Auto-Close. EINGEBAUT: neue _PB_MU/_SD/_W (final auf allen 80k gefittet, n=76.542, Basisrate 37,8 %, nach H1->H2-Validierung; alte Werte als Kommentar) + sr_close_pbreak 0,55 -> 0,35, Config-Waechter-Anker mitgezogen. Verifiziert: Chart-Linien zeigen 43 %/36 % statt 8-15 %. ACHTUNG - invertiert eine alte Projekt-Regel: mom3-Gewicht dreht das Vorzeichen (+1,4330 -> -0,5420). Alt: "kriecht ans Level -> 26 % Bruch, mit Schwung -> laufen lassen". Neu: Anlauf-Schub -> 26,5 % (Level absorbiert ihn und haelt), Schwung weg vom Level -> 51 %. Beides gilt in seiner Population (fixiertes vs. dynamisches Level); fuer den Live-Pfad gilt die neue Lesart. Offen: auch NEU schliesst bei 0,35 noch ~94-97 % der Positionen (waehlt vor allem den besseren Moment). Touch-Zahl bringt im neuen Modell nichts mehr (0,652 vs 0,654) - die dynamische Level-Wahl erfasst den Effekt bereits. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 39 ++++- backtest_pbreak_retrain.py | 306 +++++++++++++++++++++++++++++++++++++ backtest_pbreak_rvalue.py | 181 ++++++++++++++++++++++ core/engine.py | 32 +++- measurement_reminder.py | 10 +- 5 files changed, 557 insertions(+), 11 deletions(-) create mode 100644 backtest_pbreak_retrain.py create mode 100644 backtest_pbreak_rvalue.py diff --git a/CLAUDE.md b/CLAUDE.md index d96b314..86b409d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1511,10 +1511,41 @@ dort bereits nachvalidiert, ØR +0,305.) **Eine** Oberfläche: (allerdings ohne Fenster-Definition und ohne die 5+-Umkehr). **Nächster Schritt: als 5. Merkmal in `backtest_srclose_prob.py` aufnehmen und über die 34.771 Touches / 2 echten Halbjahre neu fitten** — dort ist messbar, ob die AUC steigt. - **Empfehlung: `auto_sr_close=false`** (zurück auf reines - Trailing = der gemessene Vergleichsmaßstab). Eine reine Schwellensenkung repariert es - NICHT (sie ändert die Anzahl, nicht die Auswahl); 0,25–0,35 wäre nur ein Kompromiss, - falls die Mechanik erhalten bleiben soll. + **✅ BEHOBEN am selben Tag (Weg b, User-Entscheidung): MODELL NEU TRAINIERT.** + `backtest_pbreak_retrain.py` erzeugt eine **live-spiegelnde Stichprobe** — Level + dynamisch mit Hysterese wie `_draw_levels`, gesampelt an JEDEM Bar im 0,15×ATR-Band + (KEINE Anlauf-Bedingung), Ausgang/Fenster wie live. Kontrolle: die Merkmalsmittel + dieser Stichprobe (mom6 0,203 · mom3 0,202) treffen die live rekonstruierten Werte + (0,181 · 0,149) — sie bildet den Live-Pfad also wirklich ab. + | | ALT-Modell | NEU-Modell | + |---|---|---| + | AUC auf live-spiegelnder Stichprobe (H2, oos) | **0,368** | **0,654** | + | Ø-Vorhersage vs. real | 19,2 % vs 39,4 % | kalibriert | + **R-Ertrag (`backtest_pbreak_rvalue.py`, Echtkosten, Baseline = nur Trailing):** NEU + schlägt ALT in **BEIDEN Hälften bei JEDER Schwelle**; gegen die Baseline gewinnt es + bei 0,35/0,45/0,55 in beiden (bei 0,60 kippt H1). **Bestwert 0,35** — H1 −149 vs + Baseline −506 (**+357**), H2 +1308 vs −110 (**+1418**). ⚠ Nebenbefund: das ALTE + Modell war in H1 **schlechter als gar kein Auto-Close** (−653 vs −506), in H2 besser + (+576 vs −110) — genau die Regime-Abhängigkeit, die live als −8 €/Trade sichtbar war. + **Eingebaut:** neue `_PB_MU/_SD/_W` in `engine.py` (final auf allen 80k Bars gefittet, + n=76.542, Basisrate 37,8 %, nachdem H1→H2 validiert war; alte Werte als Kommentar für + den Rückweg) + **`sr_close_pbreak=0.35`** (Config-Wächter-Anker mitgezogen). + Verifiziert: Chart-Linien zeigen jetzt 43 %/36 % statt 8–15 %. + ⚠⚠ **DAS NEUE MODELL INVERTIERT EINE ALTE PROJEKT-REGEL — bewusst so:** das Gewicht + von mom3 dreht das Vorzeichen (**+1,4330 → −0,5420**). Der alte Befund + (`backtest_srbreak.py`) lautete „Kurs KRIECHT ans Level → P(break) nur 26 %, mit + Schwung → laufen lassen (41–43 %)". Das neue Modell sagt das Gegenteil: Anlauf-Schub + → 26,5 % (das Level ABSORBIERT den Schub und hält), Schwung weg vom Level → 51 %. + **Beides gilt in seiner eigenen Population** — die alte Messung betraf den Anlauf zu + einem FIXIERTEN Level, die neue das Verweilen an einem DYNAMISCH gewählten. Für den + Live-Pfad gilt die neue Lesart; die alte Faustregel weiter oben im Text bezieht sich + auf die Anlauf-Situation und ist dort nicht widerlegt. + ⚠ **Weiterhin offen:** auch das neue Modell schließt bei 0,35 noch ~94–97 % der + Positionen (es wählt vor allem den BESSEREN Moment, ist also kein wirklich selektives + Gate). Und die **Touch-Zahl bringt im neuen Modell nichts mehr** (AUC 0,652 vs 0,654) + — die dynamische Level-Wahl erfasst denselben Effekt bereits; die User-Idee hat auf + den Fehler GEZEIGT, die Ursache lag eine Ebene tiefer. **B4/B5-Pflicht:** Live-AUC in + ~2 Wochen gegen 0,65 halten (Reminder `pbreak_accuracy_v2`, 800 Vorhersagen ab 01.08.). - **P(Trendumkehr) = GEMESSEN, NICHT BERECHENBAR (`analyze_reversal.py`, 2026-07-31, User-Frage „können wir die Trendumkehr-Wahrscheinlichkeit berechnen?"):** Aufgebaut exakt wie das erfolgreiche P(break)-Modell — mechanisch scharfes **Barriere-Rennen** diff --git a/backtest_pbreak_retrain.py b/backtest_pbreak_retrain.py new file mode 100644 index 0000000..5b9fb60 --- /dev/null +++ b/backtest_pbreak_retrain.py @@ -0,0 +1,306 @@ +#!/usr/bin/env python3 +"""P(break) NEU TRAINIEREN auf einer LIVE-SPIEGELNDEN Stichprobe (Weg b, 2026-07-31). + +WARUM: `analyze_pbreak_live.py` + `backtest_pbreak_touches.py` haben belegt, dass das +Modell nicht verfallen ist, sondern live eine ANDERE Merkmalsverteilung bekommt: + + Merkmal Live-Mittel Training-µ Differenz + mom3 0,149 1,4523 −1,303 + mom6 0,181 1,6565 −1,476 + +Ursache: das ALTE Training fixiert das Level beim Entry (≥0,3×ATR entfernt) und wartet +auf den ersten Touch nach einem echten ANLAUF → mom3 ≈ 1,45×ATR (ein Schub). **Live +wählt `_draw_levels` das nächstgelegene Level jede Sekunde NEU** (mit Hysterese) — der +Kurs steht dann oft neben einem Level, das gerade erst zum nächsten wurde, ohne je +darauf zugelaufen zu sein → mom3 ≈ 0,15. Das Modell antwortet konsequent „kein Schub → +kein Durchbruch → 23 %", liegt damit immer unter der Schwelle 0,55, und das Gate war +seit Inbetriebnahme faktisch nie aktiv (91 % aller Berührungen geschlossen). + +WAS DIESES SKRIPT ANDERS MACHT — die Stichprobe spiegelt den Live-Pfad: + * Level-Auswahl **dynamisch mit Hysterese**, wie `engine._draw_levels`: die Linie + bleibt stehen, bis der Kurs sie WIRKLICH durchbricht (±0,1×ATR); nur wenn sie + >1,5×ATR entfernt ist UND ein deutlich näheres Level existiert, wird gewechselt. + * Gesampelt wird an **JEDEM Bar** im 0,15×ATR-Band um dieses Level (= live + `at_level`) — KEINE Anlauf-Bedingung. + * Ausgang identisch zu live/Training: erreicht der Kurs binnen 12 M5-Bars zuerst + `level ± 0,5×ATR` in Bruchrichtung (break) oder in Gegenrichtung (bounce); + Timeout zählt als bounce. + * Merkmale wie live: mom6/mom3 (M5, ×Richtung), wt (EMA-Richtung), dist + (|level − entry|/ATR), plus optional die Touch-Zahl (User-Idee, im alten + Backtest +0,010 AUC). + +KONTROLLE (entscheidend): das ALTE Modell wird auf DERSELBEN live-spiegelnden +Stichprobe ausgewertet. Erwartung ~AUC 0,5 (= der live beobachtete Wert). Nur wenn +das neue Modell dort deutlich besser ist, ist der Umbau belegt — sonst hätte man +lediglich zwei Stichproben verglichen. + +Urteil: neues Modell muss out-of-sample (Fit H1 → H2) sowohl TRENNEN (AUC) als auch +KALIBRIERT sein. Beides, sonst kein Einbau. +""" +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 +_PIV_K = 3; _BRK_W = 12; _BRK_ATR = 0.5 +_M30 = 6; _PIV_LOOKBACK = 50 +_ZONE = 0.15 # „am Level" (wie live `at_level`) +_CLUSTER = 0.5 # Level-Clusterung in ×ATR (wie `_draw_levels`) +_HYST = 0.1 # echter Durchbruch erst ab ±X×ATR (Hysterese) +_FAR = 1.5 # alte Linie gilt als „irrelevant weit" ab X×ATR +_TWIN = 6 # Touch-Fenster in M5-Bars (30 min) + +# Altes Live-Modell (M30-Fit) — dient als KONTROLLE auf derselben Stichprobe +_OLD_MU = (1.6565, 1.4523, 0.9465, 1.9133) +_OLD_SD = (2.5291, 2.4177, 0.2250, 2.1257) +_OLD_W = (-0.3264, 0.4719, 1.4330, 0.0943, -0.1885) + + +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, geclustert — wie `wave.pb_levels30` + `_draw_levels`.""" + 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 _cluster(levels, atr): + """Nahe Pivots zu einer Linie zusammenfassen (~0,5×ATR) — wie `_draw_levels`.""" + if not levels: + return [] + out = []; grp = [levels[0]] + for x in levels[1:]: + if x - grp[-1] <= _CLUSTER * atr: + grp.append(x) + else: + out.append(sum(grp) / len(grp)); grp = [x] + out.append(sum(grp) / len(grp)) + return out + + +def pick_level(cands, px, d, atr, prev): + """Ziel-Level in Trade-Richtung MIT Hysterese (Nachbau von `_draw_levels`). + + d>0 → nächster Widerstand ÜBER px · d<0 → nächste Unterstützung UNTER px. + `prev` = zuletzt gezeichnete Linie; sie bleibt stehen, bis der Kurs sie wirklich + durchbricht (±_HYST×ATR) — außer sie ist >_FAR×ATR weg und es gibt eine deutlich + nähere. + """ + side = [x for x in cands if (x > px if d > 0 else x < px)] + fresh = (min(side) if d > 0 else max(side)) if side else None + if prev is not None: + broken = (px > prev + _HYST * atr) if d > 0 else (px < prev - _HYST * atr) + if not broken: + far = abs(prev - px) > _FAR * atr + if not (far and fresh is not None and abs(fresh - px) < abs(prev - px) - 0.5 * atr): + return prev + return fresh + + +def outcome(H, L, level, atr, j, d): + """break/bounce wie live: level ± 0,5×ATR, Fenster 12 Bars, Timeout = bounce.""" + up = level + d * _BRK_ATR * atr + dn = level - d * _BRK_ATR * atr + for k in range(j, min(j + _BRK_W, len(H))): + if (H[k] >= up) if d > 0 else (L[k] <= up): + return 1 + if (L[k] <= dn) if d > 0 else (H[k] >= dn): + return 0 + return 0 + + +def collect(a, b, H, L, C, EF, ES, AT, LV): + """Live-spiegelnde Stichprobe: jeder Bar im 0,15×ATR-Band um das dyn. Level.""" + TH = _REVERSAL_STRETCH + out = [] + i = max(a, _N_BARS, 320) + while i < min(b, len(C) - _MAXH - 1): + atr = AT[i] + if not atr or atr <= 0: + i += 1; 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: + i += 1; continue + entry = C[i] + # ── Position „läuft" bis _MAXH Bars; dabei jeden Bar prüfen (wie `_pos_loop`) + prev_lvl = None; hist = deque() + for j in range(i + 1, min(i + _MAXH, len(C) - _BRK_W)): + aj = AT[j] + if not aj: + continue + aj = max(aj, _ATRMIN) + lv = LV[min(j // _M30, len(LV) - 1)] + cl = _cluster(lv, aj) + level = pick_level(cl, C[j], d, aj, prev_lvl) + prev_lvl = level + if level is None: + continue + at_level = abs(C[j] - level) <= _ZONE * aj + hist.append((j, at_level, level)) + while hist and hist[0][0] < j - _TWIN: + hist.popleft() + if not at_level: + continue + # Touch-Zahl: Eintritte in die Zone desselben Levels im Fenster + tc = 0; prev_in = False + for _jj, inz, lvl2 in hist: + inside = inz and abs(lvl2 - level) < 1e-9 + if inside and not prev_in: + tc += 1 + prev_in = inside + tc = max(1, tc) + y = outcome(H, L, level, aj, j, d) + feat = [(C[j] - C[max(0, j - 6)]) * d / aj, + (C[j] - C[max(0, j - 3)]) * d / aj, + 1.0 if (EF[j] - ES[j]) * d > 0 else 0.0, + abs(level - entry) / aj] + out.append({"feat": feat, "y": y, "tc": tc, + "feat5": feat + [1.0 if 2 <= tc <= 4 else 0.0, + 1.0 if tc >= 5 else 0.0]}) + i += 12 # nächste „Position" versetzt starten (weniger Überlappung) + 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): + ps = np.asarray(ps); ys = np.asarray(ys) + order = np.argsort(ps); ys = 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 calib(ps, ys, label): + print(f" Kalibrierung {label}: {'Bucket':<12}{'n':>7}{'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(ps, ys) if lo <= p < hi] + if len(sel) < 40: + continue + print(f" {lo:.2f}–{hi:<7.2f}{len(sel):>7}" + f"{100*np.mean([p for p, _ in sel]):>9.1f}%" + f"{100*np.mean([y for _, y in sel]):>7.1f}%") + + +def old_model(feat): + z = _OLD_W[0] + for k in range(4): + z += _OLD_W[k + 1] * (feat[k] - _OLD_MU[k]) / _OLD_SD[k] + return 1 / (1 + np.exp(-max(-30, min(30, z)))) + + +def main(): + n = int(sys.argv[1]) if len(sys.argv) > 1 else 80000 + mt5.initialize(); sym = None + for c in ("SpotCrude", "USOIL", "WTI", "XTIUSD"): + if mt5.symbol_info(c): sym = c; break + 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] + 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 + s1 = collect(_N_BARS, mid, H, L, C, EF, ES, AT, LV) + s2 = collect(mid, len(C), H, L, C, EF, ES, AT, LV) + + print("=" * 100) + print(f" P(break) NEU TRAINIERT auf live-spiegelnder Stichprobe — {sym} M5 ({len(C)} Bars)") + print(f" Level dynamisch mit Hysterese · gesampelt an JEDEM Bar im {_ZONE}×ATR-Band") + print(f" H1 n={len(s1)} H2 n={len(s2)}") + print("=" * 100) + if len(s1) < 500 or len(s2) < 500: + print("zu wenige Stichproben"); return + + y1 = np.array([e["y"] for e in s1], float); y2 = np.array([e["y"] for e in s2], float) + print(f"\n Basisrate Durchbruch: H1 {100*y1.mean():.1f} % H2 {100*y2.mean():.1f} %") + + # Merkmalsverteilung — spiegelt sie wirklich die Live-Werte? + F1 = np.array([e["feat"] for e in s1]) + print(f"\n Merkmalsverteilung (Kontrolle gegen die Live-Rekonstruktion):") + print(f" {'Merkmal':<8}{'hier µ':>10}{'ALT-Training µ':>16}{'Live gemessen':>15}") + for k, nm, old, live in ((0, "mom6", 1.6565, 0.181), (1, "mom3", 1.4523, 0.149)): + print(f" {nm:<8}{F1[:, k].mean():>10.3f}{old:>16.3f}{live:>15.3f}") + + # ── KONTROLLE: altes Modell auf DIESER Stichprobe + p_old2 = np.array([old_model(e["feat"]) for e in s2]) + print(f"\n KONTROLLE — ALTES Modell auf dieser Stichprobe (H2):") + print(f" AUC {auc(p_old2, y2):.3f} Ø-Vorhersage {100*p_old2.mean():.1f} % " + f"vs. real {100*y2.mean():.1f} %") + print(f" (live beobachtet: AUC 0,539 · 23 % vs 41 % — passt es zusammen?)") + + # ── NEUES Modell, 4 und 6 Merkmale + for tag, key in (("NEU 4 Merkmale", "feat"), ("NEU 6 Merkmale (+Touch-Zahl)", "feat5")): + X1 = np.array([e[key] for e in s1]); X2 = np.array([e[key] for e in s2]) + 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))) + print(f"\n {tag}") + print(f" AUC in-sample H1 {auc(p1, y1):.3f} out-of-sample H2 {auc(p2, y2):.3f}") + calib(p2, y2, "H2") + if key == "feat": + print(f"\n → zum Einbetten (falls dieses Modell gewinnt):") + print(f" _PB_MU = ({', '.join(f'{x:.4f}' for x in mu)})") + print(f" _PB_SD = ({', '.join(f'{x:.4f}' for x in sd)})") + print(f" _PB_W = ({', '.join(f'{x:.4f}' for x in w)})") + + +if __name__ == "__main__": + main() diff --git a/backtest_pbreak_rvalue.py b/backtest_pbreak_rvalue.py new file mode 100644 index 0000000..eda7efc --- /dev/null +++ b/backtest_pbreak_rvalue.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 +"""R-Ertrag: bringt das NEU trainierte P(break)-Modell live mehr als das alte? + +Zweiter, entscheidender Teil zu `backtest_pbreak_retrain.py`. Dort wurde gezeigt, +dass das ALTE Modell auf live-typischen Situationen nur AUC ~0,34 erreicht (also +schlechter als Zufall) und das neue ~0,66. **AUC allein reicht aber nicht** — die +Projektregel verlangt einen besseren R-Ertrag in BEIDEN Hälften. + +SIMULIERTE REGEL (exakt der Live-Pfad): + Position öffnet beim Wave-Signal. Danach jeden Bar prüfen: + ist der Kurs im 0,15×ATR-Band um das dynamisch gewählte Level (mit Hysterese)? + → P(break) berechnen. Ist P < Schwelle X → JETZT schließen (R_close). + → sonst weiterlaufen lassen. + Wird nie geschlossen → R_run (Live-Trailing: SL 2,0 · Trail 1,5 ab 0,3 · BE 1,3). + Alles mit Echtkosten (Bar-Spread/ATR). + +VERGLICHEN WERDEN: + * Baseline — nie am Level schließen (nur Trailing) = der ehrliche Maßstab + * ALT-Modell — die aktuell live eingebetteten _PB_MU/_SD/_W + * NEU-Modell — auf der live-spiegelnden Stichprobe gefittet (Fit auf H1!) + +⚠ Das NEUE Modell wird auf H1 gefittet und in H2 out-of-sample angewandt — sonst +wäre der Vergleich zirkulär. +""" +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) +from backtest_pbreak_retrain import ( + _ema_series, _atr_series, build_levels, _cluster, pick_level, + fit_logreg, _OLD_MU, _OLD_SD, _OLD_W, _ZONE, _ATRMIN, _M30) + +_MAXH = 200 +_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 + + +def sim_run(entry, d, atr, H, L, C, j0): + """Live-Exit (identisch zu backtest_srclose_prob.py).""" + 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 positions(a, b, H, L, C, EF, ES, AT, SP, LV): + """→ [{R_run, cost, events:[(feat, R_close)]}] — Events in zeitlicher Reihenfolge.""" + TH = _REVERSAL_STRETCH + out = [] + i = max(a, _N_BARS, 320) + while i < min(b, len(C) - _MAXH - 1): + atr = AT[i] + if not atr or atr <= 0: + i += 1; 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: + i += 1; continue + entry = C[i] + cost = (SP[i] if SP[i] > 0 else 0.0225) / atr + rec = {"R_run": sim_run(entry, d, atr, H, L, C, i + 1) - cost, "events": []} + prev_lvl = None + for j in range(i + 1, min(i + _MAXH, len(C) - 12)): + aj = AT[j] + if not aj: + continue + aj = max(aj, _ATRMIN) + lv = LV[min(j // _M30, len(LV) - 1)] + level = pick_level(_cluster(lv, aj), C[j], d, aj, prev_lvl) + prev_lvl = level + if level is None or abs(C[j] - level) > _ZONE * aj: + continue + feat = [(C[j] - C[max(0, j - 6)]) * d / aj, + (C[j] - C[max(0, j - 3)]) * d / aj, + 1.0 if (EF[j] - ES[j]) * d > 0 else 0.0, + abs(level - entry) / aj] + # Schließen am Level: der Kurs IST dort (±0,15×ATR) → C[j] als Fill + rec["events"].append((feat, (C[j] - entry) * d / aj - cost)) + out.append(rec) + i += 12 + return out + + +def p_old(feat): + z = _OLD_W[0] + for k in range(4): + z += _OLD_W[k + 1] * (feat[k] - _OLD_MU[k]) / _OLD_SD[k] + return 1 / (1 + np.exp(-max(-30, min(30, z)))) + + +def total(pos, pfun, X): + """ΣR der Regel: beim ERSTEN Event mit P 1 else 80000 + 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 + P1 = positions(_N_BARS, mid, H, L, C, EF, ES, AT, SP, LV) + P2 = positions(mid, len(C), H, L, C, EF, ES, AT, SP, LV) + + # NEUES Modell auf H1-Events fitten + X1 = np.array([f for r in P1 for f, _ in r["events"]]) + # Ausgang je Event wird hier NICHT gebraucht — wir fitten auf demselben + # Kriterium wie im Retrain-Skript, deshalb dort erzeugte Gewichte verwenden. + from backtest_pbreak_retrain import collect as rt_collect + s1 = rt_collect(_N_BARS, mid, H, L, C, EF, ES, AT, LV) + Xf = np.array([e["feat"] for e in s1]); yf = np.array([e["y"] for e in s1], float) + mu = Xf.mean(0); sd = Xf.std(0) + 1e-9 + w = fit_logreg((Xf - mu) / sd, yf) + + def p_new(feat): + z = w[0] + float(np.dot(w[1:], (np.array(feat) - mu) / sd)) + return 1 / (1 + np.exp(-max(-30, min(30, z)))) + + print("=" * 96) + print(f" R-ERTRAG der P(break)-Close-Regel — {sym} M5 ({len(C)} Bars), Echtkosten") + print(f" NEU-Modell auf H1 gefittet ({len(s1)} Events) → H2 ist out-of-sample") + print("=" * 96) + for lbl, P in (("H1 (in-sample)", P1), ("H2 (OUT-OF-SAMPLE)", P2)): + base = sum(r["R_run"] for r in P) + nev = sum(1 for r in P if r["events"]) + print(f"\n {lbl}: {len(P)} Positionen, davon {nev} mit Level-Kontakt") + print(f" Baseline (nur Trailing, nie am Level schließen): ΣR = {base:+.0f}") + print(f" {'Schwelle':<10}{'ALT ΣR':>10}{'ALT %zu':>9}{'NEU ΣR':>10}{'NEU %zu':>9}" + f"{'NEU vs ALT':>12}{'NEU vs Base':>13}") + for X in (0.35, 0.45, 0.55, 0.60): + so, co = total(P, p_old, X) + sn, cn = total(P, p_new, X) + print(f" P<{X:.2f} {so:>+10.0f}{100*co/max(1,len(P)):>8.0f}%" + f"{sn:>+10.0f}{100*cn/max(1,len(P)):>8.0f}%" + f"{sn-so:>+12.0f}{sn-base:>+13.0f}") + + +if __name__ == "__main__": + main() diff --git a/core/engine.py b/core/engine.py index 7b0946a..066e640 100644 --- a/core/engine.py +++ b/core/engine.py @@ -100,9 +100,35 @@ _STARTUP_GRACE_S = 60.0 # nach Bot-Start schließt der Bot 60 s lang KE # ⚠ Die Merkmale bleiben M5-basiert (mom6/mom3 auf M5-Bars) — nur die LEVEL sind M30. # Alte M5-Werte (falls je zurück): MU (0.7482,0.7036,0.9112,0.7072) · # SD (1.1597,0.9687,0.2844,0.6403) · W (-0.5853,0.1192,0.7820,0.1204,-0.2994). -_PB_MU = (1.6565, 1.4523, 0.9465, 1.9133) -_PB_SD = (2.5291, 2.4177, 0.2250, 2.1257) -_PB_W = (-0.3264, 0.4719, 1.4330, 0.0943, -0.1885) # [b0, mom6, mom3, wt, dist] +# ⚠⚠ NEU TRAINIERT 2026-07-31 auf einer LIVE-SPIEGELNDEN Stichprobe +# (`backtest_pbreak_retrain.py` + `backtest_pbreak_rvalue.py`). GRUND: die alten +# Werte waren auf einer Situation trainiert, die live gar nicht vorkommt. +# Training ALT: Level beim Entry FIXIERT (≥0,3×ATR weg), warten auf den ersten +# Touch nach einem echten ANLAUF → mom3 ≈ 1,45×ATR (ein Schub) +# LIVE: `_draw_levels` wählt das nächste Level JEDE SEKUNDE NEU (Hysterese) +# → der Kurs steht neben einem Level, das gerade erst das nächste +# wurde, ohne Anlauf → mom3 ≈ 0,15×ATR +# Gemessen (aus `candles_m1` rekonstruiert, n=2275): mom3 live 0,149 vs. ALT-µ 1,4523 +# (−1,303) · mom6 0,181 vs 1,6565 (−1,476). Der daraus folgende z-Versatz (−1,05) +# erklärt die gesamte Fehlkalibrierung: das Modell sagte live IMMER ~23 % (real 41 %), +# lag damit stets unter der Schwelle → **das Gate war seit Inbetriebnahme nie aktiv** +# (91 % aller Berührungen geschlossen = der 2× verworfene pauschale S/R-Close). +# KONTROLLE auf der live-spiegelnden Stichprobe: ALT erreicht dort **AUC 0,368** +# (schlechter als Zufall!), NEU **0,654** out-of-sample (Fit H1 → H2). +# R-Ertrag (Echtkosten, Baseline = nur Trailing): NEU schlägt ALT in BEIDEN Hälften +# bei JEDER Schwelle; gegen die Baseline gewinnt es bei 0,35/0,45/0,55 in beiden +# (bei 0,60 kippt H1). Bestwert **0,35** (H1 +357, H2 +1418 gegen Baseline) → +# `sr_close_pbreak` entsprechend auf 0,35 gesetzt. +# ⚠ Das Gewicht von mom3 DREHT das Vorzeichen (+1,4330 → −0,5420): im Anlauf-Kontext +# hieß Schwung „Level bricht", im Klebe-am-Level-Kontext heißt er „Level hält". +# Deshalb war ALT live schlechter als eine Münze und nicht bloß nutzlos. +# Final auf allen 80k Bars gefittet (n=76.542 Events, Basisrate 37,8 %), nachdem +# H1→H2 out-of-sample validiert war — gleiches Vorgehen wie beim ersten Modell. +# Alte M30-Anlauf-Werte (Rückweg): MU (1.6565,1.4523,0.9465,1.9133) · +# SD (2.5291,2.4177,0.2250,2.1257) · W (-0.3264,0.4719,1.4330,0.0943,-0.1885). +_PB_MU = (0.1962, 0.2071, 0.5300, 3.2624) +_PB_SD = (1.3109, 0.9579, 0.4991, 3.5649) +_PB_W = (-0.5216, 0.0712, -0.5420, 0.0047, 0.0386) # [b0, mom6, mom3, wt, dist] def _p_break(mom6: float, mom3: float, wt: float, dist: float) -> float: """P(Level-Durchbruch) ∈ (0,1) aus den 4 standardisierten Merkmalen.""" diff --git a/measurement_reminder.py b/measurement_reminder.py index e444c69..a839c8d 100644 --- a/measurement_reminder.py +++ b/measurement_reminder.py @@ -152,10 +152,12 @@ CONFIG_DEPS = [ "weiterhin die M5-`pb_levels` — eine Umstellung auf M30 wäre ungemessen.", }, { - "key": "sr_close_pbreak", "test": "backtest_srclose_prob.py", - "validated": "0.55", - "why": "Optimum-Plateau 0,50–0,60. Außerhalb ist der S/R-Auto-Close nicht mehr " - "durch die Messung gedeckt (die Hysterese ±5 Pp hängt ebenfalls daran).", + "key": "sr_close_pbreak", "test": "backtest_pbreak_rvalue.py", + "validated": "0.35", + "why": "Seit dem Modell-Nachtraining 2026-07-31 (live-spiegelnde Stichprobe) " + "ist 0,35 der in BEIDEN Hälften beste Wert gegen die Trailing-Baseline " + "(H1 +357, H2 +1418 R). Bei 0,60 kippt H1. Die alte 0,55 gehörte zum " + "alten Modell und ist mit den neuen _PB_W nicht mehr gültig.", }, { "key": "breakout_k", "test": "backtest_breakout.py",