User: "was sind ruhende orders. Loesche die wenn der trade nicht eroeffnet werden kann." Anlass: MT5 zeichnete SL/TP-Linien, obwohl keine Position lief. Das waren die liegenden Stop-Orders des Breakout-Traders - MT5 stellt jede Pending-Order samt mitgeschicktem SL selbst dar. Der Bot exportiert NIE SL/TP ins Chart (0 Treffer im .mq5, keine SL-Zeile in der CSV). Kein Anzeigefehler. ZWEI ECHTE VERWAISUNGS-PFADE GEFUNDEN. _manage_squeeze_pending stieg VOR dem Aufraeum-Block aus, wenn squeeze_pending_entry=false oder kein Symbol da war. Die Orders sind ORDER_TIME_GTC - sie verfallen NICHT. Eine so verwaiste Order haengt beim Broker, wird von niemandem mehr bewertet und kann Stunden spaeter fuellen: ohne Dedup, ohne Guard-Pruefung und (bis heute) ohne Trailing. Neu engine._pendings_abraeumen(sym, grund) - beide Pfade raeumen jetzt ab. DIE EIGENTLICHE VORGABE: ein halbes Setup wird aufgeloest. Scheitert eine Seite mit Lot-Fehler (freie Margin reicht nicht fuers Mindestlot), wird auch die Gegenseite storniert - der gemessene Squeeze ist ein Ausbruch in BEIDE Richtungen; laege nur eine Seite, handelte der Bot ihn nur noch in eine. Ausdruecklich NICHT bei "Kurs hat das Level bereits passiert": das ist voruebergehend und betrifft nur die eine Seite. Der Lot-Fehler betrifft das KONTO und damit beide. Erkannt ueber die Konstante mt5_utils.LOT_ZU_KLEIN, nicht ueber ein deutsches Wort im Fliesstext - eine Erkennung, die an einer Formulierung haengt, hoert still auf zu funktionieren und faellt dabei auf die stille Seite. ⚠⚠ DIE LOG-DROSSEL WAR AM SELBEN TAG ZUM ZWEITEN MAL AUSGEHEBELT - durch meine eigene Verbesserung. Vormittags lag der Merker in EINER Variable fuer beide Seiten (behoben). Dann bekam die Meldung ueber lot_grund ihre Zahlen, und die freie Margin aendert sich mit jedem Tick: der Text war jedes Mal neu, der Vergleich traf immer zu, wieder 2 Zeilen pro Sekunde. Gedrosselt wird jetzt auf die URSACHE, nicht auf ihre Formulierung (_err_key: alle Zahlen -> #). Gemessen: 7 Zeilen in 90 s statt ~180, und die verbliebenen sind echte Wechsel zwischen den beiden Ursachen. Lehre: eine Drossel, die einen TEXT vergleicht, ist so stabil wie der Text. Live verifiziert: 0 liegende Orders, keine verwaisten Reste, Startup-Schonfrist raeumt korrekt ab, Orders kommen danach zurueck sobald die Margin reicht. Pipeline gruen (A-G), 97 Tests, deployt mit --feld squeeze_block. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
233 lines
9.6 KiB
Python
233 lines
9.6 KiB
Python
"""
|
||
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
|