Files
Axel HocksandClaude Opus 5 437ab56598 Trailing tat nichts - beide Gruende waren richtig, nur unsichtbar (v=203)
User: "trailing scheint nicht zu funktionieren wenn man es einmal manuell
aktiviert hat und dann wieder einschaltet."

AM LOG UND AM ZUSTAND NACHGEPRUEFT - es lief die ganze Zeit:
  19:30:52  Manuelle SL/TP-Aenderung erkannt (SL 87.297) -> Trailing abgeschaltet
  19:31:01  AKTIVIERT  ATR=0.5689 (M30)  mult=1.0x
  danach     phase=Init, HW 87.39, enabled=true, trail_mult=1.0
`toggle()` setzt alles sauber zurueck (High-Water, Phase, _last_sl/_last_tp,
TF und Mult werden neu eingefroren). Es war KEIN Defekt.

WARUM ES TROTZDEM NICHTS TAT - zwei Gruende, beide korrekt:
(a) Phase "Init": der Trail springt erst ab `trail_start` an, und der steht
    seit HEUTE auf 1,3xATR (war 0,3). Der Gewinn lag bei 0,13xATR - also
    Faktor 10 zu frueh. Wer die alte Einstellung gewohnt ist, erwartet den
    Trail zehnmal frueher als er kommt.
(b) Er lockert einen Stop NIE. Nach der Handeingabe lag der SL bei 87.300,
    der Trail haette HW-1,0xATR = 86.821 gewollt - deutlich schlechter. Also
    zu Recht keine Aenderung. Um 87.300 zu schlagen, braeuchte es HW >= 87.869.

DER EIGENTLICHE MANGEL WAR DIE SICHTBARKEIT: die Trailing-KARTE ist seit dem
22.07. ausgeblendet (User-Vorgabe). Damit gab es KEINE Stelle mehr, an der man
Phase, High-Water oder den gewollten Stop sehen konnte - "es tut nichts" und
"es wartet zu Recht" sahen identisch aus.

GEBAUT: `engine._trail_info()` -> Snapshot `trail_info` -> Zeile `#trail-note`
in der Meldungen-Karte. Live:
  "Trailing wartet - springt ab 1.3xATR Gewinn an (jetzt 0.38x, also ab Kurs
   88.054)."
Drei weitere Zustaende: "Trailing AUS - nur der Broker-SL schuetzt", "laeuft,
zieht aber NICHT nach: sein Stop waere X, der gesetzte Y ist enger", und
"laeuft - zieht den Stop auf X nach".
⚠ Nur ZUSAMMENGESETZT aus dem, was der Snapshot ohnehin enthaelt - kein
zweiter Rechenweg (der Konsens-Pfeil-Fehler).

⚠⚠ BEINAHE-FEHLER beim Bau, gefangen: der Block landete in `renderKontext(snap)`,
mein Code las aber `d.trail_info`. Ein ReferenceError haette den GESAMTEN Render
abgebrochen (Dashboard friert auf Altwerten ein) - genau die Klasse, die am
01.08. schon einmal zugeschlagen hat. `node --check` sieht das NICHT. Gefunden,
weil ich die umgebende Funktion nachgelesen habe, und danach mit einem
Minimal-DOM gegengeprueft (snap / leer / null - alle drei laufen durch).

NEBENBEI: der Kommentar an `_TRAIL_START_ATR` sagte weiterhin "0,3", der Wert
ist 1,3. Genau diese Zahl macht den Unterschied, um den es hier geht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 19:36:30 +02:00

761 lines
38 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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,31,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 # 1,3 (war 0,3 bis 2026-08-20) — Phase 1→2:
# ab hier HW-Trailing. ⚠ Der Wert lebt in
# `exit_model.LIVE`, hier steht nur der
# Verweis — die Zahl im Kommentar war
# nach der Aenderung stale.
_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.21.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.54.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
# ⚠⚠ FEST AUF M5 (2026-08-20) — DEPLOYMENT-DRIFT BEHOBEN.
# `backtest_trailmult.py` misst `mult = 1,0` auf M5-Bars mit M5-ATR.
# Live kam der ATR bis heute aus der WELLEN-Zeitebene, und die wechselt.
# Gemessen an 90 echten Aktivierungen:
# M5 Ø ATR 0,1877 (n=51) → 1,00× wie validiert
# M15 Ø ATR 0,2586 (n= 9) → 1,38×
# M30 Ø ATR 0,4751 (n=30) → 2,53×
# In 43 % der Faelle lief der Trail also mit einem Abstand, der NIE
# gemessen wurde — auf M30 entspricht `mult 1,0` effektiv ≈2,5 in
# M5-Einheiten, und 2,5 ist gemessen KLAR schlechter (H1 ΣR 1174
# gegen 73 bei 1,0).
# ⚠ Am 31.07. wurde `_MULT_BY_TF` genau gegen diese Fehlerklasse auf
# durchgehend 1,0 geglaettet. Der Multiplikator war aber nie die
# Ursache — es ist der ATR. Die Variabilitaet kam durch die Hintertuer
# zurueck; das hier schliesst sie.
# ⚠ `_atr_tf_override` bleibt unberuehrt: `engine` setzt ihn bewusst
# (u. a. fuer Squeeze-Trades), und das ist eine EXPLIZITE Vorgabe, kein
# Automatismus.
# ZURUECK auf das alte Verhalten: die vier Zeilen unten einkommentieren.
return mt5.TIMEFRAME_M5
# 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,
}