""" 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, }