From 3f1f19087d08f8aa36b6b82d4032b99aebf948ee Mon Sep 17 00:00:00 2001 From: Axel Hocks Date: Fri, 7 Aug 2026 11:55:05 +0200 Subject: [PATCH] Live-Backtest-Vergleich in die Pipeline: core/stichprobe.py + Abschnitt E Konsequenz aus dem Messfehler vom 07.08.: der Vergleich lief nur, wenn jemand daran dachte - und als er lief, war er falsch gebaut. 1) core/stichprobe.py - die Praevention ist KONSTRUKTIV, nicht ermahnend: entkoppeln() / anteil_ci() / unterscheidbar() / schluessel_level_stunde(). Regel: vor jeder berichteten Rate entkoppeln, jede Aussage ueber einen Unterschied mit Konfidenzintervall belegen. Die Zeilen in pbreak_predictions und recommendations sind nicht unabhaengig. 8 Tests, darunter einer, der die reale Konstellation nachspielt (roh 77 %, entkoppelt 33 %). Fallstrick beim Bau, gefunden weil ich es laufen liess: ein sqlite3-Cursor liefert Tupel, keine dicts - die Feldangabe akzeptiert jetzt Namen UND Spaltennummern. 2) analyze_divergence.sec_d0 war selbst der Fehler. Es mittelte AVG(p_break) ueber ALLE Rohzeilen und flaggte bei Delta > 8 Pp: vorher 1058 rohe Zeilen, Delta +8,7 Pp -> ALARM jetzt 110 entkoppelte, 37,6 % liegt in [37,3 ... 55,5] % -> OK Der Waechter haette heute also einen Fehlalarm produziert - genau den, dem ich elf Hypothesen lang nachgegangen bin. 3) NEU: Abschnitt E "GLEICHES FENSTER" (--backtest). Rechnet den Backtest ueber genau den Live-Zeitraum und haelt ihn gegen das Intervall der entkoppelten Live-Rate. Erster Lauf: live 46,4 % [37,3 ... 55,5] gegen Backtest 43,9 % -> im Intervall, kein Befund. 4) Eingebaut in den Wochenreport (weekly_review._divergenz_section, montags 07:30 per Mail + Telegram), mit demselben Fail-safe wie der Squeeze-Abschnitt. Bewusst NICHT in den pre-commit-Hook: der braucht MT5 und Minuten, Stufe 1 muss bei ~5 s bleiben. Ausserdem ein Rechenfehler in meinem ersten Entwurf behoben (p_break steht in der DB bereits in Prozent, ich hatte zusaetzlich skaliert -> "3763,9 %") und ein \n-Heredoc-Fall, der genau gegen die dokumentierte Regel verstiess. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 47 ++++++++++++++ analyze_divergence.py | 135 ++++++++++++++++++++++++++++++++++----- core/stichprobe.py | 125 ++++++++++++++++++++++++++++++++++++ tests/test_stichprobe.py | 83 ++++++++++++++++++++++++ weekly_review.py | 55 ++++++++++++++++ 5 files changed, 430 insertions(+), 15 deletions(-) create mode 100644 core/stichprobe.py create mode 100644 tests/test_stichprobe.py diff --git a/CLAUDE.md b/CLAUDE.md index 4d08c94..40e7c23 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4911,6 +4911,53 @@ Modell schliesst keine Trades. „die Rangfolge ist kaputt" ist **nicht** belegt; belegt ist nur die Basisraten-/Kalibrierungslücke. +## ✅ LIVE↔BACKTEST-VERGLEICH IST JETZT TEIL DER PIPELINE (2026-08-07) + +Konsequenz aus dem Messfehler direkt darunter: der Vergleich lief nur, wenn +jemand daran dachte — und als er lief, war er falsch gebaut. Beides behoben. + +**(1) `core/stichprobe.py` — die Prävention ist KONSTRUKTIV, nicht ermahnend.** +`entkoppeln()` · `anteil_ci()` · `unterscheidbar()` · `schluessel_level_stunde()`. +⚠ **Regel: vor jeder berichteten Rate entkoppeln, jede Aussage über einen +Unterschied mit Konfidenzintervall belegen.** Ein Punktschätzer ist auf +`pbreak_predictions`/`recommendations` keine Aussage — die Zeilen sind nicht +unabhängig. 8 Tests, darunter einer, der die reale Konstellation vom 07.08. +nachspielt (roh 77 %, entkoppelt 33 %). +⚠ Fallstrick beim Bau, gefunden weil ich es laufen liess: ein sqlite3-Cursor +liefert **Tupel**, keine dicts (`TypeError: tuple indices must be integers`) — +die Feldangabe akzeptiert deshalb Namen **und** Spaltennummern. + +**(2) `analyze_divergence.sec_d0` war selbst der Fehler — behoben.** Es mittelte +`AVG(p_break)` über ALLE Rohzeilen und flaggte bei Δ > 8 Pp. +| | vorher | jetzt | +|---|---|---| +| Grundmenge | 1.058 rohe Zeilen | **110 entkoppelte** | +| Urteil | Δ +8,7 Pp → **ALARM** | 37,6 % liegt in **[37,3 … 55,5] %** → **OK** | +Der Wächter hätte heute also einen Fehlalarm produziert — genau den, dem ich +elf Hypothesen lang nachgegangen bin. + +**(3) NEU: Abschnitt E „GLEICHES FENSTER"** (`--backtest`). Rechnet den Backtest +über **genau den Live-Zeitraum** und hält ihn gegen das Intervall der +entkoppelten Live-Rate. Erster Lauf: live 46,4 % [37,3 … 55,5] gegen Backtest +**43,9 %** → im Intervall, kein Befund. ⚠ Braucht MT5 und ~1–2 min, deshalb +opt-in — der Rest des Wächters bleibt DB-only und schnell. + +**(4) EINGEBAUT IN DEN WOCHENREPORT** (`weekly_review._divergenz_section`, +montags 07:30 per Mail + Telegram). CLAUDE.md nannte den Wächter seit dem 31.07. +als „Kandidat für den Wochenreport" — jetzt ist er drin, mit demselben +Fail-safe wie der Squeeze-Abschnitt (schlägt er fehl, fehlt der Abschnitt, der +Report läuft weiter). +⚠ **Bewusst NICHT in den pre-commit-Hook**: der braucht MT5 und Minuten; die +Stufe-1-Prüfung muss bei ~5 s bleiben, sonst wird sie deinstalliert. + +**Damit deckt die Pipeline drei verschiedene Fehlerklassen ab:** +| Stufe | schützt gegen | Takt | +|---|---|---| +| pre-commit (`check_nfalle` + `pytest`) | Syntax, undefinierte Namen, `\n`-Falle | jeder Commit | +| Track B (2 Stichproben, Nachbarn, KI) | **Overfitting** — Edge existiert nicht | je Strategie-Idee | +| **Divergenz-Wächter D0/E** | **Deployment-Drift** — Edge existiert, läuft aber anders | **wöchentlich** | +| `tools/deploy.py --feld` | Aktivierung ohne Wirkung | jeder Rollout | + ## ✅⚠⚠ DIE „BASISRATEN-LÜCKE" WAR EIN MESSFEHLER — MEINER (2026-08-07, aufgelöst) Nach elf geprüften Hypothesen ist der Befund: **es gab keinen Fehler im System.** diff --git a/analyze_divergence.py b/analyze_divergence.py index 218a3b1..d132727 100644 --- a/analyze_divergence.py +++ b/analyze_divergence.py @@ -24,14 +24,20 @@ GEPRÜFT WIRD: „feste TF" — je mehr Wechsel, desto weiter weg) D) Merkmals-Drift der eingebetteten Modelle: Live-Mittel gegen `_PB_MU`/`_PB_SD` -Aufruf: python analyze_divergence.py [tage] (Default 7) + E) GLEICHES FENSTER — Backtest auf genau dem Live-Zeitraum, gegen das + Konfidenzintervall der ENTKOPPELTEN Live-Rate (nur mit --backtest) + +Aufruf: python analyze_divergence.py [tage] [--backtest] (Default 7) """ import re import sqlite3 import sys from datetime import datetime, timedelta +from core.stichprobe import anteil_ci, entkoppeln, schluessel_level_stunde + DB = "oil_widget_history.db" +_BROKER_OFF = 10800 # Brokerzeit = UTC+3 (candles/copy_rates sind Broker-Wallclock) LOG = "oil_widget.log" # Erwartungswerte aus den Backtests — die Messlatte @@ -190,21 +196,113 @@ def sec_d0(c, t0): t0, cut = _clamp(t0, "pbreak_model") if cut: print(f" (nur Vorhersagen seit dem Nachtraining am {_EPOCHS['pbreak_model']})") - c.execute("""SELECT AVG(p_break), AVG(CASE WHEN outcome='break' THEN 100.0 ELSE 0 END), - COUNT(*) FROM pbreak_predictions - WHERE ts>=? AND outcome IS NOT NULL""", (t0,)) - pm, rm, n = c.fetchone() - if not n or n < 30: - print(f" nur {n or 0} ausgewertete Vorhersagen seit dem Nachtraining — zu wenig.") + # ⚠⚠ ENTKOPPELN, BEVOR IRGENDEINE RATE BERICHTET WIRD (Fix 2026-08-07). + # Vorher stand hier ein schlichtes `AVG(...)` über ALLE Rohzeilen. Die Zeilen + # sind aber NICHT unabhängig — dieselbe Position pendelt vielfach um dasselbe + # Level. Real am 07.08.: roh 922 Zeilen mit 52,6 % Bruchrate, entkoppelt 93 + # Zeilen mit 45,2 %. Der Wächter hätte auf der Rohzahl Alarm geschlagen + # (Δ +10,8 Pp > 8), obwohl der Backtest DESSELBEN Zeitfensters bei 43,4 % lag + # und damit im 95-%-Intervall der entkoppelten Rate [35,5 … 54,8] liegt. + # Genau diese Verwechslung hat an dem Tag eine Untersuchung mit elf + # widerlegten Hypothesen ausgelöst. + rows = c.execute("""SELECT ts, level, p_break, outcome FROM pbreak_predictions + WHERE ts>=? AND outcome IS NOT NULL ORDER BY ts""", + (t0,)).fetchall() + roh = len(rows) + ent = entkoppeln(rows, schluessel_level_stunde(1, 0)) # Tupel: ts=0, level=1 + n = len(ent) + if n < 30: + print(f" nur {n} ENTKOPPELTE Vorhersagen seit dem Nachtraining " + f"(roh {roh}) — zu wenig.") print(f" (Das ist KEIN Entwarnungssignal, sondern fehlende Evidenz. " - f"Fälligkeit: Messung `pbreak_accuracy_v2`, ≥800 Vorhersagen.)") + f"Fälligkeit: Messung `pbreak_accuracy_v2`, ≥350 ENTKOPPELTE.)") return - gap = (rm or 0) - (pm or 0) - print(f" n={n} Ø-Vorhersage {pm:.1f} % echte Rate {rm:.1f} % " - f"Δ {gap:+.1f} Pp {_flag(abs(gap) <= 8)}") - if abs(gap) > 8: - print(" ⚠ Das Modell ist systematisch verschoben — es bewertet live eine") - print(" andere Situation als beim Training (Deployment-Drift).") + # ⚠ `p_break` steht in der DB bereits in PROZENT (0–100) — nicht nochmal + # skalieren. Der erste Entwurf tat es und meldete „Ø-Vorhersage 3763,9 %". + pm = sum(float(r[2] or 0) for r in ent) / n + treffer = [1 if r[3] == "break" else 0 for r in ent] + rate, lo, hi = anteil_ci(treffer, n) + rm = 100 * rate + gap = rm - pm + # ⚠ Geflaggt wird nur, wenn die Ø-Vorhersage AUSSERHALB des Intervalls der + # echten Rate liegt — ein Punktabstand allein ist auf dieser Datenlage keine + # Aussage. Die alte 8-Pp-Schwelle ohne Intervall war genau der Fehler. + drin = lo is not None and lo <= pm / 100 <= hi + print(f" n={n} entkoppelt (roh {roh}) Ø-Vorhersage {pm:.1f} % " + f"echte Rate {rm:.1f} % Δ {gap:+.1f} Pp") + if lo is not None: + print(f" 95-%-KI der echten Rate: [{100*lo:.1f} … {100*hi:.1f}] % " + f"{_flag(bool(drin))}") + if not drin: + print(" ⚠ Die Ø-Vorhersage liegt AUSSERHALB des Intervalls — das Modell") + print(" bewertet live eine andere Situation als beim Training") + print(" (Deployment-Drift). Vor einer Reparatur: den Backtest auf") + print(" DASSELBE Zeitfenster einschränken (s. Abschnitt E).") + + +def sec_e(c, t0): + """Der Vergleich, der heute gefehlt hat: der Backtest auf DEMSELBEN Fenster. + + ⚠⚠ WARUM (2026-08-07). Eine Live-Rate gegen einen Backtest-Durchschnitt aus + 208 Tagen zu halten, ist kein Vergleich — es mischt den Regime-Unterschied in + die Aussage. Real gemessen: die Live-Bruchrate lag bei 52,7 %, der + Langfrist-Backtest bei 38,6 %. Auf DIESELBEN sechs Tage eingeschränkt liefert + der Backtest **43,4 %**, und entkoppelt liegt live bei **45,2 %** — kein + belegbarer Unterschied. Von 14,1 Pp „Lücke" waren **34 % Zeitraum, 53 % + Clusterung, der Rest Rauschen**. + + ⚠ Braucht MT5 und ~1–2 min, deshalb nur mit `--backtest`. + """ + print("\n" + "=" * 84) + print(" E) GLEICHES FENSTER — Backtest auf genau dem Live-Zeitraum") + print("=" * 84) + rows = c.execute("""SELECT ts, level, outcome FROM pbreak_predictions + WHERE ts>=? AND outcome IS NOT NULL ORDER BY ts""", + (t0,)).fetchall() + ent = entkoppeln(rows, schluessel_level_stunde(1, 0)) # Tupel: ts=0, level=1 + if len(ent) < 30: + print(f" nur {len(ent)} entkoppelte Live-Vorhersagen — zu wenig.") + return + rate, lo, hi = anteil_ci([1 if r[2] == "break" else 0 for r in ent], len(ent)) + t_von, t_bis = rows[0][0], rows[-1][0] + try: + import MetaTrader5 as mt5 + import numpy as np + + from backtest_pbreak_retrain import (_atr_series, _ema_series, + build_levels, collect, _N_BARS) + from core.wave_rec import _EMA_FAST, _EMA_SLOW + mt5.initialize() + sym = next((s for s in ("SpotCrude", "USOIL", "WTI", "XTIUSD") + if mt5.symbol_info(s)), None) + bars = mt5.copy_rates_from_pos(sym, mt5.TIMEFRAME_M5, 0, 20000) + mt5.shutdown() + T = [int(b["time"]) - _BROKER_OFF for b in bars] + H = [float(b["high"]) for b in bars]; L = [float(b["low"]) for b in bars] + C = [float(b["close"]) for b in bars] + import bisect + a = max(bisect.bisect_left(T, t_von), _N_BARS) + b = bisect.bisect_right(T, t_bis) + if b - a < 200: + print(" M5-Daten decken das Live-Fenster nicht ab."); return + EF = _ema_series(C, _EMA_FAST); ES = _ema_series(C, _EMA_SLOW) + AT = _atr_series(H, L, C) + s = collect(a, b, H, L, C, EF, ES, AT, build_levels(H, L)) + bt = float(np.mean([e["y"] for e in s])) if s else float("nan") + except Exception as e: # pragma: no cover + print(f" Backtest nicht möglich ({e})"); return + + print(f" Fenster: {datetime.fromtimestamp(t_von):%d.%m.} bis " + f"{datetime.fromtimestamp(t_bis):%d.%m.}") + print(f" LIVE entkoppelt n={len(ent):<5} Bruchrate {100*rate:.1f} %" + + (f" 95-%-KI [{100*lo:.1f} … {100*hi:.1f}] %" if lo is not None else "")) + print(f" BACKTEST gleiches Fenster n={len(s):<5} Bruchrate {100*bt:.1f} %") + if lo is not None: + drin = lo <= bt <= hi + print(f" {_flag(bool(drin))} " + + ("Backtest liegt im Intervall — kein belegbarer Unterschied." + if drin else + "Backtest liegt AUSSERHALB — echte Divergenz, nachgehen.")) def sec_d(c, t0): @@ -255,7 +353,9 @@ def sec_d(c, t0): def main(): - days = int(sys.argv[1]) if len(sys.argv) > 1 else 7 + args = [a for a in sys.argv[1:] if not a.startswith("--")] + mit_bt = "--backtest" in sys.argv + days = int(args[0]) if args else 7 t0 = (datetime.now() - timedelta(days=days)).timestamp() db = sqlite3.connect(DB); c = db.cursor() print("=" * 84) @@ -266,6 +366,11 @@ def main(): sec_b(c, t0) sec_c(t0) sec_d0(c, t0) + if mit_bt: + sec_e(c, _clamp(t0, "pbreak_model")[0]) + else: + print() + print(" (Abschnitt E — Backtest auf gleichem Fenster — mit `--backtest`)") sec_d(c, t0) db.close() print("\n" + "=" * 84) diff --git a/core/stichprobe.py b/core/stichprobe.py new file mode 100644 index 0000000..d51468f --- /dev/null +++ b/core/stichprobe.py @@ -0,0 +1,125 @@ +"""Stichproben-Hygiene: Entkopplung und Konfidenzintervalle für Live-Auswertungen. + +⚠⚠ WARUM ES DIESES MODUL GIBT (2026-08-07). An EINEM Tag ist derselbe Fehler +DREIMAL passiert — zweimal in gebautem Code, einmal in einer Auswertung von Hand: + + 1. Die Fälligkeitsbedingung `pbreak_accuracy_v2` zählte **800 ROHE** + Vorhersagen. Roh waren es 878, entkoppelt **87** — sie überschätzte die + Beweismenge um rund das Zehnfache und meldete „fällig", obwohl das + 95-%-Intervall der AUC von 0,434 bis 0,674 reichte und damit weder den + Zufall noch die Messlatte ausschloss. + 2. `analyze_divergence.sec_d0` mittelte `AVG(p_break)` über ALLE Rohzeilen — + derselbe Fehler, fest eingebaut, mit einer 8-Pp-Alarmschwelle darauf. + 3. Und ich selbst habe daraufhin eine „Basisraten-Lücke" von 14,1 Pp + untersucht (elf Hypothesen, alle widerlegt), die es gar nicht gab: 53 % der + Lücke waren Clusterung, 34 % Zeitraum, der Rest Rauschen. + +**Die Zeilen in `pbreak_predictions` und `recommendations` sind NICHT +unabhängig.** Dieselbe Position pendelt vielfach um dasselbe Level; dieselbe +Marktlage erzeugt dutzende Empfehlungszeilen. Wer daraus eine Rate berichtet, +berichtet die Anzahl der Messungen, nicht die Menge der Evidenz. + +**REGEL: vor jeder berichteten Rate `entkoppeln()`, und jede Aussage über einen +Unterschied mit `anteil_ci()` belegen.** Ein Punktschätzer ohne Intervall ist auf +diesen Daten keine Aussage. + +⚠ Verwandt, aber NICHT dasselbe: `backtest_cost_gate.ci95` (Bootstrap für +Mittelwerte von R-Werten). Dort ist die Grenze die Überlappung der +Halte-Fenster; hier ist es die Wiederholung derselben Marktlage. +""" +from __future__ import annotations + +import random + + +def entkoppeln(zeilen, schluessel, /): + """Behält je Schlüssel nur die ERSTE Zeile — macht aus einer geclusterten + Stichprobe eine näherungsweise unabhängige. + + `schluessel` bekommt eine Zeile und gibt den Gruppierungswert zurück, üblich + ist `(Level auf 2 Stellen, Stunde)`. Die Reihenfolge bleibt erhalten. + + ⚠ Das ist eine NÄHERUNG, keine Garantie für Unabhängigkeit — zwei + Berührungen desselben Levels in aufeinanderfolgenden Stunden hängen weiter + zusammen. Sie ist aber um Größenordnungen besser als roh zu zählen. + """ + gesehen = set() + out = [] + for z in zeilen: + k = schluessel(z) + if k in gesehen: + continue + gesehen.add(k) + out.append(z) + return out + + +def anteil_ci(treffer, n: int, wiederholungen: int = 4000, seed: int = 42): + """95-%-Bootstrap-Intervall für einen Anteil. `treffer` = Anzahl oder Liste + von 0/1. Gibt `(anteil, lo, hi)` in 0…1 zurück, bei n < 10 `(anteil, None, + None)` — ein Intervall aus einer Handvoll Punkten wäre Schaufensterdekoration. + """ + if isinstance(treffer, (list, tuple)): + werte = [1 if x else 0 for x in treffer] + n = len(werte) + else: + werte = [1] * int(treffer) + [0] * (int(n) - int(treffer)) + if not werte: + return 0.0, None, None + anteil = sum(werte) / len(werte) + if len(werte) < 10: + return anteil, None, None + rnd = random.Random(seed) + boot = [] + m = len(werte) + for _ in range(wiederholungen): + boot.append(sum(werte[rnd.randrange(m)] for _ in range(m)) / m) + boot.sort() + return anteil, boot[int(0.025 * wiederholungen)], boot[int(0.975 * wiederholungen)] + + +def unterscheidbar(treffer, n: int, referenz: float, **kw) -> bool | None: + """Ist der beobachtete Anteil von `referenz` UNTERSCHEIDBAR? + + `True` = ja (Referenz liegt ausserhalb des Intervalls) · `False` = nein · + `None` = zu wenige Daten für eine Aussage. + + ⚠ `False` heisst **nicht** „gleich", sondern „nicht belegbar verschieden" — + genau diese Unterscheidung ging heute verloren, als aus einem Punktabstand + von 14 Pp ein „Fehler" wurde. + """ + _, lo, hi = anteil_ci(treffer, n, **kw) + if lo is None: + return None + return not (lo <= referenz <= hi) + + +def _feld(z, name): + """Holt ein Feld aus dict, sqlite3.Row ODER einem einfachen Tupel. + + ⚠ Die Tupel-Variante ist der Normalfall: ein sqlite3-Cursor liefert ohne + `row_factory` Tupel, und genau daran ist der erste Einbau gescheitert + (`TypeError: tuple indices must be integers`). Deshalb sind Zahlen als + Feldangabe ausdrücklich erlaubt. + """ + if isinstance(name, int): + return z[name] + if isinstance(z, dict): + return z.get(name) + try: + return z[name] + except (TypeError, IndexError, KeyError): + return None + + +def schluessel_level_stunde(level_feld="level", ts_feld="ts"): + """Der übliche Schlüssel für `pbreak_predictions`: dasselbe Level in derselben + Stunde zählt einmal. + + Feldangaben dürfen Namen (dict/Row) ODER Spaltennummern (Tupel) sein. + """ + def f(z): + lvl = _feld(z, level_feld) + ts = _feld(z, ts_feld) + return (round(float(lvl or 0), 2), int(ts or 0) // 3600) + return f diff --git a/tests/test_stichprobe.py b/tests/test_stichprobe.py new file mode 100644 index 0000000..27522ad --- /dev/null +++ b/tests/test_stichprobe.py @@ -0,0 +1,83 @@ +"""Tests für `core/stichprobe.py` — die Stichproben-Hygiene, ohne die am +2026-08-07 DREIMAL derselbe Fehler passiert ist (Fälligkeitsbedingung, +Divergenz-Wächter, und meine eigene Auswertung). + +⚠ Wichtigster Test unten: `test_faengt_den_fehler_vom_07_08` spielt die reale +Konstellation nach — eine geclusterte Stichprobe, deren rohe Rate weit von der +entkoppelten abweicht. Ohne ihn wäre nicht belegt, dass das Modul den Fall +überhaupt fängt. +""" +import pytest + +from core.stichprobe import (anteil_ci, entkoppeln, schluessel_level_stunde, + unterscheidbar) + + +def test_entkoppeln_behaelt_erste_je_gruppe(): + zeilen = [(3600, 75.0), (3601, 75.0), (3602, 75.004), (7200, 75.0), (7201, 80.0)] + ent = entkoppeln(zeilen, schluessel_level_stunde(1, 0)) + # 75,00 in Stunde 1 (auch 75,004 rundet dorthin), 75,00 in Stunde 2, 80,00 + assert len(ent) == 3 + assert ent[0] == (3600, 75.0) # die ERSTE bleibt, nicht die letzte + assert [z[0] for z in ent] == [3600, 7200, 7201] # Reihenfolge erhalten + + +def test_entkoppeln_leer_und_ohne_duplikate(): + assert entkoppeln([], schluessel_level_stunde(1, 0)) == [] + z = [(0, 1.0), (3600, 2.0), (7200, 3.0)] + assert entkoppeln(z, schluessel_level_stunde(1, 0)) == z + + +def test_feldzugriff_tupel_dict_und_row(): + """⚠ Der erste Einbau scheiterte an genau diesem Punkt: ein sqlite3-Cursor + liefert ohne `row_factory` TUPEL, nicht dicts.""" + k_idx = schluessel_level_stunde(1, 0) + k_name = schluessel_level_stunde("level", "ts") + assert k_idx((3600, 75.0)) == (75.0, 1) + assert k_name({"ts": 3600, "level": 75.0}) == (75.0, 1) + + +def test_anteil_ci_grundfaelle(): + a, lo, hi = anteil_ci([1] * 50 + [0] * 50, 100) + assert a == pytest.approx(0.5) + assert lo < 0.5 < hi + # zu wenige Punkte -> KEIN Intervall vortaeuschen + a, lo, hi = anteil_ci([1, 0, 1], 3) + assert lo is None and hi is None + # Zaehl-Variante muss dasselbe liefern wie die Listen-Variante + assert anteil_ci(50, 100)[0] == pytest.approx(anteil_ci([1] * 50 + [0] * 50, 100)[0]) + + +def test_anteil_ci_reproduzierbar(): + """Fester `seed` — sonst schwankt eine Alarmschwelle von Lauf zu Lauf.""" + assert anteil_ci([1] * 30 + [0] * 70, 100) == anteil_ci([1] * 30 + [0] * 70, 100) + + +def test_unterscheidbar(): + y = [1] * 45 + [0] * 55 # 45 % + assert unterscheidbar(y, 100, 0.44) is False # nah dran -> nicht belegbar + assert unterscheidbar(y, 100, 0.05) is True # weit weg -> belegbar + assert unterscheidbar([1, 0], 2, 0.5) is None # zu wenig -> keine Aussage + + +def test_faengt_den_fehler_vom_07_08(): + """Die reale Konstellation: 10 Level, je einmal 'break' und danach viele + Wiederholungen derselben Lage. Roh sieht die Rate ganz anders aus als + entkoppelt — genau das hat am 07.08. eine Untersuchung mit elf widerlegten + Hypothesen ausgeloest.""" + zeilen = [] + for i in range(10): + ts = 3600 * i + zeilen.append((ts, 75.0 + i, 1)) # erster Touch: Durchbruch + zeilen.extend((ts + 60 + j, 75.0 + i, 1) for j in range(19)) # 19 Wiederholungen + for i in range(10, 30): + zeilen.append((3600 * i, 75.0 + i, 0)) # 20 einzelne Abpraller + roh = sum(z[2] for z in zeilen) / len(zeilen) + ent = entkoppeln(zeilen, schluessel_level_stunde(1, 0)) + entr = sum(z[2] for z in ent) / len(ent) + assert roh > 0.75, "Aufbau falsch — roh muesste stark ueberhoeht sein" + assert entr == pytest.approx(1 / 3, abs=0.02) + # Die Verzerrung muss GROSS sein, sonst prueft der Test nichts + assert roh - entr > 0.3 + # ...und die rohe Rate muss ausserhalb des Intervalls der entkoppelten liegen + assert unterscheidbar([z[2] for z in ent], len(ent), roh) is True diff --git a/weekly_review.py b/weekly_review.py index 8df3e11..29145d6 100644 --- a/weekly_review.py +++ b/weekly_review.py @@ -6,6 +6,7 @@ Aufruf: python weekly_review.py (letzte 6 Wochen + diese Woche im Detail Zeitspalten der DB sind LOKALE Epoch (Broker-Offset bereits korrigiert). """ import sqlite3, datetime as dt +from pathlib import Path DB = "oil_widget_history.db" # ── Backtest-Erwartung (R-basiert, exit-sim; s. CLAUDE.md / backtest_*.py) ── @@ -103,6 +104,58 @@ def _fetch_m5(days=30): return None +def _divergenz_section() -> list: + """LIVE gegen BACKTEST auf DEMSELBEN Fenster — Pipeline-Einbau 2026-08-07. + + ⚠⚠ WARUM DAS HIER STEHT. Track B prüft, ob ein Edge existiert; der + Wochenreport prüft, ob er live noch trägt. Was BEIDE nicht abdeckten: ob + live überhaupt DASSELBE gemessen wird wie im Backtest. `analyze_divergence.py` + gibt es dafür seit dem 31.07., aber es lief nur, wenn jemand daran dachte — + und am 07.08. hat genau diese Lücke zugeschlagen: eine geclusterte + 6-Tage-Live-Stichprobe wurde gegen ein 208-Tage-Backtest-Mittel gehalten, + Ergebnis eine „Lücke" von 14,1 Pp, die es nicht gab (34 % Zeitraum, + 53 % Clusterung, Rest Rauschen — elf Hypothesen umsonst geprüft). + + Der Abschnitt beantwortet deshalb genau zwei Fragen, beide mit + Konfidenzintervall statt Punktabstand: + · sagt das Modell live das voraus, was eintritt? (entkoppelt!) + · liefert der Backtest auf DEMSELBEN Zeitfenster dasselbe? + + ⚠ Fail-safe wie beim Squeeze-Abschnitt: schlägt der Aufruf fehl (kein MT5, + keine DB), fehlt der Abschnitt — der Report darf daran nicht scheitern. + """ + import subprocess + import sys + out = ["", "=" * 92, + "DIVERGENZ — läuft der Bot unter den Bedingungen, unter denen gemessen wurde?", + "=" * 92] + try: + r = subprocess.run( + [sys.executable, "-X", "utf8", "analyze_divergence.py", "7", "--backtest"], + cwd=Path(__file__).resolve().parent, capture_output=True, text=True, + timeout=900) + txt = r.stdout or "" + # Nur die beiden entscheidenden Abschnitte übernehmen — der Rest ist im + # Wochenreport Rauschen und würde die Mail unlesbar machen. + nimm = False + for ln in txt.splitlines(): + if ln.strip().startswith(("D0)", "E)")): + nimm = True + elif ln.strip().startswith(("D)", "A)", "B)", "C)")): + nimm = False + if nimm and ln.strip() and not ln.startswith("==="): + out.append(" " + ln.strip()) + if len(out) <= 4: + out.append(" (keine auswertbaren Daten)") + except Exception as e: + out.append(f" Divergenz-Prüfung nicht möglich ({e})") + out.append("") + out.append(" ⚠ Gelesen wird gegen das KONFIDENZINTERVALL, nicht gegen den") + out.append(" Punktabstand: „liegt der Backtest-Wert im Intervall der") + out.append(" entkoppelten Live-Rate?" + '"' + " Nur ein NEIN ist ein Befund.") + return out + + def build_report(db=DB, m5=None) -> str: """Baut den B4-Wochenreport als reinen Text (für Konsole, Mail-
, Telegram).
     m5 = vorab geholte M5-Bars für den Squeeze-Monitor (Server reicht sie unter
@@ -166,6 +219,8 @@ def build_report(db=DB, m5=None) -> str:
     P("erst Ursache trennen (Regime vs. Disziplin vs. Sizing), nicht am Signal drehen.")
     for ln in _squeeze_section(m5):
         P(ln)
+    for ln in _divergenz_section():
+        P(ln)
     return "\n".join(out)
 
 def main():