Pipeline Stufe E: Slot-Trennung und Attribut-Reihenfolge (tools/check_slots.py)

User: "Uebernehme die Logikpruefung auf allen Slots in die Deployment pipeline" -
nach dem Ausfall, bei dem der Serverstart komplett abbrach und zwei Minuten lang
kein Close moeglich war.

ZWEI FEHLERKLASSEN, beide binnen zwei Tagen real aufgetreten, beide von KEINER
bestehenden Stufe gefangen (py_compile, ruff F821, ty, pytest: alle gruen):

(A) ATTRIBUT VOR SEINER ZUWEISUNG. `self.trader_brk.history = self.history`
    stand in `__init__` VOR `self.trader_brk = TradeManager(...)`.
    AttributeError im Konstruktor -> Serverstart brach ab -> "Netzwerkfehler" am
    Close-Knopf. Syntaktisch 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 sogar still.
    Die Pruefung sammelt je `self.X` die ZEILE der ersten Zuweisung und meldet
    jede Benutzung davor.

(B) SLOT-VERWECHSLUNG. BRK-Pfade (_check_auto_squeeze, _manage_squeeze_pending,
    _check_pending_fill) duerfen den SLOT-ZUSTAND von `self.trader` nicht
    anfassen (close/snapshot/open_*/set_open_context/refresh). ⚠ Bewusst eine
    WEISSE Liste der Pfade - eine schwarze wuerde jede neue Funktion still
    durchlassen. `pending_orders`/`cancel_pending`/`place_stop` sind ausdruecklich
    ERLAUBT: magic-basierte Broker-Abfragen ohne Slot-Zustand (geprueft).
    Dazu die Invariante, dass `TradingEngine.close` beide Slots erwaehnen muss -
    sonst waere eine BRK-Position per Dashboard nicht schliessbar.

DREI RICHTUNGEN VERIFIZIERT: sauber -> gruen; Reihenfolge-Fehler zurueckgebaut ->
"self.trader_brk benutzt, aber erst in Zeile 270 zugewiesen"; Slot-Verwechslung
zurueckgebaut -> "self.trader.close im BRK-Pfad". Danach wieder gruen.

Statisch (AST), kein MT5, Millisekunden. Werkzeugfehler gilt NICHT als sauber.
Laeuft ueber check_nfalle.py im pre-commit-Hook mit.

⚠ Was diese Stufe NICHT kann: ein Startfehler AUSSERHALB von __init__. Dafuer
bliebe ein echter Rauchtest mit gestubbtem MT5 - der ist nicht gebaut.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Axel Hocks
2026-08-20 08:47:28 +02:00
co-authored by Claude Opus 5
parent a652223a05
commit 2b498fc20e
3 changed files with 239 additions and 1 deletions
+68 -1
View File
@@ -268,6 +268,14 @@ class TradingEngine:
# dieselbe Position greifen (der Default adoptiert per Symbol). # dieselbe Position greifen (der Default adoptiert per Symbol).
self.trader_brk = TradeManager(nur_ticket=True) self.trader_brk = TradeManager(nur_ticket=True)
self.trail_brk = TrailingManager(self.trader_brk) 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 # ⚠ Der adoptierende Manager darf den BRK-Slot und dessen noch offene
# Pending-Tickets NICHT greifen — sonst verwalten beide dieselbe # Pending-Tickets NICHT greifen — sonst verwalten beide dieselbe
# Position (Trailing zweimal, Notfall-Stop am falschen Trade). # Position (Trailing zweimal, Notfall-Stop am falschen Trade).
@@ -859,6 +867,7 @@ class TradingEngine:
self.trader.refresh(self.data.symbol) self.trader.refresh(self.data.symbol)
# BRK-Slot mitfuehren (strikt ticket-gebunden, adoptiert nichts) # BRK-Slot mitfuehren (strikt ticket-gebunden, adoptiert nichts)
self.trader_brk.refresh(self.data.symbol) self.trader_brk.refresh(self.data.symbol)
self._rebind_brk() # nach Neustart zurueckbinden
self._check_circuit_breaker() # Tagesverlust-Stopp (Überleben zuerst) self._check_circuit_breaker() # Tagesverlust-Stopp (Überleben zuerst)
self._check_auto_close() self._check_auto_close()
# ⚠ NACH `_check_auto_close`: erst dort wird der Flat-Übergang # ⚠ 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 " log.warning(f"⚠ DB-Nachtrag T={tk} nach {n} Versuchen aufgegeben "
f"— der Trade bleibt setup=NULL und fehlt in B4/B5.") 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): def _check_open_notify(self):
"""Telegram bei JEDER Eröffnung — beide Slots (User-Vorgabe 2026-08-19). """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) self._manual_margin = _set(st["manual_margin"] or 0)
if "auto_m15" in st: if "auto_m15" in st:
self._auto_m15 = bool(st["auto_m15"]) 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: if "margin_brk" in st:
self._margin_brk = max(0.0, min(99.0, float(st["margin_brk"] or 0))) self._margin_brk = max(0.0, min(99.0, float(st["margin_brk"] or 0)))
if "margin_m15" in st: if "margin_m15" in st:
@@ -2535,6 +2592,14 @@ class TradingEngine:
# 0 = aus. So ueberlebt auch die Hoehe den Neustart. # 0 = aus. So ueberlebt auch die Hoehe den Neustart.
"cb_limit_pct": getattr(self, "_cb_limit_pct", 0.0), "cb_limit_pct": getattr(self, "_cb_limit_pct", 0.0),
"cb_remember": getattr(self, "_cb_remember", 8.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_brk": getattr(self, "_margin_brk", 0.0),
"margin_m15": getattr(self, "_margin_m15", 0.0), "margin_m15": getattr(self, "_margin_m15", 0.0),
"auto_m15": getattr(self, "_auto_m15", False), "auto_m15": getattr(self, "_auto_m15", False),
@@ -2817,7 +2882,9 @@ class TradingEngine:
elif sig != want: elif sig != want:
log.info(f"[ORDER] {want} ohne passende Empfehlung (Signal={sig}) " log.info(f"[ORDER] {want} ohne passende Empfehlung (Signal={sig}) "
f"— vom User bestätigt, wird eröffnet") 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 # ⚠ SL-ATR-Zeitebene quellen-abhaengig (2026-08-05, User-Entscheidung nach
# `backtest_sl_basis.py`): SQUEEZE bekommt **M5**, alles andere bleibt bei # `backtest_sl_basis.py`): SQUEEZE bekommt **M5**, alles andere bleibt bei
# M15. Begruendung: auf Squeeze-Entries ist der Ertrag zwischen beiden # M15. Begruendung: auf Squeeze-Entries ist der Ertrag zwischen beiden
+29
View File
@@ -420,8 +420,37 @@ print(" keine gefunden." if not sig else f" {sig} Stellen.")
print() print()
print("=" * 88) 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_typen()
_stufe_render() _stufe_render()
_stufe_slots()
print("") print("")
print("=" * 88) print("=" * 88)
+142
View File
@@ -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)