Level-Auswahl: Nachbau behoben (core/sr_levels.py) - Luecke bleibt offen

Auftrag zum benannten naechsten Schritt: die Backtests bauten die
Live-Level-Auswahl nach, statt sie zu benutzen. Drei echte Abweichungen:

  1. Der Nachbau clusterte den GEMISCHTEN Rohpool (sorted(ch+cl)); live werden
     ph und pl GETRENNT geclustert. Das ist exakt der Fehler, den der Live-Code
     als behoben dokumentiert ("nicht vorher zusammenlegen!" - Ketten-Mega-
     Cluster in dichten Zonen). Gemessen: 4,5 % aller Zeitpunkte hatten einen
     anderen Widerstand.
  2. Live rundet jede Linie auf 3 Stellen, der Nachbau nicht.
  3. Live fuehrt Widerstand und Unterstuetzung mit getrennter Hysterese.

Geloest ueber core/sr_levels.py (cluster/linien/naechste/halten).
engine._draw_levels ruft es auf, Verhalten bitgenau unveraendert
(tests/test_sr_levels.py, 6 Tests, je 300-400 Zufallsfaelle). Der Test enthaelt
die Gegenprobe, dass der Nachbau wirklich abwich - sonst waere nicht belegt,
dass es etwas zu beheben gab.

backtest_pbreak_retrain.build_levels bleibt unveraendert (Migrations-Regel: die
daraus gefitteten Live-Gewichte duerfen sich nicht rueckwirkend verschieben).
Daneben neu: build_levels_live fuer kuenftige Fits.

ABER die Korrektur schliesst die Basisraten-Luecke nicht: 38,0 -> 38,3 %,
live 52,7 %.

Damit fuenf Hypothesen geprueft und alle widerlegt:
  Niveau/Achsenabschnitt   Roll-Achse faellt live durch
  Vola-Regime              ueber 5 Quintile flach (37,0-39,2 %)
  Sammelart                -1,7 Pp, falsche Richtung
  ATR-Floor-Artefakt       +2,8 Pp; bei live-gleichem ATR erst 40,1 %
  Level-Auswahl            +0,4 Pp

Nebenbefund: 53,9 % aller Backtest-Bars sitzen auf dem ATR-Floor 0,12, live nur
5,4 % - der Backtest-Zeitraum ist deutlich ruhiger. Der ATR selbst stimmt aber
ueberein (Median live 0,2167 gegen letzte 7 Tage 0,2174).

Kein Notfall: auto_sr_close ist false, das Modell schliesst keine Trades.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Axel Hocks
2026-08-07 11:30:07 +02:00
co-authored by Claude Opus 5
parent 2e89724368
commit aefac274d3
5 changed files with 328 additions and 25 deletions
+60
View File
@@ -4911,6 +4911,66 @@ Modell schliesst keine Trades.
„die Rangfolge ist kaputt" ist **nicht** belegt; belegt ist nur die
Basisraten-/Kalibrierungslücke.
## ⚠⚠ LEVEL-AUSWAHL: NACHBAU BEHOBEN — die Basisraten-Lücke bleibt UNERKLÄRT (2026-08-07)
Auftrag „behebe das" zum benannten nächsten Schritt: die Backtests bauten die
Live-Level-Auswahl nach, statt sie zu benutzen.
✅ **DREI ECHTE ABWEICHUNGEN GEFUNDEN — und die erste ist ein Lehrstück:**
| # | Nachbau (`backtest_pbreak_retrain.py`) | LIVE (`_draw_levels`) |
|---|---|---|
| 1 | clustert den **GEMISCHTEN** Rohpool (`sorted(ch+cl)`) | ph und pl **GETRENNT**, dann zusammenführen |
| 2 | keine Rundung | `round(…, 3)` je Linie |
| 3 | EINE Hysterese je Trade-Richtung | Widerstand und Unterstützung **getrennt** |
⚠⚠ **Punkt 1 ist genau der Fehler, den der Live-Code als BEHOBEN dokumentiert:**
„ph/pl bleiben GETRENNT geclustert (**nicht vorher zusammenlegen!**) — ein erster
Versuch clusterte den kombinierten Rohpool direkt, was in dichten Zonen zu
**Ketten-Mega-Clustern** führte." Der Nachbau machte also weiter das, was live
erkannt und repariert worden war. **Gemessen: 4,5 % aller Zeitpunkte hatten einen
anderen Widerstand** (13.283 Vergleichspunkte, Ø-Linienzahl 8,1 statt 8,4).
**GELÖST: `core/sr_levels.py`** (`cluster` · `linien` · `naechste` · `halten`).
`engine._draw_levels` ruft es auf; **Verhalten bitgenau unverändert**
(`tests/test_sr_levels.py`, 6 Tests über je 300400 Zufallsfälle inkl. Hysterese
in beide Richtungen). ⚠ Der Test enthält bewusst auch die **Gegenprobe**, dass der
Nachbau wirklich abwich — ohne sie wäre nicht belegt, dass es etwas zu beheben gab.
`backtest_pbreak_retrain.build_levels` bleibt **unverändert** (Migrations-Regel:
seine Zahlen und die daraus gefitteten Live-Gewichte `_PB_*` dürfen sich nicht
rückwirkend verschieben). Daneben steht jetzt **`build_levels_live`** — **neue Fits
gehören dorthin**, zusammen mit `core.sr_levels.linien` statt `_cluster`.
⚠⚠ **ABER: DIE KORREKTUR SCHLIESST DIE LÜCKE NICHT — +0,4 Pp.**
| | Basisrate |
|---|---|
| Nachbau (gemischter Pool) | 38,0 % |
| korrigiert (live-treu) | **38,3 %** |
| **LIVE ab 01.08.** | **52,7 %** |
⚠⚠⚠ **FÜNF HYPOTHESEN, FÜNF WIDERLEGUNGEN — die 15-Pp-Lücke ist OFFEN:**
| Hypothese | Ergebnis |
|---|---|
| Niveau/Achsenabschnitt driftet | Roll-Achse fällt live durch, oberste Klasse bleibt invertiert |
| Vola-Regime | Bruchrate über 5 Quintile **flach** (37,039,2 %) |
| Sammelart (je Berührung ↔ je Bar) | **1,7 Pp**, falsche Richtung |
| **ATR-Floor-Artefakt** | **+2,8 Pp**; bei live-gleichem ATR (≥0,20) erst **40,1 %** |
| **Level-Auswahl (dieser Fix)** | **+0,4 Pp** |
⚠ Der ATR-Befund ist trotzdem bemerkenswert: **53,9 % aller Backtest-Bars sitzen
auf dem Floor 0,12**, live nur **5,4 %** — der Backtest-Zeitraum ist also viel
ruhiger als die Live-Woche. Der ATR selbst stimmt dagegen überein (Median live
0,2167 gegen letzte 7 Tage 0,2174, Verhältnis **1,00**).
**Stand: der Fix ist richtig und gebaut, erklärt den Live-Fehler aber nicht.**
Die Live-Bruchrate von 52,7 % liegt ausserhalb der gesamten 9-Monats-Spanne
(33,6…41,1 %) und ist nach fünf geprüften Erklärungen weiterhin offen.
⚠ Nächste ungeprüfte Kandidaten: die **M1-gegen-M5-Auflösung** bei der
Ausgangsbestimmung (live wertet gegen `candles_m1` aus, der Backtest gegen
M5-Bars — innerhalb eines M5-Bars prüft der Backtest `confirm` VOR `reject`,
was ihn *zugunsten* von „break" verzerrt, also in die falsche Richtung), und die
Frage, ob `wave.pb_levels30` live überhaupt dieselben Pivots liefert wie
`build_levels`. **Beides nicht gemessen.**
⚠ Weiterhin **kein Notfall**: `auto_sr_close = false`, das Modell schliesst keine
Trades.
## ⚠⚠ LIVE-CODE HING AN EINEM BACKTEST-SKRIPT — behoben 2026-08-07
**Befund:** `core/engine.py` (`squeeze_monitor`) und `weekly_review.py` importierten
+50 -1
View File
@@ -105,8 +105,57 @@ def build_levels(H, L):
return lv
def build_levels_live(H, L):
"""Wie `build_levels`, aber hält ph und pl GETRENNT — so wie live.
⚠⚠ WARUM ES DIESE ZWEITE FASSUNG GIBT (2026-08-07). `build_levels` wirft
Hochs und Tiefs in EINE sortierte Liste, und `_cluster` läuft die
Nachbar-Kette darüber. Genau das ist im Live-Code als behobener Fehler
dokumentiert („ph/pl bleiben GETRENNT geclustert — nicht vorher
zusammenlegen!"): über den gemischten Pool verdoppelt sich die Punktdichte
und die Kette läuft durch dichte Zonen durch (Ketten-Mega-Cluster).
Gemessen: **4,5 % aller Zeitpunkte** hatten dadurch einen anderen
Widerstand; auf die Basisrate wirkt es mit **+0,4 Pp**.
⚠ Die ALTE `build_levels` bleibt unverändert — die dokumentierten Zahlen
dieses Skripts (und die daraus gefitteten Live-Gewichte `_PB_*`) dürfen sich
nicht rückwirkend verschieben (Migrations-Regel). **Neue Fits gehören auf
diese Fassung**, zusammen mit `core.sr_levels.linien` statt `_cluster`.
"""
nm = len(H) // _M30
mh = [max(H[i * _M30:(i + 1) * _M30]) for i in range(nm)]
ml = [min(L[i * _M30:(i + 1) * _M30]) for i in range(nm)]
bh = [[] for _ in range(nm)]; bl = [[] for _ in range(nm)]
for p in range(_PIV_K, nm - _PIV_K):
if mh[p] == max(mh[p - _PIV_K:p + _PIV_K + 1]) and p + _PIV_K < nm:
bh[p + _PIV_K].append(mh[p])
if ml[p] == min(ml[p - _PIV_K:p + _PIV_K + 1]) and p + _PIV_K < nm:
bl[p + _PIV_K].append(ml[p])
out = []
wh = deque(); wl = deque(); ch = []; cl = []
for m in range(nm):
wh.append(bh[m]); ch.extend(bh[m])
wl.append(bl[m]); cl.extend(bl[m])
while len(wh) > _PIV_LOOKBACK:
for x in wh.popleft():
try: ch.remove(x)
except ValueError: pass
while len(wl) > _PIV_LOOKBACK:
for x in wl.popleft():
try: cl.remove(x)
except ValueError: pass
out.append({"ph": sorted(ch), "pl": sorted(cl)})
return out
def _cluster(levels, atr):
"""Nahe Pivots zu einer Linie zusammenfassen (~0,5×ATR) — wie `_draw_levels`."""
"""Nahe Pivots zu einer Linie zusammenfassen (~0,5×ATR).
⚠ NICHT „wie `_draw_levels`" — das stand hier jahrelang und war falsch: live
wird je Seite geclustert und auf 3 Stellen gerundet, hier über den gemischten
Pool und ohne Rundung. Für neue Arbeit `core.sr_levels.linien` benutzen;
diese Funktion bleibt nur, damit die dokumentierten Zahlen reproduzierbar
sind."""
if not levels:
return []
out = []; grp = [levels[0]]
+13 -24
View File
@@ -2483,16 +2483,17 @@ class TradingEngine:
bid = (self.data.snapshot() or {}).get("bid")
if not bid:
return {"res": [], "sup": []}
# Cluster-Toleranz 0,5×ATR: nahe Pivot-Zwillinge (z. B. 80,50 + 80,59)
# verschmelzen zu EINER Linie.
# Clusterung/Auswahl/Hysterese liegen seit 2026-08-07 in
# `core/sr_levels.py` — die Backtests bauten sie vorher NACH und
# wichen dabei ab (gemischter Rohpool statt ph/pl getrennt; 4,5 %
# der Zeitpunkte hatten einen anderen Widerstand). Verhalten hier
# unverändert, bitgenau geprüft in `tests/test_sr_levels.py`.
from core.sr_levels import cluster as _cluster_lv
from core.sr_levels import halten as _halten_lv
from core.sr_levels import naechste as _naechste_lv
def cluster(vals):
out = []
for v in sorted(vals):
if out and v - out[-1][-1] <= 0.5 * atr:
out[-1].append(v)
else:
out.append([v])
return [round(sum(g) / len(g), 3) for g in out]
return _cluster_lv(vals, atr)
# Fix 2026-07-23 (User-Frage „Widerstand/Support-Retest von der falschen
# Seite"): Widerstand/Support werden NACH AKTUELLER KURSPOSITION gewählt
# (über/unter bid), NICHT mehr strikt nach Entstehungstyp (Swing-Hoch `ph`
@@ -2512,27 +2513,15 @@ class TradingEngine:
pl_c = cluster(lv.get("pl") or [])
both = ph_c + pl_c
# NUR die nächste je Seite (2 Linien total = wie im Dashboard)
r = min((p for p in both if p > bid), default=None)
s = max((p for p in both if p < bid), default=None)
r, s = _naechste_lv(both, bid)
# Hysterese „klebrig" (Fix 2026-07-21): die gezeichnete Linie bleibt
# stehen, solange der Kurs sie nicht WIRKLICH durchbricht (bid > R+0,1×ATR
# bzw. bid < S0,1×ATR). Ein neuer, näherer Pivot löst KEINEN Wechsel aus
# — genau das war die Zitter-Quelle (auf M5 bilden sich laufend neue Pivots).
# Ausnahme: ist die alte Linie weit weg (>1,5×ATR) UND ein deutlich näheres
# Level existiert, wird sie übernommen (sonst bleibt sie irrelevant hängen).
def hold(new, old, is_res):
if old is None:
return new
m = 0.10 * atr
broken = (bid > old + m) if is_res else (bid < old - m)
if broken:
return new # echter Durchbruch → nächste Linie (kann None sein)
if (new is not None and abs(old - bid) > 1.5 * atr
and abs(new - bid) < abs(old - bid) - 0.5 * atr):
return new # alte Linie veraltet/weit weg → näheres Level
return old # sonst: Linie HALTEN (kein Springen)
r = hold(r, self._draw_last.get("r"), True)
s = hold(s, self._draw_last.get("s"), False)
r = _halten_lv(r, self._draw_last.get("r"), bid, atr, True)
s = _halten_lv(s, self._draw_last.get("s"), bid, atr, False)
self._draw_last = {"r": r, "s": s}
out = {"res": [r] if r is not None else [],
"sup": [s] if s is not None else []}
+91
View File
@@ -0,0 +1,91 @@
"""S/R-Linien: Clusterung, Auswahl und Hysterese — die geteilte Mechanik.
⚠⚠ WARUM ES DIESES MODUL GIBT (2026-08-07). `engine._draw_levels` entscheidet
live, WELCHES Level das P(break)-Modell bewertet. Die Backtests bauten dieselbe
Entscheidung nach (`build_levels`/`_cluster`/`pick_level` in
`backtest_pbreak_retrain.py`) — und der Nachbau wich in drei Punkten ab:
1. ⚠⚠ **Er clusterte den GEMISCHTEN Rohpool.** `build_levels` warf Hochs und
Tiefs in EINE sortierte Liste, danach lief die Nachbar-Kette darüber. Genau
das ist im Live-Code ausdrücklich als behobener Fehler dokumentiert:
„ph/pl bleiben GETRENNT geclustert (nicht vorher zusammenlegen!) — ein
erster Versuch clusterte den kombinierten Rohpool direkt, was in dichten
Zonen zu Ketten-Mega-Clustern führte." Der Nachbau machte also das, was live
als falsch erkannt und repariert worden war.
**Gemessen: 4,5 % aller Zeitpunkte hatten dadurch einen anderen Widerstand**
(80k M5-Bars, 13.283 Vergleichspunkte; Ø-Linienzahl 8,1 statt 8,4, grösste
Abweichung 12,98×ATR).
2. Live rundet jede Cluster-Linie auf **3 Nachkommastellen**, der Nachbau nicht.
3. Live führt Widerstand UND Unterstützung mit **getrennter** Hysterese; der
Nachbau hielt nur EINE Linie je Trade-Richtung.
Dieselbe Fehlerklasse wie bei `core/squeeze_scan.py` (07.08.) und beim
P(break)-Drift (31.07.): **ein Nachbau ist kein Nutzer desselben Codes.**
Hier stehen die Parameter deshalb in der Signatur, und Live wie Backtest rufen
dieselben Funktionen auf.
⚠ Die Zahlen des Live-Pfads ändern sich dadurch NICHT — `engine._draw_levels`
liefert nachweislich dieselben Linien wie vorher (Gleichheitstest in
`tests/test_sr_levels.py`).
"""
from __future__ import annotations
TOLERANZ_STD = 0.5 # Cluster-Toleranz in ×ATR ("nahe Pivot-Zwillinge")
HYSTERESE_STD = 0.10 # echter Durchbruch erst ab ±X×ATR
WEIT_STD = 1.5 # alte Linie gilt ab X×ATR als "irrelevant weit"
NAEHER_STD = 0.5 # ... und wird nur ersetzt, wenn die neue X×ATR näher ist
def cluster(vals, atr: float, toleranz: float = TOLERANZ_STD) -> list:
"""Nahe Werte zu je EINER Linie verschmelzen (Nachbar-Kette).
⚠ Nur auf EINE Seite anwenden (Hochs ODER Tiefs). Über den gemischten Pool
laufen zu lassen, verdoppelt die Punktdichte und lässt die Kette durch dichte
Zonen "durchlaufen" — s. Modul-Docstring, Punkt 1.
"""
out: list[list[float]] = []
for v in sorted(vals):
if out and v - out[-1][-1] <= toleranz * atr:
out[-1].append(v)
else:
out.append([v])
return [round(sum(g) / len(g), 3) for g in out]
def linien(ph, pl, atr: float, toleranz: float = TOLERANZ_STD) -> list:
"""Fertige Linienliste: ph und pl GETRENNT clustern, dann zusammenführen."""
return cluster(ph or [], atr, toleranz) + cluster(pl or [], atr, toleranz)
def naechste(alle, px: float):
"""(Widerstand, Unterstützung) = nächste Linie über/unter dem Kurs.
⚠ Ausgewählt wird nach der POSITION zum Kurs, NICHT nach dem Entstehungstyp
(Swing-Hoch/-Tief). Sonst bleibt ein gebrochenes Level für immer im falschen
Topf: ein unterschrittenes Swing-Tief, von unten erneut angesteuert, tauchte
nie als Widerstand auf (Fix 2026-07-23).
"""
r = min((p for p in alle if p > px), default=None)
s = max((p for p in alle if p < px), default=None)
return r, s
def halten(neu, alt, px: float, atr: float, is_res: bool,
hysterese: float = HYSTERESE_STD, weit: float = WEIT_STD,
naeher: float = NAEHER_STD):
"""Hysterese: die Linie bleibt stehen, bis der Kurs sie WIRKLICH durchbricht.
Ein neuer, näherer Pivot löst KEINEN Wechsel aus — genau das war die
Zitter-Quelle (auf M5 bilden sich laufend neue Pivots). Ausnahme: die alte
Linie ist weit weg (>`weit`×ATR) UND eine deutlich nähere existiert.
"""
if alt is None:
return neu
m = hysterese * atr
gebrochen = (px > alt + m) if is_res else (px < alt - m)
if gebrochen:
return neu # echter Durchbruch → nächste Linie (darf None sein)
if (neu is not None and abs(alt - px) > weit * atr
and abs(neu - px) < abs(alt - px) - naeher * atr):
return neu # alte Linie veraltet/weit weg → näheres Level
return alt # sonst: HALTEN (kein Springen)
+114
View File
@@ -0,0 +1,114 @@
"""Beweist, dass `core/sr_levels.py` bitgenau das rechnet, was bis 2026-08-07
inline in `engine._draw_levels` stand — und dass der Backtest-Nachbau ABWICH.
⚠ `_draw_levels` ist LIVE-Code: er bestimmt die gezeichneten S/R-Linien, den
Dashboard-Wert UND das Level, das der P(break)-Auto-Close bewertet. Eine
Refaktorierung dort darf das Verhalten nicht anfassen.
"""
import random
from core.sr_levels import cluster, halten, linien, naechste
# ── eingefrorene Fassung, wörtlich aus `engine._draw_levels` (vor dem Umbau) ──
def _alt_cluster(vals, atr):
out = []
for v in sorted(vals):
if out and v - out[-1][-1] <= 0.5 * atr:
out[-1].append(v)
else:
out.append([v])
return [round(sum(g) / len(g), 3) for g in out]
def _alt_hold(new, old, bid, atr, is_res):
if old is None:
return new
m = 0.10 * atr
broken = (bid > old + m) if is_res else (bid < old - m)
if broken:
return new
if (new is not None and abs(old - bid) > 1.5 * atr
and abs(new - bid) < abs(old - bid) - 0.5 * atr):
return new
return old
# ── der ABWEICHENDE Backtest-Nachbau (gemischter Pool, ohne Rundung) ─────────
def _nachbau_cluster(levels, atr):
if not levels:
return []
out = []; grp = [levels[0]]
for x in levels[1:]:
if x - grp[-1] <= 0.5 * atr:
grp.append(x)
else:
out.append(sum(grp) / len(grp)); grp = [x]
out.append(sum(grp) / len(grp))
return out
def _pivots(seed, n=40):
rnd = random.Random(seed)
basis = 75.0 + rnd.uniform(-3, 3)
ph = sorted(round(basis + rnd.uniform(0.0, 3.0), 3) for _ in range(n))
pl = sorted(round(basis - rnd.uniform(0.0, 3.0), 3) for _ in range(n))
return ph, pl
def test_cluster_bitgenau():
for seed in range(300):
ph, pl = _pivots(seed)
atr = 0.12 + (seed % 30) * 0.01
assert cluster(ph, atr) == _alt_cluster(ph, atr), f"seed {seed}"
assert cluster(pl, atr) == _alt_cluster(pl, atr), f"seed {seed}"
def test_linien_und_auswahl_bitgenau():
"""Der ganze Live-Pfad: getrennt clustern → zusammenführen → nächste wählen."""
for seed in range(300):
ph, pl = _pivots(seed)
atr = 0.12 + (seed % 30) * 0.01
px = 75.0 + (seed % 11) * 0.2
alt = _alt_cluster(ph, atr) + _alt_cluster(pl, atr)
r_alt = min((p for p in alt if p > px), default=None)
s_alt = max((p for p in alt if p < px), default=None)
r_neu, s_neu = naechste(linien(ph, pl, atr), px)
assert (r_neu, s_neu) == (r_alt, s_alt), f"seed {seed}"
def test_hysterese_bitgenau():
rnd = random.Random(7)
faelle = 0
for seed in range(400):
atr = 0.1 + (seed % 25) * 0.02
px = 75.0 + rnd.uniform(-2, 2)
for alt_l in (None, px + rnd.uniform(-3, 3)):
neu = px + rnd.uniform(-3, 3)
for is_res in (True, False):
a = halten(neu, alt_l, px, atr, is_res)
b = _alt_hold(neu, alt_l, px, atr, is_res)
assert a == b, f"seed {seed}"
faelle += 1
assert faelle > 1000
def test_nachbau_wich_wirklich_ab():
"""⚠ Gegenprobe: der Backtest-Nachbau (gemischter Pool) liefert NICHT
dasselbe. Ohne diesen Test wäre nicht belegt, dass es überhaupt etwas zu
beheben gab — und ein Test, der nicht scheitern kann, ist wertlos."""
abweichungen = 0
for seed in range(300):
ph, pl = _pivots(seed)
atr = 0.12 + (seed % 30) * 0.01
live = sorted(linien(ph, pl, atr))
nachbau = sorted(_nachbau_cluster(sorted(ph + pl), atr))
if [round(x, 3) for x in nachbau] != live:
abweichungen += 1
assert abweichungen > 0, "Nachbau und Live sind gleich — dann gab es nichts zu beheben"
def test_cluster_leer():
assert cluster([], 0.2) == []
assert linien([], [], 0.2) == []
assert naechste([], 75.0) == (None, None)