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>
761 lines
38 KiB
Python
761 lines
38 KiB
Python
"""
|
||
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 # 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.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
|
||
# ⚠⚠ 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,
|
||
}
|