// 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 = `
${escapeHtml(question)}
`;
messagesArea.appendChild(userBubble);
// 2. Render loading assistant message
const assistantBubble = document.createElement('div');
assistantBubble.className = 'chat-bubble assistant';
assistantBubble.innerHTML = `
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)
`;
}
const tokenInfo = data.totalTokens ? `${data.totalTokens} Tokens (${data.model || 'Standard'})` : (data.model || 'Standard');
assistantBubble.innerHTML = `
${escapeHtml(data.answer || 'Keine Antwort erhalten.')}
${toolCallsHtml}
`;
} catch (err) {
assistantBubble.innerHTML = `
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);
}
}