// PolyTrader Web — Client-Side Application Logic let currentView = 'dashboard'; let sseSource = null; let allLogs = []; // Init on DOM ready document.addEventListener('DOMContentLoaded', () => { applyThemeChoice(currentThemeChoice()); supChatWelcome = document.getElementById('supChatMessages')?.innerHTML ?? ''; loadUpdateStatus(false); // #565: Banner im Dashboard; der Server haelt das Ergebnis vor document.addEventListener('keydown', e => { if (e.key === 'Escape' && document.getElementById('modalStopTrading')?.style.display === 'flex') closeStopTradingModal(); }); initNavMenus(); refreshNavModules(); initSseLogs(); refreshDashboard(); loadDashboardAccounts(); loadOverviewWidgets(); setInterval(refreshDashboard, 2500); setInterval(loadDashboardAccounts, 5000); setInterval(loadOverviewWidgets, 7500); }); // Navigation function switchView(viewName) { currentView = viewName; markActiveNav(viewName); closeNavDropdowns(); document.querySelectorAll('.view-panel').forEach(p => p.classList.remove('active')); const panel = document.getElementById(`view-${viewName}`); if (panel) panel.classList.add('active'); if (viewName === 'dashboard') { refreshDashboard(); loadDashboardAccounts(); loadOverviewWidgets(); } if (viewName === 'analytics') loadAnalytics(); if (viewName === 'copytrading') loadCopyTrading(); if (viewName === 'resolutionfarming') loadResolutionFarming(); if (viewName === 'supervisor') loadSupervisor(); if (viewName === 'accounting') loadAccounting(); if (viewName === 'accounts') loadAccounts(); if (viewName === 'jobs') loadJobs(); if (viewName === 'modules') loadModules(); if (viewName === 'settings') loadSettings(); if (viewName === 'logs') { const container = document.getElementById('logContainer'); if (document.getElementById('chkAutoScroll').checked) { container.scrollTop = container.scrollHeight; } } } // ==================== Farbschema und Supervisor-Verlauf (#566) ==================== const THEME_KEY = 'polytrader-theme'; const THEME_ORDER = ['system', 'light', 'dark']; const THEME_LABELS = { system: '◐ System', light: '☀ Hell', dark: '☾ Dunkel' }; /** Die Wahl gilt je Browser. Ohne gespeicherte Wahl folgt das WebUI dem Betriebssystem. */ function currentThemeChoice() { try { const t = localStorage.getItem(THEME_KEY); return t === 'light' || t === 'dark' ? t : 'system'; } catch (e) { return 'system'; } } function applyThemeChoice(choice) { if (choice === 'system') document.documentElement.removeAttribute('data-theme'); else document.documentElement.setAttribute('data-theme', choice); try { if (choice === 'system') localStorage.removeItem(THEME_KEY); else localStorage.setItem(THEME_KEY, choice); } catch (e) { /* ohne Speicher gilt die Wahl bis zum Neuladen */ } const btn = document.getElementById('btnTheme'); if (btn) { btn.textContent = THEME_LABELS[choice]; btn.title = `Farbschema: ${THEME_LABELS[choice].slice(2)} – klicken zum Wechseln`; } } function cycleTheme() { const next = THEME_ORDER[(THEME_ORDER.indexOf(currentThemeChoice()) + 1) % THEME_ORDER.length]; applyThemeChoice(next); } let supChatWelcome = ''; /** Leert nur die Unterhaltung im Browser. Gespeicherte Berichte (sup_reports) bleiben unberührt. */ function clearSupChat() { const area = document.getElementById('supChatMessages'); if (area) area.innerHTML = supChatWelcome; } // ==================== Kopfleiste: Aufklappmenues (#541) ==================== /** * Hebt den Eintrag der aktiven Ansicht hervor und dazu das Oberelement seines Menues - * sonst sieht man bei geschlossenem Menue nicht, wo man ist. Supervisor und Accounting * stehen zweimal im Menue; das Oberelement leuchtet nur an ihrem Hauptplatz (Analytics), * der zweite Zugang in der Modulliste traegt data-secondary. */ function markActiveNav(viewName) { document.querySelectorAll('.header-nav [data-view]').forEach(el => { const on = el.dataset.view === viewName; el.classList.toggle('active', on); if (on) el.setAttribute('aria-current', 'page'); else el.removeAttribute('aria-current'); }); document.querySelectorAll('.nav-dropdown').forEach(dd => { const home = dd.querySelector(`[data-view="${viewName}"]:not([data-secondary])`); dd.querySelector('.nav-dropdown-toggle').classList.toggle('active', !!home); }); } function toggleNavDropdown(toggle) { const menu = toggle.closest('.nav-dropdown').querySelector('.nav-menu'); const willOpen = menu.hidden; closeNavDropdowns(); if (willOpen) { menu.hidden = false; toggle.setAttribute('aria-expanded', 'true'); } } function closeNavDropdowns() { document.querySelectorAll('.nav-dropdown').forEach(dd => { dd.querySelector('.nav-menu').hidden = true; dd.querySelector('.nav-dropdown-toggle').setAttribute('aria-expanded', 'false'); }); } function navMenuItems(menu) { return Array.from(menu.querySelectorAll('.nav-menu-item')).filter(b => !b.disabled); } function initNavMenus() { // Klick ausserhalb schliesst. Der Klick auf ein Oberelement selbst laeuft ueber // toggleNavDropdown und kommt hier mit closest('.nav-dropdown') an - bleibt also offen. document.addEventListener('click', e => { if (!e.target.closest('.nav-dropdown')) closeNavDropdowns(); }); document.querySelector('.header-nav')?.addEventListener('keydown', e => { const dd = e.target.closest('.nav-dropdown'); if (!dd) return; const toggle = dd.querySelector('.nav-dropdown-toggle'); const menu = dd.querySelector('.nav-menu'); if (e.key === 'Escape' && !menu.hidden) { e.preventDefault(); closeNavDropdowns(); toggle.focus(); return; } // Pfeil nach unten auf dem Oberelement oeffnet und springt in das Menue. if (e.target === toggle && e.key === 'ArrowDown') { e.preventDefault(); if (menu.hidden) toggleNavDropdown(toggle); navMenuItems(menu)[0]?.focus(); return; } if (menu.contains(e.target) && ['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(e.key)) { e.preventDefault(); const items = navMenuItems(menu); const i = items.indexOf(e.target); const next = e.key === 'Home' ? 0 : e.key === 'End' ? items.length - 1 : e.key === 'ArrowDown' ? (i + 1) % items.length : (i - 1 + items.length) % items.length; items[next]?.focus(); return; } // Tab verlaesst das Menue, statt durch alle Eintraege zu wandern. if (e.key === 'Tab' && !menu.hidden) closeNavDropdowns(); }); } /** * Zeigt deaktivierte Module im Menue als deaktiviert und ergaenzt die Modulliste um * Module, die der Server fuehrt, fuer die es im WebUI aber noch keine Ansicht gibt. * Ohne Uebergabe wird die Liste selbst geholt; loadModules() reicht ihre mit durch. */ async function refreshNavModules(modules) { if (!modules) { try { const res = await fetch('/api/modules'); if (!res.ok) return; modules = await res.json(); } catch (e) { return; } } const isActive = new Map(modules.map(m => [m.name.toLowerCase(), m.isActive])); const lblModules = document.getElementById('lblModules'); if (lblModules) lblModules.textContent = `Module ${modules.filter(m => m.isActive).length}/${modules.length}`; document.querySelectorAll('.header-nav [data-module]').forEach(item => { const off = isActive.get(item.dataset.module.toLowerCase()) === false; item.disabled = off; item.title = off ? 'Modul ist deaktiviert – siehe Moduleinstellungen' : ''; const hint = item.querySelector('.nav-menu-hint'); if (hint) hint.textContent = off ? 'deaktiviert' : ''; }); const list = document.getElementById('navModuleList'); if (!list) return; list.querySelectorAll('[data-no-view]').forEach(e => e.remove()); const shown = new Set(Array.from(list.querySelectorAll('[data-module]')).map(e => e.dataset.module.toLowerCase())); modules.filter(m => !shown.has(m.name.toLowerCase())).forEach(m => { const item = document.createElement('button'); item.className = 'nav-menu-item'; item.setAttribute('role', 'menuitem'); item.disabled = true; item.dataset.noView = '1'; item.title = 'Für dieses Modul gibt es im WebUI noch keine eigene Ansicht.'; const label = document.createElement('span'); label.textContent = m.name; const hint = document.createElement('span'); hint.className = 'nav-menu-hint'; hint.textContent = m.isActive ? 'keine Ansicht' : 'deaktiviert'; item.append(label, hint); list.appendChild(item); }); } // Dashboard Refresh async function refreshDashboard() { try { const res = await fetch('/api/dashboard/summary'); if (res.status === 401) { window.location.href = '/login'; return; } if (!res.ok) return; const data = await res.json(); // Status indicators document.getElementById('dotAlchemy').className = data.alchemyHealthy ? 'dot online' : 'dot offline'; // #566: beschriftet wie "API: WSS aktiv / Polling" in der Statusleiste des Desktops. const lblAlchemy = document.getElementById('lblAlchemy'); if (lblAlchemy) { lblAlchemy.textContent = data.alchemyHealthy ? 'WSS aktiv' : 'Polling'; lblAlchemy.parentElement.title = data.alchemyHealthy ? 'Signale kommen live über den Alchemy-WebSocket' : 'Kein WebSocket – Signale werden abgefragt (langsamer)'; } document.getElementById('dotVpn').className = data.vpnConnected ? 'dot online' : 'dot offline'; document.getElementById('dotWatchdog').className = data.watchdogActive ? 'dot online' : 'dot offline'; // KPIs const pnlEl = document.getElementById('valGlobalPnl'); pnlEl.textContent = formatCurrency(data.globalPnl); pnlEl.style.color = data.globalPnl >= 0 ? 'var(--positive)' : 'var(--negative)'; document.getElementById('valTotalUsdc').textContent = formatCurrency(data.totalUsdcBalance); document.getElementById('valAccountCount').textContent = `${data.accountCount} Konten konfiguriert`; document.getElementById('valPortfolioValue').textContent = formatCurrency(data.totalPortfolioValue); // Pause Button const pauseBtn = document.getElementById('btnPause'); if (data.globalTradingPaused) { pauseBtn.textContent = '▶ Trading fortsetzen'; pauseBtn.className = 'btn btn-primary'; pauseBtn.style.background = 'var(--positive)'; } else { pauseBtn.textContent = '⏸ Trading pausieren'; pauseBtn.className = 'btn btn-danger'; } // Toggles updateToggleGroup('groupLiveTrading', data.liveTradingMode); updateToggleGroup('groupDemoTrading', data.demoTradingMode); } catch (e) { // Fehler bei Verbindung } } function updateToggleGroup(groupId, currentMode) { const group = document.getElementById(groupId); if (!group) return; const buttons = group.querySelectorAll('.btn-toggle'); buttons.forEach(btn => { btn.className = 'btn-toggle'; const mode = btn.getAttribute('onclick')?.match(/'([^']+)'\)$/)?.[1]; if (mode && mode.toLowerCase() === currentMode.toLowerCase()) { if (mode === 'Active') btn.classList.add('active-green'); else if (mode === 'SellOnly') btn.classList.add('active-yellow'); else btn.classList.add('active-red'); } }); } async function setTradingMode(type, mode) { try { const res = await fetch('/api/dashboard/trading-mode', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ modeType: type, targetMode: mode }) }); if (res.ok) refreshDashboard(); } catch (e) {} } async function togglePause() { try { const res = await fetch('/api/dashboard/trading-mode', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ modeType: 'pause', targetMode: '' }) }); if (res.ok) refreshDashboard(); } catch (e) {} } // ==================== Trading beenden (#563) ==================== const STOP_TRADING_LOCK_SECONDS = 10; let stopTradingTimer = null; function openStopTradingModal() { const modal = document.getElementById('modalStopTrading'); const btn = document.getElementById('btnConfirmStopTrading'); const err = document.getElementById('stopTradingError'); if (!modal || !btn) return; err.hidden = true; let remaining = STOP_TRADING_LOCK_SECONDS; btn.disabled = true; btn.textContent = `Trading beenden (${remaining} s)`; modal.style.display = 'flex'; clearInterval(stopTradingTimer); stopTradingTimer = setInterval(() => { remaining--; if (remaining > 0) { btn.textContent = `Trading beenden (${remaining} s)`; return; } clearInterval(stopTradingTimer); stopTradingTimer = null; btn.textContent = 'Trading beenden'; btn.disabled = false; }, 1000); } function closeStopTradingModal() { clearInterval(stopTradingTimer); stopTradingTimer = null; const modal = document.getElementById('modalStopTrading'); if (modal) modal.style.display = 'none'; } async function confirmStopTrading() { const btn = document.getElementById('btnConfirmStopTrading'); const err = document.getElementById('stopTradingError'); btn.disabled = true; try { const res = await fetch('/api/dashboard/stop-trading', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ confirmed: true }) }); if (!res.ok) throw new Error(await errorText(res)); closeStopTradingModal(); refreshDashboard(); } catch (e) { err.textContent = `Trading konnte nicht beendet werden: ${e.message}`; err.hidden = false; btn.disabled = false; } } // Dashboard Account-Übersicht async function loadDashboardAccounts() { try { const res = await fetch('/api/accounts'); if (!res.ok) return; const accounts = await res.json(); const tbody = document.getElementById('tblDashAccounts'); if (!tbody) return; if (accounts.length === 0) { tbody.innerHTML = 'Keine Konten konfiguriert.'; return; } tbody.innerHTML = accounts.map(a => ` ${escapeHtml(a.name || `#${a.accountId}`)}${a.isDemo ? ' (Demo)' : ''} ${escapeHtml(a.modules || '—')} ${a.walletAddress ? `Profil ↗` : '—'} ${formatCurrency(a.portfolioValue)} ${a.pnl3d >= 0 ? '+' : ''}${formatCurrency(a.pnl3d)} ${Number(a.winRate3d || 0).toFixed(1)} % ${a.overallPnl >= 0 ? '+' : ''}${formatCurrency(a.overallPnl)} `).join(''); } catch (e) {} } // Dashboard Live-Überblick Widgets async function loadOverviewWidgets() { try { const res = await fetch('/api/dashboard/overview-widgets'); if (!res.ok) return; const data = await res.json(); // 1. Modul-KPIs const kpisContainer = document.getElementById('dashModuleKpis'); if (kpisContainer && data.moduleKpis) { if (data.moduleKpis.length === 0) { kpisContainer.innerHTML = '
Keine Modul-KPIs verfügbar.
'; } else { kpisContainer.innerHTML = data.moduleKpis.map(k => `
${escapeHtml(k.moduleName)}
${escapeHtml(k.text)}
`).join(''); } } // 2. Auffällige Trades (24h) const notableTbody = document.getElementById('tblNotableTrades'); const notableBadge = document.getElementById('badgeNotableCount'); if (notableTbody && data.notableTrades) { if (notableBadge) notableBadge.textContent = data.notableTrades.length; if (data.notableTrades.length === 0) { notableTbody.innerHTML = 'Keine auffälligen Trades in den letzten 24h.'; } else { notableTbody.innerHTML = data.notableTrades.map(t => ` ${escapeHtml(t.module)} ${escapeHtml(t.market)} ${t.pnl >= 0 ? '+' : ''}${Number(t.pnl).toFixed(2)} $ ${t.pnlPct >= 0 ? '+' : ''}${Number(t.pnlPct).toFixed(1)}% `).join(''); } } // 3. Warnungen & Fehler (heute) const alertsContainer = document.getElementById('containerAlerts'); const alertsBadge = document.getElementById('badgeAlertsCount'); if (alertsContainer && data.alerts) { if (alertsBadge) alertsBadge.textContent = data.alerts.length; if (data.alerts.length === 0) { alertsContainer.innerHTML = '
Keine Warnungen heute registriert.
'; } else { alertsContainer.innerHTML = data.alerts.map(a => `
[${escapeHtml(a.time)}] ${escapeHtml(a.level)} ${escapeHtml(a.message)}
`).join(''); } } // 4. Supervisor-KI (letzter Bericht) const supervisorEl = document.getElementById('snippetSupervisor'); const supervisorBadge = document.getElementById('badgeSupervisorTime'); if (supervisorEl && data.supervisorSnippet) { const s = data.supervisorSnippet; if (supervisorBadge) supervisorBadge.textContent = s.createdAt || '—'; if (s.available) { supervisorEl.textContent = `[${s.createdAt}] ${s.profile} · ${s.model}\n\n${s.answer}`; } else { supervisorEl.textContent = s.answer || 'Kein Bericht verfügbar.'; } } } catch (e) {} } // Accounts View async function loadAccounts() { try { const res = await fetch('/api/accounts'); if (!res.ok) return; const accounts = await res.json(); const tbody = document.getElementById('tblAccounts'); if (accounts.length === 0) { tbody.innerHTML = 'Keine Konten gefunden.'; return; } tbody.innerHTML = accounts.map(a => ` ${a.accountId} ${escapeHtml(a.name)} ● ${a.isActive ? 'Aktiv' : 'Inaktiv'} ${a.isDemo ? 'Demo' : 'Live'} ${formatCurrency(a.usdcBalance)} ${a.pnl3d >= 0 ? '+' : ''}${formatCurrency(a.pnl3d)} ${Number(a.winRate3d || 0).toFixed(1)} % ${a.overallPnl >= 0 ? '+' : ''}${formatCurrency(a.overallPnl)} ${shortenAddress(a.walletAddress || a.proxyWalletAddress)} ${a.walletAddress ? `Profil ↗` : '—'} `).join(''); } catch (e) {} } // Jobs View async function loadJobs() { try { const res = await fetch('/api/jobs'); if (!res.ok) return; const jobs = await res.json(); const tbody = document.getElementById('tblJobs'); if (jobs.length === 0) { tbody.innerHTML = 'Keine Jobs registriert.'; return; } tbody.innerHTML = jobs.map(j => ` ${escapeHtml(j.jobName)} ${escapeHtml(j.description)} ${j.lastRun ? new Date(j.lastRun).toLocaleTimeString() : '—'} ${j.nextRun ? new Date(j.nextRun).toLocaleTimeString() : '—'} ${escapeHtml(j.statusText)} ${j.canTriggerManually ? `` : ''} `).join(''); } catch (e) {} } async function toggleJob(jobName) { try { const res = await fetch(`/api/jobs/${encodeURIComponent(jobName)}/toggle`, { method: 'POST' }); if (res.ok) { loadJobs(); } } catch (e) {} } async function triggerJob(jobName) { try { await fetch(`/api/jobs/${encodeURIComponent(jobName)}/run`, { method: 'POST' }); setTimeout(loadJobs, 500); } catch (e) {} } // Modules View async function loadModules() { const container = document.getElementById('modulesContainer'); try { const res = await fetch('/api/modules'); if (!res.ok) throw new Error(await errorText(res)); const modules = await res.json(); refreshNavModules(modules); const views = { copytrading: ['copytrading', 'CopyTrading öffnen →'], resolutionfarming: ['resolutionfarming', 'Resolution Farming öffnen →'], supervisor: ['supervisor', 'AI Supervisor öffnen →'], accounting: ['accounting', 'Accounting & BWA öffnen →'] }; container.innerHTML = modules.map(m => { const view = views[m.name.toLowerCase()]; const openBtn = view && m.isRunning ? `` : ''; // #560: ausdruecklich der gewuenschte Zustand, nicht "umschalten". const toggleBtn = m.canToggle ? `` : ''; const statusColor = m.blocker ? 'var(--warning)' : (m.isRunning ? 'var(--positive)' : 'var(--muted)'); return `
${escapeHtml(m.name)} ● ${escapeHtml(m.status)}
${escapeHtml(m.hint || (m.isRunning ? 'Läuft planmäßig im Hintergrund.' : 'In dieser Sitzung nicht geladen.'))}
${toggleBtn}${openBtn}
`; }).join(''); } catch (e) { container.innerHTML = `
Module nicht ladbar
${escapeHtml(e.message)}
`; } } async function setModuleEnabled(name, enabled) { if (!enabled && !confirm(`Modul ${name} deaktivieren?\n\nEs läuft bis zum nächsten Neustart weiter und wird danach nicht mehr geladen.`)) return; try { const res = await fetch(`/api/modules/${encodeURIComponent(name)}/enabled`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled }) }); if (!res.ok) throw new Error(await errorText(res)); } catch (e) { alert(`Modul-Einstellung nicht gespeichert:\n\n${e.message}`); } loadModules(); } // Terminal & SSE Logs function initSseLogs() { const sseDot = document.getElementById('sseDot'); const sseStatus = document.getElementById('sseStatus'); if (sseSource) sseSource.close(); sseSource = new EventSource('/api/logs/stream'); sseSource.onopen = () => { sseDot.className = 'dot online'; sseStatus.textContent = 'Verbunden'; }; sseSource.onmessage = (event) => { if (!event.data || event.data.startsWith(':')) return; try { const entry = JSON.parse(event.data); appendLog(entry); } catch (e) {} }; sseSource.onerror = () => { sseDot.className = 'dot offline'; sseStatus.textContent = 'Verbindung getrennt (Reconnect …)'; }; } function appendLog(entry) { allLogs.push(entry); if (allLogs.length > 2000) allLogs.shift(); const filter = document.getElementById('logLevelFilter').value; if (filter !== 'ALL' && entry.level.toUpperCase() !== filter) return; renderLogEntry(entry); } function renderLogEntry(entry) { const container = document.getElementById('logContainer'); const div = document.createElement('div'); div.className = 'log-entry'; div.innerHTML = ` [${entry.time}] ${entry.level} ${escapeHtml(entry.msg)} `; container.appendChild(div); if (document.getElementById('chkAutoScroll').checked && currentView === 'logs') { container.scrollTop = container.scrollHeight; } } function applyLogFilter() { const container = document.getElementById('logContainer'); container.innerHTML = ''; const filter = document.getElementById('logLevelFilter').value; allLogs.forEach(entry => { if (filter === 'ALL' || entry.level.toUpperCase() === filter) { renderLogEntry(entry); } }); } function clearLogs() { allLogs = []; document.getElementById('logContainer').innerHTML = ''; } // ==================== Log-Viewer und Kopieren (#564) ==================== let logViewerState = { page: 1, totalPages: 1, items: [] }; let logViewerDaysLoaded = false; function switchLogTab(tab) { ['live', 'viewer'].forEach(t => { document.getElementById(`logTabBtn-${t}`)?.classList.toggle('active', t === tab); const panel = document.getElementById(`logTab-${t}`); if (panel) panel.hidden = t !== tab; }); if (tab === 'viewer' && !logViewerDaysLoaded) loadLogViewerDays(); } async function loadLogViewerDays() { const select = document.getElementById('logViewerDate'); const status = document.getElementById('logViewerStatus'); try { const res = await fetch('/api/logs/files'); if (!res.ok) throw new Error(await errorText(res)); const days = await res.json(); select.innerHTML = days.length ? days.map(d => ``).join('') : ''; logViewerDaysLoaded = true; } catch (e) { status.textContent = `Tage nicht ladbar: ${e.message}`; } } function formatLogDay(iso) { const [y, m, d] = String(iso).split('-'); return `${d}.${m}.${y}`; } function formatBytes(n) { return n >= 1048576 ? `${(n / 1048576).toFixed(1)} MB` : `${Math.max(1, Math.round(n / 1024))} KB`; } /** Eine Logzeile als DOM-Knoten. textContent statt innerHTML: Logtexte enthalten, was andere eingegeben haben. */ function buildLogRow(time, level, msg, cid) { const div = document.createElement('div'); div.className = 'log-entry'; const t = document.createElement('span'); t.className = 'log-time'; t.textContent = `[${time}]`; const l = document.createElement('span'); l.className = 'log-level'; l.classList.add(String(level).replace(/[^A-Za-z]/g, '')); l.textContent = level; const m = document.createElement('span'); m.className = 'log-msg'; m.textContent = cid ? `${msg} ⟨${cid}⟩` : msg; div.append(t, l, m); return div; } async function loadLogViewer(page) { const date = document.getElementById('logViewerDate').value; const status = document.getElementById('logViewerStatus'); const container = document.getElementById('logViewerContainer'); if (!date) { status.textContent = 'Kein Tag gewählt.'; return; } const params = new URLSearchParams({ date, page: String(page || 1), pageSize: '500' }); const level = document.getElementById('logViewerLevel').value; const cid = document.getElementById('logViewerCid').value.trim(); const q = document.getElementById('logViewerText').value.trim(); if (level) params.set('level', level); if (cid) params.set('cid', cid); if (q) params.set('q', q); status.textContent = 'Lade …'; try { const res = await fetch(`/api/logs/file?${params}`); if (!res.ok) throw new Error(await errorText(res)); const data = await res.json(); logViewerState = { page: data.page, totalPages: data.totalPages, items: data.items }; container.innerHTML = ''; // ts ist ISO mit Offset ("2026-09-12T14:03:22.123+02:00"); angezeigt wird die Uhrzeit. data.items.forEach(item => container.appendChild( buildLogRow(item.time.length >= 19 ? item.time.substring(11, 19) : item.time, item.level, item.msg, item.cid))); if (!data.items.length) container.innerHTML = '
Keine Einträge für diese Auswahl.
'; container.scrollTop = 0; let text = `${data.total.toLocaleString('de-DE')} Einträge · Seite ${data.page}/${data.totalPages}`; if (data.truncated) text += ` · nach ${data.maxMatches.toLocaleString('de-DE')} Treffern abgeschnitten – Filter enger fassen`; if (data.unreadableLines) text += ` · ${data.unreadableLines} unlesbare Zeilen übersprungen`; status.textContent = text; } catch (e) { container.innerHTML = ''; status.textContent = e.message; } } function changeLogViewerPage(delta) { const next = logViewerState.page + delta; if (next < 1 || next > logViewerState.totalPages) return; loadLogViewer(next); } /** * navigator.clipboard gibt es nur in sicheren Kontexten (https oder localhost). Über LAN/VPN läuft das * WebUI per http – ohne diesen Rückweg über ein unsichtbares Textfeld schlüge das Kopieren dort still fehl. */ async function copyText(text) { if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(text); return; } const area = document.createElement('textarea'); area.value = text; area.setAttribute('readonly', ''); area.style.position = 'fixed'; area.style.opacity = '0'; document.body.appendChild(area); area.select(); const ok = document.execCommand('copy'); area.remove(); if (!ok) throw new Error('Die Zwischenablage ist in diesem Browser nicht erreichbar.'); } async function copyWithFeedback(text, okMessage) { if (!text) { alert('Nichts zu kopieren.'); return; } try { await copyText(text); alert(okMessage); } catch (e) { alert(`Kopieren fehlgeschlagen: ${e.message}`); } } async function copyLiveLogs() { const filter = document.getElementById('logLevelFilter').value; const visible = allLogs.filter(e => filter === 'ALL' || e.level.toUpperCase() === filter); await copyWithFeedback(visible.map(e => `[${e.time}] ${e.level} ${e.msg}`).join('\n'), `${visible.length} Zeilen kopiert.`); } async function copyLogViewer() { const items = logViewerState.items; await copyWithFeedback( items.map(e => `[${e.time}] ${e.level}${e.cid ? ` ⟨${e.cid}⟩` : ''} ${e.msg}`).join('\n'), `${items.length} Zeilen kopiert.`); } // Settings View async function loadSettings() { loadServerSettings(); loadUpdateStatus(false); try { const res = await fetch('/api/settings/webui'); if (!res.ok) return; const opt = await res.json(); document.getElementById('cfgEnabled').textContent = opt.enabled ? 'aktiv' : 'deaktiviert'; document.getElementById('cfgPort').textContent = opt.port; document.getElementById('cfgBind').textContent = opt.localOnly ? 'nur lokal (127.0.0.1)' : 'im Netzwerk – alle Schnittstellen, unverschlüsseltes HTTP'; document.getElementById('cfgTokenStatus').textContent = opt.hasToken ? 'gesetzt' : 'nicht gesetzt – das WebUI ist gesperrt'; } catch (e) {} } // ==================== Updates (#565) ==================== async function loadUpdateStatus(refresh) { const box = document.getElementById('updateStatusBody'); try { const res = await fetch('/api/updates' + (refresh ? '?refresh=true' : '')); if (!res.ok) throw new Error(await errorText(res)); renderUpdateStatus(await res.json(), refresh); } catch (e) { if (box) { box.innerHTML = ''; appendDefinition(box, 'Stand', `Update-Status nicht abrufbar: ${e.message}`); } } } function appendDefinition(list, term, value) { const dt = document.createElement('dt'); dt.textContent = term; const dd = document.createElement('dd'); dd.textContent = value; list.append(dt, dd); } function renderUpdateStatus(u, refreshRequested) { let state; if (!u.enabled) state = u.error || 'Die Update-Prüfung ist nicht eingerichtet.'; else if (u.error) state = u.error; else if (u.updateAvailable) state = `Update verfügbar: v${u.latestVersion}${u.isCritical ? ' – empfohlenes Sicherheitsupdate' : ''}`; else state = 'Diese Fassung ist aktuell.'; const box = document.getElementById('updateStatusBody'); if (box) { box.innerHTML = ''; appendDefinition(box, 'Laufende Fassung', u.currentBuild); appendDefinition(box, 'Stand', state); appendDefinition(box, 'Geprüft', new Date(u.checkedAt).toLocaleString('de-DE') + (u.fromCache ? ' (zwischengespeichert)' : '')); if (u.updateAvailable && u.changelog) appendDefinition(box, 'Änderungen', u.changelog); appendDefinition(box, 'Installation', 'Nur im Desktop – sie beendet die Anwendung und damit auch das WebUI.'); } const msg = document.getElementById('updateStatusMsg'); if (msg) { msg.hidden = !(refreshRequested && u.retryAfterSeconds); if (!msg.hidden) msg.textContent = `Gerade erst geprüft – erneut möglich in ${u.retryAfterSeconds} s.`; } const banner = document.getElementById('dashUpdateBanner'); if (banner) { banner.hidden = !u.updateAvailable; if (u.updateAvailable) banner.textContent = `Update verfügbar: v${u.latestVersion}${u.isCritical ? ' (empfohlenes Sicherheitsupdate)' : ''} – Installation über den Desktop.`; } } // ==================== Server-Einstellungen (#561) ==================== let serverSettingsLoaded = {}; async function loadServerSettings() { const host = document.getElementById('serverSettingsSections'); const secrets = document.getElementById('serverSecretsList'); if (!host) return; try { const res = await fetch('/api/settings/server'); if (!res.ok) throw new Error(await errorText(res)); const data = await res.json(); serverSettingsLoaded = {}; data.sections.forEach(sec => sec.fields.forEach(f => { serverSettingsLoaded[f.key] = f.value; })); host.innerHTML = data.sections.map(sec => `
${escapeHtml(sec.title)}
${sec.fields.map(renderServerField).join('')}
`).join(''); secrets.innerHTML = data.secrets .map(x => `
${escapeHtml(x.label)}
${escapeHtml(x.status)}
`) .join(''); } catch (e) { host.innerHTML = `
Server-Einstellungen nicht ladbar: ${escapeHtml(e.message)}
`; } } function renderServerField(f) { const id = 'srv_' + f.key; const key = escapeHtml(f.key); const badge = !f.editable ? 'nur im Desktop' : `${f.requiresRestart ? 'nach Neustart' : 'sofort'}`; let control; if (!f.editable) { control = `${escapeHtml(String(f.value ?? '')) || '—'}`; } else if (f.kind === 'bool') { control = ``; } else if (f.kind === 'int') { control = ``; } else if (f.kind === 'choice') { control = `'; } else { control = ``; } return `
${control}
${escapeHtml(f.description)}
`; } async function saveServerSettings() { // Nur geaenderte Felder: Wer Zeitzone und VPN nicht angefasst hat, soll sie auch nicht mitschreiben. const values = {}; document.querySelectorAll('#serverSettingsSections [data-key]').forEach(el => { const key = el.dataset.key; let v; if (el.type === 'checkbox') v = el.checked; else if (el.type === 'number') v = el.value === '' ? null : Number(el.value); else v = el.value; if (v !== serverSettingsLoaded[key]) values[key] = v; }); if (Object.keys(values).length === 0) { showServerSettingsMsg('Keine Änderungen.', false); return; } try { const res = await fetch('/api/settings/server', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ values }) }); if (!res.ok) throw new Error(await errorText(res)); const data = await res.json(); const later = data.changed.filter(c => c.requiresRestart).map(c => c.label); showServerSettingsMsg(`Gespeichert: ${data.changed.map(c => c.label).join(', ')}.` + (later.length ? ` Erst nach einem Neustart wirksam: ${later.join(', ')}.` : ' Sofort wirksam.'), false); loadServerSettings(); } catch (e) { showServerSettingsMsg(e.message, true); } } function showServerSettingsMsg(text, isError) { const msg = document.getElementById('serverSettingsMsg'); if (!msg) return; msg.textContent = text; msg.className = isError ? 'rate-warning' : 'form-desc'; msg.style.margin = '0'; msg.hidden = false; } // Auth async function logout() { try { await fetch('/api/auth/logout', { method: 'POST' }); } finally { window.location.href = '/login'; } } // Helpers function formatCurrency(val) { if (val === undefined || val === null) return '0.00 $'; return Number(val).toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' $'; } function shortenAddress(addr) { if (!addr) return '—'; if (addr.length <= 12) return addr; return addr.substring(0, 6) + '…' + addr.substring(addr.length - 4); } function escapeHtml(str) { if (!str) return ''; return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } // Zeichenkette als JS-Literal in ein onclick-Attribut. escapeHtml allein genuegt // dort nicht: Es laesst ' unberuehrt, und ein Name wie O'Brien beendet das // Literal vorzeitig - der Knopf ist dann tot. /** * Fehlertext einer abgelehnten Antwort. Die Schreibwege liefern seit #534 eine Begruendung * mit ("... Es wurde nichts geaendert."); ein blosses "HTTP 500" verschenkt genau die. */ async function errorText(res) { try { const body = await res.json(); return body.detail || body.error || body.title || `HTTP ${res.status}`; } catch (e) { return `HTTP ${res.status}`; } } function jsArg(str) { return escapeHtml(JSON.stringify(String(str === undefined || str === null ? '' : str))); } // ==================== Analytics Logic & Charts ==================== let analyticsCurrentSubTab = 'charts'; let analyticsPage = 1; let analyticsSearchTimeout = null; let analyticsData = null; function switchAnalyticsSubTab(tabName) { analyticsCurrentSubTab = tabName; const btnCharts = document.getElementById('tabBtnAnalyticsCharts'); const btnHistory = document.getElementById('tabBtnAnalyticsHistory'); const subCharts = document.getElementById('analyticsSubTabCharts'); const subHistory = document.getElementById('analyticsSubTabHistory'); if (tabName === 'charts') { btnCharts.classList.add('active'); btnHistory.classList.remove('active'); subCharts.style.display = 'block'; subHistory.style.display = 'none'; if (analyticsData && analyticsData.charts) { renderAnalyticsCharts(analyticsData.charts); } } else { btnHistory.classList.add('active'); btnCharts.classList.remove('active'); subCharts.style.display = 'none'; subHistory.style.display = 'block'; } } function onAnalyticsFilterChanged() { analyticsPage = 1; loadAnalytics(); } function onHistorySearchInput() { if (analyticsSearchTimeout) clearTimeout(analyticsSearchTimeout); analyticsSearchTimeout = setTimeout(() => { analyticsPage = 1; loadAnalytics(); }, 300); } function onHistoryWinLossChanged() { analyticsPage = 1; loadAnalytics(); } function changeAnalyticsPage(delta) { if (!analyticsData || !analyticsData.trades) return; const newPage = analyticsPage + delta; if (newPage >= 1 && newPage <= analyticsData.trades.totalPages) { analyticsPage = newPage; loadAnalytics(); } } async function loadAnalytics() { const accEl = document.getElementById('selAnalyticsAccount'); const modEl = document.getElementById('selAnalyticsModule'); const modeEl = document.getElementById('selAnalyticsMode'); const rangeEl = document.getElementById('selAnalyticsRange'); const searchEl = document.getElementById('tbHistorySearch'); const winLossEl = document.getElementById('selHistoryWinLoss'); const accountId = accEl ? accEl.value : '-1'; const module = modEl ? modEl.value : 'Alle Module'; const mode = modeEl ? modeEl.value : 'Alle'; const days = rangeEl ? rangeEl.value : '30'; const search = searchEl ? searchEl.value.trim() : ''; const winLoss = winLossEl ? winLossEl.value : 'Alle'; const params = new URLSearchParams(); if (accountId !== '-1') params.append('accountId', accountId); if (module !== 'Alle Module') params.append('module', module); if (mode !== 'Alle') params.append('mode', mode); if (days !== '0') params.append('days', days); if (search) params.append('search', search); if (winLoss !== 'Alle') params.append('winLoss', winLoss); params.append('page', analyticsPage.toString()); params.append('pageSize', '50'); try { const res = await fetch(`/api/analytics?${params.toString()}`); if (!res.ok) return; analyticsData = await res.json(); // 1. Populate Dropdowns if not populated populateAnalyticsFilterOptions(analyticsData.filterOptions); // 2. Render KPIs renderAnalyticsKpis(analyticsData.kpis); // 3. Render Charts if (analyticsCurrentSubTab === 'charts') { renderAnalyticsCharts(analyticsData.charts); } // 4. Render Trade History renderAnalyticsHistory(analyticsData.trades); } catch (e) {} } function populateAnalyticsFilterOptions(opts) { if (!opts) return; const accEl = document.getElementById('selAnalyticsAccount'); const modEl = document.getElementById('selAnalyticsModule'); if (accEl && accEl.options.length <= 1 && opts.accounts) { const currentVal = accEl.value; accEl.innerHTML = opts.accounts.map(a => ``).join(''); accEl.value = currentVal; } if (modEl && modEl.options.length <= 1 && opts.modules) { const currentVal = modEl.value; modEl.innerHTML = opts.modules.map(m => ``).join(''); modEl.value = currentVal; } } function renderAnalyticsKpis(kpis) { if (!kpis) return; const pnlEl = document.getElementById('kpiNetPnl'); if (pnlEl) { pnlEl.textContent = `${kpis.netPnl >= 0 ? '+' : ''}${formatCurrency(kpis.netPnl)}`; pnlEl.style.color = kpis.netPnl >= 0 ? 'var(--positive)' : 'var(--negative)'; } const winEl = document.getElementById('kpiWinRate'); if (winEl) winEl.textContent = `${Number(kpis.winRatePct || 0).toFixed(1)} %`; const tradesEl = document.getElementById('kpiTradeCount'); if (tradesEl) tradesEl.textContent = kpis.tradeCount || 0; const avgEl = document.getElementById('kpiAvgPnl'); if (avgEl) { avgEl.textContent = `${kpis.avgPnlPerTrade >= 0 ? '+' : ''}${formatCurrency(kpis.avgPnlPerTrade)}`; avgEl.style.color = kpis.avgPnlPerTrade >= 0 ? 'var(--positive)' : 'var(--negative)'; } const pfEl = document.getElementById('kpiProfitFactor'); if (pfEl) { pfEl.textContent = kpis.profitFactor; pfEl.style.color = Number(kpis.profitFactor) >= 1.0 || kpis.profitFactor === '∞' ? 'var(--positive)' : 'var(--negative)'; } } function renderAnalyticsCharts(charts) { if (!charts) return; renderEquitySvg(charts.equityCurve); renderModuleSvg(charts.pnlByModule); renderDaySvg(charts.pnlByDay); } function renderEquitySvg(points) { const svg = document.getElementById('svgEquityChart'); const container = document.getElementById('containerEquityChart'); const countBadge = document.getElementById('lblEquityTradesCount'); if (!svg || !container) return; if (countBadge) countBadge.textContent = `${(points || []).length} Datenpunkte`; const w = container.clientWidth || 700; const h = container.clientHeight || 260; svg.setAttribute('viewBox', `0 0 ${w} ${h}`); svg.innerHTML = ''; if (!points || points.length === 0) { svg.innerHTML = `Keine abgeschlossenen Trades im ausgewählten Zeitraum.`; return; } const padL = 65, padR = 25, padT = 20, padB = 35; const plotW = w - padL - padR; const plotH = h - padT - padB; let minVal = Math.min(0, ...points.map(p => p.cumulative)); let maxVal = Math.max(0, ...points.map(p => p.cumulative)); if (minVal === maxVal) { minVal -= 10; maxVal += 10; } const span = maxVal - minVal; const getY = (val) => padT + plotH - ((val - minVal) / span) * plotH; const getX = (idx) => padL + (idx / Math.max(1, points.length - 1)) * plotW; let svgContent = ''; // Horizontal grid lines & Y labels (5 lines) for (let i = 0; i <= 4; i++) { const val = minVal + (span * i) / 4; const y = getY(val); svgContent += ` ${Number(val).toFixed(2)} $ `; } // Zero-line if cross 0 if (minVal < 0 && maxVal > 0) { const zeroY = getY(0); svgContent += ``; } // Line Path & Area Fill let pathD = ''; let areaD = `M ${getX(0)} ${getY(0)}`; points.forEach((p, idx) => { const x = getX(idx); const y = getY(p.cumulative); if (idx === 0) { pathD += `M ${x} ${y}`; areaD = `M ${x} ${y}`; } else { pathD += ` L ${x} ${y}`; areaD += ` L ${x} ${y}`; } }); const lastX = getX(points.length - 1); const zeroOrBottomY = getY(0); areaD += ` L ${lastX} ${zeroOrBottomY} L ${getX(0)} ${zeroOrBottomY} Z`; svgContent += ` `; // Date Ticks on X-Axis (up to 5 ticks) const tickCount = Math.min(5, points.length); for (let t = 0; t < tickCount; t++) { const idx = Math.floor((t / Math.max(1, tickCount - 1)) * (points.length - 1)); const p = points[idx]; const x = getX(idx); const dateStr = p.at.substring(5, 16); // "MM-dd HH:mm" svgContent += ` ${dateStr} `; } svg.innerHTML = svgContent; } function renderModuleSvg(modules) { const svg = document.getElementById('svgModuleChart'); const container = document.getElementById('containerModuleChart'); if (!svg || !container) return; const w = container.clientWidth || 400; const h = container.clientHeight || 260; svg.setAttribute('viewBox', `0 0 ${w} ${h}`); svg.innerHTML = ''; if (!modules || modules.length === 0) { svg.innerHTML = `Keine Modul-Daten verfügbar.`; return; } const padL = 50, padR = 20, padT = 25, padB = 40; const plotW = w - padL - padR; const plotH = h - padT - padB; let minVal = Math.min(0, ...modules.map(m => m.pnl)); let maxVal = Math.max(0, ...modules.map(m => m.pnl)); if (minVal === maxVal) { minVal -= 5; maxVal += 5; } const span = maxVal - minVal; const getY = (val) => padT + plotH - ((val - minVal) / span) * plotH; const zeroY = getY(0); let svgContent = ''; svgContent += ``; const barWidth = Math.min(60, (plotW / modules.length) * 0.6); const colStep = plotW / modules.length; modules.forEach((m, idx) => { const x = padL + idx * colStep + (colStep - barWidth) / 2; const y = getY(m.pnl); const barH = Math.max(2, Math.abs(y - zeroY)); const barY = m.pnl >= 0 ? y : zeroY; const color = m.pnl >= 0 ? 'var(--positive)' : 'var(--negative)'; svgContent += ` ${m.pnl >= 0 ? '+' : ''}${Number(m.pnl).toFixed(1)} $ ${escapeHtml(m.module)} `; }); svg.innerHTML = svgContent; } function renderDaySvg(days) { const svg = document.getElementById('svgDayChart'); const container = document.getElementById('containerDayChart'); if (!svg || !container) return; const w = container.clientWidth || 400; const h = container.clientHeight || 260; svg.setAttribute('viewBox', `0 0 ${w} ${h}`); svg.innerHTML = ''; if (!days || days.length === 0) { svg.innerHTML = `Keine Tages-Daten verfügbar.`; return; } const padL = 50, padR = 20, padT = 25, padB = 40; const plotW = w - padL - padR; const plotH = h - padT - padB; let minVal = Math.min(0, ...days.map(d => d.pnl)); let maxVal = Math.max(0, ...days.map(d => d.pnl)); if (minVal === maxVal) { minVal -= 5; maxVal += 5; } const span = maxVal - minVal; const getY = (val) => padT + plotH - ((val - minVal) / span) * plotH; const zeroY = getY(0); let svgContent = ''; svgContent += ``; // Limit to latest 30 days if many const displayDays = days.slice(-30); const barWidth = Math.max(4, Math.min(24, (plotW / displayDays.length) * 0.7)); const colStep = plotW / displayDays.length; displayDays.forEach((d, idx) => { const x = padL + idx * colStep + (colStep - barWidth) / 2; const y = getY(d.pnl); const barH = Math.max(2, Math.abs(y - zeroY)); const barY = d.pnl >= 0 ? y : zeroY; const color = d.pnl >= 0 ? 'var(--positive)' : 'var(--negative)'; const dayLabel = d.day.length >= 10 ? d.day.substring(8, 10) + '.' + d.day.substring(5, 7) : d.day; svgContent += ` ${d.day}: ${d.pnl >= 0 ? '+' : ''}${Number(d.pnl).toFixed(2)} $ `; // Draw date label only every few bars if many bars const stepInterval = Math.ceil(displayDays.length / 8); if (idx % stepInterval === 0 || idx === displayDays.length - 1) { svgContent += `${dayLabel}`; } }); svg.innerHTML = svgContent; } function renderAnalyticsHistory(tradesData) { const tbody = document.getElementById('tblAnalyticsTrades'); const paginationInfo = document.getElementById('lblPaginationInfo'); const curPageLabel = document.getElementById('lblCurrentPage'); const prevBtn = document.getElementById('btnPrevPage'); const nextBtn = document.getElementById('btnNextPage'); if (!tbody || !tradesData) return; if (curPageLabel) curPageLabel.textContent = tradesData.page; if (prevBtn) prevBtn.disabled = tradesData.page <= 1; if (nextBtn) nextBtn.disabled = tradesData.page >= tradesData.totalPages; const startIdx = (tradesData.page - 1) * tradesData.pageSize + 1; const endIdx = Math.min(tradesData.totalCount, tradesData.page * tradesData.pageSize); if (paginationInfo) { paginationInfo.textContent = tradesData.totalCount === 0 ? 'Keine Trades gefunden.' : `Zeige ${startIdx}–${endIdx} von ${tradesData.totalCount} Trades (Seite ${tradesData.page} von ${tradesData.totalPages || 1})`; } if (!tradesData.items || tradesData.items.length === 0) { tbody.innerHTML = 'Keine Trades gefunden, die den Kriterien entsprechen.'; return; } tbody.innerHTML = tradesData.items.map(t => ` ${escapeHtml(t.closedAt)} ${escapeHtml(t.module)} ${escapeHtml(t.account)} ${t.isDemo ? 'Demo' : 'Live'} ${escapeHtml(t.market)} ${escapeHtml(t.outcome)} ${escapeHtml(t.side)} ${Number(t.entryPrice || 0).toFixed(3)} ${Number(t.exitPrice || 0).toFixed(3)} ${Number(t.size || 0).toFixed(2)} ${t.realizedPnl >= 0 ? '+' : ''}${Number(t.realizedPnl).toFixed(2)} $ ${t.pnlPercent >= 0 ? '+' : ''}${Number(t.pnlPercent).toFixed(1)}% `).join(''); } window.addEventListener('resize', () => { if (currentView === 'analytics' && analyticsCurrentSubTab === 'charts' && analyticsData && analyticsData.charts) { renderAnalyticsCharts(analyticsData.charts); } }); // ========================================== // COPYTRADING MODULE // ========================================== let currentCtTab = 'traders'; let cachedCtTraders = []; let ctClosedDebounceTimer = null; let ctClosedCurrentPage = 1; function switchCtTab(tabName) { currentCtTab = tabName; document.querySelectorAll('.module-tab-btn[id^="ctTabBtn-"]').forEach(btn => btn.classList.remove('active')); document.querySelectorAll('.ct-tab-content').forEach(div => { div.classList.remove('active'); div.style.display = 'none'; }); const activeBtn = document.getElementById(`ctTabBtn-${tabName}`); if (activeBtn) activeBtn.classList.add('active'); const activeDiv = document.getElementById(`ctTab-${tabName}`); if (activeDiv) { activeDiv.classList.add('active'); activeDiv.style.display = 'block'; } refreshCurrentCtTab(); } function refreshCurrentCtTab() { if (currentCtTab === 'traders') loadCtTraders(); else if (currentCtTab === 'open') loadCtOpenTrades(); else if (currentCtTab === 'closed') loadCtClosedTrades(ctClosedCurrentPage); else if (currentCtTab === 'settings') loadCtSettings(); } function loadCopyTrading() { switchCtTab(currentCtTab); } // 1. Master-Trader async function loadCtTraders() { const tbody = document.getElementById('ctTradersTbody'); const summary = document.getElementById('ctTradersSummary'); if (!tbody) return; try { const res = await fetch('/api/modules/copytrading/traders'); if (!res.ok) throw new Error('HTTP ' + res.status); cachedCtTraders = await res.json(); if (summary) summary.textContent = `${cachedCtTraders.length} Master-Trader konfiguriert (${cachedCtTraders.filter(t => t.isActive).length} aktiv)`; if (cachedCtTraders.length === 0) { tbody.innerHTML = 'Keine Master-Trader vorhanden. Klicke auf "+ Neuer Master-Trader" um einen anzulegen.'; return; } tbody.innerHTML = cachedCtTraders.map(t => { const shortWallet = t.walletAddress && t.walletAddress.length > 10 ? `${t.walletAddress.substring(0, 6)}…${t.walletAddress.substring(t.walletAddress.length - 4)}` : (t.walletAddress || '—'); const winrate = Number(t.winrate30t || 0).toFixed(1); const pnl = Number(t.copyPnl30d || 0); const pnlColor = pnl >= 0 ? 'var(--positive)' : 'var(--negative)'; const pf = Number(t.copyProfitFactor || 0) >= 999 ? '∞' : Number(t.copyProfitFactor || 0).toFixed(2); const accCount = (t.assignedAccountIds && t.assignedAccountIds.length) ? `${t.assignedAccountIds.length} Account(s)` : 'Alle / Keine'; return ` #${t.id} ${escapeHtml(t.displayName)} ${escapeHtml(shortWallet)} ↗ ${escapeHtml(t.category)} ${t.isActive ? 'Aktiv' : 'Inaktiv'} ${t.autoPauseEnabled ? '✓ An' : 'Aus'} ${t.makerEntry ? 'Maker' : 'Taker'} ${winrate}% ${pnl >= 0 ? '+' : ''}${pnl.toFixed(2)} $ ${pf} ${escapeHtml(accCount)} `; }).join(''); } catch (e) { tbody.innerHTML = `Fehler beim Laden der Master-Trader: ${escapeHtml(e.message)}`; } } // Master Trader Modal async function openTraderModal(traderId) { const modal = document.getElementById('modalTrader'); const title = document.getElementById('modalTraderTitle'); const idInput = document.getElementById('traderModalId'); const nameInput = document.getElementById('traderModalName'); const walletInput = document.getElementById('traderModalWallet'); const categorySelect = document.getElementById('traderModalCategory'); const activeCheck = document.getElementById('traderModalActive'); const autoPauseCheck = document.getElementById('traderModalAutoPause'); const makerCheck = document.getElementById('traderModalMaker'); const hiddenCheck = document.getElementById('traderModalHidden'); const descInput = document.getElementById('traderModalDesc'); const reasoningInput = document.getElementById('traderModalReasoning'); const accList = document.getElementById('traderModalAccountsList'); let trader = null; if (traderId != null) { trader = cachedCtTraders.find(t => t.id === traderId); } if (title) title.textContent = trader ? `Master-Trader #${trader.id} bearbeiten` : 'Neuen Master-Trader anlegen'; if (idInput) idInput.value = trader ? trader.id : 0; if (nameInput) nameInput.value = trader ? trader.displayName : ''; if (walletInput) walletInput.value = trader ? trader.walletAddress : ''; if (categorySelect) categorySelect.value = trader ? (trader.category || 'NEW_BIG_BET') : 'NEW_BIG_BET'; if (activeCheck) activeCheck.checked = trader ? trader.isActive : true; if (autoPauseCheck) autoPauseCheck.checked = trader ? trader.autoPauseEnabled : true; if (makerCheck) makerCheck.checked = trader ? trader.makerEntry : false; if (hiddenCheck) hiddenCheck.checked = trader ? trader.isHidden : false; if (descInput) descInput.value = trader ? (trader.description || '') : ''; if (reasoningInput) reasoningInput.value = trader ? (trader.reasoning || '') : ''; // Accounts list if (accList) { accList.innerHTML = '
Lade Konten …
'; try { const accRes = await fetch('/api/accounts'); if (accRes.ok) { const accounts = await accRes.json(); const assignedIds = new Set(trader ? trader.assignedAccountIds : []); accList.innerHTML = accounts.map(a => ` `).join(''); } } catch { accList.innerHTML = '
Konten konnten nicht geladen werden.
'; } } if (modal) modal.style.display = 'flex'; } function closeTraderModal() { const modal = document.getElementById('modalTrader'); if (modal) modal.style.display = 'none'; } async function saveTraderModal(e) { e.preventDefault(); const id = parseInt(document.getElementById('traderModalId').value) || 0; const displayName = document.getElementById('traderModalName').value.trim(); const walletAddress = document.getElementById('traderModalWallet').value.trim(); const category = document.getElementById('traderModalCategory').value; const isActive = document.getElementById('traderModalActive').checked; const autoPauseEnabled = document.getElementById('traderModalAutoPause').checked; const makerEntry = document.getElementById('traderModalMaker').checked; const isHidden = document.getElementById('traderModalHidden').checked; const description = document.getElementById('traderModalDesc').value.trim(); const reasoning = document.getElementById('traderModalReasoning').value.trim(); const assignedAccountIds = Array.from(document.querySelectorAll('.chk-trader-acc:checked')) .map(cb => parseInt(cb.value)); const payload = { id, displayName, walletAddress, category, isActive, autoPauseEnabled, makerEntry, isHidden, description, reasoning, assignedAccountIds }; try { const res = await fetch('/api/modules/copytrading/traders', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (!res.ok) throw new Error(await errorText(res)); closeTraderModal(); loadCtTraders(); } catch (err) { alert('Fehler beim Speichern: ' + err.message); } } async function deleteTrader(id, name) { if (!confirm(`Master-Trader #${id} (${name}) wirklich löschen?`)) return; try { const res = await fetch(`/api/modules/copytrading/traders/${id}`, { method: 'DELETE' }); if (!res.ok) throw new Error(await errorText(res)); loadCtTraders(); } catch (err) { alert('Fehler beim Löschen: ' + err.message); } } // 2. Offene Positionen async function loadCtOpenTrades() { const tbody = document.getElementById('ctOpenTbody'); const summary = document.getElementById('ctOpenSummary'); if (!tbody) return; try { const res = await fetch('/api/modules/copytrading/open-trades'); if (!res.ok) throw new Error('HTTP ' + res.status); const data = await res.json(); if (summary) { const unrealColor = data.totalUnrealized >= 0 ? 'var(--positive)' : 'var(--negative)'; summary.innerHTML = `${data.count} offene Position(en) · Gesamteinsatz ${Number(data.totalAmount || 0).toFixed(2)} $ · Buchgewinn ${data.totalUnrealized >= 0 ? '+' : ''}${Number(data.totalUnrealized || 0).toFixed(2)} $`; } if (!data.trades || data.trades.length === 0) { tbody.innerHTML = 'Aktuell keine offenen CopyTrading-Positionen vorhanden.'; return; } tbody.innerHTML = data.trades.map(p => { const unreal = Number(p.unrealizedPnl || 0); const unrealPct = Number(p.unrealizedPct || 0); const unrealColor = unreal >= 0 ? 'var(--positive)' : 'var(--negative)'; return ` ${escapeHtml(p.account)} ${escapeHtml(p.trader)} ${escapeHtml(p.market)} ${escapeHtml(p.outcome)} ${escapeHtml(p.side)} ${Number(p.entryPrice || 0).toFixed(3)} ${Number(p.currentPrice || 0).toFixed(3)} ${Number(p.size || 0).toFixed(2)} ${Number(p.amountUsd || 0).toFixed(2)} $ ${unreal >= 0 ? '+' : ''}${unreal.toFixed(2)} $ (${unrealPct >= 0 ? '+' : ''}${unrealPct.toFixed(1)}%) ${escapeHtml(p.status)} ${escapeHtml(p.openedAt)} `; }).join(''); } catch (e) { tbody.innerHTML = `Fehler: ${escapeHtml(e.message)}`; } } // 3. Geschlossene Trades async function loadCtClosedTrades(page = 1) { ctClosedCurrentPage = page; const tbody = document.getElementById('ctClosedTbody'); const summary = document.getElementById('ctClosedSummary'); const pageInfo = document.getElementById('ctClosedPageInfo'); const prevBtn = document.getElementById('btnCtClosedPrev'); const nextBtn = document.getElementById('btnCtClosedNext'); const traderSelect = document.getElementById('ctClosedFilterTrader'); const resultSelect = document.getElementById('ctClosedFilterResult'); const searchInput = document.getElementById('ctClosedSearch'); if (!tbody) return; // Populiere Master Filter Dropdown wenn nötig if (traderSelect && traderSelect.options.length <= 1 && cachedCtTraders.length > 0) { cachedCtTraders.forEach(t => { const opt = document.createElement('option'); opt.value = t.id; opt.textContent = `#${t.id} ${t.displayName}`; traderSelect.appendChild(opt); }); } const traderId = traderSelect ? parseInt(traderSelect.value) || 0 : 0; const result = resultSelect ? resultSelect.value : 'Alle'; const search = searchInput ? searchInput.value.trim() : ''; const params = new URLSearchParams(); if (traderId > 0) params.append('traderId', traderId); if (result && result !== 'Alle') params.append('result', result); if (search) params.append('search', search); params.append('page', page); params.append('pageSize', 50); try { const res = await fetch(`/api/modules/copytrading/closed-trades?${params.toString()}`); if (!res.ok) throw new Error('HTTP ' + res.status); const data = await res.json(); if (summary && data.summary) { const pnl = Number(data.summary.totalPnl || 0); const pnlColor = pnl >= 0 ? 'var(--positive)' : 'var(--negative)'; summary.innerHTML = `${data.summary.totalTrades} Trades · PnL ${pnl >= 0 ? '+' : ''}${pnl.toFixed(2)} $ · Winrate ${Number(data.summary.winRate || 0).toFixed(1)}%`; } if (pageInfo) pageInfo.textContent = `Seite ${data.page} von ${data.totalPages || 1} (${data.totalCount} gesamt)`; if (prevBtn) prevBtn.disabled = data.page <= 1; if (nextBtn) nextBtn.disabled = data.page >= data.totalPages; if (!data.items || data.items.length === 0) { tbody.innerHTML = 'Keine geschlossenen CopyTrading-Trades gefunden.'; return; } tbody.innerHTML = data.items.map(t => { const pnl = Number(t.realizedPnl || 0); const pnlPct = Number(t.pnlPercent || 0); const pnlColor = pnl >= 0 ? 'var(--positive)' : 'var(--negative)'; return ` #${t.tradeId} ${escapeHtml(t.account)} ${escapeHtml(t.trader)} ${escapeHtml(t.market)} ${escapeHtml(t.outcome)} ${escapeHtml(t.side)} ${Number(t.entryPrice || 0).toFixed(3)} ${Number(t.exitPrice || 0).toFixed(3)} ${Number(t.size || 0).toFixed(2)} ${pnl >= 0 ? '+' : ''}${pnl.toFixed(2)} $ ${pnlPct >= 0 ? '+' : ''}${pnlPct.toFixed(1)}% ${escapeHtml(t.closedAt)} ${escapeHtml(t.exitReason || '—')} `; }).join(''); } catch (e) { tbody.innerHTML = `Fehler: ${escapeHtml(e.message)}`; } } function debounceCtClosedSearch() { clearTimeout(ctClosedDebounceTimer); ctClosedDebounceTimer = setTimeout(() => loadCtClosedTrades(1), 300); } function changeCtClosedPage(delta) { loadCtClosedTrades(ctClosedCurrentPage + delta); } // 4. Account Settings async function loadCtSettings() { const sel = document.getElementById('ctSettingsAccountSelect'); if (!sel) return; if (sel.options.length === 0) { try { const accRes = await fetch('/api/accounts'); if (accRes.ok) { const accounts = await accRes.json(); sel.innerHTML = accounts.map(a => ` `).join(''); } } catch { } } loadCtSettingsForSelectedAccount(); } async function loadCtSettingsForSelectedAccount() { const sel = document.getElementById('ctSettingsAccountSelect'); if (!sel || !sel.value) return; const accId = parseInt(sel.value); try { const res = await fetch(`/api/modules/copytrading/settings/${accId}`); if (!res.ok) throw new Error('HTTP ' + res.status); const s = await res.json(); document.getElementById('ctPerMarketLimit').value = s.perMarketLimit ?? 5.0; document.getElementById('ctPerMasterLimit').value = s.perMasterLimit ?? 10.0; document.getElementById('ctMaxBuyPrice').value = s.maxBuyPrice ?? 0.98; document.getElementById('ctMaxPriceDiff').value = s.maxPriceDifference ?? 2.0; document.getElementById('ctTime6h').value = s.perMaxTime6h ?? 20.0; document.getElementById('ctTime24h').value = s.perMaxTime24h ?? 20.0; document.getElementById('ctTime72h').value = s.perMaxTime72h ?? 20.0; document.getElementById('ctTimeNone').value = s.perMaxTimeNone ?? 40.0; document.getElementById('ctProfitTarget').value = s.profitTarget ?? 9999; document.getElementById('ctPreRedeemLimit').value = s.preRedeemLimit ?? 0.0; document.getElementById('ctSellFloorPct').value = s.sellFloorPct ?? 15.0; document.getElementById('ctMaxSpreadPct').value = s.maxSpreadPct ?? 5.0; } catch (err) { console.error('Fehler beim Laden der CT-Settings:', err); } } async function saveCtSettings(e) { e.preventDefault(); const sel = document.getElementById('ctSettingsAccountSelect'); if (!sel || !sel.value) return; const accId = parseInt(sel.value); const payload = { accountId: accId, perMarketLimit: parseFloat(document.getElementById('ctPerMarketLimit').value) || 0, perMasterLimit: parseFloat(document.getElementById('ctPerMasterLimit').value) || 0, maxBuyPrice: parseFloat(document.getElementById('ctMaxBuyPrice').value) || 0, maxPriceDifference: parseFloat(document.getElementById('ctMaxPriceDiff').value) || 0, perMaxTime6h: parseFloat(document.getElementById('ctTime6h').value) || 0, perMaxTime24h: parseFloat(document.getElementById('ctTime24h').value) || 0, perMaxTime72h: parseFloat(document.getElementById('ctTime72h').value) || 0, perMaxTimeNone: parseFloat(document.getElementById('ctTimeNone').value) || 0, profitTarget: parseFloat(document.getElementById('ctProfitTarget').value) || 0, preRedeemLimit: parseFloat(document.getElementById('ctPreRedeemLimit').value) || 0, sellFloorPct: parseFloat(document.getElementById('ctSellFloorPct').value) || 0, maxSpreadPct: parseFloat(document.getElementById('ctMaxSpreadPct').value) || 0 }; try { const res = await fetch(`/api/modules/copytrading/settings/${accId}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (!res.ok) throw new Error(await errorText(res)); const statusEl = document.getElementById('ctSettingsStatus'); if (statusEl) { statusEl.style.display = 'inline'; setTimeout(() => { statusEl.style.display = 'none'; }, 3000); } } catch (err) { alert('Fehler beim Speichern: ' + err.message); } } // ========================================== // RESOLUTION FARMING MODULE // ========================================== let currentRfTab = 'candidates'; let rfHistoryDebounceTimer = null; function switchRfTab(tabName) { currentRfTab = tabName; document.querySelectorAll('.module-tab-btn[id^="rfTabBtn-"]').forEach(btn => btn.classList.remove('active')); document.querySelectorAll('.rf-tab-content').forEach(div => { div.classList.remove('active'); div.style.display = 'none'; }); const activeBtn = document.getElementById(`rfTabBtn-${tabName}`); if (activeBtn) activeBtn.classList.add('active'); const activeDiv = document.getElementById(`rfTab-${tabName}`); if (activeDiv) { activeDiv.classList.add('active'); activeDiv.style.display = 'block'; } refreshCurrentRfTab(); } function refreshCurrentRfTab() { if (currentRfTab === 'candidates') loadRfCandidates(); else if (currentRfTab === 'positions') loadRfPositions(); else if (currentRfTab === 'history') loadRfHistory(); else if (currentRfTab === 'settings') loadRfSettings(); } async function loadResolutionFarming() { const sel = document.getElementById('rfAccountSelect'); if (sel && sel.options.length === 0) { try { const accRes = await fetch('/api/accounts'); if (accRes.ok) { const accounts = await accRes.json(); sel.innerHTML = accounts.map(a => ` `).join(''); } } catch { } } switchRfTab(currentRfTab); } function loadRfForSelectedAccount() { refreshCurrentRfTab(); } function getSelectedRfAccountId() { const sel = document.getElementById('rfAccountSelect'); return sel ? parseInt(sel.value) || 0 : 0; } // 1. Kandidaten async function loadRfCandidates() { const tbody = document.getElementById('rfCandidatesTbody'); const summary = document.getElementById('rfCandidatesSummary'); if (!tbody) return; const accId = getSelectedRfAccountId(); try { const res = await fetch(`/api/modules/resolutionfarming/candidates?accountId=${accId}`); if (!res.ok) throw new Error('HTTP ' + res.status); const data = await res.json(); if (summary) summary.textContent = `${data.count} Kandidaten erfasst`; if (!data.candidates || data.candidates.length === 0) { tbody.innerHTML = 'Keine aktuellen Marktkandidaten im Scanner gefunden.'; return; } tbody.innerHTML = data.candidates.map(c => ` ${escapeHtml(c.scannedAt)} ${escapeHtml(c.marketQuestion)} ${escapeHtml(c.outcome)} ${escapeHtml(c.category)} ${Number(c.ask || 0).toFixed(3)} ${c.feeBps} ${Number(c.netEdgePct || 0).toFixed(2)}% ${Number(c.score || 0).toFixed(1)} ${escapeHtml(c.endDate)} ${c.accepted ? 'Akzeptiert' : 'Abgelehnt'} ${escapeHtml(c.rejectReason || '—')} `).join(''); } catch (e) { tbody.innerHTML = `Fehler: ${escapeHtml(e.message)}`; } } // 2. Offene Positionen async function loadRfPositions() { const tbody = document.getElementById('rfPositionsTbody'); const summary = document.getElementById('rfPositionsSummary'); if (!tbody) return; const accId = getSelectedRfAccountId(); try { const res = await fetch(`/api/modules/resolutionfarming/positions?accountId=${accId}`); if (!res.ok) throw new Error('HTTP ' + res.status); const data = await res.json(); if (summary) { summary.innerHTML = `${data.count} offene Position(en) · Gesamteinsatz ${Number(data.totalAmount || 0).toFixed(2)} USDC`; } if (!data.positions || data.positions.length === 0) { tbody.innerHTML = 'Keine offenen ResolutionFarming-Positionen vorhanden.'; return; } tbody.innerHTML = data.positions.map(p => ` ${escapeHtml(p.account)} ${escapeHtml(p.marketQuestion)} ${escapeHtml(p.outcome)} ${escapeHtml(p.category)} ${escapeHtml(p.clusterKey || '—')} ${Number(p.entryPrice || 0).toFixed(3)} ${Number(p.size || 0).toFixed(2)} ${Number(p.amountUsd || 0).toFixed(2)} $ ${escapeHtml(p.openedAt)} ${escapeHtml(p.endDate)} ${escapeHtml(p.status)} `).join(''); } catch (e) { tbody.innerHTML = `Fehler: ${escapeHtml(e.message)}`; } } // 3. Historie async function loadRfHistory() { const tbody = document.getElementById('rfHistoryTbody'); const summary = document.getElementById('rfHistorySummary'); const searchInput = document.getElementById('rfHistorySearch'); if (!tbody) return; const accId = getSelectedRfAccountId(); const search = searchInput ? searchInput.value.trim() : ''; const params = new URLSearchParams(); if (accId > 0) params.append('accountId', accId); if (search) params.append('search', search); try { const res = await fetch(`/api/modules/resolutionfarming/history?${params.toString()}`); if (!res.ok) throw new Error('HTTP ' + res.status); const data = await res.json(); if (summary && data.summary) { const pnl = Number(data.summary.pnl || 0); const pnlColor = pnl >= 0 ? 'var(--positive)' : 'var(--negative)'; summary.innerHTML = `${data.summary.count} Trades · PnL ${pnl >= 0 ? '+' : ''}${pnl.toFixed(2)} $ · Winrate ${Number(data.summary.winRate || 0).toFixed(1)}%`; } if (!data.trades || data.trades.length === 0) { tbody.innerHTML = 'Keine abgeschlossenen ResolutionFarming-Trades gefunden.'; return; } tbody.innerHTML = data.trades.map(t => { const pnl = Number(t.realizedPnl || 0); const pnlPct = Number(t.pnlPercent || 0); const pnlColor = pnl >= 0 ? 'var(--positive)' : 'var(--negative)'; return ` #${t.id} ${escapeHtml(t.account)} ${escapeHtml(t.marketQuestion)} ${escapeHtml(t.outcome)} ${escapeHtml(t.category)} ${Number(t.entryPrice || 0).toFixed(3)} ${Number(t.exitPrice || 0).toFixed(3)} ${Number(t.size || 0).toFixed(2)} ${pnl >= 0 ? '+' : ''}${pnl.toFixed(2)} $ ${pnlPct >= 0 ? '+' : ''}${pnlPct.toFixed(1)}% ${Number(t.totalFees || 0).toFixed(3)} $ ${escapeHtml(t.closedAt)} ${escapeHtml(t.redeemStatus || '—')} ${escapeHtml(t.exitReason || '—')} `; }).join(''); } catch (e) { tbody.innerHTML = `Fehler: ${escapeHtml(e.message)}`; } } function debounceRfHistorySearch() { clearTimeout(rfHistoryDebounceTimer); rfHistoryDebounceTimer = setTimeout(loadRfHistory, 300); } // 4. Einstellungen async function loadRfSettings() { const accId = getSelectedRfAccountId(); if (accId <= 0) return; const title = document.getElementById('rfSettingsAccountTitle'); const sel = document.getElementById('rfAccountSelect'); if (title && sel && sel.selectedOptions[0]) { title.textContent = `Einstellungen für Konto: ${sel.selectedOptions[0].text}`; } try { const res = await fetch(`/api/modules/resolutionfarming/settings/${accId}`); if (!res.ok) throw new Error('HTTP ' + res.status); const s = await res.json(); document.getElementById('rfEnabled').checked = !!s.enabled; document.getElementById('rfMinPrice').value = s.minPrice ?? 0.90; document.getElementById('rfMaxPrice').value = s.maxPrice ?? 0.98; document.getElementById('rfMaxHours').value = s.maxHoursToResolution ?? 48; document.getElementById('rfMinEdge').value = s.minEdgePct ?? 1.5; document.getElementById('rfMaxPerMarket').value = s.maxPerMarketUsd ?? 25; document.getElementById('rfMaxCluster').value = s.maxClusterExposurePct ?? 10; document.getElementById('rfWhitelist').value = s.categoryWhitelistCsv ?? 'Sports,Politics,Geopolitics'; document.getElementById('rfBlacklist').value = s.blacklistCsv ?? ''; } catch (err) { console.error('Fehler beim Laden der RF-Settings:', err); } } async function saveRfSettings(e) { e.preventDefault(); const accId = getSelectedRfAccountId(); if (accId <= 0) return; const payload = { accountId: accId, enabled: document.getElementById('rfEnabled').checked, minPrice: parseFloat(document.getElementById('rfMinPrice').value) || 0, maxPrice: parseFloat(document.getElementById('rfMaxPrice').value) || 0, maxHoursToResolution: parseInt(document.getElementById('rfMaxHours').value) || 48, minEdgePct: parseFloat(document.getElementById('rfMinEdge').value) || 0, maxPerMarketUsd: parseFloat(document.getElementById('rfMaxPerMarket').value) || 0, maxClusterExposurePct: parseFloat(document.getElementById('rfMaxCluster').value) || 0, categoryWhitelistCsv: document.getElementById('rfWhitelist').value.trim(), blacklistCsv: document.getElementById('rfBlacklist').value.trim() }; try { const res = await fetch(`/api/modules/resolutionfarming/settings/${accId}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (!res.ok) throw new Error(await errorText(res)); const statusEl = document.getElementById('rfSettingsStatus'); if (statusEl) { statusEl.style.display = 'inline'; setTimeout(() => { statusEl.style.display = 'none'; }, 3000); } } catch (err) { alert('Fehler beim Speichern: ' + err.message); } } // ========================================== // SUPERVISOR & COPILOT LOGIC // ========================================== let currentSupTab = 'chat'; let supProfilesLoaded = false; function switchSupTab(tab) { currentSupTab = tab; ['chat', 'reports', 'signals', 'counterfactuals'].forEach(t => { const btn = document.getElementById(`supTabBtn-${t}`); const content = document.getElementById(`supTab-${t}`); if (btn) btn.classList.toggle('active', t === tab); if (content) { content.classList.toggle('active', t === tab); content.style.display = (t === tab) ? 'block' : 'none'; } }); if (tab === 'chat') { if (!supProfilesLoaded) loadSupProfiles(); } else if (tab === 'reports') { loadSupReports(); } else if (tab === 'signals') { loadSupSignals(); } else if (tab === 'counterfactuals') { loadSupCounterfactuals(); } } function refreshCurrentSupTab() { switchSupTab(currentSupTab); } function loadSupervisor() { switchSupTab(currentSupTab); } async function loadSupProfiles() { try { const res = await fetch('/api/modules/supervisor/profiles'); if (!res.ok) return; const profiles = await res.json(); const sel = document.getElementById('supProfileSelect'); if (sel) { sel.innerHTML = profiles.map(p => ``).join(''); supProfilesLoaded = true; } } catch (e) {} } function applySupPreset(promptText) { const input = document.getElementById('supQuestionInput'); if (input) { input.value = promptText; input.focus(); } } async function sendSupQuestion(e) { e.preventDefault(); const input = document.getElementById('supQuestionInput'); const profileSel = document.getElementById('supProfileSelect'); const modelInput = document.getElementById('supModelInput'); const btnSend = document.getElementById('btnSupSend'); const messagesArea = document.getElementById('supChatMessages'); if (!input || !messagesArea) return; const question = input.value.trim(); if (!question) return; const profile = profileSel ? profileSel.value : 'General'; const model = modelInput ? modelInput.value.trim() : ''; // 1. Render user message const nowTime = new Date().toLocaleTimeString(); const userBubble = document.createElement('div'); userBubble.className = 'chat-bubble user'; userBubble.innerHTML = `
Du ${nowTime}
${escapeHtml(question)}
`; messagesArea.appendChild(userBubble); // 2. Render loading assistant message const assistantBubble = document.createElement('div'); assistantBubble.className = 'chat-bubble assistant'; assistantBubble.innerHTML = `
🤖 Supervisor (${escapeHtml(profile)}) Denkt nach …
Analysiere Daten und führe Tools aus …
`; messagesArea.appendChild(assistantBubble); messagesArea.scrollTop = messagesArea.scrollHeight; // Clear input & disable send input.value = ''; btnSend.disabled = true; try { const res = await fetch('/api/modules/supervisor/ask', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ question, profile, model }) }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); // Render response let toolCallsHtml = ''; if (data.toolInvocations && data.toolInvocations.length > 0) { const listItems = data.toolInvocations.map(ti => { const argsStr = typeof ti.arguments === 'object' ? JSON.stringify(ti.arguments) : String(ti.arguments || ''); return `
  • ${escapeHtml(ti.tool)}(${escapeHtml(argsStr)})
  • `; }).join(''); toolCallsHtml = `
    🛠️ ${data.toolCallCount} Tool-Aufrufe (${data.totalTokens || 0} Tokens)
      ${listItems}
    `; } const tokenInfo = data.totalTokens ? `${data.totalTokens} Tokens (${data.model || 'Standard'})` : (data.model || 'Standard'); assistantBubble.innerHTML = `
    🤖 Supervisor (${escapeHtml(data.profile || profile)}) ${tokenInfo}
    ${escapeHtml(data.answer || 'Keine Antwort erhalten.')}
    ${toolCallsHtml} `; } catch (err) { assistantBubble.innerHTML = `
    🤖 Supervisor Fehler
    Fehler bei der Kommunikation mit dem Supervisor: ${escapeHtml(err.message)}
    `; } finally { btnSend.disabled = false; messagesArea.scrollTop = messagesArea.scrollHeight; } } async function loadSupReports() { try { const res = await fetch('/api/modules/supervisor/reports?limit=50'); if (!res.ok) return; const list = await res.json(); const tbody = document.getElementById('supReportsTbody'); const summary = document.getElementById('supReportsSummary'); if (summary) summary.textContent = `${list.length} gespeicherte Supervisor-Berichte im Archiv`; if (!tbody) return; if (list.length === 0) { tbody.innerHTML = 'Bislang keine Berichte vorhanden.'; return; } tbody.innerHTML = list.map(r => ` #${r.id} ${escapeHtml(r.createdAt)} ${escapeHtml(r.profile)} ${escapeHtml(r.model || '—')} ${escapeHtml(r.question)} ${r.toolCallCount} ${r.totalTokens} `).join(''); } catch (e) {} } async function viewSupReport(id) { try { const res = await fetch(`/api/modules/supervisor/reports/${id}`); if (!res.ok) return; const report = await res.json(); const titleEl = document.getElementById('modalReportTitle'); const metaEl = document.getElementById('modalReportMeta'); const contentEl = document.getElementById('modalReportContent'); const modal = document.getElementById('modalReport'); if (titleEl) titleEl.textContent = `Supervisor-Bericht #${report.id} (${report.profile})`; if (metaEl) { metaEl.innerHTML = ` Datum: ${escapeHtml(report.createdAt)} Modell: ${escapeHtml(report.model || '—')} Tokens: ${report.totalTokens} (${report.promptTokens} in / ${report.completionTokens} out) Tool-Aufrufe: ${report.toolCallCount} `; } if (contentEl) { contentEl.textContent = `FRAGE / ANFRAGE:\n${report.question}\n\n============================================================\nANTWORT:\n\n${report.answer}`; } if (modal) modal.style.display = 'flex'; } catch (e) {} } async function loadSupSignals() { try { const res = await fetch('/api/modules/supervisor/dossiers?limit=100'); if (!res.ok) return; const list = await res.json(); const tbody = document.getElementById('supSignalsTbody'); const summary = document.getElementById('supSignalsSummary'); if (summary) summary.textContent = `${list.length} Signale mit Entscheidungsverlauf`; if (!tbody) return; if (list.length === 0) { tbody.innerHTML = 'Keine Signal-Dossiers aufgezeichnet.'; return; } tbody.innerHTML = list.map(s => ` ${escapeHtml(s.signalId)} ${escapeHtml(s.firstSeen)} ${escapeHtml(s.module || '—')} ${s.accountId != null ? `#${s.accountId}` : '—'} ${escapeHtml(s.market)} ${escapeHtml(s.side || '—')} ${escapeHtml(s.lastDecision || '—')} ${escapeHtml(s.lastReason || '—')} `).join(''); } catch (e) {} } async function viewSupDossier(signalId) { try { const res = await fetch(`/api/modules/supervisor/dossiers/${encodeURIComponent(signalId)}`); if (!res.ok) return; const data = await res.json(); const titleEl = document.getElementById('modalReportTitle'); const metaEl = document.getElementById('modalReportMeta'); const contentEl = document.getElementById('modalReportContent'); const modal = document.getElementById('modalReport'); if (titleEl) titleEl.textContent = `Signal-Dossier: ${data.signalId}`; if (metaEl) { metaEl.innerHTML = ` Modul: ${escapeHtml(data.module || '—')} Konto: #${data.accountId} Side: ${escapeHtml(data.side || '—')} Markt: ${escapeHtml(data.market || '—')} `; } if (contentEl) { contentEl.textContent = data.renderedMarkdown || 'Keine Markdown-Daten verfügbar.'; } if (modal) modal.style.display = 'flex'; } catch (e) {} } async function loadSupCounterfactuals() { try { const res = await fetch('/api/modules/supervisor/counterfactuals?limit=50'); if (!res.ok) return; const list = await res.json(); const tbody = document.getElementById('supCounterfactualsTbody'); const summary = document.getElementById('supCounterfactualsSummary'); if (summary) summary.textContent = `${list.length} abgelehnte Signale im Realitäts-Abgleich`; if (!tbody) return; if (list.length === 0) { tbody.innerHTML = 'Keine Counterfactual-Aufzeichnungen gefunden.'; return; } tbody.innerHTML = list.map(c => { const pnlColor = (c.hypotheticalPnlPerShare || 0) >= 0 ? 'var(--positive)' : 'var(--negative)'; const pnlSign = (c.hypotheticalPnlPerShare || 0) >= 0 ? '+' : ''; return ` #${c.id} ${escapeHtml(c.checkedAt)} ${escapeHtml(c.moduleName || '—')} ${c.accountId != null ? `#${c.accountId}` : '—'} ${escapeHtml(c.market)} ${escapeHtml(c.reason || '—')} $${Number(c.signalPrice || 0).toFixed(3)} ${c.isWinner ? 'Gewonnen' : 'Verloren'} ${pnlSign}$${Number(c.hypotheticalPnlPerShare || 0).toFixed(4)} `; }).join(''); } catch (e) {} } function closeReportModal() { const modal = document.getElementById('modalReport'); if (modal) modal.style.display = 'none'; } // ========================================== // ACCOUNTING & BWA LOGIC // ========================================== let currentAccTab = 'bwa'; let accCurrency = 'USDC'; let accLedgerPage = 1; let accAccountsLoaded = false; function switchAccTab(tab) { currentAccTab = tab; ['bwa', 'ledger', 'runs'].forEach(t => { const btn = document.getElementById(`accTabBtn-${t}`); const content = document.getElementById(`accTab-${t}`); if (btn) btn.classList.toggle('active', t === tab); if (content) { content.classList.toggle('active', t === tab); content.style.display = (t === tab) ? 'block' : 'none'; } }); if (tab === 'bwa') { loadAccountingBwa(); } else if (tab === 'ledger') { loadAccLedger(1); } else if (tab === 'runs') { loadAccRuns(); } } function refreshCurrentAccTab() { switchAccTab(currentAccTab); } async function loadAccounting() { // Set default dates if empty const fromInput = document.getElementById('accFilterFrom'); const toInput = document.getElementById('accFilterTo'); const now = new Date(); if (fromInput && !fromInput.value) { const y = now.getFullYear(); const m = String(now.getMonth() + 1).padStart(2, '0'); fromInput.value = `${y}-${m}-01`; } if (toInput && !toInput.value) { const y = now.getFullYear(); const m = String(now.getMonth() + 1).padStart(2, '0'); const d = String(now.getDate()).padStart(2, '0'); toInput.value = `${y}-${m}-${d}`; } if (!accAccountsLoaded) { try { const res = await fetch('/api/accounts'); if (res.ok) { const accs = await res.json(); const sel = document.getElementById('accFilterAccount'); if (sel) { sel.innerHTML = '' + accs.map(a => ``).join(''); accAccountsLoaded = true; } } } catch (e) {} } switchAccTab(currentAccTab); } function selectAccountingCurrency(curr) { accCurrency = curr; ['USDC', 'USD', 'EUR'].forEach(c => { const pill = document.getElementById(`currPill-${c}`); if (pill) pill.classList.toggle('active', c === curr); }); loadAccountingBwa(); } async function loadAccountingBwa() { const accId = document.getElementById('accFilterAccount')?.value || ''; const fromVal = document.getElementById('accFilterFrom')?.value || ''; const toVal = document.getElementById('accFilterTo')?.value || ''; let url = `/api/modules/accounting/statement?currency=${encodeURIComponent(accCurrency)}`; if (accId) url += `&accountId=${encodeURIComponent(accId)}`; if (fromVal) url += `&from=${encodeURIComponent(fromVal)}`; if (toVal) url += `&to=${encodeURIComponent(toVal)}`; try { const res = await fetch(url); if (!res.ok) return; const data = await res.json(); const cur = data.currency?.code || 'USDC'; // 1. KPI Cards const s = data.statement; document.getElementById('accBwaOpening').textContent = `${Number(s.openingBalance || 0).toFixed(2)} ${cur}`; document.getElementById('accBwaPeriodLabel').textContent = `${s.from} bis ${s.to}`; document.getElementById('accBwaClosing').textContent = `${Number(s.closingBalance || 0).toFixed(2)} ${cur}`; const balChange = Number(s.balanceChange || 0); const balChangeEl = document.getElementById('accBwaBalanceChange'); balChangeEl.textContent = `Veränderung: ${balChange >= 0 ? '+' : ''}${balChange.toFixed(2)} ${cur}`; balChangeEl.style.color = balChange >= 0 ? 'var(--positive)' : 'var(--negative)'; const netRes = Number(s.netTradingResult || 0); const netResEl = document.getElementById('accBwaNetResult'); netResEl.textContent = `${netRes >= 0 ? '+' : ''}${netRes.toFixed(2)} ${cur}`; netResEl.style.color = netRes >= 0 ? 'var(--positive)' : 'var(--negative)'; document.getElementById('accBwaTradeStats').textContent = `${s.tradeCount} Trades | ${s.entryCount} Ledger-Buchungen`; document.getElementById('accBwaVolume').textContent = `${Number(s.tradeVolume || 0).toFixed(2)} ${cur}`; document.getElementById('accBwaFees').textContent = `Gebühren: ${Number(s.fees || 0).toFixed(2)} ${cur} | Rewards: ${Number(s.rewards || 0).toFixed(2)} ${cur}`; // Currency Note const noteEl = document.getElementById('accCurrencyNote'); if (noteEl) { noteEl.textContent = data.currency?.note ? `* ${data.currency.note}` : ''; } // Veralteter oder fehlender EZB-Kurs (#533) const warnEl = document.getElementById('accRateWarning'); if (warnEl) { const c = data.currency || {}; if (c.warning) { warnEl.textContent = c.warning; warnEl.hidden = false; } else if (c.available === false) { warnEl.textContent = `${c.note} Umrechnung und Export in ${c.code} sind deshalb gesperrt.`; warnEl.hidden = false; } else { warnEl.hidden = true; } } // 2. Monthly Table const tbody = document.getElementById('accMonthlyTbody'); if (tbody) { if (!data.monthly || data.monthly.length === 0) { tbody.innerHTML = 'Keine Buchungsdaten in dieser Periode.'; } else { tbody.innerHTML = data.monthly.map(m => { const mNet = Number(m.netTradingResult || 0); const netColor = mNet >= 0 ? 'var(--positive)' : 'var(--negative)'; const netSign = mNet >= 0 ? '+' : ''; const flow = (Number(m.deposits || 0) - Number(m.withdrawals || 0)); return ` ${escapeHtml(m.monthLabel || m.from)} ${Number(m.openingBalance || 0).toFixed(2)} ${cur} ${Number(m.closingBalance || 0).toFixed(2)} ${cur} ${flow >= 0 ? '+' : ''}${flow.toFixed(2)} ${cur} ${Number(m.tradeVolume || 0).toFixed(2)} ${cur} ${Number(m.fees || 0).toFixed(2)} ${cur} ${netSign}${mNet.toFixed(2)} ${cur} ${m.tradeCount} ${m.entryCount} `; }).join(''); } } } catch (e) {} } async function exportAccountingPdf() { const accId = document.getElementById('accFilterAccount')?.value || ''; const fromVal = document.getElementById('accFilterFrom')?.value || ''; const toVal = document.getElementById('accFilterTo')?.value || ''; let url = `/api/modules/accounting/export/pdf?currency=${encodeURIComponent(accCurrency)}`; if (accId) url += `&accountId=${encodeURIComponent(accId)}`; if (fromVal) url += `&from=${encodeURIComponent(fromVal)}`; if (toVal) url += `&to=${encodeURIComponent(toVal)}`; // Nicht mit window.open: Der Endpunkt lehnt eine nicht verfügbare Währung mit 400 ab, // und die nackte JSON-Fehlermeldung in einem neuen Reiter ist keine Antwort (#533). await downloadExport(url, 'PDF'); } /** Holt einen Export, meldet Fehler im Klartext und stößt sonst den Download an. */ async function downloadExport(url, label) { try { const res = await fetch(url); if (!res.ok) { let msg = `HTTP ${res.status}`; try { msg = (await res.json()).error || msg; } catch (e) { /* kein JSON */ } alert(`${label}-Export nicht möglich: ${msg}`); return; } const blob = await res.blob(); const name = (res.headers.get('Content-Disposition') || '').match(/filename="?([^";]+)"?/)?.[1] || `export.${label.toLowerCase()}`; const objectUrl = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = objectUrl; a.download = name; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(objectUrl); } catch (e) { alert(`${label}-Export fehlgeschlagen: ${e.message}`); } } async function exportAccountingCsv() { const accId = document.getElementById('accFilterAccount')?.value || ''; const fromVal = document.getElementById('accFilterFrom')?.value || ''; const toVal = document.getElementById('accFilterTo')?.value || ''; let url = `/api/modules/accounting/export/csv?_t=${Date.now()}`; if (accId) url += `&accountId=${encodeURIComponent(accId)}`; if (fromVal) url += `&from=${encodeURIComponent(fromVal)}`; if (toVal) url += `&to=${encodeURIComponent(toVal)}`; downloadExport(url, 'CSV'); } async function loadAccLedger(page = 1) { accLedgerPage = Math.max(1, page); const accId = document.getElementById('accFilterAccount')?.value || ''; const fromVal = document.getElementById('accFilterFrom')?.value || ''; const toVal = document.getElementById('accFilterTo')?.value || ''; const searchVal = document.getElementById('accLedgerSearch')?.value.trim() || ''; let url = `/api/modules/accounting/ledger?page=${accLedgerPage}&pageSize=50`; if (accId) url += `&accountId=${encodeURIComponent(accId)}`; if (fromVal) url += `&from=${encodeURIComponent(fromVal)}`; if (toVal) url += `&to=${encodeURIComponent(toVal)}`; if (searchVal) url += `&search=${encodeURIComponent(searchVal)}`; try { const res = await fetch(url); if (!res.ok) return; const data = await res.json(); const tbody = document.getElementById('accLedgerTbody'); const summary = document.getElementById('accLedgerSummary'); const pageInfo = document.getElementById('accLedgerPageInfo'); const btnPrev = document.getElementById('btnAccLedgerPrev'); const btnNext = document.getElementById('btnAccLedgerNext'); if (summary) summary.textContent = `${data.totalCount} Buchungssätze gefunden`; if (pageInfo) pageInfo.textContent = `Seite ${data.page} von ${Math.max(1, data.totalPages)}`; if (btnPrev) btnPrev.disabled = data.page <= 1; if (btnNext) btnNext.disabled = data.page >= data.totalPages; if (!tbody) return; if (!data.items || data.items.length === 0) { tbody.innerHTML = 'Keine Buchungen gefunden.'; return; } tbody.innerHTML = data.items.map(e => { const netVal = Number(e.netUsdc || 0); const netColor = netVal >= 0 ? 'var(--positive)' : 'var(--negative)'; const netSign = netVal >= 0 ? '+' : ''; const txShort = e.txHash ? `${e.txHash.substring(0, 8)}…` : '—'; return ` ${escapeHtml(e.timestamp)} #${e.accountId} ${escapeHtml(e.eventType)} ${escapeHtml(e.side || '—')} ${escapeHtml(e.marketSlug || '—')} ${escapeHtml(e.outcome || '—')} ${Number(e.size || 0).toFixed(1)} $${Number(e.priceUsdc || 0).toFixed(3)} $${Number(e.grossUsdc || 0).toFixed(2)} $${Number(e.feeUsdc || 0).toFixed(2)} ${netSign}$${netVal.toFixed(2)} ${e.txHash ? `${txShort} ↗` : '—'} `; }).join(''); } catch (e) {} } function searchAccLedger() { loadAccLedger(1); } function changeAccLedgerPage(delta) { loadAccLedger(accLedgerPage + delta); } async function loadAccRuns() { try { const res = await fetch('/api/modules/accounting/runs?limit=50'); if (!res.ok) return; const runs = await res.json(); const tbody = document.getElementById('accRunsTbody'); const summary = document.getElementById('accRunsSummary'); if (summary) summary.textContent = `${runs.length} Ingestion-Läufe in der Historie`; if (!tbody) return; if (runs.length === 0) { tbody.innerHTML = 'Bislang keine Ingestion-Läufe protokolliert.'; return; } tbody.innerHTML = runs.map(r => ` #${r.id} #${r.accountId} ${escapeHtml(r.startedAt)} ${escapeHtml(r.completedAt)} ${r.backfill ? 'Backfill' : 'Live'} ${r.newEntries} ${r.duplicateEntries} $${Number(r.balanceDeltaUsdc || 0).toFixed(2)} ${r.success ? 'Erfolgreich' : 'Fehler'} ${escapeHtml(r.errorMessage || '—')} `).join(''); } catch (e) {} } async function triggerManualIngest() { const accInput = prompt('Konto-ID für Ingest (oder leer lassen für alle Konten):', ''); if (accInput === null) return; const accountId = accInput.trim() ? parseInt(accInput.trim()) : null; const backfill = confirm('Vollständigen Backfill durchführen?\n(OK = Ja, Abbrechen = Nur neue Buchungen seit letztem Check)'); try { const res = await fetch('/api/modules/accounting/ingest', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ accountId, backfill }) }); if (!res.ok) throw new Error('HTTP ' + res.status); const data = await res.json(); alert(`Ingestion gestartet: ${data.message}`); setTimeout(loadAccRuns, 1500); } catch (err) { alert('Fehler beim Starten des Ingestions-Laufs: ' + err.message); } }