From aefac274d302efd50cf78f914acb8e9176caaec1 Mon Sep 17 00:00:00 2001 From: Axel Hocks Date: Fri, 7 Aug 2026 11:30:07 +0200 Subject: [PATCH] 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 --- CLAUDE.md | 60 +++++++++++++++++++ backtest_pbreak_retrain.py | 51 ++++++++++++++++- core/engine.py | 37 +++++------- core/sr_levels.py | 91 +++++++++++++++++++++++++++++ tests/test_sr_levels.py | 114 +++++++++++++++++++++++++++++++++++++ 5 files changed, 328 insertions(+), 25 deletions(-) create mode 100644 core/sr_levels.py create mode 100644 tests/test_sr_levels.py diff --git a/CLAUDE.md b/CLAUDE.md index f5afac0..d3c2897 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 300–400 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,0–39,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 diff --git a/backtest_pbreak_retrain.py b/backtest_pbreak_retrain.py index 8b77f0d..6755e2b 100644 --- a/backtest_pbreak_retrain.py +++ b/backtest_pbreak_retrain.py @@ -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]] diff --git a/core/engine.py b/core/engine.py index 6dce6c7..f282b78 100644 --- a/core/engine.py +++ b/core/engine.py @@ -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 < S−0,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 []} diff --git a/core/sr_levels.py b/core/sr_levels.py new file mode 100644 index 0000000..0bf5ef0 --- /dev/null +++ b/core/sr_levels.py @@ -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) diff --git a/tests/test_sr_levels.py b/tests/test_sr_levels.py new file mode 100644 index 0000000..2b902fc --- /dev/null +++ b/tests/test_sr_levels.py @@ -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)