User-Wunsch nach einem TradingView-Bild: Resistance Zone und eine untere Zone
als farbige Baender. Neu RZ;lo;hi und SZ;lo;hi, im Indikator als OBJ_RECTANGLE
mit Beschriftung, nach rechts verlaengert.
DIE BREITE IST ECHT: sie ist die Spanne der M30-Pivots, die den Cluster gebildet
haben (sr_levels.zonen(), additiv -- cluster() bleibt unveraendert, weil es den
S/R-Auto-Close speist). Live verifiziert: R;82.786 liegt in RZ;82.776;82.797,
S;82.15 in SZ;82.14;82.16. Einzel-Pivot bekommt 0,10xATR Mindestbreite.
ZWEI ABWEICHUNGEN VOM VORBILD, beide begruendet:
(1) KEIN Fibonacci. Das Bild leitet seine Zone aus 0,618/0,658 ab. Fib ist hier
gemessen und verworfen (04.08.): 54,3/54,5 % Haltequote gegen 55,7/53,8 %
einer Zufallslinie gleicher Distanz, und schlechter als M30-Pivots.
(2) Die untere Zone heisst SUPPORT, nicht "Cumulative Long Liquidation". Echte
Liquidationsdaten hat das Projekt nicht; die HL-Waende sind am 17.08. als
reaktiv gemessen. Bricht die Support-Zone, liegen dort die Long-Stops --
dieselbe Stelle, ehrlich benannt.
Abschaltbar InpShowZonen. Kompiliert ins richtige Terminal (0/0), deployt,
verifiziert.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
113 lines
5.2 KiB
Python
113 lines
5.2 KiB
Python
"""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]
|