diff --git a/core/engine.py b/core/engine.py index c99034b..77c3c80 100644 --- a/core/engine.py +++ b/core/engine.py @@ -1469,7 +1469,12 @@ class TradingEngine: t, err = self.trader.place_stop( sym, otype, preis, comment=tag, # gleiche Regel wie beim Market-Pfad (s. `_open`) - atr_tf=(mt5.TIMEFRAME_M5 if quelle == "squeeze" else None)) + atr_tf=(mt5.TIMEFRAME_M5 if quelle == "squeeze" else None), + # ⚠⚠ 7 von 8 BRK-Trades fuellen per RUHENDER Order, nicht + # ueber `_open`. Ohne diese Zeile waere der Einsatz je + # Pfad fuer BRK praktisch WIRKUNGSLOS — Fund der + # Gesamtpruefung 2026-08-19. + buffer_pct=(getattr(self, "_margin_brk", 0.0) or None)) if t: self._pending_tickets[int(t)] = quelle log.info(f"🎯 Pending-Entry ({quelle}) gesetzt: " @@ -2619,21 +2624,9 @@ class TradingEngine: # ticket-basierten Umbau (42 Einzel-Positions-Annahmen). _mg = {"auto_squeeze": getattr(self, "_margin_brk", 0.0), "auto_m15": getattr(self, "_margin_m15", 0.0)}.get(source, 0.0) - _mg_alt = None if _mg > 0: - import core.config as _cfgmod - _mg_alt = _cfgmod.MARGIN_BUFFER - # ⚠ direkt setzen statt ueber set_margin_buffer(): das loggt bei - # JEDEM Trade eine Zeile und wuerde den globalen Wert ueberschreiben. - _cfgmod.MARGIN_BUFFER = max(0.01, min(0.99, _mg / 100.0)) - log.info(f"[ORDER] Einsatz fuer {source}: {_mg:.0f}% " - f"(global {_mg_alt*100:.0f}%)") - try: - err = fn(sym, _atr_tf) - finally: - if _mg_alt is not None: - import core.config as _cfgmod2 - _cfgmod2.MARGIN_BUFFER = _mg_alt + log.info(f"[ORDER] Einsatz fuer {source}: {_mg:.0f}%") + err = fn(sym, _atr_tf, _mg or None) if err: return err # Vom BOT eröffnete Trades markieren → nur für die greift die 15-Min-Regel diff --git a/core/mt5_utils.py b/core/mt5_utils.py index 123cf0e..8d16fed 100644 --- a/core/mt5_utils.py +++ b/core/mt5_utils.py @@ -69,7 +69,18 @@ def get_filling(sym: str) -> int: return mt5.ORDER_FILLING_RETURN -def calc_lots(sym: str, price: float, otype: int) -> float: +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 @@ -77,6 +88,8 @@ def calc_lots(sym: str, price: float, otype: int) -> float: 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: @@ -84,7 +97,7 @@ def calc_lots(sym: str, price: float, otype: int) -> float: # (sonst Reject mit kryptischem retcode statt sauberer Meldung). _man = get_manual_margin() if _man > 0: - _leistbar = acc.margin_free * get_margin_buffer() + _leistbar = acc.margin_free * _buf _ziel = min(_man, _leistbar) lots = round(((_ziel / mpl) // step) * step, 4) if lots < si.volume_min: @@ -95,7 +108,7 @@ def calc_lots(sym: str, price: float, otype: int) -> float: 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) + 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). diff --git a/core/trader.py b/core/trader.py index 3d30f73..8bf4566 100644 --- a/core/trader.py +++ b/core/trader.py @@ -152,14 +152,14 @@ class TradeManager: src_str += f", auf min {INIT_SL_MIN_ATR}xATR={min_dist:.3f} aufgeweitet" return sl, tp, src_str - def _send(self, sym, otype, atr_tf=None): + def _send(self, sym, otype, atr_tf=None, buffer_pct=None): with mt5_lock(timeout=15) as got: if not got: self.last_error = "MT5 belegt — bitte gleich nochmal" return None, 0.0 - return self._send_locked(sym, otype, atr_tf) + return self._send_locked(sym, otype, atr_tf, buffer_pct) - def _send_locked(self, sym, otype, atr_tf=None): + def _send_locked(self, sym, otype, atr_tf=None, buffer_pct=None): tick = get_tick(sym) if not tick: self.last_error = "Kein Tick"; return None, 0.0 @@ -187,7 +187,7 @@ class TradeManager: "oder Daten fehlen) — Trade abgelehnt") return None, 0.0 else: - lots = calc_lots(sym, price, otype) # margin-basiert (risk_pct=0) + lots = calc_lots(sym, price, otype, buffer_pct) # margin-basiert (risk_pct=0) if lots <= 0: self.last_error = "Lot-Fehler"; return None, 0.0 @@ -247,7 +247,7 @@ class TradeManager: def place_stop(self, sym: str, otype: int, trigger: float, comment: str = "Widget-SQZ", - atr_tf=None) -> tuple[int | None, str]: + atr_tf=None, buffer_pct=None) -> tuple[int | None, str]: """Pending BUY_STOP/SELL_STOP am Ausbruchs-Level. → (ticket, Fehlertext). Größe und SL werden mit DERSELBEN Logik bestimmt wie bei der Market-Order @@ -278,7 +278,8 @@ class TradeManager: else mt5.ORDER_TYPE_SELL, trigger, atr_tf) lots = calc_lots(sym, trigger, - mt5.ORDER_TYPE_BUY if ist_long else mt5.ORDER_TYPE_SELL) + mt5.ORDER_TYPE_BUY if ist_long else mt5.ORDER_TYPE_SELL, + buffer_pct) if lots <= 0: return None, "Lot-Fehler" req = {"action": mt5.TRADE_ACTION_PENDING, "symbol": sym, @@ -314,11 +315,11 @@ class TradeManager: return "" return _retcode_msg(res) - def open_long(self, sym, atr_tf=None): + def open_long(self, sym, atr_tf=None, buffer_pct=None): with self._lock: if self.ticket: return "Position bereits offen!" - t, e = self._send(sym, mt5.ORDER_TYPE_BUY, atr_tf) + t, e = self._send(sym, mt5.ORDER_TYPE_BUY, atr_tf, buffer_pct) if not t: return self.last_error with self._lock: @@ -327,11 +328,11 @@ class TradeManager: self._log_open(t, sym, "BUY", e) return "" - def open_short(self, sym, atr_tf=None): + def open_short(self, sym, atr_tf=None, buffer_pct=None): with self._lock: if self.ticket: return "Position bereits offen!" - t, e = self._send(sym, mt5.ORDER_TYPE_SELL, atr_tf) + t, e = self._send(sym, mt5.ORDER_TYPE_SELL, atr_tf, buffer_pct) if not t: return self.last_error with self._lock: diff --git a/measurement_reminder.py b/measurement_reminder.py index d61ded9..8362855 100644 --- a/measurement_reminder.py +++ b/measurement_reminder.py @@ -469,7 +469,7 @@ CONFIG_DEPS = [ ".removed_backup/auto_signal_2026-08-12.py.", }, { - "key": "auto_m15", "test": "backtest_m15_auto.py", "soll": "false", + "key": "auto_m15", "test": "backtest_m15_auto.py", "validated": "false", "why": "Gemessen negativ (Lesart -0,162/-0,094; die gehandelte erlaubte " "Richtung entspricht Kontrolle C, -0,085/-0,030 -- ebenfalls " "negativ). Steht er auf 'true', laeuft ein gemessen negativer " @@ -612,13 +612,28 @@ def config_drift() -> list: now = _cfg_now() drift = [] for d in CONFIG_DEPS: + # ⚠⚠ EIN fehlerhafter Eintrag darf den GESAMTEN Waechter nicht toeten. + # Real passiert am 2026-08-19: ein neuer Eintrag trug das Feld "soll" + # statt "validated" -> KeyError in dieser Schleife -> `config_drift()` + # warf, und damit meldeten ALLE 16 Anker nichts mehr. Ein Waechter, der + # still stirbt, ist schlimmer als keiner (dieselbe Klasse wie der + # ruff-Exit-2-Fall vom 07.08.). Deshalb: defekten Eintrag ueberspringen, + # aber LAUT als eigenen Befund melden statt ihn zu verschlucken. + if "validated" not in d: + drift.append(({"key": d.get("key", "?"), + "why": "⚠ DEFEKTER Waechter-Eintrag: Feld " + "'validated' fehlt (Feldnamen: %s). Dieser " + "Anker prueft NICHTS." + % ", ".join(sorted(d))}, + "?", "?")) + continue cur = now.get(d["key"]) if cur is None: continue try: # numerisch vergleichen, damit 0.60 == 0.6 same = abs(float(cur) - float(d["validated"])) < 1e-9 except (TypeError, ValueError): - same = cur.lower() == d["validated"].lower() + same = str(cur).lower() == str(d["validated"]).lower() if not same: drift.append((d, cur, d["validated"])) return drift diff --git a/tests/test_buffer_pct.py b/tests/test_buffer_pct.py new file mode 100644 index 0000000..9f18a9b --- /dev/null +++ b/tests/test_buffer_pct.py @@ -0,0 +1,112 @@ +"""Einsatz je Pfad: der explizite `buffer_pct` muss den globalen Wert schlagen. + +Hintergrund (Gesamtpruefung 2026-08-19). Der erste Entwurf hat den Einsatz je +Pfad umgesetzt, indem `engine._open` den Modul-Global `config.MARGIN_BUFFER` +temporaer ueberschrieb. Zwei Fehler steckten darin: + + (1) RACE. Der Global wirkt prozessweit. Zwischen dem Setzen in `_open` und dem + `mt5_lock` in `trader._send` liegt ein Fenster von bis zu 15 s + (Lock-Timeout). Eine gleichzeitige MANUELLE Order haette darin mit dem + Prozentsatz des AUTONOMEN Pfads gesized. + (2) WIRKUNGSLOS FUER BRK. `_manage_squeeze_pending` legt ruhende Stop-Orders + ueber `trader.place_stop` -> `calc_lots`, also NICHT ueber `_open`. Da + gemessen 7 von 8 BRK-Trades per ruhender Order fuellen, griff das Feld + genau dort nicht, wo es hingehoert. + +Beides ist behoben, indem `calc_lots` einen expliziten Parameter bekommt. Diese +Tests halten das fest -- ohne sie faellt ein Rueckbau auf die Global-Mutation +nicht auf. + +⚠ `calc_lots` braucht MT5. Statt es zu stubben wird hier die reine RECHENREGEL +geprueft, die der Parameter steuert -- das ist der Teil, der kaputtgehen kann. +""" +import core.config as C +from core.mt5_utils import calc_lots + + +def _buf(buffer_pct): + """Die Klemm-/Auswahlregel aus `calc_lots`, isoliert nachgebildet. + + ⚠ Bewusst KEINE Kopie der Formel: geprueft wird, dass der Parameter den + Global schlaegt und dieselben Grenzen gelten (1-99 %). + """ + from core.config import get_margin_buffer + return (max(0.01, min(0.99, buffer_pct / 100.0)) + if buffer_pct and buffer_pct > 0 else get_margin_buffer()) + + +def test_parameter_schlaegt_global(): + C.set_margin_buffer(40) + assert abs(_buf(None) - 0.40) < 1e-9 # kein Parameter -> global + assert abs(_buf(0) - 0.40) < 1e-9 # 0 heisst ausdruecklich "global" + assert abs(_buf(50) - 0.50) < 1e-9 # Parameter gewinnt + assert abs(_buf(25) - 0.25) < 1e-9 + + +def test_global_bleibt_unveraendert(): + """Der Kern des Race-Fixes: ein Aufruf mit Parameter darf den PROZESSWEITEN + Wert nicht anfassen -- sonst sizen parallele manuelle Orders falsch.""" + C.set_margin_buffer(40) + vorher = C.get_margin_buffer() + _buf(90) + assert C.get_margin_buffer() == vorher, "der Global wurde veraendert" + + +def test_grenzen_geklemmt(): + C.set_margin_buffer(40) + assert abs(_buf(150) - 0.99) < 1e-9 # 100 % liesse keinen Puffer + assert abs(_buf(-5) - 0.40) < 1e-9 # negativ -> global, nicht 0 + + +def test_signatur_nimmt_buffer_pct(): + """Haelt die Verdrahtung fest: `calc_lots` MUSS den Parameter annehmen. + + Ohne diesen Test faellt es nicht auf, wenn jemand die Signatur zurueckbaut -- + die Aufrufer wuerden dann mit TypeError sterben, und zwar erst beim ERSTEN + autonomen Trade (also im `except`-Zweig, still). + """ + import inspect + p = inspect.signature(calc_lots).parameters + assert "buffer_pct" in p, "calc_lots hat den Parameter verloren" + assert p["buffer_pct"].default is None, "Default muss None (= global) sein" + + +def test_pfade_reichen_durch(): + """`place_stop` und `open_long/short` muessen den Parameter weiterreichen. + + ⚠ Das ist der Fund (2): BRK fuellt ueberwiegend per RUHENDER Order. Fehlt der + Parameter an `place_stop`, ist das Feld fuer BRK wirkungslos -- und das faellt + im Betrieb nur auf, wenn jemand die Lots nachrechnet. + """ + import inspect + + import core.trader as T + for name in ("open_long", "open_short", "place_stop", "_send", "_send_locked"): + p = inspect.signature(getattr(T.TradeManager, name)).parameters + assert "buffer_pct" in p, f"{name} reicht buffer_pct nicht durch" + + +def test_calc_lots_benutzt_den_parameter_wirklich(): + """Schliesst die Luecke, die die Mutationsprobe aufgedeckt hat. + + ⚠⚠ Die Tests darueber pruefen eine NACHGEBILDETE Regel (`_buf`) -- damit + faellt es NICHT auf, wenn `calc_lots` den Parameter zwar entgegennimmt, ihn + aber ignoriert und wieder den Global liest. Genau diese Mutation lief bei + der ersten Probe gruen durch. Das ist dieselbe Nachbau-Falle, die im Projekt + schon dreimal zugeschlagen hat (`sim_run` in >=6 Skripten, der nachgebaute + Level-Cluster, das fuenfte Exit-Modell) -- hier in einem TEST. + + `calc_lots` braucht MT5 und darf in der Suite nicht laufen (conftest-Regel), + deshalb wird strukturell geprueft: der Parametername MUSS im Rumpf der + Funktion vorkommen. Das ist schwaecher als ein Verhaltenstest, faengt aber + genau den Rueckbau, um den es geht. + """ + import ast + import inspect + + import core.mt5_utils as M + baum = ast.parse(inspect.getsource(M.calc_lots)) + namen = {n.id for n in ast.walk(baum) if isinstance(n, ast.Name)} + assert "buffer_pct" in namen, ( + "calc_lots nimmt buffer_pct entgegen, benutzt es aber nicht -- " + "der Einsatz je Pfad waere wirkungslos")