""" core/mt5_utils.py — Thread-sicherer MT5-Zugriff + Trading-Hilfsfunktionen ========================================================================== Globaler Lock für die MetaTrader5-Lib (nicht thread-safe) sowie alle kleinen Hilfsfunktionen die von TradeManager, TrailingManager und MT5Data gemeinsam genutzt werden. """ from __future__ import annotations import threading from contextlib import contextmanager import MetaTrader5 as mt5 from core.config import ( get_margin_buffer, get_manual_margin, SL_TF, SL_LOOKBACK, SL_WINDOW, SL_BUFFER_TICKS, ) from core.logger import get_logger log = get_logger("mt5") # ── Globaler MT5-Lock ────────────────────────── # Die MT5 Python-Lib ist NICHT thread-safe. Alle MT5-Calls laufen # über diesen Lock. Mit Timeout, damit ein hängender order_send # (Server antwortet nicht) nicht alle anderen Threads blockiert. _mt5_lock = threading.Lock() MT5_LOCK_TIMEOUT_S = 5 @contextmanager def mt5_lock(timeout: float = MT5_LOCK_TIMEOUT_S): """ Context-Manager für den globalen MT5-Lock mit Timeout. with mt5_lock() as got: if not got: return # Lock nicht erhalten → Tick überspringen ... MT5-Calls ... """ acquired = _mt5_lock.acquire(timeout=timeout) if not acquired: try: yield False finally: pass else: try: yield True finally: _mt5_lock.release() # ── Tick / Symbol-Helpers ───────────────────── def get_tick(sym: str): t = mt5.symbol_info_tick(sym) return t if (t and t.bid > 0 and t.ask > 0) else None def get_filling(sym: str) -> int: si = mt5.symbol_info(sym) if si is None: return mt5.ORDER_FILLING_IOC for m in (mt5.ORDER_FILLING_FOK, mt5.ORDER_FILLING_IOC, mt5.ORDER_FILLING_RETURN): if si.filling_mode & m: return m return mt5.ORDER_FILLING_RETURN def calc_lots(sym: str, price: float, otype: int, buffer_pct: float | None = None) -> float: """`buffer_pct` (0..99) ueberschreibt den globalen Einsatz-Anteil NUR fuer diesen einen Aufruf — gedacht fuer den Einsatz je autonomem Pfad. ⚠⚠ Bewusst ein PARAMETER statt einer temporaeren Mutation von `MARGIN_BUFFER`: der Modul-Global wirkt prozessweit, und zwischen dem Setzen in `engine._open` und dem `mt5_lock` in `trader._send` liegt ein Fenster von bis zu 15 s (Lock-Timeout). Eine gleichzeitige MANUELLE Order haette in diesem Fenster mit dem Prozentsatz des autonomen Pfads gesized — echtes Geld, echte Race Condition. Dieselbe Klasse wie das prozessweite Mutieren von Backtest-Modul-Globalen (behoben 07.08. in `core/squeeze_scan.py`).""" si, acc = mt5.symbol_info(sym), mt5.account_info() if not si or not acc: return 0.0 mpl = mt5.order_calc_margin(otype, sym, 1.0, price) if not mpl or mpl <= 0: return 0.0 step = si.volume_step _buf = (max(0.01, min(0.99, buffer_pct / 100.0)) if buffer_pct and buffer_pct > 0 else get_margin_buffer()) # ── Manuell vorgegebene Einsatz-Margin (User-Wunsch 2026-08-04) ────────── # Ist sie gesetzt, wird die Position auf GENAU diesen Betrag gerechnet statt # auf einen Prozentsatz der freien Margin. Die freie Margin bleibt Obergrenze: # ein zu hoher Wunschwert wird gedeckelt, nicht an den Broker durchgereicht # (sonst Reject mit kryptischem retcode statt sauberer Meldung). _man = get_manual_margin() if _man > 0: _leistbar = acc.margin_free * _buf _ziel = min(_man, _leistbar) lots = round(((_ziel / mpl) // step) * step, 4) if lots < si.volume_min: log.warning(f"Einsatz-Margin {_man:.2f} reicht nicht für das Mindestlot " f"({si.volume_min}) — Margin je Lot {mpl:.2f}") return 0.0 if _ziel < _man: log.warning(f"Einsatz-Margin auf {_ziel:.2f} gedeckelt " f"(gewünscht {_man:.2f}, frei×Buffer {_leistbar:.2f})") return min(lots, si.volume_max) lots = round(((acc.margin_free * _buf / mpl) // step) * step, 4) # Margin-Guard: reicht die freie Margin nicht mal fürs Mindestvolumen, 0.0 # zurückgeben → Caller meldet sauber „Lot-Fehler" statt Broker-Reject mit # kryptischem retcode (früher: max(volume_min, …) erzwang unbezahlbare Größe). if lots < si.volume_min: return 0.0 return min(lots, si.volume_max) # ⚠⚠ MASCHINENLESBARER Marker fuer "Einsatz reicht nicht fuer das Mindestlot". # Der Aufrufer (`_manage_squeeze_pending`) raeumt daraufhin das halbe Setup ab. # Bewusst eine KONSTANTE und kein Wort im Fliesstext: eine Erkennung, die an # einer deutschen Formulierung haengt, hoert still auf zu funktionieren, sobald # jemand den Satz umschreibt — und faellt dabei auf die stille Seite (kein # Abraeumen), nicht auf die laute. LOT_ZU_KLEIN = "MINDESTLOT" def lot_grund(sym: str, price: float, otype: int, buffer_pct: float | None = None) -> str: """Warum liefert `calc_lots` 0? — als lesbarer Satz statt „Lot-Fehler". ⚠⚠ ANLASS (2026-08-20, real): der Breakout-Trader hat den ganzen Tag keinen Trade eroeffnet und meldete nur „Lot-Fehler". Die Ursache war ein Zusammenspiel, das man der Meldung NICHT ansah: das Feld „Einsatz je Pfad" stand auf 10 %, und weil `calc_lots` einen Prozentsatz der FREIEN Margin nimmt — nicht des Kontos —, blieben von 108 EUR frei nur 10,83 EUR uebrig, waehrend ein Mindestlot 7,47 EUR braucht und die freie Margin schwankt. Eine Fehlermeldung, die den Grund verschweigt, kostet hier Stunden. """ si, acc = mt5.symbol_info(sym), mt5.account_info() if not si or not acc: return "Lot-Fehler (kein Symbol/Konto)" mpl = mt5.order_calc_margin(otype, sym, 1.0, price) if not mpl or mpl <= 0: return "Lot-Fehler (Margin je Lot nicht berechenbar)" _man = get_manual_margin() if _man > 0: return (f"[{LOT_ZU_KLEIN}] Einsatz {_man:.2f} EUR reicht nicht fuer das " f"Mindestlot {si.volume_min} (braucht {si.volume_min * mpl:.2f} EUR)") buf = (max(0.01, min(0.99, buffer_pct / 100.0)) if buffer_pct and buffer_pct > 0 else get_margin_buffer()) verf = acc.margin_free * buf return (f"[{LOT_ZU_KLEIN}] Einsatz {buf * 100:.0f} % von {acc.margin_free:.2f} " f"EUR freier Margin = {verf:.2f} EUR, Mindestlot {si.volume_min} " f"braucht aber {si.volume_min * mpl:.2f} EUR") def calc_lots_risk(sym: str, price: float, otype: int, sl_distance: float | None, risk_frac: float) -> float: """Risiko-basierte Lot-Größe: Verlust beim Initial-SL ≈ risk_frac × Equity. Ersetzt das All-in-auf-freie-Margin von `calc_lots`. Die freie Margin bleibt Obergrenze (Deckel), damit Mini-Konten nicht über die Margin hinaus ordern. Gibt 0.0 zurück, wenn Daten fehlen → Caller fällt auf `calc_lots` zurück. """ si, acc = mt5.symbol_info(sym), mt5.account_info() if not si or not acc or not sl_distance or sl_distance <= 0: return 0.0 step = si.volume_step or 0.01 tick_size = si.trade_tick_size or si.point tick_value = si.trade_tick_value if not tick_size or not tick_value: return 0.0 val_per_price = tick_value / tick_size # € je 1.0 Preis je 1 Lot equity = acc.equity or acc.balance or 0.0 if equity <= 0: return 0.0 raw = (equity * risk_frac) / (sl_distance * val_per_price) lots = (raw // step) * step # Margin-Deckel: nie mehr als die freie Margin (× Buffer) zulässt mpl = mt5.order_calc_margin(otype, sym, 1.0, price) if mpl and mpl > 0: max_aff = ((acc.margin_free * get_margin_buffer() / mpl) // step) * step lots = min(lots, max_aff) lots = min(round(lots, 4), si.volume_max) # Ergibt die Risiko-Rechnung WENIGER als das Mindestlot, NICHT auf volume_min # aufrunden (das überschritte still das gewollte Risiko) → 0.0, Caller lehnt ab # (Fix 2026-07-19; vorher max(volume_min, …)). if lots < si.volume_min: return 0.0 return lots def atr_value(sym: str, tf: int = SL_TF, period: int = 14) -> float | None: """ATR (Wilder-vereinfacht: Mittel der True Ranges) auf `tf`.""" r = mt5.copy_rates_from_pos(sym, tf, 0, period + 1) if r is None or len(r) < period + 1: return None trs = [] for i in range(1, len(r)): h, l, pc = float(r[i]["high"]), float(r[i]["low"]), float(r[i - 1]["close"]) trs.append(max(h - l, abs(h - pc), abs(l - pc))) return sum(trs) / len(trs) if trs else None def pivot_low(sym: str, max_above: float): r = mt5.copy_rates_from_pos(sym, SL_TF, 0, SL_LOOKBACK) if r is None or len(r) < 2 * SL_WINDOW + 1: return None lows = [float(x["low"]) for x in r] for i in range(len(lows) - SL_WINDOW - 1, SL_WINDOW - 1, -1): c = lows[i] if (c < max_above and c < min(lows[i - SL_WINDOW:i]) and c < min(lows[i + 1:i + 1 + SL_WINDOW])): return c return None def pivot_high(sym: str, min_below: float): r = mt5.copy_rates_from_pos(sym, SL_TF, 0, SL_LOOKBACK) if r is None or len(r) < 2 * SL_WINDOW + 1: return None highs = [float(x["high"]) for x in r] for i in range(len(highs) - SL_WINDOW - 1, SL_WINDOW - 1, -1): c = highs[i] if (c > min_below and c > max(highs[i - SL_WINDOW:i]) and c > max(highs[i + 1:i + 1 + SL_WINDOW])): return c return None