From a07ad942c15d1f40988e18aaa83b425ccbfb70e7 Mon Sep 17 00:00:00 2001 From: Axel Hocks Date: Wed, 19 Aug 2026 19:38:56 +0200 Subject: [PATCH] Gesamtpruefung: drei echte Fehler behoben, alle drei aus meinem eigenen Code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dritter vollstaendiger Durchlauf nach docs/review-prompt.md (nach 02.08. und 11.08.). Bemerkenswert: alle drei Funde stammen aus Aenderungen der letzten zwei Tage, und alle drei sind DIESELBE Klasse - ein vorhandenes Muster nicht kopiert, sondern ein eigenes erfunden. FUND 1 (kritisch) - DER CONFIG-WAECHTER WAR TOT. Mein CONFIG_DEPS-Eintrag fuer auto_m15 vom 19.08. trug das Feld "soll", die anderen 15 Eintraege tragen "validated". config_drift() lief in einen KeyError und warf - damit meldeten ALLE 16 Anker nichts mehr, seit gestern. Der Waechter, der die auto_m15-Abweichung haette melden sollen, war durch genau diesen Eintrag erledigt. Behoben: Feldname angeglichen UND config_drift gehaertet - ein defekter Eintrag wird jetzt uebersprungen und als eigener Befund GEMELDET statt den ganzen Waechter zu toeten. Ein Waechter, der still stirbt, ist schlimmer als keiner (dieselbe Klasse wie der ruff-Exit-2-Fall vom 07.08.). Der Waechter meldet seitdem wieder: margin_buffer_pct 40 gegen 95, auto_m15 true gegen false, auto_squeeze true gegen false - alle drei sind dokumentierte User-Entscheidungen, aber sie waren unsichtbar geworden. FUND 2 (Geld) - DER EINSATZ JE PFAD WAR FUER BRK WIRKUNGSLOS. engine._open setzt den Einsatz je Pfad - aber _manage_squeeze_pending legt ruhende Stop-Orders ueber trader.place_stop -> calc_lots, also NICHT ueber _open. Gemessen fuellen 7 von 8 BRK-Trades per ruhender Order (seit dem Pending-Umbau 05.08.). Das Feld haette also genau an der Stelle nicht gegriffen, fuer die es gebaut wurde - und das faellt im Betrieb nur auf, wenn jemand die Lots nachrechnet. FUND 3 (Geld, Race) - GLOBALE MUTATION UEBER EINE LOCK-GRENZE. Der erste Entwurf setzte config.MARGIN_BUFFER temporaer. Der Global wirkt prozessweit, und 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. FUND 2 und 3 gemeinsam behoben: calc_lots bekommt einen EXPLIZITEN Parameter buffer_pct (Default None = global), durchgereicht ueber _send/_send_locked/ open_long/open_short/place_stop. Keine Mutation mehr, und beide Bestellwege tragen denselben Wert. TESTS - und eine Luecke in meinem eigenen Test, die erst die Mutationsprobe zeigte: tests/test_buffer_pct.py, 6 Tests. Die erste Fassung bildete die Regel NACH (_buf) statt die echte Funktion zu pruefen - die Mutation "calc_lots ignoriert den Parameter wieder" lief damit GRUEN durch. Das ist die dokumentierte Nachbau-Falle, diesmal in einem TEST. Ergaenzt um eine AST-Pruefung, dass calc_lots den Parameter im Rumpf wirklich benutzt. Beide Mutationen werden jetzt gefangen (Parameter aus place_stop entfernt -> rot; calc_lots ignoriert ihn -> rot). OHNE BEFUND (geprueft, sauber): check_nfalle, ruff F821, 91 Tests, node --check; 100 JS-Zugriffe gegen 124 HTML-IDs -> 0 fehlend (die 4 gemeldeten waren Falsch-Positive meiner Regex: zwei stehen in Kommentaren, raum-* wird dynamisch als $("raum-" + st) gebaut, tb-dur ueber getElementById); 2 Config-Schluessel ohne Leser (beide [zones], dokumentiert schlafend); 14 Snapshot-Felder ohne app.js-Leser (dokumentiert als Diagnose-Oberflaeche, deploy.py --feld braucht sie); Snapshot-Median 15,7 ms bei p90 25,7 ms - unveraendert zum 11.08. und weit unter dem 1-s-Budget, keine Massnahme. TELEMETRIE-PULS: alle 7 Logger schreiben. rec_outcomes steht bei 72 Zeilen - beim Review am 11.08. stand es auf "hat NIE geschrieben", der Fix vom 12.08. hat also gewirkt. DIVERGENZ-WAECHTER: A) WARTEN +16,6 Pp ueber der Erwartung (bekannt, wird von den Live-only-Gates getragen); D) mom3 -0,55 Sigma - knapp ueber der bewusst tiefen 0,5-Sigma-Schwelle, beobachten. ⚠ NICHT ANGEFASST (User-Entscheidungen, nur berichtet): auto_m15 laeuft live ueber runtime_state.json gegen die ini; Circuit Breaker aus (cb_limit_pct 0 gegen ini 8); BRK an trotz gefallener B5-Regel. Deploy ueber tools/deploy.py --feld margin_brk, alle 5 Schritte gruen. Co-Authored-By: Claude Opus 5 --- core/engine.py | 23 +++----- core/mt5_utils.py | 19 +++++-- core/trader.py | 21 ++++---- measurement_reminder.py | 19 ++++++- tests/test_buffer_pct.py | 112 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 164 insertions(+), 30 deletions(-) create mode 100644 tests/test_buffer_pct.py 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")