"""
core/trailing.py — TrailingManager (tick-basiert)
===================================================
High-Water-Mark wird bei jedem Preis-Tick aktualisiert (kein MT5-Call).
ATR aus Kerzenschlüssen — Timeframe wird automatisch gewählt:
  starker Trend  (Winkel-Abstand > 45°) → H1  (große Moves, breiter Puffer)
  moderater Trend (20°–45°)             → M30 (Mittelweg)
  Seitwärts      (<20°)                 → M15 (normale Range)
Gecacht, refresht nur bei neuer Kerze des jeweiligen Timeframes.
SL/TP-Modifikation via MT5 nur wenn Cooldown abgelaufen + Schwellwert überschritten.

SL drei Phasen (Phasen-Ratsche: nur vorwärts Init→Trail→Lock, nie zurück):
  1. Profit < 0.3 × ATR  → Initial-SL setzen (falls noch keiner), dann halten
  2. 0.3 ≤ Profit < 3.5  → SL = HW ∓ mult×ATR; Breakeven-Floor (Entry) erst
                            ab Profit ≥ 1.0×ATR — sonst stoppt jeder Rückläufer
                            zum Entry mit ±0 aus
  3. Profit ≥ 3.5 × ATR  → Engeres Trailing (mult × 0.6, min 1.2×) zum Gewinn-Lock-In

TP (Forward-Ziel — zieht mit High-Water nach vorne mit):
  Phase 1 (Init):  TP = Entry ± 3.5×ATR  (fester Forward-Puffer, kein Trailing)
  Phase 2 (Trail): TP = HW   ∓ 1.5×ATR  (Trailing hinter HW — breit genug für Retrace)
  Phase 3 (Lock):  TP = HW   ∓ 0.8×ATR  (engeres Lock-in bei tiefem Profit)
TP wird in Init/Trail nie zurückbewegt (nur nach oben LONG bzw. unten SHORT);
in Phase Lock darf er näher an den Kurs rücken (Gewinnsicherung).

Dynamischer Multiplikator (Trendwinkel):
  starker Trend (>45° Abstand von 90°) → 2.5× (moderat — Trend braucht Raum)
  moderater Trend (20°–45° Abstand)    → 2.0× (enger)
  Seitwärts (<20° Abstand)             → 3.0× (weiter, weil kein klarer Trend)

Stabilität: ATR-Timeframe UND Multiplikator werden beim Aktivieren eingefroren
und gelten für den gesamten Trade. Vorher wechselte die TF-Automatik mitten im
Trade (M30→H1: ATR 0.41→1.08) → Phase fiel von Trail auf Init zurück und der
SL wurde nie wieder bewegt. Manueller TF-Override im UI greift weiterhin sofort.

ATR-Floor: max(gemessener ATR, 0.25) — verhindert zu enges Trailing in ruhigen Märkten.
Schwellwert: max(8 Punkte, 4 % des ATR) — skaliert mit Marktvolatilität.
"""

from __future__ import annotations
import threading
import time

import MetaTrader5 as mt5

from core.mt5_utils import mt5_lock
from core.logger import get_logger

log = get_logger("trail")

_ATR_PERIOD        = 14
_ATR_MIN           = 0.06     # Untergrenze ATR. 0,12→0,06 gesenkt (gemessen,
                              # `backtest_atrfloor.py`, 2 Halbjahre): reale Vola fiel
                              # unter den alten Floor (H1: 81 % der Signale < 0,12 →
                              # Floor band dauernd, Exit-Distanzen künstlich ~1,3–1,6×
                              # breit). 0,06 in BEIDEN Hälften besser (H1 −153→−27 Pts,
                              # H2 +512→+554); ganz ohne Floor nur marginal besser →
                              # 0,06 als Schutz gegen Absurd-ATR (Dead-Hours) behalten.
# ── Exit-Parameter: EINE Quelle für Live UND Backtests (Stufe 3, 2026-07-31) ──
# Diese Werte kamen bis dahin hier UND in ~20 Backtests getrennt vor. Genau daraus
# entstand Deployment-Drift-Fall 3: `_MULT_BY_TF` lief live auf bis zu 3,0, während
# jeder Backtest 1,5 annahm. Jetzt liegen sie in `core/exit_model.py`; ändert sie
# jemand dort, ändern sich Live-Verhalten UND Messung gemeinsam.
from core.exit_model import LIVE as _EXIT

_TRAIL_START_ATR   = _EXIT.trail_start   # 0,3 — Phase 1→2: ab hier HW-Trailing
_BREAKEVEN_ATR     = _EXIT.be            # 1,3 — Entry-Floor (Breakeven). 0,6 war zu
                             # eng: 21 % der Trades wurden auf Breakeven gescratcht
                             # (backtest_exit.py be). 1,3 = gemessenes Plateau-Optimum:
                             # Scratch 5 %, Ø-R +15 %, ohne Tail-Risiko (SL-gedeckelt).
_PHASE4_ATR        = _EXIT.lock_start    # 3,5 — Phase 2→3: engeres Trailing (Lock-In)
_PHASE4_MULT_SCALE = _EXIT.lock_scale    # 0,6 — Multiplikator-Faktor in Phase 3
_PHASE4_MULT_MIN   = _EXIT.lock_min      # 1,2 — Untergrenze Mult in Phase 3

_PARTIAL_TP_ATR    = 1.5      # Teil-Exit: ab diesem Profit 1× die Hälfte sichern
_PARTIAL_TP_FRAC   = 0.0      # Anteil beim Teil-Exit. 0 = AUS → kompletter Trade
                              # läuft bis TP/SL (User-Vorgabe: am TP voll schließen)

_TIMESTOP_MIN      = 120      # Time-Stop (gemessen, `backtest_timestop.py`): Trade
                              # hängt nach 2 h noch in Phase "Init" (HW-Profit nie
                              # ≥0,3×ATR = Whipsaw-Opfer) → schließen. Verbessert
                              # BEIDE History-Hälften (H1 +98→+162, H2 +2191→+2298 R),
                              # Worst unverändert; kurze N (30/60 min) = Rauschen.
                              # 0 = aus.
_ADVERSE_EXIT_ATR  = 0.0      # Früh-Ausstieg: läuft der Trade ≥ diese ATR-Distanz
                              # GEGEN den Einstieg, sofort schließen. 0 = AUS.
                              # Abgeschaltet: kappte trend-konforme Trades schon auf
                              # normalen 1×ATR-Bounces, bevor der Broker-SL (1.2–1.5×
                              # ATR) Raum gab. Schutz läuft jetzt allein über den SL.

_TP_TRAIL_ATR       = 1.5     # Phase 2 (Trail): TP = HW ∓ 1.5×ATR (mehr Raum für Retrace)
_TP_LOCK_ATR        = 0.8     # Phase 3 (Lock):  TP = HW ∓ 0.8×ATR (Lock-in, weniger eng)
_TP_INIT_ATR        = 3.5     # Phase 1 (Init):  TP = Entry ± 3.5×ATR (weiter Forward-Puffer)

_MODIFY_COOLDOWN_S  = 8       # normaler Abstand zwischen zwei Modifikationen
_FAST_COOLDOWN_S    = 1       # Cooldown bei Ausbruch (HW bewegt sich schnell)
_BREAKOUT_ATR_FACTOR = 0.5   # HW-Bewegung > 0.5×ATR seit letztem Modify → Ausbruch erkannt
_ERROR_COOLDOWN_S   = 30      # Pause nach fehlgeschlagenem order_send
_ACTIVATE_DELAY_S   = 2       # Wartezeit nach Aktivierung bis erste Modifikation
_WARMUP_TICKS       = 3       # erste N Ticks: kein Modify
_SL_THRESHOLD_PTS   = 8       # SL-Mindestverbesserung in Punkten
_SL_THRESHOLD_ATR   = 0.04    # SL-Mindestverbesserung als ATR-Anteil (max der beiden)

# Fallback-ATR-TF-Wahl nach Trendstärke (nur wenn kein Override gesetzt ist —
# normalerweise koppelt das Widget den TF an die Wellen-/Agent-TF).
_TF_STRONG   = mt5.TIMEFRAME_H1   # starker Trend  → H1
_TF_MODERATE = mt5.TIMEFRAME_M30  # moderater Trend → M30
_TF_RANGE    = mt5.TIMEFRAME_M15  # Seitwärts       → M15
_TF_LABELS   = {
    mt5.TIMEFRAME_M1:  "M1",
    mt5.TIMEFRAME_M5:  "M5",
    mt5.TIMEFRAME_M15: "M15",
    mt5.TIMEFRAME_M30: "M30",
    mt5.TIMEFRAME_H1:  "H1",
}

# Trail-Multiplikator je Timeframe.
# ⚠⚠ ANGEGLICHEN AUF DEN GEMESSENEN WERT 1,5 (2026-07-31, Stufe 1 der
# Deployment-Drift-Behebung). VORHER: M15 2,0 · M30 2,5 · H1 3,0 (+0,5 bei
# Breakouts) — diese Staffelung war **nie gemessen**. `backtest_trailing.py`
# validiert ausschließlich 1,5, und die breiteren Werte sind dort MESSBAR
# SCHLECHTER (80k Bars, 2 Halbjahre, Signal Trend+Reversal):
#     mult 1,5 (start 0,3):  H1 ΣR  −249  · H2 +1252 · Worst −1,50
#     mult 2,0 (start 0,3):  H1 ΣR  −412  · H2 +1187 · Worst −2,00
#     mult 2,5 (start 0,3):  H1 ΣR −1367  · H2 +1450 · Worst −2,50
# Auf M30 fuhr der Bot also 5,5× schlechteres H1 und einen 67 % größeren
# Einzelverlust; mit `_MULT_BREAKOUT_ADD` waren es bei Breakouts sogar 3,0 —
# ein Wert, den nie jemand getestet hat. Real relevant, weil die TF-Heuristik
# häufig auf M15/M30 landet (31.07.: 19 von 33 Phasen).
# ⚠ Falls die Staffelung je zurück soll: erst `backtest_trailing.py` auf
# M15-/M30-BASIS-Signalen laufen lassen — bisher misst er nur M5.
# ⚠ Werte kommen aus `core/exit_model.py` (Stufe 3) — NICHT hier hart eintragen.
_MULT_BY_TF = {tf: _EXIT.mult for tf in (
    mt5.TIMEFRAME_M1, mt5.TIMEFRAME_M5, mt5.TIMEFRAME_M15,
    mt5.TIMEFRAME_M30, mt5.TIMEFRAME_H1)}
# 0,0 statt 0,5: der Aufschlag hob Breakout-Trades auf mind. 2,0 — gemessen
# schlechter als 1,5 (s. o.) und für Breakouts nie separat validiert.
_MULT_BREAKOUT_ADD = 0.0

_PHASE_RANK = {"Init": 0, "Trail": 1, "Lock": 2}


class TrailingManager:
    """Tick-basiertes Trailing-Stop-System mit M15-ATR als Abstandsmaß."""

    def __init__(self, trader):
        self.trader = trader
        self.enabled = False

        self._atr: float | None = None          # aktiver ATR (vom gewählten TF)
        self._atr_tf: int = _TF_RANGE           # aktuell genutzter Timeframe
        self._atr_tf_override: int | None = None  # None = Auto
        self._atr_cache: dict = {}              # {tf: atr_value}
        self._atr_bar_times: dict = {}          # {tf: last_bar_time}
        self._high_water: float | None = None
        self._last_sl: float | None = None
        self._last_tp: float | None = None
        self._last_modify_ts: float = 0.0
        self._last_idle_atr_ts: float = 0.0   # ATR-Refresh wenn Trailing aus
        self._ticks: int = 0
        self._sr_data: dict | None = None
        self._trend_angle: float | None = None
        self._phase: str = "—"
        self._hw_at_last_modify: float | None = None  # HW-Stand beim letzten Modify
        self._setup: str | None = None                # aktives Setup (für Mult-Wahl)
        self._active_tf: int | None = None    # beim Aktivieren eingefrorener ATR-TF
        self._trade_mult: float | None = None # beim Aktivieren eingefrorener Multiplikator
        self._partial_done: bool = False      # Teil-Exit pro Trade nur einmal
        self._notify = None                   # optionaler Callback (Telegram)
        self._no_close_until: float = 0.0     # Startup-Schonfrist (von der Engine gesetzt):
                                              # bis dahin kein Time-Stop-Close nach Neustart
        self._lock = threading.Lock()

    def set_notify(self, fn):
        """Callback fn(event, volume, price, profit) für Teil-Exit-Meldungen."""
        self._notify = fn

    def set_no_close_until(self, ts: float):
        """Startup-Schonfrist: bis zu diesem Zeitstempel wird KEIN Time-Stop-Close
        ausgelöst (die Engine setzt das beim Start — verhindert, dass ein nach
        Neustart adoptierter Alt-Trade sofort per Time-Stop geschlossen wird)."""
        self._no_close_until = ts or 0.0

    # ── Setter von außen ──────────────────────────────────────────────────────
    def set_atr_tf_override(self, tf: int | None):
        """Setzt den ATR-Timeframe (vom Widget an die Wellen-/Agent-TF gekoppelt).
        None = Fallback auf Trendstärke-Wahl. Ein laufender Trade behält seinen
        beim Aktivieren eingefrorenen TF — die neue Wahl greift erst beim nächsten
        Trade (verhindert ATR-Sprünge/Phasen-Rückfall mitten im Trade)."""
        with self._lock:
            self._atr_tf_override = tf
        label = _TF_LABELS.get(tf, "?") if tf is not None else "Auto"
        log.info(f"ATR-TF gesetzt: {label}")

    def set_sr(self, sr_data: dict | None):
        with self._lock:
            self._sr_data = sr_data

    def set_trend_angle(self, angle: float):
        with self._lock:
            self._trend_angle = angle

    def set_setup(self, setup: str | None):
        """Aktuelles Setup setzen — beeinflusst SL-Multiplikator."""
        with self._lock:
            self._setup = setup

    # ── ATR-Multiplikator (Timeframe-basiert) ─────────────────────────────────
    def _trail_mult(self) -> float:
        """Muss unter self._lock aufgerufen werden. Stop-Distanz richtet sich
        nach dem Trade-Timeframe: Scalp-TF (M1/M5) → eng, Trend-TF (H1) → weit.
        So passt der Stop zum tatsächlichen Trade-Horizont (vorher pauschal
        2.5–4.5, viel zu weit für M1/M5-Scalps)."""
        tf = self._active_tf or self._atr_tf
        base = _MULT_BY_TF.get(tf, 2.0)
        if self._setup and "BREAKOUT" in self._setup:
            base += _MULT_BREAKOUT_ADD
        return base

    # ── Automatische ATR-Timeframe-Wahl ──────────────────────────────────────
    def _choose_atr_tf(self) -> int:
        """Wählt ATR-Timeframe: Override wenn gesetzt, sonst automatisch nach Trendstärke."""
        with self._lock:
            override = self._atr_tf_override
            angle    = self._trend_angle
        if override is not None:
            return override
        if angle is None:
            return _TF_RANGE
        strength = abs(angle - 90.0)
        if strength > 45:
            return _TF_STRONG    # starker Trend → H1
        elif strength > 20:
            return _TF_MODERATE  # moderater Trend → M30
        return _TF_RANGE         # Seitwärts → M15

    # ── ATR (gecacht, refresht nur bei neuer Kerze des gewählten TF) ─────────
    def _refresh_atr(self, sym: str, tf: int) -> bool:
        """Muss unter aktivem MT5-Lock aufgerufen werden."""
        bars = mt5.copy_rates_from_pos(sym, tf, 0, _ATR_PERIOD + 2)
        if bars is None or len(bars) < _ATR_PERIOD + 1:
            # Fallback: gecachten Wert für diesen TF weiterverwenden
            cached = self._atr_cache.get(tf)
            if cached is not None:
                self._atr = cached
                return True
            return self._atr is not None

        last_bar_time = int(bars[-2]["time"])   # -2 = letzte abgeschlossene Kerze
        if last_bar_time == self._atr_bar_times.get(tf) and self._atr_cache.get(tf) is not None:
            self._atr = self._atr_cache[tf]
            return True

        trs = []
        for i in range(1, len(bars) - 1):       # aktuelle (offene) Kerze weglassen
            h = float(bars[i]["high"])
            l = float(bars[i]["low"])
            c = float(bars[i - 1]["close"])
            trs.append(max(h - l, abs(h - c), abs(l - c)))
        if trs:
            atr_val = sum(trs[-_ATR_PERIOD:]) / min(len(trs), _ATR_PERIOD)
            self._atr_cache[tf]      = atr_val
            self._atr_bar_times[tf]  = last_bar_time
            prev_tf  = self._atr_tf
            self._atr    = atr_val
            self._atr_tf = tf
            effective  = max(atr_val, _ATR_MIN)
            tf_label   = _TF_LABELS.get(tf, str(tf))
            floor_note = f"  (Floor, raw={atr_val:.4f})" if atr_val < _ATR_MIN else ""
            tf_note    = (f"  [TF: {_TF_LABELS.get(prev_tf, '?')}→{tf_label}]"
                          if prev_tf != tf else "")
            log.info(f"ATR(14/{tf_label}) = {effective:.4f}{floor_note}{tf_note}")
        return self._atr is not None

    # ── Haupt-Tick (aufgerufen nach jedem Preis-Tick) ─────────────────────────
    def on_price(self, sym: str, bid: float, ask: float):
        with self._lock:
            if not self.enabled:
                # ATR-Anzeige: einmal pro Minute im Idle aktualisieren
                now = time.time()
                if now - self._last_idle_atr_ts > 60:
                    self._last_idle_atr_ts = now
                else:
                    return
            else:
                self._ticks += 1
                ticks = self._ticks

        if not self.enabled:
            tf = self._choose_atr_tf()
            with mt5_lock(timeout=1) as got:
                if got:
                    self._refresh_atr(sym, tf)
            return

        ticks = self._ticks

        ps = self.trader.snapshot()
        if ps["ticket"] is None:
            with self._lock:
                if self.enabled:
                    self.enabled            = False
                    self._high_water        = None
                    self._phase             = "—"
                    self._hw_at_last_modify = None
                    self._setup             = None
                    self._active_tf         = None
                    self._trade_mult        = None
                    self._partial_done      = False
                    log.info("Auto-Deaktiviert (Position geschlossen)")
            return

        is_long = (ps["order_type"] == mt5.ORDER_TYPE_BUY)
        cur = bid if is_long else ask

        # High-Water-Mark: immer aktualisieren, ohne MT5-Call
        with self._lock:
            if self._high_water is None:
                self._high_water = cur
            elif is_long and cur > self._high_water:
                self._high_water = cur
            elif not is_long and cur < self._high_water:
                self._high_water = cur
            hw = self._high_water
            last_mod = self._last_modify_ts

        # ── Früh-Ausstieg: Trade läuft ≥ _ADVERSE_EXIT_ATR×ATR gegen den Einstieg ──
        # Greift auch in der Init-Phase / Warmup, damit „läuft sofort schief"-Trades
        # nicht bis zum weiten Initial-Stop ausbluten.
        entry = ps.get("entry_price") or 0.0
        with self._lock:
            atr_ae = max(self._atr, _ATR_MIN) if self._atr is not None else None
        if entry and atr_ae and _ADVERSE_EXIT_ATR > 0:
            adverse = (entry - cur) if is_long else (cur - entry)   # >0 = gegen Position
            if adverse >= _ADVERSE_EXIT_ATR * atr_ae:
                lots = ps.get("lots", 0.0)
                log.warning(
                    f"🛑 Früh-Ausstieg: {adverse:.3f} ≥ {_ADVERSE_EXIT_ATR}×ATR"
                    f"({atr_ae:.3f}) gegen Einstieg {entry:.3f} → Position schließen")
                err = self.trader.close(reason="adverse")
                if err:
                    log.warning(f"Früh-Ausstieg-Close fehlgeschlagen: {err}")
                else:
                    with self._lock:
                        self.enabled            = False
                        self._high_water        = None
                        self._phase             = "—"
                        self._hw_at_last_modify = None
                        self._setup             = None
                        self._active_tf         = None
                        self._trade_mult        = None
                        self._partial_done      = False
                    if self._notify:
                        try: self._notify("adverse", lots, cur, -adverse)
                        except Exception as e: log.warning(f"Adverse-Notify: {e}")
                return

        if ticks <= _WARMUP_TICKS:
            return

        # Ausbruch-Erkennung: HW hat sich seit letztem Modify stark bewegt
        # → adaptiver Cooldown statt fester 8-Sekunden-Pause
        with self._lock:
            hw_ref  = self._hw_at_last_modify
            atr_fb  = max(self._atr, _ATR_MIN) if self._atr is not None else None
        if hw_ref is not None and atr_fb is not None:
            hw_delta = abs(hw - hw_ref)
            breakout = hw_delta > _BREAKOUT_ATR_FACTOR * atr_fb
        else:
            breakout = False
        effective_cooldown = _FAST_COOLDOWN_S if breakout else _MODIFY_COOLDOWN_S
        if (time.time() - last_mod) < effective_cooldown:
            return

        with mt5_lock(timeout=2) as got:
            if not got:
                log.warning("MT5-Lock belegt — Trail-Tick übersprungen")
                return
            self._do_modify(sym, bid, ask, is_long, hw, ps, breakout=breakout)

    # ── SL-Berechnung + MT5 order_send ───────────────────────────────────────
    def _do_modify(self, sym: str, bid: float, ask: float,
                   is_long: bool, hw: float, ps: dict, breakout: bool = False):
        # Eingefrorener TF/Mult vom Aktivieren — kein Wechsel mitten im Trade
        with self._lock:
            tf   = self._active_tf
            mult = self._trade_mult
        if tf is None:
            tf = self._choose_atr_tf()
        if not self._refresh_atr(sym, tf):
            return

        with self._lock:
            raw_atr = self._atr
            atr  = max(raw_atr, _ATR_MIN)
            if mult is None:
                mult = self._trail_mult()
        if raw_atr < _ATR_MIN:
            # gedrosselt (max. 1×/5 min) — lief vorher je Modify-Tick und flutete
            # das Log (~35k Zeilen je 5-MB-Rotation in Niedrig-Vola-Phasen)
            now_ts = time.time()
            if now_ts - getattr(self, "_floor_warn_ts", 0.0) > 300:
                self._floor_warn_ts = now_ts
                log.warning(f"ATR({raw_atr:.4f}) < Floor({_ATR_MIN}) — Trailing nutzt Minimum")

        positions = mt5.positions_get(ticket=ps["ticket"])
        if not positions:
            return
        pos = positions[0]
        si  = mt5.symbol_info(sym)
        if not si:
            return

        cur      = bid if is_long else ask
        entry    = float(pos.price_open)
        cur_sl   = float(pos.sl) if pos.sl else 0.0
        cur_tp   = float(pos.tp) if pos.tp else 0.0
        # min_dist: Broker-Mindestabstand + Spread + 5 Punkte Puffer, mindestens 1 Cent
        spread   = getattr(si, "spread", 0) or 0
        min_dist = max((si.trade_stops_level + spread + 5) * si.point, 0.01)
        profit   = (cur - entry) if is_long else (entry - cur)

        # ── Manuelle SL/TP-Übersteuerung erkennen (Fix 2026-07-17) ───────────────
        # Hat jemand SL/TP EXTERN geändert — v. a. direkt im MT5-Terminal, wo der
        # App-Pfad `deactivate()` NICHT greift — d. h. der Broker-Wert weicht von dem
        # ab, was das Trailing zuletzt gesetzt hat? Dann Finger weg: Trailing abschalten,
        # sonst überschreibt es die Handeingabe (real: TP 90,000 → 81,484). Erst ab dem
        # 2. Modify aktiv (`_last_*` gesetzt); Reset bei Aktivierung verhindert Altwert-
        # Fehlalarme. Deckt sich mit dem App-Verhalten (manuelles SL/TP = Trailing aus).
        with self._lock:
            last_sl = self._last_sl
            last_tp = self._last_tp
        tol = max(_SL_THRESHOLD_PTS * si.point, atr * _SL_THRESHOLD_ATR)
        ext_sl = last_sl is not None and cur_sl and abs(cur_sl - last_sl) > tol
        ext_tp = last_tp is not None and cur_tp and abs(cur_tp - last_tp) > tol
        if ext_sl or ext_tp:
            with self._lock:
                self.enabled            = False
                self._high_water        = None
                self._phase             = "—"
                self._hw_at_last_modify = None
                self._active_tf         = None
                self._trade_mult        = None
                self._partial_done      = False
            log.info(f"Manuelle SL/TP-Änderung erkannt (SL {cur_sl:.{si.digits}f}"
                     f"≠{(last_sl or 0):.{si.digits}f} · TP {cur_tp:.{si.digits}f}"
                     f"≠{(last_tp or 0):.{si.digits}f}) → Trailing abgeschaltet, "
                     f"Handeingabe bleibt stehen")
            return

        # Gedrosselt (1×/30 s): lief je Modify-Tick → ~28k Zeilen je Log-Rotation,
        # die 5×5-MB-Rotation deckte nur noch ~2 Tage Forensik ab (Fix 2026-07-19)
        now_dbg = time.time()
        if now_dbg - getattr(self, "_dbg_ts", 0.0) > 30:
            self._dbg_ts = now_dbg
            log.debug(
                f"Trail: profit={profit:.3f}  ATR={atr:.4f}×{mult:.1f}"
                f"  HW={hw:.3f}  SL={cur_sl:.5f}  phase={self._phase}"
                + ("  [AUSBRUCH]" if breakout else ""))

        # ── Teil-Exit / Runner: einmalig die Hälfte bei 1.5×ATR sichern ──────
        # Bankt realen Gewinn ohne den Trade abzuwürgen — der Rest läuft mit
        # dem weiten ATR-Trail weiter (Breakeven ist bei 1.5×ATR bereits aktiv,
        # der Runner ist damit risikofrei).
        with self._lock:
            partial_done = self._partial_done
        if _PARTIAL_TP_FRAC > 0 and not partial_done and profit >= _PARTIAL_TP_ATR * atr:
            vol, info = self.trader.partial_close_position(
                pos, si, _PARTIAL_TP_FRAC, "partial")
            if vol > 0:
                with self._lock:
                    self._partial_done = True
                log.info(f"[Teil-Exit] {vol:.2f}L @ {info:.3f} gesichert "
                         f"(Profit {profit:.3f} ≥ {_PARTIAL_TP_ATR}×ATR)")
                if self._notify:
                    try:
                        self._notify("partial", vol, float(info), profit)
                    except Exception as e:
                        log.warning(f"Teil-Exit-Notify: {e}")
            else:
                # nur einmal pro Trade versuchen, sonst Log-Spam bei vmin-Pos
                with self._lock:
                    self._partial_done = True
                log.info(f"[Teil-Exit] übersprungen: {info}")

        # ── Drei Phasen (mit Ratsche: nie zurück) ────────────────────────────
        if profit < _TRAIL_START_ATR * atr:
            phase = "Init"
        elif profit < _PHASE4_ATR * atr:
            phase = "Trail"
        else:
            phase = "Lock"
        # Phasen-Ratsche: ATR-Drift (neue Kerze) darf die Phase nicht
        # zurückwerfen — sonst friert das Trailing in Init wieder ein
        with self._lock:
            prev_phase = self._phase
        if _PHASE_RANK.get(prev_phase, -1) > _PHASE_RANK.get(phase, 0):
            phase = prev_phase

        # ── Time-Stop: nach _TIMESTOP_MIN Minuten noch in "Init" (nie ≥0,3×ATR
        # gelaufen = Whipsaw-Opfer) → schließen statt bis SL bluten. Dank Phasen-
        # Ratsche ist phase=="Init" exakt "HW-Profit erreichte nie den Trail-Start".
        if phase == "Init" and _TIMESTOP_MIN > 0 and time.time() >= self._no_close_until:
            age_s = time.time() - (pos.time - self.trader._broker_offset_s())
            if age_s >= _TIMESTOP_MIN * 60:
                lots = ps.get("lots", 0.0)
                log.info(f"⏱ Time-Stop: {age_s/60:.0f} min ohne Fortschritt "
                         f"(Phase Init, Profit {profit:+.3f}) → Position schließen")
                err = self.trader.close(reason="timestop")
                if err:
                    log.warning(f"Time-Stop-Close fehlgeschlagen: {err}")
                else:
                    with self._lock:
                        self.enabled            = False
                        self._high_water        = None
                        self._phase             = "—"
                        self._hw_at_last_modify = None
                        self._setup             = None
                        self._active_tf         = None
                        self._trade_mult        = None
                        self._partial_done      = False
                return

        if phase == "Init":
            if cur_sl:
                new_sl = cur_sl          # Initial-SL beibehalten
            else:
                if is_long:
                    new_sl = max(entry - mult * atr, 0.001)
                else:
                    new_sl = entry + mult * atr

        elif phase == "Trail":
            # Breakeven-Floor (SL = Entry) erst ab 1.0×ATR Profit — vorher
            # stoppte jeder normale Rückläufer zum Entry mit ±0 aus (+0.08-Closes)
            breakeven = profit >= _BREAKEVEN_ATR * atr
            if is_long:
                candidate = hw - mult * atr
                if breakeven:
                    candidate = max(candidate, entry)
                candidate = max(candidate, entry - mult * atr)
                new_sl = max(candidate, cur_sl) if cur_sl else candidate
            else:
                candidate = hw + mult * atr
                if breakeven:
                    candidate = min(candidate, entry)
                candidate = min(candidate, entry + mult * atr)
                new_sl = min(candidate, cur_sl) if cur_sl else candidate

        else:
            tight_mult = max(_PHASE4_MULT_MIN, mult * _PHASE4_MULT_SCALE)
            if is_long:
                new_sl = max(hw - tight_mult * atr, entry, cur_sl)
            else:
                candidate = hw + tight_mult * atr
                new_sl = min(candidate, entry, cur_sl) if cur_sl \
                         else min(candidate, entry)
                new_sl = min(new_sl, entry + tight_mult * atr)

        # ── Mindest-Abstand zum aktuellen Kurs (inkl. Spread) ────────────────
        if is_long:
            new_sl = min(new_sl, cur - min_dist)
        else:
            new_sl = max(new_sl, cur + min_dist)
        new_sl = max(round(new_sl, si.digits), 0.001)

        # ── Phase speichern ───────────────────────────────────────────────────
        with self._lock:
            self._phase = phase

        # ── Schwellwert und SL-Qualifizierung ────────────────────────────────
        threshold = max(_SL_THRESHOLD_PTS * si.point, atr * _SL_THRESHOLD_ATR)
        if is_long:
            sl_ok = new_sl > cur_sl + threshold
        else:
            sl_ok = (not cur_sl) or (new_sl < cur_sl - threshold)
        tp_missing = (cur_tp == 0.0)

        # ── Trailing TP: kurz unter dem High-Water-Mark nachziehen ───────────
        if phase == "Init":
            # Phase 1: kein Trailing — Trade braucht Luft (fester Puffer vom Entry)
            tp_send = entry + _TP_INIT_ATR * atr if is_long else entry - _TP_INIT_ATR * atr
        elif phase == "Lock":
            # Phase 3: sehr enges Trailing direkt am HW (0.3×ATR Abstand)
            tp_send = hw - _TP_LOCK_ATR * atr if is_long else hw + _TP_LOCK_ATR * atr
        else:
            # Phase 2 (Trail): TP zieht mit HW mit (0.5×ATR unter dem Hoch)
            tp_send = hw - _TP_TRAIL_ATR * atr if is_long else hw + _TP_TRAIL_ATR * atr

        # Mindest-TP: 0.5×ATR über/unter Entry — nicht in Verlust schließen
        if is_long:
            tp_send = max(tp_send, entry + 0.5 * atr)
        else:
            tp_send = min(tp_send, entry - 0.5 * atr)

        # Broker-Mindestabstand zum aktuellen Kurs einhalten
        if is_long and tp_send <= cur + min_dist:
            tp_send = cur + min_dist * 2
        elif not is_long and tp_send >= cur - min_dist:
            tp_send = cur - min_dist * 2
        tp_send = round(tp_send, si.digits)

        # TP-Ratsche nur in Init/Trail (nie zurückbewegen). In Phase Lock darf
        # der TP näher an den Kurs rücken — Gewinnsicherung, sonst bleibt der
        # weite Init-TP für immer stehen und das Lock-TP greift nie.
        if cur_tp and phase != "Lock":
            if is_long and cur_tp > tp_send:
                tp_send = cur_tp
            elif not is_long and cur_tp < tp_send:
                tp_send = cur_tp

        # TP-Trailing: signifikante Änderung auch ohne SL-Änderung senden
        # (abs: in Lock zählt auch die Annäherung als Verbesserung)
        tp_improvement = bool(cur_tp) and not tp_missing \
                         and abs(tp_send - cur_tp) > threshold

        if not sl_ok and not tp_missing and not tp_improvement:
            return
        # Wenn nur TP verbessert: SL unverändert lassen
        sl_to_send = new_sl if sl_ok else cur_sl

        # ── order_send ────────────────────────────────────────────────────────
        res = mt5.order_send({
            "action":   mt5.TRADE_ACTION_SLTP,
            "symbol":   sym,
            "position": pos.ticket,
            "sl":       sl_to_send,
            "tp":       tp_send,
        })
        if res and res.retcode == mt5.TRADE_RETCODE_DONE:
            with self._lock:
                self._last_sl           = sl_to_send
                self._last_tp           = tp_send
                self._last_modify_ts    = time.time()
                self._hw_at_last_modify = hw
            improvement = abs(sl_to_send - cur_sl) if cur_sl else abs(sl_to_send - entry)
            tp_log = ""
            if tp_missing:
                tp_log = f"  TP={tp_send:.{si.digits}f} (neu)"
            elif tp_improvement:
                tp_log = f"  TP {cur_tp:.{si.digits}f}→{tp_send:.{si.digits}f}"
            log.info(
                f"[{phase}{'*' if breakout else ''}]"
                f" SL {cur_sl:.{si.digits}f}→{sl_to_send:.{si.digits}f}"
                f"  Δ{improvement:.{si.digits}f}"
                f"  HW={hw:.3f}  ATR={atr:.4f}×{mult:.1f}"
                f"  profit={profit:.3f}{tp_log}")
        else:
            rc = res.retcode if res else "None"
            log.error(f"SLTP-Fehler rc={rc}  SL={sl_to_send}  TP={tp_send}")
            with self._lock:
                self._last_modify_ts = time.time() - _MODIFY_COOLDOWN_S + _ERROR_COOLDOWN_S

    def deactivate(self) -> bool:
        """Trailing sofort abschalten (z. B. bei manuellem SL/TP durch den User —
        sonst überschriebe das Trailing den Wert beim nächsten Tick). Rückgabe =
        ob es vorher aktiv war."""
        with self._lock:
            was = self.enabled
            self.enabled            = False
            self._high_water        = None
            self._ticks             = 0
            self._phase             = "—"
            self._hw_at_last_modify = None
            self._active_tf         = None
            self._trade_mult        = None
            self._partial_done      = False
        return was

    # ── Toggle ────────────────────────────────────────────────────────────────
    def toggle(self, sym: str) -> bool:
        with self._lock:
            new_state = not self.enabled

        if new_state:
            tf = self._choose_atr_tf()
            with mt5_lock() as got:
                if got:
                    self._refresh_atr(sym, tf)
            with self._lock:
                self.enabled            = True
                self._high_water        = None
                self._ticks             = 0
                self._phase             = "Init"
                # Kurze Wartezeit (2s) statt vollen Cooldown (8s) nach Aktivierung
                self._last_modify_ts    = time.time() - _MODIFY_COOLDOWN_S + _ACTIVATE_DELAY_S
                self._hw_at_last_modify = None
                # TF + Mult für den gesamten Trade einfrieren — die Auto-Wahl
                # wechselte sonst mitten im Trade und warf die Phase zurück
                self._active_tf         = tf
                self._trade_mult        = self._trail_mult()
                self._partial_done      = False
                # Referenz für die Extern-Änderungs-Erkennung neu starten — sonst
                # würde der Alt-SL/-TP des Vortrades als „manuelle Änderung" gelesen
                self._last_sl           = None
                self._last_tp           = None
                atr  = self._atr
                mult = self._trade_mult
            effective_atr = max(atr, _ATR_MIN) if atr else None
            log.info(
                f"AKTIVIERT  ATR={effective_atr:.4f} ({_TF_LABELS.get(tf, '?')})"
                f"  mult={mult:.1f}x" if effective_atr
                else "AKTIVIERT  (ATR wird beim ersten Tick berechnet)")
            return True
        else:
            with self._lock:
                self.enabled            = False
                self._high_water        = None
                self._ticks             = 0
                self._phase             = "—"
                self._hw_at_last_modify = None
                self._active_tf         = None
                self._trade_mult        = None
                self._partial_done      = False
            log.info("DEAKTIVIERT")
            return False

    # ── Snapshot für UI ───────────────────────────────────────────────────────
    def snapshot(self) -> dict:
        with self._lock:
            raw_atr = self._atr
            tf      = self._atr_tf
            override = self._atr_tf_override
            return {
                "enabled":          self.enabled,
                "atr":              max(raw_atr, _ATR_MIN) if raw_atr else None,
                "atr_raw":          raw_atr,
                "atr_tf":           _TF_LABELS.get(tf, "?"),
                "atr_tf_override":  _TF_LABELS.get(override) if override is not None else None,
                "high_water":   self._high_water,
                "last_sl":      self._last_sl,
                "last_tp":      self._last_tp,
                "trail_mult":   self._trade_mult if self._trade_mult is not None
                                else self._trail_mult(),
                "phase":        self._phase,
            }
