BRK bekommt einen EIGENEN Positions-Slot - unabhaengig von manuell und M15

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 <noreply@anthropic.com>
This commit is contained in:
Axel Hocks
2026-08-19 20:03:45 +02:00
co-authored by Claude Opus 5
parent 815b6a77b8
commit 9b5b20e713
4 changed files with 131 additions and 11 deletions
+58 -3
View File
@@ -253,6 +253,25 @@ class TradingEngine:
self.trader = TradeManager() self.trader = TradeManager()
self.trader.history = self.history self.trader.history = self.history
self.trail = TrailingManager(self.trader) 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) self.trail.set_notify(self._on_trail_event)
# (Dry-Run-Auto-Trader entfernt 2026-07-23 — war Verlierer im Backtest, # (Dry-Run-Auto-Trader entfernt 2026-07-23 — war Verlierer im Backtest,
# UI-Toggle längst weg. `min_conf`-Anzeigekonstante bleibt, s. unten.) # 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: if s.get("bid") and s.get("ask") and self.data.symbol:
try: try:
self.trail.on_price(self.data.symbol, s["bid"], s["ask"]) 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: except Exception as te:
log.warning(f"trail.on_price: {te}") log.warning(f"trail.on_price: {te}")
except Exception as e: except Exception as e:
@@ -828,6 +851,8 @@ class TradingEngine:
try: try:
if self.data.symbol: if self.data.symbol:
self.trader.refresh(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_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
@@ -1321,7 +1346,23 @@ class TradingEngine:
try: try:
if not self._pending_tickets: if not self._pending_tickets:
return 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") tk = ps.get("ticket")
if not tk or tk in self._pending_tagged: if not tk or tk in self._pending_tagged:
return return
@@ -1427,7 +1468,10 @@ class TradingEngine:
if not sym: if not sym:
return return
try: 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 atr = ((self.wave.snapshot() or {}).get("pb_feats") or {}).get("atr") or 0.0
ziel, quelle = self._pending_ziel() ziel, quelle = self._pending_ziel()
# Orders sollen NUR liegen, wenn zusätzlich: flat · Schonfrist vorbei. # 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 # Nacht-Fenster, News-Blackout, Circuit-Breaker und der An/Aus-Schalter
# sitzen jetzt in `_squeeze_guard()` (oben geprüft) — EINE Quelle für # sitzen jetzt in `_squeeze_guard()` (oben geprüft) — EINE Quelle für
# Pending- und Market-Weg, s. dort. # 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") ticket = ps.get("ticket")
if ticket: if ticket:
pos_dir = "LONG" if ps.get("order_type") == 0 else "SHORT" 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 # Einsatz als % der freien Margin (`margin_buffer_pct`). Greift, solange
# kein fester Betrag gesetzt ist — der hat Vorrang (s. set_margin_pct). # kein fester Betrag gesetzt ist — der hat Vorrang (s. set_margin_pct).
"margin_pct": round(_cfg_margin_buffer() * 100.0, 1), "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. # Einsatz je autonomem Pfad; 0 = der globale `margin_pct` gilt.
"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),
+54 -1
View File
@@ -70,7 +70,24 @@ _BROKER_TICK_FRESH_S = 900
class TradeManager: 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.ticket = self.order_type = None
self.entry_price = self.lots = self.pnl = self.cur_price = 0.0 self.entry_price = self.lots = self.pnl = self.cur_price = 0.0
self.sl = self.tp = self.margin = 0.0 self.sl = self.tp = self.margin = 0.0
@@ -498,6 +515,30 @@ class TradeManager:
except Exception as e: except Exception as e:
log_hist.error(f"log_trade_close: {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): def refresh(self, sym):
with mt5_lock() as got: with mt5_lock() as got:
if not got: if not got:
@@ -507,10 +548,22 @@ class TradeManager:
def _refresh_locked(self, sym): def _refresh_locked(self, sym):
with self._lock: with self._lock:
ticket = self.ticket 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: if ticket is None:
on_sym = mt5.positions_get(symbol=sym) or [] on_sym = mt5.positions_get(symbol=sym) or []
all_pos = on_sym if on_sym else (mt5.positions_get() or []) all_pos = on_sym if on_sym else (mt5.positions_get() or [])
if all_pos: 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] own = [p for p in all_pos if getattr(p, "magic", 0) == MAGIC]
pick = own[0] if own else all_pos[0] pick = own[0] if own else all_pos[0]
pos_sym = getattr(pick, "symbol", sym) pos_sym = getattr(pick, "symbol", sym)
+6
View File
@@ -83,6 +83,10 @@ class FakeTrader:
def __init__(self): def __init__(self):
self.snap: dict = {} self.snap: dict = {}
self.offset = 3 * 3600 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): def snapshot(self):
return self.snap return self.snap
@@ -97,6 +101,8 @@ def engine(history):
e = TradingEngine.__new__(TradingEngine) e = TradingEngine.__new__(TradingEngine)
e.history = history e.history = history
e.trader = FakeTrader() 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.data = types.SimpleNamespace(symbol="XTIUSD")
e.cfg = {"telegram": {"enabled": "false"}} e.cfg = {"telegram": {"enabled": "false"}}
# Zustand der geprüften Pfade # Zustand der geprüften Pfade
+13 -7
View File
@@ -14,8 +14,9 @@ identisch mit der ORDER-Nummer ist (an allen vier Broker-Datensätzen verifizier
def test_squeeze_fill_wird_getaggt(engine): def test_squeeze_fill_wird_getaggt(engine):
engine._pending_tickets = {49483981: "squeeze"} 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} "entry_price": 75.127}
engine.trader_brk.ticket = engine.trader_brk.snap.get("ticket")
engine._check_pending_fill() engine._check_pending_fill()
assert engine._squeeze_entry_count == 1 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: waren von manuellen nicht unterscheidbar), nur mit umgekehrtem Vorzeichen:
ein Fremd-Trade wuerde die B4-/B5-Monitore verfaelschen.""" ein Fremd-Trade wuerde die B4-/B5-Monitore verfaelschen."""
engine._pending_tickets = {777: "signal"} # Altbestand / unbekannt 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() engine._check_pending_fill()
assert engine._squeeze_entry_count == 0 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 — """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.""" sonst verfälscht er genau die Monitore, die der Fix reparieren soll."""
engine._pending_tickets = {111: "squeeze"} 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() engine._check_pending_fill()
assert engine._squeeze_entry_count == 0 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 """Der `_pos_loop` läuft im Sekundentakt — ohne Dedup zählte ein Trade
hundertfach und der B4-Monitor wäre wertlos.""" hundertfach und der B4-Monitor wäre wertlos."""
engine._pending_tickets = {222: "squeeze"} 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): 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 assert engine._squeeze_entry_count == 1
def test_flat_ist_ein_no_op(engine): def test_flat_ist_ein_no_op(engine):
engine._pending_tickets = {333: "squeeze"} 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() engine._check_pending_fill()
assert engine._squeeze_entry_count == 0 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): def test_ohne_pending_merker_sofort_raus(engine):
"""Der häufigste Fall — es darf keine DB-Arbeit anfallen.""" """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() engine._check_pending_fill()
assert engine._bot_open_ticket is None assert engine._bot_open_ticket is None