Files
Axel HocksandClaude Opus 5 fd2ca46dc0 Ruhende Orders: aufraeumen statt liegenlassen (User-Vorgabe)
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>
2026-08-20 20:29:26 +02:00

233 lines
9.6 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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