Files
AH-Oil-Trader/core/mt5_utils.py
T
Axel HocksandClaude Opus 5 6af4c868bc Feste Einsatz-Margin (UI-Feld) + lokale Sprachausgabe via Piper
EINSATZ-FELD (User-Wunsch): Laufzeitwert MANUAL_MARGIN in core/config.py
(set/get_manual_margin, Muster wie set_margin_buffer). 0 = automatisch wie bisher
(margin_buffer_pct % der freien Margin), > 0 = Position wird auf genau diesen
Margin-Einsatz gerechnet.
- calc_lots setzt es um und DECKELT auf freie Margin x Buffer; ein zu hoher
  Wunschwert wird begrenzt und geloggt statt an den Broker durchgereicht. Reicht
  der Betrag nicht fuers Mindestlot -> 0.0 + Warnung, Trade sauber abgelehnt.
- Vorrang vor dem Risiko-Modus in trader._send_locked: eine ausdrueckliche
  Groessenvorgabe schlaegt die Rechenregel.
- Wirkt auf ALLE neuen Positionen, auch die autonomen.
- UI-Feld "Einsatz" in der ORDER-Leiste (nicht in der Trade-Leiste: die ist nur
  bei offener Position sichtbar, der Einsatz muss vorher einstellbar sein),
  bernstein umrandet solange gesetzt. Enter blurrt nur, change sendet einmal.
- POST /api/manualmargin, Snapshot manual_margin, neustart-fest ueber
  runtime_state.json. Ende-zu-Ende getestet (250 -> Snapshot -> 0 -> persistiert).
- Lot-Logik isoliert geprueft: 200 EUR -> 0,58 Lots; 5000 EUR -> gedeckelt.

SPRACHAUSGABE: Piper lokal (tools/speak.py). Windows-TTS funktioniert zwar, aber
es ist KEINE deutsche Stimme installiert - weder SAPI5 noch OneCore (nur
David/Zira/Mark, en-US); deutscher Text kaeme mit englischer Aussprache.
Add-WindowsCapability scheitert ohne Adminrechte.
Piper gewaehlt: laeuft lokal (passt zum Rest - WireGuard-only, Secrets in der
ini, keine Cloud), kostet nichts, aus PowerShell/Python aufrufbar. Stimme
thorsten-medium (de_DE), Real-Time-Faktor 0,08 (4,3 s Audio in 0,36 s).

Das ZIP entpackt eine Ebene tiefer als erwartet (tools/piper/piper/piper.exe) -
speak.py SUCHT Binary und Modell statt Pfade fest zu verdrahten. Der erste
Entwurf scheiterte daran; der Fail-safe meldete die Pfade sauber statt stumm zu
bleiben. Drei Modi getestet (Argument, Pipe, --wav).

tools/piper/ ist gitignored (Binaries + 60-MB-Modell), speak.py versioniert.
v=136.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 17:31:58 +02:00

181 lines
6.8 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) -> float:
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
# ── 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 * get_margin_buffer()
_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 * get_margin_buffer() / 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