Files
Axel HocksandClaude Opus 5 ca61cf1498 S/R als ZONE statt Linie (RZ/SZ, Indikator v1.37)
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>
2026-08-17 18:27:26 +02:00

113 lines
5.2 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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]