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:
co-authored by
Claude Opus 5
parent
1f58049049
commit
3f1f19087d
@@ -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.**
|
||||
|
||||
+120
-15
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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-<pre>, 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():
|
||||
|
||||
Reference in New Issue
Block a user