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
@@ -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
|
||||
Reference in New Issue
Block a user