"""
core/wave_rec.py — Trend-Empfehlungsmodul (EMA-Trend)
======================================================
Signalrichtung folgt dem geglätteten Trend (EMA12 vs EMA50) der gewählten
Zeitebene — NICHT mehr dem verrauschten ZigZag-Swing. Grund: Backtests zeigten,
dass das alte Momentum-ZigZag ~0 Edge hatte (kaufte die Spitzen von Bounces),
während EMA-Trendfolge einen klaren, positiven Edge liefert.

Signal:
  EMA12 > EMA50  → LONG   |  EMA12 < EMA50 → SHORT
  |EMA12−EMA50| < _TREND_DEADBAND×ATR        → WARTEN (kein klarer Trend / Chop)
  Kurs > _STRETCH_MAX×ATR über/unter EMA50   → WARTEN (überdehnt, kein Spät-Einstieg)

Konfidenz: 60 % Basis, +Trendstärke (EMA-Abstand in ATR), +Einstiegsqualität
           (nahe der EMA = besseres CRV, kein Hinterherkaufen),
           +Traders-Union-Bestätigung (5m/15m); stark gegenläufige TU blockt.

Arbeitsteilung (Thread-sicher):
  set_timeframe(tf)    → vom KI-Agenten gewählt (M1/M5/M15/M30/H1)
  refresh_market(sym)  → alle ~5 s im Hintergrund (MT5-Call: Bars + EMAs)
  signal()             → aktueller, fertig gefilterter rec-Dict (kein MT5)
  snapshot()           → Trend-Status für die Anzeige (EMA als wave_start,
                         Abstand zur EMA in ATR als move_atr)
"""

from __future__ import annotations
import threading
import time
from datetime import datetime
from zoneinfo import ZoneInfo

import MetaTrader5 as mt5

from core.mt5_utils import mt5_lock
from core.market_hours import session_state
from core.analysis import calc_trend_angle
from core.logger import get_logger

log = get_logger("wave")

_N_BARS           = 180
_ATR_PERIOD       = 14
_STALE_S          = 150
_BASE_CONF        = 60
_MIN_CONF         = 55     # Signale < 55 % Konfidenz → WARTEN. Gemessen
                           # (backtest_conf.py): Band 40–54 % = NEGATIVER Edge
                           # (−0,025), ≥55 % = +0,058. Gate hebt Edge + Ertrag.

# Signalrichtung = geglätteter EMA-Trend (per Backtest mit Edge belegt; das alte
# ZigZag-Momentum hatte ~0 Edge / kaufte Spitzen). Totband filtert Chop, die
# Anti-Überdehnung verhindert Spät-Einstiege weit weg von der EMA.
_EMA_FAST         = 12
_EMA_SLOW         = 50
_TREND_DEADBAND   = 0.15   # |EMA12 − EMA50| < x×ATR → kein klarer Trend → WARTEN
_STRETCH_MAX      = 3.5    # Kurs > x×ATR über/unter EMA50 → überdehnt → kein Einstieg.
                           # 2,5→3,5 (backtest_stretch.py): +26 % Signale (45→57 % der
                           # Zeit), Bänder 2,5–3,5 noch positiv, Gesamtertrag steigt.
                           # NICHT höher: ab 3,5×ATR kippt der Edge klar negativ (−0,13).
_REVERSAL_STRETCH = 3.0    # Bounce/Reversal-Trigger — ENTKOPPELT von _STRETCH_MAX:
                           # ab |stretch|≥3,0×ATR + Winkeldrehung antizyklischer
                           # Einstieg (mehr Bounces als bei 3,5). Gemessen
                           # (backtest_bounce.py, Exit-Sim): Ø-R +0,185, PF 1,35,
                           # Treffer 70 %, ~1,5× mehr Bounces als 3,5. Anti-Über-
                           # dehnung (Trend-Spät-Sperre) bleibt bei _STRETCH_MAX=3,5.

# Volatilitäts-Squeeze-Breakout (additiver Setup, gemessen `backtest_breakout_squeeze.py`,
# 2-Stichproben-positiv & robust über Box 10–20 / k 0,1–0,2): Box = Spanne der letzten
# _SQ_N abgeschlossenen M5-Bars; ist sie ≤ _SQ_MULT×ATR (Kompression) und bricht der
# Kurs _SQ_K×ATR über/unter die Box → Ausbruchssignal. Live-Params = die getesteten.
_SQ_N = 10          # Box-Länge (M5-Bars)
_SQ_MULT = 2.5      # Squeeze: Box ≤ _SQ_MULT×ATR = Kompression
_SQ_K = 0.1         # Ausbruch k×ATR über/unter die Box-Grenze
_SQ_REFRESH_S = 5.0 # eigener, schneller M5-Fetch NUR für den Squeeze (entkoppelt von
                    # der 15-s-Ampel-Drossel) → Ausbruch-Erkennung ~15 s→5 s, gleiche
                    # validierte M5-Regel (Variante A, 2026-07-21).

# Higher-TF-Gegen-Trend-Filter: Signal NUR, wenn der übergeordnete Trend (M30,
# EMA12 vs EMA50) nicht klar dagegen steht. Per Backtest belegt: verdoppelt den
# Ø-Edge pro Trade (+0,012 → +0,027), Treffer 52→54 %; wirft Gegen-Trend-Signale
# raus (z. B. Short im M30-Aufwärtstrend). Nur für Basis-TF < M30 aktiv.
_HTF              = mt5.TIMEFRAME_M30
_HTF_LABEL        = "M30"
_EQ_TOL_ATR       = 0.15   # „gleich" für Equal Highs/Lows: beide Pivots innerhalb x×ATR
_EQ_MIN_H_ATR     = 0.10   # Mindest-Bandhöhe der Liquiditätszone (sonst unsichtbar dünn)
_HTF_DEADBAND     = 0.15   # |EMA12−EMA50| < x×ATR(M30) → HTF gilt als neutral

# Multi-TF-Konfluenz (nur Konfidenz/Anzeige): wenn M30 UND H1 die Richtung
# bestätigen, ver-3,5-facht sich der Ø-Edge (+0,016→+0,056, backtest_improve.py).
_CONFLUENCE_BONUS = 12     # M30 + H1 beide dafür → Top-Setup (⭐⭐)
_H1_AGAINST_PEN   = 8      # H1 steht gegen die Richtung (M30 ok/neutral) → schwächer

# Regressions-Winkel der Basis-TF als schnellerer Wende-Detektor gegen die
# nachlaufende EMA (backtest_angle.py: Winkel dafür/neutral +0,055 vs dagegen
# +0,025). NUR Konfidenz/Warnung — als hartes Gate senkt es den Gesamtertrag.
_ANGLE_LR         = 14     # Regressionsfenster (= ANGLE_LR_BARS)
_ANGLE_DEAD       = 2.0    # Totband um 90° (Grad) → darunter „neutral"
# ⚠ Namen historisch, Bedeutung 2026-08-04 richtiggestellt (Zahlen unverändert):
_ANGLE_BONUS      = 5      # Kurzfrist-Winkel läuft GEGEN das Signal = PULLBACK → besser
_ANGLE_PENALTY    = 10     # Kurzfrist-Winkel läuft schon MIT = kein Pullback → schlechter

# Tageszeit-Gate — REAKTIVIERT 2026-07-13 (User-OK) nach Echtkosten-Messung
# (`backtest_realcosts.py`, Kosten = Bar-Spread/ATR statt pauschal 0,1):
#   0–7 Uhr = Nacht-Kostenfalle (konstanter Spread ÷ niedriger Nacht-ATR =
#   0,32–0,50×ATR Kosten → Edge sicher aufgefressen; in BEIDEN Hälften negativ).
#   12 & 16 Uhr = 3× unabhängig negativ gemessen (hourly, hourly_split, realcosts).
# Gate-Politik „0–7+12" verbesserte BEIDE Hälften (H1 −4038→−1870, H2 +100→+1191).
# Robust positiv sind nur 21–22 Uhr (US-Session). (Zwischenzeitlich war das Gate
# auf User-Wunsch ganz aus; Rückholung gezielt & gemessen, kein Pauschal-Gate.)
_DEAD_HOURS       = (0, 1, 2, 3, 4, 5, 6, 7, 12, 16)
_BERLIN           = ZoneInfo("Europe/Berlin")
# Hohe Vola (oberes ATR-Terzil, ~>0,27 bei WTI-M5) = schwächster/negativer Edge
# → nur Konfidenz-Abzug (Schwelle regime-abhängig, daher KEIN hartes Gate).
_ATR_HIGH         = 0.27
_ATR_HIGH_PEN     = 8

# S/R-Kontext (vom Engine gesetzt): Konfidenz dämpfen, wenn der Einstieg direkt
# in ein Gegen-Level läuft (wenig Raum / schlechtes CRV), anheben bei Rückenwind.
_SR_NEAR_ATR      = 0.5    # "dicht an" einer Linie = innerhalb x×ATR
_SR_PENALTY       = 12     # Konfidenz-Abzug: Trade läuft ins Level (wenig Raum)
_SR_BONUS         = 8      # Konfidenz-Bonus: Trade startet vom Level mit Rückenwind

# Börsen-Sessions (Frankfurt 9:00 / US 15:00): Vorsicht direkt nach Open,
# Bonus während aktiver Session, Dämpfung in dünnen Zeiten.
_SESSION_CAUTION  = 15     # Abzug in den ersten Minuten nach einem Open (Whipsaw)
_SESSION_BONUS    = 5      # Bonus während aktiver US/DE-Session (Liquidität)
_SESSION_OFFHOURS = 8      # Abzug außerhalb DE/US-Session (dünne Liquidität)

# TradersUnion ist KEIN harter Blocker mehr (blockte zu oft starke EMA-Trends),
# sondern ein Konfidenz-Faktor je TF (5m/15m): bestätigt +Bonus, dagegen −Abzug.
_TU_BONUS         = 6      # je TF, das die Richtung bestätigt
_TU_PENALTY       = 8      # je TF, das gegen die Richtung steht (beide = −16)

_TF_LABELS = {
    mt5.TIMEFRAME_M1:  "M1",
    mt5.TIMEFRAME_M5:  "M5",
    mt5.TIMEFRAME_M15: "M15",
    mt5.TIMEFRAME_M30: "M30",
    mt5.TIMEFRAME_H1:  "H1",
}


def _atr(highs, lows, closes, period=_ATR_PERIOD):
    trs = []
    for i in range(1, len(highs)):
        trs.append(max(highs[i] - lows[i],
                       abs(highs[i] - closes[i - 1]),
                       abs(lows[i] - closes[i - 1])))
    if not trs:
        return None
    return sum(trs[-period:]) / min(len(trs), period)


def _ema_last(vals, period):
    """Letzter EMA-Wert der Reihe (genügt für den Trend-Vergleich)."""
    if not vals:
        return None
    k = 2.0 / (period + 1)
    e = vals[0]
    for v in vals[1:]:
        e = v * k + e * (1.0 - k)
    return e


def _ema_series(vals, period):
    """Komplette EMA-Reihe (für die Kreuzungs-/Wende-Erkennung je TF)."""
    k = 2.0 / (period + 1)
    out = []
    e = vals[0] if vals else 0.0
    for i, v in enumerate(vals):
        e = v if i == 0 else v * k + e * (1.0 - k)
        out.append(e)
    return out


class WaveRecommender:
    """ATR-ZigZag-Wellen-Empfehlung, bestätigt durch Traders-Union-Daten."""

    def __init__(self, tu_provider, timeframe: int = mt5.TIMEFRAME_M5):
        self.tu = tu_provider
        self._lock = threading.Lock()
        self._tf = timeframe
        self._rec: dict | None = None
        self._snap: dict = {}
        self._ts: float = 0.0
        self._error: str | None = None
        self._sr_res: float | None = None   # nächster Widerstand über Preis
        self._sr_sup: float | None = None   # nächste Unterstützung unter Preis
        self._turn_due: float = 0.0         # nächster TF-Ampel-Refresh (Throttle)
        self._sq_due: float = 0.0           # nächster schneller M5-Squeeze-Fetch (~5 s)
        self._dead_hours: set = set(_DEAD_HOURS)   # Tageszeit-Gate (per Config setzbar)
        self._last_tf_turns: dict = {}      # zuletzt berechnete TF-Wenden
        self._last_bounce: dict = {"state": None, "dir": None, "tf": None}  # Multi-TF-Bounce
        # ATR-Breakout-Bestätigung: ein Richtungssignal wird erst durchgelassen,
        # wenn der Kurs k×ATR in Signalrichtung gelaufen ist (gemessen ~2× Edge,
        # `backtest_breakout.py`). 0 = aus. `_pend` = laufende Bestätigung.
        self._breakout_k = 1.0
        self._breakout_timeout_s = 3600.0
        self._pend: dict | None = None
        self._squeeze: dict | None = None   # Volatilitäts-Squeeze-Breakout (M5)
        # Entry-Raum-Gate (gemessen `backtest_entryroom.py`, monoton in BEIDEN
        # Hälften): kein Entry, wenn das Gegenlevel < X×ATR entfernt ist — der
        # Ertrag ist durch den S/R-Auto-Close gedeckelt, Kosten fressen den Rest.
        # 0 = aus. Nur Live-Pfad (refresh_market); Backtests via _build bleiben frei.
        self._entry_room_atr = 0.0
        # Reversal/Bounce-Setup an/aus. ⚠ Modul-Default bewusst TRUE: sämtliche
        # Backtests rufen dasselbe `_build` — ein False-Default würde alle
        # bestehenden Messungen still verändern. Abgeschaltet wird über die
        # Config (`[trading] reversal_enabled`, s. `engine`), nicht hier.
        self._reversal_enabled = True

    def set_reversal_enabled(self, on: bool):
        """Antizyklisches Reversal-Setup (WAVE_REV_*) an/aus.

        ⚠ GEMESSEN NEGATIV (`backtest_reversal_angle.py`, 2026-08-04, 80k M5,
        kanonischer Exit, Echtkosten, 2 Halbjahre): ØR −0,122/−0,031, PF 0,79/0,94.
        Auch die vorzeichenkorrigierte Variante (−0,173/−0,061) und die Kontrolle
        ohne Winkelbedingung (−0,099/−0,023) sind beidhälftig negativ. Dazu hebt
        das Setup ZWEI gemessen positive Schutzmechanismen aus (Anti-Überdehnung
        und den M30-Gegen-Trend-Filter, Edge ×2). Deshalb live abgeschaltet.
        `true` stellt das alte Verhalten wieder her.
        """
        with self._lock:
            self._reversal_enabled = bool(on)
        log.info(f"Reversal-Setup {'AN' if on else 'AUS'}"
                 + ("" if on else " (gemessen negativ, backtest_reversal_angle.py)"))

    def set_entry_room(self, x: float):
        with self._lock:
            self._entry_room_atr = max(0.0, float(x))
        log.info(f"Entry-Raum-Gate {self._entry_room_atr:.2f}×ATR "
                 f"({'aus' if self._entry_room_atr <= 0 else 'aktiv'})")

    def set_breakout_k(self, k: float):
        with self._lock:
            self._breakout_k = max(0.0, float(k))
            self._pend = None
        log.info(f"Breakout-Bestätigung k={self._breakout_k:.2f}×ATR "
                 f"({'aus' if self._breakout_k <= 0 else 'aktiv'})")

    def set_dead_hours(self, hours):
        """Tageszeit-Gate setzen (Set/Liste von Stunden 0–23). Leer/None = AUS.
        Default (Code) = gemessene Negativ-Stunden; per `[trading] dead_hours` steuerbar."""
        try:
            hs = {int(h) for h in (hours or []) if 0 <= int(h) <= 23}
        except (TypeError, ValueError):
            hs = set(_DEAD_HOURS)
        with self._lock:
            self._dead_hours = hs
        log.info(f"Tageszeit-Gate: {sorted(hs) if hs else 'AUS'}")

    def set_sr_context(self, resistance, support):
        """Vom Engine: nächste S/R-Linien über/unter dem aktuellen Preis."""
        with self._lock:
            self._sr_res = resistance
            self._sr_sup = support

    def set_timeframe(self, tf: int):
        with self._lock:
            self._tf = tf
            self._rec = None       # alte Welle verwerfen — TF gewechselt
            self._ts = 0.0
            # ⚠ `_pend` wird BEWUSST NICHT MEHR zurückgesetzt (Fix 2026-07-31).
            # Vorher: `self._pend = None`. Real beobachtet an der Rally vom 31.07.
            # (82,5 → 84,4 in 2 h): die TF-Heuristik wechselte 33× am Tag (Median-
            # Abstand 5 min), weil der Score einer TF hart auf 0,0 fällt, sobald sie
            # überdehnt ist (`_choose_tf`: `raw = sep if … stretch <= _STRETCH_MAX
            # else 0.0`) — genau das passiert M5 IM Trend. Jeder Wechsel verwarf die
            # laufende Breakout-Bestätigung und verankerte sie beim AKTUELLEN Kurs neu.
            # Bei k=0,3 und ATR_M30≈0,53 braucht sie ~0,16 $; der Kurs lief ~0,08 $
            # je 5 min → die Bestätigung konnte RECHNERISCH nie fertig werden. Folge:
            # live 93 % WARTEN gegen ~43 % Backtest-Erwartung (`backtest_dist.py`).
            # Der Anker gehört zum SIGNAL, nicht zur Zeitebene; dreht die Richtung,
            # verankert `_confirm_breakout` ohnehin neu (`p.get("dir") != d`).
            # ⚠ Das Level wurde mit dem ATR der alten TF gesetzt — bewusst behalten:
            # eine Neuberechnung wäre wieder ein Reset und damit derselbe Fehler.
        log.info(f"Wellen-TF: {_TF_LABELS.get(tf, str(tf))}")

    # ── TU als Konfidenz-Faktor (kein harter Blocker mehr) ───────────────────
    def _tu_check(self, direction: str) -> tuple[bool, int, list[str]]:
        """Rückgabe (immer True, kein Block; Bonus/Abzug; Gründe). TU bestätigt
        die EMA-Richtung → Bonus, steht dagegen → Abzug — pro TF (5m/15m)."""
        snap = self.tu.snapshot()
        iv   = snap.get("intervals") or {}
        s5   = iv.get("5m",  {}).get("score")
        s15  = iv.get("15m", {}).get("score")
        if s5 is None or s15 is None:
            return True, 0, ["TU: keine Daten"]
        d = 1 if direction == "LONG" else -1
        bonus, reasons = 0, []
        for lbl, s in (("5m", s5), ("15m", s15)):
            if s * d >= 1:
                bonus += _TU_BONUS;   reasons.append(f"TU {lbl} dafür")
            elif s * d <= -1:
                bonus -= _TU_PENALTY; reasons.append(f"TU {lbl} dagegen")
        if not reasons:
            reasons.append("TU neutral")
        return True, bonus, reasons

    # ── Marktdaten-Refresh (Hintergrund-Thread, ~5 s) ────────────────────────
    def refresh_market(self, sym: str):
        with self._lock:
            tf = self._tf
        with mt5_lock(timeout=2) as got:
            if not got:
                return
            bars = mt5.copy_rates_from_pos(sym, tf, 0, _N_BARS)
            # Higher-TF-Trend (M30) für den Gegen-Trend-Filter — nur für
            # niedrigere Basis-TFs (M30/H1 filtern sich sonst selbst).
            hbars = None
            if tf not in (_HTF, mt5.TIMEFRAME_H1):
                hbars = mt5.copy_rates_from_pos(sym, _HTF, 0, _N_BARS)
            # H1-Trend zusätzlich für die Multi-TF-Konfluenz (nur Konfidenz).
            h1bars = None
            if tf != mt5.TIMEFRAME_H1:
                h1bars = mt5.copy_rates_from_pos(sym, mt5.TIMEFRAME_H1, 0, _N_BARS)
            # Wende-Erkennung je TF (M1/M5/M15/M30) für die Welle-Ampel:
            # EMA12/50-Kreuzung mit Totband. Gedrosselt (~15 s) — die Ampel
            # braucht keine 5-s-Granularität, spart ~⅓ der Lock-Haltezeit.
            turn_data: dict = {}
            if time.time() >= self._turn_due:
                for _lbl, _t in (("M1", mt5.TIMEFRAME_M1), ("M5", mt5.TIMEFRAME_M5),
                                 ("M15", mt5.TIMEFRAME_M15), ("M30", mt5.TIMEFRAME_M30),
                                 ("H1", mt5.TIMEFRAME_H1)):
                    # M5 tiefer holen (320): daraus werden die P(break)-Pivot-Level
                    # berechnet (Training: rohe M5-Pivots k=3, Lookback 300 Bars).
                    _n = 320 if _lbl == "M5" else 120
                    _tb = mt5.copy_rates_from_pos(sym, _t, 0, _n)
                    if _tb is not None and len(_tb) > _ATR_PERIOD:
                        _c = [float(b["close"]) for b in _tb]
                        _h = [float(b["high"])  for b in _tb]
                        _l = [float(b["low"])   for b in _tb]
                        turn_data[_lbl] = (_c, _atr(_h, _l, _c))
                        if _lbl == "M5":
                            self._m5_hl = (_h, _l)
                        elif _lbl == "M30":
                            # Bar-ZEITEN mit ablegen — die Liquiditätszonen (Equal
                            # Highs/Lows) brauchen den Zeitpunkt der beiden Pivots,
                            # um im Chart als Band gezeichnet werden zu können.
                            self._m30_t = [int(b["time"]) for b in _tb]
                            # M30-H/L für die S/R-LEVEL (Chart + P(break)-Auto-Close).
                            # Gemessen 2026-07-30 (`backtest_level_tf.py`): M30-Level
                            # schlagen M5 in BEIDEN Hälften (R-Ertrag ~2×, Level 1,95
                            # statt 0,61×ATR entfernt). Das M5-`_pb_levels` bleibt
                            # daneben bestehen — es speist das Entry-Raum-Gate, das
                            # auf M5 kalibriert ist (nicht ungemessen mitverändern!).
                            self._m30_hl = (_h, _l)
                self._turn_due = time.time() + 15
            # ── Schneller M5-Squeeze-Fetch (Variante A): entkoppelt von der 15-s-
            # Ampel, kleiner 60-Bar-M5-Fetch ~alle 5 s → Ausbrüche werden ~3× schneller
            # erkannt (gleiche validierte M5-Regel). Squeeze wird danach neu berechnet.
            sq_m5 = None
            if time.time() >= self._sq_due:
                _sb = mt5.copy_rates_from_pos(sym, mt5.TIMEFRAME_M5, 0, 60)
                if _sb is not None and len(_sb) > _ATR_PERIOD:
                    sq_m5 = ([float(b["high"]) for b in _sb],
                             [float(b["low"])  for b in _sb],
                             [float(b["close"]) for b in _sb])
                self._sq_due = time.time() + _SQ_REFRESH_S
        if bars is None or len(bars) < _ATR_PERIOD + 5:
            with self._lock:
                self._error = "keine Bars"
            return

        highs  = [float(b["high"])  for b in bars]
        lows   = [float(b["low"])   for b in bars]
        closes = [float(b["close"]) for b in bars]
        atr = _atr(highs, lows, closes)
        if not atr or atr <= 0:
            with self._lock:
                self._error = "ATR=0"
            return

        cur = closes[-1]
        tf_lbl = _TF_LABELS.get(tf, str(tf))
        ef = _ema_last(closes, _EMA_FAST)
        es = _ema_last(closes, _EMA_SLOW)
        with self._lock:
            sr_res, sr_sup = self._sr_res, self._sr_sup
        htf_trend = self._htf_sign(hbars)
        h1_trend  = self._htf_sign(h1bars)
        angle = calc_trend_angle(closes[-(_ANGLE_LR + 2):], _ANGLE_LR)
        hour = datetime.now(_BERLIN).hour

        rec, snap = self._build(ef, es, cur, atr, tf_lbl, 0,
                                sr_res, sr_sup, htf_trend=htf_trend,
                                h1_trend=h1_trend, angle=angle, hour=hour)
        # Die tatsächlich genutzten Higher-TF-Signale für die Gesamtempfehlung
        # exponieren (konsistent mit der Empfehlung, nicht mit dem Winkel-Maß).
        snap["htf_trend"] = htf_trend
        snap["h1_trend"]  = h1_trend
        # Squeeze aus dem schnellen (~5 s) M5-Fetch neu berechnen — entkoppelt von der
        # 15-s-Ampel (Variante A). Gleiche validierte M5-Regel, nur öfter geprüft.
        if sq_m5:
            _h5s, _l5s, _c5s = sq_m5
            _a5s = _atr(_h5s, _l5s, _c5s)
            if _a5s and _a5s > 0:
                self._squeeze = self._squeeze_one(_h5s, _l5s, _c5s, max(_a5s, 0.12))
        if turn_data:    # frisch geholt → neu berechnen, sonst letzten Stand halten
            self._last_tf_turns = {lbl: self._turn_state(c, a)
                                   for lbl, (c, a) in turn_data.items()}
            # ⚠ ADDITIV (2026-08-08): den ATR je Zeitebene MITNEHMEN. Er wird im
            # Turn-Fetch ohnehin berechnet (`turn_data[lbl] = (closes, atr)`),
            # war aber nirgends abrufbar — die M15-Setup-Karte braucht ihn fuer
            # das Kosten-Gate (Spread/ATR_M15) und die Abstandsangaben. Ihn dort
            # neu zu holen waere ein zweiter MT5-Fetch fuer Zahlen, die schon da
            # sind (derselbe Fehler wie beim Konsens-Pfeil, der ein zweites
            # `_verdict()` rechnete).
            self._tf_atr = {lbl: a for lbl, (c, a) in turn_data.items() if a}
            # P(break)-Features IMMER aus M5 (Modell wurde auf M5 trainiert/kalibriert
            # — die Wave-TF wechselt per Heuristik bis M30; damit wäre mom6 ein 3-h-
            # statt 30-min-Momentum und der ATR 2–3× größer → P(break) völlig falsch
            # skaliert. Bug gefixt 2026-07-13.) ATR-Floor 0,12 wie im Training.
            if "M5" in turn_data:
                _c5, _a5 = turn_data["M5"]
                if _a5 and _a5 > 0 and len(_c5) >= _EMA_SLOW + 2:
                    _a5f = max(_a5, 0.12)
                    self._pb_feats = {
                        "mom6": round((_c5[-1] - _c5[-7]) / _a5f, 3),
                        "mom3": round((_c5[-1] - _c5[-4]) / _a5f, 3),
                        "ema_diff": round((_ema_last(_c5, _EMA_FAST)
                                           - _ema_last(_c5, _EMA_SLOW)) / _a5f, 3),
                        "atr": round(_a5f, 4),
                    }
                    # Pivot-Level trainingsgleich: rohe M5-Pivots (k=3) über die
                    # letzten ~300 Bars — NICHT die geclusterten M15-Level (die sind
                    # in frischen Trends oft LEER → Hint verschwand; Bugfix).
                    _h5, _l5 = getattr(self, "_m5_hl", (None, None))
                    if _h5 and len(_h5) >= 10:
                        _K = 3
                        ph = [round(_h5[j], 3) for j in range(_K, len(_h5) - _K)
                              if _h5[j] == max(_h5[j - _K:j + _K + 1])]
                        pl = [round(_l5[j], 3) for j in range(_K, len(_l5) - _K)
                              if _l5[j] == min(_l5[j - _K:j + _K + 1])]
                        self._pb_levels = {"ph": ph, "pl": pl}
                        # (Squeeze läuft jetzt auf dem schnellen ~5-s-M5-Fetch oben,
                        # nicht mehr hier auf dem 15-s-Ampel-Pfad — Variante A 2026-07-21.)
            # ── S/R-LEVEL auf M30 (gemessen 2026-07-30, `backtest_level_tf.py`) ──
            # Trainingsgleiches Fenster: der M5-Lookback von 300 Bars entspricht
            # 50 M30-Bars → nur die letzten 50+K betrachten, sonst kämen Level aus
            # einem viel längeren Zeitraum als im Training.
            _h30, _l30 = getattr(self, "_m30_hl", (None, None))
            if _h30 and len(_h30) >= 12:
                _K = 3; _WIN = 50 + _K
                _hs = _h30[-_WIN:]; _ls = _l30[-_WIN:]
                ph30 = [round(_hs[j], 3) for j in range(_K, len(_hs) - _K)
                        if _hs[j] == max(_hs[j - _K:j + _K + 1])]
                pl30 = [round(_ls[j], 3) for j in range(_K, len(_ls) - _K)
                        if _ls[j] == min(_ls[j - _K:j + _K + 1])]
                self._pb_levels30 = {"ph": ph30, "pl": pl30}
                # ── Liquiditätszonen: EQUAL HIGHS / EQUAL LOWS (SMC, User-Wunsch
                # 2026-07-30 „zeichne mir die Liquiditätszonen wie im Bild ein").
                # Zwei ~gleiche M30-Pivots (innerhalb _EQ_TOL×ATR, 2–40 Bars
                # auseinander) = Stop-Cluster. ⚠ REINE ANZEIGE — als SIGNAL gemessen
                # und verworfen (`backtest_liquidity_sweep.py`: Equal Highs/Lows
                # fallen in fast allen Varianten durch, der SMC-Exit sogar überall).
                _a30 = turn_data.get("M30", (None, None))[1] if turn_data else None
                _t30 = getattr(self, "_m30_t", None)
                if _a30 and _a30 > 0 and _t30 and len(_t30) >= len(_hs):
                    _tol = _EQ_TOL_ATR * _a30
                    _toff = len(_t30) - len(_hs)      # Index-Versatz Fenster→Voll-Liste
                    def _equal(vals, want_high):
                        # jüngstes Paar zuerst suchen (rückwärts)
                        idx = [j for j in range(_K, len(vals) - _K)
                               if ((vals[j] == max(vals[j - _K:j + _K + 1])) if want_high
                                   else (vals[j] == min(vals[j - _K:j + _K + 1])))]
                        for a in range(len(idx) - 1, 0, -1):
                            for b in range(a - 1, -1, -1):
                                gap = idx[a] - idx[b]
                                if gap < 2: continue
                                if gap > 40: break
                                if abs(vals[idx[a]] - vals[idx[b]]) <= _tol:
                                    lo = min(vals[idx[a]], vals[idx[b]])
                                    hi = max(vals[idx[a]], vals[idx[b]])
                                    pad = max(0.0, (_EQ_MIN_H_ATR * _a30 - (hi - lo)) / 2)
                                    return {"lo": round(lo - pad, 3), "hi": round(hi + pad, 3),
                                            "t": int(_t30[idx[b] + _toff])}
                        return None
                    self._liq_zones = {"eqh": _equal(_hs, True), "eql": _equal(_ls, False)}
            # Multi-TF-Bounce: stärksten Status über M1/M5/M15/M30 wählen (H1 ist NUR
            # für die Ampel im turn_data, NICHT im gemessen-neutralen Bounce → skip).
            cands = []
            for lbl, (c, a) in turn_data.items():
                if lbl == "H1":
                    continue
                b = self._bounce_one(c, a)
                if b:
                    b["tf"] = lbl; cands.append(b)
            if cands:
                rank = {"active": 2, "expected": 1}
                best = max(cands, key=lambda b: (rank[b["state"]], b["stretch"]))
                self._last_bounce = {"state": best["state"], "dir": best["dir"], "tf": best["tf"]}
            else:
                self._last_bounce = {"state": None, "dir": None, "tf": None}
        snap["tf_turns"] = dict(self._last_tf_turns)
        snap["tf_atr"]   = dict(getattr(self, "_tf_atr", {}) or {})
        snap["bounce"]   = dict(self._last_bounce)   # Multi-TF überschreibt Basis-TF
        snap["pb_feats"]  = dict(self._pb_feats) if getattr(self, "_pb_feats", None) else None
        snap["pb_levels"] = dict(self._pb_levels) if getattr(self, "_pb_levels", None) else None
        # M30-Level = Quelle für Chart-Linien UND P(break)-Auto-Close (gemessen besser);
        # `pb_levels` (M5) bleibt für das Entry-Raum-Gate.
        snap["pb_levels30"] = dict(self._pb_levels30) if getattr(self, "_pb_levels30", None) else None
        # Liquiditätszonen (Equal Highs/Lows, M30) — REINE ANZEIGE (Signal verworfen)
        snap["liq_zones"] = dict(self._liq_zones) if getattr(self, "_liq_zones", None) else None
        snap["squeeze"]   = dict(self._squeeze) if getattr(self, "_squeeze", None) else \
                            {"state": None, "dir": None, "level": None, "box_atr": None}
        # ── Entry-Raum-Gate (gemessen `backtest_entryroom.py`, beide Hälften
        # monoton): Richtungssignal → WARTEN, wenn das GEGENLEVEL (M5-Pivot in
        # Trade-Richtung, trainingsgleich zum S/R-Auto-Close) näher als
        # `_entry_room_atr`×ATR_M5 liegt — der Ertrag ist dort durch den Auto-
        # Close gedeckelt (63 % Containment), die Echtkosten fressen den Rest.
        # Verhindert die Sofort-/Klein-Close-Trades an der QUELLE (Exit bleibt).
        rec = self._room_gate(rec, cur)
        rec = self._confirm_breakout(rec, cur, atr, snap)   # k×ATR-Bestätigung
        with self._lock:
            self._rec  = rec
            self._snap = snap
            self._ts   = time.time()
            self._error = None

    @staticmethod
    def _htf_sign(hbars) -> int:
        """Vorzeichen des Higher-TF-Trends (M30, EMA12 vs EMA50): +1 auf,
        −1 ab, 0 neutral/keine Daten (Totband _HTF_DEADBAND×ATR). Fail-open:
        ohne Bars → 0 → kein Filter."""
        if hbars is None or len(hbars) < _EMA_SLOW + 5:
            return 0
        h = [float(b["high"]) for b in hbars]
        l = [float(b["low"])  for b in hbars]
        c = [float(b["close"]) for b in hbars]
        atr = _atr(h, l, c)
        if not atr or atr <= 0:
            return 0
        ef = _ema_last(c, _EMA_FAST)
        es = _ema_last(c, _EMA_SLOW)
        if ef is None or es is None:
            return 0
        d = ef - es
        if abs(d) < _HTF_DEADBAND * atr:
            return 0
        return 1 if d > 0 else -1

    @staticmethod
    def _turn_state(closes, atr: float | None = None) -> dict:
        """Wende-Status einer TF aus der EMA12/50-Kreuzung — mit Totband, damit
        bei seitwärts laufenden EMAs nicht jede Mikro-Kreuzung als „Wende" blinkt.
        dir: +1 auf / −1 ab / 0 unklar (im Totband) · bars_ago: Bars seit letzter
        Kreuzung (None = keine im Fenster) · fresh: echte Kreuzung ≤3 Bars her."""
        if not closes or len(closes) < _EMA_SLOW + 5:
            return {"dir": 0, "bars_ago": None, "fresh": False}
        ef = _ema_series(closes, _EMA_FAST)
        es = _ema_series(closes, _EMA_SLOW)
        diff = [a - b for a, b in zip(ef, es)]
        dead = (_TREND_DEADBAND * atr) if atr else 0.0   # Substanz-Schwelle
        last = diff[-1]
        cur = 1 if last > dead else -1 if last < -dead else 0
        bars_ago = None
        for i in range(len(diff) - 1, _EMA_SLOW, -1):   # Warmup-Bereich überspringen
            if diff[i] != 0 and (diff[i] > 0) != (diff[i - 1] > 0):
                bars_ago = (len(diff) - 1) - i           # Kreuzung bei Bar i
                break
        return {"dir": cur, "bars_ago": bars_ago,
                "fresh": bars_ago is not None and bars_ago <= 3 and cur != 0}

    def _confirm_breakout(self, rec, cur, atr, snap):
        """ATR-Breakout-Bestätigung (stateful): ein Richtungssignal wird erst
        durchgelassen, wenn der Kurs **k×ATR in Signalrichtung** gelaufen ist
        (= „X dynamisch"). Läuft er vorher k×ATR DAGEGEN oder Timeout → neu
        verankern (WARTEN). Gemessen ~2× Edge/Trade (`backtest_breakout.py`).
        k=0 → Filter aus (Sofort-Einstieg)."""
        k = self._breakout_k
        sig = rec.get("signal")
        snap["breakout"] = {"pending": False, "dir": None, "need": None}
        if not k or k <= 0 or atr <= 0 or sig == "WARTEN":
            if sig == "WARTEN":
                self._pend = None
            return rec
        d = 1 if sig == "LONG" else -1
        p = self._pend
        if p is None or p.get("dir") != d:          # frisches Signal → verankern
            p = {"dir": d, "level": cur + d * k * atr, "invalid": cur - d * k * atr,
                 "confirmed": False, "start": time.time()}
            self._pend = p
        # ⚠ Das Bestätigungs-Level wird AUCH nach der Bestätigung veröffentlicht
        # (2026-08-05). Zwei Konsumenten brauchen es: die ruhende Stop-Order (sie
        # liegt dort) und die Nachjagd-Bremse im Market-Fallback (sie misst den
        # Abstand dorthin). `pending` bleibt False — das Frontend rendert nur bei
        # `pending`, ist also unberührt.
        if p["confirmed"]:
            snap["breakout"] = {"pending": False, "dir": sig, "need": None,
                                "level": round(p["level"], 3), "confirmed": True}
            return rec                               # schon bestätigt → durchlassen
        broke   = (cur >= p["level"]) if d > 0 else (cur <= p["level"])
        against = (cur <= p["invalid"]) if d > 0 else (cur >= p["invalid"])
        if broke:
            p["confirmed"] = True
            snap["breakout"] = {"pending": False, "dir": sig, "need": None,
                                "level": round(p["level"], 3), "confirmed": True}
            return rec
        if against or (time.time() - p["start"]) > self._breakout_timeout_s:
            p = {"dir": d, "level": cur + d * k * atr, "invalid": cur - d * k * atr,
                 "confirmed": False, "start": time.time()}      # neu verankern
            self._pend = p
        need = abs(p["level"] - cur)
        # ⚠ `conf` = die Konfidenz VOR dem Stummschalten. Der Rückgabewert unten
        # setzt `conf_pct` auf 0 (das Signal gilt ja noch nicht) — wer die ruhende
        # Stop-Order gegen die Mindest-Konfidenz prüfen will, hätte sonst KEINE
        # Zahl und müsste die Order blind platzieren.
        snap["breakout"] = {"pending": True, "dir": sig,
                            "level": round(p["level"], 3), "need": round(need, 3),
                            "conf": rec.get("conf_pct") or 0, "confirmed": False}
        return {"signal": "WARTEN", "conf_pct": 0, "score": 0.0, "setup": "WAVE",
                "regime": None, "rsi": None, "block": "breakout_pending",
                "reasons": [f"warte auf Breakout (+{k:.1f}×ATR {sig}, noch {need:.2f})"]}

    def room_info(self, cur):
        """Abstand zum Gegenlevel JE RICHTUNG — die Zahl hinter dem entry_room-Gate.

        ⚠⚠ Warum das hier steht und nicht anderswo nachgebaut wird: die Zahl lag
        bisher NUR als Fließtext in `reasons` und war damit nicht auswertbar. Eine
        zweite Berechnung in Engine oder Frontend wäre genau die Divergenz, die im
        Projekt schon dreimal zugeschlagen hat (`sim_run`/`_ema_series` als Kopie in
        ≥6 Skripten · der nachgebaute Level-Cluster · das fünfte Exit-Modell).
        **Eine Implementierung, zwei Nutzer:** `_room_gate` (Entscheidung) und
        `engine._m15_setup` (Anzeige).

        ⚠ BEIDE Richtungen, weil die Karte bewusst keine Richtung vorgibt — der
        Mensch kann in jede handeln und braucht den Raum für die, die er wählt.
        ⚠ Quelle ist trainingsgleich **M5** (`_pb_levels`/`_pb_feats`), NICHT die
        M30-Level der Karte: `entry_room_atr=0.6` ist auf M5 kalibriert. Die drei
        divergierenden Level-Quellen sind Absicht, aber eine Stolperfalle.
        """
        lv = getattr(self, "_pb_levels", None) or {}
        pf = getattr(self, "_pb_feats", None) or {}
        atr5 = pf.get("atr")
        x = self._entry_room_atr
        out = {"gate": x, "atr_m5": atr5, "long": None, "short": None}
        if not atr5 or not cur:
            return out
        for name, d in (("long", 1), ("short", -1)):
            if d > 0:
                cands = [p for p in (lv.get("ph") or []) if p > cur]
                lvl = min(cands) if cands else None
            else:
                cands = [p for p in (lv.get("pl") or []) if p < cur]
                lvl = max(cands) if cands else None
            if lvl is None:          # freie Bahn (bestes gemessenes Segment)
                out[name] = {"level": None, "dist_atr": None, "frei": True}
                continue
            dist = (lvl - cur) * d / atr5
            # ⚠ `frei` MUSS auf dem UNGERUNDETEN Wert entschieden werden — sonst
            #   kippt ein Grenzfall (0,5951 → gerundet 0,60 → fälschlich frei)
            #   und das Gate verhielte sich anders als vor dem Umbau.
            out[name] = {"level": round(lvl, 3), "dist_atr": round(dist, 2),
                         "frei": bool(dist >= x)}
        return out

    def _room_gate(self, rec, cur):
        """Entry-Raum-Gate: Signal → WARTEN, wenn das Gegenlevel < X×ATR_M5 entfernt
        ist (gemessen `backtest_entryroom.py`: Raum <0,6×ATR in BEIDEN Hälften klar
        negativ — 79–82 % WR, aber PF<1 = Klein-Close-Falle). Level/ATR trainings-
        gleich aus `_pb_levels`/`_pb_feats` (M5). Fail-open: ohne Daten kein Gate.
        ⚠ Rechnet seit 2026-08-10 über `room_info` — Verhalten unverändert
        (bitgenau geprüft in `tests/test_room_info.py`)."""
        x = self._entry_room_atr
        sig = rec.get("signal")
        if x <= 0 or sig == "WARTEN":
            return rec
        info = self.room_info(cur)
        if not info.get("atr_m5"):
            return rec
        d = 1 if sig == "LONG" else -1
        seite = info["long"] if d > 0 else info["short"]
        if seite is None or seite["level"] is None or seite["frei"]:
            return rec
        lvl, dist = seite["level"], seite["dist_atr"]
        return {"signal": "WARTEN", "conf_pct": 0, "score": 0.0, "setup": "WAVE",
                "regime": None, "rsi": None, "block": "entry_room",
                "reasons": [f"kein Raum: {'Widerstand' if d > 0 else 'Support'} "
                            f"{lvl:.2f} nur {dist:.2f}×ATR entfernt (Gate {x:.1f}) — "
                            f"Ertrag gedeckelt, Kosten fressen den Edge"]}

    @staticmethod
    def _squeeze_one(highs, lows, closes, atr):
        """Volatilitäts-Squeeze-Breakout (M5, gemessen `backtest_breakout_squeeze.py`).
        Box = Spanne der letzten _SQ_N ABGESCHLOSSENEN Bars. Ist sie ≤ _SQ_MULT×ATR
        (Kompression):
          - Kurs bricht _SQ_K×ATR über/unter die Box → `active` (LONG/SHORT), level =
            Ausbruchsgrenze — das getestete Einstiegssignal.
          - sonst → `armed` (komprimiert, Ausbruch steht bevor; dir = nähere Grenze).
        Nicht komprimiert → state None. Rein transient (der Ausbruch weitet die Box →
        Signal klärt sich von selbst, sobald der Move läuft)."""
        none = {"state": None, "dir": None, "level": None, "box_atr": None}
        if not atr or atr <= 0 or len(closes) < _SQ_N + 2:
            return none
        hb = highs[-_SQ_N - 1:-1]; lb = lows[-_SQ_N - 1:-1]   # N abgeschlossene Bars
        if not hb or not lb:
            return none
        box_hi = max(hb); box_lo = min(lb)
        box_atr = round((box_hi - box_lo) / atr, 2)
        # Box-Grenzen immer mitgeben — der MT5-Chart zeichnet die Konsolidierung
        # daraus (reine Anzeige; Konsolidierungs-SETUPS sind gemessen verworfen,
        # `backtest_consolidation.py` — nur der Squeeze-Ausbruch ist validiert).
        _box = {"box_hi": round(box_hi, 3), "box_lo": round(box_lo, 3)}
        if box_atr > _SQ_MULT:                                # keine Kompression
            return {"state": None, "dir": None, "level": None, "box_atr": box_atr, **_box}
        px = closes[-1]
        up = box_hi + _SQ_K * atr; dn = box_lo - _SQ_K * atr
        if px >= up:
            return {"state": "active", "dir": "LONG",  "level": round(up, 3), "box_atr": box_atr, **_box}
        if px <= dn:
            return {"state": "active", "dir": "SHORT", "level": round(dn, 3), "box_atr": box_atr, **_box}
        dir_ = "LONG" if (up - px) <= (px - dn) else "SHORT"  # armed → nähere Grenze
        return {"state": "armed", "dir": dir_,
                "level": round(up if dir_ == "LONG" else dn, 3), "box_atr": box_atr, **_box}

    @staticmethod
    def _bounce_one(closes, atr):
        """Bounce-Status EINER TF: überdehnt (|stretch|≥_REVERSAL_STRETCH von EMA50)
        → expected; + Winkel gedreht → active. dir=LONG bei überverkauft (unter EMA),
        SHORT bei überkauft. None, wenn nicht überdehnt."""
        if not closes or len(closes) < _EMA_SLOW + 5 or not atr or atr <= 0:
            return None
        es = _ema_series(closes, _EMA_SLOW)[-1]
        stretch = (closes[-1] - es) / atr
        if abs(stretch) < _REVERSAL_STRETCH:
            return None
        ad = calc_trend_angle(closes[-(_ANGLE_LR + 2):], _ANGLE_LR) - 90.0
        turned = (stretch < 0 and ad >= _ANGLE_DEAD) or (stretch > 0 and ad <= -_ANGLE_DEAD)
        return {"state": "active" if turned else "expected",
                "dir": "LONG" if stretch < 0 else "SHORT", "stretch": abs(stretch)}

    def _build(self, ef, es, cur, atr, tf_lbl, n_pivots,
               sr_res=None, sr_sup=None, htf_trend=0, h1_trend=0, angle=90.0,
               hour=None):
        """Signalrichtung aus dem EMA12/50-Trend; Einstieg nur, wenn der Trend
        klar ist (Totband) und der Kurs nicht überdehnt von der EMA weg ist."""
        diff    = (ef - es) if (ef is not None and es is not None) else 0.0
        sep     = diff / atr if atr else 0.0          # Trendstärke in ATR
        stretch = (cur - es) / atr if (es is not None and atr) else 0.0
        snap = {"tf": tf_lbl, "atr": atr,
                "direction": ("up" if diff > 0 else "down" if diff < 0 else None),
                "wave_start": round(es, 3) if es is not None else None,
                "move_atr": round(stretch, 2), "n_pivots": n_pivots,
                "trend_sep": round(sep, 2)}
        # ── Bounce-Status (für die Anzeige), unabhängig vom Signal-Flow:
        #   "expected" = überdehnt (≥_REVERSAL_STRETCH), Winkel noch NICHT gedreht
        #   "active"   = überdehnt UND Winkel gedreht (= der Reversal-Trigger feuert)
        #   dir = LONG bei überverkauft (unter EMA), SHORT bei überkauft.
        _ad = angle - 90.0
        if abs(stretch) >= _REVERSAL_STRETCH:
            _turned = (stretch < 0 and _ad >= _ANGLE_DEAD) or (stretch > 0 and _ad <= -_ANGLE_DEAD)
            snap["bounce"] = {"state": "active" if _turned else "expected",
                              "dir": "LONG" if stretch < 0 else "SHORT"}
        else:
            snap["bounce"] = {"state": None, "dir": None}
        # `block` = maschinenlesbarer Grund, WARUM WARTEN (Stufe-2-Telemetrie
        # 2026-07-31). Die `reasons` sind für Menschen, `block` fuer die Statistik:
        # ohne ihn war live nicht feststellbar, welches der Gates die 93 % WARTEN
        # erzeugt (Backtest-Erwartung ~43 %).
        wait = {"signal": "WARTEN", "conf_pct": 0, "score": 0.0, "block": None,
                "setup": "WAVE", "regime": None, "rsi": None, "reasons": []}

        # Totband: kein klarer Trend → kein Trade (Chop)
        if abs(sep) < _TREND_DEADBAND:
            wait["block"] = "deadband"
            wait["reasons"] = [f"kein klarer Trend ({tf_lbl}, EMA-Abstand {sep:+.2f}×ATR)"]
            return wait, snap

        # Tageszeit-Gate (per Config `set_dead_hours`; Default _DEAD_HOURS = Nacht-
        # Kostenfalle + 12/16 Uhr. Leer = AUS, User-Vorgabe 2026-07-22 trotz Messung).
        if hour is not None and hour in self._dead_hours:
            why = "Nacht-Spread frisst den Edge" if hour <= 7 else "gemessen negativer Edge"
            wait["block"] = "dead_hour"
            wait["reasons"] = [f"Zeit-Gate {hour}:00 Uhr — {why}, kein Trade"]
            return wait, snap

        # EIA-Blackout (gemessen, `backtest_events.py`): Mittwoch 15:30–16:30 Berlin
        # = Vorlauf der EIA-Lagerdaten (16:30). In BEIDEN History-Hälften netto
        # negativ (−0,147/−0,088 vs rest) — Positionierungs-Chop vor den Zahlen.
        # Kausales Event-Fenster, kein Pauschal-Gate. Nur im Live-Pfad (hour≠None).
        if hour is not None:
            _now_b = datetime.now(_BERLIN)
            if _now_b.weekday() == 2 and (15, 30) <= (_now_b.hour, _now_b.minute) < (16, 30):
                wait["block"] = "eia"
                wait["reasons"] = ["EIA-Blackout Mi 15:30–16:30 — Lagerdaten-Vorlauf, "
                                   "gemessen negativ, kein Trade"]
                return wait, snap

        sig = "LONG" if diff > 0 else "SHORT"
        reversal = False

        # ── Reversal/BOUNCE (antizyklisch, markiertes ZWEITsignal): überdehnt
        # (|stretch|≥_REVERSAL_STRETCH=3,0) + Regressions-Winkel hat GEDREHT →
        # Einstieg in Winkel-Richtung, gegen die EMA. Exit-Sim (backtest_bounce.py):
        # Ø-R +0,185, PF 1,35, Treffer 70 %, Worst −2×ATR (SL-gedeckelt) — profitabel,
        # aber schwächer als Trend & regime-anfällig (blutet in starken Trends).
        # Hebt Anti-Überdehnung UND M30-Filter bewusst auf (per Definition gegen
        # die nachlaufende EMA/HTF).
        # ⚠⚠ ABGESCHALTET über `[trading] reversal_enabled=false` (2026-08-04).
        # Gemessen (`backtest_reversal_angle.py`, 80k M5, kanonischer Exit,
        # Echtkosten, 2 Halbjahre) ist der Trigger in JEDER Variante beidhälftig
        # negativ: IST −0,122/−0,031 · vorzeichenkorrigiert −0,173/−0,061 ·
        # ohne Winkelbedingung −0,099/−0,023. Der Modul-Default bleibt True,
        # damit die Backtests unverändert weiterlaufen.
        # ⚠ ZUR BEDINGUNG SELBST (nicht korrigiert, weil gemessen SCHLECHTER):
        # `calc_trend_angle` liefert 0°=AUFWÄRTS … 180°=ABWÄRTS, also ist
        # `ad = angle−90` NEGATIV bei STEIGENDEM Kurs. Die untenstehenden
        # Kommentare („Winkel auf/ab") beschreiben damit das GEGENTEIL dessen,
        # was der Code tut: SHORT feuert bei überkauft UND WEITER STEIGENDEM
        # Winkel. Das Vorzeichen wurde bewusst NICHT gedreht — die korrigierte
        # Variante misst sich schlechter, und alle Alt-Messungen beruhen auf
        # diesem Verhalten.
        ad = angle - 90.0
        if not self._reversal_enabled:
            pass                                   # Setup aus → nur Trendsignal
        elif stretch <= -_REVERSAL_STRETCH and ad >= _ANGLE_DEAD:
            sig, reversal = "LONG", True           # ⚠ faktisch: Winkel FÄLLT noch
        elif stretch >= _REVERSAL_STRETCH and ad <= -_ANGLE_DEAD:
            sig, reversal = "SHORT", True          # ⚠ faktisch: Winkel STEIGT noch

        d_sig = 1 if sig == "LONG" else -1

        if not reversal:
            # Higher-TF-Gegen-Trend-Filter: kein Short im M30-Aufwärtstrend (und
            # umgekehrt). Per Backtest belegt (Ø-Edge ×2). Nur reguläre Trendsignale.
            if htf_trend != 0 and htf_trend != d_sig:
                wait["block"] = "htf_counter"
                wait["reasons"] = [
                    f"gegen {_HTF_LABEL}-Trend "
                    f"({'auf' if htf_trend > 0 else 'ab'}) — kein Gegen-Trade"]
                return wait, snap
            # Anti-Überdehnung: nicht weit weg von der EMA hinterherkaufen/-shorten
            if sig == "LONG" and stretch > _STRETCH_MAX:
                wait["block"] = "stretch"
                wait["reasons"] = [f"überdehnt: {stretch:+.1f}×ATR über EMA — kein Spät-Long"]
                return wait, snap
            if sig == "SHORT" and stretch < -_STRETCH_MAX:
                wait["block"] = "stretch"
                wait["reasons"] = [f"überdehnt: {stretch:+.1f}×ATR unter EMA — kein Spät-Short"]
                return wait, snap

        if reversal:
            reasons = [f"🔄 Reversal {sig}: {abs(stretch):.1f}×ATR überdehnt + Winkel gedreht"]
        else:
            reasons = [f"{tf_lbl}-Trend {'auf' if sig == 'LONG' else 'ab'} "
                       f"(EMA-Abstand {sep:+.2f}×ATR)"]
        conf = _BASE_CONF
        # Trendstärke
        if abs(sep) >= 0.5:
            conf += 10; reasons.append("starker Trend")
        elif abs(sep) >= 0.25:
            conf += 5
        # Einstiegsqualität nach gemessenem Edge (backtest_pullback.py): ein
        # TIEFER Pullback (Kurs durch die EMA zurück, near<0) trägt mit Abstand
        # am besten; die laue Zone direkt an der EMA (0–0,3) ist der schwächste
        # Edge; weit gelaufenes Trend-Momentum (1–2×ATR) trägt wieder ordentlich.
        # Beeinflusst nur Konfidenz/Score (Anzeige + Auto-Dry-Run), NICHT die
        # Signalrichtung.
        near = stretch if sig == "LONG" else -stretch
        if near < 0:
            conf += 15; reasons.append("⭐ tiefer Pullback (bestes CRV)")
        elif near < 0.3:
            conf -= 3;  reasons.append("laue Zone an EMA (schwächster Edge)")
        elif near < 1.0:
            conf += 3
        elif near < 2.0:
            conf += 8;  reasons.append("Trend-Momentum trägt")
        else:
            conf += 5;  reasons.append("weit gelaufen — Vorsicht Überdehnung")

        # ── Multi-TF-Konfluenz (nur Konfidenz): M30 UND H1 dafür = Top-Setup;
        # H1 dagegen = schwächer. M30-Gegen-Trend ist oben schon WARTEN, hier
        # ist htf_trend also nur =Richtung oder neutral. (Backtest: Edge ×3,5.)
        if htf_trend == d_sig and h1_trend == d_sig:
            conf += _CONFLUENCE_BONUS
            reasons.append("⭐⭐ Konfluenz M30+H1")
        elif h1_trend != 0 and h1_trend != d_sig:
            conf -= _H1_AGAINST_PEN
            reasons.append("H1 gegen Richtung — schwächeres Setup")

        # ── Kurzfrist-Winkel als PULLBACK-Maß (nur Konfidenz) ────────────────
        # ⚠⚠ BESCHRIFTUNG KORRIGIERT 2026-08-04 — die ZAHLEN sind unverändert.
        # `calc_trend_angle` liefert 0°=AUFWÄRTS … 180°=ABWÄRTS, also ist
        # `ad = angle−90` NEGATIV bei steigendem Kurs. Die alten Texte
        # („Winkel bestätigt" / „⚠ Winkel gegen EMA") sagten deshalb genau das
        # GEGENTEIL dessen, was hier passiert — und `backtest_angle.py` benutzt
        # dieselbe verdrehte Beschriftung („Winkel klar dagegen" = `a5 < 90-dead`
        # = in Wahrheit STEIGEND). Der gemessene Befund lautet richtig gelesen:
        #   LONG bei FALLENDEM/flachem Kurzfrist-Winkel (= Pullback) → ØR +0,055
        #   LONG bei bereits STEIGENDEM Winkel (kein Pullback)       → ØR +0,025
        # Das ist der im Projekt mehrfach belegte Pullback-Effekt (vgl. „⭐ tiefer
        # Pullback" und den RSI-Befund: bestätigende Oszillatoren sind schlechter).
        # Die Gewichtung war also von Anfang an numerisch RICHTIG; nur hieß der
        # Bonus „bestätigt", obwohl er den Gegen-Winkel belohnt. NICHT umdrehen.
        if (d_sig > 0 and ad < -_ANGLE_DEAD) or (d_sig < 0 and ad > _ANGLE_DEAD):
            conf -= _ANGLE_PENALTY
            reasons.append("kein Pullback — Kurs läuft dem Signal schon davon")
        elif (d_sig > 0 and ad > _ANGLE_DEAD) or (d_sig < 0 and ad < -_ANGLE_DEAD):
            conf += _ANGLE_BONUS
            reasons.append("⭐ Pullback (Kurzfrist-Winkel gegen den Trend)")

        # Hohe Vola dämpfen (oberes ATR-Terzil = schwächster/negativer Edge) —
        # nur Konfidenz (Schwelle regime-abhängig, kein hartes Gate).
        if atr and atr >= _ATR_HIGH:
            conf -= _ATR_HIGH_PEN
            reasons.append("hohe Vola — schwächster Edge")

        # ── S/R-Kontext: Raum bis zur nächsten Linie in Trade-Richtung ───────
        if atr:
            thr = _SR_NEAR_ATR * atr
            if sig == "LONG":
                if sr_res is not None and 0 <= (sr_res - cur) < thr:
                    conf -= _SR_PENALTY
                    reasons.append(f"dicht unter Widerstand {sr_res:.3f} (wenig Raum)")
                if sr_sup is not None and 0 <= (cur - sr_sup) < thr:
                    conf += _SR_BONUS
                    reasons.append(f"an Unterstützung {sr_sup:.3f} (Rückenwind)")
            else:  # SHORT
                if sr_sup is not None and 0 <= (cur - sr_sup) < thr:
                    conf -= _SR_PENALTY
                    reasons.append(f"dicht über Unterstützung {sr_sup:.3f} (wenig Raum)")
                if sr_res is not None and 0 <= (sr_res - cur) < thr:
                    conf += _SR_BONUS
                    reasons.append(f"an Widerstand {sr_res:.3f} (Rückenwind)")

        # ── Börsen-Session: Vorsicht nach Open, Bonus in aktiver Session ─────
        ss = session_state()
        if ss["just_opened"]:
            conf -= _SESSION_CAUTION
            reasons.append(f"{ss['just_opened']}-Open frisch — volatil, Vorsicht")
        elif ss["active"]:
            conf += _SESSION_BONUS
            reasons.append(f"{'+'.join(ss['active'])}-Session aktiv")
        elif not ss["weekend"]:
            conf -= _SESSION_OFFHOURS
            reasons.append("außerhalb DE/US-Session (dünn)")

        # TU aus der Empfehlung ENTFERNT (User-Vorgabe 2026-07-06): Standard-Indikator-
        # Konfluenz = kein Edge (gemessen `backtest_confluence.py`); TU lagt (5m „Strong
        # Buy" während 4h/1d „Strong Sell") + ist nicht backtestbar (Live-Scrape, keine
        # History). `_tu_check`/`_TU_*` bleiben als Code, fließen aber NICHT mehr in die
        # Empfehlungs-Konfidenz. TU ist nur noch reine Anzeige (Snapshot).
        conf = max(0, min(conf, 90))

        # Mindest-Konfidenz-Gate: schwache Setups (zu viele Strafen gestapelt)
        # tragen negativen Edge (gemessen Band 40–54 %) → kein Trade.
        if conf < _MIN_CONF:
            wait["block"] = "min_conf"
            # ⚠ Die VERWORFENE Konfidenz mitgeben (2026-08-05). Bisher blieb
            # `conf_pct` hier auf 0 — die Zahl stand nur als Fliesstext in den
            # Gruenden und war damit nicht auswertbar. `min_conf` ist live aber der
            # zweitgroesste Blocker (16,1 % am 05.08. gegen 0,9 % im Backtest), und
            # ohne den Wert laesst sich nicht sagen, ob die Signale knapp (50–54)
            # oder weit (20–30) unter der Schwelle lagen. Reine Telemetrie: das
            # Signal bleibt WARTEN, nur die Zahl wird mitgeschrieben.
            wait["conf_pct"] = conf
            wait["reasons"] = [f"Konfidenz {conf}% < {_MIN_CONF}% — Setup zu schwach"] + reasons[:2]
            return wait, snap

        if reversal:
            setup = "WAVE_REV_LONG" if sig == "LONG" else "WAVE_REV_SHORT"
        else:
            setup = "WAVE_LONG" if sig == "LONG" else "WAVE_SHORT"
        rec = {"signal": sig, "conf_pct": conf,
               "score": 0.8 if sig == "LONG" else -0.8,
               "setup": setup, "regime": None, "rsi": None, "reasons": reasons}
        return rec, snap

    # ── Signal für Panel + Auto-Trader ───────────────────────────────────────
    def signal(self) -> dict:
        with self._lock:
            rec, ts, err = self._rec, self._ts, self._error
        if rec is None:
            return {"signal": "WARTEN", "conf_pct": 0, "score": 0.0,
                    "setup": "WAVE", "regime": None, "rsi": None, "block": "no_data",
                    "reasons": [err or "keine Wellen-Daten"]}
        if not ts or time.time() - ts > _STALE_S:
            return {"signal": "WARTEN", "conf_pct": 0, "score": 0.0,
                    "setup": "WAVE", "regime": None, "rsi": None, "block": "stale",
                    "reasons": ["Wellen-Daten veraltet"]}
        return dict(rec)

    def snapshot(self) -> dict:
        with self._lock:
            d = dict(self._snap)
            d["error"] = self._error
            d["last_update"] = self._ts
            return d
