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
+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)