Files
AH-Oil-Trader/web/app.js
T
Axel HocksandClaude Opus 5 33b6b8f629 Letzte Trades: 5 -> 10 (User-Wunsch)
Zwei unabhaengige Begrenzungen mussten mitgezogen werden:
- engine.snapshot -> history.last_closed_trades(5) -> (10)
- app.js -> slice(0, 5) -> slice(0, 10)
Nur eine davon zu aendern haette still weiter 5 gezeigt; beide sind jetzt
gegenseitig im Kommentar vermerkt.

Verifiziert: Snapshot liefert 10 Trades, v=135 ausgeliefert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 17:10:31 +02:00

1629 lines
80 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;
// ── 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;
if (sig === "WARTEN" && bo && bo.pending && bo.dir) {
head += ` · ${bo.dir}, noch ${fmt(bo.need, 2)} bis zur Bestätigung`;
}
$("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,320,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;
let consensus;
if (sig === "—") {
consensus = "keine Daten";
} else if (!refDir || !total) {
consensus = `Modul-Konsens: ${strength} ${dir} · kein Modul mit klarer Aussage`;
} else {
consensus = `Modul-Konsens: ${strength} ${dir} · ${agree} von ${total} `
+ `stimmberechtigten für ${refDir}`
+ (silent ? ` (${silent} ohne Aussage)` : "");
}
$("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", "—"];
$("vd-modules").innerHTML = (v.votes || []).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 ? `<span class="m-w">${w.toFixed(1).replace(".0", "")}</span>` : "";
return `<div class="vd-mod ${cls}"><div class="m-top">` +
`<span class="m-name">${mod.name}</span>${wtag}` +
`<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)})` : "";
// ⚠ Die zwei Gap-Arten unterscheidbar machen (2026-08-03): nur `vacuum`
// speist die Wellen-Konfidenz (darauf wurde gemessen), `close_open` ist
// reine Anzeige. Ohne den Hinweis sähen beide gleich verbindlich aus.
const co = x.kind === "close_open";
const tag = co
? `<span class="dim" title="Schluss→Eröffnung — reine Anzeige, fließt NICHT in die Konfidenz">Eröffnung</span>`
: `<span class="dim" title="Vakuum zwischen den Tagesspannen — speist die S/R-Magnete">Vakuum</span>`;
return `<div class="gap-row"><span class="${up ? "up" : "down"}">${arr} ${x.dir.toUpperCase()}</span>`
+ `<span class="gap-zone">${fmt(x.lo)}${fmt(x.hi)}</span>`
+ `<span class="gap-fill">schließt bei <b>${fmt(x.fill)}</b>${ds}</span>`
+ tag
+ `<span class="dim gap-date">${x.date}</span></div>`;
}).join("");
}
// Manuelle Trades (2026-08-04) — DESKRIPTIV. Zeigt die eigene Historie, macht
// keine Vorhersage: der volle Entscheidungskontext wird erst seit dem 04.08.
// mitgeschrieben, vorher wären Aussagen daraus Rauschen. Die Reifegrad-Zeile
// nennt die Abdeckung ehrlich, damit die Karte nicht überschätzt wird.
function renderManual(m) {
const grid = $("mt-grid"); if (!grid) return; // null-sicher (Cache-Regel)
if (!m || !m.gesamt) { grid.innerHTML = '<span class="dim">—</span>'; return; }
const cls = v => v > 0 ? "up" : v < 0 ? "down" : "";
const box = (lbl, b) => !b || !b.n ? "" :
`<div class="mt-box"><div class="mt-lbl">${lbl}</div>`
+ `<div class="mt-val ${cls(b.sum)}">${b.sum >= 0 ? "+" : ""}${fmt(b.sum, 2)}</div>`
+ `<div class="mt-sub2">${b.n} Trades · ${fmt(b.wr, 1)} % · Ø ${b.avg >= 0 ? "+" : ""}${fmt(b.avg, 2)}</div></div>`;
grid.innerHTML = box("heute", m.heute) + box(`${m.tage} Tage`, m.fenster)
+ box("gesamt", m.gesamt) + box("Bot (Vergleich)", m.bot);
// Signal-Deckung: die dokumentierte Kern-Frage — trägt Handeln OHNE Empfehlung?
const sp = $("mt-split");
if (sp) {
const z = (lbl, b) => !b || !b.n ? "" :
`<span class="mt-chip"><b>${lbl}</b> ${b.n}× · ${fmt(b.wr, 1)} % · `
+ `<span class="${cls(b.sum)}">${b.sum >= 0 ? "+" : ""}${fmt(b.sum, 2)}</span></span>`;
sp.innerHTML = z("mit Signal", m.mit_signal) + z("ohne Signal", m.ohne_signal);
}
const nt = $("mt-note");
if (nt) {
const c = m.ctx_abdeckung || {};
nt.textContent = `Entscheidungs-Kontext gespeichert für ${c.mit_ctx || 0} von `
+ `${c.n || 0} Trades (${fmt(c.pct || 0, 1)} %). Erst ab ausreichender `
+ `Abdeckung lässt sich messen, WORAN gute von schlechten Einstiegen zu `
+ `unterscheiden sind — bis dahin ist diese Karte reine Beschreibung.`;
}
}
// Marktstruktur (M30) — reine Anzeige (HH/HL/LH/LL, BOS, Regressionskanal).
function renderStructure(s) {
s = s || {};
const TR = { up: ["▲ Aufwärts", "up"], down: ["▼ Abwärts", "down"], range: ["→ Seitwärts", "dim"] };
const [txt, cls] = TR[s.trend] || ["—", "dim"];
const tEl = $("ms-trend");
tEl.textContent = txt; tEl.className = "ms-trend " + cls;
// Swing-Chips: HH/HL = bullisch (grün), LH/LL = bärisch (rot)
const chips = (s.swings || []).map(sw => {
const bull = sw.type === "HH" || sw.type === "HL";
const c = (sw.type === "H" || sw.type === "L") ? "dim" : (bull ? "up" : "down");
return `<span class="ms-chip ${c}" title="${fmt(sw.price)}">${sw.type}</span>`;
}).join('<span class="ms-arrow"></span>');
$("ms-swings").innerHTML = chips || '<span class="dim">—</span>';
// BOS
const b = s.bos;
const bEl = $("ms-bos");
if (b) {
const up = b.dir === "up";
bEl.innerHTML = `<span class="ms-bos-lbl">BOS</span>`
+ `<span class="${up ? "up" : "down"}">${up ? "▲ über" : "▼ unter"} ${fmt(b.level)}</span>`
+ `<span class="dim">vor ${b.bars_ago} Bars</span>`;
bEl.classList.remove("hidden");
} else { bEl.classList.add("hidden"); }
// Kanal: Richtung + Position (0 = unten, 1 = oben)
const ch = s.channel;
const CD = { up: ["Aufwärtskanal", "up"], down: ["Abwärtskanal", "down"], flat: ["Seitwärtskanal", "dim"] };
if (ch) {
const [ctxt, ccls] = CD[ch.dir] || ["Kanal", "dim"];
const dEl = $("ms-chan-dir");
dEl.textContent = ctxt; dEl.className = ccls;
const pct = Math.round((ch.pos != null ? ch.pos : 0.5) * 100);
$("ms-chan-fill").style.width = pct + "%";
$("ms-chan-dot").style.left = pct + "%";
const zone = pct <= 25 ? "am unteren Rand" : pct >= 75 ? "am oberen Rand" : "in der Mitte";
$("ms-chan-note").textContent = `Kurs ${zone} (${pct}%)`;
$("card-structure").classList.remove("hidden");
} else {
$("ms-chan-dir").textContent = "—";
$("ms-chan-note").textContent = "";
$("ms-chan-fill").style.width = "0%";
}
}
// Circuit Breaker — roter Banner wenn ausgelöst, bernstein wenn nah (>60% des Limits).
function renderCircuitBreaker(cb) {
const el = $("circuit-banner");
if (!el) return;
if (!cb || !cb.enabled) { el.className = "circuit hidden"; el.textContent = ""; return; }
const limit = cb.limit_eur, day = cb.day_total;
if (cb.tripped) {
el.className = "circuit tripped";
el.textContent = `🛑 CIRCUIT BREAKER AKTIV — Tagesverlust ${fmt(day, 2)} € erreicht ` +
`(Limit ${fmt(limit, 0)} €). Keine Auto-Trades mehr heute.`;
} else if (limit && day < 0 && Math.abs(day) >= 0.6 * limit) {
el.className = "circuit near";
el.textContent = `⚠ Tagesverlust ${fmt(day, 2)} € — Stopp bei ${fmt(limit, 0)} € ` +
`(${Math.round(100 * Math.abs(day) / limit)} % erreicht)`;
} else {
el.className = "circuit hidden"; el.textContent = "";
}
}
// Chartmuster (visuell) — reine Anzeige. Status: bildet sich / bestätigt / (ungültig raus).
// Kompakte SVG-Skizze der Muster-Form (viewBox 60×32). Rein schematisch, damit die
// Form auf einen Blick erkennbar ist; Farbe = Richtung (grün bull / rot bear / grau).
function _patternSvg(type, col) {
const P = {
double_top: "M2,26 L14,8 L22,16 L34,8 L46,26",
double_bottom: "M2,6 L14,24 L22,16 L34,24 L46,6",
hs: "M2,24 L12,14 L18,20 L28,4 L38,20 L44,14 L54,24",
inv_hs: "M2,8 L12,18 L18,12 L28,28 L38,12 L44,18 L54,8",
cup: "M2,6 C2,30 44,30 44,6 M44,6 L54,12",
inv_cup: "M2,26 C2,2 44,2 44,26 M44,26 L54,20",
tri_asc: "M2,26 L52,26 M2,4 L52,22",
tri_desc: "M2,6 L52,6 M2,28 L52,10",
tri_sym: "M2,4 L52,16 M2,28 L52,16",
};
const d = P[type] || "M2,16 L54,16";
return `<svg class="pt-svg" viewBox="0 0 56 32" width="56" height="32" aria-hidden="true">
<path d="${d}" fill="none" stroke="${col}" stroke-width="2"
stroke-linejoin="round" stroke-linecap="round"/></svg>`;
}
// Kerzen-Anatomie (M5). ⚠ Bewusst OHNE grün/rot-Richtungsfarbe: die Lehrbuch-
// Deutung ist gemessen invertiert (`backtest_candles.py` — langer Körper läuft eher
// GEGEN sich, Volumen verstärkt das statt zu bestätigen), eine Ampel würde also
// systematisch in die falsche Richtung stupsen. Dieselbe Korrektur wie bei der
// Bounce-Anzeige, die deshalb heute „Überdehnung" heißt.
function renderCandles(ca) {
const el = $("ca-list");
if (!el) return;
const items = (ca && ca.items) || [];
if (!items.length) {
el.innerHTML = `<span class="dim">Keine markante Kerzenform in den letzten Bars.</span>`;
return;
}
el.innerHTML = items.map(m => {
const arr = m.dir > 0 ? "▲" : m.dir < 0 ? "▼" : "◆";
const ago = m.bars_ago === 1 ? "letzte Kerze" : `vor ${m.bars_ago} Kerzen`;
const vol = m.vol_x
? `<span class="ca-vol${m.hot ? " hot" : ""}">Vol ${fmt(m.vol_x, 1)}×</span>` : "";
return `<div class="ca-item${m.hot ? " hot" : ""}">
<div class="ca-head"><span class="ca-name">${arr} ${m.label}</span>
${vol}<span class="ca-ago">${ago}</span></div>
<div class="ca-bars">Körper ${m.body_pct}% · Docht oben ${m.wick_up_pct}%
· unten ${m.wick_lo_pct}%</div>
<div class="ca-note">${m.reading || ""}</div>
</div>`;
}).join("") +
`<div class="pt-disclaimer">🕯 Reine Anzeige — KEIN Signal. Gemessen
(<code>backtest_candles.py</code>, 80k Bars, 2 Halbjahre): die gängige Deutung ist
<b>invertiert</b> — der lange Körper läuft eher GEGEN sich (0,06/0,08×ATR), mit
2× Volumen noch stärker (0,12/0,20). Als Handelssignal durchgefallen
(<code>backtest_candle_fade.py</code>).</div>`;
}
// Wahrscheinlichkeits-Kegel. ⚠ Zeigt STREUUNG, nicht Richtung — und beschriftet
// mit der REAL gemessenen Abdeckung (~76 %), nicht mit dem Nennwert 80 %. Der Kegel
// ist out-of-sample geprüft (analyze_cone.py, Fit H1 → Abdeckung H2) und damit nach
// dem P(break)-Modell die zweite kalibrierte Komponente im Projekt.
function renderCone(cn) {
const el = $("cone-list");
if (!el) return;
const lv = (cn && cn.levels) || [];
if (!lv.length) {
el.innerHTML = `<span class="dim">—</span>`;
return;
}
const byMin = {};
lv.forEach(x => { (byMin[x.minutes] = byMin[x.minutes] || {})[x.band] = x; });
el.innerHTML = Object.keys(byMin).sort((a, b) => a - b).map(mn => {
const b80 = byMin[mn][80], b50 = byMin[mn][50];
const cov = b80 && b80.coverage != null ? `${fmt(b80.coverage, 0)}%` : "~";
return `<div class="cone-row">
<span class="cone-h">${mn} min</span>
<span class="cone-band">${b80 ? `${fmt(b80.lo, 2)}${fmt(b80.hi, 2)}` : "—"}
<i>trifft ${cov}</i></span>
<span class="cone-inner">${b50 ? `enger: ${fmt(b50.lo, 2)}${fmt(b50.hi, 2)}` : ""}</span>
</div>`;
}).join("") +
`<div class="pt-disclaimer">📐 <b>Streuung, keine Richtung.</b> Die Spanne sagt, wie
WEIT der Kurs plausibel läuft — nicht wohin. Out-of-sample geprüft
(<code>analyze_cone.py</code>, Fit auf einem Halbjahr, Abdeckung im anderen
gemessen): das nominale 80 %-Band trifft real ~76 %, ist also leicht zu eng.
Kein Signal, kein Einfluss auf die Empfehlung.</div>`;
}
function renderPatterns(pt) {
const el = $("pt-list");
if (!el) return;
const items = (pt && pt.patterns) || [];
if (!items.length) {
el.innerHTML = `<span class="dim">Aktuell kein klares Muster erkannt.</span>`;
return;
}
el.innerHTML = items.map(m => {
const dirCls = m.dir === "bull" ? "up" : m.dir === "bear" ? "down" : "dim";
const col = m.dir === "bull" ? "#3fb950" : m.dir === "bear" ? "#f85149" : "#8b949e";
const arr = m.dir === "bull" ? "▲" : m.dir === "bear" ? "▼" : "◆";
const st = m.status === "confirmed"
? `<span class="pt-badge conf">bestätigt</span>`
: `<span class="pt-badge form">bildet sich</span>`;
const tgt = m.target != null
? `Ziel <b>${fmt(m.target, 2)}</b>` : `Ausbruch offen`;
const q = Math.round((m.quality || 0) * 100);
return `<div class="pt-item ${dirCls}">
<div class="pt-head">${_patternSvg(m.type, col)}
<span class="pt-name">${arr} ${m.name}</span>${st}
<span class="pt-q">Güte ${q}%</span></div>
<div class="pt-det">Trigger <b>${fmt(m.trigger, 2)}</b> · ${tgt}
· Höhe ${m.height_atr}×ATR · vor ${m.bars_ago} Bars</div>
<div class="pt-note">${m.note || ""}</div>
</div>`;
}).join("") +
`<div class="pt-disclaimer">📊 Reine Anzeige (mechanisch aus M30-Pivots) — KEIN Signal,
kein Einfluss auf die Empfehlung. Muster-Klasse im Projekt als Signal noch unbelegt;
Einfluss erst nach Backtest.</div>`;
}
function render(d) {
// ── Header ──────────────────────────────
$("conn").className = "dot " + (d.connected ? "on" : "off");
$("symbol").textContent = d.symbol || "—";
const m = d.market || {};
// ── Server- (Broker) + Lokalzeit ────────
const _p2 = (n) => String(n).padStart(2, "0");
// server_time ist Broker-Wallclock, als UTC etikettiert → getUTC* zeigt sie 1:1
const srv = m.server_time ? new Date(m.server_time) : null;
const srvStr = (srv && !isNaN(srv))
? `${_p2(srv.getUTCHours())}:${_p2(srv.getUTCMinutes())}:${_p2(srv.getUTCSeconds())}`
: "—";
$("hdr-clock").textContent = `Srv ${srvStr} · Lok ${new Date().toLocaleTimeString("de-DE")}`;
// ── Börsen-Session ──────────────────────
const ses = d.session || {};
let stxt = ses.phase || "—";
if (ses.next_open) stxt += ` · ${ses.next_open.name}-Open in ${ses.next_open.in_min} min`;
// 🕘 Prime-Time 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}`;
// 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) ──────────────────────
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);
// 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,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 = "";
}
}
// 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 = ""; }
}
// Schalter-Zustand: Auto-Close am S/R an/aus + Mindestgewinn
const srBtn = $("srclose-btn");
if (srBtn) {
srBtn.textContent = srAuto ? "AN" : "AUS";
srBtn.title = srAuto ? "Bot schließt am Level (im Plus)" : "nur Hinweis, kein Auto-Close";
srBtn.classList.toggle("on", srAuto);
}
const srMin = d.sr_close_min_gain || 0;
const smi = $("srmin-input");
if (smi && document.activeElement !== smi) {
smi.value = srMin > 0 ? String(srMin) : "";
}
// Einblendung: der Bot hat am S/R automatisch geschlossen (Zähler stieg)
const src = d.sr_close_count;
if (src != null) {
if (_lastSrCloseCount != null && src > _lastSrCloseCount) {
toast("🎯 Bot hat am Level geschlossen (schwacher Anlauf, Gewinn gesichert)", true);
}
_lastSrCloseCount = src;
}
// ── Letzte Trades ───────────────────────
// 5 → 10 (User-Wunsch 2026-08-04). Das Backend liefert bereits 10
// (`engine.snapshot` → `last_closed_trades(10)`); dieser Slice ist die zweite,
// unabhängige Begrenzung — beide müssen zusammenpassen.
const tds = (d.last_trades || []).slice(0, 10);
$("trades").innerHTML = tds.map(x => {
const pnl = x.pnl || 0;
const cls = pnl > 0 ? "up" : pnl < 0 ? "down" : "dim";
const arr = (x.direction || "").toUpperCase() === "BUY" ? "▲" : "▼";
const ts = (x.exit_time || x.entry_time || 0) * 1000;
const hm = ts ? new Date(ts).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" }) : "—";
const cb = (x.closed_by || "").toUpperCase().slice(0, 2) || "—";
return `<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
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;
const f = g => (g && g.n >= 5)
? `${g.wr}% Treffer · ${g.pnl >= 0 ? "+" : ""}${fmt(g.pnl, 0)} € (n${g.n})` : null;
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() {
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-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(); }
});
// 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: 15 },
grid: { vertLines: { color: "#10151c" }, horzLines: { color: "#10151c" } },
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, "");
});
// Chartmuster-Overlay (nur M30): Trigger = kräftige Linie (grün bull / rot bear),
// Ziel = gepunktete Linie gleicher Farbe. Nur die 2 stärksten, sonst wird's voll.
(d.patterns || []).slice(0, 2).forEach(p => {
const bull = p.dir === "bull";
const col = bull ? "#3fb950" : (p.dir === "bear" ? "#f85149" : "#8b949e");
c.lines.push(c.candle.createPriceLine({
price: p.trigger, color: col, lineWidth: 2, lineStyle: 0,
axisLabelVisible: true, title: (p.status === "confirmed" ? "▶ " : "◌ ") + p.name }));
if (p.target != null)
c.lines.push(c.candle.createPriceLine({
price: p.target, color: col, lineWidth: 1, lineStyle: 1,
axisLabelVisible: true, title: "Ziel" }));
});
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);