diff --git a/CLAUDE.md b/CLAUDE.md index 44b8a7c..10b24dc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1426,6 +1426,57 @@ dort bereits nachvalidiert, ØR +0,305.) **Eine** Oberfläche: Continuation = breakeven-negativ (−0,011/−0,005, Momentum-Klasse). Damit ist auch die Struktur-Idee empirisch durch — die Kachel bleibt Ist-Zustand-Anzeige (wie TF-Ampel/Squeeze), steuert nichts. +- **⚠⚠ P(break) LIVE-AUSWERTUNG (`analyze_pbreak_live.py`, 2026-07-31, 2275 ausgewertete + Vorhersagen seit 23.07.): DAS MODELL HÄLT LIVE NICHT, WAS DER BACKTEST VERSPRACH — + B5-Fall.** Fällige Messung aus dem Reminder, jetzt erledigt. + | | Gesamt | M5-Level (bis 29.07.) | M30-Level (ab 30.07.) | entkoppelt | + |---|---|---|---|---| + | n | 2275 | 1850 | 425 | 307 | + | **AUC** | **0,539** | 0,570 | **0,399** | **0,443** | + | Treffer Modell | 58,5 % | 58,7 % | 57,6 % | 56,7 % | + | „immer Abprall" | 59,0 % | 57,7 % | 64,7 % | 59,9 % | + | Überschuss | −0,5 Pp | +1,0 Pp | −7,1 Pp | −3,3 Pp | + Backtest-Erwartung war **AUC 0,71**. **Kalibrierung systematisch nach unten verzerrt:** + Bucket 0–19 % (n=1234, über die Hälfte!) sagt **8 % voraus, real 39,1 %**; Bucket + 65–100 % sagt 74,5 %, real 48,0 % (invertiert). Im Mittel prognostiziert das Modell + **23 % Durchbruch, real sind es 41 %**, und es sagt nur in **8 % der Fälle „break"**. + ⚠ **DER KERNBEFUND — das Gate gatet nicht:** die **Bruchrate der GESCHLOSSENEN Gruppe + ist bei JEDER Schwelle 38–40 %**, also identisch zur Basisrate (thr 20 → 39,1 % · + thr 35 → 37,8 % · thr 55 → 40,5 %). Es findet live keinerlei Auswahl statt. Bei der + ini-Schwelle 0,55 werden **91,2 % aller Level-Berührungen geschlossen** → der + „P(break)-gegatete Close" ist faktisch zum **pauschalen S/R-Close** degeneriert, und + DER ist 2× gemessen und verworfen (`backtest_srclose.py`: WR verdoppelt, PF/ΣR in + 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. + ✅ **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 + zählt in beiden als Abprall. Plausibelste Ursache = **andere Stichproben-Population**: + live werden 2275 Berührungen geloggt, entkoppelt (max. 1 je Level und Stunde) bleiben + nur **307** — dieselbe Position pendelt vielfach um dasselbe Level, live dominieren + also Chop-am-Level-Fälle. ⚠ Das entlastet das Modell NICHT: entkoppelt ist die AUC mit + 0,443 sogar schlechter. ⚠ Der M30-Teil (AUC 0,399) ist mit n=425 über 2 Tage zu klein, + um die Umstellung als Ursache zu belegen — die Wiedervorlage (≥30 S/R-Closes auf + M30-Leveln) läuft weiter. + **TOUCH-ZAHL als fehlendes Merkmal (User-Idee 2026-07-31 „je öfter der Kurs die Linie + berührt, desto wahrscheinlicher der Durchbruch") — auf den Live-Daten BESTÄTIGT, aber + mit Umkehr:** Bruchrate nach n-ter Berührung desselben Levels im 30-min-Fenster, in + BEIDEN Live-Hälften dasselbe Muster — 1. Berührung 38,3/41,7 % · 2. 40,8/45,6 % · + 3. 43,2/45,1 % · **4. 45,1/47,4 %** (monoton, +6/+4 Pp über Basis) · **5.+ fällt + zurück auf 37,8/41,8 %** (UNTER Basis, und mit n≈1400 die größte Gruppe). Also ein + **umgekehrtes U**, kein monotoner Anstieg: 2–4 Berührungen = Level wird mürbe, ab 5 in + 30 min = Range, das Level HÄLT. ⚠ Das Modell sieht davon **nichts** (sagt über alle + Berührungszahlen konstant ~23 % voraus) → echte orthogonale Information. ⚠ Belastbarkeit + begrenzt: die beiden „Hälften" liegen nur 4 Tage auseinander (kein Regimewechsel), + Buckets n=82–139; und `backtest_srbreak.py` fand die Touch-Zahl früher „nicht robust" + (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. - **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/analyze_pbreak_live.py b/analyze_pbreak_live.py new file mode 100644 index 0000000..966a47e --- /dev/null +++ b/analyze_pbreak_live.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +"""P(break) LIVE-Auswertung — hält das Modell, was der Backtest versprach? + +Fällige Messung aus `measurement_reminder.py` (seit 2026-07-23 werden alle +Level-Vorhersagen mitgeloggt; Stand 2026-07-31: 2275 ausgewertete Zeilen). + +BACKTEST-ERWARTUNG (die Messlatte, `backtest_srclose_prob.py` / `backtest_level_tf.py`): + AUC out-of-sample 0,71–0,72 + Kalibrierung pred 23 % → real 25 % · 38→36 · 58→59 · 86→87 + Basisrate P(break) ~37 % (M5-Level) bzw. 39,4 % (M30-Level) + +GEPRÜFT WIRD: + 1. Basisrate + Trefferquote gegen die TRIVIALE Regel („immer Abpraller sagen") — + das ist der eigentliche Maßstab, nicht die nackte Trefferquote. + 2. Trennschärfe (AUC) — trennt das Modell hohe von niedrigen Bruchraten? + 3. Kalibrierung — trifft die vorhergesagte Rate die echte? + 4. Split vor/nach der M30-Level-Umstellung (2026-07-30). + 5. Split nach Richtung. + +⚠ ABHÄNGIGKEIT DER STICHPROBEN: derselbe Level wird oft mehrfach berührt; die Zeilen +sind daher NICHT unabhängig. Deshalb zusätzlich eine entkoppelte Sicht (max. eine +Vorhersage je Level und Stunde) — kippt ein Befund dort, ist er ein Cluster-Artefakt. +""" +import sqlite3 +import sys +from datetime import datetime + +DB = "oil_widget_history.db" +M30_SWITCH = datetime(2026, 7, 30).timestamp() + + +def auc(ps, ys): + """Rangbasiert, mit Bindungskorrektur.""" + pairs = sorted(zip(ps, ys)) + pos = sum(ys); neg = len(ys) - pos + if not pos or not neg: + return None + rank = 0.0; i = 0 + while i < len(pairs): + j = i + while j + 1 < len(pairs) and pairs[j + 1][0] == pairs[i][0]: + j += 1 + r = (i + j) / 2.0 + 1 + for k in range(i, j + 1): + if pairs[k][1] == 1: + rank += r + i = j + 1 + return (rank - pos * (pos + 1) / 2) / (pos * neg) + + +def report(rows, title): + """rows = [(p_break_pct, is_break, predicted)]""" + n = len(rows) + if n < 30: + print(f"\n{title}: nur n={n} — zu wenig für eine Aussage") + return + br = sum(y for _, y, _ in rows) / n + acc = sum(1 for p, y, pr in rows + if (pr == "break") == (y == 1)) / n + # triviale Regel: immer die Mehrheitsklasse + trivial = max(br, 1 - br) + a = auc([p for p, _, _ in rows], [y for _, y, _ in rows]) + print(f"\n{title} n={n}") + print(f" Basisrate Durchbruch {100*br:>5.1f} %") + print(f" Trefferquote Modell {100*acc:>5.1f} %") + print(f" ... triviale Regel {100*trivial:>5.1f} % " + f"(immer '{'break' if br > 0.5 else 'bounce'}')") + delta = 100 * (acc - trivial) + print(f" UEBERSCHUSS {delta:>+5.1f} Pp " + f"{'<-- Modell schlaegt die triviale Regel' if delta > 1 else ''}") + print(f" AUC (Trennschaerfe) {a:.3f}" if a else " AUC: n/a") + print(f" (Backtest-Erwartung 0,71)") + + +def calib(rows, title): + print(f"\n Kalibrierung — {title}") + print(f" {'P(break)':<14}{'n':>6}{'vorhergesagt':>14}{'real':>9}{'':>4}") + edges = [0, 20, 35, 50, 65, 101] + for lo, hi in zip(edges, edges[1:]): + sel = [(p, y) for p, y, _ in rows if lo <= p < hi] + if len(sel) < 25: + continue + pm = sum(p for p, _ in sel) / len(sel) + rm = 100 * sum(y for _, y in sel) / len(sel) + gap = rm - pm + flag = "OK" if abs(gap) <= 8 else ("zu niedrig" if gap > 0 else "zu hoch") + print(f" {lo:>3}–{hi-1:<10}{len(sel):>6}{pm:>13.1f}%{rm:>8.1f}% {flag}") + + +def main(): + db = sqlite3.connect(DB) + c = db.cursor() + c.execute("""SELECT ts, direction, level, p_break, predicted, outcome + FROM pbreak_predictions WHERE outcome IS NOT NULL + ORDER BY ts""") + raw = c.fetchall() + db.close() + if not raw: + print("keine ausgewerteten Vorhersagen"); return + + rows = [(float(p), 1 if o == "break" else 0, pr) for _ts, _d, _l, p, pr, o in raw] + t0 = datetime.fromtimestamp(raw[0][0]); t1 = datetime.fromtimestamp(raw[-1][0]) + + print("=" * 84) + print(" P(break) — LIVE-AUSWERTUNG") + print(f" {len(rows)} ausgewertete Vorhersagen, {t0:%d.%m.%Y} bis {t1:%d.%m.%Y}") + print("=" * 84) + + report(rows, "GESAMT") + calib(rows, "gesamt") + + # ── entkoppelt: max. 1 Vorhersage je (Level, Stunde) + seen = set(); dedup = [] + for ts, _d, lvl, p, pr, o in raw: + key = (round(float(lvl or 0), 2), ts // 3600) + if key in seen: + continue + seen.add(key) + dedup.append((float(p), 1 if o == "break" else 0, pr)) + report(dedup, "ENTKOPPELT (max. 1 je Level und Stunde)") + + # ── vor/nach der M30-Umstellung + pre = [(float(p), 1 if o == "break" else 0, pr) + for ts, _d, _l, p, pr, o in raw if ts < M30_SWITCH] + post = [(float(p), 1 if o == "break" else 0, pr) + for ts, _d, _l, p, pr, o in raw if ts >= M30_SWITCH] + report(pre, "M5-LEVEL (bis 29.07.)") + report(post, "M30-LEVEL (ab 30.07.)") + if len(post) >= 30: + calib(post, "nur M30-Level") + + # ── nach Richtung + for d in ("LONG", "SHORT"): + sel = [(float(p), 1 if o == "break" else 0, pr) + for _ts, dd, _l, p, pr, o in raw if dd == d] + report(sel, f"Richtung {d}") + + # ── Verteilung der Vorhersagen (wie oft sagt das Modell ueberhaupt 'break'?) + nb = sum(1 for _, _, pr in rows if pr == "break") + print(f"\n Prognose-Verteilung: 'break' {nb} ({100*nb/len(rows):.1f} %) · " + f"'bounce' {len(rows)-nb} ({100*(len(rows)-nb)/len(rows):.1f} %)") + + +if __name__ == "__main__": + main() diff --git a/measurement_reminder.py b/measurement_reminder.py index 8050447..e444c69 100644 --- a/measurement_reminder.py +++ b/measurement_reminder.py @@ -50,14 +50,22 @@ def _days_since(iso: str) -> float: # cmd = was zu tun ist, wenn fällig CHECKS = [ { - "key": "pbreak_accuracy", - "title": "P(break)-Prognose-Genauigkeit auswerten", - "why": "Seit 2026-07-23 wird jede Level-Vorhersage mitgeloggt und gegen den " - "echten Ausgang geprüft. Zeigt, ob das Modell live so trifft wie im " - "Backtest (AUC 0,72) — und ob die M30-Umstellung es verbessert hat.", - "have": lambda: _q("SELECT COUNT(*) FROM pbreak_predictions WHERE outcome IS NOT NULL"), - "need": 500, "unit": "ausgewertete Vorhersagen", - "cmd": "GET /api/pbreak_accuracy?period=all · history.pbreak_accuracy()", + # ERLEDIGT 2026-07-31 (analyze_pbreak_live.py, 2275 Vorhersagen): das Modell + # hält live NICHT — AUC 0,539 statt 0,71, Kalibrierung sagt 8 % wo 39 % + # eintreten, und die Bruchrate der geschlossenen Gruppe ist bei JEDER + # Schwelle ~38 % → das Gate gatet nicht. Wiedervorlage mit HÖHERER Schwelle, + # damit ein evtl. nachtrainiertes Modell auf frischen Daten geprüft wird. + "key": "pbreak_accuracy_v2", + "title": "P(break) erneut auswerten (nach Nachtraining)", + "why": "Erste Auswertung 2026-07-31 fiel durch (AUC 0,539, Gate ohne " + "Trennschärfe). Offene Baustelle: Touch-Zahl als 5. Merkmal in " + "backtest_srclose_prob.py aufnehmen und neu fitten (live in beiden " + "Hälften +6/+4 Pp für die 2.–4. Berührung, 5.+ fällt zurück). " + "Danach mit frischen Live-Daten gegenprüfen.", + "have": lambda: _q("SELECT COUNT(*) FROM pbreak_predictions " + "WHERE outcome IS NOT NULL AND ts >= strftime('%s','2026-08-01')"), + "need": 800, "unit": "Vorhersagen seit 01.08.", + "cmd": "python analyze_pbreak_live.py", }, { "key": "verdict_votes",