Files
AH-Oil-Trader/core/trailing.py
T
Axel HocksandClaude Opus 5 97617cd81d Deployment-Drift: Stufe 1 (Trail-mult) + Stufe 2 (Entscheidungs-Telemetrie)
Behebt die strukturelle Schwachstelle, die heute dreimal zugeschlagen hat:
Komponenten werden unter anderen Bedingungen BETRIEBEN als VALIDIERT.
Track B schuetzt gegen Overfitting, nicht gegen Deployment-Drift.

STUFE 1 (core/trailing.py): _MULT_BY_TF durchgehend 1,5 (vorher M15 2,0 /
M30 2,5 / H1 3,0) und _MULT_BREAKOUT_ADD 0,5 -> 0,0. Die Staffelung war nie
gemessen; backtest_trailing.py validiert nur 1,5 und die breiteren Werte
sind dort messbar schlechter (80k Bars, 2 Halbjahre):
    mult 1,5  H1 SigmaR  -249 · H2 +1252 · Worst -1,50
    mult 2,0  H1 SigmaR  -412 · H2 +1187 · Worst -2,00
    mult 2,5  H1 SigmaR -1367 · H2 +1450 · Worst -2,50
Auf M30 fuhr der Bot also 5,5x schlechteres H1 und einen 67 % groesseren
Einzelverlust; bei Breakouts waren es 3,0 - nie getestet. Relevant, weil die
TF-Heuristik oft auf M15/M30 landet (31.07.: 19 von 33 Phasen).

STUFE 2a (wave_rec/history/engine): jede Empfehlung loggt einen
maschinenlesbaren block_reason - deadband, dead_hour, eia, htf_counter,
stretch, min_conf, breakout_pending, entry_room, no_data, stale. DB migriert
(Backup angelegt). Ohne den war nicht feststellbar, WELCHES der Gates die
93 % WARTEN erzeugt (Backtest-Erwartung ~43 %).

STUFE 2b (analyze_divergence.py, NEU): haelt vier Dinge gegen die
Backtest-Erwartung - A Signal-Verteilung, B Gate-Anteile inkl.
"im Backtest modelliert?", C TF-Wechsel-Rate, D0 Modell-Kalibrierung,
D Merkmals-Drift gegen _PB_MU/_SD.

LEHRE AUS DEM BAU SELBST: die Merkmals-Drift (D) haette den realen Fehler
NICHT gefangen - mom3 lag nur 0,50 Sigma unter mu, bei Gewicht 1,43 wurden
daraus aber -0,71 im Logit (23 % statt 41 % vorhergesagt). Deshalb ist D0
der schaerfere Test und die D-Schwelle auf 0,5 Sigma gesetzt.

Erster Lauf nach dem Fix bestaetigt das Nachtraining live:
D0 = 27,0 % vorhergesagt vs 28,0 % real (vorher 23 vs 41).

Stufe 3 (geteilter Exit-Kern) bleibt offen, spaeter und einzeln.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 12:54:34 +02:00

733 lines
36 KiB
Python
Raw 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.
_TRAIL_START_ATR = 0.3 # Phase 1→2: ab diesem Profit startet HW-Trailing
_BREAKEVEN_ATR = 1.3 # Entry-Floor (Breakeven) ab diesem Profit. 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 = 3.5 # Phase 2→3: ab hier engeres Trailing zum Lock-In
_PHASE4_MULT_SCALE = 0.6 # Multiplikator-Faktor in Phase 3
_PHASE4_MULT_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.
_MULT_BY_TF = {
mt5.TIMEFRAME_M1: 1.5,
mt5.TIMEFRAME_M5: 1.5,
mt5.TIMEFRAME_M15: 1.5,
mt5.TIMEFRAME_M30: 1.5,
mt5.TIMEFRAME_H1: 1.5,
}
# 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
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,
}