"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;
  _tone(880, 0, 0.18);            // Zwei-Ton-Alarm (hoch→tief)
  _tone(620, 0.22, 0.20);
}
// 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.
// ══════════════════════════════════════════════════════════════════════════
function renderM15(s) {
  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 || {};
  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 `<div class="stx-grp"><div class="stx-lbl">${label}</div>`
                          + `<div class="dim">noch keine Trades</div></div>`;
    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 `<div class="stx-grp"><div class="stx-lbl">${label} · ${s.n} Trades</div>`
      + `<div class="stx-cells">`
      + `<span class="stx-c">WR <b>${s.wr}%</b></span>`
      + `<span class="stx-c">PF <b class="${pfCls}">${f2(s.pf)}</b></span>`
      + `<span class="stx-c">Verh <b class="${ratioCls}">${f2(s.ratio)}</b></span>`
      + `<span class="stx-c">Øgew <b class="up">+${cs}${f2(s.avg_win)}</b></span>`
      + `<span class="stx-c">Øverl <b class="down">${cs}${f2(s.avg_loss)}</b></span>`
      + `<span class="stx-c">PnL <b class="${pnlCls}">${s.pnl >= 0 ? "+" : ""}${cs}${f2(s.pnl)}</b></span>`
      + `</div></div>`;
  };
  $("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 = '<span class="dim">keine offenen Gaps</span>';
    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
      ? `<span class="dim" title="Schluss→Eröffnung — reine Anzeige, fließt NICHT in die Konfidenz">Eröffnung</span>`
      : `<span class="dim" title="Vakuum zwischen den Tagesspannen — speist die S/R-Magnete">Vakuum</span>`;
    return `<div class="gap-row"><span class="${up ? "up" : "down"}">${arr} ${x.dir.toUpperCase()}</span>`
         + `<span class="gap-zone">${fmt(x.lo)}–${fmt(x.hi)}</span>`
         + `<span class="gap-fill">schließt bei <b>${fmt(x.fill)}</b>${ds}</span>`
         + tag
         + `<span class="dim gap-date">${x.date}</span></div>`;
  }).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 = '<span class="dim">—</span>'; return; }
  const cls = v => v > 0 ? "up" : v < 0 ? "down" : "";
  const box = (lbl, b) => !b || !b.n ? "" :
    `<div class="mt-box"><div class="mt-lbl">${lbl}</div>`
    + `<div class="mt-val ${cls(b.sum)}">${b.sum >= 0 ? "+" : ""}${fmt(b.sum, 2)}</div>`
    + `<div class="mt-sub2">${b.n} Trades · ${fmt(b.wr, 1)} % · Ø ${b.avg >= 0 ? "+" : ""}${fmt(b.avg, 2)}</div></div>`;
  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 ? "" :
      `<span class="mt-chip"><b>${lbl}</b> ${b.n}× · ${fmt(b.wr, 1)} % · `
      + `<span class="${cls(b.sum)}">${b.sum >= 0 ? "+" : ""}${fmt(b.sum, 2)}</span></span>`;
    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 `<span class="ms-chip ${c}" title="${fmt(sw.price)}">${sw.type}</span>`;
  }).join('<span class="ms-arrow">›</span>');
  $("ms-swings").innerHTML = chips || '<span class="dim">—</span>';

  // BOS
  const b = s.bos;
  const bEl = $("ms-bos");
  if (b) {
    const up = b.dir === "up";
    bEl.innerHTML = `<span class="ms-bos-lbl">BOS</span>`
      + `<span class="${up ? "up" : "down"}">${up ? "▲ über" : "▼ unter"} ${fmt(b.level)}</span>`
      + `<span class="dim">vor ${b.bars_ago} Bars</span>`;
    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 `<svg class="pt-svg" viewBox="0 0 56 32" width="56" height="32" aria-hidden="true">
    <path d="${d}" fill="none" stroke="${col}" stroke-width="2"
      stroke-linejoin="round" stroke-linecap="round"/></svg>`;
}

// 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 = `<span class="dim">Keine markante Kerzenform in den letzten Bars.</span>`;
    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
      ? `<span class="ca-vol${m.hot ? " hot" : ""}">Vol ${fmt(m.vol_x, 1)}×</span>` : "";
    return `<div class="ca-item${m.hot ? " hot" : ""}">
      <div class="ca-head"><span class="ca-name">${arr} ${m.label}</span>
        ${vol}<span class="ca-ago">${ago}</span></div>
      <div class="ca-bars">Körper ${m.body_pct}% · Docht oben ${m.wick_up_pct}%
        · unten ${m.wick_lo_pct}%</div>
      <div class="ca-note">${m.reading || ""}</div>
    </div>`;
  }).join("") +
    `<div class="pt-disclaimer">🕯 Reine Anzeige — KEIN Signal. Gemessen
     (<code>backtest_candles.py</code>, 80k Bars, 2 Halbjahre): die gängige Deutung ist
     <b>invertiert</b> — 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
     (<code>backtest_candle_fade.py</code>).</div>`;
}

// 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))
    ? `<span class="kx-cone-cov">ACI ×${fmt(sk.reduce((a, b) => a + b, 0) / sk.length, 2)}</span>`
    : "";
  el.innerHTML = `<span class="kx-cone-lbl">📐 erwartete Spanne</span>`
    + b80.map(x => `<span class="kx-cone-item"><i>${x.minutes}′</i>`
        + `${fmt(x.lo, 2)}–${fmt(x.hi, 2)}</span>`).join("")
    + `<span class="kx-cone-cov">trifft ${cov}</span>` + 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 = `<span class="dim">Aktuell kein klares Muster erkannt.</span>`;
    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"
      ? `<span class="pt-badge conf">bestätigt</span>`
      : `<span class="pt-badge form">bildet sich</span>`;
    const tgt = m.target != null
      ? `Ziel <b>${fmt(m.target, 2)}</b>` : `Ausbruch offen`;
    const q = Math.round((m.quality || 0) * 100);
    return `<div class="pt-item ${dirCls}">
      <div class="pt-head">${_patternSvg(m.type, col)}
        <span class="pt-name">${arr} ${m.name}</span>${st}
        <span class="pt-q">Güte ${q}%</span></div>
      <div class="pt-det">Trigger <b>${fmt(m.trigger, 2)}</b> · ${tgt}
        · Höhe ${m.height_atr}×ATR · vor ${m.bars_ago} Bars</div>
      <div class="pt-note">${m.note || ""}</div>
    </div>`;
  }).join("") +
    `<div class="pt-disclaimer">📊 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.</div>`;
}

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);
  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");
      if (document.activeElement !== slEl) slEl.value = p.sl ? fmt(p.sl, 2) : "";
      if (document.activeElement !== tpEl) tpEl.value = p.tp ? fmt(p.tp, 2) : "";
    } 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 ? `<span class="tf-turn">⟳${arr}</span>` : arr;
    const ago = (t.bars_ago != null && t.fresh) ? `<span class="tf-ago">${t.bars_ago}</span>` : "";
    return `<span class="tf-chip ${cls}${fresh}"><b>${tf}</b> ${turn}${ago}</span>`;
  }).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 => `<li>${r}</li>`).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;
  $("btn-squeeze").classList.toggle("on", !!d.auto_squeeze);
  // ⚠⚠ 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) : "";
  }
  // Feste Einsatz-Margin (0/leer = automatisch). Nicht überschreiben, während
  // getippt wird — sonst löscht der 1-s-Render die Eingabe unter den Fingern weg.
  const mgi = $("margin-input");
  if (mgi && document.activeElement !== mgi) {
    const mm = d.manual_margin || 0;
    mgi.value = mm > 0 ? String(mm) : "";
    mgi.classList.toggle("on", mm > 0);
  }
  // Einsatz in Prozent der freien Margin. Ausgegraut, sobald links ein fester
  // Betrag steht — der hat Vorrang, und ohne den Hinweis wäre unklar, was gilt.
  const mpi = $("marginpct-input");
  if (mpi && document.activeElement !== mpi) {
    const mp = d.margin_pct || 0;
    mpi.value = mp > 0 ? String(mp) : "";
    const fest = (d.manual_margin || 0) > 0;
    mpi.classList.toggle("on", mp > 0 && !fest);
    const wrap = $("marginpct-wrap");
    if (wrap) {
      wrap.classList.toggle("muted", fest);
      wrap.title = fest
        ? `Ohne Wirkung: links steht ein fester Einsatz (${fmt(d.manual_margin, 2)} €). `
          + `Der Prozentsatz (${mp} %) bleibt nur die Obergrenze.`
        : "Einsatz als Prozent der freien Margin (1–99). "
          + "Greift, solange links kein fester Betrag steht.";
    }
  }
  // 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 `<div class="trade"><div class="p ${cls}">${arr}${pnl >= 0 ? "+" : ""}${fmt(pnl, 2)}</div>` +
           `<div class="i">${hm} ${cb}</div></div>`;
  }).join("") || '<div class="dim">keine</div>';
}

// ── 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-squeeze").onclick = () => toggle("api/autosqueeze", "Auto-Squeeze (autonom!)");
// (🎯 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(); }
});

// Feste Einsatz-Margin (User-Wunsch 2026-08-04). Leer/0 = automatisch.
// ⚠ Wirkt auf ALLE neuen Positionen, auch die autonomen — deshalb sagt der Toast
// ausdrücklich, was gilt, statt nur „gespeichert".
async function setManualMargin() {
  const el = $("margin-input"); if (!el) return;
  const raw = el.value;
  const v = raw === "" ? 0 : parseFloat(raw);
  if (isNaN(v) || v < 0) { toast("Bitte Betrag ≥ 0 eingeben (0 = automatisch)", false); return; }
  const tok = await ensureToken();
  if (tok === null) return;
  el.blur();
  try {
    const r = await fetch("api/manualmargin", { 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"); }
    const j = await r.json();
    toast(v > 0 ? `Einsatz fest: ${fmt(j.value ?? v, 2)} € je Position`
                : "Einsatz wieder automatisch (95 % der freien Margin)", true);
  } catch (e) {
    toast(e.message === "401" ? "Token abgelaufen — neu eingeben" : "Netzwerkfehler", false);
  }
}
(() => {
  const el = $("margin-input");
  if (!el) return;                                   // null-sicher (Cache-Regel)
  el.addEventListener("change", setManualMargin);
  el.addEventListener("keydown", e => {
    if (e.key === "Enter") { e.preventDefault(); e.target.blur(); }
  });
})();

// Einsatz in PROZENT der freien Margin (User-Wunsch 2026-08-05) — das Gegenstück
// zum festen Betrag oben. Steuert `margin_buffer_pct`, das bisher nur in der ini
// stand. ⚠ Ein gesetzter fester Betrag hat Vorrang; darauf weist der Toast hin.
async function setMarginPct() {
  const el = $("marginpct-input"); if (!el) return;
  const v = parseFloat(el.value);
  if (isNaN(v) || v < 1 || v > 99) {
    toast("Bitte 1–99 % eingeben (100 % ließe keinen Puffer für Spread/Swap)", 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"); }
    const j = await r.json();
    if (j.ok === false) { toast(j.error || "abgelehnt", false); return; }
    const fest = parseFloat(($("margin-input") || {}).value || "0") > 0;
    toast(`Einsatz: ${fmt(j.value ?? v, 0)} % der freien Margin`
          + (fest ? " — greift erst, wenn der feste Betrag links leer ist" : ""), true);
  } catch (e) {
    toast(e.message === "401" ? "Token abgelaufen — neu eingeben" : "Netzwerkfehler", false);
  }
}
(() => {
  const el = $("marginpct-input");
  if (!el) return;                                   // null-sicher (Cache-Regel)
  el.addEventListener("change", setMarginPct);
  el.addEventListener("keydown", e => {
    if (e.key === "Enter") { e.preventDefault(); e.target.blur(); }
  });
})();

// 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 `<div class="stat-card"><h3>${title}</h3><div class="dim">${(s && s.error) || "—"}</div></div>`;
  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 = `
    <div class="kv"><span>Trades</span><b>${s.n_trades}</b></div>
    <div class="kv"><span>Winrate</span><b>${(s.winrate || 0).toFixed(0)}%</b></div>
    <div class="kv"><span>P&amp;L brutto</span><b class="${s.total_pnl >= 0 ? 'up' : 'down'}">${money(s.total_pnl, true)}</b></div>
    <div class="kv"><span>Profit-Faktor</span><b>${pf}</b></div>`;

  // Netto-Block (nur wenn WHT konfiguriert)
  if (hasWht) {
    rows += `
    <div class="kv net"><span>WHT ${s.wht_pct}%</span><b class="down">−${cs}${(s.wht || 0).toFixed(2)}</b></div>
    <div class="kv net"><span>P&amp;L netto</span><b class="${s.net_pnl >= 0 ? 'up' : 'down'}">${money(s.net_pnl, true)}</b></div>
    <div class="kv net"><span>Profit-Faktor netto</span><b>${npf}</b></div>`;
  }
  rows += `
    <div class="kv"><span>Ø Gewinn${hasWht ? ' netto' : ''}</span><b class="up">+${cs}${((hasWht ? s.net_avg_win : s.avg_win) || 0).toFixed(2)}</b></div>
    <div class="kv"><span>Ø Verlust</span><b class="down">${cs}${(s.avg_loss || 0).toFixed(2)}</b></div>`;

  return `<div class="stat-card"><h3>${title}</h3><div class="stat-grid">${rows}</div></div>`;
}
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 `<div class="setup-row">
    <div class="setup-name">${(s.setup || "—").replace(/</g, "&lt;")}</div>
    <div class="setup-stat dim">${s.n}×  ${(s.winrate || 0).toFixed(0)}%</div>
    <div class="setup-stat ${cls}">${pnl >= 0 ? "+" : ""}${cs}${(pnl || 0).toFixed(2)}</div>
    <div class="setup-stat">PF ${pf != null ? pf.toFixed(2) : "—"}</div>
  </div>`;
}

// 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 = '<div class="dim" style="padding:8px">Monitor n/v</div>'; return; }
  const m = d.mech || {}, lv = d.live || {}, ex = d.expect || {};
  const [vtxt, vcls] = _SQM_VERDICT[d.verdict] || ["—", "dim"];
  const row = (k, v, cls) => `<div class="sqm-row"><span>${k}</span><b class="${cls || ""}">${v}</b></div>`;
  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 = `<div class="dim">${m.error}</div>`;
  else if (!m.n) mechHtml = '<div class="dim">keine Ausbrüche im Fenster</div>';
  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 = '<div class="dim">noch keine Auto-Squeeze-Trades</div>';
  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 =
    `<div class="sqm-head"><h2 class="sub-h" style="margin:0">Auto-Squeeze-Monitor (B4)</h2>`
    + `<span class="sqm-badge ${vcls}">${vtxt}</span></div>`
    + `<div class="sqm-grid"><div class="sqm-col"><div class="sqm-h">Entry-Edge · Mechanik (~30 T M5, isoliert)</div>`
    + `<div class="sqm-rows">${mechHtml}</div><div class="sqm-exp">Erwartung ØR +0,14…+0,23 · PF&gt;1</div></div>`
    + `<div class="sqm-col"><div class="sqm-h">Live-Trades · echt (Gesamtsystem)</div>`
    + `<div class="sqm-rows">${liveHtml}</div></div></div>`
    + `<div class="sqm-note">Mechanik = nur der Squeeze-<b>Einstieg</b> 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.</div>`;
}
async function loadSqueezeMonitor() {
  try {
    const d = await (await fetch("api/squeeze_monitor")).json();
    renderSqueezeMonitor(d);
  } catch (e) { $("sqm-box").innerHTML = '<div class="dim" style="padding:8px">Monitor-Fehler</div>'; }
}

async function loadStats() {
  loadSqueezeMonitor();
  const el = $("stats-body"), setEl = $("stats-setups");
  el.innerHTML = '<div class="dim" style="padding:10px">lade …</div>';
  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) +
      `<div class="dim" style="padding:2px">Trades in DB gesamt: ${d.n_total ?? "—"}</div>`;
    const ss = (d.setups || []).filter(x => !x.error);
    setEl.innerHTML = ss.length
      ? `<div class="dim setup-hint">${d.wht_pct > 0 ? "P&amp;L/PF = netto nach " + d.wht_pct + "% WHT" : "brutto"}</div>`
        + ss.map(setupRow).join("")
      : '<div class="dim">keine Setup-Daten</div>';
  } catch (e) { el.innerHTML = '<div class="down" style="padding:10px">Netzwerkfehler</div>'; }
}

async function loadNews() {
  const sEl = $("news-sentiment"), bEl = $("news-body");
  bEl.innerHTML = '<div class="dim">lade …</div>'; 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 <b class="${sc}">${s.score >= 0 ? '+' : ''}${(s.score || 0).toFixed(2)}</b>` +
        `  ·  ▲${s.n_bull || 0}  ▼${s.n_bear || 0}`;
    }
    const hs = d.headlines || [];
    bEl.innerHTML = hs.length ? hs.map(h =>
      `<a class="news-item" href="${h.link || '#'}" target="_blank" rel="noopener">` +
      `<div class="t">${(h.title || '').replace(/</g, '&lt;')}</div>` +
      `<div class="s">${(h.source || '').replace(/</g, '&lt;')}</div></a>`).join("")
      : `<div class="dim">${d.error || "noch keine News geladen"}</div>`;
  } catch (e) { bEl.innerHTML = '<div class="down">Netzwerkfehler</div>'; }
}

$("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);
