diff --git a/CLAUDE.md b/CLAUDE.md index a152b0c..17ecd9e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8814,6 +8814,83 @@ das der Nutzer gleichzeitig bedient, gehört die Logspur mitgelesen.** nächsten Squeeze mit offener Position — Log `🧷 BRK-Slot uebernimmt Pending-Fill` und `position_brk.trail == true`. +## ✅⚠⚠ G/V-TRENNUNG GEPRÜFT — die Sperre riss bei JEDEM Neustart auf (2026-08-20, v=201) + +User: „überprüfe ob wirklich die G/V und G/V BRK Anzeigen stimmen, besonders bei +gleichzeitig laufenden Trades. Die Anzeige darf jeweils nur den eigenen Slot +anzeigen." + +✅ **Das Frontend ist sauber**: `#hdr-pnl` liest ausschließlich `d.position`, +`#hdr-pnl-brk` ausschließlich `d.position_brk`. Keine Summe, kein Fallback, +keine gemeinsame Variable. ⚠ Aber das ist auch die *falsche* Stelle zum Suchen: +stehen im Backend beide Felder auf derselben Position, kann keine Anzeige das +mehr richten. Geprüft wurde deshalb die **Auswahl** in +`TradeManager._refresh_locked`. + +⚠⚠ **DER FUND — die Adoptions-Sperre war unvollständig, und sie riss bei JEDEM +Neustart auf.** Ein BRK-Ticket kann aus **drei** Quellen kommen; die Sperrliste +kannte nur zwei: +| Quelle | in `fremde_tickets`? | lebt | +|---|---|---| +| `trader_brk.ticket` | ✅ | nur im Speicher | +| `_pending_tickets` | ✅ | nur im Speicher | +| **`_brk_restore`** (aus `runtime_state.json`) | ❌ **fehlte** | überlebt den Neustart | +Nach einem Neustart sind die ersten beiden **leer**. Und die Reihenfolge im +`_pos_loop` besiegelt es: `trader.refresh()` läuft, und erst **drei Zeilen +später** `_rebind_brk()`. Slot 1 greift die BRK-Position also, **bevor** sie +zurückgebunden werden kann. +⚠ `_rebind_brk` erkennt das zwar und bricht ab („hängt bereits an Slot 1") — +aber damit ist der Schaden nur festgestellt, nicht behoben: die BRK-Position +hängt am falschen Slot, ihr **G/V steht links statt rechts**, und „G/V BRK" +zeigt „—". Danach sieht alles normal aus, was es besonders schwer sichtbar macht +— dieselbe Signatur wie der Fehler vom Vortag, der `_rebind_brk` überhaupt erst +nötig gemacht hat. +✅ **Behoben**: `_brk_restore` steht jetzt mit in der Sperrliste. Der Zugriff +läuft über `getattr(..., None)`, ist also unabhängig davon, WANN das Attribut +gesetzt wird — genau die Reihenfolge-Falle, die in zwei Tagen zweimal +zugeschlagen hat. + +✅✅ **ZWEITER FUND — zwei verschiedene Rechnungen nebeneinander.** Bei +geschlossenem Markt zeigt Slot 1 seit dem 01.08. eine **HL-Schätzung** +(`pnl_hl`), weil der Broker-P&L stillsteht. Der BRK-Slot bekam die nie — er +zeigte den **eingefrorenen** Broker-Wert, ohne dass man es der Anzeige ansieht. +Neu `pnl_hl_brk` (dieselbe `live_pnl`-Rechnung auf dem eigenen Slot, mit ≈ +markiert). ⚠ Geprüft statt angenommen, dass `_tick_size`/`_tick_value` am +BRK-Slot überhaupt gesetzt sind — `_refresh_locked` zieht sie bei gesetztem +Ticket jeden Tick nach. + +✅ **`day_pl.open` summiert korrekt BEIDE Slots** — die Summe gehört dorthin, +die Trennung in die beiden G/V-Felder. + +**BELEG — `tests/test_slot_gv.py`, 6 Tests auf dem ECHTEN `TradeManager`** +(Broker gestubbt, Sperrliste wortgleich zur Engine nachgebaut): zwei Positionen +gleichzeitig landen je im eigenen Slot · Slot 1 adoptiert die BRK-Position nicht +· ein Pending-Fill wird nicht vorher weggeschnappt · der BRK-Slot adoptiert +**nie** · und die **Regressionsprobe**, die den alten Zustand festhält: ohne den +Merker in der Sperrliste greift Slot 1 zu. +✅ **Neue Pipeline-Stufe (E, `tools/check_slots.py`)**: die Sperrliste muss alle +**drei** Namen erwähnen. Mutationsprobe: Merker entfernt → Stufe E **Exit 1**. +⚠⚠ **Ehrlich zur Arbeitsteilung der beiden Prüfungen:** der pytest belegt den +**Mechanismus** (eine Sperrliste ohne das Ticket lässt die Adoption zu) — er +baut die Lambda selbst nach und bleibt bei der Mutation deshalb grün. Erst die +statische Stufe belegt, dass die **Engine** sie richtig verdrahtet. **Keine der +beiden allein hätte gereicht.** + +⚠ Live nach dem Deploy verifiziert: Slot 1 `T=50396824` (+10,34), BRK flat, +`day_pl.open` 10,34 — kein gemeinsames Ticket. Der Neustart adoptierte das +Ticket korrekt in Slot 1 (BRK war flat, die Sperre blockt also nicht zu viel). +⚠ **Noch nicht beobachtet**: ein Neustart *mit* offener BRK-Position — der Fall, +den der Fix adressiert. Beweis wäre dann „BRK-Slot nach Neustart +zurueckgebunden" **ohne** vorangehendes „magic-match" auf dasselbe Ticket. + +⚠⚠ **VIERTER Surrogat-Abbruch beim Schreiben dieses Abschnitts — und wieder +OHNE Schaden.** Ein Emoji als ZWEI getrennte Unicode-Escapes erzeugt lone +surrogates; das Öffnen im Schreibmodus kürzt die Datei aber schon VOR dem +Schreiben. Die Regel vom 19.08. (erst `.tmp`, Größe prüfen, dann `os.replace`) +hat gehalten: CLAUDE.md blieb unversehrt, es entstand nur eine leere `.tmp`. +✅ Konsequenz: dieser Abschnitt wurde als **reiner UTF-8-Text** geschrieben, +ohne jedes Escape — das ist der Weg, der die Falle gar nicht erst aufstellt. + ## ⚠⚠⚠ P(break) IST LIVE **INVERTIERT** — und es steuert echtes Geld (2026-08-19) Die fällige Messung ist entscheidbar geworden: **n=530 entkoppelt** gegen die diff --git a/core/engine.py b/core/engine.py index 0bca046..e314938 100644 --- a/core/engine.py +++ b/core/engine.py @@ -279,9 +279,22 @@ class TradingEngine: # ⚠ 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). + # ⚠⚠ DER MERKER `_brk_restore` GEHOERT MIT IN DIE SPERRLISTE — sonst + # reisst sie bei JEDEM Neustart auf. Reihenfolge im `_pos_loop`: + # `trader.refresh()` laeuft, und erst DREI Zeilen spaeter + # `_rebind_brk()`. Nach einem Neustart ist `trader_brk.ticket` noch + # None und `_pending_tickets` leer (beide leben nur im Speicher) — + # Slot 1 greift die BRK-Position also, BEVOR sie zurueckgebunden + # werden kann. `_rebind_brk` erkennt das zwar und bricht ab, aber dann + # ist es zu spaet: die BRK-Position haengt an Slot 1, ihr G/V steht + # links statt rechts, und „G/V BRK" zeigt „—". + # Mit Regressionsprobe belegt: `tests/test_slot_gv.py`. self.trader.fremde_tickets = lambda: ( - {self.trader_brk.ticket} if self.trader_brk.ticket else set() - ) | set(self._pending_tickets or {}) + ({self.trader_brk.ticket} if self.trader_brk.ticket else set()) + | set(self._pending_tickets or {}) + | ({int(self._brk_restore)} if getattr(self, "_brk_restore", None) + else set()) + ) 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.) @@ -4825,14 +4838,28 @@ class TradingEngine: # drinstecken. # ⚠ Bleibt eine SCHÄTZUNG: HL ist ein anderer Kontrakt (Basis herausgerechnet, # aber nicht exakt), und der Spread am Montags-Open ist unbekannt. - _pnl_hl = None + _pnl_hl = _pnl_hl_brk = None try: - if _mkt_closed and _hl_live and position and position.get("ticket"): + if _mkt_closed and _hl_live: _mid = float(_hl_live["mid_mt5"]) _sp = float((market or {}).get("spread") or 0.0) - _v = self.trader.live_pnl(_mid - _sp / 2.0, _mid + _sp / 2.0) - if _v is not None: - _pnl_hl = round(_v, 2) + _b, _a = _mid - _sp / 2.0, _mid + _sp / 2.0 + if position and position.get("ticket"): + _v = self.trader.live_pnl(_b, _a) + if _v is not None: + _pnl_hl = round(_v, 2) + # ⚠⚠ AUCH fuer den BRK-Slot (2026-08-20). Ohne das zeigte bei + # geschlossenem Markt links eine HL-SCHAETZUNG und rechts der + # EINGEFRORENE Broker-Wert — zwei verschiedene Rechnungen + # nebeneinander, ohne dass man es der Anzeige ansieht. + # `live_pnl` liest ausschliesslich den Zustand SEINER Instanz, + # ist also je Slot sauber; `_tick_size`/`_tick_value` sind am + # BRK-Slot gesetzt, weil `_refresh_locked` sie bei gesetztem + # Ticket jeden Tick nachzieht (geprueft, nicht angenommen). + if getattr(self.trader_brk, "ticket", None): + _vb = self.trader_brk.live_pnl(_b, _a) + if _vb is not None: + _pnl_hl_brk = round(_vb, 2) except Exception: pass @@ -4995,6 +5022,7 @@ class TradingEngine: "hl_live": _hl_live, # G/V-Schaetzung auf HL-Basis, nur bei geschlossenem Markt (sonst None) "pnl_hl": _pnl_hl, + "pnl_hl_brk": _pnl_hl_brk, } # ── Entry-Checkliste je Richtung (User-Vorgabe 2026-07-30: „blende einen # Hinweis ein, wenn die Checkliste gegen meine Eröffnung ist"). Wird im diff --git a/tests/test_slot_gv.py b/tests/test_slot_gv.py new file mode 100644 index 0000000..d8fcb9b --- /dev/null +++ b/tests/test_slot_gv.py @@ -0,0 +1,159 @@ +"""Zeigen G/V und G/V BRK wirklich NUR ihren eigenen Slot? (2026-08-20) + +ANLASS (User): "ueberpruefe ob wirklich die G/V und G/V BRK Anzeigen stimmen, +besonders bei gleichzeitig laufenden Trades. Die Anzeige darf jeweils nur den +eigenen Slot anzeigen." + +Geprueft wird die STELLE, an der die Trennung entstehen muss - die Auswahl der +Position in `TradeManager._refresh_locked`. Das Frontend liest nur `d.position` +bzw. `d.position_brk`; wenn schon dort dieselbe Position steht, kann keine +Anzeige das noch richten. + +DIE GEFAEHRLICHE STELLE ist die ADOPTION: der Haupt-Trader greift jede Position +auf dem Symbol, damit ein von Hand eroeffneter Trade den Schutz-Stack bekommt. +Genau das darf er beim BRK-Ticket NICHT - dafuer gibt es `fremde_tickets`. +Diese Sperrliste wird hier in EXAKT der Form nachgebaut, in der die Engine sie +setzt (core/engine.py, `self.trader.fremde_tickets = ...`). +""" +import types + +import pytest + +from core import trader as trader_mod +from core.trader import TradeManager + + +class _Pos: + def __init__(self, ticket, lots=1.0, entry=80.0, pnl=0.0, magic=0): + self.ticket, self.volume, self.price_open = ticket, lots, entry + self.profit, self.magic = pnl, magic + self.type, self.symbol, self.time = 0, "SpotCrude", 1_700_000_000 + self.sl, self.tp = 79.0, 82.0 + + +@pytest.fixture +def welt(monkeypatch): + """Stub-Broker mit einer frei setzbaren Positionsliste.""" + offen = {} + + def positions_get(**kw): + if "ticket" in kw: + p = offen.get(int(kw["ticket"])) + return (p,) if p else () + return tuple(offen.values()) + + fake = types.SimpleNamespace( + positions_get=positions_get, + order_calc_margin=lambda *a, **k: 700.0, + order_send=lambda *a, **k: None, + ORDER_TYPE_BUY=0, ORDER_TYPE_SELL=1, + TRADE_ACTION_SLTP=6, TRADE_RETCODE_DONE=10009, + symbol_info_tick=lambda s: None, + symbol_info=lambda s: None, + ) + monkeypatch.setattr(trader_mod, "mt5", fake) + monkeypatch.setattr(trader_mod, "MAGIC", 4711, raising=False) + + class _Lock: + def __enter__(self): return True + def __exit__(self, *a): return False + monkeypatch.setattr(trader_mod, "mt5_lock", lambda *a, **k: _Lock()) + return offen + + +def _slots(pending=None, brk_restore=None): + """Beide Manager wie in `TradingEngine.__init__` verdrahtet.""" + haupt, brk = TradeManager(), TradeManager(nur_ticket=True) + haupt._broker_offset_s = lambda sym=None: 0 + brk._broker_offset_s = lambda sym=None: 0 + pend = dict(pending or {}) + haupt.fremde_tickets = lambda: ( + ({brk.ticket} if brk.ticket else set()) | set(pend) + | ({brk_restore} if brk_restore else set()) + ) + return haupt, brk, pend + + +def _gv(haupt, brk): + """Genau das, was `snapshot()` ans Frontend gibt.""" + a, b = haupt.snapshot(), brk.snapshot() + return (a.get("ticket"), a.get("pnl")), (b.get("ticket"), b.get("pnl")) + + +# -------------------------------------------------------------------------- +def test_zwei_positionen_jede_im_eigenen_slot(welt): + """Der Normalfall, nach dem der User fragt: BEIDE Slots gleichzeitig.""" + haupt, brk, _ = _slots() + welt[100] = _Pos(100, pnl=-12.50, magic=4711) # BRK, gefuellte Pending + brk.bind(100, "SpotCrude") + welt[200] = _Pos(200, pnl=+31.00, magic=4711) # manueller Trade + haupt.refresh("SpotCrude") + + (t1, p1), (t2, p2) = _gv(haupt, brk) + assert t1 == 200 and p1 == pytest.approx(31.00), "Slot 1 hat die falsche Position" + assert t2 == 100 and p2 == pytest.approx(-12.50), "BRK hat die falsche Position" + assert t1 != t2, "BEIDE Anzeigen zeigen dasselbe Ticket" + + +def test_haupt_adoptiert_die_brk_position_NICHT(welt): + """Nur BRK offen, Slot 1 flat -> Slot 1 muss LEER bleiben.""" + haupt, brk, _ = _slots() + welt[100] = _Pos(100, pnl=-5.0, magic=4711) + brk.bind(100, "SpotCrude") + haupt.refresh("SpotCrude") + + (t1, _), (t2, _) = _gv(haupt, brk) + assert t1 is None, "Slot 1 hat die BRK-Position adoptiert -> G/V zeigt sie doppelt" + assert t2 == 100 + + +def test_pending_fill_wird_nicht_vorher_adoptiert(welt): + """Der Fill liegt am Broker, bevor `_check_pending_fill` laeuft. + + Die Positions-Nummer ist dann identisch mit der Order-Nummer - deshalb + haelt `_pending_tickets` die Luecke zu. + """ + haupt, brk, _ = _slots(pending={100: "squeeze"}) + welt[100] = _Pos(100, pnl=+3.0, magic=4711) # gerade gefuellt + haupt.refresh("SpotCrude") # laeuft ZUERST im _pos_loop + + assert haupt.snapshot().get("ticket") is None, ( + "Slot 1 hat den BRK-Pending-Fill adoptiert, bevor der BRK-Slot ihn " + "uebernehmen konnte") + assert brk.bind(100, "SpotCrude") + assert brk.snapshot().get("ticket") == 100 + + +def test_brk_slot_adoptiert_niemals(welt): + """`nur_ticket=True` darf sich NIE etwas greifen - auch nicht bei magic.""" + _, brk, _ = _slots() + welt[200] = _Pos(200, pnl=+9.0, magic=4711) + brk.refresh("SpotCrude") + assert brk.snapshot().get("ticket") is None + + +def test_neustart_ohne_merker_in_der_sperrliste_BRICHT(welt): + """REGRESSIONSPROBE: so sah es aus, bevor `_brk_restore` mit sperrte. + + Nach einem Neustart ist `trader_brk.ticket` None und `_pending_tickets` + leer. Steht der Merker NICHT in der Sperrliste, greift Slot 1 die + BRK-Position - und das passiert im `_pos_loop` schon eine Zeile VOR + `_rebind_brk()`. + """ + haupt, brk, _ = _slots(brk_restore=None) # <- alter Zustand + welt[100] = _Pos(100, pnl=-40.0, magic=4711) + haupt.refresh("SpotCrude") + assert haupt.snapshot().get("ticket") == 100, ( + "Der Test bildet den alten Zustand nicht mehr ab") + + +def test_neustart_mit_merker_bleibt_getrennt(welt): + """Mit dem Merker in der Sperrliste ueberlebt die Trennung den Neustart.""" + haupt, brk, _ = _slots(brk_restore=100) + welt[100] = _Pos(100, pnl=-40.0, magic=4711) + haupt.refresh("SpotCrude") + assert haupt.snapshot().get("ticket") is None, ( + "Slot 1 hat die BRK-Position trotz Merker adoptiert") + assert brk.bind(100, "SpotCrude") # _rebind_brk kommt danach + (t1, _), (t2, _) = _gv(haupt, brk) + assert t1 is None and t2 == 100 diff --git a/tools/check_slots.py b/tools/check_slots.py index c8f5a49..1cefe07 100644 --- a/tools/check_slots.py +++ b/tools/check_slots.py @@ -125,12 +125,46 @@ def _close_beide(baum: ast.AST) -> list: return [] +def _sperrliste_vollstaendig(baum: ast.AST) -> list: + """(D) Die Adoptions-Sperre muss ALLE DREI Quellen eines BRK-Tickets kennen. + + ⚠⚠ Sie hatte am 2026-08-20 eine Luecke, die bei JEDEM Neustart aufriss: + `trader_brk.ticket` und `_pending_tickets` leben nur im Speicher, nach einem + Neustart sind beide leer. Das Ticket steht dann allein im Merker + `_brk_restore` — und weil `trader.refresh()` im `_pos_loop` DREI Zeilen vor + `_rebind_brk()` laeuft, griff Slot 1 die BRK-Position, bevor sie + zurueckgebunden werden konnte. Folge: ihr G/V stand links statt rechts, und + „G/V BRK" zeigte „—". Belegt in `tests/test_slot_gv.py`. + """ + for kls in [n for n in ast.walk(baum) if isinstance(n, ast.ClassDef)]: + if kls.name != "TradingEngine": + continue + init = next((f for f in kls.body if isinstance(f, ast.FunctionDef) + and f.name == "__init__"), None) + if not init: + continue + for kn in ast.walk(init): + if (isinstance(kn, ast.Assign) + and any(isinstance(t, ast.Attribute) and t.attr == "fremde_tickets" + for t in kn.targets)): + q = ast.unparse(kn) + fehlt = [n for n in ("trader_brk", "_pending_tickets", "_brk_restore") + if n not in q] + if fehlt: + return ["fremde_tickets: Sperrliste erwaehnt {} NICHT — eine " + "BRK-Position kann von Slot 1 adoptiert werden " + "(G/V stuende dann im falschen Feld)" + .format(", ".join(fehlt))] + return [] + 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)) + + _close_beide(baum) + _sperrliste_vollstaendig(baum)) if __name__ == "__main__": diff --git a/web/app.js b/web/app.js index 0b0282a..b930cc7 100644 --- a/web/app.js +++ b/web/app.js @@ -1138,12 +1138,19 @@ function render(d) { const bpEl2 = $("hdr-pnl-brk"), bpos = d.position_brk; if (bpos && bpos.ticket) { const whtB = d.wht_pct || 0; - const rawB = Number(bpos.pnl) || 0; + // ⚠⚠ Bei geschlossenem Markt DIESELBE Rechnung wie links: der + // Broker-P&L steht dann still. Ohne `pnl_hl_brk` zeigte links eine + // HL-Schätzung und rechts der eingefrorene Broker-Wert — zwei + // verschiedene Rechnungen nebeneinander, ohne sichtbaren Unterschied. + const useHlB = d.market_closed && d.pnl_hl_brk != null; + const rawB = useHlB ? d.pnl_hl_brk : (Number(bpos.pnl) || 0); const netB = rawB > 0 ? rawB * (1 - whtB / 100) : rawB; - bpEl2.textContent = (netB >= 0 ? "+" : "") + cs + fmt(netB, 2); + bpEl2.textContent = (useHlB ? "≈" : "") + (netB >= 0 ? "+" : "") + cs + fmt(netB, 2); bpEl2.className = netB > 0 ? "up" : netB < 0 ? "down" : ""; bpEl2.title = `Breakout-Autotrader, Ticket ${bpos.ticket} · ${bpos.lots} Lots @ ` - + `${bpos.entry} · Trailing ${bpos.trail ? "an" : "AUS"}`; + + `${bpos.entry} · Trailing ${bpos.trail ? "an" : "AUS"}` + + (useHlB ? `· Schätzung auf Basis des Hyperliquid-Kurses ` + + `(Broker-P&L ${fmt(bpos.pnl || 0, 2)} ${cs} eingefroren).` : ""); } else { bpEl2.textContent = "—"; bpEl2.className = ""; @@ -2367,7 +2374,7 @@ async function closeSlot(slot, label, pnl) { // und der Browser zeigte Altes. Statt das jedes Mal von Hand zu diagnostizieren, // sagt die Seite es jetzt selbst. // ⚠ Bewusst SICHTBAR statt nur Konsole: die Konsole sieht am Handy niemand. -const _JS_V = "200"; +const _JS_V = "201"; { const htmlV = document.body && document.body.dataset ? document.body.dataset.htmlV : null; diff --git a/web/index.html b/web/index.html index 20b0fea..3388d9c 100644 --- a/web/index.html +++ b/web/index.html @@ -12,9 +12,9 @@ ⚠ LOKAL eingebunden, kein CDN: die PWA laeuft ueber WireGuard und muss offline funktionieren. --> - + -
+