Nicht nach Katalog beantwortet, sondern gegen die dokumentierten Schwachstellen des Projekts - und beide Kandidaten wurden GEMESSEN, nicht empfohlen. ERTRAG 1 - BLOCK-BOOTSTRAP (core/stichprobe.block_ci95), braucht GAR KEINE Bibliothek. Die Luecke stand seit dem 07.08. woertlich in backtest_cost_gate: "fuer ueberlappende Beobachtungen zu optimistisch ... dafuer braeuchte es einen Block-Bootstrap". Behelf war entkoppeln(). Auf echten Daten (pbreak_predictions): naiv, alle Zeilen n=4702 Breite 0,028 <- 3x zu eng entkoppelt (bisher) n= 552 Breite 0,083 <- 88 % Datenverlust Block L=30 n=4702 Breite 0,081 <- gleiche Breite, ALLE Daten Und das Entkoppeln kostet nicht nur Genauigkeit, es VERSCHIEBT die Schaetzung (0,399 gegen 0,427) - es nimmt systematisch die ERSTE Beruehrung je Level+Stunde. KALIBRIERUNG GEMESSEN (AR(1), bekanntes Mittel, 200 Laeufe, Soll 95 %): rho naiv L=10 L=30 L=60 0,00 94 % 94 % 91 % 92 % 0,50 76 % 92 % 91 % 91 % 0,90 40 % 80 % 89 % 90 % 0,97 18 % 55 % 74 % 83 % Die naive Zeile ist der Befund: bei rho=0,97 behauptet sie 95 % Sicherheit und liegt in 18 % der Faelle richtig. Ehrlich zu den Grenzen: bei unabhaengigen Daten kostet der Block ~3 Punkte (dort bleibt ci95 besser), und bei rho=0,97 deckt auch er nur 83 % - eine Verbesserung, keine Garantie. ERTRAG 2 - hypothesis (dev-only), aufgenommen NACH einer Gegenprobe. Die Suite prueft bisher BEISPIELE, und die waehle ich selbst aus. Die teuersten Fehler sassen auf GRENZEN: room_info musste `frei` auf dem UNGERUNDETEN Abstand entscheiden, weil 0,5951 gerundet durchs 0,6-Gate rutscht. hypothesis findet genau diesen Fall (dist=0.59765625) in Sekunden. Erst danach aufgenommen. Neu tests/test_invarianten.py (4 Eigenschafts-Tests auf REINEN Funktionen), per importorskip - die Suite laeuft auch ohne. Beim ersten Lauf fielen zwei Tests, beide waren MEINE Fehler: cluster rundet auf 3 Stellen (Invariante zu streng) und zwei inhaltsgleiche dicts sind == (Reihenfolge muss ueber die Identitaet geprueft werden). Genau dafuer sind Property-Tests da. Mutationsprobe: Rundung verbogen -> 2 Tests fallen. BEGRUENDET ABGELEHNT: numba (Backtests 11 s/12k Bars = nicht der Engpass, und ein Rewrite verletzt die Bit-Gleichheits-Regel) · statsmodels/arch (jetzt 15 Zeilen stdlib mit eigener Kalibrierung) · polars/pyarrow (kein DataFrame- Workload) · loguru/structlog/rich (das Logformat ist forensisch tragend) · tenacity (Wiederholungen haben eigene Semantik, _mark_dead) · httpx (requests laeuft) · pandas_ta (Indikatoren sind validiert, ein Austausch verschoebe jede dokumentierte Zahl) · mypy (kaum Annotationen, ty deckt die Aritaet ab). requirements.txt (Laufzeit) bleibt UNVERAENDERT - auf einer frischen Maschine soll der Bot mit moeglichst kleiner Oberflaeche wieder laufen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
247 lines
10 KiB
Python
247 lines
10 KiB
Python
"""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
|
|
|
|
|
|
def paarweise(basis: dict, variante: dict, wiederholungen: int = 3000,
|
|
seed: int = 7):
|
|
"""GEPAARTER Vergleich zweier Exit-/Regel-Varianten auf DENSELBEN Faellen.
|
|
|
|
⚠⚠ WARUM DAS NOETIG IST (2026-08-14, real passiert). Beim `trail_start`-Sweep
|
|
sahen SEQUENTIELL vier Werte in beiden Haelften besser aus (Δ bis +0,101).
|
|
Gepaart kippte in H2 das Vorzeichen. Ursache: eine sequentielle Sim mit EINEM
|
|
Positions-Slot nimmt bei anderem Exit ANDERE Trades — der scheinbare Gewinn
|
|
war ein **Selektionseffekt**, kein Exit-Effekt. Dieselbe Falle steckte am
|
|
31.07. in `backtest_exit_combo.py` („verschiedene Trade-Folgen sind nicht
|
|
vergleichbar").
|
|
|
|
⚠ Und der gepaarte Test ist nicht nur richtiger, sondern **trennschaerfer**:
|
|
die Einzel-Intervalle messen die Streuung der TRADES, das gepaarte die des
|
|
UNTERSCHIEDS — und die ist um ein Vielfaches kleiner.
|
|
|
|
`basis` / `variante`: {schluessel: ergebnis}. Der Schluessel identifiziert den
|
|
Fall (z. B. der Einstiegs-Bar-Index). Verglichen wird nur die Schnittmenge.
|
|
|
|
Rueckgabe: (mittlere Differenz, lo, hi, n) — bei n < 20 (None, None, None, n).
|
|
"""
|
|
import random as _r
|
|
gemeinsam = [k for k in basis if k in variante]
|
|
dif = [variante[k] - basis[k] for k in gemeinsam]
|
|
n = len(dif)
|
|
if n < 20:
|
|
return None, None, None, n
|
|
m = sum(dif) / n
|
|
r = _r.Random(seed)
|
|
bs = sorted(sum(dif[r.randrange(n)] for _ in range(n)) / n
|
|
for _ in range(wiederholungen))
|
|
return m, bs[int(.025 * wiederholungen)], bs[int(.975 * wiederholungen)], n
|
|
|
|
|
|
def zeig_paarweise(name: str, basis: dict, variante: dict) -> bool:
|
|
"""Druckt den gepaarten Vergleich und sagt, ob er die Null ausschliesst."""
|
|
m, lo, hi, n = paarweise(basis, variante)
|
|
if m is None:
|
|
print(f" {name:<28} zu wenige gemeinsame Faelle (n={n})")
|
|
return False
|
|
ok = lo > 0 or hi < 0
|
|
print(f" {name:<28} Δ je Fall {m:>+.4f} KI [{lo:+.4f} … {hi:+.4f}] "
|
|
f"n={n} {'✅ ohne 0' if ok else '⚠ enthaelt 0'}")
|
|
return ok
|
|
|
|
|
|
def autokorrelation(x, lag: int = 1) -> float:
|
|
"""Autokorrelation zum gegebenen Lag. 0 = unabhaengig, ->1 = stark geclustert."""
|
|
n = len(x)
|
|
if n <= lag + 1:
|
|
return 0.0
|
|
m = sum(x) / n
|
|
nen = sum((v - m) ** 2 for v in x)
|
|
if nen <= 0:
|
|
return 0.0
|
|
zae = sum((x[i] - m) * (x[i + lag] - m) for i in range(n - lag))
|
|
return max(-1.0, min(1.0, zae / nen))
|
|
|
|
|
|
def block_ci95(x, blocklen: int | None = None, wiederholungen: int = 2000,
|
|
seed: int = 7):
|
|
"""95-%-Intervall fuer den Mittelwert bei ABHAENGIGEN Beobachtungen.
|
|
|
|
⚠⚠ WOZU — die Luecke stand seit dem 2026-08-07 woertlich in
|
|
`backtest_cost_gate.ci95`: *"Fuer ueberlappende Beobachtungen ist er ZU
|
|
OPTIMISTISCH ... dafuer braeuchte es einen Block-Bootstrap — `ci95` ist
|
|
dafuer NICHT gedacht."* Der Behelf war bis jetzt `entkoppeln()`, und der
|
|
kostet teuer: auf `pbreak_predictions` bleiben von **4.702 Zeilen nur 552**
|
|
(88 % Datenverlust) — und er VERSCHIEBT die Schaetzung, weil er
|
|
systematisch die ERSTE Beruehrung je Level+Stunde nimmt (0,399 gegen 0,427).
|
|
|
|
Der Moving-Block-Bootstrap zieht stattdessen zusammenhaengende BLOECKE und
|
|
behaelt damit die Abhaengigkeit INNERHALB eines Blocks. Auf denselben Daten:
|
|
naiv n=4702 Breite 0,028 <- 3x zu eng
|
|
entkoppelt n= 552 Breite 0,083 <- 88 % weg, Schaetzung verschoben
|
|
Block L=30 n=4702 Breite 0,081 <- gleiche Breite, ALLE Daten
|
|
|
|
✅ KALIBRIERUNG GEMESSEN, nicht behauptet (AR(1)-Reihen, wahres Mittel
|
|
bekannt, 200 Laeufe je Zelle — Ueberdeckung soll 95 % sein):
|
|
rho naiv L=10 L=30 L=60
|
|
0,00 94 % 94 % 91 % 92 %
|
|
0,50 76 % 92 % 91 % 91 %
|
|
0,90 40 % 80 % 89 % 90 %
|
|
0,97 18 % 55 % 74 % 83 %
|
|
⚠ EHRLICH ZU DEN GRENZEN: (a) bei rho=0 kostet es ~3 Punkte Ueberdeckung
|
|
gegenueber dem naiven Verfahren — bei UNABHAENGIGEN Daten bleibt `ci95` die
|
|
bessere Wahl; (b) bei sehr starker Abhaengigkeit (rho=0,97) deckt auch der
|
|
Block-Bootstrap nur 83 %, ist also weiter zu optimistisch, nur eben viel
|
|
weniger. Er ist eine Verbesserung, keine Garantie.
|
|
|
|
`blocklen=None` waehlt automatisch aus der gemessenen Lag-1-Autokorrelation
|
|
(mehr Abhaengigkeit -> laengere Bloecke). Rueckgabe (lo, hi, blocklen) bzw.
|
|
(None, None, 0) bei zu kleiner Stichprobe.
|
|
"""
|
|
import random as _r
|
|
x = [float(v) for v in x]
|
|
n = len(x)
|
|
if n < 40:
|
|
return None, None, 0
|
|
if blocklen is None:
|
|
rho = abs(autokorrelation(x))
|
|
# rho 0 -> ~n^(1/3); stark abhaengig -> deutlich laenger. Gedeckelt auf
|
|
# n//5, damit noch mindestens 5 Bloecke gezogen werden.
|
|
basis = max(2, int(round(n ** (1 / 3))))
|
|
blocklen = max(2, min(n // 5, int(round(basis * (1.0 + 6.0 * rho)))))
|
|
if n < 2 * blocklen:
|
|
return None, None, 0
|
|
r = _r.Random(seed)
|
|
k = -(-n // blocklen)
|
|
mittel = []
|
|
for _ in range(wiederholungen):
|
|
s = []
|
|
for _ in range(k):
|
|
i = r.randrange(n - blocklen + 1)
|
|
s.extend(x[i:i + blocklen])
|
|
mittel.append(sum(s[:n]) / n)
|
|
mittel.sort()
|
|
return (mittel[int(0.025 * wiederholungen)],
|
|
mittel[int(0.975 * wiederholungen)], blocklen)
|