"use strict"; // ── Live-Anbindung an server.py via WebSocket (Fallback: Polling) ────────── // ⚠⚠ AUFFANG-ELEMENT (2026-08-07) — behebt die haeufigste Ausfallart des // Dashboards. `index.html` und `app.js` werden vom Browser UNABHAENGIG // gecacht; trifft neue JS auf alte HTML, war `$("neu").textContent = …` eine // TypeError — und weil das mitten in `render()` passiert, brach der GESAMTE // Render ab: das Dashboard fror auf Altwerten ein, obwohl Server und Snapshot // korrekt waren. Real passiert beim HL-Kurs-Einbau (01.08.), beinahe erneut // beim Entfernen der Setup-Zeile (06.08., 8 Folgezugriffe auf `w`). // Die Gegenregel „neue Elemente per `const el = $(…); if (el) {…}`" stand seit // 01.08. in CLAUDE.md — GEMESSEN befolgt wurde sie bei **0 von 95** direkten // Zugriffen. Eine Regel mit 0 % Befolgung ist keine Regel, also loest es jetzt // der Code an EINER Stelle statt an 95. // ⚠ `?.` waere hier keine Loesung gewesen: **80 der 95** Zugriffe sind // ZUWEISUNGEN, und `$("x")?.y = v` ist ein Syntaxfehler (Optional Chaining ist // links vom `=` verboten). // ⚠ Der Fehler wird NICHT verschluckt, sondern EINMAL je ID in die Konsole // gemeldet — ein stiller Fallback waere derselbe Fehler wie der `catch {}` in // `pollSnapshot`, der Render-Fehler lautlos gefressen hat (behoben 01.08.). const _FEHLT = document.createElement("span"); const _fehlendGemeldet = new Set(); const $ = (id) => { const el = document.getElementById(id); if (el) return el; if (!_fehlendGemeldet.has(id)) { _fehlendGemeldet.add(id); console.warn(`[app] Element #${id} fehlt — vermutlich alte index.html im ` + `Cache. Schreibzugriffe laufen ins Leere, der Rest rendert ` + `weiter. Abhilfe: ?v=N in index.html hochzaehlen.`); } return _FEHLT; }; // ⚠ STRENGE Variante — gibt `null` zurueck, wenn das Element fehlt. // Zwingend fuer Pfade, die einen gelesenen Wert an den BROKER schicken: dort // ist „Feld fehlt" NICHT dasselbe wie „Feld ist leer". Beim SL/TP-Feld heisst // leer ausdruecklich „Stop entfernen" — ein Auffang-Element wuerde daraus ein // stilles Loeschen des Broker-SL einer laufenden Position machen. Ein lauter // Absturz ist dort das bessere Verhalten als ein stiller Schaden. const $streng = (id) => document.getElementById(id); function fmt(v, d = 3) { return (v === null || v === undefined || isNaN(v)) ? "—" : Number(v).toFixed(d); } function curSym(m) { const c = m && m.currency; return c === "EUR" ? "€" : c === "USD" ? "$" : (c ? c + " " : "$"); } // ── Akustischer CLOSE-Alarm ───────────────────────────────────────────────── // Web-Audio (kein Sound-File). Mobile entsperrt Audio erst nach der ersten // Nutzer-Interaktion → wir starten/reaktivieren den Context bei jedem Tap. let _audioCtx = null, _lastBeep = 0; function _unlockAudio() { try { if (!_audioCtx) _audioCtx = new (window.AudioContext || window.webkitAudioContext)(); if (_audioCtx.state === "suspended") _audioCtx.resume(); } catch (e) {} } window.addEventListener("pointerdown", _unlockAudio); window.addEventListener("touchstart", _unlockAudio); // Globaler Ton-Schalter (Mute-Button oben rechts; überlebt Reloads via localStorage) let _muted = localStorage.getItem("oil_muted") === "1"; function _tone(freq, startOffset, dur) { if (_muted) return; const o = _audioCtx.createOscillator(), g = _audioCtx.createGain(); o.type = "square"; o.frequency.value = freq; o.connect(g); g.connect(_audioCtx.destination); const t = _audioCtx.currentTime + startOffset; g.gain.setValueAtTime(0.0001, t); g.gain.exponentialRampToValueAtTime(0.35, t + 0.02); g.gain.exponentialRampToValueAtTime(0.0001, t + dur); o.start(t); o.stop(t + dur + 0.03); } function beepCloseAlert() { if (_muted) return; if (!_audioCtx || _audioCtx.state !== "running") return; // erst nach 1. Tap aktiv const now = Date.now(); if (now - _lastBeep < 4000) return; // max. alle 4 s _lastBeep = now; // ⚠ DREI kurze Pulse auf GLEICHER Tonhoehe (2026-08-19). Vorher war es ein // fallender Zweiklang 880→620 -- und die SHORT-Empfehlung ist 780→520, also // ebenfalls fallend und nur ~100 Hz daneben. Real verwechselt (User: "beim // akustischen Signal blinkt der Close-Button nicht" -- es war der // Empfehlungston, der NUR flat spielt; CLOSE blieb korrekt dunkel). // Rhythmus statt Tonhoehe unterscheidet zuverlaessiger als 100 Hz Abstand. _tone(1046, 0.00, 0.09); _tone(1046, 0.14, 0.09); _tone(1046, 0.28, 0.13); } // Ton bei Empfehlung: LONG = aufsteigend, SHORT = absteigend (vom Close-Alarm // unterscheidbar). Sofort beim Signal-Wechsel + WIEDERHOLUNG alle 30 s, solange // der Button blinkt (Empfehlung aktiv & flat) — einmaliger Piep war überhörbar. const _REC_REBEEP_MS = 30000; let _lastRecBeep = 0, _lastRecSignal = null, _audioHintShown = false, _lastSrCloseCount = null; // ── Laufzeit-Uhr des offenen Trades ─────────────────────────────────────────── // Öffnungszeit kommt als echte Epoch (Broker-Offset serverseitig korrigiert) im // Snapshot (`position.open_time`); der Ticker läuft eigenständig jede Sekunde, // damit die Uhr auch zwischen den Snapshots (4 s Poll / 1 s WS) flüssig zählt. let _tradeOpenTime = 0; function _fmtDuration(sec) { sec = Math.max(0, Math.floor(sec)); const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60; const p2 = (n) => String(n).padStart(2, "0"); return h > 0 ? `${h}:${p2(m)}:${p2(s)}` : `${m}:${p2(s)}`; } function _tickTradeDuration() { const el = document.getElementById("tb-dur"), hd = document.getElementById("tb-runtime"); if (!el) return; if (!_tradeOpenTime) { el.textContent = "—"; if (hd) hd.textContent = ""; return; } const sec = Date.now() / 1000 - _tradeOpenTime; const txt = _fmtDuration(sec); el.textContent = txt; if (hd) hd.textContent = "⏱ " + txt; // Ab 2 h dezent hervorheben (Time-Stop greift bei 120 min in Phase „Init") el.className = "tb-val" + (sec >= 7200 ? " warn" : ""); } setInterval(_tickTradeDuration, 1000); function beepRecommendation(sig, isNew) { if (_muted) return; // auch kein Sperr-Toast if (!_audioCtx || _audioCtx.state !== "running") { // Audio noch nicht freigeschaltet (Mobile braucht 1× Tippen) → 1× erklären if (isNew && !_audioHintShown) { _audioHintShown = true; toast("🔇 Ton gesperrt — einmal auf die Seite tippen aktiviert ihn", false); } return; } const now = Date.now(); if (now - _lastRecBeep < (isNew ? 3000 : _REC_REBEEP_MS)) return; _lastRecBeep = now; if (sig === "LONG") { _tone(520, 0, 0.16); _tone(780, 0.18, 0.20); } // ↑ else { _tone(780, 0, 0.16); _tone(520, 0.18, 0.20); } // ↓ } // ── Gesamtempfehlung: Konfidenz-Ring + Bias-Balken + Modul-Chips ─────────── // ══════════════════════════════════════════════════════════════════════════ // `renderVerdict` ENTFERNT (2026-08-08). Die Gesamtempfehlung aggregierte die // Module zu Headline, Konfidenz-Ring, Bias-Nadel und Konsens-Zeile — gemessen // ohne Prognosewert (48–51 % Trefferquote über 1.071 Live-Episoden), und der // Konfidenz-Ring war nicht kalibriert (in der zweiten Stichprobe invertiert). // ⚠ Die Order-Logik hing NIE daran: Blinken, Order-Dialog und Entry-Checkliste // lesen `wave_signal` bzw. `checklist` — das ist unberührt. // Was in der alten Funktion nur mitlief und hier weiterlebt: // · Selbst-Kalibrierung (`#kx-selfcal`) — misst das WELLEN-Signal, das bleibt // · HL-Kontext (`#hl-note`) — schreibt in die Meldungen-Karte, nicht hierher // Entfallen ist die Block-Grund-Zeile (`#vd-blocks`). // ══════════════════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════════════════ // M15 SETUP-BEREITSCHAFT — sequenzielle GATE-KETTE, keine Score-Aggregation. // [HTF-BIAS] → [KOSTEN] → [LEVEL & KEGEL] → AUSFÜHRUNG NUR AM LEVEL // ⚠ Bewusst KEINE Richtungsaussage. Auf M15 trägt gemessen keine (Squeeze // n=33/Nachbarn kippen, VWAP-Bänder ohne Edge). Die Karte sagt, ob die // BEDINGUNGEN stimmen — der Auslöser bleibt beim Menschen. // ⚠ Der Unterschied zur entfernten Gesamtempfehlung ist grundsätzlich: dort // wurden Stimmen zu einem Bias VERRECHNET (gemessen 48–51 % Treffer), hier // muss jedes Gate EINZELN passieren. Ein Gate, das fällt, lässt sich nicht // durch ein anderes ausgleichen. // ══════════════════════════════════════════════════════════════════════════ // Breakout-Autotrader-Karte (2026-08-19). Nur ANZEIGE aus vorhandenen // Snapshot-Feldern -- hier wird nichts neu gerechnet. // Die Karte bleibt VERSTECKT, solange `squeeze_b5` fehlt (aelterer Server, das // Feld gibt es erst nach einem Neustart). Eine halb gefuellte Karte waere // schlimmer als keine: sie saehe aus wie ein Zustand statt wie eine fehlende // Quelle. function renderBrk(d) { const box = $streng("card-brk"); if (!box) return; // --- Slot 2: die vom Breakout-Trader gehaltene Position ------------------- // Ohne diese Zeile waere eine offene BRK-Position im Dashboard unsichtbar -- // genau Deployment-Drift Fall 8 (dort driftete nicht die Strategie, sondern // ihre Beobachtbarkeit) in neuer Form. const bp = d.position_brk, bpEl = $streng("brk-pos"); if (bpEl) { if (!bp) { bpEl.textContent = "keine — Slot frei"; bpEl.className = ""; } else { // Richtung aus SL/TP ableiten: place_stop setzt IMMER einen SL. const e = Number(bp.entry) || 0; const lg = (bp.sl && e) ? (bp.sl < e) : (bp.tp ? bp.tp > e : null); const pfeil = lg === null ? "" : (lg ? "▲ LONG" : "▼ SHORT"); const pnl = Number(bp.pnl) || 0; const t = [`${pfeil} ${bp.lots ?? "?"} @ ${e ? e.toFixed(3) : "?"}`, `${pnl >= 0 ? "+" : ""}${pnl.toFixed(2)} €`]; // ⚠ Der wichtigste Teil der Zeile: ohne Trailing laeuft der Trade in der // gemessen durchgefallenen SL-only-Variante. t.push(bp.trail ? "Trailing an" : "⚠ TRAILING AUS — nur Broker-SL!"); bpEl.textContent = t.join(" · "); bpEl.className = bp.trail ? (pnl >= 0 ? "pos" : "neg") : "neg"; } } // Einsatz je Pfad einspielen. ⚠ NICHT, solange das Feld den Fokus hat -- // sonst ueberschreibt der 1-s-Snapshot die laufende Eingabe (dieselbe Falle // wie beim Mindestgewinn-Feld). [["am-brk", d.margin_brk], ["am-m15", d.margin_m15], ["mg-global", d.margin_pct]].forEach(([id, v]) => { const el = $streng(id); if (!el) return; // ⚠ NICHT anfassen, solange das Feld den Fokus hat -- sonst frisst der // 1-s-Snapshot die laufende Eingabe (der Grund, warum das Feld sich // zuerst nicht bedienen liess). if (document.activeElement === el) return; const soll = (v > 0) ? String(v) : ""; // ⚠ Nur schreiben, wenn sich WIRKLICH etwas aendert: eine Zuweisung setzt // auch bei gleichem Text die Cursorposition zurueck. if (el.value !== soll) el.value = soll; }); const b5 = d.squeeze_b5; if (!b5) { box.classList.add("hidden"); return; } box.classList.remove("hidden"); const an = !!d.auto_squeeze; const nh = (d.squeeze_night_hours || []).map((h) => h + ":00").join(", "); $("brk-state").textContent = (an ? "AN" : "AUS") + (d.squeeze_pending ? " · ruhende Stop-Order" : " · Market-Fallback") + (nh ? " · Nachtsperre " + nh : ""); $("brk-state").className = an ? "ok" : "warn"; const sq = (d.wave || {}).squeeze || {}; const st = sq.state; $("brk-squeeze").textContent = st === "active" ? "AUSBRUCH " + (sq.dir || "") + " @ " + (sq.level ?? "?") : st === "armed" ? "komprimiert — Ausbruch steht bevor" : "keine Kompression (Box " + (sq.box_atr ?? "?") + "×ATR)"; const pl = d.pending_levels || []; $("brk-pending").textContent = pl.length ? pl.length + " liegend (" + (d.pending_quelle || "?") + ")" : "keine — " + (an ? "Squeeze nicht armiert oder Position offen" : "Trader aus"); // B5: der Stand der einzigen Regel, die die Autonomie beenden kann. const r = b5.ratio, pf = b5.pf; const erf = r != null && pf != null && r >= b5.latte && pf >= 1; $("brk-b5").textContent = b5.n + "/" + b5.ziel + " Trades · Verhältnis " + (r == null ? "—" : r.toFixed(2)) + " · PF " + (pf == null ? "—" : pf.toFixed(2)) + " · " + b5.sum_eur.toFixed(2) + " €"; $("brk-b5").className = erf ? "ok" : "bad"; const el = $("brk-note"); if (el) { el.textContent = erf ? "Latte (Verhältnis ≥ 1,0 UND PF ≥ 1) erfüllt." : "⚠ Latte verfehlt (Ziel: Verhältnis ≥ 1,0 UND PF ≥ 1). Der Monitor " + "im Statistik-Tab meldet daneben „ontrack“ — das kommt aus der MECHANIK " + "(Regel neu gerechnet), nicht aus diesen echten Trades. Die Lücke " + "zwischen beiden ist der Befund, kein Widerspruch."; } } function renderM15(s, snap) { // ⚠⚠ `snap` = der VOLLE Snapshot. `s` ist nur `m15_setup` — wer hier auf // `d.` zugreift, erzeugt einen ReferenceError, der den GESAMTEN Render // abbricht: alles ab der Fehlerstelle bleibt auf "—" stehen, und die // danach gerufenen Renderer (u. a. `renderBrk`) laufen gar nicht mehr. // Genau das ist am 19.08. passiert (RSI-Zeile griff auf `d.market`). const st = $("m15-status"); if (!s) { st.className = "m15-status"; st.textContent = "— keine Daten"; return; } const f = (v, d = 2) => (v == null ? "—" : (+v).toFixed(d)); // ── Gate 1: HTF-Bias (der einzige belegte Filter, Edge ×2) ─────────── // ⚠⚠ VETO, KEINE RICHTUNG (Korrektur 2026-08-08). Der erste Entwurf zeigte // „Bias ▲ LONG" und erzeugte am 07.08. dreizehn LONG-Episoden an einem Tag, // der 1,02 $ FIEL (Trefferquote 0 %). Gemessen ist nur: GEGEN den M30-Trend // zu handeln halbiert den Edge — eine Verbots-, keine Richtungsaussage. const b = s.bias || {}; const bOk = !!b.verboten; // Gate offen, sobald ein HTF steht $("m15-bias").className = "m15-item " + (bOk ? "ok" : ""); $("m15-bias").textContent = bOk ? `① Veto: ${b.verboten} vermeiden (HTF ${b.m30}${b.einig ? " + H1 einig" : ""})` : `① kein Veto — HTF flach (M30 ${b.m30 ?? "—"} / H1 ${b.h1 ?? "—"})`; // ── Gate 2: Kosten auf M15 ─────────────────────────────────────────── const k = s.kosten || {}; const kOk = k.stufe === "guenstig" || k.stufe === "normal"; $("m15-kosten").className = "m15-item " + (k.ratio == null ? "" : kOk ? "ok" : "fail"); // ⚠ Slippage-Hinweis: gemessen steigt die Exit-Slippage monoton mit ΔATR // (0,020 → 0,084 $, Faktor 4,2). ABER der Bootstrap auf die Differenz enthält // die Null (n≈85 je Quartil) — deshalb HINWEIS, kein Gate, und die // Beschriftung sagt „historisch", nicht „belegt". const vo = s.vola || {}; const slipTxt = vo.stufe === "hoch" ? " · ⚠ Vola zieht an — Slippage historisch ~4× höher" : vo.stufe === "ruhig" ? " · Vola ruhig" : ""; $("m15-kosten").textContent = k.ratio == null ? "② Kosten —" : `② Kosten ${f(k.ratio, 3)}×ATR · ${ k.stufe === "guenstig" ? "günstig" : k.stufe === "normal" ? "normal" : "TEUER"}` + slipTxt; // ── Gate 3: Level in Reichweite ────────────────────────────────────── // ⚠⚠ `zOk` kommt seit 2026-08-12 aus dem BACKEND (`s.status`), nicht mehr aus // einem eigenen Schwellenvergleich hier. Grund: die Karte wechselte den // Zustand alle 12 Sekunden — Schwellen-Flattern an `gap ≤ 0,5`. Das Backend // rechnet es EINMAL mit Hysterese (grün ab 0,50, zurück erst über 0,60) und // liefert Status und Lesart mit; Anzeige und Selbstmessung können damit nicht // mehr auseinanderlaufen (sie taten es bereits: der Logger kannte den // „kein Veto"-Fall nicht). const z = (s.zonen || []); const nah = z.filter(x => x.gap_atr15 != null && x.gap_atr15 <= 0.5); const zOk = s.status ? (s.status === "gruen") : (nah.length > 0); $("m15-zone").className = "m15-item " + (z.length ? (zOk ? "ok" : "warn") : ""); // ⚠⚠ RICHTUNGS-LESART, aber BEDINGT auf das Level. Eine FREIE Prognose ist // gemessen unmoeglich (`analyze_reversal.py`: AUC 0,499–0,509 oos und // **in-sample nur 0,52** — die Merkmale enthalten keine Richtungsinformation; // Live-Richtung 48–51 % ueber 1.071 Episoden). P(break) fragt dagegen nicht // „wohin geht der Kurs", sondern „haelt DIESE Struktur?" — bedingt auf ein // reales Objekt mit Orders dahinter, AUC 0,65 oos. // ⚠ 45–55 % bekommt BEWUSST keine Richtung: dort ist die Zahl ein Muenzwurf, // und daraus einen Pfeil zu machen waere genau der Fehler, den die // MQL5-Pfeile schon einmal vermieden haben. const lesart = (x) => { const p = x.p_break, res = x.seite === "res"; if (p == null) return ""; if (p >= 45 && p <= 55) return " → unentschieden"; const bricht = p > 55; // Widerstand gebrochen = aufwaerts · Widerstand haelt = abwaerts const auf = res ? bricht : !bricht; const q = bricht ? `${p} % Bruch` : `${100 - p} % Abprall`; return ` → ${q}, eher ${auf ? "aufwärts ▲" : "abwärts ▼"}`; }; // ⚠⚠ ZEITEBENE MIT IM TEXT (Fix 2026-08-13). ③ liest M30-Level und misst in // ATR_M15, ③b liest M5-Level und misst in ATR_M5. Beide standen vorher nur als // „×ATR" da — zwei Zahlen nebeneinander (0,48 und 0,45), die vergleichbar // AUSSAHEN und verschiedene Bezugsgroessen hatten. Die Quellen-Trennung ist // Absicht (`entry_room_atr=0,6` ist auf M5 kalibriert), die stumme // Beschriftung war es nicht. // ⚠ „(Chart)" markiert, dass GENAU diese Linie im MT5-Chart gezeichnet wird — // die M5-Level aus ③b werden dort NICHT gezeichnet. $("m15-zone").textContent = z.length ? "③ " + z.map(x => `${x.seite === "res" ? "Widerstand" : "Support"} M30 ` + `${f(x.level, 3)} (Chart · ${f(x.gap_atr15)}×ATR_M15)${lesart(x)}`).join(" · ") : "③ kein Level in Sicht"; $("m15-zone").title = "GENAU diese Linie wird im MT5-Chart gezeichnet (M30-Pivots, " + "geclustert) — der Chart nennt seit v1.35 dieselbe Zahl mit denselben Worten. " + "⚠ Die Level in ③b stammen dagegen aus M5 und stehen NICHT im Chart. " + "Richtungs-Lesart BEDINGT auf das Level, keine freie " + "Prognose: eine solche ist gemessen unmöglich (AUC 0,50 out-of-sample und " + "nur 0,52 in-sample — es steckt keine Richtungsinformation in den Daten). " + "P(break) fragt stattdessen „hält diese Struktur?“ und ist out-of-sample " + "kalibriert (AUC 0,65). ⚠ Live derzeit rund 7 Pp zu niedrig — die Abprall-" + "Quote ist also eher noch etwas optimistisch. 45–55 % bleibt ohne Richtung."; // ── Reichweite (Kegel) — sagt WIE WEIT, nicht wohin ────────────────── // ⚠ REINE GEOMETRIE: passt das Ziel noch in das 80-%-Band des jeweiligen // Horizonts? Keine Bruch-Annahme — die wäre nicht belegt. // ⚠⚠ Die Richtung steht seit dem Fix vom 2026-08-10 MIT im Text. Vorher war // das Ziel rein geometrisch das entferntere Level und stand real GEGEN das // Veto aus ② („verboten SHORT" + Ziel 0,15 $ unter dem Kurs). Ein Ziel ohne // genannte Richtung lässt sich still wieder verdrehen — mit genannter nicht. const zi = s.ziel; const r = s.reichweite || []; const spanne = r.length ? " · Spanne " + r.map(x => `${x.minutes}′ ${f(x.lo, 2)}–${f(x.hi, 2)}`).join(" · ") : ""; const rEl = $("m15-reich"); if (zi && zi.horizonte && zi.horizonte.length) { const drin = zi.horizonte.filter(x => x.drin).map(x => x.minutes); const pfeil = zi.richtung === "LONG" ? "▲" : "▼"; rEl.className = "m15-item " + (drin.length ? "ok" : "warn"); rEl.textContent = drin.length ? `④ Ziel ${pfeil} ${f(zi.level, 3)} erreichbar ab ${Math.min(...drin)}′ ` + `(${zi.strecke} $ vom Kurs, Einstieg ${f(zi.von, 3)})` : `④ Ziel ${pfeil} ${f(zi.level, 3)} liegt AUSSERHALB des 120′-Kegels ` + `(${zi.strecke} $) — CRV zeitlich nicht gedeckt`; } else if (s.ziel_grund) { // Kein berechenbares Ziel ist ein EIGENER Zustand, keine Lücke — // deshalb wird der Grund genannt statt eine Zahl erfunden. rEl.className = "m15-item"; rEl.textContent = `④ kein Ziel — ${s.ziel_grund}${spanne}`; } else { rEl.className = "m15-item"; rEl.textContent = r.length ? "④ erwartete Spanne · " + r.map(x => `${x.minutes}′ ${f(x.lo, 2)}–${f(x.hi, 2)}`).join(" · ") : "④ erwartete Spanne —"; } // ── ③b RAUM + Motor-Zustand ────────────────────────────────────────── // ⚠⚠ ANLASS: Trade #49671601 (10.08., −87 €). Die Karte stand auf GRÜN, // während der Signal-Motor zu 76 % mit `entry_room` blockte — der Widerstand // lag 0,07×ATR voraus, der Stop 4,7×ATR zurück. Die Karte prüfte, ob ein // Level NAH ist, nie ob RAUM dahinter ist. Genau das steht jetzt hier. // ⚠ Reine Anzeige: keine Richtung, kein Blockieren der Buttons. const mo = s.motor || {}; const rm = mo.raum || {}; // Dieselbe Zahl zusaetzlich AUF die Order-Knoepfe (2026-08-19). Sie wird // hier NICHT neu gerechnet — `rm` liegt bereits vor; ein zweiter Rechenweg // waere genau die Divergenz-Falle, die im Projekt dreimal zugeschlagen hat. for (const st of ["long", "short"]) { const el = $("raum-" + st); const o = rm[st]; if (!o || o.dist_atr == null) { el.textContent = ""; el.className = "btn-raum"; continue; } const eng = o.frei === false; el.textContent = (eng ? "⚠ " : "") + o.dist_atr.toFixed(2) + "×ATR"; el.className = "btn-raum" + (eng ? " eng" : ""); el.title = "Abstand zum naechsten Level in DIESER Richtung (M5-Pivots, " + "Gate " + (rm.gate ?? 0.6) + "×ATR). Unter dem Gate gemessen teuer: " + "79 % Trefferquote, aber −7 bis −12 €/Lot — der Gewinn ist am Level " + "gedeckelt, der Verlust laeuft bis zum Stop. Reine Anzeige."; } // --- M5-Kontext: reine ZUSTANDS-Zeile, keine Richtung, kein Banner ------ // Bewusst KEINE eigene M5-Karte (User-Entscheidung 19.08.): die entkoppelte // Selbstmessung dieser Karte steht bei n=342 auf 53,5 % [48,2 .. 58,8] und // schliesst die 50 nicht aus; als Handelsregel faellt sie durch. Hier steht // deshalb nur, WIE der M5-Markt gerade aussieht -- ohne Urteil. // --- RSI(14) M15: reine ANZEIGE ------------------------------------------ // ⚠⚠ BEWUSST NEUTRAL und OHNE Ampel. Die Lehrbuch-Lesart ist hier gemessen // durchgefallen (backtest_rsi_v2.py, 19.08.: 24 Zellen, keine besteht, vier // signifikant negativ). Eine gruen/rot-Ampel wuerde systematisch falsch // stupsen - dieselbe Korrektur wie bei der Kerzen-Anatomie und der // Ueberdehnungs-Anzeige, die aus demselben Grund "Ueberdehnung" heisst und // nicht "Bounce". const rsiEl = $("m15-rsi"); const rv = ((snap || {}).market || {}).rsi_m15; if (rv == null) { rsiEl.textContent = "RSI · —"; } else { // Zone NENNEN, aber nicht bewerten. Kein Pfeil, keine Farbe. const z = rv >= 70 ? "hoch" : rv <= 30 ? "tief" : "mittleres Band"; rsiEl.textContent = `RSI(14) M15 · ${rv.toFixed(1)} · ${z}`; } const m5El = $("m15-m5"); const m5 = s.m5; if (m5) { const t = []; if (m5.squeeze === "active") { t.push(`Squeeze AUSBRUCH ${m5.squeeze_dir || ""}`.trim()); } else if (m5.squeeze === "armed") { t.push("Squeeze armiert" + (m5.box_atr != null ? ` (Box ${m5.box_atr}×ATR)` : "")); } else { t.push("keine Kompression" + (m5.box_atr != null ? ` (Box ${m5.box_atr}×ATR)` : "")); } if (m5.kosten != null) { // Schwellen aus backtest_realcosts.py: Median 0,193 - ab 0,32 Kostenfalle. const k = m5.kosten; t.push(`Kosten ${k.toFixed(2)}×ATR_M5 ${k <= 0.20 ? "günstig" : k < 0.32 ? "normal" : "⚠ teuer"}`); } if (m5.atr != null) t.push(`ATR ${Number(m5.atr).toFixed(3)}`); m5El.textContent = "M5 · " + t.join(" · "); } else { m5El.textContent = "M5 · —"; } const moEl = $("m15-motor"); const seite = (o, lbl) => { if (!o) return null; if (o.level == null) return `${lbl} frei`; return `${lbl} ${o.dist_atr}×ATR_M5${o.frei ? "" : " ⚠"}`; }; if (mo.block) { // Der Motor blockt — das ist die Information, die gefehlt hat. const gr = { entry_room: "kein Raum zum Gegenlevel", breakout_pending: "Bestätigung läuft noch", min_conf: `Konfidenz ${mo.conf_pct ?? "?"} % zu schwach`, htf_counter: "gegen den M30-Trend", stretch: "überdehnt", deadband: "Totband — kein Trend", eia: "EIA-Blackout", dead_hour: "gesperrte Stunde", stale: "Daten veraltet", no_data: "keine Daten" }[mo.block] || mo.block; moEl.className = "m15-item " + (mo.block === "entry_room" ? "warn" : ""); moEl.textContent = `③b Motor blockt: ${gr}`; } else if (mo.signal) { moEl.className = "m15-item ok"; moEl.textContent = `③b Motor frei · ${mo.signal}`; } else { moEl.className = "m15-item"; moEl.textContent = "③b Motor —"; } // ⚠ „M5" und „×ATR_M5" ausgeschrieben: diese Level stammen aus einer ANDEREN // Zeitebene als ③ (M30) und werden im Chart NICHT gezeichnet. Ohne den Zusatz // sucht man die genannte Linie dort vergeblich. const teile = [seite(rm.long, "▲"), seite(rm.short, "▼")].filter(Boolean); if (teile.length) { moEl.textContent += ` · Raum ${teile.join(" / ")}` + (rm.gate ? ` (Gate ${rm.gate})` : ""); } moEl.title = "Was der Signal-Motor GERADE blockt, plus der Raum zum nächsten " + "Gegenlevel je Richtung. ⚠ Quelle sind M5-Pivots und ATR_M5 — eine ANDERE " + "Zeitebene als ③ (M30-Level, ATR_M15), und diese Linien werden im Chart NICHT " + "gezeichnet. Das ist Absicht: entry_room=0,6 ist über 80k Bars auf M5 " + "kalibriert. Die beiden ×ATR-Zahlen sind deshalb NICHT vergleichbar. " + "Dieselbe Rechnung wie das Gate selbst, kein " + "Nachbau. Unter 0,6×ATR ist der Ertrag am Level gedeckelt: gemessen " + "ØR −0,119/−0,067 bei PF 0,42/0,66 — Trefferquote hoch und trotzdem " + "Verlust. ⚠ Gate ③ liest dieselbe Nähe als GUT (ruhende Order AM Level), " + "entry_room als SCHLECHT (Markt-Einstieg mit dem Level als Hindernis). " + "Beides stimmt — für verschiedene Trades. Blockt nichts."; rEl.title = "Reine Geometrie auf dem Kegel: liegt das Ziel im 80-%-Band des " + "Horizonts? Das Ziel wird NUR in der von ② erlaubten Richtung gebildet — " + "ein rein geometrisches Ziel zeigte am 10.08. auf die verbotene Seite. " + "Beim Ausbruchs-Einstieg gibt es kein berechenbares Ziel: _draw_levels " + "liefert bewusst nur die nächste Linie je Seite."; // ── Veto: die eigene Bilanz, JE LOT ────────────────────────────────── const v = s.veto || {}; const g = v.against || {}; $("m15-veto").className = "m15-item " + (g.n >= 5 ? "warn" : ""); $("m15-veto").textContent = g.n >= 5 ? `⑤ gegen das Signal: ${g.n} Trades, ${f(g.pnl_lot, 2)} € je Lot` : "⑤ Veto: noch zu wenige Trades"; // ── ⑥ Selbstmessung: die Karte zeigt ihre EIGENE Trefferquote ──────── // ⚠ Gegen 50 % lesen. Gemessen wird die BEDINGTE Lesart am Level, nicht // eine freie Prognose — die ist gemessen unmöglich (AUC 0,52 in-sample). // ⚠⚠ ENTKOPPELT seit 2026-08-12. Vorher stand hier die ROHE Quote der letzten // 200 Zeilen — bei ~990 Zustandswechseln/Tag sind das fünf Stunden derselben // Marktlage, vielfach gezählt. Angezeigt wurden dadurch **60 %**, entkoppelt // sind es **53 %**. Dieselbe Cluster-Falle hat im Projekt schon eine // Fälligkeitsbedingung um das Zehnfache danebenliegen lassen. // ⚠ `n` ist jetzt die ENTKOPPELTE Zahl (max 1 je Level+Stunde), `roh` die // gelesene — beide werden genannt, damit die Schrumpfung sichtbar bleibt. const tk = s.m15_track || {}; const tEl = $("m15-track"); if (tk.n >= 20 && tk.wr30 != null) { tEl.className = "m15-item " + (tk.wr30 >= 55 ? "ok" : tk.wr30 <= 45 ? "warn" : ""); tEl.textContent = `⑥ eigene Trefferquote (${tk.n} unabh. Zustände` + (tk.roh ? ` aus ${tk.roh}` : "") + `): ${tk.wr30} % auf 30′` + (tk.wr60 != null ? ` · ${tk.wr60} % auf 60′` : "") + ` · Ø ${tk.avg30 == null ? "—" : (tk.avg30 >= 0 ? "+" : "") + f(tk.avg30, 3)}×ATR`; } else { tEl.className = "m15-item"; tEl.textContent = `⑥ eigene Trefferquote: sammelt noch` + (tk.n ? ` (${tk.n} von 20 ausgewertet)` : "") + (tk.offen ? ` · ${tk.offen} offen` : ""); } // ── ⑦ Ausführungsqualität — der einzige gemessen grosse Hebel ──────── const eg = s.entry_gap || {}; const eEl = $("m15-exec"); if (eg.n_seit_umbau != null) { const genug = eg.n_seit_umbau >= (eg.soll || 12); eEl.className = "m15-item " + (genug ? "ok" : ""); eEl.textContent = `⑦ Ausführung: ${eg.n_seit_umbau} Bot-Trades seit dem ` + `Stop-Order-Umbau (Prüfung ab ${eg.soll || 12})`; } else { eEl.className = "m15-item"; eEl.textContent = "⑦ Ausführung —"; } // ── Status: die Kette wird der Reihe nach ausgewertet ──────────────── // ⚠ Das Banner nennt AUSDRUECKLICH keine Richtung. Es sagt, ob die // BEDINGUNGEN fuer eine Order am Level stehen — und was verboten ist. // ⚠⚠ KLARTEXT-KOPFZEILE (2026-08-11, User: „noch etwas vereinfachen, wie: // jetzt long/short zu xxx Prozent"). Der Wunsch ist berechtigt — die Karte // war sieben Zeilen Technik. Umgesetzt ist er aber NICHT als „LONG zu 78 %": // · eine RICHTUNGS-Prognose ist gemessen ein Münzwurf (48–51 % über 1.071 // Live-Episoden, `analyze_signal_live.py`; frei sogar AUC 0,52 IN-sample), // · und die Prozentzahl war als `conf_pct` NICHT kalibriert, in H2 sogar // invertiert (85–100 % → 40 % Trefferquote, `analyze_verdict_calibration`). // Genau daran ist die Gesamtempfehlung gescheitert und wurde entfernt. // Die Zeile sagt deshalb, WAS ZU TUN IST, und nennt die EINE kalibrierte // Prozentzahl, die es gibt: P(break) des Levels, an dem eingestiegen würde // (AUC 0,65 oos). Die ist eine Aussage über die STRUKTUR, nicht über den Trade. // ⚠ Bewusst KEINE Änderung der Gate-Semantik — nur die Formulierung. Ob der // Raum (③b) das Banner auf gelb ziehen soll, ist ungemessen und bleibt offen. const erl = b.verboten === "SHORT" ? "LONG" : b.verboten === "LONG" ? "SHORT" : null; const zi2 = s.ziel; const pTxt = (lvl) => { const zz = (s.zonen || []).find(x => x.level === lvl); if (!zz || zz.p_break == null) return ""; if (zz.p_break >= 45 && zz.p_break <= 55) return " · Level unentschieden"; return zz.p_break > 55 ? ` · bricht zu ${zz.p_break} %` : ` · hält zu ${100 - zz.p_break} %`; }; let cls, txt; if (!kOk && k.ratio != null) { cls = "rot"; txt = `🔴 JETZT: nicht handeln — Spread frisst den Edge (${f(k.ratio)}×ATR)`; } else if (!erl) { // Ohne Veto ist keine Richtung ausgeschlossen — und eine zu nennen wäre // wieder eine Prognose. Die Karte sagt dann ehrlich nichts. cls = "gelb"; txt = "🟡 JETZT: kein Trend-Veto — die Karte gibt hier keine Richtung"; } else if (!zOk) { cls = "gelb"; const w = nah.length ? "" : ` · nächstes Level ${f((z[0] || {}).gap_atr15)}×ATR entfernt`; txt = `🟡 JETZT: warten auf das Level${w} · erlaubt wäre ${erl}`; } else { cls = "gruen"; const einstieg = zi2 ? zi2.von : nah[0].level; const art = zi2 ? "Abpraller" : "Ausbruch"; txt = `🟢 JETZT: ${erl}-Order bei ${f(einstieg, 3)} (${art})${pTxt(einstieg)}` + (zi2 ? ` · Ziel ${f(zi2.level, 3)}` : "") + ` · ⛔ ${b.verboten} meiden`; } st.className = "m15-status " + cls; st.textContent = txt; st.title = "Klartext-Zusammenfassung der Gate-Kette darunter. ⚠ Bewusst KEINE " + "Angabe wie 'LONG zu 78 %': die Richtung ist über 1.071 Live-Episoden mit " + "48–51 % Trefferquote gemessen (Münzwurf), und die frühere Konfidenz-Prozent" + "zahl war nicht kalibriert — in der zweiten Hälfte sogar invertiert. " + "Genannt wird nur P(break) des Einstiegs-Levels: out-of-sample kalibriert " + "(AUC 0,65), eine Aussage über die STRUKTUR, nicht über den Trade — live " + "derzeit rund 7 Pp zu niedrig. 'Erlaubt' heisst NICHT 'empfohlen': belegt " + "ist nur, dass die Gegenrichtung kostet (−6,60 € je Lot, beidhälftig). " + "⚠ Die Order-Buttons bleiben bedienbar — hartes Blockieren ist verworfen, " + "Übersteuerungen des Users liefen gemessen 68 % Trefferquote."; } function renderKontext(snap) { // ── Selbst-Kalibrierung: die Empfehlung zeigt ihre eigene Trefferquote ── // ⚠ Gegen 50 % zu lesen, nicht gegen 0 — es ist eine Richtungsaussage. // Ausgangsstand (analyze_signal_live.py, 1.071 Episoden): 48–51 %. const trkEl = $("kx-selfcal"); const tr = snap && snap.rec_track; if (tr && tr.n >= 20 && tr.wr30 != null) { const d = (x) => (x == null ? "—" : (x >= 0 ? "+" : "") + fmt(x, 3)); const cls = tr.wr30 >= 55 ? "up" : tr.wr30 <= 45 ? "down" : ""; trkEl.className = "kx-selfcal " + cls; trkEl.textContent = `🎯 Trefferquote der Empfehlung (letzte ${tr.n} Episoden): ${tr.wr30} % auf 30′` + (tr.wr60 != null ? ` · ${tr.wr60} % auf 60′` : "") + ` · Ø ${d(tr.avg30)}×ATR`; trkEl.title = "Selbst-Kalibrierung: jede gerichtete Empfehlung wird nach " + "30 und 60 Minuten gegen den echten Kurs ausgewertet. Gegen 50 % lesen, " + "nicht gegen 0. Gemessener Ausgangsstand über 1.071 Episoden: 48–51 %."; } else { trkEl.className = "kx-selfcal dim"; trkEl.textContent = `🎯 Trefferquote der Empfehlung: sammelt noch` + (tr && tr.n ? ` (${tr.n} von 20 Episoden ausgewertet)` : ""); trkEl.title = "Jede gerichtete Empfehlung wird nach 30/60 Minuten gegen " + "den echten Kurs ausgewertet. Ab 20 ausgewerteten Episoden erscheint " + "die Quote hier."; } // ── HL-Kontext (Funding / OI / Premium) — reine Anzeige ─────────────── // ⚠ Gemessen ohne Prognosewert (analyze_hl_funding.py): entüberlappt hält // kein Bucket die 2-Stichproben-Regel. Schreibt in die MELDUNGEN-Karte. const hlEl = $("hl-note"); const h = snap && snap.hl_ctx; if (h && h.premium != null) { const fr = (h.funding != null) ? (h.funding * 100).toFixed(4) + " %" : "—"; const oi = (h.open_interest != null) ? (h.open_interest / 1e6).toFixed(2) + " Mio" : "—"; hlEl.classList.remove("hidden"); hlEl.textContent = `⛓ Hyperliquid: Perp ${h.premium >= 0 ? "+" : ""}` + `${fmt(h.premium, 3)} $ zum Oracle · Funding ${fr}/h · OI ${oi}`; hlEl.title = "Kontext vom Hyperliquid-Öl-Perp (xyz:CL). Positives Premium = " + "der Perp notiert über dem Index, Longs zahlen. ⚠ Gemessen über 5.095 " + "Funding-Stundenwerte (7 Monate) gegen den CFD: kein belastbarer " + "Prognosewert."; } else { hlEl.classList.add("hidden"); } } function renderStatsCompact(sc, m) { sc = sc || {}; const cs = curSym(m || {}); const f2 = (v) => (v == null ? "—" : (+v).toFixed(2)); const block = (label, s) => { if (!s || !s.n) return `
${label}
` + `
noch keine Trades
`; const ratioCls = (s.ratio != null && s.ratio >= 1) ? "up" : "down"; const pfCls = (s.pf != null && s.pf >= 1) ? "up" : "down"; const pnlCls = (s.pnl >= 0) ? "up" : "down"; return `
${label} · ${s.n} Trades
` + `
` + `WR ${s.wr}%` + `PF ${f2(s.pf)}` + `Verh ${f2(s.ratio)}` + `Øgew +${cs}${f2(s.avg_win)}` + `Øverl ${cs}${f2(s.avg_loss)}` + `PnL ${s.pnl >= 0 ? "+" : ""}${cs}${f2(s.pnl)}` + `
`; }; $("stx-row").innerHTML = block("Woche", sc.week) + block("Gesamt", sc.all); } function renderGaps(g, m) { const el = $("gaps-list"); const list = (g && g.gaps) || []; if (!list.length) { el.innerHTML = 'keine offenen Gaps'; return; } const bid = (m || {}).bid; el.innerHTML = list.map(x => { const up = x.dir === "up"; const arr = up ? "▲" : "▼"; const dist = bid != null ? (x.fill - bid) : null; const ds = dist != null ? ` (${dist >= 0 ? "+" : ""}${fmt(dist)})` : ""; // ⚠ Die zwei Gap-Arten unterscheidbar machen (2026-08-03): nur `vacuum` // speist die Wellen-Konfidenz (darauf wurde gemessen), `close_open` ist // reine Anzeige. Ohne den Hinweis sähen beide gleich verbindlich aus. const co = x.kind === "close_open"; const tag = co ? `Eröffnung` : `Vakuum`; return `
${arr} ${x.dir.toUpperCase()}` + `${fmt(x.lo)}–${fmt(x.hi)}` + `schließt bei ${fmt(x.fill)}${ds}` + tag + `${x.date}
`; }).join(""); } // Manuelle Trades (2026-08-04) — DESKRIPTIV. Zeigt die eigene Historie, macht // keine Vorhersage: der volle Entscheidungskontext wird erst seit dem 04.08. // mitgeschrieben, vorher wären Aussagen daraus Rauschen. Die Reifegrad-Zeile // nennt die Abdeckung ehrlich, damit die Karte nicht überschätzt wird. function renderManual(m) { const grid = $("mt-grid"); if (!grid) return; // null-sicher (Cache-Regel) if (!m || !m.gesamt) { grid.innerHTML = ''; return; } const cls = v => v > 0 ? "up" : v < 0 ? "down" : ""; const box = (lbl, b) => !b || !b.n ? "" : `
${lbl}
` + `
${b.sum >= 0 ? "+" : ""}${fmt(b.sum, 2)}
` + `
${b.n} Trades · ${fmt(b.wr, 1)} % · Ø ${b.avg >= 0 ? "+" : ""}${fmt(b.avg, 2)}
`; grid.innerHTML = box("heute", m.heute) + box(`${m.tage} Tage`, m.fenster) + box("gesamt", m.gesamt) + box("Bot (Vergleich)", m.bot); // Signal-Deckung: die dokumentierte Kern-Frage — trägt Handeln OHNE Empfehlung? const sp = $("mt-split"); if (sp) { const z = (lbl, b) => !b || !b.n ? "" : `${lbl} ${b.n}× · ${fmt(b.wr, 1)} % · ` + `${b.sum >= 0 ? "+" : ""}${fmt(b.sum, 2)}`; sp.innerHTML = z("mit Signal", m.mit_signal) + z("ohne Signal", m.ohne_signal); } const nt = $("mt-note"); if (nt) { const c = m.ctx_abdeckung || {}; nt.textContent = `Entscheidungs-Kontext gespeichert für ${c.mit_ctx || 0} von ` + `${c.n || 0} Trades (${fmt(c.pct || 0, 1)} %). Erst ab ausreichender ` + `Abdeckung lässt sich messen, WORAN gute von schlechten Einstiegen zu ` + `unterscheiden sind — bis dahin ist diese Karte reine Beschreibung.`; } } // Marktstruktur (M30) — reine Anzeige (HH/HL/LH/LL, BOS, Regressionskanal). function renderStructure(s) { s = s || {}; const TR = { up: ["▲ Aufwärts", "up"], down: ["▼ Abwärts", "down"], range: ["→ Seitwärts", "dim"] }; const [txt, cls] = TR[s.trend] || ["—", "dim"]; const tEl = $("ms-trend"); tEl.textContent = txt; tEl.className = "ms-trend " + cls; // Swing-Chips: HH/HL = bullisch (grün), LH/LL = bärisch (rot) const chips = (s.swings || []).map(sw => { const bull = sw.type === "HH" || sw.type === "HL"; const c = (sw.type === "H" || sw.type === "L") ? "dim" : (bull ? "up" : "down"); return `${sw.type}`; }).join(''); $("ms-swings").innerHTML = chips || ''; // BOS const b = s.bos; const bEl = $("ms-bos"); if (b) { const up = b.dir === "up"; bEl.innerHTML = `BOS` + `${up ? "▲ über" : "▼ unter"} ${fmt(b.level)}` + `vor ${b.bars_ago} Bars`; bEl.classList.remove("hidden"); } else { bEl.classList.add("hidden"); } // Kanal: Richtung + Position (0 = unten, 1 = oben) const ch = s.channel; const CD = { up: ["Aufwärtskanal", "up"], down: ["Abwärtskanal", "down"], flat: ["Seitwärtskanal", "dim"] }; if (ch) { const [ctxt, ccls] = CD[ch.dir] || ["Kanal", "dim"]; const dEl = $("ms-chan-dir"); dEl.textContent = ctxt; dEl.className = ccls; const pct = Math.round((ch.pos != null ? ch.pos : 0.5) * 100); $("ms-chan-fill").style.width = pct + "%"; $("ms-chan-dot").style.left = pct + "%"; const zone = pct <= 25 ? "am unteren Rand" : pct >= 75 ? "am oberen Rand" : "in der Mitte"; $("ms-chan-note").textContent = `Kurs ${zone} (${pct}%)`; $("card-structure").classList.remove("hidden"); } else { $("ms-chan-dir").textContent = "—"; $("ms-chan-note").textContent = ""; $("ms-chan-fill").style.width = "0%"; } } // Circuit Breaker — roter Banner wenn ausgelöst, bernstein wenn nah (>60% des Limits). function renderCircuitBreaker(cb) { const el = $("circuit-banner"); if (!el) return; if (!cb || !cb.enabled) { el.className = "circuit hidden"; el.textContent = ""; return; } const limit = cb.limit_eur, day = cb.day_total; if (cb.tripped) { el.className = "circuit tripped"; el.textContent = `🛑 CIRCUIT BREAKER AKTIV — Tagesverlust ${fmt(day, 2)} € erreicht ` + `(Limit −${fmt(limit, 0)} €). Keine Auto-Trades mehr heute.`; } else if (limit && day < 0 && Math.abs(day) >= 0.6 * limit) { el.className = "circuit near"; el.textContent = `⚠ Tagesverlust ${fmt(day, 2)} € — Stopp bei −${fmt(limit, 0)} € ` + `(${Math.round(100 * Math.abs(day) / limit)} % erreicht)`; } else { el.className = "circuit hidden"; el.textContent = ""; } } // Chartmuster (visuell) — reine Anzeige. Status: bildet sich / bestätigt / (ungültig raus). // Kompakte SVG-Skizze der Muster-Form (viewBox 60×32). Rein schematisch, damit die // Form auf einen Blick erkennbar ist; Farbe = Richtung (grün bull / rot bear / grau). function _patternSvg(type, col) { const P = { double_top: "M2,26 L14,8 L22,16 L34,8 L46,26", double_bottom: "M2,6 L14,24 L22,16 L34,24 L46,6", hs: "M2,24 L12,14 L18,20 L28,4 L38,20 L44,14 L54,24", inv_hs: "M2,8 L12,18 L18,12 L28,28 L38,12 L44,18 L54,8", cup: "M2,6 C2,30 44,30 44,6 M44,6 L54,12", inv_cup: "M2,26 C2,2 44,2 44,26 M44,26 L54,20", tri_asc: "M2,26 L52,26 M2,4 L52,22", tri_desc: "M2,6 L52,6 M2,28 L52,10", tri_sym: "M2,4 L52,16 M2,28 L52,16", }; const d = P[type] || "M2,16 L54,16"; return ``; } // Kerzen-Anatomie (M5). ⚠ Bewusst OHNE grün/rot-Richtungsfarbe: die Lehrbuch- // Deutung ist gemessen invertiert (`backtest_candles.py` — langer Körper läuft eher // GEGEN sich, Volumen verstärkt das statt zu bestätigen), eine Ampel würde also // systematisch in die falsche Richtung stupsen. Dieselbe Korrektur wie bei der // Bounce-Anzeige, die deshalb heute „Überdehnung" heißt. function renderCandles(ca) { const el = $("ca-list"); if (!el) return; const items = (ca && ca.items) || []; if (!items.length) { el.innerHTML = `Keine markante Kerzenform in den letzten Bars.`; return; } el.innerHTML = items.map(m => { const arr = m.dir > 0 ? "▲" : m.dir < 0 ? "▼" : "◆"; const ago = m.bars_ago === 1 ? "letzte Kerze" : `vor ${m.bars_ago} Kerzen`; const vol = m.vol_x ? `Vol ${fmt(m.vol_x, 1)}×` : ""; return `
${arr} ${m.label} ${vol}${ago}
Körper ${m.body_pct}% · Docht oben ${m.wick_up_pct}% · unten ${m.wick_lo_pct}%
${m.reading || ""}
`; }).join("") + `
🕯 Reine Anzeige — KEIN Signal. Gemessen (backtest_candles.py, 80k Bars, 2 Halbjahre): die gängige Deutung ist invertiert — der lange Körper läuft eher GEGEN sich (−0,06/−0,08×ATR), mit 2× Volumen noch stärker (−0,12/−0,20). Als Handelssignal durchgefallen (backtest_candle_fade.py).
`; } // Wahrscheinlichkeits-Kegel. ⚠ Zeigt STREUUNG, nicht Richtung — und beschriftet // mit der REAL gemessenen Abdeckung (~76 %), nicht mit dem Nennwert 80 %. Der Kegel // ist out-of-sample geprüft (analyze_cone.py, Fit H1 → Abdeckung H2) und damit nach // dem P(break)-Modell die zweite kalibrierte Komponente im Projekt. // ⚠ Seit 2026-08-08 in der Karte „Marktkontext" (`#kx-cone`) — die frühere // eigenen Karte (User-Vorgabe). Gezeigt wird nur noch das 80 %-Band je Horizont — // das enge 50 %-Band und der Fließtext sind entfallen und stecken im `title`. function renderCone(cn) { const el = $("kx-cone"); if (!el) return; // null-sicher (Cache-Regel) const lv = (cn && cn.levels) || []; const b80 = lv.filter(x => x.band === 80) .sort((a, b) => a.minutes - b.minutes); if (!b80.length) { el.innerHTML = ""; return; } const cov = b80[0].coverage != null ? `${fmt(b80[0].coverage, 0)} %` : "~"; // ⚠ ACI (2026-08-08): die Bänder waren dokumentiert 3–5 Pp ZU ENG (80 % // nominal → real 76,7/76,6/75,3). Adaptive Conformal Inference justiert das // jetzt online nach; die Skala zeigt, wie stark. 1,00 = noch unverändert. const sk = b80.map(x => x.skala).filter(v => v != null); const skTxt = (sk.length && sk.some(v => Math.abs(v - 1) > 0.005)) ? `ACI ×${fmt(sk.reduce((a, b) => a + b, 0) / sk.length, 2)}` : ""; el.innerHTML = `📐 erwartete Spanne` + b80.map(x => `${x.minutes}′` + `${fmt(x.lo, 2)}–${fmt(x.hi, 2)}`).join("") + `trifft ${cov}` + skTxt; // Die Einordnung gehört an die Zahl, nicht in einen Absatz darunter — sonst // liest jemand die Spanne als Richtungsprognose. el.title = "STREUUNG, nicht Richtung: wie WEIT der Kurs plausibel läuft, nicht " + "wohin. 80 %-Band, out-of-sample geprüft (analyze_cone.py, Fit auf einem " + "Halbjahr, Abdeckung im anderen gemessen) — es trifft real ~76 %, ist also " + "leicht zu eng. Kein Signal, kein Einfluss auf die Empfehlung."; } function renderPatterns(pt) { const el = $("pt-list"); if (!el) return; const items = (pt && pt.patterns) || []; if (!items.length) { el.innerHTML = `Aktuell kein klares Muster erkannt.`; return; } el.innerHTML = items.map(m => { const dirCls = m.dir === "bull" ? "up" : m.dir === "bear" ? "down" : "dim"; const col = m.dir === "bull" ? "#3fb950" : m.dir === "bear" ? "#f85149" : "#8b949e"; const arr = m.dir === "bull" ? "▲" : m.dir === "bear" ? "▼" : "◆"; const st = m.status === "confirmed" ? `bestätigt` : `bildet sich`; const tgt = m.target != null ? `Ziel ${fmt(m.target, 2)}` : `Ausbruch offen`; const q = Math.round((m.quality || 0) * 100); return `
${_patternSvg(m.type, col)} ${arr} ${m.name}${st} Güte ${q}%
Trigger ${fmt(m.trigger, 2)} · ${tgt} · Höhe ${m.height_atr}×ATR · vor ${m.bars_ago} Bars
${m.note || ""}
`; }).join("") + `
📊 Reine Anzeige (mechanisch aus M30-Pivots) — KEIN Signal, kein Einfluss auf die Empfehlung. Muster-Klasse im Projekt als Signal noch unbelegt; Einfluss erst nach Backtest.
`; } function render(d) { // ── Header ────────────────────────────── $("conn").className = "dot " + (d.connected ? "on" : "off"); $("symbol").textContent = d.symbol || "—"; const m = d.market || {}; // ── Server- (Broker) + Lokalzeit ──────── const _p2 = (n) => String(n).padStart(2, "0"); // server_time ist Broker-Wallclock, als UTC etikettiert → getUTC* zeigt sie 1:1 const srv = m.server_time ? new Date(m.server_time) : null; const srvStr = (srv && !isNaN(srv)) ? `${_p2(srv.getUTCHours())}:${_p2(srv.getUTCMinutes())}:${_p2(srv.getUTCSeconds())}` : "—"; $("hdr-clock").textContent = `Srv ${srvStr} · Lok ${new Date().toLocaleTimeString("de-DE")}`; // ── Börsen-Session ────────────────────── const ses = d.session || {}; let stxt = ses.phase || "—"; if (ses.next_open) stxt += ` · ${ses.next_open.name}-Open in ${ses.next_open.in_min} min`; // 🕘 Prime-Time 21–22 Uhr (Berlin ≈ Browser-Lokalzeit): gemessen die mit Abstand // stärksten Stunden (backtest_hourly: netto +0,25/+0,49 — Rest ~breakeven). // 📰 News-Sturm: viele frische Öl-Headlines (news.storm_state) = Ereignis läuft // → Vorsicht/Vola. Beides REINE ANZEIGE, kein Filter. const _h = new Date().getHours(); if (_h === 21 || _h === 22) stxt = `🕘 PRIME-TIME · ${stxt}`; const storm = (d.news_storm || {}).level; if (storm === "storm") stxt = `📰 NEWS-STURM · ${stxt}`; else if (storm === "elevated") stxt = `📰 viele News · ${stxt}`; // Marktstatus VOR die Session-Zeile: bei geschlossenem Markt ist die Phase // ("Wochenende") allein zu leise — der eingefrorene Kurs sah live aus. if (d.market_closed) { stxt = `🔒 MARKT ZU · ${stxt}`; // HL-Kurs steht jetzt im Header } $("session").textContent = stxt; $("session").className = "session-line " + (d.market_closed ? "warn" : storm === "storm" ? "warn" : (_h === 21 || _h === 22) ? "warn" : ses.just_opened ? "warn" : (ses.active && ses.active.length) ? "ok" : "dim"); // ── Konto: Balance / Equity ───────────── const cs = curSym(m); $("balance").textContent = m.balance != null ? cs + fmt(m.balance, 2) : "—"; if (m.equity != null) { $("equity").textContent = cs + fmt(m.equity, 2); $("equity").className = (m.balance != null) ? (m.equity >= m.balance ? "up" : "down") : ""; } else { $("equity").textContent = "—"; $("equity").className = ""; } // ── Tages-P/L (realisiert heute + offene Position) ─────── const dp = d.day_pl || {}; if (dp.total != null) { $("day-pl").textContent = (dp.total >= 0 ? "+" : "") + cs + fmt(dp.total, 2); $("day-pl").className = dp.total > 0 ? "up" : dp.total < 0 ? "down" : ""; } else { $("day-pl").textContent = "—"; $("day-pl").className = ""; } // ── Nähe zu S/R (Farb-Signal, innerhalb 0,3×ATR) ────────── const srl = d.sr_levels || {}; const atrRef = (d.trail || {}).atr || m.atr_m15 || 0; const near = atrRef ? atrRef * 0.30 : 0.05; let priceCls = ""; if (m.bid != null) { const dRes = srl.resistance != null ? srl.resistance - m.bid : Infinity; const dSup = srl.support != null ? m.bid - srl.support : Infinity; if (dRes >= 0 && dRes <= near && dRes <= dSup) priceCls = "near-res"; else if (dSup >= 0 && dSup <= near && dSup < dRes) priceCls = "near-sup"; } // ── Header: Kurs (färbt sich nahe S/R) + G/V der offenen Position (netto) ── // ⚠ MARKT ZU (User 2026-08-01): Pepperstone handelt WTI nicht am Wochenende, der // Kurs friert dann auf dem Freitags-Stand ein — sichtbar war das NICHT (der grüne // Punkt im Header ist die VERBINDUNG, nicht der Marktstatus). Hyperliquid läuft // 24/7 weiter, also zeigen wir dessen Kurs (basis-korrigiert = MT5-vergleichbar) // statt des eingefrorenen — deutlich als „HL" gekennzeichnet, damit niemand ihn // für einen Broker-Kurs hält. // ⚠ Der BROKER-Kurs bleibt der Hauptkurs (erster Entwurf ersetzte ihn bei // geschlossenem Markt durch den HL-Kurs — der User wollte stattdessen BEIDE // nebeneinander, was ehrlicher ist: man sieht die Abweichung statt einer // stillen Vertauschung). $("hdr-price").textContent = m.bid != null ? fmt(m.bid) : "—"; $("hdr-price").className = priceCls; // ⚠ NULL-SICHER auf NEUE Elemente zugreifen (Fix 2026-08-01): `index.html` und // `app.js` werden vom Browser UNABHÄNGIG gecacht. Trifft neue JS auf eine alte, // gecachte HTML, wirft ein direkter Zugriff auf ein noch nicht existierendes // Element eine TypeError — und weil das mitten in `render()` passiert, bricht der // GESAMTE Render ab: das Dashboard friert ein und zeigt Altwerte. Genau so sah der // Fehler beim Nutzer aus („ich sehe die Änderungen nicht"). Mit Guard läuft der // Rest normal weiter, nur die neuen Felder fehlen, bis die HTML nachgeladen ist. const lblEl = $("hdr-price-lbl"); if (lblEl) { lblEl.textContent = d.market_closed ? "PEPPERSTONE KURS · ZU" : "PEPPERSTONE KURS"; lblEl.title = d.market_closed ? `Pepperstone geschlossen — letzter Tick vor ${Math.round((d.tick_age_s || 0) / 60)} min` : ""; } // Hyperliquid daneben: basis-korrigiert (= MT5-Niveau), mit Abweichung im Tooltip. const hl = d.hl_live || null; const hlEl = $("hdr-hl"); if (hlEl && hl && hl.hl_mid != null) { // ⚠ Angezeigt wird der ROHE HL-Kurs (Fix 2026-08-02, User-Fund): hier stand // vorher `mid_mt5` = HL + Basis, also der auf Pepperstone-Niveau umgerechnete // Wert. Unter der Beschriftung „HYPERLIQUID KURS" ist das schlicht falsch — der // Nutzer verglich mit app.hyperliquid.xyz (81,392) und sah bei uns 86,424. // Jetzt steht hier exakt die Zahl, die auch auf der HL-Seite steht; das // CFD-Äquivalent (für den Broker-Vergleich) wandert in den Tooltip. const arr = hl.trend === "up" ? "▲" : hl.trend === "down" ? "▼" : ""; hlEl.textContent = fmt(hl.hl_mid) + (arr ? " " + arr : ""); const dv = hl.vs_mt5; hlEl.className = d.market_closed ? "hl-price" : (dv != null && Math.abs(dv) >= 0.15) ? "hl-price" : ""; hlEl.title = `Hyperliquid xyz:CL — Rohkurs ${fmt(hl.hl_mid)}, ` + `${fmt(hl.age_s, 0)} s alt.\n` + `Auf Pepperstone-Niveau: ${fmt(hl.hl_mid)} + Basis ` + `${hl.basis >= 0 ? "+" : ""}${fmt(hl.basis, 3)} = ${fmt(hl.mid_mt5)}` + (dv != null ? ` (ggü. Broker ${dv >= 0 ? "+" : ""}${fmt(dv, 3)})` : "") + (hl.basis_stale ? `\n⚠ Broker steht — Basis ist auf dem letzten Stand mit lebendem Markt ` + `eingefroren. Ohne das würde die Umrechnung nur den eingefrorenen ` + `Broker-Kurs reproduzieren.` : ""); } else if (hlEl) { hlEl.textContent = "—"; hlEl.className = ""; hlEl.title = ""; } const hpos = d.position || {}; if (hpos.ticket) { const wht = d.wht_pct || 0; // ⚠ Bei geschlossenem Markt steht der Broker-P&L still (der Kurs ist eingefroren). // Dann die HL-SCHÄTZUNG zeigen (User 2026-08-01) — klar als Schätzung markiert: // Swap/Kommission der Wochenendtage fehlen und HL ist ein anderer Kontrakt. const useHl = d.market_closed && d.pnl_hl != null; const raw = useHl ? d.pnl_hl : (hpos.pnl || 0); const netHdr = raw > 0 ? raw * (1 - wht / 100) : raw; $("hdr-pnl").textContent = (useHl ? "≈" : "") + (netHdr >= 0 ? "+" : "") + cs + fmt(netHdr, 2); $("hdr-pnl").className = netHdr > 0 ? "up" : netHdr < 0 ? "down" : ""; $("hdr-pnl").title = useHl ? `Schätzung auf Basis des Hyperliquid-Kurses — der Broker-Kurs ist eingefroren ` + `(Broker-P&L ${fmt(hpos.pnl || 0, 2)} ${cs}). Ohne Swap/Kommission des Wochenendes.` : ""; } else { $("hdr-pnl").textContent = "—"; $("hdr-pnl").className = ""; $("hdr-pnl").title = ""; } // ── S/R-Annäherungs-Warnung (Position-Kachel) — NUR wenn kein reicherer // Positions-Hint (pos-srhint) läuft, sonst doppeltes „nähert sich Widerstand". const hasRichHint = !!((d.position && d.position.ticket) && d.sr_close_hint); if (hasRichHint) { $("cur-near").className = "cur-near"; $("cur-near").textContent = ""; } else { $("cur-near").className = "cur-near " + priceCls; $("cur-near").textContent = priceCls === "near-res" ? `▲ nähert sich Widerstand ${fmt(srl.resistance)}` : priceCls === "near-sup" ? `▼ nähert sich Unterstützung ${fmt(srl.support)}` : ""; } // ── Gesamtempfehlung (aggregiert) ─────── renderM15(d.m15_setup, d); renderBrk(d); renderKontext(d); renderCircuitBreaker(d.circuit_breaker); renderPatterns(d.patterns); renderCandles(d.candle_anat); renderCone(d.cone); renderStructure(d.structure); renderManual(d.manual_stats); renderStatsCompact(d.stats_compact, d.market); renderGaps(d.gaps, d.market); // ── Position (Kurs/G/V stehen jetzt im Header) ── const p = d.position || {}; // ── Trade-Bereich über der Gesamtempfehlung (nur bei offener Position) ── const tb = $("trade-bar"); if (tb) { if (p.ticket) { tb.classList.remove("hidden"); const long = p.order_type === 0; const dEl = $("tb-dir"); dEl.className = "tb-val tb-dir " + (long ? "up" : "down"); dEl.textContent = long ? "▲ LONG" : "▼ SHORT"; $("tb-lots").textContent = fmt(p.lots, 2); $("tb-margin").textContent = p.margin ? cs + fmt(p.margin, 2) : "—"; // Laufzeit: Öffnungszeit merken (echte Epoch, Broker-Offset korrigiert) und // sekündlich per Ticker anzeigen — unabhängig vom Snapshot-Intervall. _tradeOpenTime = p.open_time ? Number(p.open_time) : 0; _tickTradeDuration(); // SL/TP-Felder nur befüllen, wenn nicht gerade bearbeitet (kein Tipp-Konflikt) const slEl = $("tb-sl"), tpEl = $("tb-tp"); // ⚠⚠ DREI Nachkommastellen, nicht zwei (Fix 2026-08-20, User-Fund // "SL war auf 84,86 laut Dashboard"). WTI notiert dreistellig; mit // `fmt(x, 2)` wurde aus dem echten Stop 84,776 die Anzeige 84,78. // Das ist NICHT nur kosmetisch: das Feld ist beschreibbar, und ein // Enter darauf schickt den GERUNDETEN Wert an den Broker — der Stop // verschiebt sich also still um bis zu 0,5 Cent. Bei einem Trail von // 11 Cent (ATR_M5 0,11 in dieser Nacht) sind das 5 % der Distanz. if (document.activeElement !== slEl) slEl.value = p.sl ? fmt(p.sl, 3) : ""; if (document.activeElement !== tpEl) tpEl.value = p.tp ? fmt(p.tp, 3) : ""; } else { tb.classList.add("hidden"); _tradeOpenTime = 0; } } // ── Notfall-Stop: Feld wieder eingeblendet (User-Vorgabe 2026-07-22; war // 2026-07-17 entfernt, Schutz lief nur noch über Broker-SL+Trailing — Backend // (set_emergency/emergency_state.json) blieb die ganze Zeit aktiv/persistent). // %-Modus (auto_emergency_pct>0): der Auto-Arm nimmt pct% der Balance → im // Flat-Zustand diesen dynamischen Wert zeigen statt des Fixwerts. const emg = d.emergency_loss; const emgPct = d.emergency_pct || 0; const emgRem = (emgPct > 0 && d.balance) ? Math.round(emgPct / 100 * d.balance * 100) / 100 : d.emergency_remember; const ei = $("emg-input"); if (ei && document.activeElement !== ei) { const shown = emg != null ? emg : (emgRem || ""); ei.value = shown === "" ? "" : String(shown); } // („Gewinn mitnehmen"-Eingabe aus dem Dashboard entfernt — Backend-Funktion // set_takeprofit/api bleibt erhalten, nur ohne UI.) // ── Trailing ──────────────────────────── const tr = d.trail || {}; $("trail-state").textContent = tr.enabled ? "AKTIV" : "aus"; $("trail-state").className = tr.enabled ? "up" : "dim"; $("trail-phase").textContent = tr.phase || "—"; $("trail-sl").textContent = fmt(tr.last_sl, 3); $("trail-tp").textContent = fmt(tr.last_tp, 3); $("trail-atr").textContent = `${fmt(tr.atr, 3)} · ${tr.atr_tf || "—"}`; // ── Wellen-Meldungen (jetzt in „Meldungen"; Signal-Zeile entfällt — steht als // Headline in der Gesamtempfehlung) ────────────────────── // Setup- und Lauf-Zeile entfernt (User-Vorgabe 2026-08-06): das Setup steht // ohnehin in der Verdict-Headline bzw. den Gründen, der „Lauf" war eine // Beschreibung der Vergangenheit ohne Handlungsbezug. // ⚠ `w` bleibt — es wird weiter unten noch für die Gründe-Zeile, das // Button-Blinken, den Close-Alarm und die News-Konflikt-Meldung gebraucht. const w = d.wave_signal || {}, ws = d.wave || {}; // Überdehnungs-Anzeige (KEIN Handelssignal — gemessen ~Münzwurf, nur Kontext). // Reaktionsrichtung explizit: LONG=überverkauft→nach oben, SHORT=überkauft→nach unten. const bn = ws.bounce || {}; const rword = bn.dir === "LONG" ? "nach oben ↑" : "nach unten ↓"; const tf = bn.tf ? ` (${bn.tf})` : ""; const be = $("wave-bounce"); if (bn.state === "active") { be.className = "bounce-ind warn strong"; be.textContent = `⚠ stark überdehnt${tf} — Reaktion ${rword} möglich`; } else if (bn.state === "expected") { be.className = "bounce-ind warn"; be.textContent = `⚠ überdehnt${tf} — Reaktion ${rword} möglich`; } else { be.className = "bounce-ind idle"; // immer sichtbar (Ruhe-Zustand) be.textContent = "Überdehnung: keine"; } // Volatilitäts-Squeeze-Breakout (additiver Setup, gemessen 2-Stichproben-positiv): // active = Ausbruch aus Kompression (echtes Signal → grün/rot) · armed = komprimiert, // Ausbruch steht bevor (grau) · sonst keine Kompression. const sq = ws.squeeze || {}; const sqEl = $("wave-squeeze"); if (sqEl) { const arr = sq.dir === "LONG" ? "▲" : "▼"; const bx = sq.box_atr != null ? ` · Box ${sq.box_atr}×ATR` : ""; if (sq.state === "active") { sqEl.className = "squeeze-ind fire " + (sq.dir === "LONG" ? "long" : "short"); sqEl.textContent = `🚀 Squeeze-Breakout ${arr} ${sq.dir} @ ${fmt(sq.level, 3)}${bx}`; } else if (sq.state === "armed") { sqEl.className = "squeeze-ind armed"; sqEl.textContent = `🎯 Kompression${bx} — Ausbruch ${arr} steht bevor`; } else { sqEl.className = "squeeze-ind idle"; sqEl.textContent = "Squeeze: keine Kompression"; } } // TF-Ampel: M1/M5/M15/M30 grün(auf)/rot(ab); frische EMA12/50-Kreuzung // (≤3 Bars) → blinkendes ⟳ + Richtungspfeil. const turns = ws.tf_turns || {}; $("wave-turns").innerHTML = ["M1", "M5", "M15", "M30", "H1"].map(tf => { const t = turns[tf] || {}; const cls = t.dir > 0 ? "up" : t.dir < 0 ? "down" : "flat"; const arr = t.dir > 0 ? "▲" : t.dir < 0 ? "▼" : "·"; const fresh = t.fresh ? " fresh" : ""; const turn = t.fresh ? `⟳${arr}` : arr; const ago = (t.bars_ago != null && t.fresh) ? `${t.bars_ago}` : ""; return `${tf} ${turn}${ago}`; }).join(""); // (S/R-Zeile im Position-Bereich ausgeblendet — die Level stehen im Chart; // die Preis-Färbung/„nähert sich"-Warnung nutzt srl weiterhin.) $("wave-reasons").textContent = (w.reasons || []).slice(0, 3).join(" · "); // Elliott/FVG + TradersUnion: keine Einzel-Kacheln mehr (ausgeblendet) — // beide fließen weiterhin in die Gesamtempfehlung (engine._verdict) ein. // ── KI-Agent ──────────────────────────── const ag = (d.agent || {}).advisory; if (ag) { const b = ag.bias || "NEUTRAL"; const bc = b === "LONG" ? "long" : b === "SHORT" ? "short" : ""; const ba = b === "LONG" ? "▲" : b === "SHORT" ? "▼" : "◌"; $("agent-bias").className = "signal " + bc; $("agent-bias").textContent = `${ba} ${b} · ${ag.confidence}%` + (ag.timeframe ? ` · ${ag.timeframe}` : ""); $("agent-head").textContent = ag.headline || ""; $("agent-reason").textContent = ag.reasoning || ""; $("agent-risks").innerHTML = (ag.risks || []).map(r => `
  • ${r}
  • `).join(""); } else { $("agent-bias").className = "signal dim"; $("agent-bias").textContent = (d.agent || {}).busy ? "◌ analysiere …" : "◌ —"; $("agent-head").textContent = ""; $("agent-reason").textContent = (d.agent || {}).error || ""; $("agent-risks").innerHTML = ""; } // ── Aktionsleiste: Zustände ───────────── const hasPos = !!p.ticket; const longBtn = document.querySelector('.btn[data-side="long"]'); const shortBtn = document.querySelector('.btn[data-side="short"]'); const closeBtn = document.querySelector('.btn[data-side="close"]'); longBtn.disabled = hasPos; shortBtn.disabled = hasPos; closeBtn.disabled = !hasPos; // BRK-Schalter ENTFERNT (User 2026-08-19), dafuer der Circuit-Breaker. Der // Auto-Squeeze-ZUSTAND bleibt in der Breakout-Karte sichtbar -- nur sein // Schalter ist weg (jetzt POST-Endpunkt bzw. ini). const cb = d.circuit_breaker || {}; const m15b = $streng("btn-m15"); if (m15b) { m15b.classList.toggle("on", !!d.auto_m15); m15b.title = d.auto_m15 ? "Auto-Entry auf die gruene M15-Karte AN (" + (d.m15_entry_count || 0) + " Entries). ACHTUNG: gemessen negativ -- reiner Test." : "Auto-Entry auf die gruene M15-Karte AUS (gemessen negativ)."; } const srb = $streng("btn-sr"); if (srb) { srb.classList.toggle("on", !!d.auto_sr_close); srb.title = d.auto_sr_close ? "S/R-Auto-Close AN: schliesst am Level, wenn P(break) unter der Schwelle liegt." : "S/R-Auto-Close AUS -- der Bot schliesst nicht mehr selbst am Level."; } const cbb = $streng("btn-cb"); if (cbb) { cbb.classList.toggle("on", !!cb.enabled); cbb.title = cb.enabled ? "Circuit Breaker AN: schliesst bei " + fmt(cb.limit_eur, 2) + " € (" + cb.limit_pct + " % der Balance). Heute: " + fmt(cb.day_total, 2) + " €." : "Circuit Breaker AUS — kein automatischer Schutz gegen eine Verlust-Serie."; } // ⚠⚠ Der 🎯 SIG-Button ist am 2026-08-12 ENTFERNT (User-Entscheidung). Der // autonome Entry auf die Empfehlung war seit dem 01.08. aus und auf BEIDEN // Ausführungs-Achsen gemessen negativ — Markt in allen 8 Feldern, ruhende // Order mit Touch-Fill in allen 6 und dabei schlechter als Markt. Grund // strukturell: das Bestätigungs-Level `_pend` WANDERT. $("btn-trail").classList.toggle("on", !!(d.trail || {}).enabled); // Empfehlungs-Blinken: LONG/SHORT-Button blinkt, sobald die Welle eine // Richtung empfiehlt (= wie in der Welle-Kachel sichtbar) und keine Position // offen ist. Nicht mehr an die 70%-Auto-Schwelle gekoppelt. longBtn.classList.toggle("blink", !hasPos && w.signal === "LONG"); shortBtn.classList.toggle("blink", !hasPos && w.signal === "SHORT"); // Ton bei Empfehlung (nur flat): sofort beim Signal-Wechsel, danach Erinnerung // alle 30 s, solange der Button blinkt. Kein Ton bei offener Position. const recSig = (!hasPos && (w.signal === "LONG" || w.signal === "SHORT")) ? w.signal : null; if (recSig) beepRecommendation(recSig, recSig !== _lastRecSignal); _lastRecSignal = recSig; // CLOSE-Alarm (Blink + Ton) NUR wenn der Gewinn ÜBER dem eingestellten S/R- // Mindestgewinn liegt (User-Vorgabe). Feld leer/0 → wie bisher: jeder Gewinn > 0. // Deckt sich mit dem Auto-Close-Gate im Backend (unter der Schwelle schließt der // Bot ohnehin nicht → dann auch kein Alarm/Ton). const posLong = p.order_type === 0; const minGain = d.sr_close_min_gain || 0; const gainOK = hasPos && (p.pnl || 0) > minGain; const flipClose = gainOK && ((posLong && w.signal === "SHORT") || (!posLong && w.signal === "LONG")); // S/R-Close-Empfehlung (gemessen, nur das robuste Segment): Kurs AM Level + // schwacher Anlauf → Level hält ~74 % → Close schlägt Laufenlassen. Text-Hinweis // in der Position-Kachel immer; Blink/Ton nur über dem Mindestgewinn. const srHint = d.sr_close_hint || null; // vorhanden = Kurs am Level const srAuto = !!d.auto_sr_close; const srClose = !!(srHint && srHint.close); // P(break) < Schwelle → schließen const closeAlert = flipClose || (gainOK && srClose); closeBtn.classList.toggle("blink", closeAlert); if (closeAlert) beepCloseAlert(); // Blinken + Ton (gedrosselt, alle 4 s) const hintEl = $("pos-srhint"); if (hintEl) { if (hasPos && srHint) { const lvlName = posLong ? "Widerstand" : "Support"; // Bestätigungs-Preis (level±0,5×ATR, dieselbe Definition wie beim Modell- // Training): ein kurzer Docht über die Levelzahl ist noch KEIN Durchbruch. const confirmTxt = srHint.confirm != null ? ` (Bestätigung erst ab ${fmt(srHint.confirm, 3)})` : ""; if (!srHint.at_level) { // Vorwarnung: Kurs nähert sich dem Level (0,15–0,6×ATR davor). // Schwelle dynamisch aus der Config (sr_close_pbreak), nicht hart 60. const thr = (d.sr_close_pbreak || 0.60) * 100; hintEl.className = "pos-srhint approach"; hintEl.textContent = `🎯 Nähert sich ${lvlName} ${fmt(srHint.level, 3)} ` + `(${srHint.gap_atr}×ATR) · Durchbruch ${srHint.p_break}%${confirmTxt}` + (srHint.p_break < thr ? " → Bot schließt dort (im Plus)" : " → dürfte durchlaufen"); } else if (srClose) { hintEl.className = "pos-srhint warn"; const tail = (srAuto && gainOK) ? "🤖 Bot schließt automatisch" : "Close erwägen"; hintEl.textContent = `🎯 Am ${lvlName} ${fmt(srHint.level, 3)} · Durchbruch nur ` + `${srHint.p_break}%${confirmTxt} → prallt wahrscheinlich ab → ${tail}`; } else { hintEl.className = "pos-srhint run"; hintEl.textContent = `🎯 Am ${lvlName} ${fmt(srHint.level, 3)} · Durchbruch ` + `${srHint.p_break}%${confirmTxt} wahrscheinlich → läuft weiter`; } } else { hintEl.className = "pos-srhint hidden"; hintEl.textContent = ""; } } // Gegen-/Stop-Seiten-Hinweis (REINE ANZEIGE, kein Auto-Close): Kurs läuft auf das // Level GEGEN die Position zu → bricht es durch (Verlust läuft) oder prallt es ab? // P(break)-Modell auf der Gegen-Seite gemessen kalibriert (AUC 0,72 o.o.s.), // im hohen Bereich leicht konservativ. Nur zeigen, wenn KEIN Ziel-Level-Hint läuft. const stopEl = $("pos-stophint"); if (stopEl) { const sa = d.stop_approach || null; if (hasPos && sa && !srHint) { const lvlName = sa.side === "sup" ? "Support" : "Widerstand"; const dir = sa.side === "sup" ? "nach unten" : "nach oben"; const confirmTxt = sa.confirm != null ? ` (Bruch ab ${fmt(sa.confirm, 3)})` : ""; const holds = sa.p_break < 50; stopEl.className = "pos-stophint " + (holds ? "hold" : "break"); const lead = sa.at_level ? `Am ${lvlName}` : `Nähert sich ${lvlName}`; stopEl.textContent = `ℹ️ ${lead} ${fmt(sa.level, 3)} (${sa.gap_atr}×ATR) · Durchbruch ${dir} ` + `${sa.p_break}%${confirmTxt} → ` + (holds ? "Level hält wahrscheinlich" : "bricht wahrscheinlich durch"); } else { stopEl.className = "pos-stophint hidden"; stopEl.textContent = ""; } } // News-Konflikt-Chip (2026-07-24, realer Auslöser 23.07.: zwei Shorts gegen einen // +0,98-Sentiment-Burst): |Score|≥0,5 UND Richtung gegen offene Position bzw. // gegen das aktive Signal → Warnung. Reiner Kontext, Prädiktivität unbelegt. const ncEl = $("news-conflict"); if (ncEl) { const ns = d.news_sentiment; let txt = ""; if (ns != null && Math.abs(ns) >= 0.5) { const newsDir = ns > 0 ? "LONG" : "SHORT"; const newsWord = ns > 0 ? "bullisch" : "bärisch"; if (hasPos) { const posDir = posLong ? "LONG" : "SHORT"; if (posDir !== newsDir) txt = `📰 News-Flow stark ${newsWord} (${ns > 0 ? "+" : ""}${fmt(ns, 2)}) — steht gegen deine ${posDir}-Position`; } else if (w.signal === "LONG" || w.signal === "SHORT") { if (w.signal !== newsDir) txt = `📰 News-Flow stark ${newsWord} (${ns > 0 ? "+" : ""}${fmt(ns, 2)}) — steht gegen das ${w.signal}-Signal`; } } if (txt) { ncEl.className = "news-conflict warn"; ncEl.textContent = txt; } else { ncEl.className = "news-conflict hidden"; ncEl.textContent = ""; } } // Wirtschaftskalender (2026-07-30): laufendes News-Fenster bzw. Vorwarnung ≤60 min. // ⚠ NICHT gemessen — Handbuch-Regel; der Bot pausiert im Fenster nur seine // AUTONOMEN Entries, manuelle Orders bleiben frei. const calEl = $("cal-note"); if (calEl) { const c = d.calendar || {}; let t = ""; if (c.blackout) { const m = c.blackout.minutes; t = `📅 News-Fenster: ${c.blackout.title} ${m > 0 ? "in " + Math.round(m) + " min" : "vor " + Math.round(-m) + " min"} — Bot eröffnet gerade keine Auto-Trades`; } else if (c.next && c.next.minutes <= 60) { t = `📅 ${c.next.title} in ${c.next.minutes} min`; } if (t) { calEl.className = "news-conflict warn"; calEl.textContent = t; } else { calEl.className = "news-conflict hidden"; calEl.textContent = ""; } } // Auto-Flip-Close sichtbar machen (2026-08-05). Dieser Exit schliesst echte // Positionen, ist aber in BEIDEN Halbjahren gemessen NEGATIV (−92/−160 R, // backtest_flipclose2.py) und lief bis heute voellig ohne Anzeige. Die Zeile // nennt bewusst die Messung mit — sonst sieht ein Zaehler nach Erfolg aus. const flipEl = $("flip-note"); if (flipEl) { if (d.flip_close) { const n = d.flip_close_count || 0; const r = d.flip_close_min_r; flipEl.className = "news-conflict warn"; flipEl.textContent = `🔄 Auto-Flip-Close AKTIV (ab ${r != null ? r : "?"}×ATR im Plus)` + ` — bisher ${n}× ausgelöst · ⚠ gemessen in beiden Halbjahren negativ`; flipEl.title = "Schliesst die Position, wenn die Empfehlung dreht. " + "backtest_flipclose2.py: −92 R (H1) / −160 R (H2) gegenueber Nichtstun. " + "Laeuft auf ausdruecklichen Wunsch; abschalten via [trading] auto_flip_close=false."; } else { flipEl.className = "news-conflict hidden"; flipEl.textContent = ""; } } // Schalter-Zustand: Auto-Close am S/R an/aus + Mindestgewinn const srBtn = $("srclose-btn"); if (srBtn) { srBtn.textContent = srAuto ? "AN" : "AUS"; srBtn.title = srAuto ? "Bot schließt am Level (im Plus)" : "nur Hinweis, kein Auto-Close"; srBtn.classList.toggle("on", srAuto); } const srMin = d.sr_close_min_gain || 0; const smi = $("srmin-input"); if (smi && document.activeElement !== smi) { smi.value = srMin > 0 ? String(srMin) : ""; } // Einblendung: der Bot hat am S/R automatisch geschlossen (Zähler stieg) const src = d.sr_close_count; if (src != null) { if (_lastSrCloseCount != null && src > _lastSrCloseCount) { toast("🎯 Bot hat am Level geschlossen (schwacher Anlauf, Gewinn gesichert)", true); } _lastSrCloseCount = src; } // ── Letzte Trades ─────────────────────── // 5 → 10 (User-Wunsch 2026-08-04). Das Backend liefert bereits 10 // (`engine.snapshot` → `last_closed_trades(10)`); dieser Slice ist die zweite, // unabhängige Begrenzung — beide müssen zusammenpassen. const tds = (d.last_trades || []).slice(0, 10); $("trades").innerHTML = tds.map(x => { const pnl = x.pnl || 0; const cls = pnl > 0 ? "up" : pnl < 0 ? "down" : "dim"; const arr = (x.direction || "").toUpperCase() === "BUY" ? "▲" : "▼"; const ts = (x.exit_time || x.entry_time || 0) * 1000; const hm = ts ? new Date(ts).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" }) : "—"; const cb = (x.closed_by || "").toUpperCase().slice(0, 2) || "—"; return `
    ${arr}${pnl >= 0 ? "+" : ""}${fmt(pnl, 2)}
    ` + `
    ${hm} ${cb}
    `; }).join("") || '
    keine
    '; } // ── WebSocket mit Auto-Reconnect ─────────────────────────────────────────── let sock = null, retry = null; // `lastTs` dedupliziert zwischen WS-Push und REST-Poll (beide liefern denselben // `engine.snapshot_cached().ts`) — nur die jeweils NEUERE Quelle rendert, kein // Flackern/Doppel-Render, wenn beide fast gleichzeitig eintreffen. let lastTs = 0; // Wann kam zuletzt ein FRISCHER Snapshot an (Wanduhr, egal aus welcher Quelle)? // Grundlage für den 1-s-Fallback und die Stale-Erkennung unten. let lastRecvMs = 0; // Getrennt davon: wann kam zuletzt eine WS-NACHRICHT? Nur diese Uhr entscheidet, // ob der Poll uebersprungen wird (s. Kommentar in `sock.onmessage`). let lastWsMs = 0; function _applySnapshot(d) { if (typeof d.ts === "number" && d.ts <= lastTs) return; // schon Neueres gezeigt if (typeof d.ts === "number") lastTs = d.ts; lastRecvMs = Date.now(); render(d); } function connect() { const proto = location.protocol === "https:" ? "wss" : "ws"; sock = new WebSocket(`${proto}://${location.host}/ws`); sock.onmessage = (ev) => { // ⚠ NUR hier setzen, NICHT in `_applySnapshot`. Der Poll darf die // WS-Frische nicht mitschreiben, sonst drosselt er sich selbst: er würde // nach jedem eigenen Abruf wieder WS_FRISCH_MS pausieren und käme bei totem // WS nur auf ~2,9 s statt 1 s. (In der Simulation aufgefallen, s. unten.) lastWsMs = Date.now(); try { _applySnapshot(JSON.parse(ev.data)); } catch (e) { console.warn(e); } }; sock.onclose = () => { $("conn").className = "dot off"; clearTimeout(retry); retry = setTimeout(connect, 2000); }; sock.onerror = () => sock.close(); } // ── REST-Polling als Sicherheitsnetz (2026-07-22, User-Wunsch) ──────────────── // Zusätzlich zum WS-Push: alle POLL_MS Sekunden `/api/snapshot` abfragen. Fängt // Fälle auf, in denen der WebSocket STILL hängt (kein onclose, keine Zustellung // mehr — z. B. schwaches WireGuard-Signal, App-Wechsel ohne Visibility-Event), // ohne auf einen Reconnect warten zu müssen. Der `lastTs`-Dedup verhindert // Doppel-Renders, solange der WS normal liefert — reine Redundanz, kein Konflikt. // ⚠⚠ 4000 → 1000 (2026-08-07, User: „G/V wird extrem lagged, 1 Sekunde"). // GEMESSEN, bevor gedreht wurde: die gesamte Server-Kette liefert bereits 1 s // (`_pos_loop` 1 s · Snapshot-Cache 0,8 s · WS-Push **Median 1,04 s**, direkt am // Socket nachgemessen). Der Lag entsteht also NICHT im Backend, sondern wenn der // WebSocket am Handy still wegbricht (WireGuard-Aussetzer, App-Wechsel): dann // trägt nur noch dieser Poll — und der stand auf 4 s. // ⚠ Das VERDOPPELT den Verkehr NICHT: solange der WS liefert, überspringt der // Poll den Abruf (s. `WS_FRISCH_MS`). Er feuert nur, wenn der WS schweigt. const POLL_MS = 1000; const WS_FRISCH_MS = 2200; // > WS-Takt (1,04 s) + Jitter, sonst pollt es doppelt async function pollSnapshot() { if (document.hidden) return; // seite unsichtbar → spart Akku/Daten, Wake-Handler holt beim Zurückkommen sofort nach // Liefert der WS gerade? Dann NICHT zusätzlich abfragen — reine Redundanz // kostet über WireGuard sonst ~14 KB/s ohne jeden Nutzen. if (Date.now() - lastWsMs < WS_FRISCH_MS) return; let d; try { d = await (await fetch("api/snapshot")).json(); } catch (e) { return; } // still — Netz-/WS-Aussetzer, dafür IST der Fallback da // ⚠ NICHT mit ins try oben (Fix 2026-08-01): der Poll-`catch` umschloss früher // auch `_applySnapshot` → `render`. Ein Render-Fehler wurde damit LAUTLOS // verschluckt: der Poll feuerte weiter alle 4 s, jeder Durchlauf starb still, // das Dashboard fror auf dem letzten guten Stand ein und die Konsole blieb // leer. Genau diese Kombination hat eine Fehlersuche unnötig lang gemacht. // Netzfehler bleiben still (die sind erwartbar), Render-Fehler werden laut. _applySnapshot(d); } setInterval(pollSnapshot, POLL_MS); // ── Ehrlicher Verbindungspunkt (2026-08-07) ──────────────────────────────── // ⚠ Bisher wurde er NUR bei `sock.onclose` auf „off" gesetzt. Genau der Fall, // der den Lag verursacht, löst aber KEIN onclose aus: der Socket hängt still // (WireGuard-Aussetzer, App-Wechsel) — der Punkt blieb grün und behauptete eine // Verbindung, die nichts mehr liefert. Ein Zustandsanzeiger, der im // Fehlerfall nicht anschlägt, ist schlimmer als keiner. // Jetzt zählt, was WIRKLICH ankommt: liegt der letzte angewandte Snapshot mehr // als STALE_MS zurück, wird der Punkt matt — unabhängig davon, was der Socket // über sich selbst behauptet. const STALE_MS = 3500; setInterval(() => { const el = $("conn"); if (!el) return; const alt = Date.now() - lastRecvMs; el.classList.toggle("stale", lastRecvMs > 0 && alt > STALE_MS); el.title = lastRecvMs ? `letzte Aktualisierung vor ${(alt / 1000).toFixed(1)} s` + (alt > STALE_MS ? " — Verbindung hängt, es wird nachgepollt" : "") : "noch keine Daten empfangen"; }, 1000); // ── Trade-Steuerung ──────────────────────────────────────────────────────── const TOKEN_KEY = "oil_token"; // Token robust speichern: localStorage UND als langlebiges Cookie. So bleibt // "einmal eingeben" auch erhalten, wenn localStorage zickt (PWA/Privat-Tab). function saveToken(tok) { try { localStorage.setItem(TOKEN_KEY, tok); } catch (e) {} try { document.cookie = TOKEN_KEY + "=" + encodeURIComponent(tok) + ";path=/;max-age=31536000;SameSite=Lax"; } catch (e) {} } function getToken() { let t = null; try { t = localStorage.getItem(TOKEN_KEY); } catch (e) {} if (t) return t; const m = document.cookie.match(new RegExp("(?:^|; )" + TOKEN_KEY + "=([^;]*)")); return m ? decodeURIComponent(m[1]) : null; } function clearToken() { try { localStorage.removeItem(TOKEN_KEY); } catch (e) {} try { document.cookie = TOKEN_KEY + "=;path=/;max-age=0"; } catch (e) {} } let lastSnap = {}; const origRender = render; render = (d) => { lastSnap = d; origRender(d); }; // letzten Snapshot merken // Generisches Modal → Promise(true/false). mode "confirm" | "token" function modal(title, msg, mode = "confirm", danger = false) { return new Promise((resolve) => { const box = $("modal"), inp = $("modal-input"); $("modal-title").textContent = title; $("modal-msg").textContent = msg || ""; $("modal-msg").className = "box-msg" + (danger ? " danger" : ""); $("modal-ok").textContent = danger ? "Trotzdem" : "Bestätigen"; inp.classList.toggle("hidden", mode !== "token"); if (mode === "token") inp.value = ""; box.classList.remove("hidden"); if (mode === "token") setTimeout(() => inp.focus(), 50); const done = (val) => { box.classList.add("hidden"); $("modal-ok").onclick = $("modal-cancel").onclick = null; resolve(val); }; $("modal-ok").onclick = () => done(mode === "token" ? (inp.value.trim() || null) : true); $("modal-cancel").onclick = () => done(mode === "token" ? null : false); }); } function toast(msg, ok = true) { const el = $("toast"); el.textContent = msg; el.className = "toast " + (ok ? "ok" : "err"); clearTimeout(toast._t); toast._t = setTimeout(() => el.classList.add("hidden"), 3500); } // Token sicherstellen (gespeichert; bei Bedarf abfragen + serverseitig prüfen) async function ensureToken() { // Token-Pflicht serverseitig abgeschaltet? Dann kein Fenster, kein Header nötig. if (lastSnap.auth_required === false) return ""; let tok = getToken(); while (true) { if (!tok) { tok = await modal("App-Token", "Token aus der Server-Konsole eingeben " + "(steht in [web] api_token).", "token"); if (!tok) return null; } try { const r = await fetch("api/auth/check", { method: "POST", headers: { "X-Auth-Token": tok } }); if (r.ok) { saveToken(tok); return tok; } } catch (e) { /* Netzfehler → unten behandelt */ } clearToken(); tok = null; const retry = await modal("Token ungültig", "Erneut eingeben?", "confirm"); if (!retry) return null; } } async function postAuth(url, tok) { const r = await fetch(url, { method: "POST", headers: { "X-Auth-Token": tok, "Content-Type": "application/json" }, body: JSON.stringify({}) }); if (r.status === 401) { clearToken(); throw new Error("401"); } return r.json(); } async function sendOrder(side, tok) { const r = await fetch("api/order", { method: "POST", headers: { "X-Auth-Token": tok, "Content-Type": "application/json" }, body: JSON.stringify({ side }) }); if (r.status === 401) { clearToken(); throw new Error("401"); } return r.json(); } // Empfehlungs-Abgleich: zeigt Welle UND KI-Copilot, warnt bei Konflikt function recInfo(side) { const w = lastSnap.wave_signal || {}; const sig = w.signal || "—"; const conf = w.conf_pct || 0; const want = side === "long" ? "LONG" : "SHORT"; const minc = lastSnap.min_conf || 70; // ── Welle (primäres Signal → bestimmt die Warnung) ── let waveLine, danger = false; if (sig === "WARTEN" || sig === "—") { waveLine = "⚠ Welle: WARTEN — kein klarer Trend"; danger = true; } else if (sig !== want) { waveLine = `⚠ Welle GEGEN dich: ${sig} ${conf}%`; danger = true; } else if (conf < minc) { waveLine = `Welle ${sig} ${conf}% (unter Schwelle ${minc}%)`; } else { waveLine = `✓ Welle ${sig} ${conf}%`; } // ── KI-Copilot (Kontext) ── const adv = (lastSnap.agent || {}).advisory; let agentLine = "· KI-Copilot: —"; if (adv) { const ab = adv.bias || "NEUTRAL", ac = adv.confidence || 0; const mark = ab === want ? "✓" : ab === "NEUTRAL" ? "·" : "⚠"; agentLine = `${mark} KI-Copilot: ${ab} ${ac}%`; // Agent ist nur Kontext — die Warnung (danger) bestimmt die validierte Welle. } return { line: waveLine + "\n" + agentLine, danger }; } // Order-Buttons (LONG/SHORT/CLOSE). // Eröffnen wird NIE blockiert/abgefragt — bei WARTEN/Gegen-Signal nur ein Hinweis // (Toast), der Trade läuft trotzdem. Schließen direkt — außer im Minus: // Sicherheitsabfrage (kein versehentlicher Verlust-Close). document.querySelectorAll(".btn[data-side]").forEach(btn => { btn.onclick = async () => { if (btn.disabled) return; const side = btn.dataset.side; // ⚠⚠ ENTRY-DIALOG ENTFERNT (User-Vorgabe 2026-08-11: „entferne auch die // Warnung beim Eröffnen von Trades"). LONG/SHORT feuern jetzt sofort. // ⚠ Was damit wegfällt: die 7-Punkte-Checkliste (Signal-Deckung · HTF-Trend · // Entry-Raum · News · Kosten · Verlust-Serie · Nacht) UND der // Ausrichtungs-Split im Moment der Entscheidung. Letzterer zeigte die // einzige beidhälftig robuste Zahl des Projekts: gegen das Signal // −6,60 € je Lot über 1.216 Trades. Genau diese diskretionären // Abweichungen sind die gemessene Kern-Leckage. // ⚠ BEWUSSTE ENTSCHEIDUNG DES USERS, nicht ein Messergebnis. Sie deckt sich // aber mit der Projekt-Linie „sichtbar machen, nicht bevormunden": // Übersteuerungen liefen gemessen 68 % Trefferquote, und hartes Blockieren // war ohnehin nie eingebaut. // ✅ Das Backend ist UNBERÜHRT: `engine._entry_checklist` rechnet weiter und // steht als `checklist` im Snapshot — nur der Dialog ist weg. Die // Zahlen bleiben also auswertbar, und Zurückholen ist ein Einzeiler // (`const ENTRY_DIALOG = true`). const ENTRY_DIALOG = false; if (ENTRY_DIALOG && (side === "long" || side === "short")) { const cl = (lastSnap.checklist || {})[side]; const info = recInfo(side); if (cl && (cl.fails > 0 || cl.warns > 0)) { const ICON = { fail: "❌", warn: "⚠", ok: "✅" }; const bad = cl.items.filter(x => x.state !== "ok"); const good = cl.items.filter(x => x.state === "ok"); let body = bad.map(x => `${ICON[x.state]} ${x.label}` + (x.detail ? `\n ${x.detail}` : "")).join("\n"); if (good.length) body += `\n\n${good.map(x => `✅ ${x.label}`).join("\n")}`; // Eigene Zahlen zur Ausrichtung ergänzen (gemessene Kern-Leckage) const al = lastSnap.alignment || null; const sigNow = (lastSnap.wave_signal || {}).signal || "WARTEN"; const isWait0 = (sigNow === "WARTEN" || sigNow === "—"); if (al && cl.items.some(x => x.key === "signal" && x.state === "fail")) { const grp = isWait0 ? al.none : al.against, ref = al.with; // ⚠ JE LOT ist die belastbare Zahl: absolute Euro-Vergleiche sind bei // wechselnder Positionsgroesse ungueltig (Lehre 04.08. — ein reiner // Groessenzuwachs sah wie ein Strategie-Effekt aus). Seit 06.08. laeuft // der Split ueber die VOLLE Historie (1.216 Trades) statt ueber 40. const f = g => { if (!g || g.n < 5) return null; const lot = (g.pnl_lot === null || g.pnl_lot === undefined) ? "" : ` · ${g.pnl_lot >= 0 ? "+" : ""}${fmt(g.pnl_lot, 2)} €/Lot`; return `${g.wr}% Treffer${lot} · ${g.pnl >= 0 ? "+" : ""}${fmt(g.pnl, 0)} € (n${g.n})`; }; const own = f(grp), rf = f(ref); if (own) body += `\n\n📊 Deine Trades ${isWait0 ? "OHNE" : "GEGEN"} Signal: ${own}`; if (rf) body += `\n📊 Mit Signal: ${rf}`; } const head = cl.verdict === "stop" ? `🛑 Checkliste sagt NEIN (${cl.fails} Punkte dagegen)` : `⚠ Checkliste: ${cl.fails} dagegen, ${cl.warns} Vorsicht`; const ok = await modal(head, body + `\n\n${side.toUpperCase()} trotzdem eröffnen?`, "confirm", true); if (!ok) return; } else if (info.danger) { // Fallback: alter Hinweis (kein Block) const sig = (lastSnap.wave_signal || {}).signal || "WARTEN"; const isWait = (sig === "WARTEN" || sig === "—"); const head = isWait ? "ℹ Keine Empfehlung (WARTEN)" : `ℹ Empfehlung steht GEGEN dich (${sig})`; // Ehrliche EIGENE Zahlen (letzte 40 Trades, Ausrichtungs-Split aus der DB): // die gemessene Kern-Leckage sind genau diese diskretionären Abweichungen. let statLine = ""; const al = lastSnap.alignment || null; if (al) { const grp = isWait ? al.none : al.against; const ref = al.with; const f = g => (g && g.n >= 5) ? `${g.wr}% Trefferquote · ${g.pnl >= 0 ? "+" : ""}${fmt(g.pnl, 0)} € (n${g.n})` : null; const own = f(grp), rf = f(ref); if (own) statLine += `\n\n📊 Deine Trades ${isWait ? "OHNE Signal" : "GEGEN das Signal"}: ${own}`; if (rf) statLine += `\n📊 Mit Signal: ${rf}`; } const ok = await modal(head, info.line + statLine + `\n\n${side.toUpperCase()} trotzdem eröffnen?`, "confirm", true); if (!ok) return; // Abbrechen = doch nicht; sonst läuft er } } if (side === "close") { // Sicherheitsabfrage bei Verlust const p = lastSnap.position || {}; const pnl = p.pnl || 0; if (p.ticket && pnl < 0) { const cs = curSym(lastSnap.market || {}); const ok = await modal( "⚠ Position im Minus schließen?", `Aktueller Verlust: ${cs}${fmt(pnl, 2)}\n\nWirklich jetzt mit Verlust schließen?`, "confirm", true); if (!ok) return; } } const tok = await ensureToken(); if (tok === null) return; // null = Token abgebrochen ("" = kein Token nötig) try { const res = await sendOrder(side, tok); res.ok ? toast(`${side.toUpperCase()} ausgeführt`, true) : toast(`Fehler: ${res.error || "unbekannt"}`, false); } catch (e) { toast(e.message === "401" ? "Token abgelaufen — neu eingeben" : "Netzwerkfehler", false); } }; }); // (Notfall-Stop-UI entfernt 2026-07-17 — POST /api/emergency existiert weiter, ohne UI.) // („Gewinn mitnehmen"-UI entfernt — POST /api/takeprofit existiert weiter, ohne UI.) // Manuelles SL/TP (Trade-Bereich) — sendet beide Felder; deaktiviert das Trailing. async function sendSltp() { // ⚠ STRENG (s. `$streng` oben): hier ist „Feld fehlt" NICHT „Feld ist leer". // Leer heisst ausdruecklich „Stop entfernen" (`sl: null`) — mit dem // Auffang-Element waere `.value` `undefined`, `parseFloat` liefert NaN, und // `JSON.stringify` macht daraus wieder `null`. Eine fehlende Eingabemaske // wuerde so den Broker-SL einer laufenden Position stillschweigend loeschen. const elSl = $streng("tb-sl"), elTp = $streng("tb-tp"); if (!elSl || !elTp) { toast("SL/TP-Felder nicht geladen — Seite neu laden (Strg+F5)", false); return; } const slv = elSl.value, tpv = elTp.value; const tok = await ensureToken(); if (tok === null) return; elSl.blur(); elTp.blur(); try { const r = await fetch("api/sltp", { method: "POST", headers: { "X-Auth-Token": tok, "Content-Type": "application/json" }, body: JSON.stringify({ sl: slv === "" ? null : parseFloat(slv), tp: tpv === "" ? null : parseFloat(tpv) }) }); if (r.status === 401) { clearToken(); throw new Error("401"); } const j = await r.json(); if (j.ok) toast(`SL/TP gesetzt${j.trail_was_on ? " · Trailing AUS" : ""}`, true); else toast(j.error || "Broker lehnte ab", false); } catch (e) { toast(e.message === "401" ? "Token abgelaufen — neu eingeben" : "Netzwerkfehler", false); } } ["tb-sl", "tb-tp"].forEach(id => { $(id).addEventListener("change", sendSltp); $(id).addEventListener("keydown", e => { if (e.key === "Enter") { e.preventDefault(); sendSltp(); } }); }); // Mute-Button (oben rechts): alle akustischen Signale an/aus function _updateMuteBtn() { const b = $("mute-btn"); if (b) { b.textContent = _muted ? "🔇" : "🔊"; b.classList.toggle("muted", _muted); } } $("mute-btn").onclick = () => { _muted = !_muted; localStorage.setItem("oil_muted", _muted ? "1" : "0"); _updateMuteBtn(); toast(_muted ? "🔇 Alle Töne aus" : "🔊 Töne an", true); }; _updateMuteBtn(); // Toggle-Buttons (AUTO/TRAIL/S-R-Close) — ohne Bestätigung $("btn-cb").onclick = () => toggle("api/circuit", "Circuit Breaker"); $("btn-sr").onclick = () => toggle("api/srclose", "S/R-Auto-Close"); $("btn-m15").onclick = () => toggle("api/autom15", "M15-Auto-Entry (gemessen negativ!)"); // (🎯 SIG entfernt 2026-08-12 — s. renderOrderButtons oben) $("btn-trail").onclick = () => toggle("api/trail", "Trailing"); $("srclose-btn").onclick = () => toggle("api/srclose", "Auto-S/R-Close"); // Mindestgewinn für den S/R-Auto-Close (0/leer = aus) async function setSrMin() { const raw = $("srmin-input").value; const v = raw === "" ? 0 : parseFloat(raw); if (isNaN(v) || v < 0) { toast("Bitte Betrag ≥ 0 eingeben (0 = aus)", false); return; } const tok = await ensureToken(); if (tok === null) return; $("srmin-input").blur(); try { const r = await fetch("api/srclose_min", { method: "POST", headers: { "X-Auth-Token": tok, "Content-Type": "application/json" }, body: JSON.stringify({ value: v }) }); if (r.status === 401) { clearToken(); throw new Error("401"); } toast(v > 0 ? `Mindestgewinn +${v} gesetzt` : "Mindestgewinn aus", true); } catch (e) { toast(e.message === "401" ? "Token abgelaufen — neu eingeben" : "Netzwerkfehler", false); } } // Feld sitzt in der Button-Zeile: setzt beim Verlassen/Enter automatisch $("srmin-input").addEventListener("change", setSrMin); // Enter NUR blurren → das change-Event feuert setSrMin genau EINMAL (und nur, wenn // der Wert wirklich geändert wurde). Vorher: keydown + change = doppelter Send, // und Enter auf leerem Feld schickte ungewollt 0 (Fix 2026-07-22). $("srmin-input").addEventListener("keydown", e => { if (e.key === "Enter") { e.preventDefault(); e.target.blur(); } }); // Einsatz-Felder ENTFERNT (User 2026-08-19). Das BACKEND bleibt vollstaendig: // beide Endpunkte sind weiter erreichbar, die Werte leben in // `runtime_state.json` UND der ini (aktuell 40 %). Ohne UI aendert man den // Prozentsatz also dort + Neustart -- oder per POST. // Notfall-Stop: manueller Verlust-Wert, bei dem der Server sofort schließt. // Wieder eingeblendet 2026-07-22 (Backend/Persistenz waren die ganze Zeit aktiv). async function setEmg() { const raw = $("emg-input").value; const v = raw === "" ? 0 : parseFloat(raw); if (isNaN(v) || v < 0) { toast("Bitte Betrag ≥ 0 eingeben (0 = aus)", false); return; } const tok = await ensureToken(); if (tok === null) return; $("emg-input").blur(); try { const r = await fetch("api/emergency", { method: "POST", headers: { "X-Auth-Token": tok, "Content-Type": "application/json" }, body: JSON.stringify({ value: v > 0 ? v : null }) }); if (r.status === 401) { clearToken(); throw new Error("401"); } toast(v > 0 ? `Notfall-Stop bei −${v} gesetzt` : "Notfall-Stop aus", true); } catch (e) { toast(e.message === "401" ? "Token abgelaufen — neu eingeben" : "Netzwerkfehler", false); } } $("emg-input").addEventListener("change", setEmg); // Enter blurrt nur (kein doppeltes Senden, kein ungewolltes 0 auf leerem Feld — // derselbe Fix wie beim Mindestgewinn-Feld, 2026-07-22). $("emg-input").addEventListener("keydown", e => { if (e.key === "Enter") { e.preventDefault(); e.target.blur(); } }); async function toggle(url, label) { const tok = await ensureToken(); if (tok === null) return; // null = abgebrochen ("" = kein Token nötig) try { const res = await postAuth(url, tok); toast(`${label} ${res.enabled ? "AN" : "AUS"}`, true); } catch (e) { toast(e.message === "401" ? "Token abgelaufen — neu eingeben" : "Netzwerkfehler", false); } } // ── Modul-Menü / Views ───────────────────────────────────────────────────── function showView(name) { document.querySelectorAll(".view").forEach(v => v.classList.add("hidden")); const el = document.getElementById("view-" + name); if (el) el.classList.remove("hidden"); document.querySelectorAll(".tab").forEach(t => t.classList.toggle("active", t.dataset.view === name)); if (name === "logs") loadLogs(); if (name === "stats") loadStats(); if (name === "news") loadNews(); } // Nur echte View-Tabs registrieren — NICHT den Mute-Button (der trägt .tab nur // fürs Styling; ohne [data-view]-Filter überschrieb das hier seinen onclick und // showView(undefined) blendete alle Views aus). document.querySelectorAll(".tab[data-view]").forEach(t => t.onclick = () => showView(t.dataset.view)); async function loadLogs() { const el = $("logs-body"); el.textContent = "lade …"; try { const d = await (await fetch("api/logs?lines=300")).json(); if (d.error) { el.textContent = "Fehler: " + d.error; return; } el.textContent = (d.lines || []).join("\n"); el.scrollTop = el.scrollHeight; } catch (e) { el.textContent = "Netzwerkfehler"; } } function statBlock(title, s) { const cs = curSym(lastSnap.market || {}); if (!s || s.error) return `

    ${title}

    ${(s && s.error) || "—"}
    `; const money = (v, sign) => `${sign && v >= 0 ? '+' : ''}${cs}${(v || 0).toFixed(2)}`; const pf = s.profit_factor != null ? s.profit_factor.toFixed(2) : "—"; const hasWht = (s.wht_pct || 0) > 0; const npf = s.net_profit_factor != null ? s.net_profit_factor.toFixed(2) : "—"; // Brutto-Block let rows = `
    Trades${s.n_trades}
    Winrate${(s.winrate || 0).toFixed(0)}%
    P&L brutto${money(s.total_pnl, true)}
    Profit-Faktor${pf}
    `; // Netto-Block (nur wenn WHT konfiguriert) if (hasWht) { rows += `
    WHT ${s.wht_pct}%−${cs}${(s.wht || 0).toFixed(2)}
    P&L netto${money(s.net_pnl, true)}
    Profit-Faktor netto${npf}
    `; } rows += `
    Ø Gewinn${hasWht ? ' netto' : ''}+${cs}${((hasWht ? s.net_avg_win : s.avg_win) || 0).toFixed(2)}
    Ø Verlust${cs}${(s.avg_loss || 0).toFixed(2)}
    `; return `

    ${title}

    ${rows}
    `; } function setupRow(s) { const cs = curSym(lastSnap.market || {}); const hasWht = (lastSnap._wht || 0) > 0 || s.net_pnl != null; const pnl = hasWht ? (s.net_pnl ?? s.total_pnl) : s.total_pnl; const pf = hasWht ? s.net_profit_factor : s.profit_factor; const cls = pnl >= 0 ? "up" : "down"; return `
    ${(s.setup || "—").replace(/
    ${s.n}× ${(s.winrate || 0).toFixed(0)}%
    ${pnl >= 0 ? "+" : ""}${cs}${(pnl || 0).toFixed(2)}
    PF ${pf != null ? pf.toFixed(2) : "—"}
    `; } // Auto-Squeeze-Monitor (B4): Entry-Edge (Mechanik) + Live-Trades vs. Erwartung. const _SQM_VERDICT = { ontrack: ["✓ trägt live", "up"], weak: ["○ dünn/schwach", "dim"], drift: ["⚠ driftet — B5 prüfen", "down"], watch: ["… zu wenig Daten", "dim"], }; function renderSqueezeMonitor(d) { const box = $("sqm-box"); if (!d || d.error) { box.innerHTML = '
    Monitor n/v
    '; return; } const m = d.mech || {}, lv = d.live || {}, ex = d.expect || {}; const [vtxt, vcls] = _SQM_VERDICT[d.verdict] || ["—", "dim"]; const row = (k, v, cls) => `
    ${k}${v}
    `; const sign = (x, dg = 2) => (x == null ? "—" : (x >= 0 ? "+" : "") + Number(x).toFixed(dg)); // Mechanik-Block (Entry-Edge isoliert, apples-to-apples zum Backtest) let mechHtml; if (m.error) mechHtml = `
    ${m.error}
    `; else if (!m.n) mechHtml = '
    keine Ausbrüche im Fenster
    '; else { const orCls = m.oR >= (ex.or_lo || 0.14) ? "up" : m.oR > 0 ? "" : "down"; mechHtml = row("Ausbrüche (30 T)", m.n) + row("Treffer", m.wr + " %") + row("ØR", sign(m.oR, 3), orCls) + row("PF", (m.pf != null ? m.pf.toFixed(2) : "—"), m.pf > 1 ? "up" : "down"); } // Live-Block (echt, Gesamtsystem inkl. reale Exits) let liveHtml; if (!lv.n) liveHtml = '
    noch keine Auto-Squeeze-Trades
    '; else { const dn = lv.night || {}, dy = lv.day || {}; liveHtml = row("Trades", lv.n) + row("Treffer", lv.wr + " %") + row("Ø-Gewinn / -Verlust", `${sign(lv.avg_win)} / ${sign(lv.avg_loss)} €`) + row("Verhältnis", lv.ratio != null ? lv.ratio.toFixed(2) : "—", lv.ratio >= 1 ? "up" : "down") + row("Σ real", sign(lv.sum_eur) + " €", lv.sum_eur >= 0 ? "up" : "down") + row("Tag / Nacht", `${sign(dy.sum)} / ${sign(dn.sum)} € (${dy.n || 0}/${dn.n || 0})`); } box.innerHTML = `

    Auto-Squeeze-Monitor (B4)

    ` + `${vtxt}
    ` + `
    Entry-Edge · Mechanik (~30 T M5, isoliert)
    ` + `
    ${mechHtml}
    Erwartung ØR +0,14…+0,23 · PF>1
    ` + `
    Live-Trades · echt (Gesamtsystem)
    ` + `
    ${liveHtml}
    ` + `
    Mechanik = nur der Squeeze-Einstieg mit Backtest-Exit ` + `(zeigt, ob der Edge lebt). Live = die echten Trades inkl. realer Exits/Eingriffe. ` + `Klaffen sie auseinander, liegt es am Exit/Verhalten, nicht am Signal.
    `; } async function loadSqueezeMonitor() { try { const d = await (await fetch("api/squeeze_monitor")).json(); renderSqueezeMonitor(d); } catch (e) { $("sqm-box").innerHTML = '
    Monitor-Fehler
    '; } } async function loadStats() { loadSqueezeMonitor(); const el = $("stats-body"), setEl = $("stats-setups"); el.innerHTML = '
    lade …
    '; try { const d = await (await fetch("api/stats")).json(); lastSnap._wht = d.wht_pct || 0; el.innerHTML = statBlock("Heute", d.today) + statBlock("Woche", d.week) + statBlock("Gesamt", d.all) + `
    Trades in DB gesamt: ${d.n_total ?? "—"}
    `; const ss = (d.setups || []).filter(x => !x.error); setEl.innerHTML = ss.length ? `
    ${d.wht_pct > 0 ? "P&L/PF = netto nach " + d.wht_pct + "% WHT" : "brutto"}
    ` + ss.map(setupRow).join("") : '
    keine Setup-Daten
    '; } catch (e) { el.innerHTML = '
    Netzwerkfehler
    '; } } async function loadNews() { const sEl = $("news-sentiment"), bEl = $("news-body"); bEl.innerHTML = '
    lade …
    '; sEl.textContent = ""; try { const d = await (await fetch("api/news")).json(); const s = d.sentiment || {}; if (s.score != null) { const sc = s.score > 0.05 ? "up" : s.score < -0.05 ? "down" : "dim"; sEl.innerHTML = `Sentiment ${s.score >= 0 ? '+' : ''}${(s.score || 0).toFixed(2)}` + ` · ▲${s.n_bull || 0} ▼${s.n_bear || 0}`; } const hs = d.headlines || []; bEl.innerHTML = hs.length ? hs.map(h => `` + `
    ${(h.title || '').replace(/` + `
    ${(h.source || '').replace(/`).join("") : `
    ${d.error || "noch keine News geladen"}
    `; } catch (e) { bEl.innerHTML = '
    Netzwerkfehler
    '; } } $("logs-reload").onclick = loadLogs; $("stats-reload").onclick = loadStats; $("news-reload").onclick = loadNews; // Auto-Refresh nur solange der Charts-Tab sichtbar ist (schont den mt5_lock) // Sofort einmal per REST laden, dann auf den Live-Stream umschalten fetch("api/snapshot").then(r => r.json()).then(_applySnapshot).catch(() => {}); connect(); // ── Wake-up nach Sperrbildschirm/Hintergrund (Fix 2026-07-22: „G/V lag") ────── // Mobile Browser pausieren WS+Timer beim Sperren/Backgrounding — die Seite zeigt // bis zu einem Reconnect den STALEN Stand. Beim Zurückkommen (Tab/App wieder // sichtbar) sofort per REST einen frischen Snapshot holen UND die WS-Verbindung // hart neu aufbauen (statt auf den evtl. hängenden alten Socket zu warten). function _wakeRefresh() { fetch("api/snapshot").then(r => r.json()).then(_applySnapshot).catch(() => {}); if (!sock || sock.readyState !== WebSocket.OPEN) { clearTimeout(retry); try { sock && sock.close(); } catch (e) {} connect(); } } document.addEventListener("visibilitychange", () => { if (!document.hidden) _wakeRefresh(); }); window.addEventListener("pageshow", _wakeRefresh); window.addEventListener("focus", _wakeRefresh); // --- Einsatz je autonomem Pfad (User-Wunsch 2026-08-19) -------------------- // ⚠ LEER heisst hier ausdruecklich 0 = "globaler Wert", NICHT "unveraendert". // Deshalb `$streng` und eine explizite NaN-Behandlung: mit dem Auffang-`$` // waere `.value` undefined -> NaN -> JSON.stringify macht daraus null, und // der Server saehe einen anderen Wert als der Nutzer eingegeben hat. async function setAutoMargin() { const eb = $streng("am-brk"), em = $streng("am-m15"); if (!eb || !em) { toast("Eingabefelder fehlen — Seite neu laden", false); return; } const brk = eb.value.trim() === "" ? 0 : parseFloat(eb.value); const m15 = em.value.trim() === "" ? 0 : parseFloat(em.value); if (isNaN(brk) || isNaN(m15) || brk < 0 || m15 < 0 || brk > 99 || m15 > 99) { toast("Erlaubt sind 0 (= global) bis 99", false); return; } const tok = await ensureToken(); if (tok === null) return; eb.blur(); em.blur(); try { const r = await fetch("api/automargin", { method: "POST", headers: { "X-Auth-Token": tok, "Content-Type": "application/json" }, body: JSON.stringify({ brk: brk, m15: m15 }) }); if (r.status === 401) { clearToken(); throw new Error("401"); } const j = await r.json(); if (!j.ok) { toast(j.error || "abgelehnt", false); return; } toast(`Einsatz: BRK ${brk || "global"} · M15 ${m15 || "global"}`, true); } catch (e) { toast(e.message === "401" ? "Token abgelaufen — neu eingeben" : "Netzwerkfehler", false); } } // --- Globaler Einsatz: steuert MANUELLE Trades ---------------------------- // ⚠ Das Feld war am 19.08. auf Wunsch entfernt worden; danach kamen die // Felder je Pfad dazu. Damit hatte ausgerechnet die Schraube, die die // EIGENEN Trades steuert, keine UI mehr - der Wert war nur noch ueber die // ini plus Neustart erreichbar. Genau das fiel als "der Einsatz wird nicht // uebernommen" auf (die Pfad-Felder greifen nur bei BRK/M15). async function setMgGlobal() { const el = $streng("mg-global"); if (!el) { toast("Eingabefeld fehlt — Seite neu laden", false); return; } const v = parseFloat(el.value); // ⚠ Kein "leer = aus": ein globaler Einsatz von 0 waere sinnlos, es MUSS // einer gelten. Deshalb hier eine echte Untergrenze statt der 0-Semantik // der Pfad-Felder. if (isNaN(v) || v < 1 || v > 99) { toast("Erlaubt sind 1 bis 99 %", false); return; } const tok = await ensureToken(); if (tok === null) return; el.blur(); try { const r = await fetch("api/marginpct", { method: "POST", headers: { "X-Auth-Token": tok, "Content-Type": "application/json" }, body: JSON.stringify({ value: v }) }); if (r.status === 401) { clearToken(); throw new Error("401"); } toast(`Einsatz global ${v} %`, true); } catch (e) { toast(e.message === "401" ? "Token abgelaufen — neu eingeben" : "Netzwerkfehler", false); } } { const el = $streng("mg-global"); if (el) { el.addEventListener("change", setMgGlobal); el.addEventListener("keydown", e => { if (e.key === "Enter") { e.preventDefault(); e.target.blur(); } }); } } ["am-brk", "am-m15"].forEach(id => { const el = $streng(id); if (!el) return; el.addEventListener("change", setAutoMargin); // Enter NUR blurren → `change` feuert dann genau EINMAL (Fix-Muster 22.07.). el.addEventListener("keydown", e => { if (e.key === "Enter") { e.preventDefault(); e.target.blur(); } }); });