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
|
„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 ~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)
|
## ✅⚠⚠ 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
@@ -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 (0–100) — 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 ~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):
|
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)
|
||||||
|
|||||||
@@ -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).
|
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():
|
||||||
|
|||||||
Reference in New Issue
Block a user