"""Box-Ausbruchs-Scan — die geteilte Mechanik hinter dem Squeeze-Setup. ⚠⚠ WARUM ES DIESES MODUL GIBT (2026-08-07). `core/engine.py` und `weekly_review.py` importierten bis dahin **`backtest_breakout_squeeze`**, also LIVE-Code, der von einem Backtest-Skript abhing. Zwei Folgen: 1. Die Abhaengigkeitsrichtung war verkehrt. „Backtests sind nur Dokumentation" stimmte nicht — ein Fehler dort konnte den laufenden Bot treffen. 2. Schlimmer: die Aufrufer haben **Modul-Globale des Backtests mutiert** (`BQ._N = ...; BQ._K = ...`), um Live-Parameter einzuschleusen. Das wirkt prozessweit und damit auf jeden anderen Nutzer desselben Moduls im selben Prozess. Hier stehen die Parameter deshalb in der Signatur, nicht in Modul-Globalen. ⚠⚠ DER EXIT IST EIN PARAMETER, UND DAS IST ABSICHT. `exit_legacy` unten ist eine BITGENAUE Kopie des Exits aus `backtest_breakout_squeeze.sim` — inklusive seiner Abweichungen vom kanonischen Live-Exit (`core/exit_model.LIVE`): Trail 1,5 statt 1,0 · KEINE Lock-Phase · KEIN Time-Stop · KEIN TP · max_hold 288 statt 200 Damit bleiben die dokumentierten Zahlen des Backtests UND das Verhalten des B4-Monitors zunaechst unveraendert — der Umbau ist reine Struktur. ⚠ Dass der B4-Monitor damit gegen einen VERALTETEN Exit misst, ist ein separater, offener Befund (s. CLAUDE.md). Ihn hier stillschweigend mitzuaendern haette die Vergleichsgrundlage des Monitors verschoben, ohne dass es jemand merkt. """ from __future__ import annotations from typing import Callable # Vorgaben = der bisherige Stand aus `backtest_breakout_squeeze.py`, damit # Aufrufer ohne explizite Angabe exakt dasselbe bekommen wie vorher. BOX_N_STD = 12 # Box-Laenge in Bars FENSTER_STD = 24 # Fenster nach der Box, in dem der Ausbruch fallen muss COOLDOWN_STD = 12 # Vorsprung nach einem Trade (Ueberlappungsschutz) K_STD = 0.1 # Ausbruch k×ATR ueber/unter die Box-Grenze ATRMIN_STD = 0.12 MAXH_STD = 288 # Haltedauer-Obergrenze des Legacy-Exits def atr_series(H: list, L: list, C: list, p: int = 14) -> list: """Gleitender ATR — bitgenau wie `backtest_breakout_squeeze._atr_series`.""" tr = [0.0] for i in range(1, len(C)): tr.append(max(H[i] - L[i], abs(H[i] - C[i - 1]), abs(L[i] - C[i - 1]))) out = [None] for i in range(1, len(C)): seg = tr[max(1, i - p + 1):i + 1] out.append(sum(seg) / len(seg)) return out def exit_legacy(entry, d, atr, H, L, C, j0, *, sl_atr=2.0, trail=1.5, trail_on=0.3, be_on=1.3, max_hold=MAXH_STD) -> float: """⚠ BITGENAUE Kopie von `backtest_breakout_squeeze.sim` — bewusst inklusive ihrer Abweichungen vom kanonischen Exit (s. Modul-Docstring). Nicht fuer neuen Code verwenden; dort gehoert `core.exit_model.simulate` hin.""" eff = entry - d * sl_atr * atr hw = entry end = min(j0 + max_hold, len(C) - 1) exit_px = C[end] for j in range(j0, end + 1): hi, lo = H[j], L[j] if (lo <= eff) if d > 0 else (hi >= eff): exit_px = eff break hw = max(hw, hi) if d > 0 else min(hw, lo) prof = (C[j] - entry) * d if prof >= trail_on * atr: cand = hw - d * trail * atr if prof >= be_on * atr: cand = max(cand, entry) if d > 0 else min(cand, entry) eff = max(eff, cand) if d > 0 else min(eff, cand) return (exit_px - entry) * d / atr def scan(H, L, C, A, SP, point, lo_i, hi_i, squeeze_mult, *, box_n: int = BOX_N_STD, k: float = K_STD, atr_min: float = ATRMIN_STD, fenster: int = FENSTER_STD, cooldown: int = COOLDOWN_STD, max_hold: int = MAXH_STD, exit_fn: Callable | None = None, on_entry: Callable | None = None) -> tuple[list, list]: """Ein Durchlauf: sammelt R (netto) je Ausbruch. Rueckgabe `(Rs_sq, Rs_any)` — `Rs_any` = ALLE Box-Ausbrueche (die Kontrolle), `Rs_sq` = nur die aus einer KOMPRESSION (Box ≤ `squeeze_mult`×ATR). Der Vergleich der beiden ist es, der den Squeeze ueberhaupt validiert hat. `i` springt nach jedem Ausbruch um `cooldown` vor — fuer beide Listen gleich. ⚠ `point` wird nicht mehr benutzt (SP kommt bereits in Preis), bleibt aber in der Signatur, damit bestehende Aufrufe unveraendert funktionieren. ⚠ `on_entry(j, d, lvl, atr, komprimiert)` ist ADDITIV (2026-08-08): ein Rueckruf je Ausbruch, der auch sagt, ob er aus einer KOMPRESSION kam. Ohne ihn ist `exit_fn` der einzige Haken — und der wird bei JEDEM Box-Ausbruch gerufen, nicht nur bei den komprimierten. Wer darueber eine Entry-Liste sammelt, bekommt still die Kontrollgruppe statt des Squeeze (real passiert: 519 statt 132 Einstiege). Verhalten ohne `on_entry` unveraendert. """ ex = exit_fn or (lambda e, d, a, h, l, c, j: exit_legacy( e, d, a, h, l, c, j, max_hold=max_hold)) def kosten(i, atr): return (SP[i] if SP[i] > 0 else 0.0225) / atr Rs_sq: list = [] Rs_any: list = [] i = max(lo_i, box_n + 15) while i < min(hi_i, len(C) - max_hold - 1): atr = A[i] if not atr or atr < atr_min: i += 1 continue box_hi = max(H[i - box_n:i]) box_lo = min(L[i - box_n:i]) komprimiert = (box_hi - box_lo) <= squeeze_mult * atr treffer = None for j in range(i, min(i + fenster, len(C) - max_hold - 1)): oben = box_hi + k * atr unten = box_lo - k * atr if H[j] >= oben: treffer = (j, 1, oben) break if L[j] <= unten: treffer = (j, -1, unten) break if treffer is None: i += 1 continue j, d, lvl = treffer if on_entry is not None: on_entry(j, d, lvl, atr, komprimiert) R = ex(lvl, d, atr, H, L, C, j + 1) - kosten(j, atr) Rs_any.append(R) if komprimiert: Rs_sq.append(R) i = j + cooldown return Rs_sq, Rs_any