"""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) def zonen(vals, atr: float, toleranz: float = TOLERANZ_STD) -> list: """Wie `cluster`, gibt aber je Cluster (mittel, lo, hi) statt nur den Mittelwert. ⚠⚠ ADDITIV — `cluster()` bleibt unveraendert. Es speist `_draw_levels` und damit den S/R-Auto-Close; jede Aenderung dort waere ein Strategie-Eingriff. Hier geht es nur um die ANZEIGE: eine Zone hat eine Breite, eine Linie nicht. Die Breite ist NICHT erfunden — sie ist die Spanne der Pivots, die den Cluster gebildet haben. Ein Cluster aus einem einzigen Pivot hat folglich die Breite 0; der Aufrufer entscheidet, ob er ihm eine Mindestbreite gibt. """ 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), round(min(g), 3), round(max(g), 3)) for g in out]