core/econ_calendar.py (NEU): High-Impact-News-Fenster als Handels-Sperre. Quelle Forex Factory JSON (kostenlos, kein Key, mit impact-Feld), beide Wochen (thisweek endet Sa, Markt oeffnet So 23:00 -> sonst blinder Fleck), 1x/h im Daemon-Thread, dedupliziert. Filter: High+USD ODER Oil/OPEC-Titel. WICHTIG - nicht gemessen: Events sind zu selten fuer eine 2-Stichproben- Aussage. Das ist die Handbuch-Regel, kein belegter Edge. Deshalb bewusst asymmetrisch: sperrt NUR die autonomen Entries (Auto-Squeeze + Auto-Signal, ohne Dedup-Marker -> feuert nach dem Fenster nach), in der Entry-Checkliste nur "warn" statt "fail". Manuelle Orders bleiben frei. Der gemessene EIA-Blackout (Mi 15:30-16:30) bleibt unabhaengig aktiv und greift auch ohne Netz. Verifiziert: Live-Fetch (EIA korrekt Mi 16:30 Berlin, FOMC/GDP/PCE erkannt) + 4 synthetische Szenarien (5 min vor/nach Event, 50 min davor, disabled). MQL5 v1.30: graue Konsolidierungs-Box beschriftet (User-Frage "was bedeutet die graue Box") - sichtbares Label an der Oberkante, rechtsbuendig wie die uebrigen: "Konsolidierung (10-Bar-Spanne)" bzw. bei Kompression "Konsolidierung - komprimiert, Ausbruch steht bevor". 0 Fehler kompiliert. Frontend v=122: #cal-note in der Meldungen-Karte (laufendes Fenster bzw. Vorwarnung <=60 min). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1413 lines
67 KiB
JavaScript
1413 lines
67 KiB
JavaScript
"use strict";
|
||
// ── Live-Anbindung an server.py via WebSocket (Fallback: Polling) ──────────
|
||
const $ = (id) => document.getElementById(id);
|
||
|
||
function fmt(v, d = 3) {
|
||
return (v === null || v === undefined || isNaN(v)) ? "—" : Number(v).toFixed(d);
|
||
}
|
||
function curSym(m) {
|
||
const c = m && m.currency;
|
||
return c === "EUR" ? "€" : c === "USD" ? "$" : (c ? c + " " : "$");
|
||
}
|
||
|
||
// ── Akustischer CLOSE-Alarm ─────────────────────────────────────────────────
|
||
// Web-Audio (kein Sound-File). Mobile entsperrt Audio erst nach der ersten
|
||
// Nutzer-Interaktion → wir starten/reaktivieren den Context bei jedem Tap.
|
||
let _audioCtx = null, _lastBeep = 0;
|
||
function _unlockAudio() {
|
||
try {
|
||
if (!_audioCtx) _audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||
if (_audioCtx.state === "suspended") _audioCtx.resume();
|
||
} catch (e) {}
|
||
}
|
||
window.addEventListener("pointerdown", _unlockAudio);
|
||
window.addEventListener("touchstart", _unlockAudio);
|
||
|
||
// Globaler Ton-Schalter (Mute-Button oben rechts; überlebt Reloads via localStorage)
|
||
let _muted = localStorage.getItem("oil_muted") === "1";
|
||
function _tone(freq, startOffset, dur) {
|
||
if (_muted) return;
|
||
const o = _audioCtx.createOscillator(), g = _audioCtx.createGain();
|
||
o.type = "square"; o.frequency.value = freq;
|
||
o.connect(g); g.connect(_audioCtx.destination);
|
||
const t = _audioCtx.currentTime + startOffset;
|
||
g.gain.setValueAtTime(0.0001, t);
|
||
g.gain.exponentialRampToValueAtTime(0.35, t + 0.02);
|
||
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
|
||
o.start(t); o.stop(t + dur + 0.03);
|
||
}
|
||
function beepCloseAlert() {
|
||
if (_muted) return;
|
||
if (!_audioCtx || _audioCtx.state !== "running") return; // erst nach 1. Tap aktiv
|
||
const now = Date.now();
|
||
if (now - _lastBeep < 4000) return; // max. alle 4 s
|
||
_lastBeep = now;
|
||
_tone(880, 0, 0.18); // Zwei-Ton-Alarm (hoch→tief)
|
||
_tone(620, 0.22, 0.20);
|
||
}
|
||
// Ton bei Empfehlung: LONG = aufsteigend, SHORT = absteigend (vom Close-Alarm
|
||
// unterscheidbar). Sofort beim Signal-Wechsel + WIEDERHOLUNG alle 30 s, solange
|
||
// der Button blinkt (Empfehlung aktiv & flat) — einmaliger Piep war überhörbar.
|
||
const _REC_REBEEP_MS = 30000;
|
||
let _lastRecBeep = 0, _lastRecSignal = null, _audioHintShown = false, _lastSrCloseCount = null;
|
||
|
||
// ── Laufzeit-Uhr des offenen Trades ───────────────────────────────────────────
|
||
// Öffnungszeit kommt als echte Epoch (Broker-Offset serverseitig korrigiert) im
|
||
// Snapshot (`position.open_time`); der Ticker läuft eigenständig jede Sekunde,
|
||
// damit die Uhr auch zwischen den Snapshots (4 s Poll / 1 s WS) flüssig zählt.
|
||
let _tradeOpenTime = 0;
|
||
function _fmtDuration(sec) {
|
||
sec = Math.max(0, Math.floor(sec));
|
||
const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60;
|
||
const p2 = (n) => String(n).padStart(2, "0");
|
||
return h > 0 ? `${h}:${p2(m)}:${p2(s)}` : `${m}:${p2(s)}`;
|
||
}
|
||
function _tickTradeDuration() {
|
||
const el = document.getElementById("tb-dur"), hd = document.getElementById("tb-runtime");
|
||
if (!el) return;
|
||
if (!_tradeOpenTime) { el.textContent = "—"; if (hd) hd.textContent = ""; return; }
|
||
const sec = Date.now() / 1000 - _tradeOpenTime;
|
||
const txt = _fmtDuration(sec);
|
||
el.textContent = txt;
|
||
if (hd) hd.textContent = "⏱ " + txt;
|
||
// Ab 2 h dezent hervorheben (Time-Stop greift bei 120 min in Phase „Init")
|
||
el.className = "tb-val" + (sec >= 7200 ? " warn" : "");
|
||
}
|
||
setInterval(_tickTradeDuration, 1000);
|
||
function beepRecommendation(sig, isNew) {
|
||
if (_muted) return; // auch kein Sperr-Toast
|
||
if (!_audioCtx || _audioCtx.state !== "running") {
|
||
// Audio noch nicht freigeschaltet (Mobile braucht 1× Tippen) → 1× erklären
|
||
if (isNew && !_audioHintShown) {
|
||
_audioHintShown = true;
|
||
toast("🔇 Ton gesperrt — einmal auf die Seite tippen aktiviert ihn", false);
|
||
}
|
||
return;
|
||
}
|
||
const now = Date.now();
|
||
if (now - _lastRecBeep < (isNew ? 3000 : _REC_REBEEP_MS)) return;
|
||
_lastRecBeep = now;
|
||
if (sig === "LONG") { _tone(520, 0, 0.16); _tone(780, 0.18, 0.20); } // ↑
|
||
else { _tone(780, 0, 0.16); _tone(520, 0.18, 0.20); } // ↓
|
||
}
|
||
|
||
// ── Gesamtempfehlung: Konfidenz-Ring + Bias-Balken + Modul-Chips ───────────
|
||
function renderVerdict(v, snap) {
|
||
const sig = v.headline || "—";
|
||
const cls = sig === "LONG" ? "long" : sig === "SHORT" ? "short" : "";
|
||
const arr = sig === "LONG" ? "▲" : sig === "SHORT" ? "▼" : "◌";
|
||
$("vd-signal").className = "vd-signal " + cls;
|
||
// Zeitebene, für die die Empfehlung gilt (M1/M5/M15/M30/H1)
|
||
$("vd-signal").textContent = `${arr} ${sig}` + (v.tf ? ` · ${v.tf}` : "");
|
||
|
||
// Konfidenz-Ring (conic-gradient bis conf%, Farbe nach Richtung)
|
||
const conf = v.conf || 0;
|
||
const col = sig === "LONG" ? "var(--up)" : sig === "SHORT" ? "var(--down)" : "var(--dim)";
|
||
const deg = Math.max(0, Math.min(100, conf)) * 3.6;
|
||
$("vd-ring").style.background =
|
||
`conic-gradient(${col} ${deg}deg, #21262d ${deg}deg)`;
|
||
$("vd-conf").textContent = conf ? conf + "%" : "—";
|
||
|
||
// Konsens-Text + Bias-Nadel (-1..+1 → 0..100 %)
|
||
const total = v.total || 0, agree = v.agree || 0;
|
||
const bias = typeof v.bias === "number" ? v.bias : 0;
|
||
const strength = Math.abs(bias) >= 0.5 ? "stark" : Math.abs(bias) >= 0.2 ? "leicht" : "gemischt";
|
||
const dir = bias > 0.05 ? "LONG" : bias < -0.05 ? "SHORT" : "neutral";
|
||
// Live-Kosten-Chip (Spread/ATR — gemessen Ø 0,265; Nacht 0,32–0,50 = Falle):
|
||
// reine Info, die die abgeschaltete Dead-Hours-Sperre ehrlich ersetzt.
|
||
const cr = snap && snap.cost_ratio;
|
||
let costTxt = "";
|
||
if (cr != null) {
|
||
const tag = cr < 0.20 ? "✅ günstig" : cr <= 0.32 ? "· normal" : "⚠ teuer";
|
||
costTxt = ` · Kosten ${fmt(cr, 2)}×ATR ${tag}`;
|
||
}
|
||
$("vd-consensus").textContent =
|
||
(sig === "—" ? "keine Daten"
|
||
: `Modul-Konsens: ${strength} ${dir} · ${agree}/${total} Module für ${sig}`)
|
||
+ costTxt;
|
||
$("vd-needle").style.left = ((bias + 1) / 2 * 100).toFixed(1) + "%";
|
||
|
||
// Modul-Chips
|
||
const vote = (x) => x > 0 ? ["long", "▲"] : x < 0 ? ["short", "▼"] : ["flat", "—"];
|
||
$("vd-modules").innerHTML = (v.votes || []).map(mod => {
|
||
const [c, a] = vote(mod.vote);
|
||
// Gewicht sichtbar machen: Module OHNE Aussage (w=0) grau, sonst das Gewicht
|
||
// anzeigen — so ist erkennbar, dass z. B. Orderbuch/Liq-Trend (0,5, ungemessen)
|
||
// schwächer zählen als die Welle (3,0) oder der Squeeze (2,0).
|
||
const w = Number(mod.weight || 0);
|
||
const cls = w > 0 ? c : "flat mute";
|
||
const wtag = w > 0 ? `<span class="m-w">${w.toFixed(1).replace(".0", "")}</span>` : "";
|
||
return `<div class="vd-mod ${cls}"><div class="m-top">` +
|
||
`<span class="m-name">${mod.name}</span>${wtag}` +
|
||
`<span class="m-vote">${a}</span></div>` +
|
||
`<div class="m-det">${mod.detail || ""}</div></div>`;
|
||
}).join("");
|
||
}
|
||
|
||
|
||
function renderStatsCompact(sc, m) {
|
||
sc = sc || {};
|
||
const cs = curSym(m || {});
|
||
const f2 = (v) => (v == null ? "—" : (+v).toFixed(2));
|
||
const block = (label, s) => {
|
||
if (!s || !s.n) return `<div class="stx-grp"><div class="stx-lbl">${label}</div>`
|
||
+ `<div class="dim">noch keine Trades</div></div>`;
|
||
const ratioCls = (s.ratio != null && s.ratio >= 1) ? "up" : "down";
|
||
const pfCls = (s.pf != null && s.pf >= 1) ? "up" : "down";
|
||
const pnlCls = (s.pnl >= 0) ? "up" : "down";
|
||
return `<div class="stx-grp"><div class="stx-lbl">${label} · ${s.n} Trades</div>`
|
||
+ `<div class="stx-cells">`
|
||
+ `<span class="stx-c">WR <b>${s.wr}%</b></span>`
|
||
+ `<span class="stx-c">PF <b class="${pfCls}">${f2(s.pf)}</b></span>`
|
||
+ `<span class="stx-c">Verh <b class="${ratioCls}">${f2(s.ratio)}</b></span>`
|
||
+ `<span class="stx-c">Øgew <b class="up">+${cs}${f2(s.avg_win)}</b></span>`
|
||
+ `<span class="stx-c">Øverl <b class="down">${cs}${f2(s.avg_loss)}</b></span>`
|
||
+ `<span class="stx-c">PnL <b class="${pnlCls}">${s.pnl >= 0 ? "+" : ""}${cs}${f2(s.pnl)}</b></span>`
|
||
+ `</div></div>`;
|
||
};
|
||
$("stx-row").innerHTML = block("Woche", sc.week) + block("Gesamt", sc.all);
|
||
}
|
||
|
||
function renderGaps(g, m) {
|
||
const el = $("gaps-list");
|
||
const list = (g && g.gaps) || [];
|
||
if (!list.length) {
|
||
el.innerHTML = '<span class="dim">keine offenen Gaps</span>';
|
||
return;
|
||
}
|
||
const bid = (m || {}).bid;
|
||
el.innerHTML = list.map(x => {
|
||
const up = x.dir === "up";
|
||
const arr = up ? "▲" : "▼";
|
||
const dist = bid != null ? (x.fill - bid) : null;
|
||
const ds = dist != null ? ` (${dist >= 0 ? "+" : ""}${fmt(dist)})` : "";
|
||
return `<div class="gap-row"><span class="${up ? "up" : "down"}">${arr} ${x.dir.toUpperCase()}</span>`
|
||
+ `<span class="gap-zone">${fmt(x.lo)}–${fmt(x.hi)}</span>`
|
||
+ `<span class="gap-fill">schließt bei <b>${fmt(x.fill)}</b>${ds}</span>`
|
||
+ `<span class="dim gap-date">${x.date}</span></div>`;
|
||
}).join("");
|
||
}
|
||
|
||
// Marktstruktur (M30) — reine Anzeige (HH/HL/LH/LL, BOS, Regressionskanal).
|
||
function renderStructure(s) {
|
||
s = s || {};
|
||
const TR = { up: ["▲ Aufwärts", "up"], down: ["▼ Abwärts", "down"], range: ["→ Seitwärts", "dim"] };
|
||
const [txt, cls] = TR[s.trend] || ["—", "dim"];
|
||
const tEl = $("ms-trend");
|
||
tEl.textContent = txt; tEl.className = "ms-trend " + cls;
|
||
|
||
// Swing-Chips: HH/HL = bullisch (grün), LH/LL = bärisch (rot)
|
||
const chips = (s.swings || []).map(sw => {
|
||
const bull = sw.type === "HH" || sw.type === "HL";
|
||
const c = (sw.type === "H" || sw.type === "L") ? "dim" : (bull ? "up" : "down");
|
||
return `<span class="ms-chip ${c}" title="${fmt(sw.price)}">${sw.type}</span>`;
|
||
}).join('<span class="ms-arrow">›</span>');
|
||
$("ms-swings").innerHTML = chips || '<span class="dim">—</span>';
|
||
|
||
// BOS
|
||
const b = s.bos;
|
||
const bEl = $("ms-bos");
|
||
if (b) {
|
||
const up = b.dir === "up";
|
||
bEl.innerHTML = `<span class="ms-bos-lbl">BOS</span>`
|
||
+ `<span class="${up ? "up" : "down"}">${up ? "▲ über" : "▼ unter"} ${fmt(b.level)}</span>`
|
||
+ `<span class="dim">vor ${b.bars_ago} Bars</span>`;
|
||
bEl.classList.remove("hidden");
|
||
} else { bEl.classList.add("hidden"); }
|
||
|
||
// Kanal: Richtung + Position (0 = unten, 1 = oben)
|
||
const ch = s.channel;
|
||
const CD = { up: ["Aufwärtskanal", "up"], down: ["Abwärtskanal", "down"], flat: ["Seitwärtskanal", "dim"] };
|
||
if (ch) {
|
||
const [ctxt, ccls] = CD[ch.dir] || ["Kanal", "dim"];
|
||
const dEl = $("ms-chan-dir");
|
||
dEl.textContent = ctxt; dEl.className = ccls;
|
||
const pct = Math.round((ch.pos != null ? ch.pos : 0.5) * 100);
|
||
$("ms-chan-fill").style.width = pct + "%";
|
||
$("ms-chan-dot").style.left = pct + "%";
|
||
const zone = pct <= 25 ? "am unteren Rand" : pct >= 75 ? "am oberen Rand" : "in der Mitte";
|
||
$("ms-chan-note").textContent = `Kurs ${zone} (${pct}%)`;
|
||
$("card-structure").classList.remove("hidden");
|
||
} else {
|
||
$("ms-chan-dir").textContent = "—";
|
||
$("ms-chan-note").textContent = "";
|
||
$("ms-chan-fill").style.width = "0%";
|
||
}
|
||
}
|
||
|
||
// 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>`;
|
||
}
|
||
|
||
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 21–22 Uhr (Berlin ≈ Browser-Lokalzeit): gemessen die mit Abstand
|
||
// stärksten Stunden (backtest_hourly: netto +0,25/+0,49 — Rest ~breakeven).
|
||
// 📰 News-Sturm: viele frische Öl-Headlines (news.storm_state) = Ereignis läuft
|
||
// → Vorsicht/Vola. Beides REINE ANZEIGE, kein Filter.
|
||
const _h = new Date().getHours();
|
||
if (_h === 21 || _h === 22) stxt = `🕘 PRIME-TIME · ${stxt}`;
|
||
const storm = (d.news_storm || {}).level;
|
||
if (storm === "storm") stxt = `📰 NEWS-STURM · ${stxt}`;
|
||
else if (storm === "elevated") stxt = `📰 viele News · ${stxt}`;
|
||
$("session").textContent = stxt;
|
||
$("session").className = "session-line " +
|
||
(storm === "storm" ? "warn"
|
||
: (_h === 21 || _h === 22) ? "warn"
|
||
: ses.just_opened ? "warn" : (ses.active && ses.active.length) ? "ok" : "dim");
|
||
|
||
// ── Konto: Balance / Equity ─────────────
|
||
const cs = curSym(m);
|
||
$("balance").textContent = m.balance != null ? cs + fmt(m.balance, 2) : "—";
|
||
if (m.equity != null) {
|
||
$("equity").textContent = cs + fmt(m.equity, 2);
|
||
$("equity").className = (m.balance != null)
|
||
? (m.equity >= m.balance ? "up" : "down") : "";
|
||
} else { $("equity").textContent = "—"; $("equity").className = ""; }
|
||
|
||
// ── Tages-P/L (realisiert heute + offene Position) ───────
|
||
const dp = d.day_pl || {};
|
||
if (dp.total != null) {
|
||
$("day-pl").textContent = (dp.total >= 0 ? "+" : "") + cs + fmt(dp.total, 2);
|
||
$("day-pl").className = dp.total > 0 ? "up" : dp.total < 0 ? "down" : "";
|
||
} else { $("day-pl").textContent = "—"; $("day-pl").className = ""; }
|
||
|
||
// ── Nähe zu S/R (Farb-Signal, innerhalb 0,3×ATR) ──────────
|
||
const srl = d.sr_levels || {};
|
||
const atrRef = (d.trail || {}).atr || m.atr_m15 || 0;
|
||
const near = atrRef ? atrRef * 0.30 : 0.05;
|
||
let priceCls = "";
|
||
if (m.bid != null) {
|
||
const dRes = srl.resistance != null ? srl.resistance - m.bid : Infinity;
|
||
const dSup = srl.support != null ? m.bid - srl.support : Infinity;
|
||
if (dRes >= 0 && dRes <= near && dRes <= dSup) priceCls = "near-res";
|
||
else if (dSup >= 0 && dSup <= near && dSup < dRes) priceCls = "near-sup";
|
||
}
|
||
|
||
// ── Header: Kurs (färbt sich nahe S/R) + G/V der offenen Position (netto) ──
|
||
$("hdr-price").textContent = m.bid != null ? fmt(m.bid) : "—";
|
||
$("hdr-price").className = priceCls;
|
||
const hpos = d.position || {};
|
||
if (hpos.ticket) {
|
||
const wht = d.wht_pct || 0;
|
||
const raw = hpos.pnl || 0;
|
||
const netHdr = raw > 0 ? raw * (1 - wht / 100) : raw;
|
||
$("hdr-pnl").textContent = (netHdr >= 0 ? "+" : "") + cs + fmt(netHdr, 2);
|
||
$("hdr-pnl").className = netHdr > 0 ? "up" : netHdr < 0 ? "down" : "";
|
||
} else { $("hdr-pnl").textContent = "—"; $("hdr-pnl").className = ""; }
|
||
|
||
// ── S/R-Annäherungs-Warnung (Position-Kachel) — NUR wenn kein reicherer
|
||
// Positions-Hint (pos-srhint) läuft, sonst doppeltes „nähert sich Widerstand".
|
||
const hasRichHint = !!((d.position && d.position.ticket) && d.sr_close_hint);
|
||
if (hasRichHint) {
|
||
$("cur-near").className = "cur-near";
|
||
$("cur-near").textContent = "";
|
||
} else {
|
||
$("cur-near").className = "cur-near " + priceCls;
|
||
$("cur-near").textContent =
|
||
priceCls === "near-res" ? `▲ nähert sich Widerstand ${fmt(srl.resistance)}` :
|
||
priceCls === "near-sup" ? `▼ nähert sich Unterstützung ${fmt(srl.support)}` : "";
|
||
}
|
||
|
||
// ── Gesamtempfehlung (aggregiert) ───────
|
||
renderVerdict(d.verdict || {}, d);
|
||
renderCircuitBreaker(d.circuit_breaker);
|
||
renderPatterns(d.patterns);
|
||
renderStructure(d.structure);
|
||
renderStatsCompact(d.stats_compact, d.market);
|
||
renderGaps(d.gaps, d.market);
|
||
|
||
// ── Position (Kurs/G/V stehen jetzt im Header) ──
|
||
const p = d.position || {};
|
||
|
||
// ── Trade-Bereich über der Gesamtempfehlung (nur bei offener Position) ──
|
||
const tb = $("trade-bar");
|
||
if (tb) {
|
||
if (p.ticket) {
|
||
tb.classList.remove("hidden");
|
||
const long = p.order_type === 0;
|
||
const dEl = $("tb-dir");
|
||
dEl.className = "tb-val tb-dir " + (long ? "up" : "down");
|
||
dEl.textContent = long ? "▲ LONG" : "▼ SHORT";
|
||
$("tb-lots").textContent = fmt(p.lots, 2);
|
||
$("tb-margin").textContent = p.margin ? cs + fmt(p.margin, 2) : "—";
|
||
// Laufzeit: Öffnungszeit merken (echte Epoch, Broker-Offset korrigiert) und
|
||
// sekündlich per Ticker anzeigen — unabhängig vom Snapshot-Intervall.
|
||
_tradeOpenTime = p.open_time ? Number(p.open_time) : 0;
|
||
_tickTradeDuration();
|
||
// SL/TP-Felder nur befüllen, wenn nicht gerade bearbeitet (kein Tipp-Konflikt)
|
||
const slEl = $("tb-sl"), tpEl = $("tb-tp");
|
||
if (document.activeElement !== slEl) slEl.value = p.sl ? fmt(p.sl, 2) : "";
|
||
if (document.activeElement !== tpEl) tpEl.value = p.tp ? fmt(p.tp, 2) : "";
|
||
} else {
|
||
tb.classList.add("hidden");
|
||
_tradeOpenTime = 0;
|
||
}
|
||
}
|
||
|
||
// ── Notfall-Stop: Feld wieder eingeblendet (User-Vorgabe 2026-07-22; war
|
||
// 2026-07-17 entfernt, Schutz lief nur noch über Broker-SL+Trailing — Backend
|
||
// (set_emergency/emergency_state.json) blieb die ganze Zeit aktiv/persistent).
|
||
// %-Modus (auto_emergency_pct>0): der Auto-Arm nimmt pct% der Balance → im
|
||
// Flat-Zustand diesen dynamischen Wert zeigen statt des Fixwerts.
|
||
const emg = d.emergency_loss;
|
||
const emgPct = d.emergency_pct || 0;
|
||
const emgRem = (emgPct > 0 && d.balance)
|
||
? Math.round(emgPct / 100 * d.balance * 100) / 100
|
||
: d.emergency_remember;
|
||
const ei = $("emg-input");
|
||
if (ei && document.activeElement !== ei) {
|
||
const shown = emg != null ? emg : (emgRem || "");
|
||
ei.value = shown === "" ? "" : String(shown);
|
||
}
|
||
// („Gewinn mitnehmen"-Eingabe aus dem Dashboard entfernt — Backend-Funktion
|
||
// set_takeprofit/api bleibt erhalten, nur ohne UI.)
|
||
|
||
// ── Trailing ────────────────────────────
|
||
const tr = d.trail || {};
|
||
$("trail-state").textContent = tr.enabled ? "AKTIV" : "aus";
|
||
$("trail-state").className = tr.enabled ? "up" : "dim";
|
||
$("trail-phase").textContent = tr.phase || "—";
|
||
$("trail-sl").textContent = fmt(tr.last_sl, 3);
|
||
$("trail-tp").textContent = fmt(tr.last_tp, 3);
|
||
$("trail-atr").textContent = `${fmt(tr.atr, 3)} · ${tr.atr_tf || "—"}`;
|
||
|
||
// ── Wellen-Meldungen (jetzt in „Meldungen"; Signal-Zeile entfällt — steht als
|
||
// Headline in der Gesamtempfehlung) ──────────────────────
|
||
const w = d.wave_signal || {}, ws = d.wave || {};
|
||
$("wave-setup").textContent = w.setup || "—";
|
||
// Überdehnungs-Anzeige (KEIN Handelssignal — gemessen ~Münzwurf, nur Kontext).
|
||
// Reaktionsrichtung explizit: LONG=überverkauft→nach oben, SHORT=überkauft→nach unten.
|
||
const bn = ws.bounce || {};
|
||
const rword = bn.dir === "LONG" ? "nach oben ↑" : "nach unten ↓";
|
||
const tf = bn.tf ? ` (${bn.tf})` : "";
|
||
const be = $("wave-bounce");
|
||
if (bn.state === "active") {
|
||
be.className = "bounce-ind warn strong";
|
||
be.textContent = `⚠ stark überdehnt${tf} — Reaktion ${rword} möglich`;
|
||
} else if (bn.state === "expected") {
|
||
be.className = "bounce-ind warn";
|
||
be.textContent = `⚠ überdehnt${tf} — Reaktion ${rword} möglich`;
|
||
} else {
|
||
be.className = "bounce-ind idle"; // immer sichtbar (Ruhe-Zustand)
|
||
be.textContent = "Überdehnung: keine";
|
||
}
|
||
// Volatilitäts-Squeeze-Breakout (additiver Setup, gemessen 2-Stichproben-positiv):
|
||
// active = Ausbruch aus Kompression (echtes Signal → grün/rot) · armed = komprimiert,
|
||
// Ausbruch steht bevor (grau) · sonst keine Kompression.
|
||
const sq = ws.squeeze || {};
|
||
const sqEl = $("wave-squeeze");
|
||
if (sqEl) {
|
||
const arr = sq.dir === "LONG" ? "▲" : "▼";
|
||
const bx = sq.box_atr != null ? ` · Box ${sq.box_atr}×ATR` : "";
|
||
if (sq.state === "active") {
|
||
sqEl.className = "squeeze-ind fire " + (sq.dir === "LONG" ? "long" : "short");
|
||
sqEl.textContent = `🚀 Squeeze-Breakout ${arr} ${sq.dir} @ ${fmt(sq.level, 3)}${bx}`;
|
||
} else if (sq.state === "armed") {
|
||
sqEl.className = "squeeze-ind armed";
|
||
sqEl.textContent = `🎯 Kompression${bx} — Ausbruch ${arr} steht bevor`;
|
||
} else {
|
||
sqEl.className = "squeeze-ind idle";
|
||
sqEl.textContent = "Squeeze: keine Kompression";
|
||
}
|
||
}
|
||
if (ws.direction && ws.wave_start != null) {
|
||
$("wave-move").textContent =
|
||
`${ws.direction === "up" ? "▲" : "▼"} ab ${fmt(ws.wave_start)}` +
|
||
(ws.move_atr != null ? ` ${ws.move_atr >= 0 ? "+" : ""}${fmt(ws.move_atr, 2)}×ATR` : "");
|
||
} else $("wave-move").textContent = "—";
|
||
// TF-Ampel: M1/M5/M15/M30 grün(auf)/rot(ab); frische EMA12/50-Kreuzung
|
||
// (≤3 Bars) → blinkendes ⟳ + Richtungspfeil.
|
||
const turns = ws.tf_turns || {};
|
||
$("wave-turns").innerHTML = ["M1", "M5", "M15", "M30", "H1"].map(tf => {
|
||
const t = turns[tf] || {};
|
||
const cls = t.dir > 0 ? "up" : t.dir < 0 ? "down" : "flat";
|
||
const arr = t.dir > 0 ? "▲" : t.dir < 0 ? "▼" : "·";
|
||
const fresh = t.fresh ? " fresh" : "";
|
||
const turn = t.fresh ? `<span class="tf-turn">⟳${arr}</span>` : arr;
|
||
const ago = (t.bars_ago != null && t.fresh) ? `<span class="tf-ago">${t.bars_ago}</span>` : "";
|
||
return `<span class="tf-chip ${cls}${fresh}"><b>${tf}</b> ${turn}${ago}</span>`;
|
||
}).join("");
|
||
// (S/R-Zeile im Position-Bereich ausgeblendet — die Level stehen im Chart;
|
||
// die Preis-Färbung/„nähert sich"-Warnung nutzt srl weiterhin.)
|
||
$("wave-reasons").textContent = (w.reasons || []).slice(0, 3).join(" · ");
|
||
|
||
// Elliott/FVG + TradersUnion: keine Einzel-Kacheln mehr (ausgeblendet) —
|
||
// beide fließen weiterhin in die Gesamtempfehlung (engine._verdict) ein.
|
||
|
||
// ── KI-Agent ────────────────────────────
|
||
const ag = (d.agent || {}).advisory;
|
||
if (ag) {
|
||
const b = ag.bias || "NEUTRAL";
|
||
const bc = b === "LONG" ? "long" : b === "SHORT" ? "short" : "";
|
||
const ba = b === "LONG" ? "▲" : b === "SHORT" ? "▼" : "◌";
|
||
$("agent-bias").className = "signal " + bc;
|
||
$("agent-bias").textContent =
|
||
`${ba} ${b} · ${ag.confidence}%` + (ag.timeframe ? ` · ${ag.timeframe}` : "");
|
||
$("agent-head").textContent = ag.headline || "";
|
||
$("agent-reason").textContent = ag.reasoning || "";
|
||
$("agent-risks").innerHTML = (ag.risks || []).map(r => `<li>${r}</li>`).join("");
|
||
} else {
|
||
$("agent-bias").className = "signal dim";
|
||
$("agent-bias").textContent = (d.agent || {}).busy ? "◌ analysiere …" : "◌ —";
|
||
$("agent-head").textContent = "";
|
||
$("agent-reason").textContent = (d.agent || {}).error || "";
|
||
$("agent-risks").innerHTML = "";
|
||
}
|
||
|
||
// ── Aktionsleiste: Zustände ─────────────
|
||
const hasPos = !!p.ticket;
|
||
const longBtn = document.querySelector('.btn[data-side="long"]');
|
||
const shortBtn = document.querySelector('.btn[data-side="short"]');
|
||
const closeBtn = document.querySelector('.btn[data-side="close"]');
|
||
longBtn.disabled = hasPos;
|
||
shortBtn.disabled = hasPos;
|
||
closeBtn.disabled = !hasPos;
|
||
$("btn-squeeze").classList.toggle("on", !!d.auto_squeeze);
|
||
// Auto-Signal-Entry: bernstein-„warn" statt grün — er ist gemessen NICHT
|
||
// tragfähig (H1 negativ) und läuft nur auf ausdrücklichen Wunsch.
|
||
const asBtn = $("btn-autosig");
|
||
if (asBtn) {
|
||
asBtn.classList.toggle("warn-on", !!d.auto_signal);
|
||
asBtn.textContent = d.auto_signal
|
||
? `🎯 SIG ${d.auto_signal_min_conf || 75}%` : "🎯 SIG";
|
||
}
|
||
$("btn-trail").classList.toggle("on", !!(d.trail || {}).enabled);
|
||
|
||
// Empfehlungs-Blinken: LONG/SHORT-Button blinkt, sobald die Welle eine
|
||
// Richtung empfiehlt (= wie in der Welle-Kachel sichtbar) und keine Position
|
||
// offen ist. Nicht mehr an die 70%-Auto-Schwelle gekoppelt.
|
||
longBtn.classList.toggle("blink", !hasPos && w.signal === "LONG");
|
||
shortBtn.classList.toggle("blink", !hasPos && w.signal === "SHORT");
|
||
|
||
// Ton bei Empfehlung (nur flat): sofort beim Signal-Wechsel, danach Erinnerung
|
||
// alle 30 s, solange der Button blinkt. Kein Ton bei offener Position.
|
||
const recSig = (!hasPos && (w.signal === "LONG" || w.signal === "SHORT"))
|
||
? w.signal : null;
|
||
if (recSig) beepRecommendation(recSig, recSig !== _lastRecSignal);
|
||
_lastRecSignal = recSig;
|
||
|
||
// CLOSE-Alarm (Blink + Ton) NUR wenn der Gewinn ÜBER dem eingestellten S/R-
|
||
// Mindestgewinn liegt (User-Vorgabe). Feld leer/0 → wie bisher: jeder Gewinn > 0.
|
||
// Deckt sich mit dem Auto-Close-Gate im Backend (unter der Schwelle schließt der
|
||
// Bot ohnehin nicht → dann auch kein Alarm/Ton).
|
||
const posLong = p.order_type === 0;
|
||
const minGain = d.sr_close_min_gain || 0;
|
||
const gainOK = hasPos && (p.pnl || 0) > minGain;
|
||
const flipClose = gainOK &&
|
||
((posLong && w.signal === "SHORT") || (!posLong && w.signal === "LONG"));
|
||
// S/R-Close-Empfehlung (gemessen, nur das robuste Segment): Kurs AM Level +
|
||
// schwacher Anlauf → Level hält ~74 % → Close schlägt Laufenlassen. Text-Hinweis
|
||
// in der Position-Kachel immer; Blink/Ton nur über dem Mindestgewinn.
|
||
const srHint = d.sr_close_hint || null; // vorhanden = Kurs am Level
|
||
const srAuto = !!d.auto_sr_close;
|
||
const srClose = !!(srHint && srHint.close); // P(break) < Schwelle → schließen
|
||
const closeAlert = flipClose || (gainOK && srClose);
|
||
closeBtn.classList.toggle("blink", closeAlert);
|
||
if (closeAlert) beepCloseAlert(); // Blinken + Ton (gedrosselt, alle 4 s)
|
||
const hintEl = $("pos-srhint");
|
||
if (hintEl) {
|
||
if (hasPos && srHint) {
|
||
const lvlName = posLong ? "Widerstand" : "Support";
|
||
// Bestätigungs-Preis (level±0,5×ATR, dieselbe Definition wie beim Modell-
|
||
// Training): ein kurzer Docht über die Levelzahl ist noch KEIN Durchbruch.
|
||
const confirmTxt = srHint.confirm != null
|
||
? ` (Bestätigung erst ab ${fmt(srHint.confirm, 3)})` : "";
|
||
if (!srHint.at_level) {
|
||
// Vorwarnung: Kurs nähert sich dem Level (0,15–0,6×ATR davor).
|
||
// Schwelle dynamisch aus der Config (sr_close_pbreak), nicht hart 60.
|
||
const thr = (d.sr_close_pbreak || 0.60) * 100;
|
||
hintEl.className = "pos-srhint approach";
|
||
hintEl.textContent = `🎯 Nähert sich ${lvlName} ${fmt(srHint.level, 3)} ` +
|
||
`(${srHint.gap_atr}×ATR) · Durchbruch ${srHint.p_break}%${confirmTxt}` +
|
||
(srHint.p_break < thr ? " → Bot schließt dort (im Plus)" : " → dürfte durchlaufen");
|
||
} else if (srClose) {
|
||
hintEl.className = "pos-srhint warn";
|
||
const tail = (srAuto && gainOK) ? "🤖 Bot schließt automatisch" : "Close erwägen";
|
||
hintEl.textContent = `🎯 Am ${lvlName} ${fmt(srHint.level, 3)} · Durchbruch nur ` +
|
||
`${srHint.p_break}%${confirmTxt} → prallt wahrscheinlich ab → ${tail}`;
|
||
} else {
|
||
hintEl.className = "pos-srhint run";
|
||
hintEl.textContent = `🎯 Am ${lvlName} ${fmt(srHint.level, 3)} · Durchbruch ` +
|
||
`${srHint.p_break}%${confirmTxt} wahrscheinlich → läuft weiter`;
|
||
}
|
||
} else {
|
||
hintEl.className = "pos-srhint hidden";
|
||
hintEl.textContent = "";
|
||
}
|
||
}
|
||
// Gegen-/Stop-Seiten-Hinweis (REINE ANZEIGE, kein Auto-Close): Kurs läuft auf das
|
||
// Level GEGEN die Position zu → bricht es durch (Verlust läuft) oder prallt es ab?
|
||
// P(break)-Modell auf der Gegen-Seite gemessen kalibriert (AUC 0,72 o.o.s.),
|
||
// im hohen Bereich leicht konservativ. Nur zeigen, wenn KEIN Ziel-Level-Hint läuft.
|
||
const stopEl = $("pos-stophint");
|
||
if (stopEl) {
|
||
const sa = d.stop_approach || null;
|
||
if (hasPos && sa && !srHint) {
|
||
const lvlName = sa.side === "sup" ? "Support" : "Widerstand";
|
||
const dir = sa.side === "sup" ? "nach unten" : "nach oben";
|
||
const confirmTxt = sa.confirm != null ? ` (Bruch ab ${fmt(sa.confirm, 3)})` : "";
|
||
const holds = sa.p_break < 50;
|
||
stopEl.className = "pos-stophint " + (holds ? "hold" : "break");
|
||
const lead = sa.at_level ? `Am ${lvlName}` : `Nähert sich ${lvlName}`;
|
||
stopEl.textContent =
|
||
`ℹ️ ${lead} ${fmt(sa.level, 3)} (${sa.gap_atr}×ATR) · Durchbruch ${dir} ` +
|
||
`${sa.p_break}%${confirmTxt} → ` +
|
||
(holds ? "Level hält wahrscheinlich" : "bricht wahrscheinlich durch");
|
||
} else {
|
||
stopEl.className = "pos-stophint hidden";
|
||
stopEl.textContent = "";
|
||
}
|
||
}
|
||
// News-Konflikt-Chip (2026-07-24, realer Auslöser 23.07.: zwei Shorts gegen einen
|
||
// +0,98-Sentiment-Burst): |Score|≥0,5 UND Richtung gegen offene Position bzw.
|
||
// gegen das aktive Signal → Warnung. Reiner Kontext, Prädiktivität unbelegt.
|
||
const ncEl = $("news-conflict");
|
||
if (ncEl) {
|
||
const ns = d.news_sentiment;
|
||
let txt = "";
|
||
if (ns != null && Math.abs(ns) >= 0.5) {
|
||
const newsDir = ns > 0 ? "LONG" : "SHORT";
|
||
const newsWord = ns > 0 ? "bullisch" : "bärisch";
|
||
if (hasPos) {
|
||
const posDir = posLong ? "LONG" : "SHORT";
|
||
if (posDir !== newsDir)
|
||
txt = `📰 News-Flow stark ${newsWord} (${ns > 0 ? "+" : ""}${fmt(ns, 2)}) — steht gegen deine ${posDir}-Position`;
|
||
} else if (w.signal === "LONG" || w.signal === "SHORT") {
|
||
if (w.signal !== newsDir)
|
||
txt = `📰 News-Flow stark ${newsWord} (${ns > 0 ? "+" : ""}${fmt(ns, 2)}) — steht gegen das ${w.signal}-Signal`;
|
||
}
|
||
}
|
||
if (txt) { ncEl.className = "news-conflict warn"; ncEl.textContent = txt; }
|
||
else { ncEl.className = "news-conflict hidden"; ncEl.textContent = ""; }
|
||
}
|
||
// Wirtschaftskalender (2026-07-30): laufendes News-Fenster bzw. Vorwarnung ≤60 min.
|
||
// ⚠ NICHT gemessen — Handbuch-Regel; der Bot pausiert im Fenster nur seine
|
||
// AUTONOMEN Entries, manuelle Orders bleiben frei.
|
||
const calEl = $("cal-note");
|
||
if (calEl) {
|
||
const c = d.calendar || {};
|
||
let t = "";
|
||
if (c.blackout) {
|
||
const m = c.blackout.minutes;
|
||
t = `📅 News-Fenster: ${c.blackout.title} ${m > 0 ? "in " + Math.round(m) + " min" : "vor " + Math.round(-m) + " min"} — Bot eröffnet gerade keine Auto-Trades`;
|
||
} else if (c.next && c.next.minutes <= 60) {
|
||
t = `📅 ${c.next.title} in ${c.next.minutes} min`;
|
||
}
|
||
if (t) { calEl.className = "news-conflict warn"; calEl.textContent = t; }
|
||
else { calEl.className = "news-conflict hidden"; calEl.textContent = ""; }
|
||
}
|
||
// Schalter-Zustand: Auto-Close am S/R an/aus + Mindestgewinn
|
||
const srBtn = $("srclose-btn");
|
||
if (srBtn) {
|
||
srBtn.textContent = srAuto ? "AN" : "AUS";
|
||
srBtn.title = srAuto ? "Bot schließt am Level (im Plus)" : "nur Hinweis, kein Auto-Close";
|
||
srBtn.classList.toggle("on", srAuto);
|
||
}
|
||
const srMin = d.sr_close_min_gain || 0;
|
||
const smi = $("srmin-input");
|
||
if (smi && document.activeElement !== smi) {
|
||
smi.value = srMin > 0 ? String(srMin) : "";
|
||
}
|
||
// Einblendung: der Bot hat am S/R automatisch geschlossen (Zähler stieg)
|
||
const src = d.sr_close_count;
|
||
if (src != null) {
|
||
if (_lastSrCloseCount != null && src > _lastSrCloseCount) {
|
||
toast("🎯 Bot hat am Level geschlossen (schwacher Anlauf, Gewinn gesichert)", true);
|
||
}
|
||
_lastSrCloseCount = src;
|
||
}
|
||
|
||
// ── Letzte Trades ───────────────────────
|
||
const tds = (d.last_trades || []).slice(0, 5);
|
||
$("trades").innerHTML = tds.map(x => {
|
||
const pnl = x.pnl || 0;
|
||
const cls = pnl > 0 ? "up" : pnl < 0 ? "down" : "dim";
|
||
const arr = (x.direction || "").toUpperCase() === "BUY" ? "▲" : "▼";
|
||
const ts = (x.exit_time || x.entry_time || 0) * 1000;
|
||
const hm = ts ? new Date(ts).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" }) : "—";
|
||
const cb = (x.closed_by || "").toUpperCase().slice(0, 2) || "—";
|
||
return `<div class="trade"><div class="p ${cls}">${arr}${pnl >= 0 ? "+" : ""}${fmt(pnl, 2)}</div>` +
|
||
`<div class="i">${hm} ${cb}</div></div>`;
|
||
}).join("") || '<div class="dim">keine</div>';
|
||
}
|
||
|
||
// ── WebSocket mit Auto-Reconnect ───────────────────────────────────────────
|
||
let sock = null, retry = null;
|
||
// `lastTs` dedupliziert zwischen WS-Push und REST-Poll (beide liefern denselben
|
||
// `engine.snapshot_cached().ts`) — nur die jeweils NEUERE Quelle rendert, kein
|
||
// Flackern/Doppel-Render, wenn beide fast gleichzeitig eintreffen.
|
||
let lastTs = 0;
|
||
function _applySnapshot(d) {
|
||
if (typeof d.ts === "number" && d.ts <= lastTs) return; // schon Neueres gezeigt
|
||
if (typeof d.ts === "number") lastTs = d.ts;
|
||
render(d);
|
||
}
|
||
function connect() {
|
||
const proto = location.protocol === "https:" ? "wss" : "ws";
|
||
sock = new WebSocket(`${proto}://${location.host}/ws`);
|
||
sock.onmessage = (ev) => {
|
||
try { _applySnapshot(JSON.parse(ev.data)); } catch (e) { console.warn(e); }
|
||
};
|
||
sock.onclose = () => {
|
||
$("conn").className = "dot off";
|
||
clearTimeout(retry);
|
||
retry = setTimeout(connect, 2000);
|
||
};
|
||
sock.onerror = () => sock.close();
|
||
}
|
||
|
||
// ── REST-Polling als Sicherheitsnetz (2026-07-22, User-Wunsch) ────────────────
|
||
// Zusätzlich zum WS-Push: alle POLL_MS Sekunden `/api/snapshot` abfragen. Fängt
|
||
// Fälle auf, in denen der WebSocket STILL hängt (kein onclose, keine Zustellung
|
||
// mehr — z. B. schwaches WireGuard-Signal, App-Wechsel ohne Visibility-Event),
|
||
// ohne auf einen Reconnect warten zu müssen. Der `lastTs`-Dedup verhindert
|
||
// Doppel-Renders, solange der WS normal liefert — reine Redundanz, kein Konflikt.
|
||
const POLL_MS = 4000;
|
||
async function pollSnapshot() {
|
||
if (document.hidden) return; // seite unsichtbar → spart Akku/Daten, Wake-Handler holt beim Zurückkommen sofort nach
|
||
try {
|
||
const d = await (await fetch("api/snapshot")).json();
|
||
_applySnapshot(d);
|
||
} catch (e) { /* still — WS-Reconnect kümmert sich, das ist nur der Fallback */ }
|
||
}
|
||
setInterval(pollSnapshot, POLL_MS);
|
||
|
||
// ── Trade-Steuerung ────────────────────────────────────────────────────────
|
||
const TOKEN_KEY = "oil_token";
|
||
|
||
// Token robust speichern: localStorage UND als langlebiges Cookie. So bleibt
|
||
// "einmal eingeben" auch erhalten, wenn localStorage zickt (PWA/Privat-Tab).
|
||
function saveToken(tok) {
|
||
try { localStorage.setItem(TOKEN_KEY, tok); } catch (e) {}
|
||
try {
|
||
document.cookie = TOKEN_KEY + "=" + encodeURIComponent(tok) +
|
||
";path=/;max-age=31536000;SameSite=Lax";
|
||
} catch (e) {}
|
||
}
|
||
function getToken() {
|
||
let t = null;
|
||
try { t = localStorage.getItem(TOKEN_KEY); } catch (e) {}
|
||
if (t) return t;
|
||
const m = document.cookie.match(new RegExp("(?:^|; )" + TOKEN_KEY + "=([^;]*)"));
|
||
return m ? decodeURIComponent(m[1]) : null;
|
||
}
|
||
function clearToken() {
|
||
try { localStorage.removeItem(TOKEN_KEY); } catch (e) {}
|
||
try { document.cookie = TOKEN_KEY + "=;path=/;max-age=0"; } catch (e) {}
|
||
}
|
||
|
||
let lastSnap = {};
|
||
const origRender = render;
|
||
render = (d) => { lastSnap = d; origRender(d); }; // letzten Snapshot merken
|
||
|
||
// Generisches Modal → Promise(true/false). mode "confirm" | "token"
|
||
function modal(title, msg, mode = "confirm", danger = false) {
|
||
return new Promise((resolve) => {
|
||
const box = $("modal"), inp = $("modal-input");
|
||
$("modal-title").textContent = title;
|
||
$("modal-msg").textContent = msg || "";
|
||
$("modal-msg").className = "box-msg" + (danger ? " danger" : "");
|
||
$("modal-ok").textContent = danger ? "Trotzdem" : "Bestätigen";
|
||
inp.classList.toggle("hidden", mode !== "token");
|
||
if (mode === "token") inp.value = "";
|
||
box.classList.remove("hidden");
|
||
if (mode === "token") setTimeout(() => inp.focus(), 50);
|
||
const done = (val) => {
|
||
box.classList.add("hidden");
|
||
$("modal-ok").onclick = $("modal-cancel").onclick = null;
|
||
resolve(val);
|
||
};
|
||
$("modal-ok").onclick = () => done(mode === "token" ? (inp.value.trim() || null) : true);
|
||
$("modal-cancel").onclick = () => done(mode === "token" ? null : false);
|
||
});
|
||
}
|
||
|
||
function toast(msg, ok = true) {
|
||
const el = $("toast");
|
||
el.textContent = msg;
|
||
el.className = "toast " + (ok ? "ok" : "err");
|
||
clearTimeout(toast._t);
|
||
toast._t = setTimeout(() => el.classList.add("hidden"), 3500);
|
||
}
|
||
|
||
// Token sicherstellen (gespeichert; bei Bedarf abfragen + serverseitig prüfen)
|
||
async function ensureToken() {
|
||
// Token-Pflicht serverseitig abgeschaltet? Dann kein Fenster, kein Header nötig.
|
||
if (lastSnap.auth_required === false) return "";
|
||
let tok = getToken();
|
||
while (true) {
|
||
if (!tok) {
|
||
tok = await modal("App-Token", "Token aus der Server-Konsole eingeben " +
|
||
"(steht in [web] api_token).", "token");
|
||
if (!tok) return null;
|
||
}
|
||
try {
|
||
const r = await fetch("api/auth/check", { method: "POST",
|
||
headers: { "X-Auth-Token": tok } });
|
||
if (r.ok) { saveToken(tok); return tok; }
|
||
} catch (e) { /* Netzfehler → unten behandelt */ }
|
||
clearToken(); tok = null;
|
||
const retry = await modal("Token ungültig", "Erneut eingeben?", "confirm");
|
||
if (!retry) return null;
|
||
}
|
||
}
|
||
|
||
async function postAuth(url, tok) {
|
||
const r = await fetch(url, { method: "POST",
|
||
headers: { "X-Auth-Token": tok, "Content-Type": "application/json" },
|
||
body: JSON.stringify({}) });
|
||
if (r.status === 401) { clearToken(); throw new Error("401"); }
|
||
return r.json();
|
||
}
|
||
|
||
async function sendOrder(side, tok) {
|
||
const r = await fetch("api/order", { method: "POST",
|
||
headers: { "X-Auth-Token": tok, "Content-Type": "application/json" },
|
||
body: JSON.stringify({ side }) });
|
||
if (r.status === 401) { clearToken(); throw new Error("401"); }
|
||
return r.json();
|
||
}
|
||
|
||
// Empfehlungs-Abgleich: zeigt Welle UND KI-Copilot, warnt bei Konflikt
|
||
function recInfo(side) {
|
||
const w = lastSnap.wave_signal || {};
|
||
const sig = w.signal || "—";
|
||
const conf = w.conf_pct || 0;
|
||
const want = side === "long" ? "LONG" : "SHORT";
|
||
const minc = lastSnap.min_conf || 70;
|
||
|
||
// ── Welle (primäres Signal → bestimmt die Warnung) ──
|
||
let waveLine, danger = false;
|
||
if (sig === "WARTEN" || sig === "—") {
|
||
waveLine = "⚠ Welle: WARTEN — kein klarer Trend"; danger = true;
|
||
} else if (sig !== want) {
|
||
waveLine = `⚠ Welle GEGEN dich: ${sig} ${conf}%`; danger = true;
|
||
} else if (conf < minc) {
|
||
waveLine = `Welle ${sig} ${conf}% (unter Schwelle ${minc}%)`;
|
||
} else {
|
||
waveLine = `✓ Welle ${sig} ${conf}%`;
|
||
}
|
||
|
||
// ── KI-Copilot (Kontext) ──
|
||
const adv = (lastSnap.agent || {}).advisory;
|
||
let agentLine = "· KI-Copilot: —";
|
||
if (adv) {
|
||
const ab = adv.bias || "NEUTRAL", ac = adv.confidence || 0;
|
||
const mark = ab === want ? "✓" : ab === "NEUTRAL" ? "·" : "⚠";
|
||
agentLine = `${mark} KI-Copilot: ${ab} ${ac}%`;
|
||
// Agent ist nur Kontext — die Warnung (danger) bestimmt die validierte Welle.
|
||
}
|
||
|
||
return { line: waveLine + "\n" + agentLine, danger };
|
||
}
|
||
|
||
// Order-Buttons (LONG/SHORT/CLOSE).
|
||
// Eröffnen wird NIE blockiert/abgefragt — bei WARTEN/Gegen-Signal nur ein Hinweis
|
||
// (Toast), der Trade läuft trotzdem. Schließen direkt — außer im Minus:
|
||
// Sicherheitsabfrage (kein versehentlicher Verlust-Close).
|
||
document.querySelectorAll(".btn[data-side]").forEach(btn => {
|
||
btn.onclick = async () => {
|
||
if (btn.disabled) return;
|
||
const side = btn.dataset.side;
|
||
if (side === "long" || side === "short") {
|
||
// ── Entry-Checkliste (User-Vorgabe 2026-07-30): zeigt VOR der Eröffnung,
|
||
// was dagegen spricht — auch dann, wenn ein Signal da ist (der alte Dialog
|
||
// kam nur bei WARTEN/Gegen-Signal). Blockt nicht, macht nur sichtbar.
|
||
const cl = (lastSnap.checklist || {})[side];
|
||
const info = recInfo(side);
|
||
if (cl && (cl.fails > 0 || cl.warns > 0)) {
|
||
const ICON = { fail: "❌", warn: "⚠", ok: "✅" };
|
||
const bad = cl.items.filter(x => x.state !== "ok");
|
||
const good = cl.items.filter(x => x.state === "ok");
|
||
let body = bad.map(x => `${ICON[x.state]} ${x.label}`
|
||
+ (x.detail ? `\n ${x.detail}` : "")).join("\n");
|
||
if (good.length) body += `\n\n${good.map(x => `✅ ${x.label}`).join("\n")}`;
|
||
// Eigene Zahlen zur Ausrichtung ergänzen (gemessene Kern-Leckage)
|
||
const al = lastSnap.alignment || null;
|
||
const sigNow = (lastSnap.wave_signal || {}).signal || "WARTEN";
|
||
const isWait0 = (sigNow === "WARTEN" || sigNow === "—");
|
||
if (al && cl.items.some(x => x.key === "signal" && x.state === "fail")) {
|
||
const grp = isWait0 ? al.none : al.against, ref = al.with;
|
||
const f = g => (g && g.n >= 5)
|
||
? `${g.wr}% Treffer · ${g.pnl >= 0 ? "+" : ""}${fmt(g.pnl, 0)} € (n${g.n})` : null;
|
||
const own = f(grp), rf = f(ref);
|
||
if (own) body += `\n\n📊 Deine Trades ${isWait0 ? "OHNE" : "GEGEN"} Signal: ${own}`;
|
||
if (rf) body += `\n📊 Mit Signal: ${rf}`;
|
||
}
|
||
const head = cl.verdict === "stop"
|
||
? `🛑 Checkliste sagt NEIN (${cl.fails} Punkte dagegen)`
|
||
: `⚠ Checkliste: ${cl.fails} dagegen, ${cl.warns} Vorsicht`;
|
||
const ok = await modal(head, body + `\n\n${side.toUpperCase()} trotzdem eröffnen?`,
|
||
"confirm", true);
|
||
if (!ok) return;
|
||
} else if (info.danger) { // Fallback: alter Hinweis (kein Block)
|
||
const sig = (lastSnap.wave_signal || {}).signal || "WARTEN";
|
||
const isWait = (sig === "WARTEN" || sig === "—");
|
||
const head = isWait
|
||
? "ℹ Keine Empfehlung (WARTEN)"
|
||
: `ℹ Empfehlung steht GEGEN dich (${sig})`;
|
||
// Ehrliche EIGENE Zahlen (letzte 40 Trades, Ausrichtungs-Split aus der DB):
|
||
// die gemessene Kern-Leckage sind genau diese diskretionären Abweichungen.
|
||
let statLine = "";
|
||
const al = lastSnap.alignment || null;
|
||
if (al) {
|
||
const grp = isWait ? al.none : al.against;
|
||
const ref = al.with;
|
||
const f = g => (g && g.n >= 5)
|
||
? `${g.wr}% Trefferquote · ${g.pnl >= 0 ? "+" : ""}${fmt(g.pnl, 0)} € (n${g.n})`
|
||
: null;
|
||
const own = f(grp), rf = f(ref);
|
||
if (own) statLine += `\n\n📊 Deine Trades ${isWait ? "OHNE Signal" : "GEGEN das Signal"}: ${own}`;
|
||
if (rf) statLine += `\n📊 Mit Signal: ${rf}`;
|
||
}
|
||
const ok = await modal(head,
|
||
info.line + statLine + `\n\n${side.toUpperCase()} trotzdem eröffnen?`,
|
||
"confirm", true);
|
||
if (!ok) return; // Abbrechen = doch nicht; sonst läuft er
|
||
}
|
||
}
|
||
if (side === "close") { // Sicherheitsabfrage bei Verlust
|
||
const p = lastSnap.position || {};
|
||
const pnl = p.pnl || 0;
|
||
if (p.ticket && pnl < 0) {
|
||
const cs = curSym(lastSnap.market || {});
|
||
const ok = await modal(
|
||
"⚠ Position im Minus schließen?",
|
||
`Aktueller Verlust: ${cs}${fmt(pnl, 2)}\n\nWirklich jetzt mit Verlust schließen?`,
|
||
"confirm", true);
|
||
if (!ok) return;
|
||
}
|
||
}
|
||
const tok = await ensureToken();
|
||
if (tok === null) return; // null = Token abgebrochen ("" = kein Token nötig)
|
||
try {
|
||
const res = await sendOrder(side, tok);
|
||
res.ok ? toast(`${side.toUpperCase()} ausgeführt`, true)
|
||
: toast(`Fehler: ${res.error || "unbekannt"}`, false);
|
||
} catch (e) {
|
||
toast(e.message === "401" ? "Token abgelaufen — neu eingeben" : "Netzwerkfehler", false);
|
||
}
|
||
};
|
||
});
|
||
|
||
// (Notfall-Stop-UI entfernt 2026-07-17 — POST /api/emergency existiert weiter, ohne UI.)
|
||
// („Gewinn mitnehmen"-UI entfernt — POST /api/takeprofit existiert weiter, ohne UI.)
|
||
|
||
// Manuelles SL/TP (Trade-Bereich) — sendet beide Felder; deaktiviert das Trailing.
|
||
async function sendSltp() {
|
||
const slv = $("tb-sl").value, tpv = $("tb-tp").value;
|
||
const tok = await ensureToken();
|
||
if (tok === null) return;
|
||
$("tb-sl").blur(); $("tb-tp").blur();
|
||
try {
|
||
const r = await fetch("api/sltp", { method: "POST",
|
||
headers: { "X-Auth-Token": tok, "Content-Type": "application/json" },
|
||
body: JSON.stringify({ sl: slv === "" ? null : parseFloat(slv),
|
||
tp: tpv === "" ? null : parseFloat(tpv) }) });
|
||
if (r.status === 401) { clearToken(); throw new Error("401"); }
|
||
const j = await r.json();
|
||
if (j.ok) toast(`SL/TP gesetzt${j.trail_was_on ? " · Trailing AUS" : ""}`, true);
|
||
else toast(j.error || "Broker lehnte ab", false);
|
||
} catch (e) {
|
||
toast(e.message === "401" ? "Token abgelaufen — neu eingeben" : "Netzwerkfehler", false);
|
||
}
|
||
}
|
||
["tb-sl", "tb-tp"].forEach(id => {
|
||
$(id).addEventListener("change", sendSltp);
|
||
$(id).addEventListener("keydown", e => {
|
||
if (e.key === "Enter") { e.preventDefault(); sendSltp(); }
|
||
});
|
||
});
|
||
|
||
// Mute-Button (oben rechts): alle akustischen Signale an/aus
|
||
function _updateMuteBtn() {
|
||
const b = $("mute-btn");
|
||
if (b) { b.textContent = _muted ? "🔇" : "🔊"; b.classList.toggle("muted", _muted); }
|
||
}
|
||
$("mute-btn").onclick = () => {
|
||
_muted = !_muted;
|
||
localStorage.setItem("oil_muted", _muted ? "1" : "0");
|
||
_updateMuteBtn();
|
||
toast(_muted ? "🔇 Alle Töne aus" : "🔊 Töne an", true);
|
||
};
|
||
_updateMuteBtn();
|
||
|
||
// Toggle-Buttons (AUTO/TRAIL/S-R-Close) — ohne Bestätigung
|
||
$("btn-squeeze").onclick = () => toggle("api/autosqueeze", "Auto-Squeeze (autonom!)");
|
||
$("btn-autosig").onclick = () => toggle("api/autosignal", "Auto-Signal-Entry (autonom!)");
|
||
$("btn-trail").onclick = () => toggle("api/trail", "Trailing");
|
||
$("srclose-btn").onclick = () => toggle("api/srclose", "Auto-S/R-Close");
|
||
|
||
// Mindestgewinn für den S/R-Auto-Close (0/leer = aus)
|
||
async function setSrMin() {
|
||
const raw = $("srmin-input").value;
|
||
const v = raw === "" ? 0 : parseFloat(raw);
|
||
if (isNaN(v) || v < 0) { toast("Bitte Betrag ≥ 0 eingeben (0 = aus)", false); return; }
|
||
const tok = await ensureToken();
|
||
if (tok === null) return;
|
||
$("srmin-input").blur();
|
||
try {
|
||
const r = await fetch("api/srclose_min", { method: "POST",
|
||
headers: { "X-Auth-Token": tok, "Content-Type": "application/json" },
|
||
body: JSON.stringify({ value: v }) });
|
||
if (r.status === 401) { clearToken(); throw new Error("401"); }
|
||
toast(v > 0 ? `Mindestgewinn +${v} gesetzt` : "Mindestgewinn aus", true);
|
||
} catch (e) {
|
||
toast(e.message === "401" ? "Token abgelaufen — neu eingeben" : "Netzwerkfehler", false);
|
||
}
|
||
}
|
||
// Feld sitzt in der Button-Zeile: setzt beim Verlassen/Enter automatisch
|
||
$("srmin-input").addEventListener("change", setSrMin);
|
||
// Enter NUR blurren → das change-Event feuert setSrMin genau EINMAL (und nur, wenn
|
||
// der Wert wirklich geändert wurde). Vorher: keydown + change = doppelter Send,
|
||
// und Enter auf leerem Feld schickte ungewollt 0 (Fix 2026-07-22).
|
||
$("srmin-input").addEventListener("keydown", e => {
|
||
if (e.key === "Enter") { e.preventDefault(); e.target.blur(); }
|
||
});
|
||
|
||
// Notfall-Stop: manueller Verlust-Wert, bei dem der Server sofort schließt.
|
||
// Wieder eingeblendet 2026-07-22 (Backend/Persistenz waren die ganze Zeit aktiv).
|
||
async function setEmg() {
|
||
const raw = $("emg-input").value;
|
||
const v = raw === "" ? 0 : parseFloat(raw);
|
||
if (isNaN(v) || v < 0) { toast("Bitte Betrag ≥ 0 eingeben (0 = aus)", false); return; }
|
||
const tok = await ensureToken();
|
||
if (tok === null) return;
|
||
$("emg-input").blur();
|
||
try {
|
||
const r = await fetch("api/emergency", { method: "POST",
|
||
headers: { "X-Auth-Token": tok, "Content-Type": "application/json" },
|
||
body: JSON.stringify({ value: v > 0 ? v : null }) });
|
||
if (r.status === 401) { clearToken(); throw new Error("401"); }
|
||
toast(v > 0 ? `Notfall-Stop bei −${v} gesetzt` : "Notfall-Stop aus", true);
|
||
} catch (e) {
|
||
toast(e.message === "401" ? "Token abgelaufen — neu eingeben" : "Netzwerkfehler", false);
|
||
}
|
||
}
|
||
$("emg-input").addEventListener("change", setEmg);
|
||
// Enter blurrt nur (kein doppeltes Senden, kein ungewolltes 0 auf leerem Feld —
|
||
// derselbe Fix wie beim Mindestgewinn-Feld, 2026-07-22).
|
||
$("emg-input").addEventListener("keydown", e => {
|
||
if (e.key === "Enter") { e.preventDefault(); e.target.blur(); }
|
||
});
|
||
|
||
async function toggle(url, label) {
|
||
const tok = await ensureToken();
|
||
if (tok === null) return; // null = abgebrochen ("" = kein Token nötig)
|
||
try {
|
||
const res = await postAuth(url, tok);
|
||
toast(`${label} ${res.enabled ? "AN" : "AUS"}`, true);
|
||
} catch (e) {
|
||
toast(e.message === "401" ? "Token abgelaufen — neu eingeben" : "Netzwerkfehler", false);
|
||
}
|
||
}
|
||
|
||
// ── Modul-Menü / Views ─────────────────────────────────────────────────────
|
||
function showView(name) {
|
||
document.querySelectorAll(".view").forEach(v => v.classList.add("hidden"));
|
||
const el = document.getElementById("view-" + name);
|
||
if (el) el.classList.remove("hidden");
|
||
document.querySelectorAll(".tab").forEach(t =>
|
||
t.classList.toggle("active", t.dataset.view === name));
|
||
if (name === "logs") loadLogs();
|
||
if (name === "stats") loadStats();
|
||
if (name === "news") loadNews();
|
||
if (name === "charts") loadCharts();
|
||
}
|
||
// Nur echte View-Tabs registrieren — NICHT den Mute-Button (der trägt .tab nur
|
||
// fürs Styling; ohne [data-view]-Filter überschrieb das hier seinen onclick und
|
||
// showView(undefined) blendete alle Views aus).
|
||
document.querySelectorAll(".tab[data-view]").forEach(t => t.onclick = () => showView(t.dataset.view));
|
||
|
||
async function loadLogs() {
|
||
const el = $("logs-body");
|
||
el.textContent = "lade …";
|
||
try {
|
||
const d = await (await fetch("api/logs?lines=300")).json();
|
||
if (d.error) { el.textContent = "Fehler: " + d.error; return; }
|
||
el.textContent = (d.lines || []).join("\n");
|
||
el.scrollTop = el.scrollHeight;
|
||
} catch (e) { el.textContent = "Netzwerkfehler"; }
|
||
}
|
||
|
||
function statBlock(title, s) {
|
||
const cs = curSym(lastSnap.market || {});
|
||
if (!s || s.error)
|
||
return `<div class="stat-card"><h3>${title}</h3><div class="dim">${(s && s.error) || "—"}</div></div>`;
|
||
const money = (v, sign) => `${sign && v >= 0 ? '+' : ''}${cs}${(v || 0).toFixed(2)}`;
|
||
const pf = s.profit_factor != null ? s.profit_factor.toFixed(2) : "—";
|
||
const hasWht = (s.wht_pct || 0) > 0;
|
||
const npf = s.net_profit_factor != null ? s.net_profit_factor.toFixed(2) : "—";
|
||
|
||
// Brutto-Block
|
||
let rows = `
|
||
<div class="kv"><span>Trades</span><b>${s.n_trades}</b></div>
|
||
<div class="kv"><span>Winrate</span><b>${(s.winrate || 0).toFixed(0)}%</b></div>
|
||
<div class="kv"><span>P&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&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, "<")}</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>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&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, '<')}</div>` +
|
||
`<div class="s">${(h.source || '').replace(/</g, '<')}</div></a>`).join("")
|
||
: `<div class="dim">${d.error || "noch keine News geladen"}</div>`;
|
||
} catch (e) { bEl.innerHTML = '<div class="down">Netzwerkfehler</div>'; }
|
||
}
|
||
|
||
// ── Charts (Lightweight Charts): M1/M5/M15/M30/H1 untereinander mit Trend ──
|
||
const CHART_TFS = ["M1", "M5", "M15", "M30", "H1"];
|
||
const _charts = {}; // tf → {chart, candle, s12, s50}
|
||
const _TREND_TXT = { up: ["▲ Aufwärts", "up"], down: ["▼ Abwärts", "down"], flat: ["→ Seitwärts", "flat"] };
|
||
|
||
function _ensureChartCard(tf) {
|
||
if (document.getElementById("chart-" + tf)) return;
|
||
const card = document.createElement("div");
|
||
card.className = "chart-card"; card.id = "chart-" + tf;
|
||
card.innerHTML =
|
||
`<div class="chart-head"><span class="chart-tf">${tf}</span>` +
|
||
`<span class="chart-trend" id="ctr-${tf}">—</span>` +
|
||
`<span class="chart-last" id="clast-${tf}">—</span></div>` +
|
||
`<div class="chart-cv" id="ccv-${tf}"></div>`;
|
||
$("charts-body").appendChild(card);
|
||
}
|
||
|
||
function _makeChart(tf) {
|
||
const el = document.getElementById("ccv-" + tf);
|
||
// Explizite Größe statt autoSize: autoSize hängt an ResizeObserver und lieferte
|
||
// auf Mobil-Browsern teils 0×0-Charts (leerer Tab). clientWidth ist gesetzt,
|
||
// weil loadCharts erst nach dem Einblenden des Tabs läuft.
|
||
const chart = LightweightCharts.createChart(el, {
|
||
width: el.clientWidth || 320, height: 200,
|
||
layout: { background: { color: "#0d1117" }, textColor: "#8b949e", fontSize: 15 },
|
||
grid: { vertLines: { color: "#10151c" }, horzLines: { color: "#10151c" } },
|
||
timeScale: { borderColor: "#30363d", timeVisible: true, secondsVisible: false },
|
||
rightPriceScale: { borderColor: "#30363d" },
|
||
crosshair: { mode: LightweightCharts.CrosshairMode.Normal },
|
||
handleScale: { axisPressedMouseMove: false },
|
||
});
|
||
const candle = chart.addCandlestickSeries({
|
||
upColor: "#3fb950", downColor: "#f85149", borderVisible: false,
|
||
wickUpColor: "#3fb950", wickDownColor: "#f85149",
|
||
});
|
||
const line = (color) => chart.addLineSeries(
|
||
{ color, lineWidth: 1, priceLineVisible: false, lastValueVisible: false, crosshairMarkerVisible: false });
|
||
const s12 = line("#58a6ff"), s50 = line("#d29922"); // EMA12 blau, EMA50 amber
|
||
// Regressionskanal (nur M30 bekommt Daten): oben/unten gestrichelt, Mitte gepunktet
|
||
const chLine = (color, style) => chart.addLineSeries(
|
||
{ color, lineWidth: 1, lineStyle: style, priceLineVisible: false,
|
||
lastValueVisible: false, crosshairMarkerVisible: false });
|
||
const chU = chLine("#8b949e", 2), chM = chLine("#8b949e66", 1), chL = chLine("#8b949e", 2);
|
||
_charts[tf] = { chart, candle, s12, s50, chU, chM, chL };
|
||
return _charts[tf];
|
||
}
|
||
|
||
function renderChart(tf, d) {
|
||
const off = d.broker_utc_offset || 0;
|
||
const B = d.bars || [];
|
||
if (d.error || !B.length) {
|
||
const tr = document.getElementById("ctr-" + tf);
|
||
if (tr) { tr.textContent = d.error ? "⚠ " + d.error : "keine Daten"; tr.className = "chart-trend flat"; }
|
||
return;
|
||
}
|
||
const candles = B.map(b => ({ time: b.t - off, open: b.o, high: b.h, low: b.l, close: b.c }));
|
||
const em = (arr) => (arr || []).map((v, i) => (B[i] ? { time: B[i].t - off, value: v } : null)).filter(Boolean);
|
||
const c = _charts[tf] || _makeChart(tf);
|
||
c.candle.setData(candles);
|
||
c.s12.setData(em(d.ema12));
|
||
c.s50.setData(em(d.ema50));
|
||
// Regressionskanal (M30) — 3 Linien deckungsgleich mit den Bars; sonst leeren
|
||
if (c.chU) {
|
||
const ch = d.channel;
|
||
if (ch && ch.upper) {
|
||
c.chU.setData(em(ch.upper)); c.chM.setData(em(ch.mid)); c.chL.setData(em(ch.lower));
|
||
} else {
|
||
c.chU.setData([]); c.chM.setData([]); c.chL.setData([]);
|
||
}
|
||
}
|
||
// S/R-Linien + Zonen (Ranges) als horizontale Preislinien einzeichnen
|
||
(c.lines || []).forEach(pl => { try { c.candle.removePriceLine(pl); } catch (e) {} });
|
||
c.lines = [];
|
||
const addLine = (p, color, style, title) => {
|
||
if (p == null) return;
|
||
c.lines.push(c.candle.createPriceLine({ price: p, color, lineWidth: 1,
|
||
lineStyle: style, axisLabelVisible: true, title: title || "" }));
|
||
};
|
||
const LV = d.levels || {};
|
||
// Verständliche Labels: nächstes Level ausgeschrieben, weitere nummeriert
|
||
(LV.res || []).forEach((p, i) =>
|
||
addLine(p, "#1f6feb", 2, i === 0 ? "Widerstand" : `Widerstand ${i + 1}`));
|
||
(LV.sup || []).forEach((p, i) =>
|
||
addLine(p, "#58a6ff", 2, i === 0 ? "Unterstützung" : `Unterstützung ${i + 1}`));
|
||
(LV.zones || []).forEach(z => {
|
||
addLine(z.hi, "#1f6feb", 1, "Zone"); addLine(z.lo, "#1f6feb", 1, "");
|
||
});
|
||
// Chartmuster-Overlay (nur M30): Trigger = kräftige Linie (grün bull / rot bear),
|
||
// Ziel = gepunktete Linie gleicher Farbe. Nur die 2 stärksten, sonst wird's voll.
|
||
(d.patterns || []).slice(0, 2).forEach(p => {
|
||
const bull = p.dir === "bull";
|
||
const col = bull ? "#3fb950" : (p.dir === "bear" ? "#f85149" : "#8b949e");
|
||
c.lines.push(c.candle.createPriceLine({
|
||
price: p.trigger, color: col, lineWidth: 2, lineStyle: 0,
|
||
axisLabelVisible: true, title: (p.status === "confirmed" ? "▶ " : "◌ ") + p.name }));
|
||
if (p.target != null)
|
||
c.lines.push(c.candle.createPriceLine({
|
||
price: p.target, color: col, lineWidth: 1, lineStyle: 1,
|
||
axisLabelVisible: true, title: "Ziel" }));
|
||
});
|
||
c.chart.timeScale().fitContent();
|
||
const tr = document.getElementById("ctr-" + tf);
|
||
const [txt, cls] = _TREND_TXT[d.trend] || ["—", "flat"];
|
||
tr.textContent = txt; tr.className = "chart-trend " + cls;
|
||
document.getElementById("clast-" + tf).textContent = candles[candles.length - 1].close.toFixed(3);
|
||
}
|
||
|
||
async function loadCharts() {
|
||
if (typeof LightweightCharts === "undefined") {
|
||
$("charts-body").innerHTML = '<div class="dim" style="padding:14px">Chart-Framework nicht geladen (Seite neu laden).</div>';
|
||
return;
|
||
}
|
||
for (const tf of CHART_TFS) {
|
||
_ensureChartCard(tf);
|
||
try {
|
||
const d = await (await fetch(`api/bars?tf=${tf}&n=100`)).json();
|
||
renderChart(tf, d);
|
||
// Breite nachziehen (Rotation/Resize seit Erstellung)
|
||
const c = _charts[tf], el = document.getElementById("ccv-" + tf);
|
||
if (c && el && el.clientWidth > 0) c.chart.applyOptions({ width: el.clientWidth });
|
||
} catch (e) {
|
||
// Fehler SICHTBAR machen statt stumm schlucken (war: leere Kacheln ohne Grund)
|
||
const tr = document.getElementById("ctr-" + tf);
|
||
if (tr) { tr.textContent = "⚠ " + (e && e.message ? e.message : "Fehler"); tr.className = "chart-trend flat"; }
|
||
}
|
||
}
|
||
}
|
||
window.addEventListener("resize", () => {
|
||
for (const tf of CHART_TFS) {
|
||
const c = _charts[tf], el = document.getElementById("ccv-" + tf);
|
||
if (c && el && el.clientWidth > 0) c.chart.applyOptions({ width: el.clientWidth });
|
||
}
|
||
});
|
||
|
||
$("logs-reload").onclick = loadLogs;
|
||
$("stats-reload").onclick = loadStats;
|
||
$("news-reload").onclick = loadNews;
|
||
$("charts-reload").onclick = loadCharts;
|
||
// Auto-Refresh nur solange der Charts-Tab sichtbar ist (schont den mt5_lock)
|
||
setInterval(() => { if (!$("view-charts").classList.contains("hidden")) loadCharts(); }, 20000);
|
||
|
||
// Sofort einmal per REST laden, dann auf den Live-Stream umschalten
|
||
fetch("api/snapshot").then(r => r.json()).then(_applySnapshot).catch(() => {});
|
||
connect();
|
||
|
||
// ── Wake-up nach Sperrbildschirm/Hintergrund (Fix 2026-07-22: „G/V lag") ──────
|
||
// Mobile Browser pausieren WS+Timer beim Sperren/Backgrounding — die Seite zeigt
|
||
// bis zu einem Reconnect den STALEN Stand. Beim Zurückkommen (Tab/App wieder
|
||
// sichtbar) sofort per REST einen frischen Snapshot holen UND die WS-Verbindung
|
||
// hart neu aufbauen (statt auf den evtl. hängenden alten Socket zu warten).
|
||
function _wakeRefresh() {
|
||
fetch("api/snapshot").then(r => r.json()).then(_applySnapshot).catch(() => {});
|
||
if (!sock || sock.readyState !== WebSocket.OPEN) {
|
||
clearTimeout(retry);
|
||
try { sock && sock.close(); } catch (e) {}
|
||
connect();
|
||
}
|
||
}
|
||
document.addEventListener("visibilitychange", () => {
|
||
if (!document.hidden) _wakeRefresh();
|
||
});
|
||
window.addEventListener("pageshow", _wakeRefresh);
|
||
window.addEventListener("focus", _wakeRefresh);
|