From 2e897243680a94d7ebe65394ea933df8bf49ad4e Mon Sep 17 00:00:00 2001 From: Axel Hocks Date: Fri, 7 Aug 2026 11:18:35 +0200 Subject: [PATCH] P(break) Niveau-Reparatur gemessen: beide Varianten fallen durch Folgeauftrag zur B4-Pruefung. Hypothese war: das Modell rankt brauchbar, nur sein Niveau haengt an einer wandernden Basisrate. Geprueft wurden ein Regime-Merkmal (ATR/gleitender Median) und ein laufend nachgefuehrter Achsenabschnitt, Regel vorab fixiert. collect() in backtest_pbreak_retrain.py hat dafuer einen ADDITIVEN REG-Parameter bekommen (Default None), Ausgabe ohne ihn bitgenau geprueft (4371 Zeilen, 0 Abweichungen). Auf Backtest-Daten besteht die Roll-Achse (Kalib 2,61->1,95 und 10,51->3,25 Pp, AUC leicht besser), das Regime-Merkmal faellt durch (H2->H1 schlechter). Auf LIVE-Daten (n=921 ab 01.08.) faellt auch die Roll-Achse durch: Kalib 14,16 -> 10,64 Pp (also nicht repariert), AUC 0,531 -> 0,516 (ueber der Toleranz), und der Nachbar n=800 kippt auf 15,14 Pp = schlechter als der Ausgangswert. Grund: die oberste Klasse bleibt INVERTIERT (69 % vorhergesagt, 17,2 % real). Eine Niveau-Verschiebung kann eine invertierte Klasse nicht reparieren. Die Ausgangshypothese ist damit widerlegt. Drei Erklaerungen fuer die Basisraten-Luecke geprueft, alle widerlegt: Sammelart (live 1x je Beruehrung vs Backtest jeder Bar): -1,7 Pp, falsche Richtung Vola-Regime: Bruchrate ueber 5 Quintile flach (37,0 bis 39,2 %) aktuelles Regime ungewoehnlich: letzte 7 Tage Median 0,96 = normal Der flache Vola-Befund erklaert zugleich, warum das Regime-Merkmal nichts bringt. Kontrolle: das Modell VOR dem 31.07. hat auf diesen Daten AUC 0,364/0,406 - unter 0,5, also invertiert. Reproduziert exakt den Grund fuer das Nachtrainieren. Nichts gebaut. Naechster Schritt ist kein drittes Fitten, sondern die einzige ungeprueft gebliebene Divergenz: die Level-Auswahl selbst (Backtest baut sie mit pick_level/_cluster nach, live entscheidet _draw_levels). Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 63 +++++++++++ backtest_pbreak_regime.py | 208 +++++++++++++++++++++++++++++++++++++ backtest_pbreak_retrain.py | 18 +++- 3 files changed, 284 insertions(+), 5 deletions(-) create mode 100644 backtest_pbreak_regime.py diff --git a/CLAUDE.md b/CLAUDE.md index 0d29978..f5afac0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4848,6 +4848,69 @@ Weg gehen. Naheliegender wäre, das Niveau **laufend** an die jüngste Basisrate koppeln oder ein Vola-/Regime-Merkmal aufzunehmen. **Nicht gebaut, nicht gemessen.** +## ⚠⚠ P(break): NIVEAU-REPARATUR GEMESSEN — BEIDE VARIANTEN FALLEN DURCH (2026-08-07) + +Folgeauftrag zur B4-Prüfung: die Vermutung war „das Modell RANKT brauchbar, nur +sein NIVEAU hängt an einer Basisrate, die wandert" — also entweder ein +Regime-Merkmal oder ein laufend nachgeführter Achsenabschnitt. +`backtest_pbreak_regime.py`, Regel **vorab** fixiert (beide Fit-Richtungen besser +kalibriert · AUC nicht schlechter, Toleranz 0,005 · Nachbarn kippen nicht). +⚠ `collect()` in `backtest_pbreak_retrain.py` hat dafür einen **additiven** +`REG`-Parameter bekommen (Default `None`) — Ausgabe ohne ihn **bitgenau** wie +vorher geprüft (4.371 Zeilen, 0 Abweichungen). + +**(1) Auf BACKTEST-Daten besteht die Roll-Achse, das Regime-Merkmal nicht:** +| Variante | H1→H2 Kalib. / AUC | H2→H1 Kalib. / AUC | +|---|---|---| +| A) Basis (fester Achsenabschnitt) | 2,61 Pp / 0,659 | 10,51 Pp / 0,599 | +| B) + Regime (ATR/Median) | 2,51 / 0,658 | **11,20 ❌** / 0,599 | +| **C) Roll-Achse (n=400)** | **1,95 / 0,661** | **3,25 / 0,612** | +| *Kontrolle 1: Modell VOR dem 31.07.* | 25,81 / **0,364** | 20,25 / **0,406** | +| *Kontrolle 2: Modell wie HEUTE live* | 1,63 / 0,659 | 2,30 / 0,612 | +⚠ **Kontrolle 2 ist IN-SAMPLE** (die Live-Gewichte wurden final auf allen 80k +Bars gefittet) — sie muss gut aussehen und ist kein Verdienst. +✅ Kontrolle 1 reproduziert exakt, warum am 31.07. nachtrainiert wurde +(AUC **unter 0,5** = invertiert). + +**(2) AUF LIVE-DATEN FÄLLT DIE ROLL-ACHSE DURCH — und das ist die Messung, die +zählt** (n=921 ab 01.08., echte Bruchrate 52,7 %): +| | AUC | Ø-Vorhersage | Kalib.-Fehler | +|---|---|---|---| +| IST | 0,531 | 41,8 % | **14,16 Pp** | +| Roll n=400 | 0,516 | 48,7 % | 10,64 Pp | +| **Roll n=800** | 0,510 | 42,8 % | **15,14 Pp ❌ schlechter als IST** | +Sie senkt den Fehler auf ~10,6 Pp — **repariert ihn also nicht** —, verliert AUC +über der Toleranz und **kippt beim Nachbarn n=800**. Regel verfehlt, **kein +Einbau**. +⚠ **Der Grund steht in der Klassentabelle: die oberste Klasse bleibt +INVERTIERT** (69 % vorhergesagt, **17,2 %** real, n=29). Eine Niveau-Verschiebung +kann eine invertierte Klasse prinzipiell nicht reparieren. Die Ausgangshypothese +(„nur das Niveau") ist damit **widerlegt**. + +**(3) DREI ERKLÄRUNGEN FÜR DIE BASISRATEN-LÜCKE GEPRÜFT — ALLE WIDERLEGT.** +Live 52,7 % liegt **ausserhalb der gesamten 9-Monats-Spanne** des Backtests +(Zehntel-Blöcke: 33,6 … 41,1 %). +| Hypothese | Ergebnis | +|---|---| +| **Sammelart** — live loggt 1× je frischer Berührung, der Backtest JEDEN Bar in der Zone | 37,5 % gegen 39,1 % = **−1,7 Pp, falsche Richtung** ❌ | +| **Vola-Regime** — Bruchrate steigt mit ATR | über 5 Quintile **flach**: 37,0 · 37,0 · 39,2 · 38,9 · 37,4 % ❌ | +| **aktuelles Regime ungewöhnlich** | letzte 7 Tage Median **0,96** = normal (Gesamt 0,99) ❌ | +✅ Der flache Vola-Befund erklärt zugleich, **warum B nichts bringt**: in dieser +Achse steckt keine Information. + +**FAZIT: nichts gebaut.** Der nächste Schritt ist **kein drittes Fitten** — der +Fehler sitzt nicht im Niveau. Die einzige nicht geprüfte Divergenz ist die +**Level-Auswahl selbst**: der Backtest bildet sie mit `pick_level`/`_cluster` +nach, live entscheidet `_draw_levels`. Ein Nachbau ist kein Nutzer desselben +Codes — genau die Fehlerklasse, die am 31.07. schon einmal zugeschlagen hat +(„Training = Level bei Entry fixiert, Live = Level wandert mit"). **Dort wäre als +Nächstes zu messen.** +⚠ Operativ weiterhin **kein Notfall**: `auto_sr_close = false` seit 06.08., das +Modell schliesst keine Trades. +⚠ Und die Live-AUC bleibt unentschieden (entkoppelt n=87, KI [0,434 … 0,674]) — +„die Rangfolge ist kaputt" ist **nicht** belegt; belegt ist nur die +Basisraten-/Kalibrierungslücke. + ## ⚠⚠ LIVE-CODE HING AN EINEM BACKTEST-SKRIPT — behoben 2026-08-07 **Befund:** `core/engine.py` (`squeeze_monitor`) und `weekly_review.py` importierten diff --git a/backtest_pbreak_regime.py b/backtest_pbreak_regime.py new file mode 100644 index 0000000..a549f5e --- /dev/null +++ b/backtest_pbreak_regime.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +"""P(break): liegt der Live-Fehler am NIVEAU (Basisrate) oder an der RANGFOLGE? + +ANLASS (2026-08-07). Die B4-Prüfung des am 31.07. nachtrainierten Modells zeigt +zwei getrennte Dinge: + · KALIBRIERUNG messbar daneben — Training-Basisrate 37,8 %, live 53,2 %, + stabil über vier volle Tage. Alle unteren Klassen „zu niedrig". + · TRENNSCHÄRFE nicht entscheidbar — entkoppelt n=87, AUC 0,556, + 95-%-KI [0,434 … 0,674] (enthält Zufall UND Messlatte). +Die Vermutung lautet deshalb: das Modell RANKT brauchbar, aber sein NIVEAU ist an +eine Basisrate genagelt, die wandert. Ein drittes Fitten derselben Bauart würde +daran nichts ändern — es würde nur einen neuen festen Achsenabschnitt lernen. + +⚠ VORFRAGE, die über alles entscheidet: schwankt die Basisrate im Backtest-Zeitraum +überhaupt? Ist sie dort konstant, lässt sich das Problem an diesen Daten NICHT +studieren, und jede Variante wäre in-sample-Kosmetik. Wird zuerst geprüft und bei +Bedarf ausdrücklich gesagt. + +GEPRÜFT WERDEN DREI VARIANTEN, alle auf derselben live-spiegelnden Stichprobe: + A) BASIS — 4 Merkmale, fester Achsenabschnitt (der heutige Stand) + B) +REGIME — 5. Merkmal: ATR relativ zum eigenen gleitenden Median + ⚠ RELATIV, nicht absolut. Ein absoluter ATR-Schwellwert wäre + ein Selektions-Artefakt (Lehre aus `analyze_tageszeit.py` und + dem ORB-Fehlschlag) und würde über Regime hinweg nicht gelten. + C) ROLL-ACHSE — Merkmale wie A, aber der Achsenabschnitt wird laufend an die + zuletzt BEOBACHTETE Basisrate angepasst. + ⚠ KAUSAL: ein Ausgang steht erst `_BRK_W` Bars später fest, + deshalb Nachlauf. Ohne den wäre es Look-ahead — genau der + Fehler, der am 06.08. Momentum und SIG scheinbar rettete. + +⚠⚠ ENTSCHEIDUNGSREGEL, VORAB FESTGELEGT (sonst verschiebt sich hinterher die Latte): +Eine Variante gilt nur dann als besser, wenn sie in BEIDEN Fit-Richtungen +(H1→H2 UND H2→H1) + (1) den Kalibrierungsfehler senkt (mittlerer |vorhergesagt − real| über die + Klassen, gewichtet nach n), UND + (2) die AUC NICHT verschlechtert (Toleranz 0,005), UND + (3) bei den Nachbar-Parametern nicht kippt (Fenster/Nachlauf). +Sonst: kein Einbau. Zwei Richtungen, weil eine einzelne Richtung schon einmal in +die Irre führte (`backtest_metalabel.py`, 05.08.). +""" +import sys + +import numpy as np +import MetaTrader5 as mt5 + +from backtest_pbreak_retrain import (_ATRMIN, _BRK_W, _N_BARS, _atr_series, + _ema_series, auc, build_levels, collect, + fit_logreg, old_model) +from core.wave_rec import _EMA_FAST, _EMA_SLOW + +_REG_FENSTER = 288 # gleitender ATR-Median (~1 Tag auf M5) für das Regime +_ROLL_N = 400 # wie viele zurückliegende Ausgänge die Achse nachführt +_ROLL_LAG = _BRK_W # Nachlauf: so lange braucht ein Ausgang, bis er feststeht + + +def live_model(feat): + """Das HEUTE laufende Modell — Gewichte direkt aus `core/engine.py`, damit die + Kontrolle nicht von einer Kopie abhängt (Deployment-Drift wäre sonst genau + hier möglich: gemessen mit anderen Zahlen als betrieben).""" + from core.engine import _PB_MU, _PB_SD, _PB_W + z = _PB_W[0] + for k in range(4): + z += _PB_W[k + 1] * (feat[k] - _PB_MU[k]) / _PB_SD[k] + return 1 / (1 + np.exp(-z)) + + +def kalib_fehler(p, y, kanten=(0.0, 0.2, 0.35, 0.5, 0.65, 1.01)): + """Mittlerer |vorhergesagt − real| über die Klassen, nach n gewichtet. + Das ist die Größe, die live danebenlag — nicht die AUC.""" + ges = 0.0 + n_ges = 0 + for a, b in zip(kanten, kanten[1:]): + m = (p >= a) & (p < b) + k = int(m.sum()) + if k < 20: + continue + ges += k * abs(p[m].mean() - y[m].mean()) + n_ges += k + return ges / n_ges if n_ges else float("nan") + + +def zeig(titel, p, y): + print(f" {titel:<34} AUC {auc(p, y):.3f} Ø-Vorhersage {100*p.mean():>5.1f} % " + f"vs. real {100*y.mean():>5.1f} % Kalibr.-Fehler {100*kalib_fehler(p, y):>5.2f} Pp") + + +def regime_reihe(AT): + """ATR relativ zum eigenen gleitenden Median — regime-normiert, damit der Wert + über verschiedene Vola-Niveaus dasselbe bedeutet. Kausal (nur Vergangenheit).""" + out = [1.0] * len(AT) + puffer = [] + for i, a in enumerate(AT): + if a: + puffer.append(a) + if len(puffer) > _REG_FENSTER: + puffer.pop(0) + med = float(np.median(puffer)) + out[i] = (a / med) if med > 0 else 1.0 + return out + + +def roll_achse(p_roh, y, n_fenster=_ROLL_N, lag=_ROLL_LAG): + """Verschiebt den Achsenabschnitt im Logit-Raum so, dass die mittlere + Vorhersage der zuletzt BEOBACHTETEN Basisrate folgt. + + ⚠ Kausal: für Zeile i zählen nur Ausgänge bis i-lag. Vor dem ersten vollen + Fenster bleibt die Vorhersage unverändert (kein Raten aus der Zukunft).""" + p = np.clip(p_roh, 1e-6, 1 - 1e-6) + logit = np.log(p / (1 - p)) + out = np.array(p_roh, dtype=float) + for i in range(len(p)): + a = i - lag - n_fenster + b = i - lag + if a < 0 or b <= a: + continue + beob = y[a:b].mean() + erw = p_roh[a:b].mean() + if not (0 < beob < 1) or not (0 < erw < 1): + continue + # Differenz der Logits der MITTELWERTE = Niveau-Korrektur + delta = np.log(beob / (1 - beob)) - np.log(erw / (1 - erw)) + out[i] = 1 / (1 + np.exp(-(logit[i] + delta))) + return out + + +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) + 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) + REG = regime_reihe(AT) + mid = len(C) // 2 + + print("=" * 104) + print(f" P(break) — NIVEAU oder RANGFOLGE? {sym} M5, {len(C)} Bars") + print("=" * 104) + + s1 = collect(_N_BARS, mid, H, L, C, EF, ES, AT, LV, REG) + s2 = collect(mid, len(C), H, L, C, EF, ES, AT, LV, REG) + 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) + X1 = np.array([e["feat"] for e in s1]); X2 = np.array([e["feat"] for e in s2]) + + # ── VORFRAGE: wandert die Basisrate in diesen Daten ueberhaupt? ──────── + print(f"\n H1 n={len(s1)} Basisrate {100*y1.mean():.1f} %") + print(f" H2 n={len(s2)} Basisrate {100*y2.mean():.1f} %") + print(f" Live gemessen (ab 01.08.): 53,2 % · ALT-Training: 37,8 %") + print("\n Basisrate in Zehnteln des Zeitraums (wandert sie?):") + alle_y = np.concatenate([y1, y2]) + blk = len(alle_y) // 10 + raten = [100 * alle_y[i*blk:(i+1)*blk].mean() for i in range(10)] + print(" " + " ".join(f"{r:5.1f}" for r in raten)) + print(f" Spanne {min(raten):.1f} … {max(raten):.1f} % " + f"(Schwankung {max(raten)-min(raten):.1f} Pp)") + if max(raten) - min(raten) < 8: + print(" ⚠ Die Basisrate ist in diesen Daten weitgehend STABIL — der live") + print(" beobachtete Drift (37,8 → 53,2 %) lässt sich hier nur begrenzt") + print(" nachstellen. Die Ergebnisse unten sind entsprechend zu lesen.") + + # ── die drei Varianten, in BEIDEN Fit-Richtungen ─────────────────────── + print("\n" + "=" * 104) + print(" VARIANTEN (vorab fixierte Regel: beide Richtungen besser kalibriert,") + print(" AUC nicht schlechter, Nachbarn kippen nicht)") + print("=" * 104) + + for name, (Xa, ya, Xb, yb, sa, sb) in ( + ("FIT H1 → TEST H2", (X1, y1, X2, y2, s1, s2)), + ("FIT H2 → TEST H1", (X2, y2, X1, y1, s2, s1))): + print(f"\n {name}") + w = fit_logreg(Xa, ya) + pb = 1 / (1 + np.exp(-(np.hstack([np.ones((len(Xb), 1)), Xb]) @ w))) + zeig("A) BASIS (fester Achsenabschnitt)", pb, yb) + + # B) +Regime als 5. Merkmal + ra = np.array([[e["reg"]] for e in sa]); rb = np.array([[e["reg"]] for e in sb]) + w5 = fit_logreg(np.hstack([Xa, ra]), ya) + p5 = 1 / (1 + np.exp(-(np.hstack([np.ones((len(Xb), 1)), Xb, rb]) @ w5))) + zeig("B) + Regime (ATR/Median)", p5, yb) + + # C) rollierender Achsenabschnitt auf A + pc = roll_achse(pb, yb) + zeig(f"C) ROLL-ACHSE (n={_ROLL_N}, Nachlauf {_ROLL_LAG})", pc, yb) + + # Nachbarn von C + for nf in (200, 800): + zeig(f" C-Nachbar n={nf}", roll_achse(pb, yb, n_fenster=nf), yb) + + # ── ZWEI Kontrollen, und sie sind NICHT dasselbe ────────────────── + # ⚠ Beim ersten Lauf hatte ich `old_model` als „Modell wie live" + # beschriftet — falsch. `old_model` sind die Gewichte VOR dem + # Nachtrainieren (31.07.); live laufen seitdem `_PB_*` aus `engine.py`. + p_old = np.array([old_model(e["feat"]) for e in sb]) + zeig("KONTROLLE 1: Modell VOR dem 31.07.", p_old, yb) + p_live = np.array([live_model(e["feat"]) for e in sb]) + zeig("KONTROLLE 2: Modell wie HEUTE live", p_live, yb) + + +if __name__ == "__main__": + main() diff --git a/backtest_pbreak_retrain.py b/backtest_pbreak_retrain.py index 5b9fb60..8b77f0d 100644 --- a/backtest_pbreak_retrain.py +++ b/backtest_pbreak_retrain.py @@ -150,8 +150,13 @@ def outcome(H, L, level, atr, j, d): 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.""" +def collect(a, b, H, L, C, EF, ES, AT, LV, REG=None): + """Live-spiegelnde Stichprobe: jeder Bar im 0,15×ATR-Band um das dyn. Level. + + ⚠ `REG` ist ADDITIV (2026-08-07, für `backtest_pbreak_regime.py`): wird eine + Regime-Reihe übergeben, bekommt jede Zeile zusätzlich `"reg"`. OHNE das + Argument ist die Ausgabe **bitgenau wie vorher** — die dokumentierten Zahlen + dieses Skripts dürfen sich nicht verschieben (Migrations-Regel).""" TH = _REVERSAL_STRETCH out = [] i = max(a, _N_BARS, 320) @@ -201,9 +206,12 @@ def collect(a, b, H, L, C, EF, ES, AT, LV): (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]}) + zeile = {"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]} + if REG is not None: + zeile["reg"] = REG[min(j, len(REG) - 1)] + out.append(zeile) i += 12 # nächste „Position" versetzt starten (weniger Überlappung) return out