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
+54 -1
View File
@@ -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)