Headless FastAPI-Backend (server.py + core/engine.py) mit Mobile-PWA (web/), Strategie-/Backtest-Suite und Doku. Secrets, DB, Logs und Laufzeit-State sind via .gitignore ausgeschlossen; Config-Vorlage: oil_widget_config.ini.example. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
718 lines
35 KiB
Python
718 lines
35 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.
|
||
_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.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: niedrige TF (Scalp) → enger Stop,
|
||
# hohe TF (Trend) → mehr Puffer. Ersetzt die alte 2.5–4.5-Logik.
|
||
_MULT_BY_TF = {
|
||
mt5.TIMEFRAME_M1: 1.5,
|
||
mt5.TIMEFRAME_M5: 1.5,
|
||
mt5.TIMEFRAME_M15: 2.0,
|
||
mt5.TIMEFRAME_M30: 2.5,
|
||
mt5.TIMEFRAME_H1: 3.0,
|
||
}
|
||
_MULT_BREAKOUT_ADD = 0.5 # Breakouts brauchen etwas mehr Luft (gedeckelt)
|
||
|
||
_PHASE_RANK = {"Init": 0, "Trail": 1, "Lock": 2}
|
||
|
||
|
||
class TrailingManager:
|
||
"""Tick-basiertes Trailing-Stop-System mit M15-ATR als Abstandsmaß."""
|
||
|
||
def __init__(self, trader):
|
||
self.trader = trader
|
||
self.enabled = False
|
||
|
||
self._atr: float | None = None # aktiver ATR (vom gewählten TF)
|
||
self._atr_tf: int = _TF_RANGE # aktuell genutzter Timeframe
|
||
self._atr_tf_override: int | None = None # None = Auto
|
||
self._atr_cache: dict = {} # {tf: atr_value}
|
||
self._atr_bar_times: dict = {} # {tf: last_bar_time}
|
||
self._high_water: float | None = None
|
||
self._last_sl: float | None = None
|
||
self._last_tp: float | None = None
|
||
self._last_modify_ts: float = 0.0
|
||
self._last_idle_atr_ts: float = 0.0 # ATR-Refresh wenn Trailing aus
|
||
self._ticks: int = 0
|
||
self._sr_data: dict | None = None
|
||
self._trend_angle: float | None = None
|
||
self._phase: str = "—"
|
||
self._hw_at_last_modify: float | None = None # HW-Stand beim letzten Modify
|
||
self._setup: str | None = None # aktives Setup (für Mult-Wahl)
|
||
self._active_tf: int | None = None # beim Aktivieren eingefrorener ATR-TF
|
||
self._trade_mult: float | None = None # beim Aktivieren eingefrorener Multiplikator
|
||
self._partial_done: bool = False # Teil-Exit pro Trade nur einmal
|
||
self._notify = None # optionaler Callback (Telegram)
|
||
self._no_close_until: float = 0.0 # Startup-Schonfrist (von der Engine gesetzt):
|
||
# bis dahin kein Time-Stop-Close nach Neustart
|
||
self._lock = threading.Lock()
|
||
|
||
def set_notify(self, fn):
|
||
"""Callback fn(event, volume, price, profit) für Teil-Exit-Meldungen."""
|
||
self._notify = fn
|
||
|
||
def set_no_close_until(self, ts: float):
|
||
"""Startup-Schonfrist: bis zu diesem Zeitstempel wird KEIN Time-Stop-Close
|
||
ausgelöst (die Engine setzt das beim Start — verhindert, dass ein nach
|
||
Neustart adoptierter Alt-Trade sofort per Time-Stop geschlossen wird)."""
|
||
self._no_close_until = ts or 0.0
|
||
|
||
# ── Setter von außen ──────────────────────────────────────────────────────
|
||
def set_atr_tf_override(self, tf: int | None):
|
||
"""Setzt den ATR-Timeframe (vom Widget an die Wellen-/Agent-TF gekoppelt).
|
||
None = Fallback auf Trendstärke-Wahl. Ein laufender Trade behält seinen
|
||
beim Aktivieren eingefrorenen TF — die neue Wahl greift erst beim nächsten
|
||
Trade (verhindert ATR-Sprünge/Phasen-Rückfall mitten im Trade)."""
|
||
with self._lock:
|
||
self._atr_tf_override = tf
|
||
label = _TF_LABELS.get(tf, "?") if tf is not None else "Auto"
|
||
log.info(f"ATR-TF gesetzt: {label}")
|
||
|
||
def set_sr(self, sr_data: dict | None):
|
||
with self._lock:
|
||
self._sr_data = sr_data
|
||
|
||
def set_trend_angle(self, angle: float):
|
||
with self._lock:
|
||
self._trend_angle = angle
|
||
|
||
def set_setup(self, setup: str | None):
|
||
"""Aktuelles Setup setzen — beeinflusst SL-Multiplikator."""
|
||
with self._lock:
|
||
self._setup = setup
|
||
|
||
# ── ATR-Multiplikator (Timeframe-basiert) ─────────────────────────────────
|
||
def _trail_mult(self) -> float:
|
||
"""Muss unter self._lock aufgerufen werden. Stop-Distanz richtet sich
|
||
nach dem Trade-Timeframe: Scalp-TF (M1/M5) → eng, Trend-TF (H1) → weit.
|
||
So passt der Stop zum tatsächlichen Trade-Horizont (vorher pauschal
|
||
2.5–4.5, viel zu weit für M1/M5-Scalps)."""
|
||
tf = self._active_tf or self._atr_tf
|
||
base = _MULT_BY_TF.get(tf, 2.0)
|
||
if self._setup and "BREAKOUT" in self._setup:
|
||
base += _MULT_BREAKOUT_ADD
|
||
return base
|
||
|
||
# ── Automatische ATR-Timeframe-Wahl ──────────────────────────────────────
|
||
def _choose_atr_tf(self) -> int:
|
||
"""Wählt ATR-Timeframe: Override wenn gesetzt, sonst automatisch nach Trendstärke."""
|
||
with self._lock:
|
||
override = self._atr_tf_override
|
||
angle = self._trend_angle
|
||
if override is not None:
|
||
return override
|
||
if angle is None:
|
||
return _TF_RANGE
|
||
strength = abs(angle - 90.0)
|
||
if strength > 45:
|
||
return _TF_STRONG # starker Trend → H1
|
||
elif strength > 20:
|
||
return _TF_MODERATE # moderater Trend → M30
|
||
return _TF_RANGE # Seitwärts → M15
|
||
|
||
# ── ATR (gecacht, refresht nur bei neuer Kerze des gewählten TF) ─────────
|
||
def _refresh_atr(self, sym: str, tf: int) -> bool:
|
||
"""Muss unter aktivem MT5-Lock aufgerufen werden."""
|
||
bars = mt5.copy_rates_from_pos(sym, tf, 0, _ATR_PERIOD + 2)
|
||
if bars is None or len(bars) < _ATR_PERIOD + 1:
|
||
# Fallback: gecachten Wert für diesen TF weiterverwenden
|
||
cached = self._atr_cache.get(tf)
|
||
if cached is not None:
|
||
self._atr = cached
|
||
return True
|
||
return self._atr is not None
|
||
|
||
last_bar_time = int(bars[-2]["time"]) # -2 = letzte abgeschlossene Kerze
|
||
if last_bar_time == self._atr_bar_times.get(tf) and self._atr_cache.get(tf) is not None:
|
||
self._atr = self._atr_cache[tf]
|
||
return True
|
||
|
||
trs = []
|
||
for i in range(1, len(bars) - 1): # aktuelle (offene) Kerze weglassen
|
||
h = float(bars[i]["high"])
|
||
l = float(bars[i]["low"])
|
||
c = float(bars[i - 1]["close"])
|
||
trs.append(max(h - l, abs(h - c), abs(l - c)))
|
||
if trs:
|
||
atr_val = sum(trs[-_ATR_PERIOD:]) / min(len(trs), _ATR_PERIOD)
|
||
self._atr_cache[tf] = atr_val
|
||
self._atr_bar_times[tf] = last_bar_time
|
||
prev_tf = self._atr_tf
|
||
self._atr = atr_val
|
||
self._atr_tf = tf
|
||
effective = max(atr_val, _ATR_MIN)
|
||
tf_label = _TF_LABELS.get(tf, str(tf))
|
||
floor_note = f" (Floor, raw={atr_val:.4f})" if atr_val < _ATR_MIN else ""
|
||
tf_note = (f" [TF: {_TF_LABELS.get(prev_tf, '?')}→{tf_label}]"
|
||
if prev_tf != tf else "")
|
||
log.info(f"ATR(14/{tf_label}) = {effective:.4f}{floor_note}{tf_note}")
|
||
return self._atr is not None
|
||
|
||
# ── Haupt-Tick (aufgerufen nach jedem Preis-Tick) ─────────────────────────
|
||
def on_price(self, sym: str, bid: float, ask: float):
|
||
with self._lock:
|
||
if not self.enabled:
|
||
# ATR-Anzeige: einmal pro Minute im Idle aktualisieren
|
||
now = time.time()
|
||
if now - self._last_idle_atr_ts > 60:
|
||
self._last_idle_atr_ts = now
|
||
else:
|
||
return
|
||
else:
|
||
self._ticks += 1
|
||
ticks = self._ticks
|
||
|
||
if not self.enabled:
|
||
tf = self._choose_atr_tf()
|
||
with mt5_lock(timeout=1) as got:
|
||
if got:
|
||
self._refresh_atr(sym, tf)
|
||
return
|
||
|
||
ticks = self._ticks
|
||
|
||
ps = self.trader.snapshot()
|
||
if ps["ticket"] is None:
|
||
with self._lock:
|
||
if self.enabled:
|
||
self.enabled = False
|
||
self._high_water = None
|
||
self._phase = "—"
|
||
self._hw_at_last_modify = None
|
||
self._setup = None
|
||
self._active_tf = None
|
||
self._trade_mult = None
|
||
self._partial_done = False
|
||
log.info("Auto-Deaktiviert (Position geschlossen)")
|
||
return
|
||
|
||
is_long = (ps["order_type"] == mt5.ORDER_TYPE_BUY)
|
||
cur = bid if is_long else ask
|
||
|
||
# High-Water-Mark: immer aktualisieren, ohne MT5-Call
|
||
with self._lock:
|
||
if self._high_water is None:
|
||
self._high_water = cur
|
||
elif is_long and cur > self._high_water:
|
||
self._high_water = cur
|
||
elif not is_long and cur < self._high_water:
|
||
self._high_water = cur
|
||
hw = self._high_water
|
||
last_mod = self._last_modify_ts
|
||
|
||
# ── Früh-Ausstieg: Trade läuft ≥ _ADVERSE_EXIT_ATR×ATR gegen den Einstieg ──
|
||
# Greift auch in der Init-Phase / Warmup, damit „läuft sofort schief"-Trades
|
||
# nicht bis zum weiten Initial-Stop ausbluten.
|
||
entry = ps.get("entry_price") or 0.0
|
||
with self._lock:
|
||
atr_ae = max(self._atr, _ATR_MIN) if self._atr is not None else None
|
||
if entry and atr_ae and _ADVERSE_EXIT_ATR > 0:
|
||
adverse = (entry - cur) if is_long else (cur - entry) # >0 = gegen Position
|
||
if adverse >= _ADVERSE_EXIT_ATR * atr_ae:
|
||
lots = ps.get("lots", 0.0)
|
||
log.warning(
|
||
f"🛑 Früh-Ausstieg: {adverse:.3f} ≥ {_ADVERSE_EXIT_ATR}×ATR"
|
||
f"({atr_ae:.3f}) gegen Einstieg {entry:.3f} → Position schließen")
|
||
err = self.trader.close(reason="adverse")
|
||
if err:
|
||
log.warning(f"Früh-Ausstieg-Close fehlgeschlagen: {err}")
|
||
else:
|
||
with self._lock:
|
||
self.enabled = False
|
||
self._high_water = None
|
||
self._phase = "—"
|
||
self._hw_at_last_modify = None
|
||
self._setup = None
|
||
self._active_tf = None
|
||
self._trade_mult = None
|
||
self._partial_done = False
|
||
if self._notify:
|
||
try: self._notify("adverse", lots, cur, -adverse)
|
||
except Exception as e: log.warning(f"Adverse-Notify: {e}")
|
||
return
|
||
|
||
if ticks <= _WARMUP_TICKS:
|
||
return
|
||
|
||
# Ausbruch-Erkennung: HW hat sich seit letztem Modify stark bewegt
|
||
# → adaptiver Cooldown statt fester 8-Sekunden-Pause
|
||
with self._lock:
|
||
hw_ref = self._hw_at_last_modify
|
||
atr_fb = max(self._atr, _ATR_MIN) if self._atr is not None else None
|
||
if hw_ref is not None and atr_fb is not None:
|
||
hw_delta = abs(hw - hw_ref)
|
||
breakout = hw_delta > _BREAKOUT_ATR_FACTOR * atr_fb
|
||
else:
|
||
breakout = False
|
||
effective_cooldown = _FAST_COOLDOWN_S if breakout else _MODIFY_COOLDOWN_S
|
||
if (time.time() - last_mod) < effective_cooldown:
|
||
return
|
||
|
||
with mt5_lock(timeout=2) as got:
|
||
if not got:
|
||
log.warning("MT5-Lock belegt — Trail-Tick übersprungen")
|
||
return
|
||
self._do_modify(sym, bid, ask, is_long, hw, ps, breakout=breakout)
|
||
|
||
# ── SL-Berechnung + MT5 order_send ───────────────────────────────────────
|
||
def _do_modify(self, sym: str, bid: float, ask: float,
|
||
is_long: bool, hw: float, ps: dict, breakout: bool = False):
|
||
# Eingefrorener TF/Mult vom Aktivieren — kein Wechsel mitten im Trade
|
||
with self._lock:
|
||
tf = self._active_tf
|
||
mult = self._trade_mult
|
||
if tf is None:
|
||
tf = self._choose_atr_tf()
|
||
if not self._refresh_atr(sym, tf):
|
||
return
|
||
|
||
with self._lock:
|
||
raw_atr = self._atr
|
||
atr = max(raw_atr, _ATR_MIN)
|
||
if mult is None:
|
||
mult = self._trail_mult()
|
||
if raw_atr < _ATR_MIN:
|
||
# gedrosselt (max. 1×/5 min) — lief vorher je Modify-Tick und flutete
|
||
# das Log (~35k Zeilen je 5-MB-Rotation in Niedrig-Vola-Phasen)
|
||
now_ts = time.time()
|
||
if now_ts - getattr(self, "_floor_warn_ts", 0.0) > 300:
|
||
self._floor_warn_ts = now_ts
|
||
log.warning(f"ATR({raw_atr:.4f}) < Floor({_ATR_MIN}) — Trailing nutzt Minimum")
|
||
|
||
positions = mt5.positions_get(ticket=ps["ticket"])
|
||
if not positions:
|
||
return
|
||
pos = positions[0]
|
||
si = mt5.symbol_info(sym)
|
||
if not si:
|
||
return
|
||
|
||
cur = bid if is_long else ask
|
||
entry = float(pos.price_open)
|
||
cur_sl = float(pos.sl) if pos.sl else 0.0
|
||
cur_tp = float(pos.tp) if pos.tp else 0.0
|
||
# min_dist: Broker-Mindestabstand + Spread + 5 Punkte Puffer, mindestens 1 Cent
|
||
spread = getattr(si, "spread", 0) or 0
|
||
min_dist = max((si.trade_stops_level + spread + 5) * si.point, 0.01)
|
||
profit = (cur - entry) if is_long else (entry - cur)
|
||
|
||
# ── Manuelle SL/TP-Übersteuerung erkennen (Fix 2026-07-17) ───────────────
|
||
# Hat jemand SL/TP EXTERN geändert — v. a. direkt im MT5-Terminal, wo der
|
||
# App-Pfad `deactivate()` NICHT greift — d. h. der Broker-Wert weicht von dem
|
||
# ab, was das Trailing zuletzt gesetzt hat? Dann Finger weg: Trailing abschalten,
|
||
# sonst überschreibt es die Handeingabe (real: TP 90,000 → 81,484). Erst ab dem
|
||
# 2. Modify aktiv (`_last_*` gesetzt); Reset bei Aktivierung verhindert Altwert-
|
||
# Fehlalarme. Deckt sich mit dem App-Verhalten (manuelles SL/TP = Trailing aus).
|
||
with self._lock:
|
||
last_sl = self._last_sl
|
||
last_tp = self._last_tp
|
||
tol = max(_SL_THRESHOLD_PTS * si.point, atr * _SL_THRESHOLD_ATR)
|
||
ext_sl = last_sl is not None and cur_sl and abs(cur_sl - last_sl) > tol
|
||
ext_tp = last_tp is not None and cur_tp and abs(cur_tp - last_tp) > tol
|
||
if ext_sl or ext_tp:
|
||
with self._lock:
|
||
self.enabled = False
|
||
self._high_water = None
|
||
self._phase = "—"
|
||
self._hw_at_last_modify = None
|
||
self._active_tf = None
|
||
self._trade_mult = None
|
||
self._partial_done = False
|
||
log.info(f"Manuelle SL/TP-Änderung erkannt (SL {cur_sl:.{si.digits}f}"
|
||
f"≠{(last_sl or 0):.{si.digits}f} · TP {cur_tp:.{si.digits}f}"
|
||
f"≠{(last_tp or 0):.{si.digits}f}) → Trailing abgeschaltet, "
|
||
f"Handeingabe bleibt stehen")
|
||
return
|
||
|
||
# Gedrosselt (1×/30 s): lief je Modify-Tick → ~28k Zeilen je Log-Rotation,
|
||
# die 5×5-MB-Rotation deckte nur noch ~2 Tage Forensik ab (Fix 2026-07-19)
|
||
now_dbg = time.time()
|
||
if now_dbg - getattr(self, "_dbg_ts", 0.0) > 30:
|
||
self._dbg_ts = now_dbg
|
||
log.debug(
|
||
f"Trail: profit={profit:.3f} ATR={atr:.4f}×{mult:.1f}"
|
||
f" HW={hw:.3f} SL={cur_sl:.5f} phase={self._phase}"
|
||
+ (" [AUSBRUCH]" if breakout else ""))
|
||
|
||
# ── Teil-Exit / Runner: einmalig die Hälfte bei 1.5×ATR sichern ──────
|
||
# Bankt realen Gewinn ohne den Trade abzuwürgen — der Rest läuft mit
|
||
# dem weiten ATR-Trail weiter (Breakeven ist bei 1.5×ATR bereits aktiv,
|
||
# der Runner ist damit risikofrei).
|
||
with self._lock:
|
||
partial_done = self._partial_done
|
||
if _PARTIAL_TP_FRAC > 0 and not partial_done and profit >= _PARTIAL_TP_ATR * atr:
|
||
vol, info = self.trader.partial_close_position(
|
||
pos, si, _PARTIAL_TP_FRAC, "partial")
|
||
if vol > 0:
|
||
with self._lock:
|
||
self._partial_done = True
|
||
log.info(f"[Teil-Exit] {vol:.2f}L @ {info:.3f} gesichert "
|
||
f"(Profit {profit:.3f} ≥ {_PARTIAL_TP_ATR}×ATR)")
|
||
if self._notify:
|
||
try:
|
||
self._notify("partial", vol, float(info), profit)
|
||
except Exception as e:
|
||
log.warning(f"Teil-Exit-Notify: {e}")
|
||
else:
|
||
# nur einmal pro Trade versuchen, sonst Log-Spam bei vmin-Pos
|
||
with self._lock:
|
||
self._partial_done = True
|
||
log.info(f"[Teil-Exit] übersprungen: {info}")
|
||
|
||
# ── Drei Phasen (mit Ratsche: nie zurück) ────────────────────────────
|
||
if profit < _TRAIL_START_ATR * atr:
|
||
phase = "Init"
|
||
elif profit < _PHASE4_ATR * atr:
|
||
phase = "Trail"
|
||
else:
|
||
phase = "Lock"
|
||
# Phasen-Ratsche: ATR-Drift (neue Kerze) darf die Phase nicht
|
||
# zurückwerfen — sonst friert das Trailing in Init wieder ein
|
||
with self._lock:
|
||
prev_phase = self._phase
|
||
if _PHASE_RANK.get(prev_phase, -1) > _PHASE_RANK.get(phase, 0):
|
||
phase = prev_phase
|
||
|
||
# ── Time-Stop: nach _TIMESTOP_MIN Minuten noch in "Init" (nie ≥0,3×ATR
|
||
# gelaufen = Whipsaw-Opfer) → schließen statt bis SL bluten. Dank Phasen-
|
||
# Ratsche ist phase=="Init" exakt "HW-Profit erreichte nie den Trail-Start".
|
||
if phase == "Init" and _TIMESTOP_MIN > 0 and time.time() >= self._no_close_until:
|
||
age_s = time.time() - (pos.time - self.trader._broker_offset_s())
|
||
if age_s >= _TIMESTOP_MIN * 60:
|
||
lots = ps.get("lots", 0.0)
|
||
log.info(f"⏱ Time-Stop: {age_s/60:.0f} min ohne Fortschritt "
|
||
f"(Phase Init, Profit {profit:+.3f}) → Position schließen")
|
||
err = self.trader.close(reason="timestop")
|
||
if err:
|
||
log.warning(f"Time-Stop-Close fehlgeschlagen: {err}")
|
||
else:
|
||
with self._lock:
|
||
self.enabled = False
|
||
self._high_water = None
|
||
self._phase = "—"
|
||
self._hw_at_last_modify = None
|
||
self._setup = None
|
||
self._active_tf = None
|
||
self._trade_mult = None
|
||
self._partial_done = False
|
||
return
|
||
|
||
if phase == "Init":
|
||
if cur_sl:
|
||
new_sl = cur_sl # Initial-SL beibehalten
|
||
else:
|
||
if is_long:
|
||
new_sl = max(entry - mult * atr, 0.001)
|
||
else:
|
||
new_sl = entry + mult * atr
|
||
|
||
elif phase == "Trail":
|
||
# Breakeven-Floor (SL = Entry) erst ab 1.0×ATR Profit — vorher
|
||
# stoppte jeder normale Rückläufer zum Entry mit ±0 aus (+0.08-Closes)
|
||
breakeven = profit >= _BREAKEVEN_ATR * atr
|
||
if is_long:
|
||
candidate = hw - mult * atr
|
||
if breakeven:
|
||
candidate = max(candidate, entry)
|
||
candidate = max(candidate, entry - mult * atr)
|
||
new_sl = max(candidate, cur_sl) if cur_sl else candidate
|
||
else:
|
||
candidate = hw + mult * atr
|
||
if breakeven:
|
||
candidate = min(candidate, entry)
|
||
candidate = min(candidate, entry + mult * atr)
|
||
new_sl = min(candidate, cur_sl) if cur_sl else candidate
|
||
|
||
else:
|
||
tight_mult = max(_PHASE4_MULT_MIN, mult * _PHASE4_MULT_SCALE)
|
||
if is_long:
|
||
new_sl = max(hw - tight_mult * atr, entry, cur_sl)
|
||
else:
|
||
candidate = hw + tight_mult * atr
|
||
new_sl = min(candidate, entry, cur_sl) if cur_sl \
|
||
else min(candidate, entry)
|
||
new_sl = min(new_sl, entry + tight_mult * atr)
|
||
|
||
# ── Mindest-Abstand zum aktuellen Kurs (inkl. Spread) ────────────────
|
||
if is_long:
|
||
new_sl = min(new_sl, cur - min_dist)
|
||
else:
|
||
new_sl = max(new_sl, cur + min_dist)
|
||
new_sl = max(round(new_sl, si.digits), 0.001)
|
||
|
||
# ── Phase speichern ───────────────────────────────────────────────────
|
||
with self._lock:
|
||
self._phase = phase
|
||
|
||
# ── Schwellwert und SL-Qualifizierung ────────────────────────────────
|
||
threshold = max(_SL_THRESHOLD_PTS * si.point, atr * _SL_THRESHOLD_ATR)
|
||
if is_long:
|
||
sl_ok = new_sl > cur_sl + threshold
|
||
else:
|
||
sl_ok = (not cur_sl) or (new_sl < cur_sl - threshold)
|
||
tp_missing = (cur_tp == 0.0)
|
||
|
||
# ── Trailing TP: kurz unter dem High-Water-Mark nachziehen ───────────
|
||
if phase == "Init":
|
||
# Phase 1: kein Trailing — Trade braucht Luft (fester Puffer vom Entry)
|
||
tp_send = entry + _TP_INIT_ATR * atr if is_long else entry - _TP_INIT_ATR * atr
|
||
elif phase == "Lock":
|
||
# Phase 3: sehr enges Trailing direkt am HW (0.3×ATR Abstand)
|
||
tp_send = hw - _TP_LOCK_ATR * atr if is_long else hw + _TP_LOCK_ATR * atr
|
||
else:
|
||
# Phase 2 (Trail): TP zieht mit HW mit (0.5×ATR unter dem Hoch)
|
||
tp_send = hw - _TP_TRAIL_ATR * atr if is_long else hw + _TP_TRAIL_ATR * atr
|
||
|
||
# Mindest-TP: 0.5×ATR über/unter Entry — nicht in Verlust schließen
|
||
if is_long:
|
||
tp_send = max(tp_send, entry + 0.5 * atr)
|
||
else:
|
||
tp_send = min(tp_send, entry - 0.5 * atr)
|
||
|
||
# Broker-Mindestabstand zum aktuellen Kurs einhalten
|
||
if is_long and tp_send <= cur + min_dist:
|
||
tp_send = cur + min_dist * 2
|
||
elif not is_long and tp_send >= cur - min_dist:
|
||
tp_send = cur - min_dist * 2
|
||
tp_send = round(tp_send, si.digits)
|
||
|
||
# TP-Ratsche nur in Init/Trail (nie zurückbewegen). In Phase Lock darf
|
||
# der TP näher an den Kurs rücken — Gewinnsicherung, sonst bleibt der
|
||
# weite Init-TP für immer stehen und das Lock-TP greift nie.
|
||
if cur_tp and phase != "Lock":
|
||
if is_long and cur_tp > tp_send:
|
||
tp_send = cur_tp
|
||
elif not is_long and cur_tp < tp_send:
|
||
tp_send = cur_tp
|
||
|
||
# TP-Trailing: signifikante Änderung auch ohne SL-Änderung senden
|
||
# (abs: in Lock zählt auch die Annäherung als Verbesserung)
|
||
tp_improvement = bool(cur_tp) and not tp_missing \
|
||
and abs(tp_send - cur_tp) > threshold
|
||
|
||
if not sl_ok and not tp_missing and not tp_improvement:
|
||
return
|
||
# Wenn nur TP verbessert: SL unverändert lassen
|
||
sl_to_send = new_sl if sl_ok else cur_sl
|
||
|
||
# ── order_send ────────────────────────────────────────────────────────
|
||
res = mt5.order_send({
|
||
"action": mt5.TRADE_ACTION_SLTP,
|
||
"symbol": sym,
|
||
"position": pos.ticket,
|
||
"sl": sl_to_send,
|
||
"tp": tp_send,
|
||
})
|
||
if res and res.retcode == mt5.TRADE_RETCODE_DONE:
|
||
with self._lock:
|
||
self._last_sl = sl_to_send
|
||
self._last_tp = tp_send
|
||
self._last_modify_ts = time.time()
|
||
self._hw_at_last_modify = hw
|
||
improvement = abs(sl_to_send - cur_sl) if cur_sl else abs(sl_to_send - entry)
|
||
tp_log = ""
|
||
if tp_missing:
|
||
tp_log = f" TP={tp_send:.{si.digits}f} (neu)"
|
||
elif tp_improvement:
|
||
tp_log = f" TP {cur_tp:.{si.digits}f}→{tp_send:.{si.digits}f}"
|
||
log.info(
|
||
f"[{phase}{'*' if breakout else ''}]"
|
||
f" SL {cur_sl:.{si.digits}f}→{sl_to_send:.{si.digits}f}"
|
||
f" Δ{improvement:.{si.digits}f}"
|
||
f" HW={hw:.3f} ATR={atr:.4f}×{mult:.1f}"
|
||
f" profit={profit:.3f}{tp_log}")
|
||
else:
|
||
rc = res.retcode if res else "None"
|
||
log.error(f"SLTP-Fehler rc={rc} SL={sl_to_send} TP={tp_send}")
|
||
with self._lock:
|
||
self._last_modify_ts = time.time() - _MODIFY_COOLDOWN_S + _ERROR_COOLDOWN_S
|
||
|
||
def deactivate(self) -> bool:
|
||
"""Trailing sofort abschalten (z. B. bei manuellem SL/TP durch den User —
|
||
sonst überschriebe das Trailing den Wert beim nächsten Tick). Rückgabe =
|
||
ob es vorher aktiv war."""
|
||
with self._lock:
|
||
was = self.enabled
|
||
self.enabled = False
|
||
self._high_water = None
|
||
self._ticks = 0
|
||
self._phase = "—"
|
||
self._hw_at_last_modify = None
|
||
self._active_tf = None
|
||
self._trade_mult = None
|
||
self._partial_done = False
|
||
return was
|
||
|
||
# ── Toggle ────────────────────────────────────────────────────────────────
|
||
def toggle(self, sym: str) -> bool:
|
||
with self._lock:
|
||
new_state = not self.enabled
|
||
|
||
if new_state:
|
||
tf = self._choose_atr_tf()
|
||
with mt5_lock() as got:
|
||
if got:
|
||
self._refresh_atr(sym, tf)
|
||
with self._lock:
|
||
self.enabled = True
|
||
self._high_water = None
|
||
self._ticks = 0
|
||
self._phase = "Init"
|
||
# Kurze Wartezeit (2s) statt vollen Cooldown (8s) nach Aktivierung
|
||
self._last_modify_ts = time.time() - _MODIFY_COOLDOWN_S + _ACTIVATE_DELAY_S
|
||
self._hw_at_last_modify = None
|
||
# TF + Mult für den gesamten Trade einfrieren — die Auto-Wahl
|
||
# wechselte sonst mitten im Trade und warf die Phase zurück
|
||
self._active_tf = tf
|
||
self._trade_mult = self._trail_mult()
|
||
self._partial_done = False
|
||
# Referenz für die Extern-Änderungs-Erkennung neu starten — sonst
|
||
# würde der Alt-SL/-TP des Vortrades als „manuelle Änderung" gelesen
|
||
self._last_sl = None
|
||
self._last_tp = None
|
||
atr = self._atr
|
||
mult = self._trade_mult
|
||
effective_atr = max(atr, _ATR_MIN) if atr else None
|
||
log.info(
|
||
f"AKTIVIERT ATR={effective_atr:.4f} ({_TF_LABELS.get(tf, '?')})"
|
||
f" mult={mult:.1f}x" if effective_atr
|
||
else "AKTIVIERT (ATR wird beim ersten Tick berechnet)")
|
||
return True
|
||
else:
|
||
with self._lock:
|
||
self.enabled = False
|
||
self._high_water = None
|
||
self._ticks = 0
|
||
self._phase = "—"
|
||
self._hw_at_last_modify = None
|
||
self._active_tf = None
|
||
self._trade_mult = None
|
||
self._partial_done = False
|
||
log.info("DEAKTIVIERT")
|
||
return False
|
||
|
||
# ── Snapshot für UI ───────────────────────────────────────────────────────
|
||
def snapshot(self) -> dict:
|
||
with self._lock:
|
||
raw_atr = self._atr
|
||
tf = self._atr_tf
|
||
override = self._atr_tf_override
|
||
return {
|
||
"enabled": self.enabled,
|
||
"atr": max(raw_atr, _ATR_MIN) if raw_atr else None,
|
||
"atr_raw": raw_atr,
|
||
"atr_tf": _TF_LABELS.get(tf, "?"),
|
||
"atr_tf_override": _TF_LABELS.get(override) if override is not None else None,
|
||
"high_water": self._high_water,
|
||
"last_sl": self._last_sl,
|
||
"last_tp": self._last_tp,
|
||
"trail_mult": self._trade_mult if self._trade_mult is not None
|
||
else self._trail_mult(),
|
||
"phase": self._phase,
|
||
}
|