Files
AH-Oil-Trader/core/mt5_utils.py
T
Axel HocksandClaude Opus 5 b448a7ade8 Warum BRK heute nicht ausgeloest hat: drei Ursachen, zwei behoben
User: "warum wurde beim letzten anstieg kein Break Out trade ausgeloest?"

Der Squeeze HAT gefeuert - mit der echten `_squeeze_one` nachgerechnet: 8 Bars
`active` heute, davon 6 LONG (00:55 / 03:00 / 07:40 / 08:35 / 11:50 / 13:00).
`squeeze_entry_count` steht trotzdem auf 0. Im Log stehen 8
`AUTO-SQUEEZE-ENTRY`-Zeilen - die Order scheiterte JEDES MAL.

URSACHE 1 - "Position bereits offen!" (1x, 08:30:34). Der Market-Fallback lief
noch ueber Slot 1, der vom manuellen Trade belegt war. Genau die Zwei-Slot-
Luecke; der `_slot()`-Fix kam um 08:32 und hat sie geschlossen.

URSACHE 2 - "Lot-Fehler" (140x, ab 13:02 JEDER Versuch). Das ist der Hauptgrund
und er ist eine Einstellung, kein Defekt: `margin_brk = 10 %`, und `calc_lots`
nimmt einen Prozentsatz der FREIEN Margin, nicht des Kontos. Die manuelle
Position bindet 707 von 776 EUR -> 10 % von ~100 EUR frei = ~10 EUR, ein
Mindestlot braucht 7,47 EUR, und die freie Margin schwankt mit dem P&L. Damit
lag BRK den ganzen Tag auf der Kippe. NICHT eigenmaechtig geaendert - das Feld
ist eine User-Entscheidung.

URSACHE 3 - "Kurs hat das Level bereits passiert" (129x). Die BUY_STOP-Seite
war nicht platzierbar, weil das Box-Dach dem Kurs ENTGEGENKAM (rollierendes
Fenster) - die wandernde Marke aus backtest_squeeze_frozen.py, hier live.

BEHOBEN (1): LOG-DROSSEL GRIFF NIE. Der Merker lag in EINER Variable, die
beiden Seiten scheitern aber im selben Tick mit VERSCHIEDENEN Fehlern
("Level passiert" oben, "Lot-Fehler" unten) - der Wert wechselte bei jedem
Aufruf. Real: 269 Zeilen an einem Tag, 2 je Sekunde. Jetzt je Order-Seite
gemerkt. Eine Drossel, die nie drosselt, ist schlimmer als keine: sie verdeckt
im Log genau die Meldung, die zaehlt.

BEHOBEN (2): "Lot-Fehler" erklaert sich jetzt selbst (`mt5_utils.lot_grund`).
Statt zwei Woertern steht dort
  "Einsatz 2 % von 101.90 EUR freier Margin = 2.04 EUR, Mindestlot 0.01
   braucht aber 7.47 EUR"
Beide Aufrufer (Market `_send` und Pending `place_stop`) nutzen ihn. Live
gegengeprueft an drei Einsatz-Werten.

Pipeline gruen (check_nfalle, Stufe E, 97 Tests), deployt und verifiziert:
seit dem Neustart 0 Zeilen "nicht platzierbar".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 14:26:43 +02:00

224 lines
9.1 KiB
Python
Raw 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)
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"Einsatz {_man:.2f} EUR reicht nicht fuer das Mindestlot "
f"{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"Einsatz {buf * 100:.0f} % von {acc.margin_free:.2f} EUR freier "
f"Margin = {verf:.2f} EUR, Mindestlot {si.volume_min} braucht aber "
f"{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