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:
co-authored by
Claude Opus 5
parent
815b6a77b8
commit
9b5b20e713
+58
-3
@@ -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),
|
||||
|
||||
+54
-1
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,8 +63,9 @@ 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.trader_brk.ticket = engine.trader_brk.snap.get("ticket")
|
||||
engine._check_pending_fill()
|
||||
|
||||
assert engine._squeeze_entry_count == 1
|
||||
@@ -69,7 +73,8 @@ def test_derselbe_fill_zaehlt_nur_einmal(engine):
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user