Files
PolyTraderSharp/src/PolyTrader.Web/wwwroot/js/app.js
T
RichardandClaude Opus 5 30891c1bce WebUI: Hellmodus, Supervisor-Verlauf leeren, API-Status beschriftet
Umsetzung von #566.

Hell-/Dunkelmodus
Das WebUI hatte nur eine dunkle Palette. Jetzt gibt es "System", "Hell" und
"Dunkel" als Umschalter in der Kopfleiste. Die Wahl gilt je Browser; ohne
gespeicherte Wahl folgt das WebUI dem Betriebssystem. data-theme auf <html>
ueberstimmt prefers-color-scheme in beide Richtungen. Ein kleines Skript im
<head> setzt die Wahl vor dem ersten Zeichnen, sonst blitzte beim Laden kurz
das falsche Schema auf; die Login-Seite macht es genauso.

Kontrast der hellen Palette auf Weiss:
- Text 8:1
- gedaempfter Text 6,3:1
- Akzent, Gewinn, Verlust und Warnung je mindestens 4,5:1

Fuenf feste Farben haetten im Hellmodus gebrochen und sind jetzt Tokens:
- #11111b als Schrift auf Akzentfarbe (Primaerknopf, Profil-Hover, aktive
  Waehrung): im Hellmodus dunkel auf dunklem Blau.
- rgba(255,255,255,0.02 bis 0.08) fuer Zeilen-Hover und Trennlinien: auf Weiss
  unsichtbar.

Das Terminal bleibt in beiden Modi dunkel und bringt seine Palette lokal mit.
Es hatte einen festen dunklen Hintergrund, erbte aber var(--text), und das
waere im Hellmodus dunkle Schrift auf Schwarz geworden. Die uebrigen festen
Farben sind halbtransparente Toenungen und Schatten, die auf beiden Gruenden
funktionieren.

Supervisor
"Verlauf leeren" wie im Desktop. Geleert wird nur die Unterhaltung im Browser,
die Begruessung kommt zurueck, gespeicherte Berichte bleiben erhalten.

Statusanzeige
Der WSS-Punkt ist jetzt beschriftet, "WSS aktiv" oder "Polling", wie
"API: ..." in der Statusleiste des Desktops. Dazu kommt "Module x/y".

Reine Oberflaeche, keine Aenderung am Server. Die Pruefung erfolgt im Browser
auf der VM.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 14:24:23 +02:00

2934 lines
130 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 = '<tr><td colspan="7" style="text-align: center; color: var(--muted);">Keine Konten konfiguriert.</td></tr>';
return;
}
tbody.innerHTML = accounts.map(a => `
<tr>
<td><b>${escapeHtml(a.name || `#${a.accountId}`)}</b>${a.isDemo ? ' <span style="color: var(--warning); font-size: 11px;">(Demo)</span>' : ''}</td>
<td style="color: var(--muted); font-size: 12px;">${escapeHtml(a.modules || '—')}</td>
<td>
${a.walletAddress ? `<a href="https://polymarket.com/profile/${a.walletAddress}" target="_blank" rel="noopener noreferrer" class="btn-profile">Profil ↗</a>` : '<span style="color: var(--muted);">—</span>'}
</td>
<td style="font-family: var(--font-mono); font-weight: 600;">${formatCurrency(a.portfolioValue)}</td>
<td style="font-family: var(--font-mono); color: ${a.pnl3d >= 0 ? 'var(--positive)' : 'var(--negative)'}; font-weight: 600;">
${a.pnl3d >= 0 ? '+' : ''}${formatCurrency(a.pnl3d)}
</td>
<td style="font-family: var(--font-mono);">${Number(a.winRate3d || 0).toFixed(1)} %</td>
<td style="font-family: var(--font-mono); color: ${a.overallPnl >= 0 ? 'var(--positive)' : 'var(--negative)'}; font-weight: 600;">
${a.overallPnl >= 0 ? '+' : ''}${formatCurrency(a.overallPnl)}
</td>
</tr>
`).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 = '<div class="card" style="padding: 12px; color: var(--muted); font-size: 13px;">Keine Modul-KPIs verfügbar.</div>';
} else {
kpisContainer.innerHTML = data.moduleKpis.map(k => `
<div class="module-kpi-tile ${k.isPositive ? 'positive' : 'negative'}">
<div class="mod-name">${escapeHtml(k.moduleName)}</div>
<div class="mod-body">${escapeHtml(k.text)}</div>
</div>
`).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 = '<tr><td colspan="4" style="text-align: center; color: var(--muted);">Keine auffälligen Trades in den letzten 24h.</td></tr>';
} else {
notableTbody.innerHTML = data.notableTrades.map(t => `
<tr>
<td><span style="font-weight: 600; font-size: 11px;">${escapeHtml(t.module)}</span></td>
<td style="max-width: 140px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${escapeHtml(t.market)}">${escapeHtml(t.market)}</td>
<td style="font-family: var(--font-mono); color: ${t.pnl >= 0 ? 'var(--positive)' : 'var(--negative)'}; font-weight: 600;">
${t.pnl >= 0 ? '+' : ''}${Number(t.pnl).toFixed(2)} $
</td>
<td style="font-family: var(--font-mono); font-size: 11px; color: ${t.pnlPct >= 0 ? 'var(--positive)' : 'var(--negative)'};">
${t.pnlPct >= 0 ? '+' : ''}${Number(t.pnlPct).toFixed(1)}%
</td>
</tr>
`).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 = '<div style="color: var(--muted); padding: 12px 0; text-align: center;">Keine Warnungen heute registriert.</div>';
} else {
alertsContainer.innerHTML = data.alerts.map(a => `
<div class="widget-alert-entry">
<span class="time">[${escapeHtml(a.time)}]</span>
<span class="level ${escapeHtml(a.level)}">${escapeHtml(a.level)}</span>
<span class="msg">${escapeHtml(a.message)}</span>
</div>
`).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 = '<tr><td colspan="10" style="text-align: center; color: var(--muted);">Keine Konten gefunden.</td></tr>';
return;
}
tbody.innerHTML = accounts.map(a => `
<tr>
<td><b>${a.accountId}</b></td>
<td>${escapeHtml(a.name)}</td>
<td><span style="color: ${a.isActive ? 'var(--positive)' : 'var(--muted)'};">●</span> ${a.isActive ? 'Aktiv' : 'Inaktiv'}</td>
<td>${a.isDemo ? '<span style="color: var(--warning);">Demo</span>' : '<span style="color: var(--positive);">Live</span>'}</td>
<td style="font-family: var(--font-mono); font-weight: 600;">${formatCurrency(a.usdcBalance)}</td>
<td style="font-family: var(--font-mono); color: ${a.pnl3d >= 0 ? 'var(--positive)' : 'var(--negative)'}; font-weight: 600;">
${a.pnl3d >= 0 ? '+' : ''}${formatCurrency(a.pnl3d)}
</td>
<td style="font-family: var(--font-mono);">${Number(a.winRate3d || 0).toFixed(1)} %</td>
<td style="font-family: var(--font-mono); color: ${a.overallPnl >= 0 ? 'var(--positive)' : 'var(--negative)'}; font-weight: 600;">
${a.overallPnl >= 0 ? '+' : ''}${formatCurrency(a.overallPnl)}
</td>
<td style="font-family: var(--font-mono); font-size: 11px; color: var(--muted);" title="${escapeHtml(a.walletAddress || '')}">
${shortenAddress(a.walletAddress || a.proxyWalletAddress)}
</td>
<td>
${a.walletAddress ? `<a href="https://polymarket.com/profile/${a.walletAddress}" target="_blank" rel="noopener noreferrer" class="btn-profile">Profil ↗</a>` : '<span style="color: var(--muted);">—</span>'}
</td>
</tr>
`).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 = '<tr><td colspan="7" style="text-align: center; color: var(--muted);">Keine Jobs registriert.</td></tr>';
return;
}
tbody.innerHTML = jobs.map(j => `
<tr>
<td><b>${escapeHtml(j.jobName)}</b></td>
<td style="color: var(--muted); font-size: 12px;">${escapeHtml(j.description)}</td>
<td>
<button class="badge-active ${j.isEnabled ? 'is-on' : 'is-off'}" onclick="toggleJob(${jsArg(j.jobName)})">
${j.isEnabled ? '● Aktiv' : '○ Inaktiv'}
</button>
</td>
<td style="font-size: 12px; color: var(--muted);">${j.lastRun ? new Date(j.lastRun).toLocaleTimeString() : '—'}</td>
<td style="font-size: 12px; color: var(--muted);">${j.nextRun ? new Date(j.nextRun).toLocaleTimeString() : '—'}</td>
<td><span style="padding: 2px 6px; background: var(--surface-alt); border-radius: 4px; font-size: 11px;">${escapeHtml(j.statusText)}</span></td>
<td>
${j.canTriggerManually ? `<button class="btn btn-secondary btn-sm" onclick="triggerJob(${jsArg(j.jobName)})">Ausführen</button>` : ''}
</td>
</tr>
`).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
? `<button class="btn btn-secondary btn-sm" onclick="switchView(${jsArg(view[0])})">${escapeHtml(view[1])}</button>`
: '';
// #560: ausdruecklich der gewuenschte Zustand, nicht "umschalten".
const toggleBtn = m.canToggle
? `<button class="btn ${m.desiredEnabled ? 'btn-danger' : 'btn-primary'} btn-sm" onclick="setModuleEnabled(${jsArg(m.name)}, ${m.desiredEnabled ? 'false' : 'true'})">${escapeHtml(m.action)}</button>`
: '';
const statusColor = m.blocker ? 'var(--warning)' : (m.isRunning ? 'var(--positive)' : 'var(--muted)');
return `
<div class="card">
<div class="card-title">
<span>${escapeHtml(m.name)}</span>
<span style="font-size: 12px; color: ${statusColor}; font-weight: normal;">● ${escapeHtml(m.status)}</span>
</div>
<div style="font-size: 13px; color: var(--muted); min-height: 18px;">${escapeHtml(m.hint || (m.isRunning ? 'Läuft planmäßig im Hintergrund.' : 'In dieser Sitzung nicht geladen.'))}</div>
<div style="margin-top: 14px; display: flex; gap: 8px; flex-wrap: wrap;">${toggleBtn}${openBtn}</div>
</div>
`;
}).join('');
} catch (e) {
container.innerHTML = `<div class="card"><div class="card-title">Module nicht ladbar</div><div style="font-size: 13px; color: var(--negative);">${escapeHtml(e.message)}</div></div>`;
}
}
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 = `
<span class="log-time">[${entry.time}]</span>
<span class="log-level ${entry.level}">${entry.level}</span>
<span class="log-msg">${escapeHtml(entry.msg)}</span>
`;
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 => `<option value="${escapeHtml(d.date)}">${escapeHtml(formatLogDay(d.date))} · ${escapeHtml(formatBytes(d.sizeBytes))}</option>`).join('')
: '<option value="">keine Logdateien</option>';
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 = '<div class="form-desc" style="padding: 12px;">Keine Einträge für diese Auswahl.</div>';
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 => `
<div class="settings-section">
<div class="settings-section-title">${escapeHtml(sec.title)}</div>
${sec.fields.map(renderServerField).join('')}
</div>`).join('');
secrets.innerHTML = data.secrets
.map(x => `<dt>${escapeHtml(x.label)}</dt><dd>${escapeHtml(x.status)}</dd>`)
.join('');
} catch (e) {
host.innerHTML = `<div class="rate-warning">Server-Einstellungen nicht ladbar: ${escapeHtml(e.message)}</div>`;
}
}
function renderServerField(f) {
const id = 'srv_' + f.key;
const key = escapeHtml(f.key);
const badge = !f.editable
? '<span class="effect-badge">nur im Desktop</span>'
: `<span class="effect-badge ${f.requiresRestart ? 'restart' : 'live'}">${f.requiresRestart ? 'nach Neustart' : 'sofort'}</span>`;
let control;
if (!f.editable) {
control = `<span class="setting-readonly">${escapeHtml(String(f.value ?? '')) || '—'}</span>`;
} else if (f.kind === 'bool') {
control = `<input type="checkbox" id="${id}" data-key="${key}" ${f.value ? 'checked' : ''}>`;
} else if (f.kind === 'int') {
control = `<input type="number" class="form-input" id="${id}" data-key="${key}" value="${escapeHtml(String(f.value))}"` +
`${f.min != null ? ` min="${f.min}"` : ''}${f.max != null ? ` max="${f.max}"` : ''}>`;
} else if (f.kind === 'choice') {
control = `<select class="form-input form-select" id="${id}" data-key="${key}">` +
(f.options || []).map(o => `<option value="${escapeHtml(o)}" ${o === f.value ? 'selected' : ''}>${escapeHtml(o)}</option>`).join('') +
'</select>';
} else {
control = `<input type="text" class="form-input" id="${id}" data-key="${key}" value="${escapeHtml(String(f.value ?? ''))}">`;
}
return `
<div class="setting-field">
<label for="${id}">${escapeHtml(f.label)} ${badge}</label>
<div>${control}</div>
<div class="form-desc">${escapeHtml(f.description)}</div>
</div>`;
}
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// 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 => `<option value="${a.id}">${escapeHtml(a.name)}</option>`).join('');
accEl.value = currentVal;
}
if (modEl && modEl.options.length <= 1 && opts.modules) {
const currentVal = modEl.value;
modEl.innerHTML = opts.modules.map(m => `<option value="${escapeHtml(m)}">${escapeHtml(m)}</option>`).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 = `<text x="${w / 2}" y="${h / 2}" fill="var(--muted)" font-size="13" text-anchor="middle">Keine abgeschlossenen Trades im ausgewählten Zeitraum.</text>`;
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 += `
<line x1="${padL}" y1="${y}" x2="${padL + plotW}" y2="${y}" stroke="var(--border)" stroke-width="1" stroke-dasharray="3,3" />
<text x="${padL - 8}" y="${y + 4}" fill="var(--muted)" font-size="11" font-family="var(--font-mono)" text-anchor="end">${Number(val).toFixed(2)} $</text>
`;
}
// Zero-line if cross 0
if (minVal < 0 && maxVal > 0) {
const zeroY = getY(0);
svgContent += `<line x1="${padL}" y1="${zeroY}" x2="${padL + plotW}" y2="${zeroY}" stroke="#6c7086" stroke-width="1.5" />`;
}
// 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 += `
<defs>
<linearGradient id="equityGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="var(--accent)" stop-opacity="0.3" />
<stop offset="100%" stop-color="var(--accent)" stop-opacity="0.0" />
</linearGradient>
</defs>
<path d="${areaD}" fill="url(#equityGrad)" />
<path d="${pathD}" fill="none" stroke="var(--accent)" stroke-width="2.2" />
`;
// 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 += `
<text x="${x}" y="${padT + plotH + 20}" fill="var(--muted)" font-size="11" font-family="var(--font-mono)" text-anchor="${t === 0 ? 'start' : (t === tickCount - 1 ? 'end' : 'middle')}">${dateStr}</text>
`;
}
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 = `<text x="${w / 2}" y="${h / 2}" fill="var(--muted)" font-size="13" text-anchor="middle">Keine Modul-Daten verfügbar.</text>`;
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 += `<line x1="${padL}" y1="${zeroY}" x2="${padL + plotW}" y2="${zeroY}" stroke="#6c7086" stroke-width="1.5" />`;
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 += `
<rect x="${x}" y="${barY}" width="${barWidth}" height="${barH}" rx="3" fill="${color}" opacity="0.85" />
<text x="${x + barWidth / 2}" y="${m.pnl >= 0 ? barY - 6 : barY + barH + 14}" fill="${color}" font-size="11" font-weight="600" font-family="var(--font-mono)" text-anchor="middle">${m.pnl >= 0 ? '+' : ''}${Number(m.pnl).toFixed(1)} $</text>
<text x="${x + barWidth / 2}" y="${padT + plotH + 20}" fill="var(--text)" font-size="11" text-anchor="middle">${escapeHtml(m.module)}</text>
`;
});
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 = `<text x="${w / 2}" y="${h / 2}" fill="var(--muted)" font-size="13" text-anchor="middle">Keine Tages-Daten verfügbar.</text>`;
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 += `<line x1="${padL}" y1="${zeroY}" x2="${padL + plotW}" y2="${zeroY}" stroke="#6c7086" stroke-width="1.5" />`;
// 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 += `
<rect x="${x}" y="${barY}" width="${barWidth}" height="${barH}" rx="2" fill="${color}" opacity="0.85">
<title>${d.day}: ${d.pnl >= 0 ? '+' : ''}${Number(d.pnl).toFixed(2)} $</title>
</rect>
`;
// 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 += `<text x="${x + barWidth / 2}" y="${padT + plotH + 18}" fill="var(--muted)" font-size="10" font-family="var(--font-mono)" text-anchor="middle">${dayLabel}</text>`;
}
});
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 = '<tr><td colspan="12" style="text-align: center; color: var(--muted); padding: 20px;">Keine Trades gefunden, die den Kriterien entsprechen.</td></tr>';
return;
}
tbody.innerHTML = tradesData.items.map(t => `
<tr>
<td style="font-size: 11.5px; color: var(--muted); font-family: var(--font-mono);">${escapeHtml(t.closedAt)}</td>
<td><span style="font-weight: 600; font-size: 11.5px;">${escapeHtml(t.module)}</span></td>
<td>${escapeHtml(t.account)}</td>
<td>${t.isDemo ? '<span style="color: var(--warning); font-size: 11px;">Demo</span>' : '<span style="color: var(--positive); font-size: 11px;">Live</span>'}</td>
<td style="max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${escapeHtml(t.market)}">${escapeHtml(t.market)}</td>
<td><span style="padding: 2px 6px; background: var(--surface-alt); border-radius: 4px; font-size: 11px;">${escapeHtml(t.outcome)}</span></td>
<td><b>${escapeHtml(t.side)}</b></td>
<td style="font-family: var(--font-mono); font-size: 12px;">${Number(t.entryPrice || 0).toFixed(3)}</td>
<td style="font-family: var(--font-mono); font-size: 12px;">${Number(t.exitPrice || 0).toFixed(3)}</td>
<td style="font-family: var(--font-mono); font-size: 12px;">${Number(t.size || 0).toFixed(2)}</td>
<td style="font-family: var(--font-mono); font-weight: 600; color: ${t.realizedPnl >= 0 ? 'var(--positive)' : 'var(--negative)'};">
${t.realizedPnl >= 0 ? '+' : ''}${Number(t.realizedPnl).toFixed(2)} $
</td>
<td style="font-family: var(--font-mono); font-size: 11px; color: ${t.pnlPercent >= 0 ? 'var(--positive)' : 'var(--negative)'};">
${t.pnlPercent >= 0 ? '+' : ''}${Number(t.pnlPercent).toFixed(1)}%
</td>
</tr>
`).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 = '<tr><td colspan="12" style="text-align: center; color: var(--muted); padding: 20px;">Keine Master-Trader vorhanden. Klicke auf "+ Neuer Master-Trader" um einen anzulegen.</td></tr>';
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 `
<tr>
<td style="font-weight: 600;">#${t.id}</td>
<td><strong>${escapeHtml(t.displayName)}</strong></td>
<td>
<a href="https://polymarket.com/profile/${escapeHtml(t.walletAddress)}" target="_blank" rel="noopener noreferrer" style="font-family: var(--font-mono); font-size: 11.5px; color: var(--accent); text-decoration: none;">
${escapeHtml(shortWallet)} ↗
</a>
</td>
<td><span class="badge badge-outline" style="font-size: 11px;">${escapeHtml(t.category)}</span></td>
<td>
<span class="badge" style="${t.isActive ? 'background: rgba(34,197,94,0.15); color: var(--positive); border: 1px solid var(--positive);' : 'background: rgba(150,150,150,0.15); color: var(--muted); border: 1px solid var(--border);'}">
${t.isActive ? 'Aktiv' : 'Inaktiv'}
</span>
</td>
<td>${t.autoPauseEnabled ? '✓ An' : '<span style="color: var(--muted);">Aus</span>'}</td>
<td>${t.makerEntry ? '<span style="color: var(--accent);">Maker</span>' : '<span style="color: var(--muted);">Taker</span>'}</td>
<td style="text-align: right; font-family: var(--font-mono);">${winrate}%</td>
<td style="text-align: right; font-family: var(--font-mono); font-weight: 600; color: ${pnlColor};">
${pnl >= 0 ? '+' : ''}${pnl.toFixed(2)} $
</td>
<td style="text-align: right; font-family: var(--font-mono);">${pf}</td>
<td style="font-size: 12px; color: var(--muted);">${escapeHtml(accCount)}</td>
<td style="text-align: center;">
<button class="table-action-btn" onclick="openTraderModal(${t.id})">Bearbeiten</button>
<button class="table-action-btn delete" onclick="deleteTrader(${t.id}, ${jsArg(t.displayName)})">Löschen</button>
</td>
</tr>
`;
}).join('');
} catch (e) {
tbody.innerHTML = `<tr><td colspan="12" style="text-align: center; color: var(--negative); padding: 16px;">Fehler beim Laden der Master-Trader: ${escapeHtml(e.message)}</td></tr>`;
}
}
// 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 = '<div style="color: var(--muted); font-size: 12px;">Lade Konten …</div>';
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 => `
<label class="checkbox-label" style="font-size: 12px; margin-right: 12px;">
<input type="checkbox" class="chk-trader-acc" value="${a.accountId}" ${assignedIds.has(a.accountId) ? 'checked' : ''}>
#${a.accountId} ${escapeHtml(a.name || '')}${a.isDemo ? ' (Demo)' : ''}
</label>
`).join('');
}
} catch {
accList.innerHTML = '<div style="color: var(--negative); font-size: 12px;">Konten konnten nicht geladen werden.</div>';
}
}
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 = `<strong>${data.count}</strong> offene Position(en) · Gesamteinsatz <strong>${Number(data.totalAmount || 0).toFixed(2)} $</strong> · Buchgewinn <strong style="color: ${unrealColor};">${data.totalUnrealized >= 0 ? '+' : ''}${Number(data.totalUnrealized || 0).toFixed(2)} $</strong>`;
}
if (!data.trades || data.trades.length === 0) {
tbody.innerHTML = '<tr><td colspan="12" style="text-align: center; color: var(--muted); padding: 20px;">Aktuell keine offenen CopyTrading-Positionen vorhanden.</td></tr>';
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 `
<tr>
<td>${escapeHtml(p.account)}</td>
<td><strong>${escapeHtml(p.trader)}</strong></td>
<td style="max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${escapeHtml(p.market)}">
${escapeHtml(p.market)}
</td>
<td><span style="padding: 2px 6px; background: var(--surface-alt); border-radius: 4px; font-size: 11px;">${escapeHtml(p.outcome)}</span></td>
<td><b>${escapeHtml(p.side)}</b></td>
<td style="font-family: var(--font-mono); font-size: 12px; text-align: right;">${Number(p.entryPrice || 0).toFixed(3)}</td>
<td style="font-family: var(--font-mono); font-size: 12px; text-align: right;">${Number(p.currentPrice || 0).toFixed(3)}</td>
<td style="font-family: var(--font-mono); font-size: 12px; text-align: right;">${Number(p.size || 0).toFixed(2)}</td>
<td style="font-family: var(--font-mono); font-size: 12px; text-align: right;">${Number(p.amountUsd || 0).toFixed(2)} $</td>
<td style="font-family: var(--font-mono); font-size: 12px; font-weight: 600; text-align: right; color: ${unrealColor};">
${unreal >= 0 ? '+' : ''}${unreal.toFixed(2)} $ (${unrealPct >= 0 ? '+' : ''}${unrealPct.toFixed(1)}%)
</td>
<td><span class="badge ${p.status === 'Exit läuft' ? 'badge-warning' : 'badge-outline'}">${escapeHtml(p.status)}</span></td>
<td style="font-size: 11.5px; color: var(--muted); font-family: var(--font-mono);">${escapeHtml(p.openedAt)}</td>
</tr>
`;
}).join('');
} catch (e) {
tbody.innerHTML = `<tr><td colspan="12" style="text-align: center; color: var(--negative); padding: 16px;">Fehler: ${escapeHtml(e.message)}</td></tr>`;
}
}
// 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 <strong style="color: ${pnlColor};">${pnl >= 0 ? '+' : ''}${pnl.toFixed(2)} $</strong> · Winrate <strong>${Number(data.summary.winRate || 0).toFixed(1)}%</strong>`;
}
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 = '<tr><td colspan="13" style="text-align: center; color: var(--muted); padding: 20px;">Keine geschlossenen CopyTrading-Trades gefunden.</td></tr>';
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 `
<tr>
<td style="font-weight: 600;">#${t.tradeId}</td>
<td>${escapeHtml(t.account)}</td>
<td><strong>${escapeHtml(t.trader)}</strong></td>
<td style="max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${escapeHtml(t.market)}">
${escapeHtml(t.market)}
</td>
<td><span style="padding: 2px 6px; background: var(--surface-alt); border-radius: 4px; font-size: 11px;">${escapeHtml(t.outcome)}</span></td>
<td><b>${escapeHtml(t.side)}</b></td>
<td style="font-family: var(--font-mono); font-size: 12px; text-align: right;">${Number(t.entryPrice || 0).toFixed(3)}</td>
<td style="font-family: var(--font-mono); font-size: 12px; text-align: right;">${Number(t.exitPrice || 0).toFixed(3)}</td>
<td style="font-family: var(--font-mono); font-size: 12px; text-align: right;">${Number(t.size || 0).toFixed(2)}</td>
<td style="font-family: var(--font-mono); font-size: 12px; font-weight: 600; text-align: right; color: ${pnlColor};">
${pnl >= 0 ? '+' : ''}${pnl.toFixed(2)} $
</td>
<td style="font-family: var(--font-mono); font-size: 11px; text-align: right; color: ${pnlColor};">
${pnlPct >= 0 ? '+' : ''}${pnlPct.toFixed(1)}%
</td>
<td style="font-size: 11.5px; color: var(--muted); font-family: var(--font-mono);">${escapeHtml(t.closedAt)}</td>
<td style="font-size: 11px; color: var(--muted);">${escapeHtml(t.exitReason || '—')}</td>
</tr>
`;
}).join('');
} catch (e) {
tbody.innerHTML = `<tr><td colspan="13" style="text-align: center; color: var(--negative); padding: 16px;">Fehler: ${escapeHtml(e.message)}</td></tr>`;
}
}
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 => `
<option value="${a.accountId}">#${a.accountId} ${escapeHtml(a.name || '')}${a.isDemo ? ' (Demo)' : ''}</option>
`).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 => `
<option value="${a.accountId}">#${a.accountId} ${escapeHtml(a.name || '')}${a.isDemo ? ' (Demo)' : ''}</option>
`).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 = '<tr><td colspan="11" style="text-align: center; color: var(--muted); padding: 20px;">Keine aktuellen Marktkandidaten im Scanner gefunden.</td></tr>';
return;
}
tbody.innerHTML = data.candidates.map(c => `
<tr>
<td style="font-size: 11.5px; color: var(--muted); font-family: var(--font-mono);">${escapeHtml(c.scannedAt)}</td>
<td style="max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${escapeHtml(c.marketQuestion)}">
${escapeHtml(c.marketQuestion)}
</td>
<td><span style="padding: 2px 6px; background: var(--surface-alt); border-radius: 4px; font-size: 11px;">${escapeHtml(c.outcome)}</span></td>
<td><span class="badge badge-outline" style="font-size: 11px;">${escapeHtml(c.category)}</span></td>
<td style="font-family: var(--font-mono); font-size: 12px; text-align: right;">${Number(c.ask || 0).toFixed(3)}</td>
<td style="font-family: var(--font-mono); font-size: 12px; text-align: right;">${c.feeBps}</td>
<td style="font-family: var(--font-mono); font-size: 12px; text-align: right; color: ${c.netEdgePct > 0 ? 'var(--positive)' : 'var(--muted)'};">
${Number(c.netEdgePct || 0).toFixed(2)}%
</td>
<td style="font-family: var(--font-mono); font-size: 12px; text-align: right;">${Number(c.score || 0).toFixed(1)}</td>
<td style="font-size: 11.5px; color: var(--muted); font-family: var(--font-mono);">${escapeHtml(c.endDate)}</td>
<td>
<span class="badge" style="${c.accepted ? 'background: rgba(34,197,94,0.15); color: var(--positive); border: 1px solid var(--positive);' : 'background: rgba(150,150,150,0.15); color: var(--muted); border: 1px solid var(--border);'}">
${c.accepted ? 'Akzeptiert' : 'Abgelehnt'}
</span>
</td>
<td style="font-size: 11px; color: var(--muted);">${escapeHtml(c.rejectReason || '—')}</td>
</tr>
`).join('');
} catch (e) {
tbody.innerHTML = `<tr><td colspan="11" style="text-align: center; color: var(--negative); padding: 16px;">Fehler: ${escapeHtml(e.message)}</td></tr>`;
}
}
// 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 = `<strong>${data.count}</strong> offene Position(en) · Gesamteinsatz <strong>${Number(data.totalAmount || 0).toFixed(2)} USDC</strong>`;
}
if (!data.positions || data.positions.length === 0) {
tbody.innerHTML = '<tr><td colspan="11" style="text-align: center; color: var(--muted); padding: 20px;">Keine offenen ResolutionFarming-Positionen vorhanden.</td></tr>';
return;
}
tbody.innerHTML = data.positions.map(p => `
<tr>
<td>${escapeHtml(p.account)}</td>
<td style="max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${escapeHtml(p.marketQuestion)}">
${escapeHtml(p.marketQuestion)}
</td>
<td><span style="padding: 2px 6px; background: var(--surface-alt); border-radius: 4px; font-size: 11px;">${escapeHtml(p.outcome)}</span></td>
<td><span class="badge badge-outline" style="font-size: 11px;">${escapeHtml(p.category)}</span></td>
<td style="font-size: 11px; color: var(--muted);">${escapeHtml(p.clusterKey || '—')}</td>
<td style="font-family: var(--font-mono); font-size: 12px; text-align: right;">${Number(p.entryPrice || 0).toFixed(3)}</td>
<td style="font-family: var(--font-mono); font-size: 12px; text-align: right;">${Number(p.size || 0).toFixed(2)}</td>
<td style="font-family: var(--font-mono); font-size: 12px; text-align: right; font-weight: 600;">${Number(p.amountUsd || 0).toFixed(2)} $</td>
<td style="font-size: 11.5px; color: var(--muted); font-family: var(--font-mono);">${escapeHtml(p.openedAt)}</td>
<td style="font-size: 11.5px; color: var(--muted); font-family: var(--font-mono);">${escapeHtml(p.endDate)}</td>
<td><span class="badge badge-outline">${escapeHtml(p.status)}</span></td>
</tr>
`).join('');
} catch (e) {
tbody.innerHTML = `<tr><td colspan="11" style="text-align: center; color: var(--negative); padding: 16px;">Fehler: ${escapeHtml(e.message)}</td></tr>`;
}
}
// 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 <strong style="color: ${pnlColor};">${pnl >= 0 ? '+' : ''}${pnl.toFixed(2)} $</strong> · Winrate <strong>${Number(data.summary.winRate || 0).toFixed(1)}%</strong>`;
}
if (!data.trades || data.trades.length === 0) {
tbody.innerHTML = '<tr><td colspan="14" style="text-align: center; color: var(--muted); padding: 20px;">Keine abgeschlossenen ResolutionFarming-Trades gefunden.</td></tr>';
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 `
<tr>
<td style="font-weight: 600;">#${t.id}</td>
<td>${escapeHtml(t.account)}</td>
<td style="max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${escapeHtml(t.marketQuestion)}">
${escapeHtml(t.marketQuestion)}
</td>
<td><span style="padding: 2px 6px; background: var(--surface-alt); border-radius: 4px; font-size: 11px;">${escapeHtml(t.outcome)}</span></td>
<td><span class="badge badge-outline" style="font-size: 11px;">${escapeHtml(t.category)}</span></td>
<td style="font-family: var(--font-mono); font-size: 12px; text-align: right;">${Number(t.entryPrice || 0).toFixed(3)}</td>
<td style="font-family: var(--font-mono); font-size: 12px; text-align: right;">${Number(t.exitPrice || 0).toFixed(3)}</td>
<td style="font-family: var(--font-mono); font-size: 12px; text-align: right;">${Number(t.size || 0).toFixed(2)}</td>
<td style="font-family: var(--font-mono); font-size: 12px; font-weight: 600; text-align: right; color: ${pnlColor};">
${pnl >= 0 ? '+' : ''}${pnl.toFixed(2)} $
</td>
<td style="font-family: var(--font-mono); font-size: 11px; text-align: right; color: ${pnlColor};">
${pnlPct >= 0 ? '+' : ''}${pnlPct.toFixed(1)}%
</td>
<td style="font-family: var(--font-mono); font-size: 11px; text-align: right;">${Number(t.totalFees || 0).toFixed(3)} $</td>
<td style="font-size: 11.5px; color: var(--muted); font-family: var(--font-mono);">${escapeHtml(t.closedAt)}</td>
<td><span class="badge badge-outline">${escapeHtml(t.redeemStatus || '—')}</span></td>
<td style="font-size: 11px; color: var(--muted);">${escapeHtml(t.exitReason || '—')}</td>
</tr>
`;
}).join('');
} catch (e) {
tbody.innerHTML = `<tr><td colspan="14" style="text-align: center; color: var(--negative); padding: 16px;">Fehler: ${escapeHtml(e.message)}</td></tr>`;
}
}
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 => `<option value="${escapeHtml(p.name)}">${escapeHtml(p.name)}</option>`).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 = `
<div class="chat-bubble-header">
<span>Du</span>
<span>${nowTime}</span>
</div>
<div class="chat-bubble-body">${escapeHtml(question)}</div>
`;
messagesArea.appendChild(userBubble);
// 2. Render loading assistant message
const assistantBubble = document.createElement('div');
assistantBubble.className = 'chat-bubble assistant';
assistantBubble.innerHTML = `
<div class="chat-bubble-header">
<span>🤖 Supervisor (${escapeHtml(profile)})</span>
<span>Denkt nach …</span>
</div>
<div class="chat-bubble-body" style="color: var(--muted); font-style: italic;">
Analysiere Daten und führe Tools aus …
</div>
`;
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 `<li><strong>${escapeHtml(ti.tool)}</strong>(${escapeHtml(argsStr)})</li>`;
}).join('');
toolCallsHtml = `
<details class="chat-tool-details">
<summary>🛠️ ${data.toolCallCount} Tool-Aufrufe (${data.totalTokens || 0} Tokens)</summary>
<ul class="chat-tool-list">${listItems}</ul>
</details>
`;
}
const tokenInfo = data.totalTokens ? `${data.totalTokens} Tokens (${data.model || 'Standard'})` : (data.model || 'Standard');
assistantBubble.innerHTML = `
<div class="chat-bubble-header">
<span>🤖 Supervisor (${escapeHtml(data.profile || profile)})</span>
<span>${tokenInfo}</span>
</div>
<div class="chat-bubble-body">${escapeHtml(data.answer || 'Keine Antwort erhalten.')}</div>
${toolCallsHtml}
`;
} catch (err) {
assistantBubble.innerHTML = `
<div class="chat-bubble-header">
<span>🤖 Supervisor</span>
<span style="color: var(--negative);">Fehler</span>
</div>
<div class="chat-bubble-body" style="color: var(--negative);">
Fehler bei der Kommunikation mit dem Supervisor: ${escapeHtml(err.message)}
</div>
`;
} 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 = '<tr><td colspan="8" style="text-align: center; color: var(--muted);">Bislang keine Berichte vorhanden.</td></tr>';
return;
}
tbody.innerHTML = list.map(r => `
<tr>
<td style="font-family: var(--font-mono); font-size: 11px;">#${r.id}</td>
<td style="font-size: 12px;">${escapeHtml(r.createdAt)}</td>
<td><span class="badge" style="background: var(--surface); border: 1px solid var(--border); font-size: 11px;">${escapeHtml(r.profile)}</span></td>
<td style="font-size: 12px; color: var(--muted);">${escapeHtml(r.model || '—')}</td>
<td style="max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${escapeHtml(r.question)}">${escapeHtml(r.question)}</td>
<td style="text-align: center; font-family: var(--font-mono);">${r.toolCallCount}</td>
<td style="text-align: right; font-family: var(--font-mono); font-size: 12px;">${r.totalTokens}</td>
<td style="text-align: center;">
<button class="table-action-btn" onclick="viewSupReport(${r.id})">Volltext ↗</button>
</td>
</tr>
`).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 = `
<span><strong>Datum:</strong> ${escapeHtml(report.createdAt)}</span>
<span><strong>Modell:</strong> ${escapeHtml(report.model || '—')}</span>
<span><strong>Tokens:</strong> ${report.totalTokens} (${report.promptTokens} in / ${report.completionTokens} out)</span>
<span><strong>Tool-Aufrufe:</strong> ${report.toolCallCount}</span>
`;
}
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 = '<tr><td colspan="9" style="text-align: center; color: var(--muted);">Keine Signal-Dossiers aufgezeichnet.</td></tr>';
return;
}
tbody.innerHTML = list.map(s => `
<tr>
<td style="font-family: var(--font-mono); font-size: 11px; max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${escapeHtml(s.signalId)}">${escapeHtml(s.signalId)}</td>
<td style="font-size: 12px;">${escapeHtml(s.firstSeen)}</td>
<td><span class="badge" style="background: var(--surface); border: 1px solid var(--border);">${escapeHtml(s.module || '—')}</span></td>
<td>${s.accountId != null ? `#${s.accountId}` : '—'}</td>
<td style="max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${escapeHtml(s.market)}">${escapeHtml(s.market)}</td>
<td><span class="badge" style="font-size: 11px; ${s.side === 'BUY' ? 'color: var(--positive); border: 1px solid var(--positive);' : 'color: var(--negative); border: 1px solid var(--negative);'}">${escapeHtml(s.side || '—')}</span></td>
<td><b>${escapeHtml(s.lastDecision || '—')}</b></td>
<td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); font-size: 12px;" title="${escapeHtml(s.lastReason || '')}">${escapeHtml(s.lastReason || '—')}</td>
<td style="text-align: center;">
<button class="table-action-btn" onclick="viewSupDossier(${jsArg(s.signalId)})">Dossier ↗</button>
</td>
</tr>
`).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 = `
<span><strong>Modul:</strong> ${escapeHtml(data.module || '—')}</span>
<span><strong>Konto:</strong> #${data.accountId}</span>
<span><strong>Side:</strong> ${escapeHtml(data.side || '—')}</span>
<span><strong>Markt:</strong> ${escapeHtml(data.market || '—')}</span>
`;
}
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 = '<tr><td colspan="9" style="text-align: center; color: var(--muted);">Keine Counterfactual-Aufzeichnungen gefunden.</td></tr>';
return;
}
tbody.innerHTML = list.map(c => {
const pnlColor = (c.hypotheticalPnlPerShare || 0) >= 0 ? 'var(--positive)' : 'var(--negative)';
const pnlSign = (c.hypotheticalPnlPerShare || 0) >= 0 ? '+' : '';
return `
<tr>
<td style="font-family: var(--font-mono); font-size: 11px;">#${c.id}</td>
<td style="font-size: 12px;">${escapeHtml(c.checkedAt)}</td>
<td><span class="badge" style="background: var(--surface); border: 1px solid var(--border); font-size: 11px;">${escapeHtml(c.moduleName || '—')}</span></td>
<td>${c.accountId != null ? `#${c.accountId}` : '—'}</td>
<td style="max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${escapeHtml(c.market)}">${escapeHtml(c.market)}</td>
<td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); font-size: 12px;" title="${escapeHtml(c.reason || '')}">${escapeHtml(c.reason || '—')}</td>
<td style="text-align: right; font-family: var(--font-mono); font-size: 12px;">$${Number(c.signalPrice || 0).toFixed(3)}</td>
<td style="text-align: center;">
<span class="badge" style="${c.isWinner ? 'background: rgba(166, 227, 161, 0.15); color: var(--positive); border: 1px solid var(--positive);' : 'background: rgba(243, 139, 168, 0.15); color: var(--negative); border: 1px solid var(--negative);'}">
${c.isWinner ? 'Gewonnen' : 'Verloren'}
</span>
</td>
<td style="text-align: right; font-family: var(--font-mono); font-size: 12px; color: ${pnlColor}; font-weight: 600;">
${pnlSign}$${Number(c.hypotheticalPnlPerShare || 0).toFixed(4)}
</td>
</tr>
`;
}).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 = '<option value="">Alle Konten</option>' +
accs.map(a => `<option value="${a.accountId}">${escapeHtml(a.name || `#${a.accountId}`)}</option>`).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 = '<tr><td colspan="9" style="text-align: center; color: var(--muted);">Keine Buchungsdaten in dieser Periode.</td></tr>';
} 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 `
<tr>
<td><b>${escapeHtml(m.monthLabel || m.from)}</b></td>
<td style="text-align: right; font-family: var(--font-mono);">${Number(m.openingBalance || 0).toFixed(2)} ${cur}</td>
<td style="text-align: right; font-family: var(--font-mono);">${Number(m.closingBalance || 0).toFixed(2)} ${cur}</td>
<td style="text-align: right; font-family: var(--font-mono); color: ${flow >= 0 ? 'var(--text)' : 'var(--negative)'};">
${flow >= 0 ? '+' : ''}${flow.toFixed(2)} ${cur}
</td>
<td style="text-align: right; font-family: var(--font-mono);">${Number(m.tradeVolume || 0).toFixed(2)} ${cur}</td>
<td style="text-align: right; font-family: var(--font-mono); color: var(--muted);">${Number(m.fees || 0).toFixed(2)} ${cur}</td>
<td style="text-align: right; font-family: var(--font-mono); font-weight: 600; color: ${netColor};">
${netSign}${mNet.toFixed(2)} ${cur}
</td>
<td style="text-align: center; font-family: var(--font-mono);">${m.tradeCount}</td>
<td style="text-align: center; font-family: var(--font-mono); font-size: 12px; color: var(--muted);">${m.entryCount}</td>
</tr>
`;
}).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 = '<tr><td colspan="12" style="text-align: center; color: var(--muted);">Keine Buchungen gefunden.</td></tr>';
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 `
<tr>
<td style="font-size: 12px; white-space: nowrap;">${escapeHtml(e.timestamp)}</td>
<td>#${e.accountId}</td>
<td><span class="badge" style="background: var(--surface); border: 1px solid var(--border); font-size: 11px;">${escapeHtml(e.eventType)}</span></td>
<td><span class="badge" style="font-size: 11px; ${e.side === 'BUY' ? 'color: var(--positive); border: 1px solid var(--positive);' : (e.side === 'SELL' ? 'color: var(--negative); border: 1px solid var(--negative);' : '')}">${escapeHtml(e.side || '—')}</span></td>
<td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${escapeHtml(e.marketSlug || '')}">${escapeHtml(e.marketSlug || '—')}</td>
<td><span class="badge badge-accent" style="font-size: 11px;">${escapeHtml(e.outcome || '—')}</span></td>
<td style="text-align: right; font-family: var(--font-mono); font-size: 12px;">${Number(e.size || 0).toFixed(1)}</td>
<td style="text-align: right; font-family: var(--font-mono); font-size: 12px;">$${Number(e.priceUsdc || 0).toFixed(3)}</td>
<td style="text-align: right; font-family: var(--font-mono); font-size: 12px;">$${Number(e.grossUsdc || 0).toFixed(2)}</td>
<td style="text-align: right; font-family: var(--font-mono); font-size: 12px; color: var(--muted);">$${Number(e.feeUsdc || 0).toFixed(2)}</td>
<td style="text-align: right; font-family: var(--font-mono); font-size: 12px; font-weight: 600; color: ${netColor};">
${netSign}$${netVal.toFixed(2)}
</td>
<td style="font-family: var(--font-mono); font-size: 11px;">
${e.txHash ? `<a href="https://polygonscan.com/tx/${e.txHash}" target="_blank" rel="noopener noreferrer" style="color: var(--accent); text-decoration: none;">${txShort} ↗</a>` : '—'}
</td>
</tr>
`;
}).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 = '<tr><td colspan="10" style="text-align: center; color: var(--muted);">Bislang keine Ingestion-Läufe protokolliert.</td></tr>';
return;
}
tbody.innerHTML = runs.map(r => `
<tr>
<td style="font-family: var(--font-mono); font-size: 11px;">#${r.id}</td>
<td>#${r.accountId}</td>
<td style="font-size: 12px;">${escapeHtml(r.startedAt)}</td>
<td style="font-size: 12px;">${escapeHtml(r.completedAt)}</td>
<td><span class="badge" style="background: var(--surface); border: 1px solid var(--border);">${r.backfill ? 'Backfill' : 'Live'}</span></td>
<td style="text-align: right; font-family: var(--font-mono); color: var(--positive);">${r.newEntries}</td>
<td style="text-align: right; font-family: var(--font-mono); color: var(--muted);">${r.duplicateEntries}</td>
<td style="text-align: right; font-family: var(--font-mono);">$${Number(r.balanceDeltaUsdc || 0).toFixed(2)}</td>
<td style="text-align: center;">
<span class="badge" style="${r.success ? 'background: rgba(166, 227, 161, 0.15); color: var(--positive); border: 1px solid var(--positive);' : 'background: rgba(243, 139, 168, 0.15); color: var(--negative); border: 1px solid var(--negative);'}">
${r.success ? 'Erfolgreich' : 'Fehler'}
</span>
</td>
<td style="color: var(--negative); font-size: 12px; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${escapeHtml(r.errorMessage || '')}">
${escapeHtml(r.errorMessage || '—')}
</td>
</tr>
`).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);
}
}