Files
AH-Oil-Trader/core/stichprobe.py
T
Axel HocksandClaude Opus 5 792355e8f9 Sieben Verbesserungen umgesetzt: Breaker scharf, B5 entschieden, 5 Werkzeuge
1) CIRCUIT-BREAKER SCHARF -- daily_loss_limit_pct 0 -> 8. Live verifiziert:
   enabled=True, limit_eur 89,44. Steht nicht in runtime_state.json, die ini
   greift direkt. Begruendung: MaxDD 82,8 % auf dem gemessenen IST-Pfad,
   schlechtester Einzeltrade -381 EUR.

2) B5-DEADLOCK RECHNERISCH AUFGELOEST -- die Regel ist ENTSCHIEDEN, nicht
   vertagt. Stand 11/20: Verhaeltnis 0,14 (Latte 1,0), PF 0,39 (Latte 1,0).
   Was die 9 Rest-Trades leisten muessten:
     9/0 Gewinner -> 165,72 EUR je Trade = 12,2x der bisherige Schnitt
     7/2          -> 186,17 EUR
     5/4          -> 222,99 EUR
   Der groesste Squeeze-Gewinn des Zeitraums war 77,34 EUR -- selbst der beste
   Fall verlangt 2,1x den Rekord, neunmal in Folge. Latte nicht mehr erreichbar.
   auto_squeeze bleibt FALSE. Wiederaufnahme waere eine NEUE Entscheidung mit
   NEUER, vorab fixierter Latte.

3) core/kontrolle.py -- Kontroll-Fixture mit den Referenzwerten. Bricht bewusst
   NICHT ab: ein Backtest darf abweichen, er muss es nur wissen und sagen.

4) core/live_config.py -- EINE Quelle der Live-Parameter (ini + runtime_state in
   der Vorrangfolge des Engine-Starts). Dritte Instanz derselben Fehlerklasse:
   ATRMIN_STD 0,12 statt 0,06, fehlendes set_entry_room, nachgebauter Cluster.

5) stichprobe.paarweise()/zeig_paarweise() -- gepaarter Vergleich. Anlass:
   trail_start sah sequentiell besser aus, gepaart kippte das Vorzeichen
   (Selektions- statt Exit-Effekt). Selbsttest in drei Richtungen bestanden.

6) tools/check_konstanten.py -- Waechter fuer veraltete Zahlen.
   Erster Lauf: zwei Befunde, BEIDE Fehler in meiner eigenen Ableitung (Margin
   aus Balance geschaetzt statt vom Broker gelesen; Elliott ohne
   Gueltigkeitsfenster gezaehlt). Beide korrigiert.
   Danach ein ECHTER Fund: meine am 13.08. notierten "~405 EUR/Lot" sind falsch,
   der exakte Broker-Wert ist 712,84 -- die urspruenglich notierten 670 lagen
   naeher als meine Korrektur. Lehre: eine abgeleitete Zahl ist keine gemessene.
   Stand jetzt 0 Befunde.

7) Puls-Waechter bedingungs-bewusst: PULS traegt je Zeile die Vorbedingung,
   bedingte Tabellen erscheinen als Hinweis statt als Ausfall. 0 Zeilen bleibt
   IMMER ein Befund (der rec_outcomes-Fall). Grund: ein Waechter mit
   Fehlalarmen wird ignoriert -- so ist der Divergenz-Waechter verstummt.

Pipeline gruen (85 Tests), Deployment ueber deploy.py --feld circuit_breaker
verifiziert, genau eine Instanz je Port.
Nicht angefasst: Sizing (User-Entscheidung) und die 44 Backtests mit eigener
Exit-Kopie (Migrations-Regel).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 00:57:38 +02:00

172 lines
7.0 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