Files
AH-Oil-Trader/web/app.js
T
Axel HocksandClaude Opus 4.8 027995d1e7 Gegen-/Stop-Seiten-Anzeige P(Durchbruch) (stop_approach, v=109)
Zeigt die Durchbruchwahrscheinlichkeit fuer das Level, dem sich der Kurs auf der
Gegen-/Stop-Seite der offenen Position naehert (LONG->Support darunter,
SHORT->Resistance darueber) — reine Anzeige, kein Auto-Close. Vorher per
backtest_pbreak_calibration.py belegt: Modell haelt auf der Gegen-Seite
out-of-sample (AUC 0,68/0,72 in beiden Haelften), niedriger P-Bereich kalibriert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 16:19:39 +02:00

1191 lines
56 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 `<div class="vd-mod ${c}"><div class="m-top">` +
`<span class="m-name">${mod.name}</span>` +
`<span class="m-vote">${a}</span></div>` +
`<div class="m-det">${mod.detail || ""}</div></div>`;
}).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 `<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)})` : "";
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>`
+ `<span class="dim gap-date">${x.date}</span></div>`;
}).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 `<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%";
}
}
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 2122 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 ? `<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);
$("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,150,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 `<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;
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 `<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>'; }
}
// ── 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 =
`<div class="chart-head"><span class="chart-tf">${tf}</span>` +
`<span class="chart-trend" id="ctr-${tf}">—</span>` +
`<span class="chart-last" id="clast-${tf}">—</span></div>` +
`<div class="chart-cv" id="ccv-${tf}"></div>`;
$("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: 11 },
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 = '<div class="dim" style="padding:14px">Chart-Framework nicht geladen (Seite neu laden).</div>';
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);