Files
AH-Oil-Trader/web/app.js
T
Axel HocksandClaude Opus 5 5684589878 FIX: SL/TP-Feld rundete auf 2 Nachkommastellen - Stop verschob sich beim Speichern
User: "SL war auf 84,86 laut Dashboard" (nach dem Close von T=50360258).

ZWEI BEFUNDE, und der zweite ist der eigentliche:

(1) Die 84,86 gehoerten zum NAECHSTEN Trade. Der geschlossene T=50360258 hatte
    SL 84,776; 26 s spaeter lief bereits T=50360505 mit SL 84,863. Kein Fehler,
    nur Verwechslung durch die schnelle Folge.

(2) ABER dabei faellt ein echter Anzeigefehler auf: das SL/TP-Feld der
    Trade-Leiste formatierte mit `fmt(x, 2)`. WTI notiert DREISTELLIG - aus dem
    echten Stop 84,776 wurde die Anzeige 84,78.
    ⚠⚠ Das ist nicht kosmetisch: das Feld ist BESCHREIBBAR, und ein Enter darauf
    schickt den gerundeten Wert per /api/sltp an den Broker. Der Stop verschiebt
    sich also still um bis zu 0,5 Cent - bei einem Trail von 11 Cent (ATR_M5
    0,1104 in dieser Nacht) sind das 5 % der Distanz. Zusaetzlich schaltet
    /api/sltp das Trailing ab, der Nutzer wuerde also einen leicht falschen Stop
    festschreiben und die Nachfuehrung verlieren.

Behoben auf 3 Stellen. Gegengeprueft, dass es kein weiteres Preisfeld mit
`, 2)` gibt.

Reine Frontend-Aenderung (v=189), kein Neustart. node --check und die neue
Render-Probe (Stufe B2) gruen.

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

2186 lines
112 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) ──────────
// ⚠⚠ 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;
// ⚠ DREI kurze Pulse auf GLEICHER Tonhoehe (2026-08-19). Vorher war es ein
// fallender Zweiklang 880→620 -- und die SHORT-Empfehlung ist 780→520, also
// ebenfalls fallend und nur ~100 Hz daneben. Real verwechselt (User: "beim
// akustischen Signal blinkt der Close-Button nicht" -- es war der
// Empfehlungston, der NUR flat spielt; CLOSE blieb korrekt dunkel).
// Rhythmus statt Tonhoehe unterscheidet zuverlaessiger als 100 Hz Abstand.
_tone(1046, 0.00, 0.09);
_tone(1046, 0.14, 0.09);
_tone(1046, 0.28, 0.13);
}
// Ton bei Empfehlung: LONG = aufsteigend, SHORT = absteigend (vom Close-Alarm
// unterscheidbar). Sofort beim Signal-Wechsel + WIEDERHOLUNG alle 30 s, solange
// der Button blinkt (Empfehlung aktiv & flat) — einmaliger Piep war überhörbar.
const _REC_REBEEP_MS = 30000;
let _lastRecBeep = 0, _lastRecSignal = null, _audioHintShown = false, _lastSrCloseCount = null;
// ── Laufzeit-Uhr des offenen Trades ───────────────────────────────────────────
// Öffnungszeit kommt als echte Epoch (Broker-Offset serverseitig korrigiert) im
// Snapshot (`position.open_time`); der Ticker läuft eigenständig jede Sekunde,
// damit die Uhr auch zwischen den Snapshots (4 s Poll / 1 s WS) flüssig zählt.
let _tradeOpenTime = 0;
function _fmtDuration(sec) {
sec = Math.max(0, Math.floor(sec));
const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60;
const p2 = (n) => String(n).padStart(2, "0");
return h > 0 ? `${h}:${p2(m)}:${p2(s)}` : `${m}:${p2(s)}`;
}
function _tickTradeDuration() {
const el = document.getElementById("tb-dur"), hd = document.getElementById("tb-runtime");
if (!el) return;
if (!_tradeOpenTime) { el.textContent = "—"; if (hd) hd.textContent = ""; return; }
const sec = Date.now() / 1000 - _tradeOpenTime;
const txt = _fmtDuration(sec);
el.textContent = txt;
if (hd) hd.textContent = "⏱ " + txt;
// Ab 2 h dezent hervorheben (Time-Stop greift bei 120 min in Phase „Init")
el.className = "tb-val" + (sec >= 7200 ? " warn" : "");
}
setInterval(_tickTradeDuration, 1000);
function beepRecommendation(sig, isNew) {
if (_muted) return; // auch kein Sperr-Toast
if (!_audioCtx || _audioCtx.state !== "running") {
// Audio noch nicht freigeschaltet (Mobile braucht 1× Tippen) → 1× erklären
if (isNew && !_audioHintShown) {
_audioHintShown = true;
toast("🔇 Ton gesperrt — einmal auf die Seite tippen aktiviert ihn", false);
}
return;
}
const now = Date.now();
if (now - _lastRecBeep < (isNew ? 3000 : _REC_REBEEP_MS)) return;
_lastRecBeep = now;
if (sig === "LONG") { _tone(520, 0, 0.16); _tone(780, 0.18, 0.20); } // ↑
else { _tone(780, 0, 0.16); _tone(520, 0.18, 0.20); } // ↓
}
// ── Gesamtempfehlung: Konfidenz-Ring + Bias-Balken + Modul-Chips ───────────
// ══════════════════════════════════════════════════════════════════════════
// `renderVerdict` ENTFERNT (2026-08-08). Die Gesamtempfehlung aggregierte die
// Module zu Headline, Konfidenz-Ring, Bias-Nadel und Konsens-Zeile — gemessen
// ohne Prognosewert (4851 % Trefferquote über 1.071 Live-Episoden), und der
// Konfidenz-Ring war nicht kalibriert (in der zweiten Stichprobe invertiert).
// ⚠ Die Order-Logik hing NIE daran: Blinken, Order-Dialog und Entry-Checkliste
// lesen `wave_signal` bzw. `checklist` — das ist unberührt.
// Was in der alten Funktion nur mitlief und hier weiterlebt:
// · Selbst-Kalibrierung (`#kx-selfcal`) — misst das WELLEN-Signal, das bleibt
// · HL-Kontext (`#hl-note`) — schreibt in die Meldungen-Karte, nicht hierher
// Entfallen ist die Block-Grund-Zeile (`#vd-blocks`).
// ══════════════════════════════════════════════════════════════════════════
// ══════════════════════════════════════════════════════════════════════════
// M15 SETUP-BEREITSCHAFT — sequenzielle GATE-KETTE, keine Score-Aggregation.
// [HTF-BIAS] → [KOSTEN] → [LEVEL & KEGEL] → AUSFÜHRUNG NUR AM LEVEL
// ⚠ Bewusst KEINE Richtungsaussage. Auf M15 trägt gemessen keine (Squeeze
// n=33/Nachbarn kippen, VWAP-Bänder ohne Edge). Die Karte sagt, ob die
// BEDINGUNGEN stimmen — der Auslöser bleibt beim Menschen.
// ⚠ Der Unterschied zur entfernten Gesamtempfehlung ist grundsätzlich: dort
// wurden Stimmen zu einem Bias VERRECHNET (gemessen 4851 % Treffer), hier
// muss jedes Gate EINZELN passieren. Ein Gate, das fällt, lässt sich nicht
// durch ein anderes ausgleichen.
// ══════════════════════════════════════════════════════════════════════════
// Breakout-Autotrader-Karte (2026-08-19). Nur ANZEIGE aus vorhandenen
// Snapshot-Feldern -- hier wird nichts neu gerechnet.
// Die Karte bleibt VERSTECKT, solange `squeeze_b5` fehlt (aelterer Server, das
// Feld gibt es erst nach einem Neustart). Eine halb gefuellte Karte waere
// schlimmer als keine: sie saehe aus wie ein Zustand statt wie eine fehlende
// Quelle.
function renderBrk(d) {
const box = $streng("card-brk");
if (!box) return;
// --- Slot 2: die vom Breakout-Trader gehaltene Position -------------------
// Ohne diese Zeile waere eine offene BRK-Position im Dashboard unsichtbar --
// genau Deployment-Drift Fall 8 (dort driftete nicht die Strategie, sondern
// ihre Beobachtbarkeit) in neuer Form.
const bp = d.position_brk, bpEl = $streng("brk-pos");
if (bpEl) {
if (!bp) {
bpEl.textContent = "keine — Slot frei";
bpEl.className = "";
} else {
// Richtung aus SL/TP ableiten: place_stop setzt IMMER einen SL.
const e = Number(bp.entry) || 0;
const lg = (bp.sl && e) ? (bp.sl < e) : (bp.tp ? bp.tp > e : null);
const pfeil = lg === null ? "" : (lg ? "▲ LONG" : "▼ SHORT");
const pnl = Number(bp.pnl) || 0;
const t = [`${pfeil} ${bp.lots ?? "?"} @ ${e ? e.toFixed(3) : "?"}`,
`${pnl >= 0 ? "+" : ""}${pnl.toFixed(2)} €`];
// ⚠ Der wichtigste Teil der Zeile: ohne Trailing laeuft der Trade in der
// gemessen durchgefallenen SL-only-Variante.
t.push(bp.trail ? "Trailing an" : "⚠ TRAILING AUS — nur Broker-SL!");
bpEl.textContent = t.join(" · ");
bpEl.className = bp.trail ? (pnl >= 0 ? "pos" : "neg") : "neg";
}
}
// Einsatz je Pfad einspielen. ⚠ NICHT, solange das Feld den Fokus hat --
// sonst ueberschreibt der 1-s-Snapshot die laufende Eingabe (dieselbe Falle
// wie beim Mindestgewinn-Feld).
[["am-brk", d.margin_brk], ["am-m15", d.margin_m15],
["mg-global", d.margin_pct]].forEach(([id, v]) => {
const el = $streng(id);
if (!el) return;
// ⚠ NICHT anfassen, solange das Feld den Fokus hat -- sonst frisst der
// 1-s-Snapshot die laufende Eingabe (der Grund, warum das Feld sich
// zuerst nicht bedienen liess).
if (document.activeElement === el) return;
const soll = (v > 0) ? String(v) : "";
// ⚠ Nur schreiben, wenn sich WIRKLICH etwas aendert: eine Zuweisung setzt
// auch bei gleichem Text die Cursorposition zurueck.
if (el.value !== soll) el.value = soll;
});
const b5 = d.squeeze_b5;
if (!b5) { box.classList.add("hidden"); return; }
box.classList.remove("hidden");
const an = !!d.auto_squeeze;
const nh = (d.squeeze_night_hours || []).map((h) => h + ":00").join(", ");
$("brk-state").textContent = (an ? "AN" : "AUS")
+ (d.squeeze_pending ? " · ruhende Stop-Order" : " · Market-Fallback")
+ (nh ? " · Nachtsperre " + nh : "");
$("brk-state").className = an ? "ok" : "warn";
const sq = (d.wave || {}).squeeze || {};
const st = sq.state;
$("brk-squeeze").textContent =
st === "active" ? "AUSBRUCH " + (sq.dir || "") + " @ " + (sq.level ?? "?")
: st === "armed" ? "komprimiert — Ausbruch steht bevor"
: "keine Kompression (Box " + (sq.box_atr ?? "?") + "×ATR)";
const pl = d.pending_levels || [];
$("brk-pending").textContent = pl.length
? pl.length + " liegend (" + (d.pending_quelle || "?") + ")"
: "keine — " + (an ? "Squeeze nicht armiert oder Position offen" : "Trader aus");
// B5: der Stand der einzigen Regel, die die Autonomie beenden kann.
const r = b5.ratio, pf = b5.pf;
const erf = r != null && pf != null && r >= b5.latte && pf >= 1;
$("brk-b5").textContent = b5.n + "/" + b5.ziel + " Trades · Verhältnis "
+ (r == null ? "—" : r.toFixed(2)) + " · PF "
+ (pf == null ? "—" : pf.toFixed(2))
+ " · " + b5.sum_eur.toFixed(2) + " €";
$("brk-b5").className = erf ? "ok" : "bad";
const el = $("brk-note");
if (el) {
el.textContent = erf
? "Latte (Verhältnis ≥ 1,0 UND PF ≥ 1) erfüllt."
: "⚠ Latte verfehlt (Ziel: Verhältnis ≥ 1,0 UND PF ≥ 1). Der Monitor "
+ "im Statistik-Tab meldet daneben „ontrack“ — das kommt aus der MECHANIK "
+ "(Regel neu gerechnet), nicht aus diesen echten Trades. Die Lücke "
+ "zwischen beiden ist der Befund, kein Widerspruch.";
}
}
function renderM15(s, snap) {
// ⚠⚠ `snap` = der VOLLE Snapshot. `s` ist nur `m15_setup` — wer hier auf
// `d.` zugreift, erzeugt einen ReferenceError, der den GESAMTEN Render
// abbricht: alles ab der Fehlerstelle bleibt auf "—" stehen, und die
// danach gerufenen Renderer (u. a. `renderBrk`) laufen gar nicht mehr.
// Genau das ist am 19.08. passiert (RSI-Zeile griff auf `d.market`).
const st = $("m15-status");
if (!s) { st.className = "m15-status"; st.textContent = "— keine Daten"; return; }
const f = (v, d = 2) => (v == null ? "—" : (+v).toFixed(d));
// ── Gate 1: HTF-Bias (der einzige belegte Filter, Edge ×2) ───────────
// ⚠⚠ VETO, KEINE RICHTUNG (Korrektur 2026-08-08). Der erste Entwurf zeigte
// „Bias ▲ LONG" und erzeugte am 07.08. dreizehn LONG-Episoden an einem Tag,
// der 1,02 $ FIEL (Trefferquote 0 %). Gemessen ist nur: GEGEN den M30-Trend
// zu handeln halbiert den Edge — eine Verbots-, keine Richtungsaussage.
const b = s.bias || {};
const bOk = !!b.verboten; // Gate offen, sobald ein HTF steht
$("m15-bias").className = "m15-item " + (bOk ? "ok" : "");
$("m15-bias").textContent = bOk
? `① Veto: ${b.verboten} vermeiden (HTF ${b.m30}${b.einig ? " + H1 einig" : ""})`
: `① kein Veto — HTF flach (M30 ${b.m30 ?? "—"} / H1 ${b.h1 ?? "—"})`;
// ── Gate 2: Kosten auf M15 ───────────────────────────────────────────
const k = s.kosten || {};
const kOk = k.stufe === "guenstig" || k.stufe === "normal";
$("m15-kosten").className = "m15-item " + (k.ratio == null ? "" : kOk ? "ok" : "fail");
// ⚠ Slippage-Hinweis: gemessen steigt die Exit-Slippage monoton mit ΔATR
// (0,020 → 0,084 $, Faktor 4,2). ABER der Bootstrap auf die Differenz enthält
// die Null (n≈85 je Quartil) — deshalb HINWEIS, kein Gate, und die
// Beschriftung sagt „historisch", nicht „belegt".
const vo = s.vola || {};
const slipTxt = vo.stufe === "hoch"
? " · ⚠ Vola zieht an — Slippage historisch ~4× höher"
: vo.stufe === "ruhig" ? " · Vola ruhig" : "";
$("m15-kosten").textContent = k.ratio == null
? "② Kosten —"
: `② Kosten ${f(k.ratio, 3)}×ATR · ${
k.stufe === "guenstig" ? "günstig" : k.stufe === "normal" ? "normal" : "TEUER"}`
+ slipTxt;
// ── Gate 3: Level in Reichweite ──────────────────────────────────────
// ⚠⚠ `zOk` kommt seit 2026-08-12 aus dem BACKEND (`s.status`), nicht mehr aus
// einem eigenen Schwellenvergleich hier. Grund: die Karte wechselte den
// Zustand alle 12 Sekunden — Schwellen-Flattern an `gap ≤ 0,5`. Das Backend
// rechnet es EINMAL mit Hysterese (grün ab 0,50, zurück erst über 0,60) und
// liefert Status und Lesart mit; Anzeige und Selbstmessung können damit nicht
// mehr auseinanderlaufen (sie taten es bereits: der Logger kannte den
// „kein Veto"-Fall nicht).
const z = (s.zonen || []);
const nah = z.filter(x => x.gap_atr15 != null && x.gap_atr15 <= 0.5);
const zOk = s.status ? (s.status === "gruen") : (nah.length > 0);
$("m15-zone").className = "m15-item " + (z.length ? (zOk ? "ok" : "warn") : "");
// ⚠⚠ RICHTUNGS-LESART, aber BEDINGT auf das Level. Eine FREIE Prognose ist
// gemessen unmoeglich (`analyze_reversal.py`: AUC 0,4990,509 oos und
// **in-sample nur 0,52** — die Merkmale enthalten keine Richtungsinformation;
// Live-Richtung 4851 % ueber 1.071 Episoden). P(break) fragt dagegen nicht
// „wohin geht der Kurs", sondern „haelt DIESE Struktur?" — bedingt auf ein
// reales Objekt mit Orders dahinter, AUC 0,65 oos.
// ⚠ 4555 % bekommt BEWUSST keine Richtung: dort ist die Zahl ein Muenzwurf,
// und daraus einen Pfeil zu machen waere genau der Fehler, den die
// MQL5-Pfeile schon einmal vermieden haben.
const lesart = (x) => {
const p = x.p_break, res = x.seite === "res";
if (p == null) return "";
if (p >= 45 && p <= 55) return " → unentschieden";
const bricht = p > 55;
// Widerstand gebrochen = aufwaerts · Widerstand haelt = abwaerts
const auf = res ? bricht : !bricht;
const q = bricht ? `${p} % Bruch` : `${100 - p} % Abprall`;
return ` → ${q}, eher ${auf ? "aufwärts ▲" : "abwärts ▼"}`;
};
// ⚠⚠ ZEITEBENE MIT IM TEXT (Fix 2026-08-13). ③ liest M30-Level und misst in
// ATR_M15, ③b liest M5-Level und misst in ATR_M5. Beide standen vorher nur als
// „×ATR" da — zwei Zahlen nebeneinander (0,48 und 0,45), die vergleichbar
// AUSSAHEN und verschiedene Bezugsgroessen hatten. Die Quellen-Trennung ist
// Absicht (`entry_room_atr=0,6` ist auf M5 kalibriert), die stumme
// Beschriftung war es nicht.
// ⚠ „(Chart)" markiert, dass GENAU diese Linie im MT5-Chart gezeichnet wird —
// die M5-Level aus ③b werden dort NICHT gezeichnet.
$("m15-zone").textContent = z.length
? "③ " + z.map(x => `${x.seite === "res" ? "Widerstand" : "Support"} M30 `
+ `${f(x.level, 3)} (Chart · ${f(x.gap_atr15)}×ATR_M15)${lesart(x)}`).join(" · ")
: "③ kein Level in Sicht";
$("m15-zone").title = "GENAU diese Linie wird im MT5-Chart gezeichnet (M30-Pivots, "
+ "geclustert) — der Chart nennt seit v1.35 dieselbe Zahl mit denselben Worten. "
+ "⚠ Die Level in ③b stammen dagegen aus M5 und stehen NICHT im Chart. "
+ "Richtungs-Lesart BEDINGT auf das Level, keine freie "
+ "Prognose: eine solche ist gemessen unmöglich (AUC 0,50 out-of-sample und "
+ "nur 0,52 in-sample — es steckt keine Richtungsinformation in den Daten). "
+ "P(break) fragt stattdessen „hält diese Struktur?“ und ist out-of-sample "
+ "kalibriert (AUC 0,65). ⚠ Live derzeit rund 7 Pp zu niedrig — die Abprall-"
+ "Quote ist also eher noch etwas optimistisch. 4555 % bleibt ohne Richtung.";
// ── Reichweite (Kegel) — sagt WIE WEIT, nicht wohin ──────────────────
// ⚠ REINE GEOMETRIE: passt das Ziel noch in das 80-%-Band des jeweiligen
// Horizonts? Keine Bruch-Annahme — die wäre nicht belegt.
// ⚠⚠ Die Richtung steht seit dem Fix vom 2026-08-10 MIT im Text. Vorher war
// das Ziel rein geometrisch das entferntere Level und stand real GEGEN das
// Veto aus ② („verboten SHORT" + Ziel 0,15 $ unter dem Kurs). Ein Ziel ohne
// genannte Richtung lässt sich still wieder verdrehen — mit genannter nicht.
const zi = s.ziel;
const r = s.reichweite || [];
const spanne = r.length
? " · Spanne " + r.map(x => `${x.minutes} ${f(x.lo, 2)}${f(x.hi, 2)}`).join(" · ")
: "";
const rEl = $("m15-reich");
if (zi && zi.horizonte && zi.horizonte.length) {
const drin = zi.horizonte.filter(x => x.drin).map(x => x.minutes);
const pfeil = zi.richtung === "LONG" ? "▲" : "▼";
rEl.className = "m15-item " + (drin.length ? "ok" : "warn");
rEl.textContent = drin.length
? `④ Ziel ${pfeil} ${f(zi.level, 3)} erreichbar ab ${Math.min(...drin)} `
+ `(${zi.strecke} $ vom Kurs, Einstieg ${f(zi.von, 3)})`
: `④ Ziel ${pfeil} ${f(zi.level, 3)} liegt AUSSERHALB des 120-Kegels `
+ `(${zi.strecke} $) — CRV zeitlich nicht gedeckt`;
} else if (s.ziel_grund) {
// Kein berechenbares Ziel ist ein EIGENER Zustand, keine Lücke —
// deshalb wird der Grund genannt statt eine Zahl erfunden.
rEl.className = "m15-item";
rEl.textContent = `④ kein Ziel — ${s.ziel_grund}${spanne}`;
} else {
rEl.className = "m15-item";
rEl.textContent = r.length
? "④ erwartete Spanne · " + r.map(x => `${x.minutes} ${f(x.lo, 2)}${f(x.hi, 2)}`).join(" · ")
: "④ erwartete Spanne —";
}
// ── ③b RAUM + Motor-Zustand ──────────────────────────────────────────
// ⚠⚠ ANLASS: Trade #49671601 (10.08., 87 €). Die Karte stand auf GRÜN,
// während der Signal-Motor zu 76 % mit `entry_room` blockte — der Widerstand
// lag 0,07×ATR voraus, der Stop 4,7×ATR zurück. Die Karte prüfte, ob ein
// Level NAH ist, nie ob RAUM dahinter ist. Genau das steht jetzt hier.
// ⚠ Reine Anzeige: keine Richtung, kein Blockieren der Buttons.
const mo = s.motor || {};
const rm = mo.raum || {};
// Dieselbe Zahl zusaetzlich AUF die Order-Knoepfe (2026-08-19). Sie wird
// hier NICHT neu gerechnet — `rm` liegt bereits vor; ein zweiter Rechenweg
// waere genau die Divergenz-Falle, die im Projekt dreimal zugeschlagen hat.
for (const st of ["long", "short"]) {
const el = $("raum-" + st);
const o = rm[st];
if (!o || o.dist_atr == null) { el.textContent = ""; el.className = "btn-raum"; continue; }
const eng = o.frei === false;
el.textContent = (eng ? "⚠ " : "") + o.dist_atr.toFixed(2) + "×ATR";
el.className = "btn-raum" + (eng ? " eng" : "");
el.title = "Abstand zum naechsten Level in DIESER Richtung (M5-Pivots, "
+ "Gate " + (rm.gate ?? 0.6) + "×ATR). Unter dem Gate gemessen teuer: "
+ "79 % Trefferquote, aber 7 bis 12 €/Lot — der Gewinn ist am Level "
+ "gedeckelt, der Verlust laeuft bis zum Stop. Reine Anzeige.";
}
// --- M5-Kontext: reine ZUSTANDS-Zeile, keine Richtung, kein Banner ------
// Bewusst KEINE eigene M5-Karte (User-Entscheidung 19.08.): die entkoppelte
// Selbstmessung dieser Karte steht bei n=342 auf 53,5 % [48,2 .. 58,8] und
// schliesst die 50 nicht aus; als Handelsregel faellt sie durch. Hier steht
// deshalb nur, WIE der M5-Markt gerade aussieht -- ohne Urteil.
// --- RSI(14) M15: reine ANZEIGE ------------------------------------------
// ⚠⚠ BEWUSST NEUTRAL und OHNE Ampel. Die Lehrbuch-Lesart ist hier gemessen
// durchgefallen (backtest_rsi_v2.py, 19.08.: 24 Zellen, keine besteht, vier
// signifikant negativ). Eine gruen/rot-Ampel wuerde systematisch falsch
// stupsen - dieselbe Korrektur wie bei der Kerzen-Anatomie und der
// Ueberdehnungs-Anzeige, die aus demselben Grund "Ueberdehnung" heisst und
// nicht "Bounce".
const rsiEl = $("m15-rsi");
const rv = ((snap || {}).market || {}).rsi_m15;
if (rv == null) {
rsiEl.textContent = "RSI · —";
} else {
// Zone NENNEN, aber nicht bewerten. Kein Pfeil, keine Farbe.
const z = rv >= 70 ? "hoch" : rv <= 30 ? "tief" : "mittleres Band";
rsiEl.textContent = `RSI(14) M15 · ${rv.toFixed(1)} · ${z}`;
}
const m5El = $("m15-m5");
const m5 = s.m5;
if (m5) {
const t = [];
if (m5.squeeze === "active") {
t.push(`Squeeze AUSBRUCH ${m5.squeeze_dir || ""}`.trim());
} else if (m5.squeeze === "armed") {
t.push("Squeeze armiert" + (m5.box_atr != null ? ` (Box ${m5.box_atr}×ATR)` : ""));
} else {
t.push("keine Kompression"
+ (m5.box_atr != null ? ` (Box ${m5.box_atr}×ATR)` : ""));
}
if (m5.kosten != null) {
// Schwellen aus backtest_realcosts.py: Median 0,193 - ab 0,32 Kostenfalle.
const k = m5.kosten;
t.push(`Kosten ${k.toFixed(2)}×ATR_M5 ${k <= 0.20 ? "günstig" : k < 0.32 ? "normal" : "⚠ teuer"}`);
}
if (m5.atr != null) t.push(`ATR ${Number(m5.atr).toFixed(3)}`);
m5El.textContent = "M5 · " + t.join(" · ");
} else {
m5El.textContent = "M5 · —";
}
const moEl = $("m15-motor");
const seite = (o, lbl) => {
if (!o) return null;
if (o.level == null) return `${lbl} frei`;
return `${lbl} ${o.dist_atr}×ATR_M5${o.frei ? "" : " ⚠"}`;
};
if (mo.block) {
// Der Motor blockt — das ist die Information, die gefehlt hat.
const gr = {
entry_room: "kein Raum zum Gegenlevel",
breakout_pending: "Bestätigung läuft noch",
min_conf: `Konfidenz ${mo.conf_pct ?? "?"} % zu schwach`,
htf_counter: "gegen den M30-Trend",
stretch: "überdehnt",
deadband: "Totband — kein Trend",
eia: "EIA-Blackout",
dead_hour: "gesperrte Stunde",
stale: "Daten veraltet",
no_data: "keine Daten"
}[mo.block] || mo.block;
moEl.className = "m15-item " + (mo.block === "entry_room" ? "warn" : "");
moEl.textContent = `③b Motor blockt: ${gr}`;
} else if (mo.signal) {
moEl.className = "m15-item ok";
moEl.textContent = `③b Motor frei · ${mo.signal}`;
} else {
moEl.className = "m15-item";
moEl.textContent = "③b Motor —";
}
// ⚠ „M5" und „×ATR_M5" ausgeschrieben: diese Level stammen aus einer ANDEREN
// Zeitebene als ③ (M30) und werden im Chart NICHT gezeichnet. Ohne den Zusatz
// sucht man die genannte Linie dort vergeblich.
const teile = [seite(rm.long, "▲"), seite(rm.short, "▼")].filter(Boolean);
if (teile.length) {
moEl.textContent += ` · Raum ${teile.join(" / ")}`
+ (rm.gate ? ` (Gate ${rm.gate})` : "");
}
moEl.title = "Was der Signal-Motor GERADE blockt, plus der Raum zum nächsten "
+ "Gegenlevel je Richtung. ⚠ Quelle sind M5-Pivots und ATR_M5 — eine ANDERE "
+ "Zeitebene als ③ (M30-Level, ATR_M15), und diese Linien werden im Chart NICHT "
+ "gezeichnet. Das ist Absicht: entry_room=0,6 ist über 80k Bars auf M5 "
+ "kalibriert. Die beiden ×ATR-Zahlen sind deshalb NICHT vergleichbar. "
+ "Dieselbe Rechnung wie das Gate selbst, kein "
+ "Nachbau. Unter 0,6×ATR ist der Ertrag am Level gedeckelt: gemessen "
+ "ØR 0,119/0,067 bei PF 0,42/0,66 — Trefferquote hoch und trotzdem "
+ "Verlust. ⚠ Gate ③ liest dieselbe Nähe als GUT (ruhende Order AM Level), "
+ "entry_room als SCHLECHT (Markt-Einstieg mit dem Level als Hindernis). "
+ "Beides stimmt — für verschiedene Trades. Blockt nichts.";
rEl.title = "Reine Geometrie auf dem Kegel: liegt das Ziel im 80-%-Band des "
+ "Horizonts? Das Ziel wird NUR in der von ② erlaubten Richtung gebildet — "
+ "ein rein geometrisches Ziel zeigte am 10.08. auf die verbotene Seite. "
+ "Beim Ausbruchs-Einstieg gibt es kein berechenbares Ziel: _draw_levels "
+ "liefert bewusst nur die nächste Linie je Seite.";
// ── Veto: die eigene Bilanz, JE LOT ──────────────────────────────────
const v = s.veto || {};
const g = v.against || {};
$("m15-veto").className = "m15-item " + (g.n >= 5 ? "warn" : "");
$("m15-veto").textContent = g.n >= 5
? `⑤ gegen das Signal: ${g.n} Trades, ${f(g.pnl_lot, 2)} € je Lot`
: "⑤ Veto: noch zu wenige Trades";
// ── ⑥ Selbstmessung: die Karte zeigt ihre EIGENE Trefferquote ────────
// ⚠ Gegen 50 % lesen. Gemessen wird die BEDINGTE Lesart am Level, nicht
// eine freie Prognose — die ist gemessen unmöglich (AUC 0,52 in-sample).
// ⚠⚠ ENTKOPPELT seit 2026-08-12. Vorher stand hier die ROHE Quote der letzten
// 200 Zeilen — bei ~990 Zustandswechseln/Tag sind das fünf Stunden derselben
// Marktlage, vielfach gezählt. Angezeigt wurden dadurch **60 %**, entkoppelt
// sind es **53 %**. Dieselbe Cluster-Falle hat im Projekt schon eine
// Fälligkeitsbedingung um das Zehnfache danebenliegen lassen.
// ⚠ `n` ist jetzt die ENTKOPPELTE Zahl (max 1 je Level+Stunde), `roh` die
// gelesene — beide werden genannt, damit die Schrumpfung sichtbar bleibt.
const tk = s.m15_track || {};
const tEl = $("m15-track");
if (tk.n >= 20 && tk.wr30 != null) {
tEl.className = "m15-item " + (tk.wr30 >= 55 ? "ok" : tk.wr30 <= 45 ? "warn" : "");
tEl.textContent = `⑥ eigene Trefferquote (${tk.n} unabh. Zustände`
+ (tk.roh ? ` aus ${tk.roh}` : "") + `): ${tk.wr30} % auf 30`
+ (tk.wr60 != null ? ` · ${tk.wr60} % auf 60` : "")
+ ` · Ø ${tk.avg30 == null ? "—" : (tk.avg30 >= 0 ? "+" : "") + f(tk.avg30, 3)}×ATR`;
} else {
tEl.className = "m15-item";
tEl.textContent = `⑥ eigene Trefferquote: sammelt noch`
+ (tk.n ? ` (${tk.n} von 20 ausgewertet)` : "")
+ (tk.offen ? ` · ${tk.offen} offen` : "");
}
// ── ⑦ Ausführungsqualität — der einzige gemessen grosse Hebel ────────
const eg = s.entry_gap || {};
const eEl = $("m15-exec");
if (eg.n_seit_umbau != null) {
const genug = eg.n_seit_umbau >= (eg.soll || 12);
eEl.className = "m15-item " + (genug ? "ok" : "");
eEl.textContent = `⑦ Ausführung: ${eg.n_seit_umbau} Bot-Trades seit dem `
+ `Stop-Order-Umbau (Prüfung ab ${eg.soll || 12})`;
} else {
eEl.className = "m15-item";
eEl.textContent = "⑦ Ausführung —";
}
// ── Status: die Kette wird der Reihe nach ausgewertet ────────────────
// ⚠ Das Banner nennt AUSDRUECKLICH keine Richtung. Es sagt, ob die
// BEDINGUNGEN fuer eine Order am Level stehen — und was verboten ist.
// ⚠⚠ KLARTEXT-KOPFZEILE (2026-08-11, User: „noch etwas vereinfachen, wie:
// jetzt long/short zu xxx Prozent"). Der Wunsch ist berechtigt — die Karte
// war sieben Zeilen Technik. Umgesetzt ist er aber NICHT als „LONG zu 78 %":
// · eine RICHTUNGS-Prognose ist gemessen ein Münzwurf (4851 % über 1.071
// Live-Episoden, `analyze_signal_live.py`; frei sogar AUC 0,52 IN-sample),
// · und die Prozentzahl war als `conf_pct` NICHT kalibriert, in H2 sogar
// invertiert (85100 % → 40 % Trefferquote, `analyze_verdict_calibration`).
// Genau daran ist die Gesamtempfehlung gescheitert und wurde entfernt.
// Die Zeile sagt deshalb, WAS ZU TUN IST, und nennt die EINE kalibrierte
// Prozentzahl, die es gibt: P(break) des Levels, an dem eingestiegen würde
// (AUC 0,65 oos). Die ist eine Aussage über die STRUKTUR, nicht über den Trade.
// ⚠ Bewusst KEINE Änderung der Gate-Semantik — nur die Formulierung. Ob der
// Raum (③b) das Banner auf gelb ziehen soll, ist ungemessen und bleibt offen.
const erl = b.verboten === "SHORT" ? "LONG" : b.verboten === "LONG" ? "SHORT" : null;
const zi2 = s.ziel;
const pTxt = (lvl) => {
const zz = (s.zonen || []).find(x => x.level === lvl);
if (!zz || zz.p_break == null) return "";
if (zz.p_break >= 45 && zz.p_break <= 55) return " · Level unentschieden";
return zz.p_break > 55
? ` · bricht zu ${zz.p_break} %`
: ` · hält zu ${100 - zz.p_break} %`;
};
let cls, txt;
if (!kOk && k.ratio != null) {
cls = "rot";
txt = `🔴 JETZT: nicht handeln — Spread frisst den Edge (${f(k.ratio)}×ATR)`;
} else if (!erl) {
// Ohne Veto ist keine Richtung ausgeschlossen — und eine zu nennen wäre
// wieder eine Prognose. Die Karte sagt dann ehrlich nichts.
cls = "gelb";
txt = "🟡 JETZT: kein Trend-Veto — die Karte gibt hier keine Richtung";
} else if (!zOk) {
cls = "gelb";
const w = nah.length ? "" : ` · nächstes Level ${f((z[0] || {}).gap_atr15)}×ATR entfernt`;
txt = `🟡 JETZT: warten auf das Level${w} · erlaubt wäre ${erl}`;
} else {
cls = "gruen";
const einstieg = zi2 ? zi2.von : nah[0].level;
const art = zi2 ? "Abpraller" : "Ausbruch";
txt = `🟢 JETZT: ${erl}-Order bei ${f(einstieg, 3)} (${art})${pTxt(einstieg)}`
+ (zi2 ? ` · Ziel ${f(zi2.level, 3)}` : "")
+ ` · ⛔ ${b.verboten} meiden`;
}
st.className = "m15-status " + cls;
st.textContent = txt;
st.title = "Klartext-Zusammenfassung der Gate-Kette darunter. ⚠ Bewusst KEINE "
+ "Angabe wie 'LONG zu 78 %': die Richtung ist über 1.071 Live-Episoden mit "
+ "4851 % Trefferquote gemessen (Münzwurf), und die frühere Konfidenz-Prozent"
+ "zahl war nicht kalibriert — in der zweiten Hälfte sogar invertiert. "
+ "Genannt wird nur P(break) des Einstiegs-Levels: out-of-sample kalibriert "
+ "(AUC 0,65), eine Aussage über die STRUKTUR, nicht über den Trade — live "
+ "derzeit rund 7 Pp zu niedrig. 'Erlaubt' heisst NICHT 'empfohlen': belegt "
+ "ist nur, dass die Gegenrichtung kostet (6,60 € je Lot, beidhälftig). "
+ "⚠ Die Order-Buttons bleiben bedienbar — hartes Blockieren ist verworfen, "
+ "Übersteuerungen des Users liefen gemessen 68 % Trefferquote.";
}
function renderKontext(snap) {
// ── Selbst-Kalibrierung: die Empfehlung zeigt ihre eigene Trefferquote ──
// ⚠ Gegen 50 % zu lesen, nicht gegen 0 — es ist eine Richtungsaussage.
// Ausgangsstand (analyze_signal_live.py, 1.071 Episoden): 4851 %.
const trkEl = $("kx-selfcal");
const tr = snap && snap.rec_track;
if (tr && tr.n >= 20 && tr.wr30 != null) {
const d = (x) => (x == null ? "—" : (x >= 0 ? "+" : "") + fmt(x, 3));
const cls = tr.wr30 >= 55 ? "up" : tr.wr30 <= 45 ? "down" : "";
trkEl.className = "kx-selfcal " + cls;
trkEl.textContent =
`🎯 Trefferquote der Empfehlung (letzte ${tr.n} Episoden): ${tr.wr30} % auf 30`
+ (tr.wr60 != null ? ` · ${tr.wr60} % auf 60` : "")
+ ` · Ø ${d(tr.avg30)}×ATR`;
trkEl.title = "Selbst-Kalibrierung: jede gerichtete Empfehlung wird nach "
+ "30 und 60 Minuten gegen den echten Kurs ausgewertet. Gegen 50 % lesen, "
+ "nicht gegen 0. Gemessener Ausgangsstand über 1.071 Episoden: 4851 %.";
} else {
trkEl.className = "kx-selfcal dim";
trkEl.textContent = `🎯 Trefferquote der Empfehlung: sammelt noch`
+ (tr && tr.n ? ` (${tr.n} von 20 Episoden ausgewertet)` : "");
trkEl.title = "Jede gerichtete Empfehlung wird nach 30/60 Minuten gegen "
+ "den echten Kurs ausgewertet. Ab 20 ausgewerteten Episoden erscheint "
+ "die Quote hier.";
}
// ── HL-Kontext (Funding / OI / Premium) — reine Anzeige ───────────────
// ⚠ Gemessen ohne Prognosewert (analyze_hl_funding.py): entüberlappt hält
// kein Bucket die 2-Stichproben-Regel. Schreibt in die MELDUNGEN-Karte.
const hlEl = $("hl-note");
const h = snap && snap.hl_ctx;
if (h && h.premium != null) {
const fr = (h.funding != null) ? (h.funding * 100).toFixed(4) + " %" : "—";
const oi = (h.open_interest != null)
? (h.open_interest / 1e6).toFixed(2) + " Mio" : "—";
hlEl.classList.remove("hidden");
hlEl.textContent = `⛓ Hyperliquid: Perp ${h.premium >= 0 ? "+" : ""}`
+ `${fmt(h.premium, 3)} $ zum Oracle · Funding ${fr}/h · OI ${oi}`;
hlEl.title = "Kontext vom Hyperliquid-Öl-Perp (xyz:CL). Positives Premium = "
+ "der Perp notiert über dem Index, Longs zahlen. ⚠ Gemessen über 5.095 "
+ "Funding-Stundenwerte (7 Monate) gegen den CFD: kein belastbarer "
+ "Prognosewert.";
} else {
hlEl.classList.add("hidden");
}
}
function renderStatsCompact(sc, m) {
sc = sc || {};
const cs = curSym(m || {});
const f2 = (v) => (v == null ? "—" : (+v).toFixed(2));
const block = (label, s) => {
if (!s || !s.n) return `<div class="stx-grp"><div class="stx-lbl">${label}</div>`
+ `<div class="dim">noch keine Trades</div></div>`;
const ratioCls = (s.ratio != null && s.ratio >= 1) ? "up" : "down";
const pfCls = (s.pf != null && s.pf >= 1) ? "up" : "down";
const pnlCls = (s.pnl >= 0) ? "up" : "down";
return `<div class="stx-grp"><div class="stx-lbl">${label} · ${s.n} Trades</div>`
+ `<div class="stx-cells">`
+ `<span class="stx-c">WR <b>${s.wr}%</b></span>`
+ `<span class="stx-c">PF <b class="${pfCls}">${f2(s.pf)}</b></span>`
+ `<span class="stx-c">Verh <b class="${ratioCls}">${f2(s.ratio)}</b></span>`
+ `<span class="stx-c">Øgew <b class="up">+${cs}${f2(s.avg_win)}</b></span>`
+ `<span class="stx-c">Øverl <b class="down">${cs}${f2(s.avg_loss)}</b></span>`
+ `<span class="stx-c">PnL <b class="${pnlCls}">${s.pnl >= 0 ? "+" : ""}${cs}${f2(s.pnl)}</b></span>`
+ `</div></div>`;
};
$("stx-row").innerHTML = block("Woche", sc.week) + block("Gesamt", sc.all);
}
function renderGaps(g, m) {
const el = $("gaps-list");
const list = (g && g.gaps) || [];
if (!list.length) {
el.innerHTML = '<span class="dim">keine offenen Gaps</span>';
return;
}
const bid = (m || {}).bid;
el.innerHTML = list.map(x => {
const up = x.dir === "up";
const arr = up ? "▲" : "▼";
const dist = bid != null ? (x.fill - bid) : null;
const ds = dist != null ? ` (${dist >= 0 ? "+" : ""}${fmt(dist)})` : "";
// ⚠ Die zwei Gap-Arten unterscheidbar machen (2026-08-03): nur `vacuum`
// speist die Wellen-Konfidenz (darauf wurde gemessen), `close_open` ist
// reine Anzeige. Ohne den Hinweis sähen beide gleich verbindlich aus.
const co = x.kind === "close_open";
const tag = co
? `<span class="dim" title="Schluss→Eröffnung — reine Anzeige, fließt NICHT in die Konfidenz">Eröffnung</span>`
: `<span class="dim" title="Vakuum zwischen den Tagesspannen — speist die S/R-Magnete">Vakuum</span>`;
return `<div class="gap-row"><span class="${up ? "up" : "down"}">${arr} ${x.dir.toUpperCase()}</span>`
+ `<span class="gap-zone">${fmt(x.lo)}${fmt(x.hi)}</span>`
+ `<span class="gap-fill">schließt bei <b>${fmt(x.fill)}</b>${ds}</span>`
+ tag
+ `<span class="dim gap-date">${x.date}</span></div>`;
}).join("");
}
// Manuelle Trades (2026-08-04) — DESKRIPTIV. Zeigt die eigene Historie, macht
// keine Vorhersage: der volle Entscheidungskontext wird erst seit dem 04.08.
// mitgeschrieben, vorher wären Aussagen daraus Rauschen. Die Reifegrad-Zeile
// nennt die Abdeckung ehrlich, damit die Karte nicht überschätzt wird.
function renderManual(m) {
const grid = $("mt-grid"); if (!grid) return; // null-sicher (Cache-Regel)
if (!m || !m.gesamt) { grid.innerHTML = '<span class="dim">—</span>'; return; }
const cls = v => v > 0 ? "up" : v < 0 ? "down" : "";
const box = (lbl, b) => !b || !b.n ? "" :
`<div class="mt-box"><div class="mt-lbl">${lbl}</div>`
+ `<div class="mt-val ${cls(b.sum)}">${b.sum >= 0 ? "+" : ""}${fmt(b.sum, 2)}</div>`
+ `<div class="mt-sub2">${b.n} Trades · ${fmt(b.wr, 1)} % · Ø ${b.avg >= 0 ? "+" : ""}${fmt(b.avg, 2)}</div></div>`;
grid.innerHTML = box("heute", m.heute) + box(`${m.tage} Tage`, m.fenster)
+ box("gesamt", m.gesamt) + box("Bot (Vergleich)", m.bot);
// Signal-Deckung: die dokumentierte Kern-Frage — trägt Handeln OHNE Empfehlung?
const sp = $("mt-split");
if (sp) {
const z = (lbl, b) => !b || !b.n ? "" :
`<span class="mt-chip"><b>${lbl}</b> ${b.n}× · ${fmt(b.wr, 1)} % · `
+ `<span class="${cls(b.sum)}">${b.sum >= 0 ? "+" : ""}${fmt(b.sum, 2)}</span></span>`;
sp.innerHTML = z("mit Signal", m.mit_signal) + z("ohne Signal", m.ohne_signal);
}
const nt = $("mt-note");
if (nt) {
const c = m.ctx_abdeckung || {};
nt.textContent = `Entscheidungs-Kontext gespeichert für ${c.mit_ctx || 0} von `
+ `${c.n || 0} Trades (${fmt(c.pct || 0, 1)} %). Erst ab ausreichender `
+ `Abdeckung lässt sich messen, WORAN gute von schlechten Einstiegen zu `
+ `unterscheiden sind — bis dahin ist diese Karte reine Beschreibung.`;
}
}
// Marktstruktur (M30) — reine Anzeige (HH/HL/LH/LL, BOS, Regressionskanal).
function renderStructure(s) {
s = s || {};
const TR = { up: ["▲ Aufwärts", "up"], down: ["▼ Abwärts", "down"], range: ["→ Seitwärts", "dim"] };
const [txt, cls] = TR[s.trend] || ["—", "dim"];
const tEl = $("ms-trend");
tEl.textContent = txt; tEl.className = "ms-trend " + cls;
// Swing-Chips: HH/HL = bullisch (grün), LH/LL = bärisch (rot)
const chips = (s.swings || []).map(sw => {
const bull = sw.type === "HH" || sw.type === "HL";
const c = (sw.type === "H" || sw.type === "L") ? "dim" : (bull ? "up" : "down");
return `<span class="ms-chip ${c}" title="${fmt(sw.price)}">${sw.type}</span>`;
}).join('<span class="ms-arrow"></span>');
$("ms-swings").innerHTML = chips || '<span class="dim">—</span>';
// BOS
const b = s.bos;
const bEl = $("ms-bos");
if (b) {
const up = b.dir === "up";
bEl.innerHTML = `<span class="ms-bos-lbl">BOS</span>`
+ `<span class="${up ? "up" : "down"}">${up ? "▲ über" : "▼ unter"} ${fmt(b.level)}</span>`
+ `<span class="dim">vor ${b.bars_ago} Bars</span>`;
bEl.classList.remove("hidden");
} else { bEl.classList.add("hidden"); }
// Kanal: Richtung + Position (0 = unten, 1 = oben)
const ch = s.channel;
const CD = { up: ["Aufwärtskanal", "up"], down: ["Abwärtskanal", "down"], flat: ["Seitwärtskanal", "dim"] };
if (ch) {
const [ctxt, ccls] = CD[ch.dir] || ["Kanal", "dim"];
const dEl = $("ms-chan-dir");
dEl.textContent = ctxt; dEl.className = ccls;
const pct = Math.round((ch.pos != null ? ch.pos : 0.5) * 100);
$("ms-chan-fill").style.width = pct + "%";
$("ms-chan-dot").style.left = pct + "%";
const zone = pct <= 25 ? "am unteren Rand" : pct >= 75 ? "am oberen Rand" : "in der Mitte";
$("ms-chan-note").textContent = `Kurs ${zone} (${pct}%)`;
$("card-structure").classList.remove("hidden");
} else {
$("ms-chan-dir").textContent = "—";
$("ms-chan-note").textContent = "";
$("ms-chan-fill").style.width = "0%";
}
}
// Circuit Breaker — roter Banner wenn ausgelöst, bernstein wenn nah (>60% des Limits).
function renderCircuitBreaker(cb) {
const el = $("circuit-banner");
if (!el) return;
if (!cb || !cb.enabled) { el.className = "circuit hidden"; el.textContent = ""; return; }
const limit = cb.limit_eur, day = cb.day_total;
if (cb.tripped) {
el.className = "circuit tripped";
el.textContent = `🛑 CIRCUIT BREAKER AKTIV — Tagesverlust ${fmt(day, 2)} € erreicht ` +
`(Limit ${fmt(limit, 0)} €). Keine Auto-Trades mehr heute.`;
} else if (limit && day < 0 && Math.abs(day) >= 0.6 * limit) {
el.className = "circuit near";
el.textContent = `⚠ Tagesverlust ${fmt(day, 2)} € — Stopp bei ${fmt(limit, 0)} € ` +
`(${Math.round(100 * Math.abs(day) / limit)} % erreicht)`;
} else {
el.className = "circuit hidden"; el.textContent = "";
}
}
// Chartmuster (visuell) — reine Anzeige. Status: bildet sich / bestätigt / (ungültig raus).
// Kompakte SVG-Skizze der Muster-Form (viewBox 60×32). Rein schematisch, damit die
// Form auf einen Blick erkennbar ist; Farbe = Richtung (grün bull / rot bear / grau).
function _patternSvg(type, col) {
const P = {
double_top: "M2,26 L14,8 L22,16 L34,8 L46,26",
double_bottom: "M2,6 L14,24 L22,16 L34,24 L46,6",
hs: "M2,24 L12,14 L18,20 L28,4 L38,20 L44,14 L54,24",
inv_hs: "M2,8 L12,18 L18,12 L28,28 L38,12 L44,18 L54,8",
cup: "M2,6 C2,30 44,30 44,6 M44,6 L54,12",
inv_cup: "M2,26 C2,2 44,2 44,26 M44,26 L54,20",
tri_asc: "M2,26 L52,26 M2,4 L52,22",
tri_desc: "M2,6 L52,6 M2,28 L52,10",
tri_sym: "M2,4 L52,16 M2,28 L52,16",
};
const d = P[type] || "M2,16 L54,16";
return `<svg class="pt-svg" viewBox="0 0 56 32" width="56" height="32" aria-hidden="true">
<path d="${d}" fill="none" stroke="${col}" stroke-width="2"
stroke-linejoin="round" stroke-linecap="round"/></svg>`;
}
// Kerzen-Anatomie (M5). ⚠ Bewusst OHNE grün/rot-Richtungsfarbe: die Lehrbuch-
// Deutung ist gemessen invertiert (`backtest_candles.py` — langer Körper läuft eher
// GEGEN sich, Volumen verstärkt das statt zu bestätigen), eine Ampel würde also
// systematisch in die falsche Richtung stupsen. Dieselbe Korrektur wie bei der
// Bounce-Anzeige, die deshalb heute „Überdehnung" heißt.
function renderCandles(ca) {
const el = $("ca-list");
if (!el) return;
const items = (ca && ca.items) || [];
if (!items.length) {
el.innerHTML = `<span class="dim">Keine markante Kerzenform in den letzten Bars.</span>`;
return;
}
el.innerHTML = items.map(m => {
const arr = m.dir > 0 ? "▲" : m.dir < 0 ? "▼" : "◆";
const ago = m.bars_ago === 1 ? "letzte Kerze" : `vor ${m.bars_ago} Kerzen`;
const vol = m.vol_x
? `<span class="ca-vol${m.hot ? " hot" : ""}">Vol ${fmt(m.vol_x, 1)}×</span>` : "";
return `<div class="ca-item${m.hot ? " hot" : ""}">
<div class="ca-head"><span class="ca-name">${arr} ${m.label}</span>
${vol}<span class="ca-ago">${ago}</span></div>
<div class="ca-bars">Körper ${m.body_pct}% · Docht oben ${m.wick_up_pct}%
· unten ${m.wick_lo_pct}%</div>
<div class="ca-note">${m.reading || ""}</div>
</div>`;
}).join("") +
`<div class="pt-disclaimer">🕯 Reine Anzeige — KEIN Signal. Gemessen
(<code>backtest_candles.py</code>, 80k Bars, 2 Halbjahre): die gängige Deutung ist
<b>invertiert</b> — der lange Körper läuft eher GEGEN sich (0,06/0,08×ATR), mit
2× Volumen noch stärker (0,12/0,20). Als Handelssignal durchgefallen
(<code>backtest_candle_fade.py</code>).</div>`;
}
// Wahrscheinlichkeits-Kegel. ⚠ Zeigt STREUUNG, nicht Richtung — und beschriftet
// mit der REAL gemessenen Abdeckung (~76 %), nicht mit dem Nennwert 80 %. Der Kegel
// ist out-of-sample geprüft (analyze_cone.py, Fit H1 → Abdeckung H2) und damit nach
// dem P(break)-Modell die zweite kalibrierte Komponente im Projekt.
// ⚠ Seit 2026-08-08 in der Karte „Marktkontext" (`#kx-cone`) — die frühere
// eigenen Karte (User-Vorgabe). Gezeigt wird nur noch das 80 %-Band je Horizont —
// das enge 50 %-Band und der Fließtext sind entfallen und stecken im `title`.
function renderCone(cn) {
const el = $("kx-cone");
if (!el) return; // null-sicher (Cache-Regel)
const lv = (cn && cn.levels) || [];
const b80 = lv.filter(x => x.band === 80)
.sort((a, b) => a.minutes - b.minutes);
if (!b80.length) { el.innerHTML = ""; return; }
const cov = b80[0].coverage != null ? `${fmt(b80[0].coverage, 0)} %` : "~";
// ⚠ ACI (2026-08-08): die Bänder waren dokumentiert 35 Pp ZU ENG (80 %
// nominal → real 76,7/76,6/75,3). Adaptive Conformal Inference justiert das
// jetzt online nach; die Skala zeigt, wie stark. 1,00 = noch unverändert.
const sk = b80.map(x => x.skala).filter(v => v != null);
const skTxt = (sk.length && sk.some(v => Math.abs(v - 1) > 0.005))
? `<span class="kx-cone-cov">ACI ×${fmt(sk.reduce((a, b) => a + b, 0) / sk.length, 2)}</span>`
: "";
el.innerHTML = `<span class="kx-cone-lbl">📐 erwartete Spanne</span>`
+ b80.map(x => `<span class="kx-cone-item"><i>${x.minutes}</i>`
+ `${fmt(x.lo, 2)}${fmt(x.hi, 2)}</span>`).join("")
+ `<span class="kx-cone-cov">trifft ${cov}</span>` + skTxt;
// Die Einordnung gehört an die Zahl, nicht in einen Absatz darunter — sonst
// liest jemand die Spanne als Richtungsprognose.
el.title = "STREUUNG, nicht Richtung: wie WEIT der Kurs plausibel läuft, nicht "
+ "wohin. 80 %-Band, out-of-sample geprüft (analyze_cone.py, Fit auf einem "
+ "Halbjahr, Abdeckung im anderen gemessen) — es trifft real ~76 %, ist also "
+ "leicht zu eng. Kein Signal, kein Einfluss auf die Empfehlung.";
}
function renderPatterns(pt) {
const el = $("pt-list");
if (!el) return;
const items = (pt && pt.patterns) || [];
if (!items.length) {
el.innerHTML = `<span class="dim">Aktuell kein klares Muster erkannt.</span>`;
return;
}
el.innerHTML = items.map(m => {
const dirCls = m.dir === "bull" ? "up" : m.dir === "bear" ? "down" : "dim";
const col = m.dir === "bull" ? "#3fb950" : m.dir === "bear" ? "#f85149" : "#8b949e";
const arr = m.dir === "bull" ? "▲" : m.dir === "bear" ? "▼" : "◆";
const st = m.status === "confirmed"
? `<span class="pt-badge conf">bestätigt</span>`
: `<span class="pt-badge form">bildet sich</span>`;
const tgt = m.target != null
? `Ziel <b>${fmt(m.target, 2)}</b>` : `Ausbruch offen`;
const q = Math.round((m.quality || 0) * 100);
return `<div class="pt-item ${dirCls}">
<div class="pt-head">${_patternSvg(m.type, col)}
<span class="pt-name">${arr} ${m.name}</span>${st}
<span class="pt-q">Güte ${q}%</span></div>
<div class="pt-det">Trigger <b>${fmt(m.trigger, 2)}</b> · ${tgt}
· Höhe ${m.height_atr}×ATR · vor ${m.bars_ago} Bars</div>
<div class="pt-note">${m.note || ""}</div>
</div>`;
}).join("") +
`<div class="pt-disclaimer">📊 Reine Anzeige (mechanisch aus M30-Pivots) — KEIN Signal,
kein Einfluss auf die Empfehlung. Muster-Klasse im Projekt als Signal noch unbelegt;
Einfluss erst nach Backtest.</div>`;
}
function render(d) {
// ── Header ──────────────────────────────
$("conn").className = "dot " + (d.connected ? "on" : "off");
$("symbol").textContent = d.symbol || "—";
const m = d.market || {};
// ── Server- (Broker) + Lokalzeit ────────
const _p2 = (n) => String(n).padStart(2, "0");
// server_time ist Broker-Wallclock, als UTC etikettiert → getUTC* zeigt sie 1:1
const srv = m.server_time ? new Date(m.server_time) : null;
const srvStr = (srv && !isNaN(srv))
? `${_p2(srv.getUTCHours())}:${_p2(srv.getUTCMinutes())}:${_p2(srv.getUTCSeconds())}`
: "—";
$("hdr-clock").textContent = `Srv ${srvStr} · Lok ${new Date().toLocaleTimeString("de-DE")}`;
// ── Börsen-Session ──────────────────────
const ses = d.session || {};
let stxt = ses.phase || "—";
if (ses.next_open) stxt += ` · ${ses.next_open.name}-Open in ${ses.next_open.in_min} min`;
// 🕘 Prime-Time 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) ───────
renderM15(d.m15_setup, d);
renderBrk(d);
renderKontext(d);
renderCircuitBreaker(d.circuit_breaker);
renderPatterns(d.patterns);
renderCandles(d.candle_anat);
renderCone(d.cone);
renderStructure(d.structure);
renderManual(d.manual_stats);
renderStatsCompact(d.stats_compact, d.market);
renderGaps(d.gaps, d.market);
// ── Position (Kurs/G/V stehen jetzt im Header) ──
const p = d.position || {};
// ── Trade-Bereich über der Gesamtempfehlung (nur bei offener Position) ──
const tb = $("trade-bar");
if (tb) {
if (p.ticket) {
tb.classList.remove("hidden");
const long = p.order_type === 0;
const dEl = $("tb-dir");
dEl.className = "tb-val tb-dir " + (long ? "up" : "down");
dEl.textContent = long ? "▲ LONG" : "▼ SHORT";
$("tb-lots").textContent = fmt(p.lots, 2);
$("tb-margin").textContent = p.margin ? cs + fmt(p.margin, 2) : "—";
// Laufzeit: Öffnungszeit merken (echte Epoch, Broker-Offset korrigiert) und
// sekündlich per Ticker anzeigen — unabhängig vom Snapshot-Intervall.
_tradeOpenTime = p.open_time ? Number(p.open_time) : 0;
_tickTradeDuration();
// SL/TP-Felder nur befüllen, wenn nicht gerade bearbeitet (kein Tipp-Konflikt)
const slEl = $("tb-sl"), tpEl = $("tb-tp");
// ⚠⚠ DREI Nachkommastellen, nicht zwei (Fix 2026-08-20, User-Fund
// "SL war auf 84,86 laut Dashboard"). WTI notiert dreistellig; mit
// `fmt(x, 2)` wurde aus dem echten Stop 84,776 die Anzeige 84,78.
// Das ist NICHT nur kosmetisch: das Feld ist beschreibbar, und ein
// Enter darauf schickt den GERUNDETEN Wert an den Broker — der Stop
// verschiebt sich also still um bis zu 0,5 Cent. Bei einem Trail von
// 11 Cent (ATR_M5 0,11 in dieser Nacht) sind das 5 % der Distanz.
if (document.activeElement !== slEl) slEl.value = p.sl ? fmt(p.sl, 3) : "";
if (document.activeElement !== tpEl) tpEl.value = p.tp ? fmt(p.tp, 3) : "";
} else {
tb.classList.add("hidden");
_tradeOpenTime = 0;
}
}
// ── Notfall-Stop: Feld wieder eingeblendet (User-Vorgabe 2026-07-22; war
// 2026-07-17 entfernt, Schutz lief nur noch über Broker-SL+Trailing — Backend
// (set_emergency/emergency_state.json) blieb die ganze Zeit aktiv/persistent).
// %-Modus (auto_emergency_pct>0): der Auto-Arm nimmt pct% der Balance → im
// Flat-Zustand diesen dynamischen Wert zeigen statt des Fixwerts.
const emg = d.emergency_loss;
const emgPct = d.emergency_pct || 0;
const emgRem = (emgPct > 0 && d.balance)
? Math.round(emgPct / 100 * d.balance * 100) / 100
: d.emergency_remember;
const ei = $("emg-input");
if (ei && document.activeElement !== ei) {
const shown = emg != null ? emg : (emgRem || "");
ei.value = shown === "" ? "" : String(shown);
}
// („Gewinn mitnehmen"-Eingabe aus dem Dashboard entfernt — Backend-Funktion
// set_takeprofit/api bleibt erhalten, nur ohne UI.)
// ── Trailing ────────────────────────────
const tr = d.trail || {};
$("trail-state").textContent = tr.enabled ? "AKTIV" : "aus";
$("trail-state").className = tr.enabled ? "up" : "dim";
$("trail-phase").textContent = tr.phase || "—";
$("trail-sl").textContent = fmt(tr.last_sl, 3);
$("trail-tp").textContent = fmt(tr.last_tp, 3);
$("trail-atr").textContent = `${fmt(tr.atr, 3)} · ${tr.atr_tf || "—"}`;
// ── Wellen-Meldungen (jetzt in „Meldungen"; Signal-Zeile entfällt — steht als
// Headline in der Gesamtempfehlung) ──────────────────────
// Setup- und Lauf-Zeile entfernt (User-Vorgabe 2026-08-06): das Setup steht
// ohnehin in der Verdict-Headline bzw. den Gründen, der „Lauf" war eine
// Beschreibung der Vergangenheit ohne Handlungsbezug.
// ⚠ `w` bleibt — es wird weiter unten noch für die Gründe-Zeile, das
// Button-Blinken, den Close-Alarm und die News-Konflikt-Meldung gebraucht.
const w = d.wave_signal || {}, ws = d.wave || {};
// Überdehnungs-Anzeige (KEIN Handelssignal — gemessen ~Münzwurf, nur Kontext).
// Reaktionsrichtung explizit: LONG=überverkauft→nach oben, SHORT=überkauft→nach unten.
const bn = ws.bounce || {};
const rword = bn.dir === "LONG" ? "nach oben ↑" : "nach unten ↓";
const tf = bn.tf ? ` (${bn.tf})` : "";
const be = $("wave-bounce");
if (bn.state === "active") {
be.className = "bounce-ind warn strong";
be.textContent = `⚠ stark überdehnt${tf} — Reaktion ${rword} möglich`;
} else if (bn.state === "expected") {
be.className = "bounce-ind warn";
be.textContent = `⚠ überdehnt${tf} — Reaktion ${rword} möglich`;
} else {
be.className = "bounce-ind idle"; // immer sichtbar (Ruhe-Zustand)
be.textContent = "Überdehnung: keine";
}
// Volatilitäts-Squeeze-Breakout (additiver Setup, gemessen 2-Stichproben-positiv):
// active = Ausbruch aus Kompression (echtes Signal → grün/rot) · armed = komprimiert,
// Ausbruch steht bevor (grau) · sonst keine Kompression.
const sq = ws.squeeze || {};
const sqEl = $("wave-squeeze");
if (sqEl) {
const arr = sq.dir === "LONG" ? "▲" : "▼";
const bx = sq.box_atr != null ? ` · Box ${sq.box_atr}×ATR` : "";
if (sq.state === "active") {
sqEl.className = "squeeze-ind fire " + (sq.dir === "LONG" ? "long" : "short");
sqEl.textContent = `🚀 Squeeze-Breakout ${arr} ${sq.dir} @ ${fmt(sq.level, 3)}${bx}`;
} else if (sq.state === "armed") {
sqEl.className = "squeeze-ind armed";
sqEl.textContent = `🎯 Kompression${bx} — Ausbruch ${arr} steht bevor`;
} else {
sqEl.className = "squeeze-ind idle";
sqEl.textContent = "Squeeze: keine Kompression";
}
}
// TF-Ampel: M1/M5/M15/M30 grün(auf)/rot(ab); frische EMA12/50-Kreuzung
// (≤3 Bars) → blinkendes ⟳ + Richtungspfeil.
const turns = ws.tf_turns || {};
$("wave-turns").innerHTML = ["M1", "M5", "M15", "M30", "H1"].map(tf => {
const t = turns[tf] || {};
const cls = t.dir > 0 ? "up" : t.dir < 0 ? "down" : "flat";
const arr = t.dir > 0 ? "▲" : t.dir < 0 ? "▼" : "·";
const fresh = t.fresh ? " fresh" : "";
const turn = t.fresh ? `<span class="tf-turn">⟳${arr}</span>` : arr;
const ago = (t.bars_ago != null && t.fresh) ? `<span class="tf-ago">${t.bars_ago}</span>` : "";
return `<span class="tf-chip ${cls}${fresh}"><b>${tf}</b> ${turn}${ago}</span>`;
}).join("");
// (S/R-Zeile im Position-Bereich ausgeblendet — die Level stehen im Chart;
// die Preis-Färbung/„nähert sich"-Warnung nutzt srl weiterhin.)
$("wave-reasons").textContent = (w.reasons || []).slice(0, 3).join(" · ");
// Elliott/FVG + TradersUnion: keine Einzel-Kacheln mehr (ausgeblendet) —
// beide fließen weiterhin in die Gesamtempfehlung (engine._verdict) ein.
// ── KI-Agent ────────────────────────────
const ag = (d.agent || {}).advisory;
if (ag) {
const b = ag.bias || "NEUTRAL";
const bc = b === "LONG" ? "long" : b === "SHORT" ? "short" : "";
const ba = b === "LONG" ? "▲" : b === "SHORT" ? "▼" : "◌";
$("agent-bias").className = "signal " + bc;
$("agent-bias").textContent =
`${ba} ${b} · ${ag.confidence}%` + (ag.timeframe ? ` · ${ag.timeframe}` : "");
$("agent-head").textContent = ag.headline || "";
$("agent-reason").textContent = ag.reasoning || "";
$("agent-risks").innerHTML = (ag.risks || []).map(r => `<li>${r}</li>`).join("");
} else {
$("agent-bias").className = "signal dim";
$("agent-bias").textContent = (d.agent || {}).busy ? "◌ analysiere …" : "◌ —";
$("agent-head").textContent = "";
$("agent-reason").textContent = (d.agent || {}).error || "";
$("agent-risks").innerHTML = "";
}
// ── Aktionsleiste: Zustände ─────────────
const hasPos = !!p.ticket;
const longBtn = document.querySelector('.btn[data-side="long"]');
const shortBtn = document.querySelector('.btn[data-side="short"]');
const closeBtn = document.querySelector('.btn[data-side="close"]');
longBtn.disabled = hasPos;
shortBtn.disabled = hasPos;
closeBtn.disabled = !hasPos;
// BRK-Schalter ENTFERNT (User 2026-08-19), dafuer der Circuit-Breaker. Der
// Auto-Squeeze-ZUSTAND bleibt in der Breakout-Karte sichtbar -- nur sein
// Schalter ist weg (jetzt POST-Endpunkt bzw. ini).
const cb = d.circuit_breaker || {};
const m15b = $streng("btn-m15");
if (m15b) {
m15b.classList.toggle("on", !!d.auto_m15);
m15b.title = d.auto_m15
? "Auto-Entry auf die gruene M15-Karte AN (" + (d.m15_entry_count || 0)
+ " Entries). ACHTUNG: gemessen negativ -- reiner Test."
: "Auto-Entry auf die gruene M15-Karte AUS (gemessen negativ).";
}
const srb = $streng("btn-sr");
if (srb) {
srb.classList.toggle("on", !!d.auto_sr_close);
srb.title = d.auto_sr_close
? "S/R-Auto-Close AN: schliesst am Level, wenn P(break) unter der Schwelle liegt."
: "S/R-Auto-Close AUS -- der Bot schliesst nicht mehr selbst am Level.";
}
const cbb = $streng("btn-cb");
if (cbb) {
cbb.classList.toggle("on", !!cb.enabled);
cbb.title = cb.enabled
? "Circuit Breaker AN: schliesst bei " + fmt(cb.limit_eur, 2) + " € ("
+ cb.limit_pct + " % der Balance). Heute: " + fmt(cb.day_total, 2) + " €."
: "Circuit Breaker AUS — kein automatischer Schutz gegen eine Verlust-Serie.";
}
// ⚠⚠ Der 🎯 SIG-Button ist am 2026-08-12 ENTFERNT (User-Entscheidung). Der
// autonome Entry auf die Empfehlung war seit dem 01.08. aus und auf BEIDEN
// Ausführungs-Achsen gemessen negativ — Markt in allen 8 Feldern, ruhende
// Order mit Touch-Fill in allen 6 und dabei schlechter als Markt. Grund
// strukturell: das Bestätigungs-Level `_pend` WANDERT.
$("btn-trail").classList.toggle("on", !!(d.trail || {}).enabled);
// Empfehlungs-Blinken: LONG/SHORT-Button blinkt, sobald die Welle eine
// Richtung empfiehlt (= wie in der Welle-Kachel sichtbar) und keine Position
// offen ist. Nicht mehr an die 70%-Auto-Schwelle gekoppelt.
longBtn.classList.toggle("blink", !hasPos && w.signal === "LONG");
shortBtn.classList.toggle("blink", !hasPos && w.signal === "SHORT");
// Ton bei Empfehlung (nur flat): sofort beim Signal-Wechsel, danach Erinnerung
// alle 30 s, solange der Button blinkt. Kein Ton bei offener Position.
const recSig = (!hasPos && (w.signal === "LONG" || w.signal === "SHORT"))
? w.signal : null;
if (recSig) beepRecommendation(recSig, recSig !== _lastRecSignal);
_lastRecSignal = recSig;
// CLOSE-Alarm (Blink + Ton) NUR wenn der Gewinn ÜBER dem eingestellten S/R-
// Mindestgewinn liegt (User-Vorgabe). Feld leer/0 → wie bisher: jeder Gewinn > 0.
// Deckt sich mit dem Auto-Close-Gate im Backend (unter der Schwelle schließt der
// Bot ohnehin nicht → dann auch kein Alarm/Ton).
const posLong = p.order_type === 0;
const minGain = d.sr_close_min_gain || 0;
const gainOK = hasPos && (p.pnl || 0) > minGain;
const flipClose = gainOK &&
((posLong && w.signal === "SHORT") || (!posLong && w.signal === "LONG"));
// S/R-Close-Empfehlung (gemessen, nur das robuste Segment): Kurs AM Level +
// schwacher Anlauf → Level hält ~74 % → Close schlägt Laufenlassen. Text-Hinweis
// in der Position-Kachel immer; Blink/Ton nur über dem Mindestgewinn.
const srHint = d.sr_close_hint || null; // vorhanden = Kurs am Level
const srAuto = !!d.auto_sr_close;
const srClose = !!(srHint && srHint.close); // P(break) < Schwelle → schließen
const closeAlert = flipClose || (gainOK && srClose);
closeBtn.classList.toggle("blink", closeAlert);
if (closeAlert) beepCloseAlert(); // Blinken + Ton (gedrosselt, alle 4 s)
const hintEl = $("pos-srhint");
if (hintEl) {
if (hasPos && srHint) {
const lvlName = posLong ? "Widerstand" : "Support";
// Bestätigungs-Preis (level±0,5×ATR, dieselbe Definition wie beim Modell-
// Training): ein kurzer Docht über die Levelzahl ist noch KEIN Durchbruch.
const confirmTxt = srHint.confirm != null
? ` (Bestätigung erst ab ${fmt(srHint.confirm, 3)})` : "";
if (!srHint.at_level) {
// Vorwarnung: Kurs nähert sich dem Level (0,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 = ""; }
}
// 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) : "";
}
// Einblendung: der Bot hat am S/R automatisch geschlossen (Zähler stieg)
const src = d.sr_close_count;
if (src != null) {
if (_lastSrCloseCount != null && src > _lastSrCloseCount) {
toast("🎯 Bot hat am Level geschlossen (schwacher Anlauf, Gewinn gesichert)", true);
}
_lastSrCloseCount = src;
}
// ── Letzte Trades ───────────────────────
// 5 → 10 (User-Wunsch 2026-08-04). Das Backend liefert bereits 10
// (`engine.snapshot` → `last_closed_trades(10)`); dieser Slice ist die zweite,
// unabhängige Begrenzung — beide müssen zusammenpassen.
const tds = (d.last_trades || []).slice(0, 10);
$("trades").innerHTML = tds.map(x => {
const pnl = x.pnl || 0;
const cls = pnl > 0 ? "up" : pnl < 0 ? "down" : "dim";
const arr = (x.direction || "").toUpperCase() === "BUY" ? "▲" : "▼";
const ts = (x.exit_time || x.entry_time || 0) * 1000;
const hm = ts ? new Date(ts).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" }) : "—";
const cb = (x.closed_by || "").toUpperCase().slice(0, 2) || "—";
return `<div class="trade"><div class="p ${cls}">${arr}${pnl >= 0 ? "+" : ""}${fmt(pnl, 2)}</div>` +
`<div class="i">${hm} ${cb}</div></div>`;
}).join("") || '<div class="dim">keine</div>';
}
// ── WebSocket mit Auto-Reconnect ───────────────────────────────────────────
let sock = null, retry = null;
// `lastTs` dedupliziert zwischen WS-Push und REST-Poll (beide liefern denselben
// `engine.snapshot_cached().ts`) — nur die jeweils NEUERE Quelle rendert, kein
// Flackern/Doppel-Render, wenn beide fast gleichzeitig eintreffen.
let lastTs = 0;
// Wann kam zuletzt ein FRISCHER Snapshot an (Wanduhr, egal aus welcher Quelle)?
// Grundlage für den 1-s-Fallback und die Stale-Erkennung unten.
let lastRecvMs = 0;
// Getrennt davon: wann kam zuletzt eine WS-NACHRICHT? Nur diese Uhr entscheidet,
// ob der Poll uebersprungen wird (s. Kommentar in `sock.onmessage`).
let lastWsMs = 0;
function _applySnapshot(d) {
if (typeof d.ts === "number" && d.ts <= lastTs) return; // schon Neueres gezeigt
if (typeof d.ts === "number") lastTs = d.ts;
lastRecvMs = Date.now();
render(d);
}
function connect() {
const proto = location.protocol === "https:" ? "wss" : "ws";
sock = new WebSocket(`${proto}://${location.host}/ws`);
sock.onmessage = (ev) => {
// ⚠ NUR hier setzen, NICHT in `_applySnapshot`. Der Poll darf die
// WS-Frische nicht mitschreiben, sonst drosselt er sich selbst: er würde
// nach jedem eigenen Abruf wieder WS_FRISCH_MS pausieren und käme bei totem
// WS nur auf ~2,9 s statt 1 s. (In der Simulation aufgefallen, s. unten.)
lastWsMs = Date.now();
try { _applySnapshot(JSON.parse(ev.data)); } catch (e) { console.warn(e); }
};
sock.onclose = () => {
$("conn").className = "dot off";
clearTimeout(retry);
retry = setTimeout(connect, 2000);
};
sock.onerror = () => sock.close();
}
// ── REST-Polling als Sicherheitsnetz (2026-07-22, User-Wunsch) ────────────────
// Zusätzlich zum WS-Push: alle POLL_MS Sekunden `/api/snapshot` abfragen. Fängt
// Fälle auf, in denen der WebSocket STILL hängt (kein onclose, keine Zustellung
// mehr — z. B. schwaches WireGuard-Signal, App-Wechsel ohne Visibility-Event),
// ohne auf einen Reconnect warten zu müssen. Der `lastTs`-Dedup verhindert
// Doppel-Renders, solange der WS normal liefert — reine Redundanz, kein Konflikt.
// ⚠⚠ 4000 → 1000 (2026-08-07, User: „G/V wird extrem lagged, 1 Sekunde").
// GEMESSEN, bevor gedreht wurde: die gesamte Server-Kette liefert bereits 1 s
// (`_pos_loop` 1 s · Snapshot-Cache 0,8 s · WS-Push **Median 1,04 s**, direkt am
// Socket nachgemessen). Der Lag entsteht also NICHT im Backend, sondern wenn der
// WebSocket am Handy still wegbricht (WireGuard-Aussetzer, App-Wechsel): dann
// trägt nur noch dieser Poll — und der stand auf 4 s.
// ⚠ Das VERDOPPELT den Verkehr NICHT: solange der WS liefert, überspringt der
// Poll den Abruf (s. `WS_FRISCH_MS`). Er feuert nur, wenn der WS schweigt.
const POLL_MS = 1000;
const WS_FRISCH_MS = 2200; // > WS-Takt (1,04 s) + Jitter, sonst pollt es doppelt
async function pollSnapshot() {
if (document.hidden) return; // seite unsichtbar → spart Akku/Daten, Wake-Handler holt beim Zurückkommen sofort nach
// Liefert der WS gerade? Dann NICHT zusätzlich abfragen — reine Redundanz
// kostet über WireGuard sonst ~14 KB/s ohne jeden Nutzen.
if (Date.now() - lastWsMs < WS_FRISCH_MS) return;
let d;
try {
d = await (await fetch("api/snapshot")).json();
} catch (e) { return; } // still — Netz-/WS-Aussetzer, dafür IST der Fallback da
// ⚠ NICHT mit ins try oben (Fix 2026-08-01): der Poll-`catch` umschloss früher
// auch `_applySnapshot` → `render`. Ein Render-Fehler wurde damit LAUTLOS
// verschluckt: der Poll feuerte weiter alle 4 s, jeder Durchlauf starb still,
// das Dashboard fror auf dem letzten guten Stand ein und die Konsole blieb
// leer. Genau diese Kombination hat eine Fehlersuche unnötig lang gemacht.
// Netzfehler bleiben still (die sind erwartbar), Render-Fehler werden laut.
_applySnapshot(d);
}
setInterval(pollSnapshot, POLL_MS);
// ── Ehrlicher Verbindungspunkt (2026-08-07) ────────────────────────────────
// ⚠ Bisher wurde er NUR bei `sock.onclose` auf „off" gesetzt. Genau der Fall,
// der den Lag verursacht, löst aber KEIN onclose aus: der Socket hängt still
// (WireGuard-Aussetzer, App-Wechsel) — der Punkt blieb grün und behauptete eine
// Verbindung, die nichts mehr liefert. Ein Zustandsanzeiger, der im
// Fehlerfall nicht anschlägt, ist schlimmer als keiner.
// Jetzt zählt, was WIRKLICH ankommt: liegt der letzte angewandte Snapshot mehr
// als STALE_MS zurück, wird der Punkt matt — unabhängig davon, was der Socket
// über sich selbst behauptet.
const STALE_MS = 3500;
setInterval(() => {
const el = $("conn");
if (!el) return;
const alt = Date.now() - lastRecvMs;
el.classList.toggle("stale", lastRecvMs > 0 && alt > STALE_MS);
el.title = lastRecvMs
? `letzte Aktualisierung vor ${(alt / 1000).toFixed(1)} s` +
(alt > STALE_MS ? " — Verbindung hängt, es wird nachgepollt" : "")
: "noch keine Daten empfangen";
}, 1000);
// ── Trade-Steuerung ────────────────────────────────────────────────────────
const TOKEN_KEY = "oil_token";
// Token robust speichern: localStorage UND als langlebiges Cookie. So bleibt
// "einmal eingeben" auch erhalten, wenn localStorage zickt (PWA/Privat-Tab).
function saveToken(tok) {
try { localStorage.setItem(TOKEN_KEY, tok); } catch (e) {}
try {
document.cookie = TOKEN_KEY + "=" + encodeURIComponent(tok) +
";path=/;max-age=31536000;SameSite=Lax";
} catch (e) {}
}
function getToken() {
let t = null;
try { t = localStorage.getItem(TOKEN_KEY); } catch (e) {}
if (t) return t;
const m = document.cookie.match(new RegExp("(?:^|; )" + TOKEN_KEY + "=([^;]*)"));
return m ? decodeURIComponent(m[1]) : null;
}
function clearToken() {
try { localStorage.removeItem(TOKEN_KEY); } catch (e) {}
try { document.cookie = TOKEN_KEY + "=;path=/;max-age=0"; } catch (e) {}
}
let lastSnap = {};
const origRender = render;
render = (d) => { lastSnap = d; origRender(d); }; // letzten Snapshot merken
// Generisches Modal → Promise(true/false). mode "confirm" | "token"
function modal(title, msg, mode = "confirm", danger = false) {
return new Promise((resolve) => {
const box = $("modal"), inp = $("modal-input");
$("modal-title").textContent = title;
$("modal-msg").textContent = msg || "";
$("modal-msg").className = "box-msg" + (danger ? " danger" : "");
$("modal-ok").textContent = danger ? "Trotzdem" : "Bestätigen";
inp.classList.toggle("hidden", mode !== "token");
if (mode === "token") inp.value = "";
box.classList.remove("hidden");
if (mode === "token") setTimeout(() => inp.focus(), 50);
const done = (val) => {
box.classList.add("hidden");
$("modal-ok").onclick = $("modal-cancel").onclick = null;
resolve(val);
};
$("modal-ok").onclick = () => done(mode === "token" ? (inp.value.trim() || null) : true);
$("modal-cancel").onclick = () => done(mode === "token" ? null : false);
});
}
function toast(msg, ok = true) {
const el = $("toast");
el.textContent = msg;
el.className = "toast " + (ok ? "ok" : "err");
clearTimeout(toast._t);
toast._t = setTimeout(() => el.classList.add("hidden"), 3500);
}
// Token sicherstellen (gespeichert; bei Bedarf abfragen + serverseitig prüfen)
async function ensureToken() {
// Token-Pflicht serverseitig abgeschaltet? Dann kein Fenster, kein Header nötig.
if (lastSnap.auth_required === false) return "";
let tok = getToken();
while (true) {
if (!tok) {
tok = await modal("App-Token", "Token aus der Server-Konsole eingeben " +
"(steht in [web] api_token).", "token");
if (!tok) return null;
}
try {
const r = await fetch("api/auth/check", { method: "POST",
headers: { "X-Auth-Token": tok } });
if (r.ok) { saveToken(tok); return tok; }
} catch (e) { /* Netzfehler → unten behandelt */ }
clearToken(); tok = null;
const retry = await modal("Token ungültig", "Erneut eingeben?", "confirm");
if (!retry) return null;
}
}
async function postAuth(url, tok) {
const r = await fetch(url, { method: "POST",
headers: { "X-Auth-Token": tok, "Content-Type": "application/json" },
body: JSON.stringify({}) });
if (r.status === 401) { clearToken(); throw new Error("401"); }
return r.json();
}
async function sendOrder(side, tok) {
const r = await fetch("api/order", { method: "POST",
headers: { "X-Auth-Token": tok, "Content-Type": "application/json" },
body: JSON.stringify({ side }) });
if (r.status === 401) { clearToken(); throw new Error("401"); }
return r.json();
}
// Empfehlungs-Abgleich: zeigt Welle UND KI-Copilot, warnt bei Konflikt
function recInfo(side) {
const w = lastSnap.wave_signal || {};
const sig = w.signal || "—";
const conf = w.conf_pct || 0;
const want = side === "long" ? "LONG" : "SHORT";
const minc = lastSnap.min_conf || 70;
// ── Welle (primäres Signal → bestimmt die Warnung) ──
let waveLine, danger = false;
if (sig === "WARTEN" || sig === "—") {
waveLine = "⚠ Welle: WARTEN — kein klarer Trend"; danger = true;
} else if (sig !== want) {
waveLine = `⚠ Welle GEGEN dich: ${sig} ${conf}%`; danger = true;
} else if (conf < minc) {
waveLine = `Welle ${sig} ${conf}% (unter Schwelle ${minc}%)`;
} else {
waveLine = `✓ Welle ${sig} ${conf}%`;
}
// ── KI-Copilot (Kontext) ──
const adv = (lastSnap.agent || {}).advisory;
let agentLine = "· KI-Copilot: —";
if (adv) {
const ab = adv.bias || "NEUTRAL", ac = adv.confidence || 0;
const mark = ab === want ? "✓" : ab === "NEUTRAL" ? "·" : "⚠";
agentLine = `${mark} KI-Copilot: ${ab} ${ac}%`;
// Agent ist nur Kontext — die Warnung (danger) bestimmt die validierte Welle.
}
return { line: waveLine + "\n" + agentLine, danger };
}
// Order-Buttons (LONG/SHORT/CLOSE).
// Eröffnen wird NIE blockiert/abgefragt — bei WARTEN/Gegen-Signal nur ein Hinweis
// (Toast), der Trade läuft trotzdem. Schließen direkt — außer im Minus:
// Sicherheitsabfrage (kein versehentlicher Verlust-Close).
document.querySelectorAll(".btn[data-side]").forEach(btn => {
btn.onclick = async () => {
if (btn.disabled) return;
const side = btn.dataset.side;
// ⚠⚠ ENTRY-DIALOG ENTFERNT (User-Vorgabe 2026-08-11: „entferne auch die
// Warnung beim Eröffnen von Trades"). LONG/SHORT feuern jetzt sofort.
// ⚠ Was damit wegfällt: die 7-Punkte-Checkliste (Signal-Deckung · HTF-Trend ·
// Entry-Raum · News · Kosten · Verlust-Serie · Nacht) UND der
// Ausrichtungs-Split im Moment der Entscheidung. Letzterer zeigte die
// einzige beidhälftig robuste Zahl des Projekts: gegen das Signal
// 6,60 € je Lot über 1.216 Trades. Genau diese diskretionären
// Abweichungen sind die gemessene Kern-Leckage.
// ⚠ BEWUSSTE ENTSCHEIDUNG DES USERS, nicht ein Messergebnis. Sie deckt sich
// aber mit der Projekt-Linie „sichtbar machen, nicht bevormunden":
// Übersteuerungen liefen gemessen 68 % Trefferquote, und hartes Blockieren
// war ohnehin nie eingebaut.
// ✅ Das Backend ist UNBERÜHRT: `engine._entry_checklist` rechnet weiter und
// steht als `checklist` im Snapshot — nur der Dialog ist weg. Die
// Zahlen bleiben also auswertbar, und Zurückholen ist ein Einzeiler
// (`const ENTRY_DIALOG = true`).
const ENTRY_DIALOG = false;
if (ENTRY_DIALOG && (side === "long" || side === "short")) {
const cl = (lastSnap.checklist || {})[side];
const info = recInfo(side);
if (cl && (cl.fails > 0 || cl.warns > 0)) {
const ICON = { fail: "❌", warn: "⚠", ok: "✅" };
const bad = cl.items.filter(x => x.state !== "ok");
const good = cl.items.filter(x => x.state === "ok");
let body = bad.map(x => `${ICON[x.state]} ${x.label}`
+ (x.detail ? `\n ${x.detail}` : "")).join("\n");
if (good.length) body += `\n\n${good.map(x => `✅ ${x.label}`).join("\n")}`;
// Eigene Zahlen zur Ausrichtung ergänzen (gemessene Kern-Leckage)
const al = lastSnap.alignment || null;
const sigNow = (lastSnap.wave_signal || {}).signal || "WARTEN";
const isWait0 = (sigNow === "WARTEN" || sigNow === "—");
if (al && cl.items.some(x => x.key === "signal" && x.state === "fail")) {
const grp = isWait0 ? al.none : al.against, ref = al.with;
// ⚠ JE LOT ist die belastbare Zahl: absolute Euro-Vergleiche sind bei
// wechselnder Positionsgroesse ungueltig (Lehre 04.08. — ein reiner
// Groessenzuwachs sah wie ein Strategie-Effekt aus). Seit 06.08. laeuft
// der Split ueber die VOLLE Historie (1.216 Trades) statt ueber 40.
const f = g => {
if (!g || g.n < 5) return null;
const lot = (g.pnl_lot === null || g.pnl_lot === undefined) ? ""
: ` · ${g.pnl_lot >= 0 ? "+" : ""}${fmt(g.pnl_lot, 2)} €/Lot`;
return `${g.wr}% Treffer${lot} · ${g.pnl >= 0 ? "+" : ""}${fmt(g.pnl, 0)} € (n${g.n})`;
};
const own = f(grp), rf = f(ref);
if (own) body += `\n\n📊 Deine Trades ${isWait0 ? "OHNE" : "GEGEN"} Signal: ${own}`;
if (rf) body += `\n📊 Mit Signal: ${rf}`;
}
const head = cl.verdict === "stop"
? `🛑 Checkliste sagt NEIN (${cl.fails} Punkte dagegen)`
: `⚠ Checkliste: ${cl.fails} dagegen, ${cl.warns} Vorsicht`;
const ok = await modal(head, body + `\n\n${side.toUpperCase()} trotzdem eröffnen?`,
"confirm", true);
if (!ok) return;
} else if (info.danger) { // Fallback: alter Hinweis (kein Block)
const sig = (lastSnap.wave_signal || {}).signal || "WARTEN";
const isWait = (sig === "WARTEN" || sig === "—");
const head = isWait
? " Keine Empfehlung (WARTEN)"
: ` Empfehlung steht GEGEN dich (${sig})`;
// Ehrliche EIGENE Zahlen (letzte 40 Trades, Ausrichtungs-Split aus der DB):
// die gemessene Kern-Leckage sind genau diese diskretionären Abweichungen.
let statLine = "";
const al = lastSnap.alignment || null;
if (al) {
const grp = isWait ? al.none : al.against;
const ref = al.with;
const f = g => (g && g.n >= 5)
? `${g.wr}% Trefferquote · ${g.pnl >= 0 ? "+" : ""}${fmt(g.pnl, 0)} € (n${g.n})`
: null;
const own = f(grp), rf = f(ref);
if (own) statLine += `\n\n📊 Deine Trades ${isWait ? "OHNE Signal" : "GEGEN das Signal"}: ${own}`;
if (rf) statLine += `\n📊 Mit Signal: ${rf}`;
}
const ok = await modal(head,
info.line + statLine + `\n\n${side.toUpperCase()} trotzdem eröffnen?`,
"confirm", true);
if (!ok) return; // Abbrechen = doch nicht; sonst läuft er
}
}
if (side === "close") { // Sicherheitsabfrage bei Verlust
const p = lastSnap.position || {};
const pnl = p.pnl || 0;
if (p.ticket && pnl < 0) {
const cs = curSym(lastSnap.market || {});
const ok = await modal(
"⚠ Position im Minus schließen?",
`Aktueller Verlust: ${cs}${fmt(pnl, 2)}\n\nWirklich jetzt mit Verlust schließen?`,
"confirm", true);
if (!ok) return;
}
}
const tok = await ensureToken();
if (tok === null) return; // null = Token abgebrochen ("" = kein Token nötig)
try {
const res = await sendOrder(side, tok);
res.ok ? toast(`${side.toUpperCase()} ausgeführt`, true)
: toast(`Fehler: ${res.error || "unbekannt"}`, false);
} catch (e) {
toast(e.message === "401" ? "Token abgelaufen — neu eingeben" : "Netzwerkfehler", false);
}
};
});
// (Notfall-Stop-UI entfernt 2026-07-17 — POST /api/emergency existiert weiter, ohne UI.)
// („Gewinn mitnehmen"-UI entfernt — POST /api/takeprofit existiert weiter, ohne UI.)
// Manuelles SL/TP (Trade-Bereich) — sendet beide Felder; deaktiviert das Trailing.
async function sendSltp() {
// ⚠ STRENG (s. `$streng` oben): hier ist „Feld fehlt" NICHT „Feld ist leer".
// Leer heisst ausdruecklich „Stop entfernen" (`sl: null`) — mit dem
// Auffang-Element waere `.value` `undefined`, `parseFloat` liefert NaN, und
// `JSON.stringify` macht daraus wieder `null`. Eine fehlende Eingabemaske
// wuerde so den Broker-SL einer laufenden Position stillschweigend loeschen.
const elSl = $streng("tb-sl"), elTp = $streng("tb-tp");
if (!elSl || !elTp) {
toast("SL/TP-Felder nicht geladen — Seite neu laden (Strg+F5)", false);
return;
}
const slv = elSl.value, tpv = elTp.value;
const tok = await ensureToken();
if (tok === null) return;
elSl.blur(); elTp.blur();
try {
const r = await fetch("api/sltp", { method: "POST",
headers: { "X-Auth-Token": tok, "Content-Type": "application/json" },
body: JSON.stringify({ sl: slv === "" ? null : parseFloat(slv),
tp: tpv === "" ? null : parseFloat(tpv) }) });
if (r.status === 401) { clearToken(); throw new Error("401"); }
const j = await r.json();
if (j.ok) toast(`SL/TP gesetzt${j.trail_was_on ? " · Trailing AUS" : ""}`, true);
else toast(j.error || "Broker lehnte ab", false);
} catch (e) {
toast(e.message === "401" ? "Token abgelaufen — neu eingeben" : "Netzwerkfehler", false);
}
}
["tb-sl", "tb-tp"].forEach(id => {
$(id).addEventListener("change", sendSltp);
$(id).addEventListener("keydown", e => {
if (e.key === "Enter") { e.preventDefault(); sendSltp(); }
});
});
// Mute-Button (oben rechts): alle akustischen Signale an/aus
function _updateMuteBtn() {
const b = $("mute-btn");
if (b) { b.textContent = _muted ? "🔇" : "🔊"; b.classList.toggle("muted", _muted); }
}
$("mute-btn").onclick = () => {
_muted = !_muted;
localStorage.setItem("oil_muted", _muted ? "1" : "0");
_updateMuteBtn();
toast(_muted ? "🔇 Alle Töne aus" : "🔊 Töne an", true);
};
_updateMuteBtn();
// Toggle-Buttons (AUTO/TRAIL/S-R-Close) — ohne Bestätigung
$("btn-cb").onclick = () => toggle("api/circuit", "Circuit Breaker");
$("btn-sr").onclick = () => toggle("api/srclose", "S/R-Auto-Close");
$("btn-m15").onclick = () => toggle("api/autom15", "M15-Auto-Entry (gemessen negativ!)");
// (🎯 SIG entfernt 2026-08-12 — s. renderOrderButtons oben)
$("btn-trail").onclick = () => toggle("api/trail", "Trailing");
$("srclose-btn").onclick = () => toggle("api/srclose", "Auto-S/R-Close");
// Mindestgewinn für den S/R-Auto-Close (0/leer = aus)
async function setSrMin() {
const raw = $("srmin-input").value;
const v = raw === "" ? 0 : parseFloat(raw);
if (isNaN(v) || v < 0) { toast("Bitte Betrag ≥ 0 eingeben (0 = aus)", false); return; }
const tok = await ensureToken();
if (tok === null) return;
$("srmin-input").blur();
try {
const r = await fetch("api/srclose_min", { method: "POST",
headers: { "X-Auth-Token": tok, "Content-Type": "application/json" },
body: JSON.stringify({ value: v }) });
if (r.status === 401) { clearToken(); throw new Error("401"); }
toast(v > 0 ? `Mindestgewinn +${v} gesetzt` : "Mindestgewinn aus", true);
} catch (e) {
toast(e.message === "401" ? "Token abgelaufen — neu eingeben" : "Netzwerkfehler", false);
}
}
// Feld sitzt in der Button-Zeile: setzt beim Verlassen/Enter automatisch
$("srmin-input").addEventListener("change", setSrMin);
// Enter NUR blurren → das change-Event feuert setSrMin genau EINMAL (und nur, wenn
// der Wert wirklich geändert wurde). Vorher: keydown + change = doppelter Send,
// und Enter auf leerem Feld schickte ungewollt 0 (Fix 2026-07-22).
$("srmin-input").addEventListener("keydown", e => {
if (e.key === "Enter") { e.preventDefault(); e.target.blur(); }
});
// Einsatz-Felder ENTFERNT (User 2026-08-19). Das BACKEND bleibt vollstaendig:
// beide Endpunkte sind weiter erreichbar, die Werte leben in
// `runtime_state.json` UND der ini (aktuell 40 %). Ohne UI aendert man den
// Prozentsatz also dort + Neustart -- oder per POST.
// Notfall-Stop: manueller Verlust-Wert, bei dem der Server sofort schließt.
// Wieder eingeblendet 2026-07-22 (Backend/Persistenz waren die ganze Zeit aktiv).
async function setEmg() {
const raw = $("emg-input").value;
const v = raw === "" ? 0 : parseFloat(raw);
if (isNaN(v) || v < 0) { toast("Bitte Betrag ≥ 0 eingeben (0 = aus)", false); return; }
const tok = await ensureToken();
if (tok === null) return;
$("emg-input").blur();
try {
const r = await fetch("api/emergency", { method: "POST",
headers: { "X-Auth-Token": tok, "Content-Type": "application/json" },
body: JSON.stringify({ value: v > 0 ? v : null }) });
if (r.status === 401) { clearToken(); throw new Error("401"); }
toast(v > 0 ? `Notfall-Stop bei ${v} gesetzt` : "Notfall-Stop aus", true);
} catch (e) {
toast(e.message === "401" ? "Token abgelaufen — neu eingeben" : "Netzwerkfehler", false);
}
}
$("emg-input").addEventListener("change", setEmg);
// Enter blurrt nur (kein doppeltes Senden, kein ungewolltes 0 auf leerem Feld —
// derselbe Fix wie beim Mindestgewinn-Feld, 2026-07-22).
$("emg-input").addEventListener("keydown", e => {
if (e.key === "Enter") { e.preventDefault(); e.target.blur(); }
});
async function toggle(url, label) {
const tok = await ensureToken();
if (tok === null) return; // null = abgebrochen ("" = kein Token nötig)
try {
const res = await postAuth(url, tok);
toast(`${label} ${res.enabled ? "AN" : "AUS"}`, true);
} catch (e) {
toast(e.message === "401" ? "Token abgelaufen — neu eingeben" : "Netzwerkfehler", false);
}
}
// ── Modul-Menü / Views ─────────────────────────────────────────────────────
function showView(name) {
document.querySelectorAll(".view").forEach(v => v.classList.add("hidden"));
const el = document.getElementById("view-" + name);
if (el) el.classList.remove("hidden");
document.querySelectorAll(".tab").forEach(t =>
t.classList.toggle("active", t.dataset.view === name));
if (name === "logs") loadLogs();
if (name === "stats") loadStats();
if (name === "news") loadNews();
}
// Nur echte View-Tabs registrieren — NICHT den Mute-Button (der trägt .tab nur
// fürs Styling; ohne [data-view]-Filter überschrieb das hier seinen onclick und
// showView(undefined) blendete alle Views aus).
document.querySelectorAll(".tab[data-view]").forEach(t => t.onclick = () => showView(t.dataset.view));
async function loadLogs() {
const el = $("logs-body");
el.textContent = "lade …";
try {
const d = await (await fetch("api/logs?lines=300")).json();
if (d.error) { el.textContent = "Fehler: " + d.error; return; }
el.textContent = (d.lines || []).join("\n");
el.scrollTop = el.scrollHeight;
} catch (e) { el.textContent = "Netzwerkfehler"; }
}
function statBlock(title, s) {
const cs = curSym(lastSnap.market || {});
if (!s || s.error)
return `<div class="stat-card"><h3>${title}</h3><div class="dim">${(s && s.error) || "—"}</div></div>`;
const money = (v, sign) => `${sign && v >= 0 ? '+' : ''}${cs}${(v || 0).toFixed(2)}`;
const pf = s.profit_factor != null ? s.profit_factor.toFixed(2) : "—";
const hasWht = (s.wht_pct || 0) > 0;
const npf = s.net_profit_factor != null ? s.net_profit_factor.toFixed(2) : "—";
// Brutto-Block
let rows = `
<div class="kv"><span>Trades</span><b>${s.n_trades}</b></div>
<div class="kv"><span>Winrate</span><b>${(s.winrate || 0).toFixed(0)}%</b></div>
<div class="kv"><span>P&amp;L brutto</span><b class="${s.total_pnl >= 0 ? 'up' : 'down'}">${money(s.total_pnl, true)}</b></div>
<div class="kv"><span>Profit-Faktor</span><b>${pf}</b></div>`;
// Netto-Block (nur wenn WHT konfiguriert)
if (hasWht) {
rows += `
<div class="kv net"><span>WHT ${s.wht_pct}%</span><b class="down">${cs}${(s.wht || 0).toFixed(2)}</b></div>
<div class="kv net"><span>P&amp;L netto</span><b class="${s.net_pnl >= 0 ? 'up' : 'down'}">${money(s.net_pnl, true)}</b></div>
<div class="kv net"><span>Profit-Faktor netto</span><b>${npf}</b></div>`;
}
rows += `
<div class="kv"><span>Ø Gewinn${hasWht ? ' netto' : ''}</span><b class="up">+${cs}${((hasWht ? s.net_avg_win : s.avg_win) || 0).toFixed(2)}</b></div>
<div class="kv"><span>Ø Verlust</span><b class="down">${cs}${(s.avg_loss || 0).toFixed(2)}</b></div>`;
return `<div class="stat-card"><h3>${title}</h3><div class="stat-grid">${rows}</div></div>`;
}
function setupRow(s) {
const cs = curSym(lastSnap.market || {});
const hasWht = (lastSnap._wht || 0) > 0 || s.net_pnl != null;
const pnl = hasWht ? (s.net_pnl ?? s.total_pnl) : s.total_pnl;
const pf = hasWht ? s.net_profit_factor : s.profit_factor;
const cls = pnl >= 0 ? "up" : "down";
return `<div class="setup-row">
<div class="setup-name">${(s.setup || "—").replace(/</g, "&lt;")}</div>
<div class="setup-stat dim">${s.n}× ${(s.winrate || 0).toFixed(0)}%</div>
<div class="setup-stat ${cls}">${pnl >= 0 ? "+" : ""}${cs}${(pnl || 0).toFixed(2)}</div>
<div class="setup-stat">PF ${pf != null ? pf.toFixed(2) : "—"}</div>
</div>`;
}
// Auto-Squeeze-Monitor (B4): Entry-Edge (Mechanik) + Live-Trades vs. Erwartung.
const _SQM_VERDICT = {
ontrack: ["✓ trägt live", "up"], weak: ["○ dünn/schwach", "dim"],
drift: ["⚠ driftet — B5 prüfen", "down"], watch: ["… zu wenig Daten", "dim"],
};
function renderSqueezeMonitor(d) {
const box = $("sqm-box");
if (!d || d.error) { box.innerHTML = '<div class="dim" style="padding:8px">Monitor n/v</div>'; return; }
const m = d.mech || {}, lv = d.live || {}, ex = d.expect || {};
const [vtxt, vcls] = _SQM_VERDICT[d.verdict] || ["—", "dim"];
const row = (k, v, cls) => `<div class="sqm-row"><span>${k}</span><b class="${cls || ""}">${v}</b></div>`;
const sign = (x, dg = 2) => (x == null ? "—" : (x >= 0 ? "+" : "") + Number(x).toFixed(dg));
// Mechanik-Block (Entry-Edge isoliert, apples-to-apples zum Backtest)
let mechHtml;
if (m.error) mechHtml = `<div class="dim">${m.error}</div>`;
else if (!m.n) mechHtml = '<div class="dim">keine Ausbrüche im Fenster</div>';
else {
const orCls = m.oR >= (ex.or_lo || 0.14) ? "up" : m.oR > 0 ? "" : "down";
mechHtml = row("Ausbrüche (30 T)", m.n) + row("Treffer", m.wr + " %")
+ row("ØR", sign(m.oR, 3), orCls) + row("PF", (m.pf != null ? m.pf.toFixed(2) : "—"), m.pf > 1 ? "up" : "down");
}
// Live-Block (echt, Gesamtsystem inkl. reale Exits)
let liveHtml;
if (!lv.n) liveHtml = '<div class="dim">noch keine Auto-Squeeze-Trades</div>';
else {
const dn = lv.night || {}, dy = lv.day || {};
liveHtml = row("Trades", lv.n) + row("Treffer", lv.wr + " %")
+ row("Ø-Gewinn / -Verlust", `${sign(lv.avg_win)} / ${sign(lv.avg_loss)} €`)
+ row("Verhältnis", lv.ratio != null ? lv.ratio.toFixed(2) : "—", lv.ratio >= 1 ? "up" : "down")
+ row("Σ real", sign(lv.sum_eur) + " €", lv.sum_eur >= 0 ? "up" : "down")
+ row("Tag / Nacht", `${sign(dy.sum)} / ${sign(dn.sum)} € (${dy.n || 0}/${dn.n || 0})`);
}
box.innerHTML =
`<div class="sqm-head"><h2 class="sub-h" style="margin:0">Auto-Squeeze-Monitor (B4)</h2>`
+ `<span class="sqm-badge ${vcls}">${vtxt}</span></div>`
+ `<div class="sqm-grid"><div class="sqm-col"><div class="sqm-h">Entry-Edge · Mechanik (~30 T M5, isoliert)</div>`
+ `<div class="sqm-rows">${mechHtml}</div><div class="sqm-exp">Erwartung ØR +0,14…+0,23 · PF&gt;1</div></div>`
+ `<div class="sqm-col"><div class="sqm-h">Live-Trades · echt (Gesamtsystem)</div>`
+ `<div class="sqm-rows">${liveHtml}</div></div></div>`
+ `<div class="sqm-note">Mechanik = nur der Squeeze-<b>Einstieg</b> mit Backtest-Exit `
+ `(zeigt, ob der Edge lebt). Live = die echten Trades inkl. realer Exits/Eingriffe. `
+ `Klaffen sie auseinander, liegt es am Exit/Verhalten, nicht am Signal.</div>`;
}
async function loadSqueezeMonitor() {
try {
const d = await (await fetch("api/squeeze_monitor")).json();
renderSqueezeMonitor(d);
} catch (e) { $("sqm-box").innerHTML = '<div class="dim" style="padding:8px">Monitor-Fehler</div>'; }
}
async function loadStats() {
loadSqueezeMonitor();
const el = $("stats-body"), setEl = $("stats-setups");
el.innerHTML = '<div class="dim" style="padding:10px">lade …</div>';
try {
const d = await (await fetch("api/stats")).json();
lastSnap._wht = d.wht_pct || 0;
el.innerHTML = statBlock("Heute", d.today) + statBlock("Woche", d.week) +
statBlock("Gesamt", d.all) +
`<div class="dim" style="padding:2px">Trades in DB gesamt: ${d.n_total ?? "—"}</div>`;
const ss = (d.setups || []).filter(x => !x.error);
setEl.innerHTML = ss.length
? `<div class="dim setup-hint">${d.wht_pct > 0 ? "P&amp;L/PF = netto nach " + d.wht_pct + "% WHT" : "brutto"}</div>`
+ ss.map(setupRow).join("")
: '<div class="dim">keine Setup-Daten</div>';
} catch (e) { el.innerHTML = '<div class="down" style="padding:10px">Netzwerkfehler</div>'; }
}
async function loadNews() {
const sEl = $("news-sentiment"), bEl = $("news-body");
bEl.innerHTML = '<div class="dim">lade …</div>'; sEl.textContent = "";
try {
const d = await (await fetch("api/news")).json();
const s = d.sentiment || {};
if (s.score != null) {
const sc = s.score > 0.05 ? "up" : s.score < -0.05 ? "down" : "dim";
sEl.innerHTML = `Sentiment <b class="${sc}">${s.score >= 0 ? '+' : ''}${(s.score || 0).toFixed(2)}</b>` +
` · ▲${s.n_bull || 0}${s.n_bear || 0}`;
}
const hs = d.headlines || [];
bEl.innerHTML = hs.length ? hs.map(h =>
`<a class="news-item" href="${h.link || '#'}" target="_blank" rel="noopener">` +
`<div class="t">${(h.title || '').replace(/</g, '&lt;')}</div>` +
`<div class="s">${(h.source || '').replace(/</g, '&lt;')}</div></a>`).join("")
: `<div class="dim">${d.error || "noch keine News geladen"}</div>`;
} catch (e) { bEl.innerHTML = '<div class="down">Netzwerkfehler</div>'; }
}
$("logs-reload").onclick = loadLogs;
$("stats-reload").onclick = loadStats;
$("news-reload").onclick = loadNews;
// Auto-Refresh nur solange der Charts-Tab sichtbar ist (schont den mt5_lock)
// Sofort einmal per REST laden, dann auf den Live-Stream umschalten
fetch("api/snapshot").then(r => r.json()).then(_applySnapshot).catch(() => {});
connect();
// ── Wake-up nach Sperrbildschirm/Hintergrund (Fix 2026-07-22: „G/V lag") ──────
// Mobile Browser pausieren WS+Timer beim Sperren/Backgrounding — die Seite zeigt
// bis zu einem Reconnect den STALEN Stand. Beim Zurückkommen (Tab/App wieder
// sichtbar) sofort per REST einen frischen Snapshot holen UND die WS-Verbindung
// hart neu aufbauen (statt auf den evtl. hängenden alten Socket zu warten).
function _wakeRefresh() {
fetch("api/snapshot").then(r => r.json()).then(_applySnapshot).catch(() => {});
if (!sock || sock.readyState !== WebSocket.OPEN) {
clearTimeout(retry);
try { sock && sock.close(); } catch (e) {}
connect();
}
}
document.addEventListener("visibilitychange", () => {
if (!document.hidden) _wakeRefresh();
});
window.addEventListener("pageshow", _wakeRefresh);
window.addEventListener("focus", _wakeRefresh);
// --- Einsatz je autonomem Pfad (User-Wunsch 2026-08-19) --------------------
// ⚠ LEER heisst hier ausdruecklich 0 = "globaler Wert", NICHT "unveraendert".
// Deshalb `$streng` und eine explizite NaN-Behandlung: mit dem Auffang-`$`
// waere `.value` undefined -> NaN -> JSON.stringify macht daraus null, und
// der Server saehe einen anderen Wert als der Nutzer eingegeben hat.
async function setAutoMargin() {
const eb = $streng("am-brk"), em = $streng("am-m15");
if (!eb || !em) { toast("Eingabefelder fehlen — Seite neu laden", false); return; }
const brk = eb.value.trim() === "" ? 0 : parseFloat(eb.value);
const m15 = em.value.trim() === "" ? 0 : parseFloat(em.value);
if (isNaN(brk) || isNaN(m15) || brk < 0 || m15 < 0 || brk > 99 || m15 > 99) {
toast("Erlaubt sind 0 (= global) bis 99", false); return;
}
const tok = await ensureToken();
if (tok === null) return;
eb.blur(); em.blur();
try {
const r = await fetch("api/automargin", { method: "POST",
headers: { "X-Auth-Token": tok, "Content-Type": "application/json" },
body: JSON.stringify({ brk: brk, m15: m15 }) });
if (r.status === 401) { clearToken(); throw new Error("401"); }
const j = await r.json();
if (!j.ok) { toast(j.error || "abgelehnt", false); return; }
toast(`Einsatz: BRK ${brk || "global"} · M15 ${m15 || "global"}`, true);
} catch (e) {
toast(e.message === "401" ? "Token abgelaufen — neu eingeben" : "Netzwerkfehler", false);
}
}
// --- Globaler Einsatz: steuert MANUELLE Trades ----------------------------
// ⚠ Das Feld war am 19.08. auf Wunsch entfernt worden; danach kamen die
// Felder je Pfad dazu. Damit hatte ausgerechnet die Schraube, die die
// EIGENEN Trades steuert, keine UI mehr - der Wert war nur noch ueber die
// ini plus Neustart erreichbar. Genau das fiel als "der Einsatz wird nicht
// uebernommen" auf (die Pfad-Felder greifen nur bei BRK/M15).
async function setMgGlobal() {
const el = $streng("mg-global");
if (!el) { toast("Eingabefeld fehlt — Seite neu laden", false); return; }
const v = parseFloat(el.value);
// ⚠ Kein "leer = aus": ein globaler Einsatz von 0 waere sinnlos, es MUSS
// einer gelten. Deshalb hier eine echte Untergrenze statt der 0-Semantik
// der Pfad-Felder.
if (isNaN(v) || v < 1 || v > 99) { toast("Erlaubt sind 1 bis 99 %", 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"); }
toast(`Einsatz global ${v} %`, true);
} catch (e) {
toast(e.message === "401" ? "Token abgelaufen — neu eingeben" : "Netzwerkfehler", false);
}
}
{
const el = $streng("mg-global");
if (el) {
el.addEventListener("change", setMgGlobal);
el.addEventListener("keydown", e => {
if (e.key === "Enter") { e.preventDefault(); e.target.blur(); }
});
}
}
["am-brk", "am-m15"].forEach(id => {
const el = $streng(id);
if (!el) return;
el.addEventListener("change", setAutoMargin);
// Enter NUR blurren → `change` feuert dann genau EINMAL (Fix-Muster 22.07.).
el.addEventListener("keydown", e => {
if (e.key === "Enter") { e.preventDefault(); e.target.blur(); }
});
});