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 <noreply@anthropic.com>
This commit is contained in:
Axel Hocks
2026-08-07 11:55:05 +02:00
co-authored by Claude Opus 5
parent 1f58049049
commit 3f1f19087d
5 changed files with 430 additions and 15 deletions
+120 -15
View File
@@ -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 (0100) — 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 ~12 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)