Initial commit: Oil Trading Bot (MT5, WTI)

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>
This commit is contained in:
Axel Hocks
2026-07-24 08:29:23 +02:00
co-authored by Claude Opus 4.8
commit 75d28827e8
104 changed files with 21059 additions and 0 deletions
+717
View File
@@ -0,0 +1,717 @@
"""
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: niedrige TF (Scalp) → enger Stop,
# hohe TF (Trend) → mehr Puffer. Ersetzt die alte 2.54.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.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,
}