"use strict"; // ── Live-Anbindung an server.py via WebSocket (Fallback: Polling) ────────── const $ = (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; 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 ─────────── function renderVerdict(v) { const sig = v.headline || "—"; const cls = sig === "LONG" ? "long" : sig === "SHORT" ? "short" : ""; const arr = sig === "LONG" ? "▲" : sig === "SHORT" ? "▼" : "◌"; $("vd-signal").className = "vd-signal " + cls; // Zeitebene, für die die Empfehlung gilt (M1/M5/M15/M30/H1) $("vd-signal").textContent = `${arr} ${sig}` + (v.tf ? ` · ${v.tf}` : ""); // Konfidenz-Ring (conic-gradient bis conf%, Farbe nach Richtung) const conf = v.conf || 0; const col = sig === "LONG" ? "var(--up)" : sig === "SHORT" ? "var(--down)" : "var(--dim)"; const deg = Math.max(0, Math.min(100, conf)) * 3.6; $("vd-ring").style.background = `conic-gradient(${col} ${deg}deg, #21262d ${deg}deg)`; $("vd-conf").textContent = conf ? conf + "%" : "—"; // Konsens-Text + Bias-Nadel (-1..+1 → 0..100 %) const total = v.total || 0, agree = v.agree || 0; const bias = typeof v.bias === "number" ? v.bias : 0; const strength = Math.abs(bias) >= 0.5 ? "stark" : Math.abs(bias) >= 0.2 ? "leicht" : "gemischt"; const dir = bias > 0.05 ? "LONG" : bias < -0.05 ? "SHORT" : "neutral"; $("vd-consensus").textContent = sig === "—" ? "keine Daten" : `Modul-Konsens: ${strength} ${dir} · ${agree}/${total} Module für ${sig}`; $("vd-needle").style.left = ((bias + 1) / 2 * 100).toFixed(1) + "%"; // Modul-Chips const vote = (x) => x > 0 ? ["long", "▲"] : x < 0 ? ["short", "▼"] : ["flat", "—"]; $("vd-modules").innerHTML = (v.votes || []).map(mod => { const [c, a] = vote(mod.vote); return `
` + `${mod.name}` + `${a}
` + `
${mod.detail || ""}
`; }).join(""); } function renderStatsCompact(sc, m) { sc = sc || {}; const cs = curSym(m || {}); const f2 = (v) => (v == null ? "—" : (+v).toFixed(2)); const block = (label, s) => { if (!s || !s.n) return `
${label}
` + `
noch keine Trades
`; const ratioCls = (s.ratio != null && s.ratio >= 1) ? "up" : "down"; const pfCls = (s.pf != null && s.pf >= 1) ? "up" : "down"; const pnlCls = (s.pnl >= 0) ? "up" : "down"; return `
${label} · ${s.n} Trades
` + `
` + `WR ${s.wr}%` + `PF ${f2(s.pf)}` + `Verh ${f2(s.ratio)}` + `Øgew +${cs}${f2(s.avg_win)}` + `Øverl ${cs}${f2(s.avg_loss)}` + `PnL ${s.pnl >= 0 ? "+" : ""}${cs}${f2(s.pnl)}` + `
`; }; $("stx-row").innerHTML = block("Woche", sc.week) + block("Gesamt", sc.all); } function renderGaps(g, m) { const el = $("gaps-list"); const list = (g && g.gaps) || []; if (!list.length) { el.innerHTML = 'keine offenen Gaps'; return; } const bid = (m || {}).bid; el.innerHTML = list.map(x => { const up = x.dir === "up"; const arr = up ? "▲" : "▼"; const dist = bid != null ? (x.fill - bid) : null; const ds = dist != null ? ` (${dist >= 0 ? "+" : ""}${fmt(dist)})` : ""; return `
${arr} ${x.dir.toUpperCase()}` + `${fmt(x.lo)}–${fmt(x.hi)}` + `schließt bei ${fmt(x.fill)}${ds}` + `${x.date}
`; }).join(""); } // Marktstruktur (M30) — reine Anzeige (HH/HL/LH/LL, BOS, Regressionskanal). function renderStructure(s) { s = s || {}; const TR = { up: ["▲ Aufwärts", "up"], down: ["▼ Abwärts", "down"], range: ["→ Seitwärts", "dim"] }; const [txt, cls] = TR[s.trend] || ["—", "dim"]; const tEl = $("ms-trend"); tEl.textContent = txt; tEl.className = "ms-trend " + cls; // Swing-Chips: HH/HL = bullisch (grün), LH/LL = bärisch (rot) const chips = (s.swings || []).map(sw => { const bull = sw.type === "HH" || sw.type === "HL"; const c = (sw.type === "H" || sw.type === "L") ? "dim" : (bull ? "up" : "down"); return `${sw.type}`; }).join(''); $("ms-swings").innerHTML = chips || ''; // BOS const b = s.bos; const bEl = $("ms-bos"); if (b) { const up = b.dir === "up"; bEl.innerHTML = `BOS` + `${up ? "▲ über" : "▼ unter"} ${fmt(b.level)}` + `vor ${b.bars_ago} Bars`; bEl.classList.remove("hidden"); } else { bEl.classList.add("hidden"); } // Kanal: Richtung + Position (0 = unten, 1 = oben) const ch = s.channel; const CD = { up: ["Aufwärtskanal", "up"], down: ["Abwärtskanal", "down"], flat: ["Seitwärtskanal", "dim"] }; if (ch) { const [ctxt, ccls] = CD[ch.dir] || ["Kanal", "dim"]; const dEl = $("ms-chan-dir"); dEl.textContent = ctxt; dEl.className = ccls; const pct = Math.round((ch.pos != null ? ch.pos : 0.5) * 100); $("ms-chan-fill").style.width = pct + "%"; $("ms-chan-dot").style.left = pct + "%"; const zone = pct <= 25 ? "am unteren Rand" : pct >= 75 ? "am oberen Rand" : "in der Mitte"; $("ms-chan-note").textContent = `Kurs ${zone} (${pct}%)`; $("card-structure").classList.remove("hidden"); } else { $("ms-chan-dir").textContent = "—"; $("ms-chan-note").textContent = ""; $("ms-chan-fill").style.width = "0%"; } } 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}`; $("session").textContent = stxt; $("session").className = "session-line " + (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) ── $("hdr-price").textContent = m.bid != null ? fmt(m.bid) : "—"; $("hdr-price").className = priceCls; const hpos = d.position || {}; if (hpos.ticket) { const wht = d.wht_pct || 0; const raw = hpos.pnl || 0; const netHdr = raw > 0 ? raw * (1 - wht / 100) : raw; $("hdr-pnl").textContent = (netHdr >= 0 ? "+" : "") + cs + fmt(netHdr, 2); $("hdr-pnl").className = netHdr > 0 ? "up" : netHdr < 0 ? "down" : ""; } else { $("hdr-pnl").textContent = "—"; $("hdr-pnl").className = ""; } // ── 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) ─────── renderVerdict(d.verdict || {}); renderStructure(d.structure); 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) : "—"; // 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"); } } // ── 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) ────────────────────── const w = d.wave_signal || {}, ws = d.wave || {}; $("wave-setup").textContent = w.setup || "—"; // Ü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"; } } if (ws.direction && ws.wave_start != null) { $("wave-move").textContent = `${ws.direction === "up" ? "▲" : "▼"} ab ${fmt(ws.wave_start)}` + (ws.move_atr != null ? ` ${ws.move_atr >= 0 ? "+" : ""}${fmt(ws.move_atr, 2)}×ATR` : ""); } else $("wave-move").textContent = "—"; // TF-Ampel: M1/M5/M15/M30 grün(auf)/rot(ab); frische EMA12/50-Kreuzung // (≤3 Bars) → blinkendes ⟳ + Richtungspfeil. const turns = ws.tf_turns || {}; $("wave-turns").innerHTML = ["M1", "M5", "M15", "M30", "H1"].map(tf => { const t = turns[tf] || {}; const cls = t.dir > 0 ? "up" : t.dir < 0 ? "down" : "flat"; const arr = t.dir > 0 ? "▲" : t.dir < 0 ? "▼" : "·"; const fresh = t.fresh ? " fresh" : ""; const turn = t.fresh ? `⟳${arr}` : arr; const ago = (t.bars_ago != null && t.fresh) ? `${t.bars_ago}` : ""; return `${tf} ${turn}${ago}`; }).join(""); // (S/R-Zeile im Position-Bereich ausgeblendet — die Level stehen im Chart; // die Preis-Färbung/„nähert sich"-Warnung nutzt srl weiterhin.) $("wave-reasons").textContent = (w.reasons || []).slice(0, 3).join(" · "); // Elliott/FVG + TradersUnion: keine Einzel-Kacheln mehr (ausgeblendet) — // beide fließen weiterhin in die Gesamtempfehlung (engine._verdict) ein. // ── KI-Agent ──────────────────────────── const ag = (d.agent || {}).advisory; if (ag) { const b = ag.bias || "NEUTRAL"; const bc = b === "LONG" ? "long" : b === "SHORT" ? "short" : ""; const ba = b === "LONG" ? "▲" : b === "SHORT" ? "▼" : "◌"; $("agent-bias").className = "signal " + bc; $("agent-bias").textContent = `${ba} ${b} · ${ag.confidence}%` + (ag.timeframe ? ` · ${ag.timeframe}` : ""); $("agent-head").textContent = ag.headline || ""; $("agent-reason").textContent = ag.reasoning || ""; $("agent-risks").innerHTML = (ag.risks || []).map(r => `
  • ${r}
  • `).join(""); } else { $("agent-bias").className = "signal dim"; $("agent-bias").textContent = (d.agent || {}).busy ? "◌ analysiere …" : "◌ —"; $("agent-head").textContent = ""; $("agent-reason").textContent = (d.agent || {}).error || ""; $("agent-risks").innerHTML = ""; } // ── Aktionsleiste: Zustände ───────────── const hasPos = !!p.ticket; const longBtn = document.querySelector('.btn[data-side="long"]'); const shortBtn = document.querySelector('.btn[data-side="short"]'); const closeBtn = document.querySelector('.btn[data-side="close"]'); longBtn.disabled = hasPos; shortBtn.disabled = hasPos; closeBtn.disabled = !hasPos; $("btn-squeeze").classList.toggle("on", !!d.auto_squeeze); $("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 = ""; } } // Schalter-Zustand: Auto-Close am S/R an/aus + Mindestgewinn const srBtn = $("srclose-btn"); if (srBtn) { srBtn.textContent = srAuto ? "AN" : "AUS"; srBtn.title = srAuto ? "Bot schließt am Level (im Plus)" : "nur Hinweis, kein Auto-Close"; srBtn.classList.toggle("on", srAuto); } const srMin = d.sr_close_min_gain || 0; const smi = $("srmin-input"); if (smi && document.activeElement !== smi) { smi.value = srMin > 0 ? String(srMin) : ""; } // Einblendung: der Bot hat am S/R automatisch geschlossen (Zähler stieg) const src = d.sr_close_count; if (src != null) { if (_lastSrCloseCount != null && src > _lastSrCloseCount) { toast("🎯 Bot hat am Level geschlossen (schwacher Anlauf, Gewinn gesichert)", true); } _lastSrCloseCount = src; } // ── Letzte Trades ─────────────────────── const tds = (d.last_trades || []).slice(0, 5); $("trades").innerHTML = tds.map(x => { const pnl = x.pnl || 0; const cls = pnl > 0 ? "up" : pnl < 0 ? "down" : "dim"; const arr = (x.direction || "").toUpperCase() === "BUY" ? "▲" : "▼"; const ts = (x.exit_time || x.entry_time || 0) * 1000; const hm = ts ? new Date(ts).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" }) : "—"; const cb = (x.closed_by || "").toUpperCase().slice(0, 2) || "—"; return `
    ${arr}${pnl >= 0 ? "+" : ""}${fmt(pnl, 2)}
    ` + `
    ${hm} ${cb}
    `; }).join("") || '
    keine
    '; } // ── WebSocket mit Auto-Reconnect ─────────────────────────────────────────── let sock = null, retry = null; // `lastTs` dedupliziert zwischen WS-Push und REST-Poll (beide liefern denselben // `engine.snapshot_cached().ts`) — nur die jeweils NEUERE Quelle rendert, kein // Flackern/Doppel-Render, wenn beide fast gleichzeitig eintreffen. let lastTs = 0; function _applySnapshot(d) { if (typeof d.ts === "number" && d.ts <= lastTs) return; // schon Neueres gezeigt if (typeof d.ts === "number") lastTs = d.ts; render(d); } function connect() { const proto = location.protocol === "https:" ? "wss" : "ws"; sock = new WebSocket(`${proto}://${location.host}/ws`); sock.onmessage = (ev) => { 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. const POLL_MS = 4000; async function pollSnapshot() { if (document.hidden) return; // seite unsichtbar → spart Akku/Daten, Wake-Handler holt beim Zurückkommen sofort nach try { const d = await (await fetch("api/snapshot")).json(); _applySnapshot(d); } catch (e) { /* still — WS-Reconnect kümmert sich, das ist nur der Fallback */ } } setInterval(pollSnapshot, POLL_MS); // ── 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; if (side === "long" || side === "short") { const info = recInfo(side); // WARTEN/Gegen-Signal → Hinweis, der if (info.danger) { // bestätigt werden muss (kein Block) const sig = (lastSnap.wave_signal || {}).signal || "WARTEN"; const head = (sig === "WARTEN" || sig === "—") ? "ℹ Keine Empfehlung (WARTEN)" : `ℹ Empfehlung steht GEGEN dich (${sig})`; const ok = await modal(head, info.line + `\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() { const slv = $("tb-sl").value, tpv = $("tb-tp").value; const tok = await ensureToken(); if (tok === null) return; $("tb-sl").blur(); $("tb-tp").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!)"); $("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(); } }); // 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(); if (name === "charts") loadCharts(); } // Nur echte View-Tabs registrieren — NICHT den Mute-Button (der trägt .tab nur // fürs Styling; ohne [data-view]-Filter überschrieb das hier seinen onclick und // showView(undefined) blendete alle Views aus). document.querySelectorAll(".tab[data-view]").forEach(t => t.onclick = () => showView(t.dataset.view)); async function loadLogs() { const el = $("logs-body"); el.textContent = "lade …"; try { const d = await (await fetch("api/logs?lines=300")).json(); if (d.error) { el.textContent = "Fehler: " + d.error; return; } el.textContent = (d.lines || []).join("\n"); el.scrollTop = el.scrollHeight; } catch (e) { el.textContent = "Netzwerkfehler"; } } function statBlock(title, s) { const cs = curSym(lastSnap.market || {}); if (!s || s.error) return `

    ${title}

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

    ${title}

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

    Auto-Squeeze-Monitor (B4)

    ` + `${vtxt}
    ` + `
    Entry-Edge · Mechanik (~30 T M5, isoliert)
    ` + `
    ${mechHtml}
    Erwartung ØR +0,14…+0,23 · PF>1
    ` + `
    Live-Trades · echt (Gesamtsystem)
    ` + `
    ${liveHtml}
    ` + `
    Mechanik = nur der Squeeze-Einstieg mit Backtest-Exit ` + `(zeigt, ob der Edge lebt). Live = die echten Trades inkl. realer Exits/Eingriffe. ` + `Klaffen sie auseinander, liegt es am Exit/Verhalten, nicht am Signal.
    `; } async function loadSqueezeMonitor() { try { const d = await (await fetch("api/squeeze_monitor")).json(); renderSqueezeMonitor(d); } catch (e) { $("sqm-box").innerHTML = '
    Monitor-Fehler
    '; } } async function loadStats() { loadSqueezeMonitor(); const el = $("stats-body"), setEl = $("stats-setups"); el.innerHTML = '
    lade …
    '; try { const d = await (await fetch("api/stats")).json(); lastSnap._wht = d.wht_pct || 0; el.innerHTML = statBlock("Heute", d.today) + statBlock("Woche", d.week) + statBlock("Gesamt", d.all) + `
    Trades in DB gesamt: ${d.n_total ?? "—"}
    `; const ss = (d.setups || []).filter(x => !x.error); setEl.innerHTML = ss.length ? `
    ${d.wht_pct > 0 ? "P&L/PF = netto nach " + d.wht_pct + "% WHT" : "brutto"}
    ` + ss.map(setupRow).join("") : '
    keine Setup-Daten
    '; } catch (e) { el.innerHTML = '
    Netzwerkfehler
    '; } } async function loadNews() { const sEl = $("news-sentiment"), bEl = $("news-body"); bEl.innerHTML = '
    lade …
    '; sEl.textContent = ""; try { const d = await (await fetch("api/news")).json(); const s = d.sentiment || {}; if (s.score != null) { const sc = s.score > 0.05 ? "up" : s.score < -0.05 ? "down" : "dim"; sEl.innerHTML = `Sentiment ${s.score >= 0 ? '+' : ''}${(s.score || 0).toFixed(2)}` + ` · ▲${s.n_bull || 0} ▼${s.n_bear || 0}`; } const hs = d.headlines || []; bEl.innerHTML = hs.length ? hs.map(h => `` + `
    ${(h.title || '').replace(/` + `
    ${(h.source || '').replace(/`).join("") : `
    ${d.error || "noch keine News geladen"}
    `; } catch (e) { bEl.innerHTML = '
    Netzwerkfehler
    '; } } // ── Charts (Lightweight Charts): M1/M5/M15/M30/H1 untereinander mit Trend ── const CHART_TFS = ["M1", "M5", "M15", "M30", "H1"]; const _charts = {}; // tf → {chart, candle, s12, s50} const _TREND_TXT = { up: ["▲ Aufwärts", "up"], down: ["▼ Abwärts", "down"], flat: ["→ Seitwärts", "flat"] }; function _ensureChartCard(tf) { if (document.getElementById("chart-" + tf)) return; const card = document.createElement("div"); card.className = "chart-card"; card.id = "chart-" + tf; card.innerHTML = `
    ${tf}` + `` + `
    ` + `
    `; $("charts-body").appendChild(card); } function _makeChart(tf) { const el = document.getElementById("ccv-" + tf); // Explizite Größe statt autoSize: autoSize hängt an ResizeObserver und lieferte // auf Mobil-Browsern teils 0×0-Charts (leerer Tab). clientWidth ist gesetzt, // weil loadCharts erst nach dem Einblenden des Tabs läuft. const chart = LightweightCharts.createChart(el, { width: el.clientWidth || 320, height: 200, layout: { background: { color: "#0d1117" }, textColor: "#8b949e", fontSize: 13 }, grid: { vertLines: { color: "#161b22" }, horzLines: { color: "#161b22" } }, timeScale: { borderColor: "#30363d", timeVisible: true, secondsVisible: false }, rightPriceScale: { borderColor: "#30363d" }, crosshair: { mode: LightweightCharts.CrosshairMode.Normal }, handleScale: { axisPressedMouseMove: false }, }); const candle = chart.addCandlestickSeries({ upColor: "#3fb950", downColor: "#f85149", borderVisible: false, wickUpColor: "#3fb950", wickDownColor: "#f85149", }); const line = (color) => chart.addLineSeries( { color, lineWidth: 1, priceLineVisible: false, lastValueVisible: false, crosshairMarkerVisible: false }); const s12 = line("#58a6ff"), s50 = line("#d29922"); // EMA12 blau, EMA50 amber // Regressionskanal (nur M30 bekommt Daten): oben/unten gestrichelt, Mitte gepunktet const chLine = (color, style) => chart.addLineSeries( { color, lineWidth: 1, lineStyle: style, priceLineVisible: false, lastValueVisible: false, crosshairMarkerVisible: false }); const chU = chLine("#8b949e", 2), chM = chLine("#8b949e66", 1), chL = chLine("#8b949e", 2); _charts[tf] = { chart, candle, s12, s50, chU, chM, chL }; return _charts[tf]; } function renderChart(tf, d) { const off = d.broker_utc_offset || 0; const B = d.bars || []; if (d.error || !B.length) { const tr = document.getElementById("ctr-" + tf); if (tr) { tr.textContent = d.error ? "⚠ " + d.error : "keine Daten"; tr.className = "chart-trend flat"; } return; } const candles = B.map(b => ({ time: b.t - off, open: b.o, high: b.h, low: b.l, close: b.c })); const em = (arr) => (arr || []).map((v, i) => (B[i] ? { time: B[i].t - off, value: v } : null)).filter(Boolean); const c = _charts[tf] || _makeChart(tf); c.candle.setData(candles); c.s12.setData(em(d.ema12)); c.s50.setData(em(d.ema50)); // Regressionskanal (M30) — 3 Linien deckungsgleich mit den Bars; sonst leeren if (c.chU) { const ch = d.channel; if (ch && ch.upper) { c.chU.setData(em(ch.upper)); c.chM.setData(em(ch.mid)); c.chL.setData(em(ch.lower)); } else { c.chU.setData([]); c.chM.setData([]); c.chL.setData([]); } } // S/R-Linien + Zonen (Ranges) als horizontale Preislinien einzeichnen (c.lines || []).forEach(pl => { try { c.candle.removePriceLine(pl); } catch (e) {} }); c.lines = []; const addLine = (p, color, style, title) => { if (p == null) return; c.lines.push(c.candle.createPriceLine({ price: p, color, lineWidth: 1, lineStyle: style, axisLabelVisible: true, title: title || "" })); }; const LV = d.levels || {}; // Verständliche Labels: nächstes Level ausgeschrieben, weitere nummeriert (LV.res || []).forEach((p, i) => addLine(p, "#1f6feb", 2, i === 0 ? "Widerstand" : `Widerstand ${i + 1}`)); (LV.sup || []).forEach((p, i) => addLine(p, "#58a6ff", 2, i === 0 ? "Unterstützung" : `Unterstützung ${i + 1}`)); (LV.zones || []).forEach(z => { addLine(z.hi, "#1f6feb", 1, "Zone"); addLine(z.lo, "#1f6feb", 1, ""); }); c.chart.timeScale().fitContent(); const tr = document.getElementById("ctr-" + tf); const [txt, cls] = _TREND_TXT[d.trend] || ["—", "flat"]; tr.textContent = txt; tr.className = "chart-trend " + cls; document.getElementById("clast-" + tf).textContent = candles[candles.length - 1].close.toFixed(3); } async function loadCharts() { if (typeof LightweightCharts === "undefined") { $("charts-body").innerHTML = '
    Chart-Framework nicht geladen (Seite neu laden).
    '; return; } for (const tf of CHART_TFS) { _ensureChartCard(tf); try { const d = await (await fetch(`api/bars?tf=${tf}&n=100`)).json(); renderChart(tf, d); // Breite nachziehen (Rotation/Resize seit Erstellung) const c = _charts[tf], el = document.getElementById("ccv-" + tf); if (c && el && el.clientWidth > 0) c.chart.applyOptions({ width: el.clientWidth }); } catch (e) { // Fehler SICHTBAR machen statt stumm schlucken (war: leere Kacheln ohne Grund) const tr = document.getElementById("ctr-" + tf); if (tr) { tr.textContent = "⚠ " + (e && e.message ? e.message : "Fehler"); tr.className = "chart-trend flat"; } } } } window.addEventListener("resize", () => { for (const tf of CHART_TFS) { const c = _charts[tf], el = document.getElementById("ccv-" + tf); if (c && el && el.clientWidth > 0) c.chart.applyOptions({ width: el.clientWidth }); } }); $("logs-reload").onclick = loadLogs; $("stats-reload").onclick = loadStats; $("news-reload").onclick = loadNews; $("charts-reload").onclick = loadCharts; // Auto-Refresh nur solange der Charts-Tab sichtbar ist (schont den mt5_lock) setInterval(() => { if (!$("view-charts").classList.contains("hidden")) loadCharts(); }, 20000); // 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);