""" 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) 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