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
+47
View File
@@ -4911,6 +4911,53 @@ Modell schliesst keine Trades.
„die Rangfolge ist kaputt" ist **nicht** belegt; belegt ist nur die „die Rangfolge ist kaputt" ist **nicht** belegt; belegt ist nur die
Basisraten-/Kalibrierungslücke. 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 ~12 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) ## ✅⚠⚠ 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.** Nach elf geprüften Hypothesen ist der Befund: **es gab keinen Fehler im System.**
+120 -15
View File
@@ -24,14 +24,20 @@ GEPRÜFT WIRD:
„feste TF" — je mehr Wechsel, desto weiter weg) „feste TF" — je mehr Wechsel, desto weiter weg)
D) Merkmals-Drift der eingebetteten Modelle: Live-Mittel gegen `_PB_MU`/`_PB_SD` 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 re
import sqlite3 import sqlite3
import sys import sys
from datetime import datetime, timedelta from datetime import datetime, timedelta
from core.stichprobe import anteil_ci, entkoppeln, schluessel_level_stunde
DB = "oil_widget_history.db" DB = "oil_widget_history.db"
_BROKER_OFF = 10800 # Brokerzeit = UTC+3 (candles/copy_rates sind Broker-Wallclock)
LOG = "oil_widget.log" LOG = "oil_widget.log"
# Erwartungswerte aus den Backtests — die Messlatte # Erwartungswerte aus den Backtests — die Messlatte
@@ -190,21 +196,113 @@ def sec_d0(c, t0):
t0, cut = _clamp(t0, "pbreak_model") t0, cut = _clamp(t0, "pbreak_model")
if cut: if cut:
print(f" (nur Vorhersagen seit dem Nachtraining am {_EPOCHS['pbreak_model']})") 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), # ⚠⚠ ENTKOPPELN, BEVOR IRGENDEINE RATE BERICHTET WIRD (Fix 2026-08-07).
COUNT(*) FROM pbreak_predictions # Vorher stand hier ein schlichtes `AVG(...)` über ALLE Rohzeilen. Die Zeilen
WHERE ts>=? AND outcome IS NOT NULL""", (t0,)) # sind aber NICHT unabhängig — dieselbe Position pendelt vielfach um dasselbe
pm, rm, n = c.fetchone() # Level. Real am 07.08.: roh 922 Zeilen mit 52,6 % Bruchrate, entkoppelt 93
if not n or n < 30: # Zeilen mit 45,2 %. Der Wächter hätte auf der Rohzahl Alarm geschlagen
print(f" nur {n or 0} ausgewertete Vorhersagen seit dem Nachtraining — zu wenig.") # (Δ +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. " 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 return
gap = (rm or 0) - (pm or 0) # ⚠ `p_break` steht in der DB bereits in PROZENT (0100) — nicht nochmal
print(f" n={n} Ø-Vorhersage {pm:.1f} % echte Rate {rm:.1f} % " # skalieren. Der erste Entwurf tat es und meldete „Ø-Vorhersage 3763,9 %".
f"Δ {gap:+.1f} Pp {_flag(abs(gap) <= 8)}") pm = sum(float(r[2] or 0) for r in ent) / n
if abs(gap) > 8: treffer = [1 if r[3] == "break" else 0 for r in ent]
print(" ⚠ Das Modell ist systematisch verschoben — es bewertet live eine") rate, lo, hi = anteil_ci(treffer, n)
print(" andere Situation als beim Training (Deployment-Drift).") 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): def sec_d(c, t0):
@@ -255,7 +353,9 @@ def sec_d(c, t0):
def main(): 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() t0 = (datetime.now() - timedelta(days=days)).timestamp()
db = sqlite3.connect(DB); c = db.cursor() db = sqlite3.connect(DB); c = db.cursor()
print("=" * 84) print("=" * 84)
@@ -266,6 +366,11 @@ def main():
sec_b(c, t0) sec_b(c, t0)
sec_c(t0) sec_c(t0)
sec_d0(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) sec_d(c, t0)
db.close() db.close()
print("\n" + "=" * 84) print("\n" + "=" * 84)
+125
View File
@@ -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
+83
View File
@@ -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
+55
View File
@@ -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). Zeitspalten der DB sind LOKALE Epoch (Broker-Offset bereits korrigiert).
""" """
import sqlite3, datetime as dt import sqlite3, datetime as dt
from pathlib import Path
DB = "oil_widget_history.db" DB = "oil_widget_history.db"
# ── Backtest-Erwartung (R-basiert, exit-sim; s. CLAUDE.md / backtest_*.py) ── # ── Backtest-Erwartung (R-basiert, exit-sim; s. CLAUDE.md / backtest_*.py) ──
@@ -103,6 +104,58 @@ def _fetch_m5(days=30):
return None 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: def build_report(db=DB, m5=None) -> str:
"""Baut den B4-Wochenreport als reinen Text (für Konsole, Mail-<pre>, Telegram). """Baut den B4-Wochenreport als reinen Text (für Konsole, Mail-<pre>, Telegram).
m5 = vorab geholte M5-Bars für den Squeeze-Monitor (Server reicht sie unter 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.") P("erst Ursache trennen (Regime vs. Disziplin vs. Sizing), nicht am Signal drehen.")
for ln in _squeeze_section(m5): for ln in _squeeze_section(m5):
P(ln) P(ln)
for ln in _divergenz_section():
P(ln)
return "\n".join(out) return "\n".join(out)
def main(): def main():