diff --git a/core/engine.py b/core/engine.py index 714c74f..d5ee9e3 100644 --- a/core/engine.py +++ b/core/engine.py @@ -268,6 +268,14 @@ class TradingEngine: # dieselbe Position greifen (der Default adoptiert per Symbol). self.trader_brk = TradeManager(nur_ticket=True) self.trail_brk = TrailingManager(self.trader_brk) + # ⚠⚠ AUCH am BRK-Slot — ohne das kann er GAR NICHT in die DB schreiben. + # ⚠ MUSS hier stehen, NACH der Erzeugung: der erste Versuch setzte es + # oben neben `self.trader.history` und lief in einen AttributeError, + # der den GESAMTEN Serverstart verhinderte. Zweiter Reihenfolge-Fehler + # derselben Art in zwei Tagen (nach `_margin_brk` vor + # `_load_runtime_state`). Regel: ein Attribut erst benutzen, wenn das + # Objekt existiert — und den Start danach WIRKLICH pruefen. + self.trader_brk.history = self.history # ⚠ Der adoptierende Manager darf den BRK-Slot und dessen noch offene # Pending-Tickets NICHT greifen — sonst verwalten beide dieselbe # Position (Trailing zweimal, Notfall-Stop am falschen Trade). @@ -859,6 +867,7 @@ class TradingEngine: self.trader.refresh(self.data.symbol) # BRK-Slot mitfuehren (strikt ticket-gebunden, adoptiert nichts) self.trader_brk.refresh(self.data.symbol) + self._rebind_brk() # nach Neustart zurueckbinden self._check_circuit_breaker() # Tagesverlust-Stopp (Überleben zuerst) self._check_auto_close() # ⚠ NACH `_check_auto_close`: erst dort wird der Flat-Übergang @@ -985,6 +994,49 @@ class TradingEngine: log.warning(f"⚠ DB-Nachtrag T={tk} nach {n} Versuchen aufgegeben " f"— der Trade bleibt setup=NULL und fehlt in B4/B5.") + def _rebind_brk(self): + """BRK-Position nach einem Neustart zurückbinden (Fix 2026-08-20). + + ⚠⚠ WARUM. `trader_brk.ticket` lebte nur im Speicher. Nach einem Neustart + war es None, `fremde_tickets` lieferte damit eine LEERE Menge — und der + adoptierende Haupt-Trader griff die BRK-Position per magic-match. Ab da + verwaltete Slot 1 sie, inklusive Notfall-Stop und Trailing. Real am + 2026-08-20: „Position erkannt (magic-match)" in derselben Sekunde wie + „Engine gestartet". Die Trennung brach also NUR beim Neustart — und + danach sah alles normal aus, was es besonders schwer sichtbar machte. + ⚠ Erst ab hier greift die Sperre auch nach einem Neustart: sobald das + Ticket wieder am BRK-Slot hängt, liefert `fremde_tickets` es aus. + ⚠ Existiert die Position nicht mehr (zwischenzeitlich geschlossen), wird + der Merker verworfen — kein ewiges Wiederholen. + """ + tk = getattr(self, "_brk_restore", None) + if not tk: + return + if self.trader_brk.ticket: # schon gebunden + self._brk_restore = None + return + # ⚠ Hat Slot 1 sie bereits adoptiert (Altzustand aus einem Neustart VOR + # diesem Fix), NICHT dazwischenfunken — sonst verwalten beide dieselbe + # Position. Dann lieber den Merker verwerfen und es melden. + if (self.trader.snapshot() or {}).get("ticket") == tk: + log.warning(f"⚠ BRK-Ticket {tk} haengt bereits an Slot 1 — " + f"Rueckbindung uebersprungen (Altzustand).") + self._brk_restore = None + return + try: + if self.trader_brk.bind(int(tk), self.data.symbol): + log.info(f"🧷 BRK-Slot nach Neustart zurueckgebunden: T={tk}") + self._brk_restore = None + if not self.trail_brk.enabled and self.data.symbol: + self.trail_brk.toggle(self.data.symbol) + log.info("Trailing des BRK-Slots wiederhergestellt") + else: + # Position existiert nicht mehr → Merker weg + log.info(f"BRK-Ticket {tk} nicht mehr offen — Merker verworfen.") + self._brk_restore = None + except Exception as e: + log.debug(f"_rebind_brk: {e}") + def _check_open_notify(self): """Telegram bei JEDER Eröffnung — beide Slots (User-Vorgabe 2026-08-19). @@ -2492,6 +2544,11 @@ class TradingEngine: self._manual_margin = _set(st["manual_margin"] or 0) if "auto_m15" in st: self._auto_m15 = bool(st["auto_m15"]) + # ⚠ Nur MERKEN — `bind()` braucht MT5, das beim Laden noch nicht + # zwingend bereit ist. Das Zurueckbinden macht `_rebind_brk()` im + # `_pos_loop`, bis es klappt oder das Ticket weg ist. + if st.get("brk_ticket"): + self._brk_restore = int(st["brk_ticket"]) if "margin_brk" in st: self._margin_brk = max(0.0, min(99.0, float(st["margin_brk"] or 0))) if "margin_m15" in st: @@ -2535,6 +2592,14 @@ class TradingEngine: # 0 = aus. So ueberlebt auch die Hoehe den Neustart. "cb_limit_pct": getattr(self, "_cb_limit_pct", 0.0), "cb_remember": getattr(self, "_cb_remember", 8.0), + # ⚠⚠ Das BRK-Ticket MUSS persistiert werden. Ohne + # das ist `trader_brk.ticket` nach einem Neustart + # None, die `fremde_tickets`-Sperre liefert eine + # LEERE Menge, und der Haupt-Trader adoptiert die + # BRK-Position per magic-match. Real am 20.08.: + # 08:00:44 "Position erkannt (magic-match)" in + # derselben Sekunde wie "Engine gestartet". + "brk_ticket": getattr(self.trader_brk, "ticket", None), "margin_brk": getattr(self, "_margin_brk", 0.0), "margin_m15": getattr(self, "_margin_m15", 0.0), "auto_m15": getattr(self, "_auto_m15", False), @@ -2817,7 +2882,9 @@ class TradingEngine: elif sig != want: log.info(f"[ORDER] {want} ohne passende Empfehlung (Signal={sig}) " f"— vom User bestätigt, wird eröffnet") - self.trader.set_open_context(**self._last_rec) + # ⚠ An den Slot, der die Order sendet — sonst landen die 16 ctx_*-Felder + # eines BRK-Trades am falschen Objekt (Fix 2026-08-20). + self._slot(source).set_open_context(**self._last_rec) # ⚠ SL-ATR-Zeitebene quellen-abhaengig (2026-08-05, User-Entscheidung nach # `backtest_sl_basis.py`): SQUEEZE bekommt **M5**, alles andere bleibt bei # M15. Begruendung: auf Squeeze-Entries ist der Ertrag zwischen beiden diff --git a/tools/check_nfalle.py b/tools/check_nfalle.py index 7007fcd..5639295 100644 --- a/tools/check_nfalle.py +++ b/tools/check_nfalle.py @@ -420,8 +420,37 @@ print(" keine gefunden." if not sig else f" {sig} Stellen.") print() print("=" * 88) + +# ── E) SLOT-TRENNUNG + ATTRIBUT-REIHENFOLGE ────────────────────────────── +# ⚠⚠ Beide Fehlerklassen haben binnen zwei Tagen je einmal zugeschlagen, und +# KEINE andere Stufe hat sie gefangen (py_compile, ruff F821, ty, pytest: +# alle gruen). Der Attribut-Reihenfolge-Fehler brach den Serverstart KOMPLETT +# ab — der Nutzer konnte zwei Minuten lang nicht schliessen. Gefunden hat ihn +# erst `deploy.py` Schritt 2, also NACH dem Kill des laufenden Servers. +# Diese Stufe findet ihn VORHER. +def _stufe_slots(): + print("") + print("=" * 88) + print(" E) SLOTS — Attribut-Reihenfolge in __init__ + Slot-Trennung") + print("=" * 88) + try: + sys.path.insert(0, str(WURZEL / "tools")) + import check_slots + import importlib + importlib.reload(check_slots) + for z in check_slots.pruefe(): + befunde.append("SLOT " + z) + print(" FEHLER " + z) + if not check_slots.pruefe(): + print(" keine Slot-/Reihenfolge-Fehler.") + except Exception as e: + print(f" ⚠ Slot-Pruefung nicht ausfuehrbar ({e}) — NICHT als sauber gewertet.") + befunde.append("SLOT Werkzeugfehler") + + _stufe_typen() _stufe_render() +_stufe_slots() print("") print("=" * 88) diff --git a/tools/check_slots.py b/tools/check_slots.py new file mode 100644 index 0000000..c8f5a49 --- /dev/null +++ b/tools/check_slots.py @@ -0,0 +1,142 @@ +"""Statische Prüfung: Attribut-Reihenfolge in `__init__` + Slot-Trennung. + +⚠⚠ WOZU — beide Fehlerklassen haben in ZWEI TAGEN je einmal zugeschlagen, und +KEINE bestehende Stufe hat sie gefangen: + + (A) ATTRIBUT VOR SEINER ZUWEISUNG BENUTZT. + `self.trader_brk.history = self.history` stand in `__init__` VOR + `self.trader_brk = TradeManager(...)`. Ergebnis: AttributeError im + Konstruktor → der Serverstart brach KOMPLETT ab, und der Nutzer konnte + zwei Minuten lang keine Position schliessen ("Netzwerkfehler" am + Close-Knopf). py_compile, ruff F821, ty und pytest liefen alle GRÜN — + syntaktisch ist es korrekt, und kein Test instanziiert die Engine (sie + braucht MT5). Gefunden hat es erst `deploy.py` Schritt 2 — also NACH dem + Kill des laufenden Servers. + Derselbe Fehler am Vortag mit `_margin_brk` vor `_load_runtime_state()`: + dort blieb er sogar still (der Wert wurde nur ueberschrieben). + + (B) SLOT-VERWECHSLUNG. Seit dem Zwei-Slot-Umbau (19.08.) gibt es + `self.trader` (manuell + M15) und `self.trader_brk` (Breakout). Mehrere + Pfade griffen nach dem falschen: `engine.close()` schloss nur Slot 1 (eine + BRK-Position war NICHT schliessbar), `squeeze_reverse` schloss die + manuelle statt der BRK-Position, `open_long` routete den Market-Fallback + auf Slot 1 ("Position bereits offen!"). + +Beide Prüfungen sind STATISCH (AST) — kein MT5, keine Laufzeit, Millisekunden. + +Rückgabe: Liste von Befunden (leer = sauber). Wird von `tools/check_nfalle.py` +als Stufe D eingebunden und läuft damit im pre-commit-Hook mit. + +Aufruf einzeln: python tools/check_slots.py +""" +from __future__ import annotations + +import ast +import pathlib +import sys + +WURZEL = pathlib.Path(__file__).resolve().parent.parent + +# Funktionen, die zum BRK-Pfad gehören und deshalb NICHT auf `self.trader` +# zugreifen dürfen. ⚠ Bewusst eine WEISSE Liste: eine schwarze Liste („alles +# ausser…") würde bei jeder neuen Funktion stillschweigend durchlassen. +BRK_PFADE = { + "_check_auto_squeeze", + "_manage_squeeze_pending", + "_check_pending_fill", +} +# Zugriffe, die den SLOT-ZUSTAND betreffen. `pending_orders`, `cancel_pending` +# und `place_stop` sind magic-basierte BROKER-Abfragen ohne Slot-Zustand und +# deshalb ausdruecklich erlaubt (geprüft 2026-08-20). +SLOT_METHODEN = {"close", "snapshot", "open_long", "open_short", + "set_open_context", "refresh"} + + +def _init_reihenfolge(baum: ast.AST, quelle: str) -> list: + """(A) `self.X.…` benutzt, bevor `self.X = …` zugewiesen wurde.""" + befunde = [] + for kls in [n for n in ast.walk(baum) if isinstance(n, ast.ClassDef)]: + init = next((f for f in kls.body + if isinstance(f, ast.FunctionDef) and f.name == "__init__"), None) + if not init: + continue + gesetzt: set[str] = set() + for knoten in ast.walk(init): + # Zuweisungen in Reihenfolge einsammeln … aber ast.walk ist NICHT + # zeilentreu. Deshalb unten über die Zeilennummer entschieden. + pass + # Zeile → Name, an der `self.X` zugewiesen wird (erste Zuweisung zählt) + erste: dict[str, int] = {} + for knoten in ast.walk(init): + if isinstance(knoten, ast.Assign): + for ziel in knoten.targets: + for t in ([ziel] if not isinstance(ziel, ast.Tuple) else ziel.elts): + if (isinstance(t, ast.Attribute) + and isinstance(t.value, ast.Name) + and t.value.id == "self"): + erste.setdefault(t.attr, knoten.lineno) + # Benutzungen: `self.X.irgendwas` — X muss vorher zugewiesen sein + for knoten in ast.walk(init): + if (isinstance(knoten, ast.Attribute) + and isinstance(knoten.value, ast.Attribute) + and isinstance(knoten.value.value, ast.Name) + and knoten.value.value.id == "self"): + name = knoten.value.attr + if name not in erste: + continue # kommt von ausserhalb → nicht pruefbar + if knoten.lineno < erste[name]: + befunde.append( + f"{kls.name}.__init__:{knoten.lineno}: `self.{name}` " + f"benutzt, aber erst in Zeile {erste[name]} zugewiesen " + f"→ AttributeError beim Start") + return sorted(set(befunde)) + + +def _slot_trennung(baum: ast.AST) -> list: + """(B) BRK-Pfade duerfen den Slot-Zustand von `self.trader` nicht anfassen.""" + befunde = [] + for fn in [n for n in ast.walk(baum) if isinstance(n, ast.FunctionDef)]: + if fn.name not in BRK_PFADE: + continue + for knoten in ast.walk(fn): + if not isinstance(knoten, ast.Attribute): + continue + v = knoten.value + if (isinstance(v, ast.Attribute) and v.attr == "trader" + and isinstance(v.value, ast.Name) and v.value.id == "self" + and knoten.attr in SLOT_METHODEN): + befunde.append( + f"{fn.name}:{knoten.lineno}: `self.trader.{knoten.attr}` im " + f"BRK-Pfad — muss `self.trader_brk` sein") + return sorted(set(befunde)) + + +def _close_beide(baum: ast.AST) -> list: + """`engine.close()` muss BEIDE Slots schliessen (sonst ist eine BRK-Position + nicht schliessbar — die gefaehrlichste Variante der Luecke).""" + for kls in [n for n in ast.walk(baum) if isinstance(n, ast.ClassDef)]: + if kls.name != "TradingEngine": + continue + fn = next((f for f in kls.body + if isinstance(f, ast.FunctionDef) and f.name == "close"), None) + if fn and "trader_brk" not in ast.unparse(fn): + return ["TradingEngine.close: erwaehnt `trader_brk` NICHT — eine " + "BRK-Position waere per Dashboard nicht schliessbar"] + return [] + + +def pruefe() -> list: + p = WURZEL / "core" / "engine.py" + quelle = p.read_text(encoding="utf-8") + baum = ast.parse(quelle) + return (_init_reihenfolge(baum, quelle) + _slot_trennung(baum) + + _close_beide(baum)) + + +if __name__ == "__main__": + b = pruefe() + for z in b: + print(" FEHLER " + z) + print(" keine Slot-/Reihenfolge-Fehler." if not b + else f" {len(b)} Befund(e)") + sys.exit(1 if b else 0)