From 9b5b20e71385a731260167646b415fc00beeac46 Mon Sep 17 00:00:00 2001 From: Axel Hocks Date: Wed, 19 Aug 2026 20:03:45 +0200 Subject: [PATCH] BRK bekommt einen EIGENEN Positions-Slot - unabhaengig von manuell und M15 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User-Vorgabe: "der brk trade soll komplett unabhaengig von m15 oder manuellen trades laufen". Entwurf: docs/brk-eigener-slot.md DER BEFUND, DER DEN UMBAU KLEIN MACHT: TrailingManager.__init__ nimmt bereits SEINEN Trader entgegen und liest ausschliesslich ueber dessen snapshot() - es haengt also nicht an einer globalen Position. Damit entfaellt der 29-Stellen-Umbau in engine.py. Stattdessen: engine.trader - TradeManager() - trail - manuell + M15 engine.trader_brk - TradeManager(nur_ticket=True) - trail_brk - nur BRK Der validierte Pfad bleibt damit vollstaendig unangetastet - kein Regressionsrisiko auf der gemessen besseren Population (+1,56 gegen -4,71/Lot). DIE ZWEI KRITISCHEN STELLEN, beide gebaut: (1) ADOPTION. TradeManager.refresh() greift per Default jede Position auf dem Symbol (gewollt: ein von Hand eroeffneter Trade bekommt so den Schutz-Stack). Mit zwei Managern wuerden sich BEIDE dieselbe Position schnappen. Neu: `nur_ticket=True` sucht sich nichts, und der adoptierende Manager bekommt ueber `fremde_tickets` eine Sperrliste (BRK-Ticket + liegende Pendings). (2) DER FILL. Eine ruhende Order fuellt IM BROKER; der ticket-gebundene Slot kann sie nicht finden. Neu `TradeManager.bind(ticket, sym)`, aufgerufen aus _check_pending_fill - es sieht direkt am Broker nach, welches der liegenden Tickets zu einer Position geworden ist. WEITER GEAENDERT: _check_auto_squeeze und _manage_squeeze_pending lesen den EIGENEN Slot (eine manuelle oder M15-Position storniert die Pendings nicht mehr - real am 19.08. waren es 5 Fenster mit zusammen ~2 min am Markt, 3 davon vom M15-Trader); trail_brk wird in _price_loop mit Preisen versorgt (ohne das liefe BRK in der gemessen DURCHGEFALLENEN SL-only-Variante); Snapshot-Feld `position_brk` macht den zweiten Slot sichtbar - ohne das waere es Deployment-Drift Fall 8 in neuer Form (nicht die Strategie driftet, sondern ihre Beobachtbarkeit). TESTS: 91 gruen. tests/test_pending_fill.py auf den BRK-Slot umgestellt - die Aussagen bleiben unveraendert (fremde Quelle wird NICHT getaggt, derselbe Fill zaehlt nur einmal), nur der Slot ist ein anderer. Die Tests wurden NICHT abgeschwaecht; sie sichern weiter Deployment-Drift Fall 8. LIVE VERIFIZIERT: Slot 1 fuehrt die offene Position (T=50349215) unveraendert weiter, Slot 2 ist leer und wartet auf einen Squeeze. Deploy ueber tools/deploy.py --feld position_brk, alle 5 Schritte. ⚠ OFFEN und bewusst NICHT in diesem Zug: Circuit-Breaker und Tages-P&L summieren noch nicht BEIDE Slots, und die Dashboard-Zeile fuer den zweiten Slot fehlt. Mit zwei Positionen sind bei 40 % je Position 80 % der Margin gebunden - der Breaker (aktuell AUS) waere dann keine Kuer mehr. Co-Authored-By: Claude Opus 5 --- core/engine.py | 61 ++++++++++++++++++++++++++++++++++++-- core/trader.py | 55 +++++++++++++++++++++++++++++++++- tests/conftest.py | 6 ++++ tests/test_pending_fill.py | 20 ++++++++----- 4 files changed, 131 insertions(+), 11 deletions(-) diff --git a/core/engine.py b/core/engine.py index 77c3c80..bcc726c 100644 --- a/core/engine.py +++ b/core/engine.py @@ -253,6 +253,25 @@ class TradingEngine: self.trader = TradeManager() self.trader.history = self.history self.trail = TrailingManager(self.trader) + # ⚠⚠ ZWEITER, STRIKT TICKET-GEBUNDENER SLOT NUR FUER BRK + # (User-Vorgabe 2026-08-19: "der brk trade soll komplett unabhaengig + # von m15 oder manuellen trades laufen"). + # Entwurf: docs/brk-eigener-slot.md + # Der Trick ist, dass `TrailingManager` bereits SEINEN Trader nimmt und + # nur ueber dessen `snapshot()` liest — es haengt also nicht an einer + # globalen Position. Damit entfaellt der 29-Stellen-Umbau: BRK bekommt + # einen eigenen Manager samt vollem Schutz-Stack, und der validierte + # Pfad (manuell + M15) bleibt unangetastet. + # ⚠ `nur_ticket=True` ist zwingend: ohne das wuerden sich BEIDE Manager + # dieselbe Position greifen (der Default adoptiert per Symbol). + self.trader_brk = TradeManager(nur_ticket=True) + self.trail_brk = TrailingManager(self.trader_brk) + # ⚠ 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). + self.trader.fremde_tickets = lambda: ( + {self.trader_brk.ticket} if self.trader_brk.ticket else set() + ) | set(self._pending_tickets or {}) self.trail.set_notify(self._on_trail_event) # (Dry-Run-Auto-Trader entfernt 2026-07-23 — war Verlierer im Backtest, # UI-Toggle längst weg. `min_conf`-Anzeigekonstante bleibt, s. unten.) @@ -755,6 +774,10 @@ class TradingEngine: if s.get("bid") and s.get("ask") and self.data.symbol: try: self.trail.on_price(self.data.symbol, s["bid"], s["ask"]) + # ⚠ Ohne diese Zeile liefe der BRK-Trade in der gemessen + # DURCHGEFALLENEN SL-only-Variante (backtest_brk_slonly.py: + # alle zehn KI enthalten die Null). + self.trail_brk.on_price(self.data.symbol, s["bid"], s["ask"]) except Exception as te: log.warning(f"trail.on_price: {te}") except Exception as e: @@ -828,6 +851,8 @@ class TradingEngine: try: if self.data.symbol: self.trader.refresh(self.data.symbol) + # BRK-Slot mitfuehren (strikt ticket-gebunden, adoptiert nichts) + self.trader_brk.refresh(self.data.symbol) self._check_circuit_breaker() # Tagesverlust-Stopp (Überleben zuerst) self._check_auto_close() # ⚠ NACH `_check_auto_close`: erst dort wird der Flat-Übergang @@ -1321,7 +1346,23 @@ class TradingEngine: try: if not self._pending_tickets: return - ps = self.trader.snapshot() + # ⚠⚠ Der BRK-Slot ist STRIKT ticket-gebunden und adoptiert nichts — + # er kann den Fill also nicht selbst finden. Deshalb hier direkt + # am Broker nachsehen, welches der liegenden Tickets zu einer + # Position geworden ist, und es dem Slot ZUWEISEN. + # (Bei einem Pending-Fill ist die Positions-Nummer identisch mit + # der Order-Nummer — an vier echten Datensaetzen verifiziert.) + if self.trader_brk.ticket is None: + try: + with mt5_lock(timeout=5) as _got: + offen = {int(p.ticket) for p in (mt5.positions_get() or ())} if _got else set() + except Exception: + offen = set() + for _tk in list(self._pending_tickets): + if int(_tk) in offen and self.trader_brk.bind(int(_tk), self.data.symbol): + log.info(f"🧷 BRK-Slot uebernimmt Pending-Fill T={_tk}") + break + ps = self.trader_brk.snapshot() tk = ps.get("ticket") if not tk or tk in self._pending_tagged: return @@ -1427,7 +1468,10 @@ class TradingEngine: if not sym: return try: - ps = self.trader.snapshot() + # ⚠ NUR der eigene Slot entscheidet — vorher stornierte JEDE + # offene Position die Pendings (real am 19.08.: 5 Fenster, + # zusammen ~2 min am Markt, 3 davon vom M15-Trader). + ps = self.trader_brk.snapshot() atr = ((self.wave.snapshot() or {}).get("pb_feats") or {}).get("atr") or 0.0 ziel, quelle = self._pending_ziel() # Orders sollen NUR liegen, wenn zusätzlich: flat · Schonfrist vorbei. @@ -1510,7 +1554,9 @@ class TradingEngine: # Nacht-Fenster, News-Blackout, Circuit-Breaker und der An/Aus-Schalter # sitzen jetzt in `_squeeze_guard()` (oben geprüft) — EINE Quelle für # Pending- und Market-Weg, s. dort. - ps = self.trader.snapshot() + # ⚠ EIGENER Slot: eine manuelle oder M15-Position blockt BRK + # nicht mehr (User-Vorgabe 2026-08-19). + ps = self.trader_brk.snapshot() ticket = ps.get("ticket") if ticket: pos_dir = "LONG" if ps.get("order_type") == 0 else "SHORT" @@ -4541,6 +4587,15 @@ class TradingEngine: # Einsatz als % der freien Margin (`margin_buffer_pct`). Greift, solange # kein fester Betrag gesetzt ist — der hat Vorrang (s. set_margin_pct). "margin_pct": round(_cfg_margin_buffer() * 100.0, 1), + # ⚠⚠ ZWEITER Slot sichtbar machen. Ohne dieses Feld liefe eine + # offene BRK-Position unbeobachtet — genau Deployment-Drift Fall 8 + # (dort driftete nicht die Strategie, sondern ihre Beobachtbarkeit). + "position_brk": (lambda ps: { + "ticket": ps.get("ticket"), "lots": ps.get("lots"), + "entry": ps.get("entry_price"), "pnl": ps.get("pnl"), + "sl": ps.get("sl"), "tp": ps.get("tp"), + "trail": bool(getattr(self.trail_brk, "enabled", False)), + } if ps.get("ticket") else None)(self.trader_brk.snapshot()), # Einsatz je autonomem Pfad; 0 = der globale `margin_pct` gilt. "margin_brk": getattr(self, "_margin_brk", 0.0), "margin_m15": getattr(self, "_margin_m15", 0.0), diff --git a/core/trader.py b/core/trader.py index 8bf4566..4fc3815 100644 --- a/core/trader.py +++ b/core/trader.py @@ -70,7 +70,24 @@ _BROKER_TICK_FRESH_S = 900 class TradeManager: - def __init__(self): + def __init__(self, nur_ticket: bool = False): + """`nur_ticket=True` => STRIKT an das eigene Ticket gebunden. + + ⚠⚠ DER KERN DES ZWEI-SLOT-BETRIEBS (2026-08-19). Der Default-Manager + ADOPTIERT jede Position auf dem Symbol (s. `_refresh_locked`) — das ist + gewollt, damit ein von Hand im Terminal eroeffneter Trade den vollen + Schutz-Stack bekommt. Laufen aber ZWEI Manager, wuerden sich beide + dieselbe Position greifen: Trailing zweimal auf einem Ticket, oder beide + auf dem falschen. Der BRK-Manager sucht sich deshalb NICHTS: ist sein + Ticket None, ist er flat und bleibt es, bis ihm eines zugewiesen wird. + Der bestehende Manager laeuft mit `False` bitgenau wie bisher.""" + self.nur_ticket = bool(nur_ticket) + # ⚠⚠ Gegenstueck zu `nur_ticket`: der ADOPTIERENDE Manager muss die + # Tickets des anderen Slots in Ruhe lassen, sonst greift er die + # BRK-Position, sobald er selbst flat ist — und dann verwalten BEIDE + # dieselbe Position. Engine setzt hier einen Callable, der die aktuell + # fremden Tickets liefert. + self.fremde_tickets = None # Callable[[], set[int]] | None self.ticket = self.order_type = None self.entry_price = self.lots = self.pnl = self.cur_price = 0.0 self.sl = self.tp = self.margin = 0.0 @@ -498,6 +515,30 @@ class TradeManager: except Exception as e: log_hist.error(f"log_trade_close: {e}") + def bind(self, ticket: int, sym: str) -> bool: + """Dem ticket-gebundenen Slot eine bestehende Position zuweisen. + + Gedacht fuer den Pending-Fill: die Order fuellt IM BROKER, es gibt also + keinen `open_*`-Aufruf, ueber den der Manager sein Ticket bekaeme. + ⚠ Nur fuer `nur_ticket`-Manager — der adoptierende braucht es nicht.""" + with mt5_lock(timeout=10) as got: + if not got: + return False + pos = mt5.positions_get(ticket=int(ticket)) + if not pos: + return False + pk = pos[0] + _boff = self._broker_offset_s(getattr(pk, "symbol", sym)) + with self._lock: + self.ticket = pk.ticket; self.order_type = pk.type + self.entry_price = pk.price_open + self.symbol = getattr(pk, "symbol", sym) + self.lots = pk.volume; self.pnl = pk.profit + self.sl = float(getattr(pk, "sl", 0.0) or 0.0) + self.tp = float(getattr(pk, "tp", 0.0) or 0.0) + self.open_time = float(getattr(pk, "time", 0) or 0) - _boff + return True + def refresh(self, sym): with mt5_lock() as got: if not got: @@ -507,10 +548,22 @@ class TradeManager: def _refresh_locked(self, sym): with self._lock: ticket = self.ticket + if ticket is None and self.nur_ticket: + # Strikt ticket-gebunden: KEINE Adoption. Ohne eigenes Ticket flat. + return if ticket is None: on_sym = mt5.positions_get(symbol=sym) or [] all_pos = on_sym if on_sym else (mt5.positions_get() or []) if all_pos: + fremd = set() + if self.fremde_tickets: + try: + fremd = {int(x) for x in (self.fremde_tickets() or ())} + except Exception: + fremd = set() + all_pos = [p for p in all_pos if int(p.ticket) not in fremd] + if not all_pos: + return own = [p for p in all_pos if getattr(p, "magic", 0) == MAGIC] pick = own[0] if own else all_pos[0] pos_sym = getattr(pick, "symbol", sym) diff --git a/tests/conftest.py b/tests/conftest.py index 61a08d2..39ffef9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -83,6 +83,10 @@ class FakeTrader: def __init__(self): self.snap: dict = {} self.offset = 3 * 3600 + # ⚠ Der BRK-Slot ist strikt ticket-gebunden; `_check_pending_fill` + # prueft `ticket is None`, um zu entscheiden, ob es binden muss. + # Der Test setzt es direkt, damit kein MT5 gebraucht wird. + self.ticket = None def snapshot(self): return self.snap @@ -97,6 +101,8 @@ def engine(history): e = TradingEngine.__new__(TradingEngine) e.history = history e.trader = FakeTrader() + # Zweiter, strikt ticket-gebundener Slot fuer BRK (Umbau 2026-08-19). + e.trader_brk = FakeTrader() e.data = types.SimpleNamespace(symbol="XTIUSD") e.cfg = {"telegram": {"enabled": "false"}} # Zustand der geprüften Pfade diff --git a/tests/test_pending_fill.py b/tests/test_pending_fill.py index 732280d..519dc67 100644 --- a/tests/test_pending_fill.py +++ b/tests/test_pending_fill.py @@ -14,8 +14,9 @@ identisch mit der ORDER-Nummer ist (an allen vier Broker-Datensätzen verifizier def test_squeeze_fill_wird_getaggt(engine): engine._pending_tickets = {49483981: "squeeze"} - engine.trader.snap = {"ticket": 49483981, "order_type": 1, + engine.trader_brk.snap = {"ticket": 49483981, "order_type": 1, "entry_price": 75.127} + engine.trader_brk.ticket = engine.trader_brk.snap.get("ticket") engine._check_pending_fill() assert engine._squeeze_entry_count == 1 @@ -33,7 +34,8 @@ def test_unbekannte_quelle_wird_NICHT_getaggt(engine): waren von manuellen nicht unterscheidbar), nur mit umgekehrtem Vorzeichen: ein Fremd-Trade wuerde die B4-/B5-Monitore verfaelschen.""" engine._pending_tickets = {777: "signal"} # Altbestand / unbekannt - engine.trader.snap = {"ticket": 777, "order_type": 0, "entry_price": 75.2} + engine.trader_brk.snap = {"ticket": 777, "order_type": 0, "entry_price": 75.2} + engine.trader_brk.ticket = engine.trader_brk.snap.get("ticket") engine._check_pending_fill() assert engine._squeeze_entry_count == 0 @@ -48,7 +50,8 @@ def test_fremde_position_wird_nicht_getaggt(engine): """Der gefährliche Fall: ein manueller Trade darf NICHT als Bot-Trade gelten — sonst verfälscht er genau die Monitore, die der Fix reparieren soll.""" engine._pending_tickets = {111: "squeeze"} - engine.trader.snap = {"ticket": 999, "order_type": 0, "entry_price": 75.0} + engine.trader_brk.snap = {"ticket": 999, "order_type": 0, "entry_price": 75.0} + engine.trader_brk.ticket = engine.trader_brk.snap.get("ticket") engine._check_pending_fill() assert engine._squeeze_entry_count == 0 @@ -60,16 +63,18 @@ def test_derselbe_fill_zaehlt_nur_einmal(engine): """Der `_pos_loop` läuft im Sekundentakt — ohne Dedup zählte ein Trade hundertfach und der B4-Monitor wäre wertlos.""" engine._pending_tickets = {222: "squeeze"} - engine.trader.snap = {"ticket": 222, "order_type": 1, "entry_price": 74.9} + engine.trader_brk.snap = {"ticket": 222, "order_type": 1, "entry_price": 74.9} for _ in range(5): - engine._check_pending_fill() + engine.trader_brk.ticket = engine.trader_brk.snap.get("ticket") + engine._check_pending_fill() assert engine._squeeze_entry_count == 1 def test_flat_ist_ein_no_op(engine): engine._pending_tickets = {333: "squeeze"} - engine.trader.snap = {"ticket": None} + engine.trader_brk.snap = {"ticket": None} + engine.trader_brk.ticket = engine.trader_brk.snap.get("ticket") engine._check_pending_fill() assert engine._squeeze_entry_count == 0 @@ -78,7 +83,8 @@ def test_flat_ist_ein_no_op(engine): def test_ohne_pending_merker_sofort_raus(engine): """Der häufigste Fall — es darf keine DB-Arbeit anfallen.""" - engine.trader.snap = {"ticket": 444, "order_type": 0} + engine.trader_brk.snap = {"ticket": 444, "order_type": 0} + engine.trader_brk.ticket = engine.trader_brk.snap.get("ticket") engine._check_pending_fill() assert engine._bot_open_ticket is None