"use strict";
// ── Live-Anbindung an server.py via WebSocket (Fallback: Polling) ──────────
// ⚠⚠ AUFFANG-ELEMENT (2026-08-07) — behebt die haeufigste Ausfallart des
// Dashboards. `index.html` und `app.js` werden vom Browser UNABHAENGIG
// gecacht; trifft neue JS auf alte HTML, war `$("neu").textContent = …` eine
// TypeError — und weil das mitten in `render()` passiert, brach der GESAMTE
// Render ab: das Dashboard fror auf Altwerten ein, obwohl Server und Snapshot
// korrekt waren. Real passiert beim HL-Kurs-Einbau (01.08.), beinahe erneut
// beim Entfernen der Setup-Zeile (06.08., 8 Folgezugriffe auf `w`).
// Die Gegenregel „neue Elemente per `const el = $(…); if (el) {…}`" stand seit
// 01.08. in CLAUDE.md — GEMESSEN befolgt wurde sie bei **0 von 95** direkten
// Zugriffen. Eine Regel mit 0 % Befolgung ist keine Regel, also loest es jetzt
// der Code an EINER Stelle statt an 95.
// ⚠ `?.` waere hier keine Loesung gewesen: **80 der 95** Zugriffe sind
// ZUWEISUNGEN, und `$("x")?.y = v` ist ein Syntaxfehler (Optional Chaining ist
// links vom `=` verboten).
// ⚠ Der Fehler wird NICHT verschluckt, sondern EINMAL je ID in die Konsole
// gemeldet — ein stiller Fallback waere derselbe Fehler wie der `catch {}` in
// `pollSnapshot`, der Render-Fehler lautlos gefressen hat (behoben 01.08.).
const _FEHLT = document.createElement("span");
const _fehlendGemeldet = new Set();
const $ = (id) => {
const el = document.getElementById(id);
if (el) return el;
if (!_fehlendGemeldet.has(id)) {
_fehlendGemeldet.add(id);
console.warn(`[app] Element #${id} fehlt — vermutlich alte index.html im `
+ `Cache. Schreibzugriffe laufen ins Leere, der Rest rendert `
+ `weiter. Abhilfe: ?v=N in index.html hochzaehlen.`);
}
return _FEHLT;
};
// ⚠ STRENGE Variante — gibt `null` zurueck, wenn das Element fehlt.
// Zwingend fuer Pfade, die einen gelesenen Wert an den BROKER schicken: dort
// ist „Feld fehlt" NICHT dasselbe wie „Feld ist leer". Beim SL/TP-Feld heisst
// leer ausdruecklich „Stop entfernen" — ein Auffang-Element wuerde daraus ein
// stilles Loeschen des Broker-SL einer laufenden Position machen. Ein lauter
// Absturz ist dort das bessere Verhalten als ein stiller Schaden.
const $streng = (id) => document.getElementById(id);
function fmt(v, d = 3) {
return (v === null || v === undefined || isNaN(v)) ? "—" : Number(v).toFixed(d);
}
function curSym(m) {
const c = m && m.currency;
return c === "EUR" ? "€" : c === "USD" ? "$" : (c ? c + " " : "$");
}
// ── Akustischer CLOSE-Alarm ─────────────────────────────────────────────────
// Web-Audio (kein Sound-File). Mobile entsperrt Audio erst nach der ersten
// Nutzer-Interaktion → wir starten/reaktivieren den Context bei jedem Tap.
let _audioCtx = null, _lastBeep = 0;
function _unlockAudio() {
try {
if (!_audioCtx) _audioCtx = new (window.AudioContext || window.webkitAudioContext)();
if (_audioCtx.state === "suspended") _audioCtx.resume();
} catch (e) {}
}
window.addEventListener("pointerdown", _unlockAudio);
window.addEventListener("touchstart", _unlockAudio);
// Globaler Ton-Schalter (Mute-Button oben rechts; überlebt Reloads via localStorage)
let _muted = localStorage.getItem("oil_muted") === "1";
function _tone(freq, startOffset, dur) {
if (_muted) return;
const o = _audioCtx.createOscillator(), g = _audioCtx.createGain();
o.type = "square"; o.frequency.value = freq;
o.connect(g); g.connect(_audioCtx.destination);
const t = _audioCtx.currentTime + startOffset;
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(0.35, t + 0.02);
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
o.start(t); o.stop(t + dur + 0.03);
}
function beepCloseAlert() {
if (_muted) return;
if (!_audioCtx || _audioCtx.state !== "running") return; // erst nach 1. Tap aktiv
const now = Date.now();
if (now - _lastBeep < 4000) return; // max. alle 4 s
_lastBeep = now;
_tone(880, 0, 0.18); // Zwei-Ton-Alarm (hoch→tief)
_tone(620, 0.22, 0.20);
}
// Ton bei Empfehlung: LONG = aufsteigend, SHORT = absteigend (vom Close-Alarm
// unterscheidbar). Sofort beim Signal-Wechsel + WIEDERHOLUNG alle 30 s, solange
// der Button blinkt (Empfehlung aktiv & flat) — einmaliger Piep war überhörbar.
const _REC_REBEEP_MS = 30000;
let _lastRecBeep = 0, _lastRecSignal = null, _audioHintShown = false, _lastSrCloseCount = null;
// ── Laufzeit-Uhr des offenen Trades ───────────────────────────────────────────
// Öffnungszeit kommt als echte Epoch (Broker-Offset serverseitig korrigiert) im
// Snapshot (`position.open_time`); der Ticker läuft eigenständig jede Sekunde,
// damit die Uhr auch zwischen den Snapshots (4 s Poll / 1 s WS) flüssig zählt.
let _tradeOpenTime = 0;
function _fmtDuration(sec) {
sec = Math.max(0, Math.floor(sec));
const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60;
const p2 = (n) => String(n).padStart(2, "0");
return h > 0 ? `${h}:${p2(m)}:${p2(s)}` : `${m}:${p2(s)}`;
}
function _tickTradeDuration() {
const el = document.getElementById("tb-dur"), hd = document.getElementById("tb-runtime");
if (!el) return;
if (!_tradeOpenTime) { el.textContent = "—"; if (hd) hd.textContent = ""; return; }
const sec = Date.now() / 1000 - _tradeOpenTime;
const txt = _fmtDuration(sec);
el.textContent = txt;
if (hd) hd.textContent = "⏱ " + txt;
// Ab 2 h dezent hervorheben (Time-Stop greift bei 120 min in Phase „Init")
el.className = "tb-val" + (sec >= 7200 ? " warn" : "");
}
setInterval(_tickTradeDuration, 1000);
function beepRecommendation(sig, isNew) {
if (_muted) return; // auch kein Sperr-Toast
if (!_audioCtx || _audioCtx.state !== "running") {
// Audio noch nicht freigeschaltet (Mobile braucht 1× Tippen) → 1× erklären
if (isNew && !_audioHintShown) {
_audioHintShown = true;
toast("🔇 Ton gesperrt — einmal auf die Seite tippen aktiviert ihn", false);
}
return;
}
const now = Date.now();
if (now - _lastRecBeep < (isNew ? 3000 : _REC_REBEEP_MS)) return;
_lastRecBeep = now;
if (sig === "LONG") { _tone(520, 0, 0.16); _tone(780, 0.18, 0.20); } // ↑
else { _tone(780, 0, 0.16); _tone(520, 0.18, 0.20); } // ↓
}
// ── Gesamtempfehlung: Konfidenz-Ring + Bias-Balken + Modul-Chips ───────────
function renderVerdict(v, snap) {
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)
let head = `${arr} ${sig}` + (v.tf ? ` · ${v.tf}` : "");
// ⚠ WARUM WARTEN? (2026-07-31, User-Fund „WARTEN, aber Konsens stark LONG").
// Die Welle HAT meist eine Richtung — sie hängt an einem Gate. Gemessen mit der
// neuen `block_reason`-Telemetrie (3 h): breakout_pending 63 % · entry_room 20 % ·
// min_conf 18 %, KEIN einziges Mal Totband oder HTF-Gegen-Trend. Der Grund wurde
// bisher NUR als Textzeile in der Meldungen-Karte gezeigt — also weit weg von der
// Stelle, an der der scheinbare Widerspruch entsteht. Jetzt steht er direkt an der
// Headline: „◌ WARTEN · M15 · LONG, noch 0.11 bis zur Bestätigung".
const bo = (snap && snap.wave && snap.wave.breakout) || null;
const wsig = (snap && snap.wave_signal) || {};
if (sig === "WARTEN" && bo && bo.pending && bo.dir) {
head += ` · ${bo.dir}, noch ${fmt(bo.need, 2)} bis zur Bestätigung`;
} else if (sig === "WARTEN" && wsig.block) {
// ⚠ ERGÄNZUNG 2026-08-07: bisher stand der Grund NUR bei
// `breakout_pending` an der Headline — bei jedem anderen Gate blieb dort
// ein nacktes „WARTEN", während die Konsens-Zeile darunter „stark LONG"
// meldete. Genau dieser Widerspruch war der Anlass. Der Grund liegt im
// Snapshot (`wave_signal.block`), er wurde nur nie gezeigt.
const G = {
min_conf: `Konfidenz ${wsig.conf_pct || 0} % < 55 % — Setup zu schwach`,
entry_room: "kein Raum bis zum Gegenlevel",
htf_counter: "höhere Zeitebene steht dagegen",
stretch: "Kurs zu weit von der EMA entfernt",
deadband: "Totband — kein klarer Trend",
dead_hour: "gesperrte Handelsstunde",
eia: "EIA-Blackout",
stale: "Kursdaten veraltet",
no_data: "keine Daten",
};
const g = G[wsig.block] || wsig.block;
if (g) head += ` · ${g}`;
}
$("vd-signal").textContent = head;
// 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";
// Live-Kosten-Chip (Spread/ATR — gemessen Ø 0,265; Nacht 0,32–0,50 = Falle):
// reine Info, die die abgeschaltete Dead-Hours-Sperre ehrlich ersetzt.
const cr = snap && snap.cost_ratio;
let costTxt = "";
if (cr != null) {
const tag = cr < 0.20 ? "✅ günstig" : cr <= 0.32 ? "· normal" : "⚠ teuer";
costTxt = ` · Kosten ${fmt(cr, 2)}×ATR ${tag}`;
}
// ⚠ Konsens-Zeile korrigiert (2026-07-31). Zwei Fehler waren drin:
// (1) „… Module für ${sig}" nahm die HEADLINE als Bezug. Seit dem Fix vom 30.07.
// zählt `agree` bei WARTEN aber die Übereinstimmung mit der BIAS-Richtung →
// es stand „stark LONG · 4/4 Module für WARTEN". Die Richtung kommt jetzt als
// `ref_dir` vom Backend, statt hier aus `bias` nachgebaut zu werden.
// (2) `total` zählt nur Module MIT Aussage (Gewicht>0, ohne die Welle) — nach den
// Gewichts-Fixes schweigen mehr Module, während 9 Chips sichtbar bleiben.
// „4/4" las sich dadurch widersprüchlich. Jetzt steht dabei, wie viele
// Module gerade nichts sagen.
const refDir = v.ref_dir || null;
const silent = typeof v.silent === "number" ? v.silent : null;
// ⚠⚠ KORREKTUR 2026-08-07. „stark LONG · 2 von 2 stimmberechtigten" las sich
// wie Einstimmigkeit — dabei sprachen nur 2 von 9 Modulen, und die WELLE, die
// als einzige die Order steuert, war nicht dabei. `bias` ist arithmetisch
// richtig (das gesamte sprechende Gewicht war einig), aber die BASIS war
// winzig. Seit dem Entmachten von Elliott und Orderbuch (06.08.) trat das
// häufiger auf. Jetzt führt die Zeile mit der Basis und nennt die stumme
// Welle ausdrücklich — das löst den scheinbaren Widerspruch zur Headline auf.
const welleStumm = (v.votes || []).some(
m => m.name === "Welle" && !Number(m.weight));
const nMod = (v.votes || []).filter(m => m.used !== false).length;
let consensus;
if (sig === "—") {
consensus = "keine Daten";
} else if (!refDir || !total) {
consensus = "Modul-Konsens: kein Modul mit klarer Aussage";
} else {
// „stark/leicht" nur noch, wenn ueberhaupt die Haelfte der Module spricht —
// sonst beschreibt das Wort die Einigkeit von zwei Nebenstimmen.
const dueneBasis = nMod && total < nMod / 2;
consensus = `Modul-Konsens: ${agree} von ${nMod} Modulen sprechen, `
+ `${agree === total ? "alle" : `${agree} davon`} für ${refDir}`
+ (dueneBasis ? "" : ` · ${strength} ${dir}`);
if (welleStumm) consensus += " · ⚠ die Welle (steuert die Order) schweigt";
}
$("vd-consensus").textContent = consensus + costTxt;
$("vd-needle").style.left = ((bias + 1) / 2 * 100).toFixed(1) + "%";
// Modul-Chips
const vote = (x) => x > 0 ? ["long", "▲"] : x < 0 ? ["short", "▼"] : ["flat", "—"];
// ⚠ NUR Module zeigen, die wirklich in die Empfehlung einfliessen
// (User-Vorgabe 2026-08-06). `used === false` = dauerhaft entmachtet, weil
// gemessen ohne Inhalt (Elliott: 1.384 Episoden flach bei 25,9 % Einfluss;
// Orderbuch: 2.688 Episoden beidhaelftig negativ). Sie bleiben im Snapshot
// und in `verdict_votes` — nur der Chip entfaellt.
// Module mit `weight === 0` bleiben SICHTBAR: die schweigen nur situativ
// (Welle bei WARTEN, Squeeze ohne Ausbruch) und sprechen wieder.
$("vd-modules").innerHTML = (v.votes || []).filter(m => m.used !== false).map(mod => {
const [c, a] = vote(mod.vote);
// Gewicht sichtbar machen: Module OHNE Aussage (w=0) grau, sonst das Gewicht
// anzeigen — so ist erkennbar, dass z. B. Orderbuch/Liq-Trend (0,5, ungemessen)
// schwächer zählen als die Welle (3,0) oder der Squeeze (2,0).
const w = Number(mod.weight || 0);
const cls = w > 0 ? c : "flat mute";
const wtag = w > 0 ? `${w.toFixed(1).replace(".0", "")}` : "";
return `
` +
`${mod.name}${wtag}` +
`${a}
` +
`
${mod.detail || ""}
`;
}).join("");
// ── Selbst-Kalibrierung: die Karte zeigt ihre eigene Trefferquote ─────
// ⚠ Gegen 50 % zu lesen, nicht gegen 0 — es ist eine Richtungsaussage.
// Ausgangsstand (analyze_signal_live.py, 1.071 Episoden): 48–51 %.
// Bis genug Episoden ausgewertet sind, steht hier ehrlich „sammelt noch".
// Null-sicher (s. Cache-Regel): neue IDs treffen evtl. auf alte, gecachte HTML.
const trkEl = $("vd-selfcal");
if (trkEl) {
const tr = snap && snap.rec_track;
if (tr && tr.n >= 20 && tr.wr30 != null) {
const d = (x) => (x == null ? "—" : (x >= 0 ? "+" : "") + fmt(x, 3));
const cls = tr.wr30 >= 55 ? "up" : tr.wr30 <= 45 ? "down" : "";
trkEl.className = "vd-selfcal " + cls;
trkEl.textContent =
`🎯 eigene Trefferquote (letzte ${tr.n} Episoden): ${tr.wr30} % auf 30′`
+ (tr.wr60 != null ? ` · ${tr.wr60} % auf 60′` : "")
+ ` · Ø ${d(tr.avg30)}×ATR`;
trkEl.title = "Selbst-Kalibrierung: jede gerichtete Empfehlung wird nach "
+ "30 und 60 Minuten gegen den echten Kurs ausgewertet. Gegen 50 % lesen, "
+ "nicht gegen 0. Gemessener Ausgangsstand über 1.071 Episoden: 48–51 %.";
} else {
trkEl.className = "vd-selfcal dim";
trkEl.textContent = `🎯 eigene Trefferquote: sammelt noch`
+ (tr && tr.n ? ` (${tr.n} von 20 Episoden ausgewertet)` : "");
trkEl.title = "Jede gerichtete Empfehlung wird nach 30/60 Minuten gegen "
+ "den echten Kurs ausgewertet. Ab 20 ausgewerteten Episoden erscheint "
+ "die Quote hier.";
}
}
// ── HL-Kontext (Funding / OI / Premium) — reine Anzeige ───────────────
// ⚠ Gemessen ohne Prognosewert (analyze_hl_funding.py): entüberlappt hält
// kein Bucket die 2-Stichproben-Regel. Steht deshalb neutral in den
// Meldungen und NICHT in der Empfehlung. Null-sicher (Cache-Regel).
const hlEl = $("hl-note");
if (hlEl) {
const h = snap && snap.hl_ctx;
if (h && h.premium != null) {
const fr = (h.funding != null) ? (h.funding * 100).toFixed(4) + " %" : "—";
const oi = (h.open_interest != null)
? (h.open_interest / 1e6).toFixed(2) + " Mio" : "—";
hlEl.classList.remove("hidden");
hlEl.textContent = `⛓ Hyperliquid: Perp ${h.premium >= 0 ? "+" : ""}`
+ `${fmt(h.premium, 3)} $ zum Oracle · Funding ${fr}/h · OI ${oi}`;
hlEl.title = "Kontext vom Hyperliquid-Öl-Perp (xyz:CL). Positives Premium = "
+ "der Perp notiert über dem Index, Longs zahlen. ⚠ Gemessen über 5.095 "
+ "Funding-Stundenwerte (7 Monate) gegen den CFD: kein belastbarer "
+ "Prognosewert — deshalb ohne Stimme in der Empfehlung.";
} else {
hlEl.classList.add("hidden");
}
}
// ── Warum WARTEN? Verteilung der blockierenden Gates (heute) ───────────
const blkEl = $("vd-blocks");
if (blkEl) {
const bm = snap && snap.block_mix;
if (bm && bm.total) {
const pc = (n) => Math.round(100 * n / bm.total);
const top = (bm.top || []).map(x => `${x.grund} ${pc(x.n)} %`).join(" · ");
blkEl.textContent = `⛔ heute ${pc(bm.frei)} % ohne Block`
+ (top ? ` · ${top}` : "");
// ⚠ Schliessendes Anführungszeichen als „ “ (U+201C), NICHT über eine
// Konkatenation mit '"' — die war valide, aber schwer lesbar und lässt
// jeden Quote-Zähler auflaufen (der \n-Scan hat sie als offenes Literal
// gemeldet).
blkEl.title = "Welches Gate hält die Empfehlung gerade zurück — über den "
+ "heutigen Tag gezählt. Ohne diese Zeile war „warum gab es so wenige "
+ "Empfehlungen?“ nur per Datenbank-Abfrage zu beantworten.";
} else {
blkEl.textContent = "";
}
}
}
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 `
`;
};
$("stx-row").innerHTML = block("Woche", sc.week) + block("Gesamt", sc.all);
}
function renderGaps(g, m) {
const el = $("gaps-list");
const list = (g && g.gaps) || [];
if (!list.length) {
el.innerHTML = 'keine offenen Gaps';
return;
}
const bid = (m || {}).bid;
el.innerHTML = list.map(x => {
const up = x.dir === "up";
const arr = up ? "▲" : "▼";
const dist = bid != null ? (x.fill - bid) : null;
const ds = dist != null ? ` (${dist >= 0 ? "+" : ""}${fmt(dist)})` : "";
// ⚠ Die zwei Gap-Arten unterscheidbar machen (2026-08-03): nur `vacuum`
// speist die Wellen-Konfidenz (darauf wurde gemessen), `close_open` ist
// reine Anzeige. Ohne den Hinweis sähen beide gleich verbindlich aus.
const co = x.kind === "close_open";
const tag = co
? `Eröffnung`
: `Vakuum`;
return `
${arr} ${x.dir.toUpperCase()}`
+ `${fmt(x.lo)}–${fmt(x.hi)}`
+ `schließt bei ${fmt(x.fill)}${ds}`
+ tag
+ `${x.date}
`;
}).join("");
}
// Manuelle Trades (2026-08-04) — DESKRIPTIV. Zeigt die eigene Historie, macht
// keine Vorhersage: der volle Entscheidungskontext wird erst seit dem 04.08.
// mitgeschrieben, vorher wären Aussagen daraus Rauschen. Die Reifegrad-Zeile
// nennt die Abdeckung ehrlich, damit die Karte nicht überschätzt wird.
function renderManual(m) {
const grid = $("mt-grid"); if (!grid) return; // null-sicher (Cache-Regel)
if (!m || !m.gesamt) { grid.innerHTML = '—'; return; }
const cls = v => v > 0 ? "up" : v < 0 ? "down" : "";
const box = (lbl, b) => !b || !b.n ? "" :
`
🕯 Reine Anzeige — KEIN Signal. Gemessen
(backtest_candles.py, 80k Bars, 2 Halbjahre): die gängige Deutung ist
invertiert — der lange Körper läuft eher GEGEN sich (−0,06/−0,08×ATR), mit
2× Volumen noch stärker (−0,12/−0,20). Als Handelssignal durchgefallen
(backtest_candle_fade.py).
`;
}
// Wahrscheinlichkeits-Kegel. ⚠ Zeigt STREUUNG, nicht Richtung — und beschriftet
// mit der REAL gemessenen Abdeckung (~76 %), nicht mit dem Nennwert 80 %. Der Kegel
// ist out-of-sample geprüft (analyze_cone.py, Fit H1 → Abdeckung H2) und damit nach
// dem P(break)-Modell die zweite kalibrierte Komponente im Projekt.
// ⚠ Seit 2026-08-05 KOMPRIMIERT in der Gesamtempfehlung (`#vd-cone`) statt in einer
// eigenen Karte (User-Vorgabe). Gezeigt wird nur noch das 80 %-Band je Horizont —
// das enge 50 %-Band und der Fließtext sind entfallen und stecken im `title`.
function renderCone(cn) {
const el = $("vd-cone");
if (!el) return; // null-sicher (Cache-Regel)
const lv = (cn && cn.levels) || [];
const b80 = lv.filter(x => x.band === 80)
.sort((a, b) => a.minutes - b.minutes);
if (!b80.length) { el.innerHTML = ""; return; }
const cov = b80[0].coverage != null ? `${fmt(b80[0].coverage, 0)} %` : "~";
el.innerHTML = `📐 erwartete Spanne`
+ b80.map(x => `${x.minutes}′`
+ `${fmt(x.lo, 2)}–${fmt(x.hi, 2)}`).join("")
+ `trifft ${cov}`;
// Die Einordnung gehört an die Zahl, nicht in einen Absatz darunter — sonst
// liest jemand die Spanne als Richtungsprognose.
el.title = "STREUUNG, nicht Richtung: wie WEIT der Kurs plausibel läuft, nicht "
+ "wohin. 80 %-Band, out-of-sample geprüft (analyze_cone.py, Fit auf einem "
+ "Halbjahr, Abdeckung im anderen gemessen) — es trifft real ~76 %, ist also "
+ "leicht zu eng. Kein Signal, kein Einfluss auf die Empfehlung.";
}
function renderPatterns(pt) {
const el = $("pt-list");
if (!el) return;
const items = (pt && pt.patterns) || [];
if (!items.length) {
el.innerHTML = `Aktuell kein klares Muster erkannt.`;
return;
}
el.innerHTML = items.map(m => {
const dirCls = m.dir === "bull" ? "up" : m.dir === "bear" ? "down" : "dim";
const col = m.dir === "bull" ? "#3fb950" : m.dir === "bear" ? "#f85149" : "#8b949e";
const arr = m.dir === "bull" ? "▲" : m.dir === "bear" ? "▼" : "◆";
const st = m.status === "confirmed"
? `bestätigt`
: `bildet sich`;
const tgt = m.target != null
? `Ziel ${fmt(m.target, 2)}` : `Ausbruch offen`;
const q = Math.round((m.quality || 0) * 100);
return `
Trigger ${fmt(m.trigger, 2)} · ${tgt}
· Höhe ${m.height_atr}×ATR · vor ${m.bars_ago} Bars
${m.note || ""}
`;
}).join("") +
`
📊 Reine Anzeige (mechanisch aus M30-Pivots) — KEIN Signal,
kein Einfluss auf die Empfehlung. Muster-Klasse im Projekt als Signal noch unbelegt;
Einfluss erst nach Backtest.
`;
}
function render(d) {
// ── Header ──────────────────────────────
$("conn").className = "dot " + (d.connected ? "on" : "off");
$("symbol").textContent = d.symbol || "—";
const m = d.market || {};
// ── Server- (Broker) + Lokalzeit ────────
const _p2 = (n) => String(n).padStart(2, "0");
// server_time ist Broker-Wallclock, als UTC etikettiert → getUTC* zeigt sie 1:1
const srv = m.server_time ? new Date(m.server_time) : null;
const srvStr = (srv && !isNaN(srv))
? `${_p2(srv.getUTCHours())}:${_p2(srv.getUTCMinutes())}:${_p2(srv.getUTCSeconds())}`
: "—";
$("hdr-clock").textContent = `Srv ${srvStr} · Lok ${new Date().toLocaleTimeString("de-DE")}`;
// ── Börsen-Session ──────────────────────
const ses = d.session || {};
let stxt = ses.phase || "—";
if (ses.next_open) stxt += ` · ${ses.next_open.name}-Open in ${ses.next_open.in_min} min`;
// 🕘 Prime-Time 21–22 Uhr (Berlin ≈ Browser-Lokalzeit): gemessen die mit Abstand
// stärksten Stunden (backtest_hourly: netto +0,25/+0,49 — Rest ~breakeven).
// 📰 News-Sturm: viele frische Öl-Headlines (news.storm_state) = Ereignis läuft
// → Vorsicht/Vola. Beides REINE ANZEIGE, kein Filter.
const _h = new Date().getHours();
if (_h === 21 || _h === 22) stxt = `🕘 PRIME-TIME · ${stxt}`;
const storm = (d.news_storm || {}).level;
if (storm === "storm") stxt = `📰 NEWS-STURM · ${stxt}`;
else if (storm === "elevated") stxt = `📰 viele News · ${stxt}`;
// Marktstatus VOR die Session-Zeile: bei geschlossenem Markt ist die Phase
// ("Wochenende") allein zu leise — der eingefrorene Kurs sah live aus.
if (d.market_closed) {
stxt = `🔒 MARKT ZU · ${stxt}`; // HL-Kurs steht jetzt im Header
}
$("session").textContent = stxt;
$("session").className = "session-line " +
(d.market_closed ? "warn"
: storm === "storm" ? "warn"
: (_h === 21 || _h === 22) ? "warn"
: ses.just_opened ? "warn" : (ses.active && ses.active.length) ? "ok" : "dim");
// ── Konto: Balance / Equity ─────────────
const cs = curSym(m);
$("balance").textContent = m.balance != null ? cs + fmt(m.balance, 2) : "—";
if (m.equity != null) {
$("equity").textContent = cs + fmt(m.equity, 2);
$("equity").className = (m.balance != null)
? (m.equity >= m.balance ? "up" : "down") : "";
} else { $("equity").textContent = "—"; $("equity").className = ""; }
// ── Tages-P/L (realisiert heute + offene Position) ───────
const dp = d.day_pl || {};
if (dp.total != null) {
$("day-pl").textContent = (dp.total >= 0 ? "+" : "") + cs + fmt(dp.total, 2);
$("day-pl").className = dp.total > 0 ? "up" : dp.total < 0 ? "down" : "";
} else { $("day-pl").textContent = "—"; $("day-pl").className = ""; }
// ── Nähe zu S/R (Farb-Signal, innerhalb 0,3×ATR) ──────────
const srl = d.sr_levels || {};
const atrRef = (d.trail || {}).atr || m.atr_m15 || 0;
const near = atrRef ? atrRef * 0.30 : 0.05;
let priceCls = "";
if (m.bid != null) {
const dRes = srl.resistance != null ? srl.resistance - m.bid : Infinity;
const dSup = srl.support != null ? m.bid - srl.support : Infinity;
if (dRes >= 0 && dRes <= near && dRes <= dSup) priceCls = "near-res";
else if (dSup >= 0 && dSup <= near && dSup < dRes) priceCls = "near-sup";
}
// ── Header: Kurs (färbt sich nahe S/R) + G/V der offenen Position (netto) ──
// ⚠ MARKT ZU (User 2026-08-01): Pepperstone handelt WTI nicht am Wochenende, der
// Kurs friert dann auf dem Freitags-Stand ein — sichtbar war das NICHT (der grüne
// Punkt im Header ist die VERBINDUNG, nicht der Marktstatus). Hyperliquid läuft
// 24/7 weiter, also zeigen wir dessen Kurs (basis-korrigiert = MT5-vergleichbar)
// statt des eingefrorenen — deutlich als „HL" gekennzeichnet, damit niemand ihn
// für einen Broker-Kurs hält.
// ⚠ Der BROKER-Kurs bleibt der Hauptkurs (erster Entwurf ersetzte ihn bei
// geschlossenem Markt durch den HL-Kurs — der User wollte stattdessen BEIDE
// nebeneinander, was ehrlicher ist: man sieht die Abweichung statt einer
// stillen Vertauschung).
$("hdr-price").textContent = m.bid != null ? fmt(m.bid) : "—";
$("hdr-price").className = priceCls;
// ⚠ NULL-SICHER auf NEUE Elemente zugreifen (Fix 2026-08-01): `index.html` und
// `app.js` werden vom Browser UNABHÄNGIG gecacht. Trifft neue JS auf eine alte,
// gecachte HTML, wirft ein direkter Zugriff auf ein noch nicht existierendes
// Element eine TypeError — und weil das mitten in `render()` passiert, bricht der
// GESAMTE Render ab: das Dashboard friert ein und zeigt Altwerte. Genau so sah der
// Fehler beim Nutzer aus („ich sehe die Änderungen nicht"). Mit Guard läuft der
// Rest normal weiter, nur die neuen Felder fehlen, bis die HTML nachgeladen ist.
const lblEl = $("hdr-price-lbl");
if (lblEl) {
lblEl.textContent = d.market_closed ? "PEPPERSTONE KURS · ZU" : "PEPPERSTONE KURS";
lblEl.title = d.market_closed
? `Pepperstone geschlossen — letzter Tick vor ${Math.round((d.tick_age_s || 0) / 60)} min`
: "";
}
// Hyperliquid daneben: basis-korrigiert (= MT5-Niveau), mit Abweichung im Tooltip.
const hl = d.hl_live || null;
const hlEl = $("hdr-hl");
if (hlEl && hl && hl.hl_mid != null) {
// ⚠ Angezeigt wird der ROHE HL-Kurs (Fix 2026-08-02, User-Fund): hier stand
// vorher `mid_mt5` = HL + Basis, also der auf Pepperstone-Niveau umgerechnete
// Wert. Unter der Beschriftung „HYPERLIQUID KURS" ist das schlicht falsch — der
// Nutzer verglich mit app.hyperliquid.xyz (81,392) und sah bei uns 86,424.
// Jetzt steht hier exakt die Zahl, die auch auf der HL-Seite steht; das
// CFD-Äquivalent (für den Broker-Vergleich) wandert in den Tooltip.
const arr = hl.trend === "up" ? "▲" : hl.trend === "down" ? "▼" : "";
hlEl.textContent = fmt(hl.hl_mid) + (arr ? " " + arr : "");
const dv = hl.vs_mt5;
hlEl.className = d.market_closed ? "hl-price"
: (dv != null && Math.abs(dv) >= 0.15) ? "hl-price" : "";
hlEl.title = `Hyperliquid xyz:CL — Rohkurs ${fmt(hl.hl_mid)}, `
+ `${fmt(hl.age_s, 0)} s alt.\n`
+ `Auf Pepperstone-Niveau: ${fmt(hl.hl_mid)} + Basis `
+ `${hl.basis >= 0 ? "+" : ""}${fmt(hl.basis, 3)} = ${fmt(hl.mid_mt5)}`
+ (dv != null ? ` (ggü. Broker ${dv >= 0 ? "+" : ""}${fmt(dv, 3)})` : "")
+ (hl.basis_stale
? `\n⚠ Broker steht — Basis ist auf dem letzten Stand mit lebendem Markt `
+ `eingefroren. Ohne das würde die Umrechnung nur den eingefrorenen `
+ `Broker-Kurs reproduzieren.`
: "");
} else if (hlEl) {
hlEl.textContent = "—"; hlEl.className = ""; hlEl.title = "";
}
const hpos = d.position || {};
if (hpos.ticket) {
const wht = d.wht_pct || 0;
// ⚠ Bei geschlossenem Markt steht der Broker-P&L still (der Kurs ist eingefroren).
// Dann die HL-SCHÄTZUNG zeigen (User 2026-08-01) — klar als Schätzung markiert:
// Swap/Kommission der Wochenendtage fehlen und HL ist ein anderer Kontrakt.
const useHl = d.market_closed && d.pnl_hl != null;
const raw = useHl ? d.pnl_hl : (hpos.pnl || 0);
const netHdr = raw > 0 ? raw * (1 - wht / 100) : raw;
$("hdr-pnl").textContent = (useHl ? "≈" : "") + (netHdr >= 0 ? "+" : "") + cs + fmt(netHdr, 2);
$("hdr-pnl").className = netHdr > 0 ? "up" : netHdr < 0 ? "down" : "";
$("hdr-pnl").title = useHl
? `Schätzung auf Basis des Hyperliquid-Kurses — der Broker-Kurs ist eingefroren `
+ `(Broker-P&L ${fmt(hpos.pnl || 0, 2)} ${cs}). Ohne Swap/Kommission des Wochenendes.`
: "";
} else { $("hdr-pnl").textContent = "—"; $("hdr-pnl").className = ""; $("hdr-pnl").title = ""; }
// ── S/R-Annäherungs-Warnung (Position-Kachel) — NUR wenn kein reicherer
// Positions-Hint (pos-srhint) läuft, sonst doppeltes „nähert sich Widerstand".
const hasRichHint = !!((d.position && d.position.ticket) && d.sr_close_hint);
if (hasRichHint) {
$("cur-near").className = "cur-near";
$("cur-near").textContent = "";
} else {
$("cur-near").className = "cur-near " + priceCls;
$("cur-near").textContent =
priceCls === "near-res" ? `▲ nähert sich Widerstand ${fmt(srl.resistance)}` :
priceCls === "near-sup" ? `▼ nähert sich Unterstützung ${fmt(srl.support)}` : "";
}
// ── Gesamtempfehlung (aggregiert) ───────
renderVerdict(d.verdict || {}, d);
renderCircuitBreaker(d.circuit_breaker);
renderPatterns(d.patterns);
renderCandles(d.candle_anat);
renderCone(d.cone);
renderStructure(d.structure);
renderManual(d.manual_stats);
renderStatsCompact(d.stats_compact, d.market);
renderGaps(d.gaps, d.market);
// ── Position (Kurs/G/V stehen jetzt im Header) ──
const p = d.position || {};
// ── Trade-Bereich über der Gesamtempfehlung (nur bei offener Position) ──
const tb = $("trade-bar");
if (tb) {
if (p.ticket) {
tb.classList.remove("hidden");
const long = p.order_type === 0;
const dEl = $("tb-dir");
dEl.className = "tb-val tb-dir " + (long ? "up" : "down");
dEl.textContent = long ? "▲ LONG" : "▼ SHORT";
$("tb-lots").textContent = fmt(p.lots, 2);
$("tb-margin").textContent = p.margin ? cs + fmt(p.margin, 2) : "—";
// Laufzeit: Öffnungszeit merken (echte Epoch, Broker-Offset korrigiert) und
// sekündlich per Ticker anzeigen — unabhängig vom Snapshot-Intervall.
_tradeOpenTime = p.open_time ? Number(p.open_time) : 0;
_tickTradeDuration();
// SL/TP-Felder nur befüllen, wenn nicht gerade bearbeitet (kein Tipp-Konflikt)
const slEl = $("tb-sl"), tpEl = $("tb-tp");
if (document.activeElement !== slEl) slEl.value = p.sl ? fmt(p.sl, 2) : "";
if (document.activeElement !== tpEl) tpEl.value = p.tp ? fmt(p.tp, 2) : "";
} else {
tb.classList.add("hidden");
_tradeOpenTime = 0;
}
}
// ── Notfall-Stop: Feld wieder eingeblendet (User-Vorgabe 2026-07-22; war
// 2026-07-17 entfernt, Schutz lief nur noch über Broker-SL+Trailing — Backend
// (set_emergency/emergency_state.json) blieb die ganze Zeit aktiv/persistent).
// %-Modus (auto_emergency_pct>0): der Auto-Arm nimmt pct% der Balance → im
// Flat-Zustand diesen dynamischen Wert zeigen statt des Fixwerts.
const emg = d.emergency_loss;
const emgPct = d.emergency_pct || 0;
const emgRem = (emgPct > 0 && d.balance)
? Math.round(emgPct / 100 * d.balance * 100) / 100
: d.emergency_remember;
const ei = $("emg-input");
if (ei && document.activeElement !== ei) {
const shown = emg != null ? emg : (emgRem || "");
ei.value = shown === "" ? "" : String(shown);
}
// („Gewinn mitnehmen"-Eingabe aus dem Dashboard entfernt — Backend-Funktion
// set_takeprofit/api bleibt erhalten, nur ohne UI.)
// ── Trailing ────────────────────────────
const tr = d.trail || {};
$("trail-state").textContent = tr.enabled ? "AKTIV" : "aus";
$("trail-state").className = tr.enabled ? "up" : "dim";
$("trail-phase").textContent = tr.phase || "—";
$("trail-sl").textContent = fmt(tr.last_sl, 3);
$("trail-tp").textContent = fmt(tr.last_tp, 3);
$("trail-atr").textContent = `${fmt(tr.atr, 3)} · ${tr.atr_tf || "—"}`;
// ── Wellen-Meldungen (jetzt in „Meldungen"; Signal-Zeile entfällt — steht als
// Headline in der Gesamtempfehlung) ──────────────────────
// Setup- und Lauf-Zeile entfernt (User-Vorgabe 2026-08-06): das Setup steht
// ohnehin in der Verdict-Headline bzw. den Gründen, der „Lauf" war eine
// Beschreibung der Vergangenheit ohne Handlungsbezug.
// ⚠ `w` bleibt — es wird weiter unten noch für die Gründe-Zeile, das
// Button-Blinken, den Close-Alarm und die News-Konflikt-Meldung gebraucht.
const w = d.wave_signal || {}, ws = d.wave || {};
// Überdehnungs-Anzeige (KEIN Handelssignal — gemessen ~Münzwurf, nur Kontext).
// Reaktionsrichtung explizit: LONG=überverkauft→nach oben, SHORT=überkauft→nach unten.
const bn = ws.bounce || {};
const rword = bn.dir === "LONG" ? "nach oben ↑" : "nach unten ↓";
const tf = bn.tf ? ` (${bn.tf})` : "";
const be = $("wave-bounce");
if (bn.state === "active") {
be.className = "bounce-ind warn strong";
be.textContent = `⚠ stark überdehnt${tf} — Reaktion ${rword} möglich`;
} else if (bn.state === "expected") {
be.className = "bounce-ind warn";
be.textContent = `⚠ überdehnt${tf} — Reaktion ${rword} möglich`;
} else {
be.className = "bounce-ind idle"; // immer sichtbar (Ruhe-Zustand)
be.textContent = "Überdehnung: keine";
}
// Volatilitäts-Squeeze-Breakout (additiver Setup, gemessen 2-Stichproben-positiv):
// active = Ausbruch aus Kompression (echtes Signal → grün/rot) · armed = komprimiert,
// Ausbruch steht bevor (grau) · sonst keine Kompression.
const sq = ws.squeeze || {};
const sqEl = $("wave-squeeze");
if (sqEl) {
const arr = sq.dir === "LONG" ? "▲" : "▼";
const bx = sq.box_atr != null ? ` · Box ${sq.box_atr}×ATR` : "";
if (sq.state === "active") {
sqEl.className = "squeeze-ind fire " + (sq.dir === "LONG" ? "long" : "short");
sqEl.textContent = `🚀 Squeeze-Breakout ${arr} ${sq.dir} @ ${fmt(sq.level, 3)}${bx}`;
} else if (sq.state === "armed") {
sqEl.className = "squeeze-ind armed";
sqEl.textContent = `🎯 Kompression${bx} — Ausbruch ${arr} steht bevor`;
} else {
sqEl.className = "squeeze-ind idle";
sqEl.textContent = "Squeeze: keine Kompression";
}
}
// TF-Ampel: M1/M5/M15/M30 grün(auf)/rot(ab); frische EMA12/50-Kreuzung
// (≤3 Bars) → blinkendes ⟳ + Richtungspfeil.
const turns = ws.tf_turns || {};
$("wave-turns").innerHTML = ["M1", "M5", "M15", "M30", "H1"].map(tf => {
const t = turns[tf] || {};
const cls = t.dir > 0 ? "up" : t.dir < 0 ? "down" : "flat";
const arr = t.dir > 0 ? "▲" : t.dir < 0 ? "▼" : "·";
const fresh = t.fresh ? " fresh" : "";
const turn = t.fresh ? `⟳${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);
// Auto-Signal-Entry: bernstein-„warn" statt grün — er ist gemessen NICHT
// tragfähig (H1 negativ) und läuft nur auf ausdrücklichen Wunsch.
const asBtn = $("btn-autosig");
if (asBtn) {
asBtn.classList.toggle("warn-on", !!d.auto_signal);
asBtn.textContent = d.auto_signal
? `🎯 SIG ${d.auto_signal_min_conf || 75}%` : "🎯 SIG";
}
$("btn-trail").classList.toggle("on", !!(d.trail || {}).enabled);
// Empfehlungs-Blinken: LONG/SHORT-Button blinkt, sobald die Welle eine
// Richtung empfiehlt (= wie in der Welle-Kachel sichtbar) und keine Position
// offen ist. Nicht mehr an die 70%-Auto-Schwelle gekoppelt.
longBtn.classList.toggle("blink", !hasPos && w.signal === "LONG");
shortBtn.classList.toggle("blink", !hasPos && w.signal === "SHORT");
// Ton bei Empfehlung (nur flat): sofort beim Signal-Wechsel, danach Erinnerung
// alle 30 s, solange der Button blinkt. Kein Ton bei offener Position.
const recSig = (!hasPos && (w.signal === "LONG" || w.signal === "SHORT"))
? w.signal : null;
if (recSig) beepRecommendation(recSig, recSig !== _lastRecSignal);
_lastRecSignal = recSig;
// CLOSE-Alarm (Blink + Ton) NUR wenn der Gewinn ÜBER dem eingestellten S/R-
// Mindestgewinn liegt (User-Vorgabe). Feld leer/0 → wie bisher: jeder Gewinn > 0.
// Deckt sich mit dem Auto-Close-Gate im Backend (unter der Schwelle schließt der
// Bot ohnehin nicht → dann auch kein Alarm/Ton).
const posLong = p.order_type === 0;
const minGain = d.sr_close_min_gain || 0;
const gainOK = hasPos && (p.pnl || 0) > minGain;
const flipClose = gainOK &&
((posLong && w.signal === "SHORT") || (!posLong && w.signal === "LONG"));
// S/R-Close-Empfehlung (gemessen, nur das robuste Segment): Kurs AM Level +
// schwacher Anlauf → Level hält ~74 % → Close schlägt Laufenlassen. Text-Hinweis
// in der Position-Kachel immer; Blink/Ton nur über dem Mindestgewinn.
const srHint = d.sr_close_hint || null; // vorhanden = Kurs am Level
const srAuto = !!d.auto_sr_close;
const srClose = !!(srHint && srHint.close); // P(break) < Schwelle → schließen
const closeAlert = flipClose || (gainOK && srClose);
closeBtn.classList.toggle("blink", closeAlert);
if (closeAlert) beepCloseAlert(); // Blinken + Ton (gedrosselt, alle 4 s)
const hintEl = $("pos-srhint");
if (hintEl) {
if (hasPos && srHint) {
const lvlName = posLong ? "Widerstand" : "Support";
// Bestätigungs-Preis (level±0,5×ATR, dieselbe Definition wie beim Modell-
// Training): ein kurzer Docht über die Levelzahl ist noch KEIN Durchbruch.
const confirmTxt = srHint.confirm != null
? ` (Bestätigung erst ab ${fmt(srHint.confirm, 3)})` : "";
if (!srHint.at_level) {
// Vorwarnung: Kurs nähert sich dem Level (0,15–0,6×ATR davor).
// Schwelle dynamisch aus der Config (sr_close_pbreak), nicht hart 60.
const thr = (d.sr_close_pbreak || 0.60) * 100;
hintEl.className = "pos-srhint approach";
hintEl.textContent = `🎯 Nähert sich ${lvlName} ${fmt(srHint.level, 3)} ` +
`(${srHint.gap_atr}×ATR) · Durchbruch ${srHint.p_break}%${confirmTxt}` +
(srHint.p_break < thr ? " → Bot schließt dort (im Plus)" : " → dürfte durchlaufen");
} else if (srClose) {
hintEl.className = "pos-srhint warn";
const tail = (srAuto && gainOK) ? "🤖 Bot schließt automatisch" : "Close erwägen";
hintEl.textContent = `🎯 Am ${lvlName} ${fmt(srHint.level, 3)} · Durchbruch nur ` +
`${srHint.p_break}%${confirmTxt} → prallt wahrscheinlich ab → ${tail}`;
} else {
hintEl.className = "pos-srhint run";
hintEl.textContent = `🎯 Am ${lvlName} ${fmt(srHint.level, 3)} · Durchbruch ` +
`${srHint.p_break}%${confirmTxt} wahrscheinlich → läuft weiter`;
}
} else {
hintEl.className = "pos-srhint hidden";
hintEl.textContent = "";
}
}
// Gegen-/Stop-Seiten-Hinweis (REINE ANZEIGE, kein Auto-Close): Kurs läuft auf das
// Level GEGEN die Position zu → bricht es durch (Verlust läuft) oder prallt es ab?
// P(break)-Modell auf der Gegen-Seite gemessen kalibriert (AUC 0,72 o.o.s.),
// im hohen Bereich leicht konservativ. Nur zeigen, wenn KEIN Ziel-Level-Hint läuft.
const stopEl = $("pos-stophint");
if (stopEl) {
const sa = d.stop_approach || null;
if (hasPos && sa && !srHint) {
const lvlName = sa.side === "sup" ? "Support" : "Widerstand";
const dir = sa.side === "sup" ? "nach unten" : "nach oben";
const confirmTxt = sa.confirm != null ? ` (Bruch ab ${fmt(sa.confirm, 3)})` : "";
const holds = sa.p_break < 50;
stopEl.className = "pos-stophint " + (holds ? "hold" : "break");
const lead = sa.at_level ? `Am ${lvlName}` : `Nähert sich ${lvlName}`;
stopEl.textContent =
`ℹ️ ${lead} ${fmt(sa.level, 3)} (${sa.gap_atr}×ATR) · Durchbruch ${dir} ` +
`${sa.p_break}%${confirmTxt} → ` +
(holds ? "Level hält wahrscheinlich" : "bricht wahrscheinlich durch");
} else {
stopEl.className = "pos-stophint hidden";
stopEl.textContent = "";
}
}
// News-Konflikt-Chip (2026-07-24, realer Auslöser 23.07.: zwei Shorts gegen einen
// +0,98-Sentiment-Burst): |Score|≥0,5 UND Richtung gegen offene Position bzw.
// gegen das aktive Signal → Warnung. Reiner Kontext, Prädiktivität unbelegt.
const ncEl = $("news-conflict");
if (ncEl) {
const ns = d.news_sentiment;
let txt = "";
if (ns != null && Math.abs(ns) >= 0.5) {
const newsDir = ns > 0 ? "LONG" : "SHORT";
const newsWord = ns > 0 ? "bullisch" : "bärisch";
if (hasPos) {
const posDir = posLong ? "LONG" : "SHORT";
if (posDir !== newsDir)
txt = `📰 News-Flow stark ${newsWord} (${ns > 0 ? "+" : ""}${fmt(ns, 2)}) — steht gegen deine ${posDir}-Position`;
} else if (w.signal === "LONG" || w.signal === "SHORT") {
if (w.signal !== newsDir)
txt = `📰 News-Flow stark ${newsWord} (${ns > 0 ? "+" : ""}${fmt(ns, 2)}) — steht gegen das ${w.signal}-Signal`;
}
}
if (txt) { ncEl.className = "news-conflict warn"; ncEl.textContent = txt; }
else { ncEl.className = "news-conflict hidden"; ncEl.textContent = ""; }
}
// Wirtschaftskalender (2026-07-30): laufendes News-Fenster bzw. Vorwarnung ≤60 min.
// ⚠ NICHT gemessen — Handbuch-Regel; der Bot pausiert im Fenster nur seine
// AUTONOMEN Entries, manuelle Orders bleiben frei.
const calEl = $("cal-note");
if (calEl) {
const c = d.calendar || {};
let t = "";
if (c.blackout) {
const m = c.blackout.minutes;
t = `📅 News-Fenster: ${c.blackout.title} ${m > 0 ? "in " + Math.round(m) + " min" : "vor " + Math.round(-m) + " min"} — Bot eröffnet gerade keine Auto-Trades`;
} else if (c.next && c.next.minutes <= 60) {
t = `📅 ${c.next.title} in ${c.next.minutes} min`;
}
if (t) { calEl.className = "news-conflict warn"; calEl.textContent = t; }
else { calEl.className = "news-conflict hidden"; calEl.textContent = ""; }
}
// Auto-Flip-Close sichtbar machen (2026-08-05). Dieser Exit schliesst echte
// Positionen, ist aber in BEIDEN Halbjahren gemessen NEGATIV (−92/−160 R,
// backtest_flipclose2.py) und lief bis heute voellig ohne Anzeige. Die Zeile
// nennt bewusst die Messung mit — sonst sieht ein Zaehler nach Erfolg aus.
const flipEl = $("flip-note");
if (flipEl) {
if (d.flip_close) {
const n = d.flip_close_count || 0;
const r = d.flip_close_min_r;
flipEl.className = "news-conflict warn";
flipEl.textContent = `🔄 Auto-Flip-Close AKTIV (ab ${r != null ? r : "?"}×ATR im Plus)`
+ ` — bisher ${n}× ausgelöst · ⚠ gemessen in beiden Halbjahren negativ`;
flipEl.title = "Schliesst die Position, wenn die Empfehlung dreht. "
+ "backtest_flipclose2.py: −92 R (H1) / −160 R (H2) gegenueber Nichtstun. "
+ "Laeuft auf ausdruecklichen Wunsch; abschalten via [trading] auto_flip_close=false.";
} else {
flipEl.className = "news-conflict hidden"; flipEl.textContent = "";
}
}
// Schalter-Zustand: Auto-Close am S/R an/aus + Mindestgewinn
const srBtn = $("srclose-btn");
if (srBtn) {
srBtn.textContent = srAuto ? "AN" : "AUS";
srBtn.title = srAuto ? "Bot schließt am Level (im Plus)" : "nur Hinweis, kein Auto-Close";
srBtn.classList.toggle("on", srAuto);
}
const srMin = d.sr_close_min_gain || 0;
const smi = $("srmin-input");
if (smi && document.activeElement !== smi) {
smi.value = srMin > 0 ? String(srMin) : "";
}
// Feste Einsatz-Margin (0/leer = automatisch). Nicht überschreiben, während
// getippt wird — sonst löscht der 1-s-Render die Eingabe unter den Fingern weg.
const mgi = $("margin-input");
if (mgi && document.activeElement !== mgi) {
const mm = d.manual_margin || 0;
mgi.value = mm > 0 ? String(mm) : "";
mgi.classList.toggle("on", mm > 0);
}
// Einsatz in Prozent der freien Margin. Ausgegraut, sobald links ein fester
// Betrag steht — der hat Vorrang, und ohne den Hinweis wäre unklar, was gilt.
const mpi = $("marginpct-input");
if (mpi && document.activeElement !== mpi) {
const mp = d.margin_pct || 0;
mpi.value = mp > 0 ? String(mp) : "";
const fest = (d.manual_margin || 0) > 0;
mpi.classList.toggle("on", mp > 0 && !fest);
const wrap = $("marginpct-wrap");
if (wrap) {
wrap.classList.toggle("muted", fest);
wrap.title = fest
? `Ohne Wirkung: links steht ein fester Einsatz (${fmt(d.manual_margin, 2)} €). `
+ `Der Prozentsatz (${mp} %) bleibt nur die Obergrenze.`
: "Einsatz als Prozent der freien Margin (1–99). "
+ "Greift, solange links kein fester Betrag steht.";
}
}
// Einblendung: der Bot hat am S/R automatisch geschlossen (Zähler stieg)
const src = d.sr_close_count;
if (src != null) {
if (_lastSrCloseCount != null && src > _lastSrCloseCount) {
toast("🎯 Bot hat am Level geschlossen (schwacher Anlauf, Gewinn gesichert)", true);
}
_lastSrCloseCount = src;
}
// ── Letzte Trades ───────────────────────
// 5 → 10 (User-Wunsch 2026-08-04). Das Backend liefert bereits 10
// (`engine.snapshot` → `last_closed_trades(10)`); dieser Slice ist die zweite,
// unabhängige Begrenzung — beide müssen zusammenpassen.
const tds = (d.last_trades || []).slice(0, 10);
$("trades").innerHTML = tds.map(x => {
const pnl = x.pnl || 0;
const cls = pnl > 0 ? "up" : pnl < 0 ? "down" : "dim";
const arr = (x.direction || "").toUpperCase() === "BUY" ? "▲" : "▼";
const ts = (x.exit_time || x.entry_time || 0) * 1000;
const hm = ts ? new Date(ts).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" }) : "—";
const cb = (x.closed_by || "").toUpperCase().slice(0, 2) || "—";
return `
${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
let d;
try {
d = await (await fetch("api/snapshot")).json();
} catch (e) { return; } // still — Netz-/WS-Aussetzer, dafür IST der Fallback da
// ⚠ NICHT mit ins try oben (Fix 2026-08-01): der Poll-`catch` umschloss früher
// auch `_applySnapshot` → `render`. Ein Render-Fehler wurde damit LAUTLOS
// verschluckt: der Poll feuerte weiter alle 4 s, jeder Durchlauf starb still,
// das Dashboard fror auf dem letzten guten Stand ein und die Konsole blieb
// leer. Genau diese Kombination hat eine Fehlersuche unnötig lang gemacht.
// Netzfehler bleiben still (die sind erwartbar), Render-Fehler werden laut.
_applySnapshot(d);
}
setInterval(pollSnapshot, POLL_MS);
// ── 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") {
// ── Entry-Checkliste (User-Vorgabe 2026-07-30): zeigt VOR der Eröffnung,
// was dagegen spricht — auch dann, wenn ein Signal da ist (der alte Dialog
// kam nur bei WARTEN/Gegen-Signal). Blockt nicht, macht nur sichtbar.
const cl = (lastSnap.checklist || {})[side];
const info = recInfo(side);
if (cl && (cl.fails > 0 || cl.warns > 0)) {
const ICON = { fail: "❌", warn: "⚠", ok: "✅" };
const bad = cl.items.filter(x => x.state !== "ok");
const good = cl.items.filter(x => x.state === "ok");
let body = bad.map(x => `${ICON[x.state]} ${x.label}`
+ (x.detail ? `\n ${x.detail}` : "")).join("\n");
if (good.length) body += `\n\n${good.map(x => `✅ ${x.label}`).join("\n")}`;
// Eigene Zahlen zur Ausrichtung ergänzen (gemessene Kern-Leckage)
const al = lastSnap.alignment || null;
const sigNow = (lastSnap.wave_signal || {}).signal || "WARTEN";
const isWait0 = (sigNow === "WARTEN" || sigNow === "—");
if (al && cl.items.some(x => x.key === "signal" && x.state === "fail")) {
const grp = isWait0 ? al.none : al.against, ref = al.with;
// ⚠ JE LOT ist die belastbare Zahl: absolute Euro-Vergleiche sind bei
// wechselnder Positionsgroesse ungueltig (Lehre 04.08. — ein reiner
// Groessenzuwachs sah wie ein Strategie-Effekt aus). Seit 06.08. laeuft
// der Split ueber die VOLLE Historie (1.216 Trades) statt ueber 40.
const f = g => {
if (!g || g.n < 5) return null;
const lot = (g.pnl_lot === null || g.pnl_lot === undefined) ? ""
: ` · ${g.pnl_lot >= 0 ? "+" : ""}${fmt(g.pnl_lot, 2)} €/Lot`;
return `${g.wr}% Treffer${lot} · ${g.pnl >= 0 ? "+" : ""}${fmt(g.pnl, 0)} € (n${g.n})`;
};
const own = f(grp), rf = f(ref);
if (own) body += `\n\n📊 Deine Trades ${isWait0 ? "OHNE" : "GEGEN"} Signal: ${own}`;
if (rf) body += `\n📊 Mit Signal: ${rf}`;
}
const head = cl.verdict === "stop"
? `🛑 Checkliste sagt NEIN (${cl.fails} Punkte dagegen)`
: `⚠ Checkliste: ${cl.fails} dagegen, ${cl.warns} Vorsicht`;
const ok = await modal(head, body + `\n\n${side.toUpperCase()} trotzdem eröffnen?`,
"confirm", true);
if (!ok) return;
} else if (info.danger) { // Fallback: alter Hinweis (kein Block)
const sig = (lastSnap.wave_signal || {}).signal || "WARTEN";
const isWait = (sig === "WARTEN" || sig === "—");
const head = isWait
? "ℹ Keine Empfehlung (WARTEN)"
: `ℹ Empfehlung steht GEGEN dich (${sig})`;
// Ehrliche EIGENE Zahlen (letzte 40 Trades, Ausrichtungs-Split aus der DB):
// die gemessene Kern-Leckage sind genau diese diskretionären Abweichungen.
let statLine = "";
const al = lastSnap.alignment || null;
if (al) {
const grp = isWait ? al.none : al.against;
const ref = al.with;
const f = g => (g && g.n >= 5)
? `${g.wr}% Trefferquote · ${g.pnl >= 0 ? "+" : ""}${fmt(g.pnl, 0)} € (n${g.n})`
: null;
const own = f(grp), rf = f(ref);
if (own) statLine += `\n\n📊 Deine Trades ${isWait ? "OHNE Signal" : "GEGEN das Signal"}: ${own}`;
if (rf) statLine += `\n📊 Mit Signal: ${rf}`;
}
const ok = await modal(head,
info.line + statLine + `\n\n${side.toUpperCase()} trotzdem eröffnen?`,
"confirm", true);
if (!ok) return; // Abbrechen = doch nicht; sonst läuft er
}
}
if (side === "close") { // Sicherheitsabfrage bei Verlust
const p = lastSnap.position || {};
const pnl = p.pnl || 0;
if (p.ticket && pnl < 0) {
const cs = curSym(lastSnap.market || {});
const ok = await modal(
"⚠ Position im Minus schließen?",
`Aktueller Verlust: ${cs}${fmt(pnl, 2)}\n\nWirklich jetzt mit Verlust schließen?`,
"confirm", true);
if (!ok) return;
}
}
const tok = await ensureToken();
if (tok === null) return; // null = Token abgebrochen ("" = kein Token nötig)
try {
const res = await sendOrder(side, tok);
res.ok ? toast(`${side.toUpperCase()} ausgeführt`, true)
: toast(`Fehler: ${res.error || "unbekannt"}`, false);
} catch (e) {
toast(e.message === "401" ? "Token abgelaufen — neu eingeben" : "Netzwerkfehler", false);
}
};
});
// (Notfall-Stop-UI entfernt 2026-07-17 — POST /api/emergency existiert weiter, ohne UI.)
// („Gewinn mitnehmen"-UI entfernt — POST /api/takeprofit existiert weiter, ohne UI.)
// Manuelles SL/TP (Trade-Bereich) — sendet beide Felder; deaktiviert das Trailing.
async function sendSltp() {
// ⚠ STRENG (s. `$streng` oben): hier ist „Feld fehlt" NICHT „Feld ist leer".
// Leer heisst ausdruecklich „Stop entfernen" (`sl: null`) — mit dem
// Auffang-Element waere `.value` `undefined`, `parseFloat` liefert NaN, und
// `JSON.stringify` macht daraus wieder `null`. Eine fehlende Eingabemaske
// wuerde so den Broker-SL einer laufenden Position stillschweigend loeschen.
const elSl = $streng("tb-sl"), elTp = $streng("tb-tp");
if (!elSl || !elTp) {
toast("SL/TP-Felder nicht geladen — Seite neu laden (Strg+F5)", false);
return;
}
const slv = elSl.value, tpv = elTp.value;
const tok = await ensureToken();
if (tok === null) return;
elSl.blur(); elTp.blur();
try {
const r = await fetch("api/sltp", { method: "POST",
headers: { "X-Auth-Token": tok, "Content-Type": "application/json" },
body: JSON.stringify({ sl: slv === "" ? null : parseFloat(slv),
tp: tpv === "" ? null : parseFloat(tpv) }) });
if (r.status === 401) { clearToken(); throw new Error("401"); }
const j = await r.json();
if (j.ok) toast(`SL/TP gesetzt${j.trail_was_on ? " · Trailing AUS" : ""}`, true);
else toast(j.error || "Broker lehnte ab", false);
} catch (e) {
toast(e.message === "401" ? "Token abgelaufen — neu eingeben" : "Netzwerkfehler", false);
}
}
["tb-sl", "tb-tp"].forEach(id => {
$(id).addEventListener("change", sendSltp);
$(id).addEventListener("keydown", e => {
if (e.key === "Enter") { e.preventDefault(); sendSltp(); }
});
});
// Mute-Button (oben rechts): alle akustischen Signale an/aus
function _updateMuteBtn() {
const b = $("mute-btn");
if (b) { b.textContent = _muted ? "🔇" : "🔊"; b.classList.toggle("muted", _muted); }
}
$("mute-btn").onclick = () => {
_muted = !_muted;
localStorage.setItem("oil_muted", _muted ? "1" : "0");
_updateMuteBtn();
toast(_muted ? "🔇 Alle Töne aus" : "🔊 Töne an", true);
};
_updateMuteBtn();
// Toggle-Buttons (AUTO/TRAIL/S-R-Close) — ohne Bestätigung
$("btn-squeeze").onclick = () => toggle("api/autosqueeze", "Auto-Squeeze (autonom!)");
$("btn-autosig").onclick = () => toggle("api/autosignal", "Auto-Signal-Entry (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(); }
});
// Feste Einsatz-Margin (User-Wunsch 2026-08-04). Leer/0 = automatisch.
// ⚠ Wirkt auf ALLE neuen Positionen, auch die autonomen — deshalb sagt der Toast
// ausdrücklich, was gilt, statt nur „gespeichert".
async function setManualMargin() {
const el = $("margin-input"); if (!el) return;
const raw = el.value;
const v = raw === "" ? 0 : parseFloat(raw);
if (isNaN(v) || v < 0) { toast("Bitte Betrag ≥ 0 eingeben (0 = automatisch)", false); return; }
const tok = await ensureToken();
if (tok === null) return;
el.blur();
try {
const r = await fetch("api/manualmargin", { method: "POST",
headers: { "X-Auth-Token": tok, "Content-Type": "application/json" },
body: JSON.stringify({ value: v }) });
if (r.status === 401) { clearToken(); throw new Error("401"); }
const j = await r.json();
toast(v > 0 ? `Einsatz fest: ${fmt(j.value ?? v, 2)} € je Position`
: "Einsatz wieder automatisch (95 % der freien Margin)", true);
} catch (e) {
toast(e.message === "401" ? "Token abgelaufen — neu eingeben" : "Netzwerkfehler", false);
}
}
(() => {
const el = $("margin-input");
if (!el) return; // null-sicher (Cache-Regel)
el.addEventListener("change", setManualMargin);
el.addEventListener("keydown", e => {
if (e.key === "Enter") { e.preventDefault(); e.target.blur(); }
});
})();
// Einsatz in PROZENT der freien Margin (User-Wunsch 2026-08-05) — das Gegenstück
// zum festen Betrag oben. Steuert `margin_buffer_pct`, das bisher nur in der ini
// stand. ⚠ Ein gesetzter fester Betrag hat Vorrang; darauf weist der Toast hin.
async function setMarginPct() {
const el = $("marginpct-input"); if (!el) return;
const v = parseFloat(el.value);
if (isNaN(v) || v < 1 || v > 99) {
toast("Bitte 1–99 % eingeben (100 % ließe keinen Puffer für Spread/Swap)", false);
return;
}
const tok = await ensureToken();
if (tok === null) return;
el.blur();
try {
const r = await fetch("api/marginpct", { method: "POST",
headers: { "X-Auth-Token": tok, "Content-Type": "application/json" },
body: JSON.stringify({ value: v }) });
if (r.status === 401) { clearToken(); throw new Error("401"); }
const j = await r.json();
if (j.ok === false) { toast(j.error || "abgelehnt", false); return; }
const fest = parseFloat(($("margin-input") || {}).value || "0") > 0;
toast(`Einsatz: ${fmt(j.value ?? v, 0)} % der freien Margin`
+ (fest ? " — greift erst, wenn der feste Betrag links leer ist" : ""), true);
} catch (e) {
toast(e.message === "401" ? "Token abgelaufen — neu eingeben" : "Netzwerkfehler", false);
}
}
(() => {
const el = $("marginpct-input");
if (!el) return; // null-sicher (Cache-Regel)
el.addEventListener("change", setMarginPct);
el.addEventListener("keydown", e => {
if (e.key === "Enter") { e.preventDefault(); e.target.blur(); }
});
})();
// Notfall-Stop: manueller Verlust-Wert, bei dem der Server sofort schließt.
// Wieder eingeblendet 2026-07-22 (Backend/Persistenz waren die ganze Zeit aktiv).
async function setEmg() {
const raw = $("emg-input").value;
const v = raw === "" ? 0 : parseFloat(raw);
if (isNaN(v) || v < 0) { toast("Bitte Betrag ≥ 0 eingeben (0 = aus)", false); return; }
const tok = await ensureToken();
if (tok === null) return;
$("emg-input").blur();
try {
const r = await fetch("api/emergency", { method: "POST",
headers: { "X-Auth-Token": tok, "Content-Type": "application/json" },
body: JSON.stringify({ value: v > 0 ? v : null }) });
if (r.status === 401) { clearToken(); throw new Error("401"); }
toast(v > 0 ? `Notfall-Stop bei −${v} gesetzt` : "Notfall-Stop aus", true);
} catch (e) {
toast(e.message === "401" ? "Token abgelaufen — neu eingeben" : "Netzwerkfehler", false);
}
}
$("emg-input").addEventListener("change", setEmg);
// Enter blurrt nur (kein doppeltes Senden, kein ungewolltes 0 auf leerem Feld —
// derselbe Fix wie beim Mindestgewinn-Feld, 2026-07-22).
$("emg-input").addEventListener("keydown", e => {
if (e.key === "Enter") { e.preventDefault(); e.target.blur(); }
});
async function toggle(url, label) {
const tok = await ensureToken();
if (tok === null) return; // null = abgebrochen ("" = kein Token nötig)
try {
const res = await postAuth(url, tok);
toast(`${label} ${res.enabled ? "AN" : "AUS"}`, true);
} catch (e) {
toast(e.message === "401" ? "Token abgelaufen — neu eingeben" : "Netzwerkfehler", false);
}
}
// ── Modul-Menü / Views ─────────────────────────────────────────────────────
function showView(name) {
document.querySelectorAll(".view").forEach(v => v.classList.add("hidden"));
const el = document.getElementById("view-" + name);
if (el) el.classList.remove("hidden");
document.querySelectorAll(".tab").forEach(t =>
t.classList.toggle("active", t.dataset.view === name));
if (name === "logs") loadLogs();
if (name === "stats") loadStats();
if (name === "news") loadNews();
}
// Nur echte View-Tabs registrieren — NICHT den Mute-Button (der trägt .tab nur
// fürs Styling; ohne [data-view]-Filter überschrieb das hier seinen onclick und
// showView(undefined) blendete alle Views aus).
document.querySelectorAll(".tab[data-view]").forEach(t => t.onclick = () => showView(t.dataset.view));
async function loadLogs() {
const el = $("logs-body");
el.textContent = "lade …";
try {
const d = await (await fetch("api/logs?lines=300")).json();
if (d.error) { el.textContent = "Fehler: " + d.error; return; }
el.textContent = (d.lines || []).join("\n");
el.scrollTop = el.scrollHeight;
} catch (e) { el.textContent = "Netzwerkfehler"; }
}
function statBlock(title, s) {
const cs = curSym(lastSnap.market || {});
if (!s || s.error)
return `
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.